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
+66 -5
View File
@@ -24,6 +24,7 @@ from app.auth.project_dependencies import (
get_project_pg_connection,
)
from app.domain.domain_errors import DomainError
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
@@ -70,6 +71,9 @@ from app.domain.schemas.operational_task import (
TaskStatus,
TaskDataQueryResult,
TaskType,
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import (
@@ -92,10 +96,9 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
def _task_permissions(ctx: ProjectContext) -> set[str]:
permissions = set(permissions_for_context(ctx))
if ctx.system_role == "service":
permissions.difference_update({SCADA_VIEW, SIMULATION_VIEW})
return permissions
# Investigations use preconfigured project access, including service accounts.
# Do not force an already-authorized reader through legacy per-task delegation.
return set(permissions_for_context(ctx))
def _raise_domain(exc: DomainError):
@@ -176,6 +179,8 @@ async def list_operational_task_runs(
run_status: Literal[
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
] | None = Query(default=None, alias="status"),
task_type: TaskType | None = None,
task_created_from: WorkbenchDateTime | None = None,
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
@@ -193,6 +198,8 @@ async def list_operational_task_runs(
try:
items, next_cursor = await _service(ctx, conn).list_runs(
status=run_status,
task_type=task_type,
task_created_from=task_created_from,
limit=limit,
cursor_created_at=cursor_created_at,
cursor_run_id=cursor_run_id,
@@ -202,6 +209,32 @@ async def list_operational_task_runs(
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs", response_model=OperationalTaskRunPage)
async def list_task_run_history(
task_id: UUID,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return OperationalTaskRunPage(items=await _service(ctx, conn).task_runs(task_id, limit, offset))
except DomainError as exc:
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/runs/{run_id}", response_model=OperationalTaskRun)
async def get_task_run(
task_id: UUID, run_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).task_run(task_id, run_id)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/schedules",
response_model=OperationalTaskSchedule,
@@ -272,7 +305,10 @@ async def recover_operational_task_leases(
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).recover_expired_leases(command.limit)
return await _service(ctx, conn).recover_expired_leases(
command.limit, task_type=command.task_type,
task_created_from=command.task_created_from,
)
except DomainError as exc:
_raise_domain(exc)
@@ -473,6 +509,31 @@ async def publish_operational_task_artifacts(
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/presentation-cursor", response_model=TaskPresentationCursor)
async def get_operational_task_presentation_cursor(
task_id: UUID,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).presentation_cursor(task_id)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
async def publish_operational_task_presentation_event(
task_id: UUID,
command: TaskPresentationEventCommand,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).publish_presentation_event(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post(
"/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult
)