290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""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
|