报告末尾「分析范围」里的编号点得开:证据接口加 GET /runs/{id}/evidence/analyses/{alias}(?report= 同片段接口),交回那份分析结论的出处链(判定 → 方法 → 计算 → 输入 → 复算 → 封存,和点开正文里它的判定语同一条,按导出页已有的 _scope_chain),正文没引用的结论(算不了的、可比性检查)也点得开;只认这份报告证据目录里的分析结论,没有的 404(evidence_analysis_not_found)。选报告、文档完好与否的几种错误挪进 _open_report,片段接口和它共用同一套错误码。测试:同一条链、算不了的照实给原因、目录里没有的和不是分析结论的 404、文档被改过 409

This commit is contained in:
ThearchyHelios
2026-10-21 18:02:34 +08:00
parent 5f61661625
commit 998ab23bae
2 changed files with 133 additions and 4 deletions
+32 -4
View File
@@ -909,9 +909,30 @@ async def evidence_metric(run_id: str, node: str, metric: str) -> dict[str, Any]
"redacted": {"columns": masked, "note": MASK_NOTE} if masked else {"columns": []}}
def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str
) -> tuple[_Report, tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]:
"""选报告、确认文档完好、找到片段:(报告, (块, 句子, 片段))。片段接口和推断来源接口共用同一套错误码。"""
ANALYSIS_NOT_FOUND = "evidence_analysis_not_found"
@router.get("/{run_id}/evidence/analyses/{alias}")
async def evidence_analysis(run_id: str, alias: str, report: str | None = None) -> dict[str, Any]:
"""点开报告末尾系统写的「分析范围」里的一个编号(A1…):那份分析结论的出处链(判定 → 方法 → 计算 → 输入 → 复算 →
封存),和点开正文里它的判定语是同一条(_scope_chain,导出页的分析范围也按它)。正文没引用的结论(排除的、算不了的)也
点得开。只认这份报告证据目录里的分析结论;选哪份报告、文档完不完好同片段接口(?report=<节点 id>)。"""
sealed = await _sealed(run_id)
chosen = _open_report(sealed, report)
doc = chosen.doc or {}
chain = await _scope_chain(alias, doc, sealed, chosen.node_id)
if chain is None:
raise CodedHTTPException(404, "这份报告的证据目录里没有这份分析结论", ANALYSIS_NOT_FOUND)
covered = sealed.trusted and all(step.get("sealed") for step in chain if "sealed" in step)
masked = _chain_masked(chain)
return {"run_id": run_id, "report": {"node_id": chosen.node_id, "doc_artifact": chosen.doc_artifact}, "alias": alias,
"rendered": next((step.get("rendered") for step in chain if step.get("step") == LEDGER_KIND), None),
"chain": chain, "seal": {"sealed": sealed.sealed, "ok": sealed.seal["ok"], "covered": covered},
"redacted": {"columns": masked, "note": MASK_NOTE} if masked else {"columns": []}}
def _open_report(sealed: _Sealed, wanted: str | None) -> _Report:
"""选报告、确认文档完好。片段接口、推断来源接口、分析范围里的编号共用同一套错误码。"""
reports = _named_reports(sealed, wanted)
if not reports:
raise CodedHTTPException(404, "本次运行没有封存的报告文档,没有可查看的片段", REPORT_NOT_FOUND)
@@ -920,7 +941,14 @@ def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str
raise CodedHTTPException(409, "报告文档与哈希不一致,疑似被修改,不能作为证据展示", DOC_TAMPERED)
if chosen.doc is None:
raise CodedHTTPException(404, "无法读取报告文档", DOC_MISSING)
found = find_segment(chosen.doc, segment_id)
return chosen
def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str
) -> tuple[_Report, tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]:
"""选报告、确认文档完好、找到片段:(报告, (块, 句子, 片段))。片段接口和推断来源接口共用同一套错误码。"""
chosen = _open_report(sealed, wanted)
found = find_segment(chosen.doc or {}, segment_id)
if found is None:
raise CodedHTTPException(404, "报告中没有这个片段", SEGMENT_NOT_FOUND)
return chosen, found
+101
View File
@@ -0,0 +1,101 @@
"""报告末尾「分析范围」里的编号点得开(GET /runs/{id}/evidence/analyses/{alias}):那份分析结论的出处链和点开正文里它的判定语
是同一条(api/evidence._scope_chain),正文没引用的结论(算不了的、可比性检查)也点得开;证据目录里没有的编号 404。
真运行、真算子:照 tests/test_analysis_e2e_claims.py 的虚构零售库 shop(数据目录标了地区、渠道是属性),Agent 查两次库、
做一次变化拆解、一次波动基线(两周历史不够,算不了)、一次可比性检查;写作模型只引用拆解的判定语。模型都换成脚本。
"""
from __future__ import annotations
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage, ToolMessage
from app.analysis import contract
from app.core import artifact_store
from app.engine.evidence import iter_segments
from app.main import app
from tests.test_analysis_e2e_claims import ( # noqa: F401 - 夹具按名字引入
ARGS, BY_REGION, DECOMPOSE, TOOL, TOTALS, _finish, _graph, _report, engine_up, shop,
)
BASELINE = contract.TOOL_NAMES["baseline"]
COMPARABLE = contract.TOOL_NAMES["comparable"]
TEXT = "## 本周销售\n\n[[a:A1]]。[[see:A1]]"
CHAIN = ["analysis", "analysis_method", "analysis_calc", "analysis_input", "analysis_recompute", "analysis_seal"]
def _script(monkeypatch):
from app.providers import mock_model
calls = [(TOOL, {"sql": TOTALS}), (TOOL, {"sql": BY_REGION}), (DECOMPOSE, ARGS),
(BASELINE, {"query": "Q1", "period": "week", "measure": "gmv", "label": "销售额"}),
(COMPARABLE, {"query": "Q2", "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv"})]
def decide(model, messages):
if not model.tools:
return AIMessage(content=TEXT)
done = sum(isinstance(m, ToolMessage) for m in messages)
if done < len(calls):
name, args = calls[done]
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": f"call_{done}"}])
return AIMessage(content="销售额下降,主要来自华东。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
def _graph_with_tools():
graph = _graph()
bot = next(n for n in graph["nodes"] if n["id"] == "bot")
bot["data"]["config"]["tools"] = [TOOL, DECOMPOSE, BASELINE, COMPARABLE]
return graph
def _steps(chain):
"""同一步连着几条(几份输入、输入后面接的查询步骤)算一步。"""
steps = [s["step"] for s in chain]
return [s for i, s in enumerate(steps) if s.startswith("analysis") and steps[i - 1:i] != [s]]
async def test_scope_aliases_open_the_same_chain_as_the_phrase_in_the_text(monkeypatch):
_script(monkeypatch)
row = await _finish(_graph_with_tools())
assert row.status == "succeeded", row.error
_, doc, _ = await _report(row)
scope = doc["analysis_scope"]
assert [i["alias"] for i in scope["decomposed"]] == ["A1"]
assert [i["alias"] for i in scope["refused"]] == ["A2"] and [i["alias"] for i in scope["phenomena"]] == ["A3"]
phrase = next(s for s in iter_segments(doc) if s.get("ref") == "a:A1")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
cited = (await client.get(f"/api/runs/{row.id}/evidence/segments/{phrase['id']}")).json()
body = (await client.get(f"/api/runs/{row.id}/evidence/analyses/A1")).json()
# 和点开正文里的判定语同一条链(同一份结论、同样复算一致),只是没有这一处引用的那几样
assert _steps(body["chain"]) == CHAIN
assert [s["step"] for s in body["chain"]] == [s["step"] for s in cited["chain"]]
assert body["rendered"] == phrase["text"] == body["chain"][0]["phrase"]
assert body["seal"]["covered"] is True and body["report"]["node_id"] == "write"
# 正文没引用的也点得开:算不了的波动基线照实给原因,可比性检查照样复算
refused = (await client.get(f"/api/runs/{row.id}/evidence/analyses/A2", params={"report": "write"})).json()
judged = refused["chain"][0]
assert (judged["op"], judged["status"], judged["reason_code"]) == ("baseline", "refused", "history_short")
assert judged["phrase"] == scope["refused"][0]["phrase"]
assert next(s for s in refused["chain"] if s["step"] == "analysis_recompute")["status"] == "same"
comparable = (await client.get(f"/api/runs/{row.id}/evidence/analyses/A3")).json()
assert comparable["chain"][0]["method_label"] == contract.method_label("comparable")
# 目录里没有的编号、不是分析结论的编号(Q1):404,说清是哪种
for alias in ("A9", "Q1"):
missing = await client.get(f"/api/runs/{row.id}/evidence/analyses/{alias}")
assert missing.status_code == 404 and missing.json()["code"] == "evidence_analysis_not_found"
async def test_a_tampered_report_does_not_open_its_scope(monkeypatch):
_script(monkeypatch)
row = await _finish(_graph_with_tools())
checked, _, _ = await _report(row)
# 报告文档被改过(哈希对不上):和片段接口同一个错误码,不给出处
path = artifact_store._path_of(checked["doc_artifact"])
path.write_bytes(path.read_bytes().replace("本周销售".encode(), "本周营业".encode(), 1))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
res = await client.get(f"/api/runs/{row.id}/evidence/analyses/A1")
assert res.status_code == 409 and res.json()["code"] == "evidence_doc_tampered"