23 Commits
Author SHA1 Message Date
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
jiang d320972d57 fix(docker): pin GeoServer platform manifest 2026-10-22 13:03:26 +08:00
jiang b4f3204489 chore(docker): advance PostGIS 14 patch line 2026-10-22 12:30:30 +08:00
jiang 1e71f8953b test(server): align service access delegation expectation 2026-10-22 12:18:12 +08:00
jiang f3818b3fed fix(docker): include runtime samples in image 2026-10-22 12:14:40 +08:00
jiang 024abf07a8 chore(docker): pin secure runtime and resolve MCP dependencies 2026-10-22 12:08:36 +08:00
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00
jiang 2b6fc5aa52 feat(tasks): define investigation report contract and history 2026-10-10 16:02:46 +08:00
jiang 4a63fcd7ff fix(workbench): serialize events and target work order sync 2026-10-08 15:41:49 +08:00
jiang b70283c2ff feat(work-orders): synchronize peripheral changes through events 2026-10-08 11:50:15 +08:00
jiang afcbd52a5a refactor(tasks): centralize access request lookup 2026-09-24 17:11:21 +08:00
jiang 540d73add8 docs(workbench): clarify active notification counting 2026-09-24 17:00:41 +08:00
jiang ad813a7c16 feat(workbench): add dynamic task approval lifecycle 2026-09-24 16:36:12 +08:00
jiang db092b0f5e feat(workbench): keep only actionable notifications 2026-09-24 15:18:18 +08:00
jiang e374b5b3cb fix(workbench): remove synthetic check projections 2026-09-24 12:01:01 +08:00
jiang 1a6ff64bbe feat(db): add workbench schema comments 2026-09-24 10:33:12 +08:00
jiang 91afe76c6d docs(workbench): gate database rollout by project 2026-09-24 10:13:52 +08:00
jiang ed06bc2bfc fix(workbench): configure psycopg snapshot transaction
Open the psycopg transaction without unsupported SQLAlchemy-style options, then set the PostgreSQL isolation and read-only characteristics before the first query. Cover the exact snapshot endpoint path with a regression test.
2026-09-24 09:49:01 +08:00
jiang 9335bdc1ea feat(workbench): persist operational workflows and events 2026-09-24 04:19:02 +08:00
jiang baff7dfe9e chore(api): refresh workbench contracts 2026-09-22 10:44:43 +08:00
jiang 7ab9b36a5e feat(workbench): add runtime GIS and sample integrations 2026-09-22 10:44:31 +08:00
255 changed files with 50707 additions and 582 deletions
+95 -50
View File
@@ -1,90 +1,135 @@
# TJWater Server 环境变量配置模板
# 复制此文件为 .env 并填写实际值
# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。
ENVIRONMENT="production"
NETWORK_NAME="tjwater"
# ============================================
# 敏感配置加密 (必填)
# ============================================
# TJWater Server 环境变量模板
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
# 生产配置由部署主机的受控 backend.env 注入,不要把完整配置保存为仓库密钥。
# Fernet 格式,生产环境必须替换为独立密钥
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置
# 基础运行
PROJECT_NAME="TJWater Server"
ENVIRONMENT="production"
API_V1_STR="/api/v1"
NETWORK_NAME="tjwater"
# Fernet 格式;用于加密项目数据库 DSN、GeoServer 管理密码等敏感配置。
# 生成方式:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key"
# ============================================
# 数据库配置 (PostgreSQL)
# ============================================
# 业务数据库(PostgreSQL)
DB_NAME="tjwater"
DB_HOST="localhost"
DB_PORT="5432"
DB_USER="tjwater"
DB_PASSWORD="password"
# ============================================
# 数据库配置 (TimescaleDB)
# ============================================
# 时序数据库(TimescaleDB)
TIMESCALEDB_DB_NAME="tjwater"
TIMESCALEDB_DB_HOST="localhost"
TIMESCALEDB_DB_PORT="5433"
TIMESCALEDB_DB_USER="tjwater"
TIMESCALEDB_DB_PASSWORD="password"
# 实时快照替换事务的解压预算;仅事务内生效,禁止设为 0(无限制)。
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,http://127.0.0.1:15173,http://localhost:15173"
WORKBENCH_EVENTS_ENABLED=true
WORKBENCH_EVENT_RETENTION_DAYS=7
# ============================================
# Bocha Web Search API
# ============================================
BOCHA_API_KEY="sk-your-bocha-api-key"
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
# ============================================
# Tianditu Geocoding API
# ============================================
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
# 天地图服务
# 地理编码、逆地理编码和地名搜索共用同一令牌。
TIANDITU_TOKEN=""
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
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
TJWATER_MCP_ENABLED=false
TJWATER_MCP_ISSUER_URI=""
# 仅 ENVIRONMENT=development:可与 ISSUER_URI 同值并指向内网 HTTP IPv4 issuer。
# 正式环境必须留空;该例外不允许远程 HTTP MCP resource。
TJWATER_MCP_DEV_HTTP_ISSUER_URI=""
TJWATER_MCP_RESOURCE_URI=""
TJWATER_MCP_AUDIENCE="tjwater-mcp"
TJWATER_MCP_HOST_CLIENT_ID="tjwater-agent-mcp"
# 列表使用 JSON 格式。
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
TJWATER_MCP_ALLOWED_ORIGINS='[]'
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
# 普通查询预算;计算工具由 Agent 通过 timeout_seconds 按次指定。
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30
# 计算等待上限为 120–86400 秒,实际上限通过 tools/list 发布。
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
+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 }}
+9 -4
View File
@@ -1,13 +1,16 @@
FROM condaforge/miniforge3:latest AS base
FROM condaforge/miniforge3:26.7.2-0@sha256:569afc65b5338d35e276adf9a860025cd37a677659d19dcb71fc1d4c2063a94e AS base
WORKDIR /app
ENV CONDA_ENV_PATH=/opt/server \
PATH="/opt/server/bin:${PATH}"
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn \
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题)
RUN mamba install -y python=3.12 pymetis && \
# 在独立环境中安装 Python 3.12 和 pymetis,避免修改 Miniforge 基础环境。
RUN mamba create -y -p "$CONDA_ENV_PATH" python=3.12 pymetis pip && \
mamba clean -afy
COPY requirements.txt .
@@ -20,6 +23,8 @@ 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 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
RUN mkdir -p db_inp temp data inp
@@ -33,7 +38,7 @@ COPY tests ./tests
RUN python -m compileall -q app && \
python -c "import app.main" && \
python scripts/check_openapi.py && \
pytest -q tests
pytest -q --import-mode=importlib tests
FROM base AS runner
+80 -3
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
@@ -43,8 +51,30 @@ conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
conda activate server
```
`GET /health` 是无需鉴权的 HTTP 服务存活检查,返回 `200` 和 `{"status":"ok"}`,不查询数据库。
`GET /ready` 检查应用启动状态、元数据库 `SELECT 1` 及已启用的 MCP 传输生命周期;
全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为 2 秒。MCP 关闭时标记为
`disabled`,不阻止就绪。返回结果不包含数据库地址、凭据或原始异常,禁止缓存。
就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过
带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。
## 环境配置
`.env.example` 是唯一应提交的环境配置样板,主要分为以下几组:
- 元数据库、项目业务库、TimescaleDB 及项目数据库连接池。
- GeoServer 项目供应、Keycloak JWT 校验、CORS 和工作台事件。
- 天地图地理编码和地名搜索。
- 调查任务 worker、外围工单同步、数据与动作适配器。
- 可选的 MCP resource、OAuth issuer、宿主和超时预算。
基础数据库变量用于启动和项目供应。运行时的项目请求仍以元数据库中的 `biz_data` 和 `iot_data` 路由为准,不会因为存在默认数据库变量而回退到固定项目。
## 常用命令
外围接入样板作为 `/peripheral` 子应用随主 API 进程启动,复用 PostgreSQL 且不自动建表;授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD;工单经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。
```bash
conda run -n server python -m pytest tests -q
conda run -n server python scripts/run_server.py
@@ -73,6 +103,16 @@ docker compose -f infra/docker/docker-compose.yml config
- `iot_data` DSN 用于 TimescaleDB,始终使用元数据配置的完整 DSN,不再从项目代码推导数据库名。
- 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。
在线重算会原子替换一个时刻的节点、管段基线。历史分块已经压缩时,写入事务使用
`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认 100 万条;配置必须
为正整数,不允许 0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。
预算按需根据受影响压缩批次的规模调整,超过预算仍会失败并回滚当前快照;此前完成的其他
时刻不会一起回滚。同步、异步及单表批量快照入口使用同一预算。
压缩写入回归位于 `tests/integration/test_realtime_compression.py`;仅在设置
`REALTIME_COMPRESSION_TEST_DSN` 时执行,并且只接受库名以 `tjw_test_realtime_` 开头的
独立可丢弃数据库。测试会创建和删除该库内的 `realtime` schema,禁止指向项目数据库。
完整新建供水项目使用 `POST /api/v1/admin/project-provisions`,以
`multipart/form-data` 同时提交 `name`、小写 `code`、可选的
`description`、`gs_workspace`、`map_zoom` 和 INP `file`。工作流会按顺序完成:
@@ -87,6 +127,29 @@ docker compose -f infra/docker/docker-compose.yml config
使用模板复制或临时方案库的模拟功能时,`biz_data` 账号必须具备数据库创建和删除权限;只有显式删除项目时才会终止该项目的现有数据库会话,普通复制不会主动中断复制源会话。
奉贤项目的原始 GIS 与水质监测点使用独立脚本维护。导入脚本读取两份交付目录并写入项目 BizDB;水质脚本调用现有灵敏度分析服务生成 50 个监测点;发布脚本创建 10 个项目图层并刷新 GeoServer 缓存:
```bash
conda run -n server python scripts/import_fengxian_gis.py --database tjwater_v2
conda run -n server python scripts/generate_fengxian_water_quality_points.py --project tjwater_v2
conda run -n server python scripts/publish_fengxian_map_layers.py --database tjwater_v2 --workspace tjwater_next
```
发布命令需要配置 `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)。
## 测试与发布
提交前根据改动范围运行最小有效测试:
@@ -101,8 +164,22 @@ 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`
这些密钥只用于镜像仓库和受限开发机部署,不替代后端应用的运行环境配置。
## 安全规则
不要提交 `.env`、客户数据、数据库 dump、日志、生成缓存、`db_inp/`、`temp/`、`data/` 或本地密钥。CI/CD 凭据应放在 Gitea secrets 和仓库变量中。
## 产品 MCP 查询
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)。
+58
View File
@@ -0,0 +1,58 @@
"""Public process probes, separate from authenticated project diagnostics."""
import asyncio
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.infra.db.metadb.database import engine
router = APIRouter(include_in_schema=False)
METADATA_TIMEOUT_SECONDS = 2
async def metadata_database_ready() -> bool:
try:
async with asyncio.timeout(METADATA_TIMEOUT_SECONDS):
async with engine.connect() as connection:
result = await connection.execute(text("SELECT 1"))
return result.scalar_one() == 1
except Exception:
# Public probes must not expose connection strings or driver errors.
return False
@router.get("/health")
async def health() -> dict[str, str]:
"""Check HTTP liveness without project or database dependencies."""
return {"status": "ok"}
@router.get("/ready")
async def readiness(request: Request) -> JSONResponse:
"""Check startup, metadata connectivity and the enabled MCP lifecycle."""
started = getattr(request.app.state, "startup_complete", False)
runtime = getattr(request.app.state, "mcp_runtime", None)
mcp_ready = runtime is None or runtime.ready
metadata_ready = await metadata_database_ready() if started else False
ready = started and metadata_ready and mcp_ready
return JSONResponse(
status_code=200 if ready else 503,
content={
"status": "ready" if ready else "not_ready",
"checks": {
"startup": "ready" if started else "not_ready",
"metadata_database": (
"not_checked" if not started
else "ready" if metadata_ready else "unavailable"
),
"mcp_transport": (
"disabled" if runtime is None
else "ready" if mcp_ready else "not_ready"
),
},
},
headers={"Cache-Control": "no-store"},
)
+47 -5
View File
@@ -22,6 +22,10 @@ class ProblemDetails(BaseModel):
code: str
trace_id: str
errors: list[dict[str, Any]] = Field(default_factory=list)
retryable: bool = False
related_refs: list[Any] = Field(default_factory=list)
recovery_action: str = "none"
retry_after_seconds: int | None = None
def _trace_id(request: Request) -> str:
@@ -36,6 +40,10 @@ def _problem_response(
detail: str,
code: str,
errors: list[dict[str, Any]] | None = None,
retryable: bool = False,
related_refs: list[Any] | None = None,
recovery_action: str = "none",
retry_after_seconds: int | None = None,
) -> JSONResponse:
problem = ProblemDetails(
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
@@ -46,6 +54,10 @@ def _problem_response(
code=code,
trace_id=_trace_id(request),
errors=errors or [],
retryable=retryable,
related_refs=related_refs or [],
recovery_action=recovery_action,
retry_after_seconds=retry_after_seconds,
)
return JSONResponse(
status_code=status_code,
@@ -89,7 +101,6 @@ def install_problem_details_handlers(app: FastAPI) -> None:
@app.exception_handler(HTTPException)
async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
code_by_status = {
401: "unauthenticated",
403: "forbidden",
@@ -98,12 +109,43 @@ def install_problem_details_handlers(app: FastAPI) -> None:
422: "validation_error",
503: "dependency_unavailable",
}
fallback_code = code_by_status.get(exc.status_code, "request_error")
detail_payload = exc.detail if isinstance(exc.detail, dict) else None
code = (
detail_payload.get("code")
if detail_payload and isinstance(detail_payload.get("code"), str)
else fallback_code
)
detail = exc.detail if isinstance(exc.detail, str) else None
if detail_payload:
candidate = detail_payload.get("message") or detail_payload.get("detail")
if isinstance(candidate, str):
detail = candidate
if detail is None:
detail = code.replace("_", " ").title()
related_refs = detail_payload.get("related_refs", []) if detail_payload else []
if not isinstance(related_refs, list):
related_refs = []
recovery_action = (
detail_payload.get("recovery_action", "none") if detail_payload else "none"
)
if not isinstance(recovery_action, str):
recovery_action = "none"
retry_after_seconds = (
detail_payload.get("retry_after_seconds") if detail_payload else None
)
if not isinstance(retry_after_seconds, int):
retry_after_seconds = None
return _problem_response(
request,
status_code=exc.status_code,
title=code_by_status.get(exc.status_code, "request_error")
.replace("_", " ")
.title(),
title=fallback_code.replace("_", " ").title(),
detail=detail,
code=code_by_status.get(exc.status_code, "request_error"),
code=code,
retryable=(
bool(detail_payload.get("retryable", False)) if detail_payload else False
),
related_refs=related_refs,
recovery_action=recovery_action,
retry_after_seconds=retry_after_seconds,
)
+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,
)
+101
View File
@@ -0,0 +1,101 @@
"""Actor-private messaging APIs; the Agent verifies its session ownership."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection
from app.auth.permissions import OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, require_permission
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection
from app.domain.domain_errors import DomainError
from app.domain.schemas.agent_messages import (
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
)
from app.infra.db.postgresql.agent_messages import AgentMessageRepository
from app.services.agent_messages import AgentMessageService
router = APIRouter(prefix="/agent-messages")
def _service(ctx: ProjectContext, conn: AsyncConnection) -> AgentMessageService:
return AgentMessageService(AgentMessageRepository(conn, ctx.project_id), ctx)
def _raise_domain(exc: DomainError):
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
@router.post("", response_model=AgentMessage, status_code=201)
async def create_agent_message(
command: AgentMessageCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).create(command)
except DomainError as exc:
_raise_domain(exc)
@router.get("", response_model=AgentMessagePage)
async def list_agent_messages(
session_id: str = Query(min_length=1, max_length=500),
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
pending_only: bool = False,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).list_session(session_id, limit, cursor, pending_only)
except DomainError as exc:
_raise_domain(exc)
@router.get("/pending", response_model=AgentMessagePage)
async def list_pending_agent_messages(
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).pending(limit, cursor)
except DomainError as exc:
_raise_domain(exc)
@router.get("/{message_id}", response_model=AgentMessage)
async def get_agent_message(
message_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).get(message_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/{message_id}/reply", response_model=AgentMessage, status_code=201)
async def reply_agent_message(
message_id: UUID, command: AgentMessageReply,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).reply(message_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post("/{message_id}/delivery", response_model=AgentMessage)
async def record_agent_message_delivery(
message_id: UUID, command: AgentMessageDelivery,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).delivery(message_id, command)
except DomainError as exc:
_raise_domain(exc)
+60
View File
@@ -0,0 +1,60 @@
"""Host-only MCP bindings: declared in REST contracts even when MCP is disabled."""
from fastapi import APIRouter, Header, HTTPException, Request, Response
from psycopg.errors import UndefinedTable, InvalidSchemaName
from app.mcp.models import BindInput, BindResponse
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
router = APIRouter(tags=["MCP Query Context"])
def runtime_for(request: Request):
runtime = getattr(request.app.state, "mcp_runtime", None)
if runtime is None:
raise HTTPException(503, "Product MCP is disabled")
return runtime
@router.post("/mcp-query-contexts", response_model=BindResponse)
async def create_context(
command: BindInput,
request: Request,
x_project_id: str = Header(alias="X-Project-Id"),
) -> BindResponse:
runtime = runtime_for(request)
principal = await runtime.identity(request)
from app.mcp.contexts import bind_context
try:
async with runtime.scope_factory(principal, x_project_id) as scope:
binding = await bind_context(scope, principal, command, runtime.settings)
except (UndefinedTable, InvalidSchemaName):
raise HTTPException(
503, "MCP project storage has not been provisioned"
) from None
return BindResponse(
context_handle=binding.context_handle,
expires_at=binding.expires_at,
task_id=binding.task_id,
run_id=binding.run_id,
)
@router.delete("/mcp-query-contexts/{context_handle}", status_code=204)
async def delete_context(
context_handle: str,
request: Request,
x_project_id: str = Header(alias="X-Project-Id"),
) -> Response:
runtime = runtime_for(request)
principal = await runtime.identity(request)
try:
async with runtime.scope_factory(principal, x_project_id) as scope:
await McpQueryRepository(scope.connection, scope.project.project_id).revoke(
context_handle, principal.subject, principal.client_id
)
except (UndefinedTable, InvalidSchemaName):
raise HTTPException(
503, "MCP project storage has not been provisioned"
) from None
return Response(status_code=204)
+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,
+773
View File
@@ -0,0 +1,773 @@
"""Project-scoped APIs for operational tasks, evidence and approved actions."""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from psycopg import AsyncConnection
from app.auth.permissions import (
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC,
OPERATIONAL_TASK_VIEW,
OPERATIONAL_TASK_WRITE,
SCADA_VIEW,
SIMULATION_VIEW,
permissions_for_context,
require_permission,
)
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
get_project_pg_connection,
)
from app.domain.domain_errors import DomainError
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
ActionExecuteCommand,
ActionProposalCommand,
ActionReconcileCommand,
WorkOrderDraftUpdateCommand,
WorkOrderConfirmDispatchCommand,
TaskAccessRequest,
TaskAccessRequestCreate,
TaskAccessRequestDecision,
AgentArtifactsCommand,
AgentArtifactsResult,
AgentRunCancelCommand,
AgentRunCommand,
AgentRunDispatchFailureCommand,
AgentRunFailureCommand,
AgentTaskContext,
DispatchDueSchedulesCommand,
DispatchDueSchedulesResult,
EvidenceRecord,
ExternalFeedbackCommand,
ExternalFeedbackResult,
ExternalSourceWithdrawalCommand,
HumanApprovalAction,
HumanEvidenceCommand,
InvestigationReportPage,
InvestigationReportRevision,
NotificationAcknowledgeCommand,
OperationalTaskCreate,
OperationalTaskDetail,
OperationalTaskNotification,
OperationalTaskNotificationPage,
OperationalTaskPage,
OperationalTaskRetryCommand,
OperationalTaskRun,
OperationalTaskRunPage,
OperationalTaskSchedule,
OperationalTaskScheduleCreate,
OperationalTaskSchedulePage,
OperationalTaskScheduleUpdate,
RecoverExpiredLeasesCommand,
RecoverExpiredLeasesResult,
TaskStatus,
TaskDataQueryResult,
TaskType,
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import (
OperationalActionDispatcher,
get_operational_action_dispatcher,
)
from app.infra.integrations.operational_data import TaskDataError, read_task_data
from app.services.operational_tasks import OperationalTaskService
router = APIRouter()
def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService:
return OperationalTaskService(
OperationalTaskRepository(conn, ctx.project_id),
ctx,
_task_permissions(ctx),
)
def _task_permissions(ctx: ProjectContext) -> set[str]:
# Investigations use preconfigured project access, including service accounts.
# Do not force an already-authorized reader through legacy per-task delegation.
return set(permissions_for_context(ctx))
def _raise_domain(exc: DomainError):
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
@router.get("/operational-tasks/{task_id}/reports", response_model=InvestigationReportPage)
async def list_investigation_reports(
task_id: UUID,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
items, next_offset = await _service(ctx, conn).list_reports(task_id, limit, offset)
return InvestigationReportPage(items=items, next_offset=next_offset)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/reports/{revision}", response_model=InvestigationReportRevision)
async def get_investigation_report(
task_id: UUID,
revision: int,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).report(task_id, revision)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks",
response_model=OperationalTaskDetail,
status_code=status.HTTP_201_CREATED,
)
async def create_operational_task(
command: OperationalTaskCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).create(command)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks", response_model=OperationalTaskPage)
async def list_operational_tasks(
task_type: TaskType | None = None,
task_status: TaskStatus | None = Query(default=None, alias="status"),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
peripheral_work_order_id: UUID | None = Query(
default=None, description="Only tasks sourced from or linked to this peripheral work order",
),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
items, next_offset = await _service(ctx, conn).list(
task_type=task_type, status=task_status, limit=limit, offset=offset,
**({"peripheral_work_order_id": peripheral_work_order_id}
if peripheral_work_order_id is not None else {}),
)
return OperationalTaskPage(
items=items, limit=limit, offset=offset, next_offset=next_offset
)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-task-runs", response_model=OperationalTaskRunPage)
async def list_operational_task_runs(
run_status: Literal[
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
] | None = Query(default=None, alias="status"),
task_type: TaskType | None = None,
task_created_from: WorkbenchDateTime | None = None,
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
cursor_created_at = None
cursor_run_id = None
if cursor:
try:
timestamp, run_id = cursor.rsplit("|", 1)
cursor_created_at = datetime.fromisoformat(timestamp)
cursor_run_id = UUID(run_id)
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail="Invalid run cursor") from exc
try:
items, next_cursor = await _service(ctx, conn).list_runs(
status=run_status,
task_type=task_type,
task_created_from=task_created_from,
limit=limit,
cursor_created_at=cursor_created_at,
cursor_run_id=cursor_run_id,
)
return OperationalTaskRunPage(items=items, next_cursor=next_cursor)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs", response_model=OperationalTaskRunPage)
async def list_task_run_history(
task_id: UUID,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return OperationalTaskRunPage(items=await _service(ctx, conn).task_runs(task_id, limit, offset))
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs/{run_id}", response_model=OperationalTaskRun)
async def get_task_run(
task_id: UUID, run_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).task_run(task_id, run_id)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/schedules",
response_model=OperationalTaskSchedule,
status_code=status.HTTP_201_CREATED,
)
async def create_operational_schedule(
command: OperationalTaskScheduleCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).create_schedule(command)
except DomainError as exc:
_raise_domain(exc)
@router.get(
"/operational-tasks/schedules", response_model=OperationalTaskSchedulePage
)
async def list_operational_schedules(
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return OperationalTaskSchedulePage(items=await _service(ctx, conn).list_schedules())
except DomainError as exc:
_raise_domain(exc)
@router.patch(
"/operational-tasks/schedules/{schedule_id}",
response_model=OperationalTaskSchedule,
)
async def update_operational_schedule(
schedule_id: UUID,
command: OperationalTaskScheduleUpdate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).update_schedule(schedule_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/schedules/dispatch-due",
response_model=DispatchDueSchedulesResult,
)
async def dispatch_due_operational_schedules(
command: DispatchDueSchedulesCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).dispatch_due_schedules(command.limit)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/leases/recover",
response_model=RecoverExpiredLeasesResult,
)
async def recover_operational_task_leases(
command: RecoverExpiredLeasesCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).recover_expired_leases(
command.limit, task_type=command.task_type,
task_created_from=command.task_created_from,
)
except DomainError as exc:
_raise_domain(exc)
@router.get(
"/operational-task-notifications",
response_model=OperationalTaskNotificationPage,
)
async def list_operational_task_notifications(
unread_only: bool = False,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
items, next_offset = await _service(ctx, conn).list_notifications(
unread_only=unread_only, limit=limit, offset=offset
)
return OperationalTaskNotificationPage(
items=items, limit=limit, offset=offset, next_offset=next_offset
)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-task-notifications/{notification_id}/acknowledge",
response_model=OperationalTaskNotification,
)
async def acknowledge_operational_task_notification(
notification_id: UUID,
command: NotificationAcknowledgeCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).acknowledge_notification(notification_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}", response_model=OperationalTaskDetail)
async def get_operational_task(
task_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).detail(task_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/retry", response_model=OperationalTaskDetail)
async def retry_operational_task(
task_id: UUID,
command: OperationalTaskRetryCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).retry_task(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/claim",
response_model=OperationalTaskRun,
)
async def claim_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).claim_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/heartbeat",
response_model=OperationalTaskRun,
)
async def heartbeat_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).heartbeat_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/complete",
response_model=OperationalTaskDetail,
)
async def complete_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).complete_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/fail",
response_model=OperationalTaskDetail,
)
async def fail_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunFailureCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).fail_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/cancel",
response_model=OperationalTaskDetail,
)
async def cancel_operational_task_run(
task_id: UUID,
run_id: UUID,
command: AgentRunCancelCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).cancel_run(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/runs/{run_id}/dispatch-failure",
response_model=OperationalTaskRun,
)
async def record_operational_task_run_dispatch_failure(
task_id: UUID,
run_id: UUID,
command: AgentRunDispatchFailureCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).record_dispatch_failure(task_id, run_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.get(
"/operational-tasks/{task_id}/agent-context", response_model=AgentTaskContext
)
async def get_operational_task_agent_context(
task_id: UUID,
report_revision: int | None = Query(default=None, ge=1),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).agent_context(task_id, report_revision)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/agent-artifacts",
response_model=AgentArtifactsResult,
)
async def publish_operational_task_artifacts(
task_id: UUID,
command: AgentArtifactsCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).publish_artifacts(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/presentation-cursor", response_model=TaskPresentationCursor)
async def get_operational_task_presentation_cursor(
task_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).presentation_cursor(task_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
async def publish_operational_task_presentation_event(
task_id: UUID,
command: TaskPresentationEventCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).publish_presentation_event(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult
)
async def query_operational_task_data(
task_id: UUID,
query: TaskDataQuery,
authorization: str | None = Header(default=None, alias="Authorization"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
service = _service(ctx, conn)
try:
if not await service.can_read_source(task_id, query.source):
raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403)
source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
snapshot = await read_task_data(
context=ctx, query=query, credential=authorization,
delegated=source_permission not in _task_permissions(ctx),
)
evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot)
return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot)
except TaskDataError as exc:
raise HTTPException(status_code=exc.status, detail=exc.detail) from exc
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/access-requests",
response_model=TaskAccessRequest,
status_code=status.HTTP_201_CREATED,
)
async def request_operational_task_access(
task_id: UUID,
command: TaskAccessRequestCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).request_access(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/access-requests/{access_request_id}/decision",
response_model=TaskAccessRequest,
)
async def decide_operational_task_access(
task_id: UUID,
access_request_id: UUID,
command: TaskAccessRequestDecision,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).decide_access(task_id, access_request_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/human-evidence",
response_model=OperationalTaskDetail,
)
async def add_operational_task_human_evidence(
task_id: UUID,
command: HumanEvidenceCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).add_human_evidence(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.get(
"/operational-tasks/{task_id}/evidence/{evidence_id}/snapshot",
response_model=TaskDataSnapshot,
)
async def get_operational_task_data_snapshot(
task_id: UUID,
evidence_id: str,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).data_snapshot(task_id, evidence_id)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions",
response_model=HumanApprovalAction,
status_code=status.HTTP_201_CREATED,
)
async def propose_operational_task_action(
task_id: UUID,
command: ActionProposalCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).propose_action(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions/{action_id}/decision",
response_model=HumanApprovalAction,
)
async def decide_operational_task_action(
task_id: UUID,
action_id: UUID,
command: ActionDecisionCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).decide_action(task_id, action_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.patch(
"/operational-tasks/{task_id}/actions/{action_id}/draft",
response_model=HumanApprovalAction,
)
async def update_operational_work_order_draft(
task_id: UUID,
action_id: UUID,
command: WorkOrderDraftUpdateCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).update_work_order_draft(task_id, action_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch",
response_model=HumanApprovalAction,
)
async def confirm_operational_work_order_dispatch(
task_id: UUID,
action_id: UUID,
command: WorkOrderConfirmDispatchCommand,
authorization: str | None = Header(default=None, alias="Authorization"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
):
try:
return await _service(ctx, conn).confirm_work_order_dispatch(
task_id, action_id, command, dispatcher, authorization
)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions/{action_id}/execute",
response_model=HumanApprovalAction,
)
async def execute_operational_task_action(
task_id: UUID,
action_id: UUID,
command: ActionExecuteCommand,
authorization: str | None = Header(default=None, alias="Authorization"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
):
try:
return await _service(ctx, conn).submit_action(
task_id, action_id, command, dispatcher, authorization
)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/actions/{action_id}/reconcile",
response_model=HumanApprovalAction,
)
async def reconcile_operational_task_action(
task_id: UUID,
action_id: UUID,
command: ActionReconcileCommand,
authorization: str | None = Header(default=None, alias="Authorization"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher),
):
try:
return await _service(ctx, conn).reconcile_action(
task_id, action_id, command, dispatcher, authorization
)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/external-feedback",
response_model=ExternalFeedbackResult,
)
async def synchronize_operational_task_feedback(
task_id: UUID,
command: ExternalFeedbackCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).external_feedback(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/withdraw-external-source",
response_model=OperationalTaskDetail,
)
async def withdraw_operational_task_external_source(
task_id: UUID,
command: ExternalSourceWithdrawalCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).withdraw_external_source(task_id, command)
except DomainError as exc:
_raise_domain(exc)
+60
View File
@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import ValidationError
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 project places",
description="调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。",
)
async def tianditu_place_search(
request: PlaceSearchRequest,
config: PlaceSearchConfig = Depends(get_place_search_config),
) -> PlaceSearchResponse:
try:
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
)
+107 -35
View File
@@ -1,47 +1,116 @@
import logging
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.auth.project_dependencies import get_project_pg_connection
from app.domain.schemas.gis_attributes import (
ElementGisProperties,
GisElementType,
SourceGisFeatureProperties,
)
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
router = APIRouter()
logger = logging.getLogger(__name__)
async def get_database_connection(
@router.get(
"/source-gis/layers/{layer_key}/features/{source_fid}/properties",
response_model=SourceGisFeatureProperties,
summary="获取源 GIS 要素台账属性",
)
async def get_source_gis_feature_properties(
layer_key: str,
source_fid: int,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""获取数据库连接"""
yield conn
normalized_layer_key = layer_key.strip()
if (
not normalized_layer_key
or len(normalized_layer_key) > 80
or source_fid < 0
):
raise HTTPException(status_code=422, detail="无效的源 GIS 要素编号")
try:
group = await GisAttributeRepository.get_source_feature_group(
conn,
normalized_layer_key,
source_fid,
)
if group is None:
raise HTTPException(status_code=404, detail="源 GIS 要素不存在")
return SourceGisFeatureProperties(
layer_key=normalized_layer_key,
source_fid=source_fid,
group=group,
)
except HTTPException:
raise
except Exception as exc:
logger.warning("Source GIS feature properties are unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询源 GIS 要素属性失败") from exc
@router.get(
"/elements/{element_type}/{element_id}/gis-properties",
response_model=ElementGisProperties,
summary="获取元素 GIS 台账属性",
)
async def get_element_gis_properties(
element_type: GisElementType,
element_id: str,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return source GIS records linked to one current-project map element."""
normalized_id = element_id.strip()
if not normalized_id or len(normalized_id) > 160:
raise HTTPException(status_code=422, detail="无效的元素编号")
try:
groups = await GisAttributeRepository.list_element_groups(
conn,
element_type,
normalized_id,
)
return ElementGisProperties(
element_type=element_type,
element_id=normalized_id,
groups=groups,
)
except Exception as exc:
logger.warning("Element GIS properties are unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询 GIS 台账属性失败") from exc
@router.get("/analysis/runs", summary="获取分析运行列表")
async def get_analysis_runs(
conn: AsyncConnection = Depends(get_database_connection),
limit: int = Query(default=100, ge=1, le=100),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
获取所有方案信息
返回项目中所有方案的详细信息
"""
"""分页返回当前项目的持久分析运行。"""
try:
runs = await AnalysisRepository.list_runs(conn)
return {"success": True, "data": runs, "count": len(runs)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"查询分析运行时发生错误: {str(e)}")
runs = await AnalysisRepository.list_runs(conn, limit=limit, offset=offset)
return {
"success": True,
"data": runs,
"count": len(runs),
"limit": limit,
"offset": offset,
}
except Exception as exc:
logger.warning("Analysis run page is unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
@router.get("/analysis/runs/{run_id}", summary="获取分析运行")
async def get_analysis_run(
run_id: UUID,
conn: AsyncConnection = Depends(get_database_connection),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
获取所有爆管定位结果
返回项目中所有的爆管定位分析结果
"""
"""返回当前项目的一条持久分析运行。"""
try:
run = await AnalysisRepository.get_run(conn, run_id)
if run is None:
@@ -49,28 +118,31 @@ async def get_analysis_run(
return run
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"查询分析运行时发生错误: {str(e)}"
)
except Exception as exc:
logger.warning("Analysis run is unavailable", exc_info=True)
raise HTTPException(status_code=500, detail="查询分析运行失败") from exc
@router.get("/analysis/runs/{run_id}/results", summary="获取分析结果")
async def get_analysis_results(
run_id: UUID,
result_type: str | None = Query(default=None, description="结果类型"),
conn: AsyncConnection = Depends(get_database_connection),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""
根据爆管事件ID查询爆管定位结果
参数:
burst_incident: 爆管事件的唯一标识符
"""
"""分页返回一条分析运行的持久结果。"""
try:
return await AnalysisRepository.list_results(conn, run_id, result_type)
except Exception as e:
return await AnalysisRepository.list_results(
conn,
run_id,
result_type,
limit=limit,
offset=offset,
)
except Exception as exc:
logger.warning("Analysis result page is unavailable", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"查询分析结果时发生错误: {str(e)}",
)
detail="查询分析结果失败",
) from exc
+1
View File
@@ -146,6 +146,7 @@ async def optimize_sensor_placement_scheme(
sensor_count=payload.sensor_count,
min_diameter=payload.min_diameter,
created_by=current_user.username,
sensor_type=payload.sensor_type,
)
run = get_sensor_placement_run(network, created["run_id"])
return {**run, "can_edit": True}
+3 -22
View File
@@ -1,5 +1,5 @@
from typing import Any, List, Literal, Optional
from datetime import datetime, timedelta
from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi.responses import PlainTextResponse
from app.auth.keycloak_dependencies import get_current_keycloak_username
@@ -18,7 +18,6 @@ from app.services.simulation_scenarios import (
from app.services.valve_isolation import analyze_valve_isolation
from app.domain.time import (
parse_aware_time,
parse_clock_duration_seconds,
parse_utc_time,
)
from pydantic import BaseModel, Field, field_validator
@@ -50,27 +49,9 @@ class PressureRegulation(BaseModel):
def run_simulation_manually_by_date(
network_name: str, start_time: datetime, duration: int
) -> None:
end_datetime = start_time + timedelta(minutes=duration)
time_properties = simulation.get_time(network_name)
hydraulic_step_seconds = parse_clock_duration_seconds(
time_properties["HYDRAULIC TIMESTEP"],
field_name="HYDRAULIC TIMESTEP",
simulation.run_online_simulation(
name=network_name, start_time=start_time, duration_seconds=duration * 60,
)
if hydraulic_step_seconds <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
scada_mappings = simulation.query_corresponding_element_id_and_query_id(
network_name
)
current_time = start_time
while current_time < end_datetime:
simulation.run_simulation(
name=network_name,
simulation_type="realtime",
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
scada_mappings=scada_mappings,
)
current_time += hydraulic_step
# 必须用这个PlainTextResponse,不然每个key都有引号
+2 -1
View File
@@ -11,9 +11,10 @@ from app.services.timeseries_history import TimeseriesHistoryService
from .dependencies import get_timescale_connection, get_postgres_connection
router = APIRouter()
history_router = APIRouter()
@router.post(
@history_router.post(
"/timeseries/views/element-history/query",
summary="批量查询管网元素历史数据",
response_model=ElementHistoryResponse,
+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数据"
)
-29
View File
@@ -1,29 +0,0 @@
from typing import Any
from fastapi import APIRouter, HTTPException, status
from app.services.web_search import (
BochaSearchAPIError,
BochaSearchConfigError,
WebSearchRequest,
search_bocha_web,
)
router = APIRouter()
@router.post(
"/web-searches",
summary="Web Search",
description="调用 Bocha Web Search API 获取实时网页搜索结果",
)
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
try:
return await search_bocha_web(request)
except BochaSearchConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except BochaSearchAPIError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+156
View File
@@ -0,0 +1,156 @@
"""Authenticated workbench snapshot and resumable Server-Sent Events."""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from psycopg import AsyncConnection
from psycopg.conninfo import conninfo_to_dict
from app.api.v1.endpoints.workbench_runtime import workbench_runtime
from app.auth.permissions import OPERATIONAL_TASK_VIEW, permissions_for_context, require_permission
from app.auth.project_dependencies import (
ProjectContext,
get_metadata_repository,
get_project_business_routing,
get_project_pg_connection,
)
from app.core.config import settings
from app.domain.schemas.workbench_events import WorkbenchEvent, WorkbenchSyncSnapshot
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import ActiveProjectRouting
from app.services.operational_tasks import OperationalTaskService
from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs
router = APIRouter()
def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService:
return OperationalTaskService(
OperationalTaskRepository(conn, ctx.project_id),
ctx,
permissions_for_context(ctx),
)
@router.get("/workbench/sync-snapshot", response_model=WorkbenchSyncSnapshot)
async def workbench_sync_snapshot(
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
async with conn.transaction():
await conn.execute(
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
)
cursor = await WorkbenchEventRepository(conn, ctx.project_id).current_cursor()
runtime = await workbench_runtime(ctx=ctx, conn=conn)
tasks, _ = await _service(ctx, conn).list(
task_type=None, status=None, limit=50, offset=0
)
notifications, _ = await _service(ctx, conn).list_notifications(
unread_only=False, limit=100, offset=0
)
return WorkbenchSyncSnapshot(
schema="workbench-sync-snapshot@1",
event_cursor=cursor,
runtime=runtime,
tasks=tasks,
notifications=notifications,
generated_at=datetime.now(timezone.utc),
)
@router.get("/workbench/events")
async def workbench_events(
after: int | None = Query(default=None, ge=0),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
routing: ActiveProjectRouting = Depends(get_project_business_routing),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
):
if not settings.WORKBENCH_EVENTS_ENABLED:
raise HTTPException(status_code=503, detail="Workbench events are disabled")
cursor = after
if cursor is None and last_event_id:
try:
cursor = int(last_event_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid Last-Event-ID") from exc
cursor = cursor or 0
current_identity = _database_identity(routing.business_dsn)
try:
active_routings = await metadata_repo.list_active_project_db_routings("biz_data")
finally:
# FastAPI keeps yield dependencies alive for a StreamingResponse. The
# stream only needs immutable routing values, so release the shared
# metadata checkout before the five-minute SSE body begins.
await metadata_repo.session.close()
if any(
item.project_id != ctx.project_id
and _database_identity(item.dsn) == current_identity
for item in active_routings
):
raise HTTPException(
status_code=503,
detail="Workbench events require one dedicated business database per project",
)
async def stream():
started = asyncio.get_running_loop().time()
last_keepalive = started
async with workbench_event_hubs.subscribe(ctx.project_id, routing.business_dsn) as hub:
oldest = await hub.oldest_cursor()
latest = await hub.current_cursor()
if cursor > 0 and (
oldest is None or cursor < oldest - 1 or cursor > latest
):
payload = {"schema": "workbench-event@1", "cursor": cursor}
yield f"event: reset_required\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n"
return
current = cursor
wake_generation = hub.generation
while asyncio.get_running_loop().time() - started < 300:
rows = await hub.read_after(current)
for row in rows:
event = WorkbenchEvent(
schema="workbench-event@1",
**row,
)
current = event.event_seq
data = event.model_dump_json(by_alias=True)
yield f"id: {current}\nevent: workbench_event\ndata: {data}\n\n"
if rows:
continue
now = asyncio.get_running_loop().time()
if now - last_keepalive >= 15:
yield f": keepalive {int(now)}\n\n"
last_keepalive = now
# The Hub invalidates shared reads on NOTIFY and every 30s.
# Per-client timeout only keeps the transport alive, not SQL.
wake_generation = await hub.wait_for_wake(wake_generation, timeout=15)
yield "event: reconnect\ndata: {}\n\n"
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Content-Encoding": "identity",
},
)
def _database_identity(dsn: str) -> tuple[str, str, str]:
values = conninfo_to_dict(dsn)
return (
values.get("host") or values.get("hostaddr") or "localhost",
values.get("port") or "5432",
values.get("dbname") or values.get("user") or "",
)
+155
View File
@@ -0,0 +1,155 @@
"""Read-only workbench runtime aggregation."""
import logging
from contextlib import asynccontextmanager
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from psycopg import AsyncConnection
from pydantic import ValidationError
from app.auth.project_dependencies import (
ProjectContext,
get_project_context,
get_project_pg_connection,
)
from app.domain.schemas.workbench_runtime import (
WorkbenchAnalysisHistoryPage,
WorkbenchPersistedAlert,
WorkbenchRuntimeLatestAnalysis,
WorkbenchRuntimeSnapshot,
)
from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.workbench_operations import (
WorkbenchOperationsRepository,
)
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
from app.services.task_alerts import TaskAlertService
from app.domain.domain_errors import DomainError
from app.services.workbench_runtime import (
build_workbench_runtime_snapshot,
project_analysis_history_item,
project_latest_analysis,
project_sensor_location_map_payload,
sensor_location_ids_for_map_projection,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@asynccontextmanager
async def _projection_transaction(conn: AsyncConnection):
"""Isolate an optional projection so one SQL failure cannot poison others."""
transaction = getattr(conn, "transaction", None)
if transaction is None: # Lightweight endpoint test doubles.
yield
return
async with transaction():
yield
@router.get(
"/workbench/analysis-runs",
response_model=WorkbenchAnalysisHistoryPage,
)
async def workbench_analysis_runs(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return a bounded, sanitized analysis history page for the workbench."""
rows = await AnalysisRepository.list_run_summaries(
conn,
limit=limit + 1,
offset=offset,
)
has_more = len(rows) > limit
items = rows[:limit]
return {
"items": [project_analysis_history_item(run) for run in items],
"limit": limit,
"offset": offset,
"next_offset": offset + limit if has_more else None,
}
@router.get(
"/workbench/analysis-runs/{run_id}",
response_model=WorkbenchRuntimeLatestAnalysis,
)
async def workbench_analysis_run(
run_id: UUID,
conn: AsyncConnection = Depends(get_project_pg_connection),
):
"""Return one authorized project's analysis run as a typed workbench artifact."""
run = await AnalysisRepository.get_run(conn, run_id)
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Analysis run not found",
)
results = await AnalysisRepository.list_recent_results(conn, run_id, limit=100)
try:
referenced_map_payload = await _sensor_location_map_payload(conn, results)
except Exception:
referenced_map_payload = None
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
return project_latest_analysis(
run,
results,
referenced_map_payload=referenced_map_payload,
)
@router.post("/workbench/task-alert-events", response_model=TaskAlertState)
async def push_task_alert_event(
command: TaskAlertCommand,
ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
event = TaskAlertEvent(
**command.model_dump(),
producer_id=f"principal:{ctx.user_id}",
)
return await TaskAlertService(
TaskAlertRepository(conn, ctx.project_id), ctx
).push(event)
except DomainError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
@router.get("/workbench/runtime", response_model=WorkbenchRuntimeSnapshot)
async def workbench_runtime(
ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
from app.services.workbench_runtime import read_workbench_runtime
return await read_workbench_runtime(ctx, conn)
async def _sensor_location_map_payload(
conn: AsyncConnection, results: list[dict]
) -> dict | None:
node_ids = sensor_location_ids_for_map_projection(results)
if not node_ids:
return None
map_points = await AnalysisRepository.list_junction_map_points(conn, node_ids)
return project_sensor_location_map_payload(node_ids, map_points)
def _validate_documents(documents, model, label: str) -> tuple[list[dict], int]:
valid: list[dict] = []
rejected = 0
for document in documents:
try:
valid.append(model.model_validate(document).model_dump())
except ValidationError:
rejected += 1
logger.warning("Workbench %s document was rejected", label)
return valid, rejected
+29 -5
View File
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
access,
admin_metadata,
agent_auth,
agent_messages,
audit,
burst_detection,
burst_location,
@@ -11,12 +12,15 @@ from app.api.v1.endpoints import (
leakage,
meta,
model_import,
operational_tasks,
place_search,
project,
project_data,
scada,
sensor_placement,
simulation,
web_search,
workbench_runtime,
workbench_events,
)
from app.api.v1.endpoints.components import (
controls,
@@ -85,8 +89,22 @@ burst_run_access = Depends(require_permission(BURST_RUN))
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
# Core services
api_router.include_router(
workbench_runtime.router,
tags=["Workbench Runtime"],
dependencies=[simulation_access],
)
api_router.include_router(
workbench_events.router,
tags=["Workbench Events"],
)
api_router.include_router(
operational_tasks.router,
tags=["Operational Tasks"],
)
api_router.include_router(access.router, tags=["Access Control"])
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
api_router.include_router(agent_messages.router, tags=["Agent Messages"])
api_router.include_router(
admin_metadata.router,
tags=["Metadata Admin"],
@@ -143,13 +161,13 @@ api_router.include_router(
dependencies=[optimization_run_access],
)
api_router.include_router(
web_search.router,
tags=["Web Search"],
geocoding.router,
tags=["Geocoding"],
dependencies=[webgis_view_access],
)
api_router.include_router(
geocoding.router,
tags=["Geocoding"],
place_search.router,
tags=["Place Search"],
dependencies=[webgis_view_access],
)
api_router.include_router(
@@ -179,6 +197,12 @@ for endpoint_router, tag in (
dependencies=[simulation_access],
)
api_router.include_router(
ts_composite.history_router,
tags=["TimescaleDB - Composite"],
dependencies=[Depends(require_permission(SCADA_VIEW))],
)
for endpoint_router, tag in (
(ts_scada.router, "TimescaleDB - SCADA"),
(ts_composite.router, "TimescaleDB - Composite"),
+10
View File
@@ -6,6 +6,9 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from app.core.config import settings
from app.mcp.config import McpSettings
mcp_resource_settings = McpSettings()
oauth2_optional = OAuth2PasswordBearer(
tokenUrl="keycloak", auto_error=False
@@ -29,6 +32,13 @@ def _decode_keycloak_token(token: str) -> dict:
algorithms=[settings.KEYCLOAK_ALGORITHM],
audience=settings.KEYCLOAK_AUDIENCE or None,
)
# A resource-bound MCP token must never authorize product REST APIs,
# including when another audience overlaps the configured API audience.
audience = payload.get("aud", [])
audiences = [audience] if isinstance(audience, str) else audience
if (isinstance(audiences, list) and mcp_resource_settings.audience in audiences
or payload.get("azp") == mcp_resource_settings.host_client_id):
raise JWTError("MCP resource tokens cannot authorize product REST APIs")
if settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS <= 0:
return payload
+33
View File
@@ -21,6 +21,15 @@ MODEL_IMPORT = "model.import"
AUDIT_VIEW = "audit.view"
ENVIRONMENT_MANAGE = "environment.manage"
MEMBERSHIP_MANAGE = "membership.manage"
OPERATIONAL_TASK_VIEW = "operational_task.view"
OPERATIONAL_TASK_WRITE = "operational_task.write"
OPERATIONAL_TASK_EXECUTE = "operational_task.execute"
OPERATIONAL_TASK_EXTERNAL_SYNC = "operational_task.external_sync"
OPERATIONAL_TASK_ACCESS_APPROVE = "operational_task.access.approve"
OPERATIONAL_ACTION_WORK_ORDER_APPROVE = "operational_action.work_order.approve"
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE = "operational_action.work_order.execute"
OPERATIONAL_ACTION_COMMAND_APPROVE = "operational_action.command.approve"
OPERATIONAL_ACTION_COMMAND_EXECUTE = "operational_action.command.execute"
PROJECT_MEMBER_PERMISSIONS = frozenset(
{
@@ -36,6 +45,11 @@ PROJECT_MEMBER_PERMISSIONS = frozenset(
RISK_RUN,
OPTIMIZATION_VIEW,
OPTIMIZATION_RUN,
OPERATIONAL_TASK_VIEW,
OPERATIONAL_TASK_WRITE,
OPERATIONAL_TASK_ACCESS_APPROVE,
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
}
)
@@ -44,6 +58,7 @@ PROJECT_VIEWER_PERMISSIONS = frozenset(
WEBGIS_VIEW,
SCADA_VIEW,
SIMULATION_VIEW,
OPERATIONAL_TASK_VIEW,
}
)
@@ -53,6 +68,15 @@ SYSTEM_ADMIN_PERMISSIONS = frozenset(
AUDIT_VIEW,
ENVIRONMENT_MANAGE,
MEMBERSHIP_MANAGE,
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC,
}
)
SYSTEM_SERVICE_PERMISSIONS = frozenset(
{
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC,
}
)
@@ -71,6 +95,15 @@ def resolve_permissions(
permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset()))
if is_superuser or system_role == "admin":
permissions.update(SYSTEM_ADMIN_PERMISSIONS)
if project_role == "member":
permissions.update(
{
OPERATIONAL_ACTION_COMMAND_APPROVE,
OPERATIONAL_ACTION_COMMAND_EXECUTE,
}
)
elif system_role == "service" and project_role == "member":
permissions.update(SYSTEM_SERVICE_PERMISSIONS)
return frozenset(permissions)
+2
View File
@@ -129,6 +129,7 @@ async def resolve_project_business_routing(
return ActiveProjectRouting(
project_code=ctx.project_code,
business_dsn=business.dsn,
project_id=ctx.project_id,
)
@@ -161,6 +162,7 @@ async def resolve_project_simulation_routing(
project_code=ctx.project_code,
business_dsn=business.dsn,
timescale_dsn=timescale.dsn,
project_id=ctx.project_id,
)
+28 -7
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from typing import Optional
from urllib.parse import quote_plus
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -9,7 +10,6 @@ class Settings(BaseSettings):
ENVIRONMENT: str = "production"
API_V1_STR: str = "/api/v1"
NETWORK_NAME: str = "default_network"
# 敏感配置加密密钥 (Fernet)
DATABASE_ENCRYPTION_KEY: str = ""
@@ -26,6 +26,9 @@ class Settings(BaseSettings):
TIMESCALEDB_DB_PORT: str = "5433"
TIMESCALEDB_DB_USER: str = "postgres"
TIMESCALEDB_DB_PASSWORD: str = "password"
TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES: int = Field(
default=1_000_000, ge=1, le=2_147_483_647,
)
# Metadata Database Config (PostgreSQL)
METADATA_DB_NAME: str = "system_hub"
METADATA_DB_HOST: str = "localhost"
@@ -63,16 +66,34 @@ class Settings(BaseSettings):
KEYCLOAK_AUDIENCE: str = ""
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900
# Bocha Web Search API
BOCHA_API_KEY: str = ""
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0
# Authenticated workbench synchronization.
WORKBENCH_EVENTS_ENABLED: bool = True
WORKBENCH_EVENT_RETENTION_DAYS: int = 7
CORS_ALLOWED_ORIGINS: str = (
"http://127.0.0.1:5173,http://localhost:5173,"
"http://127.0.0.1:15173,http://localhost:15173"
)
# Tianditu Geocoding API
TIANDITU_GEOCODER_TOKEN: str = ""
@property
def cors_allowed_origins(self) -> list[str]:
origins = [value.strip() for value in self.CORS_ALLOWED_ORIGINS.split(",") if value.strip()]
if "*" in origins:
raise ValueError("CORS_ALLOWED_ORIGINS must list explicit origins")
return origins
# One shared Tianditu key for geocoding, place search and other services.
TIANDITU_TOKEN: str = Field(
"",
validation_alias=AliasChoices(
"TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"
),
)
TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0
TIANDITU_SEARCH_URL: str = "https://api.tianditu.gov.cn/v2/search"
TIANDITU_SEARCH_TIMEOUT_SECONDS: float = 15.0
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
db_password = quote_plus(self.DB_PASSWORD)
+17
View File
@@ -0,0 +1,17 @@
import logging
import re
class TiandituTokenFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
if "tianditu.gov.cn" in message:
record.msg = re.sub(r"([?&]tk=)[^&\s\"']+", r"\1[redacted]", message)
record.args = ()
return True
def install_tianditu_token_filter() -> None:
logger = logging.getLogger("httpx")
if not any(isinstance(item, TiandituTokenFilter) for item in logger.filters):
logger.addFilter(TiandituTokenFilter())
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
class DomainError(Exception):
"""A domain rejection with a stable API error payload."""
def __init__(self, code: str, message: str, *, refs: list | None = None,
recovery: str = "none", status: int = 422):
super().__init__(message)
self.code, self.message, self.status = code, message, status
self.refs, self.recovery = refs or [], recovery
def payload(self) -> dict:
return {"code": self.code, "message": self.message,
"retryable": self.recovery == "retry_same_request",
"related_refs": self.refs, "recovery_action": self.recovery,
"retry_after_seconds": None}
def require(condition: bool, code: str, message: str, **kwargs) -> None:
if not condition:
raise DomainError(code, message, **kwargs)
+6 -2
View File
@@ -4,8 +4,10 @@ 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", "user"]
BusinessRole = Literal["admin", "service", "user"]
ProjectRole = Literal["member", "viewer"]
ProjectStatus = Literal["active", "inactive", "archived"]
ProjectDbRole = Literal["biz_data", "iot_data"]
@@ -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
+59
View File
@@ -0,0 +1,59 @@
"""Private, durable messages between an ordinary chat and one investigation run."""
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field
from app.domain.schemas.shared import NonEmptyStr, StrictModel
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
MessageText = Annotated[str, Field(min_length=1, max_length=12000, pattern=r"\S")]
MessageStatus = Literal["pending", "enqueued", "delivered", "failed"]
class AgentMessageCreate(StrictModel):
request_id: UUID
task_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
text: MessageText
class AgentMessageReply(StrictModel):
request_id: UUID
source_session_id: NonEmptyStr
text: MessageText
class AgentMessageDelivery(StrictModel):
status: Literal["enqueued", "delivered", "failed"]
target_session_id: NonEmptyStr | None = None
native_message_id: NonEmptyStr | None = None
failure_detail: Annotated[str, Field(max_length=500)] | None = None
class AgentMessage(StrictModel):
project_id: UUID
message_id: UUID
request_id: UUID
task_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
source_owner_id: UUID
conversation_owner_id: UUID
target_session_id: NonEmptyStr | None = None
direction: Literal["to_task", "to_chat"]
in_reply_to: UUID | None = None
text: MessageText
status: MessageStatus
native_message_id: NonEmptyStr
failure_detail: str | None = None
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
delivered_at: WorkbenchDateTime | None = None
class AgentMessagePage(StrictModel):
items: list[AgentMessage]
next_cursor: str | None = None
+99
View File
@@ -0,0 +1,99 @@
"""Validated, local presentation blocks. Component props come from the frontend catalog."""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Annotated, Any, Literal
from jsonschema import Draft7Validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
_catalog = json.loads(
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
)
_validators = {name: Draft7Validator(schema) for name, schema in _catalog["props"].items()}
Key = Annotated[str, Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")]
class UiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class AnswerUiElement(UiModel):
type: str
props: dict[str, Any]
children: list[Key] = Field(max_length=48)
class AnswerUiSpec(UiModel):
root: Key
elements: dict[Key, AnswerUiElement]
class AnswerUiBlock(UiModel):
version: Literal["answer-ui@1"]
block_id: Key
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
spec: AnswerUiSpec
@model_validator(mode="after")
def validate_components(self):
spec = self.spec
if not 1 <= len(spec.elements) <= 48:
raise ValueError("UI requires 1 to 48 elements")
# Match the existing JavaScript serialized character limit, including UTF-16.
serialized = json.dumps(self.model_dump(), ensure_ascii=False, separators=(",", ":"))
if len(serialized.encode("utf-16-le")) // 2 > 100000:
raise ValueError("UI block is too large")
for element in spec.elements.values():
validator = _validators.get(element.type)
if validator is None or not validator.is_valid(element.props):
raise ValueError("Unknown UI component or invalid properties")
if element.children and element.type not in {"Stack", "Section"}:
raise ValueError("Leaf components cannot contain children")
props = element.props
if element.type == "Chart":
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
raise ValueError("Chart series must match the axis")
if any(not math.isfinite(value) for series in props["series"] for value in series["data"]):
raise ValueError("Chart values must be finite")
if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]):
raise ValueError("Table row width must match its columns")
if element.type == "FollowUpForm":
names = [field["name"] for field in props["fields"]]
if len(names) != len(set(names)):
raise ValueError("Form field names must be unique")
visited: set[str] = set()
def visit(key: str, depth: int):
if depth > 12 or key in visited or key not in spec.elements:
raise ValueError("UI must be a bounded tree without duplicate or missing references")
visited.add(key)
for child in spec.elements[key].children:
visit(child, depth + 1)
visit(spec.root, 0)
if len(visited) != len(spec.elements):
raise ValueError("UI contains unreachable elements")
return self
class ReportUiBlock(UiModel):
block: AnswerUiBlock
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
ids = [item.block.block_id for item in blocks]
if len(ids) != len(set(ids)):
raise ValueError("Report UI block ids must be unique")
serialized = json.dumps([item.model_dump() for item in blocks], ensure_ascii=False, separators=(",", ":"))
if len(serialized.encode("utf-8")) > 400000:
raise ValueError("Report UI exceeds 400 KB")
for item in blocks:
if not set(item.evidence_refs) <= set(evidence_refs):
raise ValueError("UI evidence must be included in report evidence")
for element in item.block.spec.elements.values():
if element.type == "Evidence" and element.props.get("evidence_id") not in item.evidence_refs:
raise ValueError("UI evidence component must reference its block evidence")
+47
View File
@@ -0,0 +1,47 @@
"""Schemas for project-scoped source GIS attributes."""
from datetime import date
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
GisElementType = Literal[
"pipes",
"junctions",
"valves",
"reservoirs",
"pumps",
"tanks",
"scada",
]
class GisAttributeGroup(BaseModel):
model_config = ConfigDict(extra="forbid")
dataset_key: str
dataset_title: str
source_date: date
layer_key: str
layer_name: str
source_fid: int
match_method: Literal["node_pair", "geometry", "source"]
match_distance_m: float | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
class ElementGisProperties(BaseModel):
model_config = ConfigDict(extra="forbid")
element_type: GisElementType
element_id: str
groups: list[GisAttributeGroup] = Field(default_factory=list)
class SourceGisFeatureProperties(BaseModel):
model_config = ConfigDict(extra="forbid")
layer_key: str
source_fid: int
group: GisAttributeGroup
+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
+116
View File
@@ -0,0 +1,116 @@
"""Finite read scopes available to operational investigations."""
from datetime import timedelta
from typing import Literal
from pydantic import Field, JsonValue, model_validator
from app.domain.schemas.shared import NonEmptyStr, StrictModel
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
TaskDataSource = Literal[
"workbench_runtime", "scada_devices", "scada_readings", "peripheral_points",
"peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
]
McpSnapshotSource = Literal[
'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',
'analyze_flushing',
'analyze_pressure_regulation',
'optimize_sensor_placement',
'run_burst_detection',
'run_burst_location',
'analyze_valve_isolation',
'run_leakage_identification',
'get_element_properties',
'list_elements',
'get_network_options',
'get_network_schema',
'list_scada_devices',
'get_scada_device',
'query_scada_readings',
'query_model_baseline_series',
'get_model_baseline_at_time',
'query_model_baseline_field',
'query_analysis_series',
'query_analysis_values',
'list_analysis_runs',
'get_analysis_run',
'list_analysis_results',
'list_sensor_placement_runs',
'get_sensor_placement_run',
'query_element_history',
'get_element_gis_properties',
'get_source_gis_properties',
'get_workbench_runtime',
'get_task_context',
'query_peripheral_data',
'get_task_evidence',
]
TaskSnapshotSource = TaskDataSource | McpSnapshotSource
class TaskDataQuery(StrictModel):
source: TaskDataSource
device_ids: list[NonEmptyStr] = Field(default_factory=list, max_length=20)
point_id: NonEmptyStr | None = None
start: WorkbenchDateTime | None = None
end: WorkbenchDateTime | None = None
@model_validator(mode="after")
def validate_window(self):
if self.source in {"scada_readings", "peripheral_observations"}:
if self.start is None or self.end is None:
raise ValueError("time series queries require start and end")
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
raise ValueError("query window must be positive and at most 24 hours")
if self.source == "scada_readings" and not self.device_ids:
raise ValueError("SCADA queries require device_ids")
if self.source == "peripheral_observations" and not self.point_id:
raise ValueError("peripheral queries require point_id")
return self
class TaskDataSnapshot(StrictModel):
source: TaskSnapshotSource
source_ref: NonEmptyStr
queried_at: WorkbenchDateTime
origin: Literal["system_data", "synthetic"]
data: JsonValue
truncated: bool
+883
View File
@@ -0,0 +1,883 @@
"""Contracts for project-scoped operational tasks and human-approved actions."""
from __future__ import annotations
import re
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, JsonValue, model_validator
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.domain.schemas.workbench_runtime import (
WorkbenchDateTime,
WorkOrderActivity,
)
TaskType = Literal["scheduled_flow", "external_work_order", "event_investigation"]
TaskStatus = Literal[
"queued", "running", "waiting_human", "completed", "failed", "cancelled"
]
TaskLifecycleStatus = Literal[
"open", "waiting_human", "completed", "failed", "cancelled"
]
OperationalTaskRunStatus = Literal[
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
]
TaskPriority = Literal["normal", "urgent"]
TaskSourceKind = Literal["schedule", "external_system", "operator", "agent"]
TriggerKind = Literal["schedule", "external_sync", "event", "manual"]
EvidenceKind = Literal["system_data", "external_feedback", "human_input", "synthetic"]
PlanNodeStatus = Literal[
"pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"
]
ActionKind = Literal["create_work_order", "issue_command"]
ApprovalStatus = Literal["pending", "approved", "rejected"]
ExecutionStatus = Literal[
"not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"
]
class TaskSource(StrictModel):
kind: TaskSourceKind
external_id: NonEmptyStr | None = None
external_revision: PositiveInt | None = None
event_kind: NonEmptyStr | None = None
deleted_at: WorkbenchDateTime | None = None
class TaskTrigger(StrictModel):
kind: TriggerKind
occurred_at: WorkbenchDateTime
detail: NonEmptyStr
class OperationalTaskCreate(StrictModel):
request_id: UUID
task_type: TaskType
workflow: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
priority: TaskPriority = "normal"
source: TaskSource
trigger: TaskTrigger
scheduled_for: WorkbenchDateTime | None = None
agent_session_id: NonEmptyStr | None = None
class ExecutionPlanNode(StrictModel):
id: NonEmptyStr
title: NonEmptyStr
owner: Literal["agent", "human"]
status: PlanNodeStatus
detail: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
kind: Literal["activity", "decision", "parallel_split", "parallel_join", "gate"] | None = None
selected_edge_id: NonEmptyStr | None = None
gate_ref: UUID | None = None
revisits_node_id: NonEmptyStr | None = None
class ExecutionPlanEdge(StrictModel):
id: NonEmptyStr | None = None
from_node: NonEmptyStr = Field(alias="from")
to_node: NonEmptyStr = Field(alias="to")
condition: NonEmptyStr | None = None
class ExecutionPlanInput(StrictModel):
schema_name: Literal["execution-plan@1", "execution-plan@2"] = Field(alias="schema")
title: NonEmptyStr
reason: NonEmptyStr
nodes: Annotated[list[ExecutionPlanNode], Field(min_length=1, max_length=100)]
edges: Annotated[list[ExecutionPlanEdge], Field(max_length=300)] = Field(
default_factory=list
)
@model_validator(mode="after")
def validate_graph(self):
node_ids = [node.id for node in self.nodes]
if len(set(node_ids)) != len(node_ids):
raise ValueError("execution plan node ids must be unique")
known = set(node_ids)
graph = {node_id: [] for node_id in node_ids}
indegree = {node_id: 0 for node_id in node_ids}
seen_edges: set[tuple[str, str]] = set()
edge_ids: set[str] = set()
for edge in self.edges:
pair = (edge.from_node, edge.to_node)
if edge.from_node not in known or edge.to_node not in known:
raise ValueError("execution plan edges must reference known nodes")
if edge.from_node == edge.to_node or pair in seen_edges:
raise ValueError("execution plan edges must be unique and non-reflexive")
seen_edges.add(pair)
if self.schema_name == "execution-plan@2":
if edge.id is None or edge.id in edge_ids:
raise ValueError("v2 execution plan edge ids must be unique")
edge_ids.add(edge.id)
graph[edge.from_node].append(edge.to_node)
indegree[edge.to_node] += 1
pending = [node_id for node_id, value in indegree.items() if value == 0]
visited = 0
while pending:
current = pending.pop()
visited += 1
for target in graph[current]:
indegree[target] -= 1
if indegree[target] == 0:
pending.append(target)
if visited != len(node_ids):
raise ValueError("execution plan must be acyclic")
if self.schema_name == "execution-plan@2" and sum(
not any(edge.to_node == node_id for edge in self.edges) for node_id in node_ids
) != 1:
raise ValueError("v2 execution plan requires one start node")
if self.schema_name == "execution-plan@2":
by_id = {node.id: node for node in self.nodes}
for node in self.nodes:
if node.kind is None:
raise ValueError("v2 execution plan nodes require kind")
outgoing = [edge for edge in self.edges if edge.from_node == node.id]
incoming = [edge for edge in self.edges if edge.to_node == node.id]
if node.kind == "decision":
if len(outgoing) < 2 or any(not edge.condition for edge in outgoing):
raise ValueError("decision requires at least two labeled routes")
if node.selected_edge_id is not None and node.selected_edge_id not in {edge.id for edge in outgoing}:
raise ValueError("decision selected_edge_id must reference an outgoing route")
if node.status == "completed" and node.selected_edge_id is None:
raise ValueError("completed decision requires a selected route")
if node.selected_edge_id is not None:
for edge in outgoing:
target_status = by_id[edge.to_node].status
if edge.id == node.selected_edge_id and target_status in {"skipped", "cancelled"}:
raise ValueError("selected route cannot be skipped")
if edge.id != node.selected_edge_id and target_status in {"running", "completed", "waiting"}:
raise ValueError("unchosen route cannot be active")
elif node.selected_edge_id is not None:
raise ValueError("only decision nodes may select routes")
if node.kind not in {"decision", "parallel_split"} and len(outgoing) > 1:
raise ValueError("only decision or parallel split may branch")
if node.kind == "parallel_split" and len(outgoing) < 2:
raise ValueError("parallel split requires at least two routes")
if node.kind == "parallel_join" and len(incoming) < 2:
raise ValueError("parallel join requires at least two inputs")
if node.kind == "gate" and node.gate_ref is None:
raise ValueError("gate requires a persisted reference")
if node.kind != "gate" and node.gate_ref is not None:
raise ValueError("only gate nodes may have gate_ref")
if node.revisits_node_id is not None and node.revisits_node_id not in by_id:
raise ValueError("revisited node must exist in the plan")
return self
class EvidenceInput(StrictModel):
evidence_id: NonEmptyStr
kind: EvidenceKind
summary: NonEmptyStr
source_label: NonEmptyStr
source_ref: NonEmptyStr | None = None
observed_at: WorkbenchDateTime
class EvidenceRecord(EvidenceInput):
recorded_at: WorkbenchDateTime
recorded_by: NonEmptyStr
has_snapshot: bool = False
class StoredEvidenceRecord(EvidenceRecord):
data_snapshot: TaskDataSnapshot | None = None
class InvestigationDataGap(StrictModel):
gap_id: NonEmptyStr
node_id: NonEmptyStr | None = None
missing_data: NonEmptyStr
scope: NonEmptyStr
impact: NonEmptyStr
required_data: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(min_length=1)
blocking: StrictBool
TaskMapLayer = Literal["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]
TaskFeatureId = Annotated[str, Field(min_length=1, max_length=128)]
class InvestigationMapTarget(StrictModel):
source_id: TaskMapLayer
feature_id: TaskFeatureId
class InvestigationDiagramNodeRef(StrictModel):
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
label: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
status: Literal[
"pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"
] | None = None
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
class InvestigationDiagram(StrictModel):
title: NonEmptyStr
mermaid: Annotated[str, Field(min_length=1, max_length=20000)]
node_refs: list[InvestigationDiagramNodeRef] = Field(default_factory=list, max_length=100)
@model_validator(mode="after")
def validate_description(self):
if not re.match(r"^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b", self.mermaid):
raise ValueError("investigation diagrams must be Mermaid flowcharts")
if "```" in self.mermaid or "%%{" in self.mermaid or re.search(r"(?:^|[;\n])\s*click\b", self.mermaid):
raise ValueError("diagram descriptions cannot contain fences, directives or click commands")
ids = [node.node_id for node in self.node_refs]
if len(ids) != len(set(ids)):
raise ValueError("diagram node references must be unique")
return self
class InvestigationReport(StrictModel):
"""Read model: absent historical facts remain unknown, never inferred."""
title: NonEmptyStr
current_conclusion: NonEmptyStr
conclusion_boundary: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
recommendations: list[NonEmptyStr] = Field(default_factory=list)
pending_items: list[NonEmptyStr] = Field(default_factory=list)
stage: Literal["interim", "final"] | None = None
outcome: Literal["concluded", "insufficient_data"] | None = None
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")
return self
if self.stage is None or self.data_gaps is None:
raise ValueError("structured reports require stage and data_gaps")
ids = [gap.gap_id for gap in self.data_gaps]
if len(ids) != len(set(ids)):
raise ValueError("data gap ids must be unique")
blocking = any(gap.blocking for gap in self.data_gaps)
if self.outcome == "insufficient_data":
if self.stage != "final" or not self.stop_reason or not blocking:
raise ValueError("insufficient data requires a final report, stop reason and blocking gap")
if self.recommendations or self.pending_items:
raise ValueError("insufficient data ends the investigation without downstream recommendations or pending work")
elif self.stop_reason is not None or blocking:
raise ValueError("concluded report cannot contain a stop reason or blocking gap")
return self
class InvestigationReportInput(InvestigationReport):
"""Write model: every new report explicitly declares its business result."""
stage: Literal["interim", "final"]
outcome: Literal["concluded", "insufficient_data"]
data_gaps: list[InvestigationDataGap]
class InvestigationProgress(StrictModel):
"""A complete reading snapshot, not a plan or a business result."""
summary: Annotated[str, Field(min_length=1, max_length=20000)]
diagram: InvestigationDiagram | None = None
class InvestigationProgressSnapshot(StrictModel):
revision: PositiveInt
run_id: UUID
execution_generation: PositiveInt
published_at: WorkbenchDateTime
source_session_id: NonEmptyStr
progress: InvestigationProgress
class ExecutionPlanSnapshot(StrictModel):
revision: PositiveInt
execution_generation: PositiveInt | None = None
source_session_id: NonEmptyStr
published_at: WorkbenchDateTime
published_by: NonEmptyStr
plan: ExecutionPlanInput
class InvestigationReportRevision(StrictModel):
revision: PositiveInt
execution_generation: PositiveInt | None = None
source_session_id: NonEmptyStr
published_at: WorkbenchDateTime
published_by: NonEmptyStr
report: InvestigationReport
class InvestigationReportPage(StrictModel):
items: list[InvestigationReportRevision]
next_offset: int | None = Field(default=None, ge=0)
class InvestigationReportReference(StrictModel):
execution_generation: PositiveInt
revision: PositiveInt
class AgentArtifactsCommand(StrictModel):
request_id: UUID
source_session_id: NonEmptyStr
run_id: UUID | None = None
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
plan: ExecutionPlanInput | None = None
report: InvestigationReportInput | None = None
progress: InvestigationProgress | None = None
@model_validator(mode="after")
def require_artifact(self):
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
raise ValueError("at least one artifact must be supplied")
return self
class TaskAccessRequestCreate(StrictModel):
request_id: UUID
source_session_id: NonEmptyStr
node_id: NonEmptyStr
scopes: Annotated[list[TaskDataSource], Field(min_length=1, max_length=7)]
reason: NonEmptyStr
class TaskAccessRequestDecision(StrictModel):
request_id: UUID
expected_version: PositiveInt
decision: Literal["approved", "rejected"]
approved_scopes: list[TaskDataSource] = Field(default_factory=list)
comment: NonEmptyStr | None = None
class TaskAccessRequest(StrictModel):
access_request_id: UUID
task_id: UUID
node_id: NonEmptyStr
scopes: list[TaskDataSource]
reason: NonEmptyStr
status: Literal["pending", "approved", "rejected"]
approved_scopes: list[TaskDataSource]
version: PositiveInt
requested_by: NonEmptyStr
requested_at: WorkbenchDateTime
decided_by: NonEmptyStr | None = None
decided_at: WorkbenchDateTime | None = None
comment: NonEmptyStr | None = None
class CreateWorkOrderPayload(StrictModel):
title: NonEmptyStr
description: NonEmptyStr
work_order_type: NonEmptyStr
priority: TaskPriority = "normal"
scheduled_at: WorkbenchDateTime | None = None
location: NonEmptyStr | None = None
assignee: NonEmptyStr | None = None
class CommandTarget(StrictModel):
kind: NonEmptyStr
id: NonEmptyStr
class IssueCommandPayload(StrictModel):
command_type: NonEmptyStr
target: CommandTarget
parameters: dict[str, JsonValue]
impact_summary: NonEmptyStr
preconditions: list[NonEmptyStr] = Field(min_length=1)
expires_at: WorkbenchDateTime
ActionPayload = CreateWorkOrderPayload | IssueCommandPayload
class ActionProposalCommand(StrictModel):
request_id: UUID
kind: ActionKind
reason: NonEmptyStr
payload: ActionPayload
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
node_id: NonEmptyStr | None = None
@model_validator(mode="after")
def validate_payload_kind(self):
expected = CreateWorkOrderPayload if self.kind == "create_work_order" else IssueCommandPayload
if not isinstance(self.payload, expected):
raise ValueError("action payload does not match its kind")
return self
class ActionDecisionCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
decision: Literal["approved", "rejected"]
comment: NonEmptyStr | None = None
class WorkOrderDraftUpdateCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
payload: CreateWorkOrderPayload
class WorkOrderConfirmDispatchCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
class ActionExecuteCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
class ActionReconcileCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
class ActionReceipt(StrictModel):
adapter: NonEmptyStr
receipt_id: NonEmptyStr
submitted_at: WorkbenchDateTime
completed_at: WorkbenchDateTime | None = None
external_status: NonEmptyStr
detail: NonEmptyStr
class HumanApprovalAction(StrictModel):
action_id: UUID
task_id: UUID
version: PositiveInt
kind: ActionKind
reason: NonEmptyStr
payload: ActionPayload
evidence_refs: list[NonEmptyStr]
node_id: NonEmptyStr | None = None
required_approval_permission: NonEmptyStr
required_execution_permission: NonEmptyStr
approval_status: ApprovalStatus
execution_status: ExecutionStatus
execution_request_id: UUID | None = None
proposed_by: NonEmptyStr
proposed_at: WorkbenchDateTime
edited_by: NonEmptyStr | None = None
edited_at: WorkbenchDateTime | None = None
decided_by: NonEmptyStr | None = None
decided_at: WorkbenchDateTime | None = None
decision_comment: NonEmptyStr | None = None
executed_by: NonEmptyStr | None = None
executed_at: WorkbenchDateTime | None = None
receipt: ActionReceipt | None = None
execution_detail: NonEmptyStr | None = None
class ExternalWorkOrderSnapshot(StrictModel):
work_order_id: UUID
revision: PositiveInt
title: NonEmptyStr
summary: NonEmptyStr
status: Literal[
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
]
priority: Literal["low", "normal", "high", "urgent"]
assignee: NonEmptyStr | None = None
scheduled_at: WorkbenchDateTime | None = None
work_order_type: Literal["general", "water_quality_complaint", "burst_report"] = "general"
location: NonEmptyStr | None = None
scenario_code: NonEmptyStr | None = None
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
activity: list[WorkOrderActivity] = Field(default_factory=list)
origin: Literal["synthetic"]
updated_at: WorkbenchDateTime
@model_validator(mode="before")
@classmethod
def accept_legacy_snapshot(cls, value):
if isinstance(value, dict):
return {key: item for key, item in value.items() if key != "subtasks"}
return value
class OperationalTaskRun(StrictModel):
run_id: UUID
task_id: UUID
generation: PositiveInt
execution_request_id: UUID
status: OperationalTaskRunStatus
revision: PositiveInt
agent_session_id: NonEmptyStr | None = None
lease_expires_at: WorkbenchDateTime | None = None
heartbeat_at: WorkbenchDateTime | None = None
started_at: WorkbenchDateTime | None = None
finished_at: WorkbenchDateTime | None = None
failure_code: NonEmptyStr | None = None
failure_detail: NonEmptyStr | None = None
dispatch_attempts: int = Field(default=0, ge=0)
next_dispatch_at: WorkbenchDateTime
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
class OperationalTaskSummary(StrictModel):
project_id: UUID
task_id: UUID
task_type: TaskType
workflow: NonEmptyStr
lifecycle_status: TaskLifecycleStatus = "open"
active_run: OperationalTaskRun | None = None
latest_run: OperationalTaskRun | None = None
# Compatibility fields for the staged 014 -> 015 rollout. New consumers
# must use lifecycle_status and active_run.
status: TaskStatus
priority: TaskPriority
revision: PositiveInt
execution_generation: PositiveInt
execution_request_id: UUID
title: NonEmptyStr
summary: NonEmptyStr
source: TaskSource
trigger: TaskTrigger
scheduled_for: WorkbenchDateTime | None
agent_session_id: NonEmptyStr | None
agent_lease_expires_at: WorkbenchDateTime | None
current_plan_revision: PositiveInt | None
current_report_revision: PositiveInt | None
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
class OperationalTaskPermissions(StrictModel):
can_publish_artifacts: bool
can_propose_actions: bool
can_sync_external_feedback: bool
can_decide_work_orders: bool
can_execute_work_orders: bool
can_decide_commands: bool
can_execute_commands: bool
can_decide_access: bool = False
class OperationalTaskDetail(OperationalTaskSummary):
latest_plan: ExecutionPlanSnapshot | None
latest_report: InvestigationReportRevision | None
latest_progress: InvestigationProgressSnapshot | None = None
evidence: list[EvidenceRecord]
actions: list[HumanApprovalAction]
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
permissions: OperationalTaskPermissions
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
class OperationalTaskPage(StrictModel):
items: list[OperationalTaskSummary]
limit: PositiveInt
offset: int = Field(ge=0)
next_offset: int | None = Field(default=None, ge=0)
class OperationalTaskRunPage(StrictModel):
items: list[OperationalTaskRun]
next_cursor: NonEmptyStr | None = None
class AgentTaskContext(StrictModel):
task: OperationalTaskDetail
approved_data_scopes: list[NonEmptyStr]
selected_report: InvestigationReportRevision | None = None
class AgentRunCommand(StrictModel):
request_id: UUID
expected_revision: PositiveInt
source_session_id: NonEmptyStr
class AgentRunFailureCommand(AgentRunCommand):
failure_code: NonEmptyStr
failure_detail: NonEmptyStr
class AgentRunCancelCommand(StrictModel):
request_id: UUID
expected_revision: PositiveInt
reason: NonEmptyStr
class AgentRunDispatchFailureCommand(StrictModel):
request_id: UUID
expected_revision: PositiveInt
failure_code: NonEmptyStr
failure_detail: NonEmptyStr
retryable: bool = True
class OperationalTaskRetryCommand(StrictModel):
request_id: UUID
reason: NonEmptyStr
class RecoverExpiredLeasesCommand(StrictModel):
limit: int = Field(default=20, ge=1, le=100)
task_type: TaskType | None = None
task_created_from: WorkbenchDateTime | None = None
class RecoverExpiredLeasesResult(StrictModel):
task_ids: list[UUID]
recovered_at: WorkbenchDateTime
class AgentArtifactsResult(StrictModel):
task: OperationalTaskDetail
plan_revision: PositiveInt | None
report_revision: PositiveInt | None
progress_revision: PositiveInt | None = None
class TaskSafeChartSeries(StrictModel):
name: Annotated[str, Field(max_length=128)]
data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)]], Field(min_length=1, max_length=100)]
class TaskSafeChart(StrictModel):
title: Annotated[str, Field(max_length=200)]
chart_type: Literal["line", "bar"]
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
series: Annotated[list[TaskSafeChartSeries], Field(min_length=1, max_length=8)]
@model_validator(mode="after")
def validate_alignment(self):
if any(len(series.data) != len(self.x_data) for series in self.series):
raise ValueError("chart series must align with x_data")
return self
class TaskHighlightFeatures(StrictModel):
action: Literal["highlight_features"]
layer: TaskMapLayer
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
effect: Literal["steady", "pulse"] = "steady"
class TaskClearHighlights(StrictModel):
action: Literal["clear_highlights"]
class TaskOpenChart(StrictModel):
action: Literal["open_chart"]
chart_id: TaskFeatureId
chart: TaskSafeChart
evidence_refs: Annotated[list[NonEmptyStr], Field(min_length=1, max_length=100)]
class TaskCloseChart(StrictModel):
action: Literal["close_chart"]
chart_id: TaskFeatureId
class TaskSetOverlay(StrictModel):
action: Literal["set_overlay"]
overlay_id: TaskFeatureId
title: Annotated[str, Field(min_length=1, max_length=200)]
mode: Literal["geometry", "heatmap"]
evidence_id: NonEmptyStr
class TaskClearOverlay(StrictModel):
action: Literal["clear_overlay"]
overlay_id: TaskFeatureId
TaskPresentationCommand = Annotated[
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
Field(discriminator="action"),
]
class TaskPresentationEventCommand(StrictModel):
request_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
command: TaskPresentationCommand
class TaskPresentationEventResult(StrictModel):
recorded: Literal[True] = True
event_seq: PositiveInt
run_id: UUID
execution_generation: PositiveInt
class TaskPresentationActiveRun(StrictModel):
run_id: UUID
generation: PositiveInt
status: OperationalTaskRunStatus
class TaskPresentationCursor(StrictModel):
task_id: UUID
execution_generation: PositiveInt
active_run: TaskPresentationActiveRun | None
event_cursor: int = Field(ge=0)
class TaskDataQueryResult(StrictModel):
evidence: EvidenceRecord
snapshot: TaskDataSnapshot
class ExternalFeedbackEvidence(StrictModel):
evidence_id: NonEmptyStr
kind: Literal["external_feedback", "synthetic"] = "external_feedback"
summary: NonEmptyStr
source_label: NonEmptyStr
source_ref: NonEmptyStr | None = None
observed_at: WorkbenchDateTime
class ExternalFeedbackCommand(StrictModel):
request_id: UUID
source_event_id: NonEmptyStr
source_revision: PositiveInt
status: Literal[
"scheduled", "assigned", "in_progress", "awaiting_feedback",
"completed", "cancelled", "failed"
]
occurred_at: WorkbenchDateTime
detail: NonEmptyStr
evidence: ExternalFeedbackEvidence | None = None
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
@model_validator(mode="after")
def snapshot_matches_feedback_revision(self):
if self.work_order_snapshot is not None:
work_order_id = str(self.work_order_snapshot.work_order_id)
if self.source_event_id not in {
work_order_id, f"peripheral_sample:{work_order_id}"
}:
raise ValueError("work_order_snapshot id must match source_event_id")
if self.work_order_snapshot.revision != self.source_revision:
raise ValueError("work_order_snapshot revision must match source_revision")
expected_status = {
"draft": "scheduled",
"open": "assigned",
"in_progress": "in_progress",
"awaiting_reply": "awaiting_feedback",
"completed": "completed",
"cancelled": "cancelled",
}[self.work_order_snapshot.status]
if self.status != expected_status:
raise ValueError("work_order_snapshot status must match feedback status")
return self
class ExternalFeedbackResult(StrictModel):
task: OperationalTaskDetail
source_event_id: NonEmptyStr
source_revision: PositiveInt
accepted_at: WorkbenchDateTime
class ExternalSourceWithdrawalCommand(StrictModel):
request_id: UUID
source_event_id: NonEmptyStr
class HumanEvidenceCommand(StrictModel):
request_id: UUID
evidence: EvidenceInput
class OperationalTaskNotification(StrictModel):
notification_id: UUID
task_id: UUID
kind: Literal[
"task_created", "action_pending", "report_updated", "task_completed",
"task_failed", "run_delivery_degraded", "access_approval_pending",
"action_approval_pending", "human_input_required",
"external_coordination_failed"
]
title: NonEmptyStr
description: NonEmptyStr
required_permission: NonEmptyStr | None = None
lifecycle_status: Literal["active", "resolved"] = "active"
subject_type: Literal["task", "run", "access_request", "action"] = "task"
subject_id: UUID | None = None
report_reference: InvestigationReportReference | None = None
resolved_at: WorkbenchDateTime | None = None
resolution_reason: NonEmptyStr | None = None
status: Literal["unread", "read"]
version: PositiveInt
created_at: WorkbenchDateTime
read_at: WorkbenchDateTime | None = None
read_by: NonEmptyStr | None = None
class OperationalTaskNotificationPage(StrictModel):
items: list[OperationalTaskNotification]
limit: PositiveInt
offset: int = Field(ge=0)
next_offset: int | None = Field(default=None, ge=0)
class NotificationAcknowledgeCommand(StrictModel):
request_id: UUID
expected_version: PositiveInt
class OperationalTaskScheduleCreate(StrictModel):
request_id: UUID
schedule_key: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
interval_seconds: int = Field(ge=60, le=31_536_000)
next_run_at: WorkbenchDateTime
priority: TaskPriority = "normal"
class OperationalTaskSchedule(StrictModel):
schedule_id: UUID
schedule_key: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
interval_seconds: int = Field(ge=60, le=31_536_000)
next_run_at: WorkbenchDateTime
priority: TaskPriority
enabled: bool
version: PositiveInt
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
class OperationalTaskSchedulePage(StrictModel):
items: list[OperationalTaskSchedule]
class OperationalTaskScheduleUpdate(StrictModel):
request_id: UUID
expected_version: PositiveInt
enabled: bool
class DispatchDueSchedulesCommand(StrictModel):
limit: int = Field(default=20, ge=1, le=100)
class DispatchDueSchedulesResult(StrictModel):
task_ids: list[UUID]
dispatched_at: WorkbenchDateTime
+33
View File
@@ -0,0 +1,33 @@
from pydantic import BaseModel, ConfigDict, Field
MAX_PLACE_SEARCH_RESULTS = 10
class PlaceSearchRequest(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
keyword: str = Field(..., min_length=1, max_length=120)
class 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)
address: str
longitude: float = Field(..., ge=-180, le=180, allow_inf_nan=False)
latitude: float = Field(..., ge=-90, le=90, allow_inf_nan=False)
category: str | None = None
district: 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: 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
+3 -1
View File
@@ -25,7 +25,7 @@ class SensorPlacementOptimizeRequest(BaseModel):
pattern=r"^[^/\\\x00]+$",
)
run_name: str = Field(..., min_length=1, max_length=64)
sensor_type: Literal["pressure"]
sensor_type: Literal["pressure", "water_quality"]
method: Literal["sensitivity", "kmeans"]
sensor_count: int = Field(..., gt=0, le=200)
min_diameter: int = Field(default=0, ge=0)
@@ -89,6 +89,8 @@ class SensorPlacementSchemeResponse(BaseModel):
created_by: str
created_at: datetime
status: str
sensor_type: Literal["pressure", "water_quality"] = "pressure"
method: Literal["sensitivity", "kmeans"] | None = None
sensor_locations: list[str]
sensor_points: list[SensorPointResponse]
can_edit: bool = False
+58
View File
@@ -0,0 +1,58 @@
"""Small strict value types shared by independent backend features."""
from datetime import datetime
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, model_validator
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
StrictBool = Annotated[bool, Field(strict=True)]
PositiveInt = Annotated[int, Field(strict=True, ge=1)]
NonNegativeInt = Annotated[int, Field(strict=True, ge=0)]
def _datetime_wire_value(value: object) -> datetime | str:
if not isinstance(value, (datetime, str)):
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
return value
WireDateTime = Annotated[datetime, BeforeValidator(_datetime_wire_value)]
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
ObjectType = Literal[
"analysis_task",
"goal",
"state_snapshot",
"action_contract",
"validation",
"run",
"outcome",
"evidence",
"artifact",
"operation",
]
class ObjectRef(StrictModel):
object_type: ObjectType
object_id: UUID
revision: PositiveInt
class TimeRange(StrictModel):
start: WireDateTime
end: WireDateTime
@model_validator(mode="after")
def validate_range(self) -> "TimeRange":
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("timestamps must include a timezone")
if self.start >= self.end:
raise ValueError("start must be before end")
return self
+38
View File
@@ -0,0 +1,38 @@
"""Idempotent alert events emitted by scheduled and other backend tasks."""
from __future__ import annotations
from typing import Literal
from uuid import UUID
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime
class TaskAlertCommand(StrictModel):
event_id: UUID
alert_id: NonEmptyStr
event_type: Literal["raised", "acknowledged", "resolved"]
occurred_at: WorkbenchDateTime
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
severity: Literal["warning", "critical"]
class TaskAlertEvent(TaskAlertCommand):
producer_id: NonEmptyStr
class TaskAlertState(StrictModel):
alert_id: NonEmptyStr
revision: PositiveInt
producer_id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
status: Literal["active", "acknowledged", "resolved"]
updated_at: WorkbenchDateTime
+1
View File
@@ -86,3 +86,4 @@ class ElementHistorySeries(BaseModel):
class ElementHistoryResponse(BaseModel):
series: list[ElementHistorySeries]
truncated: bool = Field(default=False, exclude=True)
+51
View File
@@ -0,0 +1,51 @@
"""Stable workbench event-stream and synchronization contracts."""
from __future__ import annotations
from typing import Literal
from uuid import UUID
from pydantic import Field, JsonValue
from app.domain.schemas.operational_task import (
OperationalTaskNotification,
OperationalTaskSummary,
)
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
from app.domain.schemas.workbench_runtime import WorkbenchDateTime, WorkbenchRuntimeSnapshot
WorkbenchEventTopic = Literal[
"operational.task.changed",
"operational.run.queued",
"operational.run.changed",
"operational.report.published",
"operational.progress.changed",
"operational.presentation.requested",
"operational.action.changed",
"operational.notification.changed",
"workbench.alert.changed",
"workbench.work_order.changed",
"analysis.run.changed",
]
class WorkbenchEvent(StrictModel):
schema_name: Literal["workbench-event@1"] = Field(alias="schema")
event_seq: PositiveInt
event_id: UUID
project_id: UUID
topic: WorkbenchEventTopic
aggregate_type: NonEmptyStr
aggregate_id: NonEmptyStr
aggregate_revision: PositiveInt
occurred_at: WorkbenchDateTime
metadata: dict[str, JsonValue]
class WorkbenchSyncSnapshot(StrictModel):
schema_name: Literal["workbench-sync-snapshot@1"] = Field(alias="schema")
event_cursor: int = Field(ge=0)
runtime: WorkbenchRuntimeSnapshot
tasks: list[OperationalTaskSummary]
notifications: list[OperationalTaskNotification]
generated_at: WorkbenchDateTime
+192
View File
@@ -0,0 +1,192 @@
"""Typed read-only projections consumed by the supply workbench."""
from typing import Annotated, Literal
from pydantic import AwareDatetime, BeforeValidator, Field, JsonValue, model_validator
from app.domain.schemas.shared import (
NonEmptyStr,
NonNegativeInt,
PositiveInt,
StrictBool,
StrictModel,
)
def _workbench_datetime_wire_value(value: object):
from datetime import datetime
if not isinstance(value, (datetime, str)):
raise ValueError("datetime input must be an RFC3339 string or datetime instance")
return value
WorkbenchDateTime = Annotated[
AwareDatetime, BeforeValidator(_workbench_datetime_wire_value)
]
ProjectionAvailability = Literal["ready", "not_configured", "degraded"]
class WorkbenchRuntimeCondition(StrictModel):
id: NonEmptyStr
task_id: Literal["backend-schedule"]
session_id: NonEmptyStr
title: NonEmptyStr
summary: NonEmptyStr
status: Literal["pending", "running", "completed", "warning", "error"]
risk_level: Literal["normal", "attention", "critical"]
scheduled_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
detail: NonEmptyStr
recommendation: NonEmptyStr | None
evidence: list[NonEmptyStr]
model_name: Literal["TJWater scheduler"]
class WorkbenchAlertSource(StrictModel):
task_type: Literal["scheduled_task", "analysis_task", "other_task"]
task_id: NonEmptyStr
run_id: NonEmptyStr
class WorkbenchRuntimeAlert(StrictModel):
id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
class WorkbenchPersistedAlert(StrictModel):
alert_id: NonEmptyStr
condition_id: NonEmptyStr | None
source: WorkbenchAlertSource
title: NonEmptyStr
description: NonEmptyStr
occurred_at: WorkbenchDateTime
severity: Literal["warning", "critical"]
status: Literal["active", "acknowledged", "resolved"]
producer_id: NonEmptyStr | None = None
updated_at: WorkbenchDateTime | None = None
class WorkbenchRuntimeScenario(StrictModel):
id: NonEmptyStr
run_id: NonEmptyStr
name: NonEmptyStr
description: NonEmptyStr
status: Literal["draft", "active", "review"]
run_type: NonEmptyStr
run_type_label: NonEmptyStr
run_status: NonEmptyStr
created_at: WorkbenchDateTime
class WorkbenchRuntimeAnalysisFact(StrictModel):
label: NonEmptyStr
value: NonEmptyStr
class WorkbenchRuntimeAnalysisResult(StrictModel):
result_type: NonEmptyStr
created_at: WorkbenchDateTime
facts: list[WorkbenchRuntimeAnalysisFact]
class WorkbenchRuntimeLatestAnalysis(StrictModel):
run_id: NonEmptyStr
name: NonEmptyStr
run_type: NonEmptyStr
run_type_label: NonEmptyStr
status: NonEmptyStr
status_label: NonEmptyStr
created_by: NonEmptyStr
created_at: WorkbenchDateTime
started_at: WorkbenchDateTime
results: list[WorkbenchRuntimeAnalysisResult]
map_payload: dict[str, JsonValue] | None
class WorkbenchAnalysisHistoryItem(StrictModel):
run_id: NonEmptyStr
name: NonEmptyStr
run_type: NonEmptyStr
run_type_label: NonEmptyStr
status: NonEmptyStr
status_label: NonEmptyStr
created_by: NonEmptyStr
created_at: WorkbenchDateTime
started_at: WorkbenchDateTime
has_results: StrictBool
class WorkbenchAnalysisHistoryPage(StrictModel):
items: list[WorkbenchAnalysisHistoryItem]
limit: PositiveInt
offset: NonNegativeInt
next_offset: NonNegativeInt | None
class WorkOrderActivity(StrictModel):
sequence: PositiveInt
occurred_at: WorkbenchDateTime
actor: NonEmptyStr
kind: Literal["created", "updated", "feedback"]
title: NonEmptyStr
detail: NonEmptyStr
class WorkbenchRuntimeWorkOrder(StrictModel):
id: NonEmptyStr
code: NonEmptyStr
scheduled_at: WorkbenchDateTime
title: NonEmptyStr
summary: NonEmptyStr
status: Literal["pending", "running", "awaiting_reply", "completed"]
risk_level: Literal["normal", "attention", "critical"]
updated_at: WorkbenchDateTime
detail: NonEmptyStr
recommendation: NonEmptyStr | None
duration_minutes: PositiveInt
source: NonEmptyStr
dispatcher: NonEmptyStr
location: NonEmptyStr | None = None
assignee: NonEmptyStr | None = None
priority: Literal["normal", "urgent"]
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
activity: list[WorkOrderActivity] = Field(default_factory=list)
@model_validator(mode="before")
@classmethod
def accept_legacy_document(cls, value):
if not isinstance(value, dict) or "subtasks" not in value:
return value
document = {key: item for key, item in value.items() if key != "subtasks"}
subtasks = value.get("subtasks") or []
first = subtasks[0] if subtasks else {}
document.setdefault("location", first.get("location"))
document.setdefault("assignee", first.get("assignee"))
document.setdefault("feedback_requirements", list(dict.fromkeys(
requirement for subtask in subtasks
for requirement in subtask.get("reply_requirements", [])
)))
document.setdefault("activity", [])
return document
class WorkbenchRuntimeSnapshot(StrictModel):
availability: ProjectionAvailability
source: Literal["none", "scheduled_tasks"]
scenario_availability: ProjectionAvailability
artifact_availability: ProjectionAvailability
schedule_availability: ProjectionAvailability
work_order_availability: ProjectionAvailability
alert_availability: ProjectionAvailability
generated_at: WorkbenchDateTime
conditions: list[WorkbenchRuntimeCondition]
alerts: list[WorkbenchRuntimeAlert]
scenarios: list[WorkbenchRuntimeScenario]
latest_analysis: WorkbenchRuntimeLatestAnalysis | None
work_orders: list[WorkbenchRuntimeWorkOrder]
+3
View File
@@ -61,7 +61,10 @@ class AuditMiddleware(BaseHTTPMiddleware):
"/api/v1/audit/session-events",
"/audit/session-events",
}
# Query handles and source arguments are not legacy REST audit payloads.
# Task queries persist scoped evidence/receipts in their own transaction.
EXCLUDED_PATH_PREFIXES = (
"/api/v1/mcp-query-contexts",
)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
+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,
)
@@ -525,6 +532,39 @@ class MetadataRepository:
pool_max_size=record.pool_max_size,
)
async def list_active_project_db_routings(
self, db_role: str
) -> List[ProjectDbRouting]:
result = await self.session.execute(
select(models.ProjectDatabase)
.join(models.Project, models.Project.id == models.ProjectDatabase.project_id)
.where(
models.ProjectDatabase.db_role == db_role,
models.Project.status == "active",
)
.order_by(models.ProjectDatabase.project_id)
)
if not is_database_encryption_configured():
raise ValueError("DATABASE_ENCRYPTION_KEY is not configured")
encryptor = get_database_encryptor()
try:
return [
ProjectDbRouting(
project_id=record.project_id,
db_role=record.db_role,
db_type=record.db_type,
dsn=_normalize_postgres_dsn(encryptor.decrypt(record.dsn_encrypted)),
pool_min_size=record.pool_min_size,
pool_max_size=record.pool_max_size,
)
for record in result.scalars().all()
]
except InvalidToken as exc:
raise ValueError(
"Failed to decrypt project DB DSN: DATABASE_ENCRYPTION_KEY mismatch "
"or invalid dsn_encrypted value"
) from exc
async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]:
stmt = (
select(models.Project, models.UserProjectMembership.project_role)
+88
View File
@@ -0,0 +1,88 @@
"""Project scoped private message storage, sharing task transaction locks."""
from psycopg import AsyncConnection
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
class AgentMessageRepository(OperationalTaskRepository):
def __init__(self, connection: AsyncConnection, project_id):
super().__init__(connection, project_id)
async def get_message(self, message_id: str) -> dict | None:
cursor = await self.connection.execute(
"SELECT * FROM workbench.agent_messages WHERE project_id=%s AND message_id=%s",
(self.project_id, message_id),
)
row = await cursor.fetchone()
return self._message(row) if row else None
async def get_message_request(self, owner_id: str, session_id: str, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT * FROM workbench.agent_messages WHERE project_id=%s
AND source_owner_id=%s AND source_session_id=%s AND request_id=%s""",
(self.project_id, owner_id, session_id, request_id),
)
row = await cursor.fetchone()
return self._message(row) if row else None
@staticmethod
def _message(row) -> dict:
return dict(row)
async def insert_message(self, message: dict) -> None:
keys = [key for key in message if key != "project_id"]
await self.connection.execute(
f"INSERT INTO workbench.agent_messages (project_id,{','.join(keys)}) "
f"VALUES ({','.join(['%s'] * (len(keys) + 1))})",
(self.project_id, *(message[key] for key in keys)),
)
async def list_session_messages(
self, owner_id: str, session_id: str, limit: int, *, cursor_at=None, cursor_id=None, pending_only=False,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT * FROM workbench.agent_messages WHERE project_id=%s
AND conversation_owner_id=%s AND (source_session_id=%s OR
(direction='to_chat' AND target_session_id=%s))
AND (%s::timestamptz IS NULL OR (created_at,message_id)<(%s::timestamptz,%s::uuid))
AND (NOT %s OR status IN ('pending','enqueued'))
ORDER BY created_at DESC,message_id DESC LIMIT %s""",
(self.project_id, owner_id, session_id, session_id, cursor_at, cursor_at, cursor_id, pending_only, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def list_run_session_messages(
self, session_id: str, limit: int, *, cursor_at=None, cursor_id=None, pending_only=False,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT m.* FROM workbench.agent_messages m
JOIN workbench.operational_task_runs r ON r.project_id=m.project_id AND r.run_id=m.run_id
WHERE m.project_id=%s AND r.agent_session_id=%s
AND r.status IN ('claimed','running')
AND (%s::timestamptz IS NULL OR (m.created_at,m.message_id)<(%s::timestamptz,%s::uuid))
AND (NOT %s OR (m.direction='to_task' AND m.status IN ('pending','enqueued')))
ORDER BY m.created_at DESC,m.message_id DESC LIMIT %s""",
(self.project_id, session_id, cursor_at, cursor_at, cursor_id, pending_only, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def list_pending_messages(self, limit: int, *, cursor_at=None, cursor_id=None) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT m.* FROM workbench.agent_messages m
JOIN workbench.operational_task_runs r
ON r.project_id=m.project_id AND r.run_id=m.run_id
WHERE m.project_id=%s AND m.status IN ('pending','enqueued')
AND (m.direction='to_chat' OR r.status IN ('claimed','running'))
AND (%s::timestamptz IS NULL OR (m.created_at,m.message_id)>(%s::timestamptz,%s::uuid))
ORDER BY m.created_at,m.message_id LIMIT %s""",
(self.project_id, cursor_at, cursor_at, cursor_id, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def update_message_delivery(self, message_id: str, update: dict) -> None:
keys = list(update)
await self.connection.execute(
f"UPDATE workbench.agent_messages SET {','.join(key+'=%s' for key in keys)} "
"WHERE project_id=%s AND message_id=%s",
(*(update[key] for key in keys), self.project_id, message_id),
)
+149 -7
View File
@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
from psycopg import AsyncConnection, Connection
from psycopg.types.json import Jsonb
from app.infra.db.postgresql.workbench_events import lock_event_transaction_sync
class AnalysisRepository:
@@ -19,6 +20,7 @@ class AnalysisRepository:
parameters: dict[str, Any],
run_id: UUID | None = None,
) -> dict[str, Any]:
lock_event_transaction_sync(conn)
execution_id = run_id or uuid4()
with conn.cursor() as cur:
cur.execute(
@@ -53,18 +55,53 @@ class AnalysisRepository:
status: str,
created_by: str,
parameters: dict[str, Any],
) -> None:
) -> int:
lock_event_transaction_sync(conn)
with conn.cursor() as cur:
cur.execute(
"""
UPDATE analysis.runs
SET created_by = %s, status = %s, parameters = %s
SET created_by = %s, status = %s, parameters = %s,
revision = revision + 1
WHERE run_id = %s
RETURNING revision
""",
(created_by, status, Jsonb(parameters), run_id),
)
if cur.rowcount != 1:
row = cur.fetchone()
if row is None:
raise LookupError(f"analysis run {run_id} does not exist")
return int(row["revision"])
@staticmethod
def append_run_changed_event_sync(
conn: Connection,
*,
project_id: UUID,
run_id: UUID,
revision: int,
status: str,
occurred_at: datetime,
results_committed: bool,
) -> None:
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO workbench.domain_event_log
(project_id,event_id,topic,aggregate_type,aggregate_id,
aggregate_revision,occurred_at,metadata)
VALUES (%s,%s,'analysis.run.changed','analysis_run',%s,%s,%s,%s)
RETURNING event_seq""",
(
project_id, uuid4(), str(run_id), revision, occurred_at,
Jsonb({"run_id": str(run_id), "status": status,
"results_committed": results_committed}),
),
)
row = cur.fetchone()
cur.execute(
"SELECT pg_notify('workbench_events', %s)",
(f'{project_id}:{row["event_seq"]}',),
)
@staticmethod
def insert_result_sync(
@@ -101,7 +138,11 @@ class AnalysisRepository:
return cur.fetchone()
@staticmethod
async def list_runs(conn: AsyncConnection) -> list[dict]:
async def list_runs(
conn: AsyncConnection, *, limit: int = 100, offset: int = 0
) -> list[dict]:
if not 1 <= limit <= 100 or offset < 0:
raise ValueError("analysis run page is out of range")
async with conn.cursor() as cur:
await cur.execute(
"""
@@ -109,7 +150,52 @@ class AnalysisRepository:
started_at, status, parameters
FROM analysis.runs
ORDER BY created_at DESC, run_id
LIMIT %s OFFSET %s
""",
(limit, offset),
)
return await cur.fetchall()
@staticmethod
async def list_recent_runs(
conn: AsyncConnection, *, limit: int = 100
) -> list[dict]:
if not 1 <= limit <= 100:
raise ValueError("recent analysis run limit must be between 1 and 100")
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT run_id, name, run_type, created_by, created_at,
started_at, status, parameters
FROM analysis.runs
ORDER BY created_at DESC, run_id
LIMIT %s
""",
(limit,),
)
return await cur.fetchall()
@staticmethod
async def list_run_summaries(
conn: AsyncConnection, *, limit: int = 21, offset: int = 0
) -> list[dict]:
if not 1 <= limit <= 101 or offset < 0:
raise ValueError("analysis run summary page is out of range")
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT r.run_id, r.name, r.run_type, r.created_by,
r.created_at, r.started_at, r.status,
EXISTS (
SELECT 1
FROM analysis.results AS result
WHERE result.run_id = r.run_id
) AS has_results
FROM analysis.runs AS r
ORDER BY r.created_at DESC, r.run_id
LIMIT %s OFFSET %s
""",
(limit, offset),
)
return await cur.fetchall()
@@ -129,20 +215,76 @@ class AnalysisRepository:
@staticmethod
async def list_results(
conn: AsyncConnection, run_id: UUID, result_type: str | None = None
conn: AsyncConnection,
run_id: UUID,
result_type: str | None = None,
*,
limit: int = 100,
offset: int = 0,
lookahead: bool = False,
) -> list[dict]:
if not 1 <= limit <= 500 or offset < 0:
raise ValueError("analysis result page is out of range")
query = """
SELECT result_id, run_id, result_type, node_id, link_id,
payload, created_at
FROM analysis.results
WHERE run_id = %s
"""
params: tuple[UUID] | tuple[UUID, str] = (run_id,)
params: tuple = (run_id,)
if result_type is not None:
query += " AND result_type = %s"
params = (run_id, result_type)
query += " ORDER BY created_at, result_id"
query += " ORDER BY created_at, result_id LIMIT %s OFFSET %s"
params += (limit + int(lookahead), offset)
async with conn.cursor() as cur:
await cur.execute(query, params)
return await cur.fetchall()
@staticmethod
async def list_recent_results(
conn: AsyncConnection, run_id: UUID, *, limit: int = 100
) -> list[dict]:
if not 1 <= limit <= 100:
raise ValueError("recent analysis result limit must be between 1 and 100")
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT result_id, run_id, result_type, node_id, link_id,
payload, created_at
FROM (
SELECT result_id, run_id, result_type, node_id, link_id,
payload, created_at
FROM analysis.results
WHERE run_id = %s
ORDER BY created_at DESC, result_id DESC
LIMIT %s
) AS recent_results
ORDER BY created_at, result_id
""",
(run_id, limit),
)
return await cur.fetchall()
@staticmethod
async def list_junction_map_points(
conn: AsyncConnection, node_ids: list[str]
) -> list[dict]:
if not node_ids:
return []
if len(node_ids) > 500:
raise ValueError("analysis map projection supports at most 500 junctions")
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT g.id AS node_id,
ST_X(ST_Transform(g.geom, 4326)) AS longitude,
ST_Y(ST_Transform(g.geom, 4326)) AS latitude
FROM gis.junctions AS g
WHERE g.id = ANY(%s)
ORDER BY g.id
""",
(node_ids,),
)
return await cur.fetchall()
+80
View File
@@ -0,0 +1,80 @@
"""Read-only access to imported source GIS attributes."""
from typing import Any
from psycopg import AsyncConnection
class GisAttributeRepository:
@staticmethod
async def list_element_groups(
conn: AsyncConnection,
element_type: str,
element_id: str,
) -> list[dict[str, Any]]:
async with conn.cursor() as cur:
await cur.execute(
"SELECT to_regclass('source_gis.element_links') AS relation"
)
availability = await cur.fetchone()
if not availability or availability["relation"] is None:
return []
await cur.execute(
"""
SELECT
dataset.dataset_key,
dataset.title AS dataset_title,
dataset.source_date,
layer.layer_key,
layer.display_name AS layer_name,
feature.source_fid,
link.match_method,
link.distance_m AS match_distance_m,
feature.attributes
FROM source_gis.element_links AS link
JOIN source_gis.features AS feature
ON feature.layer_key = link.layer_key
AND feature.source_fid = link.source_fid
JOIN source_gis.layers AS layer
ON layer.layer_key = feature.layer_key
JOIN source_gis.datasets AS dataset
ON dataset.dataset_key = layer.dataset_key
WHERE link.element_type = %s
AND link.element_id = %s
ORDER BY dataset.source_date DESC, layer.display_name, feature.source_fid
""",
(element_type, element_id),
)
return [dict(row) for row in await cur.fetchall()]
@staticmethod
async def get_source_feature_group(
conn: AsyncConnection,
layer_key: str,
source_fid: int,
) -> dict[str, Any] | None:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT
dataset.dataset_key,
dataset.title AS dataset_title,
dataset.source_date,
layer.layer_key,
layer.display_name AS layer_name,
feature.source_fid,
'source' AS match_method,
NULL::double precision AS match_distance_m,
feature.attributes
FROM source_gis.features AS feature
JOIN source_gis.layers AS layer
ON layer.layer_key = feature.layer_key
JOIN source_gis.datasets AS dataset
ON dataset.dataset_key = layer.dataset_key
WHERE feature.layer_key = %s
AND feature.source_fid = %s
""",
(layer_key, source_fid),
)
row = await cur.fetchone()
return dict(row) if row else None
+98
View File
@@ -0,0 +1,98 @@
"""Persistent MCP context and receipt storage in the selected project's BizDB."""
from datetime import datetime, timezone
from psycopg import AsyncConnection
from psycopg.types.json import Jsonb
from app.mcp.models import BoundContext
class McpQueryRepository:
def __init__(self, conn: AsyncConnection, project_id):
self.conn, self.project_id = conn, project_id
async def create(self, context: BoundContext):
async with self.conn.transaction(), self.conn.cursor() as cur:
# Bounded maintenance; expired/revoked handles remain invalid even if cleanup fails.
await cur.execute(
"""DELETE FROM workbench.mcp_query_contexts WHERE context_handle IN
(SELECT context_handle FROM workbench.mcp_query_contexts
WHERE project_id=%s AND (expires_at < now() OR revoked_at IS NOT NULL)
ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)""",
(self.project_id,),
)
await cur.execute(
"""INSERT INTO workbench.mcp_query_contexts
(context_handle,project_id,subject,client_id,document,expires_at)
VALUES (%s,%s,%s,%s,%s,%s)""",
(
context.context_handle,
self.project_id,
context.subject,
context.client_id,
Jsonb(context.model_dump(mode="json")),
context.expires_at,
),
)
async def get(self, handle: str) -> BoundContext | None:
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""SELECT document,revoked_at FROM workbench.mcp_query_contexts
WHERE context_handle=%s AND project_id=%s""",
(handle, self.project_id),
)
row = await cur.fetchone()
if not row:
return None
document = dict(row["document"])
document["revoked_at"] = row["revoked_at"]
return BoundContext.model_validate(document)
async def revoke(self, handle: str, subject: str, client_id: str):
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""UPDATE workbench.mcp_query_contexts SET revoked_at=COALESCE(revoked_at,%s)
WHERE context_handle=%s AND project_id=%s AND subject=%s AND client_id=%s""",
(
datetime.now(timezone.utc),
handle,
self.project_id,
subject,
client_id,
),
)
async def receipt(self, scope_key: str, call_id: str) -> dict | None:
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""SELECT parameters,result FROM workbench.mcp_query_receipts
WHERE project_id=%s AND scope_key=%s AND call_id=%s""",
(self.project_id, scope_key, call_id),
)
return await cur.fetchone()
async def save_receipt(
self, scope_key: str, call_id: str, parameters: dict, result: dict
):
# Caller owns the short evidence + receipt transaction.
async with self.conn.cursor() as cur:
await cur.execute(
"""INSERT INTO workbench.mcp_query_receipts
(project_id,scope_key,call_id,parameters,result) VALUES (%s,%s,%s,%s,%s)
ON CONFLICT (project_id,scope_key,call_id) DO NOTHING""",
(self.project_id, scope_key, call_id, Jsonb(parameters), Jsonb(result)),
)
async def lock_receipt(self, scope_key: str, call_id: str):
async with self.conn.cursor() as cur:
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))",
(f"mcp:{self.project_id}:{scope_key}:{call_id}",),
)
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),
)
@@ -0,0 +1,823 @@
"""PostgreSQL persistence for durable operational tasks."""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any
from psycopg import AsyncConnection
from psycopg.errors import UndefinedTable
from psycopg.types.json import Jsonb
from uuid import uuid4
from app.domain.domain_errors import DomainError
from app.infra.db.postgresql.workbench_events import lock_event_transaction
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
class OperationalTaskRepository:
def __init__(self, connection: AsyncConnection, project_id):
self.connection = connection
self.project_id = str(project_id)
@asynccontextmanager
async def transaction(self):
try:
async with self.connection.transaction():
await lock_event_transaction(self.connection)
yield self
except UndefinedTable as exc:
raise DomainError(
"DEPENDENCY_UNAVAILABLE",
"Project operational-task storage is not provisioned",
status=503,
) from exc
async def lock(self, key: str) -> None:
await self.connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
(f"tjwater-operational:{self.project_id}:{key}",),
)
async def try_lock(self, key: str) -> bool:
cursor = await self.connection.execute(
"SELECT pg_try_advisory_xact_lock(hashtextextended(%s, 0)) AS acquired",
(f"tjwater-operational:{self.project_id}:{key}",),
)
return bool((await cursor.fetchone())["acquired"])
async def get_request(self, scope: str, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT request_digest,response
FROM workbench.operational_task_requests
WHERE project_id=%s AND scope=%s AND request_id=%s""",
(self.project_id, scope, request_id),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def save_request(
self, scope: str, request_id: str, request_digest: str, response: dict
) -> None:
cursor = await self.connection.execute(
"""INSERT INTO workbench.operational_task_requests
(project_id,scope,request_id,request_digest,response)
VALUES (%s,%s,%s,%s,%s)
ON CONFLICT(project_id,scope,request_id) DO UPDATE SET
response=excluded.response
WHERE operational_task_requests.request_digest=excluded.request_digest""",
(self.project_id, scope, request_id, request_digest, Jsonb(response)),
)
if cursor.rowcount != 1:
raise DomainError(
"IDEMPOTENCY_CONFLICT",
"request_id was used with different content",
status=409,
)
async def get_task(self, task_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
WHERE project_id=%s AND task_id=%s""",
(self.project_id, task_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
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,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
WHERE project_id=%s AND task_id=%s
AND status IN ('queued','claimed','running')
ORDER BY generation DESC LIMIT 1""",
(self.project_id, task_id),
)
row = await cursor.fetchone()
return dict(row) if row else None
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,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
WHERE project_id=%s AND run_id=%s""",
(self.project_id, run_id),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def list_task_runs(self, task_id: str, limit: int, offset: int) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT * FROM workbench.operational_task_runs
WHERE project_id=%s AND task_id=%s
ORDER BY generation DESC LIMIT %s OFFSET %s""",
(self.project_id, task_id, limit, offset),
)
return [{key: value for key, value in row.items() if key != "project_id"}
for row in await cursor.fetchall()]
async def get_latest_run(self, task_id: str) -> dict | None:
rows = await self.list_task_runs(task_id, 1, 0)
return rows[0] if rows else None
async def require_execution_storage(self) -> None:
"""Reject claims before changing state if the inbox cannot be used."""
cursor = await self.connection.execute(
"SELECT to_regclass('workbench.agent_messages') IS NOT NULL AS available"
)
if not (await cursor.fetchone())["available"]:
raise DomainError(
"DEPENDENCY_UNAVAILABLE",
"Agent message storage is not provisioned; apply "
"resources/sql/024_agent_messages.sql to this project database before starting runs",
status=503,
)
async def pending_run_messages(self, run_id: str) -> list[str]:
cursor = await self.connection.execute(
"""SELECT message_id FROM workbench.agent_messages
WHERE project_id=%s AND run_id=%s AND direction='to_task'
AND status IN ('pending','enqueued') ORDER BY created_at LIMIT 100""",
(self.project_id, run_id),
)
return [str(row["message_id"]) for row in await cursor.fetchall()]
async def fail_run_messages(self, run_id: str, failure_detail: str, now: datetime) -> None:
await self.connection.execute(
"""UPDATE workbench.agent_messages SET status='failed',failure_detail=%s,updated_at=%s
WHERE project_id=%s AND run_id=%s AND direction='to_task'
AND status IN ('pending','enqueued')""",
(failure_detail, now, self.project_id, run_id),
)
async def insert_run(self, run: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_runs
(project_id,run_id,task_id,generation,execution_request_id,status,revision,
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(
self.project_id, run["run_id"], run["task_id"], run["generation"],
run["execution_request_id"], run["status"], run["revision"],
run.get("agent_session_id"), 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), run["next_dispatch_at"],
run["created_at"], run["updated_at"],
),
)
async def update_run(self, run: dict, expected_revision: int) -> None:
cursor = await self.connection.execute(
"""UPDATE workbench.operational_task_runs SET
status=%s,revision=%s,agent_session_id=%s,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"),
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),
run["next_dispatch_at"], run["updated_at"], self.project_id,
run["run_id"], expected_revision,
),
)
if cursor.rowcount != 1:
raise DomainError("VERSION_CONFLICT", "Run revision changed", status=409)
async def list_runs(
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
task_type: str | None = None, task_created_from: datetime | None = None,
) -> list[dict]:
result = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
agent_session_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
WHERE project_id=%s
AND (%s::text IS NULL OR status=%s::text)
AND (%s::timestamptz IS NULL OR (created_at,run_id)>(%s::timestamptz,%s::uuid))
AND (status<>'queued' OR next_dispatch_at<=now())
AND EXISTS (
SELECT 1 FROM workbench.operational_tasks t
WHERE t.project_id=operational_task_runs.project_id
AND t.task_id=operational_task_runs.task_id
AND (%s::text IS NULL OR t.task_type=%s::text)
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
)
ORDER BY created_at,run_id LIMIT %s""",
(
self.project_id, status, status, cursor_created_at,
cursor_created_at, cursor_run_id,
task_type, task_type, task_created_from, task_created_from, limit,
),
)
return [dict(row) for row in await result.fetchall()]
async def claim_expired_runs(
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
WHERE project_id=%s AND status IN ('claimed','running')
AND lease_expires_at IS NOT NULL AND lease_expires_at<=now()
AND EXISTS (
SELECT 1 FROM workbench.operational_tasks t
WHERE t.project_id=operational_task_runs.project_id
AND t.task_id=operational_task_runs.task_id
AND (%s::text IS NULL OR t.task_type=%s::text)
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
)
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
(self.project_id, task_type, task_type, task_created_from, task_created_from, limit),
)
return [dict(row) for row in await cursor.fetchall()]
async def consecutive_timed_out_runs(self, task_id: str) -> int:
cursor = await self.connection.execute(
"""SELECT status FROM workbench.operational_task_runs
WHERE project_id=%s AND task_id=%s ORDER BY generation DESC""",
(self.project_id, task_id),
)
count = 0
for row in await cursor.fetchall():
if row["status"] != "timed_out":
break
count += 1
return count
async def append_event(
self, *, topic: str, aggregate_type: str, aggregate_id: str,
aggregate_revision: int, occurred_at, metadata: dict | None = None,
) -> int:
event_id = uuid4()
cursor = await self.connection.execute(
"""INSERT INTO workbench.domain_event_log
(project_id,event_id,topic,aggregate_type,aggregate_id,
aggregate_revision,occurred_at,metadata)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING event_seq""",
(
self.project_id, event_id, topic, aggregate_type, aggregate_id,
aggregate_revision, occurred_at, Jsonb(metadata or {}),
),
)
row = await cursor.fetchone()
event_seq = int(row["event_seq"])
await self.connection.execute(
"SELECT pg_notify('workbench_events', %s)",
(f'{self.project_id}:{event_seq}',),
)
return event_seq
async def current_event_cursor(self) -> int:
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
async def get_task_by_create_request(self, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
WHERE project_id=%s AND create_request_id=%s""",
(self.project_id, request_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def get_task_by_external_source(
self, source_kind: str, external_id: str
) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
WHERE project_id=%s
AND document#>>'{source,kind}'=%s
AND document#>>'{source,external_id}'=%s""",
(self.project_id, source_kind, external_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def list_tasks(
self,
*,
task_type: str | None,
status: str | None,
limit: int,
offset: int,
peripheral_work_order_id: str | None = None,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT task.document,
CASE WHEN active_run.run_id IS NULL THEN NULL
ELSE to_jsonb(active_run) - 'project_id' END AS active_run,
CASE WHEN latest_run.run_id IS NULL THEN NULL
ELSE to_jsonb(latest_run) - 'project_id' END AS latest_run
FROM workbench.operational_tasks AS task
LEFT JOIN LATERAL (
SELECT run.* FROM workbench.operational_task_runs AS run
WHERE run.project_id=task.project_id AND run.task_id=task.task_id
AND run.status IN ('queued','claimed','running')
ORDER BY run.generation DESC LIMIT 1
) AS active_run ON true
LEFT JOIN LATERAL (
SELECT run.* FROM workbench.operational_task_runs AS run
WHERE run.project_id=task.project_id AND run.task_id=task.task_id
ORDER BY run.generation DESC LIMIT 1
) AS latest_run ON true
WHERE task.project_id=%s
AND (task.document#>>'{source,deleted_at}') IS NULL
AND (%s::text IS NULL OR task.task_type=%s::text)
AND (%s::text IS NULL OR task.status=%s::text)
AND (%s::text IS NULL
OR (task.document#>>'{source,kind}'='external_system'
AND task.document#>>'{source,external_id}' IN (%s,%s))
OR EXISTS (
SELECT 1 FROM workbench.operational_task_actions AS action
WHERE action.project_id=task.project_id AND action.task_id=task.task_id
AND action.document->>'kind'='create_work_order'
AND action.document#>>'{receipt,adapter}'='peripheral_sample'
AND action.document#>>'{receipt,receipt_id}'=%s
))
ORDER BY task.updated_at DESC,task.task_id
LIMIT %s OFFSET %s""",
(
self.project_id,
task_type,
task_type,
status,
status,
peripheral_work_order_id,
peripheral_work_order_id,
f"peripheral_sample:{peripheral_work_order_id}" if peripheral_work_order_id else None,
peripheral_work_order_id,
limit,
offset,
),
)
return [
{**row["document"], "active_run": row["active_run"], "latest_run": row["latest_run"]}
for row in await cursor.fetchall()
]
async def insert_task(self, task: dict, create_request_id: str) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_tasks
(project_id,task_id,create_request_id,task_type,workflow,status,lifecycle_status,priority,
revision,execution_generation,execution_request_id,agent_session_id,
agent_lease_expires_at,current_plan_revision,current_report_revision,
scheduled_for,created_at,updated_at,document)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
self._task_params(task, create_request_id),
)
async def update_task(self, task: dict) -> None:
cursor = await self.connection.execute(
"""UPDATE workbench.operational_tasks SET
status=%s,lifecycle_status=%s,revision=%s,execution_generation=%s,execution_request_id=%s,
agent_session_id=%s,agent_lease_expires_at=%s,current_plan_revision=%s,
current_report_revision=%s,updated_at=%s,document=%s
WHERE project_id=%s AND task_id=%s AND revision=%s""",
(
task["status"], task["lifecycle_status"], task["revision"], task["execution_generation"],
task["execution_request_id"], task.get("agent_session_id"),
task.get("agent_lease_expires_at"), task.get("current_plan_revision"),
task.get("current_report_revision"), task["updated_at"], Jsonb(task),
self.project_id, task["task_id"], task["revision"] - 1,
),
)
if cursor.rowcount != 1:
raise DomainError("VERSION_CONFLICT", "Task revision changed", status=409)
def _task_params(self, task: dict, create_request_id: str) -> tuple:
return (
self.project_id, task["task_id"], create_request_id, task["task_type"],
task["workflow"], task["status"], task["lifecycle_status"], task["priority"], task["revision"],
task["execution_generation"], task["execution_request_id"],
task.get("agent_session_id"), task.get("agent_lease_expires_at"),
task.get("current_plan_revision"), task.get("current_report_revision"),
task.get("scheduled_for"), task["created_at"], task["updated_at"], Jsonb(task),
)
async def get_evidence(self, task_id: str, evidence_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_evidence
WHERE project_id=%s AND task_id=%s AND evidence_id=%s""",
(self.project_id, task_id, evidence_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def list_evidence(self, task_id: str) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_evidence
WHERE project_id=%s AND task_id=%s ORDER BY created_at,evidence_id""",
(self.project_id, task_id),
)
return [row["document"] for row in await cursor.fetchall()]
async def insert_evidence(self, task_id: str, evidence: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_evidence
(project_id,task_id,evidence_id,document,created_at)
VALUES (%s,%s,%s,%s,%s)""",
(self.project_id, task_id, evidence["evidence_id"], Jsonb(evidence),
evidence["recorded_at"]),
)
async def access_requests_available(self) -> bool:
# Older project databases can serve existing tasks before migration 019.
# Keep approval writes fail-closed until the table is provisioned.
availability = await self.connection.execute(
"SELECT to_regclass('workbench.operational_task_access_requests') AS relation"
)
return (await availability.fetchone())["relation"] is not None
async def list_access_requests(self, task_id: str) -> list[dict]:
if not await self.access_requests_available():
return []
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_access_requests
WHERE project_id=%s AND task_id=%s
ORDER BY requested_at,access_request_id""",
(self.project_id, task_id),
)
return [row["document"] for row in await cursor.fetchall()]
async def get_access_request(self, task_id: str, access_request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_access_requests
WHERE project_id=%s AND task_id=%s AND access_request_id=%s""",
(self.project_id, task_id, access_request_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def insert_access_request(self, task_id: str, document: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_access_requests
(project_id,task_id,access_request_id,node_id,status,version,document,requested_at,updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(self.project_id, task_id, document["access_request_id"], document["node_id"],
document["status"], document["version"], Jsonb(document),
document["requested_at"], document["requested_at"]),
)
async def update_access_request(self, task_id: str, document: dict, expected_version: int) -> None:
cursor = await self.connection.execute(
"""UPDATE workbench.operational_task_access_requests
SET status=%s,version=%s,document=%s,updated_at=%s
WHERE project_id=%s AND task_id=%s AND access_request_id=%s AND version=%s""",
(document["status"], document["version"], Jsonb(document), document["decided_at"],
self.project_id, task_id, document["access_request_id"], expected_version),
)
if cursor.rowcount != 1:
raise DomainError("VERSION_CONFLICT", "Access request version changed", status=409)
async def get_latest_plan(self, task_id: str) -> dict | None:
return await self._latest_artifact("operational_task_plans", task_id)
async def get_latest_report(self, task_id: str) -> dict | None:
return await self._latest_artifact("operational_task_reports", task_id)
async def get_report(self, task_id: str, revision: int) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_reports
WHERE project_id=%s AND task_id=%s AND revision=%s""",
(self.project_id, task_id, revision),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def list_reports(self, task_id: str, limit: int, offset: int) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_reports
WHERE project_id=%s AND task_id=%s ORDER BY revision DESC
LIMIT %s OFFSET %s""",
(self.project_id, task_id, limit, offset),
)
return [row["document"] for row in await cursor.fetchall()]
async def _latest_artifact(self, table: str, task_id: str) -> dict | None:
if table not in {"operational_task_plans", "operational_task_reports"}:
raise ValueError("unsupported artifact table")
cursor = await self.connection.execute(
f"""SELECT document FROM workbench.{table}
WHERE project_id=%s AND task_id=%s ORDER BY revision DESC LIMIT 1""",
(self.project_id, task_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def insert_plan(self, task_id: str, snapshot: dict) -> None:
await self._insert_artifact("operational_task_plans", task_id, snapshot)
async def insert_report(self, task_id: str, snapshot: dict) -> None:
await self._insert_artifact("operational_task_reports", task_id, snapshot)
async def get_latest_progress(self, task_id: str, generation: int) -> dict | None:
# Read compatibility before the coordinated progress migration is applied.
availability = await self.connection.execute(
"SELECT to_regclass('workbench.operational_task_progress') AS relation"
)
if not (await availability.fetchone())["relation"]:
return None
cursor = await self.connection.execute(
"""SELECT progress.document FROM workbench.operational_task_progress AS progress
JOIN workbench.operational_task_runs AS run
ON run.project_id=progress.project_id AND run.run_id=progress.run_id
AND run.task_id=progress.task_id
WHERE progress.project_id=%s AND progress.task_id=%s AND run.generation=%s""",
(self.project_id, task_id, generation),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def save_progress(self, task_id: str, snapshot: dict) -> None:
cursor = await self.connection.execute(
"""INSERT INTO workbench.operational_task_progress
(project_id,task_id,run_id,revision,document,published_at)
VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT(project_id,task_id,run_id) DO UPDATE SET
revision=excluded.revision,document=excluded.document,published_at=excluded.published_at
WHERE workbench.operational_task_progress.revision=excluded.revision-1""",
(self.project_id, task_id, snapshot["run_id"], snapshot["revision"],
Jsonb(snapshot), snapshot["published_at"]),
)
if cursor.rowcount != 1:
raise DomainError("VERSION_CONFLICT", "Investigation progress revision changed", status=409)
async def _insert_artifact(self, table: str, task_id: str, snapshot: dict) -> None:
if table not in {"operational_task_plans", "operational_task_reports"}:
raise ValueError("unsupported artifact table")
await self.connection.execute(
f"""INSERT INTO workbench.{table}
(project_id,task_id,revision,document,created_at)
VALUES (%s,%s,%s,%s,%s)""",
(self.project_id, task_id, snapshot["revision"], Jsonb(snapshot),
snapshot["published_at"]),
)
async def get_action(self, task_id: str, action_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_actions
WHERE project_id=%s AND task_id=%s AND action_id=%s""",
(self.project_id, task_id, action_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def list_actions(self, task_id: str) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_actions
WHERE project_id=%s AND task_id=%s ORDER BY created_at,action_id""",
(self.project_id, task_id),
)
return [row["document"] for row in await cursor.fetchall()]
async def insert_action(self, task_id: str, action: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_actions
(project_id,task_id,action_id,version,approval_status,execution_status,
document,created_at,updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(self.project_id, task_id, action["action_id"], action["version"],
action["approval_status"], action["execution_status"], Jsonb(action),
action["proposed_at"], action["proposed_at"]),
)
await self._insert_action_revision(task_id, action)
async def update_action(self, task_id: str, action: dict) -> None:
await self._insert_action_revision(task_id, action)
await self.connection.execute(
"""UPDATE workbench.operational_task_actions SET
version=%s,approval_status=%s,execution_status=%s,document=%s,updated_at=%s
WHERE project_id=%s AND task_id=%s AND action_id=%s""",
(action["version"], action["approval_status"], action["execution_status"],
Jsonb(action), action.get("executed_at") or action.get("decided_at") or action.get("edited_at") or
action["proposed_at"], self.project_id, task_id, action["action_id"]),
)
async def _insert_action_revision(self, task_id: str, action: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_action_revisions
(project_id,task_id,action_id,version,document,recorded_at)
VALUES (%s,%s,%s,%s,%s,%s)""",
(
self.project_id, task_id, action["action_id"], action["version"],
Jsonb(action), action.get("executed_at") or action.get("decided_at") or action.get("edited_at")
or action["proposed_at"],
),
)
async def get_feedback(self, task_id: str, source_event_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT source_revision,document,accepted_at
FROM workbench.operational_task_external_feedback
WHERE project_id=%s AND task_id=%s AND source_event_id=%s""",
(self.project_id, task_id, source_event_id),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def upsert_feedback(self, task_id: str, feedback: dict, accepted_at) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_external_feedback
(project_id,task_id,source_event_id,source_revision,document,accepted_at)
VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT(project_id,task_id,source_event_id) DO UPDATE SET
source_revision=excluded.source_revision,document=excluded.document,
accepted_at=excluded.accepted_at""",
(self.project_id, task_id, feedback["source_event_id"],
feedback["source_revision"], Jsonb(feedback), accepted_at),
)
async def insert_notification(self, notification: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_notifications
(project_id,notification_id,task_id,created_at,document)
VALUES (%s,%s,%s,%s,%s)""",
(self.project_id, notification["notification_id"], notification["task_id"],
notification["created_at"], Jsonb(notification)),
)
async def resolve_notifications(
self,
task_id: str,
*,
kinds: list[str],
subject_type: str,
subject_id: str,
resolved_at,
resolution_reason: str,
) -> list[dict]:
cursor = await self.connection.execute(
"""UPDATE workbench.operational_task_notifications
SET document=document || jsonb_build_object(
'lifecycle_status','resolved',
'resolved_at',to_jsonb(%s::timestamptz),
'resolution_reason',%s::text
)
WHERE project_id=%s AND task_id=%s
AND COALESCE(document->>'lifecycle_status','active')='active'
AND document->>'kind'=ANY(%s)
AND document->>'subject_type'=%s
AND document->>'subject_id'=%s
RETURNING document""",
(
resolved_at,
resolution_reason,
self.project_id,
task_id,
kinds,
subject_type,
subject_id,
),
)
return [row["document"] for row in await cursor.fetchall()]
async def get_notification(self, notification_id: str, user_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT n.document || jsonb_build_object(
'status',CASE WHEN r.read_at IS NULL THEN 'unread' ELSE 'read' END,
'version',COALESCE(r.version,1),
'read_at',r.read_at,'read_by',
CASE WHEN r.user_id IS NULL THEN NULL ELSE 'principal:' || r.user_id::text END
) AS document
FROM workbench.operational_task_notifications n
LEFT JOIN workbench.operational_task_notification_reads r
ON r.project_id=n.project_id AND r.notification_id=n.notification_id
AND r.user_id=%s
WHERE n.project_id=%s AND n.notification_id=%s""",
(user_id, self.project_id, notification_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def mark_notification_read(
self, notification_id: str, user_id: str, version: int, read_at
) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_notification_reads
(project_id,notification_id,user_id,version,read_at)
VALUES (%s,%s,%s,%s,%s)
ON CONFLICT(project_id,notification_id,user_id) DO UPDATE SET
version=excluded.version,read_at=excluded.read_at""",
(self.project_id, notification_id, user_id, version, read_at),
)
async def list_notifications(
self, *, user_id: str, allowed_permissions: list[str],
unread_only: bool, limit: int, offset: int
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT n.document || jsonb_build_object(
'status',CASE WHEN r.read_at IS NULL THEN 'unread' ELSE 'read' END,
'version',COALESCE(r.version,1),
'read_at',r.read_at,'read_by',
CASE WHEN r.user_id IS NULL THEN NULL ELSE 'principal:' || r.user_id::text END
) AS document
FROM workbench.operational_task_notifications n
LEFT JOIN workbench.operational_task_notification_reads r
ON r.project_id=n.project_id AND r.notification_id=n.notification_id
AND r.user_id=%s
WHERE n.project_id=%s
AND COALESCE(n.document->>'lifecycle_status','active')='active'
AND (%s=false OR r.read_at IS NULL)
AND (n.document->>'required_permission' IS NULL
OR n.document->>'required_permission'=ANY(%s))
ORDER BY n.created_at DESC,n.notification_id LIMIT %s OFFSET %s""",
(user_id, self.project_id, unread_only, allowed_permissions, limit, offset),
)
return [row["document"] for row in await cursor.fetchall()]
async def get_schedule_by_request(self, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_schedules
WHERE project_id=%s AND create_request_id=%s""",
(self.project_id, request_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def insert_schedule(self, schedule: dict, request_id: str) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_schedules
(project_id,schedule_id,create_request_id,schedule_key,next_run_at,
interval_seconds,enabled,version,document,created_at,updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(self.project_id, schedule["schedule_id"], request_id,
schedule["schedule_key"], schedule["next_run_at"],
schedule["interval_seconds"], schedule["enabled"], schedule["version"],
Jsonb(schedule), schedule["created_at"], schedule["updated_at"]),
)
async def list_schedules(self) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_schedules
WHERE project_id=%s ORDER BY next_run_at,schedule_id""",
(self.project_id,),
)
return [row["document"] for row in await cursor.fetchall()]
async def get_schedule(self, schedule_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_schedules
WHERE project_id=%s AND schedule_id=%s""",
(self.project_id, schedule_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def update_schedule(self, schedule: dict) -> None:
cursor = await self.connection.execute(
"""UPDATE workbench.operational_task_schedules SET
enabled=%s,version=%s,updated_at=%s,document=%s
WHERE project_id=%s AND schedule_id=%s AND version=%s""",
(
schedule["enabled"], schedule["version"], schedule["updated_at"],
Jsonb(schedule), self.project_id, schedule["schedule_id"],
schedule["version"] - 1,
),
)
if cursor.rowcount != 1:
raise DomainError("VERSION_CONFLICT", "Schedule version changed", status=409)
async def claim_due_schedules(self, limit: int) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_schedules
WHERE project_id=%s AND enabled=true AND next_run_at <= now()
ORDER BY next_run_at,schedule_id FOR UPDATE SKIP LOCKED LIMIT %s""",
(self.project_id, limit),
)
return [row["document"] for row in await cursor.fetchall()]
async def advance_schedule(self, schedule: dict) -> None:
await self.connection.execute(
"""UPDATE workbench.operational_task_schedules SET
next_run_at=%s,version=%s,updated_at=%s,document=%s
WHERE project_id=%s AND schedule_id=%s""",
(schedule["next_run_at"], schedule["version"], schedule["updated_at"],
Jsonb(schedule), self.project_id, schedule["schedule_id"]),
)
async def insert_occurrence(self, schedule_id: str, due_at, task_id: str, created_at) -> bool:
cursor = await self.connection.execute(
"""INSERT INTO workbench.operational_schedule_occurrences
(project_id,schedule_id,due_at,task_id,created_at)
VALUES (%s,%s,%s,%s,%s)
ON CONFLICT(project_id,schedule_id,due_at) DO NOTHING
RETURNING task_id""",
(self.project_id, schedule_id, due_at, task_id, created_at),
)
return await cursor.fetchone() is not None
+406
View File
@@ -0,0 +1,406 @@
"""Bounded read primitives over existing project views and time-series tables."""
from datetime import datetime
from typing import Any
from uuid import UUID
from psycopg import AsyncConnection
from psycopg import sql
from psycopg.rows import dict_row
from fastapi import HTTPException
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
ELEMENT_VIEWS = {
v: v + "s" for v in ("junction", "pipe", "reservoir", "tank", "pump", "valve")
}
MAX_QUERY_BYTES = 8 * 1024 * 1024
async def read_rows(conn, statement, params=(), *, timeout_ms=30000):
async with conn.transaction(), conn.cursor(row_factory=dict_row) as cur:
await cur.execute(
"SELECT set_config('statement_timeout',%s,true)", (str(timeout_ms),)
)
await cur.execute(statement, params)
return await cur.fetchall()
async def asset_rows(conn, element_type, *, element_id=None, limit=100, offset=0):
view = ELEMENT_VIEWS[element_type]
where = sql.SQL("WHERE v.id=%s") if element_id else sql.SQL("")
params = (element_id,) if element_id else ()
statement = sql.SQL(
"SELECT to_jsonb(v)-'geom' AS record FROM gis.{} v {} ORDER BY v.id LIMIT %s OFFSET %s"
).format(sql.Identifier(view), where)
rows = await read_rows(conn, statement, params + (limit, offset))
return [row["record"] for row in rows]
async def scada_devices(conn, *, device_id=None, limit=100, offset=0):
where = " WHERE id=%s" if device_id else ""
params = (device_id,) if device_id else ()
rows = await read_rows(
conn,
_SCADA_VIEW_SELECT + where + " ORDER BY device_id LIMIT %s OFFSET %s",
params + (limit, offset),
)
return [_device(row) for row in rows]
async def series_rows(
conn,
*,
source,
element_type,
ids,
start=None,
end=None,
time=None,
field=None,
run_id=None,
limit=1000,
offset=0,
end_exclusive=False,
):
node = element_type == "junction"
if source == "scada":
table, id_column, fields = (
"measurements",
"device_id",
{"monitored_value", "cleaned_value"},
)
else:
table, id_column = (
("node_results", "node_id") if node else ("link_results", "link_id")
)
fields = (
(
AnalysisResultsRepository.NODE_FIELDS
if node
else AnalysisResultsRepository.LINK_FIELDS
)
if source == "analysis"
else (
RealtimeRepository.NODE_FIELDS
if node
else RealtimeRepository.LINK_FIELDS
)
)
if field is not None and field not in fields:
raise ValueError("unsupported field for this data source and element type")
columns = (
sql.SQL("{},time,{} AS value").format(
sql.Identifier(id_column), sql.Identifier(field)
)
if field
else sql.SQL("*")
)
filters = []
params = []
if ids:
filters.append(sql.SQL("{}=ANY(%s)").format(sql.Identifier(id_column)))
params.append(ids)
if run_id:
filters.append(sql.SQL("run_id=%s"))
params.append(run_id)
if start is not None:
filters.append(
sql.SQL(
"time >= %s AND time < %s"
if end_exclusive
else "time >= %s AND time <= %s"
)
)
params.extend((start, end))
else:
# Existing point-query semantics use +/- one second.
filters.append(
sql.SQL("time BETWEEN %s-interval '1 second' AND %s+interval '1 second'")
)
params.extend((time, time))
statement = sql.SQL(
"SELECT {} FROM {}.{} WHERE {} ORDER BY time,{} LIMIT %s OFFSET %s"
).format(
columns,
sql.Identifier(source),
sql.Identifier(table),
sql.SQL(" AND ").join(filters),
sql.Identifier(id_column),
)
return await read_rows(conn, statement, tuple(params) + (limit, offset))
async def sensor_runs(conn, *, run_id=None, limit=100, offset=0):
where = " AND r.run_id=%s" if run_id else ""
params = (run_id,) if run_id else ()
return await read_rows(
conn,
"""SELECT r.run_id,r.name,r.created_by,r.created_at,r.status,
result.payload FROM analysis.runs r JOIN LATERAL
(SELECT payload FROM analysis.results WHERE run_id=r.run_id
AND result_type='sensor_placement' ORDER BY created_at DESC,result_id DESC LIMIT 1) result ON true
WHERE r.run_type='sensor_placement'"""
+ where
+ " ORDER BY r.created_at DESC,r.run_id LIMIT %s OFFSET %s",
params + (limit, offset),
)
async def history_rows(
conn: AsyncConnection, descriptors: list[dict[str, Any]], *,
start: datetime, end: datetime, run_id: UUID | None, limit: int, offset: int,
) -> list[dict[str, Any]]:
"""Page points across raw, cleaned and model series in one SQL statement."""
parts, params = [], []
for index, item in enumerate(descriptors):
source, field = item["source"], item["field"]
if source == "scada":
table, id_column, fields = "measurements", "device_id", {"monitored_value", "cleaned_value"}
elif source in {"realtime", "analysis"}:
node = item["element_type"] == "junction"
table, id_column = ("node_results", "node_id") if node else ("link_results", "link_id")
fields = {"pressure"} if node else {"flow"}
else:
raise ValueError("unsupported history source")
if field not in fields or (source == "analysis" and run_id is None):
raise ValueError("unsupported history field or missing run")
parts.append(sql.SQL(
"SELECT %s::integer AS series_index,time,{} AS value FROM {}.{} "
"WHERE {}=%s AND time >= %s AND time <= %s{}"
).format(
sql.Identifier(field), sql.Identifier(source), sql.Identifier(table),
sql.Identifier(id_column), sql.SQL(" AND run_id=%s" if source == "analysis" else ""),
))
params.extend((index, item["id"], start, end))
if source == "analysis":
params.append(run_id)
if not parts:
return []
statement = sql.SQL(
"SELECT series_index,time,value FROM ({}) points "
"ORDER BY series_index, time LIMIT %s OFFSET %s"
).format(sql.SQL(" UNION ALL ").join(parts))
return await read_rows(conn, statement, tuple(params) + (limit, offset))
async def evidence_page(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, evidence_id: str, data_path: list[str],
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
) -> dict[str, Any]:
"""Project the immutable snapshot in PostgreSQL before transferring JSON."""
return await _snapshot_page(conn, stored_sql="""
SELECT document->'data_snapshot' AS snapshot
FROM workbench.operational_task_evidence
WHERE project_id=%s AND task_id=%s AND evidence_id=%s
AND jsonb_typeof(document->'data_snapshot')='object'
""", stored_params=(project_id, task_id, evidence_id), data_path=data_path,
limit=limit, offset=offset, mcp_sources=mcp_sources, full_snapshot=full_snapshot)
async def _snapshot_page(
conn: AsyncConnection, *, stored_sql: str, stored_params: tuple, data_path: list[str],
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
max_bytes: int = MAX_QUERY_BYTES,
) -> dict[str, Any]:
"""Shared JSONB projection; callers supply only fixed, scoped SQL sources."""
def descriptor(value, path):
return f"""jsonb_build_object(
'collection_type',jsonb_typeof({value}),
'count',CASE jsonb_typeof({value}) WHEN 'array' THEN jsonb_array_length({value})
WHEN 'string' THEN char_length({value} #>> '{{}}')
ELSE (SELECT count(*) FROM jsonb_object_keys({value})) END,
'data_path',to_jsonb({path}))"""
collection = descriptor("p.value", "p.path")
child = descriptor("child.item", "p.path || ARRAY[child.key]")
statement = """
WITH stored AS MATERIALIZED (
STORED_QUERY
), captured AS (
SELECT snapshot,
snapshot->>'source'=ANY(%s::text[])
AND jsonb_typeof(snapshot->'data')='object' AS is_mcp
FROM stored
), selected AS MATERIALIZED (
SELECT snapshot,
CASE WHEN is_mcp THEN (snapshot->'data')-'data' END AS query_metadata,
(CASE WHEN is_mcp THEN snapshot->'data'->'data'
ELSE snapshot->'data' END) #> %s::text[] AS value
FROM captured
), entries AS (
SELECT ordinality AS position, NULL::text AS key, item AS value
FROM selected, jsonb_array_elements(
CASE WHEN jsonb_typeof(value)='array' THEN value ELSE '[]'::jsonb END
) WITH ORDINALITY AS a(item, ordinality)
UNION ALL
SELECT row_number() OVER (ORDER BY e.key COLLATE "C"), e.key, e.item
FROM selected, jsonb_each(
CASE WHEN jsonb_typeof(value)='object' THEN value ELSE '{}'::jsonb END
) AS e(key,item)
), paged AS MATERIALIZED (
SELECT * FROM entries ORDER BY position LIMIT %s OFFSET %s
), paths AS (
SELECT paged.*, jsonb_typeof(selected.value) AS parent_type,
%s::text[] || ARRAY[CASE WHEN jsonb_typeof(selected.value)='array'
THEN (position-1)::text ELSE key END] AS path
FROM paged,selected
), summaries AS (
SELECT p.position,p.key,p.value AS original,
CASE WHEN jsonb_typeof(p.value) IN ('array','object') OR
(jsonb_typeof(p.value)='string' AND char_length(p.value #>> '{}')>256) THEN
CASE WHEN p.parent_type='object' OR jsonb_typeof(p.value) IN ('array','string')
THEN COLLECTION_PREVIEW
ELSE COALESCE((SELECT jsonb_object_agg(child.key,
CASE WHEN jsonb_typeof(child.item) IN ('array','object') OR
(jsonb_typeof(child.item)='string' AND char_length(child.item #>> '{}')>256)
THEN CHILD_PREVIEW ELSE child.item END)
FROM jsonb_each(p.value) AS child(key,item)), '{}'::jsonb)
END ELSE p.value END AS value
FROM paths p
), projected AS (
SELECT CASE WHEN %s THEN snapshot ELSE jsonb_build_object(
'snapshot_metadata',snapshot-'data', 'query_metadata',query_metadata,
'nested',EXISTS(SELECT 1 FROM summaries WHERE original<>value),
'value_type',jsonb_typeof(value),
'total',CASE jsonb_typeof(value)
WHEN 'array' THEN jsonb_array_length(value)
WHEN 'object' THEN (SELECT count(*) FROM jsonb_object_keys(value))
WHEN 'string' THEN char_length(value #>> '{}') END,
'value',CASE jsonb_typeof(value)
WHEN 'array' THEN COALESCE((SELECT jsonb_agg(value ORDER BY position) FROM summaries),'[]'::jsonb)
WHEN 'object' THEN COALESCE((SELECT jsonb_object_agg(key,value) FROM summaries),'{}'::jsonb)
WHEN 'string' THEN to_jsonb(substring(value #>> '{}' FROM %s FOR %s))
ELSE value END
) END AS payload, value IS NULL AS missing_path
FROM selected
), sized AS MATERIALIZED (
SELECT payload,missing_path,octet_length(payload::text)>%s AS too_large
FROM projected
)
SELECT CASE WHEN NOT too_large THEN payload END AS payload,too_large,missing_path
FROM sized
"""
statement = statement.replace("STORED_QUERY", stored_sql).replace("COLLECTION_PREVIEW", collection).replace("CHILD_PREVIEW", child)
rows = await read_rows(conn, statement, (
*stored_params, mcp_sources, data_path, limit, offset,
data_path, full_snapshot, offset + 1, limit, max_bytes,
))
if not rows:
raise HTTPException(404, "Stored query document was not found")
row = rows[0]
if row["too_large"]:
raise HTTPException(413, "Query exceeds the byte budget; select a deeper data_path or smaller page")
if row.get("missing_path") and not full_snapshot:
raise HTTPException(422, "Evidence data_path does not name a stored value")
return row["payload"]
async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, report_revision: int | None) -> dict:
"""Read fixed-size metadata and explicit previews, never a complete report."""
report = """jsonb_build_object('revision',r.revision,
'execution_generation',r.document->'execution_generation',
'stage',r.document#>'{report,stage}','outcome',r.document#>'{report,outcome}',
'current_conclusion_preview',left(r.document#>>'{report,current_conclusion}',200),
'preview_only',true,'detail',jsonb_build_object('section','report','report_revision',r.revision))"""
rows = await read_rows(conn, """
SELECT jsonb_build_object('task',jsonb_build_object(
'task_id',t.task_id,'task_type',t.task_type,'workflow',t.document->>'workflow','status',t.status,
'lifecycle_status',t.lifecycle_status,'priority',t.priority,'revision',t.revision,
'execution_generation',t.execution_generation,
'current_report_revision',t.current_report_revision,
'title_preview',left(t.document->>'title',160),'preview_only',true,
'detail',jsonb_build_object('section','task')),
'latest_report',(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
WHERE r.project_id=t.project_id AND r.task_id=t.task_id ORDER BY r.revision DESC LIMIT 1),
'selected_report',CASE WHEN %s::int IS NULL THEN NULL ELSE
(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
WHERE r.project_id=t.project_id AND r.task_id=t.task_id AND r.revision=%s) END
) AS payload
FROM workbench.operational_tasks t WHERE t.project_id=%s AND t.task_id=%s
""".replace("REPORT_SUMMARY", report), (report_revision, report_revision, project_id, task_id))
if not rows:
raise HTTPException(404, "Task was not found in this project")
result = rows[0]["payload"]
if report_revision is not None and result["selected_report"] is None:
raise HTTPException(404, "Selected report revision was not found")
return result
async def task_context_page(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
report_revision: int | None, data_path: list[str], limit: int, offset: int,
) -> dict:
"""Page only the requested task section inside PostgreSQL, before transfer."""
collections = {
"evidence": ("operational_task_evidence", "evidence_id"),
"actions": ("operational_task_actions", "action_id"),
"access_requests": ("operational_task_access_requests", "access_request_id"),
}
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
if section in {"access_requests", "progress"}:
table = "operational_task_access_requests" if section == "access_requests" else "operational_task_progress"
available = await read_rows(conn, "SELECT to_regclass(%s) AS relation", ("workbench." + table,))
if not available[0]["relation"]:
return {"snapshot_metadata": {"section": section, "available": False}, "value": None, "value_type": "null", "total": None, "nested": False}
projection_offset = offset
if section in collections:
table, key = collections[section]
# These identifiers come exclusively from the fixed table above.
relation = sql.Identifier("workbench", table).as_string()
identifier = sql.Identifier(key).as_string()
if data_path:
stored = f"""SELECT jsonb_build_object('section',%s::text,'data',jsonb_build_object({identifier}::text,document)) AS snapshot
FROM {relation} WHERE project_id=%s AND task_id=%s AND {identifier}::text=%s"""
params = (section, project_id, task_id, data_path[0])
else:
# Fetch just this page of rows. Collection keys remain stable across
# paging, so every nested data_path uses the real evidence/action ID.
stored = f"""WITH items AS MATERIALIZED (
SELECT {identifier}::text AS key,document FROM {relation}
WHERE project_id=%s AND task_id=%s
ORDER BY {identifier}::text COLLATE "C" LIMIT %s OFFSET %s)
SELECT jsonb_build_object('section',%s::text,
'total',(SELECT count(*) FROM {relation} WHERE project_id=%s AND task_id=%s),
'data',COALESCE(jsonb_object_agg(key,document),'{{}}'::jsonb)) AS snapshot FROM items"""
params = (project_id, task_id, limit + 1, offset, section, project_id, task_id)
projection_offset = 0
elif section in documents:
relation = sql.Identifier("workbench", documents[section]).as_string()
where = ""
params = (section, project_id, task_id)
if section == "report":
where = " AND (%s::int IS NULL OR revision=%s) ORDER BY revision DESC LIMIT 1"
params += (report_revision, report_revision)
elif section == "plan":
where = " ORDER BY revision DESC LIMIT 1"
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
elif section == "run":
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id') AS snapshot
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
params = (project_id, task_id)
elif section == "progress":
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
FROM workbench.operational_task_progress p
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation
WHERE p.project_id=%s AND p.task_id=%s"""
params = (project_id, task_id)
else:
raise HTTPException(422, "Unknown task context section")
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
async def task_data_scopes(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, permissions: set[str]) -> list[str]:
"""Bound scope discovery to seven known sources, without loading task detail."""
known = ["workbench_runtime", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", "scada_devices", "scada_readings"]
scopes = {name for name in known if ("scada.view" if name.startswith("scada_") else "simulation.view") in permissions}
available = await read_rows(conn, "SELECT to_regclass('workbench.operational_task_access_requests') AS relation")
if available[0]["relation"]:
rows = await read_rows(conn, """SELECT DISTINCT scope FROM workbench.operational_task_access_requests,
LATERAL jsonb_array_elements_text(COALESCE(document->'approved_scopes','[]'::jsonb)) AS granted(scope)
WHERE project_id=%s AND task_id=%s AND status='approved' AND scope=ANY(%s::text[])""", (project_id, task_id, known))
scopes.update(row["scope"] for row in rows)
return [name for name in known if name in scopes]
+4 -2
View File
@@ -111,14 +111,16 @@ class ScadaInfoRepository:
conn: AsyncConnection,
node_ids: list[str],
link_ids: list[str],
*, row_limit: int | None = None,
) -> list[dict[str, Any]]:
if not node_ids and not link_ids:
return []
async with conn.cursor() as cur:
await cur.execute(
_SCADA_VIEW_SELECT
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id",
(node_ids, link_ids),
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id"
+ (" LIMIT %s" if row_limit is not None else ""),
(node_ids, link_ids) + ((row_limit,) if row_limit is not None else ()),
)
return [_device(record) for record in await cur.fetchall()]
@@ -24,6 +24,8 @@ def _placement_row(row: dict[str, Any]) -> dict[str, Any]:
"created_by": row["created_by"],
"created_at": row["created_at"],
"status": row["status"],
"sensor_type": payload.get("sensor_type", "pressure"),
"method": payload.get("method"),
"sensor_locations": locations,
}
@@ -57,11 +59,15 @@ def create_sensor_placement(
min_diameter: int,
created_by: str,
sensor_locations: list[str],
sensor_type: str = "pressure",
method: str | None = None,
) -> dict[str, Any]:
run_id = uuid4()
payload = {
"sensor_number": len(sensor_locations),
"minimum_diameter": min_diameter,
"sensor_type": sensor_type,
"method": method,
"sensor_locations": sensor_locations,
}
with project_connection(name) as conn, conn.transaction():
@@ -0,0 +1,71 @@
"""Bounded nearest-model queries on the already authorized project connection."""
from math import asin, cos, degrees, radians, sin
from typing import Any
from psycopg import AsyncConnection, sql
from app.infra.db.postgresql.read_queries import ELEMENT_VIEWS, read_rows
_MODEL_RELATIONS = {
kind: sql.Identifier("gis", view) for kind, view in ELEMENT_VIEWS.items()
}
def _search_boxes(
longitude: float, latitude: float, radius_m: float,
) -> list[tuple[float, float, float, float]]:
# A deliberately smaller-than-WGS84 curvature radius gives a conservative
# geographic envelope. It is only a GiST candidate filter; geography below
# determines the actual radius and distances. Split at the antimeridian.
angle = (radius_m + 10) / 6_300_000
low, high = latitude - degrees(angle), latitude + degrees(angle)
if low < -85 or high > 85:
# Avoid Mercator's polar singularity; retain exact geography filtering.
return []
width = degrees(asin(min(1.0, sin(angle) / cos(radians(latitude)))))
left, right = longitude - width, longitude + width
if left < -180:
return [(-180, low, right, high), (left + 360, low, 180, high)]
if right > 180:
return [(left, low, 180, high), (-180, low, right - 360, high)]
return [(left, low, right, high)]
async def nearby_elements(
conn: AsyncConnection,
element_type: str,
*,
center: tuple[float, float],
radius_m: float,
limit: int,
offset: int,
) -> list[dict[str, Any]]:
"""Rank complete model geometries by spheroidal distance, never endpoints."""
relation = _MODEL_RELATIONS[element_type]
filters = []
parameters: list[Any] = [*center]
for box in _search_boxes(*center, radius_m):
filters.append(sql.SQL(
"v.geom && ST_Transform(ST_MakeEnvelope(%s,%s,%s,%s,4326),3857)"
))
parameters.extend(box)
candidate_filter = sql.SQL(" OR ").join(filters) if filters else sql.SQL("TRUE")
statement = sql.SQL("""
WITH target AS (
SELECT ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography AS point
), candidates AS MATERIALIZED (
SELECT v.id, to_jsonb(v)-'geom' AS properties,
ST_Transform(v.geom,4326)::geography AS shape
FROM {} v
WHERE v.geom IS NOT NULL AND NOT ST_IsEmpty(v.geom) AND ({})
)
SELECT c.id AS element_id, c.properties,
ST_Distance(c.shape,t.point,true) AS distance_m
FROM candidates c CROSS JOIN target t
WHERE ST_DWithin(c.shape,t.point,%s,true)
ORDER BY distance_m,c.id
LIMIT %s OFFSET %s
""").format(relation, candidate_filter)
parameters.extend((radius_m, limit, offset))
return await read_rows(conn, statement, tuple(parameters))
+109
View File
@@ -0,0 +1,109 @@
"""PostgreSQL persistence for task-produced alert events and current state."""
from __future__ import annotations
from contextlib import asynccontextmanager
from psycopg import AsyncConnection
from psycopg.errors import UndefinedTable
from psycopg.types.json import Jsonb
from uuid import uuid4
from app.domain.domain_errors import DomainError
from app.infra.db.postgresql.workbench_events import lock_event_transaction
class TaskAlertRepository:
def __init__(self, connection: AsyncConnection, project_id):
self.connection = connection
self.project_id = str(project_id)
@asynccontextmanager
async def transaction(self):
try:
async with self.connection.transaction():
await lock_event_transaction(self.connection)
yield self
except UndefinedTable as exc:
raise DomainError(
"DEPENDENCY_UNAVAILABLE",
"Project alert storage is not provisioned",
status=503,
) from exc
async def lock_alert(self, alert_id: str) -> None:
await self.connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
(f"tjwater-alert:{self.project_id}:{alert_id}",),
)
async def get_event(self, producer_id: str, event_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.alert_events
WHERE project_id=%s AND producer_id=%s AND event_id=%s""",
(self.project_id, producer_id, event_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def get_alert(self, alert_id: str) -> tuple[int, dict] | None:
cursor = await self.connection.execute(
"""SELECT revision,document FROM workbench.alerts
WHERE project_id=%s AND alert_id=%s""",
(self.project_id, alert_id),
)
row = await cursor.fetchone()
return (row["revision"], row["document"]) if row else None
async def save_event(self, event: dict, resulting_state: dict) -> None:
document = event | {"resulting_state": resulting_state}
await self.connection.execute(
"""INSERT INTO workbench.alert_events
(project_id,producer_id,event_id,alert_id,event_type,occurred_at,document)
VALUES (%s,%s,%s,%s,%s,%s,%s)""",
(
self.project_id,
event["producer_id"],
event["event_id"],
event["alert_id"],
event["event_type"],
event["occurred_at"],
Jsonb(document),
),
)
async def save_alert(self, state: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.alerts
(project_id,alert_id,revision,status,occurred_at,updated_at,document)
VALUES (%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(project_id,alert_id) DO UPDATE SET
revision=excluded.revision,status=excluded.status,
updated_at=excluded.updated_at,document=excluded.document""",
(
self.project_id,
state["alert_id"],
state["revision"],
state["status"],
state["occurred_at"],
state["updated_at"],
Jsonb({key: value for key, value in state.items() if key != "revision"}),
),
)
async def append_changed_event(self, state: dict) -> None:
cursor = await self.connection.execute(
"""INSERT INTO workbench.domain_event_log
(project_id,event_id,topic,aggregate_type,aggregate_id,
aggregate_revision,occurred_at,metadata)
VALUES (%s,%s,'workbench.alert.changed','workbench_alert',%s,%s,%s,%s)
RETURNING event_seq""",
(
self.project_id, uuid4(), state["alert_id"], state["revision"],
state["updated_at"], Jsonb({"alert_id": state["alert_id"]}),
),
)
row = await cursor.fetchone()
await self.connection.execute(
"SELECT pg_notify('workbench_events', %s)",
(f'{self.project_id}:{row["event_seq"]}',),
)
@@ -0,0 +1,221 @@
"""Per-process, project-scoped PostgreSQL LISTEN hubs for workbench SSE."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections import OrderedDict
from contextlib import asynccontextmanager
from uuid import UUID
from psycopg import AsyncConnection
from psycopg_pool import AsyncConnectionPool
from psycopg.rows import dict_row
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
logger = logging.getLogger(__name__)
class WorkbenchEventHub:
"""Own one LISTEN connection and fan wake-ups out to SSE subscribers."""
def __init__(self, project_id: UUID, dsn: str) -> None:
self.project_id = project_id
self.dsn = dsn
self.subscribers = 0
self._connection: AsyncConnection | None = None
self._pool: AsyncConnectionPool | None = None
self._connection_lock = asyncio.Lock()
self._read_lock = asyncio.Lock()
self._reads: OrderedDict[tuple, tuple[int, object]] = OrderedDict()
self._condition = asyncio.Condition()
self._generation = 0
self._ready = asyncio.Event()
self._closed = False
self._listener_task: asyncio.Task | None = None
self._idle_task: asyncio.Task | None = None
async def start(self) -> None:
if self._listener_task is None or self._listener_task.done():
self._closed = False
self._ready.clear()
self._listener_task = asyncio.create_task(
self._listen_loop(), name=f"workbench-events:{self.project_id}"
)
await asyncio.wait_for(self._ready.wait(), timeout=10)
async def _connect(self) -> AsyncConnection:
if self._pool is None:
self._pool = AsyncConnectionPool(
conninfo=self.dsn,
min_size=0,
max_size=1,
open=False,
kwargs={"autocommit": True, "row_factory": dict_row},
)
await self._pool.open()
connection = await self._pool.getconn()
await connection.execute("LISTEN workbench_events")
return connection
async def _listen_loop(self) -> None:
retry_seconds = 1
next_reconcile = asyncio.get_running_loop().time() + 30
while not self._closed:
try:
if self._connection is None or self._connection.closed:
async with self._connection_lock:
if self._connection is None or self._connection.closed:
self._connection = await self._connect()
self._ready.set()
retry_seconds = 1
notified = False
async with self._connection_lock:
connection = self._connection
if connection is None:
continue
async for notification in connection.notifies(timeout=1, stop_after=1):
if notification.payload.startswith(f"{self.project_id}:"):
notified = True
break
now = asyncio.get_running_loop().time()
if notified or now >= next_reconcile:
await self._wake()
if now >= next_reconcile:
next_reconcile = now + 30
await asyncio.sleep(0)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Workbench event listener failed for project %s", self.project_id)
await self._drop_connection()
await self._wake()
await asyncio.sleep(retry_seconds)
retry_seconds = min(retry_seconds * 2, 30)
async def _wake(self) -> None:
async with self._condition:
self._generation += 1
self._condition.notify_all()
async def wait_for_wake(self, generation: int, timeout: float) -> int:
async with self._condition:
if self._generation != generation:
return self._generation
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(
self._condition.wait_for(lambda: self._generation != generation),
timeout=timeout,
)
return self._generation
@property
def generation(self) -> int:
return self._generation
async def oldest_cursor(self) -> int | None:
return await self._read(("oldest",), lambda repository: repository.oldest_cursor())
async def current_cursor(self) -> int:
return await self._read(("current",), lambda repository: repository.current_cursor())
async def read_after(self, after: int) -> list[dict]:
return await self._read(("after", after), lambda repository: repository.read_after(after))
async def _read(self, key: tuple, operation):
await self.start()
async with self._read_lock:
cached = self._reads.get(key)
if cached is not None and cached[0] == self.generation:
self._reads.move_to_end(key)
return cached[1]
# Record the generation before the query: a concurrent NOTIFY must
# invalidate even an empty result from an older database snapshot.
generation = self.generation
async with self._connection_lock:
if self._connection is None or self._connection.closed:
self._connection = await self._connect()
self._ready.set()
repository = WorkbenchEventRepository(self._connection, self.project_id)
result = await operation(repository)
self._reads[key] = (generation, result)
self._reads.move_to_end(key)
while len(self._reads) > 32:
self._reads.popitem(last=False)
return result
async def _drop_connection(self) -> None:
async with self._connection_lock:
connection, self._connection = self._connection, None
if connection is not None and self._pool is not None:
with contextlib.suppress(Exception):
await self._pool.putconn(connection)
async def close(self) -> None:
self._closed = True
self._reads.clear()
if self._idle_task is not None:
self._idle_task.cancel()
self._idle_task = None
if self._listener_task is not None:
self._listener_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._listener_task
self._listener_task = None
await self._drop_connection()
if self._pool is not None:
await self._pool.close()
self._pool = None
class WorkbenchEventHubManager:
def __init__(self, idle_seconds: float = 60) -> None:
self.idle_seconds = idle_seconds
self._hubs: dict[tuple[UUID, str], WorkbenchEventHub] = {}
self._lock = asyncio.Lock()
@asynccontextmanager
async def subscribe(self, project_id: UUID, dsn: str):
key = (project_id, dsn)
async with self._lock:
hub = self._hubs.get(key)
if hub is None:
hub = WorkbenchEventHub(project_id, dsn)
self._hubs[key] = hub
if hub._idle_task is not None:
hub._idle_task.cancel()
hub._idle_task = None
hub.subscribers += 1
try:
await hub.start()
yield hub
finally:
async with self._lock:
hub.subscribers = max(0, hub.subscribers - 1)
if hub.subscribers == 0 and hub._idle_task is None:
hub._idle_task = asyncio.create_task(self._close_when_idle(key, hub))
async def _close_when_idle(
self, key: tuple[UUID, str], hub: WorkbenchEventHub
) -> None:
try:
await asyncio.sleep(self.idle_seconds)
async with self._lock:
if hub.subscribers or self._hubs.get(key) is not hub:
return
self._hubs.pop(key, None)
hub._idle_task = None
await hub.close()
except asyncio.CancelledError:
return
async def close_all(self) -> None:
async with self._lock:
hubs = list(self._hubs.values())
self._hubs.clear()
await asyncio.gather(*(hub.close() for hub in hubs), return_exceptions=True)
workbench_event_hubs = WorkbenchEventHubManager()
@@ -0,0 +1,60 @@
"""Cursor reads for the durable project-scoped workbench event log."""
from __future__ import annotations
from psycopg import AsyncConnection, Connection
EVENT_ORDER_LOCK = "tjwater-workbench:event-order"
async def lock_event_transaction(connection: AsyncConnection) -> None:
"""Serialize v2 event-producing transactions before business locks/writes.
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. The lock is local to the connected BizDB
and creates no schema or data in projects without an event log.
"""
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."""
connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
class WorkbenchEventRepository:
def __init__(self, connection: AsyncConnection, project_id):
self.connection = connection
self.project_id = str(project_id)
async def current_cursor(self) -> int:
cursor = await self.connection.execute(
"SELECT COALESCE(max(event_seq),0) AS cursor FROM workbench.domain_event_log WHERE project_id=%s",
(self.project_id,),
)
row = await cursor.fetchone()
return int(row["cursor"])
async def oldest_cursor(self) -> int | None:
cursor = await self.connection.execute(
"SELECT min(event_seq) AS cursor FROM workbench.domain_event_log WHERE project_id=%s",
(self.project_id,),
)
row = await cursor.fetchone()
return int(row["cursor"]) if row and row["cursor"] is not None else None
async def read_after(self, after: int, limit: int = 200) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT event_seq,event_id,project_id,topic,aggregate_type,aggregate_id,
aggregate_revision,occurred_at,metadata
FROM workbench.domain_event_log
WHERE project_id=%s AND event_seq>%s
ORDER BY event_seq LIMIT %s""",
(self.project_id, after, limit),
)
return [dict(row) for row in await cursor.fetchall()]
@@ -0,0 +1,49 @@
"""Optional project-local alert projection."""
from dataclasses import dataclass
from typing import Any
from psycopg import AsyncConnection
@dataclass(frozen=True)
class WorkbenchOperationsAvailability:
alerts: bool
schedules: bool = False
class WorkbenchOperationsRepository:
"""Read provisioned workbench records without creating or mutating storage."""
@staticmethod
async def availability(
conn: AsyncConnection,
) -> WorkbenchOperationsAvailability:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT to_regclass('workbench.alerts') IS NOT NULL AS alerts
"""
)
row = await cur.fetchone()
return WorkbenchOperationsAvailability(
alerts=bool(row and row["alerts"]),
)
@staticmethod
async def list_alerts(
conn: AsyncConnection, project_id: str, *, limit: int = 100
) -> list[dict[str, Any]]:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT document
FROM workbench.alerts
WHERE project_id = %s
AND status = 'active'
ORDER BY occurred_at DESC, alert_id
LIMIT %s
""",
(project_id, limit),
)
return [row["document"] for row in await cur.fetchall()]
+2
View File
@@ -4,6 +4,7 @@ from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Iterator
from uuid import UUID
from psycopg.conninfo import conninfo_to_dict, make_conninfo
@@ -15,6 +16,7 @@ class ActiveProjectRouting:
project_code: str
business_dsn: str
timescale_dsn: str | None = None
project_id: UUID | None = None
@property
def business_database_name(self) -> str:
@@ -184,6 +184,7 @@ class AnalysisResultsRepository:
start_time: datetime,
end_time: datetime,
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if element_type == "node":
table_name, id_column, valid_fields = (
@@ -214,8 +215,10 @@ class AnalysisResultsRepository:
sql.Identifier(id_column),
sql.Identifier(id_column),
)
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (run_id, element_ids, start_time, end_time))
await cur.execute(query, (run_id, element_ids, start_time, end_time) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["element_id"]), []).append(
{"time": row["time"], "value": row["value"]}
@@ -3,9 +3,28 @@ from datetime import datetime, timedelta
from collections import defaultdict
from psycopg import AsyncConnection, Connection, sql
from app.domain.time import parse_utc_time
from app.core.config import settings
class RealtimeRepository:
# The third set_config argument makes this transaction-local, so a pooled
# connection returns to its original guard after commit or rollback.
SNAPSHOT_BUDGET_SQL = "SELECT set_config('timescaledb.max_tuples_decompressed_per_dml_transaction', %s, true)"
@staticmethod
async def _configure_snapshot_write(cur) -> None:
await cur.execute(
RealtimeRepository.SNAPSHOT_BUDGET_SQL,
(str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),),
)
@staticmethod
def _configure_snapshot_write_sync(cur) -> None:
cur.execute(
RealtimeRepository.SNAPSHOT_BUDGET_SQL,
(str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),),
)
LINK_FIELDS = frozenset(
{
"flow",
@@ -93,6 +112,7 @@ class RealtimeRepository:
# 使用事务确保原子性
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -116,6 +136,7 @@ class RealtimeRepository:
# 使用事务确保原子性
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -163,6 +184,7 @@ class RealtimeRepository:
end_time: datetime,
link_ids: list[str],
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if field not in RealtimeRepository.LINK_FIELDS:
raise ValueError(f"Invalid field: {field}")
@@ -174,8 +196,10 @@ class RealtimeRepository:
"WHERE time BETWEEN %s AND %s AND link_id = ANY(%s) "
"ORDER BY link_id, time"
).format(sql.Identifier(field))
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (start_time, end_time, link_ids))
await cur.execute(query, (start_time, end_time, link_ids) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["link_id"]), []).append(
{"time": row["time"].isoformat(), "value": row[field]}
@@ -282,6 +306,7 @@ class RealtimeRepository:
# 使用事务确保原子性
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -304,6 +329,7 @@ class RealtimeRepository:
# 使用事务确保原子性
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -351,6 +377,7 @@ class RealtimeRepository:
end_time: datetime,
node_ids: list[str],
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if field not in RealtimeRepository.NODE_FIELDS:
raise ValueError(f"Invalid field: {field}")
@@ -362,8 +389,10 @@ class RealtimeRepository:
"WHERE time BETWEEN %s AND %s AND node_id = ANY(%s) "
"ORDER BY node_id, time"
).format(sql.Identifier(field))
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (start_time, end_time, node_ids))
await cur.execute(query, (start_time, end_time, node_ids) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["node_id"]), []).append(
{"time": row["time"].isoformat(), "value": row[field]}
@@ -482,6 +511,7 @@ class RealtimeRepository:
# table. Copy-only helpers avoid repeating replacement SQL.
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(simulation_time,),
@@ -559,6 +589,7 @@ class RealtimeRepository:
# table. Copy-only helpers avoid repeating replacement SQL.
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(simulation_time,),
+23 -2
View File
@@ -34,16 +34,37 @@ class ScadaRepository:
device_ids: List[str],
start_time: datetime,
end_time: datetime,
*, row_limit: int | None = None,
) -> List[dict]:
async with conn.cursor() as cur:
await cur.execute(
f"SELECT {ScadaRepository.RESULT_COLUMNS} FROM scada.measurements "
"WHERE device_id = ANY(%s) "
"AND time >= %s AND time <= %s ORDER BY device_id, time",
(device_ids, start_time, end_time),
"AND time >= %s AND time <= %s ORDER BY device_id, time"
+ (" LIMIT %s" if row_limit is not None else ""),
(device_ids, start_time, end_time) + ((row_limit,) if row_limit is not None else ()),
)
return await cur.fetchall()
@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()
+83
View File
@@ -94,6 +94,89 @@ class GeoServerAdminClient:
)
return True
def feature_type_exists(
self,
*,
workspace: str,
datastore: str,
layer: str,
) -> bool:
response = self._client.get(
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}.json"
)
if response.status_code == 404:
return False
if response.is_error:
raise GeoServerProvisioningError(
f"GeoServer feature type check failed with HTTP {response.status_code}"
)
return True
def publish_feature_type(
self,
*,
workspace: str,
datastore: str,
layer: str,
title: str,
map_bbox: tuple[float, float, float, float],
) -> None:
minx, miny, maxx, maxy = map_bbox
payload = {
"featureType": {
"name": layer,
"nativeName": layer,
"title": title,
"srs": "EPSG:3857",
"projectionPolicy": "FORCE_DECLARED",
"enabled": True,
"advertised": True,
"nativeBoundingBox": {
"minx": minx,
"miny": miny,
"maxx": maxx,
"maxy": maxy,
"crs": "EPSG:3857",
},
"latLonBoundingBox": {
"minx": _longitude(minx),
"miny": _latitude(miny),
"maxx": _longitude(maxx),
"maxy": _latitude(maxy),
"crs": "EPSG:4326",
},
}
}
if self.feature_type_exists(
workspace=workspace,
datastore=datastore,
layer=layer,
):
self._request(
"PUT",
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes/{layer}",
json=payload,
)
else:
self._request(
"POST",
f"/rest/workspaces/{workspace}/datastores/{datastore}/featuretypes",
json=payload,
)
self._set_client_cache(workspace, layer)
def truncate_layer_cache(self, workspace: str, layer: str) -> None:
self._request(
"POST",
"/gwc/rest/masstruncate",
content=(
"<truncateLayer><layerName>"
f"{workspace}:{layer}"
"</layerName></truncateLayer>"
),
headers={"Content-Type": "text/xml"},
)
def create_project_workspace(
self,
*,
@@ -0,0 +1,218 @@
"""Typed external action adapters. Credentials are request-scoped, never persisted."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal, Protocol
from uuid import UUID
import httpx
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.auth.project_dependencies import ProjectContext
from app.domain.schemas.operational_task import (
ActionReceipt, CreateWorkOrderPayload, HumanApprovalAction,
IssueCommandPayload, OperationalTaskDetail,
)
@dataclass(frozen=True)
class ActionDispatchOutcome:
status: Literal["submitted", "succeeded", "failed", "unsupported", "unknown"]
detail: str
receipt: ActionReceipt | None = None
class OperationalActionDispatcher(Protocol):
async def dispatch(
self, *, context: ProjectContext, task: OperationalTaskDetail,
action: HumanApprovalAction, credential: str | None,
) -> ActionDispatchOutcome: ...
async def reconcile(
self, *, context: ProjectContext, task: OperationalTaskDetail,
action: HumanApprovalAction, credential: str | None,
) -> ActionDispatchOutcome: ...
class OperationalActionSettings(BaseSettings):
peripheral_url: str = ""
command_gateway_url: str = ""
timeout_seconds: float = Field(default=10, ge=1, le=60)
model_config = SettingsConfigDict(
env_prefix="OPERATIONAL_ACTION_", extra="ignore",
env_file=Path(__file__).resolve().parents[3] / ".env",
)
class SampleWorkOrderReceipt(BaseModel):
model_config = ConfigDict(extra="ignore")
work_order_id: UUID
project_id: UUID
created_at: datetime
origin: Literal["synthetic"]
status: Literal[
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
]
class CommandGatewayReceipt(BaseModel):
model_config = ConfigDict(extra="forbid")
request_id: UUID
project_id: UUID
receipt_id: str = Field(min_length=1)
status: Literal["accepted", "running", "completed", "failed"]
submitted_at: datetime
completed_at: datetime | None = None
detail: str = Field(min_length=1)
class HttpOperationalActionDispatcher:
def __init__(
self, settings: OperationalActionSettings | None = None,
*, transport: httpx.AsyncBaseTransport | None = None,
):
self.settings = settings or OperationalActionSettings()
self.transport = transport
async def dispatch(
self, *, context: ProjectContext, task: OperationalTaskDetail,
action: HumanApprovalAction, credential: str | None,
) -> ActionDispatchOutcome:
base = (self.settings.peripheral_url if action.kind == "create_work_order"
else self.settings.command_gateway_url)
if not base:
return ActionDispatchOutcome("unsupported", "尚未配置该动作的外部执行适配器")
if not credential or not credential.lower().startswith("bearer "):
return ActionDispatchOutcome("failed", "缺少当前用户的产品身份,未提交外部动作")
if action.task_id != task.task_id or task.project_id != context.project_id:
return ActionDispatchOutcome("failed", "动作与当前项目任务不匹配,未提交")
if action.approval_status != "approved":
return ActionDispatchOutcome("failed", "动作尚未通过人工确认,未提交")
request_id = action.execution_request_id
if request_id is None:
return ActionDispatchOutcome("failed", "缺少持久化执行编号,未提交")
headers = {"Authorization": credential, "X-Project-Id": str(context.project_id)}
try:
if action.kind == "create_work_order":
payload = CreateWorkOrderPayload.model_validate(action.payload)
body = {
"request_id": str(request_id), "title": payload.title,
"summary": payload.description, "status": "open",
"priority": payload.priority, "assignee": payload.assignee,
"scheduled_at": payload.scheduled_at.isoformat() if payload.scheduled_at else None,
"location": payload.location,
# An approved action creates a child execution order, not a
# new inbound complaint/burst event for the sync loop.
"work_order_type": "general",
}
path = "/api/sample/v1/work-orders"
recovery_path = f"/api/sample/v1/requests/{request_id}"
else:
payload = IssueCommandPayload.model_validate(action.payload)
if payload.expires_at <= datetime.now(timezone.utc):
return ActionDispatchOutcome("failed", "命令确认窗口已过期,未提交")
body = {
"request_id": str(request_id), "project_id": str(context.project_id),
"task_id": str(task.task_id), "action_id": str(action.action_id),
"action_version": action.version,
"approved_by": action.decided_by,
"approved_at": action.decided_at.isoformat() if action.decided_at else None,
"command": payload.model_dump(mode="json"),
}
path = "/api/v1/commands"
recovery_path = f"/api/v1/commands/requests/{request_id}"
except (ValidationError, TypeError, ValueError):
return ActionDispatchOutcome("failed", "动作参数不符合执行契约,未提交")
async with httpx.AsyncClient(
base_url=base.rstrip("/"), headers=headers, timeout=self.settings.timeout_seconds,
follow_redirects=False, trust_env=False, transport=self.transport,
) as client:
try:
response = await client.post(path, json=body)
if response.status_code in {400, 401, 403, 404, 409, 422}:
# Sample responses explicitly state whether any write occurred.
# An untyped rejection is not proof that no effect took place.
error = response.json()
if isinstance(error, dict) and error.get("effect") == "none":
return ActionDispatchOutcome("failed", "外部系统拒绝了动作,未生效")
response.raise_for_status()
return self._receipt(action.kind, response.json(), request_id, context.project_id)
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
# A lost response can follow a successful write. Recover using the
# same idempotency key, never manufacture success or blindly retry.
try:
recovered = await client.get(recovery_path)
recovered.raise_for_status()
value = recovered.json()
if action.kind == "create_work_order":
if not isinstance(value, dict) or value.get("request_id") != str(request_id):
raise ValueError("request receipt mismatch")
value = value["result"]
return self._receipt(action.kind, value, request_id, context.project_id)
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
return ActionDispatchOutcome("unknown", "外部执行结果待核实,请通过回执核对,勿重复下发")
async def reconcile(
self, *, context: ProjectContext, task: OperationalTaskDetail,
action: HumanApprovalAction, credential: str | None,
) -> ActionDispatchOutcome:
request_id = action.execution_request_id
if not credential or request_id is None or action.task_id != task.task_id or task.project_id != context.project_id:
return ActionDispatchOutcome("unknown", "无法核实当前动作的身份或执行编号")
base = self.settings.peripheral_url if action.kind == "create_work_order" else self.settings.command_gateway_url
if not base:
return ActionDispatchOutcome("unknown", "回执查询服务尚未配置")
path = (f"/api/sample/v1/requests/{request_id}" if action.kind == "create_work_order"
else f"/api/v1/commands/requests/{request_id}")
try:
async with httpx.AsyncClient(
base_url=base.rstrip("/"), timeout=self.settings.timeout_seconds,
follow_redirects=False, trust_env=False, transport=self.transport,
) as client:
response = await client.get(path, headers={
"Authorization": credential, "X-Project-Id": str(context.project_id),
})
response.raise_for_status()
value = response.json()
if action.kind == "create_work_order":
if not isinstance(value, dict) or value.get("request_id") != str(request_id):
raise ValueError("request receipt mismatch")
value = value["result"]
return self._receipt(action.kind, value, request_id, context.project_id)
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
return ActionDispatchOutcome("unknown", "尚未取得可核实的执行回执")
@staticmethod
def _receipt(kind: str, value: object, request_id: UUID, project_id: UUID) -> ActionDispatchOutcome:
if kind == "create_work_order":
order = SampleWorkOrderReceipt.model_validate(value)
if order.project_id != project_id or order.created_at.tzinfo is None:
raise ValueError("invalid external project or timestamp")
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="合成外围工单已创建,现场工作进度由外围系统继续同步",
)
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
result = CommandGatewayReceipt.model_validate(value)
if result.project_id != project_id or result.request_id != request_id:
raise ValueError("command receipt does not match submission")
if result.submitted_at.tzinfo is None or (result.completed_at and result.completed_at.tzinfo is None):
raise ValueError("command receipt timestamps must include timezone")
if result.status == "completed" and result.completed_at is None:
raise ValueError("command has no completion receipt")
receipt = ActionReceipt(
adapter="command_gateway", receipt_id=result.receipt_id,
submitted_at=result.submitted_at, completed_at=result.completed_at,
external_status=result.status, detail=result.detail,
)
status = "succeeded" if result.status == "completed" else "failed" if result.status == "failed" else "submitted"
return ActionDispatchOutcome(status, result.detail, receipt)
def get_operational_action_dispatcher() -> OperationalActionDispatcher:
return HttpOperationalActionDispatcher()
+185
View File
@@ -0,0 +1,185 @@
"""Read-only source adapters with bounded queries and explicit provenance."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import httpx
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.auth.permissions import SCADA_VIEW, SIMULATION_VIEW, permissions_for_context
from app.auth.project_dependencies import ProjectContext
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
class TaskDataSettings(BaseSettings):
server_url: str = "http://127.0.0.1:8000"
peripheral_url: str = ""
timeout_seconds: float = Field(default=15, ge=1, le=60)
delegation_bearer: str = ""
model_config = SettingsConfigDict(
env_prefix="OPERATIONAL_DATA_", extra="ignore",
env_file=Path(__file__).resolve().parents[3] / ".env",
)
class TaskDataError(Exception):
def __init__(self, status: int, detail: str):
super().__init__(detail)
self.status = status
self.detail = detail
def _matches_project(value: object, project_id: str) -> bool:
if isinstance(value, list):
return all(_matches_project(item, project_id) for item in value)
if isinstance(value, dict):
if "project_id" in value and value["project_id"] != project_id:
return False
return all(_matches_project(item, project_id) for item in value.values())
return True
def _select_demo_observations(
payload: object, query: TaskDataQuery, project_id: str,
) -> dict[str, object]:
"""Project a bounded DEMO-* observation query from the stored demo dataset."""
if not isinstance(payload, dict) or not _matches_project(payload, project_id):
raise TaskDataError(502, "数据源返回了其他项目的数据")
points = payload.get("points")
observations = payload.get("observations")
if not isinstance(points, list) or not isinstance(observations, list):
raise TaskDataError(502, "外围合成样本格式无效")
if not any(isinstance(point, dict) and point.get("point_id") == query.point_id for point in points):
raise TaskDataError(404, "合成样本测点不存在")
assert query.start is not None and query.end is not None
items: list[object] = []
try:
for item in observations:
if not isinstance(item, dict) or item.get("point_id") != query.point_id:
continue
observed_at = datetime.fromisoformat(str(item.get("observed_at")))
if query.start <= observed_at < query.end:
items.append(item)
except (TypeError, ValueError) as exc:
raise TaskDataError(502, "外围合成样本格式无效") from exc
return {
"origin": "synthetic",
"source_id": payload.get("source_id"),
"source_revision": payload.get("source_revision"),
"point_id": query.point_id,
"window": {
"start": query.start.isoformat(),
"end": query.end.isoformat(),
},
"items": items,
"truncated": False,
}
async def read_task_data(
*, context: ProjectContext, query: TaskDataQuery, credential: str | None,
delegated: bool = False,
settings: TaskDataSettings | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> TaskDataSnapshot:
config = settings or TaskDataSettings()
permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
if permission not in permissions_for_context(context) and not delegated:
raise TaskDataError(403, "当前身份没有该数据源的读取权限")
if delegated:
credential = config.delegation_bearer
if not credential:
raise TaskDataError(503, "任务授权已批准,但数据源委托身份尚未配置")
if not credential.lower().startswith("bearer "):
credential = f"Bearer {credential}"
if not credential or not credential.lower().startswith("bearer "):
raise TaskDataError(401, "数据查询需要当前产品身份")
peripheral = query.source.startswith("peripheral_")
base = config.peripheral_url if peripheral else config.server_url
if not base:
raise TaskDataError(503, "调查数据源尚未配置")
paths = {
"workbench_runtime": "/api/v1/workbench/runtime",
"scada_devices": "/api/v1/scada-devices",
"scada_readings": "/api/v1/timeseries/scada-readings",
"peripheral_points": "/api/sample/v1/monitoring/points",
"peripheral_work_orders": "/api/sample/v1/work-orders",
"peripheral_observations": "/api/sample/v1/monitoring/observations",
"peripheral_test_data": "/api/sample/v1/test-data",
}
demo_observations = (
query.source == "peripheral_observations"
and query.point_id is not None
and query.point_id.startswith("DEMO-")
)
path = "/api/sample/v1/demo-data" if demo_observations else paths[query.source]
params: dict[str, str | int] = {"limit": 100}
if query.source == "scada_readings":
params.update(device_ids=",".join(query.device_ids), start_time=query.start.isoformat(), end_time=query.end.isoformat())
elif query.source == "peripheral_observations" and not demo_observations:
params = {"point_id": query.point_id, "start": query.start.isoformat(), "end": query.end.isoformat()}
elif demo_observations:
params = {}
try:
async with httpx.AsyncClient(
base_url=base.rstrip("/"), timeout=config.timeout_seconds, follow_redirects=False,
trust_env=False, transport=transport,
) as client:
async with client.stream("GET", path, params=params, headers={
"Authorization": credential, "X-Project-Id": str(context.project_id),
}) as response:
if response.status_code in {401, 403}:
raise TaskDataError(response.status_code, "数据源拒绝当前身份或项目权限")
response.raise_for_status()
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > 2_000_000:
raise TaskDataError(422, "查询结果过大,请缩短时间范围或减少设备数量")
data = json.loads(body)
except (httpx.HTTPError, ValueError) as exc:
raise TaskDataError(502, "调查数据查询失败,不能据此形成已核实结论") from exc
if demo_observations:
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"
historical_replay = False
if query.source in {"peripheral_points", "peripheral_observations"}:
source_origin = data.get("origin") if isinstance(data, dict) else None
if source_origin not in {"historical_replay", "synthetic"}:
raise TaskDataError(502, "监测数据缺少明确来源,不能作为已核实证据")
historical_replay = source_origin == "historical_replay"
origin = "system_data" if historical_replay else "synthetic"
truncated = isinstance(data, dict) and bool(data.get("truncated") or data.get("next_cursor") or data.get("next_offset"))
if isinstance(data, list) and len(data) > 1000:
data, truncated = data[:1000], True
if isinstance(data, dict) and isinstance(data.get("items"), list):
if len(data["items"]) > 1000:
data = {**data, "items": data["items"][:1000]}
truncated = True
if isinstance(data.get("total"), int) and data["total"] > len(data["items"]):
truncated = True
# The digest identifies the returned snapshot, not merely a mutable URL.
digest = hashlib.sha256(json.dumps(data, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
if demo_observations:
assert query.start is not None and query.end is not None
source_ref = (
f"peripheral_demo_data:{query.point_id}:"
f"{query.start.isoformat()}:{query.end.isoformat()}:sha256:{digest}"
)
elif historical_replay:
# Use a distinct identity: old snapshots may have incorrectly labeled
# this same historical response synthetic. Immutable evidence stays intact.
source_ref = f"peripheral_historical_replay:{query.source}:sha256:{digest}"
else:
source_ref = f"{query.source}:sha256:{digest}"
return TaskDataSnapshot(
source=query.source, source_ref=source_ref,
queried_at=datetime.now(timezone.utc), origin=origin,
data=data, truncated=truncated,
)
+34 -4
View File
@@ -1,17 +1,20 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, AsyncExitStack
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware
import logging
from datetime import datetime
from app.api.problem_details import install_problem_details_handlers
from app.api.health import router as health_router
from app.api.v1.rest_router import api_router
from app.infra.db.dynamic_manager import project_connection_manager
from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs
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
@@ -23,13 +26,23 @@ logger.setLevel(logging.INFO)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.startup_complete = False
logger.info("**********************************************************")
logger.info(str(datetime.now()))
logger.info("TJWater CloudService is starting...")
logger.info("**********************************************************")
try:
async with AsyncExitStack() as stack:
if mcp_runtime is not None:
await stack.enter_async_context(mcp_runtime.lifespan())
app.state.startup_complete = True
try:
yield
# 清理资源
finally:
app.state.startup_complete = False
finally:
await workbench_event_hubs.close_all()
await project_connection_manager.close_all()
close_all_timescale_pools()
close_all_project_pools()
@@ -37,6 +50,14 @@ async def lifespan(app: FastAPI):
logger.info("Database connections closed")
# Disabled deployments do not import the MCP SDK or acquire business credentials.
from app.mcp.config import McpSettings
mcp_settings = McpSettings()
mcp_runtime = None
if mcp_settings.enabled:
from app.mcp.application import McpQueryApplication
mcp_runtime = McpQueryApplication(mcp_settings)
# 根据环境配置决定是否启用文档
is_production = settings.ENVIRONMENT.lower() == "production"
@@ -53,20 +74,29 @@ app = FastAPI(
# Include Routers
app.state.startup_complete = False
app.include_router(health_router)
app.include_router(api_router, prefix="/api/v1")
install_problem_details_handlers(app)
# Legcy Routers without version prefix
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)
# 配置中间件
app.add_middleware(GZipMiddleware, minimum_size=1000)
# 添加审计中间件(可选,记录关键操作)
app.add_middleware(AuditMiddleware)
if mcp_runtime is not None:
from app.mcp.dispatch import McpDispatchMiddleware
app.add_middleware(McpDispatchMiddleware, runtime=mcp_runtime)
# 配置 CORS 中间件
# 确保这是你最后一个添加的 app.add_middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允许所有来源
allow_origins=settings.cors_allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
View File
+139
View File
@@ -0,0 +1,139 @@
"""Bounded calculation inputs; identity, routing and output paths are host-owned."""
from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, model_validator
from app.mcp.models import Id, StrictInput
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
class CalculationInput(StrictInput):
timeout_seconds: int = Field(
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
)
class BurstDetectionInput(CalculationInput):
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
sampling_interval_minutes: int = Field(default=15, ge=1, le=60)
@model_validator(mode="after")
def valid_target(self):
if self.target_time.tzinfo is None:
raise ValueError("target_time requires explicit timezone")
if 1440 % self.sampling_interval_minutes:
raise ValueError("sampling interval must divide a day")
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
raise ValueError("sensor_nodes must be distinct")
return self
class BurstLocationInput(CalculationInput):
data_source: Literal["monitoring", "simulation"] = "monitoring"
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
scada_burst_start: datetime
scada_burst_end: datetime
scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,不代表该时段已确认正常")
scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式不填写,使用同时间窗模型基线")
@model_validator(mode="after")
def valid_windows(self):
pairs = [(self.scada_burst_start, self.scada_burst_end)]
if self.data_source == "monitoring":
if self.simulation_run_id is not None:
raise ValueError("monitoring cannot use simulation_run_id")
if self.scada_normal_start is not None or self.scada_normal_end is not None:
pairs.append((self.scada_normal_start, self.scada_normal_end))
elif self.simulation_run_id is None or self.scada_normal_start is not None or self.scada_normal_end is not None:
raise ValueError("simulation requires run_id and uses the same-window model baseline")
for start, end in pairs:
if start is None or end is None or start.tzinfo is None or end.tzinfo is None:
raise ValueError("explicit timezone-aware burst and normal windows are required")
if not timedelta(0) < end - start <= timedelta(days=1):
raise ValueError("each window must be positive and at most 24 hours")
for ids in (self.pressure_scada_ids, self.flow_scada_ids):
if ids is not None and len(set(ids)) != len(ids):
raise ValueError("sensor IDs must be distinct")
return self
class ValveIsolationInput(CalculationInput):
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
class LeakageIdentificationInput(CalculationInput):
scada_start: datetime
scada_end: datetime
sensor_nodes: list[Id] = Field(min_length=1, max_length=20)
q_sum: float = Field(gt=0, allow_inf_nan=False, description="有依据的总漏损流量约束,m³/s,不得使用无依据默认值")
start_time: float = Field(ge=0, lt=24, allow_inf_nan=False, description="模型起算小时;需与SCADA时间窗及模型时钟对齐")
timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应")
dma_count: int = Field(ge=1, le=20)
@model_validator(mode="after")
def valid_window(self):
if self.scada_start.tzinfo is None or self.scada_end.tzinfo is None:
raise ValueError("timestamps require explicit timezone")
duration = self.scada_end - self.scada_start
if not timedelta(0) < duration <= timedelta(days=1):
raise ValueError("window must be positive and at most 24 hours")
if duration.total_seconds() % (self.timestep * 60):
raise ValueError("window must contain whole calculation steps")
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
raise ValueError("sensor_nodes must be distinct")
return self
class ScenarioInput(CalculationInput):
start_time: datetime
duration: int = Field(default=900, ge=60, le=86400, description="模拟时长,秒;首次优先短时间窗")
output_node_ids: list[Id] = Field(min_length=1, max_length=20, description="需要返回结果的节点;不把局部结果解释为全网无影响")
output_link_ids: list[Id] = Field(default_factory=list, max_length=20)
@model_validator(mode="after")
def timezone_required(self):
if self.start_time.tzinfo is None:
raise ValueError("start_time requires explicit timezone")
return self
class ContaminantSimulationInput(ScenarioInput):
source: Id = Field(description="假设污染源节点;这是正向传播模拟,不是自动反演污染源")
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;不填为持续恒定源")
class ValveClosureInput(ScenarioInput):
valves: list[Id] = Field(min_length=1, max_length=20, description="在临时模型中关闭的阀门,不下发现场控制")
class FlushingInput(ScenarioInput):
drainage_node_id: Id
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;不使用无依据的自动排放量")
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
PumpSeries = Annotated[list[NonnegativeValue], Field(min_length=1, max_length=96)]
class PressureRegulationInput(ScenarioInput):
pump_control: dict[Id, PumpSeries] = Field(min_length=1, max_length=20, description="已登记泵ID及运行序列;工频值为0/1,变频值为频率Hz,每项对应模型水力步长,不自动优化")
tank_init_level: dict[Id, Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=20, description="水箱初始水位,须在模型允许范围内")
class SensorPlacementInput(CalculationInput):
method: Literal["sensitivity", "kmeans"]
sensor_count: int = Field(ge=1, le=20)
min_diameter: int = Field(default=0, ge=0, le=5000, description="最小管径,mm")
+96
View File
@@ -0,0 +1,96 @@
"""Side-effect-limited adapters for existing domain calculations."""
import asyncio
from threading import BoundedSemaphore
from typing import Any
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.
TIMESCALE_CALCULATIONS = SCENARIO_TOOLS | frozenset({
"run_burst_detection", "run_burst_location", "run_leakage_identification",
})
# A timed-out HTTP request cannot kill a native algorithm thread. Keep its slot
# until the calculation really exits, so retries cannot accumulate CPU jobs.
_slots = BoundedSemaphore(2)
def _calculate(name: str, parameters: dict[str, Any], network: str, username: str) -> dict[str, Any]:
if not _slots.acquire(blocking=False):
raise 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)
method = parameters.pop("method")
optimizer = optimize_sensor_placement_by_sensitivity if method == "sensitivity" else optimize_sensor_placement_by_kmeans
return optimizer(project_code=network, run_name="MCP preview", created_by=username,
persist_results=False, **parameters)
if name == "run_leakage_identification":
from app.services.dma_leakage_estimation import run_leakage_identification
duration = (parameters["scada_end"] - parameters["scada_start"]).total_seconds() / 3600
return run_leakage_identification(
network=network, username=username, scheme_name=None,
duration=duration, q_sum_unit="m3/s", output_flow_unit="m3/s",
n_workers=1, pop_size=50, max_gen=100, **parameters,
)
if name == "run_burst_detection":
from app.services.burst_detection import run_burst_detection
return run_burst_detection(network=network, username=username, scheme_name=None, **parameters)
if name == "run_burst_location":
from app.services.burst_location import run_burst_location_by_network
return run_burst_location_by_network(
network=network, username=username, scheme_name=None,
final_candidates_csv_path=None, **parameters,
)
if name == "analyze_valve_isolation":
from app.services.valve_isolation import analyze_valve_isolation
return analyze_valve_isolation(network=network, **parameters)
raise ValueError("Calculation is not registered")
finally:
_slots.release()
async def calculate(name: str, args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
# to_thread copies the validated project's routing context. Never accept
# database routes, actor identity, arbitrary measurements or paths from MCP.
return await asyncio.to_thread(
_calculate, name, args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), network, username,
)
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)
+517
View File
@@ -0,0 +1,517 @@
"""Official SDK transport with product authorization, persistence and query handlers."""
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from urllib.parse import urlsplit
from uuid import uuid5, NAMESPACE_URL
from typing import Any
from fastapi import HTTPException
from mcp import types
from mcp.server import Server
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.settings import AuthSettings
from mcp.server.auth.routes import create_protected_resource_routes
from mcp.server.caching import CacheHint
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from psycopg.errors import UndefinedTable, InvalidSchemaName
from pydantic import ValidationError
from starlette.responses import JSONResponse
from app.mcp.auth import (
McpTokenVerifier,
QueryScopeMiddleware,
QUERY_SCOPE,
CONTEXT_SCOPE,
challenge,
)
from app.mcp.catalog import TOOLS
from app.mcp.config import McpSettings
from app.mcp.contexts import resolve_context, verify_task
from app.mcp.models import QueryResult
from app.mcp.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__)
def tool_error(code, message):
return types.CallToolResult(
isError=True,
content=[types.TextContent(type="text", text=message)],
structuredContent={"error": {"code": code, "message": message}},
)
class ModernOnlyMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope.get("path") != "/mcp":
return await self.app(scope, receive, send)
if scope["method"] in {"GET", "DELETE"}:
return await JSONResponse(
{"error": "modern_protocol_required"},
status_code=405,
headers={"Allow": "POST"},
)(scope, receive, send)
headers = {
k.decode("latin1").lower(): v.decode("latin1")
for k, v in scope.get("headers", [])
}
version = headers.get("mcp-protocol-version")
if version != "2026-07-28":
body = bytearray()
request_id = None
body_version = None
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
body.extend(message.get("body", b""))
if len(body) > 1024 * 1024:
return await JSONResponse(
{"error": "request_too_large"}, status_code=413
)(scope, receive, send)
if not message.get("more_body"):
break
try:
payload = json.loads(body)
request_id = payload.get("id")
body_version = payload.get("params", {}).get("_meta", {}).get(
types.PROTOCOL_VERSION_META_KEY
)
except (ValueError, AttributeError):
pass
mismatch = not version or (
isinstance(body_version, str) and body_version != version
)
code = -32020 if mismatch else -32022
result = {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": code,
"message": (
"MCP-Protocol-Version header is missing or differs from request metadata"
if mismatch else "MCP 2026-07-28 is required"
),
"data": {"supported": ["2026-07-28"], "requested": version},
},
}
return await JSONResponse(result, status_code=400)(scope, receive, send)
await self.app(scope, receive, send)
class McpQueryApplication:
def __init__(
self,
settings: McpSettings,
*,
verifier=None,
scope_factory=project_scope,
executor=execute_query,
):
self.settings = settings
self.verifier = verifier or McpTokenVerifier(settings)
self.scope_factory = scope_factory
self.executor = executor
self.active = {}
self.ready = False
self.server = Server(
"tjwater-query",
version="1.0.0",
instructions="Project-bound water queries. Model baselines are not observations. Truncated results do not cover the full range.",
cache_hints={
"server/discover": CacheHint(300000, "public"),
"tools/list": CacheHint(300000, "public"),
},
on_list_tools=self.list_tools,
on_call_tool=self.call_tool,
get_tool_input_schema=self.tool_input_schema,
on_list_resources=self.list_resources,
on_list_resource_templates=self.list_templates,
on_read_resource=self.read_resource,
)
self.server.add_request_handler(
"server/discover", types.RequestParams, self.discover
)
app = self.server.streamable_http_app(
stateless_http=True,
json_response=True,
max_request_body_size=1024 * 1024,
auth=AuthSettings(
issuer_url=settings.issuer_uri,
resource_server_url=settings.resource_uri,
required_scopes=["mcp.catalog.read"],
validate_token_resource=True,
),
token_verifier=self.verifier,
transport_security=TransportSecuritySettings(
allowed_hosts=settings.allowed_hosts,
allowed_origins=settings.allowed_origins,
),
)
# Catalog authentication and advertised resource scopes are different concerns.
metadata = create_protected_resource_routes(
resource_url=settings.resource_uri,
authorization_servers=[settings.issuer_uri],
scopes_supported=["mcp.catalog.read", QUERY_SCOPE, CONTEXT_SCOPE],
resource_name="TJWater Query",
)
metadata_path = metadata[0].path
app.router.routes = [
route for route in app.router.routes if route.path != metadata_path
] + metadata
self.asgi = ModernOnlyMiddleware(
QueryScopeMiddleware(app, self.verifier, settings)
)
from app.api.v1.endpoints.mcp_queries import router
self.router = router
@asynccontextmanager
async def lifespan(self):
async with self.server.session_manager.run():
self.ready = True
try:
yield
finally:
self.ready = False
async def discover(self, ctx, params):
return types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=self.server.get_capabilities(
protocol_version=ctx.protocol_version,
extensions={"io.modelcontextprotocol/oauth-client-credentials": {}},
),
instructions=self.server.instructions,
)
def tool_input_schema(self, name: str) -> dict[str, Any] | None:
tool = TOOLS.get(name)
if tool is None:
return None
schema = tool.model.model_json_schema()
if tool.computation or tool.mutating:
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
return schema
async def list_tools(self, ctx, params):
if params and params.cursor:
raise MCPError(-32602, "The query catalog has no continuation page")
tools = []
for name in sorted(TOOLS):
tool = TOOLS[name].protocol_tool()
tool.input_schema.update(self.tool_input_schema(name))
tools.append(tool)
return types.ListToolsResult(
tools=tools,
ttlMs=300000,
cacheScope="public",
)
async def list_resources(self, ctx, params):
return types.ListResourcesResult(resources=[], ttlMs=0, cacheScope="private")
async def list_templates(self, ctx, params):
return types.ListResourceTemplatesResult(
resourceTemplates=[
types.ResourceTemplate(
name="task-evidence",
uriTemplate="tjwater://tasks/{task_id}/evidence/{evidence_id}",
mimeType="application/json",
)
],
ttlMs=300000,
cacheScope="public",
)
async def read_resource(self, ctx, params):
meta = ctx.meta or {}
handle = meta.get("io.tjwater/contextHandle")
url = urlsplit(str(params.uri))
parts = url.path.strip("/").split("/")
if (
url.scheme != "tjwater"
or url.netloc != "tasks"
or len(parts) != 3
or parts[1] != "evidence"
or url.query
or url.fragment
):
raise MCPError(-32602, "Unknown query resource")
result = await self._query(
ctx,
"get_task_evidence",
{"context_handle": handle, "evidence_id": parts[2]},
resource_task=parts[0],
include_full=True,
)
if result.isError:
raise MCPError(
-32602, "Evidence is unavailable in this authorization context"
)
return types.ReadResourceResult(
contents=[
types.TextResourceContents(
uri=str(params.uri),
mimeType="application/json",
text=json.dumps(result.structuredContent, ensure_ascii=False),
)
],
ttlMs=0,
cacheScope="private",
)
async def call_tool(self, ctx, params):
return await self._query(ctx, params.name, params.arguments or {})
async def _query(
self, ctx, name, arguments, resource_task=None, include_full=False
):
tool = TOOLS.get(name)
if tool is None:
raise MCPError(-32602, "Unknown query tool")
principal = get_access_token()
if principal is None or QUERY_SCOPE not in principal.scopes:
return tool_error("PERMISSION_DENIED", "Query permission is required")
try:
args = tool.model.model_validate(arguments)
except ValidationError:
return tool_error(
"INVALID_QUERY", "Query parameters do not match the tool schema"
)
timeout_seconds = (
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_timeout_seconds
)
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_calculation_timeout_seconds:
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
request = ctx.request
project = request.headers.get("x-project-id", "") if request else ""
meta = ctx.meta or {}
call_id = meta.get("io.tjwater/callId")
if not isinstance(call_id, str) or not 0 < len(call_id) <= 128:
return tool_error(
"CALL_ID_REQUIRED", "A host-owned call identifier is required"
)
key = (principal.subject, project)
if self.active.get(key, 0) >= 4:
return tool_error(
"RATE_LIMITED", "At most four queries may run concurrently"
)
self.active[key] = self.active.get(key, 0) + 1
try:
async with (
asyncio.timeout(timeout_seconds),
self.scope_factory(principal, project) as scope,
):
binding = await resolve_context(scope, principal, args.context_handle)
if resource_task is not None and str(binding.task_id) != resource_task:
raise HTTPException(403, "Resource task mismatch")
await authorize_query(scope, binding, tool, args)
repo = McpQueryRepository(scope.connection, scope.project.project_id)
parameters = {
"tool": name,
"arguments": args.model_dump(
mode="json", exclude={"context_handle"}
),
}
scope_key = json.dumps(
[
binding.subject,
binding.client_id,
binding.native_session_id,
str(binding.task_id),
str(binding.run_id),
str(binding.execution_request_id),
],
separators=(",", ":"),
)
previous = (
await repo.receipt(scope_key, call_id) if binding.task_id else None
)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(
409, "Call identifier has different query parameters"
)
result = QueryResult.model_validate(previous["result"])
else:
result = (
await self.executor(
scope, binding, tool, args, full_snapshot=True
)
if include_full
else await self.executor(scope, binding, tool, args)
)
# Never serialize an unbounded response into a model message or database receipt.
if len(result.model_dump_json().encode()) > 8 * 1024 * 1024:
return tool_error(
"RESULT_TOO_LARGE",
"Narrow the query or request a smaller page",
)
if binding.task_id and name not in {
"get_task_context",
"get_task_evidence",
}:
async with scope.connection.transaction():
await repo.lock_receipt(scope_key, call_id)
previous = await repo.receipt(scope_key, call_id)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(
409,
"Call identifier has different query parameters",
)
result = QueryResult.model_validate(previous["result"])
else:
task_repo = task_service(scope).repo
# Lock the task row only after external/data-source work has finished.
await task_repo.lock(f"task:{binding.task_id}")
await verify_task(scope, binding)
result.source_ref = "mcp:" + str(
uuid5(NAMESPACE_URL, scope_key + ":" + call_id)
)
snapshot_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=snapshot_data,
truncated=result.truncated,
)
evidence, _ = await task_service(
scope
).record_data_snapshot(binding.task_id, snapshot)
result.evidence_id = evidence.evidence_id
await repo.save_receipt(
scope_key,
call_id,
parameters,
result.model_dump(mode="json"),
)
output = result.model_dump(mode="json")
content = []
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 (
args.evidence_id if name == "get_task_evidence" else None
)
if binding.task_id and evidence_id:
uri = (
f"tjwater://tasks/{binding.task_id}/evidence/{evidence_id}"
)
output["data"] = {
"resource_uri": uri,
"preview": "The complete query snapshot is saved in task evidence",
}
output["truncated"] = True
output["data_gaps"] = [
*result.data_gaps,
"Inline result abbreviated; read the saved evidence resource",
]
content.append(
types.ResourceLink(
type="resource_link",
uri=uri,
name="query-evidence",
mimeType="application/json",
)
)
else:
return tool_error(
"RESULT_TOO_LARGE",
"Request a smaller page or narrower window",
)
content.insert(
0,
types.TextContent(
type="text", text=json.dumps(output, ensure_ascii=False)
),
)
return types.CallToolResult(content=content, structuredContent=output)
except 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",
413: "RESULT_TOO_LARGE",
422: "INVALID_QUERY",
503: "SOURCE_UNAVAILABLE",
}
return tool_error(
codes.get(exc.status_code, "QUERY_FAILED"),
str(exc.detail)
if isinstance(exc.detail, str)
and exc.status_code in {403, 404, 409, 413, 422}
else "Query is unavailable",
)
except (UndefinedTable, InvalidSchemaName):
return tool_error(
"MCP_STORAGE_UNAVAILABLE",
"MCP project storage has not been provisioned",
)
except TimeoutError:
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
except ValueError:
return tool_error(
"INVALID_QUERY", "Invalid query value or incompatible source data"
)
except Exception:
logger.warning(
"MCP query unavailable", extra={"tool": name, "project_id": project}
)
return tool_error("SOURCE_UNAVAILABLE", "Query source is unavailable")
finally:
remaining = self.active[key] - 1
if remaining:
self.active[key] = remaining
else:
self.active.pop(key, None)
async def identity(self, request):
auth = request.headers.get("authorization", "")
try:
principal = (
await self.verifier.verify_token(auth[7:])
if auth.lower().startswith("bearer ")
else None
)
except Exception:
raise HTTPException(503, "OAuth key verification is unavailable") from None
if not principal:
raise HTTPException(
401,
"MCP authentication required",
headers=challenge(self.settings, CONTEXT_SCOPE),
)
if CONTEXT_SCOPE not in principal.scopes:
raise HTTPException(
403,
"MCP context scope required",
headers=challenge(self.settings, CONTEXT_SCOPE, insufficient=True),
)
return principal
+169
View File
@@ -0,0 +1,169 @@
"""Verify resource-bound Keycloak tokens without passing them to business APIs."""
import asyncio
import time
from uuid import UUID
import httpx
from jose import JWTError, jwt
from mcp.server.auth.provider import AccessToken
from starlette.responses import JSONResponse
from app.mcp.config import McpSettings
CATALOG_SCOPE = "mcp.catalog.read"
QUERY_SCOPE = "mcp.query"
CONTEXT_SCOPE = "mcp.context.bind"
class McpTokenVerifier:
def __init__(self, settings: McpSettings, client: httpx.AsyncClient | None = None):
self.settings = settings
self.client = client
self._keys: list[dict] = []
self._expires = 0.0
self._last_refresh = 0.0
self._lock = asyncio.Lock()
async def _load_keys(self, kid: str) -> list[dict]:
now = time.monotonic()
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
return self._keys
async with self._lock:
now = time.monotonic()
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
return self._keys
if self._expires > now and now - self._last_refresh < 20:
return self._keys
url = (
self.settings.issuer_uri.rstrip("/") + "/protocol/openid-connect/certs"
)
self._last_refresh = now
if self.client:
response = await self.client.get(url)
else:
async with httpx.AsyncClient(
timeout=5, trust_env=False, follow_redirects=False
) as client:
response = await client.get(url)
response.raise_for_status()
try:
payload = response.json()
except ValueError:
raise httpx.RequestError("invalid OAuth key set response") from None
keys = payload.get("keys") if isinstance(payload, dict) else None
if not isinstance(keys, list) or len(keys) > 100:
raise httpx.RequestError("invalid OAuth key set")
self._keys = [
k
for k in keys
if isinstance(k, dict)
and k.get("kty") == "RSA"
and k.get("use", "sig") == "sig"
and k.get("alg", "RS256") == "RS256"
]
self._expires = now + 300
return self._keys
async def verify_token(self, token: str) -> AccessToken | None:
if not token or len(token) > 16384:
return None
try:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if (
header.get("alg") != "RS256"
or not isinstance(kid, str)
or not 0 < len(kid) <= 128
):
return None
keys = await self._load_keys(kid)
key = next((k for k in keys if k.get("kid") == kid), None)
if key is None:
return None
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
audience=self.settings.audience,
issuer=self.settings.issuer_uri,
options={"require_exp": True, "require_iat": True, "require_sub": True},
)
UUID(claims["sub"])
client_id = claims.get("azp")
if not isinstance(client_id, str) or not client_id:
return None
if (
not isinstance(claims.get("iat"), (int, float))
or not time.time() - 900 < claims["iat"] <= time.time() + 30
or claims["exp"] > claims["iat"] + 900
):
return None
return AccessToken(
token=token,
client_id=client_id,
subject=claims["sub"],
scopes=str(claims.get("scope", "")).split(),
expires_at=int(claims["exp"]),
resource=self.settings.resource_uri,
claims=claims,
)
except (JWTError, ValueError, KeyError, TypeError):
return None
# JWKS/network failures propagate as unavailable, never as empty data.
def challenge(
settings: McpSettings, scope: str, *, insufficient: bool = False
) -> dict[str, str]:
from mcp.server.auth.routes import build_resource_metadata_url
metadata = build_resource_metadata_url(settings.resource_uri)
suffix = ', error="insufficient_scope"' if insufficient else ""
return {
"WWW-Authenticate": f'Bearer resource_metadata="{metadata}", scope="{scope}"{suffix}'
}
class QueryScopeMiddleware:
"""HTTP scope challenges before JSON-RPC dispatch; SDK validates mirrored headers."""
def __init__(self, app, verifier: McpTokenVerifier, settings: McpSettings):
self.app, self.verifier, self.settings = app, verifier, settings
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope.get("path") != "/mcp":
return await self.app(scope, receive, send)
headers = {
k.decode("latin1").lower(): v.decode("latin1")
for k, v in scope.get("headers", [])
}
auth = headers.get("authorization", "")
try:
token = (
await self.verifier.verify_token(auth[7:])
if auth.lower().startswith("bearer ")
else None
)
except (httpx.HTTPError, ValueError):
return await JSONResponse(
{"error": "authentication_unavailable"}, status_code=503
)(scope, receive, send)
required = (
QUERY_SCOPE
if headers.get("mcp-method") in {"tools/call", "resources/read"}
else CATALOG_SCOPE
)
if token is None:
return await JSONResponse(
{"error": "invalid_token"},
status_code=401,
headers=challenge(self.settings, required),
)(scope, receive, send)
if required not in token.scopes:
return await JSONResponse(
{"error": "insufficient_scope"},
status_code=403,
headers=challenge(self.settings, required, insufficient=True),
)(scope, receive, send)
await self.app(scope, receive, send)
+259
View File
@@ -0,0 +1,259 @@
"""One finite query catalog; names, schemas and permissions come from this registry."""
from dataclasses import dataclass
from pydantic import BaseModel
from mcp import types
from app.mcp import models as m
from app.mcp import extended_models as e
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
@dataclass(frozen=True)
class QueryTool:
name: str
description: str
model: type[BaseModel]
permission: str
semantics: str
task_only: bool = False
computation: bool = False
mutating: bool = False
def protocol_tool(self) -> types.Tool:
if self.mutating:
suffix = "。默认dry_run只预览;执行必须携带预览摘要、相同request_id并通过宿主原生许可。仅项目普通聊天可用,后台调查和reader禁止;失败或超时先核实状态,不自动重试。"
elif self.name == "run_simulation":
suffix = "。任务调用复用证据留存。"
elif self.computation:
suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。"
elif self.name == "query_nearby_elements":
suffix = "。仅查询当前已授权项目的模型管网,按距离和元素ID稳定排序;按需分页,不自动遍历全量;空间邻近不代表管网连通或供水关系。"
else:
suffix = (
"。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大,"
"不要自动遍历全量;截断或非零offset不能证明完整范围无异常。"
)
return types.Tool(
name=self.name,
description=self.description + suffix,
inputSchema=self.model.model_json_schema(),
outputSchema=m.QueryResult.model_json_schema(),
annotations=types.ToolAnnotations(
readOnlyHint=not (self.computation or self.mutating), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
),
)
_specs = [
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,不复制数据库;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,不创建方案或正式分析运行,结果不是实测", ScenarioInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,不反演污染源,不代表实测污染范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;不操作真实阀门", ValveClosureInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;不是现场冲洗或消毒合格证明", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;不自动优化调度,不实际控制泵", PressureRegulationInput, "simulation.run", "analysis", False, True),
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True),
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True),
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;需有依据的漏水量假设。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True),
(
"get_element_properties",
"读取当前项目的单个管网元素属性",
m.ElementInput,
"webgis.view",
"asset",
),
(
"list_elements",
"分页读取管网元素,不自动读取全网",
m.ElementPageInput,
"webgis.view",
"asset",
),
(
"query_nearby_elements",
"查询坐标附近的模型元素,默认pipe;center为EPSG:4326 [经度,纬度],可直接使用geocode.center;radius_m默认200米、最大10000米,limit默认5、最大100,支持offset。按点到完整几何的WGS84椭球最短距离筛选并排序,返回element_id、distance_m和属性;不查询原始GIS管线、不自动扩大半径、不解析道路交叉点",
m.NearbyElementsInput,
"webgis.view",
"asset",
),
(
"get_network_options",
"读取模型时间、能源或网络选项",
m.OptionsInput,
"webgis.view",
"asset",
),
(
"get_network_schema",
"读取当前支持的元素、SCADA和选项结构",
m.SchemaInput,
"webgis.view",
"asset",
),
(
"list_scada_devices",
"分页读取SCADA设备和物理单位",
m.PageInput,
"scada.view",
"asset",
),
("get_scada_device", "读取一个SCADA设备", m.DeviceInput, "scada.view", "asset"),
(
"query_scada_readings",
"查询带时区且最多24小时的原始或清洗监测数据",
m.ScadaInput,
"scada.view",
"observed",
),
(
"query_model_baseline_series",
"读取最多20个元素的模型基线,不能称为实测",
m.BaselineSeriesInput,
"simulation.view",
"model_baseline",
),
(
"get_model_baseline_at_time",
"读取单个元素指定时刻的模型基线",
m.BaselineAtTimeInput,
"simulation.view",
"model_baseline",
),
(
"query_model_baseline_field",
"分页读取指定时刻的模型字段",
m.BaselineFieldInput,
"simulation.view",
"model_baseline",
),
(
"query_analysis_series",
"读取指定分析运行和元素字段时序",
m.AnalysisSeriesInput,
"simulation.view",
"analysis",
),
(
"query_analysis_values",
"分页读取指定分析运行某时刻的元素字段",
m.AnalysisValuesInput,
"simulation.view",
"analysis",
),
(
"list_analysis_runs",
"分页读取持久分析运行",
m.RunsInput,
"webgis.view",
"analysis",
),
("get_analysis_run", "读取单个持久分析运行", m.RunInput, "webgis.view", "analysis"),
(
"list_analysis_results",
"分页读取分析成果,可筛选result_type",
m.ResultsInput,
"webgis.view",
"analysis",
),
(
"list_sensor_placement_runs",
"分页读取已有监测点选址运行",
m.PageInput,
"optimization.run",
"analysis",
),
(
"get_sensor_placement_run",
"读取已有监测点选址成果",
m.RunInput,
"optimization.run",
"analysis",
),
(
"query_element_history",
"比较实测与模型基线或分析结果;analysis_comparison要求run_id;limit/offset按序列、时间排序的数据点分页",
m.HistoryInput,
"scada.view",
"observed",
),
(
"get_element_gis_properties",
"读取元素关联GIS台账",
m.ElementInput,
"webgis.view",
"asset",
),
(
"get_source_gis_properties",
"读取指定图层源要素台账",
m.SourceGisInput,
"webgis.view",
"asset",
),
(
"get_workbench_runtime",
"读取工作台已有运行态",
m.StrictInput,
"simulation.view",
"task",
),
(
"get_task_context",
"读取绑定任务的有界摘要及handling_skill元数据;section=skill读取Server按任务workflow分发的业务处置Skill正文与版本,属于流程指引而非事件证据;其他section选择task/report/evidence/actions/progress/access_requests/plan/run,data_path按返回路径读取嵌套字段,limit/offset分页;report_revision保留选定报告版本",
m.TaskContextInput,
"operational_task.view",
"task",
True,
),
(
"query_peripheral_data",
"查询有限外围数据源;synthetic是合成证据",
m.PeripheralInput,
"simulation.view",
"peripheral",
),
(
"get_task_evidence",
"读取当前绑定任务已保存的证据快照",
m.EvidenceInput,
"operational_task.view",
"task",
True,
),
]
_specs += [
("list_regions", "分页查询当前项目区域/DMA列表", m.PageInput, "webgis.view", "asset"),
("get_region", "查询当前项目区域边界,返回EPSG:4326;不等于供水拓扑", e.RegionInput, "webgis.view", "asset"),
("get_region_nodes", "分页查询区域配置的成员节点,未配置时使用区域几何筛选", e.RegionInput, "webgis.view", "asset"),
("get_node_connections", "分页查询节点直接连接的管线,不自动遍历全网", e.NodeConnectionsInput, "webgis.view", "asset"),
("query_network_extent", "按EPSG:4326矩形范围分页查询当前项目模型几何;无GIS配置不跨项目回退", e.ExtentInput, "webgis.view", "asset"),
("list_major_nodes", "按关联管道最小管径查询骨干节点,经纬度EPSG:4326", e.MajorNodesInput, "webgis.view", "asset"),
("get_model_configuration", "读取模式、曲线、控制规则、需水量、水质及反应等模型配置;集合按页返回", e.ModelConfigurationInput, "webgis.view", "asset"),
("list_model_configurations", "分页读取模式或曲线ID", e.ConfigurationListInput, "webgis.view", "asset"),
("search_project_places", "按项目配置的行政区搜索地名,最多10个候选,依赖天地图配置", e.PlaceSearchInput, "webgis.view", "asset"),
("reverse_geocode", "把EPSG:4326地图取点转为项目范围内地名,保留输入坐标", e.ReverseGeocodeInput, "webgis.view", "asset"),
("get_project_info", "读取当前项目概况与地图范围,不返回数据库路由或凭据", m.StrictInput, "webgis.view", "asset"),
("export_project_model", "从当前项目数据库导出INP或ChangeSet压缩文件,由宿主保存到会话文件", e.ProjectExportInput, "webgis.view", "asset"),
("get_sensor_placement_candidate", "读取监测点候选节点工程属性", e.SensorCandidateInput, "optimization.run", "asset"),
("export_sensor_placement", "导出已有监测点方案的Excel工程清单,由宿主保存到会话文件", e.SensorExportInput, "optimization.run", "analysis"),
("list_task_schedules", "分页读取当前项目定时任务配置,不启动调度", m.PageInput, "operational_task.view", "task"),
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后须核实目标数据,不自动重试", e.MutationStatusInput, "webgis.view", "task"),
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,只计算不修改模型,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;不是爆管侦测或定位,不保存正式方案", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;仅修改模型,不控制现场设备", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True),
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
("write_scada_readings", "预览或补录/修正SCADA数据,要求真实来源依据;不把模拟当实测", e.ScadaWriteInput, "scada.clean", "observed", False, False, True),
("delete_scada_readings", "预览或删除明确设备与24小时内时间窗的SCADA记录", e.ScadaDeleteInput, "scada.clean", "observed", False, False, True),
("save_simulation_scenario", "预览或重新计算并保存正式隔离场景成果;有真实计算开销,不保存模型生成的观测数组", e.SavedScenarioInput, "simulation.run", "analysis", False, False, True),
("create_sensor_placement", "预览或计算并保存正式压力监测点选址方案", e.SensorCreateInput, "optimization.run", "analysis", False, False, True),
("update_sensor_placement", "预览或修改本人有权编辑的监测点方案,保留旧点位乐观校验", e.SensorUpdateInput, "optimization.run", "analysis", False, False, True),
("create_operational_task", "预览或创建项目任务,不伪造Agent运行完成", e.TaskCreateInput, "operational_task.write", "task", False, False, True),
("create_task_schedule", "预览或创建定时任务配置", e.ScheduleCreateInput, "operational_task.write", "task", False, False, True),
("update_task_schedule", "预览或启停定时任务配置,校验版本", e.ScheduleUpdateInput, "operational_task.write", "task", False, False, True),
("retry_operational_task", "预览或重新入队已终止任务;不自动启动新Agent会话", e.TaskRetryInput, "operational_task.write", "task", False, False, True),
("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True),
]
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
+76
View File
@@ -0,0 +1,76 @@
"""MCP is opt-in; an incomplete OAuth resource must never start listening."""
from pathlib import Path
from ipaddress import IPv4Address, IPv4Network
from urllib.parse import urlsplit
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class McpSettings(BaseSettings):
enabled: bool = False
issuer_uri: str = ""
dev_http_issuer_uri: str = ""
environment: str = Field(default="production", validation_alias="ENVIRONMENT")
resource_uri: str = ""
audience: str = "tjwater-mcp"
host_client_id: str = "tjwater-agent-mcp"
allowed_hosts: list[str] = Field(
default_factory=lambda: ["127.0.0.1:*", "localhost:*"]
)
allowed_origins: list[str] = Field(default_factory=list)
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
query_timeout_seconds: int = Field(default=30, ge=1, le=60)
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
model_config = SettingsConfigDict(
env_prefix="TJWATER_MCP_",
extra="ignore",
populate_by_name=True,
env_file=Path(__file__).resolve().parents[2] / ".env",
)
@model_validator(mode="after")
def validate_resource(self):
if self.dev_http_issuer_uri:
dev_url = urlsplit(self.dev_http_issuer_uri)
try:
address = IPv4Address(dev_url.hostname or "")
private_ipv4 = any(address in IPv4Network(network) for network in (
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"
))
except ValueError:
private_ipv4 = False
if (
self.environment != "development"
or dev_url.scheme != "http"
or not private_ipv4
or dev_url.username or dev_url.password or dev_url.query or dev_url.fragment
or self.dev_http_issuer_uri != self.issuer_uri
):
raise ValueError("development HTTP issuer requires development environment and an exact private IPv4 issuer")
if not self.enabled:
return self
for index, value in enumerate((self.issuer_uri, self.resource_uri)):
url = urlsplit(value)
if (
url.scheme not in {"http", "https"}
or not url.hostname
or url.username
or url.password
or url.query
or url.fragment
):
raise ValueError("MCP requires explicit issuer and resource URIs")
if url.scheme == "http" and url.hostname not in {
"localhost",
"127.0.0.1",
"::1",
}:
if index != 0 or value != self.dev_http_issuer_uri:
raise ValueError("non-loopback MCP and OAuth require HTTPS")
if urlsplit(self.resource_uri).path != "/mcp":
raise ValueError("MCP resource URI must identify the /mcp endpoint")
if not self.audience.strip() or not self.host_client_id.strip():
raise ValueError("MCP audience and host client ID are required")
return self
+114
View File
@@ -0,0 +1,114 @@
"""An OAuth identity and a Server-owned binding are both required for data queries."""
import secrets
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from app.auth.permissions import (
permissions_for_context,
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_VIEW,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
from app.mcp.models import BindInput, BoundContext
from app.mcp.scope import QueryScope
from app.mcp.config import McpSettings
from mcp.server.auth.provider import AccessToken
def task_repository(scope: QueryScope) -> OperationalTaskRepository:
return OperationalTaskRepository(scope.connection, scope.project.project_id)
async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
permissions = permissions_for_context(scope.project)
if OPERATIONAL_TASK_VIEW not in permissions:
raise HTTPException(403, "Task read permission is required")
repo = task_repository(scope)
async with scope.connection.transaction():
task = await repo.get_task(str(binding.task_id))
if not task:
raise HTTPException(404, "Task is not in this project")
if binding.purpose == "task_collaboration":
return
if OPERATIONAL_TASK_EXECUTE not in permissions:
raise HTTPException(403, "Task execution permission is required")
run = await repo.get_active_run(str(binding.task_id))
now = datetime.now(timezone.utc)
owner = binding.parent_session_id or binding.native_session_id
if (
not run
or run["status"] not in {"claimed", "running"}
or run.get("agent_session_id") != owner
or not run.get("lease_expires_at")
or run["lease_expires_at"] <= now
or binding.run_id is not None
and str(run["run_id"]) != str(binding.run_id)
or binding.execution_request_id is not None
and str(run["execution_request_id"]) != str(binding.execution_request_id)
):
raise HTTPException(
409, "Investigation run is no longer active for this session"
)
binding.run_id = UUID(str(run["run_id"]))
binding.execution_request_id = UUID(str(run["execution_request_id"]))
async def bind_context(
scope: QueryScope, principal: AccessToken, command: BindInput, settings: McpSettings
) -> BoundContext:
if principal.client_id != settings.host_client_id:
raise HTTPException(
403, "Only the trusted product host may bind query contexts"
)
from app.mcp.catalog import TOOLS
binding = BoundContext(
context_handle=secrets.token_urlsafe(32),
project_id=scope.project.project_id,
subject=principal.subject,
client_id=principal.client_id,
tool_cap=sorted(name for name, tool in TOOLS.items()
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating))
and (not tool.mutating or command.purpose == "ordinary_chat")),
permission_cap=sorted(permissions_for_context(scope.project)),
native_session_id=command.native_session_id,
purpose=command.purpose,
parent_session_id=command.parent_session_id,
task_id=command.task_id,
expires_at=datetime.now(timezone.utc)
+ timedelta(seconds=settings.context_ttl_seconds),
)
if binding.task_id:
await verify_task(scope, binding)
from app.infra.db.postgresql.read_queries import task_data_scopes
async with scope.connection.transaction():
binding.task_scope_cap = await task_data_scopes(scope.connection,
project_id=binding.project_id, task_id=binding.task_id,
permissions=permissions_for_context(scope.project))
await McpQueryRepository(scope.connection, scope.project.project_id).create(binding)
return binding
async def resolve_context(
scope: QueryScope, principal: AccessToken, handle: str
) -> BoundContext:
binding = await McpQueryRepository(scope.connection, scope.project.project_id).get(
handle
)
if (
not binding
or binding.revoked_at
or binding.expires_at <= datetime.now(timezone.utc)
or binding.subject != principal.subject
or binding.client_id != principal.client_id
or binding.project_id != scope.project.project_id
):
raise HTTPException(
403, "Query context is unavailable for this identity and project"
)
if binding.task_id:
await verify_task(scope, binding)
return binding
+15
View File
@@ -0,0 +1,15 @@
"""Delegate MCP transport/metadata with the original request path intact."""
class McpDispatchMiddleware:
def __init__(self, app, runtime):
self.app, self.runtime = app, runtime
async def __call__(self, scope, receive, send):
path = scope.get("path", "")
if scope["type"] == "http" and (
path == "/mcp" or path.startswith("/.well-known/oauth-protected-resource")
):
await self.runtime.asgi(scope, receive, send)
else:
await self.app(scope, receive, send)
+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)
+295
View File
@@ -0,0 +1,295 @@
"""Strict business inputs and provenance envelopes shared by MCP handlers."""
from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
Id = Annotated[str, Field(min_length=1, max_length=160, pattern=r"^\S(?:.*\S)?$")]
ElementType = Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"]
SeriesType = Literal["junction", "pipe"]
class StrictInput(BaseModel):
model_config = ConfigDict(extra="forbid")
context_handle: Annotated[str, Field(min_length=32, max_length=128)]
class PageInput(StrictInput):
limit: int = Field(default=20, ge=1, le=500)
offset: int = Field(default=0, ge=0, le=10000)
class TaskContextInput(PageInput):
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
report_revision: int | None = Field(default=None, ge=1)
data_path: list[Id] = Field(default_factory=list, max_length=8)
@model_validator(mode="after")
def summary_is_fixed(self):
if self.section in {"summary", "skill"} and (self.data_path or self.offset):
raise ValueError("Select a detail section before using data_path or offset")
return self
class ElementInput(StrictInput):
element_type: ElementType
element_id: Id
class ElementPageInput(PageInput):
element_type: ElementType
class NearbyElementsInput(PageInput):
element_type: ElementType = "pipe"
center: tuple[
Annotated[float, Field(ge=-180, le=180, allow_inf_nan=False)],
Annotated[float, Field(ge=-90, le=90, allow_inf_nan=False)],
] = Field(description="EPSG:4326 [longitude, latitude], directly from geocode.center")
radius_m: float = Field(default=200, gt=0, le=10000, allow_inf_nan=False)
limit: int = Field(default=5, ge=1, le=100)
class OptionsInput(StrictInput):
kind: Literal["time", "energy", "pump-energy", "network"]
pump_id: Id | None = None
@model_validator(mode="after")
def pump_required(self):
if self.kind == "pump-energy" and not self.pump_id:
raise ValueError("pump_id is required for pump-energy")
if self.kind != "pump-energy" and self.pump_id:
raise ValueError("pump_id is only valid for pump-energy")
return self
class SchemaInput(StrictInput):
kind: Literal[
"junction",
"pipe",
"valve",
"pump",
"tank",
"reservoir",
"time",
"energy",
"pump-energy",
"option",
"scada-device",
"region", "pattern", "curve", "control", "rule", "quality", "source",
"reaction", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand",
]
class DeviceInput(StrictInput):
device_id: Id
class WindowInput(PageInput):
start: datetime
end: datetime
limit: int = Field(default=20, ge=1, le=10000)
@model_validator(mode="after")
def window_valid(self):
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("timestamps require explicit timezone")
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
raise ValueError("time window must be positive and at most 24 hours")
return self
class ScadaInput(WindowInput):
device_ids: list[Id] = Field(min_length=1, max_length=20)
field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
class BaselineSeriesInput(WindowInput):
element_type: SeriesType
element_ids: list[Id] = Field(min_length=1, max_length=20)
class TimeInput(StrictInput):
time: datetime
@model_validator(mode="after")
def timezone_required(self):
if self.time.tzinfo is None:
raise ValueError("timestamp requires explicit timezone")
return self
class BaselineAtTimeInput(TimeInput):
element_type: SeriesType
element_id: Id
class BaselineFieldInput(TimeInput):
element_type: SeriesType
field: Id
limit: int = Field(default=20, ge=1, le=500)
offset: int = Field(default=0, ge=0, le=10000)
class AnalysisSeriesInput(WindowInput):
run_id: UUID
element_type: SeriesType
element_id: Id
field: Id
class AnalysisValuesInput(BaselineFieldInput):
run_id: UUID
class RunInput(StrictInput):
run_id: UUID
class RunsInput(PageInput):
limit: int = Field(default=20, ge=1, le=100)
class ResultsInput(PageInput):
run_id: UUID
result_type: Id | None = None
class HistoryTarget(BaseModel):
model_config = ConfigDict(extra="forbid")
element_type: SeriesType
element_id: Id
device_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20)
class HistoryInput(WindowInput):
elements: list[HistoryTarget] = Field(min_length=1, max_length=20)
mode: Literal["observed", "realtime_comparison", "analysis_comparison"] = "observed"
run_id: UUID | None = None
@model_validator(mode="after")
def analysis_required(self):
if (self.mode == "analysis_comparison") != (self.run_id is not None):
raise ValueError("run_id is required only for analysis_comparison")
return self
class SourceGisInput(StrictInput):
layer_key: Annotated[str, Field(min_length=1, max_length=80)]
source_fid: int = Field(ge=0, le=9223372036854775807)
class PeripheralInput(PageInput):
source: Literal[
"peripheral_points",
"peripheral_work_orders",
"peripheral_observations",
"peripheral_test_data",
]
point_id: Id | None = None
start: datetime | None = None
end: datetime | None = None
@model_validator(mode="after")
def observation_window(self):
if self.source == "peripheral_observations":
if not self.point_id or self.start is None or self.end is None:
raise ValueError("observations require point_id, start and end")
if (
self.start.tzinfo is None
or self.end.tzinfo is None
or not timedelta(0) < self.end - self.start <= timedelta(days=1)
):
raise ValueError(
"observation window requires timezone and at most 24 hours"
)
elif (
self.point_id is not None or self.start is not None or self.end is not None
):
raise ValueError(
"point_id and window are only valid for peripheral_observations"
)
return self
class EvidenceInput(PageInput):
evidence_id: Id
data_path: list[Id] = Field(default_factory=list, max_length=8)
class BindInput(BaseModel):
model_config = ConfigDict(extra="forbid")
native_session_id: Id
purpose: Literal[
"ordinary_chat", "task_execution", "task_reader", "task_collaboration"
]
task_id: UUID | None = None
parent_session_id: Id | None = None
@model_validator(mode="after")
def task_scope(self):
if self.purpose == "ordinary_chat" and (self.task_id or self.parent_session_id):
raise ValueError("ordinary chat cannot bind a task")
if self.purpose != "ordinary_chat" and self.task_id is None:
raise ValueError("task context requires task_id")
if (self.purpose == "task_reader") != (self.parent_session_id is not None):
raise ValueError("only a task reader requires parent_session_id")
return self
class BindResponse(BaseModel):
context_handle: str
expires_at: datetime
task_id: UUID | None
run_id: UUID | None
class PageInfo(BaseModel):
limit: int
offset: int
total: int | None = None
has_more: bool
class QueryResult(BaseModel):
model_config = ConfigDict(extra="forbid")
source: str
source_ref: str
queried_at: datetime
origin: Literal["system_data", "synthetic"] = "system_data"
semantics: Literal[
"asset",
"observed",
"model_baseline",
"analysis",
"comparison",
"task",
"peripheral",
]
data: JsonValue
page: PageInfo | None = None
window: dict[str, datetime] | None = None
units: dict[str, str] = Field(default_factory=dict)
truncated: bool = False
data_gaps: list[str] = Field(default_factory=list)
evidence_id: str | None = None
class BoundContext(BaseModel):
context_handle: str
project_id: UUID
subject: str
client_id: str
tool_cap: list[str] = Field(default_factory=list)
permission_cap: list[str] = Field(default_factory=list)
task_scope_cap: list[str] = Field(default_factory=list)
native_session_id: str
purpose: str
parent_session_id: str | None = None
task_id: UUID | None = None
run_id: UUID | None = None
execution_request_id: UUID | None = None
expires_at: datetime
revoked_at: datetime | None = None
+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
+132
View File
@@ -0,0 +1,132 @@
"""Read existing project-owned peripheral facts without forwarding MCP tokens."""
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import HTTPException
from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, scada_devices, series_rows
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
from app.services.content_digest import digest
from samples.peripheral.store import PostgresStore
from app.infra.integrations.operational_data import _select_demo_observations
async def read_peripheral_query(scope, args):
conn = scope.connection
project = str(scope.project.project_id)
truncated = False
origin = "system_data"
if args.source in {"peripheral_work_orders", "peripheral_test_data"}:
table, column = (
("work_orders", "work_order_id")
if args.source == "peripheral_work_orders"
else ("test_data_records", "record_id")
)
rows = await read_rows(
conn,
"SELECT document FROM peripheral_sample."
+ table
+ " WHERE project_id=%s AND deleted_at IS NULL ORDER BY "
+ column
+ " LIMIT %s OFFSET %s",
(project, args.limit + 1, args.offset),
)
items = [
PostgresStore._active_managed_document(row["document"])
for row in rows[: args.limit]
]
truncated = len(rows) > args.limit
if any(item.get("project_id") != project for item in items):
raise HTTPException(502, "Peripheral project mismatch")
data = {
"items": items,
"truncated": truncated,
"limit": args.limit,
"offset": args.offset,
}
origin = (
"synthetic"
if items and any(item.get("origin") == "synthetic" for item in items)
else "system_data"
)
elif args.source == "peripheral_points":
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
data = {
"origin": "historical_replay",
"items": [
{
"point_id": row["device_id"],
"label": row["device_id"] + " · " + row["device_type"],
"unit": row["measurement_unit"],
"status": "unknown",
"origin": "historical_replay",
}
for row in rows[: args.limit]
],
"truncated": len(rows) > args.limit,
}
truncated = data["truncated"]
elif args.point_id.startswith("DEMO-"):
rows = await read_rows(
conn,
"SELECT CASE WHEN octet_length(document::text)<=%s THEN document END AS document,"
"digest,octet_length(document::text)>%s AS too_large "
"FROM peripheral_sample.demo_datasets WHERE project_id=%s AND dataset_id='water-demo-v1'",
(MAX_QUERY_BYTES, MAX_QUERY_BYTES, project),
)
if rows and rows[0]["too_large"]:
raise HTTPException(413, "Synthetic dataset exceeds the integrity-check byte budget")
if not rows or digest(rows[0]["document"]) != rows[0]["digest"]:
raise HTTPException(503, "Synthetic dataset is unavailable or invalid")
query = TaskDataQuery(
source=args.source, point_id=args.point_id, start=args.start, end=args.end
)
data = _select_demo_observations(
{**rows[0]["document"], "project_id": project}, query, project
)
items = data["items"]
truncated = len(items) > args.limit + args.offset
data["items"] = items[args.offset : args.offset + args.limit]
data["truncated"] = truncated
origin = "synthetic"
else:
devices = await scada_devices(conn, device_id=args.point_id, limit=1)
if not devices:
raise HTTPException(404, "Peripheral point was not found")
device = devices[0]
if not device.get("api_query_id"):
raise HTTPException(503, "Peripheral history mapping is unavailable")
async with scope.timescale() as ts:
rows = await series_rows(
ts,
source="scada",
element_type="junction",
ids=[device["api_query_id"]],
start=args.start,
end=args.end,
field="monitored_value",
limit=args.limit + 1,
offset=args.offset,
end_exclusive=True,
)
truncated = len(rows) > args.limit
data = {
"origin": "historical_replay",
"items": [
{
"point_id": args.point_id,
"observed_at": row["time"].isoformat(),
"value": row["value"],
"unit": device["measurement_unit"],
}
for row in rows[: args.limit]
],
"truncated": truncated,
}
return TaskDataSnapshot(
source=args.source,
source_ref="mcp:" + str(uuid4()),
queried_at=datetime.now(timezone.utc),
data=data,
origin=origin,
truncated=truncated,
)
+511
View File
@@ -0,0 +1,511 @@
"""MCP domain dispatch reuses Server repositories and services, never an HTTP loopback."""
import asyncio
import math
from contextlib import nullcontext
from typing import Any
from pydantic import BaseModel
from app.mcp.scope import QueryScope
from app.mcp.catalog import QueryTool, TOOLS
from app.mcp.models import BoundContext
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from app.auth.permissions import permissions_for_context
from app.infra.db.postgresql import read_queries as reads
from app.infra.db.postgresql.spatial_queries import nearby_elements
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.services.operational_tasks import OperationalTaskService
from app.mcp.models import QueryResult, PageInfo
from app.mcp.scenario_service import SCENARIO_TOOLS
from app.mcp.work_order_skills import read_work_order_skill
def finite_data(value: Any) -> tuple[Any, int]:
"""JSON cannot represent infinity/NaN; preserve the gap as null with provenance."""
if isinstance(value, float) and not math.isfinite(value):
return None, 1
count = 0
if isinstance(value, dict):
result = {}
for key, item in value.items():
result[key], replaced = finite_data(item)
count += replaced
return result, count
if isinstance(value, list):
result = []
for item in value:
clean, replaced = finite_data(item)
result.append(clean)
count += replaced
return result, count
return value, 0
def task_service(scope: QueryScope) -> OperationalTaskService:
return OperationalTaskService(
OperationalTaskRepository(scope.connection, scope.project.project_id),
scope.project,
set(permissions_for_context(scope.project)),
)
def page_result(rows, args):
has_more = len(rows) > args.limit
return (
rows[: args.limit],
PageInfo(limit=args.limit, offset=args.offset, total=None, has_more=has_more),
has_more,
)
async def authorize_query(
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
) -> None:
if tool.name not in binding.tool_cap:
raise HTTPException(403, "Query is outside the captured tool catalog")
permissions = permissions_for_context(scope.project)
if tool.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")
if binding.purpose == "task_reader":
raise HTTPException(403, "Read-only task agents cannot start calculations")
required = {tool.permission}
if tool.name in SCENARIO_TOOLS:
required.add("scada.view")
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
raise HTTPException(403, "Calculation and input data permissions are required")
permission = (
"scada.view"
if tool.name == "get_network_schema" and args.kind == "scada-device"
else tool.permission
)
if tool.task_only and binding.task_id is None:
raise HTTPException(403, "This query requires a bound task")
granted = permission in permissions and permission in binding.permission_cap
source = {
"list_scada_devices": "scada_devices",
"get_scada_device": "scada_devices",
"query_scada_readings": "scada_readings",
"query_element_history": "scada_readings",
"get_workbench_runtime": "workbench_runtime",
}.get(tool.name)
if tool.name == "query_peripheral_data":
source = args.source
if not granted and binding.task_id and source in binding.task_scope_cap:
async with scope.connection.transaction():
granted = await task_service(scope).can_read_source(binding.task_id, source)
if not granted:
raise HTTPException(403, "Data source permission is required")
if (
tool.name == "query_element_history"
and args.mode != "observed"
and (
"simulation.view" not in permissions
or "simulation.view" not in binding.permission_cap
)
):
raise HTTPException(403, "Model comparison permission is required")
async def execute_query(
scope: QueryScope,
binding: BoundContext,
tool: QueryTool,
args: BaseModel,
*,
full_snapshot: bool = False,
) -> QueryResult:
await authorize_query(scope, binding, tool, args)
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
truncated = False
units = {}
origin = "system_data"
gaps = []
source_ref = "mcp:" + str(uuid4())
evidence_id = None
if tool.computation:
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
# Context/receipt reads may have started an implicit transaction. Release
# it before potentially long CPU/IO work; evidence uses a new transaction.
await conn.commit()
async with (
scope.simulation_routing()
if name in TIMESCALE_CALCULATIONS
else nullcontext()
):
data = await calculate(
name, args, network=scope.project.project_code,
username=scope.user.username,
)
data = {
"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": data,
}
gaps = [{
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.",
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
"optimize_sensor_placement": "Candidate pressure monitoring locations, not a saved deployment scheme or a guarantee of contaminant source observability.",
"run_leakage_identification": "DMA leakage estimates depend on the total leakage constraint and aligned model/SCADA time; they do not confirm an exact leak location.",
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
"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":
rows = await nearby_elements(
conn, args.element_type, center=args.center, radius_m=args.radius_m,
limit=args.limit + 1, offset=args.offset,
)
items, page, truncated = page_result(rows, args)
truncated = truncated or args.offset > 0
data = {
"source": "model_network", "element_type": args.element_type,
"center": list(args.center), "center_crs": "EPSG:4326",
"radius_m": args.radius_m, "distance_method": "WGS84_spheroid",
"items": items,
}
units = {"distance_m": "m", "radius_m": "m"}
gaps = ["Spatial proximity does not establish hydraulic connectivity or a supply relationship"]
elif name in {"get_element_properties", "list_elements"}:
rows = await reads.asset_rows(
conn,
args.element_type,
element_id=getattr(args, "element_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_element_properties":
if not rows:
raise HTTPException(404, "Element was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name in {"list_scada_devices", "get_scada_device"}:
rows = await reads.scada_devices(
conn,
device_id=getattr(args, "device_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_scada_device":
if not rows:
raise HTTPException(404, "SCADA device was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name == "get_network_schema":
from app.services import tjnetwork
mapping = {
"option": "get_option_v3_schema",
"scada-device": "get_scada_info_schema",
"pump-energy": "get_pump_energy_schema",
}
function = getattr(
tjnetwork, mapping.get(args.kind, "get_" + args.kind.replace("-", "_") + "_schema")
)
data = await asyncio.to_thread(function, scope.project.project_code)
elif name == "get_network_options":
if args.kind == "pump-energy":
rows = await reads.read_rows(
conn,
"SELECT price,pattern_id AS pattern,efficiency_curve_id AS effic FROM network.pump_energy_settings WHERE pump_id=%s",
(args.pump_id,),
)
data = {"pump": args.pump_id, "price": None, "pattern": None, "effic": None}
if rows:
data.update(rows[0])
else:
table = {
"time": "time_settings",
"energy": "energy_settings",
"network": "simulation_settings",
}[args.kind]
where = " WHERE engine_version='v3'" if args.kind == "network" else ""
rows = await reads.read_rows(
conn,
"SELECT key,value FROM network."
+ table
+ where
+ " ORDER BY key LIMIT 501",
)
data = {str(row["key"]): str(row["value"]) for row in rows[:500]}
truncated = len(rows) > 500
elif name in {
"query_scada_readings",
"query_model_baseline_series",
"get_model_baseline_at_time",
"query_model_baseline_field",
"query_analysis_series",
"query_analysis_values",
}:
source = (
"scada"
if name == "query_scada_readings"
else "analysis"
if name.startswith("query_analysis")
else "realtime"
)
ids = (
getattr(args, "device_ids", None)
or getattr(args, "element_ids", None)
or ([args.element_id] if hasattr(args, "element_id") else [])
)
async with scope.timescale() as ts:
rows = await reads.series_rows(
ts,
source=source,
element_type=getattr(args, "element_type", "junction"),
ids=ids,
start=getattr(args, "start", None),
end=getattr(args, "end", None),
time=getattr(args, "time", None),
field=getattr(args, "field", None),
run_id=getattr(args, "run_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_model_baseline_at_time":
data = rows[:1]
else:
data, page, truncated = page_result(rows, args)
if source != "scada":
async with conn.transaction():
units = await NetworkSettingsRepository.get_result_units(conn)
if source == "analysis":
run = await AnalysisRepository.get_run(conn, args.run_id)
if not run:
raise HTTPException(404, "Analysis run was not found")
recorded = (run.get("parameters") or {}).get("result_units")
if isinstance(recorded, dict):
units = recorded
else:
gaps.append(
"Analysis units inferred from current network settings"
)
if hasattr(args, "field") and args.field not in units:
gaps.append(f"Unit metadata is unavailable for field {args.field}")
else:
metadata = [
r
for device in ids
for r in await reads.scada_devices(conn, device_id=device, limit=1)
]
units = {
r["device_id"]: r["measurement_unit"]
for r in metadata
if r["device_id"] in ids
}
elif name in {"list_analysis_runs", "get_analysis_run", "list_analysis_results"}:
async with conn.transaction():
if name == "get_analysis_run":
data = await AnalysisRepository.get_run(conn, args.run_id)
if not data:
raise HTTPException(404, "Analysis run was not found")
elif name == "list_analysis_runs":
# Fetch one additional row with the repository's supported summary-page bound.
rows = await AnalysisRepository.list_run_summaries(
conn, limit=args.limit + 1, offset=args.offset
)
data, page, truncated = page_result(rows, args)
else:
rows = await AnalysisRepository.list_results(
conn,
args.run_id,
args.result_type,
limit=args.limit,
lookahead=True,
offset=args.offset,
)
data, page, truncated = page_result(rows, args)
elif name in {"list_sensor_placement_runs", "get_sensor_placement_run"}:
rows = await reads.sensor_runs(
conn,
run_id=getattr(args, "run_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_sensor_placement_run":
if not rows:
raise HTTPException(404, "Sensor placement run was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name == "query_element_history":
from app.domain.schemas.timeseries_history import ElementHistoryQuery
from app.services.timeseries_history import TimeseriesHistoryService
query = ElementHistoryQuery(
start_time=args.start,
end_time=args.end,
mode=args.mode,
run_id=args.run_id,
elements=[e.model_dump() for e in args.elements],
)
async with scope.timescale() as ts, conn.transaction(), ts.transaction():
result = await TimeseriesHistoryService.query(
ts, conn, query, row_limit=args.limit, row_offset=args.offset
)
data = result.model_dump(mode="json")
truncated = result.truncated
page = PageInfo(limit=args.limit, offset=args.offset, has_more=truncated)
truncated = truncated or args.offset > 0
elif name in {"get_element_gis_properties", "get_source_gis_properties"}:
async with conn.transaction():
if name == "get_element_gis_properties":
data = await GisAttributeRepository.list_element_groups(
conn, args.element_type, args.element_id
)
else:
data = await GisAttributeRepository.get_source_feature_group(
conn, args.layer_key, args.source_fid
)
if data is None:
raise HTTPException(404, "Source GIS feature was not found")
elif name == "get_task_context":
if args.section in {"summary", "skill"}:
data = await reads.task_context_summary(conn, project_id=binding.project_id,
task_id=binding.task_id, report_revision=args.report_revision)
skill = read_work_order_skill(
data.get("task", {}).get("workflow"), include_content=args.section == "skill"
)
if args.section == "skill":
data = {"section": "skill", "skill": skill}
else:
data["handling_skill"] = skill
data["display_time_zone"] = "Asia/Shanghai"
data["approved_data_scopes"] = binding.task_scope_cap
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"]
truncated = True
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
else:
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
total = projection["total"]
if args.section in {"evidence", "actions", "access_requests"} and not args.data_path:
total = projection["snapshot_metadata"].get("total", total)
if projection["value_type"] in {"array", "object", "string"}:
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
elif args.offset:
raise HTTPException(422, "Scalar task detail cannot use an offset")
truncated = bool(projection.get("nested") or args.offset or page and page.has_more)
data = {"section": args.section, "selected_path": args.data_path, "value": projection["value"], "metadata": projection["snapshot_metadata"]}
if projection.get("nested"):
gaps.append("Nested values have data_path descriptors; select a descriptor path for its values")
elif name == "get_task_evidence":
projection = await reads.evidence_page(
conn, project_id=scope.project.project_id, task_id=binding.task_id,
evidence_id=args.evidence_id, data_path=args.data_path,
limit=args.limit, offset=args.offset, mcp_sources=list(TOOLS),
full_snapshot=full_snapshot,
)
document = projection if full_snapshot else projection["snapshot_metadata"]
origin = document["origin"]
source_ref = document["source_ref"]
evidence_id = args.evidence_id
truncated = document.get("truncated", False)
if full_snapshot:
data = document
else:
selected = projection["value"]
if projection["value_type"] in {"array", "object", "string"}:
total = projection["total"]
page = PageInfo(
limit=args.limit,
offset=args.offset,
total=total,
has_more=args.offset + args.limit < total,
)
truncated = truncated or page.has_more or args.offset > 0
elif args.offset:
raise HTTPException(422, "Scalar evidence cannot use an offset")
if projection.get("nested"):
truncated = True
gaps.append(
"Nested collections have data_path descriptors; query a descriptor path for their values"
)
data = {
"snapshot_metadata": document,
"query_metadata": projection["query_metadata"],
"selected_path": args.data_path,
"value": selected,
}
elif name == "get_workbench_runtime":
from app.services.workbench_runtime import read_workbench_runtime
data = (await read_workbench_runtime(scope.project, conn)).model_dump(
mode="json"
)
elif name == "query_peripheral_data":
from app.mcp.peripheral import read_peripheral_query
snapshot = await read_peripheral_query(scope, args)
data = snapshot.data
origin = snapshot.origin
truncated = snapshot.truncated
else:
raise ValueError("query handler is not registered")
window = (
{"start": args.start, "end": args.end}
if hasattr(args, "start") and args.start is not None
else None
)
data, nonfinite = finite_data(jsonable_encoder(data))
semantics = (
"comparison"
if name == "query_element_history" and args.mode != "observed"
else tool.semantics
)
return QueryResult(
source=name,
source_ref=source_ref,
evidence_id=evidence_id,
queried_at=datetime.now(timezone.utc),
semantics=semantics,
data=data,
data_gaps=gaps
+ (
[f"{nonfinite} non-finite numeric values were returned as null"]
if nonfinite
else []
),
page=page,
window=window,
units=units,
truncated=truncated,
origin=origin,
)
+74
View File
@@ -0,0 +1,74 @@
"""MCP online recalculation and isolated scenario adapters."""
from typing import Any
SCENARIO_TOOLS = frozenset({
"run_simulation",
"run_contaminant_simulation", "analyze_valve_closure",
"analyze_flushing", "analyze_pressure_regulation",
"run_burst_simulation",
})
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]:
from app.services import simulation_scenarios as scenarios
parameters = dict(parameters)
nodes = parameters.pop("output_node_ids")
links = parameters.pop("output_link_ids")
start = parameters.pop("start_time")
duration = parameters.pop("duration")
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
modify_total_duration=duration, persist_results=False, scheme_name=None)
if name == "run_simulation":
from app.services.simulation import run_online_simulation
result = run_online_simulation(
network, start, duration, output_node_ids=nodes, output_link_ids=links,
)
elif name == "run_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"))
elif name == "analyze_valve_closure":
result = scenarios.valve_close_analysis(**common,
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
elif name == "analyze_flushing":
# The service consumes m³/h (the older REST label incorrectly says L/s).
# Reject unsupported conversion rather than silently ignore the flow.
from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing currently supports LPS or CMH models only")
result = scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
elif name == "analyze_pressure_regulation":
from app.services.simulation import query_corresponding_element_id_and_query_id
mappings = query_corresponding_element_id_and_query_id(network)
controls = parameters["pump_control"]
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
raise ValueError("Pump controls must reference registered model pumps")
if any(value not in (0, 1) for key, values in controls.items() if key in mappings.fixed_pumps for value in values):
raise ValueError("Fixed-speed pump controls must be 0 or 1")
result = scenarios.pressure_regulation(**common,
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings)
else:
raise ValueError("Scenario is not registered")
if not isinstance(result, dict):
raise ValueError("Scenario did not return calculation results")
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
return {
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
"start_time": start.isoformat(), "duration_seconds": duration,
"units": result.get("units", {}), "times": result.get("times", {}),
"node_results": selected_nodes, "link_results": selected_links,
"coverage": {"requested_nodes": nodes, "requested_links": links,
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
}
+76
View File
@@ -0,0 +1,76 @@
"""Resolve authoritative metadata and project routing for each MCP request."""
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, AsyncIterator
from psycopg import AsyncConnection
from app.auth.project_dependencies import ProjectContext
from app.infra.db.project_routing import ActiveProjectRouting
from fastapi import HTTPException
from mcp.server.auth.provider import AccessToken
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import (
resolve_project_context,
resolve_project_business_routing,
resolve_project_simulation_routing,
get_project_pg_connection,
get_project_timescale_connection,
)
from app.infra.db.metadb.database import get_metadata_session
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import activate_project_routing
class QueryMetadataRepository(MetadataRepository):
async def get_project_db_routing(self, project_id, role):
routing = await super().get_project_db_routing(project_id, role)
# Existing connection generators obtain routing before awaiting a pool.
# End that implicit SELECT transaction here, before the network wait.
await self.session.commit()
return routing
@dataclass
class QueryScope:
project: ProjectContext
user: Any
connection: AsyncConnection
routing: ActiveProjectRouting
metadata: QueryMetadataRepository
@asynccontextmanager
async def simulation_routing(self) -> AsyncIterator[ActiveProjectRouting]:
"""Resolve both databases before a native calculation can modify its model."""
routing = await resolve_project_simulation_routing(self.project, self.metadata)
await self.metadata.session.commit()
with activate_project_routing(routing):
yield routing
@asynccontextmanager
async def timescale(self):
# Metadata repository here is used only to resolve a known project DB.
async with asynccontextmanager(get_project_timescale_connection)(
self.project, self.metadata
) as conn:
await self.metadata.session.commit()
yield conn
@asynccontextmanager
async def project_scope(principal: AccessToken, project_id: str):
if not principal.claims:
raise HTTPException(401, "MCP identity is missing")
async with asynccontextmanager(get_metadata_session)() as session:
metadata = QueryMetadataRepository(session)
user = await get_current_metadata_user(principal.claims, metadata)
project = await resolve_project_context(project_id, user, metadata)
routing = await resolve_project_business_routing(project, metadata)
# End the metadata read before any external query/IO waits.
await session.commit()
async with asynccontextmanager(get_project_pg_connection)(
project, metadata
) as conn:
await session.commit()
with activate_project_routing(routing):
yield QueryScope(project, user, conn, routing, metadata)
+40
View File
@@ -0,0 +1,40 @@
"""Server-owned business instructions, selected from the persisted task workflow."""
from hashlib import sha256
from pathlib import Path
from typing import Any
import yaml
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
WORKFLOW_SKILLS = {
"burst_report": "burst-report",
"water_quality_complaint": "water-quality-complaint",
"general": "appointment-follow-up",
}
def read_work_order_skill(workflow: str | None, *, include_content: bool = False) -> dict[str, Any]:
"""Read only packaged, allowlisted instructions; never interpret a task path."""
name = WORKFLOW_SKILLS.get(workflow)
if name is None:
return {"available": False, "workflow": workflow, "reason": "No business skill is configured for this workflow"}
content = (SKILL_ROOT / name / "SKILL.md").read_text(encoding="utf-8")
_, frontmatter, body = content.split("---", 2)
header = yaml.safe_load(frontmatter)
if header["name"] != name:
raise ValueError("Packaged work-order skill name does not match its directory")
result = {
"available": True,
"kind": "business_instructions",
"workflow": workflow,
"name": header["name"],
"description": header["description"],
"version": str(header["metadata"]["version"]),
"content_sha256": sha256(content.encode("utf-8")).hexdigest(),
"read": {"section": "skill"},
}
if include_content:
result["instructions"] = body.strip()
return result
+2 -2
View File
@@ -120,7 +120,7 @@ def dump_inp(project: str, inp: str, version: str = '3'):
file.write('\n'.join(inp_out_demand(project)))
elif name == STATUS:
file.write('\n'.join(inp_out_status(project)))
file.write('\n'.join(inp_out_status(project, version=version)))
elif name == PATTERNS:
if version == '3':
@@ -232,7 +232,7 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
inp += '\n'.join(inp_out_demand(project))
elif name == STATUS:
inp += '\n'.join(inp_out_status(project))
inp += '\n'.join(inp_out_status(project, version=version))
elif name == PATTERNS:
if version == '3':
+4 -2
View File
@@ -93,14 +93,16 @@ def inp_in_status(line: str) -> str:
return str(f"insert into network.link_initial_settings (link_id, status, setting) values ({sql_literal(link)}, null, {sql_literal(float(value))});")
def inp_out_status(name: str) -> list[str]:
def inp_out_status(name: str, version: str = '2') -> list[str]:
lines = []
objs = read_all(name, 'select link_id as link, status, setting from network.link_initial_settings order by link_id')
for obj in objs:
link = obj['link']
status = obj['status'] if obj['status'] != None else ''
setting = obj['setting'] if obj['setting'] != None else ''
if status != '':
# EPANET 2 valves are active at their configured setting by default.
# ACTIVE is an internal state, not a legal [STATUS] keyword in v2.
if status != '' and not (version == '2' and status.upper() == LINK_STATUS_ACTIVE):
lines.append(f'{link} {status}')
if setting != '':
lines.append(f'{link} {setting}')
+186
View File
@@ -0,0 +1,186 @@
"""Messages never change task generation or silently target a replacement run."""
from datetime import datetime, timezone
from uuid import UUID, uuid4
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, permissions_for_context
from app.domain.domain_errors import DomainError, require
from app.domain.schemas.agent_messages import (
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
)
def _message_cursor(cursor: str | None):
if not cursor:
return None, None
try:
stamp, identifier = cursor.rsplit("|", 1)
cursor_at, cursor_id = datetime.fromisoformat(stamp), UUID(identifier)
require(cursor_at.tzinfo is not None, "INVALID_CURSOR", "Cursor needs a timezone", status=400)
return cursor_at, cursor_id
except (ValueError, TypeError) as exc:
raise DomainError("INVALID_CURSOR", "Invalid message cursor", status=400) from exc
class AgentMessageService:
def __init__(self, repository, context, *, clock=None):
self.repo = repository
self.context = context
self.clock = clock or (lambda: datetime.now(timezone.utc))
def _require_service(self) -> None:
require(self.context.system_role == "service" and
OPERATIONAL_TASK_EXECUTE in permissions_for_context(self.context), "SERVICE_REQUIRED",
"Message dispatch requires a project service account", status=403)
async def get(self, message_id: UUID) -> AgentMessage:
if self.context.system_role == "service":
self._require_service()
row = await self.repo.get_message(str(message_id))
require(row is not None, "MESSAGE_NOT_FOUND", "Message not found", status=404)
require(self.context.system_role == "service" or
str(row["conversation_owner_id"]) == str(self.context.user_id),
"MESSAGE_NOT_FOUND", "Message not found", status=404)
return AgentMessage.model_validate(row)
async def _active_run(self, task_id: UUID, run_id: UUID) -> dict:
run = await self.repo.get_run(str(run_id))
require(run is not None and str(run["task_id"]) == str(task_id),
"RUN_NOT_FOUND", "Run does not belong to the task", status=404)
task = await self.repo.get_task(str(task_id))
require(task is not None and task["task_type"] == "event_investigation",
"INVALID_TASK_TYPE", "Messages require an investigation task", status=409)
require(run["status"] in {"queued", "claimed", "running"},
"RUN_TERMINAL", "Run no longer accepts messages", status=409)
if run["status"] != "queued":
expires = run.get("lease_expires_at")
if isinstance(expires, str):
expires = datetime.fromisoformat(expires.replace("Z", "+00:00"))
require(expires is not None and expires > self.clock(),
"LEASE_EXPIRED", "Run lease expired", status=409)
return run
async def _insert(self, *, command, owner_id, conversation_owner_id, direction, task_id, run_id,
target_session_id, in_reply_to=None) -> AgentMessage:
prior = await self.repo.get_message_request(
str(owner_id), command.source_session_id, str(command.request_id),
)
expected = {
"task_id": str(task_id), "run_id": str(run_id),
"direction": direction, "in_reply_to": str(in_reply_to) if in_reply_to else None,
"text": command.text,
}
if prior is not None:
require(all((str(prior[key]) if prior[key] is not None else None) == value
for key, value in expected.items()),
"IDEMPOTENCY_CONFLICT", "request_id was used with different content", status=409)
return AgentMessage.model_validate(prior)
# The run lock serializes message creation with completion/cancellation.
await self._active_run(task_id, run_id)
message_id = uuid4()
now = self.clock()
message = AgentMessage(
project_id=self.context.project_id,
message_id=message_id, request_id=command.request_id,
task_id=task_id, run_id=run_id, source_session_id=command.source_session_id,
source_owner_id=owner_id, conversation_owner_id=conversation_owner_id,
target_session_id=target_session_id,
direction=direction, in_reply_to=in_reply_to, text=command.text,
status="pending", native_message_id=f"msg_{message_id}",
created_at=now, updated_at=now,
)
await self.repo.insert_message(message.model_dump(mode="json"))
return message
async def create(self, command: AgentMessageCreate) -> AgentMessage:
async with self.repo.transaction():
await self.repo.lock(f"run:{command.run_id}")
run = await self.repo.get_run(str(command.run_id))
return await self._insert(
command=command, owner_id=self.context.user_id,
conversation_owner_id=self.context.user_id, direction="to_task",
task_id=command.task_id, run_id=command.run_id,
target_session_id=run.get("agent_session_id") if run else None,
)
async def reply(self, message_id: UUID, command: AgentMessageReply) -> AgentMessage:
async with self.repo.transaction():
original = await self.get(message_id)
await self.repo.lock(f"run:{original.run_id}")
run = await self.repo.get_run(str(original.run_id))
if original.direction == "to_task":
self._require_service()
require(run is not None and run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Session does not own the message run", status=409)
direction, target = "to_chat", original.source_session_id
else:
require(original.conversation_owner_id == self.context.user_id,
"MESSAGE_NOT_FOUND", "Message not found", status=404)
require(command.source_session_id == original.target_session_id,
"SESSION_MISMATCH", "Only the original chat can reply", status=409)
direction = "to_task"
target = run.get("agent_session_id") if run else None
return await self._insert(
command=command, owner_id=self.context.user_id,
conversation_owner_id=original.conversation_owner_id, direction=direction,
task_id=original.task_id, run_id=original.run_id,
target_session_id=target, in_reply_to=message_id,
)
async def list_session(
self, session_id: str, limit: int, cursor: str | None, pending_only: bool = False,
) -> AgentMessagePage:
cursor_at, cursor_id = _message_cursor(cursor)
kwargs = {"cursor_at": cursor_at, "cursor_id": cursor_id, "pending_only": pending_only}
if self.context.system_role == "service":
self._require_service()
rows = await self.repo.list_run_session_messages(session_id, limit + 1, **kwargs)
if not rows:
rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs)
else:
rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs)
items = [AgentMessage.model_validate(row) for row in rows[:limit]]
next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None
return AgentMessagePage(items=items, next_cursor=next_cursor)
async def pending(self, limit: int, cursor: str | None = None) -> AgentMessagePage:
self._require_service()
cursor_at, cursor_id = _message_cursor(cursor)
rows = await self.repo.list_pending_messages(limit + 1, cursor_at=cursor_at, cursor_id=cursor_id)
items = [AgentMessage.model_validate(row) for row in rows[:limit]]
next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None
return AgentMessagePage(items=items, next_cursor=next_cursor)
async def delivery(self, message_id: UUID, command: AgentMessageDelivery) -> AgentMessage:
async with self.repo.transaction():
message = await self.get(message_id)
if self.context.system_role == "service":
self._require_service()
if self.context.system_role != "service":
require(message.direction == "to_chat",
"SERVICE_REQUIRED", "Only the receiver can acknowledge a chat reply", status=403)
await self.repo.lock(f"run:{message.run_id}")
await self.repo.lock(f"message:{message_id}")
message = await self.get(message_id)
if message.status in {"delivered", "failed"}:
require(message.status == command.status, "MESSAGE_TERMINAL",
"Message already has a terminal delivery result", status=409)
return message
if message.direction == "to_task" and command.status != "failed":
run = await self._active_run(message.task_id, message.run_id)
target = run.get("agent_session_id")
require(target is not None and command.target_session_id == target,
"SESSION_MISMATCH", "Delivery must target the owning run session", status=409)
else:
target = message.target_session_id
require(command.target_session_id in {None, target}, "SESSION_MISMATCH",
"Delivery cannot change the reply channel", status=409)
require(command.native_message_id in {None, message.native_message_id},
"MESSAGE_ID_MISMATCH", "Native message identity is immutable", status=409)
now = self.clock()
update = {
"status": command.status, "target_session_id": target,
"failure_detail": command.failure_detail, "updated_at": now,
"delivered_at": now if command.status == "delivered" else None,
}
await self.repo.update_message_delivery(str(message_id), update)
return message.model_copy(update=update)
+2
View File
@@ -63,6 +63,7 @@ def run_burst_location_by_network(
use_scada_flow: bool = False,
scheme_name: str | None = None,
simulation_run_id: UUID | None = None,
final_candidates_csv_path: str | None = "temp/burst_location/final_round_candidates.csv",
) -> dict[str, Any]:
if not network:
raise ValueError("network is required.")
@@ -312,6 +313,7 @@ def run_burst_location_by_network(
min_dpressure=min_dpressure,
basic_pressure=basic_pressure,
visualize_partition=False,
final_candidates_csv_path=final_candidates_csv_path,
)
payload: dict[str, Any] = {
+18
View File
@@ -0,0 +1,18 @@
"""RFC 8785 canonical JSON hashing shared by persisted records."""
from hashlib import sha256
import rfc8785
from app.domain.domain_errors import DomainError
def canonical(value: object) -> bytes:
try:
return rfc8785.dumps(value)
except rfc8785.CanonicalizationError as exc:
raise DomainError("SCHEMA_INVALID", "Content is not canonicalizable JSON") from exc
def digest(value: object) -> str:
return "sha256:" + sha256(canonical(value)).hexdigest()
+6 -3
View File
@@ -5,6 +5,9 @@ import httpx
from pydantic import AliasChoices, BaseModel, Field
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
install_tianditu_token_filter()
class TiandituGeocodeRequest(BaseModel):
@@ -32,12 +35,12 @@ async def geocode_tianditu(
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
if not settings.TIANDITU_GEOCODER_TOKEN:
raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured")
if not settings.TIANDITU_TOKEN:
raise TiandituGeocodingConfigError("TIANDITU_TOKEN is not configured")
params = {
"ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False),
"tk": settings.TIANDITU_GEOCODER_TOKEN,
"tk": settings.TIANDITU_TOKEN,
}
if client is not None:
+99
View File
@@ -0,0 +1,99 @@
"""Resolve browser overlays only from immutable Server-ingested snapshots."""
from __future__ import annotations
from math import isfinite
from app.domain.domain_errors import require
from app.domain.schemas.operational_data import TaskDataSnapshot
def _number(value) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)
def _position(value):
require(isinstance(value, list) and len(value) in {2, 3}
and all(_number(item) for item in value)
and -180 <= value[0] <= 180 and -90 <= value[1] <= 90,
"OVERLAY_GEOMETRY_INVALID", "Overlay coordinates must be finite WGS84 positions", status=422)
return value[:2]
def _geometry(value):
require(isinstance(value, dict), "OVERLAY_GEOMETRY_INVALID", "Overlay geometry is missing", status=422)
kind = value.get("type")
depths = {"Point": 0, "MultiPoint": 1, "LineString": 1,
"MultiLineString": 2, "Polygon": 2, "MultiPolygon": 3}
require(kind in depths, "OVERLAY_GEOMETRY_INVALID", "Unsupported overlay geometry", status=422)
positions = 0
def coordinates(items, depth):
nonlocal positions
if depth == 0:
positions += 1
require(positions <= 20000, "OVERLAY_TOO_LARGE", "Overlay geometry is too large", status=422)
return _position(items)
require(isinstance(items, list) and 0 < len(items) <= 20000,
"OVERLAY_GEOMETRY_INVALID", "Overlay coordinates are empty or too large", status=422)
return [coordinates(item, depth - 1) for item in items]
normalized = coordinates(value.get("coordinates"), depths[kind])
lines = ([normalized] if kind == "LineString" else normalized if kind == "MultiLineString" else [])
require(all(len(line) >= 2 for line in lines), "OVERLAY_GEOMETRY_INVALID",
"Overlay lines need at least two positions", status=422)
rings = (normalized if kind == "Polygon" else [ring for polygon in normalized for ring in polygon]
if kind == "MultiPolygon" else [])
require(all(len(ring) >= 4 and ring[0] == ring[-1] for ring in rings),
"OVERLAY_GEOMETRY_INVALID", "Overlay polygon rings must be closed", status=422)
return {"type": kind, "coordinates": normalized}
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
data = snapshot.data
candidates = [data]
if isinstance(data, dict):
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
latest = data.get("latest_analysis")
if isinstance(latest, dict):
candidates.append(latest.get("map_payload"))
collection = next((item for item in candidates
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
if collection is not None:
raw_features = collection.get("features")
require(isinstance(raw_features, list), "OVERLAY_GEOMETRY_INVALID",
"Snapshot FeatureCollection is invalid", status=422)
else:
rows = data.get("items") if isinstance(data, dict) else data
require(isinstance(rows, list), "OVERLAY_DATA_MISSING",
"Evidence snapshot has no recorded coordinates or GeoJSON", status=422)
raw_features = []
for row in rows:
if not isinstance(row, dict) or row.get("longitude") is None or row.get("latitude") is None:
continue
raw_features.append({
"type": "Feature", "geometry": {"type": "Point", "coordinates": [row["longitude"], row["latitude"]]},
"id": row.get("device_id") or row.get("point_id") or row.get("work_order_id") or row.get("id"),
"properties": {"weight": row.get("weight", 1)},
})
require(0 < len(raw_features) <= 1000, "OVERLAY_DATA_MISSING",
"Overlay needs between one and 1000 recorded features", status=422)
features = []
for feature in raw_features:
require(isinstance(feature, dict) and feature.get("type") == "Feature",
"OVERLAY_GEOMETRY_INVALID", "Snapshot contains an invalid feature", status=422)
geometry = _geometry(feature.get("geometry"))
require(mode != "heatmap" or geometry["type"] == "Point", "OVERLAY_HEATMAP_POINTS_REQUIRED",
"Heatmaps require recorded points, not inferred polygon centroids", status=422)
properties = feature.get("properties") or {}
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool):
normalized["id"] = feature["id"]
if mode == "heatmap":
weight = properties.get("weight", 1)
require(_number(weight) and weight >= 0, "OVERLAY_WEIGHT_INVALID",
"Heatmap weight must be a finite nonnegative number", status=422)
normalized["properties"]["weight"] = weight
features.append(normalized)
return {"type": "FeatureCollection", "features": features}
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
import json
from typing import Any
import httpx
from pydantic import ValidationError
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.domain.schemas.place_search import (
MAX_PLACE_SEARCH_RESULTS,
PlaceSearchItem,
PlaceSearchRequest,
PlaceSearchResponse,
)
install_tianditu_token_filter()
class PlaceSearchError(RuntimeError):
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
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, "地名搜索服务未配置")
params = {
"postStr": json.dumps(
{
"keyWord": request.keyword,
"queryType": 12,
"specify": "156" + config.admin_code.removeprefix("156"),
"start": 0,
"count": MAX_PLACE_SEARCH_RESULTS,
"show": 2,
},
ensure_ascii=False,
),
"type": "query",
"tk": token,
}
try:
if client is not None:
response = await client.get(settings.TIANDITU_SEARCH_URL, params=params)
else:
async with httpx.AsyncClient(
timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS
) as managed_client:
response = await managed_client.get(
settings.TIANDITU_SEARCH_URL, params=params
)
response.raise_for_status()
except httpx.TimeoutException:
raise PlaceSearchError(504, "地名搜索超时,请稍后重试") from None
except httpx.HTTPError:
# Request URLs contain the provider key and must not reach the client.
raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None
try:
return _parse_response(response.json(), config)
except (ValueError, TypeError, KeyError, AttributeError, ValidationError):
raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None
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, region=config.region)
if code != "1000":
raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试")
result_type = str(payload.get("resultType", ""))
if result_type not in {"1", "3"}:
raise PlaceSearchError(502, "未获得可定位的地名结果,请细化关键字")
raw_items = payload.get("pois", []) if result_type == "1" else payload.get("area", [])
if not isinstance(raw_items, list) or len(raw_items) > MAX_PLACE_SEARCH_RESULTS:
raise ValueError("Invalid search results")
items = []
for raw in raw_items:
county = raw.get("county")
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():
raise ValueError("Missing place name")
name = name.strip()
category = raw.get("typeName") or ("公交站" if str(raw.get("poiType")) == "102" else None)
items.append(PlaceSearchItem(
id=str(raw.get("hotPointID") or raw.get("adminCode") or f"{name}:{lon},{lat}"),
name=name,
address=str(raw.get("address") or name),
longitude=float(lon), latitude=float(lat),
category="行政区" if result_type == "3" else category,
district=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, region=config.region,
)
+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),
)
+36 -2
View File
@@ -1,9 +1,10 @@
from datetime import datetime
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.native.wndb.core.connection import project_connection, project_transaction
from app.infra.db.project_routing import get_active_project_routing
from app.domain.time import parse_utc_time
@@ -40,6 +41,7 @@ def create_analysis_run(
started_at = parse_utc_time(scheme_start_time, field_name="scheme_start_time")
run_id = uuid4()
with project_connection(name) as conn:
with conn.transaction():
AnalysisRepository.create_run_sync(
conn,
run_id=run_id,
@@ -50,6 +52,10 @@ def create_analysis_run(
status=status,
parameters=scheme_detail,
)
_append_run_event(
conn, run_id, revision=1, status=status,
occurred_at=started_at, results_committed=False,
)
return run_id
@@ -84,6 +90,10 @@ def store_analysis_run_with_result(
result_type=result_type,
payload=result_payload,
)
_append_run_event(
conn, run_id, revision=1, status="completed",
occurred_at=started_at, results_committed=True,
)
return run_id
@@ -96,13 +106,37 @@ def update_analysis_run(
scheme_detail: dict[str, Any],
) -> None:
with project_connection(name) as conn:
AnalysisRepository.update_run_sync(
with conn.transaction():
revision = AnalysisRepository.update_run_sync(
conn,
run_id,
status=status,
created_by=username,
parameters=scheme_detail,
)
_append_run_event(
conn, run_id, revision=revision, status=status,
occurred_at=datetime.now(timezone.utc),
results_committed=status == "completed",
)
def _append_run_event(
conn, run_id: UUID, *, revision: int, status: str,
occurred_at: datetime, results_committed: bool,
) -> None:
routing = get_active_project_routing()
if routing is None or routing.project_id is None:
return
AnalysisRepository.append_run_changed_event_sync(
conn,
project_id=routing.project_id,
run_id=run_id,
revision=revision,
status=status,
occurred_at=occurred_at,
results_committed=results_committed,
)
def get_analysis_run(name: str, run_id: UUID) -> dict[str, Any]:

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