feat(check): 需人工核对(P2)和分项 / 单价上限(C7)

- 需人工核对:份数、密封、递交、CA 签章加密、解密、保证金这类文件里看不出来的要求,
  从正式清单按主题合并、带招标原文出处;manual-checks 接口按项目存勾选、写勾选人和时间,
  复查沿用;查标页加「需人工核对」一块,覆盖范围写明列了几条
- 「报价」类清单项:读标从采购需求的清单表规则读出分项 / 单价上限(checklist/price.py);
  查标交给规则 PRICE.OVER_CEILING_ITEM 比对分项报价表,不交给模型;规则集 2026.11-r2
- 版面工具(分页、分行、归栏)挪到 document/pipeline/layout.py,读标和查标共用
- 评测:price_defects.py 从规矩投标文件派生只带报价问题的 4 份(P01、P02、P03)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 17:31:32 +08:00
parent db1b1f6f9f
commit 81c58798d2
22 changed files with 888 additions and 84 deletions

View File

@@ -0,0 +1,80 @@
"""版面工具:按坐标把文字层的句子分成页、行、栏(读标认表格、查标认投标文件的表格都用)。
有框线的表格按单元格的上沿分行(文字层给句子带了单元格框):同一行的格子上沿相同,垂直居中的短格、
折成几句的长格、纵向合并的格(只算进它的第一行)都不会串行;其余按句子上下范围重叠分行。
"""
from __future__ import annotations
from dataclasses import dataclass
from bid_doctor.document.pipeline.text_layer import Sentence
def box(s: Sentence) -> tuple[float, float, float, float]:
return (
min(q[0] for q in s.quads),
min(q[1] for q in s.quads),
max(q[2] for q in s.quads),
max(q[3] for q in s.quads),
)
def span(s: Sentence) -> tuple[float, float]:
"""左右范围:在表格里取单元格的,否则取文字的。"""
return (s.cell[0], s.cell[2]) if s.cell else (box(s)[0], box(s)[2])
def pages(sentences: list[Sentence]) -> dict[int, list[Sentence]]:
out: dict[int, list[Sentence]] = {}
for s in sentences:
out.setdefault(s.page_no, []).append(s)
return out
@dataclass(slots=True)
class _Row:
top: float
bottom: float
in_cell: bool
items: list[Sentence]
def rows(page: list[Sentence]) -> list[list[Sentence]]:
"""一页的句子分成行,行内从左到右。"""
out: list[_Row] = []
def top_of(s: Sentence) -> float:
return s.cell[1] if s.cell else box(s)[1]
for s in sorted((x for x in page if x.quads), key=top_of):
if s.cell:
r = next((r for r in out if r.in_cell and abs(r.top - s.cell[1]) < 2), None)
if r:
r.items.append(s)
else:
out.append(_Row(s.cell[1], s.cell[1], True, [s]))
continue
_, top, _, bottom = box(s)
last = out[-1] if out else None
if last and not last.in_cell and top < last.bottom - 1:
last.bottom = max(last.bottom, bottom)
last.items.append(s)
else:
out.append(_Row(top, bottom, False, [s]))
return [sorted(r.items, key=lambda x: box(x)[0]) for r in sorted(out, key=lambda r: r.top)]
def column(cell: Sentence, cols: dict[str, tuple[float, float]]) -> str | None:
"""单元格落在哪一栏:和表头单元格左右范围重叠最多的那一栏;都不重叠(窄数字居中、表头字靠左)取中心最近的。
cols 要包含表头的每一格,否则别的栏的数字会被归到最近的已知栏。"""
x0, x1 = span(cell)
best, score = None, 0.0
for name, (c0, c1) in cols.items():
overlap = min(x1, c1) - max(x0, c0)
if overlap > score:
best, score = name, overlap
if best is None and cols:
mid = (x0 + x1) / 2
best = min(cols, key=lambda n: abs((cols[n][0] + cols[n][1]) / 2 - mid))
return best