Files
next-tjwater-server/app/services/investigation_graph.py
T
jiang b853b61a0b 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.
2026-11-11 19:10:41 +08:00

95 lines
5.1 KiB
Python

"""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,
)