feat(workbench): persist operational workflows and events

This commit is contained in:
2026-09-24 04:19:02 +08:00
parent baff7dfe9e
commit 9335bdc1ea
66 changed files with 16580 additions and 238 deletions
+130
View File
@@ -0,0 +1,130 @@
#!/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
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 = AgentArtifactsCommand.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"Ready to import {len(plan['nodes'])} nodes and {len(evidence)} synthetic evidence records; no writes")
return
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()))