Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates. Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
100 lines
5.5 KiB
Python
100 lines
5.5 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:
|
|
"""Patch saved nodes or add labelled nodes and explicit edges; never erase a path.
|
|
|
|
Unspecified fields retain their saved values. A failed attempt remains
|
|
failed, and a retry uses a new node id. Relationships impose no execution
|
|
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)
|
|
require(node.label is not None, "GRAPH_NODE_LABEL_REQUIRED",
|
|
"A new graph node requires a label; use an existing node_id for a partial update", status=422)
|
|
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
|
|
continue
|
|
require(not in_source(node.node_id) or node.label is None 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 (node.label is None or 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 node.label is None:
|
|
changes.pop("label", None)
|
|
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,
|
|
)
|