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:
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user