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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user