contract-archive-cli 0.2.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contract_archive/__init__.py +2 -0
- contract_archive/archive/__init__.py +64 -0
- contract_archive/archive/db.py +126 -0
- contract_archive/archive/ingest.py +667 -0
- contract_archive/archive/migrations/001_init.sql +62 -0
- contract_archive/archive/migrations/002_obligations.sql +25 -0
- contract_archive/archive/migrations/003_document_types.sql +31 -0
- contract_archive/archive/migrations/004_seals_subjects.sql +36 -0
- contract_archive/archive/migrations/005_completeness.sql +18 -0
- contract_archive/archive/party_registry.py +276 -0
- contract_archive/archive/paths.py +113 -0
- contract_archive/archive/repository.py +918 -0
- contract_archive/cli.py +455 -0
- contract_archive/cli_common.py +293 -0
- contract_archive/cli_config.py +96 -0
- contract_archive/cli_introspect.py +204 -0
- contract_archive/cli_party.py +166 -0
- contract_archive/cli_query.py +492 -0
- contract_archive/cli_render.py +575 -0
- contract_archive/config.py +257 -0
- contract_archive/errors.py +163 -0
- contract_archive/extraction/__init__.py +14 -0
- contract_archive/extraction/amount_check.py +87 -0
- contract_archive/extraction/contract_extractor.py +103 -0
- contract_archive/extraction/document_extractor.py +546 -0
- contract_archive/extraction/evidence_page_fix.py +99 -0
- contract_archive/extraction/llm_extractor.py +207 -0
- contract_archive/extraction/normalize.py +210 -0
- contract_archive/extraction/property_fee.py +79 -0
- contract_archive/extraction/vision_seal.py +390 -0
- contract_archive/pipelines/__init__.py +9 -0
- contract_archive/pipelines/mineru_pipeline.py +955 -0
- contract_archive/pipelines/vl_ocr.py +160 -0
- contract_archive/schemas/__init__.py +67 -0
- contract_archive/schemas/document.py +408 -0
- contract_archive/utils/__init__.py +27 -0
- contract_archive/utils/device.py +51 -0
- contract_archive/utils/http_env.py +54 -0
- contract_archive/utils/pdf.py +207 -0
- contract_archive_cli-0.2.7.dist-info/METADATA +386 -0
- contract_archive_cli-0.2.7.dist-info/RECORD +44 -0
- contract_archive_cli-0.2.7.dist-info/WHEEL +4 -0
- contract_archive_cli-0.2.7.dist-info/entry_points.txt +2 -0
- contract_archive_cli-0.2.7.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""
|
|
2
|
+
通用文档抽取(LLM-first,跨类型)。
|
|
3
|
+
|
|
4
|
+
与合同专用抽取(自带一份调校过的合同 prompt + ContractExtraction schema)相对,
|
|
5
|
+
这里是面向任意类型的通用路径:一次调用完成「判类型 + 抽字段」,
|
|
6
|
+
结果归一化到 DocumentExtraction 信封。两者都是纯 LLM(Phase 2 起无 rule)。
|
|
7
|
+
死代码 rule 仅保留为确定性数值归一化(中文大写金额→数值、日期→ISO),
|
|
8
|
+
不参与字段抽取——加新文档类型只需扩 prompt 里的举例,无需写代码。
|
|
9
|
+
|
|
10
|
+
设计动机:用户要的是「整理各类文档让其可追溯」,核心吃 LLM 能力,
|
|
11
|
+
尽量少依赖死代码规则体系。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from ..schemas import (
|
|
20
|
+
DOC_TYPES,
|
|
21
|
+
Completeness,
|
|
22
|
+
CompletenessIssue,
|
|
23
|
+
DocumentExtraction,
|
|
24
|
+
LabeledAmount,
|
|
25
|
+
LabeledDate,
|
|
26
|
+
LabeledValue,
|
|
27
|
+
PersonIdentity,
|
|
28
|
+
Seal,
|
|
29
|
+
SubAgreement,
|
|
30
|
+
)
|
|
31
|
+
from ..config import load_settings
|
|
32
|
+
from ..errors import classify_exception, config_missing, extract_empty
|
|
33
|
+
from .llm_extractor import (
|
|
34
|
+
LlmResult,
|
|
35
|
+
_call_openai_compat,
|
|
36
|
+
_parse_json_loose,
|
|
37
|
+
_truncate_middle,
|
|
38
|
+
)
|
|
39
|
+
from .normalize import coerce_obligations, normalize_date, parse_money_value
|
|
40
|
+
from .amount_check import check_amount_consistency
|
|
41
|
+
from .property_fee import estimate_monthly_property_fee
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
DOC_EXTRACT_SYSTEM_PROMPT = f"""你是一名严谨的文档档案管理助理。给定一份文档的 OCR 文本,
|
|
47
|
+
判断它属于哪类文档,并抽取结构化字段,用于建立可检索、可追溯的个人文档档案库。
|
|
48
|
+
|
|
49
|
+
铁律:
|
|
50
|
+
1. 只输出 JSON,不要任何解释、前缀、Markdown 代码块标记。
|
|
51
|
+
2. 抽不到的字段填 null 或空数组,禁止猜测、禁止拼凑。
|
|
52
|
+
3. 日期统一 ISO 8601 (YYYY-MM-DD);占位/空白日期("___年__月__日")填 null。
|
|
53
|
+
4. 金额保留原文(含大写与币种),不要自己换算成数字。
|
|
54
|
+
5. 照实抽取,包括身份证号、电话等个人信息(这是用户本人的私人档案,需完整留存)。
|
|
55
|
+
|
|
56
|
+
doc_type 从以下规范类型择一(更细的归类写进 title):
|
|
57
|
+
{("、".join(DOC_TYPES))}
|
|
58
|
+
|
|
59
|
+
JSON 字段定义:
|
|
60
|
+
{{
|
|
61
|
+
"doc_type": "上面规范类型之一",
|
|
62
|
+
"title": "简短但能区分同类文档的标题——务必嵌入关键当事人/标的物/编号,不要只写泛化文档名。如『张三在职收入证明』『XX花园3幢102室商品房认购协议』『18号地下车位转让协议(乙方李四)』",
|
|
63
|
+
"summary": "一句话摘要,含关键主体+金额/数字+日期+标的,便于日后检索回忆与区分同类",
|
|
64
|
+
"parties": ["涉及的人或机构全称", "..."],
|
|
65
|
+
"primary_date": "该文档最重要的日期 ISO(合同=签订日,证明=出具日,发票=开票日)或 null",
|
|
66
|
+
"primary_amount": "该文档最重要的金额原文(合同=合同额,收入证明=年收入)或 null",
|
|
67
|
+
"key_dates": [{{"label": "出具日/签订日/到期日/入职日 等(用规范名词,见下方约束)", "date": "YYYY-MM-DD"}}],
|
|
68
|
+
"amounts": [{{"label": "年收入/月均收入/合同金额/首期款/余款/物业服务费 等", "text": "金额原文", "unit": "单价量纲或 null(绝对金额填 null;单价/费率填如『元/月·㎡』『元/个/月』『元/日』)", "is_total_component": true_or_false, "is_installment": true_or_false, "period_start": "YYYY-MM-DD 或 null", "period_end": "YYYY-MM-DD 或 null", "evidence": "第X页 + 原文片段"}}],
|
|
69
|
+
"fields": [{{"label": "字段名", "value": "字段值"}}],
|
|
70
|
+
"person_identities": [{{"name": "主体名(须与 parties 对应)", "role": "甲方/乙方/买受人/持证人 等或 null", "identifiers": [{{"label": "身份证号/电话/银行账号/开户行/统一社会信用代码 等", "value": "值"}}]}}],
|
|
71
|
+
"seals": [{{"owner": "盖章主体全称或 null", "seal_type": "公章/合同专用章/财务专用章/发票专用章 等或 null", "raw_text": "印章上识别到的原文"}}],
|
|
72
|
+
"obligations": [
|
|
73
|
+
{{"actor": "party_a|party_b|both", "action": "动宾短语", "deadline": "YYYY-MM-DD 或 null", "evidence": "原文片段"}}
|
|
74
|
+
],
|
|
75
|
+
"sub_agreements": [
|
|
76
|
+
{{"title": "补充协议", "summary": "改了/补充了什么", "sign_date": "YYYY-MM-DD 或 null", "seals": [{{"owner": "或 null", "seal_type": "或 null", "raw_text": "印章原文"}}], "evidence": "原文片段"}}
|
|
77
|
+
],
|
|
78
|
+
"completeness": {{
|
|
79
|
+
"status": "complete|incomplete|unknown",
|
|
80
|
+
"issues": [{{"item": "缺失要素名(缺签章请标明所属协议,如 主协议·甲方签章)", "category": "signature|field", "detail": "缺什么", "evidence": "第X页 + 原文留白片段 + 条款号,让人能翻回核对"}}]
|
|
81
|
+
}}
|
|
82
|
+
}}
|
|
83
|
+
|
|
84
|
+
字段抽取要点:
|
|
85
|
+
- key_dates label 用**规范名词**,避免同义不同名造成下游检索断链:
|
|
86
|
+
· 签订日 / 出具日 / 开票日 / 入职日 / 起租日 / 到期日 / 解除日;
|
|
87
|
+
· 商品房买卖合同优先用:房屋交付日、首期房价款支付截止日、贷款申请材料提交截止日、
|
|
88
|
+
贷款发放截止日、预售许可证取得日、土地使用权终止日期、抵押登记日期、
|
|
89
|
+
债务履行期限起始日、债务履行期限截止日、不动产登记办理截止日、配套设施竣工验收日。
|
|
90
|
+
· 不要用模糊词如"X 日"代替"X 截止日"——能区分"动作发生日"vs"动作截止日"就尽量区分。
|
|
91
|
+
- fields 是该类型专属的键值对,由文档内容自行决定抽哪些。例如:
|
|
92
|
+
· 收入证明 → 持证人、身份证号、用人单位、职位、入职日期、联系人、联系电话、单位地址
|
|
93
|
+
· 保险凭证 → 承保公司、保险产品/计划、保单号/凭证号、投保人、被保险人、证件类型/证件号、
|
|
94
|
+
保险期间起止、目的地/旅行区域、承保项目、保额、免赔额、紧急救援电话、报案电话、
|
|
95
|
+
境外服务热线、潜水/高风险运动责任、除外责任、电子保单/保单状态
|
|
96
|
+
· 旅行资料 → 行程名称、目的地/区域、服务提供方、航班/船宿/酒店/接驳信息、集合/登船/入住/离开时间、
|
|
97
|
+
证件/签证要求、材料上传截止日、费用项目、装备/行李要求、紧急联系人、活动限制/风险提示、
|
|
98
|
+
潜水/徒步/滑雪等活动规则、当地交通/港口/机场信息
|
|
99
|
+
· 发票 → 发票号、税号、开票方、购买方、税额
|
|
100
|
+
· 证件 → 证件号、有效期、签发机关
|
|
101
|
+
· 商品房买卖合同(预售/现售/二手房)→ 凡文档含相应条款的,**必抽**:
|
|
102
|
+
房屋坐落(完整地址)、房屋编号、房屋性质(毛坯/精装/全装修)、房屋类型(住宅/办公等)、
|
|
103
|
+
规划用途、预测建筑面积、套内建筑面积、分摊共有建筑面积、计价方式(按建筑面积/套内/按套)、
|
|
104
|
+
付款方式(一次性/商业贷款/公积金/组合贷)、绿色建筑等级;
|
|
105
|
+
土地用途、土地使用权终止日期、不动产权证号、预售许可证号、不动产单元号;
|
|
106
|
+
抵押状态(抵押中/无抵押)、抵押权人、抵押范围、抵押解除承诺;
|
|
107
|
+
质量担保人及担保范围(按楼幢号分担连带责任的第三方公司);
|
|
108
|
+
保修期·地基主体结构、保修期·防水/外墙渗漏、保修期·电气管线给排水、保修期·供热供冷;
|
|
109
|
+
前期物业服务企业、物业服务费、服务费、地下车位管理费、能耗费;
|
|
110
|
+
预售资金监管银行、预售资金监管账户、监管机构;
|
|
111
|
+
争议解决方式(仲裁/法院诉讼)、送达方式、合同份数、不动产登记办理期限。
|
|
112
|
+
· 房屋租赁合同 → 租赁标的、房屋用途、租期起止、支付方式、押金、违约金比例、争议解决方式。
|
|
113
|
+
把不属于 parties/amounts/key_dates 的有价值信息都放进 fields。
|
|
114
|
+
**fields 与 key_dates/obligations 的边界**:纯日期点(如"交付日")放 key_dates;
|
|
115
|
+
"X 方应做 Y" 放 obligations;客观属性、第三方机构名、约定条款值放 fields。
|
|
116
|
+
同一信息(如"房屋交付日")若已在 obligations.deadline,仍可在 key_dates 里冗余存放——
|
|
117
|
+
方便下游按时间检索。
|
|
118
|
+
- person_identities 是 fields 的"精确到人"版:把每个**具体的人/机构**与其固有标识精确绑定。
|
|
119
|
+
fields 里"乙方身份证号: A;B"分不清谁是谁,这里必须按人拆开,供跨文档逐人核对。
|
|
120
|
+
· 每个主体一个对象:name(与 parties 对应的姓名/全称)、role(其在本文档的角色)、
|
|
121
|
+
identifiers(该主体的身份证号/电话/银行账号/开户行/税号等键值,label+value)。
|
|
122
|
+
· name 必须**逐字摘自正文/parties 中的主体全称**,禁止改字、补字、规范化或自行翻译
|
|
123
|
+
(如把『浙典』写成『浙奥』即为幻觉);正文里找不到的名字一律不得编造。
|
|
124
|
+
· 同一实体只出一个对象:哪怕它在正文有多个称谓(如"出卖人(以下简称甲方)"),
|
|
125
|
+
也只用一个 name(取正文全称)、role 写其主要称谓,**禁止拆成"出卖人|X"和
|
|
126
|
+
"甲方|Y"两条指向同一实体的记录**——拆开会让跨文档核对把一家公司当成两家。
|
|
127
|
+
· 同一文档里多个自然人的身份证、电话**必须分别绑到各自名下,禁止混填或合并**。
|
|
128
|
+
例:买受人张三→身份证A、电话X;李四→身份证B、电话Y,务必拆成两个对象。
|
|
129
|
+
· 只放"主体固有"的稳定标识(身份证/电话/账号/税号),不放金额、日期、地址这类
|
|
130
|
+
随文档变化的信息。一个标识都绑不出则填空数组 []。
|
|
131
|
+
- amounts 列出文档里**所有**金额(不止主金额),各带语义 label。每个金额还需给出:
|
|
132
|
+
· unit:计量单位。**绝对金额**(合同总价、首期款、定金、年收入 等一笔确定的钱)填 null;
|
|
133
|
+
**单价/费率**(每单位若干钱)按原文量纲填,如物业费"2.25 元/月·平方米"→ "元/月·㎡"、
|
|
134
|
+
车位"100 元/个/月"→ "元/个/月"、违约金"每日万分之一点五"→ 不是金额不抽。
|
|
135
|
+
商品房合同第物业管理条款的【物业服务费】【服务费】【能耗费】【地下车位管理费】等
|
|
136
|
+
**都要各列一条**并填 unit——下游会按㎡单价 × 建筑面积派生月物业费。
|
|
137
|
+
单价项的 is_total_component 与 is_installment **一律 false**(单价不是总价组成、也非分期)。
|
|
138
|
+
· is_total_component:该金额是否计入"文档主合计"。收入证明的【年度税前收入】【年度股权应税收益】
|
|
139
|
+
等一次性年度收入项填 true;【月均收入】【公积金(个人/公司)】等会与年度项重复累加或非收入的填 false。
|
|
140
|
+
**铁律——合计项之间不可有包含关系,否则重复累加**:若文档已给出"总价款/合同总额"这类
|
|
141
|
+
汇总金额,则**只有该汇总项**标 true,它的各分期子项(首期款/余款/尾款/定金)**一律 false**。
|
|
142
|
+
例:房屋合同 总价款12279889 标 true;首期1849889、余款10430000 标 false(它们是总价的拆分,
|
|
143
|
+
再标 true 会让合计变成 总价+首期+余款=2×总价)。仅当文档**没有**单一汇总项、只有各独立
|
|
144
|
+
组成项(如收入证明的年度收入+股权收益)时,才让各组成项都标 true。宁缺勿错:拿不准一律 false。
|
|
145
|
+
· is_installment:该金额是否为某总价的"分期/部分付款"项(首期款/余款/尾款 等)。
|
|
146
|
+
车位/房屋合同的【首期款】【余款】填 true;一次性付款总额、单价(元/月·个、元/日)、
|
|
147
|
+
服务费、违约金等非分期项填 false。
|
|
148
|
+
**定金/订金/预付款一律 false**:它通常签约时支付并抵作房款(已含在首期款内或另行抵扣),
|
|
149
|
+
不与首期/余款并列再累加成总价;若误标 true,"分期之和"会虚高于总价、触发假的金额笔误告警
|
|
150
|
+
(仅当合同明确约定定金是首期/余款之外、与之并列累加构成总价的独立一期时才标 true)。
|
|
151
|
+
供代码校验"分期之和是否等于总价"以发现金额笔误。
|
|
152
|
+
注意:标了 is_installment=true 的项,is_total_component 必为 false(分期不入合计,见上)。
|
|
153
|
+
· evidence:这笔金额在原文的定位,页码(据页脚"第X页共Y页")+ 原文片段,便于翻回核对。
|
|
154
|
+
值本身只填"第X页 + 片段",勿带"出处"二字——展示时会自动加前缀。
|
|
155
|
+
· period_start / period_end:该金额覆盖的时间区间(ISO)。把"上年度""近12个月"等相对表述
|
|
156
|
+
按【出具日】解析成具体起止:
|
|
157
|
+
- "上年度/上一年度" = 上一个完整自然年(出具于 2026 年 → 2025-01-01 ~ 2025-12-31)
|
|
158
|
+
- "本年度/今年" = 当年 1月1日 ~ 出具日
|
|
159
|
+
- "近N个月/过去N个月" = 出具日往前推 N 个月 ~ 出具日
|
|
160
|
+
文档若明写具体起止日期,以原文为准;无区间概念的金额(如合同总额)两者填 null。
|
|
161
|
+
- seals 是文档上的印章(红章)。从盖章处识别到的文字(公司名/章类型/编号)填进来:
|
|
162
|
+
· raw_text 照实填识别到的原文,OCR 可能残缺、乱序甚至只剩单字——有什么填什么,不要编造。
|
|
163
|
+
· owner(盖章主体全称)/ seal_type(章类型)能判断就填,拿不准一律 null,禁止猜测编号。
|
|
164
|
+
· 文档若没有任何印章痕迹,seals 填空数组 []。
|
|
165
|
+
- obligations 仅当文档含明确"谁该在何时做什么"的待办/义务(合同尤甚);
|
|
166
|
+
保险凭证、旅行资料、证明、发票等通常为空数组。actor 只能是 party_a|party_b|both。
|
|
167
|
+
· 凡含 "X 方应/应当 在 Y 前 做 Z"、"X 方负责 Z"、"X 方承诺 Z" 的子句**全部抽进 obligations**,
|
|
168
|
+
不要只挑头几个。商品房买卖典型条款(party_a=出卖人,party_b=买受人):
|
|
169
|
+
party_a 应当在 X 前向买受人交付商品房;party_a 应当退还买受人已付全部房款(解除合同情形);
|
|
170
|
+
party_a 负责修复房屋质量问题;party_b 应当于 X 前支付首期房价款;
|
|
171
|
+
party_b 应当于 X 前向贷款机构提交贷款申请材料;party_b 自筹资金付清剩余房款;
|
|
172
|
+
party_b 配合办理退房及注销备案手续;party_b 申请办理房屋交易和不动产登记;
|
|
173
|
+
party_b 办理房屋交接手续。
|
|
174
|
+
· deadline:动作的明确截止日期(ISO)。"自 X 日起 N 日内" 解析为 X+N 日;
|
|
175
|
+
没有时间约束的填 null。
|
|
176
|
+
- sub_agreements 是这份文档里主协议之外的**附属协议**(最常见是《补充协议》,可能多份)。
|
|
177
|
+
很多合同 PDF 在主协议落款后还附了补充协议,它修改/补充原协议(如改期限、改费用承担),
|
|
178
|
+
且通常有自己独立的签章落款区与生效条件。识别与抽取:
|
|
179
|
+
· 触发信号:"《XX》补充协议""补充协议""附件协议"等标题,或"鉴于…达成如下补充协议"。
|
|
180
|
+
· 每份填:title(如"补充协议");summary(这份改了/补充了什么,一句话);
|
|
181
|
+
sign_date(该补充协议落款日期,空白填 null);seals(这份补充协议落款上的章,规则同上层
|
|
182
|
+
seals,没有填 []);evidence(原文关键片段)。
|
|
183
|
+
· 主协议本身的字段仍填在顶层(parties/amounts/obligations 等),不要塞进 sub_agreements。
|
|
184
|
+
· 没有附属协议就填空数组 []。
|
|
185
|
+
- completeness 是合同完整性核查,**仅当 doc_type 为"合同协议"时填**,其他类型一律 null
|
|
186
|
+
(保险凭证/旅行资料/证明/发票没有"甲乙双方签章齐不齐"的概念)。两步判断:
|
|
187
|
+
(1) 先据**这份合同的类型**判断它应具备哪些要素——双方主体、标的物、价款/金额、
|
|
188
|
+
签订日期、双方签章等。要素清单因类型而异,自行判断,**不要套死清单**:
|
|
189
|
+
车位转让/买卖等一次性合同没有到期日属正常,框架协议没有具体金额属正常,
|
|
190
|
+
把"本就不该有"的判成缺失是错误。
|
|
191
|
+
(2) 逐项核查实际是否齐全,缺的或留空白占位的(如"___年__月__日""甲方(盖章):"后空白)
|
|
192
|
+
列进 issues。每条:item=要素名;category=signature(签章/签字类) 或 field(其他要素);
|
|
193
|
+
detail=缺什么(简述);evidence=**出处定位**——注明页码(据每页页脚"第X页共Y页")+
|
|
194
|
+
留白处的原文片段 + 条款号,让人能翻回原文核对。**定位不出出处的缺陷不要报**(宁缺毋滥)。
|
|
195
|
+
(3) 多选一条款不算缺:若某要素是"多选一"(典型:付款方式=一次性付款 或 银行贷款分期),
|
|
196
|
+
当事人实际选用并填好其中一种即视为完整,**未选用方式的留白是正常的、不算缺失**,
|
|
197
|
+
不要报。只核查当事人实际选用方式内部的留白。
|
|
198
|
+
签章核查要点:本文档每个协议单元——主协议 + 每一份 sub_agreements——都有自己的落款区,
|
|
199
|
+
必须**逐个**核查各自"X方(盖章/签字):"处后面是否有实际印章文字或签名;空着=疑似缺。
|
|
200
|
+
缺章的 issue.item 必须标明所属协议,如"主协议·甲方签章""补充协议·甲方签章"。
|
|
201
|
+
**红章 OCR 经常读不出(淡红/模糊)**,所以凡判定缺章,detail 里务必注明"疑似,可能 OCR
|
|
202
|
+
漏识,需人工复核"——不要把"没读到章"当成"确认没盖章"。
|
|
203
|
+
status:所有协议单元的要素与签章全齐=complete;存在任一缺项=incomplete;信息不足=unknown。
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def call_llm_document(
|
|
208
|
+
document_text: str,
|
|
209
|
+
model: str | None = None,
|
|
210
|
+
api_key: str | None = None,
|
|
211
|
+
base_url: str | None = None,
|
|
212
|
+
max_chars: int = 60000,
|
|
213
|
+
) -> LlmResult:
|
|
214
|
+
"""
|
|
215
|
+
调 DashScope LLM(OpenAI 兼容口)做通用文档抽取,返回 LlmResult(parsed/model/usage)。
|
|
216
|
+
|
|
217
|
+
见 CLAUDE.md:DashScope 一律走兼容口(原生 Generation 不认部分模型 id)。
|
|
218
|
+
用通用文档 prompt,传输/解析复用 llm_extractor 的兼容口 helper。
|
|
219
|
+
失败时 parsed={}(调用方判 `if not res.parsed`),与历史"返回空 dict"语义一致。
|
|
220
|
+
"""
|
|
221
|
+
# 统一从 config 层取(env > 配置文件 > 默认);显式传参仍优先(param or settings)。
|
|
222
|
+
settings = load_settings()
|
|
223
|
+
model = model or settings.dashscope_model
|
|
224
|
+
api_key = api_key or settings.dashscope_api_key
|
|
225
|
+
base_url = base_url or settings.dashscope_base_url
|
|
226
|
+
if not api_key:
|
|
227
|
+
logger.warning("DASHSCOPE_API_KEY missing; skip LLM document extraction")
|
|
228
|
+
return LlmResult(
|
|
229
|
+
parsed={}, model=model,
|
|
230
|
+
error=config_missing("DASHSCOPE_API_KEY 缺失,跳过 LLM 文档抽取"),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
user_msg = f"以下是文档正文,请判类型并抽取字段:\n\n{_truncate_middle(document_text, max_chars)}"
|
|
234
|
+
try:
|
|
235
|
+
content, usage = _call_openai_compat(DOC_EXTRACT_SYSTEM_PROMPT, user_msg, model, api_key, base_url)
|
|
236
|
+
except Exception as e: # noqa: BLE001 — 外部调用降级返回空,但保留结构化 error 供上层判重试
|
|
237
|
+
logger.exception("DashScope document LLM call failed: %s", e)
|
|
238
|
+
return LlmResult(parsed={}, model=model, error=classify_exception(e))
|
|
239
|
+
|
|
240
|
+
if not content:
|
|
241
|
+
logger.warning("LLM empty response (document extract)")
|
|
242
|
+
return LlmResult(parsed={}, model=model, usage=None)
|
|
243
|
+
parsed = _parse_json_loose(content)
|
|
244
|
+
if not parsed:
|
|
245
|
+
logger.warning("LLM document response not parseable: %s", content[:200])
|
|
246
|
+
return LlmResult(parsed=parsed, model=model, usage=usage)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _norm_period(raw: Any) -> Optional[str]:
|
|
250
|
+
"""区间端点 → ISO 日期;非字符串/空/无法解析返回 None。"""
|
|
251
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
252
|
+
return None
|
|
253
|
+
return normalize_date(raw.strip())
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _coerce_labeled_amounts(raw: Any) -> list[LabeledAmount]:
|
|
257
|
+
"""LLM amounts 数组 → LabeledAmount,顺手算数值、归一化区间日期。跳过非法项。"""
|
|
258
|
+
if not isinstance(raw, list):
|
|
259
|
+
return []
|
|
260
|
+
out: list[LabeledAmount] = []
|
|
261
|
+
for item in raw:
|
|
262
|
+
if not isinstance(item, dict):
|
|
263
|
+
continue
|
|
264
|
+
text = str(item.get("text") or item.get("value") or "").strip()
|
|
265
|
+
if not text:
|
|
266
|
+
continue
|
|
267
|
+
label = str(item.get("label", "")).strip() or "金额"
|
|
268
|
+
unit = str(item.get("unit") or "").strip() or None
|
|
269
|
+
is_installment = bool(item.get("is_installment", False))
|
|
270
|
+
# is_total_component(计入主合计)的两个**代码强制不变量**,纠正 LLM 误标:
|
|
271
|
+
# 1) 单价项(unit 非空,如"2.25 元/月·㎡")量纲不同,绝不入合计;
|
|
272
|
+
# 2) 分期项(is_installment,如首期/余款/尾款)是某总价的部分付款,不是合计的
|
|
273
|
+
# 独立组成——若与总价同时计入会重复累加(总价12279889 + 首期 + 余款 = 2×总价)。
|
|
274
|
+
# 这两类金额无论 LLM 怎么标,is_total_component 一律压成 False。
|
|
275
|
+
is_total_component = (
|
|
276
|
+
bool(item.get("is_total_component", False))
|
|
277
|
+
and not unit
|
|
278
|
+
and not is_installment
|
|
279
|
+
)
|
|
280
|
+
# parse_money_value 对单价文本("2.25元/月·㎡")同样取得首个数值(2.25)。
|
|
281
|
+
out.append(LabeledAmount(
|
|
282
|
+
label=label,
|
|
283
|
+
text=text,
|
|
284
|
+
value=parse_money_value(text),
|
|
285
|
+
unit=unit,
|
|
286
|
+
is_total_component=is_total_component,
|
|
287
|
+
is_installment=is_installment,
|
|
288
|
+
period_start=_norm_period(item.get("period_start")),
|
|
289
|
+
period_end=_norm_period(item.get("period_end")),
|
|
290
|
+
evidence=str(item.get("evidence") or "").strip(),
|
|
291
|
+
))
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _coerce_labeled_dates(raw: Any) -> list[LabeledDate]:
|
|
296
|
+
"""LLM key_dates 数组 → LabeledDate,日期归一化到 ISO。"""
|
|
297
|
+
if not isinstance(raw, list):
|
|
298
|
+
return []
|
|
299
|
+
out: list[LabeledDate] = []
|
|
300
|
+
for item in raw:
|
|
301
|
+
if not isinstance(item, dict):
|
|
302
|
+
continue
|
|
303
|
+
label = str(item.get("label", "")).strip()
|
|
304
|
+
date = item.get("date")
|
|
305
|
+
date = normalize_date(date) if isinstance(date, str) and date else None
|
|
306
|
+
if not label and not date:
|
|
307
|
+
continue
|
|
308
|
+
out.append(LabeledDate(label=label or "日期", date=date))
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _coerce_labeled_values(raw: Any) -> list[LabeledValue]:
|
|
313
|
+
"""LLM fields 数组 → LabeledValue。跳过空值项。"""
|
|
314
|
+
if not isinstance(raw, list):
|
|
315
|
+
return []
|
|
316
|
+
out: list[LabeledValue] = []
|
|
317
|
+
for item in raw:
|
|
318
|
+
if not isinstance(item, dict):
|
|
319
|
+
continue
|
|
320
|
+
label = str(item.get("label", "")).strip()
|
|
321
|
+
value = item.get("value")
|
|
322
|
+
value = "" if value is None else str(value).strip()
|
|
323
|
+
if not label or not value:
|
|
324
|
+
continue
|
|
325
|
+
out.append(LabeledValue(label=label, value=value))
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _coerce_person_identities(raw: Any) -> list[PersonIdentity]:
|
|
330
|
+
"""
|
|
331
|
+
LLM person_identities 数组 → PersonIdentity(精确到人的固有标识)。
|
|
332
|
+
|
|
333
|
+
跳过无 name 或无任何有效 identifier 的项——光有名字没标识对核对无意义。
|
|
334
|
+
identifiers 复用 _coerce_labeled_values 的清洗(去空 label/value)。
|
|
335
|
+
"""
|
|
336
|
+
if not isinstance(raw, list):
|
|
337
|
+
return []
|
|
338
|
+
out: list[PersonIdentity] = []
|
|
339
|
+
for item in raw:
|
|
340
|
+
if not isinstance(item, dict):
|
|
341
|
+
continue
|
|
342
|
+
name = str(item.get("name") or "").strip()
|
|
343
|
+
if not name:
|
|
344
|
+
continue
|
|
345
|
+
identifiers = _coerce_labeled_values(item.get("identifiers"))
|
|
346
|
+
if not identifiers:
|
|
347
|
+
continue
|
|
348
|
+
role = str(item.get("role") or "").strip() or None
|
|
349
|
+
out.append(PersonIdentity(name=name, role=role, identifiers=identifiers))
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _filter_identities_by_text(
|
|
354
|
+
identities: list[PersonIdentity], document_text: str
|
|
355
|
+
) -> list[PersonIdentity]:
|
|
356
|
+
"""
|
|
357
|
+
丢弃 name 在正文中根本不出现的 person_identity——确定性的幻觉护栏。
|
|
358
|
+
|
|
359
|
+
prompt 已要求 name 逐字摘自正文,但 LLM 偶尔仍改字/编造(实测把『浙典』幻觉成
|
|
360
|
+
正文不存在的『浙奥』),使同一实体在 known_parties 分裂。凡正文(去空白后)不含
|
|
361
|
+
该 name 的,判为幻觉丢弃。比较去空白以容忍 OCR 在名字中夹空格。
|
|
362
|
+
注:VL 落款章绑定的主体在抽取之后才追加(owner 未必在正文),不经此过滤。
|
|
363
|
+
"""
|
|
364
|
+
if not document_text:
|
|
365
|
+
return identities
|
|
366
|
+
haystack = re.sub(r"\s+", "", document_text)
|
|
367
|
+
kept: list[PersonIdentity] = []
|
|
368
|
+
for person in identities:
|
|
369
|
+
if re.sub(r"\s+", "", person.name) in haystack:
|
|
370
|
+
kept.append(person)
|
|
371
|
+
else:
|
|
372
|
+
logger.info("丢弃疑似幻觉主体(正文未出现该名): %s", person.name)
|
|
373
|
+
return kept
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _coerce_parties(raw: Any) -> list[str]:
|
|
377
|
+
if not isinstance(raw, list):
|
|
378
|
+
return []
|
|
379
|
+
return [str(p).strip() for p in raw if p and str(p).strip()]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _coerce_seals(raw: Any) -> list[Seal]:
|
|
383
|
+
"""LLM seals 数组 → Seal。跳过 raw_text 与 owner 全空的垃圾项。"""
|
|
384
|
+
if not isinstance(raw, list):
|
|
385
|
+
return []
|
|
386
|
+
out: list[Seal] = []
|
|
387
|
+
for item in raw:
|
|
388
|
+
if not isinstance(item, dict):
|
|
389
|
+
continue
|
|
390
|
+
raw_text = str(item.get("raw_text") or "").strip()
|
|
391
|
+
owner = str(item.get("owner") or "").strip() or None
|
|
392
|
+
seal_type = str(item.get("seal_type") or "").strip() or None
|
|
393
|
+
if not raw_text and not owner:
|
|
394
|
+
continue
|
|
395
|
+
out.append(Seal(raw_text=raw_text, owner=owner, seal_type=seal_type))
|
|
396
|
+
return out
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _coerce_completeness(raw: Any, doc_type: str) -> Optional[Completeness]:
|
|
400
|
+
"""
|
|
401
|
+
LLM completeness 字段 → Completeness。仅合同协议保留;其他类型返回 None
|
|
402
|
+
(即便 LLM 误填了也丢弃,避免给证明/发票安上无意义的"缺签章")。
|
|
403
|
+
缺字段/非法结构时返回 None,不硬塞。
|
|
404
|
+
"""
|
|
405
|
+
if doc_type != "合同协议" or not isinstance(raw, dict):
|
|
406
|
+
return None
|
|
407
|
+
status = str(raw.get("status", "")).strip()
|
|
408
|
+
if status not in ("complete", "incomplete", "unknown"):
|
|
409
|
+
status = "unknown"
|
|
410
|
+
issues: list[CompletenessIssue] = []
|
|
411
|
+
for item in raw.get("issues") or []:
|
|
412
|
+
if not isinstance(item, dict):
|
|
413
|
+
continue
|
|
414
|
+
name = str(item.get("item") or "").strip()
|
|
415
|
+
if not name:
|
|
416
|
+
continue
|
|
417
|
+
category = str(item.get("category") or "").strip()
|
|
418
|
+
if category not in ("signature", "field"):
|
|
419
|
+
category = "field"
|
|
420
|
+
issues.append(CompletenessIssue(
|
|
421
|
+
item=name,
|
|
422
|
+
category=category,
|
|
423
|
+
detail=str(item.get("detail") or "").strip(),
|
|
424
|
+
evidence=str(item.get("evidence") or "").strip(),
|
|
425
|
+
))
|
|
426
|
+
# 有缺项却被 LLM 标 complete:以缺项为准纠正(issues 是更硬的证据)。
|
|
427
|
+
if issues and status == "complete":
|
|
428
|
+
status = "incomplete"
|
|
429
|
+
return Completeness(status=status, issues=issues)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _coerce_sub_agreements(raw: Any) -> list[SubAgreement]:
|
|
433
|
+
"""LLM sub_agreements 数组 → SubAgreement。跳过无 title 的垃圾项;seals 复用 _coerce_seals。"""
|
|
434
|
+
if not isinstance(raw, list):
|
|
435
|
+
return []
|
|
436
|
+
out: list[SubAgreement] = []
|
|
437
|
+
for item in raw:
|
|
438
|
+
if not isinstance(item, dict):
|
|
439
|
+
continue
|
|
440
|
+
title = str(item.get("title") or "").strip()
|
|
441
|
+
if not title:
|
|
442
|
+
continue
|
|
443
|
+
sign_date = item.get("sign_date")
|
|
444
|
+
sign_date = normalize_date(sign_date) if isinstance(sign_date, str) and sign_date else None
|
|
445
|
+
out.append(SubAgreement(
|
|
446
|
+
title=title,
|
|
447
|
+
summary=str(item.get("summary") or "").strip(),
|
|
448
|
+
sign_date=sign_date,
|
|
449
|
+
seals=_coerce_seals(item.get("seals")),
|
|
450
|
+
evidence=str(item.get("evidence") or "").strip(),
|
|
451
|
+
))
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def extract_document(
|
|
456
|
+
document_text: str,
|
|
457
|
+
llm_enabled: bool = True,
|
|
458
|
+
model: str | None = None,
|
|
459
|
+
) -> DocumentExtraction:
|
|
460
|
+
"""
|
|
461
|
+
通用文档抽取主入口:LLM 判类型 + 抽字段 → DocumentExtraction 信封。
|
|
462
|
+
|
|
463
|
+
:param llm_enabled: False(或无 API key)时返回空信封(doc_type 留默认)。
|
|
464
|
+
通用路径不依赖 rule,关掉 LLM 就没有可抽的东西——诚实返回空。
|
|
465
|
+
:param model: 覆盖抽取所用 model(默认 None=走 settings.dashscope_model)。
|
|
466
|
+
评测换模型的唯一入口——实际跑的 model 即 res.model,回填 llm_model
|
|
467
|
+
保证"记录的模型=实际跑的模型"(单一真相源,不再二次读 settings)。
|
|
468
|
+
"""
|
|
469
|
+
if not llm_enabled:
|
|
470
|
+
return DocumentExtraction()
|
|
471
|
+
|
|
472
|
+
res = call_llm_document(document_text, model=model)
|
|
473
|
+
raw = res.parsed
|
|
474
|
+
if not raw:
|
|
475
|
+
# 抽取为空:**不设 llm_model(保持 None)**——schema 定义"调用失败 llm_model 为 None",
|
|
476
|
+
# 且 evals 的 parse_ok 一票否决依赖它(llm_model 非 None = 调用/解析成功);这里若设非 None,
|
|
477
|
+
# 会让产不出 JSON 的劣质模型在换模型评测里蒙混过 parse_ok gate(见 MEMORY「评测报告撒谎坑」)。
|
|
478
|
+
# 只带结构化 error(缺 key→CONFIG_MISSING / API 异常分类 / 空 JSON→EXTRACT_EMPTY),供 ingest 判重试。
|
|
479
|
+
return DocumentExtraction(
|
|
480
|
+
extraction_error=res.error or extract_empty("LLM 返回空或无法解析为 JSON"),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
doc_type = str(raw.get("doc_type", "")).strip()
|
|
484
|
+
if doc_type not in DOC_TYPES:
|
|
485
|
+
doc_type = "其他"
|
|
486
|
+
|
|
487
|
+
primary_amount_text = (raw.get("primary_amount") or None)
|
|
488
|
+
if isinstance(primary_amount_text, str):
|
|
489
|
+
primary_amount_text = primary_amount_text.strip() or None
|
|
490
|
+
|
|
491
|
+
primary_date = raw.get("primary_date")
|
|
492
|
+
primary_date = normalize_date(primary_date) if isinstance(primary_date, str) and primary_date else None
|
|
493
|
+
|
|
494
|
+
# person_identities 过幻觉护栏:name 须在正文出现,编造名(正文不存在)丢弃。
|
|
495
|
+
person_identities = _filter_identities_by_text(
|
|
496
|
+
_coerce_person_identities(raw.get("person_identities")), document_text
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
amounts = _coerce_labeled_amounts(raw.get("amounts"))
|
|
500
|
+
# 计算值(非抽取):把 LLM 标记为 is_total_component 的金额求和。
|
|
501
|
+
# 由代码做算术(LLM 只负责语义分类),避免 LLM 加法出错。
|
|
502
|
+
components = [a.value for a in amounts if a.is_total_component and a.value is not None]
|
|
503
|
+
computed_total = round(sum(components), 2) if components else None
|
|
504
|
+
|
|
505
|
+
# 派生值(非抽取):月物业费 = Σ按㎡单价 × 建筑面积。同由代码乘算,LLM 只抽单价。
|
|
506
|
+
fields = _coerce_labeled_values(raw.get("fields"))
|
|
507
|
+
monthly_fee_value, monthly_fee_text = estimate_monthly_property_fee(amounts, fields)
|
|
508
|
+
|
|
509
|
+
# 完整性 = LLM 判的签章/要素 + 代码确定性判的金额自洽异常(分期之和≠总价 等)。
|
|
510
|
+
# 金额异常只对合同挂(completeness 是合同概念);有异常必判 incomplete。
|
|
511
|
+
# 注:vision_seal.augment 重判签章时保留 category!="signature" 的 issue,amount 类不受影响。
|
|
512
|
+
completeness = _coerce_completeness(raw.get("completeness"), doc_type)
|
|
513
|
+
if doc_type == "合同协议":
|
|
514
|
+
amount_issues = check_amount_consistency(amounts, computed_total)
|
|
515
|
+
if amount_issues:
|
|
516
|
+
base_issues = completeness.issues if completeness else []
|
|
517
|
+
completeness = Completeness(status="incomplete", issues=base_issues + amount_issues)
|
|
518
|
+
|
|
519
|
+
return DocumentExtraction(
|
|
520
|
+
doc_type=doc_type,
|
|
521
|
+
title=(str(raw["title"]).strip() if raw.get("title") else None),
|
|
522
|
+
summary=(str(raw["summary"]).strip() if raw.get("summary") else None),
|
|
523
|
+
parties=_coerce_parties(raw.get("parties")),
|
|
524
|
+
primary_date=primary_date,
|
|
525
|
+
primary_amount_text=primary_amount_text,
|
|
526
|
+
primary_amount_value=parse_money_value(primary_amount_text),
|
|
527
|
+
computed_total_value=computed_total,
|
|
528
|
+
monthly_property_fee_value=monthly_fee_value,
|
|
529
|
+
monthly_property_fee_text=monthly_fee_text,
|
|
530
|
+
key_dates=_coerce_labeled_dates(raw.get("key_dates")),
|
|
531
|
+
amounts=amounts,
|
|
532
|
+
seals=_coerce_seals(raw.get("seals")),
|
|
533
|
+
fields=fields,
|
|
534
|
+
person_identities=person_identities,
|
|
535
|
+
obligations=coerce_obligations(raw.get("obligations")),
|
|
536
|
+
sub_agreements=_coerce_sub_agreements(raw.get("sub_agreements")),
|
|
537
|
+
completeness=completeness,
|
|
538
|
+
raw_evidence={},
|
|
539
|
+
# 单一真相源:记下本次调用实际请求的 model(res.model),而非二次读 settings——
|
|
540
|
+
# 后者在评测换模型时会张冠李戴(记 qwen3.7-max 实跑 qwen-plus)。
|
|
541
|
+
llm_model=res.model,
|
|
542
|
+
# token 用量(评测算成本的旁证);生产侧也可用于成本追踪。读不到为 None。
|
|
543
|
+
llm_usage=res.usage,
|
|
544
|
+
# 结构化错误:成功为 None;用于 ingest 失败诊断与 Agent 重试决策。
|
|
545
|
+
extraction_error=res.error,
|
|
546
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
出处页码校正:用 MinerU content_list.json 的可靠 page_idx 覆盖 LLM 猜的页码。
|
|
3
|
+
|
|
4
|
+
为什么需要:LLM 抽取走扁平 raw_text(多页拼接、页边界已丢失),它填进 evidence
|
|
5
|
+
的页码靠估算,长文档常错位(实测 29 号占用费在 PDF 第6页,LLM 填了第5页)。而
|
|
6
|
+
content_list.json 每个文本块带准确 page_idx——拿 evidence 里的原文片段去反查,把
|
|
7
|
+
页码校正过来。这与签章核查(vision_seal 用 content_list 定位落款页)同一可靠来源。
|
|
8
|
+
|
|
9
|
+
只动"第X页 + 原文片段"这种带可定位片段的出处;签章类 evidence(VL 给的
|
|
10
|
+
"据落款页图:第X页",无原文片段)正则不匹配,天然不受影响。
|
|
11
|
+
|
|
12
|
+
降级:无 content_list / 片段反查不到时,保留原页码不动——诚实,不瞎改。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ..schemas import DocumentExtraction
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# 匹配 evidence 里的"第X页 + 原文片段"对(片段取到分号/串尾止)。
|
|
26
|
+
# 拼接 evidence(多分期项以分号隔开)会逐对匹配各自校正;
|
|
27
|
+
# 签章式"据落款页图:第8页"没有"+ 片段",不匹配 → 不动。
|
|
28
|
+
_PAGE_FRAG = re.compile(r"第\s*(\d+)\s*页\s*[++]\s*([^;;]*)")
|
|
29
|
+
_WS = re.compile(r"\s+")
|
|
30
|
+
|
|
31
|
+
# 反查用的最短/滑窗片段长度:太短易误命中多页,故要求 ≥8 字连续重叠。
|
|
32
|
+
_MIN_ANCHOR = 8
|
|
33
|
+
_WINDOW = 12
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_blocks(mineru_dir: Path) -> list[tuple[str, int]]:
|
|
37
|
+
"""content_list.json → [(去空白文本, page_idx)]。读不到/解析失败返回 []。"""
|
|
38
|
+
content_lists = list(mineru_dir.glob("_mineru_raw/*/auto/*_content_list.json"))
|
|
39
|
+
if not content_lists:
|
|
40
|
+
return []
|
|
41
|
+
try:
|
|
42
|
+
items = json.loads(content_lists[0].read_text(encoding="utf-8"))
|
|
43
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
44
|
+
logger.warning("读取 content_list 失败,跳过页码校正: %s", e)
|
|
45
|
+
return []
|
|
46
|
+
blocks: list[tuple[str, int]] = []
|
|
47
|
+
for it in items:
|
|
48
|
+
if isinstance(it, dict) and it.get("page_idx") is not None:
|
|
49
|
+
text = _WS.sub("", str(it.get("text", "")))
|
|
50
|
+
if text:
|
|
51
|
+
blocks.append((text, int(it["page_idx"])))
|
|
52
|
+
return blocks
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _find_page(fragment: str, blocks: list[tuple[str, int]]) -> int | None:
|
|
56
|
+
"""用原文片段在各文本块中反查 page_idx(0-based)。滑窗子串命中即返回,否则 None。"""
|
|
57
|
+
frag = _WS.sub("", fragment)
|
|
58
|
+
if len(frag) < _MIN_ANCHOR:
|
|
59
|
+
return None
|
|
60
|
+
for i in range(max(1, len(frag) - _MIN_ANCHOR + 1)):
|
|
61
|
+
sub = frag[i:i + _WINDOW]
|
|
62
|
+
if len(sub) < _MIN_ANCHOR:
|
|
63
|
+
break
|
|
64
|
+
for text, page_idx in blocks:
|
|
65
|
+
if sub in text:
|
|
66
|
+
return page_idx
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _correct_evidence(evidence: str, blocks: list[tuple[str, int]]) -> str:
|
|
71
|
+
"""校正一条 evidence 里所有"第X页 + 片段"对的页码;反查不到的对保持不动。"""
|
|
72
|
+
def repl(m: "re.Match[str]") -> str:
|
|
73
|
+
frag = m.group(2)
|
|
74
|
+
page_idx = _find_page(frag, blocks)
|
|
75
|
+
if page_idx is None:
|
|
76
|
+
return m.group(0)
|
|
77
|
+
return f"第{page_idx + 1}页 + {frag}"
|
|
78
|
+
|
|
79
|
+
return _PAGE_FRAG.sub(repl, evidence)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def correct_evidence_pages(env: DocumentExtraction, mineru_dir: Path) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
用 content_list 的 page_idx 校正 env 中 amounts / completeness issues 的 evidence 页码。
|
|
85
|
+
|
|
86
|
+
原地修改 env。有 content_list 可用返回 True;无则返回 False(调用方保留原页码)。
|
|
87
|
+
amount 类 issue 的 evidence 是各分期项出处的拼接,_PAGE_FRAG 逐对匹配,一并校正。
|
|
88
|
+
"""
|
|
89
|
+
blocks = _load_blocks(mineru_dir)
|
|
90
|
+
if not blocks:
|
|
91
|
+
return False
|
|
92
|
+
for amount in env.amounts:
|
|
93
|
+
if amount.evidence:
|
|
94
|
+
amount.evidence = _correct_evidence(amount.evidence, blocks)
|
|
95
|
+
if env.completeness:
|
|
96
|
+
for issue in env.completeness.issues:
|
|
97
|
+
if issue.evidence:
|
|
98
|
+
issue.evidence = _correct_evidence(issue.evidence, blocks)
|
|
99
|
+
return True
|