Files
next-tjwater-server/app/infra/db/postgresql/read_queries.py
T
jiang be6317eb48 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.
2026-10-19 18:46:58 +08:00

407 lines
20 KiB
Python

"""Bounded read primitives over existing project views and time-series tables."""
from datetime import datetime
from typing import Any
from uuid import UUID
from psycopg import AsyncConnection
from psycopg import sql
from psycopg.rows import dict_row
from fastapi import HTTPException
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
ELEMENT_VIEWS = {
v: v + "s" for v in ("junction", "pipe", "reservoir", "tank", "pump", "valve")
}
MAX_QUERY_BYTES = 8 * 1024 * 1024
async def read_rows(conn, statement, params=(), *, timeout_ms=30000):
async with conn.transaction(), conn.cursor(row_factory=dict_row) as cur:
await cur.execute(
"SELECT set_config('statement_timeout',%s,true)", (str(timeout_ms),)
)
await cur.execute(statement, params)
return await cur.fetchall()
async def asset_rows(conn, element_type, *, element_id=None, limit=100, offset=0):
view = ELEMENT_VIEWS[element_type]
where = sql.SQL("WHERE v.id=%s") if element_id else sql.SQL("")
params = (element_id,) if element_id else ()
statement = sql.SQL(
"SELECT to_jsonb(v)-'geom' AS record FROM gis.{} v {} ORDER BY v.id LIMIT %s OFFSET %s"
).format(sql.Identifier(view), where)
rows = await read_rows(conn, statement, params + (limit, offset))
return [row["record"] for row in rows]
async def scada_devices(conn, *, device_id=None, limit=100, offset=0):
where = " WHERE id=%s" if device_id else ""
params = (device_id,) if device_id else ()
rows = await read_rows(
conn,
_SCADA_VIEW_SELECT + where + " ORDER BY device_id LIMIT %s OFFSET %s",
params + (limit, offset),
)
return [_device(row) for row in rows]
async def series_rows(
conn,
*,
source,
element_type,
ids,
start=None,
end=None,
time=None,
field=None,
run_id=None,
limit=1000,
offset=0,
end_exclusive=False,
):
node = element_type == "junction"
if source == "scada":
table, id_column, fields = (
"measurements",
"device_id",
{"monitored_value", "cleaned_value"},
)
else:
table, id_column = (
("node_results", "node_id") if node else ("link_results", "link_id")
)
fields = (
(
AnalysisResultsRepository.NODE_FIELDS
if node
else AnalysisResultsRepository.LINK_FIELDS
)
if source == "analysis"
else (
RealtimeRepository.NODE_FIELDS
if node
else RealtimeRepository.LINK_FIELDS
)
)
if field is not None and field not in fields:
raise ValueError("unsupported field for this data source and element type")
columns = (
sql.SQL("{},time,{} AS value").format(
sql.Identifier(id_column), sql.Identifier(field)
)
if field
else sql.SQL("*")
)
filters = []
params = []
if ids:
filters.append(sql.SQL("{}=ANY(%s)").format(sql.Identifier(id_column)))
params.append(ids)
if run_id:
filters.append(sql.SQL("run_id=%s"))
params.append(run_id)
if start is not None:
filters.append(
sql.SQL(
"time >= %s AND time < %s"
if end_exclusive
else "time >= %s AND time <= %s"
)
)
params.extend((start, end))
else:
# Existing point-query semantics use +/- one second.
filters.append(
sql.SQL("time BETWEEN %s-interval '1 second' AND %s+interval '1 second'")
)
params.extend((time, time))
statement = sql.SQL(
"SELECT {} FROM {}.{} WHERE {} ORDER BY time,{} LIMIT %s OFFSET %s"
).format(
columns,
sql.Identifier(source),
sql.Identifier(table),
sql.SQL(" AND ").join(filters),
sql.Identifier(id_column),
)
return await read_rows(conn, statement, tuple(params) + (limit, offset))
async def sensor_runs(conn, *, run_id=None, limit=100, offset=0):
where = " AND r.run_id=%s" if run_id else ""
params = (run_id,) if run_id else ()
return await read_rows(
conn,
"""SELECT r.run_id,r.name,r.created_by,r.created_at,r.status,
result.payload FROM analysis.runs r JOIN LATERAL
(SELECT payload FROM analysis.results WHERE run_id=r.run_id
AND result_type='sensor_placement' ORDER BY created_at DESC,result_id DESC LIMIT 1) result ON true
WHERE r.run_type='sensor_placement'"""
+ where
+ " ORDER BY r.created_at DESC,r.run_id LIMIT %s OFFSET %s",
params + (limit, offset),
)
async def history_rows(
conn: AsyncConnection, descriptors: list[dict[str, Any]], *,
start: datetime, end: datetime, run_id: UUID | None, limit: int, offset: int,
) -> list[dict[str, Any]]:
"""Page points across raw, cleaned and model series in one SQL statement."""
parts, params = [], []
for index, item in enumerate(descriptors):
source, field = item["source"], item["field"]
if source == "scada":
table, id_column, fields = "measurements", "device_id", {"monitored_value", "cleaned_value"}
elif source in {"realtime", "analysis"}:
node = item["element_type"] == "junction"
table, id_column = ("node_results", "node_id") if node else ("link_results", "link_id")
fields = {"pressure"} if node else {"flow"}
else:
raise ValueError("unsupported history source")
if field not in fields or (source == "analysis" and run_id is None):
raise ValueError("unsupported history field or missing run")
parts.append(sql.SQL(
"SELECT %s::integer AS series_index,time,{} AS value FROM {}.{} "
"WHERE {}=%s AND time >= %s AND time <= %s{}"
).format(
sql.Identifier(field), sql.Identifier(source), sql.Identifier(table),
sql.Identifier(id_column), sql.SQL(" AND run_id=%s" if source == "analysis" else ""),
))
params.extend((index, item["id"], start, end))
if source == "analysis":
params.append(run_id)
if not parts:
return []
statement = sql.SQL(
"SELECT series_index,time,value FROM ({}) points "
"ORDER BY series_index, time LIMIT %s OFFSET %s"
).format(sql.SQL(" UNION ALL ").join(parts))
return await read_rows(conn, statement, tuple(params) + (limit, offset))
async def evidence_page(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, evidence_id: str, data_path: list[str],
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
) -> dict[str, Any]:
"""Project the immutable snapshot in PostgreSQL before transferring JSON."""
return await _snapshot_page(conn, stored_sql="""
SELECT document->'data_snapshot' AS snapshot
FROM workbench.operational_task_evidence
WHERE project_id=%s AND task_id=%s AND evidence_id=%s
AND jsonb_typeof(document->'data_snapshot')='object'
""", stored_params=(project_id, task_id, evidence_id), data_path=data_path,
limit=limit, offset=offset, mcp_sources=mcp_sources, full_snapshot=full_snapshot)
async def _snapshot_page(
conn: AsyncConnection, *, stored_sql: str, stored_params: tuple, data_path: list[str],
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
max_bytes: int = MAX_QUERY_BYTES,
) -> dict[str, Any]:
"""Shared JSONB projection; callers supply only fixed, scoped SQL sources."""
def descriptor(value, path):
return f"""jsonb_build_object(
'collection_type',jsonb_typeof({value}),
'count',CASE jsonb_typeof({value}) WHEN 'array' THEN jsonb_array_length({value})
WHEN 'string' THEN char_length({value} #>> '{{}}')
ELSE (SELECT count(*) FROM jsonb_object_keys({value})) END,
'data_path',to_jsonb({path}))"""
collection = descriptor("p.value", "p.path")
child = descriptor("child.item", "p.path || ARRAY[child.key]")
statement = """
WITH stored AS MATERIALIZED (
STORED_QUERY
), captured AS (
SELECT snapshot,
snapshot->>'source'=ANY(%s::text[])
AND jsonb_typeof(snapshot->'data')='object' AS is_mcp
FROM stored
), selected AS MATERIALIZED (
SELECT snapshot,
CASE WHEN is_mcp THEN (snapshot->'data')-'data' END AS query_metadata,
(CASE WHEN is_mcp THEN snapshot->'data'->'data'
ELSE snapshot->'data' END) #> %s::text[] AS value
FROM captured
), entries AS (
SELECT ordinality AS position, NULL::text AS key, item AS value
FROM selected, jsonb_array_elements(
CASE WHEN jsonb_typeof(value)='array' THEN value ELSE '[]'::jsonb END
) WITH ORDINALITY AS a(item, ordinality)
UNION ALL
SELECT row_number() OVER (ORDER BY e.key COLLATE "C"), e.key, e.item
FROM selected, jsonb_each(
CASE WHEN jsonb_typeof(value)='object' THEN value ELSE '{}'::jsonb END
) AS e(key,item)
), paged AS MATERIALIZED (
SELECT * FROM entries ORDER BY position LIMIT %s OFFSET %s
), paths AS (
SELECT paged.*, jsonb_typeof(selected.value) AS parent_type,
%s::text[] || ARRAY[CASE WHEN jsonb_typeof(selected.value)='array'
THEN (position-1)::text ELSE key END] AS path
FROM paged,selected
), summaries AS (
SELECT p.position,p.key,p.value AS original,
CASE WHEN jsonb_typeof(p.value) IN ('array','object') OR
(jsonb_typeof(p.value)='string' AND char_length(p.value #>> '{}')>256) THEN
CASE WHEN p.parent_type='object' OR jsonb_typeof(p.value) IN ('array','string')
THEN COLLECTION_PREVIEW
ELSE COALESCE((SELECT jsonb_object_agg(child.key,
CASE WHEN jsonb_typeof(child.item) IN ('array','object') OR
(jsonb_typeof(child.item)='string' AND char_length(child.item #>> '{}')>256)
THEN CHILD_PREVIEW ELSE child.item END)
FROM jsonb_each(p.value) AS child(key,item)), '{}'::jsonb)
END ELSE p.value END AS value
FROM paths p
), projected AS (
SELECT CASE WHEN %s THEN snapshot ELSE jsonb_build_object(
'snapshot_metadata',snapshot-'data', 'query_metadata',query_metadata,
'nested',EXISTS(SELECT 1 FROM summaries WHERE original<>value),
'value_type',jsonb_typeof(value),
'total',CASE jsonb_typeof(value)
WHEN 'array' THEN jsonb_array_length(value)
WHEN 'object' THEN (SELECT count(*) FROM jsonb_object_keys(value))
WHEN 'string' THEN char_length(value #>> '{}') END,
'value',CASE jsonb_typeof(value)
WHEN 'array' THEN COALESCE((SELECT jsonb_agg(value ORDER BY position) FROM summaries),'[]'::jsonb)
WHEN 'object' THEN COALESCE((SELECT jsonb_object_agg(key,value) FROM summaries),'{}'::jsonb)
WHEN 'string' THEN to_jsonb(substring(value #>> '{}' FROM %s FOR %s))
ELSE value END
) END AS payload, value IS NULL AS missing_path
FROM selected
), sized AS MATERIALIZED (
SELECT payload,missing_path,octet_length(payload::text)>%s AS too_large
FROM projected
)
SELECT CASE WHEN NOT too_large THEN payload END AS payload,too_large,missing_path
FROM sized
"""
statement = statement.replace("STORED_QUERY", stored_sql).replace("COLLECTION_PREVIEW", collection).replace("CHILD_PREVIEW", child)
rows = await read_rows(conn, statement, (
*stored_params, mcp_sources, data_path, limit, offset,
data_path, full_snapshot, offset + 1, limit, max_bytes,
))
if not rows:
raise HTTPException(404, "Stored query document was not found")
row = rows[0]
if row["too_large"]:
raise HTTPException(413, "Query exceeds the byte budget; select a deeper data_path or smaller page")
if row.get("missing_path") and not full_snapshot:
raise HTTPException(422, "Evidence data_path does not name a stored value")
return row["payload"]
async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, report_revision: int | None) -> dict:
"""Read fixed-size metadata and explicit previews, never a complete report."""
report = """jsonb_build_object('revision',r.revision,
'execution_generation',r.document->'execution_generation',
'stage',r.document#>'{report,stage}','outcome',r.document#>'{report,outcome}',
'current_conclusion_preview',left(r.document#>>'{report,current_conclusion}',200),
'preview_only',true,'detail',jsonb_build_object('section','report','report_revision',r.revision))"""
rows = await read_rows(conn, """
SELECT jsonb_build_object('task',jsonb_build_object(
'task_id',t.task_id,'task_type',t.task_type,'workflow',t.document->>'workflow','status',t.status,
'lifecycle_status',t.lifecycle_status,'priority',t.priority,'revision',t.revision,
'execution_generation',t.execution_generation,
'current_report_revision',t.current_report_revision,
'title_preview',left(t.document->>'title',160),'preview_only',true,
'detail',jsonb_build_object('section','task')),
'latest_report',(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
WHERE r.project_id=t.project_id AND r.task_id=t.task_id ORDER BY r.revision DESC LIMIT 1),
'selected_report',CASE WHEN %s::int IS NULL THEN NULL ELSE
(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
WHERE r.project_id=t.project_id AND r.task_id=t.task_id AND r.revision=%s) END
) AS payload
FROM workbench.operational_tasks t WHERE t.project_id=%s AND t.task_id=%s
""".replace("REPORT_SUMMARY", report), (report_revision, report_revision, project_id, task_id))
if not rows:
raise HTTPException(404, "Task was not found in this project")
result = rows[0]["payload"]
if report_revision is not None and result["selected_report"] is None:
raise HTTPException(404, "Selected report revision was not found")
return result
async def task_context_page(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
report_revision: int | None, data_path: list[str], limit: int, offset: int,
) -> dict:
"""Page only the requested task section inside PostgreSQL, before transfer."""
collections = {
"evidence": ("operational_task_evidence", "evidence_id"),
"actions": ("operational_task_actions", "action_id"),
"access_requests": ("operational_task_access_requests", "access_request_id"),
}
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
if section in {"access_requests", "progress"}:
table = "operational_task_access_requests" if section == "access_requests" else "operational_task_progress"
available = await read_rows(conn, "SELECT to_regclass(%s) AS relation", ("workbench." + table,))
if not available[0]["relation"]:
return {"snapshot_metadata": {"section": section, "available": False}, "value": None, "value_type": "null", "total": None, "nested": False}
projection_offset = offset
if section in collections:
table, key = collections[section]
# These identifiers come exclusively from the fixed table above.
relation = sql.Identifier("workbench", table).as_string()
identifier = sql.Identifier(key).as_string()
if data_path:
stored = f"""SELECT jsonb_build_object('section',%s::text,'data',jsonb_build_object({identifier}::text,document)) AS snapshot
FROM {relation} WHERE project_id=%s AND task_id=%s AND {identifier}::text=%s"""
params = (section, project_id, task_id, data_path[0])
else:
# Fetch just this page of rows. Collection keys remain stable across
# paging, so every nested data_path uses the real evidence/action ID.
stored = f"""WITH items AS MATERIALIZED (
SELECT {identifier}::text AS key,document FROM {relation}
WHERE project_id=%s AND task_id=%s
ORDER BY {identifier}::text COLLATE "C" LIMIT %s OFFSET %s)
SELECT jsonb_build_object('section',%s::text,
'total',(SELECT count(*) FROM {relation} WHERE project_id=%s AND task_id=%s),
'data',COALESCE(jsonb_object_agg(key,document),'{{}}'::jsonb)) AS snapshot FROM items"""
params = (project_id, task_id, limit + 1, offset, section, project_id, task_id)
projection_offset = 0
elif section in documents:
relation = sql.Identifier("workbench", documents[section]).as_string()
where = ""
params = (section, project_id, task_id)
if section == "report":
where = " AND (%s::int IS NULL OR revision=%s) ORDER BY revision DESC LIMIT 1"
params += (report_revision, report_revision)
elif section == "plan":
where = " ORDER BY revision DESC LIMIT 1"
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
elif section == "run":
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id') AS snapshot
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
params = (project_id, task_id)
elif section == "progress":
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
FROM workbench.operational_task_progress p
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation
WHERE p.project_id=%s AND p.task_id=%s"""
params = (project_id, task_id)
else:
raise HTTPException(422, "Unknown task context section")
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
async def task_data_scopes(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, permissions: set[str]) -> list[str]:
"""Bound scope discovery to seven known sources, without loading task detail."""
known = ["workbench_runtime", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", "scada_devices", "scada_readings"]
scopes = {name for name in known if ("scada.view" if name.startswith("scada_") else "simulation.view") in permissions}
available = await read_rows(conn, "SELECT to_regclass('workbench.operational_task_access_requests') AS relation")
if available[0]["relation"]:
rows = await read_rows(conn, """SELECT DISTINCT scope FROM workbench.operational_task_access_requests,
LATERAL jsonb_array_elements_text(COALESCE(document->'approved_scopes','[]'::jsonb)) AS granted(scope)
WHERE project_id=%s AND task_id=%s AND status='approved' AND scope=ANY(%s::text[])""", (project_id, task_id, known))
scopes.update(row["scope"] for row in rows)
return [name for name in known if name in scopes]