diff --git a/.env.example b/.env.example index 090efeb..a43c131 100644 --- a/.env.example +++ b/.env.example @@ -1,127 +1,135 @@ -# TJWater Server 环境变量配置模板 -# 复制此文件为 .env 并填写实际值 -# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。 -ENVIRONMENT="production" -NETWORK_NAME="tjwater" -# ============================================ -# 敏感配置加密 (必填) -# ============================================ +# TJWater Server 环境变量模板 +# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。 +# 生产配置由部署主机的受控 backend.env 注入,不要把完整配置保存为仓库密钥。 -# Fernet 格式,生产环境必须替换为独立密钥 -# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置 +# 基础运行 +PROJECT_NAME="TJWater Server" +ENVIRONMENT="production" +API_V1_STR="/api/v1" +NETWORK_NAME="tjwater" + +# Fernet 格式;用于加密项目数据库 DSN、GeoServer 管理密码等敏感配置。 +# 生成方式:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key" -# ============================================ -# 数据库配置 (PostgreSQL) -# ============================================ +# 业务数据库(PostgreSQL) DB_NAME="tjwater" DB_HOST="localhost" DB_PORT="5432" DB_USER="tjwater" DB_PASSWORD="password" -# ============================================ -# 数据库配置 (TimescaleDB) -# ============================================ +# 时序数据库(TimescaleDB) TIMESCALEDB_DB_NAME="tjwater" TIMESCALEDB_DB_HOST="localhost" TIMESCALEDB_DB_PORT="5433" TIMESCALEDB_DB_USER="tjwater" TIMESCALEDB_DB_PASSWORD="password" -# 实时快照替换事务的解压预算;仅事务内生效,禁止0(无限制)。 +# 实时快照替换事务的解压预算;仅事务内生效,禁止设为 0(无限制)。 TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES=1000000 -# ============================================ -# 元数据数据库配置 (Metadata DB) -# ============================================ +# 元数据数据库 METADATA_DB_NAME="system_hub" METADATA_DB_HOST="localhost" METADATA_DB_PORT="5432" METADATA_DB_USER="tjwater" METADATA_DB_PASSWORD="password" +METADATA_DB_POOL_SIZE=5 +METADATA_DB_MAX_OVERFLOW=10 -# Per-project synchronous connection pools -PROJECT_PG_CACHE_SIZE="16" -PROJECT_TS_CACHE_SIZE="16" -PROJECT_PG_POOL_MIN_SIZE="0" -PROJECT_PG_POOL_SIZE="4" -PROJECT_PG_MAX_OVERFLOW="2" -PROJECT_TS_POOL_MIN_SIZE="0" -PROJECT_TS_POOL_MAX_SIZE="4" +# 项目数据库连接池、缓存与模板 +PROJECT_PG_CACHE_SIZE=16 +PROJECT_TS_CACHE_SIZE=16 +PROJECT_PG_POOL_MIN_SIZE=0 +PROJECT_PG_POOL_SIZE=4 +PROJECT_PG_MAX_OVERFLOW=2 +PROJECT_TS_POOL_MIN_SIZE=0 +PROJECT_TS_POOL_MAX_SIZE=4 WNDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_schema_template" TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_timescale_template" -WNDB_TEMP_DB_MAX_COUNT="8" +WNDB_TEMP_DB_MAX_COUNT=8 -# ============================================ # GeoServer 项目供应 -# ============================================ GEOSERVER_URL="http://localhost:8080/geoserver" GEOSERVER_USERNAME="admin" GEOSERVER_PASSWORD="password" -# 留空时分别复用 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD。 +# 留空时分别复用 DB_HOST、DB_PORT、DB_USER、DB_PASSWORD。 # GeoServer 在容器内运行时,GEOSERVER_DB_HOST 通常应填写数据库服务名。 GEOSERVER_DB_HOST="" GEOSERVER_DB_PORT="" GEOSERVER_DB_USER="" GEOSERVER_DB_PASSWORD="" -# 30 天客户端缓存,单位为秒。 -GEOSERVER_CLIENT_CACHE_SECONDS="2592000" +# 客户端缓存时间,单位为秒;默认 30 天。 +GEOSERVER_CLIENT_CACHE_SECONDS=2592000 -# ============================================ -# Keycloak JWT (可选) -# ============================================ -KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" -KEYCLOAK_ALGORITHM=RS256 +# Keycloak JWT 校验 +# 未启用认证时可留空;生产环境应注入完整公钥和预期 audience。 +KEYCLOAK_PUBLIC_KEY="" +KEYCLOAK_ALGORITHM="RS256" KEYCLOAK_AUDIENCE="account" KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900 -# ============================================ -# 工作台事件同步 -# ============================================ +# 工作台事件与跨域 # 认证接口必须使用明确来源,逗号分隔;不要配置为 *。 -CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173" +CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173" WORKBENCH_EVENTS_ENABLED=true WORKBENCH_EVENT_RETENTION_DAYS=7 -# 每个项目启动一个 worker 实例。长期运行优先使用 OAuth 客户端凭据。 +# 天地图服务 +# 地理编码、逆地理编码和地名搜索共用同一令牌。 +TIANDITU_TOKEN="" +TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder" +TIANDITU_GEOCODER_TIMEOUT_SECONDS=30 +TIANDITU_SEARCH_URL="https://api.tianditu.gov.cn/v2/search" +TIANDITU_SEARCH_TIMEOUT_SECONDS=15 + +# 调查任务 worker(独立进程) +# 单项目填写 PROJECT_ID;多项目填写逗号分隔的 PROJECT_IDS,后者优先。 +# worker 与外围工单同步器共用项目范围,服务身份必须拥有每个项目的权限。 OPERATIONAL_WORKER_PROJECT_ID="" +OPERATIONAL_WORKER_PROJECT_IDS="" OPERATIONAL_WORKER_SERVER_URL="http://127.0.0.1:8000" OPERATIONAL_WORKER_AGENT_URL="http://127.0.0.1:8787" OPERATIONAL_WORKER_RECONCILE_SECONDS=30 OPERATIONAL_WORKER_MAX_CONCURRENCY=4 +# 可选 ISO 8601 时间下界,必须包含时区,例如 2026-10-01T00:00:00+08:00。 +OPERATIONAL_WORKER_TASK_CREATED_FROM="" + +# 长期运行使用 OAuth 客户端凭据;固定 Bearer 仅用于受控开发或短期运行。 OPERATIONAL_WORKER_TOKEN_URL="" +# 仅 ENVIRONMENT=development 时覆盖 TOKEN_URL,其他环境忽略。 +OPERATIONAL_WORKER_DEV_TOKEN_URL="" OPERATIONAL_WORKER_CLIENT_ID="" OPERATIONAL_WORKER_CLIENT_SECRET="" OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS=300 +OPERATIONAL_WORKER_BEARER="" + +# 调查数据适配器 +OPERATIONAL_DATA_SERVER_URL="http://127.0.0.1:8000" +OPERATIONAL_DATA_PERIPHERAL_URL="" +OPERATIONAL_DATA_TIMEOUT_SECONDS=15 +# 仅供 Server 在已批准的兼容委托路径中使用,不传给 Agent。 OPERATIONAL_DATA_DELEGATION_BEARER="" +# 调查动作适配器 +OPERATIONAL_ACTION_PERIPHERAL_URL="" +OPERATIONAL_ACTION_COMMAND_GATEWAY_URL="" +OPERATIONAL_ACTION_TIMEOUT_SECONDS=10 -# ============================================ -# Tianditu shared API key and service URLs -# ============================================ -TIANDITU_TOKEN="your-tianditu-token" -TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder" -TIANDITU_GEOCODER_TIMEOUT_SECONDS=30 - -# 地名搜索与地理编码共用 TIANDITU_TOKEN;也可沿用 TJWATER_TIANDITU_TOKEN。 -TIANDITU_SEARCH_URL="https://api.tianditu.gov.cn/v2/search" -TIANDITU_SEARCH_TIMEOUT_SECONDS=15 - -# MCP query resource, off until OAuth and every project BizDB are provisioned. -# 仅 ENVIRONMENT=development 时覆盖 worker 取令牌地址;留空沿用 OPERATIONAL_WORKER_TOKEN_URL。 -OPERATIONAL_WORKER_DEV_TOKEN_URL= +# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。 TJWATER_MCP_ENABLED=false -TJWATER_MCP_ISSUER_URI= -# 仅 ENVIRONMENT=development,可填写与 issuer 完全相同的内网 HTTP IPv4 issuer。 -# 正式环境必须留空;此例外不放行远程 HTTP MCP resource。 -TJWATER_MCP_DEV_HTTP_ISSUER_URI= -TJWATER_MCP_RESOURCE_URI= -TJWATER_MCP_AUDIENCE=tjwater-mcp -TJWATER_MCP_HOST_CLIENT_ID=tjwater-agent-mcp -TJWATER_MCP_ALLOWED_HOSTS=["localhost:*","127.0.0.1:*"] -TJWATER_MCP_ALLOWED_ORIGINS=[] -# 普通查询预算;计算工具由 Agent 用 timeout_seconds 按次指定(默认120秒)。 +TJWATER_MCP_ISSUER_URI="" +# 仅 ENVIRONMENT=development:可与 ISSUER_URI 同值并指向内网 HTTP IPv4 issuer。 +# 正式环境必须留空;该例外不允许远程 HTTP MCP resource。 +TJWATER_MCP_DEV_HTTP_ISSUER_URI="" +TJWATER_MCP_RESOURCE_URI="" +TJWATER_MCP_AUDIENCE="tjwater-mcp" +TJWATER_MCP_HOST_CLIENT_ID="tjwater-agent-mcp" +# 列表使用 JSON 格式。 +TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]' +TJWATER_MCP_ALLOWED_ORIGINS='[]' +TJWATER_MCP_CONTEXT_TTL_SECONDS=600 +# 普通查询预算;计算工具由 Agent 通过 timeout_seconds 按次指定。 TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30 -# 计算等待上限,默认1小时;管理员可配置120至86400秒,实际上限通过 tools/list 发布。 +# 计算等待上限为 120–86400 秒,实际上限通过 tools/list 发布。 TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600 diff --git a/.gitea/workflows/package.yml b/.gitea/workflows/package.yml index b0f35dd..b40619f 100644 --- a/.gitea/workflows/package.yml +++ b/.gitea/workflows/package.yml @@ -10,11 +10,11 @@ jobs: build-test-publish-and-deploy: uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@68c8a9855391baa31d31523674f5812cd24ec604 with: - image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend + image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-backend dockerfile: Dockerfile build_context: . test_target: test - deploy_service: backend + deploy_service: next-backend deploy_host: 192.168.1.114 secrets: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} diff --git a/app/api/v1/endpoints/admin_metadata.py b/app/api/v1/endpoints/admin_metadata.py index a69579e..872499c 100644 --- a/app/api/v1/endpoints/admin_metadata.py +++ b/app/api/v1/endpoints/admin_metadata.py @@ -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, ) diff --git a/app/api/v1/endpoints/meta.py b/app/api/v1/endpoints/meta.py index 59a0cc1..7b8be7d 100644 --- a/app/api/v1/endpoints/meta.py +++ b/app/api/v1/endpoints/meta.py @@ -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, ) diff --git a/app/api/v1/endpoints/model_import.py b/app/api/v1/endpoints/model_import.py index d43b2dc..329ba4a 100644 --- a/app/api/v1/endpoints/model_import.py +++ b/app/api/v1/endpoints/model_import.py @@ -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, diff --git a/app/api/v1/endpoints/place_search.py b/app/api/v1/endpoints/place_search.py index fc897a9..51f1cda 100644 --- a/app/api/v1/endpoints/place_search.py +++ b/app/api/v1/endpoints/place_search.py @@ -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 diff --git a/app/api/v1/endpoints/project.py b/app/api/v1/endpoints/project.py index 24bf2f5..de2d599 100644 --- a/app/api/v1/endpoints/project.py +++ b/app/api/v1/endpoints/project.py @@ -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 ) diff --git a/app/api/v1/endpoints/timeseries/scada.py b/app/api/v1/endpoints/timeseries/scada.py index 0fbeccd..f02976a 100644 --- a/app/api/v1/endpoints/timeseries/scada.py +++ b/app/api/v1/endpoints/timeseries/scada.py @@ -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数据" ) diff --git a/app/domain/schemas/admin_metadata.py b/app/domain/schemas/admin_metadata.py index 1bc9d7d..eef0e5b 100644 --- a/app/domain/schemas/admin_metadata.py +++ b/app/domain/schemas/admin_metadata.py @@ -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 diff --git a/app/domain/schemas/answer_ui.py b/app/domain/schemas/answer_ui.py new file mode 100644 index 0000000..fa4156a --- /dev/null +++ b/app/domain/schemas/answer_ui.py @@ -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") diff --git a/app/domain/schemas/metadata.py b/app/domain/schemas/metadata.py index f161a5f..b358edd 100644 --- a/app/domain/schemas/metadata.py +++ b/app/domain/schemas/metadata.py @@ -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 diff --git a/app/domain/schemas/operational_data.py b/app/domain/schemas/operational_data.py index ba6bde3..dcc90b6 100644 --- a/app/domain/schemas/operational_data.py +++ b/app/domain/schemas/operational_data.py @@ -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', diff --git a/app/domain/schemas/operational_task.py b/app/domain/schemas/operational_task.py index 32748db..0d3726d 100644 --- a/app/domain/schemas/operational_task.py +++ b/app/domain/schemas/operational_task.py @@ -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") diff --git a/app/domain/schemas/place_search.py b/app/domain/schemas/place_search.py index 97d08db..36e8b7b 100644 --- a/app/domain/schemas/place_search.py +++ b/app/domain/schemas/place_search.py @@ -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) diff --git a/app/domain/schemas/project_map_config.py b/app/domain/schemas/project_map_config.py new file mode 100644 index 0000000..26d4a3e --- /dev/null +++ b/app/domain/schemas/project_map_config.py @@ -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 diff --git a/app/domain/schemas/scada.py b/app/domain/schemas/scada.py index cc2cfef..1b0b379 100644 --- a/app/domain/schemas/scada.py +++ b/app/domain/schemas/scada.py @@ -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 diff --git a/app/infra/db/metadb/models.py b/app/infra/db/metadb/models.py index 5588954..c510e6d 100644 --- a/app/infra/db/metadb/models.py +++ b/app/infra/db/metadb/models.py @@ -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 diff --git a/app/infra/db/metadb/repositories/metadata_repository.py b/app/infra/db/metadb/repositories/metadata_repository.py index a109b63..41b1f07 100644 --- a/app/infra/db/metadb/repositories/metadata_repository.py +++ b/app/infra/db/metadb/repositories/metadata_repository.py @@ -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, ) diff --git a/app/infra/db/postgresql/mcp_queries.py b/app/infra/db/postgresql/mcp_queries.py index 1e16a7b..e5589f5 100644 --- a/app/infra/db/postgresql/mcp_queries.py +++ b/app/infra/db/postgresql/mcp_queries.py @@ -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), + ) diff --git a/app/infra/db/postgresql/workbench_events.py b/app/infra/db/postgresql/workbench_events.py index c873258..239e318 100644 --- a/app/infra/db/postgresql/workbench_events.py +++ b/app/infra/db/postgresql/workbench_events.py @@ -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: diff --git a/app/infra/db/timescaledb/repositories/scada.py b/app/infra/db/timescaledb/repositories/scada.py index 8f41c65..89c047d 100644 --- a/app/infra/db/timescaledb/repositories/scada.py +++ b/app/infra/db/timescaledb/repositories/scada.py @@ -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, diff --git a/app/infra/epanet/epanet.py b/app/infra/epanet/epanet.py index cc86302..0371f45 100644 --- a/app/infra/epanet/epanet.py +++ b/app/infra/epanet/epanet.py @@ -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() diff --git a/app/mcp/analysis_service.py b/app/mcp/analysis_service.py index 2a8bf42..c0d70f5 100644 --- a/app/mcp/analysis_service.py +++ b/app/mcp/analysis_service.py @@ -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) diff --git a/app/mcp/application.py b/app/mcp/application.py index 94a8f8e..6401ffe 100644 --- a/app/mcp/application.py +++ b/app/mcp/application.py @@ -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", diff --git a/app/mcp/catalog.py b/app/mcp/catalog.py index d0e98a0..0865e87 100644 --- a/app/mcp/catalog.py +++ b/app/mcp/catalog.py @@ -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} diff --git a/app/mcp/contexts.py b/app/mcp/contexts.py index 0d2c8b3..e83c33c 100644 --- a/app/mcp/contexts.py +++ b/app/mcp/contexts.py @@ -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, diff --git a/app/mcp/errors.py b/app/mcp/errors.py new file mode 100644 index 0000000..b502c91 --- /dev/null +++ b/app/mcp/errors.py @@ -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.", + ) diff --git a/app/mcp/extended_models.py b/app/mcp/extended_models.py new file mode 100644 index 0000000..0a9419c --- /dev/null +++ b/app/mcp/extended_models.py @@ -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 diff --git a/app/mcp/extended_service.py b/app/mcp/extended_service.py new file mode 100644 index 0000000..c282625 --- /dev/null +++ b/app/mcp/extended_service.py @@ -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) diff --git a/app/mcp/models.py b/app/mcp/models.py index 202101d..9c0a7ab 100644 --- a/app/mcp/models.py +++ b/app/mcp/models.py @@ -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", ] diff --git a/app/mcp/mutation_service.py b/app/mcp/mutation_service.py new file mode 100644 index 0000000..afe4496 --- /dev/null +++ b/app/mcp/mutation_service.py @@ -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 diff --git a/app/mcp/query_service.py b/app/mcp/query_service.py index 7c040ad..0996a9d 100644 --- a/app/mcp/query_service.py +++ b/app/mcp/query_service.py @@ -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": diff --git a/app/mcp/scenario_service.py b/app/mcp/scenario_service.py index 8b9dea2..d55c000 100644 --- a/app/mcp/scenario_service.py +++ b/app/mcp/scenario_service.py @@ -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")) diff --git a/app/services/operational_tasks.py b/app/services/operational_tasks.py index ab4fb50..bbe8806 100644 --- a/app/services/operational_tasks.py +++ b/app/services/operational_tasks.py @@ -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, diff --git a/app/services/place_search.py b/app/services/place_search.py index 3a3a3a8..63faad1 100644 --- a/app/services/place_search.py +++ b/app/services/place_search.py @@ -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, ) diff --git a/app/services/reverse_geocoding.py b/app/services/reverse_geocoding.py new file mode 100644 index 0000000..5ae8844 --- /dev/null +++ b/app/services/reverse_geocoding.py @@ -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), + ) diff --git a/app/services/simulation_scenarios.py b/app/services/simulation_scenarios.py index bc068b3..846f15e 100644 --- a/app/services/simulation_scenarios.py +++ b/app/services/simulation_scenarios.py @@ -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 diff --git a/contracts/answer-ui-catalog.json b/contracts/answer-ui-catalog.json new file mode 100644 index 0000000..ab9bbc6 --- /dev/null +++ b/contracts/answer-ui-catalog.json @@ -0,0 +1,454 @@ +{ + "version": "answer-ui@1", + "description": "Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.", + "schema": { + "type": "object", + "properties": { + "root": { + "type": "string" + }, + "elements": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "enum": [ + "Stack", + "Section", + "Text", + "Metric", + "Table", + "Chart", + "Evidence", + "MapLocation", + "FollowUpForm" + ] + }, + "props": { + "type": "object", + "additionalProperties": {} + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "slots": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "visible": {}, + "repeat": {} + }, + "required": [ + "type", + "props", + "children" + ], + "additionalProperties": false + } + } + }, + "required": [ + "root", + "elements" + ], + "additionalProperties": false + }, + "components": [ + "Stack", + "Section", + "Text", + "Metric", + "Table", + "Chart", + "Evidence", + "MapLocation", + "FollowUpForm" + ], + "props": { + "Stack": { + "type": "object", + "properties": { + "columns": { + "type": "string", + "enum": [ + "one", + "two" + ] + }, + "gap": { + "type": "string", + "enum": [ + "compact", + "normal" + ] + } + }, + "required": [ + "columns", + "gap" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Section": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 4000 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "title", + "description" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Text": { + "type": "object", + "properties": { + "text": { + "type": "string", + "maxLength": 4000 + }, + "tone": { + "type": "string", + "enum": [ + "neutral", + "info", + "warning" + ] + } + }, + "required": [ + "text", + "tone" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Metric": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 200 + }, + "value": { + "type": "string", + "maxLength": 200 + }, + "unit": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "source": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "value", + "unit", + "source" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Table": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200 + }, + "minItems": 1, + "maxItems": 8 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "maxLength": 1000 + }, + "maxItems": 8 + }, + "maxItems": 50 + } + }, + "required": [ + "title", + "columns", + "rows" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Chart": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "chart_type": { + "type": "string", + "enum": [ + "line", + "bar" + ] + }, + "x_data": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200 + }, + "minItems": 1, + "maxItems": 100 + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "data": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 100 + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 8 + }, + "source": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "title", + "chart_type", + "x_data", + "series", + "source" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Evidence": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "summary": { + "type": "string", + "maxLength": 4000 + }, + "source": { + "type": "string", + "maxLength": 200 + }, + "evidence_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "observed_at": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "observed", + "human_input", + "inference", + "demo" + ] + } + }, + "required": [ + "title", + "summary", + "source", + "evidence_id", + "observed_at", + "kind" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "MapLocation": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 200 + }, + "layer": { + "type": "string", + "enum": [ + "junctions", + "pipes", + "valves", + "pumps", + "tanks", + "reservoirs", + "scada" + ] + }, + "feature_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "minItems": 1, + "maxItems": 100 + }, + "source": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "label", + "layer", + "feature_ids", + "source" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "FollowUpForm": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 4000 + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,39}$" + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "kind": { + "type": "string", + "enum": [ + "text", + "datetime", + "number" + ] + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "name", + "label", + "kind", + "required" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 12 + } + }, + "required": [ + "title", + "description", + "fields" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } +} diff --git a/contracts/manifest.json b/contracts/manifest.json index b663c59..08282e1 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "949077b0e1c711cfef78319d24cbe081e8d71444e7b25018507e12ba374018a3" + "sha256": "66fbf1105aa966a590f93fac176cbc77d22a4a6cf0a94dfcd1d3c27273f73692" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index 4df21b0..18f5f3a 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -283,6 +283,9 @@ "title": "Gs Workspace", "type": "string" }, + "map_config": { + "$ref": "#/components/schemas/ProjectMapConfig" + }, "map_extent": { "anyOf": [ { @@ -346,6 +349,9 @@ "title": "Gs Workspace", "type": "string" }, + "map_config": { + "$ref": "#/components/schemas/ProjectMapConfig" + }, "map_extent": { "anyOf": [ { @@ -428,6 +434,16 @@ ], "title": "Gs Workspace" }, + "map_config": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectMapConfig" + }, + { + "type": "null" + } + ] + }, "map_extent": { "anyOf": [ { @@ -1128,6 +1144,93 @@ "title": "AgentTaskContext", "type": "object" }, + "AnswerUiBlock": { + "additionalProperties": false, + "properties": { + "block_id": { + "pattern": "^[a-zA-Z0-9_-]{1,80}$", + "title": "Block Id", + "type": "string" + }, + "fallback_text": { + "maxLength": 4000, + "minLength": 1, + "title": "Fallback Text", + "type": "string" + }, + "spec": { + "$ref": "#/components/schemas/AnswerUiSpec" + }, + "version": { + "const": "answer-ui@1", + "title": "Version", + "type": "string" + } + }, + "required": [ + "version", + "block_id", + "fallback_text", + "spec" + ], + "title": "AnswerUiBlock", + "type": "object" + }, + "AnswerUiElement": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "pattern": "^[a-zA-Z0-9_-]{1,80}$", + "type": "string" + }, + "maxItems": 48, + "title": "Children", + "type": "array" + }, + "props": { + "additionalProperties": true, + "title": "Props", + "type": "object" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "props", + "children" + ], + "title": "AnswerUiElement", + "type": "object" + }, + "AnswerUiSpec": { + "additionalProperties": false, + "properties": { + "elements": { + "patternProperties": { + "^[a-zA-Z0-9_-]{1,80}$": { + "$ref": "#/components/schemas/AnswerUiElement" + } + }, + "title": "Elements", + "type": "object" + }, + "root": { + "pattern": "^[a-zA-Z0-9_-]{1,80}$", + "title": "Root", + "type": "string" + } + }, + "required": [ + "root", + "elements" + ], + "title": "AnswerUiSpec", + "type": "object" + }, "AuditLogResponse": { "description": "审计日志响应", "properties": { @@ -3681,6 +3784,14 @@ "minLength": 1, "title": "Title", "type": "string" + }, + "ui_blocks": { + "items": { + "$ref": "#/components/schemas/ReportUiBlock" + }, + "maxItems": 8, + "title": "Ui Blocks", + "type": "array" } }, "required": [ @@ -3778,6 +3889,14 @@ "minLength": 1, "title": "Title", "type": "string" + }, + "ui_blocks": { + "items": { + "$ref": "#/components/schemas/ReportUiBlock" + }, + "maxItems": 8, + "title": "Ui Blocks", + "type": "array" } }, "required": [ @@ -5923,6 +6042,39 @@ "title": "Page[str]", "type": "object" }, + "PlaceSearchConfig": { + "additionalProperties": false, + "properties": { + "admin_code": { + "pattern": "^(?:156)?[0-9]{6}$", + "title": "Admin Code", + "type": "string" + }, + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "provider": { + "const": "tianditu", + "default": "tianditu", + "title": "Provider", + "type": "string" + }, + "region": { + "maxLength": 100, + "minLength": 1, + "title": "Region", + "type": "string" + } + }, + "required": [ + "admin_code", + "region" + ], + "title": "PlaceSearchConfig", + "type": "object" + }, "PlaceSearchItem": { "properties": { "address": { @@ -5941,8 +6093,7 @@ "title": "Category" }, "district": { - "const": "奉贤区", - "default": "奉贤区", + "minLength": 1, "title": "District", "type": "string" }, @@ -5974,7 +6125,8 @@ "name", "address", "longitude", - "latitude" + "latitude", + "district" ], "title": "PlaceSearchItem", "type": "object" @@ -6006,8 +6158,7 @@ "type": "array" }, "region": { - "const": "奉贤区", - "default": "奉贤区", + "minLength": 1, "title": "Region", "type": "string" }, @@ -6025,7 +6176,8 @@ }, "required": [ "items", - "total" + "total", + "region" ], "title": "PlaceSearchResponse", "type": "object" @@ -6300,6 +6452,23 @@ "title": "ProjectDatabaseUpsertRequest", "type": "object" }, + "ProjectMapConfig": { + "additionalProperties": true, + "properties": { + "place_search": { + "anyOf": [ + { + "$ref": "#/components/schemas/PlaceSearchConfig" + }, + { + "type": "null" + } + ] + } + }, + "title": "ProjectMapConfig", + "type": "object" + }, "ProjectMemberCreateRequest": { "properties": { "project_role": { @@ -6407,6 +6576,9 @@ "title": "Gs Workspace", "type": "string" }, + "map_config": { + "$ref": "#/components/schemas/ProjectMapConfig" + }, "map_extent": { "anyOf": [ { @@ -6774,6 +6946,54 @@ "title": "RecoverExpiredLeasesResult", "type": "object" }, + "ReportUiBlock": { + "additionalProperties": false, + "properties": { + "block": { + "$ref": "#/components/schemas/AnswerUiBlock" + }, + "evidence_refs": { + "items": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "title": "Evidence Refs", + "type": "array" + } + }, + "required": [ + "block", + "evidence_refs" + ], + "title": "ReportUiBlock", + "type": "object" + }, + "ReverseGeocodingRequest": { + "additionalProperties": false, + "properties": { + "latitude": { + "maximum": 90.0, + "minimum": -90.0, + "title": "Latitude", + "type": "number" + }, + "longitude": { + "maximum": 180.0, + "minimum": -180.0, + "title": "Longitude", + "type": "number" + } + }, + "required": [ + "longitude", + "latitude" + ], + "title": "ReverseGeocodingRequest", + "type": "object" + }, "RunSimulationManuallyByDateRest": { "properties": { "duration": { @@ -6917,6 +7137,130 @@ "title": "ScadaDeviceResponse", "type": "object" }, + "ScadaLatestReadingResponse": { + "additionalProperties": false, + "description": "Latest persisted observation for one project SCADA device.", + "properties": { + "cleaned_value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Cleaned Value" + }, + "device_id": { + "title": "Device Id", + "type": "string" + }, + "device_type": { + "title": "Device Type", + "type": "string" + }, + "measurement_unit": { + "title": "Measurement Unit", + "type": "string" + }, + "monitored_value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Monitored Value" + }, + "observed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Observed At" + }, + "transmission_frequency": { + "title": "Transmission Frequency", + "type": "string" + }, + "transmission_mode": { + "title": "Transmission Mode", + "type": "string" + } + }, + "required": [ + "device_id", + "device_type", + "measurement_unit", + "transmission_mode", + "transmission_frequency" + ], + "title": "ScadaLatestReadingResponse", + "type": "object" + }, + "ScadaLatestReadingsResponse": { + "additionalProperties": false, + "description": "Bounded device page with each device's latest persisted observation.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ScadaLatestReadingResponse" + }, + "title": "Items", + "type": "array" + }, + "latest_observed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Latest Observed At" + }, + "limit": { + "maximum": 500.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "observed_count": { + "minimum": 0.0, + "title": "Observed Count", + "type": "integer" + }, + "offset": { + "minimum": 0.0, + "title": "Offset", + "type": "integer" + }, + "total": { + "minimum": 0.0, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset", + "observed_count" + ], + "title": "ScadaLatestReadingsResponse", + "type": "object" + }, "ScadaReadingBatchItem": { "properties": { "cleaned_value": { @@ -7838,6 +8182,39 @@ }, { "enum": [ + "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", @@ -33926,7 +34303,7 @@ }, "/api/v1/place-search-requests": { "post": { - "description": "调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。", + "description": "调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。", "operationId": "post_place_search_requests", "parameters": [ { @@ -34026,7 +34403,7 @@ "OAuth2PasswordBearer": [] } ], - "summary": "Search Fengxian places", + "summary": "Search project places", "tags": [ "Place Search" ] @@ -39027,6 +39404,114 @@ ] } }, + "/api/v1/reverse-geocoding-requests": { + "post": { + "description": "查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。", + "operationId": "post_reverse_geocoding_requests", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReverseGeocodingRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaceSearchItem" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Reverse geocode a project map point", + "tags": [ + "Place Search" + ] + } + }, "/api/v1/rule-properties": { "get": { "description": "获取指定网络中的规则属性信息", @@ -47653,6 +48138,131 @@ ] } }, + "/api/v1/timeseries/scada-readings/latest": { + "get": { + "description": "Read one latest persisted row per project device without applying a time window.", + "operationId": "get_timeseries_scada_readings_latest", + "parameters": [ + { + "description": "返回设备数上限", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "description": "返回设备数上限", + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "description": "设备目录偏移量", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "description": "设备目录偏移量", + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScadaLatestReadingsResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取SCADA设备最新数据", + "tags": [ + "TimescaleDB - SCADA" + ] + } + }, "/api/v1/timeseries/scada-readings/{device_id}/field": { "patch": { "description": "更新指定设备的字段值\n\n更新SCADA设备在特定时间的某个字段监测数据。\n\nArgs:\n device_id: 设备ID\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误", diff --git a/docs/fengyang-burst-scada.md b/docs/fengyang-burst-scada.md new file mode 100644 index 0000000..43016e2 --- /dev/null +++ b/docs/fengyang-burst-scada.md @@ -0,0 +1,65 @@ +# 凤阳六月爆管模拟与 SCADA 复制 + +2026-10-22 已完成活动项目 `fengyang_v2` +(`b962c44e-8433-4a10-81e9-a64b70ac1152`)的三段模拟爆管数据及前一天基线复制。 +一次性复制脚本及其专属测试已清理,来源、核对结果和备份记录如下: + +| 方案名称 | 模型管段 | 模型管径 | +| --- | --- | --- | +| `2026-06-02 Pipe19026 DN200 burst` | `19026` | 200 mm | +| `2026-06-12 Pipe18751 DN160 burst` | `18751` | 160 mm | +| `2026-06-22 Pipe18750 DN160 burst` | `18750` | 160 mm | + +方案名称、`burst_ID` 和 `network.pipes.diameter` 必须一致。时间使用方案的 +`started_at` 和结果时间戳,历时为 4 小时,包含起止点,15 分钟一个采样。 +不使用项目说明中的历史时间窗,也不使用旧的 `5hrs burst scheme` 数据。 + +源数据为 `analysis.node_results.pressure`,仅复制既有 50 个压力设备 +`asset.scada_devices.node_id` 对应的节点。每段必须完整覆盖 17 个时间点, +即 850 条观测,三段共 2,550 条;方案状态必须为 `completed`。 + +压力源单位来自每个方案的 `parameters.result_units.pressure`,目标单位来自 +设备的 `measurement_unit`,并用总水头减节点高程核对压力单位。缺失水头、 +高程或单位不一致时拒绝写入。例如真正的 kPa 转米水头使用 `pressure / 9.80665`。 +不添加噪声、不平移日期、不插值。写入 `scada.measurements.monitored_value`, +同时将窗口内对应观测的 `cleaned_value` 清空,避免使用旧观测的清洗结果。 +其他日期、其他设备及模拟源数据保持原样;原有其他模拟事件也保留。 + +正常基线直接读取 `realtime.node_results`,保留其原始日期和压力值。每段在线 +模拟采用右端不含的时间窗,4 小时共有 16 个采样点,最后一个点比窗口终点 +早 15 分钟。三段共 2,400 条观测。基线源单位来自模型配置,并同样校验 +总水头减高程;不从爆管结果或旧 SCADA 推导正常值。 + +| 管段 | 爆管数据时间(北京时间,含末点) | 前一天基线采样时间(北京时间) | +| --- | --- | --- | +| `19026` DN200 | 6 月 2 日 08:00–12:00 | 6 月 1 日 08:00–11:45 | +| `18751` DN160 | 6 月 12 日 12:00–16:00 | 6 月 11 日 12:00–15:45 | +| `18750` DN160 | 6 月 22 日 18:00–22:00 | 6 月 21 日 18:00–21:45 | + +定位算法配对比较时应采用共同的 16 个相对采样时刻,或明确使用右端不含的 +4 小时时间窗,避免将爆管结果额外的终点与不存在的基线终点配对。 + +2026-10-22 核查发现 EPANET 输出解析曾将压力枚举 1、2 的单位名称写反。 +已改为 `0=PSI, 1=KPA, 2=MTR`,并通过本地原生 EPANET 输出测试验证。 +这三个方案的全部 2,550 个压力值都与米制水头减高程一致,元数据已纠正为 +`MTR`,原始模拟数值保持不变。SCADA 最终使用设备的 `m` 单位,无额外换算。 +更正前的三个方案参数与核查证据保存在忽略目录 +`data/fengyang-result-unit-correction/`。 + +本次最终复制记录为 +`data/fengyang-burst-scada/20261022T101442415337Z/report.json`: +2,550 条爆管观测与 2,400 条前一天基线,共替换 4,950 条观测;SCADA 整表 +仍为 144,000 条、50 个设备。窗口外指纹不变,独立 SQL 比对六段数据与源值 +差异为零,重复只读预览显示 4,950 条均已一致。三个方案均为 `completed`。 + +本次执行前已锁定观测表并备份整表至忽略目录 +`data/fengyang-burst-scada//measurements-before.csv`,同时保存 +`prepared.json`(来源、设备映射、备份校验和)和 `expected.json`(预期观测)。 +目录权限为 0700,文件权限为 0600,不包含数据库凭据。 + +本次复制在一个事务中完成,逐条比对预期值、核对总行数、校验窗口外数据 +指纹不变。提交后使用新连接再次读回校验,已保存 `report.json`。 + +如需恢复,先核对对应报告的备份校验和,将 CSV 导入临时表,再仅恢复 +`expected.json` 中的 `(time, device_id)` 键;不要整表覆盖当前观测。 +数据库读回校验不代表已经完成 HTTP、界面回放或爆管定位算法验收。 diff --git a/docs/mcp-analysis-tools.md b/docs/mcp-analysis-tools.md index 3b6fd80..038c082 100644 --- a/docs/mcp-analysis-tools.md +++ b/docs/mcp-analysis-tools.md @@ -1,6 +1,6 @@ # Agent 业务分析 MCP -Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共35项:25项查询、下表10项分析;合并6项宿主查询后共41项。工具参数来自Server实际MCP schema,不维护第二套参数契约。 +Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42项查询、12项计算、13项预览式写入;合并6项宿主查询后共73项。下表保留原10项分析,新增服务及边界见 [MCP项目服务](mcp-project-services.md)。工具参数来自Server实际MCP schema,不维护第二套参数契约。 | 工具名(不含前缀) | 能力与边界 | 必要权限 | | --- | --- | --- | @@ -54,7 +54,7 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共35项:25 ## 资源边界与发布 -10项计算工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。 +12项计算和13项预览式写入工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。 例如 `duration=3600, timeout_seconds=900` 表示模拟1小时、允许等待15分钟。Agent的上游HTTP/SDK预算为所选预算加5秒,整个宿主调用另留60秒处理认证、上下文绑定和清理;OpenCode连接上限从工具目录计算并写入宿主连接文件。本地MCP请求关闭Bun空闲断连,仍受上述调用截止时间控制。普通查询继续使用原30秒Server预算与35秒宿主预算。 diff --git a/docs/mcp-project-services.md b/docs/mcp-project-services.md new file mode 100644 index 0000000..db6a015 --- /dev/null +++ b/docs/mcp-project-services.md @@ -0,0 +1,65 @@ +# MCP 项目服务 + +当前 Server 有 67 项工具:42 项查询、12 项计算、13 项预览式写入。Agent 合并六项宿主查询后共 73 项。目录来自 `app/mcp/catalog.py`,Agent 使用独立的有限白名单并在启动时全等核对。工具前缀为 `mcp_tjwater_`。 + +## 新增能力 + +| 领域 | 工具(省略前缀) | 权限 | +| --- | --- | --- | +| 区域与连接 | `list_regions`, `get_region`, `get_region_nodes`, `get_node_connections` | `webgis.view` | +| 空间范围与骨干节点 | `query_network_extent`, `list_major_nodes` | `webgis.view` | +| 模型配置 | `get_model_configuration`, `list_model_configurations` | `webgis.view` | +| 项目地名 | `search_project_places`, `reverse_geocode` | `webgis.view` | +| 项目概况与导出 | `get_project_info`, `export_project_model` | `webgis.view` | +| 监测点交付 | `get_sensor_placement_candidate`, `export_sensor_placement` | `optimization.run` | +| 调度与运行历史 | `list_task_schedules`, `get_task_run_history` | `operational_task.view` | +| 需水量分配 | `calculate_demand_allocation` | `webgis.view` | +| 假设爆管水力模拟 | `run_burst_simulation` | `simulation.run` + `scada.view` | +| 模型编辑 | `edit_model_element`, `edit_model_configuration` | `webgis.edit` | +| SCADA 维护 | `clean_scada_readings`, `write_scada_readings`, `delete_scada_readings` | `scada.clean` | +| 正式分析成果 | `save_simulation_scenario` | `simulation.run` + `scada.view` | +| 正式监测点方案 | `create_sensor_placement`, `update_sensor_placement` | `optimization.run` | +| 任务与定时配置 | `create_operational_task`, `create_task_schedule`, `update_task_schedule`, `retry_operational_task`, `cancel_task_run` | `operational_task.write` | +| 写入状态核实 | `get_mutation_status` | `webgis.view`,仅当前身份与会话的执行记录 | + +健康预测不在本次接入范围。报告发布、工单/设备动作的提议与审批、消息发送继续使用既有受控插件和业务动作流程。 + +## 数据与计算边界 + +- 所有查询使用当前授权项目连接。当前只有 TJWater 提供 GIS 信息;其他项目没有所需 GIS 数据或关系时返回空数据/明确不可用,不借用 TJWater 的图层或数据库。空页不能证明其他数据源没有对象。 +- 范围查询直接用 PostGIS `ST_Intersects`,不调用旧的空占位服务。范围和输出坐标为 EPSG:4326,单轴范围最多一度。查询模型几何,不把空间邻近称为供水连通。 +- 区域成员优先使用已配置节点关系,未配置时使用区域几何;配置、几何成员与真实供水拓扑需分别解释。 +- 模型配置 `data_path` 选择嵌套字段,`limit/offset` 对选中集合分页。模式和曲线可单独列出 ID。编辑字段必须通过现有模型 schema 的可写属性校验;有限业务枚举不接受 SQL、任意路由、命令、身份、数据库地址或文件路径。 +- 需水量分配按关联管长权重,只返回建议值,不修改节点需水量;输入输出沿用模型流量单位。区域或全网结果按节点 ID 稳定分页。 +- 爆管模拟输入是已说明依据的破口面积(cm²),不是流量。复用隔离模型,`persist_results=False`;这与异常侦测和候选定位是不同工具。 +- 保存正式场景会重新执行真实计算并持久化成果。压力调节和关阀正式成果使用宿主绑定的用户名。计算共用原有两项并发槽位,超时后槽位保留至原生线程退出。 +- 新建任务限未绑定 Agent 的 operator/agent 事件调查;不能伪造外部工单或租约。重试只重新入队,实际执行仍由原有 Worker 领取。取消、启停和方案点位更新复用现有版本或旧值校验。 + +## 写入预览和原生许可 + +13 项新写入工具只对项目成员的 `ordinary_chat` 开放。任务执行、任务协作、只读 reader、个人会话和学习复盘均不能调用。每次校验当前权限及绑定时的权限上限;写入工具不加入只读自动许可名单,管理员拒绝继续优先。交互聊天遵循现有本轮 request/always 策略,always 不改变业务权限。 + +1. 为具体操作生成 UUID `request_id`,默认 `dry_run=true`,取得预览与 `expected_digest`。预览不运行模拟/优化,也不写业务数据。 +2. 保持相同参数和 `request_id`,使用 `dry_run=false` 及返回的 `expected_digest` 请求执行,并经过宿主原生许可。 +3. 服务重新读取目标并校验摘要。模型预览复核与修改共用模型变更锁;SCADA 操作在时序库串行化事务中复核相关数据。数据变化返回冲突,须重新预览。 +4. 执行前在现有 `workbench.mcp_query_receipts` 保存 started 记录,完成后保存结果。同一会话、身份、项目和请求键的完成操作返回原结果,已启动但状态不明确的操作不会再次执行。可用 `get_mutation_status` 核实记录,并通过目标查询核实实际数据。超时/进程退出可能已有部分结果,不能解释为回滚,也不能自动换键重试。 + +计算容量已满且原生函数尚未调用时,返回 `CALCULATION_CAPACITY_EXCEEDED`。 +这类明确未执行的请求记为 `rejected`、`retryable=true`;状态查询同时返回具体 +错误。容量释放后可用原身份、会话、项目、参数和 `request_id` 重试,仍需通过 +原生许可和预览摘要复核。重试时继续锁定请求记录,成功后的重复调用只返回 +已保存结果。其他 503、算法异常、超时或取消仍保留 `started`,不能据此自动重试。 + +SCADA 维护必须明确列出设备,窗口最多 24 小时。清洗/删除最多 10000 条,补录/修正最多 500 条,来源依据必填;不开放 `all`。数据写入清洗值或实测表,不从模拟结果构造实测。涉及 BizDB 和 TimescaleDB 时不承诺跨库事务原子性,执行日志中的 started 与业务修改之间也不承诺分布式 exactly-once。 + +## 导出与会话下载 + +`export_project_model` 从数据库导出 ZIP(INP 或 ChangeSet,含顶点和 SCADA 元素),`export_sensor_placement` 导出已有方案 XLSX。单文件最多 5 MiB;模型通过 ZIP 压缩传输。Server 返回长度及 SHA-256;Agent 验证后只写当前会话 `workspace/files/`,返回相对路径和下载链接,模型消息不包含文件正文或宿主绝对路径。导出暂不支持超过该预算的文件分片。 + +下载接口为 Agent `GET /api/v1/session/{session_id}/files/{filename}`。它复用身份与项目校验,验证会话所有权,仅接受服务生成的文件名格式,拒绝符号链接和跨会话目录。前端通过已认证的 Agent client 下载;链接和磁盘文件均不是公开资源。文件随会话持久化和删除,不引入独立结果引用存储。 + +无需数据库迁移、新环境变量或新密钥。地名搜索沿用项目配置与已有天地图服务凭据。需同步发布 Server、Agent、前端,再重载 Server 与 Agent 工具目录;不要以代码目录一致代替线上重载验收。 + +## 验证 + +运行 focused pytest、Agent MCP/策略/导出测试及类型检查、前端协议与下载测试、浏览器下载覆盖及构建。另用隔离 MCP fixture 验证两端 SDK 目录、上下文绑定和原生工具调用。测试不运行真实项目模拟、优化、SCADA 清洗或任何业务写入;真实项目运算与数据效果仍须按项目验收。 diff --git a/docs/mcp-query-implementation.md b/docs/mcp-query-implementation.md index b5117d5..162bd5c 100644 --- a/docs/mcp-query-implementation.md +++ b/docs/mcp-query-implementation.md @@ -1,6 +1,6 @@ # 产品 MCP 查询:本地实施与启用 -实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和35项工具(25项查询、10项受控分析);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享25项查询;10项分析仅向非reader会话开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。 +实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和67项工具(42项查询、12项计算、13项预览式写入);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。 `run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。 @@ -76,11 +76,11 @@ TJWATER_MCP_CLIENT_SECRET=<由身份平台注入> 安装 Agent 更新的 Bun 依赖。本地 MCP 使用官方 Server SDK,独立监听127.0.0.1临时端口,先于原生工具发现就绪。宿主只把公开端点写入 `SESSION_METADATA_STORAGE_DIR` 的兄弟目录 `mcp-query/connection.json`,目录0700、文件0600;不保存令牌或 client secret。embedded 自动注入连接路径,OpenCode 环境仍剔除全部 `TJWATER_MCP_*`。client 模式须同网络空间、同版本可信插件、同一内部密钥,并共享 `TJWATER_AGENT_MCP_CONNECTION_PATH` 指向的连接文件,Agent 重启后同步重载原生连接。默认路径 `data/mcp-query/connection.json`。不要把 OAuth 配置注入外部 OpenCode。 -项目及宿主只读工具统一由 MCP 提供,插件只配置原生连接与权限;额外六项是地理编码、会话搜索、调查任务列表与详情、消息读取、待办读取,合并后共41项。公开网页搜索使用 OpenCode 原生 `websearch`,搜索服务及凭据由 OpenCode 管理;Server 的 Bocha 接口、配置和 MCP 搜索入口已移除。`memory_manager` 和 `skill_manager` 保留完整读写插件,写入、展示工具保持插件形式。旧项目和任务查询工具及其内部 HTTP 查询桥已移除;未启用产品 MCP 时项目数据不可用,不回退。 +项目及宿主只读工具统一由 MCP 提供,插件只配置原生连接与权限;额外六项是地理编码、会话搜索、调查任务列表与详情、消息读取、待办读取,合并后共73项。公开网页搜索使用 OpenCode 原生 `websearch`,搜索服务及凭据由 OpenCode 管理;Server 的 Bocha 接口、配置和 MCP 搜索入口已移除。`memory_manager` 和 `skill_manager` 保留完整读写插件,报告/消息/现场业务动作与展示工具保持插件形式;新增模型和数据维护写入通过预览式MCP提供。旧项目和任务查询工具及其内部 HTTP 查询桥已移除;未启用产品 MCP 时项目数据不可用,不回退。 原生调用的 `_meta["ai.opencode/sessionID"]` 仅用于关联宿主会话,本地服务先核验内部密钥和本机来源,再核验所有权及直接父子关系。原生工具发现、权限、元数据传递与待办 RPC 已由 `bun run tests/integration/query-mcp-smoke.ts` 验证,不调用模型。任务上下文保留可选 `report_revision`,缺省使用协作会话绑定修订号。隔离启动器显式设置自己的 `TJWATER_AGENT_MCP_CONNECTION_PATH`,两端共同读取该文件,不继承其他实例的连接文件。 -6. 先用测试项目分别验证普通项目会话、worker 调查和直接 reader 子会话,再开启生产。Agent 启动先发现 Server 目录并与宿主35项白名单对齐,再验证 OpenCode 的真实工具 ID;失败不进入 ready。切换开关需重启 Agent。Server 未迁移项目的绑定接口返回503。 +6. 先用测试项目分别验证普通项目会话、worker 调查和直接 reader 子会话,再开启生产。Agent 启动先发现 Server 目录并与宿主67项白名单对齐,再验证 OpenCode 的真实工具 ID;失败不进入 ready。切换开关需重启 Agent。Server 未迁移项目的绑定接口返回503。 client_credentials 与 token exchange 均发送标准 `resource=TJWATER_MCP_RESOURCE_URI`;exchange 同时保留配置的 audience。部署前必须验证身份平台接受该参数,且两种 grant 都将规范资源映射到 Server 要求的专用 audience。若所用 Keycloak 拒绝该参数,会报告配置不兼容,需升级或提供兼容适配;代码不会移除 resource 后重试。目录的服务 grant 与用户/worker 的 exchange 均不会退回共享管理员 Bearer或直接转发产品 Bearer给MCP。[Keycloak 官方说明](https://www.keycloak.org/securing-apps/token-exchange)。外部交互客户端的授权码流程和第三方客户端绑定不包含在本次交付中。 @@ -103,6 +103,6 @@ bun test tests/product/mcpAuthDiscovery.test.ts tests/product/mcpQueryClient.tes MCP_TEST_PYTHON=<隔离Server环境Python> MCP_TEST_SERVER_ROOT= bun tests/integration/mcp-query-smoke.ts ``` -HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、35项工具、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。 +HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、67项工具、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。 查询限制回归使用 `python -m pytest tests/unit/test_mcp_query_limits.py tests/unit/test_mcp_query.py tests/unit/test_timeseries_history.py tests/unit/test_timeseries_history_schema.py -q`。实际 PostgreSQL SQL 验证可设置 `MCP_QUERY_TEST_DSN` 后运行 `python -m pytest tests/integration/test_mcp_query_limits_postgres.py -q`;该 DSN 必须指向空的、可丢弃的独立数据库,测试创建临时业务同名 schema 并在事务结束时回滚。它验证数据点跨序列翻页、证据路径/Unicode分页、任务摘要与指定报告、章节稳定路径、项目/任务隔离、既有scope边界、字节门槛和合成摘要,不代替真实项目验收。 diff --git a/docs/operational-task-workers.md b/docs/operational-task-workers.md index 294fa8a..c215c9c 100644 --- a/docs/operational-task-workers.md +++ b/docs/operational-task-workers.md @@ -28,7 +28,8 @@ conda run -n server python scripts/run_operational_worker.py | 变量 | 用途 | | --- | --- | -| `OPERATIONAL_WORKER_PROJECT_ID` | 唯一授权项目 UUID | +| `OPERATIONAL_WORKER_PROJECT_ID` | 单项目模式的授权项目 UUID | +| `OPERATIONAL_WORKER_PROJECT_IDS` | 可选,显式授权项目 UUID 列表,逗号分隔;非空时优先于单项目配置,同步器和调查 worker 均按列表运行 | | `OPERATIONAL_WORKER_SERVER_URL` | 主后端地址,默认本机 8000 | | `OPERATIONAL_WORKER_AGENT_URL` | Agent 地址,默认本机 8787 | | `OPERATIONAL_WORKER_RECONCILE_SECONDS` | SSE 中断或漏通知时的对账间隔,默认 30 秒 | @@ -42,6 +43,8 @@ conda run -n server python scripts/run_operational_worker.py 工作身份必须能通过现有产品身份与项目成员权限校验。配置令牌获取不等于自动创建产品用户、项目成员或授权。权限错误会保留任务用于后续重试,不会绕过认证。 +多项目模式为每个项目维护独立事件游标、会话、消息游标及 OAuth 缓存,单项目暂时失败不阻止其他项目对账。`OPERATIONAL_WORKER_MAX_CONCURRENCY` 是每个项目的上限,总上限随显式配置的项目数增加;需要控制整体模型用量时相应调低。进程不自动枚举所有项目或扩大服务身份权限。修改项目列表后需同时重启 `tjwater-operational-worker.service` 和 `tjwater-peripheral-sync.service`。 + 调查数据读取沿用账号已有的项目权限,不再因 `service` 身份额外移除 `scada.view` 和 `simulation.view`。上下文的 `approved_data_scopes` 与数据查询采用同一权限集合;已有读取 权限无需逐任务数据审批或委托凭据。此规则不新增角色权限、不放开跨项目读取,也不改变 diff --git a/docs/peripheral-sample.md b/docs/peripheral-sample.md index fd9473e..3c6b5ad 100644 --- a/docs/peripheral-sample.md +++ b/docs/peripheral-sample.md @@ -4,6 +4,30 @@ 浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接样板服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。 +生产环境不需要为外围服务新增域名。前端设置 +`TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`,在现有前端 NPMplus Proxy Host 中增加以下 +Custom Location: + +| 配置项 | 值 | +| --- | --- | +| Location | `/peripheral/`,必须保留末尾斜杠 | +| Scheme | `http` | +| Forward Hostname / IP | NPMplus 能访问的应用机地址,通常与现有前端上游相同 | +| Forward Port | `8011` | +| Forward Path | 留空 | + +NPMplus 会去掉 `/peripheral/` 前缀,因此浏览器请求 +`/peripheral/api/sample/v1/context` 时,上游收到 `/api/sample/v1/context`。不要在 Forward +Hostname 后再追加 `/api/sample/v1`。外围服务应只向 NPMplus 所在网络开放 `8011`,不要直接开放公网。 +本机开发和预览模式也提供等价的 `/peripheral` 代理,可通过 +`TJWATER_PERIPHERAL_SAMPLE_UPSTREAM` 覆盖默认上游 `http://127.0.0.1:8011`。 + +外围 API 支持已授权项目切换。每次请求先由主系统验证项目成员身份和权限,再从 Metadata DB 解析该项目的 `biz_data` 路由;历史观测另解析 `iot_data` 路由。逻辑项目代码、业务物理库名和时序物理库名可以不同,连接沿用各自完整 DSN。路由缺失返回 `503 PROJECT_STORAGE_NOT_CONFIGURED`,不会回退到其他项目数据库。未配置存储表的项目仍需单独完成结构部署;API 不自动创建表或复制演示数据。 + +`PERIPHERAL_SAMPLE_PROJECT_CODE` 和 `PERIPHERAL_SAMPLE_DB_NAME` 仅供显式运行的种子、导出等脚本选择默认库,不再限制 HTTP 请求。工单变更在当前项目已有 `workbench.domain_event_log` 时于同一事务发布事件;未配置事件表时不自动建表,实时同步仍需项目工作台事件结构。 + +后台同步器和调查 worker 支持 `OPERATIONAL_WORKER_PROJECT_IDS`(项目 UUID,逗号分隔),每个项目维护独立凭据、事件游标和会话状态;未配置时继续使用原 `OPERATIONAL_WORKER_PROJECT_ID`。多项目配置只选择运行范围,不新增项目成员权限,服务身份必须已获各项目授权。修改后需重启两个常驻进程,参见 [工作进程配置](operational-task-workers.md)。 + 主要接口包括: - `/api/sample/v1/context` 与 `/scenario` diff --git a/docs/project-place-search.md b/docs/project-place-search.md new file mode 100644 index 0000000..0b66c34 --- /dev/null +++ b/docs/project-place-search.md @@ -0,0 +1,109 @@ +# 项目地名搜索配置 + +地名搜索的项目配置保存在 Metadata DB 的 `public.projects.map_config.place_search`。 +复用现有 JSONB 字段,不向各项目 BizDB 或 TimescaleDB 新增表。后端天地图 Key +仍通过 `TIANDITU_TOKEN` 提供,不写入项目地图配置或返回浏览器。 + +```json +{ + "place_search": { + "enabled": true, + "provider": "tianditu", + "admin_code": "341126", + "region": "凤阳县" + } +} +``` + +`admin_code` 接受六位行政区编码或带 `156` 前缀的天地图编码。省级编码以 +`0000` 结尾,市级编码以 `00` 结尾,其余按区县校验。`region` 是配置范围的 +显示名称;区县级配置须与天地图的区县名称一致。市级搜索可返回不同区县的 +候选,候选的 `district` 保留实际区县,响应的 `region` 返回配置范围。 + +配置缺失、关闭或无效时前端禁用查询;后端拒绝未配置项目的搜索请求。 +前端启用条件、区域文案及结果校验均来自项目元数据,不再判断 GeoServer +工作空间。后端按经授权的 `X-Project-Id` 读取配置,搜索请求只接受 `keyword`, +调用方不能覆盖项目区域或分页限制。每次搜索最多返回 10 个候选。 + +## 管理接口 + +具备现有管理员权限的调用方可使用 `PATCH /api/v1/admin/projects/{project_id}`: + +```json +{ + "map_config": { + "place_search": { + "enabled": true, + "provider": "tianditu", + "admin_code": "310000", + "region": "上海市" + } + } +} +``` + +更新会合并 `map_config` 的顶层配置项,保留已有 `legend`、`coordinate_reference` +等字段。`place_search` 作为完整配置项替换;传 `null` 可清除此项, +`map_config` 自身不能为 `null`。项目元数据和管理接口均返回 `map_config`。 +修改配置后刷新工作台以重新读取。 + +初始配置使用的一次性脚本已清理。后续通过上述管理接口维护项目配置; +初次写入前的逐项目行备份保存在忽略目录 +`data/project-map-config/-/before.json`。 + +## 2026-10-21 实际配置 + +| 项目代码 | 搜索范围 | 行政区编码 | +| --- | --- | --- | +| `tjwater_v2`、`tjwater` | 奉贤区 | `310120` | +| `fengyang_v2`、`fengyang` | 凤阳县 | `341126` | +| `md_v2`、`md` | 吴中区 | `320506` | +| `szh_v2`(苏州河) | 上海市 | `310000` | +| `zjb` | 湛江市 | `440800` | + +共配置 8 条项目记录;3 个旧项目仍为 inactive。SZH 的 8 个实际模型节点经天地图 +逆地理编码落在松江、青浦、闵行,因此采用上海市范围;木渎采用吴中区,湛江项目 +采用市级范围,避免将近似配准的工程坐标当作区县边界依据。 + +配置前已备份完整元数据库并通过 `pg_restore --list` 校验: +`data/project-map-config/metadata-before-20261021T080257Z/system_hub.dump`。 +SHA-256:`77dc13f874f81699a96d4584ac1f67f1b2048fe7c68175be8154c5e277c8689f`。 +同时保留逐项目的行备份,核对除 `map_config` 与 `updated_at` 外所有字段保持原值, +已有图例、坐标参考和项目状态均保留。数据库字段注释同步为当前配置用途。 + +验证涵盖后端服务/接口/元数据配置、前端单元测试、奉贤桌面/窄屏搜索、凤阳项目 +切换后搜索定位及未配置禁用。5 个活动项目均通过真实天地图搜索;浏览器测试使用 +隔离接口数据,不代表部署环境的真实身份验收。Server OpenAPI 与供水 Agent 的 +类型快照同步更新。配置已写入数据库,代码未提交、未推送或部署。 + +## 地图取点反编码 + +地址定位面板提供“地图取点”按钮。启用后点击地图查询该点附近的地名和完整地址, +首次查询成功后自动结束取点并保留结果和地图标记;失败时可重新取点。“结束取点”、 +Escape、关闭工具或切换项目也会退出取点模式。 +取点时暂停管网要素选择和绘图标注选择,并停止当前测量、绘图,退出后恢复常规选择。 + +后端接口为 `POST /api/v1/reverse-geocoding-requests`: + +```json +{"longitude": 117.570309, "latitude": 32.868029} +``` + +坐标采用 EPSG:4326,复用授权项目的 `map_config.place_search` 和服务端 +`TIANDITU_TOKEN`,无需另加数据库配置。返回规范化的 `PlaceSearchItem`,包括 +`name`、`address`、`longitude`、`latitude`、`district`、`category` 与 `id`。 +坐标始终保留输入的地图取点坐标,附近 POI 的坐标不替代点击位置。新结果显示标记, +不自动移动地图;用户可主动点击“重新定位”。连续取点取消前次请求,过期结果不能 +覆盖新点;工具关闭、切换项目和退出取点时也会取消未完成请求。 + +反编码遵守相同的行政区范围校验:区县范围校验区县,市级范围允许所属不同区县。 +范围外返回 422,无可用地址返回 404,服务异常或无效响应返回 502,配置缺失返回 +503,超时返回 504。错误信息不包含服务商 Key 或带凭据的请求 URL。 + +使用天地图逆地理编码服务的 `postStr={"lon":...,"lat":...,"ver":1}` 与 +`type=geocode` 参数。5 个活动项目均已用数据库实际配置通过真实接口查询, +并核对输出坐标保持输入值。桌面和 320px 浏览器测试使用隔离接口数据验证交互。 + +本次最终检查中,现有本地 `127.0.0.1:8000` 实例的 OpenAPI 请求连续两次超时。 +真实天地图检查通过独立服务调用完成,浏览器使用隔离 API;既有运行实例的 HTTP +联调尚待该实例恢复响应后核对。本次未重启既有进程。 diff --git a/docs/workbench-database-rollout.md b/docs/workbench-database-rollout.md index 13b0225..162fdf9 100644 --- a/docs/workbench-database-rollout.md +++ b/docs/workbench-database-rollout.md @@ -4,6 +4,24 @@ ## 当前基线 +### 2026-10-21 凤阳结构扩展 + +维护者明确要求将 `tjwater_v2` 的表结构和业务配置复制到 `fengyang_v2`,同时保留凤阳现有管网及数据。本次单独扩展凤阳,不向其他项目批量写入。以下旧迁移状态为当时记录,以本节的实际核对结果为准。 + +新增 `workbench` 的告警事件、告警状态、任务访问申请、任务进度、Agent 消息共 5 张表,补齐 `peripheral_sample` 的 8 张空表、`source_gis` 的 4 张空表及通用 `gis.water_quality_monitoring_points` 视图。同步主项目的约束、索引与业务表列注释,事件主题约束支持调查进度和展示事件。时序库的应用表结构已经一致,本次未迁移或写入时序数据。 + +本次迁移已完成,一次性执行脚本已清理。当时先生成差异报告,再做全库备份、核对 `pg_restore --list`、在强制回滚事务中演练,最后单事务应用。固定源、目标物理库为 `tjwater_v2` 和 `fengyang_v2`。生成的迁移 SQL、备份及审计报告保留供追溯;后续结构变更仍按本文门禁单独核对范围。 + +本次备份与审计记录位于忽略目录 `data/project-rollout/fengyang_v2-20261021T041825Z/`,包含 `before.dump`、`backup.list`、`migration.sql` 和 `report.json`。备份摘要:`f0f68f689239e3fde7fda10b6e7a0cbfdf4f8ee625ab87d3f714162af9391f87`。不提交备份或生成数据。 + +回滚演练及应用时均逐表核对已有 `network`、`asset`、`gis` 基础表的行数和无序内容摘要。凤阳的 31,143 个节点、31,219 条连接、50 个 SCADA 设备保持原状;未复制奉贤模型、原始 GIS、演示工单、历史结果、项目成员或地图范围。复查差异报告中缺失对象、约束差异和索引差异均为空。 + +外围 API 现按已验证项目的 Metadata DB 路由选库,不再固定为 `tjwater_v2`。事件生产事务排序锁在当前连接的项目库内生效,不创建数据库对象或业务数据;外围工单仅在该项目已配置事件表时发布事务事件。后台进程支持显式 `OPERATIONAL_WORKER_PROJECT_IDS` 列表,既有部署配置和服务身份成员权限未自动扩展。前端使用选中项目路由;上线后仍需按实际身份完成 Server/Agent 的跨项目联调验收。 + +已从当前前端配置的 GeoServer 核对两个工作空间的 WFS 数据。凤阳返回 31,137 个 junction、30,016 条 pipe,其余节点和连接类型仍使用凤阳原模型;这些分类数量与模型总节点/连接数量不是同一指标。 + +若结构迁移失败,事务直接回滚。若提交后的恢复需要使用 `before.dump`,先停止该项目写入并按既有数据库恢复流程处理,不能在仍有新业务写入时直接覆盖恢复。 + 截至 2026-09-24,`014_operational_tasks.sql` 和 `015_workbench_events_and_task_runs.sql` 已应用到 `tjwater_v2`、`szh_v2`、`md_v2`、`fengyang_v2` 和 `zjb`。这是已有基线,不代表后续迁移可以继续批量应用。 `017_workbench_schema_comments.sql` 只为 `workbench` schema 及其 17 张表和关键列增加数据库可见注释。它先在 `tjwater_v2` 应用;其他项目数据库保持原状,直到 `tjwater_v2` 验证通过并获得明确扩展确认。 diff --git a/infra/systemd/tjwater-server.service b/infra/systemd/tjwater-server.service new file mode 100644 index 0000000..efba36c --- /dev/null +++ b/infra/systemd/tjwater-server.service @@ -0,0 +1,16 @@ +[Unit] +Description=TJWater API and MCP query service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=%h/NextTJWater/next-tjwater-server +ExecStart=%h/miniconda3/envs/server/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 +UMask=0077 + +[Install] +WantedBy=default.target diff --git a/resources/sql/005_metadata_project_configuration.sql b/resources/sql/005_metadata_project_configuration.sql index bda3627..0751487 100644 --- a/resources/sql/005_metadata_project_configuration.sql +++ b/resources/sql/005_metadata_project_configuration.sql @@ -18,12 +18,17 @@ CREATE TABLE IF NOT EXISTS projects ( description TEXT, gs_workspace VARCHAR(100) UNIQUE NOT NULL, map_extent JSONB, + map_config JSONB NOT NULL DEFAULT '{}'::jsonb, status VARCHAR(20) DEFAULT 'active' NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived')) ); +ALTER TABLE projects ADD COLUMN IF NOT EXISTS map_config JSONB NOT NULL DEFAULT '{}'::jsonb; +COMMENT ON COLUMN projects.map_config IS + '项目级地图配置(JSON);place_search 保存地名搜索启用状态、服务商、行政区编码及区域名称;保留既有图例与坐标参考配置。'; + CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); DROP TRIGGER IF EXISTS update_projects_updated_at ON projects; diff --git a/samples/peripheral/api.py b/samples/peripheral/api.py index 175d681..b9991e2 100644 --- a/samples/peripheral/api.py +++ b/samples/peripheral/api.py @@ -7,9 +7,13 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from app.auth.project_dependencies import get_metadata_repository +from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + from .auth import Identity, identity from .config import SampleSettings from .errors import SampleFailure +from .routing import resolve_store_routing from .store import TRANSITIONS from .schemas import ( AlertPage, Context, CreateTestDataRecord, CreateWorkOrder, @@ -20,9 +24,13 @@ from .schemas import ( -def store(request: Request): +async def store(request: Request, who: Identity = Depends(identity), + repository: MetadataRepository = Depends(get_metadata_repository)): from .store import PostgresStore - return PostgresStore(request.app.state.settings) + routing = await resolve_store_routing( + who, repository, timeseries=request.url.path.endswith("/monitoring/observations"), + ) + return PostgresStore(request.app.state.settings, routing=routing) def create_app(settings: SampleSettings | None = None) -> FastAPI: diff --git a/samples/peripheral/auth.py b/samples/peripheral/auth.py index 0f1fd67..c95caee 100644 --- a/samples/peripheral/auth.py +++ b/samples/peripheral/auth.py @@ -13,6 +13,7 @@ class Identity: project_id: str user_id: str can_write: bool + project_code: str = "" def next_get(request: Request, path: str, params=None): @@ -42,8 +43,9 @@ def identity(request: Request) -> Identity: user_id = str(UUID(context["user_id"])) project_id = str(UUID(context["project_id"])) ensure(project_id == str(UUID(project)), "PROJECT_MISMATCH", "项目身份不一致", 403) - ensure(context["network"] == request.app.state.settings.project_code, - "PROJECT_NOT_CONFIGURED", "该项目不是本外围样板的数据项目", 403) + project_code = context["network"] + ensure(isinstance(project_code, str) and bool(project_code.strip()), + "INVALID_AUTH_CONTEXT", "Next 项目身份不完整", 502) permissions = context["permissions"] ensure(isinstance(permissions, list) and "simulation.view" in permissions, "PERMISSION_DENIED", "没有样板读取权限", 403) @@ -51,4 +53,4 @@ def identity(request: Request) -> Identity: ensure(request.method == "GET" or can_write, "PERMISSION_DENIED", "没有样板修改权限", 403) except (KeyError, TypeError, ValueError) as exc: raise SampleFailure("INVALID_AUTH_CONTEXT", "Next 身份响应不完整", 502) from exc - return Identity(project_id, user_id, can_write) + return Identity(project_id, user_id, can_write, project_code) diff --git a/samples/peripheral/routing.py b/samples/peripheral/routing.py new file mode 100644 index 0000000..e63f0a5 --- /dev/null +++ b/samples/peripheral/routing.py @@ -0,0 +1,36 @@ +"""Resolve storage from authenticated project metadata, never browser input.""" +from uuid import UUID + +from fastapi import HTTPException +from psycopg import ProgrammingError +from psycopg.conninfo import conninfo_to_dict +from sqlalchemy.exc import SQLAlchemyError + +from app.auth.project_dependencies import ( + ProjectContext, resolve_project_business_routing, resolve_project_simulation_routing, +) +from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository +from app.infra.db.project_routing import ActiveProjectRouting + +from .auth import Identity +from .errors import SampleFailure, ensure + + +async def resolve_store_routing( + who: Identity, repository: MetadataRepository, *, timeseries: bool = False, +) -> ActiveProjectRouting: + context = ProjectContext( + project_id=UUID(who.project_id), project_code=who.project_code, + user_id=UUID(who.user_id), project_role="", + ) + try: + resolver = resolve_project_simulation_routing if timeseries else resolve_project_business_routing + routing = await resolver(context, repository) + dsns = [routing.business_dsn] + ([routing.timescale_dsn] if timeseries else []) + ensure(all(dsn and conninfo_to_dict(dsn).get("dbname") for dsn in dsns), + "PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库名称尚未配置", 503) + return routing + except (HTTPException, SQLAlchemyError, ValueError, ProgrammingError) as exc: + raise SampleFailure( + "PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库路由不可用或尚未配置", 503, + ) from exc diff --git a/samples/peripheral/store.py b/samples/peripheral/store.py index c7f074e..aecb766 100644 --- a/samples/peripheral/store.py +++ b/samples/peripheral/store.py @@ -8,11 +8,13 @@ from uuid import UUID, uuid4 import psycopg from psycopg.rows import dict_row +from psycopg.conninfo import conninfo_to_dict from psycopg.types.json import Jsonb from app.domain.schemas.shared import TimeRange from app.services.content_digest import digest from app.infra.db.postgresql.workbench_events import lock_event_transaction_sync +from app.infra.db.project_routing import ActiveProjectRouting from .errors import SampleFailure, ensure from .auth import Identity @@ -38,23 +40,37 @@ WORK_ORDER_CREATED_DETAILS = { class PostgresStore: - def __init__(self, settings: SampleSettings) -> None: - self.settings = settings + def __init__(self, settings: SampleSettings, *, routing: ActiveProjectRouting | None = None) -> None: + self.routing = routing + # Explicit seed/export scripts retain their configured database. HTTP + # dependencies always supply authenticated metadata routing. + self.settings = settings if routing is None else settings.model_copy(update={ + "db_name": routing.business_database_name, "project_code": routing.project_code, + }) @contextmanager def connection(self, *, readonly: bool = False, timeseries: bool = False) -> Iterator[psycopg.Connection]: from app.core.config import settings as product ensure(not timeseries or readonly, "READ_ONLY_SOURCE", "历史监测源仅允许只读连接", 403) prefix = "TIMESCALEDB_DB_" if timeseries else "DB_" + if self.routing is not None: + dsn = self.routing.timescale_dsn if timeseries else self.routing.business_dsn + ensure(bool(dsn), "PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库路由尚未配置", 503) + expected_database = conninfo_to_dict(dsn).get("dbname") + ensure(bool(expected_database), "PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库名称尚未配置", 503) + connect_args = {"conninfo": dsn} + else: + expected_database = self.settings.db_name + connect_args = {"host": getattr(product, prefix + "HOST"), "port": getattr(product, prefix + "PORT"), + "user": getattr(product, prefix + "USER"), "password": getattr(product, prefix + "PASSWORD"), + "dbname": expected_database} try: - with psycopg.connect(host=getattr(product, prefix + "HOST"), port=getattr(product, prefix + "PORT"), - user=getattr(product, prefix + "USER"), password=getattr(product, prefix + "PASSWORD"), - dbname=self.settings.db_name, connect_timeout=5, + with psycopg.connect(**connect_args, connect_timeout=5, options="-c statement_timeout=5000", row_factory=dict_row) as conn: if readonly: conn.execute("SET TRANSACTION READ ONLY") actual = conn.execute("SELECT current_database() AS name").fetchone()["name"] - ensure(actual == self.settings.db_name, "DATABASE_MISMATCH", "样板数据库不匹配", 503) + ensure(actual == expected_database, "DATABASE_MISMATCH", "样板数据库不匹配", 503) yield conn except (psycopg.errors.UndefinedTable, psycopg.errors.InvalidSchemaName) as exc: raise SampleFailure("STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503) from exc @@ -318,9 +334,9 @@ class PostgresStore: return document def _work_order_event(self, conn, who: Identity, document: dict[str, Any], *, deleted: bool = False) -> None: - # This rollout is restricted to the provisioned tjwater_v2 event log. - # Task projections remain owned by the Server's authenticated APIs. - if self.settings.db_name != "tjwater_v2": + # Publish only into this project's provisioned event log. Never create + # storage implicitly or write an event into another project's database. + if not conn.execute("SELECT to_regclass('workbench.domain_event_log') AS event_log").fetchone()["event_log"]: return metadata = { "source": "peripheral_sample", @@ -529,8 +545,9 @@ class PostgresStore: if scenario == "missing_data": for item in items[::2]: item.update(value=None, quality="missing") + source_database = conninfo_to_dict(self.routing.timescale_dsn)["dbname"] if self.routing else self.settings.db_name return {"origin": "synthetic" if scenario == "missing_data" else "historical_replay", - "source_id": self.settings.db_name + ":scada.measurements", "source_revision": digest(items), + "source_id": source_database + ":scada.measurements", "source_revision": digest(items), "items": items, "truncated": len(rows) > 1000} def alerts(self, who: Identity) -> dict[str, Any]: diff --git a/scripts/run_operational_worker.py b/scripts/run_operational_worker.py index e50544f..506115d 100644 --- a/scripts/run_operational_worker.py +++ b/scripts/run_operational_worker.py @@ -12,7 +12,7 @@ import json import logging import os import time -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime from pathlib import Path from uuid import UUID, uuid4 @@ -37,11 +37,21 @@ class WorkerSettings: client_secret: str = "" token_refresh_seconds: float = 300 task_created_from: str | None = None + project_ids: tuple[str, ...] = () + + def project_scopes(self) -> tuple["WorkerSettings", ...]: + """Keep credentials, sessions and cursors separate for each explicit project.""" + return tuple(replace(self, project_id=project_id, project_ids=()) + for project_id in self.project_ids or (self.project_id,)) @classmethod def from_environment(cls) -> "WorkerSettings": load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False) - project_id = str(UUID(os.environ["OPERATIONAL_WORKER_PROJECT_ID"])) + configured_projects = os.environ.get("OPERATIONAL_WORKER_PROJECT_IDS", "").strip() + project_ids = tuple(dict.fromkeys( + str(UUID(value.strip())) for value in configured_projects.split(",") + )) if configured_projects else () + project_id = project_ids[0] if project_ids else str(UUID(os.environ["OPERATIONAL_WORKER_PROJECT_ID"])) interval = float(os.environ.get("OPERATIONAL_WORKER_RECONCILE_SECONDS", "30")) if interval < 1: raise ValueError("worker interval must be at least one second") @@ -60,6 +70,7 @@ class WorkerSettings: client_secret=os.environ.get("OPERATIONAL_WORKER_CLIENT_SECRET", ""), token_refresh_seconds=float(os.environ.get("OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS", "300")), task_created_from=os.environ.get("OPERATIONAL_WORKER_TASK_CREATED_FROM") or None, + project_ids=project_ids, ) if settings.task_created_from: boundary = datetime.fromisoformat(settings.task_created_from.replace("Z", "+00:00")) @@ -305,37 +316,45 @@ class OperationalWorker: await asyncio.sleep(5) -async def run(once: bool) -> None: - settings = WorkerSettings.from_environment() +async def run_project(settings: WorkerSettings, client: httpx.AsyncClient, once: bool) -> None: logger.info( "Investigation worker scope: project=%s task_created_from=%s concurrency=%d", settings.project_id, settings.task_created_from or "all", settings.max_concurrency, ) - async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client: - worker = OperationalWorker(settings, client) - wake = asyncio.Event() - listener = None if once else asyncio.create_task(worker.listen(wake)) - try: - while True: - try: - accepted = await worker.tick() - logger.info("Task dispatch tick completed: %d accepted", accepted) - except (httpx.HTTPError, ValueError, KeyError): - # Do not log request headers, response bodies or provider secrets. - logger.error("Task dispatch tick failed; persisted tasks remain available for retry") - if once: - raise SystemExit(1) from None + worker = OperationalWorker(settings, client) + wake = asyncio.Event() + listener = None if once else asyncio.create_task(worker.listen(wake)) + try: + while True: + try: + accepted = await worker.tick() + logger.info("Task dispatch tick completed: project=%s accepted=%d", settings.project_id, accepted) + except (httpx.HTTPError, ValueError, KeyError): + logger.error("Task dispatch tick failed: project=%s; persisted tasks remain available for retry", settings.project_id) if once: - return - try: - await asyncio.wait_for(wake.wait(), timeout=settings.interval_seconds) - except TimeoutError: - pass - wake.clear() - finally: - if listener is not None: - listener.cancel() - await asyncio.gather(listener, return_exceptions=True) + raise + if once: + return + try: + await asyncio.wait_for(wake.wait(), timeout=settings.interval_seconds) + except TimeoutError: + pass + wake.clear() + finally: + if listener is not None: + listener.cancel() + await asyncio.gather(listener, return_exceptions=True) + + +async def run(once: bool) -> None: + settings = WorkerSettings.from_environment() + async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client: + results = await asyncio.gather( + *(run_project(scope, client, once) for scope in settings.project_scopes()), + return_exceptions=once, + ) + if any(isinstance(result, Exception) for result in results): + raise SystemExit(1) if __name__ == "__main__": diff --git a/scripts/sync_peripheral_work_orders.py b/scripts/sync_peripheral_work_orders.py index fb4c7f9..58cf9be 100644 --- a/scripts/sync_peripheral_work_orders.py +++ b/scripts/sync_peripheral_work_orders.py @@ -372,7 +372,7 @@ class PeripheralWorkOrderSynchronizer: event_name = "" await asyncio.sleep(1) except (httpx.HTTPError, ValueError, KeyError): - logger.warning("Work-order event stream disconnected; reconciliation remains active") + logger.warning("Work-order event stream disconnected: project=%s; reconciliation remains active", self.settings.project_id) await asyncio.sleep(5) async def serve(self, reconcile_seconds: int = 300) -> None: @@ -399,12 +399,12 @@ class PeripheralWorkOrderSynchronizer: self.client, self.settings, self.peripheral_url, self.credentials, **({"work_order_ids": order_ids} if not full_reconcile else {}), ) - logger.info("Peripheral progress synchronization completed: %d revisions", count) + logger.info("Peripheral progress synchronization completed: project=%s revisions=%d", self.settings.project_id, count) retry_seconds = 5 if full_reconcile: next_reconcile = asyncio.get_running_loop().time() + reconcile_seconds except (httpx.HTTPError, ValueError, KeyError): - logger.error("Peripheral synchronization failed; persisted revisions will be retried") + logger.error("Peripheral synchronization failed: project=%s; persisted revisions will be retried", self.settings.project_id) self.full_reconcile |= full_reconcile for order_id in order_ids: self._queue_order(order_id) @@ -419,17 +419,21 @@ class PeripheralWorkOrderSynchronizer: async def run(once: bool, reconcile_seconds: int = 300) -> None: settings = WorkerSettings.from_environment() peripheral_url = os.environ["OPERATIONAL_ACTION_PERIPHERAL_URL"] - credentials = CredentialProvider(settings) async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client: if once: - try: - count = await sync_once(client, settings, peripheral_url, credentials) - logger.info("Peripheral progress synchronization completed: %d revisions", count) - except (httpx.HTTPError, ValueError, KeyError): + results = await asyncio.gather(*( + sync_once(client, scope, peripheral_url, CredentialProvider(scope)) + for scope in settings.project_scopes() + ), return_exceptions=True) + if any(isinstance(result, Exception) for result in results): logger.error("Peripheral synchronization failed; persisted revisions remain available") raise SystemExit(1) from None + logger.info("Peripheral progress synchronization completed: %d revisions", sum(results)) return - await PeripheralWorkOrderSynchronizer(client, settings, peripheral_url, credentials).serve(reconcile_seconds) + await asyncio.gather(*( + PeripheralWorkOrderSynchronizer(client, scope, peripheral_url, CredentialProvider(scope)).serve(reconcile_seconds) + for scope in settings.project_scopes() + )) if __name__ == "__main__": diff --git a/tests/api/test_admin_metadata_endpoints.py b/tests/api/test_admin_metadata_endpoints.py index 3db355c..6f23dcf 100644 --- a/tests/api/test_admin_metadata_endpoints.py +++ b/tests/api/test_admin_metadata_endpoints.py @@ -603,3 +603,16 @@ async def test_remove_project_member_rejects_self_membership_change(monkeypatch) assert exc.value.status_code == 403 repo.remove_project_member.assert_not_called() admin_metadata.log_audit_event.assert_not_called() + + +def test_project_map_config_accepts_search_and_preserves_extra_options(): + from pydantic import ValidationError + from app.domain.schemas.admin_metadata import AdminProjectUpdateRequest + config = {'legend': 'original', 'place_search': { + 'enabled': True, 'provider': 'tianditu', 'admin_code': '341126', 'region': '凤阳县'}} + request = AdminProjectUpdateRequest(map_config=config) + assert request.model_dump(exclude_unset=True)['map_config'] == config + with pytest.raises(ValidationError): + AdminProjectUpdateRequest(map_config=None) + with pytest.raises(ValidationError): + AdminProjectUpdateRequest(map_config={'place_search': {'enabled': True, 'admin_code': 'wrong', 'region': '凤阳县'}}) diff --git a/tests/api/test_operational_task_dynamic_flow.py b/tests/api/test_operational_task_dynamic_flow.py index 1fef5c8..be479df 100644 --- a/tests/api/test_operational_task_dynamic_flow.py +++ b/tests/api/test_operational_task_dynamic_flow.py @@ -96,3 +96,30 @@ async def test_preconfigured_service_access_avoids_server_delegation(monkeypatch assert source.await_args.kwargs["delegated"] is False assert result.evidence.evidence_id == snapshot.source_ref service.record_data_snapshot.assert_awaited_once() + + +@pytest.mark.anyio +async def test_report_endpoints_preserve_interactive_blocks_and_version_context(monkeypatch): + from app.domain.schemas.operational_task import AgentArtifactsCommand, InvestigationReportRevision + from tests.unit.test_report_ui import rich_report + + report = rich_report() + snapshot = InvestigationReportRevision( + revision=2, execution_generation=1, source_session_id="session-1", + published_at="2026-10-20T08:00:00Z", published_by="agent", report=report, + ) + service = SimpleNamespace(report=AsyncMock(return_value=snapshot), + list_reports=AsyncMock(return_value=([snapshot], None)), + publish_artifacts=AsyncMock(return_value={"report_revision": 2})) + monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service) + task_id = uuid4() + command = AgentArtifactsCommand(request_id=uuid4(), source_session_id="session-1", report=report) + await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object()) + assert service.publish_artifacts.await_args.args[1].report.ui_blocks[0].block.block_id == "pressure" + result = await endpoints.get_investigation_report(task_id, 2, ctx=_service_context(), conn=object()) + restored = InvestigationReportRevision.model_validate_json(result.model_dump_json()) + assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27] + assert restored.revision == 2 + schemas = app.openapi()["components"]["schemas"] + assert "ui_blocks" in schemas["InvestigationReportInput"]["properties"] + assert "ui_blocks" in schemas["InvestigationReport"]["properties"] diff --git a/tests/api/test_place_search_endpoints.py b/tests/api/test_place_search_endpoints.py index 5dc8bc6..9493165 100644 --- a/tests/api/test_place_search_endpoints.py +++ b/tests/api/test_place_search_endpoints.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import AsyncMock from uuid import uuid4 @@ -7,7 +8,7 @@ from fastapi.testclient import TestClient from app.api.v1.endpoints import place_search from app.api.v1.rest_router import api_router -from app.auth.project_dependencies import ProjectContext, get_project_context +from app.auth.project_dependencies import ProjectContext, get_project_context, get_metadata_repository from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchResponse from app.services.place_search import PlaceSearchError @@ -17,13 +18,18 @@ def app(): application = FastAPI() application.include_router(api_router, prefix="/api/v1") application.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "viewer") + application.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace( + get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config={ + "place_search": {"enabled": True, "provider": "tianditu", "admin_code": "310120", "region": "奉贤区"} + })) + ) return application def test_viewer_can_search_with_existing_project_read_permission(app, monkeypatch): service = AsyncMock(return_value=PlaceSearchResponse(items=[PlaceSearchItem( - id="place-1", name="南桥路", address="上海市奉贤区南桥路", longitude=121.44, latitude=31.22, - )], total=1)) + id="place-1", name="南桥路", address="上海市奉贤区南桥路", longitude=121.44, latitude=31.22, district="奉贤区", + )], total=1, region="奉贤区")) monkeypatch.setattr(place_search, "search_tianditu_places", service) response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"}) assert response.status_code == 200 @@ -63,3 +69,73 @@ def test_search_contract_has_typed_results_and_required_project_header(app): assert operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("PlaceSearchResponse") assert set(contract["components"]["schemas"]["PlaceSearchRequest"]["properties"]) == {"keyword"} assert contract["components"]["schemas"]["PlaceSearchResponse"]["properties"]["items"]["maxItems"] == 10 + reverse = contract["paths"]["/api/v1/reverse-geocoding-requests"]["post"] + assert any(param["name"] == "X-Project-Id" and param["required"] for param in reverse["parameters"]) + assert reverse["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("PlaceSearchItem") + coordinates = contract["components"]["schemas"]["ReverseGeocodingRequest"] + assert set(coordinates["properties"]) == {"longitude", "latitude"} + assert coordinates["additionalProperties"] is False + + +@pytest.mark.parametrize('map_config', [{}, {'place_search': None}, + {'place_search': {'enabled': False, 'admin_code': '341126', 'region': '凤阳县'}}, + {'place_search': {'enabled': True, 'admin_code': 'invalid', 'region': '凤阳县'}}]) +def test_unconfigured_or_invalid_project_does_not_search(app, monkeypatch, map_config): + service = AsyncMock() + monkeypatch.setattr(place_search, 'search_tianditu_places', service) + app.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace( + get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config=map_config))) + response = TestClient(app).post('/api/v1/place-search-requests', json={'keyword': '地名'}) + assert response.status_code == 503 + service.assert_not_awaited() + + +def test_search_uses_configuration_of_authenticated_project(app, monkeypatch): + project_id = uuid4() + app.dependency_overrides[get_project_context] = lambda: ProjectContext(project_id, 'fengyang_v2', uuid4(), 'viewer') + repository = SimpleNamespace(get_project_by_id=AsyncMock(return_value=SimpleNamespace( + map_config={'place_search': {'enabled': True, 'admin_code': '341126', 'region': '凤阳县'}}))) + app.dependency_overrides[get_metadata_repository] = lambda: repository + service = AsyncMock(return_value=PlaceSearchResponse(items=[], total=0, region='凤阳县')) + monkeypatch.setattr(place_search, 'search_tianditu_places', service) + response = TestClient(app).post('/api/v1/place-search-requests', json={'keyword': '地名'}) + assert response.status_code == 200 and response.json()['region'] == '凤阳县' + repository.get_project_by_id.assert_awaited_once_with(project_id) + assert service.await_args.kwargs['config'].admin_code == '341126' + + +def test_viewer_reverse_lookup_uses_project_configuration_and_typed_point(app, monkeypatch): + service = AsyncMock(return_value=PlaceSearchItem(id='map-point', name='南桥路', address='奉贤区南桥路', + longitude=121.45, latitude=30.92, district='奉贤区')) + monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service) + response = TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121.45, 'latitude': 30.92}) + assert response.status_code == 200 and response.json()['name'] == '南桥路' + assert service.await_args.args[0].longitude == 121.45 + assert service.await_args.kwargs['config'].admin_code == '310120' + + +@pytest.mark.parametrize('body', [{'longitude': 181, 'latitude': 0}, + {'longitude': 121, 'latitude': 91}, {'longitude': 121, 'latitude': 31, 'region': '全国'}]) +def test_reverse_lookup_rejects_invalid_requests_before_provider_call(app, monkeypatch, body): + service = AsyncMock() + monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service) + assert TestClient(app).post('/api/v1/reverse-geocoding-requests', json=body).status_code == 422 + service.assert_not_awaited() + + +@pytest.mark.parametrize('role,status', [('none', 403), ('viewer', 503)]) +def test_reverse_lookup_requires_project_permission_and_configuration(app, monkeypatch, role, status): + app.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), 'demo', uuid4(), role) + app.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace( + get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config={}))) + service = AsyncMock() + monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service) + assert TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121, 'latitude': 31}).status_code == status + service.assert_not_awaited() + + +@pytest.mark.parametrize('status', [404, 422, 502, 503, 504]) +def test_reverse_lookup_preserves_actionable_errors(app, monkeypatch, status): + monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', AsyncMock(side_effect=PlaceSearchError(status, '请重新取点'))) + response = TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121, 'latitude': 31}) + assert response.status_code == status and response.json()['detail'] == '请重新取点' diff --git a/tests/api/test_scada_timeseries_endpoints.py b/tests/api/test_scada_timeseries_endpoints.py index 228ba87..3cd3d61 100644 --- a/tests/api/test_scada_timeseries_endpoints.py +++ b/tests/api/test_scada_timeseries_endpoints.py @@ -57,3 +57,92 @@ def test_scada_batch_writes_only_after_bizdb_validation(monkeypatch): assert result == {"message": "Inserted 1 records"} assert insert.await_args.args[1][0]["device_id"] == "known" + + +def test_latest_scada_readings_join_device_metadata_with_latest_rows(monkeypatch): + observed_at = datetime(2026, 10, 22, 1, 2, tzinfo=timezone.utc) + monkeypatch.setattr( + scada_endpoint.ScadaInfoRepository, + "get_scadas", + AsyncMock(return_value=[ + { + "device_id": "PRESSURE-1", + "device_type": "pressure", + "measurement_unit": "MPa", + "transmission_mode": "realtime", + "transmission_frequency": "15min", + }, + { + "device_id": "FLOW-2", + "device_type": "pipe_flow", + "measurement_unit": "m3/h", + "transmission_mode": "realtime", + "transmission_frequency": "15min", + }, + ]), + ) + latest = AsyncMock(return_value={ + "PRESSURE-1": { + "time": observed_at, + "device_id": "PRESSURE-1", + "monitored_value": 0.31, + "cleaned_value": 0.3, + } + }) + monkeypatch.setattr( + scada_endpoint.ScadaRepository, + "get_latest_scada_readings", + latest, + ) + + result = asyncio.run( + scada_endpoint.get_latest_scada_readings( + limit=100, + offset=0, + conn=object(), + postgres_conn=object(), + ) + ) + + assert result.total == 2 + assert result.observed_count == 1 + assert result.latest_observed_at == observed_at + assert result.items[0].monitored_value == pytest.approx(0.31) + assert result.items[1].observed_at is None + assert latest.await_args.args[1] == ["PRESSURE-1", "FLOW-2"] + + +def test_latest_scada_readings_applies_device_page_before_timeseries_query(monkeypatch): + monkeypatch.setattr( + scada_endpoint.ScadaInfoRepository, + "get_scadas", + AsyncMock(return_value=[ + { + "device_id": f"D-{index}", + "device_type": "pressure", + "measurement_unit": "MPa", + "transmission_mode": "realtime", + "transmission_frequency": "15min", + } + for index in range(4) + ]), + ) + latest = AsyncMock(return_value={}) + monkeypatch.setattr( + scada_endpoint.ScadaRepository, + "get_latest_scada_readings", + latest, + ) + + result = asyncio.run( + scada_endpoint.get_latest_scada_readings( + limit=2, + offset=1, + conn=object(), + postgres_conn=object(), + ) + ) + + assert result.total == 4 + assert [item.device_id for item in result.items] == ["D-1", "D-2"] + assert latest.await_args.args[1] == ["D-1", "D-2"] diff --git a/tests/unit/test_epanet_output_units.py b/tests/unit/test_epanet_output_units.py new file mode 100644 index 0000000..9958d50 --- /dev/null +++ b/tests/unit/test_epanet_output_units.py @@ -0,0 +1,41 @@ +import ctypes +import os +from pathlib import Path +import platform +import subprocess +from types import SimpleNamespace + +import pytest + +from app.infra.epanet.epanet import Output + + +@pytest.mark.parametrize("pressure_code,expected", [(0, "PSI"), (1, "KPA"), (2, "MTR")]) +def test_output_pressure_units_follow_native_enum(pressure_code, expected): + def units(handle, kind, value): + value._obj.value = {1: 5, 2: pressure_code, 3: 0}[kind.value] + return 0 + output = Output.__new__(Output) + output._lib = SimpleNamespace(ENR_getUnits=units, ENR_close=lambda *args: 0) + output._handle = ctypes.c_void_p() + output._check = lambda code: None + assert output.units() == {"flow": "LPS", "pressure": expected, "quality": "NONE"} + + +def test_bundled_epanet_metric_pressure_matches_head_minus_elevation(tmp_path): + binary_dir = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux" + exe = binary_dir / "runepanet" + if platform.system() != "Linux" or not exe.exists(): + pytest.skip("Bundled Linux EPANET runner unavailable") + inp, rpt, opt = (tmp_path / name for name in ("model.inp", "model.rpt", "model.opt")) + inp.write_text("[JUNCTIONS]\nJ1 10 1\n[RESERVOIRS]\nR1 100\n" + "[PIPES]\nP1 R1 J1 100 200 100 0 OPEN\n" + "[OPTIONS]\nUNITS LPS\n[END]\n") + env = {**os.environ, "LD_LIBRARY_PATH": str(binary_dir)} + result = subprocess.run([str(exe), str(inp), str(rpt), str(opt)], env=env, + capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stdout + result.stderr + output = Output(str(opt)) + assert output.units()["pressure"] == "MTR" + row = next(r for r in output.node_results() if r["node"] == "J1")["result"][0] + assert row["pressure"] == pytest.approx(row["head"] - 10, abs=1e-4) diff --git a/tests/unit/test_mcp_extended.py b/tests/unit/test_mcp_extended.py new file mode 100644 index 0000000..657f682 --- /dev/null +++ b/tests/unit/test_mcp_extended.py @@ -0,0 +1,377 @@ +import asyncio +from contextlib import asynccontextmanager, contextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +from app.mcp.catalog import TOOLS +from app.mcp.models import BoundContext, QueryResult +from app.mcp import extended_service, mutation_service, query_service, scenario_service, analysis_service + + +HANDLE = "x" * 43 +START = "2026-10-21T08:00:00+08:00" + + +def args(tool_name, **values): + return TOOLS[tool_name].model.model_validate({"context_handle": HANDLE, **values}) + + +def binding(**values): + return BoundContext(context_handle=HANDLE, project_id=uuid4(), subject="actor", client_id="host", + native_session_id="session", purpose="ordinary_chat", tool_cap=list(TOOLS), + permission_cap=["webgis.edit", "webgis.view", "scada.clean", "scada.view", "simulation.run"], + expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), **values) + + +@pytest.mark.parametrize("name,values", [ + ("query_network_extent", {"bounds": [120, 30, 122, 32]}), + ("query_network_extent", {"bounds": [121, 31, 120, 30]}), + ("query_network_extent", {"bounds": [float("nan"), 31, 122, 32]}), + ("calculate_demand_allocation", {"target": "network", "demand": 10, "node_ids": ["J1"]}), + ("edit_model_element", {"request_id": str(uuid4()), "operation": "delete", "element_type": "pipe", "element_id": "P1", "dry_run": False}), + ("edit_model_element", {"request_id": str(uuid4()), "operation": "update", "element_type": "pipe", "element_id": "P1", "properties": {"id": "other"}}), + ("clean_scada_readings", {"request_id": str(uuid4()), "start": START, "end": "2026-10-23T08:00:00+08:00", "device_ids": ["D1"]}), + ("run_burst_simulation", {"start_time": START, "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1, 2]}), +]) +def test_bounded_schemas_reject_unsafe_inputs(name, values): + with pytest.raises(ValidationError): + args(name, **values) + + +def test_saved_scenario_has_no_nested_identity_and_validates_timezone(): + values = dict(request_id=uuid4(), name="test", scenario="burst", parameters={ + "start_time": START, "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1], + }) + valid = args("save_simulation_scenario", **values) + assert "context_handle" not in valid.parameters.model_dump() + values["parameters"]["start_time"] = "2026-10-21T08:00:00" + with pytest.raises(ValidationError): + args("save_simulation_scenario", **values) + values["parameters"]["start_time"] = START + values["parameters"]["network"] = "other-project" + with pytest.raises(ValidationError): + args("save_simulation_scenario", **values) + + +@pytest.mark.parametrize("purpose,role", [("task_reader", "member"), ("task_execution", "member"), ("task_collaboration", "member"), ("ordinary_chat", "viewer")]) +def test_writes_never_enter_background_or_viewer_sessions(monkeypatch, purpose, role): + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.edit"}) + bound = binding() + bound.purpose = purpose + scope = SimpleNamespace(project=SimpleNamespace(project_role=role)) + with pytest.raises(HTTPException) as error: + asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object())) + assert error.value.status_code == 403 + + +def test_write_requires_current_and_captured_permissions(monkeypatch): + scope = SimpleNamespace(project=SimpleNamespace(project_role="member")) + bound = binding() + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: set()) + with pytest.raises(HTTPException): + asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object())) + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.edit"}) + bound.permission_cap = [] + with pytest.raises(HTTPException): + asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object())) + + +def test_extent_runs_real_parameterized_project_query_and_pages(monkeypatch): + read = AsyncMock(return_value=[{"id": "A"}, {"id": "B"}]) + monkeypatch.setattr(extended_service, "read_rows", read) + scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own")) + result = asyncio.run(extended_service.execute_extended(scope, object(), TOOLS["query_network_extent"], + args("query_network_extent", bounds=[121.0, 31.0, 121.1, 31.1], limit=1))) + assert result.page.has_more and result.truncated + assert result.data["items"] == [{"id": "A"}] + call = read.call_args.args + assert "ST_Intersects" in str(call[1]) + assert call[2] == (121.0, 31.0, 121.1, 31.1, 2, 0) + assert call[0] is scope.connection + + +def test_missing_gis_propagates_unavailable_without_cross_project_retry(monkeypatch): + read = AsyncMock(side_effect=ValueError("GIS unavailable")) + monkeypatch.setattr(extended_service, "read_rows", read) + scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="no-gis")) + with pytest.raises(ValueError): + asyncio.run(extended_service.execute_extended(scope, object(), TOOLS["query_network_extent"], + args("query_network_extent", bounds=[121.0, 31.0, 121.1, 31.1]))) + assert read.await_count == 1 + + +def test_demand_allocation_is_paged_and_never_sets_demands(monkeypatch): + from app.services import demand_allocation + monkeypatch.setattr(demand_allocation, "calculate_demand_to_nodes", lambda *a: {"B": 6, "A": 4}) + values = args("calculate_demand_allocation", target="nodes", node_ids=["A", "B"], demand=10, limit=1) + result = asyncio.run(analysis_service.calculate("calculate_demand_allocation", values, network="own", username="actor")) + assert result == {"items": [{"node_id": "A", "demand": 4}], "total": 2, "limit": 1, "offset": 0, "has_more": True, "persisted": False} + + +def test_burst_preview_uses_isolated_nonpersistent_adapter(monkeypatch): + from app.services import simulation_scenarios + calls = [] + monkeypatch.setattr(simulation_scenarios, "burst_analysis", lambda **kw: calls.append(kw) or {"node_results": [{"node": "J1"}], "link_results": []}) + values = args("run_burst_simulation", start_time=START, output_node_ids=["J1"], burst_pipe_ids=["P1"], orifice_areas_cm2=[2]) + result = scenario_service.calculate_scenario("run_burst_simulation", values.model_dump(exclude={"context_handle", "timeout_seconds"}), network="own", username="actor") + assert calls[0]["persist_results"] is False and calls[0]["scheme_name"] is None + assert calls[0]["burst_size"] == [2] + assert result["persisted_baseline"] is False + + +class Connection: + @asynccontextmanager + async def transaction(self): + yield + + async def commit(self): + pass + + +class Journal: + records = {} + + def __init__(self, *args): + pass + + async def lock_receipt(self, *args): + pass + + async def receipt(self, scope, key): + return self.records.get((scope, key)) + + async def save_receipt(self, scope, key, parameters, result): + self.records[(scope, key)] = {"parameters": parameters, "result": result} + + async def finish_receipt(self, scope, key, result): + self.records[(scope, key)]["result"] = result + + +@pytest.fixture +def mutation(monkeypatch): + Journal.records = {} + monkeypatch.setattr(mutation_service, "McpQueryRepository", Journal) + preview = AsyncMock(return_value={"before": {"diameter": 100}, "properties": {"diameter": 200}}) + apply = AsyncMock(return_value={"changed": True}) + monkeypatch.setattr(mutation_service, "preview_change", preview) + monkeypatch.setattr(mutation_service, "apply_change", apply) + scope = SimpleNamespace(connection=Connection(), project=SimpleNamespace(project_id=uuid4())) + values = dict(request_id=uuid4(), operation="update", element_type="pipe", element_id="P1", properties={"diameter": 200}) + return scope, binding(), values, preview, apply + + +def test_preview_execution_stale_state_and_idempotent_replay(mutation): + scope, bound, values, preview, apply = mutation + tool = TOOLS["edit_model_element"] + async def run(): + result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values)) + assert result.data["dry_run"] and not apply.called + execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"]) + preview.return_value = {"before": {"diameter": 150}} + with pytest.raises(HTTPException) as error: + await mutation_service.execute_mutation(scope, bound, tool, execution) + assert error.value.status_code == 409 and not apply.called + assert Journal.records == {} + preview.return_value = {"before": {"diameter": 100}, "properties": {"diameter": 200}} + result = await mutation_service.execute_mutation(scope, bound, tool, execution) + replay = await mutation_service.execute_mutation(scope, bound, tool, execution) + assert replay == result + assert apply.await_count == 1 + asyncio.run(run()) + + +def test_failed_or_timed_out_write_cannot_be_automatically_reexecuted(mutation): + scope, bound, values, preview, apply = mutation + tool = TOOLS["edit_model_element"] + async def run(): + result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values)) + execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"]) + apply.side_effect = TimeoutError() + with pytest.raises(TimeoutError): + await mutation_service.execute_mutation(scope, bound, tool, execution) + with pytest.raises(HTTPException) as error: + await mutation_service.execute_mutation(scope, bound, tool, execution) + assert error.value.status_code == 409 + assert apply.await_count == 1 + asyncio.run(run()) + + +def test_capacity_rejection_is_retryable_under_the_same_request_after_slots_free(mutation, monkeypatch): + from threading import BoundedSemaphore + + scope, bound, values, preview, apply = mutation + tool = TOOLS["edit_model_element"] + slots = BoundedSemaphore(2) + monkeypatch.setattr(analysis_service, "_slots", slots) + writes = [] + + async def bounded_write(*unused): + return await analysis_service.bounded_calculation(lambda: writes.append("written") or {"changed": True}) + + apply.side_effect = bounded_write + + async def run(): + result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values)) + execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"]) + assert slots.acquire(blocking=False) and slots.acquire(blocking=False) + try: + with pytest.raises(HTTPException) as error: + await mutation_service.execute_mutation(scope, bound, tool, execution) + assert error.value.status_code == 503 + record = next(iter(Journal.records.values())) + assert record["result"]["status"] == "rejected" + assert record["result"]["retryable"] is True + assert record["result"]["error"]["code"] == "CALCULATION_CAPACITY_EXCEEDED" + assert writes == [] + finally: + slots.release() + slots.release() + saved = await mutation_service.execute_mutation(scope, bound, tool, execution) + replay = await mutation_service.execute_mutation(scope, bound, tool, execution) + assert replay == saved and writes == ["written"] + assert next(iter(Journal.records.values()))["result"]["status"] == "completed" + asyncio.run(run()) + + +@pytest.mark.parametrize("failure", [TimeoutError(), asyncio.CancelledError(), RuntimeError("native failure"), HTTPException(503, "unrelated source failure")]) +def test_ambiguous_write_failure_remains_started_and_cannot_be_retried(mutation, failure): + scope, bound, values, preview, apply = mutation + tool = TOOLS["edit_model_element"] + + async def run(): + result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values)) + execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"]) + apply.side_effect = failure + with pytest.raises(type(failure)): + await mutation_service.execute_mutation(scope, bound, tool, execution) + assert next(iter(Journal.records.values()))["result"]["status"] == "started" + with pytest.raises(HTTPException) as error: + await mutation_service.execute_mutation(scope, bound, tool, execution) + assert error.value.status_code == 409 and apply.await_count == 1 + asyncio.run(run()) + + +def test_native_model_lock_revalidates_preview_and_only_writes_allowed_fields(monkeypatch): + from app.services import tjnetwork + from app.native.wndb.core import database + state = {"id": "P1", "diameter": 100} + monkeypatch.setattr(tjnetwork, "get_pipe", lambda *a: dict(state)) + monkeypatch.setattr(tjnetwork, "get_pipe_schema", lambda *a: {"id": {"readonly": True}, "diameter": {"type": "float", "readonly": False}}) + @contextmanager + def lock(*a): + yield + monkeypatch.setattr(database, "model_mutation_transaction", lock) + writes = [] + monkeypatch.setattr(tjnetwork, "set_pipe", lambda network, changes: writes.append(changes.operations) or changes) + values = dict(request_id=uuid4(), operation="update", element_type="pipe", element_id="P1", properties={"diameter": 200}) + preview = mutation_service.model_change("own", "edit_model_element", args("edit_model_element", **values), execute=False) + execution = args("edit_model_element", **values, dry_run=False, expected_digest=mutation_service.digest(preview)) + state["diameter"] = 110 + with pytest.raises(HTTPException): + mutation_service.model_change("own", "edit_model_element", execution, execute=True) + assert not writes + state["diameter"] = 100 + mutation_service.model_change("own", "edit_model_element", execution, execute=True) + assert writes == [[{"id": "P1", "diameter": 200}]] + + +def test_artifacts_are_integrity_checked_bounded_binary_envelopes(): + result = extended_service.artifact("model.zip", b"zip-data", "application/zip") + assert result["file"]["size"] == 8 + assert len(result["file"]["sha256"]) == 64 + with pytest.raises(HTTPException) as error: + extended_service.artifact("model.zip", bytes(5 * 1024 * 1024 + 1), "application/zip") + assert error.value.status_code == 413 + + +def test_no_health_prediction_tool_and_every_write_requests_native_permission(): + assert not any("health" in name for name in TOOLS) + assert len(TOOLS) == 67 + assert sum(t.mutating for t in TOOLS.values()) == 13 + for tool in TOOLS.values(): + if tool.mutating: + assert not tool.protocol_tool().annotations.read_only_hint + assert tool.protocol_tool().annotations.destructive_hint + + +@pytest.mark.parametrize("scenario,parameters,function", [ + ("burst", {"burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1]}, "burst_analysis"), + ("contaminant", {"source": "J2", "concentration": 1}, "contaminant_simulation"), + ("valve_closure", {"valves": ["V1"]}, "valve_close_analysis"), +]) +def test_formal_scenarios_bind_actor_and_persist_only_through_existing_services(monkeypatch, scenario, parameters, function): + from app.services import simulation_scenarios + calls = [] + monkeypatch.setattr(simulation_scenarios, function, lambda **kw: calls.append(kw)) + payload = {"start_time": START, "duration": 900, "output_node_ids": ["J1"], **parameters} + values = args("save_simulation_scenario", request_id=uuid4(), name="formal-test", scenario=scenario, parameters=payload) + mutation_service.saved_scenario("owned-project", "verified-user", values) + assert calls[0]["name"] == "owned-project" + assert calls[0]["username"] == "verified-user" + assert calls[0]["persist_results"] is True + assert calls[0]["scheme_name"] == "formal-test" + + +def test_mutation_status_is_scoped_to_bound_identity_session_and_project(monkeypatch): + calls = [] + class Repository: + def __init__(self, conn, project): + calls.append(project) + async def receipt(self, scope_key, request_id): + calls.extend([scope_key, request_id]) + return {"parameters": {"tool": "edit_model_element"}, "result": {"status": "started"}} + import app.infra.db.postgresql.mcp_queries as storage + monkeypatch.setattr(storage, "McpQueryRepository", Repository) + bound = binding() + request_id = uuid4() + scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own", project_id=bound.project_id)) + result = asyncio.run(extended_service.execute_extended(scope, bound, TOOLS["get_mutation_status"], args("get_mutation_status", request_id=request_id))) + assert calls == [bound.project_id, "mutation:actor:session", str(request_id)] + assert result.data["status"] == "started" + + +def test_mutation_status_exposes_rejected_capacity_and_retryability(monkeypatch): + class Repository: + def __init__(self, *unused): + pass + async def receipt(self, *unused): + return {"parameters": {"tool": "save_simulation_scenario"}, "result": { + "status": "rejected", "retryable": True, + "error": {"code": "CALCULATION_CAPACITY_EXCEEDED", "message": "calculation not started"}, + }} + import app.infra.db.postgresql.mcp_queries as storage + monkeypatch.setattr(storage, "McpQueryRepository", Repository) + bound = binding() + scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own", project_id=bound.project_id)) + result = asyncio.run(extended_service.execute_extended(scope, bound, TOOLS["get_mutation_status"], + args("get_mutation_status", request_id=uuid4()))) + assert result.data["status"] == "rejected" and result.data["retryable"] is True + assert result.data["error"]["code"] == "CALCULATION_CAPACITY_EXCEEDED" + + +def test_scada_preview_and_journal_distinguish_omitted_fields_from_explicit_null(): + values = dict(request_id=uuid4(), operation="update", provenance="operator correction", + records=[{"device_id": "D1", "time": START, "monitored_value": 1}]) + original = args("write_scada_readings", **values) + values["records"][0]["cleaned_value"] = None + clearing = args("write_scada_readings", **values) + for preview in [True, False]: + left = mutation_service.mutation_arguments(original, preview=preview) + right = mutation_service.mutation_arguments(clearing, preview=preview) + assert "cleaned_value" not in left["records"][0] + assert right["records"][0]["cleaned_value"] is None + assert mutation_service.digest(left) != mutation_service.digest(right) + + +def test_nested_model_properties_cannot_hide_nonfinite_or_unknown_fields(): + schema = {"demands": {"type": "list", "readonly": False, "element": {"demand": {"type": "float", "readonly": False}}}} + for value in [{"demand": float("nan")}, {"demand": 1, "url": "https://foreign"}]: + with pytest.raises(HTTPException) as error: + mutation_service.check_properties(schema, {"demands": [value]}) + assert error.value.status_code == 422 diff --git a/tests/unit/test_mcp_query.py b/tests/unit/test_mcp_query.py index ee26efa..bed4462 100644 --- a/tests/unit/test_mcp_query.py +++ b/tests/unit/test_mcp_query.py @@ -80,6 +80,32 @@ def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime): assert calls == [] +def test_calculation_capacity_has_an_explicit_mcp_error(runtime, monkeypatch): + from threading import BoundedSemaphore + from app.mcp import analysis_service + + app, client, _, _ = runtime + monkeypatch.setattr(application, "authorize_query", AsyncMock()) + slots = BoundedSemaphore(2) + monkeypatch.setattr(analysis_service, "_slots", slots) + async def execute(*unused): + return await analysis_service.bounded_calculation(lambda: pytest.fail("occupied calculation entered")) + app.executor = execute + assert slots.acquire(blocking=False) and slots.acquire(blocking=False) + try: + response = request(client, "tools/call", {"name": "run_simulation", "arguments": { + "context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00", + "duration": 3600, "output_node_ids": ["J1"], + }}).json() + finally: + slots.release() + slots.release() + error = response["result"]["structuredContent"]["error"] + assert response["result"]["isError"] is True + assert error["code"] == "CALCULATION_CAPACITY_EXCEEDED" + assert "not started" in error["message"] + + def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch): app, client, calls, _ = runtime monkeypatch.setattr(application, "authorize_query", AsyncMock()) @@ -286,10 +312,10 @@ def test_modern_discovery_catalog_and_metadata(runtime): assert result["result"]["resultType"] == "complete" assert "2026-07-28" in result["result"]["supportedVersions"] listed = request(client).json()["result"] - assert len(listed["tools"]) == 35 + assert len(listed["tools"]) == len(TOOLS) for tool in listed["tools"]: timeout = tool["inputSchema"]["properties"].get("timeout_seconds") - assert (timeout is not None) == TOOLS[tool["name"]].computation + assert (timeout is not None) == (TOOLS[tool["name"]].computation or TOOLS[tool["name"]].mutating) if timeout: assert timeout["default"] == 120 assert timeout["maximum"] == 3600 diff --git a/tests/unit/test_metadata_repository_projects.py b/tests/unit/test_metadata_repository_projects.py index 6f79718..c07d82f 100644 --- a/tests/unit/test_metadata_repository_projects.py +++ b/tests/unit/test_metadata_repository_projects.py @@ -85,3 +85,17 @@ async def test_create_provisioned_project_commits_project_routes_and_membership_ assert added[3].user_id == user_id session.commit.assert_awaited_once() session.refresh.assert_awaited_once_with(project) + + +@pytest.mark.anyio +async def test_update_map_config_preserves_legacy_settings_and_other_projects(): + project = SimpleNamespace(map_config={'legend': 'original', 'coordinate_reference': {'source_srid': 990001}}) + session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock()) + repository = MetadataRepository(session) + repository.get_project_by_id = AsyncMock(return_value=project) + project_id = uuid4() + search = {'enabled': True, 'provider': 'tianditu', 'admin_code': '341126', 'region': '凤阳县'} + await repository.update_project(project_id, updates={'map_config': {'place_search': search}}) + repository.get_project_by_id.assert_awaited_once_with(project_id) + assert project.map_config == {'legend': 'original', 'coordinate_reference': {'source_srid': 990001}, 'place_search': search} + session.commit.assert_awaited_once() diff --git a/tests/unit/test_operational_tasks.py b/tests/unit/test_operational_tasks.py index 1f1857a..a63c7c0 100644 --- a/tests/unit/test_operational_tasks.py +++ b/tests/unit/test_operational_tasks.py @@ -1803,3 +1803,42 @@ async def test_repository_task_update_rejects_a_stale_revision(): await repository.update_task(task) assert exc.value.code == "VERSION_CONFLICT" + + +@pytest.mark.anyio +async def test_interactive_report_publication_is_idempotent_and_versioned_with_evidence(): + from tests.unit.test_report_ui import rich_report + + now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + report = {**rich_report(), "stage": "final"} + command = AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report=report, + evidence=[{"evidence_id": "demo-1", "kind": "synthetic", "summary": "演练压力记录", + "source_label": "演练监测", "observed_at": now.isoformat()}], + ) + first = await service.publish_artifacts(task["task_id"], command) + replay = await service.publish_artifacts(task["task_id"], command) + assert first.report_revision == replay.report_revision == 1 + report["ui_blocks"][0]["block"]["spec"]["elements"]["chart"]["props"]["series"][0]["data"] = [0.3, 0.29] + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report=report, + )) + old = await service.report(task["task_id"], 1) + latest = await service.detail(task["task_id"]) + history, _ = await service.list_reports(task["task_id"], 10, 0) + assert old.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27] + assert latest.latest_report.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.29] + assert [item.revision for item in history] == [2, 1] + assert latest.latest_report.report.evidence_refs == ["demo-1"] + assert len(repository.notifications) == 2 + report["evidence_refs"] = ["foreign"] + report["ui_blocks"][0]["evidence_refs"] = ["foreign"] + with pytest.raises(DomainError) as error: + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report=report, + )) + assert error.value.code == "EVIDENCE_NOT_FOUND" diff --git a/tests/unit/test_peripheral_project_routing.py b/tests/unit/test_peripheral_project_routing.py new file mode 100644 index 0000000..3db5c0a --- /dev/null +++ b/tests/unit/test_peripheral_project_routing.py @@ -0,0 +1,212 @@ +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient +from psycopg.conninfo import conninfo_to_dict + +from app.auth.project_dependencies import get_metadata_repository +from app.infra.db.project_routing import ActiveProjectRouting +from samples.peripheral import auth +from samples.peripheral.api import create_app +from samples.peripheral.config import SampleSettings +from samples.peripheral.errors import SampleFailure +from samples.peripheral.store import PostgresStore + + +@pytest.fixture +def project_api(monkeypatch): + projects = {str(uuid4()): "tjwater_v2", str(uuid4()): "fengyang_v2"} + connections = [] + permissions = ["simulation.view"] + missing = set() + route_overrides = {} + + class Repository: + async def get_project_db_routing(self, project_id, role): + code = projects[str(project_id)] + if (code, role) in missing: + return None + database = code + ("_history" if role == "iot_data" else "_business") + return SimpleNamespace(**({ + "dsn": f"postgresql://test@localhost/{database}", + "db_type": "timescaledb" if role == "iot_data" else "postgresql", + } | route_overrides.get((code, role), {}))) + + class Connection: + def __init__(self, database): + self.database = database + self.info = SimpleNamespace(dbname=database) + self.rows = [] + self.readonly = False + self.statements = [] + + def execute(self, statement, parameters=None): + self.statements.append((statement, parameters)) + self.rows = [] + if statement == "SET TRANSACTION READ ONLY": + self.readonly = True + if "current_database()" in statement: + self.rows = [{"name": self.database}] + elif "peripheral_sample.scenarios" in statement: + self.rows = [] + elif "to_regclass('asset.scada_devices')" in statement: + self.rows = [{"table_name": "asset.scada_devices"}] + elif "FROM asset.scada_devices" in statement: + self.rows = [{"device_id": self.database, "device_type": "pressure", + "measurement_unit": "MPa", "api_query_id": "point-1"}] + elif "FROM scada.measurements" in statement: + self.rows = [{"time": datetime(2026, 10, 21, tzinfo=timezone.utc), "monitored_value": 0.3}] + elif "to_regclass('workbench.domain_event_log')" in statement: + self.rows = [{"event_log": "workbench.domain_event_log"}] + elif "RETURNING event_seq" in statement: + self.rows = [{"event_seq": 42}] + return self + + def fetchone(self): + return self.rows[0] if self.rows else None + + def fetchall(self): + return self.rows + + @contextmanager + def connect(*, conninfo, **kwargs): + connection = Connection(conninfo_to_dict(conninfo)["dbname"]) + connections.append(connection) + yield connection + + monkeypatch.setattr("samples.peripheral.store.psycopg.connect", connect) + monkeypatch.setattr(auth, "next_get", lambda request, path: { + "project_id": request.headers["x-project-id"], "user_id": str(uuid4()), + "network": projects[request.headers["x-project-id"]], "permissions": permissions, + }) + app = create_app(SampleSettings()) + app.dependency_overrides[get_metadata_repository] = lambda: Repository() + with TestClient(app) as client: + yield SimpleNamespace(client=client, projects=projects, connections=connections, + permissions=permissions, missing=missing, route_overrides=route_overrides) + + +def headers(project): + return {"Authorization": "Bearer TEST_ONLY", "X-Project-Id": project} + + +def test_concurrent_project_reads_use_separate_metadata_databases(project_api): + api = project_api + + def read(project): + response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project)) + assert response.status_code == 200, response.text + return project, response.json() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(read, list(api.projects) * 3)) + for project, payload in results: + database = api.projects[project] + "_business" + assert payload["source_id"] == database + ":asset.scada_devices" + assert payload["items"][0]["point_id"] == database + assert all(connection.readonly for connection in api.connections) + + +def test_history_uses_separate_project_timescale_route(project_api): + api = project_api + project, code = list(api.projects.items())[1] + response = api.client.get("/api/sample/v1/monitoring/observations", headers=headers(project), params={ + "point_id": "point-1", "start": "2026-10-21T00:00:00Z", "end": "2026-10-22T00:00:00Z", + }) + assert response.status_code == 200, response.text + assert response.json()["items"][0]["value"] == 0.3 + assert response.json()["source_id"] == code + "_history:scada.measurements" + assert [connection.database for connection in api.connections] == [code + "_business", code + "_history"] + assert all(connection.readonly for connection in api.connections) + + +def test_missing_routing_never_falls_back_to_default_database(project_api): + api = project_api + project, code = list(api.projects.items())[1] + api.missing.add((code, "biz_data")) + response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project)) + assert response.status_code == 503 + assert response.json()["code"] == "PROJECT_STORAGE_NOT_CONFIGURED" + assert not api.connections + + +def test_work_order_writes_and_events_use_the_selected_project(project_api): + api = project_api + api.permissions.append("simulation.run") + project, code = list(api.projects.items())[1] + response = api.client.post("/api/sample/v1/work-orders", headers=headers(project), json={ + "request_id": str(uuid4()), "title": "routing test", "summary": "test only", + }) + assert response.status_code == 201, response.text + assert response.json()["project_id"] == project + assert len(api.connections) == 1 + connection = api.connections[0] + assert connection.database == code + "_business" + assert not connection.readonly + event = next(params for sql, params in connection.statements if "INSERT INTO workbench.domain_event_log" in sql) + assert event[0] == project + assert event[2] == response.json()["work_order_id"] + + +@pytest.mark.parametrize("override", [ + {"dsn": "postgresql://test@localhost"}, {"dsn": "not-a-valid-dsn"}, {"db_type": "timescaledb"}, +]) +def test_invalid_project_routes_fail_without_opening_storage(project_api, override): + api = project_api + project, code = list(api.projects.items())[1] + api.route_overrides[(code, "biz_data")] = override + response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project)) + assert response.status_code == 503 + assert response.json()["code"] == "PROJECT_STORAGE_NOT_CONFIGURED" + assert not api.connections + + +def test_crud_reads_do_not_require_timescale_configuration(project_api): + api = project_api + project, code = list(api.projects.items())[1] + api.missing.add((code, "iot_data")) + response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project)) + assert response.status_code == 200 + unavailable = api.client.get("/api/sample/v1/monitoring/observations", headers=headers(project), params={ + "point_id": "point-1", "start": "2026-10-21T00:00:00Z", "end": "2026-10-22T00:00:00Z", + }) + assert unavailable.status_code == 503 + + +@pytest.mark.parametrize("method,permissions", [("GET", []), ("POST", ["simulation.view"])]) +def test_project_routing_preserves_read_and_write_permissions(project_api, method, permissions): + api = project_api + api.permissions[:] = permissions + response = api.client.request(method, "/api/sample/v1/scenario", headers=headers(next(iter(api.projects)))) + assert response.status_code == 403 + assert response.json()["code"] == "PERMISSION_DENIED" + assert not api.connections + + +def test_mismatched_identity_is_rejected_before_storage(project_api, monkeypatch): + monkeypatch.setattr(auth, "next_get", lambda request, path: { + "project_id": str(uuid4()), "user_id": str(uuid4()), "network": "fengyang_v2", + "permissions": ["simulation.view", "simulation.run"], + }) + response = project_api.client.get("/api/sample/v1/context", headers=headers(next(iter(project_api.projects)))) + assert response.status_code == 403 + assert response.json()["code"] == "PROJECT_MISMATCH" + assert not project_api.connections + + +def test_routed_connection_does_not_use_legacy_settings_or_missing_timescale(project_api): + project = next(iter(project_api.projects)) + routing = ActiveProjectRouting("logical-project", "dbname=physical-business", project_id=UUID(project)) + store = PostgresStore(SampleSettings(db_name="unrelated"), routing=routing) + assert store.settings.db_name == "physical-business" + with store.connection(readonly=True): + pass + with pytest.raises(SampleFailure) as failure: + with store.connection(readonly=True, timeseries=True): + pass + assert failure.value.code == "PROJECT_STORAGE_NOT_CONFIGURED" + assert [connection.database for connection in project_api.connections] == ["physical-business"] diff --git a/tests/unit/test_peripheral_work_order_events.py b/tests/unit/test_peripheral_work_order_events.py index 5ccd12e..95ca19e 100644 --- a/tests/unit/test_peripheral_work_order_events.py +++ b/tests/unit/test_peripheral_work_order_events.py @@ -10,27 +10,29 @@ from samples.peripheral.store import PostgresStore class EventConnection: def __init__(self): self.statements = [] + self.event_log = True - def execute(self, statement, parameters): + def execute(self, statement, parameters=None): self.statements.append((statement, parameters)) return self def fetchone(self): - return {"event_seq": 42} + return {"event_seq": 42, "event_log": self.event_log} @pytest.mark.parametrize(("revision", "deleted", "change"), [ (1, False, "created"), (2, False, "updated"), (3, True, "deleted"), ]) -def test_work_order_signals_use_same_connection_and_contain_only_identity(revision, deleted, change): +@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "physical-project-db"]) +def test_work_order_signals_use_same_connection_and_contain_only_identity(revision, deleted, change, database): conn = EventConnection() who = Identity(str(uuid4()), str(uuid4()), True) order = {"work_order_id": str(uuid4()), "revision": revision, "updated_at": "2026-10-08T00:00:00Z", "title": "private title"} - PostgresStore(SampleSettings())._work_order_event(conn, who, order, deleted=deleted) + PostgresStore(SampleSettings(db_name=database))._work_order_event(conn, who, order, deleted=deleted) - statement, parameters = conn.statements[0] + statement, parameters = conn.statements[1] assert "workbench.domain_event_log" in statement assert "'workbench.work_order.changed','peripheral_work_order'" in statement assert parameters[0] == who.project_id @@ -38,13 +40,14 @@ def test_work_order_signals_use_same_connection_and_contain_only_identity(revisi assert parameters[5].obj == { "source": "peripheral_sample", "work_order_id": order["work_order_id"], "change": change, } - assert conn.statements[1] == ( + assert conn.statements[2] == ( "SELECT pg_notify('workbench_events', %s)", (f"{who.project_id}:42",), ) -def test_event_rollout_does_not_write_to_other_databases(): +def test_event_publication_does_not_create_missing_storage(): conn = EventConnection() + conn.event_log = False store = PostgresStore(SampleSettings(db_name="another_project")) store._work_order_event(conn, Identity(str(uuid4()), str(uuid4()), True), {}) - assert conn.statements == [] + assert conn.statements == [("SELECT to_regclass('workbench.domain_event_log') AS event_log", None)] diff --git a/tests/unit/test_place_search.py b/tests/unit/test_place_search.py index a918e1e..c809d87 100644 --- a/tests/unit/test_place_search.py +++ b/tests/unit/test_place_search.py @@ -8,6 +8,9 @@ from pydantic import ValidationError from app.core.config import Settings, settings from app.domain.schemas.place_search import PlaceSearchRequest +from app.domain.schemas.project_map_config import PlaceSearchConfig + +CONFIG = PlaceSearchConfig(enabled=True, admin_code="310120", region="奉贤区") from app.services.place_search import PlaceSearchError, search_tianditu_places @@ -44,7 +47,7 @@ def shared_token(monkeypatch): def test_search_fixes_fengxian_scope_and_normalizes_candidates(): client = FakeClient() - result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), client=client)) + result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), config=CONFIG, client=client)) url, params = client.calls[0] assert url == "https://api.tianditu.gov.cn/v2/search" assert params["tk"] == "shared-token" @@ -60,14 +63,14 @@ def test_search_fixes_fengxian_scope_and_normalizes_candidates(): @pytest.mark.parametrize("data", [payload(count=0, pois=[]), {"status": {"infocode": 3001}}]) def test_no_matches_is_a_successful_empty_result(data): - result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="未知地名"), client=FakeClient(data))) + result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="未知地名"), config=CONFIG, client=FakeClient(data))) assert result.items == [] and result.total == 0 and not result.truncated @pytest.mark.parametrize("matched_total, size, truncated", [(0, 0, False), (3, 3, False), (10, 10, False), (28, 10, True), (2000, 10, True)]) def test_search_only_returns_first_ten_results(matched_total, size, truncated): client = FakeClient(payload(count=matched_total, pois=[poi(hotPointID=f"place-{index}") for index in range(size)])) - result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client)) + result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client)) assert len(result.items) == result.total == size and result.truncated == truncated params = json.loads(client.calls[0][1]["postStr"]) assert params["count"] == 10 and params["start"] == 0 and params["specify"] == "156310120" @@ -90,7 +93,7 @@ def test_search_only_returns_first_ten_results(matched_total, size, truncated): ]) def test_invalid_or_unlocatable_provider_response_is_rejected(data): with pytest.raises(PlaceSearchError) as exc: - asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=FakeClient(data))) + asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=FakeClient(data))) assert exc.value.status_code == 502 @@ -98,7 +101,7 @@ def test_missing_shared_token_is_configuration_error(monkeypatch): monkeypatch.setattr(settings, "TIANDITU_TOKEN", "") client = FakeClient() with pytest.raises(PlaceSearchError) as exc: - asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client)) + asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client)) assert exc.value.status_code == 503 and client.calls == [] @@ -108,7 +111,7 @@ def test_http_request_logs_redact_the_shared_token(caplog): async def run(): transport = httpx.MockTransport(lambda _request: httpx.Response(200, json=payload())) async with httpx.AsyncClient(transport=transport) as client: - await search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client) + await search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client) asyncio.run(run()) messages = [record.getMessage() for record in caplog.records if record.name == "httpx"] @@ -122,7 +125,7 @@ def test_http_request_logs_redact_the_shared_token(caplog): ]) def test_transport_errors_are_safe_and_actionable(failure, status): with pytest.raises(PlaceSearchError) as exc: - asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=FakeClient(error=failure))) + asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=FakeClient(error=failure))) assert exc.value.status_code == status assert "secret-token" not in exc.value.detail @@ -138,3 +141,32 @@ def test_shared_token_accepts_existing_configuration_names(monkeypatch, key): for name in ("TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"): monkeypatch.delenv(name, raising=False) assert Settings(_env_file=None, **{key: "one-token"}).TIANDITU_TOKEN == "one-token" + + +@pytest.mark.parametrize('admin_code,region,county_code,county,province', [ + ('341126', '凤阳县', '156341126', '凤阳县', '156340000'), + ('320506', '吴中区', '320506', '吴中区', '320000'), + ('440800', '湛江市', '156440803', '霞山区', '156440000'), + ('310000', '上海市', '156310118', '青浦区', '156310000'), +]) +def test_search_uses_project_scope_for_districts_and_cities(admin_code, region, county_code, county, province): + config = PlaceSearchConfig(enabled=True, admin_code=admin_code, region=region) + client = FakeClient(payload(pois=[poi(countyCode=county_code, county=county, provinceCode=province)])) + result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), config=config, client=client)) + assert json.loads(client.calls[0][1]['postStr'])['specify'] == '156' + admin_code + assert result.region == region and result.items[0].district == county + + +def test_configured_city_rejects_other_city_in_same_province(): + config = PlaceSearchConfig(enabled=True, admin_code='440800', region='湛江市') + with pytest.raises(PlaceSearchError, match='无效'): + asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), config=config, + client=FakeClient(payload(pois=[poi(countyCode='156440104', county='越秀区', provinceCode='156440000')])))) + + +def test_disabled_project_does_not_call_provider(): + client = FakeClient() + with pytest.raises(PlaceSearchError) as exc: + asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), + config=CONFIG.model_copy(update={'enabled': False}), client=client)) + assert exc.value.status_code == 503 and client.calls == [] diff --git a/tests/unit/test_report_ui.py b/tests/unit/test_report_ui.py new file mode 100644 index 0000000..49f2ac1 --- /dev/null +++ b/tests/unit/test_report_ui.py @@ -0,0 +1,74 @@ +from copy import deepcopy +import json + +import pytest +from pydantic import ValidationError + +from app.domain.schemas.operational_task import InvestigationReportInput, InvestigationReportRevision + + +def rich_report(): + return { + "title": "演练压力核验", "current_conclusion": "待核验", "conclusion_boundary": "演练记录", + "stage": "interim", "outcome": "concluded", "data_gaps": [], "evidence_refs": ["demo-1"], + "ui_blocks": [{ + "evidence_refs": ["demo-1"], + "block": { + "version": "answer-ui@1", "block_id": "pressure", "fallback_text": "演练压力 0.27 MPa", + "spec": {"root": "chart", "elements": { + "chart": {"type": "Chart", "children": [], "props": { + "title": "演练压力", "chart_type": "line", "x_data": ["08:00", "09:00"], + "series": [{"name": "压力 MPa", "data": [0.3, 0.27]}], "source": "演练记录", + }}, + }}, + }, + }], + } + + +def test_report_ui_round_trips_with_a_version_and_keeps_legacy_reports_readable(): + report = InvestigationReportInput.model_validate(rich_report()) + snapshot = InvestigationReportRevision( + revision=3, execution_generation=2, source_session_id="test", + published_at="2026-10-20T08:00:00Z", published_by="test", report=report, + ) + restored = InvestigationReportRevision.model_validate_json(snapshot.model_dump_json()) + assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27] + assert (restored.revision, restored.execution_generation) == (3, 2) + old = json.loads(snapshot.model_dump_json()) + del old["report"]["ui_blocks"] + assert InvestigationReportRevision.model_validate(old).report.ui_blocks == [] + + +@pytest.mark.parametrize("defect", ["unknown", "script", "unaligned", "nan", "cycle", "unreachable", "duplicate", "foreign", "many", "oversized"]) +def test_report_ui_rejects_invalid_or_unbound_content(defect): + data = rich_report() + entry = data["ui_blocks"][0] + spec = entry["block"]["spec"] + chart = spec["elements"]["chart"] + if defect == "unknown": + chart["type"] = "iframe" + elif defect == "script": + chart["props"]["onClick"] = "execute()" + elif defect == "unaligned": + chart["props"]["series"][0]["data"] = [1] + elif defect == "nan": + chart["props"]["series"][0]["data"][0] = float("nan") + elif defect == "cycle": + spec["elements"]["chart"] = {"type": "Stack", "props": {"columns": "one", "gap": "normal"}, "children": ["chart"]} + elif defect == "unreachable": + spec["elements"]["unused"] = deepcopy(chart) + elif defect == "duplicate": + data["ui_blocks"].append(deepcopy(entry)) + elif defect == "foreign": + entry["evidence_refs"] = ["other-task"] + elif defect == "many": + data["ui_blocks"] = [{**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": f"b{i}"}} for i in range(9)] + elif defect == "oversized": + # Individually valid blocks can still exceed the UTF-8 report budget. + elements = {"root": {"type": "Stack", "props": {"columns": "one", "gap": "normal"}, "children": [f"n{i}" for i in range(20)]}} + elements.update({f"n{i}": {"type": "Text", "props": {"text": "资" * 3900, "tone": "neutral"}, "children": []} for i in range(20)}) + entry["block"]["spec"] = {"root": "root", "elements": elements} + data["ui_blocks"] = [entry, {**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": "second"}}] + with pytest.raises(ValidationError): + InvestigationReportInput.model_validate(data) diff --git a/tests/unit/test_reverse_geocoding.py b/tests/unit/test_reverse_geocoding.py new file mode 100644 index 0000000..2755439 --- /dev/null +++ b/tests/unit/test_reverse_geocoding.py @@ -0,0 +1,110 @@ +import asyncio +import json +import logging + +import httpx +import pytest +from pydantic import ValidationError + +from app.core.config import settings +from app.domain.schemas.place_search import ReverseGeocodingRequest +from app.domain.schemas.project_map_config import PlaceSearchConfig +from app.services.place_search import PlaceSearchError +from app.services.reverse_geocoding import reverse_geocode_tianditu + +CONFIG = PlaceSearchConfig(enabled=True, admin_code='341126', region='凤阳县') +POINT = ReverseGeocodingRequest(longitude=117.570309, latitude=32.868029) + + +def provider_result(**component): + return {'status': '0', 'result': { + 'formatted_address': '安徽省滁州市凤阳县府城镇朝阳路76号', + 'location': {'lon': 117.57, 'lat': 32.86}, + 'addressComponent': {'poi': '府城镇', 'road': '朝阳路', 'county': '凤阳县', + 'county_code': '156341126', 'province_code': '156340000', **component}}} + + +@pytest.fixture(autouse=True) +def token(monkeypatch): + monkeypatch.setattr(settings, 'TIANDITU_TOKEN', 'test-only-token') + + +def lookup(data=None, *, config=CONFIG, status=200, failure=None): + calls = [] + + def transport(request): + calls.append(request) + if failure: + raise failure + return httpx.Response(status, json=provider_result() if data is None else data) + + async def run(): + async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as client: + return await reverse_geocode_tianditu(POINT, config=config, client=client) + + return asyncio.run(run()), calls + + +def test_reverse_lookup_keeps_clicked_coordinate_and_normalizes_the_address(): + result, calls = lookup() + assert result.name == '府城镇' and result.address == '安徽省滁州市凤阳县府城镇朝阳路76号' + assert (result.longitude, result.latitude) == (POINT.longitude, POINT.latitude) + assert result.district == '凤阳县' and result.category == '附近地名' + params = calls[0].url.params + assert params['type'] == 'geocode' and params['tk'] == 'test-only-token' + assert json.loads(params['postStr']) == {'lon': POINT.longitude, 'lat': POINT.latitude, 'ver': 1} + + +def test_reverse_lookup_supports_city_scope_and_falls_back_to_real_road_name(): + result, _ = lookup(provider_result(poi='', county='青浦区', county_code='156310118', province_code='156310000'), + config=PlaceSearchConfig(enabled=True, admin_code='310000', region='上海市')) + assert result.name == '朝阳路' and result.category == '地址' and result.district == '青浦区' + + +@pytest.mark.parametrize('data,status', [ + ({'status': '0', 'result': None}, 404), + ({'status': '0', 'result': {'formatted_address': ''}}, 404), + ({'status': '1'}, 502), + ({'status': '0', 'result': 'bad'}, 502), + (provider_result(county='奉贤区', county_code='156310120', province_code='156310000'), 422), + (provider_result(county='定远县', county_code='156341125'), 422), +]) +def test_reverse_lookup_distinguishes_no_data_bad_response_and_out_of_scope(data, status): + with pytest.raises(PlaceSearchError) as exc: + lookup(data) + assert exc.value.status_code == status + + +@pytest.mark.parametrize('failure,status', [(httpx.ReadTimeout('private-url'), 504), (httpx.ConnectError('private-url'), 502)]) +def test_reverse_lookup_sanitizes_transport_failures(failure, status): + with pytest.raises(PlaceSearchError) as exc: + lookup(failure=failure) + assert exc.value.status_code == status and 'private-url' not in exc.value.detail + + +def test_reverse_lookup_redacts_provider_key_from_http_logs(caplog): + caplog.set_level(logging.INFO, logger='httpx') + lookup() + messages = [record.getMessage() for record in caplog.records if record.name == 'httpx'] + assert messages and any('tk=[redacted]' in message for message in messages) + assert all('test-only-token' not in message for message in messages) + + +@pytest.mark.parametrize('body', [ + {'longitude': 181, 'latitude': 32}, {'longitude': 117, 'latitude': -91}, + {'longitude': float('nan'), 'latitude': 32}, {'longitude': 117, 'latitude': float('inf')}, + {'longitude': 117, 'latitude': 32, 'region': '全国'}, +]) +def test_reverse_lookup_rejects_invalid_coordinates_and_scope_overrides(body): + with pytest.raises(ValidationError): + ReverseGeocodingRequest(**body) + + +def test_reverse_lookup_requires_configuration_and_shared_key(monkeypatch): + with pytest.raises(PlaceSearchError) as exc: + lookup(config=CONFIG.model_copy(update={'enabled': False})) + assert exc.value.status_code == 503 + monkeypatch.setattr(settings, 'TIANDITU_TOKEN', '') + with pytest.raises(PlaceSearchError) as exc: + lookup() + assert exc.value.status_code == 503 diff --git a/tests/unit/test_scada_repository.py b/tests/unit/test_scada_repository.py index 34694b3..24a8119 100644 --- a/tests/unit/test_scada_repository.py +++ b/tests/unit/test_scada_repository.py @@ -49,6 +49,32 @@ class _FakeConnection: return self.cursor_instance +class _ReadingCursor: + def __init__(self, rows): + self.rows = rows + self.calls: list[tuple[str, tuple]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def execute(self, query, params): + self.calls.append((str(query), params)) + + async def fetchall(self): + return self.rows + + +class _ReadingConnection: + def __init__(self, rows): + self.cursor_instance = _ReadingCursor(rows) + + def cursor(self): + return self.cursor_instance + + def test_update_scada_field_inserts_when_update_hits_no_rows(): ScadaRepository = _load_scada_repository() conn = _FakeConnection(initial_rowcount=0) @@ -114,3 +140,35 @@ def test_update_scada_field_batch_uses_one_set_based_statement(): ["170490", "170491"], [26.5, 27.0], ) + + +def test_get_latest_scada_readings_uses_canonical_ids_and_one_bounded_query(): + ScadaRepository = _load_scada_repository() + observed_at = datetime(2026, 10, 22, 1, 2, tzinfo=timezone.utc) + conn = _ReadingConnection([ + { + "time": observed_at, + "device_id": "D-1", + "monitored_value": 0.31, + "cleaned_value": 0.3, + } + ]) + + result = asyncio.run( + ScadaRepository.get_latest_scada_readings(conn, ["D-1", "D-2"]) + ) + + assert result["D-1"]["time"] == observed_at + assert len(conn.cursor_instance.calls) == 1 + query, params = conn.cursor_instance.calls[0] + assert "DISTINCT ON (device_id)" in query + assert "ORDER BY device_id, time DESC" in query + assert params == (["D-1", "D-2"],) + + +def test_get_latest_scada_readings_skips_database_for_empty_page(): + ScadaRepository = _load_scada_repository() + conn = _ReadingConnection([]) + + assert asyncio.run(ScadaRepository.get_latest_scada_readings(conn, [])) == {} + assert conn.cursor_instance.calls == [] diff --git a/tests/unit/test_workbench_event_order.py b/tests/unit/test_workbench_event_order.py index ed7a35b..442fa85 100644 --- a/tests/unit/test_workbench_event_order.py +++ b/tests/unit/test_workbench_event_order.py @@ -19,8 +19,8 @@ def anyio_backend(): @pytest.mark.anyio @pytest.mark.parametrize("repository", [OperationalTaskRepository, TaskAlertRepository]) -@pytest.mark.parametrize("database", ["tjwater_v2", "other_project"]) -async def test_event_order_lock_precedes_business_writes_and_is_v2_only(repository, database): +@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "other_project"]) +async def test_event_order_lock_precedes_business_writes_in_each_project_database(repository, database): @asynccontextmanager async def transaction(): yield @@ -29,21 +29,15 @@ async def test_event_order_lock_precedes_business_writes_and_is_v2_only(reposito info=SimpleNamespace(dbname=database), execute=AsyncMock(), transaction=transaction, ) async with repository(connection, uuid4()).transaction(): - if database == "tjwater_v2": - connection.execute.assert_awaited_once_with( - "SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,), - ) - else: - connection.execute.assert_not_awaited() + connection.execute.assert_awaited_once_with( + "SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,), + ) -def test_sync_producers_use_same_database_scoped_lock(): - connection = SimpleNamespace(info=SimpleNamespace(dbname="tjwater_v2"), execute=Mock()) +@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "other_project"]) +def test_sync_producers_use_same_database_scoped_lock(database): + connection = SimpleNamespace(info=SimpleNamespace(dbname=database), execute=Mock()) lock_event_transaction_sync(connection) connection.execute.assert_called_once_with( "SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,), ) - connection.info.dbname = "other_project" - connection.execute.reset_mock() - lock_event_transaction_sync(connection) - connection.execute.assert_not_called() diff --git a/tests/unit/test_worker_project_scopes.py b/tests/unit/test_worker_project_scopes.py new file mode 100644 index 0000000..f066711 --- /dev/null +++ b/tests/unit/test_worker_project_scopes.py @@ -0,0 +1,101 @@ +from dataclasses import replace +from uuid import uuid4 + +import httpx +import pytest + +from scripts import run_operational_worker as worker +from scripts import sync_peripheral_work_orders as sync + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +def settings(): + projects = (str(uuid4()), str(uuid4())) + return worker.WorkerSettings("http://server.test", "http://agent.test", projects[0], + bearer="TEST_ONLY", project_ids=projects) + + +def test_explicit_project_list_overrides_single_project_and_deduplicates(monkeypatch, settings): + monkeypatch.setattr(worker, "load_dotenv", lambda *args, **kwargs: None) + monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_IDS", ", ".join(settings.project_ids + settings.project_ids[:1])) + monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_ID", "ignored-legacy-value") + monkeypatch.setenv("OPERATIONAL_WORKER_BEARER", "TEST_ONLY") + parsed = worker.WorkerSettings.from_environment() + assert parsed.project_ids == settings.project_ids + assert tuple(scope.project_id for scope in parsed.project_scopes()) == settings.project_ids + assert all(not scope.project_ids for scope in parsed.project_scopes()) + monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_IDS", settings.project_ids[0] + ",invalid") + with pytest.raises(ValueError): + worker.WorkerSettings.from_environment() + + +def test_existing_single_project_configuration_remains_supported(monkeypatch, settings): + monkeypatch.setattr(worker, "load_dotenv", lambda *args, **kwargs: None) + monkeypatch.delenv("OPERATIONAL_WORKER_PROJECT_IDS", raising=False) + monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_ID", settings.project_id) + monkeypatch.setenv("OPERATIONAL_WORKER_BEARER", "TEST_ONLY") + assert worker.WorkerSettings.from_environment().project_scopes()[0].project_id == settings.project_id + assert replace(settings, project_ids=()).project_scopes() == (replace(settings, project_ids=()),) + + +@pytest.mark.anyio +async def test_multi_project_worker_reconciles_other_projects_when_one_fails(monkeypatch, settings): + seen = [] + + class FakeWorker: + def __init__(self, scope, client): + self.scope = scope + + async def tick(self): + seen.append(self.scope.project_id) + if self.scope.project_id == settings.project_ids[0]: + raise httpx.ConnectError("test failure") + return 0 + + monkeypatch.setattr(worker.WorkerSettings, "from_environment", lambda: settings) + monkeypatch.setattr(worker, "OperationalWorker", FakeWorker) + with pytest.raises(SystemExit) as result: + await worker.run(once=True) + assert result.value.code == 1 + assert tuple(seen) == settings.project_ids + + +@pytest.mark.anyio +@pytest.mark.parametrize("once", [True, False]) +async def test_sync_entrypoint_keeps_project_headers_and_cursors_separate(monkeypatch, settings, once): + seen = [] + + async def check_scope(client, scope, url, credentials): + seen.append((scope.project_id, (await credentials.headers(client))["X-Project-Id"])) + return 0 + + class FakeSynchronizer: + def __init__(self, client, scope, url, credentials): + self.args = client, scope, url, credentials + + async def serve(self, interval): + return await check_scope(*self.args) + + monkeypatch.setattr(worker.WorkerSettings, "from_environment", lambda: settings) + monkeypatch.setenv("OPERATIONAL_ACTION_PERIPHERAL_URL", "http://peripheral.test") + monkeypatch.setattr(sync, "sync_once", check_scope) + monkeypatch.setattr(sync, "PeripheralWorkOrderSynchronizer", FakeSynchronizer) + await sync.run(once=once) + assert seen == [(project, project) for project in settings.project_ids] + + +@pytest.mark.anyio +async def test_project_workers_have_independent_tokens_sessions_and_message_cursors(settings): + async with httpx.AsyncClient() as client: + first, second = [worker.OperationalWorker(scope, client) for scope in settings.project_scopes()] + first.credentials._token = "FIRST_TEST_TOKEN" + first._agent_sessions["run-1"] = "session-1" + first._message_cursor = "cursor-1" + assert second.credentials._token == "" + assert second._agent_sessions == {} + assert second._message_cursor is None