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:
@@ -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": "无效情形和评审规定",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user