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