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,