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:
@@ -0,0 +1,94 @@
|
||||
"""Accumulate explanatory investigation paths and generate Mermaid deterministically."""
|
||||
import re
|
||||
from app.domain.domain_errors import require
|
||||
from app.domain.schemas.operational_task import (
|
||||
InvestigationDiagram, InvestigationDiagramNodeRef, InvestigationGraphUpdate,
|
||||
)
|
||||
|
||||
|
||||
def _label(value: str) -> str:
|
||||
# Encode Mermaid delimiters and HTML before placing untrusted text in quotes.
|
||||
replacements = {"#": "#35;", "&": "#38;", '"': "#quot;", "<": "#lt;",
|
||||
">": "#gt;", "`": "#96;", "%": "#37;", "|": "#124;", "\\": "#92;"}
|
||||
return "".join(replacements.get(c, " " if c in "\r\n" else c) for c in value)
|
||||
|
||||
|
||||
def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> InvestigationDiagram:
|
||||
"""Make a saved legacy graph cumulative without parsing or changing its source."""
|
||||
if diagram.edges is not None:
|
||||
return diagram
|
||||
return diagram.model_copy(deep=True, update={"edges": [], "source_mermaid": diagram.mermaid})
|
||||
|
||||
|
||||
def update_investigation_graph(
|
||||
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
|
||||
) -> InvestigationDiagram:
|
||||
"""Upsert nodes and append explicit edges; never delete a path or erase a failed attempt.
|
||||
|
||||
A retry uses a new node id. Relationships describe activity for readers and
|
||||
impose no ordering or completion requirements on the Agent.
|
||||
"""
|
||||
if previous is not None:
|
||||
previous = adopt_legacy_investigation_graph(previous)
|
||||
source = previous.source_mermaid if previous else None
|
||||
def in_source(node_id: str) -> bool:
|
||||
return bool(source and re.search(
|
||||
rf"(?<![A-Za-z0-9_-]){re.escape(node_id)}(?=\s*(?:[\[({{@;]|:::|--|==|-\.|$))",
|
||||
source, re.MULTILINE,
|
||||
))
|
||||
|
||||
nodes = {node.node_id: node.model_copy(deep=True) for node in previous.node_refs} if previous else {}
|
||||
require(len({node.node_id for node in update.nodes}) == len(update.nodes),
|
||||
"GRAPH_NODE_DUPLICATE", "Graph update contains duplicate node ids", status=422)
|
||||
for node in update.nodes:
|
||||
require(node.node_id.lower() not in {"end", "graph", "flowchart", "subgraph"},
|
||||
"GRAPH_NODE_ID_INVALID", "Use a descriptive graph node id", status=422)
|
||||
existing = nodes.get(node.node_id)
|
||||
if existing is None:
|
||||
require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT",
|
||||
"This id belongs to the original Mermaid; use a new node id", status=409)
|
||||
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
|
||||
continue
|
||||
require(not in_source(node.node_id) or existing.label == node.label,
|
||||
"GRAPH_LEGACY_NODE_CONFLICT", "Keep the original node label; use a new node for a new step", status=409)
|
||||
require(existing.status != "failed" or (
|
||||
("status" not in node.model_fields_set or node.status == "failed")
|
||||
and existing.label == node.label
|
||||
), "GRAPH_FAILED_ATTEMPT_IMMUTABLE", "Keep the failed attempt; use a new node for a retry", status=409)
|
||||
changes = node.model_dump(exclude_unset=True)
|
||||
if existing.status == "failed" and existing.detail and not changes.get("detail"):
|
||||
changes.pop("detail", None)
|
||||
for field in ("evidence_refs", "action_refs", "map_targets"):
|
||||
combined = list(getattr(existing, field))
|
||||
for value in getattr(node, field):
|
||||
if value not in combined:
|
||||
combined.append(value)
|
||||
changes[field] = combined
|
||||
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate({
|
||||
**existing.model_dump(), **changes,
|
||||
})
|
||||
require(bool(nodes), "GRAPH_NODE_REQUIRED", "Add a node before publishing a graph", status=422)
|
||||
edges = list(previous.edges or []) if previous else []
|
||||
for edge in update.edges:
|
||||
require(edge.source in nodes and edge.target in nodes, "GRAPH_EDGE_NODE_NOT_FOUND",
|
||||
"Every graph edge must reference saved or newly added nodes", status=422)
|
||||
if edge not in edges:
|
||||
edges.append(edge.model_copy(deep=True))
|
||||
require(len(nodes) <= 100 and len(edges) <= 200, "GRAPH_CAPACITY_EXCEEDED",
|
||||
"Investigation graph exceeds 100 nodes or 200 edges; existing paths were retained", status=422)
|
||||
lines = [source or "flowchart TD"]
|
||||
for node in nodes.values():
|
||||
if in_source(node.node_id):
|
||||
continue # Preserve original branches, shapes and labels byte for byte.
|
||||
label = node.label + ("(失败)" if node.status == "failed" else "")
|
||||
lines.append(f' {node.node_id}["{_label(label)}"]')
|
||||
for edge in edges:
|
||||
connector = f' -->|"{_label(edge.label)}"| ' if edge.label else " --> "
|
||||
lines.append(f" {edge.source}{connector}{edge.target}")
|
||||
mermaid = "\n".join(lines)
|
||||
require(len(mermaid) <= 20000, "GRAPH_CAPACITY_EXCEEDED",
|
||||
"Investigation Mermaid exceeds 20000 characters; existing paths were retained", status=422)
|
||||
return InvestigationDiagram(
|
||||
title=update.title or (previous.title if previous else "调查脉络"),
|
||||
mermaid=mermaid, node_refs=list(nodes.values()), edges=edges, source_mermaid=source,
|
||||
)
|
||||
@@ -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