feat(tasks): define investigation report contract and history
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user