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
+224
View File
@@ -0,0 +1,224 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.domain.schemas.timeseries_history import ElementHistoryQuery
from app.infra.db.postgresql import read_queries
from app.services import timeseries_history
START = datetime(2026, 10, 1, tzinfo=timezone.utc)
def test_history_pages_advance_across_raw_cleaned_and_model_series(monkeypatch):
monkeypatch.setattr(
timeseries_history.ScadaInfoRepository, "get_scadas_for_elements",
AsyncMock(return_value=[{
"device_id": "D1", "device_type": "pressure", "node_id": "J1",
"link_id": None, "measurement_unit": "m",
}]),
)
monkeypatch.setattr(
timeseries_history.ScadaRepository, "get_scada_by_ids_time_range",
AsyncMock(side_effect=AssertionError("must use paged SQL")),
)
monkeypatch.setattr(
timeseries_history.NetworkSettingsRepository, "get_result_units",
AsyncMock(return_value={"pressure": "m"}),
)
points = [
{"series_index": i, "time": START + timedelta(minutes=j), "value": i * 10 + j}
for i in range(3) for j in range(2)
]
async def page(conn, descriptors, *, start, end, run_id, limit, offset):
assert len(descriptors) == 3
return points[offset:offset + limit]
monkeypatch.setattr(read_queries, "history_rows", page, raising=False)
query = ElementHistoryQuery(
start_time=START, end_time=START + timedelta(hours=1),
mode="realtime_comparison", elements=[{"element_type": "junction", "element_id": "J1"}],
)
async def check():
seen = []
for offset in (0, 2, 4, 6):
result = await timeseries_history.TimeseriesHistoryService.query(
object(), object(), query, row_limit=2, row_offset=offset,
)
seen.extend(point.value for series in result.series for point in series.points)
assert result.truncated is (offset < 4)
assert seen == [0, 1, 10, 11, 20, 21]
asyncio.run(check())
class Connection:
def __init__(self, rows):
self.rows = rows
self.statements = []
@asynccontextmanager
async def transaction(self):
yield self
def cursor(self, **kwargs):
return self
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def execute(self, statement, params):
self.statements.append((statement.as_string() if hasattr(statement, "as_string") else statement, params))
async def fetchall(self):
return self.rows
def test_history_sql_applies_one_page_to_all_sources():
conn = Connection([])
asyncio.run(read_queries.history_rows(
conn, [
{"source": "scada", "element_type": "junction", "id": "D'1", "field": "monitored_value"},
{"source": "analysis", "element_type": "pipe", "id": "P1", "field": "flow"},
], start=START, end=START + timedelta(hours=1), run_id=uuid4(), limit=3, offset=2,
))
statement, params = conn.statements[-1]
assert "UNION ALL" in statement and "ORDER BY series_index, time" in statement
assert statement.endswith("LIMIT %s OFFSET %s") and params[-2:] == (3, 2)
assert "run_id=%s" in statement and "D'1" not in statement
def test_evidence_projection_is_scoped_paged_and_size_guarded_in_sql():
payload = {"value": [2], "total": 3, "value_type": "array", "snapshot_metadata": {}, "query_metadata": None}
conn = Connection([{"payload": payload, "too_large": False}])
result = asyncio.run(read_queries.evidence_page(
conn, project_id=uuid4(), task_id=uuid4(), evidence_id="E1",
data_path=["items"], limit=1, offset=1, mcp_sources=["query_scada_readings"],
))
assert result == payload
statement, params = conn.statements[-1]
assert "project_id=%s AND task_id=%s AND evidence_id=%s" in statement
assert "#> %s::text[]" in statement and "LIMIT %s OFFSET %s" in statement
assert "octet_length" in statement and "CASE WHEN" in statement
assert ["items"] in params
def test_oversized_evidence_does_not_return_a_document():
from fastapi import HTTPException
conn = Connection([{"payload": None, "too_large": True}])
with pytest.raises(HTTPException) as caught:
asyncio.run(read_queries.evidence_page(
conn, project_id=uuid4(), task_id=uuid4(), evidence_id="E1",
data_path=[], limit=1, offset=0, mcp_sources=[],
))
assert caught.value.status_code == 413
def test_demo_payload_has_a_size_gate_before_transfer(monkeypatch):
from fastapi import HTTPException
from app.mcp import peripheral
from app.mcp.models import PeripheralInput
calls = []
async def rows(conn, statement, params):
calls.append(statement)
return [{"document": None, "digest": "unused", "too_large": True}]
monkeypatch.setattr(peripheral, "read_rows", rows)
args = PeripheralInput(
context_handle="a" * 43, source="peripheral_observations", point_id="DEMO-1",
start=START, end=START + timedelta(hours=1), limit=1,
)
with pytest.raises(HTTPException) as caught:
asyncio.run(peripheral.read_peripheral_query(
SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=uuid4())), args,
))
assert caught.value.status_code == 413
assert "CASE WHEN" in calls[0] and "octet_length" in calls[0]
def test_history_tool_forwards_offset_and_keeps_last_page_partial(monkeypatch):
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, HistoryInput
conn = Connection([])
scope = SimpleNamespace(connection=conn, project=object(), timescale=conn.transaction)
binding = BoundContext(
context_handle="a"*43, project_id=uuid4(), subject="u", client_id="host",
tool_cap=["query_element_history"], permission_cap=["scada.view"],
native_session_id="s", purpose="ordinary_chat", expires_at=START,
)
monkeypatch.setattr(query_service, "permissions_for_context", lambda scope: {"scada.view"})
from app.domain.schemas.timeseries_history import ElementHistoryResponse
reader = AsyncMock(return_value=ElementHistoryResponse(series=[], truncated=False))
monkeypatch.setattr(timeseries_history.TimeseriesHistoryService, "query", reader)
args = HistoryInput(
context_handle="a"*43, start=START, end=START+timedelta(hours=1),
elements=[{"element_type": "junction", "element_id": "J1"}], limit=2, offset=4,
)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_element_history"], args))
assert reader.await_args.kwargs == {"row_limit": 2, "row_offset": 4}
assert result.page.offset == 4 and not result.page.has_more and result.truncated
def test_task_context_retains_selected_report_revision(monkeypatch):
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, TaskContextInput
reader = AsyncMock(return_value={"selected_report": {"revision": 3}})
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
task_id = uuid4()
binding = BoundContext(
context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
tool_cap=["get_task_context"], permission_cap=["operational_task.view"],
native_session_id="s", purpose="task_collaboration", task_id=task_id,
expires_at=START,
)
scope = SimpleNamespace(connection=Connection([]), project=object())
args = TaskContextInput(context_handle="a" * 43, report_revision=3)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], args))
reader.assert_awaited_once_with(scope.connection, project_id=binding.project_id, task_id=task_id, report_revision=3)
assert result.data["selected_report"]["revision"] == 3
with pytest.raises(ValueError):
TaskContextInput(context_handle="a" * 43, report_revision=0)
@pytest.mark.parametrize("section", ["evidence", "actions", "access_requests"])
def test_task_context_collection_pages_use_database_total_and_stable_paths(monkeypatch, section):
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, TaskContextInput
reader = AsyncMock(return_value={
"total": 2, "value_type": "object", "nested": True,
"snapshot_metadata": {"section": section, "total": 3},
"value": {"stable-id": {"collection_type": "object", "data_path": ["stable-id"], "count": 4}},
})
monkeypatch.setattr(query_service.reads, "task_context_page", reader)
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
binding = BoundContext(context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
tool_cap=["get_task_context"], permission_cap=["operational_task.view"], native_session_id="s",
purpose="task_collaboration", task_id=uuid4(), expires_at=START)
scope = SimpleNamespace(connection=Connection([]), project=object())
args = TaskContextInput(context_handle="a" * 43, section=section, limit=1, offset=1)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], args))
assert result.page.total == 3 and result.page.has_more and result.page.offset == 1
assert result.truncated and result.data["value"]["stable-id"]["data_path"] == ["stable-id"]
reader.assert_awaited_once_with(scope.connection, project_id=binding.project_id, task_id=binding.task_id,
section=section, report_revision=None, data_path=[], limit=1, offset=1)