Files
next-tjwater-server/app/mcp/query_service.py
T
jiang b45c5c6f46
Generic Container CI/CD / test-build-publish (push) Successful in 4m31s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m31s
feat(investigations): refine report authoring and replay flushing
2026-11-14 23:00:15 +08:00

665 lines
34 KiB
Python

"""MCP domain dispatch reuses Server repositories and services, never an HTTP loopback."""
import asyncio
import math
from contextlib import nullcontext
from typing import Any
from pydantic import BaseModel
from app.mcp.scope import QueryScope
from app.mcp.catalog import QueryTool, TOOLS
from app.mcp.models import BoundContext
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from app.core.config import settings
from app.auth.permissions import permissions_for_context
from app.infra.db.postgresql import read_queries as reads
from app.infra.db.postgresql.spatial_queries import nearby_elements
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.services.operational_tasks import OperationalTaskService
from app.services.burst_location_demo import location_demo_result
from app.mcp.models import QueryResult, PageInfo
from app.mcp.scenario_service import SCENARIO_TOOLS
from app.mcp.work_order_skills import read_work_order_skill
def finite_data(value: Any) -> tuple[Any, int]:
"""JSON cannot represent infinity/NaN; preserve the gap as null with provenance."""
if isinstance(value, float) and not math.isfinite(value):
return None, 1
count = 0
if isinstance(value, dict):
result = {}
for key, item in value.items():
result[key], replaced = finite_data(item)
count += replaced
return result, count
if isinstance(value, list):
result = []
for item in value:
clean, replaced = finite_data(item)
result.append(clean)
count += replaced
return result, count
return value, 0
def task_service(scope: QueryScope) -> OperationalTaskService:
return OperationalTaskService(
OperationalTaskRepository(scope.connection, scope.project.project_id),
scope.project,
set(permissions_for_context(scope.project)),
execution_scope=settings.operational_task_execution_scope,
)
def page_result(rows, args):
has_more = len(rows) > args.limit
return (
rows[: args.limit],
PageInfo(limit=args.limit, offset=args.offset, total=None, has_more=has_more),
has_more,
)
async def authorize_query(
scope: QueryScope, binding: BoundContext, tool: QueryTool, args: BaseModel
) -> None:
replay_context = getattr(binding, "replay_context", None)
if tool.name not in binding.tool_cap:
raise HTTPException(403, "Query is outside the captured tool catalog")
if replay_context:
from app.services.scada_replay_queries import check_replay_window
allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
"query_scada_readings", "get_network_schema", "run_burst_location", "run_contaminant_source_trace",
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
"get_node_connections", "get_element_gis_properties", "query_network_extent",
"list_regions", "get_region", "get_region_nodes",
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation", "analyze_flushing",
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
if tool.name not in allowed:
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
if tool.name == "run_burst_simulation":
if not args.include_impact_coverage:
raise HTTPException(422, "回放爆管情景必须启用影响覆盖,按当前模型估计并保存同刻在线基线")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "run_contaminant_simulation":
if not args.include_impact_coverage:
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "analyze_flushing":
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "query_scada_readings":
check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_contaminant_source_trace":
check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_burst_location":
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
check_replay_window(replay_context, args.scada_burst_start, args.scada_burst_end)
check_replay_window(replay_context, args.scada_normal_start, args.scada_normal_end)
permissions = permissions_for_context(scope.project)
if tool.mutating:
if binding.purpose != "ordinary_chat" or binding.task_id is not None or scope.project.project_role != "member":
raise HTTPException(403, "Writes require a project member's ordinary interactive chat")
required = {tool.permission}
if tool.name == "save_simulation_scenario":
required.add("scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
raise HTTPException(403, "Write and input data permissions are required")
if tool.computation:
if tool.name == "optimize_sensor_placement" and scope.project.project_role != "member":
raise HTTPException(403, "Sensor placement requires project member access")
if binding.purpose == "task_reader":
raise HTTPException(403, "Read-only task agents cannot start calculations")
required = {tool.permission}
if tool.name in SCENARIO_TOOLS:
required.add("scada.view")
if tool.name == "run_burst_simulation" and args.include_impact_coverage:
required.add("simulation.view")
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
raise HTTPException(403, "Calculation and input data permissions are required")
permission = (
"scada.view"
if tool.name == "get_network_schema" and args.kind == "scada-device"
else tool.permission
)
if tool.task_only and binding.task_id is None:
raise HTTPException(403, "This query requires a bound task")
granted = permission in permissions and permission in binding.permission_cap
source = {
"list_scada_devices": "scada_devices",
"get_scada_device": "scada_devices",
"query_scada_readings": "scada_readings",
"query_element_history": "scada_readings",
"get_workbench_runtime": "workbench_runtime",
}.get(tool.name)
if tool.name == "query_peripheral_data":
source = args.source
if not granted and binding.task_id and source in binding.task_scope_cap:
async with scope.connection.transaction():
granted = await task_service(scope).can_read_source(binding.task_id, source)
if granted and tool.name == "query_scada_readings" and args.device_ids is None:
granted = (
"scada_devices" in binding.task_scope_cap
and await task_service(scope).can_read_source(binding.task_id, "scada_devices")
)
if not granted:
raise HTTPException(403, "Data source permission is required")
if tool.name == "run_burst_location" and args.burst_leakage is None:
from app.services.burst_location_demo import is_location_demo
if not is_location_demo(getattr(scope.project, "project_code", None), args):
raise HTTPException(422, "水力定位需要有依据的 burst_leakage;仅已开放的候选定位路径可省略漏量")
if (
tool.name == "query_element_history"
and args.mode != "observed"
and (
"simulation.view" not in permissions
or "simulation.view" not in binding.permission_cap
)
):
raise HTTPException(403, "Model comparison permission is required")
async def execute_query(
scope: QueryScope,
binding: BoundContext,
tool: QueryTool,
args: BaseModel,
*,
full_snapshot: bool = False,
) -> QueryResult:
await authorize_query(scope, binding, tool, args)
if tool.name in {"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"}:
from app.mcp.calculation_jobs import read_calculation_job
return await read_calculation_job(scope, binding, tool, args)
if tool.mutating:
from app.mcp.mutation_service import execute_mutation
return await execute_mutation(scope, binding, tool, args)
from app.mcp.extended_service import EXTENDED_READ_TOOLS, execute_extended
if tool.name in EXTENDED_READ_TOOLS:
return await execute_extended(scope, binding, tool, args)
name = tool.name
conn = scope.connection
page = None
truncated = False
units = {}
origin = "system_data"
gaps = []
source_ref = "mcp:" + str(uuid4())
evidence_id = None
replay_context = getattr(binding, "replay_context", None)
if name == "run_contaminant_source_trace":
from app.services.contaminant_source_trace import trace_contaminant_source
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": await trace_contaminant_source(conn, args)}
origin = "synthetic"
if data["result"]["located_node"] is None:
gaps.append(data["result"]["notice"])
elif name == "query_scada_readings" and args.device_ids is None:
from app.mcp.scada_readings import read_complete_scada
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
elif name == "query_scada_readings" and replay_context:
from app.services.scada_replay_queries import replay_snapshot_rows
rows = await replay_snapshot_rows(conn, scope.project.project_id, replay_context,
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
data, page, truncated = page_result(rows, args)
origin = "synthetic"
elif name == "run_burst_simulation" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_simulation
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_burst_simulation(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放爆管影响为当前模型的独立假设情景,不读取实时SCADA;同刻在线模拟为计算时读取的模型参考,非冻结观测或历史模型重建。未来模拟样本仅为预测。"]
elif name == "run_contaminant_simulation" and replay_context:
from app.mcp.analysis_service import calculate_replay_contaminant_simulation
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_contaminant_simulation(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
elif name == "analyze_flushing" and replay_context:
from app.mcp.analysis_service import calculate_replay_flushing
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_flushing(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放冲洗为当前模型的独立水力情景,不读取实时SCADA,不继承上次模拟;未来模拟样本仅为预测,不验证污染清除、水质合格或现场操作。"]
elif name == "run_burst_location" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_location
from app.services.scada_replay_queries import replay_burst_location_inputs
parameters = await replay_burst_location_inputs(
conn, scope.project.project_id, replay_context, args,
)
result = await location_demo_result(conn, scope.project.project_code, args)
await conn.commit()
if result is None:
async with scope.simulation_routing():
result = await calculate_replay_burst_location(
parameters,
network=scope.project.project_code,
username=scope.user.username,
)
data = {
"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": result,
}
origin = "synthetic"
gaps = [result["notice"]] if result.get("notice") else []
elif name == "run_burst_location" and (
demo_result := await location_demo_result(conn, scope.project.project_code, args)
) is not None:
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": demo_result}
origin = "synthetic"
gaps = [demo_result["notice"]] if demo_result.get("notice") else []
elif tool.computation:
from app.mcp.analysis_service import TIMESCALE_CALCULATIONS, calculate
# Context/receipt reads may have started an implicit transaction. Release
# it before potentially long CPU/IO work; evidence uses a new transaction.
await conn.commit()
async with (
scope.simulation_routing()
if name in TIMESCALE_CALCULATIONS
else nullcontext()
):
data = await calculate(
name, args, network=scope.project.project_code,
username=scope.user.username,
)
data = {
"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
"result": data,
}
gaps = [{
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination. Selected series only; opt-in impact coverage uses all native nodes over the simulation window, with an explicit display threshold and existing model initial quality/reactions, not a drinking-water safety limit.",
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
"optimize_sensor_placement": "Candidate pressure monitoring locations, not a saved deployment scheme or a guarantee of contaminant source observability.",
"run_leakage_identification": "DMA leakage estimates depend on the total leakage constraint and aligned model/SCADA time; they do not confirm an exact leak location.",
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas; selected series only. Opt-in impact coverage compares all native nodes at the start time with existing online results, using current model settings for baseline units. This is an estimated pre-intervention range, not a confirmed outage or historical model reconstruction.",
}[name]]
if name == "calculate_demand_allocation":
truncated = data["result"]["has_more"] or args.offset > 0
page = PageInfo(limit=args.limit, offset=args.offset, total=data["result"]["total"], has_more=data["result"]["has_more"])
if getattr(args, "data_source", None) == "simulation":
gaps.append("Input is an existing analysis run and model baseline, not observed SCADA data.")
elif name == "query_nearby_elements":
rows = await nearby_elements(
conn, args.element_type, center=args.center, radius_m=args.radius_m,
limit=args.limit + 1, offset=args.offset,
)
items, page, truncated = page_result(rows, args)
truncated = truncated or args.offset > 0
data = {
"source": "model_network", "element_type": args.element_type,
"center": list(args.center), "center_crs": "EPSG:4326",
"radius_m": args.radius_m, "distance_method": "WGS84_spheroid",
"items": items,
}
units = {"distance_m": "m", "radius_m": "m"}
gaps = ["Spatial proximity does not establish hydraulic connectivity or a supply relationship"]
elif name in {"get_element_properties", "list_elements"}:
rows = await reads.asset_rows(
conn,
args.element_type,
element_id=getattr(args, "element_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_element_properties":
if not rows:
raise HTTPException(404, "Element was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name in {"list_scada_devices", "get_scada_device"}:
rows = await reads.scada_devices(
conn,
device_id=getattr(args, "device_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_scada_device":
if not rows:
raise HTTPException(404, "SCADA device was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name == "get_network_schema":
from app.services import tjnetwork
mapping = {
"option": "get_option_v3_schema",
"scada-device": "get_scada_info_schema",
"pump-energy": "get_pump_energy_schema",
}
function = getattr(
tjnetwork, mapping.get(args.kind, "get_" + args.kind.replace("-", "_") + "_schema")
)
data = await asyncio.to_thread(function, scope.project.project_code)
elif name == "get_network_options":
if args.kind == "pump-energy":
rows = await reads.read_rows(
conn,
"SELECT price,pattern_id AS pattern,efficiency_curve_id AS effic FROM network.pump_energy_settings WHERE pump_id=%s",
(args.pump_id,),
)
data = {"pump": args.pump_id, "price": None, "pattern": None, "effic": None}
if rows:
data.update(rows[0])
else:
table = {
"time": "time_settings",
"energy": "energy_settings",
"network": "simulation_settings",
}[args.kind]
where = " WHERE engine_version='v3'" if args.kind == "network" else ""
rows = await reads.read_rows(
conn,
"SELECT key,value FROM network."
+ table
+ where
+ " ORDER BY key LIMIT 501",
)
data = {str(row["key"]): str(row["value"]) for row in rows[:500]}
truncated = len(rows) > 500
elif name in {
"query_scada_readings",
"query_model_baseline_series",
"get_model_baseline_at_time",
"query_model_baseline_field",
"query_analysis_series",
"query_analysis_values",
}:
source = (
"scada"
if name == "query_scada_readings"
else "analysis"
if name.startswith("query_analysis")
else "realtime"
)
ids = (
getattr(args, "device_ids", None)
or getattr(args, "element_ids", None)
or ([args.element_id] if hasattr(args, "element_id") else [])
)
async with scope.timescale() as ts:
rows = await reads.series_rows(
ts,
source=source,
element_type=getattr(args, "element_type", "junction"),
ids=ids,
start=getattr(args, "start", None),
end=getattr(args, "end", None),
time=getattr(args, "time", None),
field=getattr(args, "field", None),
run_id=getattr(args, "run_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_model_baseline_at_time":
data = rows[:1]
else:
data, page, truncated = page_result(rows, args)
if source != "scada":
async with conn.transaction():
units = await NetworkSettingsRepository.get_result_units(conn)
if source == "analysis":
run = await AnalysisRepository.get_run(conn, args.run_id)
if not run:
raise HTTPException(404, "Analysis run was not found")
recorded = (run.get("parameters") or {}).get("result_units")
if isinstance(recorded, dict):
units = recorded
else:
gaps.append(
"Analysis units inferred from current network settings"
)
if hasattr(args, "field") and args.field not in units:
gaps.append(f"Unit metadata is unavailable for field {args.field}")
else:
# Keep the selected field in durable evidence; `value` alone cannot
# distinguish raw observations from cleaned observations.
data = [{**row, args.field: row["value"]} if "value" in row else row for row in data]
metadata = [
r
for device in ids
for r in await reads.scada_devices(conn, device_id=device, limit=1)
]
units = {
r["device_id"]: r["measurement_unit"]
for r in metadata
if r["device_id"] in ids
}
elif name in {"list_analysis_runs", "get_analysis_run", "list_analysis_results"}:
async with conn.transaction():
if name == "get_analysis_run":
data = await AnalysisRepository.get_run(conn, args.run_id)
if not data:
raise HTTPException(404, "Analysis run was not found")
elif name == "list_analysis_runs":
# Fetch one additional row with the repository's supported summary-page bound.
rows = await AnalysisRepository.list_run_summaries(
conn, limit=args.limit + 1, offset=args.offset
)
data, page, truncated = page_result(rows, args)
else:
rows = await AnalysisRepository.list_results(
conn,
args.run_id,
args.result_type,
limit=args.limit,
lookahead=True,
offset=args.offset,
)
data, page, truncated = page_result(rows, args)
elif name in {"list_sensor_placement_runs", "get_sensor_placement_run"}:
rows = await reads.sensor_runs(
conn,
run_id=getattr(args, "run_id", None),
limit=getattr(args, "limit", 1) + 1,
offset=getattr(args, "offset", 0),
)
if name == "get_sensor_placement_run":
if not rows:
raise HTTPException(404, "Sensor placement run was not found")
data = rows[0]
else:
data, page, truncated = page_result(rows, args)
elif name == "query_element_history":
from app.domain.schemas.timeseries_history import ElementHistoryQuery
from app.services.timeseries_history import TimeseriesHistoryService
query = ElementHistoryQuery(
start_time=args.start,
end_time=args.end,
mode=args.mode,
run_id=args.run_id,
elements=[e.model_dump() for e in args.elements],
)
async with scope.timescale() as ts, conn.transaction(), ts.transaction():
result = await TimeseriesHistoryService.query(
ts, conn, query, row_limit=args.limit, row_offset=args.offset
)
data = result.model_dump(mode="json")
truncated = result.truncated
page = PageInfo(limit=args.limit, offset=args.offset, has_more=truncated)
truncated = truncated or args.offset > 0
elif name in {"get_element_gis_properties", "get_source_gis_properties"}:
async with conn.transaction():
if name == "get_element_gis_properties":
data = await GisAttributeRepository.list_element_groups(
conn, args.element_type, args.element_id
)
else:
data = await GisAttributeRepository.get_source_feature_group(
conn, args.layer_key, args.source_fid
)
if data is None:
raise HTTPException(404, "Source GIS feature was not found")
elif name == "get_task_context":
if args.section in {"summary", "skill"}:
data = await reads.task_context_summary(conn, project_id=binding.project_id,
task_id=binding.task_id, report_revision=args.report_revision)
skill = read_work_order_skill(
data.get("task", {}).get("workflow"), include_content=args.section == "skill",
replay_context=replay_context,
)
if args.section == "skill":
data = {"section": "skill", "skill": skill}
else:
data["handling_skill"] = skill
data["display_time_zone"] = "Asia/Shanghai"
data["approved_data_scopes"] = binding.task_scope_cap
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
truncated = True
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
if skill.get("name") == "water-pollution-event":
data["source_trace_capability"] = {
"available": "run_contaminant_source_trace" in binding.tool_cap,
"tool": "run_contaminant_source_trace",
"algorithm_status": "provisional",
"required_inputs": ["candidate_node_ids", "start", "end"],
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
"source_control", "analyze_valve_isolation", "generate_node_coverage",
"analyze_flushing", "field_feedback", "clearance_verification", "report"],
"work_order_wait_blocks_analysis": False,
"simulation_parameters": {"include_impact_coverage": True},
}
if (skill.get("name") == "scada-anomaly" and replay_context
and "run_burst_location" in binding.tool_cap):
from app.services.burst_location_demo import location_demo_context
data["location_capability"] = location_demo_context(
getattr(scope.project, "project_code", None), replay_context,
)
else:
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
total = projection["total"]
if args.section in {"evidence", "actions", "access_requests", "questions"} and not args.data_path:
total = projection["snapshot_metadata"].get("total", total)
if projection["value_type"] in {"array", "object", "string"}:
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
elif args.offset:
raise HTTPException(422, "Scalar task detail cannot use an offset")
truncated = bool(projection.get("nested") or args.offset or page and page.has_more)
data = {"section": args.section, "selected_path": args.data_path, "value": projection["value"], "metadata": projection["snapshot_metadata"]}
if projection.get("nested"):
gaps.append("Nested values have data_path descriptors; select a descriptor path for its values")
elif name == "get_task_evidence":
projection = await reads.evidence_page(
conn, project_id=scope.project.project_id, task_id=binding.task_id,
evidence_id=args.evidence_id, data_path=args.data_path,
limit=args.limit, offset=args.offset, mcp_sources=list(TOOLS),
full_snapshot=full_snapshot,
)
document = projection if full_snapshot else projection["snapshot_metadata"]
origin = document["origin"]
source_ref = document["source_ref"]
evidence_id = args.evidence_id
truncated = document.get("truncated", False)
if full_snapshot:
data = document
else:
selected = projection["value"]
if projection["value_type"] in {"array", "object", "string"}:
total = projection["total"]
page = PageInfo(
limit=args.limit,
offset=args.offset,
total=total,
has_more=args.offset + args.limit < total,
)
truncated = truncated or page.has_more or args.offset > 0
elif args.offset:
raise HTTPException(422, "Scalar evidence cannot use an offset")
if projection.get("nested"):
truncated = True
gaps.append(
"Nested collections have data_path descriptors; query a descriptor path for their values"
)
data = {
"snapshot_metadata": document,
"query_metadata": projection["query_metadata"],
"selected_path": args.data_path,
"value": selected,
}
elif name == "get_workbench_runtime":
from app.services.workbench_runtime import read_workbench_runtime
data = (await read_workbench_runtime(scope.project, conn)).model_dump(
mode="json"
)
elif name == "query_peripheral_data":
from app.mcp.peripheral import read_peripheral_query
snapshot = await read_peripheral_query(scope, args)
data = snapshot.data
origin = snapshot.origin
truncated = snapshot.truncated
else:
raise ValueError("query handler is not registered")
window = (
{"start": args.start, "end": args.end}
if hasattr(args, "start") and args.start is not None
else None
)
data, nonfinite = finite_data(jsonable_encoder(data))
semantics = (
"comparison"
if name == "query_element_history" and args.mode != "observed"
else tool.semantics
)
return QueryResult(
source=name,
source_ref=source_ref,
evidence_id=evidence_id,
queried_at=datetime.now(timezone.utc),
semantics=semantics,
data=data,
data_gaps=gaps
+ (
[f"{nonfinite} non-finite numeric values were returned as null"]
if nonfinite
else []
),
page=page,
window=window,
units=units,
truncated=truncated,
origin=origin,
)