feat: add project-scoped operations
This commit is contained in:
@@ -48,6 +48,7 @@ def _project_response(project: models.Project) -> AdminProjectResponse:
|
||||
description=project.description,
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=getattr(project, "map_config", {}) or {},
|
||||
status=project.status,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
@@ -274,6 +275,7 @@ async def create_admin_project(
|
||||
description=payload.description,
|
||||
gs_workspace=payload.gs_workspace,
|
||||
map_extent=payload.map_extent,
|
||||
map_config=payload.map_config.model_dump(exclude_unset=True),
|
||||
status=payload.status,
|
||||
creator_user_id=current_user.id,
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ async def get_project_metadata(
|
||||
description=project.description,
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=getattr(project, "map_config", {}) or {},
|
||||
status=project.status,
|
||||
project_role=ctx.project_role,
|
||||
)
|
||||
|
||||
@@ -319,6 +319,7 @@ async def provision_project(
|
||||
description=project.description,
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=getattr(project, "map_config", {}) or {},
|
||||
status=project.status,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
|
||||
@@ -1,19 +1,60 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.schemas.place_search import PlaceSearchRequest, PlaceSearchResponse
|
||||
from app.auth.project_dependencies import ProjectContext, get_metadata_repository, get_project_context
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.domain.schemas.project_map_config import PlaceSearchConfig, ProjectMapConfig
|
||||
|
||||
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchRequest, PlaceSearchResponse, ReverseGeocodingRequest
|
||||
from app.services.place_search import PlaceSearchError, search_tianditu_places
|
||||
from app.services.reverse_geocoding import reverse_geocode_tianditu
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def get_place_search_config(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> PlaceSearchConfig:
|
||||
project = await metadata_repo.get_project_by_id(ctx.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
try:
|
||||
config = ProjectMapConfig.model_validate(project.map_config or {}).place_search
|
||||
except ValidationError:
|
||||
raise HTTPException(status_code=503, detail="当前项目地名搜索配置无效") from None
|
||||
if config is None or not config.enabled:
|
||||
raise HTTPException(status_code=503, detail="当前项目暂未配置地名搜索")
|
||||
return config
|
||||
|
||||
|
||||
@router.post(
|
||||
"/place-search-requests",
|
||||
response_model=PlaceSearchResponse,
|
||||
summary="Search Fengxian places",
|
||||
description="调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。",
|
||||
summary="Search project places",
|
||||
description="调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。",
|
||||
)
|
||||
async def tianditu_place_search(request: PlaceSearchRequest) -> PlaceSearchResponse:
|
||||
async def tianditu_place_search(
|
||||
request: PlaceSearchRequest,
|
||||
config: PlaceSearchConfig = Depends(get_place_search_config),
|
||||
) -> PlaceSearchResponse:
|
||||
try:
|
||||
return await search_tianditu_places(request)
|
||||
return await search_tianditu_places(request, config=config)
|
||||
except PlaceSearchError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reverse-geocoding-requests",
|
||||
response_model=PlaceSearchItem,
|
||||
summary="Reverse geocode a project map point",
|
||||
description="查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。",
|
||||
)
|
||||
async def tianditu_reverse_geocode(
|
||||
request: ReverseGeocodingRequest,
|
||||
config: PlaceSearchConfig = Depends(get_place_search_config),
|
||||
) -> PlaceSearchItem:
|
||||
try:
|
||||
return await reverse_geocode_tianditu(request, config=config)
|
||||
except PlaceSearchError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
@@ -34,6 +34,7 @@ async def get_project_info_endpoint(
|
||||
description=project_detail.description,
|
||||
gs_workspace=project_detail.gs_workspace,
|
||||
map_extent=project_detail.map_extent,
|
||||
map_config=getattr(project_detail, "map_config", {}) or {},
|
||||
status=project_detail.status,
|
||||
project_role="viewer", # Default role for public access
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
||||
from psycopg import AsyncConnection
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.domain.schemas.scada import ScadaLatestReadingsResponse
|
||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||
from .dependencies import get_postgres_connection, get_timescale_connection
|
||||
@@ -96,6 +97,54 @@ async def get_scada_by_ids_time_range(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/timeseries/scada-readings/latest",
|
||||
response_model=ScadaLatestReadingsResponse,
|
||||
summary="获取SCADA设备最新数据",
|
||||
)
|
||||
async def get_latest_scada_readings(
|
||||
limit: int = Query(100, ge=1, le=500, description="返回设备数上限"),
|
||||
offset: int = Query(0, ge=0, description="设备目录偏移量"),
|
||||
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
|
||||
) -> ScadaLatestReadingsResponse:
|
||||
"""Read one latest persisted row per project device without applying a time window."""
|
||||
devices = await ScadaInfoRepository.get_scadas(postgres_conn)
|
||||
selected_devices = devices[offset : offset + limit]
|
||||
latest_by_device = await ScadaRepository.get_latest_scada_readings(
|
||||
conn,
|
||||
[device["device_id"] for device in selected_devices],
|
||||
)
|
||||
|
||||
items = []
|
||||
for device in selected_devices:
|
||||
reading = latest_by_device.get(device["device_id"])
|
||||
items.append(
|
||||
{
|
||||
"device_id": device["device_id"],
|
||||
"device_type": device["device_type"],
|
||||
"measurement_unit": device["measurement_unit"],
|
||||
"transmission_mode": device["transmission_mode"],
|
||||
"transmission_frequency": device["transmission_frequency"],
|
||||
"observed_at": reading["time"] if reading else None,
|
||||
"monitored_value": reading["monitored_value"] if reading else None,
|
||||
"cleaned_value": reading["cleaned_value"] if reading else None,
|
||||
}
|
||||
)
|
||||
|
||||
observed_times = [item["observed_at"] for item in items if item["observed_at"] is not None]
|
||||
return ScadaLatestReadingsResponse.model_validate(
|
||||
{
|
||||
"items": items,
|
||||
"total": len(devices),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"observed_count": len(observed_times),
|
||||
"latest_observed_at": max(observed_times) if observed_times else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据"
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
|
||||
|
||||
|
||||
BusinessRole = Literal["admin", "service", "user"]
|
||||
ProjectRole = Literal["member", "viewer"]
|
||||
@@ -75,10 +77,11 @@ class AdminProjectCreateRequest(BaseModel):
|
||||
description: str | None = None
|
||||
gs_workspace: str = Field(..., min_length=1, max_length=100)
|
||||
map_extent: dict | None = None
|
||||
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
|
||||
status: ProjectStatus = "active"
|
||||
|
||||
|
||||
class AdminProjectUpdateRequest(BaseModel):
|
||||
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
code: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
description: str | None = None
|
||||
@@ -94,6 +97,7 @@ class AdminProjectResponse(BaseModel):
|
||||
description: str | None = None
|
||||
gs_workspace: str
|
||||
map_extent: dict | None = None
|
||||
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Validated, local presentation blocks. Component props come from the frontend catalog."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from jsonschema import Draft7Validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
_catalog = json.loads(
|
||||
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
||||
)
|
||||
_validators = {name: Draft7Validator(schema) for name, schema in _catalog["props"].items()}
|
||||
Key = Annotated[str, Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")]
|
||||
|
||||
|
||||
class UiModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AnswerUiElement(UiModel):
|
||||
type: str
|
||||
props: dict[str, Any]
|
||||
children: list[Key] = Field(max_length=48)
|
||||
|
||||
|
||||
class AnswerUiSpec(UiModel):
|
||||
root: Key
|
||||
elements: dict[Key, AnswerUiElement]
|
||||
|
||||
|
||||
class AnswerUiBlock(UiModel):
|
||||
version: Literal["answer-ui@1"]
|
||||
block_id: Key
|
||||
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
||||
spec: AnswerUiSpec
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_components(self):
|
||||
spec = self.spec
|
||||
if not 1 <= len(spec.elements) <= 48:
|
||||
raise ValueError("UI requires 1 to 48 elements")
|
||||
# Match the existing JavaScript serialized character limit, including UTF-16.
|
||||
serialized = json.dumps(self.model_dump(), ensure_ascii=False, separators=(",", ":"))
|
||||
if len(serialized.encode("utf-16-le")) // 2 > 100000:
|
||||
raise ValueError("UI block is too large")
|
||||
for element in spec.elements.values():
|
||||
validator = _validators.get(element.type)
|
||||
if validator is None or not validator.is_valid(element.props):
|
||||
raise ValueError("Unknown UI component or invalid properties")
|
||||
if element.children and element.type not in {"Stack", "Section"}:
|
||||
raise ValueError("Leaf components cannot contain children")
|
||||
props = element.props
|
||||
if element.type == "Chart":
|
||||
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
|
||||
raise ValueError("Chart series must match the axis")
|
||||
if any(not math.isfinite(value) for series in props["series"] for value in series["data"]):
|
||||
raise ValueError("Chart values must be finite")
|
||||
if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]):
|
||||
raise ValueError("Table row width must match its columns")
|
||||
if element.type == "FollowUpForm":
|
||||
names = [field["name"] for field in props["fields"]]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("Form field names must be unique")
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(key: str, depth: int):
|
||||
if depth > 12 or key in visited or key not in spec.elements:
|
||||
raise ValueError("UI must be a bounded tree without duplicate or missing references")
|
||||
visited.add(key)
|
||||
for child in spec.elements[key].children:
|
||||
visit(child, depth + 1)
|
||||
|
||||
visit(spec.root, 0)
|
||||
if len(visited) != len(spec.elements):
|
||||
raise ValueError("UI contains unreachable elements")
|
||||
return self
|
||||
|
||||
|
||||
class ReportUiBlock(UiModel):
|
||||
block: AnswerUiBlock
|
||||
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
||||
ids = [item.block.block_id for item in blocks]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("Report UI block ids must be unique")
|
||||
serialized = json.dumps([item.model_dump() for item in blocks], ensure_ascii=False, separators=(",", ":"))
|
||||
if len(serialized.encode("utf-8")) > 400000:
|
||||
raise ValueError("Report UI exceeds 400 KB")
|
||||
for item in blocks:
|
||||
if not set(item.evidence_refs) <= set(evidence_refs):
|
||||
raise ValueError("UI evidence must be included in report evidence")
|
||||
for element in item.block.spec.elements.values():
|
||||
if element.type == "Evidence" and element.props.get("evidence_id") not in item.evidence_refs:
|
||||
raise ValueError("UI evidence component must reference its block evidence")
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.domain.schemas.project_map_config import ProjectMapConfig
|
||||
|
||||
|
||||
class ProjectMetaResponse(BaseModel):
|
||||
@@ -11,6 +13,7 @@ class ProjectMetaResponse(BaseModel):
|
||||
description: Optional[str] = None
|
||||
gs_workspace: str
|
||||
map_extent: Optional[dict] = None
|
||||
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
|
||||
status: str
|
||||
project_role: str
|
||||
|
||||
|
||||
@@ -16,6 +16,39 @@ TaskDataSource = Literal[
|
||||
|
||||
|
||||
McpSnapshotSource = Literal[
|
||||
'get_mutation_status',
|
||||
'query_nearby_elements',
|
||||
'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',
|
||||
'calculate_demand_allocation',
|
||||
'run_burst_simulation',
|
||||
'edit_model_element',
|
||||
'edit_model_configuration',
|
||||
'clean_scada_readings',
|
||||
'write_scada_readings',
|
||||
'delete_scada_readings',
|
||||
'save_simulation_scenario',
|
||||
'create_sensor_placement',
|
||||
'update_sensor_placement',
|
||||
'create_operational_task',
|
||||
'create_task_schedule',
|
||||
'update_task_schedule',
|
||||
'retry_operational_task',
|
||||
'cancel_task_run',
|
||||
'run_simulation',
|
||||
'run_contaminant_simulation',
|
||||
'analyze_valve_closure',
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||
@@ -253,9 +254,11 @@ class InvestigationReport(StrictModel):
|
||||
stop_reason: NonEmptyStr | None = None
|
||||
data_gaps: list[InvestigationDataGap] | None = None
|
||||
diagram: InvestigationDiagram | None = None
|
||||
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_result(self):
|
||||
validate_report_ui_blocks(self.ui_blocks, self.evidence_refs)
|
||||
if self.outcome is None:
|
||||
if self.stage is not None or self.stop_reason is not None or self.data_gaps is not None:
|
||||
raise ValueError("structured report fields must be supplied together")
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
MAX_PLACE_SEARCH_RESULTS = 10
|
||||
@@ -11,6 +9,13 @@ class PlaceSearchRequest(BaseModel):
|
||||
keyword: str = Field(..., min_length=1, max_length=120)
|
||||
|
||||
|
||||
class ReverseGeocodingRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
longitude: float = Field(ge=-180, le=180, allow_inf_nan=False)
|
||||
latitude: float = Field(ge=-90, le=90, allow_inf_nan=False)
|
||||
|
||||
|
||||
class PlaceSearchItem(BaseModel):
|
||||
id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
@@ -18,11 +23,11 @@ class PlaceSearchItem(BaseModel):
|
||||
longitude: float = Field(..., ge=-180, le=180, allow_inf_nan=False)
|
||||
latitude: float = Field(..., ge=-90, le=90, allow_inf_nan=False)
|
||||
category: str | None = None
|
||||
district: Literal["奉贤区"] = "奉贤区"
|
||||
district: str = Field(min_length=1)
|
||||
|
||||
|
||||
class PlaceSearchResponse(BaseModel):
|
||||
items: list[PlaceSearchItem] = Field(..., max_length=MAX_PLACE_SEARCH_RESULTS)
|
||||
total: int = Field(..., ge=0, le=MAX_PLACE_SEARCH_RESULTS)
|
||||
truncated: bool = False
|
||||
region: Literal["奉贤区"] = "奉贤区"
|
||||
region: str = Field(min_length=1)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class PlaceSearchConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
enabled: bool = False
|
||||
provider: Literal["tianditu"] = "tianditu"
|
||||
admin_code: str = Field(pattern=r"^(?:156)?[0-9]{6}$")
|
||||
region: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ProjectMapConfig(BaseModel):
|
||||
# Preserve existing map options such as legend when editing search settings.
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
place_search: PlaceSearchConfig | None = None
|
||||
|
||||
|
||||
class ProjectMapConfigUpdate(BaseModel):
|
||||
map_config: ProjectMapConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_null_map_config(self):
|
||||
if "map_config" in self.model_fields_set and self.map_config is None:
|
||||
raise ValueError("map_config must be an object")
|
||||
return self
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ScadaDeviceResponse(BaseModel):
|
||||
@@ -17,3 +19,31 @@ class ScadaDeviceResponse(BaseModel):
|
||||
y: float | None = None
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
|
||||
|
||||
class ScadaLatestReadingResponse(BaseModel):
|
||||
"""Latest persisted observation for one project SCADA device."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
device_id: str
|
||||
device_type: str
|
||||
measurement_unit: str
|
||||
transmission_mode: str
|
||||
transmission_frequency: str
|
||||
observed_at: datetime | None = None
|
||||
monitored_value: float | None = None
|
||||
cleaned_value: float | None = None
|
||||
|
||||
|
||||
class ScadaLatestReadingsResponse(BaseModel):
|
||||
"""Bounded device page with each device's latest persisted observation."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
items: list[ScadaLatestReadingResponse]
|
||||
total: int = Field(ge=0)
|
||||
limit: int = Field(ge=1, le=500)
|
||||
offset: int = Field(ge=0)
|
||||
observed_count: int = Field(ge=0)
|
||||
latest_observed_at: datetime | None = None
|
||||
|
||||
@@ -43,6 +43,7 @@ class Project(Base):
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
map_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
from uuid import UUID, uuid4
|
||||
@@ -66,6 +66,7 @@ class ProjectDetail:
|
||||
gs_workspace: str
|
||||
map_extent: Optional[dict]
|
||||
status: str
|
||||
map_config: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -221,6 +222,7 @@ class MetadataRepository:
|
||||
map_extent: dict | None,
|
||||
status: str,
|
||||
creator_user_id: UUID | None = None,
|
||||
map_config: dict | None = None,
|
||||
) -> models.Project:
|
||||
project = models.Project(
|
||||
id=uuid4(),
|
||||
@@ -230,6 +232,7 @@ class MetadataRepository:
|
||||
gs_workspace=gs_workspace,
|
||||
map_extent=map_extent,
|
||||
status=status,
|
||||
map_config=map_config or {},
|
||||
created_at=_utcnow(),
|
||||
updated_at=_utcnow(),
|
||||
)
|
||||
@@ -272,6 +275,7 @@ class MetadataRepository:
|
||||
gs_workspace=gs_workspace,
|
||||
map_extent=map_extent,
|
||||
status="active",
|
||||
map_config={},
|
||||
created_at=_utcnow(),
|
||||
updated_at=_utcnow(),
|
||||
)
|
||||
@@ -327,6 +331,8 @@ class MetadataRepository:
|
||||
):
|
||||
if field in updates:
|
||||
setattr(project, field, updates[field])
|
||||
if "map_config" in updates:
|
||||
project.map_config = {**(project.map_config or {}), **updates["map_config"]}
|
||||
project.updated_at = _utcnow()
|
||||
await self.session.commit()
|
||||
await self.session.refresh(project)
|
||||
@@ -343,6 +349,7 @@ class MetadataRepository:
|
||||
description=project.description,
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=project.map_config or {},
|
||||
status=project.status,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,3 +89,10 @@ class McpQueryRepository:
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))",
|
||||
(f"mcp:{self.project_id}:{scope_key}:{call_id}",),
|
||||
)
|
||||
|
||||
async def finish_receipt(self, scope_key: str, call_id: str, result: dict):
|
||||
async with self.conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"UPDATE workbench.mcp_query_receipts SET result=%s WHERE project_id=%s AND scope_key=%s AND call_id=%s",
|
||||
(Jsonb(result), self.project_id, scope_key, call_id),
|
||||
)
|
||||
|
||||
@@ -12,20 +12,19 @@ async def lock_event_transaction(connection: AsyncConnection) -> None:
|
||||
|
||||
PostgreSQL sequence allocation is not commit order. All producers must use
|
||||
this same transaction lock so a committed higher cursor cannot hide an
|
||||
earlier, still-uncommitted event. Other BizDBs remain rollout opt-out.
|
||||
earlier, still-uncommitted event. The lock is local to the connected BizDB
|
||||
and creates no schema or data in projects without an event log.
|
||||
"""
|
||||
if getattr(getattr(connection, "info", None), "dbname", None) == "tjwater_v2":
|
||||
await connection.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
|
||||
)
|
||||
await connection.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
|
||||
)
|
||||
|
||||
|
||||
def lock_event_transaction_sync(connection: Connection) -> None:
|
||||
"""Synchronous counterpart using the identical PostgreSQL lock key."""
|
||||
if getattr(getattr(connection, "info", None), "dbname", None) == "tjwater_v2":
|
||||
connection.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
|
||||
)
|
||||
connection.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
|
||||
)
|
||||
|
||||
|
||||
class WorkbenchEventRepository:
|
||||
|
||||
@@ -46,6 +46,25 @@ class ScadaRepository:
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_latest_scada_readings(
|
||||
conn: AsyncConnection,
|
||||
device_ids: List[str],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Return at most one latest row for each requested canonical device ID."""
|
||||
if not device_ids:
|
||||
return {}
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"SELECT DISTINCT ON (device_id) {ScadaRepository.RESULT_COLUMNS} "
|
||||
"FROM scada.measurements WHERE device_id = ANY(%s) "
|
||||
"ORDER BY device_id, time DESC",
|
||||
(device_ids,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return {str(row["device_id"]): dict(row) for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def get_scada_by_ids_time_range_sync(
|
||||
conn: Connection,
|
||||
|
||||
@@ -84,7 +84,7 @@ class Output:
|
||||
|
||||
def units(self) -> dict[str, str]:
|
||||
f_us = ["CFS", "GPM", "MGD", "IMGD", "AFD", "LPS", "LPM", "MLD", "CMH", "CMD"]
|
||||
p_us = ["PSI", "MTR", "KPA"]
|
||||
p_us = ["PSI", "KPA", "MTR"]
|
||||
q_us = ["NONE", "MGL", "UGL", "HOURS", "PRCNT"]
|
||||
f, p, q = ctypes.c_int(1), ctypes.c_int(2), ctypes.c_int(3)
|
||||
f_u, p_u, q_u = ctypes.c_int(), ctypes.c_int(), ctypes.c_int()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -911,9 +911,9 @@ class OperationalTaskService:
|
||||
previous_report is None
|
||||
or previous_report.get("execution_generation") != task["execution_generation"]
|
||||
or previous_result.get("stage") != "final"
|
||||
or any(previous_result.get(key) != _json(command.report).get(key)
|
||||
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != _json(command.report).get(key)
|
||||
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
|
||||
"evidence_refs", "recommendations", "pending_items", "data_gaps"))
|
||||
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
|
||||
):
|
||||
await self._resolve_notifications(
|
||||
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
|
||||
|
||||
@@ -6,6 +6,7 @@ from pydantic import ValidationError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.tianditu_logging import install_tianditu_token_filter
|
||||
from app.domain.schemas.project_map_config import PlaceSearchConfig
|
||||
from app.domain.schemas.place_search import (
|
||||
MAX_PLACE_SEARCH_RESULTS,
|
||||
PlaceSearchItem,
|
||||
@@ -13,7 +14,6 @@ from app.domain.schemas.place_search import (
|
||||
PlaceSearchResponse,
|
||||
)
|
||||
|
||||
FENGXIAN_ADMIN_CODE = "156310120"
|
||||
install_tianditu_token_filter()
|
||||
|
||||
|
||||
@@ -24,11 +24,37 @@ class PlaceSearchError(RuntimeError):
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class PlaceSearchScopeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_place_scope(
|
||||
config: PlaceSearchConfig, *, county_code: str, county: str, province_code: str = "",
|
||||
) -> None:
|
||||
admin_code = config.admin_code.removeprefix("156")
|
||||
province_code = province_code.removeprefix("156")
|
||||
county_code = county_code.removeprefix("156")
|
||||
if province_code and province_code[:2] != admin_code[:2]:
|
||||
raise PlaceSearchScopeError("Result outside configured province")
|
||||
scope_prefix = admin_code
|
||||
if admin_code.endswith("0000"):
|
||||
scope_prefix = admin_code[:2]
|
||||
elif admin_code.endswith("00"):
|
||||
scope_prefix = admin_code[:4]
|
||||
if county_code and not county_code.startswith(scope_prefix):
|
||||
raise PlaceSearchScopeError("Result outside configured district")
|
||||
if (len(scope_prefix) == 6 and county and county != config.region) or (not county_code and county != config.region):
|
||||
raise PlaceSearchScopeError("Missing or mismatched district")
|
||||
|
||||
|
||||
async def search_tianditu_places(
|
||||
request: PlaceSearchRequest,
|
||||
*,
|
||||
config: PlaceSearchConfig,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> PlaceSearchResponse:
|
||||
if not config.enabled:
|
||||
raise PlaceSearchError(503, "当前项目暂未配置地名搜索")
|
||||
token = settings.TIANDITU_TOKEN
|
||||
if not token:
|
||||
raise PlaceSearchError(503, "地名搜索服务未配置")
|
||||
@@ -37,7 +63,7 @@ async def search_tianditu_places(
|
||||
{
|
||||
"keyWord": request.keyword,
|
||||
"queryType": 12,
|
||||
"specify": FENGXIAN_ADMIN_CODE,
|
||||
"specify": "156" + config.admin_code.removeprefix("156"),
|
||||
"start": 0,
|
||||
"count": MAX_PLACE_SEARCH_RESULTS,
|
||||
"show": 2,
|
||||
@@ -64,15 +90,15 @@ async def search_tianditu_places(
|
||||
# Request URLs contain the provider key and must not reach the client.
|
||||
raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None
|
||||
try:
|
||||
return _parse_response(response.json())
|
||||
return _parse_response(response.json(), config)
|
||||
except (ValueError, TypeError, KeyError, AttributeError, ValidationError):
|
||||
raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None
|
||||
|
||||
|
||||
def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
|
||||
def _parse_response(payload: dict[str, Any], config: PlaceSearchConfig) -> PlaceSearchResponse:
|
||||
code = str(payload.get("status", {}).get("infocode", ""))
|
||||
if code == "3001":
|
||||
return PlaceSearchResponse(items=[], total=0)
|
||||
return PlaceSearchResponse(items=[], total=0, region=config.region)
|
||||
if code != "1000":
|
||||
raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试")
|
||||
|
||||
@@ -84,15 +110,11 @@ def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
|
||||
raise ValueError("Invalid search results")
|
||||
items = []
|
||||
for raw in raw_items:
|
||||
admin_code = str(raw.get("provinceCode") or raw.get("adminCode") or "")
|
||||
if admin_code and not admin_code.startswith(("156310", "310")):
|
||||
raise ValueError("Result outside Shanghai")
|
||||
county_code = str(raw.get("countyCode") or (raw.get("adminCode") if result_type == "3" else "") or "")
|
||||
county = raw.get("county")
|
||||
if county_code and not county_code.startswith((FENGXIAN_ADMIN_CODE, "310120")):
|
||||
raise ValueError("Result outside Fengxian")
|
||||
if county and county != "奉贤区" or not county_code and county != "奉贤区":
|
||||
raise ValueError("Missing or mismatched district")
|
||||
validate_place_scope(
|
||||
config, county_code=str(raw.get("countyCode") or raw.get("adminCode") or ""),
|
||||
county=str(county or ""), province_code=str(raw.get("provinceCode") or ""),
|
||||
)
|
||||
lon, lat = str(raw["lonlat"]).split(",")
|
||||
name = raw["name"]
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
@@ -105,11 +127,11 @@ def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
|
||||
address=str(raw.get("address") or name),
|
||||
longitude=float(lon), latitude=float(lat),
|
||||
category="行政区" if result_type == "3" else category,
|
||||
district="奉贤区",
|
||||
district=str(county or config.region),
|
||||
))
|
||||
total = int(payload.get("count", 0))
|
||||
if total < len(items):
|
||||
raise ValueError("Invalid search total")
|
||||
return PlaceSearchResponse(
|
||||
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS,
|
||||
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS, region=config.region,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.domain.schemas.place_search import PlaceSearchItem, ReverseGeocodingRequest
|
||||
from app.domain.schemas.project_map_config import PlaceSearchConfig
|
||||
from app.services.place_search import PlaceSearchError, PlaceSearchScopeError, validate_place_scope
|
||||
|
||||
|
||||
async def reverse_geocode_tianditu(
|
||||
request: ReverseGeocodingRequest,
|
||||
*,
|
||||
config: PlaceSearchConfig,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> PlaceSearchItem:
|
||||
if not config.enabled:
|
||||
raise PlaceSearchError(503, "当前项目暂未配置地名搜索")
|
||||
if not settings.TIANDITU_TOKEN:
|
||||
raise PlaceSearchError(503, "地名搜索服务未配置")
|
||||
params = {
|
||||
"postStr": json.dumps({"lon": request.longitude, "lat": request.latitude, "ver": 1}),
|
||||
"type": "geocode",
|
||||
"tk": settings.TIANDITU_TOKEN,
|
||||
}
|
||||
try:
|
||||
if client is not None:
|
||||
response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params)
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS) as managed:
|
||||
response = await managed.get(settings.TIANDITU_GEOCODER_URL, params=params)
|
||||
response.raise_for_status()
|
||||
except httpx.TimeoutException:
|
||||
raise PlaceSearchError(504, "地图取点查询超时,请重新取点") from None
|
||||
except httpx.HTTPError:
|
||||
raise PlaceSearchError(502, "天地图反编码服务暂不可用,请重新取点") from None
|
||||
try:
|
||||
return _parse_reverse_response(response.json(), request, config)
|
||||
except PlaceSearchScopeError:
|
||||
raise PlaceSearchError(422, f"取点位置不在当前项目的地名搜索范围内({config.region})") from None
|
||||
except (ValueError, TypeError, KeyError, AttributeError):
|
||||
raise PlaceSearchError(502, "天地图返回了无效的反编码结果,请重新取点") from None
|
||||
|
||||
|
||||
def _parse_reverse_response(
|
||||
payload: dict[str, Any], request: ReverseGeocodingRequest, config: PlaceSearchConfig,
|
||||
) -> PlaceSearchItem:
|
||||
if str(payload.get("status")) != "0":
|
||||
raise PlaceSearchError(502, "天地图反编码请求失败,请重新取点")
|
||||
result = payload.get("result")
|
||||
if not result:
|
||||
raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点")
|
||||
address = result.get("formatted_address")
|
||||
if not isinstance(address, str) or not address.strip():
|
||||
raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点")
|
||||
component = result.get("addressComponent") or {}
|
||||
validate_place_scope(
|
||||
config, county_code=str(component.get("county_code") or ""),
|
||||
county=str(component.get("county") or ""),
|
||||
province_code=str(component.get("province_code") or ""),
|
||||
)
|
||||
name = component.get("poi") or component.get("road") or address
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("Invalid place name")
|
||||
# A nearby POI can have a different center. Always retain the clicked point.
|
||||
return PlaceSearchItem(
|
||||
id=f"map:{request.longitude:.8f},{request.latitude:.8f}",
|
||||
name=name.strip(), address=address.strip(), longitude=request.longitude, latitude=request.latitude,
|
||||
category="附近地名" if component.get("poi") else "地址",
|
||||
district=str(component.get("county") or config.region),
|
||||
)
|
||||
@@ -77,8 +77,9 @@ def burst_analysis(
|
||||
modify_valve_opening: dict[str, float] = None,
|
||||
scheme_name: str = None,
|
||||
username: str | None = None,
|
||||
persist_results: bool = True,
|
||||
_temporary_project: str | None = None,
|
||||
) -> None:
|
||||
) -> dict | None:
|
||||
"""
|
||||
爆管模拟
|
||||
:param name: 模型名称,数据库中对应的名字
|
||||
@@ -92,7 +93,7 @@ def burst_analysis(
|
||||
:param scheme_name: 方案名称
|
||||
:return:
|
||||
"""
|
||||
if not username:
|
||||
if persist_results and not username:
|
||||
raise ValueError("username is required when storing burst analysis scheme")
|
||||
|
||||
scheme_detail: dict = {
|
||||
@@ -193,7 +194,7 @@ def burst_analysis(
|
||||
# modify_pump_pattern=modify_pump_pattern,
|
||||
# valve_control=valve_control,
|
||||
# downloading_prohibition=True)
|
||||
simulation.run_simulation(
|
||||
result = simulation.run_simulation(
|
||||
name=new_name,
|
||||
simulation_type="extended",
|
||||
modify_pattern_start_time=modify_pattern_start_time,
|
||||
@@ -206,7 +207,9 @@ def burst_analysis(
|
||||
result_db_name=name,
|
||||
scheme_username=username,
|
||||
scheme_detail=scheme_detail,
|
||||
persist_results=persist_results,
|
||||
)
|
||||
return result if not persist_results else None
|
||||
# step 3. restore the base model status
|
||||
# execute_undo(name) #有疑惑
|
||||
|
||||
@@ -223,6 +226,7 @@ def valve_close_analysis(
|
||||
scheme_name: str = None,
|
||||
_temporary_project: str | None = None,
|
||||
persist_results: bool = True,
|
||||
username: str = "system",
|
||||
) -> dict | str | None:
|
||||
"""
|
||||
关阀模拟
|
||||
@@ -282,6 +286,7 @@ def valve_close_analysis(
|
||||
scheme_name=scheme_name,
|
||||
result_db_name=name,
|
||||
persist_results=persist_results,
|
||||
scheme_username=username,
|
||||
)
|
||||
return result if not persist_results else None
|
||||
# step 3. restore the base model
|
||||
@@ -610,6 +615,7 @@ def pressure_regulation(
|
||||
scada_mappings: simulation.ScadaElementMappings | None = None,
|
||||
_temporary_project: str | None = None,
|
||||
persist_results: bool = True,
|
||||
username: str = "system",
|
||||
) -> dict | str | None:
|
||||
"""
|
||||
区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响
|
||||
@@ -668,6 +674,7 @@ def pressure_regulation(
|
||||
result_db_name=name,
|
||||
scada_mappings=scada_mappings,
|
||||
persist_results=persist_results,
|
||||
scheme_username=username,
|
||||
)
|
||||
return result if not persist_results else None
|
||||
# return result
|
||||
|
||||
Reference in New Issue
Block a user