feat: extend operational investigation workflows
This commit is contained in:
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from jsonschema import Draft7Validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator
|
||||
|
||||
_catalog = json.loads(
|
||||
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
||||
@@ -34,9 +34,24 @@ class AnswerUiSpec(UiModel):
|
||||
class AnswerUiBlock(UiModel):
|
||||
version: Literal["answer-ui@1"]
|
||||
block_id: Key
|
||||
phase: Literal["process", "final"] | None = None
|
||||
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
||||
spec: AnswerUiSpec
|
||||
|
||||
@field_validator("phase", mode="before")
|
||||
@classmethod
|
||||
def validate_explicit_phase(cls, value):
|
||||
if value is None:
|
||||
raise ValueError("UI phase must be process or final when supplied")
|
||||
return value
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def serialize_optional_phase(self, handler):
|
||||
data = handler(self)
|
||||
if self.phase is None:
|
||||
data.pop("phase", None)
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_components(self):
|
||||
spec = self.spec
|
||||
@@ -83,6 +98,12 @@ class ReportUiBlock(UiModel):
|
||||
block: AnswerUiBlock
|
||||
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_report_phase(self):
|
||||
if self.block.phase == "process":
|
||||
raise ValueError("Report UI blocks must contain final results")
|
||||
return self
|
||||
|
||||
|
||||
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
||||
ids = [item.block.block_id for item in blocks]
|
||||
|
||||
@@ -8,6 +8,7 @@ from uuid import UUID
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
|
||||
|
||||
from app.domain.schemas.task_question import TaskQuestionRequest
|
||||
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 (
|
||||
@@ -218,8 +219,12 @@ class InvestigationDiagramNodeRef(StrictModel):
|
||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||
action_refs: list[UUID] = Field(default_factory=list)
|
||||
status: Literal[
|
||||
"pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"
|
||||
] | None = None
|
||||
"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)
|
||||
|
||||
|
||||
@@ -240,6 +245,21 @@ class InvestigationDiagram(StrictModel):
|
||||
return self
|
||||
|
||||
|
||||
def _undeclared_diagram_node_refs(diagram: InvestigationDiagram | None) -> list[str]:
|
||||
if diagram is None:
|
||||
return []
|
||||
missing: list[str] = []
|
||||
for node in diagram.node_refs:
|
||||
escaped = re.escape(node.node_id)
|
||||
if not re.search(
|
||||
rf"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|--|==|-\.|$))",
|
||||
diagram.mermaid,
|
||||
re.MULTILINE,
|
||||
):
|
||||
missing.append(node.node_id)
|
||||
return missing
|
||||
|
||||
|
||||
class InvestigationReport(StrictModel):
|
||||
"""Read model: absent historical facts remain unknown, never inferred."""
|
||||
|
||||
@@ -341,9 +361,19 @@ class AgentArtifactsCommand(StrictModel):
|
||||
progress: InvestigationProgress | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_artifact(self):
|
||||
def validate_artifacts(self):
|
||||
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
|
||||
raise ValueError("at least one artifact must be supplied")
|
||||
for diagram in (
|
||||
self.report.diagram if self.report is not None else None,
|
||||
self.progress.diagram if self.progress is not None else None,
|
||||
):
|
||||
missing = _undeclared_diagram_node_refs(diagram)
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"diagram node references must use ids declared in Mermaid source: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@@ -502,7 +532,7 @@ class ExternalWorkOrderSnapshot(StrictModel):
|
||||
scenario_code: NonEmptyStr | None = None
|
||||
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
|
||||
activity: list[WorkOrderActivity] = Field(default_factory=list)
|
||||
origin: Literal["synthetic"]
|
||||
origin: Literal["system_data"]
|
||||
updated_at: WorkbenchDateTime
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -521,6 +551,7 @@ class OperationalTaskRun(StrictModel):
|
||||
status: OperationalTaskRunStatus
|
||||
revision: PositiveInt
|
||||
agent_session_id: NonEmptyStr | None = None
|
||||
agent_instance_id: NonEmptyStr | None = None
|
||||
lease_expires_at: WorkbenchDateTime | None = None
|
||||
heartbeat_at: WorkbenchDateTime | None = None
|
||||
started_at: WorkbenchDateTime | None = None
|
||||
@@ -570,6 +601,7 @@ class OperationalTaskPermissions(StrictModel):
|
||||
can_decide_commands: bool
|
||||
can_execute_commands: bool
|
||||
can_decide_access: bool = False
|
||||
can_answer_questions: bool = False
|
||||
|
||||
|
||||
class OperationalTaskDetail(OperationalTaskSummary):
|
||||
@@ -579,6 +611,7 @@ class OperationalTaskDetail(OperationalTaskSummary):
|
||||
evidence: list[EvidenceRecord]
|
||||
actions: list[HumanApprovalAction]
|
||||
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
|
||||
questions: list[TaskQuestionRequest] = Field(default_factory=list)
|
||||
permissions: OperationalTaskPermissions
|
||||
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
||||
|
||||
@@ -605,6 +638,7 @@ class AgentRunCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_revision: PositiveInt
|
||||
source_session_id: NonEmptyStr
|
||||
agent_instance_id: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class AgentRunFailureCommand(AgentRunCommand):
|
||||
@@ -671,7 +705,59 @@ class TaskHighlightFeatures(StrictModel):
|
||||
action: Literal["highlight_features"]
|
||||
layer: TaskMapLayer
|
||||
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||
effect: Literal["steady", "pulse"] = "steady"
|
||||
effect: Literal["steady", "pulse"]
|
||||
|
||||
|
||||
class TaskLocateFeatures(StrictModel):
|
||||
action: Literal["locate_features"]
|
||||
layer: TaskMapLayer
|
||||
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||
|
||||
|
||||
class TaskZoomToMap(StrictModel):
|
||||
action: Literal["zoom_to_map"]
|
||||
center: tuple[
|
||||
Annotated[float, Field(strict=True, ge=-180, le=180, allow_inf_nan=False)],
|
||||
Annotated[float, Field(strict=True, ge=-90, le=90, allow_inf_nan=False)],
|
||||
]
|
||||
zoom: Annotated[float, Field(strict=True, ge=0, le=24, allow_inf_nan=False)]
|
||||
|
||||
|
||||
class TaskScadaAnalysisItem(StrictModel):
|
||||
device_id: TaskFeatureId
|
||||
level: Literal["high", "medium", "low", "unrated"]
|
||||
|
||||
|
||||
class TaskRenderScadaAnalysis(StrictModel):
|
||||
action: Literal["render_scada_analysis"]
|
||||
items: Annotated[list[TaskScadaAnalysisItem], Field(min_length=1, max_length=100)]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_devices(self):
|
||||
if len({item.device_id for item in self.items}) != len(self.items):
|
||||
raise ValueError("SCADA analysis device IDs must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class TaskClearScadaAnalysis(StrictModel):
|
||||
action: Literal["clear_scada_analysis"]
|
||||
|
||||
|
||||
class TaskOverlayGeometry(StrictModel):
|
||||
type: Literal["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]
|
||||
coordinates: list[JsonValue]
|
||||
|
||||
|
||||
class TaskOverlayFeature(StrictModel):
|
||||
type: Literal["Feature"]
|
||||
geometry: TaskOverlayGeometry
|
||||
properties: dict[str, JsonValue] | None
|
||||
id: Annotated[str, Field(strict=True)] | Annotated[float, Field(strict=True, allow_inf_nan=False)] | None = None
|
||||
|
||||
|
||||
class TaskOverlayData(StrictModel):
|
||||
type: Literal["FeatureCollection"]
|
||||
features: Annotated[list[TaskOverlayFeature], Field(min_length=1, max_length=1000)]
|
||||
|
||||
|
||||
class TaskClearHighlights(StrictModel):
|
||||
@@ -696,6 +782,7 @@ class TaskSetOverlay(StrictModel):
|
||||
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||
mode: Literal["geometry", "heatmap"]
|
||||
evidence_id: NonEmptyStr
|
||||
data: TaskOverlayData | None = None
|
||||
|
||||
|
||||
class TaskClearOverlay(StrictModel):
|
||||
@@ -704,7 +791,8 @@ class TaskClearOverlay(StrictModel):
|
||||
|
||||
|
||||
TaskPresentationCommand = Annotated[
|
||||
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
||||
TaskHighlightFeatures | TaskLocateFeatures | TaskZoomToMap | TaskRenderScadaAnalysis
|
||||
| TaskClearScadaAnalysis | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
||||
Field(discriminator="action"),
|
||||
]
|
||||
|
||||
@@ -736,6 +824,23 @@ class TaskPresentationCursor(StrictModel):
|
||||
event_cursor: int = Field(ge=0)
|
||||
|
||||
|
||||
class TaskPresentationHistoryItem(StrictModel):
|
||||
event_seq: PositiveInt
|
||||
run_id: UUID
|
||||
execution_generation: PositiveInt
|
||||
occurred_at: WorkbenchDateTime
|
||||
# Stored records are validated against the current command contract by the
|
||||
# reader; an obsolete record must not prevent reading the rest of the page.
|
||||
command: dict[str, JsonValue]
|
||||
|
||||
|
||||
class TaskPresentationHistoryPage(StrictModel):
|
||||
task_id: UUID
|
||||
event_cursor: int = Field(ge=0)
|
||||
items: list[TaskPresentationHistoryItem]
|
||||
next_after: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class TaskDataQueryResult(StrictModel):
|
||||
evidence: EvidenceRecord
|
||||
snapshot: TaskDataSnapshot
|
||||
@@ -816,7 +921,7 @@ class OperationalTaskNotification(StrictModel):
|
||||
description: NonEmptyStr
|
||||
required_permission: NonEmptyStr | None = None
|
||||
lifecycle_status: Literal["active", "resolved"] = "active"
|
||||
subject_type: Literal["task", "run", "access_request", "action"] = "task"
|
||||
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
||||
subject_id: UUID | None = None
|
||||
report_reference: InvestigationReportReference | None = None
|
||||
resolved_at: WorkbenchDateTime | None = None
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Durable investigation questions; answers are user input, not field evidence."""
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.domain.schemas.shared import StrictModel, PositiveInt
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||
|
||||
Id = Annotated[str, Field(min_length=1, max_length=128)]
|
||||
|
||||
|
||||
class TaskQuestionOption(StrictModel):
|
||||
id: Id
|
||||
label: Annotated[str, Field(min_length=1, max_length=200)]
|
||||
|
||||
|
||||
class TaskQuestion(StrictModel):
|
||||
question_id: Id
|
||||
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||
body: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
options: list[TaskQuestionOption] = Field(max_length=12)
|
||||
mode: Literal["single", "multiple"]
|
||||
allow_custom: bool
|
||||
required: bool
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_options(self):
|
||||
if len({o.id for o in self.options}) != len(self.options):
|
||||
raise ValueError("duplicate option ids")
|
||||
if not self.options and not self.allow_custom:
|
||||
raise ValueError("question must offer an answer input")
|
||||
return self
|
||||
|
||||
|
||||
class TaskQuestionAnswer(StrictModel):
|
||||
question_id: Id
|
||||
selected_option_ids: list[Id] = Field(max_length=12)
|
||||
custom_text: Annotated[str, Field(max_length=2000)]
|
||||
|
||||
|
||||
class TaskQuestionCreate(StrictModel):
|
||||
request_id: UUID
|
||||
source_session_id: Id
|
||||
run_id: UUID
|
||||
questions: list[TaskQuestion] = Field(min_length=1, max_length=3)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_questions(self):
|
||||
if len({q.question_id for q in self.questions}) != len(self.questions):
|
||||
raise ValueError("duplicate question ids")
|
||||
if len(self.model_dump_json().encode()) > 32768:
|
||||
raise ValueError("question definition too large")
|
||||
return self
|
||||
|
||||
|
||||
class TaskQuestionReply(StrictModel):
|
||||
request_id: UUID
|
||||
expected_version: PositiveInt
|
||||
answers: list[TaskQuestionAnswer] = Field(max_length=3)
|
||||
|
||||
|
||||
class TaskQuestionRequest(StrictModel):
|
||||
question_request_id: UUID
|
||||
task_id: UUID
|
||||
run_id: UUID
|
||||
execution_generation: PositiveInt
|
||||
source_session_id: Id
|
||||
questions: list[TaskQuestion]
|
||||
status: Literal["pending", "answered", "cancelled"]
|
||||
version: PositiveInt
|
||||
created_at: WorkbenchDateTime
|
||||
answered_at: WorkbenchDateTime | None = None
|
||||
answered_by: str | None = None
|
||||
answers: list[TaskQuestionAnswer] = Field(default_factory=list)
|
||||
Reference in New Issue
Block a user