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:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
+59
View File
@@ -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
+40 -1
View File
@@ -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"]
+123 -1
View File
@@ -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):
+28
View File
@@ -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["奉贤区"] = "奉贤区"
+1
View File
@@ -86,3 +86,4 @@ class ElementHistorySeries(BaseModel):
class ElementHistoryResponse(BaseModel):
series: list[ElementHistorySeries]
truncated: bool = Field(default=False, exclude=True)
+2
View File
@@ -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",