feat(investigation): preserve cumulative reference graphs

Accumulate investigation paths and preserve failed attempts across rounds. Keep graph annotations separate from execution state, synchronize the API contract, and consolidate documentation.
This commit is contained in:
2026-11-11 19:10:41 +08:00
parent e47c61c121
commit b853b61a0b
38 changed files with 2445 additions and 147 deletions
+315
View File
@@ -0,0 +1,315 @@
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import AgentArtifactsCommand
from app.domain.schemas.operational_task import InvestigationDiagram, InvestigationGraphUpdate
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.services.investigation_graph import update_investigation_graph
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import (
TaskRepository, _active_run, _context, _task_document,
)
@pytest.fixture
def anyio_backend():
return "asyncio"
def setup_service():
now = datetime(2026, 11, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repo = TaskRepository(task, _active_run(task, now))
return task, repo, OperationalTaskService(repo, context, set(), clock=lambda: now)
async def publish(service, repo, task_id, *, nodes=(), edges=(), **progress):
command = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
"run_id": repo.run["run_id"],
"progress": {"summary": "更新调查路线", "graph_update": {
"nodes": list(nodes), "edges": list(edges),
}, **progress},
})
return await service.publish_artifacts(task_id, command)
@pytest.mark.anyio
async def test_failed_path_is_retained_when_a_new_route_is_added():
task, repo, service = setup_service()
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "read", "label": "读取全量 SCADA", "status": "recorded"},
{"node_id": "locate_1", "label": "第一次候选定位", "status": "reference"},
], edges=[{"source": "read", "target": "locate_1"}])
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "locate_1", "label": "第一次候选定位", "status": "failed", "detail": "计算超时"},
{"node_id": "locate_2", "label": "调整窗口后重试", "status": "reference"},
], edges=[{"source": "locate_1", "target": "locate_2", "label": "调整后重试"}])
result = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "locate_2", "label": "调整窗口后重试", "status": "recorded"},
])
diagram = result.task.latest_progress.progress.diagram
assert [(n.node_id, n.status) for n in diagram.node_refs] == [
("read", "recorded"), ("locate_1", "failed"), ("locate_2", "recorded"),
]
assert diagram.node_refs[1].detail == "计算超时"
assert len(diagram.edges) == 2
assert "第一次候选定位(失败)" in diagram.mermaid
assert 'locate_1 -->|"调整后重试"| locate_2' in diagram.mermaid
# A text-only update cannot clear the accumulated graph.
summary = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
"run_id": repo.run["run_id"], "progress": {"summary": "等待人工", "diagram": None},
})
assert (await service.publish_artifacts(task["task_id"], summary)).task.latest_progress.progress.diagram == diagram
@pytest.mark.anyio
async def test_failed_attempt_cannot_be_relabelled_as_a_success():
task, repo, service = setup_service()
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "attempt_1", "label": "候选定位", "status": "failed", "detail": "超时"},
])
with pytest.raises(DomainError, match="new node"):
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "attempt_1", "label": "候选定位", "status": "recorded"},
])
@pytest.mark.anyio
async def test_new_execution_round_continues_the_same_task_graph():
task, repo, service = setup_service()
first = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "first", "label": "首次查询", "status": "failed"},
])
archived = first.task.latest_progress.progress.diagram.model_dump(mode="json")
repo.task["execution_generation"] = 2
repo.task["execution_request_id"] = str(uuid4())
repo.run["status"] = "succeeded"
repo.run = _active_run(repo.task, datetime(2026, 11, 10, tzinfo=timezone.utc))
repo.runs[repo.run["run_id"]] = repo.run
assert (await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs[0].status == "failed"
result = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "second", "label": "补充信息后查询", "status": "recorded"},
], edges=[{"source": "first", "target": "second"}])
assert result.progress_revision == 1
assert result.task.latest_progress.execution_generation == 2
assert len(result.task.latest_progress.progress.diagram.node_refs) == 2
assert repo.progress[(task["task_id"], 1)]["progress"]["diagram"] == archived
@pytest.mark.anyio
async def test_reports_archive_the_host_graph_and_keep_older_versions_unchanged():
task, repo, service = setup_service()
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "failed", "label": "数据读取", "status": "failed", "detail": "接口超时"},
])
command = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
"run_id": repo.run["run_id"], "report": {
"title": "调查", "current_conclusion": "已有阶段判断", "conclusion_boundary": "限于现有资料",
"stage": "final", "outcome": "concluded", "data_gaps": [],
"diagram": {"title": "重新生成的图", "mermaid": "flowchart TD\nNEW[新图]"},
},
})
first = await service.publish_artifacts(task["task_id"], command)
assert first.task.latest_report.report.diagram.node_refs[0].status == "failed"
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "retry", "label": "重新读取", "status": "recorded"},
], edges=[{"source": "failed", "target": "retry"}])
assert len((await service.report(task["task_id"], 1)).report.diagram.node_refs) == 1
assert len((await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs) == 2
# Idempotent replays do not append nodes or change the archived report.
assert (await service.publish_artifacts(task["task_id"], command)).report_revision == 1
@pytest.mark.anyio
async def test_graph_updates_validate_references_and_cannot_replace_a_managed_graph():
task, repo, service = setup_service()
with pytest.raises(DomainError, match="unknown evidence"):
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "unknown", "label": "未知依据", "evidence_refs": ["forged"]},
])
await publish(service, repo, task["task_id"], nodes=[
{"node_id": "kept", "label": "实际步骤", "status": "recorded"},
])
with pytest.raises(DomainError, match="graph_update"):
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
"run_id": repo.run["run_id"], "progress": {
"summary": "重画", "diagram": {"title": "替换", "mermaid": "flowchart TD\nNEW[新图]"},
},
}))
def test_graph_renderer_escapes_labels_and_keeps_explicit_parallel_and_failed_paths():
graph = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
"nodes": [
{"node_id": "start", "label": '核查 "地点" <script> ``` %%{init: {}}'},
{"node_id": "left", "label": "监测", "status": "failed"},
{"node_id": "right", "label": "工单", "status": "reference"},
], "edges": [{"source": "start", "target": target} for target in ["left", "right"]],
}))
assert "<script>" not in graph.mermaid
assert "#lt;script#gt;" in graph.mermaid
assert "#quot;地点#quot;" in graph.mermaid
assert "```" not in graph.mermaid and "%%{" not in graph.mermaid
assert "监测(失败)" in graph.mermaid
updated = update_investigation_graph(graph, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "right", "label": "工单", "status": "recorded"}],
"edges": [{"source": "start", "target": "right"}],
}))
assert len(updated.edges) == 2
assert graph.node_refs[2].status == "reference"
def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it():
original = InvestigationDiagram.model_validate({
"title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]',
"node_refs": [{"node_id": "B", "label": "原路径", "status": "failed", "detail": "查询超时"}],
})
updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "retry", "label": "改用其他数据", "status": "reference"}],
"edges": [{"source": "B", "target": "retry"}],
}))
assert updated.mermaid.startswith(original.mermaid + "\n")
assert updated.source_mermaid == original.mermaid
assert updated.node_refs[0].status == "failed"
final = update_investigation_graph(updated, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "retry", "label": "改用其他数据", "status": "recorded"}],
}))
assert final.mermaid.count("A{核查条件}") == 1
assert "B --> retry" in final.mermaid
with pytest.raises(DomainError, match="original Mermaid"):
update_investigation_graph(updated, InvestigationGraphUpdate.model_validate({
"nodes": [{"node_id": "C", "label": "与旧图冲突的新步骤"}],
}))
@pytest.mark.anyio
async def test_first_update_uses_the_last_saved_legacy_report_graph():
task, repo, service = setup_service()
repo.reports.append({
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
"published_at": "2026-11-09T00:00:00Z", "published_by": "agent", "report": {
"title": "旧报告", "current_conclusion": "已有判断", "conclusion_boundary": "已有证据",
"diagram": {"title": "旧路线", "mermaid": "flowchart TD\nold[此前查询]",
"node_refs": [{"node_id": "old", "label": "此前查询", "status": "failed"}]},
},
})
result = await publish(service, repo, task["task_id"], nodes=[
{"node_id": "retry", "label": "接续查询", "status": "reference"},
], edges=[{"source": "old", "target": "retry"}])
diagram = result.task.latest_progress.progress.diagram
assert diagram.source_mermaid == "flowchart TD\nold[此前查询]"
assert [n.node_id for n in diagram.node_refs] == ["old", "retry"]
@pytest.mark.parametrize("update,code", [
({"nodes": []}, "GRAPH_NODE_REQUIRED"),
({"nodes": [{"node_id": "end", "label": "保留字"}]}, "GRAPH_NODE_ID_INVALID"),
({"nodes": [{"node_id": "n", "label": "一"}, {"node_id": "n", "label": "二"}]}, "GRAPH_NODE_DUPLICATE"),
({"nodes": [{"node_id": "n", "label": "一"}], "edges": [{"source": "n", "target": "missing"}]}, "GRAPH_EDGE_NODE_NOT_FOUND"),
])
def test_graph_updates_reject_invalid_structure_without_erasing_history(update, code):
with pytest.raises(DomainError) as error:
update_investigation_graph(None, InvestigationGraphUpdate.model_validate(update))
assert error.value.code == code
@pytest.mark.anyio
@pytest.mark.parametrize("node_refs", [[], [{"node_id": "old", "label": "旧尝试", "status": "failed", "detail": "查询超时"}]])
async def test_summary_only_adopts_legacy_graph_before_the_first_incremental_update(node_refs):
task, repo, service = setup_service()
original = {"title": "旧路线", "mermaid": "flowchart LR\nold{旧尝试} --> other[另一分支]", "node_refs": node_refs}
for progress in [{"summary": "此前调查", "diagram": original}, {"summary": "继续调查"}]:
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"], progress=progress,
))
diagram = result.task.latest_progress.progress.diagram
assert diagram is not None
assert diagram.mermaid == diagram.source_mermaid == original["mermaid"]
assert diagram.edges == []
assert diagram.node_refs == InvestigationDiagram.model_validate(original).node_refs
@pytest.mark.anyio
async def test_identical_effective_reports_do_not_repeat_notifications_for_inherited_graph_evidence():
task, repo, service = setup_service()
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="probe", queried_at=service._now(),
origin="system_data", truncated=False, data={"data": []},
))
await publish(service, repo, task["task_id"], nodes=[{
"node_id": "finding", "label": "已有结果", "status": "recorded", "evidence_refs": [evidence.evidence_id],
}])
saved = []
for _ in range(2):
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={
"title": "调查", "current_conclusion": "已有判断", "conclusion_boundary": "当前资料",
"stage": "final", "outcome": "concluded", "data_gaps": [],
},
))
saved.append(result.task.latest_report.report)
assert saved[0] == saved[1]
assert saved[0].evidence_refs == [evidence.evidence_id]
assert len([notice for notice in repo.notifications if notice["kind"] == "report_updated"]) == 1
@pytest.mark.anyio
async def test_final_scada_report_validates_the_effective_inherited_evidence():
task, repo, service = setup_service()
task["workflow"] = "scada_anomaly"
now = service._now()
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="complete-scada", queried_at=now,
origin="system_data", truncated=False, data={
"data": {"coverage": {"mode": "all_scada", "query_complete": True}},
"window": {"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()},
},
))
await publish(service, repo, task["task_id"], nodes=[{
"node_id": "all_scada", "label": "全量读取", "evidence_refs": [evidence.evidence_id],
}])
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={
"title": "调查", "current_conclusion": "已有判断", "conclusion_boundary": "当前资料",
"stage": "final", "outcome": "concluded", "data_gaps": [],
},
))
assert result.task.latest_report.report.evidence_refs == [evidence.evidence_id]
@pytest.mark.anyio
async def test_evidence_only_publication_does_not_load_the_graph_baseline(monkeypatch):
task, repo, service = setup_service()
calls = []
original = repo.get_latest_graph_progress
async def load_graph(task_id):
calls.append(task_id)
return await original(task_id)
# The returned task detail can read progress; count separately from publication.
original_detail = service.detail
async def detail(task_id):
before_detail = len(calls)
result = await original_detail(task_id)
del calls[before_detail:]
return result
monkeypatch.setattr(repo, "get_latest_graph_progress", load_graph)
monkeypatch.setattr(service, "detail", detail)
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", evidence=[{
"evidence_id": "demo", "kind": "synthetic", "summary": "演练依据",
"source_label": "fixture", "observed_at": service._now(),
}],
))
assert calls == []