Files
jiang 1d061f6099
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s
feat: add project-scoped operations
2026-10-22 18:40:20 +08:00

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