feat(check): 同一行按单元格;业绩条款不再误跳;重复条目只合并显示(C5 修订)

- 送给模型的「同一行」在有框线的表格里按单元格分行
- 「以合同签订时间为准」不再被当成「签订合同时才交」,要附合同的业绩评分项照常查
- 重复的要求各自判断,问题接口给出 duplicate_ids,查标页同组折叠显示、一起处理
- 决策 C5 修订记入 decisions.md;开发集召回 74%、致命级 25/32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 18:00:24 +08:00
parent df3ebbb6a1
commit 4a5421a412
12 changed files with 111 additions and 13 deletions

View File

@@ -216,4 +216,5 @@ samples/
- 2026-11-04:查标第四轮:规则检查(`check/rules.py`,规则集 2026.11-r1,不调模型)——大小写金额、分项合计、超限价、多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;文字层的句子带上单元格框,表格按单元格分行。请 Fable 从真实投标文件角度审读,按「认不准的不给致命」改了定级和取值范围。开发集 17 份上规则问题 57 / 57 查出、规矩投标文件 0 误报,但规则是看着生成器和开发词库写的,这个分数说明不了泛化,要等留出集和国动真实投标文件。见 `docs/architecture/check-eval.md` §6。
- 2026-11-04:查标第 2 步收尾:需人工核对(份数、密封、递交、CA 签章加密、解密、保证金,从清单按主题合并,查标页勾选、按项目存,P2);「报价」类清单项(C7,读标从清单表规则读出分项 / 单价上限)和规则 `PRICE.OVER_CEILING_ITEM`;评测集派生 4 份报价问题投标文件,21 份上规则问题 62 / 62、误报 0。见 `docs/architecture/check-eval.md` §7。
- 2026-11-04:查标第五轮(不调模型的后处理):偏离栏直读(证据所在表格行的偏离格写着不满足就改判负偏离)、响应错位(照抄要求格 + 响应格写的是别的内容);复查复用(送审内容没变的条目沿用上次的模型结论)和每次检查的硬预算。开发集 17 份召回 61% → 73%,负偏离未说明 64% → 90%,致命级 22 → 24 / 32,规矩投标文件的问题数不变(235)。见 `docs/architecture/check-eval.md` §8。
- 下一步:送给模型的「同一行」改按单元格分行(要重跑模型),重复条目分组、致命项重问;第 2 步还差分项和采购需求逐项对照(P13)。
- 2026-11-04:查标第六轮:送给模型的「同一行」按单元格分行;修了「以合同签订时间为准」被当成中标后才交、业绩评分项被跳过的缺陷;重复条目按用户决定改为各自判断、只合并显示(C5 修订,`docs/decisions.md`);致命项重问按数据(开发集致命级证据校验失败 0 条)暂不做。评测集裁定 1 条。开发集召回 74%、致命级 25 / 32。见 `docs/architecture/check-eval.md` §9。
- 下一步:第 2 步补分项和采购需求逐项对照(P13),然后冻结第 3 步代码,跑留出集和国动真实投标文件(届时向用户要文件)。

View File

@@ -56,7 +56,18 @@ async function cancel() {
type Sev = "" | Finding["severity"];
const sev = ref<Sev>("");
const all = computed(() => findingsQ.data.value?.items ?? []);
const live = computed(() => all.value.filter((f) => f.presence !== "resolved"));
/** 清单里重复出现的要求(决策 C5 修订):各自判断,同组只显示第一条,处理时一起处理 */
const hidden = computed(() => {
const seen = new Set<string>();
const out = new Set<string>();
for (const f of all.value) {
if ((f.duplicate_ids ?? []).some((id) => seen.has(id))) out.add(f.id);
seen.add(f.id);
}
return out;
});
const group = (f: Finding) => [f, ...all.value.filter((x) => (f.duplicate_ids ?? []).includes(x.id))];
const live = computed(() => all.value.filter((f) => f.presence !== "resolved" && !hidden.value.has(f.id)));
const todo = computed(() => live.value.filter((f) => f.status === "pending" && (!sev.value || f.severity === sev.value)));
const handled = computed(() => live.value.filter((f) => f.status !== "pending" && (!sev.value || f.severity === sev.value)));
const resolved = computed(() => all.value.filter((f) => f.presence === "resolved"));
@@ -88,9 +99,11 @@ async function resolve(f: Finding, status: "confirmed_ok" | "modified" | "ignore
}
busyId.value = f.id;
try {
await unwrap(
api.POST("/findings/{finding_id}/resolve", { params: { path: { finding_id: f.id } }, body: { status, ...(text ? { note: text.trim() } : {}) } }),
);
for (const x of group(f).filter((g) => g.status === "pending")) {
await unwrap(
api.POST("/findings/{finding_id}/resolve", { params: { path: { finding_id: x.id } }, body: { status, ...(text ? { note: text.trim() } : {}) } }),
);
}
noteFor.value = null;
toast(`已标记为「${FINDING_ACTION[status]}」`, { undo: () => void reopen(f) });
refresh();
@@ -104,7 +117,9 @@ async function resolve(f: Finding, status: "confirmed_ok" | "modified" | "ignore
async function reopen(f: Finding) {
try {
await unwrap(api.POST("/findings/{finding_id}/reopen", { params: { path: { finding_id: f.id } } }));
for (const x of group(f)) {
await unwrap(api.POST("/findings/{finding_id}/reopen", { params: { path: { finding_id: x.id } } }));
}
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
@@ -226,6 +241,7 @@ const errorText = computed(() => {
<div class="f-head">
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span>
<span class="f-title">{{ f.title }}</span>
<span v-if="(f.duplicate_ids?.length ?? 0) > 0" class="flag" title="同一要求在清单里出现了几处,各自检查、结果相同,处理时一起处理">清单里出现 <span class="fig">{{ (f.duplicate_ids?.length ?? 0) + 1 }}</span> 处</span>
<span v-if="f.presence === 'carried'" class="flag">沿用<template v-if="f.carried_status === 'modified'">:上次已修改,这次仍查出</template></span>
</div>
<p class="f-text">{{ f.explanation }}</p>

View File

@@ -203,9 +203,30 @@
**读法**:两项都是确定的规则,没有改提示词、没有多花 token。响应错位报了 3 处(2 个单元),全对,规矩的投标文件上没有改判;第一版报错的 4 处(「13.56MHz」没算数字字母重合、「支持」当成别的内容、要求太短)修掉后才是这个数。偏离栏直读依赖投标文件的偏离栏写法,真实文件里偏离栏常写「无」「—」「见说明」,这些不会改判(不算不满足),只有明确写了不满足的字样才改。
## 9. 下一步
## 9. 第六轮:同一行按单元格、业绩条款不再误跳、重复条目只合并显示(2026-11-04)
**改了什么**:
- **送给模型的「同一行」按单元格分行**(有框线的表格;正文句子照旧按上下重叠):垂直居中的格子、折行的长格不再串到上下行。要重跑模型(17 份约 225 万 token)。
- **「以合同签订时间为准」不再当作「签订合同时才交」**:`applicability` 判断证明材料是不是中标后才交的正则把它也认成了「签订合同时」,业绩评分项(「需提供合同复印件……以合同签订时间为准」)因此被当作「没要求附材料」跳过。评分项的业绩条款几乎都这样写。
- **重复条目**(决策 C5 修订):每条照常单独判断;问题接口给出同一次检查里问题类型相同、要求原文完全相同的其他问题(`duplicate_ids`),查标页同组只显示一条,注明「清单里出现 N 处」,处理和撤销对整组生效。
- **致命项重问(C3)先不做**:开发集 17 份上证据没通过校验的只有 5 条,致命级 0 条,C3 定的「不超过致命问题的 10%」已经满足,开发集上没有可测的对象;等真实投标文件上看到再做。
- **评测集裁定 1 条**(`adjudication/S10-B-sloppy.md`):t20 的漏响应不成立,技术方案正文完整响应了(生成器没认出风格 B 改写过的正文复述);问题总数 150 → 149。另核实了 3 条疑似错误、不改:S10-A-low t16 正文只响应了一半;S04-A s21 / s22 是同一条认证要求在几台设备下各写一遍、投标文件漏了其中一台——清单把几台设备的同一要求合成了一条,按条目查看不出哪一台漏了,这是现在的局限。
| 指标(17 份,按 149 个问题重算) | 第五轮(mismatch) | + 同一行按单元格(cellrow) | + 业绩条款(award,本轮) |
|---|---|---|---|
| 查标召回 | 109(73%) | 108(72%) | **111 / 149(74%)** |
| 致命级召回 | 24 / 32 | 25 / 32 | **25 / 32** |
| D06 / D09 / D13 | 53 / 7 / 9 | 53 / 5 / 9 | 54 / 5 / **10** |
| 规矩响应的要求上的误报 | 84 / 620 | 81 / 620 | 81 / 620 |
| 干扰项误报 | 30 | 27 | **24** |
| 规矩的投标文件报出的问题 | 235 | 236 | 239 |
| 输入 token(17 份) | 221 万 | 225 万 | 225 万 |
**读法**:按单元格分行和上一轮基本持平(召回 -1、致命 +1、误报略少),在一次模型重跑的波动范围内,结构上更对,保留。D09 少的 2 条是响应错位判断依赖模型引用「照抄的要求格」,模型换了引用就不触发——它是补漏,不是主力。业绩条款的修正带来 D13 +1、D01 +1;规矩的投标文件上多了 3 个提示(新查的业绩评分项)。
## 10. 下一步
1. 第 2 步还差:分项报价表和采购需求清单逐项对照(P13)、散文写法的单价上限。
2. 送给模型的「同一行」改用按单元格分行(现在还用句子重叠,跨格、合并格会串);要重跑模型,等下一次改提示词时一起做。
2. 第 3 步冻结前:确认各项改动的单元测试都有开发集以外的反例;冻结后跑留出集(S03、S06、S09,风格 B + C)和国动真实投标文件,各一次。
3. 第 3 步:D09(响应对象错位)、D06(负偏离未说明)的提示词 v3,token 预算、复查复用、重复条目分组。
3. 留出集(S03、S06、S09,风格 B + C,封存的词库)和真实国动投标文件,在第 3 步冻结代码后只跑一次。

View File

@@ -11,7 +11,7 @@
| 第 0 步 评测集 | 试跑完成,继续扩充 | 规范 v0.2、留出集划分已定,留出集词库和风格 C 已封存;生成、注入、渲染、离线查标、评分工具已写;开发集 7 份招标文件、14 份投标文件第一版跑完,结果和发现见 `check-eval.md` §2 |
| 第 1 步 链路打通 | ✅ 2026-11-04 | 见下方「第 1 步落地记录」 |
| 第 2 步 规则 | 基本完成 | 响应方式(C12)、文件要在、扫描件兜底、规则检查 `rules.py`(报价形式、形式要件、响应表)、需人工核对(`manual.py`、`manual-checks` 接口)、「报价」类清单项和分项上限(C7)。还差分项和采购需求逐项对照(P13)、散文写法的单价上限。见 `check-eval.md` §4–§7 |
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2、偏离栏直读、响应错位、复查复用、硬预算;还差送给模型的同一行改按单元格、重复条目分组、致命项重问。见 `check-eval.md` §8 |
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2、偏离栏直读、响应错位、复查复用、硬预算;送给模型的同一行按单元格、重复条目只合并显示(C5 修订);致命项重问按数据暂不做。见 `check-eval.md` §8–§9 |
| 第 4~6 步 | 未开始 | |
**第 1 步落地记录**(和本文设计不一致、或留到后面的,记在这里):

View File

@@ -2879,6 +2879,10 @@ components:
sections:
type: array
items: { type: string }
duplicate_ids:
type: array
description: 同一次检查里问题类型相同、要求原文完全相同的其他问题(清单里重复出现的要求,决策 C5 修订:各自判断,界面折叠显示)
items: { $ref: '#/components/schemas/Id' }
presence: { $ref: '#/components/schemas/Presence' }
carried_status:
oneOf:

View File

@@ -4,6 +4,14 @@
---
## C5(修订)· 2026-11-04 · 重复条目不合并判断,只合并显示
| # | 决策 | 理由 |
|---|---|---|
| C5 | 修订原决策:清单里的重复要求**每条照常单独判断**,不聚组共用一个结论;只在查标页把文字完全相同的问题折叠显示「此要求出现在 N 处」,处理一条时可以连同组一起处理 | 开发集上按相似度 0.9 估算,文字几乎相同的条目常是不同的要求:「缴纳税收材料」和「缴纳社会保障资金材料」;共用一段评分说明的几个评分项;两台服务器各写一遍的同一条参数(一台响应了不代表另一台响应了,正是「响应对象错位」要查的)。合并判断会把这些判错。代价是多花一点 token(每份约 5%~10%) |
- **影响**:`check_item_result.group_key` 不写入;问题接口按「问题类型 + 要求原文」给出同组信息,前端折叠显示。
## C12、E1 · 2026-11-04 · 响应方式按建议处理;评测集挂问题的要求由另一个模型复核、用户抽查 20%
| # | 决策 | 理由 |

View File

@@ -2073,6 +2073,8 @@ export interface components {
documents?: string[];
sections?: string[];
} | null;
/** @description 同一次检查里问题类型相同、要求原文完全相同的其他问题(清单里重复出现的要求,决策 C5 修订:各自判断,界面折叠显示) */
duplicate_ids?: components["schemas"]["Id"][];
presence: components["schemas"]["Presence"];
/** @description 沿用时,上一次的处理状态(上次「已修改」的这次回到待处理) */
carried_status?: components["schemas"]["FindingStatus"] | null;

View File

@@ -19,7 +19,8 @@ from bid_doctor.checklist.response_mode import Mode
# 要求投标时附证明材料(读标的 evidence_required 常常是空的,按原文判断);中标后 / 签合同时才交的不算
EVIDENCE = re.compile(r"(提供|附|出具)[^。;]{0,20}(证明|报告|截图|复印件|扫描件|证书|合同|承诺|声明|社保|发票)")
AWARD = re.compile(r"(中标后|成交后|签订合同(前|时)|合同签订(前|时))")
# 「签订合同时提供」才算中标后;「以合同签订时间为准」(业绩条款常见)不是
AWARD = re.compile(r"(中标后|成交后|签订合同(前|时)(?!间)|合同签订(前|时)(?!间))")
NO_PROOF = re.compile(r"无需提供|不需提供|无须提供|采购人[^。;]{0,12}(查询|核查|查验)|信用中国|信用记录")
SKIP_LABELS = {
"condition": "无效情形和评审规定",

View File

@@ -25,6 +25,7 @@ from bid_doctor.ai.providers.base import TransientError
from bid_doctor.check import presence, reuse
from bid_doctor.check.retrieval import Index
from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline import layout
from bid_doctor.document.pipeline.text_layer import Sentence, norm
PROMPT = ("check.match", 2)
@@ -143,8 +144,11 @@ def _item_block(key: str, item: dict[str, Any], sids: list[str]) -> str:
return "\n".join(lines)
def row_mates(s: Sentence, page: list[Sentence]) -> list[Sentence]:
"""同一行的其他句子:同一页、上下范围重叠(和读标核对取值时找「同一行」是同一口径)。按从左到右排。"""
def row_mates(s: Sentence, page: list[Sentence], cell_rows: dict[int, list[Sentence]] | None = None) -> list[Sentence]:
"""同一行的其他句子,从左到右。在有框线的表格里按单元格分行(cell_rows,版面工具算好的):垂直居中的短格、
折成几句的长格不会串到上下行;其余按同一页上下范围重叠(和读标核对取值时找「同一行」是同一口径)。"""
if s.cell and cell_rows is not None and id(s) in cell_rows:
return [x for x in cell_rows[id(s)] if x is not s][:ROW_MATES]
if not s.quads:
return []
top, bottom = min(q[1] for q in s.quads), max(q[3] for q in s.quads)
@@ -246,6 +250,7 @@ async def run(
for s in d.sentences:
pages.setdefault((d.id, s.page_no), []).append(s)
rows: dict[str, list[str]] = {} # 候选句 → 同一行的单元格(短编号)
cell_rows = {id(c): row for page in pages.values() for row in layout.rows(page) for c in row if c.cell}
sent_of = {
short_sid(d, x): (d, x) for d in docs for x in d.sentences
} # 片段按编号查(同一行的单元格可能是别的条目的候选句)
@@ -266,7 +271,7 @@ async def run(
sid = short_sid(doc, h.sentence)
allowed[sid] = (doc, h.sentence)
hits_of.setdefault(item_id, []).append(sid)
mates = row_mates(h.sentence, pages[(doc.id, h.sentence.page_no)])
mates = row_mates(h.sentence, pages[(doc.id, h.sentence.page_no)], cell_rows)
rows[sid] = [short_sid(doc, m) for m in mates]
for m in mates: # 同一行的单元格也可以引用(响应、偏离常写在那里)
allowed.setdefault(short_sid(doc, m), (doc, m))

View File

@@ -30,6 +30,7 @@ from bid_doctor.core.ids import uuid7
from bid_doctor.document import citations
from bid_doctor.document import service as documents
from bid_doctor.document.models import SourceDocument
from bid_doctor.document.pipeline.text_layer import norm
from bid_doctor.identity import service as identity
from bid_doctor.ops import service as feedback
from bid_doctor.project import service as projects
@@ -289,6 +290,14 @@ async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> l
cites = await citations.to_api_many(s, [link.citation_id for ls in by_finding.values() for link in ls])
names = await identity.display_names(s, [f.resolved_by for f in rows if f.resolved_by])
sort_keys = await checklist.sort_keys(s, [f.checklist_item_id for f in rows if f.checklist_item_id])
# 重复的要求(决策 C5 修订):每条照常单独判断,问题类型相同、要求原文完全相同的折叠显示
groups: dict[tuple[str, str], list[UUID]] = {}
for f in rows:
item = items.get(f.checklist_item_id) if f.checklist_item_id else None
text = norm(str((item or {}).get("requirement_text") or (item or {}).get("title") or ""))
if item and f.presence != "resolved" and len(text) >= 6:
groups.setdefault((f.type, text), []).append(f.id)
same = {fid: [x for x in ids if x != fid] for ids in groups.values() if len(ids) > 1 for fid in ids}
def api(f: Finding) -> dict[str, Any]:
item = items.get(f.checklist_item_id) if f.checklist_item_id else None
@@ -315,6 +324,7 @@ async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> l
"tender_citations": [cites[x.citation_id] for x in ls if x.side == "tender" and x.citation_id in cites],
"bid_citations": [cites[x.citation_id] for x in ls if x.side == "bid" and x.citation_id in cites],
"search_scope": f.search_scope,
"duplicate_ids": same.get(f.id, []),
"presence": f.presence,
"carried_status": (f.detail or {}).get("carried_status"),
"status": f.status,

View File

@@ -360,3 +360,19 @@ async def test_recheck_reuses_unchanged_model_results(user: User) -> None:
assert n == 0
results = (await user.get(f"/check-runs/{second['id']}/item-results")).json()["items"]
assert all(r["bid_citations"] for r in results if r["verdict"] == "responded") # 沿用的结论证据也找回来了
async def test_duplicate_requirements_are_checked_separately_and_grouped_for_display(user: User) -> None:
"""重复的要求(决策 C5 修订):各自判断、各出一个问题,问题接口给出同组的其他问题,界面折叠显示。"""
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
for _ in range(2):
r = await user.post(
f"/projects/{project_id}/checklist/items",
json={"categories": ["qualification"], "title": "须提供船舶防爆检验报告"},
)
assert r.status_code == 201, r.text
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
run = await run_check(user, project_id, [bid["id"]])
dup = [f for f in await findings(user, run["id"]) if "船舶防爆" in f["title"]]
assert len(dup) == 2 and dup[0]["duplicate_ids"] == [dup[1]["id"]] and dup[1]["duplicate_ids"] == [dup[0]["id"]]

View File

@@ -279,3 +279,17 @@ async def test_over_budget_items_are_undetermined_without_model_calls() -> None:
results, stats = await match.run(ctx, uuid4(), items, [d], progress, budget=10)
(r,) = results.values()
assert (r.verdict, r.reason_code, stats.calls, stats.over_budget) == ("undetermined", "over_budget", 0, 1)
def test_award_time_wording_is_not_award_stage_evidence() -> None:
"""「以合同签订时间为准」是业绩的时间口径,不是「签订合同时才交」:要附合同的评分项照常查(check-eval §9)。"""
from bid_doctor.check import applicability
scoring = {
"categories": ["scoring"],
"title": "项目业绩(10 分)",
"requirement_text": "提供2023年以来类似项目业绩,以合同签订时间为准,需提供合同复印件。",
}
assert applicability.decide(scoring, {}).match
later = {**scoring, "requirement_text": "签订合同时提供业绩合同原件备查。"}
assert applicability.decide(later, {}).skip == "scoring"