From b6efee81fdbc846b2fd7e3c7ac7dff450f8feffe Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 15:34:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E6=AC=A1=E8=AF=B7=E6=B1=82=E9=87=8C?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=BB=BD=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=E5=8F=AA=E5=A4=8D=E7=AE=97=E4=B8=80=E6=AC=A1=EF=BC=9A=5FSealed?= =?UTF-8?q?=20=E5=8A=A0=20rechecks=EF=BC=88=E7=BB=93=E8=AE=BA=E5=B7=A5?= =?UTF-8?q?=E4=BB=B6=20id=20=E2=86=92=20=E5=A4=8D=E7=AE=97=E7=9A=84?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=EF=BC=8C=E5=8F=AA=E6=B4=BB=E5=9C=A8=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E8=AF=B7=E6=B1=82=E9=87=8C=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=5Fanalysis=5Frecheck=20=E6=8C=89=E5=B7=A5=E4=BB=B6=E5=85=88?= =?UTF-8?q?=E6=9F=A5=E5=AE=83=E3=80=82=E5=AF=BC=E5=87=BA=E9=A1=B5=E9=87=8C?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=BB=BD=E7=BB=93=E8=AE=BA=E8=A2=AB=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E8=AF=AD=E3=80=81=E5=85=B7=E5=90=8D=E7=9A=84=E6=95=B0?= =?UTF-8?q?=E3=80=81=E6=95=B4=E8=A1=A8=E5=B1=95=E5=BC=80=E7=9A=84=E6=AF=8F?= =?UTF-8?q?=E4=B8=80=E6=A0=BC=E3=80=81=E5=88=86=E6=9E=90=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E7=9A=84=E7=BC=96=E5=8F=B7=E5=90=84=E5=BC=95=E4=B8=80=E5=A4=84?= =?UTF-8?q?=EF=BC=8C=E5=BC=95=E7=94=A8=E9=94=AE=E4=B8=8D=E5=90=8C=E3=80=81?= =?UTF-8?q?=E5=90=84=E6=8E=A5=E4=B8=80=E6=9D=A1=E9=93=BE=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E5=89=8D=E6=AF=8F=E6=9D=A1=E9=93=BE=E5=90=84=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=EF=BC=881000=20=E6=AE=B5=E7=9A=84=E6=8B=86?= =?UTF-8?q?=E8=A7=A3=E5=AF=BC=E5=87=BA=E4=B8=80=E6=AC=A1=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=2033=20=E6=AC=A1=E3=80=811.66=20s=EF=BC=8C=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=201=20=E6=AC=A1=E3=80=810.2=20s=EF=BC=89=E3=80=82=E5=88=86?= =?UTF-8?q?=E6=9E=90=E8=8C=83=E5=9B=B4=E7=9A=84=E7=BC=96=E5=8F=B7=E5=85=88?= =?UTF-8?q?=E6=9F=A5=E5=AF=BC=E5=87=BA=E7=9A=84=20memo=EF=BC=9A=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E5=B7=B2=E7=BB=8F=E5=BC=95=E4=BA=86=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E8=AF=AD=E7=9A=84=EF=BC=88=E5=90=8C=E4=B8=80=E4=B8=AA=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E9=94=AE=EF=BC=89=E4=B8=8D=E5=86=8D=E5=BB=BA=E4=B8=80?= =?UTF-8?q?=E9=81=8D=E9=93=BE=EF=BC=88=5Fscope=5Fchains=20=E6=8D=A2?= =?UTF-8?q?=E6=88=90=E6=8C=89=E7=BC=96=E5=8F=B7=E5=BB=BA=E7=9A=84=20=5Fsco?= =?UTF-8?q?pe=5Fchain=EF=BC=8C=E5=BC=95=E7=94=A8=E9=94=AE=20=5Fscope=5Fcit?= =?UTF-8?q?e=5Fkey=EF=BC=89=E3=80=82=E6=B5=8B=E8=AF=95=EF=BC=9A=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E8=AF=AD=E3=80=81=E5=85=B7=E5=90=8D=E7=9A=84=E6=95=B0?= =?UTF-8?q?=E3=80=81=E6=95=B4=E8=A1=A8=2012=20=E6=A0=BC=E3=80=81=E4=BE=9D?= =?UTF-8?q?=E6=8D=AE=E3=80=81=E5=88=86=E6=9E=90=E8=8C=83=E5=9B=B4=E4=B8=80?= =?UTF-8?q?=E8=B5=B7=E5=AF=BC=E5=87=BA=E5=8F=AA=E5=A4=8D=E7=AE=97=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=EF=BC=8C=E5=90=84=E9=93=BE=E7=9A=84=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E7=85=A7=E6=A0=B7=E6=98=AF=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E4=B8=80=E8=87=B4=EF=BC=9B=E4=B8=8B=E4=B8=80=E6=AC=A1=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E9=87=8D=E6=96=B0=E5=A4=8D=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/evidence.py | 65 +++++++++++++-------- backend/tests/test_analysis_evidence_api.py | 30 ++++++++++ 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index fc4cf9ec..7b1cd16e 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -224,6 +224,9 @@ class _Sealed: #: 节点 id → node.started 上记的名字、类型。子工作流里的节点(「子工作流节点 id/…」)不在这次运行的图上,名字从这里取 labels: dict[str, str] = field(default_factory=dict) types: dict[str, str] = field(default_factory=dict) + #: 这一次请求里复算过的分析结论:结论工件 id → 复算的结论(_analysis_recheck)。导出页里同一份结论被判定语、具名的数、 + #: 整表的每一格、分析范围各引一处,各接一条链,复算只做一次。只活在这一次请求里:下一次请求重新取、重新验 + rechecks: dict[str, dict[str, Any]] = field(default_factory=dict) @property def sealed(self) -> bool: @@ -1130,7 +1133,7 @@ async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict inputs, steps = await _analysis_inputs(alias, result, catalog, doc, sealed, masks, queries=queries) hidden = {str(c).lower() for q in steps for c in q.get("masked") or []} calc = _analysis_calc(alias, result, judged["cited"], hidden) - again = await _analysis_recheck(result, sealed) + again = await _analysis_recheck(result, sealed, artifact) recheck = {"step": "analysis_recompute", "alias": alias, **again} return [judged, method, calc, *inputs, *steps, recheck, seal] @@ -1230,10 +1233,21 @@ async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str return steps, found -async def _analysis_recheck(result: dict[str, Any], sealed: _Sealed) -> dict[str, Any]: +async def _analysis_recheck(result: dict[str, Any], sealed: _Sealed, artifact: str | None = None) -> dict[str, Any]: """复算(evidence.analysis_recheck):输入快照交工件原内容,下钻的上一层交那份结论,都按 sha256 取回、复验哈希,在线程里 复算、比较。只认封存范围内的——输入快照要是这次运行交回过的查询(sealed.queries),上一层要是台账记过的分析结论; - 不在范围内、哈希对不上的一律当取不回(复算照实说取不回哪一份)。""" + 不在范围内、哈希对不上的一律当取不回(复算照实说取不回哪一份)。 + + artifact 是这份结论的工件 id(内容寻址):一次请求里同一份结论只复算一次,结果记在 sealed.rechecks。""" + if artifact and artifact in sealed.rechecks: + return dict(sealed.rechecks[artifact]) + again = await _recheck_now(result, sealed) + if artifact: + sealed.rechecks[artifact] = again + return dict(again) + + +async def _recheck_now(result: dict[str, Any], sealed: _Sealed) -> dict[str, Any]: snapshots = {str(i["artifact"]) for i in result.get("inputs") or [] if isinstance(i, dict) and i.get("artifact") and i.get("role") != "parent"} parents = {str(i["artifact"]) for i in result.get("inputs") or [] @@ -3268,22 +3282,24 @@ def scope_aliases(doc: dict[str, Any]) -> list[str]: return list(dict.fromkeys(out)) -async def _scope_chains(doc: dict[str, Any], sealed: _Sealed, report_node: str) -> dict[str, list[dict[str, Any]]]: - """分析范围里每个编号的出处链(和点开正文里的判定语同一条 _chain,按那条结论的判定语认):{编号: 链}。目录里没有的 - 编号不给(文档里写着、目录里却没有,只能是文档有问题,范围那一项点不开)。""" +def _scope_cite_key(alias: str) -> tuple: + """分析范围里一个编号的引用键:和正文里引用判定语([[a:A1]])的片段同一个键(_export_cite_key),共用一条链。""" + return (LEDGER_KIND, alias, "resolved", None, None, None, None, None, None) + + +async def _scope_chain(alias: str, doc: dict[str, Any], sealed: _Sealed, report_node: str) -> list[dict[str, Any]] | None: + """分析范围里一个编号的出处链(和点开正文里的判定语同一条 _chain,按那条结论的判定语认)。目录里没有的编号为 None + (文档里写着、目录里却没有,只能是文档有问题,范围那一项点不开)。""" catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {} - out: dict[str, list[dict[str, Any]]] = {} - for alias in scope_aliases(doc): - entry = catalog.get(alias) - if not isinstance(entry, dict) or entry.get("kind") != LEDGER_KIND: - continue - result, _ = _analysis_of(str(entry.get("artifact") or "")) - seg = {"id": SCOPE_REF.format(alias=alias), "kind": LEDGER_KIND, "state": "deterministic", - "text": render_cell(result.get("phrase"), kind="text") if result and result.get("phrase") else alias, - "cite": {"alias": alias, "kind": LEDGER_KIND, "status": "resolved", "locator": {}, - "eid": entry.get("eid")}} - out[alias] = await _chain(seg, doc, sealed, report_node) - return out + entry = catalog.get(alias) + if not isinstance(entry, dict) or entry.get("kind") != LEDGER_KIND: + return None + result, _ = _analysis_of(str(entry.get("artifact") or "")) + seg = {"id": SCOPE_REF.format(alias=alias), "kind": LEDGER_KIND, "state": "deterministic", + "text": render_cell(result.get("phrase"), kind="text") if result and result.get("phrase") else alias, + "cite": {"alias": alias, "kind": LEDGER_KIND, "status": "resolved", "locator": {}, + "eid": entry.get("eid")}} + return await _chain(seg, doc, sealed, report_node) async def _export_withheld(sealed: _Sealed, doc: dict[str, Any]) -> frozenset[str]: @@ -3391,14 +3407,17 @@ async def export_report_html(run_id: str, report: str | None = None) -> Response refs[sid] = str(ck) if key is not None: memo[key] = str(ck) - # 系统写的「分析范围」:每个编号点得开,链和点开正文里的判定语是同一条(引用键相同的共用) - for alias, chain in (await _scope_chains(doc, sealed, chosen.node_id)).items(): - sid = SCOPE_REF.format(alias=alias) - ck = (LEDGER_KIND, alias, "resolved", None, None, None, None, None, None) + # 系统写的「分析范围」:每个编号点得开,链和点开正文里的判定语是同一条(引用键相同的共用:正文已经引了判定语的, + # 不再建一遍) + for alias in scope_aliases(doc): + ck = _scope_cite_key(alias) if ck not in memo: + chain = await _scope_chain(alias, doc, sealed, chosen.node_id) + if chain is None: + continue memo[ck] = str(ck) chains[str(ck)] = chain - refs[sid] = memo[ck] + refs[SCOPE_REF.format(alias=alias)] = memo[ck] async with SessionLocal() as session: run = await session.get(Run, run_id) if run is None: # 两次读之间被删掉了 diff --git a/backend/tests/test_analysis_evidence_api.py b/backend/tests/test_analysis_evidence_api.py index 3b946a09..451b23f3 100644 --- a/backend/tests/test_analysis_evidence_api.py +++ b/backend/tests/test_analysis_evidence_api.py @@ -479,6 +479,36 @@ async def test_the_html_export_carries_the_analysis_chain_and_the_scope(client): assert f'data-seg="{phrase}"' in html and 'data-kind="analysis"' in html +async def test_the_export_recomputes_each_conclusion_once(client, monkeypatch): + """导出页里同一份结论被判定语、具名的数、整表的每一格、句末依据、分析范围各引一处,引用键不同、各接一条链,复算只做 + 一次(按结论工件记在这一次请求里);分析范围的编号和正文的判定语同一个引用键,不再另建一条链。以前每条链各复算一次, + 1000 段的拆解导出一次复算三十多次。""" + calls: list[str] = [] + real = analysis.recompute + + def counting(result, load, *, load_result=None): + calls.append(str(result.get("method"))) + return real(result, load, load_result=load_result) + + monkeypatch.setattr(analysis, "recompute", counting) + text = ("[[a:A1]]。华东占 [[a:A1.top_share|pct]]。[[see:A1]]\n\n" + "[[table:A1 cols=region,delta,share rows=0-3]]") + made = await sealed_run(text, doc_extra=SCOPE) + data = data_block((await client.get(f"/api/runs/{made['run']}/report.html")).text) + assert calls == ["decompose.additive@1"] + cells = [s["id"] for s in iter_segments(made["doc"]) if str(s.get("ref") or "").startswith("v:A1.")] + assert len(cells) == 12 + keys = {data["refs"][sid] for sid in [*cells, seg_id(made["doc"], "a:A1"), seg_id(made["doc"], "a:A1.top_share|pct")]} + assert len(keys) == 14 # 每处引用各一条链 + for key in keys: + [again] = [s for s in data["chains"][key] if s["step"] == "analysis_recompute"] + assert again["status"] == "same" + assert data["refs"]["scope:A1"] == data["refs"][seg_id(made["doc"], "a:A1")] + # 下一次请求重新复算(缓存只活在一次请求里) + await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}") + assert len(calls) == 2 + + async def test_docs_without_a_scope_render_no_scope(client): made = await sealed_run(TEXT) html = (await client.get(f"/api/runs/{made['run']}/report.html")).text