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:
80
server/src/bid_doctor/document/pipeline/layout.py
Normal file
80
server/src/bid_doctor/document/pipeline/layout.py
Normal 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
|
||||
Reference in New Issue
Block a user