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

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