feat: add project-scoped operations
This commit is contained in:
@@ -4,9 +4,9 @@ import asyncio
|
||||
from threading import BoundedSemaphore
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
from app.mcp.scenario_service import SCENARIO_TOOLS, calculate_scenario
|
||||
from app.mcp.errors import CalculationCapacityExceeded
|
||||
|
||||
# These services synchronously read SCADA/model series or write online results.
|
||||
# Topology-only isolation and model-based sensor placement need only BizDB.
|
||||
@@ -21,10 +21,25 @@ _slots = BoundedSemaphore(2)
|
||||
|
||||
def _calculate(name: str, parameters: dict[str, Any], network: str, username: str) -> dict[str, Any]:
|
||||
if not _slots.acquire(blocking=False):
|
||||
raise HTTPException(503, "Calculation capacity is occupied")
|
||||
raise CalculationCapacityExceeded()
|
||||
try:
|
||||
if name in SCENARIO_TOOLS:
|
||||
return calculate_scenario(name, parameters, network=network, username=username)
|
||||
if name == "calculate_demand_allocation":
|
||||
from app.services import demand_allocation
|
||||
target = parameters.pop("target")
|
||||
limit, offset = parameters.pop("limit"), parameters.pop("offset")
|
||||
demand = parameters["demand"]
|
||||
if target == "nodes":
|
||||
result = demand_allocation.calculate_demand_to_nodes(network, demand, parameters["node_ids"])
|
||||
elif target == "region":
|
||||
result = demand_allocation.calculate_demand_to_region(network, demand, parameters["region_id"])
|
||||
else:
|
||||
result = demand_allocation.calculate_demand_to_network(network, demand)
|
||||
keys = sorted(result)
|
||||
return {"items": [{"node_id": key, "demand": result[key]} for key in keys[offset:offset + limit]],
|
||||
"total": len(keys), "limit": limit, "offset": offset,
|
||||
"has_more": offset + limit < len(keys), "persisted": False}
|
||||
if name == "optimize_sensor_placement":
|
||||
from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity
|
||||
parameters = dict(parameters)
|
||||
@@ -67,3 +82,15 @@ async def calculate(name: str, args: BaseModel, *, network: str, username: str)
|
||||
return await asyncio.to_thread(
|
||||
_calculate, name, args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), network, username,
|
||||
)
|
||||
|
||||
|
||||
async def bounded_calculation(function, *args, **kwargs):
|
||||
"""Use the same native-work capacity for persistent calculation tools."""
|
||||
def run():
|
||||
if not _slots.acquire(blocking=False):
|
||||
raise CalculationCapacityExceeded()
|
||||
try:
|
||||
return function(*args, **kwargs)
|
||||
finally:
|
||||
_slots.release()
|
||||
return await asyncio.to_thread(run)
|
||||
|
||||
+16
-4
@@ -32,10 +32,12 @@ from app.mcp.catalog import TOOLS
|
||||
from app.mcp.config import McpSettings
|
||||
from app.mcp.contexts import resolve_context, verify_task
|
||||
from app.mcp.models import QueryResult
|
||||
from app.mcp.errors import CalculationCapacityExceeded
|
||||
from app.mcp.scope import project_scope
|
||||
from app.mcp.query_service import execute_query, task_service, authorize_query
|
||||
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.domain.domain_errors import DomainError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -200,7 +202,7 @@ class McpQueryApplication:
|
||||
if tool is None:
|
||||
return None
|
||||
schema = tool.model.model_json_schema()
|
||||
if tool.computation:
|
||||
if tool.computation or tool.mutating:
|
||||
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
|
||||
return schema
|
||||
|
||||
@@ -290,9 +292,9 @@ class McpQueryApplication:
|
||||
"INVALID_QUERY", "Query parameters do not match the tool schema"
|
||||
)
|
||||
timeout_seconds = (
|
||||
args.timeout_seconds if tool.computation else self.settings.query_timeout_seconds
|
||||
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_timeout_seconds
|
||||
)
|
||||
if tool.computation and timeout_seconds > self.settings.max_calculation_timeout_seconds:
|
||||
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_calculation_timeout_seconds:
|
||||
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
|
||||
request = ctx.request
|
||||
project = request.headers.get("x-project-id", "") if request else ""
|
||||
@@ -380,12 +382,16 @@ class McpQueryApplication:
|
||||
result.source_ref = "mcp:" + str(
|
||||
uuid5(NAMESPACE_URL, scope_key + ":" + call_id)
|
||||
)
|
||||
snapshot_data = result.model_dump(mode="json")
|
||||
from app.mcp.extended_service import ARTIFACT_TOOLS
|
||||
if name in ARTIFACT_TOOLS:
|
||||
snapshot_data["data"]["file"].pop("data", None)
|
||||
snapshot = TaskDataSnapshot(
|
||||
source=name,
|
||||
source_ref=result.source_ref,
|
||||
queried_at=result.queried_at,
|
||||
origin=result.origin,
|
||||
data=result.model_dump(mode="json"),
|
||||
data=snapshot_data,
|
||||
truncated=result.truncated,
|
||||
)
|
||||
evidence, _ = await task_service(
|
||||
@@ -400,8 +406,10 @@ class McpQueryApplication:
|
||||
)
|
||||
output = result.model_dump(mode="json")
|
||||
content = []
|
||||
from app.mcp.extended_service import ARTIFACT_TOOLS
|
||||
if (
|
||||
not include_full
|
||||
and name not in ARTIFACT_TOOLS
|
||||
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
|
||||
):
|
||||
evidence_id = result.evidence_id or (
|
||||
@@ -440,6 +448,10 @@ class McpQueryApplication:
|
||||
),
|
||||
)
|
||||
return types.CallToolResult(content=content, structuredContent=output)
|
||||
except DomainError as exc:
|
||||
return tool_error(exc.code, exc.message)
|
||||
except CalculationCapacityExceeded as exc:
|
||||
return tool_error(exc.code, str(exc.detail))
|
||||
except HTTPException as exc:
|
||||
codes = {
|
||||
401: "AUTH_REQUIRED",
|
||||
|
||||
+40
-2
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from pydantic import BaseModel
|
||||
from mcp import types
|
||||
from app.mcp import models as m
|
||||
from app.mcp import extended_models as e
|
||||
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
|
||||
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
||||
|
||||
@@ -17,9 +18,12 @@ class QueryTool:
|
||||
semantics: str
|
||||
task_only: bool = False
|
||||
computation: bool = False
|
||||
mutating: bool = False
|
||||
|
||||
def protocol_tool(self) -> types.Tool:
|
||||
if self.name == "run_simulation":
|
||||
if self.mutating:
|
||||
suffix = "。默认dry_run只预览;执行必须携带预览摘要、相同request_id并通过宿主原生许可。仅项目普通聊天可用,后台调查和reader禁止;失败或超时先核实状态,不自动重试。"
|
||||
elif self.name == "run_simulation":
|
||||
suffix = "。任务调用复用证据留存。"
|
||||
elif self.computation:
|
||||
suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。"
|
||||
@@ -36,7 +40,7 @@ class QueryTool:
|
||||
inputSchema=self.model.model_json_schema(),
|
||||
outputSchema=m.QueryResult.model_json_schema(),
|
||||
annotations=types.ToolAnnotations(
|
||||
readOnlyHint=False, destructiveHint=self.name == "run_simulation", openWorldHint=False
|
||||
readOnlyHint=not (self.computation or self.mutating), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -218,4 +222,38 @@ _specs = [
|
||||
True,
|
||||
),
|
||||
]
|
||||
_specs += [
|
||||
("list_regions", "分页查询当前项目区域/DMA列表", m.PageInput, "webgis.view", "asset"),
|
||||
("get_region", "查询当前项目区域边界,返回EPSG:4326;不等于供水拓扑", e.RegionInput, "webgis.view", "asset"),
|
||||
("get_region_nodes", "分页查询区域配置的成员节点,未配置时使用区域几何筛选", e.RegionInput, "webgis.view", "asset"),
|
||||
("get_node_connections", "分页查询节点直接连接的管线,不自动遍历全网", e.NodeConnectionsInput, "webgis.view", "asset"),
|
||||
("query_network_extent", "按EPSG:4326矩形范围分页查询当前项目模型几何;无GIS配置不跨项目回退", e.ExtentInput, "webgis.view", "asset"),
|
||||
("list_major_nodes", "按关联管道最小管径查询骨干节点,经纬度EPSG:4326", e.MajorNodesInput, "webgis.view", "asset"),
|
||||
("get_model_configuration", "读取模式、曲线、控制规则、需水量、水质及反应等模型配置;集合按页返回", e.ModelConfigurationInput, "webgis.view", "asset"),
|
||||
("list_model_configurations", "分页读取模式或曲线ID", e.ConfigurationListInput, "webgis.view", "asset"),
|
||||
("search_project_places", "按项目配置的行政区搜索地名,最多10个候选,依赖天地图配置", e.PlaceSearchInput, "webgis.view", "asset"),
|
||||
("reverse_geocode", "把EPSG:4326地图取点转为项目范围内地名,保留输入坐标", e.ReverseGeocodeInput, "webgis.view", "asset"),
|
||||
("get_project_info", "读取当前项目概况与地图范围,不返回数据库路由或凭据", m.StrictInput, "webgis.view", "asset"),
|
||||
("export_project_model", "从当前项目数据库导出INP或ChangeSet压缩文件,由宿主保存到会话文件", e.ProjectExportInput, "webgis.view", "asset"),
|
||||
("get_sensor_placement_candidate", "读取监测点候选节点工程属性", e.SensorCandidateInput, "optimization.run", "asset"),
|
||||
("export_sensor_placement", "导出已有监测点方案的Excel工程清单,由宿主保存到会话文件", e.SensorExportInput, "optimization.run", "analysis"),
|
||||
("list_task_schedules", "分页读取当前项目定时任务配置,不启动调度", m.PageInput, "operational_task.view", "task"),
|
||||
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
|
||||
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后须核实目标数据,不自动重试", e.MutationStatusInput, "webgis.view", "task"),
|
||||
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,只计算不修改模型,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
|
||||
("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;不是爆管侦测或定位,不保存正式方案", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
|
||||
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;仅修改模型,不控制现场设备", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
|
||||
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True),
|
||||
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
|
||||
("write_scada_readings", "预览或补录/修正SCADA数据,要求真实来源依据;不把模拟当实测", e.ScadaWriteInput, "scada.clean", "observed", False, False, True),
|
||||
("delete_scada_readings", "预览或删除明确设备与24小时内时间窗的SCADA记录", e.ScadaDeleteInput, "scada.clean", "observed", False, False, True),
|
||||
("save_simulation_scenario", "预览或重新计算并保存正式隔离场景成果;有真实计算开销,不保存模型生成的观测数组", e.SavedScenarioInput, "simulation.run", "analysis", False, False, True),
|
||||
("create_sensor_placement", "预览或计算并保存正式压力监测点选址方案", e.SensorCreateInput, "optimization.run", "analysis", False, False, True),
|
||||
("update_sensor_placement", "预览或修改本人有权编辑的监测点方案,保留旧点位乐观校验", e.SensorUpdateInput, "optimization.run", "analysis", False, False, True),
|
||||
("create_operational_task", "预览或创建项目任务,不伪造Agent运行完成", e.TaskCreateInput, "operational_task.write", "task", False, False, True),
|
||||
("create_task_schedule", "预览或创建定时任务配置", e.ScheduleCreateInput, "operational_task.write", "task", False, False, True),
|
||||
("update_task_schedule", "预览或启停定时任务配置,校验版本", e.ScheduleUpdateInput, "operational_task.write", "task", False, False, True),
|
||||
("retry_operational_task", "预览或重新入队已终止任务;不自动启动新Agent会话", e.TaskRetryInput, "operational_task.write", "task", False, False, True),
|
||||
("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True),
|
||||
]
|
||||
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
|
||||
|
||||
+3
-1
@@ -69,7 +69,9 @@ async def bind_context(
|
||||
project_id=scope.project.project_id,
|
||||
subject=principal.subject,
|
||||
client_id=principal.client_id,
|
||||
tool_cap=sorted(name for name, tool in TOOLS.items() if command.purpose != "task_reader" or not tool.computation),
|
||||
tool_cap=sorted(name for name, tool in TOOLS.items()
|
||||
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating))
|
||||
and (not tool.mutating or command.purpose == "ordinary_chat")),
|
||||
permission_cap=sorted(permissions_for_context(scope.project)),
|
||||
native_session_id=command.native_session_id,
|
||||
purpose=command.purpose,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Errors with a known execution boundary for MCP calculations."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class CalculationCapacityExceeded(HTTPException):
|
||||
"""Raised by admission control before the native function is invoked."""
|
||||
|
||||
code = "CALCULATION_CAPACITY_EXCEEDED"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
503,
|
||||
"Calculation capacity is occupied; calculation not started. "
|
||||
"Retry the same request after an active calculation finishes.",
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""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
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Adapters for bounded, project-owned queries and downloadable exports."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from psycopg import sql
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.infra.db.postgresql.read_queries import ELEMENT_VIEWS, read_rows
|
||||
from app.mcp.models import PageInfo, QueryResult, BoundContext
|
||||
from app.mcp.scope import QueryScope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.mcp.catalog import QueryTool
|
||||
|
||||
|
||||
# Both function names and object identities are selected from a finite registry.
|
||||
CONFIG_FUNCTIONS = {
|
||||
"pattern": ("pattern", "id"), "curve": ("curve", "id"),
|
||||
"control": ("control", None), "rule": ("rule", None),
|
||||
"quality": ("quality", "node"), "source": ("source", "node"),
|
||||
"reaction": ("reaction", None), "pipe-reaction": ("pipe_reaction", "pipe"),
|
||||
"tank-reaction": ("tank_reaction", "tank"), "mixing": ("mixing", "tank"),
|
||||
"emitter": ("emitter", "junction"), "demand": ("demand", "junction"),
|
||||
"time": ("time", None), "energy": ("energy", None),
|
||||
"pump-energy": ("pump_energy", "pump"), "option": ("option_v3", None),
|
||||
}
|
||||
|
||||
EXTENDED_READ_TOOLS = {
|
||||
"list_regions", "get_region", "get_region_nodes", "get_node_connections",
|
||||
"query_network_extent", "list_major_nodes", "get_model_configuration",
|
||||
"list_model_configurations", "search_project_places", "reverse_geocode",
|
||||
"get_project_info", "export_project_model", "get_sensor_placement_candidate",
|
||||
"export_sensor_placement", "list_task_schedules", "get_task_run_history", "get_mutation_status",
|
||||
}
|
||||
ARTIFACT_TOOLS = {"export_project_model", "export_sensor_placement"}
|
||||
|
||||
|
||||
def configuration(network: str, kind: str, object_id: str | None = None) -> Any:
|
||||
from app.services import tjnetwork
|
||||
|
||||
function, identity = CONFIG_FUNCTIONS[kind]
|
||||
args = (network, object_id) if identity else (network,)
|
||||
return getattr(tjnetwork, "get_" + function)(*args)
|
||||
|
||||
|
||||
def artifact(filename: str, payload: bytes, mime_type: str) -> dict[str, Any]:
|
||||
if len(payload) > 5 * 1024 * 1024:
|
||||
raise HTTPException(413, "Export exceeds the 5 MiB compressed file budget")
|
||||
return {"file": {
|
||||
"name": filename, "mime_type": mime_type, "size": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"encoding": "base64", "data": base64.b64encode(payload).decode("ascii"),
|
||||
}}
|
||||
|
||||
|
||||
def project_export(network: str, args: BaseModel) -> dict[str, Any]:
|
||||
from app.services import tjnetwork
|
||||
from app.services.project_inp import temporary_project_inp
|
||||
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
if args.format == "inp":
|
||||
with temporary_project_inp(network, purpose="mcp_export", version=args.version) as path:
|
||||
archive.write(path, arcname="model.inp")
|
||||
else:
|
||||
changes = tjnetwork.export_inp(network, args.version)
|
||||
if changes.operations:
|
||||
changes.operations[0]["vertex"] = json.dumps(tjnetwork.get_all_vertices(network))
|
||||
changes.operations[0]["scada"] = json.dumps(tjnetwork.get_all_scada_info(network))
|
||||
archive.writestr("model.json", json.dumps(jsonable_encoder(changes.operations), ensure_ascii=False))
|
||||
return artifact("model.zip", output.getvalue(), "application/zip")
|
||||
|
||||
|
||||
def sensor_export(network: str, args: BaseModel, user: Any, project: Any) -> dict[str, Any]:
|
||||
from app.services.sensor_placement import get_sensor_placement_run, build_sensor_placement_workbook
|
||||
|
||||
run = get_sensor_placement_run(network, args.run_id)
|
||||
workbook = build_sensor_placement_workbook(
|
||||
network=network, scheme=run, sensor_location=run["sensor_locations"], adjustment_status={},
|
||||
)
|
||||
return artifact("sensor-placement.xlsx", workbook.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
|
||||
|
||||
async def execute_extended(scope: QueryScope, binding: BoundContext, tool: "QueryTool", args: BaseModel) -> QueryResult:
|
||||
name, conn, network = tool.name, scope.connection, scope.project.project_code
|
||||
page, gaps, truncated = None, [], False
|
||||
|
||||
def paginate(items, *, already_paged=False):
|
||||
nonlocal page, truncated
|
||||
if already_paged:
|
||||
more, selected, total = len(items) > args.limit, items[:args.limit], None
|
||||
else:
|
||||
total = len(items)
|
||||
more = args.offset + args.limit < total
|
||||
selected = items[args.offset:args.offset + args.limit]
|
||||
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=more)
|
||||
truncated = more or args.offset > 0
|
||||
return selected
|
||||
|
||||
if name == "list_regions":
|
||||
rows = await read_rows(conn, "SELECT id,region_type FROM gis.regions ORDER BY id LIMIT %s OFFSET %s", (args.limit + 1, args.offset))
|
||||
data = paginate(rows, already_paged=True)
|
||||
elif name == "get_region":
|
||||
rows = await read_rows(conn, "SELECT id,region_type,ST_AsGeoJSON(ST_Transform(boundary,4326))::json AS geometry FROM gis.regions WHERE id=%s", (args.region_id,))
|
||||
if not rows:
|
||||
raise HTTPException(404, "Region not found in the current project")
|
||||
data = {**rows[0], "crs": "EPSG:4326"}
|
||||
elif name == "get_region_nodes":
|
||||
rows = await read_rows(conn, """WITH configured AS (
|
||||
SELECT node_id FROM gis.region_nodes WHERE region_id=%s
|
||||
), members AS (
|
||||
SELECT node_id FROM configured UNION ALL
|
||||
SELECT n.node_id FROM gis.node_geometries n JOIN gis.regions r
|
||||
ON r.id=%s AND ST_Intersects(n.geom,r.boundary)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM configured)
|
||||
) SELECT node_id AS id FROM members ORDER BY node_id LIMIT %s OFFSET %s""",
|
||||
(args.region_id, args.region_id, args.limit + 1, args.offset))
|
||||
data = paginate(rows, already_paged=True)
|
||||
gaps = ["Uses configured region membership, or geometry if none is configured; membership does not prove hydraulic connectivity"]
|
||||
elif name == "get_node_connections":
|
||||
rows = await read_rows(conn, """SELECT id,link_type,start_node_id,end_node_id FROM network.links
|
||||
WHERE start_node_id=%s OR end_node_id=%s ORDER BY id LIMIT %s OFFSET %s""", (args.node_id, args.node_id, args.limit + 1, args.offset))
|
||||
data = paginate(rows, already_paged=True)
|
||||
elif name == "query_network_extent":
|
||||
statement = sql.SQL("""SELECT v.id,to_jsonb(v)-'geom' AS properties,
|
||||
ST_AsGeoJSON(ST_Transform(v.geom,4326))::json AS geometry FROM gis.{} v
|
||||
WHERE ST_Intersects(v.geom,ST_Transform(ST_MakeEnvelope(%s,%s,%s,%s,4326),3857))
|
||||
ORDER BY v.id LIMIT %s OFFSET %s""").format(sql.Identifier(ELEMENT_VIEWS[args.element_type]))
|
||||
rows = await read_rows(conn, statement, (*args.bounds, args.limit + 1, args.offset))
|
||||
data = {"items": paginate(rows, already_paged=True), "crs": "EPSG:4326", "source": "current_project_model"}
|
||||
gaps = ["Only current-project model elements with valid stored geometry are covered; missing GIS geometry is not evidence of an empty network"]
|
||||
elif name == "list_major_nodes":
|
||||
rows = await read_rows(conn, """SELECT n.id,ST_X(ST_Transform(n.geom,4326)) AS longitude,
|
||||
ST_Y(ST_Transform(n.geom,4326)) AS latitude FROM gis.junctions n
|
||||
WHERE EXISTS (SELECT 1 FROM gis.pipes p WHERE p.diameter >= %s
|
||||
AND (p.node1=n.id OR p.node2=n.id)) ORDER BY n.id LIMIT %s OFFSET %s""", (args.min_diameter_mm, args.limit + 1, args.offset))
|
||||
data = {"items": paginate(rows, already_paged=True), "crs": "EPSG:4326"}
|
||||
gaps = ["Only junctions with stored geometry and qualifying model pipes are covered; no other project's GIS is queried"]
|
||||
elif name == "get_model_configuration":
|
||||
data = await asyncio.to_thread(configuration, network, args.kind, args.object_id)
|
||||
for key in args.data_path:
|
||||
if not isinstance(data, dict) or key not in data:
|
||||
raise HTTPException(404, "Configuration path not found")
|
||||
data = data[key]
|
||||
if isinstance(data, dict):
|
||||
data = {"items": paginate([{"key": key, "value": value} for key, value in sorted(data.items())]), "data_path": args.data_path}
|
||||
elif isinstance(data, list):
|
||||
data = {"items": paginate(data), "data_path": args.data_path}
|
||||
else:
|
||||
data = {"value": data, "data_path": args.data_path}
|
||||
elif name == "list_model_configurations":
|
||||
from app.services import tjnetwork
|
||||
rows = await asyncio.to_thread(getattr(tjnetwork, "get_" + args.kind + "s"), network)
|
||||
data = paginate(sorted(rows or []))
|
||||
elif name in {"search_project_places", "reverse_geocode", "get_project_info"}:
|
||||
project = await scope.metadata.get_project_by_id(scope.project.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
if name == "get_project_info":
|
||||
data = {key: getattr(project, key, None) for key in ("name", "code", "description", "map_extent", "status")}
|
||||
data["project_id"] = str(scope.project.project_id)
|
||||
else:
|
||||
from app.domain.schemas.project_map_config import ProjectMapConfig
|
||||
from app.domain.schemas.place_search import PlaceSearchRequest, ReverseGeocodingRequest
|
||||
from app.services.place_search import search_tianditu_places, PlaceSearchError
|
||||
from app.services.reverse_geocoding import reverse_geocode_tianditu
|
||||
|
||||
config = ProjectMapConfig.model_validate(project.map_config or {}).place_search
|
||||
if config is None or not config.enabled:
|
||||
raise HTTPException(503, "Current project place search is not configured")
|
||||
await scope.metadata.session.commit()
|
||||
try:
|
||||
data = (await search_tianditu_places(PlaceSearchRequest(keyword=args.keyword), config=config)
|
||||
if name == "search_project_places" else await reverse_geocode_tianditu(
|
||||
ReverseGeocodingRequest(longitude=args.longitude, latitude=args.latitude), config=config))
|
||||
truncated = bool(getattr(data, "truncated", False))
|
||||
except PlaceSearchError as exc:
|
||||
raise HTTPException(exc.status_code, exc.detail) from exc
|
||||
elif name == "export_project_model":
|
||||
data = await asyncio.to_thread(project_export, network, args)
|
||||
elif name == "get_sensor_placement_candidate":
|
||||
from app.services.sensor_placement import get_sensor_placement_candidate
|
||||
data = await asyncio.to_thread(get_sensor_placement_candidate, network, args.node_id)
|
||||
elif name == "export_sensor_placement":
|
||||
data = await asyncio.to_thread(sensor_export, network, args, scope.user, scope.project)
|
||||
elif name == "get_mutation_status":
|
||||
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||
repo = McpQueryRepository(conn, scope.project.project_id)
|
||||
previous = await repo.receipt("mutation:" + binding.subject + ":" + binding.native_session_id, str(args.request_id))
|
||||
if previous is None:
|
||||
data = {"status": "not_started", "request_id": str(args.request_id)}
|
||||
else:
|
||||
output = previous["result"].get("output", {})
|
||||
data = {"request_id": str(args.request_id), "status": previous["result"]["status"],
|
||||
"tool": previous["parameters"]["tool"], "source_ref": output.get("source_ref"),
|
||||
"queried_at": output.get("queried_at")}
|
||||
if previous["result"]["status"] == "rejected":
|
||||
from app.mcp.mutation_service import retryable_capacity_rejection
|
||||
data["retryable"] = retryable_capacity_rejection(previous["result"])
|
||||
data["error"] = previous["result"].get("error")
|
||||
gaps = ["started does not prove success or rollback; verify target state before any new operation"]
|
||||
elif name in {"list_task_schedules", "get_task_run_history"}:
|
||||
from app.mcp.query_service import task_service
|
||||
service = task_service(scope)
|
||||
if name == "list_task_schedules":
|
||||
data = paginate(await service.list_schedules())
|
||||
else:
|
||||
rows = await service.task_runs(args.target_task_id, limit=args.limit + 1, offset=args.offset)
|
||||
data = paginate(rows, already_paged=True)
|
||||
else:
|
||||
raise ValueError("Unknown extended query")
|
||||
return QueryResult(source=name, source_ref="mcp:" + uuid4().hex,
|
||||
queried_at=datetime.now(timezone.utc), semantics=tool.semantics,
|
||||
data=jsonable_encoder(data), page=page, truncated=truncated, data_gaps=gaps)
|
||||
@@ -78,6 +78,8 @@ class SchemaInput(StrictInput):
|
||||
"pump-energy",
|
||||
"option",
|
||||
"scada-device",
|
||||
"region", "pattern", "curve", "control", "rule", "quality", "source",
|
||||
"reaction", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Previewed writes with project permissions and a durable no-retry journal."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from psycopg import sql
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||
from app.infra.db.postgresql.read_queries import read_rows, scada_devices
|
||||
from app.mcp.extended_service import CONFIG_FUNCTIONS, configuration
|
||||
from app.mcp.errors import CalculationCapacityExceeded
|
||||
from app.mcp.models import QueryResult, BoundContext
|
||||
from app.mcp.scope import QueryScope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.mcp.catalog import QueryTool
|
||||
|
||||
|
||||
def digest(value: Any) -> str:
|
||||
return hashlib.sha256(json.dumps(jsonable_encoder(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def check_digest(args: BaseModel, preview: dict) -> None:
|
||||
if not args.dry_run and args.expected_digest != digest(preview):
|
||||
raise HTTPException(409, "Preview changed; request a new dry_run before execution")
|
||||
|
||||
|
||||
def mutation_arguments(args: BaseModel, *, preview: bool) -> dict[str, Any]:
|
||||
excluded = {"context_handle", "timeout_seconds"}
|
||||
if preview:
|
||||
excluded.update({"dry_run", "expected_digest"})
|
||||
payload = args.model_dump(mode="json", exclude=excluded)
|
||||
if hasattr(args, "records"):
|
||||
# An omitted cleaned_value means keep it during update; explicit null
|
||||
# means clear it. Preserve that difference in previews and replay keys.
|
||||
payload["records"] = [record.model_dump(mode="json", exclude_unset=True) for record in args.records]
|
||||
return payload
|
||||
|
||||
|
||||
def check_properties(schema: dict, properties: dict, *, creating: bool = False) -> None:
|
||||
def finite(value):
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise HTTPException(422, "Model values must be finite")
|
||||
if isinstance(value, dict):
|
||||
for child in value.values():
|
||||
finite(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
finite(child)
|
||||
finite(properties)
|
||||
for key, value in properties.items():
|
||||
spec = schema.get(key)
|
||||
if spec is None or spec.get("readonly"):
|
||||
raise HTTPException(422, "Unknown or read-only model property: " + key)
|
||||
if value is None and not spec.get("optional", False):
|
||||
raise HTTPException(422, "Required model property cannot be null: " + key)
|
||||
expected = spec.get("type")
|
||||
if value is not None:
|
||||
valid = {"str": isinstance(value, str), "float": isinstance(value, (int, float)) and not isinstance(value, bool),
|
||||
"int": isinstance(value, int) and not isinstance(value, bool), "bool": isinstance(value, bool),
|
||||
"list": isinstance(value, list), "str_list": isinstance(value, list) and all(isinstance(v, str) for v in value),
|
||||
"float_list": isinstance(value, list) and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value),
|
||||
"tuple_list": isinstance(value, list)}.get(expected, True)
|
||||
if not valid:
|
||||
raise HTTPException(422, "Invalid model property type: " + key)
|
||||
if isinstance(value, list) and len(value) > 500:
|
||||
raise HTTPException(422, "Model property exceeds 500 items")
|
||||
if isinstance(value, list) and isinstance(spec.get("element"), dict):
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
raise HTTPException(422, "Model list item must be an object")
|
||||
check_properties(spec["element"], item, creating=True)
|
||||
if creating:
|
||||
required = {key for key, spec in schema.items() if not spec.get("readonly") and not spec.get("optional")}
|
||||
if not required.issubset(properties):
|
||||
raise HTTPException(422, "Missing required model properties: " + ",".join(sorted(required - properties.keys())))
|
||||
|
||||
|
||||
def model_change(network: str, name: str, args: BaseModel, *, execute: bool) -> dict[str, Any]:
|
||||
from app.services import tjnetwork
|
||||
from app.native.wndb.core.database import ChangeSet, model_mutation_transaction
|
||||
|
||||
# Preview revalidation and the actual write share the same model mutation
|
||||
# lock; another WNDB writer cannot change the target between them.
|
||||
with model_mutation_transaction(network):
|
||||
if name == "edit_model_element":
|
||||
kind, identity, object_id = args.element_type, "id", args.element_id
|
||||
current = getattr(tjnetwork, "get_" + kind)(network, object_id)
|
||||
schema = getattr(tjnetwork, "get_" + kind + "_schema")(network)
|
||||
else:
|
||||
kind, identity = CONFIG_FUNCTIONS[args.kind]
|
||||
object_id = args.object_id
|
||||
current = configuration(network, args.kind, object_id)
|
||||
schema = getattr(tjnetwork, "get_" + kind + "_schema")(network)
|
||||
check_properties(schema, args.properties, creating=args.operation == "create")
|
||||
if identity and args.operation == "create" and current:
|
||||
raise HTTPException(409, "Object already exists")
|
||||
if identity and args.operation != "create" and not current:
|
||||
raise HTTPException(404, "Model object not found")
|
||||
preview = {"tool": name, "operation": args.operation, "kind": kind,
|
||||
"object_id": object_id, "before": current, "properties": args.properties}
|
||||
if not execute:
|
||||
return preview
|
||||
check_digest(args, preview)
|
||||
prefix = {"create": "add_", "update": "set_", "delete": "delete_"}[args.operation]
|
||||
values = ({identity: object_id} if identity else {}) | args.properties
|
||||
if kind == "region" and "boundary" in values:
|
||||
values["boundary"] = [tuple(point) for point in values["boundary"]]
|
||||
result = getattr(tjnetwork, prefix + kind)(network, ChangeSet(values))
|
||||
if not result.operations:
|
||||
raise HTTPException(409, "Model rejected the change; no operations were applied")
|
||||
return {"operations": result.operations, "field_device_control": False}
|
||||
|
||||
|
||||
async def scada_snapshot(scope: QueryScope, args: BaseModel, *, connection: Any = None, lock: bool = False) -> dict[str, Any]:
|
||||
ids = sorted({r.device_id for r in args.records}) if hasattr(args, "records") else sorted(set(args.device_ids))
|
||||
for device_id in ids:
|
||||
if not await scada_devices(scope.connection, device_id=device_id, limit=1):
|
||||
raise HTTPException(404, "SCADA device not found in the current project")
|
||||
async def read(ts):
|
||||
suffix = " FOR UPDATE" if lock else ""
|
||||
if hasattr(args, "records"):
|
||||
rows = []
|
||||
for record in args.records:
|
||||
rows.extend(await read_rows(ts, "SELECT time,device_id,monitored_value,cleaned_value FROM scada.measurements WHERE device_id=%s AND time=%s" + suffix, (record.device_id, record.time)))
|
||||
else:
|
||||
rows = await read_rows(ts, """SELECT time,device_id,monitored_value,cleaned_value FROM scada.measurements
|
||||
WHERE device_id=ANY(%s) AND time >= %s AND time <= %s ORDER BY device_id,time LIMIT 10001""" + suffix, (ids, args.start, args.end))
|
||||
if len(rows) > 10000:
|
||||
raise HTTPException(413, "Narrow the SCADA window to at most 10000 records")
|
||||
return {"count": len(rows), "devices": ids, "state_digest": digest(rows)}
|
||||
if connection is not None:
|
||||
return await read(connection)
|
||||
async with scope.timescale() as ts:
|
||||
return await read(ts)
|
||||
|
||||
|
||||
async def preview_change(scope: QueryScope, binding: BoundContext, name: str, args: BaseModel) -> dict[str, Any]:
|
||||
network = scope.project.project_code
|
||||
if name in {"edit_model_element", "edit_model_configuration"}:
|
||||
from app.mcp.analysis_service import bounded_calculation
|
||||
return await bounded_calculation(model_change, network, name, args, execute=False)
|
||||
payload = mutation_arguments(args, preview=True)
|
||||
state = None
|
||||
if name in {"clean_scada_readings", "write_scada_readings", "delete_scada_readings"}:
|
||||
state = await scada_snapshot(scope, args)
|
||||
elif name in {"update_sensor_placement"}:
|
||||
from app.services.sensor_placement import get_sensor_placement_run, can_edit_sensor_placement
|
||||
state = await asyncio.to_thread(get_sensor_placement_run, network, args.run_id)
|
||||
if not can_edit_sensor_placement(scope.user, state):
|
||||
raise HTTPException(403, "Cannot edit another user's sensor placement")
|
||||
elif name in {"retry_operational_task", "cancel_task_run"}:
|
||||
from app.mcp.query_service import task_service
|
||||
state = (await task_service(scope).detail(args.target_task_id)).model_dump(mode="json")
|
||||
elif name == "update_task_schedule":
|
||||
from app.mcp.query_service import task_service
|
||||
schedules = await task_service(scope).list_schedules()
|
||||
state = next((s.model_dump(mode="json") for s in schedules if s.schedule_id == args.schedule_id), None)
|
||||
if state is None:
|
||||
raise HTTPException(404, "Schedule not found in the current project")
|
||||
return {"tool": name, "parameters": payload, "before": state}
|
||||
|
||||
|
||||
def saved_scenario(network: str, username: str, args: BaseModel) -> Any:
|
||||
from app.services import simulation_scenarios as scenarios
|
||||
from app.services.simulation import query_corresponding_element_id_and_query_id
|
||||
|
||||
parameters = args.parameters.model_dump(exclude_none=True)
|
||||
parameters.pop("output_node_ids")
|
||||
parameters.pop("output_link_ids")
|
||||
common = dict(name=network, modify_pattern_start_time=parameters.pop("start_time").isoformat(),
|
||||
modify_total_duration=parameters.pop("duration"), scheme_name=args.name, persist_results=True)
|
||||
if args.scenario == "burst":
|
||||
return scenarios.burst_analysis(**common, username=username,
|
||||
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
|
||||
if args.scenario == "contaminant":
|
||||
return scenarios.contaminant_simulation(**common, username=username,
|
||||
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
|
||||
if args.scenario == "valve_closure":
|
||||
return scenarios.valve_close_analysis(**common, username=username,
|
||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
||||
if args.scenario == "flushing":
|
||||
from app.native.wndb.model.options import get_option
|
||||
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
|
||||
raise ValueError("Flushing supports LPS or CMH models only")
|
||||
return scenarios.flushing_analysis(**common, username=username,
|
||||
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
|
||||
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
|
||||
mappings = query_corresponding_element_id_and_query_id(network)
|
||||
controls = parameters["pump_control"]
|
||||
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
|
||||
raise ValueError("Unknown model pump")
|
||||
if any(value not in (0, 1) for key, values in controls.items() if key in mappings.fixed_pumps for value in values):
|
||||
raise ValueError("Fixed pumps accept only 0 or 1")
|
||||
return scenarios.pressure_regulation(**common, username=username,
|
||||
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
|
||||
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
|
||||
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings)
|
||||
|
||||
|
||||
async def apply_change(scope: QueryScope, binding: BoundContext, name: str, args: BaseModel) -> Any:
|
||||
network = scope.project.project_code
|
||||
if name in {"edit_model_element", "edit_model_configuration"}:
|
||||
from app.mcp.analysis_service import bounded_calculation
|
||||
return await bounded_calculation(model_change, network, name, args, execute=True)
|
||||
if name in {"clean_scada_readings", "write_scada_readings", "delete_scada_readings"}:
|
||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||
from app.services.timeseries_analysis import TimeseriesAnalysisService
|
||||
|
||||
async with scope.timescale() as ts, ts.transaction():
|
||||
await ts.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
|
||||
# Serialize with other MCP maintenance calls, then validate again.
|
||||
await ts.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ("mcp-scada-maintenance",))
|
||||
before = await scada_snapshot(scope, args, connection=ts, lock=True)
|
||||
payload = mutation_arguments(args, preview=True)
|
||||
check_digest(args, {"tool": name, "parameters": payload, "before": before})
|
||||
if name == "clean_scada_readings":
|
||||
result = await TimeseriesAnalysisService.clean_scada_data(ts, scope.connection, args.device_ids, args.start, args.end)
|
||||
return {"status": result, "field": "cleaned_value"}
|
||||
if name == "delete_scada_readings":
|
||||
async with ts.cursor() as cur:
|
||||
await cur.execute("DELETE FROM scada.measurements WHERE device_id=ANY(%s) AND time >= %s AND time <= %s", (args.device_ids, args.start, args.end))
|
||||
return {"deleted_rows": cur.rowcount}
|
||||
if args.operation == "insert":
|
||||
await ScadaRepository.insert_scada_batch(ts, [r.model_dump() for r in args.records])
|
||||
else:
|
||||
for record in args.records:
|
||||
fields = sorted(record.model_fields_set & {"monitored_value", "cleaned_value"})
|
||||
statement = sql.SQL("UPDATE scada.measurements SET {} WHERE device_id=%s AND time=%s").format(
|
||||
sql.SQL(",").join(sql.SQL("{}=%s").format(sql.Identifier(field)) for field in fields))
|
||||
async with ts.cursor() as cur:
|
||||
await cur.execute(statement, tuple(getattr(record, field) for field in fields) + (record.device_id, record.time))
|
||||
if cur.rowcount != 1:
|
||||
raise HTTPException(409, "SCADA update requires exactly one existing record")
|
||||
return {"operation": args.operation, "records": len(args.records), "provenance": args.provenance}
|
||||
if name == "save_simulation_scenario":
|
||||
from app.mcp.analysis_service import bounded_calculation
|
||||
async with scope.simulation_routing():
|
||||
await bounded_calculation(saved_scenario, network, scope.user.username, args)
|
||||
return {"persisted": True, "name": args.name, "scenario": args.scenario, "results_query": "list_analysis_runs"}
|
||||
if name == "create_sensor_placement":
|
||||
from app.mcp.analysis_service import bounded_calculation
|
||||
from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity
|
||||
optimizer = optimize_sensor_placement_by_sensitivity if args.method == "sensitivity" else optimize_sensor_placement_by_kmeans
|
||||
return await bounded_calculation(optimizer, project_code=network, run_name=args.name,
|
||||
created_by=scope.user.username, sensor_count=args.sensor_count, min_diameter=args.min_diameter,
|
||||
sensor_type="pressure", persist_results=True)
|
||||
if name == "update_sensor_placement":
|
||||
from app.services.sensor_placement import update_sensor_placement_run
|
||||
return await asyncio.to_thread(update_sensor_placement_run, network, args.run_id, **args.changes.model_dump())
|
||||
from app.mcp.query_service import task_service
|
||||
service = task_service(scope)
|
||||
if name == "create_operational_task":
|
||||
if args.command.agent_session_id is not None or args.command.task_type != "event_investigation" or args.command.source.kind not in {"operator", "agent"}:
|
||||
raise HTTPException(422, "Only unclaimed operator/agent event investigations may be created")
|
||||
return await service.create(args.command)
|
||||
if name == "create_task_schedule":
|
||||
return await service.create_schedule(args.command)
|
||||
if name == "update_task_schedule":
|
||||
return await service.update_schedule(args.schedule_id, args.command)
|
||||
if name == "retry_operational_task":
|
||||
return await service.retry_task(args.target_task_id, args.command)
|
||||
if name == "cancel_task_run":
|
||||
return await service.cancel_run(args.target_task_id, args.run_id, args.command)
|
||||
raise ValueError("Unknown mutation")
|
||||
|
||||
|
||||
def retryable_capacity_rejection(result: dict[str, Any]) -> bool:
|
||||
return (result.get("status") == "rejected" and result.get("retryable") is True
|
||||
and result.get("error", {}).get("code") == CalculationCapacityExceeded.code)
|
||||
|
||||
|
||||
async def execute_mutation(scope: QueryScope, binding: BoundContext, tool: "QueryTool", args: BaseModel) -> QueryResult:
|
||||
name = tool.name
|
||||
repo = McpQueryRepository(scope.connection, scope.project.project_id)
|
||||
key, call_id = "mutation:" + binding.subject + ":" + binding.native_session_id, str(args.request_id)
|
||||
parameters = {"tool": name, "arguments": mutation_arguments(args, preview=False)}
|
||||
if not args.dry_run:
|
||||
async with scope.connection.transaction():
|
||||
await repo.lock_receipt(key, call_id)
|
||||
previous = await repo.receipt(key, call_id)
|
||||
if previous:
|
||||
if previous["parameters"] != parameters:
|
||||
raise HTTPException(409, "request_id already has different parameters")
|
||||
if previous["result"].get("status") == "completed":
|
||||
return QueryResult.model_validate(previous["result"]["output"])
|
||||
if not retryable_capacity_rejection(previous["result"]):
|
||||
raise HTTPException(409, "Operation already started; verify its state before any retry")
|
||||
preview = await preview_change(scope, binding, name, args)
|
||||
if args.dry_run:
|
||||
result_data = {"dry_run": True, "preview": preview, "expected_digest": digest(preview), "request_id": call_id}
|
||||
if len(json.dumps(jsonable_encoder(result_data)).encode()) > 10000:
|
||||
result_data["preview"] = {"tool": name, "summary": "Preview payload omitted; inspect the target with paged read tools", "before_digest": digest(preview.get("before"))}
|
||||
else:
|
||||
check_digest(args, preview)
|
||||
async with scope.connection.transaction():
|
||||
await repo.lock_receipt(key, call_id)
|
||||
previous = await repo.receipt(key, call_id)
|
||||
if previous:
|
||||
if previous["parameters"] != parameters:
|
||||
raise HTTPException(409, "request_id already has different parameters")
|
||||
if previous["result"].get("status") == "completed":
|
||||
return QueryResult.model_validate(previous["result"]["output"])
|
||||
if not retryable_capacity_rejection(previous["result"]):
|
||||
raise HTTPException(409, "Operation was started by another request; verify its state")
|
||||
await repo.finish_receipt(key, call_id, {"status": "started"})
|
||||
else:
|
||||
await repo.save_receipt(key, call_id, parameters, {"status": "started"})
|
||||
await scope.connection.commit()
|
||||
# Keep started on cancellation/timeout: a native thread may still commit.
|
||||
try:
|
||||
applied = await apply_change(scope, binding, name, args)
|
||||
except CalculationCapacityExceeded as exc:
|
||||
# This specific admission failure proves that no calculation began.
|
||||
# Other errors and cancellation remain ambiguous and are not retried.
|
||||
async with scope.connection.transaction():
|
||||
await repo.lock_receipt(key, call_id)
|
||||
await repo.finish_receipt(key, call_id, {
|
||||
"status": "rejected", "retryable": True,
|
||||
"error": {"code": exc.code, "message": exc.detail},
|
||||
"rejected_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
await scope.connection.commit()
|
||||
raise
|
||||
result_data = {"dry_run": False, "request_id": call_id, "result": jsonable_encoder(applied)}
|
||||
result = QueryResult(source=name, source_ref="mcp:" + uuid4().hex,
|
||||
queried_at=datetime.now(timezone.utc), semantics=tool.semantics, data=result_data)
|
||||
if not args.dry_run:
|
||||
async with scope.connection.transaction():
|
||||
await repo.finish_receipt(key, call_id, {"status": "completed", "output": result.model_dump(mode="json")})
|
||||
return result
|
||||
@@ -69,6 +69,14 @@ async def authorize_query(
|
||||
if tool.name not in binding.tool_cap:
|
||||
raise HTTPException(403, "Query is outside the captured tool catalog")
|
||||
permissions = permissions_for_context(scope.project)
|
||||
if tool.mutating:
|
||||
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
|
||||
raise HTTPException(403, "Writes require a project member's ordinary interactive chat")
|
||||
required = {tool.permission}
|
||||
if tool.name == "save_simulation_scenario":
|
||||
required.add("scada.view")
|
||||
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
|
||||
raise HTTPException(403, "Write and input data permissions are required")
|
||||
if tool.computation:
|
||||
if tool.name == "optimize_sensor_placement" and scope.project.project_role != "member":
|
||||
raise HTTPException(403, "Sensor placement requires project member access")
|
||||
@@ -123,6 +131,12 @@ async def execute_query(
|
||||
full_snapshot: bool = False,
|
||||
) -> QueryResult:
|
||||
await authorize_query(scope, binding, tool, args)
|
||||
if tool.mutating:
|
||||
from app.mcp.mutation_service import execute_mutation
|
||||
return await execute_mutation(scope, binding, tool, args)
|
||||
from app.mcp.extended_service import EXTENDED_READ_TOOLS, execute_extended
|
||||
if tool.name in EXTENDED_READ_TOOLS:
|
||||
return await execute_extended(scope, binding, tool, args)
|
||||
name = tool.name
|
||||
conn = scope.connection
|
||||
page = None
|
||||
@@ -162,7 +176,12 @@ async def execute_query(
|
||||
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
|
||||
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
|
||||
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
|
||||
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
|
||||
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
|
||||
}[name]]
|
||||
if name == "calculate_demand_allocation":
|
||||
truncated = data["result"]["has_more"] or args.offset > 0
|
||||
page = PageInfo(limit=args.limit, offset=args.offset, total=data["result"]["total"], has_more=data["result"]["has_more"])
|
||||
if getattr(args, "data_source", None) == "simulation":
|
||||
gaps.append("Input is an existing analysis run and model baseline, not observed SCADA data.")
|
||||
elif name == "query_nearby_elements":
|
||||
@@ -216,7 +235,7 @@ async def execute_query(
|
||||
"pump-energy": "get_pump_energy_schema",
|
||||
}
|
||||
function = getattr(
|
||||
tjnetwork, mapping.get(args.kind, "get_" + args.kind + "_schema")
|
||||
tjnetwork, mapping.get(args.kind, "get_" + args.kind.replace("-", "_") + "_schema")
|
||||
)
|
||||
data = await asyncio.to_thread(function, scope.project.project_code)
|
||||
elif name == "get_network_options":
|
||||
|
||||
@@ -6,6 +6,7 @@ SCENARIO_TOOLS = frozenset({
|
||||
"run_simulation",
|
||||
"run_contaminant_simulation", "analyze_valve_closure",
|
||||
"analyze_flushing", "analyze_pressure_regulation",
|
||||
"run_burst_simulation",
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +26,9 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
result = run_online_simulation(
|
||||
network, start, duration, output_node_ids=nodes, output_link_ids=links,
|
||||
)
|
||||
elif name == "run_burst_simulation":
|
||||
result = scenarios.burst_analysis(**common, username=username,
|
||||
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
|
||||
elif name == "run_contaminant_simulation":
|
||||
result = scenarios.contaminant_simulation(**common, username=username,
|
||||
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
|
||||
|
||||
Reference in New Issue
Block a user