Author SHA1 Message Date
jiang f2cfce8192 feat: add node coverage and improve investigation presentation
Generic Container CI/CD / test-build-publish (push) Failing after 1m50s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m51s
2026-11-02 15:23:34 +08:00
jiang 05183aff93 feat: extend operational investigation workflows
Generic Container CI/CD / test-build-publish (push) Successful in 3m53s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m52s
2026-10-30 17:30:19 +08:00
jiang 63af998045 feat(mcp): queue long-running calculations 2026-10-29 18:25:15 +08:00
jiang 2865811f1b chore(config): align server environment templates 2026-10-27 17:17:08 +08:00
jiang af1dc11681 docs: record deployed API port 2026-10-23 16:12:26 +08:00
jiang 8673429ec4 docs: clarify peripheral proxy upstream 2026-10-23 16:11:34 +08:00
jiang 6d31f456e6 feat: mount peripheral API in main server
Generic Container CI/CD / test-build-publish (push) Successful in 2m59s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m59s
2026-10-23 16:05:56 +08:00
jiang cddfc8647d docs: update server operations guide
Generic Container CI/CD / test-build-publish (push) Successful in 5m20s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 5m20s
2026-10-23 14:29:44 +08:00
jiang 1d061f6099 feat: add project-scoped operations
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s
2026-10-22 18:40:20 +08:00
153 changed files with 11259 additions and 540 deletions
+106 -71
View File
@@ -1,127 +1,162 @@
# TJWater Server 环境变量配置模板
# 复制此文件为 .env 并填写实际值
# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。
ENVIRONMENT="production"
NETWORK_NAME="tjwater"
# ============================================
# 敏感配置加密 (必填)
# ============================================
# NextTJWater Server 环境变量模板
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
# 生产配置由部署主机的受控 next-backend.env 注入,不随镜像或 CI/CD 同步本地 .env。
# 进程环境变量优先;修改 Compose env_file 后需重新创建容器。
# Fernet 格式,生产环境必须替换为独立密钥
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置
# 基础运行
PROJECT_NAME="TJWater Server"
ENVIRONMENT="production"
API_V1_STR="/api/v1"
NETWORK_NAME="tjwater"
# Fernet 格式;用于加密项目数据库 DSN、GeoServer 管理密码等敏感配置。
# 生成方式:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key"
# ============================================
# 数据库配置 (PostgreSQL)
# ============================================
# 业务数据库(PostgreSQL)
DB_NAME="tjwater"
DB_HOST="localhost"
DB_PORT="5432"
DB_USER="tjwater"
DB_PASSWORD="password"
# ============================================
# 数据库配置 (TimescaleDB)
# ============================================
# 时序数据库(TimescaleDB)
TIMESCALEDB_DB_NAME="tjwater"
TIMESCALEDB_DB_HOST="localhost"
TIMESCALEDB_DB_PORT="5433"
TIMESCALEDB_DB_USER="tjwater"
TIMESCALEDB_DB_PASSWORD="password"
# 实时快照替换事务的解压预算;仅事务内生效,禁止0(无限制)。
# 实时快照替换事务的解压预算;仅事务内生效,禁止设为 0(无限制)。
TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES=1000000
# ============================================
# 元数据数据库配置 (Metadata DB)
# ============================================
# 元数据数据库
METADATA_DB_NAME="system_hub"
METADATA_DB_HOST="localhost"
METADATA_DB_PORT="5432"
METADATA_DB_USER="tjwater"
METADATA_DB_PASSWORD="password"
METADATA_DB_POOL_SIZE=5
METADATA_DB_MAX_OVERFLOW=10
# Per-project synchronous connection pools
PROJECT_PG_CACHE_SIZE="16"
PROJECT_TS_CACHE_SIZE="16"
PROJECT_PG_POOL_MIN_SIZE="0"
PROJECT_PG_POOL_SIZE="4"
PROJECT_PG_MAX_OVERFLOW="2"
PROJECT_TS_POOL_MIN_SIZE="0"
PROJECT_TS_POOL_MAX_SIZE="4"
# 项目数据库连接池、缓存与模板
PROJECT_PG_CACHE_SIZE=16
PROJECT_TS_CACHE_SIZE=16
PROJECT_PG_POOL_MIN_SIZE=0
PROJECT_PG_POOL_SIZE=4
PROJECT_PG_MAX_OVERFLOW=2
PROJECT_TS_POOL_MIN_SIZE=0
PROJECT_TS_POOL_MAX_SIZE=4
WNDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_schema_template"
TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_timescale_template"
WNDB_TEMP_DB_MAX_COUNT="8"
WNDB_TEMP_DB_MAX_COUNT=8
# ============================================
# GeoServer 项目供应
# ============================================
GEOSERVER_URL="http://localhost:8080/geoserver"
GEOSERVER_USERNAME="admin"
GEOSERVER_PASSWORD="password"
# 留空时分别复用 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD。
# 留空时分别复用 DB_HOST、DB_PORT、DB_USER、DB_PASSWORD。
# GeoServer 在容器内运行时,GEOSERVER_DB_HOST 通常应填写数据库服务名。
GEOSERVER_DB_HOST=""
GEOSERVER_DB_PORT=""
GEOSERVER_DB_USER=""
GEOSERVER_DB_PASSWORD=""
# 30 天客户端缓存,单位为秒。
GEOSERVER_CLIENT_CACHE_SECONDS="2592000"
# 客户端缓存时间,单位为秒;默认 30 天。
GEOSERVER_CLIENT_CACHE_SECONDS=2592000
# ============================================
# Keycloak JWT (可选)
# ============================================
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
KEYCLOAK_ALGORITHM=RS256
# Keycloak JWT 校验
# 未启用认证时可留空;生产环境应注入完整公钥和预期 audience。
KEYCLOAK_PUBLIC_KEY=""
KEYCLOAK_ALGORITHM="RS256"
KEYCLOAK_AUDIENCE="account"
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
# ============================================
# 工作台事件同步
# ============================================
# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。
CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173"
# 浏览器跨域访问(主 API)
# 必须填写明确来源(协议、域名或 IP、端口),英文逗号分隔;不允许 * 或网段正则。
CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173
# 工作台事件
WORKBENCH_EVENTS_ENABLED=true
WORKBENCH_EVENT_RETENTION_DAYS=7
# 每个项目启动一个 worker 实例。长期运行优先使用 OAuth 客户端凭据。
# 天地图服务
# 地理编码、逆地理编码和地名搜索共用同一令牌。
TIANDITU_TOKEN=""
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
TIANDITU_SEARCH_URL="https://api.tianditu.gov.cn/v2/search"
TIANDITU_SEARCH_TIMEOUT_SECONDS=15
# 调查任务 worker(独立进程)
# 单项目填写 PROJECT_ID;多项目填写逗号分隔的 PROJECT_IDS,后者优先。
# worker 与外围工单同步器共用项目范围,服务身份必须拥有每个项目的权限。
OPERATIONAL_WORKER_PROJECT_ID=""
OPERATIONAL_WORKER_PROJECT_IDS=""
OPERATIONAL_WORKER_SERVER_URL="http://127.0.0.1:8000"
OPERATIONAL_WORKER_AGENT_URL="http://127.0.0.1:8787"
OPERATIONAL_WORKER_RECONCILE_SECONDS=30
OPERATIONAL_WORKER_MAX_CONCURRENCY=4
# 可选 ISO 8601 时间下界,必须包含时区,例如 2026-10-01T00:00:00+08:00。
OPERATIONAL_WORKER_TASK_CREATED_FROM=""
# 长期运行使用 OAuth 客户端凭据;固定 Bearer 仅用于受控开发或短期运行。
OPERATIONAL_WORKER_TOKEN_URL=""
# 仅 ENVIRONMENT=development 时覆盖 TOKEN_URL,其他环境忽略。
OPERATIONAL_WORKER_DEV_TOKEN_URL=""
OPERATIONAL_WORKER_CLIENT_ID=""
OPERATIONAL_WORKER_CLIENT_SECRET=""
OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS=300
OPERATIONAL_WORKER_BEARER=""
# 调查数据适配器
OPERATIONAL_DATA_SERVER_URL="http://127.0.0.1:8000"
OPERATIONAL_DATA_PERIPHERAL_URL=""
OPERATIONAL_DATA_TIMEOUT_SECONDS=15
# 仅供 Server 在已批准的兼容委托路径中使用,不传给 Agent。
OPERATIONAL_DATA_DELEGATION_BEARER=""
# 调查动作适配器
OPERATIONAL_ACTION_PERIPHERAL_URL=""
OPERATIONAL_ACTION_COMMAND_GATEWAY_URL=""
OPERATIONAL_ACTION_TIMEOUT_SECONDS=10
# ============================================
# Tianditu shared API key and service URLs
# ============================================
TIANDITU_TOKEN="your-tianditu-token"
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
# 外围样板(挂载于 /peripheral;故障注入默认关闭)
# 独立启动时用于调用主 API 验证身份;容器内按网络填写主服务地址。
PERIPHERAL_SAMPLE_NEXT_SERVER_URL="http://127.0.0.1:8000"
# 以下两个默认库选择只供种子、导出等脚本使用,HTTP 请求仍按已授权项目路由。
PERIPHERAL_SAMPLE_DB_NAME="tjwater_v2"
PERIPHERAL_SAMPLE_PROJECT_CODE="tjwater_v2"
# 子应用独立运行时的浏览器来源列表,使用 JSON 格式;挂载时还受主 API CORS 配置控制。
PERIPHERAL_SAMPLE_CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173"]'
PERIPHERAL_SAMPLE_ENABLE_FAULTS=false
PERIPHERAL_SAMPLE_TIMEOUT_SECONDS=5
# 地名搜索与地理编码共用 TIANDITU_TOKEN;也可沿用 TJWATER_TIANDITU_TOKEN。
TIANDITU_SEARCH_URL="https://api.tianditu.gov.cn/v2/search"
TIANDITU_SEARCH_TIMEOUT_SECONDS=15
# MCP query resource, off until OAuth and every project BizDB are provisioned.
# 仅 ENVIRONMENT=development 时覆盖 worker 取令牌地址;留空沿用 OPERATIONAL_WORKER_TOKEN_URL。
OPERATIONAL_WORKER_DEV_TOKEN_URL=
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
TJWATER_MCP_ENABLED=false
TJWATER_MCP_ISSUER_URI=
# 仅 ENVIRONMENT=development,可填写与 issuer 完全相同的内网 HTTP IPv4 issuer。
# 正式环境必须留空;此例外不放行远程 HTTP MCP resource。
TJWATER_MCP_DEV_HTTP_ISSUER_URI=
TJWATER_MCP_RESOURCE_URI=
TJWATER_MCP_AUDIENCE=tjwater-mcp
TJWATER_MCP_HOST_CLIENT_ID=tjwater-agent-mcp
TJWATER_MCP_ALLOWED_HOSTS=["localhost:*","127.0.0.1:*"]
TJWATER_MCP_ALLOWED_ORIGINS=[]
# 普通查询预算;计算工具由 Agent 用 timeout_seconds 按次指定(默认120秒)。
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30
# 计算等待上限,默认1小时;管理员可配置120至86400秒,实际上限通过 tools/list 发布。
TJWATER_MCP_ISSUER_URI=""
# 仅 ENVIRONMENT=development:可与 ISSUER_URI 同值并指向内网 HTTP IPv4 issuer。
# 正式环境必须留空;该例外不允许远程 HTTP MCP resource。
TJWATER_MCP_DEV_HTTP_ISSUER_URI=""
TJWATER_MCP_RESOURCE_URI=""
TJWATER_MCP_AUDIENCE="tjwater-mcp"
TJWATER_MCP_HOST_CLIENT_ID="tjwater-agent-mcp"
# 列表使用 JSON 格式。
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
TJWATER_MCP_ALLOWED_ORIGINS='[]'
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
# 单次 HTTP 返回预算;由目录元数据公布给宿主,Agent 默认等待 180 秒。
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=120
# HTTP 请求预算上限;Agent 加 60 秒余量并封顶 270 秒,外层 MCP 总时限 300 秒。
TJWATER_MCP_MAX_REQUEST_TIMEOUT_SECONDS=240
# 后台计算独立执行时限为 120–86400 秒,不受工具 timeout_seconds 控制。
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
# 独立计算队列消费者;显式项目 UUID,以逗号分隔。不会在 API lifespan 内启动。
# 先在每个目标项目 BizDB 依次应用 026_calculation_jobs.sql 和 027_calculation_job_lifecycle.sql,再启动计算服务。
CALCULATION_WORKER_PROJECT_IDS=""
# 所有配置项目合计并发,独立于 Uvicorn --workers 4;单实例锁防止重复消费者扩容。
CALCULATION_WORKER_MAX_CONCURRENCY=2
CALCULATION_WORKER_POLL_SECONDS=2
CALCULATION_WORKER_LEASE_SECONDS=30
CALCULATION_WORKER_HEARTBEAT_SECONDS=5
# 持久化本机恢复账本;容器模板已挂载独立 volume,不能当缓存删除。
CALCULATION_WORKER_STATE_DIR="temp/calculation-jobs"
+2 -2
View File
@@ -10,11 +10,11 @@ jobs:
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@68c8a9855391baa31d31523674f5812cd24ec604
with:
image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-backend
dockerfile: Dockerfile
build_context: .
test_target: test
deploy_service: backend
deploy_service: next-backend
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
+1 -1
View File
@@ -23,7 +23,7 @@ COPY app ./app
COPY contracts ./contracts
COPY infra ./infra
COPY resources ./resources
COPY scripts/run_operational_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
COPY scripts/run_operational_worker.py scripts/run_calculation_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
COPY samples/peripheral ./samples/peripheral
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/pipe_health_prediction/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip
+88 -8
View File
@@ -1,6 +1,6 @@
# TJWaterServerBinary 内部后端
# NextTJWater Server
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA 和地图服务集成能力。该仓库用于内部开发和完整功能维护。
`next-tjwater-server` 是 NextTJWater 供水系统的 FastAPI 后端,提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务、工况任务和受控 MCP 能力。业务数据和时序数据按已认证项目路由,后台工作进程也必须使用明确的项目范围。
## 技术栈
@@ -30,6 +30,14 @@ infra/docker/ Docker Compose 编排
## 本地开发
先从样板创建本地配置,再按启用的功能填写:
```bash
cp .env.example .env
```
`.env` 只用于本地或部署主机运行,不得提交。生产配置由部署主机的受控环境文件注入,不将完整应用 `.env` 保存为 Gitea Actions 密钥。
推荐使用已有 conda 环境:
```bash
@@ -45,14 +53,50 @@ conda activate server
`GET /health` 是无需鉴权的 HTTP 服务存活检查,返回 `200` 和 `{"status":"ok"}`,不查询数据库。
`GET /ready` 检查应用启动状态、元数据库 `SELECT 1` 及已启用的 MCP 传输生命周期;
全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为2秒。MCP 关闭时标记为
全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为 2 秒。MCP 关闭时标记为
`disabled`,不阻止就绪。返回结果不包含数据库地址、凭据或原始异常,禁止缓存。
就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过
带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。
## 环境配置
`.env.example` 是唯一应提交的环境配置样板,主要分为以下几组:
- 元数据库、项目业务库、TimescaleDB 及项目数据库连接池。
- GeoServer 项目供应、Keycloak JWT 校验、CORS 和工作台事件。
- 天地图地理编码和地名搜索。
- 调查任务 worker、外围工单同步、数据与动作适配器。
- 可选的 MCP resource、OAuth issuer、宿主和超时预算。
基础数据库变量用于启动和项目供应。运行时的项目请求仍以元数据库中的 `biz_data` 和 `iot_data` 路由为准,不会因为存在默认数据库变量而回退到固定项目。
CORS 通过后端 `.env` 或部署进程环境变量中的 `CORS_ALLOWED_ORIGINS` 配置,进程环境变量优先。
必须使用英文逗号分隔的明确页面来源(协议、域名或 IP、端口,不带路径),不能填写 `*`。
未设置时默认只允许本机 `5173`、`15173` 开发来源。正式部署按实际浏览器地址填写,例如:
```dotenv
CORS_ALLOWED_ORIGINS=https://next-demo.waternetwork.cn,http://192.168.1.133,https://192.168.1.133,http://192.168.1.110,https://192.168.1.110
```
CORS 匹配浏览器页面来源,不是 Nginx 连接 IP 或域名解析结果;非默认端口须逐项列出。
保留跨域凭据支持及 `Authorization`、`X-Project-Id` 请求头,接口认证和权限校验继续生效。
修改后需重启进程;使用 Compose `env_file` 注入时需重新创建容器才能读取新值。
## 业务 Skill 与闭环
[爆管处置工程图](docs/burst-response-workflow.md)覆盖上报、候选定位、隔离阀门、影响分析、地图与数据展示、审批处置和反馈闭环。Server 通过任务 MCP 按 workflow 分发以下业务 Skill,当前版本均为 `2.0.0`:
| workflow | Skill | 调查重点 |
| --- | --- | --- |
| burst_report | [爆管处置](resources/work-order-skills/burst-report/SKILL.md) | 候选定位、隔离阀门、影响分析、抢修及恢复闭环 |
| water_quality_complaint | [水质投诉](resources/work-order-skills/water-quality-complaint/SKILL.md) | 投诉关联、监测与采样、候选影响、处置及复测回访 |
| general | [预约跟进](resources/work-order-skills/appointment-follow-up/SKILL.md) | 审批与回执、现场履约、反馈复核及与原事件的闭环区别 |
JSONL 整理遵守会话文件权限,后台图表和表格通过既有报告 `ui_blocks` 展示;Skill 不增加审批、执行或原工单关单权限。预约投影不自动启动 Agent 调查,普通协作与后台执行分别遵守其原有工具范围。
## 常用命令
外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
外围接入样板作为 `/peripheral` 子应用随主 API 进程启动,复用 PostgreSQL 且不自动建表;授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD;工单经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。
```bash
@@ -84,8 +128,8 @@ docker compose -f infra/docker/docker-compose.yml config
- 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。
在线重算会原子替换一个时刻的节点、管段基线。历史分块已经压缩时,写入事务使用
`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认100万条;配置必须
为正整数,不允许0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。
`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认 100 万条;配置必须
为正整数,不允许 0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。
预算按需根据受影响压缩批次的规模调整,超过预算仍会失败并回滚当前快照;此前完成的其他
时刻不会一起回滚。同步、异步及单表批量快照入口使用同一预算。
@@ -117,6 +161,19 @@ conda run -n server python scripts/publish_fengxian_map_layers.py --database tjw
发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。
## 工况任务工作进程
调查任务 worker 和外围工单同步器是独立进程,不随 API 进程自动启动:
```bash
conda run -n server python scripts/run_operational_worker.py
conda run -n server python scripts/sync_peripheral_work_orders.py
```
`OPERATIONAL_WORKER_PROJECT_IDS` 是逗号分隔的显式项目 UUID 列表,非空时优先于单项目兼容变量 `OPERATIONAL_WORKER_PROJECT_ID`。如需覆盖所有已启用项目,应将这些项目的 UUID 全部明确写入列表。进程不会自动枚举项目,配置列表也不会自动授权;服务身份必须是每个目标项目的有效成员。
每个项目保持独立的事件游标、会话、消息游标和 OAuth 缓存。`OPERATIONAL_WORKER_MAX_CONCURRENCY` 是每个项目的上限,而不是全局上限。更改项目列表后,需要同时重启 worker 和外围同步器。完整配置、授权和重试边界见 [工况工作进程说明](docs/operational-task-workers.md)。
## 测试与发布
提交前根据改动范围运行最小有效测试:
@@ -131,7 +188,15 @@ conda run -n server python -m pytest tests/unit tests/auth -q
docker build -t tjwater-server:local .
```
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建、推送镜像并通知部署 webhook。
Dockerfile 的 `test` 阶段会执行 Python 编译检查、应用导入、OpenAPI 检查和完整 pytest。
Gitea Actions 工作流位于 `.gitea/workflows/package.yml`,可手动触发,也会在推送 `v*` tag 时运行。工作流构建 `test` 阶段,推送不可变的 `sha-*` 镜像,然后通过受限 SSH 账号对 `next-backend` 执行候选验证。只有 tag 工作流会在验证通过后提升正式版本。仓库需要以下 Actions secrets:
- `REGISTRY_USERNAME`
- `REGISTRY_PASSWORD`
- `DEV_DEPLOY_SSH_KEY`
这些密钥只用于镜像仓库和受限开发机部署,不替代后端应用的运行环境配置。
## 安全规则
@@ -139,4 +204,19 @@ Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发
## 产品 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)。
Server 可选启用 MCP 2026-07-28,默认关闭。当前目录包含 67 项类型化工具,其中 42 项查询、12 项计算、13 项预览式写入。MCP 使用专用 OAuth resource Bearer、宿主绑定上下文和现有项目或任务权限,不提供自由命令执行。
写入工具默认只执行 `dry_run`。真实写入必须使用同一 `request_id`,带上预览返回的 `expected_digest`,并通过宿主原生许可和项目权限检查。超时或结果不明时先查询操作状态,不盲目重试。实施、启用和授权边界见 [MCP 实施与启用](docs/mcp-query-implementation.md) 和 [协议与旧 CLI 能力对照](docs/mcp-query-design.md)。
## 后台计算队列
长计算通过项目数据库队列和独立计算 Worker 执行,API 保持4个进程,HTTP超时不延长。接入需要新增数据库迁移和独立服务,详见[计算队列部署与MCP契约](docs/calculation-queue.md)。
后台调查通过 `POST /api/v1/operational-tasks/{task_id}/questions` 保存结构化问题,并沿现有任务通知展示。当前 run 可在没有最终报告时正常结束,任务进入等待人工;具备 `operational_task.write` 权限的用户通过 `questions/{question_id}/answer` 回答,原任务排队新一轮。回答保存为交互记录,不自动转为现场证据或审批。问题支持最多三个单选、多选或自定义回答,版本校验和请求幂等保护重复提交。任务详情返回最近 100 组提问,完整历史由 MCP 的 `get_task_context(section="questions")` 分页读取。
部署任务提问功能前,在各项目业务库执行 `resources/sql/028_task_questions.sql`,再同步 Server、Agent 与前端版本。迁移不修改既有调查记录;更新代码不会自动迁移数据库或重启服务。
外围工单来源统一为系统数据。部署对应版本前,在各项目业务库执行 `resources/sql/029_peripheral_work_orders_system_data.sql`,再同步 Server、同步器、Agent 与前端版本。迁移会修正既有外围源工单、修订记录、任务快照和外围反馈证据,不修改测试数据或模拟监测数据。
多 Agent 后台调查按实例归属运行。部署对应版本前,在每个启用调查 Worker 的项目 BizDB 执行 `resources/sql/030_operational_run_agent_ownership.sql`,再同步 Server、Agent 和 Worker,并为各 Agent 配置唯一且重启稳定的 `AGENT_INSTANCE_ID`。迁移只增加运行归属列和活跃运行索引;旧运行不猜测归属,待其结束或租约恢复后,新一代运行开始记录实例。
+8
View File
@@ -3,6 +3,14 @@ import os
import pandas as pd
def calculation_temp_directory(kind: str) -> str:
"""Keep native outputs inside the supervisor-owned job directory."""
root = os.environ.get("TJWATER_CALCULATION_JOB_DIR", "temp")
path = os.path.abspath(os.path.join(root, kind))
os.makedirs(path, exist_ok=True)
return path
def fill_time_gaps(
data: pd.DataFrame,
time_col: str = "time",
@@ -8,15 +8,14 @@ import sys
import pandas as pd
import wntr
from app.algorithms._utils import _cleanup_temp_files
from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory
_PIPE2LEAKNODE = None
_SIGNATURE_WORKER_DATA = {}
def _make_temp_prefix(tag):
temp_dir = os.path.abspath(os.path.join("temp", "burst_location"))
os.makedirs(temp_dir, exist_ok=True)
temp_dir = calculation_temp_directory("burst_location")
safe_tag = str(tag).replace(os.sep, "_").replace(" ", "_")
return os.path.join(temp_dir, f"{safe_tag}_{os.getpid()}")
@@ -15,7 +15,7 @@ from pymoo.optimize import minimize as pymoo_minimize
from pymoo.termination.default import DefaultSingleObjectiveTermination
from app.algorithms._utils import _cleanup_temp_files
from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory
_worker_data: dict[str, Any] = {}
DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
@@ -92,7 +92,7 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float:
demand_obj.base_value = original_val + per_node_leak
modifications.append((demand_obj, original_val))
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
temp_dir = calculation_temp_directory("leakage")
os.makedirs(temp_dir, exist_ok=True)
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
@@ -569,7 +569,7 @@ class LeakageProblem(Problem):
modifications.append((demand_obj, original_val))
# 结果保存在根目录的temp/leakage文件夹中
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
temp_dir = calculation_temp_directory("leakage")
os.makedirs(temp_dir, exist_ok=True)
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
@@ -1,10 +1,11 @@
"""Topology-only valve isolation search."""
from collections import defaultdict, deque
from typing import Any, Iterable
from typing import Any, Iterable, Literal
VALVE_LINK_TYPE = "valve"
AccidentElementType = Literal["auto", "link", "node"]
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
@@ -14,12 +15,37 @@ def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
return parts[0], parts[1], parts[2], parts[3]
def _reachable_nodes(
adjacency: dict[str, list[tuple[str, str]]],
start_nodes: Iterable[str],
blocked_links: set[str],
) -> set[str]:
visited = set(start_nodes)
queue = deque(visited)
while queue:
for neighbor, link_id in adjacency.get(queue.popleft(), ()):
if link_id not in blocked_links and neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return visited
def valve_isolation_analysis(
link_entries: Iterable[str],
accident_elements: str | list[str],
disabled_valves: list[str] | None = None,
*,
accident_element_type: AccidentElementType = "auto",
water_source_nodes: Iterable[str] | None = None,
closed_links: Iterable[str] = (),
) -> dict[str, Any]:
"""Determine boundary valves and affected nodes from a topology snapshot."""
"""Find nearest valve boundaries, then check source connectivity.
Legacy untyped IDs prefer links; explicit node mode handles cross-type ID
collisions. Supply impact is a before/after connectivity difference, not
a hydraulic pressure or demand calculation. Without source metadata only
the provisional boundary search is available.
"""
disabled_valves_set = set(disabled_valves or [])
target_elements = (
@@ -27,8 +53,13 @@ def valve_isolation_analysis(
if isinstance(accident_elements, str)
else accident_elements
)
if not target_elements:
raise ValueError("At least one accident element is required")
if accident_element_type not in {"auto", "link", "node"}:
raise ValueError(f"Invalid accident element type: {accident_element_type}")
closed_links_set = set(closed_links)
pipe_adj: dict[str, set[str]] = defaultdict(set)
adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list)
all_valves: dict[str, tuple[str, str]] = {}
link_lookup: dict[str, tuple[str, str, str]] = {}
node_set: set[str] = set()
@@ -39,46 +70,31 @@ def valve_isolation_analysis(
node_set.update((node1, node2))
if link_type_name == VALVE_LINK_TYPE:
all_valves[link_id] = (node1, node2)
else:
pipe_adj[node1].add(node2)
pipe_adj[node2].add(node1)
if link_id not in closed_links_set:
adjacency[node1].append((node2, link_id))
adjacency[node2].append((node1, link_id))
unknown_disabled = disabled_valves_set - all_valves.keys()
if unknown_disabled:
raise ValueError(f"Disabled valve was not found in topology: {sorted(unknown_disabled)}")
start_nodes: set[str] = set()
for element in target_elements:
if element in node_set:
start_nodes.add(element)
elif element in link_lookup:
if accident_element_type != "node" and element in link_lookup:
node1, node2, _ = link_lookup[element]
start_nodes.update((node1, node2))
elif accident_element_type != "link" and element in node_set:
start_nodes.add(element)
else:
raise ValueError(f"Accident element {element} was not found in topology")
extra_adj: dict[str, list[str]] = defaultdict(list)
boundary_valves: dict[str, tuple[str, str]] = {}
for valve_id, (node1, node2) in all_valves.items():
if valve_id in disabled_valves_set:
extra_adj[node1].append(node2)
extra_adj[node2].append(node1)
else:
boundary_valves[valve_id] = (node1, node2)
affected_nodes: set[str] = set()
queue = deque(start_nodes)
while queue:
node = queue.popleft()
if node in affected_nodes:
continue
affected_nodes.add(node)
queue.extend(pipe_adj.get(node, set()) - affected_nodes)
queue.extend(
neighbor
for neighbor in extra_adj.get(node, ())
if neighbor not in affected_nodes
)
closable_valves = set(all_valves) - disabled_valves_set - closed_links_set
affected_nodes = _reachable_nodes(adjacency, start_nodes, closable_valves)
must_close_valves: list[str] = []
optional_valves: list[str] = []
for valve_id, (node1, node2) in boundary_valves.items():
for valve_id in closable_valves:
node1, node2 = all_valves[valve_id]
node1_affected = node1 in affected_nodes
node2_affected = node2 in affected_nodes
if node1_affected and node2_affected:
@@ -89,14 +105,37 @@ def valve_isolation_analysis(
must_close_valves.sort()
optional_valves.sort()
isolatable = bool(must_close_valves)
failure_reason = None if isolatable else "no_boundary_valves"
sources = set(water_source_nodes or ())
source_check_performed = bool(sources)
supply_affected_nodes = None
if water_source_nodes is not None:
if not sources:
isolatable = False
failure_reason = "water_sources_unavailable"
else:
after_closure = _reachable_nodes(adjacency, sources, set(must_close_valves))
if affected_nodes & after_closure:
isolatable = False
failure_reason = "connected_to_water_source"
if isolatable:
before_closure = _reachable_nodes(adjacency, sources, set())
supply_affected_nodes = sorted(before_closure - after_closure)
result: dict[str, Any] = {
"accident_elements": target_elements,
"accident_element_type": accident_element_type,
"disabled_valves": disabled_valves,
"affected_nodes": sorted(affected_nodes) if isolatable else [],
"affected_node_count": len(affected_nodes),
"must_close_valves": must_close_valves,
"optional_valves": optional_valves,
"isolatable": isolatable,
"source_check_performed": source_check_performed,
"isolation_failure_reason": failure_reason,
"supply_affected_nodes": supply_affected_nodes,
"supply_affected_node_count": (
len(supply_affected_nodes) if supply_affected_nodes is not None else None
),
}
if len(target_elements) == 1:
result["accident_element"] = target_elements[0]
+2
View File
@@ -48,6 +48,7 @@ def _project_response(project: models.Project) -> AdminProjectResponse:
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
@@ -274,6 +275,7 @@ async def create_admin_project(
description=payload.description,
gs_workspace=payload.gs_workspace,
map_extent=payload.map_extent,
map_config=payload.map_config.model_dump(exclude_unset=True),
status=payload.status,
creator_user_id=current_user.id,
)
+1
View File
@@ -44,6 +44,7 @@ async def get_project_metadata(
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
project_role=ctx.project_role,
)
+1
View File
@@ -319,6 +319,7 @@ async def provision_project(
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
+42
View File
@@ -26,6 +26,8 @@ from app.auth.project_dependencies import (
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.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
from app.services.task_questions import create_question, answer_question
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
ActionExecuteCommand,
@@ -74,6 +76,7 @@ from app.domain.schemas.operational_task import (
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
TaskPresentationHistoryPage,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import (
@@ -521,6 +524,21 @@ async def get_operational_task_presentation_cursor(
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationHistoryPage)
async def get_operational_task_presentation_history(
task_id: UUID,
after: int = Query(default=0, ge=0),
through: int | None = Query(default=None, ge=0),
limit: int = Query(default=100, ge=1, le=200),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).presentation_history(task_id, after=after, through=through, limit=limit)
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,
@@ -771,3 +789,27 @@ async def withdraw_operational_task_external_source(
return await _service(ctx, conn).withdraw_external_source(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/questions", response_model=TaskQuestionRequest)
async def create_operational_task_question(
task_id: UUID, command: TaskQuestionCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await create_question(_service(ctx, conn), task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/questions/{question_id}/answer", response_model=OperationalTaskDetail)
async def answer_operational_task_question(
task_id: UUID, question_id: UUID, command: TaskQuestionReply,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await answer_question(_service(ctx, conn), task_id, question_id, command)
except DomainError as exc:
_raise_domain(exc)
+47 -6
View File
@@ -1,19 +1,60 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import ValidationError
from app.domain.schemas.place_search import PlaceSearchRequest, PlaceSearchResponse
from app.auth.project_dependencies import ProjectContext, get_metadata_repository, get_project_context
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.domain.schemas.project_map_config import PlaceSearchConfig, ProjectMapConfig
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchRequest, PlaceSearchResponse, ReverseGeocodingRequest
from app.services.place_search import PlaceSearchError, search_tianditu_places
from app.services.reverse_geocoding import reverse_geocode_tianditu
router = APIRouter()
async def get_place_search_config(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> PlaceSearchConfig:
project = await metadata_repo.get_project_by_id(ctx.project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
try:
config = ProjectMapConfig.model_validate(project.map_config or {}).place_search
except ValidationError:
raise HTTPException(status_code=503, detail="当前项目地名搜索配置无效") from None
if config is None or not config.enabled:
raise HTTPException(status_code=503, detail="当前项目暂未配置地名搜索")
return config
@router.post(
"/place-search-requests",
response_model=PlaceSearchResponse,
summary="Search Fengxian places",
description="调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。",
summary="Search project places",
description="调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。",
)
async def tianditu_place_search(request: PlaceSearchRequest) -> PlaceSearchResponse:
async def tianditu_place_search(
request: PlaceSearchRequest,
config: PlaceSearchConfig = Depends(get_place_search_config),
) -> PlaceSearchResponse:
try:
return await search_tianditu_places(request)
return await search_tianditu_places(request, config=config)
except PlaceSearchError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post(
"/reverse-geocoding-requests",
response_model=PlaceSearchItem,
summary="Reverse geocode a project map point",
description="查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。",
)
async def tianditu_reverse_geocode(
request: ReverseGeocodingRequest,
config: PlaceSearchConfig = Depends(get_place_search_config),
) -> PlaceSearchItem:
try:
return await reverse_geocode_tianditu(request, config=config)
except PlaceSearchError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+1
View File
@@ -34,6 +34,7 @@ async def get_project_info_endpoint(
description=project_detail.description,
gs_workspace=project_detail.gs_workspace,
map_extent=project_detail.map_extent,
map_config=getattr(project_detail, "map_config", {}) or {},
status=project_detail.status,
project_role="viewer", # Default role for public access
)
+16 -4
View File
@@ -161,6 +161,9 @@ def valve_isolation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
accident_element_type: Literal["auto", "link", "node"] = Query(
"auto", description="事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
),
):
"""
阀门隔离分析
@@ -168,13 +171,19 @@ def valve_isolation_endpoint(
- **network**: 管网名称(或数据库名称)
- **accident_element**: 发生事故的管段/节点ID列表
- **disabled_valves**: 已故障的阀门ID列表(可选)
- **accident_element_type**: auto优先管段;link仅匹配管段;node仅匹配节点
返回隔离方案,包括:
- must_close_valves: 必须关闭的阀门列表
- optional_valves: 可选关闭的阀门列表
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
- affected_node_count: 受影响的节点总数
- isolatable: 是否可以有效隔离
- affected_nodes / affected_node_count: 最近阀门围成的直接隔离区;不可隔离时列表为空,计数保留
- supply_affected_nodes / supply_affected_node_count: 按建议关阀后新增失去水源连接的节点;未验证或不可隔离时为null
- source_check_performed: 是否已执行水源连通检查
- isolation_failure_reason: 不可隔离原因;可隔离时为null
- isolatable: 存在关阀边界,且直接隔离区不再连通任何水源
使用模型初始开关状态进行无向拓扑分析,水池按潜在水源处理。
不包含时变控制、水池蓄水量或压力流量计算,不是现场停水范围的水力证明。
"""
# result = {
# "accident_element": "P461309",
@@ -193,7 +202,10 @@ def valve_isolation_endpoint(
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
# "optional_valves": [],
# }
result = analyze_valve_isolation(network, accident_element, disabled_valves)
result = analyze_valve_isolation(
network, accident_element, disabled_valves,
accident_element_type=accident_element_type,
)
return result
+49
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from psycopg import AsyncConnection
from pydantic import BaseModel, Field, field_validator
from app.domain.schemas.scada import ScadaLatestReadingsResponse
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
from .dependencies import get_postgres_connection, get_timescale_connection
@@ -96,6 +97,54 @@ async def get_scada_by_ids_time_range(
)
@router.get(
"/timeseries/scada-readings/latest",
response_model=ScadaLatestReadingsResponse,
summary="获取SCADA设备最新数据",
)
async def get_latest_scada_readings(
limit: int = Query(100, ge=1, le=500, description="返回设备数上限"),
offset: int = Query(0, ge=0, description="设备目录偏移量"),
conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
) -> ScadaLatestReadingsResponse:
"""Read one latest persisted row per project device without applying a time window."""
devices = await ScadaInfoRepository.get_scadas(postgres_conn)
selected_devices = devices[offset : offset + limit]
latest_by_device = await ScadaRepository.get_latest_scada_readings(
conn,
[device["device_id"] for device in selected_devices],
)
items = []
for device in selected_devices:
reading = latest_by_device.get(device["device_id"])
items.append(
{
"device_id": device["device_id"],
"device_type": device["device_type"],
"measurement_unit": device["measurement_unit"],
"transmission_mode": device["transmission_mode"],
"transmission_frequency": device["transmission_frequency"],
"observed_at": reading["time"] if reading else None,
"monitored_value": reading["monitored_value"] if reading else None,
"cleaned_value": reading["cleaned_value"] if reading else None,
}
)
observed_times = [item["observed_at"] for item in items if item["observed_at"] is not None]
return ScadaLatestReadingsResponse.model_validate(
{
"items": items,
"total": len(devices),
"limit": limit,
"offset": offset,
"observed_count": len(observed_times),
"latest_observed_at": max(observed_times) if observed_times else None,
}
)
@router.get(
"/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据"
)
+5 -1
View File
@@ -4,6 +4,8 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
BusinessRole = Literal["admin", "service", "user"]
ProjectRole = Literal["member", "viewer"]
@@ -75,10 +77,11 @@ class AdminProjectCreateRequest(BaseModel):
description: str | None = None
gs_workspace: str = Field(..., min_length=1, max_length=100)
map_extent: dict | None = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: ProjectStatus = "active"
class AdminProjectUpdateRequest(BaseModel):
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
name: str | None = Field(default=None, min_length=1, max_length=100)
code: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
@@ -94,6 +97,7 @@ class AdminProjectResponse(BaseModel):
description: str | None = None
gs_workspace: str
map_extent: dict | None = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: str
created_at: datetime
updated_at: datetime
+120
View File
@@ -0,0 +1,120 @@
"""Validated, local presentation blocks. Component props come from the frontend catalog."""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Annotated, Any, Literal
from jsonschema import Draft7Validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator
_catalog = json.loads(
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
)
_validators = {name: Draft7Validator(schema) for name, schema in _catalog["props"].items()}
Key = Annotated[str, Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")]
class UiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class AnswerUiElement(UiModel):
type: str
props: dict[str, Any]
children: list[Key] = Field(max_length=48)
class AnswerUiSpec(UiModel):
root: Key
elements: dict[Key, AnswerUiElement]
class AnswerUiBlock(UiModel):
version: Literal["answer-ui@1"]
block_id: Key
phase: Literal["process", "final"] | None = None
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
spec: AnswerUiSpec
@field_validator("phase", mode="before")
@classmethod
def validate_explicit_phase(cls, value):
if value is None:
raise ValueError("UI phase must be process or final when supplied")
return value
@model_serializer(mode="wrap")
def serialize_optional_phase(self, handler):
data = handler(self)
if self.phase is None:
data.pop("phase", None)
return data
@model_validator(mode="after")
def validate_components(self):
spec = self.spec
if not 1 <= len(spec.elements) <= 48:
raise ValueError("UI requires 1 to 48 elements")
# Match the existing JavaScript serialized character limit, including UTF-16.
serialized = json.dumps(self.model_dump(), ensure_ascii=False, separators=(",", ":"))
if len(serialized.encode("utf-16-le")) // 2 > 100000:
raise ValueError("UI block is too large")
for element in spec.elements.values():
validator = _validators.get(element.type)
if validator is None or not validator.is_valid(element.props):
raise ValueError("Unknown UI component or invalid properties")
if element.children and element.type not in {"Stack", "Section"}:
raise ValueError("Leaf components cannot contain children")
props = element.props
if element.type == "Chart":
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
raise ValueError("Chart series must match the axis")
if any(not math.isfinite(value) for series in props["series"] for value in series["data"]):
raise ValueError("Chart values must be finite")
if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]):
raise ValueError("Table row width must match its columns")
if element.type == "FollowUpForm":
names = [field["name"] for field in props["fields"]]
if len(names) != len(set(names)):
raise ValueError("Form field names must be unique")
visited: set[str] = set()
def visit(key: str, depth: int):
if depth > 12 or key in visited or key not in spec.elements:
raise ValueError("UI must be a bounded tree without duplicate or missing references")
visited.add(key)
for child in spec.elements[key].children:
visit(child, depth + 1)
visit(spec.root, 0)
if len(visited) != len(spec.elements):
raise ValueError("UI contains unreachable elements")
return self
class ReportUiBlock(UiModel):
block: AnswerUiBlock
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def validate_report_phase(self):
if self.block.phase == "process":
raise ValueError("Report UI blocks must contain final results")
return self
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
ids = [item.block.block_id for item in blocks]
if len(ids) != len(set(ids)):
raise ValueError("Report UI block ids must be unique")
serialized = json.dumps([item.model_dump() for item in blocks], ensure_ascii=False, separators=(",", ":"))
if len(serialized.encode("utf-8")) > 400000:
raise ValueError("Report UI exceeds 400 KB")
for item in blocks:
if not set(item.evidence_refs) <= set(evidence_refs):
raise ValueError("UI evidence must be included in report evidence")
for element in item.block.spec.elements.values():
if element.type == "Evidence" and element.props.get("evidence_id") not in item.evidence_refs:
raise ValueError("UI evidence component must reference its block evidence")
+4 -1
View File
@@ -1,7 +1,9 @@
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.domain.schemas.project_map_config import ProjectMapConfig
class ProjectMetaResponse(BaseModel):
@@ -11,6 +13,7 @@ class ProjectMetaResponse(BaseModel):
description: Optional[str] = None
gs_workspace: str
map_extent: Optional[dict] = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: str
project_role: str
+34
View File
@@ -16,6 +16,39 @@ TaskDataSource = Literal[
McpSnapshotSource = Literal[
'get_mutation_status',
'query_nearby_elements',
'list_regions',
'get_region',
'get_region_nodes',
'get_node_connections',
'query_network_extent',
'list_major_nodes',
'get_model_configuration',
'list_model_configurations',
'search_project_places',
'reverse_geocode',
'get_project_info',
'export_project_model',
'get_sensor_placement_candidate',
'export_sensor_placement',
'list_task_schedules',
'get_task_run_history',
'calculate_demand_allocation',
'run_burst_simulation',
'edit_model_element',
'edit_model_configuration',
'clean_scada_readings',
'write_scada_readings',
'delete_scada_readings',
'save_simulation_scenario',
'create_sensor_placement',
'update_sensor_placement',
'create_operational_task',
'create_task_schedule',
'update_task_schedule',
'retry_operational_task',
'cancel_task_run',
'run_simulation',
'run_contaminant_simulation',
'analyze_valve_closure',
@@ -25,6 +58,7 @@ McpSnapshotSource = Literal[
'run_burst_detection',
'run_burst_location',
'analyze_valve_isolation',
'generate_node_coverage',
'run_leakage_identification',
'get_element_properties',
'list_elements',
+115 -7
View File
@@ -6,7 +6,9 @@ from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, JsonValue, model_validator
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
from app.domain.schemas.task_question import TaskQuestionRequest
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.domain.schemas.workbench_runtime import (
@@ -217,8 +219,12 @@ class InvestigationDiagramNodeRef(StrictModel):
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
"pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"
] | None = Field(default=None, description=(
"Evidence-confirmed node state; waiting means prerequisites/external/system result, "
"waiting_human requires manual authorization/approval/feedback. "
"Unknown stays omitted/null; host controls colors."
))
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
@@ -239,6 +245,21 @@ class InvestigationDiagram(StrictModel):
return self
def _undeclared_diagram_node_refs(diagram: InvestigationDiagram | None) -> list[str]:
if diagram is None:
return []
missing: list[str] = []
for node in diagram.node_refs:
escaped = re.escape(node.node_id)
if not re.search(
rf"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|:::|--|==|-\.|$))",
diagram.mermaid,
re.MULTILINE,
):
missing.append(node.node_id)
return missing
class InvestigationReport(StrictModel):
"""Read model: absent historical facts remain unknown, never inferred."""
@@ -253,9 +274,11 @@ class InvestigationReport(StrictModel):
stop_reason: NonEmptyStr | None = None
data_gaps: list[InvestigationDataGap] | None = None
diagram: InvestigationDiagram | None = None
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
@model_validator(mode="after")
def validate_result(self):
validate_report_ui_blocks(self.ui_blocks, self.evidence_refs)
if self.outcome is None:
if self.stage is not None or self.stop_reason is not None or self.data_gaps is not None:
raise ValueError("structured report fields must be supplied together")
@@ -338,9 +361,19 @@ class AgentArtifactsCommand(StrictModel):
progress: InvestigationProgress | None = None
@model_validator(mode="after")
def require_artifact(self):
def validate_artifacts(self):
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")
for diagram in (
self.report.diagram if self.report is not None else None,
self.progress.diagram if self.progress is not None else None,
):
missing = _undeclared_diagram_node_refs(diagram)
if missing:
raise ValueError(
"diagram node references must use ids declared in Mermaid source: "
+ ", ".join(missing)
)
return self
@@ -499,7 +532,7 @@ class ExternalWorkOrderSnapshot(StrictModel):
scenario_code: NonEmptyStr | None = None
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
activity: list[WorkOrderActivity] = Field(default_factory=list)
origin: Literal["synthetic"]
origin: Literal["system_data"]
updated_at: WorkbenchDateTime
@model_validator(mode="before")
@@ -518,6 +551,7 @@ class OperationalTaskRun(StrictModel):
status: OperationalTaskRunStatus
revision: PositiveInt
agent_session_id: NonEmptyStr | None = None
agent_instance_id: NonEmptyStr | None = None
lease_expires_at: WorkbenchDateTime | None = None
heartbeat_at: WorkbenchDateTime | None = None
started_at: WorkbenchDateTime | None = None
@@ -567,6 +601,7 @@ class OperationalTaskPermissions(StrictModel):
can_decide_commands: bool
can_execute_commands: bool
can_decide_access: bool = False
can_answer_questions: bool = False
class OperationalTaskDetail(OperationalTaskSummary):
@@ -576,6 +611,7 @@ class OperationalTaskDetail(OperationalTaskSummary):
evidence: list[EvidenceRecord]
actions: list[HumanApprovalAction]
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
questions: list[TaskQuestionRequest] = Field(default_factory=list)
permissions: OperationalTaskPermissions
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
@@ -602,6 +638,7 @@ class AgentRunCommand(StrictModel):
request_id: UUID
expected_revision: PositiveInt
source_session_id: NonEmptyStr
agent_instance_id: NonEmptyStr | None = None
class AgentRunFailureCommand(AgentRunCommand):
@@ -668,7 +705,59 @@ 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"
effect: Literal["steady", "pulse"]
class TaskLocateFeatures(StrictModel):
action: Literal["locate_features"]
layer: TaskMapLayer
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
class TaskZoomToMap(StrictModel):
action: Literal["zoom_to_map"]
center: tuple[
Annotated[float, Field(strict=True, ge=-180, le=180, allow_inf_nan=False)],
Annotated[float, Field(strict=True, ge=-90, le=90, allow_inf_nan=False)],
]
zoom: Annotated[float, Field(strict=True, ge=0, le=24, allow_inf_nan=False)]
class TaskScadaAnalysisItem(StrictModel):
device_id: TaskFeatureId
level: Literal["high", "medium", "low", "unrated"]
class TaskRenderScadaAnalysis(StrictModel):
action: Literal["render_scada_analysis"]
items: Annotated[list[TaskScadaAnalysisItem], Field(min_length=1, max_length=100)]
@model_validator(mode="after")
def validate_unique_devices(self):
if len({item.device_id for item in self.items}) != len(self.items):
raise ValueError("SCADA analysis device IDs must be unique")
return self
class TaskClearScadaAnalysis(StrictModel):
action: Literal["clear_scada_analysis"]
class TaskOverlayGeometry(StrictModel):
type: Literal["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]
coordinates: list[JsonValue]
class TaskOverlayFeature(StrictModel):
type: Literal["Feature"]
geometry: TaskOverlayGeometry
properties: dict[str, JsonValue] | None
id: Annotated[str, Field(strict=True)] | Annotated[float, Field(strict=True, allow_inf_nan=False)] | None = None
class TaskOverlayData(StrictModel):
type: Literal["FeatureCollection"]
features: Annotated[list[TaskOverlayFeature], Field(min_length=1, max_length=1000)]
class TaskClearHighlights(StrictModel):
@@ -693,6 +782,7 @@ class TaskSetOverlay(StrictModel):
title: Annotated[str, Field(min_length=1, max_length=200)]
mode: Literal["geometry", "heatmap"]
evidence_id: NonEmptyStr
data: TaskOverlayData | None = None
class TaskClearOverlay(StrictModel):
@@ -701,7 +791,8 @@ class TaskClearOverlay(StrictModel):
TaskPresentationCommand = Annotated[
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
TaskHighlightFeatures | TaskLocateFeatures | TaskZoomToMap | TaskRenderScadaAnalysis
| TaskClearScadaAnalysis | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
Field(discriminator="action"),
]
@@ -733,6 +824,23 @@ class TaskPresentationCursor(StrictModel):
event_cursor: int = Field(ge=0)
class TaskPresentationHistoryItem(StrictModel):
event_seq: PositiveInt
run_id: UUID
execution_generation: PositiveInt
occurred_at: WorkbenchDateTime
# Stored records are validated against the current command contract by the
# reader; an obsolete record must not prevent reading the rest of the page.
command: dict[str, JsonValue]
class TaskPresentationHistoryPage(StrictModel):
task_id: UUID
event_cursor: int = Field(ge=0)
items: list[TaskPresentationHistoryItem]
next_after: int | None = Field(default=None, ge=0)
class TaskDataQueryResult(StrictModel):
evidence: EvidenceRecord
snapshot: TaskDataSnapshot
@@ -813,7 +921,7 @@ class OperationalTaskNotification(StrictModel):
description: NonEmptyStr
required_permission: NonEmptyStr | None = None
lifecycle_status: Literal["active", "resolved"] = "active"
subject_type: Literal["task", "run", "access_request", "action"] = "task"
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
subject_id: UUID | None = None
report_reference: InvestigationReportReference | None = None
resolved_at: WorkbenchDateTime | None = None
+9 -4
View File
@@ -1,5 +1,3 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
MAX_PLACE_SEARCH_RESULTS = 10
@@ -11,6 +9,13 @@ class PlaceSearchRequest(BaseModel):
keyword: str = Field(..., min_length=1, max_length=120)
class ReverseGeocodingRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
longitude: float = Field(ge=-180, le=180, allow_inf_nan=False)
latitude: float = Field(ge=-90, le=90, allow_inf_nan=False)
class PlaceSearchItem(BaseModel):
id: str = Field(..., min_length=1)
name: str = Field(..., min_length=1)
@@ -18,11 +23,11 @@ class PlaceSearchItem(BaseModel):
longitude: float = Field(..., ge=-180, le=180, allow_inf_nan=False)
latitude: float = Field(..., ge=-90, le=90, allow_inf_nan=False)
category: str | None = None
district: Literal["奉贤区"] = "奉贤区"
district: str = Field(min_length=1)
class PlaceSearchResponse(BaseModel):
items: list[PlaceSearchItem] = Field(..., max_length=MAX_PLACE_SEARCH_RESULTS)
total: int = Field(..., ge=0, le=MAX_PLACE_SEARCH_RESULTS)
truncated: bool = False
region: Literal["奉贤区"] = "奉贤区"
region: str = Field(min_length=1)
+29
View File
@@ -0,0 +1,29 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class PlaceSearchConfig(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
enabled: bool = False
provider: Literal["tianditu"] = "tianditu"
admin_code: str = Field(pattern=r"^(?:156)?[0-9]{6}$")
region: str = Field(min_length=1, max_length=100)
class ProjectMapConfig(BaseModel):
# Preserve existing map options such as legend when editing search settings.
model_config = ConfigDict(extra="allow")
place_search: PlaceSearchConfig | None = None
class ProjectMapConfigUpdate(BaseModel):
map_config: ProjectMapConfig | None = None
@model_validator(mode="after")
def reject_null_map_config(self):
if "map_config" in self.model_fields_set and self.map_config is None:
raise ValueError("map_config must be an object")
return self
+31 -1
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ScadaDeviceResponse(BaseModel):
@@ -17,3 +19,31 @@ class ScadaDeviceResponse(BaseModel):
y: float | None = None
longitude: float | None = None
latitude: float | None = None
class ScadaLatestReadingResponse(BaseModel):
"""Latest persisted observation for one project SCADA device."""
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
device_id: str
device_type: str
measurement_unit: str
transmission_mode: str
transmission_frequency: str
observed_at: datetime | None = None
monitored_value: float | None = None
cleaned_value: float | None = None
class ScadaLatestReadingsResponse(BaseModel):
"""Bounded device page with each device's latest persisted observation."""
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
items: list[ScadaLatestReadingResponse]
total: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
offset: int = Field(ge=0)
observed_count: int = Field(ge=0)
latest_observed_at: datetime | None = None
+75
View File
@@ -0,0 +1,75 @@
"""Durable investigation questions; answers are user input, not field evidence."""
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, model_validator
from app.domain.schemas.shared import StrictModel, PositiveInt
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
Id = Annotated[str, Field(min_length=1, max_length=128)]
class TaskQuestionOption(StrictModel):
id: Id
label: Annotated[str, Field(min_length=1, max_length=200)]
class TaskQuestion(StrictModel):
question_id: Id
title: Annotated[str, Field(min_length=1, max_length=200)]
body: Annotated[str, Field(min_length=1, max_length=2000)]
options: list[TaskQuestionOption] = Field(max_length=12)
mode: Literal["single", "multiple"]
allow_custom: bool
required: bool
@model_validator(mode="after")
def valid_options(self):
if len({o.id for o in self.options}) != len(self.options):
raise ValueError("duplicate option ids")
if not self.options and not self.allow_custom:
raise ValueError("question must offer an answer input")
return self
class TaskQuestionAnswer(StrictModel):
question_id: Id
selected_option_ids: list[Id] = Field(max_length=12)
custom_text: Annotated[str, Field(max_length=2000)]
class TaskQuestionCreate(StrictModel):
request_id: UUID
source_session_id: Id
run_id: UUID
questions: list[TaskQuestion] = Field(min_length=1, max_length=3)
@model_validator(mode="after")
def unique_questions(self):
if len({q.question_id for q in self.questions}) != len(self.questions):
raise ValueError("duplicate question ids")
if len(self.model_dump_json().encode()) > 32768:
raise ValueError("question definition too large")
return self
class TaskQuestionReply(StrictModel):
request_id: UUID
expected_version: PositiveInt
answers: list[TaskQuestionAnswer] = Field(max_length=3)
class TaskQuestionRequest(StrictModel):
question_request_id: UUID
task_id: UUID
run_id: UUID
execution_generation: PositiveInt
source_session_id: Id
questions: list[TaskQuestion]
status: Literal["pending", "answered", "cancelled"]
version: PositiveInt
created_at: WorkbenchDateTime
answered_at: WorkbenchDateTime | None = None
answered_by: str | None = None
answers: list[TaskQuestionAnswer] = Field(default_factory=list)
+74
View File
@@ -0,0 +1,74 @@
"""Database resources owned by the standalone calculation worker."""
from __future__ import annotations
from contextlib import asynccontextmanager
from uuid import UUID
from sqlalchemy import text
@asynccontextmanager
async def queue_repository(project_id):
"""Resolve the current project route and open its calculation queue."""
from app.core.config import settings
from app.infra.db.dynamic_manager import project_connection_manager
from app.infra.db.metadb.database import SessionLocal
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
project_uuid = UUID(str(project_id))
async with SessionLocal() as session:
metadata = MetadataRepository(session)
project = await metadata.get_project_by_id(project_uuid)
if not project or project.status != "active":
raise ValueError("Calculation project is not active")
routing = await metadata.get_project_db_routing(project_uuid, "biz_data")
if not routing or routing.db_type != "postgresql":
raise ValueError("Project business PostgreSQL routing is unavailable")
await session.commit()
pool_min_size = (
routing.pool_min_size
if routing.pool_min_size is not None
else settings.PROJECT_PG_POOL_MIN_SIZE
)
pool_max_size = (
routing.pool_max_size
if routing.pool_max_size is not None
else settings.PROJECT_PG_POOL_SIZE
)
async with project_connection_manager.pg_connection(
project_uuid,
"biz_data",
routing.dsn,
pool_min_size,
pool_max_size,
) as connection:
yield CalculationJobRepository(connection, project_id)
class CalculationSupervisorLease:
"""Keep the singleton advisory lock alive on one metadata connection."""
def __init__(self, connection):
self.connection = connection
async def ping(self) -> None:
await self.connection.execute(text("SELECT 1"))
await self.connection.commit()
@asynccontextmanager
async def calculation_supervisor_lease():
"""Acquire and release the session-level calculation supervisor lock."""
from app.infra.db.metadb.database import engine
async with engine.connect() as connection:
acquired = await connection.scalar(text("SELECT pg_try_advisory_lock(1748531781, 2)"))
await connection.commit()
if not acquired:
raise RuntimeError("Another calculation supervisor is already active")
try:
yield CalculationSupervisorLease(connection)
finally:
await connection.execute(text("SELECT pg_advisory_unlock(1748531781, 2)"))
await connection.commit()
+1
View File
@@ -43,6 +43,7 @@ class Project(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
map_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
status: Mapped[str] = mapped_column(String(20), default="active")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional, List
from uuid import UUID, uuid4
@@ -66,6 +66,7 @@ class ProjectDetail:
gs_workspace: str
map_extent: Optional[dict]
status: str
map_config: dict = field(default_factory=dict)
@dataclass(frozen=True)
@@ -221,6 +222,7 @@ class MetadataRepository:
map_extent: dict | None,
status: str,
creator_user_id: UUID | None = None,
map_config: dict | None = None,
) -> models.Project:
project = models.Project(
id=uuid4(),
@@ -230,6 +232,7 @@ class MetadataRepository:
gs_workspace=gs_workspace,
map_extent=map_extent,
status=status,
map_config=map_config or {},
created_at=_utcnow(),
updated_at=_utcnow(),
)
@@ -272,6 +275,7 @@ class MetadataRepository:
gs_workspace=gs_workspace,
map_extent=map_extent,
status="active",
map_config={},
created_at=_utcnow(),
updated_at=_utcnow(),
)
@@ -327,6 +331,8 @@ class MetadataRepository:
):
if field in updates:
setattr(project, field, updates[field])
if "map_config" in updates:
project.map_config = {**(project.map_config or {}), **updates["map_config"]}
project.updated_at = _utcnow()
await self.session.commit()
await self.session.refresh(project)
@@ -343,6 +349,7 @@ class MetadataRepository:
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=project.map_config or {},
status=project.status,
)
+150
View File
@@ -0,0 +1,150 @@
"""Durable project calculation queue. Every worker update is generation fenced."""
from uuid import uuid4
from fastapi import HTTPException
from psycopg.types.json import Jsonb
from app.mcp.errors import CalculationCapacityExceeded
# Explicit metadata projection prevents status polling from decoding large JSON results.
JOB_COLUMNS = "job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,status,error,lease_generation,lease_owner,lease_expires_at,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at"
PUBLIC_JOB_COLUMNS = "job_id,status,tool,error,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at"
class CalculationJobRepository:
def __init__(self, connection, project_id):
self.connection = connection
self.project_id = str(project_id)
async def maintain(self):
await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET status='timed_out',
error='{"code":"QUEUE_TIMEOUT"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
WHERE project_id=%s AND status='queued' AND queue_expires_at<=clock_timestamp()""",
(self.project_id,),
)
await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET result=NULL
WHERE project_id=%s AND result IS NOT NULL AND result_expires_at<=clock_timestamp()""",
(self.project_id,),
)
async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds,
queue_wait_seconds=3600, max_active_jobs=100):
async with self.connection.transaction():
return await self._enqueue(owner_user_id=owner_user_id, request_key=request_key,
tool=tool, parameters=parameters, binding=binding, timeout_seconds=timeout_seconds,
queue_wait_seconds=queue_wait_seconds, max_active_jobs=max_active_jobs)
async def _enqueue(self, *, owner_user_id, request_key, tool, parameters, binding,
timeout_seconds, queue_wait_seconds, max_active_jobs):
# Serialize admission within the caller's transaction, across every API process.
await self.connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 1748531781))", (self.project_id,))
await self.maintain()
cursor = await self.connection.execute(
f"SELECT {JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s AND request_key=%s",
(self.project_id, owner_user_id, request_key),
)
existing = await cursor.fetchone()
if existing:
row = dict(existing)
if (row['tool'] != tool
or {key: value for key, value in row['parameters'].items() if key != 'timeout_seconds'}
!= {key: value for key, value in parameters.items() if key != 'timeout_seconds'}
or row['binding'] != binding):
raise HTTPException(409, 'Calculation request_key was used with different inputs')
return row
cursor = await self.connection.execute(
"""SELECT count(*) AS active FROM workbench.calculation_jobs WHERE project_id=%s
AND status IN ('queued','running','cancel_requested')""", (self.project_id,))
if (await cursor.fetchone())['active'] >= max_active_jobs:
raise CalculationCapacityExceeded()
cursor = await self.connection.execute(
f"""INSERT INTO workbench.calculation_jobs
(job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,queue_expires_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,clock_timestamp()+%s*interval '1 second')
RETURNING {JOB_COLUMNS}""",
(uuid4(), self.project_id, owner_user_id, request_key, tool,
Jsonb(parameters), Jsonb(binding), timeout_seconds, queue_wait_seconds),
)
return dict(await cursor.fetchone())
async def get(self, job_id, owner_user_id=None, *, include_result=False):
await self.maintain()
columns = JOB_COLUMNS + (",result" if include_result else "")
cursor = await self.connection.execute(
f"""SELECT {columns} FROM workbench.calculation_jobs WHERE project_id=%s AND job_id=%s
AND (%s::uuid IS NULL OR owner_user_id=%s)""",
(self.project_id, job_id, owner_user_id, owner_user_id),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def list(self, *, owner_user_id, binding, limit, offset, status=None):
await self.maintain()
cursor = await self.connection.execute(
f"""SELECT {PUBLIC_JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s
AND binding @> %s AND (%s::text IS NULL OR status=%s) ORDER BY created_at DESC,job_id LIMIT %s OFFSET %s""",
(self.project_id, owner_user_id, Jsonb(binding), status, status, limit, offset),
)
return [dict(row) for row in await cursor.fetchall()]
async def cancel(self, job_id, owner_user_id):
await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET status=CASE WHEN status='queued' THEN 'cancelled'
ELSE 'cancel_requested' END,finished_at=CASE WHEN status='queued' THEN clock_timestamp() ELSE finished_at END,updated_at=clock_timestamp()
WHERE project_id=%s AND job_id=%s AND owner_user_id=%s AND status IN ('queued','running')""",
(self.project_id, job_id, owner_user_id),
)
return await self.get(job_id, owner_user_id)
async def recover(self):
await self.maintain()
# A crashed write is never automatically replayed.
await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET status='interrupted',
error='{"code":"WORKER_INTERRUPTED"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
WHERE project_id=%s AND status IN ('running','cancel_requested')
AND lease_expires_at < clock_timestamp()""", (self.project_id,),
)
async def claim(self, owner, lease_seconds):
await self.maintain()
cursor = await self.connection.execute(
"""UPDATE workbench.calculation_jobs j SET status='running',started_at=clock_timestamp(),lease_owner=%s,
lease_generation=j.lease_generation+1,
lease_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
WHERE j.job_id=(SELECT job_id FROM workbench.calculation_jobs
WHERE project_id=%s AND status='queued' AND queue_expires_at>clock_timestamp() ORDER BY created_at,job_id
FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING j.*""",
(owner, lease_seconds, self.project_id),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def heartbeat(self, row, owner, lease_seconds):
cursor = await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET lease_expires_at=clock_timestamp()+%s*interval '1 second',
updated_at=clock_timestamp() WHERE project_id=%s AND job_id=%s AND lease_owner=%s
AND lease_generation=%s AND lease_expires_at>clock_timestamp()
AND status IN ('running','cancel_requested') RETURNING status""",
(lease_seconds, self.project_id, row['job_id'], owner, row['lease_generation']),
)
result = await cursor.fetchone()
return result['status'] if result else None
async def finish(self, row, owner, status, *, result=None, error=None, result_retention_seconds=604800):
cursor = await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET status=%s,result=%s,error=%s,finished_at=clock_timestamp(),
result_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
WHERE project_id=%s AND job_id=%s AND lease_owner=%s AND lease_generation=%s
AND lease_expires_at>clock_timestamp() AND status IN ('running','cancel_requested')
AND (status!='cancel_requested' OR %s IN ('cancelled','interrupted')
OR (%s='failed' AND %s='CLEANUP_FAILED')) RETURNING job_id""",
(status, Jsonb(result) if result is not None else None, Jsonb(error) if error else None, result_retention_seconds,
self.project_id, row['job_id'], owner, row['lease_generation'], status, status, (error or {}).get('code')),
)
return await cursor.fetchone() is not None
+7
View File
@@ -89,3 +89,10 @@ class McpQueryRepository:
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))",
(f"mcp:{self.project_id}:{scope_key}:{call_id}",),
)
async def finish_receipt(self, scope_key: str, call_id: str, result: dict):
async with self.conn.cursor() as cur:
await cur.execute(
"UPDATE workbench.mcp_query_receipts SET result=%s WHERE project_id=%s AND scope_key=%s AND call_id=%s",
(Jsonb(result), self.project_id, scope_key, call_id),
)
+91 -10
View File
@@ -19,6 +19,7 @@ class OperationalTaskRepository:
def __init__(self, connection: AsyncConnection, project_id):
self.connection = connection
self.project_id = str(project_id)
self._agent_ownership_available: bool | None = None
@asynccontextmanager
async def transaction(self):
@@ -75,6 +76,48 @@ class OperationalTaskRepository:
status=409,
)
async def pending_calculation_jobs(
self, task_id: str, run_id: str, execution_request_id: str,
) -> list[str]:
"""Only the exact original run blocks final artifacts and completion."""
cursor = await self.connection.execute(
"""SELECT job_id FROM workbench.calculation_jobs
WHERE project_id=%s AND binding->>'task_id'=%s
AND binding->>'run_id'=%s AND binding->>'execution_request_id'=%s
AND status IN ('queued','running','cancel_requested')
ORDER BY created_at,job_id LIMIT 100""",
(self.project_id, task_id, run_id, execution_request_id),
)
return [str(row["job_id"]) for row in await cursor.fetchall()]
async def list_questions(self, task_id: str) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_questions
WHERE project_id=%s AND task_id=%s
ORDER BY document->>'created_at' DESC, question_request_id DESC LIMIT 100""",
(self.project_id, task_id),
)
return [row["document"] for row in await cursor.fetchall()]
async def get_question(self, task_id: str, question_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_questions
WHERE project_id=%s AND task_id=%s AND question_request_id=%s""",
(self.project_id, task_id, question_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def save_question(self, task_id: str, document: dict) -> None:
# All writers hold the owning task advisory lock.
await self.connection.execute(
"""INSERT INTO workbench.operational_task_questions
(project_id,task_id,question_request_id,document) VALUES (%s,%s,%s,%s)
ON CONFLICT (project_id,question_request_id) DO UPDATE SET document=excluded.document
WHERE operational_task_questions.task_id=excluded.task_id""",
(self.project_id, task_id, document["question_request_id"], Jsonb(document)),
)
async def get_task(self, task_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
@@ -87,7 +130,8 @@ class OperationalTaskRepository:
async def get_active_run(self, task_id: str) -> dict | None:
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,
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
@@ -102,7 +146,8 @@ class OperationalTaskRepository:
async def get_run(self, run_id: str) -> dict | None:
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,
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
@@ -129,13 +174,23 @@ class OperationalTaskRepository:
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"
"""SELECT to_regclass('workbench.agent_messages') IS NOT NULL
AND to_regclass('workbench.operational_task_questions') IS NOT NULL AS available,
EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid=to_regclass('workbench.operational_task_runs')
AND attname='agent_instance_id' AND NOT attisdropped
) AS agent_ownership_available"""
)
if not (await cursor.fetchone())["available"]:
readiness = await cursor.fetchone()
self._agent_ownership_available = bool(readiness.get("agent_ownership_available"))
if not readiness["available"] or not self._agent_ownership_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",
"Agent execution storage is not provisioned; apply "
"resources/sql/024_agent_messages.sql, resources/sql/028_task_questions.sql, "
"and resources/sql/030_operational_run_agent_ownership.sql "
"to this project database before starting runs",
status=503,
)
@@ -176,14 +231,26 @@ class OperationalTaskRepository:
)
async def update_run(self, run: dict, expected_revision: int) -> None:
if self._agent_ownership_available is None:
cursor = await self.connection.execute(
"""SELECT EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid=to_regclass('workbench.operational_task_runs')
AND attname='agent_instance_id' AND NOT attisdropped
) AS available"""
)
self._agent_ownership_available = bool((await cursor.fetchone())["available"])
owner_assignment = "agent_instance_id=%s," if self._agent_ownership_available else ""
owner_values = (run.get("agent_instance_id"),) if self._agent_ownership_available else ()
cursor = await self.connection.execute(
"""UPDATE workbench.operational_task_runs SET
status=%s,revision=%s,agent_session_id=%s,lease_expires_at=%s,
f"""UPDATE workbench.operational_task_runs SET
status=%s,revision=%s,agent_session_id=%s,{owner_assignment}lease_expires_at=%s,
heartbeat_at=%s,started_at=%s,finished_at=%s,failure_code=%s,
failure_detail=%s,dispatch_attempts=%s,next_dispatch_at=%s,updated_at=%s
WHERE project_id=%s AND run_id=%s AND revision=%s""",
(
run["status"], run["revision"], run.get("agent_session_id"),
*owner_values,
run.get("lease_expires_at"), run.get("heartbeat_at"),
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
run.get("failure_detail"), run.get("dispatch_attempts", 0),
@@ -200,7 +267,8 @@ class OperationalTaskRepository:
) -> list[dict]:
result = 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,
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
@@ -229,7 +297,8 @@ class OperationalTaskRepository:
) -> 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,
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
@@ -286,6 +355,18 @@ class OperationalTaskRepository:
async def current_event_cursor(self) -> int:
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
async def list_presentation_events(self, task_id: str, after: int, through: int, limit: int) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT event_seq,occurred_at,metadata
FROM workbench.domain_event_log
WHERE project_id=%s AND topic='operational.presentation.requested'
AND aggregate_type='operational_task_presentation' AND aggregate_id=%s
AND event_seq>%s AND event_seq<=%s
ORDER BY event_seq LIMIT %s""",
(self.project_id, task_id, after, through, limit),
)
return [dict(row) for row in await cursor.fetchall()]
async def get_task_by_create_request(self, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
+1
View File
@@ -337,6 +337,7 @@ async def task_context_page(
"evidence": ("operational_task_evidence", "evidence_id"),
"actions": ("operational_task_actions", "action_id"),
"access_requests": ("operational_task_access_requests", "access_request_id"),
"questions": ("operational_task_questions", "question_request_id"),
}
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
if section in {"access_requests", "progress"}:
+8 -9
View File
@@ -12,20 +12,19 @@ async def lock_event_transaction(connection: AsyncConnection) -> None:
PostgreSQL sequence allocation is not commit order. All producers must use
this same transaction lock so a committed higher cursor cannot hide an
earlier, still-uncommitted event. Other BizDBs remain rollout opt-out.
earlier, still-uncommitted event. The lock is local to the connected BizDB
and creates no schema or data in projects without an event log.
"""
if getattr(getattr(connection, "info", None), "dbname", None) == "tjwater_v2":
await connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
await connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
def lock_event_transaction_sync(connection: Connection) -> None:
"""Synchronous counterpart using the identical PostgreSQL lock key."""
if getattr(getattr(connection, "info", None), "dbname", None) == "tjwater_v2":
connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
class WorkbenchEventRepository:
@@ -46,6 +46,25 @@ class ScadaRepository:
)
return await cur.fetchall()
@staticmethod
async def get_latest_scada_readings(
conn: AsyncConnection,
device_ids: List[str],
) -> dict[str, dict[str, Any]]:
"""Return at most one latest row for each requested canonical device ID."""
if not device_ids:
return {}
async with conn.cursor() as cur:
await cur.execute(
f"SELECT DISTINCT ON (device_id) {ScadaRepository.RESULT_COLUMNS} "
"FROM scada.measurements WHERE device_id = ANY(%s) "
"ORDER BY device_id, time DESC",
(device_ids,),
)
rows = await cur.fetchall()
return {str(row["device_id"]): dict(row) for row in rows}
@staticmethod
def get_scada_by_ids_time_range_sync(
conn: Connection,
+1 -1
View File
@@ -84,7 +84,7 @@ class Output:
def units(self) -> dict[str, str]:
f_us = ["CFS", "GPM", "MGD", "IMGD", "AFD", "LPS", "LPM", "MLD", "CMH", "CMD"]
p_us = ["PSI", "MTR", "KPA"]
p_us = ["PSI", "KPA", "MTR"]
q_us = ["NONE", "MGL", "UGL", "HOURS", "PRCNT"]
f, p, q = ctypes.c_int(1), ctypes.c_int(2), ctypes.c_int(3)
f_u, p_u, q_u = ctypes.c_int(), ctypes.c_int(), ctypes.c_int()
@@ -52,7 +52,7 @@ class SampleWorkOrderReceipt(BaseModel):
work_order_id: UUID
project_id: UUID
created_at: datetime
origin: Literal["synthetic"]
origin: Literal["system_data"]
status: Literal[
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
]
@@ -195,7 +195,8 @@ class HttpOperationalActionDispatcher:
receipt = ActionReceipt(
adapter="peripheral_sample", receipt_id=str(order.work_order_id),
submitted_at=order.created_at, completed_at=order.created_at,
external_status=order.status, detail="合成外围工单已创建,现场工作进度由外围系统继续同步",
external_status=order.status,
detail="外围工单已创建,现场工作进度由外围系统继续同步",
)
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
result = CommandGatewayReceipt.model_validate(value)
+26 -1
View File
@@ -5,6 +5,7 @@ import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
import httpx
from pydantic import Field
@@ -33,6 +34,23 @@ class TaskDataError(Exception):
self.detail = detail
def peripheral_records_origin(items: object) -> Literal["system_data", "synthetic"]:
"""Classify a record collection; mixed collections retain per-item provenance."""
if not isinstance(items, list) or any(
not isinstance(item, dict) or item.get("origin") not in {"system_data", "synthetic"}
for item in items
):
raise TaskDataError(502, "外围记录缺少明确来源,不能作为已核实证据")
return "synthetic" if items and all(item["origin"] == "synthetic" for item in items) else "system_data"
def normalize_peripheral_work_orders(items: object) -> list[dict[str, object]]:
"""Expose peripheral work orders only as project-owned system data."""
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
raise TaskDataError(502, "外围工单格式无效,不能作为已核实证据")
return [{**item, "origin": "system_data"} for item in items]
def _matches_project(value: object, project_id: str) -> bool:
if isinstance(value, list):
return all(_matches_project(item, project_id) for item in value)
@@ -147,7 +165,14 @@ async def read_task_data(
data = _select_demo_observations(data, query, str(context.project_id))
elif not _matches_project(data, str(context.project_id)):
raise TaskDataError(502, "数据源返回了其他项目的数据")
origin = "synthetic" if peripheral else "system_data"
origin = "system_data"
if query.source == "peripheral_work_orders":
items = data.get("items") if isinstance(data, dict) else None
data = {**data, "items": normalize_peripheral_work_orders(items)}
elif query.source == "peripheral_test_data":
items = data.get("items") if isinstance(data, dict) else None
origin = peripheral_records_origin(items)
data = {**data, "provenance_scope": "per_record", "source_origins": sorted({item["origin"] for item in items})}
historical_replay = False
if query.source in {"peripheral_points", "peripheral_observations"}:
source_origin = data.get("origin") if isinstance(data, dict) else None
+2
View File
@@ -14,6 +14,7 @@ from app.infra.db.metadb.database import close_metadata_engine
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
from app.native.wndb.core.connection import close_all_project_pools
from app.core.config import settings
from samples.peripheral.api import create_app as create_peripheral_app
# 导入审计中间件
from app.infra.audit.middleware import AuditMiddleware
@@ -80,6 +81,7 @@ 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")
app.mount("/peripheral", create_peripheral_app(), name="peripheral")
# Legacy routers without version prefix
# app.include_router(api_router)
+36 -5
View File
@@ -4,21 +4,51 @@ from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.mcp.models import Id, StrictInput
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
MAX_REQUEST_TIMEOUT_SECONDS = 240
class CalculationInput(StrictInput):
timeout_seconds: int = Field(
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
default=120, ge=1, le=MAX_REQUEST_TIMEOUT_SECONDS, strict=True,
description="本次HTTP请求响应等待预算,秒,默认120秒;不是后台计算执行时限,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询",
)
class NodeCoverageGroup(BaseModel):
model_config = ConfigDict(extra="forbid")
group_id: Id = Field(max_length=80)
title: str = Field(min_length=1, max_length=200)
node_ids: list[Id] = Field(min_length=1, max_length=10000)
class NodeCoverageInput(CalculationInput):
groups: list[NodeCoverageGroup] = Field(min_length=1, max_length=8,
description="已核实的模型节点集合;各组为需要比较的业务或空间集合,可重叠,不接受任意坐标")
concave_ratio: float = Field(default=0.25, ge=0, le=1, allow_inf_nan=False,
description="凹包比例;越接近1越平滑接近凸包,参数只决定示意形状")
buffer_m: float = Field(default=25, gt=0, le=1000, allow_inf_nan=False,
description="展示缓冲距离,米;不是服务半径或影响距离")
split_by_connectivity: bool = Field(default=False, strict=True,
description="需要按模型连接分组时设true;地理分布比较默认false,不能据此认定实际供水关系")
excluded_link_ids: list[Id] = Field(default_factory=list, max_length=200,
description="连通分组时额外视为断开的管段/阀门,例如已有隔离建议中的关阀ID;仅影响绘图分组")
@model_validator(mode="after")
def bounded_groups(self):
if len({group.group_id for group in self.groups}) != len(self.groups):
raise ValueError("coverage group IDs must be distinct")
if len({node for group in self.groups for node in group.node_ids}) > 10000:
raise ValueError("coverage supports at most 10000 distinct nodes")
if self.excluded_link_ids and not self.split_by_connectivity:
raise ValueError("excluded_link_ids requires split_by_connectivity")
return self
class BurstDetectionInput(CalculationInput):
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
@@ -38,7 +68,7 @@ class BurstDetectionInput(CalculationInput):
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;不是已知爆管管段,不能随意猜测")
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
@@ -69,6 +99,7 @@ class BurstLocationInput(CalculationInput):
class ValveIsolationInput(CalculationInput):
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link")
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
+33 -2
View File
@@ -4,9 +4,9 @@ import asyncio
from threading import BoundedSemaphore
from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel
from app.mcp.scenario_service import SCENARIO_TOOLS, calculate_scenario
from app.mcp.errors import CalculationCapacityExceeded
# These services synchronously read SCADA/model series or write online results.
# Topology-only isolation and model-based sensor placement need only BizDB.
@@ -21,10 +21,25 @@ _slots = BoundedSemaphore(2)
def _calculate(name: str, parameters: dict[str, Any], network: str, username: str) -> dict[str, Any]:
if not _slots.acquire(blocking=False):
raise HTTPException(503, "Calculation capacity is occupied")
raise CalculationCapacityExceeded()
try:
if name in SCENARIO_TOOLS:
return calculate_scenario(name, parameters, network=network, username=username)
if name == "calculate_demand_allocation":
from app.services import demand_allocation
target = parameters.pop("target")
limit, offset = parameters.pop("limit"), parameters.pop("offset")
demand = parameters["demand"]
if target == "nodes":
result = demand_allocation.calculate_demand_to_nodes(network, demand, parameters["node_ids"])
elif target == "region":
result = demand_allocation.calculate_demand_to_region(network, demand, parameters["region_id"])
else:
result = demand_allocation.calculate_demand_to_network(network, demand)
keys = sorted(result)
return {"items": [{"node_id": key, "demand": result[key]} for key in keys[offset:offset + limit]],
"total": len(keys), "limit": limit, "offset": offset,
"has_more": offset + limit < len(keys), "persisted": False}
if name == "optimize_sensor_placement":
from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity
parameters = dict(parameters)
@@ -56,6 +71,10 @@ def _calculate(name: str, parameters: dict[str, Any], network: str, username: st
from app.services.valve_isolation import analyze_valve_isolation
return analyze_valve_isolation(network=network, **parameters)
if name == "generate_node_coverage":
from app.services.node_coverage import generate_node_coverage
return generate_node_coverage(network=network, **parameters)
raise ValueError("Calculation is not registered")
finally:
_slots.release()
@@ -67,3 +86,15 @@ async def calculate(name: str, args: BaseModel, *, network: str, username: str)
return await asyncio.to_thread(
_calculate, name, args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), network, username,
)
async def bounded_calculation(function, *args, **kwargs):
"""Use the same native-work capacity for persistent calculation tools."""
def run():
if not _slots.acquire(blocking=False):
raise CalculationCapacityExceeded()
try:
return function(*args, **kwargs)
finally:
_slots.release()
return await asyncio.to_thread(run)
+50 -15
View File
@@ -32,10 +32,12 @@ from app.mcp.catalog import TOOLS
from app.mcp.config import McpSettings
from app.mcp.contexts import resolve_context, verify_task
from app.mcp.models import QueryResult
from app.mcp.errors import CalculationCapacityExceeded
from app.mcp.scope import project_scope
from app.mcp.query_service import execute_query, task_service, authorize_query
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.domain_errors import DomainError
logger = logging.getLogger(__name__)
@@ -200,8 +202,12 @@ class McpQueryApplication:
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
if tool.computation or tool.mutating:
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_request_timeout_seconds
schema["properties"]["timeout_seconds"]["default"] = min(
self.settings.query_timeout_seconds,
self.settings.max_request_timeout_seconds,
)
return schema
async def list_tools(self, ctx, params):
@@ -211,6 +217,10 @@ class McpQueryApplication:
for name in sorted(TOOLS):
tool = TOOLS[name].protocol_tool()
tool.input_schema.update(self.tool_input_schema(name))
tool.meta = {**(tool.meta or {}), "io.tjwater/request-timeout": {
"defaultSeconds": self.settings.query_timeout_seconds,
"maxSeconds": self.settings.max_request_timeout_seconds,
}}
tools.append(tool)
return types.ListToolsResult(
tools=tools,
@@ -289,11 +299,13 @@ class McpQueryApplication:
return tool_error(
"INVALID_QUERY", "Query parameters do not match the tool schema"
)
if (tool.computation or tool.mutating) and "timeout_seconds" not in args.model_fields_set:
args.timeout_seconds = min(self.settings.query_timeout_seconds, self.settings.max_request_timeout_seconds)
timeout_seconds = (
args.timeout_seconds if tool.computation else self.settings.query_timeout_seconds
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_timeout_seconds
)
if tool.computation and timeout_seconds > self.settings.max_calculation_timeout_seconds:
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_request_timeout_seconds:
return tool_error("INVALID_QUERY", "HTTP request timeout exceeds the server limit")
request = ctx.request
project = request.headers.get("x-project-id", "") if request else ""
meta = ctx.meta or {}
@@ -345,20 +357,28 @@ class McpQueryApplication:
)
result = QueryResult.model_validate(previous["result"])
else:
result = (
await self.executor(
scope, binding, tool, args, full_snapshot=True
from app.mcp.calculation_jobs import should_queue, submit_calculation
if should_queue(tool, args):
result = await submit_calculation(
scope, binding, tool, args, call_id,
job_timeout_seconds=self.settings.max_calculation_timeout_seconds,
)
else:
result = (
await self.executor(
scope, binding, tool, args, full_snapshot=True
)
if include_full
else await self.executor(scope, binding, tool, args)
)
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 {
if binding.task_id and not should_queue(tool, args) and name not in {
"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs",
"get_task_context",
"get_task_evidence",
}:
@@ -380,12 +400,16 @@ class McpQueryApplication:
result.source_ref = "mcp:" + str(
uuid5(NAMESPACE_URL, scope_key + ":" + call_id)
)
snapshot_data = result.model_dump(mode="json")
from app.mcp.extended_service import ARTIFACT_TOOLS
if name in ARTIFACT_TOOLS:
snapshot_data["data"]["file"].pop("data", None)
snapshot = TaskDataSnapshot(
source=name,
source_ref=result.source_ref,
queried_at=result.queried_at,
origin=result.origin,
data=result.model_dump(mode="json"),
data=snapshot_data,
truncated=result.truncated,
)
evidence, _ = await task_service(
@@ -400,8 +424,10 @@ class McpQueryApplication:
)
output = result.model_dump(mode="json")
content = []
from app.mcp.extended_service import ARTIFACT_TOOLS
if (
not include_full
and name not in ARTIFACT_TOOLS
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
):
evidence_id = result.evidence_id or (
@@ -415,6 +441,10 @@ class McpQueryApplication:
"resource_uri": uri,
"preview": "The complete query snapshot is saved in task evidence",
}
from app.mcp.presentation_preview import saved_presentation_preview
presentation = saved_presentation_preview(result)
if presentation is not None:
output["data"]["presentation"] = presentation
output["truncated"] = True
output["data_gaps"] = [
*result.data_gaps,
@@ -440,12 +470,17 @@ class McpQueryApplication:
),
)
return types.CallToolResult(content=content, structuredContent=output)
except DomainError as exc:
return tool_error(exc.code, exc.message)
except CalculationCapacityExceeded as exc:
return tool_error(exc.code, str(exc.detail))
except HTTPException as exc:
codes = {
401: "AUTH_REQUIRED",
403: "QUERY_CONTEXT_DENIED",
404: "NOT_FOUND",
409: "QUERY_CONTEXT_STALE",
410: "RESULT_EXPIRED",
413: "RESULT_TOO_LARGE",
422: "INVALID_QUERY",
503: "SOURCE_UNAVAILABLE",
@@ -454,7 +489,7 @@ class McpQueryApplication:
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}
and exc.status_code in {403, 404, 409, 410, 413, 422}
else "Query is unavailable",
)
except (UndefinedTable, InvalidSchemaName):
@@ -463,7 +498,7 @@ class McpQueryApplication:
"MCP project storage has not been provisioned",
)
except TimeoutError:
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
return tool_error("QUERY_TIMEOUT", "HTTP request timed out")
except ValueError:
return tool_error(
"INVALID_QUERY", "Invalid query value or incompatible source data"
+205
View File
@@ -0,0 +1,205 @@
"""Durable MCP calculation submission and owner-scoped result retrieval.
Only safe server-owned binding fields are persisted. OAuth tokens and context
handles are never used to resume a calculation.
"""
from datetime import datetime, timezone
from uuid import uuid5, NAMESPACE_URL
from fastapi import HTTPException
from app.mcp.models import BoundContext, QueryResult, PageInfo
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
QUEUED_TOOLS = frozenset({
"run_burst_location", "run_leakage_identification", "run_burst_detection",
"run_simulation", "run_burst_simulation", "run_contaminant_simulation",
"analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation",
"optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement",
})
CONTROL_TOOLS = frozenset({"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"})
BINDING_FIELDS = {"project_id", "subject", "client_id", "native_session_id", "purpose", "parent_session_id", "task_id", "run_id", "execution_request_id", "tool_cap", "permission_cap", "task_scope_cap"}
def durable_binding(binding: BoundContext) -> dict:
return binding.model_dump(mode="json", include=BINDING_FIELDS)
def restore_binding(document: dict) -> BoundContext:
# Expiration governs admission via a context handle; durable work has its own
# lifecycle and must revalidate identity, membership and run on every use.
return BoundContext.model_validate({**document, "context_handle": "durable-calculation", "expires_at": datetime.max.replace(tzinfo=timezone.utc)})
def should_queue(tool, args) -> bool:
return tool.name in QUEUED_TOOLS and (not tool.mutating or not args.dry_run)
def envelope(source: str, data, *, job_id="list", page=None) -> QueryResult:
return QueryResult(source=source, source_ref="calculation:" + str(job_id), queried_at=datetime.now(timezone.utc), semantics="task", data=data, page=page, truncated=bool(page and (page.offset or page.has_more)),
data_gaps=["Calculation status and queue receipts are not analytical evidence."])
def public_job(row: dict) -> dict:
fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error", "queue_expires_at", "result_expires_at"}
from fastapi.encoders import jsonable_encoder
expiry = row.get("result_expires_at")
available = row.get("status") == "succeeded" and (expiry is None or expiry > datetime.now(timezone.utc))
return {**jsonable_encoder({key: row[key] for key in fields if key in row}),
"result_available": available, "poll_after_ms": 1500}
async def submit_calculation(scope, binding, tool, args, call_id: str, *, job_timeout_seconds: int = 3600) -> QueryResult:
from app.mcp.query_service import authorize_query
await authorize_query(scope, binding, tool, args)
if binding.purpose == "task_reader":
raise HTTPException(403, "Read-only task agents cannot start calculations")
if tool.mutating:
from app.mcp.mutation_service import (
preview_change, check_digest, mutation_arguments, retryable_capacity_rejection,
)
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
mutation_repo = McpQueryRepository(scope.connection, scope.project.project_id)
mutation_key = "mutation:" + binding.subject + ":" + binding.native_session_id
approved_parameters = {"tool": tool.name, "arguments": mutation_arguments(args, preview=False)}
async with scope.connection.transaction():
await mutation_repo.lock_receipt(mutation_key, str(args.request_id))
previous = await mutation_repo.receipt(mutation_key, str(args.request_id))
if previous:
if previous["parameters"] != approved_parameters:
raise HTTPException(409, "request_id already has different parameters")
if previous["result"].get("status") == "completed":
return QueryResult.model_validate(previous["result"]["output"])
if not retryable_capacity_rejection(previous["result"]):
raise HTTPException(409, "Operation already started; inspect the existing calculation or mutation status")
check_digest(args, await preview_change(scope, binding, tool.name, args))
parameters = args.model_dump(mode="json", exclude={"context_handle", "timeout_seconds"})
request_id = str(args.request_id) if tool.mutating else call_id
key = str(uuid5(NAMESPACE_URL, ":".join([binding.subject, binding.client_id, binding.native_session_id, str(binding.task_id), str(binding.run_id), request_id])))
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
if binding.task_id:
from app.mcp.query_service import task_service
from app.mcp.contexts import verify_task
task_repo = task_service(scope).repo
# Share the operational transaction lock with finalization. Admission
# cannot race a pending-job check and then publish work into a closed run.
async with task_repo.transaction():
await task_repo.lock(f"task:{binding.task_id}")
await task_repo.lock(f"run:{binding.run_id}")
await verify_task(scope, binding)
row = await repo.enqueue(
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
)
else:
row = await repo.enqueue(
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
)
await scope.connection.commit()
return envelope(tool.name, public_job(row), job_id=row["job_id"])
def check_binding(binding, row):
original = row["binding"]
if any(str(original.get(key)) != str(getattr(binding, key)) for key in ("subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id")):
raise HTTPException(403, "Calculation belongs to a different session or task run")
async def authorize_original(scope, row):
from app.mcp.catalog import TOOLS
from app.mcp.query_service import authorize_query
from app.mcp.contexts import verify_task
original = restore_binding(row["binding"])
tool = TOOLS[row["tool"]]
# Legacy queue rows stored the old HTTP timeout. It does not govern durable execution.
parameters = {key: value for key, value in row["parameters"].items() if key != "timeout_seconds"}
args = tool.model.model_validate({**parameters, "context_handle": "durable-calculation-context-handle"})
if (str(scope.project.user_id) != str(row["owner_user_id"])
or str(scope.project.project_id) != str(original.project_id)):
raise HTTPException(403, "Calculation owner is no longer authorized")
if original.task_id:
await verify_task(scope, original)
await authorize_query(scope, original, tool, args)
return original, tool, args
async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
if tool.name == "list_calculation_jobs":
identity_scope = binding.model_dump(mode="json", include={
"subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id",
})
rows = await repo.list(owner_user_id=scope.project.user_id, binding=identity_scope, limit=args.limit + 1, offset=args.offset, status=args.status)
return envelope(tool.name, [public_job(row) for row in rows[:args.limit]], page=PageInfo(limit=args.limit, offset=args.offset, has_more=len(rows) > args.limit))
row = await repo.get(args.job_id, owner_user_id=scope.project.user_id,
include_result=tool.name == "get_calculation_result")
if row is None:
raise HTTPException(404, "Calculation was not found")
check_binding(binding, row)
await authorize_original(scope, row)
if tool.name == "cancel_calculation_job":
if binding.purpose == "task_reader":
raise HTTPException(403, "Read-only task agents cannot cancel calculations")
row = await repo.cancel(args.job_id, owner_user_id=scope.project.user_id)
await scope.connection.commit()
if tool.name != "get_calculation_result" or row["status"] != "succeeded":
return envelope(tool.name, public_job(row), job_id=row["job_id"])
expiry = row.get("result_expires_at")
if row.get("result") is None or (expiry is not None and expiry <= datetime.now(timezone.utc)):
raise HTTPException(410, "Calculation result retention period has expired")
result = QueryResult.model_validate(row["result"])
result = await attach_calculation_evidence(scope, row, result)
value = result.data
for part in args.data_path:
if isinstance(value, dict) and part in value:
value = value[part]
else:
raise HTTPException(422, "data_path must select an existing object field")
if isinstance(value, list):
result.data = value[args.offset:args.offset + args.limit]
result.page = PageInfo(limit=args.limit, offset=args.offset, total=len(value), has_more=args.offset + args.limit < len(value))
result.truncated = result.truncated or args.offset > 0 or result.page.has_more
elif args.offset:
raise HTTPException(422, "offset requires a collection data_path")
else:
result.data = value
if len(result.model_dump_json().encode()) > 11000:
raise HTTPException(413, "Select a narrower data_path or smaller result page")
return result
async def execute_calculation_job(scope, row) -> QueryResult:
"""Worker entry point; success is persisted by the fenced supervisor."""
from app.mcp.query_service import execute_query
original, tool, args = await authorize_original(scope, row)
result = await execute_query(scope, original, tool, args)
return result
async def attach_calculation_evidence(scope, row, result):
from app.mcp.query_service import task_service
from app.mcp.contexts import verify_task
original, tool, _ = await authorize_original(scope, row)
if original.task_id:
from app.domain.schemas.operational_task import TaskDataSnapshot
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
repo = McpQueryRepository(scope.connection, scope.project.project_id)
key, call_id = "calculation:" + str(row["job_id"]), "result"
# Result lookup can leave an implicit SELECT transaction open. End it so
# evidence and its receipt commit together before returning a response.
await scope.connection.commit()
task_repo = task_service(scope).repo
# Match operational finalization's lock order: event lock, then task.
async with task_repo.transaction():
await repo.lock_receipt(key, call_id)
previous = await repo.receipt(key, call_id)
if previous:
return QueryResult.model_validate(previous["result"])
await task_repo.lock(f"task:{original.task_id}")
await verify_task(scope, original)
result.source_ref = key
evidence, _ = await task_service(scope).record_data_snapshot(original.task_id, TaskDataSnapshot(
source=tool.name, source_ref=key, queried_at=result.queried_at, origin=result.origin,
data=result.model_dump(mode="json"), truncated=result.truncated))
result.evidence_id = evidence.evidence_id
await repo.save_receipt(key, call_id, {"job_id": str(row["job_id"])}, result.model_dump(mode="json"))
return result
+21
View File
@@ -0,0 +1,21 @@
"""Bounded inputs for durable computation control."""
from typing import Literal
from uuid import UUID
from pydantic import Field
from app.mcp.models import StrictInput, PageInput, Id
CalculationStatus = Literal["queued", "running", "cancel_requested", "succeeded", "failed", "cancelled", "interrupted", "timed_out"]
class CalculationJobInput(StrictInput):
job_id: UUID
class CalculationResultInput(CalculationJobInput):
data_path: list[Id] = Field(default_factory=list, max_length=8)
limit: int = Field(default=20, ge=1, le=500)
offset: int = Field(default=0, ge=0, le=1000000)
class CalculationJobsInput(PageInput):
status: CalculationStatus | None = None
+57 -7
View File
@@ -4,7 +4,9 @@ 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 import calculation_models as jobs
from app.mcp import extended_models as e
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
@@ -17,9 +19,12 @@ class QueryTool:
semantics: str
task_only: bool = False
computation: bool = False
mutating: bool = False
def protocol_tool(self) -> types.Tool:
if self.name == "run_simulation":
if self.mutating:
suffix = "。默认dry_run只预览;执行必须携带预览摘要、相同request_id并通过宿主原生许可。仅项目普通聊天可用,后台调查和reader禁止;失败或超时先核实状态,不自动重试。"
elif self.name == "run_simulation":
suffix = "。任务调用复用证据留存。"
elif self.computation:
suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。"
@@ -32,11 +37,15 @@ class QueryTool:
)
return types.Tool(
name=self.name,
description=self.description + suffix,
description=self.description + suffix + (
"。执行返回持久job_id;排队回执不是分析证据,使用get_calculation_job查询状态、get_calculation_result分页读取成功结果,不重复提交。"
if self.name in {"run_burst_location", "run_leakage_identification", "run_burst_detection", "run_simulation", "run_burst_simulation", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement"}
else ""
),
inputSchema=self.model.model_json_schema(),
outputSchema=m.QueryResult.model_json_schema(),
annotations=types.ToolAnnotations(
readOnlyHint=False, destructiveHint=self.name == "run_simulation", openWorldHint=False
readOnlyHint=not (self.computation or self.mutating or self.name == "cancel_calculation_job"), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
),
)
@@ -50,8 +59,9 @@ _specs = [
("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),
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;burst_leakage必需,可使用有依据的估计或明确给定的情景假设,不要求现场确诊;本工具不反演总漏水量。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出不是污染扩散或水力影响范围;现场操作前须核实阀门映射及可用性,本工具不执行关阀", ValveIsolationInput, "simulation.run", "analysis", False, True),
("generate_node_coverage", "为当前项目已核实的模型节点集合生成空间覆盖示意,适用于候选分布、监测覆盖比较、区域成员、隔离或模拟结果节点等,不限调查任务。支持最多8组、合计10000个不同节点,从真实GIS查询坐标;监测设备、投诉或地址须先关联模型节点。可按连接分组,记录凹包/缓冲参数、缺失节点和display_only;不计算故障、污染、实际停水或服务边界。返回result.map_payload及coverage,任务中用set_overlay引用本次evidence_id、省略data展示;生成不自动操作地图", NodeCoverageInput, "webgis.view", "analysis", False, True),
(
"get_element_properties",
"读取当前项目的单个管网元素属性",
@@ -204,7 +214,7 @@ _specs = [
),
(
"query_peripheral_data",
"查询有限外围数据源;synthetic是合成证据",
"查询外围业务工单及监测数据;外围工单属于系统数据,监测数据按实际来源保留来源信息",
m.PeripheralInput,
"simulation.view",
"peripheral",
@@ -218,4 +228,44 @@ _specs = [
True,
),
]
_specs += [
("list_regions", "分页查询当前项目区域/DMA列表", m.PageInput, "webgis.view", "asset"),
("get_region", "查询当前项目区域边界,返回EPSG:4326;不等于供水拓扑", e.RegionInput, "webgis.view", "asset"),
("get_region_nodes", "分页查询区域配置的成员节点,未配置时使用区域几何筛选", e.RegionInput, "webgis.view", "asset"),
("get_node_connections", "分页查询节点直接连接的管线,不自动遍历全网", e.NodeConnectionsInput, "webgis.view", "asset"),
("query_network_extent", "按EPSG:4326矩形范围分页查询当前项目模型几何;无GIS配置不跨项目回退", e.ExtentInput, "webgis.view", "asset"),
("list_major_nodes", "按关联管道最小管径查询骨干节点,经纬度EPSG:4326", e.MajorNodesInput, "webgis.view", "asset"),
("get_model_configuration", "读取模式、曲线、控制规则、需水量、水质及反应等模型配置;集合按页返回", e.ModelConfigurationInput, "webgis.view", "asset"),
("list_model_configurations", "分页读取模式或曲线ID", e.ConfigurationListInput, "webgis.view", "asset"),
("search_project_places", "按项目配置的行政区搜索地名,最多10个候选,依赖天地图配置", e.PlaceSearchInput, "webgis.view", "asset"),
("reverse_geocode", "把EPSG:4326地图取点转为项目范围内地名,保留输入坐标", e.ReverseGeocodeInput, "webgis.view", "asset"),
("get_project_info", "读取当前项目概况与地图范围,不返回数据库路由或凭据", m.StrictInput, "webgis.view", "asset"),
("export_project_model", "从当前项目数据库导出INP或ChangeSet压缩文件,由宿主保存到会话文件", e.ProjectExportInput, "webgis.view", "asset"),
("get_sensor_placement_candidate", "读取监测点候选节点工程属性", e.SensorCandidateInput, "optimization.run", "asset"),
("export_sensor_placement", "导出已有监测点方案的Excel工程清单,由宿主保存到会话文件", e.SensorExportInput, "optimization.run", "analysis"),
("list_task_schedules", "分页读取当前项目定时任务配置,不启动调度", m.PageInput, "operational_task.view", "task"),
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后须核实目标数据,不自动重试", e.MutationStatusInput, "webgis.view", "task"),
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,只计算不修改模型,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;不是爆管侦测或定位,不保存正式方案", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;仅修改模型,不控制现场设备", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True),
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
("write_scada_readings", "预览或补录/修正SCADA数据,要求真实来源依据;不把模拟当实测", e.ScadaWriteInput, "scada.clean", "observed", False, False, True),
("delete_scada_readings", "预览或删除明确设备与24小时内时间窗的SCADA记录", e.ScadaDeleteInput, "scada.clean", "observed", False, False, True),
("save_simulation_scenario", "预览或重新计算并保存正式隔离场景成果;有真实计算开销,不保存模型生成的观测数组", e.SavedScenarioInput, "simulation.run", "analysis", False, False, True),
("create_sensor_placement", "预览或计算并保存正式压力监测点选址方案", e.SensorCreateInput, "optimization.run", "analysis", False, False, True),
("update_sensor_placement", "预览或修改本人有权编辑的监测点方案,保留旧点位乐观校验", e.SensorUpdateInput, "optimization.run", "analysis", False, False, True),
("create_operational_task", "预览或创建项目任务,不伪造Agent运行完成", e.TaskCreateInput, "operational_task.write", "task", False, False, True),
("create_task_schedule", "预览或创建定时任务配置", e.ScheduleCreateInput, "operational_task.write", "task", False, False, True),
("update_task_schedule", "预览或启停定时任务配置,校验版本", e.ScheduleUpdateInput, "operational_task.write", "task", False, False, True),
("retry_operational_task", "预览或重新入队已终止任务;不自动启动新Agent会话", e.TaskRetryInput, "operational_task.write", "task", False, False, True),
("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True),
]
_specs += [
("get_calculation_job", "读取当前身份与会话的持久计算状态;排队回执不是分析证据", jobs.CalculationJobInput, "webgis.view", "task"),
("get_calculation_result", "读取成功计算的原始结果;data_path选择嵌套集合,limit/offset按需分页", jobs.CalculationResultInput, "webgis.view", "task"),
("cancel_calculation_job", "请求取消本人当前会话的计算;运行中取消由独立计算进程处理,须继续核实最终状态", jobs.CalculationJobInput, "webgis.view", "task"),
("list_calculation_jobs", "分页读取本人当前会话及任务范围的计算状态", jobs.CalculationJobsInput, "webgis.view", "task"),
]
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
+4 -1
View File
@@ -21,7 +21,8 @@ class McpSettings(BaseSettings):
)
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)
query_timeout_seconds: int = Field(default=120, ge=1, le=240)
max_request_timeout_seconds: int = Field(default=240, ge=1, le=240)
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
model_config = SettingsConfigDict(
env_prefix="TJWATER_MCP_",
@@ -32,6 +33,8 @@ class McpSettings(BaseSettings):
@model_validator(mode="after")
def validate_resource(self):
if self.query_timeout_seconds > self.max_request_timeout_seconds:
raise ValueError("query timeout exceeds the HTTP request timeout limit")
if self.dev_http_issuer_uri:
dev_url = urlsplit(self.dev_http_issuer_uri)
try:
+3 -1
View File
@@ -69,7 +69,9 @@ async def bind_context(
project_id=scope.project.project_id,
subject=principal.subject,
client_id=principal.client_id,
tool_cap=sorted(name for name, tool in TOOLS.items() if command.purpose != "task_reader" or not tool.computation),
tool_cap=sorted(name for name, tool in TOOLS.items()
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating or name == "cancel_calculation_job"))
and (not tool.mutating or command.purpose == "ordinary_chat")),
permission_cap=sorted(permissions_for_context(scope.project)),
native_session_id=command.native_session_id,
purpose=command.purpose,
+16
View File
@@ -0,0 +1,16 @@
"""Errors with a known execution boundary for MCP calculations."""
from fastapi import HTTPException
class CalculationCapacityExceeded(HTTPException):
"""Raised by admission control before the native function is invoked."""
code = "CALCULATION_CAPACITY_EXCEEDED"
def __init__(self) -> None:
super().__init__(
503,
"Calculation capacity is occupied; calculation not started. "
"Retry the same request after an active calculation finishes.",
)
+289
View File
@@ -0,0 +1,289 @@
"""Finite project services exposed through MCP; no routes, SQL or actor overrides."""
from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator, create_model
from app.mcp.models import Id, PageInput, StrictInput
from app.mcp.analysis_models import (
CalculationInput, ScenarioInput, ContaminantSimulationInput, ValveClosureInput,
FlushingInput, PressureRegulationInput,
)
from app.domain.schemas.operational_task import (
OperationalTaskCreate, OperationalTaskScheduleCreate, OperationalTaskScheduleUpdate,
OperationalTaskRetryCommand, AgentRunCancelCommand,
)
from app.domain.schemas.sensor_placement import SensorPlacementUpdateRequest
class RegionInput(PageInput):
region_id: Id
class NodeConnectionsInput(PageInput):
node_id: Id
class ExtentInput(PageInput):
element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"] = "pipe"
bounds: tuple[float, float, float, float] = Field(description="EPSG:4326 [west,south,east,north]; 不跨180度经线")
@model_validator(mode="after")
def valid_bounds(self):
west, south, east, north = self.bounds
if not (-180 <= west < east <= 180 and -85 <= south < north <= 85):
raise ValueError("Invalid EPSG:4326 bounds")
if east - west > 1 or north - south > 1:
raise ValueError("Extent must be at most one degree per axis")
return self
class MajorNodesInput(PageInput):
min_diameter_mm: float = Field(gt=0, le=10000, allow_inf_nan=False)
ConfigKind = Literal[
"pattern", "curve", "control", "rule", "quality", "source", "reaction",
"pipe-reaction", "tank-reaction", "mixing", "emitter", "demand",
"time", "energy", "pump-energy", "option",
]
class ModelConfigurationInput(PageInput):
kind: ConfigKind
object_id: Id | None = None
data_path: list[Id] = Field(default_factory=list, max_length=8, description="选择嵌套字段,再按limit/offset分页读取")
@model_validator(mode="after")
def valid_target(self):
needs_id = {"pattern", "curve", "quality", "source", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand", "pump-energy"}
if (self.kind in needs_id) != (self.object_id is not None):
raise ValueError("object_id is required only for object-scoped configuration")
return self
class ConfigurationListInput(PageInput):
kind: Literal["pattern", "curve"]
class PlaceSearchInput(StrictInput):
keyword: str = Field(min_length=1, max_length=120)
class ReverseGeocodeInput(StrictInput):
longitude: float = Field(ge=-180, le=180, allow_inf_nan=False)
latitude: float = Field(ge=-90, le=90, allow_inf_nan=False)
class ProjectExportInput(StrictInput):
format: Literal["inp", "change_set"] = "inp"
version: Literal["2", "3"] = "2"
class SensorCandidateInput(StrictInput):
node_id: Id
class SensorExportInput(StrictInput):
run_id: UUID
class TaskHistoryInput(PageInput):
target_task_id: UUID
class MutationStatusInput(StrictInput):
request_id: UUID
class DemandAllocationInput(CalculationInput):
target: Literal["nodes", "region", "network"]
demand: float = Field(gt=0, allow_inf_nan=False, description="模型流量单位的总需水量;调用前查询模型单位")
node_ids: list[Id] = Field(default_factory=list, max_length=200)
region_id: Id | None = None
limit: int = Field(default=20, ge=1, le=200)
offset: int = Field(default=0, ge=0, le=10000)
@model_validator(mode="after")
def valid_target(self):
if (self.target == "nodes") != bool(self.node_ids) or (self.target == "region") != (self.region_id is not None):
raise ValueError("Provide node_ids for nodes or region_id for region only")
if len(self.node_ids) != len(set(self.node_ids)):
raise ValueError("node_ids must be distinct")
return self
class BurstSimulationInput(ScenarioInput):
burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20)
orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积cm²,不是流量;必须有依据")
@model_validator(mode="after")
def paired_bursts(self):
if len(self.burst_pipe_ids) != len(self.orifice_areas_cm2) or len(set(self.burst_pipe_ids)) != len(self.burst_pipe_ids):
raise ValueError("Distinct pipes and one orifice area per pipe are required")
return self
class MutationInput(CalculationInput):
request_id: UUID = Field(description="操作幂等键;预览与执行使用相同值,失败后先核实状态")
dry_run: bool = Field(default=True, strict=True, description="默认只预览;执行需dry_run=false及预览返回的expected_digest")
expected_digest: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
@model_validator(mode="after")
def require_preview(self):
if not self.dry_run and self.expected_digest is None:
raise ValueError("Execution requires the preview's expected_digest")
command = getattr(self, "command", None)
if command is not None and getattr(command, "request_id", self.request_id) != self.request_id:
raise ValueError("Nested command request_id must match the operation")
return self
class ModelElementMutationInput(MutationInput):
operation: Literal["create", "update", "delete"]
element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir", "region"]
element_id: Id
properties: dict[Id, JsonValue] = Field(default_factory=dict, max_length=40, description="仅允许get_network_schema声明的可写字段;id由element_id绑定")
@model_validator(mode="after")
def valid_properties(self):
if (self.operation == "delete") == bool(self.properties):
raise ValueError("Create/update require properties; delete takes none")
if "id" in self.properties or "type" in self.properties:
raise ValueError("Element identity is bound separately")
return self
class ConfigurationMutationInput(MutationInput):
operation: Literal["create", "update", "delete"] = "update"
kind: ConfigKind
object_id: Id | None = None
properties: dict[Id, JsonValue] = Field(default_factory=dict, max_length=40)
@model_validator(mode="after")
def valid_configuration(self):
ModelConfigurationInput(context_handle=self.context_handle, kind=self.kind, object_id=self.object_id)
if self.operation != "update" and self.kind not in {"pattern", "curve", "source", "mixing"}:
raise ValueError("Only pattern, curve, source and mixing support create/delete")
if (self.operation == "delete") == bool(self.properties):
raise ValueError("Create/update require properties; delete takes none")
if "id" in self.properties:
raise ValueError("Object identity is bound separately")
return self
class ScadaCleaningInput(MutationInput):
start: datetime
end: datetime
device_ids: list[Id] = Field(min_length=1, max_length=20, description="必须显式列出设备;不支持all")
@model_validator(mode="after")
def valid_window(self):
if self.start.tzinfo is None or self.end.tzinfo is None or not timedelta(0) < self.end - self.start <= timedelta(days=1):
raise ValueError("SCADA writes require explicit timezone and at most 24 hours")
if len(set(self.device_ids)) != len(self.device_ids):
raise ValueError("device_ids must be distinct")
return self
class ScadaRecord(BaseModel):
model_config = ConfigDict(extra="forbid")
device_id: Id
time: datetime
monitored_value: float | None = Field(default=None, allow_inf_nan=False)
cleaned_value: float | None = Field(default=None, allow_inf_nan=False)
@model_validator(mode="after")
def valid_record(self):
if self.time.tzinfo is None or not ({"monitored_value", "cleaned_value"} & self.model_fields_set):
raise ValueError("Record needs an explicit timezone and a value field")
return self
class ScadaWriteInput(MutationInput):
operation: Literal["insert", "update"]
records: list[ScadaRecord] = Field(min_length=1, max_length=500)
provenance: str = Field(min_length=1, max_length=2000, description="补录/修正的真实来源与依据,不得把模型值冒充实测")
@model_validator(mode="after")
def bounded_records(self):
keys = [(r.device_id, r.time) for r in self.records]
if len(set(keys)) != len(keys):
raise ValueError("Duplicate device/time records")
if (max(r.time for r in self.records) - min(r.time for r in self.records)).total_seconds() > 86400:
raise ValueError("At most 24 hours per write")
return self
class ScadaDeleteInput(ScadaCleaningInput):
reason: str = Field(min_length=1, max_length=2000)
class SensorCreateInput(MutationInput):
name: str = Field(min_length=1, max_length=64)
method: Literal["sensitivity", "kmeans"]
sensor_count: int = Field(ge=1, le=20)
min_diameter: int = Field(default=0, ge=0, le=5000)
class SensorUpdateInput(MutationInput):
run_id: UUID
changes: SensorPlacementUpdateRequest
def _payload(model):
return create_model(model.__name__ + "Payload", __config__=ConfigDict(extra="forbid"), **{
name: (field.annotation, field) for name, field in model.model_fields.items()
if name not in {"context_handle", "timeout_seconds"}
})
BurstPayload = _payload(BurstSimulationInput)
ContaminantPayload = _payload(ContaminantSimulationInput)
ValvePayload = _payload(ValveClosureInput)
FlushingPayload = _payload(FlushingInput)
PressurePayload = _payload(PressureRegulationInput)
class SavedScenarioInput(MutationInput):
name: str = Field(min_length=1, max_length=64)
scenario: Literal["burst", "contaminant", "valve_closure", "flushing", "pressure_regulation"]
parameters: BurstPayload | ContaminantPayload | ValvePayload | FlushingPayload | PressurePayload
@model_validator(mode="after")
def matching_scenario(self):
expected = {"burst": BurstSimulationInput, "contaminant": ContaminantSimulationInput, "valve_closure": ValveClosureInput, "flushing": FlushingInput, "pressure_regulation": PressureRegulationInput}[self.scenario]
expected.model_validate({**self.parameters.model_dump(), "context_handle": self.context_handle})
return self
class TaskCreateInput(MutationInput):
command: OperationalTaskCreate
@model_validator(mode="after")
def unclaimed_event(self):
if self.command.agent_session_id is not None or self.command.task_type != "event_investigation" or self.command.source.kind not in {"operator", "agent"}:
raise ValueError("Only unclaimed operator/agent event investigations may be created")
return self
class ScheduleCreateInput(MutationInput):
command: OperationalTaskScheduleCreate
class ScheduleUpdateInput(MutationInput):
schedule_id: UUID
command: OperationalTaskScheduleUpdate
class TaskRetryInput(MutationInput):
target_task_id: UUID
command: OperationalTaskRetryCommand
class TaskCancelInput(MutationInput):
target_task_id: UUID
run_id: UUID
command: AgentRunCancelCommand
+224
View File
@@ -0,0 +1,224 @@
"""Adapters for bounded, project-owned queries and downloadable exports."""
import asyncio
import base64
import hashlib
import io
import json
import zipfile
from datetime import datetime, timezone
from uuid import uuid4
from typing import Any, TYPE_CHECKING
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from psycopg import sql
from pydantic import BaseModel
from app.infra.db.postgresql.read_queries import ELEMENT_VIEWS, read_rows
from app.mcp.models import PageInfo, QueryResult, BoundContext
from app.mcp.scope import QueryScope
if TYPE_CHECKING:
from app.mcp.catalog import QueryTool
# Both function names and object identities are selected from a finite registry.
CONFIG_FUNCTIONS = {
"pattern": ("pattern", "id"), "curve": ("curve", "id"),
"control": ("control", None), "rule": ("rule", None),
"quality": ("quality", "node"), "source": ("source", "node"),
"reaction": ("reaction", None), "pipe-reaction": ("pipe_reaction", "pipe"),
"tank-reaction": ("tank_reaction", "tank"), "mixing": ("mixing", "tank"),
"emitter": ("emitter", "junction"), "demand": ("demand", "junction"),
"time": ("time", None), "energy": ("energy", None),
"pump-energy": ("pump_energy", "pump"), "option": ("option_v3", None),
}
EXTENDED_READ_TOOLS = {
"list_regions", "get_region", "get_region_nodes", "get_node_connections",
"query_network_extent", "list_major_nodes", "get_model_configuration",
"list_model_configurations", "search_project_places", "reverse_geocode",
"get_project_info", "export_project_model", "get_sensor_placement_candidate",
"export_sensor_placement", "list_task_schedules", "get_task_run_history", "get_mutation_status",
}
ARTIFACT_TOOLS = {"export_project_model", "export_sensor_placement"}
def configuration(network: str, kind: str, object_id: str | None = None) -> Any:
from app.services import tjnetwork
function, identity = CONFIG_FUNCTIONS[kind]
args = (network, object_id) if identity else (network,)
return getattr(tjnetwork, "get_" + function)(*args)
def artifact(filename: str, payload: bytes, mime_type: str) -> dict[str, Any]:
if len(payload) > 5 * 1024 * 1024:
raise HTTPException(413, "Export exceeds the 5 MiB compressed file budget")
return {"file": {
"name": filename, "mime_type": mime_type, "size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
"encoding": "base64", "data": base64.b64encode(payload).decode("ascii"),
}}
def project_export(network: str, args: BaseModel) -> dict[str, Any]:
from app.services import tjnetwork
from app.services.project_inp import temporary_project_inp
output = io.BytesIO()
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
if args.format == "inp":
with temporary_project_inp(network, purpose="mcp_export", version=args.version) as path:
archive.write(path, arcname="model.inp")
else:
changes = tjnetwork.export_inp(network, args.version)
if changes.operations:
changes.operations[0]["vertex"] = json.dumps(tjnetwork.get_all_vertices(network))
changes.operations[0]["scada"] = json.dumps(tjnetwork.get_all_scada_info(network))
archive.writestr("model.json", json.dumps(jsonable_encoder(changes.operations), ensure_ascii=False))
return artifact("model.zip", output.getvalue(), "application/zip")
def sensor_export(network: str, args: BaseModel, user: Any, project: Any) -> dict[str, Any]:
from app.services.sensor_placement import get_sensor_placement_run, build_sensor_placement_workbook
run = get_sensor_placement_run(network, args.run_id)
workbook = build_sensor_placement_workbook(
network=network, scheme=run, sensor_location=run["sensor_locations"], adjustment_status={},
)
return artifact("sensor-placement.xlsx", workbook.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
async def execute_extended(scope: QueryScope, binding: BoundContext, tool: "QueryTool", args: BaseModel) -> QueryResult:
name, conn, network = tool.name, scope.connection, scope.project.project_code
page, gaps, truncated = None, [], False
def paginate(items, *, already_paged=False):
nonlocal page, truncated
if already_paged:
more, selected, total = len(items) > args.limit, items[:args.limit], None
else:
total = len(items)
more = args.offset + args.limit < total
selected = items[args.offset:args.offset + args.limit]
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=more)
truncated = more or args.offset > 0
return selected
if name == "list_regions":
rows = await read_rows(conn, "SELECT id,region_type FROM gis.regions ORDER BY id LIMIT %s OFFSET %s", (args.limit + 1, args.offset))
data = paginate(rows, already_paged=True)
elif name == "get_region":
rows = await read_rows(conn, "SELECT id,region_type,ST_AsGeoJSON(ST_Transform(boundary,4326))::json AS geometry FROM gis.regions WHERE id=%s", (args.region_id,))
if not rows:
raise HTTPException(404, "Region not found in the current project")
data = {**rows[0], "crs": "EPSG:4326"}
elif name == "get_region_nodes":
rows = await read_rows(conn, """WITH configured AS (
SELECT node_id FROM gis.region_nodes WHERE region_id=%s
), members AS (
SELECT node_id FROM configured UNION ALL
SELECT n.node_id FROM gis.node_geometries n JOIN gis.regions r
ON r.id=%s AND ST_Intersects(n.geom,r.boundary)
WHERE NOT EXISTS (SELECT 1 FROM configured)
) SELECT node_id AS id FROM members ORDER BY node_id LIMIT %s OFFSET %s""",
(args.region_id, args.region_id, args.limit + 1, args.offset))
data = paginate(rows, already_paged=True)
gaps = ["Uses configured region membership, or geometry if none is configured; membership does not prove hydraulic connectivity"]
elif name == "get_node_connections":
rows = await read_rows(conn, """SELECT id,link_type,start_node_id,end_node_id FROM network.links
WHERE start_node_id=%s OR end_node_id=%s ORDER BY id LIMIT %s OFFSET %s""", (args.node_id, args.node_id, args.limit + 1, args.offset))
data = paginate(rows, already_paged=True)
elif name == "query_network_extent":
statement = sql.SQL("""SELECT v.id,to_jsonb(v)-'geom' AS properties,
ST_AsGeoJSON(ST_Transform(v.geom,4326))::json AS geometry FROM gis.{} v
WHERE ST_Intersects(v.geom,ST_Transform(ST_MakeEnvelope(%s,%s,%s,%s,4326),3857))
ORDER BY v.id LIMIT %s OFFSET %s""").format(sql.Identifier(ELEMENT_VIEWS[args.element_type]))
rows = await read_rows(conn, statement, (*args.bounds, args.limit + 1, args.offset))
data = {"items": paginate(rows, already_paged=True), "crs": "EPSG:4326", "source": "current_project_model"}
gaps = ["Only current-project model elements with valid stored geometry are covered; missing GIS geometry is not evidence of an empty network"]
elif name == "list_major_nodes":
rows = await read_rows(conn, """SELECT n.id,ST_X(ST_Transform(n.geom,4326)) AS longitude,
ST_Y(ST_Transform(n.geom,4326)) AS latitude FROM gis.junctions n
WHERE EXISTS (SELECT 1 FROM gis.pipes p WHERE p.diameter >= %s
AND (p.node1=n.id OR p.node2=n.id)) ORDER BY n.id LIMIT %s OFFSET %s""", (args.min_diameter_mm, args.limit + 1, args.offset))
data = {"items": paginate(rows, already_paged=True), "crs": "EPSG:4326"}
gaps = ["Only junctions with stored geometry and qualifying model pipes are covered; no other project's GIS is queried"]
elif name == "get_model_configuration":
data = await asyncio.to_thread(configuration, network, args.kind, args.object_id)
for key in args.data_path:
if not isinstance(data, dict) or key not in data:
raise HTTPException(404, "Configuration path not found")
data = data[key]
if isinstance(data, dict):
data = {"items": paginate([{"key": key, "value": value} for key, value in sorted(data.items())]), "data_path": args.data_path}
elif isinstance(data, list):
data = {"items": paginate(data), "data_path": args.data_path}
else:
data = {"value": data, "data_path": args.data_path}
elif name == "list_model_configurations":
from app.services import tjnetwork
rows = await asyncio.to_thread(getattr(tjnetwork, "get_" + args.kind + "s"), network)
data = paginate(sorted(rows or []))
elif name in {"search_project_places", "reverse_geocode", "get_project_info"}:
project = await scope.metadata.get_project_by_id(scope.project.project_id)
if project is None:
raise HTTPException(404, "Project not found")
if name == "get_project_info":
data = {key: getattr(project, key, None) for key in ("name", "code", "description", "map_extent", "status")}
data["project_id"] = str(scope.project.project_id)
else:
from app.domain.schemas.project_map_config import ProjectMapConfig
from app.domain.schemas.place_search import PlaceSearchRequest, ReverseGeocodingRequest
from app.services.place_search import search_tianditu_places, PlaceSearchError
from app.services.reverse_geocoding import reverse_geocode_tianditu
config = ProjectMapConfig.model_validate(project.map_config or {}).place_search
if config is None or not config.enabled:
raise HTTPException(503, "Current project place search is not configured")
await scope.metadata.session.commit()
try:
data = (await search_tianditu_places(PlaceSearchRequest(keyword=args.keyword), config=config)
if name == "search_project_places" else await reverse_geocode_tianditu(
ReverseGeocodingRequest(longitude=args.longitude, latitude=args.latitude), config=config))
truncated = bool(getattr(data, "truncated", False))
except PlaceSearchError as exc:
raise HTTPException(exc.status_code, exc.detail) from exc
elif name == "export_project_model":
data = await asyncio.to_thread(project_export, network, args)
elif name == "get_sensor_placement_candidate":
from app.services.sensor_placement import get_sensor_placement_candidate
data = await asyncio.to_thread(get_sensor_placement_candidate, network, args.node_id)
elif name == "export_sensor_placement":
data = await asyncio.to_thread(sensor_export, network, args, scope.user, scope.project)
elif name == "get_mutation_status":
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
repo = McpQueryRepository(conn, scope.project.project_id)
previous = await repo.receipt("mutation:" + binding.subject + ":" + binding.native_session_id, str(args.request_id))
if previous is None:
data = {"status": "not_started", "request_id": str(args.request_id)}
else:
output = previous["result"].get("output", {})
data = {"request_id": str(args.request_id), "status": previous["result"]["status"],
"tool": previous["parameters"]["tool"], "source_ref": output.get("source_ref"),
"queried_at": output.get("queried_at")}
if previous["result"]["status"] == "rejected":
from app.mcp.mutation_service import retryable_capacity_rejection
data["retryable"] = retryable_capacity_rejection(previous["result"])
data["error"] = previous["result"].get("error")
gaps = ["started does not prove success or rollback; verify target state before any new operation"]
elif name in {"list_task_schedules", "get_task_run_history"}:
from app.mcp.query_service import task_service
service = task_service(scope)
if name == "list_task_schedules":
data = paginate(await service.list_schedules())
else:
rows = await service.task_runs(args.target_task_id, limit=args.limit + 1, offset=args.offset)
data = paginate(rows, already_paged=True)
else:
raise ValueError("Unknown extended query")
return QueryResult(source=name, source_ref="mcp:" + uuid4().hex,
queried_at=datetime.now(timezone.utc), semantics=tool.semantics,
data=jsonable_encoder(data), page=page, truncated=truncated, data_gaps=gaps)
+3 -1
View File
@@ -22,7 +22,7 @@ class PageInput(StrictInput):
class TaskContextInput(PageInput):
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"] = "summary"
report_revision: int | None = Field(default=None, ge=1)
data_path: list[Id] = Field(default_factory=list, max_length=8)
@@ -78,6 +78,8 @@ class SchemaInput(StrictInput):
"pump-energy",
"option",
"scada-device",
"region", "pattern", "curve", "control", "rule", "quality", "source",
"reaction", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand",
]
+339
View File
@@ -0,0 +1,339 @@
"""Previewed writes with project permissions and a durable no-retry journal."""
import asyncio
import hashlib
import json
import math
from datetime import datetime, timezone
from uuid import uuid4
from typing import Any, TYPE_CHECKING
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from psycopg import sql
from pydantic import BaseModel
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
from app.infra.db.postgresql.read_queries import read_rows, scada_devices
from app.mcp.extended_service import CONFIG_FUNCTIONS, configuration
from app.mcp.errors import CalculationCapacityExceeded
from app.mcp.models import QueryResult, BoundContext
from app.mcp.scope import QueryScope
if TYPE_CHECKING:
from app.mcp.catalog import QueryTool
def digest(value: Any) -> str:
return hashlib.sha256(json.dumps(jsonable_encoder(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
def check_digest(args: BaseModel, preview: dict) -> None:
if not args.dry_run and args.expected_digest != digest(preview):
raise HTTPException(409, "Preview changed; request a new dry_run before execution")
def mutation_arguments(args: BaseModel, *, preview: bool) -> dict[str, Any]:
excluded = {"context_handle", "timeout_seconds"}
if preview:
excluded.update({"dry_run", "expected_digest"})
payload = args.model_dump(mode="json", exclude=excluded)
if hasattr(args, "records"):
# An omitted cleaned_value means keep it during update; explicit null
# means clear it. Preserve that difference in previews and replay keys.
payload["records"] = [record.model_dump(mode="json", exclude_unset=True) for record in args.records]
return payload
def check_properties(schema: dict, properties: dict, *, creating: bool = False) -> None:
def finite(value):
if isinstance(value, float) and not math.isfinite(value):
raise HTTPException(422, "Model values must be finite")
if isinstance(value, dict):
for child in value.values():
finite(child)
elif isinstance(value, list):
for child in value:
finite(child)
finite(properties)
for key, value in properties.items():
spec = schema.get(key)
if spec is None or spec.get("readonly"):
raise HTTPException(422, "Unknown or read-only model property: " + key)
if value is None and not spec.get("optional", False):
raise HTTPException(422, "Required model property cannot be null: " + key)
expected = spec.get("type")
if value is not None:
valid = {"str": isinstance(value, str), "float": isinstance(value, (int, float)) and not isinstance(value, bool),
"int": isinstance(value, int) and not isinstance(value, bool), "bool": isinstance(value, bool),
"list": isinstance(value, list), "str_list": isinstance(value, list) and all(isinstance(v, str) for v in value),
"float_list": isinstance(value, list) and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value),
"tuple_list": isinstance(value, list)}.get(expected, True)
if not valid:
raise HTTPException(422, "Invalid model property type: " + key)
if isinstance(value, list) and len(value) > 500:
raise HTTPException(422, "Model property exceeds 500 items")
if isinstance(value, list) and isinstance(spec.get("element"), dict):
for item in value:
if not isinstance(item, dict):
raise HTTPException(422, "Model list item must be an object")
check_properties(spec["element"], item, creating=True)
if creating:
required = {key for key, spec in schema.items() if not spec.get("readonly") and not spec.get("optional")}
if not required.issubset(properties):
raise HTTPException(422, "Missing required model properties: " + ",".join(sorted(required - properties.keys())))
def model_change(network: str, name: str, args: BaseModel, *, execute: bool) -> dict[str, Any]:
from app.services import tjnetwork
from app.native.wndb.core.database import ChangeSet, model_mutation_transaction
# Preview revalidation and the actual write share the same model mutation
# lock; another WNDB writer cannot change the target between them.
with model_mutation_transaction(network):
if name == "edit_model_element":
kind, identity, object_id = args.element_type, "id", args.element_id
current = getattr(tjnetwork, "get_" + kind)(network, object_id)
schema = getattr(tjnetwork, "get_" + kind + "_schema")(network)
else:
kind, identity = CONFIG_FUNCTIONS[args.kind]
object_id = args.object_id
current = configuration(network, args.kind, object_id)
schema = getattr(tjnetwork, "get_" + kind + "_schema")(network)
check_properties(schema, args.properties, creating=args.operation == "create")
if identity and args.operation == "create" and current:
raise HTTPException(409, "Object already exists")
if identity and args.operation != "create" and not current:
raise HTTPException(404, "Model object not found")
preview = {"tool": name, "operation": args.operation, "kind": kind,
"object_id": object_id, "before": current, "properties": args.properties}
if not execute:
return preview
check_digest(args, preview)
prefix = {"create": "add_", "update": "set_", "delete": "delete_"}[args.operation]
values = ({identity: object_id} if identity else {}) | args.properties
if kind == "region" and "boundary" in values:
values["boundary"] = [tuple(point) for point in values["boundary"]]
result = getattr(tjnetwork, prefix + kind)(network, ChangeSet(values))
if not result.operations:
raise HTTPException(409, "Model rejected the change; no operations were applied")
return {"operations": result.operations, "field_device_control": False}
async def scada_snapshot(scope: QueryScope, args: BaseModel, *, connection: Any = None, lock: bool = False) -> dict[str, Any]:
ids = sorted({r.device_id for r in args.records}) if hasattr(args, "records") else sorted(set(args.device_ids))
for device_id in ids:
if not await scada_devices(scope.connection, device_id=device_id, limit=1):
raise HTTPException(404, "SCADA device not found in the current project")
async def read(ts):
suffix = " FOR UPDATE" if lock else ""
if hasattr(args, "records"):
rows = []
for record in args.records:
rows.extend(await read_rows(ts, "SELECT time,device_id,monitored_value,cleaned_value FROM scada.measurements WHERE device_id=%s AND time=%s" + suffix, (record.device_id, record.time)))
else:
rows = await read_rows(ts, """SELECT time,device_id,monitored_value,cleaned_value FROM scada.measurements
WHERE device_id=ANY(%s) AND time >= %s AND time <= %s ORDER BY device_id,time LIMIT 10001""" + suffix, (ids, args.start, args.end))
if len(rows) > 10000:
raise HTTPException(413, "Narrow the SCADA window to at most 10000 records")
return {"count": len(rows), "devices": ids, "state_digest": digest(rows)}
if connection is not None:
return await read(connection)
async with scope.timescale() as ts:
return await read(ts)
async def preview_change(scope: QueryScope, binding: BoundContext, name: str, args: BaseModel) -> dict[str, Any]:
network = scope.project.project_code
if name in {"edit_model_element", "edit_model_configuration"}:
from app.mcp.analysis_service import bounded_calculation
return await bounded_calculation(model_change, network, name, args, execute=False)
payload = mutation_arguments(args, preview=True)
state = None
if name in {"clean_scada_readings", "write_scada_readings", "delete_scada_readings"}:
state = await scada_snapshot(scope, args)
elif name in {"update_sensor_placement"}:
from app.services.sensor_placement import get_sensor_placement_run, can_edit_sensor_placement
state = await asyncio.to_thread(get_sensor_placement_run, network, args.run_id)
if not can_edit_sensor_placement(scope.user, state):
raise HTTPException(403, "Cannot edit another user's sensor placement")
elif name in {"retry_operational_task", "cancel_task_run"}:
from app.mcp.query_service import task_service
state = (await task_service(scope).detail(args.target_task_id)).model_dump(mode="json")
elif name == "update_task_schedule":
from app.mcp.query_service import task_service
schedules = await task_service(scope).list_schedules()
state = next((s.model_dump(mode="json") for s in schedules if s.schedule_id == args.schedule_id), None)
if state is None:
raise HTTPException(404, "Schedule not found in the current project")
return {"tool": name, "parameters": payload, "before": state}
def saved_scenario(network: str, username: str, args: BaseModel) -> Any:
from app.services import simulation_scenarios as scenarios
from app.services.simulation import query_corresponding_element_id_and_query_id
parameters = args.parameters.model_dump(exclude_none=True)
parameters.pop("output_node_ids")
parameters.pop("output_link_ids")
common = dict(name=network, modify_pattern_start_time=parameters.pop("start_time").isoformat(),
modify_total_duration=parameters.pop("duration"), scheme_name=args.name, persist_results=True)
if args.scenario == "burst":
return scenarios.burst_analysis(**common, username=username,
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
if args.scenario == "contaminant":
return scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
if args.scenario == "valve_closure":
return scenarios.valve_close_analysis(**common, username=username,
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
if args.scenario == "flushing":
from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing supports LPS or CMH models only")
return scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
mappings = query_corresponding_element_id_and_query_id(network)
controls = parameters["pump_control"]
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
raise ValueError("Unknown model pump")
if any(value not in (0, 1) for key, values in controls.items() if key in mappings.fixed_pumps for value in values):
raise ValueError("Fixed pumps accept only 0 or 1")
return scenarios.pressure_regulation(**common, username=username,
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings)
async def apply_change(scope: QueryScope, binding: BoundContext, name: str, args: BaseModel) -> Any:
network = scope.project.project_code
if name in {"edit_model_element", "edit_model_configuration"}:
from app.mcp.analysis_service import bounded_calculation
return await bounded_calculation(model_change, network, name, args, execute=True)
if name in {"clean_scada_readings", "write_scada_readings", "delete_scada_readings"}:
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
from app.services.timeseries_analysis import TimeseriesAnalysisService
async with scope.timescale() as ts, ts.transaction():
await ts.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
# Serialize with other MCP maintenance calls, then validate again.
await ts.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ("mcp-scada-maintenance",))
before = await scada_snapshot(scope, args, connection=ts, lock=True)
payload = mutation_arguments(args, preview=True)
check_digest(args, {"tool": name, "parameters": payload, "before": before})
if name == "clean_scada_readings":
result = await TimeseriesAnalysisService.clean_scada_data(ts, scope.connection, args.device_ids, args.start, args.end)
return {"status": result, "field": "cleaned_value"}
if name == "delete_scada_readings":
async with ts.cursor() as cur:
await cur.execute("DELETE FROM scada.measurements WHERE device_id=ANY(%s) AND time >= %s AND time <= %s", (args.device_ids, args.start, args.end))
return {"deleted_rows": cur.rowcount}
if args.operation == "insert":
await ScadaRepository.insert_scada_batch(ts, [r.model_dump() for r in args.records])
else:
for record in args.records:
fields = sorted(record.model_fields_set & {"monitored_value", "cleaned_value"})
statement = sql.SQL("UPDATE scada.measurements SET {} WHERE device_id=%s AND time=%s").format(
sql.SQL(",").join(sql.SQL("{}=%s").format(sql.Identifier(field)) for field in fields))
async with ts.cursor() as cur:
await cur.execute(statement, tuple(getattr(record, field) for field in fields) + (record.device_id, record.time))
if cur.rowcount != 1:
raise HTTPException(409, "SCADA update requires exactly one existing record")
return {"operation": args.operation, "records": len(args.records), "provenance": args.provenance}
if name == "save_simulation_scenario":
from app.mcp.analysis_service import bounded_calculation
async with scope.simulation_routing():
await bounded_calculation(saved_scenario, network, scope.user.username, args)
return {"persisted": True, "name": args.name, "scenario": args.scenario, "results_query": "list_analysis_runs"}
if name == "create_sensor_placement":
from app.mcp.analysis_service import bounded_calculation
from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity
optimizer = optimize_sensor_placement_by_sensitivity if args.method == "sensitivity" else optimize_sensor_placement_by_kmeans
return await bounded_calculation(optimizer, project_code=network, run_name=args.name,
created_by=scope.user.username, sensor_count=args.sensor_count, min_diameter=args.min_diameter,
sensor_type="pressure", persist_results=True)
if name == "update_sensor_placement":
from app.services.sensor_placement import update_sensor_placement_run
return await asyncio.to_thread(update_sensor_placement_run, network, args.run_id, **args.changes.model_dump())
from app.mcp.query_service import task_service
service = task_service(scope)
if name == "create_operational_task":
if args.command.agent_session_id is not None or args.command.task_type != "event_investigation" or args.command.source.kind not in {"operator", "agent"}:
raise HTTPException(422, "Only unclaimed operator/agent event investigations may be created")
return await service.create(args.command)
if name == "create_task_schedule":
return await service.create_schedule(args.command)
if name == "update_task_schedule":
return await service.update_schedule(args.schedule_id, args.command)
if name == "retry_operational_task":
return await service.retry_task(args.target_task_id, args.command)
if name == "cancel_task_run":
return await service.cancel_run(args.target_task_id, args.run_id, args.command)
raise ValueError("Unknown mutation")
def retryable_capacity_rejection(result: dict[str, Any]) -> bool:
return (result.get("status") == "rejected" and result.get("retryable") is True
and result.get("error", {}).get("code") == CalculationCapacityExceeded.code)
async def execute_mutation(scope: QueryScope, binding: BoundContext, tool: "QueryTool", args: BaseModel) -> QueryResult:
name = tool.name
repo = McpQueryRepository(scope.connection, scope.project.project_id)
key, call_id = "mutation:" + binding.subject + ":" + binding.native_session_id, str(args.request_id)
parameters = {"tool": name, "arguments": mutation_arguments(args, preview=False)}
if not args.dry_run:
async with scope.connection.transaction():
await repo.lock_receipt(key, call_id)
previous = await repo.receipt(key, call_id)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(409, "request_id already has different parameters")
if previous["result"].get("status") == "completed":
return QueryResult.model_validate(previous["result"]["output"])
if not retryable_capacity_rejection(previous["result"]):
raise HTTPException(409, "Operation already started; verify its state before any retry")
preview = await preview_change(scope, binding, name, args)
if args.dry_run:
result_data = {"dry_run": True, "preview": preview, "expected_digest": digest(preview), "request_id": call_id}
if len(json.dumps(jsonable_encoder(result_data)).encode()) > 10000:
result_data["preview"] = {"tool": name, "summary": "Preview payload omitted; inspect the target with paged read tools", "before_digest": digest(preview.get("before"))}
else:
check_digest(args, preview)
async with scope.connection.transaction():
await repo.lock_receipt(key, call_id)
previous = await repo.receipt(key, call_id)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(409, "request_id already has different parameters")
if previous["result"].get("status") == "completed":
return QueryResult.model_validate(previous["result"]["output"])
if not retryable_capacity_rejection(previous["result"]):
raise HTTPException(409, "Operation was started by another request; verify its state")
await repo.finish_receipt(key, call_id, {"status": "started"})
else:
await repo.save_receipt(key, call_id, parameters, {"status": "started"})
await scope.connection.commit()
# Keep started on cancellation/timeout: a native thread may still commit.
try:
applied = await apply_change(scope, binding, name, args)
except CalculationCapacityExceeded as exc:
# This specific admission failure proves that no calculation began.
# Other errors and cancellation remain ambiguous and are not retried.
async with scope.connection.transaction():
await repo.lock_receipt(key, call_id)
await repo.finish_receipt(key, call_id, {
"status": "rejected", "retryable": True,
"error": {"code": exc.code, "message": exc.detail},
"rejected_at": datetime.now(timezone.utc).isoformat(),
})
await scope.connection.commit()
raise
result_data = {"dry_run": False, "request_id": call_id, "result": jsonable_encoder(applied)}
result = QueryResult(source=name, source_ref="mcp:" + uuid4().hex,
queried_at=datetime.now(timezone.utc), semantics=tool.semantics, data=result_data)
if not args.dry_run:
async with scope.connection.transaction():
await repo.finish_receipt(key, call_id, {"status": "completed", "output": result.model_dump(mode="json")})
return result
+19 -6
View File
@@ -7,7 +7,12 @@ from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, sca
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
from app.infra.integrations.operational_data import (
TaskDataError,
_select_demo_observations,
normalize_peripheral_work_orders,
peripheral_records_origin,
)
async def read_peripheral_query(scope, args):
@@ -37,17 +42,25 @@ async def read_peripheral_query(scope, args):
truncated = len(rows) > args.limit
if any(item.get("project_id") != project for item in items):
raise HTTPException(502, "Peripheral project mismatch")
try:
if args.source == "peripheral_work_orders":
items = normalize_peripheral_work_orders(items)
origin = "system_data"
else:
origin = peripheral_records_origin(items)
except TaskDataError as exc:
raise HTTPException(exc.status, exc.detail) from exc
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"
)
if args.source == "peripheral_test_data":
data.update(
provenance_scope="per_record",
source_origins=sorted({item["origin"] for item in items}),
)
elif args.source == "peripheral_points":
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
data = {
+31
View File
@@ -0,0 +1,31 @@
"""Keep a saved geometry discoverable even when its inline response is shortened."""
from uuid import NAMESPACE_URL, uuid5
from app.mcp.models import QueryResult
def saved_presentation_preview(result: QueryResult) -> dict | None:
if result.source != "generate_node_coverage" or not result.evidence_id:
return None
analysis = result.data.get("result") if isinstance(result.data, dict) else None
if not isinstance(analysis, dict):
return None
payload = analysis.get("map_payload")
if not isinstance(payload, dict) or not payload.get("features"):
return None
coverage = analysis.get("coverage", {})
return {
"command": {
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}",
"title": "节点空间覆盖示意",
},
"display_only": True,
"coverage_status": coverage.get("status"),
"groups": {role: {
"node_count": len(group.get("node_ids", [])),
"mapped_node_count": group.get("mapped_node_count", 0),
"missing_node_count": len(group.get("missing_node_ids", [])),
} for role, group in coverage.get("groups", {}).items()},
}
+26 -3
View File
@@ -69,6 +69,14 @@ async def authorize_query(
if tool.name not in binding.tool_cap:
raise HTTPException(403, "Query is outside the captured tool catalog")
permissions = permissions_for_context(scope.project)
if tool.mutating:
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
raise HTTPException(403, "Writes require a project member's ordinary interactive chat")
required = {tool.permission}
if tool.name == "save_simulation_scenario":
required.add("scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
raise HTTPException(403, "Write and input data permissions are required")
if tool.computation:
if tool.name == "optimize_sensor_placement" and scope.project.project_role != "member":
raise HTTPException(403, "Sensor placement requires project member access")
@@ -123,6 +131,15 @@ async def execute_query(
full_snapshot: bool = False,
) -> QueryResult:
await authorize_query(scope, binding, tool, args)
if tool.name in {"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"}:
from app.mcp.calculation_jobs import read_calculation_job
return await read_calculation_job(scope, binding, tool, args)
if tool.mutating:
from app.mcp.mutation_service import execute_mutation
return await execute_mutation(scope, binding, tool, args)
from app.mcp.extended_service import EXTENDED_READ_TOOLS, execute_extended
if tool.name in EXTENDED_READ_TOOLS:
return await execute_extended(scope, binding, tool, args)
name = tool.name
conn = scope.connection
page = None
@@ -162,7 +179,13 @@ async def execute_query(
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
}[name]]
if name == "calculate_demand_allocation":
truncated = data["result"]["has_more"] or args.offset > 0
page = PageInfo(limit=args.limit, offset=args.offset, total=data["result"]["total"], has_more=data["result"]["has_more"])
if getattr(args, "data_source", None) == "simulation":
gaps.append("Input is an existing analysis run and model baseline, not observed SCADA data.")
elif name == "query_nearby_elements":
@@ -216,7 +239,7 @@ async def execute_query(
"pump-energy": "get_pump_energy_schema",
}
function = getattr(
tjnetwork, mapping.get(args.kind, "get_" + args.kind + "_schema")
tjnetwork, mapping.get(args.kind, "get_" + args.kind.replace("-", "_") + "_schema")
)
data = await asyncio.to_thread(function, scope.project.project_code)
elif name == "get_network_options":
@@ -390,14 +413,14 @@ async def execute_query(
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"]
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
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:
if args.section in {"evidence", "actions", "access_requests", "questions"} 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)
+4
View File
@@ -6,6 +6,7 @@ SCENARIO_TOOLS = frozenset({
"run_simulation",
"run_contaminant_simulation", "analyze_valve_closure",
"analyze_flushing", "analyze_pressure_regulation",
"run_burst_simulation",
})
@@ -25,6 +26,9 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
result = run_online_simulation(
network, start, duration, output_node_ids=nodes, output_link_ids=links,
)
elif name == "run_burst_simulation":
result = scenarios.burst_analysis(**common, username=username,
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
elif name == "run_contaminant_simulation":
result = scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
+6
View File
@@ -201,6 +201,8 @@ def temporary_project_name(project: str, purpose: str) -> str:
def temporary_project_database(project: str, purpose: str):
"""Clone one project's runnable model into an isolated temporary database."""
temporary_name = temporary_project_name(project, purpose)
from app.services.calculation_cleanup import record_calculation_database
record_calculation_database(temporary_name)
try:
copy_project(
get_project_template_database_name(project),
@@ -211,12 +213,15 @@ def temporary_project_database(project: str, purpose: str):
finally:
if have_project(temporary_name):
delete_project(temporary_name)
record_calculation_database(temporary_name, removed=True)
@contextmanager
def temporary_template_database(name_hint: str, purpose: str):
"""Create an empty schema-only temporary database from the fixed template."""
temporary_name = temporary_project_name(name_hint, purpose)
from app.services.calculation_cleanup import record_calculation_database
record_calculation_database(temporary_name)
try:
copy_project(
get_schema_template_database_name(),
@@ -227,6 +232,7 @@ def temporary_template_database(name_hint: str, purpose: str):
finally:
if have_project(temporary_name):
delete_project(temporary_name)
record_calculation_database(temporary_name, removed=True)
def have_project(name: str) -> bool:
+70 -1
View File
@@ -1,10 +1,39 @@
"""Read-only projections backed by the GIS materialized-view query layer."""
"""Read-only GIS projections and authoritative network topology snapshots."""
from typing import Any
from ..core.database import read, read_all
def get_node_geographic_positions(name: str, node_ids: list[str]) -> list[Any]:
"""Resolve only the selected project's stored node geometries to WGS84."""
if not node_ids:
return []
return read_all(name, """
SELECT node_id AS id,
ST_X(ST_Transform(geom,4326)) AS longitude,
ST_Y(ST_Transform(geom,4326)) AS latitude
FROM gis.node_geometries
WHERE node_id = ANY(%s) AND geom IS NOT NULL AND NOT ST_IsEmpty(geom)
ORDER BY node_id
""", (node_ids,))
def get_node_coverage_links(name: str, node_ids: list[str]) -> list[Any]:
"""Read the selected subgraph and initial statuses without changing the model."""
if not node_ids:
return []
return read_all(name, """
SELECT l.id, l.start_node_id, l.end_node_id,
COALESCE(s.status::text, p.status::text) AS status
FROM network.links l
LEFT JOIN network.pipes p ON p.link_id = l.id
LEFT JOIN network.link_initial_settings s ON s.link_id = l.id
WHERE l.start_node_id = ANY(%s) AND l.end_node_id = ANY(%s)
ORDER BY l.id
""", (node_ids, node_ids))
def _node_coord_rows(
rows: list[Any],
) -> dict[str, dict[str, Any]]:
@@ -69,6 +98,46 @@ def get_network_link_nodes(name: str) -> list[str]:
]
def get_valve_isolation_topology(name: str) -> tuple[list[str], list[str], list[str]]:
"""Read links, closed statuses and sources from one consistent SQL snapshot.
Isolation must include model links without published geometries, and use
initial statuses rather than treating every link as open. UNION ALL keeps
source nodes in the same statement snapshot without repeating them for
every link or relying on materialized view refresh timing.
"""
rows = read_all(
name,
"""
SELECT l.id, l.link_type::text AS element_type,
l.start_node_id, l.end_node_id,
COALESCE(s.status::text, p.status::text) AS status
FROM network.links AS l
LEFT JOIN network.pipes AS p ON p.link_id = l.id
LEFT JOIN network.link_initial_settings AS s ON s.link_id = l.id
UNION ALL
SELECT n.id, n.node_type::text AS element_type,
NULL, NULL, NULL
FROM network.nodes AS n
WHERE n.node_type::text IN ('reservoir', 'tank')
""",
)
link_entries: list[str] = []
source_nodes: list[str] = []
closed_links: list[str] = []
for row in rows:
element_id = str(row["id"])
if row["element_type"] in {"reservoir", "tank"}:
source_nodes.append(element_id)
else:
link_entries.append(
f"{element_id}:{row['element_type']}:{row['start_node_id']}:{row['end_node_id']}"
)
if row["status"] == "CLOSED":
closed_links.append(element_id)
return link_entries, source_nodes, closed_links
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
"""Return large pipes in the established API wire format."""
rows = read_all(
+74
View File
@@ -0,0 +1,74 @@
"""Record only job-owned native databases so abrupt cancellation is recoverable."""
import asyncio
import json
import os
from pathlib import Path
import re
from uuid import UUID
def _names(directory: Path) -> list[str]:
path = directory / "databases.json"
if not path.exists():
return []
if path.is_symlink() or path.stat().st_size > 65536:
raise ValueError("Invalid calculation database ledger")
names = json.loads(path.read_text())
if not isinstance(names, list) or len(names) > 100 or any(
not isinstance(name, str) or not re.fullmatch(r"tjw_tmp_[a-z0-9_]+_[a-f0-9]{32}", name)
or len(name) > 63 for name in names
):
raise ValueError("Invalid calculation database ledger")
return names
def _write(directory: Path, names: list[str]) -> None:
path = directory / "databases.json"
staging = directory / "databases.json.new"
with staging.open("w") as output:
json.dump(names, output)
output.flush()
os.fsync(output.fileno())
staging.replace(path)
def record_calculation_database(name: str, *, removed: bool = False) -> None:
"""Called before clone creation and after successful normal cleanup."""
root = os.environ.get("TJWATER_CALCULATION_JOB_DIR")
if not root:
return
directory = Path(root)
names = _names(directory)
if removed:
names = [value for value in names if value != name]
elif name not in names:
names.append(name)
_write(directory, names)
async def cleanup_calculation_databases(project_id: str, job_directory: str) -> None:
directory = Path(job_directory)
names = _names(directory)
if not names:
return
from app.infra.db.metadb.database import SessionLocal
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
from app.native.wndb.core.projects import have_project, delete_project
async with SessionLocal() as session:
metadata = MetadataRepository(session)
project = await metadata.get_project_by_id(UUID(str(project_id)))
route = await metadata.get_project_db_routing(UUID(str(project_id)), "biz_data")
if not project or not route or route.db_type != "postgresql":
raise ValueError("Calculation cleanup routing unavailable")
routing = ActiveProjectRouting(project.code, route.dsn, project_id=project.id)
await session.commit()
def cleanup():
with activate_project_routing(routing):
for name in names[:]:
if have_project(name):
delete_project(name)
names.remove(name)
_write(directory, names)
await asyncio.to_thread(cleanup)
+299
View File
@@ -0,0 +1,299 @@
"""Independent, singleton calculation supervisor and isolated job subprocesses.
Never imported by API lifespan. Child output contains the original QueryResult;
algorithm stdout/stderr is discarded rather than interpreted as a protocol.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager, contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import signal
import shutil
import sys
import tempfile
import time
from uuid import UUID, uuid4
@dataclass(frozen=True)
class WorkerSettings:
project_ids: tuple[str, ...]
max_concurrency: int = 2
poll_seconds: float = 2
lease_seconds: int = 30
heartbeat_seconds: float = 5
@classmethod
def from_environment(cls):
projects = tuple(dict.fromkeys(str(UUID(p.strip())) for p in
os.environ['CALCULATION_WORKER_PROJECT_IDS'].split(',')))
result = cls(projects, int(os.getenv('CALCULATION_WORKER_MAX_CONCURRENCY', '2')),
float(os.getenv('CALCULATION_WORKER_POLL_SECONDS', '2')),
int(os.getenv('CALCULATION_WORKER_LEASE_SECONDS', '30')),
float(os.getenv('CALCULATION_WORKER_HEARTBEAT_SECONDS', '5')))
if not projects or not 1 <= result.max_concurrency <= 32:
raise ValueError('Configure explicit project IDs and concurrency between 1 and 32')
if not 0 < result.heartbeat_seconds < result.lease_seconds / 3 or result.poll_seconds <= 0:
raise ValueError('Heartbeat must be positive and below one third of the lease')
return result
@asynccontextmanager
async def queue_repository(project_id):
"""Resolve current routing from metadata, never from a job payload."""
from app.infra.db.calculation_worker import queue_repository as open_repository
async with open_repository(project_id) as repository:
yield repository
@asynccontextmanager
async def execution_scope(row):
from fastapi import HTTPException
from app.mcp.scope import QueryMetadataRepository, QueryScope
from app.auth.project_dependencies import (resolve_project_context,
resolve_project_business_routing, get_project_pg_connection)
from app.infra.db.metadb.database import SessionLocal
from app.infra.db.project_routing import activate_project_routing
async with SessionLocal() as session:
metadata = QueryMetadataRepository(session)
user = await metadata.get_user_by_id(UUID(str(row['owner_user_id'])))
if not user or not user.is_active:
raise HTTPException(403, 'Calculation owner is inactive')
project = await resolve_project_context(str(row['project_id']), user, metadata)
routing = await resolve_project_business_routing(project, metadata)
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)
async def execute_child(input_path: str, output_path: str):
from app.mcp.calculation_jobs import execute_calculation_job
row = json.loads(Path(input_path).read_text())
try:
async with execution_scope(row) as scope:
result = await execute_calculation_job(scope, row)
payload = {'status': 'succeeded', 'result': result.model_dump(mode='json')}
except Exception as exc:
# Do not persist exception text: DSNs, SQL, credentials or model data can occur there.
category = 'CALCULATION_FAILED'
if getattr(exc, 'status_code', None) in (401, 403):
category = 'AUTHORIZATION_DENIED'
elif getattr(exc, 'status_code', None) == 409:
category = 'EXECUTION_CONFLICT'
elif getattr(exc, 'status_code', None) in (404, 410):
category = 'EXECUTION_CONTEXT_UNAVAILABLE'
payload = {'status': 'failed', 'error': {'code': category}}
encoded = json.dumps(payload, allow_nan=False)
if len(encoded.encode()) > 8 * 1024 * 1024:
encoded = json.dumps({'status': 'failed', 'error': {'code': 'RESULT_TOO_LARGE'}})
Path(output_path).write_text(encoded)
async def stop_process_group(process):
"""Always kill the group, even if its original leader already exited."""
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
await asyncio.wait_for(process.wait(), 2)
except TimeoutError:
pass
# Native pools can ignore SIGTERM or survive the Python leader.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
await process.wait()
if process.stdin is not None:
process.stdin.close()
@contextmanager
def job_directory(row):
root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs'))
root.mkdir(parents=True, exist_ok=True)
directory = Path(tempfile.mkdtemp(prefix=f"{row.get('job_id', 'job')}-", dir=root))
directory.chmod(0o700)
(directory / 'scope.json').write_text(json.dumps({'project_id': str(row['project_id']), 'job_id': str(row.get('job_id', 'job')),
'lease_generation': row.get('lease_generation')}))
try:
yield str(directory.resolve())
finally:
# Failed external resource cleanup must remain operator-recoverable.
if not (directory / 'cleanup-failed').exists():
shutil.rmtree(directory)
async def cleanup_job_resources(row, directory):
from app.services.calculation_cleanup import cleanup_calculation_databases
try:
await cleanup_calculation_databases(row['project_id'], directory)
except BaseException:
(Path(directory) / 'cleanup-failed').touch()
raise
async def recover_job_resources(project_ids):
"""Reconcile only this worker's explicit scopes after obtaining the singleton."""
root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs'))
if not root.exists():
return
for directory in root.iterdir():
if directory.is_symlink() or not directory.is_dir():
continue
scope_file = directory / 'scope.json'
if not scope_file.exists():
continue
row = json.loads(scope_file.read_text())
if str(row.get('project_id')) not in project_ids:
continue
job_id = str(UUID(row['job_id']))
if not directory.name.startswith(job_id + '-'):
raise RuntimeError('Calculation state directory identity is invalid')
async with queue_repository(row['project_id']) as repository:
stored = await repository.get(job_id)
if not stored or stored['lease_generation'] != row['lease_generation']:
raise RuntimeError('Calculation state ownership cannot be verified')
expiry = stored.get('lease_expires_at')
if expiry and expiry > datetime.now(timezone.utc) and stored['status'] in ('running', 'cancel_requested'):
raise RuntimeError('Previous calculation lease has not expired')
await cleanup_job_resources(row, str(directory.resolve()))
async with queue_repository(row['project_id']) as repository:
await repository.recover()
shutil.rmtree(directory)
class CalculationWorker:
def __init__(self, settings, *, repository=queue_repository):
self.settings = settings
self.repository = repository
self.owner = str(uuid4())
async def run_job(self, row):
from fastapi.encoders import jsonable_encoder
with job_directory(row) as directory:
input_path = Path(directory) / 'input.json'
output_path = Path(directory) / 'result.json'
input_path.write_text(json.dumps(jsonable_encoder(row)))
process = await asyncio.create_subprocess_exec(
sys.executable, '-m', 'app.services.calculation_worker',
'--guard', str(input_path), str(output_path), start_new_session=True,
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
env={**os.environ, 'TMPDIR': directory, 'TJWATER_CALCULATION_JOB_DIR': directory},
)
started = time.monotonic()
cleaned = False
try:
while True:
async with asyncio.timeout(self.settings.heartbeat_seconds):
async with self.repository(row['project_id']) as repository:
status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds)
if status is None:
return # Finally stops all owned computation on a lost lease.
terminal = None
if status == 'cancel_requested':
terminal = {'status': 'cancelled'}
elif time.monotonic() - started >= row['timeout_seconds']:
terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}}
elif process.returncode is not None:
try:
if output_path.stat().st_size > 8 * 1024 * 1024:
raise ValueError('Result exceeds persistence limit')
terminal = json.loads(output_path.read_text())
except (OSError, ValueError):
terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}}
if terminal:
await stop_process_group(process)
try:
await cleanup_job_resources(row, directory)
cleaned = True
except Exception:
terminal = {'status': 'failed', 'error': {'code': 'CLEANUP_FAILED'}}
cleaned = True
async with self.repository(row['project_id']) as repository:
finished = await repository.finish(row, self.owner, **terminal)
if not finished:
status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds)
if status == 'cancel_requested':
await repository.finish(row, self.owner, 'cancelled')
return
await asyncio.sleep(self.settings.heartbeat_seconds)
finally:
await stop_process_group(process)
if not cleaned:
await cleanup_job_resources(row, directory)
async def run(self):
from app.infra.db.calculation_worker import calculation_supervisor_lease
# Dedicated session-level metadata lock: independent API workers never
# participate; duplicate supervisors exit before claiming any jobs.
async with calculation_supervisor_lease() as lease:
tasks = set()
project_cursor = 0
try:
# Allow any old supervisor to detect a broken lock connection and
# stop its children before this replacement claims capacity.
await asyncio.sleep(self.settings.lease_seconds)
await recover_job_resources(self.settings.project_ids)
while True:
async with asyncio.timeout(self.settings.heartbeat_seconds):
await lease.ping()
for task in tuple(tasks):
if task.done():
tasks.remove(task)
task.result() # Fail closed if heartbeat/persistence failed.
projects = self.settings.project_ids
ordered = projects[project_cursor:] + projects[:project_cursor]
project_cursor = (project_cursor + 1) % len(projects)
for project_id in ordered:
if len(tasks) >= self.settings.max_concurrency:
break
async with self.repository(project_id) as repository:
await repository.recover()
row = await repository.claim(self.owner, self.settings.lease_seconds)
if row:
tasks.add(asyncio.create_task(self.run_job(row)))
await asyncio.sleep(min(self.settings.poll_seconds, self.settings.heartbeat_seconds))
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def guard_child(input_path, output_path):
"""A parent-death pipe kills the whole group even when the supervisor is SIGKILLed."""
reader = asyncio.StreamReader()
protocol = asyncio.StreamReaderProtocol(reader)
transport, _ = await asyncio.get_running_loop().connect_read_pipe(lambda: protocol, sys.stdin)
process = await asyncio.create_subprocess_exec(
sys.executable, '-m', 'app.services.calculation_worker', input_path, output_path,
stdin=asyncio.subprocess.DEVNULL,
)
completed = asyncio.create_task(process.wait())
parent_gone = asyncio.create_task(reader.read(1))
try:
done, _ = await asyncio.wait((completed, parent_gone), return_when=asyncio.FIRST_COMPLETED)
if parent_gone in done:
os.killpg(os.getpgrp(), signal.SIGKILL)
await completed
finally:
parent_gone.cancel()
transport.close()
if __name__ == '__main__':
if sys.argv[1] == '--guard':
asyncio.run(guard_child(sys.argv[2], sys.argv[3]))
else:
asyncio.run(execute_child(sys.argv[1], sys.argv[2]))
+128
View File
@@ -0,0 +1,128 @@
"""Reusable geometric coverage of verified project nodes, independent of business analysis."""
import json
from math import isfinite
from typing import Any
from pyproj import CRS, Transformer
from shapely import MultiPoint, concave_hull, get_num_coordinates
from shapely.geometry import mapping
from shapely.ops import transform, unary_union
from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions
CONCAVE_RATIO = 0.25
BUFFER_METERS = 25.0
SIMPLIFY_METERS = 2.0
MAX_COVERAGE_NODES = 10000
def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]:
parent = {node_id: node_id for node_id in node_ids}
def root(node: str) -> str:
while parent[node] != node:
parent[node] = parent[parent[node]]
node = parent[node]
return node
for link in links:
start, end = link["start_node_id"], link["end_node_id"]
if link["id"] not in closed and link.get("status") != "CLOSED" and start in parent and end in parent:
parent[root(start)] = root(end)
groups: dict[str, list[str]] = {}
for node_id in node_ids:
groups.setdefault(root(node_id), []).append(node_id)
return list(groups.values())
def build_node_coverage(
groups: list[dict[str, Any]], positions: list[dict[str, Any]],
links: list[dict[str, Any]], *, split_by_connectivity: bool = False,
excluded_link_ids: list[str] | None = None,
concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS,
) -> dict[str, Any]:
"""Keep caller-defined comparison groups and derive geometry without assigning business meaning."""
coordinates = {}
for row in positions:
longitude, latitude = row.get("longitude"), row.get("latitude")
if (isinstance(longitude, (int, float)) and not isinstance(longitude, bool)
and isinstance(latitude, (int, float)) and not isinstance(latitude, bool)
and isfinite(longitude) and isfinite(latitude)
and -180 <= longitude <= 180 and -90 < latitude < 90):
coordinates[str(row["id"])] = (longitude, latitude)
features, groups_metadata = [], {}
closed = set(excluded_link_ids or [])
simplify_m = min(SIMPLIFY_METERS, buffer_m / 4)
for group in groups:
group_id, label = group["group_id"], group["title"]
ids = sorted(set(group["node_ids"]))
mapped = [node for node in ids if node in coordinates]
missing = [node for node in ids if node not in coordinates]
metadata = {"node_ids": ids, "mapped_node_count": len(mapped), "missing_node_ids": missing}
groups_metadata[group_id] = {"title": label, **metadata}
metadata = groups_metadata[group_id]
if not mapped:
metadata["component_count"] = 0
continue
points = [coordinates[node] for node in mapped]
longitude = sum(point[0] for point in points) / len(points)
latitude = sum(point[1] for point in points) / len(points)
local = CRS.from_proj4(
f"+proj=aeqd +lat_0={latitude} +lon_0={longitude} +datum=WGS84 +units=m"
)
project = Transformer.from_crs(4326, local, always_xy=True).transform
unproject = Transformer.from_crs(local, 4326, always_xy=True).transform
envelopes = []
components = _components(ids, links, closed) if split_by_connectivity else [ids]
for nodes in components:
component_points = [coordinates[node] for node in nodes if node in coordinates]
if not component_points:
continue
cloud = transform(project, MultiPoint(component_points))
# A point or collinear cloud remains drawable through the same metric buffer.
envelope = concave_hull(cloud, ratio=concave_ratio).buffer(buffer_m)
envelopes.append(envelope)
metadata["component_count"] = len(envelopes)
geometry = unary_union(envelopes).simplify(simplify_m, preserve_topology=True)
if geometry.is_empty or not geometry.is_valid or get_num_coordinates(geometry) > 20000:
raise ValueError("Invalid derived coverage")
feature = {
"type": "Feature", "id": group_id,
"properties": {
"coverage_group": group_id, "label": label, "derived": True, "display_only": True,
"node_count": len(ids), "mapped_node_count": len(mapped),
"missing_node_count": len(missing), "component_count": len(envelopes),
},
"geometry": mapping(transform(unproject, geometry)),
}
features.append(feature)
return {
"status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values())
else "ready" if features else "empty",
"method": "concave_hull_buffer",
"parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m,
"split_by_connectivity": split_by_connectivity,
"excluded_link_ids": sorted(closed),
"simplify_m": simplify_m, "projection": "local_azimuthal_equidistant"},
"output_crs": "EPSG:4326", "display_only": True,
"position_source": "gis.node_geometries",
"boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。",
"groups": groups_metadata,
"map_payload": {"type": "FeatureCollection", "features": features},
}
def generate_node_coverage(network: str, groups: list[dict[str, Any]], **parameters: Any) -> dict[str, Any]:
node_ids = sorted({node for group in groups for node in group["node_ids"]})
if len(node_ids) > MAX_COVERAGE_NODES:
raise ValueError("Node coverage supports at most 10000 distinct nodes")
positions = get_node_geographic_positions(network, node_ids)
links = get_node_coverage_links(network, node_ids) if parameters.get("split_by_connectivity") else []
coverage = build_node_coverage(groups, positions, links, **parameters)
payload = coverage.pop("map_payload")
if len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) > 400 * 1024:
raise ValueError("Coverage exceeds presentation budget; narrow the node selection")
return {"coverage": coverage, "map_payload": payload}
+8 -1
View File
@@ -51,12 +51,19 @@ def _geometry(value):
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
data = snapshot.data
# MCP persists its complete QueryResult envelope, unlike direct data queries.
if (isinstance(data, dict) and data.get("source") == snapshot.source
and isinstance(data.get("data"), (dict, list))):
data = data["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"))
result = data.get("result")
if isinstance(result, dict):
candidates.append(result.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:
@@ -88,7 +95,7 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
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):
if isinstance(feature.get("id"), str) or _number(feature.get("id")):
normalized["id"] = feature["id"]
if mode == "heatmap":
weight = properties.get("weight", 1)
+77 -9
View File
@@ -19,6 +19,7 @@ from app.auth.permissions import (
SIMULATION_VIEW,
)
from app.domain.domain_errors import DomainError, require
from app.services import task_questions
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
ActionExecuteCommand,
@@ -65,6 +66,8 @@ from app.domain.schemas.operational_task import (
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
TaskPresentationHistoryItem,
TaskPresentationHistoryPage,
)
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.services.content_digest import digest
@@ -97,6 +100,15 @@ class OperationalTaskService:
def _actor(self) -> str:
return f"principal:{self.context.user_id}"
@staticmethod
def _require_run_owner(run: dict, command: AgentRunCommand) -> None:
require(run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
owner = run.get("agent_instance_id")
if owner is not None:
require(owner == command.agent_instance_id,
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
@property
def _supports_runs(self) -> bool:
return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run")
@@ -138,6 +150,7 @@ class OperationalTaskService:
permissions = self.permissions
return OperationalTaskPermissions(
can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions,
can_answer_questions=OPERATIONAL_TASK_WRITE in permissions,
can_propose_actions=OPERATIONAL_TASK_WRITE in permissions,
can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions,
can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions,
@@ -258,6 +271,7 @@ class OperationalTaskService:
evidence=evidence,
actions=actions,
access_requests=access_requests,
questions=await task_questions.list_questions(self, task_id),
permissions=self._permission_view(),
work_order_snapshot=work_order_snapshot,
)
@@ -483,6 +497,7 @@ class OperationalTaskService:
status="running",
revision=expected + 1,
agent_session_id=command.source_session_id,
agent_instance_id=command.agent_instance_id,
lease_expires_at=now + LEASE_DURATION,
heartbeat_at=now,
started_at=run.get("started_at") or now,
@@ -527,8 +542,7 @@ class OperationalTaskService:
"Run revision changed", status=409)
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
"Only an active run has a lease", status=409)
require(run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
self._require_run_owner(run, command)
now = self._now()
self._require_active_run_lease(run, now)
expected = run["revision"]
@@ -580,6 +594,7 @@ class OperationalTaskService:
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return await self.detail(task_id)
await self.repo.lock(f"task:{task_id}")
await self.repo.lock(f"run:{run_id}")
run = await self._run(task_id, run_id)
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
@@ -587,6 +602,7 @@ class OperationalTaskService:
require(run["status"] in {"queued", "claimed", "running"},
"INVALID_TRANSITION", "Run is already terminal", status=409)
now = self._now()
await task_questions.cancel_pending(self, task_id, now)
expected = run["revision"]
run.update(
status="cancelled", revision=expected + 1, finished_at=now,
@@ -624,6 +640,7 @@ class OperationalTaskService:
require(await self.repo.get_active_run(str(task_id)) is None,
"ACTIVE_RUN_EXISTS", "Task already has an active run", status=409)
now = self._now()
await task_questions.cancel_pending(self, task_id, now)
generation = task["execution_generation"] + 1
run = self._new_run(task_id, generation, now)
await self.repo.insert_run(_json(run))
@@ -778,6 +795,10 @@ class OperationalTaskService:
"Only a running run accepts Agent artifacts", status=409)
now = self._now()
self._require_active_run_lease(active_run, now)
if command.report and (
command.report.stage == "final" or command.report.outcome == "insufficient_data"
):
await self._require_no_pending_calculations(task_id, active_run)
previous_report = await self.repo.get_latest_report(str(task_id))
require(not previous_report or previous_report.get("execution_generation") != task["execution_generation"]
or previous_report["report"].get("outcome") != "insufficient_data",
@@ -911,9 +932,9 @@ class OperationalTaskService:
previous_report is None
or previous_report.get("execution_generation") != task["execution_generation"]
or previous_result.get("stage") != "final"
or any(previous_result.get(key) != _json(command.report).get(key)
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != _json(command.report).get(key)
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
"evidence_refs", "recommendations", "pending_items", "data_gaps"))
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
):
await self._resolve_notifications(
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
@@ -982,6 +1003,27 @@ class OperationalTaskService:
event_cursor=cursor,
)
async def presentation_history(
self, task_id: UUID, *, after: int = 0, through: int | None = None, limit: int = 100,
) -> TaskPresentationHistoryPage:
require(0 <= after and (through is None or through >= 0) and 1 <= limit <= 200,
"INVALID_ARGUMENT", "Invalid presentation history page", status=422)
async with self.repo.transaction():
await self._task(task_id)
cursor = await self.repo.current_event_cursor()
boundary = min(through, cursor) if through is not None else cursor
rows = await self.repo.list_presentation_events(str(task_id), after, boundary, limit + 1)
items = [TaskPresentationHistoryItem(
event_seq=row["event_seq"], occurred_at=row["occurred_at"],
run_id=row["metadata"]["run_id"],
execution_generation=row["metadata"]["execution_generation"],
command=row["metadata"]["command"],
) for row in rows[:limit]]
return TaskPresentationHistoryPage(
task_id=task_id, event_cursor=boundary, items=items,
next_after=items[-1].event_seq if len(rows) > limit else None,
)
async def publish_presentation_event(
self, task_id: UUID, command: TaskPresentationEventCommand
) -> TaskPresentationEventResult:
@@ -1015,7 +1057,16 @@ class OperationalTaskService:
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)
resolved = resolve_overlay(evidence.data_snapshot, command.command.mode)
if command.command.data is not None:
supplied = resolve_overlay(
evidence.data_snapshot.model_copy(update={
"data": command.command.data.model_dump(mode="json", exclude_unset=True),
}), command.command.mode,
)
require(supplied == resolved, "OVERLAY_DATA_MISMATCH",
"Overlay data must match the immutable evidence snapshot", status=422)
payload["data"] = resolved
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
"execution_generation": run["generation"], "command": payload}
try:
@@ -1866,6 +1917,15 @@ class OperationalTaskService:
task_ids.append(task_id)
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
if hasattr(self.repo, "pending_calculation_jobs"):
pending = await self.repo.pending_calculation_jobs(
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
)
require(not pending, "PENDING_CALCULATIONS",
"Wait for submitted calculations before publishing a final report or completing the run",
refs=pending, status=409, recovery="retry_same_request")
async def _finish_run(
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
*, outcome: str, failure_code: str | None = None,
@@ -1877,19 +1937,23 @@ class OperationalTaskService:
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return await self.detail(task_id)
await self.repo.lock(f"task:{task_id}")
await self.repo.lock(f"run:{run_id}")
run = await self._run(task_id, run_id)
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
"Run revision changed", status=409)
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
"Only an active run can finish", status=409)
require(run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
self._require_run_owner(run, command)
now = self._now()
self._require_active_run_lease(run, now)
task = await self._task(task_id)
final_outcome = outcome
pending_question = any(q["status"] == "pending" and str(q["run_id"]) == str(run_id)
for q in await task_questions.list_questions(self, task_id))
actions = []
if outcome == "succeeded":
await self._require_no_pending_calculations(task_id, run)
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",
@@ -1915,7 +1979,9 @@ class OperationalTaskService:
and plan.source_session_id == command.source_session_id
))
)
if not same_generation or report.report.stage != "final":
if pending_question:
lifecycle = "waiting_human"
elif not same_generation or report.report.stage != "final":
final_outcome = "failed"
lifecycle = "failed"
failure_code = "ARTIFACT_INCOMPLETE"
@@ -1960,6 +2026,7 @@ class OperationalTaskService:
run_model = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(run_model), expected)
if final_outcome != "succeeded":
await task_questions.cancel_pending(self, task_id, now)
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
task.update(
lifecycle_status=lifecycle,
@@ -1980,7 +2047,7 @@ class OperationalTaskService:
failure_detail or "工况任务执行失败",
now,
)
elif lifecycle == "waiting_human":
elif lifecycle == "waiting_human" and not pending_question:
pending_actions = any(
action.approval_status == "pending" for action in actions
)
@@ -2193,6 +2260,7 @@ class OperationalTaskService:
task["agent_lease_expires_at"] = None
async def _requeue_task(self, task: dict, now: datetime) -> None:
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
if self._supports_runs:
active_run = await self.repo.get_active_run(str(task["task_id"]))
while active_run is not None:
+37 -15
View File
@@ -6,6 +6,7 @@ from pydantic import ValidationError
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.domain.schemas.place_search import (
MAX_PLACE_SEARCH_RESULTS,
PlaceSearchItem,
@@ -13,7 +14,6 @@ from app.domain.schemas.place_search import (
PlaceSearchResponse,
)
FENGXIAN_ADMIN_CODE = "156310120"
install_tianditu_token_filter()
@@ -24,11 +24,37 @@ class PlaceSearchError(RuntimeError):
self.detail = detail
class PlaceSearchScopeError(ValueError):
pass
def validate_place_scope(
config: PlaceSearchConfig, *, county_code: str, county: str, province_code: str = "",
) -> None:
admin_code = config.admin_code.removeprefix("156")
province_code = province_code.removeprefix("156")
county_code = county_code.removeprefix("156")
if province_code and province_code[:2] != admin_code[:2]:
raise PlaceSearchScopeError("Result outside configured province")
scope_prefix = admin_code
if admin_code.endswith("0000"):
scope_prefix = admin_code[:2]
elif admin_code.endswith("00"):
scope_prefix = admin_code[:4]
if county_code and not county_code.startswith(scope_prefix):
raise PlaceSearchScopeError("Result outside configured district")
if (len(scope_prefix) == 6 and county and county != config.region) or (not county_code and county != config.region):
raise PlaceSearchScopeError("Missing or mismatched district")
async def search_tianditu_places(
request: PlaceSearchRequest,
*,
config: PlaceSearchConfig,
client: httpx.AsyncClient | None = None,
) -> PlaceSearchResponse:
if not config.enabled:
raise PlaceSearchError(503, "当前项目暂未配置地名搜索")
token = settings.TIANDITU_TOKEN
if not token:
raise PlaceSearchError(503, "地名搜索服务未配置")
@@ -37,7 +63,7 @@ async def search_tianditu_places(
{
"keyWord": request.keyword,
"queryType": 12,
"specify": FENGXIAN_ADMIN_CODE,
"specify": "156" + config.admin_code.removeprefix("156"),
"start": 0,
"count": MAX_PLACE_SEARCH_RESULTS,
"show": 2,
@@ -64,15 +90,15 @@ async def search_tianditu_places(
# Request URLs contain the provider key and must not reach the client.
raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None
try:
return _parse_response(response.json())
return _parse_response(response.json(), config)
except (ValueError, TypeError, KeyError, AttributeError, ValidationError):
raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None
def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
def _parse_response(payload: dict[str, Any], config: PlaceSearchConfig) -> PlaceSearchResponse:
code = str(payload.get("status", {}).get("infocode", ""))
if code == "3001":
return PlaceSearchResponse(items=[], total=0)
return PlaceSearchResponse(items=[], total=0, region=config.region)
if code != "1000":
raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试")
@@ -84,15 +110,11 @@ def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
raise ValueError("Invalid search results")
items = []
for raw in raw_items:
admin_code = str(raw.get("provinceCode") or raw.get("adminCode") or "")
if admin_code and not admin_code.startswith(("156310", "310")):
raise ValueError("Result outside Shanghai")
county_code = str(raw.get("countyCode") or (raw.get("adminCode") if result_type == "3" else "") or "")
county = raw.get("county")
if county_code and not county_code.startswith((FENGXIAN_ADMIN_CODE, "310120")):
raise ValueError("Result outside Fengxian")
if county and county != "奉贤区" or not county_code and county != "奉贤区":
raise ValueError("Missing or mismatched district")
validate_place_scope(
config, county_code=str(raw.get("countyCode") or raw.get("adminCode") or ""),
county=str(county or ""), province_code=str(raw.get("provinceCode") or ""),
)
lon, lat = str(raw["lonlat"]).split(",")
name = raw["name"]
if not isinstance(name, str) or not name.strip():
@@ -105,11 +127,11 @@ def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
address=str(raw.get("address") or name),
longitude=float(lon), latitude=float(lat),
category="行政区" if result_type == "3" else category,
district="奉贤区",
district=str(county or config.region),
))
total = int(payload.get("count", 0))
if total < len(items):
raise ValueError("Invalid search total")
return PlaceSearchResponse(
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS,
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS, region=config.region,
)
+5 -2
View File
@@ -2,6 +2,7 @@ from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from tempfile import NamedTemporaryFile
import os
from app.native.wndb.inp.exporter import dump_inp
@@ -17,9 +18,11 @@ def temporary_project_inp(
version: str = "2",
) -> Iterator[Path]:
"""Export one project model to an isolated INP and remove it afterwards."""
PROJECT_INP_DIRECTORY.mkdir(parents=True, exist_ok=True)
job_directory = os.environ.get("TJWATER_CALCULATION_JOB_DIR")
directory = Path(job_directory) / "inp" if job_directory else PROJECT_INP_DIRECTORY
directory.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile(
dir=PROJECT_INP_DIRECTORY,
dir=directory,
prefix=f"{purpose}_",
suffix=".inp",
delete=False,
+72
View File
@@ -0,0 +1,72 @@
import json
from typing import Any
import httpx
from app.core.config import settings
from app.domain.schemas.place_search import PlaceSearchItem, ReverseGeocodingRequest
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.services.place_search import PlaceSearchError, PlaceSearchScopeError, validate_place_scope
async def reverse_geocode_tianditu(
request: ReverseGeocodingRequest,
*,
config: PlaceSearchConfig,
client: httpx.AsyncClient | None = None,
) -> PlaceSearchItem:
if not config.enabled:
raise PlaceSearchError(503, "当前项目暂未配置地名搜索")
if not settings.TIANDITU_TOKEN:
raise PlaceSearchError(503, "地名搜索服务未配置")
params = {
"postStr": json.dumps({"lon": request.longitude, "lat": request.latitude, "ver": 1}),
"type": "geocode",
"tk": settings.TIANDITU_TOKEN,
}
try:
if client is not None:
response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params)
else:
async with httpx.AsyncClient(timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS) as managed:
response = await managed.get(settings.TIANDITU_GEOCODER_URL, params=params)
response.raise_for_status()
except httpx.TimeoutException:
raise PlaceSearchError(504, "地图取点查询超时,请重新取点") from None
except httpx.HTTPError:
raise PlaceSearchError(502, "天地图反编码服务暂不可用,请重新取点") from None
try:
return _parse_reverse_response(response.json(), request, config)
except PlaceSearchScopeError:
raise PlaceSearchError(422, f"取点位置不在当前项目的地名搜索范围内({config.region})") from None
except (ValueError, TypeError, KeyError, AttributeError):
raise PlaceSearchError(502, "天地图返回了无效的反编码结果,请重新取点") from None
def _parse_reverse_response(
payload: dict[str, Any], request: ReverseGeocodingRequest, config: PlaceSearchConfig,
) -> PlaceSearchItem:
if str(payload.get("status")) != "0":
raise PlaceSearchError(502, "天地图反编码请求失败,请重新取点")
result = payload.get("result")
if not result:
raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点")
address = result.get("formatted_address")
if not isinstance(address, str) or not address.strip():
raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点")
component = result.get("addressComponent") or {}
validate_place_scope(
config, county_code=str(component.get("county_code") or ""),
county=str(component.get("county") or ""),
province_code=str(component.get("province_code") or ""),
)
name = component.get("poi") or component.get("road") or address
if not isinstance(name, str) or not name.strip():
raise ValueError("Invalid place name")
# A nearby POI can have a different center. Always retain the clicked point.
return PlaceSearchItem(
id=f"map:{request.longitude:.8f},{request.latitude:.8f}",
name=name.strip(), address=address.strip(), longitude=request.longitude, latitude=request.latitude,
category="附近地名" if component.get("poi") else "地址",
district=str(component.get("county") or config.region),
)
+10 -3
View File
@@ -77,8 +77,9 @@ def burst_analysis(
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
username: str | None = None,
persist_results: bool = True,
_temporary_project: str | None = None,
) -> None:
) -> dict | None:
"""
爆管模拟
:param name: 模型名称,数据库中对应的名字
@@ -92,7 +93,7 @@ def burst_analysis(
:param scheme_name: 方案名称
:return:
"""
if not username:
if persist_results and not username:
raise ValueError("username is required when storing burst analysis scheme")
scheme_detail: dict = {
@@ -193,7 +194,7 @@ def burst_analysis(
# modify_pump_pattern=modify_pump_pattern,
# valve_control=valve_control,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -206,7 +207,9 @@ def burst_analysis(
result_db_name=name,
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
)
return result if not persist_results else None
# step 3. restore the base model status
# execute_undo(name) #有疑惑
@@ -223,6 +226,7 @@ def valve_close_analysis(
scheme_name: str = None,
_temporary_project: str | None = None,
persist_results: bool = True,
username: str = "system",
) -> dict | str | None:
"""
关阀模拟
@@ -282,6 +286,7 @@ def valve_close_analysis(
scheme_name=scheme_name,
result_db_name=name,
persist_results=persist_results,
scheme_username=username,
)
return result if not persist_results else None
# step 3. restore the base model
@@ -610,6 +615,7 @@ def pressure_regulation(
scada_mappings: simulation.ScadaElementMappings | None = None,
_temporary_project: str | None = None,
persist_results: bool = True,
username: str = "system",
) -> dict | str | None:
"""
区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响
@@ -668,6 +674,7 @@ def pressure_regulation(
result_db_name=name,
scada_mappings=scada_mappings,
persist_results=persist_results,
scheme_username=username,
)
return result if not persist_results else None
# return result
+120
View File
@@ -0,0 +1,120 @@
"""Question lifecycle sharing the operational task transaction and notification bus."""
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
from app.domain.domain_errors import require
from app.domain.schemas.operational_task import OperationalTaskDetail, OperationalTaskSummary
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
if TYPE_CHECKING:
from app.services.operational_tasks import OperationalTaskService
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
# Legacy test repositories do not implement question persistence.
reader = getattr(service.repo, "list_questions", None)
return await reader(str(task_id)) if reader else []
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
for raw in await list_questions(service, task_id):
question = TaskQuestionRequest.model_validate(raw)
if question.status != "pending":
continue
question = question.model_copy(update={"status": "cancelled", "version": question.version + 1})
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
await service._resolve_notifications(task_id, kinds=["human_input_required"],
subject_type="question", subject_id=question.question_request_id,
now=now, reason="question_superseded")
async def create_question(service: "OperationalTaskService", task_id: UUID,
command: TaskQuestionCreate) -> TaskQuestionRequest:
require(OPERATIONAL_TASK_EXECUTE in service.permissions, "PERMISSION_DENIED",
"Current user cannot publish task questions", status=403)
body = command.model_dump(mode="json")
scope = f"task:{task_id}:question:create"
async with service.repo.transaction():
await service.repo.lock(f"task:{task_id}")
replay = await service._request_replay(scope, command.request_id, body)
if replay:
return TaskQuestionRequest.model_validate(await service.repo.get_question(
str(task_id), replay["question_request_id"]))
task = await service._task(task_id)
service._require_actionable_task(task)
require(task["task_type"] == "event_investigation", "INVALID_TASK_TYPE",
"Questions require an investigation task", status=409)
await service.repo.lock(f"run:{command.run_id}")
run = await service._run(task_id, command.run_id)
require(run["status"] in {"claimed", "running"}
and run["generation"] == task["execution_generation"]
and run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Question must belong to the active investigation run", status=409)
now = service._now()
service._require_active_run_lease(run, now)
await service._require_no_pending_calculations(task_id, run)
require(not any(q["status"] == "pending" for q in await list_questions(service, task_id)),
"QUESTION_PENDING", "Answer the existing questions first", status=409)
question = TaskQuestionRequest(question_request_id=uuid4(), task_id=task_id,
run_id=command.run_id, execution_generation=run["generation"],
source_session_id=command.source_session_id, questions=command.questions,
status="pending", version=1, created_at=now)
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
task.update(revision=task["revision"] + 1, updated_at=now)
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
await service._task_event(task, now)
await service._notify(task_id, "human_input_required", task["title"],
";".join(q.title for q in question.questions), now,
required_permission=OPERATIONAL_TASK_WRITE, subject_type="question",
subject_id=question.question_request_id)
await service._save_request(scope, command.request_id, body,
{"question_request_id": str(question.question_request_id)})
return question
async def answer_question(service: "OperationalTaskService", task_id: UUID, question_id: UUID,
command: TaskQuestionReply) -> OperationalTaskDetail:
require(OPERATIONAL_TASK_WRITE in service.permissions, "PERMISSION_DENIED",
"Current user cannot answer task questions", status=403)
body = command.model_dump(mode="json")
scope = f"task:{task_id}:question:{question_id}:answer"
async with service.repo.transaction():
await service.repo.lock(f"task:{task_id}")
if await service._request_replay(scope, command.request_id, body):
return await service.detail(task_id)
task = await service._task(task_id)
service._require_actionable_task(task)
raw = await service.repo.get_question(str(task_id), str(question_id))
require(raw is not None, "QUESTION_NOT_FOUND", "Question not found", status=404)
question = TaskQuestionRequest.model_validate(raw)
require(question.status == "pending" and question.version == command.expected_version
and question.execution_generation == task["execution_generation"],
"VERSION_CONFLICT", "Question is no longer pending in the current generation", status=409)
require(task["lifecycle_status"] == "waiting_human"
and await service.repo.get_active_run(str(task_id)) is None,
"RUN_NOT_SETTLED", "Wait until the investigation has entered waiting_human", status=409)
answers = {a.question_id: a for a in command.answers}
known = {q.question_id for q in question.questions}
require(len(answers) == len(command.answers) and set(answers) <= known,
"ANSWER_INVALID", "Unknown or duplicate questions", status=422)
for q in question.questions:
answer = answers.get(q.question_id)
selected = answer.selected_option_ids if answer else []
text = answer.custom_text.strip() if answer else ""
require(len(set(selected)) == len(selected) and set(selected) <= {o.id for o in q.options}
and (q.mode == "multiple" or len(selected) <= 1)
and (q.allow_custom or not text) and (not q.required or bool(selected or text)),
"ANSWER_INVALID", "Answer does not satisfy the question", status=422)
now = service._now()
question = question.model_copy(update={"status": "answered", "version": question.version + 1,
"answers": command.answers, "answered_at": now, "answered_by": service._actor()})
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
await service._resolve_notifications(task_id, kinds=["human_input_required"],
subject_type="question", subject_id=question_id, now=now, reason="question_answered")
await service._requeue_task(task, now)
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
await service._task_event(task, now)
await service._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
return await service.detail(task_id)
+14 -5
View File
@@ -1,13 +1,22 @@
from typing import Any
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
from app.native.wndb.gis.network_views import get_network_link_nodes
from app.algorithms.valve_isolation.topology_search import (
AccidentElementType,
valve_isolation_analysis,
)
from app.native.wndb.gis.network_views import get_valve_isolation_topology
def analyze_valve_isolation(
network: str,
accident_element: str | list[str],
disabled_valves: list[str] = None,
disabled_valves: list[str] | None = None,
accident_element_type: AccidentElementType = "auto",
) -> dict[str, Any]:
link_entries = get_network_link_nodes(network)
return valve_isolation_analysis(link_entries, accident_element, disabled_valves)
link_entries, source_nodes, closed_links = get_valve_isolation_topology(network)
return valve_isolation_analysis(
link_entries, accident_element, disabled_valves,
accident_element_type=accident_element_type,
water_source_nodes=source_nodes,
closed_links=closed_links,
)
+525
View File
@@ -0,0 +1,525 @@
{
"version": "answer-ui@1",
"description": "Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.",
"schema": {
"type": "object",
"properties": {
"root": {
"type": "string"
},
"elements": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"enum": [
"Stack",
"Section",
"Text",
"Result",
"Metric",
"Table",
"Chart",
"Evidence",
"MapLocation",
"FollowUpForm"
]
},
"props": {
"type": "object",
"additionalProperties": {}
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"slots": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"visible": {},
"repeat": {}
},
"required": [
"type",
"props",
"children"
],
"additionalProperties": false
}
}
},
"required": [
"root",
"elements"
],
"additionalProperties": false
},
"components": [
"Stack",
"Section",
"Text",
"Result",
"Metric",
"Table",
"Chart",
"Evidence",
"MapLocation",
"FollowUpForm"
],
"props": {
"Stack": {
"type": "object",
"properties": {
"columns": {
"type": "string",
"enum": [
"one",
"two"
]
},
"gap": {
"type": "string",
"enum": [
"compact",
"normal"
]
},
"intent": {
"type": "string",
"enum": [
"response",
"comparison",
"report"
]
},
"density": {
"type": "string",
"enum": [
"compact",
"comfortable"
]
}
},
"required": [
"columns",
"gap"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Section": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"description": {
"anyOf": [
{
"type": "string",
"maxLength": 4000
},
{
"type": "null"
}
]
},
"role": {
"type": "string",
"enum": [
"result",
"analysis",
"evidence",
"follow_up"
]
}
},
"required": [
"title",
"description"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Text": {
"type": "object",
"properties": {
"text": {
"type": "string",
"maxLength": 4000
},
"tone": {
"type": "string",
"enum": [
"neutral",
"info",
"warning"
]
},
"title": {
"type": "string",
"maxLength": 200
}
},
"required": [
"text",
"tone"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Result": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"summary": {
"type": "string",
"maxLength": 4000
},
"status": {
"type": "string",
"enum": [
"success",
"partial",
"unconfirmed",
"error"
]
},
"source": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
}
},
"required": [
"title",
"summary",
"status",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Metric": {
"type": "object",
"properties": {
"label": {
"type": "string",
"maxLength": 200
},
"value": {
"type": "string",
"maxLength": 200
},
"unit": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"source": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
}
},
"required": [
"label",
"value",
"unit",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Table": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"columns": {
"type": "array",
"items": {
"type": "string",
"maxLength": 200
},
"minItems": 1,
"maxItems": 8
},
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string",
"maxLength": 1000
},
"maxItems": 8
},
"maxItems": 50
}
},
"required": [
"title",
"columns",
"rows"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Chart": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"chart_type": {
"type": "string",
"enum": [
"line",
"bar"
]
},
"x_data": {
"type": "array",
"items": {
"type": "string",
"maxLength": 200
},
"minItems": 1,
"maxItems": 100
},
"series": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 200
},
"data": {
"type": "array",
"items": {
"type": "number"
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"name",
"data"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 8
},
"source": {
"type": "string",
"maxLength": 4000
}
},
"required": [
"title",
"chart_type",
"x_data",
"series",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Evidence": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"summary": {
"type": "string",
"maxLength": 4000
},
"source": {
"type": "string",
"maxLength": 200
},
"evidence_id": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"observed_at": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"kind": {
"type": "string",
"enum": [
"observed",
"human_input",
"inference",
"demo"
]
}
},
"required": [
"title",
"summary",
"source",
"evidence_id",
"observed_at",
"kind"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"MapLocation": {
"type": "object",
"properties": {
"label": {
"type": "string",
"maxLength": 200
},
"layer": {
"type": "string",
"enum": [
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]
},
"feature_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
},
"source": {
"type": "string",
"maxLength": 200
}
},
"required": [
"label",
"layer",
"feature_ids",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"FollowUpForm": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"description": {
"type": "string",
"maxLength": 4000
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,39}$"
},
"label": {
"type": "string",
"maxLength": 200
},
"kind": {
"type": "string",
"enum": [
"text",
"datetime",
"number"
]
},
"required": {
"type": "boolean"
}
},
"required": [
"name",
"label",
"kind",
"required"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 12
}
},
"required": [
"title",
"description",
"fields"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "949077b0e1c711cfef78319d24cbe081e8d71444e7b25018507e12ba374018a3"
"sha256": "2c1d757ccf06ae72b10c592e1e9b3d32b4f72cda8489e224cf2c5f2690fc6972"
}
}
}
+10 -4
View File
@@ -103,7 +103,7 @@
"$ref": "#/components/schemas/ManagedResourceCapabilities"
},
"origin": {
"const": "synthetic",
"const": "system_data",
"title": "Origin",
"type": "string"
},
@@ -273,6 +273,12 @@
],
"title": "Location"
},
"origin": {
"const": "system_data",
"default": "system_data",
"title": "Origin",
"type": "string"
},
"priority": {
"default": "normal",
"enum": [
@@ -1843,7 +1849,7 @@
"title": "Location"
},
"origin": {
"const": "synthetic",
"const": "system_data",
"title": "Origin",
"type": "string"
},
@@ -2036,8 +2042,8 @@
}
},
"info": {
"description": "测试监测与核查反馈样板,不是正式工单平台或设备控制服务。",
"title": "TJWater 外围系统模拟接入",
"description": "按项目管理业务工单与反馈、读取监测历史;测试数据保留独立来源标记。",
"title": "TJWater 外围系统接入",
"version": "1.0.0"
},
"openapi": "3.1.0",
File diff suppressed because it is too large Load Diff
+663
View File
@@ -0,0 +1,663 @@
{
"anyOf": [
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "locate_features"
},
"layer": {
"type": "string",
"enum": [
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]
},
"feature_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"action",
"layer",
"feature_ids"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "zoom_to_map"
},
"center": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
]
},
"zoom": {
"type": "number",
"minimum": 0,
"maximum": 24
}
},
"required": [
"action",
"center",
"zoom"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "render_scada_analysis"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"device_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"level": {
"type": "string",
"enum": [
"high",
"medium",
"low",
"unrated"
]
}
},
"required": [
"device_id",
"level"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"action",
"items"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "clear_scada_analysis"
}
},
"required": [
"action"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "highlight_features"
},
"layer": {
"type": "string",
"enum": [
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]
},
"feature_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
},
"effect": {
"type": "string",
"enum": [
"steady",
"pulse"
]
}
},
"required": [
"action",
"layer",
"feature_ids",
"effect"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "clear_highlights"
}
},
"required": [
"action"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "open_chart"
},
"chart_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"chart": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"chart_type": {
"type": "string",
"enum": [
"line",
"bar"
]
},
"x_data": {
"type": "array",
"items": {
"type": "string",
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
},
"series": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 128
},
"data": {
"type": "array",
"items": {
"type": "number"
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"name",
"data"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 8
}
},
"required": [
"title",
"chart_type",
"x_data",
"series"
],
"additionalProperties": false
},
"evidence_refs": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 0,
"maxItems": 100
}
},
"required": [
"action",
"chart_id",
"chart",
"evidence_refs"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "close_chart"
},
"chart_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"required": [
"action",
"chart_id"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "set_overlay"
},
"overlay_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"mode": {
"type": "string",
"enum": [
"geometry",
"heatmap"
]
},
"evidence_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"data": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "FeatureCollection"
},
"features": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Feature"
},
"id": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
}
]
},
"properties": {
"anyOf": [
{
"type": "object",
"additionalProperties": {}
},
{
"type": "null"
}
]
},
"geometry": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Point"
},
"coordinates": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "MultiPoint"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 1,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "LineString"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 2,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "MultiLineString"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 2,
"maxItems": 20000
},
"minItems": 1,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Polygon"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 4,
"maxItems": 20000
},
"minItems": 1,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "MultiPolygon"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 4,
"maxItems": 20000
},
"minItems": 1,
"maxItems": 20000
},
"minItems": 1,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
}
]
}
},
"required": [
"type",
"properties",
"geometry"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 1000
}
},
"required": [
"type",
"features"
],
"additionalProperties": false
}
},
"required": [
"action",
"overlay_id",
"title",
"mode",
"evidence_id",
"data"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "clear_overlay"
},
"overlay_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"required": [
"action",
"overlay_id"
],
"additionalProperties": false
}
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
+248
View File
@@ -0,0 +1,248 @@
# 从爆管上报到处置闭环
本文定义爆管调查的完整业务路线,供维护者审阅和验收。运行时指令位于 [burst-report/SKILL.md](../resources/work-order-skills/burst-report/SKILL.md),由 Server 根据绑定任务的 `workflow=burst_report` 经 MCP 分发。
重点成果是有依据的事故候选位置、隔离阀门清单和影响范围,以及经审批执行、反馈核验后的闭环记录。Agent 根据证据选择、跳过或重做分支,不按图逐节点机械调用工具。工程图覆盖多轮调查和人工业务;每轮发布的进度图只标注实际进行的工作,不复制整图冒充执行记录。
## 完整工程图
```mermaid
flowchart TB
subgraph intake[工单进入与后台任务]
order[用户或外围系统提交爆管上报工单]
source[保存原工单及来源版本、发生时间、位置和描述]
sync[同步器按来源关联调查及工单投影]
queue[Server 保存调查任务和 queued run]
claim[Worker 投递,Agent 领取并维持租约]
context[读取任务摘要、业务 Skill、历史报告和既有动作]
order --> source --> sync --> queue --> claim --> context
end
subgraph investigation[Agent 调查与工程分析]
scope[确定事件时刻、地点、调查目标和真实或演练来源]
location[核对已有资产编号;必要时解析地点和查询附近管段]
monitor[核对相关测点映射、异常窗口、正常对照和数据质量]
cover{查询是否实际覆盖事件与对照窗口}
more[缩小对象和窗口,按需补页,核对缺测及零值]
hypothesis{是否具备定位所需观测和漏水量依据}
locate[计算爆管候选排序,保留假设、覆盖范围与证据]
candidate[整合上报、资产关联、算法与现场反馈得到候选集合]
usable{是否存在可用于隔离评估的候选资产}
isolation[逐候选分析拓扑隔离边界、必关阀及受影响节点]
isolateok{可隔离且阀门可用性有可核验依据}
alternate[纳入已知不可关闭阀门,重新评估替代隔离方案]
hydraulic[按需模拟候选关阀组合的压力和流量影响]
impact[区分拓扑停水区、水力影响区和有资料支持的用户范围]
findings[形成候选位置、阀门清单、影响对比和建议方案]
fieldbasis{是否已有足以支持具体现场核验的结论}
fielddraft[形成有位置、核验目标及回执要求的现场核验建议]
gap[真实关键数据缺失:发布 final insufficient_data 并结束本轮]
error[接口、权限或配置异常:记录具体失败并由宿主结束失败运行]
scope --> location --> candidate
scope --> monitor --> cover
cover -->|未覆盖且可补齐| more --> monitor
cover -->|已覆盖或确认实际缺失| hypothesis
hypothesis -->|具备依据| locate --> candidate
hypothesis -->|条件不具备,明确原因| candidate
candidate --> usable
usable -->|候选有效,标明待现场确认| isolation --> isolateok
isolateok -->|有可评估方案| hydraulic --> impact --> findings
isolateok -->|已知存在不可关阀| alternate --> isolation
isolateok -->|无法形成方案| fieldbasis
usable -->|尚无候选| fieldbasis
fieldbasis -->|可形成具体核验建议| fielddraft
fieldbasis -->|当前判断所需证据确实缺失| gap
monitor -->|不可恢复的查询异常| error
location -->|不可恢复的必要查询异常| error
end
subgraph approval[报告、审批与现场处置]
interim[保存本轮 interim concluded 阶段报告]
proposals[创建具体待审批草稿,关联证据和执行前置条件]
finalround[保存本轮 final 报告,由宿主结束本轮 run]
review[有权限人员审核当前草稿版本]
decision{审批结果}
revise[驳回理由保存并触发原调查新一轮]
submit[Server 通过配置的适配器幂等提交]
receipt{外部提交结果}
reconcile[使用同一执行请求号核对回执,不重复下发]
unsupported[未配置或明确失败:记录原因并重新分析]
workkind{本次获批的是核验还是处置}
fieldcheck[现场核验准确位置、漏水迹象及资产,回传实际记录]
notice[人工实施获批的通知、供水保障和应急安排]
isolatefield[现场核验阀门及实际隔离结果]
repair[现场定位、止水和抢修,回传实际故障管段及修复记录]
clean[按现场风险及获批方案清洗、消毒或冲洗]
restore[核验修复与适用复测,按批准方案恢复供水]
follow[记录恢复后压力流量、漏水复查及用户回访]
findings --> interim
fielddraft --> interim
interim --> proposals --> finalround --> review --> decision
decision -->|驳回| revise
decision -->|批准| submit --> receipt
receipt -->|结果不明或仍在执行| reconcile --> receipt
receipt -->|明确失败或不支持| unsupported
receipt -->|工单已创建或设备动作有回执| workkind
workkind -->|现场核验事项| fieldcheck
workkind -->|有依据的处置事项| notice
notice --> isolatefield --> repair --> clean --> restore --> follow
end
subgraph closure[现场反馈、复核与原事件闭环]
feedback[外围系统保存关联工单的新反馈和单调版本]
requeue[Server 校验来源并为原调查排队新 generation]
reread[重新读取现场事实、执行回执和最新监测证据]
verify[对照预测与现场结果,复核修复、恢复和风险]
faultstate{复核结果支持故障、误报还是尚待核验}
falsealarm[核对排除故障的证据及人工确认]
closed{处置及闭环证据是否齐全}
remaining[列出未完成事项及对应已有或待审批动作]
sourceclosed{原上报工单已结案且版本已核对}
readyreport[发布具备结案条件报告并结束本轮]
closedreport[发布闭环复核 final 报告并结束本轮]
closeowner[有权限人员在原工单系统核对并完成原事件结案]
closedcase[报告与来源事实一致,确认业务闭环]
follow --> feedback --> requeue
fieldcheck --> feedback
reread --> verify --> faultstate
faultstate -->|已确认故障| closed
faultstate -->|有依据排除故障| falsealarm --> sourceclosed
faultstate -->|尚需核验| remaining
closed -->|尚未齐全或复测异常| remaining --> interim
closed -->|证据齐全| sourceclosed
sourceclosed -->|是| closedreport --> closedcase
sourceclosed -->|否| readyreport --> closeowner --> feedback
reread -->|现场位置或阀门条件改变| candidate
end
subgraph presentation[贯穿调查的地图、图表与数据整理]
progress[发布真实调查进展和 Mermaid 状态]
map[高亮候选管段、闪烁建议关阀、展示已有证据覆盖层]
chart[打开异常与对照曲线,展示候选及影响对比表]
filepermit{会话是否已有文件写入权限}
jsonl[按权限整理有来源引用的 JSONL 调查数据]
reportui[通过报告 ui_blocks 发布 json-render 结构化界面]
sourceview[展示保持证据来源,失败不阻断业务调查]
progress --> map --> chart --> filepermit
filepermit -->|已获准且需要导出| jsonl --> reportui
filepermit -->|未获准或无需导出| reportui
reportui --> sourceview
end
context -->|首次或需要重新分析| scope
context -->|已有新反馈待复核| reread
revise --> requeue
unsupported --> requeue
requeue --> queue
gap -->|补充事实被 Server 接受后才重开| requeue
findings -->|无须新增动作| finalonly[无需新增动作时保存 final 报告并结束本轮]
scope -.-> progress
candidate -.-> map
impact -.-> chart
verify -.-> reportui
```
图中的现场处置由实际审批、工单与回执推进;顺序按现场紧迫性和获批方案调整。必要的紧急通知、现场核验和供水保障不应等待可选的完整模型计算。`clean` 为按风险适用的步骤,不能把每次爆管都判断为污染。`hydraulic` 在缺少模型、时序输入或权限时记录具体限制,仍可保留已完成的拓扑隔离结果。
`requeue` 表示 Server 接受了新业务事实后分配下一轮,不是 Agent 复活旧会话或自行循环等待。原工单结案由原系统有权限人员处理,后台 Agent 目前没有直接关闭原工单的工具。`completed`、`concluded`、动作 `succeeded` 和“业务闭环”分别核对,不能互相推导。
## 将业务步骤映射为现有能力
以下产品查询与计算均使用 `mcp_tjwater_` 前缀。参数以本轮实际发现的工具 schema 为准。
| 业务动作 | 已有入口 | 必须形成的结果 |
| --- | --- | --- |
| 接续原任务 | `get_task_context`,依次读取 summary、skill 及所需分节;`get_task_run_history` | 原工单来源、事件时刻、调查代次、流程版本、既有动作与现场反馈 |
| 查找地点和资产 | `geocode` / `search_project_places`、`query_nearby_elements`、`get_element_properties`、`get_node_connections` | 实际候选管段或节点、位置来源、匹配理由;邻近不代表故障确认 |
| 核对监测 | `list_scada_devices`、`get_scada_device`、`query_scada_readings`、`query_element_history` | 设备和模型节点的映射、异常及对照窗口、单位、缺测、分页覆盖 |
| 压力异常侦测 | `run_burst_detection` | 至少 5 个完整压力测点及此前 15 天数据满足时才调用;不把侦测当作定位 |
| 爆管候选定位 | `run_burst_location` | 候选排序、算法实际返回的评价值、观测范围、漏水量假设及来源 |
| 逐候选隔离 | `analyze_valve_isolation` | `accident_elements`、`isolatable`、`must_close_valves`、`optional_valves`、`affected_nodes`、`affected_node_count` 及已知 `disabled_valves` |
| 水力影响 | `analyze_valve_closure` | 指定关阀组合、时间窗、输出对象和模拟压力流量;参数不足时保留明确原因 |
| 用户影响 | 已授权资产属性、区域及外围资料查询 | 受影响对象、关联依据和覆盖限制;没有用户资料不估造户数 |
| 发布和提议 | `publish_task_progress`、`publish_task_report`、`propose_task_action` | 持久化报告、证据引用和待审批动作;动作不是执行事实 |
| 反馈接续 | Server `external-feedback` / `human-evidence` 及 worker | 同一任务的新轮次、真实来源版本、修复和复测证据 |
定位的 `burst_leakage` 必须有依据,单位为 m³/s;`pressure_scada_ids` 是模型节点 ID,不能填设备查询编号,也可省略并使用服务的项目配置。monitoring 模式正常窗可成对省略,使用前一天同窗,但这只是一项对照假设,仍要核对数据有效性。simulation 模式必须引用已有分析运行 UUID,不把在线重算当成已有分析运行。已知事故管段可直接做候选隔离;不必先完成一次没有必要的定位计算,也不能把已知答案作为算法独立定位成功的证明。
隔离工具支持明确标注的候选管段,无须等到现场确认才评估方案。多个候选的比较优先分开调用;把多个 ID 一次传入表示联合事故场景,不等同于逐个比较。`disabled_valves` 仅填已有事实支持的不可关阀;空数组不证明现场阀门均可用。`isolatable=false` 不能解释为零影响,即使 `affected_nodes` 为空也须解释返回的计数和限制。
## 像调查员一样查询、查看、比较和复核
每一步先明确待回答的问题,再查小范围资料,观察实际结果后决定下一步。例如:核实地址,查附近管段并查看属性,关联压力点,比较事件前后及正常对照,检查候选,评估关阀影响。只把必要的查询、事实和判断依据写入进度及报告,不保存模型内部推理。
- 不满足于“查过 SCADA”:必须确认返回的时间实际覆盖事件。查询 24 小时而只返回当天前 45 分钟时,应缩到事件前后的小窗,按需补页,不直接进入最终判断。
- 正常基线、实测、模型模拟、外围演练和人工反馈分别标注;缺测不是零值,异常不是爆管确认,候选评分不是校准后的概率。
- 查不到事故管段时先检查地址解析、附近资产与上下游关联;能查询的字段先查询,不能直接全部交给现场。
- 既有证据通过 `get_task_evidence` 的有界分页读取,不重复拉取同一批事实。失败、权限拒绝和存储未配置不当作“无异常”或“无数据”。
- 可以用最多两个前台 `investigation-reader` 并行核对无依赖的问题。读取、计算、发布和文件权限仍按角色执行;reader 只返回查询事实及真实证据编号。
## 地图与图表操作
后台调查统一调用 `publish_task_presentation`,每个不同命令使用不同 UUID `request_id`,相同命令重试复用原编号。以下为工具已有能力,不需要浏览器自动化。
| 调查进展 | command.action | 取值及依据 |
| --- | --- | --- |
| 找到实际候选资产 | `highlight_features` | `layer=pipes` / `junctions`,`feature_ids` 为已核实的地图要素编号,`effect=steady` |
| 正在核查候选或建议关阀 | `highlight_features` | 对应 pipes 或 valves,`effect=pulse`;闪烁只表示调查关注,不代表设备正在操作 |
| 查看异常与对照 | `open_chart` | 有限长度的时间和值,引用真实 `evidence_refs`;不同日期的对照对齐方式在标题或来源中说明 |
| 查看已有空间结果 | `set_overlay` | 提供 `overlay_id/title/mode/evidence_id`,Server 从证据提取几何,不接收手绘影响区 |
| 撤下旧的临时展示 | `close_chart` / `clear_overlay` / `clear_highlights` | 清理自己记录的临时展示;清理失败不修改业务结果 |
支持的图层为 junctions、pipes、valves、pumps、tanks、reservoirs、scada。先核实业务 ID 与地图 feature ID 的映射,不能把设备编号、节点编号与 GIS 编号混用。每次高亮最多 100 个要素,需要更多时说明所显示的子集;不要假定多次命令会累积成完整范围。没有几何时可以按已知 ID 高亮、用表格解释结果,不画包络多边形冒充影响范围。
图表最多 100 个横轴值和 8 组数值序列,单位须在系列名称或标题中明确。不能把 null 缺测填零以通过 schema;可以按有数据的区间分图,并保留缺测说明。`recorded` 仅表示请求保存。浏览器未开 Dev 跟随时仍继续调查,不能等待地图显示回执。后台接口目前没有摄像机缩放、平移、鼠标点击或任意图层开关命令;不要调用仅供聊天的 `locate_features`、`zoom_to_map` 或原生浏览器工具替代。
## JSONL 调查数据与 json-render 展示
JSONL 用于整理已经取得的数据与分析结果,不补造监测、阀门、现场反馈或审批。推荐每行一个对象,记录 `record_type/origin/source_evidence_refs`,并按对象保留时间窗、单位、模型或假设、覆盖限制和实际结果。`origin` 沿用来源分类;本地派生记录引用输入证据,并说明转换方法。不要把 JSONL 文件自身当作 Server 已保存的业务证据。
按内容选择 `event_context`、`monitoring_coverage`、`candidate`、`isolation`、`hydraulic_impact`、`field_feedback`、`closure_check`,无需为没有结果的类别制造空壳文件。候选记录写实际排序及评价值;隔离记录保留建议必关阀、可选阀、已知不可关阀及受影响节点;闭环记录逐项保留已核验、待核验或不适用及原因。JSONL 是导出约定,不新增 MCP 输入字段或服务端数据协议。
以下两行只演示文件格式,`synthetic`、`example_only` 与空证据引用表示它们不可用于实际报告或审批,不包含真实分析结果:
```jsonl
{"record_type":"candidate","origin":"synthetic","example_only":true,"source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":null,"evaluation_value":null,"status":"not_computed","limitations":["仅说明字段,未执行定位"]}
{"record_type":"closure_check","origin":"synthetic","example_only":true,"source_evidence_refs":[],"item":"repair_verified","status":"not_verified","observed_at":null,"limitations":["无现场反馈,不能声称闭环"]}
```
如果当前会话已获准使用原生文件写入,则在当前 workspace 下写 `files/burst-investigation-<generation>.jsonl`,保留原始证据不变;通过允许的读取工具逐行复核 JSON、行数、对象 ID、时间和单位。只引用宿主绑定的 generation,不能从用户文本自选。不得读取兄弟会话、业务数据库、宿主源码或凭据;需要本地转换时只使用已获准的原生工具及沙箱 Shell,不引入 HTTP、SQL 或自由命令查询桥。
当前没有专用的免审批 JSONL 导出工具。文件权限未授予时,不通过 Shell 绕过、不让后台无限等待批准、不声称已生成文件;继续完成报告内的结构化数据,并如实说明文件未保存。恢复写入授权须由有权限的人或宿主处理,Skill 不扩大权限。
图表、表格和指标使用 `publish_task_report.report.ui_blocks`,每块为 `answer-ui@1`,包含独立 `block_id`、`fallback_text` 和 json-render 的 `spec.root/elements`,外层附真实 `evidence_refs`。最多 8 块、总计 400 KB,单块还受现有 schema 限制。优先组织以下内容:
| 展示 | 建议组件 | 数据来源 |
| --- | --- | --- |
| 候选管段及理由 | Table、Evidence、MapLocation | 地点、资产与定位证据;明确候选和已确认的区别 |
| 异常及正常对照 | Chart | 同单位的实际序列,说明时间对齐及缺测 |
| 关阀与隔离范围 | Table、Metric、MapLocation | 隔离证据、阀门属性及候选场景 |
| 水力与用户影响 | Table、Chart | 指定输出节点的模拟结果及真实用户关联资料 |
| 处置与闭环核对 | Table、Evidence | 动作回执、现场反馈、恢复监测和原工单版本 |
只用工具公布的组件属性。JSONL 可作为整理中间格式,但目前没有“把任意 JSONL 直接传给 json-render”的通道;需转换为已注册组件的 JSON 属性。后台不用 `present_answer`,不把原始 JSONL 输出到聊天正文,也不把表单提交当成已保存现场证据。小数据和简单结论可直接文字表达。
## 本轮成果与爆管闭环的验收
报告至少回答下列四类问题,逐项给出实际结果或经过查询证实的具体限制;无需固定排版或创建固定数量的动作。
1. **位置**:事故已确认还是仅有候选?每个候选的模型/地图编号、匹配或定位依据、排名及未确认事项是什么?
2. **隔离**:每个候选场景是否可隔离?应关、可选和已知不可关的阀门分别是什么?有无替代方案及现场前置核验?
3. **影响**:拓扑受影响节点、水力压降或失供范围、用户关联分别有哪些实际结果?未覆盖什么?没有水力或用户资料时分别说明,不能推定为零。
4. **处置与闭环**:当前可审批的工作是什么?已有哪些动作、审批、提交回执和现场事实?还缺什么反馈才具备结案条件?
有具体结论和动作建议时,先保存 `interim/concluded`,再 `propose_task_action`,最后保存 `final`。人工执行事项使用 `work_order_type=general`,保留原爆管事件。工单描述写位置、任务内容、依赖、执行前核验、需回传的事实和责任要求;`propose_task_action` 当前 payload 没有独立的 dependencies 或 feedback_requirements 字段,不发明字段。没有客户通知适配器时,将通知作为人工工单事项,不宣称已发送。
闭环复核至少核对实际故障资产及原因、隔离/阀门实施记录、抢修结果、适用的清洗及水质复测、恢复供水记录和恢复后监测、用户通知及回访、未决动作与遗留风险、原上报工单结案状态。不适用事项必须有事件依据;未收到结果写待核验,不把记录创建时间当现场完成时间。只齐备处置证据而原工单未结案时,结论为“具备结案条件,待原系统确认”,而非“已闭环”。误报或未发现故障的结案也要有支持排除的实际核查与人工确认。
必要查询失败、权限拒绝和存储未配置属于执行异常,按 schema 修正可纠正问题;仍无法取得当前判断必需的资料时交宿主记录失败。已通过有效查询确认关键数据确实缺失且无法补齐,才发布 `final/insufficient_data`,提供 blocking 缺口和查询依据,建议与待办为空,不在该退出分支再生成补数据工单。能够明确形成一项现场核验结论时可提议具体核验动作,但不能把“尚未开展定位”包装为“已完成定位调查”。
## 能力边界与验证
本次交付为工程图和运行时 Skill 文本,使用现有 MCP、发布、审批与反馈能力;不新增自动关单、鼠标自动化、摄像机操作、JSONL 导出接口或数据库字段。Server 目前没有按 Skill 检查定位/隔离成果的强制验收器,本文的业务验收要求仍由 Agent 执行并由人复核。
当前 Skill 仅通过 `section=skill` 整份分发,没有相对路径 reference 读取协议,因此运行时指令保持自包含,不能要求 Agent 读取本工程文档。内容需控制在既有内联预算内。Server 更新资源后,应按原发布方式让实际 Server 实例取得新文件;Agent 读取时核对 `name/version/content_sha256`,不把本地文件更新当成运行中实例已生效。
验证包含 Mermaid 语法解析、本地路径和工具 ID 核对、Skill 分发/权限/体积测试及 `git diff --check`。后续真实行为验收应分别覆盖:有效观测和漏水量依据下的定位;已知候选但缺漏水量时的候选隔离;不可关阀替代方案;SCADA 首屏未覆盖事件时继续读取;接口失败明确终止;文件权限缺失不绕过;无浏览器时照常调查;实际反馈触发新轮及最终闭环复核。没有真实模型计算或现场回执,不报告定位准确性或业务闭环已通过。
关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)、[后台工作进程](operational-task-workers.md)。
+94
View File
@@ -0,0 +1,94 @@
# MCP 后台计算队列
API 保留 `uvicorn --workers 4`。长计算在项目 BizDB 持久化入队,由独立计算进程领取;所有 API 进程读取相同任务状态,不依赖粘性会话。HTTP 返回预算由宿主管理,Server 默认 120 秒、Agent 默认 180 秒。Server 通过工具目录 `_meta["io.tjwater/request-timeout"]` 统一发布 `defaultSeconds` 和 `maxSeconds`,所有工具(包括状态和结果读取)使用同一配置来源。Server 兼容保留 `timeout_seconds` 参数;Agent 对模型隐藏并拒绝该参数,按目录预算代填。Server 请求上限为 240 秒,Agent 加 60 秒返回余量并封顶 270 秒,原生 MCP 与本地连接的总时限为 300 秒;上游 HTTP 的 5 分钟空闲超时保留。提交、查询状态和读取结果都应及时返回,不等待整个计算。模拟时间仍由 `duration` 表示。
后台执行时限由管理员配置 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS`(默认 3600 秒),入队时单独写入任务记录。HTTP 参数不参与作业幂等参数比较,也不会缩短或延长后台执行。任务表的内部 `timeout_seconds` 列仍保存执行时限。HTTP 超时或断开只结束本次等待,后台作业需通过独立取消工具取消。
```text
前端 / Agent → API × 4 → 项目 BizDB calculation_jobs
↑
独立计算 supervisor
↓
受管理的计算子进程组
```
## 工具契约
原工具名继续使用。以下工具提交后返回 `QueryResult`,其中 `semantics=task`,`data` 包含 `job_id`、`status`、`tool` 和 `poll_after_ms`;这只证明提交状态,不是分析结果或调查证据:
- `run_burst_location`、`run_leakage_identification`、`run_burst_detection`
- `run_simulation`、`run_burst_simulation`、`run_contaminant_simulation`
- `analyze_valve_closure`、`analyze_flushing`、`analyze_pressure_regulation`
- `optimize_sensor_placement`
- `save_simulation_scenario`、`create_sensor_placement` 的 `dry_run=false` 执行阶段
后两项保留预览、摘要校验和原有审批;提交与执行时均校验权限及预览摘要,已有写入回执继续约束重复请求。`analyze_valve_isolation`、`calculate_demand_allocation` 保持同步执行。普通查询和其他写入不进入此队列。
| MCP 工具 | 输入 | 返回 |
| --- | --- | --- |
| `get_calculation_job` | `job_id` | 状态、工具、时间和安全错误码 |
| `get_calculation_result` | `job_id`,可选 `data_path/limit/offset` | 成功后读取原分析结果;未成功返回任务状态 |
| `list_calculation_jobs` | 可选 `status/limit/offset` | 当前授权会话及调查轮次的任务列表 |
| `cancel_calculation_job` | `job_id` | 排队任务可直接取消;运行任务先进入 `cancel_requested` |
状态为 `queued/running/cancel_requested/succeeded/failed/cancelled/timed_out/interrupted`。返回创建、开始、结束和排队/结果到期时间;`result_available` 表示结果是否仍可读取。任务取消不等于在线模型写入回滚;发生失败、超时或中断时,需要核查已有写入,不能自动重算整个窗口。
当前使用普通 MCP 业务工具承载队列契约,没有宣称已实现协议级 Tasks 扩展。Server 和 Agent 目录必须配套更新,旧 Agent 不应接收新的队列返回契约。
## 身份、幂等和结果
任务记录保存提交人的元数据用户 ID、项目、原工具参数和受限授权快照,不保存 Bearer、数据库 DSN 或短期 `context_handle`。执行和读取结果时重新查询当前用户、成员关系、项目路由、权限及调查运行租约。任务不会因为短期 MCP 句柄失效而丢失;也不会借持久化任务绕过撤销的权限。
普通计算使用宿主从原生工具调用记录解析出的稳定调用 ID;同一次传输重试复用任务,不将新的业务计算自动按参数合并。重写工具同时保留原审批 `request_id` 幂等语义。不同参数复用同一操作编号会被拒绝。
调查的排队回执不写成分析证据。成功结果读取时,按原计算来源、任务及运行验证,并幂等地保存证据。未完成的计算阻止提交最终报告、`insufficient_data` 或将运行标记成功;中间进度仍可保存。
Agent 调查执行在原生回合空闲后由宿主短轮询等待,继续维持调查心跳。新作业进入终态即触发模型读取结果,其余作业继续后台运行。状态读取遇到网络、限流或临时服务错误,最多重试 3 次,采用指数退避和随机抖动;权限拒绝、上下文失效立即停止,重试耗尽明确报错,不重新提交或取消计算。普通聊天展示任务编号,可在当前会话后续查询。异常流结束时若无法安全继续处理新结果,会明确失败而不是虚报完成;Server 中的计算结果仍保留。
## 排队期限与结果保留
每项目最多 100 个未结束作业(queued/running/cancel_requested),跨 API 进程用数据库事务锁串行执行幂等检查与容量准入;同一请求的重试优先返回已有作业。排队最长 3600 秒,超过后标为 `timed_out`,错误码为 `QUEUE_TIMEOUT`;开始执行后使用独立的执行期限。提交、查询和 Worker 领取都会核查到期任务,所以 Worker 停止时,查询也能结束过期排队。部署监控仍须覆盖 Worker 进程状态和最老排队任务年龄,队列到期机制不代替服务可用性监控。
结果正文在终态后保留 7 天,到期由队列维护清除,读取返回 `RESULT_EXPIRED`。任务状态、输入、幂等标识和已归档调查证据不随之删除,也不会因结果过期重新计算。状态列表只读取状态字段,不加载完整计算结果。
## 并发、取消和恢复
一个 supervisor 默认全项目合计并发 2 项,独立于 API 的 4 个进程。它持有元数据库的会话级 advisory lock,第二个 supervisor 会拒绝启动;当前不是多台计算节点各自扩容的模式。各项目轮转领取,使用 PostgreSQL 短事务 `SKIP LOCKED`,租约代次约束心跳和结果写回。
每项任务有独立子进程组;取消、执行超时或失去租约时先停止进程组,再写终态。守护管道在 supervisor 被强杀后关闭,负责终止仍在运行的算法进程。API 重启不影响计算 supervisor;计算进程崩溃后,旧任务标为中断,不自动重放。
原生临时文件写入独立任务目录。临时模型数据库创建前登记到该目录的账本,正常结束或取消后只清理明确登记的临时数据库。清理失败返回 `CLEANUP_FAILED` 并保留恢复目录;启动时检查项目、任务和执行代次,确认旧租约过期后恢复清理。无法验证归属或清理失败时停止领取新工作。
## 部署
1. 先对每个目标项目的 BizDB 依次应用 `resources/sql/026_calculation_jobs.sql` 和 `resources/sql/027_calculation_job_lifecycle.sql`,包括未来项目使用的相应业务模板。已有 026 的库只需追加 027。不要应用到 TimescaleDB。027 增加时间字段与索引,不清除结果;已有结果从首次迁移起获得完整 7 天保留期。必须先迁移,再加载新 API/Worker 代码。
2. 配置 `CALCULATION_WORKER_PROJECT_IDS` 为明确的项目 UUID 列表,配置现有元数据库连接及数据库路由解密密钥。Worker 不需要新 OAuth 密钥;仅部署侧工作进程读取这些设置。
3. 同步发布 Server 和 Agent,在接入长计算前启动独立 Worker。不要从 API `lifespan` 启动,不要把调查 Worker 当作计算 Worker。
从 Server 仓库启动:
```bash
conda run -n server python scripts/run_calculation_worker.py
```
systemd 模板:`infra/systemd/tjwater-calculation-worker.service`。Docker 使用同一 Server 镜像的独立服务:
```bash
docker compose -f infra/docker/docker-compose.yml --profile calculations up -d calculation-worker
```
Compose 使用 `../../.env` 注入配置,需确保其中数据库地址在容器内可达。`calculation-state` volume 保存任务恢复账本,不能当作临时缓存删除。非容器启动可用 `CALCULATION_WORKER_STATE_DIR` 指定持久目录。
并发默认 2、轮询默认 2 秒、租约 30 秒、心跳 5 秒。算法内部并行度仍消耗额外 CPU/内存;调整并发必须依据实际资源测量。
回滚前暂停接收新的队列计算并等待或明确取消在途任务,核对有副作用任务的写入。保留任务表、结果和恢复目录;旧同步版本不支持继续处理这些作业,不能直接删除记录后重新提交。
## 验证
```bash
conda run -n server python -m pytest tests/unit/test_mcp_calculation_jobs.py tests/unit/test_calculation_worker.py tests/unit/test_calculation_cleanup.py tests/unit/test_mcp_query.py tests/unit/test_mcp_analysis.py tests/unit/test_operational_tasks.py -q
```
真实 PostgreSQL 并发测试为 `tests/integration/test_calculation_queue_storage.py`。它要求 `CALCULATION_QUEUE_TEST_DSN` 指向名称以 `tjwater_calc_test_` 开头的专用可丢弃数据库,不能指向业务库。覆盖四连接重复提交、参数冲突、并发领取、项目和用户隔离、取消竞争、失效代次拒绝和过期租约恢复。
这些验证不代替真实项目算法运行、浏览器断线重连或部署验收。生产接入还应观察超过 5 分钟的真实计算、恢复后结果读取和原生临时资源清理。
+65
View File
@@ -0,0 +1,65 @@
# 凤阳六月爆管模拟与 SCADA 复制
2026-10-22 已完成活动项目 `fengyang_v2`
(`b962c44e-8433-4a10-81e9-a64b70ac1152`)的三段模拟爆管数据及前一天基线复制。
一次性复制脚本及其专属测试已清理,来源、核对结果和备份记录如下:
| 方案名称 | 模型管段 | 模型管径 |
| --- | --- | --- |
| `2026-06-02 Pipe19026 DN200 burst` | `19026` | 200 mm |
| `2026-06-12 Pipe18751 DN160 burst` | `18751` | 160 mm |
| `2026-06-22 Pipe18750 DN160 burst` | `18750` | 160 mm |
方案名称、`burst_ID` 和 `network.pipes.diameter` 必须一致。时间使用方案的
`started_at` 和结果时间戳,历时为 4 小时,包含起止点,15 分钟一个采样。
不使用项目说明中的历史时间窗,也不使用旧的 `5hrs burst scheme` 数据。
源数据为 `analysis.node_results.pressure`,仅复制既有 50 个压力设备
`asset.scada_devices.node_id` 对应的节点。每段必须完整覆盖 17 个时间点,
即 850 条观测,三段共 2,550 条;方案状态必须为 `completed`。
压力源单位来自每个方案的 `parameters.result_units.pressure`,目标单位来自
设备的 `measurement_unit`,并用总水头减节点高程核对压力单位。缺失水头、
高程或单位不一致时拒绝写入。例如真正的 kPa 转米水头使用 `pressure / 9.80665`。
不添加噪声、不平移日期、不插值。写入 `scada.measurements.monitored_value`,
同时将窗口内对应观测的 `cleaned_value` 清空,避免使用旧观测的清洗结果。
其他日期、其他设备及模拟源数据保持原样;原有其他模拟事件也保留。
正常基线直接读取 `realtime.node_results`,保留其原始日期和压力值。每段在线
模拟采用右端不含的时间窗,4 小时共有 16 个采样点,最后一个点比窗口终点
早 15 分钟。三段共 2,400 条观测。基线源单位来自模型配置,并同样校验
总水头减高程;不从爆管结果或旧 SCADA 推导正常值。
| 管段 | 爆管数据时间(北京时间,含末点) | 前一天基线采样时间(北京时间) |
| --- | --- | --- |
| `19026` DN200 | 6 月 2 日 08:00–12:00 | 6 月 1 日 08:00–11:45 |
| `18751` DN160 | 6 月 12 日 12:00–16:00 | 6 月 11 日 12:00–15:45 |
| `18750` DN160 | 6 月 22 日 18:00–22:00 | 6 月 21 日 18:00–21:45 |
定位算法配对比较时应采用共同的 16 个相对采样时刻,或明确使用右端不含的
4 小时时间窗,避免将爆管结果额外的终点与不存在的基线终点配对。
2026-10-22 核查发现 EPANET 输出解析曾将压力枚举 1、2 的单位名称写反。
已改为 `0=PSI, 1=KPA, 2=MTR`,并通过本地原生 EPANET 输出测试验证。
这三个方案的全部 2,550 个压力值都与米制水头减高程一致,元数据已纠正为
`MTR`,原始模拟数值保持不变。SCADA 最终使用设备的 `m` 单位,无额外换算。
更正前的三个方案参数与核查证据保存在忽略目录
`data/fengyang-result-unit-correction/`。
本次最终复制记录为
`data/fengyang-burst-scada/20261022T101442415337Z/report.json`:
2,550 条爆管观测与 2,400 条前一天基线,共替换 4,950 条观测;SCADA 整表
仍为 144,000 条、50 个设备。窗口外指纹不变,独立 SQL 比对六段数据与源值
差异为零,重复只读预览显示 4,950 条均已一致。三个方案均为 `completed`。
本次执行前已锁定观测表并备份整表至忽略目录
`data/fengyang-burst-scada/<UTC时间>/measurements-before.csv`,同时保存
`prepared.json`(来源、设备映射、备份校验和)和 `expected.json`(预期观测)。
目录权限为 0700,文件权限为 0600,不包含数据库凭据。
本次复制在一个事务中完成,逐条比对预期值、核对总行数、校验窗口外数据
指纹不变。提交后使用新连接再次读回校验,已保存 `report.json`。
如需恢复,先核对对应报告的备份校验和,将 CSV 导入临时表,再仅恢复
`expected.json` 中的 `(time, device_id)` 键;不要整表覆盖当前观测。
数据库读回校验不代表已经完成 HTTP、界面回放或爆管定位算法验收。
@@ -55,21 +55,60 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
| command.action | 参数 |
| --- | --- |
| highlight_features | layer、feature_ids、effect:steady 或 pulse |
| locate_features | layer、feature_ids |
| zoom_to_map | center:WGS84 经度纬度、zoom:0–24 |
| 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 |
| set_overlay | overlay_id、title、mode:geometry 或 heatmap、evidence_id,可选 data |
| clear_overlay | overlay_id |
| render_scada_analysis | items:device_id 与 high/medium/low/unrated 等级,设备编号不重复 |
| clear_scada_analysis | 无 |
以上十种动作与 Chat 的 `present_view`、Dev JSONL 共用扁平 `command: {action, ...字段}`。Chat 使用 `frontend-action@2` 和执行回执;任务仍只记录展示请求,Dev 只做本地预览。三者使用独立的地图源、SCADA 图层和图表状态。Dev 的动作数据行必须为 `{"record_type":"presentation","command":{...}}`;界面块行使用 `record_type: ui_block`,新示例明确带 `phase: final`。普通调查数据保持来源字段,不执行其中的 action。仓库示例见[前端统一展示夹具](../../next-tjwater-frontend/tests/fixtures/unified-presentation.jsonl),其中编号来自测试夹具。
图表限定折线和柱形,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。
覆盖层输入不接受模型提供的 `data`。Server 从证据的不可变数据快照提取现有 GeoJSON,
覆盖层的权威数据来自不可变证据。省略 `data` 时,Server 从证据快照提取现有 GeoJSON,
或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。
若传入 `data`,必须与同一证据快照解析出的内容一致;Server 始终发布快照解析结果,不能用调用参数覆盖证据。
热力图只接受记录中的点,权重为有限非负数,未记录权重时取 1。
没有坐标的地址、空数据、非法几何、模型自绘区域均不能生成覆盖层。该接口不做地址解析、
范围推算或污染传播计算。展示事件总大小上限为 512 KiB,超限返回 422。
`generate_node_coverage` 是独立的节点空间覆盖工具,适用于普通项目聊天和后台调查,
不绑定爆管、阀门隔离或固定执行顺序。调用者提供需要比较的节点分组,Server 从当前项目
`gis.node_geometries` 读取真实位置。监测设备、投诉、地址等须先关联模型节点,
不接受模型自由生成的坐标。具体适用范围和参数见[节点覆盖工具](node-coverage-tool.md)。
计算先投影到局部米制坐标,取凹包(默认 ratio=0.25)、加展示缓冲(默认 25 m),
再以 2 m 容差简化并转回 WGS84;凹包比例与缓冲距离可在有界参数内选择。
默认展示各组的地理包络,不判断连接关系;需要时可按模型连接拆分,额外指定视为断开的连接。
少量点、重合点和共线点同样通过缓冲形成面。多组结果一起保存在 `result.map_payload`,
避免连续 `set_overlay` 覆盖前一组;`result.coverage` 保存来源节点、映射完整性及算法参数。
该面不推断故障、污染、实际停水、水力影响或用户服务边界,缓冲距离也不是供水服务半径。
缺失几何时标记 `partial`,没有可用位置时不生成面。输入最多 8 组、合计 10000 个不同节点;
几何输出遵守覆盖层顶点和大小限制,超限要求缩小范围,不静默抽样或改变原分析结果。
MCP 保存完整查询响应作为不可变证据,展示解析器同时支持响应信封中的
`data.result.map_payload`。Agent 使用 `set_overlay` 引用本次 `evidence_id` 并省略 `data`。
超过内联返回预算时,响应的 `data.presentation.command` 仍保留可发布的展示命令,
`data.presentation.groups` 保留覆盖完整性计数,
完整几何留在证据中,不能把缩略返回误认为没有空间成果。调查按实际步骤同步发布展示:
地址得到明确坐标后缩放定位,取得候选资产后定位候选,检查影响节点后生成并展示覆盖示意,
形成或调整关阀方案后定位阀门,读取监测对照后展示点位及序列。
图表同样随结果返回发布:监测趋势与事件对照用折线图,可比候选评价或方案用柱状图,
水力及污染传播等模拟按实际返回的时刻、对象和指标画图。压力、流量、浓度等不同单位分图,
注明来源、范围与实测或模型属性;没有同口径基准时不计算或绘制改善量,缺测不补零。
过程图表通过 `open_chart` 提供 `chart_id/chart/evidence_refs`,每图最多100个横轴值、8组序列,
超限时分图或明确说明抽样、聚合范围;单值或无可比数据用文字或表格。
关键趋势与比较结论保留到报告中,最终报告的图表不替代过程发布。
业务 Skill 说明本步骤应展示什么,调查指令统一说明发布方法、非空几何检查、去重和失败处理。
同一阶段批量展示,数据或方案没有变化时不重复请求;不为地图动作增加无关业务分析。
报告的 `map_targets` 仅为手动定位入口。关键步骤的数据缺失或展示失败需记录具体限制,
没有浏览器连接也不阻止分析或报告收尾。
`GET /api/v1/operational-tasks/{task_id}/presentation-cursor` 在同一事务中读取
`task_id/execution_generation/active_run/event_cursor`,其中活跃 run 仅返回 `run_id/generation/status`。
浏览器打开 Dev 联动、切换任务或断线重连时,以此建立新基线,只消费之后的展示事件。
@@ -98,7 +137,7 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
实际 worker 曾成功投递指定调查,模型发布进展和报告,但因 service 账号的任务权限过滤
导致 `approved_data_scopes` 为空,误作数据不足结束。这不是业务数据确实不足。
用户确认开放规则后,任务 API 已移除该额外过滤,沿用账号已有的项目读取权限。
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围合成工单成功;没有新增
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围系统工单成功;没有新增
角色权限或放宽跨项目、审批和设备控制边界。旧委托路径保留兼容,已有权限无需委托凭据。
用户级 `tjwater-operational-worker.service` 已重新启用。旧报告保留,未自动重新调查,
不能把旧报告视为修复后的业务验收。任务创建时间下界和并发配置见
+2 -1
View File
@@ -43,7 +43,8 @@
完整图语法的解析失败只影响图示,正文、依据和审核仍可阅读。
每条 node_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一;
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一,
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
最多 100 条引用。证据必须来自报告 evidence_refs,动作必须属于当前任务。
节点可以说明候选方向与条件关系;图中的分支、状态文字不能证明实际并发、动作执行或现场完成。
图的颜色、字体和交互由前端统一配置。正文不包含 SVG 或 HTML。
+25 -5
View File
@@ -1,6 +1,6 @@
# Agent 业务分析 MCP
Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共35项:25项查询、下表10项分析;合并6项宿主查询后共41项。工具参数来自Server实际MCP schema,不维护第二套参数契约。
Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42项查询、12项计算、13项预览式写入;合并6项宿主查询后共73项。下表保留原10项分析,新增服务及边界见 [MCP项目服务](mcp-project-services.md)。工具参数来自Server实际MCP schema,不维护第二套参数契约。
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
| --- | --- | --- |
@@ -52,17 +52,37 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共35项:25
- 数据源和计算假设必须有依据。定位漏水量和DMA总漏损约束使用m³/s;冲洗使用服务实际接收的m³/h,不沿用旧REST误标的L/s。工频泵序列0/1,变频泵序列为Hz;每项对应模型水力步长。冲洗当前限LPS/CMH模型。选址当前推荐压力点,不冒充污染溯源优化。
- 调查结果经现有快照/证据/回执链保存,只有返回evidence_id后才引用为系统证据;普通聊天不自动保存调查证据。模拟结果始终是analysis而非实测。已知模拟事件源管段是输入背景,不是算法定位准确性的证明。
## 关阀隔离输入与影响范围
REST `/api/v1/valve-isolation-analyses` 与 MCP `analyze_valve_isolation` 共用同一服务。
`accident_element_type` 默认为 `auto`:先识别管段 ID,再识别节点 ID;指定 `link` 时只匹配管段,指定 `node` 时只匹配节点。
节点表和管段表分别保证 ID 唯一,但不同类型可以重号;分析同名节点必须显式指定 `node`。
多个 ID 表示联合事故,不表示分别比较;类型参数作用于本次调用中的所有 ID。
服务从模型主表的一致快照读取连接关系、初始开关状态和水源,不依赖 GIS 几何或物化视图刷新。
不可关阀按保持现状处理;已关闭的管段、泵和阀门不参与连通,已关闭阀门不再列为建议关阀。
未知的事故 ID、未知的不可关阀 ID 和空事故列表会被拒绝。
- `affected_nodes` / `affected_node_count` 仍表示最近可关阀门围成的直接隔离区。不可隔离时列表为空,计数保留。
- `supply_affected_nodes` / `supply_affected_node_count` 表示按建议关阀后新增失去水源连接的节点,包含间接受影响区域,排除原本就不连通水源的节点。不可隔离或缺少水源资料时为 `null`。
- `source_check_performed` 表示是否已执行水源连通检查。`isolatable=true` 要求存在关阀边界,且关闭这些阀门后直接隔离区不再连通任何水源。
- `isolation_failure_reason` 可为 `connected_to_water_source`、`water_sources_unavailable`、`no_boundary_valves`;可隔离时为 `null`。
以上是模型初始开关状态下的无向拓扑分析,水库和水池均按潜在水源处理;不模拟泵或止回阀的单向性、时变控制、水池蓄水量、压力与流量。
因此“失去水源连接”不能直接当作经过水力计算或现场核实的停水范围。已保存的旧调查证据不会自动重算,需重新调用隔离分析生成新结果。
本次无需数据库迁移。
## 资源边界与发布
10项计算工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。
长计算采用[后台计算队列](calculation-queue.md)。10项分析计算与2项重计算写入的正式执行阶段快速返回任务编号,再使用 `get_calculation_job`、`get_calculation_result`、`list_calculation_jobs`、`cancel_calculation_job` 管理。原有预览与审批仍有效;排队回执不作为分析证据。
例如 `duration=3600, timeout_seconds=900` 表示模拟1小时、允许等待15分钟。Agent的上游HTTP/SDK预算为所选预算加5秒,整个宿主调用另留60秒处理认证、上下文绑定和清理;OpenCode连接上限从工具目录计算并写入宿主连接文件。本地MCP请求关闭Bun空闲断连,仍受上述调用截止时间控制。普通查询继续使用原30秒Server预算与35秒宿主预算。
HTTP 预算由宿主从目录元数据读取,Server 默认 120 秒、Agent 默认 180 秒;爆管定位不再设置单独的长超时。`timeout_seconds` 保留在 Server 兼容入口,Agent 对模型隐藏并拒绝覆盖。Server 上限 240 秒,Agent 加 60 秒余量并封顶 270 秒,外层原生 MCP 保留 300 秒总时限与上游 Bun 的 5 分钟空闲超时。后台执行时限由管理员配置 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS`(默认 3600 秒),不受该 HTTP 参数控制;模拟时间仍由 `duration` 表示。
计算在线程执行,不阻塞事件循环,单Server进程最多两项计算。超时不能杀死底层原生计算,槽位一直保留到计算真正退出;超时结果不会补写为调查成功结果,不自动反复重试。本次未增加后台计算作业队列。普通聊天大结果仍受现有12KB内联限制,调查结果可经证据分页读取。
`analyze_valve_isolation` 和 `calculate_demand_allocation` 保持同步。队列消费者独立于Uvicorn的4个API进程,默认全部配置项目合计并发2项,并通过元数据库单实例锁防止重复启动。取消、超时和租约失效需要停止计算子进程组;成功结果按原运行授权保存证据,未完成计算阻止调查提前结案。
在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。
无需新增数据库迁移或模型配置。须同步发布Server、Agent和前端证据枚举;先更新Server目录,再重启Agent,client模式还需重载原生连接。保持既有OAuth、MCP开关及数据库路由配置,不自动更改权限或重启调查。
队列接入需要在目标项目BizDB依次应用 `resources/sql/026_calculation_jobs.sql`、`resources/sql/027_calculation_job_lifecycle.sql` 并启动独立计算Worker。Server与Agent需配套发布,client模式还需重载原生连接。既有OAuth与项目权限保持不变;完整部署与回滚步骤见后台计算队列文档。
本次验证限schema、权限、协议目录和mock业务函数,不运行真实模拟、优化或付费模型。真实项目的模板同步状态、运算耗时、SCADA覆盖和结果准确性仍需另行验收。
+65
View File
@@ -0,0 +1,65 @@
# MCP 项目服务
当前 Server 有 67 项工具:42 项查询、12 项计算、13 项预览式写入。Agent 合并六项宿主查询后共 73 项。目录来自 `app/mcp/catalog.py`,Agent 使用独立的有限白名单并在启动时全等核对。工具前缀为 `mcp_tjwater_`。
## 新增能力
| 领域 | 工具(省略前缀) | 权限 |
| --- | --- | --- |
| 区域与连接 | `list_regions`, `get_region`, `get_region_nodes`, `get_node_connections` | `webgis.view` |
| 空间范围与骨干节点 | `query_network_extent`, `list_major_nodes` | `webgis.view` |
| 模型配置 | `get_model_configuration`, `list_model_configurations` | `webgis.view` |
| 项目地名 | `search_project_places`, `reverse_geocode` | `webgis.view` |
| 项目概况与导出 | `get_project_info`, `export_project_model` | `webgis.view` |
| 监测点交付 | `get_sensor_placement_candidate`, `export_sensor_placement` | `optimization.run` |
| 调度与运行历史 | `list_task_schedules`, `get_task_run_history` | `operational_task.view` |
| 需水量分配 | `calculate_demand_allocation` | `webgis.view` |
| 假设爆管水力模拟 | `run_burst_simulation` | `simulation.run` + `scada.view` |
| 模型编辑 | `edit_model_element`, `edit_model_configuration` | `webgis.edit` |
| SCADA 维护 | `clean_scada_readings`, `write_scada_readings`, `delete_scada_readings` | `scada.clean` |
| 正式分析成果 | `save_simulation_scenario` | `simulation.run` + `scada.view` |
| 正式监测点方案 | `create_sensor_placement`, `update_sensor_placement` | `optimization.run` |
| 任务与定时配置 | `create_operational_task`, `create_task_schedule`, `update_task_schedule`, `retry_operational_task`, `cancel_task_run` | `operational_task.write` |
| 写入状态核实 | `get_mutation_status` | `webgis.view`,仅当前身份与会话的执行记录 |
健康预测不在本次接入范围。报告发布、工单/设备动作的提议与审批、消息发送继续使用既有受控插件和业务动作流程。
## 数据与计算边界
- 所有查询使用当前授权项目连接。当前只有 TJWater 提供 GIS 信息;其他项目没有所需 GIS 数据或关系时返回空数据/明确不可用,不借用 TJWater 的图层或数据库。空页不能证明其他数据源没有对象。
- 范围查询直接用 PostGIS `ST_Intersects`,不调用旧的空占位服务。范围和输出坐标为 EPSG:4326,单轴范围最多一度。查询模型几何,不把空间邻近称为供水连通。
- 区域成员优先使用已配置节点关系,未配置时使用区域几何;配置、几何成员与真实供水拓扑需分别解释。
- 模型配置 `data_path` 选择嵌套字段,`limit/offset` 对选中集合分页。模式和曲线可单独列出 ID。编辑字段必须通过现有模型 schema 的可写属性校验;有限业务枚举不接受 SQL、任意路由、命令、身份、数据库地址或文件路径。
- 需水量分配按关联管长权重,只返回建议值,不修改节点需水量;输入输出沿用模型流量单位。区域或全网结果按节点 ID 稳定分页。
- 爆管模拟输入是已说明依据的破口面积(cm²),不是流量。复用隔离模型,`persist_results=False`;这与异常侦测和候选定位是不同工具。
- 保存正式场景会重新执行真实计算并持久化成果。压力调节和关阀正式成果使用宿主绑定的用户名。计算共用原有两项并发槽位,超时后槽位保留至原生线程退出。
- 新建任务限未绑定 Agent 的 operator/agent 事件调查;不能伪造外部工单或租约。重试只重新入队,实际执行仍由原有 Worker 领取。取消、启停和方案点位更新复用现有版本或旧值校验。
## 写入预览和原生许可
13 项新写入工具只对项目成员的 `ordinary_chat` 开放。任务执行、任务协作、只读 reader、个人会话和学习复盘均不能调用。每次校验当前权限及绑定时的权限上限;写入工具不加入只读自动许可名单,管理员拒绝继续优先。交互聊天遵循现有本轮 request/always 策略,always 不改变业务权限。
1. 为具体操作生成 UUID `request_id`,默认 `dry_run=true`,取得预览与 `expected_digest`。预览不运行模拟/优化,也不写业务数据。
2. 保持相同参数和 `request_id`,使用 `dry_run=false` 及返回的 `expected_digest` 请求执行,并经过宿主原生许可。
3. 服务重新读取目标并校验摘要。模型预览复核与修改共用模型变更锁;SCADA 操作在时序库串行化事务中复核相关数据。数据变化返回冲突,须重新预览。
4. 执行前在现有 `workbench.mcp_query_receipts` 保存 started 记录,完成后保存结果。同一会话、身份、项目和请求键的完成操作返回原结果,已启动但状态不明确的操作不会再次执行。可用 `get_mutation_status` 核实记录,并通过目标查询核实实际数据。超时/进程退出可能已有部分结果,不能解释为回滚,也不能自动换键重试。
计算容量已满且原生函数尚未调用时,返回 `CALCULATION_CAPACITY_EXCEEDED`。
这类明确未执行的请求记为 `rejected`、`retryable=true`;状态查询同时返回具体
错误。容量释放后可用原身份、会话、项目、参数和 `request_id` 重试,仍需通过
原生许可和预览摘要复核。重试时继续锁定请求记录,成功后的重复调用只返回
已保存结果。其他 503、算法异常、超时或取消仍保留 `started`,不能据此自动重试。
SCADA 维护必须明确列出设备,窗口最多 24 小时。清洗/删除最多 10000 条,补录/修正最多 500 条,来源依据必填;不开放 `all`。数据写入清洗值或实测表,不从模拟结果构造实测。涉及 BizDB 和 TimescaleDB 时不承诺跨库事务原子性,执行日志中的 started 与业务修改之间也不承诺分布式 exactly-once。
## 导出与会话下载
`export_project_model` 从数据库导出 ZIP(INP 或 ChangeSet,含顶点和 SCADA 元素),`export_sensor_placement` 导出已有方案 XLSX。单文件最多 5 MiB;模型通过 ZIP 压缩传输。Server 返回长度及 SHA-256;Agent 验证后只写当前会话 `workspace/files/`,返回相对路径和下载链接,模型消息不包含文件正文或宿主绝对路径。导出暂不支持超过该预算的文件分片。
下载接口为 Agent `GET /api/v1/session/{session_id}/files/{filename}`。它复用身份与项目校验,验证会话所有权,仅接受服务生成的文件名格式,拒绝符号链接和跨会话目录。前端通过已认证的 Agent client 下载;链接和磁盘文件均不是公开资源。文件随会话持久化和删除,不引入独立结果引用存储。
无需数据库迁移、新环境变量或新密钥。地名搜索沿用项目配置与已有天地图服务凭据。需同步发布 Server、Agent、前端,再重载 Server 与 Agent 工具目录;不要以代码目录一致代替线上重载验收。
## 验证
运行 focused pytest、Agent MCP/策略/导出测试及类型检查、前端协议与下载测试、浏览器下载覆盖及构建。另用隔离 MCP fixture 验证两端 SDK 目录、上下文绑定和原生工具调用。测试不运行真实项目模拟、优化、SCADA 清洗或任何业务写入;真实项目运算与数据效果仍须按项目验收。
+7 -7
View File
@@ -1,10 +1,10 @@
# 产品 MCP 查询:本地实施与启用
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和35项工具(25项查询、10项受控分析);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享25项查询;10项分析仅向非reader会话开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和71项工具(42项原查询、12项计算、13项预览式写入、4项计算任务管理);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
`run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。
计算工具支持Agent按次指定 `timeout_seconds`,默认120秒;计算上限默认1小时,由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 配置并在工具schema公布。Agent、OpenCode和本地HTTP调用预算随目录同步,普通查询仍保持原有短超时。等待超时不意味着底层线程已停止或在线基线已回滚,禁止自动重试整个写入窗口。
长计算使用持久化队列,原工具快速返回任务编号,新增状态、结果、列表和取消工具。HTTP 返回预算由目录元数据和宿主管理,Server 默认120秒、Agent默认180秒;模型不再提供 `timeout_seconds`,Server保留兼容入口。后台执行时限由管理员独立配置。提交和读取保持短调用,不延长上游5分钟空闲超时。独立计算Worker、迁移与权限边界见[后台计算队列](calculation-queue.md)。
## 认证与数据边界
@@ -14,13 +14,13 @@ Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数
调查数据返回前,在短事务内追加现有任务证据并保存调用回执。同一主体、会话、任务、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个有效点;这不改变模型首次查询默认值或时序接口上限。
普通查询默认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秒超时(单次请求同时受该剩余总预算约束),图表最多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;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的合成点冒充实测设备。
外围 MCP 直接读取所选项目 BizDB 的工单事实和既有时序。外围工单统一返回 `system_data`;测试数据和 DEMO-* 观测仍按各自数据来源处理。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的测试点冒充实测设备。
## 启用步骤
@@ -76,11 +76,11 @@ TJWATER_MCP_CLIENT_SECRET=<由身份平台注入>
安装 Agent 更新的 Bun 依赖。本地 MCP 使用官方 Server SDK,独立监听127.0.0.1临时端口,先于原生工具发现就绪。宿主只把公开端点写入 `SESSION_METADATA_STORAGE_DIR` 的兄弟目录 `mcp-query/connection.json`,目录0700、文件0600;不保存令牌或 client secret。embedded 自动注入连接路径,OpenCode 环境仍剔除全部 `TJWATER_MCP_*`。client 模式须同网络空间、同版本可信插件、同一内部密钥,并共享 `TJWATER_AGENT_MCP_CONNECTION_PATH` 指向的连接文件,Agent 重启后同步重载原生连接。默认路径 `data/mcp-query/connection.json`。不要把 OAuth 配置注入外部 OpenCode。
项目及宿主只读工具统一由 MCP 提供,插件只配置原生连接与权限;额外六项是地理编码、会话搜索、调查任务列表与详情、消息读取、待办读取,合并后共41项。公开网页搜索使用 OpenCode 原生 `websearch`,搜索服务及凭据由 OpenCode 管理;Server 的 Bocha 接口、配置和 MCP 搜索入口已移除。`memory_manager` 和 `skill_manager` 保留完整读写插件,写入、展示工具保持插件形式。旧项目和任务查询工具及其内部 HTTP 查询桥已移除;未启用产品 MCP 时项目数据不可用,不回退。
项目及宿主只读工具统一由 MCP 提供,插件只配置原生连接与权限;额外六项是地理编码、会话搜索、调查任务列表与详情、消息读取、待办读取,合并后共73项。公开网页搜索使用 OpenCode 原生 `websearch`,搜索服务及凭据由 OpenCode 管理;Server 的 Bocha 接口、配置和 MCP 搜索入口已移除。`memory_manager` 和 `skill_manager` 保留完整读写插件,报告/消息/现场业务动作与展示工具保持插件形式;新增模型和数据维护写入通过预览式MCP提供。旧项目和任务查询工具及其内部 HTTP 查询桥已移除;未启用产品 MCP 时项目数据不可用,不回退。
原生调用的 `_meta["ai.opencode/sessionID"]` 仅用于关联宿主会话,本地服务先核验内部密钥和本机来源,再核验所有权及直接父子关系。原生工具发现、权限、元数据传递与待办 RPC 已由 `bun run tests/integration/query-mcp-smoke.ts` 验证,不调用模型。任务上下文保留可选 `report_revision`,缺省使用协作会话绑定修订号。隔离启动器显式设置自己的 `TJWATER_AGENT_MCP_CONNECTION_PATH`,两端共同读取该文件,不继承其他实例的连接文件。
6. 先用测试项目分别验证普通项目会话、worker 调查和直接 reader 子会话,再开启生产。Agent 启动先发现 Server 目录并与宿主35项白名单对齐,再验证 OpenCode 的真实工具 ID;失败不进入 ready。切换开关需重启 Agent。Server 未迁移项目的绑定接口返回503。
6. 先用测试项目分别验证普通项目会话、worker 调查和直接 reader 子会话,再开启生产。Agent 启动先发现 Server 目录并与宿主67项白名单对齐,再验证 OpenCode 的真实工具 ID;失败不进入 ready。切换开关需重启 Agent。Server 未迁移项目的绑定接口返回503。
client_credentials 与 token exchange 均发送标准 `resource=TJWATER_MCP_RESOURCE_URI`;exchange 同时保留配置的 audience。部署前必须验证身份平台接受该参数,且两种 grant 都将规范资源映射到 Server 要求的专用 audience。若所用 Keycloak 拒绝该参数,会报告配置不兼容,需升级或提供兼容适配;代码不会移除 resource 后重试。目录的服务 grant 与用户/worker 的 exchange 均不会退回共享管理员 Bearer或直接转发产品 Bearer给MCP。[Keycloak 官方说明](https://www.keycloak.org/securing-apps/token-exchange)。外部交互客户端的授权码流程和第三方客户端绑定不包含在本次交付中。
@@ -103,6 +103,6 @@ bun test tests/product/mcpAuthDiscovery.test.ts tests/product/mcpQueryClient.tes
MCP_TEST_PYTHON=<隔离Server环境Python> MCP_TEST_SERVER_ROOT=<Server仓库绝对路径> bun tests/integration/mcp-query-smoke.ts
```
HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、35项工具、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。
HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、67项工具、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。
查询限制回归使用 `python -m pytest tests/unit/test_mcp_query_limits.py tests/unit/test_mcp_query.py tests/unit/test_timeseries_history.py tests/unit/test_timeseries_history_schema.py -q`。实际 PostgreSQL SQL 验证可设置 `MCP_QUERY_TEST_DSN` 后运行 `python -m pytest tests/integration/test_mcp_query_limits_postgres.py -q`;该 DSN 必须指向空的、可丢弃的独立数据库,测试创建临时业务同名 schema 并在事务结束时回滚。它验证数据点跨序列翻页、证据路径/Unicode分页、任务摘要与指定报告、章节稳定路径、项目/任务隔离、既有scope边界、字节门槛和合成摘要,不代替真实项目验收。
+43
View File
@@ -0,0 +1,43 @@
# 节点空间覆盖工具
`generate_node_coverage` 增加 Agent 可选择的空间表达能力,将已取得的模型节点集合转为
可解释、可复核的覆盖示意。它可以在项目聊天、任务协作和后台调查中使用,
不要求爆管任务、隔离成功或特定报告。生成、发布展示和业务分析是三个独立操作。
## 适用范围
| 问题 | 输入依据 | 输出含义 |
| --- | --- | --- |
| 候选对象分布在哪里 | 已核实的候选模型节点 | 候选节点空间包络 |
| 两组监测点覆盖有何不同 | 已核对的设备到模型节点映射 | 已接入节点分布比较,不证明监测可观测性 |
| 区域成员分布在哪里 | 已查询的区域成员节点 | 成员节点包络,不替代已有行政区或 DMA 边界 |
| 哪些节点属于模型影响集合 | 已有拓扑或模拟结果中的节点集合 | 结果节点的空间示意,不重新计算影响 |
| 投诉关联的节点如何分布 | 已解析并核对的投诉到模型节点关联 | 已关联节点分布,不证明投诉或污染真实边界 |
只有地址或未关联设备时,先查询位置和资产关联;已有权威区域几何时优先展示原始边界。
本工具不接收任意坐标、HTML、SQL、脚本或调用者指定的项目身份,不执行关阀、模拟或方案保存。
## 参数和结果
- `groups`:最多 8 组,每组提供唯一 `group_id`、`title` 和 `node_ids`;组之间可重叠,
合计最多 10000 个不同节点。不按组名自动赋予隔离、污染或服务覆盖语义。
- `concave_ratio`:0–1,默认 0.25;越接近 1 越接近凸包。分散的点取单一包络可能包含较大空白,
应按实际比较目标分组,并明确示意含义。
- `buffer_m`:大于 0、最多 1000 m,默认 25 m,仅是展示缓冲。
- `split_by_connectivity`:默认 false。true 时按所选节点的模型连接拆分,并排除初始 CLOSED 连接。
`excluded_link_ids` 可额外指定最多 200 个断开连接,适用于已有关阀建议等有依据的绘图情景。
这不会修改模型,也不能替代完整拓扑分析。
`result.map_payload` 是多组共存的 GeoJSON;`result.coverage` 记录算法参数、节点 ID、
已映射数量、缺失几何节点、分组数量和 `display_only` 边界。缺少部分位置时返回 `partial`,
不补造坐标。单点、重合点或共线点通过米制缓冲生成可绘制面。
## 与展示工具配合
生成工具不会移动地图。项目聊天可用 `present_view` 展示本次返回的几何;后台调查用
`publish_task_presentation` 的 `set_overlay`,引用生成结果自己的 `evidence_id` 并省略 `data`,
Server 从不可变证据解析完整几何。大结果内联缩略时,`data.presentation.command` 保留可发布命令。
`recorded` 仍只表示请求已记录,浏览器显示和后台调查完成各自独立。
本次爆管报告只是一个验证样例:可将 24 个直接隔离节点、295 个供水影响节点作为两组,
按已有三阀建议设置连通分组;不将这种调用固定进 `analyze_valve_isolation`。
+15 -9
View File
@@ -28,7 +28,8 @@ conda run -n server python scripts/run_operational_worker.py
| 变量 | 用途 |
| --- | --- |
| `OPERATIONAL_WORKER_PROJECT_ID` | 唯一授权项目 UUID |
| `OPERATIONAL_WORKER_PROJECT_ID` | 单项目模式的授权项目 UUID |
| `OPERATIONAL_WORKER_PROJECT_IDS` | 可选,显式授权项目 UUID 列表,逗号分隔;非空时优先于单项目配置,同步器和调查 worker 均按列表运行 |
| `OPERATIONAL_WORKER_SERVER_URL` | 主后端地址,默认本机 8000 |
| `OPERATIONAL_WORKER_AGENT_URL` | Agent 地址,默认本机 8787 |
| `OPERATIONAL_WORKER_RECONCILE_SECONDS` | SSE 中断或漏通知时的对账间隔,默认 30 秒 |
@@ -42,6 +43,8 @@ conda run -n server python scripts/run_operational_worker.py
工作身份必须能通过现有产品身份与项目成员权限校验。配置令牌获取不等于自动创建产品用户、项目成员或授权。权限错误会保留任务用于后续重试,不会绕过认证。
多项目模式为每个项目维护独立事件游标、会话、消息游标及 OAuth 缓存,单项目暂时失败不阻止其他项目对账。`OPERATIONAL_WORKER_MAX_CONCURRENCY` 是每个项目的上限,总上限随显式配置的项目数增加;需要控制整体模型用量时相应调低。进程不自动枚举所有项目或扩大服务身份权限。修改项目列表后需同时重启 `tjwater-operational-worker.service` 和 `tjwater-peripheral-sync.service`。
调查数据读取沿用账号已有的项目权限,不再因 `service` 身份额外移除 `scada.view` 和
`simulation.view`。上下文的 `approved_data_scopes` 与数据查询采用同一权限集合;已有读取
权限无需逐任务数据审批或委托凭据。此规则不新增角色权限、不放开跨项目读取,也不改变
@@ -69,8 +72,8 @@ OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:8787
OPERATIONAL_WORKER_TOKEN_URL=https://<identity-host>/realms/<realm>/protocol/openid-connect/token
OPERATIONAL_WORKER_CLIENT_ID=<dedicated-service-client>
OPERATIONAL_WORKER_CLIENT_SECRET=<inject-securely>
OPERATIONAL_ACTION_PERIPHERAL_URL=http://127.0.0.1:8011
OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8011
OPERATIONAL_ACTION_PERIPHERAL_URL=http://127.0.0.1:8000/peripheral
OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8000/peripheral
```
选择 OAuth 时不要同时配置 `OPERATIONAL_WORKER_BEARER`,否则固定 Bearer 优先。不要配置设备命令网关进行本次合成联调。
@@ -106,8 +109,9 @@ Server 校验该会话属于当前活动运行。跨用户投递查询与任务
原对话用户可以确认收到的回复,但不能改写目标会话或原生消息 ID。
既有 worker 每轮最多对账四页、每页 50 条私有待投递消息,即使调查并发已满仍继续对账,
不增加进程或配置项。临时网络错误保留消息等待重试;权限拒绝、不存在等确定性
拒绝保存失败结果。消息正文、发送人和会话归属不写入项目事件日志。
不增加进程或配置项。临时网络错误保留消息等待重试;400、403、410、422 等确定性
拒绝保存失败结果。404 可能表示目标会话属于另一个 Agent 实例,因此保持待投递,
不把路由未命中误记为消息终态失败。消息正文、发送人和会话归属不写入项目事件日志。
待投递接口按创建时间与消息 ID 返回 keyset 游标,worker 跨轮保留扫描位置;
扫到末页后下一轮从头重试。长期空闲对话的待收消息不会一直占住首屏、阻止后续调查投递。
@@ -119,6 +123,8 @@ Server 校验该会话属于当前活动运行。跨用户投递查询与任务
`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 秒后关闭。
`resources/sql/030_operational_run_agent_ownership.sql` 为运行增加 `agent_instance_id`。Worker 从目标 Agent 的 `/health` 读取实例标识,只恢复、续期该实例拥有的本地会话,并在投递时要求 Agent 回显同一标识。Server 在领取、心跳和完成时保存或校验归属,避免多个独立 Agent 共用项目队列时,一个实例反复刷新另一个实例并不存在的 session。部署顺序是:先迁移各目标 BizDB,再发布 Server、Agent 与 Worker;每个 Agent 的 `AGENT_INSTANCE_ID` 必须唯一且跨重启稳定。旧运行的归属保持为空,不会由任意 Worker 猜测接管。
事件默认保留 7 天。清理脚本会从 Metadata DB 解析项目路由,并在发现多个项目共享同一 BizDB 时拒绝运行。先确认 `OPERATIONAL_WORKER_PROJECT_ID` 对应 `tjwater_v2`,然后运行定向 dry-run:
```bash
@@ -135,7 +141,7 @@ conda run -n server python scripts/cleanup_workbench_events.py \
禁止省略 `--project-id` 执行清理。数据库维护脚本同样遵循先在 `tjwater_v2` 验证、再逐库扩展的规则。
浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源。
浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源,不允许 `*`。配置说明见 [README](../README.md#环境配置)。
## 外围样板工单同步
@@ -144,11 +150,11 @@ conda run -n server python scripts/sync_peripheral_work_orders.py --once
conda run -n server python scripts/sync_peripheral_work_orders.py
```
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到消息即读取分页工单,将外围版本投影到 `external_work_order` 任务;默认每 300 秒兜底对账。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、`tjwater_v2` 范围与启用步骤见 [外围工单主动同步](peripheral-work-order-events.md)。
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到消息即读取分页工单,将外围版本按系统数据投影到 `external_work_order` 任务;默认每 300 秒兜底对账。工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、`tjwater_v2` 范围与启用步骤见 [外围工单主动同步](peripheral-work-order-events.md)。
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
通用合成检查工单不属于预约任务。早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;同步器按不可编辑的专用 seed actor 跳过旧种子工单。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。`018_remove_synthetic_check_task_projections.sql` 只清理专用 seed actor 创建的旧检查投影,不按用户可编辑的标题或状态删除任务。三张独立演示工单已迁入外围样板,由同一同步器维护。
早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;`018_remove_synthetic_check_task_projections.sql` 清理了当时错误生成的预约投影。当前同步器投影所有有效外围工单,`029_peripheral_work_orders_system_data.sql` 将既有工单及任务快照统一为系统数据。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。
水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。
@@ -162,7 +168,7 @@ conda run -n server python scripts/sync_peripheral_work_orders.py
## 人工确认后的动作
- `OPERATIONAL_ACTION_PERIPHERAL_URL`:使用既有 `/api/sample/v1/work-orders` 创建合成外围工单。成功回执说明工单已创建,不表示现场工作已经完成。
- `OPERATIONAL_ACTION_PERIPHERAL_URL`:使用既有 `/api/sample/v1/work-orders` 创建外围工单。成功回执说明工单已创建,不表示现场工作已经完成。
- `OPERATIONAL_ACTION_COMMAND_GATEWAY_URL`:可选设备命令网关。未配置时明确返回不支持执行,不能生成成功回执。
- `OPERATIONAL_ACTION_TIMEOUT_SECONDS`:外部请求超时,默认 10 秒。
+33 -6
View File
@@ -1,8 +1,35 @@
# 外围样板服务
# 外围系统接入服务
该独立 FastAPI 服务提供历史监测回放、固定合成数据,以及 synthetic 测试工单和测试数据的版本化 CRUD。管理入口位于供水工作台“外围样板”。它不控制设备,也不修改正式业务资产。
该 FastAPI 子应用接入真实业务工单,提供版本化 CRUD、处理反馈和事件调查入口,同时保留独立的历史监测回放与演练数据接口。生产环境挂载在主 Server 的 `/peripheral`,随 `next-backend` 单容器发布;源码仍可独立启动用于本地隔离调试。管理入口位于供水工作台“外围系统”。
浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接样板服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。
外围工单的 `origin` 固定为 `system_data`,前端录入、同步器、任务快照、执行回执和工单查询均使用该来源,不再提供演练来源分支。业务录入代表系统中的上报,不代表投诉内容或故障原因已核实,Agent 仍需查询实际证据。
此次变更不修改表结构;部署时需在每个项目业务库执行 `resources/sql/029_peripheral_work_orders_system_data.sql`,统一修正源工单、修订记录、任务快照和外围反馈证据。API 路径、配置键中的 `sample` 为兼容名称,不代表工单是模拟数据。更新需发布 Server、同步器、Agent 与前端对应版本。
浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接外围服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。权限键沿用现有契约,不用于判断数据来源。
生产环境不需要为外围服务新增域名。前端设置
`TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`,在现有前端 NPMplus Proxy Host 中增加以下
Custom Location:
| 配置项 | 值 |
| --- | --- |
| Location | `/peripheral`,不添加末尾斜杠 |
| Scheme | `http` |
| Forward Hostname / IP | `192.168.1.114`,不追加路径或末尾斜杠 |
| Forward Port | `8001` |
| Forward Path | 留空 |
容器内部仍监听 `8000`,生产发布在宿主机映射为 `8001`,避免与已有服务冲突。该配置保留 `/peripheral` 前缀,因此浏览器请求
`/peripheral/api/sample/v1/context` 时,主 Server 收到相同路径并交给挂载的子应用。不要在
Forward Hostname 后追加 `/api/sample/v1`。本机开发和预览模式也将 `/peripheral` 代理到
`TJWATER_SERVER_API_BASE_URL`,默认是 `http://127.0.0.1:8000`。
外围 API 支持已授权项目切换。每次请求先由主系统验证项目成员身份和权限,再从 Metadata DB 解析该项目的 `biz_data` 路由;历史观测另解析 `iot_data` 路由。逻辑项目代码、业务物理库名和时序物理库名可以不同,连接沿用各自完整 DSN。路由缺失返回 `503 PROJECT_STORAGE_NOT_CONFIGURED`,不会回退到其他项目数据库。未配置存储表的项目仍需单独完成结构部署;API 不自动创建表或复制演示数据。
`PERIPHERAL_SAMPLE_PROJECT_CODE` 和 `PERIPHERAL_SAMPLE_DB_NAME` 仅供显式运行的种子、导出等脚本选择默认库,不再限制 HTTP 请求。工单变更在当前项目已有 `workbench.domain_event_log` 时于同一事务发布事件;未配置事件表时不自动建表,实时同步仍需项目工作台事件结构。
后台同步器和调查 worker 支持 `OPERATIONAL_WORKER_PROJECT_IDS`(项目 UUID,逗号分隔),每个项目维护独立凭据、事件游标和会话状态;未配置时继续使用原 `OPERATIONAL_WORKER_PROJECT_ID`。多项目配置只选择运行范围,不新增项目成员权限,服务身份必须已获各项目授权。修改后需重启两个常驻进程,参见 [工作进程配置](operational-task-workers.md)。
主要接口包括:
@@ -14,8 +41,8 @@
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
## 水质投诉 Agent 演示
## 工单事件调查
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的合成投诉事件,或 `work_order_type=burst_report` 的合成爆管上报。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的水质投诉事件,或 `work_order_type=burst_report` 的爆管上报,均默认使用业务来源。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。普通业务录入无需绑定演练场景码,也不会向 Agent 注入预设监测结果或邻区投诉情况。
样板服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
外围服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
+109
View File
@@ -0,0 +1,109 @@
# 项目地名搜索配置
地名搜索的项目配置保存在 Metadata DB 的 `public.projects.map_config.place_search`。
复用现有 JSONB 字段,不向各项目 BizDB 或 TimescaleDB 新增表。后端天地图 Key
仍通过 `TIANDITU_TOKEN` 提供,不写入项目地图配置或返回浏览器。
```json
{
"place_search": {
"enabled": true,
"provider": "tianditu",
"admin_code": "341126",
"region": "凤阳县"
}
}
```
`admin_code` 接受六位行政区编码或带 `156` 前缀的天地图编码。省级编码以
`0000` 结尾,市级编码以 `00` 结尾,其余按区县校验。`region` 是配置范围的
显示名称;区县级配置须与天地图的区县名称一致。市级搜索可返回不同区县的
候选,候选的 `district` 保留实际区县,响应的 `region` 返回配置范围。
配置缺失、关闭或无效时前端禁用查询;后端拒绝未配置项目的搜索请求。
前端启用条件、区域文案及结果校验均来自项目元数据,不再判断 GeoServer
工作空间。后端按经授权的 `X-Project-Id` 读取配置,搜索请求只接受 `keyword`,
调用方不能覆盖项目区域或分页限制。每次搜索最多返回 10 个候选。
## 管理接口
具备现有管理员权限的调用方可使用 `PATCH /api/v1/admin/projects/{project_id}`:
```json
{
"map_config": {
"place_search": {
"enabled": true,
"provider": "tianditu",
"admin_code": "310000",
"region": "上海市"
}
}
}
```
更新会合并 `map_config` 的顶层配置项,保留已有 `legend`、`coordinate_reference`
等字段。`place_search` 作为完整配置项替换;传 `null` 可清除此项,
`map_config` 自身不能为 `null`。项目元数据和管理接口均返回 `map_config`。
修改配置后刷新工作台以重新读取。
初始配置使用的一次性脚本已清理。后续通过上述管理接口维护项目配置;
初次写入前的逐项目行备份保存在忽略目录
`data/project-map-config/<project-id>-<timestamp>/before.json`。
## 2026-10-21 实际配置
| 项目代码 | 搜索范围 | 行政区编码 |
| --- | --- | --- |
| `tjwater_v2`、`tjwater` | 奉贤区 | `310120` |
| `fengyang_v2`、`fengyang` | 凤阳县 | `341126` |
| `md_v2`、`md` | 吴中区 | `320506` |
| `szh_v2`(苏州河) | 上海市 | `310000` |
| `zjb` | 湛江市 | `440800` |
共配置 8 条项目记录;3 个旧项目仍为 inactive。SZH 的 8 个实际模型节点经天地图
逆地理编码落在松江、青浦、闵行,因此采用上海市范围;木渎采用吴中区,湛江项目
采用市级范围,避免将近似配准的工程坐标当作区县边界依据。
配置前已备份完整元数据库并通过 `pg_restore --list` 校验:
`data/project-map-config/metadata-before-20261021T080257Z/system_hub.dump`。
SHA-256:`77dc13f874f81699a96d4584ac1f67f1b2048fe7c68175be8154c5e277c8689f`。
同时保留逐项目的行备份,核对除 `map_config` 与 `updated_at` 外所有字段保持原值,
已有图例、坐标参考和项目状态均保留。数据库字段注释同步为当前配置用途。
验证涵盖后端服务/接口/元数据配置、前端单元测试、奉贤桌面/窄屏搜索、凤阳项目
切换后搜索定位及未配置禁用。5 个活动项目均通过真实天地图搜索;浏览器测试使用
隔离接口数据,不代表部署环境的真实身份验收。Server OpenAPI 与供水 Agent 的
类型快照同步更新。配置已写入数据库,代码未提交、未推送或部署。
## 地图取点反编码
地址定位面板提供“地图取点”按钮。启用后点击地图查询该点附近的地名和完整地址,
首次查询成功后自动结束取点并保留结果和地图标记;失败时可重新取点。“结束取点”、
Escape、关闭工具或切换项目也会退出取点模式。
取点时暂停管网要素选择和绘图标注选择,并停止当前测量、绘图,退出后恢复常规选择。
后端接口为 `POST /api/v1/reverse-geocoding-requests`:
```json
{"longitude": 117.570309, "latitude": 32.868029}
```
坐标采用 EPSG:4326,复用授权项目的 `map_config.place_search` 和服务端
`TIANDITU_TOKEN`,无需另加数据库配置。返回规范化的 `PlaceSearchItem`,包括
`name`、`address`、`longitude`、`latitude`、`district`、`category` 与 `id`。
坐标始终保留输入的地图取点坐标,附近 POI 的坐标不替代点击位置。新结果显示标记,
不自动移动地图;用户可主动点击“重新定位”。连续取点取消前次请求,过期结果不能
覆盖新点;工具关闭、切换项目和退出取点时也会取消未完成请求。
反编码遵守相同的行政区范围校验:区县范围校验区县,市级范围允许所属不同区县。
范围外返回 422,无可用地址返回 404,服务异常或无效响应返回 502,配置缺失返回
503,超时返回 504。错误信息不包含服务商 Key 或带凭据的请求 URL。
使用天地图逆地理编码服务的 `postStr={"lon":...,"lat":...,"ver":1}` 与
`type=geocode` 参数。5 个活动项目均已用数据库实际配置通过真实接口查询,
并核对输出坐标保持输入值。桌面和 320px 浏览器测试使用隔离接口数据验证交互。
本次最终检查中,现有本地 `127.0.0.1:8000` 实例的 OpenAPI 请求连续两次超时。
真实天地图检查通过独立服务调用完成,浏览器使用隔离 API;既有运行实例的 HTTP
联调尚待该实例恢复响应后核对。本次未重启既有进程。
+18
View File
@@ -4,6 +4,24 @@
## 当前基线
### 2026-10-21 凤阳结构扩展
维护者明确要求将 `tjwater_v2` 的表结构和业务配置复制到 `fengyang_v2`,同时保留凤阳现有管网及数据。本次单独扩展凤阳,不向其他项目批量写入。以下旧迁移状态为当时记录,以本节的实际核对结果为准。
新增 `workbench` 的告警事件、告警状态、任务访问申请、任务进度、Agent 消息共 5 张表,补齐 `peripheral_sample` 的 8 张空表、`source_gis` 的 4 张空表及通用 `gis.water_quality_monitoring_points` 视图。同步主项目的约束、索引与业务表列注释,事件主题约束支持调查进度和展示事件。时序库的应用表结构已经一致,本次未迁移或写入时序数据。
本次迁移已完成,一次性执行脚本已清理。当时先生成差异报告,再做全库备份、核对 `pg_restore --list`、在强制回滚事务中演练,最后单事务应用。固定源、目标物理库为 `tjwater_v2` 和 `fengyang_v2`。生成的迁移 SQL、备份及审计报告保留供追溯;后续结构变更仍按本文门禁单独核对范围。
本次备份与审计记录位于忽略目录 `data/project-rollout/fengyang_v2-20261021T041825Z/`,包含 `before.dump`、`backup.list`、`migration.sql` 和 `report.json`。备份摘要:`f0f68f689239e3fde7fda10b6e7a0cbfdf4f8ee625ab87d3f714162af9391f87`。不提交备份或生成数据。
回滚演练及应用时均逐表核对已有 `network`、`asset`、`gis` 基础表的行数和无序内容摘要。凤阳的 31,143 个节点、31,219 条连接、50 个 SCADA 设备保持原状;未复制奉贤模型、原始 GIS、演示工单、历史结果、项目成员或地图范围。复查差异报告中缺失对象、约束差异和索引差异均为空。
外围 API 现按已验证项目的 Metadata DB 路由选库,不再固定为 `tjwater_v2`。事件生产事务排序锁在当前连接的项目库内生效,不创建数据库对象或业务数据;外围工单仅在该项目已配置事件表时发布事务事件。后台进程支持显式 `OPERATIONAL_WORKER_PROJECT_IDS` 列表,既有部署配置和服务身份成员权限未自动扩展。前端使用选中项目路由;上线后仍需按实际身份完成 Server/Agent 的跨项目联调验收。
已从当前前端配置的 GeoServer 核对两个工作空间的 WFS 数据。凤阳返回 31,137 个 junction、30,016 条 pipe,其余节点和连接类型仍使用凤阳原模型;这些分类数量与模型总节点/连接数量不是同一指标。
若结构迁移失败,事务直接回滚。若提交后的恢复需要使用 `before.dump`,先停止该项目写入并按既有数据库恢复流程处理,不能在仍有新业务写入时直接覆盖恢复。
截至 2026-09-24,`014_operational_tasks.sql` 和 `015_workbench_events_and_task_runs.sql` 已应用到 `tjwater_v2`、`szh_v2`、`md_v2`、`fengyang_v2` 和 `zjb`。这是已有基线,不代表后续迁移可以继续批量应用。
`017_workbench_schema_comments.sql` 只为 `workbench` schema 及其 17 张表和关键列增加数据库可见注释。它先在 `tjwater_v2` 应用;其他项目数据库保持原状,直到 `tjwater_v2` 验证通过并获得明确扩展确认。
+23
View File
@@ -21,6 +21,26 @@ services:
- timescaledb
- postgis
# Enable after provisioning calculation storage and explicit project scopes.
calculation-worker:
profiles: [calculations]
image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local}
init: true
restart: on-failure
command: ["python", "scripts/run_calculation_worker.py"]
env_file:
- ../../.env
environment:
- PYTHONPATH=/app
volumes:
- ../../app:/app/app
- ../../resources:/app/resources
- calculation-state:/app/temp/calculation-jobs
stop_grace_period: 30s
depends_on:
- timescaledb
- postgis
# ==========================================
# Infrastructure Services
# ==========================================
@@ -127,6 +147,9 @@ services:
volumes:
- ./mapservice/geoserver_data:/opt/geoserver_data
volumes:
calculation-state:
networks:
default:
driver: bridge
@@ -0,0 +1,17 @@
[Unit]
Description=TJWater queued calculation 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_calculation_worker.py
Restart=on-failure
RestartSec=5
KillMode=control-group
TimeoutStopSec=30
UMask=0077
[Install]
WantedBy=default.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=TJWater API and MCP query service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/NextTJWater/next-tjwater-server
ExecStart=%h/miniconda3/envs/server/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
UMask=0077
[Install]
WantedBy=default.target
@@ -18,12 +18,17 @@ CREATE TABLE IF NOT EXISTS projects (
description TEXT,
gs_workspace VARCHAR(100) UNIQUE NOT NULL,
map_extent JSONB,
map_config JSONB NOT NULL DEFAULT '{}'::jsonb,
status VARCHAR(20) DEFAULT 'active' NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived'))
);
ALTER TABLE projects ADD COLUMN IF NOT EXISTS map_config JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN projects.map_config IS
'项目级地图配置(JSON);place_search 保存地名搜索启用状态、服务商、行政区编码及区域名称;保留既有图例与坐标参考配置。';
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
DROP TRIGGER IF EXISTS update_projects_updated_at ON projects;
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS workbench.operational_task_runs (
)),
revision bigint NOT NULL CHECK (revision >= 1),
agent_session_id text,
agent_instance_id text,
lease_expires_at timestamptz,
heartbeat_at timestamptz,
started_at timestamptz,
+23
View File
@@ -0,0 +1,23 @@
-- Apply independently to each project's business database.
CREATE SCHEMA IF NOT EXISTS workbench;
CREATE TABLE IF NOT EXISTS workbench.calculation_jobs (
job_id uuid PRIMARY KEY,
project_id uuid NOT NULL,
owner_user_id uuid NOT NULL,
request_key text NOT NULL,
tool text NOT NULL,
parameters jsonb NOT NULL,
binding jsonb NOT NULL,
timeout_seconds integer NOT NULL CHECK (timeout_seconds BETWEEN 1 AND 86400),
status text NOT NULL DEFAULT 'queued' CHECK (status IN
('queued','running','cancel_requested','succeeded','failed','cancelled','timed_out','interrupted')),
result jsonb,
error jsonb,
lease_generation bigint NOT NULL DEFAULT 0,
lease_owner text,
lease_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
UNIQUE(project_id,owner_user_id,request_key)
);
CREATE INDEX IF NOT EXISTS calculation_jobs_queue ON workbench.calculation_jobs(project_id,status,created_at);
@@ -0,0 +1,21 @@
-- Apply after 026, independently to every project business database.
-- Preserve job identity, deduplication and audit metadata, including legacy results.
ALTER TABLE workbench.calculation_jobs
ADD COLUMN IF NOT EXISTS started_at timestamptz,
ADD COLUMN IF NOT EXISTS finished_at timestamptz,
ADD COLUMN IF NOT EXISTS queue_expires_at timestamptz,
ADD COLUMN IF NOT EXISTS result_expires_at timestamptz;
UPDATE workbench.calculation_jobs SET queue_expires_at=created_at+interval '1 hour'
WHERE queue_expires_at IS NULL;
UPDATE workbench.calculation_jobs SET finished_at=updated_at
WHERE finished_at IS NULL AND status IN ('succeeded','failed','cancelled','timed_out','interrupted');
-- Give pre-deployment results a complete retention window, even on reruns.
UPDATE workbench.calculation_jobs SET result_expires_at=clock_timestamp()+interval '7 days'
WHERE result IS NOT NULL AND result_expires_at IS NULL;
ALTER TABLE workbench.calculation_jobs
ALTER COLUMN queue_expires_at SET DEFAULT (clock_timestamp()+interval '1 hour'),
ALTER COLUMN queue_expires_at SET NOT NULL;
CREATE INDEX IF NOT EXISTS calculation_jobs_queue_expiry
ON workbench.calculation_jobs(project_id,queue_expires_at) WHERE status='queued';
CREATE INDEX IF NOT EXISTS calculation_jobs_result_expiry
ON workbench.calculation_jobs(project_id,result_expires_at) WHERE result IS NOT NULL;
+15
View File
@@ -0,0 +1,15 @@
-- Apply to every project business database before enabling task questions.
CREATE TABLE IF NOT EXISTS workbench.operational_task_questions (
project_id uuid NOT NULL,
task_id uuid NOT NULL,
question_request_id uuid NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (project_id, question_request_id),
FOREIGN KEY (project_id, task_id)
REFERENCES workbench.operational_tasks(project_id, task_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS operational_task_questions_task
ON workbench.operational_task_questions(project_id, task_id);
CREATE UNIQUE INDEX IF NOT EXISTS operational_task_questions_pending
ON workbench.operational_task_questions(project_id, task_id)
WHERE document->>'status' = 'pending';
@@ -0,0 +1,141 @@
-- Peripheral work orders are business-system records. Remove the former
-- synthetic provenance branch from source rows and their task projections.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE FUNCTION pg_temp.normalize_peripheral_work_order_text(value jsonb)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
normalized text := value::text;
BEGIN
normalized := replace(normalized, '对应外围记录明确标为 synthetic(系统测试)', '对应外围记录为 system_data 系统数据');
normalized := replace(normalized, '有一条演练上报', '有一条系统上报');
normalized := replace(normalized, '证据覆盖为 synthetic 上报工单快照', '证据覆盖为 system_data 上报工单快照');
normalized := replace(normalized, '记录快照带 synthetic 标识', '记录快照为 system_data');
normalized := replace(normalized, '外围工单结果注明为synthetic演练数据', '外围工单为system_data系统数据');
normalized := replace(normalized, '工单为synthetic演练', '工单为system_data系统数据');
normalized := replace(normalized, 'synthetic 爆管上报记录', '系统爆管上报记录');
normalized := replace(normalized, '合成上报记录', '系统上报记录');
normalized := replace(normalized, '外围模拟工单', '外围系统工单');
normalized := replace(normalized, '外围记录为synthetic', '外围记录为system_data');
normalized := replace(normalized, '证据快照整体为synthetic演练来源', '工单快照为system_data系统来源');
normalized := replace(normalized, '来源标记为synthetic,仅能作为演练资料', '来源为system_data系统数据');
normalized := replace(normalized, 'synthetic演练记录', '系统记录');
normalized := replace(normalized, 'synthetic演练来源', 'system_data系统来源');
normalized := replace(normalized, 'synthetic 演练来源', 'system_data 系统来源');
normalized := replace(normalized, '外围样板合成摘要', '外围工单摘要');
normalized := replace(normalized, '合成外围演练', '外围系统记录');
normalized := replace(normalized, '合成外围', '外围');
normalized := replace(normalized, '合成联调', '系统联调');
normalized := replace(normalized, '合成演练', '系统测试');
normalized := replace(normalized, '合成事件', '系统测试事件');
RETURN normalized::jsonb;
END
$$;
UPDATE peripheral_sample.work_orders
SET document = pg_temp.normalize_peripheral_work_order_text(
jsonb_set(document, '{origin}', '"system_data"'::jsonb, true)
)
WHERE document->>'origin' IS DISTINCT FROM 'system_data'
OR document::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
UPDATE peripheral_sample.work_order_revisions
SET document = pg_temp.normalize_peripheral_work_order_text(
jsonb_set(document, '{origin}', '"system_data"'::jsonb, true)
)
WHERE document->>'origin' IS DISTINCT FROM 'system_data'
OR document::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
UPDATE peripheral_sample.requests
SET result = pg_temp.normalize_peripheral_work_order_text(
jsonb_set(result, '{origin}', '"system_data"'::jsonb, true)
)
WHERE command_key IN ('create_work_order', 'update_work_order')
AND jsonb_typeof(result) = 'object'
AND result ? 'work_order_id'
AND (
result->>'origin' IS DISTINCT FROM 'system_data'
OR result::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)'
);
UPDATE workbench.operational_task_external_feedback
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
document,
'{work_order_snapshot,origin}',
'"system_data"'::jsonb,
false
),
'{evidence,kind}',
'"external_feedback"'::jsonb,
false
),
'{evidence,source_label}',
'"外围工单"'::jsonb,
false
),
'{detail}',
to_jsonb(replace(COALESCE(document->>'detail', ''), '合成外围工单', '外围工单')),
false
))
WHERE document ? 'work_order_snapshot';
UPDATE workbench.operational_task_evidence
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
jsonb_set(
document,
'{kind}',
'"external_feedback"'::jsonb,
false
),
'{source_label}',
'"外围工单"'::jsonb,
false
))
WHERE document->>'source_ref' LIKE 'peripheral_sample:%'
OR evidence_id LIKE 'provenance-correction:peripheral_sample:%';
UPDATE workbench.operational_tasks AS task
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
task.document,
'{summary}',
to_jsonb(
replace(
replace(COALESCE(task.document->>'summary', ''), '合成外围工单', '外围工单'),
'合成外围事件',
'外围事件'
)
),
false
))
FROM peripheral_sample.work_orders AS source_order
WHERE task.project_id = source_order.project_id
AND (
task.document#>>'{source,external_id}' = source_order.work_order_id::text
OR task.document#>>'{source,external_id}' =
'peripheral_sample:' || source_order.work_order_id::text
);
UPDATE workbench.operational_task_reports AS report
SET document = pg_temp.normalize_peripheral_work_order_text(report.document)
FROM workbench.operational_tasks AS task
JOIN peripheral_sample.work_orders AS source_order
ON task.project_id = source_order.project_id
AND (
task.document#>>'{source,external_id}' = source_order.work_order_id::text
OR task.document#>>'{source,external_id}' =
'peripheral_sample:' || source_order.work_order_id::text
)
WHERE report.project_id = task.project_id
AND report.task_id = task.task_id
AND report.document::text ~*
'(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
COMMIT;
@@ -0,0 +1,13 @@
-- Bind durable task runs to the Agent instance that owns their local session.
BEGIN;
ALTER TABLE workbench.operational_task_runs
ADD COLUMN IF NOT EXISTS agent_instance_id text;
CREATE INDEX IF NOT EXISTS operational_task_runs_agent_owner_idx
ON workbench.operational_task_runs(project_id,agent_instance_id,status,created_at,run_id)
WHERE agent_instance_id IS NOT NULL
AND status IN ('claimed','running');
COMMIT;
@@ -1,16 +1,36 @@
---
name: appointment-follow-up
description: 调查关联预约任务的执行状态与反馈,核对审批、派发和现场结果,避免把预约任务当成新的爆管或投诉事件。
description: 核查关联预约工单的审批、提交、现场执行和反馈,分别判断预约履约与原事件闭环。
metadata:
version: "1.0.0"
version: "2.0.4"
---
# 预约任务跟进
预约工单承载已安排的人工工作。先读取原任务、已审批动作、预约时间、派发状态和现场反馈;需要时追溯关联的投诉或爆管事件。预约时间、工单派发时间、现场完成时间和事件发生时间分别说明。
目标是说明预约安排了什么、流程到哪一步、现场实际做了什么、证据是否满足要求,以及还有哪些责任事项。预约工单承载已安排的人工工作,不自动代表新的爆管或水质事件,也不因存在待办而反复创建跟进工单。
核对预约工作的内容、执行条件和实际回执。只有现场反馈与证据支持时,才能认定采样、通知、抢修、冲洗或复测完成;工单创建或派发成功仅代表流程状态变化。对尚未完成的工作,报告责任事项、已有进展、缺少的反馈和下一步依赖。
## 接续与核查
预约工单不自动触发隔离停水或污染水排放,也不生成同类型的投诉、爆管事件。确有新增处置需要且已有证据支持时,保存阶段报告,再提议具体的关联预约工单(work_order_type=general),供人工审批;先核查已有有效草稿,避免在跟进过程中递归建单。
读取当前任务、关联工单、已有报告、证据、动作和最新反馈,核对预约时间、地点、负责人、工作内容及回执要求;需要时追溯关联的投诉或爆管。新一轮优先检查新增事实和旧未决项,保留本流程名称和版本。
如发现新的爆管或水质风险,明确记录有依据的发现及待核验项,不擅自更改当前任务类型。审批、现场操作、业务重新排队和恢复供水均遵守现有 Server 流程。正式报告保留所用流程名称和版本,便于追溯。
围绕原工作目标完成必要核查:
1. 将要求与来源工单最新版本逐项对照,区分预约、派发、到场、完成和原事件发生时间,不用创建或更新时间替代现场时间,也不因预约已过推定已经执行或失约。
2. 核对动作的审批、提交、执行状态和回执。`approved` 只表示允许提交,`submitted` 只表示已接收,`succeeded` 的建单动作只证明工单创建成功;这些状态均不证明采样、通知、抢修、冲洗或复测完成。`unknown` 由 Server 复核原执行请求,不重复下发。
3. 用 `query_peripheral_data` 查询外围工单、点位和现场观察,按来源 ID、关联动作及版本核对;目录首屏不能证明没有反馈。区分缺失、重复、旧版本和相互冲突的记录。
4. 按原目标查询位置、资产和工作前后的监测。地点和资产明确后展示正在核查的工作位置;取得监测对照后展示核查点位,并画出实际取得的工作前后趋势或对比图,核对现场发生时间与数据覆盖,不能把计划时间当实际执行分界;已有影响结果时展示其真实几何或节点覆盖示意。核实设备与模型要素、时间覆盖、单位和数据质量;缺测不是零值,局部正常不证明整项工作完成。
5. 将现场描述、来源系统附件或照片说明、监测变化和原工作要求交叉核对。没有图像查看能力时不声称已检查照片内容。每项要求标为已核验、待核验或不适用并给出依据;状态与证据冲突时明确列出。
## 结果与后续
报告分别说明原预约要求、审批及派发事实、已核实的现场工作和效果、冲突或缺口、当前责任事项,以及它与原事件的关系。等待回执不等于调查完成,更不等于现场工作完成。
位置、资产、监测和已有影响结果随核查步骤同步展示,遵循后台地图与图表规则,不用最终报告代替过程展示;单个数值或无可比数据时用文字、指标或表格,不为凑图新增业务分析。无数据或展示失败时说明具体限制,调查不等待浏览器。
只有证据支持新的具体工作时才提议动作。先核对已有有效动作和关联工单,优先沿原工单的反馈与人工修改流程推进;不要因缺少一项反馈就新建催办或复查工单。新增人工工作使用关联的 `work_order_type=general` 草稿,写明地点、内容、依赖、执行前核验和回执要求。
本流程不自动发起隔离停水、污染水排放或设备命令。发现新的爆管或水质风险时,列出证据、紧迫事项和建议,由原事件的合法调查继续专业分析;不要改写当前任务类型或递归创建同类事件。
预约履约完成须有与原要求对应的现场记录、结果与时间、必要回执、适用的监测或复测证据,以及未决事项核对。由有权限人员在来源系统确认工单完成,核对其新版本后才能认定履约完成。
预约完成不等于关联爆管或水质投诉闭环。报告须分别说明预约履约和原事件处置;只具备履约证据时,不宣称原事件风险已消除或已经结案。
@@ -1,26 +1,41 @@
---
name: burst-report
description: 调查供水爆管上报,形成隔离停水、用户通知、抢修和恢复供水的处置方案及待审批动作。
description: 调查供水爆管上报,形成有依据的候选位置、隔离阀门、影响范围和后续处置建议。
metadata:
version: "1.0.0"
version: "2.0.6"
---
# 爆管上报处置
依据上报位置、时间、现场描述、相关管段台账和同窗压力流量证据,区分上报事实、爆管候选、已确认故障与演练假设。完成初步风险判断不要求抢修、复测和回访已经发生;这些属于后续执行反馈。演练数据可支持明确标注的演练判断,不能证明生产故障。
目标是形成有依据的候选位置、隔离方案、影响判断和可审批处置,并在后续轮次根据真实反馈复核事件闭环。区分候选方案、现场确诊和事件闭环;后两者不能提前阻断当前可完成的分析。演练数据可以用于比较,但必须标注 `synthetic`,不能证明生产故障。
## 调查与处置路线
## 接续与证据
1. 核对上报对象与时间窗,查询相关管段、周边监测和既有处置反馈。先确定实际覆盖范围;目录首屏或查询错误不能证明全网没有异常。具备输入时使用爆管侦测或定位分析,候选结果仍需现场核验。
2. 形成隔离停水方案。已确认目标管段时,按需分析隔离阀门、关阀影响、失供范围和供水保障。资产映射、阀门状态或影响范围不清时,明确待现场核验条件;不得编造阀门编号或直接生成无目标的设备命令。
3. 安排用户通知和供水保障。草稿写明已知影响区域、拟停水时段或“待确认”、通知内容、通知渠道及责任岗位;关阀前通知与紧急处置后的补通知取决于现场紧迫性和获批方案。未知用户数量不填虚构值。通知草稿或内部调查消息均不能说成用户已收到通知。
4. 安排现场定位、止水与抢修。说明施工位置、隔离前置条件、抢修目标和需要回传的现场事实。存在管内污染侵入风险时,再安排适用的清洗、消毒或冲洗评估,不能把每次爆管都判断为污染事件。
5. 恢复供水前核验修复、压力、漏水复查及适用的水质复测结果,按获批方案恢复并通知用户;缺少现场反馈时保留“待执行/待核验”,不能宣称恢复完成。
读取当前任务、事件时刻、地点、来源、已有报告、证据、动作和反馈。新一轮优先检查新增事实和上轮未决项,按时间、来源、对象及输入比较新旧结果,不按 ID 推断先后,也不因原工单未更新就断言没有新证据。保留本流程名称和版本。
## 报告与动作草稿
## 定位与影响分析
报告说明当前判断、证据覆盖、隔离影响、通知安排、抢修及恢复条件。能够形成有边界的初步判断时,不因后续维修或复测尚未完成而把整个调查判为关键数据不足。确实缺少支撑当前判断的关键证据时,遵守 insufficient_data 契约,不强造结论或动作。
根据现有线索自主选择必要步骤:
在已有证据足以支持具体处置建议时,先保存阶段报告,再通过现有动作提议入口保存待人工审批的具体草稿。现场核验、停水通知、抢修等人工执行事项使用关联预约工单(work_order_type=general);原爆管上报事件保持独立。草稿含位置、工作内容、先后依赖、执行前核验条件和回执要求,避免为每个图节点机械建单。没有实际客户通知工具时,将通知列为待执行的人工工单步骤。
1. 核对已有管段或节点。只有地点描述时,先解析位置,再查询附近资产、属性和连接。地点明确后展示调查位置,候选资产取得后展示正在核查的管段或节点。空间邻近只支持候选,不能单独证明故障。
2. 核对 SCADA 设备与模型节点映射,用 `query_scada_readings` 或 `query_element_history` 查询事件前后及正常对照。先用少量对象和合适时间窗,确认返回时间实际覆盖事件;必要时缩窗或翻页。同步展示核查点位,并将取得的事件、对照压力或流量序列画成过程曲线,分清指标和单位;保留缺测、真实零值和覆盖限制。
3. 有异常观测时评估爆管定位输入。`run_burst_location` 必须有 `burst_leakage`(m³/s);先查询已有估计及方法依据,或结合边界流量、用水和储量变化判断能否估计。不能把单表增量、压力下降或任意数值直接当漏量。
4. 有依据的漏量估计或明确的情景假设可用于条件定位,须说明来源、方法和不确定性。`pressure_scada_ids` 使用模型节点 ID;监测模式读取 SCADA,模拟模式须引用已有分析运行。取得多个可比候选的评价值后,用柱状图展示实际排序及评价含义。压力点和历史数据满足工具要求时可选 `run_burst_detection`,但侦测不代替定位,评价值也不表述为概率。缺少定位参数时说明已查来源和影响,继续其他可独立完成的分析。
5. 对已确认或有依据的候选资产使用 `analyze_valve_isolation` 评估阀门隔离,无需等待现场确诊。逐候选比较;多个事故 ID 表示联合场景。保留可隔离性、必关阀、可选阀及受影响节点。结果取得后展示直接隔离与供水影响节点的覆盖示意,形成或调整关阀方案时展示对应阀门;区分两种影响及必关、可选阀。未知阀门状态不能写成可用,不可隔离或空结果也不能解释为零影响;已知阀门失效时评估替代边界。
6. 输入具备时用 `analyze_valve_closure` 分析关阀水力影响,核对阀门组合、起算时间、时长和输出对象。结果返回后,用曲线展示请求节点的压力、请求管段的流量;有同口径方案或基准时再画对比图,不自造前后变化。不同单位分图,标明局部模型结果。区分拓扑范围、水力影响和有依据的用户影响,不虚构户数,不用局部结果代表全网。
有真实且可控制的设备目标、明确命令及充分依据时,才提议设备命令。审批和执行由 Server 控制,调查不直接关闭阀门或发送用户通知。复用已存在的有效草稿,结合实际回执继续调查,不重复提议同一动作。正式报告保留所用流程名称和版本,便于追溯。
查询错误、权限拒绝或接口故障不是缺数证据。必要分析无法执行时,说明失败步骤和已经完成的部分,不借有限摘要宣布调查完成。
## 处置与报告
报告分别给出位置候选及依据、隔离阀门及可用性、替代方案、拓扑与水力影响、用户影响边界,以及处置和闭环缺口。只有初步结果时明确范围,不宣称已经定位、确诊或闭环。位置、候选、监测、影响和阀门方案按实际调查步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。覆盖面保留分组、缺失几何和示意边界,不能当作实际停水、水力影响或用户服务边界。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。
形成具体建议后,先保存阶段结论,再提议必要动作。人工核验、通知、定位隔离、抢修、适用的清洗复测和恢复回访使用关联的 `work_order_type=general` 草稿;原爆管事件保持独立。草稿写明位置、任务、依赖、执行前核验和回执要求。没有通知适配器时明确由人工实施。设备命令仅用于真实可控目标,审批和执行由 Server 控制。
抢修或复测尚未发生不阻止有边界的初步方案。现场阀门状态应写为执行前核验条件;工单创建、审批或派发均不证明现场完成。
## 闭环复核
后续轮次核对实际故障资产和原因、隔离实施、抢修记录、适用的清洗与水质复测、恢复供水及恢复后监测、用户通知回访、未决动作和遗留风险。每项标明已核验、待核验或不适用及依据。
证据齐全时报告具备结案条件,由来源系统的有权限人员确认关闭。原工单未结案时写明待确认,读取其新版本后才能认定闭环;误报结案也须有足够的排除证据和人工确认。
@@ -1,26 +1,41 @@
---
name: water-quality-complaint
description: 调查供水水质投诉,按证据和风险提出采样、隔离、用户通知、污染水处置及复测恢复方案。
description: 调查供水水质投诉,核查投诉与监测覆盖,评估候选原因、影响和采样处置建议。
metadata:
version: "1.0.0"
version: "2.0.4"
---
# 水质投诉处置
区分用户感官投诉、监测异常、已确认的水质问题与污染源假设。单个投诉不能直接证明供水管网污染;上游监测正常也不能排除末梢或二次供水问题。不预设异常必然来自二次供水,不把模拟扩散结果当成污染源反演。
目标是说明投诉和异常的实际覆盖、可能原因及依据、候选影响范围、采样或处置建议和后续复核条件。区分感官投诉、实测异常、污染源假设和已确认问题;不预设异常来自上游或二次供水。演练结果须标注 `synthetic`,不能证明生产风险。
## 调查与处置路线
## 接续与证据
1. 核对投诉时间、位置、异常描述与同类投诉,查询供水路径、相关水质和压力监测、适用的历史记录及现场采样反馈。分别说明实际覆盖的上游、末梢和二次供水范围,不混用模型基线与实测。
2. 评估是否需要隔离停水及饮用水风险告知。已确认或有充分证据支持局部供水风险时,提出与风险相称的隔离范围和保障措施,核对阀门与影响区域。尚不能确定风险时,先提出有依据的现场核验或采样安排,不因一条投诉直接建议全网停水,也不承诺水可安全饮用。
3. 将用户通知纳入处置方案:明确已知影响范围、需人工确认的用水提示、通知渠道、责任岗位和更新条件。没有实际发送工具时形成待审批的人工通知步骤,不把调查消息当成客户通知。
4. 对已支持的污染水风险,安排排查来源、控制污染进入并评估受影响管段的冲洗或排放。仅在来源参数、目标管段或冲洗节点等必需输入已知时使用扩散或冲洗分析;参数不足不虚构源浓度、排放量或节点。具体排水位置、承接条件及现场操作要求需核验后审批执行,不默认污染水可任意排放。
5. 清洗或冲洗后取得适用采样和复测反馈,再评估恢复供水与用户回访。现场处置、复测和恢复是下游执行步骤,其尚未发生不能单独作为拒绝初步风险分析的理由。
读取当前任务、投诉时刻、位置、现象、来源版本、已有报告、证据、采样、动作和处置反馈。新一轮先核对新增事实和上轮未决项,保留本流程名称和版本。
## 报告与动作草稿
## 风险与工程分析
报告列明当前风险判断、证据覆盖、隔离与通知条件、污染水处置路线和复测恢复条件。演练输入只支持标注为演练的判断。真正缺少支撑当前判断的关键证据时,遵守 insufficient_data 契约,不用通用处置模板强造已确认污染结论。
根据当前证据选择必要步骤:
已能形成有边界的处置建议时,保存阶段报告后,通过现有动作提议入口生成待人工审批的关联预约工单(work_order_type=general)。保留原投诉事件,草稿写明实际位置、采样或处置内容、责任要求、执行依赖、现场待核验条件和回执要求;可将通知、采样、冲洗和复测组织成合理的人工执行事项,不机械逐节点建单。
1. 核实投诉位置及相近时段的同类投诉,用 `query_peripheral_data` 按实际时间、位置和来源标识核对外围工单或观察。目录首屏不能证明没有其他投诉;未知点位先查目录,不猜编号或把演示点当真实资产。
2. 解析未知地址,查询附近资产、属性和连接,区分上游、管网、末梢、二次供水和用户端的实际覆盖。地点明确后展示投诉位置,取得关联资产后展示正在核查的管段或节点。拓扑连接不等于实际流向,方向判断需要同窗模型或监测依据。
3. 核对 SCADA 指标、单位和模型映射,用 `query_scada_readings` 或 `query_element_history` 查询投诉前后及对照,检查时间覆盖、在线状态、漂移、长期缺测和质量标识。同步展示核查点位,将实际取得的水质、压力等指标画成趋势或对比图,不同单位分图。缺测不是零值,上游正常不能排除末梢问题,压力正常不能证明水质合格。
4. 综合投诉分布、监测、供水关联和实际采样,列出候选原因、支持证据、反证和区分原因所需的核验。未证实污染源时保留假设,不把感官描述换算成浓度。水质限值只能引用已取得且适用的依据,不能自行承诺安全饮用。
5. 有合理目标管段或节点时,可用 `analyze_valve_isolation` 和 `analyze_valve_closure` 评估候选隔离及关阀影响,无需等待污染确诊,但不能据此直接执行停水。逐候选区分可隔离性、必关阀、可选阀、受影响节点和未知阀门状态。取得影响节点后展示覆盖示意,形成或调整关阀方案时展示对应阀门。拓扑隔离区、水力影响、污染范围和用户影响分别说明;节点覆盖面不能当作污染传播边界,无用户资料时不虚构户数。
6. 只有假设源节点、浓度及必要模型输入有依据时,才用 `run_contaminant_simulation` 正向比较传播;它不是源头反演,未模拟范围不能宣布安全。具备实际排放节点、流量和前置条件时才用 `analyze_flushing` 评估冲洗。取得模拟结果后,按实际返回的对象、时刻和指标展示浓度、压力或流量曲线;同口径的隔离、冲洗方案可画对比图,标明情景假设和局部覆盖。冲洗模拟不证明消毒合格,排放位置和承接条件仍需现场核验与审批。
设备命令只针对真实且可控制的目标,审批与执行由 Server 控制。草稿保存或工单派发不等于采样、通知、冲洗、复测完成。核对已有草稿和现场反馈后再继续,避免重复提议。正式报告保留所用流程名称和版本,便于追溯。
缺少源参数不阻止已有证据支持的采样、候选隔离或人工告知建议。查询错误、权限拒绝或接口故障属于执行异常,不能写成无数据或未见异常。
## 处置与报告
报告分别说明投诉与监测覆盖、候选原因和未排除路径、隔离及影响、通知保障、采样或污染水处置,以及复测与恢复条件。每项给出实际结果或经查询确认的限制;区分当前风险判断与尚未发生的未来处置。投诉位置、监测、候选隔离和影响按实际步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。
形成具体建议后,先保存阶段结论,再提议关联的 `work_order_type=general` 草稿。写明实际位置、采样或处置目标、时间要求、依赖、现场待核验条件和回执要求。通知、采样、清洗冲洗与复测按依赖合理组织,不机械逐节点建单,也不重复已有有效动作。没有客户通知适配器时明确由人工实施;调查消息不能当作用户通知。设备命令仅针对真实可控目标,审批与执行由 Server 控制。
采样、处置和回访尚未发生不阻止有边界的初步建议。工单创建、审批或派发不证明采样、通知、冲洗或复测完成。
## 闭环复核
后续轮次核对实际采样位置、时间、结果和判定依据,以及污染进入控制、隔离与通知、清洗冲洗、复测、恢复供水、用户回访、未决动作和遗留风险。不适用项说明依据,缺少反馈保留待核验。
证据齐全时报告具备结案条件,由来源系统的有权限人员确认关闭。核对来源新版本后才能认定闭环;误报或未发现异常也须有足够核查和人工确认,不能用有限正常数据、模拟结果或流程状态证明水质安全。
+15 -7
View File
@@ -1,4 +1,4 @@
"""Independent sample API for synthetic work orders and monitoring data."""
"""Project-scoped peripheral work orders, monitoring history and explicit test data."""
from typing import get_args
from uuid import UUID
@@ -7,9 +7,13 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.auth.project_dependencies import get_metadata_repository
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from .auth import Identity, identity
from .config import SampleSettings
from .errors import SampleFailure
from .routing import resolve_store_routing
from .store import TRANSITIONS
from .schemas import (
AlertPage, Context, CreateTestDataRecord, CreateWorkOrder,
@@ -20,14 +24,18 @@ from .schemas import (
def store(request: Request):
async def store(request: Request, who: Identity = Depends(identity),
repository: MetadataRepository = Depends(get_metadata_repository)):
from .store import PostgresStore
return PostgresStore(request.app.state.settings)
routing = await resolve_store_routing(
who, repository, timeseries=request.url.path.endswith("/monitoring/observations"),
)
return PostgresStore(request.app.state.settings, routing=routing)
def create_app(settings: SampleSettings | None = None) -> FastAPI:
app = FastAPI(title="TJWater 外围系统模拟接入", version="1.0.0",
description="测试监测与核查反馈样板,不是正式工单平台或设备控制服务。")
app = FastAPI(title="TJWater 外围系统接入", version="1.0.0",
description="按项目管理业务工单与反馈、读取监测历史;测试数据保留独立来源标记。")
app.state.settings = settings or SampleSettings()
app.add_middleware(CORSMiddleware, allow_origins=app.state.settings.cors_origins,
allow_credentials=False, allow_methods=["GET", "POST", "PATCH", "DELETE"],
@@ -43,7 +51,7 @@ def create_app(settings: SampleSettings | None = None) -> FastAPI:
@app.get("/health")
def health():
return {"service": "peripheral-sample", "origin": "synthetic", "storage": "postgresql",
return {"service": "peripheral-sample", "origin": "system_data", "storage": "postgresql",
"status": "up", "storage_verified": False}
prefix = "/api/sample/v1"
@@ -51,7 +59,7 @@ def create_app(settings: SampleSettings | None = None) -> FastAPI:
@app.get(prefix + "/context", response_model=Context, responses=errors)
def context(request: Request, who: Identity = Depends(identity), db=Depends(store)):
return {"protocol_version": "tjwater-peripheral-sample@1", "origin": "synthetic",
return {"protocol_version": "tjwater-peripheral-sample@1", "origin": "system_data",
"project_id": who.project_id, "user_id": who.user_id, "can_write": who.can_write,
"scenarios": list(get_args(Scenario)) if request.app.state.settings.enable_faults else ["normal"],
"state_transitions": TRANSITIONS, "managed_resources": db.managed_resource_capabilities()}

Some files were not shown because too many files have changed in this diff Show More