Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d31f456e6 | ||
|
|
cddfc8647d | ||
|
|
1d061f6099 | ||
|
|
d320972d57 | ||
|
|
b4f3204489 | ||
|
|
1e71f8953b | ||
|
|
f3818b3fed | ||
|
|
024abf07a8 | ||
|
|
be6317eb48 | ||
|
|
2b6fc5aa52 | ||
|
|
4a63fcd7ff | ||
|
|
b70283c2ff | ||
|
|
afcbd52a5a | ||
|
|
540d73add8 | ||
|
|
ad813a7c16 | ||
|
|
db092b0f5e | ||
|
|
e374b5b3cb | ||
|
|
1a6ff64bbe | ||
|
|
91afe76c6d | ||
|
|
ed06bc2bfc | ||
|
|
9335bdc1ea | ||
|
|
baff7dfe9e | ||
|
|
7ab9b36a5e |
+95
-50
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -44,6 +44,7 @@ async def get_project_metadata(
|
||||
description=project.description,
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=getattr(project, "map_config", {}) or {},
|
||||
status=project.status,
|
||||
project_role=ctx.project_role,
|
||||
)
|
||||
|
||||
@@ -319,6 +319,7 @@ async def provision_project(
|
||||
description=project.description,
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
map_config=getattr(project, "map_config", {}) or {},
|
||||
status=project.status,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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都有引号
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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数据"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 "",
|
||||
)
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class PlaceSearchConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
enabled: bool = False
|
||||
provider: Literal["tianditu"] = "tianditu"
|
||||
admin_code: str = Field(pattern=r"^(?:156)?[0-9]{6}$")
|
||||
region: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ProjectMapConfig(BaseModel):
|
||||
# Preserve existing map options such as legend when editing search settings.
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
place_search: PlaceSearchConfig | None = None
|
||||
|
||||
|
||||
class ProjectMapConfigUpdate(BaseModel):
|
||||
map_config: ProjectMapConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_null_map_config(self):
|
||||
if "map_config" in self.model_fields_set and self.map_config is None:
|
||||
raise ValueError("map_config must be an object")
|
||||
return self
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ScadaDeviceResponse(BaseModel):
|
||||
@@ -17,3 +19,31 @@ class ScadaDeviceResponse(BaseModel):
|
||||
y: float | None = None
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
|
||||
|
||||
class ScadaLatestReadingResponse(BaseModel):
|
||||
"""Latest persisted observation for one project SCADA device."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
device_id: str
|
||||
device_type: str
|
||||
measurement_unit: str
|
||||
transmission_mode: str
|
||||
transmission_frequency: str
|
||||
observed_at: datetime | None = None
|
||||
monitored_value: float | None = None
|
||||
cleaned_value: float | None = None
|
||||
|
||||
|
||||
class ScadaLatestReadingsResponse(BaseModel):
|
||||
"""Bounded device page with each device's latest persisted observation."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
items: list[ScadaLatestReadingResponse]
|
||||
total: int = Field(ge=0)
|
||||
limit: int = Field(ge=1, le=500)
|
||||
offset: int = Field(ge=0)
|
||||
observed_count: int = Field(ge=0)
|
||||
latest_observed_at: datetime | None = None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -86,3 +86,4 @@ class ElementHistorySeries(BaseModel):
|
||||
|
||||
class ElementHistoryResponse(BaseModel):
|
||||
series: list[ElementHistorySeries]
|
||||
truncated: bool = Field(default=False, exclude=True)
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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))
|
||||
@@ -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()]
|
||||
@@ -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,),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
+40
-10
@@ -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,20 +26,38 @@ 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("**********************************************************")
|
||||
|
||||
yield
|
||||
# 清理资源
|
||||
await project_connection_manager.close_all()
|
||||
close_all_timescale_pools()
|
||||
close_all_project_pools()
|
||||
await close_metadata_engine()
|
||||
logger.info("Database connections closed")
|
||||
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()
|
||||
await close_metadata_engine()
|
||||
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=["*"],
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Errors with a known execution boundary for MCP calculations."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class CalculationCapacityExceeded(HTTPException):
|
||||
"""Raised by admission control before the native function is invoked."""
|
||||
|
||||
code = "CALCULATION_CAPACITY_EXCEEDED"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
503,
|
||||
"Calculation capacity is occupied; calculation not started. "
|
||||
"Retry the same request after an active calculation finishes.",
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Finite project services exposed through MCP; no routes, SQL or actor overrides."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator, create_model
|
||||
|
||||
from app.mcp.models import Id, PageInput, StrictInput
|
||||
from app.mcp.analysis_models import (
|
||||
CalculationInput, ScenarioInput, ContaminantSimulationInput, ValveClosureInput,
|
||||
FlushingInput, PressureRegulationInput,
|
||||
)
|
||||
from app.domain.schemas.operational_task import (
|
||||
OperationalTaskCreate, OperationalTaskScheduleCreate, OperationalTaskScheduleUpdate,
|
||||
OperationalTaskRetryCommand, AgentRunCancelCommand,
|
||||
)
|
||||
from app.domain.schemas.sensor_placement import SensorPlacementUpdateRequest
|
||||
|
||||
|
||||
class RegionInput(PageInput):
|
||||
region_id: Id
|
||||
|
||||
|
||||
class NodeConnectionsInput(PageInput):
|
||||
node_id: Id
|
||||
|
||||
|
||||
class ExtentInput(PageInput):
|
||||
element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"] = "pipe"
|
||||
bounds: tuple[float, float, float, float] = Field(description="EPSG:4326 [west,south,east,north]; 不跨180度经线")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_bounds(self):
|
||||
west, south, east, north = self.bounds
|
||||
if not (-180 <= west < east <= 180 and -85 <= south < north <= 85):
|
||||
raise ValueError("Invalid EPSG:4326 bounds")
|
||||
if east - west > 1 or north - south > 1:
|
||||
raise ValueError("Extent must be at most one degree per axis")
|
||||
return self
|
||||
|
||||
|
||||
class MajorNodesInput(PageInput):
|
||||
min_diameter_mm: float = Field(gt=0, le=10000, allow_inf_nan=False)
|
||||
|
||||
|
||||
ConfigKind = Literal[
|
||||
"pattern", "curve", "control", "rule", "quality", "source", "reaction",
|
||||
"pipe-reaction", "tank-reaction", "mixing", "emitter", "demand",
|
||||
"time", "energy", "pump-energy", "option",
|
||||
]
|
||||
|
||||
|
||||
class ModelConfigurationInput(PageInput):
|
||||
kind: ConfigKind
|
||||
object_id: Id | None = None
|
||||
data_path: list[Id] = Field(default_factory=list, max_length=8, description="选择嵌套字段,再按limit/offset分页读取")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_target(self):
|
||||
needs_id = {"pattern", "curve", "quality", "source", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand", "pump-energy"}
|
||||
if (self.kind in needs_id) != (self.object_id is not None):
|
||||
raise ValueError("object_id is required only for object-scoped configuration")
|
||||
return self
|
||||
|
||||
|
||||
class ConfigurationListInput(PageInput):
|
||||
kind: Literal["pattern", "curve"]
|
||||
|
||||
|
||||
class PlaceSearchInput(StrictInput):
|
||||
keyword: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class ReverseGeocodeInput(StrictInput):
|
||||
longitude: float = Field(ge=-180, le=180, allow_inf_nan=False)
|
||||
latitude: float = Field(ge=-90, le=90, allow_inf_nan=False)
|
||||
|
||||
|
||||
class ProjectExportInput(StrictInput):
|
||||
format: Literal["inp", "change_set"] = "inp"
|
||||
version: Literal["2", "3"] = "2"
|
||||
|
||||
|
||||
class SensorCandidateInput(StrictInput):
|
||||
node_id: Id
|
||||
|
||||
|
||||
class SensorExportInput(StrictInput):
|
||||
run_id: UUID
|
||||
|
||||
|
||||
class TaskHistoryInput(PageInput):
|
||||
target_task_id: UUID
|
||||
|
||||
|
||||
class MutationStatusInput(StrictInput):
|
||||
request_id: UUID
|
||||
|
||||
|
||||
class DemandAllocationInput(CalculationInput):
|
||||
target: Literal["nodes", "region", "network"]
|
||||
demand: float = Field(gt=0, allow_inf_nan=False, description="模型流量单位的总需水量;调用前查询模型单位")
|
||||
node_ids: list[Id] = Field(default_factory=list, max_length=200)
|
||||
region_id: Id | None = None
|
||||
limit: int = Field(default=20, ge=1, le=200)
|
||||
offset: int = Field(default=0, ge=0, le=10000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_target(self):
|
||||
if (self.target == "nodes") != bool(self.node_ids) or (self.target == "region") != (self.region_id is not None):
|
||||
raise ValueError("Provide node_ids for nodes or region_id for region only")
|
||||
if len(self.node_ids) != len(set(self.node_ids)):
|
||||
raise ValueError("node_ids must be distinct")
|
||||
return self
|
||||
|
||||
|
||||
class BurstSimulationInput(ScenarioInput):
|
||||
burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20)
|
||||
orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积cm²,不是流量;必须有依据")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def paired_bursts(self):
|
||||
if len(self.burst_pipe_ids) != len(self.orifice_areas_cm2) or len(set(self.burst_pipe_ids)) != len(self.burst_pipe_ids):
|
||||
raise ValueError("Distinct pipes and one orifice area per pipe are required")
|
||||
return self
|
||||
|
||||
|
||||
class MutationInput(CalculationInput):
|
||||
request_id: UUID = Field(description="操作幂等键;预览与执行使用相同值,失败后先核实状态")
|
||||
dry_run: bool = Field(default=True, strict=True, description="默认只预览;执行需dry_run=false及预览返回的expected_digest")
|
||||
expected_digest: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_preview(self):
|
||||
if not self.dry_run and self.expected_digest is None:
|
||||
raise ValueError("Execution requires the preview's expected_digest")
|
||||
command = getattr(self, "command", None)
|
||||
if command is not None and getattr(command, "request_id", self.request_id) != self.request_id:
|
||||
raise ValueError("Nested command request_id must match the operation")
|
||||
return self
|
||||
|
||||
|
||||
class ModelElementMutationInput(MutationInput):
|
||||
operation: Literal["create", "update", "delete"]
|
||||
element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir", "region"]
|
||||
element_id: Id
|
||||
properties: dict[Id, JsonValue] = Field(default_factory=dict, max_length=40, description="仅允许get_network_schema声明的可写字段;id由element_id绑定")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_properties(self):
|
||||
if (self.operation == "delete") == bool(self.properties):
|
||||
raise ValueError("Create/update require properties; delete takes none")
|
||||
if "id" in self.properties or "type" in self.properties:
|
||||
raise ValueError("Element identity is bound separately")
|
||||
return self
|
||||
|
||||
|
||||
class ConfigurationMutationInput(MutationInput):
|
||||
operation: Literal["create", "update", "delete"] = "update"
|
||||
kind: ConfigKind
|
||||
object_id: Id | None = None
|
||||
properties: dict[Id, JsonValue] = Field(default_factory=dict, max_length=40)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_configuration(self):
|
||||
ModelConfigurationInput(context_handle=self.context_handle, kind=self.kind, object_id=self.object_id)
|
||||
if self.operation != "update" and self.kind not in {"pattern", "curve", "source", "mixing"}:
|
||||
raise ValueError("Only pattern, curve, source and mixing support create/delete")
|
||||
if (self.operation == "delete") == bool(self.properties):
|
||||
raise ValueError("Create/update require properties; delete takes none")
|
||||
if "id" in self.properties:
|
||||
raise ValueError("Object identity is bound separately")
|
||||
return self
|
||||
|
||||
|
||||
class ScadaCleaningInput(MutationInput):
|
||||
start: datetime
|
||||
end: datetime
|
||||
device_ids: list[Id] = Field(min_length=1, max_length=20, description="必须显式列出设备;不支持all")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_window(self):
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None or not timedelta(0) < self.end - self.start <= timedelta(days=1):
|
||||
raise ValueError("SCADA writes require explicit timezone and at most 24 hours")
|
||||
if len(set(self.device_ids)) != len(self.device_ids):
|
||||
raise ValueError("device_ids must be distinct")
|
||||
return self
|
||||
|
||||
|
||||
class ScadaRecord(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
device_id: Id
|
||||
time: datetime
|
||||
monitored_value: float | None = Field(default=None, allow_inf_nan=False)
|
||||
cleaned_value: float | None = Field(default=None, allow_inf_nan=False)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_record(self):
|
||||
if self.time.tzinfo is None or not ({"monitored_value", "cleaned_value"} & self.model_fields_set):
|
||||
raise ValueError("Record needs an explicit timezone and a value field")
|
||||
return self
|
||||
|
||||
|
||||
class ScadaWriteInput(MutationInput):
|
||||
operation: Literal["insert", "update"]
|
||||
records: list[ScadaRecord] = Field(min_length=1, max_length=500)
|
||||
provenance: str = Field(min_length=1, max_length=2000, description="补录/修正的真实来源与依据,不得把模型值冒充实测")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def bounded_records(self):
|
||||
keys = [(r.device_id, r.time) for r in self.records]
|
||||
if len(set(keys)) != len(keys):
|
||||
raise ValueError("Duplicate device/time records")
|
||||
if (max(r.time for r in self.records) - min(r.time for r in self.records)).total_seconds() > 86400:
|
||||
raise ValueError("At most 24 hours per write")
|
||||
return self
|
||||
|
||||
|
||||
class ScadaDeleteInput(ScadaCleaningInput):
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class SensorCreateInput(MutationInput):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
method: Literal["sensitivity", "kmeans"]
|
||||
sensor_count: int = Field(ge=1, le=20)
|
||||
min_diameter: int = Field(default=0, ge=0, le=5000)
|
||||
|
||||
|
||||
class SensorUpdateInput(MutationInput):
|
||||
run_id: UUID
|
||||
changes: SensorPlacementUpdateRequest
|
||||
|
||||
|
||||
def _payload(model):
|
||||
return create_model(model.__name__ + "Payload", __config__=ConfigDict(extra="forbid"), **{
|
||||
name: (field.annotation, field) for name, field in model.model_fields.items()
|
||||
if name not in {"context_handle", "timeout_seconds"}
|
||||
})
|
||||
|
||||
|
||||
BurstPayload = _payload(BurstSimulationInput)
|
||||
ContaminantPayload = _payload(ContaminantSimulationInput)
|
||||
ValvePayload = _payload(ValveClosureInput)
|
||||
FlushingPayload = _payload(FlushingInput)
|
||||
PressurePayload = _payload(PressureRegulationInput)
|
||||
|
||||
|
||||
class SavedScenarioInput(MutationInput):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
scenario: Literal["burst", "contaminant", "valve_closure", "flushing", "pressure_regulation"]
|
||||
parameters: BurstPayload | ContaminantPayload | ValvePayload | FlushingPayload | PressurePayload
|
||||
|
||||
@model_validator(mode="after")
|
||||
def matching_scenario(self):
|
||||
expected = {"burst": BurstSimulationInput, "contaminant": ContaminantSimulationInput, "valve_closure": ValveClosureInput, "flushing": FlushingInput, "pressure_regulation": PressureRegulationInput}[self.scenario]
|
||||
expected.model_validate({**self.parameters.model_dump(), "context_handle": self.context_handle})
|
||||
return self
|
||||
|
||||
|
||||
class TaskCreateInput(MutationInput):
|
||||
command: OperationalTaskCreate
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unclaimed_event(self):
|
||||
if self.command.agent_session_id is not None or self.command.task_type != "event_investigation" or self.command.source.kind not in {"operator", "agent"}:
|
||||
raise ValueError("Only unclaimed operator/agent event investigations may be created")
|
||||
return self
|
||||
|
||||
|
||||
class ScheduleCreateInput(MutationInput):
|
||||
command: OperationalTaskScheduleCreate
|
||||
|
||||
|
||||
class ScheduleUpdateInput(MutationInput):
|
||||
schedule_id: UUID
|
||||
command: OperationalTaskScheduleUpdate
|
||||
|
||||
|
||||
class TaskRetryInput(MutationInput):
|
||||
target_task_id: UUID
|
||||
command: OperationalTaskRetryCommand
|
||||
|
||||
|
||||
class TaskCancelInput(MutationInput):
|
||||
target_task_id: UUID
|
||||
run_id: UUID
|
||||
command: AgentRunCancelCommand
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Adapters for bounded, project-owned queries and downloadable exports."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from psycopg import sql
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.infra.db.postgresql.read_queries import ELEMENT_VIEWS, read_rows
|
||||
from app.mcp.models import PageInfo, QueryResult, BoundContext
|
||||
from app.mcp.scope import QueryScope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.mcp.catalog import QueryTool
|
||||
|
||||
|
||||
# Both function names and object identities are selected from a finite registry.
|
||||
CONFIG_FUNCTIONS = {
|
||||
"pattern": ("pattern", "id"), "curve": ("curve", "id"),
|
||||
"control": ("control", None), "rule": ("rule", None),
|
||||
"quality": ("quality", "node"), "source": ("source", "node"),
|
||||
"reaction": ("reaction", None), "pipe-reaction": ("pipe_reaction", "pipe"),
|
||||
"tank-reaction": ("tank_reaction", "tank"), "mixing": ("mixing", "tank"),
|
||||
"emitter": ("emitter", "junction"), "demand": ("demand", "junction"),
|
||||
"time": ("time", None), "energy": ("energy", None),
|
||||
"pump-energy": ("pump_energy", "pump"), "option": ("option_v3", None),
|
||||
}
|
||||
|
||||
EXTENDED_READ_TOOLS = {
|
||||
"list_regions", "get_region", "get_region_nodes", "get_node_connections",
|
||||
"query_network_extent", "list_major_nodes", "get_model_configuration",
|
||||
"list_model_configurations", "search_project_places", "reverse_geocode",
|
||||
"get_project_info", "export_project_model", "get_sensor_placement_candidate",
|
||||
"export_sensor_placement", "list_task_schedules", "get_task_run_history", "get_mutation_status",
|
||||
}
|
||||
ARTIFACT_TOOLS = {"export_project_model", "export_sensor_placement"}
|
||||
|
||||
|
||||
def configuration(network: str, kind: str, object_id: str | None = None) -> Any:
|
||||
from app.services import tjnetwork
|
||||
|
||||
function, identity = CONFIG_FUNCTIONS[kind]
|
||||
args = (network, object_id) if identity else (network,)
|
||||
return getattr(tjnetwork, "get_" + function)(*args)
|
||||
|
||||
|
||||
def artifact(filename: str, payload: bytes, mime_type: str) -> dict[str, Any]:
|
||||
if len(payload) > 5 * 1024 * 1024:
|
||||
raise HTTPException(413, "Export exceeds the 5 MiB compressed file budget")
|
||||
return {"file": {
|
||||
"name": filename, "mime_type": mime_type, "size": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"encoding": "base64", "data": base64.b64encode(payload).decode("ascii"),
|
||||
}}
|
||||
|
||||
|
||||
def project_export(network: str, args: BaseModel) -> dict[str, Any]:
|
||||
from app.services import tjnetwork
|
||||
from app.services.project_inp import temporary_project_inp
|
||||
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
if args.format == "inp":
|
||||
with temporary_project_inp(network, purpose="mcp_export", version=args.version) as path:
|
||||
archive.write(path, arcname="model.inp")
|
||||
else:
|
||||
changes = tjnetwork.export_inp(network, args.version)
|
||||
if changes.operations:
|
||||
changes.operations[0]["vertex"] = json.dumps(tjnetwork.get_all_vertices(network))
|
||||
changes.operations[0]["scada"] = json.dumps(tjnetwork.get_all_scada_info(network))
|
||||
archive.writestr("model.json", json.dumps(jsonable_encoder(changes.operations), ensure_ascii=False))
|
||||
return artifact("model.zip", output.getvalue(), "application/zip")
|
||||
|
||||
|
||||
def sensor_export(network: str, args: BaseModel, user: Any, project: Any) -> dict[str, Any]:
|
||||
from app.services.sensor_placement import get_sensor_placement_run, build_sensor_placement_workbook
|
||||
|
||||
run = get_sensor_placement_run(network, args.run_id)
|
||||
workbook = build_sensor_placement_workbook(
|
||||
network=network, scheme=run, sensor_location=run["sensor_locations"], adjustment_status={},
|
||||
)
|
||||
return artifact("sensor-placement.xlsx", workbook.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
|
||||
|
||||
async def execute_extended(scope: QueryScope, binding: BoundContext, tool: "QueryTool", args: BaseModel) -> QueryResult:
|
||||
name, conn, network = tool.name, scope.connection, scope.project.project_code
|
||||
page, gaps, truncated = None, [], False
|
||||
|
||||
def paginate(items, *, already_paged=False):
|
||||
nonlocal page, truncated
|
||||
if already_paged:
|
||||
more, selected, total = len(items) > args.limit, items[:args.limit], None
|
||||
else:
|
||||
total = len(items)
|
||||
more = args.offset + args.limit < total
|
||||
selected = items[args.offset:args.offset + args.limit]
|
||||
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=more)
|
||||
truncated = more or args.offset > 0
|
||||
return selected
|
||||
|
||||
if name == "list_regions":
|
||||
rows = await read_rows(conn, "SELECT id,region_type FROM gis.regions ORDER BY id LIMIT %s OFFSET %s", (args.limit + 1, args.offset))
|
||||
data = paginate(rows, already_paged=True)
|
||||
elif name == "get_region":
|
||||
rows = await read_rows(conn, "SELECT id,region_type,ST_AsGeoJSON(ST_Transform(boundary,4326))::json AS geometry FROM gis.regions WHERE id=%s", (args.region_id,))
|
||||
if not rows:
|
||||
raise HTTPException(404, "Region not found in the current project")
|
||||
data = {**rows[0], "crs": "EPSG:4326"}
|
||||
elif name == "get_region_nodes":
|
||||
rows = await read_rows(conn, """WITH configured AS (
|
||||
SELECT node_id FROM gis.region_nodes WHERE region_id=%s
|
||||
), members AS (
|
||||
SELECT node_id FROM configured UNION ALL
|
||||
SELECT n.node_id FROM gis.node_geometries n JOIN gis.regions r
|
||||
ON r.id=%s AND ST_Intersects(n.geom,r.boundary)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM configured)
|
||||
) SELECT node_id AS id FROM members ORDER BY node_id LIMIT %s OFFSET %s""",
|
||||
(args.region_id, args.region_id, args.limit + 1, args.offset))
|
||||
data = paginate(rows, already_paged=True)
|
||||
gaps = ["Uses configured region membership, or geometry if none is configured; membership does not prove hydraulic connectivity"]
|
||||
elif name == "get_node_connections":
|
||||
rows = await read_rows(conn, """SELECT id,link_type,start_node_id,end_node_id FROM network.links
|
||||
WHERE start_node_id=%s OR end_node_id=%s ORDER BY id LIMIT %s OFFSET %s""", (args.node_id, args.node_id, args.limit + 1, args.offset))
|
||||
data = paginate(rows, already_paged=True)
|
||||
elif name == "query_network_extent":
|
||||
statement = sql.SQL("""SELECT v.id,to_jsonb(v)-'geom' AS properties,
|
||||
ST_AsGeoJSON(ST_Transform(v.geom,4326))::json AS geometry FROM gis.{} v
|
||||
WHERE ST_Intersects(v.geom,ST_Transform(ST_MakeEnvelope(%s,%s,%s,%s,4326),3857))
|
||||
ORDER BY v.id LIMIT %s OFFSET %s""").format(sql.Identifier(ELEMENT_VIEWS[args.element_type]))
|
||||
rows = await read_rows(conn, statement, (*args.bounds, args.limit + 1, args.offset))
|
||||
data = {"items": paginate(rows, already_paged=True), "crs": "EPSG:4326", "source": "current_project_model"}
|
||||
gaps = ["Only current-project model elements with valid stored geometry are covered; missing GIS geometry is not evidence of an empty network"]
|
||||
elif name == "list_major_nodes":
|
||||
rows = await read_rows(conn, """SELECT n.id,ST_X(ST_Transform(n.geom,4326)) AS longitude,
|
||||
ST_Y(ST_Transform(n.geom,4326)) AS latitude FROM gis.junctions n
|
||||
WHERE EXISTS (SELECT 1 FROM gis.pipes p WHERE p.diameter >= %s
|
||||
AND (p.node1=n.id OR p.node2=n.id)) ORDER BY n.id LIMIT %s OFFSET %s""", (args.min_diameter_mm, args.limit + 1, args.offset))
|
||||
data = {"items": paginate(rows, already_paged=True), "crs": "EPSG:4326"}
|
||||
gaps = ["Only junctions with stored geometry and qualifying model pipes are covered; no other project's GIS is queried"]
|
||||
elif name == "get_model_configuration":
|
||||
data = await asyncio.to_thread(configuration, network, args.kind, args.object_id)
|
||||
for key in args.data_path:
|
||||
if not isinstance(data, dict) or key not in data:
|
||||
raise HTTPException(404, "Configuration path not found")
|
||||
data = data[key]
|
||||
if isinstance(data, dict):
|
||||
data = {"items": paginate([{"key": key, "value": value} for key, value in sorted(data.items())]), "data_path": args.data_path}
|
||||
elif isinstance(data, list):
|
||||
data = {"items": paginate(data), "data_path": args.data_path}
|
||||
else:
|
||||
data = {"value": data, "data_path": args.data_path}
|
||||
elif name == "list_model_configurations":
|
||||
from app.services import tjnetwork
|
||||
rows = await asyncio.to_thread(getattr(tjnetwork, "get_" + args.kind + "s"), network)
|
||||
data = paginate(sorted(rows or []))
|
||||
elif name in {"search_project_places", "reverse_geocode", "get_project_info"}:
|
||||
project = await scope.metadata.get_project_by_id(scope.project.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
if name == "get_project_info":
|
||||
data = {key: getattr(project, key, None) for key in ("name", "code", "description", "map_extent", "status")}
|
||||
data["project_id"] = str(scope.project.project_id)
|
||||
else:
|
||||
from app.domain.schemas.project_map_config import ProjectMapConfig
|
||||
from app.domain.schemas.place_search import PlaceSearchRequest, ReverseGeocodingRequest
|
||||
from app.services.place_search import search_tianditu_places, PlaceSearchError
|
||||
from app.services.reverse_geocoding import reverse_geocode_tianditu
|
||||
|
||||
config = ProjectMapConfig.model_validate(project.map_config or {}).place_search
|
||||
if config is None or not config.enabled:
|
||||
raise HTTPException(503, "Current project place search is not configured")
|
||||
await scope.metadata.session.commit()
|
||||
try:
|
||||
data = (await search_tianditu_places(PlaceSearchRequest(keyword=args.keyword), config=config)
|
||||
if name == "search_project_places" else await reverse_geocode_tianditu(
|
||||
ReverseGeocodingRequest(longitude=args.longitude, latitude=args.latitude), config=config))
|
||||
truncated = bool(getattr(data, "truncated", False))
|
||||
except PlaceSearchError as exc:
|
||||
raise HTTPException(exc.status_code, exc.detail) from exc
|
||||
elif name == "export_project_model":
|
||||
data = await asyncio.to_thread(project_export, network, args)
|
||||
elif name == "get_sensor_placement_candidate":
|
||||
from app.services.sensor_placement import get_sensor_placement_candidate
|
||||
data = await asyncio.to_thread(get_sensor_placement_candidate, network, args.node_id)
|
||||
elif name == "export_sensor_placement":
|
||||
data = await asyncio.to_thread(sensor_export, network, args, scope.user, scope.project)
|
||||
elif name == "get_mutation_status":
|
||||
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||
repo = McpQueryRepository(conn, scope.project.project_id)
|
||||
previous = await repo.receipt("mutation:" + binding.subject + ":" + binding.native_session_id, str(args.request_id))
|
||||
if previous is None:
|
||||
data = {"status": "not_started", "request_id": str(args.request_id)}
|
||||
else:
|
||||
output = previous["result"].get("output", {})
|
||||
data = {"request_id": str(args.request_id), "status": previous["result"]["status"],
|
||||
"tool": previous["parameters"]["tool"], "source_ref": output.get("source_ref"),
|
||||
"queried_at": output.get("queried_at")}
|
||||
if previous["result"]["status"] == "rejected":
|
||||
from app.mcp.mutation_service import retryable_capacity_rejection
|
||||
data["retryable"] = retryable_capacity_rejection(previous["result"])
|
||||
data["error"] = previous["result"].get("error")
|
||||
gaps = ["started does not prove success or rollback; verify target state before any new operation"]
|
||||
elif name in {"list_task_schedules", "get_task_run_history"}:
|
||||
from app.mcp.query_service import task_service
|
||||
service = task_service(scope)
|
||||
if name == "list_task_schedules":
|
||||
data = paginate(await service.list_schedules())
|
||||
else:
|
||||
rows = await service.task_runs(args.target_task_id, limit=args.limit + 1, offset=args.offset)
|
||||
data = paginate(rows, already_paged=True)
|
||||
else:
|
||||
raise ValueError("Unknown extended query")
|
||||
return QueryResult(source=name, source_ref="mcp:" + uuid4().hex,
|
||||
queried_at=datetime.now(timezone.utc), semantics=tool.semantics,
|
||||
data=jsonable_encoder(data), page=page, truncated=truncated, data_gaps=gaps)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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})},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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':
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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)
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,16 +41,21 @@ 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:
|
||||
AnalysisRepository.create_run_sync(
|
||||
conn,
|
||||
run_id=run_id,
|
||||
name=scheme_name,
|
||||
run_type=scheme_type,
|
||||
created_by=username,
|
||||
started_at=started_at,
|
||||
status=status,
|
||||
parameters=scheme_detail,
|
||||
)
|
||||
with conn.transaction():
|
||||
AnalysisRepository.create_run_sync(
|
||||
conn,
|
||||
run_id=run_id,
|
||||
name=scheme_name,
|
||||
run_type=scheme_type,
|
||||
created_by=username,
|
||||
started_at=started_at,
|
||||
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(
|
||||
conn,
|
||||
run_id,
|
||||
status=status,
|
||||
created_by=username,
|
||||
parameters=scheme_detail,
|
||||
)
|
||||
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
Reference in New Issue
Block a user