Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26fa5d1909 | ||
|
|
f25423dec4 | ||
|
|
e48ccfba9e | ||
|
|
f2cfce8192 | ||
|
|
05183aff93 | ||
|
|
63af998045 | ||
|
|
2865811f1b | ||
|
|
af1dc11681 | ||
|
|
8673429ec4 | ||
|
|
6d31f456e6 | ||
|
|
cddfc8647d |
+35
-8
@@ -1,6 +1,7 @@
|
|||||||
# TJWater Server 环境变量模板
|
# NextTJWater Server 环境变量模板
|
||||||
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
|
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
|
||||||
# 生产配置由部署主机的受控 backend.env 注入,不要把完整配置保存为仓库密钥。
|
# 生产配置由部署主机的受控 next-backend.env 注入,不随镜像或 CI/CD 同步本地 .env。
|
||||||
|
# 进程环境变量优先;修改 Compose env_file 后需重新创建容器。
|
||||||
|
|
||||||
# 基础运行
|
# 基础运行
|
||||||
PROJECT_NAME="TJWater Server"
|
PROJECT_NAME="TJWater Server"
|
||||||
@@ -69,9 +70,11 @@ KEYCLOAK_ALGORITHM="RS256"
|
|||||||
KEYCLOAK_AUDIENCE="account"
|
KEYCLOAK_AUDIENCE="account"
|
||||||
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
|
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
|
||||||
|
|
||||||
# 工作台事件与跨域
|
# 浏览器跨域访问(主 API)
|
||||||
# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。
|
# 必须填写明确来源(协议、域名或 IP、端口),英文逗号分隔;不允许 * 或网段正则。
|
||||||
CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173"
|
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_EVENTS_ENABLED=true
|
||||||
WORKBENCH_EVENT_RETENTION_DAYS=7
|
WORKBENCH_EVENT_RETENTION_DAYS=7
|
||||||
|
|
||||||
@@ -116,6 +119,17 @@ OPERATIONAL_ACTION_PERIPHERAL_URL=""
|
|||||||
OPERATIONAL_ACTION_COMMAND_GATEWAY_URL=""
|
OPERATIONAL_ACTION_COMMAND_GATEWAY_URL=""
|
||||||
OPERATIONAL_ACTION_TIMEOUT_SECONDS=10
|
OPERATIONAL_ACTION_TIMEOUT_SECONDS=10
|
||||||
|
|
||||||
|
# 外围样板(挂载于 /peripheral;故障注入默认关闭)
|
||||||
|
# 独立启动时用于调用主 API 验证身份;容器内按网络填写主服务地址。
|
||||||
|
PERIPHERAL_SAMPLE_NEXT_SERVER_URL="http://127.0.0.1:8000"
|
||||||
|
# 以下两个默认库选择只供种子、导出等脚本使用,HTTP 请求仍按已授权项目路由。
|
||||||
|
PERIPHERAL_SAMPLE_DB_NAME="tjwater_v2"
|
||||||
|
PERIPHERAL_SAMPLE_PROJECT_CODE="tjwater_v2"
|
||||||
|
# 子应用独立运行时的浏览器来源列表,使用 JSON 格式;挂载时还受主 API CORS 配置控制。
|
||||||
|
PERIPHERAL_SAMPLE_CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173"]'
|
||||||
|
PERIPHERAL_SAMPLE_ENABLE_FAULTS=false
|
||||||
|
PERIPHERAL_SAMPLE_TIMEOUT_SECONDS=5
|
||||||
|
|
||||||
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
|
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
|
||||||
TJWATER_MCP_ENABLED=false
|
TJWATER_MCP_ENABLED=false
|
||||||
TJWATER_MCP_ISSUER_URI=""
|
TJWATER_MCP_ISSUER_URI=""
|
||||||
@@ -129,7 +143,20 @@ TJWATER_MCP_HOST_CLIENT_ID="tjwater-agent-mcp"
|
|||||||
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
|
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
|
||||||
TJWATER_MCP_ALLOWED_ORIGINS='[]'
|
TJWATER_MCP_ALLOWED_ORIGINS='[]'
|
||||||
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
|
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
|
||||||
# 普通查询预算;计算工具由 Agent 通过 timeout_seconds 按次指定。
|
# 单次 HTTP 返回预算;由目录元数据公布给宿主,Agent 默认等待 180 秒。
|
||||||
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30
|
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=120
|
||||||
# 计算等待上限为 120–86400 秒,实际上限通过 tools/list 发布。
|
# HTTP 请求预算上限;Agent 加 60 秒余量并封顶 270 秒,外层 MCP 总时限 300 秒。
|
||||||
|
TJWATER_MCP_MAX_REQUEST_TIMEOUT_SECONDS=240
|
||||||
|
# 后台计算独立执行时限为 120–86400 秒,不受工具 timeout_seconds 控制。
|
||||||
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
|
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
|
||||||
|
|
||||||
|
# 独立计算队列消费者;显式项目 UUID,以逗号分隔。不会在 API lifespan 内启动。
|
||||||
|
# 先在每个目标项目 BizDB 依次应用 026_calculation_jobs.sql 和 027_calculation_job_lifecycle.sql,再启动计算服务。
|
||||||
|
CALCULATION_WORKER_PROJECT_IDS=""
|
||||||
|
# 所有配置项目合计并发,独立于 Uvicorn --workers 4;单实例锁防止重复消费者扩容。
|
||||||
|
CALCULATION_WORKER_MAX_CONCURRENCY=2
|
||||||
|
CALCULATION_WORKER_POLL_SECONDS=2
|
||||||
|
CALCULATION_WORKER_LEASE_SECONDS=30
|
||||||
|
CALCULATION_WORKER_HEARTBEAT_SECONDS=5
|
||||||
|
# 持久化本机恢复账本;容器模板已挂载独立 volume,不能当缓存删除。
|
||||||
|
CALCULATION_WORKER_STATE_DIR="temp/calculation-jobs"
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ COPY app ./app
|
|||||||
COPY contracts ./contracts
|
COPY contracts ./contracts
|
||||||
COPY infra ./infra
|
COPY infra ./infra
|
||||||
COPY resources ./resources
|
COPY resources ./resources
|
||||||
COPY scripts/run_operational_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
|
COPY scripts/run_operational_worker.py scripts/run_calculation_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
|
||||||
COPY samples/peripheral ./samples/peripheral
|
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}'" && \
|
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
|
rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip
|
||||||
|
|||||||
@@ -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 环境:
|
推荐使用已有 conda 环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -45,14 +53,50 @@ conda activate server
|
|||||||
|
|
||||||
`GET /health` 是无需鉴权的 HTTP 服务存活检查,返回 `200` 和 `{"status":"ok"}`,不查询数据库。
|
`GET /health` 是无需鉴权的 HTTP 服务存活检查,返回 `200` 和 `{"status":"ok"}`,不查询数据库。
|
||||||
`GET /ready` 检查应用启动状态、元数据库 `SELECT 1` 及已启用的 MCP 传输生命周期;
|
`GET /ready` 检查应用启动状态、元数据库 `SELECT 1` 及已启用的 MCP 传输生命周期;
|
||||||
全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为2秒。MCP 关闭时标记为
|
全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为 2 秒。MCP 关闭时标记为
|
||||||
`disabled`,不阻止就绪。返回结果不包含数据库地址、凭据或原始异常,禁止缓存。
|
`disabled`,不阻止就绪。返回结果不包含数据库地址、凭据或原始异常,禁止缓存。
|
||||||
就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过
|
就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过
|
||||||
带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。
|
带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
`.env.example` 是唯一应提交的环境配置样板,主要分为以下几组:
|
||||||
|
|
||||||
|
- 元数据库、项目业务库、TimescaleDB 及项目数据库连接池。
|
||||||
|
- GeoServer 项目供应、Keycloak JWT 校验、CORS 和工作台事件。
|
||||||
|
- 天地图地理编码和地名搜索。
|
||||||
|
- 调查任务 worker、外围工单同步、数据与动作适配器。
|
||||||
|
- 可选的 MCP resource、OAuth issuer、宿主和超时预算。
|
||||||
|
|
||||||
|
基础数据库变量用于启动和项目供应。运行时的项目请求仍以元数据库中的 `biz_data` 和 `iot_data` 路由为准,不会因为存在默认数据库变量而回退到固定项目。
|
||||||
|
|
||||||
|
CORS 通过后端 `.env` 或部署进程环境变量中的 `CORS_ALLOWED_ORIGINS` 配置,进程环境变量优先。
|
||||||
|
必须使用英文逗号分隔的明确页面来源(协议、域名或 IP、端口,不带路径),不能填写 `*`。
|
||||||
|
未设置时默认只允许本机 `5173`、`15173` 开发来源。正式部署按实际浏览器地址填写,例如:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CORS_ALLOWED_ORIGINS=https://next-demo.waternetwork.cn,http://192.168.1.133,https://192.168.1.133,http://192.168.1.110,https://192.168.1.110
|
||||||
|
```
|
||||||
|
|
||||||
|
CORS 匹配浏览器页面来源,不是 Nginx 连接 IP 或域名解析结果;非默认端口须逐项列出。
|
||||||
|
保留跨域凭据支持及 `Authorization`、`X-Project-Id` 请求头,接口认证和权限校验继续生效。
|
||||||
|
修改后需重启进程;使用 Compose `env_file` 注入时需重新创建容器才能读取新值。
|
||||||
|
|
||||||
|
## 业务 Skill 与闭环
|
||||||
|
|
||||||
|
[爆管处置工程图](docs/burst-response-workflow.md)覆盖上报、候选定位、隔离阀门、影响分析、地图与数据展示、审批处置和反馈闭环。Server 通过任务 MCP 按 workflow 分发以下业务 Skill,当前版本均为 `2.0.0`:
|
||||||
|
|
||||||
|
| workflow | Skill | 调查重点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| burst_report | [爆管处置](resources/work-order-skills/burst-report/SKILL.md) | 候选定位、隔离阀门、影响分析、抢修及恢复闭环 |
|
||||||
|
| water_quality_complaint | [水质投诉](resources/work-order-skills/water-quality-complaint/SKILL.md) | 投诉关联、监测与采样、候选影响、处置及复测回访 |
|
||||||
|
| general | [预约跟进](resources/work-order-skills/appointment-follow-up/SKILL.md) | 审批与回执、现场履约、反馈复核及与原事件的闭环区别 |
|
||||||
|
|
||||||
|
JSONL 整理遵守会话文件权限,后台图表和表格通过既有报告 `ui_blocks` 展示;Skill 不增加审批、执行或原工单关单权限。预约投影不自动启动 Agent 调查,普通协作与后台执行分别遵守其原有工具范围。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
|
外围接入样板作为 `/peripheral` 子应用随主 API 进程启动,复用 PostgreSQL 且不自动建表;授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
|
||||||
外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD;工单经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。
|
外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD;工单经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -84,8 +128,8 @@ docker compose -f infra/docker/docker-compose.yml config
|
|||||||
- 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。
|
- 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。
|
||||||
|
|
||||||
在线重算会原子替换一个时刻的节点、管段基线。历史分块已经压缩时,写入事务使用
|
在线重算会原子替换一个时刻的节点、管段基线。历史分块已经压缩时,写入事务使用
|
||||||
`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认100万条;配置必须
|
`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认 100 万条;配置必须
|
||||||
为正整数,不允许0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。
|
为正整数,不允许 0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。
|
||||||
预算按需根据受影响压缩批次的规模调整,超过预算仍会失败并回滚当前快照;此前完成的其他
|
预算按需根据受影响压缩批次的规模调整,超过预算仍会失败并回滚当前快照;此前完成的其他
|
||||||
时刻不会一起回滚。同步、异步及单表批量快照入口使用同一预算。
|
时刻不会一起回滚。同步、异步及单表批量快照入口使用同一预算。
|
||||||
|
|
||||||
@@ -117,6 +161,19 @@ conda run -n server python scripts/publish_fengxian_map_layers.py --database tjw
|
|||||||
|
|
||||||
发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。
|
发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。
|
||||||
|
|
||||||
|
## 工况任务工作进程
|
||||||
|
|
||||||
|
调查任务 worker 和外围工单同步器是独立进程,不随 API 进程自动启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/run_operational_worker.py
|
||||||
|
conda run -n server python scripts/sync_peripheral_work_orders.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`OPERATIONAL_WORKER_PROJECT_IDS` 是逗号分隔的显式项目 UUID 列表,非空时优先于单项目兼容变量 `OPERATIONAL_WORKER_PROJECT_ID`。如需覆盖所有已启用项目,应将这些项目的 UUID 全部明确写入列表。进程不会自动枚举项目,配置列表也不会自动授权;服务身份必须是每个目标项目的有效成员。
|
||||||
|
|
||||||
|
每个项目保持独立的事件游标、会话、消息游标和 OAuth 缓存。`OPERATIONAL_WORKER_MAX_CONCURRENCY` 是每个项目的上限,而不是全局上限。更改项目列表后,需要同时重启 worker 和外围同步器。完整配置、授权和重试边界见 [工况工作进程说明](docs/operational-task-workers.md)。
|
||||||
|
|
||||||
## 测试与发布
|
## 测试与发布
|
||||||
|
|
||||||
提交前根据改动范围运行最小有效测试:
|
提交前根据改动范围运行最小有效测试:
|
||||||
@@ -131,7 +188,15 @@ conda run -n server python -m pytest tests/unit tests/auth -q
|
|||||||
docker build -t tjwater-server:local .
|
docker build -t tjwater-server:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建、推送镜像并通知部署 webhook。
|
Dockerfile 的 `test` 阶段会执行 Python 编译检查、应用导入、OpenAPI 检查和完整 pytest。
|
||||||
|
|
||||||
|
Gitea Actions 工作流位于 `.gitea/workflows/package.yml`,可手动触发,也会在推送 `v*` tag 时运行。工作流构建 `test` 阶段,推送不可变的 `sha-*` 镜像,然后通过受限 SSH 账号对 `next-backend` 执行候选验证。只有 tag 工作流会在验证通过后提升正式版本。仓库需要以下 Actions secrets:
|
||||||
|
|
||||||
|
- `REGISTRY_USERNAME`
|
||||||
|
- `REGISTRY_PASSWORD`
|
||||||
|
- `DEV_DEPLOY_SSH_KEY`
|
||||||
|
|
||||||
|
这些密钥只用于镜像仓库和受限开发机部署,不替代后端应用的运行环境配置。
|
||||||
|
|
||||||
## 安全规则
|
## 安全规则
|
||||||
|
|
||||||
@@ -139,4 +204,19 @@ Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发
|
|||||||
|
|
||||||
## 产品 MCP 查询
|
## 产品 MCP 查询
|
||||||
|
|
||||||
Server 可选启用 MCP 2026-07-28 的25项项目查询及10项受控分析,默认关闭。它使用专用 OAuth resource Bearer、宿主绑定上下文及现有项目/任务权限,调查查询返回前持久化证据。启用需更新依赖、应用项目 SQL 迁移和配置 Keycloak token exchange;这些生产操作尚未执行。详见 [实施与启用](docs/mcp-query-implementation.md) 和 [协议/旧 CLI 能力对照](docs/mcp-query-design.md)。
|
Server 可选启用 MCP 2026-07-28,默认关闭。当前目录包含 67 项类型化工具,其中 42 项查询、12 项计算、13 项预览式写入。MCP 使用专用 OAuth resource Bearer、宿主绑定上下文和现有项目或任务权限,不提供自由命令执行。
|
||||||
|
|
||||||
|
写入工具默认只执行 `dry_run`。真实写入必须使用同一 `request_id`,带上预览返回的 `expected_digest`,并通过宿主原生许可和项目权限检查。超时或结果不明时先查询操作状态,不盲目重试。实施、启用和授权边界见 [MCP 实施与启用](docs/mcp-query-implementation.md) 和 [协议与旧 CLI 能力对照](docs/mcp-query-design.md)。
|
||||||
|
|
||||||
|
## 后台计算队列
|
||||||
|
|
||||||
|
长计算通过项目数据库队列和独立计算 Worker 执行,API 保持4个进程,HTTP超时不延长。接入需要新增数据库迁移和独立服务,详见[计算队列部署与MCP契约](docs/calculation-queue.md)。
|
||||||
|
|
||||||
|
|
||||||
|
后台调查通过 `POST /api/v1/operational-tasks/{task_id}/questions` 保存结构化问题,并沿现有任务通知展示。当前 run 可在没有最终报告时正常结束,任务进入等待人工;具备 `operational_task.write` 权限的用户通过 `questions/{question_id}/answer` 回答,原任务排队新一轮。回答保存为交互记录,不自动转为现场证据或审批。问题支持最多三个单选、多选或自定义回答,版本校验和请求幂等保护重复提交。任务详情返回最近 100 组提问,完整历史由 MCP 的 `get_task_context(section="questions")` 分页读取。
|
||||||
|
|
||||||
|
部署任务提问功能前,在各项目业务库执行 `resources/sql/028_task_questions.sql`,再同步 Server、Agent 与前端版本。迁移不修改既有调查记录;更新代码不会自动迁移数据库或重启服务。
|
||||||
|
|
||||||
|
外围工单来源统一为系统数据。部署对应版本前,在各项目业务库执行 `resources/sql/029_peripheral_work_orders_system_data.sql`,再同步 Server、同步器、Agent 与前端版本。迁移会修正既有外围源工单、修订记录、任务快照和外围反馈证据,不修改测试数据或模拟监测数据。
|
||||||
|
|
||||||
|
多 Agent 后台调查按实例归属运行。部署对应版本前,在每个启用调查 Worker 的项目 BizDB 执行 `resources/sql/030_operational_run_agent_ownership.sql`,再同步 Server、Agent 和 Worker,并为各 Agent 配置唯一且重启稳定的 `AGENT_INSTANCE_ID`。迁移只增加运行归属列和活跃运行索引;旧运行不猜测归属,待其结束或租约恢复后,新一代运行开始记录实例。
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import os
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def calculation_temp_directory(kind: str) -> str:
|
||||||
|
"""Keep native outputs inside the supervisor-owned job directory."""
|
||||||
|
root = os.environ.get("TJWATER_CALCULATION_JOB_DIR", "temp")
|
||||||
|
path = os.path.abspath(os.path.join(root, kind))
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def fill_time_gaps(
|
def fill_time_gaps(
|
||||||
data: pd.DataFrame,
|
data: pd.DataFrame,
|
||||||
time_col: str = "time",
|
time_col: str = "time",
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ import sys
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import wntr
|
import wntr
|
||||||
|
|
||||||
from app.algorithms._utils import _cleanup_temp_files
|
from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory
|
||||||
|
|
||||||
_PIPE2LEAKNODE = None
|
_PIPE2LEAKNODE = None
|
||||||
_SIGNATURE_WORKER_DATA = {}
|
_SIGNATURE_WORKER_DATA = {}
|
||||||
|
|
||||||
|
|
||||||
def _make_temp_prefix(tag):
|
def _make_temp_prefix(tag):
|
||||||
temp_dir = os.path.abspath(os.path.join("temp", "burst_location"))
|
temp_dir = calculation_temp_directory("burst_location")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
|
||||||
safe_tag = str(tag).replace(os.sep, "_").replace(" ", "_")
|
safe_tag = str(tag).replace(os.sep, "_").replace(" ", "_")
|
||||||
return os.path.join(temp_dir, f"{safe_tag}_{os.getpid()}")
|
return os.path.join(temp_dir, f"{safe_tag}_{os.getpid()}")
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from pymoo.optimize import minimize as pymoo_minimize
|
|||||||
from pymoo.termination.default import DefaultSingleObjectiveTermination
|
from pymoo.termination.default import DefaultSingleObjectiveTermination
|
||||||
|
|
||||||
|
|
||||||
from app.algorithms._utils import _cleanup_temp_files
|
from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory
|
||||||
|
|
||||||
_worker_data: dict[str, Any] = {}
|
_worker_data: dict[str, Any] = {}
|
||||||
DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
|
DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
|
||||||
@@ -92,7 +92,7 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float:
|
|||||||
demand_obj.base_value = original_val + per_node_leak
|
demand_obj.base_value = original_val + per_node_leak
|
||||||
modifications.append((demand_obj, original_val))
|
modifications.append((demand_obj, original_val))
|
||||||
|
|
||||||
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
|
temp_dir = calculation_temp_directory("leakage")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
||||||
|
|
||||||
@@ -569,7 +569,7 @@ class LeakageProblem(Problem):
|
|||||||
modifications.append((demand_obj, original_val))
|
modifications.append((demand_obj, original_val))
|
||||||
|
|
||||||
# 结果保存在根目录的temp/leakage文件夹中
|
# 结果保存在根目录的temp/leakage文件夹中
|
||||||
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
|
temp_dir = calculation_temp_directory("leakage")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Topology-only valve isolation search."""
|
"""Topology-only valve isolation search."""
|
||||||
|
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable, Literal
|
||||||
|
|
||||||
|
|
||||||
VALVE_LINK_TYPE = "valve"
|
VALVE_LINK_TYPE = "valve"
|
||||||
|
AccidentElementType = Literal["auto", "link", "node"]
|
||||||
|
|
||||||
|
|
||||||
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
||||||
@@ -14,12 +15,37 @@ def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
|||||||
return parts[0], parts[1], parts[2], parts[3]
|
return parts[0], parts[1], parts[2], parts[3]
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable_nodes(
|
||||||
|
adjacency: dict[str, list[tuple[str, str]]],
|
||||||
|
start_nodes: Iterable[str],
|
||||||
|
blocked_links: set[str],
|
||||||
|
) -> set[str]:
|
||||||
|
visited = set(start_nodes)
|
||||||
|
queue = deque(visited)
|
||||||
|
while queue:
|
||||||
|
for neighbor, link_id in adjacency.get(queue.popleft(), ()):
|
||||||
|
if link_id not in blocked_links and neighbor not in visited:
|
||||||
|
visited.add(neighbor)
|
||||||
|
queue.append(neighbor)
|
||||||
|
return visited
|
||||||
|
|
||||||
|
|
||||||
def valve_isolation_analysis(
|
def valve_isolation_analysis(
|
||||||
link_entries: Iterable[str],
|
link_entries: Iterable[str],
|
||||||
accident_elements: str | list[str],
|
accident_elements: str | list[str],
|
||||||
disabled_valves: list[str] | None = None,
|
disabled_valves: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
accident_element_type: AccidentElementType = "auto",
|
||||||
|
water_source_nodes: Iterable[str] | None = None,
|
||||||
|
closed_links: Iterable[str] = (),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Determine boundary valves and affected nodes from a topology snapshot."""
|
"""Find nearest valve boundaries, then check source connectivity.
|
||||||
|
|
||||||
|
Legacy untyped IDs prefer links; explicit node mode handles cross-type ID
|
||||||
|
collisions. Supply impact is a before/after connectivity difference, not
|
||||||
|
a hydraulic pressure or demand calculation. Without source metadata only
|
||||||
|
the provisional boundary search is available.
|
||||||
|
"""
|
||||||
|
|
||||||
disabled_valves_set = set(disabled_valves or [])
|
disabled_valves_set = set(disabled_valves or [])
|
||||||
target_elements = (
|
target_elements = (
|
||||||
@@ -27,8 +53,13 @@ def valve_isolation_analysis(
|
|||||||
if isinstance(accident_elements, str)
|
if isinstance(accident_elements, str)
|
||||||
else accident_elements
|
else accident_elements
|
||||||
)
|
)
|
||||||
|
if not target_elements:
|
||||||
|
raise ValueError("At least one accident element is required")
|
||||||
|
if accident_element_type not in {"auto", "link", "node"}:
|
||||||
|
raise ValueError(f"Invalid accident element type: {accident_element_type}")
|
||||||
|
closed_links_set = set(closed_links)
|
||||||
|
|
||||||
pipe_adj: dict[str, set[str]] = defaultdict(set)
|
adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||||
all_valves: dict[str, tuple[str, str]] = {}
|
all_valves: dict[str, tuple[str, str]] = {}
|
||||||
link_lookup: dict[str, tuple[str, str, str]] = {}
|
link_lookup: dict[str, tuple[str, str, str]] = {}
|
||||||
node_set: set[str] = set()
|
node_set: set[str] = set()
|
||||||
@@ -39,46 +70,31 @@ def valve_isolation_analysis(
|
|||||||
node_set.update((node1, node2))
|
node_set.update((node1, node2))
|
||||||
if link_type_name == VALVE_LINK_TYPE:
|
if link_type_name == VALVE_LINK_TYPE:
|
||||||
all_valves[link_id] = (node1, node2)
|
all_valves[link_id] = (node1, node2)
|
||||||
else:
|
if link_id not in closed_links_set:
|
||||||
pipe_adj[node1].add(node2)
|
adjacency[node1].append((node2, link_id))
|
||||||
pipe_adj[node2].add(node1)
|
adjacency[node2].append((node1, link_id))
|
||||||
|
|
||||||
|
unknown_disabled = disabled_valves_set - all_valves.keys()
|
||||||
|
if unknown_disabled:
|
||||||
|
raise ValueError(f"Disabled valve was not found in topology: {sorted(unknown_disabled)}")
|
||||||
|
|
||||||
start_nodes: set[str] = set()
|
start_nodes: set[str] = set()
|
||||||
for element in target_elements:
|
for element in target_elements:
|
||||||
if element in node_set:
|
if accident_element_type != "node" and element in link_lookup:
|
||||||
start_nodes.add(element)
|
|
||||||
elif element in link_lookup:
|
|
||||||
node1, node2, _ = link_lookup[element]
|
node1, node2, _ = link_lookup[element]
|
||||||
start_nodes.update((node1, node2))
|
start_nodes.update((node1, node2))
|
||||||
|
elif accident_element_type != "link" and element in node_set:
|
||||||
|
start_nodes.add(element)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Accident element {element} was not found in topology")
|
raise ValueError(f"Accident element {element} was not found in topology")
|
||||||
|
|
||||||
extra_adj: dict[str, list[str]] = defaultdict(list)
|
closable_valves = set(all_valves) - disabled_valves_set - closed_links_set
|
||||||
boundary_valves: dict[str, tuple[str, str]] = {}
|
affected_nodes = _reachable_nodes(adjacency, start_nodes, closable_valves)
|
||||||
for valve_id, (node1, node2) in all_valves.items():
|
|
||||||
if valve_id in disabled_valves_set:
|
|
||||||
extra_adj[node1].append(node2)
|
|
||||||
extra_adj[node2].append(node1)
|
|
||||||
else:
|
|
||||||
boundary_valves[valve_id] = (node1, node2)
|
|
||||||
|
|
||||||
affected_nodes: set[str] = set()
|
|
||||||
queue = deque(start_nodes)
|
|
||||||
while queue:
|
|
||||||
node = queue.popleft()
|
|
||||||
if node in affected_nodes:
|
|
||||||
continue
|
|
||||||
affected_nodes.add(node)
|
|
||||||
queue.extend(pipe_adj.get(node, set()) - affected_nodes)
|
|
||||||
queue.extend(
|
|
||||||
neighbor
|
|
||||||
for neighbor in extra_adj.get(node, ())
|
|
||||||
if neighbor not in affected_nodes
|
|
||||||
)
|
|
||||||
|
|
||||||
must_close_valves: list[str] = []
|
must_close_valves: list[str] = []
|
||||||
optional_valves: list[str] = []
|
optional_valves: list[str] = []
|
||||||
for valve_id, (node1, node2) in boundary_valves.items():
|
for valve_id in closable_valves:
|
||||||
|
node1, node2 = all_valves[valve_id]
|
||||||
node1_affected = node1 in affected_nodes
|
node1_affected = node1 in affected_nodes
|
||||||
node2_affected = node2 in affected_nodes
|
node2_affected = node2 in affected_nodes
|
||||||
if node1_affected and node2_affected:
|
if node1_affected and node2_affected:
|
||||||
@@ -89,14 +105,37 @@ def valve_isolation_analysis(
|
|||||||
must_close_valves.sort()
|
must_close_valves.sort()
|
||||||
optional_valves.sort()
|
optional_valves.sort()
|
||||||
isolatable = bool(must_close_valves)
|
isolatable = bool(must_close_valves)
|
||||||
|
failure_reason = None if isolatable else "no_boundary_valves"
|
||||||
|
sources = set(water_source_nodes or ())
|
||||||
|
source_check_performed = bool(sources)
|
||||||
|
supply_affected_nodes = None
|
||||||
|
if water_source_nodes is not None:
|
||||||
|
if not sources:
|
||||||
|
isolatable = False
|
||||||
|
failure_reason = "water_sources_unavailable"
|
||||||
|
else:
|
||||||
|
after_closure = _reachable_nodes(adjacency, sources, set(must_close_valves))
|
||||||
|
if affected_nodes & after_closure:
|
||||||
|
isolatable = False
|
||||||
|
failure_reason = "connected_to_water_source"
|
||||||
|
if isolatable:
|
||||||
|
before_closure = _reachable_nodes(adjacency, sources, set())
|
||||||
|
supply_affected_nodes = sorted(before_closure - after_closure)
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"accident_elements": target_elements,
|
"accident_elements": target_elements,
|
||||||
|
"accident_element_type": accident_element_type,
|
||||||
"disabled_valves": disabled_valves,
|
"disabled_valves": disabled_valves,
|
||||||
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
||||||
"affected_node_count": len(affected_nodes),
|
"affected_node_count": len(affected_nodes),
|
||||||
"must_close_valves": must_close_valves,
|
"must_close_valves": must_close_valves,
|
||||||
"optional_valves": optional_valves,
|
"optional_valves": optional_valves,
|
||||||
"isolatable": isolatable,
|
"isolatable": isolatable,
|
||||||
|
"source_check_performed": source_check_performed,
|
||||||
|
"isolation_failure_reason": failure_reason,
|
||||||
|
"supply_affected_nodes": supply_affected_nodes,
|
||||||
|
"supply_affected_node_count": (
|
||||||
|
len(supply_affected_nodes) if supply_affected_nodes is not None else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if len(target_elements) == 1:
|
if len(target_elements) == 1:
|
||||||
result["accident_element"] = target_elements[0]
|
result["accident_element"] = target_elements[0]
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def _project_response(project: models.Project) -> AdminProjectResponse:
|
|||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
map_extent=project.map_extent,
|
map_extent=project.map_extent,
|
||||||
map_config=getattr(project, "map_config", {}) or {},
|
map_config=getattr(project, "map_config", {}) or {},
|
||||||
|
settings=getattr(project, "settings", {}) or {},
|
||||||
status=project.status,
|
status=project.status,
|
||||||
created_at=project.created_at,
|
created_at=project.created_at,
|
||||||
updated_at=project.updated_at,
|
updated_at=project.updated_at,
|
||||||
@@ -276,6 +277,7 @@ async def create_admin_project(
|
|||||||
gs_workspace=payload.gs_workspace,
|
gs_workspace=payload.gs_workspace,
|
||||||
map_extent=payload.map_extent,
|
map_extent=payload.map_extent,
|
||||||
map_config=payload.map_config.model_dump(exclude_unset=True),
|
map_config=payload.map_config.model_dump(exclude_unset=True),
|
||||||
|
settings=payload.settings.model_dump(exclude_unset=True),
|
||||||
status=payload.status,
|
status=payload.status,
|
||||||
creator_user_id=current_user.id,
|
creator_user_id=current_user.id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async def get_project_metadata(
|
|||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
map_extent=project.map_extent,
|
map_extent=project.map_extent,
|
||||||
map_config=getattr(project, "map_config", {}) or {},
|
map_config=getattr(project, "map_config", {}) or {},
|
||||||
|
settings=getattr(project, "settings", {}) or {},
|
||||||
status=project.status,
|
status=project.status,
|
||||||
project_role=ctx.project_role,
|
project_role=ctx.project_role,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ async def provision_project(
|
|||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
map_extent=project.map_extent,
|
map_extent=project.map_extent,
|
||||||
map_config=getattr(project, "map_config", {}) or {},
|
map_config=getattr(project, "map_config", {}) or {},
|
||||||
|
settings=getattr(project, "settings", {}) or {},
|
||||||
status=project.status,
|
status=project.status,
|
||||||
created_at=project.created_at,
|
created_at=project.created_at,
|
||||||
updated_at=project.updated_at,
|
updated_at=project.updated_at,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ from app.auth.project_dependencies import (
|
|||||||
from app.domain.domain_errors import DomainError
|
from app.domain.domain_errors import DomainError
|
||||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||||
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
||||||
|
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
|
||||||
|
from app.services.task_questions import create_question, answer_question
|
||||||
from app.domain.schemas.operational_task import (
|
from app.domain.schemas.operational_task import (
|
||||||
ActionDecisionCommand,
|
ActionDecisionCommand,
|
||||||
ActionExecuteCommand,
|
ActionExecuteCommand,
|
||||||
@@ -74,6 +76,7 @@ from app.domain.schemas.operational_task import (
|
|||||||
TaskPresentationEventCommand,
|
TaskPresentationEventCommand,
|
||||||
TaskPresentationEventResult,
|
TaskPresentationEventResult,
|
||||||
TaskPresentationCursor,
|
TaskPresentationCursor,
|
||||||
|
TaskPresentationHistoryPage,
|
||||||
)
|
)
|
||||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
from app.infra.integrations.operational_actions import (
|
from app.infra.integrations.operational_actions import (
|
||||||
@@ -521,6 +524,21 @@ async def get_operational_task_presentation_cursor(
|
|||||||
_raise_domain(exc)
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationHistoryPage)
|
||||||
|
async def get_operational_task_presentation_history(
|
||||||
|
task_id: UUID,
|
||||||
|
after: int = Query(default=0, ge=0),
|
||||||
|
through: int | None = Query(default=None, ge=0),
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await _service(ctx, conn).presentation_history(task_id, after=after, through=through, limit=limit)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
|
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
|
||||||
async def publish_operational_task_presentation_event(
|
async def publish_operational_task_presentation_event(
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
@@ -549,10 +567,24 @@ async def query_operational_task_data(
|
|||||||
if not await service.can_read_source(task_id, query.source):
|
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)
|
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
|
source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
|
||||||
snapshot = await read_task_data(
|
from app.services.scada_replay_queries import replay_snapshot_rows
|
||||||
context=ctx, query=query, credential=authorization,
|
replay_context = await service.replay_context(task_id)
|
||||||
delegated=source_permission not in _task_permissions(ctx),
|
if replay_context:
|
||||||
)
|
if query.source != "scada_readings":
|
||||||
|
raise HTTPException(403, "回放调查数据接口仅开放本轮 SCADA 快照,请通过 MCP 查询设备目录")
|
||||||
|
rows = await replay_snapshot_rows(conn, ctx.project_id, replay_context, query.device_ids,
|
||||||
|
query.start, query.end, 1001)
|
||||||
|
from app.services.content_digest import digest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
snapshot = TaskDataSnapshot(
|
||||||
|
source="scada_readings", source_ref=f"scada_replay:{replay_context['replay_id']}:{digest(rows)}",
|
||||||
|
queried_at=datetime.now(timezone.utc), origin="synthetic", data=rows[:1000], truncated=len(rows) > 1000,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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)
|
evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot)
|
||||||
return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot)
|
return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot)
|
||||||
except TaskDataError as exc:
|
except TaskDataError as exc:
|
||||||
@@ -771,3 +803,27 @@ async def withdraw_operational_task_external_source(
|
|||||||
return await _service(ctx, conn).withdraw_external_source(task_id, command)
|
return await _service(ctx, conn).withdraw_external_source(task_id, command)
|
||||||
except DomainError as exc:
|
except DomainError as exc:
|
||||||
_raise_domain(exc)
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/operational-tasks/{task_id}/questions", response_model=TaskQuestionRequest)
|
||||||
|
async def create_operational_task_question(
|
||||||
|
task_id: UUID, command: TaskQuestionCreate,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await create_question(_service(ctx, conn), task_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/operational-tasks/{task_id}/questions/{question_id}/answer", response_model=OperationalTaskDetail)
|
||||||
|
async def answer_operational_task_question(
|
||||||
|
task_id: UUID, question_id: UUID, command: TaskQuestionReply,
|
||||||
|
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||||
|
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return await answer_question(_service(ctx, conn), task_id, question_id, command)
|
||||||
|
except DomainError as exc:
|
||||||
|
_raise_domain(exc)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async def get_project_info_endpoint(
|
|||||||
gs_workspace=project_detail.gs_workspace,
|
gs_workspace=project_detail.gs_workspace,
|
||||||
map_extent=project_detail.map_extent,
|
map_extent=project_detail.map_extent,
|
||||||
map_config=getattr(project_detail, "map_config", {}) or {},
|
map_config=getattr(project_detail, "map_config", {}) or {},
|
||||||
|
settings=getattr(project_detail, "settings", {}) or {},
|
||||||
status=project_detail.status,
|
status=project_detail.status,
|
||||||
project_role="viewer", # Default role for public access
|
project_role="viewer", # Default role for public access
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""SCADA replay controls. Manual steps are processed by the command request."""
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
|
||||||
|
from app.auth.metadata_dependencies import get_metadata_repository
|
||||||
|
from app.auth.permissions import SCADA_VIEW, SCADA_CLEAN, require_permission, permissions_for_context
|
||||||
|
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection, get_project_timescale_connection
|
||||||
|
from app.domain.schemas.scada_replay import (
|
||||||
|
ScadaReplayCapabilities, ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot,
|
||||||
|
ScadaReplayEventsPage, ScadaReplayReadings,
|
||||||
|
)
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
from app.domain.schemas.project_settings import ProjectSettings
|
||||||
|
from app.services.scada_replay import ScadaReplayService, snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/scada-replays", tags=["SCADA Replay"])
|
||||||
|
|
||||||
|
|
||||||
|
async def repository(
|
||||||
|
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
|
||||||
|
connection=Depends(get_project_pg_connection),
|
||||||
|
):
|
||||||
|
return ScadaReplayRepository(connection, ctx.project_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def project_settings(
|
||||||
|
ctx: ProjectContext = Depends(require_permission(SCADA_VIEW)),
|
||||||
|
metadata_repo=Depends(get_metadata_repository),
|
||||||
|
) -> ProjectSettings:
|
||||||
|
project = await metadata_repo.get_project_by_id(ctx.project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(404, "当前项目不存在")
|
||||||
|
return ProjectSettings.model_validate(project.settings or {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capabilities", response_model=ScadaReplayCapabilities)
|
||||||
|
async def replay_capabilities(repo=Depends(repository), ctx=Depends(require_permission(SCADA_VIEW)),
|
||||||
|
settings=Depends(project_settings)):
|
||||||
|
available = await repo.available()
|
||||||
|
current = await repo.current() if available else None
|
||||||
|
monitoring = settings.scada_monitoring
|
||||||
|
monitoring_ready = bool(
|
||||||
|
monitoring
|
||||||
|
and len(monitoring.pressure_device_ids) >= 5
|
||||||
|
and monitoring.outlet_flow_device_ids
|
||||||
|
)
|
||||||
|
if monitoring_ready:
|
||||||
|
monitoring_reason = None
|
||||||
|
elif monitoring is None:
|
||||||
|
monitoring_reason = "项目尚未配置压力与出厂边界流量监测角色"
|
||||||
|
elif len(monitoring.pressure_device_ids) < 5:
|
||||||
|
monitoring_reason = "爆管侦测至少需要 5 个压力监测设备"
|
||||||
|
else:
|
||||||
|
monitoring_reason = "项目尚未配置出厂边界流量设备"
|
||||||
|
return ScadaReplayCapabilities(
|
||||||
|
available=available, can_write=SCADA_CLEAN in permissions_for_context(ctx),
|
||||||
|
reason=None if available else "需先部署 SCADA 回放存储结构",
|
||||||
|
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
|
||||||
|
burst_monitoring_ready=monitoring_ready,
|
||||||
|
burst_monitoring_reason=monitoring_reason,
|
||||||
|
current=snapshot(current) if current else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ScadaReplaySnapshot, status_code=201)
|
||||||
|
async def create_replay(command: ScadaReplayCreate, repo=Depends(repository),
|
||||||
|
ctx=Depends(require_permission(SCADA_CLEAN)), settings=Depends(project_settings)):
|
||||||
|
return await ScadaReplayService(repo, ctx).create(
|
||||||
|
command,
|
||||||
|
monitoring_interval_seconds=settings.scada_monitoring_interval_seconds,
|
||||||
|
monitoring_config=settings.scada_monitoring,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{replay_id}", response_model=ScadaReplaySnapshot)
|
||||||
|
async def get_replay(replay_id: UUID, repo=Depends(repository)):
|
||||||
|
await ScadaReplayService(repo, None).ensure_storage()
|
||||||
|
return snapshot(await repo.get(replay_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{replay_id}/commands", response_model=ScadaReplaySnapshot)
|
||||||
|
async def replay_command(replay_id: UUID, command: ScadaReplayCommand, repo=Depends(repository),
|
||||||
|
ctx=Depends(require_permission(SCADA_CLEAN)),
|
||||||
|
timescale=Depends(get_project_timescale_connection)):
|
||||||
|
service = ScadaReplayService(repo, ctx, timescale)
|
||||||
|
result = await service.command(replay_id, command)
|
||||||
|
if command.action in {"step", "retry"} and result.step_pending:
|
||||||
|
return await service.process_pending_step(replay_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{replay_id}/events", response_model=ScadaReplayEventsPage)
|
||||||
|
async def replay_events(replay_id: UUID, offset: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200),
|
||||||
|
repo=Depends(repository)):
|
||||||
|
await ScadaReplayService(repo, None).ensure_storage()
|
||||||
|
await repo.get(replay_id)
|
||||||
|
items = await repo.events(replay_id, offset, limit + 1)
|
||||||
|
return ScadaReplayEventsPage(items=items[:limit], next_offset=offset + limit if len(items) > limit else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{replay_id}/observations", response_model=ScadaReplayReadings)
|
||||||
|
async def replay_observations(replay_id: UUID, through: datetime, repo=Depends(repository)):
|
||||||
|
await ScadaReplayService(repo, None).ensure_storage()
|
||||||
|
document = await repo.get(replay_id)
|
||||||
|
if (through.tzinfo is None or not document.get("delivered_until")
|
||||||
|
or through > datetime.fromisoformat(document["delivered_until"])
|
||||||
|
or through < datetime.fromisoformat(document["start_time"])):
|
||||||
|
raise HTTPException(422, "回看仅允许本轮已读取的时间区间")
|
||||||
|
return ScadaReplayReadings(
|
||||||
|
items=await repo.latest_outputs(replay_id, through),
|
||||||
|
through=through,
|
||||||
|
)
|
||||||
@@ -161,6 +161,9 @@ def valve_isolation_endpoint(
|
|||||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||||
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
|
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
|
||||||
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
|
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
|
||||||
|
accident_element_type: Literal["auto", "link", "node"] = Query(
|
||||||
|
"auto", description="事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
阀门隔离分析
|
阀门隔离分析
|
||||||
@@ -168,13 +171,19 @@ def valve_isolation_endpoint(
|
|||||||
- **network**: 管网名称(或数据库名称)
|
- **network**: 管网名称(或数据库名称)
|
||||||
- **accident_element**: 发生事故的管段/节点ID列表
|
- **accident_element**: 发生事故的管段/节点ID列表
|
||||||
- **disabled_valves**: 已故障的阀门ID列表(可选)
|
- **disabled_valves**: 已故障的阀门ID列表(可选)
|
||||||
|
- **accident_element_type**: auto优先管段;link仅匹配管段;node仅匹配节点
|
||||||
|
|
||||||
返回隔离方案,包括:
|
返回隔离方案,包括:
|
||||||
- must_close_valves: 必须关闭的阀门列表
|
- must_close_valves: 必须关闭的阀门列表
|
||||||
- optional_valves: 可选关闭的阀门列表
|
- optional_valves: 可选关闭的阀门列表
|
||||||
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
|
- affected_nodes / affected_node_count: 最近阀门围成的直接隔离区;不可隔离时列表为空,计数保留
|
||||||
- affected_node_count: 受影响的节点总数
|
- supply_affected_nodes / supply_affected_node_count: 按建议关阀后新增失去水源连接的节点;未验证或不可隔离时为null
|
||||||
- isolatable: 是否可以有效隔离
|
- source_check_performed: 是否已执行水源连通检查
|
||||||
|
- isolation_failure_reason: 不可隔离原因;可隔离时为null
|
||||||
|
- isolatable: 存在关阀边界,且直接隔离区不再连通任何水源
|
||||||
|
|
||||||
|
使用模型初始开关状态进行无向拓扑分析,水池按潜在水源处理。
|
||||||
|
不包含时变控制、水池蓄水量或压力流量计算,不是现场停水范围的水力证明。
|
||||||
"""
|
"""
|
||||||
# result = {
|
# result = {
|
||||||
# "accident_element": "P461309",
|
# "accident_element": "P461309",
|
||||||
@@ -193,7 +202,10 @@ def valve_isolation_endpoint(
|
|||||||
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
|
||||||
# "optional_valves": [],
|
# "optional_valves": [],
|
||||||
# }
|
# }
|
||||||
result = analyze_valve_isolation(network, accident_element, disabled_valves)
|
result = analyze_valve_isolation(
|
||||||
|
network, accident_element, disabled_valves,
|
||||||
|
accident_element_type=accident_element_type,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
||||||
from typing import List
|
from typing import List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from app.domain.schemas.scada import ScadaLatestReadingsResponse
|
from app.domain.schemas.scada import ScadaLatestReadingsResponse, ScadaRawPage
|
||||||
|
from app.auth.project_dependencies import get_project_context, ProjectContext
|
||||||
|
from app.services.scada_raw import read_raw_page, validate_window
|
||||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
from .dependencies import get_postgres_connection, get_timescale_connection
|
from .dependencies import get_postgres_connection, get_timescale_connection
|
||||||
@@ -13,6 +16,46 @@ router = APIRouter()
|
|||||||
SCADA_BATCH_MAX_ITEMS = 10_000
|
SCADA_BATCH_MAX_ITEMS = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeseries/scada-readings/raw", response_model=ScadaRawPage,
|
||||||
|
summary="按时间顺序分页读取 SCADA 原始值")
|
||||||
|
async def get_scada_raw(
|
||||||
|
start_time: datetime, end_time: datetime, device_ids: str,
|
||||||
|
limit: int = Query(500, ge=1, le=500),
|
||||||
|
cursor: str | None = Query(None, max_length=2048),
|
||||||
|
replay_id: UUID | None = None,
|
||||||
|
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||||
|
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
|
||||||
|
ctx: ProjectContext = Depends(get_project_context),
|
||||||
|
) -> ScadaRawPage:
|
||||||
|
try:
|
||||||
|
validate_window(start_time, end_time)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
ids = list(dict.fromkeys(item.strip() for item in device_ids.split(",") if item.strip()))
|
||||||
|
if not ids or len(ids) > 500:
|
||||||
|
raise HTTPException(422, "请选择 1 至 500 个 SCADA 设备")
|
||||||
|
existing = await ScadaInfoRepository.get_existing_device_ids(postgres_conn, ids)
|
||||||
|
if set(ids) != existing:
|
||||||
|
raise HTTPException(422, "查询包含当前项目未配置的 SCADA 设备")
|
||||||
|
try:
|
||||||
|
through = None
|
||||||
|
if replay_id is not None:
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
from app.services.scada_replay import ScadaReplayService
|
||||||
|
repo = ScadaReplayRepository(postgres_conn, ctx.project_id)
|
||||||
|
await ScadaReplayService(repo, ctx).ensure_storage()
|
||||||
|
replay = await repo.get(replay_id)
|
||||||
|
if not set(ids) <= set(replay["config"]["device_ids"]):
|
||||||
|
raise HTTPException(403, "设备超出回放范围")
|
||||||
|
if start_time < datetime.fromisoformat(replay["start_time"]) or end_time > datetime.fromisoformat(replay["end_time"]):
|
||||||
|
raise HTTPException(422, "查询超出选定回放区间")
|
||||||
|
through = datetime.fromisoformat(replay["virtual_now"])
|
||||||
|
return await read_raw_page(conn, project_id=str(ctx.project_id), device_ids=ids,
|
||||||
|
start=start_time, end=end_time, limit=limit, cursor=cursor, through=through)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
class ScadaReadingBatchItem(BaseModel):
|
class ScadaReadingBatchItem(BaseModel):
|
||||||
time: datetime
|
time: datetime
|
||||||
device_id: str = Field(min_length=1)
|
device_id: str = Field(min_length=1)
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ from app.auth.permissions import (
|
|||||||
require_method_permission,
|
require_method_permission,
|
||||||
require_permission,
|
require_permission,
|
||||||
)
|
)
|
||||||
|
from app.api.v1.endpoints import scada_replay
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(scada_replay.router)
|
||||||
|
|
||||||
webgis_access = Depends(
|
webgis_access = Depends(
|
||||||
require_method_permission(
|
require_method_permission(
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ from datetime import datetime
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
|
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
|
||||||
|
from app.domain.schemas.project_settings import ProjectSettings
|
||||||
|
|
||||||
|
|
||||||
BusinessRole = Literal["admin", "service", "user"]
|
BusinessRole = Literal["admin", "service", "user"]
|
||||||
@@ -72,6 +73,7 @@ class ProjectMemberResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AdminProjectCreateRequest(BaseModel):
|
class AdminProjectCreateRequest(BaseModel):
|
||||||
|
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
code: str = Field(..., min_length=1, max_length=50)
|
code: str = Field(..., min_length=1, max_length=50)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -82,6 +84,10 @@ class AdminProjectCreateRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
||||||
|
settings: ProjectSettings | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="按顶层键合并项目配置;省略的键保持原值,嵌套对象整体替换",
|
||||||
|
)
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
code: str | None = Field(default=None, min_length=1, max_length=50)
|
code: str | None = Field(default=None, min_length=1, max_length=50)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -89,8 +95,16 @@ class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
|
|||||||
map_extent: dict | None = None
|
map_extent: dict | None = None
|
||||||
status: ProjectStatus | None = None
|
status: ProjectStatus | None = None
|
||||||
|
|
||||||
|
@field_validator("settings")
|
||||||
|
@classmethod
|
||||||
|
def reject_null_settings(cls, value: ProjectSettings | None) -> ProjectSettings:
|
||||||
|
if value is None:
|
||||||
|
raise ValueError("settings must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class AdminProjectResponse(BaseModel):
|
class AdminProjectResponse(BaseModel):
|
||||||
|
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
name: str
|
name: str
|
||||||
code: str
|
code: str
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from jsonschema import Draft7Validator
|
from jsonschema import Draft7Validator
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator
|
||||||
|
|
||||||
_catalog = json.loads(
|
_catalog = json.loads(
|
||||||
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
|
||||||
@@ -34,9 +34,24 @@ class AnswerUiSpec(UiModel):
|
|||||||
class AnswerUiBlock(UiModel):
|
class AnswerUiBlock(UiModel):
|
||||||
version: Literal["answer-ui@1"]
|
version: Literal["answer-ui@1"]
|
||||||
block_id: Key
|
block_id: Key
|
||||||
|
phase: Literal["process", "final"] | None = None
|
||||||
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
|
||||||
spec: AnswerUiSpec
|
spec: AnswerUiSpec
|
||||||
|
|
||||||
|
@field_validator("phase", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_explicit_phase(cls, value):
|
||||||
|
if value is None:
|
||||||
|
raise ValueError("UI phase must be process or final when supplied")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_serializer(mode="wrap")
|
||||||
|
def serialize_optional_phase(self, handler):
|
||||||
|
data = handler(self)
|
||||||
|
if self.phase is None:
|
||||||
|
data.pop("phase", None)
|
||||||
|
return data
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_components(self):
|
def validate_components(self):
|
||||||
spec = self.spec
|
spec = self.spec
|
||||||
@@ -83,6 +98,12 @@ class ReportUiBlock(UiModel):
|
|||||||
block: AnswerUiBlock
|
block: AnswerUiBlock
|
||||||
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_report_phase(self):
|
||||||
|
if self.block.phase == "process":
|
||||||
|
raise ValueError("Report UI blocks must contain final results")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
|
||||||
ids = [item.block.block_id for item in blocks]
|
ids = [item.block.block_id for item in blocks]
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.domain.schemas.project_map_config import ProjectMapConfig
|
from app.domain.schemas.project_map_config import ProjectMapConfig
|
||||||
|
from app.domain.schemas.project_settings import ProjectSettings
|
||||||
|
|
||||||
|
|
||||||
class ProjectMetaResponse(BaseModel):
|
class ProjectMetaResponse(BaseModel):
|
||||||
|
settings: ProjectSettings = Field(default_factory=ProjectSettings)
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
name: str
|
name: str
|
||||||
code: str
|
code: str
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ McpSnapshotSource = Literal[
|
|||||||
'run_burst_detection',
|
'run_burst_detection',
|
||||||
'run_burst_location',
|
'run_burst_location',
|
||||||
'analyze_valve_isolation',
|
'analyze_valve_isolation',
|
||||||
|
'generate_node_coverage',
|
||||||
'run_leakage_identification',
|
'run_leakage_identification',
|
||||||
'get_element_properties',
|
'get_element_properties',
|
||||||
'list_elements',
|
'list_elements',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from uuid import UUID
|
|||||||
from pydantic import Field, JsonValue, model_validator
|
from pydantic import Field, JsonValue, model_validator
|
||||||
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
|
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
|
||||||
|
|
||||||
|
from app.domain.schemas.task_question import TaskQuestionRequest
|
||||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
||||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||||
from app.domain.schemas.workbench_runtime import (
|
from app.domain.schemas.workbench_runtime import (
|
||||||
@@ -40,12 +41,19 @@ ExecutionStatus = Literal[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayTaskContext(StrictModel):
|
||||||
|
replay_id: UUID
|
||||||
|
knowledge_cutoff: WorkbenchDateTime
|
||||||
|
batch_sequence: PositiveInt
|
||||||
|
|
||||||
|
|
||||||
class TaskSource(StrictModel):
|
class TaskSource(StrictModel):
|
||||||
kind: TaskSourceKind
|
kind: TaskSourceKind
|
||||||
external_id: NonEmptyStr | None = None
|
external_id: NonEmptyStr | None = None
|
||||||
external_revision: PositiveInt | None = None
|
external_revision: PositiveInt | None = None
|
||||||
event_kind: NonEmptyStr | None = None
|
event_kind: NonEmptyStr | None = None
|
||||||
deleted_at: WorkbenchDateTime | None = None
|
deleted_at: WorkbenchDateTime | None = None
|
||||||
|
replay_context: ScadaReplayTaskContext | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskTrigger(StrictModel):
|
class TaskTrigger(StrictModel):
|
||||||
@@ -218,8 +226,12 @@ class InvestigationDiagramNodeRef(StrictModel):
|
|||||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
action_refs: list[UUID] = Field(default_factory=list)
|
action_refs: list[UUID] = Field(default_factory=list)
|
||||||
status: Literal[
|
status: Literal[
|
||||||
"pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"
|
"pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"
|
||||||
] | None = None
|
] | None = Field(default=None, description=(
|
||||||
|
"Evidence-confirmed node state; waiting means prerequisites/external/system result, "
|
||||||
|
"waiting_human requires manual authorization/approval/feedback. "
|
||||||
|
"Unknown stays omitted/null; host controls colors."
|
||||||
|
))
|
||||||
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
|
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
@@ -240,6 +252,21 @@ class InvestigationDiagram(StrictModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def _undeclared_diagram_node_refs(diagram: InvestigationDiagram | None) -> list[str]:
|
||||||
|
if diagram is None:
|
||||||
|
return []
|
||||||
|
missing: list[str] = []
|
||||||
|
for node in diagram.node_refs:
|
||||||
|
escaped = re.escape(node.node_id)
|
||||||
|
if not re.search(
|
||||||
|
rf"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|:::|--|==|-\.|$))",
|
||||||
|
diagram.mermaid,
|
||||||
|
re.MULTILINE,
|
||||||
|
):
|
||||||
|
missing.append(node.node_id)
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
class InvestigationReport(StrictModel):
|
class InvestigationReport(StrictModel):
|
||||||
"""Read model: absent historical facts remain unknown, never inferred."""
|
"""Read model: absent historical facts remain unknown, never inferred."""
|
||||||
|
|
||||||
@@ -341,9 +368,19 @@ class AgentArtifactsCommand(StrictModel):
|
|||||||
progress: InvestigationProgress | None = None
|
progress: InvestigationProgress | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def require_artifact(self):
|
def validate_artifacts(self):
|
||||||
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
|
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")
|
raise ValueError("at least one artifact must be supplied")
|
||||||
|
for diagram in (
|
||||||
|
self.report.diagram if self.report is not None else None,
|
||||||
|
self.progress.diagram if self.progress is not None else None,
|
||||||
|
):
|
||||||
|
missing = _undeclared_diagram_node_refs(diagram)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
"diagram node references must use ids declared in Mermaid source: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -502,7 +539,7 @@ class ExternalWorkOrderSnapshot(StrictModel):
|
|||||||
scenario_code: NonEmptyStr | None = None
|
scenario_code: NonEmptyStr | None = None
|
||||||
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
|
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
|
||||||
activity: list[WorkOrderActivity] = Field(default_factory=list)
|
activity: list[WorkOrderActivity] = Field(default_factory=list)
|
||||||
origin: Literal["synthetic"]
|
origin: Literal["system_data"]
|
||||||
updated_at: WorkbenchDateTime
|
updated_at: WorkbenchDateTime
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@@ -521,6 +558,7 @@ class OperationalTaskRun(StrictModel):
|
|||||||
status: OperationalTaskRunStatus
|
status: OperationalTaskRunStatus
|
||||||
revision: PositiveInt
|
revision: PositiveInt
|
||||||
agent_session_id: NonEmptyStr | None = None
|
agent_session_id: NonEmptyStr | None = None
|
||||||
|
agent_instance_id: NonEmptyStr | None = None
|
||||||
lease_expires_at: WorkbenchDateTime | None = None
|
lease_expires_at: WorkbenchDateTime | None = None
|
||||||
heartbeat_at: WorkbenchDateTime | None = None
|
heartbeat_at: WorkbenchDateTime | None = None
|
||||||
started_at: WorkbenchDateTime | None = None
|
started_at: WorkbenchDateTime | None = None
|
||||||
@@ -570,6 +608,7 @@ class OperationalTaskPermissions(StrictModel):
|
|||||||
can_decide_commands: bool
|
can_decide_commands: bool
|
||||||
can_execute_commands: bool
|
can_execute_commands: bool
|
||||||
can_decide_access: bool = False
|
can_decide_access: bool = False
|
||||||
|
can_answer_questions: bool = False
|
||||||
|
|
||||||
|
|
||||||
class OperationalTaskDetail(OperationalTaskSummary):
|
class OperationalTaskDetail(OperationalTaskSummary):
|
||||||
@@ -579,6 +618,7 @@ class OperationalTaskDetail(OperationalTaskSummary):
|
|||||||
evidence: list[EvidenceRecord]
|
evidence: list[EvidenceRecord]
|
||||||
actions: list[HumanApprovalAction]
|
actions: list[HumanApprovalAction]
|
||||||
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
|
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
|
||||||
|
questions: list[TaskQuestionRequest] = Field(default_factory=list)
|
||||||
permissions: OperationalTaskPermissions
|
permissions: OperationalTaskPermissions
|
||||||
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
||||||
|
|
||||||
@@ -605,6 +645,7 @@ class AgentRunCommand(StrictModel):
|
|||||||
request_id: UUID
|
request_id: UUID
|
||||||
expected_revision: PositiveInt
|
expected_revision: PositiveInt
|
||||||
source_session_id: NonEmptyStr
|
source_session_id: NonEmptyStr
|
||||||
|
agent_instance_id: NonEmptyStr | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentRunFailureCommand(AgentRunCommand):
|
class AgentRunFailureCommand(AgentRunCommand):
|
||||||
@@ -671,7 +712,59 @@ class TaskHighlightFeatures(StrictModel):
|
|||||||
action: Literal["highlight_features"]
|
action: Literal["highlight_features"]
|
||||||
layer: TaskMapLayer
|
layer: TaskMapLayer
|
||||||
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||||
effect: Literal["steady", "pulse"] = "steady"
|
effect: Literal["steady", "pulse"]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskLocateFeatures(StrictModel):
|
||||||
|
action: Literal["locate_features"]
|
||||||
|
layer: TaskMapLayer
|
||||||
|
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskZoomToMap(StrictModel):
|
||||||
|
action: Literal["zoom_to_map"]
|
||||||
|
center: tuple[
|
||||||
|
Annotated[float, Field(strict=True, ge=-180, le=180, allow_inf_nan=False)],
|
||||||
|
Annotated[float, Field(strict=True, ge=-90, le=90, allow_inf_nan=False)],
|
||||||
|
]
|
||||||
|
zoom: Annotated[float, Field(strict=True, ge=0, le=24, allow_inf_nan=False)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskScadaAnalysisItem(StrictModel):
|
||||||
|
device_id: TaskFeatureId
|
||||||
|
level: Literal["high", "medium", "low", "unrated"]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRenderScadaAnalysis(StrictModel):
|
||||||
|
action: Literal["render_scada_analysis"]
|
||||||
|
items: Annotated[list[TaskScadaAnalysisItem], Field(min_length=1, max_length=100)]
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_unique_devices(self):
|
||||||
|
if len({item.device_id for item in self.items}) != len(self.items):
|
||||||
|
raise ValueError("SCADA analysis device IDs must be unique")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskClearScadaAnalysis(StrictModel):
|
||||||
|
action: Literal["clear_scada_analysis"]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOverlayGeometry(StrictModel):
|
||||||
|
type: Literal["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]
|
||||||
|
coordinates: list[JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOverlayFeature(StrictModel):
|
||||||
|
type: Literal["Feature"]
|
||||||
|
geometry: TaskOverlayGeometry
|
||||||
|
properties: dict[str, JsonValue] | None
|
||||||
|
id: Annotated[str, Field(strict=True)] | Annotated[float, Field(strict=True, allow_inf_nan=False)] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOverlayData(StrictModel):
|
||||||
|
type: Literal["FeatureCollection"]
|
||||||
|
features: Annotated[list[TaskOverlayFeature], Field(min_length=1, max_length=1000)]
|
||||||
|
|
||||||
|
|
||||||
class TaskClearHighlights(StrictModel):
|
class TaskClearHighlights(StrictModel):
|
||||||
@@ -696,6 +789,7 @@ class TaskSetOverlay(StrictModel):
|
|||||||
title: Annotated[str, Field(min_length=1, max_length=200)]
|
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||||
mode: Literal["geometry", "heatmap"]
|
mode: Literal["geometry", "heatmap"]
|
||||||
evidence_id: NonEmptyStr
|
evidence_id: NonEmptyStr
|
||||||
|
data: TaskOverlayData | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskClearOverlay(StrictModel):
|
class TaskClearOverlay(StrictModel):
|
||||||
@@ -704,7 +798,8 @@ class TaskClearOverlay(StrictModel):
|
|||||||
|
|
||||||
|
|
||||||
TaskPresentationCommand = Annotated[
|
TaskPresentationCommand = Annotated[
|
||||||
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
TaskHighlightFeatures | TaskLocateFeatures | TaskZoomToMap | TaskRenderScadaAnalysis
|
||||||
|
| TaskClearScadaAnalysis | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
||||||
Field(discriminator="action"),
|
Field(discriminator="action"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -736,6 +831,23 @@ class TaskPresentationCursor(StrictModel):
|
|||||||
event_cursor: int = Field(ge=0)
|
event_cursor: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPresentationHistoryItem(StrictModel):
|
||||||
|
event_seq: PositiveInt
|
||||||
|
run_id: UUID
|
||||||
|
execution_generation: PositiveInt
|
||||||
|
occurred_at: WorkbenchDateTime
|
||||||
|
# Stored records are validated against the current command contract by the
|
||||||
|
# reader; an obsolete record must not prevent reading the rest of the page.
|
||||||
|
command: dict[str, JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPresentationHistoryPage(StrictModel):
|
||||||
|
task_id: UUID
|
||||||
|
event_cursor: int = Field(ge=0)
|
||||||
|
items: list[TaskPresentationHistoryItem]
|
||||||
|
next_after: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class TaskDataQueryResult(StrictModel):
|
class TaskDataQueryResult(StrictModel):
|
||||||
evidence: EvidenceRecord
|
evidence: EvidenceRecord
|
||||||
snapshot: TaskDataSnapshot
|
snapshot: TaskDataSnapshot
|
||||||
@@ -816,7 +928,7 @@ class OperationalTaskNotification(StrictModel):
|
|||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
required_permission: NonEmptyStr | None = None
|
required_permission: NonEmptyStr | None = None
|
||||||
lifecycle_status: Literal["active", "resolved"] = "active"
|
lifecycle_status: Literal["active", "resolved"] = "active"
|
||||||
subject_type: Literal["task", "run", "access_request", "action"] = "task"
|
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
||||||
subject_id: UUID | None = None
|
subject_id: UUID | None = None
|
||||||
report_reference: InvestigationReportReference | None = None
|
report_reference: InvestigationReportReference | None = None
|
||||||
resolved_at: WorkbenchDateTime | None = None
|
resolved_at: WorkbenchDateTime | None = None
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaMonitoringSettings(BaseModel):
|
||||||
|
"""Project-owned device roles used by the manual SCADA monitoring demo."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||||
|
|
||||||
|
rule_version: str = Field(
|
||||||
|
default="burst-monitor-v1",
|
||||||
|
min_length=1,
|
||||||
|
max_length=80,
|
||||||
|
pattern=r"^[a-zA-Z0-9_.-]+$",
|
||||||
|
)
|
||||||
|
pressure_device_ids: list[str] = Field(default_factory=list, max_length=20)
|
||||||
|
outlet_flow_device_ids: list[str] = Field(default_factory=list, max_length=20)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def distinct_roles(self):
|
||||||
|
for values in (self.pressure_device_ids, self.outlet_flow_device_ids):
|
||||||
|
if len(set(values)) != len(values) or any(not value.strip() for value in values):
|
||||||
|
raise ValueError("SCADA 监测角色中的设备标识不能为空或重复")
|
||||||
|
if set(self.pressure_device_ids) & set(self.outlet_flow_device_ids):
|
||||||
|
raise ValueError("同一设备不能同时作为压力与出厂边界流量")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSettings(BaseModel):
|
||||||
|
"""Extensible, non-secret project parameters stored in MetaDB as JSON."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow", allow_inf_nan=False)
|
||||||
|
|
||||||
|
__pydantic_extra__: dict[str, JsonValue] = Field(init=False)
|
||||||
|
scada_monitoring_interval_seconds: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
strict=True,
|
||||||
|
ge=60,
|
||||||
|
le=31_536_000,
|
||||||
|
description="SCADA 主动监测演示步长,单位秒;null 表示未配置",
|
||||||
|
)
|
||||||
|
scada_monitoring: ScadaMonitoringSettings | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="主动监测使用的项目设备角色;不创建定时任务",
|
||||||
|
)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class ScadaDeviceResponse(BaseModel):
|
class ScadaDeviceResponse(BaseModel):
|
||||||
"""Project SCADA metadata keyed by the canonical device identifier."""
|
"""Project SCADA metadata keyed by the canonical device identifier."""
|
||||||
|
|
||||||
@@ -47,3 +47,20 @@ class ScadaLatestReadingsResponse(BaseModel):
|
|||||||
offset: int = Field(ge=0)
|
offset: int = Field(ge=0)
|
||||||
observed_count: int = Field(ge=0)
|
observed_count: int = Field(ge=0)
|
||||||
latest_observed_at: datetime | None = None
|
latest_observed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaRawReading(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||||
|
|
||||||
|
time: datetime
|
||||||
|
device_id: str
|
||||||
|
monitored_value: float | None
|
||||||
|
quality: Literal["unvalidated", "missing", "invalid"] = "unvalidated"
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaRawPage(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||||
|
|
||||||
|
items: list[ScadaRawReading]
|
||||||
|
next_cursor: str | None
|
||||||
|
scanned_through: datetime
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Persisted replay controls and explicit, unit-aware demonstration rules."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayRule(ReplayModel):
|
||||||
|
rule_id: str = Field(min_length=1, max_length=80, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||||
|
device_ids: list[str] = Field(min_length=1, max_length=500)
|
||||||
|
kind: Literal["relative_change", "bounds"]
|
||||||
|
direction: Literal["both", "increase", "decrease"] = "both"
|
||||||
|
threshold: float | None = Field(None, gt=0)
|
||||||
|
lower: float | None = None
|
||||||
|
upper: float | None = None
|
||||||
|
unit: str = Field(min_length=1, max_length=40)
|
||||||
|
metric_name: str = Field(default="", max_length=80)
|
||||||
|
minimum_samples: int = Field(3, ge=1, le=96)
|
||||||
|
window_samples: int = Field(4, ge=1, le=96)
|
||||||
|
consecutive_samples: int = Field(1, ge=1, le=20)
|
||||||
|
recovery_samples: int = Field(2, ge=1, le=20)
|
||||||
|
minimum_devices: int = Field(1, ge=1, le=500)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def parameters(self):
|
||||||
|
if len(set(self.device_ids)) != len(self.device_ids):
|
||||||
|
raise ValueError("规则内设备不能重复")
|
||||||
|
if self.minimum_devices > len(self.device_ids):
|
||||||
|
raise ValueError("同时异常设备数超过规则设备数")
|
||||||
|
if self.kind == "relative_change" and (self.threshold is None or self.minimum_samples > self.window_samples):
|
||||||
|
raise ValueError("变化率规则需要阈值和足够的历史窗口")
|
||||||
|
if self.kind == "bounds" and self.lower is None and self.upper is None:
|
||||||
|
raise ValueError("上下限规则至少配置一个阈值")
|
||||||
|
if self.lower is not None and self.upper is not None and self.lower >= self.upper:
|
||||||
|
raise ValueError("规则下限必须小于上限")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaQualityRange(ReplayModel):
|
||||||
|
lower: float | None = None
|
||||||
|
upper: float | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def bounds(self):
|
||||||
|
if self.lower is not None and self.upper is not None and self.lower >= self.upper:
|
||||||
|
raise ValueError("仪表量程下限必须小于上限")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayCreate(ReplayModel):
|
||||||
|
request_id: UUID
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
device_ids: list[str] = Field(min_length=1, max_length=500)
|
||||||
|
rules: list[ScadaReplayRule] = Field(min_length=1, max_length=30)
|
||||||
|
quality_ranges: dict[str, ScadaQualityRange] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def configuration(self):
|
||||||
|
if (self.start_time.tzinfo is None or self.end_time.tzinfo is None
|
||||||
|
or not timedelta(0) < self.end_time - self.start_time <= timedelta(days=31)):
|
||||||
|
raise ValueError("请选择带时区、最长 31 天的有效时间区间")
|
||||||
|
ids = set(self.device_ids)
|
||||||
|
if len(ids) != len(self.device_ids) or any(not value.strip() for value in ids):
|
||||||
|
raise ValueError("设备标识不能为空或重复")
|
||||||
|
if len({rule.rule_id for rule in self.rules}) != len(self.rules):
|
||||||
|
raise ValueError("规则标识不能重复")
|
||||||
|
if any(not set(rule.device_ids) <= ids for rule in self.rules) or not set(self.quality_ranges) <= ids:
|
||||||
|
raise ValueError("规则及量程设备必须属于本次回放")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayCommand(ReplayModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
action: Literal["step", "stop", "retry"]
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaMonitoringResult(ReplayModel):
|
||||||
|
monitoring_id: UUID
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
knowledge_cutoff: datetime
|
||||||
|
batch_sequence: int = Field(ge=1)
|
||||||
|
rule_version: str = Field(min_length=1, max_length=80)
|
||||||
|
status: Literal["normal", "attention", "insufficient_data", "suspected_burst"]
|
||||||
|
detail: str = Field(min_length=1, max_length=1000)
|
||||||
|
task_id: UUID | None = None
|
||||||
|
pressure: dict
|
||||||
|
outlet_flow: dict
|
||||||
|
burst_detection: dict
|
||||||
|
role_gaps: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplaySnapshot(ReplayModel):
|
||||||
|
replay_id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
revision: int
|
||||||
|
status: Literal["ready", "paused", "completed", "stopped", "failed"]
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000)
|
||||||
|
step_pending: bool = False
|
||||||
|
monitoring_count: int = 0
|
||||||
|
last_monitoring: ScadaMonitoringResult | None = None
|
||||||
|
virtual_now: datetime
|
||||||
|
delivered_until: datetime | None = None
|
||||||
|
processed_until: datetime | None = None
|
||||||
|
processed_count: int = 0
|
||||||
|
anomaly_count: int = 0
|
||||||
|
devices: list[dict]
|
||||||
|
rules: list[ScadaReplayRule]
|
||||||
|
latest: list[dict]
|
||||||
|
active_events: list[dict]
|
||||||
|
error: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayCapabilities(ReplayModel):
|
||||||
|
available: bool
|
||||||
|
can_write: bool
|
||||||
|
reason: str | None = None
|
||||||
|
monitoring_interval_seconds: int | None = Field(None, ge=60, le=31_536_000)
|
||||||
|
burst_monitoring_ready: bool = False
|
||||||
|
burst_monitoring_reason: str | None = None
|
||||||
|
current: ScadaReplaySnapshot | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayEventsPage(ReplayModel):
|
||||||
|
items: list[dict]
|
||||||
|
next_offset: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayReadings(ReplayModel):
|
||||||
|
items: list[dict]
|
||||||
|
through: datetime
|
||||||
@@ -5,7 +5,7 @@ from typing import Literal
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||||
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchDateTime
|
from app.domain.schemas.workbench_runtime import WorkbenchAlertSource, WorkbenchScadaReplaySource, WorkbenchDateTime
|
||||||
|
|
||||||
|
|
||||||
class TaskAlertCommand(StrictModel):
|
class TaskAlertCommand(StrictModel):
|
||||||
@@ -14,7 +14,7 @@ class TaskAlertCommand(StrictModel):
|
|||||||
event_type: Literal["raised", "acknowledged", "resolved"]
|
event_type: Literal["raised", "acknowledged", "resolved"]
|
||||||
occurred_at: WorkbenchDateTime
|
occurred_at: WorkbenchDateTime
|
||||||
condition_id: NonEmptyStr | None
|
condition_id: NonEmptyStr | None
|
||||||
source: WorkbenchAlertSource
|
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
severity: Literal["warning", "critical"]
|
severity: Literal["warning", "critical"]
|
||||||
@@ -29,7 +29,7 @@ class TaskAlertState(StrictModel):
|
|||||||
revision: PositiveInt
|
revision: PositiveInt
|
||||||
producer_id: NonEmptyStr
|
producer_id: NonEmptyStr
|
||||||
condition_id: NonEmptyStr | None
|
condition_id: NonEmptyStr | None
|
||||||
source: WorkbenchAlertSource
|
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
occurred_at: WorkbenchDateTime
|
occurred_at: WorkbenchDateTime
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Durable investigation questions; answers are user input, not field evidence."""
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import Field, model_validator
|
||||||
|
|
||||||
|
from app.domain.schemas.shared import StrictModel, PositiveInt
|
||||||
|
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||||
|
|
||||||
|
Id = Annotated[str, Field(min_length=1, max_length=128)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionOption(StrictModel):
|
||||||
|
id: Id
|
||||||
|
label: Annotated[str, Field(min_length=1, max_length=200)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestion(StrictModel):
|
||||||
|
question_id: Id
|
||||||
|
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||||
|
body: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||||
|
options: list[TaskQuestionOption] = Field(max_length=12)
|
||||||
|
mode: Literal["single", "multiple"]
|
||||||
|
allow_custom: bool
|
||||||
|
required: bool
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def valid_options(self):
|
||||||
|
if len({o.id for o in self.options}) != len(self.options):
|
||||||
|
raise ValueError("duplicate option ids")
|
||||||
|
if not self.options and not self.allow_custom:
|
||||||
|
raise ValueError("question must offer an answer input")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionAnswer(StrictModel):
|
||||||
|
question_id: Id
|
||||||
|
selected_option_ids: list[Id] = Field(max_length=12)
|
||||||
|
custom_text: Annotated[str, Field(max_length=2000)]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionCreate(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
source_session_id: Id
|
||||||
|
run_id: UUID
|
||||||
|
questions: list[TaskQuestion] = Field(min_length=1, max_length=3)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def unique_questions(self):
|
||||||
|
if len({q.question_id for q in self.questions}) != len(self.questions):
|
||||||
|
raise ValueError("duplicate question ids")
|
||||||
|
if len(self.model_dump_json().encode()) > 32768:
|
||||||
|
raise ValueError("question definition too large")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionReply(StrictModel):
|
||||||
|
request_id: UUID
|
||||||
|
expected_version: PositiveInt
|
||||||
|
answers: list[TaskQuestionAnswer] = Field(max_length=3)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQuestionRequest(StrictModel):
|
||||||
|
question_request_id: UUID
|
||||||
|
task_id: UUID
|
||||||
|
run_id: UUID
|
||||||
|
execution_generation: PositiveInt
|
||||||
|
source_session_id: Id
|
||||||
|
questions: list[TaskQuestion]
|
||||||
|
status: Literal["pending", "answered", "cancelled"]
|
||||||
|
version: PositiveInt
|
||||||
|
created_at: WorkbenchDateTime
|
||||||
|
answered_at: WorkbenchDateTime | None = None
|
||||||
|
answered_by: str | None = None
|
||||||
|
answers: list[TaskQuestionAnswer] = Field(default_factory=list)
|
||||||
@@ -49,10 +49,17 @@ class WorkbenchAlertSource(StrictModel):
|
|||||||
run_id: NonEmptyStr
|
run_id: NonEmptyStr
|
||||||
|
|
||||||
|
|
||||||
|
class WorkbenchScadaReplaySource(StrictModel):
|
||||||
|
kind: Literal["scada_replay"]
|
||||||
|
replay_id: NonEmptyStr
|
||||||
|
rule_id: NonEmptyStr
|
||||||
|
batch_sequence: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
class WorkbenchRuntimeAlert(StrictModel):
|
class WorkbenchRuntimeAlert(StrictModel):
|
||||||
id: NonEmptyStr
|
id: NonEmptyStr
|
||||||
condition_id: NonEmptyStr | None
|
condition_id: NonEmptyStr | None
|
||||||
source: WorkbenchAlertSource
|
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
occurred_at: WorkbenchDateTime
|
occurred_at: WorkbenchDateTime
|
||||||
@@ -62,7 +69,7 @@ class WorkbenchRuntimeAlert(StrictModel):
|
|||||||
class WorkbenchPersistedAlert(StrictModel):
|
class WorkbenchPersistedAlert(StrictModel):
|
||||||
alert_id: NonEmptyStr
|
alert_id: NonEmptyStr
|
||||||
condition_id: NonEmptyStr | None
|
condition_id: NonEmptyStr | None
|
||||||
source: WorkbenchAlertSource
|
source: WorkbenchAlertSource | WorkbenchScadaReplaySource
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
occurred_at: WorkbenchDateTime
|
occurred_at: WorkbenchDateTime
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Database resources owned by the standalone calculation worker."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def queue_repository(project_id):
|
||||||
|
"""Resolve the current project route and open its calculation queue."""
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.infra.db.dynamic_manager import project_connection_manager
|
||||||
|
from app.infra.db.metadb.database import SessionLocal
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
|
||||||
|
|
||||||
|
project_uuid = UUID(str(project_id))
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
metadata = MetadataRepository(session)
|
||||||
|
project = await metadata.get_project_by_id(project_uuid)
|
||||||
|
if not project or project.status != "active":
|
||||||
|
raise ValueError("Calculation project is not active")
|
||||||
|
routing = await metadata.get_project_db_routing(project_uuid, "biz_data")
|
||||||
|
if not routing or routing.db_type != "postgresql":
|
||||||
|
raise ValueError("Project business PostgreSQL routing is unavailable")
|
||||||
|
await session.commit()
|
||||||
|
pool_min_size = (
|
||||||
|
routing.pool_min_size
|
||||||
|
if routing.pool_min_size is not None
|
||||||
|
else settings.PROJECT_PG_POOL_MIN_SIZE
|
||||||
|
)
|
||||||
|
pool_max_size = (
|
||||||
|
routing.pool_max_size
|
||||||
|
if routing.pool_max_size is not None
|
||||||
|
else settings.PROJECT_PG_POOL_SIZE
|
||||||
|
)
|
||||||
|
async with project_connection_manager.pg_connection(
|
||||||
|
project_uuid,
|
||||||
|
"biz_data",
|
||||||
|
routing.dsn,
|
||||||
|
pool_min_size,
|
||||||
|
pool_max_size,
|
||||||
|
) as connection:
|
||||||
|
yield CalculationJobRepository(connection, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationSupervisorLease:
|
||||||
|
"""Keep the singleton advisory lock alive on one metadata connection."""
|
||||||
|
|
||||||
|
def __init__(self, connection):
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
async def ping(self) -> None:
|
||||||
|
await self.connection.execute(text("SELECT 1"))
|
||||||
|
await self.connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def calculation_supervisor_lease():
|
||||||
|
"""Acquire and release the session-level calculation supervisor lock."""
|
||||||
|
from app.infra.db.metadb.database import engine
|
||||||
|
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
acquired = await connection.scalar(text("SELECT pg_try_advisory_lock(1748531781, 2)"))
|
||||||
|
await connection.commit()
|
||||||
|
if not acquired:
|
||||||
|
raise RuntimeError("Another calculation supervisor is already active")
|
||||||
|
try:
|
||||||
|
yield CalculationSupervisorLease(connection)
|
||||||
|
finally:
|
||||||
|
await connection.execute(text("SELECT pg_advisory_unlock(1748531781, 2)"))
|
||||||
|
await connection.commit()
|
||||||
@@ -44,6 +44,7 @@ class Project(Base):
|
|||||||
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
|
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
map_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
map_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||||
|
settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=datetime.utcnow
|
DateTime(timezone=True), default=datetime.utcnow
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class ProjectDetail:
|
|||||||
map_extent: Optional[dict]
|
map_extent: Optional[dict]
|
||||||
status: str
|
status: str
|
||||||
map_config: dict = field(default_factory=dict)
|
map_config: dict = field(default_factory=dict)
|
||||||
|
settings: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -223,6 +224,7 @@ class MetadataRepository:
|
|||||||
status: str,
|
status: str,
|
||||||
creator_user_id: UUID | None = None,
|
creator_user_id: UUID | None = None,
|
||||||
map_config: dict | None = None,
|
map_config: dict | None = None,
|
||||||
|
settings: dict | None = None,
|
||||||
) -> models.Project:
|
) -> models.Project:
|
||||||
project = models.Project(
|
project = models.Project(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
@@ -233,6 +235,7 @@ class MetadataRepository:
|
|||||||
map_extent=map_extent,
|
map_extent=map_extent,
|
||||||
status=status,
|
status=status,
|
||||||
map_config=map_config or {},
|
map_config=map_config or {},
|
||||||
|
settings=settings or {},
|
||||||
created_at=_utcnow(),
|
created_at=_utcnow(),
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
)
|
)
|
||||||
@@ -263,6 +266,7 @@ class MetadataRepository:
|
|||||||
timescale_dsn: str,
|
timescale_dsn: str,
|
||||||
pool_min_size: int = 1,
|
pool_min_size: int = 1,
|
||||||
pool_max_size: int = 4,
|
pool_max_size: int = 4,
|
||||||
|
settings: dict | None = None,
|
||||||
) -> models.Project:
|
) -> models.Project:
|
||||||
"""Atomically expose a fully provisioned project and both DB routes."""
|
"""Atomically expose a fully provisioned project and both DB routes."""
|
||||||
business_secret = _encrypt_database_secret(business_dsn)
|
business_secret = _encrypt_database_secret(business_dsn)
|
||||||
@@ -276,6 +280,7 @@ class MetadataRepository:
|
|||||||
map_extent=map_extent,
|
map_extent=map_extent,
|
||||||
status="active",
|
status="active",
|
||||||
map_config={},
|
map_config={},
|
||||||
|
settings=settings or {},
|
||||||
created_at=_utcnow(),
|
created_at=_utcnow(),
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
)
|
)
|
||||||
@@ -333,6 +338,8 @@ class MetadataRepository:
|
|||||||
setattr(project, field, updates[field])
|
setattr(project, field, updates[field])
|
||||||
if "map_config" in updates:
|
if "map_config" in updates:
|
||||||
project.map_config = {**(project.map_config or {}), **updates["map_config"]}
|
project.map_config = {**(project.map_config or {}), **updates["map_config"]}
|
||||||
|
if "settings" in updates:
|
||||||
|
project.settings = {**(project.settings or {}), **updates["settings"]}
|
||||||
project.updated_at = _utcnow()
|
project.updated_at = _utcnow()
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(project)
|
await self.session.refresh(project)
|
||||||
@@ -350,6 +357,7 @@ class MetadataRepository:
|
|||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
map_extent=project.map_extent,
|
map_extent=project.map_extent,
|
||||||
map_config=project.map_config or {},
|
map_config=project.map_config or {},
|
||||||
|
settings=project.settings or {},
|
||||||
status=project.status,
|
status=project.status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Durable project calculation queue. Every worker update is generation fenced."""
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
from app.mcp.errors import CalculationCapacityExceeded
|
||||||
|
|
||||||
|
|
||||||
|
# Explicit metadata projection prevents status polling from decoding large JSON results.
|
||||||
|
JOB_COLUMNS = "job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,status,error,lease_generation,lease_owner,lease_expires_at,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at"
|
||||||
|
|
||||||
|
|
||||||
|
PUBLIC_JOB_COLUMNS = "job_id,status,tool,error,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at"
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationJobRepository:
|
||||||
|
def __init__(self, connection, project_id):
|
||||||
|
self.connection = connection
|
||||||
|
self.project_id = str(project_id)
|
||||||
|
|
||||||
|
async def maintain(self):
|
||||||
|
await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET status='timed_out',
|
||||||
|
error='{"code":"QUEUE_TIMEOUT"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
|
||||||
|
WHERE project_id=%s AND status='queued' AND queue_expires_at<=clock_timestamp()""",
|
||||||
|
(self.project_id,),
|
||||||
|
)
|
||||||
|
await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET result=NULL
|
||||||
|
WHERE project_id=%s AND result IS NOT NULL AND result_expires_at<=clock_timestamp()""",
|
||||||
|
(self.project_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds,
|
||||||
|
queue_wait_seconds=3600, max_active_jobs=100):
|
||||||
|
async with self.connection.transaction():
|
||||||
|
return await self._enqueue(owner_user_id=owner_user_id, request_key=request_key,
|
||||||
|
tool=tool, parameters=parameters, binding=binding, timeout_seconds=timeout_seconds,
|
||||||
|
queue_wait_seconds=queue_wait_seconds, max_active_jobs=max_active_jobs)
|
||||||
|
|
||||||
|
async def _enqueue(self, *, owner_user_id, request_key, tool, parameters, binding,
|
||||||
|
timeout_seconds, queue_wait_seconds, max_active_jobs):
|
||||||
|
# Serialize admission within the caller's transaction, across every API process.
|
||||||
|
await self.connection.execute(
|
||||||
|
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 1748531781))", (self.project_id,))
|
||||||
|
await self.maintain()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"SELECT {JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s AND request_key=%s",
|
||||||
|
(self.project_id, owner_user_id, request_key),
|
||||||
|
)
|
||||||
|
existing = await cursor.fetchone()
|
||||||
|
if existing:
|
||||||
|
row = dict(existing)
|
||||||
|
if (row['tool'] != tool
|
||||||
|
or {key: value for key, value in row['parameters'].items() if key != 'timeout_seconds'}
|
||||||
|
!= {key: value for key, value in parameters.items() if key != 'timeout_seconds'}
|
||||||
|
or row['binding'] != binding):
|
||||||
|
raise HTTPException(409, 'Calculation request_key was used with different inputs')
|
||||||
|
return row
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT count(*) AS active FROM workbench.calculation_jobs WHERE project_id=%s
|
||||||
|
AND status IN ('queued','running','cancel_requested')""", (self.project_id,))
|
||||||
|
if (await cursor.fetchone())['active'] >= max_active_jobs:
|
||||||
|
raise CalculationCapacityExceeded()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"""INSERT INTO workbench.calculation_jobs
|
||||||
|
(job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,queue_expires_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,clock_timestamp()+%s*interval '1 second')
|
||||||
|
RETURNING {JOB_COLUMNS}""",
|
||||||
|
(uuid4(), self.project_id, owner_user_id, request_key, tool,
|
||||||
|
Jsonb(parameters), Jsonb(binding), timeout_seconds, queue_wait_seconds),
|
||||||
|
)
|
||||||
|
return dict(await cursor.fetchone())
|
||||||
|
|
||||||
|
async def get(self, job_id, owner_user_id=None, *, include_result=False):
|
||||||
|
await self.maintain()
|
||||||
|
columns = JOB_COLUMNS + (",result" if include_result else "")
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"""SELECT {columns} FROM workbench.calculation_jobs WHERE project_id=%s AND job_id=%s
|
||||||
|
AND (%s::uuid IS NULL OR owner_user_id=%s)""",
|
||||||
|
(self.project_id, job_id, owner_user_id, owner_user_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def list(self, *, owner_user_id, binding, limit, offset, status=None):
|
||||||
|
await self.maintain()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
f"""SELECT {PUBLIC_JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s
|
||||||
|
AND binding @> %s AND (%s::text IS NULL OR status=%s) ORDER BY created_at DESC,job_id LIMIT %s OFFSET %s""",
|
||||||
|
(self.project_id, owner_user_id, Jsonb(binding), status, status, limit, offset),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def cancel(self, job_id, owner_user_id):
|
||||||
|
await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET status=CASE WHEN status='queued' THEN 'cancelled'
|
||||||
|
ELSE 'cancel_requested' END,finished_at=CASE WHEN status='queued' THEN clock_timestamp() ELSE finished_at END,updated_at=clock_timestamp()
|
||||||
|
WHERE project_id=%s AND job_id=%s AND owner_user_id=%s AND status IN ('queued','running')""",
|
||||||
|
(self.project_id, job_id, owner_user_id),
|
||||||
|
)
|
||||||
|
return await self.get(job_id, owner_user_id)
|
||||||
|
|
||||||
|
async def recover(self):
|
||||||
|
await self.maintain()
|
||||||
|
# A crashed write is never automatically replayed.
|
||||||
|
await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET status='interrupted',
|
||||||
|
error='{"code":"WORKER_INTERRUPTED"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
|
||||||
|
WHERE project_id=%s AND status IN ('running','cancel_requested')
|
||||||
|
AND lease_expires_at < clock_timestamp()""", (self.project_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def claim(self, owner, lease_seconds):
|
||||||
|
await self.maintain()
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs j SET status='running',started_at=clock_timestamp(),lease_owner=%s,
|
||||||
|
lease_generation=j.lease_generation+1,
|
||||||
|
lease_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
|
||||||
|
WHERE j.job_id=(SELECT job_id FROM workbench.calculation_jobs
|
||||||
|
WHERE project_id=%s AND status='queued' AND queue_expires_at>clock_timestamp() ORDER BY created_at,job_id
|
||||||
|
FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING j.*""",
|
||||||
|
(owner, lease_seconds, self.project_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def heartbeat(self, row, owner, lease_seconds):
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET lease_expires_at=clock_timestamp()+%s*interval '1 second',
|
||||||
|
updated_at=clock_timestamp() WHERE project_id=%s AND job_id=%s AND lease_owner=%s
|
||||||
|
AND lease_generation=%s AND lease_expires_at>clock_timestamp()
|
||||||
|
AND status IN ('running','cancel_requested') RETURNING status""",
|
||||||
|
(lease_seconds, self.project_id, row['job_id'], owner, row['lease_generation']),
|
||||||
|
)
|
||||||
|
result = await cursor.fetchone()
|
||||||
|
return result['status'] if result else None
|
||||||
|
|
||||||
|
async def finish(self, row, owner, status, *, result=None, error=None, result_retention_seconds=604800):
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""UPDATE workbench.calculation_jobs SET status=%s,result=%s,error=%s,finished_at=clock_timestamp(),
|
||||||
|
result_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
|
||||||
|
WHERE project_id=%s AND job_id=%s AND lease_owner=%s AND lease_generation=%s
|
||||||
|
AND lease_expires_at>clock_timestamp() AND status IN ('running','cancel_requested')
|
||||||
|
AND (status!='cancel_requested' OR %s IN ('cancelled','interrupted')
|
||||||
|
OR (%s='failed' AND %s='CLEANUP_FAILED')) RETURNING job_id""",
|
||||||
|
(status, Jsonb(result) if result is not None else None, Jsonb(error) if error else None, result_retention_seconds,
|
||||||
|
self.project_id, row['job_id'], owner, row['lease_generation'], status, status, (error or {}).get('code')),
|
||||||
|
)
|
||||||
|
return await cursor.fetchone() is not None
|
||||||
@@ -19,6 +19,7 @@ class OperationalTaskRepository:
|
|||||||
def __init__(self, connection: AsyncConnection, project_id):
|
def __init__(self, connection: AsyncConnection, project_id):
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.project_id = str(project_id)
|
self.project_id = str(project_id)
|
||||||
|
self._agent_ownership_available: bool | None = None
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def transaction(self):
|
async def transaction(self):
|
||||||
@@ -75,6 +76,48 @@ class OperationalTaskRepository:
|
|||||||
status=409,
|
status=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def pending_calculation_jobs(
|
||||||
|
self, task_id: str, run_id: str, execution_request_id: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Only the exact original run blocks final artifacts and completion."""
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT job_id FROM workbench.calculation_jobs
|
||||||
|
WHERE project_id=%s AND binding->>'task_id'=%s
|
||||||
|
AND binding->>'run_id'=%s AND binding->>'execution_request_id'=%s
|
||||||
|
AND status IN ('queued','running','cancel_requested')
|
||||||
|
ORDER BY created_at,job_id LIMIT 100""",
|
||||||
|
(self.project_id, task_id, run_id, execution_request_id),
|
||||||
|
)
|
||||||
|
return [str(row["job_id"]) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def list_questions(self, task_id: str) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_questions
|
||||||
|
WHERE project_id=%s AND task_id=%s
|
||||||
|
ORDER BY document->>'created_at' DESC, question_request_id DESC LIMIT 100""",
|
||||||
|
(self.project_id, task_id),
|
||||||
|
)
|
||||||
|
return [row["document"] for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
async def get_question(self, task_id: str, question_id: str) -> dict | None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT document FROM workbench.operational_task_questions
|
||||||
|
WHERE project_id=%s AND task_id=%s AND question_request_id=%s""",
|
||||||
|
(self.project_id, task_id, question_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def save_question(self, task_id: str, document: dict) -> None:
|
||||||
|
# All writers hold the owning task advisory lock.
|
||||||
|
await self.connection.execute(
|
||||||
|
"""INSERT INTO workbench.operational_task_questions
|
||||||
|
(project_id,task_id,question_request_id,document) VALUES (%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (project_id,question_request_id) DO UPDATE SET document=excluded.document
|
||||||
|
WHERE operational_task_questions.task_id=excluded.task_id""",
|
||||||
|
(self.project_id, task_id, document["question_request_id"], Jsonb(document)),
|
||||||
|
)
|
||||||
|
|
||||||
async def get_task(self, task_id: str) -> dict | None:
|
async def get_task(self, task_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT document FROM workbench.operational_tasks
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
@@ -87,7 +130,8 @@ class OperationalTaskRepository:
|
|||||||
async def get_active_run(self, task_id: str) -> dict | None:
|
async def get_active_run(self, task_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||||
|
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
created_at,updated_at
|
created_at,updated_at
|
||||||
FROM workbench.operational_task_runs
|
FROM workbench.operational_task_runs
|
||||||
@@ -102,7 +146,8 @@ class OperationalTaskRepository:
|
|||||||
async def get_run(self, run_id: str) -> dict | None:
|
async def get_run(self, run_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||||
|
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
created_at,updated_at
|
created_at,updated_at
|
||||||
FROM workbench.operational_task_runs
|
FROM workbench.operational_task_runs
|
||||||
@@ -129,13 +174,23 @@ class OperationalTaskRepository:
|
|||||||
async def require_execution_storage(self) -> None:
|
async def require_execution_storage(self) -> None:
|
||||||
"""Reject claims before changing state if the inbox cannot be used."""
|
"""Reject claims before changing state if the inbox cannot be used."""
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"SELECT to_regclass('workbench.agent_messages') IS NOT NULL AS available"
|
"""SELECT to_regclass('workbench.agent_messages') IS NOT NULL
|
||||||
|
AND to_regclass('workbench.operational_task_questions') IS NOT NULL AS available,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid=to_regclass('workbench.operational_task_runs')
|
||||||
|
AND attname='agent_instance_id' AND NOT attisdropped
|
||||||
|
) AS agent_ownership_available"""
|
||||||
)
|
)
|
||||||
if not (await cursor.fetchone())["available"]:
|
readiness = await cursor.fetchone()
|
||||||
|
self._agent_ownership_available = bool(readiness.get("agent_ownership_available"))
|
||||||
|
if not readiness["available"] or not self._agent_ownership_available:
|
||||||
raise DomainError(
|
raise DomainError(
|
||||||
"DEPENDENCY_UNAVAILABLE",
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
"Agent message storage is not provisioned; apply "
|
"Agent execution storage is not provisioned; apply "
|
||||||
"resources/sql/024_agent_messages.sql to this project database before starting runs",
|
"resources/sql/024_agent_messages.sql, resources/sql/028_task_questions.sql, "
|
||||||
|
"and resources/sql/030_operational_run_agent_ownership.sql "
|
||||||
|
"to this project database before starting runs",
|
||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,14 +231,26 @@ class OperationalTaskRepository:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def update_run(self, run: dict, expected_revision: int) -> None:
|
async def update_run(self, run: dict, expected_revision: int) -> None:
|
||||||
|
if self._agent_ownership_available is None:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT EXISTS (
|
||||||
|
SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid=to_regclass('workbench.operational_task_runs')
|
||||||
|
AND attname='agent_instance_id' AND NOT attisdropped
|
||||||
|
) AS available"""
|
||||||
|
)
|
||||||
|
self._agent_ownership_available = bool((await cursor.fetchone())["available"])
|
||||||
|
owner_assignment = "agent_instance_id=%s," if self._agent_ownership_available else ""
|
||||||
|
owner_values = (run.get("agent_instance_id"),) if self._agent_ownership_available else ()
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""UPDATE workbench.operational_task_runs SET
|
f"""UPDATE workbench.operational_task_runs SET
|
||||||
status=%s,revision=%s,agent_session_id=%s,lease_expires_at=%s,
|
status=%s,revision=%s,agent_session_id=%s,{owner_assignment}lease_expires_at=%s,
|
||||||
heartbeat_at=%s,started_at=%s,finished_at=%s,failure_code=%s,
|
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
|
failure_detail=%s,dispatch_attempts=%s,next_dispatch_at=%s,updated_at=%s
|
||||||
WHERE project_id=%s AND run_id=%s AND revision=%s""",
|
WHERE project_id=%s AND run_id=%s AND revision=%s""",
|
||||||
(
|
(
|
||||||
run["status"], run["revision"], run.get("agent_session_id"),
|
run["status"], run["revision"], run.get("agent_session_id"),
|
||||||
|
*owner_values,
|
||||||
run.get("lease_expires_at"), run.get("heartbeat_at"),
|
run.get("lease_expires_at"), run.get("heartbeat_at"),
|
||||||
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
|
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
|
||||||
run.get("failure_detail"), run.get("dispatch_attempts", 0),
|
run.get("failure_detail"), run.get("dispatch_attempts", 0),
|
||||||
@@ -200,7 +267,8 @@ class OperationalTaskRepository:
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
result = await self.connection.execute(
|
result = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||||
|
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
created_at,updated_at
|
created_at,updated_at
|
||||||
FROM workbench.operational_task_runs
|
FROM workbench.operational_task_runs
|
||||||
@@ -229,7 +297,8 @@ class OperationalTaskRepository:
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
|
||||||
|
lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||||
created_at,updated_at
|
created_at,updated_at
|
||||||
FROM workbench.operational_task_runs
|
FROM workbench.operational_task_runs
|
||||||
@@ -286,6 +355,18 @@ class OperationalTaskRepository:
|
|||||||
async def current_event_cursor(self) -> int:
|
async def current_event_cursor(self) -> int:
|
||||||
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
|
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
|
||||||
|
|
||||||
|
async def list_presentation_events(self, task_id: str, after: int, through: int, limit: int) -> list[dict]:
|
||||||
|
cursor = await self.connection.execute(
|
||||||
|
"""SELECT event_seq,occurred_at,metadata
|
||||||
|
FROM workbench.domain_event_log
|
||||||
|
WHERE project_id=%s AND topic='operational.presentation.requested'
|
||||||
|
AND aggregate_type='operational_task_presentation' AND aggregate_id=%s
|
||||||
|
AND event_seq>%s AND event_seq<=%s
|
||||||
|
ORDER BY event_seq LIMIT %s""",
|
||||||
|
(self.project_id, task_id, after, through, limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
||||||
cursor = await self.connection.execute(
|
cursor = await self.connection.execute(
|
||||||
"""SELECT document FROM workbench.operational_tasks
|
"""SELECT document FROM workbench.operational_tasks
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ async def task_context_page(
|
|||||||
"evidence": ("operational_task_evidence", "evidence_id"),
|
"evidence": ("operational_task_evidence", "evidence_id"),
|
||||||
"actions": ("operational_task_actions", "action_id"),
|
"actions": ("operational_task_actions", "action_id"),
|
||||||
"access_requests": ("operational_task_access_requests", "access_request_id"),
|
"access_requests": ("operational_task_access_requests", "access_request_id"),
|
||||||
|
"questions": ("operational_task_questions", "question_request_id"),
|
||||||
}
|
}
|
||||||
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
|
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
|
||||||
if section in {"access_requests", "progress"}:
|
if section in {"access_requests", "progress"}:
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Short-transaction storage for replay control and resumable batches."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayRepository:
|
||||||
|
def __init__(self, connection, project_id):
|
||||||
|
self.connection, self.project_id = connection, str(project_id)
|
||||||
|
self.lock_key = f"scada-replay:{self.project_id}"
|
||||||
|
|
||||||
|
async def available(self) -> bool:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT to_regclass('peripheral_sample.scada_replays') IS NOT NULL "
|
||||||
|
"AND to_regclass('peripheral_sample.scada_replay_batches') IS NOT NULL AS ready"
|
||||||
|
)
|
||||||
|
return (await cur.fetchone())["ready"]
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def control(self):
|
||||||
|
async with self.connection.transaction():
|
||||||
|
await self.connection.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (self.lock_key,))
|
||||||
|
yield
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def processing(self):
|
||||||
|
# Session lock outlives short transactions, never an open SQL transaction
|
||||||
|
# across Timescale IO. A dead connection automatically releases ownership.
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute("SELECT pg_try_advisory_lock(hashtextextended(%s,0)) AS owned", (self.lock_key,))
|
||||||
|
owned = (await cur.fetchone())["owned"]
|
||||||
|
try:
|
||||||
|
yield owned
|
||||||
|
finally:
|
||||||
|
if owned:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
await self.connection.execute("SELECT pg_advisory_unlock(hashtextextended(%s,0))", (self.lock_key,))
|
||||||
|
|
||||||
|
async def get(self, replay_id) -> dict:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s AND replay_id=%s",
|
||||||
|
(self.project_id, str(replay_id)),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "当前项目不存在此 SCADA 回放")
|
||||||
|
return row["document"]
|
||||||
|
|
||||||
|
async def current(self) -> dict | None:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s"
|
||||||
|
" ORDER BY created_at DESC LIMIT 1", (self.project_id,),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def by_request(self, request_id) -> dict | None:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT document FROM peripheral_sample.scada_replays WHERE project_id=%s AND request_id=%s",
|
||||||
|
(self.project_id, str(request_id)),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def active_other(self, replay_id) -> bool:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT 1 FROM peripheral_sample.scada_replays WHERE project_id=%s AND replay_id<>%s "
|
||||||
|
"AND status IN ('ready','paused') LIMIT 1",
|
||||||
|
(self.project_id, str(replay_id)),
|
||||||
|
)
|
||||||
|
return await cur.fetchone() is not None
|
||||||
|
|
||||||
|
async def insert(self, document: dict):
|
||||||
|
await self.connection.execute(
|
||||||
|
"INSERT INTO peripheral_sample.scada_replays(project_id,replay_id,request_id,status,document) "
|
||||||
|
"VALUES(%s,%s,%s,%s,%s)",
|
||||||
|
(self.project_id, document["replay_id"], document["config"]["request_id"], document["status"], Jsonb(document)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save(self, document: dict):
|
||||||
|
document["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
await self.connection.execute(
|
||||||
|
"UPDATE peripheral_sample.scada_replays SET status=%s,document=%s,updated_at=now() "
|
||||||
|
"WHERE project_id=%s AND replay_id=%s",
|
||||||
|
(document["status"], Jsonb(document), self.project_id, document["replay_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pending_batch(self, replay_id) -> dict | None:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT document FROM peripheral_sample.scada_replay_batches "
|
||||||
|
"WHERE project_id=%s AND replay_id=%s AND stage<>'detected' ORDER BY sequence LIMIT 1",
|
||||||
|
(self.project_id, str(replay_id)),
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row["document"] if row else None
|
||||||
|
|
||||||
|
async def save_batch(self, replay_id, batch: dict):
|
||||||
|
await self.connection.execute(
|
||||||
|
"INSERT INTO peripheral_sample.scada_replay_batches(project_id,replay_id,sequence,stage,document) "
|
||||||
|
"VALUES(%s,%s,%s,%s,%s) ON CONFLICT(project_id,replay_id,sequence) "
|
||||||
|
"DO UPDATE SET stage=excluded.stage,document=excluded.document",
|
||||||
|
(self.project_id, str(replay_id), batch["sequence"], batch["stage"], Jsonb(batch)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def events(self, replay_id, offset=0, limit=100) -> list[dict]:
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT e.value AS event FROM peripheral_sample.scada_replay_batches b "
|
||||||
|
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'events','[]'::jsonb)) e "
|
||||||
|
"WHERE b.project_id=%s AND b.replay_id=%s ORDER BY b.sequence DESC LIMIT %s OFFSET %s",
|
||||||
|
(self.project_id, str(replay_id), limit, offset),
|
||||||
|
)
|
||||||
|
return [r["event"] for r in await cur.fetchall()]
|
||||||
|
|
||||||
|
async def monitoring_outputs(
|
||||||
|
self,
|
||||||
|
replay_id,
|
||||||
|
device_ids: list[str],
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
before_sequence: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Read only committed replay outputs available before the current batch."""
|
||||||
|
if not device_ids:
|
||||||
|
return []
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT r.value AS reading FROM peripheral_sample.scada_replay_batches b "
|
||||||
|
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
|
||||||
|
"WHERE b.project_id=%s AND b.replay_id=%s AND b.stage='detected' AND b.sequence<%s "
|
||||||
|
"AND r.value->>'device_id'=ANY(%s) "
|
||||||
|
"AND (r.value->>'time')::timestamptz>=%s AND (r.value->>'time')::timestamptz<=%s "
|
||||||
|
"ORDER BY (r.value->>'time')::timestamptz,r.value->>'device_id'",
|
||||||
|
(self.project_id, str(replay_id), before_sequence, device_ids, start, end),
|
||||||
|
)
|
||||||
|
return [row["reading"] for row in await cur.fetchall()]
|
||||||
|
|
||||||
|
async def detected_outputs(
|
||||||
|
self,
|
||||||
|
replay_id,
|
||||||
|
device_ids: list[str],
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
before_sequence: int,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Read immutable detector outputs available to a bounded replay investigation."""
|
||||||
|
statement = (
|
||||||
|
"SELECT r.value AS reading FROM peripheral_sample.scada_replay_batches b "
|
||||||
|
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
|
||||||
|
"WHERE b.project_id=%s AND b.replay_id=%s AND b.sequence<=%s AND b.stage='detected' "
|
||||||
|
"AND r.value->>'device_id'=ANY(%s) "
|
||||||
|
"AND (r.value->>'time')::timestamptz>=%s AND (r.value->>'time')::timestamptz<=%s "
|
||||||
|
"ORDER BY (r.value->>'time')::timestamptz,r.value->>'device_id'"
|
||||||
|
)
|
||||||
|
params: tuple = (
|
||||||
|
self.project_id, str(replay_id), before_sequence, device_ids, start, end,
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
statement += " LIMIT %s OFFSET %s"
|
||||||
|
params += (limit, offset)
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(statement, params)
|
||||||
|
return [row["reading"] for row in await cur.fetchall()]
|
||||||
|
|
||||||
|
async def latest_outputs(self, replay_id, through: datetime, limit: int = 500) -> list[dict]:
|
||||||
|
"""Return the latest committed cleaned output for each replay device."""
|
||||||
|
async with self.connection.transaction():
|
||||||
|
cur = await self.connection.execute(
|
||||||
|
"SELECT DISTINCT ON(r.value->>'device_id') r.value AS reading "
|
||||||
|
"FROM peripheral_sample.scada_replay_batches b "
|
||||||
|
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(b.document->'outputs','[]'::jsonb)) r "
|
||||||
|
"WHERE b.project_id=%s AND b.replay_id=%s AND b.stage IN ('cleaned','detected') "
|
||||||
|
"AND (r.value->>'time')::timestamptz<=%s "
|
||||||
|
"ORDER BY r.value->>'device_id',(r.value->>'time')::timestamptz DESC LIMIT %s",
|
||||||
|
(self.project_id, str(replay_id), through, limit),
|
||||||
|
)
|
||||||
|
return [row["reading"] for row in await cur.fetchall()]
|
||||||
@@ -9,6 +9,86 @@ class ScadaRepository:
|
|||||||
VALUE_FIELDS = frozenset({"monitored_value", "cleaned_value"})
|
VALUE_FIELDS = frozenset({"monitored_value", "cleaned_value"})
|
||||||
RESULT_COLUMNS = "time, device_id, monitored_value, cleaned_value"
|
RESULT_COLUMNS = "time, device_id, monitored_value, cleaned_value"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_warmup_values(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
device_ids: list[str],
|
||||||
|
before: datetime,
|
||||||
|
per_device_limit: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return the latest raw values before a replay starts for detector warmup."""
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT d.device_id,h.monitored_value FROM unnest(%s::text[]) d(device_id) "
|
||||||
|
"CROSS JOIN LATERAL (SELECT monitored_value,time FROM scada.measurements "
|
||||||
|
"WHERE device_id=d.device_id AND time<%s ORDER BY time DESC LIMIT %s) h "
|
||||||
|
"ORDER BY d.device_id,h.time",
|
||||||
|
(device_ids, before, per_device_limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in await cur.fetchall()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_raw_page(
|
||||||
|
conn: AsyncConnection, device_ids: list[str], start: datetime,
|
||||||
|
end: datetime, through: datetime, limit: int,
|
||||||
|
after: tuple[datetime, str] | None = None, *, frame: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Read raw only, with a stable composite key and a bounded frame/page."""
|
||||||
|
predicate = "device_id=ANY(%s) AND time>=%s AND time<%s AND time<=%s"
|
||||||
|
params: tuple = (device_ids, start, end, through)
|
||||||
|
if after is not None:
|
||||||
|
predicate += " AND (time,device_id)>(%s,%s)"
|
||||||
|
params += after
|
||||||
|
if frame:
|
||||||
|
statement = (
|
||||||
|
"WITH next_time AS (SELECT min(time) AS time FROM scada.measurements WHERE "
|
||||||
|
+ predicate + ") SELECT m.time,m.device_id,m.monitored_value,"
|
||||||
|
"md5(COALESCE(m.monitored_value::text,'NULL')) AS source_hash "
|
||||||
|
"FROM scada.measurements m WHERE m.device_id=ANY(%s) "
|
||||||
|
"AND m.time=(SELECT time FROM next_time) ORDER BY m.time,m.device_id LIMIT %s"
|
||||||
|
)
|
||||||
|
params += (device_ids, limit)
|
||||||
|
else:
|
||||||
|
statement = (
|
||||||
|
"SELECT time,device_id,monitored_value,"
|
||||||
|
"md5(COALESCE(monitored_value::text,'NULL')) AS source_hash "
|
||||||
|
"FROM scada.measurements WHERE " + predicate
|
||||||
|
+ " ORDER BY time,device_id LIMIT %s"
|
||||||
|
)
|
||||||
|
params += (limit,)
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(statement, params)
|
||||||
|
return [dict(row) for row in await cur.fetchall()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def apply_replay_cleaning(conn: AsyncConnection, rows: list[dict]) -> None:
|
||||||
|
"""Guard every raw version; update corrected values without inserting raw."""
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
async with conn.transaction():
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT m.time,m.device_id,md5(COALESCE(m.monitored_value::text,'NULL')) AS source_hash "
|
||||||
|
"FROM scada.measurements m JOIN unnest(%s::timestamptz[],%s::text[]) "
|
||||||
|
"b(time,device_id) USING(time,device_id) FOR UPDATE OF m",
|
||||||
|
([r["time"] for r in rows], [r["device_id"] for r in rows]),
|
||||||
|
)
|
||||||
|
existing = {(r["time"].isoformat(), r["device_id"]): r["source_hash"]
|
||||||
|
for r in await cur.fetchall()}
|
||||||
|
if len(existing) != len(rows) or any(
|
||||||
|
existing.get((datetime.fromisoformat(r["time"]).isoformat(), r["device_id"]))
|
||||||
|
!= r["source_hash"] for r in rows
|
||||||
|
):
|
||||||
|
raise ValueError("SOURCE_CHANGED: SCADA 原始值在处理期间发生变化")
|
||||||
|
await cur.execute(
|
||||||
|
"UPDATE scada.measurements m SET cleaned_value=b.value "
|
||||||
|
"FROM unnest(%s::timestamptz[],%s::text[],%s::double precision[]) "
|
||||||
|
"b(time,device_id,value) WHERE m.time=b.time AND m.device_id=b.device_id "
|
||||||
|
"AND m.cleaned_value IS DISTINCT FROM b.value",
|
||||||
|
([r["time"] for r in rows], [r["device_id"] for r in rows],
|
||||||
|
[r["cleaned_value"] for r in rows]),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def insert_scada_batch(conn: AsyncConnection, data: List[dict]):
|
async def insert_scada_batch(conn: AsyncConnection, data: List[dict]):
|
||||||
if not data:
|
if not data:
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class SampleWorkOrderReceipt(BaseModel):
|
|||||||
work_order_id: UUID
|
work_order_id: UUID
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
origin: Literal["synthetic"]
|
origin: Literal["system_data"]
|
||||||
status: Literal[
|
status: Literal[
|
||||||
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
||||||
]
|
]
|
||||||
@@ -195,7 +195,8 @@ class HttpOperationalActionDispatcher:
|
|||||||
receipt = ActionReceipt(
|
receipt = ActionReceipt(
|
||||||
adapter="peripheral_sample", receipt_id=str(order.work_order_id),
|
adapter="peripheral_sample", receipt_id=str(order.work_order_id),
|
||||||
submitted_at=order.created_at, completed_at=order.created_at,
|
submitted_at=order.created_at, completed_at=order.created_at,
|
||||||
external_status=order.status, detail="合成外围工单已创建,现场工作进度由外围系统继续同步",
|
external_status=order.status,
|
||||||
|
detail="外围工单已创建,现场工作进度由外围系统继续同步",
|
||||||
)
|
)
|
||||||
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
|
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
|
||||||
result = CommandGatewayReceipt.model_validate(value)
|
result = CommandGatewayReceipt.model_validate(value)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
@@ -33,6 +34,23 @@ class TaskDataError(Exception):
|
|||||||
self.detail = detail
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
def peripheral_records_origin(items: object) -> Literal["system_data", "synthetic"]:
|
||||||
|
"""Classify a record collection; mixed collections retain per-item provenance."""
|
||||||
|
if not isinstance(items, list) or any(
|
||||||
|
not isinstance(item, dict) or item.get("origin") not in {"system_data", "synthetic"}
|
||||||
|
for item in items
|
||||||
|
):
|
||||||
|
raise TaskDataError(502, "外围记录缺少明确来源,不能作为已核实证据")
|
||||||
|
return "synthetic" if items and all(item["origin"] == "synthetic" for item in items) else "system_data"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_peripheral_work_orders(items: object) -> list[dict[str, object]]:
|
||||||
|
"""Expose peripheral work orders only as project-owned system data."""
|
||||||
|
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
|
||||||
|
raise TaskDataError(502, "外围工单格式无效,不能作为已核实证据")
|
||||||
|
return [{**item, "origin": "system_data"} for item in items]
|
||||||
|
|
||||||
|
|
||||||
def _matches_project(value: object, project_id: str) -> bool:
|
def _matches_project(value: object, project_id: str) -> bool:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return all(_matches_project(item, project_id) for item in value)
|
return all(_matches_project(item, project_id) for item in value)
|
||||||
@@ -147,7 +165,14 @@ async def read_task_data(
|
|||||||
data = _select_demo_observations(data, query, str(context.project_id))
|
data = _select_demo_observations(data, query, str(context.project_id))
|
||||||
elif not _matches_project(data, str(context.project_id)):
|
elif not _matches_project(data, str(context.project_id)):
|
||||||
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
||||||
origin = "synthetic" if peripheral else "system_data"
|
origin = "system_data"
|
||||||
|
if query.source == "peripheral_work_orders":
|
||||||
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
|
data = {**data, "items": normalize_peripheral_work_orders(items)}
|
||||||
|
elif query.source == "peripheral_test_data":
|
||||||
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
|
origin = peripheral_records_origin(items)
|
||||||
|
data = {**data, "provenance_scope": "per_record", "source_origins": sorted({item["origin"] for item in items})}
|
||||||
historical_replay = False
|
historical_replay = False
|
||||||
if query.source in {"peripheral_points", "peripheral_observations"}:
|
if query.source in {"peripheral_points", "peripheral_observations"}:
|
||||||
source_origin = data.get("origin") if isinstance(data, dict) else None
|
source_origin = data.get("origin") if isinstance(data, dict) else None
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.infra.db.metadb.database import close_metadata_engine
|
|||||||
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
|
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
|
||||||
from app.native.wndb.core.connection import close_all_project_pools
|
from app.native.wndb.core.connection import close_all_project_pools
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from samples.peripheral.api import create_app as create_peripheral_app
|
||||||
|
|
||||||
# 导入审计中间件
|
# 导入审计中间件
|
||||||
from app.infra.audit.middleware import AuditMiddleware
|
from app.infra.audit.middleware import AuditMiddleware
|
||||||
@@ -80,6 +81,7 @@ install_problem_details_handlers(app)
|
|||||||
from app.api.v1.endpoints.mcp_queries import router as mcp_context_router
|
from app.api.v1.endpoints.mcp_queries import router as mcp_context_router
|
||||||
app.state.mcp_runtime = mcp_runtime
|
app.state.mcp_runtime = mcp_runtime
|
||||||
app.include_router(mcp_context_router, prefix="/api/v1")
|
app.include_router(mcp_context_router, prefix="/api/v1")
|
||||||
|
app.mount("/peripheral", create_peripheral_app(), name="peripheral")
|
||||||
# Legacy routers without version prefix
|
# Legacy routers without version prefix
|
||||||
# app.include_router(api_router)
|
# app.include_router(api_router)
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,51 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from app.mcp.models import Id, StrictInput
|
from app.mcp.models import Id, StrictInput
|
||||||
|
|
||||||
|
|
||||||
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
|
MAX_REQUEST_TIMEOUT_SECONDS = 240
|
||||||
|
|
||||||
|
|
||||||
class CalculationInput(StrictInput):
|
class CalculationInput(StrictInput):
|
||||||
timeout_seconds: int = Field(
|
timeout_seconds: int = Field(
|
||||||
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
|
default=120, ge=1, le=MAX_REQUEST_TIMEOUT_SECONDS, strict=True,
|
||||||
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
|
description="本次HTTP请求响应等待预算,秒,默认120秒;不是后台计算执行时限,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeCoverageGroup(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
group_id: Id = Field(max_length=80)
|
||||||
|
title: str = Field(min_length=1, max_length=200)
|
||||||
|
node_ids: list[Id] = Field(min_length=1, max_length=10000)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeCoverageInput(CalculationInput):
|
||||||
|
groups: list[NodeCoverageGroup] = Field(min_length=1, max_length=8,
|
||||||
|
description="已核实的模型节点集合;各组为需要比较的业务或空间集合,可重叠,不接受任意坐标")
|
||||||
|
concave_ratio: float = Field(default=0.25, ge=0, le=1, allow_inf_nan=False,
|
||||||
|
description="凹包比例;越接近1越平滑接近凸包,参数只决定示意形状")
|
||||||
|
buffer_m: float = Field(default=25, gt=0, le=1000, allow_inf_nan=False,
|
||||||
|
description="展示缓冲距离,米;不是服务半径或影响距离")
|
||||||
|
split_by_connectivity: bool = Field(default=False, strict=True,
|
||||||
|
description="需要按模型连接分组时设true;地理分布比较默认false,不能据此认定实际供水关系")
|
||||||
|
excluded_link_ids: list[Id] = Field(default_factory=list, max_length=200,
|
||||||
|
description="连通分组时额外视为断开的管段/阀门,例如已有隔离建议中的关阀ID;仅影响绘图分组")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def bounded_groups(self):
|
||||||
|
if len({group.group_id for group in self.groups}) != len(self.groups):
|
||||||
|
raise ValueError("coverage group IDs must be distinct")
|
||||||
|
if len({node for group in self.groups for node in group.node_ids}) > 10000:
|
||||||
|
raise ValueError("coverage supports at most 10000 distinct nodes")
|
||||||
|
if self.excluded_link_ids and not self.split_by_connectivity:
|
||||||
|
raise ValueError("excluded_link_ids requires split_by_connectivity")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class BurstDetectionInput(CalculationInput):
|
class BurstDetectionInput(CalculationInput):
|
||||||
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
|
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
|
||||||
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
|
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
|
||||||
@@ -38,7 +68,7 @@ class BurstDetectionInput(CalculationInput):
|
|||||||
class BurstLocationInput(CalculationInput):
|
class BurstLocationInput(CalculationInput):
|
||||||
data_source: Literal["monitoring", "simulation"] = "monitoring"
|
data_source: Literal["monitoring", "simulation"] = "monitoring"
|
||||||
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
|
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
|
||||||
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
|
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="当前爆管定位算法必需的假定漏水流量,WNTR SI单位m³/s;可用有数据和方法依据的估计或明确给定的情景假设,说明来源与不确定性,不要求现场实测确认;不能任填数值")
|
||||||
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
|
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,不是设备查询编号")
|
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
|
||||||
scada_burst_start: datetime
|
scada_burst_start: datetime
|
||||||
@@ -69,6 +99,7 @@ class BurstLocationInput(CalculationInput):
|
|||||||
|
|
||||||
class ValveIsolationInput(CalculationInput):
|
class ValveIsolationInput(CalculationInput):
|
||||||
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
||||||
|
accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link")
|
||||||
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
|
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ def _calculate(name: str, parameters: dict[str, Any], network: str, username: st
|
|||||||
from app.services.valve_isolation import analyze_valve_isolation
|
from app.services.valve_isolation import analyze_valve_isolation
|
||||||
|
|
||||||
return analyze_valve_isolation(network=network, **parameters)
|
return analyze_valve_isolation(network=network, **parameters)
|
||||||
|
if name == "generate_node_coverage":
|
||||||
|
from app.services.node_coverage import generate_node_coverage
|
||||||
|
|
||||||
|
return generate_node_coverage(network=network, **parameters)
|
||||||
raise ValueError("Calculation is not registered")
|
raise ValueError("Calculation is not registered")
|
||||||
finally:
|
finally:
|
||||||
_slots.release()
|
_slots.release()
|
||||||
@@ -94,3 +98,19 @@ async def bounded_calculation(function, *args, **kwargs):
|
|||||||
finally:
|
finally:
|
||||||
_slots.release()
|
_slots.release()
|
||||||
return await asyncio.to_thread(run)
|
return await asyncio.to_thread(run)
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_replay_burst_location(
|
||||||
|
parameters: dict[str, Any], *, network: str, username: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run location with Server-built frozen replay series and no live SCADA query."""
|
||||||
|
from app.services.burst_location import run_burst_location_by_network
|
||||||
|
|
||||||
|
return await bounded_calculation(
|
||||||
|
run_burst_location_by_network,
|
||||||
|
network=network,
|
||||||
|
username=username,
|
||||||
|
scheme_name=None,
|
||||||
|
final_candidates_csv_path=None,
|
||||||
|
**parameters,
|
||||||
|
)
|
||||||
|
|||||||
+35
-12
@@ -203,7 +203,11 @@ class McpQueryApplication:
|
|||||||
return None
|
return None
|
||||||
schema = tool.model.model_json_schema()
|
schema = tool.model.model_json_schema()
|
||||||
if tool.computation or tool.mutating:
|
if tool.computation or tool.mutating:
|
||||||
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
|
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_request_timeout_seconds
|
||||||
|
schema["properties"]["timeout_seconds"]["default"] = min(
|
||||||
|
self.settings.query_timeout_seconds,
|
||||||
|
self.settings.max_request_timeout_seconds,
|
||||||
|
)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
async def list_tools(self, ctx, params):
|
async def list_tools(self, ctx, params):
|
||||||
@@ -213,6 +217,10 @@ class McpQueryApplication:
|
|||||||
for name in sorted(TOOLS):
|
for name in sorted(TOOLS):
|
||||||
tool = TOOLS[name].protocol_tool()
|
tool = TOOLS[name].protocol_tool()
|
||||||
tool.input_schema.update(self.tool_input_schema(name))
|
tool.input_schema.update(self.tool_input_schema(name))
|
||||||
|
tool.meta = {**(tool.meta or {}), "io.tjwater/request-timeout": {
|
||||||
|
"defaultSeconds": self.settings.query_timeout_seconds,
|
||||||
|
"maxSeconds": self.settings.max_request_timeout_seconds,
|
||||||
|
}}
|
||||||
tools.append(tool)
|
tools.append(tool)
|
||||||
return types.ListToolsResult(
|
return types.ListToolsResult(
|
||||||
tools=tools,
|
tools=tools,
|
||||||
@@ -291,11 +299,13 @@ class McpQueryApplication:
|
|||||||
return tool_error(
|
return tool_error(
|
||||||
"INVALID_QUERY", "Query parameters do not match the tool schema"
|
"INVALID_QUERY", "Query parameters do not match the tool schema"
|
||||||
)
|
)
|
||||||
|
if (tool.computation or tool.mutating) and "timeout_seconds" not in args.model_fields_set:
|
||||||
|
args.timeout_seconds = min(self.settings.query_timeout_seconds, self.settings.max_request_timeout_seconds)
|
||||||
timeout_seconds = (
|
timeout_seconds = (
|
||||||
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_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:
|
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_request_timeout_seconds:
|
||||||
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
|
return tool_error("INVALID_QUERY", "HTTP request timeout exceeds the server limit")
|
||||||
request = ctx.request
|
request = ctx.request
|
||||||
project = request.headers.get("x-project-id", "") if request else ""
|
project = request.headers.get("x-project-id", "") if request else ""
|
||||||
meta = ctx.meta or {}
|
meta = ctx.meta or {}
|
||||||
@@ -347,20 +357,28 @@ class McpQueryApplication:
|
|||||||
)
|
)
|
||||||
result = QueryResult.model_validate(previous["result"])
|
result = QueryResult.model_validate(previous["result"])
|
||||||
else:
|
else:
|
||||||
result = (
|
from app.mcp.calculation_jobs import should_queue, submit_calculation
|
||||||
await self.executor(
|
if should_queue(tool, args):
|
||||||
scope, binding, tool, args, full_snapshot=True
|
result = await submit_calculation(
|
||||||
|
scope, binding, tool, args, call_id,
|
||||||
|
job_timeout_seconds=self.settings.max_calculation_timeout_seconds,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = (
|
||||||
|
await self.executor(
|
||||||
|
scope, binding, tool, args, full_snapshot=True
|
||||||
|
)
|
||||||
|
if include_full
|
||||||
|
else await self.executor(scope, binding, tool, args)
|
||||||
)
|
)
|
||||||
if include_full
|
|
||||||
else await self.executor(scope, binding, tool, args)
|
|
||||||
)
|
|
||||||
# Never serialize an unbounded response into a model message or database receipt.
|
# Never serialize an unbounded response into a model message or database receipt.
|
||||||
if len(result.model_dump_json().encode()) > 8 * 1024 * 1024:
|
if len(result.model_dump_json().encode()) > 8 * 1024 * 1024:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
"RESULT_TOO_LARGE",
|
"RESULT_TOO_LARGE",
|
||||||
"Narrow the query or request a smaller page",
|
"Narrow the query or request a smaller page",
|
||||||
)
|
)
|
||||||
if binding.task_id and name not in {
|
if binding.task_id and not should_queue(tool, args) and name not in {
|
||||||
|
"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs",
|
||||||
"get_task_context",
|
"get_task_context",
|
||||||
"get_task_evidence",
|
"get_task_evidence",
|
||||||
}:
|
}:
|
||||||
@@ -423,6 +441,10 @@ class McpQueryApplication:
|
|||||||
"resource_uri": uri,
|
"resource_uri": uri,
|
||||||
"preview": "The complete query snapshot is saved in task evidence",
|
"preview": "The complete query snapshot is saved in task evidence",
|
||||||
}
|
}
|
||||||
|
from app.mcp.presentation_preview import saved_presentation_preview
|
||||||
|
presentation = saved_presentation_preview(result)
|
||||||
|
if presentation is not None:
|
||||||
|
output["data"]["presentation"] = presentation
|
||||||
output["truncated"] = True
|
output["truncated"] = True
|
||||||
output["data_gaps"] = [
|
output["data_gaps"] = [
|
||||||
*result.data_gaps,
|
*result.data_gaps,
|
||||||
@@ -458,6 +480,7 @@ class McpQueryApplication:
|
|||||||
403: "QUERY_CONTEXT_DENIED",
|
403: "QUERY_CONTEXT_DENIED",
|
||||||
404: "NOT_FOUND",
|
404: "NOT_FOUND",
|
||||||
409: "QUERY_CONTEXT_STALE",
|
409: "QUERY_CONTEXT_STALE",
|
||||||
|
410: "RESULT_EXPIRED",
|
||||||
413: "RESULT_TOO_LARGE",
|
413: "RESULT_TOO_LARGE",
|
||||||
422: "INVALID_QUERY",
|
422: "INVALID_QUERY",
|
||||||
503: "SOURCE_UNAVAILABLE",
|
503: "SOURCE_UNAVAILABLE",
|
||||||
@@ -466,7 +489,7 @@ class McpQueryApplication:
|
|||||||
codes.get(exc.status_code, "QUERY_FAILED"),
|
codes.get(exc.status_code, "QUERY_FAILED"),
|
||||||
str(exc.detail)
|
str(exc.detail)
|
||||||
if isinstance(exc.detail, str)
|
if isinstance(exc.detail, str)
|
||||||
and exc.status_code in {403, 404, 409, 413, 422}
|
and exc.status_code in {403, 404, 409, 410, 413, 422}
|
||||||
else "Query is unavailable",
|
else "Query is unavailable",
|
||||||
)
|
)
|
||||||
except (UndefinedTable, InvalidSchemaName):
|
except (UndefinedTable, InvalidSchemaName):
|
||||||
@@ -475,7 +498,7 @@ class McpQueryApplication:
|
|||||||
"MCP project storage has not been provisioned",
|
"MCP project storage has not been provisioned",
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
|
return tool_error("QUERY_TIMEOUT", "HTTP request timed out")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return tool_error(
|
return tool_error(
|
||||||
"INVALID_QUERY", "Invalid query value or incompatible source data"
|
"INVALID_QUERY", "Invalid query value or incompatible source data"
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Durable MCP calculation submission and owner-scoped result retrieval.
|
||||||
|
|
||||||
|
Only safe server-owned binding fields are persisted. OAuth tokens and context
|
||||||
|
handles are never used to resume a calculation.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid5, NAMESPACE_URL
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from app.mcp.models import BoundContext, QueryResult, PageInfo
|
||||||
|
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
|
||||||
|
|
||||||
|
QUEUED_TOOLS = frozenset({
|
||||||
|
"run_burst_location", "run_leakage_identification", "run_burst_detection",
|
||||||
|
"run_simulation", "run_burst_simulation", "run_contaminant_simulation",
|
||||||
|
"analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation",
|
||||||
|
"optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement",
|
||||||
|
})
|
||||||
|
CONTROL_TOOLS = frozenset({"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"})
|
||||||
|
BINDING_FIELDS = {"project_id", "subject", "client_id", "native_session_id", "purpose", "parent_session_id", "task_id", "run_id", "execution_request_id", "tool_cap", "permission_cap", "task_scope_cap"}
|
||||||
|
|
||||||
|
|
||||||
|
def durable_binding(binding: BoundContext) -> dict:
|
||||||
|
return binding.model_dump(mode="json", include=BINDING_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_binding(document: dict) -> BoundContext:
|
||||||
|
# Expiration governs admission via a context handle; durable work has its own
|
||||||
|
# lifecycle and must revalidate identity, membership and run on every use.
|
||||||
|
return BoundContext.model_validate({**document, "context_handle": "durable-calculation", "expires_at": datetime.max.replace(tzinfo=timezone.utc)})
|
||||||
|
|
||||||
|
|
||||||
|
def should_queue(tool, args) -> bool:
|
||||||
|
return tool.name in QUEUED_TOOLS and (not tool.mutating or not args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def envelope(source: str, data, *, job_id="list", page=None) -> QueryResult:
|
||||||
|
return QueryResult(source=source, source_ref="calculation:" + str(job_id), queried_at=datetime.now(timezone.utc), semantics="task", data=data, page=page, truncated=bool(page and (page.offset or page.has_more)),
|
||||||
|
data_gaps=["Calculation status and queue receipts are not analytical evidence."])
|
||||||
|
|
||||||
|
|
||||||
|
def public_job(row: dict) -> dict:
|
||||||
|
fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error", "queue_expires_at", "result_expires_at"}
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
expiry = row.get("result_expires_at")
|
||||||
|
available = row.get("status") == "succeeded" and (expiry is None or expiry > datetime.now(timezone.utc))
|
||||||
|
return {**jsonable_encoder({key: row[key] for key in fields if key in row}),
|
||||||
|
"result_available": available, "poll_after_ms": 1500}
|
||||||
|
|
||||||
|
|
||||||
|
async def submit_calculation(scope, binding, tool, args, call_id: str, *, job_timeout_seconds: int = 3600) -> QueryResult:
|
||||||
|
from app.mcp.query_service import authorize_query
|
||||||
|
await authorize_query(scope, binding, tool, args)
|
||||||
|
if binding.purpose == "task_reader":
|
||||||
|
raise HTTPException(403, "Read-only task agents cannot start calculations")
|
||||||
|
if tool.mutating:
|
||||||
|
from app.mcp.mutation_service import (
|
||||||
|
preview_change, check_digest, mutation_arguments, retryable_capacity_rejection,
|
||||||
|
)
|
||||||
|
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||||
|
mutation_repo = McpQueryRepository(scope.connection, scope.project.project_id)
|
||||||
|
mutation_key = "mutation:" + binding.subject + ":" + binding.native_session_id
|
||||||
|
approved_parameters = {"tool": tool.name, "arguments": mutation_arguments(args, preview=False)}
|
||||||
|
async with scope.connection.transaction():
|
||||||
|
await mutation_repo.lock_receipt(mutation_key, str(args.request_id))
|
||||||
|
previous = await mutation_repo.receipt(mutation_key, str(args.request_id))
|
||||||
|
if previous:
|
||||||
|
if previous["parameters"] != approved_parameters:
|
||||||
|
raise HTTPException(409, "request_id already has different parameters")
|
||||||
|
if previous["result"].get("status") == "completed":
|
||||||
|
return QueryResult.model_validate(previous["result"]["output"])
|
||||||
|
if not retryable_capacity_rejection(previous["result"]):
|
||||||
|
raise HTTPException(409, "Operation already started; inspect the existing calculation or mutation status")
|
||||||
|
check_digest(args, await preview_change(scope, binding, tool.name, args))
|
||||||
|
parameters = args.model_dump(mode="json", exclude={"context_handle", "timeout_seconds"})
|
||||||
|
request_id = str(args.request_id) if tool.mutating else call_id
|
||||||
|
key = str(uuid5(NAMESPACE_URL, ":".join([binding.subject, binding.client_id, binding.native_session_id, str(binding.task_id), str(binding.run_id), request_id])))
|
||||||
|
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
|
||||||
|
if binding.task_id:
|
||||||
|
from app.mcp.query_service import task_service
|
||||||
|
from app.mcp.contexts import verify_task
|
||||||
|
task_repo = task_service(scope).repo
|
||||||
|
# Share the operational transaction lock with finalization. Admission
|
||||||
|
# cannot race a pending-job check and then publish work into a closed run.
|
||||||
|
async with task_repo.transaction():
|
||||||
|
await task_repo.lock(f"task:{binding.task_id}")
|
||||||
|
await task_repo.lock(f"run:{binding.run_id}")
|
||||||
|
await verify_task(scope, binding)
|
||||||
|
row = await repo.enqueue(
|
||||||
|
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
|
||||||
|
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row = await repo.enqueue(
|
||||||
|
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
|
||||||
|
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
|
||||||
|
)
|
||||||
|
await scope.connection.commit()
|
||||||
|
return envelope(tool.name, public_job(row), job_id=row["job_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def check_binding(binding, row):
|
||||||
|
original = row["binding"]
|
||||||
|
if any(str(original.get(key)) != str(getattr(binding, key)) for key in ("subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id")):
|
||||||
|
raise HTTPException(403, "Calculation belongs to a different session or task run")
|
||||||
|
|
||||||
|
|
||||||
|
async def authorize_original(scope, row):
|
||||||
|
from app.mcp.catalog import TOOLS
|
||||||
|
from app.mcp.query_service import authorize_query
|
||||||
|
from app.mcp.contexts import verify_task
|
||||||
|
original = restore_binding(row["binding"])
|
||||||
|
tool = TOOLS[row["tool"]]
|
||||||
|
# Legacy queue rows stored the old HTTP timeout. It does not govern durable execution.
|
||||||
|
parameters = {key: value for key, value in row["parameters"].items() if key != "timeout_seconds"}
|
||||||
|
args = tool.model.model_validate({**parameters, "context_handle": "durable-calculation-context-handle"})
|
||||||
|
if (str(scope.project.user_id) != str(row["owner_user_id"])
|
||||||
|
or str(scope.project.project_id) != str(original.project_id)):
|
||||||
|
raise HTTPException(403, "Calculation owner is no longer authorized")
|
||||||
|
if original.task_id:
|
||||||
|
await verify_task(scope, original)
|
||||||
|
await authorize_query(scope, original, tool, args)
|
||||||
|
return original, tool, args
|
||||||
|
|
||||||
|
|
||||||
|
async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
|
||||||
|
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
|
||||||
|
if tool.name == "list_calculation_jobs":
|
||||||
|
identity_scope = binding.model_dump(mode="json", include={
|
||||||
|
"subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id",
|
||||||
|
})
|
||||||
|
rows = await repo.list(owner_user_id=scope.project.user_id, binding=identity_scope, limit=args.limit + 1, offset=args.offset, status=args.status)
|
||||||
|
return envelope(tool.name, [public_job(row) for row in rows[:args.limit]], page=PageInfo(limit=args.limit, offset=args.offset, has_more=len(rows) > args.limit))
|
||||||
|
row = await repo.get(args.job_id, owner_user_id=scope.project.user_id,
|
||||||
|
include_result=tool.name == "get_calculation_result")
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Calculation was not found")
|
||||||
|
check_binding(binding, row)
|
||||||
|
await authorize_original(scope, row)
|
||||||
|
if tool.name == "cancel_calculation_job":
|
||||||
|
if binding.purpose == "task_reader":
|
||||||
|
raise HTTPException(403, "Read-only task agents cannot cancel calculations")
|
||||||
|
row = await repo.cancel(args.job_id, owner_user_id=scope.project.user_id)
|
||||||
|
await scope.connection.commit()
|
||||||
|
if tool.name != "get_calculation_result" or row["status"] != "succeeded":
|
||||||
|
return envelope(tool.name, public_job(row), job_id=row["job_id"])
|
||||||
|
expiry = row.get("result_expires_at")
|
||||||
|
if row.get("result") is None or (expiry is not None and expiry <= datetime.now(timezone.utc)):
|
||||||
|
raise HTTPException(410, "Calculation result retention period has expired")
|
||||||
|
result = QueryResult.model_validate(row["result"])
|
||||||
|
result = await attach_calculation_evidence(scope, row, result)
|
||||||
|
value = result.data
|
||||||
|
for part in args.data_path:
|
||||||
|
if isinstance(value, dict) and part in value:
|
||||||
|
value = value[part]
|
||||||
|
else:
|
||||||
|
raise HTTPException(422, "data_path must select an existing object field")
|
||||||
|
if isinstance(value, list):
|
||||||
|
result.data = value[args.offset:args.offset + args.limit]
|
||||||
|
result.page = PageInfo(limit=args.limit, offset=args.offset, total=len(value), has_more=args.offset + args.limit < len(value))
|
||||||
|
result.truncated = result.truncated or args.offset > 0 or result.page.has_more
|
||||||
|
elif args.offset:
|
||||||
|
raise HTTPException(422, "offset requires a collection data_path")
|
||||||
|
else:
|
||||||
|
result.data = value
|
||||||
|
if len(result.model_dump_json().encode()) > 11000:
|
||||||
|
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_calculation_job(scope, row) -> QueryResult:
|
||||||
|
"""Worker entry point; success is persisted by the fenced supervisor."""
|
||||||
|
from app.mcp.query_service import execute_query
|
||||||
|
original, tool, args = await authorize_original(scope, row)
|
||||||
|
result = await execute_query(scope, original, tool, args)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def attach_calculation_evidence(scope, row, result):
|
||||||
|
from app.mcp.query_service import task_service
|
||||||
|
from app.mcp.contexts import verify_task
|
||||||
|
original, tool, _ = await authorize_original(scope, row)
|
||||||
|
if original.task_id:
|
||||||
|
from app.domain.schemas.operational_task import TaskDataSnapshot
|
||||||
|
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||||
|
repo = McpQueryRepository(scope.connection, scope.project.project_id)
|
||||||
|
key, call_id = "calculation:" + str(row["job_id"]), "result"
|
||||||
|
# Result lookup can leave an implicit SELECT transaction open. End it so
|
||||||
|
# evidence and its receipt commit together before returning a response.
|
||||||
|
await scope.connection.commit()
|
||||||
|
task_repo = task_service(scope).repo
|
||||||
|
# Match operational finalization's lock order: event lock, then task.
|
||||||
|
async with task_repo.transaction():
|
||||||
|
await repo.lock_receipt(key, call_id)
|
||||||
|
previous = await repo.receipt(key, call_id)
|
||||||
|
if previous:
|
||||||
|
return QueryResult.model_validate(previous["result"])
|
||||||
|
await task_repo.lock(f"task:{original.task_id}")
|
||||||
|
await verify_task(scope, original)
|
||||||
|
result.source_ref = key
|
||||||
|
evidence, _ = await task_service(scope).record_data_snapshot(original.task_id, TaskDataSnapshot(
|
||||||
|
source=tool.name, source_ref=key, queried_at=result.queried_at, origin=result.origin,
|
||||||
|
data=result.model_dump(mode="json"), truncated=result.truncated))
|
||||||
|
result.evidence_id = evidence.evidence_id
|
||||||
|
await repo.save_receipt(key, call_id, {"job_id": str(row["job_id"])}, result.model_dump(mode="json"))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Bounded inputs for durable computation control."""
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import Field
|
||||||
|
from app.mcp.models import StrictInput, PageInput, Id
|
||||||
|
|
||||||
|
CalculationStatus = Literal["queued", "running", "cancel_requested", "succeeded", "failed", "cancelled", "interrupted", "timed_out"]
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationJobInput(StrictInput):
|
||||||
|
job_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationResultInput(CalculationJobInput):
|
||||||
|
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
||||||
|
limit: int = Field(default=20, ge=1, le=500)
|
||||||
|
offset: int = Field(default=0, ge=0, le=1000000)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationJobsInput(PageInput):
|
||||||
|
status: CalculationStatus | None = None
|
||||||
+18
-6
@@ -4,8 +4,9 @@ from dataclasses import dataclass
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from mcp import types
|
from mcp import types
|
||||||
from app.mcp import models as m
|
from app.mcp import models as m
|
||||||
|
from app.mcp import calculation_models as jobs
|
||||||
from app.mcp import extended_models as e
|
from app.mcp import extended_models as e
|
||||||
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
|
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
|
||||||
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
||||||
|
|
||||||
|
|
||||||
@@ -36,11 +37,15 @@ class QueryTool:
|
|||||||
)
|
)
|
||||||
return types.Tool(
|
return types.Tool(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
description=self.description + suffix,
|
description=self.description + suffix + (
|
||||||
|
"。执行返回持久job_id;排队回执不是分析证据,使用get_calculation_job查询状态、get_calculation_result分页读取成功结果,不重复提交。"
|
||||||
|
if self.name in {"run_burst_location", "run_leakage_identification", "run_burst_detection", "run_simulation", "run_burst_simulation", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement"}
|
||||||
|
else ""
|
||||||
|
),
|
||||||
inputSchema=self.model.model_json_schema(),
|
inputSchema=self.model.model_json_schema(),
|
||||||
outputSchema=m.QueryResult.model_json_schema(),
|
outputSchema=m.QueryResult.model_json_schema(),
|
||||||
annotations=types.ToolAnnotations(
|
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"}
|
readOnlyHint=not (self.computation or self.mutating or self.name == "cancel_calculation_job"), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,8 +59,9 @@ _specs = [
|
|||||||
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.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_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
||||||
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "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),
|
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;burst_leakage必需,可使用有依据的估计或明确给定的情景假设,不要求现场确诊;本工具不反演总漏水量。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
|
||||||
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出不是污染扩散或水力影响范围;现场操作前须核实阀门映射及可用性,本工具不执行关阀", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
||||||
|
("generate_node_coverage", "为当前项目已核实的模型节点集合生成空间覆盖示意,适用于候选分布、监测覆盖比较、区域成员、隔离或模拟结果节点等,不限调查任务。支持最多8组、合计10000个不同节点,从真实GIS查询坐标;监测设备、投诉或地址须先关联模型节点。可按连接分组,记录凹包/缓冲参数、缺失节点和display_only;不计算故障、污染、实际停水或服务边界。返回result.map_payload及coverage,任务中用set_overlay引用本次evidence_id、省略data展示;生成不自动操作地图", NodeCoverageInput, "webgis.view", "analysis", False, True),
|
||||||
(
|
(
|
||||||
"get_element_properties",
|
"get_element_properties",
|
||||||
"读取当前项目的单个管网元素属性",
|
"读取当前项目的单个管网元素属性",
|
||||||
@@ -208,7 +214,7 @@ _specs = [
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"query_peripheral_data",
|
"query_peripheral_data",
|
||||||
"查询有限外围数据源;synthetic是合成证据",
|
"查询外围业务工单及监测数据;外围工单属于系统数据,监测数据按实际来源保留来源信息",
|
||||||
m.PeripheralInput,
|
m.PeripheralInput,
|
||||||
"simulation.view",
|
"simulation.view",
|
||||||
"peripheral",
|
"peripheral",
|
||||||
@@ -256,4 +262,10 @@ _specs += [
|
|||||||
("retry_operational_task", "预览或重新入队已终止任务;不自动启动新Agent会话", e.TaskRetryInput, "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),
|
("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True),
|
||||||
]
|
]
|
||||||
|
_specs += [
|
||||||
|
("get_calculation_job", "读取当前身份与会话的持久计算状态;排队回执不是分析证据", jobs.CalculationJobInput, "webgis.view", "task"),
|
||||||
|
("get_calculation_result", "读取成功计算的原始结果;data_path选择嵌套集合,limit/offset按需分页", jobs.CalculationResultInput, "webgis.view", "task"),
|
||||||
|
("cancel_calculation_job", "请求取消本人当前会话的计算;运行中取消由独立计算进程处理,须继续核实最终状态", jobs.CalculationJobInput, "webgis.view", "task"),
|
||||||
|
("list_calculation_jobs", "分页读取本人当前会话及任务范围的计算状态", jobs.CalculationJobsInput, "webgis.view", "task"),
|
||||||
|
]
|
||||||
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
|
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
|
||||||
|
|||||||
+4
-1
@@ -21,7 +21,8 @@ class McpSettings(BaseSettings):
|
|||||||
)
|
)
|
||||||
allowed_origins: list[str] = Field(default_factory=list)
|
allowed_origins: list[str] = Field(default_factory=list)
|
||||||
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
|
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
|
||||||
query_timeout_seconds: int = Field(default=30, ge=1, le=60)
|
query_timeout_seconds: int = Field(default=120, ge=1, le=240)
|
||||||
|
max_request_timeout_seconds: int = Field(default=240, ge=1, le=240)
|
||||||
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
|
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_prefix="TJWATER_MCP_",
|
env_prefix="TJWATER_MCP_",
|
||||||
@@ -32,6 +33,8 @@ class McpSettings(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_resource(self):
|
def validate_resource(self):
|
||||||
|
if self.query_timeout_seconds > self.max_request_timeout_seconds:
|
||||||
|
raise ValueError("query timeout exceeds the HTTP request timeout limit")
|
||||||
if self.dev_http_issuer_uri:
|
if self.dev_http_issuer_uri:
|
||||||
dev_url = urlsplit(self.dev_http_issuer_uri)
|
dev_url = urlsplit(self.dev_http_issuer_uri)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+2
-1
@@ -30,6 +30,7 @@ async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
|
|||||||
task = await repo.get_task(str(binding.task_id))
|
task = await repo.get_task(str(binding.task_id))
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "Task is not in this project")
|
raise HTTPException(404, "Task is not in this project")
|
||||||
|
binding.replay_context = task.get("source", {}).get("replay_context")
|
||||||
if binding.purpose == "task_collaboration":
|
if binding.purpose == "task_collaboration":
|
||||||
return
|
return
|
||||||
if OPERATIONAL_TASK_EXECUTE not in permissions:
|
if OPERATIONAL_TASK_EXECUTE not in permissions:
|
||||||
@@ -70,7 +71,7 @@ async def bind_context(
|
|||||||
subject=principal.subject,
|
subject=principal.subject,
|
||||||
client_id=principal.client_id,
|
client_id=principal.client_id,
|
||||||
tool_cap=sorted(name for name, tool in TOOLS.items()
|
tool_cap=sorted(name for name, tool in TOOLS.items()
|
||||||
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating))
|
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating or name == "cancel_calculation_job"))
|
||||||
and (not tool.mutating or command.purpose == "ordinary_chat")),
|
and (not tool.mutating or command.purpose == "ordinary_chat")),
|
||||||
permission_cap=sorted(permissions_for_context(scope.project)),
|
permission_cap=sorted(permissions_for_context(scope.project)),
|
||||||
native_session_id=command.native_session_id,
|
native_session_id=command.native_session_id,
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,7 @@ class PageInput(StrictInput):
|
|||||||
|
|
||||||
|
|
||||||
class TaskContextInput(PageInput):
|
class TaskContextInput(PageInput):
|
||||||
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
|
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"] = "summary"
|
||||||
report_revision: int | None = Field(default=None, ge=1)
|
report_revision: int | None = Field(default=None, ge=1)
|
||||||
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
||||||
|
|
||||||
@@ -291,5 +291,6 @@ class BoundContext(BaseModel):
|
|||||||
task_id: UUID | None = None
|
task_id: UUID | None = None
|
||||||
run_id: UUID | None = None
|
run_id: UUID | None = None
|
||||||
execution_request_id: UUID | None = None
|
execution_request_id: UUID | None = None
|
||||||
|
replay_context: dict | None = None
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
revoked_at: datetime | None = None
|
revoked_at: datetime | None = None
|
||||||
|
|||||||
+19
-6
@@ -7,7 +7,12 @@ from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, sca
|
|||||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
|
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
|
||||||
from app.services.content_digest import digest
|
from app.services.content_digest import digest
|
||||||
from samples.peripheral.store import PostgresStore
|
from samples.peripheral.store import PostgresStore
|
||||||
from app.infra.integrations.operational_data import _select_demo_observations
|
from app.infra.integrations.operational_data import (
|
||||||
|
TaskDataError,
|
||||||
|
_select_demo_observations,
|
||||||
|
normalize_peripheral_work_orders,
|
||||||
|
peripheral_records_origin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def read_peripheral_query(scope, args):
|
async def read_peripheral_query(scope, args):
|
||||||
@@ -37,17 +42,25 @@ async def read_peripheral_query(scope, args):
|
|||||||
truncated = len(rows) > args.limit
|
truncated = len(rows) > args.limit
|
||||||
if any(item.get("project_id") != project for item in items):
|
if any(item.get("project_id") != project for item in items):
|
||||||
raise HTTPException(502, "Peripheral project mismatch")
|
raise HTTPException(502, "Peripheral project mismatch")
|
||||||
|
try:
|
||||||
|
if args.source == "peripheral_work_orders":
|
||||||
|
items = normalize_peripheral_work_orders(items)
|
||||||
|
origin = "system_data"
|
||||||
|
else:
|
||||||
|
origin = peripheral_records_origin(items)
|
||||||
|
except TaskDataError as exc:
|
||||||
|
raise HTTPException(exc.status, exc.detail) from exc
|
||||||
data = {
|
data = {
|
||||||
"items": items,
|
"items": items,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"limit": args.limit,
|
"limit": args.limit,
|
||||||
"offset": args.offset,
|
"offset": args.offset,
|
||||||
}
|
}
|
||||||
origin = (
|
if args.source == "peripheral_test_data":
|
||||||
"synthetic"
|
data.update(
|
||||||
if items and any(item.get("origin") == "synthetic" for item in items)
|
provenance_scope="per_record",
|
||||||
else "system_data"
|
source_origins=sorted({item["origin"] for item in items}),
|
||||||
)
|
)
|
||||||
elif args.source == "peripheral_points":
|
elif args.source == "peripheral_points":
|
||||||
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
|
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
|
||||||
data = {
|
data = {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Keep a saved geometry discoverable even when its inline response is shortened."""
|
||||||
|
|
||||||
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
|
|
||||||
|
from app.mcp.models import QueryResult
|
||||||
|
|
||||||
|
|
||||||
|
def saved_presentation_preview(result: QueryResult) -> dict | None:
|
||||||
|
if result.source != "generate_node_coverage" or not result.evidence_id:
|
||||||
|
return None
|
||||||
|
analysis = result.data.get("result") if isinstance(result.data, dict) else None
|
||||||
|
if not isinstance(analysis, dict):
|
||||||
|
return None
|
||||||
|
payload = analysis.get("map_payload")
|
||||||
|
if not isinstance(payload, dict) or not payload.get("features"):
|
||||||
|
return None
|
||||||
|
coverage = analysis.get("coverage", {})
|
||||||
|
return {
|
||||||
|
"command": {
|
||||||
|
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
|
||||||
|
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}",
|
||||||
|
"title": "节点空间覆盖示意",
|
||||||
|
},
|
||||||
|
"display_only": True,
|
||||||
|
"coverage_status": coverage.get("status"),
|
||||||
|
"groups": {role: {
|
||||||
|
"node_count": len(group.get("node_ids", [])),
|
||||||
|
"mapped_node_count": group.get("mapped_node_count", 0),
|
||||||
|
"missing_node_count": len(group.get("missing_node_ids", [])),
|
||||||
|
} for role, group in coverage.get("groups", {}).items()},
|
||||||
|
}
|
||||||
@@ -66,8 +66,23 @@ def page_result(rows, args):
|
|||||||
async def authorize_query(
|
async def authorize_query(
|
||||||
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
|
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
|
||||||
) -> None:
|
) -> None:
|
||||||
|
replay_context = getattr(binding, "replay_context", None)
|
||||||
if tool.name not in binding.tool_cap:
|
if tool.name not in binding.tool_cap:
|
||||||
raise HTTPException(403, "Query is outside the captured tool catalog")
|
raise HTTPException(403, "Query is outside the captured tool catalog")
|
||||||
|
if replay_context:
|
||||||
|
from app.services.scada_replay_queries import check_replay_window
|
||||||
|
allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
|
||||||
|
"query_scada_readings", "get_network_schema", "run_burst_location",
|
||||||
|
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
|
||||||
|
if tool.name not in allowed:
|
||||||
|
raise HTTPException(403, "回放调查当前仅开放冻结 SCADA 证据与模型目录查询;请使用 query_scada_readings")
|
||||||
|
if tool.name == "query_scada_readings":
|
||||||
|
check_replay_window(replay_context, args.start, args.end)
|
||||||
|
if tool.name == "run_burst_location":
|
||||||
|
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
|
||||||
|
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
|
||||||
|
check_replay_window(replay_context, args.scada_burst_start, args.scada_burst_end)
|
||||||
|
check_replay_window(replay_context, args.scada_normal_start, args.scada_normal_end)
|
||||||
permissions = permissions_for_context(scope.project)
|
permissions = permissions_for_context(scope.project)
|
||||||
if tool.mutating:
|
if tool.mutating:
|
||||||
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
|
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
|
||||||
@@ -131,6 +146,9 @@ async def execute_query(
|
|||||||
full_snapshot: bool = False,
|
full_snapshot: bool = False,
|
||||||
) -> QueryResult:
|
) -> QueryResult:
|
||||||
await authorize_query(scope, binding, tool, args)
|
await authorize_query(scope, binding, tool, args)
|
||||||
|
if tool.name in {"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"}:
|
||||||
|
from app.mcp.calculation_jobs import read_calculation_job
|
||||||
|
return await read_calculation_job(scope, binding, tool, args)
|
||||||
if tool.mutating:
|
if tool.mutating:
|
||||||
from app.mcp.mutation_service import execute_mutation
|
from app.mcp.mutation_service import execute_mutation
|
||||||
return await execute_mutation(scope, binding, tool, args)
|
return await execute_mutation(scope, binding, tool, args)
|
||||||
@@ -146,7 +164,37 @@ async def execute_query(
|
|||||||
gaps = []
|
gaps = []
|
||||||
source_ref = "mcp:" + str(uuid4())
|
source_ref = "mcp:" + str(uuid4())
|
||||||
evidence_id = None
|
evidence_id = None
|
||||||
if tool.computation:
|
replay_context = getattr(binding, "replay_context", None)
|
||||||
|
if name == "query_scada_readings" and replay_context:
|
||||||
|
from app.services.scada_replay_queries import replay_snapshot_rows
|
||||||
|
rows = await replay_snapshot_rows(conn, scope.project.project_id, replay_context,
|
||||||
|
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
|
||||||
|
data, page, truncated = page_result(rows, args)
|
||||||
|
origin = "synthetic"
|
||||||
|
gaps.append("历史数据回放证据,仅覆盖该轮调查已提交的批次,不表示现场实时观测。")
|
||||||
|
elif name == "run_burst_location" and replay_context:
|
||||||
|
from app.mcp.analysis_service import calculate_replay_burst_location
|
||||||
|
from app.services.scada_replay_queries import replay_burst_location_inputs
|
||||||
|
|
||||||
|
parameters = await replay_burst_location_inputs(
|
||||||
|
conn, scope.project.project_id, replay_context, args,
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
async with scope.simulation_routing():
|
||||||
|
result = await calculate_replay_burst_location(
|
||||||
|
parameters,
|
||||||
|
network=scope.project.project_code,
|
||||||
|
username=scope.user.username,
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
origin = "synthetic"
|
||||||
|
gaps = [
|
||||||
|
"定位只使用任务知识截止前已固化的回放清洗输出;候选排序依赖漏量假设、模型和测点覆盖,仍需现场核实。"
|
||||||
|
]
|
||||||
|
elif tool.computation:
|
||||||
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
|
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
|
||||||
|
|
||||||
# Context/receipt reads may have started an implicit transaction. Release
|
# Context/receipt reads may have started an implicit transaction. Release
|
||||||
@@ -176,6 +224,7 @@ async def execute_query(
|
|||||||
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
|
"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.",
|
"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.",
|
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
|
||||||
|
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
|
||||||
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
|
"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.",
|
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
|
||||||
}[name]]
|
}[name]]
|
||||||
@@ -409,14 +458,14 @@ async def execute_query(
|
|||||||
data["display_time_zone"] = "Asia/Shanghai"
|
data["display_time_zone"] = "Asia/Shanghai"
|
||||||
data["approved_data_scopes"] = binding.task_scope_cap
|
data["approved_data_scopes"] = binding.task_scope_cap
|
||||||
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
|
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
|
||||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"]
|
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
||||||
truncated = True
|
truncated = True
|
||||||
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
||||||
else:
|
else:
|
||||||
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
|
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)
|
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
|
||||||
total = projection["total"]
|
total = projection["total"]
|
||||||
if args.section in {"evidence", "actions", "access_requests"} and not args.data_path:
|
if args.section in {"evidence", "actions", "access_requests", "questions"} and not args.data_path:
|
||||||
total = projection["snapshot_metadata"].get("total", total)
|
total = projection["snapshot_metadata"].get("total", total)
|
||||||
if projection["value_type"] in {"array", "object", "string"}:
|
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)
|
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import yaml
|
|||||||
|
|
||||||
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
||||||
WORKFLOW_SKILLS = {
|
WORKFLOW_SKILLS = {
|
||||||
|
"scada_anomaly": "scada-anomaly",
|
||||||
"burst_report": "burst-report",
|
"burst_report": "burst-report",
|
||||||
"water_quality_complaint": "water-quality-complaint",
|
"water_quality_complaint": "water-quality-complaint",
|
||||||
"general": "appointment-follow-up",
|
"general": "appointment-follow-up",
|
||||||
|
|||||||
@@ -201,6 +201,8 @@ def temporary_project_name(project: str, purpose: str) -> str:
|
|||||||
def temporary_project_database(project: str, purpose: str):
|
def temporary_project_database(project: str, purpose: str):
|
||||||
"""Clone one project's runnable model into an isolated temporary database."""
|
"""Clone one project's runnable model into an isolated temporary database."""
|
||||||
temporary_name = temporary_project_name(project, purpose)
|
temporary_name = temporary_project_name(project, purpose)
|
||||||
|
from app.services.calculation_cleanup import record_calculation_database
|
||||||
|
record_calculation_database(temporary_name)
|
||||||
try:
|
try:
|
||||||
copy_project(
|
copy_project(
|
||||||
get_project_template_database_name(project),
|
get_project_template_database_name(project),
|
||||||
@@ -211,12 +213,15 @@ def temporary_project_database(project: str, purpose: str):
|
|||||||
finally:
|
finally:
|
||||||
if have_project(temporary_name):
|
if have_project(temporary_name):
|
||||||
delete_project(temporary_name)
|
delete_project(temporary_name)
|
||||||
|
record_calculation_database(temporary_name, removed=True)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def temporary_template_database(name_hint: str, purpose: str):
|
def temporary_template_database(name_hint: str, purpose: str):
|
||||||
"""Create an empty schema-only temporary database from the fixed template."""
|
"""Create an empty schema-only temporary database from the fixed template."""
|
||||||
temporary_name = temporary_project_name(name_hint, purpose)
|
temporary_name = temporary_project_name(name_hint, purpose)
|
||||||
|
from app.services.calculation_cleanup import record_calculation_database
|
||||||
|
record_calculation_database(temporary_name)
|
||||||
try:
|
try:
|
||||||
copy_project(
|
copy_project(
|
||||||
get_schema_template_database_name(),
|
get_schema_template_database_name(),
|
||||||
@@ -227,6 +232,7 @@ def temporary_template_database(name_hint: str, purpose: str):
|
|||||||
finally:
|
finally:
|
||||||
if have_project(temporary_name):
|
if have_project(temporary_name):
|
||||||
delete_project(temporary_name)
|
delete_project(temporary_name)
|
||||||
|
record_calculation_database(temporary_name, removed=True)
|
||||||
|
|
||||||
|
|
||||||
def have_project(name: str) -> bool:
|
def have_project(name: str) -> bool:
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
"""Read-only projections backed by the GIS materialized-view query layer."""
|
"""Read-only GIS projections and authoritative network topology snapshots."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..core.database import read, read_all
|
from ..core.database import read, read_all
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_geographic_positions(name: str, node_ids: list[str]) -> list[Any]:
|
||||||
|
"""Resolve only the selected project's stored node geometries to WGS84."""
|
||||||
|
if not node_ids:
|
||||||
|
return []
|
||||||
|
return read_all(name, """
|
||||||
|
SELECT node_id AS id,
|
||||||
|
ST_X(ST_Transform(geom,4326)) AS longitude,
|
||||||
|
ST_Y(ST_Transform(geom,4326)) AS latitude
|
||||||
|
FROM gis.node_geometries
|
||||||
|
WHERE node_id = ANY(%s) AND geom IS NOT NULL AND NOT ST_IsEmpty(geom)
|
||||||
|
ORDER BY node_id
|
||||||
|
""", (node_ids,))
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_coverage_links(name: str, node_ids: list[str]) -> list[Any]:
|
||||||
|
"""Read the selected subgraph and initial statuses without changing the model."""
|
||||||
|
if not node_ids:
|
||||||
|
return []
|
||||||
|
return read_all(name, """
|
||||||
|
SELECT l.id, l.start_node_id, l.end_node_id,
|
||||||
|
COALESCE(s.status::text, p.status::text) AS status
|
||||||
|
FROM network.links l
|
||||||
|
LEFT JOIN network.pipes p ON p.link_id = l.id
|
||||||
|
LEFT JOIN network.link_initial_settings s ON s.link_id = l.id
|
||||||
|
WHERE l.start_node_id = ANY(%s) AND l.end_node_id = ANY(%s)
|
||||||
|
ORDER BY l.id
|
||||||
|
""", (node_ids, node_ids))
|
||||||
|
|
||||||
|
|
||||||
def _node_coord_rows(
|
def _node_coord_rows(
|
||||||
rows: list[Any],
|
rows: list[Any],
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
@@ -69,6 +98,46 @@ def get_network_link_nodes(name: str) -> list[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_valve_isolation_topology(name: str) -> tuple[list[str], list[str], list[str]]:
|
||||||
|
"""Read links, closed statuses and sources from one consistent SQL snapshot.
|
||||||
|
|
||||||
|
Isolation must include model links without published geometries, and use
|
||||||
|
initial statuses rather than treating every link as open. UNION ALL keeps
|
||||||
|
source nodes in the same statement snapshot without repeating them for
|
||||||
|
every link or relying on materialized view refresh timing.
|
||||||
|
"""
|
||||||
|
rows = read_all(
|
||||||
|
name,
|
||||||
|
"""
|
||||||
|
SELECT l.id, l.link_type::text AS element_type,
|
||||||
|
l.start_node_id, l.end_node_id,
|
||||||
|
COALESCE(s.status::text, p.status::text) AS status
|
||||||
|
FROM network.links AS l
|
||||||
|
LEFT JOIN network.pipes AS p ON p.link_id = l.id
|
||||||
|
LEFT JOIN network.link_initial_settings AS s ON s.link_id = l.id
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id, n.node_type::text AS element_type,
|
||||||
|
NULL, NULL, NULL
|
||||||
|
FROM network.nodes AS n
|
||||||
|
WHERE n.node_type::text IN ('reservoir', 'tank')
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
link_entries: list[str] = []
|
||||||
|
source_nodes: list[str] = []
|
||||||
|
closed_links: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
element_id = str(row["id"])
|
||||||
|
if row["element_type"] in {"reservoir", "tank"}:
|
||||||
|
source_nodes.append(element_id)
|
||||||
|
else:
|
||||||
|
link_entries.append(
|
||||||
|
f"{element_id}:{row['element_type']}:{row['start_node_id']}:{row['end_node_id']}"
|
||||||
|
)
|
||||||
|
if row["status"] == "CLOSED":
|
||||||
|
closed_links.append(element_id)
|
||||||
|
return link_entries, source_nodes, closed_links
|
||||||
|
|
||||||
|
|
||||||
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
|
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
|
||||||
"""Return large pipes in the established API wire format."""
|
"""Return large pipes in the established API wire format."""
|
||||||
rows = read_all(
|
rows = read_all(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Record only job-owned native databases so abrupt cancellation is recoverable."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def _names(directory: Path) -> list[str]:
|
||||||
|
path = directory / "databases.json"
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
if path.is_symlink() or path.stat().st_size > 65536:
|
||||||
|
raise ValueError("Invalid calculation database ledger")
|
||||||
|
names = json.loads(path.read_text())
|
||||||
|
if not isinstance(names, list) or len(names) > 100 or any(
|
||||||
|
not isinstance(name, str) or not re.fullmatch(r"tjw_tmp_[a-z0-9_]+_[a-f0-9]{32}", name)
|
||||||
|
or len(name) > 63 for name in names
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid calculation database ledger")
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _write(directory: Path, names: list[str]) -> None:
|
||||||
|
path = directory / "databases.json"
|
||||||
|
staging = directory / "databases.json.new"
|
||||||
|
with staging.open("w") as output:
|
||||||
|
json.dump(names, output)
|
||||||
|
output.flush()
|
||||||
|
os.fsync(output.fileno())
|
||||||
|
staging.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def record_calculation_database(name: str, *, removed: bool = False) -> None:
|
||||||
|
"""Called before clone creation and after successful normal cleanup."""
|
||||||
|
root = os.environ.get("TJWATER_CALCULATION_JOB_DIR")
|
||||||
|
if not root:
|
||||||
|
return
|
||||||
|
directory = Path(root)
|
||||||
|
names = _names(directory)
|
||||||
|
if removed:
|
||||||
|
names = [value for value in names if value != name]
|
||||||
|
elif name not in names:
|
||||||
|
names.append(name)
|
||||||
|
_write(directory, names)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_calculation_databases(project_id: str, job_directory: str) -> None:
|
||||||
|
directory = Path(job_directory)
|
||||||
|
names = _names(directory)
|
||||||
|
if not names:
|
||||||
|
return
|
||||||
|
from app.infra.db.metadb.database import SessionLocal
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
|
||||||
|
from app.native.wndb.core.projects import have_project, delete_project
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
metadata = MetadataRepository(session)
|
||||||
|
project = await metadata.get_project_by_id(UUID(str(project_id)))
|
||||||
|
route = await metadata.get_project_db_routing(UUID(str(project_id)), "biz_data")
|
||||||
|
if not project or not route or route.db_type != "postgresql":
|
||||||
|
raise ValueError("Calculation cleanup routing unavailable")
|
||||||
|
routing = ActiveProjectRouting(project.code, route.dsn, project_id=project.id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
with activate_project_routing(routing):
|
||||||
|
for name in names[:]:
|
||||||
|
if have_project(name):
|
||||||
|
delete_project(name)
|
||||||
|
names.remove(name)
|
||||||
|
_write(directory, names)
|
||||||
|
await asyncio.to_thread(cleanup)
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Independent, singleton calculation supervisor and isolated job subprocesses.
|
||||||
|
|
||||||
|
Never imported by API lifespan. Child output contains the original QueryResult;
|
||||||
|
algorithm stdout/stderr is discarded rather than interpreted as a protocol.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerSettings:
|
||||||
|
project_ids: tuple[str, ...]
|
||||||
|
max_concurrency: int = 2
|
||||||
|
poll_seconds: float = 2
|
||||||
|
lease_seconds: int = 30
|
||||||
|
heartbeat_seconds: float = 5
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environment(cls):
|
||||||
|
projects = tuple(dict.fromkeys(str(UUID(p.strip())) for p in
|
||||||
|
os.environ['CALCULATION_WORKER_PROJECT_IDS'].split(',')))
|
||||||
|
result = cls(projects, int(os.getenv('CALCULATION_WORKER_MAX_CONCURRENCY', '2')),
|
||||||
|
float(os.getenv('CALCULATION_WORKER_POLL_SECONDS', '2')),
|
||||||
|
int(os.getenv('CALCULATION_WORKER_LEASE_SECONDS', '30')),
|
||||||
|
float(os.getenv('CALCULATION_WORKER_HEARTBEAT_SECONDS', '5')))
|
||||||
|
if not projects or not 1 <= result.max_concurrency <= 32:
|
||||||
|
raise ValueError('Configure explicit project IDs and concurrency between 1 and 32')
|
||||||
|
if not 0 < result.heartbeat_seconds < result.lease_seconds / 3 or result.poll_seconds <= 0:
|
||||||
|
raise ValueError('Heartbeat must be positive and below one third of the lease')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def queue_repository(project_id):
|
||||||
|
"""Resolve current routing from metadata, never from a job payload."""
|
||||||
|
from app.infra.db.calculation_worker import queue_repository as open_repository
|
||||||
|
|
||||||
|
async with open_repository(project_id) as repository:
|
||||||
|
yield repository
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def execution_scope(row):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from app.mcp.scope import QueryMetadataRepository, QueryScope
|
||||||
|
from app.auth.project_dependencies import (resolve_project_context,
|
||||||
|
resolve_project_business_routing, get_project_pg_connection)
|
||||||
|
from app.infra.db.metadb.database import SessionLocal
|
||||||
|
from app.infra.db.project_routing import activate_project_routing
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
metadata = QueryMetadataRepository(session)
|
||||||
|
user = await metadata.get_user_by_id(UUID(str(row['owner_user_id'])))
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(403, 'Calculation owner is inactive')
|
||||||
|
project = await resolve_project_context(str(row['project_id']), user, metadata)
|
||||||
|
routing = await resolve_project_business_routing(project, metadata)
|
||||||
|
await session.commit()
|
||||||
|
async with asynccontextmanager(get_project_pg_connection)(project, metadata) as conn:
|
||||||
|
await session.commit()
|
||||||
|
with activate_project_routing(routing):
|
||||||
|
yield QueryScope(project, user, conn, routing, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_child(input_path: str, output_path: str):
|
||||||
|
from app.mcp.calculation_jobs import execute_calculation_job
|
||||||
|
row = json.loads(Path(input_path).read_text())
|
||||||
|
try:
|
||||||
|
async with execution_scope(row) as scope:
|
||||||
|
result = await execute_calculation_job(scope, row)
|
||||||
|
payload = {'status': 'succeeded', 'result': result.model_dump(mode='json')}
|
||||||
|
except Exception as exc:
|
||||||
|
# Do not persist exception text: DSNs, SQL, credentials or model data can occur there.
|
||||||
|
category = 'CALCULATION_FAILED'
|
||||||
|
if getattr(exc, 'status_code', None) in (401, 403):
|
||||||
|
category = 'AUTHORIZATION_DENIED'
|
||||||
|
elif getattr(exc, 'status_code', None) == 409:
|
||||||
|
category = 'EXECUTION_CONFLICT'
|
||||||
|
elif getattr(exc, 'status_code', None) in (404, 410):
|
||||||
|
category = 'EXECUTION_CONTEXT_UNAVAILABLE'
|
||||||
|
payload = {'status': 'failed', 'error': {'code': category}}
|
||||||
|
encoded = json.dumps(payload, allow_nan=False)
|
||||||
|
if len(encoded.encode()) > 8 * 1024 * 1024:
|
||||||
|
encoded = json.dumps({'status': 'failed', 'error': {'code': 'RESULT_TOO_LARGE'}})
|
||||||
|
Path(output_path).write_text(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_process_group(process):
|
||||||
|
"""Always kill the group, even if its original leader already exited."""
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), 2)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
# Native pools can ignore SIGTERM or survive the Python leader.
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
await process.wait()
|
||||||
|
if process.stdin is not None:
|
||||||
|
process.stdin.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def job_directory(row):
|
||||||
|
root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs'))
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
directory = Path(tempfile.mkdtemp(prefix=f"{row.get('job_id', 'job')}-", dir=root))
|
||||||
|
directory.chmod(0o700)
|
||||||
|
(directory / 'scope.json').write_text(json.dumps({'project_id': str(row['project_id']), 'job_id': str(row.get('job_id', 'job')),
|
||||||
|
'lease_generation': row.get('lease_generation')}))
|
||||||
|
try:
|
||||||
|
yield str(directory.resolve())
|
||||||
|
finally:
|
||||||
|
# Failed external resource cleanup must remain operator-recoverable.
|
||||||
|
if not (directory / 'cleanup-failed').exists():
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_job_resources(row, directory):
|
||||||
|
from app.services.calculation_cleanup import cleanup_calculation_databases
|
||||||
|
try:
|
||||||
|
await cleanup_calculation_databases(row['project_id'], directory)
|
||||||
|
except BaseException:
|
||||||
|
(Path(directory) / 'cleanup-failed').touch()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def recover_job_resources(project_ids):
|
||||||
|
"""Reconcile only this worker's explicit scopes after obtaining the singleton."""
|
||||||
|
root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs'))
|
||||||
|
if not root.exists():
|
||||||
|
return
|
||||||
|
for directory in root.iterdir():
|
||||||
|
if directory.is_symlink() or not directory.is_dir():
|
||||||
|
continue
|
||||||
|
scope_file = directory / 'scope.json'
|
||||||
|
if not scope_file.exists():
|
||||||
|
continue
|
||||||
|
row = json.loads(scope_file.read_text())
|
||||||
|
if str(row.get('project_id')) not in project_ids:
|
||||||
|
continue
|
||||||
|
job_id = str(UUID(row['job_id']))
|
||||||
|
if not directory.name.startswith(job_id + '-'):
|
||||||
|
raise RuntimeError('Calculation state directory identity is invalid')
|
||||||
|
async with queue_repository(row['project_id']) as repository:
|
||||||
|
stored = await repository.get(job_id)
|
||||||
|
if not stored or stored['lease_generation'] != row['lease_generation']:
|
||||||
|
raise RuntimeError('Calculation state ownership cannot be verified')
|
||||||
|
expiry = stored.get('lease_expires_at')
|
||||||
|
if expiry and expiry > datetime.now(timezone.utc) and stored['status'] in ('running', 'cancel_requested'):
|
||||||
|
raise RuntimeError('Previous calculation lease has not expired')
|
||||||
|
await cleanup_job_resources(row, str(directory.resolve()))
|
||||||
|
async with queue_repository(row['project_id']) as repository:
|
||||||
|
await repository.recover()
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
|
||||||
|
|
||||||
|
class CalculationWorker:
|
||||||
|
def __init__(self, settings, *, repository=queue_repository):
|
||||||
|
self.settings = settings
|
||||||
|
self.repository = repository
|
||||||
|
self.owner = str(uuid4())
|
||||||
|
|
||||||
|
async def run_job(self, row):
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
with job_directory(row) as directory:
|
||||||
|
input_path = Path(directory) / 'input.json'
|
||||||
|
output_path = Path(directory) / 'result.json'
|
||||||
|
input_path.write_text(json.dumps(jsonable_encoder(row)))
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable, '-m', 'app.services.calculation_worker',
|
||||||
|
'--guard', str(input_path), str(output_path), start_new_session=True,
|
||||||
|
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
env={**os.environ, 'TMPDIR': directory, 'TJWATER_CALCULATION_JOB_DIR': directory},
|
||||||
|
)
|
||||||
|
started = time.monotonic()
|
||||||
|
cleaned = False
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
async with asyncio.timeout(self.settings.heartbeat_seconds):
|
||||||
|
async with self.repository(row['project_id']) as repository:
|
||||||
|
status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds)
|
||||||
|
if status is None:
|
||||||
|
return # Finally stops all owned computation on a lost lease.
|
||||||
|
terminal = None
|
||||||
|
if status == 'cancel_requested':
|
||||||
|
terminal = {'status': 'cancelled'}
|
||||||
|
elif time.monotonic() - started >= row['timeout_seconds']:
|
||||||
|
terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}}
|
||||||
|
elif process.returncode is not None:
|
||||||
|
try:
|
||||||
|
if output_path.stat().st_size > 8 * 1024 * 1024:
|
||||||
|
raise ValueError('Result exceeds persistence limit')
|
||||||
|
terminal = json.loads(output_path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}}
|
||||||
|
if terminal:
|
||||||
|
await stop_process_group(process)
|
||||||
|
try:
|
||||||
|
await cleanup_job_resources(row, directory)
|
||||||
|
cleaned = True
|
||||||
|
except Exception:
|
||||||
|
terminal = {'status': 'failed', 'error': {'code': 'CLEANUP_FAILED'}}
|
||||||
|
cleaned = True
|
||||||
|
async with self.repository(row['project_id']) as repository:
|
||||||
|
finished = await repository.finish(row, self.owner, **terminal)
|
||||||
|
if not finished:
|
||||||
|
status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds)
|
||||||
|
if status == 'cancel_requested':
|
||||||
|
await repository.finish(row, self.owner, 'cancelled')
|
||||||
|
return
|
||||||
|
await asyncio.sleep(self.settings.heartbeat_seconds)
|
||||||
|
finally:
|
||||||
|
await stop_process_group(process)
|
||||||
|
if not cleaned:
|
||||||
|
await cleanup_job_resources(row, directory)
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
from app.infra.db.calculation_worker import calculation_supervisor_lease
|
||||||
|
|
||||||
|
# Dedicated session-level metadata lock: independent API workers never
|
||||||
|
# participate; duplicate supervisors exit before claiming any jobs.
|
||||||
|
async with calculation_supervisor_lease() as lease:
|
||||||
|
tasks = set()
|
||||||
|
project_cursor = 0
|
||||||
|
try:
|
||||||
|
# Allow any old supervisor to detect a broken lock connection and
|
||||||
|
# stop its children before this replacement claims capacity.
|
||||||
|
await asyncio.sleep(self.settings.lease_seconds)
|
||||||
|
await recover_job_resources(self.settings.project_ids)
|
||||||
|
while True:
|
||||||
|
async with asyncio.timeout(self.settings.heartbeat_seconds):
|
||||||
|
await lease.ping()
|
||||||
|
for task in tuple(tasks):
|
||||||
|
if task.done():
|
||||||
|
tasks.remove(task)
|
||||||
|
task.result() # Fail closed if heartbeat/persistence failed.
|
||||||
|
projects = self.settings.project_ids
|
||||||
|
ordered = projects[project_cursor:] + projects[:project_cursor]
|
||||||
|
project_cursor = (project_cursor + 1) % len(projects)
|
||||||
|
for project_id in ordered:
|
||||||
|
if len(tasks) >= self.settings.max_concurrency:
|
||||||
|
break
|
||||||
|
async with self.repository(project_id) as repository:
|
||||||
|
await repository.recover()
|
||||||
|
row = await repository.claim(self.owner, self.settings.lease_seconds)
|
||||||
|
if row:
|
||||||
|
tasks.add(asyncio.create_task(self.run_job(row)))
|
||||||
|
await asyncio.sleep(min(self.settings.poll_seconds, self.settings.heartbeat_seconds))
|
||||||
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def guard_child(input_path, output_path):
|
||||||
|
"""A parent-death pipe kills the whole group even when the supervisor is SIGKILLed."""
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
protocol = asyncio.StreamReaderProtocol(reader)
|
||||||
|
transport, _ = await asyncio.get_running_loop().connect_read_pipe(lambda: protocol, sys.stdin)
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable, '-m', 'app.services.calculation_worker', input_path, output_path,
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
completed = asyncio.create_task(process.wait())
|
||||||
|
parent_gone = asyncio.create_task(reader.read(1))
|
||||||
|
try:
|
||||||
|
done, _ = await asyncio.wait((completed, parent_gone), return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
if parent_gone in done:
|
||||||
|
os.killpg(os.getpgrp(), signal.SIGKILL)
|
||||||
|
await completed
|
||||||
|
finally:
|
||||||
|
parent_gone.cancel()
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if sys.argv[1] == '--guard':
|
||||||
|
asyncio.run(guard_child(sys.argv[2], sys.argv[3]))
|
||||||
|
else:
|
||||||
|
asyncio.run(execute_child(sys.argv[1], sys.argv[2]))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Reusable geometric coverage of verified project nodes, independent of business analysis."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from math import isfinite
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pyproj import CRS, Transformer
|
||||||
|
from shapely import MultiPoint, concave_hull, get_num_coordinates
|
||||||
|
from shapely.geometry import mapping
|
||||||
|
from shapely.ops import transform, unary_union
|
||||||
|
|
||||||
|
from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions
|
||||||
|
|
||||||
|
CONCAVE_RATIO = 0.25
|
||||||
|
BUFFER_METERS = 25.0
|
||||||
|
SIMPLIFY_METERS = 2.0
|
||||||
|
MAX_COVERAGE_NODES = 10000
|
||||||
|
|
||||||
|
|
||||||
|
def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]:
|
||||||
|
parent = {node_id: node_id for node_id in node_ids}
|
||||||
|
|
||||||
|
def root(node: str) -> str:
|
||||||
|
while parent[node] != node:
|
||||||
|
parent[node] = parent[parent[node]]
|
||||||
|
node = parent[node]
|
||||||
|
return node
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
start, end = link["start_node_id"], link["end_node_id"]
|
||||||
|
if link["id"] not in closed and link.get("status") != "CLOSED" and start in parent and end in parent:
|
||||||
|
parent[root(start)] = root(end)
|
||||||
|
groups: dict[str, list[str]] = {}
|
||||||
|
for node_id in node_ids:
|
||||||
|
groups.setdefault(root(node_id), []).append(node_id)
|
||||||
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
def build_node_coverage(
|
||||||
|
groups: list[dict[str, Any]], positions: list[dict[str, Any]],
|
||||||
|
links: list[dict[str, Any]], *, split_by_connectivity: bool = False,
|
||||||
|
excluded_link_ids: list[str] | None = None,
|
||||||
|
concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Keep caller-defined comparison groups and derive geometry without assigning business meaning."""
|
||||||
|
coordinates = {}
|
||||||
|
for row in positions:
|
||||||
|
longitude, latitude = row.get("longitude"), row.get("latitude")
|
||||||
|
if (isinstance(longitude, (int, float)) and not isinstance(longitude, bool)
|
||||||
|
and isinstance(latitude, (int, float)) and not isinstance(latitude, bool)
|
||||||
|
and isfinite(longitude) and isfinite(latitude)
|
||||||
|
and -180 <= longitude <= 180 and -90 < latitude < 90):
|
||||||
|
coordinates[str(row["id"])] = (longitude, latitude)
|
||||||
|
|
||||||
|
features, groups_metadata = [], {}
|
||||||
|
closed = set(excluded_link_ids or [])
|
||||||
|
simplify_m = min(SIMPLIFY_METERS, buffer_m / 4)
|
||||||
|
for group in groups:
|
||||||
|
group_id, label = group["group_id"], group["title"]
|
||||||
|
ids = sorted(set(group["node_ids"]))
|
||||||
|
mapped = [node for node in ids if node in coordinates]
|
||||||
|
missing = [node for node in ids if node not in coordinates]
|
||||||
|
metadata = {"node_ids": ids, "mapped_node_count": len(mapped), "missing_node_ids": missing}
|
||||||
|
groups_metadata[group_id] = {"title": label, **metadata}
|
||||||
|
metadata = groups_metadata[group_id]
|
||||||
|
if not mapped:
|
||||||
|
metadata["component_count"] = 0
|
||||||
|
continue
|
||||||
|
points = [coordinates[node] for node in mapped]
|
||||||
|
longitude = sum(point[0] for point in points) / len(points)
|
||||||
|
latitude = sum(point[1] for point in points) / len(points)
|
||||||
|
local = CRS.from_proj4(
|
||||||
|
f"+proj=aeqd +lat_0={latitude} +lon_0={longitude} +datum=WGS84 +units=m"
|
||||||
|
)
|
||||||
|
project = Transformer.from_crs(4326, local, always_xy=True).transform
|
||||||
|
unproject = Transformer.from_crs(local, 4326, always_xy=True).transform
|
||||||
|
envelopes = []
|
||||||
|
components = _components(ids, links, closed) if split_by_connectivity else [ids]
|
||||||
|
for nodes in components:
|
||||||
|
component_points = [coordinates[node] for node in nodes if node in coordinates]
|
||||||
|
if not component_points:
|
||||||
|
continue
|
||||||
|
cloud = transform(project, MultiPoint(component_points))
|
||||||
|
# A point or collinear cloud remains drawable through the same metric buffer.
|
||||||
|
envelope = concave_hull(cloud, ratio=concave_ratio).buffer(buffer_m)
|
||||||
|
envelopes.append(envelope)
|
||||||
|
metadata["component_count"] = len(envelopes)
|
||||||
|
geometry = unary_union(envelopes).simplify(simplify_m, preserve_topology=True)
|
||||||
|
if geometry.is_empty or not geometry.is_valid or get_num_coordinates(geometry) > 20000:
|
||||||
|
raise ValueError("Invalid derived coverage")
|
||||||
|
feature = {
|
||||||
|
"type": "Feature", "id": group_id,
|
||||||
|
"properties": {
|
||||||
|
"coverage_group": group_id, "label": label, "derived": True, "display_only": True,
|
||||||
|
"node_count": len(ids), "mapped_node_count": len(mapped),
|
||||||
|
"missing_node_count": len(missing), "component_count": len(envelopes),
|
||||||
|
},
|
||||||
|
"geometry": mapping(transform(unproject, geometry)),
|
||||||
|
}
|
||||||
|
features.append(feature)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values())
|
||||||
|
else "ready" if features else "empty",
|
||||||
|
"method": "concave_hull_buffer",
|
||||||
|
"parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m,
|
||||||
|
"split_by_connectivity": split_by_connectivity,
|
||||||
|
"excluded_link_ids": sorted(closed),
|
||||||
|
"simplify_m": simplify_m, "projection": "local_azimuthal_equidistant"},
|
||||||
|
"output_crs": "EPSG:4326", "display_only": True,
|
||||||
|
"position_source": "gis.node_geometries",
|
||||||
|
"boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。",
|
||||||
|
"groups": groups_metadata,
|
||||||
|
"map_payload": {"type": "FeatureCollection", "features": features},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_node_coverage(network: str, groups: list[dict[str, Any]], **parameters: Any) -> dict[str, Any]:
|
||||||
|
node_ids = sorted({node for group in groups for node in group["node_ids"]})
|
||||||
|
if len(node_ids) > MAX_COVERAGE_NODES:
|
||||||
|
raise ValueError("Node coverage supports at most 10000 distinct nodes")
|
||||||
|
positions = get_node_geographic_positions(network, node_ids)
|
||||||
|
links = get_node_coverage_links(network, node_ids) if parameters.get("split_by_connectivity") else []
|
||||||
|
coverage = build_node_coverage(groups, positions, links, **parameters)
|
||||||
|
payload = coverage.pop("map_payload")
|
||||||
|
if len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) > 400 * 1024:
|
||||||
|
raise ValueError("Coverage exceeds presentation budget; narrow the node selection")
|
||||||
|
return {"coverage": coverage, "map_payload": payload}
|
||||||
@@ -51,12 +51,19 @@ def _geometry(value):
|
|||||||
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
||||||
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
|
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
|
||||||
data = snapshot.data
|
data = snapshot.data
|
||||||
|
# MCP persists its complete QueryResult envelope, unlike direct data queries.
|
||||||
|
if (isinstance(data, dict) and data.get("source") == snapshot.source
|
||||||
|
and isinstance(data.get("data"), (dict, list))):
|
||||||
|
data = data["data"]
|
||||||
candidates = [data]
|
candidates = [data]
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
|
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
|
||||||
latest = data.get("latest_analysis")
|
latest = data.get("latest_analysis")
|
||||||
if isinstance(latest, dict):
|
if isinstance(latest, dict):
|
||||||
candidates.append(latest.get("map_payload"))
|
candidates.append(latest.get("map_payload"))
|
||||||
|
result = data.get("result")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
candidates.append(result.get("map_payload"))
|
||||||
collection = next((item for item in candidates
|
collection = next((item for item in candidates
|
||||||
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
|
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
|
||||||
if collection is not None:
|
if collection is not None:
|
||||||
@@ -88,7 +95,7 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
|||||||
properties = feature.get("properties") or {}
|
properties = feature.get("properties") or {}
|
||||||
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
|
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
|
||||||
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
|
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
|
||||||
if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool):
|
if isinstance(feature.get("id"), str) or _number(feature.get("id")):
|
||||||
normalized["id"] = feature["id"]
|
normalized["id"] = feature["id"]
|
||||||
if mode == "heatmap":
|
if mode == "heatmap":
|
||||||
weight = properties.get("weight", 1)
|
weight = properties.get("weight", 1)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.auth.permissions import (
|
|||||||
SIMULATION_VIEW,
|
SIMULATION_VIEW,
|
||||||
)
|
)
|
||||||
from app.domain.domain_errors import DomainError, require
|
from app.domain.domain_errors import DomainError, require
|
||||||
|
from app.services import task_questions
|
||||||
from app.domain.schemas.operational_task import (
|
from app.domain.schemas.operational_task import (
|
||||||
ActionDecisionCommand,
|
ActionDecisionCommand,
|
||||||
ActionExecuteCommand,
|
ActionExecuteCommand,
|
||||||
@@ -65,6 +66,8 @@ from app.domain.schemas.operational_task import (
|
|||||||
TaskPresentationEventCommand,
|
TaskPresentationEventCommand,
|
||||||
TaskPresentationEventResult,
|
TaskPresentationEventResult,
|
||||||
TaskPresentationCursor,
|
TaskPresentationCursor,
|
||||||
|
TaskPresentationHistoryItem,
|
||||||
|
TaskPresentationHistoryPage,
|
||||||
)
|
)
|
||||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||||
from app.services.content_digest import digest
|
from app.services.content_digest import digest
|
||||||
@@ -97,6 +100,15 @@ class OperationalTaskService:
|
|||||||
def _actor(self) -> str:
|
def _actor(self) -> str:
|
||||||
return f"principal:{self.context.user_id}"
|
return f"principal:{self.context.user_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_run_owner(run: dict, command: AgentRunCommand) -> None:
|
||||||
|
require(run.get("agent_session_id") == command.source_session_id,
|
||||||
|
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
||||||
|
owner = run.get("agent_instance_id")
|
||||||
|
if owner is not None:
|
||||||
|
require(owner == command.agent_instance_id,
|
||||||
|
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _supports_runs(self) -> bool:
|
def _supports_runs(self) -> bool:
|
||||||
return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run")
|
return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run")
|
||||||
@@ -138,6 +150,7 @@ class OperationalTaskService:
|
|||||||
permissions = self.permissions
|
permissions = self.permissions
|
||||||
return OperationalTaskPermissions(
|
return OperationalTaskPermissions(
|
||||||
can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions,
|
can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions,
|
||||||
|
can_answer_questions=OPERATIONAL_TASK_WRITE in permissions,
|
||||||
can_propose_actions=OPERATIONAL_TASK_WRITE in permissions,
|
can_propose_actions=OPERATIONAL_TASK_WRITE in permissions,
|
||||||
can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions,
|
can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions,
|
||||||
can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions,
|
can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions,
|
||||||
@@ -148,6 +161,14 @@ class OperationalTaskService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail:
|
async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail:
|
||||||
|
if command.source.replay_context:
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
replay_context = command.source.replay_context
|
||||||
|
replay_doc = await ScadaReplayRepository(self.repo.connection, self.context.project_id).get(replay_context.replay_id)
|
||||||
|
require(replay_doc.get("delivered_until") is not None
|
||||||
|
and replay_context.knowledge_cutoff <= datetime.fromisoformat(replay_doc["delivered_until"])
|
||||||
|
and replay_context.batch_sequence <= replay_doc["batch_sequence"],
|
||||||
|
"REPLAY_SCOPE_INVALID", "回放调查不能使用尚未读取的批次", status=422)
|
||||||
body = _json(command)
|
body = _json(command)
|
||||||
request_id = str(command.request_id)
|
request_id = str(command.request_id)
|
||||||
scope = "task:create"
|
scope = "task:create"
|
||||||
@@ -206,6 +227,27 @@ class OperationalTaskService:
|
|||||||
await self._task_event(task_document, now)
|
await self._task_event(task_document, now)
|
||||||
return await self.detail(task_id)
|
return await self.detail(task_id)
|
||||||
|
|
||||||
|
async def advance_replay_investigation(self, task_id: UUID, replay_context: dict, request_id: UUID) -> None:
|
||||||
|
"""A recovery is new evidence, coalesced through the existing run lifecycle."""
|
||||||
|
scope = f"task:{task_id}:replay-evidence"
|
||||||
|
async with self.repo.transaction():
|
||||||
|
if await self._request_replay(scope, request_id, replay_context):
|
||||||
|
return
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
|
task = await self._task(task_id)
|
||||||
|
prior = task["source"].get("replay_context")
|
||||||
|
require(prior and prior["replay_id"] == replay_context["replay_id"], "REPLAY_SCOPE_INVALID",
|
||||||
|
"Replay evidence belongs to another investigation", status=409)
|
||||||
|
require(datetime.fromisoformat(prior["knowledge_cutoff"]) <= datetime.fromisoformat(replay_context["knowledge_cutoff"]),
|
||||||
|
"REPLAY_SCOPE_INVALID", "Replay evidence cannot move backwards", status=409)
|
||||||
|
task["source"]["replay_context"] = replay_context
|
||||||
|
task["source"]["external_revision"] = task["source"].get("external_revision", 1) + 1
|
||||||
|
now = self._now()
|
||||||
|
await self._requeue_task(task, now)
|
||||||
|
await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task)))
|
||||||
|
await self._task_event(task, now)
|
||||||
|
await self._save_request(scope, request_id, replay_context, {"task_id": str(task_id)})
|
||||||
|
|
||||||
async def list(
|
async def list(
|
||||||
self, *, task_type: str | None, status: str | None, limit: int, offset: int,
|
self, *, task_type: str | None, status: str | None, limit: int, offset: int,
|
||||||
peripheral_work_order_id: UUID | None = None,
|
peripheral_work_order_id: UUID | None = None,
|
||||||
@@ -258,6 +300,7 @@ class OperationalTaskService:
|
|||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
actions=actions,
|
actions=actions,
|
||||||
access_requests=access_requests,
|
access_requests=access_requests,
|
||||||
|
questions=await task_questions.list_questions(self, task_id),
|
||||||
permissions=self._permission_view(),
|
permissions=self._permission_view(),
|
||||||
work_order_snapshot=work_order_snapshot,
|
work_order_snapshot=work_order_snapshot,
|
||||||
)
|
)
|
||||||
@@ -322,6 +365,9 @@ class OperationalTaskService:
|
|||||||
selected_report=await self.report(task_id, report_revision) if report_revision is not None else None,
|
selected_report=await self.report(task_id, report_revision) if report_revision is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def replay_context(self, task_id: UUID) -> dict | None:
|
||||||
|
return (await self._task(task_id)).get("source", {}).get("replay_context")
|
||||||
|
|
||||||
async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool:
|
async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool:
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW
|
permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW
|
||||||
@@ -483,6 +529,7 @@ class OperationalTaskService:
|
|||||||
status="running",
|
status="running",
|
||||||
revision=expected + 1,
|
revision=expected + 1,
|
||||||
agent_session_id=command.source_session_id,
|
agent_session_id=command.source_session_id,
|
||||||
|
agent_instance_id=command.agent_instance_id,
|
||||||
lease_expires_at=now + LEASE_DURATION,
|
lease_expires_at=now + LEASE_DURATION,
|
||||||
heartbeat_at=now,
|
heartbeat_at=now,
|
||||||
started_at=run.get("started_at") or now,
|
started_at=run.get("started_at") or now,
|
||||||
@@ -527,8 +574,7 @@ class OperationalTaskService:
|
|||||||
"Run revision changed", status=409)
|
"Run revision changed", status=409)
|
||||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||||
"Only an active run has a lease", status=409)
|
"Only an active run has a lease", status=409)
|
||||||
require(run.get("agent_session_id") == command.source_session_id,
|
self._require_run_owner(run, command)
|
||||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
|
||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(run, now)
|
self._require_active_run_lease(run, now)
|
||||||
expected = run["revision"]
|
expected = run["revision"]
|
||||||
@@ -580,6 +626,7 @@ class OperationalTaskService:
|
|||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return await self.detail(task_id)
|
return await self.detail(task_id)
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"run:{run_id}")
|
await self.repo.lock(f"run:{run_id}")
|
||||||
run = await self._run(task_id, run_id)
|
run = await self._run(task_id, run_id)
|
||||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||||
@@ -587,6 +634,7 @@ class OperationalTaskService:
|
|||||||
require(run["status"] in {"queued", "claimed", "running"},
|
require(run["status"] in {"queued", "claimed", "running"},
|
||||||
"INVALID_TRANSITION", "Run is already terminal", status=409)
|
"INVALID_TRANSITION", "Run is already terminal", status=409)
|
||||||
now = self._now()
|
now = self._now()
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
expected = run["revision"]
|
expected = run["revision"]
|
||||||
run.update(
|
run.update(
|
||||||
status="cancelled", revision=expected + 1, finished_at=now,
|
status="cancelled", revision=expected + 1, finished_at=now,
|
||||||
@@ -624,6 +672,7 @@ class OperationalTaskService:
|
|||||||
require(await self.repo.get_active_run(str(task_id)) is None,
|
require(await self.repo.get_active_run(str(task_id)) is None,
|
||||||
"ACTIVE_RUN_EXISTS", "Task already has an active run", status=409)
|
"ACTIVE_RUN_EXISTS", "Task already has an active run", status=409)
|
||||||
now = self._now()
|
now = self._now()
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
generation = task["execution_generation"] + 1
|
generation = task["execution_generation"] + 1
|
||||||
run = self._new_run(task_id, generation, now)
|
run = self._new_run(task_id, generation, now)
|
||||||
await self.repo.insert_run(_json(run))
|
await self.repo.insert_run(_json(run))
|
||||||
@@ -778,6 +827,10 @@ class OperationalTaskService:
|
|||||||
"Only a running run accepts Agent artifacts", status=409)
|
"Only a running run accepts Agent artifacts", status=409)
|
||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(active_run, now)
|
self._require_active_run_lease(active_run, now)
|
||||||
|
if command.report and (
|
||||||
|
command.report.stage == "final" or command.report.outcome == "insufficient_data"
|
||||||
|
):
|
||||||
|
await self._require_no_pending_calculations(task_id, active_run)
|
||||||
previous_report = await self.repo.get_latest_report(str(task_id))
|
previous_report = await self.repo.get_latest_report(str(task_id))
|
||||||
require(not previous_report or previous_report.get("execution_generation") != task["execution_generation"]
|
require(not previous_report or previous_report.get("execution_generation") != task["execution_generation"]
|
||||||
or previous_report["report"].get("outcome") != "insufficient_data",
|
or previous_report["report"].get("outcome") != "insufficient_data",
|
||||||
@@ -982,6 +1035,27 @@ class OperationalTaskService:
|
|||||||
event_cursor=cursor,
|
event_cursor=cursor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def presentation_history(
|
||||||
|
self, task_id: UUID, *, after: int = 0, through: int | None = None, limit: int = 100,
|
||||||
|
) -> TaskPresentationHistoryPage:
|
||||||
|
require(0 <= after and (through is None or through >= 0) and 1 <= limit <= 200,
|
||||||
|
"INVALID_ARGUMENT", "Invalid presentation history page", status=422)
|
||||||
|
async with self.repo.transaction():
|
||||||
|
await self._task(task_id)
|
||||||
|
cursor = await self.repo.current_event_cursor()
|
||||||
|
boundary = min(through, cursor) if through is not None else cursor
|
||||||
|
rows = await self.repo.list_presentation_events(str(task_id), after, boundary, limit + 1)
|
||||||
|
items = [TaskPresentationHistoryItem(
|
||||||
|
event_seq=row["event_seq"], occurred_at=row["occurred_at"],
|
||||||
|
run_id=row["metadata"]["run_id"],
|
||||||
|
execution_generation=row["metadata"]["execution_generation"],
|
||||||
|
command=row["metadata"]["command"],
|
||||||
|
) for row in rows[:limit]]
|
||||||
|
return TaskPresentationHistoryPage(
|
||||||
|
task_id=task_id, event_cursor=boundary, items=items,
|
||||||
|
next_after=items[-1].event_seq if len(rows) > limit else None,
|
||||||
|
)
|
||||||
|
|
||||||
async def publish_presentation_event(
|
async def publish_presentation_event(
|
||||||
self, task_id: UUID, command: TaskPresentationEventCommand
|
self, task_id: UUID, command: TaskPresentationEventCommand
|
||||||
) -> TaskPresentationEventResult:
|
) -> TaskPresentationEventResult:
|
||||||
@@ -995,6 +1069,8 @@ class OperationalTaskService:
|
|||||||
await self.repo.lock(f"run:{command.run_id}")
|
await self.repo.lock(f"run:{command.run_id}")
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
self._require_actionable_task(task)
|
self._require_actionable_task(task)
|
||||||
|
require(not task.get("source", {}).get("replay_context"), "REPLAY_ACTION_DISABLED",
|
||||||
|
"SCADA 回放调查仅生成分析与报告,不执行真实工单或设备动作", status=409)
|
||||||
run = await self._active_run(task)
|
run = await self._active_run(task)
|
||||||
require(run is not None and str(run["run_id"]) == str(command.run_id)
|
require(run is not None and str(run["run_id"]) == str(command.run_id)
|
||||||
and run["generation"] == task["execution_generation"],
|
and run["generation"] == task["execution_generation"],
|
||||||
@@ -1015,7 +1091,16 @@ class OperationalTaskService:
|
|||||||
evidence = StoredEvidenceRecord.model_validate(stored)
|
evidence = StoredEvidenceRecord.model_validate(stored)
|
||||||
require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND",
|
require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND",
|
||||||
"Overlay requires a Server-ingested immutable data snapshot", status=422)
|
"Overlay requires a Server-ingested immutable data snapshot", status=422)
|
||||||
payload["data"] = resolve_overlay(evidence.data_snapshot, command.command.mode)
|
resolved = resolve_overlay(evidence.data_snapshot, command.command.mode)
|
||||||
|
if command.command.data is not None:
|
||||||
|
supplied = resolve_overlay(
|
||||||
|
evidence.data_snapshot.model_copy(update={
|
||||||
|
"data": command.command.data.model_dump(mode="json", exclude_unset=True),
|
||||||
|
}), command.command.mode,
|
||||||
|
)
|
||||||
|
require(supplied == resolved, "OVERLAY_DATA_MISMATCH",
|
||||||
|
"Overlay data must match the immutable evidence snapshot", status=422)
|
||||||
|
payload["data"] = resolved
|
||||||
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
|
||||||
"execution_generation": run["generation"], "command": payload}
|
"execution_generation": run["generation"], "command": payload}
|
||||||
try:
|
try:
|
||||||
@@ -1866,6 +1951,15 @@ class OperationalTaskService:
|
|||||||
task_ids.append(task_id)
|
task_ids.append(task_id)
|
||||||
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
||||||
|
|
||||||
|
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
|
||||||
|
if hasattr(self.repo, "pending_calculation_jobs"):
|
||||||
|
pending = await self.repo.pending_calculation_jobs(
|
||||||
|
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
|
||||||
|
)
|
||||||
|
require(not pending, "PENDING_CALCULATIONS",
|
||||||
|
"Wait for submitted calculations before publishing a final report or completing the run",
|
||||||
|
refs=pending, status=409, recovery="retry_same_request")
|
||||||
|
|
||||||
async def _finish_run(
|
async def _finish_run(
|
||||||
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
|
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
|
||||||
*, outcome: str, failure_code: str | None = None,
|
*, outcome: str, failure_code: str | None = None,
|
||||||
@@ -1877,19 +1971,23 @@ class OperationalTaskService:
|
|||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return await self.detail(task_id)
|
return await self.detail(task_id)
|
||||||
|
await self.repo.lock(f"task:{task_id}")
|
||||||
await self.repo.lock(f"run:{run_id}")
|
await self.repo.lock(f"run:{run_id}")
|
||||||
run = await self._run(task_id, run_id)
|
run = await self._run(task_id, run_id)
|
||||||
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
|
||||||
"Run revision changed", status=409)
|
"Run revision changed", status=409)
|
||||||
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
|
||||||
"Only an active run can finish", status=409)
|
"Only an active run can finish", status=409)
|
||||||
require(run.get("agent_session_id") == command.source_session_id,
|
self._require_run_owner(run, command)
|
||||||
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
|
|
||||||
now = self._now()
|
now = self._now()
|
||||||
self._require_active_run_lease(run, now)
|
self._require_active_run_lease(run, now)
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
final_outcome = outcome
|
final_outcome = outcome
|
||||||
|
pending_question = any(q["status"] == "pending" and str(q["run_id"]) == str(run_id)
|
||||||
|
for q in await task_questions.list_questions(self, task_id))
|
||||||
|
actions = []
|
||||||
if outcome == "succeeded":
|
if outcome == "succeeded":
|
||||||
|
await self._require_no_pending_calculations(task_id, run)
|
||||||
if hasattr(self.repo, "pending_run_messages"):
|
if hasattr(self.repo, "pending_run_messages"):
|
||||||
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
||||||
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
||||||
@@ -1915,7 +2013,9 @@ class OperationalTaskService:
|
|||||||
and plan.source_session_id == command.source_session_id
|
and plan.source_session_id == command.source_session_id
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
if not same_generation or report.report.stage != "final":
|
if pending_question:
|
||||||
|
lifecycle = "waiting_human"
|
||||||
|
elif not same_generation or report.report.stage != "final":
|
||||||
final_outcome = "failed"
|
final_outcome = "failed"
|
||||||
lifecycle = "failed"
|
lifecycle = "failed"
|
||||||
failure_code = "ARTIFACT_INCOMPLETE"
|
failure_code = "ARTIFACT_INCOMPLETE"
|
||||||
@@ -1960,6 +2060,7 @@ class OperationalTaskService:
|
|||||||
run_model = OperationalTaskRun.model_validate(run)
|
run_model = OperationalTaskRun.model_validate(run)
|
||||||
await self.repo.update_run(_json(run_model), expected)
|
await self.repo.update_run(_json(run_model), expected)
|
||||||
if final_outcome != "succeeded":
|
if final_outcome != "succeeded":
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
|
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
|
||||||
task.update(
|
task.update(
|
||||||
lifecycle_status=lifecycle,
|
lifecycle_status=lifecycle,
|
||||||
@@ -1980,7 +2081,7 @@ class OperationalTaskService:
|
|||||||
failure_detail or "工况任务执行失败",
|
failure_detail or "工况任务执行失败",
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
elif lifecycle == "waiting_human":
|
elif lifecycle == "waiting_human" and not pending_question:
|
||||||
pending_actions = any(
|
pending_actions = any(
|
||||||
action.approval_status == "pending" for action in actions
|
action.approval_status == "pending" for action in actions
|
||||||
)
|
)
|
||||||
@@ -2193,6 +2294,7 @@ class OperationalTaskService:
|
|||||||
task["agent_lease_expires_at"] = None
|
task["agent_lease_expires_at"] = None
|
||||||
|
|
||||||
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
||||||
|
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
||||||
if self._supports_runs:
|
if self._supports_runs:
|
||||||
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
while active_run is not None:
|
while active_run is not None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from collections.abc import Iterator
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
|
import os
|
||||||
|
|
||||||
from app.native.wndb.inp.exporter import dump_inp
|
from app.native.wndb.inp.exporter import dump_inp
|
||||||
|
|
||||||
@@ -17,9 +18,11 @@ def temporary_project_inp(
|
|||||||
version: str = "2",
|
version: str = "2",
|
||||||
) -> Iterator[Path]:
|
) -> Iterator[Path]:
|
||||||
"""Export one project model to an isolated INP and remove it afterwards."""
|
"""Export one project model to an isolated INP and remove it afterwards."""
|
||||||
PROJECT_INP_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
job_directory = os.environ.get("TJWATER_CALCULATION_JOB_DIR")
|
||||||
|
directory = Path(job_directory) / "inp" if job_directory else PROJECT_INP_DIRECTORY
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
with NamedTemporaryFile(
|
with NamedTemporaryFile(
|
||||||
dir=PROJECT_INP_DIRECTORY,
|
dir=directory,
|
||||||
prefix=f"{purpose}_",
|
prefix=f"{purpose}_",
|
||||||
suffix=".inp",
|
suffix=".inp",
|
||||||
delete=False,
|
delete=False,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Read-only chronological SCADA paging shared by REST and replay processing."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.domain.schemas.scada import ScadaRawPage, ScadaRawReading
|
||||||
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
|
|
||||||
|
def validate_window(start: datetime, end: datetime) -> None:
|
||||||
|
if start.tzinfo is None or end.tzinfo is None or start >= end:
|
||||||
|
raise ValueError("需要带时区、结束晚于开始的 SCADA 时间区间")
|
||||||
|
|
||||||
|
|
||||||
|
def raw_reading(row: dict) -> ScadaRawReading:
|
||||||
|
value = row["monitored_value"]
|
||||||
|
invalid = value is not None and not math.isfinite(float(value))
|
||||||
|
return ScadaRawReading(
|
||||||
|
time=row["time"], device_id=row["device_id"],
|
||||||
|
monitored_value=None if invalid else value,
|
||||||
|
quality="invalid" if invalid else "missing" if value is None else "unvalidated",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def read_raw_page(
|
||||||
|
conn, *, project_id: str, device_ids: list[str], start: datetime,
|
||||||
|
end: datetime, limit: int = 500, cursor: str | None = None,
|
||||||
|
through: datetime | None = None,
|
||||||
|
) -> ScadaRawPage:
|
||||||
|
validate_window(start, end)
|
||||||
|
ids = sorted(set(device_ids))
|
||||||
|
if not ids or len(ids) > 500 or not 1 <= limit <= 500:
|
||||||
|
raise ValueError("请选择 1 至 500 个设备,每页最多 500 条")
|
||||||
|
cap = min(through or end, end)
|
||||||
|
if cap.tzinfo is None or cap < start:
|
||||||
|
raise ValueError("模拟时间不在可读取区间内")
|
||||||
|
signature = hashlib.sha256(json.dumps(
|
||||||
|
[project_id, ids, start.isoformat(), end.isoformat()], separators=(",", ":")
|
||||||
|
).encode()).hexdigest()
|
||||||
|
after = None
|
||||||
|
if cursor:
|
||||||
|
try:
|
||||||
|
data = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||||
|
frozen = datetime.fromisoformat(data["through"])
|
||||||
|
last = datetime.fromisoformat(data["time"])
|
||||||
|
if (data["query"] != signature or frozen.tzinfo is None or last.tzinfo is None
|
||||||
|
or frozen > cap or not start <= last <= frozen or data["device"] not in ids):
|
||||||
|
raise ValueError()
|
||||||
|
cap, after = frozen, (last, data["device"])
|
||||||
|
except (ValueError, KeyError, TypeError, UnicodeError) as exc:
|
||||||
|
raise ValueError("SCADA 分页游标无效或查询范围已改变") from exc
|
||||||
|
rows = await ScadaRepository.get_raw_page(conn, ids, start, end, cap, limit + 1, after)
|
||||||
|
selected = rows[:limit]
|
||||||
|
next_cursor = None
|
||||||
|
if len(rows) > limit:
|
||||||
|
last = selected[-1]
|
||||||
|
next_cursor = base64.urlsafe_b64encode(json.dumps({
|
||||||
|
"query": signature, "through": cap.isoformat(),
|
||||||
|
"time": last["time"].isoformat(), "device": last["device_id"],
|
||||||
|
}, separators=(",", ":")).encode()).decode()
|
||||||
|
return ScadaRawPage(items=[raw_reading(row) for row in selected],
|
||||||
|
next_cursor=next_cursor, scanned_through=cap)
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Durable, project-scoped SCADA playback; raw is never inserted or rewritten."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from app.auth.permissions import permissions_for_context
|
||||||
|
|
||||||
|
from app.domain.schemas.project_settings import ScadaMonitoringSettings
|
||||||
|
from app.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand, ScadaReplaySnapshot
|
||||||
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
from app.services.content_digest import digest
|
||||||
|
from app.services.scada_replay_processing import prepare_cleaning, detect_frame
|
||||||
|
from app.services.scada_replay_monitoring import evaluate_monitoring_period
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(value: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(document: dict) -> ScadaReplaySnapshot:
|
||||||
|
return ScadaReplaySnapshot(
|
||||||
|
**{key: document[key] for key in (
|
||||||
|
"replay_id", "project_id", "revision", "status", "start_time", "end_time",
|
||||||
|
"virtual_now", "created_at", "updated_at",
|
||||||
|
)},
|
||||||
|
delivered_until=document.get("delivered_until"), processed_until=document.get("processed_until"),
|
||||||
|
processed_count=document.get("processed_count", 0), anomaly_count=document.get("anomaly_count", 0),
|
||||||
|
devices=document["devices"], rules=document["config"]["rules"],
|
||||||
|
latest=list(document.get("latest", {}).values()),
|
||||||
|
active_events=[state["event"] for state in document.get("rule_states", {}).values() if state.get("event")],
|
||||||
|
error=document.get("error"),
|
||||||
|
monitoring_interval_seconds=document.get("monitoring_interval_seconds"),
|
||||||
|
step_pending=document.get("step_pending", False),
|
||||||
|
monitoring_count=document.get("monitoring_count", 0),
|
||||||
|
last_monitoring=document.get("last_monitoring"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScadaReplayService:
|
||||||
|
def __init__(self, repository, context, timescale=None, *, clock=None):
|
||||||
|
self.repo, self.context, self.timescale = repository, context, timescale
|
||||||
|
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
async def ensure_storage(self):
|
||||||
|
if not await self.repo.available():
|
||||||
|
raise HTTPException(503, "SCADA 回放存储未配置,需要应用 031_scada_replays.sql")
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
command: ScadaReplayCreate,
|
||||||
|
*,
|
||||||
|
monitoring_interval_seconds: int | None,
|
||||||
|
monitoring_config: ScadaMonitoringSettings | None = None,
|
||||||
|
):
|
||||||
|
await self.ensure_storage()
|
||||||
|
async with self.repo.control():
|
||||||
|
previous = await self.repo.by_request(command.request_id)
|
||||||
|
config = command.model_dump(mode="json")
|
||||||
|
if previous:
|
||||||
|
if previous["config"] != config:
|
||||||
|
raise HTTPException(409, "创建请求号已用于不同回放配置")
|
||||||
|
return snapshot(previous)
|
||||||
|
if monitoring_interval_seconds is None:
|
||||||
|
raise HTTPException(422, "请先配置项目 settings.scada_monitoring_interval_seconds,再创建演示")
|
||||||
|
replay_id = str(uuid4())
|
||||||
|
if await self.repo.active_other(replay_id):
|
||||||
|
raise HTTPException(409, "当前项目已有回放,请先停止原回放再新建")
|
||||||
|
metadata = {row["device_id"]: row for row in await ScadaInfoRepository.get_scadas(self.repo.connection)}
|
||||||
|
if not set(command.device_ids) <= metadata.keys():
|
||||||
|
raise HTTPException(422, "设备不属于当前项目")
|
||||||
|
for rule in command.rules:
|
||||||
|
if any(metadata[device]["measurement_unit"] != rule.unit for device in rule.device_ids):
|
||||||
|
raise HTTPException(422, "规则单位与设备单位不一致,请分组配置")
|
||||||
|
if any(metadata[device]["device_type"] == "quality" for device in rule.device_ids) and not rule.metric_name.strip():
|
||||||
|
raise HTTPException(422, "水质规则必须明确指标名称,不能仅凭单位解释读数")
|
||||||
|
if monitoring_config:
|
||||||
|
role_ids = set(monitoring_config.pressure_device_ids) | set(monitoring_config.outlet_flow_device_ids)
|
||||||
|
if not role_ids <= metadata.keys():
|
||||||
|
raise HTTPException(422, "项目 SCADA 监测角色包含不存在的设备")
|
||||||
|
if any(metadata[device]["device_type"] != "pressure" for device in monitoring_config.pressure_device_ids):
|
||||||
|
raise HTTPException(422, "压力监测角色必须引用 pressure 设备")
|
||||||
|
if any(metadata[device]["device_type"] not in {"pipe_flow", "flow"}
|
||||||
|
for device in monitoring_config.outlet_flow_device_ids):
|
||||||
|
raise HTTPException(422, "出厂边界流量角色必须引用流量设备")
|
||||||
|
now = self.clock().isoformat()
|
||||||
|
document = {
|
||||||
|
"replay_id": replay_id, "project_id": str(self.context.project_id),
|
||||||
|
"created_by": str(self.context.user_id), "config": config,
|
||||||
|
"revision": 1, "status": "ready", "start_time": config["start_time"],
|
||||||
|
"end_time": config["end_time"], "virtual_now": config["start_time"],
|
||||||
|
"created_at": now, "updated_at": now,
|
||||||
|
"monitoring_interval_seconds": monitoring_interval_seconds,
|
||||||
|
"monitoring_config": monitoring_config.model_dump(mode="json") if monitoring_config else None,
|
||||||
|
"devices": [metadata[device] for device in command.device_ids],
|
||||||
|
"batch_sequence": 0, "processed_count": 0, "anomaly_count": 0,
|
||||||
|
"monitoring_count": 0, "latest": {}, "histories": {}, "rule_states": {}, "receipts": {},
|
||||||
|
}
|
||||||
|
await self.repo.insert(document)
|
||||||
|
return snapshot(document)
|
||||||
|
|
||||||
|
async def command(self, replay_id, command: ScadaReplayCommand):
|
||||||
|
await self.ensure_storage()
|
||||||
|
async with self.repo.control():
|
||||||
|
document = await self.repo.get(replay_id)
|
||||||
|
key, body = str(command.request_id), command.model_dump(mode="json")
|
||||||
|
receipt = document.setdefault("receipts", {}).get(key)
|
||||||
|
if receipt:
|
||||||
|
if receipt != body:
|
||||||
|
raise HTTPException(409, "命令请求号已用于不同内容")
|
||||||
|
return snapshot(document)
|
||||||
|
if command.expected_revision != document["revision"]:
|
||||||
|
raise HTTPException(409, "回放控制状态已更新,请刷新后重试")
|
||||||
|
action, status = command.action, document["status"]
|
||||||
|
if document.get("step_pending") and action == "step":
|
||||||
|
raise HTTPException(409, "当前时间步正在处理,请完成后继续")
|
||||||
|
if action in {"step", "retry"} and await self.repo.active_other(replay_id):
|
||||||
|
raise HTTPException(409, "当前项目已有另一条活动回放")
|
||||||
|
if action == "step" and status in {"ready", "paused"}:
|
||||||
|
interval = document.get("monitoring_interval_seconds")
|
||||||
|
if interval is None:
|
||||||
|
raise HTTPException(409, "旧回放未固定监测周期,请停止后新建演示")
|
||||||
|
start = parse_time(document["start_time"])
|
||||||
|
elapsed = (parse_time(document["virtual_now"]) - start).total_seconds()
|
||||||
|
target = start + timedelta(seconds=(int(elapsed // interval) + 1) * interval)
|
||||||
|
document["step_start"] = document["virtual_now"]
|
||||||
|
document["step_target"] = min(parse_time(document["end_time"]), target).isoformat()
|
||||||
|
document["status"], document["step_pending"] = "paused", True
|
||||||
|
elif action == "stop" and status not in {"completed", "stopped"}:
|
||||||
|
document["status"], document["step_pending"] = "stopped", False
|
||||||
|
document.pop("step_target", None)
|
||||||
|
document.pop("step_start", None)
|
||||||
|
elif action == "retry" and status == "failed":
|
||||||
|
if "SOURCE_CHANGED" in (document.get("error") or ""):
|
||||||
|
raise HTTPException(409, "原始数据发生变化,需要停止并新建回放")
|
||||||
|
document["status"], document["error"] = "paused", None
|
||||||
|
document["step_pending"] = bool(document.get("step_target"))
|
||||||
|
else:
|
||||||
|
raise HTTPException(409, "当前回放状态不支持此操作")
|
||||||
|
document["revision"] += 1
|
||||||
|
document["receipts"][key] = body
|
||||||
|
if len(document["receipts"]) > 256:
|
||||||
|
del document["receipts"][next(iter(document["receipts"]))]
|
||||||
|
await self.repo.save(document)
|
||||||
|
return snapshot(document)
|
||||||
|
|
||||||
|
async def process_pending_step(self, replay_id):
|
||||||
|
await self.ensure_storage()
|
||||||
|
async with self.repo.processing() as owned:
|
||||||
|
if not owned:
|
||||||
|
raise HTTPException(409, "当前时间步正在由另一请求处理")
|
||||||
|
while True:
|
||||||
|
document = await self.repo.get(replay_id)
|
||||||
|
if not document.get("step_pending"):
|
||||||
|
return snapshot(document)
|
||||||
|
try:
|
||||||
|
await self._advance(document)
|
||||||
|
except Exception as exc:
|
||||||
|
# Failure never discards the prepared batch; retry resumes it.
|
||||||
|
document = await self.repo.get(replay_id)
|
||||||
|
document["status"], document["step_pending"] = "failed", False
|
||||||
|
document["error"] = (
|
||||||
|
"SOURCE_CHANGED: 原始观测已改变,需要新建回放"
|
||||||
|
if "SOURCE_CHANGED" in str(exc)
|
||||||
|
else "SCADA 处理失败,已保留批次与进度;请检查服务日志后重试"
|
||||||
|
)
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self.repo.save(document)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _advance(self, document):
|
||||||
|
config = ScadaReplayCreate.model_validate(document["config"])
|
||||||
|
replay_id = document["replay_id"]
|
||||||
|
if not document.get("warmed"):
|
||||||
|
import math
|
||||||
|
window = max(rule.window_samples for rule in config.rules)
|
||||||
|
async with self.timescale.transaction():
|
||||||
|
warmup = await ScadaRepository.get_warmup_values(
|
||||||
|
self.timescale, config.device_ids, config.start_time, window,
|
||||||
|
)
|
||||||
|
for row in warmup:
|
||||||
|
value = row["monitored_value"]
|
||||||
|
bounds = config.quality_ranges.get(row["device_id"])
|
||||||
|
if value is not None and math.isfinite(value) and not (bounds and (
|
||||||
|
bounds.lower is not None and value < bounds.lower
|
||||||
|
or bounds.upper is not None and value > bounds.upper
|
||||||
|
)):
|
||||||
|
document["histories"].setdefault(row["device_id"], []).append(value)
|
||||||
|
document["warmed"] = True
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self.repo.save(document)
|
||||||
|
batch = await self.repo.pending_batch(replay_id)
|
||||||
|
if batch is None:
|
||||||
|
step = document.get("step_pending", False)
|
||||||
|
virtual = parse_time(document["virtual_now"])
|
||||||
|
through = parse_time(document["step_target"]) if step else virtual
|
||||||
|
after = ((parse_time(document["cursor"][0]), document["cursor"][1])
|
||||||
|
if document.get("cursor") else None)
|
||||||
|
async with self.timescale.transaction():
|
||||||
|
rows = await ScadaRepository.get_raw_page(
|
||||||
|
self.timescale, config.device_ids, config.start_time, config.end_time,
|
||||||
|
through, 501, after, frame=True,
|
||||||
|
)
|
||||||
|
if len(rows) > 500:
|
||||||
|
raise ValueError("SCADA frame exceeds selected device bound")
|
||||||
|
at = rows[0]["time"] if rows else through
|
||||||
|
if step:
|
||||||
|
virtual = through
|
||||||
|
document["virtual_now"] = virtual.isoformat()
|
||||||
|
if not rows and document.get("processed_until") == at.isoformat() and not step:
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self.repo.save(document)
|
||||||
|
return
|
||||||
|
sequence = document["batch_sequence"] + 1
|
||||||
|
refs = [{"time": row["time"].isoformat(), "device_id": row["device_id"],
|
||||||
|
"source_hash": row["source_hash"]} for row in rows]
|
||||||
|
batch = {"sequence": sequence, "stage": "selected", "at": at.isoformat(),
|
||||||
|
"refs": refs, "input_digest": digest(refs), "events": []}
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
document["batch_sequence"] = sequence
|
||||||
|
document["delivered_until"] = at.isoformat()
|
||||||
|
await self.repo.save_batch(replay_id, batch)
|
||||||
|
await self.repo.save(document)
|
||||||
|
else:
|
||||||
|
rows = None
|
||||||
|
if batch["stage"] == "selected":
|
||||||
|
if rows is None:
|
||||||
|
# Only crash recovery reloads the selected raw frame.
|
||||||
|
at = parse_time(batch["at"])
|
||||||
|
async with self.timescale.transaction():
|
||||||
|
rows = await ScadaRepository.get_raw_page(
|
||||||
|
self.timescale, config.device_ids, at, config.end_time, at, 501,
|
||||||
|
) if batch["refs"] else []
|
||||||
|
actual = [{"time": row["time"].isoformat(), "device_id": row["device_id"],
|
||||||
|
"source_hash": row["source_hash"]} for row in rows]
|
||||||
|
if digest(actual) != batch["input_digest"]:
|
||||||
|
raise ValueError("SOURCE_CHANGED: selected raw frame differs")
|
||||||
|
batch["outputs"] = prepare_cleaning(rows, config)
|
||||||
|
batch["stage"] = "clean_prepared"
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self.repo.save_batch(replay_id, batch)
|
||||||
|
if batch["stage"] == "clean_prepared":
|
||||||
|
await ScadaRepository.apply_replay_cleaning(self.timescale, batch["outputs"])
|
||||||
|
batch["stage"] = "cleaned"
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self.repo.save_batch(replay_id, batch)
|
||||||
|
if batch["stage"] == "cleaned":
|
||||||
|
result, changes = detect_frame(document, batch["outputs"], config, parse_time(batch["at"]))
|
||||||
|
result["processed_until"] = batch["at"]
|
||||||
|
target = result.get("step_target")
|
||||||
|
was_step = bool(target)
|
||||||
|
result["step_pending"] = bool(target and parse_time(batch["at"]) < parse_time(target))
|
||||||
|
monitoring = None
|
||||||
|
if was_step and not result["step_pending"]:
|
||||||
|
period_start = parse_time(result.get("step_start", result["start_time"]))
|
||||||
|
period_end = parse_time(target)
|
||||||
|
role = result.get("monitoring_config") or {}
|
||||||
|
role_ids = list(dict.fromkeys(
|
||||||
|
role.get("pressure_device_ids", []) + role.get("outlet_flow_device_ids", [])
|
||||||
|
))
|
||||||
|
previous = await self.repo.monitoring_outputs(
|
||||||
|
replay_id, role_ids, max(config.start_time, period_end - timedelta(days=15)),
|
||||||
|
period_end, batch["sequence"],
|
||||||
|
) if role_ids else []
|
||||||
|
from app.mcp.analysis_service import bounded_calculation
|
||||||
|
monitoring = await bounded_calculation(
|
||||||
|
evaluate_monitoring_period,
|
||||||
|
result,
|
||||||
|
previous + batch["outputs"],
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
batch_sequence=batch["sequence"],
|
||||||
|
)
|
||||||
|
result["last_monitoring"] = monitoring
|
||||||
|
result["monitoring_count"] = result.get("monitoring_count", 0) + 1
|
||||||
|
if not result["step_pending"]:
|
||||||
|
result.pop("step_target", None)
|
||||||
|
result.pop("step_start", None)
|
||||||
|
if batch["refs"]:
|
||||||
|
last = batch["refs"][-1]
|
||||||
|
result["cursor"] = [last["time"], last["device_id"]]
|
||||||
|
if parse_time(batch["at"]) >= config.end_time:
|
||||||
|
result["status"] = "completed"
|
||||||
|
batch["stage"], batch["events"] = "detected", changes
|
||||||
|
if monitoring is not None:
|
||||||
|
batch["monitoring"] = monitoring
|
||||||
|
async with self.repo.connection.transaction():
|
||||||
|
await self._create_investigations(result, changes, batch, create_tasks=not was_step)
|
||||||
|
if monitoring is not None:
|
||||||
|
await self._create_monitoring_task(result, monitoring, batch)
|
||||||
|
batch["monitoring"] = monitoring
|
||||||
|
result["last_monitoring"] = monitoring
|
||||||
|
await self.repo.save_batch(replay_id, batch)
|
||||||
|
await self.repo.save(result)
|
||||||
|
|
||||||
|
async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
from app.domain.schemas.task_alert import TaskAlertEvent
|
||||||
|
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||||||
|
from app.services.task_alerts import TaskAlertService
|
||||||
|
from uuid import UUID
|
||||||
|
producer = "scada-replay:" + document["replay_id"]
|
||||||
|
alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id),
|
||||||
|
self.context, internal_producer_id=producer)
|
||||||
|
tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||||||
|
self.context, permissions_for_context(self.context))
|
||||||
|
for event in changes:
|
||||||
|
if event["change"] == "raised":
|
||||||
|
event["batch_sequence"] = batch["sequence"]
|
||||||
|
document["rule_states"][event["rule_id"]]["event"]["batch_sequence"] = batch["sequence"]
|
||||||
|
await alerts.push(TaskAlertEvent(
|
||||||
|
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
|
||||||
|
alert_id=event["event_id"], producer_id=producer, event_type=event["change"],
|
||||||
|
occurred_at=event["occurred_at"], condition_id=None,
|
||||||
|
source={"kind": "scada_replay", "replay_id": document["replay_id"],
|
||||||
|
"rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
|
||||||
|
title=f"SCADA 回放异常 · {event['rule_id']}", description=event["detail"], severity="warning",
|
||||||
|
))
|
||||||
|
if event["change"] != "raised":
|
||||||
|
if event.get("task_id"):
|
||||||
|
await tasks.advance_replay_investigation(UUID(event["task_id"]), {
|
||||||
|
"replay_id": document["replay_id"], "knowledge_cutoff": batch["at"],
|
||||||
|
"batch_sequence": batch["sequence"],
|
||||||
|
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
|
||||||
|
continue
|
||||||
|
if event.get("category") == "data_quality":
|
||||||
|
continue
|
||||||
|
if not create_tasks:
|
||||||
|
continue
|
||||||
|
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
|
||||||
|
command = OperationalTaskCreate(
|
||||||
|
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
|
||||||
|
workflow="scada_anomaly", title=f"SCADA 回放调查 · {event['rule_id']}",
|
||||||
|
summary=event["detail"], source=TaskSource(
|
||||||
|
kind="external_system", external_id=external, external_revision=1,
|
||||||
|
event_kind="scada_anomaly",
|
||||||
|
replay_context={"replay_id": document["replay_id"],
|
||||||
|
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
|
||||||
|
),
|
||||||
|
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail="已存 SCADA 历史的回放检测信号,原因尚待调查"),
|
||||||
|
)
|
||||||
|
task = await tasks.create(command)
|
||||||
|
event["task_id"] = str(task.task_id)
|
||||||
|
document["rule_states"][event["rule_id"]]["event"]["task_id"] = event["task_id"]
|
||||||
|
|
||||||
|
async def _create_monitoring_task(self, document, monitoring, batch):
|
||||||
|
if monitoring["status"] != "suspected_burst":
|
||||||
|
return
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
tasks = OperationalTaskService(
|
||||||
|
OperationalTaskRepository(self.repo.connection, self.context.project_id),
|
||||||
|
self.context,
|
||||||
|
permissions_for_context(self.context),
|
||||||
|
)
|
||||||
|
external = (
|
||||||
|
f"scada_replay:{document['replay_id']}:monitor:"
|
||||||
|
f"{monitoring['period_end']}:{monitoring['rule_version']}"
|
||||||
|
)
|
||||||
|
pressure = monitoring["pressure"]
|
||||||
|
flow = monitoring["outlet_flow"]
|
||||||
|
detector = monitoring["burst_detection"]
|
||||||
|
summary = (
|
||||||
|
f"步长监测疑似爆管:爆管侦测得分 {detector['target_score']:.4f};"
|
||||||
|
f"压力下降测点 {len(pressure['hit_device_ids'])} 个;"
|
||||||
|
f"出厂边界流量增加测点 {len(flow['hit_device_ids'])} 个。"
|
||||||
|
)
|
||||||
|
command = OperationalTaskCreate(
|
||||||
|
request_id=uuid5(NAMESPACE_URL, external),
|
||||||
|
task_type="event_investigation",
|
||||||
|
workflow="burst_report",
|
||||||
|
title=f"SCADA 回放疑似爆管 · {monitoring['period_end']}",
|
||||||
|
summary=summary,
|
||||||
|
source=TaskSource(
|
||||||
|
kind="external_system",
|
||||||
|
external_id=external,
|
||||||
|
external_revision=1,
|
||||||
|
event_kind="scada_burst_suspected",
|
||||||
|
replay_context={
|
||||||
|
"replay_id": document["replay_id"],
|
||||||
|
"knowledge_cutoff": monitoring["knowledge_cutoff"],
|
||||||
|
"batch_sequence": batch["sequence"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trigger=TaskTrigger(
|
||||||
|
kind="event",
|
||||||
|
occurred_at=monitoring["period_end"],
|
||||||
|
detail="主动步长监测的多项指标相互支持,进入爆管调查;尚未现场确诊",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
task = await tasks.create(command)
|
||||||
|
monitoring["task_id"] = str(task.task_id)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Deterministic monitoring summaries built only from frozen replay outputs."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from statistics import median
|
||||||
|
from typing import Any
|
||||||
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
||||||
|
from app.services.burst_detection import (
|
||||||
|
TARGET_MU,
|
||||||
|
TARGET_N_ESTIMATORS,
|
||||||
|
TARGET_RANDOM_STATE,
|
||||||
|
TARGET_SCORE_THRESHOLD,
|
||||||
|
run_burst_detection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _time(value: str | datetime) -> datetime:
|
||||||
|
return value if isinstance(value, datetime) else datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_rows(rows: list[dict], device_id: str) -> list[dict]:
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
row for row in rows
|
||||||
|
if row.get("device_id") == device_id
|
||||||
|
and row.get("quality") in {"valid", "corrected"}
|
||||||
|
and row.get("cleaned_value") is not None
|
||||||
|
),
|
||||||
|
key=lambda row: _time(row["time"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_indicator(
|
||||||
|
rows: list[dict],
|
||||||
|
config: ScadaReplayCreate,
|
||||||
|
device_ids: list[str],
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
*,
|
||||||
|
direction: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
details: list[dict[str, Any]] = []
|
||||||
|
gaps: list[dict[str, str]] = []
|
||||||
|
for device_id in device_ids:
|
||||||
|
rule = next((
|
||||||
|
item for item in config.rules
|
||||||
|
if item.kind == "relative_change" and device_id in item.device_ids
|
||||||
|
and item.threshold is not None
|
||||||
|
), None)
|
||||||
|
if rule is None:
|
||||||
|
gaps.append({"device_id": device_id, "reason": "relative_change_rule_missing"})
|
||||||
|
continue
|
||||||
|
valid = _valid_rows(rows, device_id)
|
||||||
|
baseline = [row for row in valid if _time(row["time"]) < period_start][-rule.window_samples:]
|
||||||
|
current = [row for row in valid if period_start <= _time(row["time"]) <= period_end]
|
||||||
|
if len(baseline) < rule.minimum_samples:
|
||||||
|
gaps.append({"device_id": device_id, "reason": "baseline_not_ready"})
|
||||||
|
continue
|
||||||
|
if not current:
|
||||||
|
gaps.append({"device_id": device_id, "reason": "period_reading_missing"})
|
||||||
|
continue
|
||||||
|
reference = median(float(row["cleaned_value"]) for row in baseline)
|
||||||
|
if reference == 0:
|
||||||
|
gaps.append({"device_id": device_id, "reason": "zero_baseline"})
|
||||||
|
continue
|
||||||
|
latest = current[-1]
|
||||||
|
value = float(latest["cleaned_value"])
|
||||||
|
change = (value - reference) / abs(reference) * 100
|
||||||
|
hit = change <= -rule.threshold if direction == "decrease" else change >= rule.threshold
|
||||||
|
details.append({
|
||||||
|
"device_id": device_id,
|
||||||
|
"observed_at": _time(latest["time"]).isoformat(),
|
||||||
|
"baseline": reference,
|
||||||
|
"value": value,
|
||||||
|
"change_percent": change,
|
||||||
|
"threshold_percent": rule.threshold,
|
||||||
|
"hit": hit,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"status": "ready" if not gaps else "insufficient_data",
|
||||||
|
"hit": any(item["hit"] for item in details),
|
||||||
|
"hit_device_ids": [item["device_id"] for item in details if item["hit"]],
|
||||||
|
"devices": details,
|
||||||
|
"gaps": gaps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _burst_detection(
|
||||||
|
rows: list[dict],
|
||||||
|
pressure_device_ids: list[str],
|
||||||
|
interval_seconds: int,
|
||||||
|
period_end: datetime,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if len(pressure_device_ids) < 5:
|
||||||
|
return {"status": "sensor_coverage_insufficient", "hit": False,
|
||||||
|
"required_sensors": 5, "available_sensors": len(pressure_device_ids)}
|
||||||
|
if interval_seconds % 60 or 86400 % interval_seconds:
|
||||||
|
return {"status": "unsupported_sampling_interval", "hit": False,
|
||||||
|
"interval_seconds": interval_seconds}
|
||||||
|
points_per_day = 86400 // interval_seconds
|
||||||
|
count = 15 * points_per_day
|
||||||
|
series_by_device: dict[str, dict[datetime, float]] = {}
|
||||||
|
common_times: set[datetime] | None = None
|
||||||
|
for device_id in pressure_device_ids:
|
||||||
|
values = {_time(row["time"]): float(row["cleaned_value"])
|
||||||
|
for row in _valid_rows(rows, device_id) if _time(row["time"]) <= period_end}
|
||||||
|
series_by_device[device_id] = values
|
||||||
|
common_times = set(values) if common_times is None else common_times & set(values)
|
||||||
|
if not common_times:
|
||||||
|
return {"status": "baseline_not_ready", "hit": False, "required_samples": count}
|
||||||
|
target = max(common_times)
|
||||||
|
expected = [target - timedelta(seconds=interval_seconds * offset)
|
||||||
|
for offset in range(count - 1, -1, -1)]
|
||||||
|
incomplete = [device_id for device_id, values in series_by_device.items()
|
||||||
|
if any(at not in values for at in expected)]
|
||||||
|
if incomplete:
|
||||||
|
return {"status": "baseline_not_ready", "hit": False, "required_samples": count,
|
||||||
|
"incomplete_device_ids": incomplete}
|
||||||
|
frame = pd.DataFrame({device_id: [series_by_device[device_id][at] for at in expected]
|
||||||
|
for device_id in pressure_device_ids}, index=expected)
|
||||||
|
try:
|
||||||
|
result = run_burst_detection(
|
||||||
|
network="scada-replay",
|
||||||
|
username="scada-replay",
|
||||||
|
observed_pressure_data=frame,
|
||||||
|
points_per_day=points_per_day,
|
||||||
|
mu=TARGET_MU,
|
||||||
|
iforest_params={
|
||||||
|
"n_estimators": TARGET_N_ESTIMATORS,
|
||||||
|
"random_state": TARGET_RANDOM_STATE,
|
||||||
|
"contamination": "auto",
|
||||||
|
},
|
||||||
|
sensor_nodes=pressure_device_ids,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return {"status": "analysis_failed", "hit": False, "reason": str(exc)[:300]}
|
||||||
|
latest = result["rows"][-1]
|
||||||
|
score = float(latest["Score"])
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"hit": score <= TARGET_SCORE_THRESHOLD,
|
||||||
|
"target_time": target.isoformat(),
|
||||||
|
"target_score": score,
|
||||||
|
"score_threshold": TARGET_SCORE_THRESHOLD,
|
||||||
|
"sensor_count": len(pressure_device_ids),
|
||||||
|
"reference_day_count": 14,
|
||||||
|
"latest_sensor_rankings": result["summary"].get("latest_sensor_rankings", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_monitoring_period(
|
||||||
|
document: dict,
|
||||||
|
rows: list[dict],
|
||||||
|
*,
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
batch_sequence: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = ScadaReplayCreate.model_validate(document["config"])
|
||||||
|
monitoring = document.get("monitoring_config")
|
||||||
|
rule_version = (monitoring or {}).get("rule_version", "unconfigured")
|
||||||
|
monitoring_id = str(uuid5(
|
||||||
|
NAMESPACE_URL,
|
||||||
|
f"{document['replay_id']}:{period_end.isoformat()}:{rule_version}",
|
||||||
|
))
|
||||||
|
base = {
|
||||||
|
"monitoring_id": monitoring_id,
|
||||||
|
"period_start": period_start.isoformat(),
|
||||||
|
"period_end": period_end.isoformat(),
|
||||||
|
"knowledge_cutoff": period_end.isoformat(),
|
||||||
|
"batch_sequence": batch_sequence,
|
||||||
|
"rule_version": rule_version,
|
||||||
|
"task_id": None,
|
||||||
|
}
|
||||||
|
if not monitoring:
|
||||||
|
return {**base, "status": "insufficient_data", "detail": "项目未配置 SCADA 监测设备角色",
|
||||||
|
"pressure": {"status": "unconfigured", "hit": False},
|
||||||
|
"outlet_flow": {"status": "unconfigured", "hit": False},
|
||||||
|
"burst_detection": {"status": "not_run", "hit": False}}
|
||||||
|
pressure_ids = monitoring.get("pressure_device_ids", [])
|
||||||
|
flow_ids = monitoring.get("outlet_flow_device_ids", [])
|
||||||
|
selected = set(config.device_ids)
|
||||||
|
role_gaps = sorted((set(pressure_ids) | set(flow_ids)) - selected)
|
||||||
|
if not pressure_ids or not flow_ids or role_gaps:
|
||||||
|
return {**base, "status": "insufficient_data", "detail": "监测角色为空或不属于本轮回放设备",
|
||||||
|
"role_gaps": role_gaps,
|
||||||
|
"pressure": {"status": "unconfigured", "hit": False},
|
||||||
|
"outlet_flow": {"status": "unconfigured", "hit": False},
|
||||||
|
"burst_detection": {"status": "not_run", "hit": False}}
|
||||||
|
pressure = _relative_indicator(rows, config, pressure_ids, period_start, period_end, direction="decrease")
|
||||||
|
outlet_flow = _relative_indicator(rows, config, flow_ids, period_start, period_end, direction="increase")
|
||||||
|
detector = _burst_detection(
|
||||||
|
rows, pressure_ids, int(document["monitoring_interval_seconds"]), period_end,
|
||||||
|
)
|
||||||
|
complete = pressure["status"] == outlet_flow["status"] == detector["status"] == "ready"
|
||||||
|
hits = (bool(detector["hit"]), bool(pressure["hit"]), bool(outlet_flow["hit"]))
|
||||||
|
if not complete:
|
||||||
|
status, detail = "insufficient_data", "监测数据、设备角色或爆管侦测基线不足"
|
||||||
|
elif all(hits):
|
||||||
|
status, detail = "suspected_burst", "爆管侦测、压力下降与出厂边界流量增加相互支持"
|
||||||
|
elif any(hits):
|
||||||
|
status, detail = "attention", "仅部分爆管相关指标命中,需要继续观察或人工核查"
|
||||||
|
else:
|
||||||
|
status, detail = "normal", "本周期爆管相关指标均未命中"
|
||||||
|
return {**base, "status": status, "detail": detail, "pressure": pressure,
|
||||||
|
"outlet_flow": outlet_flow, "burst_detection": detector}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Causal batch quality checks and grouped anomaly episodes, no database IO."""
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime
|
||||||
|
from math import isfinite
|
||||||
|
from statistics import median
|
||||||
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
|
import re
|
||||||
|
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_cleaning(rows: list[dict], config: ScadaReplayCreate) -> list[dict]:
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
value = row["monitored_value"]
|
||||||
|
quality, reason = "valid", "原始值通过数值与已配置量程校验"
|
||||||
|
if value is None:
|
||||||
|
quality, reason = "missing", "原始读数缺失"
|
||||||
|
elif not isfinite(float(value)):
|
||||||
|
quality, reason = "invalid", "原始读数不是有限数值"
|
||||||
|
else:
|
||||||
|
bounds = config.quality_ranges.get(row["device_id"])
|
||||||
|
if bounds and ((bounds.lower is not None and value < bounds.lower)
|
||||||
|
or (bounds.upper is not None and value > bounds.upper)):
|
||||||
|
quality, reason = "invalid", "超出本次明确配置的仪表量程"
|
||||||
|
result.append({
|
||||||
|
"time": row["time"].isoformat() if isinstance(row["time"], datetime) else row["time"],
|
||||||
|
"device_id": row["device_id"], "source_hash": row["source_hash"],
|
||||||
|
"monitored_value": value if value is not None and isfinite(float(value)) else None,
|
||||||
|
"cleaned_value": value if quality == "valid" else None,
|
||||||
|
"quality": quality, "reason": reason,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def detect_frame(document: dict, rows: list[dict], config: ScadaReplayCreate, at: datetime) -> tuple[dict, list[dict]]:
|
||||||
|
"""Consume a complete timestamp; never treat missing data as recovery."""
|
||||||
|
result = deepcopy(document)
|
||||||
|
histories = result.setdefault("histories", {})
|
||||||
|
states = result.setdefault("rule_states", {})
|
||||||
|
latest = result.setdefault("latest", {})
|
||||||
|
for row in rows:
|
||||||
|
latest[row["device_id"]] = row
|
||||||
|
by_id = {row["device_id"]: row for row in rows}
|
||||||
|
changes = []
|
||||||
|
devices = {device["device_id"]: device for device in result.get("devices", [])}
|
||||||
|
for device_id in config.device_ids:
|
||||||
|
row = by_id.get(device_id)
|
||||||
|
invalid = row is not None and row["quality"] != "valid"
|
||||||
|
previous = latest.get(device_id)
|
||||||
|
frequency = str(devices.get(device_id, {}).get("transmission_frequency", ""))
|
||||||
|
match = re.fullmatch(r"(\d+):(\d+):(\d+)", frequency)
|
||||||
|
if match:
|
||||||
|
seconds = int(match[1]) * 3600 + int(match[2]) * 60 + int(match[3])
|
||||||
|
else:
|
||||||
|
minutes = re.fullmatch(r"(\d+)\s*(?:min|分钟)", frequency)
|
||||||
|
seconds = int(minutes[1]) * 60 if minutes else 0
|
||||||
|
prior_time = datetime.fromisoformat(previous["time"]) if previous else config.start_time
|
||||||
|
missing = not row and seconds > 0 and (at - prior_time).total_seconds() >= seconds * 2
|
||||||
|
key = "quality-" + device_id
|
||||||
|
state = states.setdefault(key, {"event": None, "recoveries": 0})
|
||||||
|
if invalid or missing:
|
||||||
|
state["recoveries"] = 0
|
||||||
|
if state["event"] is None:
|
||||||
|
event = {"event_id": str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{key}:{at.isoformat()}")),
|
||||||
|
"rule_id": key, "device_ids": [device_id], "occurred_at": at.isoformat(),
|
||||||
|
"last_observed_at": at.isoformat(), "status": "active", "category": "data_quality",
|
||||||
|
"detail": row["reason"] if invalid else "连续两个设备周期未读取到观测",
|
||||||
|
"task_id": None}
|
||||||
|
state["event"] = event
|
||||||
|
changes.append({**event, "change": "raised"})
|
||||||
|
result["anomaly_count"] = result.get("anomaly_count", 0) + 1
|
||||||
|
elif row and row["quality"] == "valid" and state["event"]:
|
||||||
|
state["recoveries"] += 1
|
||||||
|
if state["recoveries"] >= 2:
|
||||||
|
changes.append({**state["event"], "status": "resolved", "resolved_at": at.isoformat(), "change": "resolved"})
|
||||||
|
state["event"] = None
|
||||||
|
for rule in config.rules:
|
||||||
|
state = states.setdefault(rule.rule_id, {"hits": 0, "recoveries": 0, "event": None})
|
||||||
|
hits, usable, reasons = [], [], []
|
||||||
|
for device_id in rule.device_ids:
|
||||||
|
row = by_id.get(device_id)
|
||||||
|
if not row or row["quality"] != "valid":
|
||||||
|
continue
|
||||||
|
value = row["cleaned_value"]
|
||||||
|
if rule.kind == "bounds":
|
||||||
|
triggered = ((rule.lower is not None and value < rule.lower)
|
||||||
|
or (rule.upper is not None and value > rule.upper))
|
||||||
|
reason = f"{device_id}={value} {rule.unit},超出配置范围"
|
||||||
|
else:
|
||||||
|
previous = histories.get(device_id, [])[-rule.window_samples:]
|
||||||
|
if len(previous) < rule.minimum_samples:
|
||||||
|
continue
|
||||||
|
baseline = median(previous)
|
||||||
|
if baseline == 0:
|
||||||
|
continue
|
||||||
|
change = (value - baseline) / abs(baseline) * 100
|
||||||
|
triggered = (abs(change) >= rule.threshold if rule.direction == "both"
|
||||||
|
else change >= rule.threshold if rule.direction == "increase"
|
||||||
|
else change <= -rule.threshold)
|
||||||
|
reason = f"{device_id} 较历史中位值变化 {change:.1f}%,阈值 {rule.threshold}%"
|
||||||
|
usable.append(device_id)
|
||||||
|
if triggered:
|
||||||
|
hits.append(device_id)
|
||||||
|
reasons.append(reason)
|
||||||
|
if len(hits) >= rule.minimum_devices:
|
||||||
|
state["hits"] += 1
|
||||||
|
state["recoveries"] = 0
|
||||||
|
if state["event"] is None and state["hits"] >= rule.consecutive_samples:
|
||||||
|
event_id = str(uuid5(NAMESPACE_URL, f"{result['replay_id']}:{rule.rule_id}:{at.isoformat()}"))
|
||||||
|
event = {"event_id": event_id, "rule_id": rule.rule_id, "device_ids": hits,
|
||||||
|
"occurred_at": at.isoformat(), "last_observed_at": at.isoformat(),
|
||||||
|
"status": "active", "detail": ";".join(reasons)[:2000], "task_id": None}
|
||||||
|
state["event"] = event
|
||||||
|
changes.append({**event, "change": "raised"})
|
||||||
|
result["anomaly_count"] = result.get("anomaly_count", 0) + 1
|
||||||
|
elif state["event"]:
|
||||||
|
state["event"]["last_observed_at"] = at.isoformat()
|
||||||
|
elif len(usable) == len(rule.device_ids):
|
||||||
|
state["hits"] = 0
|
||||||
|
if state["event"]:
|
||||||
|
state["recoveries"] += 1
|
||||||
|
if state["recoveries"] >= rule.recovery_samples:
|
||||||
|
event = {**state["event"], "status": "resolved", "resolved_at": at.isoformat(),
|
||||||
|
"change": "resolved"}
|
||||||
|
changes.append(event)
|
||||||
|
state["event"] = None
|
||||||
|
state["recoveries"] = 0
|
||||||
|
# Incomplete frames cannot count as a consecutive hit/recovery.
|
||||||
|
elif not usable and any(device in by_id for device in rule.device_ids):
|
||||||
|
state["hits"] = state["recoveries"] = 0
|
||||||
|
frozen = {device for rule in config.rules if states[rule.rule_id]["event"]
|
||||||
|
for device in rule.device_ids}
|
||||||
|
for row in rows:
|
||||||
|
if row["quality"] == "valid" and row["device_id"] not in frozen:
|
||||||
|
history = histories.setdefault(row["device_id"], [])
|
||||||
|
history.append(row["cleaned_value"])
|
||||||
|
histories[row["device_id"]] = history[-96:]
|
||||||
|
result["processed_count"] = result.get("processed_count", 0) + len(rows)
|
||||||
|
return result, changes
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Server-enforced replay knowledge bounds and immutable corrected snapshots."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
|
||||||
|
|
||||||
|
|
||||||
|
def check_replay_window(context, start, end):
|
||||||
|
cap = datetime.fromisoformat(context["knowledge_cutoff"])
|
||||||
|
if start is None or end is None or start.tzinfo is None or end.tzinfo is None or start > end or end > cap:
|
||||||
|
raise HTTPException(422, f"回放查询需要明确时间范围,结束不得晚于 {cap.isoformat()}")
|
||||||
|
|
||||||
|
|
||||||
|
async def replay_snapshot_rows(connection, project_id, context, device_ids, start, end, limit, offset=0):
|
||||||
|
check_replay_window(context, start, end)
|
||||||
|
repository = ScadaReplayRepository(connection, project_id)
|
||||||
|
run = await repository.get(context["replay_id"])
|
||||||
|
if not set(device_ids) <= set(run["config"]["device_ids"]):
|
||||||
|
raise HTTPException(403, "设备或回放不属于当前调查")
|
||||||
|
if start < datetime.fromisoformat(run["start_time"]):
|
||||||
|
raise HTTPException(422, "当前调查证据接口仅返回本轮已处理观测,回放前基线已用于后台初始化")
|
||||||
|
rows = await repository.detected_outputs(
|
||||||
|
context["replay_id"], device_ids, start, end, context["batch_sequence"],
|
||||||
|
limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
return [{key: row[key] for key in (
|
||||||
|
"time", "device_id", "monitored_value", "cleaned_value", "quality", "reason"
|
||||||
|
)} for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def replay_burst_location_inputs(connection, project_id, context, args):
|
||||||
|
"""Build location series from immutable replay outputs, never live TSDB rows."""
|
||||||
|
if args.data_source != "monitoring":
|
||||||
|
raise HTTPException(422, "回放定位只支持 monitoring 数据源")
|
||||||
|
if args.scada_normal_start is None or args.scada_normal_end is None:
|
||||||
|
raise HTTPException(422, "回放定位必须显式提供已处理范围内的正常对照窗口")
|
||||||
|
check_replay_window(context, args.scada_burst_start, args.scada_burst_end)
|
||||||
|
check_replay_window(context, args.scada_normal_start, args.scada_normal_end)
|
||||||
|
repository = ScadaReplayRepository(connection, project_id)
|
||||||
|
run = await repository.get(context["replay_id"])
|
||||||
|
if run:
|
||||||
|
replay_start = datetime.fromisoformat(run["start_time"])
|
||||||
|
if args.scada_burst_start < replay_start or args.scada_normal_start < replay_start:
|
||||||
|
raise HTTPException(422, "定位窗口必须位于本轮已处理回放范围内")
|
||||||
|
devices = {item["device_id"]: item for item in run.get("devices", [])}
|
||||||
|
monitoring = run.get("monitoring_config") or {}
|
||||||
|
pressure_devices = monitoring.get("pressure_device_ids", [])
|
||||||
|
pressure_map = {str(devices[item]["node_id"]): item for item in pressure_devices
|
||||||
|
if item in devices and devices[item].get("node_id")}
|
||||||
|
requested_pressure = args.pressure_scada_ids or list(pressure_map)
|
||||||
|
if not requested_pressure or not set(requested_pressure) <= set(pressure_map):
|
||||||
|
raise HTTPException(422, "压力节点必须来自本轮已配置的回放监测角色")
|
||||||
|
flow_devices = monitoring.get("outlet_flow_device_ids", [])
|
||||||
|
flow_map = {str(devices[item]["link_id"]): item for item in flow_devices
|
||||||
|
if item in devices and devices[item].get("link_id")}
|
||||||
|
requested_flow = args.flow_scada_ids or []
|
||||||
|
if not set(requested_flow) <= set(flow_map):
|
||||||
|
raise HTTPException(422, "流量要素必须来自本轮已配置的出厂边界流量角色")
|
||||||
|
selected = [pressure_map[item] for item in requested_pressure] + [flow_map[item] for item in requested_flow]
|
||||||
|
rows = await repository.detected_outputs(
|
||||||
|
context["replay_id"], selected,
|
||||||
|
min(args.scada_burst_start, args.scada_normal_start),
|
||||||
|
max(args.scada_burst_end, args.scada_normal_end),
|
||||||
|
context["batch_sequence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def averages(mapping, start, end):
|
||||||
|
result = {}
|
||||||
|
for element_id, device_id in mapping.items():
|
||||||
|
values = [float(row["cleaned_value"]) for row in rows
|
||||||
|
if row.get("device_id") == device_id
|
||||||
|
and row.get("quality") in {"valid", "corrected"}
|
||||||
|
and row.get("cleaned_value") is not None
|
||||||
|
and start <= datetime.fromisoformat(row["time"]) <= end]
|
||||||
|
if not values:
|
||||||
|
raise HTTPException(422, f"回放窗口缺少 {device_id} 的有效清洗观测")
|
||||||
|
result[element_id] = sum(values) / len(values)
|
||||||
|
return result
|
||||||
|
|
||||||
|
pressure_mapping = {item: pressure_map[item] for item in requested_pressure}
|
||||||
|
flow_mapping = {item: flow_map[item] for item in requested_flow}
|
||||||
|
return {
|
||||||
|
"data_source": "monitoring",
|
||||||
|
"burst_leakage": args.burst_leakage,
|
||||||
|
"pressure_scada_ids": requested_pressure,
|
||||||
|
"burst_pressure": averages(pressure_mapping, args.scada_burst_start, args.scada_burst_end),
|
||||||
|
"normal_pressure": averages(pressure_mapping, args.scada_normal_start, args.scada_normal_end),
|
||||||
|
**({
|
||||||
|
"flow_scada_ids": requested_flow,
|
||||||
|
"burst_flow": averages(flow_mapping, args.scada_burst_start, args.scada_burst_end),
|
||||||
|
"normal_flow": averages(flow_mapping, args.scada_normal_start, args.scada_normal_end),
|
||||||
|
"use_scada_flow": True,
|
||||||
|
} if requested_flow else {}),
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Question lifecycle sharing the operational task transaction and notification bus."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
|
||||||
|
from app.domain.domain_errors import require
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskDetail, OperationalTaskSummary
|
||||||
|
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
|
||||||
|
|
||||||
|
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
||||||
|
# Legacy test repositories do not implement question persistence.
|
||||||
|
reader = getattr(service.repo, "list_questions", None)
|
||||||
|
return await reader(str(task_id)) if reader else []
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
||||||
|
for raw in await list_questions(service, task_id):
|
||||||
|
question = TaskQuestionRequest.model_validate(raw)
|
||||||
|
if question.status != "pending":
|
||||||
|
continue
|
||||||
|
question = question.model_copy(update={"status": "cancelled", "version": question.version + 1})
|
||||||
|
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||||
|
await service._resolve_notifications(task_id, kinds=["human_input_required"],
|
||||||
|
subject_type="question", subject_id=question.question_request_id,
|
||||||
|
now=now, reason="question_superseded")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_question(service: "OperationalTaskService", task_id: UUID,
|
||||||
|
command: TaskQuestionCreate) -> TaskQuestionRequest:
|
||||||
|
require(OPERATIONAL_TASK_EXECUTE in service.permissions, "PERMISSION_DENIED",
|
||||||
|
"Current user cannot publish task questions", status=403)
|
||||||
|
body = command.model_dump(mode="json")
|
||||||
|
scope = f"task:{task_id}:question:create"
|
||||||
|
async with service.repo.transaction():
|
||||||
|
await service.repo.lock(f"task:{task_id}")
|
||||||
|
replay = await service._request_replay(scope, command.request_id, body)
|
||||||
|
if replay:
|
||||||
|
return TaskQuestionRequest.model_validate(await service.repo.get_question(
|
||||||
|
str(task_id), replay["question_request_id"]))
|
||||||
|
task = await service._task(task_id)
|
||||||
|
service._require_actionable_task(task)
|
||||||
|
require(task["task_type"] == "event_investigation", "INVALID_TASK_TYPE",
|
||||||
|
"Questions require an investigation task", status=409)
|
||||||
|
await service.repo.lock(f"run:{command.run_id}")
|
||||||
|
run = await service._run(task_id, command.run_id)
|
||||||
|
require(run["status"] in {"claimed", "running"}
|
||||||
|
and run["generation"] == task["execution_generation"]
|
||||||
|
and run.get("agent_session_id") == command.source_session_id,
|
||||||
|
"SESSION_MISMATCH", "Question must belong to the active investigation run", status=409)
|
||||||
|
now = service._now()
|
||||||
|
service._require_active_run_lease(run, now)
|
||||||
|
await service._require_no_pending_calculations(task_id, run)
|
||||||
|
require(not any(q["status"] == "pending" for q in await list_questions(service, task_id)),
|
||||||
|
"QUESTION_PENDING", "Answer the existing questions first", status=409)
|
||||||
|
question = TaskQuestionRequest(question_request_id=uuid4(), task_id=task_id,
|
||||||
|
run_id=command.run_id, execution_generation=run["generation"],
|
||||||
|
source_session_id=command.source_session_id, questions=command.questions,
|
||||||
|
status="pending", version=1, created_at=now)
|
||||||
|
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||||
|
task.update(revision=task["revision"] + 1, updated_at=now)
|
||||||
|
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
|
||||||
|
await service._task_event(task, now)
|
||||||
|
await service._notify(task_id, "human_input_required", task["title"],
|
||||||
|
";".join(q.title for q in question.questions), now,
|
||||||
|
required_permission=OPERATIONAL_TASK_WRITE, subject_type="question",
|
||||||
|
subject_id=question.question_request_id)
|
||||||
|
await service._save_request(scope, command.request_id, body,
|
||||||
|
{"question_request_id": str(question.question_request_id)})
|
||||||
|
return question
|
||||||
|
|
||||||
|
|
||||||
|
async def answer_question(service: "OperationalTaskService", task_id: UUID, question_id: UUID,
|
||||||
|
command: TaskQuestionReply) -> OperationalTaskDetail:
|
||||||
|
require(OPERATIONAL_TASK_WRITE in service.permissions, "PERMISSION_DENIED",
|
||||||
|
"Current user cannot answer task questions", status=403)
|
||||||
|
body = command.model_dump(mode="json")
|
||||||
|
scope = f"task:{task_id}:question:{question_id}:answer"
|
||||||
|
async with service.repo.transaction():
|
||||||
|
await service.repo.lock(f"task:{task_id}")
|
||||||
|
if await service._request_replay(scope, command.request_id, body):
|
||||||
|
return await service.detail(task_id)
|
||||||
|
task = await service._task(task_id)
|
||||||
|
service._require_actionable_task(task)
|
||||||
|
raw = await service.repo.get_question(str(task_id), str(question_id))
|
||||||
|
require(raw is not None, "QUESTION_NOT_FOUND", "Question not found", status=404)
|
||||||
|
question = TaskQuestionRequest.model_validate(raw)
|
||||||
|
require(question.status == "pending" and question.version == command.expected_version
|
||||||
|
and question.execution_generation == task["execution_generation"],
|
||||||
|
"VERSION_CONFLICT", "Question is no longer pending in the current generation", status=409)
|
||||||
|
require(task["lifecycle_status"] == "waiting_human"
|
||||||
|
and await service.repo.get_active_run(str(task_id)) is None,
|
||||||
|
"RUN_NOT_SETTLED", "Wait until the investigation has entered waiting_human", status=409)
|
||||||
|
answers = {a.question_id: a for a in command.answers}
|
||||||
|
known = {q.question_id for q in question.questions}
|
||||||
|
require(len(answers) == len(command.answers) and set(answers) <= known,
|
||||||
|
"ANSWER_INVALID", "Unknown or duplicate questions", status=422)
|
||||||
|
for q in question.questions:
|
||||||
|
answer = answers.get(q.question_id)
|
||||||
|
selected = answer.selected_option_ids if answer else []
|
||||||
|
text = answer.custom_text.strip() if answer else ""
|
||||||
|
require(len(set(selected)) == len(selected) and set(selected) <= {o.id for o in q.options}
|
||||||
|
and (q.mode == "multiple" or len(selected) <= 1)
|
||||||
|
and (q.allow_custom or not text) and (not q.required or bool(selected or text)),
|
||||||
|
"ANSWER_INVALID", "Answer does not satisfy the question", status=422)
|
||||||
|
now = service._now()
|
||||||
|
question = question.model_copy(update={"status": "answered", "version": question.version + 1,
|
||||||
|
"answers": command.answers, "answered_at": now, "answered_by": service._actor()})
|
||||||
|
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
|
||||||
|
await service._resolve_notifications(task_id, kinds=["human_input_required"],
|
||||||
|
subject_type="question", subject_id=question_id, now=now, reason="question_answered")
|
||||||
|
await service._requeue_task(task, now)
|
||||||
|
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
|
||||||
|
await service._task_event(task, now)
|
||||||
|
await service._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
|
||||||
|
return await service.detail(task_id)
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
|
from app.algorithms.valve_isolation.topology_search import (
|
||||||
from app.native.wndb.gis.network_views import get_network_link_nodes
|
AccidentElementType,
|
||||||
|
valve_isolation_analysis,
|
||||||
|
)
|
||||||
|
from app.native.wndb.gis.network_views import get_valve_isolation_topology
|
||||||
|
|
||||||
|
|
||||||
def analyze_valve_isolation(
|
def analyze_valve_isolation(
|
||||||
network: str,
|
network: str,
|
||||||
accident_element: str | list[str],
|
accident_element: str | list[str],
|
||||||
disabled_valves: list[str] = None,
|
disabled_valves: list[str] | None = None,
|
||||||
|
accident_element_type: AccidentElementType = "auto",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
link_entries = get_network_link_nodes(network)
|
link_entries, source_nodes, closed_links = get_valve_isolation_topology(network)
|
||||||
return valve_isolation_analysis(link_entries, accident_element, disabled_valves)
|
return valve_isolation_analysis(
|
||||||
|
link_entries, accident_element, disabled_valves,
|
||||||
|
accident_element_type=accident_element_type,
|
||||||
|
water_source_nodes=source_nodes,
|
||||||
|
closed_links=closed_links,
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"Stack",
|
"Stack",
|
||||||
"Section",
|
"Section",
|
||||||
"Text",
|
"Text",
|
||||||
|
"Result",
|
||||||
"Metric",
|
"Metric",
|
||||||
"Table",
|
"Table",
|
||||||
"Chart",
|
"Chart",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"Stack",
|
"Stack",
|
||||||
"Section",
|
"Section",
|
||||||
"Text",
|
"Text",
|
||||||
|
"Result",
|
||||||
"Metric",
|
"Metric",
|
||||||
"Table",
|
"Table",
|
||||||
"Chart",
|
"Chart",
|
||||||
@@ -90,6 +92,21 @@
|
|||||||
"compact",
|
"compact",
|
||||||
"normal"
|
"normal"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"intent": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"response",
|
||||||
|
"comparison",
|
||||||
|
"report"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"density": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"compact",
|
||||||
|
"comfortable"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -116,6 +133,15 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"result",
|
||||||
|
"analysis",
|
||||||
|
"evidence",
|
||||||
|
"follow_up"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -139,6 +165,10 @@
|
|||||||
"info",
|
"info",
|
||||||
"warning"
|
"warning"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 200
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -148,6 +178,47 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
},
|
},
|
||||||
|
"Result": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 200
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 4000
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"partial",
|
||||||
|
"unconfirmed",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"summary",
|
||||||
|
"status",
|
||||||
|
"source"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
|
},
|
||||||
"Metric": {
|
"Metric": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "66fbf1105aa966a590f93fac176cbc77d22a4a6cf0a94dfcd1d3c27273f73692"
|
"sha256": "b039042be13243a88691309cb74eee105a3b71765c342edb9222374e41c0458e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
"$ref": "#/components/schemas/ManagedResourceCapabilities"
|
"$ref": "#/components/schemas/ManagedResourceCapabilities"
|
||||||
},
|
},
|
||||||
"origin": {
|
"origin": {
|
||||||
"const": "synthetic",
|
"const": "system_data",
|
||||||
"title": "Origin",
|
"title": "Origin",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -273,6 +273,12 @@
|
|||||||
],
|
],
|
||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
|
"origin": {
|
||||||
|
"const": "system_data",
|
||||||
|
"default": "system_data",
|
||||||
|
"title": "Origin",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"priority": {
|
"priority": {
|
||||||
"default": "normal",
|
"default": "normal",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1843,7 +1849,7 @@
|
|||||||
"title": "Location"
|
"title": "Location"
|
||||||
},
|
},
|
||||||
"origin": {
|
"origin": {
|
||||||
"const": "synthetic",
|
"const": "system_data",
|
||||||
"title": "Origin",
|
"title": "Origin",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2036,8 +2042,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"description": "测试监测与核查反馈样板,不是正式工单平台或设备控制服务。",
|
"description": "按项目管理业务工单与反馈、读取监测历史;测试数据保留独立来源标记。",
|
||||||
"title": "TJWater 外围系统模拟接入",
|
"title": "TJWater 外围系统接入",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,663 @@
|
|||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "locate_features"
|
||||||
|
},
|
||||||
|
"layer": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"junctions",
|
||||||
|
"pipes",
|
||||||
|
"valves",
|
||||||
|
"pumps",
|
||||||
|
"tanks",
|
||||||
|
"reservoirs",
|
||||||
|
"scada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"layer",
|
||||||
|
"feature_ids"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "zoom_to_map"
|
||||||
|
},
|
||||||
|
"center": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -180,
|
||||||
|
"maximum": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -90,
|
||||||
|
"maximum": 90
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"zoom": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 24
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"center",
|
||||||
|
"zoom"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "render_scada_analysis"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"device_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"level": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"high",
|
||||||
|
"medium",
|
||||||
|
"low",
|
||||||
|
"unrated"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"device_id",
|
||||||
|
"level"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"items"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "clear_scada_analysis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "highlight_features"
|
||||||
|
},
|
||||||
|
"layer": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"junctions",
|
||||||
|
"pipes",
|
||||||
|
"valves",
|
||||||
|
"pumps",
|
||||||
|
"tanks",
|
||||||
|
"reservoirs",
|
||||||
|
"scada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feature_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 100
|
||||||
|
},
|
||||||
|
"effect": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"steady",
|
||||||
|
"pulse"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"layer",
|
||||||
|
"feature_ids",
|
||||||
|
"effect"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "clear_highlights"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "open_chart"
|
||||||
|
},
|
||||||
|
"chart_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 200
|
||||||
|
},
|
||||||
|
"chart_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"line",
|
||||||
|
"bar"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"x_data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 100
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"chart_type",
|
||||||
|
"x_data",
|
||||||
|
"series"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"evidence_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"maxItems": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"chart_id",
|
||||||
|
"chart",
|
||||||
|
"evidence_refs"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "close_chart"
|
||||||
|
},
|
||||||
|
"chart_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"chart_id"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "set_overlay"
|
||||||
|
},
|
||||||
|
"overlay_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 200
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"geometry",
|
||||||
|
"heatmap"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "FeatureCollection"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "Feature"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "Point"
|
||||||
|
},
|
||||||
|
"coordinates": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -180,
|
||||||
|
"maximum": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -90,
|
||||||
|
"maximum": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additionalItems": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"coordinates"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "MultiPoint"
|
||||||
|
},
|
||||||
|
"coordinates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -180,
|
||||||
|
"maximum": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -90,
|
||||||
|
"maximum": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additionalItems": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 20000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"coordinates"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "LineString"
|
||||||
|
},
|
||||||
|
"coordinates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -180,
|
||||||
|
"maximum": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -90,
|
||||||
|
"maximum": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additionalItems": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 20000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"coordinates"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "MultiLineString"
|
||||||
|
},
|
||||||
|
"coordinates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -180,
|
||||||
|
"maximum": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -90,
|
||||||
|
"maximum": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additionalItems": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 20000
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 20000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"coordinates"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "Polygon"
|
||||||
|
},
|
||||||
|
"coordinates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -180,
|
||||||
|
"maximum": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -90,
|
||||||
|
"maximum": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additionalItems": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minItems": 4,
|
||||||
|
"maxItems": 20000
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 20000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"coordinates"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "MultiPolygon"
|
||||||
|
},
|
||||||
|
"coordinates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -180,
|
||||||
|
"maximum": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -90,
|
||||||
|
"maximum": 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additionalItems": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minItems": 4,
|
||||||
|
"maxItems": 20000
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 20000
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 20000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"coordinates"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"properties",
|
||||||
|
"geometry"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"features"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"overlay_id",
|
||||||
|
"title",
|
||||||
|
"mode",
|
||||||
|
"evidence_id",
|
||||||
|
"data"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "clear_overlay"
|
||||||
|
},
|
||||||
|
"overlay_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"overlay_id"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="362" viewBox="0 0 720 362" role="img" aria-labelledby="title description">
|
||||||
|
<title id="title">SCADA 演示时间轴 FM 调频盘样式</title>
|
||||||
|
<desc id="description">静态设计示意。选定区间为十月八日八点至十四点,当前时间十点十五分。半透明玻璃刻度窗内细密时间刻度横向排列,中央红色指示线固定,控制栏包含暂停、下一批和六十倍速。</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="glass" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity=".72"/>
|
||||||
|
<stop offset=".5" stop-color="#f7faff" stop-opacity=".4"/>
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity=".64"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#f7f9fc"/>
|
||||||
|
<stop offset=".12" stop-color="#f7f9fc" stop-opacity="0"/>
|
||||||
|
<stop offset=".88" stop-color="#f7f9fc" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#f7f9fc"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="blur"><feGaussianBlur stdDeviation="16"/></filter>
|
||||||
|
<filter id="shadow" x="-.1" y="-.3" width="1.2" height="1.6">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#243852" flood-opacity=".1"/>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="window"><rect x="48" y="130" width="624" height="110" rx="12"/></clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect width="720" height="362" rx="18" fill="#e9edf2"/>
|
||||||
|
<rect x="24" y="22" width="672" height="318" rx="16" fill="#fafbfd" stroke="#d7dee7"/>
|
||||||
|
<g font-family="'Noto Sans SC Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans CJK SC', sans-serif" fill="#263347">
|
||||||
|
<text x="48" y="54" font-size="13" font-weight="600">SCADA 时段演示</text>
|
||||||
|
<text x="672" y="54" text-anchor="end" font-size="11" fill="#778398">静态样式示意</text>
|
||||||
|
<text x="48" y="79" font-size="12" fill="#68758a">演示区间</text>
|
||||||
|
<text x="114" y="79" font-size="12">10 月 8 日 08:00 — 14:00</text>
|
||||||
|
<text x="360" y="118" text-anchor="middle" font-size="28" font-weight="500" font-variant-numeric="tabular-nums" letter-spacing="1">10:15:00</text>
|
||||||
|
<rect x="48" y="130" width="624" height="110" rx="12" fill="#edf2f7" filter="url(#shadow)"/>
|
||||||
|
<g clip-path="url(#window)">
|
||||||
|
<g filter="url(#blur)" opacity=".6">
|
||||||
|
<path d="M12 234 L280 132 L423 247 L749 142" fill="none" stroke="#c7d8e7" stroke-width="35"/>
|
||||||
|
<path d="M63 134 L221 219 L438 128 L647 212" fill="none" stroke="#fff" stroke-width="38"/>
|
||||||
|
</g>
|
||||||
|
<rect x="48" y="130" width="624" height="110" fill="url(#glass)"/>
|
||||||
|
<path d="M65 131 H655" stroke="#fff" stroke-width="2" opacity=".9"/>
|
||||||
|
<path d="M49 138 V231 M671 138 V231" stroke="#fff" opacity=".6"/>
|
||||||
|
<path d="M60 237 H660" stroke="#a8b6c8" opacity=".25"/>
|
||||||
|
<g fill="none" stroke="#67778b" stroke-width="1">
|
||||||
|
<path d="M90 166 V197 M180 166 V197 M270 166 V197 M360 166 V197 M450 166 V197 M540 166 V197 M630 166 V197"/>
|
||||||
|
<path d="M60 179 V196 M120 179 V196 M150 179 V196 M210 179 V196 M240 179 V196 M300 179 V196 M330 179 V196 M390 179 V196 M420 179 V196 M480 179 V196 M510 179 V196 M570 179 V196 M600 179 V196 M660 179 V196" opacity=".65"/>
|
||||||
|
</g>
|
||||||
|
<g font-size="11" text-anchor="middle" font-variant-numeric="tabular-nums">
|
||||||
|
<text x="90" y="216">09:30</text><text x="180" y="216">09:45</text>
|
||||||
|
<text x="270" y="216">10:00</text><text x="360" y="216" font-weight="600">10:15</text>
|
||||||
|
<g fill="#7b889a"><text x="450" y="216">10:30</text><text x="540" y="216">10:45</text><text x="630" y="216">11:00</text></g>
|
||||||
|
</g>
|
||||||
|
<rect x="48" y="131" width="624" height="108" fill="url(#edge)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="48.5" y="130.5" width="623" height="109" rx="11.5" fill="none" stroke="#fff" stroke-opacity=".8"/>
|
||||||
|
<path d="M356 141 H364 L360 147 Z" fill="#cf453f"/>
|
||||||
|
<path d="M360 147 V201" stroke="#cf453f" stroke-width="2"/>
|
||||||
|
<rect x="48" y="256" width="88" height="38" rx="8" fill="#4662ba"/>
|
||||||
|
<path d="M65 269 V281 M70 269 V281" stroke="#fff" stroke-width="2"/>
|
||||||
|
<text x="98" y="280" text-anchor="middle" fill="#fff" font-size="12">暂停</text>
|
||||||
|
<rect x="148" y="256" width="88" height="38" rx="8" fill="#fff" stroke="#d7dee7"/>
|
||||||
|
<text x="192" y="280" text-anchor="middle" font-size="12" fill="#7b8799">下一批</text>
|
||||||
|
<rect x="578" y="256" width="94" height="38" rx="8" fill="#fff" stroke="#d7dee7"/>
|
||||||
|
<text x="601" y="280" font-size="12">60×</text>
|
||||||
|
<path d="M651 273 L655 277 L659 273" fill="none" stroke="#64738a" stroke-width="1.5"/>
|
||||||
|
<text x="48" y="319" font-size="11" fill="#68758a">已读取 10:15 · 已检测 10:10</text>
|
||||||
|
<text x="672" y="319" text-anchor="end" font-size="11" fill="#68758a">红线对应当前时间 · 横向拨动可回看</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,248 @@
|
|||||||
|
# 从爆管上报到处置闭环
|
||||||
|
|
||||||
|
本文定义爆管调查的完整业务路线,供维护者审阅和验收。运行时指令位于 [burst-report/SKILL.md](../resources/work-order-skills/burst-report/SKILL.md),由 Server 根据绑定任务的 `workflow=burst_report` 经 MCP 分发。
|
||||||
|
|
||||||
|
重点成果是有依据的事故候选位置、隔离阀门清单和影响范围,以及经审批执行、反馈核验后的闭环记录。Agent 根据证据选择、跳过或重做分支,不按图逐节点机械调用工具。工程图覆盖多轮调查和人工业务;每轮发布的进度图只标注实际进行的工作,不复制整图冒充执行记录。
|
||||||
|
|
||||||
|
## 完整工程图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph intake[工单进入与后台任务]
|
||||||
|
order[用户或外围系统提交爆管上报工单]
|
||||||
|
source[保存原工单及来源版本、发生时间、位置和描述]
|
||||||
|
sync[同步器按来源关联调查及工单投影]
|
||||||
|
queue[Server 保存调查任务和 queued run]
|
||||||
|
claim[Worker 投递,Agent 领取并维持租约]
|
||||||
|
context[读取任务摘要、业务 Skill、历史报告和既有动作]
|
||||||
|
order --> source --> sync --> queue --> claim --> context
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph investigation[Agent 调查与工程分析]
|
||||||
|
scope[确定事件时刻、地点、调查目标和真实或演练来源]
|
||||||
|
location[核对已有资产编号;必要时解析地点和查询附近管段]
|
||||||
|
monitor[核对相关测点映射、异常窗口、正常对照和数据质量]
|
||||||
|
cover{查询是否实际覆盖事件与对照窗口}
|
||||||
|
more[缩小对象和窗口,按需补页,核对缺测及零值]
|
||||||
|
hypothesis{是否具备定位所需观测和漏水量依据}
|
||||||
|
locate[计算爆管候选排序,保留假设、覆盖范围与证据]
|
||||||
|
candidate[整合上报、资产关联、算法与现场反馈得到候选集合]
|
||||||
|
usable{是否存在可用于隔离评估的候选资产}
|
||||||
|
isolation[逐候选分析拓扑隔离边界、必关阀及受影响节点]
|
||||||
|
isolateok{可隔离且阀门可用性有可核验依据}
|
||||||
|
alternate[纳入已知不可关闭阀门,重新评估替代隔离方案]
|
||||||
|
hydraulic[按需模拟候选关阀组合的压力和流量影响]
|
||||||
|
impact[区分拓扑停水区、水力影响区和有资料支持的用户范围]
|
||||||
|
findings[形成候选位置、阀门清单、影响对比和建议方案]
|
||||||
|
fieldbasis{是否已有足以支持具体现场核验的结论}
|
||||||
|
fielddraft[形成有位置、核验目标及回执要求的现场核验建议]
|
||||||
|
gap[真实关键数据缺失:发布 final insufficient_data 并结束本轮]
|
||||||
|
error[接口、权限或配置异常:记录具体失败并由宿主结束失败运行]
|
||||||
|
scope --> location --> candidate
|
||||||
|
scope --> monitor --> cover
|
||||||
|
cover -->|未覆盖且可补齐| more --> monitor
|
||||||
|
cover -->|已覆盖或确认实际缺失| hypothesis
|
||||||
|
hypothesis -->|具备依据| locate --> candidate
|
||||||
|
hypothesis -->|条件不具备,明确原因| candidate
|
||||||
|
candidate --> usable
|
||||||
|
usable -->|候选有效,标明待现场确认| isolation --> isolateok
|
||||||
|
isolateok -->|有可评估方案| hydraulic --> impact --> findings
|
||||||
|
isolateok -->|已知存在不可关阀| alternate --> isolation
|
||||||
|
isolateok -->|无法形成方案| fieldbasis
|
||||||
|
usable -->|尚无候选| fieldbasis
|
||||||
|
fieldbasis -->|可形成具体核验建议| fielddraft
|
||||||
|
fieldbasis -->|当前判断所需证据确实缺失| gap
|
||||||
|
monitor -->|不可恢复的查询异常| error
|
||||||
|
location -->|不可恢复的必要查询异常| error
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph approval[报告、审批与现场处置]
|
||||||
|
interim[保存本轮 interim concluded 阶段报告]
|
||||||
|
proposals[创建具体待审批草稿,关联证据和执行前置条件]
|
||||||
|
finalround[保存本轮 final 报告,由宿主结束本轮 run]
|
||||||
|
review[有权限人员审核当前草稿版本]
|
||||||
|
decision{审批结果}
|
||||||
|
revise[驳回理由保存并触发原调查新一轮]
|
||||||
|
submit[Server 通过配置的适配器幂等提交]
|
||||||
|
receipt{外部提交结果}
|
||||||
|
reconcile[使用同一执行请求号核对回执,不重复下发]
|
||||||
|
unsupported[未配置或明确失败:记录原因并重新分析]
|
||||||
|
workkind{本次获批的是核验还是处置}
|
||||||
|
fieldcheck[现场核验准确位置、漏水迹象及资产,回传实际记录]
|
||||||
|
notice[人工实施获批的通知、供水保障和应急安排]
|
||||||
|
isolatefield[现场核验阀门及实际隔离结果]
|
||||||
|
repair[现场定位、止水和抢修,回传实际故障管段及修复记录]
|
||||||
|
clean[按现场风险及获批方案清洗、消毒或冲洗]
|
||||||
|
restore[核验修复与适用复测,按批准方案恢复供水]
|
||||||
|
follow[记录恢复后压力流量、漏水复查及用户回访]
|
||||||
|
findings --> interim
|
||||||
|
fielddraft --> interim
|
||||||
|
interim --> proposals --> finalround --> review --> decision
|
||||||
|
decision -->|驳回| revise
|
||||||
|
decision -->|批准| submit --> receipt
|
||||||
|
receipt -->|结果不明或仍在执行| reconcile --> receipt
|
||||||
|
receipt -->|明确失败或不支持| unsupported
|
||||||
|
receipt -->|工单已创建或设备动作有回执| workkind
|
||||||
|
workkind -->|现场核验事项| fieldcheck
|
||||||
|
workkind -->|有依据的处置事项| notice
|
||||||
|
notice --> isolatefield --> repair --> clean --> restore --> follow
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph closure[现场反馈、复核与原事件闭环]
|
||||||
|
feedback[外围系统保存关联工单的新反馈和单调版本]
|
||||||
|
requeue[Server 校验来源并为原调查排队新 generation]
|
||||||
|
reread[重新读取现场事实、执行回执和最新监测证据]
|
||||||
|
verify[对照预测与现场结果,复核修复、恢复和风险]
|
||||||
|
faultstate{复核结果支持故障、误报还是尚待核验}
|
||||||
|
falsealarm[核对排除故障的证据及人工确认]
|
||||||
|
closed{处置及闭环证据是否齐全}
|
||||||
|
remaining[列出未完成事项及对应已有或待审批动作]
|
||||||
|
sourceclosed{原上报工单已结案且版本已核对}
|
||||||
|
readyreport[发布具备结案条件报告并结束本轮]
|
||||||
|
closedreport[发布闭环复核 final 报告并结束本轮]
|
||||||
|
closeowner[有权限人员在原工单系统核对并完成原事件结案]
|
||||||
|
closedcase[报告与来源事实一致,确认业务闭环]
|
||||||
|
follow --> feedback --> requeue
|
||||||
|
fieldcheck --> feedback
|
||||||
|
reread --> verify --> faultstate
|
||||||
|
faultstate -->|已确认故障| closed
|
||||||
|
faultstate -->|有依据排除故障| falsealarm --> sourceclosed
|
||||||
|
faultstate -->|尚需核验| remaining
|
||||||
|
closed -->|尚未齐全或复测异常| remaining --> interim
|
||||||
|
closed -->|证据齐全| sourceclosed
|
||||||
|
sourceclosed -->|是| closedreport --> closedcase
|
||||||
|
sourceclosed -->|否| readyreport --> closeowner --> feedback
|
||||||
|
reread -->|现场位置或阀门条件改变| candidate
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph presentation[贯穿调查的地图、图表与数据整理]
|
||||||
|
progress[发布真实调查进展和 Mermaid 状态]
|
||||||
|
map[高亮候选管段、闪烁建议关阀、展示已有证据覆盖层]
|
||||||
|
chart[打开异常与对照曲线,展示候选及影响对比表]
|
||||||
|
filepermit{会话是否已有文件写入权限}
|
||||||
|
jsonl[按权限整理有来源引用的 JSONL 调查数据]
|
||||||
|
reportui[通过报告 ui_blocks 发布 json-render 结构化界面]
|
||||||
|
sourceview[展示保持证据来源,失败不阻断业务调查]
|
||||||
|
progress --> map --> chart --> filepermit
|
||||||
|
filepermit -->|已获准且需要导出| jsonl --> reportui
|
||||||
|
filepermit -->|未获准或无需导出| reportui
|
||||||
|
reportui --> sourceview
|
||||||
|
end
|
||||||
|
|
||||||
|
context -->|首次或需要重新分析| scope
|
||||||
|
context -->|已有新反馈待复核| reread
|
||||||
|
revise --> requeue
|
||||||
|
unsupported --> requeue
|
||||||
|
requeue --> queue
|
||||||
|
gap -->|补充事实被 Server 接受后才重开| requeue
|
||||||
|
findings -->|无须新增动作| finalonly[无需新增动作时保存 final 报告并结束本轮]
|
||||||
|
scope -.-> progress
|
||||||
|
candidate -.-> map
|
||||||
|
impact -.-> chart
|
||||||
|
verify -.-> reportui
|
||||||
|
```
|
||||||
|
|
||||||
|
图中的现场处置由实际审批、工单与回执推进;顺序按现场紧迫性和获批方案调整。必要的紧急通知、现场核验和供水保障不应等待可选的完整模型计算。`clean` 为按风险适用的步骤,不能把每次爆管都判断为污染。`hydraulic` 在缺少模型、时序输入或权限时记录具体限制,仍可保留已完成的拓扑隔离结果。
|
||||||
|
|
||||||
|
`requeue` 表示 Server 接受了新业务事实后分配下一轮,不是 Agent 复活旧会话或自行循环等待。原工单结案由原系统有权限人员处理,后台 Agent 目前没有直接关闭原工单的工具。`completed`、`concluded`、动作 `succeeded` 和“业务闭环”分别核对,不能互相推导。
|
||||||
|
|
||||||
|
## 将业务步骤映射为现有能力
|
||||||
|
|
||||||
|
以下产品查询与计算均使用 `mcp_tjwater_` 前缀。参数以本轮实际发现的工具 schema 为准。
|
||||||
|
|
||||||
|
| 业务动作 | 已有入口 | 必须形成的结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 接续原任务 | `get_task_context`,依次读取 summary、skill 及所需分节;`get_task_run_history` | 原工单来源、事件时刻、调查代次、流程版本、既有动作与现场反馈 |
|
||||||
|
| 查找地点和资产 | `geocode` / `search_project_places`、`query_nearby_elements`、`get_element_properties`、`get_node_connections` | 实际候选管段或节点、位置来源、匹配理由;邻近不代表故障确认 |
|
||||||
|
| 核对监测 | `list_scada_devices`、`get_scada_device`、`query_scada_readings`、`query_element_history` | 设备和模型节点的映射、异常及对照窗口、单位、缺测、分页覆盖 |
|
||||||
|
| 压力异常侦测 | `run_burst_detection` | 至少 5 个完整压力测点及此前 15 天数据满足时才调用;不把侦测当作定位 |
|
||||||
|
| 爆管候选定位 | `run_burst_location` | 候选排序、算法实际返回的评价值、观测范围、漏水量假设及来源 |
|
||||||
|
| 逐候选隔离 | `analyze_valve_isolation` | `accident_elements`、`isolatable`、`must_close_valves`、`optional_valves`、`affected_nodes`、`affected_node_count` 及已知 `disabled_valves` |
|
||||||
|
| 水力影响 | `analyze_valve_closure` | 指定关阀组合、时间窗、输出对象和模拟压力流量;参数不足时保留明确原因 |
|
||||||
|
| 用户影响 | 已授权资产属性、区域及外围资料查询 | 受影响对象、关联依据和覆盖限制;没有用户资料不估造户数 |
|
||||||
|
| 发布和提议 | `publish_task_progress`、`publish_task_report`、`propose_task_action` | 持久化报告、证据引用和待审批动作;动作不是执行事实 |
|
||||||
|
| 反馈接续 | Server `external-feedback` / `human-evidence` 及 worker | 同一任务的新轮次、真实来源版本、修复和复测证据 |
|
||||||
|
|
||||||
|
定位的 `burst_leakage` 必须有依据,单位为 m³/s;`pressure_scada_ids` 是模型节点 ID,不能填设备查询编号,也可省略并使用服务的项目配置。monitoring 模式正常窗可成对省略,使用前一天同窗,但这只是一项对照假设,仍要核对数据有效性。simulation 模式必须引用已有分析运行 UUID,不把在线重算当成已有分析运行。已知事故管段可直接做候选隔离;不必先完成一次没有必要的定位计算,也不能把已知答案作为算法独立定位成功的证明。
|
||||||
|
|
||||||
|
隔离工具支持明确标注的候选管段,无须等到现场确认才评估方案。多个候选的比较优先分开调用;把多个 ID 一次传入表示联合事故场景,不等同于逐个比较。`disabled_valves` 仅填已有事实支持的不可关阀;空数组不证明现场阀门均可用。`isolatable=false` 不能解释为零影响,即使 `affected_nodes` 为空也须解释返回的计数和限制。
|
||||||
|
|
||||||
|
## 像调查员一样查询、查看、比较和复核
|
||||||
|
|
||||||
|
每一步先明确待回答的问题,再查小范围资料,观察实际结果后决定下一步。例如:核实地址,查附近管段并查看属性,关联压力点,比较事件前后及正常对照,检查候选,评估关阀影响。只把必要的查询、事实和判断依据写入进度及报告,不保存模型内部推理。
|
||||||
|
|
||||||
|
- 不满足于“查过 SCADA”:必须确认返回的时间实际覆盖事件。查询 24 小时而只返回当天前 45 分钟时,应缩到事件前后的小窗,按需补页,不直接进入最终判断。
|
||||||
|
- 正常基线、实测、模型模拟、外围演练和人工反馈分别标注;缺测不是零值,异常不是爆管确认,候选评分不是校准后的概率。
|
||||||
|
- 查不到事故管段时先检查地址解析、附近资产与上下游关联;能查询的字段先查询,不能直接全部交给现场。
|
||||||
|
- 既有证据通过 `get_task_evidence` 的有界分页读取,不重复拉取同一批事实。失败、权限拒绝和存储未配置不当作“无异常”或“无数据”。
|
||||||
|
- 可以用最多两个前台 `investigation-reader` 并行核对无依赖的问题。读取、计算、发布和文件权限仍按角色执行;reader 只返回查询事实及真实证据编号。
|
||||||
|
|
||||||
|
## 地图与图表操作
|
||||||
|
|
||||||
|
后台调查统一调用 `publish_task_presentation`,每个不同命令使用不同 UUID `request_id`,相同命令重试复用原编号。以下为工具已有能力,不需要浏览器自动化。
|
||||||
|
|
||||||
|
| 调查进展 | command.action | 取值及依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 找到实际候选资产 | `highlight_features` | `layer=pipes` / `junctions`,`feature_ids` 为已核实的地图要素编号,`effect=steady` |
|
||||||
|
| 正在核查候选或建议关阀 | `highlight_features` | 对应 pipes 或 valves,`effect=pulse`;闪烁只表示调查关注,不代表设备正在操作 |
|
||||||
|
| 查看异常与对照 | `open_chart` | 有限长度的时间和值,引用真实 `evidence_refs`;不同日期的对照对齐方式在标题或来源中说明 |
|
||||||
|
| 查看已有空间结果 | `set_overlay` | 提供 `overlay_id/title/mode/evidence_id`,Server 从证据提取几何,不接收手绘影响区 |
|
||||||
|
| 撤下旧的临时展示 | `close_chart` / `clear_overlay` / `clear_highlights` | 清理自己记录的临时展示;清理失败不修改业务结果 |
|
||||||
|
|
||||||
|
支持的图层为 junctions、pipes、valves、pumps、tanks、reservoirs、scada。先核实业务 ID 与地图 feature ID 的映射,不能把设备编号、节点编号与 GIS 编号混用。每次高亮最多 100 个要素,需要更多时说明所显示的子集;不要假定多次命令会累积成完整范围。没有几何时可以按已知 ID 高亮、用表格解释结果,不画包络多边形冒充影响范围。
|
||||||
|
|
||||||
|
图表最多 100 个横轴值和 8 组数值序列,单位须在系列名称或标题中明确。不能把 null 缺测填零以通过 schema;可以按有数据的区间分图,并保留缺测说明。`recorded` 仅表示请求保存。浏览器未开 Dev 跟随时仍继续调查,不能等待地图显示回执。后台接口目前没有摄像机缩放、平移、鼠标点击或任意图层开关命令;不要调用仅供聊天的 `locate_features`、`zoom_to_map` 或原生浏览器工具替代。
|
||||||
|
|
||||||
|
## JSONL 调查数据与 json-render 展示
|
||||||
|
|
||||||
|
JSONL 用于整理已经取得的数据与分析结果,不补造监测、阀门、现场反馈或审批。推荐每行一个对象,记录 `record_type/origin/source_evidence_refs`,并按对象保留时间窗、单位、模型或假设、覆盖限制和实际结果。`origin` 沿用来源分类;本地派生记录引用输入证据,并说明转换方法。不要把 JSONL 文件自身当作 Server 已保存的业务证据。
|
||||||
|
|
||||||
|
按内容选择 `event_context`、`monitoring_coverage`、`candidate`、`isolation`、`hydraulic_impact`、`field_feedback`、`closure_check`,无需为没有结果的类别制造空壳文件。候选记录写实际排序及评价值;隔离记录保留建议必关阀、可选阀、已知不可关阀及受影响节点;闭环记录逐项保留已核验、待核验或不适用及原因。JSONL 是导出约定,不新增 MCP 输入字段或服务端数据协议。
|
||||||
|
|
||||||
|
以下两行只演示文件格式,`synthetic`、`example_only` 与空证据引用表示它们不可用于实际报告或审批,不包含真实分析结果:
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"record_type":"candidate","origin":"synthetic","example_only":true,"source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":null,"evaluation_value":null,"status":"not_computed","limitations":["仅说明字段,未执行定位"]}
|
||||||
|
{"record_type":"closure_check","origin":"synthetic","example_only":true,"source_evidence_refs":[],"item":"repair_verified","status":"not_verified","observed_at":null,"limitations":["无现场反馈,不能声称闭环"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果当前会话已获准使用原生文件写入,则在当前 workspace 下写 `files/burst-investigation-<generation>.jsonl`,保留原始证据不变;通过允许的读取工具逐行复核 JSON、行数、对象 ID、时间和单位。只引用宿主绑定的 generation,不能从用户文本自选。不得读取兄弟会话、业务数据库、宿主源码或凭据;需要本地转换时只使用已获准的原生工具及沙箱 Shell,不引入 HTTP、SQL 或自由命令查询桥。
|
||||||
|
|
||||||
|
当前没有专用的免审批 JSONL 导出工具。文件权限未授予时,不通过 Shell 绕过、不让后台无限等待批准、不声称已生成文件;继续完成报告内的结构化数据,并如实说明文件未保存。恢复写入授权须由有权限的人或宿主处理,Skill 不扩大权限。
|
||||||
|
|
||||||
|
图表、表格和指标使用 `publish_task_report.report.ui_blocks`,每块为 `answer-ui@1`,包含独立 `block_id`、`fallback_text` 和 json-render 的 `spec.root/elements`,外层附真实 `evidence_refs`。最多 8 块、总计 400 KB,单块还受现有 schema 限制。优先组织以下内容:
|
||||||
|
|
||||||
|
| 展示 | 建议组件 | 数据来源 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 候选管段及理由 | Table、Evidence、MapLocation | 地点、资产与定位证据;明确候选和已确认的区别 |
|
||||||
|
| 异常及正常对照 | Chart | 同单位的实际序列,说明时间对齐及缺测 |
|
||||||
|
| 关阀与隔离范围 | Table、Metric、MapLocation | 隔离证据、阀门属性及候选场景 |
|
||||||
|
| 水力与用户影响 | Table、Chart | 指定输出节点的模拟结果及真实用户关联资料 |
|
||||||
|
| 处置与闭环核对 | Table、Evidence | 动作回执、现场反馈、恢复监测和原工单版本 |
|
||||||
|
|
||||||
|
只用工具公布的组件属性。JSONL 可作为整理中间格式,但目前没有“把任意 JSONL 直接传给 json-render”的通道;需转换为已注册组件的 JSON 属性。后台不用 `present_answer`,不把原始 JSONL 输出到聊天正文,也不把表单提交当成已保存现场证据。小数据和简单结论可直接文字表达。
|
||||||
|
|
||||||
|
## 本轮成果与爆管闭环的验收
|
||||||
|
|
||||||
|
报告至少回答下列四类问题,逐项给出实际结果或经过查询证实的具体限制;无需固定排版或创建固定数量的动作。
|
||||||
|
|
||||||
|
1. **位置**:事故已确认还是仅有候选?每个候选的模型/地图编号、匹配或定位依据、排名及未确认事项是什么?
|
||||||
|
2. **隔离**:每个候选场景是否可隔离?应关、可选和已知不可关的阀门分别是什么?有无替代方案及现场前置核验?
|
||||||
|
3. **影响**:拓扑受影响节点、水力压降或失供范围、用户关联分别有哪些实际结果?未覆盖什么?没有水力或用户资料时分别说明,不能推定为零。
|
||||||
|
4. **处置与闭环**:当前可审批的工作是什么?已有哪些动作、审批、提交回执和现场事实?还缺什么反馈才具备结案条件?
|
||||||
|
|
||||||
|
有具体结论和动作建议时,先保存 `interim/concluded`,再 `propose_task_action`,最后保存 `final`。人工执行事项使用 `work_order_type=general`,保留原爆管事件。工单描述写位置、任务内容、依赖、执行前核验、需回传的事实和责任要求;`propose_task_action` 当前 payload 没有独立的 dependencies 或 feedback_requirements 字段,不发明字段。没有客户通知适配器时,将通知作为人工工单事项,不宣称已发送。
|
||||||
|
|
||||||
|
闭环复核至少核对实际故障资产及原因、隔离/阀门实施记录、抢修结果、适用的清洗及水质复测、恢复供水记录和恢复后监测、用户通知及回访、未决动作与遗留风险、原上报工单结案状态。不适用事项必须有事件依据;未收到结果写待核验,不把记录创建时间当现场完成时间。只齐备处置证据而原工单未结案时,结论为“具备结案条件,待原系统确认”,而非“已闭环”。误报或未发现故障的结案也要有支持排除的实际核查与人工确认。
|
||||||
|
|
||||||
|
必要查询失败、权限拒绝和存储未配置属于执行异常,按 schema 修正可纠正问题;仍无法取得当前判断必需的资料时交宿主记录失败。已通过有效查询确认关键数据确实缺失且无法补齐,才发布 `final/insufficient_data`,提供 blocking 缺口和查询依据,建议与待办为空,不在该退出分支再生成补数据工单。能够明确形成一项现场核验结论时可提议具体核验动作,但不能把“尚未开展定位”包装为“已完成定位调查”。
|
||||||
|
|
||||||
|
## 能力边界与验证
|
||||||
|
|
||||||
|
本次交付为工程图和运行时 Skill 文本,使用现有 MCP、发布、审批与反馈能力;不新增自动关单、鼠标自动化、摄像机操作、JSONL 导出接口或数据库字段。Server 目前没有按 Skill 检查定位/隔离成果的强制验收器,本文的业务验收要求仍由 Agent 执行并由人复核。
|
||||||
|
|
||||||
|
当前 Skill 仅通过 `section=skill` 整份分发,没有相对路径 reference 读取协议,因此运行时指令保持自包含,不能要求 Agent 读取本工程文档。内容需控制在既有内联预算内。Server 更新资源后,应按原发布方式让实际 Server 实例取得新文件;Agent 读取时核对 `name/version/content_sha256`,不把本地文件更新当成运行中实例已生效。
|
||||||
|
|
||||||
|
验证包含 Mermaid 语法解析、本地路径和工具 ID 核对、Skill 分发/权限/体积测试及 `git diff --check`。后续真实行为验收应分别覆盖:有效观测和漏水量依据下的定位;已知候选但缺漏水量时的候选隔离;不可关阀替代方案;SCADA 首屏未覆盖事件时继续读取;接口失败明确终止;文件权限缺失不绕过;无浏览器时照常调查;实际反馈触发新轮及最终闭环复核。没有真实模型计算或现场回执,不报告定位准确性或业务闭环已通过。
|
||||||
|
|
||||||
|
关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)、[后台工作进程](operational-task-workers.md)。
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# MCP 后台计算队列
|
||||||
|
|
||||||
|
API 保留 `uvicorn --workers 4`。长计算在项目 BizDB 持久化入队,由独立计算进程领取;所有 API 进程读取相同任务状态,不依赖粘性会话。HTTP 返回预算由宿主管理,Server 默认 120 秒、Agent 默认 180 秒。Server 通过工具目录 `_meta["io.tjwater/request-timeout"]` 统一发布 `defaultSeconds` 和 `maxSeconds`,所有工具(包括状态和结果读取)使用同一配置来源。Server 兼容保留 `timeout_seconds` 参数;Agent 对模型隐藏并拒绝该参数,按目录预算代填。Server 请求上限为 240 秒,Agent 加 60 秒返回余量并封顶 270 秒,原生 MCP 与本地连接的总时限为 300 秒;上游 HTTP 的 5 分钟空闲超时保留。提交、查询状态和读取结果都应及时返回,不等待整个计算。模拟时间仍由 `duration` 表示。
|
||||||
|
|
||||||
|
后台执行时限由管理员配置 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS`(默认 3600 秒),入队时单独写入任务记录。HTTP 参数不参与作业幂等参数比较,也不会缩短或延长后台执行。任务表的内部 `timeout_seconds` 列仍保存执行时限。HTTP 超时或断开只结束本次等待,后台作业需通过独立取消工具取消。
|
||||||
|
|
||||||
|
```text
|
||||||
|
前端 / Agent → API × 4 → 项目 BizDB calculation_jobs
|
||||||
|
↑
|
||||||
|
独立计算 supervisor
|
||||||
|
↓
|
||||||
|
受管理的计算子进程组
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具契约
|
||||||
|
|
||||||
|
原工具名继续使用。以下工具提交后返回 `QueryResult`,其中 `semantics=task`,`data` 包含 `job_id`、`status`、`tool` 和 `poll_after_ms`;这只证明提交状态,不是分析结果或调查证据:
|
||||||
|
|
||||||
|
- `run_burst_location`、`run_leakage_identification`、`run_burst_detection`
|
||||||
|
- `run_simulation`、`run_burst_simulation`、`run_contaminant_simulation`
|
||||||
|
- `analyze_valve_closure`、`analyze_flushing`、`analyze_pressure_regulation`
|
||||||
|
- `optimize_sensor_placement`
|
||||||
|
- `save_simulation_scenario`、`create_sensor_placement` 的 `dry_run=false` 执行阶段
|
||||||
|
|
||||||
|
后两项保留预览、摘要校验和原有审批;提交与执行时均校验权限及预览摘要,已有写入回执继续约束重复请求。`analyze_valve_isolation`、`calculate_demand_allocation` 保持同步执行。普通查询和其他写入不进入此队列。
|
||||||
|
|
||||||
|
| MCP 工具 | 输入 | 返回 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `get_calculation_job` | `job_id` | 状态、工具、时间和安全错误码 |
|
||||||
|
| `get_calculation_result` | `job_id`,可选 `data_path/limit/offset` | 成功后读取原分析结果;未成功返回任务状态 |
|
||||||
|
| `list_calculation_jobs` | 可选 `status/limit/offset` | 当前授权会话及调查轮次的任务列表 |
|
||||||
|
| `cancel_calculation_job` | `job_id` | 排队任务可直接取消;运行任务先进入 `cancel_requested` |
|
||||||
|
|
||||||
|
状态为 `queued/running/cancel_requested/succeeded/failed/cancelled/timed_out/interrupted`。返回创建、开始、结束和排队/结果到期时间;`result_available` 表示结果是否仍可读取。任务取消不等于在线模型写入回滚;发生失败、超时或中断时,需要核查已有写入,不能自动重算整个窗口。
|
||||||
|
|
||||||
|
当前使用普通 MCP 业务工具承载队列契约,没有宣称已实现协议级 Tasks 扩展。Server 和 Agent 目录必须配套更新,旧 Agent 不应接收新的队列返回契约。
|
||||||
|
|
||||||
|
## 身份、幂等和结果
|
||||||
|
|
||||||
|
任务记录保存提交人的元数据用户 ID、项目、原工具参数和受限授权快照,不保存 Bearer、数据库 DSN 或短期 `context_handle`。执行和读取结果时重新查询当前用户、成员关系、项目路由、权限及调查运行租约。任务不会因为短期 MCP 句柄失效而丢失;也不会借持久化任务绕过撤销的权限。
|
||||||
|
|
||||||
|
普通计算使用宿主从原生工具调用记录解析出的稳定调用 ID;同一次传输重试复用任务,不将新的业务计算自动按参数合并。重写工具同时保留原审批 `request_id` 幂等语义。不同参数复用同一操作编号会被拒绝。
|
||||||
|
|
||||||
|
调查的排队回执不写成分析证据。成功结果读取时,按原计算来源、任务及运行验证,并幂等地保存证据。未完成的计算阻止提交最终报告、`insufficient_data` 或将运行标记成功;中间进度仍可保存。
|
||||||
|
|
||||||
|
Agent 调查执行在原生回合空闲后由宿主短轮询等待,继续维持调查心跳。新作业进入终态即触发模型读取结果,其余作业继续后台运行。状态读取遇到网络、限流或临时服务错误,最多重试 3 次,采用指数退避和随机抖动;权限拒绝、上下文失效立即停止,重试耗尽明确报错,不重新提交或取消计算。普通聊天展示任务编号,可在当前会话后续查询。异常流结束时若无法安全继续处理新结果,会明确失败而不是虚报完成;Server 中的计算结果仍保留。
|
||||||
|
|
||||||
|
## 排队期限与结果保留
|
||||||
|
|
||||||
|
每项目最多 100 个未结束作业(queued/running/cancel_requested),跨 API 进程用数据库事务锁串行执行幂等检查与容量准入;同一请求的重试优先返回已有作业。排队最长 3600 秒,超过后标为 `timed_out`,错误码为 `QUEUE_TIMEOUT`;开始执行后使用独立的执行期限。提交、查询和 Worker 领取都会核查到期任务,所以 Worker 停止时,查询也能结束过期排队。部署监控仍须覆盖 Worker 进程状态和最老排队任务年龄,队列到期机制不代替服务可用性监控。
|
||||||
|
|
||||||
|
结果正文在终态后保留 7 天,到期由队列维护清除,读取返回 `RESULT_EXPIRED`。任务状态、输入、幂等标识和已归档调查证据不随之删除,也不会因结果过期重新计算。状态列表只读取状态字段,不加载完整计算结果。
|
||||||
|
|
||||||
|
## 并发、取消和恢复
|
||||||
|
|
||||||
|
一个 supervisor 默认全项目合计并发 2 项,独立于 API 的 4 个进程。它持有元数据库的会话级 advisory lock,第二个 supervisor 会拒绝启动;当前不是多台计算节点各自扩容的模式。各项目轮转领取,使用 PostgreSQL 短事务 `SKIP LOCKED`,租约代次约束心跳和结果写回。
|
||||||
|
|
||||||
|
每项任务有独立子进程组;取消、执行超时或失去租约时先停止进程组,再写终态。守护管道在 supervisor 被强杀后关闭,负责终止仍在运行的算法进程。API 重启不影响计算 supervisor;计算进程崩溃后,旧任务标为中断,不自动重放。
|
||||||
|
|
||||||
|
原生临时文件写入独立任务目录。临时模型数据库创建前登记到该目录的账本,正常结束或取消后只清理明确登记的临时数据库。清理失败返回 `CLEANUP_FAILED` 并保留恢复目录;启动时检查项目、任务和执行代次,确认旧租约过期后恢复清理。无法验证归属或清理失败时停止领取新工作。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
1. 先对每个目标项目的 BizDB 依次应用 `resources/sql/026_calculation_jobs.sql` 和 `resources/sql/027_calculation_job_lifecycle.sql`,包括未来项目使用的相应业务模板。已有 026 的库只需追加 027。不要应用到 TimescaleDB。027 增加时间字段与索引,不清除结果;已有结果从首次迁移起获得完整 7 天保留期。必须先迁移,再加载新 API/Worker 代码。
|
||||||
|
2. 配置 `CALCULATION_WORKER_PROJECT_IDS` 为明确的项目 UUID 列表,配置现有元数据库连接及数据库路由解密密钥。Worker 不需要新 OAuth 密钥;仅部署侧工作进程读取这些设置。
|
||||||
|
3. 同步发布 Server 和 Agent,在接入长计算前启动独立 Worker。不要从 API `lifespan` 启动,不要把调查 Worker 当作计算 Worker。
|
||||||
|
|
||||||
|
从 Server 仓库启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/run_calculation_worker.py
|
||||||
|
```
|
||||||
|
|
||||||
|
systemd 模板:`infra/systemd/tjwater-calculation-worker.service`。Docker 使用同一 Server 镜像的独立服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infra/docker/docker-compose.yml --profile calculations up -d calculation-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose 使用 `../../.env` 注入配置,需确保其中数据库地址在容器内可达。`calculation-state` volume 保存任务恢复账本,不能当作临时缓存删除。非容器启动可用 `CALCULATION_WORKER_STATE_DIR` 指定持久目录。
|
||||||
|
|
||||||
|
并发默认 2、轮询默认 2 秒、租约 30 秒、心跳 5 秒。算法内部并行度仍消耗额外 CPU/内存;调整并发必须依据实际资源测量。
|
||||||
|
|
||||||
|
回滚前暂停接收新的队列计算并等待或明确取消在途任务,核对有副作用任务的写入。保留任务表、结果和恢复目录;旧同步版本不支持继续处理这些作业,不能直接删除记录后重新提交。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python -m pytest tests/unit/test_mcp_calculation_jobs.py tests/unit/test_calculation_worker.py tests/unit/test_calculation_cleanup.py tests/unit/test_mcp_query.py tests/unit/test_mcp_analysis.py tests/unit/test_operational_tasks.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 PostgreSQL 并发测试为 `tests/integration/test_calculation_queue_storage.py`。它要求 `CALCULATION_QUEUE_TEST_DSN` 指向名称以 `tjwater_calc_test_` 开头的专用可丢弃数据库,不能指向业务库。覆盖四连接重复提交、参数冲突、并发领取、项目和用户隔离、取消竞争、失效代次拒绝和过期租约恢复。
|
||||||
|
|
||||||
|
这些验证不代替真实项目算法运行、浏览器断线重连或部署验收。生产接入还应观察超过 5 分钟的真实计算、恢复后结果读取和原生临时资源清理。
|
||||||
@@ -55,21 +55,60 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
|
|||||||
|
|
||||||
| command.action | 参数 |
|
| command.action | 参数 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| highlight_features | layer、feature_ids、effect:steady 或 pulse |
|
| locate_features | layer、feature_ids |
|
||||||
|
| zoom_to_map | center:WGS84 经度纬度、zoom:0–24 |
|
||||||
|
| highlight_features | layer、feature_ids、必填 effect:steady 或 pulse |
|
||||||
| clear_highlights | 无 |
|
| clear_highlights | 无 |
|
||||||
| open_chart | chart_id、chart、非空 evidence_refs |
|
| open_chart | chart_id、chart、非空 evidence_refs |
|
||||||
| close_chart | chart_id |
|
| close_chart | chart_id |
|
||||||
| set_overlay | overlay_id、title、mode:geometry 或 heatmap、evidence_id |
|
| set_overlay | overlay_id、title、mode:geometry 或 heatmap、evidence_id,可选 data |
|
||||||
| clear_overlay | overlay_id |
|
| clear_overlay | overlay_id |
|
||||||
|
| render_scada_analysis | items:device_id 与 high/medium/low/unrated 等级,设备编号不重复 |
|
||||||
|
| clear_scada_analysis | 无 |
|
||||||
|
|
||||||
|
以上十种动作与 Chat 的 `present_view`、Dev JSONL 共用扁平 `command: {action, ...字段}`。Chat 使用 `frontend-action@2` 和执行回执;任务仍只记录展示请求,Dev 只做本地预览。三者使用独立的地图源、SCADA 图层和图表状态。Dev 的动作数据行必须为 `{"record_type":"presentation","command":{...}}`;界面块行使用 `record_type: ui_block`,新示例明确带 `phase: final`。普通调查数据保持来源字段,不执行其中的 action。仓库示例见[前端统一展示夹具](../../next-tjwater-frontend/tests/fixtures/unified-presentation.jsonl),其中编号来自测试夹具。
|
||||||
|
|
||||||
图表限定折线和柱形,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
|
图表限定折线和柱形,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
|
||||||
序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。
|
序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。
|
||||||
覆盖层输入不接受模型提供的 `data`。Server 从证据的不可变数据快照提取现有 GeoJSON,
|
覆盖层的权威数据来自不可变证据。省略 `data` 时,Server 从证据快照提取现有 GeoJSON,
|
||||||
或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。
|
或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。
|
||||||
|
若传入 `data`,必须与同一证据快照解析出的内容一致;Server 始终发布快照解析结果,不能用调用参数覆盖证据。
|
||||||
热力图只接受记录中的点,权重为有限非负数,未记录权重时取 1。
|
热力图只接受记录中的点,权重为有限非负数,未记录权重时取 1。
|
||||||
没有坐标的地址、空数据、非法几何、模型自绘区域均不能生成覆盖层。该接口不做地址解析、
|
没有坐标的地址、空数据、非法几何、模型自绘区域均不能生成覆盖层。该接口不做地址解析、
|
||||||
范围推算或污染传播计算。展示事件总大小上限为 512 KiB,超限返回 422。
|
范围推算或污染传播计算。展示事件总大小上限为 512 KiB,超限返回 422。
|
||||||
|
|
||||||
|
`generate_node_coverage` 是独立的节点空间覆盖工具,适用于普通项目聊天和后台调查,
|
||||||
|
不绑定爆管、阀门隔离或固定执行顺序。调用者提供需要比较的节点分组,Server 从当前项目
|
||||||
|
`gis.node_geometries` 读取真实位置。监测设备、投诉、地址等须先关联模型节点,
|
||||||
|
不接受模型自由生成的坐标。具体适用范围和参数见[节点覆盖工具](node-coverage-tool.md)。
|
||||||
|
|
||||||
|
计算先投影到局部米制坐标,取凹包(默认 ratio=0.25)、加展示缓冲(默认 25 m),
|
||||||
|
再以 2 m 容差简化并转回 WGS84;凹包比例与缓冲距离可在有界参数内选择。
|
||||||
|
默认展示各组的地理包络,不判断连接关系;需要时可按模型连接拆分,额外指定视为断开的连接。
|
||||||
|
少量点、重合点和共线点同样通过缓冲形成面。多组结果一起保存在 `result.map_payload`,
|
||||||
|
避免连续 `set_overlay` 覆盖前一组;`result.coverage` 保存来源节点、映射完整性及算法参数。
|
||||||
|
该面不推断故障、污染、实际停水、水力影响或用户服务边界,缓冲距离也不是供水服务半径。
|
||||||
|
缺失几何时标记 `partial`,没有可用位置时不生成面。输入最多 8 组、合计 10000 个不同节点;
|
||||||
|
几何输出遵守覆盖层顶点和大小限制,超限要求缩小范围,不静默抽样或改变原分析结果。
|
||||||
|
|
||||||
|
MCP 保存完整查询响应作为不可变证据,展示解析器同时支持响应信封中的
|
||||||
|
`data.result.map_payload`。Agent 使用 `set_overlay` 引用本次 `evidence_id` 并省略 `data`。
|
||||||
|
超过内联返回预算时,响应的 `data.presentation.command` 仍保留可发布的展示命令,
|
||||||
|
`data.presentation.groups` 保留覆盖完整性计数,
|
||||||
|
完整几何留在证据中,不能把缩略返回误认为没有空间成果。调查按实际步骤同步发布展示:
|
||||||
|
地址得到明确坐标后缩放定位,取得候选资产后定位候选,检查影响节点后生成并展示覆盖示意,
|
||||||
|
形成或调整关阀方案后定位阀门,读取监测对照后展示点位及序列。
|
||||||
|
图表同样随结果返回发布:监测趋势与事件对照用折线图,可比候选评价或方案用柱状图,
|
||||||
|
水力及污染传播等模拟按实际返回的时刻、对象和指标画图。压力、流量、浓度等不同单位分图,
|
||||||
|
注明来源、范围与实测或模型属性;没有同口径基准时不计算或绘制改善量,缺测不补零。
|
||||||
|
过程图表通过 `open_chart` 提供 `chart_id/chart/evidence_refs`,每图最多100个横轴值、8组序列,
|
||||||
|
超限时分图或明确说明抽样、聚合范围;单值或无可比数据用文字或表格。
|
||||||
|
关键趋势与比较结论保留到报告中,最终报告的图表不替代过程发布。
|
||||||
|
业务 Skill 说明本步骤应展示什么,调查指令统一说明发布方法、非空几何检查、去重和失败处理。
|
||||||
|
同一阶段批量展示,数据或方案没有变化时不重复请求;不为地图动作增加无关业务分析。
|
||||||
|
报告的 `map_targets` 仅为手动定位入口。关键步骤的数据缺失或展示失败需记录具体限制,
|
||||||
|
没有浏览器连接也不阻止分析或报告收尾。
|
||||||
|
|
||||||
`GET /api/v1/operational-tasks/{task_id}/presentation-cursor` 在同一事务中读取
|
`GET /api/v1/operational-tasks/{task_id}/presentation-cursor` 在同一事务中读取
|
||||||
`task_id/execution_generation/active_run/event_cursor`,其中活跃 run 仅返回 `run_id/generation/status`。
|
`task_id/execution_generation/active_run/event_cursor`,其中活跃 run 仅返回 `run_id/generation/status`。
|
||||||
浏览器打开 Dev 联动、切换任务或断线重连时,以此建立新基线,只消费之后的展示事件。
|
浏览器打开 Dev 联动、切换任务或断线重连时,以此建立新基线,只消费之后的展示事件。
|
||||||
@@ -98,7 +137,7 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
|
|||||||
实际 worker 曾成功投递指定调查,模型发布进展和报告,但因 service 账号的任务权限过滤
|
实际 worker 曾成功投递指定调查,模型发布进展和报告,但因 service 账号的任务权限过滤
|
||||||
导致 `approved_data_scopes` 为空,误作数据不足结束。这不是业务数据确实不足。
|
导致 `approved_data_scopes` 为空,误作数据不足结束。这不是业务数据确实不足。
|
||||||
用户确认开放规则后,任务 API 已移除该额外过滤,沿用账号已有的项目读取权限。
|
用户确认开放规则后,任务 API 已移除该额外过滤,沿用账号已有的项目读取权限。
|
||||||
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围合成工单成功;没有新增
|
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围系统工单成功;没有新增
|
||||||
角色权限或放宽跨项目、审批和设备控制边界。旧委托路径保留兼容,已有权限无需委托凭据。
|
角色权限或放宽跨项目、审批和设备控制边界。旧委托路径保留兼容,已有权限无需委托凭据。
|
||||||
用户级 `tjwater-operational-worker.service` 已重新启用。旧报告保留,未自动重新调查,
|
用户级 `tjwater-operational-worker.service` 已重新启用。旧报告保留,未自动重新调查,
|
||||||
不能把旧报告视为修复后的业务验收。任务创建时间下界和并发配置见
|
不能把旧报告视为修复后的业务验收。任务创建时间下界和并发配置见
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
完整图语法的解析失败只影响图示,正文、依据和审核仍可阅读。
|
完整图语法的解析失败只影响图示,正文、依据和审核仍可阅读。
|
||||||
|
|
||||||
每条 node_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
|
每条 node_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
|
||||||
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一;
|
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一,
|
||||||
|
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
|
||||||
最多 100 条引用。证据必须来自报告 evidence_refs,动作必须属于当前任务。
|
最多 100 条引用。证据必须来自报告 evidence_refs,动作必须属于当前任务。
|
||||||
节点可以说明候选方向与条件关系;图中的分支、状态文字不能证明实际并发、动作执行或现场完成。
|
节点可以说明候选方向与条件关系;图中的分支、状态文字不能证明实际并发、动作执行或现场完成。
|
||||||
图的颜色、字体和交互由前端统一配置。正文不包含 SVG 或 HTML。
|
图的颜色、字体和交互由前端统一配置。正文不包含 SVG 或 HTML。
|
||||||
|
|||||||
@@ -52,17 +52,37 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
|
|||||||
- 数据源和计算假设必须有依据。定位漏水量和DMA总漏损约束使用m³/s;冲洗使用服务实际接收的m³/h,不沿用旧REST误标的L/s。工频泵序列0/1,变频泵序列为Hz;每项对应模型水力步长。冲洗当前限LPS/CMH模型。选址当前推荐压力点,不冒充污染溯源优化。
|
- 数据源和计算假设必须有依据。定位漏水量和DMA总漏损约束使用m³/s;冲洗使用服务实际接收的m³/h,不沿用旧REST误标的L/s。工频泵序列0/1,变频泵序列为Hz;每项对应模型水力步长。冲洗当前限LPS/CMH模型。选址当前推荐压力点,不冒充污染溯源优化。
|
||||||
- 调查结果经现有快照/证据/回执链保存,只有返回evidence_id后才引用为系统证据;普通聊天不自动保存调查证据。模拟结果始终是analysis而非实测。已知模拟事件源管段是输入背景,不是算法定位准确性的证明。
|
- 调查结果经现有快照/证据/回执链保存,只有返回evidence_id后才引用为系统证据;普通聊天不自动保存调查证据。模拟结果始终是analysis而非实测。已知模拟事件源管段是输入背景,不是算法定位准确性的证明。
|
||||||
|
|
||||||
|
## 关阀隔离输入与影响范围
|
||||||
|
|
||||||
|
REST `/api/v1/valve-isolation-analyses` 与 MCP `analyze_valve_isolation` 共用同一服务。
|
||||||
|
`accident_element_type` 默认为 `auto`:先识别管段 ID,再识别节点 ID;指定 `link` 时只匹配管段,指定 `node` 时只匹配节点。
|
||||||
|
节点表和管段表分别保证 ID 唯一,但不同类型可以重号;分析同名节点必须显式指定 `node`。
|
||||||
|
多个 ID 表示联合事故,不表示分别比较;类型参数作用于本次调用中的所有 ID。
|
||||||
|
|
||||||
|
服务从模型主表的一致快照读取连接关系、初始开关状态和水源,不依赖 GIS 几何或物化视图刷新。
|
||||||
|
不可关阀按保持现状处理;已关闭的管段、泵和阀门不参与连通,已关闭阀门不再列为建议关阀。
|
||||||
|
未知的事故 ID、未知的不可关阀 ID 和空事故列表会被拒绝。
|
||||||
|
|
||||||
|
- `affected_nodes` / `affected_node_count` 仍表示最近可关阀门围成的直接隔离区。不可隔离时列表为空,计数保留。
|
||||||
|
- `supply_affected_nodes` / `supply_affected_node_count` 表示按建议关阀后新增失去水源连接的节点,包含间接受影响区域,排除原本就不连通水源的节点。不可隔离或缺少水源资料时为 `null`。
|
||||||
|
- `source_check_performed` 表示是否已执行水源连通检查。`isolatable=true` 要求存在关阀边界,且关闭这些阀门后直接隔离区不再连通任何水源。
|
||||||
|
- `isolation_failure_reason` 可为 `connected_to_water_source`、`water_sources_unavailable`、`no_boundary_valves`;可隔离时为 `null`。
|
||||||
|
|
||||||
|
以上是模型初始开关状态下的无向拓扑分析,水库和水池均按潜在水源处理;不模拟泵或止回阀的单向性、时变控制、水池蓄水量、压力与流量。
|
||||||
|
因此“失去水源连接”不能直接当作经过水力计算或现场核实的停水范围。已保存的旧调查证据不会自动重算,需重新调用隔离分析生成新结果。
|
||||||
|
本次无需数据库迁移。
|
||||||
|
|
||||||
## 资源边界与发布
|
## 资源边界与发布
|
||||||
|
|
||||||
12项计算和13项预览式写入工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。
|
长计算采用[后台计算队列](calculation-queue.md)。10项分析计算与2项重计算写入的正式执行阶段快速返回任务编号,再使用 `get_calculation_job`、`get_calculation_result`、`list_calculation_jobs`、`cancel_calculation_job` 管理。原有预览与审批仍有效;排队回执不作为分析证据。
|
||||||
|
|
||||||
例如 `duration=3600, timeout_seconds=900` 表示模拟1小时、允许等待15分钟。Agent的上游HTTP/SDK预算为所选预算加5秒,整个宿主调用另留60秒处理认证、上下文绑定和清理;OpenCode连接上限从工具目录计算并写入宿主连接文件。本地MCP请求关闭Bun空闲断连,仍受上述调用截止时间控制。普通查询继续使用原30秒Server预算与35秒宿主预算。
|
HTTP 预算由宿主从目录元数据读取,Server 默认 120 秒、Agent 默认 180 秒;爆管定位不再设置单独的长超时。`timeout_seconds` 保留在 Server 兼容入口,Agent 对模型隐藏并拒绝覆盖。Server 上限 240 秒,Agent 加 60 秒余量并封顶 270 秒,外层原生 MCP 保留 300 秒总时限与上游 Bun 的 5 分钟空闲超时。后台执行时限由管理员配置 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS`(默认 3600 秒),不受该 HTTP 参数控制;模拟时间仍由 `duration` 表示。
|
||||||
|
|
||||||
计算在线程执行,不阻塞事件循环,单Server进程最多两项计算。超时不能杀死底层原生计算,槽位一直保留到计算真正退出;超时结果不会补写为调查成功结果,不自动反复重试。本次未增加后台计算作业队列。普通聊天大结果仍受现有12KB内联限制,调查结果可经证据分页读取。
|
`analyze_valve_isolation` 和 `calculate_demand_allocation` 保持同步。队列消费者独立于Uvicorn的4个API进程,默认全部配置项目合计并发2项,并通过元数据库单实例锁防止重复启动。取消、超时和租约失效需要停止计算子进程组;成功结果按原运行授权保存证据,未完成计算阻止调查提前结案。
|
||||||
|
|
||||||
在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。
|
在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。
|
||||||
|
|
||||||
无需新增数据库迁移或模型配置。须同步发布Server、Agent和前端证据枚举;先更新Server目录,再重启Agent,client模式还需重载原生连接。保持既有OAuth、MCP开关及数据库路由配置,不自动更改权限或重启调查。
|
队列接入需要在目标项目BizDB依次应用 `resources/sql/026_calculation_jobs.sql`、`resources/sql/027_calculation_job_lifecycle.sql` 并启动独立计算Worker。Server与Agent需配套发布,client模式还需重载原生连接。既有OAuth与项目权限保持不变;完整部署与回滚步骤见后台计算队列文档。
|
||||||
|
|
||||||
本次验证限schema、权限、协议目录和mock业务函数,不运行真实模拟、优化或付费模型。真实项目的模板同步状态、运算耗时、SCADA覆盖和结果准确性仍需另行验收。
|
本次验证限schema、权限、协议目录和mock业务函数,不运行真实模拟、优化或付费模型。真实项目的模板同步状态、运算耗时、SCADA覆盖和结果准确性仍需另行验收。
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# 产品 MCP 查询:本地实施与启用
|
# 产品 MCP 查询:本地实施与启用
|
||||||
|
|
||||||
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和67项工具(42项查询、12项计算、13项预览式写入);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
|
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和71项工具(42项原查询、12项计算、13项预览式写入、4项计算任务管理);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
|
||||||
|
|
||||||
`run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。
|
`run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。
|
||||||
|
|
||||||
计算工具支持Agent按次指定 `timeout_seconds`,默认120秒;计算上限默认1小时,由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 配置并在工具schema公布。Agent、OpenCode和本地HTTP调用预算随目录同步,普通查询仍保持原有短超时。等待超时不意味着底层线程已停止或在线基线已回滚,禁止自动重试整个写入窗口。
|
长计算使用持久化队列,原工具快速返回任务编号,新增状态、结果、列表和取消工具。HTTP 返回预算由目录元数据和宿主管理,Server 默认120秒、Agent默认180秒;模型不再提供 `timeout_seconds`,Server保留兼容入口。后台执行时限由管理员独立配置。提交和读取保持短调用,不延长上游5分钟空闲超时。独立计算Worker、迁移与权限边界见[后台计算队列](calculation-queue.md)。
|
||||||
|
|
||||||
## 认证与数据边界
|
## 认证与数据边界
|
||||||
|
|
||||||
@@ -14,13 +14,13 @@ Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数
|
|||||||
|
|
||||||
调查数据返回前,在短事务内追加现有任务证据并保存调用回执。同一主体、会话、任务、run 和原生 call ID 的重试复用证据;不同参数或失效范围明确报错。七类原有访问申请 scope 保持原契约,MCP 的新增来源只扩充快照来源枚举。没有新增报告发布、审批、控制、Shell 或递归委派权限。
|
调查数据返回前,在短事务内追加现有任务证据并保存调用回执。同一主体、会话、任务、run 和原生 call ID 的重试复用证据;不同参数或失效范围明确报错。七类原有访问申请 scope 保持原契约,MCP 的新增来源只扩充快照来源枚举。没有新增报告发布、审批、控制、Shell 或递归委派权限。
|
||||||
|
|
||||||
普通查询默认20条;目录类最多500条,时序最多10000条、20个标识、24小时;offset 最大10000。Agent 首次优先单对象、必要的短时间窗和小页,仅在证据不足时按需扩大,不自动遍历全网。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次普通查询仍为30秒),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
|
普通查询默认20条;目录类最多500条,时序最多10000条、20个标识、24小时;offset 最大10000。Agent 首次优先单对象、必要的短时间窗和小页,仅在证据不足时按需扩大,不自动遍历全网。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次请求同时受该剩余总预算约束),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
|
||||||
|
|
||||||
单份结果超过8MiB报错;超过12KB的调查工具消息只返回证据资源引用,完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。
|
单份结果超过8MiB报错;超过12KB的调查工具消息只返回证据资源引用,完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。
|
||||||
|
|
||||||
`get_task_context` 默认 section=summary,只查固定元数据、160字符任务标题及200字符结论预览,并返回 available_sections;preview_only 和 truncated 明确说明不是完整报告。按 section=task/report/evidence/actions/progress/access_requests/plan/run 读取详情,report_revision 保留选择旧报告的语义。data_path 选择嵌套值,limit 默认20、最多500,offset 最大10000;证据、动作和授权申请集合以稳定 ID 为键,按 ID 字典序分页。字符串按字符分页。详情在数据库返回前检查8KB预算,超限缩页或选更深路径。缺少可选进度或授权申请表时返回 metadata.available=false,不冒充空事实。绑定权限范围只读取七类既有 scope,不再加载整个任务上下文。不增加工具数量、数据库表或迁移,也不改变原 REST 完整上下文契约。
|
`get_task_context` 默认 section=summary,只查固定元数据、160字符任务标题及200字符结论预览,并返回 available_sections;preview_only 和 truncated 明确说明不是完整报告。按 section=task/report/evidence/actions/progress/access_requests/plan/run 读取详情,report_revision 保留选择旧报告的语义。data_path 选择嵌套值,limit 默认20、最多500,offset 最大10000;证据、动作和授权申请集合以稳定 ID 为键,按 ID 字典序分页。字符串按字符分页。详情在数据库返回前检查8KB预算,超限缩页或选更深路径。缺少可选进度或授权申请表时返回 metadata.available=false,不冒充空事实。绑定权限范围只读取七类既有 scope,不再加载整个任务上下文。不增加工具数量、数据库表或迁移,也不改变原 REST 完整上下文契约。
|
||||||
|
|
||||||
外围 MCP 直接读取所选项目 BizDB 的样板事实和既有时序。它不执行外围样板 HTTP 的故障场景注入;混有合成记录的结果按 synthetic 保守标记。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选并保留 synthetic;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的合成点冒充实测设备。
|
外围 MCP 直接读取所选项目 BizDB 的工单事实和既有时序。外围工单统一返回 `system_data`;测试数据和 DEMO-* 观测仍按各自数据来源处理。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的测试点冒充实测设备。
|
||||||
|
|
||||||
## 启用步骤
|
## 启用步骤
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 节点空间覆盖工具
|
||||||
|
|
||||||
|
`generate_node_coverage` 增加 Agent 可选择的空间表达能力,将已取得的模型节点集合转为
|
||||||
|
可解释、可复核的覆盖示意。它可以在项目聊天、任务协作和后台调查中使用,
|
||||||
|
不要求爆管任务、隔离成功或特定报告。生成、发布展示和业务分析是三个独立操作。
|
||||||
|
|
||||||
|
## 适用范围
|
||||||
|
|
||||||
|
| 问题 | 输入依据 | 输出含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 候选对象分布在哪里 | 已核实的候选模型节点 | 候选节点空间包络 |
|
||||||
|
| 两组监测点覆盖有何不同 | 已核对的设备到模型节点映射 | 已接入节点分布比较,不证明监测可观测性 |
|
||||||
|
| 区域成员分布在哪里 | 已查询的区域成员节点 | 成员节点包络,不替代已有行政区或 DMA 边界 |
|
||||||
|
| 哪些节点属于模型影响集合 | 已有拓扑或模拟结果中的节点集合 | 结果节点的空间示意,不重新计算影响 |
|
||||||
|
| 投诉关联的节点如何分布 | 已解析并核对的投诉到模型节点关联 | 已关联节点分布,不证明投诉或污染真实边界 |
|
||||||
|
|
||||||
|
只有地址或未关联设备时,先查询位置和资产关联;已有权威区域几何时优先展示原始边界。
|
||||||
|
本工具不接收任意坐标、HTML、SQL、脚本或调用者指定的项目身份,不执行关阀、模拟或方案保存。
|
||||||
|
|
||||||
|
## 参数和结果
|
||||||
|
|
||||||
|
- `groups`:最多 8 组,每组提供唯一 `group_id`、`title` 和 `node_ids`;组之间可重叠,
|
||||||
|
合计最多 10000 个不同节点。不按组名自动赋予隔离、污染或服务覆盖语义。
|
||||||
|
- `concave_ratio`:0–1,默认 0.25;越接近 1 越接近凸包。分散的点取单一包络可能包含较大空白,
|
||||||
|
应按实际比较目标分组,并明确示意含义。
|
||||||
|
- `buffer_m`:大于 0、最多 1000 m,默认 25 m,仅是展示缓冲。
|
||||||
|
- `split_by_connectivity`:默认 false。true 时按所选节点的模型连接拆分,并排除初始 CLOSED 连接。
|
||||||
|
`excluded_link_ids` 可额外指定最多 200 个断开连接,适用于已有关阀建议等有依据的绘图情景。
|
||||||
|
这不会修改模型,也不能替代完整拓扑分析。
|
||||||
|
|
||||||
|
`result.map_payload` 是多组共存的 GeoJSON;`result.coverage` 记录算法参数、节点 ID、
|
||||||
|
已映射数量、缺失几何节点、分组数量和 `display_only` 边界。缺少部分位置时返回 `partial`,
|
||||||
|
不补造坐标。单点、重合点或共线点通过米制缓冲生成可绘制面。
|
||||||
|
|
||||||
|
## 与展示工具配合
|
||||||
|
|
||||||
|
生成工具不会移动地图。项目聊天可用 `present_view` 展示本次返回的几何;后台调查用
|
||||||
|
`publish_task_presentation` 的 `set_overlay`,引用生成结果自己的 `evidence_id` 并省略 `data`,
|
||||||
|
Server 从不可变证据解析完整几何。大结果内联缩略时,`data.presentation.command` 保留可发布命令。
|
||||||
|
`recorded` 仍只表示请求已记录,浏览器显示和后台调查完成各自独立。
|
||||||
|
|
||||||
|
本次爆管报告只是一个验证样例:可将 24 个直接隔离节点、295 个供水影响节点作为两组,
|
||||||
|
按已有三阀建议设置连通分组;不将这种调用固定进 `analyze_valve_isolation`。
|
||||||
@@ -72,8 +72,8 @@ OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:8787
|
|||||||
OPERATIONAL_WORKER_TOKEN_URL=https://<identity-host>/realms/<realm>/protocol/openid-connect/token
|
OPERATIONAL_WORKER_TOKEN_URL=https://<identity-host>/realms/<realm>/protocol/openid-connect/token
|
||||||
OPERATIONAL_WORKER_CLIENT_ID=<dedicated-service-client>
|
OPERATIONAL_WORKER_CLIENT_ID=<dedicated-service-client>
|
||||||
OPERATIONAL_WORKER_CLIENT_SECRET=<inject-securely>
|
OPERATIONAL_WORKER_CLIENT_SECRET=<inject-securely>
|
||||||
OPERATIONAL_ACTION_PERIPHERAL_URL=http://127.0.0.1:8011
|
OPERATIONAL_ACTION_PERIPHERAL_URL=http://127.0.0.1:8000/peripheral
|
||||||
OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8011
|
OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8000/peripheral
|
||||||
```
|
```
|
||||||
|
|
||||||
选择 OAuth 时不要同时配置 `OPERATIONAL_WORKER_BEARER`,否则固定 Bearer 优先。不要配置设备命令网关进行本次合成联调。
|
选择 OAuth 时不要同时配置 `OPERATIONAL_WORKER_BEARER`,否则固定 Bearer 优先。不要配置设备命令网关进行本次合成联调。
|
||||||
@@ -109,8 +109,9 @@ Server 校验该会话属于当前活动运行。跨用户投递查询与任务
|
|||||||
原对话用户可以确认收到的回复,但不能改写目标会话或原生消息 ID。
|
原对话用户可以确认收到的回复,但不能改写目标会话或原生消息 ID。
|
||||||
|
|
||||||
既有 worker 每轮最多对账四页、每页 50 条私有待投递消息,即使调查并发已满仍继续对账,
|
既有 worker 每轮最多对账四页、每页 50 条私有待投递消息,即使调查并发已满仍继续对账,
|
||||||
不增加进程或配置项。临时网络错误保留消息等待重试;权限拒绝、不存在等确定性
|
不增加进程或配置项。临时网络错误保留消息等待重试;400、403、410、422 等确定性
|
||||||
拒绝保存失败结果。消息正文、发送人和会话归属不写入项目事件日志。
|
拒绝保存失败结果。404 可能表示目标会话属于另一个 Agent 实例,因此保持待投递,
|
||||||
|
不把路由未命中误记为消息终态失败。消息正文、发送人和会话归属不写入项目事件日志。
|
||||||
待投递接口按创建时间与消息 ID 返回 keyset 游标,worker 跨轮保留扫描位置;
|
待投递接口按创建时间与消息 ID 返回 keyset 游标,worker 跨轮保留扫描位置;
|
||||||
扫到末页后下一轮从头重试。长期空闲对话的待收消息不会一直占住首屏、阻止后续调查投递。
|
扫到末页后下一轮从头重试。长期空闲对话的待收消息不会一直占住首屏、阻止后续调查投递。
|
||||||
|
|
||||||
@@ -122,6 +123,8 @@ Server 校验该会话属于当前活动运行。跨用户投递查询与任务
|
|||||||
|
|
||||||
`resources/sql/015_workbench_events_and_task_runs.sql` 在项目 BizDB 中增加 `domain_event_log` 和 `operational_task_runs`。后续迁移默认只应用到 `tjwater_v2`,扩展规则见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。事件与业务投影在同一事务提交,`LISTEN/NOTIFY` 只负责唤醒;浏览器与 worker 始终按持久游标补读。每个 API 进程对活跃项目最多保留一条 LISTEN 连接,无订阅者 60 秒后关闭。
|
`resources/sql/015_workbench_events_and_task_runs.sql` 在项目 BizDB 中增加 `domain_event_log` 和 `operational_task_runs`。后续迁移默认只应用到 `tjwater_v2`,扩展规则见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。事件与业务投影在同一事务提交,`LISTEN/NOTIFY` 只负责唤醒;浏览器与 worker 始终按持久游标补读。每个 API 进程对活跃项目最多保留一条 LISTEN 连接,无订阅者 60 秒后关闭。
|
||||||
|
|
||||||
|
`resources/sql/030_operational_run_agent_ownership.sql` 为运行增加 `agent_instance_id`。Worker 从目标 Agent 的 `/health` 读取实例标识,只恢复、续期该实例拥有的本地会话,并在投递时要求 Agent 回显同一标识。Server 在领取、心跳和完成时保存或校验归属,避免多个独立 Agent 共用项目队列时,一个实例反复刷新另一个实例并不存在的 session。部署顺序是:先迁移各目标 BizDB,再发布 Server、Agent 与 Worker;每个 Agent 的 `AGENT_INSTANCE_ID` 必须唯一且跨重启稳定。旧运行的归属保持为空,不会由任意 Worker 猜测接管。
|
||||||
|
|
||||||
事件默认保留 7 天。清理脚本会从 Metadata DB 解析项目路由,并在发现多个项目共享同一 BizDB 时拒绝运行。先确认 `OPERATIONAL_WORKER_PROJECT_ID` 对应 `tjwater_v2`,然后运行定向 dry-run:
|
事件默认保留 7 天。清理脚本会从 Metadata DB 解析项目路由,并在发现多个项目共享同一 BizDB 时拒绝运行。先确认 `OPERATIONAL_WORKER_PROJECT_ID` 对应 `tjwater_v2`,然后运行定向 dry-run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -138,7 +141,7 @@ conda run -n server python scripts/cleanup_workbench_events.py \
|
|||||||
|
|
||||||
禁止省略 `--project-id` 执行清理。数据库维护脚本同样遵循先在 `tjwater_v2` 验证、再逐库扩展的规则。
|
禁止省略 `--project-id` 执行清理。数据库维护脚本同样遵循先在 `tjwater_v2` 验证、再逐库扩展的规则。
|
||||||
|
|
||||||
浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源。
|
浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源,不允许 `*`。配置说明见 [README](../README.md#环境配置)。
|
||||||
|
|
||||||
## 外围样板工单同步
|
## 外围样板工单同步
|
||||||
|
|
||||||
@@ -147,11 +150,11 @@ conda run -n server python scripts/sync_peripheral_work_orders.py --once
|
|||||||
conda run -n server python scripts/sync_peripheral_work_orders.py
|
conda run -n server python scripts/sync_peripheral_work_orders.py
|
||||||
```
|
```
|
||||||
|
|
||||||
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到消息即读取分页工单,将外围版本投影到 `external_work_order` 任务;默认每 300 秒兜底对账。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、`tjwater_v2` 范围与启用步骤见 [外围工单主动同步](peripheral-work-order-events.md)。
|
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到消息即读取分页工单,将外围版本按系统数据投影到 `external_work_order` 任务;默认每 300 秒兜底对账。工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、`tjwater_v2` 范围与启用步骤见 [外围工单主动同步](peripheral-work-order-events.md)。
|
||||||
|
|
||||||
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
|
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
|
||||||
|
|
||||||
通用合成检查工单不属于预约任务。早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;同步器按不可编辑的专用 seed actor 跳过旧种子工单。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。`018_remove_synthetic_check_task_projections.sql` 只清理专用 seed actor 创建的旧检查投影,不按用户可编辑的标题或状态删除任务。三张独立演示工单已迁入外围样板,由同一同步器维护。
|
早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;`018_remove_synthetic_check_task_projections.sql` 清理了当时错误生成的预约投影。当前同步器投影所有有效外围工单,`029_peripheral_work_orders_system_data.sql` 将既有工单及任务快照统一为系统数据。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。
|
||||||
|
|
||||||
水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。
|
水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。
|
||||||
|
|
||||||
@@ -165,7 +168,7 @@ conda run -n server python scripts/sync_peripheral_work_orders.py
|
|||||||
|
|
||||||
## 人工确认后的动作
|
## 人工确认后的动作
|
||||||
|
|
||||||
- `OPERATIONAL_ACTION_PERIPHERAL_URL`:使用既有 `/api/sample/v1/work-orders` 创建合成外围工单。成功回执说明工单已创建,不表示现场工作已经完成。
|
- `OPERATIONAL_ACTION_PERIPHERAL_URL`:使用既有 `/api/sample/v1/work-orders` 创建外围工单。成功回执说明工单已创建,不表示现场工作已经完成。
|
||||||
- `OPERATIONAL_ACTION_COMMAND_GATEWAY_URL`:可选设备命令网关。未配置时明确返回不支持执行,不能生成成功回执。
|
- `OPERATIONAL_ACTION_COMMAND_GATEWAY_URL`:可选设备命令网关。未配置时明确返回不支持执行,不能生成成功回执。
|
||||||
- `OPERATIONAL_ACTION_TIMEOUT_SECONDS`:外部请求超时,默认 10 秒。
|
- `OPERATIONAL_ACTION_TIMEOUT_SECONDS`:外部请求超时,默认 10 秒。
|
||||||
|
|
||||||
|
|||||||
+19
-14
@@ -1,8 +1,12 @@
|
|||||||
# 外围样板服务
|
# 外围系统接入服务
|
||||||
|
|
||||||
该独立 FastAPI 服务提供历史监测回放、固定合成数据,以及 synthetic 测试工单和测试数据的版本化 CRUD。管理入口位于供水工作台“外围样板”。它不控制设备,也不修改正式业务资产。
|
该 FastAPI 子应用接入真实业务工单,提供版本化 CRUD、处理反馈和事件调查入口,同时保留独立的历史监测回放与演练数据接口。生产环境挂载在主 Server 的 `/peripheral`,随 `next-backend` 单容器发布;源码仍可独立启动用于本地隔离调试。管理入口位于供水工作台“外围系统”。
|
||||||
|
|
||||||
浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接样板服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。
|
外围工单的 `origin` 固定为 `system_data`,前端录入、同步器、任务快照、执行回执和工单查询均使用该来源,不再提供演练来源分支。业务录入代表系统中的上报,不代表投诉内容或故障原因已核实,Agent 仍需查询实际证据。
|
||||||
|
|
||||||
|
此次变更不修改表结构;部署时需在每个项目业务库执行 `resources/sql/029_peripheral_work_orders_system_data.sql`,统一修正源工单、修订记录、任务快照和外围反馈证据。API 路径、配置键中的 `sample` 为兼容名称,不代表工单是模拟数据。更新需发布 Server、同步器、Agent 与前端对应版本。
|
||||||
|
|
||||||
|
浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接外围服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。权限键沿用现有契约,不用于判断数据来源。
|
||||||
|
|
||||||
生产环境不需要为外围服务新增域名。前端设置
|
生产环境不需要为外围服务新增域名。前端设置
|
||||||
`TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`,在现有前端 NPMplus Proxy Host 中增加以下
|
`TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`,在现有前端 NPMplus Proxy Host 中增加以下
|
||||||
@@ -10,17 +14,16 @@ Custom Location:
|
|||||||
|
|
||||||
| 配置项 | 值 |
|
| 配置项 | 值 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Location | `/peripheral/`,必须保留末尾斜杠 |
|
| Location | `/peripheral`,不添加末尾斜杠 |
|
||||||
| Scheme | `http` |
|
| Scheme | `http` |
|
||||||
| Forward Hostname / IP | NPMplus 能访问的应用机地址,通常与现有前端上游相同 |
|
| Forward Hostname / IP | `192.168.1.114`,不追加路径或末尾斜杠 |
|
||||||
| Forward Port | `8011` |
|
| Forward Port | `8001` |
|
||||||
| Forward Path | 留空 |
|
| Forward Path | 留空 |
|
||||||
|
|
||||||
NPMplus 会去掉 `/peripheral/` 前缀,因此浏览器请求
|
容器内部仍监听 `8000`,生产发布在宿主机映射为 `8001`,避免与已有服务冲突。该配置保留 `/peripheral` 前缀,因此浏览器请求
|
||||||
`/peripheral/api/sample/v1/context` 时,上游收到 `/api/sample/v1/context`。不要在 Forward
|
`/peripheral/api/sample/v1/context` 时,主 Server 收到相同路径并交给挂载的子应用。不要在
|
||||||
Hostname 后再追加 `/api/sample/v1`。外围服务应只向 NPMplus 所在网络开放 `8011`,不要直接开放公网。
|
Forward Hostname 后追加 `/api/sample/v1`。本机开发和预览模式也将 `/peripheral` 代理到
|
||||||
本机开发和预览模式也提供等价的 `/peripheral` 代理,可通过
|
`TJWATER_SERVER_API_BASE_URL`,默认是 `http://127.0.0.1:8000`。
|
||||||
`TJWATER_PERIPHERAL_SAMPLE_UPSTREAM` 覆盖默认上游 `http://127.0.0.1:8011`。
|
|
||||||
|
|
||||||
外围 API 支持已授权项目切换。每次请求先由主系统验证项目成员身份和权限,再从 Metadata DB 解析该项目的 `biz_data` 路由;历史观测另解析 `iot_data` 路由。逻辑项目代码、业务物理库名和时序物理库名可以不同,连接沿用各自完整 DSN。路由缺失返回 `503 PROJECT_STORAGE_NOT_CONFIGURED`,不会回退到其他项目数据库。未配置存储表的项目仍需单独完成结构部署;API 不自动创建表或复制演示数据。
|
外围 API 支持已授权项目切换。每次请求先由主系统验证项目成员身份和权限,再从 Metadata DB 解析该项目的 `biz_data` 路由;历史观测另解析 `iot_data` 路由。逻辑项目代码、业务物理库名和时序物理库名可以不同,连接沿用各自完整 DSN。路由缺失返回 `503 PROJECT_STORAGE_NOT_CONFIGURED`,不会回退到其他项目数据库。未配置存储表的项目仍需单独完成结构部署;API 不自动创建表或复制演示数据。
|
||||||
|
|
||||||
@@ -36,10 +39,12 @@ Hostname 后再追加 `/api/sample/v1`。外围服务应只向 NPMplus 所在网
|
|||||||
- `/api/sample/v1/monitoring/points`、`observations`、`alerts`
|
- `/api/sample/v1/monitoring/points`、`observations`、`alerts`
|
||||||
- `/api/sample/v1/demo-data`
|
- `/api/sample/v1/demo-data`
|
||||||
|
|
||||||
|
SCADA 按月数据集、5 分钟时间定位、主动周期监测、质量校验、爆管调查与定位边界见 [SCADA 外围系统时段回放、主动监测与爆管调查设计](scada-peripheral-replay-design.md)。部署回放存储需在项目 BizDB 执行 `031_scada_replays.sql`;项目通用配置需在 MetaDB 执行 `033_metadata_project_settings.sql`。监测频率保存于 `projects.settings.scada_monitoring_interval_seconds`,压力与出厂边界流量角色保存在类型化 `projects.settings.scada_monitoring` 中;本阶段不注册定时任务,也不部署 SCADA 回放 worker。代码和结构迁移完成不等于月度数据或 Agent 联调已经验收。
|
||||||
|
|
||||||
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
|
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
|
||||||
|
|
||||||
## 水质投诉 Agent 演示
|
## 工单事件调查
|
||||||
|
|
||||||
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的合成投诉事件,或 `work_order_type=burst_report` 的合成爆管上报。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。
|
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的水质投诉事件,或 `work_order_type=burst_report` 的爆管上报,均默认使用业务来源。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。普通业务录入无需绑定演练场景码,也不会向 Agent 注入预设监测结果或邻区投诉情况。
|
||||||
|
|
||||||
样板服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
|
外围服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
# SCADA 外围系统时段回放、主动监测与爆管调查设计
|
||||||
|
|
||||||
|
状态:时段回放、主动周期监测、条件式 `burst_report` 调查及回放安全的爆管定位适配已实现。单步处理由用户操作触发并在当前 API 请求内完成,不部署 SCADA 回放 worker。`tjwater_v2` 与 `tjwater2_v2` 尚未确认出厂边界流量角色;仍待完整月度数据和真实 Agent 联调。更新日期:2026-11-03。下面明确区分已实现范围、后续目标与验收要求。
|
||||||
|
|
||||||
|
本次将外围 SCADA 定义为一个模拟外部数据源:本地 TSDB 已保存原始观测,通过只读 API 按选定时间区间和后台时钟分批读取 `scada.measurements.monitored_value`,将这一批直接交给清洗流水线,保存 `cleaned_value` 后执行异常检测及后续 Agent 调查。原始值、修正值、真实工况异常和处理结论分别保留。浏览器负责控制和展示,关闭页面不停止后台回放。
|
||||||
|
|
||||||
|
演示主路径为“TSDB 已存 raw → 用户点击下一步并监测 → 顺序读取一个步长内的观测 → 清洗并更新修正值 → 在步长边界执行多指标监测 → 满足条件时进入爆管调查与定位”。真实外部系统新增观测时,仍先通过入库接口保存 raw,再进入相同的清洗检测流程。演示读取已有数据不再次调用原始值插入接口,也不把同一批数据从一个内部服务读出后再写回原表。
|
||||||
|
|
||||||
|
演示操作以用户选定的时间区间为中心:一个月是可选择的数据范围,单次演示可以只覆盖其中几小时或几天。时间轴采用 FM 调频盘样式,玻璃刻度窗内有固定红色指示线,时间刻度带随拨动横向移动。时间轴使用 5 分钟时间戳定位;项目监测频率只决定“下一步并监测”覆盖的业务周期,两者互不替代。
|
||||||
|
|
||||||
|
## 首版实现范围与限制
|
||||||
|
|
||||||
|
已实现只读 raw 分页、持久回放控制、按项目监测频率推进、逐时刻清洗检测、质量告警与运行异常事件、周期监测回执、组合判定及调查任务。主动单步只有组合结果为 `suspected_burst` 时才创建 `burst_report`。前端改为后端状态投影,主界面只保留选定区间、5 分钟 FM 时间定位、主动周期控制和本周期监测结论,不重复展示 SCADA 设备目录与逐测点读数表。主要代码为 [回放服务](../app/services/scada_replay.py)、[组合监测](../app/services/scada_replay_monitoring.py)、[质量与检测](../app/services/scada_replay_processing.py)、[持久状态](../app/infra/db/postgresql/scada_replays.py) 和 [前端组件](../../next-tjwater-frontend/src/features/workbench/peripheral-sample/scada-replay-section.tsx)。
|
||||||
|
|
||||||
|
本版采用每项目最多 500 个设备、一个完整时间戳一批、至多一个未完成批次的串行处理,不实现 20 批并发队列或大于 500 个设备的分片接收。处理变慢时逐批追赶且不跳过中间观测;后台停机期间最多补算两秒现实时间,不一次性追赶全部停机时长。数据质量以有限数值和显式仪表量程为依据,不自动插值或平滑;缺测判断使用设备档案的采样周期。分组规则的恢复需要该组设备在同一帧都有可信观测,不同采样周期或采样相位应拆成规则。
|
||||||
|
|
||||||
|
本版冻结设备、区间、规则及批次输出,按批次原始值摘要校验源变更。完整月度数据清单、全月源版本摘要、独立事件真值和检测效果评估仍是后续数据准备工作;能力接口仅判断回放存储是否存在与当前操作权限,不声明月度数据已经就绪。现有 14 天数据不能当作一个月验收。
|
||||||
|
|
||||||
|
Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 约束。开始前历史只供后台基线初始化,本版调查查询不返回该部分;爆管定位已支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。新 [SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明这些证据限制。运行异常自动建立调查,数据质量异常仅建立告警,不触发模型;恢复时推进调查代次。真实模型推理与报告效果尚未联调。
|
||||||
|
|
||||||
|
控制进度和事件状态使用有界轮询,沿用告警流发布业务告警;本版未新增 scada.replay.changed SSE 主题。演示面板只显示最近一个周期的组合结论,逐测点输出和完整事件历史由分页 API 保留给诊断与调查入口,不在主面板重复铺开。
|
||||||
|
|
||||||
|
## 已确定的设计边界
|
||||||
|
|
||||||
|
- 数据集覆盖一个月,包含多次独立异常及各次事件发生、持续、恢复的过程。
|
||||||
|
- 项目配置一个 SCADA 监测频率,只用于一次主动监测所覆盖的周期;时间轴独立使用 5 分钟时间戳,它不关联设备或设备类型,本阶段不据此创建定时任务。
|
||||||
|
- 设备采样周期仍属于设备档案。回放实际传入时间以原始观测时间戳为准,不使用项目监测任务间隔生成、重采样或判断设备缺测。
|
||||||
|
- 复用 `scada.measurements.monitored_value` 与 `cleaned_value`;不另建一套正式 SCADA 原始值和修正值表。
|
||||||
|
- 回放读取 API 只返回原始观测,修正值由本地清洗流水线计算和保存;GET 不更新游标、不清洗、不创建检测任务。
|
||||||
|
- 真实压力、流量、水质异常保留在历史序列中。事件恢复或调查结束不删除、不抹平历史异常。
|
||||||
|
- 数据处理顺序为原始观测持久化、数据质量校验、必要的修正、业务异常检测、事件与调查。
|
||||||
|
- 步长内的数据批次按原始时间驱动清洗和基础规则检测;到达步长边界后,用户触发的本周期监测汇总多项指标。正常周期不启动 Agent。
|
||||||
|
- 时间范围、设备、数据集版本和检测规则在一次回放创建后固定;改变这些条件需要新建回放。
|
||||||
|
- 页面首先选择演示起止时间,再建立回放。月度数据集、设备配置和规则作为来源与设置,不占据主播放区。
|
||||||
|
- 本次演示对已存 raw 只读,清洗阶段仅更新同一条观测的 cleaned_value;不导出整月副本、不重复插入原始值。每次回放的质量结论、检测结果和调查证据按回放身份隔离。
|
||||||
|
|
||||||
|
### 项目通用配置与监测频率
|
||||||
|
|
||||||
|
MetaDB 的 `projects.settings` 为项目级通用 JSONB 对象,默认 `{}`,用于保存可扩展的业务参数,不存放密钥。新增参数可直接增加 JSON 键,无需为每项配置新增数据库列;后端为已知参数提供类型校验,并保留其他 JSON 键。
|
||||||
|
|
||||||
|
其中 `settings.scada_monitoring_interval_seconds` 表示项目的 SCADA 监测频率,单位为秒。本阶段只用于主动处理边界和监测周期标识,不控制时间轴刻度,不注册 cron、后台 schedule 或周期性 Agent 任务。它与设备目录、设备类型、设备采样周期和页面刷新周期均无关联。省略或设为 `null` 表示未配置;有效范围为 60 秒至 365 天。
|
||||||
|
|
||||||
|
管理员可通过 `PATCH /api/v1/admin/projects/{project_id}` 配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"scada_monitoring_interval_seconds": 900
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
更新时按 `settings` 顶层键合并:未传入的键保留,传入的嵌套对象整体替换,`settings: {}` 不清空已有配置,`settings: null` 会被拒绝。读取项目详情和管理员项目列表均返回 `settings`。
|
||||||
|
|
||||||
|
组合监测仍复用该 JSON 对象,不新增固定数据库列。后端类型化嵌套对象显式声明项目实际具备的监测角色:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"scada_monitoring_interval_seconds": 900,
|
||||||
|
"scada_monitoring": {
|
||||||
|
"rule_version": "burst-monitor-v1",
|
||||||
|
"pressure_device_ids": ["明确的压力设备 ID"],
|
||||||
|
"outlet_flow_device_ids": ["明确的出厂边界流量设备 ID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前代码已类型校验并在创建回放时冻结 `scada_monitoring`。项目没有出厂流量或不足 5 个完整压力点时保持缺项,能力接口说明原因,监测回执返回 `insufficient_data`;不能为了让演示通过而把其他流量点冒充出厂流量或虚构设备。`tjwater_v2` 现有目录只有通用 `pipe_flow` 类型及模型管段映射,没有足以证明“出厂边界”的角色字段,因此本轮未写入该嵌套配置。
|
||||||
|
|
||||||
|
部署前需在 MetaDB 执行 `resources/sql/033_metadata_project_settings.sql`。迁移会将实验版本可能存在的独立监测间隔列搬入 `settings`,核对冲突后移除旧列;重复执行不丢失已有配置。当前 `tjwater_v2` 与 `tjwater2_v2` 均配置 900 秒,即 15 分钟的主动监测周期。该参数不改变 5 分钟时间轴定位,本阶段明确不实现通用定时任务调度。
|
||||||
|
|
||||||
|
## 实施前基础与原设计差距
|
||||||
|
|
||||||
|
下表记录实施前基础,用于说明改动缘由;本次实际实现以首版范围及后文接口表为准。设备目录采样周期为本轮讨论中 2026-11-02 的只读核查快照,与项目监测任务间隔是两个概念。
|
||||||
|
|
||||||
|
| 位置 | 当前能力 | 本次需要补齐 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [外围 API](../samples/peripheral/api.py)、[存储路由](../samples/peripheral/routing.py) | 挂载在 `/peripheral`,使用 Bearer 与 `X-Project-Id`,按已授权项目解析 BizDB 和 TimescaleDB | 回放控制、源批次、回放状态与能力声明 |
|
||||||
|
| [历史观测接口](../samples/peripheral/store.py) | 单点、最多 7 天、最多返回 1000 条并声明截断;当前使用 `api_query_id` 查历史 | 回放读取使用规范 `device_id`,按时间和设备联合游标分页;不能直接拼接现有单点接口来装载一个月 |
|
||||||
|
| [SCADA API](../app/api/v1/endpoints/timeseries/scada.py)、[仓储](../app/infra/db/timescaledb/repositories/scada.py) | 范围读取、最新值读取、批量插入;当前范围读取返回 raw 和 corrected,缺少回放游标及完整流水线 | 增加只读 raw 分页契约;复用存储查询,补齐处理检查点、清洗与检测调度 |
|
||||||
|
| [清洗服务](../app/services/timeseries_analysis.py) | 从原始值计算并更新 `cleaned_value`;目前支持压力和流量 | 在线质量标记、因果修正及选择值依据;水质不能直接套用现有清洗 |
|
||||||
|
| [压力清洗](../app/algorithms/scada_cleaning/pressure_series.py)、[流量清洗](../app/algorithms/scada_cleaning/flow_series.py) | 包含居中窗口、后向补值、Kalman 平滑;流量路径还会补为 1 分钟序列 | 在线路径只使用已到达的数据,保留设备频率,不整月预清洗后再模拟检测 |
|
||||||
|
| [外围工单事件同步](peripheral-work-order-events.md) | 持久事件、通知唤醒、后台同步和定期对账 | 复用可靠处理方式,SCADA 的批次有自己的持久状态 |
|
||||||
|
| [调查 worker](../scripts/run_operational_worker.py) | 可靠投递 `event_investigation`,恢复执行租约,管理 Agent 执行代次 | 接收由 SCADA 异常事件建立的调查;不承担每条观测的检测 |
|
||||||
|
| [任务告警](../app/domain/schemas/task_alert.py) | 支持 raised、acknowledged、resolved;当前来源必须携带任务和执行标识 | 增加真实的回放检测来源,不能为调查前告警伪造 `task_id`、`run_id` |
|
||||||
|
| [MCP 上下文](../app/mcp/contexts.py)、[SCADA 查询](../app/mcp/query_service.py) | 校验任务、项目、执行代次和会话,保存查询证据 | 在所有回放调查读取入口执行统一的时间与数据版本约束 |
|
||||||
|
| [业务 Skill 映射](../app/mcp/work_order_skills.py) | 目前映射爆管上报、水质投诉、预约跟进 | 增加 SCADA 回放调查流程,不将检测信号直接等同于已确认爆管或污染 |
|
||||||
|
| [前端 SCADA 区域](../../next-tjwater-frontend/src/features/workbench/peripheral-sample/scada-system-section.tsx) | 旧版同时展示设备表、逐点读数、重复概览和手工合成读数 | 改为服务端状态投影;主界面只承担时间定位、主动单步和周期结论,详细数据留在诊断与调查接口 |
|
||||||
|
|
||||||
|
实施前只读核查中,`tjwater_v2` 有压力、流量和水质监测类型,其他项目的类型集合与历史周期并不相同。项目通用配置保存一个监测任务周期,不与设备类型绑定,也不把 5/15 分钟写成枚举;设备原有采样信息仍用于缺测判断。
|
||||||
|
|
||||||
|
现有 [演示记录](../../present.md) 描述的是 14 天合成观测,不能作为一个月数据已经准备好的证明。一个月数据准备与本设计实现分别验收。
|
||||||
|
|
||||||
|
## 运行链路与职责
|
||||||
|
|
||||||
|
```text
|
||||||
|
本地 TSDB 已存月度原始观测;项目 BizDB 保存设备目录
|
||||||
|
|
|
||||||
|
v
|
||||||
|
只读 raw API / 同一读取服务 <--- 持久回放状态 <--- 页面“下一步并监测”
|
||||||
|
|
|
||||||
|
| 返回一批原始观测,不再写入 raw
|
||||||
|
v
|
||||||
|
后台固定本批记录引用、摘要和处理身份
|
||||||
|
|
|
||||||
|
v
|
||||||
|
后台质量校验与清洗
|
||||||
|
|
|
||||||
|
v
|
||||||
|
本地 TSDB:仅更新 cleaned_value
|
||||||
|
|
|
||||||
|
v
|
||||||
|
用同批清洗结果执行基础规则检测
|
||||||
|
|
|
||||||
|
v
|
||||||
|
到达步长边界,汇总爆管侦测、压力下降、出厂流量变化
|
||||||
|
|
|
||||||
|
v
|
||||||
|
疑似爆管且定位输入具备 ---> burst_report 任务与 MCP Skill
|
||||||
|
|
|
||||||
|
v
|
||||||
|
工作台告警、调查进度与结果
|
||||||
|
```
|
||||||
|
|
||||||
|
SCADA 单步命令在当前 API 请求内按原始时间顺序完成 raw 读取、清洗、基础规则检测和周期组合监测。每个阶段仍写入持久批次,网络中断后的同一幂等请求可以继续未完成步骤,但页面关闭后不会自动推进时间,也不需要单独的 SCADA 回放进程。
|
||||||
|
|
||||||
|
现有调查 worker 只处理已经创建的调查执行,计算 worker 继续处理长计算。当前阶段不增加消息中间件,不扩展通用 schedule API,也不注册 SCADA 定时任务。页面的明确单步命令写入持久 `step_target`,API 只完成这次已授权的周期处理;不能用“每隔几分钟创建一条 Agent 任务”替代。
|
||||||
|
|
||||||
|
最小可交付版本必须包含主动周期处理、批次可靠保存、至少一种明确配置的检测规则和结果展示;只移动页面时间轴不能代表数据已经处理。
|
||||||
|
|
||||||
|
## 一个自然月的数据集
|
||||||
|
|
||||||
|
数据集用带时区的半开区间定义。例如北京时间 `[2026-10-01 00:00, 2026-11-01 00:00)` 是 31 天,不能固定按 30 天计算。内部统一保存带时区时间,界面按 Asia/Shanghai 展示。
|
||||||
|
|
||||||
|
准备数据时冻结清单:`dataset_id`、`dataset_revision`、源项目、观测区间、可用历史基线区间、设备规范 ID、类型、单位、设备采样周期、采样相位、指标含义、raw 分片摘要和规则配置版本。由服务端读取项目路由,页面不传物理库名、SQL 或连接信息。摘要只覆盖原始观测与必要元数据,不覆盖会被本轮清洗更新的 cleaned_value。月度摘要由数据集准备流程预先生成,创建回放的 HTTP 请求只校验清单,不同步扫描和加载整个月。
|
||||||
|
|
||||||
|
首版演示直接读取已准备在本地 TSDB 的 raw,不另导出一套完整原始数据源。月份数据如果尚未齐备,由单独授权的数据准备过程补齐并生成版本清单;创建或播放回放本身不生成、删除、搬移或重复插入 SCADA 原始观测。演示批次是对已有观测的读取与处理身份,不能在统计中称为本轮新插入条数。
|
||||||
|
|
||||||
|
设备档案采样周期与实际观测间隔不一致时,记录配置差异和缺测情况,仍按实际时间戳读取。不会将 15 分钟数据插值成 5 分钟观测,也不会把没有记录的时刻当作正常值。项目监测任务间隔不参与这项判断。
|
||||||
|
|
||||||
|
多次异常应同时覆盖:真实压力异常、真实流量变化、真实水质变化、仪表或通信故障、正常调度引起的变化,以及恢复后的再次异常。数据集真值清单独立保存,用于事后计算命中、误报、漏报、重复事件数和发现延迟。真值不得出现在检测输入、Agent 上下文、预告型时间轴标记或普通业务报告中。
|
||||||
|
|
||||||
|
水质必须声明具体指标。当前演示中的化学示踪物浓度不能直接解释为余氯、浊度,也不能只凭 `mg/L` 选择检测阈值。流量保留方向,压力确认 m、kPa、MPa 等单位后再应用规则。
|
||||||
|
|
||||||
|
数据集准备负责人需要在运行前提交版本化设备清单、规则参数和独立真值。这是新数据集的输入要求,不表示本次已选定新的 5 分钟设备分组或生成了观测。
|
||||||
|
|
||||||
|
## 时间轴和读取语义
|
||||||
|
|
||||||
|
### 时间与进度
|
||||||
|
|
||||||
|
| 名称 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `observed_at` | 原始观测时间,不随回放改写 |
|
||||||
|
| `virtual_now` | 已完成主动监测的周期边界时间 |
|
||||||
|
| `delivered_until` | 本轮源区间已读完,且所有分片的记录引用已登记为待处理的时间上限;表示模拟传入进度,不是原始值新插入时间 |
|
||||||
|
| `processed_until` | 质量校验和检测已完整提交的时间上限 |
|
||||||
|
| `read_at`、`cleaned_at`、`processed_at` | 实际批次读取、清洗提交和检测完成时间,用于延迟、故障与审计 |
|
||||||
|
|
||||||
|
始终满足 `processed_until <= delivered_until <= virtual_now <= end_at`。还未处理任何批次时,读取和处理进度为空,不能伪装成起点已经处理。GET 返回数据不自动推进任何进度;后台确认本批处理身份后才更新读取进度。表格可显示本轮已读取但仍在清洗的 raw 并标明状态,检测只能消费已完成清洗阶段的批次。主界面的次级进度使用“已读取”和“已检测”。
|
||||||
|
|
||||||
|
后台仅在播放状态累计模拟时间。暂停期间不推进;恢复时重新锚定现实时间。进程重启从持久进度重新锚定,不把停机时长一次性折算成需要跳过的数据。
|
||||||
|
|
||||||
|
### 顺序读取
|
||||||
|
|
||||||
|
1. 整次回放选取 `[start_at, end_at)`;首批包含 `start_at` 的观测。
|
||||||
|
2. 时间推进到 T 时,候选记录必须不晚于 T、早于 `end_at`,且在已成功接收的联合游标 `(observed_at, device_id)` 之后。
|
||||||
|
3. 所有选中设备合并后按 `observed_at, device_id` 排序;优先按同一观测时刻成批,不按设备分别跑独立时钟。
|
||||||
|
4. 每个传输片段最多 500 条、序列化正文最多 2 MiB;超限继续分片。记录片段序号与该时刻是否完整,不在半个时刻接收成功时提前推进时间上限;同一时刻的设备分组检测等待该时刻所有分片收齐。
|
||||||
|
5. 后台将本批记录引用和原始摘要登记成功后,才推进读取游标。重试同一批使用相同编号和摘要;清洗或检测失败从该阶段恢复,不重新选择一批、不重新插入 raw。正常路径复用本次读取的 raw 对象计算清洗结果,清洗提交后直接将同批结果用于检测,不再重新查询该批 raw 或 corrected。
|
||||||
|
6. 没有观测的时间段按设备档案采样周期与采样相位识别应到时刻。后台提交记录数为零的区间检查批次,推进覆盖进度并触发缺测判断;它不生成伪造观测。不能仅依赖“下一条数据到来”唤醒,否则连续断流永远不会被发现,终点也无法排空完成。
|
||||||
|
|
||||||
|
例如设备档案中的压力采样周期为 5 分钟、流量采样周期为 15 分钟。时间轴到 09:10 时,压力设备可传入 09:00、09:05、09:10,流量设备只有 09:00;到 09:15 时,再传入两类设备对应的 09:15 记录。
|
||||||
|
|
||||||
|
首版至多保留一个未完成批次,清洗检测提交后才选择下一批。单次请求不跳过周期内的中间观测、质量检查、异常开始和恢复,也不提供播放倍率。
|
||||||
|
|
||||||
|
### 页面操作
|
||||||
|
|
||||||
|
| 操作 | 约定 |
|
||||||
|
| --- | --- |
|
||||||
|
| 创建 | 固定数据集、范围、设备和规则,进入 ready |
|
||||||
|
| 播放 | ready 或 paused 进入 running,由后台推进 |
|
||||||
|
| 暂停 | 停止释放新批次;已持久接收的批次继续处理。返回实际生效进度,在途接收与暂停命令通过同一回放版本串行裁决 |
|
||||||
|
| 下一步并监测 | 在 ready 或 paused 状态推进到下一个项目监测周期边界;周期内的所有观测按原始时间顺序处理,到边界后提交一次本周期监测回执并保持暂停,尾步可短于完整周期 |
|
||||||
|
| 调速 | 原子更新速度和时间锚点,不改变观测顺序 |
|
||||||
|
| 拖动时间轴 | 只查看已释放的数据与已产生的事件;未来区间不预览读数,不触发事件 |
|
||||||
|
| 从指定时刻重播 | 新建 `replay_id`,独立检测与调查;新起点之前仅作为有声明的历史基线,跳过段不声称已经检测 |
|
||||||
|
| 到达终点 | 停止新批次,进入 draining;待已接收批次完成后 completed。Agent 调查是否完成另行展示 |
|
||||||
|
| 停止 | 终止继续传入,保留已接收批次、事件和证据;不自动撤销已经建立的调查 |
|
||||||
|
| 重试故障 | 修复依赖后校验固定配置,failed 进入 paused,重试原未完成批次;源版本或批次内容冲突需要新建回放,不能靠重试忽略 |
|
||||||
|
|
||||||
|
生命周期为 ready、running、paused、draining、completed、stopped、failed。队列等待、基线不足和依赖暂不可用是处理状态或原因字段,不再增加一套并行生命周期。
|
||||||
|
|
||||||
|
首次运行遇到可重试投递错误,按现实时间退避重试同一批次;权限错误、数据集版本变化、同批编号内容冲突和结构缺失进入 failed,明确原因。用户恢复前必须解决原因;原批次事实继续保留。
|
||||||
|
|
||||||
|
### 选定演示区间
|
||||||
|
|
||||||
|
用户先在月度数据可用范围内选择起止时间。创建回放后,将该区间固定为当前播放范围;进度比例与剩余时长都相对该区间计算。例如选择 10 月 8 日 08:00 至 14:00,时间轴只覆盖这 6 小时,不能将月内 8 日的位置作为本轮进度。
|
||||||
|
|
||||||
|
主要界面常驻显示选定区间和当前监测时间。月份、数据版本、设备范围及规则收进来源与设置区域;需要校验的配置差异仍在开始前明确显示。不存在上一次选择时不自动处理整个月,等待用户确定区间。
|
||||||
|
|
||||||
|
演示建立后改变时间区间需要停止并创建新回放;保留旧回放的事件和证据,不把旧回放时间游标直接重设。已完成区间需要再次演示时,新建独立回放;不自动循环处理和重复触发调查。
|
||||||
|
|
||||||
|
选定区间之前的可用历史仅用于已声明的基线初始化,不占用主时间轴、不作为新到达批次、不预触发此前事件。基线不足时在当前区间显示具体可用性,不能悄悄扩大演示范围或读取区间之后的数据。
|
||||||
|
|
||||||
|
处理到选定终点后停在终点,显示数据处理是否完成及剩余调查数量;不会自动继续处理该月剩余数据。空区间明确显示没有观测,不能通过控件动画假装已经收到 SCADA 数据。
|
||||||
|
|
||||||
|
### 5 分钟时间定位与项目监测周期
|
||||||
|
|
||||||
|
时间轴以 5 分钟时间戳组织刻度、标签和选择值,只负责定位和回看,不读取 `projects.settings.scada_monitoring_interval_seconds`。方向键按 5 分钟移动;鼠标和触摸的拖动轨迹保持连续调频手感,选定值仍按最接近的 5 分钟时间戳归档。标签密度可以随面板宽度合并,不会改变拖动手感或时间选择精度。
|
||||||
|
|
||||||
|
能力接口另行返回当前项目监测周期;创建回放时由服务端固定到回放文档及快照的 `monitoring_interval_seconds`,页面不能提交不同周期。项目配置后续调整只影响新回放,当前演示继续使用固定值;未配置时提示设置。
|
||||||
|
|
||||||
|
| 交互 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 方向键 | 前后移动一个 5 分钟时间戳 |
|
||||||
|
| PageUp / PageDown | 移动一个当前可见的主刻度,主刻度始终为 5 分钟的整数倍 |
|
||||||
|
| 拖动回看 | 拖动轨迹连续无缝,选择值按 5 分钟量化;只读定位不推进后台 |
|
||||||
|
| 主刻度 | 宽屏可每 5 分钟标注,窄屏按 10、15 分钟等密度显示,细刻线保留 5 分钟定位 |
|
||||||
|
| 下一步并监测 | 不读取时间轴位置;如后台已监测至 08:07,15 分钟周期处理到 08:22 |
|
||||||
|
|
||||||
|
“下一步并监测”通过持久化 `step_target` 与 `step_pending` 管理。单步开始后,API 按原观测时刻读取、清洗和检测到目标边界,完成前禁用重复单步。若周期内有 5 分钟观测,15 分钟监测周期仍处理其中每个时刻;没有观测则提交空区间检查,不补造数据。尾步到达 end_time 时,仍遵守原始区间 `[start_time, end_time)`。
|
||||||
|
|
||||||
|
时间轴以 5 分钟时间戳定位,不能控制或触发周期处理;“下一步并监测”按项目监测频率推进后台周期,不将原始观测重采样。旧回放没有固定周期时,页面要求停止后重新创建,不能把后来修改的项目周期追补为历史事实。
|
||||||
|
|
||||||
|
### 步长边界主动监测与爆管流程
|
||||||
|
|
||||||
|
一次主动监测的身份为 `(replay_id, period_end, monitor_rule_version)`。同一步骤重试复用原回执,不能重复创建告警、调查或计算。监测回执保存在该步最后一个回放批次的 `monitoring` 中,并将最近一次结果投影到回放快照;内容包含周期起止、知识截止、输入批次上限、数据完整性、各指标状态、组合结论和关联任务。它不是 `scheduled_flow`,也不需要通用定时器。
|
||||||
|
|
||||||
|
步长边界按以下顺序执行:
|
||||||
|
|
||||||
|
1. 确认本周期内所有已到达观测均已完成质量校验和清洗;缺测、无效值和单位冲突进入数据完整性结果,不能按正常值处理。
|
||||||
|
2. 汇总压力下降幅度。只使用映射明确、单位一致、质量合格的压力测点,并同时保留异常测点数、覆盖测点数、基线窗口和最大或中位下降幅度。
|
||||||
|
3. 汇总出厂流量变化。设备必须在项目配置或设备档案中明确标注为“出厂边界流量”,不能把任意 `pipe_flow` 测点当作出厂流量;保留方向、单位、基线和增量。
|
||||||
|
4. 条件满足时运行爆管侦测。现有侦测要求至少 5 个完整压力点和目标时刻之前 15 天同周期数据;不满足时返回 `baseline_not_ready` 或 `sensor_coverage_insufficient`,不能将“未运行”解释为“无异常”。
|
||||||
|
5. 组合上述证据形成监测结论;只有疑似爆管才创建 `event_investigation`,工作流使用现有 `burst_report`,并绑定本轮 `replay_context`。正常周期只保存回执,不启动 Agent;数据不足周期保存缺口并在界面展示。
|
||||||
|
6. `burst_report` Skill 核对证据后决定是否调用爆管定位。定位必须有 m³/s 的有依据漏量估计或明确情景假设,以及模型节点映射、异常窗和正常对照窗;输入不具备时保持“疑似爆管、定位待补充”,不能填默认漏量。
|
||||||
|
|
||||||
|
组合判定采用可版本化策略,首版演示建议如下:
|
||||||
|
|
||||||
|
| 监测结果 | 判定条件 | 后续动作 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `insufficient_data` | 必需点位缺测、质量无效、单位或角色未配置,或爆管侦测基线不满足 | 保存缺口,不声明正常,不启动定位 |
|
||||||
|
| `normal` | 数据完整,爆管侦测未命中,压力与出厂流量规则均未命中 | 保存周期回执,不创建 Agent 任务 |
|
||||||
|
| `attention` | 只有单项指标命中,或指标间方向不一致 | 保存观察结果;下一步继续监测,必要时由人发起调查 |
|
||||||
|
| `suspected_burst` | 爆管侦测命中,且压力下降与出厂流量增加在同一业务时间窗内相互支持 | 创建一次 `burst_report` 调查;定位输入具备后进入计算 |
|
||||||
|
|
||||||
|
爆管侦测和压力下降存在相关性,因此回执不能把两者描述成完全独立的两票。出厂流量也会受泵站调度、储水设施和正常用水变化影响。组合结论只是调查触发条件,不是现场确诊。
|
||||||
|
|
||||||
|
回放中的 MCP 定位已走 Server 持有的安全适配层:按 `replay_id`、`knowledge_cutoff` 和 `batch_sequence` 从已固化清洗输出组装压力与可选流量序列,再调用现有计算队列。回放只允许 monitoring 模式,并要求显式异常窗、正常对照窗和有依据的 `burst_leakage`;不能直接查询 TSDB 中尚未回放的未来记录。步长爆管侦测同样只读取最近 15 天已固化输出。计算结果保存输入、算法与证据引用,任务恢复或重试仍使用相同知识上限。
|
||||||
|
|
||||||
|
### FM 调频盘样式
|
||||||
|
|
||||||
|
用户明确选择电台调频盘的刻度、红色指示线和玻璃质感。这里的 FM 表示调谐外观,不增加电台、音频、频段切换或拟物旋钮功能。
|
||||||
|
|
||||||
|
视觉方向:工作台内一块冷白半透明的玻璃刻度窗,红色细线作为唯一醒目的时间定位标记;文字、控件与下方数据沿用现有工作台主题。内容顺序为选定区间、当前时间、刻度窗、控制栏、传入与检测进度。交互特征为固定指示线与横向移动的刻度带,手动拨动直接跟手。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
图中时间和状态仅为静态样式示意,不是运行结果。玻璃下的底色用于说明透光与边缘高光,运行时仍需验证实际工作台背景下的对比度。
|
||||||
|
|
||||||
|
| 元素 | 设计约定 |
|
||||||
|
| --- | --- |
|
||||||
|
| 演示区间 | 常驻显示完整起止日期和时间;更换区间是次要入口,月度来源收在设置中 |
|
||||||
|
| 当前时间 | 刻度窗上方突出显示红线对应的时间,数字等宽;跨日时同时显示日期 |
|
||||||
|
| 时间刻度 | 主刻度有时间标签,次刻度细密且高低有别;实际密度按区间与容器宽度计算,窄屏优先减少标签而非缩小文字 |
|
||||||
|
| 红色指示线 | 固定在刻度窗水平中央,1 至 2px 宽,带小型顶端指示;这是时间定位色,不代表告警严重度 |
|
||||||
|
| 玻璃刻度窗 | 局部半透明、背景柔化、细边缘高光和轻微内阴影,玻璃层不影响数字、刻度和红线的清晰度 |
|
||||||
|
| 控制栏 | 播放或暂停是唯一主操作,下一批与倍速为辅助;回看时才出现回到当前 |
|
||||||
|
| 次级进度 | 单行显示已传入与已检测时间,积压时显示等待处理;不增加三块同等醒目的仪表 |
|
||||||
|
|
||||||
|
玻璃效果是本次按用户要求对 SCADA 调频刻度窗的局部材质例外,外围面板外壳、表格、表单、菜单和 Agent 面板仍沿用现有 shadcn/ui 实体设计。新增时间指示颜色使用独立语义变量,不能让正常播放状态套用危险告警颜色。关闭透明效果或不支持背景模糊时,回退到清晰的实体刻度窗。
|
||||||
|
|
||||||
|
固定红线表示当前定位时间,刻度带按 5 分钟时间戳移动;只在选定区间内标注时间,两端之外留白,不带出整个月的刻度。该窗是选定区间的只读定位视窗,完整起止范围始终由上方文字明确表达;拨动不改变传入或检测进度。
|
||||||
|
|
||||||
|
手动拖动和触屏横拨改变查看位置,不立即改写后台时间。拨动到已释放时间时显示历史快照并标明回看;拨动到尚未释放时间时只显示待传入时间,不展示未来读数,若需从该位置开始则明确新建回放。回到当前只恢复跟随,不补发旧数据、告警或调查。
|
||||||
|
|
||||||
|
时间拨动按 slider 语义提供中文名称、时间范围、当前时间文本和键盘操作:方向键按 5 分钟刻度调整,PageUp/PageDown 调整一个主刻度,Home/End 到选定范围两端。键盘操作即时生效;鼠标和触摸拖动轨迹连续无缝、不带惯性,选定时间按 5 分钟量化。减少动态效果时关闭装饰性过渡,保留真实时间定位。状态不用颜色单独表达。
|
||||||
|
|
||||||
|
## 数据质量与业务异常
|
||||||
|
|
||||||
|
真实外部系统新增观测时,先通过现有入库边界将 raw 保存到 TSDB。本次演示使用已经入库的原始值,读取 API 校验身份、项目、设备与时间范围后返回 raw,直接进入质量校验和清洗;不为了模拟传入而重复执行原始值 INSERT。清洗只写回同一条观测的 cleaned_value,monitored_value 始终保留。
|
||||||
|
|
||||||
|
清洗完成后才启动业务异常检测。valid 可将 cleaned_value 设为原始值,corrected 保存有依据的修正值,无法可靠修正的 invalid 或 missing 保持空值并保存质量结论。suspect 保留 raw 与不确定性,不能自动替换为正常值。是否已经清洗以阶段回执和质量结论为准,不能仅用 cleaned_value 是否为空判断。
|
||||||
|
|
||||||
|
| 质量结论 | 选择值与后续处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| valid | 使用 raw,包括可靠采集的突降、突升、低压和浓度变化 |
|
||||||
|
| corrected | 使用有依据的修正值,同时保留 raw、修正值、规则版本、原因和可用时间 |
|
||||||
|
| suspect | 保留 raw 并声明不确定性,结合其他测点和设备状态复核;不自动改成正常值 |
|
||||||
|
| invalid 或 missing | 保留原始记录或缺测事实,退出要求可信观测的规则输入,形成数据质量事件 |
|
||||||
|
|
||||||
|
质量校验依据结构、有限数值、单位、设备身份、观测顺序、重复记录、量程与设备状态;仅偏离日常水平不能直接判 invalid。负流量、零浓度、低压均需按设备和业务含义解释。缺少仪表状态时明确证据不足,不能凭一次尖峰确认仪表损坏。
|
||||||
|
|
||||||
|
当前正式表中的 `cleaned_value` 可以保留,但不等于本轮已经完成清洗。读取 raw 时不清空已有 corrected,也不将旧 corrected 当作清洗输入。清洗阶段使用本批 raw 和本轮已知历史重新判断,提交本轮清洗版本并保存处理结果;检测只能使用该版本已提交的结果,不能直接取一个来源和时间不明的旧 corrected 当作已校验输入。
|
||||||
|
|
||||||
|
在线修正只使用当前已到达数据。当前居中窗口、后向补值、整段 Kalman 平滑和整段统计阈值保留为离线分析能力;不能一次性清洗整个月后将结果送入时间轴。在线清洗更新目标 TSDB 的修正值投影,原始值不变;原修正版本、当前修正版本和依据保留在处理证据中,旧回放仍读取自己的固定版本。
|
||||||
|
|
||||||
|
每条参与判断的数据记录其 `value_source`、质量结论、选用值、原始记录引用、规则版本和计算时间。回放过程不为未知错误强制生成一个修正数值。
|
||||||
|
|
||||||
|
这一区分对应 [EPA 在线水质监测指南第 6.1、6.2 节](https://nepis.epa.gov/Exe/ZyPURL.cgi?Dockey=P100UJYF.txt) 的“先校验、再异常检测,并保留原始值”原则;历史修订留痕可参考 [OPC UA Historical Access 第 4.4 节](https://reference.opcfoundation.org/specs/OPC-10000-11/4)。本项目的具体质量判定仍由指标和设备证据决定。
|
||||||
|
|
||||||
|
## 检测规则和多次事件
|
||||||
|
|
||||||
|
首版实时检测使用有版本的类型化规则:压力变化与上下限、流量变化与方向、水质具体指标的上下限或变化,以及数据质量规则。规则输入包含质量结论,输出只表示异常信号及依据,不确认爆管位置或污染原因。
|
||||||
|
|
||||||
|
每项启用的规则必须声明:`rule_id`、指标、方向、单位、阈值、使用的历史窗口、最少有效样本、连续命中点数、恢复阈值、连续恢复点数,以及事件组和最少同时命中的设备数。参数在创建回放前校验并冻结,至少需要一种已配置规则才可创建检测回放。缺失参数时该规则为 not_configured,不能借用统一 30% 或把零到非零统一解释为 100% 污染风险。
|
||||||
|
|
||||||
|
同一个事件组由明确的设备集合组成,不使用预设异常真值自动分组。同组、同规则、同一持续异常归并为一个事件;不同事件组可以同时产生不同事件。恢复需要可信的新观测满足恢复条件,缺测不构成恢复。恢复后的再次命中建立新事件。
|
||||||
|
|
||||||
|
事件保存首次异常、检测触发、最后异常、恢复时刻、证据引用和调查结论。传感器恢复正常只表示观测恢复,不代表现场维修、污染处置或业务调查已经完成。
|
||||||
|
|
||||||
|
事件身份由 `replay_id + rule_id + event_group_id + first_trigger_at` 确定;同一事件更新不创建多条调查。批次重试不重复创建事件,新一次回放的事件与旧回放独立。
|
||||||
|
|
||||||
|
现有 [爆管侦测服务](../app/services/burst_detection.py) 可作为满足条件后的进一步分析工具,不能直接等同于逐批在线检测器。当前 target 模式需要 15 天数据及至少 5 个完整压力测点。回放前没有足够历史时,展示 baseline_not_ready;支持短历史的规则仍可运行,不能用未来几天补足参考窗口。
|
||||||
|
|
||||||
|
正常基线只使用当时已知、质量合格且适用于该规则的历史。已确认异常可以从正常基线中排除,原始记录仍保留。事后确认或重新标注产生新版本,不改写此前检测“当时知道了什么”。
|
||||||
|
|
||||||
|
## 持久化和事务
|
||||||
|
|
||||||
|
观测数据以项目目标 TSDB 的 `scada.measurements` 为权威存储,沿用 time、device_id、monitored_value 和 cleaned_value。业务库不替代 TSDB 保存正式观测。
|
||||||
|
|
||||||
|
建议在 BizDB 增加以下处理对象:
|
||||||
|
|
||||||
|
| 对象 | 权威信息 |
|
||||||
|
| --- | --- |
|
||||||
|
| `peripheral_sample.scada_replays` | 回放身份、项目、创建者、冻结配置、生命周期、版本、模拟时间锚点、读取与处理进度、工作租约和恢复检查点 |
|
||||||
|
| `peripheral_sample.scada_replay_batches` | 本批 TSDB 记录键、raw 摘要、固定规则版本、清洗输出、质量结论、检测结果、处理阶段和重试信息 |
|
||||||
|
|
||||||
|
两个对象分别具有控制生命周期与批次处理生命周期。现有 test_data 是手工单条演练 CRUD,缺少顺序、批次和消费语义;任务表表示业务调查,也不能存正常观测批次。这里保存记录引用和处理结果,不复制一整套 raw;需要固化异常证据或修正依据时,只保存必要的原始片段。由于本次不重新摄入 raw,不新增 TSDB 原始值收件表。表和列同时写清数据库注释。
|
||||||
|
|
||||||
|
事件及当前告警复用 `workbench.alert_events`、`workbench.alerts`,调查及其证据复用现有 operational 表。告警来源扩展为判别联合:保留现有任务来源,新增 `kind=scada_replay`、`replay_id`、`dataset_revision`、规则组与触发批次,避免伪造调查身份。质量故障也用同一事件存储,按类别区分。
|
||||||
|
|
||||||
|
### 批次处理与提交
|
||||||
|
|
||||||
|
不使用一个长期跨库事务。批次按以下阶段单调推进,重试保持原批次身份:
|
||||||
|
|
||||||
|
1. `selected`:后台读取 raw 后,BizDB 固定记录键、输入摘要、规则与知识截止时间,并在同一事务登记处理身份和读取检查点。GET 本身不做此写入。
|
||||||
|
2. `clean_prepared`:直接对已读入的这一批 raw 和已加载的历史状态计算质量与修正结果,先将确定的输出及输入摘要保存在 BizDB。正常路径不调用会重新查询同批 raw 的整段清洗入口。
|
||||||
|
3. `cleaned`:TSDB 用一个有界事务按记录键更新 cleaned_value,并校验 raw 仍与本批输入一致;不更新 monitored_value。只有 TSDB 提交成功且 BizDB 记录已提交阶段后,才允许检测。单行结果相同时不必重写字段。
|
||||||
|
4. 若 TSDB 已提交但 BizDB 阶段尚未更新,恢复时核验预存的输入与输出;已经匹配则补记 cleaned,未写入则幂等应用预存结果,raw 已变则停止该批次。只在这种恢复或校验冲突路径增加读取,不为每批正常处理增加一次重复查询。
|
||||||
|
5. `detected`:直接使用本批已提交的清洗结果做检测,BizDB 在同一事务保存检测结果、事件状态、调查意图、检查点和 processed_until,然后发布业务事件。检测失败只重试本阶段,不重新清洗或重写 raw。
|
||||||
|
|
||||||
|
以上阶段保存在批次中。页面继续突出模拟时间、已读取进度和已检测进度,清洗中或失败作为当前批次状态展示。正常路径对新批次读取一次 raw、必要时批量更新一次修正值,再复用这批处理结果完成检测;界面后续查看与故障恢复属于独立的必要读取。
|
||||||
|
|
||||||
|
BizDB 批次状态是持久待处理队列,通知只负责唤醒。后台启动、通知中断与定期对账均扫描未结束批次,按已提交阶段恢复;同库状态与事件在同一事务保存,不能假定两个数据库天然原子。[PostgreSQL NOTIFY](https://www.postgresql.org/docs/15/sql-notify.html) 和 [SELECT 锁定语义](https://www.postgresql.org/docs/15/sql-select.html) 提供通知及领取基础。
|
||||||
|
|
||||||
|
调用慢算法和外部服务前结束数据库事务,不持有事务锁等待 HTTP。清洗结果未提交,不执行异常检测;中断后从已有检查点恢复,不丢弃批次身份重新处理一遍。
|
||||||
|
|
||||||
|
### 重复读取与重复回放
|
||||||
|
|
||||||
|
读取 API 的同一次游标请求可以安全重试,但相同数据不能被当作新的处理批次。后台以项目、回放、批次序号和分片序号登记幂等身份,记录级使用规范设备 ID 与原始时间;同键同摘要复用现有处理状态,不同摘要返回冲突。
|
||||||
|
|
||||||
|
新一轮演示按新的 replay_id 建立独立处理状态,可以重新读取和分析选定区间,但仍不插入 raw。界面统计已读取条数和已处理条数,不能称为新写入 SCADA 条数。
|
||||||
|
|
||||||
|
首版同一项目只允许一条活动的读取清洗流水线,避免不同回放同时改写同一 cleaned_value。历史结果仍可同时查看。修正字段表示最近一次已发布清洗结果,旧版结果和依据保留在批次输出及必要的原始证据中;旧 Agent 执行和历史回看不直接读取被后续处理替换的修正投影。
|
||||||
|
|
||||||
|
工作台事件流只发布回放控制状态变化、故障与业务告警;不为每个测点值发布 SSE,也不把全部原始数据放入事件日志。未来若新增 `scada.replay.changed` 主题,只用于控制状态,并需同步主题模型、约束和消费契约;当前控制进度由轮询读取。
|
||||||
|
|
||||||
|
未读取数据所在 raw 分片摘要变化时停止该回放,要求新数据集版本;本轮 cleaned_value 更新不计作源版本漂移。空检查批次也需要保存处理阶段,记录数为零不代表可以绕过缺测判断。
|
||||||
|
|
||||||
|
每个回放同一时刻只有一个有效处理租约,采用数据库会话锁阻止两个请求重复推进同一周期。每个批次提交后再读取下一原始时间戳,不持久保存进程计时器值。
|
||||||
|
|
||||||
|
源数据和相关回放证据在本次演示中不自动过期;一个月是数据覆盖范围,不是保存期限。事件结束、暂停或重播不清理已有证据。生产保留策略单独制定。
|
||||||
|
|
||||||
|
## Agent 调查与时间隔离
|
||||||
|
|
||||||
|
主动单步在边界形成组合监测回执:一般关注和数据不足只保存回执,只有 `suspected_burst` 进入 `burst_report`。SCADA 演示请求只负责建立持久调查任务,不要求先生成一条合成 test_data,也不在请求内执行 Agent。
|
||||||
|
|
||||||
|
告警事实与“待建立调查”意图在事件事务中保存。创建调查失败时后台对账重试;组合监测调查的来源稳定标识使用 `scada_replay:<replay_id>:monitor:<period_end>:<rule_version>`,一般事件继续使用 `scada_replay:<replay_id>:event:<event_id>`,均沿用现有外部来源幂等约束。事件不因 Agent 不可用而丢失。
|
||||||
|
|
||||||
|
已新增 `scada_anomaly` 工作流与对应 Skill,要求先核验数据质量、点位和单位,再读取事件窗口及正常参考。疑似爆管复用现有 `burst_report` Skill;它会区分侦测、定位候选和现场确诊,并要求漏量依据。`run_burst_location` 在回放任务中通过冻结输出适配执行,其他未适配计算继续拒绝;不能仅修改 Skill 文本或 MCP allowlist 绕过知识截止。重计算沿用 [后台计算队列](calculation-queue.md),保留外层 HTTP 限制。
|
||||||
|
|
||||||
|
新增 Server 持有的回放查询上下文:`replay_id`、数据集版本、知识截止时刻 `knowledge_cutoff`、已接收批次上限、处理版本和来源事件。它与任务及执行代次绑定,不能由模型或客户端自行扩大。
|
||||||
|
|
||||||
|
每代调查开始时冻结其可用批次与时间上限;同一执行中后续回放推进不会偷偷改变证据。确有新的重要证据时,按现有 [调查轮次规则](operational-tasks.md) 创建或合并后续调查轮次,而不是每批次追加一次模型执行。
|
||||||
|
|
||||||
|
时间隔离必须覆盖 MCP 的 SCADA 查询、要素历史、任务数据快照、外围观测,以及检测算法内部读取。raw 源读取 API 只允许读取不晚于模拟时钟的观测;检测只使用已完成本轮清洗的批次结果,原始表格可以显示本轮已读取且尚在清洗的 raw。历史回看和旧代调查读取对应固定版本或原始记录引用,异常证据使用已经固化的必要片段。即使 TSDB 存有整个月记录,也必须按本轮读取检查点、设备集合和知识截止时间过滤,不能只在提示词中要求遵守。
|
||||||
|
|
||||||
|
所有回放调查证据保留真实来源:合成数据仍为 synthetic,历史回放有明确标识,不能因为读取的是正式表就改称现场实时观测。普通历史数据管理仍可按原权限查看数据集,但不能作为回放会话绕过截止时间的途径。
|
||||||
|
|
||||||
|
回放调查只生成演示分析、计划和报告;真实外围工单和现场控制不属于自动回放动作。Server 动作执行边界必须识别回放上下文,不能仅靠 Agent 提示阻止将演示事件发成真实业务工单。
|
||||||
|
|
||||||
|
## 接口与界面
|
||||||
|
|
||||||
|
以下为已经加入 Server OpenAPI 的路径,统一使用 Bearer 与 X-Project-Id。保留原有 `/peripheral/api/sample/v1` 工单与手工演练接口,回放不增加一层外围代理。
|
||||||
|
|
||||||
|
| 接口 | 语义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/v1/scada-replays/capabilities` | 回放表可用性、写权限、监测频率、爆管组合监测角色是否就绪及最近一轮状态;不代表完整月度数据就绪 |
|
||||||
|
| `POST /api/v1/scada-replays` | 固定区间、设备、规则和质量量程,返回 ready;携带稳定 request_id |
|
||||||
|
| `GET /api/v1/scada-replays/{replay_id}` | 权威控制、时间、计数、设备与固定规则、当前输出及故障 |
|
||||||
|
| `POST /api/v1/scada-replays/{replay_id}/commands` | play、pause、step、set_speed、stop、retry;request_id + expected_revision |
|
||||||
|
| `GET /api/v1/scada-replays/{replay_id}/observations?through=...` | 仅本轮已读取时间范围内的历史快照,每设备最近一条、最多 500 个 |
|
||||||
|
| `GET /api/v1/timeseries/scada-readings/raw` | 当前项目 TSDB 原始值联合游标分页;不插入、不隐式清洗 |
|
||||||
|
| `GET /api/v1/scada-replays/{replay_id}/events?offset=0&limit=100` | 检测变化与调查关联,返回 items、next_offset |
|
||||||
|
|
||||||
|
raw API 允许参数为 start_time、end_time、device_ids、cursor、limit;回放请求携带经服务端验证的 replay_id,任务请求由已有绑定解析回放身份。有效范围为所选区间与模拟时间上限的交集,不能因省略 replay_id 绕过已绑定的任务限制。返回 items(time、device_id、monitored_value、quality)、next_cursor 和 scanned_through;本版不返回尚未准备的 dataset_revision,不返回 corrected 作为源输入。
|
||||||
|
|
||||||
|
next_cursor 绑定项目、设备、查询区间并固定本轮分页时间上限;本版不为整个查询提供 MVCC 源版本快照,按 time、device_id 排序,每页最多 500 条。读取窗口未结束但当前页为空时,scanned_through 表示已经确认没有观测的区间上限,供后台登记空检查批次。分页、刷新和网络重试均是无副作用读取。
|
||||||
|
|
||||||
|
查询和检查点更新使用类型化服务方法及已验证项目上下文。清洗函数接受已经读入的批次,不再调用当前会重新查询 raw 的整段清洗入口;检测函数接受这批已提交清洗结果。页面的单步 POST 命令同步完成一个监测周期,GET 预览和回看不启动任务。
|
||||||
|
|
||||||
|
回放读取要求 scada.view,创建及控制要求 scada.clean;普通 raw API 沿用 timeseries SCADA 路由的读权限。API 不自动创建表,不回退默认项目,也不隐式枚举全部活跃项目。
|
||||||
|
|
||||||
|
前端 SCADA 页签按以下顺序组织:
|
||||||
|
|
||||||
|
1. 选定演示区间:开始时间、结束时间和项目监测步长;设备范围、规则及基线收纳在折叠的检测参数区域。
|
||||||
|
2. FM 调频式时间轴:局部玻璃刻度窗、固定红色指示线、两侧渐暗及中央放大;按 5 分钟时间戳定位,不读取逐测点列表,也不触发监测。
|
||||||
|
3. 主动监测操作:以“开始并监测”或“下一步并监测”为唯一主操作,按项目频率同步处理一个周期。
|
||||||
|
4. 本周期结论:显示正常、关注、数据不足或疑似爆管,以及是否已经进入爆管调查与定位流程。
|
||||||
|
|
||||||
|
设备目录、raw 与修正值明细、完整事件列表和手工单点异常录入不在演示主面板展示。底层数据与 API 继续保留,供诊断、追溯和调查任务按权限读取。
|
||||||
|
|
||||||
|
沿用现有 workbench-shadcn-surface、日期控件和实体表面,调频刻度窗采用上文明确的局部玻璃例外;时间轴支持键盘操作和中文可访问名称。拖动回看不触发后台写操作。
|
||||||
|
|
||||||
|
页面可见时每 5 秒读取一次权威控制快照;单步请求执行期间由操作本身返回最终快照。页面隐藏时停止界面采样。演示面板不轮询完整事件列表,调查入口需要时再按分页 API 查询。
|
||||||
|
|
||||||
|
## 实施范围与验证
|
||||||
|
|
||||||
|
这是跨 Server、供水前端和 Agent 契约的变更,不新增后台进程。数据、存储、身份及网络复用现有部署。
|
||||||
|
|
||||||
|
| 仓库 | 主要落点 |
|
||||||
|
| --- | --- |
|
||||||
|
| Server | `app/api/v1/endpoints/scada_replay.py` 的回放控制与能力;`app/services/` 和 `app/infra/db/` 的批次、质量和事件;现有 task_alert、operational_task、MCP 上下文、查询和 Skill |
|
||||||
|
| Server 契约与存储 | `resources/sql/` 的 BizDB 两张回放表迁移;`contracts/` 的 raw 读取、Server OpenAPI 和清单;外围协议不变;TSDB 沿用已有观测表 |
|
||||||
|
| 前端 | `src/features/workbench/peripheral-sample/` 的回放控制与状态 hook;SCADA 客户端与生成契约;告警来源和事件解析 |
|
||||||
|
| Agent | `src/product/` 的生成契约及任务上下文适配,保留已有原生 MCP 与执行权限边界 |
|
||||||
|
|
||||||
|
验收用例必须覆盖:
|
||||||
|
|
||||||
|
- 5/15 分钟设备混合、乱序源记录、同一时间戳跨页、空片段、结束边界和多次异常。
|
||||||
|
- 重复批次、相同编号不同内容、响应丢失、请求中断和恢复重试;不漏读、不重复事件。
|
||||||
|
- GET 重试、分页重复请求、清洗输出预存后中断、TSDB 更新提交而 BizDB 阶段未更新、检测失败;逐阶段恢复,不提前检测。
|
||||||
|
- 正常处理每批只读一次 raw,清洗后检测复用结果;回放不执行 raw INSERT,不在读取时清空旧 corrected,空数据不伪造观测。
|
||||||
|
- 周期内多批次单步、重复单步拒绝、并发请求互斥和尾步完成状态。
|
||||||
|
- 5 分钟时间定位与 15 分钟监测周期相互独立、非整点起点、周期配置变更不影响当前回放、尾步不足完整周期和未配置时禁止开始。
|
||||||
|
- 每个步长边界只产生一份幂等监测回执;正常、数据不足、单指标关注和疑似爆管分别处理,不为正常周期创建 Agent。
|
||||||
|
- 爆管侦测基线不足、压力下降、出厂流量变化、设备角色错误、指标方向冲突和多指标同时命中;只有疑似爆管建立 `burst_report`。
|
||||||
|
- 回放 MCP 侦测与定位只能读取固定批次和知识截止前数据;定位缺少漏量依据时停止在待补充,不调用算法。
|
||||||
|
- 选定小时区间、跨日区间和再次演示;刻度与进度只对应选定区间,基线初始化不释放旧事件,到终点不自动播放整月。
|
||||||
|
- FM 红线固定、刻度带移动、拖动回看不重复投递、未来位置不泄露读数;桌面与 375px、320px 窄屏下的长日期、键盘、减少动态效果及玻璃回退。
|
||||||
|
- 真实突降不被当作采集误差修掉;负流量、零水质值、空值、错误单位和缺测恢复分别处理。
|
||||||
|
- 首个异常、持续异常、可信恢复、再次异常及两个事件组并发;不会逐点创建调查。
|
||||||
|
- 缺少基线、缺少水质指标配置、历史 corrected 无可用时间,以及 source_revision 变化。
|
||||||
|
- MCP、任务数据接口及算法内部的未来查询均被拒绝;下一代调查才能使用新增证据。
|
||||||
|
- 合成来源、历史回放来源、事件真值隔离;回放不发布真实现场动作。
|
||||||
|
- 合成测试链路、真实项目 API、常驻后台、真实 Agent 执行和浏览器分别记录验证结果。
|
||||||
|
|
||||||
|
后续实现的验证命令从各仓库执行:Server 运行新增回放、批次、质量、事件与查询边界的聚焦 pytest,并保留现有 SCADA/告警/调查回归;前端运行 `pnpm lint`、`pnpm typecheck`、`pnpm test` 和聚焦 `pnpm test:browser`;Agent 修改后运行 `bun run check` 及对应聚焦测试。具体已执行结果见下方,不把待部署链路计为通过。
|
||||||
|
|
||||||
|
数据库应用遵守 [工作台数据库变更门禁](workbench-database-rollout.md):先核对目标项目,备份和回滚演练,再按明确范围应用及验收;不得默认向全部项目写入。本地 MetaDB 已对明确的演示项目应用并复验 `033_metadata_project_settings.sql`,其他项目保持原值;`tjwater2_v2` BizDB 已应用 `031_scada_replays.sql`,不据此推断其他项目已迁移。本次没有生成一个月观测或启动常驻进程。
|
||||||
|
|
||||||
|
回退应用前暂停新批次读取,核对 clean_prepared、cleaned 与 detected 阶段,保留原始观测、已提交修正值、回放表、告警与调查证据;升级恢复后从原阶段继续。BizDB 流程记录和 TSDB 修正值不在同一事务,回滚应用不能假定另一库结果也被撤回。不能通过删除事件、重置时间游标或清空 SCADA 原始表来伪装成从未处理。若需要撤回数据库变更,另按备份与新增业务写入情况制定恢复步骤,不在仍有写入时覆盖数据库。
|
||||||
|
|
||||||
|
本方案依赖的关键前提是源历史能提供稳定设备身份、可信时间戳和固定数据版本。缺少完整月度观测时,能力接口只能声明实际可用范围;缺少修正来源时采用 raw;缺少检测参数时该类检测不启用。任何这些缺口都不能由时间轴、插值或预设事件答案掩盖。
|
||||||
|
|
||||||
|
## 部署准备与验证记录
|
||||||
|
|
||||||
|
迁移脚本为 [031_scada_replays.sql](../resources/sql/031_scada_replays.sql),显式限制物理数据库 `tjwater_v2` 与 `tjwater2_v2`,依赖既有 peripheral_sample schema、告警及调查结构。仅新增两张 BizDB 表,不新增 TSDB 观测表。`tjwater2_v2` 已应用该迁移并配置 `settings.scada_monitoring_interval_seconds=900`。
|
||||||
|
|
||||||
|
真实数据库验证使用 `SCADA_REPLAY_STORAGE_TEST=1 conda run -n server python -m pytest tests/integration/test_scada_replay_storage.py -q`。该测试在项目路由选出的 BizDB 与 TSDB 双回滚事务中临时建表、写测试观测,覆盖真实 SQL 的 cleaned 更新、事件持久化和主动周期推进,退出全部回滚;它不证明身份认证 HTTP 和真实 Agent 推理已经验收。浏览器测试以 mock Server 验证交互与窄屏布局,不能作为现场检测效果证明。
|
||||||
|
|
||||||
|
本轮验证结果(2026-11-03,当前工作区):
|
||||||
|
|
||||||
|
| 层级 | 结果与边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| Server | 主动监测、条件式爆管调查、冻结定位适配、MCP、项目配置、raw、处理及 OpenAPI 聚焦回归共 120 项通过 |
|
||||||
|
| 数据库与契约 | 本地 MetaDB 的 033 迁移已单项目应用并重复执行验证;OpenAPI 279 条路径与清单摘要一致;本轮未运行 031 的真实双库集成测试 |
|
||||||
|
| 前端 | lint、typecheck、全量 148 文件共 884 项 Vitest 通过;设备列表、手工单点演练及对应触发适配已移除 |
|
||||||
|
| 浏览器 | 1 项 mock Server 场景通过,包含 5 分钟刻度、选择值与键盘步进、连续拖动轨迹、15 分钟主动监测、中央放大、两侧渐暗、固定红针及 1280/375/320px 布局;未作为真实检测效果证明 |
|
||||||
|
| Agent | 同步 Server OpenAPI 并生成 server-api.d.ts,bun run check 通过;无真实模型推理 |
|
||||||
|
| 仓库 | Server、供水前端、Agent 的 git diff --check 通过;未提交、推送或部署 |
|
||||||
|
|
||||||
|
生产联调仍需核验真实 HTTP 接口、调查执行链路和 Agent 报告,并单独准备完整月度数据及事件真值。不能将以上局部测试解释为这些环节已经完成。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# tjwater2_v2 爆管模拟校准设计
|
||||||
|
|
||||||
|
状态:参数与只读校验已准备,尚未运行水力模拟。目标项目为 `tjwater2_v2`(演示项目2(新版)),不是 `tjwater_v2`。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
先用现有管网、阀门和监测点选择可观测的事故管段,再在隔离评测数据库中校准破口面积。首版使用管道截面积的 1/32、1/16、1/8;1/8 是上限。若 1/8 仍不能形成明确降压信号,该管段退出 SCADA 爆管用例,不继续扩大破口。
|
||||||
|
|
||||||
|
模拟调用链为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
只读源数据快照 -> 隔离 BizDB/模型模板 -> 正常对照模拟
|
||||||
|
-> 爆管三档校准 -> 选择最小通过面积
|
||||||
|
-> 保存正式分析结果 -> 映射 SCADA 数据
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 `temporary_project_database` 会从项目模型模板创建临时数据库,同时短暂禁止模板连接并终止模板会话。因此即使 `run_burst_simulation` 不保存结果,也不能直接针对原项目执行。正式运行必须把只读逻辑快照恢复到独立评测数据库及其模板,Server、worker 和 Agent 全部使用评测路由。
|
||||||
|
|
||||||
|
## 方法参数
|
||||||
|
|
||||||
|
`run_burst_simulation` 接收带时区的 `start_time`、60 至 86400 秒的 `duration`、1 至 20 个输出节点、至多 20 条输出管段、1 至 20 条爆管管段,以及与管段一一对应的正数孔口面积 `orifice_areas_cm2`。本次每个调用只模拟一条爆管管段,等待预算使用 240 秒;排队成功只表示已接受,仍需查询计算任务和最终结果。
|
||||||
|
|
||||||
|
`tjwater2_v2` 使用 LPS 流量单位、米制压力、0.5 emitter 指数,水力和报告步长均为 15 分钟。压力设备值使用 m,流量设备值使用 m³/h。3600 秒模拟必须产生 0、15、30、45、60 分钟五个结果时刻;缺少任一时刻即失败。爆管服务把 emitter 放在管段末端 junction,切换临时模型为 PDA,并设置 `REQUIRED PRESSURE=10 m`。
|
||||||
|
|
||||||
|
孔口面积是输入,不是漏水流量。服务的 emitter 系数为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
coefficient[L/s/sqrt(m)] = 0.65 * area[cm²] / 10000 * sqrt(19.6) * 1000
|
||||||
|
```
|
||||||
|
|
||||||
|
正式运行后,定位使用的泄漏量按事故节点相对正常对照的 demand 增量计算,并从 L/s 除以 1000 转成 m³/s。流量监测设备单位为 m³/h,模型流量结果为 L/s,映射 SCADA 时乘以 3.6。
|
||||||
|
|
||||||
|
## 管段与场景
|
||||||
|
|
||||||
|
主场景管段 `541720` 为 DN800、长 483.038 m,破口节点 `502046`。附近两个压力点沿管网约 1.2 km,最近流量点约 1.2 km;两座边界阀可隔离 11 个节点。其破口面积为 157.079633、314.159265、628.318531 cm²。
|
||||||
|
|
||||||
|
清单同时保存 SCADA 设备 ID 与其实际水力模型对象 ID,不能假定两者相同。例如压力设备 `447382`、`444985` 分别绑定节点 `7543252226207002012`、`14141146287589625673`,流量设备 `542121` 绑定管段 `pipe-eaa5c512bd8f4649b37bdbe3d`。模拟输出使用模型对象 ID,得分与数据映射使用设备 ID。
|
||||||
|
|
||||||
|
启动校验还要求所选压力设备到绑定节点、流量设备到绑定管段的本地坐标距离均不超过 1 m。全量复核中待人工确认的 `542017`、`547063` 和 `DEMO-WQ-551571` 不进入本套爆管 benchmark;不得为通过校验而自动吸附设备坐标或猜测改绑对象。
|
||||||
|
|
||||||
|
次级场景管段 `520323` 为 DN300、长 33.5951 m,破口节点 `91055`。最近压力点沿管网约 262 m,五座边界阀可隔离 19 个节点。其破口面积为 22.089323、44.178647、88.357293 cm²。
|
||||||
|
|
||||||
|
两条管段分别在北京时间 2026-10-08 02:00 和 12:00 模拟,形成 B01 至 B04 四个物理场景。每个场景都必须有同时间、同持续时长、相同模型状态的无爆管正常对照。严重档先验证是否可观测;通过后依次测试中度和轻度,最终选择满足要求的最小面积。最坏情况下执行 12 次爆管校准模拟,正常对照按项目、时间和模型版本复用。
|
||||||
|
|
||||||
|
## 通过条件
|
||||||
|
|
||||||
|
一个面积只有同时满足以下条件才可冻结:
|
||||||
|
|
||||||
|
1. 至少一个指定压力点连续两个 15 分钟时刻较正常对照下降 30%。
|
||||||
|
2. 第二压力点下降至少 10%,或指定流量点相对正常对照变化至少 20%。
|
||||||
|
3. 正常对照不触发相同规则。
|
||||||
|
4. 指定监测节点不出现负压,模拟正常收敛,所有请求输出完整。
|
||||||
|
5. 同一管段的夜间和白天场景使用同一面积;两者都通过时才能进入正式 benchmark。
|
||||||
|
|
||||||
|
压力与流量变化都按相同时间戳的正常对照计算:`abs(事故值 - 正常值) / max(abs(正常值), 1e-6)`。压力规则只取下降方向,流量规则取绝对变化。不得用其他时段均值代替同时间戳对照。
|
||||||
|
|
||||||
|
压力下降、流量变化、实际泄漏量、计算状态和缺失输出都保存到校准报告。失败的档位保留,不重试到通过。若严重档失败,该管段标记为不适用;若较小档位失败而较大档位通过,选择最小通过档位。
|
||||||
|
|
||||||
|
## 校验与后续执行
|
||||||
|
|
||||||
|
清单保存在 `resources/benchmarks/tjwater2-v2-burst-simulation.json`。以下命令使用本地占位 context handle,把全部 12 组计划参数送入当前 `BurstSimulationInput` 做契约校验,再只读检查 Metadata 项目身份、当前 BizDB、模型模板、管段属性、模型单位、步长、监测点、设备到绑定对象的距离、边界阀和既有 emitter;它不创建运行上下文,也不调用模拟工具:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n server python scripts/check_burst_simulation_design.py
|
||||||
|
```
|
||||||
|
|
||||||
|
隔离评测数据库准备完成后,runner 先按清单调用非持久 `run_burst_simulation` 校准。面积冻结后,通过 `save_simulation_scenario` 在评测数据库保存全量节点和管段结果,生成固定的运行 ID 和数据摘要。模型分析结果与模拟 SCADA 保留明确来源,不标记为实测数据。
|
||||||
@@ -21,6 +21,26 @@ services:
|
|||||||
- timescaledb
|
- timescaledb
|
||||||
- postgis
|
- postgis
|
||||||
|
|
||||||
|
# Enable after provisioning calculation storage and explicit project scopes.
|
||||||
|
calculation-worker:
|
||||||
|
profiles: [calculations]
|
||||||
|
image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local}
|
||||||
|
init: true
|
||||||
|
restart: on-failure
|
||||||
|
command: ["python", "scripts/run_calculation_worker.py"]
|
||||||
|
env_file:
|
||||||
|
- ../../.env
|
||||||
|
environment:
|
||||||
|
- PYTHONPATH=/app
|
||||||
|
volumes:
|
||||||
|
- ../../app:/app/app
|
||||||
|
- ../../resources:/app/resources
|
||||||
|
- calculation-state:/app/temp/calculation-jobs
|
||||||
|
stop_grace_period: 30s
|
||||||
|
depends_on:
|
||||||
|
- timescaledb
|
||||||
|
- postgis
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Infrastructure Services
|
# Infrastructure Services
|
||||||
# ==========================================
|
# ==========================================
|
||||||
@@ -127,6 +147,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./mapservice/geoserver_data:/opt/geoserver_data
|
- ./mapservice/geoserver_data:/opt/geoserver_data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
calculation-state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TJWater queued calculation worker
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/NextTJWater/next-tjwater-server
|
||||||
|
ExecStart=%h/miniconda3/envs/server/bin/python scripts/run_calculation_worker.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=30
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"version": "burst-simulation-design@1",
|
||||||
|
"suite_id": "tjwater2-v2-burst-calibration",
|
||||||
|
"project_code": "tjwater2_v2",
|
||||||
|
"project_id": "871d7538-589c-47de-a6d5-3185ccb9f0e1",
|
||||||
|
"model_expectations": {
|
||||||
|
"node_count": 92392,
|
||||||
|
"pipe_count": 91049,
|
||||||
|
"valve_count": 4489,
|
||||||
|
"flow_unit": "LPS",
|
||||||
|
"pressure_unit": "METERS",
|
||||||
|
"pressure_measurement_unit": "m",
|
||||||
|
"flow_measurement_unit": "m3/h",
|
||||||
|
"emitter_exponent": 0.5,
|
||||||
|
"hydraulic_timestep_seconds": 900,
|
||||||
|
"report_timestep_seconds": 900
|
||||||
|
},
|
||||||
|
"duration_seconds": 3600,
|
||||||
|
"event_times": [
|
||||||
|
"2026-10-08T02:00:00+08:00",
|
||||||
|
"2026-10-08T12:00:00+08:00"
|
||||||
|
],
|
||||||
|
"severity_levels": [
|
||||||
|
{"level": "light", "cross_section_fraction": 0.03125},
|
||||||
|
{"level": "medium", "cross_section_fraction": 0.0625},
|
||||||
|
{"level": "severe", "cross_section_fraction": 0.125}
|
||||||
|
],
|
||||||
|
"calibration_order": ["severe", "medium", "light"],
|
||||||
|
"selection_rule": {
|
||||||
|
"primary_pressure_drop_percent": 30,
|
||||||
|
"corroborating_pressure_drop_percent": 10,
|
||||||
|
"flow_change_percent": 20,
|
||||||
|
"consecutive_samples": 2,
|
||||||
|
"expected_sample_count": 5,
|
||||||
|
"relative_change_epsilon": 0.000001,
|
||||||
|
"normal_control_must_not_trigger": true,
|
||||||
|
"negative_monitor_pressure_allowed": false,
|
||||||
|
"choose_smallest_passing_area": true
|
||||||
|
},
|
||||||
|
"binding_validation": {
|
||||||
|
"max_pressure_distance_m": 1.0,
|
||||||
|
"max_flow_distance_m": 1.0
|
||||||
|
},
|
||||||
|
"cases": [
|
||||||
|
{"case_id": "B01", "pipe_id": "541720", "occurred_at": "2026-10-08T02:00:00+08:00"},
|
||||||
|
{"case_id": "B02", "pipe_id": "541720", "occurred_at": "2026-10-08T12:00:00+08:00"},
|
||||||
|
{"case_id": "B03", "pipe_id": "520323", "occurred_at": "2026-10-08T02:00:00+08:00"},
|
||||||
|
{"case_id": "B04", "pipe_id": "520323", "occurred_at": "2026-10-08T12:00:00+08:00"}
|
||||||
|
],
|
||||||
|
"pipes": [
|
||||||
|
{
|
||||||
|
"role": "primary",
|
||||||
|
"pipe_id": "541720",
|
||||||
|
"diameter_mm": 800,
|
||||||
|
"length_m": 483.038,
|
||||||
|
"start_node_id": "532586",
|
||||||
|
"emitter_node_id": "502046",
|
||||||
|
"longitude": 121.483586,
|
||||||
|
"latitude": 30.996219,
|
||||||
|
"candidate_orifice_areas_cm2": {
|
||||||
|
"light": 157.079633,
|
||||||
|
"medium": 314.159265,
|
||||||
|
"severe": 628.318531
|
||||||
|
},
|
||||||
|
"pressure_bindings": [
|
||||||
|
{"device_id": "447382", "node_id": "7543252226207002012"},
|
||||||
|
{"device_id": "444985", "node_id": "14141146287589625673"},
|
||||||
|
{"device_id": "100026", "node_id": "100026"},
|
||||||
|
{"device_id": "115039", "node_id": "115039"},
|
||||||
|
{"device_id": "107672", "node_id": "107672"}
|
||||||
|
],
|
||||||
|
"flow_bindings": [
|
||||||
|
{"device_id": "542121", "link_id": "pipe-eaa5c512bd8f4649b37bdbe3d"},
|
||||||
|
{"device_id": "449417", "link_id": "449417"},
|
||||||
|
{"device_id": "516234", "link_id": "516234"}
|
||||||
|
],
|
||||||
|
"boundary_valve_ids": [
|
||||||
|
"valve-6fd58ab5b5714fbab738a544",
|
||||||
|
"valve-8aea6a1876d8475b9f35a845"
|
||||||
|
],
|
||||||
|
"output_node_ids": [
|
||||||
|
"12290448554375617026", "1371402016460607520", "400946", "413270",
|
||||||
|
"414851", "465564", "466633", "482531", "493166", "502046",
|
||||||
|
"532586", "7543252226207002012", "14141146287589625673",
|
||||||
|
"100026", "115039", "107672"
|
||||||
|
],
|
||||||
|
"output_link_ids": [
|
||||||
|
"541720", "pipe-eaa5c512bd8f4649b37bdbe3d", "449417", "516234",
|
||||||
|
"valve-6fd58ab5b5714fbab738a544",
|
||||||
|
"valve-8aea6a1876d8475b9f35a845"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "secondary",
|
||||||
|
"pipe_id": "520323",
|
||||||
|
"diameter_mm": 300,
|
||||||
|
"length_m": 33.5951,
|
||||||
|
"start_node_id": "91054",
|
||||||
|
"emitter_node_id": "91055",
|
||||||
|
"longitude": 121.468305,
|
||||||
|
"latitude": 30.932363,
|
||||||
|
"candidate_orifice_areas_cm2": {
|
||||||
|
"light": 22.089323,
|
||||||
|
"medium": 44.178647,
|
||||||
|
"severe": 88.357293
|
||||||
|
},
|
||||||
|
"pressure_bindings": [
|
||||||
|
{"device_id": "91100", "node_id": "91100"},
|
||||||
|
{"device_id": "98367", "node_id": "98367"},
|
||||||
|
{"device_id": "69693", "node_id": "69693"},
|
||||||
|
{"device_id": "78085", "node_id": "78085"},
|
||||||
|
{"device_id": "105833", "node_id": "105833"}
|
||||||
|
],
|
||||||
|
"flow_bindings": [
|
||||||
|
{"device_id": "527865", "link_id": "527865"},
|
||||||
|
{"device_id": "489082", "link_id": "489082"},
|
||||||
|
{"device_id": "531564", "link_id": "531564"}
|
||||||
|
],
|
||||||
|
"boundary_valve_ids": [
|
||||||
|
"valve-18b95b02a7564e7dac737102",
|
||||||
|
"valve-3a8fd38228d74583a08b00d9",
|
||||||
|
"valve-3b453e4e901049d1a1346689",
|
||||||
|
"valve-ad39de2f15f24ade9d18c836",
|
||||||
|
"valve-fc1164434a954f418a6f11d6"
|
||||||
|
],
|
||||||
|
"output_node_ids": ["91054", "91055", "91100", "98367", "69693", "78085", "105833"],
|
||||||
|
"output_link_ids": [
|
||||||
|
"520323", "527865", "489082", "531564",
|
||||||
|
"valve-18b95b02a7564e7dac737102",
|
||||||
|
"valve-3a8fd38228d74583a08b00d9",
|
||||||
|
"valve-3b453e4e901049d1a1346689",
|
||||||
|
"valve-ad39de2f15f24ade9d18c836",
|
||||||
|
"valve-fc1164434a954f418a6f11d6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -19,13 +19,18 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||||||
gs_workspace VARCHAR(100) UNIQUE NOT NULL,
|
gs_workspace VARCHAR(100) UNIQUE NOT NULL,
|
||||||
map_extent JSONB,
|
map_extent JSONB,
|
||||||
map_config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
map_config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
status VARCHAR(20) DEFAULT 'active' NOT NULL,
|
status VARCHAR(20) DEFAULT 'active' NOT NULL,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived'))
|
CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived')),
|
||||||
|
CONSTRAINT projects_settings_object_check CHECK (jsonb_typeof(settings) = 'object')
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS map_config JSONB NOT NULL DEFAULT '{}'::jsonb;
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS map_config JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS settings JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
COMMENT ON COLUMN projects.settings IS
|
||||||
|
'项目级通用配置(JSON 对象,不存放密钥);scada_monitoring_interval_seconds 为监测任务间隔秒数。';
|
||||||
COMMENT ON COLUMN projects.map_config IS
|
COMMENT ON COLUMN projects.map_config IS
|
||||||
'项目级地图配置(JSON);place_search 保存地名搜索启用状态、服务商、行政区编码及区域名称;保留既有图例与坐标参考配置。';
|
'项目级地图配置(JSON);place_search 保存地名搜索启用状态、服务商、行政区编码及区域名称;保留既有图例与坐标参考配置。';
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS workbench.operational_task_runs (
|
|||||||
)),
|
)),
|
||||||
revision bigint NOT NULL CHECK (revision >= 1),
|
revision bigint NOT NULL CHECK (revision >= 1),
|
||||||
agent_session_id text,
|
agent_session_id text,
|
||||||
|
agent_instance_id text,
|
||||||
lease_expires_at timestamptz,
|
lease_expires_at timestamptz,
|
||||||
heartbeat_at timestamptz,
|
heartbeat_at timestamptz,
|
||||||
started_at timestamptz,
|
started_at timestamptz,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Apply independently to each project's business database.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS workbench;
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.calculation_jobs (
|
||||||
|
job_id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
owner_user_id uuid NOT NULL,
|
||||||
|
request_key text NOT NULL,
|
||||||
|
tool text NOT NULL,
|
||||||
|
parameters jsonb NOT NULL,
|
||||||
|
binding jsonb NOT NULL,
|
||||||
|
timeout_seconds integer NOT NULL CHECK (timeout_seconds BETWEEN 1 AND 86400),
|
||||||
|
status text NOT NULL DEFAULT 'queued' CHECK (status IN
|
||||||
|
('queued','running','cancel_requested','succeeded','failed','cancelled','timed_out','interrupted')),
|
||||||
|
result jsonb,
|
||||||
|
error jsonb,
|
||||||
|
lease_generation bigint NOT NULL DEFAULT 0,
|
||||||
|
lease_owner text,
|
||||||
|
lease_expires_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
UNIQUE(project_id,owner_user_id,request_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS calculation_jobs_queue ON workbench.calculation_jobs(project_id,status,created_at);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Apply after 026, independently to every project business database.
|
||||||
|
-- Preserve job identity, deduplication and audit metadata, including legacy results.
|
||||||
|
ALTER TABLE workbench.calculation_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS started_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS finished_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS queue_expires_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS result_expires_at timestamptz;
|
||||||
|
UPDATE workbench.calculation_jobs SET queue_expires_at=created_at+interval '1 hour'
|
||||||
|
WHERE queue_expires_at IS NULL;
|
||||||
|
UPDATE workbench.calculation_jobs SET finished_at=updated_at
|
||||||
|
WHERE finished_at IS NULL AND status IN ('succeeded','failed','cancelled','timed_out','interrupted');
|
||||||
|
-- Give pre-deployment results a complete retention window, even on reruns.
|
||||||
|
UPDATE workbench.calculation_jobs SET result_expires_at=clock_timestamp()+interval '7 days'
|
||||||
|
WHERE result IS NOT NULL AND result_expires_at IS NULL;
|
||||||
|
ALTER TABLE workbench.calculation_jobs
|
||||||
|
ALTER COLUMN queue_expires_at SET DEFAULT (clock_timestamp()+interval '1 hour'),
|
||||||
|
ALTER COLUMN queue_expires_at SET NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS calculation_jobs_queue_expiry
|
||||||
|
ON workbench.calculation_jobs(project_id,queue_expires_at) WHERE status='queued';
|
||||||
|
CREATE INDEX IF NOT EXISTS calculation_jobs_result_expiry
|
||||||
|
ON workbench.calculation_jobs(project_id,result_expires_at) WHERE result IS NOT NULL;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Apply to every project business database before enabling task questions.
|
||||||
|
CREATE TABLE IF NOT EXISTS workbench.operational_task_questions (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
task_id uuid NOT NULL,
|
||||||
|
question_request_id uuid NOT NULL,
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, question_request_id),
|
||||||
|
FOREIGN KEY (project_id, task_id)
|
||||||
|
REFERENCES workbench.operational_tasks(project_id, task_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS operational_task_questions_task
|
||||||
|
ON workbench.operational_task_questions(project_id, task_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS operational_task_questions_pending
|
||||||
|
ON workbench.operational_task_questions(project_id, task_id)
|
||||||
|
WHERE document->>'status' = 'pending';
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
-- Peripheral work orders are business-system records. Remove the former
|
||||||
|
-- synthetic provenance branch from source rows and their task projections.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL lock_timeout = '5s';
|
||||||
|
|
||||||
|
CREATE FUNCTION pg_temp.normalize_peripheral_work_order_text(value jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
normalized text := value::text;
|
||||||
|
BEGIN
|
||||||
|
normalized := replace(normalized, '对应外围记录明确标为 synthetic(系统测试)', '对应外围记录为 system_data 系统数据');
|
||||||
|
normalized := replace(normalized, '有一条演练上报', '有一条系统上报');
|
||||||
|
normalized := replace(normalized, '证据覆盖为 synthetic 上报工单快照', '证据覆盖为 system_data 上报工单快照');
|
||||||
|
normalized := replace(normalized, '记录快照带 synthetic 标识', '记录快照为 system_data');
|
||||||
|
normalized := replace(normalized, '外围工单结果注明为synthetic演练数据', '外围工单为system_data系统数据');
|
||||||
|
normalized := replace(normalized, '工单为synthetic演练', '工单为system_data系统数据');
|
||||||
|
normalized := replace(normalized, 'synthetic 爆管上报记录', '系统爆管上报记录');
|
||||||
|
normalized := replace(normalized, '合成上报记录', '系统上报记录');
|
||||||
|
normalized := replace(normalized, '外围模拟工单', '外围系统工单');
|
||||||
|
normalized := replace(normalized, '外围记录为synthetic', '外围记录为system_data');
|
||||||
|
normalized := replace(normalized, '证据快照整体为synthetic演练来源', '工单快照为system_data系统来源');
|
||||||
|
normalized := replace(normalized, '来源标记为synthetic,仅能作为演练资料', '来源为system_data系统数据');
|
||||||
|
normalized := replace(normalized, 'synthetic演练记录', '系统记录');
|
||||||
|
normalized := replace(normalized, 'synthetic演练来源', 'system_data系统来源');
|
||||||
|
normalized := replace(normalized, 'synthetic 演练来源', 'system_data 系统来源');
|
||||||
|
normalized := replace(normalized, '外围样板合成摘要', '外围工单摘要');
|
||||||
|
normalized := replace(normalized, '合成外围演练', '外围系统记录');
|
||||||
|
normalized := replace(normalized, '合成外围', '外围');
|
||||||
|
normalized := replace(normalized, '合成联调', '系统联调');
|
||||||
|
normalized := replace(normalized, '合成演练', '系统测试');
|
||||||
|
normalized := replace(normalized, '合成事件', '系统测试事件');
|
||||||
|
RETURN normalized::jsonb;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
UPDATE peripheral_sample.work_orders
|
||||||
|
SET document = pg_temp.normalize_peripheral_work_order_text(
|
||||||
|
jsonb_set(document, '{origin}', '"system_data"'::jsonb, true)
|
||||||
|
)
|
||||||
|
WHERE document->>'origin' IS DISTINCT FROM 'system_data'
|
||||||
|
OR document::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
|
||||||
|
|
||||||
|
UPDATE peripheral_sample.work_order_revisions
|
||||||
|
SET document = pg_temp.normalize_peripheral_work_order_text(
|
||||||
|
jsonb_set(document, '{origin}', '"system_data"'::jsonb, true)
|
||||||
|
)
|
||||||
|
WHERE document->>'origin' IS DISTINCT FROM 'system_data'
|
||||||
|
OR document::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
|
||||||
|
|
||||||
|
UPDATE peripheral_sample.requests
|
||||||
|
SET result = pg_temp.normalize_peripheral_work_order_text(
|
||||||
|
jsonb_set(result, '{origin}', '"system_data"'::jsonb, true)
|
||||||
|
)
|
||||||
|
WHERE command_key IN ('create_work_order', 'update_work_order')
|
||||||
|
AND jsonb_typeof(result) = 'object'
|
||||||
|
AND result ? 'work_order_id'
|
||||||
|
AND (
|
||||||
|
result->>'origin' IS DISTINCT FROM 'system_data'
|
||||||
|
OR result::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE workbench.operational_task_external_feedback
|
||||||
|
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
|
||||||
|
jsonb_set(
|
||||||
|
jsonb_set(
|
||||||
|
jsonb_set(
|
||||||
|
document,
|
||||||
|
'{work_order_snapshot,origin}',
|
||||||
|
'"system_data"'::jsonb,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
'{evidence,kind}',
|
||||||
|
'"external_feedback"'::jsonb,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
'{evidence,source_label}',
|
||||||
|
'"外围工单"'::jsonb,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
'{detail}',
|
||||||
|
to_jsonb(replace(COALESCE(document->>'detail', ''), '合成外围工单', '外围工单')),
|
||||||
|
false
|
||||||
|
))
|
||||||
|
WHERE document ? 'work_order_snapshot';
|
||||||
|
|
||||||
|
UPDATE workbench.operational_task_evidence
|
||||||
|
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
|
||||||
|
jsonb_set(
|
||||||
|
document,
|
||||||
|
'{kind}',
|
||||||
|
'"external_feedback"'::jsonb,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
'{source_label}',
|
||||||
|
'"外围工单"'::jsonb,
|
||||||
|
false
|
||||||
|
))
|
||||||
|
WHERE document->>'source_ref' LIKE 'peripheral_sample:%'
|
||||||
|
OR evidence_id LIKE 'provenance-correction:peripheral_sample:%';
|
||||||
|
|
||||||
|
UPDATE workbench.operational_tasks AS task
|
||||||
|
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
|
||||||
|
task.document,
|
||||||
|
'{summary}',
|
||||||
|
to_jsonb(
|
||||||
|
replace(
|
||||||
|
replace(COALESCE(task.document->>'summary', ''), '合成外围工单', '外围工单'),
|
||||||
|
'合成外围事件',
|
||||||
|
'外围事件'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
false
|
||||||
|
))
|
||||||
|
FROM peripheral_sample.work_orders AS source_order
|
||||||
|
WHERE task.project_id = source_order.project_id
|
||||||
|
AND (
|
||||||
|
task.document#>>'{source,external_id}' = source_order.work_order_id::text
|
||||||
|
OR task.document#>>'{source,external_id}' =
|
||||||
|
'peripheral_sample:' || source_order.work_order_id::text
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE workbench.operational_task_reports AS report
|
||||||
|
SET document = pg_temp.normalize_peripheral_work_order_text(report.document)
|
||||||
|
FROM workbench.operational_tasks AS task
|
||||||
|
JOIN peripheral_sample.work_orders AS source_order
|
||||||
|
ON task.project_id = source_order.project_id
|
||||||
|
AND (
|
||||||
|
task.document#>>'{source,external_id}' = source_order.work_order_id::text
|
||||||
|
OR task.document#>>'{source,external_id}' =
|
||||||
|
'peripheral_sample:' || source_order.work_order_id::text
|
||||||
|
)
|
||||||
|
WHERE report.project_id = task.project_id
|
||||||
|
AND report.task_id = task.task_id
|
||||||
|
AND report.document::text ~*
|
||||||
|
'(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Bind durable task runs to the Agent instance that owns their local session.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE workbench.operational_task_runs
|
||||||
|
ADD COLUMN IF NOT EXISTS agent_instance_id text;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS operational_task_runs_agent_owner_idx
|
||||||
|
ON workbench.operational_task_runs(project_id,agent_instance_id,status,created_at,run_id)
|
||||||
|
WHERE agent_instance_id IS NOT NULL
|
||||||
|
AND status IN ('claimed','running');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
BEGIN;
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF current_database() NOT IN ('tjwater_v2', 'tjwater2_v2') THEN
|
||||||
|
RAISE EXCEPTION '031_scada_replays requires an explicitly verified SCADA demo project';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS peripheral_sample.scada_replays (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
replay_id uuid NOT NULL,
|
||||||
|
request_id uuid NOT NULL,
|
||||||
|
status text NOT NULL CHECK (status IN ('ready','paused','completed','stopped','failed')),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (project_id,replay_id),
|
||||||
|
UNIQUE (project_id,request_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS scada_replays_status_idx
|
||||||
|
ON peripheral_sample.scada_replays(project_id,status,created_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS peripheral_sample.scada_replay_batches (
|
||||||
|
project_id uuid NOT NULL,
|
||||||
|
replay_id uuid NOT NULL,
|
||||||
|
sequence bigint NOT NULL,
|
||||||
|
stage text NOT NULL CHECK(stage IN ('selected','clean_prepared','cleaned','detected')),
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY(project_id,replay_id,sequence),
|
||||||
|
FOREIGN KEY(project_id,replay_id) REFERENCES peripheral_sample.scada_replays(project_id,replay_id)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE peripheral_sample.scada_replays IS 'SCADA 演示回放的权威控制状态与处理检查点,不保存正式原始观测';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replays.project_id IS '经认证与路由校验的所属项目';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replays.replay_id IS '独立回放身份,不等同 Agent 执行编号';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replays.request_id IS '创建请求的稳定幂等编号';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replays.status IS '供领取和约束使用的 document.status 查询投影';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replays.document IS '冻结配置、设备快照、时钟、检查点、规则状态和命令回执';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replays.created_at IS '实际创建时间';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replays.updated_at IS '实际控制或进度更新时间';
|
||||||
|
COMMENT ON TABLE peripheral_sample.scada_replay_batches IS 'SCADA raw 引用、清洗输出及检测阶段的恢复凭证,禁止重写 raw';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replay_batches.project_id IS '所属项目';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replay_batches.replay_id IS '所属回放';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replay_batches.sequence IS '回放内单调批次序号';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replay_batches.stage IS '已提交处理阶段';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replay_batches.document IS '观测键、raw 摘要、确定的清洗结果与检测事件';
|
||||||
|
COMMENT ON COLUMN peripheral_sample.scada_replay_batches.created_at IS '实际登记批次时间';
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.project_databases') IS NULL
|
||||||
|
OR to_regclass('public.user_project_membership') IS NULL THEN
|
||||||
|
RAISE EXCEPTION '033_metadata_project_settings must run in the project metadata database';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE public.projects
|
||||||
|
ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'public.projects'::regclass
|
||||||
|
AND conname = 'projects_settings_object_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.projects
|
||||||
|
ADD CONSTRAINT projects_settings_object_check
|
||||||
|
CHECK (jsonb_typeof(settings) = 'object');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'projects'
|
||||||
|
AND column_name = 'scada_monitoring_interval_seconds'
|
||||||
|
) THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM public.projects
|
||||||
|
WHERE scada_monitoring_interval_seconds IS NOT NULL
|
||||||
|
AND settings ? 'scada_monitoring_interval_seconds'
|
||||||
|
AND settings->'scada_monitoring_interval_seconds'
|
||||||
|
IS DISTINCT FROM to_jsonb(scada_monitoring_interval_seconds)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Conflicting SCADA monitoring intervals; resolve before migration';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE public.projects
|
||||||
|
SET settings = settings || jsonb_build_object(
|
||||||
|
'scada_monitoring_interval_seconds', scada_monitoring_interval_seconds
|
||||||
|
)
|
||||||
|
WHERE scada_monitoring_interval_seconds IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE public.projects DROP COLUMN scada_monitoring_interval_seconds;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.projects.settings IS
|
||||||
|
'项目级通用配置(JSON 对象,不存放密钥);scada_monitoring_interval_seconds 为监测任务间隔秒数。';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -1,16 +1,36 @@
|
|||||||
---
|
---
|
||||||
name: appointment-follow-up
|
name: appointment-follow-up
|
||||||
description: 调查关联预约任务的执行状态与反馈,核对审批、派发和现场结果,避免把预约任务当成新的爆管或投诉事件。
|
description: 核查关联预约工单的审批、提交、现场执行和反馈,分别判断预约履约与原事件闭环。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "2.0.4"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 预约任务跟进
|
# 预约任务跟进
|
||||||
|
|
||||||
预约工单承载已安排的人工工作。先读取原任务、已审批动作、预约时间、派发状态和现场反馈;需要时追溯关联的投诉或爆管事件。预约时间、工单派发时间、现场完成时间和事件发生时间分别说明。
|
目标是说明预约安排了什么、流程到哪一步、现场实际做了什么、证据是否满足要求,以及还有哪些责任事项。预约工单承载已安排的人工工作,不自动代表新的爆管或水质事件,也不因存在待办而反复创建跟进工单。
|
||||||
|
|
||||||
核对预约工作的内容、执行条件和实际回执。只有现场反馈与证据支持时,才能认定采样、通知、抢修、冲洗或复测完成;工单创建或派发成功仅代表流程状态变化。对尚未完成的工作,报告责任事项、已有进展、缺少的反馈和下一步依赖。
|
## 接续与核查
|
||||||
|
|
||||||
预约工单不自动触发隔离停水或污染水排放,也不生成同类型的投诉、爆管事件。确有新增处置需要且已有证据支持时,保存阶段报告,再提议具体的关联预约工单(work_order_type=general),供人工审批;先核查已有有效草稿,避免在跟进过程中递归建单。
|
读取当前任务、关联工单、已有报告、证据、动作和最新反馈,核对预约时间、地点、负责人、工作内容及回执要求;需要时追溯关联的投诉或爆管。新一轮优先检查新增事实和旧未决项,保留本流程名称和版本。
|
||||||
|
|
||||||
如发现新的爆管或水质风险,明确记录有依据的发现及待核验项,不擅自更改当前任务类型。审批、现场操作、业务重新排队和恢复供水均遵守现有 Server 流程。正式报告保留所用流程名称和版本,便于追溯。
|
围绕原工作目标完成必要核查:
|
||||||
|
|
||||||
|
1. 将要求与来源工单最新版本逐项对照,区分预约、派发、到场、完成和原事件发生时间,不用创建或更新时间替代现场时间,也不因预约已过推定已经执行或失约。
|
||||||
|
2. 核对动作的审批、提交、执行状态和回执。`approved` 只表示允许提交,`submitted` 只表示已接收,`succeeded` 的建单动作只证明工单创建成功;这些状态均不证明采样、通知、抢修、冲洗或复测完成。`unknown` 由 Server 复核原执行请求,不重复下发。
|
||||||
|
3. 用 `query_peripheral_data` 查询外围工单、点位和现场观察,按来源 ID、关联动作及版本核对;目录首屏不能证明没有反馈。区分缺失、重复、旧版本和相互冲突的记录。
|
||||||
|
4. 按原目标查询位置、资产和工作前后的监测。地点和资产明确后展示正在核查的工作位置;取得监测对照后展示核查点位,并画出实际取得的工作前后趋势或对比图,核对现场发生时间与数据覆盖,不能把计划时间当实际执行分界;已有影响结果时展示其真实几何或节点覆盖示意。核实设备与模型要素、时间覆盖、单位和数据质量;缺测不是零值,局部正常不证明整项工作完成。
|
||||||
|
5. 将现场描述、来源系统附件或照片说明、监测变化和原工作要求交叉核对。没有图像查看能力时不声称已检查照片内容。每项要求标为已核验、待核验或不适用并给出依据;状态与证据冲突时明确列出。
|
||||||
|
|
||||||
|
## 结果与后续
|
||||||
|
|
||||||
|
报告分别说明原预约要求、审批及派发事实、已核实的现场工作和效果、冲突或缺口、当前责任事项,以及它与原事件的关系。等待回执不等于调查完成,更不等于现场工作完成。
|
||||||
|
|
||||||
|
位置、资产、监测和已有影响结果随核查步骤同步展示,遵循后台地图与图表规则,不用最终报告代替过程展示;单个数值或无可比数据时用文字、指标或表格,不为凑图新增业务分析。无数据或展示失败时说明具体限制,调查不等待浏览器。
|
||||||
|
|
||||||
|
只有证据支持新的具体工作时才提议动作。先核对已有有效动作和关联工单,优先沿原工单的反馈与人工修改流程推进;不要因缺少一项反馈就新建催办或复查工单。新增人工工作使用关联的 `work_order_type=general` 草稿,写明地点、内容、依赖、执行前核验和回执要求。
|
||||||
|
|
||||||
|
本流程不自动发起隔离停水、污染水排放或设备命令。发现新的爆管或水质风险时,列出证据、紧迫事项和建议,由原事件的合法调查继续专业分析;不要改写当前任务类型或递归创建同类事件。
|
||||||
|
|
||||||
|
预约履约完成须有与原要求对应的现场记录、结果与时间、必要回执、适用的监测或复测证据,以及未决事项核对。由有权限人员在来源系统确认工单完成,核对其新版本后才能认定履约完成。
|
||||||
|
|
||||||
|
预约完成不等于关联爆管或水质投诉闭环。报告须分别说明预约履约和原事件处置;只具备履约证据时,不宣称原事件风险已消除或已经结案。
|
||||||
|
|||||||
@@ -1,26 +1,53 @@
|
|||||||
---
|
---
|
||||||
name: burst-report
|
name: burst-report
|
||||||
description: 调查供水爆管上报,形成隔离停水、用户通知、抢修和恢复供水的处置方案及待审批动作。
|
description: 调查供水爆管上报,形成有依据的候选位置、隔离阀门、影响范围和后续处置建议。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "2.0.8"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 爆管上报处置
|
# 爆管上报处置
|
||||||
|
|
||||||
依据上报位置、时间、现场描述、相关管段台账和同窗压力流量证据,区分上报事实、爆管候选、已确认故障与演练假设。完成初步风险判断不要求抢修、复测和回访已经发生;这些属于后续执行反馈。演练数据可支持明确标注的演练判断,不能证明生产故障。
|
目标是形成有依据的候选位置、隔离方案、影响判断和可审批处置,并在后续轮次根据真实反馈复核事件闭环。区分候选方案、现场确诊和事件闭环;后两者不能提前阻断当前可完成的分析。演练数据可以用于比较,但必须标注 `synthetic`,不能证明生产故障。
|
||||||
|
|
||||||
## 调查与处置路线
|
## 接续与证据
|
||||||
|
|
||||||
1. 核对上报对象与时间窗,查询相关管段、周边监测和既有处置反馈。先确定实际覆盖范围;目录首屏或查询错误不能证明全网没有异常。具备输入时使用爆管侦测或定位分析,候选结果仍需现场核验。
|
读取当前任务、事件时刻、地点、来源、已有报告、证据、动作和反馈。新一轮优先检查新增事实和上轮未决项,按时间、来源、对象及输入比较新旧结果,不按 ID 推断先后,也不因原工单未更新就断言没有新证据。保留本流程名称和版本。
|
||||||
2. 形成隔离停水方案。已确认目标管段时,按需分析隔离阀门、关阀影响、失供范围和供水保障。资产映射、阀门状态或影响范围不清时,明确待现场核验条件;不得编造阀门编号或直接生成无目标的设备命令。
|
|
||||||
3. 安排用户通知和供水保障。草稿写明已知影响区域、拟停水时段或“待确认”、通知内容、通知渠道及责任岗位;关阀前通知与紧急处置后的补通知取决于现场紧迫性和获批方案。未知用户数量不填虚构值。通知草稿或内部调查消息均不能说成用户已收到通知。
|
|
||||||
4. 安排现场定位、止水与抢修。说明施工位置、隔离前置条件、抢修目标和需要回传的现场事实。存在管内污染侵入风险时,再安排适用的清洗、消毒或冲洗评估,不能把每次爆管都判断为污染事件。
|
|
||||||
5. 恢复供水前核验修复、压力、漏水复查及适用的水质复测结果,按获批方案恢复并通知用户;缺少现场反馈时保留“待执行/待核验”,不能宣称恢复完成。
|
|
||||||
|
|
||||||
## 报告与动作草稿
|
若任务来源带有 `replay_context`,这是 SCADA 历史回放中的疑似爆管演示。先读取任务摘要中的步长监测结论,再用 `query_scada_readings` 核对冻结批次;不得查询知识截止之后的数据。回放中的 `run_burst_location` 只接受 monitoring 模式,并要求显式提供本轮已处理范围内的异常窗和正常对照窗;Server 会从冻结清洗输出构造计算输入。缺少有依据的 `burst_leakage` 时保留“定位待补充”,不得用出厂流量增量、压力下降或默认值替代。回放结果保持 synthetic,不发布真实工单或设备动作。
|
||||||
|
|
||||||
报告说明当前判断、证据覆盖、隔离影响、通知安排、抢修及恢复条件。能够形成有边界的初步判断时,不因后续维修或复测尚未完成而把整个调查判为关键数据不足。确实缺少支撑当前判断的关键证据时,遵守 insufficient_data 契约,不强造结论或动作。
|
## 定位与影响分析
|
||||||
|
|
||||||
在已有证据足以支持具体处置建议时,先保存阶段报告,再通过现有动作提议入口保存待人工审批的具体草稿。现场核验、停水通知、抢修等人工执行事项使用关联预约工单(work_order_type=general);原爆管上报事件保持独立。草稿含位置、工作内容、先后依赖、执行前核验条件和回执要求,避免为每个图节点机械建单。没有实际客户通知工具时,将通知列为待执行的人工工单步骤。
|
根据现有线索自主选择必要步骤:
|
||||||
|
|
||||||
有真实且可控制的设备目标、明确命令及充分依据时,才提议设备命令。审批和执行由 Server 控制,调查不直接关闭阀门或发送用户通知。复用已存在的有效草稿,结合实际回执继续调查,不重复提议同一动作。正式报告保留所用流程名称和版本,便于追溯。
|
1. 核对已有管段或节点。只有地点描述时,先解析位置,再查询附近资产、属性和连接。地点明确后展示调查位置,候选资产取得后展示正在核查的管段或节点。空间邻近只支持候选,不能单独证明故障。
|
||||||
|
2. 核对 SCADA 设备与模型节点映射,用 `query_scada_readings` 或 `query_element_history` 查询事件前后及正常对照。先用少量对象和合适时间窗,确认返回时间实际覆盖事件;必要时缩窗或翻页。同步展示核查点位,并将取得的事件、对照压力或流量序列画成过程曲线,分清指标和单位;保留缺测、真实零值和覆盖限制。
|
||||||
|
3. 有异常观测时评估爆管定位输入。`run_burst_location` 必须有 `burst_leakage`(m³/s);先查询已有估计及方法依据,或结合边界流量、用水和储量变化判断能否估计。不能把单表增量、压力下降或任意数值直接当漏量。
|
||||||
|
4. 有依据的漏量估计或明确的情景假设可用于条件定位,须说明来源、方法和不确定性。`pressure_scada_ids` 使用模型节点 ID;监测模式读取 SCADA,模拟模式须引用已有分析运行。取得多个可比候选的评价值后,用柱状图展示实际排序及评价含义。压力点和历史数据满足工具要求时可选 `run_burst_detection`,但侦测不代替定位,评价值也不表述为概率。缺少定位参数时说明已查来源和影响,继续其他可独立完成的分析。
|
||||||
|
5. 对已确认或有依据的候选资产使用 `analyze_valve_isolation` 评估阀门隔离,无需等待现场确诊。逐候选比较;多个事故 ID 表示联合场景。保留可隔离性、必关阀、可选阀及受影响节点。结果取得后展示直接隔离与供水影响节点的覆盖示意,形成或调整关阀方案时展示对应阀门;区分两种影响及必关、可选阀。未知阀门状态不能写成可用,不可隔离或空结果也不能解释为零影响;已知阀门失效时评估替代边界。
|
||||||
|
6. 输入具备时用 `analyze_valve_closure` 分析关阀水力影响,核对阀门组合、起算时间、时长和输出对象。结果返回后,用曲线展示请求节点的压力、请求管段的流量;有同口径方案或基准时再画对比图,不自造前后变化。不同单位分图,标明局部模型结果。区分拓扑范围、水力影响和有依据的用户影响,不虚构户数,不用局部结果代表全网。
|
||||||
|
|
||||||
|
查询错误、权限拒绝或接口故障不是缺数证据。必要分析无法执行时,说明失败步骤和已经完成的部分,不借有限摘要宣布调查完成。
|
||||||
|
|
||||||
|
## 事件前后影响说明
|
||||||
|
|
||||||
|
每轮按实际证据说明以下阶段的影响,给出结果或具体限制;尚未发生的处置列为预测和待验证项:
|
||||||
|
|
||||||
|
1. **事件前与爆管后、隔离前**:核对正常基线和异常窗口,说明压力、流量变化及已覆盖的异常点位、资产和区域。候选管段及破口面积有依据时,可用 `run_burst_simulation` 补充情景水力影响,标明假设。漏量估计须说明边界流量、正常用水及储量变化等依据;有可靠漏量序列和时间覆盖时可累计漏水量,保留单位、方法和不确定性。定位工具的 `burst_leakage` 是输入,不能当成反演所得漏量。
|
||||||
|
2. **关阀隔离方案**:说明止漏目标、直接隔离节点和关阀后新增失去水源连接的节点,分别保留 `affected_nodes` 与 `supply_affected_nodes` 及其计数。结合关阀水力结果说明预计停水、低压或供水不足的范围和覆盖限制;有用户关联资料时说明受影响用户、重要设施及保障需求。拓扑失源、模型预测和现场实际停水分别标注,不把节点数当户数。
|
||||||
|
3. **实际隔离与恢复供水后**:后续轮次按实际关阀、抢修和恢复时间读取前后监测与现场反馈,对照预测核查止漏效果、实际影响范围和恢复情况,说明持续低压、异常流量、再次异常及尚待验证的影响。核对正常基线、观察窗口和判定依据,工单状态或单个正常读数不足以证明影响消除。
|
||||||
|
|
||||||
|
比较须核对对象、单位、时间对齐、模型版本、需水与水源边界、泵阀状态及计算设置;区分实测变化与模型方案差异。关阀工具不会自动继承爆管情景,条件不同的独立计算不能直接作为同一事故前后效果。可比时给出变化量和方案差异,缺少可比基准时说明当前结果及缺口,不补造改善比例。必要的紧急核验、通知和供水保障不等待可选的完整模拟。
|
||||||
|
|
||||||
|
## 处置与报告
|
||||||
|
|
||||||
|
报告分别给出位置候选及依据、隔离阀门及可用性、替代方案、事件前后分阶段的拓扑与水力影响、用户影响边界,以及处置和闭环缺口。影响说明保留时间窗、对象、证据、实测或预测属性及限制。只有初步结果时明确范围,不宣称已经定位、确诊或闭环。位置、候选、监测、影响和阀门方案按实际调查步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。覆盖面保留分组、缺失几何和示意边界,不能当作实际停水、水力影响或用户服务边界。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。
|
||||||
|
|
||||||
|
形成具体建议后,先保存阶段结论,再提议必要动作。人工核验、通知、定位隔离、抢修、适用的清洗复测和恢复回访使用关联的 `work_order_type=general` 草稿;原爆管事件保持独立。草稿写明位置、任务、依赖、执行前核验和回执要求。没有通知适配器时明确由人工实施。设备命令仅用于真实可控目标,审批和执行由 Server 控制。
|
||||||
|
|
||||||
|
抢修或复测尚未发生不阻止有边界的初步方案。现场阀门状态应写为执行前核验条件;工单创建、审批或派发均不证明现场完成。
|
||||||
|
|
||||||
|
## 闭环复核
|
||||||
|
|
||||||
|
后续轮次核对实际故障资产和原因、隔离实施、抢修记录、适用的清洗与水质复测、恢复供水及恢复后监测、用户通知回访、未决动作和遗留风险。每项标明已核验、待核验或不适用及依据。
|
||||||
|
|
||||||
|
证据齐全时报告具备结案条件,由来源系统的有权限人员确认关闭。原工单未结案时写明待确认,读取其新版本后才能认定闭环;误报结案也须有足够的排除证据和人工确认。
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: scada-anomaly
|
||||||
|
description: 调查 SCADA 回放中的异常信号,区分读数质量与运行异常,形成可追溯的演示报告。
|
||||||
|
metadata:
|
||||||
|
version: "1.0.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
# SCADA 回放异常调查
|
||||||
|
|
||||||
|
先读取任务上下文和来源中的 replay_context,核对规则、设备、单位、异常时刻及知识截止时间。数据来自已存 SCADA 的演示回放,不代表新采集的现场故障。
|
||||||
|
|
||||||
|
通过 query_scada_readings 读取事件前后本轮已处理的观测;查询结束时间不得晚于 knowledge_cutoff。结合 monitored_value 与 cleaned_value 判断数值变化,不将被可靠采集的低压或水质变化当作数据错误。缺测、无效值和未完成清洗应明确记录。
|
||||||
|
|
||||||
|
报告说明事件前后影响:在已开放且已处理的观测范围内,核对异常前参考、异常期间及已有恢复观测,列出压力、流量或水质变化、持续时间、涉及测点和已核实的资产关联。只有对象、单位、时间与运行条件可比时才给出变化量;说明参考窗口、证据和覆盖限制。尚无恢复观测时列为待验证,读数恢复不能证明现场修复或用户影响消除。停水范围、关阀影响及泄漏量需要相应拓扑、水力、流量平衡或现场依据;当前证据无法支持时明确待分析,不由单点低压推定停水区、受影响户数或漏量。
|
||||||
|
|
||||||
|
当前回放调查仅开放冻结 SCADA 证据、设备与模型目录;不支持原生计算或绕过回放上限的其他数据入口。回放前历史用于后台基线初始化,当前证据接口不返回该部分。需要更多历史或进一步计算时,报告具体缺口,不能读取未来或虚构分析结果。
|
||||||
|
|
||||||
|
报告列出观测事实、规则信号、质量问题、尚未确认的原因和建议核查。压力异常不等于已确认爆管,浓度变化不等于已确认污染。回放只生成演示分析与报告,不创建真实现场工单、不下发设备命令。
|
||||||
@@ -1,26 +1,41 @@
|
|||||||
---
|
---
|
||||||
name: water-quality-complaint
|
name: water-quality-complaint
|
||||||
description: 调查供水水质投诉,按证据和风险提出采样、隔离、用户通知、污染水处置及复测恢复方案。
|
description: 调查供水水质投诉,核查投诉与监测覆盖,评估候选原因、影响和采样处置建议。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.0"
|
version: "2.0.4"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 水质投诉处置
|
# 水质投诉处置
|
||||||
|
|
||||||
区分用户感官投诉、监测异常、已确认的水质问题与污染源假设。单个投诉不能直接证明供水管网污染;上游监测正常也不能排除末梢或二次供水问题。不预设异常必然来自二次供水,不把模拟扩散结果当成污染源反演。
|
目标是说明投诉和异常的实际覆盖、可能原因及依据、候选影响范围、采样或处置建议和后续复核条件。区分感官投诉、实测异常、污染源假设和已确认问题;不预设异常来自上游或二次供水。演练结果须标注 `synthetic`,不能证明生产风险。
|
||||||
|
|
||||||
## 调查与处置路线
|
## 接续与证据
|
||||||
|
|
||||||
1. 核对投诉时间、位置、异常描述与同类投诉,查询供水路径、相关水质和压力监测、适用的历史记录及现场采样反馈。分别说明实际覆盖的上游、末梢和二次供水范围,不混用模型基线与实测。
|
读取当前任务、投诉时刻、位置、现象、来源版本、已有报告、证据、采样、动作和处置反馈。新一轮先核对新增事实和上轮未决项,保留本流程名称和版本。
|
||||||
2. 评估是否需要隔离停水及饮用水风险告知。已确认或有充分证据支持局部供水风险时,提出与风险相称的隔离范围和保障措施,核对阀门与影响区域。尚不能确定风险时,先提出有依据的现场核验或采样安排,不因一条投诉直接建议全网停水,也不承诺水可安全饮用。
|
|
||||||
3. 将用户通知纳入处置方案:明确已知影响范围、需人工确认的用水提示、通知渠道、责任岗位和更新条件。没有实际发送工具时形成待审批的人工通知步骤,不把调查消息当成客户通知。
|
|
||||||
4. 对已支持的污染水风险,安排排查来源、控制污染进入并评估受影响管段的冲洗或排放。仅在来源参数、目标管段或冲洗节点等必需输入已知时使用扩散或冲洗分析;参数不足不虚构源浓度、排放量或节点。具体排水位置、承接条件及现场操作要求需核验后审批执行,不默认污染水可任意排放。
|
|
||||||
5. 清洗或冲洗后取得适用采样和复测反馈,再评估恢复供水与用户回访。现场处置、复测和恢复是下游执行步骤,其尚未发生不能单独作为拒绝初步风险分析的理由。
|
|
||||||
|
|
||||||
## 报告与动作草稿
|
## 风险与工程分析
|
||||||
|
|
||||||
报告列明当前风险判断、证据覆盖、隔离与通知条件、污染水处置路线和复测恢复条件。演练输入只支持标注为演练的判断。真正缺少支撑当前判断的关键证据时,遵守 insufficient_data 契约,不用通用处置模板强造已确认污染结论。
|
根据当前证据选择必要步骤:
|
||||||
|
|
||||||
已能形成有边界的处置建议时,保存阶段报告后,通过现有动作提议入口生成待人工审批的关联预约工单(work_order_type=general)。保留原投诉事件,草稿写明实际位置、采样或处置内容、责任要求、执行依赖、现场待核验条件和回执要求;可将通知、采样、冲洗和复测组织成合理的人工执行事项,不机械逐节点建单。
|
1. 核实投诉位置及相近时段的同类投诉,用 `query_peripheral_data` 按实际时间、位置和来源标识核对外围工单或观察。目录首屏不能证明没有其他投诉;未知点位先查目录,不猜编号或把演示点当真实资产。
|
||||||
|
2. 解析未知地址,查询附近资产、属性和连接,区分上游、管网、末梢、二次供水和用户端的实际覆盖。地点明确后展示投诉位置,取得关联资产后展示正在核查的管段或节点。拓扑连接不等于实际流向,方向判断需要同窗模型或监测依据。
|
||||||
|
3. 核对 SCADA 指标、单位和模型映射,用 `query_scada_readings` 或 `query_element_history` 查询投诉前后及对照,检查时间覆盖、在线状态、漂移、长期缺测和质量标识。同步展示核查点位,将实际取得的水质、压力等指标画成趋势或对比图,不同单位分图。缺测不是零值,上游正常不能排除末梢问题,压力正常不能证明水质合格。
|
||||||
|
4. 综合投诉分布、监测、供水关联和实际采样,列出候选原因、支持证据、反证和区分原因所需的核验。未证实污染源时保留假设,不把感官描述换算成浓度。水质限值只能引用已取得且适用的依据,不能自行承诺安全饮用。
|
||||||
|
5. 有合理目标管段或节点时,可用 `analyze_valve_isolation` 和 `analyze_valve_closure` 评估候选隔离及关阀影响,无需等待污染确诊,但不能据此直接执行停水。逐候选区分可隔离性、必关阀、可选阀、受影响节点和未知阀门状态。取得影响节点后展示覆盖示意,形成或调整关阀方案时展示对应阀门。拓扑隔离区、水力影响、污染范围和用户影响分别说明;节点覆盖面不能当作污染传播边界,无用户资料时不虚构户数。
|
||||||
|
6. 只有假设源节点、浓度及必要模型输入有依据时,才用 `run_contaminant_simulation` 正向比较传播;它不是源头反演,未模拟范围不能宣布安全。具备实际排放节点、流量和前置条件时才用 `analyze_flushing` 评估冲洗。取得模拟结果后,按实际返回的对象、时刻和指标展示浓度、压力或流量曲线;同口径的隔离、冲洗方案可画对比图,标明情景假设和局部覆盖。冲洗模拟不证明消毒合格,排放位置和承接条件仍需现场核验与审批。
|
||||||
|
|
||||||
设备命令只针对真实且可控制的目标,审批与执行由 Server 控制。草稿保存或工单派发不等于采样、通知、冲洗、复测完成。核对已有草稿和现场反馈后再继续,避免重复提议。正式报告保留所用流程名称和版本,便于追溯。
|
缺少源参数不阻止已有证据支持的采样、候选隔离或人工告知建议。查询错误、权限拒绝或接口故障属于执行异常,不能写成无数据或未见异常。
|
||||||
|
|
||||||
|
## 处置与报告
|
||||||
|
|
||||||
|
报告分别说明投诉与监测覆盖、候选原因和未排除路径、隔离及影响、通知保障、采样或污染水处置,以及复测与恢复条件。每项给出实际结果或经查询确认的限制;区分当前风险判断与尚未发生的未来处置。投诉位置、监测、候选隔离和影响按实际步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。
|
||||||
|
|
||||||
|
形成具体建议后,先保存阶段结论,再提议关联的 `work_order_type=general` 草稿。写明实际位置、采样或处置目标、时间要求、依赖、现场待核验条件和回执要求。通知、采样、清洗冲洗与复测按依赖合理组织,不机械逐节点建单,也不重复已有有效动作。没有客户通知适配器时明确由人工实施;调查消息不能当作用户通知。设备命令仅针对真实可控目标,审批与执行由 Server 控制。
|
||||||
|
|
||||||
|
采样、处置和回访尚未发生不阻止有边界的初步建议。工单创建、审批或派发不证明采样、通知、冲洗或复测完成。
|
||||||
|
|
||||||
|
## 闭环复核
|
||||||
|
|
||||||
|
后续轮次核对实际采样位置、时间、结果和判定依据,以及污染进入控制、隔离与通知、清洗冲洗、复测、恢复供水、用户回访、未决动作和遗留风险。不适用项说明依据,缺少反馈保留待核验。
|
||||||
|
|
||||||
|
证据齐全时报告具备结案条件,由来源系统的有权限人员确认关闭。核对来源新版本后才能认定闭环;误报或未发现异常也须有足够核查和人工确认,不能用有限正常数据、模拟结果或流程状态证明水质安全。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Independent sample API for synthetic work orders and monitoring data."""
|
"""Project-scoped peripheral work orders, monitoring history and explicit test data."""
|
||||||
from typing import get_args
|
from typing import get_args
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -34,8 +34,8 @@ async def store(request: Request, who: Identity = Depends(identity),
|
|||||||
|
|
||||||
|
|
||||||
def create_app(settings: SampleSettings | None = None) -> FastAPI:
|
def create_app(settings: SampleSettings | None = None) -> FastAPI:
|
||||||
app = FastAPI(title="TJWater 外围系统模拟接入", version="1.0.0",
|
app = FastAPI(title="TJWater 外围系统接入", version="1.0.0",
|
||||||
description="测试监测与核查反馈样板,不是正式工单平台或设备控制服务。")
|
description="按项目管理业务工单与反馈、读取监测历史;测试数据保留独立来源标记。")
|
||||||
app.state.settings = settings or SampleSettings()
|
app.state.settings = settings or SampleSettings()
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=app.state.settings.cors_origins,
|
app.add_middleware(CORSMiddleware, allow_origins=app.state.settings.cors_origins,
|
||||||
allow_credentials=False, allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
allow_credentials=False, allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||||
@@ -51,7 +51,7 @@ def create_app(settings: SampleSettings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"service": "peripheral-sample", "origin": "synthetic", "storage": "postgresql",
|
return {"service": "peripheral-sample", "origin": "system_data", "storage": "postgresql",
|
||||||
"status": "up", "storage_verified": False}
|
"status": "up", "storage_verified": False}
|
||||||
|
|
||||||
prefix = "/api/sample/v1"
|
prefix = "/api/sample/v1"
|
||||||
@@ -59,7 +59,7 @@ def create_app(settings: SampleSettings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get(prefix + "/context", response_model=Context, responses=errors)
|
@app.get(prefix + "/context", response_model=Context, responses=errors)
|
||||||
def context(request: Request, who: Identity = Depends(identity), db=Depends(store)):
|
def context(request: Request, who: Identity = Depends(identity), db=Depends(store)):
|
||||||
return {"protocol_version": "tjwater-peripheral-sample@1", "origin": "synthetic",
|
return {"protocol_version": "tjwater-peripheral-sample@1", "origin": "system_data",
|
||||||
"project_id": who.project_id, "user_id": who.user_id, "can_write": who.can_write,
|
"project_id": who.project_id, "user_id": who.user_id, "can_write": who.can_write,
|
||||||
"scenarios": list(get_args(Scenario)) if request.app.state.settings.enable_faults else ["normal"],
|
"scenarios": list(get_args(Scenario)) if request.app.state.settings.enable_faults else ["normal"],
|
||||||
"state_transitions": TRANSITIONS, "managed_resources": db.managed_resource_capabilities()}
|
"state_transitions": TRANSITIONS, "managed_resources": db.managed_resource_capabilities()}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user