From 3a7e3d7f3f0ffa5023ad9d2c0830044906f5612f Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 5 Nov 2026 11:51:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(extract):=20=E2=98=85=20=E4=B8=80=E5=BE=8B?= =?UTF-8?q?=E6=8C=89=E5=AE=9E=E8=B4=A8=E6=80=A7=E6=9D=A1=E6=AC=BE=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=88=E5=8F=A3=E5=BE=84=20Q2=20=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件把 ★ 定义成计分项(S02「加★为核心技术参数」)时不再降为「重要」: 删掉 star_scored / STAR_IMPORTANT 及相关分支,不再生成 meaning = important 的标注规则。 S02 的 11 条 ★ 要求离线验证全部为实质性。决策记入 docs/decisions.md。 Co-Authored-By: Claude Opus 5.5 --- docs/architecture/extract-status.md | 4 +- docs/decisions.md | 8 +++ server/src/bid_doctor/checklist/extract.py | 59 +++------------------- server/tests/test_units.py | 15 ++---- 4 files changed, 21 insertions(+), 65 deletions(-) diff --git a/docs/architecture/extract-status.md b/docs/architecture/extract-status.md index 80ba81c..19c4ca4 100644 --- a/docs/architecture/extract-status.md +++ b/docs/architecture/extract-status.md @@ -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`,写进解析结果; diff --git a/docs/decisions.md b/docs/decisions.md index 4720ac3..4bf7a62 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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。 diff --git a/server/src/bid_doctor/checklist/extract.py b/server/src/bid_doctor/checklist/extract.py index 40672c2..1be5f6e 100644 --- a/server/src/bid_doctor/checklist/extract.py +++ b/server/src/bid_doctor/checklist/extract.py @@ -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"|〔(?:续下页|上级:[^〕]*|表后说明:[^〕]*)〕") -# ★ 的含义被文件定义成别的(「加★为核心技术参数」),而且没有任何地方说 ★ 是实质性要求或不满足就无效(口径 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,8 +562,7 @@ 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 = "substantive" e.importance_basis = e.importance_basis or ("tender_text" if basis == "explicit_invalid" else "marker") return e diff --git a/server/tests/test_units.py b/server/tests/test_units.py index 1bdb885..0260b9d 100644 --- a/server/tests/test_units.py +++ b/server/tests/test_units.py @@ -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: