From 64b6586ecc399be69084182425f76c85e22ac666 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:45:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=BA=E5=85=B7=E7=BC=BA=E5=8F=A3=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=8A=E8=8A=82=E7=82=B9=E7=B1=BB=E5=9E=8B=E8=AF=B4?= =?UTF-8?q?=E4=B8=A4=E9=81=8D=EF=BC=9A=E4=B8=8A=E6=B8=B8=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E7=9A=84=E5=89=8D=E7=BC=80=E5=9C=A8=E8=8A=82=E7=82=B9=E5=90=8D?= =?UTF-8?q?=E5=B0=B1=E6=98=AF=E7=B1=BB=E5=9E=8B=E5=8F=AB=E6=B3=95=E6=97=B6?= =?UTF-8?q?=E5=8F=AA=E5=86=99=E4=B8=80=E6=AC=A1=EF=BC=88=E3=80=8C=E5=8F=8D?= =?UTF-8?q?=E6=96=B9=E5=AE=A1=E7=A8=BF=EF=BC=9A=E3=80=8D=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E5=A4=8D=E7=AE=97=E3=80=81=E5=8F=8D=E6=96=B9?= =?UTF-8?q?=E5=AE=A1=E7=A8=BF=E7=9A=84=E7=BC=BA=E5=8F=A3=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E8=87=AA=E5=B8=A6=E7=9A=84=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=EF=BC=88=E3=80=8C=E6=8E=A8=E7=BF=BB=E4=BA=86=201=20=E5=8F=A5?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=EF=BC=9A=E2=80=A6=E3=80=8D=E3=80=8C=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E3=80=8CX=E3=80=8D=E4=B8=A4=E8=B7=AF=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E4=B8=8D=E4=B8=80=E8=87=B4=EF=BC=8C=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E4=BA=BA=E9=80=89=E6=8B=A9=E4=BA=86=E7=9B=B2=E7=AE=97=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E3=80=8D=EF=BC=89=EF=BC=9B=E6=97=B6=E9=97=B4=E7=BA=BF?= =?UTF-8?q?=E5=92=8C=E6=8A=A5=E9=94=99=E9=87=8C=E9=82=A3=E5=8F=A5=E7=85=A7?= =?UTF-8?q?=E6=97=A7=E5=B8=A6=E4=B8=BB=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/nodes/challenge.py | 12 ++++++++--- backend/app/engine/nodes/dual_check.py | 9 ++++---- backend/app/engine/nodes/io.py | 6 ++++-- backend/tests/test_challenge_node.py | 19 ++++++++++++++--- backend/tests/test_dual_check_node.py | 22 +++++++++++++++----- backend/tests/test_issuance_upstream_gaps.py | 6 ++++++ 6 files changed, 57 insertions(+), 17 deletions(-) diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index b856f34e..6a02cef1 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -466,9 +466,15 @@ def _named(claims: list[dict[str, Any]]) -> str: return names + ("等" if len(claims) > _NAMED else "") +def _refuted_gap(claims: list[dict[str, Any]]) -> str: + """出具缺口里的那句:「推翻了 N 句结论:「…」」。不带节点名也不带「反方审稿」:出具契约统一加「反方审稿「节点名」:」 + (没改名的节点只加「反方审稿:」),正文再说一遍就成了「反方审稿:反方审稿推翻了……」。""" + return f"推翻了 {len(claims)} 句结论:{_named(claims)}" + + def _refuted_words(claims: list[dict[str, Any]]) -> str: - """出具缺口、时间线、报错里的那句:「反方审稿推翻了 N 句结论:「…」」。不带节点名:出具契约统一加前缀。""" - return f"反方审稿推翻了 {len(claims)} 句结论:{_named(claims)}" + """时间线、报错里的那句:「反方审稿推翻了 N 句结论:「…」」。它们不经出具契约加前缀,主语得自己带上。""" + return f"反方审稿{_refuted_gap(claims)}" def pick_items(claims: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -676,7 +682,7 @@ def _finish(ctx: NodeContext, state: GraphState, outcome: dict[str, Any], *, on_ result: dict[str, Any] = {"challenge_artifact": outcome.get("artifact"), "counts": outcome.get("counts") or {}, "items": pick_items(claims), "on_refuted": on_refuted} if refuted and on_refuted == "degrade": - result["issuance_gaps"] = [_refuted_words(refuted)] + result["issuance_gaps"] = [_refuted_gap(refuted)] updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} spent = outcome.get("usage") or {} if spent.get("calls"): diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 9b54b752..b1c47136 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -110,10 +110,11 @@ SAME_SQL_ASPECT = "data" DIFF_TIMEOUT = "两条 SQL 比较超时,未能归纳成具体差异,请对照原文" DIFF_FAILED = "两条 SQL 比较时出错,未能归纳成具体差异,请对照原文" NO_SQL = "有一边没有可比较的 SQL,请对照原文" -#: 交给成果节点的缺口(不带节点名:出具契约统一加「双盲复算「节点名」:」) -GAP_BLIND = "指标「{name}」双盲复算不一致,审核人选择了盲算结果" -GAP_NEITHER = "指标「{name}」双盲复算不一致,审核人判断两边都不对" -GAP_UNANSWERED = "指标「{name}」双盲复算不一致,审核人没有作出选择" +#: 交给成果节点的缺口。不带节点名,也不再说「双盲复算」:出具契约统一加「双盲复算「节点名」:」(没改名的节点只加 +#: 「双盲复算:」),正文再说一遍就成了「双盲复算:指标「X」双盲复算不一致……」 +GAP_BLIND = "指标「{name}」两路结果不一致,审核人选择了盲算结果" +GAP_NEITHER = "指标「{name}」两路结果不一致,审核人判断两边都不对" +GAP_UNANSWERED = "指标「{name}」两路结果不一致,审核人没有作出选择" GAP_UNCHECKED = "指标「{name}」无法复算:{reason}" BLIND_SYSTEM = ( diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 80102660..d1c83c2d 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -322,7 +322,8 @@ def _upstream_issuance_gaps(state: GraphState, ctx: NodeContext) -> list[str]: 这是给「把关类」节点留的通用口子:节点觉得这次的结论不该完整出具(双盲复算两路对不上、 反方审稿的反驳没有回应),在自己的产出里写 issuance_gaps,成果节点的出具契约照单收进缺口, 出具随之降档。写的时候不用带节点名,这里统一加上「双盲复算「复核销售额」:」这样的前缀, - 和别的缺口一样读得出是谁报的。 + 和别的缺口一样读得出是谁报的。节点名就是类型的叫法(拖进画布没改名)时只写一次:「反方审稿:」, + 不写成「反方审稿「反方审稿」:」。缺口正文也不必再提节点类型,前缀已经说了。 只认这个成果节点的上游:另一条支路上的节点管不到这份成果。不是字符串、空白的条目跳过—— 这个键谁都能写(数据整形的 JSON 模板也行),写坏了只当没写,不能让出具崩掉。 @@ -340,7 +341,8 @@ def _upstream_issuance_gaps(state: GraphState, ctx: NodeContext) -> list[str]: reported = payload.get("issuance_gaps") if not isinstance(reported, list): continue - prefix = f"{type_label(node.type)}「{node.title}」:" + kind = type_label(node.type) + prefix = f"{kind}:" if node.title == kind else f"{kind}「{node.title}」:" out.extend(prefix + gap.strip() for gap in reported if isinstance(gap, str) and gap.strip()) return out diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 41bc16ac..62c35895 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -265,7 +265,7 @@ async def test_a_refuted_conclusion_degrades_the_issuance(monkeypatch, shop): issuance = row.output["_issuance"] assert issuance["tier"] == "degraded" - assert issuance["gaps"] == [f"反方审稿「审稿」:反方审稿推翻了 1 句结论:「{SOUTH}」"], "缺口不带节点名,前缀由出具契约加" + assert issuance["gaps"] == [f"反方审稿「审稿」:推翻了 1 句结论:「{SOUTH}」"], "缺口不带节点名,前缀由出具契约加" doc = await report_doc(row.id) east, south = unit_of(doc, EAST), unit_of(doc, SOUTH) @@ -275,7 +275,7 @@ async def test_a_refuted_conclusion_degrades_the_issuance(monkeypatch, shop): assert out["items"] == [{"unit": east, "text": EAST, "verdict": "weakened"}, {"unit": south, "text": SOUTH, "verdict": "refuted"}] assert out["on_refuted"] == "degrade" - assert out["issuance_gaps"] == [f"反方审稿推翻了 1 句结论:「{SOUTH}」"] + assert out["issuance_gaps"] == [f"推翻了 1 句结论:「{SOUTH}」"] record = artifact_store.load(out["challenge_artifact"]) assert set(record) == {"report", "doc_artifact", "claims", "model"} @@ -317,6 +317,19 @@ async def test_a_refuted_conclusion_degrades_the_issuance(monkeypatch, shop): assert finished.seq <= row.manifest_seq +async def test_a_challenge_named_after_its_type_is_named_once_in_the_gap(monkeypatch, shop): + """拖进画布没改名的反方审稿:缺口读成「反方审稿:推翻了 1 句结论:…」,前缀和正文都不再重复「反方审稿」""" + Models(monkeypatch, shop) + spec = graph(shop) + next(n for n in spec["nodes"] if n["id"] == "chal")["data"]["label"] = "反方审稿" + row = await finish(spec) + assert row.status == "succeeded", row.error + assert row.output["_issuance"]["gaps"] == [f"反方审稿:推翻了 1 句结论:「{SOUTH}」"] + # 时间线、报错里那一句照旧带主语:它们不经出具契约加前缀 + [refuted] = await logs(row.id, "challenge_refuted") + assert refuted["message"] == f"反方审稿推翻了 1 句结论:「{SOUTH}」" + + async def test_flag_only_records_and_fail_stops_the_run(monkeypatch, shop): Models(monkeypatch, shop) row = await finish(graph(shop, on_refuted="flag")) @@ -843,7 +856,7 @@ async def test_a_fail_that_would_be_swallowed_degrades_instead(monkeypatch, shop row = await finish(graph(shop, on_refuted="fail", on_error="continue")) assert row.status == "succeeded", row.error out = await output_of(row.id, "chal") - assert out["on_refuted"] == "degrade" and out["issuance_gaps"] == [f"反方审稿推翻了 1 句结论:「{SOUTH}」"] + assert out["on_refuted"] == "degrade" and out["issuance_gaps"] == [f"推翻了 1 句结论:「{SOUTH}」"] assert row.output["_issuance"]["tier"] == "degraded" diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 1aa09683..9703144d 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -446,13 +446,25 @@ async def test_choosing_the_blind_result_degrades_and_writes_the_catalog(monkeyp done = await decide(client, run.id, {"gmv": "b"}) assert done.status == "succeeded", done.error out = dual_output(done, await events(run.id)) - assert out["issuance_gaps"] == ["指标「销售额」双盲复算不一致,审核人选择了盲算结果"] + assert out["issuance_gaps"] == ["指标「销售额」两路结果不一致,审核人选择了盲算结果"] issuance = done.output["_issuance"] assert issuance["tier"] == "degraded" - assert issuance["gaps"] == ["双盲复算「复核销售额」:指标「销售额」双盲复算不一致,审核人选择了盲算结果"] + assert issuance["gaps"] == ["双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人选择了盲算结果"] assert (await notes_of(source_id)).notes["business_date"]["value"] == {"column": "paid_date"} +async def test_a_dual_check_named_after_its_type_is_named_once_in_the_gap(monkeypatch, shop, client): + """拖进画布没改名的双盲复算:缺口读成「双盲复算:指标「销售额」两路结果不一致……」,不写两遍「双盲复算」""" + name, _ = shop + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + spec = graph(name, BY_ORDER_DATE) + next(n for n in spec["nodes"] if n["id"] == "dual")["data"]["label"] = "双盲复算" + run = await start(spec) + done = await decide(client, run.id, {"gmv": "neither"}) + assert done.status == "succeeded", done.error + assert done.output["_issuance"]["gaps"] == ["双盲复算:指标「销售额」两路结果不一致,审核人判断两边都不对"] + + async def test_neither_degrades_and_writes_nothing(monkeypatch, shop, client): name, source_id = shop Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) @@ -461,7 +473,7 @@ async def test_neither_degrades_and_writes_nothing(monkeypatch, shop, client): assert done.status == "succeeded", done.error evs = await events(run.id) out = dual_output(done, evs) - assert out["issuance_gaps"] == ["指标「销售额」双盲复算不一致,审核人判断两边都不对"] + assert out["issuance_gaps"] == ["指标「销售额」两路结果不一致,审核人判断两边都不对"] assert done.output["_issuance"]["tier"] == "degraded" assert not logs(evs, "dual_check_catalog") and await notes_of(source_id) is None @@ -476,7 +488,7 @@ async def test_a_missing_choice_is_recorded_as_no_choice(monkeypatch, shop, clie evs = await events(run.id) out = dual_output(done, evs) assert out["choices"] == {} and "choice" not in out["metrics"][0] - assert out["issuance_gaps"] == ["指标「销售额」双盲复算不一致,审核人没有作出选择"] + assert out["issuance_gaps"] == ["指标「销售额」两路结果不一致,审核人没有作出选择"] [resolved] = logs(evs, "dual_check_resolved") assert resolved["choices"] == {} and resolved["gaps"] == out["issuance_gaps"] assert "没有作出选择" in resolved["message"] and "两边都不对" not in resolved["message"] @@ -505,7 +517,7 @@ async def test_on_reject_fail_stops_the_run(monkeypatch, shop, client): assert done.status == "failed" and "审核人没有采用原流程的结果" in (done.error or "") and "销售额" in done.error evs = await events(run.id) assert [d["gaps"] for d in logs(evs, "dual_check_resolved")] == [ - ["指标「销售额」双盲复算不一致,审核人选择了盲算结果"]] + ["指标「销售额」两路结果不一致,审核人选择了盲算结果"]] # 审核人的选择照样写进了目录:那是对数据的判断,和运行停不停无关 entry = await notes_of(source_id) assert entry.notes["business_date"]["value"] == {"column": "paid_date"} and entry.version == 1 diff --git a/backend/tests/test_issuance_upstream_gaps.py b/backend/tests/test_issuance_upstream_gaps.py index 92fbfc12..0df9d8e9 100644 --- a/backend/tests/test_issuance_upstream_gaps.py +++ b/backend/tests/test_issuance_upstream_gaps.py @@ -93,3 +93,9 @@ async def test_without_issuance_gaps_nothing_changes(): issuance = row.output["_issuance"] assert issuance["tier"] == "formal" assert issuance["gaps"] == [] + + +async def test_a_node_named_after_its_type_is_named_once(): + """节点名就是类型的叫法(拖进画布没改名的):前缀只写一次「数据整形:」,不写成「数据整形「数据整形」:」""" + row = await finish(graph(gaps_node("recheck", "数据整形", ["两路结果不一致"]))) + assert row.output["_issuance"]["gaps"] == ["数据整形:两路结果不一致"]