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
@@ -184,6 +184,7 @@ class AnalysisResultsRepository:
start_time: datetime,
end_time: datetime,
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if element_type == "node":
table_name, id_column, valid_fields = (
@@ -214,8 +215,10 @@ class AnalysisResultsRepository:
sql.Identifier(id_column),
sql.Identifier(id_column),
)
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (run_id, element_ids, start_time, end_time))
await cur.execute(query, (run_id, element_ids, start_time, end_time) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["element_id"]), []).append(
{"time": row["time"], "value": row["value"]}
@@ -3,9 +3,28 @@ from datetime import datetime, timedelta
from collections import defaultdict
from psycopg import AsyncConnection, Connection, sql
from app.domain.time import parse_utc_time
from app.core.config import settings
class RealtimeRepository:
# The third set_config argument makes this transaction-local, so a pooled
# connection returns to its original guard after commit or rollback.
SNAPSHOT_BUDGET_SQL = "SELECT set_config('timescaledb.max_tuples_decompressed_per_dml_transaction', %s, true)"
@staticmethod
async def _configure_snapshot_write(cur) -> None:
await cur.execute(
RealtimeRepository.SNAPSHOT_BUDGET_SQL,
(str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),),
)
@staticmethod
def _configure_snapshot_write_sync(cur) -> None:
cur.execute(
RealtimeRepository.SNAPSHOT_BUDGET_SQL,
(str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),),
)
LINK_FIELDS = frozenset(
{
"flow",
@@ -93,6 +112,7 @@ class RealtimeRepository:
# 使用事务确保原子性
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -116,6 +136,7 @@ class RealtimeRepository:
# 使用事务确保原子性
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -163,6 +184,7 @@ class RealtimeRepository:
end_time: datetime,
link_ids: list[str],
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if field not in RealtimeRepository.LINK_FIELDS:
raise ValueError(f"Invalid field: {field}")
@@ -174,8 +196,10 @@ class RealtimeRepository:
"WHERE time BETWEEN %s AND %s AND link_id = ANY(%s) "
"ORDER BY link_id, time"
).format(sql.Identifier(field))
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (start_time, end_time, link_ids))
await cur.execute(query, (start_time, end_time, link_ids) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["link_id"]), []).append(
{"time": row["time"].isoformat(), "value": row[field]}
@@ -282,6 +306,7 @@ class RealtimeRepository:
# 使用事务确保原子性
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -304,6 +329,7 @@ class RealtimeRepository:
# 使用事务确保原子性
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(target_time,),
@@ -351,6 +377,7 @@ class RealtimeRepository:
end_time: datetime,
node_ids: list[str],
field: str,
*, row_limit: int | None = None,
) -> dict[str, list[dict[str, Any]]]:
if field not in RealtimeRepository.NODE_FIELDS:
raise ValueError(f"Invalid field: {field}")
@@ -362,8 +389,10 @@ class RealtimeRepository:
"WHERE time BETWEEN %s AND %s AND node_id = ANY(%s) "
"ORDER BY node_id, time"
).format(sql.Identifier(field))
if row_limit is not None:
query += sql.SQL(" LIMIT %s")
async with conn.cursor() as cur:
await cur.execute(query, (start_time, end_time, node_ids))
await cur.execute(query, (start_time, end_time, node_ids) + ((row_limit,) if row_limit is not None else ()))
for row in await cur.fetchall():
result.setdefault(str(row["node_id"]), []).append(
{"time": row["time"].isoformat(), "value": row[field]}
@@ -482,6 +511,7 @@ class RealtimeRepository:
# table. Copy-only helpers avoid repeating replacement SQL.
async with conn.transaction():
async with conn.cursor() as cur:
await RealtimeRepository._configure_snapshot_write(cur)
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(simulation_time,),
@@ -559,6 +589,7 @@ class RealtimeRepository:
# table. Copy-only helpers avoid repeating replacement SQL.
with conn.transaction():
with conn.cursor() as cur:
RealtimeRepository._configure_snapshot_write_sync(cur)
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
(simulation_time,),
@@ -34,13 +34,15 @@ class ScadaRepository:
device_ids: List[str],
start_time: datetime,
end_time: datetime,
*, row_limit: int | None = None,
) -> List[dict]:
async with conn.cursor() as cur:
await cur.execute(
f"SELECT {ScadaRepository.RESULT_COLUMNS} FROM scada.measurements "
"WHERE device_id = ANY(%s) "
"AND time >= %s AND time <= %s ORDER BY device_id, time",
(device_ids, start_time, end_time),
"AND time >= %s AND time <= %s ORDER BY device_id, time"
+ (" LIMIT %s" if row_limit is not None else ""),
(device_ids, start_time, end_time) + ((row_limit,) if row_limit is not None else ()),
)
return await cur.fetchall()