feat: extend operational investigation workflows
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user