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,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,
|
||||
)
|
||||
Reference in New Issue
Block a user