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

@@ -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"