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:
@@ -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 == []
|
||||
Reference in New Issue
Block a user