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
+94
View File
@@ -0,0 +1,94 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from pydantic import ValidationError
from app.infra.db.postgresql import spatial_queries
from app.mcp.models import NearbyElementsInput
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from fastapi import HTTPException
def test_nearby_input_uses_geocoder_coordinate_order_and_bounded_defaults():
args = NearbyElementsInput(context_handle="a" * 43, center=[121.5, 30.9])
assert args.center == (121.5, 30.9)
assert (args.element_type, args.radius_m, args.limit, args.offset) == ("pipe", 200, 5, 0)
schema = NearbyElementsInput.model_json_schema()
assert schema["properties"]["center"]["prefixItems"][0]["maximum"] == 180
assert schema["properties"]["center"]["prefixItems"][1]["maximum"] == 90
@pytest.mark.parametrize("overrides", [
{"center": [181, 30]}, {"center": [121, 91]}, {"center": [121]},
{"center": [121, 30, 0]}, {"center": [float("nan"), 30]},
{"center": [121, float("inf")]}, {"radius_m": 0}, {"radius_m": 10001},
{"radius_m": float("inf")}, {"limit": 101}, {"offset": 10001},
{"element_type": "pipe; DROP TABLE network.pipes"}, {"source": "source_gis"},
{"project_id": "foreign"}, {"sql": "SELECT * FROM network.pipes"},
])
def test_nearby_rejects_invalid_or_unscoped_parameters(overrides):
with pytest.raises(ValidationError):
NearbyElementsInput.model_validate({
"context_handle": "a" * 43, "center": [121.5, 30.9], **overrides,
})
def test_nearby_sql_limits_before_transfer_and_measures_complete_geometries(monkeypatch):
rows = [{"element_id": "P1", "distance_m": 3.5, "properties": {"diameter": 300}}]
read = AsyncMock(return_value=rows)
monkeypatch.setattr(spatial_queries, "read_rows", read)
result = asyncio.run(spatial_queries.nearby_elements(
object(), "pipe", center=(121.5, 30.9), radius_m=500, limit=6, offset=10,
))
assert result == rows
_, statement, parameters = read.call_args.args
query = statement.as_string()
assert 'FROM "gis"."pipes"' in query
assert "v.geom && ST_Transform" in query
assert "ST_Transform(v.geom,4326)::geography" in query
assert "ST_DWithin(c.shape,t.point,%s,true)" in query
assert "ST_Distance(c.shape,t.point,true)" in query
assert "ST_StartPoint" not in query and "ST_EndPoint" not in query
assert "ORDER BY distance_m,c.id" in query
assert "LIMIT %s OFFSET %s" in query
assert parameters[:2] == (121.5, 30.9) and parameters[-3:] == (500, 6, 10)
def test_candidate_envelopes_split_at_dateline_and_skip_mercator_poles():
boxes = spatial_queries._search_boxes(179.9999, 30, 500)
assert len(boxes) == 2
assert all(-180 <= left < right <= 180 for left, _, right, _ in boxes)
assert spatial_queries._search_boxes(0, 90, 500) == []
assert spatial_queries._search_boxes(0, -90, 500) == []
def test_nearby_dispatch_preserves_scope_paging_units_and_current_permissions(monkeypatch):
connection = object()
scope = SimpleNamespace(connection=connection, project=object())
binding = SimpleNamespace(
tool_cap=["query_nearby_elements"], permission_cap=["webgis.view"],
task_id=None, purpose="ordinary_chat", task_scope_cap=[],
)
args = NearbyElementsInput(context_handle="a" * 43, center=[121.5, 30.9], limit=2, offset=1)
rows = [{"element_id": f"P{i}", "properties": {}, "distance_m": i} for i in (1, 2, 3)]
read = AsyncMock(return_value=rows)
monkeypatch.setattr(query_service, "nearby_elements", read)
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.view"})
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_nearby_elements"], args))
assert result.data["items"] == rows[:2]
assert result.data["center"] == [121.5, 30.9]
assert result.data["center_crs"] == "EPSG:4326"
assert result.data["source"] == "model_network"
assert result.page.has_more and result.page.offset == 1 and result.truncated
assert result.units["distance_m"] == "m" and result.semantics == "asset"
assert read.call_args.args == (connection, "pipe")
assert read.call_args.kwargs == {"center": (121.5, 30.9), "radius_m": 200, "limit": 3, "offset": 1}
read.reset_mock()
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: set())
with pytest.raises(HTTPException) as denied:
asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_nearby_elements"], args))
assert denied.value.status_code == 403
read.assert_not_called()