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