from __future__ import annotations import http.client import json import threading from datetime import datetime, timezone from pathlib import Path from zoneinfo import ZoneInfo from gyxx_flow.catalog import ScheduleEntry from gyxx_flow.console import create_console_server from gyxx_flow.core.config import Settings from gyxx_flow.core.context import RunContext from gyxx_flow.core.layout import DataLayout from gyxx_flow.core.records import RunJournal from gyxx_flow.daily_summary import DailySummaryBuilder, _schedule_slots from gyxx_flow.ops import RunIndex PROJECT_ROOT = Path(__file__).resolve().parents[1] class FakeAnalyzer: def __init__(self) -> None: self.calls: list[dict[str, object]] = [] @property def identity(self) -> str: return "fake:v1" def analyze(self, payload: dict[str, object]) -> dict[str, object]: self.calls.append(payload) workflows = [] for item in payload["workflows"]: workflow_id = item["workflow_id"] status = item["status"] if workflow_id == "content.success.daily": execution_result = "当天的数据采集已顺利完成,预定内容已经产出。" anomalies = [] repair_actions = [] elif workflow_id == "content.missed.daily": execution_result = "计划执行时间已过,但当天没有发现任务启动记录。" anomalies = ["常驻调度服务没有按计划启动这个工作流,日报内容因此没有生成。"] repair_actions = ["检查常驻调度服务和计划设置,恢复后按原业务日期补跑。"] else: execution_result = "当天的工作流执行情况已经完成检查。" anomalies = ( [] if status in {"normal", "recovered"} else ["该工作流没有按预期完成,相关业务结果可能缺失。"] ) repair_actions = ( [] if status in {"normal", "recovered"} else ["检查运行环境和计划设置,排除原因后按原业务日期重新执行。"] ) workflows.append( { "workflow_id": workflow_id, "execution_result": execution_result, "anomalies": anomalies, "repair_actions": repair_actions, } ) return { "overview": "模型已逐个检查运行证据。", "workflows": workflows, } def _write_catalog(project_root: Path) -> None: config = project_root / "config" config.mkdir(parents=True) workflows = { "schema_version": 3, "workflows": [ { "id": workflow_id, "module": "content_marketing", "trigger": "scheduled", "execution": {"entry": "daily.py"}, "provenance": { "source_project": "content", "task_name": name, }, } for workflow_id, name in ( ("content.success.daily", "成功日报"), ("content.missed.daily", "漏跑日报"), ) ], } schedules = { "schema_version": 1, "timezone": "Asia/Shanghai", "schedules": [ { "workflow_id": workflow_id, "kind": "daily", "at": at, "enabled": True, } for workflow_id, at in ( ("content.success.daily", "09:00"), ("content.missed.daily", "10:00"), ) ], } (config / "workflows.json").write_text( json.dumps(workflows, ensure_ascii=False), encoding="utf-8", ) (config / "schedules.json").write_text( json.dumps(schedules, ensure_ascii=False), encoding="utf-8", ) def _write_multi_slot_catalog(project_root: Path) -> None: config = project_root / "config" config.mkdir(parents=True) workflows = { "schema_version": 3, "workflows": [ { "id": "content.success.daily", "module": "content_marketing", "trigger": "scheduled", "execution": {"entry": "daily.py"}, "provenance": { "source_project": "content", "task_name": "成功日报", }, } ], } schedules = { "schema_version": 1, "timezone": "Asia/Shanghai", "schedules": [ { "workflow_id": "content.success.daily", "kind": "daily", "at": ["08:00", "16:00", "22:00"], "enabled": True, } ], } (config / "workflows.json").write_text( json.dumps(workflows, ensure_ascii=False), encoding="utf-8", ) (config / "schedules.json").write_text( json.dumps(schedules, ensure_ascii=False), encoding="utf-8", ) def _successful_run(data_root: Path) -> None: context = RunContext.create( "content.success.daily", "2026-08-12", now=datetime(2026, 8, 13, 1, 0, tzinfo=timezone.utc), random_suffix="abc123", ) journal = RunJournal.create(DataLayout(data_root), context, mode="execute") journal.start_step("collect.attempt-1", attempt=1) journal.finish_step("collect.attempt-1", status="success", exit_code=0) journal.finalize("success") RunIndex(data_root).index_journal(journal) def test_daily_summary_keeps_status_deterministic_and_analyzes_each_workflow( tmp_path: Path, ) -> None: project_root = tmp_path / "project" data_root = tmp_path / "data" _write_catalog(project_root) _successful_run(data_root) log_root = data_root / "logs" / "scheduler" log_root.mkdir(parents=True) (log_root / "content.success.daily-20260813T090000+0800.log").write_text( "completed token=top-secret-value", encoding="utf-8", ) analyzer = FakeAnalyzer() builder = DailySummaryBuilder( project_root=project_root, data_root=data_root, analyzer=analyzer, workflow_name=lambda workflow_id: { "content.success.daily": "成功日报", "content.missed.daily": "漏跑日报", }[workflow_id], ) report = builder.build(datetime(2026, 8, 13).date()) by_id = {item["workflow_id"]: item for item in report["workflows"]} assert report["overview"] == "模型已逐个检查运行证据。" assert report["summary"] == { "total": 2, "normal": 1, "recovered": 0, "abnormal": 0, "running": 0, "missed": 1, } assert by_id["content.success.daily"]["status"] == "normal" assert by_id["content.success.daily"]["execution_result"] == ( "当天的数据采集已顺利完成,预定内容已经产出。" ) assert by_id["content.missed.daily"]["status"] == "missed" assert by_id["content.missed.daily"]["repair_actions"] == [ "检查常驻调度服务和计划设置,恢复后按原业务日期补跑。" ] assert "top-secret-value" not in json.dumps(analyzer.calls, ensure_ascii=False) assert "[REDACTED]" in json.dumps(analyzer.calls, ensure_ascii=False) serialized_report = json.dumps(report, ensure_ascii=False) assert "content.success.daily-20260813T090000+0800.log" not in serialized_report assert "completed token" not in serialized_report assert "[REDACTED]" not in serialized_report cached = builder.build(datetime(2026, 8, 13).date()) assert cached == report assert len(analyzer.calls) == 1 def test_daily_summary_detects_failed_run_and_returns_rule_based_repair( tmp_path: Path, ) -> None: project_root = tmp_path / "project" data_root = tmp_path / "data" _write_catalog(project_root) context = RunContext.create( "content.success.daily", "2026-08-12", now=datetime(2026, 8, 13, 1, 0, tzinfo=timezone.utc), random_suffix="def456", ) journal = RunJournal.create(DataLayout(data_root), context, mode="execute") journal.start_step("notify.attempt-1", attempt=1) journal.finish_step( "notify.attempt-1", status="failed", exit_code=1, error=( 'Traceback: File "D:\\secret\\notify.py", line 42; ' "open_id cross app (99992361) token=do-not-publish" ), ) journal.finalize( "failed", error=( 'Traceback: File "D:\\secret\\notify.py", line 42; ' "open_id cross app (99992361) token=do-not-publish" ), ) RunIndex(data_root).index_journal(journal) class UnavailableAnalyzer(FakeAnalyzer): @property def identity(self) -> str: return "unavailable:v1" def analyze(self, payload: dict[str, object]) -> dict[str, object]: from gyxx_flow.daily_summary import DailySummaryAnalysisError raise DailySummaryAnalysisError("Hermes 未启动") report = DailySummaryBuilder( project_root=project_root, data_root=data_root, analyzer=UnavailableAnalyzer(), ).build(datetime(2026, 8, 13).date()) by_id = {item["workflow_id"]: item for item in report["workflows"]} failed = by_id["content.success.daily"] assert report["analysis"] == { "status": "unavailable", "source": "rules", "message": "大模型分析服务暂时不可用,当前展示系统生成的通俗基础说明。", } assert failed["status"] == "abnormal" assert "身份绑定不一致" in failed["anomalies"][0] assert "重新绑定" in failed["repair_actions"][0] serialized = json.dumps(report, ensure_ascii=False) assert "Traceback" not in serialized assert "notify.py" not in serialized assert "open_id cross app" not in serialized assert "99992361" not in serialized assert "do-not-publish" not in serialized assert all("error" not in run and "steps" not in run for run in failed["runs"]) assert "log_evidence" not in failed def test_daily_summary_rejects_model_output_that_copies_raw_diagnostics( tmp_path: Path, ) -> None: project_root = tmp_path / "project" data_root = tmp_path / "data" _write_catalog(project_root) _successful_run(data_root) class EchoingAnalyzer(FakeAnalyzer): @property def identity(self) -> str: return "echoing:v1" def analyze(self, payload: dict[str, object]) -> dict[str, object]: result = super().analyze(payload) result["workflows"][0]["execution_result"] = ( 'Traceback: File "D:\\internal\\worker.py", line 17' ) return result report = DailySummaryBuilder( project_root=project_root, data_root=data_root, analyzer=EchoingAnalyzer(), ).build(datetime(2026, 8, 13).date()) serialized = json.dumps(report, ensure_ascii=False) assert report["analysis"]["status"] == "unavailable" assert "Traceback" not in serialized assert "worker.py" not in serialized assert "正式执行 1 次" in serialized def test_daily_summary_normalizes_relative_dates_in_model_output(tmp_path: Path) -> None: project_root = tmp_path / "project" data_root = tmp_path / "data" _write_catalog(project_root) _successful_run(data_root) class RelativeDateAnalyzer(FakeAnalyzer): def analyze(self, payload: dict[str, object]) -> dict[str, object]: result = super().analyze(payload) result["overview"] = "今日工作流已完成检查。" result["workflows"][0]["execution_result"] = "今天的数据采集已完成。" return result report = DailySummaryBuilder( project_root=project_root, data_root=data_root, analyzer=RelativeDateAnalyzer(), ).build(datetime(2026, 8, 13).date()) serialized = json.dumps(report, ensure_ascii=False) assert "今日" not in serialized assert "今天" not in serialized assert "当天工作流已完成检查" in serialized def test_daily_summary_http_get_and_forced_refresh(tmp_path: Path) -> None: analyzer = FakeAnalyzer() settings = Settings(project_root=PROJECT_ROOT, data_root=tmp_path / "data") server = create_console_server( settings, host="127.0.0.1", port=0, daily_summary_analyzer=analyzer, ) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: connection = http.client.HTTPConnection( "127.0.0.1", server.server_port, timeout=10, ) connection.request("GET", "/api/daily-summary?date=2026-08-13") response = connection.getresponse() payload = json.loads(response.read().decode("utf-8")) assert response.status == 200 assert payload["report_date"] == "2026-08-13" assert payload["schema_version"] == 2 assert payload["workflows"] assert all( {"workflow_id", "status", "execution_result", "repair_actions"} <= set(item) for item in payload["workflows"] ) body = json.dumps({"date": "2026-08-13"}).encode("utf-8") connection.request( "POST", "/api/daily-summary/refresh", body=body, headers={ "Content-Type": "application/json", "Content-Length": str(len(body)), "X-GYXX-Console": "1", "Origin": f"http://127.0.0.1:{server.server_port}", }, ) refreshed = connection.getresponse() refreshed.read() assert refreshed.status == 200 assert len(analyzer.calls) == 2 connection.close() finally: server.shutdown() server.server_close() thread.join(timeout=5) def test_schedule_slots_filters_future_times_when_now_is_given() -> None: schedule = ScheduleEntry( workflow_id="content.success.daily", kind="daily", at="08:00", at_times=("08:00", "16:00", "22:00"), ) timezone_info = ZoneInfo("Asia/Shanghai") target = datetime(2026, 8, 18).date() all_slots = _schedule_slots(schedule, target, timezone_info) assert [slot.strftime("%H:%M") for slot in all_slots] == [ "08:00", "16:00", "22:00", ] morning = _schedule_slots( schedule, target, timezone_info, now=datetime(2026, 8, 18, 9, 55, tzinfo=timezone_info), ) assert [slot.strftime("%H:%M") for slot in morning] == ["08:00"] def test_daily_summary_does_not_flag_future_multi_slots_as_missing( tmp_path: Path, ) -> None: project_root = tmp_path / "project" data_root = tmp_path / "data" _write_multi_slot_catalog(project_root) context = RunContext.create( "content.success.daily", "2026-08-18", now=datetime(2026, 8, 18, 0, 1, tzinfo=timezone.utc), random_suffix="abc123", ) journal = RunJournal.create(DataLayout(data_root), context, mode="execute") journal.start_step("collect.attempt-1", attempt=1) journal.finish_step("collect.attempt-1", status="success", exit_code=0) journal.finalize("success") RunIndex(data_root).index_journal(journal) log_root = data_root / "logs" / "scheduler" log_root.mkdir(parents=True) (log_root / "content.success.daily-20260818T080000+0800.log").write_text( "completed", encoding="utf-8", ) analyzer = FakeAnalyzer() builder = DailySummaryBuilder( project_root=project_root, data_root=data_root, analyzer=analyzer, workflow_name=lambda workflow_id: "成功日报", ) report = builder.build( datetime(2026, 8, 18).date(), now=datetime(2026, 8, 18, 9, 55, tzinfo=ZoneInfo("Asia/Shanghai")), ) item = report["workflows"][0] assert item["workflow_id"] == "content.success.daily" assert item["expected_slots"] == ["2026-08-18T08:00:00+08:00"] assert item["missed_slots"] == 0 assert item["status"] == "normal" assert "没有发现调度日志或运行记录" not in item["execution_result"]