feat(tasks): define investigation report contract and history

This commit is contained in:
2026-10-10 16:02:46 +08:00
parent 4a63fcd7ff
commit 2b6fc5aa52
12 changed files with 1380 additions and 52 deletions
+87 -3
View File
@@ -1,12 +1,13 @@
"""Contracts for project-scoped operational tasks and human-approved actions."""
from __future__ import annotations
import re
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, JsonValue, model_validator
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.domain.schemas.workbench_runtime import (
WorkbenchDateTime,
@@ -190,13 +191,84 @@ class StoredEvidenceRecord(EvidenceRecord):
data_snapshot: TaskDataSnapshot | None = None
class InvestigationReportInput(StrictModel):
class InvestigationDataGap(StrictModel):
gap_id: NonEmptyStr
node_id: NonEmptyStr | None = None
missing_data: NonEmptyStr
scope: NonEmptyStr
impact: NonEmptyStr
required_data: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(min_length=1)
blocking: StrictBool
class InvestigationDiagramNodeRef(StrictModel):
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
label: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
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)
@model_validator(mode="after")
def validate_description(self):
if not re.match(r"^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b", self.mermaid):
raise ValueError("investigation diagrams must be Mermaid flowcharts")
if "```" in self.mermaid or "%%{" in self.mermaid or re.search(r"(?:^|[;\n])\s*click\b", self.mermaid):
raise ValueError("diagram descriptions cannot contain fences, directives or click commands")
ids = [node.node_id for node in self.node_refs]
if len(ids) != len(set(ids)):
raise ValueError("diagram node references must be unique")
return self
class InvestigationReport(StrictModel):
"""Read model: absent historical facts remain unknown, never inferred."""
title: NonEmptyStr
current_conclusion: NonEmptyStr
conclusion_boundary: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
recommendations: list[NonEmptyStr] = Field(default_factory=list)
pending_items: list[NonEmptyStr] = Field(default_factory=list)
stage: Literal["interim", "final"] | None = None
outcome: Literal["concluded", "insufficient_data"] | None = None
stop_reason: NonEmptyStr | None = None
data_gaps: list[InvestigationDataGap] | None = None
diagram: InvestigationDiagram | None = None
@model_validator(mode="after")
def validate_result(self):
if self.outcome is None:
if self.stage is not None or self.stop_reason is not None or self.data_gaps is not None:
raise ValueError("structured report fields must be supplied together")
return self
if self.stage is None or self.data_gaps is None:
raise ValueError("structured reports require stage and data_gaps")
ids = [gap.gap_id for gap in self.data_gaps]
if len(ids) != len(set(ids)):
raise ValueError("data gap ids must be unique")
blocking = any(gap.blocking for gap in self.data_gaps)
if self.outcome == "insufficient_data":
if self.stage != "final" or not self.stop_reason or not blocking:
raise ValueError("insufficient data requires a final report, stop reason and blocking gap")
if self.recommendations or self.pending_items:
raise ValueError("insufficient data ends the investigation without downstream recommendations or pending work")
elif self.stop_reason is not None or blocking:
raise ValueError("concluded report cannot contain a stop reason or blocking gap")
return self
class InvestigationReportInput(InvestigationReport):
"""Write model: every new report explicitly declares its business result."""
stage: Literal["interim", "final"]
outcome: Literal["concluded", "insufficient_data"]
data_gaps: list[InvestigationDataGap]
class ExecutionPlanSnapshot(StrictModel):
@@ -214,7 +286,17 @@ class InvestigationReportRevision(StrictModel):
source_session_id: NonEmptyStr
published_at: WorkbenchDateTime
published_by: NonEmptyStr
report: InvestigationReportInput
report: InvestigationReport
class InvestigationReportPage(StrictModel):
items: list[InvestigationReportRevision]
next_offset: int | None = Field(default=None, ge=0)
class InvestigationReportReference(StrictModel):
execution_generation: PositiveInt
revision: PositiveInt
class AgentArtifactsCommand(StrictModel):
@@ -481,6 +563,7 @@ class OperationalTaskRunPage(StrictModel):
class AgentTaskContext(StrictModel):
task: OperationalTaskDetail
approved_data_scopes: list[NonEmptyStr]
selected_report: InvestigationReportRevision | None = None
class AgentRunCommand(StrictModel):
@@ -610,6 +693,7 @@ class OperationalTaskNotification(StrictModel):
lifecycle_status: Literal["active", "resolved"] = "active"
subject_type: Literal["task", "run", "access_request", "action"] = "task"
subject_id: UUID | None = None
report_reference: InvestigationReportReference | None = None
resolved_at: WorkbenchDateTime | None = None
resolution_reason: NonEmptyStr | None = None
status: Literal["unread", "read"]