feat: 读标可以走完一份文件;审计待确认区,修规则缺陷、提示词出新版本
读标操作(FR-M2-07、08) - 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、 采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、 替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本, 内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读 - 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖 - 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除 (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、 选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示 待确认审计(架构 §13 SK11) - 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句 (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文 数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记 - 不放宽:引文最短 8 个字、编号必须对、证据全部通过 - 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2; 引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本 - BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py - 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,7 +46,7 @@ PROGRESS = {
|
||||
"merge": 95,
|
||||
}
|
||||
# text_layer v2:页眉页脚带单独成句,句子编号和 v1 不同(同一份规范化 PDF 在 v1、v2 下的编号不能混用)
|
||||
PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v2", "sentence_split": "s3-v2"}
|
||||
PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v3", "sentence_split": "s3-v3"}
|
||||
|
||||
|
||||
class Cancelled(Exception):
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
用处:「25.3 未实质性响应磋商文件的情形」下面列出的 25.3.1~25.3.11,每一条本身都不写「无效」,
|
||||
要靠上级标题才知道是无效情形。抽取时把上级标题作为上下文给模型,依据校验也认上级标题。
|
||||
|
||||
只认两种最常见的写法:
|
||||
认这几种写法:
|
||||
1. 十进制分级编号:25.3 → 25.3.1,上级是编号去掉最后一段的那一句;
|
||||
2. 冒号引出的列表:「有下列情形之一的,投标无效:」后面跟(1)(2)……或 1. 2. ……或 ①②……
|
||||
3. 中文编号的小节:「三、符合条件(必须提供,否则投标无效)」下面的 1. 2. 3.……(S02 第 20 页);
|
||||
4. 以冒号结尾的编号条目:「1.报价符合性审查:」下面不带编号的句子,到下一个编号为止。
|
||||
上级还可以有上级(第 4 种的上级常常是第 3 种),用 ancestors() 取整条链。
|
||||
TODO(结构识别):章、节层级,表格结构,分包识别。
|
||||
"""
|
||||
|
||||
@@ -18,6 +21,7 @@ from bid_doctor.document.pipeline.text_layer import Sentence
|
||||
DOTTED = re.compile(r"^(\d{1,3}(?:\.\d{1,3})+)(?![.\d])")
|
||||
LIST_ITEM = re.compile(r"^((\d{1,2})|\(\d{1,2}\)|\d{1,2}[.、.](?!\d)|[①-⑳]|[a-z][.、))])")
|
||||
CHAPTER = re.compile(r"^第[一二三四五六七八九十百\d]+[章节]")
|
||||
CN_SECTION = re.compile(r"^(?:[一二三四五六七八九十]{1,3}、|([一二三四五六七八九十]{1,3}))")
|
||||
LEAD_MAX = 30 # 冒号引出的列表,最多往后认这么多句
|
||||
|
||||
|
||||
@@ -41,12 +45,28 @@ def parents(sentences: list[Sentence]) -> dict[str, Sentence]:
|
||||
lead: Sentence | None = None
|
||||
lead_left = 0
|
||||
expect = 1 # 冒号引出的列表:序号要从 1 开始连续,接不上就是列表结束了(例如下一节的「26.评审」)
|
||||
section: Sentence | None = None # 中文编号的小节标题(第 3 种)
|
||||
section_expect = 1
|
||||
sub: Sentence | None = None # 以冒号结尾的编号条目(第 4 种)
|
||||
for s in sentences:
|
||||
text = s.text.strip()
|
||||
m = DOTTED.match(text)
|
||||
no = _list_no(text)
|
||||
if CHAPTER.match(text) or m:
|
||||
section, sub = None, None
|
||||
if CHAPTER.match(text):
|
||||
lead, by_number = None, {}
|
||||
no = _list_no(text)
|
||||
if CN_SECTION.match(text):
|
||||
section, section_expect, sub = s, 1, None
|
||||
elif lead is None and section is not None and not m and no is not None:
|
||||
if no == section_expect:
|
||||
out[s.key] = section
|
||||
section_expect += 1
|
||||
sub = s if text.endswith((":", ":")) else None
|
||||
else:
|
||||
section, sub = None, None # 序号接不上:小节里的列表结束了
|
||||
elif sub is not None and no is None and not m:
|
||||
out[s.key] = sub
|
||||
if m:
|
||||
number = m.group(1)
|
||||
parent_no = number.rsplit(".", 1)[0]
|
||||
@@ -66,3 +86,13 @@ def parents(sentences: list[Sentence]) -> dict[str, Sentence]:
|
||||
if text.endswith((":", ":")) and no is None:
|
||||
lead, lead_left, expect = s, LEAD_MAX, 1
|
||||
return out
|
||||
|
||||
|
||||
def ancestors(key: str, parents: dict[str, Sentence], depth: int = 3) -> list[Sentence]:
|
||||
"""上级、上级的上级……(最多 depth 层)。"""
|
||||
out: list[Sentence] = []
|
||||
while key in parents and len(out) < depth:
|
||||
p = parents[key]
|
||||
out.append(p)
|
||||
key = p.key
|
||||
return out
|
||||
|
||||
@@ -191,10 +191,24 @@ def _band_repeats(pages: list[tuple[Any, list[list[dict[str, Any]]]]]) -> set[st
|
||||
return {t for t, ps in seen.items() if t and len(ps) >= need}
|
||||
|
||||
|
||||
def _underlines(page: Any) -> list[tuple[float, float, float]]:
|
||||
"""可能是下划线的横线 (x0, x1, y)。表格框线不算:它两端都接着竖线(单元格的角)。
|
||||
国动样本第 26~32 页的功能表:每格最后一行字正好压在框线上,整句被当成「下划线」,
|
||||
模型据此把普通技术要求认成斜体下划线的实质性条款(2026-11-03 审计)。"""
|
||||
hl = [(ln["x0"], ln["x1"], ln["top"]) for ln in page.lines if abs(ln["top"] - ln["bottom"]) < 0.5]
|
||||
hl += [(r["x0"], r["x1"], r["top"]) for r in page.rects if r["height"] < 1.5 and r["width"] > 2]
|
||||
vl = [(ln["x0"], ln["top"], ln["bottom"]) for ln in page.lines if abs(ln["x0"] - ln["x1"]) < 0.5]
|
||||
vl += [(r["x0"], r["top"], r["bottom"]) for r in page.rects if r["width"] < 1.5 and r["height"] > 2]
|
||||
|
||||
def joined(x: float, y: float) -> bool:
|
||||
return any(abs(vx - x) < 1.5 and vt - 1.5 <= y <= vb + 1.5 for vx, vt, vb in vl)
|
||||
|
||||
return [(x0, x1, y) for x0, x1, y in hl if not (joined(x0, y) and joined(x1, y))]
|
||||
|
||||
|
||||
def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], repeats: set[str]) -> Page:
|
||||
"""页眉页脚带里的页码和重复行单独成句(location = header_footer),不并进正文句子(S3 的改进点 2)。"""
|
||||
hlines = [(ln["x0"], ln["x1"], ln["top"]) for ln in page.lines if abs(ln["top"] - ln["bottom"]) < 0.5]
|
||||
hlines += [(r["x0"], r["x1"], r["top"]) for r in page.rects if r["height"] < 1.5 and r["width"] > 2]
|
||||
hlines = _underlines(page)
|
||||
chars = [c for c in page.chars if str(c["text"]).strip()]
|
||||
height = float(page.height)
|
||||
|
||||
@@ -207,21 +221,28 @@ def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], repeats: se
|
||||
groups: list[tuple[str, list[tuple[int, dict[str, Any]]]]] = []
|
||||
open_tail = False
|
||||
cur: list[tuple[int, dict[str, Any]]] = []
|
||||
# 句末标点之后紧跟的右括号、右引号(可能在下一行)还属于这一句:「……格式》。);」不切成「……。」和「);」
|
||||
ended = False
|
||||
for fi, frag in enumerate(frags):
|
||||
if is_band(frag):
|
||||
if ended:
|
||||
groups.append(("body", cur))
|
||||
cur, ended = [], False
|
||||
groups.append(("header_footer", [(fi, c) for c in frag]))
|
||||
continue
|
||||
for c in frag:
|
||||
if ended and c["text"] not in CLOSERS and c["text"] not in END:
|
||||
groups.append(("body", cur))
|
||||
cur, ended = [], False
|
||||
cur.append((fi, c))
|
||||
if c["text"] in END:
|
||||
groups.append(("body", cur))
|
||||
cur = []
|
||||
if cur and frag[-1]["x1"] < right - 2 * frag[-1]["size"]:
|
||||
ended = True
|
||||
if cur and not ended and frag[-1]["x1"] < right - 2 * frag[-1]["size"]:
|
||||
groups.append(("body", cur))
|
||||
cur = []
|
||||
if cur:
|
||||
groups.append(("body", cur))
|
||||
open_tail = True # 页面写完了句子还没结束
|
||||
open_tail = not ended # 页面写完了句子还没结束
|
||||
tail_seq = max((i for i, (loc, _) in enumerate(groups, 1) if loc == "body"), default=0) if open_tail else 0
|
||||
sentences: list[Sentence] = []
|
||||
for seq, (location, g) in enumerate(groups, 1):
|
||||
|
||||
Reference in New Issue
Block a user