From 31531925a9c5cd0dd4a1615959d800b795605984 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 4 Oct 2026 02:24:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=86=99=E4=BD=9C=E6=8C=87=E5=BC=95=E8=AF=B4?= =?UTF-8?q?=E6=B8=85=20[[see:]]=20=E5=8F=AA=E6=94=BE=E5=8F=A5=E6=9C=AB?= =?UTF-8?q?=E3=80=81=E7=A0=81=E5=80=BC=E5=86=99=E5=90=AB=E4=B9=89=EF=BC=88?= =?UTF-8?q?A4=E3=80=81B4=EF=BC=89=EF=BC=9A=E5=86=99=E4=BD=9C=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=90=8E=E9=9D=A2=E5=8A=A0=E4=B8=A4=E6=9D=A1=EF=BC=8C?= =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E6=A0=87=E8=AE=B0=E6=B8=B2=E6=9F=93=E5=90=8E?= =?UTF-8?q?=E4=B8=8D=E6=98=BE=E7=A4=BA=EF=BC=8C=E5=86=99=E8=BF=9B=E5=8F=A5?= =?UTF-8?q?=E5=AD=90=E4=B8=AD=E9=97=B4=E4=BC=9A=E7=95=99=E4=B8=8B=E3=80=8C?= =?UTF-8?q?=E4=BB=A5=20=E4=B8=BA=E4=BE=9D=E6=8D=AE=E3=80=8D=E8=BF=99?= =?UTF-8?q?=E6=A0=B7=E7=9A=84=E6=96=AD=E5=8F=A5=EF=BC=9B=E6=B6=89=E5=8F=8A?= =?UTF-8?q?=E7=A0=81=E5=80=BC=E6=97=B6=E5=86=99=E5=90=AB=E4=B9=89=EF=BC=88?= =?UTF-8?q?=E3=80=8C=E4=BD=9C=E5=BA=9F=E8=AE=B0=E5=BD=95=E3=80=8D=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=99=E7=A0=81=E5=80=BC=E6=9C=AC=E8=BA=AB?= =?UTF-8?q?=EF=BC=8C=E6=95=B0=E5=AD=97=E6=A0=B8=E5=AF=B9=E4=B8=8D=E6=94=BE?= =?UTF-8?q?=E5=AE=BD=E3=80=82=E7=BB=84=E8=A3=85=E6=8A=A5=E5=91=8A=E6=97=B6?= =?UTF-8?q?=E8=AE=A4=E5=87=BA=E6=8B=BF=E5=BE=97=E5=87=86=E7=9A=84=E5=8F=A5?= =?UTF-8?q?=E4=B8=AD=E5=86=99=E6=B3=95=EF=BC=88=E3=80=8C=E4=BB=A5=20[[see:?= =?UTF-8?q?=E2=80=A6]]=20=E4=B8=BA=E3=80=8D=E3=80=8C=E6=A0=B9=E6=8D=AE?= =?UTF-8?q?=E3=80=81=E6=8C=89=E7=85=A7=E3=80=81=E5=9F=BA=E4=BA=8E=E3=80=81?= =?UTF-8?q?=E5=8F=82=E8=A7=81=E3=80=81=E8=AF=A6=E8=A7=81=20[[see:=E2=80=A6?= =?UTF-8?q?]]=E3=80=8D=EF=BC=89=E8=AE=B0=20inline=5Fsee=EF=BC=8C=E4=BA=A4?= =?UTF-8?q?=E5=9B=9E=E5=86=99=E4=BD=9C=E8=80=85=E9=87=8D=E5=86=99=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E5=86=99=E4=B8=8D=E5=A5=BD=E4=B9=9F=E4=B8=8D=E5=88=A4?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=EF=BC=9B=E5=8A=A9=E6=89=8B=E5=86=99=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E8=8A=82=E7=82=B9=E7=9A=84=E6=8C=87=E5=BC=95=E5=90=8C?= =?UTF-8?q?=E6=A0=B7=E5=86=99=E6=98=8E=E8=BF=99=E4=B8=A4=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/copilot.py | 5 +- backend/app/engine/evidence.py | 35 ++++++++- backend/app/engine/nodes/report.py | 17 +++- backend/tests/test_report_see_placement.py | 90 ++++++++++++++++++++++ 4 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_report_see_placement.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index f253d126..54732cfc 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -219,7 +219,10 @@ NODE_REFERENCE = """\ 查过的数据源里真实存在的名字,表结构、查询、结果列里都没有的会被标成「可能是编造的名字」(受管模板的正式出具因此降档) - [[q:K1|逐字引文]]:知识库检索的原话,必须和命中片段里的字逐字一致(空白不计,至少 4 个字),改一个字就算 没有出处;只是拿检索当依据时写 [[see:K1]]。要引原话,在 report 前面接 retrieve 节点 - - 句末的 [[see:m:指标id,Q1,K1]]:这句结论的依据,不显示 + - 句末的 [[see:m:指标id,Q1,K1]]:这句结论的依据,不显示,所以只能放在句末;instructions 里不要写「以 [[see:…]] 为依据」 + 「根据 [[see:…]]」这样把它当成句子成分的示例,模型照着写会留下「以 为依据」这样的断句 + - 涉及码值(状态、类型这类用数字编码的取值)时,instructions 里让模型写它的含义(「作废记录」),不要写码值本身: + 码值是没有出处的数字,会被数字核对打回重写 metrics_from 不写就取所有上游口径卡;on_violation 不写时探索运行是 flag(标出来、照常产出),正式运行是 fail; numbers 默认 strict(裸数字算违规)。claims 是结论句策略:off(默认,不管)/ require_citation(每句结论都要 挂引用,没挂的计入缺口、出具降档)/ judge(在 require_citation 之外,再请另一个模型按每句挂的依据判断支不支持: diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 0bd84461..62ebaf79 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -2631,7 +2631,8 @@ def compose_doc( "markdown": markdown, "source": source, "catalog": kept, "blocks": out_blocks, } checked = verify_doc(doc, catalog, allow_numbers=allow_numbers, cells_allowed=cells_allowed, loader=snaps) - doc["stats"], doc["violations"] = checked["stats"], checked["violations"] + # 句中的 [[see:]] 只在组装时查(交回写作者重写):出口复核看的是数字和出处,句子通不通不影响出具 + doc["stats"], doc["violations"] = checked["stats"], checked["violations"] + inline_see(source) return doc @@ -2999,6 +3000,38 @@ MARKER_RULES = """写作规则(系统会逐字核对): 4. 日期、ISO 周(2026-W37)、「前 3 名」「第 2 季度」这类序号可以直接写。 5. 目录里没有的指标不要编造引用;标着「没有值」的指标不要写进报告。""" +#: 跟在写作规则后面的两条(R2 走查 A4、B4):写作规则本身保持一期那份,这两条另起一段 +#: - [[see:]] 渲染后不显示:写进句子中间(「以 [[see:Q1]] 为依据看」)会留下「以 为依据看」这样的断句; +#: - 码值(status = 9 里的 9)是没有出处的数字,会被数字核对打回重写:写它的含义,不写码值本身。 +SEE_AND_CODE_RULES = ( + "另外两条:\n" + "- [[see:…]] 渲染后不显示,只能挂在句末(句号前后都行)。不要把它写进句子当成分:「以 [[see:Q1]] 为依据看,……」" + "「根据 [[see:Q1]],……」渲染后会变成「以 为依据看」「根据 ,」这样的断句。先说结论,句末再写 [[see:Q1]]。\n" + "- 涉及码值(状态、类型这类用数字编码的取值)时,写它的含义,比如「作废记录」「有效记录」,不要写码值本身" + "(「状态为 9」「类型 1」):码值是没有出处的数字,系统会当成裸数字打回重写。" +) + +#: 句中的 [[see:]]:前面是介词、后面接着句子的写法(「以 [[see:Q1]] 为依据」「根据 [[see:Q1]],」「详见 [[see:Q1]]」)。 +#: 只认这几种拿得准的:「以」前面是「所、可、加……」的是另一个词(所以、可以),不算;「依据 [[see:…]]。」可能是 +#: 「这是结论的依据」,不算。认出来就交回写作者重写(report.REWRITE_ONLY:重写不好也不判失败) +_SEE_MARK = r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]" +_INLINE_SEE = re.compile( + r"(? list[dict[str, Any]]: + """原文里写在句子中间的 [[see:]](_INLINE_SEE),每处一条违规 inline_see。text 是那一截原文,没有 span: + 标记渲染后不在正文里,定位不到片段。""" + return [_violation("inline_see", INLINE_SEE_MESSAGE, text=m.group(0)) for m in _INLINE_SEE.finditer(source or "")] + + #: 引用查询结果的写法。只跟着目录里的查询出现(catalog_prompt):写作规则本身保持一期那份,升级前 #: 发起的运行跑到报告节点时提示一字不差;只有口径卡的目录也不该教写作者去引用不存在的 Q1 CELL_RULES = ( diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 24516992..1fcd50c4 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -48,6 +48,7 @@ from app.engine.evidence import ( CLAIMS_RULE, ENTITY_KINDS, MARKER_RULES, + SEE_AND_CODE_RULES, StreamRenderer, build_catalog, catalog_prompt, @@ -80,6 +81,8 @@ CLAIMS = CLAIMS_VALUES #: 只标注、不让写作者重写也不判失败的违规:可疑实体(受管的正式出具按缺口降档)、表结构快照不全时 #: 核对不了的名字(出口只标注) SOFT = frozenset({"unknown_entity", "unverified_entity"}) +#: 交回写作者重写、重写不好也不判失败的违规:句中的 [[see:]](渲染后留下断句,数字和出处都没问题) +REWRITE_ONLY = frozenset({"inline_see"}) #: 重写一次就是多一次整篇的模型调用,写不对的模型多给几次也大多写不对 MAX_REPAIRS = 3 @@ -304,6 +307,11 @@ def _blocking(violations: list[dict[str, Any]], numbers: str) -> list[dict[str, and not (numbers == "off" and v.get("code") == "uncited_number")] +def _fatal(blocking: list[dict[str, Any]]) -> list[dict[str, Any]]: + """要判失败的违规:重写之后还在的、不属于 REWRITE_ONLY 的。""" + return [v for v in blocking if v.get("code") not in REWRITE_ONLY] + + def _violation_key(violation: dict[str, Any]) -> tuple[str, str]: """认一处违规:(code, 点名的字)。点名的字按 text、ref、message 的顺序取第一个有的。""" named = violation.get("text") or violation.get("ref") or violation.get("message") or "" @@ -348,7 +356,7 @@ def _messages(ctx: NodeContext, state: GraphState, catalog: dict[str, Any], cell system = ctx.render_str(ctx.cfg("system", ""), state) instructions = ctx.render_str(ctx.cfg("instructions", ""), state).strip() \ or "根据下面的证据写一份简洁的报告,先总后分。" - rules = MARKER_RULES + rules = f"{MARKER_RULES}\n{SEE_AND_CODE_RULES}" if claims in ("require_citation", CLAIMS_JUDGE): # 裁判只看写作者挂的依据:要判,就更得每句都挂上 rules = f"{rules}\n{CLAIMS_RULE}" + (f"\n{judging.JUDGE_RULE}" if claims == CLAIMS_JUDGE else "") @@ -451,7 +459,7 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 探索运行默认按需裁判:点开哪句才判哪句,节点里不花这笔钱 judgement = judging.on_demand(doc, on_unsupported=judge_cfg["on_unsupported"], rewrite_once=judge_cfg["rewrite_once"], writer_model=model_id) - elif not (blocking and on_violation == "fail"): + elif not (_fatal(blocking) and on_violation == "fail"): # 反正要判失败的报告不值得再花钱判(节点紧接着就报错) doc, response, blocking, judgement, usages = await _judge_inline( ctx, doc, catalog, judge_cfg, model_id=model_id, messages=[*messages, response], @@ -461,10 +469,11 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: doc_artifact = await _store(doc, ctx) ctx.emit(EventType.REPORT_CHECKED, doc_artifact=doc_artifact, ok=not doc["violations"], stats=doc["stats"], violations=doc["violations"][:20], repairs=repairs, - on_violation=on_violation, failed=bool(blocking) and on_violation == "fail", + on_violation=on_violation, failed=bool(_fatal(blocking)) and on_violation == "fail", **({"claims": claims} if evidence_on else {}), **({"judge": judgement} if judgement is not None else {})) - if blocking and on_violation == "fail": + if (fatal := _fatal(blocking)) and on_violation == "fail": + blocking = fatal head = ";".join(v["message"] for v in blocking[:5]) more = f";…另有 {len(blocking) - 5} 处" if len(blocking) > 5 else "" raise NodeError( diff --git a/backend/tests/test_report_see_placement.py b/backend/tests/test_report_see_placement.py new file mode 100644 index 00000000..c786492f --- /dev/null +++ b/backend/tests/test_report_see_placement.py @@ -0,0 +1,90 @@ +"""写作指引:[[see:]] 只放句末;涉及码值时写含义、不写码值本身(R2 走查的 A4、B4)。 + +- A4:报告正文出现「以 为依据看」这样的断句。[[see:]] 渲染后不显示,写作者把它写进了句子中间 + (「以 [[see:Q1]] 为依据看,……」)。指引写明只放句末;组装文档时能稳妥认出的写法(「以 [[see:…]] 为」 + 「根据 [[see:…]]」之类)报出来,交回写作者重写;重写不好也不判失败——数字和出处都没问题,只是句子不通。 +- B4:报告核对把码值 1、0、9 当成没有出处的数字,每次运行都强制重写一轮。数字核对不放宽(不可回退的约定), + 指引里写明:涉及码值时写它的含义(「作废记录」),不写码值本身。 +""" +from __future__ import annotations + +import pytest + +from app.api.copilot import NODE_REFERENCE as COPILOT_PROMPT +from app.engine.evidence import SEE_AND_CODE_RULES, build_catalog, compose_doc +from app.engine.nodes.report import REWRITE_ONLY +from tests.test_report_node import ( # noqa: F401 - 夹具按名字引入 + CLEAN, + engine_up, + events, + finish, + script, + weekly, +) + + +def test_the_writer_is_told_to_put_see_at_the_end_and_write_code_meanings(): + assert "[[see:…]]" in SEE_AND_CODE_RULES and "句末" in SEE_AND_CODE_RULES + assert "以 [[see:Q1]] 为依据" in SEE_AND_CODE_RULES # 点名不要这样写 + assert "码值" in SEE_AND_CODE_RULES and "含义" in SEE_AND_CODE_RULES and "作废记录" in SEE_AND_CODE_RULES + + +def test_the_copilot_tells_report_instructions_the_same(): + """助手写报告节点的指引(copilot 的系统提示)同样说清:see 只放句末,码值写含义。""" + assert "只能放在句末" in COPILOT_PROMPT and "以 [[see:" not in COPILOT_PROMPT.replace("不要写「以 [[see:", "") + assert "码值" in COPILOT_PROMPT and "含义" in COPILOT_PROMPT + + +@pytest.fixture +def catalog(): + card = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", + "metrics": [{"id": "gmv", "name": "销售额", "value": 45678.5, "unit": "元", "format": "thousands"}]} + return build_catalog(nodes={"card": card}, ledger=[{"kind": "metric_set", "node_id": "card", "artifact": "c" * 64}]) + + +@pytest.mark.parametrize("text", [ + "以 [[see:m:gmv]] 为依据看,本周销售额 [[m:gmv]] 偏高。", + "以[[see:m:gmv]]为依据,本周销售额 [[m:gmv]] 偏高。", + "根据 [[see:m:gmv]],本周销售额 [[m:gmv]] 偏高。", + "本周销售额 [[m:gmv]] 偏高,详见 [[see:m:gmv]]。", + "基于 [[see:m:gmv]] 的结果,本周销售额 [[m:gmv]] 偏高。", +]) +def test_see_used_as_part_of_the_sentence_is_flagged(catalog, text): + doc = compose_doc(text, catalog) + [v] = [v for v in doc["violations"] if v["code"] == "inline_see"] + assert "[[see:" in v["text"] and "句末" in v["for_model"] + assert "断句" in v["message"] and "[[see:" not in doc["markdown"] + + +@pytest.mark.parametrize("text", [ + "本周销售额 [[m:gmv]] 偏高。[[see:m:gmv]]", + "本周销售额 [[m:gmv]] 偏高 [[see:m:gmv]]。", + "本周销售额 [[m:gmv]] 偏高 [[see:m:gmv]],所以 [[see:m:gmv]] 下周要关注。", # 「所以」不是介词 + "这是结论的依据 [[see:m:gmv]]。", + "可以 [[see:m:gmv]]。", +]) +def test_see_at_the_end_of_a_clause_is_fine(catalog, text): + doc = compose_doc(text, catalog) + assert not [v for v in doc["violations"] if v["code"] == "inline_see"], doc["violations"] + + +INLINE = "## 本周概览\n\n以 [[see:m:gmv]] 为依据看,本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]]。" + + +async def test_inline_see_is_sent_back_for_one_rewrite(engine_up, monkeypatch): + seen = script(monkeypatch, INLINE, CLEAN) + row = await finish(weekly({"on_violation": "fail"})) + assert row.status == "succeeded", row.error + assert len(seen) == 2, "句中的依据应当交回重写一次" + assert "只能挂在句末" in str(seen[1][-1].content) + + +async def test_inline_see_that_survives_the_rewrite_does_not_fail_the_node(engine_up, monkeypatch): + """数字、出处都对得上,只是句子不通:重写还这样也不判失败,留在核对结果里。""" + assert "inline_see" in REWRITE_ONLY + seen = script(monkeypatch, INLINE) + row = await finish(weekly({"on_violation": "fail"})) + assert row.status == "succeeded", row.error + assert len(seen) == 2 + [checked] = [e.data for e in await events(row.id, "report.checked")] + assert checked["failed"] is False and [v["code"] for v in checked["violations"]] == ["inline_see"]