写作指引说清 [[see:]] 只放句末、码值写含义(A4、B4):写作规则后面加两条,依据标记渲染后不显示,写进句子中间会留下「以 为依据」这样的断句;涉及码值时写含义(「作废记录」),不写码值本身,数字核对不放宽。组装报告时认出拿得准的句中写法(「以 [[see:…]] 为」「根据、按照、基于、参见、详见 [[see:…]]」)记 inline_see,交回写作者重写,重写不好也不判失败;助手写报告节点的指引同样写明这两条

This commit is contained in:
ThearchyHelios
2026-10-04 02:24:49 +08:00
parent 99ca98a87d
commit 31531925a9
4 changed files with 141 additions and 6 deletions
+4 -1
View File
@@ -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 之外,再请另一个模型按每句挂的依据判断支不支持:
+34 -1
View File
@@ -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"(?<![所可加予得难足用是何之])以[ \t\u3000]*" + _SEE_MARK + r"[ \t\u3000]*为"
r"|(?:根据|按照|基于|参见|详见)[ \t\u3000]*" + _SEE_MARK
)
INLINE_SEE_MESSAGE = _Reason(
"依据标记写在了句子中间:依据不显示,正文会留下「以 为依据」这样的断句",
"依据标记 [[see:…]] 写在了句子中间。它渲染后不显示,只能挂在句末:把这一句改成先说结论、句末再写 [[see:…]],"
"不要写「以 [[see:Q1]] 为依据」「根据 [[see:Q1]]」「详见 [[see:Q1]]」",
)
def inline_see(source: str) -> 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 = (
+13 -4
View File
@@ -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(
@@ -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"]