from __future__ import annotations from typing import Annotated, Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime from app.domain.schemas.workbench_runtime import WorkOrderActivity Text = Annotated[str, Field(min_length=1, max_length=4000)] PositiveInt = Annotated[StrictInt, Field(ge=1)] TaskStatus = Literal["pending", "processing", "pending_review", "completed"] Scenario = Literal["normal", "missing_data", "unavailable", "response_lost", "duplicate_feedback", "conflicting_feedback", "late_state"] class Model(BaseModel): model_config = ConfigDict(extra="forbid", allow_inf_nan=False) class SourceLink(Model): system: Literal["next"] task_id: UUID object_type: Literal["validation", "artifact"] object_id: UUID revision: PositiveInt class CreateTask(Model): request_id: UUID title: Annotated[str, Field(min_length=1, max_length=200)] summary: Text source: SourceLink asset_ids: Annotated[list[Annotated[str, Field(min_length=1, max_length=128)]], Field(min_length=1, max_length=100)] analysis_window: TimeRange evidence_refs: Annotated[list[ObjectRef], Field(max_length=100)] requested_check: Text @model_validator(mode="after") def unique_assets(self): if len(set(self.asset_ids)) != len(self.asset_ids): raise ValueError("asset_ids must be unique") return self class Transition(Model): request_id: UUID expected_revision: PositiveInt target_status: TaskStatus reason: Text class AddFeedback(Model): request_id: UUID expected_revision: PositiveInt feedback_id: UUID occurred_at: WireDateTime applicable_time: TimeRange asset_ids: Annotated[list[Annotated[str, Field(min_length=1, max_length=128)]], Field(min_length=1, max_length=100)] finding: Literal["instrument_fault", "no_instrument_fault", "uncertain"] notes: Text attachment_refs: Annotated[list[Annotated[str, Field(min_length=1, max_length=500)]], Field(max_length=20)] @model_validator(mode="after") def aware_observation(self): if self.occurred_at.tzinfo is None: raise ValueError("occurred_at must include timezone") if len(set(self.asset_ids)) != len(self.asset_ids): raise ValueError("asset_ids must be unique") return self class Feedback(Model): feedback_id: UUID task_id: UUID recorded_by: str recorded_at: WireDateTime occurred_at: WireDateTime applicable_time: TimeRange asset_ids: list[str] finding: Literal["instrument_fault", "no_instrument_fault", "uncertain"] notes: str attachment_refs: list[str] origin: Literal["synthetic"] class HistoryEntry(Model): sequence: PositiveInt from_status: TaskStatus | None to_status: TaskStatus actor: str at: WireDateTime reason: str class Task(Model): task_id: UUID project_id: UUID revision: PositiveInt title: str summary: str source: SourceLink asset_ids: list[str] analysis_window: TimeRange evidence_refs: list[ObjectRef] requested_check: str status: TaskStatus origin: Literal["synthetic"] created_at: WireDateTime updated_at: WireDateTime created_by: str history: list[HistoryEntry] feedback: list[Feedback] class TaskPage(Model): items: list[Task] next_cursor: UUID | None WorkOrderStatus = Literal[ "draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled" ] Priority = Literal["low", "normal", "high", "urgent"] WorkOrderType = Literal["general", "water_quality_complaint", "burst_report"] class CreateWorkOrder(Model): request_id: UUID title: Annotated[str, Field(min_length=1, max_length=200)] summary: Text status: WorkOrderStatus = "draft" priority: Priority = "normal" assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None scheduled_at: WireDateTime | None = None work_order_type: WorkOrderType = "general" location: Annotated[str, Field(min_length=1, max_length=200)] | None = None scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None feedback_requirements: list[Text] = Field(default_factory=list) @model_validator(mode="after") def aware_schedule(self): if self.scheduled_at is not None and self.scheduled_at.tzinfo is None: raise ValueError("scheduled_at must include timezone") return self class UpdateWorkOrder(Model): request_id: UUID expected_revision: PositiveInt title: Annotated[str, Field(min_length=1, max_length=200)] | None = None summary: Text | None = None status: WorkOrderStatus | None = None priority: Priority | None = None assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None clear_assignee: StrictBool = False scheduled_at: WireDateTime | None = None clear_scheduled_at: StrictBool = False location: Annotated[str, Field(min_length=1, max_length=200)] | None = None clear_location: StrictBool = False scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None clear_scenario_code: StrictBool = False feedback_requirements: list[Text] | None = None progress_note: Text | None = None @model_validator(mode="after") def valid_update(self): changed = set(self.model_fields_set) - {"request_id", "expected_revision"} if not changed: raise ValueError("at least one work order field must be updated") if self.clear_assignee and self.assignee is not None: raise ValueError("clear_assignee conflicts with assignee") if self.clear_scheduled_at and self.scheduled_at is not None: raise ValueError("clear_scheduled_at conflicts with scheduled_at") if self.clear_location and self.location is not None: raise ValueError("clear_location conflicts with location") if self.clear_scenario_code and self.scenario_code is not None: raise ValueError("clear_scenario_code conflicts with scenario_code") if self.scheduled_at is not None and self.scheduled_at.tzinfo is None: raise ValueError("scheduled_at must include timezone") return self class DeleteResource(Model): request_id: UUID expected_revision: PositiveInt class WorkOrder(Model): work_order_id: UUID project_id: UUID revision: PositiveInt title: str summary: str status: WorkOrderStatus priority: Priority assignee: str | None scheduled_at: WireDateTime | None origin: Literal["synthetic"] created_at: WireDateTime updated_at: WireDateTime created_by: str updated_by: str work_order_type: WorkOrderType = "general" location: str | None = None scenario_code: str | None = None feedback_requirements: list[str] = Field(default_factory=list) activity: list[WorkOrderActivity] = Field(default_factory=list) @model_validator(mode="before") @classmethod def accept_legacy_document(cls, value): if isinstance(value, dict): return {key: item for key, item in value.items() if key != "subtasks"} return value class WorkOrderPage(Model): items: list[WorkOrder] next_cursor: UUID | None class CreateTestDataRecord(Model): request_id: UUID label: Annotated[str, Field(min_length=1, max_length=200)] point_id: Annotated[str, Field(min_length=1, max_length=128)] observed_at: WireDateTime value: StrictFloat | StrictInt | None unit: Annotated[str, Field(min_length=1, max_length=32)] quality: Literal["valid", "unknown", "missing"] = "valid" notes: Annotated[str, Field(max_length=4000)] = "" @model_validator(mode="after") def aware_observation(self): if self.observed_at.tzinfo is None: raise ValueError("observed_at must include timezone") return self class UpdateTestDataRecord(Model): request_id: UUID expected_revision: PositiveInt label: Annotated[str, Field(min_length=1, max_length=200)] | None = None point_id: Annotated[str, Field(min_length=1, max_length=128)] | None = None observed_at: WireDateTime | None = None value: StrictFloat | StrictInt | None = None clear_value: StrictBool = False unit: Annotated[str, Field(min_length=1, max_length=32)] | None = None quality: Literal["valid", "unknown", "missing"] | None = None notes: Annotated[str, Field(max_length=4000)] | None = None @model_validator(mode="after") def valid_update(self): changed = set(self.model_fields_set) - {"request_id", "expected_revision"} if not changed: raise ValueError("at least one test data field must be updated") if self.clear_value and self.value is not None: raise ValueError("clear_value conflicts with value") if self.observed_at is not None and self.observed_at.tzinfo is None: raise ValueError("observed_at must include timezone") return self class TestDataRecord(Model): record_id: UUID project_id: UUID revision: PositiveInt label: str point_id: str observed_at: WireDateTime value: float | int | None unit: str quality: Literal["valid", "unknown", "missing"] notes: str origin: Literal["synthetic"] created_at: WireDateTime updated_at: WireDateTime created_by: str updated_by: str class TestDataRecordPage(Model): items: list[TestDataRecord] next_cursor: UUID | None class DeletedResource(Model): resource_type: Literal["work_order", "test_data_record"] resource_id: UUID revision: Annotated[StrictInt, Field(ge=2)] deleted: Literal[True] class Receipt(Model): request_id: UUID task_id: UUID | None result: Task | ScenarioState | WorkOrder | TestDataRecord | DeletedResource class ScenarioCommand(Model): request_id: UUID scenario: Scenario class ScenarioState(Model): scenario: Scenario revision: PositiveInt ResourceAvailability = Literal["available", "not_configured"] class ManagedResourceCapabilities(Model): work_orders: ResourceAvailability test_data: ResourceAvailability class Context(Model): protocol_version: Literal["tjwater-peripheral-sample@1"] origin: Literal["synthetic"] project_id: UUID user_id: UUID can_write: StrictBool scenarios: list[Scenario] state_transitions: dict[str, list[str]] managed_resources: ManagedResourceCapabilities class Point(Model): point_id: str label: str unit: str status: str origin: Literal["historical_replay", "synthetic"] class PointPage(Model): items: list[Point] source_id: str origin: Literal["historical_replay", "synthetic"] next_cursor: str | None class Observation(Model): record_id: str point_id: str observed_at: WireDateTime value: float | None quality: Literal["valid", "unknown", "missing"] unit: str class Observations(Model): origin: Literal["historical_replay", "synthetic"] source_id: str source_revision: str items: list[Observation] truncated: StrictBool class Alert(Model): alert_id: str point_id: str occurred_at: WireDateTime message: str origin: Literal["historical_replay", "synthetic"] class AlertPage(Model): items: list[Alert] source_id: str origin: Literal["historical_replay", "synthetic"] availability: Literal["available", "not_configured"] reason: str | None class SampleError(Model): code: str message: str request_id: UUID | None effect: Literal["none", "unknown"] class DemoDeviceState(Model): record_id: UUID asset_id: str asset_type: Literal["pressure_sensor", "pump", "valve"] observed_at: WireDateTime state: Literal["online", "offline", "running", "stopped", "open", "closed"] class DemoAlert(Model): alert_id: UUID asset_id: str occurred_at: WireDateTime cleared_at: WireDateTime | None code: Literal["communication_lost", "low_level"] message: str class DemoOperation(Model): record_id: UUID asset_id: str occurred_at: WireDateTime operation: Literal["pump_start", "pump_stop", "valve_open", "valve_close"] actor: Literal["synthetic-generator"] note: str class DemoContent(Model): generator_version: Literal["water-demo@2"] seed: str origin: Literal["synthetic"] window: TimeRange points: list[Point] observations: list[Observation] device_states: list[DemoDeviceState] alerts: list[DemoAlert] operations: list[DemoOperation] class DemoDataset(DemoContent): project_id: UUID source_id: str source_revision: str Receipt.model_rebuild()