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:
@@ -0,0 +1,293 @@
|
||||
"""Strict business inputs and provenance envelopes shared by MCP handlers."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||
|
||||
Id = Annotated[str, Field(min_length=1, max_length=160, pattern=r"^\S(?:.*\S)?$")]
|
||||
ElementType = Literal["junction", "pipe", "valve", "pump", "tank", "reservoir"]
|
||||
SeriesType = Literal["junction", "pipe"]
|
||||
|
||||
|
||||
class StrictInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
context_handle: Annotated[str, Field(min_length=32, max_length=128)]
|
||||
|
||||
|
||||
class PageInput(StrictInput):
|
||||
limit: int = Field(default=20, ge=1, le=500)
|
||||
offset: int = Field(default=0, ge=0, le=10000)
|
||||
|
||||
|
||||
class TaskContextInput(PageInput):
|
||||
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
|
||||
report_revision: int | None = Field(default=None, ge=1)
|
||||
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def summary_is_fixed(self):
|
||||
if self.section in {"summary", "skill"} and (self.data_path or self.offset):
|
||||
raise ValueError("Select a detail section before using data_path or offset")
|
||||
return self
|
||||
|
||||
|
||||
class ElementInput(StrictInput):
|
||||
element_type: ElementType
|
||||
element_id: Id
|
||||
|
||||
|
||||
class ElementPageInput(PageInput):
|
||||
element_type: ElementType
|
||||
|
||||
|
||||
class NearbyElementsInput(PageInput):
|
||||
element_type: ElementType = "pipe"
|
||||
center: tuple[
|
||||
Annotated[float, Field(ge=-180, le=180, allow_inf_nan=False)],
|
||||
Annotated[float, Field(ge=-90, le=90, allow_inf_nan=False)],
|
||||
] = Field(description="EPSG:4326 [longitude, latitude], directly from geocode.center")
|
||||
radius_m: float = Field(default=200, gt=0, le=10000, allow_inf_nan=False)
|
||||
limit: int = Field(default=5, ge=1, le=100)
|
||||
|
||||
|
||||
class OptionsInput(StrictInput):
|
||||
kind: Literal["time", "energy", "pump-energy", "network"]
|
||||
pump_id: Id | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def pump_required(self):
|
||||
if self.kind == "pump-energy" and not self.pump_id:
|
||||
raise ValueError("pump_id is required for pump-energy")
|
||||
if self.kind != "pump-energy" and self.pump_id:
|
||||
raise ValueError("pump_id is only valid for pump-energy")
|
||||
return self
|
||||
|
||||
|
||||
class SchemaInput(StrictInput):
|
||||
kind: Literal[
|
||||
"junction",
|
||||
"pipe",
|
||||
"valve",
|
||||
"pump",
|
||||
"tank",
|
||||
"reservoir",
|
||||
"time",
|
||||
"energy",
|
||||
"pump-energy",
|
||||
"option",
|
||||
"scada-device",
|
||||
]
|
||||
|
||||
|
||||
class DeviceInput(StrictInput):
|
||||
device_id: Id
|
||||
|
||||
|
||||
class WindowInput(PageInput):
|
||||
start: datetime
|
||||
end: datetime
|
||||
limit: int = Field(default=20, ge=1, le=10000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def window_valid(self):
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||
raise ValueError("timestamps require explicit timezone")
|
||||
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
|
||||
raise ValueError("time window must be positive and at most 24 hours")
|
||||
return self
|
||||
|
||||
|
||||
class ScadaInput(WindowInput):
|
||||
device_ids: list[Id] = Field(min_length=1, max_length=20)
|
||||
field: Literal["monitored_value", "cleaned_value"] = "monitored_value"
|
||||
|
||||
|
||||
class BaselineSeriesInput(WindowInput):
|
||||
element_type: SeriesType
|
||||
element_ids: list[Id] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TimeInput(StrictInput):
|
||||
time: datetime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def timezone_required(self):
|
||||
if self.time.tzinfo is None:
|
||||
raise ValueError("timestamp requires explicit timezone")
|
||||
return self
|
||||
|
||||
|
||||
class BaselineAtTimeInput(TimeInput):
|
||||
element_type: SeriesType
|
||||
element_id: Id
|
||||
|
||||
|
||||
class BaselineFieldInput(TimeInput):
|
||||
element_type: SeriesType
|
||||
field: Id
|
||||
limit: int = Field(default=20, ge=1, le=500)
|
||||
offset: int = Field(default=0, ge=0, le=10000)
|
||||
|
||||
|
||||
class AnalysisSeriesInput(WindowInput):
|
||||
run_id: UUID
|
||||
element_type: SeriesType
|
||||
element_id: Id
|
||||
field: Id
|
||||
|
||||
|
||||
class AnalysisValuesInput(BaselineFieldInput):
|
||||
run_id: UUID
|
||||
|
||||
|
||||
class RunInput(StrictInput):
|
||||
run_id: UUID
|
||||
|
||||
|
||||
class RunsInput(PageInput):
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class ResultsInput(PageInput):
|
||||
run_id: UUID
|
||||
result_type: Id | None = None
|
||||
|
||||
|
||||
class HistoryTarget(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
element_type: SeriesType
|
||||
element_id: Id
|
||||
device_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20)
|
||||
|
||||
|
||||
class HistoryInput(WindowInput):
|
||||
elements: list[HistoryTarget] = Field(min_length=1, max_length=20)
|
||||
mode: Literal["observed", "realtime_comparison", "analysis_comparison"] = "observed"
|
||||
run_id: UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def analysis_required(self):
|
||||
if (self.mode == "analysis_comparison") != (self.run_id is not None):
|
||||
raise ValueError("run_id is required only for analysis_comparison")
|
||||
return self
|
||||
|
||||
|
||||
class SourceGisInput(StrictInput):
|
||||
layer_key: Annotated[str, Field(min_length=1, max_length=80)]
|
||||
source_fid: int = Field(ge=0, le=9223372036854775807)
|
||||
|
||||
|
||||
class PeripheralInput(PageInput):
|
||||
source: Literal[
|
||||
"peripheral_points",
|
||||
"peripheral_work_orders",
|
||||
"peripheral_observations",
|
||||
"peripheral_test_data",
|
||||
]
|
||||
point_id: Id | None = None
|
||||
start: datetime | None = None
|
||||
end: datetime | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def observation_window(self):
|
||||
if self.source == "peripheral_observations":
|
||||
if not self.point_id or self.start is None or self.end is None:
|
||||
raise ValueError("observations require point_id, start and end")
|
||||
if (
|
||||
self.start.tzinfo is None
|
||||
or self.end.tzinfo is None
|
||||
or not timedelta(0) < self.end - self.start <= timedelta(days=1)
|
||||
):
|
||||
raise ValueError(
|
||||
"observation window requires timezone and at most 24 hours"
|
||||
)
|
||||
elif (
|
||||
self.point_id is not None or self.start is not None or self.end is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"point_id and window are only valid for peripheral_observations"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class EvidenceInput(PageInput):
|
||||
evidence_id: Id
|
||||
data_path: list[Id] = Field(default_factory=list, max_length=8)
|
||||
|
||||
|
||||
class BindInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
native_session_id: Id
|
||||
purpose: Literal[
|
||||
"ordinary_chat", "task_execution", "task_reader", "task_collaboration"
|
||||
]
|
||||
task_id: UUID | None = None
|
||||
parent_session_id: Id | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def task_scope(self):
|
||||
if self.purpose == "ordinary_chat" and (self.task_id or self.parent_session_id):
|
||||
raise ValueError("ordinary chat cannot bind a task")
|
||||
if self.purpose != "ordinary_chat" and self.task_id is None:
|
||||
raise ValueError("task context requires task_id")
|
||||
if (self.purpose == "task_reader") != (self.parent_session_id is not None):
|
||||
raise ValueError("only a task reader requires parent_session_id")
|
||||
return self
|
||||
|
||||
|
||||
class BindResponse(BaseModel):
|
||||
context_handle: str
|
||||
expires_at: datetime
|
||||
task_id: UUID | None
|
||||
run_id: UUID | None
|
||||
|
||||
|
||||
class PageInfo(BaseModel):
|
||||
limit: int
|
||||
offset: int
|
||||
total: int | None = None
|
||||
has_more: bool
|
||||
|
||||
|
||||
class QueryResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
source: str
|
||||
source_ref: str
|
||||
queried_at: datetime
|
||||
origin: Literal["system_data", "synthetic"] = "system_data"
|
||||
semantics: Literal[
|
||||
"asset",
|
||||
"observed",
|
||||
"model_baseline",
|
||||
"analysis",
|
||||
"comparison",
|
||||
"task",
|
||||
"peripheral",
|
||||
]
|
||||
data: JsonValue
|
||||
page: PageInfo | None = None
|
||||
window: dict[str, datetime] | None = None
|
||||
units: dict[str, str] = Field(default_factory=dict)
|
||||
truncated: bool = False
|
||||
data_gaps: list[str] = Field(default_factory=list)
|
||||
evidence_id: str | None = None
|
||||
|
||||
|
||||
class BoundContext(BaseModel):
|
||||
context_handle: str
|
||||
project_id: UUID
|
||||
subject: str
|
||||
client_id: str
|
||||
tool_cap: list[str] = Field(default_factory=list)
|
||||
permission_cap: list[str] = Field(default_factory=list)
|
||||
task_scope_cap: list[str] = Field(default_factory=list)
|
||||
native_session_id: str
|
||||
purpose: str
|
||||
parent_session_id: str | None = None
|
||||
task_id: UUID | None = None
|
||||
run_id: UUID | None = None
|
||||
execution_request_id: UUID | None = None
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None = None
|
||||
Reference in New Issue
Block a user