"""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"(? 1: neighbours = {node_id: set() for node_id in nodes} for edge in edges: neighbours[edge.source].add(edge.target) neighbours[edge.target].add(edge.source) connected = set(saved_ids) or {next(iter(nodes))} pending = list(connected) while pending: for node_id in neighbours[pending.pop()] - connected: connected.add(node_id) pending.append(node_id) missing = sorted(new_ids - connected) require(not missing, "GRAPH_RELATION_REQUIRED", "Add explicit edges connecting these new steps to the investigation narrative: " + ", ".join(missing) + ". Describe branches, parallel findings or retries; edges do not schedule execution.", 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, )