"""Strict business inputs and provenance envelopes shared by MCP handlers.""" from datetime import datetime, timedelta from typing import Annotated, Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator Id = Annotated[str, Field(min_length=1, max_length=160, pattern=r"^\S(?:.*\S)?$")] ElementType = Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"] SeriesType = Literal["junction", "pipe"] class StrictInput(BaseModel): model_config = ConfigDict(extra="forbid") context_handle: Annotated[str, Field(min_length=32, max_length=128)] class PageInput(StrictInput): limit: int = Field(default=20, ge=1, le=500) offset: int = Field(default=0, ge=0, le=10000) class TaskContextInput(PageInput): section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary" report_revision: int | None = Field(default=None, ge=1) data_path: list[Id] = Field(default_factory=list, max_length=8) @model_validator(mode="after") def summary_is_fixed(self): if self.section in {"summary", "skill"} and (self.data_path or self.offset): raise ValueError("Select a detail section before using data_path or offset") return self class ElementInput(StrictInput): element_type: ElementType element_id: Id class ElementPageInput(PageInput): element_type: ElementType class NearbyElementsInput(PageInput): element_type: ElementType = "pipe" center: tuple[ Annotated[float, Field(ge=-180, le=180, allow_inf_nan=False)], Annotated[float, Field(ge=-90, le=90, allow_inf_nan=False)], ] = Field(description="EPSG:4326 [longitude, latitude], directly from geocode.center") radius_m: float = Field(default=200, gt=0, le=10000, allow_inf_nan=False) limit: int = Field(default=5, ge=1, le=100) class OptionsInput(StrictInput): kind: Literal["time", "energy", "pump-energy", "network"] pump_id: Id | None = None @model_validator(mode="after") def pump_required(self): if self.kind == "pump-energy" and not self.pump_id: raise ValueError("pump_id is required for pump-energy") if self.kind != "pump-energy" and self.pump_id: raise ValueError("pump_id is only valid for pump-energy") return self class SchemaInput(StrictInput): kind: Literal[ "junction", "pipe", "valve", "pump", "tank", "reservoir", "time", "energy", "pump-energy", "option", "scada-device", "region", "pattern", "curve", "control", "rule", "quality", "source", "reaction", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand", ] class DeviceInput(StrictInput): device_id: Id class WindowInput(PageInput): start: datetime end: datetime limit: int = Field(default=20, ge=1, le=10000) @model_validator(mode="after") def window_valid(self): if self.start.tzinfo is None or self.end.tzinfo is None: raise ValueError("timestamps require explicit timezone") if not timedelta(0) < self.end - self.start <= timedelta(days=1): raise ValueError("time window must be positive and at most 24 hours") return self class ScadaInput(WindowInput): device_ids: list[Id] = Field(min_length=1, max_length=20) field: Literal["monitored_value", "cleaned_value"] = "monitored_value" class BaselineSeriesInput(WindowInput): element_type: SeriesType element_ids: list[Id] = Field(min_length=1, max_length=20) class TimeInput(StrictInput): time: datetime @model_validator(mode="after") def timezone_required(self): if self.time.tzinfo is None: raise ValueError("timestamp requires explicit timezone") return self class BaselineAtTimeInput(TimeInput): element_type: SeriesType element_id: Id class BaselineFieldInput(TimeInput): element_type: SeriesType field: Id limit: int = Field(default=20, ge=1, le=500) offset: int = Field(default=0, ge=0, le=10000) class AnalysisSeriesInput(WindowInput): run_id: UUID element_type: SeriesType element_id: Id field: Id class AnalysisValuesInput(BaselineFieldInput): run_id: UUID class RunInput(StrictInput): run_id: UUID class RunsInput(PageInput): limit: int = Field(default=20, ge=1, le=100) class ResultsInput(PageInput): run_id: UUID result_type: Id | None = None class HistoryTarget(BaseModel): model_config = ConfigDict(extra="forbid") element_type: SeriesType element_id: Id device_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20) class HistoryInput(WindowInput): elements: list[HistoryTarget] = Field(min_length=1, max_length=20) mode: Literal["observed", "realtime_comparison", "analysis_comparison"] = "observed" run_id: UUID | None = None @model_validator(mode="after") def analysis_required(self): if (self.mode == "analysis_comparison") != (self.run_id is not None): raise ValueError("run_id is required only for analysis_comparison") return self class SourceGisInput(StrictInput): layer_key: Annotated[str, Field(min_length=1, max_length=80)] source_fid: int = Field(ge=0, le=9223372036854775807) class PeripheralInput(PageInput): source: Literal[ "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", ] point_id: Id | None = None start: datetime | None = None end: datetime | None = None @model_validator(mode="after") def observation_window(self): if self.source == "peripheral_observations": if not self.point_id or self.start is None or self.end is None: raise ValueError("observations require point_id, start and end") if ( self.start.tzinfo is None or self.end.tzinfo is None or not timedelta(0) < self.end - self.start <= timedelta(days=1) ): raise ValueError( "observation window requires timezone and at most 24 hours" ) elif ( self.point_id is not None or self.start is not None or self.end is not None ): raise ValueError( "point_id and window are only valid for peripheral_observations" ) return self class EvidenceInput(PageInput): evidence_id: Id data_path: list[Id] = Field(default_factory=list, max_length=8) class BindInput(BaseModel): model_config = ConfigDict(extra="forbid") native_session_id: Id purpose: Literal[ "ordinary_chat", "task_execution", "task_reader", "task_collaboration" ] task_id: UUID | None = None parent_session_id: Id | None = None @model_validator(mode="after") def task_scope(self): if self.purpose == "ordinary_chat" and (self.task_id or self.parent_session_id): raise ValueError("ordinary chat cannot bind a task") if self.purpose != "ordinary_chat" and self.task_id is None: raise ValueError("task context requires task_id") if (self.purpose == "task_reader") != (self.parent_session_id is not None): raise ValueError("only a task reader requires parent_session_id") return self class BindResponse(BaseModel): context_handle: str expires_at: datetime task_id: UUID | None run_id: UUID | None class PageInfo(BaseModel): limit: int offset: int total: int | None = None has_more: bool class QueryResult(BaseModel): model_config = ConfigDict(extra="forbid") source: str source_ref: str queried_at: datetime origin: Literal["system_data", "synthetic"] = "system_data" semantics: Literal[ "asset", "observed", "model_baseline", "analysis", "comparison", "task", "peripheral", ] data: JsonValue page: PageInfo | None = None window: dict[str, datetime] | None = None units: dict[str, str] = Field(default_factory=dict) truncated: bool = False data_gaps: list[str] = Field(default_factory=list) evidence_id: str | None = None class BoundContext(BaseModel): context_handle: str project_id: UUID subject: str client_id: str tool_cap: list[str] = Field(default_factory=list) permission_cap: list[str] = Field(default_factory=list) task_scope_cap: list[str] = Field(default_factory=list) native_session_id: str purpose: str parent_session_id: str | None = None task_id: UUID | None = None run_id: UUID | None = None execution_request_id: UUID | None = None expires_at: datetime revoked_at: datetime | None = None