feat: add project-scoped operations
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent d320972d57
commit 1d061f6099
73 changed files with 4384 additions and 228 deletions
+78 -70
View File
@@ -1,127 +1,135 @@
# TJWater Server 环境变量配置模板
# 复制此文件为 .env 并填写实际值
# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。
ENVIRONMENT="production"
NETWORK_NAME="tjwater"
# ============================================
# 敏感配置加密 (必填)
# ============================================
# TJWater Server 环境变量模板
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
# 生产配置由部署主机的受控 backend.env 注入,不要把完整配置保存为仓库密钥。
# Fernet 格式,生产环境必须替换为独立密钥
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置
# 基础运行
PROJECT_NAME="TJWater Server"
ENVIRONMENT="production"
API_V1_STR="/api/v1"
NETWORK_NAME="tjwater"
# Fernet 格式;用于加密项目数据库 DSN、GeoServer 管理密码等敏感配置。
# 生成方式:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key"
# ============================================
# 数据库配置 (PostgreSQL)
# ============================================
# 业务数据库(PostgreSQL)
DB_NAME="tjwater"
DB_HOST="localhost"
DB_PORT="5432"
DB_USER="tjwater"
DB_PASSWORD="password"
# ============================================
# 数据库配置 (TimescaleDB)
# ============================================
# 时序数据库(TimescaleDB)
TIMESCALEDB_DB_NAME="tjwater"
TIMESCALEDB_DB_HOST="localhost"
TIMESCALEDB_DB_PORT="5433"
TIMESCALEDB_DB_USER="tjwater"
TIMESCALEDB_DB_PASSWORD="password"
# 实时快照替换事务的解压预算;仅事务内生效,禁止0(无限制)。
# 实时快照替换事务的解压预算;仅事务内生效,禁止设为 0(无限制)。
TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES=1000000
# ============================================
# 元数据数据库配置 (Metadata DB)
# ============================================
# 元数据数据库
METADATA_DB_NAME="system_hub"
METADATA_DB_HOST="localhost"
METADATA_DB_PORT="5432"
METADATA_DB_USER="tjwater"
METADATA_DB_PASSWORD="password"
METADATA_DB_POOL_SIZE=5
METADATA_DB_MAX_OVERFLOW=10
# Per-project synchronous connection pools
PROJECT_PG_CACHE_SIZE="16"
PROJECT_TS_CACHE_SIZE="16"
PROJECT_PG_POOL_MIN_SIZE="0"
PROJECT_PG_POOL_SIZE="4"
PROJECT_PG_MAX_OVERFLOW="2"
PROJECT_TS_POOL_MIN_SIZE="0"
PROJECT_TS_POOL_MAX_SIZE="4"
# 项目数据库连接池、缓存与模板
PROJECT_PG_CACHE_SIZE=16
PROJECT_TS_CACHE_SIZE=16
PROJECT_PG_POOL_MIN_SIZE=0
PROJECT_PG_POOL_SIZE=4
PROJECT_PG_MAX_OVERFLOW=2
PROJECT_TS_POOL_MIN_SIZE=0
PROJECT_TS_POOL_MAX_SIZE=4
WNDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_schema_template"
TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_timescale_template"
WNDB_TEMP_DB_MAX_COUNT="8"
WNDB_TEMP_DB_MAX_COUNT=8
# ============================================
# GeoServer 项目供应
# ============================================
GEOSERVER_URL="http://localhost:8080/geoserver"
GEOSERVER_USERNAME="admin"
GEOSERVER_PASSWORD="password"
# 留空时分别复用 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD。
# 留空时分别复用 DB_HOST、DB_PORT、DB_USER、DB_PASSWORD。
# GeoServer 在容器内运行时,GEOSERVER_DB_HOST 通常应填写数据库服务名。
GEOSERVER_DB_HOST=""
GEOSERVER_DB_PORT=""
GEOSERVER_DB_USER=""
GEOSERVER_DB_PASSWORD=""
# 30 天客户端缓存,单位为秒。
GEOSERVER_CLIENT_CACHE_SECONDS="2592000"
# 客户端缓存时间,单位为秒;默认 30 天。
GEOSERVER_CLIENT_CACHE_SECONDS=2592000
# ============================================
# Keycloak JWT (可选)
# ============================================
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
KEYCLOAK_ALGORITHM=RS256
# Keycloak JWT 校验
# 未启用认证时可留空;生产环境应注入完整公钥和预期 audience。
KEYCLOAK_PUBLIC_KEY=""
KEYCLOAK_ALGORITHM="RS256"
KEYCLOAK_AUDIENCE="account"
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
# ============================================
# 工作台事件同步
# ============================================
# 工作台事件与跨域
# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。
CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173"
CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173"
WORKBENCH_EVENTS_ENABLED=true
WORKBENCH_EVENT_RETENTION_DAYS=7
# 每个项目启动一个 worker 实例。长期运行优先使用 OAuth 客户端凭据。
# 天地图服务
# 地理编码、逆地理编码和地名搜索共用同一令牌。
TIANDITU_TOKEN=""
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
TIANDITU_SEARCH_URL="https://api.tianditu.gov.cn/v2/search"
TIANDITU_SEARCH_TIMEOUT_SECONDS=15
# 调查任务 worker(独立进程)
# 单项目填写 PROJECT_ID;多项目填写逗号分隔的 PROJECT_IDS,后者优先。
# worker 与外围工单同步器共用项目范围,服务身份必须拥有每个项目的权限。
OPERATIONAL_WORKER_PROJECT_ID=""
OPERATIONAL_WORKER_PROJECT_IDS=""
OPERATIONAL_WORKER_SERVER_URL="http://127.0.0.1:8000"
OPERATIONAL_WORKER_AGENT_URL="http://127.0.0.1:8787"
OPERATIONAL_WORKER_RECONCILE_SECONDS=30
OPERATIONAL_WORKER_MAX_CONCURRENCY=4
# 可选 ISO 8601 时间下界,必须包含时区,例如 2026-10-01T00:00:00+08:00。
OPERATIONAL_WORKER_TASK_CREATED_FROM=""
# 长期运行使用 OAuth 客户端凭据;固定 Bearer 仅用于受控开发或短期运行。
OPERATIONAL_WORKER_TOKEN_URL=""
# 仅 ENVIRONMENT=development 时覆盖 TOKEN_URL,其他环境忽略。
OPERATIONAL_WORKER_DEV_TOKEN_URL=""
OPERATIONAL_WORKER_CLIENT_ID=""
OPERATIONAL_WORKER_CLIENT_SECRET=""
OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS=300
OPERATIONAL_WORKER_BEARER=""
# 调查数据适配器
OPERATIONAL_DATA_SERVER_URL="http://127.0.0.1:8000"
OPERATIONAL_DATA_PERIPHERAL_URL=""
OPERATIONAL_DATA_TIMEOUT_SECONDS=15
# 仅供 Server 在已批准的兼容委托路径中使用,不传给 Agent。
OPERATIONAL_DATA_DELEGATION_BEARER=""
# 调查动作适配器
OPERATIONAL_ACTION_PERIPHERAL_URL=""
OPERATIONAL_ACTION_COMMAND_GATEWAY_URL=""
OPERATIONAL_ACTION_TIMEOUT_SECONDS=10
# ============================================
# Tianditu shared API key and service URLs
# ============================================
TIANDITU_TOKEN="your-tianditu-token"
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
# 地名搜索与地理编码共用 TIANDITU_TOKEN;也可沿用 TJWATER_TIANDITU_TOKEN。
TIANDITU_SEARCH_URL="https://api.tianditu.gov.cn/v2/search"
TIANDITU_SEARCH_TIMEOUT_SECONDS=15
# MCP query resource, off until OAuth and every project BizDB are provisioned.
# 仅 ENVIRONMENT=development 时覆盖 worker 取令牌地址;留空沿用 OPERATIONAL_WORKER_TOKEN_URL。
OPERATIONAL_WORKER_DEV_TOKEN_URL=
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
TJWATER_MCP_ENABLED=false
TJWATER_MCP_ISSUER_URI=
# 仅 ENVIRONMENT=development,可填写与 issuer 完全相同的内网 HTTP IPv4 issuer。
# 正式环境必须留空;此例外不放行远程 HTTP MCP resource。
TJWATER_MCP_DEV_HTTP_ISSUER_URI=
TJWATER_MCP_RESOURCE_URI=
TJWATER_MCP_AUDIENCE=tjwater-mcp
TJWATER_MCP_HOST_CLIENT_ID=tjwater-agent-mcp
TJWATER_MCP_ALLOWED_HOSTS=["localhost:*","127.0.0.1:*"]
TJWATER_MCP_ALLOWED_ORIGINS=[]
# 普通查询预算;计算工具由 Agent 用 timeout_seconds 按次指定(默认120秒)。
TJWATER_MCP_ISSUER_URI=""
# 仅 ENVIRONMENT=development:可与 ISSUER_URI 同值并指向内网 HTTP IPv4 issuer。
# 正式环境必须留空;该例外不允许远程 HTTP MCP resource。
TJWATER_MCP_DEV_HTTP_ISSUER_URI=""
TJWATER_MCP_RESOURCE_URI=""
TJWATER_MCP_AUDIENCE="tjwater-mcp"
TJWATER_MCP_HOST_CLIENT_ID="tjwater-agent-mcp"
# 列表使用 JSON 格式。
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
TJWATER_MCP_ALLOWED_ORIGINS='[]'
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
# 普通查询预算;计算工具由 Agent 通过 timeout_seconds 按次指定。
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30
# 计算等待上限,默认1小时;管理员可配置120至86400秒,实际上限通过 tools/list 发布。
# 计算等待上限为 120–86400 秒,实际上限通过 tools/list 发布。
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
+2 -2
View File
@@ -10,11 +10,11 @@ jobs:
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@68c8a9855391baa31d31523674f5812cd24ec604
with:
image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-backend
dockerfile: Dockerfile
build_context: .
test_target: test
deploy_service: backend
deploy_service: next-backend
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
+2
View File
@@ -48,6 +48,7 @@ def _project_response(project: models.Project) -> AdminProjectResponse:
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
@@ -274,6 +275,7 @@ async def create_admin_project(
description=payload.description,
gs_workspace=payload.gs_workspace,
map_extent=payload.map_extent,
map_config=payload.map_config.model_dump(exclude_unset=True),
status=payload.status,
creator_user_id=current_user.id,
)
+1
View File
@@ -44,6 +44,7 @@ async def get_project_metadata(
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
project_role=ctx.project_role,
)
+1
View File
@@ -319,6 +319,7 @@ async def provision_project(
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=getattr(project, "map_config", {}) or {},
status=project.status,
created_at=project.created_at,
updated_at=project.updated_at,
+47 -6
View File
@@ -1,19 +1,60 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import ValidationError
from app.domain.schemas.place_search import PlaceSearchRequest, PlaceSearchResponse
from app.auth.project_dependencies import ProjectContext, get_metadata_repository, get_project_context
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.domain.schemas.project_map_config import PlaceSearchConfig, ProjectMapConfig
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchRequest, PlaceSearchResponse, ReverseGeocodingRequest
from app.services.place_search import PlaceSearchError, search_tianditu_places
from app.services.reverse_geocoding import reverse_geocode_tianditu
router = APIRouter()
async def get_place_search_config(
ctx: ProjectContext = Depends(get_project_context),
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
) -> PlaceSearchConfig:
project = await metadata_repo.get_project_by_id(ctx.project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
try:
config = ProjectMapConfig.model_validate(project.map_config or {}).place_search
except ValidationError:
raise HTTPException(status_code=503, detail="当前项目地名搜索配置无效") from None
if config is None or not config.enabled:
raise HTTPException(status_code=503, detail="当前项目暂未配置地名搜索")
return config
@router.post(
"/place-search-requests",
response_model=PlaceSearchResponse,
summary="Search Fengxian places",
description="调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。",
summary="Search project places",
description="调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。",
)
async def tianditu_place_search(request: PlaceSearchRequest) -> PlaceSearchResponse:
async def tianditu_place_search(
request: PlaceSearchRequest,
config: PlaceSearchConfig = Depends(get_place_search_config),
) -> PlaceSearchResponse:
try:
return await search_tianditu_places(request)
return await search_tianditu_places(request, config=config)
except PlaceSearchError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@router.post(
"/reverse-geocoding-requests",
response_model=PlaceSearchItem,
summary="Reverse geocode a project map point",
description="查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。",
)
async def tianditu_reverse_geocode(
request: ReverseGeocodingRequest,
config: PlaceSearchConfig = Depends(get_place_search_config),
) -> PlaceSearchItem:
try:
return await reverse_geocode_tianditu(request, config=config)
except PlaceSearchError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+1
View File
@@ -34,6 +34,7 @@ async def get_project_info_endpoint(
description=project_detail.description,
gs_workspace=project_detail.gs_workspace,
map_extent=project_detail.map_extent,
map_config=getattr(project_detail, "map_config", {}) or {},
status=project_detail.status,
project_role="viewer", # Default role for public access
)
+49
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from psycopg import AsyncConnection
from pydantic import BaseModel, Field, field_validator
from app.domain.schemas.scada import ScadaLatestReadingsResponse
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
from .dependencies import get_postgres_connection, get_timescale_connection
@@ -96,6 +97,54 @@ async def get_scada_by_ids_time_range(
)
@router.get(
"/timeseries/scada-readings/latest",
response_model=ScadaLatestReadingsResponse,
summary="获取SCADA设备最新数据",
)
async def get_latest_scada_readings(
limit: int = Query(100, ge=1, le=500, description="返回设备数上限"),
offset: int = Query(0, ge=0, description="设备目录偏移量"),
conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
) -> ScadaLatestReadingsResponse:
"""Read one latest persisted row per project device without applying a time window."""
devices = await ScadaInfoRepository.get_scadas(postgres_conn)
selected_devices = devices[offset : offset + limit]
latest_by_device = await ScadaRepository.get_latest_scada_readings(
conn,
[device["device_id"] for device in selected_devices],
)
items = []
for device in selected_devices:
reading = latest_by_device.get(device["device_id"])
items.append(
{
"device_id": device["device_id"],
"device_type": device["device_type"],
"measurement_unit": device["measurement_unit"],
"transmission_mode": device["transmission_mode"],
"transmission_frequency": device["transmission_frequency"],
"observed_at": reading["time"] if reading else None,
"monitored_value": reading["monitored_value"] if reading else None,
"cleaned_value": reading["cleaned_value"] if reading else None,
}
)
observed_times = [item["observed_at"] for item in items if item["observed_at"] is not None]
return ScadaLatestReadingsResponse.model_validate(
{
"items": items,
"total": len(devices),
"limit": limit,
"offset": offset,
"observed_count": len(observed_times),
"latest_observed_at": max(observed_times) if observed_times else None,
}
)
@router.get(
"/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据"
)
+5 -1
View File
@@ -4,6 +4,8 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.schemas.project_map_config import ProjectMapConfig, ProjectMapConfigUpdate
BusinessRole = Literal["admin", "service", "user"]
ProjectRole = Literal["member", "viewer"]
@@ -75,10 +77,11 @@ class AdminProjectCreateRequest(BaseModel):
description: str | None = None
gs_workspace: str = Field(..., min_length=1, max_length=100)
map_extent: dict | None = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: ProjectStatus = "active"
class AdminProjectUpdateRequest(BaseModel):
class AdminProjectUpdateRequest(ProjectMapConfigUpdate):
name: str | None = Field(default=None, min_length=1, max_length=100)
code: str | None = Field(default=None, min_length=1, max_length=50)
description: str | None = None
@@ -94,6 +97,7 @@ class AdminProjectResponse(BaseModel):
description: str | None = None
gs_workspace: str
map_extent: dict | None = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: str
created_at: datetime
updated_at: datetime
+99
View File
@@ -0,0 +1,99 @@
"""Validated, local presentation blocks. Component props come from the frontend catalog."""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Annotated, Any, Literal
from jsonschema import Draft7Validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
_catalog = json.loads(
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
)
_validators = {name: Draft7Validator(schema) for name, schema in _catalog["props"].items()}
Key = Annotated[str, Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")]
class UiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class AnswerUiElement(UiModel):
type: str
props: dict[str, Any]
children: list[Key] = Field(max_length=48)
class AnswerUiSpec(UiModel):
root: Key
elements: dict[Key, AnswerUiElement]
class AnswerUiBlock(UiModel):
version: Literal["answer-ui@1"]
block_id: Key
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
spec: AnswerUiSpec
@model_validator(mode="after")
def validate_components(self):
spec = self.spec
if not 1 <= len(spec.elements) <= 48:
raise ValueError("UI requires 1 to 48 elements")
# Match the existing JavaScript serialized character limit, including UTF-16.
serialized = json.dumps(self.model_dump(), ensure_ascii=False, separators=(",", ":"))
if len(serialized.encode("utf-16-le")) // 2 > 100000:
raise ValueError("UI block is too large")
for element in spec.elements.values():
validator = _validators.get(element.type)
if validator is None or not validator.is_valid(element.props):
raise ValueError("Unknown UI component or invalid properties")
if element.children and element.type not in {"Stack", "Section"}:
raise ValueError("Leaf components cannot contain children")
props = element.props
if element.type == "Chart":
if any(len(series["data"]) != len(props["x_data"]) for series in props["series"]):
raise ValueError("Chart series must match the axis")
if any(not math.isfinite(value) for series in props["series"] for value in series["data"]):
raise ValueError("Chart values must be finite")
if element.type == "Table" and any(len(row) != len(props["columns"]) for row in props["rows"]):
raise ValueError("Table row width must match its columns")
if element.type == "FollowUpForm":
names = [field["name"] for field in props["fields"]]
if len(names) != len(set(names)):
raise ValueError("Form field names must be unique")
visited: set[str] = set()
def visit(key: str, depth: int):
if depth > 12 or key in visited or key not in spec.elements:
raise ValueError("UI must be a bounded tree without duplicate or missing references")
visited.add(key)
for child in spec.elements[key].children:
visit(child, depth + 1)
visit(spec.root, 0)
if len(visited) != len(spec.elements):
raise ValueError("UI contains unreachable elements")
return self
class ReportUiBlock(UiModel):
block: AnswerUiBlock
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
ids = [item.block.block_id for item in blocks]
if len(ids) != len(set(ids)):
raise ValueError("Report UI block ids must be unique")
serialized = json.dumps([item.model_dump() for item in blocks], ensure_ascii=False, separators=(",", ":"))
if len(serialized.encode("utf-8")) > 400000:
raise ValueError("Report UI exceeds 400 KB")
for item in blocks:
if not set(item.evidence_refs) <= set(evidence_refs):
raise ValueError("UI evidence must be included in report evidence")
for element in item.block.spec.elements.values():
if element.type == "Evidence" and element.props.get("evidence_id") not in item.evidence_refs:
raise ValueError("UI evidence component must reference its block evidence")
+4 -1
View File
@@ -1,7 +1,9 @@
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.domain.schemas.project_map_config import ProjectMapConfig
class ProjectMetaResponse(BaseModel):
@@ -11,6 +13,7 @@ class ProjectMetaResponse(BaseModel):
description: Optional[str] = None
gs_workspace: str
map_extent: Optional[dict] = None
map_config: ProjectMapConfig = Field(default_factory=ProjectMapConfig)
status: str
project_role: str
+33
View File
@@ -16,6 +16,39 @@ TaskDataSource = Literal[
McpSnapshotSource = Literal[
'get_mutation_status',
'query_nearby_elements',
'list_regions',
'get_region',
'get_region_nodes',
'get_node_connections',
'query_network_extent',
'list_major_nodes',
'get_model_configuration',
'list_model_configurations',
'search_project_places',
'reverse_geocode',
'get_project_info',
'export_project_model',
'get_sensor_placement_candidate',
'export_sensor_placement',
'list_task_schedules',
'get_task_run_history',
'calculate_demand_allocation',
'run_burst_simulation',
'edit_model_element',
'edit_model_configuration',
'clean_scada_readings',
'write_scada_readings',
'delete_scada_readings',
'save_simulation_scenario',
'create_sensor_placement',
'update_sensor_placement',
'create_operational_task',
'create_task_schedule',
'update_task_schedule',
'retry_operational_task',
'cancel_task_run',
'run_simulation',
'run_contaminant_simulation',
'analyze_valve_closure',
+3
View File
@@ -6,6 +6,7 @@ from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, JsonValue, model_validator
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
@@ -253,9 +254,11 @@ class InvestigationReport(StrictModel):
stop_reason: NonEmptyStr | None = None
data_gaps: list[InvestigationDataGap] | None = None
diagram: InvestigationDiagram | None = None
ui_blocks: list["ReportUiBlock"] = Field(default_factory=list, max_length=8)
@model_validator(mode="after")
def validate_result(self):
validate_report_ui_blocks(self.ui_blocks, self.evidence_refs)
if self.outcome is None:
if self.stage is not None or self.stop_reason is not None or self.data_gaps is not None:
raise ValueError("structured report fields must be supplied together")
+9 -4
View File
@@ -1,5 +1,3 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
MAX_PLACE_SEARCH_RESULTS = 10
@@ -11,6 +9,13 @@ class PlaceSearchRequest(BaseModel):
keyword: str = Field(..., min_length=1, max_length=120)
class ReverseGeocodingRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
longitude: float = Field(ge=-180, le=180, allow_inf_nan=False)
latitude: float = Field(ge=-90, le=90, allow_inf_nan=False)
class PlaceSearchItem(BaseModel):
id: str = Field(..., min_length=1)
name: str = Field(..., min_length=1)
@@ -18,11 +23,11 @@ class PlaceSearchItem(BaseModel):
longitude: float = Field(..., ge=-180, le=180, allow_inf_nan=False)
latitude: float = Field(..., ge=-90, le=90, allow_inf_nan=False)
category: str | None = None
district: Literal["奉贤区"] = "奉贤区"
district: str = Field(min_length=1)
class PlaceSearchResponse(BaseModel):
items: list[PlaceSearchItem] = Field(..., max_length=MAX_PLACE_SEARCH_RESULTS)
total: int = Field(..., ge=0, le=MAX_PLACE_SEARCH_RESULTS)
truncated: bool = False
region: Literal["奉贤区"] = "奉贤区"
region: str = Field(min_length=1)
+29
View File
@@ -0,0 +1,29 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class PlaceSearchConfig(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
enabled: bool = False
provider: Literal["tianditu"] = "tianditu"
admin_code: str = Field(pattern=r"^(?:156)?[0-9]{6}$")
region: str = Field(min_length=1, max_length=100)
class ProjectMapConfig(BaseModel):
# Preserve existing map options such as legend when editing search settings.
model_config = ConfigDict(extra="allow")
place_search: PlaceSearchConfig | None = None
class ProjectMapConfigUpdate(BaseModel):
map_config: ProjectMapConfig | None = None
@model_validator(mode="after")
def reject_null_map_config(self):
if "map_config" in self.model_fields_set and self.map_config is None:
raise ValueError("map_config must be an object")
return self
+31 -1
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ScadaDeviceResponse(BaseModel):
@@ -17,3 +19,31 @@ class ScadaDeviceResponse(BaseModel):
y: float | None = None
longitude: float | None = None
latitude: float | None = None
class ScadaLatestReadingResponse(BaseModel):
"""Latest persisted observation for one project SCADA device."""
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
device_id: str
device_type: str
measurement_unit: str
transmission_mode: str
transmission_frequency: str
observed_at: datetime | None = None
monitored_value: float | None = None
cleaned_value: float | None = None
class ScadaLatestReadingsResponse(BaseModel):
"""Bounded device page with each device's latest persisted observation."""
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
items: list[ScadaLatestReadingResponse]
total: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
offset: int = Field(ge=0)
observed_count: int = Field(ge=0)
latest_observed_at: datetime | None = None
+1
View File
@@ -43,6 +43,7 @@ class Project(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
map_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
status: Mapped[str] = mapped_column(String(20), default="active")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional, List
from uuid import UUID, uuid4
@@ -66,6 +66,7 @@ class ProjectDetail:
gs_workspace: str
map_extent: Optional[dict]
status: str
map_config: dict = field(default_factory=dict)
@dataclass(frozen=True)
@@ -221,6 +222,7 @@ class MetadataRepository:
map_extent: dict | None,
status: str,
creator_user_id: UUID | None = None,
map_config: dict | None = None,
) -> models.Project:
project = models.Project(
id=uuid4(),
@@ -230,6 +232,7 @@ class MetadataRepository:
gs_workspace=gs_workspace,
map_extent=map_extent,
status=status,
map_config=map_config or {},
created_at=_utcnow(),
updated_at=_utcnow(),
)
@@ -272,6 +275,7 @@ class MetadataRepository:
gs_workspace=gs_workspace,
map_extent=map_extent,
status="active",
map_config={},
created_at=_utcnow(),
updated_at=_utcnow(),
)
@@ -327,6 +331,8 @@ class MetadataRepository:
):
if field in updates:
setattr(project, field, updates[field])
if "map_config" in updates:
project.map_config = {**(project.map_config or {}), **updates["map_config"]}
project.updated_at = _utcnow()
await self.session.commit()
await self.session.refresh(project)
@@ -343,6 +349,7 @@ class MetadataRepository:
description=project.description,
gs_workspace=project.gs_workspace,
map_extent=project.map_extent,
map_config=project.map_config or {},
status=project.status,
)
+7
View File
@@ -89,3 +89,10 @@ class McpQueryRepository:
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))",
(f"mcp:{self.project_id}:{scope_key}:{call_id}",),
)
async def finish_receipt(self, scope_key: str, call_id: str, result: dict):
async with self.conn.cursor() as cur:
await cur.execute(
"UPDATE workbench.mcp_query_receipts SET result=%s WHERE project_id=%s AND scope_key=%s AND call_id=%s",
(Jsonb(result), self.project_id, scope_key, call_id),
)
+8 -9
View File
@@ -12,20 +12,19 @@ async def lock_event_transaction(connection: AsyncConnection) -> None:
PostgreSQL sequence allocation is not commit order. All producers must use
this same transaction lock so a committed higher cursor cannot hide an
earlier, still-uncommitted event. Other BizDBs remain rollout opt-out.
earlier, still-uncommitted event. The lock is local to the connected BizDB
and creates no schema or data in projects without an event log.
"""
if getattr(getattr(connection, "info", None), "dbname", None) == "tjwater_v2":
await connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
await connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
def lock_event_transaction_sync(connection: Connection) -> None:
"""Synchronous counterpart using the identical PostgreSQL lock key."""
if getattr(getattr(connection, "info", None), "dbname", None) == "tjwater_v2":
connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,)
)
class WorkbenchEventRepository:
@@ -46,6 +46,25 @@ class ScadaRepository:
)
return await cur.fetchall()
@staticmethod
async def get_latest_scada_readings(
conn: AsyncConnection,
device_ids: List[str],
) -> dict[str, dict[str, Any]]:
"""Return at most one latest row for each requested canonical device ID."""
if not device_ids:
return {}
async with conn.cursor() as cur:
await cur.execute(
f"SELECT DISTINCT ON (device_id) {ScadaRepository.RESULT_COLUMNS} "
"FROM scada.measurements WHERE device_id = ANY(%s) "
"ORDER BY device_id, time DESC",
(device_ids,),
)
rows = await cur.fetchall()
return {str(row["device_id"]): dict(row) for row in rows}
@staticmethod
def get_scada_by_ids_time_range_sync(
conn: Connection,
+1 -1
View File
@@ -84,7 +84,7 @@ class Output:
def units(self) -> dict[str, str]:
f_us = ["CFS", "GPM", "MGD", "IMGD", "AFD", "LPS", "LPM", "MLD", "CMH", "CMD"]
p_us = ["PSI", "MTR", "KPA"]
p_us = ["PSI", "KPA", "MTR"]
q_us = ["NONE", "MGL", "UGL", "HOURS", "PRCNT"]
f, p, q = ctypes.c_int(1), ctypes.c_int(2), ctypes.c_int(3)
f_u, p_u, q_u = ctypes.c_int(), ctypes.c_int(), ctypes.c_int()
+29 -2
View File
@@ -4,9 +4,9 @@ import asyncio
from threading import BoundedSemaphore
from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel
from app.mcp.scenario_service import SCENARIO_TOOLS, calculate_scenario
from app.mcp.errors import CalculationCapacityExceeded
# These services synchronously read SCADA/model series or write online results.
# Topology-only isolation and model-based sensor placement need only BizDB.
@@ -21,10 +21,25 @@ _slots = BoundedSemaphore(2)
def _calculate(name: str, parameters: dict[str, Any], network: str, username: str) -> dict[str, Any]:
if not _slots.acquire(blocking=False):
raise HTTPException(503, "Calculation capacity is occupied")
raise CalculationCapacityExceeded()
try:
if name in SCENARIO_TOOLS:
return calculate_scenario(name, parameters, network=network, username=username)
if name == "calculate_demand_allocation":
from app.services import demand_allocation
target = parameters.pop("target")
limit, offset = parameters.pop("limit"), parameters.pop("offset")
demand = parameters["demand"]
if target == "nodes":
result = demand_allocation.calculate_demand_to_nodes(network, demand, parameters["node_ids"])
elif target == "region":
result = demand_allocation.calculate_demand_to_region(network, demand, parameters["region_id"])
else:
result = demand_allocation.calculate_demand_to_network(network, demand)
keys = sorted(result)
return {"items": [{"node_id": key, "demand": result[key]} for key in keys[offset:offset + limit]],
"total": len(keys), "limit": limit, "offset": offset,
"has_more": offset + limit < len(keys), "persisted": False}
if name == "optimize_sensor_placement":
from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity
parameters = dict(parameters)
@@ -67,3 +82,15 @@ async def calculate(name: str, args: BaseModel, *, network: str, username: str)
return await asyncio.to_thread(
_calculate, name, args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), network, username,
)
async def bounded_calculation(function, *args, **kwargs):
"""Use the same native-work capacity for persistent calculation tools."""
def run():
if not _slots.acquire(blocking=False):
raise CalculationCapacityExceeded()
try:
return function(*args, **kwargs)
finally:
_slots.release()
return await asyncio.to_thread(run)
+16 -4
View File
@@ -32,10 +32,12 @@ from app.mcp.catalog import TOOLS
from app.mcp.config import McpSettings
from app.mcp.contexts import resolve_context, verify_task
from app.mcp.models import QueryResult
from app.mcp.errors import CalculationCapacityExceeded
from app.mcp.scope import project_scope
from app.mcp.query_service import execute_query, task_service, authorize_query
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.domain_errors import DomainError
logger = logging.getLogger(__name__)
@@ -200,7 +202,7 @@ class McpQueryApplication:
if tool is None:
return None
schema = tool.model.model_json_schema()
if tool.computation:
if tool.computation or tool.mutating:
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
return schema
@@ -290,9 +292,9 @@ class McpQueryApplication:
"INVALID_QUERY", "Query parameters do not match the tool schema"
)
timeout_seconds = (
args.timeout_seconds if tool.computation else self.settings.query_timeout_seconds
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_timeout_seconds
)
if tool.computation and timeout_seconds > self.settings.max_calculation_timeout_seconds:
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_calculation_timeout_seconds:
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
request = ctx.request
project = request.headers.get("x-project-id", "") if request else ""
@@ -380,12 +382,16 @@ class McpQueryApplication:
result.source_ref = "mcp:" + str(
uuid5(NAMESPACE_URL, scope_key + ":" + call_id)
)
snapshot_data = result.model_dump(mode="json")
from app.mcp.extended_service import ARTIFACT_TOOLS
if name in ARTIFACT_TOOLS:
snapshot_data["data"]["file"].pop("data", None)
snapshot = TaskDataSnapshot(
source=name,
source_ref=result.source_ref,
queried_at=result.queried_at,
origin=result.origin,
data=result.model_dump(mode="json"),
data=snapshot_data,
truncated=result.truncated,
)
evidence, _ = await task_service(
@@ -400,8 +406,10 @@ class McpQueryApplication:
)
output = result.model_dump(mode="json")
content = []
from app.mcp.extended_service import ARTIFACT_TOOLS
if (
not include_full
and name not in ARTIFACT_TOOLS
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
):
evidence_id = result.evidence_id or (
@@ -440,6 +448,10 @@ class McpQueryApplication:
),
)
return types.CallToolResult(content=content, structuredContent=output)
except DomainError as exc:
return tool_error(exc.code, exc.message)
except CalculationCapacityExceeded as exc:
return tool_error(exc.code, str(exc.detail))
except HTTPException as exc:
codes = {
401: "AUTH_REQUIRED",
+40 -2
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from pydantic import BaseModel
from mcp import types
from app.mcp import models as m
from app.mcp import extended_models as e
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
@@ -17,9 +18,12 @@ class QueryTool:
semantics: str
task_only: bool = False
computation: bool = False
mutating: bool = False
def protocol_tool(self) -> types.Tool:
if self.name == "run_simulation":
if self.mutating:
suffix = "。默认dry_run只预览;执行必须携带预览摘要、相同request_id并通过宿主原生许可。仅项目普通聊天可用,后台调查和reader禁止;失败或超时先核实状态,不自动重试。"
elif self.name == "run_simulation":
suffix = "。任务调用复用证据留存。"
elif self.computation:
suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。"
@@ -36,7 +40,7 @@ class QueryTool:
inputSchema=self.model.model_json_schema(),
outputSchema=m.QueryResult.model_json_schema(),
annotations=types.ToolAnnotations(
readOnlyHint=False, destructiveHint=self.name == "run_simulation", openWorldHint=False
readOnlyHint=not (self.computation or self.mutating), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
),
)
@@ -218,4 +222,38 @@ _specs = [
True,
),
]
_specs += [
("list_regions", "分页查询当前项目区域/DMA列表", m.PageInput, "webgis.view", "asset"),
("get_region", "查询当前项目区域边界,返回EPSG:4326;不等于供水拓扑", e.RegionInput, "webgis.view", "asset"),
("get_region_nodes", "分页查询区域配置的成员节点,未配置时使用区域几何筛选", e.RegionInput, "webgis.view", "asset"),
("get_node_connections", "分页查询节点直接连接的管线,不自动遍历全网", e.NodeConnectionsInput, "webgis.view", "asset"),
("query_network_extent", "按EPSG:4326矩形范围分页查询当前项目模型几何;无GIS配置不跨项目回退", e.ExtentInput, "webgis.view", "asset"),
("list_major_nodes", "按关联管道最小管径查询骨干节点,经纬度EPSG:4326", e.MajorNodesInput, "webgis.view", "asset"),
("get_model_configuration", "读取模式、曲线、控制规则、需水量、水质及反应等模型配置;集合按页返回", e.ModelConfigurationInput, "webgis.view", "asset"),
("list_model_configurations", "分页读取模式或曲线ID", e.ConfigurationListInput, "webgis.view", "asset"),
("search_project_places", "按项目配置的行政区搜索地名,最多10个候选,依赖天地图配置", e.PlaceSearchInput, "webgis.view", "asset"),
("reverse_geocode", "把EPSG:4326地图取点转为项目范围内地名,保留输入坐标", e.ReverseGeocodeInput, "webgis.view", "asset"),
("get_project_info", "读取当前项目概况与地图范围,不返回数据库路由或凭据", m.StrictInput, "webgis.view", "asset"),
("export_project_model", "从当前项目数据库导出INP或ChangeSet压缩文件,由宿主保存到会话文件", e.ProjectExportInput, "webgis.view", "asset"),
("get_sensor_placement_candidate", "读取监测点候选节点工程属性", e.SensorCandidateInput, "optimization.run", "asset"),
("export_sensor_placement", "导出已有监测点方案的Excel工程清单,由宿主保存到会话文件", e.SensorExportInput, "optimization.run", "analysis"),
("list_task_schedules", "分页读取当前项目定时任务配置,不启动调度", m.PageInput, "operational_task.view", "task"),
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后须核实目标数据,不自动重试", e.MutationStatusInput, "webgis.view", "task"),
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,只计算不修改模型,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;不是爆管侦测或定位,不保存正式方案", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;仅修改模型,不控制现场设备", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True),
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
("write_scada_readings", "预览或补录/修正SCADA数据,要求真实来源依据;不把模拟当实测", e.ScadaWriteInput, "scada.clean", "observed", False, False, True),
("delete_scada_readings", "预览或删除明确设备与24小时内时间窗的SCADA记录", e.ScadaDeleteInput, "scada.clean", "observed", False, False, True),
("save_simulation_scenario", "预览或重新计算并保存正式隔离场景成果;有真实计算开销,不保存模型生成的观测数组", e.SavedScenarioInput, "simulation.run", "analysis", False, False, True),
("create_sensor_placement", "预览或计算并保存正式压力监测点选址方案", e.SensorCreateInput, "optimization.run", "analysis", False, False, True),
("update_sensor_placement", "预览或修改本人有权编辑的监测点方案,保留旧点位乐观校验", e.SensorUpdateInput, "optimization.run", "analysis", False, False, True),
("create_operational_task", "预览或创建项目任务,不伪造Agent运行完成", e.TaskCreateInput, "operational_task.write", "task", False, False, True),
("create_task_schedule", "预览或创建定时任务配置", e.ScheduleCreateInput, "operational_task.write", "task", False, False, True),
("update_task_schedule", "预览或启停定时任务配置,校验版本", e.ScheduleUpdateInput, "operational_task.write", "task", False, False, True),
("retry_operational_task", "预览或重新入队已终止任务;不自动启动新Agent会话", e.TaskRetryInput, "operational_task.write", "task", False, False, True),
("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True),
]
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
+3 -1
View File
@@ -69,7 +69,9 @@ async def bind_context(
project_id=scope.project.project_id,
subject=principal.subject,
client_id=principal.client_id,
tool_cap=sorted(name for name, tool in TOOLS.items() if command.purpose != "task_reader" or not tool.computation),
tool_cap=sorted(name for name, tool in TOOLS.items()
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating))
and (not tool.mutating or command.purpose == "ordinary_chat")),
permission_cap=sorted(permissions_for_context(scope.project)),
native_session_id=command.native_session_id,
purpose=command.purpose,
+16
View File
@@ -0,0 +1,16 @@
"""Errors with a known execution boundary for MCP calculations."""
from fastapi import HTTPException
class CalculationCapacityExceeded(HTTPException):
"""Raised by admission control before the native function is invoked."""
code = "CALCULATION_CAPACITY_EXCEEDED"
def __init__(self) -> None:
super().__init__(
503,
"Calculation capacity is occupied; calculation not started. "
"Retry the same request after an active calculation finishes.",
)
+289
View File
@@ -0,0 +1,289 @@
"""Finite project services exposed through MCP; no routes, SQL or actor overrides."""
from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator, create_model
from app.mcp.models import Id, PageInput, StrictInput
from app.mcp.analysis_models import (
CalculationInput, ScenarioInput, ContaminantSimulationInput, ValveClosureInput,
FlushingInput, PressureRegulationInput,
)
from app.domain.schemas.operational_task import (
OperationalTaskCreate, OperationalTaskScheduleCreate, OperationalTaskScheduleUpdate,
OperationalTaskRetryCommand, AgentRunCancelCommand,
)
from app.domain.schemas.sensor_placement import SensorPlacementUpdateRequest
class RegionInput(PageInput):
region_id: Id
class NodeConnectionsInput(PageInput):
node_id: Id
class ExtentInput(PageInput):
element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"] = "pipe"
bounds: tuple[float, float, float, float] = Field(description="EPSG:4326 [west,south,east,north]; 不跨180度经线")
@model_validator(mode="after")
def valid_bounds(self):
west, south, east, north = self.bounds
if not (-180 <= west < east <= 180 and -85 <= south < north <= 85):
raise ValueError("Invalid EPSG:4326 bounds")
if east - west > 1 or north - south > 1:
raise ValueError("Extent must be at most one degree per axis")
return self
class MajorNodesInput(PageInput):
min_diameter_mm: float = Field(gt=0, le=10000, allow_inf_nan=False)
ConfigKind = Literal[
"pattern", "curve", "control", "rule", "quality", "source", "reaction",
"pipe-reaction", "tank-reaction", "mixing", "emitter", "demand",
"time", "energy", "pump-energy", "option",
]
class ModelConfigurationInput(PageInput):
kind: ConfigKind
object_id: Id | None = None
data_path: list[Id] = Field(default_factory=list, max_length=8, description="选择嵌套字段,再按limit/offset分页读取")
@model_validator(mode="after")
def valid_target(self):
needs_id = {"pattern", "curve", "quality", "source", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand", "pump-energy"}
if (self.kind in needs_id) != (self.object_id is not None):
raise ValueError("object_id is required only for object-scoped configuration")
return self
class ConfigurationListInput(PageInput):
kind: Literal["pattern", "curve"]
class PlaceSearchInput(StrictInput):
keyword: str = Field(min_length=1, max_length=120)
class ReverseGeocodeInput(StrictInput):
longitude: float = Field(ge=-180, le=180, allow_inf_nan=False)
latitude: float = Field(ge=-90, le=90, allow_inf_nan=False)
class ProjectExportInput(StrictInput):
format: Literal["inp", "change_set"] = "inp"
version: Literal["2", "3"] = "2"
class SensorCandidateInput(StrictInput):
node_id: Id
class SensorExportInput(StrictInput):
run_id: UUID
class TaskHistoryInput(PageInput):
target_task_id: UUID
class MutationStatusInput(StrictInput):
request_id: UUID
class DemandAllocationInput(CalculationInput):
target: Literal["nodes", "region", "network"]
demand: float = Field(gt=0, allow_inf_nan=False, description="模型流量单位的总需水量;调用前查询模型单位")
node_ids: list[Id] = Field(default_factory=list, max_length=200)
region_id: Id | None = None
limit: int = Field(default=20, ge=1, le=200)
offset: int = Field(default=0, ge=0, le=10000)
@model_validator(mode="after")
def valid_target(self):
if (self.target == "nodes") != bool(self.node_ids) or (self.target == "region") != (self.region_id is not None):
raise ValueError("Provide node_ids for nodes or region_id for region only")
if len(self.node_ids) != len(set(self.node_ids)):
raise ValueError("node_ids must be distinct")
return self
class BurstSimulationInput(ScenarioInput):
burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20)
orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积cm²,不是流量;必须有依据")
@model_validator(mode="after")
def paired_bursts(self):
if len(self.burst_pipe_ids) != len(self.orifice_areas_cm2) or len(set(self.burst_pipe_ids)) != len(self.burst_pipe_ids):
raise ValueError("Distinct pipes and one orifice area per pipe are required")
return self
class MutationInput(CalculationInput):
request_id: UUID = Field(description="操作幂等键;预览与执行使用相同值,失败后先核实状态")
dry_run: bool = Field(default=True, strict=True, description="默认只预览;执行需dry_run=false及预览返回的expected_digest")
expected_digest: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
@model_validator(mode="after")
def require_preview(self):
if not self.dry_run and self.expected_digest is None:
raise ValueError("Execution requires the preview's expected_digest")
command = getattr(self, "command", None)
if command is not None and getattr(command, "request_id", self.request_id) != self.request_id:
raise ValueError("Nested command request_id must match the operation")
return self
class ModelElementMutationInput(MutationInput):
operation: Literal["create", "update", "delete"]
element_type: Literal["junction", "pipe", "valve", "pump", "tank", "reservoir", "region"]
element_id: Id
properties: dict[Id, JsonValue] = Field(default_factory=dict, max_length=40, description="仅允许get_network_schema声明的可写字段;id由element_id绑定")
@model_validator(mode="after")
def valid_properties(self):
if (self.operation == "delete") == bool(self.properties):
raise ValueError("Create/update require properties; delete takes none")
if "id" in self.properties or "type" in self.properties:
raise ValueError("Element identity is bound separately")
return self
class ConfigurationMutationInput(MutationInput):
operation: Literal["create", "update", "delete"] = "update"
kind: ConfigKind
object_id: Id | None = None
properties: dict[Id, JsonValue] = Field(default_factory=dict, max_length=40)
@model_validator(mode="after")
def valid_configuration(self):
ModelConfigurationInput(context_handle=self.context_handle, kind=self.kind, object_id=self.object_id)
if self.operation != "update" and self.kind not in {"pattern", "curve", "source", "mixing"}:
raise ValueError("Only pattern, curve, source and mixing support create/delete")
if (self.operation == "delete") == bool(self.properties):
raise ValueError("Create/update require properties; delete takes none")
if "id" in self.properties:
raise ValueError("Object identity is bound separately")
return self
class ScadaCleaningInput(MutationInput):
start: datetime
end: datetime
device_ids: list[Id] = Field(min_length=1, max_length=20, description="必须显式列出设备;不支持all")
@model_validator(mode="after")
def valid_window(self):
if self.start.tzinfo is None or self.end.tzinfo is None or not timedelta(0) < self.end - self.start <= timedelta(days=1):
raise ValueError("SCADA writes require explicit timezone and at most 24 hours")
if len(set(self.device_ids)) != len(self.device_ids):
raise ValueError("device_ids must be distinct")
return self
class ScadaRecord(BaseModel):
model_config = ConfigDict(extra="forbid")
device_id: Id
time: datetime
monitored_value: float | None = Field(default=None, allow_inf_nan=False)
cleaned_value: float | None = Field(default=None, allow_inf_nan=False)
@model_validator(mode="after")
def valid_record(self):
if self.time.tzinfo is None or not ({"monitored_value", "cleaned_value"} & self.model_fields_set):
raise ValueError("Record needs an explicit timezone and a value field")
return self
class ScadaWriteInput(MutationInput):
operation: Literal["insert", "update"]
records: list[ScadaRecord] = Field(min_length=1, max_length=500)
provenance: str = Field(min_length=1, max_length=2000, description="补录/修正的真实来源与依据,不得把模型值冒充实测")
@model_validator(mode="after")
def bounded_records(self):
keys = [(r.device_id, r.time) for r in self.records]
if len(set(keys)) != len(keys):
raise ValueError("Duplicate device/time records")
if (max(r.time for r in self.records) - min(r.time for r in self.records)).total_seconds() > 86400:
raise ValueError("At most 24 hours per write")
return self
class ScadaDeleteInput(ScadaCleaningInput):
reason: str = Field(min_length=1, max_length=2000)
class SensorCreateInput(MutationInput):
name: str = Field(min_length=1, max_length=64)
method: Literal["sensitivity", "kmeans"]
sensor_count: int = Field(ge=1, le=20)
min_diameter: int = Field(default=0, ge=0, le=5000)
class SensorUpdateInput(MutationInput):
run_id: UUID
changes: SensorPlacementUpdateRequest
def _payload(model):
return create_model(model.__name__ + "Payload", __config__=ConfigDict(extra="forbid"), **{
name: (field.annotation, field) for name, field in model.model_fields.items()
if name not in {"context_handle", "timeout_seconds"}
})
BurstPayload = _payload(BurstSimulationInput)
ContaminantPayload = _payload(ContaminantSimulationInput)
ValvePayload = _payload(ValveClosureInput)
FlushingPayload = _payload(FlushingInput)
PressurePayload = _payload(PressureRegulationInput)
class SavedScenarioInput(MutationInput):
name: str = Field(min_length=1, max_length=64)
scenario: Literal["burst", "contaminant", "valve_closure", "flushing", "pressure_regulation"]
parameters: BurstPayload | ContaminantPayload | ValvePayload | FlushingPayload | PressurePayload
@model_validator(mode="after")
def matching_scenario(self):
expected = {"burst": BurstSimulationInput, "contaminant": ContaminantSimulationInput, "valve_closure": ValveClosureInput, "flushing": FlushingInput, "pressure_regulation": PressureRegulationInput}[self.scenario]
expected.model_validate({**self.parameters.model_dump(), "context_handle": self.context_handle})
return self
class TaskCreateInput(MutationInput):
command: OperationalTaskCreate
@model_validator(mode="after")
def unclaimed_event(self):
if self.command.agent_session_id is not None or self.command.task_type != "event_investigation" or self.command.source.kind not in {"operator", "agent"}:
raise ValueError("Only unclaimed operator/agent event investigations may be created")
return self
class ScheduleCreateInput(MutationInput):
command: OperationalTaskScheduleCreate
class ScheduleUpdateInput(MutationInput):
schedule_id: UUID
command: OperationalTaskScheduleUpdate
class TaskRetryInput(MutationInput):
target_task_id: UUID
command: OperationalTaskRetryCommand
class TaskCancelInput(MutationInput):
target_task_id: UUID
run_id: UUID
command: AgentRunCancelCommand
+224
View File
@@ -0,0 +1,224 @@
"""Adapters for bounded, project-owned queries and downloadable exports."""
import asyncio
import base64
import hashlib
import io
import json
import zipfile
from datetime import datetime, timezone
from uuid import uuid4
from typing import Any, TYPE_CHECKING
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from psycopg import sql
from pydantic import BaseModel
from app.infra.db.postgresql.read_queries import ELEMENT_VIEWS, read_rows
from app.mcp.models import PageInfo, QueryResult, BoundContext
from app.mcp.scope import QueryScope
if TYPE_CHECKING:
from app.mcp.catalog import QueryTool
# Both function names and object identities are selected from a finite registry.
CONFIG_FUNCTIONS = {
"pattern": ("pattern", "id"), "curve": ("curve", "id"),
"control": ("control", None), "rule": ("rule", None),
"quality": ("quality", "node"), "source": ("source", "node"),
"reaction": ("reaction", None), "pipe-reaction": ("pipe_reaction", "pipe"),
"tank-reaction": ("tank_reaction", "tank"), "mixing": ("mixing", "tank"),
"emitter": ("emitter", "junction"), "demand": ("demand", "junction"),
"time": ("time", None), "energy": ("energy", None),
"pump-energy": ("pump_energy", "pump"), "option": ("option_v3", None),
}
EXTENDED_READ_TOOLS = {
"list_regions", "get_region", "get_region_nodes", "get_node_connections",
"query_network_extent", "list_major_nodes", "get_model_configuration",
"list_model_configurations", "search_project_places", "reverse_geocode",
"get_project_info", "export_project_model", "get_sensor_placement_candidate",
"export_sensor_placement", "list_task_schedules", "get_task_run_history", "get_mutation_status",
}
ARTIFACT_TOOLS = {"export_project_model", "export_sensor_placement"}
def configuration(network: str, kind: str, object_id: str | None = None) -> Any:
from app.services import tjnetwork
function, identity = CONFIG_FUNCTIONS[kind]
args = (network, object_id) if identity else (network,)
return getattr(tjnetwork, "get_" + function)(*args)
def artifact(filename: str, payload: bytes, mime_type: str) -> dict[str, Any]:
if len(payload) > 5 * 1024 * 1024:
raise HTTPException(413, "Export exceeds the 5 MiB compressed file budget")
return {"file": {
"name": filename, "mime_type": mime_type, "size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
"encoding": "base64", "data": base64.b64encode(payload).decode("ascii"),
}}
def project_export(network: str, args: BaseModel) -> dict[str, Any]:
from app.services import tjnetwork
from app.services.project_inp import temporary_project_inp
output = io.BytesIO()
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
if args.format == "inp":
with temporary_project_inp(network, purpose="mcp_export", version=args.version) as path:
archive.write(path, arcname="model.inp")
else:
changes = tjnetwork.export_inp(network, args.version)
if changes.operations:
changes.operations[0]["vertex"] = json.dumps(tjnetwork.get_all_vertices(network))
changes.operations[0]["scada"] = json.dumps(tjnetwork.get_all_scada_info(network))
archive.writestr("model.json", json.dumps(jsonable_encoder(changes.operations), ensure_ascii=False))
return artifact("model.zip", output.getvalue(), "application/zip")
def sensor_export(network: str, args: BaseModel, user: Any, project: Any) -> dict[str, Any]:
from app.services.sensor_placement import get_sensor_placement_run, build_sensor_placement_workbook
run = get_sensor_placement_run(network, args.run_id)
workbook = build_sensor_placement_workbook(
network=network, scheme=run, sensor_location=run["sensor_locations"], adjustment_status={},
)
return artifact("sensor-placement.xlsx", workbook.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
async def execute_extended(scope: QueryScope, binding: BoundContext, tool: "QueryTool", args: BaseModel) -> QueryResult:
name, conn, network = tool.name, scope.connection, scope.project.project_code
page, gaps, truncated = None, [], False
def paginate(items, *, already_paged=False):
nonlocal page, truncated
if already_paged:
more, selected, total = len(items) > args.limit, items[:args.limit], None
else:
total = len(items)
more = args.offset + args.limit < total
selected = items[args.offset:args.offset + args.limit]
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=more)
truncated = more or args.offset > 0
return selected
if name == "list_regions":
rows = await read_rows(conn, "SELECT id,region_type FROM gis.regions ORDER BY id LIMIT %s OFFSET %s", (args.limit + 1, args.offset))
data = paginate(rows, already_paged=True)
elif name == "get_region":
rows = await read_rows(conn, "SELECT id,region_type,ST_AsGeoJSON(ST_Transform(boundary,4326))::json AS geometry FROM gis.regions WHERE id=%s", (args.region_id,))
if not rows:
raise HTTPException(404, "Region not found in the current project")
data = {**rows[0], "crs": "EPSG:4326"}
elif name == "get_region_nodes":
rows = await read_rows(conn, """WITH configured AS (
SELECT node_id FROM gis.region_nodes WHERE region_id=%s
), members AS (
SELECT node_id FROM configured UNION ALL
SELECT n.node_id FROM gis.node_geometries n JOIN gis.regions r
ON r.id=%s AND ST_Intersects(n.geom,r.boundary)
WHERE NOT EXISTS (SELECT 1 FROM configured)
) SELECT node_id AS id FROM members ORDER BY node_id LIMIT %s OFFSET %s""",
(args.region_id, args.region_id, args.limit + 1, args.offset))
data = paginate(rows, already_paged=True)
gaps = ["Uses configured region membership, or geometry if none is configured; membership does not prove hydraulic connectivity"]
elif name == "get_node_connections":
rows = await read_rows(conn, """SELECT id,link_type,start_node_id,end_node_id FROM network.links
WHERE start_node_id=%s OR end_node_id=%s ORDER BY id LIMIT %s OFFSET %s""", (args.node_id, args.node_id, args.limit + 1, args.offset))
data = paginate(rows, already_paged=True)
elif name == "query_network_extent":
statement = sql.SQL("""SELECT v.id,to_jsonb(v)-'geom' AS properties,
ST_AsGeoJSON(ST_Transform(v.geom,4326))::json AS geometry FROM gis.{} v
WHERE ST_Intersects(v.geom,ST_Transform(ST_MakeEnvelope(%s,%s,%s,%s,4326),3857))
ORDER BY v.id LIMIT %s OFFSET %s""").format(sql.Identifier(ELEMENT_VIEWS[args.element_type]))
rows = await read_rows(conn, statement, (*args.bounds, args.limit + 1, args.offset))
data = {"items": paginate(rows, already_paged=True), "crs": "EPSG:4326", "source": "current_project_model"}
gaps = ["Only current-project model elements with valid stored geometry are covered; missing GIS geometry is not evidence of an empty network"]
elif name == "list_major_nodes":
rows = await read_rows(conn, """SELECT n.id,ST_X(ST_Transform(n.geom,4326)) AS longitude,
ST_Y(ST_Transform(n.geom,4326)) AS latitude FROM gis.junctions n
WHERE EXISTS (SELECT 1 FROM gis.pipes p WHERE p.diameter >= %s
AND (p.node1=n.id OR p.node2=n.id)) ORDER BY n.id LIMIT %s OFFSET %s""", (args.min_diameter_mm, args.limit + 1, args.offset))
data = {"items": paginate(rows, already_paged=True), "crs": "EPSG:4326"}
gaps = ["Only junctions with stored geometry and qualifying model pipes are covered; no other project's GIS is queried"]
elif name == "get_model_configuration":
data = await asyncio.to_thread(configuration, network, args.kind, args.object_id)
for key in args.data_path:
if not isinstance(data, dict) or key not in data:
raise HTTPException(404, "Configuration path not found")
data = data[key]
if isinstance(data, dict):
data = {"items": paginate([{"key": key, "value": value} for key, value in sorted(data.items())]), "data_path": args.data_path}
elif isinstance(data, list):
data = {"items": paginate(data), "data_path": args.data_path}
else:
data = {"value": data, "data_path": args.data_path}
elif name == "list_model_configurations":
from app.services import tjnetwork
rows = await asyncio.to_thread(getattr(tjnetwork, "get_" + args.kind + "s"), network)
data = paginate(sorted(rows or []))
elif name in {"search_project_places", "reverse_geocode", "get_project_info"}:
project = await scope.metadata.get_project_by_id(scope.project.project_id)
if project is None:
raise HTTPException(404, "Project not found")
if name == "get_project_info":
data = {key: getattr(project, key, None) for key in ("name", "code", "description", "map_extent", "status")}
data["project_id"] = str(scope.project.project_id)
else:
from app.domain.schemas.project_map_config import ProjectMapConfig
from app.domain.schemas.place_search import PlaceSearchRequest, ReverseGeocodingRequest
from app.services.place_search import search_tianditu_places, PlaceSearchError
from app.services.reverse_geocoding import reverse_geocode_tianditu
config = ProjectMapConfig.model_validate(project.map_config or {}).place_search
if config is None or not config.enabled:
raise HTTPException(503, "Current project place search is not configured")
await scope.metadata.session.commit()
try:
data = (await search_tianditu_places(PlaceSearchRequest(keyword=args.keyword), config=config)
if name == "search_project_places" else await reverse_geocode_tianditu(
ReverseGeocodingRequest(longitude=args.longitude, latitude=args.latitude), config=config))
truncated = bool(getattr(data, "truncated", False))
except PlaceSearchError as exc:
raise HTTPException(exc.status_code, exc.detail) from exc
elif name == "export_project_model":
data = await asyncio.to_thread(project_export, network, args)
elif name == "get_sensor_placement_candidate":
from app.services.sensor_placement import get_sensor_placement_candidate
data = await asyncio.to_thread(get_sensor_placement_candidate, network, args.node_id)
elif name == "export_sensor_placement":
data = await asyncio.to_thread(sensor_export, network, args, scope.user, scope.project)
elif name == "get_mutation_status":
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
repo = McpQueryRepository(conn, scope.project.project_id)
previous = await repo.receipt("mutation:" + binding.subject + ":" + binding.native_session_id, str(args.request_id))
if previous is None:
data = {"status": "not_started", "request_id": str(args.request_id)}
else:
output = previous["result"].get("output", {})
data = {"request_id": str(args.request_id), "status": previous["result"]["status"],
"tool": previous["parameters"]["tool"], "source_ref": output.get("source_ref"),
"queried_at": output.get("queried_at")}
if previous["result"]["status"] == "rejected":
from app.mcp.mutation_service import retryable_capacity_rejection
data["retryable"] = retryable_capacity_rejection(previous["result"])
data["error"] = previous["result"].get("error")
gaps = ["started does not prove success or rollback; verify target state before any new operation"]
elif name in {"list_task_schedules", "get_task_run_history"}:
from app.mcp.query_service import task_service
service = task_service(scope)
if name == "list_task_schedules":
data = paginate(await service.list_schedules())
else:
rows = await service.task_runs(args.target_task_id, limit=args.limit + 1, offset=args.offset)
data = paginate(rows, already_paged=True)
else:
raise ValueError("Unknown extended query")
return QueryResult(source=name, source_ref="mcp:" + uuid4().hex,
queried_at=datetime.now(timezone.utc), semantics=tool.semantics,
data=jsonable_encoder(data), page=page, truncated=truncated, data_gaps=gaps)
+2
View File
@@ -78,6 +78,8 @@ class SchemaInput(StrictInput):
"pump-energy",
"option",
"scada-device",
"region", "pattern", "curve", "control", "rule", "quality", "source",
"reaction", "pipe-reaction", "tank-reaction", "mixing", "emitter", "demand",
]
+339
View File
@@ -0,0 +1,339 @@
"""Previewed writes with project permissions and a durable no-retry journal."""
import asyncio
import hashlib
import json
import math
from datetime import datetime, timezone
from uuid import uuid4
from typing import Any, TYPE_CHECKING
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from psycopg import sql
from pydantic import BaseModel
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
from app.infra.db.postgresql.read_queries import read_rows, scada_devices
from app.mcp.extended_service import CONFIG_FUNCTIONS, configuration
from app.mcp.errors import CalculationCapacityExceeded
from app.mcp.models import QueryResult, BoundContext
from app.mcp.scope import QueryScope
if TYPE_CHECKING:
from app.mcp.catalog import QueryTool
def digest(value: Any) -> str:
return hashlib.sha256(json.dumps(jsonable_encoder(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
def check_digest(args: BaseModel, preview: dict) -> None:
if not args.dry_run and args.expected_digest != digest(preview):
raise HTTPException(409, "Preview changed; request a new dry_run before execution")
def mutation_arguments(args: BaseModel, *, preview: bool) -> dict[str, Any]:
excluded = {"context_handle", "timeout_seconds"}
if preview:
excluded.update({"dry_run", "expected_digest"})
payload = args.model_dump(mode="json", exclude=excluded)
if hasattr(args, "records"):
# An omitted cleaned_value means keep it during update; explicit null
# means clear it. Preserve that difference in previews and replay keys.
payload["records"] = [record.model_dump(mode="json", exclude_unset=True) for record in args.records]
return payload
def check_properties(schema: dict, properties: dict, *, creating: bool = False) -> None:
def finite(value):
if isinstance(value, float) and not math.isfinite(value):
raise HTTPException(422, "Model values must be finite")
if isinstance(value, dict):
for child in value.values():
finite(child)
elif isinstance(value, list):
for child in value:
finite(child)
finite(properties)
for key, value in properties.items():
spec = schema.get(key)
if spec is None or spec.get("readonly"):
raise HTTPException(422, "Unknown or read-only model property: " + key)
if value is None and not spec.get("optional", False):
raise HTTPException(422, "Required model property cannot be null: " + key)
expected = spec.get("type")
if value is not None:
valid = {"str": isinstance(value, str), "float": isinstance(value, (int, float)) and not isinstance(value, bool),
"int": isinstance(value, int) and not isinstance(value, bool), "bool": isinstance(value, bool),
"list": isinstance(value, list), "str_list": isinstance(value, list) and all(isinstance(v, str) for v in value),
"float_list": isinstance(value, list) and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value),
"tuple_list": isinstance(value, list)}.get(expected, True)
if not valid:
raise HTTPException(422, "Invalid model property type: " + key)
if isinstance(value, list) and len(value) > 500:
raise HTTPException(422, "Model property exceeds 500 items")
if isinstance(value, list) and isinstance(spec.get("element"), dict):
for item in value:
if not isinstance(item, dict):
raise HTTPException(422, "Model list item must be an object")
check_properties(spec["element"], item, creating=True)
if creating:
required = {key for key, spec in schema.items() if not spec.get("readonly") and not spec.get("optional")}
if not required.issubset(properties):
raise HTTPException(422, "Missing required model properties: " + ",".join(sorted(required - properties.keys())))
def model_change(network: str, name: str, args: BaseModel, *, execute: bool) -> dict[str, Any]:
from app.services import tjnetwork
from app.native.wndb.core.database import ChangeSet, model_mutation_transaction
# Preview revalidation and the actual write share the same model mutation
# lock; another WNDB writer cannot change the target between them.
with model_mutation_transaction(network):
if name == "edit_model_element":
kind, identity, object_id = args.element_type, "id", args.element_id
current = getattr(tjnetwork, "get_" + kind)(network, object_id)
schema = getattr(tjnetwork, "get_" + kind + "_schema")(network)
else:
kind, identity = CONFIG_FUNCTIONS[args.kind]
object_id = args.object_id
current = configuration(network, args.kind, object_id)
schema = getattr(tjnetwork, "get_" + kind + "_schema")(network)
check_properties(schema, args.properties, creating=args.operation == "create")
if identity and args.operation == "create" and current:
raise HTTPException(409, "Object already exists")
if identity and args.operation != "create" and not current:
raise HTTPException(404, "Model object not found")
preview = {"tool": name, "operation": args.operation, "kind": kind,
"object_id": object_id, "before": current, "properties": args.properties}
if not execute:
return preview
check_digest(args, preview)
prefix = {"create": "add_", "update": "set_", "delete": "delete_"}[args.operation]
values = ({identity: object_id} if identity else {}) | args.properties
if kind == "region" and "boundary" in values:
values["boundary"] = [tuple(point) for point in values["boundary"]]
result = getattr(tjnetwork, prefix + kind)(network, ChangeSet(values))
if not result.operations:
raise HTTPException(409, "Model rejected the change; no operations were applied")
return {"operations": result.operations, "field_device_control": False}
async def scada_snapshot(scope: QueryScope, args: BaseModel, *, connection: Any = None, lock: bool = False) -> dict[str, Any]:
ids = sorted({r.device_id for r in args.records}) if hasattr(args, "records") else sorted(set(args.device_ids))
for device_id in ids:
if not await scada_devices(scope.connection, device_id=device_id, limit=1):
raise HTTPException(404, "SCADA device not found in the current project")
async def read(ts):
suffix = " FOR UPDATE" if lock else ""
if hasattr(args, "records"):
rows = []
for record in args.records:
rows.extend(await read_rows(ts, "SELECT time,device_id,monitored_value,cleaned_value FROM scada.measurements WHERE device_id=%s AND time=%s" + suffix, (record.device_id, record.time)))
else:
rows = await read_rows(ts, """SELECT time,device_id,monitored_value,cleaned_value FROM scada.measurements
WHERE device_id=ANY(%s) AND time >= %s AND time <= %s ORDER BY device_id,time LIMIT 10001""" + suffix, (ids, args.start, args.end))
if len(rows) > 10000:
raise HTTPException(413, "Narrow the SCADA window to at most 10000 records")
return {"count": len(rows), "devices": ids, "state_digest": digest(rows)}
if connection is not None:
return await read(connection)
async with scope.timescale() as ts:
return await read(ts)
async def preview_change(scope: QueryScope, binding: BoundContext, name: str, args: BaseModel) -> dict[str, Any]:
network = scope.project.project_code
if name in {"edit_model_element", "edit_model_configuration"}:
from app.mcp.analysis_service import bounded_calculation
return await bounded_calculation(model_change, network, name, args, execute=False)
payload = mutation_arguments(args, preview=True)
state = None
if name in {"clean_scada_readings", "write_scada_readings", "delete_scada_readings"}:
state = await scada_snapshot(scope, args)
elif name in {"update_sensor_placement"}:
from app.services.sensor_placement import get_sensor_placement_run, can_edit_sensor_placement
state = await asyncio.to_thread(get_sensor_placement_run, network, args.run_id)
if not can_edit_sensor_placement(scope.user, state):
raise HTTPException(403, "Cannot edit another user's sensor placement")
elif name in {"retry_operational_task", "cancel_task_run"}:
from app.mcp.query_service import task_service
state = (await task_service(scope).detail(args.target_task_id)).model_dump(mode="json")
elif name == "update_task_schedule":
from app.mcp.query_service import task_service
schedules = await task_service(scope).list_schedules()
state = next((s.model_dump(mode="json") for s in schedules if s.schedule_id == args.schedule_id), None)
if state is None:
raise HTTPException(404, "Schedule not found in the current project")
return {"tool": name, "parameters": payload, "before": state}
def saved_scenario(network: str, username: str, args: BaseModel) -> Any:
from app.services import simulation_scenarios as scenarios
from app.services.simulation import query_corresponding_element_id_and_query_id
parameters = args.parameters.model_dump(exclude_none=True)
parameters.pop("output_node_ids")
parameters.pop("output_link_ids")
common = dict(name=network, modify_pattern_start_time=parameters.pop("start_time").isoformat(),
modify_total_duration=parameters.pop("duration"), scheme_name=args.name, persist_results=True)
if args.scenario == "burst":
return scenarios.burst_analysis(**common, username=username,
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
if args.scenario == "contaminant":
return scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
if args.scenario == "valve_closure":
return scenarios.valve_close_analysis(**common, username=username,
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
if args.scenario == "flushing":
from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing supports LPS or CMH models only")
return scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
mappings = query_corresponding_element_id_and_query_id(network)
controls = parameters["pump_control"]
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
raise ValueError("Unknown model pump")
if any(value not in (0, 1) for key, values in controls.items() if key in mappings.fixed_pumps for value in values):
raise ValueError("Fixed pumps accept only 0 or 1")
return scenarios.pressure_regulation(**common, username=username,
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings)
async def apply_change(scope: QueryScope, binding: BoundContext, name: str, args: BaseModel) -> Any:
network = scope.project.project_code
if name in {"edit_model_element", "edit_model_configuration"}:
from app.mcp.analysis_service import bounded_calculation
return await bounded_calculation(model_change, network, name, args, execute=True)
if name in {"clean_scada_readings", "write_scada_readings", "delete_scada_readings"}:
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
from app.services.timeseries_analysis import TimeseriesAnalysisService
async with scope.timescale() as ts, ts.transaction():
await ts.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
# Serialize with other MCP maintenance calls, then validate again.
await ts.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ("mcp-scada-maintenance",))
before = await scada_snapshot(scope, args, connection=ts, lock=True)
payload = mutation_arguments(args, preview=True)
check_digest(args, {"tool": name, "parameters": payload, "before": before})
if name == "clean_scada_readings":
result = await TimeseriesAnalysisService.clean_scada_data(ts, scope.connection, args.device_ids, args.start, args.end)
return {"status": result, "field": "cleaned_value"}
if name == "delete_scada_readings":
async with ts.cursor() as cur:
await cur.execute("DELETE FROM scada.measurements WHERE device_id=ANY(%s) AND time >= %s AND time <= %s", (args.device_ids, args.start, args.end))
return {"deleted_rows": cur.rowcount}
if args.operation == "insert":
await ScadaRepository.insert_scada_batch(ts, [r.model_dump() for r in args.records])
else:
for record in args.records:
fields = sorted(record.model_fields_set & {"monitored_value", "cleaned_value"})
statement = sql.SQL("UPDATE scada.measurements SET {} WHERE device_id=%s AND time=%s").format(
sql.SQL(",").join(sql.SQL("{}=%s").format(sql.Identifier(field)) for field in fields))
async with ts.cursor() as cur:
await cur.execute(statement, tuple(getattr(record, field) for field in fields) + (record.device_id, record.time))
if cur.rowcount != 1:
raise HTTPException(409, "SCADA update requires exactly one existing record")
return {"operation": args.operation, "records": len(args.records), "provenance": args.provenance}
if name == "save_simulation_scenario":
from app.mcp.analysis_service import bounded_calculation
async with scope.simulation_routing():
await bounded_calculation(saved_scenario, network, scope.user.username, args)
return {"persisted": True, "name": args.name, "scenario": args.scenario, "results_query": "list_analysis_runs"}
if name == "create_sensor_placement":
from app.mcp.analysis_service import bounded_calculation
from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity
optimizer = optimize_sensor_placement_by_sensitivity if args.method == "sensitivity" else optimize_sensor_placement_by_kmeans
return await bounded_calculation(optimizer, project_code=network, run_name=args.name,
created_by=scope.user.username, sensor_count=args.sensor_count, min_diameter=args.min_diameter,
sensor_type="pressure", persist_results=True)
if name == "update_sensor_placement":
from app.services.sensor_placement import update_sensor_placement_run
return await asyncio.to_thread(update_sensor_placement_run, network, args.run_id, **args.changes.model_dump())
from app.mcp.query_service import task_service
service = task_service(scope)
if name == "create_operational_task":
if args.command.agent_session_id is not None or args.command.task_type != "event_investigation" or args.command.source.kind not in {"operator", "agent"}:
raise HTTPException(422, "Only unclaimed operator/agent event investigations may be created")
return await service.create(args.command)
if name == "create_task_schedule":
return await service.create_schedule(args.command)
if name == "update_task_schedule":
return await service.update_schedule(args.schedule_id, args.command)
if name == "retry_operational_task":
return await service.retry_task(args.target_task_id, args.command)
if name == "cancel_task_run":
return await service.cancel_run(args.target_task_id, args.run_id, args.command)
raise ValueError("Unknown mutation")
def retryable_capacity_rejection(result: dict[str, Any]) -> bool:
return (result.get("status") == "rejected" and result.get("retryable") is True
and result.get("error", {}).get("code") == CalculationCapacityExceeded.code)
async def execute_mutation(scope: QueryScope, binding: BoundContext, tool: "QueryTool", args: BaseModel) -> QueryResult:
name = tool.name
repo = McpQueryRepository(scope.connection, scope.project.project_id)
key, call_id = "mutation:" + binding.subject + ":" + binding.native_session_id, str(args.request_id)
parameters = {"tool": name, "arguments": mutation_arguments(args, preview=False)}
if not args.dry_run:
async with scope.connection.transaction():
await repo.lock_receipt(key, call_id)
previous = await repo.receipt(key, call_id)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(409, "request_id already has different parameters")
if previous["result"].get("status") == "completed":
return QueryResult.model_validate(previous["result"]["output"])
if not retryable_capacity_rejection(previous["result"]):
raise HTTPException(409, "Operation already started; verify its state before any retry")
preview = await preview_change(scope, binding, name, args)
if args.dry_run:
result_data = {"dry_run": True, "preview": preview, "expected_digest": digest(preview), "request_id": call_id}
if len(json.dumps(jsonable_encoder(result_data)).encode()) > 10000:
result_data["preview"] = {"tool": name, "summary": "Preview payload omitted; inspect the target with paged read tools", "before_digest": digest(preview.get("before"))}
else:
check_digest(args, preview)
async with scope.connection.transaction():
await repo.lock_receipt(key, call_id)
previous = await repo.receipt(key, call_id)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(409, "request_id already has different parameters")
if previous["result"].get("status") == "completed":
return QueryResult.model_validate(previous["result"]["output"])
if not retryable_capacity_rejection(previous["result"]):
raise HTTPException(409, "Operation was started by another request; verify its state")
await repo.finish_receipt(key, call_id, {"status": "started"})
else:
await repo.save_receipt(key, call_id, parameters, {"status": "started"})
await scope.connection.commit()
# Keep started on cancellation/timeout: a native thread may still commit.
try:
applied = await apply_change(scope, binding, name, args)
except CalculationCapacityExceeded as exc:
# This specific admission failure proves that no calculation began.
# Other errors and cancellation remain ambiguous and are not retried.
async with scope.connection.transaction():
await repo.lock_receipt(key, call_id)
await repo.finish_receipt(key, call_id, {
"status": "rejected", "retryable": True,
"error": {"code": exc.code, "message": exc.detail},
"rejected_at": datetime.now(timezone.utc).isoformat(),
})
await scope.connection.commit()
raise
result_data = {"dry_run": False, "request_id": call_id, "result": jsonable_encoder(applied)}
result = QueryResult(source=name, source_ref="mcp:" + uuid4().hex,
queried_at=datetime.now(timezone.utc), semantics=tool.semantics, data=result_data)
if not args.dry_run:
async with scope.connection.transaction():
await repo.finish_receipt(key, call_id, {"status": "completed", "output": result.model_dump(mode="json")})
return result
+20 -1
View File
@@ -69,6 +69,14 @@ async def authorize_query(
if tool.name not in binding.tool_cap:
raise HTTPException(403, "Query is outside the captured tool catalog")
permissions = permissions_for_context(scope.project)
if tool.mutating:
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
raise HTTPException(403, "Writes require a project member's ordinary interactive chat")
required = {tool.permission}
if tool.name == "save_simulation_scenario":
required.add("scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
raise HTTPException(403, "Write and input data permissions are required")
if tool.computation:
if tool.name == "optimize_sensor_placement" and scope.project.project_role != "member":
raise HTTPException(403, "Sensor placement requires project member access")
@@ -123,6 +131,12 @@ async def execute_query(
full_snapshot: bool = False,
) -> QueryResult:
await authorize_query(scope, binding, tool, args)
if tool.mutating:
from app.mcp.mutation_service import execute_mutation
return await execute_mutation(scope, binding, tool, args)
from app.mcp.extended_service import EXTENDED_READ_TOOLS, execute_extended
if tool.name in EXTENDED_READ_TOOLS:
return await execute_extended(scope, binding, tool, args)
name = tool.name
conn = scope.connection
page = None
@@ -162,7 +176,12 @@ async def execute_query(
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
}[name]]
if name == "calculate_demand_allocation":
truncated = data["result"]["has_more"] or args.offset > 0
page = PageInfo(limit=args.limit, offset=args.offset, total=data["result"]["total"], has_more=data["result"]["has_more"])
if getattr(args, "data_source", None) == "simulation":
gaps.append("Input is an existing analysis run and model baseline, not observed SCADA data.")
elif name == "query_nearby_elements":
@@ -216,7 +235,7 @@ async def execute_query(
"pump-energy": "get_pump_energy_schema",
}
function = getattr(
tjnetwork, mapping.get(args.kind, "get_" + args.kind + "_schema")
tjnetwork, mapping.get(args.kind, "get_" + args.kind.replace("-", "_") + "_schema")
)
data = await asyncio.to_thread(function, scope.project.project_code)
elif name == "get_network_options":
+4
View File
@@ -6,6 +6,7 @@ SCENARIO_TOOLS = frozenset({
"run_simulation",
"run_contaminant_simulation", "analyze_valve_closure",
"analyze_flushing", "analyze_pressure_regulation",
"run_burst_simulation",
})
@@ -25,6 +26,9 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
result = run_online_simulation(
network, start, duration, output_node_ids=nodes, output_link_ids=links,
)
elif name == "run_burst_simulation":
result = scenarios.burst_analysis(**common, username=username,
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
elif name == "run_contaminant_simulation":
result = scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
+2 -2
View File
@@ -911,9 +911,9 @@ class OperationalTaskService:
previous_report is None
or previous_report.get("execution_generation") != task["execution_generation"]
or previous_result.get("stage") != "final"
or any(previous_result.get(key) != _json(command.report).get(key)
or any(previous_result.get(key, [] if key == "ui_blocks" else None) != _json(command.report).get(key)
for key in ("current_conclusion", "conclusion_boundary", "outcome", "stop_reason",
"evidence_refs", "recommendations", "pending_items", "data_gaps"))
"evidence_refs", "recommendations", "pending_items", "data_gaps", "ui_blocks"))
):
await self._resolve_notifications(
task_id, kinds=["report_updated"], subject_type="task", subject_id=task_id,
+37 -15
View File
@@ -6,6 +6,7 @@ from pydantic import ValidationError
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.domain.schemas.place_search import (
MAX_PLACE_SEARCH_RESULTS,
PlaceSearchItem,
@@ -13,7 +14,6 @@ from app.domain.schemas.place_search import (
PlaceSearchResponse,
)
FENGXIAN_ADMIN_CODE = "156310120"
install_tianditu_token_filter()
@@ -24,11 +24,37 @@ class PlaceSearchError(RuntimeError):
self.detail = detail
class PlaceSearchScopeError(ValueError):
pass
def validate_place_scope(
config: PlaceSearchConfig, *, county_code: str, county: str, province_code: str = "",
) -> None:
admin_code = config.admin_code.removeprefix("156")
province_code = province_code.removeprefix("156")
county_code = county_code.removeprefix("156")
if province_code and province_code[:2] != admin_code[:2]:
raise PlaceSearchScopeError("Result outside configured province")
scope_prefix = admin_code
if admin_code.endswith("0000"):
scope_prefix = admin_code[:2]
elif admin_code.endswith("00"):
scope_prefix = admin_code[:4]
if county_code and not county_code.startswith(scope_prefix):
raise PlaceSearchScopeError("Result outside configured district")
if (len(scope_prefix) == 6 and county and county != config.region) or (not county_code and county != config.region):
raise PlaceSearchScopeError("Missing or mismatched district")
async def search_tianditu_places(
request: PlaceSearchRequest,
*,
config: PlaceSearchConfig,
client: httpx.AsyncClient | None = None,
) -> PlaceSearchResponse:
if not config.enabled:
raise PlaceSearchError(503, "当前项目暂未配置地名搜索")
token = settings.TIANDITU_TOKEN
if not token:
raise PlaceSearchError(503, "地名搜索服务未配置")
@@ -37,7 +63,7 @@ async def search_tianditu_places(
{
"keyWord": request.keyword,
"queryType": 12,
"specify": FENGXIAN_ADMIN_CODE,
"specify": "156" + config.admin_code.removeprefix("156"),
"start": 0,
"count": MAX_PLACE_SEARCH_RESULTS,
"show": 2,
@@ -64,15 +90,15 @@ async def search_tianditu_places(
# Request URLs contain the provider key and must not reach the client.
raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None
try:
return _parse_response(response.json())
return _parse_response(response.json(), config)
except (ValueError, TypeError, KeyError, AttributeError, ValidationError):
raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None
def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
def _parse_response(payload: dict[str, Any], config: PlaceSearchConfig) -> PlaceSearchResponse:
code = str(payload.get("status", {}).get("infocode", ""))
if code == "3001":
return PlaceSearchResponse(items=[], total=0)
return PlaceSearchResponse(items=[], total=0, region=config.region)
if code != "1000":
raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试")
@@ -84,15 +110,11 @@ def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
raise ValueError("Invalid search results")
items = []
for raw in raw_items:
admin_code = str(raw.get("provinceCode") or raw.get("adminCode") or "")
if admin_code and not admin_code.startswith(("156310", "310")):
raise ValueError("Result outside Shanghai")
county_code = str(raw.get("countyCode") or (raw.get("adminCode") if result_type == "3" else "") or "")
county = raw.get("county")
if county_code and not county_code.startswith((FENGXIAN_ADMIN_CODE, "310120")):
raise ValueError("Result outside Fengxian")
if county and county != "奉贤区" or not county_code and county != "奉贤区":
raise ValueError("Missing or mismatched district")
validate_place_scope(
config, county_code=str(raw.get("countyCode") or raw.get("adminCode") or ""),
county=str(county or ""), province_code=str(raw.get("provinceCode") or ""),
)
lon, lat = str(raw["lonlat"]).split(",")
name = raw["name"]
if not isinstance(name, str) or not name.strip():
@@ -105,11 +127,11 @@ def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
address=str(raw.get("address") or name),
longitude=float(lon), latitude=float(lat),
category="行政区" if result_type == "3" else category,
district="奉贤区",
district=str(county or config.region),
))
total = int(payload.get("count", 0))
if total < len(items):
raise ValueError("Invalid search total")
return PlaceSearchResponse(
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS,
items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS, region=config.region,
)
+72
View File
@@ -0,0 +1,72 @@
import json
from typing import Any
import httpx
from app.core.config import settings
from app.domain.schemas.place_search import PlaceSearchItem, ReverseGeocodingRequest
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.services.place_search import PlaceSearchError, PlaceSearchScopeError, validate_place_scope
async def reverse_geocode_tianditu(
request: ReverseGeocodingRequest,
*,
config: PlaceSearchConfig,
client: httpx.AsyncClient | None = None,
) -> PlaceSearchItem:
if not config.enabled:
raise PlaceSearchError(503, "当前项目暂未配置地名搜索")
if not settings.TIANDITU_TOKEN:
raise PlaceSearchError(503, "地名搜索服务未配置")
params = {
"postStr": json.dumps({"lon": request.longitude, "lat": request.latitude, "ver": 1}),
"type": "geocode",
"tk": settings.TIANDITU_TOKEN,
}
try:
if client is not None:
response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params)
else:
async with httpx.AsyncClient(timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS) as managed:
response = await managed.get(settings.TIANDITU_GEOCODER_URL, params=params)
response.raise_for_status()
except httpx.TimeoutException:
raise PlaceSearchError(504, "地图取点查询超时,请重新取点") from None
except httpx.HTTPError:
raise PlaceSearchError(502, "天地图反编码服务暂不可用,请重新取点") from None
try:
return _parse_reverse_response(response.json(), request, config)
except PlaceSearchScopeError:
raise PlaceSearchError(422, f"取点位置不在当前项目的地名搜索范围内({config.region})") from None
except (ValueError, TypeError, KeyError, AttributeError):
raise PlaceSearchError(502, "天地图返回了无效的反编码结果,请重新取点") from None
def _parse_reverse_response(
payload: dict[str, Any], request: ReverseGeocodingRequest, config: PlaceSearchConfig,
) -> PlaceSearchItem:
if str(payload.get("status")) != "0":
raise PlaceSearchError(502, "天地图反编码请求失败,请重新取点")
result = payload.get("result")
if not result:
raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点")
address = result.get("formatted_address")
if not isinstance(address, str) or not address.strip():
raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点")
component = result.get("addressComponent") or {}
validate_place_scope(
config, county_code=str(component.get("county_code") or ""),
county=str(component.get("county") or ""),
province_code=str(component.get("province_code") or ""),
)
name = component.get("poi") or component.get("road") or address
if not isinstance(name, str) or not name.strip():
raise ValueError("Invalid place name")
# A nearby POI can have a different center. Always retain the clicked point.
return PlaceSearchItem(
id=f"map:{request.longitude:.8f},{request.latitude:.8f}",
name=name.strip(), address=address.strip(), longitude=request.longitude, latitude=request.latitude,
category="附近地名" if component.get("poi") else "地址",
district=str(component.get("county") or config.region),
)
+10 -3
View File
@@ -77,8 +77,9 @@ def burst_analysis(
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
username: str | None = None,
persist_results: bool = True,
_temporary_project: str | None = None,
) -> None:
) -> dict | None:
"""
爆管模拟
:param name: 模型名称,数据库中对应的名字
@@ -92,7 +93,7 @@ def burst_analysis(
:param scheme_name: 方案名称
:return:
"""
if not username:
if persist_results and not username:
raise ValueError("username is required when storing burst analysis scheme")
scheme_detail: dict = {
@@ -193,7 +194,7 @@ def burst_analysis(
# modify_pump_pattern=modify_pump_pattern,
# valve_control=valve_control,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -206,7 +207,9 @@ def burst_analysis(
result_db_name=name,
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
)
return result if not persist_results else None
# step 3. restore the base model status
# execute_undo(name) #有疑惑
@@ -223,6 +226,7 @@ def valve_close_analysis(
scheme_name: str = None,
_temporary_project: str | None = None,
persist_results: bool = True,
username: str = "system",
) -> dict | str | None:
"""
关阀模拟
@@ -282,6 +286,7 @@ def valve_close_analysis(
scheme_name=scheme_name,
result_db_name=name,
persist_results=persist_results,
scheme_username=username,
)
return result if not persist_results else None
# step 3. restore the base model
@@ -610,6 +615,7 @@ def pressure_regulation(
scada_mappings: simulation.ScadaElementMappings | None = None,
_temporary_project: str | None = None,
persist_results: bool = True,
username: str = "system",
) -> dict | str | None:
"""
区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响
@@ -668,6 +674,7 @@ def pressure_regulation(
result_db_name=name,
scada_mappings=scada_mappings,
persist_results=persist_results,
scheme_username=username,
)
return result if not persist_results else None
# return result
+454
View File
@@ -0,0 +1,454 @@
{
"version": "answer-ui@1",
"description": "Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.",
"schema": {
"type": "object",
"properties": {
"root": {
"type": "string"
},
"elements": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"enum": [
"Stack",
"Section",
"Text",
"Metric",
"Table",
"Chart",
"Evidence",
"MapLocation",
"FollowUpForm"
]
},
"props": {
"type": "object",
"additionalProperties": {}
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"slots": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"visible": {},
"repeat": {}
},
"required": [
"type",
"props",
"children"
],
"additionalProperties": false
}
}
},
"required": [
"root",
"elements"
],
"additionalProperties": false
},
"components": [
"Stack",
"Section",
"Text",
"Metric",
"Table",
"Chart",
"Evidence",
"MapLocation",
"FollowUpForm"
],
"props": {
"Stack": {
"type": "object",
"properties": {
"columns": {
"type": "string",
"enum": [
"one",
"two"
]
},
"gap": {
"type": "string",
"enum": [
"compact",
"normal"
]
}
},
"required": [
"columns",
"gap"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Section": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"description": {
"anyOf": [
{
"type": "string",
"maxLength": 4000
},
{
"type": "null"
}
]
}
},
"required": [
"title",
"description"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Text": {
"type": "object",
"properties": {
"text": {
"type": "string",
"maxLength": 4000
},
"tone": {
"type": "string",
"enum": [
"neutral",
"info",
"warning"
]
}
},
"required": [
"text",
"tone"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Metric": {
"type": "object",
"properties": {
"label": {
"type": "string",
"maxLength": 200
},
"value": {
"type": "string",
"maxLength": 200
},
"unit": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"source": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
}
},
"required": [
"label",
"value",
"unit",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Table": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"columns": {
"type": "array",
"items": {
"type": "string",
"maxLength": 200
},
"minItems": 1,
"maxItems": 8
},
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string",
"maxLength": 1000
},
"maxItems": 8
},
"maxItems": 50
}
},
"required": [
"title",
"columns",
"rows"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Chart": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"chart_type": {
"type": "string",
"enum": [
"line",
"bar"
]
},
"x_data": {
"type": "array",
"items": {
"type": "string",
"maxLength": 200
},
"minItems": 1,
"maxItems": 100
},
"series": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 200
},
"data": {
"type": "array",
"items": {
"type": "number"
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"name",
"data"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 8
},
"source": {
"type": "string",
"maxLength": 4000
}
},
"required": [
"title",
"chart_type",
"x_data",
"series",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Evidence": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"summary": {
"type": "string",
"maxLength": 4000
},
"source": {
"type": "string",
"maxLength": 200
},
"evidence_id": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"observed_at": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"kind": {
"type": "string",
"enum": [
"observed",
"human_input",
"inference",
"demo"
]
}
},
"required": [
"title",
"summary",
"source",
"evidence_id",
"observed_at",
"kind"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"MapLocation": {
"type": "object",
"properties": {
"label": {
"type": "string",
"maxLength": 200
},
"layer": {
"type": "string",
"enum": [
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]
},
"feature_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
},
"source": {
"type": "string",
"maxLength": 200
}
},
"required": [
"label",
"layer",
"feature_ids",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"FollowUpForm": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"description": {
"type": "string",
"maxLength": 4000
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,39}$"
},
"label": {
"type": "string",
"maxLength": 200
},
"kind": {
"type": "string",
"enum": [
"text",
"datetime",
"number"
]
},
"required": {
"type": "boolean"
}
},
"required": [
"name",
"label",
"kind",
"required"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 12
}
},
"required": [
"title",
"description",
"fields"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "949077b0e1c711cfef78319d24cbe081e8d71444e7b25018507e12ba374018a3"
"sha256": "66fbf1105aa966a590f93fac176cbc77d22a4a6cf0a94dfcd1d3c27273f73692"
}
}
}
+618 -8
View File
@@ -283,6 +283,9 @@
"title": "Gs Workspace",
"type": "string"
},
"map_config": {
"$ref": "#/components/schemas/ProjectMapConfig"
},
"map_extent": {
"anyOf": [
{
@@ -346,6 +349,9 @@
"title": "Gs Workspace",
"type": "string"
},
"map_config": {
"$ref": "#/components/schemas/ProjectMapConfig"
},
"map_extent": {
"anyOf": [
{
@@ -428,6 +434,16 @@
],
"title": "Gs Workspace"
},
"map_config": {
"anyOf": [
{
"$ref": "#/components/schemas/ProjectMapConfig"
},
{
"type": "null"
}
]
},
"map_extent": {
"anyOf": [
{
@@ -1128,6 +1144,93 @@
"title": "AgentTaskContext",
"type": "object"
},
"AnswerUiBlock": {
"additionalProperties": false,
"properties": {
"block_id": {
"pattern": "^[a-zA-Z0-9_-]{1,80}$",
"title": "Block Id",
"type": "string"
},
"fallback_text": {
"maxLength": 4000,
"minLength": 1,
"title": "Fallback Text",
"type": "string"
},
"spec": {
"$ref": "#/components/schemas/AnswerUiSpec"
},
"version": {
"const": "answer-ui@1",
"title": "Version",
"type": "string"
}
},
"required": [
"version",
"block_id",
"fallback_text",
"spec"
],
"title": "AnswerUiBlock",
"type": "object"
},
"AnswerUiElement": {
"additionalProperties": false,
"properties": {
"children": {
"items": {
"pattern": "^[a-zA-Z0-9_-]{1,80}$",
"type": "string"
},
"maxItems": 48,
"title": "Children",
"type": "array"
},
"props": {
"additionalProperties": true,
"title": "Props",
"type": "object"
},
"type": {
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"props",
"children"
],
"title": "AnswerUiElement",
"type": "object"
},
"AnswerUiSpec": {
"additionalProperties": false,
"properties": {
"elements": {
"patternProperties": {
"^[a-zA-Z0-9_-]{1,80}$": {
"$ref": "#/components/schemas/AnswerUiElement"
}
},
"title": "Elements",
"type": "object"
},
"root": {
"pattern": "^[a-zA-Z0-9_-]{1,80}$",
"title": "Root",
"type": "string"
}
},
"required": [
"root",
"elements"
],
"title": "AnswerUiSpec",
"type": "object"
},
"AuditLogResponse": {
"description": "审计日志响应",
"properties": {
@@ -3681,6 +3784,14 @@
"minLength": 1,
"title": "Title",
"type": "string"
},
"ui_blocks": {
"items": {
"$ref": "#/components/schemas/ReportUiBlock"
},
"maxItems": 8,
"title": "Ui Blocks",
"type": "array"
}
},
"required": [
@@ -3778,6 +3889,14 @@
"minLength": 1,
"title": "Title",
"type": "string"
},
"ui_blocks": {
"items": {
"$ref": "#/components/schemas/ReportUiBlock"
},
"maxItems": 8,
"title": "Ui Blocks",
"type": "array"
}
},
"required": [
@@ -5923,6 +6042,39 @@
"title": "Page[str]",
"type": "object"
},
"PlaceSearchConfig": {
"additionalProperties": false,
"properties": {
"admin_code": {
"pattern": "^(?:156)?[0-9]{6}$",
"title": "Admin Code",
"type": "string"
},
"enabled": {
"default": false,
"title": "Enabled",
"type": "boolean"
},
"provider": {
"const": "tianditu",
"default": "tianditu",
"title": "Provider",
"type": "string"
},
"region": {
"maxLength": 100,
"minLength": 1,
"title": "Region",
"type": "string"
}
},
"required": [
"admin_code",
"region"
],
"title": "PlaceSearchConfig",
"type": "object"
},
"PlaceSearchItem": {
"properties": {
"address": {
@@ -5941,8 +6093,7 @@
"title": "Category"
},
"district": {
"const": "奉贤区",
"default": "奉贤区",
"minLength": 1,
"title": "District",
"type": "string"
},
@@ -5974,7 +6125,8 @@
"name",
"address",
"longitude",
"latitude"
"latitude",
"district"
],
"title": "PlaceSearchItem",
"type": "object"
@@ -6006,8 +6158,7 @@
"type": "array"
},
"region": {
"const": "奉贤区",
"default": "奉贤区",
"minLength": 1,
"title": "Region",
"type": "string"
},
@@ -6025,7 +6176,8 @@
},
"required": [
"items",
"total"
"total",
"region"
],
"title": "PlaceSearchResponse",
"type": "object"
@@ -6300,6 +6452,23 @@
"title": "ProjectDatabaseUpsertRequest",
"type": "object"
},
"ProjectMapConfig": {
"additionalProperties": true,
"properties": {
"place_search": {
"anyOf": [
{
"$ref": "#/components/schemas/PlaceSearchConfig"
},
{
"type": "null"
}
]
}
},
"title": "ProjectMapConfig",
"type": "object"
},
"ProjectMemberCreateRequest": {
"properties": {
"project_role": {
@@ -6407,6 +6576,9 @@
"title": "Gs Workspace",
"type": "string"
},
"map_config": {
"$ref": "#/components/schemas/ProjectMapConfig"
},
"map_extent": {
"anyOf": [
{
@@ -6774,6 +6946,54 @@
"title": "RecoverExpiredLeasesResult",
"type": "object"
},
"ReportUiBlock": {
"additionalProperties": false,
"properties": {
"block": {
"$ref": "#/components/schemas/AnswerUiBlock"
},
"evidence_refs": {
"items": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"maxItems": 100,
"minItems": 1,
"title": "Evidence Refs",
"type": "array"
}
},
"required": [
"block",
"evidence_refs"
],
"title": "ReportUiBlock",
"type": "object"
},
"ReverseGeocodingRequest": {
"additionalProperties": false,
"properties": {
"latitude": {
"maximum": 90.0,
"minimum": -90.0,
"title": "Latitude",
"type": "number"
},
"longitude": {
"maximum": 180.0,
"minimum": -180.0,
"title": "Longitude",
"type": "number"
}
},
"required": [
"longitude",
"latitude"
],
"title": "ReverseGeocodingRequest",
"type": "object"
},
"RunSimulationManuallyByDateRest": {
"properties": {
"duration": {
@@ -6917,6 +7137,130 @@
"title": "ScadaDeviceResponse",
"type": "object"
},
"ScadaLatestReadingResponse": {
"additionalProperties": false,
"description": "Latest persisted observation for one project SCADA device.",
"properties": {
"cleaned_value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cleaned Value"
},
"device_id": {
"title": "Device Id",
"type": "string"
},
"device_type": {
"title": "Device Type",
"type": "string"
},
"measurement_unit": {
"title": "Measurement Unit",
"type": "string"
},
"monitored_value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Monitored Value"
},
"observed_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Observed At"
},
"transmission_frequency": {
"title": "Transmission Frequency",
"type": "string"
},
"transmission_mode": {
"title": "Transmission Mode",
"type": "string"
}
},
"required": [
"device_id",
"device_type",
"measurement_unit",
"transmission_mode",
"transmission_frequency"
],
"title": "ScadaLatestReadingResponse",
"type": "object"
},
"ScadaLatestReadingsResponse": {
"additionalProperties": false,
"description": "Bounded device page with each device's latest persisted observation.",
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/ScadaLatestReadingResponse"
},
"title": "Items",
"type": "array"
},
"latest_observed_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Latest Observed At"
},
"limit": {
"maximum": 500.0,
"minimum": 1.0,
"title": "Limit",
"type": "integer"
},
"observed_count": {
"minimum": 0.0,
"title": "Observed Count",
"type": "integer"
},
"offset": {
"minimum": 0.0,
"title": "Offset",
"type": "integer"
},
"total": {
"minimum": 0.0,
"title": "Total",
"type": "integer"
}
},
"required": [
"items",
"total",
"limit",
"offset",
"observed_count"
],
"title": "ScadaLatestReadingsResponse",
"type": "object"
},
"ScadaReadingBatchItem": {
"properties": {
"cleaned_value": {
@@ -7838,6 +8182,39 @@
},
{
"enum": [
"get_mutation_status",
"query_nearby_elements",
"list_regions",
"get_region",
"get_region_nodes",
"get_node_connections",
"query_network_extent",
"list_major_nodes",
"get_model_configuration",
"list_model_configurations",
"search_project_places",
"reverse_geocode",
"get_project_info",
"export_project_model",
"get_sensor_placement_candidate",
"export_sensor_placement",
"list_task_schedules",
"get_task_run_history",
"calculate_demand_allocation",
"run_burst_simulation",
"edit_model_element",
"edit_model_configuration",
"clean_scada_readings",
"write_scada_readings",
"delete_scada_readings",
"save_simulation_scenario",
"create_sensor_placement",
"update_sensor_placement",
"create_operational_task",
"create_task_schedule",
"update_task_schedule",
"retry_operational_task",
"cancel_task_run",
"run_simulation",
"run_contaminant_simulation",
"analyze_valve_closure",
@@ -33926,7 +34303,7 @@
},
"/api/v1/place-search-requests": {
"post": {
"description": "调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。",
"description": "调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。",
"operationId": "post_place_search_requests",
"parameters": [
{
@@ -34026,7 +34403,7 @@
"OAuth2PasswordBearer": []
}
],
"summary": "Search Fengxian places",
"summary": "Search project places",
"tags": [
"Place Search"
]
@@ -39027,6 +39404,114 @@
]
}
},
"/api/v1/reverse-geocoding-requests": {
"post": {
"description": "查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。",
"operationId": "post_reverse_geocoding_requests",
"parameters": [
{
"in": "header",
"name": "X-Project-Id",
"required": true,
"schema": {
"title": "X-Project-Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReverseGeocodingRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlaceSearchItem"
}
}
},
"description": "Successful Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Authentication required"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Insufficient permission"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource not found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource conflict"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Validation error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Dependency unavailable"
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
],
"summary": "Reverse geocode a project map point",
"tags": [
"Place Search"
]
}
},
"/api/v1/rule-properties": {
"get": {
"description": "获取指定网络中的规则属性信息",
@@ -47653,6 +48138,131 @@
]
}
},
"/api/v1/timeseries/scada-readings/latest": {
"get": {
"description": "Read one latest persisted row per project device without applying a time window.",
"operationId": "get_timeseries_scada_readings_latest",
"parameters": [
{
"description": "返回设备数上限",
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 100,
"description": "返回设备数上限",
"maximum": 500,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"description": "设备目录偏移量",
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"description": "设备目录偏移量",
"minimum": 0,
"title": "Offset",
"type": "integer"
}
},
{
"in": "header",
"name": "X-Project-Id",
"required": true,
"schema": {
"title": "X-Project-Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScadaLatestReadingsResponse"
}
}
},
"description": "Successful Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Authentication required"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Insufficient permission"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource not found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource conflict"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Validation error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Dependency unavailable"
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
],
"summary": "获取SCADA设备最新数据",
"tags": [
"TimescaleDB - SCADA"
]
}
},
"/api/v1/timeseries/scada-readings/{device_id}/field": {
"patch": {
"description": "更新指定设备的字段值\n\n更新SCADA设备在特定时间的某个字段监测数据。\n\nArgs:\n device_id: 设备ID\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误",
+65
View File
@@ -0,0 +1,65 @@
# 凤阳六月爆管模拟与 SCADA 复制
2026-10-22 已完成活动项目 `fengyang_v2`
(`b962c44e-8433-4a10-81e9-a64b70ac1152`)的三段模拟爆管数据及前一天基线复制。
一次性复制脚本及其专属测试已清理,来源、核对结果和备份记录如下:
| 方案名称 | 模型管段 | 模型管径 |
| --- | --- | --- |
| `2026-06-02 Pipe19026 DN200 burst` | `19026` | 200 mm |
| `2026-06-12 Pipe18751 DN160 burst` | `18751` | 160 mm |
| `2026-06-22 Pipe18750 DN160 burst` | `18750` | 160 mm |
方案名称、`burst_ID` 和 `network.pipes.diameter` 必须一致。时间使用方案的
`started_at` 和结果时间戳,历时为 4 小时,包含起止点,15 分钟一个采样。
不使用项目说明中的历史时间窗,也不使用旧的 `5hrs burst scheme` 数据。
源数据为 `analysis.node_results.pressure`,仅复制既有 50 个压力设备
`asset.scada_devices.node_id` 对应的节点。每段必须完整覆盖 17 个时间点,
即 850 条观测,三段共 2,550 条;方案状态必须为 `completed`。
压力源单位来自每个方案的 `parameters.result_units.pressure`,目标单位来自
设备的 `measurement_unit`,并用总水头减节点高程核对压力单位。缺失水头、
高程或单位不一致时拒绝写入。例如真正的 kPa 转米水头使用 `pressure / 9.80665`。
不添加噪声、不平移日期、不插值。写入 `scada.measurements.monitored_value`,
同时将窗口内对应观测的 `cleaned_value` 清空,避免使用旧观测的清洗结果。
其他日期、其他设备及模拟源数据保持原样;原有其他模拟事件也保留。
正常基线直接读取 `realtime.node_results`,保留其原始日期和压力值。每段在线
模拟采用右端不含的时间窗,4 小时共有 16 个采样点,最后一个点比窗口终点
早 15 分钟。三段共 2,400 条观测。基线源单位来自模型配置,并同样校验
总水头减高程;不从爆管结果或旧 SCADA 推导正常值。
| 管段 | 爆管数据时间(北京时间,含末点) | 前一天基线采样时间(北京时间) |
| --- | --- | --- |
| `19026` DN200 | 6 月 2 日 08:00–12:00 | 6 月 1 日 08:00–11:45 |
| `18751` DN160 | 6 月 12 日 12:00–16:00 | 6 月 11 日 12:00–15:45 |
| `18750` DN160 | 6 月 22 日 18:00–22:00 | 6 月 21 日 18:00–21:45 |
定位算法配对比较时应采用共同的 16 个相对采样时刻,或明确使用右端不含的
4 小时时间窗,避免将爆管结果额外的终点与不存在的基线终点配对。
2026-10-22 核查发现 EPANET 输出解析曾将压力枚举 1、2 的单位名称写反。
已改为 `0=PSI, 1=KPA, 2=MTR`,并通过本地原生 EPANET 输出测试验证。
这三个方案的全部 2,550 个压力值都与米制水头减高程一致,元数据已纠正为
`MTR`,原始模拟数值保持不变。SCADA 最终使用设备的 `m` 单位,无额外换算。
更正前的三个方案参数与核查证据保存在忽略目录
`data/fengyang-result-unit-correction/`。
本次最终复制记录为
`data/fengyang-burst-scada/20261022T101442415337Z/report.json`:
2,550 条爆管观测与 2,400 条前一天基线,共替换 4,950 条观测;SCADA 整表
仍为 144,000 条、50 个设备。窗口外指纹不变,独立 SQL 比对六段数据与源值
差异为零,重复只读预览显示 4,950 条均已一致。三个方案均为 `completed`。
本次执行前已锁定观测表并备份整表至忽略目录
`data/fengyang-burst-scada/<UTC时间>/measurements-before.csv`,同时保存
`prepared.json`(来源、设备映射、备份校验和)和 `expected.json`(预期观测)。
目录权限为 0700,文件权限为 0600,不包含数据库凭据。
本次复制在一个事务中完成,逐条比对预期值、核对总行数、校验窗口外数据
指纹不变。提交后使用新连接再次读回校验,已保存 `report.json`。
如需恢复,先核对对应报告的备份校验和,将 CSV 导入临时表,再仅恢复
`expected.json` 中的 `(time, device_id)` 键;不要整表覆盖当前观测。
数据库读回校验不代表已经完成 HTTP、界面回放或爆管定位算法验收。
+2 -2
View File
@@ -1,6 +1,6 @@
# Agent 业务分析 MCP
Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共35项:25项查询、下表10项分析;合并6项宿主查询后共41项。工具参数来自Server实际MCP schema,不维护第二套参数契约。
Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42项查询、12项计算、13项预览式写入;合并6项宿主查询后共73项。下表保留原10项分析,新增服务及边界见 [MCP项目服务](mcp-project-services.md)。工具参数来自Server实际MCP schema,不维护第二套参数契约。
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
| --- | --- | --- |
@@ -54,7 +54,7 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共35项:25
## 资源边界与发布
10项计算工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。
12项计算和13项预览式写入工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。
例如 `duration=3600, timeout_seconds=900` 表示模拟1小时、允许等待15分钟。Agent的上游HTTP/SDK预算为所选预算加5秒,整个宿主调用另留60秒处理认证、上下文绑定和清理;OpenCode连接上限从工具目录计算并写入宿主连接文件。本地MCP请求关闭Bun空闲断连,仍受上述调用截止时间控制。普通查询继续使用原30秒Server预算与35秒宿主预算。
+65
View File
@@ -0,0 +1,65 @@
# MCP 项目服务
当前 Server 有 67 项工具:42 项查询、12 项计算、13 项预览式写入。Agent 合并六项宿主查询后共 73 项。目录来自 `app/mcp/catalog.py`,Agent 使用独立的有限白名单并在启动时全等核对。工具前缀为 `mcp_tjwater_`。
## 新增能力
| 领域 | 工具(省略前缀) | 权限 |
| --- | --- | --- |
| 区域与连接 | `list_regions`, `get_region`, `get_region_nodes`, `get_node_connections` | `webgis.view` |
| 空间范围与骨干节点 | `query_network_extent`, `list_major_nodes` | `webgis.view` |
| 模型配置 | `get_model_configuration`, `list_model_configurations` | `webgis.view` |
| 项目地名 | `search_project_places`, `reverse_geocode` | `webgis.view` |
| 项目概况与导出 | `get_project_info`, `export_project_model` | `webgis.view` |
| 监测点交付 | `get_sensor_placement_candidate`, `export_sensor_placement` | `optimization.run` |
| 调度与运行历史 | `list_task_schedules`, `get_task_run_history` | `operational_task.view` |
| 需水量分配 | `calculate_demand_allocation` | `webgis.view` |
| 假设爆管水力模拟 | `run_burst_simulation` | `simulation.run` + `scada.view` |
| 模型编辑 | `edit_model_element`, `edit_model_configuration` | `webgis.edit` |
| SCADA 维护 | `clean_scada_readings`, `write_scada_readings`, `delete_scada_readings` | `scada.clean` |
| 正式分析成果 | `save_simulation_scenario` | `simulation.run` + `scada.view` |
| 正式监测点方案 | `create_sensor_placement`, `update_sensor_placement` | `optimization.run` |
| 任务与定时配置 | `create_operational_task`, `create_task_schedule`, `update_task_schedule`, `retry_operational_task`, `cancel_task_run` | `operational_task.write` |
| 写入状态核实 | `get_mutation_status` | `webgis.view`,仅当前身份与会话的执行记录 |
健康预测不在本次接入范围。报告发布、工单/设备动作的提议与审批、消息发送继续使用既有受控插件和业务动作流程。
## 数据与计算边界
- 所有查询使用当前授权项目连接。当前只有 TJWater 提供 GIS 信息;其他项目没有所需 GIS 数据或关系时返回空数据/明确不可用,不借用 TJWater 的图层或数据库。空页不能证明其他数据源没有对象。
- 范围查询直接用 PostGIS `ST_Intersects`,不调用旧的空占位服务。范围和输出坐标为 EPSG:4326,单轴范围最多一度。查询模型几何,不把空间邻近称为供水连通。
- 区域成员优先使用已配置节点关系,未配置时使用区域几何;配置、几何成员与真实供水拓扑需分别解释。
- 模型配置 `data_path` 选择嵌套字段,`limit/offset` 对选中集合分页。模式和曲线可单独列出 ID。编辑字段必须通过现有模型 schema 的可写属性校验;有限业务枚举不接受 SQL、任意路由、命令、身份、数据库地址或文件路径。
- 需水量分配按关联管长权重,只返回建议值,不修改节点需水量;输入输出沿用模型流量单位。区域或全网结果按节点 ID 稳定分页。
- 爆管模拟输入是已说明依据的破口面积(cm²),不是流量。复用隔离模型,`persist_results=False`;这与异常侦测和候选定位是不同工具。
- 保存正式场景会重新执行真实计算并持久化成果。压力调节和关阀正式成果使用宿主绑定的用户名。计算共用原有两项并发槽位,超时后槽位保留至原生线程退出。
- 新建任务限未绑定 Agent 的 operator/agent 事件调查;不能伪造外部工单或租约。重试只重新入队,实际执行仍由原有 Worker 领取。取消、启停和方案点位更新复用现有版本或旧值校验。
## 写入预览和原生许可
13 项新写入工具只对项目成员的 `ordinary_chat` 开放。任务执行、任务协作、只读 reader、个人会话和学习复盘均不能调用。每次校验当前权限及绑定时的权限上限;写入工具不加入只读自动许可名单,管理员拒绝继续优先。交互聊天遵循现有本轮 request/always 策略,always 不改变业务权限。
1. 为具体操作生成 UUID `request_id`,默认 `dry_run=true`,取得预览与 `expected_digest`。预览不运行模拟/优化,也不写业务数据。
2. 保持相同参数和 `request_id`,使用 `dry_run=false` 及返回的 `expected_digest` 请求执行,并经过宿主原生许可。
3. 服务重新读取目标并校验摘要。模型预览复核与修改共用模型变更锁;SCADA 操作在时序库串行化事务中复核相关数据。数据变化返回冲突,须重新预览。
4. 执行前在现有 `workbench.mcp_query_receipts` 保存 started 记录,完成后保存结果。同一会话、身份、项目和请求键的完成操作返回原结果,已启动但状态不明确的操作不会再次执行。可用 `get_mutation_status` 核实记录,并通过目标查询核实实际数据。超时/进程退出可能已有部分结果,不能解释为回滚,也不能自动换键重试。
计算容量已满且原生函数尚未调用时,返回 `CALCULATION_CAPACITY_EXCEEDED`。
这类明确未执行的请求记为 `rejected`、`retryable=true`;状态查询同时返回具体
错误。容量释放后可用原身份、会话、项目、参数和 `request_id` 重试,仍需通过
原生许可和预览摘要复核。重试时继续锁定请求记录,成功后的重复调用只返回
已保存结果。其他 503、算法异常、超时或取消仍保留 `started`,不能据此自动重试。
SCADA 维护必须明确列出设备,窗口最多 24 小时。清洗/删除最多 10000 条,补录/修正最多 500 条,来源依据必填;不开放 `all`。数据写入清洗值或实测表,不从模拟结果构造实测。涉及 BizDB 和 TimescaleDB 时不承诺跨库事务原子性,执行日志中的 started 与业务修改之间也不承诺分布式 exactly-once。
## 导出与会话下载
`export_project_model` 从数据库导出 ZIP(INP 或 ChangeSet,含顶点和 SCADA 元素),`export_sensor_placement` 导出已有方案 XLSX。单文件最多 5 MiB;模型通过 ZIP 压缩传输。Server 返回长度及 SHA-256;Agent 验证后只写当前会话 `workspace/files/`,返回相对路径和下载链接,模型消息不包含文件正文或宿主绝对路径。导出暂不支持超过该预算的文件分片。
下载接口为 Agent `GET /api/v1/session/{session_id}/files/{filename}`。它复用身份与项目校验,验证会话所有权,仅接受服务生成的文件名格式,拒绝符号链接和跨会话目录。前端通过已认证的 Agent client 下载;链接和磁盘文件均不是公开资源。文件随会话持久化和删除,不引入独立结果引用存储。
无需数据库迁移、新环境变量或新密钥。地名搜索沿用项目配置与已有天地图服务凭据。需同步发布 Server、Agent、前端,再重载 Server 与 Agent 工具目录;不要以代码目录一致代替线上重载验收。
## 验证
运行 focused pytest、Agent MCP/策略/导出测试及类型检查、前端协议与下载测试、浏览器下载覆盖及构建。另用隔离 MCP fixture 验证两端 SDK 目录、上下文绑定和原生工具调用。测试不运行真实项目模拟、优化、SCADA 清洗或任何业务写入;真实项目运算与数据效果仍须按项目验收。
+4 -4
View File
@@ -1,6 +1,6 @@
# 产品 MCP 查询:本地实施与启用
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和35项工具(25项查询、10项受控分析);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享25项查询;10项分析仅向非reader会话开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和67项工具(42项查询、12项计算、13项预览式写入);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)。
@@ -76,11 +76,11 @@ TJWATER_MCP_CLIENT_SECRET=<由身份平台注入>
安装 Agent 更新的 Bun 依赖。本地 MCP 使用官方 Server SDK,独立监听127.0.0.1临时端口,先于原生工具发现就绪。宿主只把公开端点写入 `SESSION_METADATA_STORAGE_DIR` 的兄弟目录 `mcp-query/connection.json`,目录0700、文件0600;不保存令牌或 client secret。embedded 自动注入连接路径,OpenCode 环境仍剔除全部 `TJWATER_MCP_*`。client 模式须同网络空间、同版本可信插件、同一内部密钥,并共享 `TJWATER_AGENT_MCP_CONNECTION_PATH` 指向的连接文件,Agent 重启后同步重载原生连接。默认路径 `data/mcp-query/connection.json`。不要把 OAuth 配置注入外部 OpenCode。
项目及宿主只读工具统一由 MCP 提供,插件只配置原生连接与权限;额外六项是地理编码、会话搜索、调查任务列表与详情、消息读取、待办读取,合并后共41项。公开网页搜索使用 OpenCode 原生 `websearch`,搜索服务及凭据由 OpenCode 管理;Server 的 Bocha 接口、配置和 MCP 搜索入口已移除。`memory_manager` 和 `skill_manager` 保留完整读写插件,写入、展示工具保持插件形式。旧项目和任务查询工具及其内部 HTTP 查询桥已移除;未启用产品 MCP 时项目数据不可用,不回退。
项目及宿主只读工具统一由 MCP 提供,插件只配置原生连接与权限;额外六项是地理编码、会话搜索、调查任务列表与详情、消息读取、待办读取,合并后共73项。公开网页搜索使用 OpenCode 原生 `websearch`,搜索服务及凭据由 OpenCode 管理;Server 的 Bocha 接口、配置和 MCP 搜索入口已移除。`memory_manager` 和 `skill_manager` 保留完整读写插件,报告/消息/现场业务动作与展示工具保持插件形式;新增模型和数据维护写入通过预览式MCP提供。旧项目和任务查询工具及其内部 HTTP 查询桥已移除;未启用产品 MCP 时项目数据不可用,不回退。
原生调用的 `_meta["ai.opencode/sessionID"]` 仅用于关联宿主会话,本地服务先核验内部密钥和本机来源,再核验所有权及直接父子关系。原生工具发现、权限、元数据传递与待办 RPC 已由 `bun run tests/integration/query-mcp-smoke.ts` 验证,不调用模型。任务上下文保留可选 `report_revision`,缺省使用协作会话绑定修订号。隔离启动器显式设置自己的 `TJWATER_AGENT_MCP_CONNECTION_PATH`,两端共同读取该文件,不继承其他实例的连接文件。
6. 先用测试项目分别验证普通项目会话、worker 调查和直接 reader 子会话,再开启生产。Agent 启动先发现 Server 目录并与宿主35项白名单对齐,再验证 OpenCode 的真实工具 ID;失败不进入 ready。切换开关需重启 Agent。Server 未迁移项目的绑定接口返回503。
6. 先用测试项目分别验证普通项目会话、worker 调查和直接 reader 子会话,再开启生产。Agent 启动先发现 Server 目录并与宿主67项白名单对齐,再验证 OpenCode 的真实工具 ID;失败不进入 ready。切换开关需重启 Agent。Server 未迁移项目的绑定接口返回503。
client_credentials 与 token exchange 均发送标准 `resource=TJWATER_MCP_RESOURCE_URI`;exchange 同时保留配置的 audience。部署前必须验证身份平台接受该参数,且两种 grant 都将规范资源映射到 Server 要求的专用 audience。若所用 Keycloak 拒绝该参数,会报告配置不兼容,需升级或提供兼容适配;代码不会移除 resource 后重试。目录的服务 grant 与用户/worker 的 exchange 均不会退回共享管理员 Bearer或直接转发产品 Bearer给MCP。[Keycloak 官方说明](https://www.keycloak.org/securing-apps/token-exchange)。外部交互客户端的授权码流程和第三方客户端绑定不包含在本次交付中。
@@ -103,6 +103,6 @@ bun test tests/product/mcpAuthDiscovery.test.ts tests/product/mcpQueryClient.tes
MCP_TEST_PYTHON=<隔离Server环境Python> MCP_TEST_SERVER_ROOT=<Server仓库绝对路径> bun tests/integration/mcp-query-smoke.ts
```
HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、35项工具、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。
HTTP smoke 只启动自己的 loopback fixture,使用真实两端 SDK,无生产数据库、Keycloak或模型推理。它验证新版发现、真实资源元数据 HTTP 读取、授权服务器元数据及带 resource 的两种 grant(后两者为桩)、67项工具、主机绑定、查询及撤销;真实环境验收记录见上方实际运行环境一节,不能用 fixture 结果代替。前端严格解析已接受这些 MCP 快照来源,未加入浏览器 MCP 凭据或新 UI。
查询限制回归使用 `python -m pytest tests/unit/test_mcp_query_limits.py tests/unit/test_mcp_query.py tests/unit/test_timeseries_history.py tests/unit/test_timeseries_history_schema.py -q`。实际 PostgreSQL SQL 验证可设置 `MCP_QUERY_TEST_DSN` 后运行 `python -m pytest tests/integration/test_mcp_query_limits_postgres.py -q`;该 DSN 必须指向空的、可丢弃的独立数据库,测试创建临时业务同名 schema 并在事务结束时回滚。它验证数据点跨序列翻页、证据路径/Unicode分页、任务摘要与指定报告、章节稳定路径、项目/任务隔离、既有scope边界、字节门槛和合成摘要,不代替真实项目验收。
+4 -1
View File
@@ -28,7 +28,8 @@ conda run -n server python scripts/run_operational_worker.py
| 变量 | 用途 |
| --- | --- |
| `OPERATIONAL_WORKER_PROJECT_ID` | 唯一授权项目 UUID |
| `OPERATIONAL_WORKER_PROJECT_ID` | 单项目模式的授权项目 UUID |
| `OPERATIONAL_WORKER_PROJECT_IDS` | 可选,显式授权项目 UUID 列表,逗号分隔;非空时优先于单项目配置,同步器和调查 worker 均按列表运行 |
| `OPERATIONAL_WORKER_SERVER_URL` | 主后端地址,默认本机 8000 |
| `OPERATIONAL_WORKER_AGENT_URL` | Agent 地址,默认本机 8787 |
| `OPERATIONAL_WORKER_RECONCILE_SECONDS` | SSE 中断或漏通知时的对账间隔,默认 30 秒 |
@@ -42,6 +43,8 @@ conda run -n server python scripts/run_operational_worker.py
工作身份必须能通过现有产品身份与项目成员权限校验。配置令牌获取不等于自动创建产品用户、项目成员或授权。权限错误会保留任务用于后续重试,不会绕过认证。
多项目模式为每个项目维护独立事件游标、会话、消息游标及 OAuth 缓存,单项目暂时失败不阻止其他项目对账。`OPERATIONAL_WORKER_MAX_CONCURRENCY` 是每个项目的上限,总上限随显式配置的项目数增加;需要控制整体模型用量时相应调低。进程不自动枚举所有项目或扩大服务身份权限。修改项目列表后需同时重启 `tjwater-operational-worker.service` 和 `tjwater-peripheral-sync.service`。
调查数据读取沿用账号已有的项目权限,不再因 `service` 身份额外移除 `scada.view` 和
`simulation.view`。上下文的 `approved_data_scopes` 与数据查询采用同一权限集合;已有读取
权限无需逐任务数据审批或委托凭据。此规则不新增角色权限、不放开跨项目读取,也不改变
+24
View File
@@ -4,6 +4,30 @@
浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接样板服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。
生产环境不需要为外围服务新增域名。前端设置
`TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`,在现有前端 NPMplus Proxy Host 中增加以下
Custom Location:
| 配置项 | 值 |
| --- | --- |
| Location | `/peripheral/`,必须保留末尾斜杠 |
| Scheme | `http` |
| Forward Hostname / IP | NPMplus 能访问的应用机地址,通常与现有前端上游相同 |
| Forward Port | `8011` |
| Forward Path | 留空 |
NPMplus 会去掉 `/peripheral/` 前缀,因此浏览器请求
`/peripheral/api/sample/v1/context` 时,上游收到 `/api/sample/v1/context`。不要在 Forward
Hostname 后再追加 `/api/sample/v1`。外围服务应只向 NPMplus 所在网络开放 `8011`,不要直接开放公网。
本机开发和预览模式也提供等价的 `/peripheral` 代理,可通过
`TJWATER_PERIPHERAL_SAMPLE_UPSTREAM` 覆盖默认上游 `http://127.0.0.1:8011`。
外围 API 支持已授权项目切换。每次请求先由主系统验证项目成员身份和权限,再从 Metadata DB 解析该项目的 `biz_data` 路由;历史观测另解析 `iot_data` 路由。逻辑项目代码、业务物理库名和时序物理库名可以不同,连接沿用各自完整 DSN。路由缺失返回 `503 PROJECT_STORAGE_NOT_CONFIGURED`,不会回退到其他项目数据库。未配置存储表的项目仍需单独完成结构部署;API 不自动创建表或复制演示数据。
`PERIPHERAL_SAMPLE_PROJECT_CODE` 和 `PERIPHERAL_SAMPLE_DB_NAME` 仅供显式运行的种子、导出等脚本选择默认库,不再限制 HTTP 请求。工单变更在当前项目已有 `workbench.domain_event_log` 时于同一事务发布事件;未配置事件表时不自动建表,实时同步仍需项目工作台事件结构。
后台同步器和调查 worker 支持 `OPERATIONAL_WORKER_PROJECT_IDS`(项目 UUID,逗号分隔),每个项目维护独立凭据、事件游标和会话状态;未配置时继续使用原 `OPERATIONAL_WORKER_PROJECT_ID`。多项目配置只选择运行范围,不新增项目成员权限,服务身份必须已获各项目授权。修改后需重启两个常驻进程,参见 [工作进程配置](operational-task-workers.md)。
主要接口包括:
- `/api/sample/v1/context` 与 `/scenario`
+109
View File
@@ -0,0 +1,109 @@
# 项目地名搜索配置
地名搜索的项目配置保存在 Metadata DB 的 `public.projects.map_config.place_search`。
复用现有 JSONB 字段,不向各项目 BizDB 或 TimescaleDB 新增表。后端天地图 Key
仍通过 `TIANDITU_TOKEN` 提供,不写入项目地图配置或返回浏览器。
```json
{
"place_search": {
"enabled": true,
"provider": "tianditu",
"admin_code": "341126",
"region": "凤阳县"
}
}
```
`admin_code` 接受六位行政区编码或带 `156` 前缀的天地图编码。省级编码以
`0000` 结尾,市级编码以 `00` 结尾,其余按区县校验。`region` 是配置范围的
显示名称;区县级配置须与天地图的区县名称一致。市级搜索可返回不同区县的
候选,候选的 `district` 保留实际区县,响应的 `region` 返回配置范围。
配置缺失、关闭或无效时前端禁用查询;后端拒绝未配置项目的搜索请求。
前端启用条件、区域文案及结果校验均来自项目元数据,不再判断 GeoServer
工作空间。后端按经授权的 `X-Project-Id` 读取配置,搜索请求只接受 `keyword`,
调用方不能覆盖项目区域或分页限制。每次搜索最多返回 10 个候选。
## 管理接口
具备现有管理员权限的调用方可使用 `PATCH /api/v1/admin/projects/{project_id}`:
```json
{
"map_config": {
"place_search": {
"enabled": true,
"provider": "tianditu",
"admin_code": "310000",
"region": "上海市"
}
}
}
```
更新会合并 `map_config` 的顶层配置项,保留已有 `legend`、`coordinate_reference`
等字段。`place_search` 作为完整配置项替换;传 `null` 可清除此项,
`map_config` 自身不能为 `null`。项目元数据和管理接口均返回 `map_config`。
修改配置后刷新工作台以重新读取。
初始配置使用的一次性脚本已清理。后续通过上述管理接口维护项目配置;
初次写入前的逐项目行备份保存在忽略目录
`data/project-map-config/<project-id>-<timestamp>/before.json`。
## 2026-10-21 实际配置
| 项目代码 | 搜索范围 | 行政区编码 |
| --- | --- | --- |
| `tjwater_v2`、`tjwater` | 奉贤区 | `310120` |
| `fengyang_v2`、`fengyang` | 凤阳县 | `341126` |
| `md_v2`、`md` | 吴中区 | `320506` |
| `szh_v2`(苏州河) | 上海市 | `310000` |
| `zjb` | 湛江市 | `440800` |
共配置 8 条项目记录;3 个旧项目仍为 inactive。SZH 的 8 个实际模型节点经天地图
逆地理编码落在松江、青浦、闵行,因此采用上海市范围;木渎采用吴中区,湛江项目
采用市级范围,避免将近似配准的工程坐标当作区县边界依据。
配置前已备份完整元数据库并通过 `pg_restore --list` 校验:
`data/project-map-config/metadata-before-20261021T080257Z/system_hub.dump`。
SHA-256:`77dc13f874f81699a96d4584ac1f67f1b2048fe7c68175be8154c5e277c8689f`。
同时保留逐项目的行备份,核对除 `map_config` 与 `updated_at` 外所有字段保持原值,
已有图例、坐标参考和项目状态均保留。数据库字段注释同步为当前配置用途。
验证涵盖后端服务/接口/元数据配置、前端单元测试、奉贤桌面/窄屏搜索、凤阳项目
切换后搜索定位及未配置禁用。5 个活动项目均通过真实天地图搜索;浏览器测试使用
隔离接口数据,不代表部署环境的真实身份验收。Server OpenAPI 与供水 Agent 的
类型快照同步更新。配置已写入数据库,代码未提交、未推送或部署。
## 地图取点反编码
地址定位面板提供“地图取点”按钮。启用后点击地图查询该点附近的地名和完整地址,
首次查询成功后自动结束取点并保留结果和地图标记;失败时可重新取点。“结束取点”、
Escape、关闭工具或切换项目也会退出取点模式。
取点时暂停管网要素选择和绘图标注选择,并停止当前测量、绘图,退出后恢复常规选择。
后端接口为 `POST /api/v1/reverse-geocoding-requests`:
```json
{"longitude": 117.570309, "latitude": 32.868029}
```
坐标采用 EPSG:4326,复用授权项目的 `map_config.place_search` 和服务端
`TIANDITU_TOKEN`,无需另加数据库配置。返回规范化的 `PlaceSearchItem`,包括
`name`、`address`、`longitude`、`latitude`、`district`、`category` 与 `id`。
坐标始终保留输入的地图取点坐标,附近 POI 的坐标不替代点击位置。新结果显示标记,
不自动移动地图;用户可主动点击“重新定位”。连续取点取消前次请求,过期结果不能
覆盖新点;工具关闭、切换项目和退出取点时也会取消未完成请求。
反编码遵守相同的行政区范围校验:区县范围校验区县,市级范围允许所属不同区县。
范围外返回 422,无可用地址返回 404,服务异常或无效响应返回 502,配置缺失返回
503,超时返回 504。错误信息不包含服务商 Key 或带凭据的请求 URL。
使用天地图逆地理编码服务的 `postStr={"lon":...,"lat":...,"ver":1}` 与
`type=geocode` 参数。5 个活动项目均已用数据库实际配置通过真实接口查询,
并核对输出坐标保持输入值。桌面和 320px 浏览器测试使用隔离接口数据验证交互。
本次最终检查中,现有本地 `127.0.0.1:8000` 实例的 OpenAPI 请求连续两次超时。
真实天地图检查通过独立服务调用完成,浏览器使用隔离 API;既有运行实例的 HTTP
联调尚待该实例恢复响应后核对。本次未重启既有进程。
+18
View File
@@ -4,6 +4,24 @@
## 当前基线
### 2026-10-21 凤阳结构扩展
维护者明确要求将 `tjwater_v2` 的表结构和业务配置复制到 `fengyang_v2`,同时保留凤阳现有管网及数据。本次单独扩展凤阳,不向其他项目批量写入。以下旧迁移状态为当时记录,以本节的实际核对结果为准。
新增 `workbench` 的告警事件、告警状态、任务访问申请、任务进度、Agent 消息共 5 张表,补齐 `peripheral_sample` 的 8 张空表、`source_gis` 的 4 张空表及通用 `gis.water_quality_monitoring_points` 视图。同步主项目的约束、索引与业务表列注释,事件主题约束支持调查进度和展示事件。时序库的应用表结构已经一致,本次未迁移或写入时序数据。
本次迁移已完成,一次性执行脚本已清理。当时先生成差异报告,再做全库备份、核对 `pg_restore --list`、在强制回滚事务中演练,最后单事务应用。固定源、目标物理库为 `tjwater_v2` 和 `fengyang_v2`。生成的迁移 SQL、备份及审计报告保留供追溯;后续结构变更仍按本文门禁单独核对范围。
本次备份与审计记录位于忽略目录 `data/project-rollout/fengyang_v2-20261021T041825Z/`,包含 `before.dump`、`backup.list`、`migration.sql` 和 `report.json`。备份摘要:`f0f68f689239e3fde7fda10b6e7a0cbfdf4f8ee625ab87d3f714162af9391f87`。不提交备份或生成数据。
回滚演练及应用时均逐表核对已有 `network`、`asset`、`gis` 基础表的行数和无序内容摘要。凤阳的 31,143 个节点、31,219 条连接、50 个 SCADA 设备保持原状;未复制奉贤模型、原始 GIS、演示工单、历史结果、项目成员或地图范围。复查差异报告中缺失对象、约束差异和索引差异均为空。
外围 API 现按已验证项目的 Metadata DB 路由选库,不再固定为 `tjwater_v2`。事件生产事务排序锁在当前连接的项目库内生效,不创建数据库对象或业务数据;外围工单仅在该项目已配置事件表时发布事务事件。后台进程支持显式 `OPERATIONAL_WORKER_PROJECT_IDS` 列表,既有部署配置和服务身份成员权限未自动扩展。前端使用选中项目路由;上线后仍需按实际身份完成 Server/Agent 的跨项目联调验收。
已从当前前端配置的 GeoServer 核对两个工作空间的 WFS 数据。凤阳返回 31,137 个 junction、30,016 条 pipe,其余节点和连接类型仍使用凤阳原模型;这些分类数量与模型总节点/连接数量不是同一指标。
若结构迁移失败,事务直接回滚。若提交后的恢复需要使用 `before.dump`,先停止该项目写入并按既有数据库恢复流程处理,不能在仍有新业务写入时直接覆盖恢复。
截至 2026-09-24,`014_operational_tasks.sql` 和 `015_workbench_events_and_task_runs.sql` 已应用到 `tjwater_v2`、`szh_v2`、`md_v2`、`fengyang_v2` 和 `zjb`。这是已有基线,不代表后续迁移可以继续批量应用。
`017_workbench_schema_comments.sql` 只为 `workbench` schema 及其 17 张表和关键列增加数据库可见注释。它先在 `tjwater_v2` 应用;其他项目数据库保持原状,直到 `tjwater_v2` 验证通过并获得明确扩展确认。
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=TJWater API and MCP query service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/NextTJWater/next-tjwater-server
ExecStart=%h/miniconda3/envs/server/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
UMask=0077
[Install]
WantedBy=default.target
@@ -18,12 +18,17 @@ CREATE TABLE IF NOT EXISTS projects (
description TEXT,
gs_workspace VARCHAR(100) UNIQUE NOT NULL,
map_extent JSONB,
map_config JSONB NOT NULL DEFAULT '{}'::jsonb,
status VARCHAR(20) DEFAULT 'active' NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT projects_status_check CHECK (status IN ('active', 'inactive', 'archived'))
);
ALTER TABLE projects ADD COLUMN IF NOT EXISTS map_config JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN projects.map_config IS
'项目级地图配置(JSON);place_search 保存地名搜索启用状态、服务商、行政区编码及区域名称;保留既有图例与坐标参考配置。';
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
DROP TRIGGER IF EXISTS update_projects_updated_at ON projects;
+10 -2
View File
@@ -7,9 +7,13 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.auth.project_dependencies import get_metadata_repository
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from .auth import Identity, identity
from .config import SampleSettings
from .errors import SampleFailure
from .routing import resolve_store_routing
from .store import TRANSITIONS
from .schemas import (
AlertPage, Context, CreateTestDataRecord, CreateWorkOrder,
@@ -20,9 +24,13 @@ from .schemas import (
def store(request: Request):
async def store(request: Request, who: Identity = Depends(identity),
repository: MetadataRepository = Depends(get_metadata_repository)):
from .store import PostgresStore
return PostgresStore(request.app.state.settings)
routing = await resolve_store_routing(
who, repository, timeseries=request.url.path.endswith("/monitoring/observations"),
)
return PostgresStore(request.app.state.settings, routing=routing)
def create_app(settings: SampleSettings | None = None) -> FastAPI:
+5 -3
View File
@@ -13,6 +13,7 @@ class Identity:
project_id: str
user_id: str
can_write: bool
project_code: str = ""
def next_get(request: Request, path: str, params=None):
@@ -42,8 +43,9 @@ def identity(request: Request) -> Identity:
user_id = str(UUID(context["user_id"]))
project_id = str(UUID(context["project_id"]))
ensure(project_id == str(UUID(project)), "PROJECT_MISMATCH", "项目身份不一致", 403)
ensure(context["network"] == request.app.state.settings.project_code,
"PROJECT_NOT_CONFIGURED", "该项目不是本外围样板的数据项目", 403)
project_code = context["network"]
ensure(isinstance(project_code, str) and bool(project_code.strip()),
"INVALID_AUTH_CONTEXT", "Next 项目身份不完整", 502)
permissions = context["permissions"]
ensure(isinstance(permissions, list) and "simulation.view" in permissions,
"PERMISSION_DENIED", "没有样板读取权限", 403)
@@ -51,4 +53,4 @@ def identity(request: Request) -> Identity:
ensure(request.method == "GET" or can_write, "PERMISSION_DENIED", "没有样板修改权限", 403)
except (KeyError, TypeError, ValueError) as exc:
raise SampleFailure("INVALID_AUTH_CONTEXT", "Next 身份响应不完整", 502) from exc
return Identity(project_id, user_id, can_write)
return Identity(project_id, user_id, can_write, project_code)
+36
View File
@@ -0,0 +1,36 @@
"""Resolve storage from authenticated project metadata, never browser input."""
from uuid import UUID
from fastapi import HTTPException
from psycopg import ProgrammingError
from psycopg.conninfo import conninfo_to_dict
from sqlalchemy.exc import SQLAlchemyError
from app.auth.project_dependencies import (
ProjectContext, resolve_project_business_routing, resolve_project_simulation_routing,
)
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import ActiveProjectRouting
from .auth import Identity
from .errors import SampleFailure, ensure
async def resolve_store_routing(
who: Identity, repository: MetadataRepository, *, timeseries: bool = False,
) -> ActiveProjectRouting:
context = ProjectContext(
project_id=UUID(who.project_id), project_code=who.project_code,
user_id=UUID(who.user_id), project_role="",
)
try:
resolver = resolve_project_simulation_routing if timeseries else resolve_project_business_routing
routing = await resolver(context, repository)
dsns = [routing.business_dsn] + ([routing.timescale_dsn] if timeseries else [])
ensure(all(dsn and conninfo_to_dict(dsn).get("dbname") for dsn in dsns),
"PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库名称尚未配置", 503)
return routing
except (HTTPException, SQLAlchemyError, ValueError, ProgrammingError) as exc:
raise SampleFailure(
"PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库路由不可用或尚未配置", 503,
) from exc
+27 -10
View File
@@ -8,11 +8,13 @@ from uuid import UUID, uuid4
import psycopg
from psycopg.rows import dict_row
from psycopg.conninfo import conninfo_to_dict
from psycopg.types.json import Jsonb
from app.domain.schemas.shared import TimeRange
from app.services.content_digest import digest
from app.infra.db.postgresql.workbench_events import lock_event_transaction_sync
from app.infra.db.project_routing import ActiveProjectRouting
from .errors import SampleFailure, ensure
from .auth import Identity
@@ -38,23 +40,37 @@ WORK_ORDER_CREATED_DETAILS = {
class PostgresStore:
def __init__(self, settings: SampleSettings) -> None:
self.settings = settings
def __init__(self, settings: SampleSettings, *, routing: ActiveProjectRouting | None = None) -> None:
self.routing = routing
# Explicit seed/export scripts retain their configured database. HTTP
# dependencies always supply authenticated metadata routing.
self.settings = settings if routing is None else settings.model_copy(update={
"db_name": routing.business_database_name, "project_code": routing.project_code,
})
@contextmanager
def connection(self, *, readonly: bool = False, timeseries: bool = False) -> Iterator[psycopg.Connection]:
from app.core.config import settings as product
ensure(not timeseries or readonly, "READ_ONLY_SOURCE", "历史监测源仅允许只读连接", 403)
prefix = "TIMESCALEDB_DB_" if timeseries else "DB_"
if self.routing is not None:
dsn = self.routing.timescale_dsn if timeseries else self.routing.business_dsn
ensure(bool(dsn), "PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库路由尚未配置", 503)
expected_database = conninfo_to_dict(dsn).get("dbname")
ensure(bool(expected_database), "PROJECT_STORAGE_NOT_CONFIGURED", "项目数据库名称尚未配置", 503)
connect_args = {"conninfo": dsn}
else:
expected_database = self.settings.db_name
connect_args = {"host": getattr(product, prefix + "HOST"), "port": getattr(product, prefix + "PORT"),
"user": getattr(product, prefix + "USER"), "password": getattr(product, prefix + "PASSWORD"),
"dbname": expected_database}
try:
with psycopg.connect(host=getattr(product, prefix + "HOST"), port=getattr(product, prefix + "PORT"),
user=getattr(product, prefix + "USER"), password=getattr(product, prefix + "PASSWORD"),
dbname=self.settings.db_name, connect_timeout=5,
with psycopg.connect(**connect_args, connect_timeout=5,
options="-c statement_timeout=5000", row_factory=dict_row) as conn:
if readonly:
conn.execute("SET TRANSACTION READ ONLY")
actual = conn.execute("SELECT current_database() AS name").fetchone()["name"]
ensure(actual == self.settings.db_name, "DATABASE_MISMATCH", "样板数据库不匹配", 503)
ensure(actual == expected_database, "DATABASE_MISMATCH", "样板数据库不匹配", 503)
yield conn
except (psycopg.errors.UndefinedTable, psycopg.errors.InvalidSchemaName) as exc:
raise SampleFailure("STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503) from exc
@@ -318,9 +334,9 @@ class PostgresStore:
return document
def _work_order_event(self, conn, who: Identity, document: dict[str, Any], *, deleted: bool = False) -> None:
# This rollout is restricted to the provisioned tjwater_v2 event log.
# Task projections remain owned by the Server's authenticated APIs.
if self.settings.db_name != "tjwater_v2":
# Publish only into this project's provisioned event log. Never create
# storage implicitly or write an event into another project's database.
if not conn.execute("SELECT to_regclass('workbench.domain_event_log') AS event_log").fetchone()["event_log"]:
return
metadata = {
"source": "peripheral_sample",
@@ -529,8 +545,9 @@ class PostgresStore:
if scenario == "missing_data":
for item in items[::2]:
item.update(value=None, quality="missing")
source_database = conninfo_to_dict(self.routing.timescale_dsn)["dbname"] if self.routing else self.settings.db_name
return {"origin": "synthetic" if scenario == "missing_data" else "historical_replay",
"source_id": self.settings.db_name + ":scada.measurements", "source_revision": digest(items),
"source_id": source_database + ":scada.measurements", "source_revision": digest(items),
"items": items, "truncated": len(rows) > 1000}
def alerts(self, who: Identity) -> dict[str, Any]:
+47 -28
View File
@@ -12,7 +12,7 @@ import json
import logging
import os
import time
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime
from pathlib import Path
from uuid import UUID, uuid4
@@ -37,11 +37,21 @@ class WorkerSettings:
client_secret: str = ""
token_refresh_seconds: float = 300
task_created_from: str | None = None
project_ids: tuple[str, ...] = ()
def project_scopes(self) -> tuple["WorkerSettings", ...]:
"""Keep credentials, sessions and cursors separate for each explicit project."""
return tuple(replace(self, project_id=project_id, project_ids=())
for project_id in self.project_ids or (self.project_id,))
@classmethod
def from_environment(cls) -> "WorkerSettings":
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)
project_id = str(UUID(os.environ["OPERATIONAL_WORKER_PROJECT_ID"]))
configured_projects = os.environ.get("OPERATIONAL_WORKER_PROJECT_IDS", "").strip()
project_ids = tuple(dict.fromkeys(
str(UUID(value.strip())) for value in configured_projects.split(",")
)) if configured_projects else ()
project_id = project_ids[0] if project_ids else str(UUID(os.environ["OPERATIONAL_WORKER_PROJECT_ID"]))
interval = float(os.environ.get("OPERATIONAL_WORKER_RECONCILE_SECONDS", "30"))
if interval < 1:
raise ValueError("worker interval must be at least one second")
@@ -60,6 +70,7 @@ class WorkerSettings:
client_secret=os.environ.get("OPERATIONAL_WORKER_CLIENT_SECRET", ""),
token_refresh_seconds=float(os.environ.get("OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS", "300")),
task_created_from=os.environ.get("OPERATIONAL_WORKER_TASK_CREATED_FROM") or None,
project_ids=project_ids,
)
if settings.task_created_from:
boundary = datetime.fromisoformat(settings.task_created_from.replace("Z", "+00:00"))
@@ -305,37 +316,45 @@ class OperationalWorker:
await asyncio.sleep(5)
async def run(once: bool) -> None:
settings = WorkerSettings.from_environment()
async def run_project(settings: WorkerSettings, client: httpx.AsyncClient, once: bool) -> None:
logger.info(
"Investigation worker scope: project=%s task_created_from=%s concurrency=%d",
settings.project_id, settings.task_created_from or "all", settings.max_concurrency,
)
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
worker = OperationalWorker(settings, client)
wake = asyncio.Event()
listener = None if once else asyncio.create_task(worker.listen(wake))
try:
while True:
try:
accepted = await worker.tick()
logger.info("Task dispatch tick completed: %d accepted", accepted)
except (httpx.HTTPError, ValueError, KeyError):
# Do not log request headers, response bodies or provider secrets.
logger.error("Task dispatch tick failed; persisted tasks remain available for retry")
if once:
raise SystemExit(1) from None
worker = OperationalWorker(settings, client)
wake = asyncio.Event()
listener = None if once else asyncio.create_task(worker.listen(wake))
try:
while True:
try:
accepted = await worker.tick()
logger.info("Task dispatch tick completed: project=%s accepted=%d", settings.project_id, accepted)
except (httpx.HTTPError, ValueError, KeyError):
logger.error("Task dispatch tick failed: project=%s; persisted tasks remain available for retry", settings.project_id)
if once:
return
try:
await asyncio.wait_for(wake.wait(), timeout=settings.interval_seconds)
except TimeoutError:
pass
wake.clear()
finally:
if listener is not None:
listener.cancel()
await asyncio.gather(listener, return_exceptions=True)
raise
if once:
return
try:
await asyncio.wait_for(wake.wait(), timeout=settings.interval_seconds)
except TimeoutError:
pass
wake.clear()
finally:
if listener is not None:
listener.cancel()
await asyncio.gather(listener, return_exceptions=True)
async def run(once: bool) -> None:
settings = WorkerSettings.from_environment()
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
results = await asyncio.gather(
*(run_project(scope, client, once) for scope in settings.project_scopes()),
return_exceptions=once,
)
if any(isinstance(result, Exception) for result in results):
raise SystemExit(1)
if __name__ == "__main__":
+13 -9
View File
@@ -372,7 +372,7 @@ class PeripheralWorkOrderSynchronizer:
event_name = ""
await asyncio.sleep(1)
except (httpx.HTTPError, ValueError, KeyError):
logger.warning("Work-order event stream disconnected; reconciliation remains active")
logger.warning("Work-order event stream disconnected: project=%s; reconciliation remains active", self.settings.project_id)
await asyncio.sleep(5)
async def serve(self, reconcile_seconds: int = 300) -> None:
@@ -399,12 +399,12 @@ class PeripheralWorkOrderSynchronizer:
self.client, self.settings, self.peripheral_url, self.credentials,
**({"work_order_ids": order_ids} if not full_reconcile else {}),
)
logger.info("Peripheral progress synchronization completed: %d revisions", count)
logger.info("Peripheral progress synchronization completed: project=%s revisions=%d", self.settings.project_id, count)
retry_seconds = 5
if full_reconcile:
next_reconcile = asyncio.get_running_loop().time() + reconcile_seconds
except (httpx.HTTPError, ValueError, KeyError):
logger.error("Peripheral synchronization failed; persisted revisions will be retried")
logger.error("Peripheral synchronization failed: project=%s; persisted revisions will be retried", self.settings.project_id)
self.full_reconcile |= full_reconcile
for order_id in order_ids:
self._queue_order(order_id)
@@ -419,17 +419,21 @@ class PeripheralWorkOrderSynchronizer:
async def run(once: bool, reconcile_seconds: int = 300) -> None:
settings = WorkerSettings.from_environment()
peripheral_url = os.environ["OPERATIONAL_ACTION_PERIPHERAL_URL"]
credentials = CredentialProvider(settings)
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
if once:
try:
count = await sync_once(client, settings, peripheral_url, credentials)
logger.info("Peripheral progress synchronization completed: %d revisions", count)
except (httpx.HTTPError, ValueError, KeyError):
results = await asyncio.gather(*(
sync_once(client, scope, peripheral_url, CredentialProvider(scope))
for scope in settings.project_scopes()
), return_exceptions=True)
if any(isinstance(result, Exception) for result in results):
logger.error("Peripheral synchronization failed; persisted revisions remain available")
raise SystemExit(1) from None
logger.info("Peripheral progress synchronization completed: %d revisions", sum(results))
return
await PeripheralWorkOrderSynchronizer(client, settings, peripheral_url, credentials).serve(reconcile_seconds)
await asyncio.gather(*(
PeripheralWorkOrderSynchronizer(client, scope, peripheral_url, CredentialProvider(scope)).serve(reconcile_seconds)
for scope in settings.project_scopes()
))
if __name__ == "__main__":
@@ -603,3 +603,16 @@ async def test_remove_project_member_rejects_self_membership_change(monkeypatch)
assert exc.value.status_code == 403
repo.remove_project_member.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
def test_project_map_config_accepts_search_and_preserves_extra_options():
from pydantic import ValidationError
from app.domain.schemas.admin_metadata import AdminProjectUpdateRequest
config = {'legend': 'original', 'place_search': {
'enabled': True, 'provider': 'tianditu', 'admin_code': '341126', 'region': '凤阳县'}}
request = AdminProjectUpdateRequest(map_config=config)
assert request.model_dump(exclude_unset=True)['map_config'] == config
with pytest.raises(ValidationError):
AdminProjectUpdateRequest(map_config=None)
with pytest.raises(ValidationError):
AdminProjectUpdateRequest(map_config={'place_search': {'enabled': True, 'admin_code': 'wrong', 'region': '凤阳县'}})
@@ -96,3 +96,30 @@ async def test_preconfigured_service_access_avoids_server_delegation(monkeypatch
assert source.await_args.kwargs["delegated"] is False
assert result.evidence.evidence_id == snapshot.source_ref
service.record_data_snapshot.assert_awaited_once()
@pytest.mark.anyio
async def test_report_endpoints_preserve_interactive_blocks_and_version_context(monkeypatch):
from app.domain.schemas.operational_task import AgentArtifactsCommand, InvestigationReportRevision
from tests.unit.test_report_ui import rich_report
report = rich_report()
snapshot = InvestigationReportRevision(
revision=2, execution_generation=1, source_session_id="session-1",
published_at="2026-10-20T08:00:00Z", published_by="agent", report=report,
)
service = SimpleNamespace(report=AsyncMock(return_value=snapshot),
list_reports=AsyncMock(return_value=([snapshot], None)),
publish_artifacts=AsyncMock(return_value={"report_revision": 2}))
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
task_id = uuid4()
command = AgentArtifactsCommand(request_id=uuid4(), source_session_id="session-1", report=report)
await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object())
assert service.publish_artifacts.await_args.args[1].report.ui_blocks[0].block.block_id == "pressure"
result = await endpoints.get_investigation_report(task_id, 2, ctx=_service_context(), conn=object())
restored = InvestigationReportRevision.model_validate_json(result.model_dump_json())
assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
assert restored.revision == 2
schemas = app.openapi()["components"]["schemas"]
assert "ui_blocks" in schemas["InvestigationReportInput"]["properties"]
assert "ui_blocks" in schemas["InvestigationReport"]["properties"]
+79 -3
View File
@@ -1,3 +1,4 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
@@ -7,7 +8,7 @@ from fastapi.testclient import TestClient
from app.api.v1.endpoints import place_search
from app.api.v1.rest_router import api_router
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.auth.project_dependencies import ProjectContext, get_project_context, get_metadata_repository
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchResponse
from app.services.place_search import PlaceSearchError
@@ -17,13 +18,18 @@ def app():
application = FastAPI()
application.include_router(api_router, prefix="/api/v1")
application.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "viewer")
application.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace(
get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config={
"place_search": {"enabled": True, "provider": "tianditu", "admin_code": "310120", "region": "奉贤区"}
}))
)
return application
def test_viewer_can_search_with_existing_project_read_permission(app, monkeypatch):
service = AsyncMock(return_value=PlaceSearchResponse(items=[PlaceSearchItem(
id="place-1", name="南桥路", address="上海市奉贤区南桥路", longitude=121.44, latitude=31.22,
)], total=1))
id="place-1", name="南桥路", address="上海市奉贤区南桥路", longitude=121.44, latitude=31.22, district="奉贤区",
)], total=1, region="奉贤区"))
monkeypatch.setattr(place_search, "search_tianditu_places", service)
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
assert response.status_code == 200
@@ -63,3 +69,73 @@ def test_search_contract_has_typed_results_and_required_project_header(app):
assert operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("PlaceSearchResponse")
assert set(contract["components"]["schemas"]["PlaceSearchRequest"]["properties"]) == {"keyword"}
assert contract["components"]["schemas"]["PlaceSearchResponse"]["properties"]["items"]["maxItems"] == 10
reverse = contract["paths"]["/api/v1/reverse-geocoding-requests"]["post"]
assert any(param["name"] == "X-Project-Id" and param["required"] for param in reverse["parameters"])
assert reverse["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("PlaceSearchItem")
coordinates = contract["components"]["schemas"]["ReverseGeocodingRequest"]
assert set(coordinates["properties"]) == {"longitude", "latitude"}
assert coordinates["additionalProperties"] is False
@pytest.mark.parametrize('map_config', [{}, {'place_search': None},
{'place_search': {'enabled': False, 'admin_code': '341126', 'region': '凤阳县'}},
{'place_search': {'enabled': True, 'admin_code': 'invalid', 'region': '凤阳县'}}])
def test_unconfigured_or_invalid_project_does_not_search(app, monkeypatch, map_config):
service = AsyncMock()
monkeypatch.setattr(place_search, 'search_tianditu_places', service)
app.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace(
get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config=map_config)))
response = TestClient(app).post('/api/v1/place-search-requests', json={'keyword': '地名'})
assert response.status_code == 503
service.assert_not_awaited()
def test_search_uses_configuration_of_authenticated_project(app, monkeypatch):
project_id = uuid4()
app.dependency_overrides[get_project_context] = lambda: ProjectContext(project_id, 'fengyang_v2', uuid4(), 'viewer')
repository = SimpleNamespace(get_project_by_id=AsyncMock(return_value=SimpleNamespace(
map_config={'place_search': {'enabled': True, 'admin_code': '341126', 'region': '凤阳县'}})))
app.dependency_overrides[get_metadata_repository] = lambda: repository
service = AsyncMock(return_value=PlaceSearchResponse(items=[], total=0, region='凤阳县'))
monkeypatch.setattr(place_search, 'search_tianditu_places', service)
response = TestClient(app).post('/api/v1/place-search-requests', json={'keyword': '地名'})
assert response.status_code == 200 and response.json()['region'] == '凤阳县'
repository.get_project_by_id.assert_awaited_once_with(project_id)
assert service.await_args.kwargs['config'].admin_code == '341126'
def test_viewer_reverse_lookup_uses_project_configuration_and_typed_point(app, monkeypatch):
service = AsyncMock(return_value=PlaceSearchItem(id='map-point', name='南桥路', address='奉贤区南桥路',
longitude=121.45, latitude=30.92, district='奉贤区'))
monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service)
response = TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121.45, 'latitude': 30.92})
assert response.status_code == 200 and response.json()['name'] == '南桥路'
assert service.await_args.args[0].longitude == 121.45
assert service.await_args.kwargs['config'].admin_code == '310120'
@pytest.mark.parametrize('body', [{'longitude': 181, 'latitude': 0},
{'longitude': 121, 'latitude': 91}, {'longitude': 121, 'latitude': 31, 'region': '全国'}])
def test_reverse_lookup_rejects_invalid_requests_before_provider_call(app, monkeypatch, body):
service = AsyncMock()
monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service)
assert TestClient(app).post('/api/v1/reverse-geocoding-requests', json=body).status_code == 422
service.assert_not_awaited()
@pytest.mark.parametrize('role,status', [('none', 403), ('viewer', 503)])
def test_reverse_lookup_requires_project_permission_and_configuration(app, monkeypatch, role, status):
app.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), 'demo', uuid4(), role)
app.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace(
get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config={})))
service = AsyncMock()
monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service)
assert TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121, 'latitude': 31}).status_code == status
service.assert_not_awaited()
@pytest.mark.parametrize('status', [404, 422, 502, 503, 504])
def test_reverse_lookup_preserves_actionable_errors(app, monkeypatch, status):
monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', AsyncMock(side_effect=PlaceSearchError(status, '请重新取点')))
response = TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121, 'latitude': 31})
assert response.status_code == status and response.json()['detail'] == '请重新取点'
@@ -57,3 +57,92 @@ def test_scada_batch_writes_only_after_bizdb_validation(monkeypatch):
assert result == {"message": "Inserted 1 records"}
assert insert.await_args.args[1][0]["device_id"] == "known"
def test_latest_scada_readings_join_device_metadata_with_latest_rows(monkeypatch):
observed_at = datetime(2026, 10, 22, 1, 2, tzinfo=timezone.utc)
monkeypatch.setattr(
scada_endpoint.ScadaInfoRepository,
"get_scadas",
AsyncMock(return_value=[
{
"device_id": "PRESSURE-1",
"device_type": "pressure",
"measurement_unit": "MPa",
"transmission_mode": "realtime",
"transmission_frequency": "15min",
},
{
"device_id": "FLOW-2",
"device_type": "pipe_flow",
"measurement_unit": "m3/h",
"transmission_mode": "realtime",
"transmission_frequency": "15min",
},
]),
)
latest = AsyncMock(return_value={
"PRESSURE-1": {
"time": observed_at,
"device_id": "PRESSURE-1",
"monitored_value": 0.31,
"cleaned_value": 0.3,
}
})
monkeypatch.setattr(
scada_endpoint.ScadaRepository,
"get_latest_scada_readings",
latest,
)
result = asyncio.run(
scada_endpoint.get_latest_scada_readings(
limit=100,
offset=0,
conn=object(),
postgres_conn=object(),
)
)
assert result.total == 2
assert result.observed_count == 1
assert result.latest_observed_at == observed_at
assert result.items[0].monitored_value == pytest.approx(0.31)
assert result.items[1].observed_at is None
assert latest.await_args.args[1] == ["PRESSURE-1", "FLOW-2"]
def test_latest_scada_readings_applies_device_page_before_timeseries_query(monkeypatch):
monkeypatch.setattr(
scada_endpoint.ScadaInfoRepository,
"get_scadas",
AsyncMock(return_value=[
{
"device_id": f"D-{index}",
"device_type": "pressure",
"measurement_unit": "MPa",
"transmission_mode": "realtime",
"transmission_frequency": "15min",
}
for index in range(4)
]),
)
latest = AsyncMock(return_value={})
monkeypatch.setattr(
scada_endpoint.ScadaRepository,
"get_latest_scada_readings",
latest,
)
result = asyncio.run(
scada_endpoint.get_latest_scada_readings(
limit=2,
offset=1,
conn=object(),
postgres_conn=object(),
)
)
assert result.total == 4
assert [item.device_id for item in result.items] == ["D-1", "D-2"]
assert latest.await_args.args[1] == ["D-1", "D-2"]
+41
View File
@@ -0,0 +1,41 @@
import ctypes
import os
from pathlib import Path
import platform
import subprocess
from types import SimpleNamespace
import pytest
from app.infra.epanet.epanet import Output
@pytest.mark.parametrize("pressure_code,expected", [(0, "PSI"), (1, "KPA"), (2, "MTR")])
def test_output_pressure_units_follow_native_enum(pressure_code, expected):
def units(handle, kind, value):
value._obj.value = {1: 5, 2: pressure_code, 3: 0}[kind.value]
return 0
output = Output.__new__(Output)
output._lib = SimpleNamespace(ENR_getUnits=units, ENR_close=lambda *args: 0)
output._handle = ctypes.c_void_p()
output._check = lambda code: None
assert output.units() == {"flow": "LPS", "pressure": expected, "quality": "NONE"}
def test_bundled_epanet_metric_pressure_matches_head_minus_elevation(tmp_path):
binary_dir = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux"
exe = binary_dir / "runepanet"
if platform.system() != "Linux" or not exe.exists():
pytest.skip("Bundled Linux EPANET runner unavailable")
inp, rpt, opt = (tmp_path / name for name in ("model.inp", "model.rpt", "model.opt"))
inp.write_text("[JUNCTIONS]\nJ1 10 1\n[RESERVOIRS]\nR1 100\n"
"[PIPES]\nP1 R1 J1 100 200 100 0 OPEN\n"
"[OPTIONS]\nUNITS LPS\n[END]\n")
env = {**os.environ, "LD_LIBRARY_PATH": str(binary_dir)}
result = subprocess.run([str(exe), str(inp), str(rpt), str(opt)], env=env,
capture_output=True, text=True, timeout=10)
assert result.returncode == 0, result.stdout + result.stderr
output = Output(str(opt))
assert output.units()["pressure"] == "MTR"
row = next(r for r in output.node_results() if r["node"] == "J1")["result"][0]
assert row["pressure"] == pytest.approx(row["head"] - 10, abs=1e-4)
+377
View File
@@ -0,0 +1,377 @@
import asyncio
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, QueryResult
from app.mcp import extended_service, mutation_service, query_service, scenario_service, analysis_service
HANDLE = "x" * 43
START = "2026-10-21T08:00:00+08:00"
def args(tool_name, **values):
return TOOLS[tool_name].model.model_validate({"context_handle": HANDLE, **values})
def binding(**values):
return BoundContext(context_handle=HANDLE, project_id=uuid4(), subject="actor", client_id="host",
native_session_id="session", purpose="ordinary_chat", tool_cap=list(TOOLS),
permission_cap=["webgis.edit", "webgis.view", "scada.clean", "scada.view", "simulation.run"],
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), **values)
@pytest.mark.parametrize("name,values", [
("query_network_extent", {"bounds": [120, 30, 122, 32]}),
("query_network_extent", {"bounds": [121, 31, 120, 30]}),
("query_network_extent", {"bounds": [float("nan"), 31, 122, 32]}),
("calculate_demand_allocation", {"target": "network", "demand": 10, "node_ids": ["J1"]}),
("edit_model_element", {"request_id": str(uuid4()), "operation": "delete", "element_type": "pipe", "element_id": "P1", "dry_run": False}),
("edit_model_element", {"request_id": str(uuid4()), "operation": "update", "element_type": "pipe", "element_id": "P1", "properties": {"id": "other"}}),
("clean_scada_readings", {"request_id": str(uuid4()), "start": START, "end": "2026-10-23T08:00:00+08:00", "device_ids": ["D1"]}),
("run_burst_simulation", {"start_time": START, "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1, 2]}),
])
def test_bounded_schemas_reject_unsafe_inputs(name, values):
with pytest.raises(ValidationError):
args(name, **values)
def test_saved_scenario_has_no_nested_identity_and_validates_timezone():
values = dict(request_id=uuid4(), name="test", scenario="burst", parameters={
"start_time": START, "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1],
})
valid = args("save_simulation_scenario", **values)
assert "context_handle" not in valid.parameters.model_dump()
values["parameters"]["start_time"] = "2026-10-21T08:00:00"
with pytest.raises(ValidationError):
args("save_simulation_scenario", **values)
values["parameters"]["start_time"] = START
values["parameters"]["network"] = "other-project"
with pytest.raises(ValidationError):
args("save_simulation_scenario", **values)
@pytest.mark.parametrize("purpose,role", [("task_reader", "member"), ("task_execution", "member"), ("task_collaboration", "member"), ("ordinary_chat", "viewer")])
def test_writes_never_enter_background_or_viewer_sessions(monkeypatch, purpose, role):
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.edit"})
bound = binding()
bound.purpose = purpose
scope = SimpleNamespace(project=SimpleNamespace(project_role=role))
with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
assert error.value.status_code == 403
def test_write_requires_current_and_captured_permissions(monkeypatch):
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
bound = binding()
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: set())
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.edit"})
bound.permission_cap = []
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
def test_extent_runs_real_parameterized_project_query_and_pages(monkeypatch):
read = AsyncMock(return_value=[{"id": "A"}, {"id": "B"}])
monkeypatch.setattr(extended_service, "read_rows", read)
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own"))
result = asyncio.run(extended_service.execute_extended(scope, object(), TOOLS["query_network_extent"],
args("query_network_extent", bounds=[121.0, 31.0, 121.1, 31.1], limit=1)))
assert result.page.has_more and result.truncated
assert result.data["items"] == [{"id": "A"}]
call = read.call_args.args
assert "ST_Intersects" in str(call[1])
assert call[2] == (121.0, 31.0, 121.1, 31.1, 2, 0)
assert call[0] is scope.connection
def test_missing_gis_propagates_unavailable_without_cross_project_retry(monkeypatch):
read = AsyncMock(side_effect=ValueError("GIS unavailable"))
monkeypatch.setattr(extended_service, "read_rows", read)
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="no-gis"))
with pytest.raises(ValueError):
asyncio.run(extended_service.execute_extended(scope, object(), TOOLS["query_network_extent"],
args("query_network_extent", bounds=[121.0, 31.0, 121.1, 31.1])))
assert read.await_count == 1
def test_demand_allocation_is_paged_and_never_sets_demands(monkeypatch):
from app.services import demand_allocation
monkeypatch.setattr(demand_allocation, "calculate_demand_to_nodes", lambda *a: {"B": 6, "A": 4})
values = args("calculate_demand_allocation", target="nodes", node_ids=["A", "B"], demand=10, limit=1)
result = asyncio.run(analysis_service.calculate("calculate_demand_allocation", values, network="own", username="actor"))
assert result == {"items": [{"node_id": "A", "demand": 4}], "total": 2, "limit": 1, "offset": 0, "has_more": True, "persisted": False}
def test_burst_preview_uses_isolated_nonpersistent_adapter(monkeypatch):
from app.services import simulation_scenarios
calls = []
monkeypatch.setattr(simulation_scenarios, "burst_analysis", lambda **kw: calls.append(kw) or {"node_results": [{"node": "J1"}], "link_results": []})
values = args("run_burst_simulation", start_time=START, output_node_ids=["J1"], burst_pipe_ids=["P1"], orifice_areas_cm2=[2])
result = scenario_service.calculate_scenario("run_burst_simulation", values.model_dump(exclude={"context_handle", "timeout_seconds"}), network="own", username="actor")
assert calls[0]["persist_results"] is False and calls[0]["scheme_name"] is None
assert calls[0]["burst_size"] == [2]
assert result["persisted_baseline"] is False
class Connection:
@asynccontextmanager
async def transaction(self):
yield
async def commit(self):
pass
class Journal:
records = {}
def __init__(self, *args):
pass
async def lock_receipt(self, *args):
pass
async def receipt(self, scope, key):
return self.records.get((scope, key))
async def save_receipt(self, scope, key, parameters, result):
self.records[(scope, key)] = {"parameters": parameters, "result": result}
async def finish_receipt(self, scope, key, result):
self.records[(scope, key)]["result"] = result
@pytest.fixture
def mutation(monkeypatch):
Journal.records = {}
monkeypatch.setattr(mutation_service, "McpQueryRepository", Journal)
preview = AsyncMock(return_value={"before": {"diameter": 100}, "properties": {"diameter": 200}})
apply = AsyncMock(return_value={"changed": True})
monkeypatch.setattr(mutation_service, "preview_change", preview)
monkeypatch.setattr(mutation_service, "apply_change", apply)
scope = SimpleNamespace(connection=Connection(), project=SimpleNamespace(project_id=uuid4()))
values = dict(request_id=uuid4(), operation="update", element_type="pipe", element_id="P1", properties={"diameter": 200})
return scope, binding(), values, preview, apply
def test_preview_execution_stale_state_and_idempotent_replay(mutation):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
assert result.data["dry_run"] and not apply.called
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
preview.return_value = {"before": {"diameter": 150}}
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409 and not apply.called
assert Journal.records == {}
preview.return_value = {"before": {"diameter": 100}, "properties": {"diameter": 200}}
result = await mutation_service.execute_mutation(scope, bound, tool, execution)
replay = await mutation_service.execute_mutation(scope, bound, tool, execution)
assert replay == result
assert apply.await_count == 1
asyncio.run(run())
def test_failed_or_timed_out_write_cannot_be_automatically_reexecuted(mutation):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
apply.side_effect = TimeoutError()
with pytest.raises(TimeoutError):
await mutation_service.execute_mutation(scope, bound, tool, execution)
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409
assert apply.await_count == 1
asyncio.run(run())
def test_capacity_rejection_is_retryable_under_the_same_request_after_slots_free(mutation, monkeypatch):
from threading import BoundedSemaphore
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
slots = BoundedSemaphore(2)
monkeypatch.setattr(analysis_service, "_slots", slots)
writes = []
async def bounded_write(*unused):
return await analysis_service.bounded_calculation(lambda: writes.append("written") or {"changed": True})
apply.side_effect = bounded_write
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
assert slots.acquire(blocking=False) and slots.acquire(blocking=False)
try:
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 503
record = next(iter(Journal.records.values()))
assert record["result"]["status"] == "rejected"
assert record["result"]["retryable"] is True
assert record["result"]["error"]["code"] == "CALCULATION_CAPACITY_EXCEEDED"
assert writes == []
finally:
slots.release()
slots.release()
saved = await mutation_service.execute_mutation(scope, bound, tool, execution)
replay = await mutation_service.execute_mutation(scope, bound, tool, execution)
assert replay == saved and writes == ["written"]
assert next(iter(Journal.records.values()))["result"]["status"] == "completed"
asyncio.run(run())
@pytest.mark.parametrize("failure", [TimeoutError(), asyncio.CancelledError(), RuntimeError("native failure"), HTTPException(503, "unrelated source failure")])
def test_ambiguous_write_failure_remains_started_and_cannot_be_retried(mutation, failure):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
apply.side_effect = failure
with pytest.raises(type(failure)):
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert next(iter(Journal.records.values()))["result"]["status"] == "started"
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409 and apply.await_count == 1
asyncio.run(run())
def test_native_model_lock_revalidates_preview_and_only_writes_allowed_fields(monkeypatch):
from app.services import tjnetwork
from app.native.wndb.core import database
state = {"id": "P1", "diameter": 100}
monkeypatch.setattr(tjnetwork, "get_pipe", lambda *a: dict(state))
monkeypatch.setattr(tjnetwork, "get_pipe_schema", lambda *a: {"id": {"readonly": True}, "diameter": {"type": "float", "readonly": False}})
@contextmanager
def lock(*a):
yield
monkeypatch.setattr(database, "model_mutation_transaction", lock)
writes = []
monkeypatch.setattr(tjnetwork, "set_pipe", lambda network, changes: writes.append(changes.operations) or changes)
values = dict(request_id=uuid4(), operation="update", element_type="pipe", element_id="P1", properties={"diameter": 200})
preview = mutation_service.model_change("own", "edit_model_element", args("edit_model_element", **values), execute=False)
execution = args("edit_model_element", **values, dry_run=False, expected_digest=mutation_service.digest(preview))
state["diameter"] = 110
with pytest.raises(HTTPException):
mutation_service.model_change("own", "edit_model_element", execution, execute=True)
assert not writes
state["diameter"] = 100
mutation_service.model_change("own", "edit_model_element", execution, execute=True)
assert writes == [[{"id": "P1", "diameter": 200}]]
def test_artifacts_are_integrity_checked_bounded_binary_envelopes():
result = extended_service.artifact("model.zip", b"zip-data", "application/zip")
assert result["file"]["size"] == 8
assert len(result["file"]["sha256"]) == 64
with pytest.raises(HTTPException) as error:
extended_service.artifact("model.zip", bytes(5 * 1024 * 1024 + 1), "application/zip")
assert error.value.status_code == 413
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
assert not any("health" in name for name in TOOLS)
assert len(TOOLS) == 67
assert sum(t.mutating for t in TOOLS.values()) == 13
for tool in TOOLS.values():
if tool.mutating:
assert not tool.protocol_tool().annotations.read_only_hint
assert tool.protocol_tool().annotations.destructive_hint
@pytest.mark.parametrize("scenario,parameters,function", [
("burst", {"burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1]}, "burst_analysis"),
("contaminant", {"source": "J2", "concentration": 1}, "contaminant_simulation"),
("valve_closure", {"valves": ["V1"]}, "valve_close_analysis"),
])
def test_formal_scenarios_bind_actor_and_persist_only_through_existing_services(monkeypatch, scenario, parameters, function):
from app.services import simulation_scenarios
calls = []
monkeypatch.setattr(simulation_scenarios, function, lambda **kw: calls.append(kw))
payload = {"start_time": START, "duration": 900, "output_node_ids": ["J1"], **parameters}
values = args("save_simulation_scenario", request_id=uuid4(), name="formal-test", scenario=scenario, parameters=payload)
mutation_service.saved_scenario("owned-project", "verified-user", values)
assert calls[0]["name"] == "owned-project"
assert calls[0]["username"] == "verified-user"
assert calls[0]["persist_results"] is True
assert calls[0]["scheme_name"] == "formal-test"
def test_mutation_status_is_scoped_to_bound_identity_session_and_project(monkeypatch):
calls = []
class Repository:
def __init__(self, conn, project):
calls.append(project)
async def receipt(self, scope_key, request_id):
calls.extend([scope_key, request_id])
return {"parameters": {"tool": "edit_model_element"}, "result": {"status": "started"}}
import app.infra.db.postgresql.mcp_queries as storage
monkeypatch.setattr(storage, "McpQueryRepository", Repository)
bound = binding()
request_id = uuid4()
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own", project_id=bound.project_id))
result = asyncio.run(extended_service.execute_extended(scope, bound, TOOLS["get_mutation_status"], args("get_mutation_status", request_id=request_id)))
assert calls == [bound.project_id, "mutation:actor:session", str(request_id)]
assert result.data["status"] == "started"
def test_mutation_status_exposes_rejected_capacity_and_retryability(monkeypatch):
class Repository:
def __init__(self, *unused):
pass
async def receipt(self, *unused):
return {"parameters": {"tool": "save_simulation_scenario"}, "result": {
"status": "rejected", "retryable": True,
"error": {"code": "CALCULATION_CAPACITY_EXCEEDED", "message": "calculation not started"},
}}
import app.infra.db.postgresql.mcp_queries as storage
monkeypatch.setattr(storage, "McpQueryRepository", Repository)
bound = binding()
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own", project_id=bound.project_id))
result = asyncio.run(extended_service.execute_extended(scope, bound, TOOLS["get_mutation_status"],
args("get_mutation_status", request_id=uuid4())))
assert result.data["status"] == "rejected" and result.data["retryable"] is True
assert result.data["error"]["code"] == "CALCULATION_CAPACITY_EXCEEDED"
def test_scada_preview_and_journal_distinguish_omitted_fields_from_explicit_null():
values = dict(request_id=uuid4(), operation="update", provenance="operator correction",
records=[{"device_id": "D1", "time": START, "monitored_value": 1}])
original = args("write_scada_readings", **values)
values["records"][0]["cleaned_value"] = None
clearing = args("write_scada_readings", **values)
for preview in [True, False]:
left = mutation_service.mutation_arguments(original, preview=preview)
right = mutation_service.mutation_arguments(clearing, preview=preview)
assert "cleaned_value" not in left["records"][0]
assert right["records"][0]["cleaned_value"] is None
assert mutation_service.digest(left) != mutation_service.digest(right)
def test_nested_model_properties_cannot_hide_nonfinite_or_unknown_fields():
schema = {"demands": {"type": "list", "readonly": False, "element": {"demand": {"type": "float", "readonly": False}}}}
for value in [{"demand": float("nan")}, {"demand": 1, "url": "https://foreign"}]:
with pytest.raises(HTTPException) as error:
mutation_service.check_properties(schema, {"demands": [value]})
assert error.value.status_code == 422
+28 -2
View File
@@ -80,6 +80,32 @@ def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime):
assert calls == []
def test_calculation_capacity_has_an_explicit_mcp_error(runtime, monkeypatch):
from threading import BoundedSemaphore
from app.mcp import analysis_service
app, client, _, _ = runtime
monkeypatch.setattr(application, "authorize_query", AsyncMock())
slots = BoundedSemaphore(2)
monkeypatch.setattr(analysis_service, "_slots", slots)
async def execute(*unused):
return await analysis_service.bounded_calculation(lambda: pytest.fail("occupied calculation entered"))
app.executor = execute
assert slots.acquire(blocking=False) and slots.acquire(blocking=False)
try:
response = request(client, "tools/call", {"name": "run_simulation", "arguments": {
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"],
}}).json()
finally:
slots.release()
slots.release()
error = response["result"]["structuredContent"]["error"]
assert response["result"]["isError"] is True
assert error["code"] == "CALCULATION_CAPACITY_EXCEEDED"
assert "not started" in error["message"]
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch):
app, client, calls, _ = runtime
monkeypatch.setattr(application, "authorize_query", AsyncMock())
@@ -286,10 +312,10 @@ def test_modern_discovery_catalog_and_metadata(runtime):
assert result["result"]["resultType"] == "complete"
assert "2026-07-28" in result["result"]["supportedVersions"]
listed = request(client).json()["result"]
assert len(listed["tools"]) == 35
assert len(listed["tools"]) == len(TOOLS)
for tool in listed["tools"]:
timeout = tool["inputSchema"]["properties"].get("timeout_seconds")
assert (timeout is not None) == TOOLS[tool["name"]].computation
assert (timeout is not None) == (TOOLS[tool["name"]].computation or TOOLS[tool["name"]].mutating)
if timeout:
assert timeout["default"] == 120
assert timeout["maximum"] == 3600
@@ -85,3 +85,17 @@ async def test_create_provisioned_project_commits_project_routes_and_membership_
assert added[3].user_id == user_id
session.commit.assert_awaited_once()
session.refresh.assert_awaited_once_with(project)
@pytest.mark.anyio
async def test_update_map_config_preserves_legacy_settings_and_other_projects():
project = SimpleNamespace(map_config={'legend': 'original', 'coordinate_reference': {'source_srid': 990001}})
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
repository = MetadataRepository(session)
repository.get_project_by_id = AsyncMock(return_value=project)
project_id = uuid4()
search = {'enabled': True, 'provider': 'tianditu', 'admin_code': '341126', 'region': '凤阳县'}
await repository.update_project(project_id, updates={'map_config': {'place_search': search}})
repository.get_project_by_id.assert_awaited_once_with(project_id)
assert project.map_config == {'legend': 'original', 'coordinate_reference': {'source_srid': 990001}, 'place_search': search}
session.commit.assert_awaited_once()
+39
View File
@@ -1803,3 +1803,42 @@ async def test_repository_task_update_rejects_a_stale_revision():
await repository.update_task(task)
assert exc.value.code == "VERSION_CONFLICT"
@pytest.mark.anyio
async def test_interactive_report_publication_is_idempotent_and_versioned_with_evidence():
from tests.unit.test_report_ui import rich_report
now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
report = {**rich_report(), "stage": "final"}
command = AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
evidence=[{"evidence_id": "demo-1", "kind": "synthetic", "summary": "演练压力记录",
"source_label": "演练监测", "observed_at": now.isoformat()}],
)
first = await service.publish_artifacts(task["task_id"], command)
replay = await service.publish_artifacts(task["task_id"], command)
assert first.report_revision == replay.report_revision == 1
report["ui_blocks"][0]["block"]["spec"]["elements"]["chart"]["props"]["series"][0]["data"] = [0.3, 0.29]
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
))
old = await service.report(task["task_id"], 1)
latest = await service.detail(task["task_id"])
history, _ = await service.list_reports(task["task_id"], 10, 0)
assert old.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
assert latest.latest_report.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.29]
assert [item.revision for item in history] == [2, 1]
assert latest.latest_report.report.evidence_refs == ["demo-1"]
assert len(repository.notifications) == 2
report["evidence_refs"] = ["foreign"]
report["ui_blocks"][0]["evidence_refs"] = ["foreign"]
with pytest.raises(DomainError) as error:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
))
assert error.value.code == "EVIDENCE_NOT_FOUND"
@@ -0,0 +1,212 @@
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import UUID, uuid4
import pytest
from fastapi.testclient import TestClient
from psycopg.conninfo import conninfo_to_dict
from app.auth.project_dependencies import get_metadata_repository
from app.infra.db.project_routing import ActiveProjectRouting
from samples.peripheral import auth
from samples.peripheral.api import create_app
from samples.peripheral.config import SampleSettings
from samples.peripheral.errors import SampleFailure
from samples.peripheral.store import PostgresStore
@pytest.fixture
def project_api(monkeypatch):
projects = {str(uuid4()): "tjwater_v2", str(uuid4()): "fengyang_v2"}
connections = []
permissions = ["simulation.view"]
missing = set()
route_overrides = {}
class Repository:
async def get_project_db_routing(self, project_id, role):
code = projects[str(project_id)]
if (code, role) in missing:
return None
database = code + ("_history" if role == "iot_data" else "_business")
return SimpleNamespace(**({
"dsn": f"postgresql://test@localhost/{database}",
"db_type": "timescaledb" if role == "iot_data" else "postgresql",
} | route_overrides.get((code, role), {})))
class Connection:
def __init__(self, database):
self.database = database
self.info = SimpleNamespace(dbname=database)
self.rows = []
self.readonly = False
self.statements = []
def execute(self, statement, parameters=None):
self.statements.append((statement, parameters))
self.rows = []
if statement == "SET TRANSACTION READ ONLY":
self.readonly = True
if "current_database()" in statement:
self.rows = [{"name": self.database}]
elif "peripheral_sample.scenarios" in statement:
self.rows = []
elif "to_regclass('asset.scada_devices')" in statement:
self.rows = [{"table_name": "asset.scada_devices"}]
elif "FROM asset.scada_devices" in statement:
self.rows = [{"device_id": self.database, "device_type": "pressure",
"measurement_unit": "MPa", "api_query_id": "point-1"}]
elif "FROM scada.measurements" in statement:
self.rows = [{"time": datetime(2026, 10, 21, tzinfo=timezone.utc), "monitored_value": 0.3}]
elif "to_regclass('workbench.domain_event_log')" in statement:
self.rows = [{"event_log": "workbench.domain_event_log"}]
elif "RETURNING event_seq" in statement:
self.rows = [{"event_seq": 42}]
return self
def fetchone(self):
return self.rows[0] if self.rows else None
def fetchall(self):
return self.rows
@contextmanager
def connect(*, conninfo, **kwargs):
connection = Connection(conninfo_to_dict(conninfo)["dbname"])
connections.append(connection)
yield connection
monkeypatch.setattr("samples.peripheral.store.psycopg.connect", connect)
monkeypatch.setattr(auth, "next_get", lambda request, path: {
"project_id": request.headers["x-project-id"], "user_id": str(uuid4()),
"network": projects[request.headers["x-project-id"]], "permissions": permissions,
})
app = create_app(SampleSettings())
app.dependency_overrides[get_metadata_repository] = lambda: Repository()
with TestClient(app) as client:
yield SimpleNamespace(client=client, projects=projects, connections=connections,
permissions=permissions, missing=missing, route_overrides=route_overrides)
def headers(project):
return {"Authorization": "Bearer TEST_ONLY", "X-Project-Id": project}
def test_concurrent_project_reads_use_separate_metadata_databases(project_api):
api = project_api
def read(project):
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
assert response.status_code == 200, response.text
return project, response.json()
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(read, list(api.projects) * 3))
for project, payload in results:
database = api.projects[project] + "_business"
assert payload["source_id"] == database + ":asset.scada_devices"
assert payload["items"][0]["point_id"] == database
assert all(connection.readonly for connection in api.connections)
def test_history_uses_separate_project_timescale_route(project_api):
api = project_api
project, code = list(api.projects.items())[1]
response = api.client.get("/api/sample/v1/monitoring/observations", headers=headers(project), params={
"point_id": "point-1", "start": "2026-10-21T00:00:00Z", "end": "2026-10-22T00:00:00Z",
})
assert response.status_code == 200, response.text
assert response.json()["items"][0]["value"] == 0.3
assert response.json()["source_id"] == code + "_history:scada.measurements"
assert [connection.database for connection in api.connections] == [code + "_business", code + "_history"]
assert all(connection.readonly for connection in api.connections)
def test_missing_routing_never_falls_back_to_default_database(project_api):
api = project_api
project, code = list(api.projects.items())[1]
api.missing.add((code, "biz_data"))
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
assert response.status_code == 503
assert response.json()["code"] == "PROJECT_STORAGE_NOT_CONFIGURED"
assert not api.connections
def test_work_order_writes_and_events_use_the_selected_project(project_api):
api = project_api
api.permissions.append("simulation.run")
project, code = list(api.projects.items())[1]
response = api.client.post("/api/sample/v1/work-orders", headers=headers(project), json={
"request_id": str(uuid4()), "title": "routing test", "summary": "test only",
})
assert response.status_code == 201, response.text
assert response.json()["project_id"] == project
assert len(api.connections) == 1
connection = api.connections[0]
assert connection.database == code + "_business"
assert not connection.readonly
event = next(params for sql, params in connection.statements if "INSERT INTO workbench.domain_event_log" in sql)
assert event[0] == project
assert event[2] == response.json()["work_order_id"]
@pytest.mark.parametrize("override", [
{"dsn": "postgresql://test@localhost"}, {"dsn": "not-a-valid-dsn"}, {"db_type": "timescaledb"},
])
def test_invalid_project_routes_fail_without_opening_storage(project_api, override):
api = project_api
project, code = list(api.projects.items())[1]
api.route_overrides[(code, "biz_data")] = override
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
assert response.status_code == 503
assert response.json()["code"] == "PROJECT_STORAGE_NOT_CONFIGURED"
assert not api.connections
def test_crud_reads_do_not_require_timescale_configuration(project_api):
api = project_api
project, code = list(api.projects.items())[1]
api.missing.add((code, "iot_data"))
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
assert response.status_code == 200
unavailable = api.client.get("/api/sample/v1/monitoring/observations", headers=headers(project), params={
"point_id": "point-1", "start": "2026-10-21T00:00:00Z", "end": "2026-10-22T00:00:00Z",
})
assert unavailable.status_code == 503
@pytest.mark.parametrize("method,permissions", [("GET", []), ("POST", ["simulation.view"])])
def test_project_routing_preserves_read_and_write_permissions(project_api, method, permissions):
api = project_api
api.permissions[:] = permissions
response = api.client.request(method, "/api/sample/v1/scenario", headers=headers(next(iter(api.projects))))
assert response.status_code == 403
assert response.json()["code"] == "PERMISSION_DENIED"
assert not api.connections
def test_mismatched_identity_is_rejected_before_storage(project_api, monkeypatch):
monkeypatch.setattr(auth, "next_get", lambda request, path: {
"project_id": str(uuid4()), "user_id": str(uuid4()), "network": "fengyang_v2",
"permissions": ["simulation.view", "simulation.run"],
})
response = project_api.client.get("/api/sample/v1/context", headers=headers(next(iter(project_api.projects))))
assert response.status_code == 403
assert response.json()["code"] == "PROJECT_MISMATCH"
assert not project_api.connections
def test_routed_connection_does_not_use_legacy_settings_or_missing_timescale(project_api):
project = next(iter(project_api.projects))
routing = ActiveProjectRouting("logical-project", "dbname=physical-business", project_id=UUID(project))
store = PostgresStore(SampleSettings(db_name="unrelated"), routing=routing)
assert store.settings.db_name == "physical-business"
with store.connection(readonly=True):
pass
with pytest.raises(SampleFailure) as failure:
with store.connection(readonly=True, timeseries=True):
pass
assert failure.value.code == "PROJECT_STORAGE_NOT_CONFIGURED"
assert [connection.database for connection in project_api.connections] == ["physical-business"]
@@ -10,27 +10,29 @@ from samples.peripheral.store import PostgresStore
class EventConnection:
def __init__(self):
self.statements = []
self.event_log = True
def execute(self, statement, parameters):
def execute(self, statement, parameters=None):
self.statements.append((statement, parameters))
return self
def fetchone(self):
return {"event_seq": 42}
return {"event_seq": 42, "event_log": self.event_log}
@pytest.mark.parametrize(("revision", "deleted", "change"), [
(1, False, "created"), (2, False, "updated"), (3, True, "deleted"),
])
def test_work_order_signals_use_same_connection_and_contain_only_identity(revision, deleted, change):
@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "physical-project-db"])
def test_work_order_signals_use_same_connection_and_contain_only_identity(revision, deleted, change, database):
conn = EventConnection()
who = Identity(str(uuid4()), str(uuid4()), True)
order = {"work_order_id": str(uuid4()), "revision": revision,
"updated_at": "2026-10-08T00:00:00Z", "title": "private title"}
PostgresStore(SampleSettings())._work_order_event(conn, who, order, deleted=deleted)
PostgresStore(SampleSettings(db_name=database))._work_order_event(conn, who, order, deleted=deleted)
statement, parameters = conn.statements[0]
statement, parameters = conn.statements[1]
assert "workbench.domain_event_log" in statement
assert "'workbench.work_order.changed','peripheral_work_order'" in statement
assert parameters[0] == who.project_id
@@ -38,13 +40,14 @@ def test_work_order_signals_use_same_connection_and_contain_only_identity(revisi
assert parameters[5].obj == {
"source": "peripheral_sample", "work_order_id": order["work_order_id"], "change": change,
}
assert conn.statements[1] == (
assert conn.statements[2] == (
"SELECT pg_notify('workbench_events', %s)", (f"{who.project_id}:42",),
)
def test_event_rollout_does_not_write_to_other_databases():
def test_event_publication_does_not_create_missing_storage():
conn = EventConnection()
conn.event_log = False
store = PostgresStore(SampleSettings(db_name="another_project"))
store._work_order_event(conn, Identity(str(uuid4()), str(uuid4()), True), {})
assert conn.statements == []
assert conn.statements == [("SELECT to_regclass('workbench.domain_event_log') AS event_log", None)]
+39 -7
View File
@@ -8,6 +8,9 @@ from pydantic import ValidationError
from app.core.config import Settings, settings
from app.domain.schemas.place_search import PlaceSearchRequest
from app.domain.schemas.project_map_config import PlaceSearchConfig
CONFIG = PlaceSearchConfig(enabled=True, admin_code="310120", region="奉贤区")
from app.services.place_search import PlaceSearchError, search_tianditu_places
@@ -44,7 +47,7 @@ def shared_token(monkeypatch):
def test_search_fixes_fengxian_scope_and_normalizes_candidates():
client = FakeClient()
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), client=client))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), config=CONFIG, client=client))
url, params = client.calls[0]
assert url == "https://api.tianditu.gov.cn/v2/search"
assert params["tk"] == "shared-token"
@@ -60,14 +63,14 @@ def test_search_fixes_fengxian_scope_and_normalizes_candidates():
@pytest.mark.parametrize("data", [payload(count=0, pois=[]), {"status": {"infocode": 3001}}])
def test_no_matches_is_a_successful_empty_result(data):
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="未知地名"), client=FakeClient(data)))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="未知地名"), config=CONFIG, client=FakeClient(data)))
assert result.items == [] and result.total == 0 and not result.truncated
@pytest.mark.parametrize("matched_total, size, truncated", [(0, 0, False), (3, 3, False), (10, 10, False), (28, 10, True), (2000, 10, True)])
def test_search_only_returns_first_ten_results(matched_total, size, truncated):
client = FakeClient(payload(count=matched_total, pois=[poi(hotPointID=f"place-{index}") for index in range(size)]))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client))
assert len(result.items) == result.total == size and result.truncated == truncated
params = json.loads(client.calls[0][1]["postStr"])
assert params["count"] == 10 and params["start"] == 0 and params["specify"] == "156310120"
@@ -90,7 +93,7 @@ def test_search_only_returns_first_ten_results(matched_total, size, truncated):
])
def test_invalid_or_unlocatable_provider_response_is_rejected(data):
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=FakeClient(data)))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=FakeClient(data)))
assert exc.value.status_code == 502
@@ -98,7 +101,7 @@ def test_missing_shared_token_is_configuration_error(monkeypatch):
monkeypatch.setattr(settings, "TIANDITU_TOKEN", "")
client = FakeClient()
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client))
assert exc.value.status_code == 503 and client.calls == []
@@ -108,7 +111,7 @@ def test_http_request_logs_redact_the_shared_token(caplog):
async def run():
transport = httpx.MockTransport(lambda _request: httpx.Response(200, json=payload()))
async with httpx.AsyncClient(transport=transport) as client:
await search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client)
await search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client)
asyncio.run(run())
messages = [record.getMessage() for record in caplog.records if record.name == "httpx"]
@@ -122,7 +125,7 @@ def test_http_request_logs_redact_the_shared_token(caplog):
])
def test_transport_errors_are_safe_and_actionable(failure, status):
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=FakeClient(error=failure)))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=FakeClient(error=failure)))
assert exc.value.status_code == status
assert "secret-token" not in exc.value.detail
@@ -138,3 +141,32 @@ def test_shared_token_accepts_existing_configuration_names(monkeypatch, key):
for name in ("TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"):
monkeypatch.delenv(name, raising=False)
assert Settings(_env_file=None, **{key: "one-token"}).TIANDITU_TOKEN == "one-token"
@pytest.mark.parametrize('admin_code,region,county_code,county,province', [
('341126', '凤阳县', '156341126', '凤阳县', '156340000'),
('320506', '吴中区', '320506', '吴中区', '320000'),
('440800', '湛江市', '156440803', '霞山区', '156440000'),
('310000', '上海市', '156310118', '青浦区', '156310000'),
])
def test_search_uses_project_scope_for_districts_and_cities(admin_code, region, county_code, county, province):
config = PlaceSearchConfig(enabled=True, admin_code=admin_code, region=region)
client = FakeClient(payload(pois=[poi(countyCode=county_code, county=county, provinceCode=province)]))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), config=config, client=client))
assert json.loads(client.calls[0][1]['postStr'])['specify'] == '156' + admin_code
assert result.region == region and result.items[0].district == county
def test_configured_city_rejects_other_city_in_same_province():
config = PlaceSearchConfig(enabled=True, admin_code='440800', region='湛江市')
with pytest.raises(PlaceSearchError, match='无效'):
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), config=config,
client=FakeClient(payload(pois=[poi(countyCode='156440104', county='越秀区', provinceCode='156440000')]))))
def test_disabled_project_does_not_call_provider():
client = FakeClient()
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'),
config=CONFIG.model_copy(update={'enabled': False}), client=client))
assert exc.value.status_code == 503 and client.calls == []
+74
View File
@@ -0,0 +1,74 @@
from copy import deepcopy
import json
import pytest
from pydantic import ValidationError
from app.domain.schemas.operational_task import InvestigationReportInput, InvestigationReportRevision
def rich_report():
return {
"title": "演练压力核验", "current_conclusion": "待核验", "conclusion_boundary": "演练记录",
"stage": "interim", "outcome": "concluded", "data_gaps": [], "evidence_refs": ["demo-1"],
"ui_blocks": [{
"evidence_refs": ["demo-1"],
"block": {
"version": "answer-ui@1", "block_id": "pressure", "fallback_text": "演练压力 0.27 MPa",
"spec": {"root": "chart", "elements": {
"chart": {"type": "Chart", "children": [], "props": {
"title": "演练压力", "chart_type": "line", "x_data": ["08:00", "09:00"],
"series": [{"name": "压力 MPa", "data": [0.3, 0.27]}], "source": "演练记录",
}},
}},
},
}],
}
def test_report_ui_round_trips_with_a_version_and_keeps_legacy_reports_readable():
report = InvestigationReportInput.model_validate(rich_report())
snapshot = InvestigationReportRevision(
revision=3, execution_generation=2, source_session_id="test",
published_at="2026-10-20T08:00:00Z", published_by="test", report=report,
)
restored = InvestigationReportRevision.model_validate_json(snapshot.model_dump_json())
assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
assert (restored.revision, restored.execution_generation) == (3, 2)
old = json.loads(snapshot.model_dump_json())
del old["report"]["ui_blocks"]
assert InvestigationReportRevision.model_validate(old).report.ui_blocks == []
@pytest.mark.parametrize("defect", ["unknown", "script", "unaligned", "nan", "cycle", "unreachable", "duplicate", "foreign", "many", "oversized"])
def test_report_ui_rejects_invalid_or_unbound_content(defect):
data = rich_report()
entry = data["ui_blocks"][0]
spec = entry["block"]["spec"]
chart = spec["elements"]["chart"]
if defect == "unknown":
chart["type"] = "iframe"
elif defect == "script":
chart["props"]["onClick"] = "execute()"
elif defect == "unaligned":
chart["props"]["series"][0]["data"] = [1]
elif defect == "nan":
chart["props"]["series"][0]["data"][0] = float("nan")
elif defect == "cycle":
spec["elements"]["chart"] = {"type": "Stack", "props": {"columns": "one", "gap": "normal"}, "children": ["chart"]}
elif defect == "unreachable":
spec["elements"]["unused"] = deepcopy(chart)
elif defect == "duplicate":
data["ui_blocks"].append(deepcopy(entry))
elif defect == "foreign":
entry["evidence_refs"] = ["other-task"]
elif defect == "many":
data["ui_blocks"] = [{**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": f"b{i}"}} for i in range(9)]
elif defect == "oversized":
# Individually valid blocks can still exceed the UTF-8 report budget.
elements = {"root": {"type": "Stack", "props": {"columns": "one", "gap": "normal"}, "children": [f"n{i}" for i in range(20)]}}
elements.update({f"n{i}": {"type": "Text", "props": {"text": "资" * 3900, "tone": "neutral"}, "children": []} for i in range(20)})
entry["block"]["spec"] = {"root": "root", "elements": elements}
data["ui_blocks"] = [entry, {**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": "second"}}]
with pytest.raises(ValidationError):
InvestigationReportInput.model_validate(data)
+110
View File
@@ -0,0 +1,110 @@
import asyncio
import json
import logging
import httpx
import pytest
from pydantic import ValidationError
from app.core.config import settings
from app.domain.schemas.place_search import ReverseGeocodingRequest
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.services.place_search import PlaceSearchError
from app.services.reverse_geocoding import reverse_geocode_tianditu
CONFIG = PlaceSearchConfig(enabled=True, admin_code='341126', region='凤阳县')
POINT = ReverseGeocodingRequest(longitude=117.570309, latitude=32.868029)
def provider_result(**component):
return {'status': '0', 'result': {
'formatted_address': '安徽省滁州市凤阳县府城镇朝阳路76号',
'location': {'lon': 117.57, 'lat': 32.86},
'addressComponent': {'poi': '府城镇', 'road': '朝阳路', 'county': '凤阳县',
'county_code': '156341126', 'province_code': '156340000', **component}}}
@pytest.fixture(autouse=True)
def token(monkeypatch):
monkeypatch.setattr(settings, 'TIANDITU_TOKEN', 'test-only-token')
def lookup(data=None, *, config=CONFIG, status=200, failure=None):
calls = []
def transport(request):
calls.append(request)
if failure:
raise failure
return httpx.Response(status, json=provider_result() if data is None else data)
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as client:
return await reverse_geocode_tianditu(POINT, config=config, client=client)
return asyncio.run(run()), calls
def test_reverse_lookup_keeps_clicked_coordinate_and_normalizes_the_address():
result, calls = lookup()
assert result.name == '府城镇' and result.address == '安徽省滁州市凤阳县府城镇朝阳路76号'
assert (result.longitude, result.latitude) == (POINT.longitude, POINT.latitude)
assert result.district == '凤阳县' and result.category == '附近地名'
params = calls[0].url.params
assert params['type'] == 'geocode' and params['tk'] == 'test-only-token'
assert json.loads(params['postStr']) == {'lon': POINT.longitude, 'lat': POINT.latitude, 'ver': 1}
def test_reverse_lookup_supports_city_scope_and_falls_back_to_real_road_name():
result, _ = lookup(provider_result(poi='', county='青浦区', county_code='156310118', province_code='156310000'),
config=PlaceSearchConfig(enabled=True, admin_code='310000', region='上海市'))
assert result.name == '朝阳路' and result.category == '地址' and result.district == '青浦区'
@pytest.mark.parametrize('data,status', [
({'status': '0', 'result': None}, 404),
({'status': '0', 'result': {'formatted_address': ''}}, 404),
({'status': '1'}, 502),
({'status': '0', 'result': 'bad'}, 502),
(provider_result(county='奉贤区', county_code='156310120', province_code='156310000'), 422),
(provider_result(county='定远县', county_code='156341125'), 422),
])
def test_reverse_lookup_distinguishes_no_data_bad_response_and_out_of_scope(data, status):
with pytest.raises(PlaceSearchError) as exc:
lookup(data)
assert exc.value.status_code == status
@pytest.mark.parametrize('failure,status', [(httpx.ReadTimeout('private-url'), 504), (httpx.ConnectError('private-url'), 502)])
def test_reverse_lookup_sanitizes_transport_failures(failure, status):
with pytest.raises(PlaceSearchError) as exc:
lookup(failure=failure)
assert exc.value.status_code == status and 'private-url' not in exc.value.detail
def test_reverse_lookup_redacts_provider_key_from_http_logs(caplog):
caplog.set_level(logging.INFO, logger='httpx')
lookup()
messages = [record.getMessage() for record in caplog.records if record.name == 'httpx']
assert messages and any('tk=[redacted]' in message for message in messages)
assert all('test-only-token' not in message for message in messages)
@pytest.mark.parametrize('body', [
{'longitude': 181, 'latitude': 32}, {'longitude': 117, 'latitude': -91},
{'longitude': float('nan'), 'latitude': 32}, {'longitude': 117, 'latitude': float('inf')},
{'longitude': 117, 'latitude': 32, 'region': '全国'},
])
def test_reverse_lookup_rejects_invalid_coordinates_and_scope_overrides(body):
with pytest.raises(ValidationError):
ReverseGeocodingRequest(**body)
def test_reverse_lookup_requires_configuration_and_shared_key(monkeypatch):
with pytest.raises(PlaceSearchError) as exc:
lookup(config=CONFIG.model_copy(update={'enabled': False}))
assert exc.value.status_code == 503
monkeypatch.setattr(settings, 'TIANDITU_TOKEN', '')
with pytest.raises(PlaceSearchError) as exc:
lookup()
assert exc.value.status_code == 503
+58
View File
@@ -49,6 +49,32 @@ class _FakeConnection:
return self.cursor_instance
class _ReadingCursor:
def __init__(self, rows):
self.rows = rows
self.calls: list[tuple[str, tuple]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def execute(self, query, params):
self.calls.append((str(query), params))
async def fetchall(self):
return self.rows
class _ReadingConnection:
def __init__(self, rows):
self.cursor_instance = _ReadingCursor(rows)
def cursor(self):
return self.cursor_instance
def test_update_scada_field_inserts_when_update_hits_no_rows():
ScadaRepository = _load_scada_repository()
conn = _FakeConnection(initial_rowcount=0)
@@ -114,3 +140,35 @@ def test_update_scada_field_batch_uses_one_set_based_statement():
["170490", "170491"],
[26.5, 27.0],
)
def test_get_latest_scada_readings_uses_canonical_ids_and_one_bounded_query():
ScadaRepository = _load_scada_repository()
observed_at = datetime(2026, 10, 22, 1, 2, tzinfo=timezone.utc)
conn = _ReadingConnection([
{
"time": observed_at,
"device_id": "D-1",
"monitored_value": 0.31,
"cleaned_value": 0.3,
}
])
result = asyncio.run(
ScadaRepository.get_latest_scada_readings(conn, ["D-1", "D-2"])
)
assert result["D-1"]["time"] == observed_at
assert len(conn.cursor_instance.calls) == 1
query, params = conn.cursor_instance.calls[0]
assert "DISTINCT ON (device_id)" in query
assert "ORDER BY device_id, time DESC" in query
assert params == (["D-1", "D-2"],)
def test_get_latest_scada_readings_skips_database_for_empty_page():
ScadaRepository = _load_scada_repository()
conn = _ReadingConnection([])
assert asyncio.run(ScadaRepository.get_latest_scada_readings(conn, [])) == {}
assert conn.cursor_instance.calls == []
+8 -14
View File
@@ -19,8 +19,8 @@ def anyio_backend():
@pytest.mark.anyio
@pytest.mark.parametrize("repository", [OperationalTaskRepository, TaskAlertRepository])
@pytest.mark.parametrize("database", ["tjwater_v2", "other_project"])
async def test_event_order_lock_precedes_business_writes_and_is_v2_only(repository, database):
@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "other_project"])
async def test_event_order_lock_precedes_business_writes_in_each_project_database(repository, database):
@asynccontextmanager
async def transaction():
yield
@@ -29,21 +29,15 @@ async def test_event_order_lock_precedes_business_writes_and_is_v2_only(reposito
info=SimpleNamespace(dbname=database), execute=AsyncMock(), transaction=transaction,
)
async with repository(connection, uuid4()).transaction():
if database == "tjwater_v2":
connection.execute.assert_awaited_once_with(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,),
)
else:
connection.execute.assert_not_awaited()
connection.execute.assert_awaited_once_with(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,),
)
def test_sync_producers_use_same_database_scoped_lock():
connection = SimpleNamespace(info=SimpleNamespace(dbname="tjwater_v2"), execute=Mock())
@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "other_project"])
def test_sync_producers_use_same_database_scoped_lock(database):
connection = SimpleNamespace(info=SimpleNamespace(dbname=database), execute=Mock())
lock_event_transaction_sync(connection)
connection.execute.assert_called_once_with(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,),
)
connection.info.dbname = "other_project"
connection.execute.reset_mock()
lock_event_transaction_sync(connection)
connection.execute.assert_not_called()
+101
View File
@@ -0,0 +1,101 @@
from dataclasses import replace
from uuid import uuid4
import httpx
import pytest
from scripts import run_operational_worker as worker
from scripts import sync_peripheral_work_orders as sync
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
def settings():
projects = (str(uuid4()), str(uuid4()))
return worker.WorkerSettings("http://server.test", "http://agent.test", projects[0],
bearer="TEST_ONLY", project_ids=projects)
def test_explicit_project_list_overrides_single_project_and_deduplicates(monkeypatch, settings):
monkeypatch.setattr(worker, "load_dotenv", lambda *args, **kwargs: None)
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_IDS", ", ".join(settings.project_ids + settings.project_ids[:1]))
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_ID", "ignored-legacy-value")
monkeypatch.setenv("OPERATIONAL_WORKER_BEARER", "TEST_ONLY")
parsed = worker.WorkerSettings.from_environment()
assert parsed.project_ids == settings.project_ids
assert tuple(scope.project_id for scope in parsed.project_scopes()) == settings.project_ids
assert all(not scope.project_ids for scope in parsed.project_scopes())
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_IDS", settings.project_ids[0] + ",invalid")
with pytest.raises(ValueError):
worker.WorkerSettings.from_environment()
def test_existing_single_project_configuration_remains_supported(monkeypatch, settings):
monkeypatch.setattr(worker, "load_dotenv", lambda *args, **kwargs: None)
monkeypatch.delenv("OPERATIONAL_WORKER_PROJECT_IDS", raising=False)
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_ID", settings.project_id)
monkeypatch.setenv("OPERATIONAL_WORKER_BEARER", "TEST_ONLY")
assert worker.WorkerSettings.from_environment().project_scopes()[0].project_id == settings.project_id
assert replace(settings, project_ids=()).project_scopes() == (replace(settings, project_ids=()),)
@pytest.mark.anyio
async def test_multi_project_worker_reconciles_other_projects_when_one_fails(monkeypatch, settings):
seen = []
class FakeWorker:
def __init__(self, scope, client):
self.scope = scope
async def tick(self):
seen.append(self.scope.project_id)
if self.scope.project_id == settings.project_ids[0]:
raise httpx.ConnectError("test failure")
return 0
monkeypatch.setattr(worker.WorkerSettings, "from_environment", lambda: settings)
monkeypatch.setattr(worker, "OperationalWorker", FakeWorker)
with pytest.raises(SystemExit) as result:
await worker.run(once=True)
assert result.value.code == 1
assert tuple(seen) == settings.project_ids
@pytest.mark.anyio
@pytest.mark.parametrize("once", [True, False])
async def test_sync_entrypoint_keeps_project_headers_and_cursors_separate(monkeypatch, settings, once):
seen = []
async def check_scope(client, scope, url, credentials):
seen.append((scope.project_id, (await credentials.headers(client))["X-Project-Id"]))
return 0
class FakeSynchronizer:
def __init__(self, client, scope, url, credentials):
self.args = client, scope, url, credentials
async def serve(self, interval):
return await check_scope(*self.args)
monkeypatch.setattr(worker.WorkerSettings, "from_environment", lambda: settings)
monkeypatch.setenv("OPERATIONAL_ACTION_PERIPHERAL_URL", "http://peripheral.test")
monkeypatch.setattr(sync, "sync_once", check_scope)
monkeypatch.setattr(sync, "PeripheralWorkOrderSynchronizer", FakeSynchronizer)
await sync.run(once=once)
assert seen == [(project, project) for project in settings.project_ids]
@pytest.mark.anyio
async def test_project_workers_have_independent_tokens_sessions_and_message_cursors(settings):
async with httpx.AsyncClient() as client:
first, second = [worker.OperationalWorker(scope, client) for scope in settings.project_scopes()]
first.credentials._token = "FIRST_TEST_TOKEN"
first._agent_sessions["run-1"] = "session-1"
first._message_cursor = "cursor-1"
assert second.credentials._token == ""
assert second._agent_sessions == {}
assert second._message_cursor is None