From 998ab23bae81ddd02aad3e70bf4d40ddb243abb5 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 18:02:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A5=E5=91=8A=E6=9C=AB=E5=B0=BE=E3=80=8C?= =?UTF-8?q?=E5=88=86=E6=9E=90=E8=8C=83=E5=9B=B4=E3=80=8D=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E7=82=B9=E5=BE=97=E5=BC=80=EF=BC=9A=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E6=8E=A5=E5=8F=A3=E5=8A=A0=20GET=20/runs/{id}/evidenc?= =?UTF-8?q?e/analyses/{alias}=EF=BC=88=3Freport=3D=20=E5=90=8C=E7=89=87?= =?UTF-8?q?=E6=AE=B5=E6=8E=A5=E5=8F=A3=EF=BC=89=EF=BC=8C=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=E9=82=A3=E4=BB=BD=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA=E7=9A=84?= =?UTF-8?q?=E5=87=BA=E5=A4=84=E9=93=BE=EF=BC=88=E5=88=A4=E5=AE=9A=20?= =?UTF-8?q?=E2=86=92=20=E6=96=B9=E6=B3=95=20=E2=86=92=20=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=20=E2=86=92=20=E8=BE=93=E5=85=A5=20=E2=86=92=20=E5=A4=8D?= =?UTF-8?q?=E7=AE=97=20=E2=86=92=20=E5=B0=81=E5=AD=98=EF=BC=8C=E5=92=8C?= =?UTF-8?q?=E7=82=B9=E5=BC=80=E6=AD=A3=E6=96=87=E9=87=8C=E5=AE=83=E7=9A=84?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E8=AF=AD=E5=90=8C=E4=B8=80=E6=9D=A1=EF=BC=8C?= =?UTF-8?q?=E6=8C=89=E5=AF=BC=E5=87=BA=E9=A1=B5=E5=B7=B2=E6=9C=89=E7=9A=84?= =?UTF-8?q?=20=5Fscope=5Fchain=EF=BC=89=EF=BC=8C=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E6=B2=A1=E5=BC=95=E7=94=A8=E7=9A=84=E7=BB=93=E8=AE=BA=EF=BC=88?= =?UTF-8?q?=E7=AE=97=E4=B8=8D=E4=BA=86=E7=9A=84=E3=80=81=E5=8F=AF=E6=AF=94?= =?UTF-8?q?=E6=80=A7=E6=A3=80=E6=9F=A5=EF=BC=89=E4=B9=9F=E7=82=B9=E5=BE=97?= =?UTF-8?q?=E5=BC=80=EF=BC=9B=E5=8F=AA=E8=AE=A4=E8=BF=99=E4=BB=BD=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E8=AF=81=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA=EF=BC=8C=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E7=9A=84=20404=EF=BC=88evidence=5Fanalysis=5Fnot=5Ffound?= =?UTF-8?q?=EF=BC=89=E3=80=82=E9=80=89=E6=8A=A5=E5=91=8A=E3=80=81=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=AE=8C=E5=A5=BD=E4=B8=8E=E5=90=A6=E7=9A=84=E5=87=A0?= =?UTF-8?q?=E7=A7=8D=E9=94=99=E8=AF=AF=E6=8C=AA=E8=BF=9B=20=5Fopen=5Frepor?= =?UTF-8?q?t=EF=BC=8C=E7=89=87=E6=AE=B5=E6=8E=A5=E5=8F=A3=E5=92=8C?= =?UTF-8?q?=E5=AE=83=E5=85=B1=E7=94=A8=E5=90=8C=E4=B8=80=E5=A5=97=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=A0=81=E3=80=82=E6=B5=8B=E8=AF=95=EF=BC=9A=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E9=93=BE=E3=80=81=E7=AE=97=E4=B8=8D=E4=BA=86?= =?UTF-8?q?=E7=9A=84=E7=85=A7=E5=AE=9E=E7=BB=99=E5=8E=9F=E5=9B=A0=E3=80=81?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E9=87=8C=E6=B2=A1=E6=9C=89=E7=9A=84=E5=92=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA=E7=9A=84?= =?UTF-8?q?=20404=E3=80=81=E6=96=87=E6=A1=A3=E8=A2=AB=E6=94=B9=E8=BF=87=20?= =?UTF-8?q?409?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/evidence.py | 36 +++++++- backend/tests/test_analysis_scope_api.py | 101 +++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_analysis_scope_api.py diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 741dab46..5deb102d 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -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 diff --git a/backend/tests/test_analysis_scope_api.py b/backend/tests/test_analysis_scope_api.py new file mode 100644 index 00000000..9727f874 --- /dev/null +++ b/backend/tests/test_analysis_scope_api.py @@ -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"