feat: extend operational investigation workflows
Generic Container CI/CD / test-build-publish (push) Successful in 3m53s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 63af998045
commit 05183aff93
82 changed files with 4644 additions and 330 deletions
+22 -1
View File
@@ -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]
+112 -7
View File
@@ -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
+75
View File
@@ -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)