From be6317eb4832a9270d7ac55c299ca5420cc0de6b Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 19 Oct 2026 18:46:58 +0800 Subject: [PATCH] feat(server): integrate MCP queries and investigation messaging Add project-bound OAuth MCP queries and controlled analysis with scoped evidence, limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs. Default nearby searches to 200 meters and five results. Integrate investigation messaging/presentation, health/readiness and typed history; remove duplicate web search and fix online simulation and compressed baseline writes. Include project migrations 023-025; keep local credentials and demo seeding excluded. Validation: 319 focused pytest checks; real PostGIS temporary-table regression; read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls. --- .env.example | 35 +- README.md | 21 + app/api/health.py | 58 + app/api/v1/endpoints/agent_messages.py | 101 + app/api/v1/endpoints/mcp_queries.py | 60 + app/api/v1/endpoints/operational_tasks.py | 71 +- app/api/v1/endpoints/place_search.py | 19 + app/api/v1/endpoints/simulation.py | 25 +- app/api/v1/endpoints/timeseries/composite.py | 3 +- app/api/v1/endpoints/web_search.py | 29 - app/api/v1/endpoints/workbench_runtime.py | 68 +- app/api/v1/router.py | 18 +- app/auth/keycloak_dependencies.py | 10 + app/core/config.py | 21 +- app/core/tianditu_logging.py | 17 + app/domain/schemas/agent_messages.py | 59 + app/domain/schemas/operational_data.py | 41 +- app/domain/schemas/operational_task.py | 124 +- app/domain/schemas/place_search.py | 28 + app/domain/schemas/timeseries_history.py | 1 + app/domain/schemas/workbench_events.py | 2 + app/infra/audit/middleware.py | 3 + app/infra/db/postgresql/agent_messages.py | 88 + app/infra/db/postgresql/analysis.py | 3 +- app/infra/db/postgresql/mcp_queries.py | 91 + app/infra/db/postgresql/operational_tasks.py | 125 +- app/infra/db/postgresql/read_queries.py | 406 +++ app/infra/db/postgresql/scada.py | 6 +- app/infra/db/postgresql/spatial_queries.py | 71 + .../db/timescaledb/repositories/analysis.py | 5 +- .../db/timescaledb/repositories/realtime.py | 35 +- .../db/timescaledb/repositories/scada.py | 6 +- app/main.py | 29 +- app/mcp/__init__.py | 0 app/mcp/analysis_models.py | 139 + app/mcp/analysis_service.py | 69 + app/mcp/application.py | 505 +++ app/mcp/auth.py | 169 + app/mcp/catalog.py | 221 ++ app/mcp/config.py | 76 + app/mcp/contexts.py | 112 + app/mcp/dispatch.py | 15 + app/mcp/models.py | 293 ++ app/mcp/peripheral.py | 132 + app/mcp/query_service.py | 492 +++ app/mcp/scenario_service.py | 70 + app/mcp/scope.py | 76 + app/mcp/work_order_skills.py | 40 + app/native/wndb/inp/exporter.py | 4 +- app/native/wndb/model/status.py | 6 +- app/services/agent_messages.py | 186 + app/services/burst_location.py | 2 + app/services/geocoding.py | 9 +- app/services/operational_presentation.py | 99 + app/services/operational_tasks.py | 219 +- app/services/place_search.py | 115 + app/services/sensor_placement.py | 12 + app/services/simulation.py | 76 +- app/services/simulation_scenarios.py | 28 +- app/services/timeseries_history.py | 67 +- app/services/web_search.py | 93 - app/services/workbench_runtime.py | 109 + contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 2984 +++++++++++++++-- ...investigation-progress-and-presentation.md | 116 + docs/investigation-report-contract.md | 4 + docs/mcp-analysis-tools.md | 71 + docs/mcp-keycloak-setup.md | 88 + docs/mcp-query-design.md | 280 ++ docs/mcp-query-implementation.md | 108 + docs/operational-task-workers.md | 67 +- docs/operational-tasks.md | 55 +- .../tjwater-operational-worker.service | 15 + requirements.txt | 13 +- .../sql/023_operational_task_progress.sql | 42 + resources/sql/024_agent_messages.sql | 42 + resources/sql/025_mcp_query_contexts.sql | 27 + .../appointment-follow-up/SKILL.md | 16 + .../work-order-skills/burst-report/SKILL.md | 26 + .../water-quality-complaint/SKILL.md | 26 + samples/peripheral/store.py | 12 +- scripts/run_operational_worker.py | 102 +- scripts/sync_peripheral_work_orders.py | 55 +- tests/api/test_agent_message_routes.py | 41 + tests/api/test_health_endpoints.py | 150 + tests/api/test_history_query_permissions.py | 34 + tests/api/test_openapi_contract.py | 4 +- .../api/test_operational_task_dynamic_flow.py | 25 + tests/api/test_place_search_endpoints.py | 65 + tests/api/test_simulation_endpoints.py | 23 +- tests/auth/test_keycloak_dependencies.py | 18 + tests/fixtures/mcp_query_app.py | 101 + .../test_mcp_query_limits_postgres.py | 216 ++ tests/integration/test_mcp_spatial_postgis.py | 58 + .../test_operational_tasks_storage.py | 2 + .../integration/test_realtime_compression.py | 101 + tests/unit/test_agent_messages.py | 240 ++ tests/unit/test_analysis_simulation.py | 37 +- tests/unit/test_epanet_status_export.py | 70 + tests/unit/test_geocoding.py | 10 +- tests/unit/test_mcp_analysis.py | 479 +++ tests/unit/test_mcp_config.py | 53 + tests/unit/test_mcp_query.py | 900 +++++ tests/unit/test_mcp_query_limits.py | 224 ++ tests/unit/test_mcp_spatial.py | 94 + tests/unit/test_mcp_work_order_skills.py | 73 + tests/unit/test_online_simulation.py | 65 + tests/unit/test_operational_data.py | 36 +- tests/unit/test_operational_presentation.py | 56 + ...test_operational_task_storage_readiness.py | 58 + tests/unit/test_operational_tasks.py | 320 ++ tests/unit/test_operational_worker.py | 135 + tests/unit/test_peripheral_task_sync.py | 75 +- .../test_peripheral_work_order_activity.py | 21 + tests/unit/test_place_search.py | 140 + tests/unit/test_realtime_repository.py | 38 + tests/unit/test_web_search.py | 115 - 117 files changed, 12703 insertions(+), 638 deletions(-) create mode 100644 app/api/health.py create mode 100644 app/api/v1/endpoints/agent_messages.py create mode 100644 app/api/v1/endpoints/mcp_queries.py create mode 100644 app/api/v1/endpoints/place_search.py delete mode 100644 app/api/v1/endpoints/web_search.py create mode 100644 app/core/tianditu_logging.py create mode 100644 app/domain/schemas/agent_messages.py create mode 100644 app/domain/schemas/place_search.py create mode 100644 app/infra/db/postgresql/agent_messages.py create mode 100644 app/infra/db/postgresql/mcp_queries.py create mode 100644 app/infra/db/postgresql/read_queries.py create mode 100644 app/infra/db/postgresql/spatial_queries.py create mode 100644 app/mcp/__init__.py create mode 100644 app/mcp/analysis_models.py create mode 100644 app/mcp/analysis_service.py create mode 100644 app/mcp/application.py create mode 100644 app/mcp/auth.py create mode 100644 app/mcp/catalog.py create mode 100644 app/mcp/config.py create mode 100644 app/mcp/contexts.py create mode 100644 app/mcp/dispatch.py create mode 100644 app/mcp/models.py create mode 100644 app/mcp/peripheral.py create mode 100644 app/mcp/query_service.py create mode 100644 app/mcp/scenario_service.py create mode 100644 app/mcp/scope.py create mode 100644 app/mcp/work_order_skills.py create mode 100644 app/services/agent_messages.py create mode 100644 app/services/operational_presentation.py create mode 100644 app/services/place_search.py delete mode 100644 app/services/web_search.py create mode 100644 docs/investigation-progress-and-presentation.md create mode 100644 docs/mcp-analysis-tools.md create mode 100644 docs/mcp-keycloak-setup.md create mode 100644 docs/mcp-query-design.md create mode 100644 docs/mcp-query-implementation.md create mode 100644 infra/systemd/tjwater-operational-worker.service create mode 100644 resources/sql/023_operational_task_progress.sql create mode 100644 resources/sql/024_agent_messages.sql create mode 100644 resources/sql/025_mcp_query_contexts.sql create mode 100644 resources/work-order-skills/appointment-follow-up/SKILL.md create mode 100644 resources/work-order-skills/burst-report/SKILL.md create mode 100644 resources/work-order-skills/water-quality-complaint/SKILL.md create mode 100644 tests/api/test_agent_message_routes.py create mode 100644 tests/api/test_health_endpoints.py create mode 100644 tests/api/test_history_query_permissions.py create mode 100644 tests/api/test_place_search_endpoints.py create mode 100644 tests/fixtures/mcp_query_app.py create mode 100644 tests/integration/test_mcp_query_limits_postgres.py create mode 100644 tests/integration/test_mcp_spatial_postgis.py create mode 100644 tests/integration/test_realtime_compression.py create mode 100644 tests/unit/test_agent_messages.py create mode 100644 tests/unit/test_epanet_status_export.py create mode 100644 tests/unit/test_mcp_analysis.py create mode 100644 tests/unit/test_mcp_config.py create mode 100644 tests/unit/test_mcp_query.py create mode 100644 tests/unit/test_mcp_query_limits.py create mode 100644 tests/unit/test_mcp_spatial.py create mode 100644 tests/unit/test_mcp_work_order_skills.py create mode 100644 tests/unit/test_online_simulation.py create mode 100644 tests/unit/test_operational_presentation.py create mode 100644 tests/unit/test_operational_task_storage_readiness.py create mode 100644 tests/unit/test_place_search.py delete mode 100644 tests/unit/test_web_search.py diff --git a/.env.example b/.env.example index 8ca1641..090efeb 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,8 @@ TIMESCALEDB_DB_HOST="localhost" TIMESCALEDB_DB_PORT="5433" TIMESCALEDB_DB_USER="tjwater" TIMESCALEDB_DB_PASSWORD="password" +# 实时快照替换事务的解压预算;仅事务内生效,禁止0(无限制)。 +TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES=1000000 # ============================================ # 元数据数据库配置 (Metadata DB) @@ -96,15 +98,30 @@ OPERATIONAL_DATA_DELEGATION_BEARER="" # ============================================ -# Bocha Web Search API +# Tianditu shared API key and service URLs # ============================================ -BOCHA_API_KEY="sk-your-bocha-api-key" -BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search" -BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30 - -# ============================================ -# Tianditu Geocoding API -# ============================================ -TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token" +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= +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_QUERY_TIMEOUT_SECONDS=30 +# 计算等待上限,默认1小时;管理员可配置120至86400秒,实际上限通过 tools/list 发布。 +TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600 diff --git a/README.md b/README.md index 4fcc705..a747c47 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,13 @@ conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload conda activate server ``` +`GET /health` 是无需鉴权的 HTTP 服务存活检查,返回 `200` 和 `{"status":"ok"}`,不查询数据库。 +`GET /ready` 检查应用启动状态、元数据库 `SELECT 1` 及已启用的 MCP 传输生命周期; +全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为2秒。MCP 关闭时标记为 +`disabled`,不阻止就绪。返回结果不包含数据库地址、凭据或原始异常,禁止缓存。 +就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过 +带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。 + ## 常用命令 外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。 @@ -76,6 +83,16 @@ docker compose -f infra/docker/docker-compose.yml config - `iot_data` DSN 用于 TimescaleDB,始终使用元数据配置的完整 DSN,不再从项目代码推导数据库名。 - 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。 +在线重算会原子替换一个时刻的节点、管段基线。历史分块已经压缩时,写入事务使用 +`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认100万条;配置必须 +为正整数,不允许0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。 +预算按需根据受影响压缩批次的规模调整,超过预算仍会失败并回滚当前快照;此前完成的其他 +时刻不会一起回滚。同步、异步及单表批量快照入口使用同一预算。 + +压缩写入回归位于 `tests/integration/test_realtime_compression.py`;仅在设置 +`REALTIME_COMPRESSION_TEST_DSN` 时执行,并且只接受库名以 `tjw_test_realtime_` 开头的 +独立可丢弃数据库。测试会创建和删除该库内的 `realtime` schema,禁止指向项目数据库。 + 完整新建供水项目使用 `POST /api/v1/admin/project-provisions`,以 `multipart/form-data` 同时提交 `name`、小写 `code`、可选的 `description`、`gs_workspace`、`map_zoom` 和 INP `file`。工作流会按顺序完成: @@ -119,3 +136,7 @@ Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发 ## 安全规则 不要提交 `.env`、客户数据、数据库 dump、日志、生成缓存、`db_inp/`、`temp/`、`data/` 或本地密钥。CI/CD 凭据应放在 Gitea secrets 和仓库变量中。 + +## 产品 MCP 查询 + +Server 可选启用 MCP 2026-07-28 的25项项目查询及10项受控分析,默认关闭。它使用专用 OAuth resource Bearer、宿主绑定上下文及现有项目/任务权限,调查查询返回前持久化证据。启用需更新依赖、应用项目 SQL 迁移和配置 Keycloak token exchange;这些生产操作尚未执行。详见 [实施与启用](docs/mcp-query-implementation.md) 和 [协议/旧 CLI 能力对照](docs/mcp-query-design.md)。 diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..24b8a59 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,58 @@ +"""Public process probes, separate from authenticated project diagnostics.""" + +import asyncio + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from sqlalchemy import text + +from app.infra.db.metadb.database import engine + + +router = APIRouter(include_in_schema=False) +METADATA_TIMEOUT_SECONDS = 2 + + +async def metadata_database_ready() -> bool: + try: + async with asyncio.timeout(METADATA_TIMEOUT_SECONDS): + async with engine.connect() as connection: + result = await connection.execute(text("SELECT 1")) + return result.scalar_one() == 1 + except Exception: + # Public probes must not expose connection strings or driver errors. + return False + + +@router.get("/health") +async def health() -> dict[str, str]: + """Check HTTP liveness without project or database dependencies.""" + return {"status": "ok"} + + +@router.get("/ready") +async def readiness(request: Request) -> JSONResponse: + """Check startup, metadata connectivity and the enabled MCP lifecycle.""" + started = getattr(request.app.state, "startup_complete", False) + runtime = getattr(request.app.state, "mcp_runtime", None) + mcp_ready = runtime is None or runtime.ready + metadata_ready = await metadata_database_ready() if started else False + ready = started and metadata_ready and mcp_ready + return JSONResponse( + status_code=200 if ready else 503, + content={ + "status": "ready" if ready else "not_ready", + "checks": { + "startup": "ready" if started else "not_ready", + "metadata_database": ( + "not_checked" if not started + else "ready" if metadata_ready else "unavailable" + ), + "mcp_transport": ( + "disabled" if runtime is None + else "ready" if mcp_ready else "not_ready" + ), + }, + }, + headers={"Cache-Control": "no-store"}, + ) diff --git a/app/api/v1/endpoints/agent_messages.py b/app/api/v1/endpoints/agent_messages.py new file mode 100644 index 0000000..46985fd --- /dev/null +++ b/app/api/v1/endpoints/agent_messages.py @@ -0,0 +1,101 @@ +"""Actor-private messaging APIs; the Agent verifies its session ownership.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +from psycopg import AsyncConnection + +from app.auth.permissions import OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, require_permission +from app.auth.project_dependencies import ProjectContext, get_project_pg_connection +from app.domain.domain_errors import DomainError +from app.domain.schemas.agent_messages import ( + AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply, +) +from app.infra.db.postgresql.agent_messages import AgentMessageRepository +from app.services.agent_messages import AgentMessageService + + +router = APIRouter(prefix="/agent-messages") + + +def _service(ctx: ProjectContext, conn: AsyncConnection) -> AgentMessageService: + return AgentMessageService(AgentMessageRepository(conn, ctx.project_id), ctx) + + +def _raise_domain(exc: DomainError): + raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc + + +@router.post("", response_model=AgentMessage, status_code=201) +async def create_agent_message( + command: AgentMessageCreate, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).create(command) + except DomainError as exc: + _raise_domain(exc) + + +@router.get("", response_model=AgentMessagePage) +async def list_agent_messages( + session_id: str = Query(min_length=1, max_length=500), + limit: int = Query(default=50, ge=1, le=100), + cursor: str | None = None, + pending_only: bool = False, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).list_session(session_id, limit, cursor, pending_only) + except DomainError as exc: + _raise_domain(exc) + + +@router.get("/pending", response_model=AgentMessagePage) +async def list_pending_agent_messages( + limit: int = Query(default=50, ge=1, le=100), + cursor: str | None = None, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).pending(limit, cursor) + except DomainError as exc: + _raise_domain(exc) + + +@router.get("/{message_id}", response_model=AgentMessage) +async def get_agent_message( + message_id: UUID, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).get(message_id) + except DomainError as exc: + _raise_domain(exc) + + +@router.post("/{message_id}/reply", response_model=AgentMessage, status_code=201) +async def reply_agent_message( + message_id: UUID, command: AgentMessageReply, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).reply(message_id, command) + except DomainError as exc: + _raise_domain(exc) + + +@router.post("/{message_id}/delivery", response_model=AgentMessage) +async def record_agent_message_delivery( + message_id: UUID, command: AgentMessageDelivery, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).delivery(message_id, command) + except DomainError as exc: + _raise_domain(exc) diff --git a/app/api/v1/endpoints/mcp_queries.py b/app/api/v1/endpoints/mcp_queries.py new file mode 100644 index 0000000..8703b08 --- /dev/null +++ b/app/api/v1/endpoints/mcp_queries.py @@ -0,0 +1,60 @@ +"""Host-only MCP bindings: declared in REST contracts even when MCP is disabled.""" + +from fastapi import APIRouter, Header, HTTPException, Request, Response +from psycopg.errors import UndefinedTable, InvalidSchemaName +from app.mcp.models import BindInput, BindResponse +from app.infra.db.postgresql.mcp_queries import McpQueryRepository + +router = APIRouter(tags=["MCP Query Context"]) + + +def runtime_for(request: Request): + runtime = getattr(request.app.state, "mcp_runtime", None) + if runtime is None: + raise HTTPException(503, "Product MCP is disabled") + return runtime + + +@router.post("/mcp-query-contexts", response_model=BindResponse) +async def create_context( + command: BindInput, + request: Request, + x_project_id: str = Header(alias="X-Project-Id"), +) -> BindResponse: + runtime = runtime_for(request) + principal = await runtime.identity(request) + from app.mcp.contexts import bind_context + + try: + async with runtime.scope_factory(principal, x_project_id) as scope: + binding = await bind_context(scope, principal, command, runtime.settings) + except (UndefinedTable, InvalidSchemaName): + raise HTTPException( + 503, "MCP project storage has not been provisioned" + ) from None + return BindResponse( + context_handle=binding.context_handle, + expires_at=binding.expires_at, + task_id=binding.task_id, + run_id=binding.run_id, + ) + + +@router.delete("/mcp-query-contexts/{context_handle}", status_code=204) +async def delete_context( + context_handle: str, + request: Request, + x_project_id: str = Header(alias="X-Project-Id"), +) -> Response: + runtime = runtime_for(request) + principal = await runtime.identity(request) + try: + async with runtime.scope_factory(principal, x_project_id) as scope: + await McpQueryRepository(scope.connection, scope.project.project_id).revoke( + context_handle, principal.subject, principal.client_id + ) + except (UndefinedTable, InvalidSchemaName): + raise HTTPException( + 503, "MCP project storage has not been provisioned" + ) from None + return Response(status_code=204) diff --git a/app/api/v1/endpoints/operational_tasks.py b/app/api/v1/endpoints/operational_tasks.py index ae552ea..f992b1d 100644 --- a/app/api/v1/endpoints/operational_tasks.py +++ b/app/api/v1/endpoints/operational_tasks.py @@ -24,6 +24,7 @@ from app.auth.project_dependencies import ( get_project_pg_connection, ) from app.domain.domain_errors import DomainError +from app.domain.schemas.workbench_runtime import WorkbenchDateTime from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot from app.domain.schemas.operational_task import ( ActionDecisionCommand, @@ -70,6 +71,9 @@ from app.domain.schemas.operational_task import ( TaskStatus, TaskDataQueryResult, TaskType, + TaskPresentationEventCommand, + TaskPresentationEventResult, + TaskPresentationCursor, ) from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository from app.infra.integrations.operational_actions import ( @@ -92,10 +96,9 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi def _task_permissions(ctx: ProjectContext) -> set[str]: - permissions = set(permissions_for_context(ctx)) - if ctx.system_role == "service": - permissions.difference_update({SCADA_VIEW, SIMULATION_VIEW}) - return permissions + # Investigations use preconfigured project access, including service accounts. + # Do not force an already-authorized reader through legacy per-task delegation. + return set(permissions_for_context(ctx)) def _raise_domain(exc: DomainError): @@ -176,6 +179,8 @@ async def list_operational_task_runs( run_status: Literal[ "queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled" ] | None = Query(default=None, alias="status"), + task_type: TaskType | None = None, + task_created_from: WorkbenchDateTime | None = None, limit: int = Query(default=50, ge=1, le=100), cursor: str | None = None, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), @@ -193,6 +198,8 @@ async def list_operational_task_runs( try: items, next_cursor = await _service(ctx, conn).list_runs( status=run_status, + task_type=task_type, + task_created_from=task_created_from, limit=limit, cursor_created_at=cursor_created_at, cursor_run_id=cursor_run_id, @@ -202,6 +209,32 @@ async def list_operational_task_runs( _raise_domain(exc) +@router.get("/operational-tasks/{task_id}/runs", response_model=OperationalTaskRunPage) +async def list_task_run_history( + task_id: UUID, + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return OperationalTaskRunPage(items=await _service(ctx, conn).task_runs(task_id, limit, offset)) + except DomainError as exc: + _raise_domain(exc) + + +@router.get("/operational-tasks/{task_id}/runs/{run_id}", response_model=OperationalTaskRun) +async def get_task_run( + task_id: UUID, run_id: UUID, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).task_run(task_id, run_id) + except DomainError as exc: + _raise_domain(exc) + + @router.post( "/operational-tasks/schedules", response_model=OperationalTaskSchedule, @@ -272,7 +305,10 @@ async def recover_operational_task_leases( conn: AsyncConnection = Depends(get_project_pg_connection), ): try: - return await _service(ctx, conn).recover_expired_leases(command.limit) + return await _service(ctx, conn).recover_expired_leases( + command.limit, task_type=command.task_type, + task_created_from=command.task_created_from, + ) except DomainError as exc: _raise_domain(exc) @@ -473,6 +509,31 @@ async def publish_operational_task_artifacts( _raise_domain(exc) +@router.get("/operational-tasks/{task_id}/presentation-cursor", response_model=TaskPresentationCursor) +async def get_operational_task_presentation_cursor( + task_id: UUID, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).presentation_cursor(task_id) + except DomainError as exc: + _raise_domain(exc) + + +@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult) +async def publish_operational_task_presentation_event( + task_id: UUID, + command: TaskPresentationEventCommand, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).publish_presentation_event(task_id, command) + except DomainError as exc: + _raise_domain(exc) + + @router.post( "/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult ) diff --git a/app/api/v1/endpoints/place_search.py b/app/api/v1/endpoints/place_search.py new file mode 100644 index 0000000..fc897a9 --- /dev/null +++ b/app/api/v1/endpoints/place_search.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, HTTPException + +from app.domain.schemas.place_search import PlaceSearchRequest, PlaceSearchResponse +from app.services.place_search import PlaceSearchError, search_tianditu_places + +router = APIRouter() + + +@router.post( + "/place-search-requests", + response_model=PlaceSearchResponse, + summary="Search Fengxian places", + description="调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。", +) +async def tianditu_place_search(request: PlaceSearchRequest) -> PlaceSearchResponse: + try: + return await search_tianditu_places(request) + except PlaceSearchError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc diff --git a/app/api/v1/endpoints/simulation.py b/app/api/v1/endpoints/simulation.py index 59ce57e..d9a0987 100644 --- a/app/api/v1/endpoints/simulation.py +++ b/app/api/v1/endpoints/simulation.py @@ -1,5 +1,5 @@ from typing import Any, List, Literal, Optional -from datetime import datetime, timedelta +from datetime import datetime from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi.responses import PlainTextResponse from app.auth.keycloak_dependencies import get_current_keycloak_username @@ -18,7 +18,6 @@ from app.services.simulation_scenarios import ( from app.services.valve_isolation import analyze_valve_isolation from app.domain.time import ( parse_aware_time, - parse_clock_duration_seconds, parse_utc_time, ) from pydantic import BaseModel, Field, field_validator @@ -50,27 +49,9 @@ class PressureRegulation(BaseModel): def run_simulation_manually_by_date( network_name: str, start_time: datetime, duration: int ) -> None: - end_datetime = start_time + timedelta(minutes=duration) - time_properties = simulation.get_time(network_name) - hydraulic_step_seconds = parse_clock_duration_seconds( - time_properties["HYDRAULIC TIMESTEP"], - field_name="HYDRAULIC TIMESTEP", + simulation.run_online_simulation( + name=network_name, start_time=start_time, duration_seconds=duration * 60, ) - if hydraulic_step_seconds <= 0: - raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.") - hydraulic_step = timedelta(seconds=hydraulic_step_seconds) - scada_mappings = simulation.query_corresponding_element_id_and_query_id( - network_name - ) - current_time = start_time - while current_time < end_datetime: - simulation.run_simulation( - name=network_name, - simulation_type="realtime", - modify_pattern_start_time=current_time.isoformat(timespec="seconds"), - scada_mappings=scada_mappings, - ) - current_time += hydraulic_step # 必须用这个PlainTextResponse,不然每个key都有引号 diff --git a/app/api/v1/endpoints/timeseries/composite.py b/app/api/v1/endpoints/timeseries/composite.py index 6259246..e9f4ebe 100644 --- a/app/api/v1/endpoints/timeseries/composite.py +++ b/app/api/v1/endpoints/timeseries/composite.py @@ -11,9 +11,10 @@ from app.services.timeseries_history import TimeseriesHistoryService from .dependencies import get_timescale_connection, get_postgres_connection router = APIRouter() +history_router = APIRouter() -@router.post( +@history_router.post( "/timeseries/views/element-history/query", summary="批量查询管网元素历史数据", response_model=ElementHistoryResponse, diff --git a/app/api/v1/endpoints/web_search.py b/app/api/v1/endpoints/web_search.py deleted file mode 100644 index 29f05e1..0000000 --- a/app/api/v1/endpoints/web_search.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Any - -from fastapi import APIRouter, HTTPException, status - -from app.services.web_search import ( - BochaSearchAPIError, - BochaSearchConfigError, - WebSearchRequest, - search_bocha_web, -) - -router = APIRouter() - - -@router.post( - "/web-searches", - summary="Web Search", - description="调用 Bocha Web Search API 获取实时网页搜索结果", -) -async def web_search(request: WebSearchRequest) -> dict[str, Any]: - try: - return await search_bocha_web(request) - except BochaSearchConfigError as exc: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=str(exc), - ) from exc - except BochaSearchAPIError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc diff --git a/app/api/v1/endpoints/workbench_runtime.py b/app/api/v1/endpoints/workbench_runtime.py index ce82524..2d8bd66 100644 --- a/app/api/v1/endpoints/workbench_runtime.py +++ b/app/api/v1/endpoints/workbench_runtime.py @@ -129,72 +129,8 @@ async def workbench_runtime( ctx: ProjectContext = Depends(get_project_context), conn: AsyncConnection = Depends(get_project_pg_connection), ): - analysis_runs: list[dict] | None = None - latest_results: list[dict] | None = None - latest_analysis_map_payload: dict | None = None - analysis_availability = "not_configured" - alert_storage_configured = False - alerts: list[dict] | None = None - schedule_availability = "not_configured" - alert_availability = "not_configured" - try: - async with _projection_transaction(conn): - analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100) - if analysis_runs: - latest_results = await AnalysisRepository.list_recent_results( - conn, analysis_runs[0]["run_id"], limit=100 - ) - analysis_availability = "ready" if analysis_runs else "not_configured" - except Exception: - logger.warning("Workbench analysis projection is unavailable", exc_info=True) - analysis_runs = None - latest_results = None - analysis_availability = "degraded" - if analysis_runs and latest_results: - try: - async with _projection_transaction(conn): - latest_analysis_map_payload = await _sensor_location_map_payload( - conn, latest_results - ) - except Exception: - logger.warning( - "Workbench analysis spatial references are unavailable", - exc_info=True, - ) - try: - async with _projection_transaction(conn): - operations_availability = await WorkbenchOperationsRepository.availability(conn) - alert_storage_configured = operations_availability.alerts - except Exception: - logger.warning("Workbench operations storage probe is unavailable", exc_info=True) - schedule_availability = "degraded" - alert_availability = "degraded" - if alert_storage_configured: - try: - async with _projection_transaction(conn): - alert_documents = await WorkbenchOperationsRepository.list_alerts( - conn, str(ctx.project_id) - ) - alerts, invalid_alerts = _validate_documents( - alert_documents, WorkbenchPersistedAlert, "alert" - ) - alert_availability = "degraded" if invalid_alerts else "ready" - except Exception: - alerts = None - alert_availability = "degraded" - logger.warning("Workbench alert projection is unavailable", exc_info=True) - return await build_workbench_runtime_snapshot( - analysis_runs=analysis_runs, - latest_analysis_results=latest_results, - latest_analysis_map_payload=latest_analysis_map_payload, - alert_storage_configured=alert_storage_configured, - persisted_alerts=alerts, - scenario_availability=analysis_availability, - artifact_availability=analysis_availability, - schedule_availability=schedule_availability, - alert_availability=alert_availability, - projection_degraded=False, - ) + from app.services.workbench_runtime import read_workbench_runtime + return await read_workbench_runtime(ctx, conn) async def _sensor_location_map_payload( diff --git a/app/api/v1/router.py b/app/api/v1/router.py index cf4d009..7108288 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -4,6 +4,7 @@ from app.api.v1.endpoints import ( access, admin_metadata, agent_auth, + agent_messages, audit, burst_detection, burst_location, @@ -12,12 +13,12 @@ from app.api.v1.endpoints import ( meta, model_import, operational_tasks, + place_search, project, project_data, scada, sensor_placement, simulation, - web_search, workbench_runtime, workbench_events, ) @@ -103,6 +104,7 @@ api_router.include_router( ) api_router.include_router(access.router, tags=["Access Control"]) api_router.include_router(agent_auth.router, tags=["Agent Auth"]) +api_router.include_router(agent_messages.router, tags=["Agent Messages"]) api_router.include_router( admin_metadata.router, tags=["Metadata Admin"], @@ -159,13 +161,13 @@ api_router.include_router( dependencies=[optimization_run_access], ) api_router.include_router( - web_search.router, - tags=["Web Search"], + geocoding.router, + tags=["Geocoding"], dependencies=[webgis_view_access], ) api_router.include_router( - geocoding.router, - tags=["Geocoding"], + place_search.router, + tags=["Place Search"], dependencies=[webgis_view_access], ) api_router.include_router( @@ -195,6 +197,12 @@ for endpoint_router, tag in ( dependencies=[simulation_access], ) +api_router.include_router( + ts_composite.history_router, + tags=["TimescaleDB - Composite"], + dependencies=[Depends(require_permission(SCADA_VIEW))], +) + for endpoint_router, tag in ( (ts_scada.router, "TimescaleDB - SCADA"), (ts_composite.router, "TimescaleDB - Composite"), diff --git a/app/auth/keycloak_dependencies.py b/app/auth/keycloak_dependencies.py index 2ddf5e9..03c0a76 100644 --- a/app/auth/keycloak_dependencies.py +++ b/app/auth/keycloak_dependencies.py @@ -6,6 +6,9 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from app.core.config import settings +from app.mcp.config import McpSettings + +mcp_resource_settings = McpSettings() oauth2_optional = OAuth2PasswordBearer( tokenUrl="keycloak", auto_error=False @@ -29,6 +32,13 @@ def _decode_keycloak_token(token: str) -> dict: algorithms=[settings.KEYCLOAK_ALGORITHM], audience=settings.KEYCLOAK_AUDIENCE or None, ) + # A resource-bound MCP token must never authorize product REST APIs, + # including when another audience overlaps the configured API audience. + audience = payload.get("aud", []) + audiences = [audience] if isinstance(audience, str) else audience + if (isinstance(audiences, list) and mcp_resource_settings.audience in audiences + or payload.get("azp") == mcp_resource_settings.host_client_id): + raise JWTError("MCP resource tokens cannot authorize product REST APIs") if settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS <= 0: return payload diff --git a/app/core/config.py b/app/core/config.py index eb2a079..bbed419 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Optional from urllib.parse import quote_plus +from pydantic import AliasChoices, Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -25,6 +26,9 @@ class Settings(BaseSettings): TIMESCALEDB_DB_PORT: str = "5433" TIMESCALEDB_DB_USER: str = "postgres" TIMESCALEDB_DB_PASSWORD: str = "password" + TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES: int = Field( + default=1_000_000, ge=1, le=2_147_483_647, + ) # Metadata Database Config (PostgreSQL) METADATA_DB_NAME: str = "system_hub" METADATA_DB_HOST: str = "localhost" @@ -77,16 +81,19 @@ class Settings(BaseSettings): raise ValueError("CORS_ALLOWED_ORIGINS must list explicit origins") return origins - # Bocha Web Search API - BOCHA_API_KEY: str = "" - BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search" - BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0 - - # Tianditu Geocoding API - TIANDITU_GEOCODER_TOKEN: str = "" + # One shared Tianditu key for geocoding, place search and other services. + TIANDITU_TOKEN: str = Field( + "", + validation_alias=AliasChoices( + "TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN" + ), + ) TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder" TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0 + TIANDITU_SEARCH_URL: str = "https://api.tianditu.gov.cn/v2/search" + TIANDITU_SEARCH_TIMEOUT_SECONDS: float = 15.0 + @property def SQLALCHEMY_DATABASE_URI(self) -> str: db_password = quote_plus(self.DB_PASSWORD) diff --git a/app/core/tianditu_logging.py b/app/core/tianditu_logging.py new file mode 100644 index 0000000..8d935dd --- /dev/null +++ b/app/core/tianditu_logging.py @@ -0,0 +1,17 @@ +import logging +import re + + +class TiandituTokenFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + message = record.getMessage() + if "tianditu.gov.cn" in message: + record.msg = re.sub(r"([?&]tk=)[^&\s\"']+", r"\1[redacted]", message) + record.args = () + return True + + +def install_tianditu_token_filter() -> None: + logger = logging.getLogger("httpx") + if not any(isinstance(item, TiandituTokenFilter) for item in logger.filters): + logger.addFilter(TiandituTokenFilter()) diff --git a/app/domain/schemas/agent_messages.py b/app/domain/schemas/agent_messages.py new file mode 100644 index 0000000..6b890ab --- /dev/null +++ b/app/domain/schemas/agent_messages.py @@ -0,0 +1,59 @@ +"""Private, durable messages between an ordinary chat and one investigation run.""" +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import Field + +from app.domain.schemas.shared import NonEmptyStr, StrictModel +from app.domain.schemas.workbench_runtime import WorkbenchDateTime + + +MessageText = Annotated[str, Field(min_length=1, max_length=12000, pattern=r"\S")] +MessageStatus = Literal["pending", "enqueued", "delivered", "failed"] + + +class AgentMessageCreate(StrictModel): + request_id: UUID + task_id: UUID + run_id: UUID + source_session_id: NonEmptyStr + text: MessageText + + +class AgentMessageReply(StrictModel): + request_id: UUID + source_session_id: NonEmptyStr + text: MessageText + + +class AgentMessageDelivery(StrictModel): + status: Literal["enqueued", "delivered", "failed"] + target_session_id: NonEmptyStr | None = None + native_message_id: NonEmptyStr | None = None + failure_detail: Annotated[str, Field(max_length=500)] | None = None + + +class AgentMessage(StrictModel): + project_id: UUID + message_id: UUID + request_id: UUID + task_id: UUID + run_id: UUID + source_session_id: NonEmptyStr + source_owner_id: UUID + conversation_owner_id: UUID + target_session_id: NonEmptyStr | None = None + direction: Literal["to_task", "to_chat"] + in_reply_to: UUID | None = None + text: MessageText + status: MessageStatus + native_message_id: NonEmptyStr + failure_detail: str | None = None + created_at: WorkbenchDateTime + updated_at: WorkbenchDateTime + delivered_at: WorkbenchDateTime | None = None + + +class AgentMessagePage(StrictModel): + items: list[AgentMessage] + next_cursor: str | None = None diff --git a/app/domain/schemas/operational_data.py b/app/domain/schemas/operational_data.py index 7987ea0..ba6bde3 100644 --- a/app/domain/schemas/operational_data.py +++ b/app/domain/schemas/operational_data.py @@ -11,9 +11,48 @@ from app.domain.schemas.workbench_runtime import WorkbenchDateTime TaskDataSource = Literal[ "workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", + ] +McpSnapshotSource = Literal[ + 'run_simulation', + 'run_contaminant_simulation', + 'analyze_valve_closure', + 'analyze_flushing', + 'analyze_pressure_regulation', + 'optimize_sensor_placement', + 'run_burst_detection', + 'run_burst_location', + 'analyze_valve_isolation', + 'run_leakage_identification', + 'get_element_properties', + 'list_elements', + 'get_network_options', + 'get_network_schema', + 'list_scada_devices', + 'get_scada_device', + 'query_scada_readings', + 'query_model_baseline_series', + 'get_model_baseline_at_time', + 'query_model_baseline_field', + 'query_analysis_series', + 'query_analysis_values', + 'list_analysis_runs', + 'get_analysis_run', + 'list_analysis_results', + 'list_sensor_placement_runs', + 'get_sensor_placement_run', + 'query_element_history', + 'get_element_gis_properties', + 'get_source_gis_properties', + 'get_workbench_runtime', + 'get_task_context', + 'query_peripheral_data', + 'get_task_evidence', +] +TaskSnapshotSource = TaskDataSource | McpSnapshotSource + class TaskDataQuery(StrictModel): source: TaskDataSource device_ids: list[NonEmptyStr] = Field(default_factory=list, max_length=20) @@ -36,7 +75,7 @@ class TaskDataQuery(StrictModel): class TaskDataSnapshot(StrictModel): - source: TaskDataSource + source: TaskSnapshotSource source_ref: NonEmptyStr queried_at: WorkbenchDateTime origin: Literal["system_data", "synthetic"] diff --git a/app/domain/schemas/operational_task.py b/app/domain/schemas/operational_task.py index 9f19349..32748db 100644 --- a/app/domain/schemas/operational_task.py +++ b/app/domain/schemas/operational_task.py @@ -202,11 +202,24 @@ class InvestigationDataGap(StrictModel): blocking: StrictBool +TaskMapLayer = Literal["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"] +TaskFeatureId = Annotated[str, Field(min_length=1, max_length=128)] + + +class InvestigationMapTarget(StrictModel): + source_id: TaskMapLayer + feature_id: TaskFeatureId + + class InvestigationDiagramNodeRef(StrictModel): node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")] label: NonEmptyStr evidence_refs: list[NonEmptyStr] = Field(default_factory=list) action_refs: list[UUID] = Field(default_factory=list) + status: Literal[ + "pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled" + ] | None = None + map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100) class InvestigationDiagram(StrictModel): @@ -271,6 +284,22 @@ class InvestigationReportInput(InvestigationReport): data_gaps: list[InvestigationDataGap] +class InvestigationProgress(StrictModel): + """A complete reading snapshot, not a plan or a business result.""" + + summary: Annotated[str, Field(min_length=1, max_length=20000)] + diagram: InvestigationDiagram | None = None + + +class InvestigationProgressSnapshot(StrictModel): + revision: PositiveInt + run_id: UUID + execution_generation: PositiveInt + published_at: WorkbenchDateTime + source_session_id: NonEmptyStr + progress: InvestigationProgress + + class ExecutionPlanSnapshot(StrictModel): revision: PositiveInt execution_generation: PositiveInt | None = None @@ -306,10 +335,11 @@ class AgentArtifactsCommand(StrictModel): evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100) plan: ExecutionPlanInput | None = None report: InvestigationReportInput | None = None + progress: InvestigationProgress | None = None @model_validator(mode="after") def require_artifact(self): - if not self.evidence and self.plan is None and self.report is None: + if not self.evidence and self.plan is None and self.report is None and self.progress is None: raise ValueError("at least one artifact must be supplied") return self @@ -507,6 +537,7 @@ class OperationalTaskSummary(StrictModel): workflow: NonEmptyStr lifecycle_status: TaskLifecycleStatus = "open" active_run: OperationalTaskRun | None = None + latest_run: OperationalTaskRun | None = None # Compatibility fields for the staged 014 -> 015 rollout. New consumers # must use lifecycle_status and active_run. status: TaskStatus @@ -541,6 +572,7 @@ class OperationalTaskPermissions(StrictModel): class OperationalTaskDetail(OperationalTaskSummary): latest_plan: ExecutionPlanSnapshot | None latest_report: InvestigationReportRevision | None + latest_progress: InvestigationProgressSnapshot | None = None evidence: list[EvidenceRecord] actions: list[HumanApprovalAction] access_requests: list[TaskAccessRequest] = Field(default_factory=list) @@ -598,6 +630,8 @@ class OperationalTaskRetryCommand(StrictModel): class RecoverExpiredLeasesCommand(StrictModel): limit: int = Field(default=20, ge=1, le=100) + task_type: TaskType | None = None + task_created_from: WorkbenchDateTime | None = None class RecoverExpiredLeasesResult(StrictModel): @@ -609,6 +643,94 @@ class AgentArtifactsResult(StrictModel): task: OperationalTaskDetail plan_revision: PositiveInt | None report_revision: PositiveInt | None + progress_revision: PositiveInt | None = None + + +class TaskSafeChartSeries(StrictModel): + name: Annotated[str, Field(max_length=128)] + data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)]], Field(min_length=1, max_length=100)] + + +class TaskSafeChart(StrictModel): + title: Annotated[str, Field(max_length=200)] + chart_type: Literal["line", "bar"] + x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)] + series: Annotated[list[TaskSafeChartSeries], Field(min_length=1, max_length=8)] + + @model_validator(mode="after") + def validate_alignment(self): + if any(len(series.data) != len(self.x_data) for series in self.series): + raise ValueError("chart series must align with x_data") + return self + + +class TaskHighlightFeatures(StrictModel): + action: Literal["highlight_features"] + layer: TaskMapLayer + feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)] + effect: Literal["steady", "pulse"] = "steady" + + +class TaskClearHighlights(StrictModel): + action: Literal["clear_highlights"] + + +class TaskOpenChart(StrictModel): + action: Literal["open_chart"] + chart_id: TaskFeatureId + chart: TaskSafeChart + evidence_refs: Annotated[list[NonEmptyStr], Field(min_length=1, max_length=100)] + + +class TaskCloseChart(StrictModel): + action: Literal["close_chart"] + chart_id: TaskFeatureId + + +class TaskSetOverlay(StrictModel): + action: Literal["set_overlay"] + overlay_id: TaskFeatureId + title: Annotated[str, Field(min_length=1, max_length=200)] + mode: Literal["geometry", "heatmap"] + evidence_id: NonEmptyStr + + +class TaskClearOverlay(StrictModel): + action: Literal["clear_overlay"] + overlay_id: TaskFeatureId + + +TaskPresentationCommand = Annotated[ + TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay, + Field(discriminator="action"), +] + + +class TaskPresentationEventCommand(StrictModel): + request_id: UUID + run_id: UUID + source_session_id: NonEmptyStr + command: TaskPresentationCommand + + +class TaskPresentationEventResult(StrictModel): + recorded: Literal[True] = True + event_seq: PositiveInt + run_id: UUID + execution_generation: PositiveInt + + +class TaskPresentationActiveRun(StrictModel): + run_id: UUID + generation: PositiveInt + status: OperationalTaskRunStatus + + +class TaskPresentationCursor(StrictModel): + task_id: UUID + execution_generation: PositiveInt + active_run: TaskPresentationActiveRun | None + event_cursor: int = Field(ge=0) class TaskDataQueryResult(StrictModel): diff --git a/app/domain/schemas/place_search.py b/app/domain/schemas/place_search.py new file mode 100644 index 0000000..97d08db --- /dev/null +++ b/app/domain/schemas/place_search.py @@ -0,0 +1,28 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +MAX_PLACE_SEARCH_RESULTS = 10 + + +class PlaceSearchRequest(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") + + keyword: str = Field(..., min_length=1, max_length=120) + + +class PlaceSearchItem(BaseModel): + id: str = Field(..., min_length=1) + name: str = Field(..., min_length=1) + address: str + 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["奉贤区"] = "奉贤区" + + +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["奉贤区"] = "奉贤区" diff --git a/app/domain/schemas/timeseries_history.py b/app/domain/schemas/timeseries_history.py index 94f8284..f7afe98 100644 --- a/app/domain/schemas/timeseries_history.py +++ b/app/domain/schemas/timeseries_history.py @@ -86,3 +86,4 @@ class ElementHistorySeries(BaseModel): class ElementHistoryResponse(BaseModel): series: list[ElementHistorySeries] + truncated: bool = Field(default=False, exclude=True) diff --git a/app/domain/schemas/workbench_events.py b/app/domain/schemas/workbench_events.py index d894cf9..a24e5b2 100644 --- a/app/domain/schemas/workbench_events.py +++ b/app/domain/schemas/workbench_events.py @@ -19,6 +19,8 @@ WorkbenchEventTopic = Literal[ "operational.run.queued", "operational.run.changed", "operational.report.published", + "operational.progress.changed", + "operational.presentation.requested", "operational.action.changed", "operational.notification.changed", "workbench.alert.changed", diff --git a/app/infra/audit/middleware.py b/app/infra/audit/middleware.py index fbb3f02..a4ec06d 100644 --- a/app/infra/audit/middleware.py +++ b/app/infra/audit/middleware.py @@ -61,7 +61,10 @@ class AuditMiddleware(BaseHTTPMiddleware): "/api/v1/audit/session-events", "/audit/session-events", } + # Query handles and source arguments are not legacy REST audit payloads. + # Task queries persist scoped evidence/receipts in their own transaction. EXCLUDED_PATH_PREFIXES = ( + "/api/v1/mcp-query-contexts", ) async def dispatch(self, request: Request, call_next: Callable) -> Response: diff --git a/app/infra/db/postgresql/agent_messages.py b/app/infra/db/postgresql/agent_messages.py new file mode 100644 index 0000000..73c9dd3 --- /dev/null +++ b/app/infra/db/postgresql/agent_messages.py @@ -0,0 +1,88 @@ +"""Project scoped private message storage, sharing task transaction locks.""" +from psycopg import AsyncConnection + +from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository + + +class AgentMessageRepository(OperationalTaskRepository): + def __init__(self, connection: AsyncConnection, project_id): + super().__init__(connection, project_id) + + async def get_message(self, message_id: str) -> dict | None: + cursor = await self.connection.execute( + "SELECT * FROM workbench.agent_messages WHERE project_id=%s AND message_id=%s", + (self.project_id, message_id), + ) + row = await cursor.fetchone() + return self._message(row) if row else None + + async def get_message_request(self, owner_id: str, session_id: str, request_id: str) -> dict | None: + cursor = await self.connection.execute( + """SELECT * FROM workbench.agent_messages WHERE project_id=%s + AND source_owner_id=%s AND source_session_id=%s AND request_id=%s""", + (self.project_id, owner_id, session_id, request_id), + ) + row = await cursor.fetchone() + return self._message(row) if row else None + + @staticmethod + def _message(row) -> dict: + return dict(row) + + async def insert_message(self, message: dict) -> None: + keys = [key for key in message if key != "project_id"] + await self.connection.execute( + f"INSERT INTO workbench.agent_messages (project_id,{','.join(keys)}) " + f"VALUES ({','.join(['%s'] * (len(keys) + 1))})", + (self.project_id, *(message[key] for key in keys)), + ) + + async def list_session_messages( + self, owner_id: str, session_id: str, limit: int, *, cursor_at=None, cursor_id=None, pending_only=False, + ) -> list[dict]: + cursor = await self.connection.execute( + """SELECT * FROM workbench.agent_messages WHERE project_id=%s + AND conversation_owner_id=%s AND (source_session_id=%s OR + (direction='to_chat' AND target_session_id=%s)) + AND (%s::timestamptz IS NULL OR (created_at,message_id)<(%s::timestamptz,%s::uuid)) + AND (NOT %s OR status IN ('pending','enqueued')) + ORDER BY created_at DESC,message_id DESC LIMIT %s""", + (self.project_id, owner_id, session_id, session_id, cursor_at, cursor_at, cursor_id, pending_only, limit), + ) + return [self._message(row) for row in await cursor.fetchall()] + + async def list_run_session_messages( + self, session_id: str, limit: int, *, cursor_at=None, cursor_id=None, pending_only=False, + ) -> list[dict]: + cursor = await self.connection.execute( + """SELECT m.* FROM workbench.agent_messages m + JOIN workbench.operational_task_runs r ON r.project_id=m.project_id AND r.run_id=m.run_id + WHERE m.project_id=%s AND r.agent_session_id=%s + AND r.status IN ('claimed','running') + AND (%s::timestamptz IS NULL OR (m.created_at,m.message_id)<(%s::timestamptz,%s::uuid)) + AND (NOT %s OR (m.direction='to_task' AND m.status IN ('pending','enqueued'))) + ORDER BY m.created_at DESC,m.message_id DESC LIMIT %s""", + (self.project_id, session_id, cursor_at, cursor_at, cursor_id, pending_only, limit), + ) + return [self._message(row) for row in await cursor.fetchall()] + + async def list_pending_messages(self, limit: int, *, cursor_at=None, cursor_id=None) -> list[dict]: + cursor = await self.connection.execute( + """SELECT m.* FROM workbench.agent_messages m + JOIN workbench.operational_task_runs r + ON r.project_id=m.project_id AND r.run_id=m.run_id + WHERE m.project_id=%s AND m.status IN ('pending','enqueued') + AND (m.direction='to_chat' OR r.status IN ('claimed','running')) + AND (%s::timestamptz IS NULL OR (m.created_at,m.message_id)>(%s::timestamptz,%s::uuid)) + ORDER BY m.created_at,m.message_id LIMIT %s""", + (self.project_id, cursor_at, cursor_at, cursor_id, limit), + ) + return [self._message(row) for row in await cursor.fetchall()] + + async def update_message_delivery(self, message_id: str, update: dict) -> None: + keys = list(update) + await self.connection.execute( + f"UPDATE workbench.agent_messages SET {','.join(key+'=%s' for key in keys)} " + "WHERE project_id=%s AND message_id=%s", + (*(update[key] for key in keys), self.project_id, message_id), + ) diff --git a/app/infra/db/postgresql/analysis.py b/app/infra/db/postgresql/analysis.py index 6268df5..c2d1cfd 100644 --- a/app/infra/db/postgresql/analysis.py +++ b/app/infra/db/postgresql/analysis.py @@ -221,6 +221,7 @@ class AnalysisRepository: *, limit: int = 100, offset: int = 0, + lookahead: bool = False, ) -> list[dict]: if not 1 <= limit <= 500 or offset < 0: raise ValueError("analysis result page is out of range") @@ -235,7 +236,7 @@ class AnalysisRepository: query += " AND result_type = %s" params = (run_id, result_type) query += " ORDER BY created_at, result_id LIMIT %s OFFSET %s" - params += (limit, offset) + params += (limit + int(lookahead), offset) async with conn.cursor() as cur: await cur.execute(query, params) diff --git a/app/infra/db/postgresql/mcp_queries.py b/app/infra/db/postgresql/mcp_queries.py new file mode 100644 index 0000000..1e16a7b --- /dev/null +++ b/app/infra/db/postgresql/mcp_queries.py @@ -0,0 +1,91 @@ +"""Persistent MCP context and receipt storage in the selected project's BizDB.""" + +from datetime import datetime, timezone +from psycopg import AsyncConnection +from psycopg.types.json import Jsonb +from app.mcp.models import BoundContext + + +class McpQueryRepository: + def __init__(self, conn: AsyncConnection, project_id): + self.conn, self.project_id = conn, project_id + + async def create(self, context: BoundContext): + async with self.conn.transaction(), self.conn.cursor() as cur: + # Bounded maintenance; expired/revoked handles remain invalid even if cleanup fails. + await cur.execute( + """DELETE FROM workbench.mcp_query_contexts WHERE context_handle IN + (SELECT context_handle FROM workbench.mcp_query_contexts + WHERE project_id=%s AND (expires_at < now() OR revoked_at IS NOT NULL) + ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)""", + (self.project_id,), + ) + await cur.execute( + """INSERT INTO workbench.mcp_query_contexts + (context_handle,project_id,subject,client_id,document,expires_at) + VALUES (%s,%s,%s,%s,%s,%s)""", + ( + context.context_handle, + self.project_id, + context.subject, + context.client_id, + Jsonb(context.model_dump(mode="json")), + context.expires_at, + ), + ) + + async def get(self, handle: str) -> BoundContext | None: + async with self.conn.transaction(), self.conn.cursor() as cur: + await cur.execute( + """SELECT document,revoked_at FROM workbench.mcp_query_contexts + WHERE context_handle=%s AND project_id=%s""", + (handle, self.project_id), + ) + row = await cur.fetchone() + if not row: + return None + document = dict(row["document"]) + document["revoked_at"] = row["revoked_at"] + return BoundContext.model_validate(document) + + async def revoke(self, handle: str, subject: str, client_id: str): + async with self.conn.transaction(), self.conn.cursor() as cur: + await cur.execute( + """UPDATE workbench.mcp_query_contexts SET revoked_at=COALESCE(revoked_at,%s) + WHERE context_handle=%s AND project_id=%s AND subject=%s AND client_id=%s""", + ( + datetime.now(timezone.utc), + handle, + self.project_id, + subject, + client_id, + ), + ) + + async def receipt(self, scope_key: str, call_id: str) -> dict | None: + async with self.conn.transaction(), self.conn.cursor() as cur: + await cur.execute( + """SELECT parameters,result FROM workbench.mcp_query_receipts + WHERE project_id=%s AND scope_key=%s AND call_id=%s""", + (self.project_id, scope_key, call_id), + ) + return await cur.fetchone() + + async def save_receipt( + self, scope_key: str, call_id: str, parameters: dict, result: dict + ): + # Caller owns the short evidence + receipt transaction. + async with self.conn.cursor() as cur: + await cur.execute( + """INSERT INTO workbench.mcp_query_receipts + (project_id,scope_key,call_id,parameters,result) VALUES (%s,%s,%s,%s,%s) + ON CONFLICT (project_id,scope_key,call_id) DO NOTHING""", + (self.project_id, scope_key, call_id, Jsonb(parameters), Jsonb(result)), + ) + + async def lock_receipt(self, scope_key: str, call_id: str): + async with self.conn.cursor() as cur: + await cur.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", + (f"mcp:{self.project_id}:{scope_key}:{call_id}",), + ) diff --git a/app/infra/db/postgresql/operational_tasks.py b/app/infra/db/postgresql/operational_tasks.py index a20a189..539b00b 100644 --- a/app/infra/db/postgresql/operational_tasks.py +++ b/app/infra/db/postgresql/operational_tasks.py @@ -2,6 +2,7 @@ from __future__ import annotations from contextlib import asynccontextmanager +from datetime import datetime from typing import Any from psycopg import AsyncConnection @@ -11,6 +12,7 @@ from uuid import uuid4 from app.domain.domain_errors import DomainError from app.infra.db.postgresql.workbench_events import lock_event_transaction +from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository class OperationalTaskRepository: @@ -37,6 +39,13 @@ class OperationalTaskRepository: (f"tjwater-operational:{self.project_id}:{key}",), ) + async def try_lock(self, key: str) -> bool: + cursor = await self.connection.execute( + "SELECT pg_try_advisory_xact_lock(hashtextextended(%s, 0)) AS acquired", + (f"tjwater-operational:{self.project_id}:{key}",), + ) + return bool((await cursor.fetchone())["acquired"]) + async def get_request(self, scope: str, request_id: str) -> dict | None: cursor = await self.connection.execute( """SELECT request_digest,response @@ -103,6 +112,50 @@ class OperationalTaskRepository: row = await cursor.fetchone() return dict(row) if row else None + async def list_task_runs(self, task_id: str, limit: int, offset: int) -> list[dict]: + cursor = await self.connection.execute( + """SELECT * FROM workbench.operational_task_runs + WHERE project_id=%s AND task_id=%s + ORDER BY generation DESC LIMIT %s OFFSET %s""", + (self.project_id, task_id, limit, offset), + ) + return [{key: value for key, value in row.items() if key != "project_id"} + for row in await cursor.fetchall()] + + async def get_latest_run(self, task_id: str) -> dict | None: + rows = await self.list_task_runs(task_id, 1, 0) + return rows[0] if rows else None + + async def require_execution_storage(self) -> None: + """Reject claims before changing state if the inbox cannot be used.""" + cursor = await self.connection.execute( + "SELECT to_regclass('workbench.agent_messages') IS NOT NULL AS available" + ) + if not (await cursor.fetchone())["available"]: + raise DomainError( + "DEPENDENCY_UNAVAILABLE", + "Agent message storage is not provisioned; apply " + "resources/sql/024_agent_messages.sql to this project database before starting runs", + status=503, + ) + + async def pending_run_messages(self, run_id: str) -> list[str]: + cursor = await self.connection.execute( + """SELECT message_id FROM workbench.agent_messages + WHERE project_id=%s AND run_id=%s AND direction='to_task' + AND status IN ('pending','enqueued') ORDER BY created_at LIMIT 100""", + (self.project_id, run_id), + ) + return [str(row["message_id"]) for row in await cursor.fetchall()] + + async def fail_run_messages(self, run_id: str, failure_detail: str, now: datetime) -> None: + await self.connection.execute( + """UPDATE workbench.agent_messages SET status='failed',failure_detail=%s,updated_at=%s + WHERE project_id=%s AND run_id=%s AND direction='to_task' + AND status IN ('pending','enqueued')""", + (failure_detail, now, self.project_id, run_id), + ) + async def insert_run(self, run: dict) -> None: await self.connection.execute( """INSERT INTO workbench.operational_task_runs @@ -142,7 +195,8 @@ class OperationalTaskRepository: raise DomainError("VERSION_CONFLICT", "Run revision changed", status=409) async def list_runs( - self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None + self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None, + task_type: str | None = None, task_created_from: datetime | None = None, ) -> list[dict]: result = await self.connection.execute( """SELECT run_id,task_id,generation,execution_request_id,status,revision, @@ -154,15 +208,25 @@ class OperationalTaskRepository: AND (%s::text IS NULL OR status=%s::text) AND (%s::timestamptz IS NULL OR (created_at,run_id)>(%s::timestamptz,%s::uuid)) AND (status<>'queued' OR next_dispatch_at<=now()) + AND EXISTS ( + SELECT 1 FROM workbench.operational_tasks t + WHERE t.project_id=operational_task_runs.project_id + AND t.task_id=operational_task_runs.task_id + AND (%s::text IS NULL OR t.task_type=%s::text) + AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz) + ) ORDER BY created_at,run_id LIMIT %s""", ( self.project_id, status, status, cursor_created_at, - cursor_created_at, cursor_run_id, limit, + cursor_created_at, cursor_run_id, + task_type, task_type, task_created_from, task_created_from, limit, ), ) return [dict(row) for row in await result.fetchall()] - async def claim_expired_runs(self, limit: int) -> list[dict]: + async def claim_expired_runs( + self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None, + ) -> list[dict]: cursor = await self.connection.execute( """SELECT run_id,task_id,generation,execution_request_id,status,revision, agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at, @@ -171,8 +235,15 @@ class OperationalTaskRepository: FROM workbench.operational_task_runs WHERE project_id=%s AND status IN ('claimed','running') AND lease_expires_at IS NOT NULL AND lease_expires_at<=now() + AND EXISTS ( + SELECT 1 FROM workbench.operational_tasks t + WHERE t.project_id=operational_task_runs.project_id + AND t.task_id=operational_task_runs.task_id + AND (%s::text IS NULL OR t.task_type=%s::text) + AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz) + ) ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""", - (self.project_id, limit), + (self.project_id, task_type, task_type, task_created_from, task_created_from, limit), ) return [dict(row) for row in await cursor.fetchall()] @@ -212,6 +283,9 @@ class OperationalTaskRepository: ) return event_seq + async def current_event_cursor(self) -> int: + return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor() + async def get_task_by_create_request(self, request_id: str) -> dict | None: cursor = await self.connection.execute( """SELECT document FROM workbench.operational_tasks @@ -246,7 +320,9 @@ class OperationalTaskRepository: cursor = await self.connection.execute( """SELECT task.document, CASE WHEN active_run.run_id IS NULL THEN NULL - ELSE to_jsonb(active_run) - 'project_id' END AS active_run + ELSE to_jsonb(active_run) - 'project_id' END AS active_run, + CASE WHEN latest_run.run_id IS NULL THEN NULL + ELSE to_jsonb(latest_run) - 'project_id' END AS latest_run FROM workbench.operational_tasks AS task LEFT JOIN LATERAL ( SELECT run.* FROM workbench.operational_task_runs AS run @@ -254,6 +330,11 @@ class OperationalTaskRepository: AND run.status IN ('queued','claimed','running') ORDER BY run.generation DESC LIMIT 1 ) AS active_run ON true + LEFT JOIN LATERAL ( + SELECT run.* FROM workbench.operational_task_runs AS run + WHERE run.project_id=task.project_id AND run.task_id=task.task_id + ORDER BY run.generation DESC LIMIT 1 + ) AS latest_run ON true WHERE task.project_id=%s AND (task.document#>>'{source,deleted_at}') IS NULL AND (%s::text IS NULL OR task.task_type=%s::text) @@ -285,7 +366,7 @@ class OperationalTaskRepository: ), ) return [ - {**row["document"], "active_run": row["active_run"]} + {**row["document"], "active_run": row["active_run"], "latest_run": row["latest_run"]} for row in await cursor.fetchall() ] @@ -444,6 +525,38 @@ class OperationalTaskRepository: async def insert_report(self, task_id: str, snapshot: dict) -> None: await self._insert_artifact("operational_task_reports", task_id, snapshot) + async def get_latest_progress(self, task_id: str, generation: int) -> dict | None: + # Read compatibility before the coordinated progress migration is applied. + availability = await self.connection.execute( + "SELECT to_regclass('workbench.operational_task_progress') AS relation" + ) + if not (await availability.fetchone())["relation"]: + return None + cursor = await self.connection.execute( + """SELECT progress.document FROM workbench.operational_task_progress AS progress + JOIN workbench.operational_task_runs AS run + ON run.project_id=progress.project_id AND run.run_id=progress.run_id + AND run.task_id=progress.task_id + WHERE progress.project_id=%s AND progress.task_id=%s AND run.generation=%s""", + (self.project_id, task_id, generation), + ) + row = await cursor.fetchone() + return row["document"] if row else None + + async def save_progress(self, task_id: str, snapshot: dict) -> None: + cursor = await self.connection.execute( + """INSERT INTO workbench.operational_task_progress + (project_id,task_id,run_id,revision,document,published_at) + VALUES (%s,%s,%s,%s,%s,%s) + ON CONFLICT(project_id,task_id,run_id) DO UPDATE SET + revision=excluded.revision,document=excluded.document,published_at=excluded.published_at + WHERE workbench.operational_task_progress.revision=excluded.revision-1""", + (self.project_id, task_id, snapshot["run_id"], snapshot["revision"], + Jsonb(snapshot), snapshot["published_at"]), + ) + if cursor.rowcount != 1: + raise DomainError("VERSION_CONFLICT", "Investigation progress revision changed", status=409) + async def _insert_artifact(self, table: str, task_id: str, snapshot: dict) -> None: if table not in {"operational_task_plans", "operational_task_reports"}: raise ValueError("unsupported artifact table") diff --git a/app/infra/db/postgresql/read_queries.py b/app/infra/db/postgresql/read_queries.py new file mode 100644 index 0000000..e83960c --- /dev/null +++ b/app/infra/db/postgresql/read_queries.py @@ -0,0 +1,406 @@ +"""Bounded read primitives over existing project views and time-series tables.""" + +from datetime import datetime +from typing import Any +from uuid import UUID + +from psycopg import AsyncConnection +from psycopg import sql +from psycopg.rows import dict_row +from fastapi import HTTPException +from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device +from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository +from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository + +ELEMENT_VIEWS = { + v: v + "s" for v in ("junction", "pipe", "reservoir", "tank", "pump", "valve") +} +MAX_QUERY_BYTES = 8 * 1024 * 1024 + + +async def read_rows(conn, statement, params=(), *, timeout_ms=30000): + async with conn.transaction(), conn.cursor(row_factory=dict_row) as cur: + await cur.execute( + "SELECT set_config('statement_timeout',%s,true)", (str(timeout_ms),) + ) + await cur.execute(statement, params) + return await cur.fetchall() + + +async def asset_rows(conn, element_type, *, element_id=None, limit=100, offset=0): + view = ELEMENT_VIEWS[element_type] + where = sql.SQL("WHERE v.id=%s") if element_id else sql.SQL("") + params = (element_id,) if element_id else () + statement = sql.SQL( + "SELECT to_jsonb(v)-'geom' AS record FROM gis.{} v {} ORDER BY v.id LIMIT %s OFFSET %s" + ).format(sql.Identifier(view), where) + rows = await read_rows(conn, statement, params + (limit, offset)) + return [row["record"] for row in rows] + + +async def scada_devices(conn, *, device_id=None, limit=100, offset=0): + where = " WHERE id=%s" if device_id else "" + params = (device_id,) if device_id else () + rows = await read_rows( + conn, + _SCADA_VIEW_SELECT + where + " ORDER BY device_id LIMIT %s OFFSET %s", + params + (limit, offset), + ) + return [_device(row) for row in rows] + + +async def series_rows( + conn, + *, + source, + element_type, + ids, + start=None, + end=None, + time=None, + field=None, + run_id=None, + limit=1000, + offset=0, + end_exclusive=False, +): + node = element_type == "junction" + if source == "scada": + table, id_column, fields = ( + "measurements", + "device_id", + {"monitored_value", "cleaned_value"}, + ) + else: + table, id_column = ( + ("node_results", "node_id") if node else ("link_results", "link_id") + ) + fields = ( + ( + AnalysisResultsRepository.NODE_FIELDS + if node + else AnalysisResultsRepository.LINK_FIELDS + ) + if source == "analysis" + else ( + RealtimeRepository.NODE_FIELDS + if node + else RealtimeRepository.LINK_FIELDS + ) + ) + if field is not None and field not in fields: + raise ValueError("unsupported field for this data source and element type") + columns = ( + sql.SQL("{},time,{} AS value").format( + sql.Identifier(id_column), sql.Identifier(field) + ) + if field + else sql.SQL("*") + ) + filters = [] + params = [] + if ids: + filters.append(sql.SQL("{}=ANY(%s)").format(sql.Identifier(id_column))) + params.append(ids) + if run_id: + filters.append(sql.SQL("run_id=%s")) + params.append(run_id) + if start is not None: + filters.append( + sql.SQL( + "time >= %s AND time < %s" + if end_exclusive + else "time >= %s AND time <= %s" + ) + ) + params.extend((start, end)) + else: + # Existing point-query semantics use +/- one second. + filters.append( + sql.SQL("time BETWEEN %s-interval '1 second' AND %s+interval '1 second'") + ) + params.extend((time, time)) + statement = sql.SQL( + "SELECT {} FROM {}.{} WHERE {} ORDER BY time,{} LIMIT %s OFFSET %s" + ).format( + columns, + sql.Identifier(source), + sql.Identifier(table), + sql.SQL(" AND ").join(filters), + sql.Identifier(id_column), + ) + return await read_rows(conn, statement, tuple(params) + (limit, offset)) + + +async def sensor_runs(conn, *, run_id=None, limit=100, offset=0): + where = " AND r.run_id=%s" if run_id else "" + params = (run_id,) if run_id else () + return await read_rows( + conn, + """SELECT r.run_id,r.name,r.created_by,r.created_at,r.status, + result.payload FROM analysis.runs r JOIN LATERAL + (SELECT payload FROM analysis.results WHERE run_id=r.run_id + AND result_type='sensor_placement' ORDER BY created_at DESC,result_id DESC LIMIT 1) result ON true + WHERE r.run_type='sensor_placement'""" + + where + + " ORDER BY r.created_at DESC,r.run_id LIMIT %s OFFSET %s", + params + (limit, offset), + ) + + +async def history_rows( + conn: AsyncConnection, descriptors: list[dict[str, Any]], *, + start: datetime, end: datetime, run_id: UUID | None, limit: int, offset: int, +) -> list[dict[str, Any]]: + """Page points across raw, cleaned and model series in one SQL statement.""" + parts, params = [], [] + for index, item in enumerate(descriptors): + source, field = item["source"], item["field"] + if source == "scada": + table, id_column, fields = "measurements", "device_id", {"monitored_value", "cleaned_value"} + elif source in {"realtime", "analysis"}: + node = item["element_type"] == "junction" + table, id_column = ("node_results", "node_id") if node else ("link_results", "link_id") + fields = {"pressure"} if node else {"flow"} + else: + raise ValueError("unsupported history source") + if field not in fields or (source == "analysis" and run_id is None): + raise ValueError("unsupported history field or missing run") + parts.append(sql.SQL( + "SELECT %s::integer AS series_index,time,{} AS value FROM {}.{} " + "WHERE {}=%s AND time >= %s AND time <= %s{}" + ).format( + sql.Identifier(field), sql.Identifier(source), sql.Identifier(table), + sql.Identifier(id_column), sql.SQL(" AND run_id=%s" if source == "analysis" else ""), + )) + params.extend((index, item["id"], start, end)) + if source == "analysis": + params.append(run_id) + if not parts: + return [] + statement = sql.SQL( + "SELECT series_index,time,value FROM ({}) points " + "ORDER BY series_index, time LIMIT %s OFFSET %s" + ).format(sql.SQL(" UNION ALL ").join(parts)) + return await read_rows(conn, statement, tuple(params) + (limit, offset)) + + +async def evidence_page( + conn: AsyncConnection, *, project_id: UUID, task_id: UUID, evidence_id: str, data_path: list[str], + limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False, +) -> dict[str, Any]: + """Project the immutable snapshot in PostgreSQL before transferring JSON.""" + return await _snapshot_page(conn, stored_sql=""" + SELECT document->'data_snapshot' AS snapshot + FROM workbench.operational_task_evidence + WHERE project_id=%s AND task_id=%s AND evidence_id=%s + AND jsonb_typeof(document->'data_snapshot')='object' + """, stored_params=(project_id, task_id, evidence_id), data_path=data_path, + limit=limit, offset=offset, mcp_sources=mcp_sources, full_snapshot=full_snapshot) + + +async def _snapshot_page( + conn: AsyncConnection, *, stored_sql: str, stored_params: tuple, data_path: list[str], + limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False, + max_bytes: int = MAX_QUERY_BYTES, +) -> dict[str, Any]: + """Shared JSONB projection; callers supply only fixed, scoped SQL sources.""" + def descriptor(value, path): + return f"""jsonb_build_object( + 'collection_type',jsonb_typeof({value}), + 'count',CASE jsonb_typeof({value}) WHEN 'array' THEN jsonb_array_length({value}) + WHEN 'string' THEN char_length({value} #>> '{{}}') + ELSE (SELECT count(*) FROM jsonb_object_keys({value})) END, + 'data_path',to_jsonb({path}))""" + + collection = descriptor("p.value", "p.path") + child = descriptor("child.item", "p.path || ARRAY[child.key]") + statement = """ + WITH stored AS MATERIALIZED ( + STORED_QUERY + ), captured AS ( + SELECT snapshot, + snapshot->>'source'=ANY(%s::text[]) + AND jsonb_typeof(snapshot->'data')='object' AS is_mcp + FROM stored + ), selected AS MATERIALIZED ( + SELECT snapshot, + CASE WHEN is_mcp THEN (snapshot->'data')-'data' END AS query_metadata, + (CASE WHEN is_mcp THEN snapshot->'data'->'data' + ELSE snapshot->'data' END) #> %s::text[] AS value + FROM captured + ), entries AS ( + SELECT ordinality AS position, NULL::text AS key, item AS value + FROM selected, jsonb_array_elements( + CASE WHEN jsonb_typeof(value)='array' THEN value ELSE '[]'::jsonb END + ) WITH ORDINALITY AS a(item, ordinality) + UNION ALL + SELECT row_number() OVER (ORDER BY e.key COLLATE "C"), e.key, e.item + FROM selected, jsonb_each( + CASE WHEN jsonb_typeof(value)='object' THEN value ELSE '{}'::jsonb END + ) AS e(key,item) + ), paged AS MATERIALIZED ( + SELECT * FROM entries ORDER BY position LIMIT %s OFFSET %s + ), paths AS ( + SELECT paged.*, jsonb_typeof(selected.value) AS parent_type, + %s::text[] || ARRAY[CASE WHEN jsonb_typeof(selected.value)='array' + THEN (position-1)::text ELSE key END] AS path + FROM paged,selected + ), summaries AS ( + SELECT p.position,p.key,p.value AS original, + CASE WHEN jsonb_typeof(p.value) IN ('array','object') OR + (jsonb_typeof(p.value)='string' AND char_length(p.value #>> '{}')>256) THEN + CASE WHEN p.parent_type='object' OR jsonb_typeof(p.value) IN ('array','string') + THEN COLLECTION_PREVIEW + ELSE COALESCE((SELECT jsonb_object_agg(child.key, + CASE WHEN jsonb_typeof(child.item) IN ('array','object') OR + (jsonb_typeof(child.item)='string' AND char_length(child.item #>> '{}')>256) + THEN CHILD_PREVIEW ELSE child.item END) + FROM jsonb_each(p.value) AS child(key,item)), '{}'::jsonb) + END ELSE p.value END AS value + FROM paths p + ), projected AS ( + SELECT CASE WHEN %s THEN snapshot ELSE jsonb_build_object( + 'snapshot_metadata',snapshot-'data', 'query_metadata',query_metadata, + 'nested',EXISTS(SELECT 1 FROM summaries WHERE original<>value), + 'value_type',jsonb_typeof(value), + 'total',CASE jsonb_typeof(value) + WHEN 'array' THEN jsonb_array_length(value) + WHEN 'object' THEN (SELECT count(*) FROM jsonb_object_keys(value)) + WHEN 'string' THEN char_length(value #>> '{}') END, + 'value',CASE jsonb_typeof(value) + WHEN 'array' THEN COALESCE((SELECT jsonb_agg(value ORDER BY position) FROM summaries),'[]'::jsonb) + WHEN 'object' THEN COALESCE((SELECT jsonb_object_agg(key,value) FROM summaries),'{}'::jsonb) + WHEN 'string' THEN to_jsonb(substring(value #>> '{}' FROM %s FOR %s)) + ELSE value END + ) END AS payload, value IS NULL AS missing_path + FROM selected + ), sized AS MATERIALIZED ( + SELECT payload,missing_path,octet_length(payload::text)>%s AS too_large + FROM projected + ) + SELECT CASE WHEN NOT too_large THEN payload END AS payload,too_large,missing_path + FROM sized + """ + statement = statement.replace("STORED_QUERY", stored_sql).replace("COLLECTION_PREVIEW", collection).replace("CHILD_PREVIEW", child) + rows = await read_rows(conn, statement, ( + *stored_params, mcp_sources, data_path, limit, offset, + data_path, full_snapshot, offset + 1, limit, max_bytes, + )) + if not rows: + raise HTTPException(404, "Stored query document was not found") + row = rows[0] + if row["too_large"]: + raise HTTPException(413, "Query exceeds the byte budget; select a deeper data_path or smaller page") + if row.get("missing_path") and not full_snapshot: + raise HTTPException(422, "Evidence data_path does not name a stored value") + return row["payload"] + + +async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, report_revision: int | None) -> dict: + """Read fixed-size metadata and explicit previews, never a complete report.""" + report = """jsonb_build_object('revision',r.revision, + 'execution_generation',r.document->'execution_generation', + 'stage',r.document#>'{report,stage}','outcome',r.document#>'{report,outcome}', + 'current_conclusion_preview',left(r.document#>>'{report,current_conclusion}',200), + 'preview_only',true,'detail',jsonb_build_object('section','report','report_revision',r.revision))""" + rows = await read_rows(conn, """ + SELECT jsonb_build_object('task',jsonb_build_object( + 'task_id',t.task_id,'task_type',t.task_type,'workflow',t.document->>'workflow','status',t.status, + 'lifecycle_status',t.lifecycle_status,'priority',t.priority,'revision',t.revision, + 'execution_generation',t.execution_generation, + 'current_report_revision',t.current_report_revision, + 'title_preview',left(t.document->>'title',160),'preview_only',true, + 'detail',jsonb_build_object('section','task')), + 'latest_report',(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r + WHERE r.project_id=t.project_id AND r.task_id=t.task_id ORDER BY r.revision DESC LIMIT 1), + 'selected_report',CASE WHEN %s::int IS NULL THEN NULL ELSE + (SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r + WHERE r.project_id=t.project_id AND r.task_id=t.task_id AND r.revision=%s) END + ) AS payload + FROM workbench.operational_tasks t WHERE t.project_id=%s AND t.task_id=%s + """.replace("REPORT_SUMMARY", report), (report_revision, report_revision, project_id, task_id)) + if not rows: + raise HTTPException(404, "Task was not found in this project") + result = rows[0]["payload"] + if report_revision is not None and result["selected_report"] is None: + raise HTTPException(404, "Selected report revision was not found") + return result + + +async def task_context_page( + conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str, + report_revision: int | None, data_path: list[str], limit: int, offset: int, +) -> dict: + """Page only the requested task section inside PostgreSQL, before transfer.""" + collections = { + "evidence": ("operational_task_evidence", "evidence_id"), + "actions": ("operational_task_actions", "action_id"), + "access_requests": ("operational_task_access_requests", "access_request_id"), + } + documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"} + if section in {"access_requests", "progress"}: + table = "operational_task_access_requests" if section == "access_requests" else "operational_task_progress" + available = await read_rows(conn, "SELECT to_regclass(%s) AS relation", ("workbench." + table,)) + if not available[0]["relation"]: + return {"snapshot_metadata": {"section": section, "available": False}, "value": None, "value_type": "null", "total": None, "nested": False} + projection_offset = offset + if section in collections: + table, key = collections[section] + # These identifiers come exclusively from the fixed table above. + relation = sql.Identifier("workbench", table).as_string() + identifier = sql.Identifier(key).as_string() + if data_path: + stored = f"""SELECT jsonb_build_object('section',%s::text,'data',jsonb_build_object({identifier}::text,document)) AS snapshot + FROM {relation} WHERE project_id=%s AND task_id=%s AND {identifier}::text=%s""" + params = (section, project_id, task_id, data_path[0]) + else: + # Fetch just this page of rows. Collection keys remain stable across + # paging, so every nested data_path uses the real evidence/action ID. + stored = f"""WITH items AS MATERIALIZED ( + SELECT {identifier}::text AS key,document FROM {relation} + WHERE project_id=%s AND task_id=%s + ORDER BY {identifier}::text COLLATE "C" LIMIT %s OFFSET %s) + SELECT jsonb_build_object('section',%s::text, + 'total',(SELECT count(*) FROM {relation} WHERE project_id=%s AND task_id=%s), + 'data',COALESCE(jsonb_object_agg(key,document),'{{}}'::jsonb)) AS snapshot FROM items""" + params = (project_id, task_id, limit + 1, offset, section, project_id, task_id) + projection_offset = 0 + elif section in documents: + relation = sql.Identifier("workbench", documents[section]).as_string() + where = "" + params = (section, project_id, task_id) + if section == "report": + where = " AND (%s::int IS NULL OR revision=%s) ORDER BY revision DESC LIMIT 1" + params += (report_revision, report_revision) + elif section == "plan": + where = " ORDER BY revision DESC LIMIT 1" + stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}" + elif section == "run": + stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id') AS snapshot + FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1""" + params = (project_id, task_id) + elif section == "progress": + stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot + FROM workbench.operational_task_progress p + JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id + JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation + WHERE p.project_id=%s AND p.task_id=%s""" + params = (project_id, task_id) + else: + raise HTTPException(422, "Unknown task context section") + return await _snapshot_page(conn, stored_sql=stored, stored_params=params, + data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000) + + +async def task_data_scopes(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, permissions: set[str]) -> list[str]: + """Bound scope discovery to seven known sources, without loading task detail.""" + known = ["workbench_runtime", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", "scada_devices", "scada_readings"] + scopes = {name for name in known if ("scada.view" if name.startswith("scada_") else "simulation.view") in permissions} + available = await read_rows(conn, "SELECT to_regclass('workbench.operational_task_access_requests') AS relation") + if available[0]["relation"]: + rows = await read_rows(conn, """SELECT DISTINCT scope FROM workbench.operational_task_access_requests, + LATERAL jsonb_array_elements_text(COALESCE(document->'approved_scopes','[]'::jsonb)) AS granted(scope) + WHERE project_id=%s AND task_id=%s AND status='approved' AND scope=ANY(%s::text[])""", (project_id, task_id, known)) + scopes.update(row["scope"] for row in rows) + return [name for name in known if name in scopes] diff --git a/app/infra/db/postgresql/scada.py b/app/infra/db/postgresql/scada.py index 9e67e73..ef9d530 100644 --- a/app/infra/db/postgresql/scada.py +++ b/app/infra/db/postgresql/scada.py @@ -111,14 +111,16 @@ class ScadaInfoRepository: conn: AsyncConnection, node_ids: list[str], link_ids: list[str], + *, row_limit: int | None = None, ) -> list[dict[str, Any]]: if not node_ids and not link_ids: return [] async with conn.cursor() as cur: await cur.execute( _SCADA_VIEW_SELECT - + " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id", - (node_ids, link_ids), + + " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id" + + (" LIMIT %s" if row_limit is not None else ""), + (node_ids, link_ids) + ((row_limit,) if row_limit is not None else ()), ) return [_device(record) for record in await cur.fetchall()] diff --git a/app/infra/db/postgresql/spatial_queries.py b/app/infra/db/postgresql/spatial_queries.py new file mode 100644 index 0000000..5f32b0e --- /dev/null +++ b/app/infra/db/postgresql/spatial_queries.py @@ -0,0 +1,71 @@ +"""Bounded nearest-model queries on the already authorized project connection.""" + +from math import asin, cos, degrees, radians, sin +from typing import Any + +from psycopg import AsyncConnection, sql + +from app.infra.db.postgresql.read_queries import ELEMENT_VIEWS, read_rows + +_MODEL_RELATIONS = { + kind: sql.Identifier("gis", view) for kind, view in ELEMENT_VIEWS.items() +} + + +def _search_boxes( + longitude: float, latitude: float, radius_m: float, +) -> list[tuple[float, float, float, float]]: + # A deliberately smaller-than-WGS84 curvature radius gives a conservative + # geographic envelope. It is only a GiST candidate filter; geography below + # determines the actual radius and distances. Split at the antimeridian. + angle = (radius_m + 10) / 6_300_000 + low, high = latitude - degrees(angle), latitude + degrees(angle) + if low < -85 or high > 85: + # Avoid Mercator's polar singularity; retain exact geography filtering. + return [] + width = degrees(asin(min(1.0, sin(angle) / cos(radians(latitude))))) + left, right = longitude - width, longitude + width + if left < -180: + return [(-180, low, right, high), (left + 360, low, 180, high)] + if right > 180: + return [(left, low, 180, high), (-180, low, right - 360, high)] + return [(left, low, right, high)] + + +async def nearby_elements( + conn: AsyncConnection, + element_type: str, + *, + center: tuple[float, float], + radius_m: float, + limit: int, + offset: int, +) -> list[dict[str, Any]]: + """Rank complete model geometries by spheroidal distance, never endpoints.""" + relation = _MODEL_RELATIONS[element_type] + filters = [] + parameters: list[Any] = [*center] + for box in _search_boxes(*center, radius_m): + filters.append(sql.SQL( + "v.geom && ST_Transform(ST_MakeEnvelope(%s,%s,%s,%s,4326),3857)" + )) + parameters.extend(box) + candidate_filter = sql.SQL(" OR ").join(filters) if filters else sql.SQL("TRUE") + statement = sql.SQL(""" + WITH target AS ( + SELECT ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography AS point + ), candidates AS MATERIALIZED ( + SELECT v.id, to_jsonb(v)-'geom' AS properties, + ST_Transform(v.geom,4326)::geography AS shape + FROM {} v + WHERE v.geom IS NOT NULL AND NOT ST_IsEmpty(v.geom) AND ({}) + ) + SELECT c.id AS element_id, c.properties, + ST_Distance(c.shape,t.point,true) AS distance_m + FROM candidates c CROSS JOIN target t + WHERE ST_DWithin(c.shape,t.point,%s,true) + ORDER BY distance_m,c.id + LIMIT %s OFFSET %s + """).format(relation, candidate_filter) + parameters.extend((radius_m, limit, offset)) + return await read_rows(conn, statement, tuple(parameters)) diff --git a/app/infra/db/timescaledb/repositories/analysis.py b/app/infra/db/timescaledb/repositories/analysis.py index cda4e51..d5bc413 100644 --- a/app/infra/db/timescaledb/repositories/analysis.py +++ b/app/infra/db/timescaledb/repositories/analysis.py @@ -184,6 +184,7 @@ class AnalysisResultsRepository: start_time: datetime, end_time: datetime, field: str, + *, row_limit: int | None = None, ) -> dict[str, list[dict[str, Any]]]: if element_type == "node": table_name, id_column, valid_fields = ( @@ -214,8 +215,10 @@ class AnalysisResultsRepository: sql.Identifier(id_column), sql.Identifier(id_column), ) + if row_limit is not None: + query += sql.SQL(" LIMIT %s") async with conn.cursor() as cur: - await cur.execute(query, (run_id, element_ids, start_time, end_time)) + await cur.execute(query, (run_id, element_ids, start_time, end_time) + ((row_limit,) if row_limit is not None else ())) for row in await cur.fetchall(): result.setdefault(str(row["element_id"]), []).append( {"time": row["time"], "value": row["value"]} diff --git a/app/infra/db/timescaledb/repositories/realtime.py b/app/infra/db/timescaledb/repositories/realtime.py index df53ea3..e919f9b 100644 --- a/app/infra/db/timescaledb/repositories/realtime.py +++ b/app/infra/db/timescaledb/repositories/realtime.py @@ -3,9 +3,28 @@ from datetime import datetime, timedelta from collections import defaultdict from psycopg import AsyncConnection, Connection, sql from app.domain.time import parse_utc_time +from app.core.config import settings class RealtimeRepository: + # The third set_config argument makes this transaction-local, so a pooled + # connection returns to its original guard after commit or rollback. + SNAPSHOT_BUDGET_SQL = "SELECT set_config('timescaledb.max_tuples_decompressed_per_dml_transaction', %s, true)" + + @staticmethod + async def _configure_snapshot_write(cur) -> None: + await cur.execute( + RealtimeRepository.SNAPSHOT_BUDGET_SQL, + (str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),), + ) + + @staticmethod + def _configure_snapshot_write_sync(cur) -> None: + cur.execute( + RealtimeRepository.SNAPSHOT_BUDGET_SQL, + (str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),), + ) + LINK_FIELDS = frozenset( { "flow", @@ -93,6 +112,7 @@ class RealtimeRepository: # 使用事务确保原子性 async with conn.transaction(): async with conn.cursor() as cur: + await RealtimeRepository._configure_snapshot_write(cur) await cur.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", (target_time,), @@ -116,6 +136,7 @@ class RealtimeRepository: # 使用事务确保原子性 with conn.transaction(): with conn.cursor() as cur: + RealtimeRepository._configure_snapshot_write_sync(cur) cur.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", (target_time,), @@ -163,6 +184,7 @@ class RealtimeRepository: end_time: datetime, link_ids: list[str], field: str, + *, row_limit: int | None = None, ) -> dict[str, list[dict[str, Any]]]: if field not in RealtimeRepository.LINK_FIELDS: raise ValueError(f"Invalid field: {field}") @@ -174,8 +196,10 @@ class RealtimeRepository: "WHERE time BETWEEN %s AND %s AND link_id = ANY(%s) " "ORDER BY link_id, time" ).format(sql.Identifier(field)) + if row_limit is not None: + query += sql.SQL(" LIMIT %s") async with conn.cursor() as cur: - await cur.execute(query, (start_time, end_time, link_ids)) + await cur.execute(query, (start_time, end_time, link_ids) + ((row_limit,) if row_limit is not None else ())) for row in await cur.fetchall(): result.setdefault(str(row["link_id"]), []).append( {"time": row["time"].isoformat(), "value": row[field]} @@ -282,6 +306,7 @@ class RealtimeRepository: # 使用事务确保原子性 async with conn.transaction(): async with conn.cursor() as cur: + await RealtimeRepository._configure_snapshot_write(cur) await cur.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", (target_time,), @@ -304,6 +329,7 @@ class RealtimeRepository: # 使用事务确保原子性 with conn.transaction(): with conn.cursor() as cur: + RealtimeRepository._configure_snapshot_write_sync(cur) cur.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", (target_time,), @@ -351,6 +377,7 @@ class RealtimeRepository: end_time: datetime, node_ids: list[str], field: str, + *, row_limit: int | None = None, ) -> dict[str, list[dict[str, Any]]]: if field not in RealtimeRepository.NODE_FIELDS: raise ValueError(f"Invalid field: {field}") @@ -362,8 +389,10 @@ class RealtimeRepository: "WHERE time BETWEEN %s AND %s AND node_id = ANY(%s) " "ORDER BY node_id, time" ).format(sql.Identifier(field)) + if row_limit is not None: + query += sql.SQL(" LIMIT %s") async with conn.cursor() as cur: - await cur.execute(query, (start_time, end_time, node_ids)) + await cur.execute(query, (start_time, end_time, node_ids) + ((row_limit,) if row_limit is not None else ())) for row in await cur.fetchall(): result.setdefault(str(row["node_id"]), []).append( {"time": row["time"].isoformat(), "value": row[field]} @@ -482,6 +511,7 @@ class RealtimeRepository: # table. Copy-only helpers avoid repeating replacement SQL. async with conn.transaction(): async with conn.cursor() as cur: + await RealtimeRepository._configure_snapshot_write(cur) await cur.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", (simulation_time,), @@ -559,6 +589,7 @@ class RealtimeRepository: # table. Copy-only helpers avoid repeating replacement SQL. with conn.transaction(): with conn.cursor() as cur: + RealtimeRepository._configure_snapshot_write_sync(cur) cur.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", (simulation_time,), diff --git a/app/infra/db/timescaledb/repositories/scada.py b/app/infra/db/timescaledb/repositories/scada.py index 4679678..8f41c65 100644 --- a/app/infra/db/timescaledb/repositories/scada.py +++ b/app/infra/db/timescaledb/repositories/scada.py @@ -34,13 +34,15 @@ class ScadaRepository: device_ids: List[str], start_time: datetime, end_time: datetime, + *, row_limit: int | None = None, ) -> List[dict]: async with conn.cursor() as cur: await cur.execute( f"SELECT {ScadaRepository.RESULT_COLUMNS} FROM scada.measurements " "WHERE device_id = ANY(%s) " - "AND time >= %s AND time <= %s ORDER BY device_id, time", - (device_ids, start_time, end_time), + "AND time >= %s AND time <= %s ORDER BY device_id, time" + + (" LIMIT %s" if row_limit is not None else ""), + (device_ids, start_time, end_time) + ((row_limit,) if row_limit is not None else ()), ) return await cur.fetchall() diff --git a/app/main.py b/app/main.py index dfaa603..f7828b5 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,12 @@ from fastapi import FastAPI -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, AsyncExitStack from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.cors import CORSMiddleware import logging from datetime import datetime from app.api.problem_details import install_problem_details_handlers +from app.api.health import router as health_router from app.api.v1.rest_router import api_router from app.infra.db.dynamic_manager import project_connection_manager from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs @@ -24,13 +25,21 @@ logger.setLevel(logging.INFO) @asynccontextmanager async def lifespan(app: FastAPI): + app.state.startup_complete = False logger.info("**********************************************************") logger.info(str(datetime.now())) logger.info("TJWater CloudService is starting...") logger.info("**********************************************************") try: - yield + async with AsyncExitStack() as stack: + if mcp_runtime is not None: + await stack.enter_async_context(mcp_runtime.lifespan()) + app.state.startup_complete = True + try: + yield + finally: + app.state.startup_complete = False finally: await workbench_event_hubs.close_all() await project_connection_manager.close_all() @@ -40,6 +49,14 @@ async def lifespan(app: FastAPI): logger.info("Database connections closed") +# Disabled deployments do not import the MCP SDK or acquire business credentials. +from app.mcp.config import McpSettings +mcp_settings = McpSettings() +mcp_runtime = None +if mcp_settings.enabled: + from app.mcp.application import McpQueryApplication + mcp_runtime = McpQueryApplication(mcp_settings) + # 根据环境配置决定是否启用文档 is_production = settings.ENVIRONMENT.lower() == "production" @@ -56,8 +73,13 @@ app = FastAPI( # Include Routers +app.state.startup_complete = False +app.include_router(health_router) app.include_router(api_router, prefix="/api/v1") install_problem_details_handlers(app) +from app.api.v1.endpoints.mcp_queries import router as mcp_context_router +app.state.mcp_runtime = mcp_runtime +app.include_router(mcp_context_router, prefix="/api/v1") # Legacy routers without version prefix # app.include_router(api_router) @@ -65,6 +87,9 @@ install_problem_details_handlers(app) app.add_middleware(GZipMiddleware, minimum_size=1000) # 添加审计中间件(可选,记录关键操作) app.add_middleware(AuditMiddleware) +if mcp_runtime is not None: + from app.mcp.dispatch import McpDispatchMiddleware + app.add_middleware(McpDispatchMiddleware, runtime=mcp_runtime) # 配置 CORS 中间件 # 确保这是你最后一个添加的 app.add_middleware app.add_middleware( diff --git a/app/mcp/__init__.py b/app/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/mcp/analysis_models.py b/app/mcp/analysis_models.py new file mode 100644 index 0000000..ec4b731 --- /dev/null +++ b/app/mcp/analysis_models.py @@ -0,0 +1,139 @@ +"""Bounded calculation inputs; identity, routing and output paths are host-owned.""" + +from datetime import datetime, timedelta +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import Field, model_validator + +from app.mcp.models import Id, StrictInput + + +MAX_CALCULATION_TIMEOUT_SECONDS = 86400 + + +class CalculationInput(StrictInput): + timeout_seconds: int = Field( + default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True, + description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚", + ) + + +class BurstDetectionInput(CalculationInput): + target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位") + sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器") + sampling_interval_minutes: int = Field(default=15, ge=1, le=60) + + @model_validator(mode="after") + def valid_target(self): + if self.target_time.tzinfo is None: + raise ValueError("target_time requires explicit timezone") + if 1440 % self.sampling_interval_minutes: + raise ValueError("sampling interval must divide a day") + if len(set(self.sensor_nodes)) != len(self.sensor_nodes): + raise ValueError("sensor_nodes must be distinct") + return self + + +class BurstLocationInput(CalculationInput): + data_source: Literal["monitoring", "simulation"] = "monitoring" + simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线") + burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测") + pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID") + flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号") + scada_burst_start: datetime + scada_burst_end: datetime + scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,不代表该时段已确认正常") + scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式不填写,使用同时间窗模型基线") + + @model_validator(mode="after") + def valid_windows(self): + pairs = [(self.scada_burst_start, self.scada_burst_end)] + if self.data_source == "monitoring": + if self.simulation_run_id is not None: + raise ValueError("monitoring cannot use simulation_run_id") + if self.scada_normal_start is not None or self.scada_normal_end is not None: + pairs.append((self.scada_normal_start, self.scada_normal_end)) + elif self.simulation_run_id is None or self.scada_normal_start is not None or self.scada_normal_end is not None: + raise ValueError("simulation requires run_id and uses the same-window model baseline") + for start, end in pairs: + if start is None or end is None or start.tzinfo is None or end.tzinfo is None: + raise ValueError("explicit timezone-aware burst and normal windows are required") + if not timedelta(0) < end - start <= timedelta(days=1): + raise ValueError("each window must be positive and at most 24 hours") + for ids in (self.pressure_scada_ids, self.flow_scada_ids): + if ids is not None and len(set(ids)) != len(ids): + raise ValueError("sensor IDs must be distinct") + return self + + +class ValveIsolationInput(CalculationInput): + accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID") + disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用") + + +class LeakageIdentificationInput(CalculationInput): + scada_start: datetime + scada_end: datetime + sensor_nodes: list[Id] = Field(min_length=1, max_length=20) + q_sum: float = Field(gt=0, allow_inf_nan=False, description="有依据的总漏损流量约束,m³/s,不得使用无依据默认值") + start_time: float = Field(ge=0, lt=24, allow_inf_nan=False, description="模型起算小时;需与SCADA时间窗及模型时钟对齐") + timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应") + dma_count: int = Field(ge=1, le=20) + + @model_validator(mode="after") + def valid_window(self): + if self.scada_start.tzinfo is None or self.scada_end.tzinfo is None: + raise ValueError("timestamps require explicit timezone") + duration = self.scada_end - self.scada_start + if not timedelta(0) < duration <= timedelta(days=1): + raise ValueError("window must be positive and at most 24 hours") + if duration.total_seconds() % (self.timestep * 60): + raise ValueError("window must contain whole calculation steps") + if len(set(self.sensor_nodes)) != len(self.sensor_nodes): + raise ValueError("sensor_nodes must be distinct") + return self + + +class ScenarioInput(CalculationInput): + start_time: datetime + duration: int = Field(default=900, ge=60, le=86400, description="模拟时长,秒;首次优先短时间窗") + output_node_ids: list[Id] = Field(min_length=1, max_length=20, description="需要返回结果的节点;不把局部结果解释为全网无影响") + output_link_ids: list[Id] = Field(default_factory=list, max_length=20) + + @model_validator(mode="after") + def timezone_required(self): + if self.start_time.tzinfo is None: + raise ValueError("start_time requires explicit timezone") + return self + + +class ContaminantSimulationInput(ScenarioInput): + source: Id = Field(description="假设污染源节点;这是正向传播模拟,不是自动反演污染源") + concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L") + pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;不填为持续恒定源") + + +class ValveClosureInput(ScenarioInput): + valves: list[Id] = Field(min_length=1, max_length=20, description="在临时模型中关闭的阀门,不下发现场控制") + + +class FlushingInput(ScenarioInput): + drainage_node_id: Id + flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;不使用无依据的自动排放量") + closed_valves: list[Id] = Field(default_factory=list, max_length=20) + + +NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)] +PumpSeries = Annotated[list[NonnegativeValue], Field(min_length=1, max_length=96)] + + +class PressureRegulationInput(ScenarioInput): + pump_control: dict[Id, PumpSeries] = Field(min_length=1, max_length=20, description="已登记泵ID及运行序列;工频值为0/1,变频值为频率Hz,每项对应模型水力步长,不自动优化") + tank_init_level: dict[Id, Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=20, description="水箱初始水位,须在模型允许范围内") + + +class SensorPlacementInput(CalculationInput): + method: Literal["sensitivity", "kmeans"] + sensor_count: int = Field(ge=1, le=20) + min_diameter: int = Field(default=0, ge=0, le=5000, description="最小管径,mm") diff --git a/app/mcp/analysis_service.py b/app/mcp/analysis_service.py new file mode 100644 index 0000000..2a8bf42 --- /dev/null +++ b/app/mcp/analysis_service.py @@ -0,0 +1,69 @@ +"""Side-effect-limited adapters for existing domain calculations.""" + +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 + +# These services synchronously read SCADA/model series or write online results. +# Topology-only isolation and model-based sensor placement need only BizDB. +TIMESCALE_CALCULATIONS = SCENARIO_TOOLS | frozenset({ + "run_burst_detection", "run_burst_location", "run_leakage_identification", +}) + +# A timed-out HTTP request cannot kill a native algorithm thread. Keep its slot +# until the calculation really exits, so retries cannot accumulate CPU jobs. +_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") + try: + if name in SCENARIO_TOOLS: + return calculate_scenario(name, parameters, network=network, username=username) + if name == "optimize_sensor_placement": + from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity + parameters = dict(parameters) + method = parameters.pop("method") + optimizer = optimize_sensor_placement_by_sensitivity if method == "sensitivity" else optimize_sensor_placement_by_kmeans + return optimizer(project_code=network, run_name="MCP preview", created_by=username, + persist_results=False, **parameters) + if name == "run_leakage_identification": + from app.services.dma_leakage_estimation import run_leakage_identification + + duration = (parameters["scada_end"] - parameters["scada_start"]).total_seconds() / 3600 + return run_leakage_identification( + network=network, username=username, scheme_name=None, + duration=duration, q_sum_unit="m3/s", output_flow_unit="m3/s", + n_workers=1, pop_size=50, max_gen=100, **parameters, + ) + if name == "run_burst_detection": + from app.services.burst_detection import run_burst_detection + + return run_burst_detection(network=network, username=username, scheme_name=None, **parameters) + if name == "run_burst_location": + from app.services.burst_location import run_burst_location_by_network + + return run_burst_location_by_network( + network=network, username=username, scheme_name=None, + final_candidates_csv_path=None, **parameters, + ) + if name == "analyze_valve_isolation": + from app.services.valve_isolation import analyze_valve_isolation + + return analyze_valve_isolation(network=network, **parameters) + raise ValueError("Calculation is not registered") + finally: + _slots.release() + + +async def calculate(name: str, args: BaseModel, *, network: str, username: str) -> dict[str, Any]: + # to_thread copies the validated project's routing context. Never accept + # database routes, actor identity, arbitrary measurements or paths from MCP. + return await asyncio.to_thread( + _calculate, name, args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), network, username, + ) diff --git a/app/mcp/application.py b/app/mcp/application.py new file mode 100644 index 0000000..94a8f8e --- /dev/null +++ b/app/mcp/application.py @@ -0,0 +1,505 @@ +"""Official SDK transport with product authorization, persistence and query handlers.""" + +import asyncio +import json +import logging +from contextlib import asynccontextmanager +from urllib.parse import urlsplit +from uuid import uuid5, NAMESPACE_URL +from typing import Any + +from fastapi import HTTPException +from mcp import types +from mcp.server import Server +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.settings import AuthSettings +from mcp.server.auth.routes import create_protected_resource_routes +from mcp.server.caching import CacheHint +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.exceptions import MCPError +from psycopg.errors import UndefinedTable, InvalidSchemaName +from pydantic import ValidationError +from starlette.responses import JSONResponse + +from app.mcp.auth import ( + McpTokenVerifier, + QueryScopeMiddleware, + QUERY_SCOPE, + CONTEXT_SCOPE, + challenge, +) +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.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 + +logger = logging.getLogger(__name__) + + +def tool_error(code, message): + return types.CallToolResult( + isError=True, + content=[types.TextContent(type="text", text=message)], + structuredContent={"error": {"code": code, "message": message}}, + ) + + +class ModernOnlyMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or scope.get("path") != "/mcp": + return await self.app(scope, receive, send) + if scope["method"] in {"GET", "DELETE"}: + return await JSONResponse( + {"error": "modern_protocol_required"}, + status_code=405, + headers={"Allow": "POST"}, + )(scope, receive, send) + headers = { + k.decode("latin1").lower(): v.decode("latin1") + for k, v in scope.get("headers", []) + } + version = headers.get("mcp-protocol-version") + if version != "2026-07-28": + body = bytearray() + request_id = None + body_version = None + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + body.extend(message.get("body", b"")) + if len(body) > 1024 * 1024: + return await JSONResponse( + {"error": "request_too_large"}, status_code=413 + )(scope, receive, send) + if not message.get("more_body"): + break + try: + payload = json.loads(body) + request_id = payload.get("id") + body_version = payload.get("params", {}).get("_meta", {}).get( + types.PROTOCOL_VERSION_META_KEY + ) + except (ValueError, AttributeError): + pass + mismatch = not version or ( + isinstance(body_version, str) and body_version != version + ) + code = -32020 if mismatch else -32022 + result = { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": code, + "message": ( + "MCP-Protocol-Version header is missing or differs from request metadata" + if mismatch else "MCP 2026-07-28 is required" + ), + "data": {"supported": ["2026-07-28"], "requested": version}, + }, + } + return await JSONResponse(result, status_code=400)(scope, receive, send) + await self.app(scope, receive, send) + + +class McpQueryApplication: + def __init__( + self, + settings: McpSettings, + *, + verifier=None, + scope_factory=project_scope, + executor=execute_query, + ): + self.settings = settings + self.verifier = verifier or McpTokenVerifier(settings) + self.scope_factory = scope_factory + self.executor = executor + self.active = {} + self.ready = False + self.server = Server( + "tjwater-query", + version="1.0.0", + instructions="Project-bound water queries. Model baselines are not observations. Truncated results do not cover the full range.", + cache_hints={ + "server/discover": CacheHint(300000, "public"), + "tools/list": CacheHint(300000, "public"), + }, + on_list_tools=self.list_tools, + on_call_tool=self.call_tool, + get_tool_input_schema=self.tool_input_schema, + on_list_resources=self.list_resources, + on_list_resource_templates=self.list_templates, + on_read_resource=self.read_resource, + ) + self.server.add_request_handler( + "server/discover", types.RequestParams, self.discover + ) + app = self.server.streamable_http_app( + stateless_http=True, + json_response=True, + max_request_body_size=1024 * 1024, + auth=AuthSettings( + issuer_url=settings.issuer_uri, + resource_server_url=settings.resource_uri, + required_scopes=["mcp.catalog.read"], + validate_token_resource=True, + ), + token_verifier=self.verifier, + transport_security=TransportSecuritySettings( + allowed_hosts=settings.allowed_hosts, + allowed_origins=settings.allowed_origins, + ), + ) + # Catalog authentication and advertised resource scopes are different concerns. + metadata = create_protected_resource_routes( + resource_url=settings.resource_uri, + authorization_servers=[settings.issuer_uri], + scopes_supported=["mcp.catalog.read", QUERY_SCOPE, CONTEXT_SCOPE], + resource_name="TJWater Query", + ) + metadata_path = metadata[0].path + app.router.routes = [ + route for route in app.router.routes if route.path != metadata_path + ] + metadata + self.asgi = ModernOnlyMiddleware( + QueryScopeMiddleware(app, self.verifier, settings) + ) + from app.api.v1.endpoints.mcp_queries import router + + self.router = router + + @asynccontextmanager + async def lifespan(self): + async with self.server.session_manager.run(): + self.ready = True + try: + yield + finally: + self.ready = False + + async def discover(self, ctx, params): + return types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=self.server.get_capabilities( + protocol_version=ctx.protocol_version, + extensions={"io.modelcontextprotocol/oauth-client-credentials": {}}, + ), + instructions=self.server.instructions, + ) + + def tool_input_schema(self, name: str) -> dict[str, Any] | None: + tool = TOOLS.get(name) + if tool is None: + return None + schema = tool.model.model_json_schema() + if tool.computation: + schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds + return schema + + async def list_tools(self, ctx, params): + if params and params.cursor: + raise MCPError(-32602, "The query catalog has no continuation page") + tools = [] + for name in sorted(TOOLS): + tool = TOOLS[name].protocol_tool() + tool.input_schema.update(self.tool_input_schema(name)) + tools.append(tool) + return types.ListToolsResult( + tools=tools, + ttlMs=300000, + cacheScope="public", + ) + + async def list_resources(self, ctx, params): + return types.ListResourcesResult(resources=[], ttlMs=0, cacheScope="private") + + async def list_templates(self, ctx, params): + return types.ListResourceTemplatesResult( + resourceTemplates=[ + types.ResourceTemplate( + name="task-evidence", + uriTemplate="tjwater://tasks/{task_id}/evidence/{evidence_id}", + mimeType="application/json", + ) + ], + ttlMs=300000, + cacheScope="public", + ) + + async def read_resource(self, ctx, params): + meta = ctx.meta or {} + handle = meta.get("io.tjwater/contextHandle") + url = urlsplit(str(params.uri)) + parts = url.path.strip("/").split("/") + if ( + url.scheme != "tjwater" + or url.netloc != "tasks" + or len(parts) != 3 + or parts[1] != "evidence" + or url.query + or url.fragment + ): + raise MCPError(-32602, "Unknown query resource") + result = await self._query( + ctx, + "get_task_evidence", + {"context_handle": handle, "evidence_id": parts[2]}, + resource_task=parts[0], + include_full=True, + ) + if result.isError: + raise MCPError( + -32602, "Evidence is unavailable in this authorization context" + ) + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=str(params.uri), + mimeType="application/json", + text=json.dumps(result.structuredContent, ensure_ascii=False), + ) + ], + ttlMs=0, + cacheScope="private", + ) + + async def call_tool(self, ctx, params): + return await self._query(ctx, params.name, params.arguments or {}) + + async def _query( + self, ctx, name, arguments, resource_task=None, include_full=False + ): + tool = TOOLS.get(name) + if tool is None: + raise MCPError(-32602, "Unknown query tool") + principal = get_access_token() + if principal is None or QUERY_SCOPE not in principal.scopes: + return tool_error("PERMISSION_DENIED", "Query permission is required") + try: + args = tool.model.model_validate(arguments) + except ValidationError: + return tool_error( + "INVALID_QUERY", "Query parameters do not match the tool schema" + ) + timeout_seconds = ( + args.timeout_seconds if tool.computation else self.settings.query_timeout_seconds + ) + if tool.computation 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 "" + meta = ctx.meta or {} + call_id = meta.get("io.tjwater/callId") + if not isinstance(call_id, str) or not 0 < len(call_id) <= 128: + return tool_error( + "CALL_ID_REQUIRED", "A host-owned call identifier is required" + ) + key = (principal.subject, project) + if self.active.get(key, 0) >= 4: + return tool_error( + "RATE_LIMITED", "At most four queries may run concurrently" + ) + self.active[key] = self.active.get(key, 0) + 1 + try: + async with ( + asyncio.timeout(timeout_seconds), + self.scope_factory(principal, project) as scope, + ): + binding = await resolve_context(scope, principal, args.context_handle) + if resource_task is not None and str(binding.task_id) != resource_task: + raise HTTPException(403, "Resource task mismatch") + await authorize_query(scope, binding, tool, args) + repo = McpQueryRepository(scope.connection, scope.project.project_id) + parameters = { + "tool": name, + "arguments": args.model_dump( + mode="json", exclude={"context_handle"} + ), + } + scope_key = json.dumps( + [ + binding.subject, + binding.client_id, + binding.native_session_id, + str(binding.task_id), + str(binding.run_id), + str(binding.execution_request_id), + ], + separators=(",", ":"), + ) + previous = ( + await repo.receipt(scope_key, call_id) if binding.task_id else None + ) + if previous: + if previous["parameters"] != parameters: + raise HTTPException( + 409, "Call identifier has different query parameters" + ) + result = QueryResult.model_validate(previous["result"]) + else: + result = ( + await self.executor( + scope, binding, tool, args, full_snapshot=True + ) + if include_full + else await self.executor(scope, binding, tool, args) + ) + # Never serialize an unbounded response into a model message or database receipt. + if len(result.model_dump_json().encode()) > 8 * 1024 * 1024: + return tool_error( + "RESULT_TOO_LARGE", + "Narrow the query or request a smaller page", + ) + if binding.task_id and name not in { + "get_task_context", + "get_task_evidence", + }: + async with scope.connection.transaction(): + await repo.lock_receipt(scope_key, call_id) + previous = await repo.receipt(scope_key, call_id) + if previous: + if previous["parameters"] != parameters: + raise HTTPException( + 409, + "Call identifier has different query parameters", + ) + result = QueryResult.model_validate(previous["result"]) + else: + task_repo = task_service(scope).repo + # Lock the task row only after external/data-source work has finished. + await task_repo.lock(f"task:{binding.task_id}") + await verify_task(scope, binding) + result.source_ref = "mcp:" + str( + uuid5(NAMESPACE_URL, scope_key + ":" + call_id) + ) + snapshot = TaskDataSnapshot( + source=name, + source_ref=result.source_ref, + queried_at=result.queried_at, + origin=result.origin, + data=result.model_dump(mode="json"), + truncated=result.truncated, + ) + evidence, _ = await task_service( + scope + ).record_data_snapshot(binding.task_id, snapshot) + result.evidence_id = evidence.evidence_id + await repo.save_receipt( + scope_key, + call_id, + parameters, + result.model_dump(mode="json"), + ) + output = result.model_dump(mode="json") + content = [] + if ( + not include_full + and len(json.dumps(output, ensure_ascii=False).encode()) > 12000 + ): + evidence_id = result.evidence_id or ( + args.evidence_id if name == "get_task_evidence" else None + ) + if binding.task_id and evidence_id: + uri = ( + f"tjwater://tasks/{binding.task_id}/evidence/{evidence_id}" + ) + output["data"] = { + "resource_uri": uri, + "preview": "The complete query snapshot is saved in task evidence", + } + output["truncated"] = True + output["data_gaps"] = [ + *result.data_gaps, + "Inline result abbreviated; read the saved evidence resource", + ] + content.append( + types.ResourceLink( + type="resource_link", + uri=uri, + name="query-evidence", + mimeType="application/json", + ) + ) + else: + return tool_error( + "RESULT_TOO_LARGE", + "Request a smaller page or narrower window", + ) + content.insert( + 0, + types.TextContent( + type="text", text=json.dumps(output, ensure_ascii=False) + ), + ) + return types.CallToolResult(content=content, structuredContent=output) + except HTTPException as exc: + codes = { + 401: "AUTH_REQUIRED", + 403: "QUERY_CONTEXT_DENIED", + 404: "NOT_FOUND", + 409: "QUERY_CONTEXT_STALE", + 413: "RESULT_TOO_LARGE", + 422: "INVALID_QUERY", + 503: "SOURCE_UNAVAILABLE", + } + return tool_error( + codes.get(exc.status_code, "QUERY_FAILED"), + str(exc.detail) + if isinstance(exc.detail, str) + and exc.status_code in {403, 404, 409, 413, 422} + else "Query is unavailable", + ) + except (UndefinedTable, InvalidSchemaName): + return tool_error( + "MCP_STORAGE_UNAVAILABLE", + "MCP project storage has not been provisioned", + ) + except TimeoutError: + return tool_error("QUERY_TIMEOUT", "Query execution timed out") + except ValueError: + return tool_error( + "INVALID_QUERY", "Invalid query value or incompatible source data" + ) + except Exception: + logger.warning( + "MCP query unavailable", extra={"tool": name, "project_id": project} + ) + return tool_error("SOURCE_UNAVAILABLE", "Query source is unavailable") + finally: + remaining = self.active[key] - 1 + if remaining: + self.active[key] = remaining + else: + self.active.pop(key, None) + + async def identity(self, request): + auth = request.headers.get("authorization", "") + try: + principal = ( + await self.verifier.verify_token(auth[7:]) + if auth.lower().startswith("bearer ") + else None + ) + except Exception: + raise HTTPException(503, "OAuth key verification is unavailable") from None + if not principal: + raise HTTPException( + 401, + "MCP authentication required", + headers=challenge(self.settings, CONTEXT_SCOPE), + ) + if CONTEXT_SCOPE not in principal.scopes: + raise HTTPException( + 403, + "MCP context scope required", + headers=challenge(self.settings, CONTEXT_SCOPE, insufficient=True), + ) + return principal diff --git a/app/mcp/auth.py b/app/mcp/auth.py new file mode 100644 index 0000000..5143253 --- /dev/null +++ b/app/mcp/auth.py @@ -0,0 +1,169 @@ +"""Verify resource-bound Keycloak tokens without passing them to business APIs.""" + +import asyncio +import time +from uuid import UUID + +import httpx +from jose import JWTError, jwt +from mcp.server.auth.provider import AccessToken +from starlette.responses import JSONResponse + +from app.mcp.config import McpSettings + +CATALOG_SCOPE = "mcp.catalog.read" +QUERY_SCOPE = "mcp.query" +CONTEXT_SCOPE = "mcp.context.bind" + + +class McpTokenVerifier: + def __init__(self, settings: McpSettings, client: httpx.AsyncClient | None = None): + self.settings = settings + self.client = client + self._keys: list[dict] = [] + self._expires = 0.0 + self._last_refresh = 0.0 + self._lock = asyncio.Lock() + + async def _load_keys(self, kid: str) -> list[dict]: + now = time.monotonic() + if self._expires > now and any(k.get("kid") == kid for k in self._keys): + return self._keys + async with self._lock: + now = time.monotonic() + if self._expires > now and any(k.get("kid") == kid for k in self._keys): + return self._keys + if self._expires > now and now - self._last_refresh < 20: + return self._keys + url = ( + self.settings.issuer_uri.rstrip("/") + "/protocol/openid-connect/certs" + ) + self._last_refresh = now + if self.client: + response = await self.client.get(url) + else: + async with httpx.AsyncClient( + timeout=5, trust_env=False, follow_redirects=False + ) as client: + response = await client.get(url) + response.raise_for_status() + try: + payload = response.json() + except ValueError: + raise httpx.RequestError("invalid OAuth key set response") from None + keys = payload.get("keys") if isinstance(payload, dict) else None + if not isinstance(keys, list) or len(keys) > 100: + raise httpx.RequestError("invalid OAuth key set") + self._keys = [ + k + for k in keys + if isinstance(k, dict) + and k.get("kty") == "RSA" + and k.get("use", "sig") == "sig" + and k.get("alg", "RS256") == "RS256" + ] + self._expires = now + 300 + return self._keys + + async def verify_token(self, token: str) -> AccessToken | None: + if not token or len(token) > 16384: + return None + try: + header = jwt.get_unverified_header(token) + kid = header.get("kid") + if ( + header.get("alg") != "RS256" + or not isinstance(kid, str) + or not 0 < len(kid) <= 128 + ): + return None + keys = await self._load_keys(kid) + key = next((k for k in keys if k.get("kid") == kid), None) + if key is None: + return None + claims = jwt.decode( + token, + key, + algorithms=["RS256"], + audience=self.settings.audience, + issuer=self.settings.issuer_uri, + options={"require_exp": True, "require_iat": True, "require_sub": True}, + ) + UUID(claims["sub"]) + client_id = claims.get("azp") + if not isinstance(client_id, str) or not client_id: + return None + if ( + not isinstance(claims.get("iat"), (int, float)) + or not time.time() - 900 < claims["iat"] <= time.time() + 30 + or claims["exp"] > claims["iat"] + 900 + ): + return None + return AccessToken( + token=token, + client_id=client_id, + subject=claims["sub"], + scopes=str(claims.get("scope", "")).split(), + expires_at=int(claims["exp"]), + resource=self.settings.resource_uri, + claims=claims, + ) + except (JWTError, ValueError, KeyError, TypeError): + return None + # JWKS/network failures propagate as unavailable, never as empty data. + + +def challenge( + settings: McpSettings, scope: str, *, insufficient: bool = False +) -> dict[str, str]: + from mcp.server.auth.routes import build_resource_metadata_url + + metadata = build_resource_metadata_url(settings.resource_uri) + suffix = ', error="insufficient_scope"' if insufficient else "" + return { + "WWW-Authenticate": f'Bearer resource_metadata="{metadata}", scope="{scope}"{suffix}' + } + + +class QueryScopeMiddleware: + """HTTP scope challenges before JSON-RPC dispatch; SDK validates mirrored headers.""" + + def __init__(self, app, verifier: McpTokenVerifier, settings: McpSettings): + self.app, self.verifier, self.settings = app, verifier, settings + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or scope.get("path") != "/mcp": + return await self.app(scope, receive, send) + headers = { + k.decode("latin1").lower(): v.decode("latin1") + for k, v in scope.get("headers", []) + } + auth = headers.get("authorization", "") + try: + token = ( + await self.verifier.verify_token(auth[7:]) + if auth.lower().startswith("bearer ") + else None + ) + except (httpx.HTTPError, ValueError): + return await JSONResponse( + {"error": "authentication_unavailable"}, status_code=503 + )(scope, receive, send) + required = ( + QUERY_SCOPE + if headers.get("mcp-method") in {"tools/call", "resources/read"} + else CATALOG_SCOPE + ) + if token is None: + return await JSONResponse( + {"error": "invalid_token"}, + status_code=401, + headers=challenge(self.settings, required), + )(scope, receive, send) + if required not in token.scopes: + return await JSONResponse( + {"error": "insufficient_scope"}, + status_code=403, + headers=challenge(self.settings, required, insufficient=True), + )(scope, receive, send) + await self.app(scope, receive, send) diff --git a/app/mcp/catalog.py b/app/mcp/catalog.py new file mode 100644 index 0000000..d0e98a0 --- /dev/null +++ b/app/mcp/catalog.py @@ -0,0 +1,221 @@ +"""One finite query catalog; names, schemas and permissions come from this registry.""" + +from dataclasses import dataclass +from pydantic import BaseModel +from mcp import types +from app.mcp import models as m +from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput +from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput + + +@dataclass(frozen=True) +class QueryTool: + name: str + description: str + model: type[BaseModel] + permission: str + semantics: str + task_only: bool = False + computation: bool = False + + def protocol_tool(self) -> types.Tool: + if self.name == "run_simulation": + suffix = "。任务调用复用证据留存。" + elif self.computation: + suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。" + elif self.name == "query_nearby_elements": + suffix = "。仅查询当前已授权项目的模型管网,按距离和元素ID稳定排序;按需分页,不自动遍历全量;空间邻近不代表管网连通或供水关系。" + else: + suffix = ( + "。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大," + "不要自动遍历全量;截断或非零offset不能证明完整范围无异常。" + ) + return types.Tool( + name=self.name, + description=self.description + suffix, + inputSchema=self.model.model_json_schema(), + outputSchema=m.QueryResult.model_json_schema(), + annotations=types.ToolAnnotations( + readOnlyHint=False, destructiveHint=self.name == "run_simulation", openWorldHint=False + ), + ) + + +_specs = [ + ("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,不复制数据库;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,不创建方案或正式分析运行,结果不是实测", ScenarioInput, "simulation.run", "analysis", False, True), + ("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,不反演污染源,不代表实测污染范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True), + ("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;不操作真实阀门", ValveClosureInput, "simulation.run", "analysis", False, True), + ("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;不是现场冲洗或消毒合格证明", FlushingInput, "simulation.run", "analysis", False, True), + ("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;不自动优化调度,不实际控制泵", PressureRegulationInput, "simulation.run", "analysis", False, True), + ("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True), + ("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True), + ("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True), + ("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;需有依据的漏水量假设。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True), + ("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True), + ( + "get_element_properties", + "读取当前项目的单个管网元素属性", + m.ElementInput, + "webgis.view", + "asset", + ), + ( + "list_elements", + "分页读取管网元素,不自动读取全网", + m.ElementPageInput, + "webgis.view", + "asset", + ), + ( + "query_nearby_elements", + "查询坐标附近的模型元素,默认pipe;center为EPSG:4326 [经度,纬度],可直接使用geocode.center;radius_m默认200米、最大10000米,limit默认5、最大100,支持offset。按点到完整几何的WGS84椭球最短距离筛选并排序,返回element_id、distance_m和属性;不查询原始GIS管线、不自动扩大半径、不解析道路交叉点", + m.NearbyElementsInput, + "webgis.view", + "asset", + ), + ( + "get_network_options", + "读取模型时间、能源或网络选项", + m.OptionsInput, + "webgis.view", + "asset", + ), + ( + "get_network_schema", + "读取当前支持的元素、SCADA和选项结构", + m.SchemaInput, + "webgis.view", + "asset", + ), + ( + "list_scada_devices", + "分页读取SCADA设备和物理单位", + m.PageInput, + "scada.view", + "asset", + ), + ("get_scada_device", "读取一个SCADA设备", m.DeviceInput, "scada.view", "asset"), + ( + "query_scada_readings", + "查询带时区且最多24小时的原始或清洗监测数据", + m.ScadaInput, + "scada.view", + "observed", + ), + ( + "query_model_baseline_series", + "读取最多20个元素的模型基线,不能称为实测", + m.BaselineSeriesInput, + "simulation.view", + "model_baseline", + ), + ( + "get_model_baseline_at_time", + "读取单个元素指定时刻的模型基线", + m.BaselineAtTimeInput, + "simulation.view", + "model_baseline", + ), + ( + "query_model_baseline_field", + "分页读取指定时刻的模型字段", + m.BaselineFieldInput, + "simulation.view", + "model_baseline", + ), + ( + "query_analysis_series", + "读取指定分析运行和元素字段时序", + m.AnalysisSeriesInput, + "simulation.view", + "analysis", + ), + ( + "query_analysis_values", + "分页读取指定分析运行某时刻的元素字段", + m.AnalysisValuesInput, + "simulation.view", + "analysis", + ), + ( + "list_analysis_runs", + "分页读取持久分析运行", + m.RunsInput, + "webgis.view", + "analysis", + ), + ("get_analysis_run", "读取单个持久分析运行", m.RunInput, "webgis.view", "analysis"), + ( + "list_analysis_results", + "分页读取分析成果,可筛选result_type", + m.ResultsInput, + "webgis.view", + "analysis", + ), + ( + "list_sensor_placement_runs", + "分页读取已有监测点选址运行", + m.PageInput, + "optimization.run", + "analysis", + ), + ( + "get_sensor_placement_run", + "读取已有监测点选址成果", + m.RunInput, + "optimization.run", + "analysis", + ), + ( + "query_element_history", + "比较实测与模型基线或分析结果;analysis_comparison要求run_id;limit/offset按序列、时间排序的数据点分页", + m.HistoryInput, + "scada.view", + "observed", + ), + ( + "get_element_gis_properties", + "读取元素关联GIS台账", + m.ElementInput, + "webgis.view", + "asset", + ), + ( + "get_source_gis_properties", + "读取指定图层源要素台账", + m.SourceGisInput, + "webgis.view", + "asset", + ), + ( + "get_workbench_runtime", + "读取工作台已有运行态", + m.StrictInput, + "simulation.view", + "task", + ), + ( + "get_task_context", + "读取绑定任务的有界摘要及handling_skill元数据;section=skill读取Server按任务workflow分发的业务处置Skill正文与版本,属于流程指引而非事件证据;其他section选择task/report/evidence/actions/progress/access_requests/plan/run,data_path按返回路径读取嵌套字段,limit/offset分页;report_revision保留选定报告版本", + m.TaskContextInput, + "operational_task.view", + "task", + True, + ), + ( + "query_peripheral_data", + "查询有限外围数据源;synthetic是合成证据", + m.PeripheralInput, + "simulation.view", + "peripheral", + ), + ( + "get_task_evidence", + "读取当前绑定任务已保存的证据快照", + m.EvidenceInput, + "operational_task.view", + "task", + True, + ), +] +TOOLS = {row[0]: QueryTool(*row) for row in _specs} diff --git a/app/mcp/config.py b/app/mcp/config.py new file mode 100644 index 0000000..1c2d6fa --- /dev/null +++ b/app/mcp/config.py @@ -0,0 +1,76 @@ +"""MCP is opt-in; an incomplete OAuth resource must never start listening.""" + +from pathlib import Path +from ipaddress import IPv4Address, IPv4Network +from urllib.parse import urlsplit + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class McpSettings(BaseSettings): + enabled: bool = False + issuer_uri: str = "" + dev_http_issuer_uri: str = "" + environment: str = Field(default="production", validation_alias="ENVIRONMENT") + resource_uri: str = "" + audience: str = "tjwater-mcp" + host_client_id: str = "tjwater-agent-mcp" + allowed_hosts: list[str] = Field( + default_factory=lambda: ["127.0.0.1:*", "localhost:*"] + ) + allowed_origins: list[str] = Field(default_factory=list) + context_ttl_seconds: int = Field(default=600, ge=30, le=600) + query_timeout_seconds: int = Field(default=30, ge=1, le=60) + max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400) + model_config = SettingsConfigDict( + env_prefix="TJWATER_MCP_", + extra="ignore", + populate_by_name=True, + env_file=Path(__file__).resolve().parents[2] / ".env", + ) + + @model_validator(mode="after") + def validate_resource(self): + if self.dev_http_issuer_uri: + dev_url = urlsplit(self.dev_http_issuer_uri) + try: + address = IPv4Address(dev_url.hostname or "") + private_ipv4 = any(address in IPv4Network(network) for network in ( + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" + )) + except ValueError: + private_ipv4 = False + if ( + self.environment != "development" + or dev_url.scheme != "http" + or not private_ipv4 + or dev_url.username or dev_url.password or dev_url.query or dev_url.fragment + or self.dev_http_issuer_uri != self.issuer_uri + ): + raise ValueError("development HTTP issuer requires development environment and an exact private IPv4 issuer") + if not self.enabled: + return self + for index, value in enumerate((self.issuer_uri, self.resource_uri)): + url = urlsplit(value) + if ( + url.scheme not in {"http", "https"} + or not url.hostname + or url.username + or url.password + or url.query + or url.fragment + ): + raise ValueError("MCP requires explicit issuer and resource URIs") + if url.scheme == "http" and url.hostname not in { + "localhost", + "127.0.0.1", + "::1", + }: + if index != 0 or value != self.dev_http_issuer_uri: + raise ValueError("non-loopback MCP and OAuth require HTTPS") + if urlsplit(self.resource_uri).path != "/mcp": + raise ValueError("MCP resource URI must identify the /mcp endpoint") + if not self.audience.strip() or not self.host_client_id.strip(): + raise ValueError("MCP audience and host client ID are required") + return self diff --git a/app/mcp/contexts.py b/app/mcp/contexts.py new file mode 100644 index 0000000..0d2c8b3 --- /dev/null +++ b/app/mcp/contexts.py @@ -0,0 +1,112 @@ +"""An OAuth identity and a Server-owned binding are both required for data queries.""" + +import secrets +from datetime import datetime, timedelta, timezone +from uuid import UUID +from fastapi import HTTPException +from app.auth.permissions import ( + permissions_for_context, + OPERATIONAL_TASK_EXECUTE, + OPERATIONAL_TASK_VIEW, +) +from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository +from app.infra.db.postgresql.mcp_queries import McpQueryRepository +from app.mcp.models import BindInput, BoundContext +from app.mcp.scope import QueryScope +from app.mcp.config import McpSettings +from mcp.server.auth.provider import AccessToken + + +def task_repository(scope: QueryScope) -> OperationalTaskRepository: + return OperationalTaskRepository(scope.connection, scope.project.project_id) + + +async def verify_task(scope: QueryScope, binding: BoundContext) -> None: + permissions = permissions_for_context(scope.project) + if OPERATIONAL_TASK_VIEW not in permissions: + raise HTTPException(403, "Task read permission is required") + repo = task_repository(scope) + async with scope.connection.transaction(): + task = await repo.get_task(str(binding.task_id)) + if not task: + raise HTTPException(404, "Task is not in this project") + if binding.purpose == "task_collaboration": + return + if OPERATIONAL_TASK_EXECUTE not in permissions: + raise HTTPException(403, "Task execution permission is required") + run = await repo.get_active_run(str(binding.task_id)) + now = datetime.now(timezone.utc) + owner = binding.parent_session_id or binding.native_session_id + if ( + not run + or run["status"] not in {"claimed", "running"} + or run.get("agent_session_id") != owner + or not run.get("lease_expires_at") + or run["lease_expires_at"] <= now + or binding.run_id is not None + and str(run["run_id"]) != str(binding.run_id) + or binding.execution_request_id is not None + and str(run["execution_request_id"]) != str(binding.execution_request_id) + ): + raise HTTPException( + 409, "Investigation run is no longer active for this session" + ) + binding.run_id = UUID(str(run["run_id"])) + binding.execution_request_id = UUID(str(run["execution_request_id"])) + + +async def bind_context( + scope: QueryScope, principal: AccessToken, command: BindInput, settings: McpSettings +) -> BoundContext: + if principal.client_id != settings.host_client_id: + raise HTTPException( + 403, "Only the trusted product host may bind query contexts" + ) + from app.mcp.catalog import TOOLS + + binding = BoundContext( + context_handle=secrets.token_urlsafe(32), + 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), + permission_cap=sorted(permissions_for_context(scope.project)), + native_session_id=command.native_session_id, + purpose=command.purpose, + parent_session_id=command.parent_session_id, + task_id=command.task_id, + expires_at=datetime.now(timezone.utc) + + timedelta(seconds=settings.context_ttl_seconds), + ) + if binding.task_id: + await verify_task(scope, binding) + from app.infra.db.postgresql.read_queries import task_data_scopes + + async with scope.connection.transaction(): + binding.task_scope_cap = await task_data_scopes(scope.connection, + project_id=binding.project_id, task_id=binding.task_id, + permissions=permissions_for_context(scope.project)) + await McpQueryRepository(scope.connection, scope.project.project_id).create(binding) + return binding + + +async def resolve_context( + scope: QueryScope, principal: AccessToken, handle: str +) -> BoundContext: + binding = await McpQueryRepository(scope.connection, scope.project.project_id).get( + handle + ) + if ( + not binding + or binding.revoked_at + or binding.expires_at <= datetime.now(timezone.utc) + or binding.subject != principal.subject + or binding.client_id != principal.client_id + or binding.project_id != scope.project.project_id + ): + raise HTTPException( + 403, "Query context is unavailable for this identity and project" + ) + if binding.task_id: + await verify_task(scope, binding) + return binding diff --git a/app/mcp/dispatch.py b/app/mcp/dispatch.py new file mode 100644 index 0000000..0af862f --- /dev/null +++ b/app/mcp/dispatch.py @@ -0,0 +1,15 @@ +"""Delegate MCP transport/metadata with the original request path intact.""" + + +class McpDispatchMiddleware: + def __init__(self, app, runtime): + self.app, self.runtime = app, runtime + + async def __call__(self, scope, receive, send): + path = scope.get("path", "") + if scope["type"] == "http" and ( + path == "/mcp" or path.startswith("/.well-known/oauth-protected-resource") + ): + await self.runtime.asgi(scope, receive, send) + else: + await self.app(scope, receive, send) diff --git a/app/mcp/models.py b/app/mcp/models.py new file mode 100644 index 0000000..202101d --- /dev/null +++ b/app/mcp/models.py @@ -0,0 +1,293 @@ +"""Strict business inputs and provenance envelopes shared by MCP handlers.""" + +from datetime import datetime, timedelta +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +Id = Annotated[str, Field(min_length=1, max_length=160, pattern=r"^\S(?:.*\S)?$")] +ElementType = Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"] +SeriesType = Literal["junction", "pipe"] + + +class StrictInput(BaseModel): + model_config = ConfigDict(extra="forbid") + context_handle: Annotated[str, Field(min_length=32, max_length=128)] + + +class PageInput(StrictInput): + limit: int = Field(default=20, ge=1, le=500) + offset: int = Field(default=0, ge=0, le=10000) + + +class TaskContextInput(PageInput): + section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary" + report_revision: int | None = Field(default=None, ge=1) + data_path: list[Id] = Field(default_factory=list, max_length=8) + + @model_validator(mode="after") + def summary_is_fixed(self): + if self.section in {"summary", "skill"} and (self.data_path or self.offset): + raise ValueError("Select a detail section before using data_path or offset") + return self + + +class ElementInput(StrictInput): + element_type: ElementType + element_id: Id + + +class ElementPageInput(PageInput): + element_type: ElementType + + +class NearbyElementsInput(PageInput): + element_type: ElementType = "pipe" + center: tuple[ + Annotated[float, Field(ge=-180, le=180, allow_inf_nan=False)], + Annotated[float, Field(ge=-90, le=90, allow_inf_nan=False)], + ] = Field(description="EPSG:4326 [longitude, latitude], directly from geocode.center") + radius_m: float = Field(default=200, gt=0, le=10000, allow_inf_nan=False) + limit: int = Field(default=5, ge=1, le=100) + + +class OptionsInput(StrictInput): + kind: Literal["time", "energy", "pump-energy", "network"] + pump_id: Id | None = None + + @model_validator(mode="after") + def pump_required(self): + if self.kind == "pump-energy" and not self.pump_id: + raise ValueError("pump_id is required for pump-energy") + if self.kind != "pump-energy" and self.pump_id: + raise ValueError("pump_id is only valid for pump-energy") + return self + + +class SchemaInput(StrictInput): + kind: Literal[ + "junction", + "pipe", + "valve", + "pump", + "tank", + "reservoir", + "time", + "energy", + "pump-energy", + "option", + "scada-device", + ] + + +class DeviceInput(StrictInput): + device_id: Id + + +class WindowInput(PageInput): + start: datetime + end: datetime + limit: int = Field(default=20, ge=1, le=10000) + + @model_validator(mode="after") + def window_valid(self): + if self.start.tzinfo is None or self.end.tzinfo is None: + raise ValueError("timestamps require explicit timezone") + if not timedelta(0) < self.end - self.start <= timedelta(days=1): + raise ValueError("time window must be positive and at most 24 hours") + return self + + +class ScadaInput(WindowInput): + device_ids: list[Id] = Field(min_length=1, max_length=20) + field: Literal["monitored_value", "cleaned_value"] = "monitored_value" + + +class BaselineSeriesInput(WindowInput): + element_type: SeriesType + element_ids: list[Id] = Field(min_length=1, max_length=20) + + +class TimeInput(StrictInput): + time: datetime + + @model_validator(mode="after") + def timezone_required(self): + if self.time.tzinfo is None: + raise ValueError("timestamp requires explicit timezone") + return self + + +class BaselineAtTimeInput(TimeInput): + element_type: SeriesType + element_id: Id + + +class BaselineFieldInput(TimeInput): + element_type: SeriesType + field: Id + limit: int = Field(default=20, ge=1, le=500) + offset: int = Field(default=0, ge=0, le=10000) + + +class AnalysisSeriesInput(WindowInput): + run_id: UUID + element_type: SeriesType + element_id: Id + field: Id + + +class AnalysisValuesInput(BaselineFieldInput): + run_id: UUID + + +class RunInput(StrictInput): + run_id: UUID + + +class RunsInput(PageInput): + limit: int = Field(default=20, ge=1, le=100) + + +class ResultsInput(PageInput): + run_id: UUID + result_type: Id | None = None + + +class HistoryTarget(BaseModel): + model_config = ConfigDict(extra="forbid") + element_type: SeriesType + element_id: Id + device_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20) + + +class HistoryInput(WindowInput): + elements: list[HistoryTarget] = Field(min_length=1, max_length=20) + mode: Literal["observed", "realtime_comparison", "analysis_comparison"] = "observed" + run_id: UUID | None = None + + @model_validator(mode="after") + def analysis_required(self): + if (self.mode == "analysis_comparison") != (self.run_id is not None): + raise ValueError("run_id is required only for analysis_comparison") + return self + + +class SourceGisInput(StrictInput): + layer_key: Annotated[str, Field(min_length=1, max_length=80)] + source_fid: int = Field(ge=0, le=9223372036854775807) + + +class PeripheralInput(PageInput): + source: Literal[ + "peripheral_points", + "peripheral_work_orders", + "peripheral_observations", + "peripheral_test_data", + ] + point_id: Id | None = None + start: datetime | None = None + end: datetime | None = None + + @model_validator(mode="after") + def observation_window(self): + if self.source == "peripheral_observations": + if not self.point_id or self.start is None or self.end is None: + raise ValueError("observations require point_id, start and end") + 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( + "observation window requires timezone and at most 24 hours" + ) + elif ( + self.point_id is not None or self.start is not None or self.end is not None + ): + raise ValueError( + "point_id and window are only valid for peripheral_observations" + ) + return self + + +class EvidenceInput(PageInput): + evidence_id: Id + data_path: list[Id] = Field(default_factory=list, max_length=8) + + +class BindInput(BaseModel): + model_config = ConfigDict(extra="forbid") + native_session_id: Id + purpose: Literal[ + "ordinary_chat", "task_execution", "task_reader", "task_collaboration" + ] + task_id: UUID | None = None + parent_session_id: Id | None = None + + @model_validator(mode="after") + def task_scope(self): + if self.purpose == "ordinary_chat" and (self.task_id or self.parent_session_id): + raise ValueError("ordinary chat cannot bind a task") + if self.purpose != "ordinary_chat" and self.task_id is None: + raise ValueError("task context requires task_id") + if (self.purpose == "task_reader") != (self.parent_session_id is not None): + raise ValueError("only a task reader requires parent_session_id") + return self + + +class BindResponse(BaseModel): + context_handle: str + expires_at: datetime + task_id: UUID | None + run_id: UUID | None + + +class PageInfo(BaseModel): + limit: int + offset: int + total: int | None = None + has_more: bool + + +class QueryResult(BaseModel): + model_config = ConfigDict(extra="forbid") + source: str + source_ref: str + queried_at: datetime + origin: Literal["system_data", "synthetic"] = "system_data" + semantics: Literal[ + "asset", + "observed", + "model_baseline", + "analysis", + "comparison", + "task", + "peripheral", + ] + data: JsonValue + page: PageInfo | None = None + window: dict[str, datetime] | None = None + units: dict[str, str] = Field(default_factory=dict) + truncated: bool = False + data_gaps: list[str] = Field(default_factory=list) + evidence_id: str | None = None + + +class BoundContext(BaseModel): + context_handle: str + project_id: UUID + subject: str + client_id: str + tool_cap: list[str] = Field(default_factory=list) + permission_cap: list[str] = Field(default_factory=list) + task_scope_cap: list[str] = Field(default_factory=list) + native_session_id: str + purpose: str + parent_session_id: str | None = None + task_id: UUID | None = None + run_id: UUID | None = None + execution_request_id: UUID | None = None + expires_at: datetime + revoked_at: datetime | None = None diff --git a/app/mcp/peripheral.py b/app/mcp/peripheral.py new file mode 100644 index 0000000..04cf6ef --- /dev/null +++ b/app/mcp/peripheral.py @@ -0,0 +1,132 @@ +"""Read existing project-owned peripheral facts without forwarding MCP tokens.""" + +from datetime import datetime, timezone +from uuid import uuid4 +from fastapi import HTTPException +from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, scada_devices, series_rows +from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery +from app.services.content_digest import digest +from samples.peripheral.store import PostgresStore +from app.infra.integrations.operational_data import _select_demo_observations + + +async def read_peripheral_query(scope, args): + conn = scope.connection + project = str(scope.project.project_id) + truncated = False + origin = "system_data" + if args.source in {"peripheral_work_orders", "peripheral_test_data"}: + table, column = ( + ("work_orders", "work_order_id") + if args.source == "peripheral_work_orders" + else ("test_data_records", "record_id") + ) + rows = await read_rows( + conn, + "SELECT document FROM peripheral_sample." + + table + + " WHERE project_id=%s AND deleted_at IS NULL ORDER BY " + + column + + " LIMIT %s OFFSET %s", + (project, args.limit + 1, args.offset), + ) + items = [ + PostgresStore._active_managed_document(row["document"]) + for row in rows[: args.limit] + ] + truncated = len(rows) > args.limit + if any(item.get("project_id") != project for item in items): + raise HTTPException(502, "Peripheral project mismatch") + data = { + "items": items, + "truncated": truncated, + "limit": args.limit, + "offset": args.offset, + } + origin = ( + "synthetic" + if items and any(item.get("origin") == "synthetic" for item in items) + else "system_data" + ) + elif args.source == "peripheral_points": + rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset) + data = { + "origin": "historical_replay", + "items": [ + { + "point_id": row["device_id"], + "label": row["device_id"] + " · " + row["device_type"], + "unit": row["measurement_unit"], + "status": "unknown", + "origin": "historical_replay", + } + for row in rows[: args.limit] + ], + "truncated": len(rows) > args.limit, + } + truncated = data["truncated"] + elif args.point_id.startswith("DEMO-"): + rows = await read_rows( + conn, + "SELECT CASE WHEN octet_length(document::text)<=%s THEN document END AS document," + "digest,octet_length(document::text)>%s AS too_large " + "FROM peripheral_sample.demo_datasets WHERE project_id=%s AND dataset_id='water-demo-v1'", + (MAX_QUERY_BYTES, MAX_QUERY_BYTES, project), + ) + if rows and rows[0]["too_large"]: + raise HTTPException(413, "Synthetic dataset exceeds the integrity-check byte budget") + if not rows or digest(rows[0]["document"]) != rows[0]["digest"]: + raise HTTPException(503, "Synthetic dataset is unavailable or invalid") + query = TaskDataQuery( + source=args.source, point_id=args.point_id, start=args.start, end=args.end + ) + data = _select_demo_observations( + {**rows[0]["document"], "project_id": project}, query, project + ) + items = data["items"] + truncated = len(items) > args.limit + args.offset + data["items"] = items[args.offset : args.offset + args.limit] + data["truncated"] = truncated + origin = "synthetic" + else: + devices = await scada_devices(conn, device_id=args.point_id, limit=1) + if not devices: + raise HTTPException(404, "Peripheral point was not found") + device = devices[0] + if not device.get("api_query_id"): + raise HTTPException(503, "Peripheral history mapping is unavailable") + async with scope.timescale() as ts: + rows = await series_rows( + ts, + source="scada", + element_type="junction", + ids=[device["api_query_id"]], + start=args.start, + end=args.end, + field="monitored_value", + limit=args.limit + 1, + offset=args.offset, + end_exclusive=True, + ) + truncated = len(rows) > args.limit + data = { + "origin": "historical_replay", + "items": [ + { + "point_id": args.point_id, + "observed_at": row["time"].isoformat(), + "value": row["value"], + "unit": device["measurement_unit"], + } + for row in rows[: args.limit] + ], + "truncated": truncated, + } + return TaskDataSnapshot( + source=args.source, + source_ref="mcp:" + str(uuid4()), + queried_at=datetime.now(timezone.utc), + data=data, + origin=origin, + truncated=truncated, + ) diff --git a/app/mcp/query_service.py b/app/mcp/query_service.py new file mode 100644 index 0000000..7c040ad --- /dev/null +++ b/app/mcp/query_service.py @@ -0,0 +1,492 @@ +"""MCP domain dispatch reuses Server repositories and services, never an HTTP loopback.""" + +import asyncio +import math +from contextlib import nullcontext +from typing import Any +from pydantic import BaseModel +from app.mcp.scope import QueryScope +from app.mcp.catalog import QueryTool, TOOLS +from app.mcp.models import BoundContext +from datetime import datetime, timezone +from uuid import uuid4 +from fastapi import HTTPException +from fastapi.encoders import jsonable_encoder +from app.auth.permissions import permissions_for_context +from app.infra.db.postgresql import read_queries as reads +from app.infra.db.postgresql.spatial_queries import nearby_elements +from app.infra.db.postgresql.analysis import AnalysisRepository +from app.infra.db.postgresql.gis_attributes import GisAttributeRepository +from app.infra.db.postgresql.network_settings import NetworkSettingsRepository +from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository +from app.services.operational_tasks import OperationalTaskService +from app.mcp.models import QueryResult, PageInfo +from app.mcp.scenario_service import SCENARIO_TOOLS +from app.mcp.work_order_skills import read_work_order_skill + + +def finite_data(value: Any) -> tuple[Any, int]: + """JSON cannot represent infinity/NaN; preserve the gap as null with provenance.""" + if isinstance(value, float) and not math.isfinite(value): + return None, 1 + count = 0 + if isinstance(value, dict): + result = {} + for key, item in value.items(): + result[key], replaced = finite_data(item) + count += replaced + return result, count + if isinstance(value, list): + result = [] + for item in value: + clean, replaced = finite_data(item) + result.append(clean) + count += replaced + return result, count + return value, 0 + + +def task_service(scope: QueryScope) -> OperationalTaskService: + return OperationalTaskService( + OperationalTaskRepository(scope.connection, scope.project.project_id), + scope.project, + set(permissions_for_context(scope.project)), + ) + + +def page_result(rows, args): + has_more = len(rows) > args.limit + return ( + rows[: args.limit], + PageInfo(limit=args.limit, offset=args.offset, total=None, has_more=has_more), + has_more, + ) + + +async def authorize_query( + scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel +) -> None: + 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.computation: + if tool.name == "optimize_sensor_placement" and scope.project.project_role != "member": + raise HTTPException(403, "Sensor placement requires project member access") + if binding.purpose == "task_reader": + raise HTTPException(403, "Read-only task agents cannot start calculations") + required = {tool.permission} + if tool.name in SCENARIO_TOOLS: + required.add("scada.view") + if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}: + required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view") + if not required.issubset(permissions) or not required.issubset(binding.permission_cap): + raise HTTPException(403, "Calculation and input data permissions are required") + permission = ( + "scada.view" + if tool.name == "get_network_schema" and args.kind == "scada-device" + else tool.permission + ) + if tool.task_only and binding.task_id is None: + raise HTTPException(403, "This query requires a bound task") + granted = permission in permissions and permission in binding.permission_cap + source = { + "list_scada_devices": "scada_devices", + "get_scada_device": "scada_devices", + "query_scada_readings": "scada_readings", + "query_element_history": "scada_readings", + "get_workbench_runtime": "workbench_runtime", + }.get(tool.name) + if tool.name == "query_peripheral_data": + source = args.source + if not granted and binding.task_id and source in binding.task_scope_cap: + async with scope.connection.transaction(): + granted = await task_service(scope).can_read_source(binding.task_id, source) + if not granted: + raise HTTPException(403, "Data source permission is required") + if ( + tool.name == "query_element_history" + and args.mode != "observed" + and ( + "simulation.view" not in permissions + or "simulation.view" not in binding.permission_cap + ) + ): + raise HTTPException(403, "Model comparison permission is required") + + +async def execute_query( + scope: QueryScope, + binding: BoundContext, + tool: QueryTool, + args: BaseModel, + *, + full_snapshot: bool = False, +) -> QueryResult: + await authorize_query(scope, binding, tool, args) + name = tool.name + conn = scope.connection + page = None + truncated = False + units = {} + origin = "system_data" + gaps = [] + source_ref = "mcp:" + str(uuid4()) + evidence_id = None + if tool.computation: + from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate + + # Context/receipt reads may have started an implicit transaction. Release + # it before potentially long CPU/IO work; evidence uses a new transaction. + await conn.commit() + async with ( + scope.simulation_routing() + if name in TIMESCALE_CALCULATIONS + else nullcontext() + ): + data = await calculate( + name, args, network=scope.project.project_code, + username=scope.user.username, + ) + data = { + "parameters": args.model_dump(mode="json", exclude={"context_handle"}), + "result": data, + } + gaps = [{ + "run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.", + "run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.", + "analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.", + "analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.", + "analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.", + "optimize_sensor_placement": "Candidate pressure monitoring locations, not a saved deployment scheme or a guarantee of contaminant source observability.", + "run_leakage_identification": "DMA leakage estimates depend on the total leakage constraint and aligned model/SCADA time; they do not confirm an exact leak location.", + "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.", + }[name]] + 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": + rows = await nearby_elements( + conn, args.element_type, center=args.center, radius_m=args.radius_m, + limit=args.limit + 1, offset=args.offset, + ) + items, page, truncated = page_result(rows, args) + truncated = truncated or args.offset > 0 + data = { + "source": "model_network", "element_type": args.element_type, + "center": list(args.center), "center_crs": "EPSG:4326", + "radius_m": args.radius_m, "distance_method": "WGS84_spheroid", + "items": items, + } + units = {"distance_m": "m", "radius_m": "m"} + gaps = ["Spatial proximity does not establish hydraulic connectivity or a supply relationship"] + elif name in {"get_element_properties", "list_elements"}: + rows = await reads.asset_rows( + conn, + args.element_type, + element_id=getattr(args, "element_id", None), + limit=getattr(args, "limit", 1) + 1, + offset=getattr(args, "offset", 0), + ) + if name == "get_element_properties": + if not rows: + raise HTTPException(404, "Element was not found") + data = rows[0] + else: + data, page, truncated = page_result(rows, args) + elif name in {"list_scada_devices", "get_scada_device"}: + rows = await reads.scada_devices( + conn, + device_id=getattr(args, "device_id", None), + limit=getattr(args, "limit", 1) + 1, + offset=getattr(args, "offset", 0), + ) + if name == "get_scada_device": + if not rows: + raise HTTPException(404, "SCADA device was not found") + data = rows[0] + else: + data, page, truncated = page_result(rows, args) + elif name == "get_network_schema": + from app.services import tjnetwork + + mapping = { + "option": "get_option_v3_schema", + "scada-device": "get_scada_info_schema", + "pump-energy": "get_pump_energy_schema", + } + function = getattr( + tjnetwork, mapping.get(args.kind, "get_" + args.kind + "_schema") + ) + data = await asyncio.to_thread(function, scope.project.project_code) + elif name == "get_network_options": + if args.kind == "pump-energy": + rows = await reads.read_rows( + conn, + "SELECT price,pattern_id AS pattern,efficiency_curve_id AS effic FROM network.pump_energy_settings WHERE pump_id=%s", + (args.pump_id,), + ) + data = {"pump": args.pump_id, "price": None, "pattern": None, "effic": None} + if rows: + data.update(rows[0]) + else: + table = { + "time": "time_settings", + "energy": "energy_settings", + "network": "simulation_settings", + }[args.kind] + where = " WHERE engine_version='v3'" if args.kind == "network" else "" + rows = await reads.read_rows( + conn, + "SELECT key,value FROM network." + + table + + where + + " ORDER BY key LIMIT 501", + ) + data = {str(row["key"]): str(row["value"]) for row in rows[:500]} + truncated = len(rows) > 500 + elif name in { + "query_scada_readings", + "query_model_baseline_series", + "get_model_baseline_at_time", + "query_model_baseline_field", + "query_analysis_series", + "query_analysis_values", + }: + source = ( + "scada" + if name == "query_scada_readings" + else "analysis" + if name.startswith("query_analysis") + else "realtime" + ) + ids = ( + getattr(args, "device_ids", None) + or getattr(args, "element_ids", None) + or ([args.element_id] if hasattr(args, "element_id") else []) + ) + async with scope.timescale() as ts: + rows = await reads.series_rows( + ts, + source=source, + element_type=getattr(args, "element_type", "junction"), + ids=ids, + start=getattr(args, "start", None), + end=getattr(args, "end", None), + time=getattr(args, "time", None), + field=getattr(args, "field", None), + run_id=getattr(args, "run_id", None), + limit=getattr(args, "limit", 1) + 1, + offset=getattr(args, "offset", 0), + ) + if name == "get_model_baseline_at_time": + data = rows[:1] + else: + data, page, truncated = page_result(rows, args) + if source != "scada": + async with conn.transaction(): + units = await NetworkSettingsRepository.get_result_units(conn) + if source == "analysis": + run = await AnalysisRepository.get_run(conn, args.run_id) + if not run: + raise HTTPException(404, "Analysis run was not found") + recorded = (run.get("parameters") or {}).get("result_units") + if isinstance(recorded, dict): + units = recorded + else: + gaps.append( + "Analysis units inferred from current network settings" + ) + if hasattr(args, "field") and args.field not in units: + gaps.append(f"Unit metadata is unavailable for field {args.field}") + else: + metadata = [ + r + for device in ids + for r in await reads.scada_devices(conn, device_id=device, limit=1) + ] + units = { + r["device_id"]: r["measurement_unit"] + for r in metadata + if r["device_id"] in ids + } + elif name in {"list_analysis_runs", "get_analysis_run", "list_analysis_results"}: + async with conn.transaction(): + if name == "get_analysis_run": + data = await AnalysisRepository.get_run(conn, args.run_id) + if not data: + raise HTTPException(404, "Analysis run was not found") + elif name == "list_analysis_runs": + # Fetch one additional row with the repository's supported summary-page bound. + rows = await AnalysisRepository.list_run_summaries( + conn, limit=args.limit + 1, offset=args.offset + ) + data, page, truncated = page_result(rows, args) + else: + rows = await AnalysisRepository.list_results( + conn, + args.run_id, + args.result_type, + limit=args.limit, + lookahead=True, + offset=args.offset, + ) + data, page, truncated = page_result(rows, args) + elif name in {"list_sensor_placement_runs", "get_sensor_placement_run"}: + rows = await reads.sensor_runs( + conn, + run_id=getattr(args, "run_id", None), + limit=getattr(args, "limit", 1) + 1, + offset=getattr(args, "offset", 0), + ) + if name == "get_sensor_placement_run": + if not rows: + raise HTTPException(404, "Sensor placement run was not found") + data = rows[0] + else: + data, page, truncated = page_result(rows, args) + elif name == "query_element_history": + from app.domain.schemas.timeseries_history import ElementHistoryQuery + from app.services.timeseries_history import TimeseriesHistoryService + + query = ElementHistoryQuery( + start_time=args.start, + end_time=args.end, + mode=args.mode, + run_id=args.run_id, + elements=[e.model_dump() for e in args.elements], + ) + async with scope.timescale() as ts, conn.transaction(), ts.transaction(): + result = await TimeseriesHistoryService.query( + ts, conn, query, row_limit=args.limit, row_offset=args.offset + ) + data = result.model_dump(mode="json") + truncated = result.truncated + page = PageInfo(limit=args.limit, offset=args.offset, has_more=truncated) + truncated = truncated or args.offset > 0 + elif name in {"get_element_gis_properties", "get_source_gis_properties"}: + async with conn.transaction(): + if name == "get_element_gis_properties": + data = await GisAttributeRepository.list_element_groups( + conn, args.element_type, args.element_id + ) + else: + data = await GisAttributeRepository.get_source_feature_group( + conn, args.layer_key, args.source_fid + ) + if data is None: + raise HTTPException(404, "Source GIS feature was not found") + elif name == "get_task_context": + if args.section in {"summary", "skill"}: + data = await reads.task_context_summary(conn, project_id=binding.project_id, + task_id=binding.task_id, report_revision=args.report_revision) + skill = read_work_order_skill( + data.get("task", {}).get("workflow"), include_content=args.section == "skill" + ) + if args.section == "skill": + data = {"section": "skill", "skill": skill} + else: + data["handling_skill"] = skill + data["display_time_zone"] = "Asia/Shanghai" + data["approved_data_scopes"] = binding.task_scope_cap + data["bound_run_id"] = str(binding.run_id) if binding.run_id else None + data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] + truncated = True + gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.") + else: + projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id, + section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset) + total = projection["total"] + if args.section in {"evidence", "actions", "access_requests"} and not args.data_path: + total = projection["snapshot_metadata"].get("total", total) + if projection["value_type"] in {"array", "object", "string"}: + page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total) + elif args.offset: + raise HTTPException(422, "Scalar task detail cannot use an offset") + truncated = bool(projection.get("nested") or args.offset or page and page.has_more) + data = {"section": args.section, "selected_path": args.data_path, "value": projection["value"], "metadata": projection["snapshot_metadata"]} + if projection.get("nested"): + gaps.append("Nested values have data_path descriptors; select a descriptor path for its values") + elif name == "get_task_evidence": + projection = await reads.evidence_page( + conn, project_id=scope.project.project_id, task_id=binding.task_id, + evidence_id=args.evidence_id, data_path=args.data_path, + limit=args.limit, offset=args.offset, mcp_sources=list(TOOLS), + full_snapshot=full_snapshot, + ) + document = projection if full_snapshot else projection["snapshot_metadata"] + origin = document["origin"] + source_ref = document["source_ref"] + evidence_id = args.evidence_id + truncated = document.get("truncated", False) + if full_snapshot: + data = document + else: + selected = projection["value"] + if projection["value_type"] in {"array", "object", "string"}: + total = projection["total"] + page = PageInfo( + limit=args.limit, + offset=args.offset, + total=total, + has_more=args.offset + args.limit < total, + ) + truncated = truncated or page.has_more or args.offset > 0 + elif args.offset: + raise HTTPException(422, "Scalar evidence cannot use an offset") + if projection.get("nested"): + truncated = True + gaps.append( + "Nested collections have data_path descriptors; query a descriptor path for their values" + ) + data = { + "snapshot_metadata": document, + "query_metadata": projection["query_metadata"], + "selected_path": args.data_path, + "value": selected, + } + elif name == "get_workbench_runtime": + from app.services.workbench_runtime import read_workbench_runtime + + data = (await read_workbench_runtime(scope.project, conn)).model_dump( + mode="json" + ) + elif name == "query_peripheral_data": + from app.mcp.peripheral import read_peripheral_query + + snapshot = await read_peripheral_query(scope, args) + data = snapshot.data + origin = snapshot.origin + truncated = snapshot.truncated + else: + raise ValueError("query handler is not registered") + window = ( + {"start": args.start, "end": args.end} + if hasattr(args, "start") and args.start is not None + else None + ) + data, nonfinite = finite_data(jsonable_encoder(data)) + semantics = ( + "comparison" + if name == "query_element_history" and args.mode != "observed" + else tool.semantics + ) + return QueryResult( + source=name, + source_ref=source_ref, + evidence_id=evidence_id, + queried_at=datetime.now(timezone.utc), + semantics=semantics, + data=data, + data_gaps=gaps + + ( + [f"{nonfinite} non-finite numeric values were returned as null"] + if nonfinite + else [] + ), + page=page, + window=window, + units=units, + truncated=truncated, + origin=origin, + ) diff --git a/app/mcp/scenario_service.py b/app/mcp/scenario_service.py new file mode 100644 index 0000000..8b9dea2 --- /dev/null +++ b/app/mcp/scenario_service.py @@ -0,0 +1,70 @@ +"""MCP online recalculation and isolated scenario adapters.""" + +from typing import Any + +SCENARIO_TOOLS = frozenset({ + "run_simulation", + "run_contaminant_simulation", "analyze_valve_closure", + "analyze_flushing", "analyze_pressure_regulation", +}) + + +def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]: + from app.services import simulation_scenarios as scenarios + + parameters = dict(parameters) + nodes = parameters.pop("output_node_ids") + links = parameters.pop("output_link_ids") + start = parameters.pop("start_time") + duration = parameters.pop("duration") + common = dict(name=network, modify_pattern_start_time=start.isoformat(), + modify_total_duration=duration, persist_results=False, scheme_name=None) + if name == "run_simulation": + from app.services.simulation import run_online_simulation + + result = run_online_simulation( + network, start, duration, output_node_ids=nodes, output_link_ids=links, + ) + elif name == "run_contaminant_simulation": + result = scenarios.contaminant_simulation(**common, username=username, + source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern")) + elif name == "analyze_valve_closure": + result = scenarios.valve_close_analysis(**common, + modify_valve_opening={valve: 0.0 for valve in parameters["valves"]}) + elif name == "analyze_flushing": + # The service consumes m³/h (the older REST label incorrectly says L/s). + # Reject unsupported conversion rather than silently ignore the flow. + from app.native.wndb.model.options import get_option + if get_option(network).get("UNITS") not in {"LPS", "CMH"}: + raise ValueError("Flushing currently supports LPS or CMH models only") + result = 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"]}) + elif name == "analyze_pressure_regulation": + from app.services.simulation import query_corresponding_element_id_and_query_id + 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("Pump controls must reference registered model pumps") + 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-speed pump controls must be 0 or 1") + result = scenarios.pressure_regulation(**common, + 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) + else: + raise ValueError("Scenario is not registered") + if not isinstance(result, dict): + raise ValueError("Scenario did not return calculation results") + selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes] + selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links] + return { + "input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation", + "persisted_baseline": name == "run_simulation", "persisted_scheme": False, + "start_time": start.isoformat(), "duration_seconds": duration, + "units": result.get("units", {}), "times": result.get("times", {}), + "node_results": selected_nodes, "link_results": selected_links, + "coverage": {"requested_nodes": nodes, "requested_links": links, + "missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}), + "missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})}, + } diff --git a/app/mcp/scope.py b/app/mcp/scope.py new file mode 100644 index 0000000..acf302c --- /dev/null +++ b/app/mcp/scope.py @@ -0,0 +1,76 @@ +"""Resolve authoritative metadata and project routing for each MCP request.""" + +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any, AsyncIterator +from psycopg import AsyncConnection +from app.auth.project_dependencies import ProjectContext +from app.infra.db.project_routing import ActiveProjectRouting +from fastapi import HTTPException +from mcp.server.auth.provider import AccessToken + +from app.auth.metadata_dependencies import get_current_metadata_user +from app.auth.project_dependencies import ( + resolve_project_context, + resolve_project_business_routing, + resolve_project_simulation_routing, + get_project_pg_connection, + get_project_timescale_connection, +) +from app.infra.db.metadb.database import get_metadata_session +from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository +from app.infra.db.project_routing import activate_project_routing + + +class QueryMetadataRepository(MetadataRepository): + async def get_project_db_routing(self, project_id, role): + routing = await super().get_project_db_routing(project_id, role) + # Existing connection generators obtain routing before awaiting a pool. + # End that implicit SELECT transaction here, before the network wait. + await self.session.commit() + return routing + + +@dataclass +class QueryScope: + project: ProjectContext + user: Any + connection: AsyncConnection + routing: ActiveProjectRouting + metadata: QueryMetadataRepository + + @asynccontextmanager + async def simulation_routing(self) -> AsyncIterator[ActiveProjectRouting]: + """Resolve both databases before a native calculation can modify its model.""" + routing = await resolve_project_simulation_routing(self.project, self.metadata) + await self.metadata.session.commit() + with activate_project_routing(routing): + yield routing + + @asynccontextmanager + async def timescale(self): + # Metadata repository here is used only to resolve a known project DB. + async with asynccontextmanager(get_project_timescale_connection)( + self.project, self.metadata + ) as conn: + await self.metadata.session.commit() + yield conn + + +@asynccontextmanager +async def project_scope(principal: AccessToken, project_id: str): + if not principal.claims: + raise HTTPException(401, "MCP identity is missing") + async with asynccontextmanager(get_metadata_session)() as session: + metadata = QueryMetadataRepository(session) + user = await get_current_metadata_user(principal.claims, metadata) + project = await resolve_project_context(project_id, user, metadata) + routing = await resolve_project_business_routing(project, metadata) + # End the metadata read before any external query/IO waits. + await session.commit() + async with asynccontextmanager(get_project_pg_connection)( + project, metadata + ) as conn: + await session.commit() + with activate_project_routing(routing): + yield QueryScope(project, user, conn, routing, metadata) diff --git a/app/mcp/work_order_skills.py b/app/mcp/work_order_skills.py new file mode 100644 index 0000000..12ad30b --- /dev/null +++ b/app/mcp/work_order_skills.py @@ -0,0 +1,40 @@ +"""Server-owned business instructions, selected from the persisted task workflow.""" + +from hashlib import sha256 +from pathlib import Path +from typing import Any + +import yaml + + +SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills" +WORKFLOW_SKILLS = { + "burst_report": "burst-report", + "water_quality_complaint": "water-quality-complaint", + "general": "appointment-follow-up", +} + + +def read_work_order_skill(workflow: str | None, *, include_content: bool = False) -> dict[str, Any]: + """Read only packaged, allowlisted instructions; never interpret a task path.""" + name = WORKFLOW_SKILLS.get(workflow) + if name is None: + return {"available": False, "workflow": workflow, "reason": "No business skill is configured for this workflow"} + content = (SKILL_ROOT / name / "SKILL.md").read_text(encoding="utf-8") + _, frontmatter, body = content.split("---", 2) + header = yaml.safe_load(frontmatter) + if header["name"] != name: + raise ValueError("Packaged work-order skill name does not match its directory") + result = { + "available": True, + "kind": "business_instructions", + "workflow": workflow, + "name": header["name"], + "description": header["description"], + "version": str(header["metadata"]["version"]), + "content_sha256": sha256(content.encode("utf-8")).hexdigest(), + "read": {"section": "skill"}, + } + if include_content: + result["instructions"] = body.strip() + return result diff --git a/app/native/wndb/inp/exporter.py b/app/native/wndb/inp/exporter.py index 34b708b..fe5937b 100644 --- a/app/native/wndb/inp/exporter.py +++ b/app/native/wndb/inp/exporter.py @@ -120,7 +120,7 @@ def dump_inp(project: str, inp: str, version: str = '3'): file.write('\n'.join(inp_out_demand(project))) elif name == STATUS: - file.write('\n'.join(inp_out_status(project))) + file.write('\n'.join(inp_out_status(project, version=version))) elif name == PATTERNS: if version == '3': @@ -232,7 +232,7 @@ def export_inp(project: str, version: str = '3') -> ChangeSet: inp += '\n'.join(inp_out_demand(project)) elif name == STATUS: - inp += '\n'.join(inp_out_status(project)) + inp += '\n'.join(inp_out_status(project, version=version)) elif name == PATTERNS: if version == '3': diff --git a/app/native/wndb/model/status.py b/app/native/wndb/model/status.py index b056c11..9f35ef8 100644 --- a/app/native/wndb/model/status.py +++ b/app/native/wndb/model/status.py @@ -93,14 +93,16 @@ def inp_in_status(line: str) -> str: return str(f"insert into network.link_initial_settings (link_id, status, setting) values ({sql_literal(link)}, null, {sql_literal(float(value))});") -def inp_out_status(name: str) -> list[str]: +def inp_out_status(name: str, version: str = '2') -> list[str]: lines = [] objs = read_all(name, 'select link_id as link, status, setting from network.link_initial_settings order by link_id') for obj in objs: link = obj['link'] status = obj['status'] if obj['status'] != None else '' setting = obj['setting'] if obj['setting'] != None else '' - if status != '': + # EPANET 2 valves are active at their configured setting by default. + # ACTIVE is an internal state, not a legal [STATUS] keyword in v2. + if status != '' and not (version == '2' and status.upper() == LINK_STATUS_ACTIVE): lines.append(f'{link} {status}') if setting != '': lines.append(f'{link} {setting}') diff --git a/app/services/agent_messages.py b/app/services/agent_messages.py new file mode 100644 index 0000000..e9e15d0 --- /dev/null +++ b/app/services/agent_messages.py @@ -0,0 +1,186 @@ +"""Messages never change task generation or silently target a replacement run.""" +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, permissions_for_context +from app.domain.domain_errors import DomainError, require +from app.domain.schemas.agent_messages import ( + AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply, +) + + +def _message_cursor(cursor: str | None): + if not cursor: + return None, None + try: + stamp, identifier = cursor.rsplit("|", 1) + cursor_at, cursor_id = datetime.fromisoformat(stamp), UUID(identifier) + require(cursor_at.tzinfo is not None, "INVALID_CURSOR", "Cursor needs a timezone", status=400) + return cursor_at, cursor_id + except (ValueError, TypeError) as exc: + raise DomainError("INVALID_CURSOR", "Invalid message cursor", status=400) from exc + + +class AgentMessageService: + def __init__(self, repository, context, *, clock=None): + self.repo = repository + self.context = context + self.clock = clock or (lambda: datetime.now(timezone.utc)) + + def _require_service(self) -> None: + require(self.context.system_role == "service" and + OPERATIONAL_TASK_EXECUTE in permissions_for_context(self.context), "SERVICE_REQUIRED", + "Message dispatch requires a project service account", status=403) + + async def get(self, message_id: UUID) -> AgentMessage: + if self.context.system_role == "service": + self._require_service() + row = await self.repo.get_message(str(message_id)) + require(row is not None, "MESSAGE_NOT_FOUND", "Message not found", status=404) + require(self.context.system_role == "service" or + str(row["conversation_owner_id"]) == str(self.context.user_id), + "MESSAGE_NOT_FOUND", "Message not found", status=404) + return AgentMessage.model_validate(row) + + async def _active_run(self, task_id: UUID, run_id: UUID) -> dict: + run = await self.repo.get_run(str(run_id)) + require(run is not None and str(run["task_id"]) == str(task_id), + "RUN_NOT_FOUND", "Run does not belong to the task", status=404) + task = await self.repo.get_task(str(task_id)) + require(task is not None and task["task_type"] == "event_investigation", + "INVALID_TASK_TYPE", "Messages require an investigation task", status=409) + require(run["status"] in {"queued", "claimed", "running"}, + "RUN_TERMINAL", "Run no longer accepts messages", status=409) + if run["status"] != "queued": + expires = run.get("lease_expires_at") + if isinstance(expires, str): + expires = datetime.fromisoformat(expires.replace("Z", "+00:00")) + require(expires is not None and expires > self.clock(), + "LEASE_EXPIRED", "Run lease expired", status=409) + return run + + async def _insert(self, *, command, owner_id, conversation_owner_id, direction, task_id, run_id, + target_session_id, in_reply_to=None) -> AgentMessage: + prior = await self.repo.get_message_request( + str(owner_id), command.source_session_id, str(command.request_id), + ) + expected = { + "task_id": str(task_id), "run_id": str(run_id), + "direction": direction, "in_reply_to": str(in_reply_to) if in_reply_to else None, + "text": command.text, + } + if prior is not None: + require(all((str(prior[key]) if prior[key] is not None else None) == value + for key, value in expected.items()), + "IDEMPOTENCY_CONFLICT", "request_id was used with different content", status=409) + return AgentMessage.model_validate(prior) + # The run lock serializes message creation with completion/cancellation. + await self._active_run(task_id, run_id) + message_id = uuid4() + now = self.clock() + message = AgentMessage( + project_id=self.context.project_id, + message_id=message_id, request_id=command.request_id, + task_id=task_id, run_id=run_id, source_session_id=command.source_session_id, + source_owner_id=owner_id, conversation_owner_id=conversation_owner_id, + target_session_id=target_session_id, + direction=direction, in_reply_to=in_reply_to, text=command.text, + status="pending", native_message_id=f"msg_{message_id}", + created_at=now, updated_at=now, + ) + await self.repo.insert_message(message.model_dump(mode="json")) + return message + + async def create(self, command: AgentMessageCreate) -> AgentMessage: + async with self.repo.transaction(): + await self.repo.lock(f"run:{command.run_id}") + run = await self.repo.get_run(str(command.run_id)) + return await self._insert( + command=command, owner_id=self.context.user_id, + conversation_owner_id=self.context.user_id, direction="to_task", + task_id=command.task_id, run_id=command.run_id, + target_session_id=run.get("agent_session_id") if run else None, + ) + + async def reply(self, message_id: UUID, command: AgentMessageReply) -> AgentMessage: + async with self.repo.transaction(): + original = await self.get(message_id) + await self.repo.lock(f"run:{original.run_id}") + run = await self.repo.get_run(str(original.run_id)) + if original.direction == "to_task": + self._require_service() + require(run is not None and run.get("agent_session_id") == command.source_session_id, + "SESSION_MISMATCH", "Session does not own the message run", status=409) + direction, target = "to_chat", original.source_session_id + else: + require(original.conversation_owner_id == self.context.user_id, + "MESSAGE_NOT_FOUND", "Message not found", status=404) + require(command.source_session_id == original.target_session_id, + "SESSION_MISMATCH", "Only the original chat can reply", status=409) + direction = "to_task" + target = run.get("agent_session_id") if run else None + return await self._insert( + command=command, owner_id=self.context.user_id, + conversation_owner_id=original.conversation_owner_id, direction=direction, + task_id=original.task_id, run_id=original.run_id, + target_session_id=target, in_reply_to=message_id, + ) + + async def list_session( + self, session_id: str, limit: int, cursor: str | None, pending_only: bool = False, + ) -> AgentMessagePage: + cursor_at, cursor_id = _message_cursor(cursor) + kwargs = {"cursor_at": cursor_at, "cursor_id": cursor_id, "pending_only": pending_only} + if self.context.system_role == "service": + self._require_service() + rows = await self.repo.list_run_session_messages(session_id, limit + 1, **kwargs) + if not rows: + rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs) + else: + rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs) + items = [AgentMessage.model_validate(row) for row in rows[:limit]] + next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None + return AgentMessagePage(items=items, next_cursor=next_cursor) + + async def pending(self, limit: int, cursor: str | None = None) -> AgentMessagePage: + self._require_service() + cursor_at, cursor_id = _message_cursor(cursor) + rows = await self.repo.list_pending_messages(limit + 1, cursor_at=cursor_at, cursor_id=cursor_id) + items = [AgentMessage.model_validate(row) for row in rows[:limit]] + next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None + return AgentMessagePage(items=items, next_cursor=next_cursor) + + async def delivery(self, message_id: UUID, command: AgentMessageDelivery) -> AgentMessage: + async with self.repo.transaction(): + message = await self.get(message_id) + if self.context.system_role == "service": + self._require_service() + if self.context.system_role != "service": + require(message.direction == "to_chat", + "SERVICE_REQUIRED", "Only the receiver can acknowledge a chat reply", status=403) + await self.repo.lock(f"run:{message.run_id}") + await self.repo.lock(f"message:{message_id}") + message = await self.get(message_id) + if message.status in {"delivered", "failed"}: + require(message.status == command.status, "MESSAGE_TERMINAL", + "Message already has a terminal delivery result", status=409) + return message + if message.direction == "to_task" and command.status != "failed": + run = await self._active_run(message.task_id, message.run_id) + target = run.get("agent_session_id") + require(target is not None and command.target_session_id == target, + "SESSION_MISMATCH", "Delivery must target the owning run session", status=409) + else: + target = message.target_session_id + require(command.target_session_id in {None, target}, "SESSION_MISMATCH", + "Delivery cannot change the reply channel", status=409) + require(command.native_message_id in {None, message.native_message_id}, + "MESSAGE_ID_MISMATCH", "Native message identity is immutable", status=409) + now = self.clock() + update = { + "status": command.status, "target_session_id": target, + "failure_detail": command.failure_detail, "updated_at": now, + "delivered_at": now if command.status == "delivered" else None, + } + await self.repo.update_message_delivery(str(message_id), update) + return message.model_copy(update=update) diff --git a/app/services/burst_location.py b/app/services/burst_location.py index 5d9c5d4..29f05e5 100644 --- a/app/services/burst_location.py +++ b/app/services/burst_location.py @@ -63,6 +63,7 @@ def run_burst_location_by_network( use_scada_flow: bool = False, scheme_name: str | None = None, simulation_run_id: UUID | None = None, + final_candidates_csv_path: str | None = "temp/burst_location/final_round_candidates.csv", ) -> dict[str, Any]: if not network: raise ValueError("network is required.") @@ -312,6 +313,7 @@ def run_burst_location_by_network( min_dpressure=min_dpressure, basic_pressure=basic_pressure, visualize_partition=False, + final_candidates_csv_path=final_candidates_csv_path, ) payload: dict[str, Any] = { diff --git a/app/services/geocoding.py b/app/services/geocoding.py index 1fa7eab..06e8782 100644 --- a/app/services/geocoding.py +++ b/app/services/geocoding.py @@ -5,6 +5,9 @@ import httpx from pydantic import AliasChoices, BaseModel, Field from app.core.config import settings +from app.core.tianditu_logging import install_tianditu_token_filter + +install_tianditu_token_filter() class TiandituGeocodeRequest(BaseModel): @@ -32,12 +35,12 @@ async def geocode_tianditu( *, client: httpx.AsyncClient | None = None, ) -> dict[str, Any]: - if not settings.TIANDITU_GEOCODER_TOKEN: - raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured") + if not settings.TIANDITU_TOKEN: + raise TiandituGeocodingConfigError("TIANDITU_TOKEN is not configured") params = { "ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False), - "tk": settings.TIANDITU_GEOCODER_TOKEN, + "tk": settings.TIANDITU_TOKEN, } if client is not None: diff --git a/app/services/operational_presentation.py b/app/services/operational_presentation.py new file mode 100644 index 0000000..6332f60 --- /dev/null +++ b/app/services/operational_presentation.py @@ -0,0 +1,99 @@ +"""Resolve browser overlays only from immutable Server-ingested snapshots.""" +from __future__ import annotations + +from math import isfinite + +from app.domain.domain_errors import require +from app.domain.schemas.operational_data import TaskDataSnapshot + + +def _number(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) + + +def _position(value): + require(isinstance(value, list) and len(value) in {2, 3} + and all(_number(item) for item in value) + and -180 <= value[0] <= 180 and -90 <= value[1] <= 90, + "OVERLAY_GEOMETRY_INVALID", "Overlay coordinates must be finite WGS84 positions", status=422) + return value[:2] + + +def _geometry(value): + require(isinstance(value, dict), "OVERLAY_GEOMETRY_INVALID", "Overlay geometry is missing", status=422) + kind = value.get("type") + depths = {"Point": 0, "MultiPoint": 1, "LineString": 1, + "MultiLineString": 2, "Polygon": 2, "MultiPolygon": 3} + require(kind in depths, "OVERLAY_GEOMETRY_INVALID", "Unsupported overlay geometry", status=422) + positions = 0 + + def coordinates(items, depth): + nonlocal positions + if depth == 0: + positions += 1 + require(positions <= 20000, "OVERLAY_TOO_LARGE", "Overlay geometry is too large", status=422) + return _position(items) + require(isinstance(items, list) and 0 < len(items) <= 20000, + "OVERLAY_GEOMETRY_INVALID", "Overlay coordinates are empty or too large", status=422) + return [coordinates(item, depth - 1) for item in items] + + normalized = coordinates(value.get("coordinates"), depths[kind]) + lines = ([normalized] if kind == "LineString" else normalized if kind == "MultiLineString" else []) + require(all(len(line) >= 2 for line in lines), "OVERLAY_GEOMETRY_INVALID", + "Overlay lines need at least two positions", status=422) + rings = (normalized if kind == "Polygon" else [ring for polygon in normalized for ring in polygon] + if kind == "MultiPolygon" else []) + require(all(len(ring) >= 4 and ring[0] == ring[-1] for ring in rings), + "OVERLAY_GEOMETRY_INVALID", "Overlay polygon rings must be closed", status=422) + return {"type": kind, "coordinates": normalized} + + +def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict: + """Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary.""" + data = snapshot.data + candidates = [data] + if isinstance(data, dict): + candidates.extend([data.get("map_payload"), data.get("drawing_payload")]) + latest = data.get("latest_analysis") + if isinstance(latest, dict): + candidates.append(latest.get("map_payload")) + collection = next((item for item in candidates + if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None) + if collection is not None: + raw_features = collection.get("features") + require(isinstance(raw_features, list), "OVERLAY_GEOMETRY_INVALID", + "Snapshot FeatureCollection is invalid", status=422) + else: + rows = data.get("items") if isinstance(data, dict) else data + require(isinstance(rows, list), "OVERLAY_DATA_MISSING", + "Evidence snapshot has no recorded coordinates or GeoJSON", status=422) + raw_features = [] + for row in rows: + if not isinstance(row, dict) or row.get("longitude") is None or row.get("latitude") is None: + continue + raw_features.append({ + "type": "Feature", "geometry": {"type": "Point", "coordinates": [row["longitude"], row["latitude"]]}, + "id": row.get("device_id") or row.get("point_id") or row.get("work_order_id") or row.get("id"), + "properties": {"weight": row.get("weight", 1)}, + }) + require(0 < len(raw_features) <= 1000, "OVERLAY_DATA_MISSING", + "Overlay needs between one and 1000 recorded features", status=422) + features = [] + for feature in raw_features: + require(isinstance(feature, dict) and feature.get("type") == "Feature", + "OVERLAY_GEOMETRY_INVALID", "Snapshot contains an invalid feature", status=422) + geometry = _geometry(feature.get("geometry")) + require(mode != "heatmap" or geometry["type"] == "Point", "OVERLAY_HEATMAP_POINTS_REQUIRED", + "Heatmaps require recorded points, not inferred polygon centroids", status=422) + properties = feature.get("properties") or {} + require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422) + normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)} + if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool): + normalized["id"] = feature["id"] + if mode == "heatmap": + weight = properties.get("weight", 1) + require(_number(weight) and weight >= 0, "OVERLAY_WEIGHT_INVALID", + "Heatmap weight must be a finite nonnegative number", status=422) + normalized["properties"]["weight"] = weight + features.append(normalized) + return {"type": "FeatureCollection", "features": features} diff --git a/app/services/operational_tasks.py b/app/services/operational_tasks.py index 42f9fe5..ab4fb50 100644 --- a/app/services/operational_tasks.py +++ b/app/services/operational_tasks.py @@ -1,6 +1,7 @@ """State machines for durable operational tasks and approved external actions.""" from __future__ import annotations +import json from datetime import datetime, timedelta, timezone from typing import Iterable from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 @@ -46,6 +47,7 @@ from app.domain.schemas.operational_task import ( HumanEvidenceCommand, InvestigationReportRevision, InvestigationReportReference, + InvestigationProgressSnapshot, IssueCommandPayload, NotificationAcknowledgeCommand, OperationalTaskCreate, @@ -60,9 +62,13 @@ from app.domain.schemas.operational_task import ( OperationalTaskRun, RecoverExpiredLeasesResult, StoredEvidenceRecord, + TaskPresentationEventCommand, + TaskPresentationEventResult, + TaskPresentationCursor, ) from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource from app.services.content_digest import digest +from app.services.operational_presentation import resolve_overlay LEASE_DURATION = timedelta(seconds=60) @@ -73,7 +79,7 @@ def _json(model) -> dict: def _task_json(model: OperationalTaskSummary) -> dict: - return model.model_dump(mode="json", by_alias=True, exclude={"active_run"}) + return model.model_dump(mode="json", by_alias=True, exclude={"active_run", "latest_run"}) class OperationalTaskService: @@ -223,6 +229,8 @@ class OperationalTaskService: require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404) plan = await self.repo.get_latest_plan(str(task_id)) report = await self.repo.get_latest_report(str(task_id)) + if report is not None: + report = await self._report_with_progress_diagram(task_id, report) evidence = [ self._public_evidence(document) for document in await self.repo.list_evidence(str(task_id)) @@ -230,6 +238,9 @@ class OperationalTaskService: actions = await self.repo.list_actions(str(task_id)) access_requests = await self._list_access_requests(task_id) active_run = await self._active_run(task) + latest_run = (await self.repo.get_latest_run(str(task_id)) + if hasattr(self.repo, "get_latest_run") else active_run) + progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"]) work_order_snapshot = None if task["task_type"] == "external_work_order" and task["source"].get("external_id"): feedback = await self.repo.get_feedback( @@ -238,10 +249,12 @@ class OperationalTaskService: if feedback: work_order_snapshot = feedback["document"].get("work_order_snapshot") return OperationalTaskDetail( - **{key: value for key, value in task.items() if key != "active_run"}, + **{key: value for key, value in task.items() if key not in {"active_run", "latest_run"}}, active_run=active_run, + latest_run=latest_run, latest_plan=plan, latest_report=report, + latest_progress=progress, evidence=evidence, actions=actions, access_requests=access_requests, @@ -249,18 +262,45 @@ class OperationalTaskService: work_order_snapshot=work_order_snapshot, ) + async def _report_with_progress_diagram( + self, task_id: UUID, value: dict | InvestigationReportRevision + ) -> InvestigationReportRevision: + """Archive an eligible recorded graph, without inventing completion states. + + Also projects older reports that predate graph archival. Read paths never + rewrite their stored document or borrow a newer round's progress. + """ + snapshot = InvestigationReportRevision.model_validate(value) + if (snapshot.report.diagram is not None or snapshot.report.stage != "final" + or snapshot.execution_generation is None): + return snapshot + stored = await self.repo.get_latest_progress(str(task_id), snapshot.execution_generation) + if stored is None: + return snapshot + progress = InvestigationProgressSnapshot.model_validate(stored) + diagram = progress.progress.diagram + if (diagram is None or progress.execution_generation != snapshot.execution_generation + or progress.source_session_id != snapshot.source_session_id + or progress.published_at > snapshot.published_at + or any(not set(node.evidence_refs) <= set(snapshot.report.evidence_refs) + for node in diagram.node_refs)): + return snapshot + return snapshot.model_copy(update={ + "report": snapshot.report.model_copy(update={"diagram": diagram}), + }) + async def report(self, task_id: UUID, revision: int) -> InvestigationReportRevision: await self._task(task_id) value = await self.repo.get_report(str(task_id), revision) require(value is not None, "REPORT_NOT_FOUND", "Report revision not found", status=404) - return InvestigationReportRevision.model_validate(value) + return await self._report_with_progress_diagram(task_id, value) async def list_reports( self, task_id: UUID, limit: int, offset: int ) -> tuple[list[InvestigationReportRevision], int | None]: await self._task(task_id) rows = await self.repo.list_reports(str(task_id), limit + 1, offset) - return [InvestigationReportRevision.model_validate(row) for row in rows[:limit]], ( + return [await self._report_with_progress_diagram(task_id, row) for row in rows[:limit]], ( offset + limit if len(rows) > limit else None ) @@ -398,12 +438,15 @@ class OperationalTaskService: async def list_runs( self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None, + task_type: str | None = None, task_created_from: datetime | None = None, ) -> tuple[list[OperationalTaskRun], str | None]: rows = await self.repo.list_runs( status=status, limit=limit + 1, cursor_created_at=cursor_created_at, cursor_run_id=cursor_run_id, + task_type=task_type, + task_created_from=task_created_from, ) items = [OperationalTaskRun.model_validate(row) for row in rows[:limit]] next_cursor = None @@ -418,6 +461,7 @@ class OperationalTaskService: body = _json(command) scope = f"run:{run_id}:claim" async with self.repo.transaction(): + await self.repo.require_execution_storage() replay = await self._request_replay(scope, command.request_id, body) if replay: return OperationalTaskRun.model_validate(replay["run"]) @@ -507,6 +551,18 @@ class OperationalTaskService: ) -> OperationalTaskDetail: return await self._finish_run(task_id, run_id, command, outcome="succeeded") + async def task_runs(self, task_id: UUID, limit: int, offset: int) -> list[OperationalTaskRun]: + await self._task(task_id) + return [OperationalTaskRun.model_validate(row) + for row in await self.repo.list_task_runs(str(task_id), limit, offset)] + + async def task_run(self, task_id: UUID, run_id: UUID) -> OperationalTaskRun: + return OperationalTaskRun.model_validate(await self._run(task_id, run_id)) + + async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None: + if hasattr(self.repo, "fail_run_messages"): + await self.repo.fail_run_messages(str(run_id), reason, now) + async def fail_run( self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand ) -> OperationalTaskDetail: @@ -539,6 +595,7 @@ class OperationalTaskService: ) run_model = OperationalTaskRun.model_validate(run) await self.repo.update_run(_json(run_model), expected) + await self._fail_run_messages(run_id, "Run cancelled", now) task = await self._task(task_id) task.update( lifecycle_status="cancelled", status="cancelled", @@ -630,6 +687,7 @@ class OperationalTaskService: "Agent 执行投递连续失败 10 次,请检查服务连接", now, ) if not command.retryable: + await self._fail_run_messages(run_id, "Run dispatch failed", now) task = await self._task(task_id) task.update( lifecycle_status="failed", status="failed", @@ -642,11 +700,20 @@ class OperationalTaskService: await self._save_request(scope, command.request_id, body, {"run": _json(run_model)}) return run_model - async def recover_expired_leases(self, limit: int) -> RecoverExpiredLeasesResult: + async def recover_expired_leases( + self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None, + ) -> RecoverExpiredLeasesResult: now = self._now() recovered: list[UUID] = [] async with self.repo.transaction(): - for run in await self.repo.claim_expired_runs(limit): + for run in await self.repo.claim_expired_runs( + limit, task_type=task_type, task_created_from=task_created_from, + ): + # Lease recovery already holds a row lock. Try rather than wait + # for the shared run lock to avoid reversing the lock order of + # message/finish transactions; a busy run is checked next tick. + if hasattr(self.repo, "try_lock") and not await self.repo.try_lock(f"run:{run['run_id']}"): + continue task_id = UUID(str(run["task_id"])) expected = run["revision"] run.update( @@ -656,6 +723,7 @@ class OperationalTaskService: ) timed_out = OperationalTaskRun.model_validate(run) await self.repo.update_run(_json(timed_out), expected) + await self._fail_run_messages(run["run_id"], "Run lease expired", now) await self._run_event(timed_out, now) task = await self._task(task_id) timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id)) @@ -738,6 +806,29 @@ class OperationalTaskService: item["action_id"]: HumanApprovalAction.model_validate(item) for item in await self.repo.list_actions(str(task_id)) } + progress_revision = None + if command.progress is not None: + require(command.run_id is not None, "RUN_REQUIRED", + "Progress must identify its current execution run", status=422) + if command.progress.diagram: + for node in command.progress.diagram.node_refs: + self._require_evidence_refs(node.evidence_refs, known_evidence) + require(all(str(ref) in actions for ref in node.action_refs), + "ACTION_NOT_FOUND", "Progress references an unknown action", status=422) + previous = await self.repo.get_latest_progress(str(task_id), task["execution_generation"]) + progress_revision = (previous["revision"] if previous else 0) + 1 + snapshot = InvestigationProgressSnapshot( + revision=progress_revision, run_id=active_run["run_id"], + execution_generation=task["execution_generation"], + source_session_id=command.source_session_id, published_at=now, progress=command.progress, + ) + await self.repo.save_progress(str(task_id), _json(snapshot)) + await self._append_event( + topic="operational.progress.changed", aggregate_type="operational_task_progress", + aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now, + metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"]), + "execution_generation": task["execution_generation"]}, + ) if command.plan: self._require_evidence_refs( (ref for node in command.plan.nodes for ref in node.evidence_refs), @@ -805,6 +896,7 @@ class OperationalTaskService: source_session_id=command.source_session_id, published_at=now, published_by=self._actor(), report=command.report, ) + report = await self._report_with_progress_diagram(task_id, report) await self.repo.insert_report(str(task_id), _json(report)) await self._append_event( topic="operational.report.published", @@ -873,9 +965,76 @@ class OperationalTaskService: ) await self._task_event(task, now) response = {"plan_revision": plan_revision, "report_revision": report_revision} + if progress_revision is not None: + response["progress_revision"] = progress_revision await self._save_request(scope, command.request_id, body, response) return AgentArtifactsResult(task=await self.detail(task_id), **response) + async def presentation_cursor(self, task_id: UUID) -> TaskPresentationCursor: + # The shared event-order lock gives the run and cursor one committed boundary. + async with self.repo.transaction(): + task = await self._task(task_id) + run = await self._active_run(task) + cursor = await self.repo.current_event_cursor() + return TaskPresentationCursor( + task_id=task_id, execution_generation=task["execution_generation"], + active_run=({key: run[key] for key in ("run_id", "generation", "status")} if run else None), + event_cursor=cursor, + ) + + async def publish_presentation_event( + self, task_id: UUID, command: TaskPresentationEventCommand + ) -> TaskPresentationEventResult: + body = _json(command) + scope = f"task:{task_id}:presentation" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return TaskPresentationEventResult.model_validate(replay) + await self.repo.lock(f"task:{task_id}") + await self.repo.lock(f"run:{command.run_id}") + task = await self._task(task_id) + self._require_actionable_task(task) + run = await self._active_run(task) + require(run is not None and str(run["run_id"]) == str(command.run_id) + and run["generation"] == task["execution_generation"], + "EXECUTION_GENERATION_MISMATCH", "Presentation targets a stale execution run", status=409) + require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION", + "Only a running run can publish presentation", status=409) + require(run.get("agent_session_id") == command.source_session_id, + "SESSION_MISMATCH", "Presentation must come from the claimed Agent session", status=409) + now = self._now() + self._require_active_run_lease(run, now) + payload = _json(command.command) + if command.command.action == "open_chart": + known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))} + self._require_evidence_refs(command.command.evidence_refs, known) + elif command.command.action == "set_overlay": + stored = await self.repo.get_evidence(str(task_id), command.command.evidence_id) + require(stored is not None, "EVIDENCE_NOT_FOUND", "Overlay evidence does not belong to this task", status=422) + evidence = StoredEvidenceRecord.model_validate(stored) + require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND", + "Overlay requires a Server-ingested immutable data snapshot", status=422) + payload["data"] = resolve_overlay(evidence.data_snapshot, command.command.mode) + metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]), + "execution_generation": run["generation"], "command": payload} + try: + size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8")) + except (ValueError, TypeError) as exc: + raise DomainError("PRESENTATION_DATA_INVALID", "Presentation data must be finite JSON", status=422) from exc + require(size <= 512 * 1024, "PRESENTATION_TOO_LARGE", + "Presentation event exceeds the 512 KiB limit", status=422) + event_seq = await self._append_event( + topic="operational.presentation.requested", aggregate_type="operational_task_presentation", + aggregate_id=str(task_id), aggregate_revision=run["revision"], occurred_at=now, + metadata=metadata, + ) + require(event_seq is not None, "DEPENDENCY_UNAVAILABLE", "Presentation event storage is unavailable", status=503) + result = TaskPresentationEventResult(event_seq=event_seq, run_id=run["run_id"], + execution_generation=run["generation"]) + await self._save_request(scope, command.request_id, body, _json(result)) + return result + async def propose_action( self, task_id: UUID, command: ActionProposalCommand ) -> HumanApprovalAction: @@ -1246,6 +1405,22 @@ class OperationalTaskService: return ExternalFeedbackResult(task=await self.detail(task_id), **replay) await self.repo.lock(f"task:{task_id}") task = await self._task(task_id) + if task["task_type"] == "event_investigation": + self._require_actionable_task(task) + own_source = task["source"].get("external_id") + matching_source = own_source is not None and command.source_event_id in { + own_source, f"peripheral_sample:{own_source}", + } + if task["status"] == "completed" and not matching_source: + actions = await self.repo.list_actions(str(task_id)) + require(any( + item["kind"] == "create_work_order" and item.get("receipt") + and item["receipt"].get("adapter") == "peripheral_sample" + and command.source_event_id == f"peripheral_sample:{item['receipt']['receipt_id']}" + for item in actions + ), "EXTERNAL_SOURCE_MISMATCH", "Feedback does not match an associated child work order", status=409) + require(command.source_revision > 1, "INITIAL_CHILD_FEEDBACK", + "The child creation receipt is not new field feedback", status=409) if task["task_type"] == "external_work_order": require( task["source"].get("external_id") == command.source_event_id, @@ -1263,7 +1438,9 @@ class OperationalTaskService: existing = await self.repo.get_feedback(str(task_id), command.source_event_id) current_source_revision = ( task.get("source", {}).get("external_revision") - if task["task_type"] == "external_work_order" + if task["task_type"] == "external_work_order" or ( + task["task_type"] == "event_investigation" and matching_source + ) else None ) if current_source_revision is not None: @@ -1276,7 +1453,8 @@ class OperationalTaskService: if existing: prior = existing["document"] if command.source_revision == existing["source_revision"]: - require(digest(prior) == digest(body), "IDEMPOTENCY_CONFLICT", + require(digest({key: value for key, value in prior.items() if key != "request_id"}) + == digest({key: value for key, value in body.items() if key != "request_id"}), "IDEMPOTENCY_CONFLICT", "External feedback revision was reused with different content", status=409) response = { "source_event_id": command.source_event_id, @@ -1287,6 +1465,10 @@ class OperationalTaskService: return ExternalFeedbackResult(task=await self.detail(task_id), **response) require(command.source_revision > existing["source_revision"], "STALE_EXTERNAL_REVISION", "External feedback revision is stale", status=409) + if (task["task_type"] == "event_investigation" and matching_source + and current_source_revision is not None): + require(command.source_revision > current_source_revision, "STALE_EXTERNAL_REVISION", + "Original event feedback must contain a new source version", status=409) now = self._now() await self.repo.upsert_feedback(str(task_id), body, now) if command.evidence: @@ -1322,6 +1504,8 @@ class OperationalTaskService: ), ) if task["task_type"] == "event_investigation": + if matching_source: + task["source"] = {**task["source"], "external_revision": command.source_revision} await self._requeue_task(task, now) else: task.update(revision=task["revision"] + 1, updated_at=now) @@ -1706,6 +1890,11 @@ class OperationalTaskService: task = await self._task(task_id) final_outcome = outcome if outcome == "succeeded": + if hasattr(self.repo, "pending_run_messages"): + pending_messages = await self.repo.pending_run_messages(str(run_id)) + require(not pending_messages, "PENDING_AGENT_MESSAGES", + "Run has messages not yet entered into Agent context", + status=409, recovery="retry_same_request", refs=pending_messages) plan_document = await self.repo.get_latest_plan(str(task_id)) report_document = await self.repo.get_latest_report(str(task_id)) plan = ( @@ -1770,6 +1959,8 @@ class OperationalTaskService: ) run_model = OperationalTaskRun.model_validate(run) await self.repo.update_run(_json(run_model), expected) + if final_outcome != "succeeded": + await self._fail_run_messages(run_id, failure_code or "Run failed", now) task.update( lifecycle_status=lifecycle, status=lifecycle, @@ -1825,7 +2016,8 @@ class OperationalTaskService: def _require_actionable_task(task: dict) -> None: source = task.get("source") or {} require( - task.get("lifecycle_status") != "cancelled" and source.get("deleted_at") is None, + task.get("lifecycle_status") != "cancelled" and task.get("status") != "cancelled" + and source.get("deleted_at") is None, "TASK_NOT_ACTIONABLE", "Cancelled or withdrawn tasks do not accept action changes", status=409, @@ -2003,6 +2195,14 @@ class OperationalTaskService: async def _requeue_task(self, task: dict, now: datetime) -> None: if self._supports_runs: active_run = await self.repo.get_active_run(str(task["task_id"])) + while active_run is not None: + await self.repo.lock(f"run:{active_run['run_id']}") + # Completion may have won the run lock after our first read. + refreshed_run = await self.repo.get_active_run(str(task["task_id"])) + if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]): + active_run = refreshed_run + break + active_run = refreshed_run if active_run is not None and active_run["status"] == "queued": # Multiple inputs can arrive before the worker claims the next # generation. They are all visible when that queued run starts, @@ -2027,6 +2227,7 @@ class OperationalTaskService: ) cancelled = OperationalTaskRun.model_validate(active_run) await self.repo.update_run(_json(cancelled), expected) + await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now) await self._run_event(cancelled, now) generation = task["execution_generation"] + 1 task_id = UUID(task["task_id"]) diff --git a/app/services/place_search.py b/app/services/place_search.py new file mode 100644 index 0000000..3a3a3a8 --- /dev/null +++ b/app/services/place_search.py @@ -0,0 +1,115 @@ +import json +from typing import Any + +import httpx +from pydantic import ValidationError + +from app.core.config import settings +from app.core.tianditu_logging import install_tianditu_token_filter +from app.domain.schemas.place_search import ( + MAX_PLACE_SEARCH_RESULTS, + PlaceSearchItem, + PlaceSearchRequest, + PlaceSearchResponse, +) + +FENGXIAN_ADMIN_CODE = "156310120" +install_tianditu_token_filter() + + +class PlaceSearchError(RuntimeError): + def __init__(self, status_code: int, detail: str): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +async def search_tianditu_places( + request: PlaceSearchRequest, + *, + client: httpx.AsyncClient | None = None, +) -> PlaceSearchResponse: + token = settings.TIANDITU_TOKEN + if not token: + raise PlaceSearchError(503, "地名搜索服务未配置") + params = { + "postStr": json.dumps( + { + "keyWord": request.keyword, + "queryType": 12, + "specify": FENGXIAN_ADMIN_CODE, + "start": 0, + "count": MAX_PLACE_SEARCH_RESULTS, + "show": 2, + }, + ensure_ascii=False, + ), + "type": "query", + "tk": token, + } + try: + if client is not None: + response = await client.get(settings.TIANDITU_SEARCH_URL, params=params) + else: + async with httpx.AsyncClient( + timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS + ) as managed_client: + response = await managed_client.get( + settings.TIANDITU_SEARCH_URL, params=params + ) + response.raise_for_status() + except httpx.TimeoutException: + raise PlaceSearchError(504, "地名搜索超时,请稍后重试") from None + except httpx.HTTPError: + # Request URLs contain the provider key and must not reach the client. + raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None + try: + return _parse_response(response.json()) + except (ValueError, TypeError, KeyError, AttributeError, ValidationError): + raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None + + +def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse: + code = str(payload.get("status", {}).get("infocode", "")) + if code == "3001": + return PlaceSearchResponse(items=[], total=0) + if code != "1000": + raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试") + + result_type = str(payload.get("resultType", "")) + if result_type not in {"1", "3"}: + raise PlaceSearchError(502, "未获得可定位的地名结果,请细化关键字") + raw_items = payload.get("pois", []) if result_type == "1" else payload.get("area", []) + if not isinstance(raw_items, list) or len(raw_items) > MAX_PLACE_SEARCH_RESULTS: + 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") + lon, lat = str(raw["lonlat"]).split(",") + name = raw["name"] + if not isinstance(name, str) or not name.strip(): + raise ValueError("Missing place name") + name = name.strip() + category = raw.get("typeName") or ("公交站" if str(raw.get("poiType")) == "102" else None) + items.append(PlaceSearchItem( + id=str(raw.get("hotPointID") or raw.get("adminCode") or f"{name}:{lon},{lat}"), + name=name, + address=str(raw.get("address") or name), + longitude=float(lon), latitude=float(lat), + category="行政区" if result_type == "3" else category, + district="奉贤区", + )) + 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, + ) diff --git a/app/services/sensor_placement.py b/app/services/sensor_placement.py index 74397a5..d8e4aa4 100644 --- a/app/services/sensor_placement.py +++ b/app/services/sensor_placement.py @@ -82,8 +82,16 @@ def _create_validated_placement( sensor_locations: list[str], sensor_type: str = "pressure", method: str | None = None, + persist_results: bool = True, ) -> dict[str, Any]: validate_sensor_placement_nodes(project_code, sensor_locations) + if not persist_results: + return { + "sensor_locations": sensor_locations, + "sensor_points": _sensor_points(project_code, sensor_locations), + "method": method, + "sensor_type": sensor_type, + } return sensor_placement_repository.create_sensor_placement( project_code, run_name=run_name, @@ -102,6 +110,7 @@ def optimize_sensor_placement_by_sensitivity( min_diameter: int, created_by: str, sensor_type: str = "pressure", + persist_results: bool = True, ) -> dict[str, Any]: """Run sensitivity placement and persist the validated result.""" @@ -119,6 +128,7 @@ def optimize_sensor_placement_by_sensitivity( sensor_locations=sensor_locations, sensor_type=sensor_type, method="sensitivity", + persist_results=persist_results, ) @@ -129,6 +139,7 @@ def optimize_sensor_placement_by_kmeans( min_diameter: int, created_by: str, sensor_type: str = "pressure", + persist_results: bool = True, ) -> dict[str, Any]: """Export the model, run K-means placement, and persist the result.""" @@ -146,6 +157,7 @@ def optimize_sensor_placement_by_kmeans( sensor_locations=sensor_locations, sensor_type=sensor_type, method="kmeans", + persist_results=persist_results, ) diff --git a/app/services/simulation.py b/app/services/simulation.py index a8bb1ff..aebf725 100644 --- a/app/services/simulation.py +++ b/app/services/simulation.py @@ -11,7 +11,7 @@ from app.native.wndb.model.tanks import get_tank, set_tank from app.native.wndb.model.times import get_time, set_time # from get_real_status import * -from datetime import datetime +from datetime import datetime, timedelta from math import modf import json import pytz @@ -150,6 +150,69 @@ def _apply_valve_control( set_status(project_name, cs) +def run_online_simulation( + name: str, + start_time: datetime, + duration_seconds: int, + *, + output_node_ids: list[str] | None = None, + output_link_ids: list[str] | None = None, +) -> dict: + """Recalculate online baseline snapshots without cloning a model database.""" + if start_time.tzinfo is None or duration_seconds <= 0: + raise ValueError("Online simulation requires an aware start time and positive duration") + step = parse_clock_duration_seconds( + get_time(name)["HYDRAULIC TIMESTEP"], field_name="HYDRAULIC TIMESTEP" + ) + if step <= 0: + raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.") + mappings = query_corresponding_element_id_and_query_id(name) + selected = { + "node": {node: [] for node in output_node_ids or []}, + "link": {link: [] for link in output_link_ids or []}, + } + sample_times = [] + units = {} + current = start_time + end = start_time + timedelta(seconds=duration_seconds) + while current < end: + output = run_simulation( + name=name, + simulation_type="realtime", + modify_pattern_start_time=current.isoformat(timespec="seconds"), + scada_mappings=mappings, + ) + if not isinstance(output, dict): + raise RuntimeError("Online simulation did not return calculation results") + units = output.get("units", {}) + sample_times.append(current.isoformat(timespec="seconds")) + for kind, elements in selected.items(): + if not elements: + continue + rows = { + str(row[kind]): row.get("result", []) + for row in output.get(f"{kind}_results", []) if str(row[kind]) in elements + } + for element, values in elements.items(): + samples = rows.get(element, []) + # Each realtime run is a single snapshot. Missing samples remain + # explicit so later values cannot shift to an earlier timestamp. + values.append(samples[0] if samples else None) + current += timedelta(seconds=step) + return { + "units": units, + "times": { + "report_start": 0, "report_step": step, + "sim_duration": duration_seconds, "num_periods": len(sample_times), + "sample_times": sample_times, + }, + **{f"{kind}_results": [ + {kind: element, "result": values} + for element, values in elements.items() if any(value is not None for value in values) + ] for kind, elements in selected.items()}, + } + + # 2025/01/11 def run_simulation( name: str, @@ -170,7 +233,8 @@ def run_simulation( scheme_username: str = "system", scheme_detail: dict | None = None, scada_mappings: ScadaElementMappings | None = None, -) -> UUID | None: + persist_results: bool = True, +) -> UUID | dict | None: """ 传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果 :param name: 模型名称,数据库中对应的名字 @@ -189,6 +253,10 @@ def run_simulation( :param scheme_name:模拟方案名称 :return: """ + if not persist_results and ( + not name.startswith("tjw_tmp_") or simulation_type.upper() != "EXTENDED" + ): + raise ValueError("Non-persistent simulation requires an isolated temporary model") # 记录开始时间 time_cost_start = time.perf_counter() @@ -514,6 +582,8 @@ def run_simulation( num_periods_result = times_info.get("num_periods") if num_periods_result is None: raise RuntimeError("run_project output missing times.num_periods") + if not persist_results: + return output_data print("simulation_type", simulation_type) print("before store result") # print(num_periods_result) @@ -583,4 +653,6 @@ def run_simulation( # TimescaleInternalQueries.fill_scheme_simulation_result_to_SCADA(scheme_type=scheme_type, scheme_name=scheme_name) print("after store result") + if simulation_type.upper() == "REALTIME": + return output_data return run_id if simulation_type.upper() == "EXTENDED" else None diff --git a/app/services/simulation_scenarios.py b/app/services/simulation_scenarios.py index 20a2f5e..bc068b3 100644 --- a/app/services/simulation_scenarios.py +++ b/app/services/simulation_scenarios.py @@ -222,7 +222,8 @@ def valve_close_analysis( modify_valve_opening: dict[str, float] = None, scheme_name: str = None, _temporary_project: str | None = None, -) -> None: + persist_results: bool = True, +) -> dict | str | None: """ 关阀模拟 :param name: 模型名称,数据库中对应的名字 @@ -271,7 +272,7 @@ def valve_close_analysis( set_option(new_name, cs_options) # result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration, # downloading_prohibition=True) - simulation.run_simulation( + result = simulation.run_simulation( name=new_name, simulation_type="extended", modify_pattern_start_time=modify_pattern_start_time, @@ -280,7 +281,9 @@ def valve_close_analysis( scheme_type="valve_close_analysis", scheme_name=scheme_name, result_db_name=name, + persist_results=persist_results, ) + return result if not persist_results else None # step 3. restore the base model # for valve in valves: # execute_undo(name) @@ -303,7 +306,8 @@ def flushing_analysis( username: str | None = None, valve_control: dict[str, dict] = None, _temporary_project: str | None = None, -) -> None: + persist_results: bool = True, +) -> dict | str | None: """ 管道冲洗模拟 :param name: 模型名称,数据库中对应的名字 @@ -425,7 +429,7 @@ def flushing_analysis( set_option(new_name, cs_options) # result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration, # downloading_prohibition=True) - simulation.run_simulation( + result = simulation.run_simulation( name=new_name, simulation_type="extended", modify_pattern_start_time=modify_pattern_start_time, @@ -437,7 +441,9 @@ def flushing_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 4. restore the base model # return result @@ -457,7 +463,8 @@ def contaminant_simulation( source_pattern: str = None, # 污染源时间变化模式名称 username: str | None = None, _temporary_project: str | None = None, -) -> None: + persist_results: bool = True, +) -> dict | str | None: """ 污染模拟 :param name: 模型名称,数据库中对应的名字 @@ -568,7 +575,7 @@ def contaminant_simulation( # step 5. run simulation # result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration, # downloading_prohibition=True) - simulation.run_simulation( + result = simulation.run_simulation( name=new_name, simulation_type="extended", modify_pattern_start_time=modify_pattern_start_time, @@ -578,7 +585,9 @@ def contaminant_simulation( result_db_name=name, scheme_username=username, scheme_detail=scheme_detail, + persist_results=persist_results, ) + return result if not persist_results else None # for i in range(1,operation_step): # execute_undo(name) @@ -600,7 +609,8 @@ def pressure_regulation( scheme_name: str = None, scada_mappings: simulation.ScadaElementMappings | None = None, _temporary_project: str | None = None, -) -> None: + persist_results: bool = True, +) -> dict | str | None: """ 区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响 :param name: 模型名称,数据库中对应的名字 @@ -645,7 +655,7 @@ def pressure_regulation( # pump_control=pump_control, # tank_initial_level_control=tank_initial_level_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, @@ -657,5 +667,7 @@ def pressure_regulation( scheme_name=scheme_name, result_db_name=name, scada_mappings=scada_mappings, + persist_results=persist_results, ) + return result if not persist_results else None # return result diff --git a/app/services/timeseries_history.py b/app/services/timeseries_history.py index 66d11f9..da58440 100644 --- a/app/services/timeseries_history.py +++ b/app/services/timeseries_history.py @@ -16,6 +16,7 @@ from app.domain.schemas.timeseries_history import ( from app.infra.db.postgresql.analysis import AnalysisRepository from app.infra.db.postgresql.network_settings import NetworkSettingsRepository from app.infra.db.postgresql.scada import ScadaInfoRepository +from app.infra.db.postgresql import read_queries from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository @@ -56,13 +57,69 @@ def _points( ] +async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset): + descriptors = [] + series = [] + + def add(*, source, element_type, element_id, device_id=None, field, unit, unit_inferred=False): + descriptors.append({ + "source": source, "element_type": element_type, + "id": device_id if source == "scada" else element_id, "field": field, + }) + metric = _target_metric(element_type) + series.append(ElementHistorySeries( + element_id=element_id, element_type=element_type, device_id=device_id, + metric=metric, source=( + HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED + ) if source == "scada" else ( + HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION + ), + source_unit=unit, display_unit=display_unit_for_metric(metric.value), + unit_inferred=unit_inferred, points=[], + )) + + # Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable. + for device_id, (device, element_type, element_id) in sorted(selected_devices.items()): + for field in ("monitored_value", "cleaned_value"): + add(source="scada", element_type=element_type, element_id=element_id, + device_id=device_id, field=field, unit=device["measurement_unit"]) + if query.mode.value != "observed": + units = await NetworkSettingsRepository.get_result_units(postgres_conn) + source = "analysis" if query.mode.value == "analysis_comparison" else "realtime" + inferred = False + if source == "analysis": + run = await AnalysisRepository.get_run(postgres_conn, query.run_id) + if run is None: + raise ValueError(f"analysis run does not exist: {query.run_id}") + recorded = (run.get("parameters") or {}).get("result_units") + inferred = not isinstance(recorded, dict) + if not inferred: + units = recorded + targets = {(t.element_type, t.element_id) for t in query.elements} + for element_type, element_id in sorted(targets): + metric = _target_metric(element_type) + add(source=source, element_type=element_type, element_id=element_id, + field=metric.value, unit=str(units.get(metric.value, "")).strip(), unit_inferred=inferred) + rows = await read_queries.history_rows( + timescale_conn, descriptors, start=query.start_time, end=query.end_time, + run_id=query.run_id, limit=limit + 1, offset=offset, + ) + for row in rows[:limit]: + series[row["series_index"]].points.append(HistoryPoint(time=row["time"], value=row["value"])) + return ElementHistoryResponse(series=[s for s in series if s.points], truncated=len(rows) > limit) + + class TimeseriesHistoryService: @staticmethod async def query( timescale_conn: AsyncConnection, postgres_conn: AsyncConnection, query: ElementHistoryQuery, + *, row_limit: int | None = None, row_offset: int = 0, ) -> ElementHistoryResponse: + if row_offset < 0 or (row_offset and row_limit is None) or (row_limit is not None and row_limit < 1): + raise ValueError("row_offset requires a bounded page") + pipe_ids = list( dict.fromkeys( target.element_id @@ -78,8 +135,10 @@ class TimeseriesHistoryService: ) ) devices = await ScadaInfoRepository.get_scadas_for_elements( - postgres_conn, junction_ids, pipe_ids + postgres_conn, junction_ids, pipe_ids, **({"row_limit": 1001} if row_limit is not None else {}) ) + if row_limit is not None and len(devices) > 1000: + raise ValueError("too many device mappings; narrow the element selection") devices_by_element: dict[tuple[HistoryElementType, str], list[dict[str, Any]]] = ( defaultdict(list) ) @@ -122,6 +181,12 @@ class TimeseriesHistoryService: target.element_id, ) + if row_limit is not None and len(selected_devices) > 20: + raise ValueError("select at most 20 devices explicitly") + if row_limit is not None: + return await _query_page( + timescale_conn, postgres_conn, query, selected_devices, row_limit, row_offset, + ) scada_rows = await ScadaRepository.get_scada_by_ids_time_range( timescale_conn, list(selected_devices), diff --git a/app/services/web_search.py b/app/services/web_search.py deleted file mode 100644 index dc98efa..0000000 --- a/app/services/web_search.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import Any, Literal - -import httpx -from pydantic import BaseModel, Field - -from app.core.config import settings - - -Freshness = Literal["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"] - - -class WebSearchRequest(BaseModel): - query: str = Field(..., min_length=1, description="搜索关键词") - freshness: Freshness | str = Field( - default="noLimit", - description="时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围", - ) - summary: bool = Field(default=True, description="是否返回网页摘要") - count: int = Field(default=10, ge=1, le=50, description="返回结果数量") - include: list[str] | None = Field(default=None, description="限定搜索域名") - exclude: list[str] | None = Field(default=None, description="排除搜索域名") - - -class BochaSearchConfigError(RuntimeError): - pass - - -class BochaSearchAPIError(RuntimeError): - def __init__(self, status_code: int, detail: Any): - super().__init__("Bocha Web Search API request failed") - self.status_code = status_code - self.detail = detail - - -def _build_payload(request: WebSearchRequest) -> dict[str, Any]: - payload = request.model_dump(exclude_none=True) - if request.include: - payload["include"] = ",".join(request.include) - if request.exclude: - payload["exclude"] = ",".join(request.exclude) - return payload - - -async def search_bocha_web( - request: WebSearchRequest, - *, - client: httpx.AsyncClient | None = None, -) -> dict[str, Any]: - if not settings.BOCHA_API_KEY: - raise BochaSearchConfigError("BOCHA_API_KEY is not configured") - - headers = { - "Authorization": f"Bearer {settings.BOCHA_API_KEY}", - "Content-Type": "application/json", - } - payload = _build_payload(request) - - if client is not None: - response = await client.post( - settings.BOCHA_WEB_SEARCH_URL, - headers=headers, - json=payload, - ) - return _parse_response(response) - - async with httpx.AsyncClient( - timeout=settings.BOCHA_WEB_SEARCH_TIMEOUT_SECONDS - ) as managed_client: - response = await managed_client.post( - settings.BOCHA_WEB_SEARCH_URL, - headers=headers, - json=payload, - ) - return _parse_response(response) - - -def _parse_response(response: httpx.Response) -> dict[str, Any]: - try: - response.raise_for_status() - except httpx.HTTPStatusError as exc: - raise BochaSearchAPIError( - exc.response.status_code, - _response_detail(exc.response), - ) from exc - - return response.json() - - -def _response_detail(response: httpx.Response) -> Any: - try: - return response.json() - except ValueError: - return response.text diff --git a/app/services/workbench_runtime.py b/app/services/workbench_runtime.py index b3f4d21..7b12901 100644 --- a/app/services/workbench_runtime.py +++ b/app/services/workbench_runtime.py @@ -371,3 +371,112 @@ def _timestamp(value: datetime | str) -> float: else datetime.fromisoformat(value.replace("Z", "+00:00")) ) return parsed.timestamp() + + +# Persisted runtime queries shared by REST and MCP. +from contextlib import asynccontextmanager +import logging +from pydantic import ValidationError +from app.auth.project_dependencies import ProjectContext +from app.infra.db.postgresql.analysis import AnalysisRepository +from app.infra.db.postgresql.workbench_operations import WorkbenchOperationsRepository +from app.domain.schemas.workbench_runtime import WorkbenchRuntimeSnapshot + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def _projection_transaction(conn): + transaction = getattr(conn, "transaction", None) + if transaction is None: + yield + return + async with transaction(): + yield + + +async def _sensor_location_map_payload(conn, results): + node_ids = sensor_location_ids_for_map_projection(results) + if not node_ids: + return None + points = await AnalysisRepository.list_junction_map_points(conn,node_ids) + return project_sensor_location_map_payload(node_ids,points) + + +def _validate_documents(documents, model, label): + valid=[];rejected=0 + for document in documents: + try:valid.append(model.model_validate(document).model_dump()) + except ValidationError: + rejected+=1 + logger.warning("Workbench %s document was rejected",label) + return valid,rejected + + +async def read_workbench_runtime(ctx: ProjectContext, conn): + analysis_runs: list[dict] | None = None + latest_results: list[dict] | None = None + latest_analysis_map_payload: dict | None = None + analysis_availability = "not_configured" + alert_storage_configured = False + alerts: list[dict] | None = None + schedule_availability = "not_configured" + alert_availability = "not_configured" + try: + async with _projection_transaction(conn): + analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100) + if analysis_runs: + latest_results = await AnalysisRepository.list_recent_results( + conn, analysis_runs[0]["run_id"], limit=100 + ) + analysis_availability = "ready" if analysis_runs else "not_configured" + except Exception: + logger.warning("Workbench analysis projection is unavailable", exc_info=True) + analysis_runs = None + latest_results = None + analysis_availability = "degraded" + if analysis_runs and latest_results: + try: + async with _projection_transaction(conn): + latest_analysis_map_payload = await _sensor_location_map_payload( + conn, latest_results + ) + except Exception: + logger.warning( + "Workbench analysis spatial references are unavailable", + exc_info=True, + ) + try: + async with _projection_transaction(conn): + operations_availability = await WorkbenchOperationsRepository.availability(conn) + alert_storage_configured = operations_availability.alerts + except Exception: + logger.warning("Workbench operations storage probe is unavailable", exc_info=True) + schedule_availability = "degraded" + alert_availability = "degraded" + if alert_storage_configured: + try: + async with _projection_transaction(conn): + alert_documents = await WorkbenchOperationsRepository.list_alerts( + conn, str(ctx.project_id) + ) + alerts, invalid_alerts = _validate_documents( + alert_documents, WorkbenchPersistedAlert, "alert" + ) + alert_availability = "degraded" if invalid_alerts else "ready" + except Exception: + alerts = None + alert_availability = "degraded" + logger.warning("Workbench alert projection is unavailable", exc_info=True) + return await build_workbench_runtime_snapshot( + analysis_runs=analysis_runs, + latest_analysis_results=latest_results, + latest_analysis_map_payload=latest_analysis_map_payload, + alert_storage_configured=alert_storage_configured, + persisted_alerts=alerts, + scenario_availability=analysis_availability, + artifact_availability=analysis_availability, + schedule_availability=schedule_availability, + alert_availability=alert_availability, + projection_degraded=False, + ) diff --git a/contracts/manifest.json b/contracts/manifest.json index 53f0536..b663c59 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "c85b123b9d736a9558fce129cc8343441d50e9fef99532f2ad68c9241405b034" + "sha256": "949077b0e1c711cfef78319d24cbe081e8d71444e7b25018507e12ba374018a3" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index 3d7a85a..4df21b0 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -286,6 +286,7 @@ "map_extent": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -348,6 +349,7 @@ "map_extent": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -429,6 +431,7 @@ "map_extent": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -491,6 +494,16 @@ } ] }, + "progress": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationProgress" + }, + { + "type": "null" + } + ] + }, "report": { "anyOf": [ { @@ -546,6 +559,18 @@ ], "title": "Plan Revision" }, + "progress_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Progress Revision" + }, "report_revision": { "anyOf": [ { @@ -637,6 +662,309 @@ "title": "AgentAuthContextResponse", "type": "object" }, + "AgentMessage": { + "additionalProperties": false, + "properties": { + "conversation_owner_id": { + "format": "uuid", + "title": "Conversation Owner Id", + "type": "string" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "delivered_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Delivered At" + }, + "direction": { + "enum": [ + "to_task", + "to_chat" + ], + "title": "Direction", + "type": "string" + }, + "failure_detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Detail" + }, + "in_reply_to": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "In Reply To" + }, + "message_id": { + "format": "uuid", + "title": "Message Id", + "type": "string" + }, + "native_message_id": { + "minLength": 1, + "title": "Native Message Id", + "type": "string" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "run_id": { + "format": "uuid", + "title": "Run Id", + "type": "string" + }, + "source_owner_id": { + "format": "uuid", + "title": "Source Owner Id", + "type": "string" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + }, + "status": { + "enum": [ + "pending", + "enqueued", + "delivered", + "failed" + ], + "title": "Status", + "type": "string" + }, + "target_session_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Session Id" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + }, + "text": { + "maxLength": 12000, + "minLength": 1, + "pattern": "\\S", + "title": "Text", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "project_id", + "message_id", + "request_id", + "task_id", + "run_id", + "source_session_id", + "source_owner_id", + "conversation_owner_id", + "direction", + "text", + "status", + "native_message_id", + "created_at", + "updated_at" + ], + "title": "AgentMessage", + "type": "object" + }, + "AgentMessageCreate": { + "additionalProperties": false, + "properties": { + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "run_id": { + "format": "uuid", + "title": "Run Id", + "type": "string" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + }, + "text": { + "maxLength": 12000, + "minLength": 1, + "pattern": "\\S", + "title": "Text", + "type": "string" + } + }, + "required": [ + "request_id", + "task_id", + "run_id", + "source_session_id", + "text" + ], + "title": "AgentMessageCreate", + "type": "object" + }, + "AgentMessageDelivery": { + "additionalProperties": false, + "properties": { + "failure_detail": { + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Detail" + }, + "native_message_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Native Message Id" + }, + "status": { + "enum": [ + "enqueued", + "delivered", + "failed" + ], + "title": "Status", + "type": "string" + }, + "target_session_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Session Id" + } + }, + "required": [ + "status" + ], + "title": "AgentMessageDelivery", + "type": "object" + }, + "AgentMessagePage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AgentMessage" + }, + "title": "Items", + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + } + }, + "required": [ + "items" + ], + "title": "AgentMessagePage", + "type": "object" + }, + "AgentMessageReply": { + "additionalProperties": false, + "properties": { + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + }, + "text": { + "maxLength": 12000, + "minLength": 1, + "pattern": "\\S", + "title": "Text", + "type": "string" + } + }, + "required": [ + "request_id", + "source_session_id", + "text" + ], + "title": "AgentMessageReply", + "type": "object" + }, "AgentRunCancelCommand": { "additionalProperties": false, "properties": { @@ -838,6 +1166,7 @@ "request_data": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -936,6 +1265,105 @@ "title": "AuditLogResponse", "type": "object" }, + "BindInput": { + "additionalProperties": false, + "properties": { + "native_session_id": { + "maxLength": 160, + "minLength": 1, + "pattern": "^\\S(?:.*\\S)?$", + "title": "Native Session Id", + "type": "string" + }, + "parent_session_id": { + "anyOf": [ + { + "maxLength": 160, + "minLength": 1, + "pattern": "^\\S(?:.*\\S)?$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Session Id" + }, + "purpose": { + "enum": [ + "ordinary_chat", + "task_execution", + "task_reader", + "task_collaboration" + ], + "title": "Purpose", + "type": "string" + }, + "task_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + } + }, + "required": [ + "native_session_id", + "purpose" + ], + "title": "BindInput", + "type": "object" + }, + "BindResponse": { + "properties": { + "context_handle": { + "title": "Context Handle", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "run_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Run Id" + }, + "task_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + } + }, + "required": [ + "context_handle", + "expires_at", + "task_id", + "run_id" + ], + "title": "BindResponse", + "type": "object" + }, "Body_patch_admin_projects_project_id_model_imports": { "properties": { "file": { @@ -1030,6 +1458,7 @@ "link_result_list": { "description": "管道模拟结果列表", "items": { + "additionalProperties": true, "type": "object" }, "title": "Link Result List", @@ -1038,6 +1467,7 @@ "node_result_list": { "description": "节点模拟结果列表", "items": { + "additionalProperties": true, "type": "object" }, "title": "Node Result List", @@ -1062,6 +1492,7 @@ "iforest_params": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -1088,6 +1519,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -1227,6 +1659,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -1253,6 +1686,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -1305,6 +1739,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -1326,6 +1761,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -2481,6 +2917,7 @@ "additionalProperties": false, "properties": { "attributes": { + "additionalProperties": true, "title": "Attributes", "type": "object" }, @@ -2542,6 +2979,19 @@ "title": "GisAttributeGroup", "type": "object" }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, "HistoryElementType": { "enum": [ "pipe", @@ -2982,10 +3432,38 @@ "title": "Label", "type": "string" }, + "map_targets": { + "items": { + "$ref": "#/components/schemas/InvestigationMapTarget" + }, + "maxItems": 100, + "title": "Map Targets", + "type": "array" + }, "node_id": { "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,79}$", "title": "Node Id", "type": "string" + }, + "status": { + "anyOf": [ + { + "enum": [ + "pending", + "running", + "completed", + "waiting_human", + "failed", + "skipped", + "cancelled" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" } }, "required": [ @@ -2995,6 +3473,106 @@ "title": "InvestigationDiagramNodeRef", "type": "object" }, + "InvestigationMapTarget": { + "additionalProperties": false, + "properties": { + "feature_id": { + "maxLength": 128, + "minLength": 1, + "title": "Feature Id", + "type": "string" + }, + "source_id": { + "enum": [ + "junctions", + "pipes", + "valves", + "pumps", + "tanks", + "reservoirs", + "scada" + ], + "title": "Source Id", + "type": "string" + } + }, + "required": [ + "source_id", + "feature_id" + ], + "title": "InvestigationMapTarget", + "type": "object" + }, + "InvestigationProgress": { + "additionalProperties": false, + "description": "A complete reading snapshot, not a plan or a business result.", + "properties": { + "diagram": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationDiagram" + }, + { + "type": "null" + } + ] + }, + "summary": { + "maxLength": 20000, + "minLength": 1, + "title": "Summary", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "InvestigationProgress", + "type": "object" + }, + "InvestigationProgressSnapshot": { + "additionalProperties": false, + "properties": { + "execution_generation": { + "minimum": 1.0, + "title": "Execution Generation", + "type": "integer" + }, + "progress": { + "$ref": "#/components/schemas/InvestigationProgress" + }, + "published_at": { + "format": "date-time", + "title": "Published At", + "type": "string" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "run_id": { + "format": "uuid", + "title": "Run Id", + "type": "string" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + } + }, + "required": [ + "revision", + "run_id", + "execution_generation", + "published_at", + "source_session_id", + "progress" + ], + "title": "InvestigationProgressSnapshot", + "type": "object" + }, "InvestigationReport": { "additionalProperties": false, "description": "Read model: absent historical facts remain unknown, never inferred.", @@ -3412,6 +3990,7 @@ }, { "items": { + "additionalProperties": true, "type": "object" }, "type": "array" @@ -3937,6 +4516,16 @@ } ] }, + "latest_progress": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationProgressSnapshot" + }, + { + "type": "null" + } + ] + }, "latest_report": { "anyOf": [ { @@ -3947,6 +4536,16 @@ } ] }, + "latest_run": { + "anyOf": [ + { + "$ref": "#/components/schemas/OperationalTaskRun" + }, + { + "type": "null" + } + ] + }, "lifecycle_status": { "default": "open", "enum": [ @@ -4852,6 +5451,16 @@ "title": "Execution Request Id", "type": "string" }, + "latest_run": { + "anyOf": [ + { + "$ref": "#/components/schemas/OperationalTaskRun" + }, + { + "type": "null" + } + ] + }, "lifecycle_status": { "default": "open", "enum": [ @@ -5255,6 +5864,7 @@ "properties": { "items": { "items": { + "additionalProperties": true, "type": "object" }, "title": "Items", @@ -5313,6 +5923,113 @@ "title": "Page[str]", "type": "object" }, + "PlaceSearchItem": { + "properties": { + "address": { + "title": "Address", + "type": "string" + }, + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + }, + "district": { + "const": "奉贤区", + "default": "奉贤区", + "title": "District", + "type": "string" + }, + "id": { + "minLength": 1, + "title": "Id", + "type": "string" + }, + "latitude": { + "maximum": 90.0, + "minimum": -90.0, + "title": "Latitude", + "type": "number" + }, + "longitude": { + "maximum": 180.0, + "minimum": -180.0, + "title": "Longitude", + "type": "number" + }, + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "id", + "name", + "address", + "longitude", + "latitude" + ], + "title": "PlaceSearchItem", + "type": "object" + }, + "PlaceSearchRequest": { + "additionalProperties": false, + "properties": { + "keyword": { + "maxLength": 120, + "minLength": 1, + "title": "Keyword", + "type": "string" + } + }, + "required": [ + "keyword" + ], + "title": "PlaceSearchRequest", + "type": "object" + }, + "PlaceSearchResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PlaceSearchItem" + }, + "maxItems": 10, + "title": "Items", + "type": "array" + }, + "region": { + "const": "奉贤区", + "default": "奉贤区", + "title": "Region", + "type": "string" + }, + "total": { + "maximum": 10.0, + "minimum": 0.0, + "title": "Total", + "type": "integer" + }, + "truncated": { + "default": false, + "title": "Truncated", + "type": "boolean" + } + }, + "required": [ + "items", + "total" + ], + "title": "PlaceSearchResponse", + "type": "object" + }, "PressureRegulationRest": { "properties": { "duration": { @@ -5329,6 +6046,7 @@ "title": "Duration" }, "pump_control": { + "additionalProperties": true, "description": "泵控制策略", "title": "Pump Control", "type": "object" @@ -5347,6 +6065,7 @@ "tank_init_level": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -5378,6 +6097,7 @@ }, "errors": { "items": { + "additionalProperties": true, "type": "object" }, "title": "Errors", @@ -5690,6 +6410,7 @@ "map_extent": { "anyOf": [ { + "additionalProperties": true, "type": "object" }, { @@ -5996,6 +6717,34 @@ "minimum": 1.0, "title": "Limit", "type": "integer" + }, + "task_created_from": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Created From" + }, + "task_type": { + "anyOf": [ + { + "enum": [ + "scheduled_flow", + "external_work_order", + "event_investigation" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Type" } }, "title": "RecoverExpiredLeasesCommand", @@ -6910,6 +7659,65 @@ "title": "TaskAlertState", "type": "object" }, + "TaskClearHighlights": { + "additionalProperties": false, + "properties": { + "action": { + "const": "clear_highlights", + "title": "Action", + "type": "string" + } + }, + "required": [ + "action" + ], + "title": "TaskClearHighlights", + "type": "object" + }, + "TaskClearOverlay": { + "additionalProperties": false, + "properties": { + "action": { + "const": "clear_overlay", + "title": "Action", + "type": "string" + }, + "overlay_id": { + "maxLength": 128, + "minLength": 1, + "title": "Overlay Id", + "type": "string" + } + }, + "required": [ + "action", + "overlay_id" + ], + "title": "TaskClearOverlay", + "type": "object" + }, + "TaskCloseChart": { + "additionalProperties": false, + "properties": { + "action": { + "const": "close_chart", + "title": "Action", + "type": "string" + }, + "chart_id": { + "maxLength": 128, + "minLength": 1, + "title": "Chart Id", + "type": "string" + } + }, + "required": [ + "action", + "chart_id" + ], + "title": "TaskCloseChart", + "type": "object" + }, "TaskDataQuery": { "additionalProperties": false, "properties": { @@ -7015,17 +7823,60 @@ "type": "string" }, "source": { - "enum": [ - "workbench_runtime", - "scada_devices", - "scada_readings", - "peripheral_points", - "peripheral_work_orders", - "peripheral_observations", - "peripheral_test_data" + "anyOf": [ + { + "enum": [ + "workbench_runtime", + "scada_devices", + "scada_readings", + "peripheral_points", + "peripheral_work_orders", + "peripheral_observations", + "peripheral_test_data" + ], + "type": "string" + }, + { + "enum": [ + "run_simulation", + "run_contaminant_simulation", + "analyze_valve_closure", + "analyze_flushing", + "analyze_pressure_regulation", + "optimize_sensor_placement", + "run_burst_detection", + "run_burst_location", + "analyze_valve_isolation", + "run_leakage_identification", + "get_element_properties", + "list_elements", + "get_network_options", + "get_network_schema", + "list_scada_devices", + "get_scada_device", + "query_scada_readings", + "query_model_baseline_series", + "get_model_baseline_at_time", + "query_model_baseline_field", + "query_analysis_series", + "query_analysis_values", + "list_analysis_runs", + "get_analysis_run", + "list_analysis_results", + "list_sensor_placement_runs", + "get_sensor_placement_run", + "query_element_history", + "get_element_gis_properties", + "get_source_gis_properties", + "get_workbench_runtime", + "get_task_context", + "query_peripheral_data", + "get_task_evidence" + ], + "type": "string" + } ], - "title": "Source", - "type": "string" + "title": "Source" }, "source_ref": { "minLength": 1, @@ -7048,6 +7899,375 @@ "title": "TaskDataSnapshot", "type": "object" }, + "TaskHighlightFeatures": { + "additionalProperties": false, + "properties": { + "action": { + "const": "highlight_features", + "title": "Action", + "type": "string" + }, + "effect": { + "default": "steady", + "enum": [ + "steady", + "pulse" + ], + "title": "Effect", + "type": "string" + }, + "feature_ids": { + "items": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "title": "Feature Ids", + "type": "array" + }, + "layer": { + "enum": [ + "junctions", + "pipes", + "valves", + "pumps", + "tanks", + "reservoirs", + "scada" + ], + "title": "Layer", + "type": "string" + } + }, + "required": [ + "action", + "layer", + "feature_ids" + ], + "title": "TaskHighlightFeatures", + "type": "object" + }, + "TaskOpenChart": { + "additionalProperties": false, + "properties": { + "action": { + "const": "open_chart", + "title": "Action", + "type": "string" + }, + "chart": { + "$ref": "#/components/schemas/TaskSafeChart" + }, + "chart_id": { + "maxLength": 128, + "minLength": 1, + "title": "Chart Id", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "title": "Evidence Refs", + "type": "array" + } + }, + "required": [ + "action", + "chart_id", + "chart", + "evidence_refs" + ], + "title": "TaskOpenChart", + "type": "object" + }, + "TaskPresentationActiveRun": { + "additionalProperties": false, + "properties": { + "generation": { + "minimum": 1.0, + "title": "Generation", + "type": "integer" + }, + "run_id": { + "format": "uuid", + "title": "Run Id", + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "running", + "succeeded", + "failed", + "timed_out", + "cancelled" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "run_id", + "generation", + "status" + ], + "title": "TaskPresentationActiveRun", + "type": "object" + }, + "TaskPresentationCursor": { + "additionalProperties": false, + "properties": { + "active_run": { + "anyOf": [ + { + "$ref": "#/components/schemas/TaskPresentationActiveRun" + }, + { + "type": "null" + } + ] + }, + "event_cursor": { + "minimum": 0.0, + "title": "Event Cursor", + "type": "integer" + }, + "execution_generation": { + "minimum": 1.0, + "title": "Execution Generation", + "type": "integer" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + "required": [ + "task_id", + "execution_generation", + "active_run", + "event_cursor" + ], + "title": "TaskPresentationCursor", + "type": "object" + }, + "TaskPresentationEventCommand": { + "additionalProperties": false, + "properties": { + "command": { + "discriminator": { + "mapping": { + "clear_highlights": "#/components/schemas/TaskClearHighlights", + "clear_overlay": "#/components/schemas/TaskClearOverlay", + "close_chart": "#/components/schemas/TaskCloseChart", + "highlight_features": "#/components/schemas/TaskHighlightFeatures", + "open_chart": "#/components/schemas/TaskOpenChart", + "set_overlay": "#/components/schemas/TaskSetOverlay" + }, + "propertyName": "action" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/TaskHighlightFeatures" + }, + { + "$ref": "#/components/schemas/TaskClearHighlights" + }, + { + "$ref": "#/components/schemas/TaskOpenChart" + }, + { + "$ref": "#/components/schemas/TaskCloseChart" + }, + { + "$ref": "#/components/schemas/TaskSetOverlay" + }, + { + "$ref": "#/components/schemas/TaskClearOverlay" + } + ], + "title": "Command" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "run_id": { + "format": "uuid", + "title": "Run Id", + "type": "string" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + } + }, + "required": [ + "request_id", + "run_id", + "source_session_id", + "command" + ], + "title": "TaskPresentationEventCommand", + "type": "object" + }, + "TaskPresentationEventResult": { + "additionalProperties": false, + "properties": { + "event_seq": { + "minimum": 1.0, + "title": "Event Seq", + "type": "integer" + }, + "execution_generation": { + "minimum": 1.0, + "title": "Execution Generation", + "type": "integer" + }, + "recorded": { + "const": true, + "default": true, + "title": "Recorded", + "type": "boolean" + }, + "run_id": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + "required": [ + "event_seq", + "run_id", + "execution_generation" + ], + "title": "TaskPresentationEventResult", + "type": "object" + }, + "TaskSafeChart": { + "additionalProperties": false, + "properties": { + "chart_type": { + "enum": [ + "line", + "bar" + ], + "title": "Chart Type", + "type": "string" + }, + "series": { + "items": { + "$ref": "#/components/schemas/TaskSafeChartSeries" + }, + "maxItems": 8, + "minItems": 1, + "title": "Series", + "type": "array" + }, + "title": { + "maxLength": 200, + "title": "Title", + "type": "string" + }, + "x_data": { + "items": { + "maxLength": 128, + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "title": "X Data", + "type": "array" + } + }, + "required": [ + "title", + "chart_type", + "x_data", + "series" + ], + "title": "TaskSafeChart", + "type": "object" + }, + "TaskSafeChartSeries": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "type": "number" + }, + "maxItems": 100, + "minItems": 1, + "title": "Data", + "type": "array" + }, + "name": { + "maxLength": 128, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name", + "data" + ], + "title": "TaskSafeChartSeries", + "type": "object" + }, + "TaskSetOverlay": { + "additionalProperties": false, + "properties": { + "action": { + "const": "set_overlay", + "title": "Action", + "type": "string" + }, + "evidence_id": { + "minLength": 1, + "title": "Evidence Id", + "type": "string" + }, + "mode": { + "enum": [ + "geometry", + "heatmap" + ], + "title": "Mode", + "type": "string" + }, + "overlay_id": { + "maxLength": 128, + "minLength": 1, + "title": "Overlay Id", + "type": "string" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "action", + "overlay_id", + "title", + "mode", + "evidence_id" + ], + "title": "TaskSetOverlay", + "type": "object" + }, "TaskSource": { "additionalProperties": false, "properties": { @@ -7163,83 +8383,37 @@ "title": "TiandituGeocodeRequest", "type": "object" }, - "WebSearchRequest": { + "ValidationError": { "properties": { - "count": { - "default": 10, - "description": "返回结果数量", - "maximum": 50.0, - "minimum": 1.0, - "title": "Count", - "type": "integer" - }, - "exclude": { - "anyOf": [ - { - "items": { + "loc": { + "items": { + "anyOf": [ + { "type": "string" }, - "type": "array" - }, - { - "type": "null" - } - ], - "description": "排除搜索域名", - "title": "Exclude" + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" }, - "freshness": { - "anyOf": [ - { - "enum": [ - "noLimit", - "oneDay", - "oneWeek", - "oneMonth", - "oneYear" - ], - "type": "string" - }, - { - "type": "string" - } - ], - "default": "noLimit", - "description": "时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围", - "title": "Freshness" - }, - "include": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "description": "限定搜索域名", - "title": "Include" - }, - "query": { - "description": "搜索关键词", - "minLength": 1, - "title": "Query", + "msg": { + "title": "Message", "type": "string" }, - "summary": { - "default": true, - "description": "是否返回网页摘要", - "title": "Summary", - "type": "boolean" + "type": { + "title": "Error Type", + "type": "string" } }, "required": [ - "query" + "loc", + "msg", + "type" ], - "title": "WebSearchRequest", + "title": "ValidationError", "type": "object" }, "WorkOrderActivity": { @@ -9639,6 +10813,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Patch Admin Projects Project Id Model Imports", "type": "object" } @@ -9746,6 +10921,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Admin Projects Project Id Model Imports", "type": "object" } @@ -10540,6 +11716,723 @@ ] } }, + "/api/v1/agent-messages": { + "get": { + "operationId": "get_agent_messages", + "parameters": [ + { + "in": "query", + "name": "session_id", + "required": true, + "schema": { + "maxLength": 500, + "minLength": 1, + "title": "Session Id", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + }, + { + "in": "query", + "name": "pending_only", + "required": false, + "schema": { + "default": false, + "title": "Pending Only", + "type": "boolean" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessagePage" + } + } + }, + "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": "List Agent Messages", + "tags": [ + "Agent Messages" + ] + }, + "post": { + "operationId": "post_agent_messages", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessageCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessage" + } + } + }, + "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": "Create Agent Message", + "tags": [ + "Agent Messages" + ] + } + }, + "/api/v1/agent-messages/pending": { + "get": { + "operationId": "get_agent_messages_pending", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessagePage" + } + } + }, + "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": "List Pending Agent Messages", + "tags": [ + "Agent Messages" + ] + } + }, + "/api/v1/agent-messages/{message_id}": { + "get": { + "operationId": "get_agent_messages_message_id", + "parameters": [ + { + "in": "path", + "name": "message_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Message Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessage" + } + } + }, + "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": "Get Agent Message", + "tags": [ + "Agent Messages" + ] + } + }, + "/api/v1/agent-messages/{message_id}/delivery": { + "post": { + "operationId": "post_agent_messages_message_id_delivery", + "parameters": [ + { + "in": "path", + "name": "message_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Message Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessageDelivery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessage" + } + } + }, + "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": "Record Agent Message Delivery", + "tags": [ + "Agent Messages" + ] + } + }, + "/api/v1/agent-messages/{message_id}/reply": { + "post": { + "operationId": "post_agent_messages_message_id_reply", + "parameters": [ + { + "in": "path", + "name": "message_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Message Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessageReply" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentMessage" + } + } + }, + "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": "Reply Agent Message", + "tags": [ + "Agent Messages" + ] + } + }, "/api/v1/all-scada-properties": { "get": { "description": "获取指定水网中所有SCADA点的属性信息", @@ -11600,6 +13493,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Audit Logs Count", "type": "object" } @@ -11871,6 +13765,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Backdrops Properties", "type": "object" } @@ -11967,6 +13862,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -12252,6 +14148,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Burst Detections", "type": "object" } @@ -12362,6 +14259,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Burst Locations", "type": "object" } @@ -12632,6 +14530,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Controls Properties", "type": "object" } @@ -12728,6 +14627,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -13067,6 +14967,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -13298,6 +15199,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Curves Properties", "type": "object" } @@ -13405,6 +15307,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -13526,6 +15429,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Demands Properties", "type": "object" } @@ -13633,6 +15537,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -13735,7 +15640,7 @@ "required": true, "schema": { "description": "总需水量(m³/h)", - "exclusiveMinimum": 0.0, + "exclusiveMinimum": 0, "title": "Demand", "type": "number" } @@ -13856,6 +15761,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -13969,6 +15875,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -14094,6 +16001,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Element Properties", "type": "object" } @@ -14215,6 +16123,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Element Properties With Types", "type": "object" } @@ -14670,6 +16579,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Emitters Properties", "type": "object" } @@ -14777,6 +16687,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -14886,6 +16797,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -15256,6 +17168,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Geocoding Requests", "type": "object" } @@ -16763,6 +18676,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Junctions Properties", "type": "object" } @@ -16870,6 +18784,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -17435,6 +19350,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -17535,6 +19451,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -17667,6 +19584,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Labels Properties", "type": "object" } @@ -17763,6 +19681,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -17884,6 +19803,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Leakage Identifications", "type": "object" } @@ -17994,6 +19914,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Link Properties", "type": "object" } @@ -18752,6 +20673,102 @@ ] } }, + "/api/v1/mcp-query-contexts": { + "post": { + "operationId": "create_context_api_v1_mcp_query_contexts_post", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BindInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BindResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Context", + "tags": [ + "MCP Query Context" + ] + } + }, + "/api/v1/mcp-query-contexts/{context_handle}": { + "delete": { + "operationId": "delete_context_api_v1_mcp_query_contexts__context_handle__delete", + "parameters": [ + { + "in": "path", + "name": "context_handle", + "required": true, + "schema": { + "title": "Context Handle", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Context", + "tags": [ + "MCP Query Context" + ] + } + }, "/api/v1/mixing-configurations": { "delete": { "description": "从网络中删除指定的混合", @@ -18771,6 +20788,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -18871,6 +20889,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -18978,6 +20997,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -19099,6 +21119,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Mixing Configurations Detail", "type": "object" } @@ -19242,6 +21263,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network In Extents", "type": "object" } @@ -19450,6 +21472,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network Options", "type": "object" } @@ -19546,6 +21569,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -19656,6 +21680,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network Options Energy", "type": "object" } @@ -19766,6 +21791,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network Options Pump Energy", "type": "object" } @@ -19873,6 +21899,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -19983,6 +22010,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Network Options Time", "type": "object" } @@ -20083,6 +22111,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Backdrop", @@ -20185,6 +22214,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Control", @@ -20287,6 +22317,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Curve", @@ -20389,6 +22420,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Demand", @@ -20491,6 +22523,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Emitter", @@ -20593,6 +22626,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Energy", @@ -20695,6 +22729,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Junction", @@ -20797,6 +22832,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Label", @@ -20899,6 +22935,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Mixing", @@ -21001,6 +23038,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Option", @@ -21103,6 +23141,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pattern", @@ -21205,6 +23244,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pipe", @@ -21307,6 +23347,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pipe Reaction", @@ -21409,6 +23450,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pump", @@ -21511,6 +23553,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Pump Energy", @@ -21613,6 +23656,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Quality", @@ -21715,6 +23759,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Reaction", @@ -21816,6 +23861,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Region", @@ -21918,6 +23964,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Reservoir", @@ -22019,6 +24066,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Scada Device", @@ -22121,6 +24169,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Source", @@ -22223,6 +24272,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Tag", @@ -22325,6 +24375,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Tank", @@ -22427,6 +24478,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Tank Reaction", @@ -22529,6 +24581,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Time", @@ -22631,6 +24684,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Valve", @@ -22733,6 +24787,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Network Schemas Vertex", @@ -23097,6 +25152,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Node Properties", "type": "object" } @@ -23893,6 +25949,44 @@ "title": "Status" } }, + { + "in": "query", + "name": "task_type", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "scheduled_flow", + "external_work_order", + "event_investigation" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Type" + } + }, + { + "in": "query", + "name": "task_created_from", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Created From" + } + }, { "in": "query", "name": "limit", @@ -26711,6 +28805,230 @@ ] } }, + "/api/v1/operational-tasks/{task_id}/presentation-cursor": { + "get": { + "operationId": "get_operational_tasks_task_id_presentation_cursor", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskPresentationCursor" + } + } + }, + "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": "Get Operational Task Presentation Cursor", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/presentation-events": { + "post": { + "operationId": "post_operational_tasks_task_id_presentation_events", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskPresentationEventCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskPresentationEventResult" + } + } + }, + "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": "Publish Operational Task Presentation Event", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/operational-tasks/{task_id}/reports": { "get": { "operationId": "get_operational_tasks_task_id_reports", @@ -27074,6 +29392,253 @@ ] } }, + "/api/v1/operational-tasks/{task_id}/runs": { + "get": { + "operationId": "get_operational_tasks_task_id_runs", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "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/OperationalTaskRunPage" + } + } + }, + "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": "List Task Run History", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/runs/{run_id}": { + "get": { + "operationId": "get_operational_tasks_task_id_runs_run_id", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskRun" + } + } + }, + "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": "Get Task Run", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/operational-tasks/{task_id}/runs/{run_id}/cancel": { "post": { "operationId": "post_operational_tasks_task_id_runs_run_id_cancel", @@ -28202,6 +30767,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -28433,6 +30999,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Patterns Properties", "type": "object" } @@ -28540,6 +31107,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -28649,6 +31217,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -28770,6 +31339,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Pipe Reactions Detail", "type": "object" } @@ -30685,6 +33255,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Pipes Properties", "type": "object" } @@ -30792,6 +33363,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -31352,6 +33924,114 @@ ] } }, + "/api/v1/place-search-requests": { + "post": { + "description": "调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。", + "operationId": "post_place_search_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/PlaceSearchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaceSearchResponse" + } + } + }, + "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": "Search Fengxian places", + "tags": [ + "Place Search" + ] + } + }, "/api/v1/pressure-regulation-analyses": { "post": { "description": "高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。", @@ -31482,6 +34162,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Post Project Return Dict Runs", "type": "object" } @@ -33147,6 +35828,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Pumps Properties", "type": "object" } @@ -33254,6 +35936,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -33375,6 +36058,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Quality Configurations Properties", "type": "object" } @@ -33471,6 +36155,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -33580,6 +36265,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -33690,6 +36376,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Reactions Detail", "type": "object" } @@ -33787,6 +36474,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -34004,6 +36692,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -34110,6 +36799,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -34230,6 +36920,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Regions Detail", "type": "object" } @@ -35661,6 +38352,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Reservoirs Properties", "type": "object" } @@ -35768,6 +38460,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -36354,6 +39047,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Rule Properties", "type": "object" } @@ -36450,6 +39144,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -36561,6 +39256,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Rule Schemas", @@ -36899,6 +39595,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Scada Properties", "type": "object" } @@ -37997,6 +40694,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -38104,6 +40802,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -38225,6 +40924,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Sources Detail", "type": "object" } @@ -38335,6 +41035,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Status", "type": "object" } @@ -38444,6 +41145,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -38555,6 +41257,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Status Schemas", @@ -38774,6 +41477,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -38906,6 +41610,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Tags Detail", "type": "object" } @@ -39004,6 +41709,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -39125,6 +41831,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Tank Reactions Detail", "type": "object" } @@ -41653,6 +44360,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Tanks Properties", "type": "object" } @@ -41760,6 +44468,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -42560,6 +45269,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -45214,6 +47924,7 @@ "application/json": { "schema": { "additionalProperties": { + "additionalProperties": true, "type": "object" }, "title": "Response Get Title Schemas", @@ -45315,6 +48026,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Titles", "type": "object" } @@ -45411,6 +48123,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -47140,6 +49853,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Valves Properties", "type": "object" } @@ -47247,6 +49961,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -47826,6 +50541,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -47926,6 +50642,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -48168,6 +50885,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Response Get Visual Elements Properties", "type": "object" } @@ -48264,6 +50982,7 @@ "content": { "application/json": { "schema": { + "additionalProperties": true, "title": "Payload", "type": "object" } @@ -48354,115 +51073,6 @@ ] } }, - "/api/v1/web-searches": { - "post": { - "description": "调用 Bocha Web Search API 获取实时网页搜索结果", - "operationId": "post_web_searches", - "parameters": [ - { - "in": "header", - "name": "X-Project-Id", - "required": true, - "schema": { - "title": "X-Project-Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebSearchRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "title": "Response Post Web Searches", - "type": "object" - } - } - }, - "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": "Web Search", - "tags": [ - "Web Search" - ] - } - }, "/api/v1/workbench/analysis-runs": { "get": { "description": "Return a bounded, sanitized analysis history page for the workbench.", diff --git a/docs/investigation-progress-and-presentation.md b/docs/investigation-progress-and-presentation.md new file mode 100644 index 0000000..065c6aa --- /dev/null +++ b/docs/investigation-progress-and-presentation.md @@ -0,0 +1,116 @@ +# 调查进展与实时展示 + +调查执行中可持续发布正文和完整 Mermaid 图,尚未形成结论时也能发布。 +Server 将阅读进展、业务报告和浏览器展示请求分别保存。运行结束仍要求同轮最终报告, +进展中的节点状态不参与调度、审批或结束判断。 + +## 进展快照 + +`POST /api/v1/operational-tasks/{task_id}/agent-artifacts` 新增可选 `progress`: + +```json +{ + "request_id": "", + "run_id": "", + "source_session_id": "", + "progress": { + "summary": "正在核对投诉时段的监测记录,尚未形成结论。", + "diagram": { + "title": "当前调查进展", + "mermaid": "flowchart LR\nread[核查监测记录] --> judge[判断证据范围]", + "node_refs": [{ + "node_id": "read", + "label": "核查监测记录", + "status": "running", + "evidence_refs": [], + "action_refs": [], + "map_targets": [{"source_id": "pipes", "feature_id": ""}] + }] + } + } +} +``` + +示例中的编号由实际任务和资产替换。正文最长 20,000 字符,`diagram` 可空。 +图示沿用 [调查报告契约](investigation-report-contract.md) 的 Mermaid 安全限制。 +节点状态可空,或为 `pending/running/completed/waiting_human/failed/skipped/cancelled`。 +`map_targets` 只接受 `junctions/pipes/valves/pumps/tanks/reservoirs/scada` 七类来源和要素编号。 +图中节点引用在本次发布内唯一,证据和动作引用必须属于该任务。跨修订可以修改节点编号、 +分支和文字,Server 不验证依赖执行顺序或节点完工。 + +每次传入 `progress` 都完整替换该 run 的快照,传入 `diagram: null` 清除上一版图示。 +省略 `progress` 保留原快照。同一个请求编号重试只发布一次。 +`workbench.operational_task_progress` 每个任务和 run 保存最新一份快照,独立于七天事件清理。 +任务详情和 Agent 上下文返回 `latest_progress`,包含 `revision/run_id/execution_generation/` +`published_at/source_session_id/progress`。只读取当前执行代次,新轮次尚未发布时返回空。 +快照更新发出 `operational.progress.changed`,前端据此读取最新内容。 + +## 浏览器实时联动 + +`POST /api/v1/operational-tasks/{task_id}/presentation-events` 接收 +`request_id/run_id/source_session_id/command`,要求任务执行权限。 +Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `operational.presentation.requested`。 +响应为 `recorded/event_seq/run_id/execution_generation`,表示展示请求已保存, +不表示浏览器已显示或现场动作已执行。 + +| command.action | 参数 | +| --- | --- | +| highlight_features | layer、feature_ids、effect:steady 或 pulse | +| clear_highlights | 无 | +| open_chart | chart_id、chart、非空 evidence_refs | +| close_chart | chart_id | +| set_overlay | overlay_id、title、mode:geometry 或 heatmap、evidence_id | +| clear_overlay | overlay_id | + +图表限定折线和柱形,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列, +序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。 +覆盖层输入不接受模型提供的 `data`。Server 从证据的不可变数据快照提取现有 GeoJSON, +或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。 +热力图只接受记录中的点,权重为有限非负数,未记录权重时取 1。 +没有坐标的地址、空数据、非法几何、模型自绘区域均不能生成覆盖层。该接口不做地址解析、 +范围推算或污染传播计算。展示事件总大小上限为 512 KiB,超限返回 422。 + +`GET /api/v1/operational-tasks/{task_id}/presentation-cursor` 在同一事务中读取 +`task_id/execution_generation/active_run/event_cursor`,其中活跃 run 仅返回 `run_id/generation/status`。 +浏览器打开 Dev 联动、切换任务或断线重连时,以此建立新基线,只消费之后的展示事件。 +跟随开关只影响当前浏览器的临时展示,正文和图示更新始终可读,调查仍由后台继续执行。 +关闭 Dev 面板不改变跟随开关。关闭跟随开关、切换任务或断线时清理临时效果, +执行结束停止执行态动效,已显示的图表与覆盖层保留供阅读。不回放历史请求,不重新执行调查。 + +## 人工反馈续调 + +外围同步器根据动作回执找到关联子工单。样板创建固定为 `revision=1`,首次创建回执不作为 +新的现场反馈入队。后续版本可唤醒已结束调查,旧报告和证据保留,生成新的执行代次。 +现场备注从工单 `activity.kind=feedback` 的记录进入反馈证据,仍标明样板合成来源。 +重复请求或同一源版本重放不增加执行代次,取消调查拒绝新反馈。 + +同步器先送达各父调查反馈,再推进预约工单投影的源版本。定向源事件和动作回执补查保留, +处理源更新早于回执保存的情况。全量对账只为源版本变化补查已结束调查,本次不回填历史反馈。 +原投诉收到新版本后同步推进其已有来源修订号。数据不足仍正常结束本轮, +只有新的可信业务输入进入后续轮次,不依赖图示节点制造人工等待。 + +## 部署与验证 + +新增 [023 迁移](../resources/sql/023_operational_task_progress.sql) 仅适用于 `tjwater_v2`, +增加进展表和两个事件 topic 的 CHECK 取值。2026-10-10 已在目标库完成全库备份、 +迁移事务回滚演练及正式应用,其他项目和旧任务未改动。 + +实际 worker 曾成功投递指定调查,模型发布进展和报告,但因 service 账号的任务权限过滤 +导致 `approved_data_scopes` 为空,误作数据不足结束。这不是业务数据确实不足。 +用户确认开放规则后,任务 API 已移除该额外过滤,沿用账号已有的项目读取权限。 +在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围合成工单成功;没有新增 +角色权限或放宽跨项目、审批和设备控制边界。旧委托路径保留兼容,已有权限无需委托凭据。 +用户级 `tjwater-operational-worker.service` 已重新启用。旧报告保留,未自动重新调查, +不能把旧报告视为修复后的业务验收。任务创建时间下界和并发配置见 +[执行 worker](operational-task-workers.md),未回填旧任务。 +进展读接口在表尚未部署时返回空,写入口须在迁移与 Server 更新后启用。 +按现有数据库发布流程应用迁移,再更新 Server、Agent 和前端,运行中的 worker 需要加载更新。 + +聚焦检查: + +```bash +conda run -n server python -m pytest tests/unit/test_operational_tasks.py tests/unit/test_operational_presentation.py tests/unit/test_peripheral_task_sync.py tests/unit/test_peripheral_push_sync.py tests/api/test_operational_task_dynamic_flow.py tests/api/test_openapi_contract.py -q +git diff --check +``` + +这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。 diff --git a/docs/investigation-report-contract.md b/docs/investigation-report-contract.md index e6fe151..3acc13f 100644 --- a/docs/investigation-report-contract.md +++ b/docs/investigation-report-contract.md @@ -149,6 +149,10 @@ failure_code 为 ARTIFACT_INCOMPLETE;调用方须读取返回的业务状态 ## 发布、阅读与协作 +执行中尚未形成结论的正文和 Mermaid 使用独立的 `progress` 快照持续更新, +见 [调查进展与实时展示](investigation-progress-and-presentation.md)。 +报告的 interim/final 与 concluded/insufficient_data 结果契约保持不变。 + 每次发布保留 `operational.report.published` 同步事件。首次 final、阶段报告转 final、 新轮次 final 或判断/边界/依据/建议/缺口实质变化时,生成 `report_updated` 通知, 其中 `report_reference = {execution_generation, revision}`。纯图示修订不生成额外成果提醒。 diff --git a/docs/mcp-analysis-tools.md b/docs/mcp-analysis-tools.md new file mode 100644 index 0000000..3b6fd80 --- /dev/null +++ b/docs/mcp-analysis-tools.md @@ -0,0 +1,71 @@ +# Agent 业务分析 MCP + +Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共35项:25项查询、下表10项分析;合并6项宿主查询后共41项。工具参数来自Server实际MCP schema,不维护第二套参数契约。 + +| 工具名(不含前缀) | 能力与边界 | 必要权限 | +| --- | --- | --- | +| `run_simulation` | 在线模拟:按水力步长直接计算业务库,不复制数据库;更新模型条件和实时基线,返回指定节点/管段,不创建方案或正式分析运行 | `simulation.run` + `scada.view` | +| `run_burst_detection` | 指定时刻的15天压力异常侦测,至少5个完整监测节点;不定位 | `burst.run` + `scada.view` | +| `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` | +| `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` | +| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` | +| `run_contaminant_simulation` | 已假设污染源的正向浓度传播;不是源头反演 | `simulation.run` + `scada.view` | +| `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` | +| `analyze_flushing` | 指定排放节点和流量的冲洗水力影响;不证明消毒合格 | `simulation.run` + `scada.view` | +| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` | +| `optimize_sensor_placement` | 灵敏度或K-means压力监测点建议;不保存正式布点方案 | `optimization.run`,项目成员角色 | + +## 执行与证据 + +标准水力模拟示例(只展示参数,不执行): + +```json +{"start_time":"2026-10-08T12:00:00+08:00","duration":3600,"output_node_ids":["<已核实节点ID>"],"output_link_ids":["540672"]} +``` + +工具完整 ID 为 `mcp_tjwater_run_simulation`。开始时间必须带时区,时长为60至86400秒, +默认900秒;至少指定一个节点,节点和管段各最多20个。项目与身份由宿主绑定。 +输出沿用场景结果信封,包含 `node_results`、`link_results`、单位、时间和覆盖范围。 +它复用 REST `/api/v1/simulation-runs` 的在线计算服务:REST 时长仍为分钟,MCP 时长为秒。 +每个水力步长执行一次 `realtime` 快照,更新在线模型条件和该时刻的实时基线;不复制模型数据库。 +结果中的 `times.sample_times` 对齐采样点,窗口不包含结束时刻;例如一步长15分钟、时长3600秒时返回4个采样点。 +`persisted_baseline=true` 表示在线基线已重算,`persisted_scheme=false` 表示没有创建方案。 +该工具的 MCP annotation 为 `readOnlyHint=false`、`destructiveHint=true`,因为重算会替换对应时刻的基线。 +它不会创建 `analysis.run_id`,不能将本次计算冒充已有爆管事件运行;调查调用仍可保存证据。 +计算读取现有模型和对应SCADA条件,不从摘要虚构观测,也不自动补齐数据或执行现场操作。 + +爆管定位 `run_burst_location` 默认使用 `data_source="monitoring"`。`pressure_scada_ids` 可省略,由后端从当前项目SCADA配置自动获取压力测点对应的模型节点;显式指定时仍限制为1至20个不同节点。正常对照时间窗 `scada_normal_start` / `scada_normal_end` 可成对省略,沿用后端默认的前一天同时间段;返回结果中的 `pressure_scada_ids` 和 `scada_window` 说明实际测点和时间窗。默认对照不等于已确认该时段正常。正常窗无有效压力数据或两窗没有共同有效测点时计算失败,不补造数据、不自动换窗。正常窗只有部分测点缺数时,沿用服务逻辑使用两窗共同有效测点,结合返回测点范围解释结果。 + +仅提供异常时段和有依据的漏水量即可使用上述默认值,例如: + +```json +{"scada_burst_start":"2026-10-08T12:00:00+08:00","scada_burst_end":"2026-10-08T13:00:00+08:00","burst_leakage":0.0274} +``` + +`data_source="simulation"` 仍须提供已有 `simulation_run_id`,使用同窗模型基线作正常对照,不填写正常时间窗;在线 `run_simulation` 不创建该运行UUID,不能替代已有分析运行。 + +- 身份、项目路由、任务和运行由宿主绑定;模型不能提供network、username、凭据、文件路径、原始观测数组或方案保存名称。 +- 依赖时序数据的8项计算通过权限校验后,先从项目元数据解析并激活 BizDB 与 TimescaleDB 完整路由,再启动计算线程;时序库未配置时在修改模型前失败。普通业务查询、纯拓扑隔离及监测点选址不强制要求时序库路由。 +- 普通项目会话、任务协作和调查主Agent可调用;个人会话、学习复盘和只读reader不能调用分析。Server每次同时校验当前权限与绑定时的权限上限;任务数据读取scope不能替代计算权限。管理员显式deny继续生效。 +- 复用现有业务函数。在线模拟直接调用业务库;定位关闭共享候选CSV输出;识别不保存方案;选址使用不持久化模式。其余四项场景模拟复用项目专用`<业务库>_template`及唯一临时模型库,校验模板同步就绪,沿用临时库并发限制和清理;不是复制在线业务库。模板复制仍会短暂停止模板连接/订阅工作进程,不能称为完全没有基础设施副作用。 +- 场景`persist_results=False`仅允许隔离临时模型,跳过正式analysis运行、Timescale结果和基线写入;既有REST默认保存行为不变。仅返回请求的最多20个节点及20个管段,包含单位、时间和缺失对象;不能用局部结果断言全网无影响。 +- 数据源和计算假设必须有依据。定位漏水量和DMA总漏损约束使用m³/s;冲洗使用服务实际接收的m³/h,不沿用旧REST误标的L/s。工频泵序列0/1,变频泵序列为Hz;每项对应模型水力步长。冲洗当前限LPS/CMH模型。选址当前推荐压力点,不冒充污染溯源优化。 +- 调查结果经现有快照/证据/回执链保存,只有返回evidence_id后才引用为系统证据;普通聊天不自动保存调查证据。模拟结果始终是analysis而非实测。已知模拟事件源管段是输入背景,不是算法定位准确性的证明。 + +## 资源边界与发布 + +10项计算工具均提供 `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秒宿主预算。 + +计算在线程执行,不阻塞事件循环,单Server进程最多两项计算。超时不能杀死底层原生计算,槽位一直保留到计算真正退出;超时结果不会补写为调查成功结果,不自动反复重试。本次未增加后台计算作业队列。普通聊天大结果仍受现有12KB内联限制,调查结果可经证据分页读取。 + +在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。 + +无需新增数据库迁移或模型配置。须同步发布Server、Agent和前端证据枚举;先更新Server目录,再重启Agent,client模式还需重载原生连接。保持既有OAuth、MCP开关及数据库路由配置,不自动更改权限或重启调查。 + +本次验证限schema、权限、协议目录和mock业务函数,不运行真实模拟、优化或付费模型。真实项目的模板同步状态、运算耗时、SCADA覆盖和结果准确性仍需另行验收。 + +2026-10-19 修复:EPANET 2 导出省略阀门默认 `ACTIVE` 状态行并保留数值设置,EPANET 3 导出保留原表达;覆盖文件和文本导出。MCP 在线模拟改为共用在线计算服务,方案模拟继续隔离。回归验证包含原生 EPANET 输入校验及 mock 在线基线写入,不重算真实项目历史数据。 + +标准水力模拟接入已通过后端schema/权限/隔离适配测试、Agent目录与权限测试、前端证据解析测试;Agent和前端类型检查、前端改动文件ESLint通过。隔离HTTP smoke使用真实两端SDK发现34项Server工具及40项合并工具,并核对 `run_simulation` 输入schema和上下文绑定/查询;身份服务及数据执行为fixture,不等同生产验收。测试启动器剔除宿主MCP环境配置,避免私有issuer覆盖测试的loopback设置。运行中的服务仍需同步重载后确认新目录,未启动真实模拟。 diff --git a/docs/mcp-keycloak-setup.md b/docs/mcp-keycloak-setup.md new file mode 100644 index 0000000..b9bea8d --- /dev/null +++ b/docs/mcp-keycloak-setup.md @@ -0,0 +1,88 @@ +# MCP 接入的 Keycloak 管理员配置清单 + +这里创建的是 OAuth 认证配置。OpenCode 到 Agent、Agent 到 Server 的 MCP 协议客户端已实现,无需再开发或安装 MCP 客户端程序。 + +以下使用已创建的请求方客户端 `agent-mcp`,MCP 资源的 audience 使用 `tjwater-mcp`。它们位于现有业务登录使用的同一个 realm。 + +## 1. 请求方客户端 agent-mcp + +在 Keycloak 的 Clients 中打开 `agent-mcp`: + +| 设置 | 值 | +| --- | --- | +| Client type / Protocol | OpenID Connect | +| Client authentication | On;这是 confidential 客户端 | +| Service accounts roles | On;用于启动时读取 MCP 工具目录 | +| Standard token exchange | On;用于按当前用户或 worker 的身份换取 MCP 令牌 | +| Standard flow | Off;该客户端不负责浏览器登录 | +| Direct access grants | Off | +| Access token lifespan | 建议300秒,不超过900秒 | +| 签名算法 | RS256 | + +保存后,在 Credentials 中取得 Client Secret。密钥只写入 Agent 的忽略文件 `.local.env`: + +```dotenv +TJWATER_MCP_CLIENT_ID='agent-mcp' +TJWATER_MCP_CLIENT_SECRET='' +``` + +不要把密钥放入聊天、前端配置、OpenCode 配置或 Git。Server 只配置 `TJWATER_MCP_HOST_CLIENT_ID='agent-mcp'`,不需要保存这个客户端的密钥。 + +## 2. 资源客户端及 scopes + +创建或复用表示 MCP 服务的 OIDC 客户端 `tjwater-mcp`;关闭浏览器登录、直接密码登录和服务账户。这个 ID 是目标 audience,不是另一个需要部署的服务。 + +创建以下 OIDC Client scopes,并作为 Optional scopes 分配给 `agent-mcp`;启用 Include in token scope: + +- `mcp.catalog.read` +- `mcp.query` +- `mcp.context.bind` + +在这些 scopes 中添加 Audience mapper,使对应 token 的 `aud` 包含目标客户端 `tjwater-mcp`。应用按实际 grant 请求 scope:服务账户请求只有 `mcp.catalog.read`;用户/worker 的交换请求才包含查询和上下文 scope。不要给服务账户额外分配项目成员、管理或设备控制权限。 + +## 3. 允许当前业务身份交换 + +在原有前端登录客户端和 worker 客户端的专用 scope 中添加 Audience mapper,使源 token 的 `aud` 包含请求方 `agent-mcp`。保留原业务 API 所需的 audience;不要给源 token 添加 MCP 目标 `tjwater-mcp`。 + +交换后必须保持用户主体 `sub` 不变,`azp=agent-mcp`,目标 `aud` 包含 `tjwater-mcp`。Keycloak 的 `audience` 参数只筛选已有映射的 audience,不能代替 Audience mapper。 + +版本和界面名称以当前 Keycloak 为准。配置依据:[Keycloak Standard Token Exchange](https://www.keycloak.org/securing-apps/token-exchange)。仅在公开发现中看到 token exchange,并不能证明具体客户端已启用或映射正确。 + +## 4. 地址、数据库和启用顺序 + +正式环境提供该 realm 的 HTTPS issuer。本地开发可显式允许一个 RFC1918 IPv4 HTTP issuer:Agent 设置 `NODE_ENV=development`,Server 设置 `ENVIRONMENT=development`,两端 `TJWATER_MCP_DEV_HTTP_ISSUER_URI` 均填写与各自 `TJWATER_MCP_ISSUER_URI` 完全相同的地址(含 realm)。此例外不放行远程 HTTP MCP resource;Agent 只允许该 issuer 同源的远程 HTTP 发现及令牌端点,继续拒绝重定向。正式环境必须清空此项并使用 HTTPS 域名。配置值必须与发现文档和令牌 `iss` 一致,不能仅替换访问地址。 + +例如,同机开发时 Server `.env` 可配置为(内网地址按实际认证服务替换): + +```dotenv +ENVIRONMENT=development +TJWATER_MCP_ENABLED=true +TJWATER_MCP_ISSUER_URI=http://192.168.1.114:8088/realms/tjwater +TJWATER_MCP_DEV_HTTP_ISSUER_URI=http://192.168.1.114:8088/realms/tjwater +TJWATER_MCP_RESOURCE_URI=http://127.0.0.1:8000/mcp +``` + +Agent `.local.env` 使用相同的三个 URI,并设置 `NODE_ENV=development`、 +`TJWATER_MCP_ENABLED=true`。开发模式不跳过签名、issuer、audience、scope 或项目权限校验, +也不自动接受另一种 issuer。切换 Server 环境还会开启开发 API 文档页面。 +修改环境文件后需重启相应进程;已有进程不一定自动重读 `.env`,系统环境变量也可能覆盖文件。 +应先确认 Server 的 `/.well-known/oauth-protected-resource/mcp` 公布的新 issuer, +再启动 Agent 检查工具目录。切回生产时,两端恢复相同 HTTPS issuer 并删除 HTTP 白名单。 + +业务身份也必须使用同一 issuer:供水前端只在被 Git 忽略的 +`.env.development.local` 中将 `TJWATER_KEYCLOAK_ISSUER` 设为该内网地址, +保留 `.env.local` 的 HTTPS 配置供正常 production 构建使用。 +Server 本机 `.env` 可额外设置 `OPERATIONAL_WORKER_DEV_TOKEN_URL` 为同一内网 issuer +发现文档中的 `token_endpoint`;该值仅在 `ENVIRONMENT=development` 时生效, +不替换原来的 `OPERATIONAL_WORKER_TOKEN_URL`。重启开发前端和 worker,并在浏览器重新登录。 +旧 HTTPS 令牌不能用于内网 issuer 的交换;工具目录读取成功不等于业务身份交换成功。 + +接入人员随后执行: + +1. 安装 MCP SDK 2 对应的 Server 依赖。 +2. 对选定项目业务库备份并应用 [025_mcp_query_contexts.sql](../resources/sql/025_mcp_query_contexts.sql)。 +3. 在 Agent 和 Server 的私有环境中配置相同的 issuer、resource、audience;resource 必须指向实际 Server `/mcp`,请求方客户端 ID 均使用 `agent-mcp`。 +4. 验证带标准 `resource` 的目录 grant 和用户/worker token exchange 都成功;若身份平台拒绝,先解决兼容性,不移除参数重试。 +5. 在受控测试实例中开启两端 `TJWATER_MCP_ENABLED` 并重载;通过测试项目查询、任务证据和越权拒绝验收后,再按部署流程启用正式环境。 + +完整环境变量与验收要求见 [MCP 实施文档](mcp-query-implementation.md)。在以上步骤完成前保持 MCP 开关关闭。 diff --git a/docs/mcp-query-design.md b/docs/mcp-query-design.md new file mode 100644 index 0000000..05d00d2 --- /dev/null +++ b/docs/mcp-query-design.md @@ -0,0 +1,280 @@ +# NextTJWater MCP 查询与认证设计 + +日期:2026-10-15。状态:查询与认证代码已在本地实现,认证发现、resource 参数和版本头体校验已补齐,默认关闭,未部署;真实 Keycloak 与项目数据库验收待完成。 + +本设计将现有后端查询能力通过 MCP 开放给普通项目 Agent、调查主 Agent 和调查只读子 Agent。采用 MCP 2026-07-28、Streamable HTTP、现有 Python Server 内挂载服务、可信 Agent 宿主按请求代理认证。旧 CLI 用于查找功能和接口线索,业务实现继续由当前 Server 负责。 + +第一版完成查询、权限、取消、分页、调查证据和凭据续期的完整闭环。已有报告发布、工单审批、设备控制、调查运行生命周期继续使用当前受控工具;不通过 MCP 自动开放旧 CLI 中的分析执行命令。 + +## 当前系统与功能核对 + +Agent 的独立只读查询现统一通过原生 `mcp_tjwater` 连接调用。重复的项目查询插件与自定义 HTTP 查询桥已移除;`memory_manager`、`skill_manager` 保留完整读写插件,发布、动作、消息发送和界面展示也保留插件形式。普通会话凭据保存在宿主的 `src/product/credential-refresh.ts`;调查由 worker 以服务身份投递并续期。调查子 Agent 通过父任务绑定访问数据,不持有独立产品凭据。 + +实施前从 `app.main.app.openapi()` 核对了 266 路径的契约,其路径集合及各 HTTP operation 与工作区 `contracts/server-v1.openapi.json` 一致。公开 API 经 `app/api/v1/rest_router.py` 适配,项目字段注入和分页须看最终注册契约,不能只看底层 handler 签名。旧 CLI 的 41 条业务命令中,38 条能匹配当前 HTTP 方法与路径:29 条读命令、9 条执行命令。另有 3 条需要替换或不开放,详见附表。路径匹配不代表参数兼容、数据可用或算法已完成业务验收。 + +实施前 `requirements.txt` 和 conda 环境均为 `mcp==1.9.4`、`fastmcp==2.9.2`,当时没有 Server MCP 注册实现。Agent 使用的 OpenCode 2.0.27 配置类型支持 `legacy`、`auto`、`2026-07-28`,宿主认证代理现已补充,见实施说明。 + +## 协议选择 + +官方 `specification/latest` 指向 [MCP 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28)。新实现按该正式版本建设;产品内部连接固定要求这个版本,版本不支持时返回明确错误。第一版不提供旧 `initialize` 握手兼容层。 + +| 协议要求 | 本系统设计 | +| --- | --- | +| 无状态请求与逐请求能力声明 | 每次请求发送标准 `_meta` 中的版本、客户端信息和能力;不以连接识别用户或调查轮次 | +| `server/discover` | 返回版本、服务信息和实际支持能力;不能用自报服务名建立信任 | +| HTTP 请求镜像头 | 由 SDK发送并校验 `MCP-Protocol-Version`、`Mcp-Method`、适用时的 `Mcp-Name`;头与正文不一致拒绝 | +| 普通结果 | 返回 `resultType=complete`、`structuredContent`、匹配的 `outputSchema`,以及简短文字说明 | +| 列表和资源缓存提示 | 返回 `ttlMs`、`cacheScope`;缓存不作为权限证明 | +| 取消与断线 | 取消传到 SDK、数据库和外部请求;断开的流不按旧 SSE 游标恢复 | + +这些改变来自[正式版变更说明](https://modelcontextprotocol.io/specification/2026-07-28/changelog)和 [Streamable HTTP 规范](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)。协议支持情况以实际发现与调用验收为准,不以软件版本字符串代替。 + +第一版声明 tools 和 resources;不声明 prompts、subscriptions、MRTR、Tasks、MCP Apps、Sampling、Roots 或 Logging 能力。查询不要求模型补填身份、密码或令牌。现有产品调查任务不会替换为 MCP Tasks。 + +Schema 使用 JSON Schema 2020-12,模型输入对象拒绝未声明字段;只使用服务自带的本地定义,不在校验时从任意外部 URL 获取 `$ref`。宿主校验模型投影,Server 再校验完整线协议参数。工具发现、结构化结果、状态句柄和执行错误遵循[正式工具规范](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)。 + +| 失败类型 | 响应与宿主处理 | +| --- | --- | +| 未认证或过期 | HTTP 401 和 Bearer challenge;宿主最多续期一次 | +| scope 或产品权限不足 | HTTP 403;scope 不足时带 insufficient_scope,不自动扩大产品权限 | +| 请求头与正文不一致 | HTTP 400、JSON-RPC -32020 | +| 协议版本不支持 | HTTP 400、JSON-RPC -32022,列出支持版本 | +| RPC 方法不存在 | HTTP 404、JSON-RPC -32601 | +| 工具不存在或 RPC 参数结构无效 | JSON-RPC -32602 | +| 业务参数、数据源或上下文失败 | resultType=complete、isError=true;返回稳定业务错误码和可操作说明 | + +旧客户端 GET/DELETE 到 `/mcp` 返回 405;旧 Mcp-Session-Id 和 Last-Event-ID 不建立或恢复身份。产品上下文删除使用独立 REST 控制路径,不混用协议会话删除。 + +`server/discover` 和固定查询目录按工具名稳定排序,缓存提示取 `ttlMs=300000`、`cacheScope=public`,正文仅包含通用定义。业务结果资源取 `ttlMs=0`、`cacheScope=private`;宿主按令牌、主体、项目和上下文分别保存缓存,不跨授权上下文复用。[缓存规范](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching) + +## 服务与调用结构 + +在现有 FastAPI Server 挂载规范路径 `/mcp`,通过官方 Python SDK v2 承担协议处理。ASGI 的生命周期纳入现有应用启动和关闭;不得用根路径挂载遮蔽 REST 路由。官方 SDK 支持[在现有 ASGI 应用中挂载](https://py.sdk.modelcontextprotocol.io/run/asgi/),其[协议版本说明](https://py.sdk.modelcontextprotocol.io/protocol-versions/)覆盖 2026-07-28。 + +查询适配器复用现有 service、repository、项目路由和业务权限,不从 MCP 向自己的 REST API再发一次 HTTP 请求,不转发收到的 MCP Bearer。需要从 endpoint 提取逻辑时,提取供 REST 和 MCP 共用的 service,而不直接调用带 FastAPI Depends 默认值的 endpoint 函数。 + +```text +模型调用查询工具 + → OpenCode 原生 MCP(tools/call,_meta 中关联原生 sessionID) + → Agent 本地 MCP 服务(内部密钥认证、宿主会话和直接父子关系检查) + → 宿主专用产品 MCP 客户端 + → Keycloak(按主体获取面向 MCP 的令牌) + → Server /mcp(标准 MCP 请求 + 宿主绑定的上下文) + → 项目与任务权限检查 + → 共用查询 service + → BizDB / TimescaleDB / 既有外围适配器 + → 调查证据保存和有范围的数据结果 +``` + +OpenCode 原生发现本地 MCP 的工具目录,工具 ID 为 `mcp_tjwater_`。本地 MCP 默认提供地址解析、会话搜索、调查任务列表与详情、消息读取、待办读取六项查询;启用产品 MCP 后,宿主从真实 Server `tools/list` 发现24项项目查询,投影并合并到本地目录,共30项。公开网页搜索使用 OpenCode 原生 `websearch`,Server 不提供搜索接口。可信插件只配置连接和权限策略,不注册独立查询工具或自定义执行器。待办读取经宿主 RPC 访问原插件存储,保留已有清单。 + +本地服务使用官方 TypeScript Server SDK 2.3.1、2026-07-28 Streamable HTTP,绑定127.0.0.1临时端口,拒绝浏览器来源和错误内部密钥。OpenCode 的 `_meta["ai.opencode/sessionID"]` 仅是关联值;通过内部通道认证后,服务再解析宿主身份或已核验的直接只读子会话。未知会话、个人项目查询和学习复盘均拒绝。URL、身份、项目、网络、产品认证头和上下文句柄不属于模型参数。产品 MCP 未启用时项目查询不可用,没有旧桥回退。取消信号贯穿整个调用链。[OpenCode MCP 会话上下文](https://dev.opencode.ai/v2/docs/mcp-servers/#context) + +现有 OpenCode 共享 MCP 配置缺少逐会话 OAuth 提供器,不能把一份用户 Bearer 填进 `mcp.servers.headers`。产品内部连接由 Agent 宿主按请求认证,外部 MCP 继续保持原配置路径和现有策略,二者不能误注册为同名工具或形成两份不同凭据的业务连接。 + +## 认证与上下文绑定 + +### OAuth 资源与现有登录 + +Keycloak 继续作为授权服务器。MCP 是独立的受保护资源,资源标识由 `TJWATER_MCP_RESOURCE_URI` 指定为部署的规范 `/mcp` URI;令牌目标 audience 取专用客户端 `tjwater-mcp`。Agent 宿主使用预注册 confidential client `tjwater-agent-mcp`。用户继续使用当前前端标准流程和 S256 PKCE 登录,不新增产品内的第二次登录。 + +OAuth scope 分成 `mcp.catalog.read`、`mcp.query` 和 `mcp.context.bind`。宿主启动时以自身 client_credentials 取得仅含 catalog scope 的令牌,发现真实目录;该身份不获得项目业务读取能力。会话查询令牌必须具有 query scope,绑定接口还要求 context.bind。scope 只开放接口类别,具体数据权限仍由产品主体、项目成员关系和工具 registry 求交。 + +MCP 端提供 `/.well-known/oauth-protected-resource/mcp`,401 响应包含对应的 `WWW-Authenticate` 资源发现信息。验证签名、固定 issuer、audience、exp、iat、客户端来源和查询 scope;对新 MCP 使用独立验证器,不依赖当前 REST 验证器可为空的 audience,也不通过修改全局 audience 破坏现有 REST。 + +标准 OAuth 客户端在授权和 token 请求中携带规范资源 URI。MCP 仅接收确实面向它发行的访问令牌,令牌每次通过 Authorization 头发送,不出现在 URL、工具参数或模型文本中。[MCP 授权规范](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)、[授权服务器发现](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery) + +产品宿主先发送无凭据的标准发现请求,从401 challenge 读取资源元数据地址;无地址时按 MCP 路径、根路径依次发现 well-known。资源 URI 必须匹配已配置资源,authorization_servers 必须包含预注册凭据所属的固定 issuer。随后使用官方 SDK 按规范顺序发现 OAuth/OIDC 元数据,严格比较 issuer,从元数据取得 token endpoint;不向其他 issuer 发送原凭据。公开发现请求不带产品 Bearer 或 secret,单份文档最多64KiB,总预算10秒;拒绝重定向和远程明文地址。发现缓存最多5分钟,MCP 401 后失效重查。根据元数据使用 client_secret_post 或 client_secret_basic。 + +产品内部宿主基于当前产品凭据使用 Keycloak Standard Token Exchange V2 换取面向 `tjwater-mcp` 的短期访问令牌;保留原主体,限定查询 scope,避免共享一个超级服务账号访问所有普通用户数据。专用令牌只保存在 Agent 主进程内存中,刷新令牌仍由现有登录机制持有。 + +Keycloak V2 需要 confidential requester、启用 Standard token exchange、subject token 对 requester 的 audience,以及目标 audience/scope 配置。client_credentials 与 token exchange 均发送 `resource=规范MCP URI`;exchange 另保留产品要求的 audience。身份平台必须将该资源映射到专用 audience,并核验两种 grant 返回的令牌。若部署版本拒绝 resource,应先升级或提供兼容适配再启用;不能删除标准参数后重试。不增加第三方客户端授权码流程。[Keycloak Token Exchange](https://www.keycloak.org/securing-apps/token-exchange) + +上述 realm/client 能力尚未在线核验,是部署前提,由身份平台配置负责人落实。达不到前提时停止启用内部 MCP;不以普通 API Bearer 转发、共享管理员 token 或关闭 audience 校验降级。 + +### 每次调用绑定项目与调查轮次 + +OAuth 令牌只证明主体与可用 scope,业务范围由 Server 签发的 `context_handle` 约束。它是应用上下文,不是 MCP 协议会话,也不能单独作为认证凭据。 + +可信 Agent 宿主通过受认证的 `POST /api/v1/mcp-query-contexts` 申请上下文。请求中的 session、purpose、项目和任务信息均由原生会话及宿主已有绑定读取;仅允许 `azp=tjwater-agent-mcp` 的宿主客户端创建任务上下文。Server 从令牌确定主体、从成员关系确定项目读取权限,验证 task、run、execution_request 和父会话占用的租约;原生父子关系由可信宿主先核验;不接受自报用户、权限或数据 scope。 + +Server 保存的绑定字段包括:主体、client ID、project ID、native session ID、purpose、可用查询工具、任务及运行标识、父会话、创建与过期时间、撤销状态。句柄使用至少 256 位随机值,10 分钟过期。普通上下文到期由宿主重新申请;任务上下文有效性另外取决于当前轮次和 lease,不能用续租让旧轮次重新获得访问权。 + +所有业务 MCP tool 的线协议 schema 具有宿主字段 `context_handle`。Agent 宿主客户端在发布给本地 MCP 的 schema 投影中移除它,并在请求 Server 时覆盖注入;它不使用 `x-mcp-header`,也不进入聊天 transcript 或普通日志。若输入包含同名字段、身份字段或保留 scope 字段,宿主直接拒绝。resources/read 由宿主在应用命名空间 `_meta` 中注入同一上下文,URI 仅标识任务证据而不携带句柄或令牌。外部客户端若将来接入,应自己调用绑定接口并在宿主侧选择项目,不从模型内容决定项目。 + +Server 每次同时核验 Bearer 与句柄所属的 `sub/azp`,重新校验成员关系和当前权限;任务调用还检查项目、任务、run、execution request 和 lease。句柄复制到其他用户、客户端或项目无效。切换项目新建会话,关闭/删除会话撤销绑定;调用 `DELETE /api/v1/mcp-query-contexts/{context_handle}` 时检查绑定所有者。令牌在新 scope 下续期时不会自动扩大已有上下文的工具授权。 + +普通会话的 `purpose=ordinary_chat`,仅访问其绑定项目。调查主 Agent 的 `purpose=task_execution`,使用 worker 的现有服务主体和本轮任务范围。只读子 Agent 的 `purpose=task_reader`,宿主根据已经验证的父子关系绑定父轮次;绑定只允许查询和系统证据追加,不授予报告发布、审批、设备控制、Shell 或再次委派权限。个人会话不创建项目查询上下文,返回现有 `PROJECT_CONTEXT_REQUIRED`。 + +### 续期和退出 + +普通项目会话复用现有 `credential_refresh_required` 回传与前端 Keycloak `updateToken`。收到更新的产品 access token 后,宿主重新 exchange 并继续原查询,用户只在现有登录本身失效时重新登录。并发续期按主体与原凭据版本合并,最终缓存按 issuer、resource、主体、scope 和原凭据版本隔离。 + +后台调查不依赖浏览器。worker 保持当前 client_credentials 续期;新服务 Bearer 到达后由 Agent 更新原会话产品凭据,再 exchange。MCP 声明官方 `io.modelcontextprotocol/oauth-client-credentials` 授权扩展以支持服务身份;首次范围仍由服务账号成员关系与任务授权决定。[Client Credentials 扩展](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials) + +MCP 401 清除本主体缓存并续期一次;403 不反复刷新、不自动申请更大 scope。认证服务不可用时返回认证暂不可用,不能返回空数组掩盖故障。注销时撤销产品绑定、取消调用并清除宿主缓存。JWT exchange 的源 token 撤销不必然撤销已交换 token,因此 MCP 令牌短期有效,Server 的绑定撤销和每次业务权限检查仍然必须执行。 + +## 查询工具目录 + +以下为第一版明确目录。名称为 MCP 服务端名字,OpenCode 原生发现后添加 `mcp_tjwater_` 前缀。元素枚举固定为 junction、pipe、valve、pump、tank、reservoir;时间必须带时区;身份、项目和 network 都不在模型输入中。HTTP 路径表示现有能力来源,MCP 实现调用其共用 service。 + +| 工具 | 业务输入 | 当前能力来源与限制 | +| --- | --- | --- | +| `get_element_properties` | element_type、element_id | 六类 `/{type}s/properties` | +| `list_elements` | element_type、limit、offset | 六类 `/{type}s`;返回一页,不暗中获取全网 | +| `query_nearby_elements` | center、可选 element_type、radius_m、limit、offset | 当前项目模型管网空间邻近查询;直接接收地理编码的 EPSG:4326 `[经度,纬度]`,默认pipe、200米、5条,最大10000米、单页100条;返回element_id、distance_m及properties,不查询原始GIS源要素 | +| `get_network_options` | kind、可选 pump_id | network-options 及 time、energy、pump-energy;泵能源必须给泵 ID | +| `get_network_schema` | kind | 固定 network-schemas 枚举,以当前注册路径为准 | +| `list_scada_devices` | limit、offset | `/scada-devices`,显式分页 | +| `get_scada_device` | device_id | `/scada-devices/{device_id}` | +| `query_scada_readings` | device_ids、start、end、field | scada-readings/fields;区分 monitored_value 和 cleaned_value | +| `query_model_baseline_series` | element_type、start、end、元素筛选 | realtime/links、realtime/nodes;标记为模型基线,不称为实测 | +| `get_model_baseline_at_time` | element_type、element_id、time | realtime/simulation-results | +| `query_model_baseline_field` | element_type、field、time | realtime/records;只允许当前字段枚举 | +| `query_analysis_series` | run_id、element_type、element_id、field、start、end | analysis/runs/{run_id}/links 或 nodes/{id} | +| `query_analysis_values` | run_id、element_type、field、time | analysis/runs/{run_id}/values | +| `list_analysis_runs` | limit、offset | `/analysis/runs` | +| `get_analysis_run` | run_id | `/analysis/runs/{run_id}` | +| `list_analysis_results` | run_id、result_type、limit、offset | `/analysis/runs/{run_id}/results`;保留 result_type | +| `list_sensor_placement_runs` | limit、offset | sensor-placement-runs;公开 REST 已包装分页,但底层 service 读取整数组,MCP 应推动共用 service 做真实有界查询 | +| `get_sensor_placement_run` | run_id | sensor-placement-runs/{run_id};REST 适配层已注入 network,MCP 共用 service 也从可信项目上下文解析 | +| `query_element_history` | elements、start、end、mode、可选 run_id | element-history/query;observed、realtime_comparison、analysis_comparison | +| `get_element_gis_properties` | element_type、element_id | elements/{type}/{id}/gis-properties | +| `get_source_gis_properties` | layer_key、source_fid | source-gis/layers/{key}/features/{id}/properties | +| `get_workbench_runtime` | 已有运行态查询参数 | workbench/runtime;只读取 | +| `get_task_context` | section、data_path、limit、offset、可选 report_revision;无模型可选 task_id | 当前绑定任务的固定摘要及分章节、路径分页详情;预览不等于完整报告 | +| `query_peripheral_data` | source、point_id、start、end、limit、offset | 固定外围 points、work_orders、observations、test_data 适配器;保留 synthetic | +| `get_task_evidence` | evidence_id、data_path、limit、offset | 当前任务的 evidence/{id}/snapshot;项目、任务及可读轮次由绑定约束 | + +初版为24项查询;新增空间邻近查询后,当前为25项查询,连同10项分析共35项Server工具。旧 CLI 的 `data pipeline-health` 实际触发模型预测,不能包装成“读取已保存结果”;它作为 `predict_pipeline_health` 的后续计算候选,先核对模型资产、执行成本和计算权限。旧 CLI 的 9 条 POST 执行命令也仅列作后续候选,不加入查询目录。 + +空间邻近查询沿用 `webgis.view` 和宿主绑定项目,数据库连接不从工具参数选择。`app/infra/db/postgresql/spatial_queries.py` 使用既有 `gis.*` 模型视图及GiST几何索引做保守候选筛选,再转换到WGS84 geography,按点到完整几何的椭球最短距离以米筛选和排序,最后在数据库中执行LIMIT/OFFSET,不把全网几何交给Agent。跨日期变更线时拆分候选包围框,极区跳过Mercator候选筛选;空或缺失几何不参与查询。距离语义依据 [PostGIS ST_DWithin](https://postgis.net/docs/ST_DWithin.html) 和 [ST_Distance](https://postgis.net/docs/ST_Distance.html),不把EPSG:3857平面距离直接当作实际米数。 + +输出 `data.source=model_network`、`center_crs=EPSG:4326`、`distance_method=WGS84_spheroid`,`data.items` 包含 `element_id`、`distance_m` 和 `properties`;`page.has_more` 与 `truncated` 保留分页边界。没有命中返回空items,不自动扩大半径;空间邻近不证明拓扑连接或供水归属,道路/河流交叉位置必须由调用方先确认。查询预算仍为30秒,调查调用沿用现有证据与回执保存,不新增REST查询桥或数据库迁移。 + +聚焦验证:`python -m pytest tests/unit/test_mcp_spatial.py tests/unit/test_mcp_query.py -q`;设置 `MCP_SPATIAL_TEST_DSN` 后运行 `python -m pytest tests/integration/test_mcp_spatial_postgis.py -q` 可验证实际PostGIS距离与分页,测试只创建事务内TEMP表并回滚,不写业务数据。 + +权限保持当前真实规则:管网、GIS 和分析运行读取沿用 webgis.view,SCADA 沿用 scada.view,模型基线和分析时序沿用 simulation.view;监测点选址路由当前整体要求 optimization.run,MCP 不擅自改成 optimization.view。历史比较模式额外核对其中使用的模拟数据权限。拆分读权限需要单独的后端授权设计,不能借 MCP 接入顺便降级。 + +新源 registry 明确 source、所需权限、查询参数 Schema、数据语义、结果上限、任务 scope 和是否允许证据保存。原 7 个 TaskDataSource 与新源按明确规则扩展,历史证据仍可反序列化。既有已批准 scope 只迁移到与其原意等价的查询能力,不用“只读”作为放开所有源的理由。 + +## 分页 大结果与数据语义 + +集合查询默认 limit=20,最大 500;分析运行最大 100。offset 最大10000,超过时缩小时间窗或目标。常规集合在 SQL 中限制返回量。带总量的集合返回 items、total、limit、offset、has_more;没有稳定总量时 total=null,不伪造总数。Agent 首次优先单对象、必要的短时间窗和小页;只有明确问题仍缺证据且 has_more=true 时继续翻页,不自动遍历全网。 + +历史组合查询按稳定的序列顺序(设备ID、原始/清洗,再按类型和元素ID排列模型序列)及时间对数据点分页;在同一条 UNION ALL SQL 上应用全局 limit/offset,并多读一个点计算 has_more。证据查询按绑定项目、任务和证据ID,在数据库中选取 data_path 和页;嵌套集合返回路径描述,数组保持索引顺序,对象按 C 排序,字符串按 Unicode 字符分页。证据页和完整资源在返回数据库客户端前检查8MiB上限。此限制约束传输和应用进程,不代表数据库无需读取或解压 JSONB。 + +DEMO 样本仍需校验整份 RFC 8785 摘要,不能仅验证所选页。数据库先检查整份 JSON 文本大小,超过8MiB则拒绝传输;预算内读取完整样本,校验后筛选和分页。超限须由数据源拆分样本,不能通过反复缩小查询窗口消除该限制。 + +时序默认最多 20 个目标、24 小时,最多返回 10000 条或 8 MiB。全网模型基线接口当前没有元素筛选和结果分页,接入前必须在共用 service 增加有界筛选/游标;MCP 不直接代理当前无限制接口。批量历史采用其现有 mode 和 run_id 语义,不假定新接口完全等价于旧三类 composite 查询。 + +结果超过内联 12000 字节时返回摘要、数量、时间范围、truncated、继续查询参数或受保护 resource_link。第一版 resources 暴露已有任务证据快照,普通查询用分页继续读取;不新增全局 result-ref 存储或任意主机文件路径 API。资源读取同样核验令牌和上下文,不能凭 URI 得到数据。 + +共用输出对象包含 source、source_ref、queried_at、origin、data、window、units、page、truncated、data_gaps,以及调查时的 evidence_id。原 service 返回结构由各工具的 Pydantic 输出模型明确描述,不靠自由文本或一个无约束的 data 字段作为最终契约。第一版每主体/项目最多 4 个并发查询;超过并发返回可重试限流结果,队列不无限增长。查询执行预算沿用 Server 已配置的访问超时,认证续期等待单独计时。 + +普通原始查询错误返回结构化业务错误,不改成空数据。结果标明数据来源、查询时间、源时间窗口、单位、SCADA 实测或清洗、模型基线或分析运行、synthetic、缺失及截断。`truncated=true` 不能支持“完整范围未见异常”的结论。 + +## 调查证据与幂等 + +共享查询入口根据已验证 purpose 选择包装方式。普通聊天返回查询结果;调查及 reader 查询在返回前调用现有 `OperationalTaskService.record_data_snapshot`,保存范围与来源,再返回 snapshot 和 evidence_id。外部 MCP 返回内容不能通过这一入口被重新标成内部系统证据。 + +原生 callID 经宿主转为 application request key。Server 按主体、项目、会话、任务、run、callID 和规范参数记录查询回执;同 key、同参数重试读取已保存 snapshot,同 key、不同参数拒绝。MCP 断线重发使用新 JSON-RPC ID,但保持同一 application request key;证据存储提交失败就返回失败,不能先返回成功再异步补证据。 + +调查取数前后都校验本轮仍然有效。长查询期间不持有任务行锁;完成后用短事务确认轮次并保存证据。查询途中被新轮次替代时,不把晚到结果写进新轮次,也不复活旧运行。外部网络请求与数据库读写使用短事务,不跨网络等待持锁。 + +查询业务源是只读,但调查包装会追加证据,因此相应工具不能仅根据 GET 方法声明 `readOnlyHint=true`。固定目录内可能保存证据的工具保守标为 `readOnlyHint=false`、`destructiveHint=false`,执行策略按已审查的精确工具名允许“读源并追加系统证据”,不由 annotation 决定授权。精确 grant 不覆盖报告发布、工单或设备动作。 + +## 客户端策略与生命周期 + +内部业务查询在项目会话、调查执行和已验证 task_reader 中开放对应精确工具。reader 的精确白名单仅增加可信 `mcp_tjwater_*` 项目查询目录;现有外部 MCP 禁用规则继续执行,没有向模型开放通用 resources 桥或子 Agent 写权限。 + +取消传播至 OpenCode 原生 MCP、本地 MCP、产品 MCP 客户端、Server handler 和底层工作。接口断线时停止请求,宿主不会无条件后台补跑。读查询只允许一次认证重试和一次可判定未完成的传输重试;调查依 application request key 防止重复保存。 + +本地 MCP 采用已有内部 token 和浏览器来源拒绝规则;它只证明可信 OpenCode 到宿主的通道,不能替代 Server 的 OAuth 主体认证。日志保留主体、项目、任务、run、工具名、耗时、数量、取消与错误类别,不保存 Bearer、secret、context_handle 或原始敏感数据。 + +MCP 服务失败时其调用返回可重试错误,现有 REST 和原生查询仍可使用;如果管理员启用 MCP 作为必需产品能力,Agent 启动预热必须验证 server/discover、实际 tools/list、Schema、身份路径和取消,不调用付费推理,不用“连接成功”代替能力就绪。 + +## 实施范围与验收 + +这是两仓库的中等规模改动,预计超过 8 个文件;第一版挂载于已有 Server,不新增独立部署服务。前端正常情况下继续复用现有凭据刷新事件,只有验收发现契约缺口时才扩展。 + +| 仓库 | 实施落点 | +| --- | --- | +| Server | app/main.py 挂载和 lifespan;app/core/config.py;新增 app/mcp 的协议、工具与认证适配模块 | +| Server | app/services 的共用查询与上下文绑定服务;app/domain/schemas 的查询/输出契约;任务源与证据回执的持久存储要求 | +| Server | 现有 API service 提取、有界查询;独立 MCP 令牌验证;requirements.txt;.env.example;tests/api、tests/auth、tests/unit;OpenAPI 导出 | +| Agent | src/product 的 MCP 客户端与令牌缓存;现有 credential-refresh;新增内部 MCP call 桥 | +| Agent | .opencode/plugins/tjwater-tools.ts;MCP 和 task-reader 策略;工具 schema 投影;真实目录启动检查;package.json 与锁文件;聚焦测试 | + +升级 Server 的官方 mcp SDK 到已发布 v2,按官方迁移文档核对 lifespan、认证、协议模式和结果类型后固定实际发布版本。不能仅升级 fastmcp 并假定新协议成立;已有依赖是否删除,须先确认真实使用与兼容关系。当前 Python SDK 文档示例同时包含 legacy 配置,协议行为以 2026-07-28 规范和实际验收为准。[Python SDK v2 迁移说明](https://py.sdk.modelcontextprotocol.io/migration/) + +部署配置包含固定 Keycloak issuer、规范 MCP resource URI、专用 audience、Agent confidential client ID 与 secret。secret 只在 Agent 宿主环境或密钥管理中,Keycloak 签名密钥由 JWKS 获取并校验固定 issuer,不发给 OpenCode。沿用 worker 自有 client_credentials 配置,不把 worker secret 转交给 Agent。 + +Server 需提供上下文绑定、撤销和调查查询回执的持久存储;多进程读写须一致,不能用单进程 dict 宣称重启或横向扩容可用。应用层只保存令牌派生身份和范围,不保存 token。存储由后端按现有数据库管理流程交付,本设计不执行数据库迁移。 + +验收必须覆盖: + +1. 实际 `server/discover`、24 工具目录、版本固定、标准 `_meta`、resultType、缓存提示;头与正文不一致、未知工具、非法 schema、旧握手的错误行为。 +2. 两个用户两个项目并发,交错调用也不串凭据;句柄换用户、项目、client、task、run 或失效均拒绝;个人会话不得获得业务数据。 +3. 普通登录无需再次登录 MCP;401续期一次、403不升权;并发 refresh 合并;注销、成员撤销和背景服务凭据续期。 +4. worker 无浏览器完成查询;reader 只取父任务范围;不获得报告、审批、控制、Shell 或其他外部 MCP。 +5. 管网属性与分页、SCADA 原值/清洗值、模型基线、分析 run、历史三种 mode、GIS 与外围 synthetic;不支持的旧功能返回明确能力缺口。 +6. 24小时/目标数/分页边界、实际服务端限量、资源读取授权;超大结果不会全量进入内存或聊天。 +7. 查询成功且证据已经持久化;断线重试仅一个证据回执;查询中轮次被替代、取消、DB失败和认证服务失败均不误报成功。 +8. REST 与 MCP 的权限和数据结果相同;新 SDK 不改变原 API、worker 或报告读取;启动真实目录检查不调用模型推理。 + +使用 Server 现有 conda 环境运行新增 MCP 聚焦 tests/api、tests/auth、tests/unit;Agent 执行 bun run check 与新增凭据、MCP桥、策略、目录聚焦测试。用户登录、refresh 和两项目隔离需用现有前端做一次实际浏览器验收。设计文档本身仅进行路径检查与 git diff --check,不执行业务写请求。 + +交付可以分两次独立上线:先增加服务端只读 MCP、OAuth与上下文,保留现有产品调用;再接入 Agent 宿主客户端和 reader,验证后切换产品查询。任何一步失败都可以回到已有原生工具,不能在单次失败时自动切换凭据绕过权限。回滚停用 MCP 入口/宿主注册并撤销上下文,保留已经保存的调查证据与旧报告。提交、身份平台变更、数据库供给和部署另按实际实施授权执行。 + +## 旧 CLI 与当前后端对照 + +路径均为 `/api/v1` 下的当前能力;“执行候选”不加入第一版查询 MCP。此表依据当前源码生成 OpenAPI 和旧 CLI handler,而非线上算法成功证明。 + +| 旧 CLI 命令 | 当前路径或处理 | 第一版决定 | +| --- | --- | --- | +| network get-junction-properties | GET /junctions/properties | get_element_properties | +| network get-pipe-properties | GET /pipes/properties | get_element_properties | +| network get-all-pipes-properties | GET /pipes | list_elements 分页 | +| network get-reservoir-properties | GET /reservoirs/properties | get_element_properties | +| network get-all-reservoirs-properties | GET /reservoirs | list_elements 分页 | +| network get-tank-properties | GET /tanks/properties | get_element_properties | +| network get-all-tanks-properties | GET /tanks | list_elements 分页 | +| network get-pump-properties | GET /pumps/properties | get_element_properties | +| network get-all-pumps-properties | GET /pumps | list_elements 分页 | +| network get-valve-properties | GET /valves/properties | get_element_properties | +| network get-all-valves-properties | GET /valves | list_elements 分页 | +| component option schema | GET /network-schemas/{kind} | get_network_schema | +| component option get | GET /network-options 及 time、energy、pump-energy | get_network_options | +| simulation run | POST /simulation-runs | 执行候选,duration 为分钟 | +| analysis burst | POST /burst-analyses | 执行候选 | +| analysis valve isolation | POST /valve-isolation-analyses | 执行候选 | +| analysis flushing | POST /flushing-analyses | 执行候选 | +| analysis age | /water-age-analyses 未注册 | 不开放,不假定算法仍可运行 | +| analysis contaminant | POST /contaminant-simulations | 执行候选 | +| analysis sensor-placement run | POST /sensor-placement-runs | 执行候选 | +| analysis sensor-placement list | GET /sensor-placement-runs | list_sensor_placement_runs,有界适配 | +| analysis sensor-placement get | GET /sensor-placement-runs/{run_id} | get_sensor_placement_run,读取项目路由中的既有成果 | +| analysis runs list | GET /analysis/runs | list_analysis_runs | +| analysis runs get | GET /analysis/runs/{run_id} | get_analysis_run | +| analysis runs results | GET /analysis/runs/{run_id}/results | list_analysis_results | +| analysis leakage identify | POST /leakage-identifications | 执行候选 | +| analysis burst-detection detect | POST /burst-detections | 执行候选 | +| analysis burst-location locate | POST /burst-locations | 执行候选 | +| data timeseries realtime links | GET /timeseries/realtime/links | query_model_baseline_series,已补最多20个元素筛选及SQL限量 | +| data timeseries realtime nodes | GET /timeseries/realtime/nodes | query_model_baseline_series,已补最多20个元素筛选及SQL限量 | +| data timeseries realtime simulation-by-id-time | GET /timeseries/realtime/simulation-results | get_model_baseline_at_time | +| data timeseries realtime simulation-by-time-property | GET /timeseries/realtime/records | query_model_baseline_field | +| data timeseries analysis link-field | GET /timeseries/analysis/runs/{run_id}/links/{link_id} | query_analysis_series | +| data timeseries analysis node-field | GET /timeseries/analysis/runs/{run_id}/nodes/{node_id} | query_analysis_series | +| data timeseries analysis values | GET /timeseries/analysis/runs/{run_id}/values | query_analysis_values | +| data timeseries scada query | GET /timeseries/scada-readings 及 fields | query_scada_readings | +| data timeseries composite | 原三个 views 路径未注册;现有 POST /timeseries/views/element-history/query | 按三种 history mode 重新适配,不直接宣称等价 | +| data pipeline-health | GET /pipeline-health-predictions,实际进行预测 | 后续计算候选,先验证模型资产 | +| data scada get | 原 /scada-devices/detail 未注册;现有 GET /scada-devices/{device_id} | 修正为 get_scada_device | +| data scada list | GET /scada-devices | list_scada_devices 分页 | +| data scada schema | GET /network-schemas/scada-device | get_network_schema | + +当前 Agent 的 `ProjectDataClient` 读取单个元素等能力仍较窄;其中 SCADA 设备读取仅列表,缺少单设备新路径入口。MCP 切换前以本表和当前 Schema 对齐,不能从旧 CLI 直接复制参数或依赖其全量分页行为。 + +任务上下文查询支持可选的正整数 `report_revision`;未显式指定时宿主保留协作会话绑定的报告修订号,避免将历史报告替换为最新报告。 diff --git a/docs/mcp-query-implementation.md b/docs/mcp-query-implementation.md new file mode 100644 index 0000000..b5117d5 --- /dev/null +++ b/docs/mcp-query-implementation.md @@ -0,0 +1,108 @@ +# 产品 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 桥接。 + +`run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。 + +计算工具支持Agent按次指定 `timeout_seconds`,默认120秒;计算上限默认1小时,由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 配置并在工具schema公布。Agent、OpenCode和本地HTTP调用预算随目录同步,普通查询仍保持原有短超时。等待超时不意味着底层线程已停止或在线基线已回滚,禁止自动重试整个写入窗口。 + +## 认证与数据边界 + +Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数据,只选择配置中预注册的固定 issuer,再按规范顺序发现 OAuth/OIDC 元数据并严格核对 issuer。token endpoint 从已校验元数据读取,支持 client_secret_post 和 client_secret_basic。公开发现不带产品 Bearer 或 secret,文档最多64KiB、总预算10秒,拒绝重定向和远程明文地址;元数据缓存最多5分钟,MCP 401 后重新发现。产品 Bearer 只发给该 issuer 声明的 token endpoint,采用 Standard Token Exchange V2 取得 MCP 专用短期 Bearer,检查主体不变、issuer、audience、azp。Server 独立用缓存 JWKS 核验签名、issuer、audience、scope 和最长15分钟寿命。普通 REST 额外拒绝 MCP audience 或宿主 azp 的令牌,防止 audience 配置重叠时混用两类令牌。 + +上下文由专用宿主申请:绑定主体、client、项目、原生会话、用途、工具目录、权限上限和已批准的七类任务数据 scope;调查另绑定 task/run/execution_request/父会话。每次查询重新核验成员和权限;调查必须仍由父会话持有有效租约。资源读取仅限句柄绑定的任务。模型输入没有项目、network、URL、header、Bearer 或 context_handle。 + +调查数据返回前,在短事务内追加现有任务证据并保存调用回执。同一主体、会话、任务、run 和原生 call ID 的重试复用证据;不同参数或失效范围明确报错。七类原有访问申请 scope 保持原契约,MCP 的新增来源只扩充快照来源枚举。没有新增报告发布、审批、控制、Shell 或递归委派权限。 + +普通查询默认20条;目录类最多500条,时序最多10000条、20个标识、24小时;offset 最大10000。Agent 首次优先单对象、必要的短时间窗和小页,仅在证据不足时按需扩大,不自动遍历全网。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次普通查询仍为30秒),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。 + +单份结果超过8MiB报错;超过12KB的调查工具消息只返回证据资源引用,完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。 + +`get_task_context` 默认 section=summary,只查固定元数据、160字符任务标题及200字符结论预览,并返回 available_sections;preview_only 和 truncated 明确说明不是完整报告。按 section=task/report/evidence/actions/progress/access_requests/plan/run 读取详情,report_revision 保留选择旧报告的语义。data_path 选择嵌套值,limit 默认20、最多500,offset 最大10000;证据、动作和授权申请集合以稳定 ID 为键,按 ID 字典序分页。字符串按字符分页。详情在数据库返回前检查8KB预算,超限缩页或选更深路径。缺少可选进度或授权申请表时返回 metadata.available=false,不冒充空事实。绑定权限范围只读取七类既有 scope,不再加载整个任务上下文。不增加工具数量、数据库表或迁移,也不改变原 REST 完整上下文契约。 + +外围 MCP 直接读取所选项目 BizDB 的样板事实和既有时序。它不执行外围样板 HTTP 的故障场景注入;混有合成记录的结果按 synthetic 保守标记。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选并保留 synthetic;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的合成点冒充实测设备。 + +## 启用步骤 + +### Server 分发业务处置 Skill + +业务流程源文件位于 `resources/work-order-skills/*/SKILL.md`,随 Server 镜像分发。`get_task_context` 摘要提供 `handling_skill`(适用 workflow、名称、版本和 SHA-256)及 `display_time_zone=Asia/Shanghai`;使用 `section="skill"` 读取完整正文。流程由服务端持久化任务的 workflow 选择,调用方不能传入文件路径或其他任务类型;未知 workflow 返回 `available=false`,不自动套用其他流程。 + +当前覆盖爆管上报、水质投诉与预约跟进。业务 Skill 描述风险核查、隔离停水、用户通知、现场处置和恢复条件;Agent 本地 Skill 维护通用执行方法。权限、审批和执行仍由 Server 代码控制。流程说明没有 evidence_id,不记录成事件证据。具体草稿由现有 `propose_task_action` 保存并等待人工审批;人工通知等步骤通过关联预约工单承接,不能当作通知已发送或现场工作已完成。 + +接口复用现有任务绑定、工具权限和授权上下文,不新增 MCP 工具、REST API 或数据库迁移。更新后需让 Server 加载新代码,并重启 Agent 重新获取工具 schema 和指令;不自动重跑既有调查或改写已发布报告。后续单独修改流程正文由 Server 从打包资源读取,版本随正文一起维护,SHA-256 可区分同版本内容变化。 + +### 2026-10-15 实际运行环境 + +当前 `conda server` 已升级至 MCP 2.3.0、Pydantic 2.14.1、AnyIO 4.15.1,移除未使用且与 SDK 2 冲突的旧 fastmcp,并将 httpcore 更新至 requirements 中的 1.0.9;`pip check` 通过。旧环境保留为 `server-before-mcp-20261015`,配置、项目库和原生会话数据库的备份保存在私有 `data/mcp-rollout/`。 + +五个启用项目的业务库均已完成完整备份、归档目录可读检查、025 迁移回滚演练和正式应用。新项目仍须通过项目数据库迁移流程准备 MCP 表,不会在查询请求中自动创建。 + +后端通过 [tjwater-server.service](../infra/systemd/tjwater-server.service) 运行于 127.0.0.1:8000;Agent 运行于 8787,保留原生会话数据库和用户会话文件。两端使用 `https://keycloak.waternetwork.cn/realms/tjwater`,请求方为 `agent-mcp`,目标 audience 为 `tjwater-mcp`。worker 取令牌地址和前端登录 issuer 也已统一为该域名,避免 HTTP issuer 签发的令牌在 HTTPS 端点交换失败。 + +真实 Keycloak 及项目验证已覆盖 24 项后端目录、30 项 Agent 原生目录、管网分页、SCADA 设备与读数、分析运行和模型时间选项。读数请求成功但当前验证窗口为空,不代表已有完整实测数据。正式就绪与目录检查不调用模型推理。 + +以下为新环境的接入步骤。 + +本地 HTTP 身份服务的显式开发白名单见 [Keycloak 地址配置](mcp-keycloak-setup.md#4-地址数据库和启用顺序)。正式配置使用 HTTPS 域名;开发例外只适用于指定内网 issuer,不放行远程 HTTP MCP resource。 + +Keycloak 管理员可先按 [MCP 认证客户端配置清单](mcp-keycloak-setup.md) 操作。已有请求方客户端 `agent-mcp` 时直接使用该 ID,并同步 Agent 的 `TJWATER_MCP_CLIENT_ID` 与 Server 的 `TJWATER_MCP_HOST_CLIENT_ID`;下方默认示例中的 `tjwater-agent-mcp` 应相应替换。 + +1. 为 Server 安装更新后的 requirements。SDK 2 需要新的 Pydantic/AnyIO;升级现有环境前保留完整回退环境,移除没有业务引用且与 SDK 2 冲突的旧 fastmcp 依赖,并执行 `pip check`。不要依赖旧环境生成新版 OpenAPI。 +2. 按项目 BizDB 迁移流程执行 `resources/sql/025_mcp_query_contexts.sql`。先备份并检查 workbench schema 已存在;脚本只新增上下文/回执表和索引。每次建立句柄最多清理100个本项目已失效句柄;证据/回执保留遵循任务归档策略。 +3. 配置 Keycloak:专用 confidential requester `tjwater-agent-mcp`,启用 Standard Token Exchange V2,设置目标 audience `tjwater-mcp`,配置 `mcp.catalog.read`、`mcp.query`、`mcp.context.bind`。产品前端和 worker 的源 token 需要包含 requester audience;源 token 不应包含目标 MCP audience。交换令牌须保持源主体,azp 为 requester,RS256,寿命至多900秒,建议300秒。目录 client_credentials 只申请 catalog scope,也须带目标 audience;目录服务身份不因此获得项目业务权限。 +4. 在 Server 受保护环境设置: + +```dotenv +TJWATER_MCP_ENABLED=true +TJWATER_MCP_ISSUER_URI=https://identity.example/realms/water +TJWATER_MCP_RESOURCE_URI=https://server.example/mcp +TJWATER_MCP_AUDIENCE=tjwater-mcp +TJWATER_MCP_HOST_CLIENT_ID=tjwater-agent-mcp +TJWATER_MCP_ALLOWED_HOSTS=["server.example","localhost:*","127.0.0.1:*"] +TJWATER_MCP_ALLOWED_ORIGINS=[] +``` + +5. 在 Agent 宿主受保护环境设置: + +```dotenv +TJWATER_MCP_ENABLED=true +TJWATER_MCP_ISSUER_URI=https://identity.example/realms/water +TJWATER_MCP_RESOURCE_URI=https://server.example/mcp +TJWATER_MCP_AUDIENCE=tjwater-mcp +TJWATER_MCP_CLIENT_ID=tjwater-agent-mcp +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 时项目数据不可用,不回退。 + +原生调用的 `_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。 + +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)。外部交互客户端的授权码流程和第三方客户端绑定不包含在本次交付中。 + +HTTP 版本校验先核对头体一致性,再报告不支持的版本:不一致返回400/-32020;双方一致且版本不支持才返回400/-32022及支持列表。 + +## 验证 + +Server: + +```bash +python -m pytest tests/unit/test_mcp_query.py tests/unit/test_timeseries_history.py tests/unit/test_timeseries_history_schema.py tests/api/test_workbench_runtime_endpoints.py tests/api/test_history_query_permissions.py tests/unit/test_operational_data.py tests/auth -q +python scripts/check_openapi.py +``` + +Agent: + +```bash +bun run check +bun test tests/product/mcpAuthDiscovery.test.ts tests/product/mcpQueryClient.test.ts tests/product/credentialRefresh.test.ts tests/runtime/task-agent-policy.test.ts tests/runtime/opencodeConfig.test.ts tests/opencode/mcpPolicy.test.ts tests/opencode/productMcp.test.ts +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。 + +查询限制回归使用 `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 5439545..294fa8a 100644 --- a/docs/operational-task-workers.md +++ b/docs/operational-task-workers.md @@ -4,6 +4,17 @@ ## 调查任务队列 +第二轮及后续调查的触发条件、待执行轮次合并、旧运行替代,以及协作与消息的边界,见 [调查轮次与协作边界](operational-tasks.md#调查轮次与协作边界)。 + +worker 始终只处理 `event_investigation`,外部预约工单不占用模型并发。 +执行列表支持可选 `task_type`、`task_created_from` 查询参数;租约恢复请求支持同名字段, +均在数据库分页之前过滤。省略字段保持原接口行为。时间下界使用任务创建时间, +旧任务产生新 run 不会越过边界,不删除历史或修改旧任务状态来缩小执行范围。 + +本机常驻模板位于 `infra/systemd/tjwater-operational-worker.service`,使用已有 `.env` +凭据与配置。安装到用户 systemd 后启用,避免同时启动第二个 worker。 +用户服务未启用 linger 时,不保证退出登录后持续运行。 + 当前产品分为定时任务、调查任务、预约任务。定时任务保留前端 mock 数据,不调用后端计划创建或到期调度;调查任务读取数据库并进入本工作队列;预约任务读取数据库中的外围工单投影,以外部同步进度为准。已有后端 schedule API 暂不在当前产品主路径使用。 从后端仓库运行: @@ -22,13 +33,31 @@ conda run -n server python scripts/run_operational_worker.py | `OPERATIONAL_WORKER_AGENT_URL` | Agent 地址,默认本机 8787 | | `OPERATIONAL_WORKER_RECONCILE_SECONDS` | SSE 中断或漏通知时的对账间隔,默认 30 秒 | | `OPERATIONAL_WORKER_MAX_CONCURRENCY` | 同时投递的 run 上限,默认 4,可设置 1 至 32 | +| `OPERATIONAL_WORKER_TASK_CREATED_FROM` | 可选、带时区的任务创建时间下界(包含边界),限制排队、并发计数、凭据刷新和租约恢复;不是 run 创建时间 | | `OPERATIONAL_WORKER_BEARER` | 已具有该项目权限的产品身份,仅用于受控开发或短期运行 | | `OPERATIONAL_WORKER_TOKEN_URL` | 可选 OAuth 客户端凭据取令牌地址 | +| `OPERATIONAL_WORKER_DEV_TOKEN_URL` | 仅 `ENVIRONMENT=development` 且非空时覆盖取令牌地址;其他环境忽略,保留正式地址 | | `OPERATIONAL_WORKER_CLIENT_ID`、`OPERATIONAL_WORKER_CLIENT_SECRET` | 长期运行身份;不配置固定 Bearer 时使用,并按有效期刷新令牌 | | `OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS` | OAuth 缓存上限,默认 300 秒,可设置 30 至 600 秒;同时受令牌 expires_in 限制,应短于后端允许的最大令牌年龄 | 工作身份必须能通过现有产品身份与项目成员权限校验。配置令牌获取不等于自动创建产品用户、项目成员或授权。权限错误会保留任务用于后续重试,不会绕过认证。 +调查数据读取沿用账号已有的项目权限,不再因 `service` 身份额外移除 `scada.view` 和 +`simulation.view`。上下文的 `approved_data_scopes` 与数据查询采用同一权限集合;已有读取 +权限无需逐任务数据审批或委托凭据。此规则不新增角色权限、不放开跨项目读取,也不改变 +工单审批和设备控制权限。旧的显式批准委托路径保留兼容。 + +工作进程每轮从 Server 的 claimed/running 运行快照读取 agent_session_id,并使用当前 OAuth 凭据向 Agent 的 `POST /api/v1/session/{session_id}/credentials` 更新正在执行的会话凭据。令牌只在 header 中传递,body 为 `{}`,不会进入任务、报告或会话持久化文件。Agent 再次校验会话所属用户和项目;工作进程重启后从 Server 快照恢复关联,不依赖浏览器续期。固定 Bearer 仍受自身有效期限制。 + +Agent API 统一使用 `/api/v1` 前缀:任务投递为 `POST /api/v1/task-executions`,消息投递为 `POST /api/v1/task-messages/deliver`,凭据续期使用上述会话接口。旧 `/api/v1/agent/chat` 前缀已移除。更新脚本后须重新启动常驻 worker,现有 Python 进程不会自动加载文件变化。本机用户服务使用: + +```bash +systemctl --user restart tjwater-operational-worker.service +systemctl --user status tjwater-operational-worker.service --no-pager +``` + +重启后检查日志中的实际请求路径;HTTP 404 属于不可自动重试的投递失败,已失败的 run 不会因重启自动恢复,需通过原有重新调查流程显式创建新一轮。 + 长期 OAuth 身份由身份管理员创建支持 `client_credentials` 的专用机密客户端;服务账号令牌应具有后端配置要求的 audience、UUID `sub` 和 `preferred_username`。再由产品管理员把该 `sub` 绑定到启用的产品用户及目标项目成员关系。只赋予本次调查所需项目权限,不授予系统管理员或设备命令权限。当前项目成员策略同时包含工单审批/执行权限,不能将其误称为已经细分的专用最小权限服务角色。 本机联调配置示意(密钥由管理员通过安全配置填入,禁止粘贴到聊天): @@ -36,7 +65,7 @@ conda run -n server python scripts/run_operational_worker.py ```dotenv OPERATIONAL_WORKER_PROJECT_ID=324ff7ee-c2d4-5184-a84b-48fb14a2933e OPERATIONAL_WORKER_SERVER_URL=http://127.0.0.1:8000 -OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:18787 +OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:8787 OPERATIONAL_WORKER_TOKEN_URL=https:///realms//protocol/openid-connect/token OPERATIONAL_WORKER_CLIENT_ID= OPERATIONAL_WORKER_CLIENT_SECRET= @@ -50,8 +79,44 @@ OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8011 Agent 正常结束必须调用 run `complete`。同一执行代次缺少最终计划或报告时,Server 将 run 以 `ARTIFACT_INCOMPLETE` 明确失败,不依赖租约超时猜测完成状态。心跳只增加 run revision,不改变任务业务 revision。 +最终报告未提供 Mermaid 图(包括 `diagram=null`)时,Server 将同轮、同会话且不晚于报告发布时刻的最新有效进度图保存到报告中;图的证据引用必须已在报告范围内,节点状态保留记录原值,不把“记录时执行中”改成“已完成”。进度快照显式清空图后,不再从更旧的进度恢复。既有无图最终报告在详情、指定版本和历史列表读取时使用相同兼容规则,不改写历史数据库记录,也不借用新轮次的图。 + +前端默认跟随最新轮次的进展,新报告发布后自动显示新报告;没有本轮报告时保留并标注上一轮报告。本轮最终报告图优先于过程图。主动选择历史报告或展开阅读报告详情时保留阅读版本,通过“返回实时内容”或“查看最新内容”切换。 + ## 事件日志与清理 +### 普通对话与调查运行的私有消息 + +启用消息接口前,需要按现有项目数据库发布规则应用 +`resources/sql/024_agent_messages.sql`;本次代码修改不自动执行迁移或重启服务。 +该表也是调查启动前收信、运行完成/失败和租约恢复的必需依赖,即使没有协作消息, +也必须先迁移再启用调查执行。Server 在领取运行、修改状态前检查消息表;缺失时返回 +`503 DEPENDENCY_UNAVAILABLE`,提示具体迁移文件,不将排队运行改成“调查中”。 +若旧运行已因缺表卡住,补齐迁移后现有 worker 会按租约恢复规则创建下一代运行; +不要手工改状态或重复提交投诉。应用迁移前须确认允许自动恢复及后续模型调用。 +迁移保留每条消息的项目、会话归属、明确的 `task_id`/`run_id`、幂等请求 ID +及固定原生消息 ID。普通对话只能向排队或有效租约内的调查运行发送消息, +调查回复只能沿已存在的消息关系返回原对话。终态运行拒绝新消息,不创建新的运行代次。 + +`POST /api/v1/agent-messages` 与 `/{message_id}/reply` 要求项目任务写权限; +`GET /api/v1/agent-messages?session_id=...` 按认证用户的对话归属过滤,支持 +`cursor`、`limit` 与 `pending_only`。Agent 仍须验证实际会话归属,Server +不把客户端提交的会话 ID 当成会话所有权证明。服务身份读取任务会话消息时, +Server 校验该会话属于当前活动运行。跨用户投递查询与任务回执要求项目工作服务身份。 +原对话用户可以确认收到的回复,但不能改写目标会话或原生消息 ID。 + +既有 worker 每轮最多对账四页、每页 50 条私有待投递消息,即使调查并发已满仍继续对账, +不增加进程或配置项。临时网络错误保留消息等待重试;权限拒绝、不存在等确定性 +拒绝保存失败结果。消息正文、发送人和会话归属不写入项目事件日志。 +待投递接口按创建时间与消息 ID 返回 keyset 游标,worker 跨轮保留扫描位置; +扫到末页后下一轮从头重试。长期空闲对话的待收消息不会一直占住首屏、阻止后续调查投递。 + +`pending` 表示等待投递,`enqueued` 表示运行时已接受,`delivered` 表示消息已进入 +模型上下文;这三者都不能作为业务事项已经处理完成的证明。Agent 应在确认进入 +上下文后保存回执。同一运行仍有 `pending`/`enqueued` 入站消息时,`complete` +返回结构化 `PENDING_AGENT_MESSAGES` 409,Agent 需接收后重试完成。取消、租约 +超时、失败或被新代次替代会将旧运行未投递的入站消息明确标记为失败。 + `resources/sql/015_workbench_events_and_task_runs.sql` 在项目 BizDB 中增加 `domain_event_log` 和 `operational_task_runs`。后续迁移默认只应用到 `tjwater_v2`,扩展规则见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。事件与业务投影在同一事务提交,`LISTEN/NOTIFY` 只负责唤醒;浏览器与 worker 始终按持久游标补读。每个 API 进程对活跃项目最多保留一条 LISTEN 连接,无订阅者 60 秒后关闭。 事件默认保留 7 天。清理脚本会从 Metadata DB 解析项目路由,并在发现多个项目共享同一 BizDB 时拒绝运行。先确认 `OPERATIONAL_WORKER_PROJECT_ID` 对应 `tjwater_v2`,然后运行定向 dry-run: diff --git a/docs/operational-tasks.md b/docs/operational-tasks.md index 0051c87..5722e32 100644 --- a/docs/operational-tasks.md +++ b/docs/operational-tasks.md @@ -12,7 +12,7 @@ 调查计划由 Agent 按本次工单或人工请求动态生成。新计划使用 `execution-plan@2`,节点声明执行步骤、条件判断、并行分叉/汇合和审核点;判断节点记录所选路线,审核点引用持久化申请或动作。重新分析追加计划修订和新节点,不使用环形连线。旧 `execution-plan@1` 快照保留原样读取。字段、约束与示例见 [`dynamic-investigation-plan.md`](dynamic-investigation-plan.md)。 -数据读取申请通过 `POST /operational-tasks/{task_id}/access-requests` 创建,`POST .../access-requests/{access_request_id}/decision` 由具备项目审核权限及对应数据源读取权限的用户决定。申请、批准范围和审核身份保存在任务库中,批准或拒绝后重新排队分析。调查接口不会将服务身份继承的项目数据读取权限直接授予 Agent;批准的范围也只在该任务的有效执行期可查询。需配置 `OPERATIONAL_DATA_DELEGATION_BEARER` 为具备对应来源读取权限及该项目成员身份的专用产品凭证。未配置时返回 503,不以普通 Agent 会话的工具许可代替任务授权。 +调查数据读取沿用账号已有的项目权限,已有来源读取权限不需要逐任务审批或委托凭据。显式数据审批路径保留兼容:通过 `POST /operational-tasks/{task_id}/access-requests` 创建申请,`POST .../access-requests/{access_request_id}/decision` 由具备项目审核权限及对应数据源读取权限的用户决定。申请、批准范围和审核身份保存在任务库中,批准或拒绝后重新排队分析。兼容委托路径使用 `OPERATIONAL_DATA_DELEGATION_BEARER`,不能用普通 Agent 会话的工具许可代替任务授权。账号权限、委托和工作身份边界见 [工作进程文档](operational-task-workers.md)。 工单草稿可通过 `PATCH .../actions/{action_id}/draft` 修改并增加审计版本;`POST .../actions/{action_id}/confirm-dispatch` 使用一次人工操作批准当前版本并幂等提交,结果不明时继续使用回执核对。其他动作类型维持既有审批接口,不在调查页伪装为已支持的工单编辑动作。 @@ -26,6 +26,7 @@ | `GET /operational-tasks` | 按类型、状态分页读取任务 | | `GET /operational-tasks/{task_id}` | 读取当前计划、报告、证据摘要、审批动作和当前用户权限 | | `GET /operational-tasks/{task_id}/agent-context` | 读取 Agent 任务上下文及当前身份允许的数据源 | +| `POST /operational-tasks/{task_id}/retry` | 无活动运行时,为原任务创建新一轮执行 | | `POST /operational-tasks/{task_id}/runs/{run_id}/claim` | 使用当前执行请求号领取 queued run | | `POST /operational-tasks/{task_id}/runs/{run_id}/heartbeat` | 续期当前 Agent run,不递增任务业务 revision | | `POST /operational-tasks/{task_id}/runs/{run_id}/complete` | 校验同代最终产物并显式结束 run | @@ -46,6 +47,58 @@ 报告采用不可变修订,正文只保存当前结论、结论边界、建议、证据引用和待确认事项。详细的 Agent 工具轨迹和模型内部推理不进入报告。系统数据只能通过受控 `data-query` 形成证据,外围反馈和人工补充也有各自入口;Agent 不能自行声明这三类证据。合成演练证据始终标记为 `synthetic`。 +## 调查轮次与协作边界 + +本节按 2026-10-13 工作区代码核对,区分已实现行为和待接入功能,不代表运行环境已完成部署。 + +### 第二轮调查的触发条件 + +下列业务入口会要求重新分析。只有服务端接受并保存请求后才改变任务,聊天文字本身不构成触发条件。 + +| 入口 | 触发条件 | 当前处理 | +| --- | --- | --- | +| 人工补充证据 | 协作 Agent 调用 `add_task_human_evidence`,通过 `human-evidence` 保存用户明确提供或确认的事实 | 保存 `human_input` 证据并重新排队;假设、查询要求和 Agent 推断不能当作现场事实 | +| 外围工单反馈 | `external-feedback` 接受原事件的新版本,或符合关联校验的现场工单新反馈 | 重新排队;重复版本不重复触发,子工单创建回执不作为已结束调查的新现场反馈 | +| 动作审核 | `decide_action` 接受人工驳回 | 重新排队,让调查重新判断处置方案;仅批准不直接触发 | +| 动作执行与回执核对 | `submit_action` 或 `reconcile_action` 得到 `succeeded`、`failed`、`unsupported` | 重新排队;`submitted`、`unknown` 不走这一终态触发分支,后续由回执或外部反馈推进 | +| 数据授权审批 | 兼容的显式审批路径接受批准或拒绝 | 按可用范围重新排队;已有项目数据读取权限不需要逐任务审批,见工作进程文档 | +| 显式重新调查 | 调用 `POST /operational-tasks/{task_id}/retry`,且不存在活动运行 | 为原任务创建下一轮;后端接口已实现,主 Agent 的受控工具入口尚未接入 | +| 租约超时恢复 | 工作进程发现执行租约过期 | 将旧运行记为 `timed_out`,创建下一轮;连续三轮超时后任务失败,停止自动重试 | + +业务重新排队统一经过 `OperationalTaskService._requeue_task`,显式重试和租约恢复分别由 `retry_task`、`recover_expired_leases` 处理。具体实现见 [任务服务](../app/services/operational_tasks.py)。 + +### 轮次合并、替代与执行 + +- 原任务的 `task_id` 保持不变,历史报告修订保留。创建下一轮时,`execution_generation` 加一,并分配新的 `run_id` 和对应的 `execution_request_id`;报告版本号不是调查轮次。 +- 业务重新排队时,如果已有 `queued` 运行,补充信息合并到该待执行轮次,不增加轮次或另建运行。 +- 如果已有领取中或执行中的运行,业务重新排队会将其标记为 `cancelled`,失败码为 `SUPERSEDED_BY_REQUEUE`,再创建下一轮。旧轮次未投递消息会失败,不自动转投新轮次;旧执行也不能覆盖新轮次结果。 +- 显式 `retry` 与上述合并逻辑不同:存在活动运行时返回 `ACTIVE_RUN_EXISTS`,不会替代或再建一轮。 +- 同一幂等请求的重放不重复创建轮次。外部反馈还按来源版本去重,不能把重复提交或重复通知当作新反馈。 +- 保存为 `queued` 只表示已排队。后台工作进程领取并交给 Agent 执行后才算开始调查,浏览器不负责启动或维持执行。新一轮结束时追加报告,不改写旧报告。 + +### 协作对话和运行消息 + +任务页的“Agent 协作”打开或复用独立的 `task_collaboration` 会话。它不是后台 `task_execution` 会话,也不等于主对话向调查运行发送消息。已结束的任务仍可打开协作,讨论报告和证据,不因执行结束而禁用入口。 + +点击按钮只打开对话、显示引用并进入编辑状态,保留已有草稿,不自动发送。普通问题读取授权任务上下文及所选报告后回答,不改变轮次。查看报告、刷新页面、更新 Mermaid 或报告修订本身也不会创建下一轮。 + +主 Agent 的 `send_task_message` 必须指定核实过的 `task_id` 和 `run_id`,只接受 `queued` 或具有有效租约的 `claimed`、`running` 运行。新消息发往已结束轮次时返回 `409 RUN_TERMINAL`,不会唤醒旧执行、创建新轮次或改投最新轮次。消息属于讨论输入,不自动保存为人工证据。投递与回执规则见 [工作进程的私有消息说明](operational-task-workers.md#普通对话与调查运行的私有消息)。 + +### 前端引用与说明 + +任务页保留单一“Agent 协作”入口,不直接展示 `task_id`、`run_id`,但底层仍保留任务和会话绑定。对话引用区只显示任务名称、所选报告的轮次和版本,以及证据或缺口引用说明。轮次取自所选报告快照,旧报告缺少轮次时省略,不拿任务当前轮次补齐。 + +引用区不显示实时执行状态,不以缓存的“调查中”或“已结束”决定消息能否投递。发送时由后端重新校验目标和状态,历史报告引用不随任务进入新轮次而改变。 + +- 按钮提示:打开调查协作对话,引用此任务,不会直接发送。 +- 输入框提示:可询问调查进展、讨论报告或补充信息。 + +### 待接入功能 + +目前保存人工证据会直接重新排队,没有单独的重跑确认步骤。后续拟在保存前明确提示“保存此信息将启动新一轮调查”;若已有待执行轮次,则应说明会合并到该轮次,不能把确认步骤描述为已经实现。 + +主 Agent 尚不能通过普通消息重启已结束任务。后续受控的重新调查工具应在用户明确要求后读取最新任务,校验项目权限和活动运行,再为原任务创建新轮次并关联补充要求。目标是保留原 `task_id`、返回新 `run_id` 并等待后台领取,不复活旧运行,不把普通提问隐式转换成重跑。 + ## 人工确认与执行真实性 动作分为 `create_work_order` 和 `issue_command`,审批状态与执行状态分别保存。每次状态变化都会写入不可变动作版本;首次提交生成固定 `execution_request_id`,后续回执核对不改变它。 diff --git a/infra/systemd/tjwater-operational-worker.service b/infra/systemd/tjwater-operational-worker.service new file mode 100644 index 0000000..270dedd --- /dev/null +++ b/infra/systemd/tjwater-operational-worker.service @@ -0,0 +1,15 @@ +[Unit] +Description=TJWater investigation task worker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=%h/NextTJWater/next-tjwater-server +ExecStart=%h/miniconda3/envs/server/bin/python scripts/run_operational_worker.py +Restart=on-failure +RestartSec=5 +UMask=0077 + +[Install] +WantedBy=default.target diff --git a/requirements.txt b/requirements.txt index 0f0fd59..78e2908 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ annotated-doc==0.0.4 annotated-types==0.7.0 -anyio==4.8.0 +anyio==4.15.1 attrs==25.4.0 Authlib==1.6.6 beartype==0.22.9 @@ -30,14 +30,13 @@ esda==2.7.0 et_xmlfile==2.0.0 exceptiongroup==1.3.1 fastapi==0.128.0 -fastmcp==2.9.2 fonttools==4.58.0 future==0.18.3 GeoAlchemy2==0.17.1 geopandas==1.0.1 greenlet==3.1.1 -h11==0.14.0 -httpcore==1.0.7 +h11==0.16.0 +httpcore==1.0.9 httpx==0.28.1 httpx-sse==0.4.3 idna==3.10 @@ -63,7 +62,7 @@ mapclassify==2.8.1 markdown-it-py==3.0.0 MarkupSafe==3.0.3 matplotlib==3.10.3 -mcp==1.9.4 +mcp==2.3.0 mdurl==0.1.2 more-itertools==10.8.0 msgpack==1.1.0 @@ -101,9 +100,9 @@ pyarmor==9.0.7 pyarmor.cli.core==7.6.4 pybind11-stubgen==2.5.1 pycparser==2.23 -pydantic==2.10.6 +pydantic==2.14.1 pydantic-settings==2.12.0 -pydantic_core==2.27.2 +pydantic_core==2.50.1 pydevd-pycharm==243.16718.36 Pygments==2.18.0 PyJWT==2.10.1 diff --git a/resources/sql/023_operational_task_progress.sql b/resources/sql/023_operational_task_progress.sql new file mode 100644 index 0000000..25f82ee --- /dev/null +++ b/resources/sql/023_operational_task_progress.sql @@ -0,0 +1,42 @@ +-- Latest investigation reading snapshots and controlled browser presentation events. +BEGIN; +SET LOCAL lock_timeout = '5s'; + +DO $$ +BEGIN + IF current_database() <> 'tjwater_v2' THEN + RAISE EXCEPTION 'Investigation progress migration is restricted to tjwater_v2'; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS workbench.operational_task_progress ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + run_id uuid NOT NULL, + revision bigint NOT NULL CHECK (revision >= 1), + document jsonb NOT NULL, + published_at timestamptz NOT NULL, + PRIMARY KEY (project_id,task_id,run_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE, + FOREIGN KEY (project_id,run_id) + REFERENCES workbench.operational_task_runs(project_id,run_id) ON DELETE CASCADE, + CHECK ((jsonb_typeof(document)='object' + AND document->>'run_id'=run_id::text + AND (document->>'revision')::bigint=revision) IS TRUE) +); + +ALTER TABLE workbench.domain_event_log + DROP CONSTRAINT IF EXISTS domain_event_log_topic_check; +ALTER TABLE workbench.domain_event_log ADD CONSTRAINT domain_event_log_topic_check + CHECK (topic IN ( + 'operational.task.changed','operational.run.queued','operational.run.changed', + 'operational.report.published','operational.progress.changed', + 'operational.presentation.requested','operational.action.changed', + 'operational.notification.changed','workbench.alert.changed', + 'workbench.work_order.changed','analysis.run.changed' + )); + +COMMENT ON TABLE workbench.operational_task_progress IS + 'One latest complete reading snapshot per investigation run; never an execution scheduler.'; +COMMIT; diff --git a/resources/sql/024_agent_messages.sql b/resources/sql/024_agent_messages.sql new file mode 100644 index 0000000..30e6531 --- /dev/null +++ b/resources/sql/024_agent_messages.sql @@ -0,0 +1,42 @@ +-- Private messages are deliberately excluded from the project event log. +BEGIN; + +CREATE TABLE IF NOT EXISTS workbench.agent_messages ( + project_id uuid NOT NULL, + message_id uuid NOT NULL, + request_id uuid NOT NULL, + task_id uuid NOT NULL, + run_id uuid NOT NULL, + source_session_id text NOT NULL CHECK (length(source_session_id)>0), + source_owner_id uuid NOT NULL, + conversation_owner_id uuid NOT NULL, + target_session_id text, + direction text NOT NULL CHECK (direction IN ('to_task','to_chat')), + in_reply_to uuid, + text text NOT NULL CHECK (length(text) BETWEEN 1 AND 12000 AND text ~ '\S'), + status text NOT NULL CHECK (status IN ('pending','enqueued','delivered','failed')), + native_message_id text NOT NULL, + failure_detail text, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + delivered_at timestamptz, + PRIMARY KEY (project_id,message_id), + UNIQUE (project_id,source_owner_id,source_session_id,request_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id), + FOREIGN KEY (project_id,run_id) + REFERENCES workbench.operational_task_runs(project_id,run_id), + FOREIGN KEY (project_id,in_reply_to) + REFERENCES workbench.agent_messages(project_id,message_id), + CHECK ((direction='to_task') + OR (direction='to_chat' AND in_reply_to IS NOT NULL AND target_session_id IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS agent_messages_pending_idx + ON workbench.agent_messages(project_id,created_at,message_id) + WHERE status IN ('pending','enqueued'); +CREATE INDEX IF NOT EXISTS agent_messages_owner_session_idx + ON workbench.agent_messages(project_id,conversation_owner_id,created_at); +CREATE INDEX IF NOT EXISTS agent_messages_run_idx + ON workbench.agent_messages(project_id,run_id,status); + +COMMIT; diff --git a/resources/sql/025_mcp_query_contexts.sql b/resources/sql/025_mcp_query_contexts.sql new file mode 100644 index 0000000..8498782 --- /dev/null +++ b/resources/sql/025_mcp_query_contexts.sql @@ -0,0 +1,27 @@ +-- Additive MCP storage. Apply through the existing project database workflow. +-- No access token, refresh token or OAuth client secret is stored here. +BEGIN; +CREATE TABLE IF NOT EXISTS workbench.mcp_query_contexts ( + context_handle text PRIMARY KEY, + project_id uuid NOT NULL, + subject text NOT NULL, + client_id text NOT NULL, + document jsonb NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS mcp_query_context_owner + ON workbench.mcp_query_contexts (project_id,subject,client_id); +CREATE INDEX IF NOT EXISTS mcp_query_context_expiry + ON workbench.mcp_query_contexts (project_id,expires_at); +CREATE TABLE IF NOT EXISTS workbench.mcp_query_receipts ( + project_id uuid NOT NULL, + scope_key text NOT NULL, + call_id text NOT NULL, + parameters jsonb NOT NULL, + result jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project_id,scope_key,call_id) +); +COMMIT; diff --git a/resources/work-order-skills/appointment-follow-up/SKILL.md b/resources/work-order-skills/appointment-follow-up/SKILL.md new file mode 100644 index 0000000..ce64110 --- /dev/null +++ b/resources/work-order-skills/appointment-follow-up/SKILL.md @@ -0,0 +1,16 @@ +--- +name: appointment-follow-up +description: 调查关联预约任务的执行状态与反馈,核对审批、派发和现场结果,避免把预约任务当成新的爆管或投诉事件。 +metadata: + version: "1.0.0" +--- + +# 预约任务跟进 + +预约工单承载已安排的人工工作。先读取原任务、已审批动作、预约时间、派发状态和现场反馈;需要时追溯关联的投诉或爆管事件。预约时间、工单派发时间、现场完成时间和事件发生时间分别说明。 + +核对预约工作的内容、执行条件和实际回执。只有现场反馈与证据支持时,才能认定采样、通知、抢修、冲洗或复测完成;工单创建或派发成功仅代表流程状态变化。对尚未完成的工作,报告责任事项、已有进展、缺少的反馈和下一步依赖。 + +预约工单不自动触发隔离停水或污染水排放,也不生成同类型的投诉、爆管事件。确有新增处置需要且已有证据支持时,保存阶段报告,再提议具体的关联预约工单(work_order_type=general),供人工审批;先核查已有有效草稿,避免在跟进过程中递归建单。 + +如发现新的爆管或水质风险,明确记录有依据的发现及待核验项,不擅自更改当前任务类型。审批、现场操作、业务重新排队和恢复供水均遵守现有 Server 流程。正式报告保留所用流程名称和版本,便于追溯。 diff --git a/resources/work-order-skills/burst-report/SKILL.md b/resources/work-order-skills/burst-report/SKILL.md new file mode 100644 index 0000000..ad592f8 --- /dev/null +++ b/resources/work-order-skills/burst-report/SKILL.md @@ -0,0 +1,26 @@ +--- +name: burst-report +description: 调查供水爆管上报,形成隔离停水、用户通知、抢修和恢复供水的处置方案及待审批动作。 +metadata: + version: "1.0.0" +--- + +# 爆管上报处置 + +依据上报位置、时间、现场描述、相关管段台账和同窗压力流量证据,区分上报事实、爆管候选、已确认故障与演练假设。完成初步风险判断不要求抢修、复测和回访已经发生;这些属于后续执行反馈。演练数据可支持明确标注的演练判断,不能证明生产故障。 + +## 调查与处置路线 + +1. 核对上报对象与时间窗,查询相关管段、周边监测和既有处置反馈。先确定实际覆盖范围;目录首屏或查询错误不能证明全网没有异常。具备输入时使用爆管侦测或定位分析,候选结果仍需现场核验。 +2. 形成隔离停水方案。已确认目标管段时,按需分析隔离阀门、关阀影响、失供范围和供水保障。资产映射、阀门状态或影响范围不清时,明确待现场核验条件;不得编造阀门编号或直接生成无目标的设备命令。 +3. 安排用户通知和供水保障。草稿写明已知影响区域、拟停水时段或“待确认”、通知内容、通知渠道及责任岗位;关阀前通知与紧急处置后的补通知取决于现场紧迫性和获批方案。未知用户数量不填虚构值。通知草稿或内部调查消息均不能说成用户已收到通知。 +4. 安排现场定位、止水与抢修。说明施工位置、隔离前置条件、抢修目标和需要回传的现场事实。存在管内污染侵入风险时,再安排适用的清洗、消毒或冲洗评估,不能把每次爆管都判断为污染事件。 +5. 恢复供水前核验修复、压力、漏水复查及适用的水质复测结果,按获批方案恢复并通知用户;缺少现场反馈时保留“待执行/待核验”,不能宣称恢复完成。 + +## 报告与动作草稿 + +报告说明当前判断、证据覆盖、隔离影响、通知安排、抢修及恢复条件。能够形成有边界的初步判断时,不因后续维修或复测尚未完成而把整个调查判为关键数据不足。确实缺少支撑当前判断的关键证据时,遵守 insufficient_data 契约,不强造结论或动作。 + +在已有证据足以支持具体处置建议时,先保存阶段报告,再通过现有动作提议入口保存待人工审批的具体草稿。现场核验、停水通知、抢修等人工执行事项使用关联预约工单(work_order_type=general);原爆管上报事件保持独立。草稿含位置、工作内容、先后依赖、执行前核验条件和回执要求,避免为每个图节点机械建单。没有实际客户通知工具时,将通知列为待执行的人工工单步骤。 + +有真实且可控制的设备目标、明确命令及充分依据时,才提议设备命令。审批和执行由 Server 控制,调查不直接关闭阀门或发送用户通知。复用已存在的有效草稿,结合实际回执继续调查,不重复提议同一动作。正式报告保留所用流程名称和版本,便于追溯。 diff --git a/resources/work-order-skills/water-quality-complaint/SKILL.md b/resources/work-order-skills/water-quality-complaint/SKILL.md new file mode 100644 index 0000000..29ec962 --- /dev/null +++ b/resources/work-order-skills/water-quality-complaint/SKILL.md @@ -0,0 +1,26 @@ +--- +name: water-quality-complaint +description: 调查供水水质投诉,按证据和风险提出采样、隔离、用户通知、污染水处置及复测恢复方案。 +metadata: + version: "1.0.0" +--- + +# 水质投诉处置 + +区分用户感官投诉、监测异常、已确认的水质问题与污染源假设。单个投诉不能直接证明供水管网污染;上游监测正常也不能排除末梢或二次供水问题。不预设异常必然来自二次供水,不把模拟扩散结果当成污染源反演。 + +## 调查与处置路线 + +1. 核对投诉时间、位置、异常描述与同类投诉,查询供水路径、相关水质和压力监测、适用的历史记录及现场采样反馈。分别说明实际覆盖的上游、末梢和二次供水范围,不混用模型基线与实测。 +2. 评估是否需要隔离停水及饮用水风险告知。已确认或有充分证据支持局部供水风险时,提出与风险相称的隔离范围和保障措施,核对阀门与影响区域。尚不能确定风险时,先提出有依据的现场核验或采样安排,不因一条投诉直接建议全网停水,也不承诺水可安全饮用。 +3. 将用户通知纳入处置方案:明确已知影响范围、需人工确认的用水提示、通知渠道、责任岗位和更新条件。没有实际发送工具时形成待审批的人工通知步骤,不把调查消息当成客户通知。 +4. 对已支持的污染水风险,安排排查来源、控制污染进入并评估受影响管段的冲洗或排放。仅在来源参数、目标管段或冲洗节点等必需输入已知时使用扩散或冲洗分析;参数不足不虚构源浓度、排放量或节点。具体排水位置、承接条件及现场操作要求需核验后审批执行,不默认污染水可任意排放。 +5. 清洗或冲洗后取得适用采样和复测反馈,再评估恢复供水与用户回访。现场处置、复测和恢复是下游执行步骤,其尚未发生不能单独作为拒绝初步风险分析的理由。 + +## 报告与动作草稿 + +报告列明当前风险判断、证据覆盖、隔离与通知条件、污染水处置路线和复测恢复条件。演练输入只支持标注为演练的判断。真正缺少支撑当前判断的关键证据时,遵守 insufficient_data 契约,不用通用处置模板强造已确认污染结论。 + +已能形成有边界的处置建议时,保存阶段报告后,通过现有动作提议入口生成待人工审批的关联预约工单(work_order_type=general)。保留原投诉事件,草稿写明实际位置、采样或处置内容、责任要求、执行依赖、现场待核验条件和回执要求;可将通知、采样、冲洗和复测组织成合理的人工执行事项,不机械逐节点建单。 + +设备命令只针对真实且可控制的目标,审批与执行由 Server 控制。草稿保存或工单派发不等于采样、通知、冲洗、复测完成。核对已有草稿和现场反馈后再继续,避免重复提议。正式报告保留所用流程名称和版本,便于追溯。 diff --git a/samples/peripheral/store.py b/samples/peripheral/store.py index 9328a04..c7f074e 100644 --- a/samples/peripheral/store.py +++ b/samples/peripheral/store.py @@ -30,6 +30,11 @@ WORK_ORDER_FIELD_LABELS = { "assignee": "负责人", "scheduled_at": "预约时间", "location": "位置", "scenario_code": "场景编号", "feedback_requirements": "回执要求", } +WORK_ORDER_CREATED_DETAILS = { + "general": "预约工单已创建。", + "water_quality_complaint": "投诉工单已创建。", + "burst_report": "爆管上报工单已创建。", +} class PostgresStore: @@ -351,7 +356,8 @@ class PostgresStore: "origin": "synthetic", "created_at": now, "updated_at": now, "created_by": who.user_id, "updated_by": who.user_id, "activity": [{"sequence": 1, "occurred_at": now, "actor": who.user_id, - "kind": "created", "title": "工单创建", "detail": "预约工单已创建。"}], + "kind": "created", "title": "工单创建", + "detail": WORK_ORDER_CREATED_DETAILS[command.work_order_type]}], } return self._save_work_order(conn, who, document) return self._mutate(who, "create_work_order", command, write) @@ -380,8 +386,10 @@ class PostgresStore: if command.progress_note: title, detail, kind = "现场反馈", command.progress_note, "feedback" elif changed_fields: + field_labels = WORK_ORDER_FIELD_LABELS | ({"scheduled_at": "发生时间"} + if document["work_order_type"] != "general" else {}) title, detail, kind = "工单信息更新", "更新了" + "、".join( - WORK_ORDER_FIELD_LABELS.get(field, field) for field in changed_fields + field_labels.get(field, field) for field in changed_fields ) + "。", "updated" else: title, detail, kind = "工单信息确认", "工单信息已确认,未记录新的现场进展。", "updated" diff --git a/scripts/run_operational_worker.py b/scripts/run_operational_worker.py index 310aa42..e50544f 100644 --- a/scripts/run_operational_worker.py +++ b/scripts/run_operational_worker.py @@ -13,6 +13,7 @@ import logging import os import time from dataclasses import dataclass +from datetime import datetime from pathlib import Path from uuid import UUID, uuid4 @@ -35,6 +36,7 @@ class WorkerSettings: client_id: str = "" client_secret: str = "" token_refresh_seconds: float = 300 + task_created_from: str | None = None @classmethod def from_environment(cls) -> "WorkerSettings": @@ -43,6 +45,9 @@ class WorkerSettings: interval = float(os.environ.get("OPERATIONAL_WORKER_RECONCILE_SECONDS", "30")) if interval < 1: raise ValueError("worker interval must be at least one second") + token_url = os.environ.get("OPERATIONAL_WORKER_TOKEN_URL", "") + if os.environ.get("ENVIRONMENT") == "development": + token_url = os.environ.get("OPERATIONAL_WORKER_DEV_TOKEN_URL") or token_url settings = cls( server_url=os.environ.get("OPERATIONAL_WORKER_SERVER_URL", "http://127.0.0.1:8000"), agent_url=os.environ.get("OPERATIONAL_WORKER_AGENT_URL", "http://127.0.0.1:8787"), @@ -50,11 +55,16 @@ class WorkerSettings: interval_seconds=interval, max_concurrency=int(os.environ.get("OPERATIONAL_WORKER_MAX_CONCURRENCY", "4")), bearer=os.environ.get("OPERATIONAL_WORKER_BEARER", ""), - token_url=os.environ.get("OPERATIONAL_WORKER_TOKEN_URL", ""), + token_url=token_url, client_id=os.environ.get("OPERATIONAL_WORKER_CLIENT_ID", ""), 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, ) + if settings.task_created_from: + boundary = datetime.fromisoformat(settings.task_created_from.replace("Z", "+00:00")) + if boundary.tzinfo is None: + raise ValueError("worker task creation boundary must include a timezone") if not 30 <= settings.token_refresh_seconds <= 600: raise ValueError("service token refresh interval must be between 30 and 600 seconds") if not 1 <= settings.max_concurrency <= 32: @@ -97,28 +107,52 @@ class OperationalWorker: self.settings = settings self.client = client self.credentials = CredentialProvider(settings) + self._agent_sessions: dict[str, str] = {} + self._message_cursor: str | None = None async def tick(self) -> int: headers = await self.credentials.headers(self.client) server = self.settings.server_url.rstrip("/") + scope = {"task_type": "event_investigation"} + if self.settings.task_created_from: + scope["task_created_from"] = self.settings.task_created_from recovery = await self.client.post( - f"{server}/api/v1/operational-tasks/leases/recover", headers=headers, json={"limit": 100}, + f"{server}/api/v1/operational-tasks/leases/recover", headers=headers, + json={"limit": 100, **scope}, ) recovery.raise_for_status() active_run_ids: set[str] = set() for status_value in ("claimed", "running"): response = await self.client.get( f"{server}/api/v1/operational-task-runs", headers=headers, - params={"status": status_value, "limit": self.settings.max_concurrency}, + params={"status": status_value, "limit": self.settings.max_concurrency, **scope}, ) response.raise_for_status() - active_run_ids.update(str(item["run_id"]) for item in response.json()["items"]) + for item in response.json()["items"]: + run_id = str(item["run_id"]) + active_run_ids.add(run_id) + session_id = item.get("agent_session_id") + if isinstance(session_id, str) and session_id: + self._agent_sessions[run_id] = session_id + for run_id, session_id in list(self._agent_sessions.items()): + if run_id not in active_run_ids: + self._agent_sessions.pop(run_id, None) + continue + # Refresh the Agent vault with the worker's existing OAuth provider. + # Background runs never depend on an interactive browser refresh. + renewed = await self.client.post( + f"{self.settings.agent_url.rstrip('/')}/api/v1/session/{session_id}/credentials", + headers=headers, json={}, + ) + if renewed.status_code not in (202, 404, 409): + renewed.raise_for_status() available = max(0, self.settings.max_concurrency - len(active_run_ids)) if available == 0: + await self._reconcile_messages(headers) return 0 response = await self.client.get( f"{server}/api/v1/operational-task-runs", headers=headers, - params={"status": "queued", "limit": available}, + params={"status": "queued", "limit": available, **scope}, ) response.raise_for_status() queued = response.json()["items"] @@ -131,7 +165,7 @@ class OperationalWorker: request_id = str(UUID(run["execution_request_id"])) try: result = await self.client.post( - f"{self.settings.agent_url.rstrip('/')}/api/v1/agent/chat/task-executions", + f"{self.settings.agent_url.rstrip('/')}/api/v1/task-executions", headers=headers, json={ "task_id": task_id, @@ -143,6 +177,9 @@ class OperationalWorker: payload = result.json() if payload.get("task_id") != task_id or payload.get("run_id") != run_id: raise ValueError("Agent returned a different task run") + session_id = payload.get("session_id") + if isinstance(session_id, str) and session_id: + self._agent_sessions[run_id] = session_id return True await self._record_dispatch_failure( headers, run, retryable=result.status_code >= 500, @@ -157,8 +194,57 @@ class OperationalWorker: return False results = await asyncio.gather(*(dispatch(run) for run in queued)) + await self._reconcile_messages(headers) return sum(results) + async def _reconcile_messages(self, headers: dict[str, str]) -> None: + # Reconcile already bound runs even when task dispatch is at capacity. + # Agent and Server share immutable message/native identities for retries. + semaphore = asyncio.Semaphore(self.settings.max_concurrency) + + async def dispatch(message: dict) -> None: + async with semaphore: + message_id = str(UUID(message["message_id"])) + try: + delivered = await self.client.post( + f"{self.settings.agent_url.rstrip('/')}/api/v1/task-messages/deliver", + headers=headers, json={"message_id": message_id}, + ) + if delivered.status_code in {400, 403, 404, 410, 422}: + failed = await self.client.post( + f"{self.settings.server_url.rstrip('/')}/api/v1/agent-messages/{message_id}/delivery", + headers=headers, json={ + "status": "failed", + "failure_detail": f"Agent rejected message delivery (HTTP {delivered.status_code})", + }, + ) + if failed.status_code != 409: + failed.raise_for_status() + elif not delivered.is_success: + delivered.raise_for_status() + except httpx.HTTPError: + # Avoid message content and credentials in logs. Durable + # nonterminal messages are available at the next tick. + logger.warning("Agent message delivery remains pending: %s", message_id) + + # Bounded pages and a cursor retained between ticks prevent idle chats + # from occupying the first page forever. Completing a sweep starts the + # next tick from the beginning so older transient failures are retried. + for _ in range(4): + params = {"limit": 50} + if self._message_cursor: + params["cursor"] = self._message_cursor + response = await self.client.get( + f"{self.settings.server_url.rstrip('/')}/api/v1/agent-messages/pending", + headers=headers, params=params, + ) + response.raise_for_status() + payload = response.json() + await asyncio.gather(*(dispatch(message) for message in payload["items"])) + self._message_cursor = payload.get("next_cursor") + if not self._message_cursor: + break + async def _record_dispatch_failure( self, headers: dict[str, str], run: dict, *, retryable: bool, code: str, detail: str = "Agent rejected task run dispatch", @@ -221,6 +307,10 @@ class OperationalWorker: async def run(once: bool) -> None: settings = WorkerSettings.from_environment() + 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() diff --git a/scripts/sync_peripheral_work_orders.py b/scripts/sync_peripheral_work_orders.py index dcdaa20..fb4c7f9 100644 --- a/scripts/sync_peripheral_work_orders.py +++ b/scripts/sync_peripheral_work_orders.py @@ -122,7 +122,9 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe existing[task["source"].get("external_id")] = task elif task["task_type"] == "event_investigation": event_tasks[task.get("source", {}).get("external_id")] = task - if task["status"] in {"queued", "waiting_human", "running"}: + if task["status"] in {"queued", "waiting_human", "running"} or ( + work_order_ids is not None and task["status"] == "completed" + ): investigations[task["task_id"]] = task if page.get("next_offset") is None: break @@ -188,23 +190,62 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe result.raise_for_status() task = result.json() existing[external_id] = task + changed = (task["source"].get("external_revision") or 0) < order["revision"] + if work_order_ids is None and changed and order["revision"] > 1: + # Only changed sources need completed-parent receipt discovery. + # Never fetch every historical completed investigation each tick. + offset = 0 + while True: + related = await client.get(tasks_url, headers=headers, params={ + "limit": 100, "offset": offset, "peripheral_work_order_id": order["work_order_id"], + }) + related.raise_for_status() + related_page = related.json() + for parent in related_page["items"]: + if parent["task_type"] != "event_investigation" or parent.get("status") != "completed": + continue + detail = await client.get(f"{tasks_url}/{parent['task_id']}", headers=headers) + detail.raise_for_status() + for action in detail.json().get("actions", []): + receipt = action.get("receipt") + if (action["kind"] == "create_work_order" and receipt + and receipt["adapter"] == "peripheral_sample" + and receipt["receipt_id"] == order["work_order_id"]): + linked.setdefault(order["work_order_id"], []).append(parent["task_id"]) + if related_page.get("next_offset") is None: + break + offset = related_page["next_offset"] event_id = f"{external_id}:{order['revision']}" + notes = [activity["detail"] for activity in order.get("activity", []) + if activity.get("kind") == "feedback" and activity.get("detail")] + evidence_summary = order["summary"] + if notes: + evidence_summary += "\n现场反馈:\n" + "\n".join(notes[-10:]) feedback = { "request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id, "source_revision": order["revision"], "status": STATUS[order["status"]], "occurred_at": order["updated_at"], "detail": f"合成外围工单 {order['work_order_id']}:{order['status']}", - "evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": order["summary"], + "evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": evidence_summary, "source_label": "外围样板合成工单", "source_ref": external_id, "observed_at": order["updated_at"]}, } - targets = set(linked.get(order["work_order_id"], [])) + # A child order's creation is already represented by its dispatch + # receipt. Only a subsequent source revision is new field input. + targets = set(linked.get(order["work_order_id"], [])) if order["revision"] > 1 else set() + if work_order_ids is None and not changed: + targets.clear() event_task = event_tasks.get(order["work_order_id"]) - if event_task and order["revision"] > (event_task["source"].get("external_revision") or 0): + if (event_task and event_task.get("status") != "cancelled" + and order["revision"] > (event_task["source"].get("external_revision") or 0)): targets.add(event_task["task_id"]) - if (task["source"].get("external_revision") or 0) < order["revision"]: - targets.add(task["task_id"]) - for target in targets: + # The external projection is the reconciliation cursor. Advance it + # only after all investigation feedback was accepted; retry remains + # safe if a response is lost or a later target fails. + ordered_targets = sorted(targets) + if changed: + ordered_targets.append(task["task_id"]) + for target in ordered_targets: payload = dict(feedback) if target == task["task_id"]: payload["work_order_snapshot"] = work_order_snapshot(order) diff --git a/tests/api/test_agent_message_routes.py b/tests/api/test_agent_message_routes.py new file mode 100644 index 0000000..3765d9c --- /dev/null +++ b/tests/api/test_agent_message_routes.py @@ -0,0 +1,41 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.api.v1.endpoints import agent_messages +from app.auth.project_dependencies import ProjectContext +from app.domain.domain_errors import DomainError +from app.domain.schemas.agent_messages import AgentMessageCreate +from app.main import app + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def test_private_message_and_exact_run_routes_are_in_contract(): + paths = app.openapi()["paths"] + assert "post" in paths["/api/v1/agent-messages"] + assert "get" in paths["/api/v1/agent-messages/pending"] + assert "post" in paths["/api/v1/agent-messages/{message_id}/reply"] + assert "post" in paths["/api/v1/agent-messages/{message_id}/delivery"] + assert "get" in paths["/api/v1/operational-tasks/{task_id}/runs/{run_id}"] + parameters = paths["/api/v1/agent-messages"]["get"]["parameters"] + assert {"session_id", "cursor", "pending_only"} <= {param["name"] for param in parameters} + + +@pytest.mark.anyio +async def test_message_route_preserves_structured_terminal_rejection(monkeypatch): + service = SimpleNamespace(create=AsyncMock(side_effect=DomainError( + "RUN_TERMINAL", "Run no longer accepts messages", status=409))) + monkeypatch.setattr(agent_messages, "_service", lambda ctx, conn: service) + context = ProjectContext(uuid4(), "test", uuid4(), "member") + with pytest.raises(HTTPException) as error: + await agent_messages.create_agent_message(AgentMessageCreate( + request_id=uuid4(), task_id=uuid4(), run_id=uuid4(), + source_session_id="main", text="当前情况?"), ctx=context, conn=object()) + assert error.value.status_code == 409 and error.value.detail["code"] == "RUN_TERMINAL" diff --git a/tests/api/test_health_endpoints.py b/tests/api/test_health_endpoints.py new file mode 100644 index 0000000..50708d1 --- /dev/null +++ b/tests/api/test_health_endpoints.py @@ -0,0 +1,150 @@ +import asyncio +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api import health + + +@pytest.fixture +def probe_app(monkeypatch): + app = FastAPI() + app.include_router(health.router) + app.state.startup_complete = True + app.state.mcp_runtime = SimpleNamespace(ready=True) + database_check = AsyncMock(return_value=True) + monkeypatch.setattr(health, "metadata_database_ready", database_check) + return app, TestClient(app), database_check + + +@pytest.mark.parametrize("runtime", [None, SimpleNamespace(ready=True)]) +def test_ready_accepts_disabled_or_started_mcp(probe_app, runtime): + app, client, database_check = probe_app + app.state.mcp_runtime = runtime + response = client.get("/ready") + assert response.status_code == 200 + assert response.json() == { + "status": "ready", + "checks": { + "startup": "ready", "metadata_database": "ready", + "mcp_transport": "disabled" if runtime is None else "ready", + }, + } + assert response.headers["cache-control"] == "no-store" + database_check.assert_awaited_once_with() + + +def test_startup_and_shutdown_are_not_ready_without_touching_database(probe_app): + app, client, database_check = probe_app + app.state.startup_complete = False + response = client.get("/ready") + assert response.status_code == 503 + assert response.json()["checks"]["startup"] == "not_ready" + assert response.json()["checks"]["metadata_database"] == "not_checked" + database_check.assert_not_awaited() + + +@pytest.mark.parametrize("component", ["metadata_database", "mcp_transport"]) +def test_dependency_not_ready_returns_503_without_breaking_liveness(probe_app, component): + app, client, database_check = probe_app + if component == "metadata_database": + database_check.return_value = False + else: + app.state.mcp_runtime.ready = False + response = client.get("/ready") + assert response.status_code == 503 + assert response.json()["status"] == "not_ready" + assert response.json()["checks"][component] in {"unavailable", "not_ready"} + database_check.reset_mock() + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + database_check.assert_not_awaited() + + +@pytest.mark.parametrize("outcome", ["ready", "error", "timeout"]) +def test_metadata_probe_runs_bounded_select_and_sanitizes_errors(monkeypatch, outcome): + queries = [] + closed = [] + + async def execute(query): + queries.append(str(query)) + if outcome == "error": + raise RuntimeError("postgresql://private:secret@internal/database") + if outcome == "timeout": + await asyncio.Event().wait() + return SimpleNamespace(scalar_one=lambda: 1) + + @asynccontextmanager + async def connect(): + try: + yield SimpleNamespace(execute=execute) + finally: + closed.append(True) + + monkeypatch.setattr(health, "engine", SimpleNamespace(connect=connect)) + monkeypatch.setattr(health, "METADATA_TIMEOUT_SECONDS", 0.01) + assert asyncio.run(health.metadata_database_ready()) is (outcome == "ready") + assert queries == ["SELECT 1"] + assert closed == [True] + + +@pytest.mark.parametrize("startup_fails", [False, True]) +def test_application_lifecycle_marks_readiness_before_cleanup(monkeypatch, startup_fails): + from app import main + + app = FastAPI() + + @asynccontextmanager + async def mcp_lifespan(): + assert app.state.startup_complete is False + if startup_fails: + raise RuntimeError("MCP startup failed") + try: + yield + finally: + assert app.state.startup_complete is False + + monkeypatch.setattr(main, "mcp_runtime", SimpleNamespace(lifespan=mcp_lifespan)) + for owner, method in [ + (main.workbench_event_hubs, "close_all"), + (main.project_connection_manager, "close_all"), + (main, "close_metadata_engine"), + ]: + monkeypatch.setattr(owner, method, AsyncMock()) + monkeypatch.setattr(main, "close_all_timescale_pools", Mock()) + monkeypatch.setattr(main, "close_all_project_pools", Mock()) + + async def run(): + if startup_fails: + with pytest.raises(RuntimeError, match="MCP startup failed"): + async with main.lifespan(app): + pytest.fail("Failed startup must not yield") + else: + async with main.lifespan(app): + assert app.state.startup_complete is True + assert app.state.startup_complete is False + + asyncio.run(run()) + + +def test_mcp_runtime_tracks_its_actual_lifecycle(): + from app.mcp.application import McpQueryApplication + from app.mcp.config import McpSettings + + runtime = McpQueryApplication(McpSettings( + enabled=True, issuer_uri="http://localhost/realms/water", + resource_uri="http://localhost/mcp", _env_file=None, + )) + + async def run(): + assert runtime.ready is False + async with runtime.lifespan(): + assert runtime.ready is True + assert runtime.ready is False + + asyncio.run(run()) diff --git a/tests/api/test_history_query_permissions.py b/tests/api/test_history_query_permissions.py new file mode 100644 index 0000000..096f90f --- /dev/null +++ b/tests/api/test_history_query_permissions.py @@ -0,0 +1,34 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1.rest_router import api_router +from app.api.v1.endpoints.timeseries import composite +from app.auth.project_dependencies import ProjectContext, get_project_context +from app.domain.schemas.timeseries_history import ElementHistoryResponse + + +def test_viewer_can_query_history_without_scada_clean_permission(monkeypatch): + project_id = uuid4() + context = ProjectContext(project_id=project_id, project_code="test", user_id=uuid4(), project_role="viewer") + app = FastAPI() + app.include_router(api_router, prefix="/api/v1") + app.dependency_overrides[get_project_context] = lambda: context + app.dependency_overrides[composite.get_timescale_connection] = lambda: object() + app.dependency_overrides[composite.get_postgres_connection] = lambda: object() + query = AsyncMock(return_value=ElementHistoryResponse(series=[])) + monkeypatch.setattr(composite.TimeseriesHistoryService, "query", query) + with TestClient(app) as client: + headers = {"X-Project-Id": str(project_id)} + response = client.post("/api/v1/timeseries/views/element-history/query", headers=headers, json={ + "start_time": "2026-09-01T00:00:00Z", "end_time": "2026-09-01T01:00:00Z", + "elements": [{"element_type": "junction", "element_id": "J1"}], + }) + assert response.status_code == 200 + assert response.json() == {"series": []} + assert query.await_count == 1 + write_response = client.post("/api/v1/timeseries/scada-cleaning-runs", headers=headers, json={}) + assert write_response.status_code == 403 + assert write_response.json()["detail"]["permission"] == "scada.clean" diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py index eb8519e..3ef3482 100644 --- a/tests/api/test_openapi_contract.py +++ b/tests/api/test_openapi_contract.py @@ -71,13 +71,13 @@ def test_rest_router_has_unique_method_path_pairs() -> None: assert len(pairs) == len(set(pairs)) -def test_removed_redis_management_routes_are_not_published() -> None: +def test_retired_management_and_web_search_routes_are_not_published() -> None: published_paths = { route.path for route in api_router.routes if isinstance(route, APIRoute) } assert published_paths.isdisjoint( - {"/redis-keys/detail", "/redis-keys", "/all-redis", "/redis"} + {"/redis-keys/detail", "/redis-keys", "/all-redis", "/redis", "/web-searches"} ) diff --git a/tests/api/test_operational_task_dynamic_flow.py b/tests/api/test_operational_task_dynamic_flow.py index 4757bff..634c7a2 100644 --- a/tests/api/test_operational_task_dynamic_flow.py +++ b/tests/api/test_operational_task_dynamic_flow.py @@ -10,6 +10,7 @@ from app.api.v1.endpoints import operational_tasks as endpoints from app.auth.project_dependencies import ProjectContext from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot from app.domain.schemas.operational_task import EvidenceRecord +from app.domain.schemas.operational_task import TaskPresentationEventCommand from app.main import app @@ -31,10 +32,34 @@ def test_dynamic_investigation_routes_are_in_the_public_contract(): assert "post" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch"] assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports"] assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports/{revision}"] + assert "post" in paths["/api/v1/operational-tasks/{task_id}/presentation-events"] + assert "get" in paths["/api/v1/operational-tasks/{task_id}/presentation-cursor"] context_params = paths["/api/v1/operational-tasks/{task_id}/agent-context"]["get"]["parameters"] assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params) +@pytest.mark.anyio +async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch): + from app.domain.domain_errors import DomainError + + service = SimpleNamespace( + publish_presentation_event=AsyncMock(side_effect=DomainError("SESSION_MISMATCH", "stale session", status=409)), + presentation_cursor=AsyncMock(return_value={"task_id": str(uuid4()), "execution_generation": 1, + "active_run": None, "event_cursor": 17}), + ) + monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service) + with pytest.raises(HTTPException) as error: + await endpoints.publish_operational_task_presentation_event( + uuid4(), TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="stale", + command={"action": "clear_highlights"}), + ctx=_service_context(), conn=object(), + ) + assert error.value.status_code == 409 + assert error.value.detail["code"] == "SESSION_MISMATCH" + result = await endpoints.get_operational_task_presentation_cursor(uuid4(), ctx=_service_context(), conn=object()) + assert result["event_cursor"] == 17 and result["active_run"] is None + + @pytest.mark.anyio async def test_data_query_requires_task_grant_before_source_request(monkeypatch): service = SimpleNamespace(can_read_source=AsyncMock(return_value=False)) diff --git a/tests/api/test_place_search_endpoints.py b/tests/api/test_place_search_endpoints.py new file mode 100644 index 0000000..5dc8bc6 --- /dev/null +++ b/tests/api/test_place_search_endpoints.py @@ -0,0 +1,65 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +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.domain.schemas.place_search import PlaceSearchItem, PlaceSearchResponse +from app.services.place_search import PlaceSearchError + + +@pytest.fixture +def app(): + application = FastAPI() + application.include_router(api_router, prefix="/api/v1") + application.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "viewer") + 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)) + monkeypatch.setattr(place_search, "search_tianditu_places", service) + response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"}) + assert response.status_code == 200 + assert response.json()["items"][0]["name"] == "南桥路" + assert response.json()["region"] == "奉贤区" + assert set(response.json()) == {"items", "total", "truncated", "region"} + assert service.await_args.args[0].keyword == "南桥路" + + +@pytest.mark.parametrize("status", [502, 503, 504]) +def test_endpoint_preserves_provider_failure_status(app, monkeypatch, status): + monkeypatch.setattr(place_search, "search_tianditu_places", AsyncMock(side_effect=PlaceSearchError(status, "查询暂不可用"))) + response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"}) + assert response.status_code == status and response.json()["detail"] == "查询暂不可用" + + +@pytest.mark.parametrize("extra", [{"region": "北京市"}, {"specify": "156310104"}, {"offset": 0}, {"limit": 10}]) +def test_scope_and_result_limit_cannot_be_overridden(app, monkeypatch, extra): + service = AsyncMock() + monkeypatch.setattr(place_search, "search_tianditu_places", service) + response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路", **extra}) + assert response.status_code == 422 and not service.called + + +def test_search_denies_users_without_project_read_permission(app, monkeypatch): + app.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "none") + service = AsyncMock() + monkeypatch.setattr(place_search, "search_tianditu_places", service) + response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"}) + assert response.status_code == 403 and not service.called + + +def test_search_contract_has_typed_results_and_required_project_header(app): + contract = app.openapi() + operation = contract["paths"]["/api/v1/place-search-requests"]["post"] + assert any(param["name"] == "X-Project-Id" and param["required"] for param in operation["parameters"]) + 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 diff --git a/tests/api/test_simulation_endpoints.py b/tests/api/test_simulation_endpoints.py index 92e40bc..2befbf9 100644 --- a/tests/api/test_simulation_endpoints.py +++ b/tests/api/test_simulation_endpoints.py @@ -31,6 +31,7 @@ def _load_simulation_module(monkeypatch): { "get_time": lambda name: {"HYDRAULIC TIMESTEP": "0:15:00"}, "run_simulation": lambda **kwargs: None, + "run_online_simulation": lambda **kwargs: None, "query_corresponding_element_id_and_query_id": lambda name: SimpleNamespace( fixed_pumps={}, variable_pumps={} ), @@ -145,13 +146,13 @@ def test_pressure_regulation_requires_scheme_name(monkeypatch): assert response.status_code == 422 -def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch): +def test_run_simulation_manually_by_date_passes_aware_time_and_converts_minutes(monkeypatch): module = _load_simulation_module(monkeypatch) captured_calls = [] monkeypatch.setattr( module.simulation, - "run_simulation", + "run_online_simulation", lambda **kwargs: captured_calls.append(kwargs), ) @@ -161,13 +162,13 @@ def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch): 30, ) - assert [call["modify_pattern_start_time"] for call in captured_calls] == [ - "2025-01-01T19:04:05+00:00", - "2025-01-01T19:19:05+00:00", - ] + assert captured_calls == [{ + "name": "demo", "start_time": datetime(2025, 1, 1, 19, 4, 5, tzinfo=timezone.utc), + "duration_seconds": 1800, + }] -def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch): +def test_run_simulation_manually_by_date_preserves_two_hour_window(monkeypatch): module = _load_simulation_module(monkeypatch) captured_calls = [] @@ -178,7 +179,7 @@ def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch): ) monkeypatch.setattr( module.simulation, - "run_simulation", + "run_online_simulation", lambda **kwargs: captured_calls.append(kwargs), ) @@ -188,10 +189,8 @@ def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch): 120, ) - assert [call["modify_pattern_start_time"] for call in captured_calls] == [ - "2025-01-01T16:00:00+00:00", - "2025-01-01T17:00:00+00:00", - ] + assert captured_calls[0]["duration_seconds"] == 7200 + assert captured_calls[0]["start_time"] == datetime(2025, 1, 1, 16, 0, 0, tzinfo=timezone.utc) def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(monkeypatch): diff --git a/tests/auth/test_keycloak_dependencies.py b/tests/auth/test_keycloak_dependencies.py index 18ba63d..0bb6f1d 100644 --- a/tests/auth/test_keycloak_dependencies.py +++ b/tests/auth/test_keycloak_dependencies.py @@ -71,3 +71,21 @@ def test_decode_keycloak_token_accepts_a_recent_token( ) assert _decode_keycloak_token("recent") == {"iat": 1_000, "sub": "subject"} + + +@pytest.mark.parametrize("claims", [ + {"aud": "tjwater-mcp"}, {"aud": ["account", "tjwater-mcp"]}, + {"azp": "tjwater-agent-mcp"}, +]) +@pytest.mark.parametrize("host_client_id", ["agent-mcp", "tjwater-agent-mcp"]) +def test_product_rest_rejects_mcp_resource_tokens_even_without_api_audience(monkeypatch, claims, host_client_id): + monkeypatch.setattr(keycloak_dependencies.mcp_resource_settings, "audience", "tjwater-mcp") + monkeypatch.setattr(keycloak_dependencies.mcp_resource_settings, "host_client_id", host_client_id) + if "azp" in claims: + claims = {**claims, "azp": host_client_id} + monkeypatch.setattr(keycloak_dependencies.settings, "KEYCLOAK_PUBLIC_KEY", "public-key") + monkeypatch.setattr(keycloak_dependencies.settings, "KEYCLOAK_AUDIENCE", "") + monkeypatch.setattr(keycloak_dependencies.settings, "KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS", 0) + monkeypatch.setattr(keycloak_dependencies.jwt, "decode", lambda *args, **kwargs: claims) + with pytest.raises(keycloak_dependencies.JWTError): + _decode_keycloak_token("resource-token") diff --git a/tests/fixtures/mcp_query_app.py b/tests/fixtures/mcp_query_app.py new file mode 100644 index 0000000..e5983df --- /dev/null +++ b/tests/fixtures/mcp_query_app.py @@ -0,0 +1,101 @@ +"""Owned loopback HTTP fixture for cross-language SDK smoke tests. No database/auth service.""" + +import asyncio +import base64 +import json +import socket +import sys +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace +from uuid import UUID + +import uvicorn +from fastapi import FastAPI + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "unit")) +from test_mcp_query import Repository, Connection, Verifier, PERMISSIONS +from app.mcp import application, contexts, query_service +from app.mcp.application import McpQueryApplication +from app.mcp.config import McpSettings +from app.mcp.dispatch import McpDispatchMiddleware +from app.mcp.models import QueryResult + +application.McpQueryRepository = contexts.McpQueryRepository = Repository +contexts.permissions_for_context = query_service.permissions_for_context = ( + lambda project: PERMISSIONS +) + + +@asynccontextmanager +async def query_scope(identity, project): + yield SimpleNamespace( + project=SimpleNamespace(project_id=UUID(project)), connection=Connection() + ) + + +async def execute(scope, binding, tool, args): + return QueryResult( + source=tool.name, + source_ref="fixture:query", + queried_at=datetime.now(timezone.utc), + semantics=tool.semantics, + data=[{"id": "P-1"}], + ) + + +async def main(): + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + settings = McpSettings( + enabled=True, + issuer_uri="http://localhost/realms/test", + resource_uri=f"http://127.0.0.1:{port}/mcp", + _env_file=None, + ) + + class JwtFixtureVerifier(Verifier): + async def verify_token(self, token): + try: + payload = json.loads( + base64.urlsafe_b64decode(token.split(".")[1] + "==") + ) + value = self.identities.get(payload.get("fixture")) + if value is not None: + value.subject = payload["sub"] + value.claims = {"sub": value.subject} + return value + except (ValueError, IndexError, KeyError): + return None + + verifier = JwtFixtureVerifier() + for identity in verifier.identities.values(): + identity.resource = settings.resource_uri + runtime = McpQueryApplication( + settings, verifier=verifier, scope_factory=query_scope, executor=execute + ) + + @asynccontextmanager + async def lifespan(app): + async with runtime.lifespan(): + yield + + app = FastAPI(lifespan=lifespan) + app.state.mcp_runtime = runtime + app.include_router(runtime.router, prefix="/api/v1") + app.add_middleware(McpDispatchMiddleware, runtime=runtime) + server = uvicorn.Server(uvicorn.Config(app, log_level="error", access_log=False)) + serving = asyncio.create_task(server.serve(sockets=[sock])) + while not server.started: + if serving.done(): + await serving + raise RuntimeError("MCP fixture exited before listening") + await asyncio.sleep(0.01) + print(f"http://127.0.0.1:{port}/mcp", flush=True) + await serving + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/integration/test_mcp_query_limits_postgres.py b/tests/integration/test_mcp_query_limits_postgres.py new file mode 100644 index 0000000..f637ec4 --- /dev/null +++ b/tests/integration/test_mcp_query_limits_postgres.py @@ -0,0 +1,216 @@ +"""Opt-in SQL checks against a disposable PostgreSQL database; no business data.""" + +import asyncio +import os +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from psycopg import AsyncConnection +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.infra.db.postgresql import read_queries +from app.mcp.models import PeripheralInput +from app.mcp.peripheral import read_peripheral_query +from app.services.content_digest import digest + + +def test_history_and_evidence_pages_with_real_postgresql(): + dsn = os.environ.get("MCP_QUERY_TEST_DSN") + if not dsn: + pytest.skip("MCP_QUERY_TEST_DSN must point to a disposable PostgreSQL database") + + async def check(): + conn = await AsyncConnection.connect(dsn, row_factory=dict_row) + async with conn, conn.transaction(force_rollback=True): + await conn.execute("CREATE SCHEMA workbench") + await conn.execute("CREATE TABLE workbench.operational_task_evidence (project_id uuid, task_id uuid, evidence_id text, document jsonb)") + await conn.execute("CREATE SCHEMA scada") + await conn.execute("CREATE TABLE scada.measurements (device_id text, time timestamptz, monitored_value double precision, cleaned_value double precision)") + await conn.execute("CREATE SCHEMA analysis") + await conn.execute("CREATE TABLE analysis.node_results (run_id uuid, node_id text, time timestamptz, pressure double precision)") + await conn.execute("CREATE SCHEMA peripheral_sample") + await conn.execute("CREATE TABLE peripheral_sample.demo_datasets (project_id uuid, dataset_id text, document jsonb, digest text)") + project, task, run = uuid4(), uuid4(), uuid4() + start = datetime(2026, 10, 1, tzinfo=timezone.utc) + for i in range(2): + await conn.execute("INSERT INTO scada.measurements VALUES (%s,%s,%s,%s)", ("D1", start+timedelta(minutes=i), i, i+10)) + await conn.execute("INSERT INTO analysis.node_results VALUES (%s,%s,%s,%s)", (run, "J1", start+timedelta(minutes=i), i+20)) + await conn.execute("INSERT INTO analysis.node_results VALUES (%s,%s,%s,%s)", (uuid4(), "J1", start+timedelta(minutes=i), 999)) + descriptors = [ + {"source": "scada", "element_type": "junction", "id": "D1", "field": field} + for field in ("monitored_value", "cleaned_value") + ] + [{"source": "analysis", "element_type": "junction", "id": "J1", "field": "pressure"}] + seen = [] + for offset in (0, 2, 4, 6): + rows = await read_queries.history_rows(conn, descriptors, start=start, end=start+timedelta(hours=1), run_id=run, limit=2, offset=offset) + seen.extend(row["value"] for row in rows) + assert seen == [0, 1, 10, 11, 20, 21] + + snapshot = { + "source": "query_scada_readings", "source_ref": "saved:1", "queried_at": start.isoformat(), + "origin": "synthetic", "truncated": False, + "data": {"semantics": "observed", "data": { + "items": [{"value": 1}, {"value": 2, "points": [3, 4]}], + "text": "甲😀乙丙", "none": None, + }}, + } + await conn.execute("INSERT INTO workbench.operational_task_evidence VALUES (%s,%s,%s,%s)", (project, task, "E1", Jsonb({"data_snapshot": snapshot}))) + + async def evidence(path, **overrides): + values = dict(project_id=project, task_id=task, evidence_id="E1", data_path=path, limit=1, offset=0, mcp_sources=["query_scada_readings"]) + return await read_queries.evidence_page(conn, **(values | overrides)) + + page = await evidence(["items"], offset=1) + assert page["total"] == 2 and page["nested"] + assert page["snapshot_metadata"]["origin"] == "synthetic" + assert page["query_metadata"] == {"semantics": "observed"} + assert page["value"] == [{"value": 2, "points": {"collection_type": "array", "count": 2, "data_path": ["items", "1", "points"]}}] + assert (await evidence(["items", "1", "points"], offset=1))["value"] == [4] + assert (await evidence(["text"], offset=1, limit=2))["value"] == "😀乙" + assert (await evidence(["none"]))["value"] is None + assert (await evidence([], full_snapshot=True)) == snapshot + assert (await evidence([]))["value"]["items"]["data_path"] == ["items"] + for overrides, status in (({"project_id": uuid4()}, 404), ({"task_id": uuid4()}, 404), ({"data_path": ["absent"]}, 422)): + with pytest.raises(HTTPException) as caught: + await evidence([], **overrides) + assert caught.value.status_code == status + + legacy = {**snapshot, "source": "scada_readings", "data": {"items": [5, 6]}} + await conn.execute("INSERT INTO workbench.operational_task_evidence VALUES (%s,%s,%s,%s)", (project, task, "legacy", Jsonb({"data_snapshot": legacy}))) + old_page = await evidence(["items"], evidence_id="legacy", offset=1) + assert old_page["value"] == [6] and old_page["query_metadata"] is None + + # A large nested collection is represented by a path, not transferred in full. + snapshot["data"]["data"]["huge"] = ["x" * (read_queries.MAX_QUERY_BYTES + 1)] + await conn.execute("UPDATE workbench.operational_task_evidence SET document=%s", (Jsonb({"data_snapshot": snapshot}),)) + assert (await evidence(["huge"], offset=1))["value"] == [] + assert (await evidence([], limit=1))["value"]["huge"]["count"] == 1 + descriptor = (await evidence(["huge"]))["value"][0] + assert descriptor["collection_type"] == "string" and descriptor["data_path"] == ["huge", "0"] + assert (await evidence(["huge", "0"], offset=7, limit=3))["value"] == "xxx" + with pytest.raises(HTTPException) as caught: + await evidence([], full_snapshot=True) + assert caught.value.status_code == 413 + + payload = {"project_id": str(project), "points": [{"point_id": "DEMO-1"}], "observations": [ + {"point_id": "DEMO-1", "observed_at": (start+timedelta(minutes=i)).isoformat(), "value": i} for i in range(3) + ]} + await conn.execute("INSERT INTO peripheral_sample.demo_datasets VALUES (%s,'water-demo-v1',%s,%s)", (project, Jsonb(payload), digest(payload))) + args = PeripheralInput(context_handle="a"*43, source="peripheral_observations", point_id="DEMO-1", start=start, end=start+timedelta(hours=1), limit=1, offset=1) + scope = SimpleNamespace(connection=conn, project=SimpleNamespace(project_id=project)) + demo = await read_peripheral_query(scope, args) + assert demo.origin == "synthetic" and demo.truncated and demo.data["items"][0]["value"] == 1 + await conn.execute("UPDATE peripheral_sample.demo_datasets SET digest='corrupted'") + with pytest.raises(HTTPException) as caught: + await read_peripheral_query(scope, args) + assert caught.value.status_code == 503 + payload["padding"] = "x" * (read_queries.MAX_QUERY_BYTES + 1) + await conn.execute("UPDATE peripheral_sample.demo_datasets SET document=%s", (Jsonb(payload),)) + with pytest.raises(HTTPException) as caught: + await read_peripheral_query(scope, args) + assert caught.value.status_code == 413 + + asyncio.run(check()) + + +def test_task_context_summary_and_sections_with_real_postgresql(): + dsn = os.environ.get("MCP_QUERY_TEST_DSN") + if not dsn: + pytest.skip("MCP_QUERY_TEST_DSN must point to a disposable PostgreSQL database") + + async def check(): + conn = await AsyncConnection.connect(dsn, row_factory=dict_row) + async with conn, conn.transaction(force_rollback=True): + await conn.execute("CREATE SCHEMA workbench") + await conn.execute("""CREATE TABLE workbench.operational_tasks ( + project_id uuid,task_id uuid,task_type text,status text,lifecycle_status text,priority text, + revision bigint,execution_generation bigint,current_report_revision bigint,document jsonb)""") + for name, key in (("reports", "revision bigint"), ("plans", "revision bigint"), + ("evidence", "evidence_id text"), ("actions", "action_id uuid")): + await conn.execute(f"CREATE TABLE workbench.operational_task_{name} (project_id uuid,task_id uuid,{key},document jsonb)") + await conn.execute("CREATE TABLE workbench.operational_task_runs (project_id uuid,task_id uuid,run_id uuid,generation bigint,status text)") + project, task, run = uuid4(), uuid4(), uuid4() + long_text = "甲😀" * 10000 + await conn.execute("INSERT INTO workbench.operational_tasks VALUES (%s,%s,'event_investigation','running','active','normal',1,2,4,%s)", + (project, task, Jsonb({"title": long_text, "source": {"description": long_text}}))) + for revision in (3, 4): + await conn.execute("INSERT INTO workbench.operational_task_reports VALUES (%s,%s,%s,%s)", + (project, task, revision, Jsonb({"revision": revision, "report": {"stage": "investigation", "outcome": "pending", "current_conclusion": long_text}}))) + await conn.execute("INSERT INTO workbench.operational_task_plans VALUES (%s,%s,1,%s)", + (project, task, Jsonb({"nodes": [{"id": "N1"}]}))) + for evidence_id in ("E1", "E2", "E3"): + await conn.execute("INSERT INTO workbench.operational_task_evidence VALUES (%s,%s,%s,%s)", + (project, task, evidence_id, Jsonb({"data_snapshot": {"text": long_text}, "none": None}))) + action_id = uuid4() + await conn.execute("INSERT INTO workbench.operational_task_actions VALUES (%s,%s,%s,%s)", + (project, task, action_id, Jsonb({"reason": long_text}))) + await conn.execute("INSERT INTO workbench.operational_task_runs VALUES (%s,%s,%s,2,'running')", (project, task, run)) + + summary = await read_queries.task_context_summary(conn, project_id=project, task_id=task, report_revision=3) + assert summary["selected_report"]["revision"] == 3 and summary["latest_report"]["revision"] == 4 + assert summary["task"]["preview_only"] and len(summary["task"]["title_preview"]) == 160 + assert len(summary["selected_report"]["current_conclusion_preview"]) == 200 + assert "source" not in summary["task"] + for overrides in ({"report_revision": 99}, {"project_id": uuid4()}, {"task_id": uuid4()}): + with pytest.raises(HTTPException) as caught: + await read_queries.task_context_summary(conn, **(dict(project_id=project, task_id=task, report_revision=3) | overrides)) + assert caught.value.status_code == 404 + + async def page(section, path=None, **overrides): + values = dict(project_id=project, task_id=task, section=section, report_revision=None, data_path=path or [], limit=1, offset=0) + return await read_queries.task_context_page(conn, **(values | overrides)) + + second = await page("evidence", offset=1) + assert second["snapshot_metadata"]["total"] == 3 + assert list(second["value"]) == ["E2"] + assert second["value"]["E2"]["data_path"] == ["E2"] + nested = await page("evidence", ["E2"], limit=20) + assert nested["value"]["data_snapshot"]["data_path"] == ["E2", "data_snapshot"] + assert nested["value"]["none"] is None + text = await page("evidence", ["E2", "data_snapshot", "text"], offset=1, limit=2) + assert text["value"] == "😀甲" and text["total"] == len(long_text) + assert (await page("evidence", offset=3))["value"] == {} + assert (await page("evidence", project_id=uuid4()))["value"] == {} + with pytest.raises(HTTPException) as caught: + await page("evidence", ["E2"], task_id=uuid4()) + assert caught.value.status_code == 404 + + report = await page("report", ["report", "current_conclusion"], report_revision=3, offset=2, limit=2) + assert report["value"] == "甲😀" + assert (await page("task"))["nested"] + assert (await page("plan"))["value"]["nodes"]["collection_type"] == "array" + assert (await page("actions"))["value"][str(action_id)]["data_path"] == [str(action_id)] + assert "project_id" not in (await page("run", limit=20))["value"] + + for section in ("progress", "access_requests"): + assert (await page(section))["snapshot_metadata"]["available"] is False + assert await read_queries.task_data_scopes(conn, project_id=project, task_id=task, permissions={"scada.view"}) == ["scada_devices", "scada_readings"] + await conn.execute("CREATE TABLE workbench.operational_task_progress (project_id uuid,task_id uuid,run_id uuid,document jsonb)") + await conn.execute("INSERT INTO workbench.operational_task_progress VALUES (%s,%s,%s,%s)", (project, task, run, Jsonb({"content": long_text}))) + assert (await page("progress"))["value"]["content"]["collection_type"] == "string" + await conn.execute("CREATE TABLE workbench.operational_task_access_requests (project_id uuid,task_id uuid,access_request_id uuid,status text,document jsonb)") + for grant_project, grant_task, status, scopes in ( + (project, task, "approved", ["peripheral_points", "unknown_source"]), + (project, task, "pending", ["scada_readings"]), + (uuid4(), task, "approved", ["peripheral_observations"]), + (project, uuid4(), "approved", ["peripheral_test_data"]), + ): + await conn.execute("INSERT INTO workbench.operational_task_access_requests VALUES (%s,%s,%s,%s,%s)", + (grant_project, grant_task, uuid4(), status, Jsonb({"approved_scopes": scopes}))) + assert await read_queries.task_data_scopes(conn, project_id=project, task_id=task, permissions=set()) == ["peripheral_points"] + assert (await page("access_requests", limit=20))["snapshot_metadata"]["total"] == 2 + + # A very wide document must fail before it crosses the DB boundary; + # a smaller page remains readable. + wide = {str(i): "x" * 250 for i in range(200)} + await conn.execute("UPDATE workbench.operational_tasks SET document=%s", (Jsonb(wide),)) + with pytest.raises(HTTPException) as caught: + await page("task", limit=200) + assert caught.value.status_code == 413 + assert len((await page("task", limit=1))["value"]) == 1 + + asyncio.run(check()) diff --git a/tests/integration/test_mcp_spatial_postgis.py b/tests/integration/test_mcp_spatial_postgis.py new file mode 100644 index 0000000..a9b1468 --- /dev/null +++ b/tests/integration/test_mcp_spatial_postgis.py @@ -0,0 +1,58 @@ +"""Opt-in PostGIS checks using transaction-local temporary model tables only.""" + +import asyncio +import os + +import pytest +from psycopg import AsyncConnection, sql +from psycopg.rows import dict_row + +from app.infra.db.postgresql import spatial_queries + + +def test_nearby_model_queries_with_real_postgis(monkeypatch): + dsn = os.environ.get("MCP_SPATIAL_TEST_DSN") + if not dsn: + pytest.skip("MCP_SPATIAL_TEST_DSN must point to a PostGIS database; only TEMP tables are written") + monkeypatch.setitem(spatial_queries._MODEL_RELATIONS, "pipe", sql.Identifier("pg_temp", "mcp_model_pipes")) + monkeypatch.setitem(spatial_queries._MODEL_RELATIONS, "junction", sql.Identifier("pg_temp", "mcp_model_junctions")) + + async def check(): + conn = await AsyncConnection.connect(dsn, row_factory=dict_row) + async with conn, conn.transaction(force_rollback=True): + await conn.execute("CREATE TEMP TABLE mcp_model_pipes (id text PRIMARY KEY, diameter float, geom geometry(LineString,3857)) ON COMMIT DROP") + await conn.execute("CREATE TEMP TABLE mcp_model_junctions (id text PRIMARY KEY, geom geometry(Point,3857)) ON COMMIT DROP") + await conn.execute("CREATE INDEX ON mcp_model_pipes USING gist (geom)") + # The crossing pipe has distant endpoints, while its middle is at the center. + for pipe_id, wkt in [ + ("crossing", "LINESTRING(121.49 30.9,121.51 30.9)"), + ("near-a", "LINESTRING(121.4999 30.9005,121.5001 30.9005)"), + ("near-b", "LINESTRING(121.4999 30.9005,121.5001 30.9005)"), + ("outside", "LINESTRING(121.4999 30.91,121.5001 30.91)"), + ]: + await conn.execute("INSERT INTO mcp_model_pipes VALUES (%s,300,ST_Transform(ST_GeomFromText(%s,4326),3857))", (pipe_id, wkt)) + await conn.execute("INSERT INTO mcp_model_pipes VALUES ('missing',300,NULL),('empty',300,ST_GeomFromText('LINESTRING EMPTY',3857))") + await conn.execute("INSERT INTO mcp_model_junctions VALUES ('junction',ST_Transform(ST_SetSRID(ST_MakePoint(121.5,30.9002),4326),3857))") + + async def nearby(kind="pipe", **overrides): + values = dict(center=(121.5, 30.9), radius_m=100, limit=10, offset=0) + return await spatial_queries.nearby_elements(conn, kind, **(values | overrides)) + + rows = await nearby() + assert [row["element_id"] for row in rows] == ["crossing", "near-a", "near-b"] + assert rows[0]["distance_m"] < 1 + assert 54 < rows[1]["distance_m"] < 57 + assert rows[1]["properties"]["diameter"] == 300 and "geom" not in rows[1]["properties"] + # 55 m geodesic is ~65 m in Web Mercator here: a 60 m radius must include it. + assert len(await nearby(radius_m=60)) == 3 + assert len(await nearby(radius_m=50)) == 1 + assert [row["element_id"] for row in await nearby(limit=1, offset=2)] == ["near-b"] + assert await nearby(center=(0, 0)) == [] + assert [row["element_id"] for row in await nearby("junction")] == ["junction"] + assert await nearby(center=(0, 90)) == [] + + # A point west of the dateline must find a model east of it. + await conn.execute("INSERT INTO mcp_model_pipes VALUES ('dateline',300,ST_Transform(ST_GeomFromText('LINESTRING(179.9999 30,179.9999 30.0001)',4326),3857))") + assert [row["element_id"] for row in await nearby(center=(-179.9999, 30), radius_m=100)] == ["dateline"] + + asyncio.run(check()) diff --git a/tests/integration/test_operational_tasks_storage.py b/tests/integration/test_operational_tasks_storage.py index 25c19b4..640facd 100644 --- a/tests/integration/test_operational_tasks_storage.py +++ b/tests/integration/test_operational_tasks_storage.py @@ -66,6 +66,8 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back( ).fetchall() assert len(comment_rows) >= 17 assert all(row["description"] for row in comment_rows) + messages_migration = (Path(__file__).parents[2] / "resources/sql/024_agent_messages.sql").read_text() + await conn.execute(messages_migration.replace("\nBEGIN;", "").replace("\nCOMMIT;", "")) repository = OperationalTaskRepository(conn, context.project_id) service = OperationalTaskService(repository, context, permissions_for_context(context)) now = datetime.now(timezone.utc) diff --git a/tests/integration/test_realtime_compression.py b/tests/integration/test_realtime_compression.py new file mode 100644 index 0000000..ca3aff6 --- /dev/null +++ b/tests/integration/test_realtime_compression.py @@ -0,0 +1,101 @@ +"""Opt-in TimescaleDB regression; only accepts an independently disposable DB.""" + +import asyncio +import os + +import psycopg +import pytest +from psycopg.conninfo import conninfo_to_dict + +from app.core.config import settings +from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository + + +DSN = os.environ.get("REALTIME_COMPRESSION_TEST_DSN", "") +pytestmark = pytest.mark.skipif(not DSN, reason="Needs an isolated TimescaleDB test database") +START = "2026-10-08T04:00:00Z" + + +@pytest.fixture +def compressed_snapshot(): + assert conninfo_to_dict(DSN).get("dbname", "").startswith("tjw_test_realtime_"), "Refusing a non-test database" + with psycopg.connect(DSN, autocommit=True) as conn: + conn.execute("CREATE SCHEMA realtime") + conn.execute("""CREATE TABLE realtime.node_results ( + time timestamptz NOT NULL, node_id text NOT NULL, + actual_demand double precision, total_head double precision, + pressure double precision, quality double precision, + PRIMARY KEY(time,node_id))""") + conn.execute("""CREATE TABLE realtime.link_results ( + time timestamptz NOT NULL, link_id text NOT NULL, flow double precision, + friction double precision, headloss double precision, quality double precision, + reaction double precision, setting double precision, status double precision, + velocity double precision, PRIMARY KEY(time,link_id))""") + for table, element, value in [("node_results", "node_id", "pressure"), ("link_results", "link_id", "flow")]: + conn.execute("SELECT create_hypertable(%s,'time',chunk_time_interval=>interval '1 day')", (f"realtime.{table}",)) + conn.execute(f"""INSERT INTO realtime.{table}(time,{element},{value}) + SELECT %s::timestamptz + period * interval '15 minutes', id::text, 0 + FROM generate_series(0,3) period CROSS JOIN generate_series(1,100) id""", (START,)) + conn.execute(f"ALTER TABLE realtime.{table} SET (timescaledb.compress, timescaledb.compress_orderby='{element},time')") + conn.execute("SELECT compress_chunk(show_chunks(%s))", (f"realtime.{table}",)) + # Scale down the production 100000-row guard: each compressed table has + # 400 rows, and deleting one snapshot needs batch decompression. + conn.execute("SET timescaledb.max_tuples_decompressed_per_dml_transaction=100") + try: + yield conn + finally: + conn.execute("DROP SCHEMA realtime CASCADE") + + +def snapshot(): + return ( + [{"node": str(i), "result": [{"pressure": 999}]} for i in range(1,101)], + [{"link": str(i), "result": [{"flow": 888}]} for i in range(1,101)], + ) + + +def assert_snapshot(conn, expected_pressure, expected_flow): + for table, value, expected in [("node_results", "pressure", expected_pressure), ("link_results", "flow", expected_flow)]: + assert conn.execute(f"SELECT count(*),min({value}),max({value}) FROM realtime.{table} WHERE time=%s", (START,)).fetchone() == (100,expected,expected) + assert conn.execute(f"SELECT count(*) FROM realtime.{table}").fetchone() == (400,) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_compressed_snapshot_replacement_and_transaction_local_budget(monkeypatch, compressed_snapshot, asynchronous): + conn = compressed_snapshot + nodes, links = snapshot() + monkeypatch.setattr(settings, "TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES", 1000) + # Execute the old DELETE in the actual compressed database, then roll it + # back. This proves the guard really fires rather than trusting a SQL mock. + with pytest.raises(psycopg.Error, match="tuple decompression limit exceeded"): + with conn.transaction(): + conn.execute("DELETE FROM realtime.node_results WHERE time=%s", (START,)) + assert_snapshot(conn, 0, 0) + + if asynchronous: + async def run(): + async with await psycopg.AsyncConnection.connect(DSN, autocommit=True) as async_conn: + await async_conn.execute("SET timescaledb.max_tuples_decompressed_per_dml_transaction=100") + await RealtimeRepository.store_realtime_simulation_result(async_conn, nodes, links, START) + cursor = await async_conn.execute("SHOW timescaledb.max_tuples_decompressed_per_dml_transaction") + assert await cursor.fetchone() == ("100",) + asyncio.run(run()) + else: + RealtimeRepository.store_realtime_simulation_result_sync(conn, nodes, links, START) + assert_snapshot(conn, 999, 888) + assert conn.execute("SHOW timescaledb.max_tuples_decompressed_per_dml_transaction").fetchone() == ("100",) + + +def test_copy_failure_rolls_back_both_tables_and_local_budget(monkeypatch, compressed_snapshot): + conn = compressed_snapshot + monkeypatch.setattr(settings, "TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES", 1000) + + def fail_copy(*_): + raise RuntimeError("fixture link copy failure") + + monkeypatch.setattr(RealtimeRepository, "_copy_links_sync", fail_copy) + nodes, links = snapshot() + with pytest.raises(RuntimeError, match="fixture link copy failure"): + RealtimeRepository.store_realtime_simulation_result_sync(conn, nodes, links, START) + assert_snapshot(conn, 0, 0) + assert conn.execute("SHOW timescaledb.max_tuples_decompressed_per_dml_transaction").fetchone() == ("100",) diff --git a/tests/unit/test_agent_messages.py b/tests/unit/test_agent_messages.py new file mode 100644 index 0000000..c4a50f1 --- /dev/null +++ b/tests/unit/test_agent_messages.py @@ -0,0 +1,240 @@ +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.auth.project_dependencies import ProjectContext +from app.domain.domain_errors import DomainError +from app.domain.schemas.agent_messages import AgentMessageCreate, AgentMessageDelivery, AgentMessageReply +from app.domain.schemas.operational_task import AgentRunCommand +from app.services.agent_messages import AgentMessageService +from app.services.operational_tasks import OperationalTaskService + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +NOW = datetime(2026, 10, 13, 0, tzinfo=timezone.utc) + + +class Repository: + def __init__(self): + self.project_id, self.task_id, self.run_id = uuid4(), uuid4(), uuid4() + self.run = {"run_id": str(self.run_id), "task_id": str(self.task_id), + "status": "running", "agent_session_id": "task-session", + "lease_expires_at": NOW + timedelta(minutes=1), "revision": 2, + "generation": 1, "execution_request_id": str(uuid4()), + "next_dispatch_at": NOW, "created_at": NOW, "updated_at": NOW} + self.messages = {} + + @asynccontextmanager + async def transaction(self): + yield self + + async def lock(self, key): + pass + + async def get_run(self, run_id): + return self.run if run_id == str(self.run_id) else None + + async def get_task(self, task_id): + return {"task_type": "event_investigation"} if task_id == str(self.task_id) else None + + async def get_message(self, message_id): + return self.messages.get(message_id) + + async def get_message_request(self, owner_id, session_id, request_id): + return next((row for row in self.messages.values() + if row["source_owner_id"] == owner_id and row["source_session_id"] == session_id + and row["request_id"] == request_id), None) + + async def insert_message(self, message): + self.messages[message["message_id"]] = message + + async def update_message_delivery(self, message_id, update): + self.messages[message_id].update(update) + + +def setup(): + repo = Repository() + actor = ProjectContext(repo.project_id, "test", uuid4(), "member") + worker = ProjectContext(repo.project_id, "test", uuid4(), "member", system_role="service") + service = AgentMessageService(repo, actor, clock=lambda: NOW) + trusted = AgentMessageService(repo, worker, clock=lambda: NOW) + command = AgentMessageCreate(request_id=uuid4(), task_id=repo.task_id, run_id=repo.run_id, + source_session_id="main-session", text="核对当前压力") + return repo, actor, service, trusted, command + + +@pytest.mark.anyio +async def test_messages_are_bound_to_exact_run_and_idempotent_even_after_finish(): + repo, actor, service, trusted, command = setup() + sent = await service.create(command) + assert sent.source_owner_id == actor.user_id == sent.conversation_owner_id + assert sent.native_message_id == f"msg_{sent.message_id}" + repo.run["status"] = "succeeded" + assert (await service.create(command)).message_id == sent.message_id + with pytest.raises(DomainError, match="request_id") as error: + await service.create(command.model_copy(update={"text": "different"})) + assert error.value.code == "IDEMPOTENCY_CONFLICT" + with pytest.raises(DomainError) as error: + await service.create(command.model_copy(update={"request_id": uuid4()})) + assert error.value.code == "RUN_TERMINAL" + assert len(repo.messages) == 1 + + +@pytest.mark.anyio +async def test_wrong_task_or_expired_lease_cannot_accept_message(): + repo, actor, service, trusted, command = setup() + with pytest.raises(DomainError) as error: + await service.create(command.model_copy(update={"task_id": uuid4()})) + assert error.value.code == "RUN_NOT_FOUND" + repo.run["lease_expires_at"] = NOW + with pytest.raises(DomainError) as error: + await service.create(command) + assert error.value.code == "LEASE_EXPIRED" + repo.run.update(status="queued", agent_session_id=None) + assert (await service.create(command)).target_session_id is None + + +@pytest.mark.anyio +async def test_reply_has_original_conversation_owner_and_immutable_channel(): + repo, actor, service, trusted, command = setup() + incoming = await service.create(command) + reply_command = AgentMessageReply(request_id=uuid4(), source_session_id="task-session", text="压力正常") + with pytest.raises(DomainError) as error: + await service.reply(incoming.message_id, reply_command) + assert error.value.code == "SERVICE_REQUIRED" + with pytest.raises(DomainError) as error: + await trusted.reply(incoming.message_id, reply_command.model_copy(update={"source_session_id": "other"})) + assert error.value.code == "SESSION_MISMATCH" + reply = await trusted.reply(incoming.message_id, reply_command) + assert reply.source_owner_id == trusted.context.user_id + assert reply.conversation_owner_id == actor.user_id + assert reply.target_session_id == command.source_session_id + followup = await service.reply(reply.message_id, AgentMessageReply( + request_id=uuid4(), source_session_id="main-session", text="请继续核对")) + assert followup.direction == "to_task" and followup.in_reply_to == reply.message_id + assert followup.target_session_id == "task-session" + with pytest.raises(DomainError) as error: + await service.reply(reply.message_id, AgentMessageReply( + request_id=uuid4(), source_session_id="wrong-main", text="伪造来源")) + assert error.value.code == "SESSION_MISMATCH" + repo.run["status"] = "succeeded" + assert (await trusted.reply(incoming.message_id, reply_command)).message_id == reply.message_id + with pytest.raises(DomainError) as error: + await service.delivery(reply.message_id, AgentMessageDelivery(status="delivered", target_session_id="other")) + assert error.value.code == "SESSION_MISMATCH" + received = await service.delivery(reply.message_id, AgentMessageDelivery(status="enqueued")) + assert received.status == "enqueued" and received.delivered_at is None + assert (await service.delivery(reply.message_id, AgentMessageDelivery(status="delivered"))).delivered_at == NOW + + +@pytest.mark.anyio +async def test_inbound_delivery_requires_service_run_session_and_fixed_native_identity(): + repo, actor, service, trusted, command = setup() + incoming = await service.create(command) + with pytest.raises(DomainError) as error: + await service.delivery(incoming.message_id, AgentMessageDelivery(status="delivered")) + assert error.value.code == "SERVICE_REQUIRED" + with pytest.raises(DomainError) as error: + await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="enqueued", target_session_id="wrong")) + assert error.value.code == "SESSION_MISMATCH" + with pytest.raises(DomainError) as error: + await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="delivered", target_session_id="task-session", native_message_id="changed")) + assert error.value.code == "MESSAGE_ID_MISMATCH" + await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="enqueued", target_session_id="task-session")) + assert (await service.get(incoming.message_id)).status == "enqueued" + await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="delivered", target_session_id="task-session")) + with pytest.raises(DomainError) as error: + await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="failed")) + assert error.value.code == "MESSAGE_TERMINAL" + + +@pytest.mark.anyio +async def test_other_project_member_cannot_read_private_message_or_pending_dispatch(): + repo, actor, service, trusted, command = setup() + incoming = await service.create(command) + outsider = AgentMessageService(repo, ProjectContext(repo.project_id, "test", uuid4(), "member")) + with pytest.raises(DomainError) as error: + await outsider.get(incoming.message_id) + assert error.value.code == "MESSAGE_NOT_FOUND" + with pytest.raises(DomainError) as error: + await outsider.pending(20) + assert error.value.code == "SERVICE_REQUIRED" + viewer_service = AgentMessageService(repo, ProjectContext( + repo.project_id, "test", uuid4(), "viewer", system_role="service")) + with pytest.raises(DomainError) as error: + await viewer_service.get(incoming.message_id) + assert error.value.code == "SERVICE_REQUIRED" + + +@pytest.mark.anyio +async def test_own_session_listing_uses_owner_and_keyset_cursor(): + repo, actor, service, trusted, command = setup() + sent = await service.create(command) + other = await service.create(command.model_copy(update={"request_id": uuid4(), "text": "后续问题"})) + repo.list_session_messages = AsyncMock(return_value=[sent.model_dump(), other.model_dump()]) + page = await service.list_session("main-session", 1, None, pending_only=True) + assert len(page.items) == 1 and page.next_cursor + repo.list_session_messages.assert_awaited_once_with( + str(actor.user_id), "main-session", 2, + cursor_at=None, cursor_id=None, pending_only=True) + repo.list_session_messages.reset_mock() + repo.list_session_messages.return_value = [] + await service.list_session("main-session", 1, page.next_cursor) + assert repo.list_session_messages.await_args.kwargs["cursor_id"] == sent.message_id + with pytest.raises(DomainError) as error: + await service.list_session("main-session", 20, "invalid") + assert error.value.code == "INVALID_CURSOR" + + +@pytest.mark.anyio +async def test_pending_dispatch_page_returns_forward_keyset_cursor(): + repo, actor, service, trusted, command = setup() + first = await service.create(command) + second = await service.create(command.model_copy(update={"request_id": uuid4()})) + repo.list_pending_messages = AsyncMock(return_value=[first.model_dump(), second.model_dump()]) + page = await trusted.pending(1) + assert page.items[0].message_id == first.message_id and page.next_cursor + await trusted.pending(1, page.next_cursor) + assert repo.list_pending_messages.await_args.kwargs == { + "cursor_at": first.created_at, "cursor_id": first.message_id} + + +@pytest.mark.anyio +async def test_requeue_locks_and_rechecks_run_before_cancelling_it(): + repo, actor, service, trusted, command = setup() + run = repo.run.copy() + replacement = {**run, "run_id": str(uuid4())} + repo.get_active_run = AsyncMock(side_effect=[run, replacement, replacement]) + repo.lock = AsyncMock() + repo.update_run, repo.insert_run, repo.fail_run_messages = AsyncMock(), AsyncMock(), AsyncMock() + task_service = OperationalTaskService(repo, actor, [], clock=lambda: NOW) + task_service._run_event = AsyncMock() + task = {"task_id": str(repo.task_id), "execution_generation": 1, "revision": 2} + await task_service._requeue_task(task, NOW) + assert [call.args[0] for call in repo.lock.await_args_list] == [ + f"run:{run['run_id']}", f"run:{replacement['run_id']}"] + assert repo.update_run.await_args.args[0]["run_id"] == replacement["run_id"] + repo.fail_run_messages.assert_awaited_once_with(replacement["run_id"], "Run superseded by requeue", NOW) + + +@pytest.mark.anyio +async def test_finish_refuses_messages_enqueued_but_not_entered_in_model_context(): + repo, actor, service, trusted, command = setup() + repo.get_request = AsyncMock(return_value=None) + repo.pending_run_messages = AsyncMock(return_value=["pending-message"]) + repo.get_latest_report = AsyncMock() + task_service = OperationalTaskService(repo, actor, [], clock=lambda: NOW) + task_service._task = AsyncMock(return_value={"task_type": "event_investigation"}) + with pytest.raises(DomainError) as error: + await task_service.complete_run(repo.task_id, repo.run_id, AgentRunCommand( + request_id=uuid4(), expected_revision=2, source_session_id="task-session")) + assert error.value.code == "PENDING_AGENT_MESSAGES" and error.value.status == 409 + assert error.value.refs == ["pending-message"] + repo.get_latest_report.assert_not_awaited() diff --git a/tests/unit/test_analysis_simulation.py b/tests/unit/test_analysis_simulation.py index c15cfaa..0b5ae01 100644 --- a/tests/unit/test_analysis_simulation.py +++ b/tests/unit/test_analysis_simulation.py @@ -38,10 +38,10 @@ def test_valve_close_analysis_uses_normalized_scheme_type(monkeypatch): monkeypatch.setattr( simulation_scenarios.simulation, "run_simulation", - lambda **kwargs: captured.update(kwargs), + lambda **kwargs: (captured.update(kwargs), {"node_results": []})[1], ) - simulation_scenarios.valve_close_analysis.__wrapped__( + saved_result = simulation_scenarios.valve_close_analysis.__wrapped__( name="demo", modify_pattern_start_time="2026-01-01T00:00:00+08:00", modify_valve_opening={"V1": 0.0}, @@ -51,6 +51,13 @@ def test_valve_close_analysis_uses_normalized_scheme_type(monkeypatch): assert captured["scheme_type"] == "valve_close_analysis" assert captured["result_db_name"] == "demo" + assert saved_result is None # Preserve the existing REST "success" response. + preview = simulation_scenarios.valve_close_analysis.__wrapped__( + name="demo", modify_pattern_start_time="2026-01-01T00:00:00+08:00", + _temporary_project="tjw_tmp_demo", persist_results=False, + ) + assert preview == {"node_results": []} + assert captured["persist_results"] is False def test_apply_valve_control_matches_runner_semantics(monkeypatch): @@ -208,6 +215,32 @@ def test_extended_simulation_stores_results_by_run_id(monkeypatch): assert transaction_calls == [("begin", "demo"), ("end", "demo")] refresh_mock.assert_called_once_with("demo") + preview = simulation.run_simulation( + name="tjw_tmp_preview_test", simulation_type="extended", + modify_pattern_start_time="2026-07-16T00:00:00+08:00", + modify_total_duration=900, persist_results=False, + scada_mappings=_empty_scada_mappings(simulation), + ) + assert preview["node_results"][0]["node"] == "J1" + assert len(storage_calls) == len(create_calls) == 1 + assert len(lifecycle_calls) == 1 + with pytest.raises(ValueError, match="isolated temporary model"): + simulation.run_simulation(name="demo", simulation_type="extended", + modify_pattern_start_time="2026-07-16T00:00:00+08:00", persist_results=False) + + realtime_storage = Mock() + monkeypatch.setattr(simulation.TimescaleInternalStorage, "store_realtime_simulation", realtime_storage) + realtime = simulation.run_simulation( + name="demo", simulation_type="realtime", + modify_pattern_start_time="2026-07-16T00:00:00+08:00", + scada_mappings=_empty_scada_mappings(simulation), + ) + realtime_storage.assert_called_once_with( + realtime["node_results"], realtime["link_results"], + "2026-07-16T00:00:00+08:00", db_name="demo", + ) + assert len(storage_calls) == len(create_calls) == len(lifecycle_calls) == 1 + def test_extended_simulation_marks_run_failed_when_result_storage_fails(monkeypatch): from app.services import simulation diff --git a/tests/unit/test_epanet_status_export.py b/tests/unit/test_epanet_status_export.py new file mode 100644 index 0000000..af017eb --- /dev/null +++ b/tests/unit/test_epanet_status_export.py @@ -0,0 +1,70 @@ +import ctypes +import platform +from pathlib import Path + +import pytest + +from app.native.wndb.model import status + + +def test_epanet2_preserves_valve_setting_without_emitting_active(monkeypatch): + monkeypatch.setattr(status, "read_all", lambda *_: [ + {"link": "V1", "status": "ACTIVE", "setting": None}, + {"link": "V2", "status": "ACTIVE", "setting": 2.5}, + {"link": "V3", "status": "CLOSED", "setting": None}, + {"link": "P1", "status": "OPEN", "setting": 0}, + ]) + assert status.inp_out_status("model") == ["V2 2.5", "V3 CLOSED", "P1 OPEN", "P1 0"] + + +def test_epanet3_retains_explicit_active_status(monkeypatch): + monkeypatch.setattr(status, "read_all", lambda *_: [ + {"link": "V1", "status": "ACTIVE", "setting": 100}, + ]) + assert status.inp_out_status("model", version="3") == ["V1 ACTIVE", "V1 100"] + + +@pytest.mark.parametrize("version", ["2", "3"]) +def test_file_and_text_exporters_forward_the_selected_format(monkeypatch, tmp_path, version): + from app.native.wndb.inp import exporter + + monkeypatch.setattr(exporter, "have_project", lambda _: True) + monkeypatch.setattr(exporter, "section_name", [exporter.STATUS]) + monkeypatch.setattr(exporter, "section_names_for_epanetv2", [exporter.STATUS]) + monkeypatch.setattr(status, "read_all", lambda *_: [ + {"link": "V1", "status": "ACTIVE", "setting": 100}, + ]) + path = tmp_path / "model.inp" + exporter.dump_inp("model", str(path), version) + text = exporter.export_inp("model", version).operations[0]["inp"] + assert "V1 100" in path.read_text() and "V1 100" in text + assert ("V1 ACTIVE" in path.read_text()) == (version == "3") + assert ("V1 ACTIVE" in text) == (version == "3") + + +def test_exported_active_valve_passes_bundled_epanet2_input_validation(monkeypatch, tmp_path): + library = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux/libepanet2.so" + if platform.system() != "Linux" or not library.exists(): + pytest.skip("Bundled Linux EPANET library is unavailable") + monkeypatch.setattr(status, "read_all", lambda *_: [ + {"link": "551711", "status": "ACTIVE", "setting": None}, + ]) + inp = tmp_path / "valve.inp" + inp.write_text( + "[JUNCTIONS]\nJ1 0 1\nJ2 0 1\n[RESERVOIRS]\nR1 100\n" + "[PIPES]\nP1 R1 J1 100 300 100 0 OPEN\n" + "[VALVES]\n551711 J1 J2 600 TCV 100 0\n[STATUS]\n" + + "\n".join(status.inp_out_status("model")) + + "\n[OPTIONS]\nUNITS LPS\n[END]\n" + ) + lib = ctypes.CDLL(str(library)) + lib.EN_createproject.argtypes = [ctypes.POINTER(ctypes.c_void_p)] + lib.EN_open.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p] + lib.EN_deleteproject.argtypes = [ctypes.c_void_p] + handle = ctypes.c_void_p() + assert lib.EN_createproject(ctypes.byref(handle)) == 0 + try: + code = lib.EN_open(handle, str(inp).encode(), str(tmp_path / "valve.rpt").encode(), b"") + assert code == 0 + finally: + lib.EN_deleteproject(handle) diff --git a/tests/unit/test_geocoding.py b/tests/unit/test_geocoding.py index 9f4366b..c52a76f 100644 --- a/tests/unit/test_geocoding.py +++ b/tests/unit/test_geocoding.py @@ -30,7 +30,7 @@ class FakeClient: def test_geocode_tianditu_gets_expected_params(monkeypatch): - monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "tk-test") monkeypatch.setattr( geocoding.settings, "TIANDITU_GEOCODER_URL", @@ -71,7 +71,7 @@ def test_geocode_tianditu_gets_expected_params(monkeypatch): def test_geocode_tianditu_accepts_key_word_alias(monkeypatch): - monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "tk-test") response = httpx.Response( 200, json={"location": {"lon": "116", "lat": "39"}, "status": "0", "msg": "ok"}, @@ -89,7 +89,7 @@ def test_geocode_tianditu_accepts_key_word_alias(monkeypatch): def test_geocode_tianditu_requires_token(monkeypatch): - monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "") + monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "") with pytest.raises(geocoding.TiandituGeocodingConfigError): asyncio.run( @@ -101,7 +101,7 @@ def test_geocode_tianditu_requires_token(monkeypatch): def test_geocode_tianditu_surfaces_http_error(monkeypatch): - monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "tk-test") response = httpx.Response( 403, json={"msg": "invalid tk"}, @@ -121,7 +121,7 @@ def test_geocode_tianditu_surfaces_http_error(monkeypatch): def test_geocode_tianditu_surfaces_tianditu_error_status(monkeypatch): - monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "tk-test") response = httpx.Response( 200, json={"status": "100", "msg": "bad request"}, diff --git a/tests/unit/test_mcp_analysis.py b/tests/unit/test_mcp_analysis.py new file mode 100644 index 0000000..5290728 --- /dev/null +++ b/tests/unit/test_mcp_analysis.py @@ -0,0 +1,479 @@ +import asyncio +from contextlib import asynccontextmanager, contextmanager +from datetime import datetime, 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.domain.schemas.operational_data import TaskDataSnapshot +from app.mcp import analysis_service, query_service +from app.mcp.catalog import TOOLS +from app.mcp.scope import QueryScope +from app.infra.db.project_routing import ( + ActiveProjectRouting, + activate_project_routing, + get_active_project_routing, + get_project_pgconn_string, + get_project_timescale_pgconn_string, +) + + +HANDLE = "x" * 43 +START = "2026-10-08T12:00:00+08:00" +END = "2026-10-08T13:00:00+08:00" +INPUTS = { + "run_simulation": {"start_time": START, "duration": 3600, "output_node_ids": ["J1"], "output_link_ids": ["L1"]}, + "run_contaminant_simulation": {"start_time": START, "output_node_ids": ["J1"], "source": "J2", "concentration": 1.0}, + "analyze_valve_closure": {"start_time": START, "output_node_ids": ["J1"], "valves": ["V1"]}, + "analyze_flushing": {"start_time": START, "output_node_ids": ["J1"], "drainage_node_id": "J2", "flushing_flow_m3h": 10}, + "analyze_pressure_regulation": {"start_time": START, "output_node_ids": ["J1"], "pump_control": {"P1": [1]}}, + "optimize_sensor_placement": {"method": "kmeans", "sensor_count": 3}, + "run_burst_detection": {"target_time": START, "sensor_nodes": ["1", "2", "3", "4", "5"]}, + "run_burst_location": {"data_source": "simulation", "simulation_run_id": str(uuid4()), "burst_leakage": 0.02, "pressure_scada_ids": ["1"], "scada_burst_start": START, "scada_burst_end": END}, + "analyze_valve_isolation": {"accident_element": ["540672"]}, + "run_leakage_identification": {"scada_start": START, "scada_end": END, "sensor_nodes": ["1"], "q_sum": 0.02, "start_time": 12, "dma_count": 1}, +} + + +def arguments(name, **extra): + return TOOLS[name].model.model_validate({"context_handle": HANDLE, **INPUTS[name], **extra}) + + +@pytest.fixture +def routed_calculation_scope(monkeypatch): + events = [] + project = SimpleNamespace(project_id=uuid4(), project_code="logical-project") + business = "host=localhost dbname=physical_business" + timescale = "host=localhost dbname=physical_iot" + + async def resolve(project_id, role): + assert project_id == project.project_id + events.append(role) + return SimpleNamespace( + db_type="postgresql" if role == "biz_data" else "timescaledb", + dsn=business if role == "biz_data" else timescale, + ) + + async def commit(): + events.append("metadata_commit") + + async def release_business_read(): + events.append("business_commit") + + metadata = SimpleNamespace( + get_project_db_routing=AsyncMock(side_effect=resolve), + session=SimpleNamespace(commit=AsyncMock(side_effect=commit)), + ) + routing = ActiveProjectRouting(project.project_code, business, project_id=project.project_id) + scope = QueryScope( + project, SimpleNamespace(username="actor"), + SimpleNamespace(commit=release_business_read), routing, metadata, + ) + monkeypatch.setattr(query_service, "authorize_query", AsyncMock()) + return scope, timescale, events + + +@pytest.mark.parametrize("name", [ + "run_simulation", "run_contaminant_simulation", "analyze_valve_closure", + "analyze_flushing", "analyze_pressure_regulation", "run_burst_detection", + "run_burst_location", "run_leakage_identification", +]) +def test_timescale_calculation_routes_are_ready_before_native_worker(monkeypatch, routed_calculation_scope, name): + scope, timescale, events = routed_calculation_scope + + def worker(tool_name, parameters, network, username): + assert tool_name == name + assert network == scope.project.project_code + assert username == "actor" + assert "context_handle" not in parameters + assert "timeout_seconds" not in parameters + assert get_project_timescale_pgconn_string(network) == timescale + assert get_project_pgconn_string(network) == scope.routing.business_dsn + assert events == ["business_commit", "biz_data", "iot_data", "metadata_commit"] + events.append("worker") + return {"routed": True} + + monkeypatch.setattr(analysis_service, "_calculate", worker) + + async def execute(): + result = await query_service.execute_query(scope, object(), TOOLS[name], arguments(name)) + assert get_active_project_routing() is scope.routing + return result + + with activate_project_routing(scope.routing): + result = asyncio.run(execute()) + assert result.data["result"] == {"routed": True} + assert events[-1] == "worker" + + +def test_missing_timescale_route_stops_online_simulation_before_model_changes(monkeypatch, routed_calculation_scope): + scope, _, _ = routed_calculation_scope + resolve = scope.metadata.get_project_db_routing.side_effect + + async def missing_iot(project_id, role): + return None if role == "iot_data" else await resolve(project_id, role) + + scope.metadata.get_project_db_routing.side_effect = missing_iot + calculate = AsyncMock(return_value={}) + monkeypatch.setattr(analysis_service, "calculate", calculate) + with activate_project_routing(scope.routing): + with pytest.raises(HTTPException) as denied: + asyncio.run(query_service.execute_query(scope, object(), TOOLS["run_simulation"], arguments("run_simulation"))) + assert get_active_project_routing() is scope.routing + assert denied.value.status_code == 503 + assert denied.value.detail == "Project TimescaleDB not configured" + calculate.assert_not_awaited() + + +@pytest.mark.parametrize("name", ["analyze_valve_isolation", "optimize_sensor_placement"]) +def test_business_only_calculations_do_not_require_timescale(monkeypatch, routed_calculation_scope, name): + scope, _, events = routed_calculation_scope + + def worker(*_): + assert get_active_project_routing() is scope.routing + assert get_project_pgconn_string(scope.project.project_code) == scope.routing.business_dsn + return {"business_only": True} + + monkeypatch.setattr(analysis_service, "_calculate", worker) + with activate_project_routing(scope.routing): + result = asyncio.run(query_service.execute_query(scope, object(), TOOLS[name], arguments(name))) + assert result.data["result"] == {"business_only": True} + assert events == ["business_commit"] + scope.metadata.get_project_db_routing.assert_not_awaited() + + +def test_calculation_failure_restores_business_routing(monkeypatch, routed_calculation_scope): + scope, timescale, _ = routed_calculation_scope + + def worker(*_): + assert get_project_timescale_pgconn_string(scope.project.project_code) == timescale + raise RuntimeError("native calculation failed") + + monkeypatch.setattr(analysis_service, "_calculate", worker) + + async def execute(): + with pytest.raises(RuntimeError, match="native calculation failed"): + await query_service.execute_query(scope, object(), TOOLS["run_simulation"], arguments("run_simulation")) + assert get_active_project_routing() is scope.routing + + with activate_project_routing(scope.routing): + asyncio.run(execute()) + + +def test_denied_calculation_never_loads_timescale_or_starts_worker(monkeypatch, routed_calculation_scope): + scope, _, events = routed_calculation_scope + monkeypatch.setattr(query_service, "authorize_query", AsyncMock(side_effect=HTTPException(403, "denied"))) + calculate = AsyncMock(return_value={}) + monkeypatch.setattr(analysis_service, "calculate", calculate) + with pytest.raises(HTTPException) as denied: + asyncio.run(query_service.execute_query(scope, object(), TOOLS["run_simulation"], arguments("run_simulation"))) + assert denied.value.status_code == 403 + assert events == [] + scope.metadata.get_project_db_routing.assert_not_awaited() + calculate.assert_not_awaited() + + +@pytest.mark.parametrize("name", INPUTS) +def test_catalog_and_snapshots_accept_calculations_but_reject_model_owned_authority(name): + tool = TOOLS[name] + assert tool.computation + assert arguments(name).timeout_seconds == 120 + assert tool.protocol_tool().input_schema["additionalProperties"] is False + for field in ("network", "username", "scheme_name", "final_candidates_csv_path", "observed_pressure_data", "persist_results", "simulation_type"): + with pytest.raises(ValidationError): + arguments(name, **{field: "untrusted"}) + snapshot = TaskDataSnapshot(source=name, source_ref="mcp:test", queried_at=datetime.now(timezone.utc), origin="system_data", data={}, truncated=False) + assert snapshot.source == name + + +@pytest.mark.parametrize("name", INPUTS) +def test_calculation_permission_and_reader_boundary(monkeypatch, name): + permissions = {"burst.run", "simulation.run", "simulation.view", "scada.view", "optimization.run"} + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions) + scope = SimpleNamespace(project=SimpleNamespace(project_role="member")) + binding = SimpleNamespace(tool_cap=list(TOOLS), purpose="task_execution", permission_cap=list(permissions), task_id=uuid4(), task_scope_cap=["scada_readings"]) + tool, args = TOOLS[name], arguments(name) + asyncio.run(query_service.authorize_query(scope, binding, tool, args)) + binding.purpose = "task_reader" + with pytest.raises(HTTPException) as denied: + asyncio.run(query_service.authorize_query(scope, binding, tool, args)) + assert denied.value.status_code == 403 + binding.purpose = "task_execution" + binding.permission_cap.remove(tool.permission) + with pytest.raises(HTTPException): + asyncio.run(query_service.authorize_query(scope, binding, tool, args)) + binding.permission_cap = list(permissions) + permissions.remove(tool.permission) + with pytest.raises(HTTPException): + asyncio.run(query_service.authorize_query(scope, binding, tool, args)) + + +def test_time_sources_and_finite_inputs_are_explicit(): + invalid = [ + ("run_simulation", {"start_time": "2026-10-08T12:00:00"}), + ("run_simulation", {"duration": 0}), + ("run_simulation", {"duration": 86401}), + ("run_simulation", {"timeout_seconds": 0}), + ("run_simulation", {"timeout_seconds": 86401}), + ("run_simulation", {"timeout_seconds": 30.5}), + ("run_simulation", {"timeout_seconds": True}), + ("run_simulation", {"output_node_ids": []}), + ("run_simulation", {"output_link_ids": [str(i) for i in range(21)]}), + ("run_burst_detection", {"target_time": "2026-10-08T12:00:00"}), + ("run_burst_detection", {"sensor_nodes": ["1"] * 5}), + ("run_burst_location", {"burst_leakage": float("nan")}), + ("run_burst_location", {"simulation_run_id": None}), + ("run_burst_location", {"data_source": "monitoring"}), + ("run_burst_location", {"scada_burst_end": "2026-10-10T12:00:00+08:00"}), + ("run_leakage_identification", {"q_sum": 0}), + ] + for name, changes in invalid: + with pytest.raises(ValidationError): + arguments(name, **changes) + args = arguments("run_burst_location", data_source="monitoring", simulation_run_id=None, + scada_normal_start="2026-10-07T12:00:00+08:00", scada_normal_end="2026-10-07T13:00:00+08:00") + assert args.data_source == "monitoring" + + +@pytest.mark.parametrize("overrides", [{}, { + "pressure_scada_ids": None, "scada_normal_start": None, "scada_normal_end": None, +}, { + "pressure_scada_ids": ["J2"], + "scada_normal_start": "2026-10-06T12:00:00+08:00", + "scada_normal_end": "2026-10-06T13:00:00+08:00", +}]) +@pytest.mark.parametrize("missing_normal", [False, True]) +def test_burst_location_mcp_reuses_project_sensors_and_normal_window_defaults(monkeypatch, tmp_path, overrides, missing_normal): + from app.services import burst_location + + args = TOOLS["run_burst_location"].model.model_validate({ + "context_handle": HANDLE, "burst_leakage": 0.0274, + "scada_burst_start": START, "scada_burst_end": END, **overrides, + }) + devices = [{"device_type": "pressure", "node_id": node, "api_query_id": f"q-{node}"} + for node in ("J1", "J2")] + monkeypatch.setattr(burst_location, "get_all_scada_info", lambda network: devices) + queries = [] + + def read_scada(**kwargs): + queries.append(kwargs) + if missing_normal and len(queries) == 2: + return {} + return {device: [{"value": 20.0}] for device in kwargs["device_ids"]} + + monkeypatch.setattr(burst_location.InternalQueries, "query_scada_by_ids_timerange", read_scada) + + @contextmanager + def inp(network, *, purpose): + yield tmp_path / "model.inp" + + monkeypatch.setattr(burst_location, "temporary_project_inp", inp) + algorithm = [] + + def locate(**kwargs): + algorithm.append(kwargs) + return {"located_pipe": "candidate"} + + monkeypatch.setattr(burst_location, "run_burst_location", locate) + if missing_normal: + with pytest.raises(ValueError, match="正常压力数据.*无有效数据"): + asyncio.run(analysis_service.calculate( + "run_burst_location", args, network="trusted-project", username="trusted-user", + )) + assert algorithm == [] + return + result = asyncio.run(analysis_service.calculate( + "run_burst_location", args, network="trusted-project", username="trusted-user", + )) + expected_ids = overrides.get("pressure_scada_ids") or ["J1", "J2"] + assert result["pressure_scada_ids"] == expected_ids + assert [q["device_ids"] for q in queries] == [[f"q-{node}" for node in expected_ids]] * 2 + normal_start = overrides.get("scada_normal_start") or "2026-10-07T12:00:00+08:00" + normal_end = overrides.get("scada_normal_end") or "2026-10-07T13:00:00+08:00" + assert datetime.fromisoformat(result["scada_window"]["normal_start"]) == datetime.fromisoformat(normal_start) + assert datetime.fromisoformat(result["scada_window"]["normal_end"]) == datetime.fromisoformat(normal_end) + assert algorithm[0]["pressure_scada_ids"] == expected_ids + assert algorithm[0]["final_candidates_csv_path"] is None + + +@pytest.mark.parametrize("overrides", [ + {"scada_normal_start": "2026-10-07T12:00:00+08:00"}, + {"scada_normal_end": "2026-10-07T13:00:00+08:00"}, + {"scada_normal_start": "2026-10-07T12:00:00", "scada_normal_end": "2026-10-07T13:00:00"}, + {"pressure_scada_ids": []}, {"pressure_scada_ids": ["J1", "J1"]}, + {"pressure_scada_ids": [str(i) for i in range(21)]}, +]) +def test_burst_location_optional_inputs_still_reject_partial_or_invalid_values(overrides): + with pytest.raises(ValidationError): + TOOLS["run_burst_location"].model.model_validate({ + "context_handle": HANDLE, "burst_leakage": 0.0274, + "scada_burst_start": START, "scada_burst_end": END, **overrides, + }) + + +@pytest.mark.parametrize("name", INPUTS) +def test_calculations_accept_agent_execution_budget_separately_from_duration(name): + args = arguments(name, timeout_seconds=900) + assert args.timeout_seconds == 900 + if hasattr(args, "duration"): + assert args.duration == INPUTS[name].get("duration", 900) + + +@pytest.mark.parametrize("name,module,function", [ + ("run_burst_detection", "app.services.burst_detection", "run_burst_detection"), + ("run_burst_location", "app.services.burst_location", "run_burst_location_by_network"), + ("analyze_valve_isolation", "app.services.valve_isolation", "analyze_valve_isolation"), + ("run_leakage_identification", "app.services.dma_leakage_estimation", "run_leakage_identification"), +]) +def test_adapter_uses_existing_service_without_scheme_or_shared_output(monkeypatch, name, module, function): + calls = [] + def calculate(**kwargs): + calls.append(kwargs) + return {"ok": True} + monkeypatch.setattr(f"{module}.{function}", calculate) + result = asyncio.run(analysis_service.calculate(name, arguments(name), network="trusted-project", username="trusted-user")) + assert result == {"ok": True} + call = calls[0] + assert call["network"] == "trusted-project" + assert "context_handle" not in call + if name != "analyze_valve_isolation": + assert call["username"] == "trusted-user" + assert call["scheme_name"] is None + if name == "run_burst_location": + assert call["final_candidates_csv_path"] is None + if name == "run_leakage_identification": + assert call["duration"] == 1 + assert call["n_workers"] == 1 + + +@pytest.mark.parametrize("name", ["run_burst_location", "run_simulation"]) +def test_dispatch_ends_read_transaction_and_preserves_analysis_provenance(monkeypatch, name): + order = [] + async def commit(): + order.append("commit") + async def calculate(*args, **kwargs): + order.append("calculate") + assert kwargs == {"network": "trusted-project", "username": "trusted-user"} + return {"located_pipe": "candidate"} + + @asynccontextmanager + async def simulation_routing(): + yield + + monkeypatch.setattr(query_service, "authorize_query", AsyncMock()) + monkeypatch.setattr(analysis_service, "calculate", calculate) + scope = SimpleNamespace(connection=SimpleNamespace(commit=commit), project=SimpleNamespace(project_code="trusted-project"), user=SimpleNamespace(username="trusted-user"), simulation_routing=simulation_routing) + result = asyncio.run(query_service.execute_query(scope, object(), TOOLS[name], arguments(name))) + assert order == ["commit", "calculate"] + assert result.semantics == "analysis" + if name == "run_burst_location": + assert result.data["parameters"]["burst_leakage"] == 0.02 + else: + assert result.data["parameters"]["duration"] == 3600 + assert result.source == "run_simulation" + assert "context_handle" not in result.data["parameters"] + assert result.data["result"]["located_pipe"] == "candidate" + assert "not observed SCADA" in result.data_gaps[-1] + + +def test_busy_calculation_does_not_start_another_worker(monkeypatch): + from threading import BoundedSemaphore + slots = BoundedSemaphore(1) + slots.acquire() + monkeypatch.setattr(analysis_service, "_slots", slots) + with pytest.raises(HTTPException) as denied: + analysis_service._calculate("analyze_valve_isolation", {}, "project", "user") + assert denied.value.status_code == 503 + + +@pytest.mark.parametrize("name,function", [ + ("run_contaminant_simulation", "contaminant_simulation"), + ("analyze_valve_closure", "valve_close_analysis"), + ("analyze_flushing", "flushing_analysis"), + ("analyze_pressure_regulation", "pressure_regulation"), +]) +def test_scenario_adapters_are_nonpersistent_and_return_only_requested_elements(monkeypatch, name, function): + from app.mcp.scenario_service import calculate_scenario + calls = [] + def scenario(**kwargs): + calls.append(kwargs) + return {"node_results": [{"node": "J1", "result": []}, {"node": "J2", "result": []}], "link_results": [{"link": "L1", "result": []}]} + monkeypatch.setattr(f"app.services.simulation_scenarios.{function}", scenario) + monkeypatch.setattr("app.native.wndb.model.options.get_option", lambda _: {"UNITS": "CMH"}) + monkeypatch.setattr("app.services.simulation.query_corresponding_element_id_and_query_id", lambda _: SimpleNamespace(fixed_pumps={"P1": "S1"}, variable_pumps={})) + result = calculate_scenario(name, arguments(name).model_dump(exclude={"context_handle"}, exclude_none=True), network="trusted", username="actor") + assert calls[0]["name"] == "trusted" + assert calls[0]["persist_results"] is False + assert calls[0]["scheme_name"] is None + assert result["node_results"] == [{"node": "J1", "result": []}] + assert result["link_results"] == [] + assert result["coverage"]["missing_nodes"] == [] + if name == "analyze_flushing": + assert calls[0]["flushing_flow"] == 10 + + +def test_sensor_preview_validates_but_does_not_store(monkeypatch): + from app.services import sensor_placement as service + from unittest.mock import Mock + validate = Mock() + save = Mock() + monkeypatch.setattr(service, "validate_sensor_placement_nodes", validate) + monkeypatch.setattr(service, "_sensor_points", lambda *_: [{"node_id": "J1"}]) + monkeypatch.setattr(service.sensor_placement_repository, "create_sensor_placement", save) + result = service._create_validated_placement("project", run_name="preview", min_diameter=0, + created_by="actor", sensor_locations=["J1"], persist_results=False) + validate.assert_called_once_with("project", ["J1"]) + save.assert_not_called() + assert result["sensor_locations"] == ["J1"] + + +@pytest.mark.parametrize("name", ["run_contaminant_simulation", "run_simulation"]) +def test_scenario_also_requires_scada_permission_even_with_task_read_scope(monkeypatch, name): + permissions = {"simulation.run"} + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions) + binding = SimpleNamespace(tool_cap=list(TOOLS), purpose="task_execution", permission_cap=list(permissions), task_id=uuid4(), task_scope_cap=["scada_readings"]) + with pytest.raises(HTTPException): + asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), binding, TOOLS[name], arguments(name))) + + +@pytest.mark.parametrize("fails", [False, True]) +def test_standard_simulation_uses_online_service_and_propagates_failure(monkeypatch, fails): + from app.mcp.scenario_service import calculate_scenario + + events = [] + def temporary_project(*_): + pytest.fail("Online calculation must not copy a database") + + def simulate(network, start, duration, **kwargs): + assert network == "trusted-project" + assert start.isoformat() == START + assert duration == 3600 + assert kwargs == {"output_node_ids": ["J1"], "output_link_ids": ["L1"]} + events.append("calculate") + if fails: + raise RuntimeError("calculation failed") + return {"node_results": [{"node": "J1"}, {"node": "J2"}], + "link_results": [{"link": "L1"}, {"link": "L2"}], + "times": {"report_step": 900}, "units": {"pressure": "m", "flow": "LPS"}} + + monkeypatch.setattr("app.native.wndb.core.projects.temporary_project_database", temporary_project) + monkeypatch.setattr("app.services.simulation.run_online_simulation", simulate) + def execute(): + return calculate_scenario("run_simulation", arguments("run_simulation").model_dump( + exclude={"context_handle"}), network="trusted-project", username="actor") + if fails: + with pytest.raises(RuntimeError, match="calculation failed"): + execute() + else: + result = execute() + assert result["node_results"] == [{"node": "J1"}] + assert result["link_results"] == [{"link": "L1"}] + assert result["duration_seconds"] == 3600 + assert result["persisted_scheme"] is False + assert result["persisted_baseline"] is True + assert result["units"]["pressure"] == "m" + assert events == ["calculate"] diff --git a/tests/unit/test_mcp_config.py b/tests/unit/test_mcp_config.py new file mode 100644 index 0000000..5eb1c0f --- /dev/null +++ b/tests/unit/test_mcp_config.py @@ -0,0 +1,53 @@ +import pytest +from pydantic import ValidationError + +from app.mcp.config import McpSettings + + +ISSUER = "http://192.168.1.114:8088/realms/tjwater" + + +def local_settings(**overrides): + return McpSettings(**{ + "enabled": True, + "environment": "development", + "issuer_uri": ISSUER, + "dev_http_issuer_uri": ISSUER, + "resource_uri": "http://localhost/mcp", + "_env_file": None, + **overrides, + }) + + +def test_explicit_private_development_issuer(): + assert local_settings().issuer_uri == ISSUER + + +def test_calculation_limit_covers_default_without_extending_ordinary_queries(): + config = local_settings(max_calculation_timeout_seconds=120) + assert config.query_timeout_seconds == 30 + with pytest.raises(ValidationError): + local_settings(max_calculation_timeout_seconds=119) + + +@pytest.mark.parametrize("overrides", [ + {"environment": "production"}, + {"environment": "production", "enabled": False}, + {"dev_http_issuer_uri": ""}, + {"issuer_uri": ISSUER + "/other"}, + {"resource_uri": "http://192.168.1.114:8088/mcp"}, + {"issuer_uri": "http://example.com/realm", "dev_http_issuer_uri": "http://example.com/realm"}, + {"issuer_uri": "http://8.8.8.8/realm", "dev_http_issuer_uri": "http://8.8.8.8/realm"}, + {"issuer_uri": ISSUER + "?query=1", "dev_http_issuer_uri": ISSUER + "?query=1"}, +]) +def test_rejects_unapproved_http_issuer_or_resource(overrides): + with pytest.raises(ValidationError): + local_settings(**overrides) + + +def test_development_mode_can_be_injected_from_environment(monkeypatch): + monkeypatch.setenv("ENVIRONMENT", "development") + monkeypatch.setenv("TJWATER_MCP_DEV_HTTP_ISSUER_URI", ISSUER) + monkeypatch.setenv("TJWATER_MCP_ISSUER_URI", ISSUER) + config = McpSettings(enabled=True, resource_uri="http://localhost/mcp", _env_file=None) + assert config.environment == "development" diff --git a/tests/unit/test_mcp_query.py b/tests/unit/test_mcp_query.py new file mode 100644 index 0000000..ee26efa --- /dev/null +++ b/tests/unit/test_mcp_query.py @@ -0,0 +1,900 @@ +import asyncio +import base64 +import time +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from uuid import uuid4 +from unittest.mock import AsyncMock + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from fastapi import HTTPException +from jose import jwt +from mcp import types +from mcp.server.auth.provider import AccessToken +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from app.mcp.application import McpQueryApplication +from app.mcp.auth import McpTokenVerifier +from app.mcp.config import McpSettings +from app.mcp.catalog import TOOLS +from app.mcp.models import BindInput, BoundContext, QueryResult, ScadaInput +from app.mcp import application, contexts, query_service + +PROJECT = uuid4() +SUBJECT = str(uuid4()) +PERMISSIONS = { + "webgis.view", + "simulation.view", + "scada.view", + "operational_task.view", + "operational_task.execute", + "optimization.run", +} + + +def test_calculation_budget_outlives_query_default_and_still_times_out(runtime, monkeypatch): + app, client, _, _ = runtime + app.settings.query_timeout_seconds = 1 + monkeypatch.setattr(application, "authorize_query", AsyncMock()) + + async def execute(scope, bound, tool, args): + await asyncio.sleep(1.05) + return QueryResult( + source=tool.name, source_ref="test:calculation", semantics="analysis", + queried_at=datetime.now(timezone.utc), data={"completed": True}, + ) + + app.executor = execute + handle = binding().context_handle + arguments = { + "context_handle": handle, "start_time": "2026-10-08T12:00:00+08:00", + "duration": 3600, "output_node_ids": ["J1"], "timeout_seconds": 2, + } + response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json() + assert response["result"].get("isError") is not True + assert response["result"]["structuredContent"]["data"] == {"completed": True} + arguments["timeout_seconds"] = 1 + response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json() + assert response["result"]["isError"] is True + assert response["result"]["structuredContent"]["error"]["code"] == "QUERY_TIMEOUT" + + +def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime): + app, client, calls, _ = runtime + app.settings.max_calculation_timeout_seconds = 120 + tools = request(client).json()["result"]["tools"] + schema = next(tool["inputSchema"] for tool in tools if tool["name"] == "run_simulation") + assert schema["properties"]["timeout_seconds"]["maximum"] == 120 + assert schema["properties"]["timeout_seconds"]["default"] == 120 + arguments = { + "context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00", + "duration": 3600, "output_node_ids": ["J1"], "timeout_seconds": 121, + } + response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json() + assert "error" in response or response["result"]["isError"] is True + assert calls == [] + + +def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch): + app, client, calls, _ = runtime + monkeypatch.setattr(application, "authorize_query", AsyncMock()) + schema = next(tool["inputSchema"] for tool in request(client).json()["result"]["tools"] + if tool["name"] == "run_burst_location") + assert "pressure_scada_ids" not in schema["required"] + response = request(client, "tools/call", {"name": "run_burst_location", "arguments": { + "context_handle": binding().context_handle, "burst_leakage": 0.0274, + "scada_burst_start": "2026-10-08T12:00:00+08:00", + "scada_burst_end": "2026-10-08T13:00:00+08:00", + }}).json() + assert response["result"].get("isError") is not True + assert calls == [("session-1", "run_burst_location")] + + +def settings(): + return McpSettings( + enabled=True, + issuer_uri="http://localhost/realms/water", + resource_uri="http://localhost/mcp", + allowed_hosts=["testserver", "localhost:*"], + _env_file=None, + ) + + +def principal(subject=SUBJECT, scopes=None, client="tjwater-agent-mcp"): + return AccessToken( + token="fake", + subject=subject, + client_id=client, + scopes=scopes + if scopes is not None + else ["mcp.catalog.read", "mcp.query", "mcp.context.bind"], + resource="http://localhost/mcp", + claims={"sub": subject}, + ) + + +class Verifier: + def __init__(self): + self.identities = { + "user": principal(), + "other": principal(str(uuid4())), + "catalog": principal(scopes=["mcp.catalog.read"]), + } + + async def verify_token(self, token): + return self.identities.get(token) + + +class Connection: + @asynccontextmanager + async def transaction(self): + yield + + +class Repository: + bindings = {} + receipts = {} + + def __init__(self, conn, project): + self.project = project + + async def create(self, binding): + self.bindings[binding.context_handle] = binding.model_copy(deep=True) + + async def get(self, handle): + return self.bindings.get(handle) + + async def revoke(self, handle, subject, client): + binding = self.bindings.get(handle) + if ( + binding + and binding.subject == subject + and binding.client_id == client + and binding.project_id == self.project + ): + binding.revoked_at = datetime.now(timezone.utc) + + async def receipt(self, key, call): + return self.receipts.get((self.project, key, call)) + + async def save_receipt(self, key, call, parameters, result): + self.receipts[(self.project, key, call)] = { + "parameters": parameters, + "result": result, + } + + async def lock_receipt(self, key, call): + pass + + +@pytest.fixture +def runtime(monkeypatch): + Repository.bindings = {} + Repository.receipts = {} + monkeypatch.setattr(application, "McpQueryRepository", Repository) + monkeypatch.setattr(contexts, "McpQueryRepository", Repository) + monkeypatch.setattr(contexts, "permissions_for_context", lambda ctx: PERMISSIONS) + monkeypatch.setattr( + query_service, "permissions_for_context", lambda ctx: PERMISSIONS + ) + + @asynccontextmanager + async def scope_factory(identity, project): + try: + project_id = __import__("uuid").UUID(project) + except ValueError: + raise HTTPException(400, "Invalid project") + yield SimpleNamespace( + project=SimpleNamespace(project_id=project_id), connection=Connection() + ) + + calls = [] + + async def execute(scope, binding, tool, args): + calls.append((binding.native_session_id, tool.name)) + return QueryResult( + source=tool.name, + source_ref="source:1", + queried_at=datetime.now(timezone.utc), + semantics=tool.semantics, + data=[{"id": "P-1"}], + ) + + verifier = Verifier() + runtime = McpQueryApplication( + settings(), verifier=verifier, scope_factory=scope_factory, executor=execute + ) + + @asynccontextmanager + async def lifespan(app): + async with runtime.lifespan(): + yield + + host = Starlette(lifespan=lifespan) + + # Lifespan belongs to host; HTTP is delegated to SDK without mounting path prefixes. + class App: + async def __call__(self, scope, receive, send): + if scope["type"] == "lifespan": + await host(scope, receive, send) + else: + await runtime.asgi(scope, receive, send) + + with TestClient(App()) as client: + yield runtime, client, calls, verifier + + +def request( + client, + method="tools/list", + params=None, + token="user", + version="2026-07-28", + extra=None, +): + meta = { + types.PROTOCOL_VERSION_META_KEY: version, + types.CLIENT_CAPABILITIES_META_KEY: {}, + types.CLIENT_INFO_META_KEY: {"name": "test", "version": "1"}, + "io.tjwater/callId": "call-1", + } + body = { + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": {"_meta": meta, **(params or {})}, + } + headers = { + "Authorization": "Bearer " + token, + "MCP-Protocol-Version": version, + "MCP-Method": method, + "Accept": "application/json, text/event-stream", + "X-Project-Id": str(PROJECT), + } + if method == "tools/call": + headers["MCP-Name"] = params["name"] + headers.update(extra or {}) + return client.post("/mcp", json=body, headers=headers) + + +def binding(task=False): + value = BoundContext( + context_handle="a" * 43, + project_id=PROJECT, + subject=SUBJECT, + client_id="tjwater-agent-mcp", + native_session_id="session-1", + purpose="task_execution" if task else "ordinary_chat", + tool_cap=sorted(TOOLS), + permission_cap=sorted(PERMISSIONS), + task_id=uuid4() if task else None, + run_id=uuid4() if task else None, + expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), + ) + Repository.bindings[value.context_handle] = value + return value + + +def test_modern_discovery_catalog_and_metadata(runtime): + _, client, _, _ = runtime + result = request(client, "server/discover").json() + assert result["result"]["resultType"] == "complete" + assert "2026-07-28" in result["result"]["supportedVersions"] + listed = request(client).json()["result"] + assert len(listed["tools"]) == 35 + for tool in listed["tools"]: + timeout = tool["inputSchema"]["properties"].get("timeout_seconds") + assert (timeout is not None) == TOOLS[tool["name"]].computation + if timeout: + assert timeout["default"] == 120 + assert timeout["maximum"] == 3600 + assert listed["ttlMs"] == 300000 and listed["cacheScope"] == "public" + assert all( + tool["inputSchema"]["additionalProperties"] is False for tool in listed["tools"] + ) + assert ( + client.get("/.well-known/oauth-protected-resource/mcp").json()["resource"] + == "http://localhost/mcp" + ) + assert "mcp-session-id" not in request(client).headers + + +@pytest.mark.parametrize("token,status", [("bad", 401), ("catalog", 403)]) +def test_oauth_challenges(runtime, token, status): + _, client, _, _ = runtime + result = request( + client, "tools/call", {"name": "list_elements", "arguments": {}}, token=token + ) + assert result.status_code == status + assert "resource_metadata=" in result.headers["www-authenticate"] + assert "mcp.query" in result.headers["www-authenticate"] + + +def test_rejects_legacy_and_mirror_mismatch(runtime): + _, client, _, _ = runtime + assert request(client, version="2025-03-26").json()["error"]["code"] == -32022 + assert client.get("/mcp").status_code == 405 + assert client.delete("/mcp").status_code == 405 + mismatch = request(client, extra={"MCP-Method": "resources/list"}) + assert mismatch.status_code == 400 and mismatch.json()["error"]["code"] == -32020 + unknown = request(client, "tools/call", {"name": "missing", "arguments": {}}) + assert unknown.json()["error"]["code"] == -32602 + + +@pytest.mark.parametrize("header,body", [ + ("2025-11-25", "2026-07-28"), + ("1900-01-01", "2026-07-28"), + ("2026-07-28", "2025-11-25"), +]) +def test_version_header_mismatch_precedes_version_support(runtime, header, body): + _, client, calls, _ = runtime + response = request(client, version=body, extra={"MCP-Protocol-Version": header}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == -32020 + assert calls == [] + + +def test_query_identity_project_expiry_and_permission_boundaries(runtime): + _, client, calls, _ = runtime + value = binding() + params = { + "name": "list_elements", + "arguments": {"context_handle": value.context_handle, "element_type": "pipe"}, + } + assert request(client, "tools/call", params).json()["result"]["structuredContent"][ + "data" + ] == [{"id": "P-1"}] + assert len(calls) == 1 + for token, extra in [("other", None), ("user", {"X-Project-Id": str(uuid4())})]: + assert request(client, "tools/call", params, token=token, extra=extra).json()[ + "result" + ]["isError"] + value.permission_cap = [] + assert ( + request(client, "tools/call", params).json()["result"]["structuredContent"][ + "error" + ]["code"] + == "QUERY_CONTEXT_DENIED" + ) + value.permission_cap = sorted(PERMISSIONS) + value.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + assert request(client, "tools/call", params).json()["result"]["isError"] + assert len(calls) == 1 + + +def test_host_only_binding_and_no_identity_arguments(): + with pytest.raises(Exception): + BindInput(native_session_id="s", purpose="ordinary_chat", task_id=uuid4()) + with pytest.raises(Exception): + BindInput(native_session_id="s", purpose="task_reader", task_id=uuid4()) + with pytest.raises(Exception): + ScadaInput( + context_handle="x" * 43, + device_ids=["d"], + start="2026-10-01T00:00:00", + end="2026-10-02T00:00:00", + ) + with pytest.raises(Exception): + TOOLS["list_elements"].model.model_validate( + { + "context_handle": "x" * 43, + "element_type": "pipe", + "project_id": str(PROJECT), + } + ) + + +def test_small_page_defaults_and_invalid_deep_offset(runtime): + _, client, calls, _ = runtime + value = binding() + for name in ("list_elements", "list_analysis_runs", "query_scada_readings", "query_model_baseline_field"): + assert TOOLS[name].model.model_fields["limit"].default == 20 + result = request(client, "tools/call", { + "name": "list_elements", "arguments": { + "context_handle": value.context_handle, "element_type": "pipe", "offset": 10001, + }, + }).json()["result"] + assert result["isError"] and calls == [] + + +def test_receipt_retry_checks_authorization_before_returning_cache( + runtime, monkeypatch +): + _, client, calls, _ = runtime + value = binding(task=True) + + async def verify(scope, binding): + pass + + monkeypatch.setattr(contexts, "verify_task", verify) + # Existing persisted receipt, not a second execution/evidence append. + import json + + key = json.dumps( + [ + value.subject, + value.client_id, + value.native_session_id, + str(value.task_id), + str(value.run_id), + str(value.execution_request_id), + ], + separators=(",", ":"), + ) + params = { + "tool": "list_elements", + "arguments": {"element_type": "pipe", "limit": 20, "offset": 0}, + } + result = QueryResult( + source="list_elements", + source_ref="mcp:saved", + queried_at=datetime.now(timezone.utc), + semantics="asset", + data=[], + evidence_id="evidence:1", + ).model_dump(mode="json") + Repository.receipts[(PROJECT, key, "call-1")] = { + "parameters": params, + "result": result, + } + query = { + "name": "list_elements", + "arguments": {"context_handle": value.context_handle, "element_type": "pipe"}, + } + assert ( + request(client, "tools/call", query).json()["result"]["structuredContent"][ + "evidence_id" + ] + == "evidence:1" + ) + value.permission_cap = [] + assert request(client, "tools/call", query).json()["result"]["isError"] + assert calls == [] + + +def test_evidence_is_saved_before_return_and_call_conflicts_fail(runtime, monkeypatch): + _, client, calls, _ = runtime + value = binding(task=True) + saved = [] + + async def verify(scope, binding): + pass + + monkeypatch.setattr(contexts, "verify_task", verify) + monkeypatch.setattr(application, "verify_task", verify) + + class TaskService: + repo = SimpleNamespace(lock=lambda key: asyncio.sleep(0)) + + async def record_data_snapshot(self, task_id, snapshot): + saved.append(snapshot) + return SimpleNamespace(evidence_id=snapshot.source_ref), snapshot + + monkeypatch.setattr(application, "task_service", lambda scope: TaskService()) + query = { + "name": "list_elements", + "arguments": {"context_handle": value.context_handle, "element_type": "pipe"}, + } + first = request(client, "tools/call", query).json()["result"]["structuredContent"] + second = request(client, "tools/call", query).json()["result"]["structuredContent"] + assert ( + first == second + and first["evidence_id"] == saved[0].source_ref + and len(saved) == len(calls) == 1 + ) + query["arguments"]["element_type"] = "junction" + assert request(client, "tools/call", query).json()["result"]["isError"] + assert len(saved) == 1 + + +def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch): + app, client, _, _ = runtime + value = binding(task=True) + monkeypatch.setattr(contexts, "verify_task", AsyncMock()) + reader = AsyncMock(return_value={"task": {"workflow": "water_quality_complaint"}}) + monkeypatch.setattr(query_service.reads, "task_context_summary", reader) + event_service = AsyncMock() + monkeypatch.setattr(application, "task_service", event_service) + app.executor = query_service.execute_query + response = request(client, "tools/call", { + "name": "get_task_context", + "arguments": {"context_handle": value.context_handle, "section": "skill"}, + }).json()["result"] + assert response.get("isError") is not True + result = response["structuredContent"] + assert result["data"]["skill"]["name"] == "water-quality-complaint" + assert result["data"]["skill"]["instructions"] and not result["truncated"] + assert result["evidence_id"] is None + event_service.assert_not_called() + assert not Repository.receipts + + +def test_old_task_run_and_reader_parent_are_rejected(monkeypatch): + monkeypatch.setattr(contexts, "permissions_for_context", lambda ctx: PERMISSIONS) + value = binding(task=True) + value.parent_session_id = "parent" + run = { + "status": "running", + "agent_session_id": "wrong", + "run_id": str(value.run_id), + "execution_request_id": str(uuid4()), + "lease_expires_at": datetime.now(timezone.utc) + timedelta(minutes=1), + } + + class Tasks: + async def get_task(self, id): + return {"task_id": id} + + async def get_active_run(self, id): + return run + + monkeypatch.setattr(contexts, "task_repository", lambda scope: Tasks()) + scope = SimpleNamespace(project=object(), connection=Connection()) + with pytest.raises(HTTPException) as failure: + asyncio.run(contexts.verify_task(scope, value)) + assert failure.value.status_code == 409 + run["agent_session_id"] = "parent" + asyncio.run(contexts.verify_task(scope, value)) + run["run_id"] = str(uuid4()) + with pytest.raises(HTTPException): + asyncio.run(contexts.verify_task(scope, value)) + + +def test_jwt_signature_issuer_audience_lifetime_and_key_cache(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + public = key.public_key().public_numbers() + + def encoded(number): + return ( + base64.urlsafe_b64encode( + number.to_bytes((number.bit_length() + 7) // 8, "big") + ) + .rstrip(b"=") + .decode() + ) + + jwks = { + "keys": [ + { + "kid": "one", + "kty": "RSA", + "alg": "RS256", + "n": encoded(public.n), + "e": encoded(public.e), + } + ] + } + calls = [] + + def respond(request): + calls.append(request) + return httpx.Response(200, json=jwks) + + async def check(): + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http: + verifier = McpTokenVerifier(settings(), http) + claims = { + "sub": SUBJECT, + "iss": settings().issuer_uri, + "aud": "tjwater-mcp", + "azp": "tjwater-agent-mcp", + "iat": time.time(), + "exp": time.time() + 300, + "scope": "mcp.query mcp.catalog.read", + } + + def sign(overrides=None): + return jwt.encode( + {**claims, **(overrides or {})}, + pem, + algorithm="RS256", + headers={"kid": "one"}, + ) + + assert (await verifier.verify_token(sign())).subject == SUBJECT + for overrides in [ + {"aud": "tjwater-api"}, + {"iss": "http://other"}, + {"exp": time.time() - 1}, + {"iat": time.time() + 120}, + {"sub": "not-a-user"}, + ]: + assert await verifier.verify_token(sign(overrides)) is None + assert len(calls) == 1 + assert await verifier.verify_token("broken") is None + + asyncio.run(check()) + + +def test_context_control_routes_are_host_only_and_support_revocation( + runtime, monkeypatch +): + runtime, _, _, verifier = runtime + from fastapi import FastAPI + from app.api.v1.endpoints import mcp_queries + + monkeypatch.setattr(mcp_queries, "McpQueryRepository", Repository) + host = FastAPI() + host.state.mcp_runtime = runtime + host.include_router(runtime.router, prefix="/api/v1") + command = {"native_session_id": "host-native", "purpose": "ordinary_chat"} + headers = {"Authorization": "Bearer user", "X-Project-Id": str(PROJECT)} + with TestClient(host) as client: + result = client.post( + "/api/v1/mcp-query-contexts", json=command, headers=headers + ) + assert result.status_code == 200 + handle = result.json()["context_handle"] + assert Repository.bindings[handle].permission_cap == sorted(PERMISSIONS) + verifier.identities["outsider"] = principal(client="browser") + denied = client.post( + "/api/v1/mcp-query-contexts", + json=command, + headers={**headers, "Authorization": "Bearer outsider"}, + ) + assert denied.status_code == 403 + assert ( + client.post( + "/api/v1/mcp-query-contexts", + json={**command, "subject": SUBJECT}, + headers=headers, + ).status_code + == 422 + ) + assert ( + client.delete( + "/api/v1/mcp-query-contexts/" + handle, + headers={**headers, "Authorization": "Bearer other"}, + ).status_code + == 204 + ) + assert Repository.bindings[handle].revoked_at is None + assert ( + client.delete( + "/api/v1/mcp-query-contexts/" + handle, headers=headers + ).status_code + == 204 + ) + assert Repository.bindings[handle].revoked_at is not None + host.state.mcp_runtime = None + assert ( + client.post( + "/api/v1/mcp-query-contexts", json=command, headers=headers + ).status_code + == 503 + ) + + +def test_large_task_context_uses_bounded_summary_without_loading_full_context(runtime, monkeypatch): + from unittest.mock import AsyncMock + from app.infra.db.postgresql import read_queries + + runtime, client, _, _ = runtime + value = binding(task=True) + monkeypatch.setattr(contexts, "verify_task", AsyncMock()) + full = SimpleNamespace(model_dump=lambda **kwargs: {"task": {"latest_progress": {"summary": "x" * 13000}}}) + service = SimpleNamespace(agent_context=AsyncMock(return_value=full)) + monkeypatch.setattr(query_service, "task_service", lambda scope: service) + summary = AsyncMock(return_value={"task": {"task_id": str(value.task_id)}, "selected_report": {"revision": 3}}) + monkeypatch.setattr(read_queries, "task_context_summary", summary, raising=False) + runtime.executor = query_service.execute_query + response = request(client, "tools/call", {"name": "get_task_context", "arguments": {"context_handle": value.context_handle, "report_revision": 3}}) + result = response.json()["result"] + assert response.status_code == 200 and not result.get("isError", False), result + assert result["structuredContent"]["data"]["selected_report"]["revision"] == 3 + service.agent_context.assert_not_awaited() + assert summary.await_args.kwargs["report_revision"] == 3 + + +def test_task_context_sections_support_projection_and_bounded_pages(): + from app.mcp.models import TaskContextInput + + args = TaskContextInput(context_handle="a" * 43, section="report", report_revision=3, + data_path=["report", "current_conclusion"], limit=100, offset=100) + assert args.section == "report" and args.offset == 100 + with pytest.raises(ValueError): + TaskContextInput(context_handle="a" * 43, section="evidence", offset=10001) + + +def test_concurrency_bound_is_not_a_successful_empty_query(runtime): + runtime, client, _, _ = runtime + runtime.active[(SUBJECT, str(PROJECT))] = 4 + query = { + "name": "list_elements", + "arguments": {"context_handle": "a" * 43, "element_type": "pipe"}, + } + assert ( + request(client, "tools/call", query).json()["result"]["structuredContent"][ + "error" + ]["code"] + == "RATE_LIMITED" + ) + assert runtime.active[(SUBJECT, str(PROJECT))] == 4 + + +def test_time_series_sql_is_bounded_and_field_names_cannot_inject_sql(): + from app.infra.db.postgresql import read_queries + + statements = [] + + class Cursor: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def execute(self, sql, params): + statements.append( + (sql.as_string() if hasattr(sql, "as_string") else sql, params) + ) + + async def fetchall(self): + return [] + + class Db(Connection): + def cursor(self, **options): + return Cursor() + + start = datetime.now(timezone.utc) + + async def check(): + await read_queries.series_rows( + Db(), + source="scada", + element_type="junction", + ids=["D'1"], + start=start, + end=start + timedelta(hours=1), + field="monitored_value", + limit=101, + offset=200, + ) + assert statements[-1][1][-2:] == (101, 200) + assert "LIMIT %s OFFSET %s" in statements[-1][0] + assert "D'1" not in statements[-1][0] + assert "statement_timeout" in statements[0][0] + with pytest.raises(ValueError): + await read_queries.series_rows( + Db(), + source="realtime", + element_type="pipe", + ids=["P1"], + time=start, + field="pressure; DROP TABLE network.nodes", + ) + + asyncio.run(check()) + + +def test_analysis_preserves_run_units_and_reports_inference(monkeypatch): + from app.mcp.models import AnalysisValuesInput + from unittest.mock import AsyncMock + + monkeypatch.setattr( + query_service, "permissions_for_context", lambda ctx: PERMISSIONS + ) + monkeypatch.setattr( + query_service.reads, "series_rows", AsyncMock(return_value=[{"value": 3.5}]) + ) + monkeypatch.setattr( + query_service.NetworkSettingsRepository, + "get_result_units", + AsyncMock(return_value={"pressure": "m"}), + ) + run = {"parameters": {"result_units": {"pressure": "bar"}}} + monkeypatch.setattr( + query_service.AnalysisRepository, + "get_run", + AsyncMock(side_effect=lambda *args: run), + ) + + class Scope: + project = object() + connection = Connection() + + @asynccontextmanager + async def timescale(self): + yield self.connection + + args = AnalysisValuesInput( + context_handle="a" * 43, + run_id=uuid4(), + element_type="junction", + field="pressure", + time=datetime.now(timezone.utc), + ) + + async def check(): + result = await query_service.execute_query( + Scope(), binding(), TOOLS["query_analysis_values"], args + ) + assert result.units == {"pressure": "bar"} and not result.data_gaps + run["parameters"] = {} + result = await query_service.execute_query( + Scope(), binding(), TOOLS["query_analysis_values"], args + ) + assert result.units == {"pressure": "m"} and "inferred" in result.data_gaps[0] + assert query_service.finite_data([float("nan"), 2.0, float("inf")]) == ( + [None, 2.0, None], + 2, + ) + + asyncio.run(check()) + + +def test_saved_evidence_pages_are_immutable_and_keep_synthetic_origin(monkeypatch): + from app.domain.schemas.operational_data import TaskDataSnapshot + from app.mcp.models import EvidenceInput + + monkeypatch.setattr( + query_service, "permissions_for_context", lambda ctx: PERMISSIONS + ) + snapshot = TaskDataSnapshot( + source="query_scada_readings", + source_ref="evidence:stored", + queried_at=datetime.now(timezone.utc), + origin="synthetic", + truncated=False, + data={ + "semantics": "observed", + "data": { + "items": [{"value": 1}, {"value": 2}], + "series": [{"points": [{"value": 3}, {"value": 4}]}], + }, + }, + ) + async def project(conn, *, full_snapshot=False, **options): + document = snapshot.model_dump(mode="json") + if full_snapshot: + return document + selected = document["data"]["data"] + for part in options["data_path"]: + selected = selected[int(part)] if isinstance(selected, list) else selected[part] + return { + "snapshot_metadata": {k: v for k, v in document.items() if k != "data"}, + "query_metadata": {"semantics": "observed"}, + "value_type": "array", "total": len(selected), + "value": selected[options["offset"]:options["offset"] + options["limit"]], + } + + monkeypatch.setattr(query_service.reads, "evidence_page", project) + scope = SimpleNamespace(project=SimpleNamespace(project_id=PROJECT), connection=Connection()) + value = binding(task=True) + + async def query(path, **options): + args = EvidenceInput( + context_handle=value.context_handle, + evidence_id=snapshot.source_ref, + data_path=path, + limit=1, + offset=1, + ) + return await query_service.execute_query( + scope, value, TOOLS["get_task_evidence"], args, **options + ) + + async def check(): + page = await query(["items"]) + assert page.data["value"] == [{"value": 2}] and page.page.total == 2 + assert ( + page.origin == "synthetic" + and page.evidence_id == page.source_ref == "evidence:stored" + and page.truncated + ) + assert (await query(["series", "0", "points"])).data["value"] == [{"value": 4}] + full = await query([], full_snapshot=True) + assert full.data == snapshot.model_dump(mode="json") + assert snapshot.data["data"]["items"] == [{"value": 1}, {"value": 2}] + + asyncio.run(check()) diff --git a/tests/unit/test_mcp_query_limits.py b/tests/unit/test_mcp_query_limits.py new file mode 100644 index 0000000..51d0703 --- /dev/null +++ b/tests/unit/test_mcp_query_limits.py @@ -0,0 +1,224 @@ +import asyncio +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.domain.schemas.timeseries_history import ElementHistoryQuery +from app.infra.db.postgresql import read_queries +from app.services import timeseries_history + + +START = datetime(2026, 10, 1, tzinfo=timezone.utc) + + +def test_history_pages_advance_across_raw_cleaned_and_model_series(monkeypatch): + monkeypatch.setattr( + timeseries_history.ScadaInfoRepository, "get_scadas_for_elements", + AsyncMock(return_value=[{ + "device_id": "D1", "device_type": "pressure", "node_id": "J1", + "link_id": None, "measurement_unit": "m", + }]), + ) + monkeypatch.setattr( + timeseries_history.ScadaRepository, "get_scada_by_ids_time_range", + AsyncMock(side_effect=AssertionError("must use paged SQL")), + ) + monkeypatch.setattr( + timeseries_history.NetworkSettingsRepository, "get_result_units", + AsyncMock(return_value={"pressure": "m"}), + ) + points = [ + {"series_index": i, "time": START + timedelta(minutes=j), "value": i * 10 + j} + for i in range(3) for j in range(2) + ] + + async def page(conn, descriptors, *, start, end, run_id, limit, offset): + assert len(descriptors) == 3 + return points[offset:offset + limit] + + monkeypatch.setattr(read_queries, "history_rows", page, raising=False) + query = ElementHistoryQuery( + start_time=START, end_time=START + timedelta(hours=1), + mode="realtime_comparison", elements=[{"element_type": "junction", "element_id": "J1"}], + ) + + async def check(): + seen = [] + for offset in (0, 2, 4, 6): + result = await timeseries_history.TimeseriesHistoryService.query( + object(), object(), query, row_limit=2, row_offset=offset, + ) + seen.extend(point.value for series in result.series for point in series.points) + assert result.truncated is (offset < 4) + assert seen == [0, 1, 10, 11, 20, 21] + + asyncio.run(check()) + + +class Connection: + def __init__(self, rows): + self.rows = rows + self.statements = [] + + @asynccontextmanager + async def transaction(self): + yield self + + def cursor(self, **kwargs): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def execute(self, statement, params): + self.statements.append((statement.as_string() if hasattr(statement, "as_string") else statement, params)) + + async def fetchall(self): + return self.rows + + +def test_history_sql_applies_one_page_to_all_sources(): + conn = Connection([]) + asyncio.run(read_queries.history_rows( + conn, [ + {"source": "scada", "element_type": "junction", "id": "D'1", "field": "monitored_value"}, + {"source": "analysis", "element_type": "pipe", "id": "P1", "field": "flow"}, + ], start=START, end=START + timedelta(hours=1), run_id=uuid4(), limit=3, offset=2, + )) + statement, params = conn.statements[-1] + assert "UNION ALL" in statement and "ORDER BY series_index, time" in statement + assert statement.endswith("LIMIT %s OFFSET %s") and params[-2:] == (3, 2) + assert "run_id=%s" in statement and "D'1" not in statement + + +def test_evidence_projection_is_scoped_paged_and_size_guarded_in_sql(): + payload = {"value": [2], "total": 3, "value_type": "array", "snapshot_metadata": {}, "query_metadata": None} + conn = Connection([{"payload": payload, "too_large": False}]) + result = asyncio.run(read_queries.evidence_page( + conn, project_id=uuid4(), task_id=uuid4(), evidence_id="E1", + data_path=["items"], limit=1, offset=1, mcp_sources=["query_scada_readings"], + )) + assert result == payload + statement, params = conn.statements[-1] + assert "project_id=%s AND task_id=%s AND evidence_id=%s" in statement + assert "#> %s::text[]" in statement and "LIMIT %s OFFSET %s" in statement + assert "octet_length" in statement and "CASE WHEN" in statement + assert ["items"] in params + + +def test_oversized_evidence_does_not_return_a_document(): + from fastapi import HTTPException + + conn = Connection([{"payload": None, "too_large": True}]) + with pytest.raises(HTTPException) as caught: + asyncio.run(read_queries.evidence_page( + conn, project_id=uuid4(), task_id=uuid4(), evidence_id="E1", + data_path=[], limit=1, offset=0, mcp_sources=[], + )) + assert caught.value.status_code == 413 + + +def test_demo_payload_has_a_size_gate_before_transfer(monkeypatch): + from fastapi import HTTPException + from app.mcp import peripheral + from app.mcp.models import PeripheralInput + + calls = [] + + async def rows(conn, statement, params): + calls.append(statement) + return [{"document": None, "digest": "unused", "too_large": True}] + + monkeypatch.setattr(peripheral, "read_rows", rows) + args = PeripheralInput( + context_handle="a" * 43, source="peripheral_observations", point_id="DEMO-1", + start=START, end=START + timedelta(hours=1), limit=1, + ) + with pytest.raises(HTTPException) as caught: + asyncio.run(peripheral.read_peripheral_query( + SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=uuid4())), args, + )) + assert caught.value.status_code == 413 + assert "CASE WHEN" in calls[0] and "octet_length" in calls[0] + + +def test_history_tool_forwards_offset_and_keeps_last_page_partial(monkeypatch): + from app.mcp import query_service + from app.mcp.catalog import TOOLS + from app.mcp.models import BoundContext, HistoryInput + + conn = Connection([]) + scope = SimpleNamespace(connection=conn, project=object(), timescale=conn.transaction) + binding = BoundContext( + context_handle="a"*43, project_id=uuid4(), subject="u", client_id="host", + tool_cap=["query_element_history"], permission_cap=["scada.view"], + native_session_id="s", purpose="ordinary_chat", expires_at=START, + ) + monkeypatch.setattr(query_service, "permissions_for_context", lambda scope: {"scada.view"}) + from app.domain.schemas.timeseries_history import ElementHistoryResponse + + reader = AsyncMock(return_value=ElementHistoryResponse(series=[], truncated=False)) + monkeypatch.setattr(timeseries_history.TimeseriesHistoryService, "query", reader) + args = HistoryInput( + context_handle="a"*43, start=START, end=START+timedelta(hours=1), + elements=[{"element_type": "junction", "element_id": "J1"}], limit=2, offset=4, + ) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_element_history"], args)) + assert reader.await_args.kwargs == {"row_limit": 2, "row_offset": 4} + assert result.page.offset == 4 and not result.page.has_more and result.truncated + + +def test_task_context_retains_selected_report_revision(monkeypatch): + from app.mcp import query_service + from app.mcp.catalog import TOOLS + from app.mcp.models import BoundContext, TaskContextInput + + reader = AsyncMock(return_value={"selected_report": {"revision": 3}}) + monkeypatch.setattr(query_service.reads, "task_context_summary", reader) + monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"}) + task_id = uuid4() + binding = BoundContext( + context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host", + tool_cap=["get_task_context"], permission_cap=["operational_task.view"], + native_session_id="s", purpose="task_collaboration", task_id=task_id, + expires_at=START, + ) + scope = SimpleNamespace(connection=Connection([]), project=object()) + args = TaskContextInput(context_handle="a" * 43, report_revision=3) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], args)) + reader.assert_awaited_once_with(scope.connection, project_id=binding.project_id, task_id=task_id, report_revision=3) + assert result.data["selected_report"]["revision"] == 3 + with pytest.raises(ValueError): + TaskContextInput(context_handle="a" * 43, report_revision=0) + + +@pytest.mark.parametrize("section", ["evidence", "actions", "access_requests"]) +def test_task_context_collection_pages_use_database_total_and_stable_paths(monkeypatch, section): + from app.mcp import query_service + from app.mcp.catalog import TOOLS + from app.mcp.models import BoundContext, TaskContextInput + + reader = AsyncMock(return_value={ + "total": 2, "value_type": "object", "nested": True, + "snapshot_metadata": {"section": section, "total": 3}, + "value": {"stable-id": {"collection_type": "object", "data_path": ["stable-id"], "count": 4}}, + }) + monkeypatch.setattr(query_service.reads, "task_context_page", reader) + monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"}) + binding = BoundContext(context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host", + tool_cap=["get_task_context"], permission_cap=["operational_task.view"], native_session_id="s", + purpose="task_collaboration", task_id=uuid4(), expires_at=START) + scope = SimpleNamespace(connection=Connection([]), project=object()) + args = TaskContextInput(context_handle="a" * 43, section=section, limit=1, offset=1) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], args)) + assert result.page.total == 3 and result.page.has_more and result.page.offset == 1 + assert result.truncated and result.data["value"]["stable-id"]["data_path"] == ["stable-id"] + reader.assert_awaited_once_with(scope.connection, project_id=binding.project_id, task_id=binding.task_id, + section=section, report_revision=None, data_path=[], limit=1, offset=1) diff --git a/tests/unit/test_mcp_spatial.py b/tests/unit/test_mcp_spatial.py new file mode 100644 index 0000000..0ad4a4d --- /dev/null +++ b/tests/unit/test_mcp_spatial.py @@ -0,0 +1,94 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from pydantic import ValidationError + +from app.infra.db.postgresql import spatial_queries +from app.mcp.models import NearbyElementsInput +from app.mcp import query_service +from app.mcp.catalog import TOOLS +from fastapi import HTTPException + + +def test_nearby_input_uses_geocoder_coordinate_order_and_bounded_defaults(): + args = NearbyElementsInput(context_handle="a" * 43, center=[121.5, 30.9]) + assert args.center == (121.5, 30.9) + assert (args.element_type, args.radius_m, args.limit, args.offset) == ("pipe", 200, 5, 0) + schema = NearbyElementsInput.model_json_schema() + assert schema["properties"]["center"]["prefixItems"][0]["maximum"] == 180 + assert schema["properties"]["center"]["prefixItems"][1]["maximum"] == 90 + + +@pytest.mark.parametrize("overrides", [ + {"center": [181, 30]}, {"center": [121, 91]}, {"center": [121]}, + {"center": [121, 30, 0]}, {"center": [float("nan"), 30]}, + {"center": [121, float("inf")]}, {"radius_m": 0}, {"radius_m": 10001}, + {"radius_m": float("inf")}, {"limit": 101}, {"offset": 10001}, + {"element_type": "pipe; DROP TABLE network.pipes"}, {"source": "source_gis"}, + {"project_id": "foreign"}, {"sql": "SELECT * FROM network.pipes"}, +]) +def test_nearby_rejects_invalid_or_unscoped_parameters(overrides): + with pytest.raises(ValidationError): + NearbyElementsInput.model_validate({ + "context_handle": "a" * 43, "center": [121.5, 30.9], **overrides, + }) + + +def test_nearby_sql_limits_before_transfer_and_measures_complete_geometries(monkeypatch): + rows = [{"element_id": "P1", "distance_m": 3.5, "properties": {"diameter": 300}}] + read = AsyncMock(return_value=rows) + monkeypatch.setattr(spatial_queries, "read_rows", read) + result = asyncio.run(spatial_queries.nearby_elements( + object(), "pipe", center=(121.5, 30.9), radius_m=500, limit=6, offset=10, + )) + assert result == rows + _, statement, parameters = read.call_args.args + query = statement.as_string() + assert 'FROM "gis"."pipes"' in query + assert "v.geom && ST_Transform" in query + assert "ST_Transform(v.geom,4326)::geography" in query + assert "ST_DWithin(c.shape,t.point,%s,true)" in query + assert "ST_Distance(c.shape,t.point,true)" in query + assert "ST_StartPoint" not in query and "ST_EndPoint" not in query + assert "ORDER BY distance_m,c.id" in query + assert "LIMIT %s OFFSET %s" in query + assert parameters[:2] == (121.5, 30.9) and parameters[-3:] == (500, 6, 10) + + +def test_candidate_envelopes_split_at_dateline_and_skip_mercator_poles(): + boxes = spatial_queries._search_boxes(179.9999, 30, 500) + assert len(boxes) == 2 + assert all(-180 <= left < right <= 180 for left, _, right, _ in boxes) + assert spatial_queries._search_boxes(0, 90, 500) == [] + assert spatial_queries._search_boxes(0, -90, 500) == [] + + +def test_nearby_dispatch_preserves_scope_paging_units_and_current_permissions(monkeypatch): + connection = object() + scope = SimpleNamespace(connection=connection, project=object()) + binding = SimpleNamespace( + tool_cap=["query_nearby_elements"], permission_cap=["webgis.view"], + task_id=None, purpose="ordinary_chat", task_scope_cap=[], + ) + args = NearbyElementsInput(context_handle="a" * 43, center=[121.5, 30.9], limit=2, offset=1) + rows = [{"element_id": f"P{i}", "properties": {}, "distance_m": i} for i in (1, 2, 3)] + read = AsyncMock(return_value=rows) + monkeypatch.setattr(query_service, "nearby_elements", read) + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.view"}) + result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_nearby_elements"], args)) + assert result.data["items"] == rows[:2] + assert result.data["center"] == [121.5, 30.9] + assert result.data["center_crs"] == "EPSG:4326" + assert result.data["source"] == "model_network" + assert result.page.has_more and result.page.offset == 1 and result.truncated + assert result.units["distance_m"] == "m" and result.semantics == "asset" + assert read.call_args.args == (connection, "pipe") + assert read.call_args.kwargs == {"center": (121.5, 30.9), "radius_m": 200, "limit": 3, "offset": 1} + read.reset_mock() + monkeypatch.setattr(query_service, "permissions_for_context", lambda _: set()) + with pytest.raises(HTTPException) as denied: + asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_nearby_elements"], args)) + assert denied.value.status_code == 403 + read.assert_not_called() diff --git a/tests/unit/test_mcp_work_order_skills.py b/tests/unit/test_mcp_work_order_skills.py new file mode 100644 index 0000000..40fa227 --- /dev/null +++ b/tests/unit/test_mcp_work_order_skills.py @@ -0,0 +1,73 @@ +import asyncio +import json +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.mcp import query_service +from app.mcp.catalog import TOOLS +from app.mcp.models import BoundContext, TaskContextInput +from app.mcp.work_order_skills import read_work_order_skill + + +@pytest.mark.parametrize("workflow,name", [ + ("burst_report", "burst-report"), + ("water_quality_complaint", "water-quality-complaint"), + ("general", "appointment-follow-up"), +]) +def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name): + descriptor = read_work_order_skill(workflow) + body = read_work_order_skill(workflow, include_content=True) + assert descriptor == {key: value for key, value in body.items() if key != "instructions"} + assert body["name"] == name and body["version"] + assert len(body["content_sha256"]) == 64 and body["instructions"] + assert "instructions" not in descriptor + # MCP task context has no evidence resource fallback: the complete instructions + # and result envelope must fit its inline response budget. + assert len(json.dumps(body, ensure_ascii=False).encode()) < 10000 + + +@pytest.mark.parametrize("workflow", [None, "unknown", "../../secrets", "burst-report"]) +def test_unknown_workflow_never_falls_back_or_reads_an_arbitrary_path(workflow): + assert read_work_order_skill(workflow)["available"] is False + + +def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch): + task_id, project_id = uuid4(), uuid4() + reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}}) + monkeypatch.setattr(query_service.reads, "task_context_summary", reader) + monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"}) + scope = SimpleNamespace(connection=object(), project=object()) + binding = BoundContext( + context_handle="a" * 43, project_id=project_id, subject="u", client_id="host", + native_session_id="s", purpose="task_execution", task_id=task_id, + tool_cap=["get_task_context"], permission_cap=["operational_task.view"], + expires_at=datetime.now(timezone.utc), + ) + + def query(section): + return asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], + TaskContextInput(context_handle=binding.context_handle, section=section))) + + summary = query("summary") + body = query("skill") + assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"] + assert summary.data["display_time_zone"] == "Asia/Shanghai" + assert body.data["skill"]["workflow"] == "burst_report" + assert body.data["skill"]["instructions"] and not body.truncated + assert body.evidence_id is None and body.semantics == "task" + reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None) + binding.permission_cap = [] + with pytest.raises(HTTPException) as caught: + query("skill") + assert caught.value.status_code == 403 and reader.await_count == 2 + + +@pytest.mark.parametrize("extra", [{"workflow": "general"}, {"data_path": ["instructions"]}, {"offset": 1}]) +def test_skill_input_cannot_select_another_workflow_or_partial_instructions(extra): + with pytest.raises(ValueError): + TaskContextInput(context_handle="a" * 43, section="skill", **extra) diff --git a/tests/unit/test_online_simulation.py b/tests/unit/test_online_simulation.py new file mode 100644 index 0000000..9d29c67 --- /dev/null +++ b/tests/unit/test_online_simulation.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta +from unittest.mock import Mock + +import pytest + +from app.services import simulation + + +START = datetime.fromisoformat("2026-10-08T12:00:00+08:00") + + +def test_online_simulation_uses_realtime_steps_and_returns_requested_samples(monkeypatch): + calls = [] + mappings = object() + monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:15:00"}) + monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id", lambda _: mappings) + + def run(**kwargs): + calls.append(kwargs) + return { + "units": {"pressure": "MTR", "flow": "LPS"}, + "node_results": [{"node": "123422", "result": [{"pressure": len(calls)}]}, {"node": "other", "result": [{}]}], + "link_results": [{"link": "540672", "result": [{"flow": len(calls)}]}], + } + + monkeypatch.setattr(simulation, "run_simulation", run) + result = simulation.run_online_simulation("online", START, 3600, output_node_ids=["123422"], output_link_ids=["540672"]) + assert calls == [{ + "name": "online", "simulation_type": "realtime", + "modify_pattern_start_time": (START + timedelta(minutes=15 * index)).isoformat(timespec="seconds"), + "scada_mappings": mappings, + } for index in range(4)] + assert result["node_results"] == [{"node": "123422", "result": [{"pressure": index} for index in range(1, 5)]}] + assert result["link_results"] == [{"link": "540672", "result": [{"flow": index} for index in range(1, 5)]}] + assert result["times"]["num_periods"] == 4 + assert result["times"]["report_step"] == 900 + assert result["times"]["sample_times"][-1] == "2026-10-08T12:45:00+08:00" + + +def test_online_simulation_rejects_invalid_step_before_calculating(monkeypatch): + monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:00:00"}) + run = Mock() + monkeypatch.setattr(simulation, "run_simulation", run) + with pytest.raises(ValueError, match="HYDRAULIC TIMESTEP"): + simulation.run_online_simulation("online", START, 3600) + run.assert_not_called() + + +def test_mcp_online_simulation_does_not_copy_a_database(monkeypatch): + from app.mcp.scenario_service import calculate_scenario + + def reject_copy(*_): + pytest.fail("Online simulation must not clone the project database") + + run = Mock(return_value={"node_results": [], "link_results": [], "times": {}, "units": {}}) + monkeypatch.setattr("app.native.wndb.core.projects.temporary_project_database", reject_copy) + monkeypatch.setattr(simulation, "run_online_simulation", run, raising=False) + result = calculate_scenario("run_simulation", { + "start_time": START, "duration": 3600, + "output_node_ids": ["123422", "123425"], "output_link_ids": ["540672"], + }, network="online", username="actor") + run.assert_called_once_with("online", START, 3600, output_node_ids=["123422", "123425"], output_link_ids=["540672"]) + assert result["input_source"] == "online_model_simulation" + assert result["persisted_baseline"] is True + assert result["persisted_scheme"] is False diff --git a/tests/unit/test_operational_data.py b/tests/unit/test_operational_data.py index 6a1d8c4..8d5cd7b 100644 --- a/tests/unit/test_operational_data.py +++ b/tests/unit/test_operational_data.py @@ -6,7 +6,7 @@ from pydantic import ValidationError from app.auth.project_dependencies import ProjectContext from app.api.v1.endpoints.operational_tasks import _task_permissions -from app.auth.permissions import SCADA_VIEW, SIMULATION_VIEW +from app.auth.permissions import SCADA_VIEW, SIMULATION_VIEW, permissions_for_context from app.domain.schemas.operational_data import TaskDataQuery from app.infra.integrations.operational_data import TaskDataError, TaskDataSettings, read_task_data @@ -133,12 +133,42 @@ def test_query_is_a_finite_read_scope_with_bounded_time_and_device_count(): TaskDataQuery(source="peripheral_observations") +@pytest.mark.anyio +@pytest.mark.parametrize("source", ["scada_devices", "peripheral_work_orders"]) +async def test_task_service_reads_with_existing_project_permissions(source): + ctx = ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", + project_role="member", system_role="service") + permissions = _task_permissions(ctx) + assert permissions == set(permissions_for_context(ctx)) + assert {SCADA_VIEW, SIMULATION_VIEW} <= permissions + + def handler(request): + assert request.method == "GET" + assert request.headers["Authorization"] == "Bearer AGENT" + assert request.headers["X-Project-Id"] == str(ctx.project_id) + return httpx.Response(200, json={"items": [], "project_id": str(ctx.project_id)}) + + snapshot = await read_task_data( + context=ctx, credential="Bearer AGENT", query=TaskDataQuery(source=source), + settings=settings(), transport=httpx.MockTransport(handler), + ) + assert snapshot.source == source + + +@pytest.mark.anyio +async def test_service_without_project_read_permission_does_not_gain_access(): + ctx = ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", + project_role="", system_role="service") + assert not {SCADA_VIEW, SIMULATION_VIEW} & _task_permissions(ctx) + with pytest.raises(TaskDataError, match="没有该数据源的读取权限"): + await read_task_data(context=ctx, credential="Bearer AGENT", + query=TaskDataQuery(source="scada_devices"), settings=settings()) + + @pytest.mark.anyio async def test_task_service_uses_approved_delegation_only_during_source_read(): ctx = ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", project_role="member", system_role="service") - assert SCADA_VIEW not in _task_permissions(ctx) - assert SIMULATION_VIEW not in _task_permissions(ctx) query = TaskDataQuery(source="scada_devices") with pytest.raises(TaskDataError, match="委托身份尚未配置"): await read_task_data(context=ctx, credential="Bearer AGENT", query=query, diff --git a/tests/unit/test_operational_presentation.py b/tests/unit/test_operational_presentation.py new file mode 100644 index 0000000..cbd3eac --- /dev/null +++ b/tests/unit/test_operational_presentation.py @@ -0,0 +1,56 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from app.domain.domain_errors import DomainError +from app.domain.schemas.operational_data import TaskDataSnapshot +from app.domain.schemas.operational_task import TaskPresentationEventCommand +from app.services.operational_presentation import resolve_overlay + + +def _snapshot(data): + return TaskDataSnapshot(source="workbench_runtime", source_ref="stored-spatial-evidence", + queried_at=datetime(2026, 10, 10, tzinfo=timezone.utc), + origin="system_data", data=data, truncated=False) + + +def test_overlay_preserves_recorded_polygon_and_never_converts_it_to_heatmap_points(): + geometry = {"type": "Polygon", "coordinates": [[[121, 31], [122, 31], [122, 32], [121, 31]]]} + collection = {"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": geometry, + "properties": {"source": "actual-analysis"}}]} + snapshot = _snapshot({"latest_analysis": {"map_payload": collection}}) + assert resolve_overlay(snapshot, "geometry")["features"][0]["geometry"] == geometry + with pytest.raises(DomainError) as error: + resolve_overlay(snapshot, "heatmap") + assert error.value.code == "OVERLAY_HEATMAP_POINTS_REQUIRED" + + +@pytest.mark.parametrize("data", [ + {"items": [{"location": "某小区,无法仅凭地址确定坐标"}]}, + {"items": [{"longitude": True, "latitude": 31}]}, + {"items": [{"longitude": 121, "latitude": 91}]}, + {"items": [{"longitude": 121, "latitude": 31, "weight": -1}]}, + {"type": "FeatureCollection", "features": []}, +]) +def test_overlay_rejects_missing_or_invalid_recorded_spatial_data(data): + with pytest.raises(DomainError): + resolve_overlay(_snapshot(data), "heatmap") + + +@pytest.mark.parametrize("command", [ + {"action": "run_script", "code": "arbitrary"}, + {"action": "highlight_features", "layer": "arbitrary-layer", "feature_ids": ["X"]}, + {"action": "set_overlay", "overlay_id": "X", "title": "X", "mode": "geometry", "evidence_id": "E", + "data": {"type": "FeatureCollection", "features": []}}, + {"action": "open_chart", "chart_id": "X", "evidence_refs": ["E"], "chart": { + "title": "X", "chart_type": "line", "x_data": ["t1", "t2"], "series": [{"name": "s", "data": [1]}], + }}, + {"action": "open_chart", "chart_id": "X", "evidence_refs": ["E"], "chart": { + "title": "X", "chart_type": "line", "x_data": ["t1"], "series": [{"name": "s", "data": [True]}], + }}, +]) +def test_presentation_input_rejects_free_form_or_invalid_commands(command): + with pytest.raises(ValidationError): + TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command) diff --git a/tests/unit/test_operational_task_storage_readiness.py b/tests/unit/test_operational_task_storage_readiness.py new file mode 100644 index 0000000..91ea719 --- /dev/null +++ b/tests/unit/test_operational_task_storage_readiness.py @@ -0,0 +1,58 @@ +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.domain.domain_errors import DomainError +from app.domain.schemas.operational_task import AgentRunCommand +from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository +from app.services.operational_tasks import OperationalTaskService + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +@pytest.mark.parametrize("available", [False, True]) +async def test_execution_requires_message_storage(available): + cursor = SimpleNamespace(fetchone=AsyncMock(return_value={"available": available})) + connection = SimpleNamespace(execute=AsyncMock(return_value=cursor)) + repository = OperationalTaskRepository(connection, uuid4()) + if available: + await repository.require_execution_storage() + else: + with pytest.raises(DomainError) as failure: + await repository.require_execution_storage() + assert failure.value.status == 503 + assert failure.value.code == "DEPENDENCY_UNAVAILABLE" + assert "024_agent_messages.sql" in failure.value.message + assert "to_regclass('workbench.agent_messages')" in connection.execute.call_args.args[0] + + +@pytest.mark.anyio +async def test_missing_message_storage_rejects_claim_before_state_changes(): + @asynccontextmanager + async def transaction(): + yield + + repository = SimpleNamespace( + transaction=transaction, + require_execution_storage=AsyncMock(side_effect=DomainError( + "DEPENDENCY_UNAVAILABLE", "Apply 024_agent_messages.sql", status=503, + )), + get_request=AsyncMock(return_value=None), + lock=AsyncMock(), update_run=AsyncMock(), update_task=AsyncMock(), + ) + service = OperationalTaskService(repository, SimpleNamespace(), set()) + with pytest.raises(DomainError, match="024_agent_messages.sql"): + await service.claim_run(uuid4(), uuid4(), AgentRunCommand( + request_id=uuid4(), expected_revision=1, source_session_id="test-session", + )) + repository.get_request.assert_not_awaited() + repository.lock.assert_not_awaited() + repository.update_run.assert_not_awaited() + repository.update_task.assert_not_awaited() diff --git a/tests/unit/test_operational_tasks.py b/tests/unit/test_operational_tasks.py index d5bb25b..1f1857a 100644 --- a/tests/unit/test_operational_tasks.py +++ b/tests/unit/test_operational_tasks.py @@ -35,6 +35,8 @@ from app.domain.schemas.operational_task import ( TaskAccessRequest, WorkOrderDraftUpdateCommand, WorkOrderConfirmDispatchCommand, + TaskPresentationEventCommand, + StoredEvidenceRecord, ) from app.domain.schemas.operational_data import TaskDataSnapshot from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository @@ -265,6 +267,7 @@ class TaskRepository: self.feedback = {} self.plans = [] self.reports = [] + self.progress = {} self.notifications = [] self.locks = [] self.access_requests = {} @@ -297,6 +300,12 @@ class TaskRepository: async def get_latest_report(self, _task_id): return self.reports[-1] if self.reports else None + async def get_latest_progress(self, task_id, generation): + return self.progress.get((task_id, generation)) + + async def save_progress(self, task_id, snapshot): + self.progress[(task_id, snapshot["execution_generation"])] = snapshot + async def get_report(self, _task_id, revision): return next((item for item in self.reports if item["revision"] == revision), None) @@ -404,6 +413,9 @@ class RunTaskRepository(TaskRepository): self.events.append(event) return len(self.events) + async def current_event_cursor(self): + return len(self.events) + def _active_run(task, now): return OperationalTaskRun( @@ -415,6 +427,258 @@ def _active_run(task, now): ).model_dump(mode="json") +@pytest.mark.anyio +async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph(): + now = datetime(2026, 10, 10, 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) + first = AgentArtifactsCommand.model_validate({ + "request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"], + "progress": {"summary": "正在核对输入,尚未形成结论。", "diagram": { + "title": "执行进展", "mermaid": "flowchart LR\nfirst[开始查询]", + "node_refs": [{"node_id": "first", "label": "开始查询", "status": "running", + "map_targets": [{"source_id": "pipes", "feature_id": "P-1"}]}], + }}, + }) + initial = await service.publish_artifacts(task["task_id"], first) + assert initial.progress_revision == 1 + assert initial.task.latest_report is None and initial.task.latest_plan is None + assert initial.task.active_run.status == "running" + assert (await service.publish_artifacts(task["task_id"], first)).progress_revision == 1 + assert len([event for event in repository.events if event["topic"] == "operational.progress.changed"]) == 1 + replacement = AgentArtifactsCommand.model_validate({ + "request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"], + "progress": {"summary": "重新核查另一条路径。", "diagram": { + "title": "新脉络", "mermaid": "flowchart TD\nother[核查新路径]", + "node_refs": [{"node_id": "other", "label": "核查新路径", "status": "running"}], + }}, + }) + updated = await service.publish_artifacts(task["task_id"], replacement) + assert updated.progress_revision == 2 + assert [node.node_id for node in updated.task.latest_progress.progress.diagram.node_refs] == ["other"] + cleared = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"], + progress={"summary": "当前不需要图示。", "diagram": None}, + )) + assert cleared.progress_revision == 3 + assert cleared.task.latest_progress.progress.diagram is None + assert not repository.notifications + + +@pytest.mark.anyio +@pytest.mark.parametrize("kind", ["progress", "presentation"]) +@pytest.mark.parametrize("invalid", ["run", "session", "lease", "finished"]) +async def test_reading_publications_reject_stale_execution_ownership(kind, invalid): + now = datetime(2026, 10, 10, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + run = _active_run(task, now) + if invalid == "lease": + run["lease_expires_at"] = now.isoformat() + elif invalid == "finished": + run["status"] = "succeeded" + repository = RunTaskRepository(task, run) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"], + "source_session_id": "other-session" if invalid == "session" else "session-1"} + with pytest.raises(DomainError): + if kind == "progress": + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + **identity, progress={"summary": "执行中"}, + )) + else: + await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( + **identity, command={"action": "clear_highlights"}, + )) + assert repository.progress == {} and repository.events == [] + + +@pytest.mark.anyio +async def test_progress_unknown_evidence_does_not_publish_or_authorize_actions(): + now = datetime(2026, 10, 10, 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) + with pytest.raises(DomainError): + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", + progress={"summary": "执行中", "diagram": {"title": "核查", "mermaid": "graph LR\nA[核查]", + "node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["unknown"]}]}}, + )) + assert not repository.progress + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", + progress={"summary": "查询完成,正在判断"}, + )) + with pytest.raises(DomainError) as error: + await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="不能用进展授权动作", + payload={"title": "现场核查", "description": "核查", "work_order_type": "sampling"}, + )) + assert error.value.code == "ACTION_CONCLUSION_REQUIRED" + + +@pytest.mark.anyio +async def test_presentation_cursor_and_event_are_read_only_and_idempotent(): + now = datetime(2026, 10, 10, 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) + baseline = await service.presentation_cursor(task["task_id"]) + assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"] + command = TaskPresentationEventCommand( + request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", + command={"action": "highlight_features", "layer": "pipes", "feature_ids": ["P-1"], "effect": "pulse"}, + ) + result = await service.publish_presentation_event(task["task_id"], command) + assert (await service.publish_presentation_event(task["task_id"], command)) == result + assert (await service.presentation_cursor(task["task_id"])).event_cursor == result.event_seq + assert repository.task["revision"] == task["revision"] + assert len(repository.events) == 1 + assert repository.events[0]["metadata"]["command"]["effect"] == "pulse" + with pytest.raises(DomainError): + await service.presentation_cursor(uuid4()) + + +@pytest.mark.anyio +async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data(): + now = datetime(2026, 10, 10, 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) + snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now, + origin="system_data", truncated=False, + data={"items": [{"device_id": "D-1", "longitude": 121.4, "latitude": 31.1}]}) + evidence = StoredEvidenceRecord(evidence_id="verified-points", kind="system_data", summary="实际坐标", + source_label="SCADA", observed_at=now, recorded_at=now, recorded_by="server", + has_snapshot=True, data_snapshot=snapshot) + repository.evidence[evidence.evidence_id] = evidence.model_dump(mode="json") + identity = {"run_id": repository.run["run_id"], "source_session_id": "session-1"} + overlay = {"action": "set_overlay", "overlay_id": "points", "title": "监测点分布", "mode": "heatmap"} + await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( + request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id}, + )) + emitted = repository.events[-1]["metadata"]["command"]["data"] + assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1] + assert emitted["features"][0]["properties"]["weight"] == 1 + with pytest.raises(DomainError) as missing: + await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( + request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"}, + )) + assert missing.value.code == "EVIDENCE_NOT_FOUND" + repository.evidence[evidence.evidence_id]["data_snapshot"]["data"] = { + "type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [121, 31]}, + "properties": {"label": "x" * (512 * 1024)}}], + } + with pytest.raises(DomainError) as oversized: + await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( + request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id}, + )) + assert oversized.value.code == "PRESENTATION_TOO_LARGE" + assert len(repository.events) == 1 + + +@pytest.mark.anyio +async def test_completed_investigation_resumes_once_for_linked_new_field_feedback(): + now = datetime(2026, 10, 10, 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) + _seed_concluded_report(repository, now) + action = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="现场检测", + payload={"title": "现场检测", "description": "回填复测结果", "work_order_type": "sampling"}, + )) + child_id = str(uuid4()) + repository.action = action.model_copy(update={ + "approval_status": "approved", "execution_status": "succeeded", + }).model_dump(mode="json") + repository.action["receipt"] = { + "adapter": "peripheral_sample", "receipt_id": child_id, "submitted_at": now.isoformat(), + "completed_at": now.isoformat(), "external_status": "open", "detail": "工单已创建,现场未完成", + } + repository.run["status"] = "succeeded" + repository.task.update(status="completed", lifecycle_status="completed", agent_session_id=None, + agent_lease_expires_at=None) + identity = {"source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2, + "status": "completed", "occurred_at": now, "detail": "现场复测通过"} + report_before = repository.reports[-1].copy() + command = ExternalFeedbackCommand(request_id=uuid4(), **identity) + accepted = await service.external_feedback(task["task_id"], command) + assert accepted.task.execution_generation == 2 and accepted.task.active_run.status == "queued" + assert repository.reports[-1] == report_before + assert (await service.external_feedback(task["task_id"], command)).task.execution_generation == 2 + # Source-version deduplication also survives a caller changing the request ID. + again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity)) + assert again.task.execution_generation == 2 + assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1 + repository.task.update(status="cancelled", lifecycle_status="cancelled") + with pytest.raises(DomainError) as cancelled: + await service.external_feedback(task["task_id"], ExternalFeedbackCommand( + request_id=uuid4(), **{**identity, "source_revision": 3}, + )) + assert cancelled.value.code == "TASK_NOT_ACTIONABLE" + assert repository.task["execution_generation"] == 2 + + +@pytest.mark.anyio +async def test_completed_investigation_rejects_unlinked_or_initial_child_feedback(): + now = datetime(2026, 10, 10, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=None) + task.update(status="completed", lifecycle_status="completed") + run = _active_run(task, now) + run["status"] = "succeeded" + repository = RunTaskRepository(task, run) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + with pytest.raises(DomainError) as unlinked: + await service.external_feedback(task["task_id"], ExternalFeedbackCommand( + request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}", source_revision=2, + status="completed", occurred_at=now, detail="无关联的工单", + )) + assert unlinked.value.code == "EXTERNAL_SOURCE_MISMATCH" + assert repository.task["execution_generation"] == 1 and repository.feedback == {} + child_id = str(uuid4()) + repository.action = {"kind": "create_work_order", "receipt": { + "adapter": "peripheral_sample", "receipt_id": child_id, + }} + with pytest.raises(DomainError) as initial: + await service.external_feedback(task["task_id"], ExternalFeedbackCommand( + request_id=uuid4(), source_event_id=f"peripheral_sample:{child_id}", source_revision=1, + status="assigned", occurred_at=now, detail="工单创建,尚未回填", + )) + assert initial.value.code == "INITIAL_CHILD_FEEDBACK" + assert repository.feedback == {} + + +@pytest.mark.anyio +async def test_original_complaint_feedback_advances_its_source_revision(): + now = datetime(2026, 10, 10, tzinfo=timezone.utc) + context = _context() + source_id = str(uuid4()) + task = _task_document(context, now, lease=None) + task.update(status="completed", lifecycle_status="completed", + source={"kind": "external_system", "external_id": source_id, "external_revision": 1}) + run = _active_run(task, now) + run["status"] = "succeeded" + repository = RunTaskRepository(task, run) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}", + source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息") + accepted = await service.external_feedback(task["task_id"], command) + assert accepted.task.source.external_revision == 2 + assert accepted.task.execution_generation == 2 + with pytest.raises(DomainError) as stale: + await service.external_feedback(task["task_id"], command.model_copy(update={"request_id": uuid4(), "source_revision": 1})) + assert stale.value.code == "STALE_EXTERNAL_REVISION" + + @pytest.mark.anyio async def test_run_heartbeat_does_not_change_task_business_revision(): now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) @@ -804,6 +1068,62 @@ async def test_report_history_and_notifications_keep_selected_and_current_refere assert other_task.value.status == 404 +@pytest.mark.anyio +async def test_final_report_archives_its_progress_diagram_across_new_rounds(): + now = datetime(2026, 10, 9, 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) + diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]", + "node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]} + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"], + progress={"summary": "正在整理判断", "diagram": diagram}, + )) + command = AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report={**_current_report(), "diagram": None}, + ) + await service.publish_artifacts(task["task_id"], command) + assert repository.reports[-1]["report"]["diagram"]["title"] == "调查过程" + assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "running" + assert command.report.diagram is None # Do not change the idempotency request. + repository.task["execution_generation"] = 2 + detail = await service.detail(task["task_id"]) + assert detail.latest_progress is None + assert detail.latest_report.report.diagram.title == "调查过程" + + +@pytest.mark.anyio +@pytest.mark.parametrize("mismatch", [None, "session", "future", "evidence", "cleared"]) +async def test_legacy_report_diagram_reads_only_matching_prepublication_progress(mismatch): + now = datetime(2026, 10, 9, 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) + repository.reports.append({ + "revision": 1, "execution_generation": 1, "source_session_id": "session-1", + "published_at": now.isoformat(), "published_by": "agent", "report": _current_report(), + }) + repository.progress[(task["task_id"], 1)] = { + "revision": 1, "execution_generation": 1, "run_id": repository.run["run_id"], + "source_session_id": "another-session" if mismatch == "session" else "session-1", + "published_at": (now + timedelta(seconds=1) if mismatch == "future" else now - timedelta(seconds=1)).isoformat(), + "progress": {"summary": "报告发布前的核查", "diagram": None if mismatch == "cleared" else { + "title": "已保存的过程图", "mermaid": "flowchart LR\nA[核查]", + "node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["not-in-report"] if mismatch == "evidence" else []}], + }}, + } + repository.task["execution_generation"] = 2 + detail = await service.detail(task["task_id"]) + selected = await service.report(task["task_id"], 1) + history, _ = await service.list_reports(task["task_id"], 20, 0) + for report in (detail.latest_report, selected, history[0]): + assert (report.report.diagram is not None) is (mismatch is None) + assert repository.reports[0]["report"].get("diagram") is None # Read compatibility never rewrites history. + + @pytest.mark.anyio @pytest.mark.parametrize("node_ref,code", [ ({"node_id": "A", "label": "依据", "evidence_refs": ["forged"]}, "EVIDENCE_NOT_FOUND"), diff --git a/tests/unit/test_operational_worker.py b/tests/unit/test_operational_worker.py index 9b9397a..6ca1927 100644 --- a/tests/unit/test_operational_worker.py +++ b/tests/unit/test_operational_worker.py @@ -1,4 +1,5 @@ import json +import os from uuid import uuid4 import httpx @@ -12,6 +13,114 @@ def anyio_backend(): return "asyncio" +@pytest.mark.parametrize("environment,use_dev_url", [ + ("development", True), ("production", False), ("staging", False), (None, False), +]) +def test_worker_dev_token_url_is_only_used_in_development(monkeypatch, environment, use_dev_url): + monkeypatch.setattr("scripts.run_operational_worker.load_dotenv", lambda *args, **kwargs: None) + for key in list(os.environ): + if key.startswith("OPERATIONAL_WORKER_") or key == "ENVIRONMENT": + monkeypatch.delenv(key) + if environment: + monkeypatch.setenv("ENVIRONMENT", environment) + monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_ID", str(uuid4())) + monkeypatch.setenv("OPERATIONAL_WORKER_CLIENT_ID", "test-service") + monkeypatch.setenv("OPERATIONAL_WORKER_CLIENT_SECRET", "TEST_ONLY_SECRET") + standard_url = "https://identity.test/token" + dev_url = "http://192.168.1.10:8080/token" + monkeypatch.setenv("OPERATIONAL_WORKER_TOKEN_URL", standard_url) + monkeypatch.setenv("OPERATIONAL_WORKER_DEV_TOKEN_URL", dev_url) + assert WorkerSettings.from_environment().token_url == (dev_url if use_dev_url else standard_url) + monkeypatch.delenv("OPERATIONAL_WORKER_DEV_TOKEN_URL") + assert WorkerSettings.from_environment().token_url == standard_url + + +@pytest.mark.anyio +async def test_all_worker_operations_share_investigation_creation_scope(): + boundary = "2026-10-10T10:29:47.864313Z" + requests = [] + + def handler(request): + requests.append(request) + if request.url.path.endswith("/agent-messages/pending"): + return httpx.Response(200, json={"items": []}) + if request.method == "GET": + assert request.url.params["task_type"] == "event_investigation" + assert request.url.params["task_created_from"] == boundary + return httpx.Response(200, json={"items": []}) + assert request.url.path.endswith("leases/recover") + assert json.loads(request.content) == { + "limit": 100, "task_type": "event_investigation", "task_created_from": boundary, + } + return httpx.Response(200, json={"task_ids": []}) + + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), + bearer="TEST_ONLY", task_created_from=boundary) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + assert await OperationalWorker(settings, client).tick() == 0 + assert len(requests) == 5 + + +@pytest.mark.anyio +async def test_worker_reconciles_messages_at_capacity_and_preserves_transport_retries(): + pending = [{"message_id": str(uuid4())} for _ in range(3)] + failures, submitted = [], [] + + def handler(request): + path = request.url.path + if path.endswith("leases/recover"): + return httpx.Response(200, json={"items": []}) + if path.endswith("agent-messages/pending"): + return httpx.Response(200, json={"items": pending}) + if path.endswith("operational-task-runs"): + return httpx.Response(200, json={"items": [{"run_id": str(uuid4())}] + if request.url.params["status"] == "running" else []}) + body = json.loads(request.content) + if path.endswith("task-messages/deliver"): + assert str(request.url) == "http://agent.test/api/v1/task-messages/deliver" + submitted.append(body["message_id"]) + status = {pending[0]["message_id"]: 503, pending[1]["message_id"]: 404}.get(body["message_id"], 202) + return httpx.Response(status, json={}) + assert path.endswith("/delivery") and request.url.host == "server.test" + failures.append((path, body)) + return httpx.Response(200, json={}) + + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), + bearer="TEST_ONLY", max_concurrency=1) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + assert await OperationalWorker(settings, client).tick() == 0 + assert submitted == [row["message_id"] for row in pending] + assert len(failures) == 1 and pending[1]["message_id"] in failures[0][0] + assert failures[0][1]["status"] == "failed" + + +@pytest.mark.anyio +async def test_message_sweep_advances_past_idle_chats_across_bounded_ticks(): + messages = [str(uuid4()) for _ in range(5)] + scans, delivered = [], [] + + def handler(request): + if request.method == "GET": + cursor = request.url.params.get("cursor") + page = int(cursor) if cursor else 0 + scans.append(cursor) + return httpx.Response(200, json={ + "items": [{"message_id": messages[page]}], + "next_cursor": str(page + 1) if page < 4 else None, + }) + delivered.append(json.loads(request.content)["message_id"]) + return httpx.Response(202, json={}) + + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + worker = OperationalWorker(settings, client) + await worker._reconcile_messages({}) + assert scans == [None, "1", "2", "3"] and worker._message_cursor == "4" + await worker._reconcile_messages({}) + assert scans[-1] == "4" and worker._message_cursor is None + assert delivered == messages + + @pytest.mark.anyio async def test_persisted_queue_retries_with_backend_execution_identity(): task_id, run_id, execution_id = str(uuid4()), str(uuid4()), str(uuid4()) @@ -29,6 +138,7 @@ async def test_persisted_queue_retries_with_backend_execution_identity(): "status": "queued", "revision": 1, }]}) body = json.loads(request.content) + assert str(request.url) == "http://agent.test/api/v1/task-executions" submitted.append(body) return httpx.Response(202, json={"task_id": task_id, "run_id": run_id, "session_id": "execution-session"}) settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY") @@ -139,3 +249,28 @@ async def test_long_lived_oauth_token_is_refreshed_before_product_token_age_limi clock[0] = 301.0 await provider.headers(client) assert len(requests) == 2 + + +@pytest.mark.anyio +async def test_worker_refreshes_running_agent_credentials_after_restart(): + run_id, project_id = str(uuid4()), str(uuid4()) + renewed = [] + + def handler(request): + if request.url.path.endswith("leases/recover"): + return httpx.Response(200, json={"items": []}) + if request.method == "GET": + items = [{"run_id": run_id, "agent_session_id": "existing-session"}] if request.url.params.get("status") == "running" else [] + return httpx.Response(200, json={"items": items}) + assert request.url.host == "agent.test" + assert str(request.url) == "http://agent.test/api/v1/session/existing-session/credentials" + assert request.headers["Authorization"] == "Bearer TEST_ONLY" + assert request.headers["X-Project-Id"] == project_id + assert json.loads(request.content) == {} + renewed.append(request) + return httpx.Response(202, json={"accepted": True}) + + settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + assert await OperationalWorker(settings, client).tick() == 0 + assert len(renewed) == 1 diff --git a/tests/unit/test_peripheral_task_sync.py b/tests/unit/test_peripheral_task_sync.py index 3123206..67d63c5 100644 --- a/tests/unit/test_peripheral_task_sync.py +++ b/tests/unit/test_peripheral_task_sync.py @@ -241,12 +241,14 @@ async def test_sample_sync_rejects_cross_project_data_before_writing(): @pytest.mark.anyio @pytest.mark.parametrize("targeted", [False, True]) +@pytest.mark.parametrize("order_revision", [1, 2]) @pytest.mark.parametrize( ("investigation_status", "expected_feedback_count", "routes_to_investigation"), [ ("waiting_human", 2, True), ("queued", 2, True), - ("completed", 1, False), + ("completed", 2, True), + ("cancelled", 1, False), ], ) async def test_created_work_order_receipt_preserves_projection_and_routes_active_feedback( @@ -254,6 +256,7 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active expected_feedback_count, routes_to_investigation, targeted, + order_revision, ): project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4())) received = [] @@ -262,10 +265,14 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active order = { "project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "created_by": str(uuid4()), - "revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z", + "revision": order_revision, "summary": "采样任务", "status": "completed" if order_revision > 1 else "open", + "updated_at": "2026-09-23T09:00:00Z", "title": "合成采样工单", "priority": "normal", "work_order_type": "general", "created_at": "2026-09-23T08:00:00Z", "scheduled_at": None, + "activity": ([{"sequence": 2, "occurred_at": "2026-09-23T09:00:00Z", "actor": "现场人员", + "kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"}] + if order_revision > 1 else []), } if targeted: assert request.url.path.endswith(f"/work-orders/{order_id}") @@ -277,6 +284,8 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active payload = json.loads(request.content) assert payload["source_event_id"] == f"peripheral_sample:{order_id}" assert ("work_order_snapshot" in payload) == request.url.path.endswith(f"{projection_id}/external-feedback") + if order_revision > 1: + assert "复测余氯0.2mg/L" in payload["evidence"]["summary"] return httpx.Response(200, json={}) if request.url.path.endswith(investigation_id): return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": { @@ -287,7 +296,7 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active return httpx.Response(200, json={"items": [ {"task_id": investigation_id, "task_type": "event_investigation", "status": investigation_status}, {"task_id": projection_id, "task_type": "external_work_order", "source": { - "external_id": f"peripheral_sample:{order_id}", "external_revision": 1, + "external_id": f"peripheral_sample:{order_id}", "external_revision": 1 if order_revision > 1 else None, }}, ], "next_offset": None}) settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") @@ -295,9 +304,10 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active assert await sync_once( client, settings, "http://sample.test", CredentialProvider(settings), **({"work_order_ids": {order_id}} if targeted else {}), - ) == expected_feedback_count - assert any(investigation_id in path for path in received) is routes_to_investigation + ) == (expected_feedback_count if order_revision > 1 else 1) + assert any(investigation_id in path for path in received) is (routes_to_investigation and order_revision > 1) assert any(projection_id in path for path in received) + assert received[-1].endswith(f"{projection_id}/external-feedback") @pytest.mark.anyio @@ -331,6 +341,61 @@ async def test_external_events_start_investigations_without_browser(event_kind): assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z" +@pytest.mark.anyio +@pytest.mark.parametrize("failure_target", ["parent", "projection"]) +async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_deliveries(failure_target): + project_id, order_id, parent_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4())) + parent = {"task_id": parent_id, "task_type": "event_investigation", "status": "completed", "source": {}} + projection = {"task_id": projection_id, "task_type": "external_work_order", "source": { + "external_id": f"peripheral_sample:{order_id}", "external_revision": 1, + }} + order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", + "revision": 2, "title": "采样", "summary": "复测通过", "status": "completed", "priority": "normal", + "created_at": "2026-10-10T00:00:00Z", "updated_at": "2026-10-10T01:00:00Z", "scheduled_at": None, + "work_order_type": "general"} + failed = False + accepted_parent_requests = set() + detail_reads = [] + + def handler(request): + nonlocal failed + if request.url.host == "sample.test": + return httpx.Response(200, json={"items": [order], "next_cursor": None}) + if request.method == "GET": + if request.url.path.endswith(parent_id): + detail_reads.append(parent_id) + return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": { + "adapter": "peripheral_sample", "receipt_id": order_id, + }}]}) + return httpx.Response(200, json={"items": [parent, projection], "next_offset": None}) + payload = json.loads(request.content) + is_parent = request.url.path.endswith(f"{parent_id}/external-feedback") + if not failed and is_parent == (failure_target == "parent"): + failed = True + return httpx.Response(503, json={}) + if is_parent: + accepted_parent_requests.add(payload["request_id"]) + parent["status"] = "queued" + else: + assert accepted_parent_requests, "Projection must not advance before its associated investigation" + projection["source"]["external_revision"] = payload["source_revision"] + return httpx.Response(200, json={}) + + settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + credentials = CredentialProvider(settings) + with pytest.raises(httpx.HTTPStatusError): + await sync_once(client, settings, "http://sample.test", credentials) + assert projection["source"]["external_revision"] == 1 + assert await sync_once(client, settings, "http://sample.test", credentials) == 2 + assert len(accepted_parent_requests) == 1 + assert projection["source"]["external_revision"] == 2 + parent["status"] = "completed" + detail_reads.clear() + assert await sync_once(client, settings, "http://sample.test", credentials) == 0 + assert detail_reads == [], "Unchanged source versions must not scan completed parent details forever" + + def test_snapshot_preserves_recorded_activity_without_legacy_subtasks(): order = { "work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步", diff --git a/tests/unit/test_peripheral_work_order_activity.py b/tests/unit/test_peripheral_work_order_activity.py index bdb5470..620358b 100644 --- a/tests/unit/test_peripheral_work_order_activity.py +++ b/tests/unit/test_peripheral_work_order_activity.py @@ -1,5 +1,7 @@ from uuid import uuid4 +import pytest + from samples.peripheral.auth import Identity from samples.peripheral.config import SampleSettings from samples.peripheral.schemas import CreateWorkOrder, UpdateWorkOrder, WorkOrder @@ -41,6 +43,25 @@ def test_create_and_update_record_flat_order_activity(monkeypatch): assert "progress_note" not in updated +@pytest.mark.parametrize(("work_order_type", "detail"), [ + ("general", "预约工单已创建。"), + ("water_quality_complaint", "投诉工单已创建。"), + ("burst_report", "爆管上报工单已创建。"), +]) +def test_created_activity_distinguishes_work_order_type(monkeypatch, work_order_type, detail): + store = PostgresStore(SampleSettings()) + actor = Identity(str(uuid4()), str(uuid4()), True) + monkeypatch.setattr(store, "_mutate", lambda who, key, command, operation: operation(None, "normal")) + monkeypatch.setattr(store, "_save_work_order", lambda _conn, _who, document: document) + + created = store.create_work_order(actor, CreateWorkOrder( + request_id=uuid4(), title="测试工单", summary="验证创建活动语义", + work_order_type=work_order_type, + )) + + assert created["activity"][0]["detail"] == detail + + def test_legacy_order_reads_flat_without_claiming_unrecorded_progress(): legacy = {"work_order_id": str(uuid4()), "subtasks": [{"stages": [{"status": "done"}]}]} projected = PostgresStore._active_managed_document(legacy) diff --git a/tests/unit/test_place_search.py b/tests/unit/test_place_search.py new file mode 100644 index 0000000..a918e1e --- /dev/null +++ b/tests/unit/test_place_search.py @@ -0,0 +1,140 @@ +import asyncio +import json +import logging + +import httpx +import pytest +from pydantic import ValidationError + +from app.core.config import Settings, settings +from app.domain.schemas.place_search import PlaceSearchRequest +from app.services.place_search import PlaceSearchError, search_tianditu_places + + +def poi(**overrides): + return { + "hotPointID": "place-1", "name": "南桥路", "address": "上海市奉贤区南桥路", + "lonlat": "121.441891,31.220188", "county": "奉贤区", "countyCode": "156310120", + "provinceCode": "156310000", "typeName": "道路名", **overrides, + } + + +def payload(**overrides): + return {"status": {"infocode": 1000}, "resultType": 1, "count": 21, "pois": [poi()], **overrides} + + +class FakeClient: + def __init__(self, data=None, status=200, error=None): + self.data = data if data is not None else payload() + self.status = status + self.error = error + self.calls = [] + + async def get(self, url, *, params): + self.calls.append((url, params)) + if self.error: + raise self.error + return httpx.Response(self.status, json=self.data, request=httpx.Request("GET", url)) + + +@pytest.fixture(autouse=True) +def shared_token(monkeypatch): + monkeypatch.setattr(settings, "TIANDITU_TOKEN", "shared-token") + + +def test_search_fixes_fengxian_scope_and_normalizes_candidates(): + client = FakeClient() + result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), client=client)) + url, params = client.calls[0] + assert url == "https://api.tianditu.gov.cn/v2/search" + assert params["tk"] == "shared-token" + assert json.loads(params["postStr"]) == { + "keyWord": "南桥路", "queryType": 12, "specify": "156310120", "show": 2, "start": 0, "count": 10, + } + assert result.items[0].name == "南桥路" + assert result.items[0].longitude == 121.441891 + assert result.items[0].district == "奉贤区" + assert result.items[0].category == "道路名" + assert result.truncated and result.total == 1 and result.region == "奉贤区" + + +@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))) + 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)) + 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" + assert "has_more" not in result.model_dump() and "offset" not in result.model_dump() + + +@pytest.mark.parametrize("data", [ + payload(pois=[poi(lonlat="bad")]), + payload(pois=[poi(lonlat="181,31")]), + payload(pois=[poi(lonlat="121,NaN")]), + payload(pois=[poi(name=None)]), + payload(pois=[poi(provinceCode="156320000")]), + payload(pois=[poi(countyCode="156310104", county="徐汇区")]), + payload(pois=[poi(countyCode="156310120", county="静安区")]), + payload(pois=[poi(countyCode=None, county=None)]), + payload(count=-1), + payload(pois=[poi() for _ in range(11)]), + payload(resultType=4), + {"status": {"infocode": 2001}}, +]) +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))) + assert exc.value.status_code == 502 + + +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)) + assert exc.value.status_code == 503 and client.calls == [] + + +def test_http_request_logs_redact_the_shared_token(caplog): + caplog.set_level(logging.INFO, logger="httpx") + + 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) + + asyncio.run(run()) + 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("shared-token" not in message for message in messages) + + +@pytest.mark.parametrize("failure, status", [ + (httpx.ReadTimeout("secret-token-in-url"), 504), + (httpx.ConnectError("secret-token-in-url"), 502), +]) +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))) + assert exc.value.status_code == status + assert "secret-token" not in exc.value.detail + + +@pytest.mark.parametrize("body", [{"keyword": " "}, {"keyword": "南桥路", "specify": "北京"}, {"keyword": "南桥路", "offset": 0}, {"keyword": "南桥路", "limit": 10}]) +def test_request_rejects_blank_keywords_scope_overrides_and_paging_options(body): + with pytest.raises(ValidationError): + PlaceSearchRequest(**body) + + +@pytest.mark.parametrize("key", ["TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"]) +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" diff --git a/tests/unit/test_realtime_repository.py b/tests/unit/test_realtime_repository.py index 42ad06f..965e351 100644 --- a/tests/unit/test_realtime_repository.py +++ b/tests/unit/test_realtime_repository.py @@ -2,9 +2,16 @@ import asyncio from contextlib import asynccontextmanager, contextmanager from datetime import datetime, timezone +import pytest +from pydantic import ValidationError + +from app.core.config import Settings, settings from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository +SNAPSHOT_BUDGET_SQL = "SELECT set_config('timescaledb.max_tuples_decompressed_per_dml_transaction', %s, true)" + + class _FakeCursor: def __init__(self): self.calls: list[tuple[str, tuple]] = [] @@ -113,10 +120,12 @@ def test_realtime_node_and_link_replacement_share_outer_transaction(monkeypatch) assert conn.transactions == 1 assert calls == ["nodes", "links"] assert [query for query, _params in conn.calls] == [ + SNAPSHOT_BUDGET_SQL, "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", "DELETE FROM realtime.node_results WHERE time = %s", "DELETE FROM realtime.link_results WHERE time = %s", ] + assert conn.calls[0][1] == (str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),) def test_realtime_batch_rejects_multiple_timestamps_before_writing(): @@ -170,7 +179,36 @@ def test_empty_realtime_side_is_deleted_as_part_of_snapshot_replacement(): ) assert [query for query, _params in conn.calls] == [ + SNAPSHOT_BUDGET_SQL, "SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))", "DELETE FROM realtime.node_results WHERE time = %s", "DELETE FROM realtime.link_results WHERE time = %s", ] + + +@pytest.mark.parametrize("side", ["nodes", "links"]) +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_all_snapshot_batch_writers_use_configured_local_budget(monkeypatch, side, asynchronous): + monkeypatch.setattr(settings, "TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES", 1_200_000) + conn = _AsyncEmptySnapshotConnection() if asynchronous else _SyncTransactionConnection() + if asynchronous: + async def copy(*_): + pass + monkeypatch.setattr(RealtimeRepository, f"_copy_{side}", copy) + else: + monkeypatch.setattr(RealtimeRepository, f"_copy_{side}_sync", lambda *_: None) + method = getattr(RealtimeRepository, f"insert_{side}_batch" + ("" if asynchronous else "_sync")) + rows = [{"time": "2026-10-08T04:00:00Z", "id": "fixture"}] + if asynchronous: + asyncio.run(method(conn, rows)) + else: + method(conn, rows) + assert conn.calls[0] == (SNAPSHOT_BUDGET_SQL, ("1200000",)) + assert "pg_advisory_xact_lock" in conn.calls[1][0] + assert conn.calls[2][0].startswith("DELETE FROM realtime.") + + +@pytest.mark.parametrize("value", [0, -1, 2_147_483_648]) +def test_snapshot_budget_must_remain_a_finite_positive_postgres_limit(value): + with pytest.raises(ValidationError): + Settings(TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES=value, _env_file=None) diff --git a/tests/unit/test_web_search.py b/tests/unit/test_web_search.py deleted file mode 100644 index f1f2c4a..0000000 --- a/tests/unit/test_web_search.py +++ /dev/null @@ -1,115 +0,0 @@ -import asyncio -import importlib.util -from pathlib import Path - -import httpx -import pytest - - -def _load_web_search_module(): - module_path = ( - Path(__file__).resolve().parents[2] / "app" / "services" / "web_search.py" - ) - spec = importlib.util.spec_from_file_location("tests_web_search_under_test", module_path) - module = importlib.util.module_from_spec(spec) - assert spec and spec.loader - spec.loader.exec_module(module) - return module - - -web_search = _load_web_search_module() - - -class FakeClient: - def __init__(self, response): - self.response = response - self.calls = [] - - async def post(self, url, *, headers, json): - self.calls.append({"url": url, "headers": headers, "json": json}) - return self.response - - -def test_search_bocha_web_posts_expected_payload(monkeypatch): - monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test") - monkeypatch.setattr( - web_search.settings, - "BOCHA_WEB_SEARCH_URL", - "https://api.bochaai.com/v1/web-search", - ) - response = httpx.Response( - 200, - json={"data": {"webPages": {"value": []}}}, - request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"), - ) - client = FakeClient(response) - - result = asyncio.run( - web_search.search_bocha_web( - web_search.WebSearchRequest( - query="天津水务", - freshness="oneWeek", - summary=True, - count=5, - include=["example.com", "news.example.com"], - exclude=["spam.example.com"], - ), - client=client, - ) - ) - - assert result == {"data": {"webPages": {"value": []}}} - assert client.calls == [ - { - "url": "https://api.bochaai.com/v1/web-search", - "headers": { - "Authorization": "Bearer sk-test", - "Content-Type": "application/json", - }, - "json": { - "query": "天津水务", - "freshness": "oneWeek", - "summary": True, - "count": 5, - "include": "example.com,news.example.com", - "exclude": "spam.example.com", - }, - } - ] - - -def test_search_bocha_web_requires_api_key(monkeypatch): - monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "") - - with pytest.raises(web_search.BochaSearchConfigError): - asyncio.run( - web_search.search_bocha_web( - web_search.WebSearchRequest(query="天津水务"), - client=FakeClient(httpx.Response(200, json={})), - ) - ) - - -def test_search_bocha_web_surfaces_upstream_error(monkeypatch): - monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test") - response = httpx.Response( - 401, - json={"error": "invalid api key"}, - request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"), - ) - - with pytest.raises(web_search.BochaSearchAPIError) as exc_info: - asyncio.run( - web_search.search_bocha_web( - web_search.WebSearchRequest(query="天津水务"), - client=FakeClient(response), - ) - ) - - assert exc_info.value.status_code == 401 - assert exc_info.value.detail == {"error": "invalid api key"} - - -def test_web_search_request_validates_count_range(): - with pytest.raises(ValueError): - web_search.WebSearchRequest(query="天津水务", count=51)