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