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:
2026-11-11 19:10:41 +08:00
parent e47c61c121
commit b853b61a0b
38 changed files with 2445 additions and 147 deletions
+58 -10
View File
@@ -5,7 +5,7 @@ import re
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, JsonValue, model_serializer, model_validator
from pydantic import Field, JsonValue, field_validator, model_serializer, model_validator
from app.domain.schemas.answer_ui import (
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
)
@@ -222,25 +222,61 @@ class InvestigationMapTarget(StrictModel):
feature_id: TaskFeatureId
class InvestigationDiagramNodeRef(StrictModel):
InvestigationNodeAnnotation = Literal["reference", "recorded", "needs_input", "failed", "not_taken"]
LEGACY_INVESTIGATION_NODE_ANNOTATIONS = {
"pending": "reference", "running": "reference", "completed": "recorded",
"waiting": "needs_input", "waiting_human": "needs_input",
"skipped": "not_taken", "cancelled": "not_taken",
}
class _InvestigationDiagramNode(StrictModel):
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
label: NonEmptyStr
detail: Annotated[str, Field(min_length=1, max_length=2000)] | None = None
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
status: Literal[
"pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"
] | None = Field(default=None, description=(
"Evidence-confirmed node state; waiting means prerequisites/external/system result, "
"waiting_human requires manual authorization/approval/feedback. "
"Unknown stays omitted/null; host controls colors."
))
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
class InvestigationDiagramNodeRef(_InvestigationDiagramNode):
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
"A saved process-reference annotation, not live execution state. "
"recorded means a finding exists, not that a task or field action completed."
))
@field_validator("status", mode="before")
@classmethod
def read_legacy_annotation(cls, value: object) -> object:
return LEGACY_INVESTIGATION_NODE_ANNOTATIONS.get(value, value) if isinstance(value, str) else value
class InvestigationGraphNodeUpdate(_InvestigationDiagramNode):
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
"No live node/task binding or per-step synchronization is required."
))
class InvestigationGraphEdge(StrictModel):
source: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
target: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
label: Annotated[str, Field(min_length=1, max_length=200)] | None = None
class InvestigationGraphUpdate(StrictModel):
title: Annotated[str, Field(min_length=1, max_length=200)] | None = None
nodes: list[InvestigationGraphNodeUpdate] = Field(default_factory=list, max_length=100)
edges: list[InvestigationGraphEdge] = Field(default_factory=list, max_length=200)
class InvestigationDiagram(StrictModel):
title: NonEmptyStr
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
node_refs: list[InvestigationDiagramNodeRef] = Field(default_factory=list, max_length=100)
# Present only on a graph maintained by the host. Legacy Mermaid stays readable.
edges: list[InvestigationGraphEdge] | None = Field(default=None, max_length=200)
source_mermaid: Annotated[str, Field(min_length=1, max_length=20000)] | None = None
@model_validator(mode="after")
def validate_description(self):
@@ -323,6 +359,16 @@ class InvestigationProgress(StrictModel):
diagram: InvestigationDiagram | None = None
class InvestigationProgressInput(InvestigationProgress):
graph_update: InvestigationGraphUpdate | None = None
@model_validator(mode="after")
def validate_graph_input(self):
if self.graph_update is not None and self.diagram is not None:
raise ValueError("use graph_update without a generated Mermaid diagram")
return self
class InvestigationProgressSnapshot(StrictModel):
revision: PositiveInt
run_id: UUID
@@ -367,7 +413,7 @@ class AgentArtifactsCommand(StrictModel):
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
plan: ExecutionPlanInput | None = None
report: InvestigationReportInput | None = None
progress: InvestigationProgress | None = None
progress: InvestigationProgressInput | None = None
@model_validator(mode="after")
def validate_artifacts(self):
@@ -377,6 +423,8 @@ class AgentArtifactsCommand(StrictModel):
self.report.diagram if self.report is not None else None,
self.progress.diagram if self.progress is not None else None,
):
if diagram is not None and (diagram.edges is not None or diagram.source_mermaid is not None):
raise ValueError("structured graph metadata is host-owned; use progress.graph_update")
missing = _undeclared_diagram_node_refs(diagram)
if missing:
raise ValueError(