"""Finite project services exposed through MCP; no routes, SQL or actor overrides.""" from datetime import datetime, timedelta from typing import Annotated, Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator, create_model from app.mcp.models import Id, PageInput, StrictInput from app.mcp.analysis_models import ( CalculationInput, ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, ) from app.domain.schemas.operational_task import ( OperationalTaskCreate, OperationalTaskScheduleCreate, OperationalTaskScheduleUpdate, OperationalTaskRetryCommand, AgentRunCancelCommand, ) from app.domain.schemas.sensor_placement import SensorPlacementUpdateRequest class RegionInput(PageInput): region_id: Id class NodeConnectionsInput(PageInput): node_id: Id class ExtentInput(PageInput): element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"] = "pipe" bounds: tuple[float, float, float, float] = Field(description="EPSG:4326 [west,south,east,north]; 不跨180度经线") @model_validator(mode="after") def valid_bounds(self): west, south, east, north = self.bounds if not (-180 <= west < east <= 180 and -85 <= south < north <= 85): raise ValueError("Invalid EPSG:4326 bounds") if east - west > 1 or north - south > 1: raise ValueError("Extent must be at most one degree per axis") return self class MajorNodesInput(PageInput): min_diameter_mm: float = Field(gt=0, le=10000, allow_inf_nan=False) ConfigKind = Literal[ "pattern", "curve", "control", "rule", "quality", "source", "reaction", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand", "time", "energy", "pump-energy", "option", ] class ModelConfigurationInput(PageInput): kind: ConfigKind object_id: Id | None = None data_path: list[Id] = Field(default_factory=list, max_length=8, description="选择嵌套字段,再按limit/offset分页读取") @model_validator(mode="after") def valid_target(self): needs_id = {"pattern", "curve", "quality", "source", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand", "pump-energy"} if (self.kind in needs_id) != (self.object_id is not None): raise ValueError("object_id is required only for object-scoped configuration") return self class ConfigurationListInput(PageInput): kind: Literal["pattern", "curve"] class PlaceSearchInput(StrictInput): keyword: str = Field(min_length=1, max_length=120) class ReverseGeocodeInput(StrictInput): longitude: float = Field(ge=-180, le=180, allow_inf_nan=False) latitude: float = Field(ge=-90, le=90, allow_inf_nan=False) class ProjectExportInput(StrictInput): format: Literal["inp", "change_set"] = "inp" version: Literal["2", "3"] = "2" class SensorCandidateInput(StrictInput): node_id: Id class SensorExportInput(StrictInput): run_id: UUID class TaskHistoryInput(PageInput): target_task_id: UUID class MutationStatusInput(StrictInput): request_id: UUID class DemandAllocationInput(CalculationInput): target: Literal["nodes", "region", "network"] demand: float = Field(gt=0, allow_inf_nan=False, description="模型流量单位的总需水量;调用前查询模型单位") node_ids: list[Id] = Field(default_factory=list, max_length=200) region_id: Id | None = None limit: int = Field(default=20, ge=1, le=200) offset: int = Field(default=0, ge=0, le=10000) @model_validator(mode="after") def valid_target(self): if (self.target == "nodes") != bool(self.node_ids) or (self.target == "region") != (self.region_id is not None): raise ValueError("Provide node_ids for nodes or region_id for region only") if len(self.node_ids) != len(set(self.node_ids)): raise ValueError("node_ids must be distinct") return self class BurstSimulationInput(ScenarioInput): burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20) orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积cm²,不是流量;必须有依据") @model_validator(mode="after") def paired_bursts(self): if len(self.burst_pipe_ids) != len(self.orifice_areas_cm2) or len(set(self.burst_pipe_ids)) != len(self.burst_pipe_ids): raise ValueError("Distinct pipes and one orifice area per pipe are required") return self class MutationInput(CalculationInput): request_id: UUID = Field(description="操作幂等键;预览与执行使用相同值,失败后先核实状态") dry_run: bool = Field(default=True, strict=True, description="默认只预览;执行需dry_run=false及预览返回的expected_digest") expected_digest: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$") @model_validator(mode="after") def require_preview(self): if not self.dry_run and self.expected_digest is None: raise ValueError("Execution requires the preview's expected_digest") command = getattr(self, "command", None) if command is not None and getattr(command, "request_id", self.request_id) != self.request_id: raise ValueError("Nested command request_id must match the operation") return self class ModelElementMutationInput(MutationInput): operation: Literal["create", "update", "delete"] element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir", "region"] element_id: Id properties: dict[Id, JsonValue] = Field(default_factory=dict, max_length=40, description="仅允许get_network_schema声明的可写字段;id由element_id绑定") @model_validator(mode="after") def valid_properties(self): if (self.operation == "delete") == bool(self.properties): raise ValueError("Create/update require properties; delete takes none") if "id" in self.properties or "type" in self.properties: raise ValueError("Element identity is bound separately") return self class ConfigurationMutationInput(MutationInput): operation: Literal["create", "update", "delete"] = "update" kind: ConfigKind object_id: Id | None = None properties: dict[Id, JsonValue] = Field(default_factory=dict, max_length=40) @model_validator(mode="after") def valid_configuration(self): ModelConfigurationInput(context_handle=self.context_handle, kind=self.kind, object_id=self.object_id) if self.operation != "update" and self.kind not in {"pattern", "curve", "source", "mixing"}: raise ValueError("Only pattern, curve, source and mixing support create/delete") if (self.operation == "delete") == bool(self.properties): raise ValueError("Create/update require properties; delete takes none") if "id" in self.properties: raise ValueError("Object identity is bound separately") return self class ScadaCleaningInput(MutationInput): start: datetime end: datetime device_ids: list[Id] = Field(min_length=1, max_length=20, description="必须显式列出设备;不支持all") @model_validator(mode="after") def valid_window(self): 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("SCADA writes require explicit timezone and at most 24 hours") if len(set(self.device_ids)) != len(self.device_ids): raise ValueError("device_ids must be distinct") return self class ScadaRecord(BaseModel): model_config = ConfigDict(extra="forbid") device_id: Id time: datetime monitored_value: float | None = Field(default=None, allow_inf_nan=False) cleaned_value: float | None = Field(default=None, allow_inf_nan=False) @model_validator(mode="after") def valid_record(self): if self.time.tzinfo is None or not ({"monitored_value", "cleaned_value"} & self.model_fields_set): raise ValueError("Record needs an explicit timezone and a value field") return self class ScadaWriteInput(MutationInput): operation: Literal["insert", "update"] records: list[ScadaRecord] = Field(min_length=1, max_length=500) provenance: str = Field(min_length=1, max_length=2000, description="补录/修正的真实来源与依据,不得把模型值冒充实测") @model_validator(mode="after") def bounded_records(self): keys = [(r.device_id, r.time) for r in self.records] if len(set(keys)) != len(keys): raise ValueError("Duplicate device/time records") if (max(r.time for r in self.records) - min(r.time for r in self.records)).total_seconds() > 86400: raise ValueError("At most 24 hours per write") return self class ScadaDeleteInput(ScadaCleaningInput): reason: str = Field(min_length=1, max_length=2000) class SensorCreateInput(MutationInput): name: str = Field(min_length=1, max_length=64) method: Literal["sensitivity", "kmeans"] sensor_count: int = Field(ge=1, le=20) min_diameter: int = Field(default=0, ge=0, le=5000) class SensorUpdateInput(MutationInput): run_id: UUID changes: SensorPlacementUpdateRequest def _payload(model): return create_model(model.__name__ + "Payload", __config__=ConfigDict(extra="forbid"), **{ name: (field.annotation, field) for name, field in model.model_fields.items() if name not in {"context_handle", "timeout_seconds"} }) BurstPayload = _payload(BurstSimulationInput) ContaminantPayload = _payload(ContaminantSimulationInput) ValvePayload = _payload(ValveClosureInput) FlushingPayload = _payload(FlushingInput) PressurePayload = _payload(PressureRegulationInput) class SavedScenarioInput(MutationInput): name: str = Field(min_length=1, max_length=64) scenario: Literal["burst", "contaminant", "valve_closure", "flushing", "pressure_regulation"] parameters: BurstPayload | ContaminantPayload | ValvePayload | FlushingPayload | PressurePayload @model_validator(mode="after") def matching_scenario(self): expected = {"burst": BurstSimulationInput, "contaminant": ContaminantSimulationInput, "valve_closure": ValveClosureInput, "flushing": FlushingInput, "pressure_regulation": PressureRegulationInput}[self.scenario] expected.model_validate({**self.parameters.model_dump(), "context_handle": self.context_handle}) return self class TaskCreateInput(MutationInput): command: OperationalTaskCreate @model_validator(mode="after") def unclaimed_event(self): if self.command.agent_session_id is not None or self.command.task_type != "event_investigation" or self.command.source.kind not in {"operator", "agent"}: raise ValueError("Only unclaimed operator/agent event investigations may be created") return self class ScheduleCreateInput(MutationInput): command: OperationalTaskScheduleCreate class ScheduleUpdateInput(MutationInput): schedule_id: UUID command: OperationalTaskScheduleUpdate class TaskRetryInput(MutationInput): target_task_id: UUID command: OperationalTaskRetryCommand class TaskCancelInput(MutationInput): target_task_id: UUID run_id: UUID command: AgentRunCancelCommand