"""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"(?|"{_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, )