feat(server): integrate MCP queries and investigation messaging

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

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

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
+66 -1
View File
@@ -16,6 +16,7 @@ from app.domain.schemas.timeseries_history import (
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.postgresql import read_queries
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
@@ -56,13 +57,69 @@ def _points(
]
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
descriptors = []
series = []
def add(*, source, element_type, element_id, device_id=None, field, unit, unit_inferred=False):
descriptors.append({
"source": source, "element_type": element_type,
"id": device_id if source == "scada" else element_id, "field": field,
})
metric = _target_metric(element_type)
series.append(ElementHistorySeries(
element_id=element_id, element_type=element_type, device_id=device_id,
metric=metric, source=(
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
) if source == "scada" else (
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
),
source_unit=unit, display_unit=display_unit_for_metric(metric.value),
unit_inferred=unit_inferred, points=[],
))
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
for device_id, (device, element_type, element_id) in sorted(selected_devices.items()):
for field in ("monitored_value", "cleaned_value"):
add(source="scada", element_type=element_type, element_id=element_id,
device_id=device_id, field=field, unit=device["measurement_unit"])
if query.mode.value != "observed":
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
inferred = False
if source == "analysis":
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
if run is None:
raise ValueError(f"analysis run does not exist: {query.run_id}")
recorded = (run.get("parameters") or {}).get("result_units")
inferred = not isinstance(recorded, dict)
if not inferred:
units = recorded
targets = {(t.element_type, t.element_id) for t in query.elements}
for element_type, element_id in sorted(targets):
metric = _target_metric(element_type)
add(source=source, element_type=element_type, element_id=element_id,
field=metric.value, unit=str(units.get(metric.value, "")).strip(), unit_inferred=inferred)
rows = await read_queries.history_rows(
timescale_conn, descriptors, start=query.start_time, end=query.end_time,
run_id=query.run_id, limit=limit + 1, offset=offset,
)
for row in rows[:limit]:
series[row["series_index"]].points.append(HistoryPoint(time=row["time"], value=row["value"]))
return ElementHistoryResponse(series=[s for s in series if s.points], truncated=len(rows) > limit)
class TimeseriesHistoryService:
@staticmethod
async def query(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
query: ElementHistoryQuery,
*, row_limit: int | None = None, row_offset: int = 0,
) -> ElementHistoryResponse:
if row_offset < 0 or (row_offset and row_limit is None) or (row_limit is not None and row_limit < 1):
raise ValueError("row_offset requires a bounded page")
pipe_ids = list(
dict.fromkeys(
target.element_id
@@ -78,8 +135,10 @@ class TimeseriesHistoryService:
)
)
devices = await ScadaInfoRepository.get_scadas_for_elements(
postgres_conn, junction_ids, pipe_ids
postgres_conn, junction_ids, pipe_ids, **({"row_limit": 1001} if row_limit is not None else {})
)
if row_limit is not None and len(devices) > 1000:
raise ValueError("too many device mappings; narrow the element selection")
devices_by_element: dict[tuple[HistoryElementType, str], list[dict[str, Any]]] = (
defaultdict(list)
)
@@ -122,6 +181,12 @@ class TimeseriesHistoryService:
target.element_id,
)
if row_limit is not None and len(selected_devices) > 20:
raise ValueError("select at most 20 devices explicitly")
if row_limit is not None:
return await _query_page(
timescale_conn, postgres_conn, query, selected_devices, row_limit, row_offset,
)
scada_rows = await ScadaRepository.get_scada_by_ids_time_range(
timescale_conn,
list(selected_devices),