feat(investigation): preserve cumulative reference graphs

Accumulate investigation paths and preserve failed attempts across rounds. Keep graph annotations separate from execution state, synchronize the API contract, and consolidate documentation.
This commit is contained in:
2026-11-11 19:10:41 +08:00
parent e47c61c121
commit b853b61a0b
38 changed files with 2445 additions and 147 deletions
+50 -16
View File
@@ -49,6 +49,8 @@ from app.domain.schemas.operational_task import (
InvestigationReportRevision,
InvestigationReportReference,
InvestigationProgressSnapshot,
InvestigationProgress,
InvestigationDiagram,
IssueCommandPayload,
NotificationAcknowledgeCommand,
OperationalTaskCreate,
@@ -71,6 +73,7 @@ from app.domain.schemas.operational_task import (
)
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.services.content_digest import digest
from app.services.investigation_graph import adopt_legacy_investigation_graph, update_investigation_graph
from app.services.operational_presentation import resolve_overlay
from app.services.operational_recovery import accept_completion, track_work_order
from app.services.operational_scada_coverage import require_complete_scada_evidence
@@ -264,6 +267,8 @@ class OperationalTaskService:
active_run = await self._active_run(task)
latest_run = await self.repo.get_latest_run(str(task_id))
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
if progress is None:
progress = await self.repo.get_latest_graph_progress(str(task_id))
work_order_snapshot = None
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
feedback = await self.repo.get_feedback(
@@ -850,11 +855,27 @@ class OperationalTaskService:
for item in await self.repo.list_actions(str(task_id))
}
progress_revision = None
saved_diagram = None
if command.progress is not None or command.report is not None:
stored = await self.repo.get_latest_graph_progress(str(task_id))
if stored is not None:
saved_diagram = InvestigationProgressSnapshot.model_validate(stored).progress.diagram
elif command.progress is not None:
legacy = await self.repo.get_latest_diagram(str(task_id))
saved_diagram = InvestigationDiagram.model_validate(legacy) if legacy else None
if command.progress is not None:
require(command.run_id is not None, "RUN_REQUIRED",
"Progress must identify its current execution run", status=422)
if command.progress.diagram:
for node in command.progress.diagram.node_refs:
diagram = command.progress.diagram
if command.progress.graph_update is not None:
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
elif saved_diagram is not None:
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
"Use graph_update to extend the saved investigation graph", status=422)
if diagram is None:
diagram = adopt_legacy_investigation_graph(saved_diagram)
if diagram:
for node in diagram.node_refs:
self._require_evidence_refs(node.evidence_refs, known_evidence)
require(all(str(ref) in actions for ref in node.action_refs),
"ACTION_NOT_FOUND", "Progress references an unknown action", status=422)
@@ -863,9 +884,11 @@ class OperationalTaskService:
snapshot = InvestigationProgressSnapshot(
revision=progress_revision, run_id=active_run["run_id"],
execution_generation=task["execution_generation"],
source_session_id=command.source_session_id, published_at=now, progress=command.progress,
source_session_id=command.source_session_id, published_at=now,
progress=InvestigationProgress(summary=command.progress.summary, diagram=diagram),
)
await self.repo.save_progress(str(task_id), _json(snapshot))
saved_diagram = diagram
await self._append_event(
topic="operational.progress.changed", aggregate_type="operational_task_progress",
aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now,
@@ -917,21 +940,31 @@ class OperationalTaskService:
await self.repo.insert_plan(str(task_id), _json(snapshot))
current_plan = snapshot
if command.report:
self._require_evidence_refs(command.report.evidence_refs, known_evidence)
if command.report.stage == "final":
report_input = command.report
if saved_diagram is not None:
diagram = adopt_legacy_investigation_graph(saved_diagram)
report_input = report_input.model_copy(update={
"diagram": diagram,
"evidence_refs": list(dict.fromkeys([
*report_input.evidence_refs,
*(ref for node in diagram.node_refs for ref in node.evidence_refs),
])),
})
self._require_evidence_refs(report_input.evidence_refs, known_evidence)
if report_input.stage == "final":
require_complete_scada_evidence(
task, await self.repo.list_evidence(str(task_id)), command.report.evidence_refs,
task, await self.repo.list_evidence(str(task_id)), report_input.evidence_refs,
)
for gap in command.report.data_gaps:
require(set(gap.evidence_refs) <= set(command.report.evidence_refs),
for gap in report_input.data_gaps:
require(set(gap.evidence_refs) <= set(report_input.evidence_refs),
"REPORT_GAP_EVIDENCE_INVALID",
"Gap evidence must be included in report evidence", status=422)
if command.report.diagram:
for node in command.report.diagram.node_refs:
self._require_evidence_refs(node.evidence_refs, set(command.report.evidence_refs))
if report_input.diagram:
for node in report_input.diagram.node_refs:
self._require_evidence_refs(node.evidence_refs, set(report_input.evidence_refs))
require(all(str(ref) in actions for ref in node.action_refs),
"ACTION_NOT_FOUND", "Diagram references an unknown action", status=422)
if command.report.outcome == "insufficient_data":
if report_input.outcome == "insufficient_data":
require(not any(action.approval_status == "pending" or (
action.approval_status == "approved" and action.execution_status in {"not_submitted", "submitted", "unknown"}
) for action in actions.values()), "REPORT_ACTION_PENDING",
@@ -941,7 +974,7 @@ class OperationalTaskService:
revision=report_revision,
execution_generation=task["execution_generation"],
source_session_id=command.source_session_id,
published_at=now, published_by=self._actor(), report=command.report,
published_at=now, published_by=self._actor(), report=report_input,
)
report = await self._report_with_progress_diagram(task_id, report)
await self.repo.insert_report(str(task_id), _json(report))
@@ -954,11 +987,12 @@ class OperationalTaskService:
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"])},
)
previous_result = previous_report["report"] if previous_report else None
if command.report.stage == "final" and (
effective_report = _json(report.report)
if report.report.stage == "final" and (
previous_report is None
or previous_report.get("execution_generation") != task["execution_generation"]
or previous_result.get("stage") != "final"
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != _json(command.report).get(key)
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != effective_report.get(key)
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
):
@@ -967,7 +1001,7 @@ class OperationalTaskService:
now=now, reason="report_superseded",
)
await self._notify(
task_id, "report_updated", task["title"], command.report.current_conclusion, now,
task_id, "report_updated", task["title"], report.report.current_conclusion, now,
report_reference=InvestigationReportReference(
execution_generation=task["execution_generation"], revision=report_revision,
),