feat(server): integrate MCP queries and investigation messaging

Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

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

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
+58
View File
@@ -0,0 +1,58 @@
"""Public process probes, separate from authenticated project diagnostics."""
import asyncio
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.infra.db.metadb.database import engine
router = APIRouter(include_in_schema=False)
METADATA_TIMEOUT_SECONDS = 2
async def metadata_database_ready() -> bool:
try:
async with asyncio.timeout(METADATA_TIMEOUT_SECONDS):
async with engine.connect() as connection:
result = await connection.execute(text("SELECT 1"))
return result.scalar_one() == 1
except Exception:
# Public probes must not expose connection strings or driver errors.
return False
@router.get("/health")
async def health() -> dict[str, str]:
"""Check HTTP liveness without project or database dependencies."""
return {"status": "ok"}
@router.get("/ready")
async def readiness(request: Request) -> JSONResponse:
"""Check startup, metadata connectivity and the enabled MCP lifecycle."""
started = getattr(request.app.state, "startup_complete", False)
runtime = getattr(request.app.state, "mcp_runtime", None)
mcp_ready = runtime is None or runtime.ready
metadata_ready = await metadata_database_ready() if started else False
ready = started and metadata_ready and mcp_ready
return JSONResponse(
status_code=200 if ready else 503,
content={
"status": "ready" if ready else "not_ready",
"checks": {
"startup": "ready" if started else "not_ready",
"metadata_database": (
"not_checked" if not started
else "ready" if metadata_ready else "unavailable"
),
"mcp_transport": (
"disabled" if runtime is None
else "ready" if mcp_ready else "not_ready"
),
},
},
headers={"Cache-Control": "no-store"},
)
+101
View File
@@ -0,0 +1,101 @@
"""Actor-private messaging APIs; the Agent verifies its session ownership."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from psycopg import AsyncConnection
from app.auth.permissions import OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, require_permission
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection
from app.domain.domain_errors import DomainError
from app.domain.schemas.agent_messages import (
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
)
from app.infra.db.postgresql.agent_messages import AgentMessageRepository
from app.services.agent_messages import AgentMessageService
router = APIRouter(prefix="/agent-messages")
def _service(ctx: ProjectContext, conn: AsyncConnection) -> AgentMessageService:
return AgentMessageService(AgentMessageRepository(conn, ctx.project_id), ctx)
def _raise_domain(exc: DomainError):
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
@router.post("", response_model=AgentMessage, status_code=201)
async def create_agent_message(
command: AgentMessageCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).create(command)
except DomainError as exc:
_raise_domain(exc)
@router.get("", response_model=AgentMessagePage)
async def list_agent_messages(
session_id: str = Query(min_length=1, max_length=500),
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
pending_only: bool = False,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).list_session(session_id, limit, cursor, pending_only)
except DomainError as exc:
_raise_domain(exc)
@router.get("/pending", response_model=AgentMessagePage)
async def list_pending_agent_messages(
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).pending(limit, cursor)
except DomainError as exc:
_raise_domain(exc)
@router.get("/{message_id}", response_model=AgentMessage)
async def get_agent_message(
message_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).get(message_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/{message_id}/reply", response_model=AgentMessage, status_code=201)
async def reply_agent_message(
message_id: UUID, command: AgentMessageReply,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).reply(message_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post("/{message_id}/delivery", response_model=AgentMessage)
async def record_agent_message_delivery(
message_id: UUID, command: AgentMessageDelivery,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).delivery(message_id, command)
except DomainError as exc:
_raise_domain(exc)
+60
View File
@@ -0,0 +1,60 @@
"""Host-only MCP bindings: declared in REST contracts even when MCP is disabled."""
from fastapi import APIRouter, Header, HTTPException, Request, Response
from psycopg.errors import UndefinedTable, InvalidSchemaName
from app.mcp.models import BindInput, BindResponse
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
router = APIRouter(tags=["MCP Query Context"])
def runtime_for(request: Request):
runtime = getattr(request.app.state, "mcp_runtime", None)
if runtime is None:
raise HTTPException(503, "Product MCP is disabled")
return runtime
@router.post("/mcp-query-contexts", response_model=BindResponse)
async def create_context(
command: BindInput,
request: Request,
x_project_id: str = Header(alias="X-Project-Id"),
) -> BindResponse:
runtime = runtime_for(request)
principal = await runtime.identity(request)
from app.mcp.contexts import bind_context
try:
async with runtime.scope_factory(principal, x_project_id) as scope:
binding = await bind_context(scope, principal, command, runtime.settings)
except (UndefinedTable, InvalidSchemaName):
raise HTTPException(
503, "MCP project storage has not been provisioned"
) from None
return BindResponse(
context_handle=binding.context_handle,
expires_at=binding.expires_at,
task_id=binding.task_id,
run_id=binding.run_id,
)
@router.delete("/mcp-query-contexts/{context_handle}", status_code=204)
async def delete_context(
context_handle: str,
request: Request,
x_project_id: str = Header(alias="X-Project-Id"),
) -> Response:
runtime = runtime_for(request)
principal = await runtime.identity(request)
try:
async with runtime.scope_factory(principal, x_project_id) as scope:
await McpQueryRepository(scope.connection, scope.project.project_id).revoke(
context_handle, principal.subject, principal.client_id
)
except (UndefinedTable, InvalidSchemaName):
raise HTTPException(
503, "MCP project storage has not been provisioned"
) from None
return Response(status_code=204)
+66 -5
View File
@@ -24,6 +24,7 @@ from app.auth.project_dependencies import (
get_project_pg_connection,
)
from app.domain.domain_errors import DomainError
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
@@ -70,6 +71,9 @@ from app.domain.schemas.operational_task import (
TaskStatus,
TaskDataQueryResult,
TaskType,
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import (
@@ -92,10 +96,9 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
def _task_permissions(ctx: ProjectContext) -> set[str]:
permissions = set(permissions_for_context(ctx))
if ctx.system_role == "service":
permissions.difference_update({SCADA_VIEW, SIMULATION_VIEW})
return permissions
# Investigations use preconfigured project access, including service accounts.
# Do not force an already-authorized reader through legacy per-task delegation.
return set(permissions_for_context(ctx))
def _raise_domain(exc: DomainError):
@@ -176,6 +179,8 @@ async def list_operational_task_runs(
run_status: Literal[
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
] | None = Query(default=None, alias="status"),
task_type: TaskType | None = None,
task_created_from: WorkbenchDateTime | None = None,
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
@@ -193,6 +198,8 @@ async def list_operational_task_runs(
try:
items, next_cursor = await _service(ctx, conn).list_runs(
status=run_status,
task_type=task_type,
task_created_from=task_created_from,
limit=limit,
cursor_created_at=cursor_created_at,
cursor_run_id=cursor_run_id,
@@ -202,6 +209,32 @@ async def list_operational_task_runs(
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs", response_model=OperationalTaskRunPage)
async def list_task_run_history(
task_id: UUID,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return OperationalTaskRunPage(items=await _service(ctx, conn).task_runs(task_id, limit, offset))
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs/{run_id}", response_model=OperationalTaskRun)
async def get_task_run(
task_id: UUID, run_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).task_run(task_id, run_id)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/schedules",
response_model=OperationalTaskSchedule,
@@ -272,7 +305,10 @@ async def recover_operational_task_leases(
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).recover_expired_leases(command.limit)
return await _service(ctx, conn).recover_expired_leases(
command.limit, task_type=command.task_type,
task_created_from=command.task_created_from,
)
except DomainError as exc:
_raise_domain(exc)
@@ -473,6 +509,31 @@ async def publish_operational_task_artifacts(
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/presentation-cursor", response_model=TaskPresentationCursor)
async def get_operational_task_presentation_cursor(
task_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).presentation_cursor(task_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
async def publish_operational_task_presentation_event(
task_id: UUID,
command: TaskPresentationEventCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).publish_presentation_event(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult
)
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, HTTPException
from app.domain.schemas.place_search import PlaceSearchRequest, PlaceSearchResponse
from app.services.place_search import PlaceSearchError, search_tianditu_places
router = APIRouter()
@router.post(
"/place-search-requests",
response_model=PlaceSearchResponse,
summary="Search Fengxian places",
description="调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。",
)
async def tianditu_place_search(request: PlaceSearchRequest) -> PlaceSearchResponse:
try:
return await search_tianditu_places(request)
except PlaceSearchError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+3 -22
View File
@@ -1,5 +1,5 @@
from typing import Any, List, Literal, Optional
from datetime import datetime, timedelta
from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi.responses import PlainTextResponse
from app.auth.keycloak_dependencies import get_current_keycloak_username
@@ -18,7 +18,6 @@ from app.services.simulation_scenarios import (
from app.services.valve_isolation import analyze_valve_isolation
from app.domain.time import (
parse_aware_time,
parse_clock_duration_seconds,
parse_utc_time,
)
from pydantic import BaseModel, Field, field_validator
@@ -50,27 +49,9 @@ class PressureRegulation(BaseModel):
def run_simulation_manually_by_date(
network_name: str, start_time: datetime, duration: int
) -> None:
end_datetime = start_time + timedelta(minutes=duration)
time_properties = simulation.get_time(network_name)
hydraulic_step_seconds = parse_clock_duration_seconds(
time_properties["HYDRAULIC TIMESTEP"],
field_name="HYDRAULIC TIMESTEP",
simulation.run_online_simulation(
name=network_name, start_time=start_time, duration_seconds=duration * 60,
)
if hydraulic_step_seconds <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
scada_mappings = simulation.query_corresponding_element_id_and_query_id(
network_name
)
current_time = start_time
while current_time < end_datetime:
simulation.run_simulation(
name=network_name,
simulation_type="realtime",
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
scada_mappings=scada_mappings,
)
current_time += hydraulic_step
# 必须用这个PlainTextResponse,不然每个key都有引号
+2 -1
View File
@@ -11,9 +11,10 @@ from app.services.timeseries_history import TimeseriesHistoryService
from .dependencies import get_timescale_connection, get_postgres_connection
router = APIRouter()
history_router = APIRouter()
@router.post(
@history_router.post(
"/timeseries/views/element-history/query",
summary="批量查询管网元素历史数据",
response_model=ElementHistoryResponse,
-29
View File
@@ -1,29 +0,0 @@
from typing import Any
from fastapi import APIRouter, HTTPException, status
from app.services.web_search import (
BochaSearchAPIError,
BochaSearchConfigError,
WebSearchRequest,
search_bocha_web,
)
router = APIRouter()
@router.post(
"/web-searches",
summary="Web Search",
description="调用 Bocha Web Search API 获取实时网页搜索结果",
)
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
try:
return await search_bocha_web(request)
except BochaSearchConfigError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except BochaSearchAPIError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
+2 -66
View File
@@ -129,72 +129,8 @@ async def workbench_runtime(
ctx: ProjectContext = Depends(get_project_context),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
analysis_runs: list[dict] | None = None
latest_results: list[dict] | None = None
latest_analysis_map_payload: dict | None = None
analysis_availability = "not_configured"
alert_storage_configured = False
alerts: list[dict] | None = None
schedule_availability = "not_configured"
alert_availability = "not_configured"
try:
async with _projection_transaction(conn):
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
if analysis_runs:
latest_results = await AnalysisRepository.list_recent_results(
conn, analysis_runs[0]["run_id"], limit=100
)
analysis_availability = "ready" if analysis_runs else "not_configured"
except Exception:
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
analysis_runs = None
latest_results = None
analysis_availability = "degraded"
if analysis_runs and latest_results:
try:
async with _projection_transaction(conn):
latest_analysis_map_payload = await _sensor_location_map_payload(
conn, latest_results
)
except Exception:
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
try:
async with _projection_transaction(conn):
operations_availability = await WorkbenchOperationsRepository.availability(conn)
alert_storage_configured = operations_availability.alerts
except Exception:
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
schedule_availability = "degraded"
alert_availability = "degraded"
if alert_storage_configured:
try:
async with _projection_transaction(conn):
alert_documents = await WorkbenchOperationsRepository.list_alerts(
conn, str(ctx.project_id)
)
alerts, invalid_alerts = _validate_documents(
alert_documents, WorkbenchPersistedAlert, "alert"
)
alert_availability = "degraded" if invalid_alerts else "ready"
except Exception:
alerts = None
alert_availability = "degraded"
logger.warning("Workbench alert projection is unavailable", exc_info=True)
return await build_workbench_runtime_snapshot(
analysis_runs=analysis_runs,
latest_analysis_results=latest_results,
latest_analysis_map_payload=latest_analysis_map_payload,
alert_storage_configured=alert_storage_configured,
persisted_alerts=alerts,
scenario_availability=analysis_availability,
artifact_availability=analysis_availability,
schedule_availability=schedule_availability,
alert_availability=alert_availability,
projection_degraded=False,
)
from app.services.workbench_runtime import read_workbench_runtime
return await read_workbench_runtime(ctx, conn)
async def _sensor_location_map_payload(
+13 -5
View File
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
access,
admin_metadata,
agent_auth,
agent_messages,
audit,
burst_detection,
burst_location,
@@ -12,12 +13,12 @@ from app.api.v1.endpoints import (
meta,
model_import,
operational_tasks,
place_search,
project,
project_data,
scada,
sensor_placement,
simulation,
web_search,
workbench_runtime,
workbench_events,
)
@@ -103,6 +104,7 @@ api_router.include_router(
)
api_router.include_router(access.router, tags=["Access Control"])
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
api_router.include_router(agent_messages.router, tags=["Agent Messages"])
api_router.include_router(
admin_metadata.router,
tags=["Metadata Admin"],
@@ -159,13 +161,13 @@ api_router.include_router(
dependencies=[optimization_run_access],
)
api_router.include_router(
web_search.router,
tags=["Web Search"],
geocoding.router,
tags=["Geocoding"],
dependencies=[webgis_view_access],
)
api_router.include_router(
geocoding.router,
tags=["Geocoding"],
place_search.router,
tags=["Place Search"],
dependencies=[webgis_view_access],
)
api_router.include_router(
@@ -195,6 +197,12 @@ for endpoint_router, tag in (
dependencies=[simulation_access],
)
api_router.include_router(
ts_composite.history_router,
tags=["TimescaleDB - Composite"],
dependencies=[Depends(require_permission(SCADA_VIEW))],
)
for endpoint_router, tag in (
(ts_scada.router, "TimescaleDB - SCADA"),
(ts_composite.router, "TimescaleDB - Composite"),
+10
View File
@@ -6,6 +6,9 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from app.core.config import settings
from app.mcp.config import McpSettings
mcp_resource_settings = McpSettings()
oauth2_optional = OAuth2PasswordBearer(
tokenUrl="keycloak", auto_error=False
@@ -29,6 +32,13 @@ def _decode_keycloak_token(token: str) -> dict:
algorithms=[settings.KEYCLOAK_ALGORITHM],
audience=settings.KEYCLOAK_AUDIENCE or None,
)
# A resource-bound MCP token must never authorize product REST APIs,
# including when another audience overlaps the configured API audience.
audience = payload.get("aud", [])
audiences = [audience] if isinstance(audience, str) else audience
if (isinstance(audiences, list) and mcp_resource_settings.audience in audiences
or payload.get("azp") == mcp_resource_settings.host_client_id):
raise JWTError("MCP resource tokens cannot authorize product REST APIs")
if settings.KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS <= 0:
return payload
+14 -7
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from typing import Optional
from urllib.parse import quote_plus
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -25,6 +26,9 @@ class Settings(BaseSettings):
TIMESCALEDB_DB_PORT: str = "5433"
TIMESCALEDB_DB_USER: str = "postgres"
TIMESCALEDB_DB_PASSWORD: str = "password"
TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES: int = Field(
default=1_000_000, ge=1, le=2_147_483_647,
)
# Metadata Database Config (PostgreSQL)
METADATA_DB_NAME: str = "system_hub"
METADATA_DB_HOST: str = "localhost"
@@ -77,16 +81,19 @@ class Settings(BaseSettings):
raise ValueError("CORS_ALLOWED_ORIGINS must list explicit origins")
return origins
# Bocha Web Search API
BOCHA_API_KEY: str = ""
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0
# Tianditu Geocoding API
TIANDITU_GEOCODER_TOKEN: str = ""
# One shared Tianditu key for geocoding, place search and other services.
TIANDITU_TOKEN: str = Field(
"",
validation_alias=AliasChoices(
"TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"
),
)
TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0
TIANDITU_SEARCH_URL: str = "https://api.tianditu.gov.cn/v2/search"
TIANDITU_SEARCH_TIMEOUT_SECONDS: float = 15.0
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
db_password = quote_plus(self.DB_PASSWORD)
+17
View File
@@ -0,0 +1,17 @@
import logging
import re
class TiandituTokenFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
if "tianditu.gov.cn" in message:
record.msg = re.sub(r"([?&]tk=)[^&\s\"']+", r"\1[redacted]", message)
record.args = ()
return True
def install_tianditu_token_filter() -> None:
logger = logging.getLogger("httpx")
if not any(isinstance(item, TiandituTokenFilter) for item in logger.filters):
logger.addFilter(TiandituTokenFilter())
+59
View File
@@ -0,0 +1,59 @@
"""Private, durable messages between an ordinary chat and one investigation run."""
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field
from app.domain.schemas.shared import NonEmptyStr, StrictModel
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
MessageText = Annotated[str, Field(min_length=1, max_length=12000, pattern=r"\S")]
MessageStatus = Literal["pending", "enqueued", "delivered", "failed"]
class AgentMessageCreate(StrictModel):
request_id: UUID
task_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
text: MessageText
class AgentMessageReply(StrictModel):
request_id: UUID
source_session_id: NonEmptyStr
text: MessageText
class AgentMessageDelivery(StrictModel):
status: Literal["enqueued", "delivered", "failed"]
target_session_id: NonEmptyStr | None = None
native_message_id: NonEmptyStr | None = None
failure_detail: Annotated[str, Field(max_length=500)] | None = None
class AgentMessage(StrictModel):
project_id: UUID
message_id: UUID
request_id: UUID
task_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
source_owner_id: UUID
conversation_owner_id: UUID
target_session_id: NonEmptyStr | None = None
direction: Literal["to_task", "to_chat"]
in_reply_to: UUID | None = None
text: MessageText
status: MessageStatus
native_message_id: NonEmptyStr
failure_detail: str | None = None
created_at: WorkbenchDateTime
updated_at: WorkbenchDateTime
delivered_at: WorkbenchDateTime | None = None
class AgentMessagePage(StrictModel):
items: list[AgentMessage]
next_cursor: str | None = None
+40 -1
View File
@@ -11,9 +11,48 @@ from app.domain.schemas.workbench_runtime import WorkbenchDateTime
TaskDataSource = Literal[
"workbench_runtime", "scada_devices", "scada_readings", "peripheral_points",
"peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
]
McpSnapshotSource = Literal[
'run_simulation',
'run_contaminant_simulation',
'analyze_valve_closure',
'analyze_flushing',
'analyze_pressure_regulation',
'optimize_sensor_placement',
'run_burst_detection',
'run_burst_location',
'analyze_valve_isolation',
'run_leakage_identification',
'get_element_properties',
'list_elements',
'get_network_options',
'get_network_schema',
'list_scada_devices',
'get_scada_device',
'query_scada_readings',
'query_model_baseline_series',
'get_model_baseline_at_time',
'query_model_baseline_field',
'query_analysis_series',
'query_analysis_values',
'list_analysis_runs',
'get_analysis_run',
'list_analysis_results',
'list_sensor_placement_runs',
'get_sensor_placement_run',
'query_element_history',
'get_element_gis_properties',
'get_source_gis_properties',
'get_workbench_runtime',
'get_task_context',
'query_peripheral_data',
'get_task_evidence',
]
TaskSnapshotSource = TaskDataSource | McpSnapshotSource
class TaskDataQuery(StrictModel):
source: TaskDataSource
device_ids: list[NonEmptyStr] = Field(default_factory=list, max_length=20)
@@ -36,7 +75,7 @@ class TaskDataQuery(StrictModel):
class TaskDataSnapshot(StrictModel):
source: TaskDataSource
source: TaskSnapshotSource
source_ref: NonEmptyStr
queried_at: WorkbenchDateTime
origin: Literal["system_data", "synthetic"]
+123 -1
View File
@@ -202,11 +202,24 @@ class InvestigationDataGap(StrictModel):
blocking: StrictBool
TaskMapLayer = Literal["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]
TaskFeatureId = Annotated[str, Field(min_length=1, max_length=128)]
class InvestigationMapTarget(StrictModel):
source_id: TaskMapLayer
feature_id: TaskFeatureId
class InvestigationDiagramNodeRef(StrictModel):
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
label: NonEmptyStr
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
status: Literal[
"pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"
] | None = None
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
class InvestigationDiagram(StrictModel):
@@ -271,6 +284,22 @@ class InvestigationReportInput(InvestigationReport):
data_gaps: list[InvestigationDataGap]
class InvestigationProgress(StrictModel):
"""A complete reading snapshot, not a plan or a business result."""
summary: Annotated[str, Field(min_length=1, max_length=20000)]
diagram: InvestigationDiagram | None = None
class InvestigationProgressSnapshot(StrictModel):
revision: PositiveInt
run_id: UUID
execution_generation: PositiveInt
published_at: WorkbenchDateTime
source_session_id: NonEmptyStr
progress: InvestigationProgress
class ExecutionPlanSnapshot(StrictModel):
revision: PositiveInt
execution_generation: PositiveInt | None = None
@@ -306,10 +335,11 @@ class AgentArtifactsCommand(StrictModel):
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
plan: ExecutionPlanInput | None = None
report: InvestigationReportInput | None = None
progress: InvestigationProgress | None = None
@model_validator(mode="after")
def require_artifact(self):
if not self.evidence and self.plan is None and self.report is None:
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
raise ValueError("at least one artifact must be supplied")
return self
@@ -507,6 +537,7 @@ class OperationalTaskSummary(StrictModel):
workflow: NonEmptyStr
lifecycle_status: TaskLifecycleStatus = "open"
active_run: OperationalTaskRun | None = None
latest_run: OperationalTaskRun | None = None
# Compatibility fields for the staged 014 -> 015 rollout. New consumers
# must use lifecycle_status and active_run.
status: TaskStatus
@@ -541,6 +572,7 @@ class OperationalTaskPermissions(StrictModel):
class OperationalTaskDetail(OperationalTaskSummary):
latest_plan: ExecutionPlanSnapshot | None
latest_report: InvestigationReportRevision | None
latest_progress: InvestigationProgressSnapshot | None = None
evidence: list[EvidenceRecord]
actions: list[HumanApprovalAction]
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
@@ -598,6 +630,8 @@ class OperationalTaskRetryCommand(StrictModel):
class RecoverExpiredLeasesCommand(StrictModel):
limit: int = Field(default=20, ge=1, le=100)
task_type: TaskType | None = None
task_created_from: WorkbenchDateTime | None = None
class RecoverExpiredLeasesResult(StrictModel):
@@ -609,6 +643,94 @@ class AgentArtifactsResult(StrictModel):
task: OperationalTaskDetail
plan_revision: PositiveInt | None
report_revision: PositiveInt | None
progress_revision: PositiveInt | None = None
class TaskSafeChartSeries(StrictModel):
name: Annotated[str, Field(max_length=128)]
data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)]], Field(min_length=1, max_length=100)]
class TaskSafeChart(StrictModel):
title: Annotated[str, Field(max_length=200)]
chart_type: Literal["line", "bar"]
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
series: Annotated[list[TaskSafeChartSeries], Field(min_length=1, max_length=8)]
@model_validator(mode="after")
def validate_alignment(self):
if any(len(series.data) != len(self.x_data) for series in self.series):
raise ValueError("chart series must align with x_data")
return self
class TaskHighlightFeatures(StrictModel):
action: Literal["highlight_features"]
layer: TaskMapLayer
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
effect: Literal["steady", "pulse"] = "steady"
class TaskClearHighlights(StrictModel):
action: Literal["clear_highlights"]
class TaskOpenChart(StrictModel):
action: Literal["open_chart"]
chart_id: TaskFeatureId
chart: TaskSafeChart
evidence_refs: Annotated[list[NonEmptyStr], Field(min_length=1, max_length=100)]
class TaskCloseChart(StrictModel):
action: Literal["close_chart"]
chart_id: TaskFeatureId
class TaskSetOverlay(StrictModel):
action: Literal["set_overlay"]
overlay_id: TaskFeatureId
title: Annotated[str, Field(min_length=1, max_length=200)]
mode: Literal["geometry", "heatmap"]
evidence_id: NonEmptyStr
class TaskClearOverlay(StrictModel):
action: Literal["clear_overlay"]
overlay_id: TaskFeatureId
TaskPresentationCommand = Annotated[
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
Field(discriminator="action"),
]
class TaskPresentationEventCommand(StrictModel):
request_id: UUID
run_id: UUID
source_session_id: NonEmptyStr
command: TaskPresentationCommand
class TaskPresentationEventResult(StrictModel):
recorded: Literal[True] = True
event_seq: PositiveInt
run_id: UUID
execution_generation: PositiveInt
class TaskPresentationActiveRun(StrictModel):
run_id: UUID
generation: PositiveInt
status: OperationalTaskRunStatus
class TaskPresentationCursor(StrictModel):
task_id: UUID
execution_generation: PositiveInt
active_run: TaskPresentationActiveRun | None
event_cursor: int = Field(ge=0)
class TaskDataQueryResult(StrictModel):
+28
View File
@@ -0,0 +1,28 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
MAX_PLACE_SEARCH_RESULTS = 10
class PlaceSearchRequest(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
keyword: str = Field(..., min_length=1, max_length=120)
class PlaceSearchItem(BaseModel):
id: str = Field(..., min_length=1)
name: str = Field(..., min_length=1)
address: str
longitude: float = Field(..., ge=-180, le=180, allow_inf_nan=False)
latitude: float = Field(..., ge=-90, le=90, allow_inf_nan=False)
category: str | None = None
district: Literal["奉贤区"] = "奉贤区"
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["奉贤区"] = "奉贤区"
+1
View File
@@ -86,3 +86,4 @@ class ElementHistorySeries(BaseModel):
class ElementHistoryResponse(BaseModel):
series: list[ElementHistorySeries]
truncated: bool = Field(default=False, exclude=True)
+2
View File
@@ -19,6 +19,8 @@ WorkbenchEventTopic = Literal[
"operational.run.queued",
"operational.run.changed",
"operational.report.published",
"operational.progress.changed",
"operational.presentation.requested",
"operational.action.changed",
"operational.notification.changed",
"workbench.alert.changed",
+3
View File
@@ -61,7 +61,10 @@ class AuditMiddleware(BaseHTTPMiddleware):
"/api/v1/audit/session-events",
"/audit/session-events",
}
# Query handles and source arguments are not legacy REST audit payloads.
# Task queries persist scoped evidence/receipts in their own transaction.
EXCLUDED_PATH_PREFIXES = (
"/api/v1/mcp-query-contexts",
)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
+88
View File
@@ -0,0 +1,88 @@
"""Project scoped private message storage, sharing task transaction locks."""
from psycopg import AsyncConnection
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
class AgentMessageRepository(OperationalTaskRepository):
def __init__(self, connection: AsyncConnection, project_id):
super().__init__(connection, project_id)
async def get_message(self, message_id: str) -> dict | None:
cursor = await self.connection.execute(
"SELECT * FROM workbench.agent_messages WHERE project_id=%s AND message_id=%s",
(self.project_id, message_id),
)
row = await cursor.fetchone()
return self._message(row) if row else None
async def get_message_request(self, owner_id: str, session_id: str, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT * FROM workbench.agent_messages WHERE project_id=%s
AND source_owner_id=%s AND source_session_id=%s AND request_id=%s""",
(self.project_id, owner_id, session_id, request_id),
)
row = await cursor.fetchone()
return self._message(row) if row else None
@staticmethod
def _message(row) -> dict:
return dict(row)
async def insert_message(self, message: dict) -> None:
keys = [key for key in message if key != "project_id"]
await self.connection.execute(
f"INSERT INTO workbench.agent_messages (project_id,{','.join(keys)}) "
f"VALUES ({','.join(['%s'] * (len(keys) + 1))})",
(self.project_id, *(message[key] for key in keys)),
)
async def list_session_messages(
self, owner_id: str, session_id: str, limit: int, *, cursor_at=None, cursor_id=None, pending_only=False,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT * FROM workbench.agent_messages WHERE project_id=%s
AND conversation_owner_id=%s AND (source_session_id=%s OR
(direction='to_chat' AND target_session_id=%s))
AND (%s::timestamptz IS NULL OR (created_at,message_id)<(%s::timestamptz,%s::uuid))
AND (NOT %s OR status IN ('pending','enqueued'))
ORDER BY created_at DESC,message_id DESC LIMIT %s""",
(self.project_id, owner_id, session_id, session_id, cursor_at, cursor_at, cursor_id, pending_only, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def list_run_session_messages(
self, session_id: str, limit: int, *, cursor_at=None, cursor_id=None, pending_only=False,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT m.* FROM workbench.agent_messages m
JOIN workbench.operational_task_runs r ON r.project_id=m.project_id AND r.run_id=m.run_id
WHERE m.project_id=%s AND r.agent_session_id=%s
AND r.status IN ('claimed','running')
AND (%s::timestamptz IS NULL OR (m.created_at,m.message_id)<(%s::timestamptz,%s::uuid))
AND (NOT %s OR (m.direction='to_task' AND m.status IN ('pending','enqueued')))
ORDER BY m.created_at DESC,m.message_id DESC LIMIT %s""",
(self.project_id, session_id, cursor_at, cursor_at, cursor_id, pending_only, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def list_pending_messages(self, limit: int, *, cursor_at=None, cursor_id=None) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT m.* FROM workbench.agent_messages m
JOIN workbench.operational_task_runs r
ON r.project_id=m.project_id AND r.run_id=m.run_id
WHERE m.project_id=%s AND m.status IN ('pending','enqueued')
AND (m.direction='to_chat' OR r.status IN ('claimed','running'))
AND (%s::timestamptz IS NULL OR (m.created_at,m.message_id)>(%s::timestamptz,%s::uuid))
ORDER BY m.created_at,m.message_id LIMIT %s""",
(self.project_id, cursor_at, cursor_at, cursor_id, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def update_message_delivery(self, message_id: str, update: dict) -> None:
keys = list(update)
await self.connection.execute(
f"UPDATE workbench.agent_messages SET {','.join(key+'=%s' for key in keys)} "
"WHERE project_id=%s AND message_id=%s",
(*(update[key] for key in keys), self.project_id, message_id),
)
+2 -1
View File
@@ -221,6 +221,7 @@ class AnalysisRepository:
*,
limit: int = 100,
offset: int = 0,
lookahead: bool = False,
) -> list[dict]:
if not 1 <= limit <= 500 or offset < 0:
raise ValueError("analysis result page is out of range")
@@ -235,7 +236,7 @@ class AnalysisRepository:
query += " AND result_type = %s"
params = (run_id, result_type)
query += " ORDER BY created_at, result_id LIMIT %s OFFSET %s"
params += (limit, offset)
params += (limit + int(lookahead), offset)
async with conn.cursor() as cur:
await cur.execute(query, params)
+91
View File
@@ -0,0 +1,91 @@
"""Persistent MCP context and receipt storage in the selected project's BizDB."""
from datetime import datetime, timezone
from psycopg import AsyncConnection
from psycopg.types.json import Jsonb
from app.mcp.models import BoundContext
class McpQueryRepository:
def __init__(self, conn: AsyncConnection, project_id):
self.conn, self.project_id = conn, project_id
async def create(self, context: BoundContext):
async with self.conn.transaction(), self.conn.cursor() as cur:
# Bounded maintenance; expired/revoked handles remain invalid even if cleanup fails.
await cur.execute(
"""DELETE FROM workbench.mcp_query_contexts WHERE context_handle IN
(SELECT context_handle FROM workbench.mcp_query_contexts
WHERE project_id=%s AND (expires_at < now() OR revoked_at IS NOT NULL)
ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)""",
(self.project_id,),
)
await cur.execute(
"""INSERT INTO workbench.mcp_query_contexts
(context_handle,project_id,subject,client_id,document,expires_at)
VALUES (%s,%s,%s,%s,%s,%s)""",
(
context.context_handle,
self.project_id,
context.subject,
context.client_id,
Jsonb(context.model_dump(mode="json")),
context.expires_at,
),
)
async def get(self, handle: str) -> BoundContext | None:
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""SELECT document,revoked_at FROM workbench.mcp_query_contexts
WHERE context_handle=%s AND project_id=%s""",
(handle, self.project_id),
)
row = await cur.fetchone()
if not row:
return None
document = dict(row["document"])
document["revoked_at"] = row["revoked_at"]
return BoundContext.model_validate(document)
async def revoke(self, handle: str, subject: str, client_id: str):
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""UPDATE workbench.mcp_query_contexts SET revoked_at=COALESCE(revoked_at,%s)
WHERE context_handle=%s AND project_id=%s AND subject=%s AND client_id=%s""",
(
datetime.now(timezone.utc),
handle,
self.project_id,
subject,
client_id,
),
)
async def receipt(self, scope_key: str, call_id: str) -> dict | None:
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""SELECT parameters,result FROM workbench.mcp_query_receipts
WHERE project_id=%s AND scope_key=%s AND call_id=%s""",
(self.project_id, scope_key, call_id),
)
return await cur.fetchone()
async def save_receipt(
self, scope_key: str, call_id: str, parameters: dict, result: dict
):
# Caller owns the short evidence + receipt transaction.
async with self.conn.cursor() as cur:
await cur.execute(
"""INSERT INTO workbench.mcp_query_receipts
(project_id,scope_key,call_id,parameters,result) VALUES (%s,%s,%s,%s,%s)
ON CONFLICT (project_id,scope_key,call_id) DO NOTHING""",
(self.project_id, scope_key, call_id, Jsonb(parameters), Jsonb(result)),
)
async def lock_receipt(self, scope_key: str, call_id: str):
async with self.conn.cursor() as cur:
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))",
(f"mcp:{self.project_id}:{scope_key}:{call_id}",),
)
+119 -6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any
from psycopg import AsyncConnection
@@ -11,6 +12,7 @@ from uuid import uuid4
from app.domain.domain_errors import DomainError
from app.infra.db.postgresql.workbench_events import lock_event_transaction
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
class OperationalTaskRepository:
@@ -37,6 +39,13 @@ class OperationalTaskRepository:
(f"tjwater-operational:{self.project_id}:{key}",),
)
async def try_lock(self, key: str) -> bool:
cursor = await self.connection.execute(
"SELECT pg_try_advisory_xact_lock(hashtextextended(%s, 0)) AS acquired",
(f"tjwater-operational:{self.project_id}:{key}",),
)
return bool((await cursor.fetchone())["acquired"])
async def get_request(self, scope: str, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT request_digest,response
@@ -103,6 +112,50 @@ class OperationalTaskRepository:
row = await cursor.fetchone()
return dict(row) if row else None
async def list_task_runs(self, task_id: str, limit: int, offset: int) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT * FROM workbench.operational_task_runs
WHERE project_id=%s AND task_id=%s
ORDER BY generation DESC LIMIT %s OFFSET %s""",
(self.project_id, task_id, limit, offset),
)
return [{key: value for key, value in row.items() if key != "project_id"}
for row in await cursor.fetchall()]
async def get_latest_run(self, task_id: str) -> dict | None:
rows = await self.list_task_runs(task_id, 1, 0)
return rows[0] if rows else None
async def require_execution_storage(self) -> None:
"""Reject claims before changing state if the inbox cannot be used."""
cursor = await self.connection.execute(
"SELECT to_regclass('workbench.agent_messages') IS NOT NULL AS available"
)
if not (await cursor.fetchone())["available"]:
raise DomainError(
"DEPENDENCY_UNAVAILABLE",
"Agent message storage is not provisioned; apply "
"resources/sql/024_agent_messages.sql to this project database before starting runs",
status=503,
)
async def pending_run_messages(self, run_id: str) -> list[str]:
cursor = await self.connection.execute(
"""SELECT message_id FROM workbench.agent_messages
WHERE project_id=%s AND run_id=%s AND direction='to_task'
AND status IN ('pending','enqueued') ORDER BY created_at LIMIT 100""",
(self.project_id, run_id),
)
return [str(row["message_id"]) for row in await cursor.fetchall()]
async def fail_run_messages(self, run_id: str, failure_detail: str, now: datetime) -> None:
await self.connection.execute(
"""UPDATE workbench.agent_messages SET status='failed',failure_detail=%s,updated_at=%s
WHERE project_id=%s AND run_id=%s AND direction='to_task'
AND status IN ('pending','enqueued')""",
(failure_detail, now, self.project_id, run_id),
)
async def insert_run(self, run: dict) -> None:
await self.connection.execute(
"""INSERT INTO workbench.operational_task_runs
@@ -142,7 +195,8 @@ class OperationalTaskRepository:
raise DomainError("VERSION_CONFLICT", "Run revision changed", status=409)
async def list_runs(
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
task_type: str | None = None, task_created_from: datetime | None = None,
) -> list[dict]:
result = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
@@ -154,15 +208,25 @@ class OperationalTaskRepository:
AND (%s::text IS NULL OR status=%s::text)
AND (%s::timestamptz IS NULL OR (created_at,run_id)>(%s::timestamptz,%s::uuid))
AND (status<>'queued' OR next_dispatch_at<=now())
AND EXISTS (
SELECT 1 FROM workbench.operational_tasks t
WHERE t.project_id=operational_task_runs.project_id
AND t.task_id=operational_task_runs.task_id
AND (%s::text IS NULL OR t.task_type=%s::text)
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
)
ORDER BY created_at,run_id LIMIT %s""",
(
self.project_id, status, status, cursor_created_at,
cursor_created_at, cursor_run_id, limit,
cursor_created_at, cursor_run_id,
task_type, task_type, task_created_from, task_created_from, limit,
),
)
return [dict(row) for row in await result.fetchall()]
async def claim_expired_runs(self, limit: int) -> list[dict]:
async def claim_expired_runs(
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
@@ -171,8 +235,15 @@ class OperationalTaskRepository:
FROM workbench.operational_task_runs
WHERE project_id=%s AND status IN ('claimed','running')
AND lease_expires_at IS NOT NULL AND lease_expires_at<=now()
AND EXISTS (
SELECT 1 FROM workbench.operational_tasks t
WHERE t.project_id=operational_task_runs.project_id
AND t.task_id=operational_task_runs.task_id
AND (%s::text IS NULL OR t.task_type=%s::text)
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
)
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
(self.project_id, limit),
(self.project_id, task_type, task_type, task_created_from, task_created_from, limit),
)
return [dict(row) for row in await cursor.fetchall()]
@@ -212,6 +283,9 @@ class OperationalTaskRepository:
)
return event_seq
async def current_event_cursor(self) -> int:
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
async def get_task_by_create_request(self, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
@@ -246,7 +320,9 @@ class OperationalTaskRepository:
cursor = await self.connection.execute(
"""SELECT task.document,
CASE WHEN active_run.run_id IS NULL THEN NULL
ELSE to_jsonb(active_run) - 'project_id' END AS active_run
ELSE to_jsonb(active_run) - 'project_id' END AS active_run,
CASE WHEN latest_run.run_id IS NULL THEN NULL
ELSE to_jsonb(latest_run) - 'project_id' END AS latest_run
FROM workbench.operational_tasks AS task
LEFT JOIN LATERAL (
SELECT run.* FROM workbench.operational_task_runs AS run
@@ -254,6 +330,11 @@ class OperationalTaskRepository:
AND run.status IN ('queued','claimed','running')
ORDER BY run.generation DESC LIMIT 1
) AS active_run ON true
LEFT JOIN LATERAL (
SELECT run.* FROM workbench.operational_task_runs AS run
WHERE run.project_id=task.project_id AND run.task_id=task.task_id
ORDER BY run.generation DESC LIMIT 1
) AS latest_run ON true
WHERE task.project_id=%s
AND (task.document#>>'{source,deleted_at}') IS NULL
AND (%s::text IS NULL OR task.task_type=%s::text)
@@ -285,7 +366,7 @@ class OperationalTaskRepository:
),
)
return [
{**row["document"], "active_run": row["active_run"]}
{**row["document"], "active_run": row["active_run"], "latest_run": row["latest_run"]}
for row in await cursor.fetchall()
]
@@ -444,6 +525,38 @@ class OperationalTaskRepository:
async def insert_report(self, task_id: str, snapshot: dict) -> None:
await self._insert_artifact("operational_task_reports", task_id, snapshot)
async def get_latest_progress(self, task_id: str, generation: int) -> dict | None:
# Read compatibility before the coordinated progress migration is applied.
availability = await self.connection.execute(
"SELECT to_regclass('workbench.operational_task_progress') AS relation"
)
if not (await availability.fetchone())["relation"]:
return None
cursor = await self.connection.execute(
"""SELECT progress.document FROM workbench.operational_task_progress AS progress
JOIN workbench.operational_task_runs AS run
ON run.project_id=progress.project_id AND run.run_id=progress.run_id
AND run.task_id=progress.task_id
WHERE progress.project_id=%s AND progress.task_id=%s AND run.generation=%s""",
(self.project_id, task_id, generation),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def save_progress(self, task_id: str, snapshot: dict) -> None:
cursor = await self.connection.execute(
"""INSERT INTO workbench.operational_task_progress
(project_id,task_id,run_id,revision,document,published_at)
VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT(project_id,task_id,run_id) DO UPDATE SET
revision=excluded.revision,document=excluded.document,published_at=excluded.published_at
WHERE workbench.operational_task_progress.revision=excluded.revision-1""",
(self.project_id, task_id, snapshot["run_id"], snapshot["revision"],
Jsonb(snapshot), snapshot["published_at"]),
)
if cursor.rowcount != 1:
raise DomainError("VERSION_CONFLICT", "Investigation progress revision changed", status=409)
async def _insert_artifact(self, table: str, task_id: str, snapshot: dict) -> None:
if table not in {"operational_task_plans", "operational_task_reports"}:
raise ValueError("unsupported artifact table")
+406
View File
@@ -0,0 +1,406 @@
"""Bounded read primitives over existing project views and time-series tables."""
from datetime import datetime
from typing import Any
from uuid import UUID
from psycopg import AsyncConnection
from psycopg import sql
from psycopg.rows import dict_row
from fastapi import HTTPException
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
ELEMENT_VIEWS = {
v: v + "s" for v in ("junction", "pipe", "reservoir", "tank", "pump", "valve")
}
MAX_QUERY_BYTES = 8 * 1024 * 1024
async def read_rows(conn, statement, params=(), *, timeout_ms=30000):
async with conn.transaction(), conn.cursor(row_factory=dict_row) as cur:
await cur.execute(
"SELECT set_config('statement_timeout',%s,true)", (str(timeout_ms),)
)
await cur.execute(statement, params)
return await cur.fetchall()
async def asset_rows(conn, element_type, *, element_id=None, limit=100, offset=0):
view = ELEMENT_VIEWS[element_type]
where = sql.SQL("WHERE v.id=%s") if element_id else sql.SQL("")
params = (element_id,) if element_id else ()
statement = sql.SQL(
"SELECT to_jsonb(v)-'geom' AS record FROM gis.{} v {} ORDER BY v.id LIMIT %s OFFSET %s"
).format(sql.Identifier(view), where)
rows = await read_rows(conn, statement, params + (limit, offset))
return [row["record"] for row in rows]
async def scada_devices(conn, *, device_id=None, limit=100, offset=0):
where = " WHERE id=%s" if device_id else ""
params = (device_id,) if device_id else ()
rows = await read_rows(
conn,
_SCADA_VIEW_SELECT + where + " ORDER BY device_id LIMIT %s OFFSET %s",
params + (limit, offset),
)
return [_device(row) for row in rows]
async def series_rows(
conn,
*,
source,
element_type,
ids,
start=None,
end=None,
time=None,
field=None,
run_id=None,
limit=1000,
offset=0,
end_exclusive=False,
):
node = element_type == "junction"
if source == "scada":
table, id_column, fields = (
"measurements",
"device_id",
{"monitored_value", "cleaned_value"},
)
else:
table, id_column = (
("node_results", "node_id") if node else ("link_results", "link_id")
)
fields = (
(
AnalysisResultsRepository.NODE_FIELDS
if node
else AnalysisResultsRepository.LINK_FIELDS
)
if source == "analysis"
else (
RealtimeRepository.NODE_FIELDS
if node
else RealtimeRepository.LINK_FIELDS
)
)
if field is not None and field not in fields:
raise ValueError("unsupported field for this data source and element type")
columns = (
sql.SQL("{},time,{} AS value").format(
sql.Identifier(id_column), sql.Identifier(field)
)
if field
else sql.SQL("*")
)
filters = []
params = []
if ids:
filters.append(sql.SQL("{}=ANY(%s)").format(sql.Identifier(id_column)))
params.append(ids)
if run_id:
filters.append(sql.SQL("run_id=%s"))
params.append(run_id)
if start is not None:
filters.append(
sql.SQL(
"time >= %s AND time < %s"
if end_exclusive
else "time >= %s AND time <= %s"
)
)
params.extend((start, end))
else:
# Existing point-query semantics use +/- one second.
filters.append(
sql.SQL("time BETWEEN %s-interval '1 second' AND %s+interval '1 second'")
)
params.extend((time, time))
statement = sql.SQL(
"SELECT {} FROM {}.{} WHERE {} ORDER BY time,{} LIMIT %s OFFSET %s"
).format(
columns,
sql.Identifier(source),
sql.Identifier(table),
sql.SQL(" AND ").join(filters),
sql.Identifier(id_column),
)
return await read_rows(conn, statement, tuple(params) + (limit, offset))
async def sensor_runs(conn, *, run_id=None, limit=100, offset=0):
where = " AND r.run_id=%s" if run_id else ""
params = (run_id,) if run_id else ()
return await read_rows(
conn,
"""SELECT r.run_id,r.name,r.created_by,r.created_at,r.status,
result.payload FROM analysis.runs r JOIN LATERAL
(SELECT payload FROM analysis.results WHERE run_id=r.run_id
AND result_type='sensor_placement' ORDER BY created_at DESC,result_id DESC LIMIT 1) result ON true
WHERE r.run_type='sensor_placement'"""
+ where
+ " ORDER BY r.created_at DESC,r.run_id LIMIT %s OFFSET %s",
params + (limit, offset),
)
async def history_rows(
conn: AsyncConnection, descriptors: list[dict[str, Any]], *,
start: datetime, end: datetime, run_id: UUID | None, limit: int, offset: int,
) -> list[dict[str, Any]]:
"""Page points across raw, cleaned and model series in one SQL statement."""
parts, params = [], []
for index, item in enumerate(descriptors):
source, field = item["source"], item["field"]
if source == "scada":
table, id_column, fields = "measurements", "device_id", {"monitored_value", "cleaned_value"}
elif source in {"realtime", "analysis"}:
node = item["element_type"] == "junction"
table, id_column = ("node_results", "node_id") if node else ("link_results", "link_id")
fields = {"pressure"} if node else {"flow"}
else:
raise ValueError("unsupported history source")
if field not in fields or (source == "analysis" and run_id is None):
raise ValueError("unsupported history field or missing run")
parts.append(sql.SQL(
"SELECT %s::integer AS series_index,time,{} AS value FROM {}.{} "
"WHERE {}=%s AND time >= %s AND time <= %s{}"
).format(
sql.Identifier(field), sql.Identifier(source), sql.Identifier(table),
sql.Identifier(id_column), sql.SQL(" AND run_id=%s" if source == "analysis" else ""),
))
params.extend((index, item["id"], start, end))
if source == "analysis":
params.append(run_id)
if not parts:
return []
statement = sql.SQL(
"SELECT series_index,time,value FROM ({}) points "
"ORDER BY series_index, time LIMIT %s OFFSET %s"
).format(sql.SQL(" UNION ALL ").join(parts))
return await read_rows(conn, statement, tuple(params) + (limit, offset))
async def evidence_page(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, evidence_id: str, data_path: list[str],
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
) -> dict[str, Any]:
"""Project the immutable snapshot in PostgreSQL before transferring JSON."""
return await _snapshot_page(conn, stored_sql="""
SELECT document->'data_snapshot' AS snapshot
FROM workbench.operational_task_evidence
WHERE project_id=%s AND task_id=%s AND evidence_id=%s
AND jsonb_typeof(document->'data_snapshot')='object'
""", stored_params=(project_id, task_id, evidence_id), data_path=data_path,
limit=limit, offset=offset, mcp_sources=mcp_sources, full_snapshot=full_snapshot)
async def _snapshot_page(
conn: AsyncConnection, *, stored_sql: str, stored_params: tuple, data_path: list[str],
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
max_bytes: int = MAX_QUERY_BYTES,
) -> dict[str, Any]:
"""Shared JSONB projection; callers supply only fixed, scoped SQL sources."""
def descriptor(value, path):
return f"""jsonb_build_object(
'collection_type',jsonb_typeof({value}),
'count',CASE jsonb_typeof({value}) WHEN 'array' THEN jsonb_array_length({value})
WHEN 'string' THEN char_length({value} #>> '{{}}')
ELSE (SELECT count(*) FROM jsonb_object_keys({value})) END,
'data_path',to_jsonb({path}))"""
collection = descriptor("p.value", "p.path")
child = descriptor("child.item", "p.path || ARRAY[child.key]")
statement = """
WITH stored AS MATERIALIZED (
STORED_QUERY
), captured AS (
SELECT snapshot,
snapshot->>'source'=ANY(%s::text[])
AND jsonb_typeof(snapshot->'data')='object' AS is_mcp
FROM stored
), selected AS MATERIALIZED (
SELECT snapshot,
CASE WHEN is_mcp THEN (snapshot->'data')-'data' END AS query_metadata,
(CASE WHEN is_mcp THEN snapshot->'data'->'data'
ELSE snapshot->'data' END) #> %s::text[] AS value
FROM captured
), entries AS (
SELECT ordinality AS position, NULL::text AS key, item AS value
FROM selected, jsonb_array_elements(
CASE WHEN jsonb_typeof(value)='array' THEN value ELSE '[]'::jsonb END
) WITH ORDINALITY AS a(item, ordinality)
UNION ALL
SELECT row_number() OVER (ORDER BY e.key COLLATE "C"), e.key, e.item
FROM selected, jsonb_each(
CASE WHEN jsonb_typeof(value)='object' THEN value ELSE '{}'::jsonb END
) AS e(key,item)
), paged AS MATERIALIZED (
SELECT * FROM entries ORDER BY position LIMIT %s OFFSET %s
), paths AS (
SELECT paged.*, jsonb_typeof(selected.value) AS parent_type,
%s::text[] || ARRAY[CASE WHEN jsonb_typeof(selected.value)='array'
THEN (position-1)::text ELSE key END] AS path
FROM paged,selected
), summaries AS (
SELECT p.position,p.key,p.value AS original,
CASE WHEN jsonb_typeof(p.value) IN ('array','object') OR
(jsonb_typeof(p.value)='string' AND char_length(p.value #>> '{}')>256) THEN
CASE WHEN p.parent_type='object' OR jsonb_typeof(p.value) IN ('array','string')
THEN COLLECTION_PREVIEW
ELSE COALESCE((SELECT jsonb_object_agg(child.key,
CASE WHEN jsonb_typeof(child.item) IN ('array','object') OR
(jsonb_typeof(child.item)='string' AND char_length(child.item #>> '{}')>256)
THEN CHILD_PREVIEW ELSE child.item END)
FROM jsonb_each(p.value) AS child(key,item)), '{}'::jsonb)
END ELSE p.value END AS value
FROM paths p
), projected AS (
SELECT CASE WHEN %s THEN snapshot ELSE jsonb_build_object(
'snapshot_metadata',snapshot-'data', 'query_metadata',query_metadata,
'nested',EXISTS(SELECT 1 FROM summaries WHERE original<>value),
'value_type',jsonb_typeof(value),
'total',CASE jsonb_typeof(value)
WHEN 'array' THEN jsonb_array_length(value)
WHEN 'object' THEN (SELECT count(*) FROM jsonb_object_keys(value))
WHEN 'string' THEN char_length(value #>> '{}') END,
'value',CASE jsonb_typeof(value)
WHEN 'array' THEN COALESCE((SELECT jsonb_agg(value ORDER BY position) FROM summaries),'[]'::jsonb)
WHEN 'object' THEN COALESCE((SELECT jsonb_object_agg(key,value) FROM summaries),'{}'::jsonb)
WHEN 'string' THEN to_jsonb(substring(value #>> '{}' FROM %s FOR %s))
ELSE value END
) END AS payload, value IS NULL AS missing_path
FROM selected
), sized AS MATERIALIZED (
SELECT payload,missing_path,octet_length(payload::text)>%s AS too_large
FROM projected
)
SELECT CASE WHEN NOT too_large THEN payload END AS payload,too_large,missing_path
FROM sized
"""
statement = statement.replace("STORED_QUERY", stored_sql).replace("COLLECTION_PREVIEW", collection).replace("CHILD_PREVIEW", child)
rows = await read_rows(conn, statement, (
*stored_params, mcp_sources, data_path, limit, offset,
data_path, full_snapshot, offset + 1, limit, max_bytes,
))
if not rows:
raise HTTPException(404, "Stored query document was not found")
row = rows[0]
if row["too_large"]:
raise HTTPException(413, "Query exceeds the byte budget; select a deeper data_path or smaller page")
if row.get("missing_path") and not full_snapshot:
raise HTTPException(422, "Evidence data_path does not name a stored value")
return row["payload"]
async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, report_revision: int | None) -> dict:
"""Read fixed-size metadata and explicit previews, never a complete report."""
report = """jsonb_build_object('revision',r.revision,
'execution_generation',r.document->'execution_generation',
'stage',r.document#>'{report,stage}','outcome',r.document#>'{report,outcome}',
'current_conclusion_preview',left(r.document#>>'{report,current_conclusion}',200),
'preview_only',true,'detail',jsonb_build_object('section','report','report_revision',r.revision))"""
rows = await read_rows(conn, """
SELECT jsonb_build_object('task',jsonb_build_object(
'task_id',t.task_id,'task_type',t.task_type,'workflow',t.document->>'workflow','status',t.status,
'lifecycle_status',t.lifecycle_status,'priority',t.priority,'revision',t.revision,
'execution_generation',t.execution_generation,
'current_report_revision',t.current_report_revision,
'title_preview',left(t.document->>'title',160),'preview_only',true,
'detail',jsonb_build_object('section','task')),
'latest_report',(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
WHERE r.project_id=t.project_id AND r.task_id=t.task_id ORDER BY r.revision DESC LIMIT 1),
'selected_report',CASE WHEN %s::int IS NULL THEN NULL ELSE
(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
WHERE r.project_id=t.project_id AND r.task_id=t.task_id AND r.revision=%s) END
) AS payload
FROM workbench.operational_tasks t WHERE t.project_id=%s AND t.task_id=%s
""".replace("REPORT_SUMMARY", report), (report_revision, report_revision, project_id, task_id))
if not rows:
raise HTTPException(404, "Task was not found in this project")
result = rows[0]["payload"]
if report_revision is not None and result["selected_report"] is None:
raise HTTPException(404, "Selected report revision was not found")
return result
async def task_context_page(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
report_revision: int | None, data_path: list[str], limit: int, offset: int,
) -> dict:
"""Page only the requested task section inside PostgreSQL, before transfer."""
collections = {
"evidence": ("operational_task_evidence", "evidence_id"),
"actions": ("operational_task_actions", "action_id"),
"access_requests": ("operational_task_access_requests", "access_request_id"),
}
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
if section in {"access_requests", "progress"}:
table = "operational_task_access_requests" if section == "access_requests" else "operational_task_progress"
available = await read_rows(conn, "SELECT to_regclass(%s) AS relation", ("workbench." + table,))
if not available[0]["relation"]:
return {"snapshot_metadata": {"section": section, "available": False}, "value": None, "value_type": "null", "total": None, "nested": False}
projection_offset = offset
if section in collections:
table, key = collections[section]
# These identifiers come exclusively from the fixed table above.
relation = sql.Identifier("workbench", table).as_string()
identifier = sql.Identifier(key).as_string()
if data_path:
stored = f"""SELECT jsonb_build_object('section',%s::text,'data',jsonb_build_object({identifier}::text,document)) AS snapshot
FROM {relation} WHERE project_id=%s AND task_id=%s AND {identifier}::text=%s"""
params = (section, project_id, task_id, data_path[0])
else:
# Fetch just this page of rows. Collection keys remain stable across
# paging, so every nested data_path uses the real evidence/action ID.
stored = f"""WITH items AS MATERIALIZED (
SELECT {identifier}::text AS key,document FROM {relation}
WHERE project_id=%s AND task_id=%s
ORDER BY {identifier}::text COLLATE "C" LIMIT %s OFFSET %s)
SELECT jsonb_build_object('section',%s::text,
'total',(SELECT count(*) FROM {relation} WHERE project_id=%s AND task_id=%s),
'data',COALESCE(jsonb_object_agg(key,document),'{{}}'::jsonb)) AS snapshot FROM items"""
params = (project_id, task_id, limit + 1, offset, section, project_id, task_id)
projection_offset = 0
elif section in documents:
relation = sql.Identifier("workbench", documents[section]).as_string()
where = ""
params = (section, project_id, task_id)
if section == "report":
where = " AND (%s::int IS NULL OR revision=%s) ORDER BY revision DESC LIMIT 1"
params += (report_revision, report_revision)
elif section == "plan":
where = " ORDER BY revision DESC LIMIT 1"
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
elif section == "run":
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id') AS snapshot
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
params = (project_id, task_id)
elif section == "progress":
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
FROM workbench.operational_task_progress p
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation
WHERE p.project_id=%s AND p.task_id=%s"""
params = (project_id, task_id)
else:
raise HTTPException(422, "Unknown task context section")
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
async def task_data_scopes(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, permissions: set[str]) -> list[str]:
"""Bound scope discovery to seven known sources, without loading task detail."""
known = ["workbench_runtime", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", "scada_devices", "scada_readings"]
scopes = {name for name in known if ("scada.view" if name.startswith("scada_") else "simulation.view") in permissions}
available = await read_rows(conn, "SELECT to_regclass('workbench.operational_task_access_requests') AS relation")
if available[0]["relation"]:
rows = await read_rows(conn, """SELECT DISTINCT scope FROM workbench.operational_task_access_requests,
LATERAL jsonb_array_elements_text(COALESCE(document->'approved_scopes','[]'::jsonb)) AS granted(scope)
WHERE project_id=%s AND task_id=%s AND status='approved' AND scope=ANY(%s::text[])""", (project_id, task_id, known))
scopes.update(row["scope"] for row in rows)
return [name for name in known if name in scopes]
+4 -2
View File
@@ -111,14 +111,16 @@ class ScadaInfoRepository:
conn: AsyncConnection,
node_ids: list[str],
link_ids: list[str],
*, row_limit: int | None = None,
) -> list[dict[str, Any]]:
if not node_ids and not link_ids:
return []
async with conn.cursor() as cur:
await cur.execute(
_SCADA_VIEW_SELECT
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id",
(node_ids, link_ids),
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id"
+ (" LIMIT %s" if row_limit is not None else ""),
(node_ids, link_ids) + ((row_limit,) if row_limit is not None else ()),
)
return [_device(record) for record in await cur.fetchall()]
@@ -0,0 +1,71 @@
"""Bounded nearest-model queries on the already authorized project connection."""
from math import asin, cos, degrees, radians, sin
from typing import Any
from psycopg import AsyncConnection, sql
from app.infra.db.postgresql.read_queries import ELEMENT_VIEWS, read_rows
_MODEL_RELATIONS = {
kind: sql.Identifier("gis", view) for kind, view in ELEMENT_VIEWS.items()
}
def _search_boxes(
longitude: float, latitude: float, radius_m: float,
) -> list[tuple[float, float, float, float]]:
# A deliberately smaller-than-WGS84 curvature radius gives a conservative
# geographic envelope. It is only a GiST candidate filter; geography below
# determines the actual radius and distances. Split at the antimeridian.
angle = (radius_m + 10) / 6_300_000
low, high = latitude - degrees(angle), latitude + degrees(angle)
if low < -85 or high > 85:
# Avoid Mercator's polar singularity; retain exact geography filtering.
return []
width = degrees(asin(min(1.0, sin(angle) / cos(radians(latitude)))))
left, right = longitude - width, longitude + width
if left < -180:
return [(-180, low, right, high), (left + 360, low, 180, high)]
if right > 180:
return [(left, low, 180, high), (-180, low, right - 360, high)]
return [(left, low, right, high)]
async def nearby_elements(
conn: AsyncConnection,
element_type: str,
*,
center: tuple[float, float],
radius_m: float,
limit: int,
offset: int,
) -> list[dict[str, Any]]:
"""Rank complete model geometries by spheroidal distance, never endpoints."""
relation = _MODEL_RELATIONS[element_type]
filters = []
parameters: list[Any] = [*center]
for box in _search_boxes(*center, radius_m):
filters.append(sql.SQL(
"v.geom && ST_Transform(ST_MakeEnvelope(%s,%s,%s,%s,4326),3857)"
))
parameters.extend(box)
candidate_filter = sql.SQL(" OR ").join(filters) if filters else sql.SQL("TRUE")
statement = sql.SQL("""
WITH target AS (
SELECT ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography AS point
), candidates AS MATERIALIZED (
SELECT v.id, to_jsonb(v)-'geom' AS properties,
ST_Transform(v.geom,4326)::geography AS shape
FROM {} v
WHERE v.geom IS NOT NULL AND NOT ST_IsEmpty(v.geom) AND ({})
)
SELECT c.id AS element_id, c.properties,
ST_Distance(c.shape,t.point,true) AS distance_m
FROM candidates c CROSS JOIN target t
WHERE ST_DWithin(c.shape,t.point,%s,true)
ORDER BY distance_m,c.id
LIMIT %s OFFSET %s
""").format(relation, candidate_filter)
parameters.extend((radius_m, limit, offset))
return await read_rows(conn, statement, tuple(parameters))
@@ -184,6 +184,7 @@ class AnalysisResultsRepository:
start_time: datetime,
end_time: datetime,
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if element_type == "node":
table_name, id_column, valid_fields = (
@@ -214,8 +215,10 @@ class AnalysisResultsRepository:
sql.Identifier(id_column),
sql.Identifier(id_column),
)
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (run_id, element_ids, start_time, end_time))
await cur.execute(query, (run_id, element_ids, start_time, end_time) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["element_id"]), []).append(
{"time": row["time"], "value": row["value"]}
@@ -3,9 +3,28 @@ from datetime import datetime, timedelta
from collections import defaultdict
from psycopg import AsyncConnection, Connection, sql
from app.domain.time import parse_utc_time
from app.core.config import settings
class RealtimeRepository:
# The third set_config argument makes this transaction-local, so a pooled
# connection returns to its original guard after commit or rollback.
SNAPSHOT_BUDGET_SQL = "SELECT set_config('timescaledb.max_tuples_decompressed_per_dml_transaction', %s, true)"
@staticmethod
async def _configure_snapshot_write(cur) -> None:
await cur.execute(
RealtimeRepository.SNAPSHOT_BUDGET_SQL,
(str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),),
)
@staticmethod
def _configure_snapshot_write_sync(cur) -> None:
cur.execute(
RealtimeRepository.SNAPSHOT_BUDGET_SQL,
(str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),),
)
LINK_FIELDS = frozenset(
{
"flow",
@@ -93,6 +112,7 @@ class RealtimeRepository:
# 使用事务确保原子性
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -116,6 +136,7 @@ class RealtimeRepository:
# 使用事务确保原子性
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -163,6 +184,7 @@ class RealtimeRepository:
end_time: datetime,
link_ids: list[str],
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if field not in RealtimeRepository.LINK_FIELDS:
raise ValueError(f"Invalid field: {field}")
@@ -174,8 +196,10 @@ class RealtimeRepository:
"WHERE time BETWEEN %s AND %s AND link_id = ANY(%s) "
"ORDER BY link_id, time"
).format(sql.Identifier(field))
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (start_time, end_time, link_ids))
await cur.execute(query, (start_time, end_time, link_ids) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["link_id"]), []).append(
{"time": row["time"].isoformat(), "value": row[field]}
@@ -282,6 +306,7 @@ class RealtimeRepository:
# 使用事务确保原子性
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -304,6 +329,7 @@ class RealtimeRepository:
# 使用事务确保原子性
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -351,6 +377,7 @@ class RealtimeRepository:
end_time: datetime,
node_ids: list[str],
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if field not in RealtimeRepository.NODE_FIELDS:
raise ValueError(f"Invalid field: {field}")
@@ -362,8 +389,10 @@ class RealtimeRepository:
"WHERE time BETWEEN %s AND %s AND node_id = ANY(%s) "
"ORDER BY node_id, time"
).format(sql.Identifier(field))
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (start_time, end_time, node_ids))
await cur.execute(query, (start_time, end_time, node_ids) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["node_id"]), []).append(
{"time": row["time"].isoformat(), "value": row[field]}
@@ -482,6 +511,7 @@ class RealtimeRepository:
# table. Copy-only helpers avoid repeating replacement SQL.
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(simulation_time,),
@@ -559,6 +589,7 @@ class RealtimeRepository:
# table. Copy-only helpers avoid repeating replacement SQL.
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(simulation_time,),
@@ -34,13 +34,15 @@ class ScadaRepository:
device_ids: List[str],
start_time: datetime,
end_time: datetime,
*, row_limit: int | None = None,
) -> List[dict]:
async with conn.cursor() as cur:
await cur.execute(
f"SELECT {ScadaRepository.RESULT_COLUMNS} FROM scada.measurements "
"WHERE device_id = ANY(%s) "
"AND time >= %s AND time <= %s ORDER BY device_id, time",
(device_ids, start_time, end_time),
"AND time >= %s AND time <= %s ORDER BY device_id, time"
+ (" LIMIT %s" if row_limit is not None else ""),
(device_ids, start_time, end_time) + ((row_limit,) if row_limit is not None else ()),
)
return await cur.fetchall()
+27 -2
View File
@@ -1,11 +1,12 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, AsyncExitStack
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware
import logging
from datetime import datetime
from app.api.problem_details import install_problem_details_handlers
from app.api.health import router as health_router
from app.api.v1.rest_router import api_router
from app.infra.db.dynamic_manager import project_connection_manager
from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs
@@ -24,13 +25,21 @@ logger.setLevel(logging.INFO)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.startup_complete = False
logger.info("**********************************************************")
logger.info(str(datetime.now()))
logger.info("TJWater CloudService is starting...")
logger.info("**********************************************************")
try:
yield
async with AsyncExitStack() as stack:
if mcp_runtime is not None:
await stack.enter_async_context(mcp_runtime.lifespan())
app.state.startup_complete = True
try:
yield
finally:
app.state.startup_complete = False
finally:
await workbench_event_hubs.close_all()
await project_connection_manager.close_all()
@@ -40,6 +49,14 @@ async def lifespan(app: FastAPI):
logger.info("Database connections closed")
# Disabled deployments do not import the MCP SDK or acquire business credentials.
from app.mcp.config import McpSettings
mcp_settings = McpSettings()
mcp_runtime = None
if mcp_settings.enabled:
from app.mcp.application import McpQueryApplication
mcp_runtime = McpQueryApplication(mcp_settings)
# 根据环境配置决定是否启用文档
is_production = settings.ENVIRONMENT.lower() == "production"
@@ -56,8 +73,13 @@ app = FastAPI(
# Include Routers
app.state.startup_complete = False
app.include_router(health_router)
app.include_router(api_router, prefix="/api/v1")
install_problem_details_handlers(app)
from app.api.v1.endpoints.mcp_queries import router as mcp_context_router
app.state.mcp_runtime = mcp_runtime
app.include_router(mcp_context_router, prefix="/api/v1")
# Legacy routers without version prefix
# app.include_router(api_router)
@@ -65,6 +87,9 @@ install_problem_details_handlers(app)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# 添加审计中间件(可选,记录关键操作)
app.add_middleware(AuditMiddleware)
if mcp_runtime is not None:
from app.mcp.dispatch import McpDispatchMiddleware
app.add_middleware(McpDispatchMiddleware, runtime=mcp_runtime)
# 配置 CORS 中间件
# 确保这是你最后一个添加的 app.add_middleware
app.add_middleware(
View File
+139
View File
@@ -0,0 +1,139 @@
"""Bounded calculation inputs; identity, routing and output paths are host-owned."""
from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, model_validator
from app.mcp.models import Id, StrictInput
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
class CalculationInput(StrictInput):
timeout_seconds: int = Field(
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
)
class BurstDetectionInput(CalculationInput):
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
sampling_interval_minutes: int = Field(default=15, ge=1, le=60)
@model_validator(mode="after")
def valid_target(self):
if self.target_time.tzinfo is None:
raise ValueError("target_time requires explicit timezone")
if 1440 % self.sampling_interval_minutes:
raise ValueError("sampling interval must divide a day")
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
raise ValueError("sensor_nodes must be distinct")
return self
class BurstLocationInput(CalculationInput):
data_source: Literal["monitoring", "simulation"] = "monitoring"
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
scada_burst_start: datetime
scada_burst_end: datetime
scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,不代表该时段已确认正常")
scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式不填写,使用同时间窗模型基线")
@model_validator(mode="after")
def valid_windows(self):
pairs = [(self.scada_burst_start, self.scada_burst_end)]
if self.data_source == "monitoring":
if self.simulation_run_id is not None:
raise ValueError("monitoring cannot use simulation_run_id")
if self.scada_normal_start is not None or self.scada_normal_end is not None:
pairs.append((self.scada_normal_start, self.scada_normal_end))
elif self.simulation_run_id is None or self.scada_normal_start is not None or self.scada_normal_end is not None:
raise ValueError("simulation requires run_id and uses the same-window model baseline")
for start, end in pairs:
if start is None or end is None or start.tzinfo is None or end.tzinfo is None:
raise ValueError("explicit timezone-aware burst and normal windows are required")
if not timedelta(0) < end - start <= timedelta(days=1):
raise ValueError("each window must be positive and at most 24 hours")
for ids in (self.pressure_scada_ids, self.flow_scada_ids):
if ids is not None and len(set(ids)) != len(ids):
raise ValueError("sensor IDs must be distinct")
return self
class ValveIsolationInput(CalculationInput):
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
class LeakageIdentificationInput(CalculationInput):
scada_start: datetime
scada_end: datetime
sensor_nodes: list[Id] = Field(min_length=1, max_length=20)
q_sum: float = Field(gt=0, allow_inf_nan=False, description="有依据的总漏损流量约束,m³/s,不得使用无依据默认值")
start_time: float = Field(ge=0, lt=24, allow_inf_nan=False, description="模型起算小时;需与SCADA时间窗及模型时钟对齐")
timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应")
dma_count: int = Field(ge=1, le=20)
@model_validator(mode="after")
def valid_window(self):
if self.scada_start.tzinfo is None or self.scada_end.tzinfo is None:
raise ValueError("timestamps require explicit timezone")
duration = self.scada_end - self.scada_start
if not timedelta(0) < duration <= timedelta(days=1):
raise ValueError("window must be positive and at most 24 hours")
if duration.total_seconds() % (self.timestep * 60):
raise ValueError("window must contain whole calculation steps")
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
raise ValueError("sensor_nodes must be distinct")
return self
class ScenarioInput(CalculationInput):
start_time: datetime
duration: int = Field(default=900, ge=60, le=86400, description="模拟时长,秒;首次优先短时间窗")
output_node_ids: list[Id] = Field(min_length=1, max_length=20, description="需要返回结果的节点;不把局部结果解释为全网无影响")
output_link_ids: list[Id] = Field(default_factory=list, max_length=20)
@model_validator(mode="after")
def timezone_required(self):
if self.start_time.tzinfo is None:
raise ValueError("start_time requires explicit timezone")
return self
class ContaminantSimulationInput(ScenarioInput):
source: Id = Field(description="假设污染源节点;这是正向传播模拟,不是自动反演污染源")
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;不填为持续恒定源")
class ValveClosureInput(ScenarioInput):
valves: list[Id] = Field(min_length=1, max_length=20, description="在临时模型中关闭的阀门,不下发现场控制")
class FlushingInput(ScenarioInput):
drainage_node_id: Id
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;不使用无依据的自动排放量")
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
PumpSeries = Annotated[list[NonnegativeValue], Field(min_length=1, max_length=96)]
class PressureRegulationInput(ScenarioInput):
pump_control: dict[Id, PumpSeries] = Field(min_length=1, max_length=20, description="已登记泵ID及运行序列;工频值为0/1,变频值为频率Hz,每项对应模型水力步长,不自动优化")
tank_init_level: dict[Id, Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=20, description="水箱初始水位,须在模型允许范围内")
class SensorPlacementInput(CalculationInput):
method: Literal["sensitivity", "kmeans"]
sensor_count: int = Field(ge=1, le=20)
min_diameter: int = Field(default=0, ge=0, le=5000, description="最小管径,mm")
+69
View File
@@ -0,0 +1,69 @@
"""Side-effect-limited adapters for existing domain calculations."""
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
# These services synchronously read SCADA/model series or write online results.
# Topology-only isolation and model-based sensor placement need only BizDB.
TIMESCALE_CALCULATIONS = SCENARIO_TOOLS | frozenset({
"run_burst_detection", "run_burst_location", "run_leakage_identification",
})
# A timed-out HTTP request cannot kill a native algorithm thread. Keep its slot
# until the calculation really exits, so retries cannot accumulate CPU jobs.
_slots = BoundedSemaphore(2)
def _calculate(name: str, parameters: dict[str, Any], network: str, username: str) -> dict[str, Any]:
if not _slots.acquire(blocking=False):
raise HTTPException(503, "Calculation capacity is occupied")
try:
if name in SCENARIO_TOOLS:
return calculate_scenario(name, parameters, network=network, username=username)
if name == "optimize_sensor_placement":
from app.services.sensor_placement import optimize_sensor_placement_by_kmeans, optimize_sensor_placement_by_sensitivity
parameters = dict(parameters)
method = parameters.pop("method")
optimizer = optimize_sensor_placement_by_sensitivity if method == "sensitivity" else optimize_sensor_placement_by_kmeans
return optimizer(project_code=network, run_name="MCP preview", created_by=username,
persist_results=False, **parameters)
if name == "run_leakage_identification":
from app.services.dma_leakage_estimation import run_leakage_identification
duration = (parameters["scada_end"] - parameters["scada_start"]).total_seconds() / 3600
return run_leakage_identification(
network=network, username=username, scheme_name=None,
duration=duration, q_sum_unit="m3/s", output_flow_unit="m3/s",
n_workers=1, pop_size=50, max_gen=100, **parameters,
)
if name == "run_burst_detection":
from app.services.burst_detection import run_burst_detection
return run_burst_detection(network=network, username=username, scheme_name=None, **parameters)
if name == "run_burst_location":
from app.services.burst_location import run_burst_location_by_network
return run_burst_location_by_network(
network=network, username=username, scheme_name=None,
final_candidates_csv_path=None, **parameters,
)
if name == "analyze_valve_isolation":
from app.services.valve_isolation import analyze_valve_isolation
return analyze_valve_isolation(network=network, **parameters)
raise ValueError("Calculation is not registered")
finally:
_slots.release()
async def calculate(name: str, args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
# to_thread copies the validated project's routing context. Never accept
# database routes, actor identity, arbitrary measurements or paths from MCP.
return await asyncio.to_thread(
_calculate, name, args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True), network, username,
)
+505
View File
@@ -0,0 +1,505 @@
"""Official SDK transport with product authorization, persistence and query handlers."""
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from urllib.parse import urlsplit
from uuid import uuid5, NAMESPACE_URL
from typing import Any
from fastapi import HTTPException
from mcp import types
from mcp.server import Server
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.settings import AuthSettings
from mcp.server.auth.routes import create_protected_resource_routes
from mcp.server.caching import CacheHint
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from psycopg.errors import UndefinedTable, InvalidSchemaName
from pydantic import ValidationError
from starlette.responses import JSONResponse
from app.mcp.auth import (
McpTokenVerifier,
QueryScopeMiddleware,
QUERY_SCOPE,
CONTEXT_SCOPE,
challenge,
)
from app.mcp.catalog import TOOLS
from app.mcp.config import McpSettings
from app.mcp.contexts import resolve_context, verify_task
from app.mcp.models import QueryResult
from app.mcp.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
logger = logging.getLogger(__name__)
def tool_error(code, message):
return types.CallToolResult(
isError=True,
content=[types.TextContent(type="text", text=message)],
structuredContent={"error": {"code": code, "message": message}},
)
class ModernOnlyMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope.get("path") != "/mcp":
return await self.app(scope, receive, send)
if scope["method"] in {"GET", "DELETE"}:
return await JSONResponse(
{"error": "modern_protocol_required"},
status_code=405,
headers={"Allow": "POST"},
)(scope, receive, send)
headers = {
k.decode("latin1").lower(): v.decode("latin1")
for k, v in scope.get("headers", [])
}
version = headers.get("mcp-protocol-version")
if version != "2026-07-28":
body = bytearray()
request_id = None
body_version = None
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
body.extend(message.get("body", b""))
if len(body) > 1024 * 1024:
return await JSONResponse(
{"error": "request_too_large"}, status_code=413
)(scope, receive, send)
if not message.get("more_body"):
break
try:
payload = json.loads(body)
request_id = payload.get("id")
body_version = payload.get("params", {}).get("_meta", {}).get(
types.PROTOCOL_VERSION_META_KEY
)
except (ValueError, AttributeError):
pass
mismatch = not version or (
isinstance(body_version, str) and body_version != version
)
code = -32020 if mismatch else -32022
result = {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": code,
"message": (
"MCP-Protocol-Version header is missing or differs from request metadata"
if mismatch else "MCP 2026-07-28 is required"
),
"data": {"supported": ["2026-07-28"], "requested": version},
},
}
return await JSONResponse(result, status_code=400)(scope, receive, send)
await self.app(scope, receive, send)
class McpQueryApplication:
def __init__(
self,
settings: McpSettings,
*,
verifier=None,
scope_factory=project_scope,
executor=execute_query,
):
self.settings = settings
self.verifier = verifier or McpTokenVerifier(settings)
self.scope_factory = scope_factory
self.executor = executor
self.active = {}
self.ready = False
self.server = Server(
"tjwater-query",
version="1.0.0",
instructions="Project-bound water queries. Model baselines are not observations. Truncated results do not cover the full range.",
cache_hints={
"server/discover": CacheHint(300000, "public"),
"tools/list": CacheHint(300000, "public"),
},
on_list_tools=self.list_tools,
on_call_tool=self.call_tool,
get_tool_input_schema=self.tool_input_schema,
on_list_resources=self.list_resources,
on_list_resource_templates=self.list_templates,
on_read_resource=self.read_resource,
)
self.server.add_request_handler(
"server/discover", types.RequestParams, self.discover
)
app = self.server.streamable_http_app(
stateless_http=True,
json_response=True,
max_request_body_size=1024 * 1024,
auth=AuthSettings(
issuer_url=settings.issuer_uri,
resource_server_url=settings.resource_uri,
required_scopes=["mcp.catalog.read"],
validate_token_resource=True,
),
token_verifier=self.verifier,
transport_security=TransportSecuritySettings(
allowed_hosts=settings.allowed_hosts,
allowed_origins=settings.allowed_origins,
),
)
# Catalog authentication and advertised resource scopes are different concerns.
metadata = create_protected_resource_routes(
resource_url=settings.resource_uri,
authorization_servers=[settings.issuer_uri],
scopes_supported=["mcp.catalog.read", QUERY_SCOPE, CONTEXT_SCOPE],
resource_name="TJWater Query",
)
metadata_path = metadata[0].path
app.router.routes = [
route for route in app.router.routes if route.path != metadata_path
] + metadata
self.asgi = ModernOnlyMiddleware(
QueryScopeMiddleware(app, self.verifier, settings)
)
from app.api.v1.endpoints.mcp_queries import router
self.router = router
@asynccontextmanager
async def lifespan(self):
async with self.server.session_manager.run():
self.ready = True
try:
yield
finally:
self.ready = False
async def discover(self, ctx, params):
return types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=self.server.get_capabilities(
protocol_version=ctx.protocol_version,
extensions={"io.modelcontextprotocol/oauth-client-credentials": {}},
),
instructions=self.server.instructions,
)
def tool_input_schema(self, name: str) -> dict[str, Any] | None:
tool = TOOLS.get(name)
if tool is None:
return None
schema = tool.model.model_json_schema()
if tool.computation:
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
return schema
async def list_tools(self, ctx, params):
if params and params.cursor:
raise MCPError(-32602, "The query catalog has no continuation page")
tools = []
for name in sorted(TOOLS):
tool = TOOLS[name].protocol_tool()
tool.input_schema.update(self.tool_input_schema(name))
tools.append(tool)
return types.ListToolsResult(
tools=tools,
ttlMs=300000,
cacheScope="public",
)
async def list_resources(self, ctx, params):
return types.ListResourcesResult(resources=[], ttlMs=0, cacheScope="private")
async def list_templates(self, ctx, params):
return types.ListResourceTemplatesResult(
resourceTemplates=[
types.ResourceTemplate(
name="task-evidence",
uriTemplate="tjwater://tasks/{task_id}/evidence/{evidence_id}",
mimeType="application/json",
)
],
ttlMs=300000,
cacheScope="public",
)
async def read_resource(self, ctx, params):
meta = ctx.meta or {}
handle = meta.get("io.tjwater/contextHandle")
url = urlsplit(str(params.uri))
parts = url.path.strip("/").split("/")
if (
url.scheme != "tjwater"
or url.netloc != "tasks"
or len(parts) != 3
or parts[1] != "evidence"
or url.query
or url.fragment
):
raise MCPError(-32602, "Unknown query resource")
result = await self._query(
ctx,
"get_task_evidence",
{"context_handle": handle, "evidence_id": parts[2]},
resource_task=parts[0],
include_full=True,
)
if result.isError:
raise MCPError(
-32602, "Evidence is unavailable in this authorization context"
)
return types.ReadResourceResult(
contents=[
types.TextResourceContents(
uri=str(params.uri),
mimeType="application/json",
text=json.dumps(result.structuredContent, ensure_ascii=False),
)
],
ttlMs=0,
cacheScope="private",
)
async def call_tool(self, ctx, params):
return await self._query(ctx, params.name, params.arguments or {})
async def _query(
self, ctx, name, arguments, resource_task=None, include_full=False
):
tool = TOOLS.get(name)
if tool is None:
raise MCPError(-32602, "Unknown query tool")
principal = get_access_token()
if principal is None or QUERY_SCOPE not in principal.scopes:
return tool_error("PERMISSION_DENIED", "Query permission is required")
try:
args = tool.model.model_validate(arguments)
except ValidationError:
return tool_error(
"INVALID_QUERY", "Query parameters do not match the tool schema"
)
timeout_seconds = (
args.timeout_seconds if tool.computation else self.settings.query_timeout_seconds
)
if tool.computation and timeout_seconds > self.settings.max_calculation_timeout_seconds:
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
request = ctx.request
project = request.headers.get("x-project-id", "") if request else ""
meta = ctx.meta or {}
call_id = meta.get("io.tjwater/callId")
if not isinstance(call_id, str) or not 0 < len(call_id) <= 128:
return tool_error(
"CALL_ID_REQUIRED", "A host-owned call identifier is required"
)
key = (principal.subject, project)
if self.active.get(key, 0) >= 4:
return tool_error(
"RATE_LIMITED", "At most four queries may run concurrently"
)
self.active[key] = self.active.get(key, 0) + 1
try:
async with (
asyncio.timeout(timeout_seconds),
self.scope_factory(principal, project) as scope,
):
binding = await resolve_context(scope, principal, args.context_handle)
if resource_task is not None and str(binding.task_id) != resource_task:
raise HTTPException(403, "Resource task mismatch")
await authorize_query(scope, binding, tool, args)
repo = McpQueryRepository(scope.connection, scope.project.project_id)
parameters = {
"tool": name,
"arguments": args.model_dump(
mode="json", exclude={"context_handle"}
),
}
scope_key = json.dumps(
[
binding.subject,
binding.client_id,
binding.native_session_id,
str(binding.task_id),
str(binding.run_id),
str(binding.execution_request_id),
],
separators=(",", ":"),
)
previous = (
await repo.receipt(scope_key, call_id) if binding.task_id else None
)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(
409, "Call identifier has different query parameters"
)
result = QueryResult.model_validate(previous["result"])
else:
result = (
await self.executor(
scope, binding, tool, args, full_snapshot=True
)
if include_full
else await self.executor(scope, binding, tool, args)
)
# Never serialize an unbounded response into a model message or database receipt.
if len(result.model_dump_json().encode()) > 8 * 1024 * 1024:
return tool_error(
"RESULT_TOO_LARGE",
"Narrow the query or request a smaller page",
)
if binding.task_id and name not in {
"get_task_context",
"get_task_evidence",
}:
async with scope.connection.transaction():
await repo.lock_receipt(scope_key, call_id)
previous = await repo.receipt(scope_key, call_id)
if previous:
if previous["parameters"] != parameters:
raise HTTPException(
409,
"Call identifier has different query parameters",
)
result = QueryResult.model_validate(previous["result"])
else:
task_repo = task_service(scope).repo
# Lock the task row only after external/data-source work has finished.
await task_repo.lock(f"task:{binding.task_id}")
await verify_task(scope, binding)
result.source_ref = "mcp:" + str(
uuid5(NAMESPACE_URL, scope_key + ":" + call_id)
)
snapshot = TaskDataSnapshot(
source=name,
source_ref=result.source_ref,
queried_at=result.queried_at,
origin=result.origin,
data=result.model_dump(mode="json"),
truncated=result.truncated,
)
evidence, _ = await task_service(
scope
).record_data_snapshot(binding.task_id, snapshot)
result.evidence_id = evidence.evidence_id
await repo.save_receipt(
scope_key,
call_id,
parameters,
result.model_dump(mode="json"),
)
output = result.model_dump(mode="json")
content = []
if (
not include_full
and len(json.dumps(output, ensure_ascii=False).encode()) > 12000
):
evidence_id = result.evidence_id or (
args.evidence_id if name == "get_task_evidence" else None
)
if binding.task_id and evidence_id:
uri = (
f"tjwater://tasks/{binding.task_id}/evidence/{evidence_id}"
)
output["data"] = {
"resource_uri": uri,
"preview": "The complete query snapshot is saved in task evidence",
}
output["truncated"] = True
output["data_gaps"] = [
*result.data_gaps,
"Inline result abbreviated; read the saved evidence resource",
]
content.append(
types.ResourceLink(
type="resource_link",
uri=uri,
name="query-evidence",
mimeType="application/json",
)
)
else:
return tool_error(
"RESULT_TOO_LARGE",
"Request a smaller page or narrower window",
)
content.insert(
0,
types.TextContent(
type="text", text=json.dumps(output, ensure_ascii=False)
),
)
return types.CallToolResult(content=content, structuredContent=output)
except HTTPException as exc:
codes = {
401: "AUTH_REQUIRED",
403: "QUERY_CONTEXT_DENIED",
404: "NOT_FOUND",
409: "QUERY_CONTEXT_STALE",
413: "RESULT_TOO_LARGE",
422: "INVALID_QUERY",
503: "SOURCE_UNAVAILABLE",
}
return tool_error(
codes.get(exc.status_code, "QUERY_FAILED"),
str(exc.detail)
if isinstance(exc.detail, str)
and exc.status_code in {403, 404, 409, 413, 422}
else "Query is unavailable",
)
except (UndefinedTable, InvalidSchemaName):
return tool_error(
"MCP_STORAGE_UNAVAILABLE",
"MCP project storage has not been provisioned",
)
except TimeoutError:
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
except ValueError:
return tool_error(
"INVALID_QUERY", "Invalid query value or incompatible source data"
)
except Exception:
logger.warning(
"MCP query unavailable", extra={"tool": name, "project_id": project}
)
return tool_error("SOURCE_UNAVAILABLE", "Query source is unavailable")
finally:
remaining = self.active[key] - 1
if remaining:
self.active[key] = remaining
else:
self.active.pop(key, None)
async def identity(self, request):
auth = request.headers.get("authorization", "")
try:
principal = (
await self.verifier.verify_token(auth[7:])
if auth.lower().startswith("bearer ")
else None
)
except Exception:
raise HTTPException(503, "OAuth key verification is unavailable") from None
if not principal:
raise HTTPException(
401,
"MCP authentication required",
headers=challenge(self.settings, CONTEXT_SCOPE),
)
if CONTEXT_SCOPE not in principal.scopes:
raise HTTPException(
403,
"MCP context scope required",
headers=challenge(self.settings, CONTEXT_SCOPE, insufficient=True),
)
return principal
+169
View File
@@ -0,0 +1,169 @@
"""Verify resource-bound Keycloak tokens without passing them to business APIs."""
import asyncio
import time
from uuid import UUID
import httpx
from jose import JWTError, jwt
from mcp.server.auth.provider import AccessToken
from starlette.responses import JSONResponse
from app.mcp.config import McpSettings
CATALOG_SCOPE = "mcp.catalog.read"
QUERY_SCOPE = "mcp.query"
CONTEXT_SCOPE = "mcp.context.bind"
class McpTokenVerifier:
def __init__(self, settings: McpSettings, client: httpx.AsyncClient | None = None):
self.settings = settings
self.client = client
self._keys: list[dict] = []
self._expires = 0.0
self._last_refresh = 0.0
self._lock = asyncio.Lock()
async def _load_keys(self, kid: str) -> list[dict]:
now = time.monotonic()
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
return self._keys
async with self._lock:
now = time.monotonic()
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
return self._keys
if self._expires > now and now - self._last_refresh < 20:
return self._keys
url = (
self.settings.issuer_uri.rstrip("/") + "/protocol/openid-connect/certs"
)
self._last_refresh = now
if self.client:
response = await self.client.get(url)
else:
async with httpx.AsyncClient(
timeout=5, trust_env=False, follow_redirects=False
) as client:
response = await client.get(url)
response.raise_for_status()
try:
payload = response.json()
except ValueError:
raise httpx.RequestError("invalid OAuth key set response") from None
keys = payload.get("keys") if isinstance(payload, dict) else None
if not isinstance(keys, list) or len(keys) > 100:
raise httpx.RequestError("invalid OAuth key set")
self._keys = [
k
for k in keys
if isinstance(k, dict)
and k.get("kty") == "RSA"
and k.get("use", "sig") == "sig"
and k.get("alg", "RS256") == "RS256"
]
self._expires = now + 300
return self._keys
async def verify_token(self, token: str) -> AccessToken | None:
if not token or len(token) > 16384:
return None
try:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if (
header.get("alg") != "RS256"
or not isinstance(kid, str)
or not 0 < len(kid) <= 128
):
return None
keys = await self._load_keys(kid)
key = next((k for k in keys if k.get("kid") == kid), None)
if key is None:
return None
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
audience=self.settings.audience,
issuer=self.settings.issuer_uri,
options={"require_exp": True, "require_iat": True, "require_sub": True},
)
UUID(claims["sub"])
client_id = claims.get("azp")
if not isinstance(client_id, str) or not client_id:
return None
if (
not isinstance(claims.get("iat"), (int, float))
or not time.time() - 900 < claims["iat"] <= time.time() + 30
or claims["exp"] > claims["iat"] + 900
):
return None
return AccessToken(
token=token,
client_id=client_id,
subject=claims["sub"],
scopes=str(claims.get("scope", "")).split(),
expires_at=int(claims["exp"]),
resource=self.settings.resource_uri,
claims=claims,
)
except (JWTError, ValueError, KeyError, TypeError):
return None
# JWKS/network failures propagate as unavailable, never as empty data.
def challenge(
settings: McpSettings, scope: str, *, insufficient: bool = False
) -> dict[str, str]:
from mcp.server.auth.routes import build_resource_metadata_url
metadata = build_resource_metadata_url(settings.resource_uri)
suffix = ', error="insufficient_scope"' if insufficient else ""
return {
"WWW-Authenticate": f'Bearer resource_metadata="{metadata}", scope="{scope}"{suffix}'
}
class QueryScopeMiddleware:
"""HTTP scope challenges before JSON-RPC dispatch; SDK validates mirrored headers."""
def __init__(self, app, verifier: McpTokenVerifier, settings: McpSettings):
self.app, self.verifier, self.settings = app, verifier, settings
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope.get("path") != "/mcp":
return await self.app(scope, receive, send)
headers = {
k.decode("latin1").lower(): v.decode("latin1")
for k, v in scope.get("headers", [])
}
auth = headers.get("authorization", "")
try:
token = (
await self.verifier.verify_token(auth[7:])
if auth.lower().startswith("bearer ")
else None
)
except (httpx.HTTPError, ValueError):
return await JSONResponse(
{"error": "authentication_unavailable"}, status_code=503
)(scope, receive, send)
required = (
QUERY_SCOPE
if headers.get("mcp-method") in {"tools/call", "resources/read"}
else CATALOG_SCOPE
)
if token is None:
return await JSONResponse(
{"error": "invalid_token"},
status_code=401,
headers=challenge(self.settings, required),
)(scope, receive, send)
if required not in token.scopes:
return await JSONResponse(
{"error": "insufficient_scope"},
status_code=403,
headers=challenge(self.settings, required, insufficient=True),
)(scope, receive, send)
await self.app(scope, receive, send)
+221
View File
@@ -0,0 +1,221 @@
"""One finite query catalog; names, schemas and permissions come from this registry."""
from dataclasses import dataclass
from pydantic import BaseModel
from mcp import types
from app.mcp import models as m
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
@dataclass(frozen=True)
class QueryTool:
name: str
description: str
model: type[BaseModel]
permission: str
semantics: str
task_only: bool = False
computation: bool = False
def protocol_tool(self) -> types.Tool:
if self.name == "run_simulation":
suffix = "。任务调用复用证据留存。"
elif self.computation:
suffix = "。仅计算建议,不保存方案、不控制设备;任务调用复用证据留存。"
elif self.name == "query_nearby_elements":
suffix = "。仅查询当前已授权项目的模型管网,按距离和元素ID稳定排序;按需分页,不自动遍历全量;空间邻近不代表管网连通或供水关系。"
else:
suffix = (
"。先限定对象和时间范围,再读取明细;集合默认20条,按需逐页扩大,"
"不要自动遍历全量;截断或非零offset不能证明完整范围无异常。"
)
return types.Tool(
name=self.name,
description=self.description + suffix,
inputSchema=self.model.model_json_schema(),
outputSchema=m.QueryResult.model_json_schema(),
annotations=types.ToolAnnotations(
readOnlyHint=False, destructiveHint=self.name == "run_simulation", openWorldHint=False
),
)
_specs = [
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟,不复制数据库;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,不创建方案或正式分析运行,结果不是实测", ScenarioInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,不反演污染源,不代表实测污染范围", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;不操作真实阀门", ValveClosureInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;不是现场冲洗或消毒合格证明", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;不自动优化调度,不实际控制泵", PressureRegulationInput, "simulation.run", "analysis", False, True),
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True),
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True),
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;需有依据的漏水量假设。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True),
(
"get_element_properties",
"读取当前项目的单个管网元素属性",
m.ElementInput,
"webgis.view",
"asset",
),
(
"list_elements",
"分页读取管网元素,不自动读取全网",
m.ElementPageInput,
"webgis.view",
"asset",
),
(
"query_nearby_elements",
"查询坐标附近的模型元素,默认pipe;center为EPSG:4326 [经度,纬度],可直接使用geocode.center;radius_m默认200米、最大10000米,limit默认5、最大100,支持offset。按点到完整几何的WGS84椭球最短距离筛选并排序,返回element_id、distance_m和属性;不查询原始GIS管线、不自动扩大半径、不解析道路交叉点",
m.NearbyElementsInput,
"webgis.view",
"asset",
),
(
"get_network_options",
"读取模型时间、能源或网络选项",
m.OptionsInput,
"webgis.view",
"asset",
),
(
"get_network_schema",
"读取当前支持的元素、SCADA和选项结构",
m.SchemaInput,
"webgis.view",
"asset",
),
(
"list_scada_devices",
"分页读取SCADA设备和物理单位",
m.PageInput,
"scada.view",
"asset",
),
("get_scada_device", "读取一个SCADA设备", m.DeviceInput, "scada.view", "asset"),
(
"query_scada_readings",
"查询带时区且最多24小时的原始或清洗监测数据",
m.ScadaInput,
"scada.view",
"observed",
),
(
"query_model_baseline_series",
"读取最多20个元素的模型基线,不能称为实测",
m.BaselineSeriesInput,
"simulation.view",
"model_baseline",
),
(
"get_model_baseline_at_time",
"读取单个元素指定时刻的模型基线",
m.BaselineAtTimeInput,
"simulation.view",
"model_baseline",
),
(
"query_model_baseline_field",
"分页读取指定时刻的模型字段",
m.BaselineFieldInput,
"simulation.view",
"model_baseline",
),
(
"query_analysis_series",
"读取指定分析运行和元素字段时序",
m.AnalysisSeriesInput,
"simulation.view",
"analysis",
),
(
"query_analysis_values",
"分页读取指定分析运行某时刻的元素字段",
m.AnalysisValuesInput,
"simulation.view",
"analysis",
),
(
"list_analysis_runs",
"分页读取持久分析运行",
m.RunsInput,
"webgis.view",
"analysis",
),
("get_analysis_run", "读取单个持久分析运行", m.RunInput, "webgis.view", "analysis"),
(
"list_analysis_results",
"分页读取分析成果,可筛选result_type",
m.ResultsInput,
"webgis.view",
"analysis",
),
(
"list_sensor_placement_runs",
"分页读取已有监测点选址运行",
m.PageInput,
"optimization.run",
"analysis",
),
(
"get_sensor_placement_run",
"读取已有监测点选址成果",
m.RunInput,
"optimization.run",
"analysis",
),
(
"query_element_history",
"比较实测与模型基线或分析结果;analysis_comparison要求run_id;limit/offset按序列、时间排序的数据点分页",
m.HistoryInput,
"scada.view",
"observed",
),
(
"get_element_gis_properties",
"读取元素关联GIS台账",
m.ElementInput,
"webgis.view",
"asset",
),
(
"get_source_gis_properties",
"读取指定图层源要素台账",
m.SourceGisInput,
"webgis.view",
"asset",
),
(
"get_workbench_runtime",
"读取工作台已有运行态",
m.StrictInput,
"simulation.view",
"task",
),
(
"get_task_context",
"读取绑定任务的有界摘要及handling_skill元数据;section=skill读取Server按任务workflow分发的业务处置Skill正文与版本,属于流程指引而非事件证据;其他section选择task/report/evidence/actions/progress/access_requests/plan/run,data_path按返回路径读取嵌套字段,limit/offset分页;report_revision保留选定报告版本",
m.TaskContextInput,
"operational_task.view",
"task",
True,
),
(
"query_peripheral_data",
"查询有限外围数据源;synthetic是合成证据",
m.PeripheralInput,
"simulation.view",
"peripheral",
),
(
"get_task_evidence",
"读取当前绑定任务已保存的证据快照",
m.EvidenceInput,
"operational_task.view",
"task",
True,
),
]
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
+76
View File
@@ -0,0 +1,76 @@
"""MCP is opt-in; an incomplete OAuth resource must never start listening."""
from pathlib import Path
from ipaddress import IPv4Address, IPv4Network
from urllib.parse import urlsplit
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class McpSettings(BaseSettings):
enabled: bool = False
issuer_uri: str = ""
dev_http_issuer_uri: str = ""
environment: str = Field(default="production", validation_alias="ENVIRONMENT")
resource_uri: str = ""
audience: str = "tjwater-mcp"
host_client_id: str = "tjwater-agent-mcp"
allowed_hosts: list[str] = Field(
default_factory=lambda: ["127.0.0.1:*", "localhost:*"]
)
allowed_origins: list[str] = Field(default_factory=list)
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
query_timeout_seconds: int = Field(default=30, ge=1, le=60)
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
model_config = SettingsConfigDict(
env_prefix="TJWATER_MCP_",
extra="ignore",
populate_by_name=True,
env_file=Path(__file__).resolve().parents[2] / ".env",
)
@model_validator(mode="after")
def validate_resource(self):
if self.dev_http_issuer_uri:
dev_url = urlsplit(self.dev_http_issuer_uri)
try:
address = IPv4Address(dev_url.hostname or "")
private_ipv4 = any(address in IPv4Network(network) for network in (
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"
))
except ValueError:
private_ipv4 = False
if (
self.environment != "development"
or dev_url.scheme != "http"
or not private_ipv4
or dev_url.username or dev_url.password or dev_url.query or dev_url.fragment
or self.dev_http_issuer_uri != self.issuer_uri
):
raise ValueError("development HTTP issuer requires development environment and an exact private IPv4 issuer")
if not self.enabled:
return self
for index, value in enumerate((self.issuer_uri, self.resource_uri)):
url = urlsplit(value)
if (
url.scheme not in {"http", "https"}
or not url.hostname
or url.username
or url.password
or url.query
or url.fragment
):
raise ValueError("MCP requires explicit issuer and resource URIs")
if url.scheme == "http" and url.hostname not in {
"localhost",
"127.0.0.1",
"::1",
}:
if index != 0 or value != self.dev_http_issuer_uri:
raise ValueError("non-loopback MCP and OAuth require HTTPS")
if urlsplit(self.resource_uri).path != "/mcp":
raise ValueError("MCP resource URI must identify the /mcp endpoint")
if not self.audience.strip() or not self.host_client_id.strip():
raise ValueError("MCP audience and host client ID are required")
return self
+112
View File
@@ -0,0 +1,112 @@
"""An OAuth identity and a Server-owned binding are both required for data queries."""
import secrets
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from app.auth.permissions import (
permissions_for_context,
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_VIEW,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
from app.mcp.models import BindInput, BoundContext
from app.mcp.scope import QueryScope
from app.mcp.config import McpSettings
from mcp.server.auth.provider import AccessToken
def task_repository(scope: QueryScope) -> OperationalTaskRepository:
return OperationalTaskRepository(scope.connection, scope.project.project_id)
async def verify_task(scope: QueryScope, binding: BoundContext) -> None:
permissions = permissions_for_context(scope.project)
if OPERATIONAL_TASK_VIEW not in permissions:
raise HTTPException(403, "Task read permission is required")
repo = task_repository(scope)
async with scope.connection.transaction():
task = await repo.get_task(str(binding.task_id))
if not task:
raise HTTPException(404, "Task is not in this project")
if binding.purpose == "task_collaboration":
return
if OPERATIONAL_TASK_EXECUTE not in permissions:
raise HTTPException(403, "Task execution permission is required")
run = await repo.get_active_run(str(binding.task_id))
now = datetime.now(timezone.utc)
owner = binding.parent_session_id or binding.native_session_id
if (
not run
or run["status"] not in {"claimed", "running"}
or run.get("agent_session_id") != owner
or not run.get("lease_expires_at")
or run["lease_expires_at"] <= now
or binding.run_id is not None
and str(run["run_id"]) != str(binding.run_id)
or binding.execution_request_id is not None
and str(run["execution_request_id"]) != str(binding.execution_request_id)
):
raise HTTPException(
409, "Investigation run is no longer active for this session"
)
binding.run_id = UUID(str(run["run_id"]))
binding.execution_request_id = UUID(str(run["execution_request_id"]))
async def bind_context(
scope: QueryScope, principal: AccessToken, command: BindInput, settings: McpSettings
) -> BoundContext:
if principal.client_id != settings.host_client_id:
raise HTTPException(
403, "Only the trusted product host may bind query contexts"
)
from app.mcp.catalog import TOOLS
binding = BoundContext(
context_handle=secrets.token_urlsafe(32),
project_id=scope.project.project_id,
subject=principal.subject,
client_id=principal.client_id,
tool_cap=sorted(name for name, tool in TOOLS.items() if command.purpose != "task_reader" or not tool.computation),
permission_cap=sorted(permissions_for_context(scope.project)),
native_session_id=command.native_session_id,
purpose=command.purpose,
parent_session_id=command.parent_session_id,
task_id=command.task_id,
expires_at=datetime.now(timezone.utc)
+ timedelta(seconds=settings.context_ttl_seconds),
)
if binding.task_id:
await verify_task(scope, binding)
from app.infra.db.postgresql.read_queries import task_data_scopes
async with scope.connection.transaction():
binding.task_scope_cap = await task_data_scopes(scope.connection,
project_id=binding.project_id, task_id=binding.task_id,
permissions=permissions_for_context(scope.project))
await McpQueryRepository(scope.connection, scope.project.project_id).create(binding)
return binding
async def resolve_context(
scope: QueryScope, principal: AccessToken, handle: str
) -> BoundContext:
binding = await McpQueryRepository(scope.connection, scope.project.project_id).get(
handle
)
if (
not binding
or binding.revoked_at
or binding.expires_at <= datetime.now(timezone.utc)
or binding.subject != principal.subject
or binding.client_id != principal.client_id
or binding.project_id != scope.project.project_id
):
raise HTTPException(
403, "Query context is unavailable for this identity and project"
)
if binding.task_id:
await verify_task(scope, binding)
return binding
+15
View File
@@ -0,0 +1,15 @@
"""Delegate MCP transport/metadata with the original request path intact."""
class McpDispatchMiddleware:
def __init__(self, app, runtime):
self.app, self.runtime = app, runtime
async def __call__(self, scope, receive, send):
path = scope.get("path", "")
if scope["type"] == "http" and (
path == "/mcp" or path.startswith("/.well-known/oauth-protected-resource")
):
await self.runtime.asgi(scope, receive, send)
else:
await self.app(scope, receive, send)
+293
View File
@@ -0,0 +1,293 @@
"""Strict business inputs and provenance envelopes shared by MCP handlers."""
from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
Id = Annotated[str, Field(min_length=1, max_length=160, pattern=r"^\S(?:.*\S)?$")]
ElementType = Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"]
SeriesType = Literal["junction", "pipe"]
class StrictInput(BaseModel):
model_config = ConfigDict(extra="forbid")
context_handle: Annotated[str, Field(min_length=32, max_length=128)]
class PageInput(StrictInput):
limit: int = Field(default=20, ge=1, le=500)
offset: int = Field(default=0, ge=0, le=10000)
class TaskContextInput(PageInput):
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
report_revision: int | None = Field(default=None, ge=1)
data_path: list[Id] = Field(default_factory=list, max_length=8)
@model_validator(mode="after")
def summary_is_fixed(self):
if self.section in {"summary", "skill"} and (self.data_path or self.offset):
raise ValueError("Select a detail section before using data_path or offset")
return self
class ElementInput(StrictInput):
element_type: ElementType
element_id: Id
class ElementPageInput(PageInput):
element_type: ElementType
class NearbyElementsInput(PageInput):
element_type: ElementType = "pipe"
center: tuple[
Annotated[float, Field(ge=-180, le=180, allow_inf_nan=False)],
Annotated[float, Field(ge=-90, le=90, allow_inf_nan=False)],
] = Field(description="EPSG:4326 [longitude, latitude], directly from geocode.center")
radius_m: float = Field(default=200, gt=0, le=10000, allow_inf_nan=False)
limit: int = Field(default=5, ge=1, le=100)
class OptionsInput(StrictInput):
kind: Literal["time", "energy", "pump-energy", "network"]
pump_id: Id | None = None
@model_validator(mode="after")
def pump_required(self):
if self.kind == "pump-energy" and not self.pump_id:
raise ValueError("pump_id is required for pump-energy")
if self.kind != "pump-energy" and self.pump_id:
raise ValueError("pump_id is only valid for pump-energy")
return self
class SchemaInput(StrictInput):
kind: Literal[
"junction",
"pipe",
"valve",
"pump",
"tank",
"reservoir",
"time",
"energy",
"pump-energy",
"option",
"scada-device",
]
class DeviceInput(StrictInput):
device_id: Id
class WindowInput(PageInput):
start: datetime
end: datetime
limit: int = Field(default=20, ge=1, le=10000)
@model_validator(mode="after")
def window_valid(self):
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("timestamps require explicit timezone")
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
raise ValueError("time window must be positive and at most 24 hours")
return self
class ScadaInput(WindowInput):
device_ids: list[Id] = Field(min_length=1, max_length=20)
field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
class BaselineSeriesInput(WindowInput):
element_type: SeriesType
element_ids: list[Id] = Field(min_length=1, max_length=20)
class TimeInput(StrictInput):
time: datetime
@model_validator(mode="after")
def timezone_required(self):
if self.time.tzinfo is None:
raise ValueError("timestamp requires explicit timezone")
return self
class BaselineAtTimeInput(TimeInput):
element_type: SeriesType
element_id: Id
class BaselineFieldInput(TimeInput):
element_type: SeriesType
field: Id
limit: int = Field(default=20, ge=1, le=500)
offset: int = Field(default=0, ge=0, le=10000)
class AnalysisSeriesInput(WindowInput):
run_id: UUID
element_type: SeriesType
element_id: Id
field: Id
class AnalysisValuesInput(BaselineFieldInput):
run_id: UUID
class RunInput(StrictInput):
run_id: UUID
class RunsInput(PageInput):
limit: int = Field(default=20, ge=1, le=100)
class ResultsInput(PageInput):
run_id: UUID
result_type: Id | None = None
class HistoryTarget(BaseModel):
model_config = ConfigDict(extra="forbid")
element_type: SeriesType
element_id: Id
device_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20)
class HistoryInput(WindowInput):
elements: list[HistoryTarget] = Field(min_length=1, max_length=20)
mode: Literal["observed", "realtime_comparison", "analysis_comparison"] = "observed"
run_id: UUID | None = None
@model_validator(mode="after")
def analysis_required(self):
if (self.mode == "analysis_comparison") != (self.run_id is not None):
raise ValueError("run_id is required only for analysis_comparison")
return self
class SourceGisInput(StrictInput):
layer_key: Annotated[str, Field(min_length=1, max_length=80)]
source_fid: int = Field(ge=0, le=9223372036854775807)
class PeripheralInput(PageInput):
source: Literal[
"peripheral_points",
"peripheral_work_orders",
"peripheral_observations",
"peripheral_test_data",
]
point_id: Id | None = None
start: datetime | None = None
end: datetime | None = None
@model_validator(mode="after")
def observation_window(self):
if self.source == "peripheral_observations":
if not self.point_id or self.start is None or self.end is None:
raise ValueError("observations require point_id, start and end")
if (
self.start.tzinfo is None
or self.end.tzinfo is None
or not timedelta(0) < self.end - self.start <= timedelta(days=1)
):
raise ValueError(
"observation window requires timezone and at most 24 hours"
)
elif (
self.point_id is not None or self.start is not None or self.end is not None
):
raise ValueError(
"point_id and window are only valid for peripheral_observations"
)
return self
class EvidenceInput(PageInput):
evidence_id: Id
data_path: list[Id] = Field(default_factory=list, max_length=8)
class BindInput(BaseModel):
model_config = ConfigDict(extra="forbid")
native_session_id: Id
purpose: Literal[
"ordinary_chat", "task_execution", "task_reader", "task_collaboration"
]
task_id: UUID | None = None
parent_session_id: Id | None = None
@model_validator(mode="after")
def task_scope(self):
if self.purpose == "ordinary_chat" and (self.task_id or self.parent_session_id):
raise ValueError("ordinary chat cannot bind a task")
if self.purpose != "ordinary_chat" and self.task_id is None:
raise ValueError("task context requires task_id")
if (self.purpose == "task_reader") != (self.parent_session_id is not None):
raise ValueError("only a task reader requires parent_session_id")
return self
class BindResponse(BaseModel):
context_handle: str
expires_at: datetime
task_id: UUID | None
run_id: UUID | None
class PageInfo(BaseModel):
limit: int
offset: int
total: int | None = None
has_more: bool
class QueryResult(BaseModel):
model_config = ConfigDict(extra="forbid")
source: str
source_ref: str
queried_at: datetime
origin: Literal["system_data", "synthetic"] = "system_data"
semantics: Literal[
"asset",
"observed",
"model_baseline",
"analysis",
"comparison",
"task",
"peripheral",
]
data: JsonValue
page: PageInfo | None = None
window: dict[str, datetime] | None = None
units: dict[str, str] = Field(default_factory=dict)
truncated: bool = False
data_gaps: list[str] = Field(default_factory=list)
evidence_id: str | None = None
class BoundContext(BaseModel):
context_handle: str
project_id: UUID
subject: str
client_id: str
tool_cap: list[str] = Field(default_factory=list)
permission_cap: list[str] = Field(default_factory=list)
task_scope_cap: list[str] = Field(default_factory=list)
native_session_id: str
purpose: str
parent_session_id: str | None = None
task_id: UUID | None = None
run_id: UUID | None = None
execution_request_id: UUID | None = None
expires_at: datetime
revoked_at: datetime | None = None
+132
View File
@@ -0,0 +1,132 @@
"""Read existing project-owned peripheral facts without forwarding MCP tokens."""
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import HTTPException
from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, scada_devices, series_rows
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
from app.services.content_digest import digest
from samples.peripheral.store import PostgresStore
from app.infra.integrations.operational_data import _select_demo_observations
async def read_peripheral_query(scope, args):
conn = scope.connection
project = str(scope.project.project_id)
truncated = False
origin = "system_data"
if args.source in {"peripheral_work_orders", "peripheral_test_data"}:
table, column = (
("work_orders", "work_order_id")
if args.source == "peripheral_work_orders"
else ("test_data_records", "record_id")
)
rows = await read_rows(
conn,
"SELECT document FROM peripheral_sample."
+ table
+ " WHERE project_id=%s AND deleted_at IS NULL ORDER BY "
+ column
+ " LIMIT %s OFFSET %s",
(project, args.limit + 1, args.offset),
)
items = [
PostgresStore._active_managed_document(row["document"])
for row in rows[: args.limit]
]
truncated = len(rows) > args.limit
if any(item.get("project_id") != project for item in items):
raise HTTPException(502, "Peripheral project mismatch")
data = {
"items": items,
"truncated": truncated,
"limit": args.limit,
"offset": args.offset,
}
origin = (
"synthetic"
if items and any(item.get("origin") == "synthetic" for item in items)
else "system_data"
)
elif args.source == "peripheral_points":
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
data = {
"origin": "historical_replay",
"items": [
{
"point_id": row["device_id"],
"label": row["device_id"] + " · " + row["device_type"],
"unit": row["measurement_unit"],
"status": "unknown",
"origin": "historical_replay",
}
for row in rows[: args.limit]
],
"truncated": len(rows) > args.limit,
}
truncated = data["truncated"]
elif args.point_id.startswith("DEMO-"):
rows = await read_rows(
conn,
"SELECT CASE WHEN octet_length(document::text)<=%s THEN document END AS document,"
"digest,octet_length(document::text)>%s AS too_large "
"FROM peripheral_sample.demo_datasets WHERE project_id=%s AND dataset_id='water-demo-v1'",
(MAX_QUERY_BYTES, MAX_QUERY_BYTES, project),
)
if rows and rows[0]["too_large"]:
raise HTTPException(413, "Synthetic dataset exceeds the integrity-check byte budget")
if not rows or digest(rows[0]["document"]) != rows[0]["digest"]:
raise HTTPException(503, "Synthetic dataset is unavailable or invalid")
query = TaskDataQuery(
source=args.source, point_id=args.point_id, start=args.start, end=args.end
)
data = _select_demo_observations(
{**rows[0]["document"], "project_id": project}, query, project
)
items = data["items"]
truncated = len(items) > args.limit + args.offset
data["items"] = items[args.offset : args.offset + args.limit]
data["truncated"] = truncated
origin = "synthetic"
else:
devices = await scada_devices(conn, device_id=args.point_id, limit=1)
if not devices:
raise HTTPException(404, "Peripheral point was not found")
device = devices[0]
if not device.get("api_query_id"):
raise HTTPException(503, "Peripheral history mapping is unavailable")
async with scope.timescale() as ts:
rows = await series_rows(
ts,
source="scada",
element_type="junction",
ids=[device["api_query_id"]],
start=args.start,
end=args.end,
field="monitored_value",
limit=args.limit + 1,
offset=args.offset,
end_exclusive=True,
)
truncated = len(rows) > args.limit
data = {
"origin": "historical_replay",
"items": [
{
"point_id": args.point_id,
"observed_at": row["time"].isoformat(),
"value": row["value"],
"unit": device["measurement_unit"],
}
for row in rows[: args.limit]
],
"truncated": truncated,
}
return TaskDataSnapshot(
source=args.source,
source_ref="mcp:" + str(uuid4()),
queried_at=datetime.now(timezone.utc),
data=data,
origin=origin,
truncated=truncated,
)
+492
View File
@@ -0,0 +1,492 @@
"""MCP domain dispatch reuses Server repositories and services, never an HTTP loopback."""
import asyncio
import math
from contextlib import nullcontext
from typing import Any
from pydantic import BaseModel
from app.mcp.scope import QueryScope
from app.mcp.catalog import QueryTool, TOOLS
from app.mcp.models import BoundContext
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from app.auth.permissions import permissions_for_context
from app.infra.db.postgresql import read_queries as reads
from app.infra.db.postgresql.spatial_queries import nearby_elements
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.services.operational_tasks import OperationalTaskService
from app.mcp.models import QueryResult, PageInfo
from app.mcp.scenario_service import SCENARIO_TOOLS
from app.mcp.work_order_skills import read_work_order_skill
def finite_data(value: Any) -> tuple[Any, int]:
"""JSON cannot represent infinity/NaN; preserve the gap as null with provenance."""
if isinstance(value, float) and not math.isfinite(value):
return None, 1
count = 0
if isinstance(value, dict):
result = {}
for key, item in value.items():
result[key], replaced = finite_data(item)
count += replaced
return result, count
if isinstance(value, list):
result = []
for item in value:
clean, replaced = finite_data(item)
result.append(clean)
count += replaced
return result, count
return value, 0
def task_service(scope: QueryScope) -> OperationalTaskService:
return OperationalTaskService(
OperationalTaskRepository(scope.connection, scope.project.project_id),
scope.project,
set(permissions_for_context(scope.project)),
)
def page_result(rows, args):
has_more = len(rows) > args.limit
return (
rows[: args.limit],
PageInfo(limit=args.limit, offset=args.offset, total=None, has_more=has_more),
has_more,
)
async def authorize_query(
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
) -> None:
if tool.name not in binding.tool_cap:
raise HTTPException(403, "Query is outside the captured tool catalog")
permissions = permissions_for_context(scope.project)
if tool.computation:
if tool.name == "optimize_sensor_placement" and scope.project.project_role != "member":
raise HTTPException(403, "Sensor placement requires project member access")
if binding.purpose == "task_reader":
raise HTTPException(403, "Read-only task agents cannot start calculations")
required = {tool.permission}
if tool.name in SCENARIO_TOOLS:
required.add("scada.view")
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
raise HTTPException(403, "Calculation and input data permissions are required")
permission = (
"scada.view"
if tool.name == "get_network_schema" and args.kind == "scada-device"
else tool.permission
)
if tool.task_only and binding.task_id is None:
raise HTTPException(403, "This query requires a bound task")
granted = permission in permissions and permission in binding.permission_cap
source = {
"list_scada_devices": "scada_devices",
"get_scada_device": "scada_devices",
"query_scada_readings": "scada_readings",
"query_element_history": "scada_readings",
"get_workbench_runtime": "workbench_runtime",
}.get(tool.name)
if tool.name == "query_peripheral_data":
source = args.source
if not granted and binding.task_id and source in binding.task_scope_cap:
async with scope.connection.transaction():
granted = await task_service(scope).can_read_source(binding.task_id, source)
if not granted:
raise HTTPException(403, "Data source permission is required")
if (
tool.name == "query_element_history"
and args.mode != "observed"
and (
"simulation.view" not in permissions
or "simulation.view" not in binding.permission_cap
)
):
raise HTTPException(403, "Model comparison permission is required")
async def execute_query(
scope: QueryScope,
binding: BoundContext,
tool: QueryTool,
args: BaseModel,
*,
full_snapshot: bool = False,
) -> QueryResult:
await authorize_query(scope, binding, tool, args)
name = tool.name
conn = scope.connection
page = None
truncated = False
units = {}
origin = "system_data"
gaps = []
source_ref = "mcp:" + str(uuid4())
evidence_id = None
if tool.computation:
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
# Context/receipt reads may have started an implicit transaction. Release
# it before potentially long CPU/IO work; evidence uses a new transaction.
await conn.commit()
async with (
scope.simulation_routing()
if name in TIMESCALE_CALCULATIONS
else nullcontext()
):
data = await calculate(
name, args, network=scope.project.project_code,
username=scope.user.username,
)
data = {
"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": data,
}
gaps = [{
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.",
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
"optimize_sensor_placement": "Candidate pressure monitoring locations, not a saved deployment scheme or a guarantee of contaminant source observability.",
"run_leakage_identification": "DMA leakage estimates depend on the total leakage constraint and aligned model/SCADA time; they do not confirm an exact leak location.",
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
}[name]]
if getattr(args, "data_source", None) == "simulation":
gaps.append("Input is an existing analysis run and model baseline, not observed SCADA data.")
elif name == "query_nearby_elements":
rows = await nearby_elements(
conn, args.element_type, center=args.center, radius_m=args.radius_m,
limit=args.limit + 1, offset=args.offset,
)
items, page, truncated = page_result(rows, args)
truncated = truncated or args.offset > 0
data = {
"source": "model_network", "element_type": args.element_type,
"center": list(args.center), "center_crs": "EPSG:4326",
"radius_m": args.radius_m, "distance_method": "WGS84_spheroid",
"items": items,
}
units = {"distance_m": "m", "radius_m": "m"}
gaps = ["Spatial proximity does not establish hydraulic connectivity or a supply relationship"]
elif name in {"get_element_properties", "list_elements"}:
rows = await reads.asset_rows(
conn,
args.element_type,
element_id=getattr(args, "element_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_element_properties":
if not rows:
raise HTTPException(404, "Element was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name in {"list_scada_devices", "get_scada_device"}:
rows = await reads.scada_devices(
conn,
device_id=getattr(args, "device_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_scada_device":
if not rows:
raise HTTPException(404, "SCADA device was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name == "get_network_schema":
from app.services import tjnetwork
mapping = {
"option": "get_option_v3_schema",
"scada-device": "get_scada_info_schema",
"pump-energy": "get_pump_energy_schema",
}
function = getattr(
tjnetwork, mapping.get(args.kind, "get_" + args.kind + "_schema")
)
data = await asyncio.to_thread(function, scope.project.project_code)
elif name == "get_network_options":
if args.kind == "pump-energy":
rows = await reads.read_rows(
conn,
"SELECT price,pattern_id AS pattern,efficiency_curve_id AS effic FROM network.pump_energy_settings WHERE pump_id=%s",
(args.pump_id,),
)
data = {"pump": args.pump_id, "price": None, "pattern": None, "effic": None}
if rows:
data.update(rows[0])
else:
table = {
"time": "time_settings",
"energy": "energy_settings",
"network": "simulation_settings",
}[args.kind]
where = " WHERE engine_version='v3'" if args.kind == "network" else ""
rows = await reads.read_rows(
conn,
"SELECT key,value FROM network."
+ table
+ where
+ " ORDER BY key LIMIT 501",
)
data = {str(row["key"]): str(row["value"]) for row in rows[:500]}
truncated = len(rows) > 500
elif name in {
"query_scada_readings",
"query_model_baseline_series",
"get_model_baseline_at_time",
"query_model_baseline_field",
"query_analysis_series",
"query_analysis_values",
}:
source = (
"scada"
if name == "query_scada_readings"
else "analysis"
if name.startswith("query_analysis")
else "realtime"
)
ids = (
getattr(args, "device_ids", None)
or getattr(args, "element_ids", None)
or ([args.element_id] if hasattr(args, "element_id") else [])
)
async with scope.timescale() as ts:
rows = await reads.series_rows(
ts,
source=source,
element_type=getattr(args, "element_type", "junction"),
ids=ids,
start=getattr(args, "start", None),
end=getattr(args, "end", None),
time=getattr(args, "time", None),
field=getattr(args, "field", None),
run_id=getattr(args, "run_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_model_baseline_at_time":
data = rows[:1]
else:
data, page, truncated = page_result(rows, args)
if source != "scada":
async with conn.transaction():
units = await NetworkSettingsRepository.get_result_units(conn)
if source == "analysis":
run = await AnalysisRepository.get_run(conn, args.run_id)
if not run:
raise HTTPException(404, "Analysis run was not found")
recorded = (run.get("parameters") or {}).get("result_units")
if isinstance(recorded, dict):
units = recorded
else:
gaps.append(
"Analysis units inferred from current network settings"
)
if hasattr(args, "field") and args.field not in units:
gaps.append(f"Unit metadata is unavailable for field {args.field}")
else:
metadata = [
r
for device in ids
for r in await reads.scada_devices(conn, device_id=device, limit=1)
]
units = {
r["device_id"]: r["measurement_unit"]
for r in metadata
if r["device_id"] in ids
}
elif name in {"list_analysis_runs", "get_analysis_run", "list_analysis_results"}:
async with conn.transaction():
if name == "get_analysis_run":
data = await AnalysisRepository.get_run(conn, args.run_id)
if not data:
raise HTTPException(404, "Analysis run was not found")
elif name == "list_analysis_runs":
# Fetch one additional row with the repository's supported summary-page bound.
rows = await AnalysisRepository.list_run_summaries(
conn, limit=args.limit + 1, offset=args.offset
)
data, page, truncated = page_result(rows, args)
else:
rows = await AnalysisRepository.list_results(
conn,
args.run_id,
args.result_type,
limit=args.limit,
lookahead=True,
offset=args.offset,
)
data, page, truncated = page_result(rows, args)
elif name in {"list_sensor_placement_runs", "get_sensor_placement_run"}:
rows = await reads.sensor_runs(
conn,
run_id=getattr(args, "run_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_sensor_placement_run":
if not rows:
raise HTTPException(404, "Sensor placement run was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name == "query_element_history":
from app.domain.schemas.timeseries_history import ElementHistoryQuery
from app.services.timeseries_history import TimeseriesHistoryService
query = ElementHistoryQuery(
start_time=args.start,
end_time=args.end,
mode=args.mode,
run_id=args.run_id,
elements=[e.model_dump() for e in args.elements],
)
async with scope.timescale() as ts, conn.transaction(), ts.transaction():
result = await TimeseriesHistoryService.query(
ts, conn, query, row_limit=args.limit, row_offset=args.offset
)
data = result.model_dump(mode="json")
truncated = result.truncated
page = PageInfo(limit=args.limit, offset=args.offset, has_more=truncated)
truncated = truncated or args.offset > 0
elif name in {"get_element_gis_properties", "get_source_gis_properties"}:
async with conn.transaction():
if name == "get_element_gis_properties":
data = await GisAttributeRepository.list_element_groups(
conn, args.element_type, args.element_id
)
else:
data = await GisAttributeRepository.get_source_feature_group(
conn, args.layer_key, args.source_fid
)
if data is None:
raise HTTPException(404, "Source GIS feature was not found")
elif name == "get_task_context":
if args.section in {"summary", "skill"}:
data = await reads.task_context_summary(conn, project_id=binding.project_id,
task_id=binding.task_id, report_revision=args.report_revision)
skill = read_work_order_skill(
data.get("task", {}).get("workflow"), include_content=args.section == "skill"
)
if args.section == "skill":
data = {"section": "skill", "skill": skill}
else:
data["handling_skill"] = skill
data["display_time_zone"] = "Asia/Shanghai"
data["approved_data_scopes"] = binding.task_scope_cap
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"]
truncated = True
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
else:
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
total = projection["total"]
if args.section in {"evidence", "actions", "access_requests"} and not args.data_path:
total = projection["snapshot_metadata"].get("total", total)
if projection["value_type"] in {"array", "object", "string"}:
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
elif args.offset:
raise HTTPException(422, "Scalar task detail cannot use an offset")
truncated = bool(projection.get("nested") or args.offset or page and page.has_more)
data = {"section": args.section, "selected_path": args.data_path, "value": projection["value"], "metadata": projection["snapshot_metadata"]}
if projection.get("nested"):
gaps.append("Nested values have data_path descriptors; select a descriptor path for its values")
elif name == "get_task_evidence":
projection = await reads.evidence_page(
conn, project_id=scope.project.project_id, task_id=binding.task_id,
evidence_id=args.evidence_id, data_path=args.data_path,
limit=args.limit, offset=args.offset, mcp_sources=list(TOOLS),
full_snapshot=full_snapshot,
)
document = projection if full_snapshot else projection["snapshot_metadata"]
origin = document["origin"]
source_ref = document["source_ref"]
evidence_id = args.evidence_id
truncated = document.get("truncated", False)
if full_snapshot:
data = document
else:
selected = projection["value"]
if projection["value_type"] in {"array", "object", "string"}:
total = projection["total"]
page = PageInfo(
limit=args.limit,
offset=args.offset,
total=total,
has_more=args.offset + args.limit < total,
)
truncated = truncated or page.has_more or args.offset > 0
elif args.offset:
raise HTTPException(422, "Scalar evidence cannot use an offset")
if projection.get("nested"):
truncated = True
gaps.append(
"Nested collections have data_path descriptors; query a descriptor path for their values"
)
data = {
"snapshot_metadata": document,
"query_metadata": projection["query_metadata"],
"selected_path": args.data_path,
"value": selected,
}
elif name == "get_workbench_runtime":
from app.services.workbench_runtime import read_workbench_runtime
data = (await read_workbench_runtime(scope.project, conn)).model_dump(
mode="json"
)
elif name == "query_peripheral_data":
from app.mcp.peripheral import read_peripheral_query
snapshot = await read_peripheral_query(scope, args)
data = snapshot.data
origin = snapshot.origin
truncated = snapshot.truncated
else:
raise ValueError("query handler is not registered")
window = (
{"start": args.start, "end": args.end}
if hasattr(args, "start") and args.start is not None
else None
)
data, nonfinite = finite_data(jsonable_encoder(data))
semantics = (
"comparison"
if name == "query_element_history" and args.mode != "observed"
else tool.semantics
)
return QueryResult(
source=name,
source_ref=source_ref,
evidence_id=evidence_id,
queried_at=datetime.now(timezone.utc),
semantics=semantics,
data=data,
data_gaps=gaps
+ (
[f"{nonfinite} non-finite numeric values were returned as null"]
if nonfinite
else []
),
page=page,
window=window,
units=units,
truncated=truncated,
origin=origin,
)
+70
View File
@@ -0,0 +1,70 @@
"""MCP online recalculation and isolated scenario adapters."""
from typing import Any
SCENARIO_TOOLS = frozenset({
"run_simulation",
"run_contaminant_simulation", "analyze_valve_closure",
"analyze_flushing", "analyze_pressure_regulation",
})
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]:
from app.services import simulation_scenarios as scenarios
parameters = dict(parameters)
nodes = parameters.pop("output_node_ids")
links = parameters.pop("output_link_ids")
start = parameters.pop("start_time")
duration = parameters.pop("duration")
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
modify_total_duration=duration, persist_results=False, scheme_name=None)
if name == "run_simulation":
from app.services.simulation import run_online_simulation
result = run_online_simulation(
network, start, duration, output_node_ids=nodes, output_link_ids=links,
)
elif name == "run_contaminant_simulation":
result = scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
elif name == "analyze_valve_closure":
result = scenarios.valve_close_analysis(**common,
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
elif name == "analyze_flushing":
# The service consumes m³/h (the older REST label incorrectly says L/s).
# Reject unsupported conversion rather than silently ignore the flow.
from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing currently supports LPS or CMH models only")
result = scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
elif name == "analyze_pressure_regulation":
from app.services.simulation import query_corresponding_element_id_and_query_id
mappings = query_corresponding_element_id_and_query_id(network)
controls = parameters["pump_control"]
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
raise ValueError("Pump controls must reference registered model pumps")
if any(value not in (0, 1) for key, values in controls.items() if key in mappings.fixed_pumps for value in values):
raise ValueError("Fixed-speed pump controls must be 0 or 1")
result = scenarios.pressure_regulation(**common,
modify_fixed_pump_pattern={key: value for key, value in controls.items() if key in mappings.fixed_pumps},
modify_variable_pump_pattern={key: value for key, value in controls.items() if key in mappings.variable_pumps},
modify_tank_initial_level=parameters["tank_init_level"], scada_mappings=mappings)
else:
raise ValueError("Scenario is not registered")
if not isinstance(result, dict):
raise ValueError("Scenario did not return calculation results")
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
return {
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
"start_time": start.isoformat(), "duration_seconds": duration,
"units": result.get("units", {}), "times": result.get("times", {}),
"node_results": selected_nodes, "link_results": selected_links,
"coverage": {"requested_nodes": nodes, "requested_links": links,
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
}
+76
View File
@@ -0,0 +1,76 @@
"""Resolve authoritative metadata and project routing for each MCP request."""
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, AsyncIterator
from psycopg import AsyncConnection
from app.auth.project_dependencies import ProjectContext
from app.infra.db.project_routing import ActiveProjectRouting
from fastapi import HTTPException
from mcp.server.auth.provider import AccessToken
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import (
resolve_project_context,
resolve_project_business_routing,
resolve_project_simulation_routing,
get_project_pg_connection,
get_project_timescale_connection,
)
from app.infra.db.metadb.database import get_metadata_session
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import activate_project_routing
class QueryMetadataRepository(MetadataRepository):
async def get_project_db_routing(self, project_id, role):
routing = await super().get_project_db_routing(project_id, role)
# Existing connection generators obtain routing before awaiting a pool.
# End that implicit SELECT transaction here, before the network wait.
await self.session.commit()
return routing
@dataclass
class QueryScope:
project: ProjectContext
user: Any
connection: AsyncConnection
routing: ActiveProjectRouting
metadata: QueryMetadataRepository
@asynccontextmanager
async def simulation_routing(self) -> AsyncIterator[ActiveProjectRouting]:
"""Resolve both databases before a native calculation can modify its model."""
routing = await resolve_project_simulation_routing(self.project, self.metadata)
await self.metadata.session.commit()
with activate_project_routing(routing):
yield routing
@asynccontextmanager
async def timescale(self):
# Metadata repository here is used only to resolve a known project DB.
async with asynccontextmanager(get_project_timescale_connection)(
self.project, self.metadata
) as conn:
await self.metadata.session.commit()
yield conn
@asynccontextmanager
async def project_scope(principal: AccessToken, project_id: str):
if not principal.claims:
raise HTTPException(401, "MCP identity is missing")
async with asynccontextmanager(get_metadata_session)() as session:
metadata = QueryMetadataRepository(session)
user = await get_current_metadata_user(principal.claims, metadata)
project = await resolve_project_context(project_id, user, metadata)
routing = await resolve_project_business_routing(project, metadata)
# End the metadata read before any external query/IO waits.
await session.commit()
async with asynccontextmanager(get_project_pg_connection)(
project, metadata
) as conn:
await session.commit()
with activate_project_routing(routing):
yield QueryScope(project, user, conn, routing, metadata)
+40
View File
@@ -0,0 +1,40 @@
"""Server-owned business instructions, selected from the persisted task workflow."""
from hashlib import sha256
from pathlib import Path
from typing import Any
import yaml
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
WORKFLOW_SKILLS = {
"burst_report": "burst-report",
"water_quality_complaint": "water-quality-complaint",
"general": "appointment-follow-up",
}
def read_work_order_skill(workflow: str | None, *, include_content: bool = False) -> dict[str, Any]:
"""Read only packaged, allowlisted instructions; never interpret a task path."""
name = WORKFLOW_SKILLS.get(workflow)
if name is None:
return {"available": False, "workflow": workflow, "reason": "No business skill is configured for this workflow"}
content = (SKILL_ROOT / name / "SKILL.md").read_text(encoding="utf-8")
_, frontmatter, body = content.split("---", 2)
header = yaml.safe_load(frontmatter)
if header["name"] != name:
raise ValueError("Packaged work-order skill name does not match its directory")
result = {
"available": True,
"kind": "business_instructions",
"workflow": workflow,
"name": header["name"],
"description": header["description"],
"version": str(header["metadata"]["version"]),
"content_sha256": sha256(content.encode("utf-8")).hexdigest(),
"read": {"section": "skill"},
}
if include_content:
result["instructions"] = body.strip()
return result
+2 -2
View File
@@ -120,7 +120,7 @@ def dump_inp(project: str, inp: str, version: str = '3'):
file.write('\n'.join(inp_out_demand(project)))
elif name == STATUS:
file.write('\n'.join(inp_out_status(project)))
file.write('\n'.join(inp_out_status(project, version=version)))
elif name == PATTERNS:
if version == '3':
@@ -232,7 +232,7 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
inp += '\n'.join(inp_out_demand(project))
elif name == STATUS:
inp += '\n'.join(inp_out_status(project))
inp += '\n'.join(inp_out_status(project, version=version))
elif name == PATTERNS:
if version == '3':
+4 -2
View File
@@ -93,14 +93,16 @@ def inp_in_status(line: str) -> str:
return str(f"insert into network.link_initial_settings (link_id, status, setting) values ({sql_literal(link)}, null, {sql_literal(float(value))});")
def inp_out_status(name: str) -> list[str]:
def inp_out_status(name: str, version: str = '2') -> list[str]:
lines = []
objs = read_all(name, 'select link_id as link, status, setting from network.link_initial_settings order by link_id')
for obj in objs:
link = obj['link']
status = obj['status'] if obj['status'] != None else ''
setting = obj['setting'] if obj['setting'] != None else ''
if status != '':
# EPANET 2 valves are active at their configured setting by default.
# ACTIVE is an internal state, not a legal [STATUS] keyword in v2.
if status != '' and not (version == '2' and status.upper() == LINK_STATUS_ACTIVE):
lines.append(f'{link} {status}')
if setting != '':
lines.append(f'{link} {setting}')
+186
View File
@@ -0,0 +1,186 @@
"""Messages never change task generation or silently target a replacement run."""
from datetime import datetime, timezone
from uuid import UUID, uuid4
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, permissions_for_context
from app.domain.domain_errors import DomainError, require
from app.domain.schemas.agent_messages import (
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
)
def _message_cursor(cursor: str | None):
if not cursor:
return None, None
try:
stamp, identifier = cursor.rsplit("|", 1)
cursor_at, cursor_id = datetime.fromisoformat(stamp), UUID(identifier)
require(cursor_at.tzinfo is not None, "INVALID_CURSOR", "Cursor needs a timezone", status=400)
return cursor_at, cursor_id
except (ValueError, TypeError) as exc:
raise DomainError("INVALID_CURSOR", "Invalid message cursor", status=400) from exc
class AgentMessageService:
def __init__(self, repository, context, *, clock=None):
self.repo = repository
self.context = context
self.clock = clock or (lambda: datetime.now(timezone.utc))
def _require_service(self) -> None:
require(self.context.system_role == "service" and
OPERATIONAL_TASK_EXECUTE in permissions_for_context(self.context), "SERVICE_REQUIRED",
"Message dispatch requires a project service account", status=403)
async def get(self, message_id: UUID) -> AgentMessage:
if self.context.system_role == "service":
self._require_service()
row = await self.repo.get_message(str(message_id))
require(row is not None, "MESSAGE_NOT_FOUND", "Message not found", status=404)
require(self.context.system_role == "service" or
str(row["conversation_owner_id"]) == str(self.context.user_id),
"MESSAGE_NOT_FOUND", "Message not found", status=404)
return AgentMessage.model_validate(row)
async def _active_run(self, task_id: UUID, run_id: UUID) -> dict:
run = await self.repo.get_run(str(run_id))
require(run is not None and str(run["task_id"]) == str(task_id),
"RUN_NOT_FOUND", "Run does not belong to the task", status=404)
task = await self.repo.get_task(str(task_id))
require(task is not None and task["task_type"] == "event_investigation",
"INVALID_TASK_TYPE", "Messages require an investigation task", status=409)
require(run["status"] in {"queued", "claimed", "running"},
"RUN_TERMINAL", "Run no longer accepts messages", status=409)
if run["status"] != "queued":
expires = run.get("lease_expires_at")
if isinstance(expires, str):
expires = datetime.fromisoformat(expires.replace("Z", "+00:00"))
require(expires is not None and expires > self.clock(),
"LEASE_EXPIRED", "Run lease expired", status=409)
return run
async def _insert(self, *, command, owner_id, conversation_owner_id, direction, task_id, run_id,
target_session_id, in_reply_to=None) -> AgentMessage:
prior = await self.repo.get_message_request(
str(owner_id), command.source_session_id, str(command.request_id),
)
expected = {
"task_id": str(task_id), "run_id": str(run_id),
"direction": direction, "in_reply_to": str(in_reply_to) if in_reply_to else None,
"text": command.text,
}
if prior is not None:
require(all((str(prior[key]) if prior[key] is not None else None) == value
for key, value in expected.items()),
"IDEMPOTENCY_CONFLICT", "request_id was used with different content", status=409)
return AgentMessage.model_validate(prior)
# The run lock serializes message creation with completion/cancellation.
await self._active_run(task_id, run_id)
message_id = uuid4()
now = self.clock()
message = AgentMessage(
project_id=self.context.project_id,
message_id=message_id, request_id=command.request_id,
task_id=task_id, run_id=run_id, source_session_id=command.source_session_id,
source_owner_id=owner_id, conversation_owner_id=conversation_owner_id,
target_session_id=target_session_id,
direction=direction, in_reply_to=in_reply_to, text=command.text,
status="pending", native_message_id=f"msg_{message_id}",
created_at=now, updated_at=now,
)
await self.repo.insert_message(message.model_dump(mode="json"))
return message
async def create(self, command: AgentMessageCreate) -> AgentMessage:
async with self.repo.transaction():
await self.repo.lock(f"run:{command.run_id}")
run = await self.repo.get_run(str(command.run_id))
return await self._insert(
command=command, owner_id=self.context.user_id,
conversation_owner_id=self.context.user_id, direction="to_task",
task_id=command.task_id, run_id=command.run_id,
target_session_id=run.get("agent_session_id") if run else None,
)
async def reply(self, message_id: UUID, command: AgentMessageReply) -> AgentMessage:
async with self.repo.transaction():
original = await self.get(message_id)
await self.repo.lock(f"run:{original.run_id}")
run = await self.repo.get_run(str(original.run_id))
if original.direction == "to_task":
self._require_service()
require(run is not None and run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Session does not own the message run", status=409)
direction, target = "to_chat", original.source_session_id
else:
require(original.conversation_owner_id == self.context.user_id,
"MESSAGE_NOT_FOUND", "Message not found", status=404)
require(command.source_session_id == original.target_session_id,
"SESSION_MISMATCH", "Only the original chat can reply", status=409)
direction = "to_task"
target = run.get("agent_session_id") if run else None
return await self._insert(
command=command, owner_id=self.context.user_id,
conversation_owner_id=original.conversation_owner_id, direction=direction,
task_id=original.task_id, run_id=original.run_id,
target_session_id=target, in_reply_to=message_id,
)
async def list_session(
self, session_id: str, limit: int, cursor: str | None, pending_only: bool = False,
) -> AgentMessagePage:
cursor_at, cursor_id = _message_cursor(cursor)
kwargs = {"cursor_at": cursor_at, "cursor_id": cursor_id, "pending_only": pending_only}
if self.context.system_role == "service":
self._require_service()
rows = await self.repo.list_run_session_messages(session_id, limit + 1, **kwargs)
if not rows:
rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs)
else:
rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs)
items = [AgentMessage.model_validate(row) for row in rows[:limit]]
next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None
return AgentMessagePage(items=items, next_cursor=next_cursor)
async def pending(self, limit: int, cursor: str | None = None) -> AgentMessagePage:
self._require_service()
cursor_at, cursor_id = _message_cursor(cursor)
rows = await self.repo.list_pending_messages(limit + 1, cursor_at=cursor_at, cursor_id=cursor_id)
items = [AgentMessage.model_validate(row) for row in rows[:limit]]
next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None
return AgentMessagePage(items=items, next_cursor=next_cursor)
async def delivery(self, message_id: UUID, command: AgentMessageDelivery) -> AgentMessage:
async with self.repo.transaction():
message = await self.get(message_id)
if self.context.system_role == "service":
self._require_service()
if self.context.system_role != "service":
require(message.direction == "to_chat",
"SERVICE_REQUIRED", "Only the receiver can acknowledge a chat reply", status=403)
await self.repo.lock(f"run:{message.run_id}")
await self.repo.lock(f"message:{message_id}")
message = await self.get(message_id)
if message.status in {"delivered", "failed"}:
require(message.status == command.status, "MESSAGE_TERMINAL",
"Message already has a terminal delivery result", status=409)
return message
if message.direction == "to_task" and command.status != "failed":
run = await self._active_run(message.task_id, message.run_id)
target = run.get("agent_session_id")
require(target is not None and command.target_session_id == target,
"SESSION_MISMATCH", "Delivery must target the owning run session", status=409)
else:
target = message.target_session_id
require(command.target_session_id in {None, target}, "SESSION_MISMATCH",
"Delivery cannot change the reply channel", status=409)
require(command.native_message_id in {None, message.native_message_id},
"MESSAGE_ID_MISMATCH", "Native message identity is immutable", status=409)
now = self.clock()
update = {
"status": command.status, "target_session_id": target,
"failure_detail": command.failure_detail, "updated_at": now,
"delivered_at": now if command.status == "delivered" else None,
}
await self.repo.update_message_delivery(str(message_id), update)
return message.model_copy(update=update)
+2
View File
@@ -63,6 +63,7 @@ def run_burst_location_by_network(
use_scada_flow: bool = False,
scheme_name: str | None = None,
simulation_run_id: UUID | None = None,
final_candidates_csv_path: str | None = "temp/burst_location/final_round_candidates.csv",
) -> dict[str, Any]:
if not network:
raise ValueError("network is required.")
@@ -312,6 +313,7 @@ def run_burst_location_by_network(
min_dpressure=min_dpressure,
basic_pressure=basic_pressure,
visualize_partition=False,
final_candidates_csv_path=final_candidates_csv_path,
)
payload: dict[str, Any] = {
+6 -3
View File
@@ -5,6 +5,9 @@ import httpx
from pydantic import AliasChoices, BaseModel, Field
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
install_tianditu_token_filter()
class TiandituGeocodeRequest(BaseModel):
@@ -32,12 +35,12 @@ async def geocode_tianditu(
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
if not settings.TIANDITU_GEOCODER_TOKEN:
raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured")
if not settings.TIANDITU_TOKEN:
raise TiandituGeocodingConfigError("TIANDITU_TOKEN is not configured")
params = {
"ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False),
"tk": settings.TIANDITU_GEOCODER_TOKEN,
"tk": settings.TIANDITU_TOKEN,
}
if client is not None:
+99
View File
@@ -0,0 +1,99 @@
"""Resolve browser overlays only from immutable Server-ingested snapshots."""
from __future__ import annotations
from math import isfinite
from app.domain.domain_errors import require
from app.domain.schemas.operational_data import TaskDataSnapshot
def _number(value) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)
def _position(value):
require(isinstance(value, list) and len(value) in {2, 3}
and all(_number(item) for item in value)
and -180 <= value[0] <= 180 and -90 <= value[1] <= 90,
"OVERLAY_GEOMETRY_INVALID", "Overlay coordinates must be finite WGS84 positions", status=422)
return value[:2]
def _geometry(value):
require(isinstance(value, dict), "OVERLAY_GEOMETRY_INVALID", "Overlay geometry is missing", status=422)
kind = value.get("type")
depths = {"Point": 0, "MultiPoint": 1, "LineString": 1,
"MultiLineString": 2, "Polygon": 2, "MultiPolygon": 3}
require(kind in depths, "OVERLAY_GEOMETRY_INVALID", "Unsupported overlay geometry", status=422)
positions = 0
def coordinates(items, depth):
nonlocal positions
if depth == 0:
positions += 1
require(positions <= 20000, "OVERLAY_TOO_LARGE", "Overlay geometry is too large", status=422)
return _position(items)
require(isinstance(items, list) and 0 < len(items) <= 20000,
"OVERLAY_GEOMETRY_INVALID", "Overlay coordinates are empty or too large", status=422)
return [coordinates(item, depth - 1) for item in items]
normalized = coordinates(value.get("coordinates"), depths[kind])
lines = ([normalized] if kind == "LineString" else normalized if kind == "MultiLineString" else [])
require(all(len(line) >= 2 for line in lines), "OVERLAY_GEOMETRY_INVALID",
"Overlay lines need at least two positions", status=422)
rings = (normalized if kind == "Polygon" else [ring for polygon in normalized for ring in polygon]
if kind == "MultiPolygon" else [])
require(all(len(ring) >= 4 and ring[0] == ring[-1] for ring in rings),
"OVERLAY_GEOMETRY_INVALID", "Overlay polygon rings must be closed", status=422)
return {"type": kind, "coordinates": normalized}
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
data = snapshot.data
candidates = [data]
if isinstance(data, dict):
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
latest = data.get("latest_analysis")
if isinstance(latest, dict):
candidates.append(latest.get("map_payload"))
collection = next((item for item in candidates
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
if collection is not None:
raw_features = collection.get("features")
require(isinstance(raw_features, list), "OVERLAY_GEOMETRY_INVALID",
"Snapshot FeatureCollection is invalid", status=422)
else:
rows = data.get("items") if isinstance(data, dict) else data
require(isinstance(rows, list), "OVERLAY_DATA_MISSING",
"Evidence snapshot has no recorded coordinates or GeoJSON", status=422)
raw_features = []
for row in rows:
if not isinstance(row, dict) or row.get("longitude") is None or row.get("latitude") is None:
continue
raw_features.append({
"type": "Feature", "geometry": {"type": "Point", "coordinates": [row["longitude"], row["latitude"]]},
"id": row.get("device_id") or row.get("point_id") or row.get("work_order_id") or row.get("id"),
"properties": {"weight": row.get("weight", 1)},
})
require(0 < len(raw_features) <= 1000, "OVERLAY_DATA_MISSING",
"Overlay needs between one and 1000 recorded features", status=422)
features = []
for feature in raw_features:
require(isinstance(feature, dict) and feature.get("type") == "Feature",
"OVERLAY_GEOMETRY_INVALID", "Snapshot contains an invalid feature", status=422)
geometry = _geometry(feature.get("geometry"))
require(mode != "heatmap" or geometry["type"] == "Point", "OVERLAY_HEATMAP_POINTS_REQUIRED",
"Heatmaps require recorded points, not inferred polygon centroids", status=422)
properties = feature.get("properties") or {}
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool):
normalized["id"] = feature["id"]
if mode == "heatmap":
weight = properties.get("weight", 1)
require(_number(weight) and weight >= 0, "OVERLAY_WEIGHT_INVALID",
"Heatmap weight must be a finite nonnegative number", status=422)
normalized["properties"]["weight"] = weight
features.append(normalized)
return {"type": "FeatureCollection", "features": features}
+210 -9
View File
@@ -1,6 +1,7 @@
"""State machines for durable operational tasks and approved external actions."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Iterable
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
@@ -46,6 +47,7 @@ from app.domain.schemas.operational_task import (
HumanEvidenceCommand,
InvestigationReportRevision,
InvestigationReportReference,
InvestigationProgressSnapshot,
IssueCommandPayload,
NotificationAcknowledgeCommand,
OperationalTaskCreate,
@@ -60,9 +62,13 @@ from app.domain.schemas.operational_task import (
OperationalTaskRun,
RecoverExpiredLeasesResult,
StoredEvidenceRecord,
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
)
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.services.content_digest import digest
from app.services.operational_presentation import resolve_overlay
LEASE_DURATION = timedelta(seconds=60)
@@ -73,7 +79,7 @@ def _json(model) -> dict:
def _task_json(model: OperationalTaskSummary) -> dict:
return model.model_dump(mode="json", by_alias=True, exclude={"active_run"})
return model.model_dump(mode="json", by_alias=True, exclude={"active_run", "latest_run"})
class OperationalTaskService:
@@ -223,6 +229,8 @@ class OperationalTaskService:
require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404)
plan = await self.repo.get_latest_plan(str(task_id))
report = await self.repo.get_latest_report(str(task_id))
if report is not None:
report = await self._report_with_progress_diagram(task_id, report)
evidence = [
self._public_evidence(document)
for document in await self.repo.list_evidence(str(task_id))
@@ -230,6 +238,9 @@ class OperationalTaskService:
actions = await self.repo.list_actions(str(task_id))
access_requests = await self._list_access_requests(task_id)
active_run = await self._active_run(task)
latest_run = (await self.repo.get_latest_run(str(task_id))
if hasattr(self.repo, "get_latest_run") else active_run)
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
work_order_snapshot = None
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
feedback = await self.repo.get_feedback(
@@ -238,10 +249,12 @@ class OperationalTaskService:
if feedback:
work_order_snapshot = feedback["document"].get("work_order_snapshot")
return OperationalTaskDetail(
**{key: value for key, value in task.items() if key != "active_run"},
**{key: value for key, value in task.items() if key not in {"active_run", "latest_run"}},
active_run=active_run,
latest_run=latest_run,
latest_plan=plan,
latest_report=report,
latest_progress=progress,
evidence=evidence,
actions=actions,
access_requests=access_requests,
@@ -249,18 +262,45 @@ class OperationalTaskService:
work_order_snapshot=work_order_snapshot,
)
async def _report_with_progress_diagram(
self, task_id: UUID, value: dict | InvestigationReportRevision
) -> InvestigationReportRevision:
"""Archive an eligible recorded graph, without inventing completion states.
Also projects older reports that predate graph archival. Read paths never
rewrite their stored document or borrow a newer round's progress.
"""
snapshot = InvestigationReportRevision.model_validate(value)
if (snapshot.report.diagram is not None or snapshot.report.stage != "final"
or snapshot.execution_generation is None):
return snapshot
stored = await self.repo.get_latest_progress(str(task_id), snapshot.execution_generation)
if stored is None:
return snapshot
progress = InvestigationProgressSnapshot.model_validate(stored)
diagram = progress.progress.diagram
if (diagram is None or progress.execution_generation != snapshot.execution_generation
or progress.source_session_id != snapshot.source_session_id
or progress.published_at > snapshot.published_at
or any(not set(node.evidence_refs) <= set(snapshot.report.evidence_refs)
for node in diagram.node_refs)):
return snapshot
return snapshot.model_copy(update={
"report": snapshot.report.model_copy(update={"diagram": diagram}),
})
async def report(self, task_id: UUID, revision: int) -> InvestigationReportRevision:
await self._task(task_id)
value = await self.repo.get_report(str(task_id), revision)
require(value is not None, "REPORT_NOT_FOUND", "Report revision not found", status=404)
return InvestigationReportRevision.model_validate(value)
return await self._report_with_progress_diagram(task_id, value)
async def list_reports(
self, task_id: UUID, limit: int, offset: int
) -> tuple[list[InvestigationReportRevision], int | None]:
await self._task(task_id)
rows = await self.repo.list_reports(str(task_id), limit + 1, offset)
return [InvestigationReportRevision.model_validate(row) for row in rows[:limit]], (
return [await self._report_with_progress_diagram(task_id, row) for row in rows[:limit]], (
offset + limit if len(rows) > limit else None
)
@@ -398,12 +438,15 @@ class OperationalTaskService:
async def list_runs(
self, *, status: str | None, limit: int,
cursor_created_at=None, cursor_run_id=None,
task_type: str | None = None, task_created_from: datetime | None = None,
) -> tuple[list[OperationalTaskRun], str | None]:
rows = await self.repo.list_runs(
status=status,
limit=limit + 1,
cursor_created_at=cursor_created_at,
cursor_run_id=cursor_run_id,
task_type=task_type,
task_created_from=task_created_from,
)
items = [OperationalTaskRun.model_validate(row) for row in rows[:limit]]
next_cursor = None
@@ -418,6 +461,7 @@ class OperationalTaskService:
body = _json(command)
scope = f"run:{run_id}:claim"
async with self.repo.transaction():
await self.repo.require_execution_storage()
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return OperationalTaskRun.model_validate(replay["run"])
@@ -507,6 +551,18 @@ class OperationalTaskService:
) -> OperationalTaskDetail:
return await self._finish_run(task_id, run_id, command, outcome="succeeded")
async def task_runs(self, task_id: UUID, limit: int, offset: int) -> list[OperationalTaskRun]:
await self._task(task_id)
return [OperationalTaskRun.model_validate(row)
for row in await self.repo.list_task_runs(str(task_id), limit, offset)]
async def task_run(self, task_id: UUID, run_id: UUID) -> OperationalTaskRun:
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
if hasattr(self.repo, "fail_run_messages"):
await self.repo.fail_run_messages(str(run_id), reason, now)
async def fail_run(
self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand
) -> OperationalTaskDetail:
@@ -539,6 +595,7 @@ class OperationalTaskService:
)
run_model = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(run_model), expected)
await self._fail_run_messages(run_id, "Run cancelled", now)
task = await self._task(task_id)
task.update(
lifecycle_status="cancelled", status="cancelled",
@@ -630,6 +687,7 @@ class OperationalTaskService:
"Agent 执行投递连续失败 10 次,请检查服务连接", now,
)
if not command.retryable:
await self._fail_run_messages(run_id, "Run dispatch failed", now)
task = await self._task(task_id)
task.update(
lifecycle_status="failed", status="failed",
@@ -642,11 +700,20 @@ class OperationalTaskService:
await self._save_request(scope, command.request_id, body, {"run": _json(run_model)})
return run_model
async def recover_expired_leases(self, limit: int) -> RecoverExpiredLeasesResult:
async def recover_expired_leases(
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
) -> RecoverExpiredLeasesResult:
now = self._now()
recovered: list[UUID] = []
async with self.repo.transaction():
for run in await self.repo.claim_expired_runs(limit):
for run in await self.repo.claim_expired_runs(
limit, task_type=task_type, task_created_from=task_created_from,
):
# Lease recovery already holds a row lock. Try rather than wait
# for the shared run lock to avoid reversing the lock order of
# message/finish transactions; a busy run is checked next tick.
if hasattr(self.repo, "try_lock") and not await self.repo.try_lock(f"run:{run['run_id']}"):
continue
task_id = UUID(str(run["task_id"]))
expected = run["revision"]
run.update(
@@ -656,6 +723,7 @@ class OperationalTaskService:
)
timed_out = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(timed_out), expected)
await self._fail_run_messages(run["run_id"], "Run lease expired", now)
await self._run_event(timed_out, now)
task = await self._task(task_id)
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
@@ -738,6 +806,29 @@ class OperationalTaskService:
item["action_id"]: HumanApprovalAction.model_validate(item)
for item in await self.repo.list_actions(str(task_id))
}
progress_revision = None
if command.progress is not None:
require(command.run_id is not None, "RUN_REQUIRED",
"Progress must identify its current execution run", status=422)
if command.progress.diagram:
for node in command.progress.diagram.node_refs:
self._require_evidence_refs(node.evidence_refs, known_evidence)
require(all(str(ref) in actions for ref in node.action_refs),
"ACTION_NOT_FOUND", "Progress references an unknown action", status=422)
previous = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
progress_revision = (previous["revision"] if previous else 0) + 1
snapshot = InvestigationProgressSnapshot(
revision=progress_revision, run_id=active_run["run_id"],
execution_generation=task["execution_generation"],
source_session_id=command.source_session_id, published_at=now, progress=command.progress,
)
await self.repo.save_progress(str(task_id), _json(snapshot))
await self._append_event(
topic="operational.progress.changed", aggregate_type="operational_task_progress",
aggregate_id=str(task_id), aggregate_revision=progress_revision, occurred_at=now,
metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"]),
"execution_generation": task["execution_generation"]},
)
if command.plan:
self._require_evidence_refs(
(ref for node in command.plan.nodes for ref in node.evidence_refs),
@@ -805,6 +896,7 @@ class OperationalTaskService:
source_session_id=command.source_session_id,
published_at=now, published_by=self._actor(), report=command.report,
)
report = await self._report_with_progress_diagram(task_id, report)
await self.repo.insert_report(str(task_id), _json(report))
await self._append_event(
topic="operational.report.published",
@@ -873,9 +965,76 @@ class OperationalTaskService:
)
await self._task_event(task, now)
response = {"plan_revision": plan_revision, "report_revision": report_revision}
if progress_revision is not None:
response["progress_revision"] = progress_revision
await self._save_request(scope, command.request_id, body, response)
return AgentArtifactsResult(task=await self.detail(task_id), **response)
async def presentation_cursor(self, task_id: UUID) -> TaskPresentationCursor:
# The shared event-order lock gives the run and cursor one committed boundary.
async with self.repo.transaction():
task = await self._task(task_id)
run = await self._active_run(task)
cursor = await self.repo.current_event_cursor()
return TaskPresentationCursor(
task_id=task_id, execution_generation=task["execution_generation"],
active_run=({key: run[key] for key in ("run_id", "generation", "status")} if run else None),
event_cursor=cursor,
)
async def publish_presentation_event(
self, task_id: UUID, command: TaskPresentationEventCommand
) -> TaskPresentationEventResult:
body = _json(command)
scope = f"task:{task_id}:presentation"
async with self.repo.transaction():
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return TaskPresentationEventResult.model_validate(replay)
await self.repo.lock(f"task:{task_id}")
await self.repo.lock(f"run:{command.run_id}")
task = await self._task(task_id)
self._require_actionable_task(task)
run = await self._active_run(task)
require(run is not None and str(run["run_id"]) == str(command.run_id)
and run["generation"] == task["execution_generation"],
"EXECUTION_GENERATION_MISMATCH", "Presentation targets a stale execution run", status=409)
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
"Only a running run can publish presentation", status=409)
require(run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Presentation must come from the claimed Agent session", status=409)
now = self._now()
self._require_active_run_lease(run, now)
payload = _json(command.command)
if command.command.action == "open_chart":
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.command.evidence_refs, known)
elif command.command.action == "set_overlay":
stored = await self.repo.get_evidence(str(task_id), command.command.evidence_id)
require(stored is not None, "EVIDENCE_NOT_FOUND", "Overlay evidence does not belong to this task", status=422)
evidence = StoredEvidenceRecord.model_validate(stored)
require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND",
"Overlay requires a Server-ingested immutable data snapshot", status=422)
payload["data"] = resolve_overlay(evidence.data_snapshot, command.command.mode)
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
"execution_generation": run["generation"], "command": payload}
try:
size = len(json.dumps(metadata, ensure_ascii=False, allow_nan=False).encode("utf-8"))
except (ValueError, TypeError) as exc:
raise DomainError("PRESENTATION_DATA_INVALID", "Presentation data must be finite JSON", status=422) from exc
require(size <= 512 * 1024, "PRESENTATION_TOO_LARGE",
"Presentation event exceeds the 512 KiB limit", status=422)
event_seq = await self._append_event(
topic="operational.presentation.requested", aggregate_type="operational_task_presentation",
aggregate_id=str(task_id), aggregate_revision=run["revision"], occurred_at=now,
metadata=metadata,
)
require(event_seq is not None, "DEPENDENCY_UNAVAILABLE", "Presentation event storage is unavailable", status=503)
result = TaskPresentationEventResult(event_seq=event_seq, run_id=run["run_id"],
execution_generation=run["generation"])
await self._save_request(scope, command.request_id, body, _json(result))
return result
async def propose_action(
self, task_id: UUID, command: ActionProposalCommand
) -> HumanApprovalAction:
@@ -1246,6 +1405,22 @@ class OperationalTaskService:
return ExternalFeedbackResult(task=await self.detail(task_id), **replay)
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
if task["task_type"] == "event_investigation":
self._require_actionable_task(task)
own_source = task["source"].get("external_id")
matching_source = own_source is not None and command.source_event_id in {
own_source, f"peripheral_sample:{own_source}",
}
if task["status"] == "completed" and not matching_source:
actions = await self.repo.list_actions(str(task_id))
require(any(
item["kind"] == "create_work_order" and item.get("receipt")
and item["receipt"].get("adapter") == "peripheral_sample"
and command.source_event_id == f"peripheral_sample:{item['receipt']['receipt_id']}"
for item in actions
), "EXTERNAL_SOURCE_MISMATCH", "Feedback does not match an associated child work order", status=409)
require(command.source_revision > 1, "INITIAL_CHILD_FEEDBACK",
"The child creation receipt is not new field feedback", status=409)
if task["task_type"] == "external_work_order":
require(
task["source"].get("external_id") == command.source_event_id,
@@ -1263,7 +1438,9 @@ class OperationalTaskService:
existing = await self.repo.get_feedback(str(task_id), command.source_event_id)
current_source_revision = (
task.get("source", {}).get("external_revision")
if task["task_type"] == "external_work_order"
if task["task_type"] == "external_work_order" or (
task["task_type"] == "event_investigation" and matching_source
)
else None
)
if current_source_revision is not None:
@@ -1276,7 +1453,8 @@ class OperationalTaskService:
if existing:
prior = existing["document"]
if command.source_revision == existing["source_revision"]:
require(digest(prior) == digest(body), "IDEMPOTENCY_CONFLICT",
require(digest({key: value for key, value in prior.items() if key != "request_id"})
== digest({key: value for key, value in body.items() if key != "request_id"}), "IDEMPOTENCY_CONFLICT",
"External feedback revision was reused with different content", status=409)
response = {
"source_event_id": command.source_event_id,
@@ -1287,6 +1465,10 @@ class OperationalTaskService:
return ExternalFeedbackResult(task=await self.detail(task_id), **response)
require(command.source_revision > existing["source_revision"],
"STALE_EXTERNAL_REVISION", "External feedback revision is stale", status=409)
if (task["task_type"] == "event_investigation" and matching_source
and current_source_revision is not None):
require(command.source_revision > current_source_revision, "STALE_EXTERNAL_REVISION",
"Original event feedback must contain a new source version", status=409)
now = self._now()
await self.repo.upsert_feedback(str(task_id), body, now)
if command.evidence:
@@ -1322,6 +1504,8 @@ class OperationalTaskService:
),
)
if task["task_type"] == "event_investigation":
if matching_source:
task["source"] = {**task["source"], "external_revision": command.source_revision}
await self._requeue_task(task, now)
else:
task.update(revision=task["revision"] + 1, updated_at=now)
@@ -1706,6 +1890,11 @@ class OperationalTaskService:
task = await self._task(task_id)
final_outcome = outcome
if outcome == "succeeded":
if hasattr(self.repo, "pending_run_messages"):
pending_messages = await self.repo.pending_run_messages(str(run_id))
require(not pending_messages, "PENDING_AGENT_MESSAGES",
"Run has messages not yet entered into Agent context",
status=409, recovery="retry_same_request", refs=pending_messages)
plan_document = await self.repo.get_latest_plan(str(task_id))
report_document = await self.repo.get_latest_report(str(task_id))
plan = (
@@ -1770,6 +1959,8 @@ class OperationalTaskService:
)
run_model = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(run_model), expected)
if final_outcome != "succeeded":
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
task.update(
lifecycle_status=lifecycle,
status=lifecycle,
@@ -1825,7 +2016,8 @@ class OperationalTaskService:
def _require_actionable_task(task: dict) -> None:
source = task.get("source") or {}
require(
task.get("lifecycle_status") != "cancelled" and source.get("deleted_at") is None,
task.get("lifecycle_status") != "cancelled" and task.get("status") != "cancelled"
and source.get("deleted_at") is None,
"TASK_NOT_ACTIONABLE",
"Cancelled or withdrawn tasks do not accept action changes",
status=409,
@@ -2003,6 +2195,14 @@ class OperationalTaskService:
async def _requeue_task(self, task: dict, now: datetime) -> None:
if self._supports_runs:
active_run = await self.repo.get_active_run(str(task["task_id"]))
while active_run is not None:
await self.repo.lock(f"run:{active_run['run_id']}")
# Completion may have won the run lock after our first read.
refreshed_run = await self.repo.get_active_run(str(task["task_id"]))
if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]):
active_run = refreshed_run
break
active_run = refreshed_run
if active_run is not None and active_run["status"] == "queued":
# Multiple inputs can arrive before the worker claims the next
# generation. They are all visible when that queued run starts,
@@ -2027,6 +2227,7 @@ class OperationalTaskService:
)
cancelled = OperationalTaskRun.model_validate(active_run)
await self.repo.update_run(_json(cancelled), expected)
await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now)
await self._run_event(cancelled, now)
generation = task["execution_generation"] + 1
task_id = UUID(task["task_id"])
+115
View File
@@ -0,0 +1,115 @@
import json
from typing import Any
import httpx
from pydantic import ValidationError
from app.core.config import settings
from app.core.tianditu_logging import install_tianditu_token_filter
from app.domain.schemas.place_search import (
MAX_PLACE_SEARCH_RESULTS,
PlaceSearchItem,
PlaceSearchRequest,
PlaceSearchResponse,
)
FENGXIAN_ADMIN_CODE = "156310120"
install_tianditu_token_filter()
class PlaceSearchError(RuntimeError):
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
async def search_tianditu_places(
request: PlaceSearchRequest,
*,
client: httpx.AsyncClient | None = None,
) -> PlaceSearchResponse:
token = settings.TIANDITU_TOKEN
if not token:
raise PlaceSearchError(503, "地名搜索服务未配置")
params = {
"postStr": json.dumps(
{
"keyWord": request.keyword,
"queryType": 12,
"specify": FENGXIAN_ADMIN_CODE,
"start": 0,
"count": MAX_PLACE_SEARCH_RESULTS,
"show": 2,
},
ensure_ascii=False,
),
"type": "query",
"tk": token,
}
try:
if client is not None:
response = await client.get(settings.TIANDITU_SEARCH_URL, params=params)
else:
async with httpx.AsyncClient(
timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS
) as managed_client:
response = await managed_client.get(
settings.TIANDITU_SEARCH_URL, params=params
)
response.raise_for_status()
except httpx.TimeoutException:
raise PlaceSearchError(504, "地名搜索超时,请稍后重试") from None
except httpx.HTTPError:
# Request URLs contain the provider key and must not reach the client.
raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None
try:
return _parse_response(response.json())
except (ValueError, TypeError, KeyError, AttributeError, ValidationError):
raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None
def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse:
code = str(payload.get("status", {}).get("infocode", ""))
if code == "3001":
return PlaceSearchResponse(items=[], total=0)
if code != "1000":
raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试")
result_type = str(payload.get("resultType", ""))
if result_type not in {"1", "3"}:
raise PlaceSearchError(502, "未获得可定位的地名结果,请细化关键字")
raw_items = payload.get("pois", []) if result_type == "1" else payload.get("area", [])
if not isinstance(raw_items, list) or len(raw_items) > MAX_PLACE_SEARCH_RESULTS:
raise ValueError("Invalid search results")
items = []
for raw in raw_items:
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")
lon, lat = str(raw["lonlat"]).split(",")
name = raw["name"]
if not isinstance(name, str) or not name.strip():
raise ValueError("Missing place name")
name = name.strip()
category = raw.get("typeName") or ("公交站" if str(raw.get("poiType")) == "102" else None)
items.append(PlaceSearchItem(
id=str(raw.get("hotPointID") or raw.get("adminCode") or f"{name}:{lon},{lat}"),
name=name,
address=str(raw.get("address") or name),
longitude=float(lon), latitude=float(lat),
category="行政区" if result_type == "3" else category,
district="奉贤区",
))
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,
)
+12
View File
@@ -82,8 +82,16 @@ def _create_validated_placement(
sensor_locations: list[str],
sensor_type: str = "pressure",
method: str | None = None,
persist_results: bool = True,
) -> dict[str, Any]:
validate_sensor_placement_nodes(project_code, sensor_locations)
if not persist_results:
return {
"sensor_locations": sensor_locations,
"sensor_points": _sensor_points(project_code, sensor_locations),
"method": method,
"sensor_type": sensor_type,
}
return sensor_placement_repository.create_sensor_placement(
project_code,
run_name=run_name,
@@ -102,6 +110,7 @@ def optimize_sensor_placement_by_sensitivity(
min_diameter: int,
created_by: str,
sensor_type: str = "pressure",
persist_results: bool = True,
) -> dict[str, Any]:
"""Run sensitivity placement and persist the validated result."""
@@ -119,6 +128,7 @@ def optimize_sensor_placement_by_sensitivity(
sensor_locations=sensor_locations,
sensor_type=sensor_type,
method="sensitivity",
persist_results=persist_results,
)
@@ -129,6 +139,7 @@ def optimize_sensor_placement_by_kmeans(
min_diameter: int,
created_by: str,
sensor_type: str = "pressure",
persist_results: bool = True,
) -> dict[str, Any]:
"""Export the model, run K-means placement, and persist the result."""
@@ -146,6 +157,7 @@ def optimize_sensor_placement_by_kmeans(
sensor_locations=sensor_locations,
sensor_type=sensor_type,
method="kmeans",
persist_results=persist_results,
)
+74 -2
View File
@@ -11,7 +11,7 @@ from app.native.wndb.model.tanks import get_tank, set_tank
from app.native.wndb.model.times import get_time, set_time
# from get_real_status import *
from datetime import datetime
from datetime import datetime, timedelta
from math import modf
import json
import pytz
@@ -150,6 +150,69 @@ def _apply_valve_control(
set_status(project_name, cs)
def run_online_simulation(
name: str,
start_time: datetime,
duration_seconds: int,
*,
output_node_ids: list[str] | None = None,
output_link_ids: list[str] | None = None,
) -> dict:
"""Recalculate online baseline snapshots without cloning a model database."""
if start_time.tzinfo is None or duration_seconds <= 0:
raise ValueError("Online simulation requires an aware start time and positive duration")
step = parse_clock_duration_seconds(
get_time(name)["HYDRAULIC TIMESTEP"], field_name="HYDRAULIC TIMESTEP"
)
if step <= 0:
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
mappings = query_corresponding_element_id_and_query_id(name)
selected = {
"node": {node: [] for node in output_node_ids or []},
"link": {link: [] for link in output_link_ids or []},
}
sample_times = []
units = {}
current = start_time
end = start_time + timedelta(seconds=duration_seconds)
while current < end:
output = run_simulation(
name=name,
simulation_type="realtime",
modify_pattern_start_time=current.isoformat(timespec="seconds"),
scada_mappings=mappings,
)
if not isinstance(output, dict):
raise RuntimeError("Online simulation did not return calculation results")
units = output.get("units", {})
sample_times.append(current.isoformat(timespec="seconds"))
for kind, elements in selected.items():
if not elements:
continue
rows = {
str(row[kind]): row.get("result", [])
for row in output.get(f"{kind}_results", []) if str(row[kind]) in elements
}
for element, values in elements.items():
samples = rows.get(element, [])
# Each realtime run is a single snapshot. Missing samples remain
# explicit so later values cannot shift to an earlier timestamp.
values.append(samples[0] if samples else None)
current += timedelta(seconds=step)
return {
"units": units,
"times": {
"report_start": 0, "report_step": step,
"sim_duration": duration_seconds, "num_periods": len(sample_times),
"sample_times": sample_times,
},
**{f"{kind}_results": [
{kind: element, "result": values}
for element, values in elements.items() if any(value is not None for value in values)
] for kind, elements in selected.items()},
}
# 2025/01/11
def run_simulation(
name: str,
@@ -170,7 +233,8 @@ def run_simulation(
scheme_username: str = "system",
scheme_detail: dict | None = None,
scada_mappings: ScadaElementMappings | None = None,
) -> UUID | None:
persist_results: bool = True,
) -> UUID | dict | None:
"""
传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果
:param name: 模型名称,数据库中对应的名字
@@ -189,6 +253,10 @@ def run_simulation(
:param scheme_name:模拟方案名称
:return:
"""
if not persist_results and (
not name.startswith("tjw_tmp_") or simulation_type.upper() != "EXTENDED"
):
raise ValueError("Non-persistent simulation requires an isolated temporary model")
# 记录开始时间
time_cost_start = time.perf_counter()
@@ -514,6 +582,8 @@ def run_simulation(
num_periods_result = times_info.get("num_periods")
if num_periods_result is None:
raise RuntimeError("run_project output missing times.num_periods")
if not persist_results:
return output_data
print("simulation_type", simulation_type)
print("before store result")
# print(num_periods_result)
@@ -583,4 +653,6 @@ def run_simulation(
# TimescaleInternalQueries.fill_scheme_simulation_result_to_SCADA(scheme_type=scheme_type, scheme_name=scheme_name)
print("after store result")
if simulation_type.upper() == "REALTIME":
return output_data
return run_id if simulation_type.upper() == "EXTENDED" else None
+20 -8
View File
@@ -222,7 +222,8 @@ def valve_close_analysis(
modify_valve_opening: dict[str, float] = None,
scheme_name: str = None,
_temporary_project: str | None = None,
) -> None:
persist_results: bool = True,
) -> dict | str | None:
"""
关阀模拟
:param name: 模型名称,数据库中对应的名字
@@ -271,7 +272,7 @@ def valve_close_analysis(
set_option(new_name, cs_options)
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -280,7 +281,9 @@ def valve_close_analysis(
scheme_type="valve_close_analysis",
scheme_name=scheme_name,
result_db_name=name,
persist_results=persist_results,
)
return result if not persist_results else None
# step 3. restore the base model
# for valve in valves:
# execute_undo(name)
@@ -303,7 +306,8 @@ def flushing_analysis(
username: str | None = None,
valve_control: dict[str, dict] = None,
_temporary_project: str | None = None,
) -> None:
persist_results: bool = True,
) -> dict | str | None:
"""
管道冲洗模拟
:param name: 模型名称,数据库中对应的名字
@@ -425,7 +429,7 @@ def flushing_analysis(
set_option(new_name, cs_options)
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -437,7 +441,9 @@ def flushing_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 4. restore the base model
# return result
@@ -457,7 +463,8 @@ def contaminant_simulation(
source_pattern: str = None, # 污染源时间变化模式名称
username: str | None = None,
_temporary_project: str | None = None,
) -> None:
persist_results: bool = True,
) -> dict | str | None:
"""
污染模拟
:param name: 模型名称,数据库中对应的名字
@@ -568,7 +575,7 @@ def contaminant_simulation(
# step 5. run simulation
# result = run_simulation_ex(new_name,'realtime', modify_pattern_start_time, modify_pattern_start_time, modify_total_duration,
# downloading_prohibition=True)
simulation.run_simulation(
result = simulation.run_simulation(
name=new_name,
simulation_type="extended",
modify_pattern_start_time=modify_pattern_start_time,
@@ -578,7 +585,9 @@ def contaminant_simulation(
result_db_name=name,
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
)
return result if not persist_results else None
# for i in range(1,operation_step):
# execute_undo(name)
@@ -600,7 +609,8 @@ def pressure_regulation(
scheme_name: str = None,
scada_mappings: simulation.ScadaElementMappings | None = None,
_temporary_project: str | None = None,
) -> None:
persist_results: bool = True,
) -> dict | str | None:
"""
区域调压模拟,用来模拟未来15分钟内,开关水泵对区域压力的影响
:param name: 模型名称,数据库中对应的名字
@@ -645,7 +655,7 @@ def pressure_regulation(
# pump_control=pump_control,
# tank_initial_level_control=tank_initial_level_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,
@@ -657,5 +667,7 @@ def pressure_regulation(
scheme_name=scheme_name,
result_db_name=name,
scada_mappings=scada_mappings,
persist_results=persist_results,
)
return result if not persist_results else None
# return result
+66 -1
View File
@@ -16,6 +16,7 @@ from app.domain.schemas.timeseries_history import (
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.postgresql import read_queries
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
@@ -56,13 +57,69 @@ def _points(
]
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
descriptors = []
series = []
def add(*, source, element_type, element_id, device_id=None, field, unit, unit_inferred=False):
descriptors.append({
"source": source, "element_type": element_type,
"id": device_id if source == "scada" else element_id, "field": field,
})
metric = _target_metric(element_type)
series.append(ElementHistorySeries(
element_id=element_id, element_type=element_type, device_id=device_id,
metric=metric, source=(
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
) if source == "scada" else (
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
),
source_unit=unit, display_unit=display_unit_for_metric(metric.value),
unit_inferred=unit_inferred, points=[],
))
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
for device_id, (device, element_type, element_id) in sorted(selected_devices.items()):
for field in ("monitored_value", "cleaned_value"):
add(source="scada", element_type=element_type, element_id=element_id,
device_id=device_id, field=field, unit=device["measurement_unit"])
if query.mode.value != "observed":
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
inferred = False
if source == "analysis":
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
if run is None:
raise ValueError(f"analysis run does not exist: {query.run_id}")
recorded = (run.get("parameters") or {}).get("result_units")
inferred = not isinstance(recorded, dict)
if not inferred:
units = recorded
targets = {(t.element_type, t.element_id) for t in query.elements}
for element_type, element_id in sorted(targets):
metric = _target_metric(element_type)
add(source=source, element_type=element_type, element_id=element_id,
field=metric.value, unit=str(units.get(metric.value, "")).strip(), unit_inferred=inferred)
rows = await read_queries.history_rows(
timescale_conn, descriptors, start=query.start_time, end=query.end_time,
run_id=query.run_id, limit=limit + 1, offset=offset,
)
for row in rows[:limit]:
series[row["series_index"]].points.append(HistoryPoint(time=row["time"], value=row["value"]))
return ElementHistoryResponse(series=[s for s in series if s.points], truncated=len(rows) > limit)
class TimeseriesHistoryService:
@staticmethod
async def query(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
query: ElementHistoryQuery,
*, row_limit: int | None = None, row_offset: int = 0,
) -> ElementHistoryResponse:
if row_offset < 0 or (row_offset and row_limit is None) or (row_limit is not None and row_limit < 1):
raise ValueError("row_offset requires a bounded page")
pipe_ids = list(
dict.fromkeys(
target.element_id
@@ -78,8 +135,10 @@ class TimeseriesHistoryService:
)
)
devices = await ScadaInfoRepository.get_scadas_for_elements(
postgres_conn, junction_ids, pipe_ids
postgres_conn, junction_ids, pipe_ids, **({"row_limit": 1001} if row_limit is not None else {})
)
if row_limit is not None and len(devices) > 1000:
raise ValueError("too many device mappings; narrow the element selection")
devices_by_element: dict[tuple[HistoryElementType, str], list[dict[str, Any]]] = (
defaultdict(list)
)
@@ -122,6 +181,12 @@ class TimeseriesHistoryService:
target.element_id,
)
if row_limit is not None and len(selected_devices) > 20:
raise ValueError("select at most 20 devices explicitly")
if row_limit is not None:
return await _query_page(
timescale_conn, postgres_conn, query, selected_devices, row_limit, row_offset,
)
scada_rows = await ScadaRepository.get_scada_by_ids_time_range(
timescale_conn,
list(selected_devices),
-93
View File
@@ -1,93 +0,0 @@
from typing import Any, Literal
import httpx
from pydantic import BaseModel, Field
from app.core.config import settings
Freshness = Literal["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"]
class WebSearchRequest(BaseModel):
query: str = Field(..., min_length=1, description="搜索关键词")
freshness: Freshness | str = Field(
default="noLimit",
description="时间范围:noLimit、oneDay、oneWeek、oneMonth、oneYear 或日期范围",
)
summary: bool = Field(default=True, description="是否返回网页摘要")
count: int = Field(default=10, ge=1, le=50, description="返回结果数量")
include: list[str] | None = Field(default=None, description="限定搜索域名")
exclude: list[str] | None = Field(default=None, description="排除搜索域名")
class BochaSearchConfigError(RuntimeError):
pass
class BochaSearchAPIError(RuntimeError):
def __init__(self, status_code: int, detail: Any):
super().__init__("Bocha Web Search API request failed")
self.status_code = status_code
self.detail = detail
def _build_payload(request: WebSearchRequest) -> dict[str, Any]:
payload = request.model_dump(exclude_none=True)
if request.include:
payload["include"] = ",".join(request.include)
if request.exclude:
payload["exclude"] = ",".join(request.exclude)
return payload
async def search_bocha_web(
request: WebSearchRequest,
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
if not settings.BOCHA_API_KEY:
raise BochaSearchConfigError("BOCHA_API_KEY is not configured")
headers = {
"Authorization": f"Bearer {settings.BOCHA_API_KEY}",
"Content-Type": "application/json",
}
payload = _build_payload(request)
if client is not None:
response = await client.post(
settings.BOCHA_WEB_SEARCH_URL,
headers=headers,
json=payload,
)
return _parse_response(response)
async with httpx.AsyncClient(
timeout=settings.BOCHA_WEB_SEARCH_TIMEOUT_SECONDS
) as managed_client:
response = await managed_client.post(
settings.BOCHA_WEB_SEARCH_URL,
headers=headers,
json=payload,
)
return _parse_response(response)
def _parse_response(response: httpx.Response) -> dict[str, Any]:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise BochaSearchAPIError(
exc.response.status_code,
_response_detail(exc.response),
) from exc
return response.json()
def _response_detail(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
+109
View File
@@ -371,3 +371,112 @@ def _timestamp(value: datetime | str) -> float:
else datetime.fromisoformat(value.replace("Z", "+00:00"))
)
return parsed.timestamp()
# Persisted runtime queries shared by REST and MCP.
from contextlib import asynccontextmanager
import logging
from pydantic import ValidationError
from app.auth.project_dependencies import ProjectContext
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.workbench_operations import WorkbenchOperationsRepository
from app.domain.schemas.workbench_runtime import WorkbenchRuntimeSnapshot
logger = logging.getLogger(__name__)
@asynccontextmanager
async def _projection_transaction(conn):
transaction = getattr(conn, "transaction", None)
if transaction is None:
yield
return
async with transaction():
yield
async def _sensor_location_map_payload(conn, results):
node_ids = sensor_location_ids_for_map_projection(results)
if not node_ids:
return None
points = await AnalysisRepository.list_junction_map_points(conn,node_ids)
return project_sensor_location_map_payload(node_ids,points)
def _validate_documents(documents, model, label):
valid=[];rejected=0
for document in documents:
try:valid.append(model.model_validate(document).model_dump())
except ValidationError:
rejected+=1
logger.warning("Workbench %s document was rejected",label)
return valid,rejected
async def read_workbench_runtime(ctx: ProjectContext, conn):
analysis_runs: list[dict] | None = None
latest_results: list[dict] | None = None
latest_analysis_map_payload: dict | None = None
analysis_availability = "not_configured"
alert_storage_configured = False
alerts: list[dict] | None = None
schedule_availability = "not_configured"
alert_availability = "not_configured"
try:
async with _projection_transaction(conn):
analysis_runs = await AnalysisRepository.list_recent_runs(conn, limit=100)
if analysis_runs:
latest_results = await AnalysisRepository.list_recent_results(
conn, analysis_runs[0]["run_id"], limit=100
)
analysis_availability = "ready" if analysis_runs else "not_configured"
except Exception:
logger.warning("Workbench analysis projection is unavailable", exc_info=True)
analysis_runs = None
latest_results = None
analysis_availability = "degraded"
if analysis_runs and latest_results:
try:
async with _projection_transaction(conn):
latest_analysis_map_payload = await _sensor_location_map_payload(
conn, latest_results
)
except Exception:
logger.warning(
"Workbench analysis spatial references are unavailable",
exc_info=True,
)
try:
async with _projection_transaction(conn):
operations_availability = await WorkbenchOperationsRepository.availability(conn)
alert_storage_configured = operations_availability.alerts
except Exception:
logger.warning("Workbench operations storage probe is unavailable", exc_info=True)
schedule_availability = "degraded"
alert_availability = "degraded"
if alert_storage_configured:
try:
async with _projection_transaction(conn):
alert_documents = await WorkbenchOperationsRepository.list_alerts(
conn, str(ctx.project_id)
)
alerts, invalid_alerts = _validate_documents(
alert_documents, WorkbenchPersistedAlert, "alert"
)
alert_availability = "degraded" if invalid_alerts else "ready"
except Exception:
alerts = None
alert_availability = "degraded"
logger.warning("Workbench alert projection is unavailable", exc_info=True)
return await build_workbench_runtime_snapshot(
analysis_runs=analysis_runs,
latest_analysis_results=latest_results,
latest_analysis_map_payload=latest_analysis_map_payload,
alert_storage_configured=alert_storage_configured,
persisted_alerts=alerts,
scenario_availability=analysis_availability,
artifact_availability=analysis_availability,
schedule_availability=schedule_availability,
alert_availability=alert_availability,
projection_degraded=False,
)