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
+320
View File
@@ -35,6 +35,8 @@ from app.domain.schemas.operational_task import (
TaskAccessRequest,
WorkOrderDraftUpdateCommand,
WorkOrderConfirmDispatchCommand,
TaskPresentationEventCommand,
StoredEvidenceRecord,
)
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
@@ -265,6 +267,7 @@ class TaskRepository:
self.feedback = {}
self.plans = []
self.reports = []
self.progress = {}
self.notifications = []
self.locks = []
self.access_requests = {}
@@ -297,6 +300,12 @@ class TaskRepository:
async def get_latest_report(self, _task_id):
return self.reports[-1] if self.reports else None
async def get_latest_progress(self, task_id, generation):
return self.progress.get((task_id, generation))
async def save_progress(self, task_id, snapshot):
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
async def get_report(self, _task_id, revision):
return next((item for item in self.reports if item["revision"] == revision), None)
@@ -404,6 +413,9 @@ class RunTaskRepository(TaskRepository):
self.events.append(event)
return len(self.events)
async def current_event_cursor(self):
return len(self.events)
def _active_run(task, now):
return OperationalTaskRun(
@@ -415,6 +427,258 @@ def _active_run(task, now):
).model_dump(mode="json")
@pytest.mark.anyio
async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
first = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
"progress": {"summary": "正在核对输入,尚未形成结论。", "diagram": {
"title": "执行进展", "mermaid": "flowchart LR\nfirst[开始查询]",
"node_refs": [{"node_id": "first", "label": "开始查询", "status": "running",
"map_targets": [{"source_id": "pipes", "feature_id": "P-1"}]}],
}},
})
initial = await service.publish_artifacts(task["task_id"], first)
assert initial.progress_revision == 1
assert initial.task.latest_report is None and initial.task.latest_plan is None
assert initial.task.active_run.status == "running"
assert (await service.publish_artifacts(task["task_id"], first)).progress_revision == 1
assert len([event for event in repository.events if event["topic"] == "operational.progress.changed"]) == 1
replacement = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
"progress": {"summary": "重新核查另一条路径。", "diagram": {
"title": "新脉络", "mermaid": "flowchart TD\nother[核查新路径]",
"node_refs": [{"node_id": "other", "label": "核查新路径", "status": "running"}],
}},
})
updated = await service.publish_artifacts(task["task_id"], replacement)
assert updated.progress_revision == 2
assert [node.node_id for node in updated.task.latest_progress.progress.diagram.node_refs] == ["other"]
cleared = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
progress={"summary": "当前不需要图示。", "diagram": None},
))
assert cleared.progress_revision == 3
assert cleared.task.latest_progress.progress.diagram is None
assert not repository.notifications
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["progress", "presentation"])
@pytest.mark.parametrize("invalid", ["run", "session", "lease", "finished"])
async def test_reading_publications_reject_stale_execution_ownership(kind, invalid):
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
run = _active_run(task, now)
if invalid == "lease":
run["lease_expires_at"] = now.isoformat()
elif invalid == "finished":
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"],
"source_session_id": "other-session" if invalid == "session" else "session-1"}
with pytest.raises(DomainError):
if kind == "progress":
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
**identity, progress={"summary": "执行中"},
))
else:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
**identity, command={"action": "clear_highlights"},
))
assert repository.progress == {} and repository.events == []
@pytest.mark.anyio
async def test_progress_unknown_evidence_does_not_publish_or_authorize_actions():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError):
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
progress={"summary": "执行中", "diagram": {"title": "核查", "mermaid": "graph LR\nA[核查]",
"node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["unknown"]}]}},
))
assert not repository.progress
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
progress={"summary": "查询完成,正在判断"},
))
with pytest.raises(DomainError) as error:
await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="不能用进展授权动作",
payload={"title": "现场核查", "description": "核查", "work_order_type": "sampling"},
))
assert error.value.code == "ACTION_CONCLUSION_REQUIRED"
@pytest.mark.anyio
async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
baseline = await service.presentation_cursor(task["task_id"])
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "highlight_features", "layer": "pipes", "feature_ids": ["P-1"], "effect": "pulse"},
)
result = await service.publish_presentation_event(task["task_id"], command)
assert (await service.publish_presentation_event(task["task_id"], command)) == result
assert (await service.presentation_cursor(task["task_id"])).event_cursor == result.event_seq
assert repository.task["revision"] == task["revision"]
assert len(repository.events) == 1
assert repository.events[0]["metadata"]["command"]["effect"] == "pulse"
with pytest.raises(DomainError):
await service.presentation_cursor(uuid4())
@pytest.mark.anyio
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
origin="system_data", truncated=False,
data={"items": [{"device_id": "D-1", "longitude": 121.4, "latitude": 31.1}]})
evidence = StoredEvidenceRecord(evidence_id="verified-points", kind="system_data", summary="实际坐标",
source_label="SCADA", observed_at=now, recorded_at=now, recorded_by="server",
has_snapshot=True, data_snapshot=snapshot)
repository.evidence[evidence.evidence_id] = evidence.model_dump(mode="json")
identity = {"run_id": repository.run["run_id"], "source_session_id": "session-1"}
overlay = {"action": "set_overlay", "overlay_id": "points", "title": "监测点分布", "mode": "heatmap"}
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
))
emitted = repository.events[-1]["metadata"]["command"]["data"]
assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1]
assert emitted["features"][0]["properties"]["weight"] == 1
with pytest.raises(DomainError) as missing:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"},
))
assert missing.value.code == "EVIDENCE_NOT_FOUND"
repository.evidence[evidence.evidence_id]["data_snapshot"]["data"] = {
"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [121, 31]},
"properties": {"label": "x" * (512 * 1024)}}],
}
with pytest.raises(DomainError) as oversized:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
))
assert oversized.value.code == "PRESENTATION_TOO_LARGE"
assert len(repository.events) == 1
@pytest.mark.anyio
async def test_completed_investigation_resumes_once_for_linked_new_field_feedback():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="现场检测",
payload={"title": "现场检测", "description": "回填复测结果", "work_order_type": "sampling"},
))
child_id = str(uuid4())
repository.action = action.model_copy(update={
"approval_status": "approved", "execution_status": "succeeded",
}).model_dump(mode="json")
repository.action["receipt"] = {
"adapter": "peripheral_sample", "receipt_id": child_id, "submitted_at": now.isoformat(),
"completed_at": now.isoformat(), "external_status": "open", "detail": "工单已创建,现场未完成",
}
repository.run["status"] = "succeeded"
repository.task.update(status="completed", lifecycle_status="completed", agent_session_id=None,
agent_lease_expires_at=None)
identity = {"source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2,
"status": "completed", "occurred_at": now, "detail": "现场复测通过"}
report_before = repository.reports[-1].copy()
command = ExternalFeedbackCommand(request_id=uuid4(), **identity)
accepted = await service.external_feedback(task["task_id"], command)
assert accepted.task.execution_generation == 2 and accepted.task.active_run.status == "queued"
assert repository.reports[-1] == report_before
assert (await service.external_feedback(task["task_id"], command)).task.execution_generation == 2
# Source-version deduplication also survives a caller changing the request ID.
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
assert again.task.execution_generation == 2
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
repository.task.update(status="cancelled", lifecycle_status="cancelled")
with pytest.raises(DomainError) as cancelled:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), **{**identity, "source_revision": 3},
))
assert cancelled.value.code == "TASK_NOT_ACTIONABLE"
assert repository.task["execution_generation"] == 2
@pytest.mark.anyio
async def test_completed_investigation_rejects_unlinked_or_initial_child_feedback():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=None)
task.update(status="completed", lifecycle_status="completed")
run = _active_run(task, now)
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as unlinked:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}", source_revision=2,
status="completed", occurred_at=now, detail="无关联的工单",
))
assert unlinked.value.code == "EXTERNAL_SOURCE_MISMATCH"
assert repository.task["execution_generation"] == 1 and repository.feedback == {}
child_id = str(uuid4())
repository.action = {"kind": "create_work_order", "receipt": {
"adapter": "peripheral_sample", "receipt_id": child_id,
}}
with pytest.raises(DomainError) as initial:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{child_id}", source_revision=1,
status="assigned", occurred_at=now, detail="工单创建,尚未回填",
))
assert initial.value.code == "INITIAL_CHILD_FEEDBACK"
assert repository.feedback == {}
@pytest.mark.anyio
async def test_original_complaint_feedback_advances_its_source_revision():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
source_id = str(uuid4())
task = _task_document(context, now, lease=None)
task.update(status="completed", lifecycle_status="completed",
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
run = _active_run(task, now)
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
accepted = await service.external_feedback(task["task_id"], command)
assert accepted.task.source.external_revision == 2
assert accepted.task.execution_generation == 2
with pytest.raises(DomainError) as stale:
await service.external_feedback(task["task_id"], command.model_copy(update={"request_id": uuid4(), "source_revision": 1}))
assert stale.value.code == "STALE_EXTERNAL_REVISION"
@pytest.mark.anyio
async def test_run_heartbeat_does_not_change_task_business_revision():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
@@ -804,6 +1068,62 @@ async def test_report_history_and_notifications_keep_selected_and_current_refere
assert other_task.value.status == 404
@pytest.mark.anyio
async def test_final_report_archives_its_progress_diagram_across_new_rounds():
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
progress={"summary": "正在整理判断", "diagram": diagram},
))
command = AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**_current_report(), "diagram": None},
)
await service.publish_artifacts(task["task_id"], command)
assert repository.reports[-1]["report"]["diagram"]["title"] == "调查过程"
assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "running"
assert command.report.diagram is None # Do not change the idempotency request.
repository.task["execution_generation"] = 2
detail = await service.detail(task["task_id"])
assert detail.latest_progress is None
assert detail.latest_report.report.diagram.title == "调查过程"
@pytest.mark.anyio
@pytest.mark.parametrize("mismatch", [None, "session", "future", "evidence", "cleared"])
async def test_legacy_report_diagram_reads_only_matching_prepublication_progress(mismatch):
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
repository.reports.append({
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
"published_at": now.isoformat(), "published_by": "agent", "report": _current_report(),
})
repository.progress[(task["task_id"], 1)] = {
"revision": 1, "execution_generation": 1, "run_id": repository.run["run_id"],
"source_session_id": "another-session" if mismatch == "session" else "session-1",
"published_at": (now + timedelta(seconds=1) if mismatch == "future" else now - timedelta(seconds=1)).isoformat(),
"progress": {"summary": "报告发布前的核查", "diagram": None if mismatch == "cleared" else {
"title": "已保存的过程图", "mermaid": "flowchart LR\nA[核查]",
"node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["not-in-report"] if mismatch == "evidence" else []}],
}},
}
repository.task["execution_generation"] = 2
detail = await service.detail(task["task_id"])
selected = await service.report(task["task_id"], 1)
history, _ = await service.list_reports(task["task_id"], 20, 0)
for report in (detail.latest_report, selected, history[0]):
assert (report.report.diagram is not None) is (mismatch is None)
assert repository.reports[0]["report"].get("diagram") is None # Read compatibility never rewrites history.
@pytest.mark.anyio
@pytest.mark.parametrize("node_ref,code", [
({"node_id": "A", "label": "依据", "evidence_refs": ["forged"]}, "EVIDENCE_NOT_FOUND"),