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,59 @@
|
||||
"""Private, durable messages between an ordinary chat and one investigation run."""
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, StrictModel
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||
|
||||
|
||||
MessageText = Annotated[str, Field(min_length=1, max_length=12000, pattern=r"\S")]
|
||||
MessageStatus = Literal["pending", "enqueued", "delivered", "failed"]
|
||||
|
||||
|
||||
class AgentMessageCreate(StrictModel):
|
||||
request_id: UUID
|
||||
task_id: UUID
|
||||
run_id: UUID
|
||||
source_session_id: NonEmptyStr
|
||||
text: MessageText
|
||||
|
||||
|
||||
class AgentMessageReply(StrictModel):
|
||||
request_id: UUID
|
||||
source_session_id: NonEmptyStr
|
||||
text: MessageText
|
||||
|
||||
|
||||
class AgentMessageDelivery(StrictModel):
|
||||
status: Literal["enqueued", "delivered", "failed"]
|
||||
target_session_id: NonEmptyStr | None = None
|
||||
native_message_id: NonEmptyStr | None = None
|
||||
failure_detail: Annotated[str, Field(max_length=500)] | None = None
|
||||
|
||||
|
||||
class AgentMessage(StrictModel):
|
||||
project_id: UUID
|
||||
message_id: UUID
|
||||
request_id: UUID
|
||||
task_id: UUID
|
||||
run_id: UUID
|
||||
source_session_id: NonEmptyStr
|
||||
source_owner_id: UUID
|
||||
conversation_owner_id: UUID
|
||||
target_session_id: NonEmptyStr | None = None
|
||||
direction: Literal["to_task", "to_chat"]
|
||||
in_reply_to: UUID | None = None
|
||||
text: MessageText
|
||||
status: MessageStatus
|
||||
native_message_id: NonEmptyStr
|
||||
failure_detail: str | None = None
|
||||
created_at: WorkbenchDateTime
|
||||
updated_at: WorkbenchDateTime
|
||||
delivered_at: WorkbenchDateTime | None = None
|
||||
|
||||
|
||||
class AgentMessagePage(StrictModel):
|
||||
items: list[AgentMessage]
|
||||
next_cursor: str | None = None
|
||||
@@ -11,9 +11,48 @@ from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||
TaskDataSource = Literal[
|
||||
"workbench_runtime", "scada_devices", "scada_readings", "peripheral_points",
|
||||
"peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
|
||||
|
||||
]
|
||||
|
||||
|
||||
McpSnapshotSource = Literal[
|
||||
'run_simulation',
|
||||
'run_contaminant_simulation',
|
||||
'analyze_valve_closure',
|
||||
'analyze_flushing',
|
||||
'analyze_pressure_regulation',
|
||||
'optimize_sensor_placement',
|
||||
'run_burst_detection',
|
||||
'run_burst_location',
|
||||
'analyze_valve_isolation',
|
||||
'run_leakage_identification',
|
||||
'get_element_properties',
|
||||
'list_elements',
|
||||
'get_network_options',
|
||||
'get_network_schema',
|
||||
'list_scada_devices',
|
||||
'get_scada_device',
|
||||
'query_scada_readings',
|
||||
'query_model_baseline_series',
|
||||
'get_model_baseline_at_time',
|
||||
'query_model_baseline_field',
|
||||
'query_analysis_series',
|
||||
'query_analysis_values',
|
||||
'list_analysis_runs',
|
||||
'get_analysis_run',
|
||||
'list_analysis_results',
|
||||
'list_sensor_placement_runs',
|
||||
'get_sensor_placement_run',
|
||||
'query_element_history',
|
||||
'get_element_gis_properties',
|
||||
'get_source_gis_properties',
|
||||
'get_workbench_runtime',
|
||||
'get_task_context',
|
||||
'query_peripheral_data',
|
||||
'get_task_evidence',
|
||||
]
|
||||
TaskSnapshotSource = TaskDataSource | McpSnapshotSource
|
||||
|
||||
class TaskDataQuery(StrictModel):
|
||||
source: TaskDataSource
|
||||
device_ids: list[NonEmptyStr] = Field(default_factory=list, max_length=20)
|
||||
@@ -36,7 +75,7 @@ class TaskDataQuery(StrictModel):
|
||||
|
||||
|
||||
class TaskDataSnapshot(StrictModel):
|
||||
source: TaskDataSource
|
||||
source: TaskSnapshotSource
|
||||
source_ref: NonEmptyStr
|
||||
queried_at: WorkbenchDateTime
|
||||
origin: Literal["system_data", "synthetic"]
|
||||
|
||||
@@ -202,11 +202,24 @@ class InvestigationDataGap(StrictModel):
|
||||
blocking: StrictBool
|
||||
|
||||
|
||||
TaskMapLayer = Literal["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]
|
||||
TaskFeatureId = Annotated[str, Field(min_length=1, max_length=128)]
|
||||
|
||||
|
||||
class InvestigationMapTarget(StrictModel):
|
||||
source_id: TaskMapLayer
|
||||
feature_id: TaskFeatureId
|
||||
|
||||
|
||||
class InvestigationDiagramNodeRef(StrictModel):
|
||||
node_id: Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_-]{0,79}$")]
|
||||
label: NonEmptyStr
|
||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||
action_refs: list[UUID] = Field(default_factory=list)
|
||||
status: Literal[
|
||||
"pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"
|
||||
] | None = None
|
||||
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class InvestigationDiagram(StrictModel):
|
||||
@@ -271,6 +284,22 @@ class InvestigationReportInput(InvestigationReport):
|
||||
data_gaps: list[InvestigationDataGap]
|
||||
|
||||
|
||||
class InvestigationProgress(StrictModel):
|
||||
"""A complete reading snapshot, not a plan or a business result."""
|
||||
|
||||
summary: Annotated[str, Field(min_length=1, max_length=20000)]
|
||||
diagram: InvestigationDiagram | None = None
|
||||
|
||||
|
||||
class InvestigationProgressSnapshot(StrictModel):
|
||||
revision: PositiveInt
|
||||
run_id: UUID
|
||||
execution_generation: PositiveInt
|
||||
published_at: WorkbenchDateTime
|
||||
source_session_id: NonEmptyStr
|
||||
progress: InvestigationProgress
|
||||
|
||||
|
||||
class ExecutionPlanSnapshot(StrictModel):
|
||||
revision: PositiveInt
|
||||
execution_generation: PositiveInt | None = None
|
||||
@@ -306,10 +335,11 @@ class AgentArtifactsCommand(StrictModel):
|
||||
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
|
||||
plan: ExecutionPlanInput | None = None
|
||||
report: InvestigationReportInput | None = None
|
||||
progress: InvestigationProgress | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_artifact(self):
|
||||
if not self.evidence and self.plan is None and self.report is None:
|
||||
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
|
||||
raise ValueError("at least one artifact must be supplied")
|
||||
return self
|
||||
|
||||
@@ -507,6 +537,7 @@ class OperationalTaskSummary(StrictModel):
|
||||
workflow: NonEmptyStr
|
||||
lifecycle_status: TaskLifecycleStatus = "open"
|
||||
active_run: OperationalTaskRun | None = None
|
||||
latest_run: OperationalTaskRun | None = None
|
||||
# Compatibility fields for the staged 014 -> 015 rollout. New consumers
|
||||
# must use lifecycle_status and active_run.
|
||||
status: TaskStatus
|
||||
@@ -541,6 +572,7 @@ class OperationalTaskPermissions(StrictModel):
|
||||
class OperationalTaskDetail(OperationalTaskSummary):
|
||||
latest_plan: ExecutionPlanSnapshot | None
|
||||
latest_report: InvestigationReportRevision | None
|
||||
latest_progress: InvestigationProgressSnapshot | None = None
|
||||
evidence: list[EvidenceRecord]
|
||||
actions: list[HumanApprovalAction]
|
||||
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
|
||||
@@ -598,6 +630,8 @@ class OperationalTaskRetryCommand(StrictModel):
|
||||
|
||||
class RecoverExpiredLeasesCommand(StrictModel):
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
task_type: TaskType | None = None
|
||||
task_created_from: WorkbenchDateTime | None = None
|
||||
|
||||
|
||||
class RecoverExpiredLeasesResult(StrictModel):
|
||||
@@ -609,6 +643,94 @@ class AgentArtifactsResult(StrictModel):
|
||||
task: OperationalTaskDetail
|
||||
plan_revision: PositiveInt | None
|
||||
report_revision: PositiveInt | None
|
||||
progress_revision: PositiveInt | None = None
|
||||
|
||||
|
||||
class TaskSafeChartSeries(StrictModel):
|
||||
name: Annotated[str, Field(max_length=128)]
|
||||
data: Annotated[list[Annotated[float, Field(strict=True, allow_inf_nan=False)]], Field(min_length=1, max_length=100)]
|
||||
|
||||
|
||||
class TaskSafeChart(StrictModel):
|
||||
title: Annotated[str, Field(max_length=200)]
|
||||
chart_type: Literal["line", "bar"]
|
||||
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
|
||||
series: Annotated[list[TaskSafeChartSeries], Field(min_length=1, max_length=8)]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_alignment(self):
|
||||
if any(len(series.data) != len(self.x_data) for series in self.series):
|
||||
raise ValueError("chart series must align with x_data")
|
||||
return self
|
||||
|
||||
|
||||
class TaskHighlightFeatures(StrictModel):
|
||||
action: Literal["highlight_features"]
|
||||
layer: TaskMapLayer
|
||||
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||
effect: Literal["steady", "pulse"] = "steady"
|
||||
|
||||
|
||||
class TaskClearHighlights(StrictModel):
|
||||
action: Literal["clear_highlights"]
|
||||
|
||||
|
||||
class TaskOpenChart(StrictModel):
|
||||
action: Literal["open_chart"]
|
||||
chart_id: TaskFeatureId
|
||||
chart: TaskSafeChart
|
||||
evidence_refs: Annotated[list[NonEmptyStr], Field(min_length=1, max_length=100)]
|
||||
|
||||
|
||||
class TaskCloseChart(StrictModel):
|
||||
action: Literal["close_chart"]
|
||||
chart_id: TaskFeatureId
|
||||
|
||||
|
||||
class TaskSetOverlay(StrictModel):
|
||||
action: Literal["set_overlay"]
|
||||
overlay_id: TaskFeatureId
|
||||
title: Annotated[str, Field(min_length=1, max_length=200)]
|
||||
mode: Literal["geometry", "heatmap"]
|
||||
evidence_id: NonEmptyStr
|
||||
|
||||
|
||||
class TaskClearOverlay(StrictModel):
|
||||
action: Literal["clear_overlay"]
|
||||
overlay_id: TaskFeatureId
|
||||
|
||||
|
||||
TaskPresentationCommand = Annotated[
|
||||
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
|
||||
Field(discriminator="action"),
|
||||
]
|
||||
|
||||
|
||||
class TaskPresentationEventCommand(StrictModel):
|
||||
request_id: UUID
|
||||
run_id: UUID
|
||||
source_session_id: NonEmptyStr
|
||||
command: TaskPresentationCommand
|
||||
|
||||
|
||||
class TaskPresentationEventResult(StrictModel):
|
||||
recorded: Literal[True] = True
|
||||
event_seq: PositiveInt
|
||||
run_id: UUID
|
||||
execution_generation: PositiveInt
|
||||
|
||||
|
||||
class TaskPresentationActiveRun(StrictModel):
|
||||
run_id: UUID
|
||||
generation: PositiveInt
|
||||
status: OperationalTaskRunStatus
|
||||
|
||||
|
||||
class TaskPresentationCursor(StrictModel):
|
||||
task_id: UUID
|
||||
execution_generation: PositiveInt
|
||||
active_run: TaskPresentationActiveRun | None
|
||||
event_cursor: int = Field(ge=0)
|
||||
|
||||
|
||||
class TaskDataQueryResult(StrictModel):
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
MAX_PLACE_SEARCH_RESULTS = 10
|
||||
|
||||
|
||||
class PlaceSearchRequest(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
||||
|
||||
keyword: str = Field(..., min_length=1, max_length=120)
|
||||
|
||||
|
||||
class PlaceSearchItem(BaseModel):
|
||||
id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
address: str
|
||||
longitude: float = Field(..., ge=-180, le=180, allow_inf_nan=False)
|
||||
latitude: float = Field(..., ge=-90, le=90, allow_inf_nan=False)
|
||||
category: str | None = None
|
||||
district: Literal["奉贤区"] = "奉贤区"
|
||||
|
||||
|
||||
class PlaceSearchResponse(BaseModel):
|
||||
items: list[PlaceSearchItem] = Field(..., max_length=MAX_PLACE_SEARCH_RESULTS)
|
||||
total: int = Field(..., ge=0, le=MAX_PLACE_SEARCH_RESULTS)
|
||||
truncated: bool = False
|
||||
region: Literal["奉贤区"] = "奉贤区"
|
||||
@@ -86,3 +86,4 @@ class ElementHistorySeries(BaseModel):
|
||||
|
||||
class ElementHistoryResponse(BaseModel):
|
||||
series: list[ElementHistorySeries]
|
||||
truncated: bool = Field(default=False, exclude=True)
|
||||
|
||||
@@ -19,6 +19,8 @@ WorkbenchEventTopic = Literal[
|
||||
"operational.run.queued",
|
||||
"operational.run.changed",
|
||||
"operational.report.published",
|
||||
"operational.progress.changed",
|
||||
"operational.presentation.requested",
|
||||
"operational.action.changed",
|
||||
"operational.notification.changed",
|
||||
"workbench.alert.changed",
|
||||
|
||||
Reference in New Issue
Block a user