diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 8abc6ed2..5917e9b5 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -229,7 +229,8 @@ NODE_REFERENCE = """\ **凡是给人看的、带数字的报告、分析、结论,都用 report,不要用 llm**。它自动收集上游的口径卡指标、查询结果 (tool 节点和 agent 查过的库、merge 的合并结果,按查询先后编成 Q1、Q2…)、查库当时的表结构、知识库检索(按先后编成 K1、K2…) 和运行输入,模型只能写引用标记,数值由系统从证据里取出来渲染;模型自己写的数字会被打回重写: - - [[m:指标id]]:口径卡指标。比率、增幅、占比、差值这类派生计算只能先在口径卡里登记再引用 + - [[m:指标id]]:口径卡指标。比率、增幅、占比、差值这类派生计算只能先在口径卡里登记再引用。渲染出来自带单位(口径卡里的 + unit,「8条」),instructions 里不要写成「覆盖 [[m:line_count]] 条产线」这样后面再跟一个单位,写「覆盖 [[m:line_count]] 产线」 - [[v:Q1.r0.列名]]:第 1 次查询结果里第 0 行那一列的原值(行号从 0 数) - [[table:Q1 cols=列a,列b rows=0-4]]:把查询结果的几行几列原样生成表格,每一格都点得开出处 - [[i:输入字段]]:运行输入 diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 5f3f7a8e..e15404b6 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -1869,6 +1869,10 @@ _UNIT_GAP = 2 _UNIT_MAX = 10 UNIT_LOOKAHEAD = _UNIT_GAP + _UNIT_MAX + 1 _UNIT_KINDS = frozenset({"m", "i", "v"}) +#: 以一个字的单位打头的更长的单位:口径卡是「人」、正文紧跟着写「人次」,是另一个单位,不当重复的去掉(_tidy) +_LONGER_UNIT = re.compile( + r"人次|人天|人日|人月|人年|人份|件次|台次|台套|台时|车次|架次|班次|户次|批次|个月|个季度|个工作日|个百分点" + r"|小时|分钟|吨公里|公里|公斤|千克|千米|千瓦|万元|亿元|美元|港元|欧元|日元|元/") #: 渲染结果末尾的单位:最后一个数字后面的字(12,345人次 → 人次,4.57万元 → 万元,12.5% → %) _UNIT_TAIL = re.compile(r"\d([^\d\s][^\d]*)$") _BLANK = " \t\u3000" @@ -1924,8 +1928,9 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: """渲染前收拾两处排版,数和引用都不动: - 数字标记后面重复写的单位去掉。[[m:visits]] 渲染出来已经带单位(12,345人次),写作者常常 - 还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字时 - 不动:写作者写的可能是另一个单位(口径卡是「人」,正文写「人次」),硬删会把不一致藏起来; + 还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字、和它 + 连起来是一个更长的单位时(_LONGER_UNIT)不动:写作者写的是另一个单位(口径卡是「人」,正文写「人次」), + 硬删会把不一致藏起来;连起来不是单位的(「覆盖 [[m:lines]] 条产线」,口径卡的单位是「条」)照样去掉; - [[see:…]] 渲染成空,它周围留下的空白、括号、标点去掉(_voids、_see_cuts):「最高月份 [[see:Q2]]。」会剩下 「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。依据(连同去掉的字)两边 挨上的字会读成一串的,补一个空格(_glue):「4,919.5[[see:Q1]]212 笔」会读成「4,919.5212 笔」。 @@ -1942,7 +1947,8 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: hit = re.compile(f"[{_BLANK}]{{0,{_UNIT_GAP}}}" + re.escape(unit)).match(text, marker["end"]) if hit: after = text[hit.end():hit.end() + 1] - if len(unit) > 1 or not after.isalnum(): + longer = _LONGER_UNIT.match(text, hit.end() - len(unit)) + if len(unit) > 1 or not after.isalnum() or not (longer and len(longer.group(0)) > len(unit)): cuts.append((marker["end"], hit.end())) see: list[tuple[int, int]] = [] spaces: set[int] = set() @@ -3390,6 +3396,11 @@ SEE_AND_CODE_RULES = ( "(「状态为 9」「类型 1」):码值是没有出处的数字,系统会当成裸数字打回重写。" ) +#: [[m:]]、[[v:]] 渲染出来自带单位(部署上撞过「覆盖 8条 条产线」:口径卡的单位是「条」,写作者照着助手的指令又写了一个)。 +#: 渲染时紧跟着的同一个单位会去掉(_tidy),这里先说给写作者听 +UNIT_RULE = ("数字标记渲染出来自带单位([[m:…]] 是口径卡里的单位,比如「8条」「12,345人次」),后面不要再写一遍单位:" + "写「覆盖 [[m:line_count]] 产线」,不写「覆盖 [[m:line_count]] 条产线」。") + #: 相对日期(「今天、昨天、上周、本月、去年」)一律按运行当天算(period_rule)。部署上撞过:数据只更新到两周前,查库时拿表里 #: 最大的日期往前推七天当「上周」,写作者照着写成「上周产量……」。写作者不知道今天是哪天,就分不出数据有没有覆盖问的那段, #: 所以把运行当天和上周的起止日期直接告诉它:没覆盖时先照实说、写明数据最新到哪天;最近一段的数可以给,但写具体起止日期 diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 322fe5ab..00ab5795 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -57,7 +57,7 @@ from app.engine.evidence import ( ENTITY_KINDS, MARKER_RULES, SEE_AND_CODE_RULES, - period_rule, + UNIT_RULE, StreamRenderer, WriterCatalog, build_catalog, @@ -65,6 +65,7 @@ from app.engine.evidence import ( compose_doc, describe_violations, ledger_enabled, + period_rule, withhold_metrics, writer_catalog, ) @@ -646,7 +647,7 @@ def _messages(ctx: NodeContext, state: GraphState, writer: WriterCatalog, claims system = ctx.render_str(ctx.cfg("system", ""), state) instructions = ctx.render_str(ctx.cfg("instructions", ""), state).strip() \ or "根据下面的证据写一份简洁的报告,先总后分。" - rules = f"{MARKER_RULES}\n{SEE_AND_CODE_RULES}\n{period_rule(run_today())}" + rules = f"{MARKER_RULES}\n{SEE_AND_CODE_RULES}\n{UNIT_RULE}\n{period_rule(run_today())}" if writer.guarded: # 写作者看不全证据(目录有省略、有来源的结构化结论为空):「没有查到」这类结论没有依据 rules = f"{rules}\n{ABSENCE_RULE}" diff --git a/backend/tests/test_evidence_tidy_claims.py b/backend/tests/test_evidence_tidy_claims.py index 2bae8038..d226b8c1 100644 --- a/backend/tests/test_evidence_tidy_claims.py +++ b/backend/tests/test_evidence_tidy_claims.py @@ -37,6 +37,7 @@ CARD = { metric("gmv", "销售额", 45678.5, "元", 1), metric("yoy", "同比增幅", 12.5, "%", 1, "plain"), metric("gone", "缺数", None, "人次"), + metric("lines", "产线条数", 8, "条"), ], } LEDGER = [{"kind": "metric_set", "node_id": "cal", "exec": 1, "artifact": ART, "caliber": "客流口径", @@ -108,6 +109,10 @@ def test_a_definition_with_a_number_is_still_a_claim(catalog): ("销售额 [[m:gmv|万]] 万元。", "销售额 4.57万元。"), ("同比 [[m:yoy]] %。", "同比 12.5%。"), ("客流 [[m:visits]] 人次的水平", "客流 12,345人次的水平"), # 多字单位后面跟着字,照样去掉 + # 单字的单位后面紧跟着字:和单位连起来不是一个更长的单位(「条产」),照样去掉。部署上撞过「覆盖 8条 条产线」 + ("覆盖 [[m:lines]] 条产线。", "覆盖 8条产线。"), + ("覆盖[[m:lines]]条产线。", "覆盖8条产线。"), + ("到访 [[m:heads]] 人参加。", "到访 1,200人参加。"), ]) def test_a_repeated_unit_is_dropped(catalog, raw, shown): doc = compose_doc(raw, catalog) @@ -117,6 +122,17 @@ def test_a_repeated_unit_is_dropped(catalog, raw, shown): assert verify_doc(doc, catalog)["violations"] == [] +def test_the_writer_and_the_copilot_are_told_markers_carry_their_unit(): + """助手写报告指令、写作者写正文,都先知道 [[m:]] 渲染出来自带单位、后面不要再写一遍。""" + from app.api.copilot import NODE_REFERENCE + from app.engine.evidence import UNIT_RULE + + start = NODE_REFERENCE.index("- report:报告撰写") + report = NODE_REFERENCE[start:NODE_REFERENCE.index("\n- output 的出具契约", start)] + assert "自带单位" in report and "条产线" in report + assert "自带单位" in UNIT_RULE and "后面不要再写" in UNIT_RULE + + @pytest.mark.parametrize(("raw", "shown"), [ # 口径卡的单位是「人」,写作者写的是「人次」:不是同一个单位,不动,让不一致露出来 ("到访 [[m:heads]] 人次。", "到访 1,200人 人次。"), @@ -188,9 +204,10 @@ def _stream(catalog, raw, sizes): @pytest.mark.parametrize("size", [1, 2, 3, 5, 8, UNIT_LOOKAHEAD, 40]) def test_stream_drops_the_repeated_unit_and_the_blank_like_the_full_render(catalog, size): - raw = "客流 [[m:visits]] 人次,为最高月份 [[see:m:visits]]。到访 [[m:heads]] 人次 [[see:m:heads]]\n末尾 [[m:gmv]] 元" + raw = ("客流 [[m:visits]] 人次,为最高月份 [[see:m:visits]]。到访 [[m:heads]] 人次 [[see:m:heads]]\n" + "覆盖 [[m:lines]] 条产线,末尾 [[m:gmv]] 元") full = render_markers(raw, catalog) - assert full == "客流 12,345人次,为最高月份。到访 1,200人 人次\n末尾 45,678.5元" + assert full == "客流 12,345人次,为最高月份。到访 1,200人 人次\n覆盖 8条产线,末尾 45,678.5元" out = _stream(catalog, raw, [size] * len(raw)) assert "".join(out) == full, out @@ -209,7 +226,8 @@ def test_stream_holds_the_blank_before_see_until_it_knows_what_follows(catalog, def _corpus(n): rng = random.Random(20260930) pieces = ["[[m:visits]]", "[[m:gmv|万]]", "[[m:heads]]", "[[m:yoy]]", "[[see:m:visits]]", "[[see:m:yoy]]", - "人次", "人", "次", "万元", "元", "%", " ", " ", " ", "\n", "。", ",", "文字", "[[", "]]", "["] + "[[m:lines]]", "条", "产线", "人次", "人", "次", "万元", "元", "%", " ", " ", " ", "\n", "。", ",", + "文字", "[[", "]]", "["] return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 16))) for _ in range(n)] diff --git a/backend/tests/test_relative_dates.py b/backend/tests/test_relative_dates.py index ced2ff07..58a2a32f 100644 --- a/backend/tests/test_relative_dates.py +++ b/backend/tests/test_relative_dates.py @@ -58,3 +58,6 @@ async def test_the_report_prompt_carries_the_run_day(monkeypatch): await finish(graph) prompt = "\n".join(str(m.content) for m in seen[0]) assert "今天是 2031-03-12(周三)" in prompt and "上周是 2031-03-03 至 2031-03-09" in prompt + from app.engine.evidence import UNIT_RULE + + assert UNIT_RULE in prompt, "[[m:]] 自带单位的那条也在写作规则里"