"""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, StringConstraints, field_validator, model_serializer, model_validator from app.domain.schemas.answer_ui import ( CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, 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.report_authoring import ( validate_presentation_command_text, validate_report_text, validate_report_ui_text, ) from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource from app.domain.schemas.workbench_runtime import ( WorkbenchDateTime, WorkOrderActivity, ) TaskType = Literal["scheduled_flow", "external_work_order", "event_investigation"] TaskStatus = Literal[ "queued", "running", "waiting_human", "completed", "failed", "cancelled" ] TaskLifecycleStatus = Literal[ "open", "waiting_human", "completed", "failed", "cancelled" ] OperationalTaskRunStatus = Literal[ "queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled" ] TaskPriority = Literal["normal", "urgent"] TaskSourceKind = Literal["schedule", "external_system", "operator", "agent"] TriggerKind = Literal["schedule", "external_sync", "event", "manual"] EvidenceKind = Literal["system_data", "external_feedback", "human_input", "synthetic"] PlanNodeStatus = Literal[ "pending", "waiting", "running", "completed", "failed", "skipped", "cancelled" ] ActionKind = Literal["create_work_order", "issue_command"] ApprovalStatus = Literal["pending", "approved", "rejected"] ExecutionStatus = Literal[ "not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown" ] class ScadaReplayTaskContext(StrictModel): replay_id: UUID knowledge_cutoff: WorkbenchDateTime batch_sequence: PositiveInt class TaskSource(StrictModel): kind: TaskSourceKind external_id: NonEmptyStr | None = None external_revision: PositiveInt | None = None event_kind: NonEmptyStr | None = None deleted_at: WorkbenchDateTime | None = None replay_context: ScadaReplayTaskContext | None = None class TaskTrigger(StrictModel): kind: TriggerKind occurred_at: WorkbenchDateTime detail: NonEmptyStr class OperationalTaskCreate(StrictModel): request_id: UUID task_type: TaskType workflow: NonEmptyStr title: NonEmptyStr summary: NonEmptyStr priority: TaskPriority = "normal" source: TaskSource trigger: TaskTrigger scheduled_for: WorkbenchDateTime | None = None agent_session_id: NonEmptyStr | None = None class ExecutionPlanNode(StrictModel): id: NonEmptyStr title: NonEmptyStr owner: Literal["agent", "human"] status: PlanNodeStatus detail: NonEmptyStr evidence_refs: list[NonEmptyStr] = Field(default_factory=list) action_refs: list[UUID] = Field(default_factory=list) kind: Literal["activity", "decision", "parallel_split", "parallel_join", "gate"] | None = None selected_edge_id: NonEmptyStr | None = None gate_ref: UUID | None = None revisits_node_id: NonEmptyStr | None = None class ExecutionPlanEdge(StrictModel): id: NonEmptyStr | None = None from_node: NonEmptyStr = Field(alias="from") to_node: NonEmptyStr = Field(alias="to") condition: NonEmptyStr | None = None class ExecutionPlanInput(StrictModel): schema_name: Literal["execution-plan@1", "execution-plan@2"] = Field(alias="schema") title: NonEmptyStr reason: NonEmptyStr nodes: Annotated[list[ExecutionPlanNode], Field(min_length=1, max_length=100)] edges: Annotated[list[ExecutionPlanEdge], Field(max_length=300)] = Field( default_factory=list ) @model_validator(mode="after") def validate_graph(self): node_ids = [node.id for node in self.nodes] if len(set(node_ids)) != len(node_ids): raise ValueError("execution plan node ids must be unique") known = set(node_ids) graph = {node_id: [] for node_id in node_ids} indegree = {node_id: 0 for node_id in node_ids} seen_edges: set[tuple[str, str]] = set() edge_ids: set[str] = set() for edge in self.edges: pair = (edge.from_node, edge.to_node) if edge.from_node not in known or edge.to_node not in known: raise ValueError("execution plan edges must reference known nodes") if edge.from_node == edge.to_node or pair in seen_edges: raise ValueError("execution plan edges must be unique and non-reflexive") seen_edges.add(pair) if self.schema_name == "execution-plan@2": if edge.id is None or edge.id in edge_ids: raise ValueError("v2 execution plan edge ids must be unique") edge_ids.add(edge.id) graph[edge.from_node].append(edge.to_node) indegree[edge.to_node] += 1 pending = [node_id for node_id, value in indegree.items() if value == 0] visited = 0 while pending: current = pending.pop() visited += 1 for target in graph[current]: indegree[target] -= 1 if indegree[target] == 0: pending.append(target) if visited != len(node_ids): raise ValueError("execution plan must be acyclic") if self.schema_name == "execution-plan@2" and sum( not any(edge.to_node == node_id for edge in self.edges) for node_id in node_ids ) != 1: raise ValueError("v2 execution plan requires one start node") if self.schema_name == "execution-plan@2": by_id = {node.id: node for node in self.nodes} for node in self.nodes: if node.kind is None: raise ValueError("v2 execution plan nodes require kind") outgoing = [edge for edge in self.edges if edge.from_node == node.id] incoming = [edge for edge in self.edges if edge.to_node == node.id] if node.kind == "decision": if len(outgoing) < 2 or any(not edge.condition for edge in outgoing): raise ValueError("decision requires at least two labeled routes") if node.selected_edge_id is not None and node.selected_edge_id not in {edge.id for edge in outgoing}: raise ValueError("decision selected_edge_id must reference an outgoing route") if node.status == "completed" and node.selected_edge_id is None: raise ValueError("completed decision requires a selected route") if node.selected_edge_id is not None: for edge in outgoing: target_status = by_id[edge.to_node].status if edge.id == node.selected_edge_id and target_status in {"skipped", "cancelled"}: raise ValueError("selected route cannot be skipped") if edge.id != node.selected_edge_id and target_status in {"running", "completed", "waiting"}: raise ValueError("unchosen route cannot be active") elif node.selected_edge_id is not None: raise ValueError("only decision nodes may select routes") if node.kind not in {"decision", "parallel_split"} and len(outgoing) > 1: raise ValueError("only decision or parallel split may branch") if node.kind == "parallel_split" and len(outgoing) < 2: raise ValueError("parallel split requires at least two routes") if node.kind == "parallel_join" and len(incoming) < 2: raise ValueError("parallel join requires at least two inputs") if node.kind == "gate" and node.gate_ref is None: raise ValueError("gate requires a persisted reference") if node.kind != "gate" and node.gate_ref is not None: raise ValueError("only gate nodes may have gate_ref") if node.revisits_node_id is not None and node.revisits_node_id not in by_id: raise ValueError("revisited node must exist in the plan") return self class EvidenceInput(StrictModel): evidence_id: NonEmptyStr kind: EvidenceKind summary: NonEmptyStr source_label: NonEmptyStr source_ref: NonEmptyStr | None = None observed_at: WorkbenchDateTime class EvidenceRecord(EvidenceInput): recorded_at: WorkbenchDateTime recorded_by: NonEmptyStr has_snapshot: bool = False class StoredEvidenceRecord(EvidenceRecord): data_snapshot: TaskDataSnapshot | None = None 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 TaskMapLayer = Literal["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"] TaskFeatureId = Annotated[str, Field(min_length=1, max_length=128)] class InvestigationMapTarget(StrictModel): source_id: TaskMapLayer feature_id: TaskFeatureId InvestigationNodeAnnotation = Literal["reference", "recorded", "needs_input", "failed", "not_taken"] LEGACY_INVESTIGATION_NODE_ANNOTATIONS = { "pending": "reference", "running": "reference", "completed": "recorded", "waiting": "needs_input", "waiting_human": "needs_input", "skipped": "not_taken", "cancelled": "not_taken", } class _InvestigationDiagramNode(StrictModel): node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")] label: NonEmptyStr detail: Annotated[str, Field(min_length=1, max_length=2000)] | None = None evidence_refs: list[NonEmptyStr] = Field(default_factory=list) action_refs: list[UUID] = Field(default_factory=list) map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100) class InvestigationDiagramNodeRef(_InvestigationDiagramNode): status: InvestigationNodeAnnotation | None = Field(default=None, description=( "A saved process-reference annotation, not live execution state. " "recorded means a finding exists, not that a task or field action completed." )) @field_validator("status", mode="before") @classmethod def read_legacy_annotation(cls, value: object) -> object: return LEGACY_INVESTIGATION_NODE_ANNOTATIONS.get(value, value) if isinstance(value, str) else value class InvestigationGraphNodeUpdate(_InvestigationDiagramNode): label: NonEmptyStr | None = Field(default=None, description=( "Required for a new node. Omit or use null to retain an existing node's label. " "Use the same node_id to revise status or detail without adding another step." )) status: InvestigationNodeAnnotation | None = Field(default=None, description=( "Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. " "No live node/task binding or per-step synchronization is required." )) class InvestigationGraphEdge(StrictModel): source: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")] target: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")] label: Annotated[str, Field(min_length=1, max_length=200)] | None = None class InvestigationGraphUpdate(StrictModel): title: Annotated[str, Field(min_length=1, max_length=200)] | None = None nodes: list[InvestigationGraphNodeUpdate] = Field(default_factory=list, max_length=100) edges: list[InvestigationGraphEdge] = Field(default_factory=list, max_length=200) 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) # Present only on a graph maintained by the host. Legacy Mermaid stays readable. edges: list[InvestigationGraphEdge] | None = Field(default=None, max_length=200) source_mermaid: Annotated[str, Field(min_length=1, max_length=20000)] | None = None @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 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"(? str: return validate_report_text(value) class InvestigationReportInput(InvestigationReport): """Write model: every new report explicitly declares its business result.""" title: NonEmptyStr = Field(max_length=80, description="简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳") current_conclusion: NonEmptyStr = Field(max_length=300, description="先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table") conclusion_boundary: NonEmptyStr = Field(max_length=240, description="只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数") recommendations: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list) pending_items: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list) stop_reason: Annotated[NonEmptyStr, Field(max_length=160)] | None = None stage: Literal["interim", "final"] outcome: Literal["concluded", "insufficient_data"] data_gaps: list[InvestigationDataGapInput] @field_validator("title", "current_conclusion", "conclusion_boundary", "stop_reason") @classmethod def validate_reader_text(cls, value: str | None) -> str | None: return validate_report_text(value) if value is not None else value @model_validator(mode="after") def validate_authoring(self): for value in [*self.recommendations, *self.pending_items]: validate_report_text(value) validate_report_ui_text(self.ui_blocks) return self class InvestigationProgress(StrictModel): """A complete reading snapshot, not a plan or a business result.""" summary: Annotated[str, Field(min_length=1, max_length=20000)] diagram: InvestigationDiagram | None = None class InvestigationProgressInput(InvestigationProgress): summary: NonEmptyStr = Field(max_length=300, description="用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号") graph_update: InvestigationGraphUpdate | None = None @field_validator("summary") @classmethod def validate_reader_summary(cls, value: str) -> str: return validate_report_text(value) @model_validator(mode="after") def validate_graph_input(self): if self.graph_update is not None and self.diagram is not None: raise ValueError("use graph_update without a generated Mermaid diagram") return self class InvestigationProgressSnapshot(StrictModel): revision: PositiveInt run_id: UUID execution_generation: PositiveInt published_at: WorkbenchDateTime source_session_id: NonEmptyStr progress: InvestigationProgress class ExecutionPlanSnapshot(StrictModel): revision: PositiveInt execution_generation: PositiveInt | None = None source_session_id: NonEmptyStr published_at: WorkbenchDateTime published_by: NonEmptyStr plan: ExecutionPlanInput class InvestigationReportRevision(StrictModel): revision: PositiveInt execution_generation: PositiveInt | None = None source_session_id: NonEmptyStr published_at: WorkbenchDateTime published_by: NonEmptyStr 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): request_id: UUID source_session_id: NonEmptyStr run_id: UUID | None = None evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100) plan: ExecutionPlanInput | None = None report: InvestigationReportInput | None = None progress: InvestigationProgressInput | None = None @model_validator(mode="after") 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, ): if diagram is not None and (diagram.edges is not None or diagram.source_mermaid is not None): raise ValueError("structured graph metadata is host-owned; use progress.graph_update") missing = _undeclared_diagram_node_refs(diagram) if missing: raise ValueError( "diagram node references must use ids declared in Mermaid source: " + ", ".join(missing) ) return self class TaskAccessRequestCreate(StrictModel): request_id: UUID source_session_id: NonEmptyStr node_id: NonEmptyStr scopes: Annotated[list[TaskDataSource], Field(min_length=1, max_length=7)] reason: NonEmptyStr class TaskAccessRequestDecision(StrictModel): request_id: UUID expected_version: PositiveInt decision: Literal["approved", "rejected"] approved_scopes: list[TaskDataSource] = Field(default_factory=list) comment: NonEmptyStr | None = None class TaskAccessRequest(StrictModel): access_request_id: UUID task_id: UUID node_id: NonEmptyStr scopes: list[TaskDataSource] reason: NonEmptyStr status: Literal["pending", "approved", "rejected"] approved_scopes: list[TaskDataSource] version: PositiveInt requested_by: NonEmptyStr requested_at: WorkbenchDateTime decided_by: NonEmptyStr | None = None decided_at: WorkbenchDateTime | None = None comment: NonEmptyStr | None = None class CreateWorkOrderPayload(StrictModel): title: NonEmptyStr description: NonEmptyStr work_order_type: NonEmptyStr priority: TaskPriority = "normal" scheduled_at: WorkbenchDateTime | None = None location: NonEmptyStr | None = None assignee: NonEmptyStr | None = None class CommandTarget(StrictModel): kind: NonEmptyStr id: NonEmptyStr class IssueCommandPayload(StrictModel): command_type: NonEmptyStr target: CommandTarget parameters: dict[str, JsonValue] impact_summary: NonEmptyStr preconditions: list[NonEmptyStr] = Field(min_length=1) expires_at: WorkbenchDateTime ActionPayload = CreateWorkOrderPayload | IssueCommandPayload class ActionProposalCommand(StrictModel): request_id: UUID kind: ActionKind reason: NonEmptyStr payload: ActionPayload evidence_refs: list[NonEmptyStr] = Field(default_factory=list) node_id: NonEmptyStr | None = None @model_validator(mode="after") def validate_payload_kind(self): expected = CreateWorkOrderPayload if self.kind == "create_work_order" else IssueCommandPayload if not isinstance(self.payload, expected): raise ValueError("action payload does not match its kind") return self class ActionDecisionCommand(StrictModel): request_id: UUID expected_version: PositiveInt decision: Literal["approved", "rejected"] comment: NonEmptyStr | None = None class WorkOrderDraftUpdateCommand(StrictModel): request_id: UUID expected_version: PositiveInt payload: CreateWorkOrderPayload class WorkOrderConfirmDispatchCommand(StrictModel): request_id: UUID expected_version: PositiveInt class ActionExecuteCommand(StrictModel): request_id: UUID expected_version: PositiveInt class ActionReconcileCommand(StrictModel): request_id: UUID expected_version: PositiveInt class ActionReceipt(StrictModel): adapter: NonEmptyStr receipt_id: NonEmptyStr submitted_at: WorkbenchDateTime completed_at: WorkbenchDateTime | None = None external_status: NonEmptyStr detail: NonEmptyStr class HumanApprovalAction(StrictModel): action_id: UUID task_id: UUID version: PositiveInt kind: ActionKind reason: NonEmptyStr payload: ActionPayload evidence_refs: list[NonEmptyStr] node_id: NonEmptyStr | None = None required_approval_permission: NonEmptyStr required_execution_permission: NonEmptyStr approval_status: ApprovalStatus execution_status: ExecutionStatus execution_request_id: UUID | None = None proposed_by: NonEmptyStr proposed_at: WorkbenchDateTime edited_by: NonEmptyStr | None = None edited_at: WorkbenchDateTime | None = None decided_by: NonEmptyStr | None = None decided_at: WorkbenchDateTime | None = None decision_comment: NonEmptyStr | None = None executed_by: NonEmptyStr | None = None executed_at: WorkbenchDateTime | None = None receipt: ActionReceipt | None = None execution_detail: NonEmptyStr | None = None class ExternalWorkOrderSnapshot(StrictModel): work_order_id: UUID revision: PositiveInt title: NonEmptyStr summary: NonEmptyStr status: Literal[ "draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled" ] priority: Literal["low", "normal", "high", "urgent"] assignee: NonEmptyStr | None = None scheduled_at: WorkbenchDateTime | None = None work_order_type: Literal["general", "water_quality_complaint", "burst_report"] = "general" location: NonEmptyStr | None = None scenario_code: NonEmptyStr | None = None feedback_requirements: list[NonEmptyStr] = Field(default_factory=list) activity: list[WorkOrderActivity] = Field(default_factory=list) origin: Literal["system_data"] updated_at: WorkbenchDateTime @model_validator(mode="before") @classmethod def accept_legacy_snapshot(cls, value): if isinstance(value, dict): return {key: item for key, item in value.items() if key != "subtasks"} return value class OperationalTaskRun(StrictModel): run_id: UUID task_id: UUID generation: PositiveInt execution_request_id: UUID 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 finished_at: WorkbenchDateTime | None = None failure_code: NonEmptyStr | None = None failure_detail: NonEmptyStr | None = None dispatch_attempts: int = Field(default=0, ge=0) next_dispatch_at: WorkbenchDateTime created_at: WorkbenchDateTime updated_at: WorkbenchDateTime class OperationalTaskSummary(StrictModel): execution_scope: NonEmptyStr project_id: UUID task_id: UUID task_type: TaskType workflow: NonEmptyStr lifecycle_status: TaskLifecycleStatus = "open" active_run: OperationalTaskRun | None = None latest_run: OperationalTaskRun | None = None # Compatibility fields for the staged 014 -> 015 rollout. New consumers # must use lifecycle_status and active_run. status: TaskStatus priority: TaskPriority revision: PositiveInt execution_generation: PositiveInt execution_request_id: UUID title: NonEmptyStr summary: NonEmptyStr handling_context: dict[str, JsonValue] = Field(default_factory=dict) source: TaskSource trigger: TaskTrigger scheduled_for: WorkbenchDateTime | None agent_session_id: NonEmptyStr | None agent_lease_expires_at: WorkbenchDateTime | None current_plan_revision: PositiveInt | None current_report_revision: PositiveInt | None created_at: WorkbenchDateTime updated_at: WorkbenchDateTime class OperationalTaskPermissions(StrictModel): can_publish_artifacts: bool can_propose_actions: bool can_sync_external_feedback: bool can_decide_work_orders: bool can_execute_work_orders: bool can_decide_commands: bool can_execute_commands: bool can_decide_access: bool = False can_answer_questions: bool = False class OperationalTaskDetail(OperationalTaskSummary): latest_plan: ExecutionPlanSnapshot | None latest_report: InvestigationReportRevision | None latest_progress: InvestigationProgressSnapshot | None = None 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 class OperationalTaskPage(StrictModel): items: list[OperationalTaskSummary] limit: PositiveInt offset: int = Field(ge=0) next_offset: int | None = Field(default=None, ge=0) class OperationalTaskRunPage(StrictModel): items: list[OperationalTaskRun] next_cursor: NonEmptyStr | None = None class AgentTaskContext(StrictModel): task: OperationalTaskDetail approved_data_scopes: list[NonEmptyStr] selected_report: InvestigationReportRevision | None = None class AgentRunCommand(StrictModel): request_id: UUID expected_revision: PositiveInt source_session_id: NonEmptyStr agent_instance_id: NonEmptyStr | None = None class AgentRunFailureCommand(AgentRunCommand): failure_code: NonEmptyStr failure_detail: NonEmptyStr class AgentRunCancelCommand(StrictModel): request_id: UUID expected_revision: PositiveInt reason: NonEmptyStr class AgentRunDispatchFailureCommand(StrictModel): request_id: UUID expected_revision: PositiveInt failure_code: NonEmptyStr failure_detail: NonEmptyStr retryable: bool = True class OperationalTaskRetryCommand(StrictModel): request_id: UUID reason: NonEmptyStr class RecoverExpiredLeasesCommand(StrictModel): limit: int = Field(default=20, ge=1, le=100) task_type: TaskType | None = None task_created_from: WorkbenchDateTime | None = None class RecoverExpiredLeasesResult(StrictModel): task_ids: list[UUID] recovered_at: WorkbenchDateTime class AgentArtifactsResult(StrictModel): task: OperationalTaskDetail plan_revision: PositiveInt | None report_revision: PositiveInt | None progress_revision: PositiveInt | None = None class ScadaChartGap(StrictModel): device_id: TaskFeatureId day_offset: Annotated[int, Field(ge=0, le=7)] missing_count: Annotated[int, Field(ge=1, le=100)] reason: Literal["no_readings", "outside_frozen_scope", "outside_frozen_window", "no_scheduled_samples"] class ScadaChartContext(StrictModel): role: Literal["current", "comparison"] metric: Literal["pressure", "flow", "quality"] unit: Annotated[str, Field(min_length=1, max_length=128)] window_start: WorkbenchDateTime window_end: WorkbenchDateTime value_field: Literal["monitored_value", "cleaned_value"] = "monitored_value" gaps: list[ScadaChartGap] = Field(default_factory=list, max_length=8) class TaskSafeChartSeries(StrictModel): name: Annotated[str, Field(max_length=128)] device_id: TaskFeatureId | None = None day_offset: Annotated[int, Field(ge=0, le=7)] | None = None type: Literal["line", "bar", "scatter"] | None = None data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)] | None], Field(min_length=1, max_length=100)] sample_times: Annotated[list[Annotated[str, Field( strict=True, max_length=64, pattern=CHART_SAMPLE_TIME_PATTERN, )] | None], Field(min_length=1, max_length=100)] | None = None @model_serializer(mode="wrap") def serialize_optional_fields(self, handler): return {key: value for key, value in handler(self).items() if value is not None} @model_validator(mode="after") def validate_sample_times(self): if self.sample_times is not None: if len(self.sample_times) != len(self.data): raise ValueError("chart sample_times must align with data") for value, at in zip(self.data, self.sample_times, strict=True): if at is not None: if value is None: raise ValueError("chart sample_times require an observed value") validate_chart_sample_time(at) return self class TaskSafeChart(StrictModel): title: Annotated[str, Field(max_length=200)] scada: ScadaChartContext | None = None chart_type: Literal["line", "bar", "time_comparison"] x_axis_name: Annotated[str, Field(max_length=128)] | None = None y_axis_name: Annotated[str, Field(max_length=128)] | None = None x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)] series: Annotated[list[TaskSafeChartSeries], Field(min_length=1, max_length=8)] @model_serializer(mode="wrap") def serialize_optional_fields(self, handler): return {key: value for key, value in handler(self).items() if value is not None} @model_validator(mode="after") def validate_alignment(self): if any(len(series.data) != len(self.x_data) for series in self.series): raise ValueError("chart series must align with x_data") return self 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"] 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): action: Literal["clear_highlights"] class TaskOpenChart(StrictModel): action: Literal["open_chart"] chart_id: TaskFeatureId chart: TaskSafeChart evidence_refs: Annotated[list[NonEmptyStr], Field(min_length=1, max_length=100)] class TaskCloseChart(StrictModel): action: Literal["close_chart"] chart_id: TaskFeatureId class TaskSetOverlay(StrictModel): action: Literal["set_overlay"] overlay_id: TaskFeatureId title: Annotated[str, Field(min_length=1, max_length=200)] mode: Literal["geometry", "heatmap"] evidence_id: NonEmptyStr data: TaskOverlayData | None = None class TaskClearOverlay(StrictModel): action: Literal["clear_overlay"] overlay_id: TaskFeatureId TaskPresentationCommand = Annotated[ TaskHighlightFeatures | TaskLocateFeatures | TaskZoomToMap | TaskRenderScadaAnalysis | TaskClearScadaAnalysis | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay, Field(discriminator="action"), ] class TaskPresentationNarration(StrictModel): stage: Literal[ "anomaly_detection", "evidence_verification", "asset_localization", "event_impact", "source_control", "isolation_plan", "flushing_plan", "field_feedback", "clearance_verification", "recovery_verification", ] summary: Annotated[str, Field(strict=True, min_length=1, max_length=160)] evidence_refs: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)] next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120)] | None = None @model_validator(mode="after") def validate_narration(self): if not self.summary.strip() or (self.next_step is not None and not self.next_step.strip()): raise ValueError("presentation narration must contain readable text") if len(set(self.evidence_refs)) != len(self.evidence_refs): raise ValueError("presentation narration evidence IDs must be unique") return self TaskPresentationPurpose = Annotated[ str, StringConstraints(strict=True, strip_whitespace=True, min_length=1, max_length=160), ] class TaskPresentationNarrationInput(TaskPresentationNarration): """New live findings use business prose; saved narration remains readable.""" summary: Annotated[str, Field(strict=True, min_length=1, max_length=160, description="本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的")] next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120, description="实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行")] | None = None @field_validator("summary", "next_step") @classmethod def validate_reader_text(cls, value: str | None) -> str | None: return validate_report_text(value) if value is not None else value class TaskPresentationEventCommand(StrictModel): request_id: UUID run_id: UUID source_session_id: NonEmptyStr command: TaskPresentationCommand narration: TaskPresentationNarrationInput | None = None purpose: TaskPresentationPurpose | None = Field(default=None, description="一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现") @field_validator("purpose") @classmethod def validate_reader_purpose(cls, value: str | None) -> str | None: return validate_report_text(value) if value is not None else value @model_validator(mode="after") def validate_visible_labels(self): validate_presentation_command_text(self.command.model_dump()) return self @model_serializer(mode="wrap") def serialize_legacy_command(self, handler): value = handler(self) # Keep old idempotency digests stable when no narration was supplied. if self.narration is None: value.pop("narration", None) if self.purpose is None: value.pop("purpose", None) return value class TaskPresentationEventResult(StrictModel): recorded: Literal[True] = True event_seq: PositiveInt run_id: UUID execution_generation: PositiveInt class TaskPresentationActiveRun(StrictModel): run_id: UUID generation: PositiveInt status: OperationalTaskRunStatus class TaskPresentationCursor(StrictModel): task_id: UUID execution_generation: PositiveInt active_run: TaskPresentationActiveRun | None 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] narration: TaskPresentationNarration | None = None purpose: TaskPresentationPurpose | None = None 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 class ExternalFeedbackEvidence(StrictModel): evidence_id: NonEmptyStr kind: Literal["external_feedback", "synthetic"] = "external_feedback" summary: NonEmptyStr source_label: NonEmptyStr source_ref: NonEmptyStr | None = None observed_at: WorkbenchDateTime class ExternalFeedbackCommand(StrictModel): request_id: UUID source_event_id: NonEmptyStr source_revision: PositiveInt status: Literal[ "scheduled", "assigned", "in_progress", "awaiting_feedback", "completed", "cancelled", "failed" ] occurred_at: WorkbenchDateTime detail: NonEmptyStr evidence: ExternalFeedbackEvidence | None = None work_order_snapshot: ExternalWorkOrderSnapshot | None = None @model_validator(mode="after") def snapshot_matches_feedback_revision(self): if self.work_order_snapshot is not None: work_order_id = str(self.work_order_snapshot.work_order_id) if self.source_event_id not in { work_order_id, f"peripheral_sample:{work_order_id}" }: raise ValueError("work_order_snapshot id must match source_event_id") if self.work_order_snapshot.revision != self.source_revision: raise ValueError("work_order_snapshot revision must match source_revision") expected_status = { "draft": "scheduled", "open": "assigned", "in_progress": "in_progress", "awaiting_reply": "awaiting_feedback", "completed": "completed", "cancelled": "cancelled", }[self.work_order_snapshot.status] if self.status != expected_status: raise ValueError("work_order_snapshot status must match feedback status") return self class ExternalFeedbackResult(StrictModel): task: OperationalTaskDetail source_event_id: NonEmptyStr source_revision: PositiveInt accepted_at: WorkbenchDateTime class ExternalSourceWithdrawalCommand(StrictModel): request_id: UUID source_event_id: NonEmptyStr class HumanEvidenceCommand(StrictModel): request_id: UUID evidence: EvidenceInput class WorkOrderNotificationReference(StrictModel): source_event_id: NonEmptyStr source_revision: PositiveInt evidence_id: NonEmptyStr | None = None status: Literal["scheduled", "assigned", "in_progress", "awaiting_feedback", "completed", "cancelled", "failed"] class OperationalTaskNotification(StrictModel): notification_id: UUID task_id: UUID kind: Literal[ "task_created", "action_pending", "report_updated", "task_completed", "task_failed", "run_delivery_degraded", "access_approval_pending", "action_approval_pending", "human_input_required", "external_coordination_failed", "work_order_feedback" ] title: NonEmptyStr description: NonEmptyStr required_permission: NonEmptyStr | None = None lifecycle_status: Literal["active", "resolved"] = "active" subject_type: Literal["task", "run", "access_request", "action", "question"] = "task" subject_id: UUID | None = None report_reference: InvestigationReportReference | None = None work_order_action: HumanApprovalAction | None = None work_order_reference: WorkOrderNotificationReference | None = None resolved_at: WorkbenchDateTime | None = None resolution_reason: NonEmptyStr | None = None status: Literal["unread", "read"] version: PositiveInt created_at: WorkbenchDateTime read_at: WorkbenchDateTime | None = None read_by: NonEmptyStr | None = None class OperationalTaskNotificationPage(StrictModel): items: list[OperationalTaskNotification] limit: PositiveInt offset: int = Field(ge=0) next_offset: int | None = Field(default=None, ge=0) class NotificationAcknowledgeCommand(StrictModel): request_id: UUID expected_version: PositiveInt class OperationalTaskScheduleCreate(StrictModel): request_id: UUID schedule_key: NonEmptyStr title: NonEmptyStr summary: NonEmptyStr interval_seconds: int = Field(ge=60, le=31_536_000) next_run_at: WorkbenchDateTime priority: TaskPriority = "normal" class OperationalTaskSchedule(StrictModel): schedule_id: UUID schedule_key: NonEmptyStr title: NonEmptyStr summary: NonEmptyStr interval_seconds: int = Field(ge=60, le=31_536_000) next_run_at: WorkbenchDateTime priority: TaskPriority enabled: bool version: PositiveInt created_at: WorkbenchDateTime updated_at: WorkbenchDateTime class OperationalTaskSchedulePage(StrictModel): items: list[OperationalTaskSchedule] class OperationalTaskScheduleUpdate(StrictModel): request_id: UUID expected_version: PositiveInt enabled: bool class DispatchDueSchedulesCommand(StrictModel): limit: int = Field(default=20, ge=1, le=100) class DispatchDueSchedulesResult(StrictModel): task_ids: list[UUID] dispatched_at: WorkbenchDateTime