#!/usr/bin/env python3 """Import one historical synthetic investigation without rewriting Agent history.""" from __future__ import annotations import argparse import asyncio import hashlib import json import re import sys from pathlib import Path from uuid import NAMESPACE_URL, uuid5 import httpx sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.domain.schemas.operational_task import AgentArtifactsCommand, InvestigationReport class HistoricalArtifacts(AgentArtifactsCommand): """Validate history for inspection without inventing a current outcome.""" report: InvestigationReport try: from scripts.run_operational_worker import CredentialProvider, WorkerSettings except ModuleNotFoundError: from run_operational_worker import CredentialProvider, WorkerSettings def convert_plan(snapshot: dict) -> tuple[dict, list[dict], dict]: evidence, nodes = {}, [] for node in snapshot["nodes"]: refs = [] for text in node.get("evidence", []): evidence_id = "legacy-synthetic:" + hashlib.sha256(text.encode()).hexdigest()[:24] refs.append(evidence_id) evidence[evidence_id] = { "evidence_id": evidence_id, "kind": "synthetic", "summary": text, "source_label": "历史水质投诉合成调查输入", "source_ref": snapshot["session_id"], "observed_at": snapshot["updated_at"], } nodes.append({"id": node["id"], "title": node["title"], "owner": node["owner"], "status": node["status"], "detail": node["detail"], "evidence_refs": refs}) plan = {"schema": "execution-plan@1", "title": snapshot["title"], "reason": "从原调查会话导入已保存的计划,未重新执行调查", "nodes": nodes, "edges": [{"from": edge["from"], "to": edge["to"], **({"condition": edge["label"]} if edge.get("label") else {})} for edge in snapshot["edges"]]} judgment = next((node for node in nodes if node["id"] == "STAGE_JUDGMENT"), None) report = {"title": "历史水质投诉调查报告", "current_conclusion": judgment["detail"] if judgment else "历史调查尚无阶段判断", "conclusion_boundary": "合成演练的历史结果,未重新查询当前数据;现场工作以实际反馈为准", "evidence_refs": judgment["evidence_refs"] if judgment else [], "pending_items": [node["detail"] for node in nodes if node["status"] in {"waiting", "pending"}]} return plan, list(evidence.values()), report async def run(args): if not re.fullmatch(r"ses_[A-Za-z0-9_-]+", args.session_id): raise ValueError("invalid session id") filename = args.session_id.lower() + ".json" metadata = json.loads((args.agent_data / "session-metadata" / filename).read_text()) state = json.loads((args.agent_data / "session-ui-states" / filename).read_text()) if metadata.get("triggerWorkflow") != "water_quality_complaint" or metadata.get("triggerSource") != "peripheral_sample": raise ValueError("only explicitly synthetic peripheral water investigations can be imported") snapshots = state.get("executionPlans", []) if not snapshots: raise ValueError("session has no saved plan; no progress will be fabricated") snapshot = max(snapshots, key=lambda item: item["revision"]) plan, evidence, report = convert_plan(snapshot) # Validate the complete payload before reporting readiness or creating a # backend task. A dry run must catch incompatible historical plan states. artifacts = HistoricalArtifacts.model_validate({ "request_id": str(uuid5(NAMESPACE_URL, f"legacy-artifacts:{metadata.get('projectId')}:{args.session_id}:{snapshot['revision']}")), "source_session_id": args.session_id, "plan": plan, "evidence": evidence, "report": report, }).model_dump(mode="json", by_alias=True) if args.dry_run: print(f"Validated {len(plan['nodes'])} nodes and {len(evidence)} synthetic evidence records; no writes. " "Historical outcomes are unrecorded; automatic publication as a new report is unavailable.") return # A historical read is not a new investigation result. Reject before task # creation, rather than inventing concluded/insufficient_data from old prose. artifacts = AgentArtifactsCommand.model_validate(artifacts).model_dump(mode="json", by_alias=True) settings = WorkerSettings.from_environment() if metadata.get("projectId") != settings.project_id: raise ValueError("session does not belong to the worker project") async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client: headers = await CredentialProvider(settings).headers(client) base = settings.server_url.rstrip("/") + "/api/v1/operational-tasks" response = await client.post(base, headers=headers, json={ "request_id": str(uuid5(NAMESPACE_URL, f"legacy-investigation:{settings.project_id}:{args.session_id}")), "task_type": "event_investigation", "workflow": "water_quality_complaint", "title": metadata.get("title") or "水质投诉调查", "summary": "从历史合成调查导入,原会话完整保留", "source": {"kind": "external_system", "external_id": metadata["triggerSubjectId"], "event_kind": "water_quality_complaint"}, "trigger": {"kind": "event", "occurred_at": metadata["triggerOccurredAt"], "detail": "历史合成外围投诉事件"}, }) response.raise_for_status() task = response.json() if task["current_plan_revision"] is not None: print("Task already has a plan; preserved without overwrite") return run = task.get("active_run") if not run: raise RuntimeError("task has no active Agent run") if run.get("agent_session_id") != args.session_id or run["status"] != "running": response = await client.post( f"{base}/{task['task_id']}/runs/{run['run_id']}/claim", headers=headers, json={ "request_id": run["execution_request_id"], "expected_revision": run["revision"], "source_session_id": args.session_id, }, ) response.raise_for_status() run = response.json() artifacts["run_id"] = run["run_id"] response = await client.post(f"{base}/{task['task_id']}/agent-artifacts", headers=headers, json=artifacts) response.raise_for_status() run = response.json()["task"]["active_run"] response = await client.post( f"{base}/{task['task_id']}/runs/{run['run_id']}/complete", headers=headers, json={ "request_id": str(uuid5(NAMESPACE_URL, f"legacy-complete:{settings.project_id}:{args.session_id}:{snapshot['revision']}")), "expected_revision": run["revision"], "source_session_id": args.session_id, }, ) response.raise_for_status() print(f"Imported task {task['task_id']}; original Agent records unchanged") if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("session_id") parser.add_argument("--agent-data", type=Path, default=Path(__file__).resolve().parents[2] / "next-tjwater-agent/data") parser.add_argument("--dry-run", action="store_true") asyncio.run(run(parser.parse_args()))