Files
bid-doctor/spikes/s4-llm/prep.py
YANG JIANKUAN 604e889ca4 docs(architecture): 完成技术验证 S1–S4,确定 FR-M2-05/06 目标值
- spikes/:Word 转 PDF(Gotenberg)、格式识别、原句坐标、DeepSeek 抽取四项验证代码与两版提示词;样本派生产物和 .env 不进 git
- tech-validation.md:完整结论。四项都可行:WPS 字体字符集要修正并改用行高调整过的字体;格式识别不需要 Docling;表格内切句待改进;抽取需第三道依据校验与规则兜底
- architecture.md:§3.5 加预处理与字体包,§3.6 证据校验改为三道,§10 回填结论
- 03 功能需求与 decisions.md(TV1):召回 ≥ 99%、采纳准确率 ≥ 95%、页码 100%、高亮正文 ≥ 98% / 表格 ≥ 95%

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:46:49 +08:00

113 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""S4 数据准备:把规范化 PDF 切成带编号、带格式标记的句子,并生成银标准。
句子编号和 S3 一样(同一套切句规则),格式标记:
斜体且下划线的连续文字 → <iu>…</iu>;只有下划线 → <u>…</u>;只有斜体 → <i>…</i>
银标准(实质性条款句):
★ 句:句子含 ★(排除「带 ★ 的为实质性要求」这类说明句,以及「★号条款」「“★”项」这类引用句)
斜体下划线句:句子里一半以上的字是斜体且下划线,且文件自己声明了这种标注方式(S06 / S08 / S09)
另外抽出「标注规则说明」句(文件自己说明怎么标注实质性条款的句子),作为每个分块的背景。
用法:python -I prep.py <规范化 PDF 目录> S01 S02 …
"""
from __future__ import annotations
import json
import pathlib
import re
import sys
import pdfplumber
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "s3-citation"))
from sentences import split_sentences # noqa: E402 同一套切句规则,保证编号一致
OUT = HERE / "out"
WS = re.compile(r"[\s ]")
# 说明标注规则的句子:既提到标注方式,又提到实质性 / 无效
# 只是「提到」★ 条款的句子(“★”、★号、★项、★条款),本身不是条款
STAR_REF = re.compile(r"[“\"]★[”\"]|★\s*(号|项|条款|标注|标记)")
RULE_NOTE = re.compile(r"(★|▲|\*|斜体|下划线|加粗|标注|标记|加注|标有).{0,30}(实质性|无效|否决|废标)|(实质性|无效|否决).{0,30}(★|斜体|下划线|标注|标记|加注|标有)")
def char_flags(page):
hlines = [(l["x0"], l["x1"], l["top"]) for l in page.lines if abs(l["top"] - l["bottom"]) < 0.5]
hlines += [(r["x0"], r["x1"], r["top"]) for r in page.rects if r["height"] < 1.5 and r["width"] > 2]
flags = {}
for c in page.chars:
if not c["text"].strip():
continue
name = c["fontname"].split("+")[-1]
it = "Italic" in name or "Oblique" in name or abs(c["matrix"][2]) > 0.1
mid = (c["x0"] + c["x1"]) / 2
un = any(x0 - 0.5 <= mid <= x1 + 0.5 and -0.25 * c["size"] <= y - c["bottom"] <= 4 for x0, x1, y in hlines)
flags[(round(c["x0"], 2), round(c["top"], 2), c["text"])] = (it, un)
return flags
def chars_in_quads(page, quads):
got = []
for c in page.chars:
if not c["text"].strip():
continue
cx, cy = (c["x0"] + c["x1"]) / 2, (c["top"] + c["bottom"]) / 2
for qi, (x0, t, x1, b) in enumerate(quads):
if x0 <= cx <= x1 and t <= cy <= b:
got.append((qi, c["x0"], c)); break
got.sort(key=lambda x: (x[0], x[1]))
return [c for _, _, c in got]
def markup(chars, flags):
out, cur_tag = [], None
for c in chars:
it, un = flags.get((round(c["x0"], 2), round(c["top"], 2), c["text"]), (False, False))
tag = "iu" if it and un else "u" if un else "i" if it else None
if tag != cur_tag:
if cur_tag:
out.append(f"</{cur_tag}>")
if tag:
out.append(f"<{tag}>")
cur_tag = tag
out.append(c["text"])
if cur_tag:
out.append(f"</{cur_tag}>")
iu = sum(1 for c in chars if flags.get((round(c["x0"], 2), round(c["top"], 2), c["text"]), (0, 0)) == (True, True))
return "".join(out), iu
def main():
pdf_dir, sids = pathlib.Path(sys.argv[1]), sys.argv[2:]
for sid in sids:
sents = []
with pdfplumber.open(pdf_dir / f"{sid}.pdf") as pdf:
for page in pdf.pages:
right = max((c["x1"] for c in page.chars), default=page.width)
flags = char_flags(page)
for s in split_sentences(sid, page, right):
chars = chars_in_quads(page, s["quads"])
marked, iu = markup(chars, flags)
plain = "".join(c["text"] for c in chars)
n = len(WS.sub("", plain))
is_rule_note = bool(RULE_NOTE.search(plain))
silver = None
if "★" in plain and not is_rule_note and not STAR_REF.search(plain):
silver = "star"
elif n >= 6 and iu / max(1, n) >= 0.5 and not is_rule_note:
silver = "iu"
sents.append({"id": s["id"].split(":", 1)[1].replace(":", ""), # p12s3
"page": s["page"], "text": plain, "marked": marked,
"silver": silver, "rule_note": is_rule_note})
# 斜体下划线只在文件自己声明了这种标注方式时才算实质性(其他文件里它常是模板填空处)
declares_iu = any(s["rule_note"] and "斜体" in s["text"] for s in sents)
for s in sents:
if s["silver"] == "iu" and not declares_iu:
s["silver"] = None
(OUT / f"{sid}-input.json").write_text(json.dumps(sents, ensure_ascii=False))
print(sid, "sentences", len(sents), "silver_star", sum(s["silver"] == "star" for s in sents),
"silver_iu", sum(s["silver"] == "iu" for s in sents), "rule_notes", sum(s["rule_note"] for s in sents))
if __name__ == "__main__":
main()