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:
@@ -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"},
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Actor-private messaging APIs; the Agent verifies its session ownership."""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from app.auth.permissions import OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, require_permission
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_pg_connection
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.agent_messages import (
|
||||
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
|
||||
)
|
||||
from app.infra.db.postgresql.agent_messages import AgentMessageRepository
|
||||
from app.services.agent_messages import AgentMessageService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/agent-messages")
|
||||
|
||||
|
||||
def _service(ctx: ProjectContext, conn: AsyncConnection) -> AgentMessageService:
|
||||
return AgentMessageService(AgentMessageRepository(conn, ctx.project_id), ctx)
|
||||
|
||||
|
||||
def _raise_domain(exc: DomainError):
|
||||
raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc
|
||||
|
||||
|
||||
@router.post("", response_model=AgentMessage, status_code=201)
|
||||
async def create_agent_message(
|
||||
command: AgentMessageCreate,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).create(command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("", response_model=AgentMessagePage)
|
||||
async def list_agent_messages(
|
||||
session_id: str = Query(min_length=1, max_length=500),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
pending_only: bool = False,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).list_session(session_id, limit, cursor, pending_only)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("/pending", response_model=AgentMessagePage)
|
||||
async def list_pending_agent_messages(
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).pending(limit, cursor)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.get("/{message_id}", response_model=AgentMessage)
|
||||
async def get_agent_message(
|
||||
message_id: UUID,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).get(message_id)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post("/{message_id}/reply", response_model=AgentMessage, status_code=201)
|
||||
async def reply_agent_message(
|
||||
message_id: UUID, command: AgentMessageReply,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).reply(message_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
|
||||
|
||||
@router.post("/{message_id}/delivery", response_model=AgentMessage)
|
||||
async def record_agent_message_delivery(
|
||||
message_id: UUID, command: AgentMessageDelivery,
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
try:
|
||||
return await _service(ctx, conn).delivery(message_id, command)
|
||||
except DomainError as exc:
|
||||
_raise_domain(exc)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Host-only MCP bindings: declared in REST contracts even when MCP is disabled."""
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, Response
|
||||
from psycopg.errors import UndefinedTable, InvalidSchemaName
|
||||
from app.mcp.models import BindInput, BindResponse
|
||||
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
|
||||
|
||||
router = APIRouter(tags=["MCP Query Context"])
|
||||
|
||||
|
||||
def runtime_for(request: Request):
|
||||
runtime = getattr(request.app.state, "mcp_runtime", None)
|
||||
if runtime is None:
|
||||
raise HTTPException(503, "Product MCP is disabled")
|
||||
return runtime
|
||||
|
||||
|
||||
@router.post("/mcp-query-contexts", response_model=BindResponse)
|
||||
async def create_context(
|
||||
command: BindInput,
|
||||
request: Request,
|
||||
x_project_id: str = Header(alias="X-Project-Id"),
|
||||
) -> BindResponse:
|
||||
runtime = runtime_for(request)
|
||||
principal = await runtime.identity(request)
|
||||
from app.mcp.contexts import bind_context
|
||||
|
||||
try:
|
||||
async with runtime.scope_factory(principal, x_project_id) as scope:
|
||||
binding = await bind_context(scope, principal, command, runtime.settings)
|
||||
except (UndefinedTable, InvalidSchemaName):
|
||||
raise HTTPException(
|
||||
503, "MCP project storage has not been provisioned"
|
||||
) from None
|
||||
return BindResponse(
|
||||
context_handle=binding.context_handle,
|
||||
expires_at=binding.expires_at,
|
||||
task_id=binding.task_id,
|
||||
run_id=binding.run_id,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/mcp-query-contexts/{context_handle}", status_code=204)
|
||||
async def delete_context(
|
||||
context_handle: str,
|
||||
request: Request,
|
||||
x_project_id: str = Header(alias="X-Project-Id"),
|
||||
) -> Response:
|
||||
runtime = runtime_for(request)
|
||||
principal = await runtime.identity(request)
|
||||
try:
|
||||
async with runtime.scope_factory(principal, x_project_id) as scope:
|
||||
await McpQueryRepository(scope.connection, scope.project.project_id).revoke(
|
||||
context_handle, principal.subject, principal.client_id
|
||||
)
|
||||
except (UndefinedTable, InvalidSchemaName):
|
||||
raise HTTPException(
|
||||
503, "MCP project storage has not been provisioned"
|
||||
) from None
|
||||
return Response(status_code=204)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Any, List, Literal, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||
@@ -18,7 +18,6 @@ from app.services.simulation_scenarios import (
|
||||
from app.services.valve_isolation import analyze_valve_isolation
|
||||
from app.domain.time import (
|
||||
parse_aware_time,
|
||||
parse_clock_duration_seconds,
|
||||
parse_utc_time,
|
||||
)
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
@@ -50,27 +49,9 @@ class PressureRegulation(BaseModel):
|
||||
def run_simulation_manually_by_date(
|
||||
network_name: str, start_time: datetime, duration: int
|
||||
) -> None:
|
||||
end_datetime = start_time + timedelta(minutes=duration)
|
||||
time_properties = simulation.get_time(network_name)
|
||||
hydraulic_step_seconds = parse_clock_duration_seconds(
|
||||
time_properties["HYDRAULIC TIMESTEP"],
|
||||
field_name="HYDRAULIC TIMESTEP",
|
||||
simulation.run_online_simulation(
|
||||
name=network_name, start_time=start_time, duration_seconds=duration * 60,
|
||||
)
|
||||
if hydraulic_step_seconds <= 0:
|
||||
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
|
||||
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
|
||||
scada_mappings = simulation.query_corresponding_element_id_and_query_id(
|
||||
network_name
|
||||
)
|
||||
current_time = start_time
|
||||
while current_time < end_datetime:
|
||||
simulation.run_simulation(
|
||||
name=network_name,
|
||||
simulation_type="realtime",
|
||||
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
|
||||
scada_mappings=scada_mappings,
|
||||
)
|
||||
current_time += hydraulic_step
|
||||
|
||||
|
||||
# 必须用这个PlainTextResponse,不然每个key都有引号
|
||||
|
||||
@@ -11,9 +11,10 @@ from app.services.timeseries_history import TimeseriesHistoryService
|
||||
from .dependencies import get_timescale_connection, get_postgres_connection
|
||||
|
||||
router = APIRouter()
|
||||
history_router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
@history_router.post(
|
||||
"/timeseries/views/element-history/query",
|
||||
summary="批量查询管网元素历史数据",
|
||||
response_model=ElementHistoryResponse,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
|
||||
class TiandituTokenFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
message = record.getMessage()
|
||||
if "tianditu.gov.cn" in message:
|
||||
record.msg = re.sub(r"([?&]tk=)[^&\s\"']+", r"\1[redacted]", message)
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def install_tianditu_token_filter() -> None:
|
||||
logger = logging.getLogger("httpx")
|
||||
if not any(isinstance(item, TiandituTokenFilter) for item in logger.filters):
|
||||
logger.addFilter(TiandituTokenFilter())
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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["奉贤区"] = "奉贤区"
|
||||
@@ -86,3 +86,4 @@ class ElementHistorySeries(BaseModel):
|
||||
|
||||
class ElementHistoryResponse(BaseModel):
|
||||
series: list[ElementHistorySeries]
|
||||
truncated: bool = Field(default=False, exclude=True)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",),
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Bounded read primitives over existing project views and time-series tables."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg import sql
|
||||
from psycopg.rows import dict_row
|
||||
from fastapi import HTTPException
|
||||
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
|
||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
||||
|
||||
ELEMENT_VIEWS = {
|
||||
v: v + "s" for v in ("junction", "pipe", "reservoir", "tank", "pump", "valve")
|
||||
}
|
||||
MAX_QUERY_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
async def read_rows(conn, statement, params=(), *, timeout_ms=30000):
|
||||
async with conn.transaction(), conn.cursor(row_factory=dict_row) as cur:
|
||||
await cur.execute(
|
||||
"SELECT set_config('statement_timeout',%s,true)", (str(timeout_ms),)
|
||||
)
|
||||
await cur.execute(statement, params)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def asset_rows(conn, element_type, *, element_id=None, limit=100, offset=0):
|
||||
view = ELEMENT_VIEWS[element_type]
|
||||
where = sql.SQL("WHERE v.id=%s") if element_id else sql.SQL("")
|
||||
params = (element_id,) if element_id else ()
|
||||
statement = sql.SQL(
|
||||
"SELECT to_jsonb(v)-'geom' AS record FROM gis.{} v {} ORDER BY v.id LIMIT %s OFFSET %s"
|
||||
).format(sql.Identifier(view), where)
|
||||
rows = await read_rows(conn, statement, params + (limit, offset))
|
||||
return [row["record"] for row in rows]
|
||||
|
||||
|
||||
async def scada_devices(conn, *, device_id=None, limit=100, offset=0):
|
||||
where = " WHERE id=%s" if device_id else ""
|
||||
params = (device_id,) if device_id else ()
|
||||
rows = await read_rows(
|
||||
conn,
|
||||
_SCADA_VIEW_SELECT + where + " ORDER BY device_id LIMIT %s OFFSET %s",
|
||||
params + (limit, offset),
|
||||
)
|
||||
return [_device(row) for row in rows]
|
||||
|
||||
|
||||
async def series_rows(
|
||||
conn,
|
||||
*,
|
||||
source,
|
||||
element_type,
|
||||
ids,
|
||||
start=None,
|
||||
end=None,
|
||||
time=None,
|
||||
field=None,
|
||||
run_id=None,
|
||||
limit=1000,
|
||||
offset=0,
|
||||
end_exclusive=False,
|
||||
):
|
||||
node = element_type == "junction"
|
||||
if source == "scada":
|
||||
table, id_column, fields = (
|
||||
"measurements",
|
||||
"device_id",
|
||||
{"monitored_value", "cleaned_value"},
|
||||
)
|
||||
else:
|
||||
table, id_column = (
|
||||
("node_results", "node_id") if node else ("link_results", "link_id")
|
||||
)
|
||||
fields = (
|
||||
(
|
||||
AnalysisResultsRepository.NODE_FIELDS
|
||||
if node
|
||||
else AnalysisResultsRepository.LINK_FIELDS
|
||||
)
|
||||
if source == "analysis"
|
||||
else (
|
||||
RealtimeRepository.NODE_FIELDS
|
||||
if node
|
||||
else RealtimeRepository.LINK_FIELDS
|
||||
)
|
||||
)
|
||||
if field is not None and field not in fields:
|
||||
raise ValueError("unsupported field for this data source and element type")
|
||||
columns = (
|
||||
sql.SQL("{},time,{} AS value").format(
|
||||
sql.Identifier(id_column), sql.Identifier(field)
|
||||
)
|
||||
if field
|
||||
else sql.SQL("*")
|
||||
)
|
||||
filters = []
|
||||
params = []
|
||||
if ids:
|
||||
filters.append(sql.SQL("{}=ANY(%s)").format(sql.Identifier(id_column)))
|
||||
params.append(ids)
|
||||
if run_id:
|
||||
filters.append(sql.SQL("run_id=%s"))
|
||||
params.append(run_id)
|
||||
if start is not None:
|
||||
filters.append(
|
||||
sql.SQL(
|
||||
"time >= %s AND time < %s"
|
||||
if end_exclusive
|
||||
else "time >= %s AND time <= %s"
|
||||
)
|
||||
)
|
||||
params.extend((start, end))
|
||||
else:
|
||||
# Existing point-query semantics use +/- one second.
|
||||
filters.append(
|
||||
sql.SQL("time BETWEEN %s-interval '1 second' AND %s+interval '1 second'")
|
||||
)
|
||||
params.extend((time, time))
|
||||
statement = sql.SQL(
|
||||
"SELECT {} FROM {}.{} WHERE {} ORDER BY time,{} LIMIT %s OFFSET %s"
|
||||
).format(
|
||||
columns,
|
||||
sql.Identifier(source),
|
||||
sql.Identifier(table),
|
||||
sql.SQL(" AND ").join(filters),
|
||||
sql.Identifier(id_column),
|
||||
)
|
||||
return await read_rows(conn, statement, tuple(params) + (limit, offset))
|
||||
|
||||
|
||||
async def sensor_runs(conn, *, run_id=None, limit=100, offset=0):
|
||||
where = " AND r.run_id=%s" if run_id else ""
|
||||
params = (run_id,) if run_id else ()
|
||||
return await read_rows(
|
||||
conn,
|
||||
"""SELECT r.run_id,r.name,r.created_by,r.created_at,r.status,
|
||||
result.payload FROM analysis.runs r JOIN LATERAL
|
||||
(SELECT payload FROM analysis.results WHERE run_id=r.run_id
|
||||
AND result_type='sensor_placement' ORDER BY created_at DESC,result_id DESC LIMIT 1) result ON true
|
||||
WHERE r.run_type='sensor_placement'"""
|
||||
+ where
|
||||
+ " ORDER BY r.created_at DESC,r.run_id LIMIT %s OFFSET %s",
|
||||
params + (limit, offset),
|
||||
)
|
||||
|
||||
|
||||
async def history_rows(
|
||||
conn: AsyncConnection, descriptors: list[dict[str, Any]], *,
|
||||
start: datetime, end: datetime, run_id: UUID | None, limit: int, offset: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Page points across raw, cleaned and model series in one SQL statement."""
|
||||
parts, params = [], []
|
||||
for index, item in enumerate(descriptors):
|
||||
source, field = item["source"], item["field"]
|
||||
if source == "scada":
|
||||
table, id_column, fields = "measurements", "device_id", {"monitored_value", "cleaned_value"}
|
||||
elif source in {"realtime", "analysis"}:
|
||||
node = item["element_type"] == "junction"
|
||||
table, id_column = ("node_results", "node_id") if node else ("link_results", "link_id")
|
||||
fields = {"pressure"} if node else {"flow"}
|
||||
else:
|
||||
raise ValueError("unsupported history source")
|
||||
if field not in fields or (source == "analysis" and run_id is None):
|
||||
raise ValueError("unsupported history field or missing run")
|
||||
parts.append(sql.SQL(
|
||||
"SELECT %s::integer AS series_index,time,{} AS value FROM {}.{} "
|
||||
"WHERE {}=%s AND time >= %s AND time <= %s{}"
|
||||
).format(
|
||||
sql.Identifier(field), sql.Identifier(source), sql.Identifier(table),
|
||||
sql.Identifier(id_column), sql.SQL(" AND run_id=%s" if source == "analysis" else ""),
|
||||
))
|
||||
params.extend((index, item["id"], start, end))
|
||||
if source == "analysis":
|
||||
params.append(run_id)
|
||||
if not parts:
|
||||
return []
|
||||
statement = sql.SQL(
|
||||
"SELECT series_index,time,value FROM ({}) points "
|
||||
"ORDER BY series_index, time LIMIT %s OFFSET %s"
|
||||
).format(sql.SQL(" UNION ALL ").join(parts))
|
||||
return await read_rows(conn, statement, tuple(params) + (limit, offset))
|
||||
|
||||
|
||||
async def evidence_page(
|
||||
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, evidence_id: str, data_path: list[str],
|
||||
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Project the immutable snapshot in PostgreSQL before transferring JSON."""
|
||||
return await _snapshot_page(conn, stored_sql="""
|
||||
SELECT document->'data_snapshot' AS snapshot
|
||||
FROM workbench.operational_task_evidence
|
||||
WHERE project_id=%s AND task_id=%s AND evidence_id=%s
|
||||
AND jsonb_typeof(document->'data_snapshot')='object'
|
||||
""", stored_params=(project_id, task_id, evidence_id), data_path=data_path,
|
||||
limit=limit, offset=offset, mcp_sources=mcp_sources, full_snapshot=full_snapshot)
|
||||
|
||||
|
||||
async def _snapshot_page(
|
||||
conn: AsyncConnection, *, stored_sql: str, stored_params: tuple, data_path: list[str],
|
||||
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
|
||||
max_bytes: int = MAX_QUERY_BYTES,
|
||||
) -> dict[str, Any]:
|
||||
"""Shared JSONB projection; callers supply only fixed, scoped SQL sources."""
|
||||
def descriptor(value, path):
|
||||
return f"""jsonb_build_object(
|
||||
'collection_type',jsonb_typeof({value}),
|
||||
'count',CASE jsonb_typeof({value}) WHEN 'array' THEN jsonb_array_length({value})
|
||||
WHEN 'string' THEN char_length({value} #>> '{{}}')
|
||||
ELSE (SELECT count(*) FROM jsonb_object_keys({value})) END,
|
||||
'data_path',to_jsonb({path}))"""
|
||||
|
||||
collection = descriptor("p.value", "p.path")
|
||||
child = descriptor("child.item", "p.path || ARRAY[child.key]")
|
||||
statement = """
|
||||
WITH stored AS MATERIALIZED (
|
||||
STORED_QUERY
|
||||
), captured AS (
|
||||
SELECT snapshot,
|
||||
snapshot->>'source'=ANY(%s::text[])
|
||||
AND jsonb_typeof(snapshot->'data')='object' AS is_mcp
|
||||
FROM stored
|
||||
), selected AS MATERIALIZED (
|
||||
SELECT snapshot,
|
||||
CASE WHEN is_mcp THEN (snapshot->'data')-'data' END AS query_metadata,
|
||||
(CASE WHEN is_mcp THEN snapshot->'data'->'data'
|
||||
ELSE snapshot->'data' END) #> %s::text[] AS value
|
||||
FROM captured
|
||||
), entries AS (
|
||||
SELECT ordinality AS position, NULL::text AS key, item AS value
|
||||
FROM selected, jsonb_array_elements(
|
||||
CASE WHEN jsonb_typeof(value)='array' THEN value ELSE '[]'::jsonb END
|
||||
) WITH ORDINALITY AS a(item, ordinality)
|
||||
UNION ALL
|
||||
SELECT row_number() OVER (ORDER BY e.key COLLATE "C"), e.key, e.item
|
||||
FROM selected, jsonb_each(
|
||||
CASE WHEN jsonb_typeof(value)='object' THEN value ELSE '{}'::jsonb END
|
||||
) AS e(key,item)
|
||||
), paged AS MATERIALIZED (
|
||||
SELECT * FROM entries ORDER BY position LIMIT %s OFFSET %s
|
||||
), paths AS (
|
||||
SELECT paged.*, jsonb_typeof(selected.value) AS parent_type,
|
||||
%s::text[] || ARRAY[CASE WHEN jsonb_typeof(selected.value)='array'
|
||||
THEN (position-1)::text ELSE key END] AS path
|
||||
FROM paged,selected
|
||||
), summaries AS (
|
||||
SELECT p.position,p.key,p.value AS original,
|
||||
CASE WHEN jsonb_typeof(p.value) IN ('array','object') OR
|
||||
(jsonb_typeof(p.value)='string' AND char_length(p.value #>> '{}')>256) THEN
|
||||
CASE WHEN p.parent_type='object' OR jsonb_typeof(p.value) IN ('array','string')
|
||||
THEN COLLECTION_PREVIEW
|
||||
ELSE COALESCE((SELECT jsonb_object_agg(child.key,
|
||||
CASE WHEN jsonb_typeof(child.item) IN ('array','object') OR
|
||||
(jsonb_typeof(child.item)='string' AND char_length(child.item #>> '{}')>256)
|
||||
THEN CHILD_PREVIEW ELSE child.item END)
|
||||
FROM jsonb_each(p.value) AS child(key,item)), '{}'::jsonb)
|
||||
END ELSE p.value END AS value
|
||||
FROM paths p
|
||||
), projected AS (
|
||||
SELECT CASE WHEN %s THEN snapshot ELSE jsonb_build_object(
|
||||
'snapshot_metadata',snapshot-'data', 'query_metadata',query_metadata,
|
||||
'nested',EXISTS(SELECT 1 FROM summaries WHERE original<>value),
|
||||
'value_type',jsonb_typeof(value),
|
||||
'total',CASE jsonb_typeof(value)
|
||||
WHEN 'array' THEN jsonb_array_length(value)
|
||||
WHEN 'object' THEN (SELECT count(*) FROM jsonb_object_keys(value))
|
||||
WHEN 'string' THEN char_length(value #>> '{}') END,
|
||||
'value',CASE jsonb_typeof(value)
|
||||
WHEN 'array' THEN COALESCE((SELECT jsonb_agg(value ORDER BY position) FROM summaries),'[]'::jsonb)
|
||||
WHEN 'object' THEN COALESCE((SELECT jsonb_object_agg(key,value) FROM summaries),'{}'::jsonb)
|
||||
WHEN 'string' THEN to_jsonb(substring(value #>> '{}' FROM %s FOR %s))
|
||||
ELSE value END
|
||||
) END AS payload, value IS NULL AS missing_path
|
||||
FROM selected
|
||||
), sized AS MATERIALIZED (
|
||||
SELECT payload,missing_path,octet_length(payload::text)>%s AS too_large
|
||||
FROM projected
|
||||
)
|
||||
SELECT CASE WHEN NOT too_large THEN payload END AS payload,too_large,missing_path
|
||||
FROM sized
|
||||
"""
|
||||
statement = statement.replace("STORED_QUERY", stored_sql).replace("COLLECTION_PREVIEW", collection).replace("CHILD_PREVIEW", child)
|
||||
rows = await read_rows(conn, statement, (
|
||||
*stored_params, mcp_sources, data_path, limit, offset,
|
||||
data_path, full_snapshot, offset + 1, limit, max_bytes,
|
||||
))
|
||||
if not rows:
|
||||
raise HTTPException(404, "Stored query document was not found")
|
||||
row = rows[0]
|
||||
if row["too_large"]:
|
||||
raise HTTPException(413, "Query exceeds the byte budget; select a deeper data_path or smaller page")
|
||||
if row.get("missing_path") and not full_snapshot:
|
||||
raise HTTPException(422, "Evidence data_path does not name a stored value")
|
||||
return row["payload"]
|
||||
|
||||
|
||||
async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, report_revision: int | None) -> dict:
|
||||
"""Read fixed-size metadata and explicit previews, never a complete report."""
|
||||
report = """jsonb_build_object('revision',r.revision,
|
||||
'execution_generation',r.document->'execution_generation',
|
||||
'stage',r.document#>'{report,stage}','outcome',r.document#>'{report,outcome}',
|
||||
'current_conclusion_preview',left(r.document#>>'{report,current_conclusion}',200),
|
||||
'preview_only',true,'detail',jsonb_build_object('section','report','report_revision',r.revision))"""
|
||||
rows = await read_rows(conn, """
|
||||
SELECT jsonb_build_object('task',jsonb_build_object(
|
||||
'task_id',t.task_id,'task_type',t.task_type,'workflow',t.document->>'workflow','status',t.status,
|
||||
'lifecycle_status',t.lifecycle_status,'priority',t.priority,'revision',t.revision,
|
||||
'execution_generation',t.execution_generation,
|
||||
'current_report_revision',t.current_report_revision,
|
||||
'title_preview',left(t.document->>'title',160),'preview_only',true,
|
||||
'detail',jsonb_build_object('section','task')),
|
||||
'latest_report',(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
|
||||
WHERE r.project_id=t.project_id AND r.task_id=t.task_id ORDER BY r.revision DESC LIMIT 1),
|
||||
'selected_report',CASE WHEN %s::int IS NULL THEN NULL ELSE
|
||||
(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
|
||||
WHERE r.project_id=t.project_id AND r.task_id=t.task_id AND r.revision=%s) END
|
||||
) AS payload
|
||||
FROM workbench.operational_tasks t WHERE t.project_id=%s AND t.task_id=%s
|
||||
""".replace("REPORT_SUMMARY", report), (report_revision, report_revision, project_id, task_id))
|
||||
if not rows:
|
||||
raise HTTPException(404, "Task was not found in this project")
|
||||
result = rows[0]["payload"]
|
||||
if report_revision is not None and result["selected_report"] is None:
|
||||
raise HTTPException(404, "Selected report revision was not found")
|
||||
return result
|
||||
|
||||
|
||||
async def task_context_page(
|
||||
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
|
||||
report_revision: int | None, data_path: list[str], limit: int, offset: int,
|
||||
) -> dict:
|
||||
"""Page only the requested task section inside PostgreSQL, before transfer."""
|
||||
collections = {
|
||||
"evidence": ("operational_task_evidence", "evidence_id"),
|
||||
"actions": ("operational_task_actions", "action_id"),
|
||||
"access_requests": ("operational_task_access_requests", "access_request_id"),
|
||||
}
|
||||
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
|
||||
if section in {"access_requests", "progress"}:
|
||||
table = "operational_task_access_requests" if section == "access_requests" else "operational_task_progress"
|
||||
available = await read_rows(conn, "SELECT to_regclass(%s) AS relation", ("workbench." + table,))
|
||||
if not available[0]["relation"]:
|
||||
return {"snapshot_metadata": {"section": section, "available": False}, "value": None, "value_type": "null", "total": None, "nested": False}
|
||||
projection_offset = offset
|
||||
if section in collections:
|
||||
table, key = collections[section]
|
||||
# These identifiers come exclusively from the fixed table above.
|
||||
relation = sql.Identifier("workbench", table).as_string()
|
||||
identifier = sql.Identifier(key).as_string()
|
||||
if data_path:
|
||||
stored = f"""SELECT jsonb_build_object('section',%s::text,'data',jsonb_build_object({identifier}::text,document)) AS snapshot
|
||||
FROM {relation} WHERE project_id=%s AND task_id=%s AND {identifier}::text=%s"""
|
||||
params = (section, project_id, task_id, data_path[0])
|
||||
else:
|
||||
# Fetch just this page of rows. Collection keys remain stable across
|
||||
# paging, so every nested data_path uses the real evidence/action ID.
|
||||
stored = f"""WITH items AS MATERIALIZED (
|
||||
SELECT {identifier}::text AS key,document FROM {relation}
|
||||
WHERE project_id=%s AND task_id=%s
|
||||
ORDER BY {identifier}::text COLLATE "C" LIMIT %s OFFSET %s)
|
||||
SELECT jsonb_build_object('section',%s::text,
|
||||
'total',(SELECT count(*) FROM {relation} WHERE project_id=%s AND task_id=%s),
|
||||
'data',COALESCE(jsonb_object_agg(key,document),'{{}}'::jsonb)) AS snapshot FROM items"""
|
||||
params = (project_id, task_id, limit + 1, offset, section, project_id, task_id)
|
||||
projection_offset = 0
|
||||
elif section in documents:
|
||||
relation = sql.Identifier("workbench", documents[section]).as_string()
|
||||
where = ""
|
||||
params = (section, project_id, task_id)
|
||||
if section == "report":
|
||||
where = " AND (%s::int IS NULL OR revision=%s) ORDER BY revision DESC LIMIT 1"
|
||||
params += (report_revision, report_revision)
|
||||
elif section == "plan":
|
||||
where = " ORDER BY revision DESC LIMIT 1"
|
||||
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
|
||||
elif section == "run":
|
||||
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id') AS snapshot
|
||||
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
|
||||
params = (project_id, task_id)
|
||||
elif section == "progress":
|
||||
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
|
||||
FROM workbench.operational_task_progress p
|
||||
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
|
||||
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation
|
||||
WHERE p.project_id=%s AND p.task_id=%s"""
|
||||
params = (project_id, task_id)
|
||||
else:
|
||||
raise HTTPException(422, "Unknown task context section")
|
||||
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
|
||||
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
|
||||
|
||||
|
||||
async def task_data_scopes(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, permissions: set[str]) -> list[str]:
|
||||
"""Bound scope discovery to seven known sources, without loading task detail."""
|
||||
known = ["workbench_runtime", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", "scada_devices", "scada_readings"]
|
||||
scopes = {name for name in known if ("scada.view" if name.startswith("scada_") else "simulation.view") in permissions}
|
||||
available = await read_rows(conn, "SELECT to_regclass('workbench.operational_task_access_requests') AS relation")
|
||||
if available[0]["relation"]:
|
||||
rows = await read_rows(conn, """SELECT DISTINCT scope FROM workbench.operational_task_access_requests,
|
||||
LATERAL jsonb_array_elements_text(COALESCE(document->'approved_scopes','[]'::jsonb)) AS granted(scope)
|
||||
WHERE project_id=%s AND task_id=%s AND status='approved' AND scope=ANY(%s::text[])""", (project_id, task_id, known))
|
||||
scopes.update(row["scope"] for row in rows)
|
||||
return [name for name in known if name in scopes]
|
||||
@@ -111,14 +111,16 @@ class ScadaInfoRepository:
|
||||
conn: AsyncConnection,
|
||||
node_ids: list[str],
|
||||
link_ids: list[str],
|
||||
*, row_limit: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not node_ids and not link_ids:
|
||||
return []
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
_SCADA_VIEW_SELECT
|
||||
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id",
|
||||
(node_ids, link_ids),
|
||||
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id"
|
||||
+ (" LIMIT %s" if row_limit is not None else ""),
|
||||
(node_ids, link_ids) + ((row_limit,) if row_limit is not None else ()),
|
||||
)
|
||||
return [_device(record) for record in await cur.fetchall()]
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Bounded calculation inputs; identity, routing and output paths are host-owned."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.mcp.models import Id, StrictInput
|
||||
|
||||
|
||||
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
|
||||
|
||||
|
||||
class CalculationInput(StrictInput):
|
||||
timeout_seconds: int = Field(
|
||||
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
|
||||
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
|
||||
)
|
||||
|
||||
|
||||
class BurstDetectionInput(CalculationInput):
|
||||
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
|
||||
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
|
||||
sampling_interval_minutes: int = Field(default=15, ge=1, le=60)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_target(self):
|
||||
if self.target_time.tzinfo is None:
|
||||
raise ValueError("target_time requires explicit timezone")
|
||||
if 1440 % self.sampling_interval_minutes:
|
||||
raise ValueError("sampling interval must divide a day")
|
||||
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
|
||||
raise ValueError("sensor_nodes must be distinct")
|
||||
return self
|
||||
|
||||
|
||||
class BurstLocationInput(CalculationInput):
|
||||
data_source: Literal["monitoring", "simulation"] = "monitoring"
|
||||
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
|
||||
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
|
||||
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
|
||||
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
|
||||
scada_burst_start: datetime
|
||||
scada_burst_end: datetime
|
||||
scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,不代表该时段已确认正常")
|
||||
scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式不填写,使用同时间窗模型基线")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_windows(self):
|
||||
pairs = [(self.scada_burst_start, self.scada_burst_end)]
|
||||
if self.data_source == "monitoring":
|
||||
if self.simulation_run_id is not None:
|
||||
raise ValueError("monitoring cannot use simulation_run_id")
|
||||
if self.scada_normal_start is not None or self.scada_normal_end is not None:
|
||||
pairs.append((self.scada_normal_start, self.scada_normal_end))
|
||||
elif self.simulation_run_id is None or self.scada_normal_start is not None or self.scada_normal_end is not None:
|
||||
raise ValueError("simulation requires run_id and uses the same-window model baseline")
|
||||
for start, end in pairs:
|
||||
if start is None or end is None or start.tzinfo is None or end.tzinfo is None:
|
||||
raise ValueError("explicit timezone-aware burst and normal windows are required")
|
||||
if not timedelta(0) < end - start <= timedelta(days=1):
|
||||
raise ValueError("each window must be positive and at most 24 hours")
|
||||
for ids in (self.pressure_scada_ids, self.flow_scada_ids):
|
||||
if ids is not None and len(set(ids)) != len(ids):
|
||||
raise ValueError("sensor IDs must be distinct")
|
||||
return self
|
||||
|
||||
|
||||
class ValveIsolationInput(CalculationInput):
|
||||
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
||||
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
|
||||
|
||||
|
||||
class LeakageIdentificationInput(CalculationInput):
|
||||
scada_start: datetime
|
||||
scada_end: datetime
|
||||
sensor_nodes: list[Id] = Field(min_length=1, max_length=20)
|
||||
q_sum: float = Field(gt=0, allow_inf_nan=False, description="有依据的总漏损流量约束,m³/s,不得使用无依据默认值")
|
||||
start_time: float = Field(ge=0, lt=24, allow_inf_nan=False, description="模型起算小时;需与SCADA时间窗及模型时钟对齐")
|
||||
timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应")
|
||||
dma_count: int = Field(ge=1, le=20)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_window(self):
|
||||
if self.scada_start.tzinfo is None or self.scada_end.tzinfo is None:
|
||||
raise ValueError("timestamps require explicit timezone")
|
||||
duration = self.scada_end - self.scada_start
|
||||
if not timedelta(0) < duration <= timedelta(days=1):
|
||||
raise ValueError("window must be positive and at most 24 hours")
|
||||
if duration.total_seconds() % (self.timestep * 60):
|
||||
raise ValueError("window must contain whole calculation steps")
|
||||
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
|
||||
raise ValueError("sensor_nodes must be distinct")
|
||||
return self
|
||||
|
||||
|
||||
class ScenarioInput(CalculationInput):
|
||||
start_time: datetime
|
||||
duration: int = Field(default=900, ge=60, le=86400, description="模拟时长,秒;首次优先短时间窗")
|
||||
output_node_ids: list[Id] = Field(min_length=1, max_length=20, description="需要返回结果的节点;不把局部结果解释为全网无影响")
|
||||
output_link_ids: list[Id] = Field(default_factory=list, max_length=20)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def timezone_required(self):
|
||||
if self.start_time.tzinfo is None:
|
||||
raise ValueError("start_time requires explicit timezone")
|
||||
return self
|
||||
|
||||
|
||||
class ContaminantSimulationInput(ScenarioInput):
|
||||
source: Id = Field(description="假设污染源节点;这是正向传播模拟,不是自动反演污染源")
|
||||
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
|
||||
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;不填为持续恒定源")
|
||||
|
||||
|
||||
class ValveClosureInput(ScenarioInput):
|
||||
valves: list[Id] = Field(min_length=1, max_length=20, description="在临时模型中关闭的阀门,不下发现场控制")
|
||||
|
||||
|
||||
class FlushingInput(ScenarioInput):
|
||||
drainage_node_id: Id
|
||||
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;不使用无依据的自动排放量")
|
||||
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
|
||||
PumpSeries = Annotated[list[NonnegativeValue], Field(min_length=1, max_length=96)]
|
||||
|
||||
|
||||
class PressureRegulationInput(ScenarioInput):
|
||||
pump_control: dict[Id, PumpSeries] = Field(min_length=1, max_length=20, description="已登记泵ID及运行序列;工频值为0/1,变频值为频率Hz,每项对应模型水力步长,不自动优化")
|
||||
tank_init_level: dict[Id, Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=20, description="水箱初始水位,须在模型允许范围内")
|
||||
|
||||
|
||||
class SensorPlacementInput(CalculationInput):
|
||||
method: Literal["sensitivity", "kmeans"]
|
||||
sensor_count: int = Field(ge=1, le=20)
|
||||
min_diameter: int = Field(default=0, ge=0, le=5000, description="最小管径,mm")
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
@@ -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
@@ -0,0 +1,169 @@
|
||||
"""Verify resource-bound Keycloak tokens without passing them to business APIs."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from jose import JWTError, jwt
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.mcp.config import McpSettings
|
||||
|
||||
CATALOG_SCOPE = "mcp.catalog.read"
|
||||
QUERY_SCOPE = "mcp.query"
|
||||
CONTEXT_SCOPE = "mcp.context.bind"
|
||||
|
||||
|
||||
class McpTokenVerifier:
|
||||
def __init__(self, settings: McpSettings, client: httpx.AsyncClient | None = None):
|
||||
self.settings = settings
|
||||
self.client = client
|
||||
self._keys: list[dict] = []
|
||||
self._expires = 0.0
|
||||
self._last_refresh = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _load_keys(self, kid: str) -> list[dict]:
|
||||
now = time.monotonic()
|
||||
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
|
||||
return self._keys
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
|
||||
return self._keys
|
||||
if self._expires > now and now - self._last_refresh < 20:
|
||||
return self._keys
|
||||
url = (
|
||||
self.settings.issuer_uri.rstrip("/") + "/protocol/openid-connect/certs"
|
||||
)
|
||||
self._last_refresh = now
|
||||
if self.client:
|
||||
response = await self.client.get(url)
|
||||
else:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=5, trust_env=False, follow_redirects=False
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
raise httpx.RequestError("invalid OAuth key set response") from None
|
||||
keys = payload.get("keys") if isinstance(payload, dict) else None
|
||||
if not isinstance(keys, list) or len(keys) > 100:
|
||||
raise httpx.RequestError("invalid OAuth key set")
|
||||
self._keys = [
|
||||
k
|
||||
for k in keys
|
||||
if isinstance(k, dict)
|
||||
and k.get("kty") == "RSA"
|
||||
and k.get("use", "sig") == "sig"
|
||||
and k.get("alg", "RS256") == "RS256"
|
||||
]
|
||||
self._expires = now + 300
|
||||
return self._keys
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
if not token or len(token) > 16384:
|
||||
return None
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
kid = header.get("kid")
|
||||
if (
|
||||
header.get("alg") != "RS256"
|
||||
or not isinstance(kid, str)
|
||||
or not 0 < len(kid) <= 128
|
||||
):
|
||||
return None
|
||||
keys = await self._load_keys(kid)
|
||||
key = next((k for k in keys if k.get("kid") == kid), None)
|
||||
if key is None:
|
||||
return None
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.settings.audience,
|
||||
issuer=self.settings.issuer_uri,
|
||||
options={"require_exp": True, "require_iat": True, "require_sub": True},
|
||||
)
|
||||
UUID(claims["sub"])
|
||||
client_id = claims.get("azp")
|
||||
if not isinstance(client_id, str) or not client_id:
|
||||
return None
|
||||
if (
|
||||
not isinstance(claims.get("iat"), (int, float))
|
||||
or not time.time() - 900 < claims["iat"] <= time.time() + 30
|
||||
or claims["exp"] > claims["iat"] + 900
|
||||
):
|
||||
return None
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
subject=claims["sub"],
|
||||
scopes=str(claims.get("scope", "")).split(),
|
||||
expires_at=int(claims["exp"]),
|
||||
resource=self.settings.resource_uri,
|
||||
claims=claims,
|
||||
)
|
||||
except (JWTError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
# JWKS/network failures propagate as unavailable, never as empty data.
|
||||
|
||||
|
||||
def challenge(
|
||||
settings: McpSettings, scope: str, *, insufficient: bool = False
|
||||
) -> dict[str, str]:
|
||||
from mcp.server.auth.routes import build_resource_metadata_url
|
||||
|
||||
metadata = build_resource_metadata_url(settings.resource_uri)
|
||||
suffix = ', error="insufficient_scope"' if insufficient else ""
|
||||
return {
|
||||
"WWW-Authenticate": f'Bearer resource_metadata="{metadata}", scope="{scope}"{suffix}'
|
||||
}
|
||||
|
||||
|
||||
class QueryScopeMiddleware:
|
||||
"""HTTP scope challenges before JSON-RPC dispatch; SDK validates mirrored headers."""
|
||||
|
||||
def __init__(self, app, verifier: McpTokenVerifier, settings: McpSettings):
|
||||
self.app, self.verifier, self.settings = app, verifier, settings
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http" or scope.get("path") != "/mcp":
|
||||
return await self.app(scope, receive, send)
|
||||
headers = {
|
||||
k.decode("latin1").lower(): v.decode("latin1")
|
||||
for k, v in scope.get("headers", [])
|
||||
}
|
||||
auth = headers.get("authorization", "")
|
||||
try:
|
||||
token = (
|
||||
await self.verifier.verify_token(auth[7:])
|
||||
if auth.lower().startswith("bearer ")
|
||||
else None
|
||||
)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return await JSONResponse(
|
||||
{"error": "authentication_unavailable"}, status_code=503
|
||||
)(scope, receive, send)
|
||||
required = (
|
||||
QUERY_SCOPE
|
||||
if headers.get("mcp-method") in {"tools/call", "resources/read"}
|
||||
else CATALOG_SCOPE
|
||||
)
|
||||
if token is None:
|
||||
return await JSONResponse(
|
||||
{"error": "invalid_token"},
|
||||
status_code=401,
|
||||
headers=challenge(self.settings, required),
|
||||
)(scope, receive, send)
|
||||
if required not in token.scopes:
|
||||
return await JSONResponse(
|
||||
{"error": "insufficient_scope"},
|
||||
status_code=403,
|
||||
headers=challenge(self.settings, required, insufficient=True),
|
||||
)(scope, receive, send)
|
||||
await self.app(scope, receive, send)
|
||||
@@ -0,0 +1,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}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""MCP is opt-in; an incomplete OAuth resource must never start listening."""
|
||||
|
||||
from pathlib import Path
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class McpSettings(BaseSettings):
|
||||
enabled: bool = False
|
||||
issuer_uri: str = ""
|
||||
dev_http_issuer_uri: str = ""
|
||||
environment: str = Field(default="production", validation_alias="ENVIRONMENT")
|
||||
resource_uri: str = ""
|
||||
audience: str = "tjwater-mcp"
|
||||
host_client_id: str = "tjwater-agent-mcp"
|
||||
allowed_hosts: list[str] = Field(
|
||||
default_factory=lambda: ["127.0.0.1:*", "localhost:*"]
|
||||
)
|
||||
allowed_origins: list[str] = Field(default_factory=list)
|
||||
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
|
||||
query_timeout_seconds: int = Field(default=30, ge=1, le=60)
|
||||
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="TJWATER_MCP_",
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
env_file=Path(__file__).resolve().parents[2] / ".env",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_resource(self):
|
||||
if self.dev_http_issuer_uri:
|
||||
dev_url = urlsplit(self.dev_http_issuer_uri)
|
||||
try:
|
||||
address = IPv4Address(dev_url.hostname or "")
|
||||
private_ipv4 = any(address in IPv4Network(network) for network in (
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"
|
||||
))
|
||||
except ValueError:
|
||||
private_ipv4 = False
|
||||
if (
|
||||
self.environment != "development"
|
||||
or dev_url.scheme != "http"
|
||||
or not private_ipv4
|
||||
or dev_url.username or dev_url.password or dev_url.query or dev_url.fragment
|
||||
or self.dev_http_issuer_uri != self.issuer_uri
|
||||
):
|
||||
raise ValueError("development HTTP issuer requires development environment and an exact private IPv4 issuer")
|
||||
if not self.enabled:
|
||||
return self
|
||||
for index, value in enumerate((self.issuer_uri, self.resource_uri)):
|
||||
url = urlsplit(value)
|
||||
if (
|
||||
url.scheme not in {"http", "https"}
|
||||
or not url.hostname
|
||||
or url.username
|
||||
or url.password
|
||||
or url.query
|
||||
or url.fragment
|
||||
):
|
||||
raise ValueError("MCP requires explicit issuer and resource URIs")
|
||||
if url.scheme == "http" and url.hostname not in {
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
}:
|
||||
if index != 0 or value != self.dev_http_issuer_uri:
|
||||
raise ValueError("non-loopback MCP and OAuth require HTTPS")
|
||||
if urlsplit(self.resource_uri).path != "/mcp":
|
||||
raise ValueError("MCP resource URI must identify the /mcp endpoint")
|
||||
if not self.audience.strip() or not self.host_client_id.strip():
|
||||
raise ValueError("MCP audience and host client ID are required")
|
||||
return self
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Delegate MCP transport/metadata with the original request path intact."""
|
||||
|
||||
|
||||
class McpDispatchMiddleware:
|
||||
def __init__(self, app, runtime):
|
||||
self.app, self.runtime = app, runtime
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
path = scope.get("path", "")
|
||||
if scope["type"] == "http" and (
|
||||
path == "/mcp" or path.startswith("/.well-known/oauth-protected-resource")
|
||||
):
|
||||
await self.runtime.asgi(scope, receive, send)
|
||||
else:
|
||||
await self.app(scope, receive, send)
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Read existing project-owned peripheral facts without forwarding MCP tokens."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from fastapi import HTTPException
|
||||
from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, scada_devices, series_rows
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
|
||||
from app.services.content_digest import digest
|
||||
from samples.peripheral.store import PostgresStore
|
||||
from app.infra.integrations.operational_data import _select_demo_observations
|
||||
|
||||
|
||||
async def read_peripheral_query(scope, args):
|
||||
conn = scope.connection
|
||||
project = str(scope.project.project_id)
|
||||
truncated = False
|
||||
origin = "system_data"
|
||||
if args.source in {"peripheral_work_orders", "peripheral_test_data"}:
|
||||
table, column = (
|
||||
("work_orders", "work_order_id")
|
||||
if args.source == "peripheral_work_orders"
|
||||
else ("test_data_records", "record_id")
|
||||
)
|
||||
rows = await read_rows(
|
||||
conn,
|
||||
"SELECT document FROM peripheral_sample."
|
||||
+ table
|
||||
+ " WHERE project_id=%s AND deleted_at IS NULL ORDER BY "
|
||||
+ column
|
||||
+ " LIMIT %s OFFSET %s",
|
||||
(project, args.limit + 1, args.offset),
|
||||
)
|
||||
items = [
|
||||
PostgresStore._active_managed_document(row["document"])
|
||||
for row in rows[: args.limit]
|
||||
]
|
||||
truncated = len(rows) > args.limit
|
||||
if any(item.get("project_id") != project for item in items):
|
||||
raise HTTPException(502, "Peripheral project mismatch")
|
||||
data = {
|
||||
"items": items,
|
||||
"truncated": truncated,
|
||||
"limit": args.limit,
|
||||
"offset": args.offset,
|
||||
}
|
||||
origin = (
|
||||
"synthetic"
|
||||
if items and any(item.get("origin") == "synthetic" for item in items)
|
||||
else "system_data"
|
||||
)
|
||||
elif args.source == "peripheral_points":
|
||||
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
|
||||
data = {
|
||||
"origin": "historical_replay",
|
||||
"items": [
|
||||
{
|
||||
"point_id": row["device_id"],
|
||||
"label": row["device_id"] + " · " + row["device_type"],
|
||||
"unit": row["measurement_unit"],
|
||||
"status": "unknown",
|
||||
"origin": "historical_replay",
|
||||
}
|
||||
for row in rows[: args.limit]
|
||||
],
|
||||
"truncated": len(rows) > args.limit,
|
||||
}
|
||||
truncated = data["truncated"]
|
||||
elif args.point_id.startswith("DEMO-"):
|
||||
rows = await read_rows(
|
||||
conn,
|
||||
"SELECT CASE WHEN octet_length(document::text)<=%s THEN document END AS document,"
|
||||
"digest,octet_length(document::text)>%s AS too_large "
|
||||
"FROM peripheral_sample.demo_datasets WHERE project_id=%s AND dataset_id='water-demo-v1'",
|
||||
(MAX_QUERY_BYTES, MAX_QUERY_BYTES, project),
|
||||
)
|
||||
if rows and rows[0]["too_large"]:
|
||||
raise HTTPException(413, "Synthetic dataset exceeds the integrity-check byte budget")
|
||||
if not rows or digest(rows[0]["document"]) != rows[0]["digest"]:
|
||||
raise HTTPException(503, "Synthetic dataset is unavailable or invalid")
|
||||
query = TaskDataQuery(
|
||||
source=args.source, point_id=args.point_id, start=args.start, end=args.end
|
||||
)
|
||||
data = _select_demo_observations(
|
||||
{**rows[0]["document"], "project_id": project}, query, project
|
||||
)
|
||||
items = data["items"]
|
||||
truncated = len(items) > args.limit + args.offset
|
||||
data["items"] = items[args.offset : args.offset + args.limit]
|
||||
data["truncated"] = truncated
|
||||
origin = "synthetic"
|
||||
else:
|
||||
devices = await scada_devices(conn, device_id=args.point_id, limit=1)
|
||||
if not devices:
|
||||
raise HTTPException(404, "Peripheral point was not found")
|
||||
device = devices[0]
|
||||
if not device.get("api_query_id"):
|
||||
raise HTTPException(503, "Peripheral history mapping is unavailable")
|
||||
async with scope.timescale() as ts:
|
||||
rows = await series_rows(
|
||||
ts,
|
||||
source="scada",
|
||||
element_type="junction",
|
||||
ids=[device["api_query_id"]],
|
||||
start=args.start,
|
||||
end=args.end,
|
||||
field="monitored_value",
|
||||
limit=args.limit + 1,
|
||||
offset=args.offset,
|
||||
end_exclusive=True,
|
||||
)
|
||||
truncated = len(rows) > args.limit
|
||||
data = {
|
||||
"origin": "historical_replay",
|
||||
"items": [
|
||||
{
|
||||
"point_id": args.point_id,
|
||||
"observed_at": row["time"].isoformat(),
|
||||
"value": row["value"],
|
||||
"unit": device["measurement_unit"],
|
||||
}
|
||||
for row in rows[: args.limit]
|
||||
],
|
||||
"truncated": truncated,
|
||||
}
|
||||
return TaskDataSnapshot(
|
||||
source=args.source,
|
||||
source_ref="mcp:" + str(uuid4()),
|
||||
queried_at=datetime.now(timezone.utc),
|
||||
data=data,
|
||||
origin=origin,
|
||||
truncated=truncated,
|
||||
)
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
@@ -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})},
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Resolve authoritative metadata and project routing for each MCP request."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, AsyncIterator
|
||||
from psycopg import AsyncConnection
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.infra.db.project_routing import ActiveProjectRouting
|
||||
from fastapi import HTTPException
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.auth.project_dependencies import (
|
||||
resolve_project_context,
|
||||
resolve_project_business_routing,
|
||||
resolve_project_simulation_routing,
|
||||
get_project_pg_connection,
|
||||
get_project_timescale_connection,
|
||||
)
|
||||
from app.infra.db.metadb.database import get_metadata_session
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.infra.db.project_routing import activate_project_routing
|
||||
|
||||
|
||||
class QueryMetadataRepository(MetadataRepository):
|
||||
async def get_project_db_routing(self, project_id, role):
|
||||
routing = await super().get_project_db_routing(project_id, role)
|
||||
# Existing connection generators obtain routing before awaiting a pool.
|
||||
# End that implicit SELECT transaction here, before the network wait.
|
||||
await self.session.commit()
|
||||
return routing
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryScope:
|
||||
project: ProjectContext
|
||||
user: Any
|
||||
connection: AsyncConnection
|
||||
routing: ActiveProjectRouting
|
||||
metadata: QueryMetadataRepository
|
||||
|
||||
@asynccontextmanager
|
||||
async def simulation_routing(self) -> AsyncIterator[ActiveProjectRouting]:
|
||||
"""Resolve both databases before a native calculation can modify its model."""
|
||||
routing = await resolve_project_simulation_routing(self.project, self.metadata)
|
||||
await self.metadata.session.commit()
|
||||
with activate_project_routing(routing):
|
||||
yield routing
|
||||
|
||||
@asynccontextmanager
|
||||
async def timescale(self):
|
||||
# Metadata repository here is used only to resolve a known project DB.
|
||||
async with asynccontextmanager(get_project_timescale_connection)(
|
||||
self.project, self.metadata
|
||||
) as conn:
|
||||
await self.metadata.session.commit()
|
||||
yield conn
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def project_scope(principal: AccessToken, project_id: str):
|
||||
if not principal.claims:
|
||||
raise HTTPException(401, "MCP identity is missing")
|
||||
async with asynccontextmanager(get_metadata_session)() as session:
|
||||
metadata = QueryMetadataRepository(session)
|
||||
user = await get_current_metadata_user(principal.claims, metadata)
|
||||
project = await resolve_project_context(project_id, user, metadata)
|
||||
routing = await resolve_project_business_routing(project, metadata)
|
||||
# End the metadata read before any external query/IO waits.
|
||||
await session.commit()
|
||||
async with asynccontextmanager(get_project_pg_connection)(
|
||||
project, metadata
|
||||
) as conn:
|
||||
await session.commit()
|
||||
with activate_project_routing(routing):
|
||||
yield QueryScope(project, user, conn, routing, metadata)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Server-owned business instructions, selected from the persisted task workflow."""
|
||||
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
||||
WORKFLOW_SKILLS = {
|
||||
"burst_report": "burst-report",
|
||||
"water_quality_complaint": "water-quality-complaint",
|
||||
"general": "appointment-follow-up",
|
||||
}
|
||||
|
||||
|
||||
def read_work_order_skill(workflow: str | None, *, include_content: bool = False) -> dict[str, Any]:
|
||||
"""Read only packaged, allowlisted instructions; never interpret a task path."""
|
||||
name = WORKFLOW_SKILLS.get(workflow)
|
||||
if name is None:
|
||||
return {"available": False, "workflow": workflow, "reason": "No business skill is configured for this workflow"}
|
||||
content = (SKILL_ROOT / name / "SKILL.md").read_text(encoding="utf-8")
|
||||
_, frontmatter, body = content.split("---", 2)
|
||||
header = yaml.safe_load(frontmatter)
|
||||
if header["name"] != name:
|
||||
raise ValueError("Packaged work-order skill name does not match its directory")
|
||||
result = {
|
||||
"available": True,
|
||||
"kind": "business_instructions",
|
||||
"workflow": workflow,
|
||||
"name": header["name"],
|
||||
"description": header["description"],
|
||||
"version": str(header["metadata"]["version"]),
|
||||
"content_sha256": sha256(content.encode("utf-8")).hexdigest(),
|
||||
"read": {"section": "skill"},
|
||||
}
|
||||
if include_content:
|
||||
result["instructions"] = body.strip()
|
||||
return result
|
||||
@@ -120,7 +120,7 @@ def dump_inp(project: str, inp: str, version: str = '3'):
|
||||
file.write('\n'.join(inp_out_demand(project)))
|
||||
|
||||
elif name == STATUS:
|
||||
file.write('\n'.join(inp_out_status(project)))
|
||||
file.write('\n'.join(inp_out_status(project, version=version)))
|
||||
|
||||
elif name == PATTERNS:
|
||||
if version == '3':
|
||||
@@ -232,7 +232,7 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
|
||||
inp += '\n'.join(inp_out_demand(project))
|
||||
|
||||
elif name == STATUS:
|
||||
inp += '\n'.join(inp_out_status(project))
|
||||
inp += '\n'.join(inp_out_status(project, version=version))
|
||||
|
||||
elif name == PATTERNS:
|
||||
if version == '3':
|
||||
|
||||
@@ -93,14 +93,16 @@ def inp_in_status(line: str) -> str:
|
||||
return str(f"insert into network.link_initial_settings (link_id, status, setting) values ({sql_literal(link)}, null, {sql_literal(float(value))});")
|
||||
|
||||
|
||||
def inp_out_status(name: str) -> list[str]:
|
||||
def inp_out_status(name: str, version: str = '2') -> list[str]:
|
||||
lines = []
|
||||
objs = read_all(name, 'select link_id as link, status, setting from network.link_initial_settings order by link_id')
|
||||
for obj in objs:
|
||||
link = obj['link']
|
||||
status = obj['status'] if obj['status'] != None else ''
|
||||
setting = obj['setting'] if obj['setting'] != None else ''
|
||||
if status != '':
|
||||
# EPANET 2 valves are active at their configured setting by default.
|
||||
# ACTIVE is an internal state, not a legal [STATUS] keyword in v2.
|
||||
if status != '' and not (version == '2' and status.upper() == LINK_STATUS_ACTIVE):
|
||||
lines.append(f'{link} {status}')
|
||||
if setting != '':
|
||||
lines.append(f'{link} {setting}')
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Messages never change task generation or silently target a replacement run."""
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, permissions_for_context
|
||||
from app.domain.domain_errors import DomainError, require
|
||||
from app.domain.schemas.agent_messages import (
|
||||
AgentMessage, AgentMessageCreate, AgentMessageDelivery, AgentMessagePage, AgentMessageReply,
|
||||
)
|
||||
|
||||
|
||||
def _message_cursor(cursor: str | None):
|
||||
if not cursor:
|
||||
return None, None
|
||||
try:
|
||||
stamp, identifier = cursor.rsplit("|", 1)
|
||||
cursor_at, cursor_id = datetime.fromisoformat(stamp), UUID(identifier)
|
||||
require(cursor_at.tzinfo is not None, "INVALID_CURSOR", "Cursor needs a timezone", status=400)
|
||||
return cursor_at, cursor_id
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise DomainError("INVALID_CURSOR", "Invalid message cursor", status=400) from exc
|
||||
|
||||
|
||||
class AgentMessageService:
|
||||
def __init__(self, repository, context, *, clock=None):
|
||||
self.repo = repository
|
||||
self.context = context
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
|
||||
def _require_service(self) -> None:
|
||||
require(self.context.system_role == "service" and
|
||||
OPERATIONAL_TASK_EXECUTE in permissions_for_context(self.context), "SERVICE_REQUIRED",
|
||||
"Message dispatch requires a project service account", status=403)
|
||||
|
||||
async def get(self, message_id: UUID) -> AgentMessage:
|
||||
if self.context.system_role == "service":
|
||||
self._require_service()
|
||||
row = await self.repo.get_message(str(message_id))
|
||||
require(row is not None, "MESSAGE_NOT_FOUND", "Message not found", status=404)
|
||||
require(self.context.system_role == "service" or
|
||||
str(row["conversation_owner_id"]) == str(self.context.user_id),
|
||||
"MESSAGE_NOT_FOUND", "Message not found", status=404)
|
||||
return AgentMessage.model_validate(row)
|
||||
|
||||
async def _active_run(self, task_id: UUID, run_id: UUID) -> dict:
|
||||
run = await self.repo.get_run(str(run_id))
|
||||
require(run is not None and str(run["task_id"]) == str(task_id),
|
||||
"RUN_NOT_FOUND", "Run does not belong to the task", status=404)
|
||||
task = await self.repo.get_task(str(task_id))
|
||||
require(task is not None and task["task_type"] == "event_investigation",
|
||||
"INVALID_TASK_TYPE", "Messages require an investigation task", status=409)
|
||||
require(run["status"] in {"queued", "claimed", "running"},
|
||||
"RUN_TERMINAL", "Run no longer accepts messages", status=409)
|
||||
if run["status"] != "queued":
|
||||
expires = run.get("lease_expires_at")
|
||||
if isinstance(expires, str):
|
||||
expires = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
||||
require(expires is not None and expires > self.clock(),
|
||||
"LEASE_EXPIRED", "Run lease expired", status=409)
|
||||
return run
|
||||
|
||||
async def _insert(self, *, command, owner_id, conversation_owner_id, direction, task_id, run_id,
|
||||
target_session_id, in_reply_to=None) -> AgentMessage:
|
||||
prior = await self.repo.get_message_request(
|
||||
str(owner_id), command.source_session_id, str(command.request_id),
|
||||
)
|
||||
expected = {
|
||||
"task_id": str(task_id), "run_id": str(run_id),
|
||||
"direction": direction, "in_reply_to": str(in_reply_to) if in_reply_to else None,
|
||||
"text": command.text,
|
||||
}
|
||||
if prior is not None:
|
||||
require(all((str(prior[key]) if prior[key] is not None else None) == value
|
||||
for key, value in expected.items()),
|
||||
"IDEMPOTENCY_CONFLICT", "request_id was used with different content", status=409)
|
||||
return AgentMessage.model_validate(prior)
|
||||
# The run lock serializes message creation with completion/cancellation.
|
||||
await self._active_run(task_id, run_id)
|
||||
message_id = uuid4()
|
||||
now = self.clock()
|
||||
message = AgentMessage(
|
||||
project_id=self.context.project_id,
|
||||
message_id=message_id, request_id=command.request_id,
|
||||
task_id=task_id, run_id=run_id, source_session_id=command.source_session_id,
|
||||
source_owner_id=owner_id, conversation_owner_id=conversation_owner_id,
|
||||
target_session_id=target_session_id,
|
||||
direction=direction, in_reply_to=in_reply_to, text=command.text,
|
||||
status="pending", native_message_id=f"msg_{message_id}",
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
await self.repo.insert_message(message.model_dump(mode="json"))
|
||||
return message
|
||||
|
||||
async def create(self, command: AgentMessageCreate) -> AgentMessage:
|
||||
async with self.repo.transaction():
|
||||
await self.repo.lock(f"run:{command.run_id}")
|
||||
run = await self.repo.get_run(str(command.run_id))
|
||||
return await self._insert(
|
||||
command=command, owner_id=self.context.user_id,
|
||||
conversation_owner_id=self.context.user_id, direction="to_task",
|
||||
task_id=command.task_id, run_id=command.run_id,
|
||||
target_session_id=run.get("agent_session_id") if run else None,
|
||||
)
|
||||
|
||||
async def reply(self, message_id: UUID, command: AgentMessageReply) -> AgentMessage:
|
||||
async with self.repo.transaction():
|
||||
original = await self.get(message_id)
|
||||
await self.repo.lock(f"run:{original.run_id}")
|
||||
run = await self.repo.get_run(str(original.run_id))
|
||||
if original.direction == "to_task":
|
||||
self._require_service()
|
||||
require(run is not None and run.get("agent_session_id") == command.source_session_id,
|
||||
"SESSION_MISMATCH", "Session does not own the message run", status=409)
|
||||
direction, target = "to_chat", original.source_session_id
|
||||
else:
|
||||
require(original.conversation_owner_id == self.context.user_id,
|
||||
"MESSAGE_NOT_FOUND", "Message not found", status=404)
|
||||
require(command.source_session_id == original.target_session_id,
|
||||
"SESSION_MISMATCH", "Only the original chat can reply", status=409)
|
||||
direction = "to_task"
|
||||
target = run.get("agent_session_id") if run else None
|
||||
return await self._insert(
|
||||
command=command, owner_id=self.context.user_id,
|
||||
conversation_owner_id=original.conversation_owner_id, direction=direction,
|
||||
task_id=original.task_id, run_id=original.run_id,
|
||||
target_session_id=target, in_reply_to=message_id,
|
||||
)
|
||||
|
||||
async def list_session(
|
||||
self, session_id: str, limit: int, cursor: str | None, pending_only: bool = False,
|
||||
) -> AgentMessagePage:
|
||||
cursor_at, cursor_id = _message_cursor(cursor)
|
||||
kwargs = {"cursor_at": cursor_at, "cursor_id": cursor_id, "pending_only": pending_only}
|
||||
if self.context.system_role == "service":
|
||||
self._require_service()
|
||||
rows = await self.repo.list_run_session_messages(session_id, limit + 1, **kwargs)
|
||||
if not rows:
|
||||
rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs)
|
||||
else:
|
||||
rows = await self.repo.list_session_messages(str(self.context.user_id), session_id, limit + 1, **kwargs)
|
||||
items = [AgentMessage.model_validate(row) for row in rows[:limit]]
|
||||
next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None
|
||||
return AgentMessagePage(items=items, next_cursor=next_cursor)
|
||||
|
||||
async def pending(self, limit: int, cursor: str | None = None) -> AgentMessagePage:
|
||||
self._require_service()
|
||||
cursor_at, cursor_id = _message_cursor(cursor)
|
||||
rows = await self.repo.list_pending_messages(limit + 1, cursor_at=cursor_at, cursor_id=cursor_id)
|
||||
items = [AgentMessage.model_validate(row) for row in rows[:limit]]
|
||||
next_cursor = f"{items[-1].created_at.isoformat()}|{items[-1].message_id}" if len(rows) > limit else None
|
||||
return AgentMessagePage(items=items, next_cursor=next_cursor)
|
||||
|
||||
async def delivery(self, message_id: UUID, command: AgentMessageDelivery) -> AgentMessage:
|
||||
async with self.repo.transaction():
|
||||
message = await self.get(message_id)
|
||||
if self.context.system_role == "service":
|
||||
self._require_service()
|
||||
if self.context.system_role != "service":
|
||||
require(message.direction == "to_chat",
|
||||
"SERVICE_REQUIRED", "Only the receiver can acknowledge a chat reply", status=403)
|
||||
await self.repo.lock(f"run:{message.run_id}")
|
||||
await self.repo.lock(f"message:{message_id}")
|
||||
message = await self.get(message_id)
|
||||
if message.status in {"delivered", "failed"}:
|
||||
require(message.status == command.status, "MESSAGE_TERMINAL",
|
||||
"Message already has a terminal delivery result", status=409)
|
||||
return message
|
||||
if message.direction == "to_task" and command.status != "failed":
|
||||
run = await self._active_run(message.task_id, message.run_id)
|
||||
target = run.get("agent_session_id")
|
||||
require(target is not None and command.target_session_id == target,
|
||||
"SESSION_MISMATCH", "Delivery must target the owning run session", status=409)
|
||||
else:
|
||||
target = message.target_session_id
|
||||
require(command.target_session_id in {None, target}, "SESSION_MISMATCH",
|
||||
"Delivery cannot change the reply channel", status=409)
|
||||
require(command.native_message_id in {None, message.native_message_id},
|
||||
"MESSAGE_ID_MISMATCH", "Native message identity is immutable", status=409)
|
||||
now = self.clock()
|
||||
update = {
|
||||
"status": command.status, "target_session_id": target,
|
||||
"failure_detail": command.failure_detail, "updated_at": now,
|
||||
"delivered_at": now if command.status == "delivered" else None,
|
||||
}
|
||||
await self.repo.update_message_delivery(str(message_id), update)
|
||||
return message.model_copy(update=update)
|
||||
@@ -63,6 +63,7 @@ def run_burst_location_by_network(
|
||||
use_scada_flow: bool = False,
|
||||
scheme_name: str | None = None,
|
||||
simulation_run_id: UUID | None = None,
|
||||
final_candidates_csv_path: str | None = "temp/burst_location/final_round_candidates.csv",
|
||||
) -> dict[str, Any]:
|
||||
if not network:
|
||||
raise ValueError("network is required.")
|
||||
@@ -312,6 +313,7 @@ def run_burst_location_by_network(
|
||||
min_dpressure=min_dpressure,
|
||||
basic_pressure=basic_pressure,
|
||||
visualize_partition=False,
|
||||
final_candidates_csv_path=final_candidates_csv_path,
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
|
||||
@@ -5,6 +5,9 @@ import httpx
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.tianditu_logging import install_tianditu_token_filter
|
||||
|
||||
install_tianditu_token_filter()
|
||||
|
||||
|
||||
class TiandituGeocodeRequest(BaseModel):
|
||||
@@ -32,12 +35,12 @@ async def geocode_tianditu(
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not settings.TIANDITU_GEOCODER_TOKEN:
|
||||
raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured")
|
||||
if not settings.TIANDITU_TOKEN:
|
||||
raise TiandituGeocodingConfigError("TIANDITU_TOKEN is not configured")
|
||||
|
||||
params = {
|
||||
"ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False),
|
||||
"tk": settings.TIANDITU_GEOCODER_TOKEN,
|
||||
"tk": settings.TIANDITU_TOKEN,
|
||||
}
|
||||
|
||||
if client is not None:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Resolve browser overlays only from immutable Server-ingested snapshots."""
|
||||
from __future__ import annotations
|
||||
|
||||
from math import isfinite
|
||||
|
||||
from app.domain.domain_errors import require
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
|
||||
|
||||
def _number(value) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)
|
||||
|
||||
|
||||
def _position(value):
|
||||
require(isinstance(value, list) and len(value) in {2, 3}
|
||||
and all(_number(item) for item in value)
|
||||
and -180 <= value[0] <= 180 and -90 <= value[1] <= 90,
|
||||
"OVERLAY_GEOMETRY_INVALID", "Overlay coordinates must be finite WGS84 positions", status=422)
|
||||
return value[:2]
|
||||
|
||||
|
||||
def _geometry(value):
|
||||
require(isinstance(value, dict), "OVERLAY_GEOMETRY_INVALID", "Overlay geometry is missing", status=422)
|
||||
kind = value.get("type")
|
||||
depths = {"Point": 0, "MultiPoint": 1, "LineString": 1,
|
||||
"MultiLineString": 2, "Polygon": 2, "MultiPolygon": 3}
|
||||
require(kind in depths, "OVERLAY_GEOMETRY_INVALID", "Unsupported overlay geometry", status=422)
|
||||
positions = 0
|
||||
|
||||
def coordinates(items, depth):
|
||||
nonlocal positions
|
||||
if depth == 0:
|
||||
positions += 1
|
||||
require(positions <= 20000, "OVERLAY_TOO_LARGE", "Overlay geometry is too large", status=422)
|
||||
return _position(items)
|
||||
require(isinstance(items, list) and 0 < len(items) <= 20000,
|
||||
"OVERLAY_GEOMETRY_INVALID", "Overlay coordinates are empty or too large", status=422)
|
||||
return [coordinates(item, depth - 1) for item in items]
|
||||
|
||||
normalized = coordinates(value.get("coordinates"), depths[kind])
|
||||
lines = ([normalized] if kind == "LineString" else normalized if kind == "MultiLineString" else [])
|
||||
require(all(len(line) >= 2 for line in lines), "OVERLAY_GEOMETRY_INVALID",
|
||||
"Overlay lines need at least two positions", status=422)
|
||||
rings = (normalized if kind == "Polygon" else [ring for polygon in normalized for ring in polygon]
|
||||
if kind == "MultiPolygon" else [])
|
||||
require(all(len(ring) >= 4 and ring[0] == ring[-1] for ring in rings),
|
||||
"OVERLAY_GEOMETRY_INVALID", "Overlay polygon rings must be closed", status=422)
|
||||
return {"type": kind, "coordinates": normalized}
|
||||
|
||||
|
||||
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
||||
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
|
||||
data = snapshot.data
|
||||
candidates = [data]
|
||||
if isinstance(data, dict):
|
||||
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
|
||||
latest = data.get("latest_analysis")
|
||||
if isinstance(latest, dict):
|
||||
candidates.append(latest.get("map_payload"))
|
||||
collection = next((item for item in candidates
|
||||
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
|
||||
if collection is not None:
|
||||
raw_features = collection.get("features")
|
||||
require(isinstance(raw_features, list), "OVERLAY_GEOMETRY_INVALID",
|
||||
"Snapshot FeatureCollection is invalid", status=422)
|
||||
else:
|
||||
rows = data.get("items") if isinstance(data, dict) else data
|
||||
require(isinstance(rows, list), "OVERLAY_DATA_MISSING",
|
||||
"Evidence snapshot has no recorded coordinates or GeoJSON", status=422)
|
||||
raw_features = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or row.get("longitude") is None or row.get("latitude") is None:
|
||||
continue
|
||||
raw_features.append({
|
||||
"type": "Feature", "geometry": {"type": "Point", "coordinates": [row["longitude"], row["latitude"]]},
|
||||
"id": row.get("device_id") or row.get("point_id") or row.get("work_order_id") or row.get("id"),
|
||||
"properties": {"weight": row.get("weight", 1)},
|
||||
})
|
||||
require(0 < len(raw_features) <= 1000, "OVERLAY_DATA_MISSING",
|
||||
"Overlay needs between one and 1000 recorded features", status=422)
|
||||
features = []
|
||||
for feature in raw_features:
|
||||
require(isinstance(feature, dict) and feature.get("type") == "Feature",
|
||||
"OVERLAY_GEOMETRY_INVALID", "Snapshot contains an invalid feature", status=422)
|
||||
geometry = _geometry(feature.get("geometry"))
|
||||
require(mode != "heatmap" or geometry["type"] == "Point", "OVERLAY_HEATMAP_POINTS_REQUIRED",
|
||||
"Heatmaps require recorded points, not inferred polygon centroids", status=422)
|
||||
properties = feature.get("properties") or {}
|
||||
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
|
||||
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
|
||||
if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool):
|
||||
normalized["id"] = feature["id"]
|
||||
if mode == "heatmap":
|
||||
weight = properties.get("weight", 1)
|
||||
require(_number(weight) and weight >= 0, "OVERLAY_WEIGHT_INVALID",
|
||||
"Heatmap weight must be a finite nonnegative number", status=422)
|
||||
normalized["properties"]["weight"] = weight
|
||||
features.append(normalized)
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user