296 lines
8.7 KiB
Python
296 lines
8.7 KiB
Python
"""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
|