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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user