From 15a473ce3d08abfd62cd4a7ba5d780180884d460 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Sun, 9 Aug 2026 21:51:23 +0900 Subject: [PATCH] Phase 12 --- .gitignore | 2 ++ README.md | 15 ++++++++++ config/default.yaml | 30 ++++++++++++++++++- config/experiment.yaml | 28 ++++++++++++++++++ data/experiment_snapshots/.gitkeep | 1 + data/final_results/.gitkeep | 1 + docs/final_results_template.md | 13 +++++++++ docs/main_experiment_protocol.md | 9 ++++++ docs/phase12_specification.md | 31 ++++++++++++++++++++ main.py | 18 +++++++++++- src/__init__.py | 2 +- src/config_loader.py | 24 ++++++++++++++++ src/experiment_snapshot.py | 19 ++++++++++++ src/final_analysis_workflow.py | 31 ++++++++++++++++++++ src/final_report_builder.py | 22 ++++++++++++++ src/gui/final_results_panel.py | 12 ++++++++ src/gui/main_window.py | 11 +++---- src/gui/operations_controller.py | 9 +++++- src/main_experiment_manager.py | 25 ++++++++++++++++ src/models.py | 35 +++++++++++++++++++++++ src/phase12_cli.py | 12 ++++++++ src/presentation_assets_builder.py | 16 +++++++++++ tests/test_experiment_snapshot.py | 5 ++++ tests/test_final_report_builder.py | 4 +++ tests/test_main_experiment_manager.py | 6 ++++ tests/test_phase12_cli.py | 3 ++ tests/test_presentation_assets_builder.py | 5 ++++ 27 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 data/experiment_snapshots/.gitkeep create mode 100644 data/final_results/.gitkeep create mode 100644 docs/final_results_template.md create mode 100644 docs/main_experiment_protocol.md create mode 100644 docs/phase12_specification.md create mode 100644 src/experiment_snapshot.py create mode 100644 src/final_analysis_workflow.py create mode 100644 src/final_report_builder.py create mode 100644 src/gui/final_results_panel.py create mode 100644 src/main_experiment_manager.py create mode 100644 src/phase12_cli.py create mode 100644 src/presentation_assets_builder.py create mode 100644 tests/test_experiment_snapshot.py create mode 100644 tests/test_final_report_builder.py create mode 100644 tests/test_main_experiment_manager.py create mode 100644 tests/test_phase12_cli.py create mode 100644 tests/test_presentation_assets_builder.py diff --git a/.gitignore b/.gitignore index 9c72923..b1bbfb7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ data/analysis/* data/gui/* data/experiment_notes/* data/calibration_history/* +data/final_results/* +data/experiment_snapshots/* !data/analysis/plots/ assets/audio/* !**/.gitkeep diff --git a/README.md b/README.md index 87f4052..fb8dd47 100644 --- a/README.md +++ b/README.md @@ -440,3 +440,18 @@ python main.py calibration-list `valid_for_voice_analysis=false`は「振り向きなし」ではなく評価不能です。カメラ位置は主要な実験条件であり、PromptとControlで音声以外の位置、照明、通行方向、トリガー、閾値を可能な限り揃えてください。 診断がreadyでも研究デザインの妥当性や因果関係を保証しません。少数サンプルの統計判断、高い反応率からの因果推論、ID switchの無視を避けてください。詳細は`docs/phase11_specification.md`を参照してください。 +# Phase 12 本実験・最終結果整理 + +```powershell +python main.py main-progress --logs data/logs/unified --analysis data/analysis --output data/final_results +python main.py snapshot --config config/experiment.yaml --output data/experiment_snapshots +python main.py final-analysis --logs data/logs/unified --analysis data/analysis --output data/final_results +python main.py final-report --analysis data/analysis --output data/final_results +python main.py presentation-assets --analysis data/analysis --output data/final_results +``` + +本実験前にPilot Diagnostics、カメラ位置記録、Prompt/Control運用ルール、音声、response window、turn閾値を確認し、設定snapshotを保存してください。目標人数は初期運用値であり正式なサンプルサイズ設計ではありません。 + +最終レポートでも`valid_for_voice_analysis=false`を反応なしへ混ぜません。カメラ位置は主要な実験条件です。p値や効果量が得られても、観察研究上の制約から音声との因果関係を直接断定しません。 + +GUIにはFinal Resultsタブを追加しました。Phase 12操作はカメラ、YOLO、MediaPipe、音声を初期化しません。詳細は`docs/phase12_specification.md`を参照してください。 diff --git a/config/default.yaml b/config/default.yaml index 899673e..3b1f8b2 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -1,6 +1,6 @@ application: name: pedestrian-signage-system - version: "0.11.0" + version: "0.12.0" window_name: "Pedestrian Detection" exit_key: "q" screenshot_key: "s" @@ -240,6 +240,34 @@ pilot_diagnostics: calibration_history: enabled: true path: data/calibration_history/calibration_history.csv +main_experiment: + enabled: true + target_prompt_valid_records: 30 + target_control_valid_records: 30 + minimum_prompt_valid_records: 20 + minimum_control_valid_records: 20 + planned_prompt_sessions: 3 + planned_control_sessions: 3 + condition_order_rule: "PromptとControlを同じ場所・時間帯で交互に実施する。" + camera_position_policy: "位置・高さ・角度を固定し、各セッションでcamera_position_noteを記録する。" + final_output_directory: data/final_results +experiment_snapshot: + enabled: true + output_directory: data/experiment_snapshots + include_git_commit: true + include_merged_config: true +final_report: + enabled: true + output_markdown: data/final_results/final_experiment_report.md + output_summary_csv: data/final_results/final_experiment_report_summary.csv + include_limitations: true + include_quality_notes: true + avoid_causal_claims: true +presentation_assets: + enabled: true + output_markdown: data/final_results/presentation_assets.md + output_tables_csv: data/final_results/presentation_tables.csv + include_slide_outline: true display: enabled: true show_bounding_boxes: true diff --git a/config/experiment.yaml b/config/experiment.yaml index ee14061..e5b42cb 100644 --- a/config/experiment.yaml +++ b/config/experiment.yaml @@ -84,6 +84,34 @@ pilot_diagnostics: enabled: true target_valid_records_per_condition: 20 require_camera_position_note: true +main_experiment: + enabled: true + target_prompt_valid_records: 30 + target_control_valid_records: 30 + minimum_prompt_valid_records: 20 + minimum_control_valid_records: 20 + planned_prompt_sessions: 3 + planned_control_sessions: 3 + condition_order_rule: "PromptとControlを同じ場所・時間帯で交互に実施する。" + camera_position_policy: "位置・高さ・角度を固定し、各セッションでcamera_position_noteを記録する。" + final_output_directory: data/final_results +experiment_snapshot: + enabled: true + output_directory: data/experiment_snapshots + include_git_commit: true + include_merged_config: true +final_report: + enabled: true + output_markdown: data/final_results/final_experiment_report.md + output_summary_csv: data/final_results/final_experiment_report_summary.csv + include_limitations: true + include_quality_notes: true + avoid_causal_claims: true +presentation_assets: + enabled: true + output_markdown: data/final_results/presentation_assets.md + output_tables_csv: data/final_results/presentation_tables.csv + include_slide_outline: true evaluation_region: enabled: true coordinate_mode: normalized diff --git a/data/experiment_snapshots/.gitkeep b/data/experiment_snapshots/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/experiment_snapshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/final_results/.gitkeep b/data/final_results/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/final_results/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/final_results_template.md b/docs/final_results_template.md new file mode 100644 index 0000000..2b5c340 --- /dev/null +++ b/docs/final_results_template.md @@ -0,0 +1,13 @@ +# 最終結果テンプレート + +## 実験概要 +## 実装システム +## 実験条件 +## 取得データと有効解析人数 +## 反応率・反応時間・Yaw変化 +## 統計検定と効果量 +## 除外理由・ログ品質 +## 制約 +## 今後の課題 + +統計的有意差や高い反応率だけから因果関係を断定しない。 diff --git a/docs/main_experiment_protocol.md b/docs/main_experiment_protocol.md new file mode 100644 index 0000000..df55865 --- /dev/null +++ b/docs/main_experiment_protocol.md @@ -0,0 +1,9 @@ +# 本実験プロトコル + +1. Pilot Diagnosticsの重大Issueを解消する。 +2. Prompt/Controlを同じ場所・時間帯で交互に実施する。 +3. カメラ位置・高さ・角度、音声内容・音量、trigger、response window、turn閾値を固定する。 +4. 各セッションでconditionとcamera_position_noteを記録する。 +5. 実験中に条件を変更しない。変更時はCalibration Historyへ記録する。 +6. 実験後にanalyze、quality-check、pilot-diagnostics、main-progressを確認する。 +7. 評価不能者を反応なしに含めない。 diff --git a/docs/phase12_specification.md b/docs/phase12_specification.md new file mode 100644 index 0000000..ff2ca03 --- /dev/null +++ b/docs/phase12_specification.md @@ -0,0 +1,31 @@ +# Phase 12 本実験・最終結果整理仕様 + +Phase 12は、パイロット診断後の固定設定で本実験データを収集し、進捗、設定スナップショット、解析、品質検査、診断、最終レポート、発表用成果物を一貫して管理する。 + +## 進捗 + +Prompt/Controlの総数、有効解析人数、セッション数、目標達成率を集計する。両条件が最低有効人数以上で、camera_position_noteが記録されている場合に最終解析可能とする。目標人数30、最低人数20は初期運用値であり、正式なサンプルサイズ設計ではない。 + +## 設定スナップショット + +アプリバージョン、設定ファイル、可能ならgit commit、カメラ位置、音声内容・音量、trigger、response window、turn閾値、顔処理頻度、Yaw方向をYAML/Markdownで保存する。 + +## 最終ワークフロー + +Phase 8解析、Phase 10品質確認、Phase 11診断、最終レポート、発表用成果物を順番に実行する。一部失敗は`final_workflow_result.json`へwarning/errorとして残す。カメラやYOLOは起動しない。 + +## 結果の扱い + +有効解析者だけを反応率の分母に使い、評価不能を反応なしへ混ぜない。Prompt/Controlは音声以外のカメラ、照明、通行方向、trigger、閾値を揃える。p値、効果量、反応率差を記載しても因果関係を断定しない。 + +## 出力 + +`main_experiment_progress.csv/.md`、設定snapshot YAML/Markdown、`final_experiment_report.md`、`final_experiment_report_summary.csv`、`presentation_assets.md`、`presentation_tables.csv`、`final_workflow_result.json`を生成する。 + +## GUI/CLI + +Final Resultsタブと`main-progress`、`snapshot`、`final-analysis`、`final-report`、`presentation-assets`を提供する。すべてオフライン処理である。 + +## 制約とPhase 13 + +本機能は研究デザインの妥当性、個人識別、ID switch解消、因果推論を保証しない。Phase 13では論文章立て、図表キャプション、最終スライド、最終成果物チェックを支援する。 diff --git a/main.py b/main.py index a6cec37..5c6fe00 100644 --- a/main.py +++ b/main.py @@ -78,7 +78,7 @@ def build_analysis_parser() -> argparse.ArgumentParser: def build_gui_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="main.py gui", description="Phase 10 desktop experiment and quality GUI") + parser = argparse.ArgumentParser(prog="main.py gui", description="Phase 12 desktop experiment and final-results GUI") parser.add_argument("--config", type=Path, default=Path("config/experiment.yaml")) return parser @@ -95,6 +95,11 @@ def build_phase11_parser(command:str)->argparse.ArgumentParser: elif command=="calibration-add":p.add_argument("--path",type=Path);p.add_argument("--operator",default="unknown");p.add_argument("--category",required=True);p.add_argument("--before",required=True);p.add_argument("--after",required=True);p.add_argument("--reason",required=True);p.add_argument("--report-id");p.add_argument("--notes",default="") else:p.add_argument("--path",type=Path) return p +def build_phase12_parser(command:str)->argparse.ArgumentParser: + p=argparse.ArgumentParser(prog=f"main.py {command}",description="Phase 12 final experiment workflow");p.add_argument("--config",type=Path,default=Path("config/experiment.yaml"));p.add_argument("--output",type=Path,default=Path("data/final_results")) + if command in {"main-progress","final-analysis"}:p.add_argument("--logs",type=Path,default=Path("data/logs/unified"));p.add_argument("--analysis",type=Path,default=Path("data/analysis")) + elif command in {"final-report","presentation-assets"}:p.add_argument("--analysis",type=Path,default=Path("data/analysis")) + return p def cli_overrides(args: argparse.Namespace) -> dict[str, Any]: @@ -169,6 +174,17 @@ def cli_overrides(args: argparse.Namespace) -> dict[str, Any]: def main(argv: list[str] | None = None) -> int: actual = list(sys.argv[1:] if argv is None else argv) + if actual and actual[0] in {"main-progress","snapshot","final-analysis","final-report","presentation-assets"}: + command=actual[0];args=build_phase12_parser(command).parse_args(actual[1:]);from src.config_loader import load_config;from src.phase12_cli import assets,final_report,progress,snapshot,workflow + try: + c=load_config("config/default.yaml",args.config) + if command=="main-progress":outputs=progress(c,args.logs,args.analysis,args.output) + elif command=="snapshot":outputs=snapshot(c,args.config,args.output) + elif command=="final-report":outputs=final_report(c,args.analysis,args.output) + elif command=="presentation-assets":outputs=assets(c,args.analysis,args.output) + else:r=workflow(c,args.logs,args.analysis,args.output);outputs=[Path(x) for x in r.output_files]+[args.output/"final_workflow_result.json"] + [print(x) for x in outputs];return 0 + except Exception as exc:print(f"{command} error: {exc}",file=sys.stderr);return 1 if actual and actual[0] in {"pilot-diagnostics","calibration-add","calibration-list"}: from datetime import datetime command=actual[0];args=build_phase11_parser(command).parse_args(actual[1:]);from src.config_loader import load_config;from src.models import CalibrationChangeRecord;from src.phase11_cli import add_calibration,list_calibrations,run_diagnostics diff --git a/src/__init__.py b/src/__init__.py index fb69fe3..dd156e4 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Phase 1-5 pedestrian detection, tracking, pose, turn, and voice system.""" -__version__ = "0.11.0" +__version__ = "0.12.0" diff --git a/src/config_loader.py b/src/config_loader.py index 8eefc19..b66cbd7 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -374,6 +374,20 @@ class PilotDiagnosticsSettings: @dataclass(frozen=True,slots=True) class CalibrationHistorySettings: enabled:bool;path:str +@dataclass(frozen=True,slots=True) +class MainExperimentSettings: + enabled:bool;target_prompt_valid_records:int;target_control_valid_records:int;minimum_prompt_valid_records:int + minimum_control_valid_records:int;planned_prompt_sessions:int;planned_control_sessions:int + condition_order_rule:str;camera_position_policy:str;final_output_directory:str +@dataclass(frozen=True,slots=True) +class ExperimentSnapshotSettings: + enabled:bool;output_directory:str;include_git_commit:bool;include_merged_config:bool +@dataclass(frozen=True,slots=True) +class FinalReportSettings: + enabled:bool;output_markdown:str;output_summary_csv:str;include_limitations:bool;include_quality_notes:bool;avoid_causal_claims:bool +@dataclass(frozen=True,slots=True) +class PresentationAssetsSettings: + enabled:bool;output_markdown:str;output_tables_csv:str;include_slide_outline:bool @dataclass(frozen=True, slots=True) @@ -438,6 +452,10 @@ class AppConfig: presentation_summary: PresentationSummarySettings pilot_diagnostics:PilotDiagnosticsSettings calibration_history:CalibrationHistorySettings + main_experiment:MainExperimentSettings + experiment_snapshot:ExperimentSnapshotSettings + final_report:FinalReportSettings + presentation_assets:PresentationAssetsSettings display: DisplaySettings evaluation_region: EvaluationRegionSettings trigger_line: TriggerLineSettings @@ -676,6 +694,8 @@ def _validate(config: AppConfig) -> None: pd=config.pilot_diagnostics if (not isinstance(pd.enabled,bool) or min(pd.target_valid_records_per_condition,pd.min_valid_records_per_condition,pd.min_sessions_per_condition)<=0 or pd.min_valid_records_per_condition>pd.target_valid_records_per_condition or any(not 0<=v<=1 for v in (pd.min_valid_voice_analysis_rate,pd.max_not_evaluable_rate,pd.max_no_pose_estimated_rate)) or pd.max_condition_valid_count_ratio<1 or any(not isinstance(v,bool) for v in (pd.require_camera_position_note,pd.ready_requires_prompt_and_control))):raise ConfigurationError("pilot_diagnostics settings are invalid") if not isinstance(config.calibration_history.enabled,bool) or not isinstance(config.calibration_history.path,str):raise ConfigurationError("calibration_history settings are invalid") + me=config.main_experiment + if any(v<=0 for v in (me.target_prompt_valid_records,me.target_control_valid_records,me.minimum_prompt_valid_records,me.minimum_control_valid_records,me.planned_prompt_sessions,me.planned_control_sessions)) or me.minimum_prompt_valid_records>me.target_prompt_valid_records or me.minimum_control_valid_records>me.target_control_valid_records:raise ConfigurationError("main_experiment settings are invalid") def load_config( @@ -717,6 +737,10 @@ def load_config( presentation_summary=_make(PresentationSummarySettings,merged.get("presentation_summary"),"presentation_summary"), pilot_diagnostics=_make(PilotDiagnosticsSettings,merged.get("pilot_diagnostics"),"pilot_diagnostics"), calibration_history=_make(CalibrationHistorySettings,merged.get("calibration_history"),"calibration_history"), + main_experiment=_make(MainExperimentSettings,merged.get("main_experiment"),"main_experiment"), + experiment_snapshot=_make(ExperimentSnapshotSettings,merged.get("experiment_snapshot"),"experiment_snapshot"), + final_report=_make(FinalReportSettings,merged.get("final_report"),"final_report"), + presentation_assets=_make(PresentationAssetsSettings,merged.get("presentation_assets"),"presentation_assets"), display=_make(DisplaySettings, merged.get("display"), "display"), evaluation_region=_make(EvaluationRegionSettings, merged.get("evaluation_region"), "evaluation_region"), trigger_line=_make(TriggerLineSettings, merged.get("trigger_line"), "trigger_line"), diff --git a/src/experiment_snapshot.py b/src/experiment_snapshot.py new file mode 100644 index 0000000..62b4759 --- /dev/null +++ b/src/experiment_snapshot.py @@ -0,0 +1,19 @@ +"""Reproducible main-experiment setting snapshot.""" +from __future__ import annotations +import subprocess +from dataclasses import asdict +from datetime import datetime +from pathlib import Path +import yaml +from .config_loader import AppConfig +from .models import ExperimentSnapshot +class ExperimentSnapshotBuilder: + def __init__(self,config:AppConfig)->None:self.config=config + def build_snapshot(self,config_path:Path|None=None)->ExperimentSnapshot: + commit=None + if self.config.experiment_snapshot.include_git_commit: + try:commit=subprocess.run(["git","rev-parse","HEAD"],capture_output=True,text=True,timeout=2,check=True).stdout.strip() + except Exception:pass + return ExperimentSnapshot(f"snapshot_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),str(config_path or "config/experiment.yaml"),None,self.config.application.version,commit,self.config.unified_logging.camera_position_note,self.config.experiment_protocol.voice_prompt_text,self.config.audio.volume,self.config.voice_prompt.trigger_strategy,self.config.voice_prompt.response_window_sec,self.config.turn_detection.weak_threshold_deg,self.config.face.process_every_n_frames,self.config.turn_detection.signage_yaw_direction,"本実験用固定設定") + def export_yaml(self,s,output_path):output_path.parent.mkdir(parents=True,exist_ok=True);output_path.write_text(yaml.safe_dump(asdict(s),allow_unicode=True,sort_keys=False),encoding="utf-8");return output_path + def export_markdown(self,s,output_path):output_path.write_text("# 実験設定スナップショット\n\n"+"\n".join(f"- {k}: {v}" for k,v in asdict(s).items())+"\n",encoding="utf-8");return output_path diff --git a/src/final_analysis_workflow.py b/src/final_analysis_workflow.py new file mode 100644 index 0000000..ecfc969 --- /dev/null +++ b/src/final_analysis_workflow.py @@ -0,0 +1,31 @@ +"""Best-effort final offline workflow with explicit per-stage status.""" +from __future__ import annotations +import json +from dataclasses import asdict +from datetime import datetime +from pathlib import Path +from .analysis_cli import run_analysis_cli +from .config_loader import AppConfig +from .final_report_builder import FinalReportBuilder +from .models import FinalAnalysisWorkflowResult +from .phase10_cli import run_quality +from .phase11_cli import run_diagnostics +from .presentation_assets_builder import PresentationAssetsBuilder +class FinalAnalysisWorkflow: + def __init__(self,config:AppConfig)->None:self.config=config + def run(self,logs:Path,analysis:Path,output:Path)->FinalAnalysisWorkflowResult: + outputs=[];warnings=[];errors=[];flags={"analysis":False,"quality":False,"diagnostics":False,"report":False,"assets":False};output.mkdir(parents=True,exist_ok=True) + try:outputs += [str(x) for x in run_analysis_cli(self.config.analysis,logs,analysis)];flags["analysis"]=True + except Exception as e:errors.append(f"analysis: {e}") + try:outputs += [str(x) for x in run_quality(self.config,logs,analysis,output)];flags["quality"]=True + except Exception as e:warnings.append(f"quality: {e}") + try:outputs += [str(x) for x in run_diagnostics(self.config,analysis,logs,output)];flags["diagnostics"]=True + except Exception as e:warnings.append(f"diagnostics: {e}") + report=None + try:b=FinalReportBuilder();report=b.build(analysis);outputs += [str(x) for x in b.export(report,output/"final_experiment_report.md",output/"final_experiment_report_summary.csv")];flags["report"]=True + except Exception as e:errors.append(f"report: {e}") + try: + if report is None:report=FinalReportBuilder().build(analysis) + b=PresentationAssetsBuilder();outputs += [str(x) for x in b.export(b.build(report,analysis),output/"presentation_assets.md",output/"presentation_tables.csv")];flags["assets"]=True + except Exception as e:warnings.append(f"assets: {e}") + result=FinalAnalysisWorkflowResult(f"workflow_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),str(logs),str(analysis),str(output),flags["analysis"],flags["quality"],flags["diagnostics"],flags["report"],flags["assets"],outputs,warnings,errors);(output/"final_workflow_result.json").write_text(json.dumps(asdict(result),ensure_ascii=False,indent=2),encoding="utf-8");return result diff --git a/src/final_report_builder.py b/src/final_report_builder.py new file mode 100644 index 0000000..c01bd63 --- /dev/null +++ b/src/final_report_builder.py @@ -0,0 +1,22 @@ +"""Final descriptive report assembled from Phase 8 fixed-schema CSVs.""" +from __future__ import annotations +import csv +from dataclasses import asdict +from datetime import datetime +from pathlib import Path +from .models import FinalExperimentReport +def _rows(path): + if not path.exists():return [] + with path.open(encoding="utf-8-sig",newline="") as f:return list(csv.DictReader(f)) +def _n(d,k): + try:return float(d[k]) if d.get(k) not in {None,""} else None + except:return None +class FinalReportBuilder: + def build(self,analysis:Path)->FinalExperimentReport: + cond={x.get("condition"):x for x in _rows(analysis/"condition_summary.csv")};p=cond.get("prompt",{});c=cond.get("control",{});test=(_rows(analysis/"response_rate_test.csv") or [{}])[0];nums={x.get("metric_name"):x for x in _rows(analysis/"numeric_comparisons.csv")};react=nums.get("reaction_time_sec",{});yaw=nums.get("max_yaw_delta_toward_signage",{});ex=sorted(_rows(analysis/"exclusion_summary.csv"),key=lambda x:float(x.get("count") or 0),reverse=True)[:5] + return FinalExperimentReport(f"final_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),"デジタルサイネージ声掛け実験",0,0,0,int(float(p.get("valid_voice_analysis_count") or 0)),int(float(c.get("valid_voice_analysis_count") or 0)),_n(p,"response_rate"),_n(c,"response_rate"),_n(test,"rate_difference"),_n(test,"p_value"),test.get("test_name"),_n(test,"effect_size"),_n(test,"odds_ratio"),_n(p,"mean_reaction_time_sec"),_n(c,"mean_reaction_time_sec"),_n(react,"p_value"),_n(p,"mean_max_yaw_delta"),_n(c,"mean_max_yaw_delta"),_n(yaw,"p_value"),[f"{x.get('condition')}:{x.get('exclusion_reason')} ({x.get('count')})" for x in ex],"評価不能者を反応なしに含めず、有効解析者を分母とした。","観察研究であり因果関係を断定できない。カメラ位置、照明、通行方向、ID switchの影響がある。","Prompt/Controlの記述的比較結果を示す。統計結果のみから音声効果を断定しない。") + def export(self,r:FinalExperimentReport,md:Path,csv_path:Path)->list[Path]: + md.parent.mkdir(parents=True,exist_ok=True);d=asdict(r);md.write_text("# 最終実験結果\n\n"+"\n".join(f"- **{k}**: {v}" for k,v in d.items())+"\n",encoding="utf-8") + row=dict(d);row["top_exclusion_reasons"]=" | ".join(r.top_exclusion_reasons) + with csv_path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=row.keys());w.writeheader();w.writerow(row) + return [md,csv_path] diff --git a/src/gui/final_results_panel.py b/src/gui/final_results_panel.py new file mode 100644 index 0000000..d84be98 --- /dev/null +++ b/src/gui/final_results_panel.py @@ -0,0 +1,12 @@ +"""Phase 12 final experiment controls.""" +from pathlib import Path +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QFormLayout,QLineEdit,QListWidget,QPushButton,QVBoxLayout,QWidget,QHBoxLayout +class FinalResultsPanel(QWidget): + operation_requested=Signal(str,str,str,str);open_requested=Signal(str) + def __init__(self)->None: + super().__init__();layout=QVBoxLayout(self);form=QFormLayout();self.logs=QLineEdit("data/logs/unified");self.analysis=QLineEdit("data/analysis");self.output=QLineEdit("data/final_results");form.addRow("Logs",self.logs);form.addRow("Analysis",self.analysis);form.addRow("Output",self.output);layout.addLayout(form);row=QHBoxLayout() + for label,op in (("Progress","main-progress"),("Snapshot","snapshot"),("Run final analysis","final-analysis"),("Final report","final-report"),("Presentation assets","presentation-assets")): + b=QPushButton(label);b.clicked.connect(lambda checked=False,x=op:self.operation_requested.emit(x,self.logs.text(),self.analysis.text(),self.output.text()));row.addWidget(b) + layout.addLayout(row);self.files=QListWidget();self.files.itemDoubleClicked.connect(lambda i:self.open_requested.emit(i.text()));layout.addWidget(self.files) + def set_outputs(self,paths:list[Path])->None:self.files.clear();self.files.addItems([str(p) for p in paths]) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index be9aa9a..2f58f47 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -11,6 +11,7 @@ from .gui_models import GuiExperimentRequest from .log_panel import LogPanel from .operations_controller import OperationsController from .diagnostics_panel import DiagnosticsPanel +from .final_results_panel import FinalResultsPanel from .quality_panel import QualityPanel from .status_panel import StatusPanel from .video_widget import VideoWidget @@ -29,12 +30,12 @@ class _Bridge(QObject): class MainWindow(QMainWindow): def __init__(self,gui_settings)->None: - super().__init__();self.setWindowTitle("Pedestrian Signage Research System - Phase 10");self.resize(1200,800);self.bridge=_Bridge();self.experiment=ExperimentController(preview_max_fps=gui_settings.preview_max_fps);self.analysis=AnalysisController();self.operations=OperationsController();self._paused=False;self._last_request=None + super().__init__();self.setWindowTitle("Pedestrian Signage Research System - Phase 12");self.resize(1200,800);self.bridge=_Bridge();self.experiment=ExperimentController(preview_max_fps=gui_settings.preview_max_fps);self.analysis=AnalysisController();self.operations=OperationsController();self._paused=False;self._last_request=None self.experiment.set_frame_callback(lambda f,m:self.bridge.frame.emit(f,m));self.experiment.set_status_callback(lambda m:self.bridge.status.emit(m));self.experiment.set_error_callback(self.bridge.error.emit);self.experiment.set_finished_callback(self.bridge.finished.emit) self.analysis.set_finished_callback(self.bridge.analysis_finished.emit);self.analysis.set_error_callback(self.bridge.error.emit) self.operations.set_finished_callback(self.bridge.operation_finished.emit);self.operations.set_error_callback(self.bridge.error.emit) - tabs=QTabWidget();tabs.addTab(self._experiment_tab(gui_settings),"Experiment");self.analysis_panel=AnalysisPanel(gui_settings.default_analysis_input,gui_settings.default_analysis_output);tabs.addTab(self.analysis_panel,"Analysis");self.quality_panel=QualityPanel();tabs.addTab(self.quality_panel,"Quality & Reports");self.diagnostics_panel=DiagnosticsPanel();tabs.addTab(self.diagnostics_panel,"Diagnostics");self.setCentralWidget(tabs) - self.bridge.frame.connect(lambda f,m:self.preview.set_frame(f));self.bridge.status.connect(self.status.update_metrics);self.bridge.error.connect(self._error);self.bridge.finished.connect(self._finished);self.bridge.analysis_finished.connect(self._analysis_finished);self.bridge.operation_finished.connect(self._operation_finished);self.analysis_panel.run_requested.connect(self._run_analysis);self.analysis_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.quality_panel.operation_requested.connect(self._run_operation);self.quality_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.diagnostics_panel.run_requested.connect(lambda a,l,o:self._run_operation("diagnostics",l,a,o));self.diagnostics_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p))) + tabs=QTabWidget();tabs.addTab(self._experiment_tab(gui_settings),"Experiment");self.analysis_panel=AnalysisPanel(gui_settings.default_analysis_input,gui_settings.default_analysis_output);tabs.addTab(self.analysis_panel,"Analysis");self.quality_panel=QualityPanel();tabs.addTab(self.quality_panel,"Quality & Reports");self.diagnostics_panel=DiagnosticsPanel();tabs.addTab(self.diagnostics_panel,"Diagnostics");self.final_panel=FinalResultsPanel();tabs.addTab(self.final_panel,"Final Results");self.setCentralWidget(tabs) + self.bridge.frame.connect(lambda f,m:self.preview.set_frame(f));self.bridge.status.connect(self.status.update_metrics);self.bridge.error.connect(self._error);self.bridge.finished.connect(self._finished);self.bridge.analysis_finished.connect(self._analysis_finished);self.bridge.operation_finished.connect(self._operation_finished);self.analysis_panel.run_requested.connect(self._run_analysis);self.analysis_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.quality_panel.operation_requested.connect(self._run_operation);self.quality_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.diagnostics_panel.run_requested.connect(lambda a,l,o:self._run_operation("diagnostics",l,a,o));self.diagnostics_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.final_panel.operation_requested.connect(self._run_operation);self.final_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p))) def _experiment_tab(self,s)->QWidget: root=QWidget();layout=QHBoxLayout(root);left=QWidget();form=QFormLayout(left);self.config=QLineEdit(s.default_config_path);form.addRow("Config",self._file_row(self.config,"yaml"));self.input_type=QComboBox();self.input_type.addItems(["camera","video"]);form.addRow("Input",self.input_type);self.camera=QSpinBox();self.camera.setMinimum(0);form.addRow("Camera index",self.camera);self.video=QLineEdit();form.addRow("Video",self._file_row(self.video,"video"));self.voice=QComboBox();self.voice.addItems(["prompt","control","disabled"]);form.addRow("Voice mode",self.voice);self.audio=QLineEdit();form.addRow("Audio",self._file_row(self.audio,"audio"));self.volume=QDoubleSpinBox();self.volume.setRange(0,1);self.volume.setSingleStep(.1);self.volume.setValue(.8);form.addRow("Volume",self.volume);self.note=QLineEdit();form.addRow("Camera position note",self.note);self.yaw=QComboBox();self.yaw.addItems(["positive","negative"]);form.addRow("Signage yaw",self.yaw);self.face_every=QSpinBox();self.face_every.setMinimum(1);self.face_every.setValue(1);form.addRow("Face every",self.face_every);self.max_faces=QSpinBox();self.max_faces.setMinimum(1);self.max_faces.setValue(3);form.addRow("Max face persons",self.max_faces);self.unified=QCheckBox();self.unified.setChecked(True);form.addRow("Unified logging",self.unified) buttons=QWidget();row=QHBoxLayout(buttons);self.start=QPushButton("Start");self.stop=QPushButton("Stop");self.pause=QPushButton("Pause");row.addWidget(self.start);row.addWidget(self.stop);row.addWidget(self.pause);form.addRow(buttons);self.start.clicked.connect(self._start);self.stop.clicked.connect(self.experiment.stop_experiment);self.pause.clicked.connect(self._pause) @@ -70,10 +71,10 @@ class MainWindow(QMainWindow): def _analysis_finished(self,paths)->None:self.analysis_panel.set_outputs(paths);self.logs.add_message(f"Analysis finished: {len(paths)} files") @Slot(str,str,str,str) def _run_operation(self,operation,logs,analysis,output)->None: - try:self.operations.run(operation,Path(self.config.text()),Path(logs),Path(analysis),Path(output));self.logs.add_message(f"Phase 10 operation started: {operation}") + try:self.operations.run(operation,Path(self.config.text()),Path(logs),Path(analysis),Path(output));self.logs.add_message(f"Operation started: {operation}") except Exception as exc:self._error(str(exc)) @Slot(object) - def _operation_finished(self,paths)->None:self.quality_panel.set_outputs(paths);self.diagnostics_panel.set_outputs(paths);self.logs.add_message(f"Offline operation outputs: {len(paths)} files") + def _operation_finished(self,paths)->None:self.quality_panel.set_outputs(paths);self.diagnostics_panel.set_outputs(paths);self.final_panel.set_outputs(paths);self.logs.add_message(f"Offline operation outputs: {len(paths)} files") def closeEvent(self,event)->None: # type: ignore[no-untyped-def] if self.experiment.is_running():self.experiment.stop_experiment() event.accept() diff --git a/src/gui/operations_controller.py b/src/gui/operations_controller.py index 23bfd3d..f3dfb87 100644 --- a/src/gui/operations_controller.py +++ b/src/gui/operations_controller.py @@ -6,6 +6,7 @@ from typing import Callable from ..config_loader import load_config from ..phase10_cli import generate_protocol,run_pilot_report,run_presentation,run_quality from ..phase11_cli import run_diagnostics +from ..phase12_cli import assets,final_report,progress,snapshot,workflow class OperationsController: def __init__(self)->None:self._thread:threading.Thread|None=None;self._finished:Callable[[list[Path]],None]|None=None;self._error:Callable[[str],None]|None=None def run(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None: @@ -14,7 +15,13 @@ class OperationsController: def _worker(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None: try: c=load_config("config/default.yaml",config_path) - if operation=="diagnostics":paths=run_diagnostics(c,analysis,logs,output) + if operation=="main-progress":paths=progress(c,logs,analysis,output) + elif operation=="snapshot":paths=snapshot(c,config_path,output) + elif operation=="final-report":paths=final_report(c,analysis,output) + elif operation=="presentation-assets":paths=assets(c,analysis,output) + elif operation=="final-analysis": + result=workflow(c,logs,analysis,output);paths=[Path(x) for x in result.output_files]+[output/"final_workflow_result.json"] + elif operation=="diagnostics":paths=run_diagnostics(c,analysis,logs,output) elif operation=="quality":paths=run_quality(c,logs,analysis,output) elif operation=="pilot":paths=[run_pilot_report(c,analysis,output/"pilot_report.md",logs)] elif operation=="presentation":paths=[run_presentation(c,analysis,output/"midterm_summary.md",logs)] diff --git a/src/main_experiment_manager.py b/src/main_experiment_manager.py new file mode 100644 index 0000000..fca4f64 --- /dev/null +++ b/src/main_experiment_manager.py @@ -0,0 +1,25 @@ +"""Main experiment plan and collection progress management.""" +from __future__ import annotations +import csv +from dataclasses import asdict +from datetime import datetime +from pathlib import Path +from .config_loader import AppConfig +from .models import MainExperimentPlan,MainExperimentProgress +def _b(v):return str(v).lower() in {"true","1","yes"} +class MainExperimentManager: + def __init__(self,config:AppConfig)->None:self.config=config + def build_plan(self)->MainExperimentPlan: + c=self.config.main_experiment;return MainExperimentPlan(f"plan_{datetime.now():%Y%m%d}","デジタルサイネージ本実験",datetime.now().astimezone().isoformat(timespec="seconds"),c.target_prompt_valid_records,c.target_control_valid_records,c.minimum_prompt_valid_records,c.minimum_control_valid_records,c.planned_prompt_sessions,c.planned_control_sessions,c.condition_order_rule,c.camera_position_policy,self.config.experiment_protocol.voice_prompt_text,self.config.audio.volume,self.config.voice_prompt.response_window_sec,self.config.turn_detection.weak_threshold_deg,"目標人数は初期運用値であり正式な検定設計ではない") + def evaluate_progress(self,logs_dir:Path,analysis_dir:Path|None=None)->MainExperimentProgress: + rows=[] + if logs_dir.exists(): + for p in logs_dir.rglob("*person_analysis_table.csv"): + with p.open(encoding="utf-8-sig",newline="") as f:rows.extend(csv.DictReader(f)) + p=[r for r in rows if r.get("prompt_condition")=="prompt"];c=[r for r in rows if r.get("prompt_condition")=="control"];pv=sum(_b(r.get("valid_for_voice_analysis")) for r in p);cv=sum(_b(r.get("valid_for_voice_analysis")) for r in c);ps=len({r.get("session_id") for r in p});cs=len({r.get("session_id") for r in c});cfg=self.config.main_experiment;target=pv>=cfg.target_prompt_valid_records and cv>=cfg.target_control_valid_records;camera=all(r.get("camera_position_note","").strip() for r in rows);ready=pv>=cfg.minimum_prompt_valid_records and cv>=cfg.minimum_control_valid_records and camera;issue="none" if ready else "有効人数不足" if min(pv,cv)Path: + path.parent.mkdir(parents=True,exist_ok=True) + with path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=asdict(p).keys());w.writeheader();w.writerow(asdict(p)) + return path + def export_progress_markdown(self,p:MainExperimentProgress,path:Path)->Path:path.write_text("# 本実験収集進捗\n\n"+"\n".join(f"- {k}: {v}" for k,v in asdict(p).items())+"\n",encoding="utf-8");return path diff --git a/src/models.py b/src/models.py index abe694b..5c5c365 100644 --- a/src/models.py +++ b/src/models.py @@ -875,3 +875,38 @@ class PilotDiagnosticReport: class CalibrationChangeRecord: change_id:str;created_at:str;operator:str;category:str;before_value:str;after_value:str;reason:str related_report_id:str|None=None;notes:str="" +@dataclass(frozen=True,slots=True) +class MainExperimentPlan: + plan_id:str;title:str;created_at:str;target_prompt_valid_records:int;target_control_valid_records:int + minimum_prompt_valid_records:int;minimum_control_valid_records:int;planned_prompt_sessions:int;planned_control_sessions:int + condition_order_rule:str;camera_position_policy:str;voice_prompt_text:str;voice_volume:float + response_window_sec:float;turn_weak_threshold_deg:float;notes:str +@dataclass(frozen=True,slots=True) +class MainExperimentProgress: + plan_id:str;generated_at:str;prompt_total_records:int;control_total_records:int;prompt_valid_records:int + control_valid_records:int;prompt_sessions:int;control_sessions:int;prompt_completion_rate:float|None + control_completion_rate:float|None;total_valid_records:int;target_reached:bool;ready_for_final_analysis:bool + main_issue:str;suggested_action:str +@dataclass(frozen=True,slots=True) +class ExperimentSnapshot: + snapshot_id:str;created_at:str;config_path:str;merged_config_path:str|None;application_version:str + git_commit:str|None;camera_position_note:str;voice_prompt_text:str;voice_volume:float|None + trigger_strategy:str;response_window_sec:float|None;turn_weak_threshold_deg:float|None + face_process_every_n_frames:int|None;signage_yaw_direction:str|None;notes:str +@dataclass(frozen=True,slots=True) +class FinalAnalysisWorkflowResult: + workflow_id:str;generated_at:str;input_logs_dir:str;analysis_output_dir:str;final_output_dir:str + analysis_success:bool;quality_check_success:bool;diagnostics_success:bool;final_report_success:bool + presentation_assets_success:bool;output_files:list[str];warnings:list[str];errors:list[str] +@dataclass(frozen=True,slots=True) +class FinalExperimentReport: + report_id:str;generated_at:str;experiment_title:str;total_sessions:int;prompt_sessions:int;control_sessions:int + prompt_valid_records:int;control_valid_records:int;prompt_response_rate:float|None;control_response_rate:float|None + rate_difference:float|None;response_rate_p_value:float|None;response_rate_test_name:str|None + response_rate_effect_size:float|None;odds_ratio:float|None;mean_reaction_time_prompt:float|None + mean_reaction_time_control:float|None;reaction_time_p_value:float|None;mean_yaw_delta_prompt:float|None + mean_yaw_delta_control:float|None;yaw_delta_p_value:float|None;top_exclusion_reasons:list[str] + main_quality_notes:str;limitations:str;conclusion_for_presentation:str +@dataclass(frozen=True,slots=True) +class PresentationAsset: + asset_id:str;asset_type:str;title:str;file_path:str;description:str;suggested_slide:str diff --git a/src/phase12_cli.py b/src/phase12_cli.py new file mode 100644 index 0000000..385808c --- /dev/null +++ b/src/phase12_cli.py @@ -0,0 +1,12 @@ +from pathlib import Path +from .config_loader import AppConfig +from .experiment_snapshot import ExperimentSnapshotBuilder +from .final_analysis_workflow import FinalAnalysisWorkflow +from .final_report_builder import FinalReportBuilder +from .main_experiment_manager import MainExperimentManager +from .presentation_assets_builder import PresentationAssetsBuilder +def progress(c:AppConfig,logs:Path,analysis:Path,output:Path):m=MainExperimentManager(c);p=m.evaluate_progress(logs,analysis);return [m.export_progress_csv(p,output/"main_experiment_progress.csv"),m.export_progress_markdown(p,output/"main_experiment_progress.md")] +def snapshot(c:AppConfig,config_path:Path,output:Path):b=ExperimentSnapshotBuilder(c);s=b.build_snapshot(config_path);stamp=s.snapshot_id;return [b.export_yaml(s,output/f"{stamp}.yaml"),b.export_markdown(s,output/f"{stamp}.md")] +def final_report(c:AppConfig,analysis:Path,output:Path):b=FinalReportBuilder();return b.export(b.build(analysis),output/"final_experiment_report.md",output/"final_experiment_report_summary.csv") +def assets(c:AppConfig,analysis:Path,output:Path):r=FinalReportBuilder().build(analysis);b=PresentationAssetsBuilder();return b.export(b.build(r,analysis),output/"presentation_assets.md",output/"presentation_tables.csv") +def workflow(c:AppConfig,logs:Path,analysis:Path,output:Path):return FinalAnalysisWorkflow(c).run(logs,analysis,output) diff --git a/src/presentation_assets_builder.py b/src/presentation_assets_builder.py new file mode 100644 index 0000000..a22bb02 --- /dev/null +++ b/src/presentation_assets_builder.py @@ -0,0 +1,16 @@ +"""Inventory final tables, plots, and slide-ready notes.""" +from __future__ import annotations +import csv +from dataclasses import asdict +from pathlib import Path +from .models import FinalExperimentReport,PresentationAsset +class PresentationAssetsBuilder: + def build(self,report:FinalExperimentReport,analysis:Path)->list[PresentationAsset]: + assets=[PresentationAsset("table_summary","table","条件別主要結果","final_experiment_report_summary.csv","有効人数、反応率、反応時間、Yaw","結果")] + for i,p in enumerate(sorted((analysis/"plots").glob("*.png")),1):assets.append(PresentationAsset(f"figure_{i}","figure",p.stem,str(p),"Phase 8生成図","結果")) + return assets + def export(self,assets:list[PresentationAsset],md:Path,csv_path:Path)->list[Path]: + md.parent.mkdir(parents=True,exist_ok=True);md.write_text("# 発表用成果物\n\n## スライド構成\n研究目的 → システム → 条件 → 結果 → 除外・品質 → 制約 → 今後\n\n"+"\n".join(f"- {a.asset_type}: [{a.title}]({a.file_path}) — {a.description}" for a in assets)+"\n\n統計的有意差は因果関係を直接証明しない。\n",encoding="utf-8") + fields=["asset_id","asset_type","title","file_path","description","suggested_slide"] + with csv_path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows(asdict(a) for a in assets) + return [md,csv_path] diff --git a/tests/test_experiment_snapshot.py b/tests/test_experiment_snapshot.py new file mode 100644 index 0000000..be7b57d --- /dev/null +++ b/tests/test_experiment_snapshot.py @@ -0,0 +1,5 @@ +from pathlib import Path +from src.config_loader import load_config +from src.experiment_snapshot import ExperimentSnapshotBuilder +def test_snapshot_exports(tmp_path:Path)->None: + b=ExperimentSnapshotBuilder(load_config("config/default.yaml",None));s=b.build_snapshot();assert s.voice_volume==.8;assert b.export_yaml(s,tmp_path/"s.yaml").exists();assert "camera_position_note" in b.export_markdown(s,tmp_path/"s.md").read_text(encoding="utf-8") diff --git a/tests/test_final_report_builder.py b/tests/test_final_report_builder.py new file mode 100644 index 0000000..47bae56 --- /dev/null +++ b/tests/test_final_report_builder.py @@ -0,0 +1,4 @@ +from pathlib import Path +from src.final_report_builder import FinalReportBuilder +def test_empty_final_report_exports(tmp_path:Path)->None: + b=FinalReportBuilder();r=b.build(tmp_path);assert "因果関係" in r.limitations;paths=b.export(r,tmp_path/"r.md",tmp_path/"r.csv");assert paths[1].read_bytes().startswith(b"\xef\xbb\xbf") diff --git a/tests/test_main_experiment_manager.py b/tests/test_main_experiment_manager.py new file mode 100644 index 0000000..a7c8714 --- /dev/null +++ b/tests/test_main_experiment_manager.py @@ -0,0 +1,6 @@ +import csv +from pathlib import Path +from src.config_loader import load_config +from src.main_experiment_manager import MainExperimentManager +def test_plan_empty_progress_exports(tmp_path:Path)->None: + m=MainExperimentManager(load_config("config/default.yaml",None));assert m.build_plan().target_prompt_valid_records==30;p=m.evaluate_progress(tmp_path);assert not p.ready_for_final_analysis;assert m.export_progress_csv(p,tmp_path/"p.csv").read_bytes().startswith(b"\xef\xbb\xbf");assert m.export_progress_markdown(p,tmp_path/"p.md").exists() diff --git a/tests/test_phase12_cli.py b/tests/test_phase12_cli.py new file mode 100644 index 0000000..9923af0 --- /dev/null +++ b/tests/test_phase12_cli.py @@ -0,0 +1,3 @@ +from main import build_phase12_parser +def test_phase12_parsers()->None: + for command in ("main-progress","snapshot","final-analysis","final-report","presentation-assets"):assert build_phase12_parser(command).parse_args([]).output diff --git a/tests/test_presentation_assets_builder.py b/tests/test_presentation_assets_builder.py new file mode 100644 index 0000000..75a0831 --- /dev/null +++ b/tests/test_presentation_assets_builder.py @@ -0,0 +1,5 @@ +from pathlib import Path +from src.final_report_builder import FinalReportBuilder +from src.presentation_assets_builder import PresentationAssetsBuilder +def test_empty_assets_export(tmp_path:Path)->None: + b=PresentationAssetsBuilder();assets=b.build(FinalReportBuilder().build(tmp_path),tmp_path);paths=b.export(assets,tmp_path/"a.md",tmp_path/"a.csv");assert "因果関係" in paths[0].read_text(encoding="utf-8")