fix(extract): ★ 一律按实质性条款处理(口径 Q2 修订)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

文件把 ★ 定义成计分项(S02「加★为核心技术参数」)时不再降为「重要」:
删掉 star_scored / STAR_IMPORTANT 及相关分支,不再生成 meaning = important 的标注规则。
S02 的 11 条 ★ 要求离线验证全部为实质性。决策记入 docs/decisions.md。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 11:51:25 +08:00
parent 1966ce9b15
commit 3a7e3d7f3f
4 changed files with 21 additions and 65 deletions

View File

@@ -50,7 +50,7 @@ Fable 用样本外的写法逐条验证过,标「已验证」的是复现过
| `INVALID` 新增「不得参加 / 被拒绝 / 拒绝接受」 | 「评标委员会成员不得参加评审」「不得参加后续培训」「甲方有权拒绝接受不合格货物」(已验证) | 一般要求冒充写明无效;只排除了合同章,而且前提是章识别成功 |
| 概括句过滤 `BLANKET` / leads | 「上述证书原件须在开标时携带,否则投标无效」本身是一条要求(已验证) | 被当成引导句丢掉,兜底也不补 |
| ★ 节 `star_sections` | 「★1 核心交换机」被当成节标题(已验证);后面没有同级编号时,范围最多延伸 800 句 | S10 一个标题覆盖 381 句、产出 225 条致命,没有人逐条核对 |
| ★ 降级 `star_scored`(Q2) | 后果写在不带 ★ 的句子里(「重要技术参数负偏离超过三项的,投标无效」)(已验证) | 真正的废标 ★ 被显示成「重要」 |
| ~~★ 降级 `star_scored`(Q2)~~ | 2026-11-05 Q2 修订后 ★ 一律按实质性,降级已删除,这条风险不再存在 | — |
| 评标方法 `EVAL_METHOD` | 招标投标体系的「综合评估法」「经评审的最低投标价法」 | 项目信息答不出 |
| 阈值 | 表后往回 40 句、行长 ≤ 60 字、★ 节范围 800 句、最后一章不超过一半、92% | 都是看样本定的,没有样本外依据 |
@@ -88,7 +88,7 @@ Fable 用样本外的写法逐条验证过,标「已验证」的是复现过
- 「拒绝接受」只认主语是采购人或代理机构、对象是投标文件的写法;
- 按法律体系补「作废标处理」「予以废标」(招标投标体系里等于否决投标;政府采购里「废标」多指整个采购终止,不能混用)。
4. **概括句过滤**:只在上下确实有编号列表时生效,句子本身写着具体要求的不过滤。
5. **★ 降级**:带后果的句子里没有 ★,但提到了 ★ 的定义词(「核心技术参数」「重要技术参数」),也不降级。
5. ~~**★ 降级**~~:不再需要(2026-11-05 Q2 修订:★ 一律按实质性)。
6. **章识别**:
- 支持「第1章」「第一部分」、章号和标题拆成两句;
- 认不出时计数 `chapters_unreliable`,写进解析结果;

View File

@@ -4,6 +4,14 @@
---
## Q2(修订)· 2026-11-05 · ★ 一律按实质性条款处理
| # | 决策 | 理由 |
|---|---|---|
| Q2 | 修订原决策:文件把 ★ 定义成计分项(如 S02「加★为核心技术参数」、不满足只扣分)时,**也不再降为「重要」**,★ 一律按实质性要求;不再生成 meaning = important 的标注规则 | 用户在读标、查标走查后决定。走查时 S02 的 ★ 条款显示「重要」、负偏离只报「重要」,和用户对 ★ 的理解不一致。按实质性处理的代价是计分类 ★ 的负偏离会报成致命,用户可以「确认无误」并写说明(会作为误报反馈);降级的风险是漏掉真正导致无效的条款 |
- **影响**:`checklist/extract.py` 删掉 `star_scored` 和 `STAR_IMPORTANT`;已解析的项目要重新解析才会变。查标评测开发集里 S02 的级别分布会变(致命级多出 ★ 条款),下次跑评测时注意和旧数字不可直接比。
## AD1–AD6 · 2026-11-05 · 补遗追加:全部按建议
依据 `docs/architecture/addendum-plan.md` §7。

View File

@@ -65,9 +65,6 @@ CN_DIGIT = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4
CN_UNIT = {"十": 10, "百": 100, "千": 1000}
END_PUNCT = "。;;,,、::!!??"
OUR_MARKUP = re.compile(r"</?(?:iu|u|i)>|〔(?:续下页|上级:[^〕]*|表后说明:[^〕]*)〕")
# ★ 的含义被文件定义成别的(「加★为核心技术参数」),而且没有任何地方说 ★ 是实质性要求或不满足就无效(口径 Q2)
STAR_DEFINED = re.compile(r"(?:加|标|打|带)\s*[“\"]?★[”\"]?\s*号?\s*(?:的|为|是|表示)+\s*([^,。;]{0,12})")
STAR_IMPORTANT = "star:important" # 放进 Doc.declared:这份文件的 ★ 只是「重要」,不是实质性
# 「必须」「应」字样的要求都是实质性要求(S07 第 18 页):只在文件层面提示,不逐句抽(口径 Q3)
KEYWORD_NOTE = re.compile(r"[“\"](?:必须|应|应当|应(应当))[”\"]")
SID = re.compile(r"^p\d+s\d+$")
@@ -192,42 +189,13 @@ def marking_notes(sentences: list[Sentence]) -> list[MarkingNote]:
markers.add("keyword")
m = DECLARED.search(s.text)
notes.append(MarkingNote(markers=markers, sentence=s, declared_count=int(m.group(1)) if m else None))
notes = notes[:10]
if not any("star" in n.markers for n in notes) and (d := star_scored(sentences)) is not None:
notes.append(MarkingNote(markers={"star"}, sentence=d, declared_count=None, meaning="important"))
return notes
STAR_CONSEQUENCE = re.compile(r"废标|负偏离|不得偏离|未响应|不响应|不予通过|实质|否决|拒绝")
SCORED = re.compile(r"扣\s*\d|减\s*\d|扣分|得分|加分|计分|评分")
def star_scored(sentences: list[Sentence]) -> Sentence | None:
"""★ 只是计分项的定义句(口径 Q2,S02「备注:加★为核心技术参数」),没有就返回 None。
降级的代价是把致命问题显示成「重要」,所以条件从严(顾问审读第 1 条):
1. 定义句说 ★ 是什么(「核心技术参数」),这句本身不带任何后果写法;
2. 有计分证据:这个说法出现在写着扣分、得分的句子里(S02 p29「每有一项核心技术参数不满足……减 4 分」);
3. 全文没有一句带 ★ 的话写着无效、废标、负偏离、未响应、实质性这类后果。
"""
if any("★" in s.text and (INVALID.search(s.text) or STAR_CONSEQUENCE.search(s.text)) for s in sentences):
return None
for s in sentences:
m = STAR_DEFINED.search(s.text)
if not m or INVALID.search(s.text) or STAR_CONSEQUENCE.search(s.text) or re.search(r"必须|关键", s.text):
continue
term = re.sub(r"^[的为是]+", "", m.group(1)).strip()
if len(term) >= 4 and any(term in x.text and SCORED.search(x.text) for x in sentences if x is not s):
return s
return None
return notes[:10]
def build_doc(sentences: list[Sentence]) -> Doc:
"""一份文档给各抽取任务共用的结构信息(解析流水线和离线评测都用它)。"""
notes = marking_notes(sentences)
declared = {m for n in notes if n.meaning == "substantive" for m in n.markers}
if any(n.meaning == "important" and "star" in n.markers for n in notes):
declared.add(STAR_IMPORTANT)
return Doc(
sentences=sentences,
parents=structure.parents(sentences),
@@ -236,7 +204,7 @@ def build_doc(sentences: list[Sentence]) -> Doc:
declared=declared,
chapters=structure.chapters(sentences),
trailing=structure.trailing_notes(sentences),
star_scope=structure.star_sections(sentences) if STAR_IMPORTANT not in declared else {},
star_scope=structure.star_sections(sentences),
regime=legal_regime_by_rule(sentences),
)
@@ -254,7 +222,7 @@ def is_marked(s: Sentence, declared: set[str]) -> str | None:
if is_marker_talk(s):
return None
if "★" in s.text:
return None if STAR_IMPORTANT in declared else "star"
return "star" # ★ 一律按实质性要求(口径 Q2 修订)
if "asterisk" in declared and ASTERISK.match(s.text.strip()):
return "asterisk"
iu = sum(e - b for b, e, f in s.fmt_runs if "i" in f and "u" in f)
@@ -270,7 +238,7 @@ def inherited_star(s: Sentence, doc: Doc) -> bool:
def importance_of(sents: list[Sentence], doc: Doc) -> tuple[str, str | None]:
"""重要性按原文标注确定,不信模型:★ / 声明过的标注 / ★ 节里的句子 → 实质性;▲ → 重要。
文件把 ★ 定义成计分项时(口径 Q2),★ 和 ▲ 一样是重要。"""
文件把 ★ 定义成计分项(「加★为核心技术参数」)也按实质性(口径 Q2 修订,2026-11-05)。"""
if any(is_marked(s, doc.declared) or inherited_star(s, doc) for s in sents):
return "substantive", "marker"
if any("▲" in s.text or "★" in s.text for s in sents):
@@ -349,14 +317,7 @@ def _render(chunk: list[Sentence], doc: Doc, marks: bool = False) -> str:
if s.key in doc.continuations:
line += "〔续下页〕" # 这句在页尾被截断,下一行是它在下一页的接续部分
lines.append(line)
notes = (
"\n".join(
f"- {n.sentence.text[:200]}"
+ ("(本文件的 ★ 只是计分项,不是实质性标注)" if n.meaning == "important" else "")
for n in doc.notes
)
or "(未找到标注规则说明)"
)
notes = "\n".join(f"- {n.sentence.text[:200]}" for n in doc.notes) or "(未找到标注规则说明)"
return "## 标注规则说明\n" + notes + "\n\n## 待审分块\n" + "\n".join(lines)
@@ -565,13 +526,6 @@ def _item(
if "basis" not in kw and importance == "substantive":
# 按哪种标注认定为实质性:清单项据此挂到对应的标注规则上;★ 节里的句子挂 ★
kw["basis"] = next((m for x in sents if (m := is_marked(x, doc.declared))), "star")
elif (
"basis" not in kw
and importance == "important"
and STAR_IMPORTANT in doc.declared
and any("★" in x.text for x in sents)
):
kw["basis"] = "star" # ★ 只是「重要」的文件也挂到 ★ 规则上,清单页「已识别 N 条」才数得对
if importance == "substantive" and "invalidity" not in categories:
categories = [*categories, "invalidity"] # ★ 条款同时进「无效 / 废标」类(FR-M2-05)
return Extracted(
@@ -608,7 +562,6 @@ def _substantive(
basis=basis,
spec={"invalidity": {"source": "substantive_marker" if basis != "explicit_invalid" else "tender_text"}},
)
if not (basis == "star" and STAR_IMPORTANT in doc.declared): # ★ 只是计分项的文件:按 importance_of 的结果
e.importance = "substantive"
e.importance_basis = e.importance_basis or ("tender_text" if basis == "explicit_invalid" else "marker")
return e

View File

@@ -469,21 +469,16 @@ def test_star_section_heading() -> None:
assert importance_of([ss[4]], d) == ("normal", None)
def test_star_defined_as_scored_is_important() -> None:
"""文件把 ★ 定义成「核心技术参数」、不满足只扣分(S02):★ 是「重要」,不是实质性(口径 Q2)。"""
def test_star_defined_as_scored_stays_substantive() -> None:
"""文件把 ★ 定义成「核心技术参数」、不满足只扣分(S02):★ 仍按实质性要求(口径 Q2 修订)。"""
from bid_doctor.checklist.extract import build_doc, importance_of
star, define = sent("★设备采用国产自主可控CPU芯片。", seq=1), sent("备注:加★为核心技术参数;", seq=2)
scored = sent("每有一项核心技术参数不满足的减4分。", seq=3)
d = build_doc([star, define, scored])
assert is_marked(star, d.declared) is None
assert importance_of([star], d) == ("important", "marker")
assert [(n.markers, n.meaning) for n in d.notes] == [({"star"}, "important")]
# 没有计分证据:维持实质性(降级的代价是把致命显示成重要)
assert is_marked(star, build_doc([star, define]).declared) == "star"
# 有一处说 ★ 是实质性要求、或者带 ★ 的句子写着后果,就按实质性(顾问审读第 1 条的反例)
for extra in ("本文件中打“★”内容为实质性要求。", "打★号的参数负偏离的,视为未响应。"):
assert is_marked(star, build_doc([star, define, scored, sent(extra, seq=4)]).declared) == "star"
assert is_marked(star, d.declared) == "star"
assert importance_of([star], d) == ("substantive", "marker")
assert all(n.meaning != "important" for n in d.notes)
def test_heuristics_reject_lookalikes() -> None: