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.
Files changed (44) hide show
  1. contract_archive/__init__.py +2 -0
  2. contract_archive/archive/__init__.py +64 -0
  3. contract_archive/archive/db.py +126 -0
  4. contract_archive/archive/ingest.py +667 -0
  5. contract_archive/archive/migrations/001_init.sql +62 -0
  6. contract_archive/archive/migrations/002_obligations.sql +25 -0
  7. contract_archive/archive/migrations/003_document_types.sql +31 -0
  8. contract_archive/archive/migrations/004_seals_subjects.sql +36 -0
  9. contract_archive/archive/migrations/005_completeness.sql +18 -0
  10. contract_archive/archive/party_registry.py +276 -0
  11. contract_archive/archive/paths.py +113 -0
  12. contract_archive/archive/repository.py +918 -0
  13. contract_archive/cli.py +455 -0
  14. contract_archive/cli_common.py +293 -0
  15. contract_archive/cli_config.py +96 -0
  16. contract_archive/cli_introspect.py +204 -0
  17. contract_archive/cli_party.py +166 -0
  18. contract_archive/cli_query.py +492 -0
  19. contract_archive/cli_render.py +575 -0
  20. contract_archive/config.py +257 -0
  21. contract_archive/errors.py +163 -0
  22. contract_archive/extraction/__init__.py +14 -0
  23. contract_archive/extraction/amount_check.py +87 -0
  24. contract_archive/extraction/contract_extractor.py +103 -0
  25. contract_archive/extraction/document_extractor.py +546 -0
  26. contract_archive/extraction/evidence_page_fix.py +99 -0
  27. contract_archive/extraction/llm_extractor.py +207 -0
  28. contract_archive/extraction/normalize.py +210 -0
  29. contract_archive/extraction/property_fee.py +79 -0
  30. contract_archive/extraction/vision_seal.py +390 -0
  31. contract_archive/pipelines/__init__.py +9 -0
  32. contract_archive/pipelines/mineru_pipeline.py +955 -0
  33. contract_archive/pipelines/vl_ocr.py +160 -0
  34. contract_archive/schemas/__init__.py +67 -0
  35. contract_archive/schemas/document.py +408 -0
  36. contract_archive/utils/__init__.py +27 -0
  37. contract_archive/utils/device.py +51 -0
  38. contract_archive/utils/http_env.py +54 -0
  39. contract_archive/utils/pdf.py +207 -0
  40. contract_archive_cli-0.2.7.dist-info/METADATA +386 -0
  41. contract_archive_cli-0.2.7.dist-info/RECORD +44 -0
  42. contract_archive_cli-0.2.7.dist-info/WHEEL +4 -0
  43. contract_archive_cli-0.2.7.dist-info/entry_points.txt +2 -0
  44. contract_archive_cli-0.2.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,160 @@
1
+ """DashScope OCR:用专用 OCR 模型(qwen-vl-ocr)逐页转写 PDF 图片。
2
+
3
+ 为什么逐页:qwen-vl-ocr 是专用 OCR 模型,maxInputTokens 仅 30000,一次塞不下多页
4
+ 高分辨率图(旧实现把整份 PDF 全部页塞进一个请求,只有上下文极大的通用 VL 模型
5
+ qwen3.6-flash 才扛得住,且慢、易超时)。这里改为每页一次调用、拼接结果,既用上专用
6
+ OCR 模型,也消除了"页数超上限就回退 mineru"的硬限制。
7
+
8
+ 模型取 settings.dashscope_ocr_model(默认 qwen-vl-ocr-latest);签章核查仍用 vl_model,
9
+ 互不影响。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import logging
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from ..config import get_timeout_s, load_settings
20
+ from ..utils.http_env import sanitized_httpx_proxy_env
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ VL_OCR_PAGE_PROMPT = """你是严谨的 OCR 助理。请只转写这张图片中的全部文本,输出简洁 Markdown,不要总结、不要解释、不要编造。
26
+
27
+ 要求:
28
+ - 表格尽量转成 Markdown 表格;复杂表格逐行保留字段名和值。
29
+ - 保留保险/合同/凭证中的编号、姓名、日期、金额、保障责任、电话、地址等关键字段。
30
+ - 看不清的地方写 `[看不清]`,不要猜。
31
+ - 只输出本页文本,不要自己加页码标题(调用方会统一加 `## 第 X 页`)。
32
+ """
33
+
34
+ # 单页三种异常态各用独立标记,互不混淆 —— 关键是把"请求失败"和"模型识别不清"分开:
35
+ # _MARK_FAILED 请求级失败(SDK 自动重试耗尽后仍抛错)。混进 [看不清] 就永久救不回、
36
+ # 也无法事后审计/单页补跑,所以必须独立。
37
+ # _MARK_TRUNCATED 输出触达模型 8192 token 上限被截断;已得内容保留,但显式标记残页。
38
+ # _MARK_ILLEGIBLE 模型正常返回但本页无可识别文本。
39
+ _MARK_FAILED = "[本页 OCR 调用失败]"
40
+ _MARK_TRUNCATED = "[本页输出达模型上限被截断]"
41
+ _MARK_ILLEGIBLE = "[看不清]"
42
+
43
+
44
+ def _ocr_max_retries() -> int:
45
+ """逐页 OCR 的 SDK 重试次数(CONTRACT_ARCHIVE_VL_OCR_RETRIES 可调,默认 4)。
46
+
47
+ openai SDK 对 429/超时/5xx/连接错误本就会自动指数退避重试(且读 Retry-After),
48
+ 这里只是把默认的 2 调高 —— 一份文档逐页要发几十上百个请求,偶发限流/抖动的概率
49
+ 随页数累积,靠 SDK 多重试几次比手写循环干净,也避免单页偶发失败直接丢一整页内容。
50
+ """
51
+ raw = os.getenv("CONTRACT_ARCHIVE_VL_OCR_RETRIES")
52
+ if not raw or not raw.strip():
53
+ return 4
54
+ try:
55
+ val = int(raw.strip())
56
+ except ValueError:
57
+ logger.warning("CONTRACT_ARCHIVE_VL_OCR_RETRIES=%r 不是整数,回退默认 4", raw)
58
+ return 4
59
+ return val if val >= 0 else 4
60
+
61
+
62
+ def ocr_pdf_images_with_vl(
63
+ image_paths: list[Path],
64
+ *,
65
+ model: str | None = None,
66
+ api_key: str | None = None,
67
+ base_url: str | None = None,
68
+ ) -> Optional[str]:
69
+ """
70
+ 用 DashScope 专用 OCR 模型(OpenAI 兼容口)逐页转写渲染好的 PDF 页图片。
71
+
72
+ 每页一次请求,拼成 `## 第 X 页` 分隔的 Markdown。单页异常不中断整份,三种异常态各记
73
+ 独立标记(调用失败 / 输出截断 / 看不清),只有无任何可用页时才返回 None 让调用方回退到
74
+ 原 MinerU 路径。429/超时/5xx 由 SDK 自动重试(见 _ocr_max_retries)。无凭证时返回 None。
75
+ """
76
+ if not image_paths:
77
+ return ""
78
+
79
+ settings = load_settings()
80
+ model = model or settings.dashscope_ocr_model
81
+ api_key = api_key or settings.dashscope_api_key
82
+ base_url = base_url or settings.dashscope_base_url
83
+ if not api_key:
84
+ logger.warning("DASHSCOPE_API_KEY missing; skip VL OCR")
85
+ return None
86
+
87
+ from openai import OpenAI
88
+
89
+ compat_url = base_url.replace("/api/v1", "/compatible-mode/v1")
90
+ total = len(image_paths)
91
+ logger.info("[vl-ocr] %s page(s) via %s (逐页)", total, model)
92
+
93
+ parts: list[str] = []
94
+ ok_pages = 0
95
+ failed_pages = 0
96
+ truncated_pages = 0
97
+ with sanitized_httpx_proxy_env():
98
+ client = OpenAI(
99
+ api_key=api_key,
100
+ base_url=compat_url,
101
+ timeout=get_timeout_s("DASHSCOPE_TIMEOUT_S", 300.0),
102
+ max_retries=_ocr_max_retries(),
103
+ )
104
+ for idx, path in enumerate(image_paths, 1):
105
+ content = [
106
+ {"type": "image_url", "image_url": {"url": _encode_image(path)}},
107
+ {"type": "text", "text": VL_OCR_PAGE_PROMPT},
108
+ ]
109
+ try:
110
+ resp = client.chat.completions.create(
111
+ model=model,
112
+ messages=[{"role": "user", "content": content}],
113
+ temperature=0.0,
114
+ )
115
+ choice = resp.choices[0]
116
+ page_text = (choice.message.content or "").strip()
117
+ truncated = choice.finish_reason == "length"
118
+ except Exception as e: # noqa: BLE001 - 单页失败不能拖垮整份;全失败才回退 MinerU
119
+ logger.warning("[vl-ocr] page %s/%s failed after retries: %s", idx, total, e)
120
+ failed_pages += 1
121
+ parts.append(f"## 第 {idx} 页\n\n{_MARK_FAILED}")
122
+ continue
123
+
124
+ if truncated:
125
+ # qwen-vl-ocr 单页输出硬上限 8192 token,超了会被静默截断。
126
+ # 保留已得内容(残页也有价值),但显式标记,避免下游把残页当完整页。
127
+ truncated_pages += 1
128
+ logger.warning(
129
+ "[vl-ocr] page %s/%s truncated at output cap (maxOutputTokens=8192)",
130
+ idx,
131
+ total,
132
+ )
133
+ page_text = f"{page_text}\n\n{_MARK_TRUNCATED}".strip()
134
+
135
+ if page_text:
136
+ ok_pages += 1
137
+ parts.append(f"## 第 {idx} 页\n\n{page_text}")
138
+ else:
139
+ parts.append(f"## 第 {idx} 页\n\n{_MARK_ILLEGIBLE}")
140
+
141
+ if ok_pages == 0:
142
+ logger.warning(
143
+ "[vl-ocr] no usable page (%s failed / %s total); caller will fall back",
144
+ failed_pages,
145
+ total,
146
+ )
147
+ return None
148
+ logger.info(
149
+ "[vl-ocr] done: %s/%s ok, %s failed, %s truncated",
150
+ ok_pages,
151
+ total,
152
+ failed_pages,
153
+ truncated_pages,
154
+ )
155
+ return "\n\n".join(parts).strip() or None
156
+
157
+
158
+ def _encode_image(path: Path) -> str:
159
+ data = base64.b64encode(path.read_bytes()).decode("ascii")
160
+ return f"data:image/png;base64,{data}"
@@ -0,0 +1,67 @@
1
+ from .document import (
2
+ PREVIEW_DIR,
3
+ FILE_EXTRACTION,
4
+ FILE_EXTRACTION_CONF,
5
+ FILE_LAYOUT,
6
+ FILE_MARKDOWN,
7
+ FILE_PIPELINE_META,
8
+ FILE_RAW_TEXT,
9
+ FILE_STRUCTURED,
10
+ DOC_TYPES,
11
+ BBox,
12
+ Completeness,
13
+ CompletenessIssue,
14
+ SubAgreement,
15
+ ContractExtraction,
16
+ DocumentExtraction,
17
+ ExtractedEntity,
18
+ ExtractionConfidence,
19
+ FieldConfidence,
20
+ LabeledAmount,
21
+ LabeledDate,
22
+ LabeledValue,
23
+ LayoutBlock,
24
+ ObligationItem,
25
+ PersonIdentity,
26
+ Seal,
27
+ PipelineMeta,
28
+ PipelineOutput,
29
+ Section,
30
+ StructuredDocument,
31
+ Table,
32
+ TableCell,
33
+ )
34
+
35
+ __all__ = [
36
+ "BBox",
37
+ "LayoutBlock",
38
+ "Section",
39
+ "Table",
40
+ "TableCell",
41
+ "ExtractedEntity",
42
+ "StructuredDocument",
43
+ "PipelineMeta",
44
+ "PipelineOutput",
45
+ "ContractExtraction",
46
+ "DocumentExtraction",
47
+ "Completeness",
48
+ "CompletenessIssue",
49
+ "SubAgreement",
50
+ "LabeledValue",
51
+ "LabeledAmount",
52
+ "LabeledDate",
53
+ "PersonIdentity",
54
+ "Seal",
55
+ "DOC_TYPES",
56
+ "ObligationItem",
57
+ "FieldConfidence",
58
+ "ExtractionConfidence",
59
+ "FILE_RAW_TEXT",
60
+ "FILE_MARKDOWN",
61
+ "FILE_STRUCTURED",
62
+ "FILE_LAYOUT",
63
+ "FILE_PIPELINE_META",
64
+ "FILE_EXTRACTION",
65
+ "FILE_EXTRACTION_CONF",
66
+ "PREVIEW_DIR",
67
+ ]
@@ -0,0 +1,408 @@
1
+ """
2
+ 统一文档 schema:所有 OCR pipeline 必须把自己的输出归一化到这里。
3
+ Schema 的稳定性是这个项目的命脉——后续 compare.py 完全依赖它。
4
+
5
+ 设计原则(Linus 的"好品味"):
6
+ - 字段尽量扁平、不嵌过深
7
+ - 缺失字段统一用 None / 空列表,绝不报错
8
+ - bbox 坐标统一规约为 PDF 原始 point (1pt = 1/72inch),render dpi 不影响 schema
9
+ - 多 pipeline 通过 pipeline_name 字段区分来源
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime
14
+ from typing import Literal, Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from ..errors import ErrorInfo
19
+
20
+ # -------- 共用基本块 --------
21
+
22
+
23
+ class BBox(BaseModel):
24
+ """
25
+ 版面坐标框。坐标系:PDF 原始坐标系 (point),原点左上角,y 向下。
26
+ 所有 pipeline 在归一化时必须做坐标换算 (px → pt),确保 layout.json 可跨 pipeline 比较。
27
+ """
28
+
29
+ page: int = Field(..., description="0-based 页码")
30
+ x0: float
31
+ y0: float
32
+ x1: float
33
+ y1: float
34
+
35
+ @property
36
+ def width(self) -> float:
37
+ return self.x1 - self.x0
38
+
39
+ @property
40
+ def height(self) -> float:
41
+ return self.y1 - self.y0
42
+
43
+
44
+ class LayoutBlock(BaseModel):
45
+ """单个版面块——layout.json 的元素。"""
46
+
47
+ bbox: BBox
48
+ text: str = ""
49
+ block_type: Literal[
50
+ "title",
51
+ "paragraph",
52
+ "table",
53
+ "figure",
54
+ "header",
55
+ "footer",
56
+ "list",
57
+ "formula",
58
+ "stamp",
59
+ "signature",
60
+ "other",
61
+ ] = "other"
62
+ confidence: Optional[float] = None
63
+ reading_order: Optional[int] = None
64
+
65
+
66
+ # -------- structured.json --------
67
+
68
+
69
+ class TableCell(BaseModel):
70
+ row: int
71
+ col: int
72
+ rowspan: int = 1
73
+ colspan: int = 1
74
+ text: str = ""
75
+
76
+
77
+ class Table(BaseModel):
78
+ """归一化表格结构。同时保留 HTML 与 cell 矩阵,方便不同评估方式。"""
79
+
80
+ page: int
81
+ bbox: Optional[BBox] = None
82
+ html: Optional[str] = None # PP-StructureV3/MinerU 都能直接给
83
+ cells: list[TableCell] = Field(default_factory=list)
84
+ n_rows: int = 0
85
+ n_cols: int = 0
86
+ caption: Optional[str] = None
87
+
88
+
89
+ class Section(BaseModel):
90
+ level: int = 1 # 1=H1, 2=H2 ...
91
+ title: str
92
+ text: str = ""
93
+ page_start: int
94
+ page_end: int
95
+
96
+
97
+ class ExtractedEntity(BaseModel):
98
+ """structured.json 里的通用 entity。专用合同字段走 extraction_result.json。"""
99
+
100
+ entity_type: str # "person" / "org" / "money" / "date" / "address" ...
101
+ text: str
102
+ page: Optional[int] = None
103
+ bbox: Optional[BBox] = None
104
+ confidence: Optional[float] = None
105
+
106
+
107
+ class StructuredDocument(BaseModel):
108
+ """structured.json 主体。"""
109
+
110
+ title: Optional[str] = None
111
+ document_type: Optional[str] = None # "contract" / "invoice" / "report" / ...
112
+ language: str = "zh"
113
+ pages: int = 0
114
+ sections: list[Section] = Field(default_factory=list)
115
+ tables: list[Table] = Field(default_factory=list)
116
+ extracted_entities: list[ExtractedEntity] = Field(default_factory=list)
117
+
118
+
119
+ # -------- 顶层 pipeline 输出 --------
120
+
121
+
122
+ class PipelineMeta(BaseModel):
123
+ pipeline_name: Literal["mineru"]
124
+ pipeline_version: str = ""
125
+ model: str = ""
126
+ device: str = "cpu"
127
+ source_pdf: str
128
+ started_at: datetime
129
+ finished_at: datetime
130
+ duration_seconds: float
131
+ notes: str = ""
132
+
133
+
134
+ class PipelineOutput(BaseModel):
135
+ """
136
+ 一个 pipeline 跑完一份 PDF 的全部产物(结构化部分)。
137
+ raw_text.txt / markdown.md / preview_images/ 直接写文件,
138
+ layout.json / structured.json / pipeline_meta.json 也直接落盘。
139
+ 本对象用于 in-memory 传递和单元测试。
140
+ """
141
+
142
+ meta: PipelineMeta
143
+ raw_text: str = ""
144
+ markdown: str = ""
145
+ layout: list[LayoutBlock] = Field(default_factory=list)
146
+ structured: StructuredDocument = Field(default_factory=StructuredDocument)
147
+ preview_image_paths: list[str] = Field(default_factory=list)
148
+
149
+
150
+ # -------- Semantic Extraction --------
151
+
152
+
153
+ class ObligationItem(BaseModel):
154
+ """
155
+ 合同义务/动作条款。
156
+ 与 risk_clauses 区别:
157
+ - obligation = "X 方应该做什么"(动作 + 截止)
158
+ - risk_clause = "违约后果"(罚则/赔偿/解除条件)
159
+ """
160
+
161
+ actor: Literal["party_a", "party_b", "both"]
162
+ action: str # "递交审贷资料"
163
+ deadline: Optional[str] = None # ISO 'YYYY-MM-DD' 或 None
164
+ evidence: str = "" # 原文片段
165
+
166
+
167
+ class ContractExtraction(BaseModel):
168
+ """合同语义抽取的统一 schema。所有字段都允许 None(抽不到比硬塞更诚实)。"""
169
+
170
+ contract_name: Optional[str] = None
171
+ party_a: Optional[str] = None # 甲方
172
+ party_b: Optional[str] = None # 乙方
173
+ amount: Optional[str] = None # 保留原文(含币种),不强制转 float
174
+ amount_value: Optional[float] = None # 解析后的数值(人民币元)
175
+ sign_date: Optional[str] = None # 签订日期 ISO 8601
176
+ expire_date: Optional[str] = None # 到期/失效日期 ISO 8601
177
+ auto_renewal: Optional[bool] = None
178
+ risk_clauses: list[str] = Field(default_factory=list)
179
+ obligations: list[ObligationItem] = Field(default_factory=list)
180
+ raw_evidence: dict[str, str] = Field(
181
+ default_factory=dict,
182
+ description="字段→原文证据片段,用于人工抽检",
183
+ )
184
+
185
+
186
+ # -------- 通用文档抽取(LLM-first,跨类型) --------
187
+ #
188
+ # 设计(贴合"LLM-first、少死代码"):不为每种文档类型写死 pydantic 字段表,
189
+ # 而是一个通用信封——可查询的公共核心 + 柔性键值/金额/日期列表。
190
+ # 加新文档类型 = 零代码:LLM 自行决定 fields/amounts/key_dates 抽哪些。
191
+
192
+
193
+ class LabeledValue(BaseModel):
194
+ """类型专属字段的通用键值对。"""
195
+
196
+ label: str # "持证人" / "职位" / "身份证号" / "发票号" ...
197
+ value: str # 原文值(统一用字符串承载;数值/日期另见 amounts/key_dates)
198
+
199
+
200
+ class PersonIdentity(BaseModel):
201
+ """
202
+ 单个主体(自然人/机构)精确绑定的固有标识。
203
+
204
+ 与扁平 fields 的区别:fields 是文档级零散键值,常把多人混在一条里
205
+ (如"乙方身份证号: 330106…;420302…"分不清哪个号属于谁);这里把每个标识
206
+ 精确绑定到具体的人/机构,是跨文档身份核对(known_parties 基准库)的基础——
207
+ 同一主体的身份证号/电话/银行账号本应稳定,OCR 读错或被改动即可比对告警。
208
+ """
209
+
210
+ name: str # 主体名(须与 parties 中某项对应)
211
+ role: Optional[str] = None # 本文档中的角色:甲方/乙方/买受人/持证人 等
212
+ # 该主体的固有标识键值:身份证号/电话/银行账号/开户行/统一社会信用代码 等。
213
+ # 复用 LabeledValue(label=标识名,value=标识值)。
214
+ identifiers: list[LabeledValue] = Field(default_factory=list)
215
+
216
+
217
+ class LabeledAmount(BaseModel):
218
+ """带标签的金额。"""
219
+
220
+ label: str # "年收入" / "月均收入" / "公积金(个人)" / "合同金额"
221
+ text: str # 原文(含大写/币种)
222
+ value: Optional[float] = None # 归一化数值(人民币元;unit 非空时为该单位下的单价数值)
223
+ # 计量单位:None=绝对金额(人民币元,默认,与历史一致);非 None=单价/费率,
224
+ # value 是「每单位」的数值,量纲见此字段(如"元/月·㎡""元/个/月""元/日")。
225
+ # 用于区分"合同总价 1228 万元"(unit=None) 与"物业费 2.25 元/月·㎡"(unit 非空)——
226
+ # 后者量纲不同,不可与绝对金额相加,也不参与 computed_total / 金额自洽校验。
227
+ # 同 unit 的周期单价可由代码派生周期费用(如物业费 = Σ按㎡单价 × 建筑面积)。
228
+ unit: Optional[str] = None
229
+ # 是否计入文档主合计:收入证明的"年度税前收入""年度股权收益"=True;
230
+ # "月均收入""公积金"等不该重复累加的=False。供 computed_total_value 求和。
231
+ # 单价项(unit 非空)一律 False——单价不是合同总价的组成部分。
232
+ is_total_component: bool = False
233
+ # 是否为某总价的"分期/部分付款"项(首期款/余款/定金/尾款)。供金额自洽校验:
234
+ # 同一总价的各分期项之和应≈总价(合计),不符即疑似金额笔误
235
+ # (如车位首期误填 500000 > 总价 200000)。一次性付款、单价(元/月)等非分期项=False。
236
+ is_installment: bool = False
237
+ # 该金额覆盖的时间区间(ISO)。如"上年度""近12个月"由 LLM 据出具日解析为具体起止。
238
+ period_start: Optional[str] = None
239
+ period_end: Optional[str] = None
240
+ # 出处定位:页码 + 原文片段,让人能翻回原文核对这笔金额从哪来(与签章缺陷出处同一原则)。
241
+ evidence: str = ""
242
+
243
+
244
+ class LabeledDate(BaseModel):
245
+ """带标签的日期。"""
246
+
247
+ label: str # "出具日" / "签订日" / "到期日" / "入职日"
248
+ date: Optional[str] = None # ISO 8601 'YYYY-MM-DD'
249
+
250
+
251
+ class Seal(BaseModel):
252
+ """
253
+ 印章(红章)。来源是 MinerU 检测+OCR 的盖章文字,质量参差:
254
+ 清晰的能给出主体+章类型("XX有限公司 合同专用章"),残缺的可能只剩单字。
255
+ 不强求拆出编号——OCR 残字硬塞编号是幻觉,宁可只留 raw_text。
256
+ """
257
+
258
+ raw_text: str # 印章 OCR 原文(可能残缺/乱序),可追溯
259
+ owner: Optional[str] = None # 盖章主体(公司/机构全称),认不出留 None
260
+ seal_type: Optional[str] = None # "公章" / "合同专用章" / "财务专用章" / "发票专用章" ...
261
+
262
+
263
+ class CompletenessIssue(BaseModel):
264
+ """单条完整性缺陷(缺签章 / 缺要素)。"""
265
+
266
+ item: str # 缺失/异常要素,如"甲方签章""签订日期""转让价款"
267
+ # signature=签章类,field=要素缺失类,amount=金额自洽异常类(如分期之和≠总价,代码确定性判出),
268
+ # identity=主体固有标识与基准不符(known_parties 跨文档核对,如身份证号被 OCR 读错/被改)
269
+ category: Literal["signature", "field", "amount", "identity"] = "field"
270
+ detail: str = "" # 缺什么/异常什么(简述),如"落款处空白无章"
271
+ # 出处定位:页码 + 原文片段(签章类带落款页码),让人能翻回原文核对。
272
+ # 审计性结论的底线——不可追溯的缺陷不合格,宁可不报。
273
+ evidence: str = ""
274
+
275
+
276
+ class Completeness(BaseModel):
277
+ """
278
+ 合同完整性核查(仅合同协议适用,LLM 判定)。
279
+
280
+ 两类缺陷:
281
+ - signature:落款区应盖章/签字的主体空着(用户最初的痛点:甲方未签章)
282
+ - field:该合同类型应具备的要素缺失(双方/标的/价款/签订日 等)
283
+
284
+ 两条诚实底线:
285
+ 1. 红章 OCR 不可靠——检测不到章可能是真没盖,也可能是淡红/模糊没被识别。
286
+ 故缺章一律表述为"疑似"、供人工复核,不作为终判。
287
+ 2. 必填要素由 LLM 据合同类型自行判断(车位转让无到期日、框架协议无具体金额
288
+ 都属正常),不套死清单——把"本就不该有"的当缺失是误报之源。
289
+ """
290
+
291
+ status: Literal["complete", "incomplete", "unknown"] = "unknown"
292
+ issues: list[CompletenessIssue] = Field(default_factory=list)
293
+
294
+
295
+ class SubAgreement(BaseModel):
296
+ """
297
+ 文档内的附属协议——主协议之后所附的《补充协议》等。
298
+
299
+ 依附主协议(修改/补充原协议条款),但常有自己独立的签章落款区与生效条件
300
+ (如"自甲方加盖公章、乙方签字之日生效"),故单列:既体现"这份 PDF 其实含 N 份
301
+ 协议",又让 completeness 能逐个协议单元分别核查签章(主协议缺章 ≠ 补充协议缺章)。
302
+ 依附主协议故不单列 amounts/obligations——关键变更写进 summary,可追溯片段进 evidence。
303
+ """
304
+
305
+ title: str # 协议标题,如 "补充协议" / "补充协议(二)"
306
+ summary: str = "" # 这份补充协议改了/补充了什么(关键变更,便于检索回忆)
307
+ sign_date: Optional[str] = None # 该补充协议自己的签订/生效日 ISO(可能空白→None)
308
+ seals: list[Seal] = Field(default_factory=list) # 补充协议落款上的印章(供完整性核查)
309
+ evidence: str = "" # 原文关键片段,可追溯
310
+
311
+
312
+ # 粗粒度规范类型(用于 --type 过滤)。LLM 从中择一,更细的归类放进 title/fields。
313
+ DOC_TYPES = ("合同协议", "保险凭证", "旅行资料", "证明", "发票票据", "报告", "证件", "其他")
314
+ DocType = str # 存库用 str(保持柔性,不上 Literal 以免 LLM 新类型被卡死)
315
+
316
+
317
+ class DocumentExtraction(BaseModel):
318
+ """
319
+ 通用文档抽取信封:任何文档类型都归一化到这里(LLM-first)。
320
+
321
+ 公共核心(doc_type/title/summary/primary_*/parties)落 documents 表列、可查询;
322
+ 柔性 fields/amounts/key_dates/obligations 整体存 details_json,承载类型专属信息。
323
+ 所有字段允许空——抽不到比硬塞更诚实。
324
+ """
325
+
326
+ doc_type: str = "其他" # 规范类型,取自 DOC_TYPES
327
+ title: Optional[str] = None # 文档标题/抬头
328
+ summary: Optional[str] = None # 一句话摘要(可追溯的关键钩子)
329
+ parties: list[str] = Field(default_factory=list) # 涉及主体(人/机构全称)
330
+ primary_date: Optional[str] = None # 主日期 ISO(合同=签订日,证明=出具日)
331
+ primary_amount_text: Optional[str] = None
332
+ primary_amount_value: Optional[float] = None
333
+ # 计算值(非抽取):amounts 中 is_total_component=True 项之和。
334
+ # 例:收入证明 = 年度税前收入 + 年度股权收益。无可累加项则为 None。
335
+ computed_total_value: Optional[float] = None
336
+ # 派生值(非抽取,代码确定性算):每月物业费估算
337
+ # = Σ(按建筑面积计价的物业类单价,元/月·㎡) × 建筑面积。
338
+ # 合同只给单价(物业服务费 2.25 + 服务费 4.55 + 能耗费 0.8 元/月·㎡),
339
+ # 买受人关心的是月实付额——由代码乘算(按㎡项才并入,车位"元/个/月"等不同量纲不并)。
340
+ # 抽不到单价或建筑面积则为 None。_text 是可追溯的算式说明。
341
+ monthly_property_fee_value: Optional[float] = None
342
+ monthly_property_fee_text: Optional[str] = None
343
+ key_dates: list[LabeledDate] = Field(default_factory=list)
344
+ amounts: list[LabeledAmount] = Field(default_factory=list)
345
+ seals: list[Seal] = Field(default_factory=list) # 文档上的印章(有则可验真/索引)
346
+ fields: list[LabeledValue] = Field(default_factory=list)
347
+ # 精确绑定到人的固有标识(身份证/电话/银行账号…)。与扁平 fields 互补:
348
+ # fields 易把多人号码混在一条,这里按人拆开,供 known_parties 基准库逐人核对。
349
+ person_identities: list[PersonIdentity] = Field(default_factory=list)
350
+ obligations: list[ObligationItem] = Field(default_factory=list)
351
+ # 附属协议(主协议之外的《补充协议》等)。一份 PDF 可能含主协议 + N 份补充协议,
352
+ # 每份有独立签章落款;completeness 会逐个协议单元核查。无则空列表。
353
+ sub_agreements: list[SubAgreement] = Field(default_factory=list)
354
+ # 完整性核查:仅合同协议填,其他类型 None
355
+ # ("该不该有甲乙签章/要素齐不齐"对证明/发票无意义,强判只会制造噪声)。
356
+ completeness: Optional[Completeness] = None
357
+ # 身份基本信息核对结果(跨文档类型,不限合同):person_identities 与 known_parties
358
+ # 基准库比对的不一致项(category="identity")。首见入库不产生 issue,再见冲突才报。
359
+ # 独立于 completeness(后者专司合同签章/要素),避免污染其"仅合同适用"的语义。
360
+ identity_issues: list[CompletenessIssue] = Field(default_factory=list)
361
+ raw_evidence: dict[str, str] = Field(
362
+ default_factory=dict, description="字段→原文证据片段,用于人工抽检"
363
+ )
364
+ # 抽取元数据(非文档内容):本次实际调用的 LLM 模型名(如 qwen3.7-max)。
365
+ # 随 extraction_result.json 留存,并镜像到 documents.llm_model,供 show 追溯抽取来源。
366
+ # 仅成功调用 LLM 时填;--no-llm / 无 key / 调用失败为 None。
367
+ llm_model: Optional[str] = None
368
+ # 抽取元数据(非文档内容):本次 LLM 调用的 token 用量(input/output/total_tokens)。
369
+ # 来源 DashScope resp["usage"];供评测算成本、生产侧成本追踪。读不到 / 未调用为 None。
370
+ llm_usage: Optional[dict] = None
371
+ # 抽取元数据(非文档内容):本次抽取失败的结构化错误,含 retryable 供 Agent 判重试。
372
+ # 成功 / --no-llm 为 None。随 extraction_result.json 留存,并由 ingest 读出填 IngestResult.error。
373
+ extraction_error: Optional[ErrorInfo] = None
374
+
375
+
376
+ class FieldConfidence(BaseModel):
377
+ """单字段置信度。"""
378
+
379
+ value_source: Literal["rule", "llm", "merged", "missing"] = "missing"
380
+ confidence: float = 0.0 # [0, 1]
381
+ rule_hit: bool = False
382
+ llm_agreed: Optional[bool] = None # None = 未交叉验证
383
+
384
+
385
+ class ExtractionConfidence(BaseModel):
386
+ """extraction_confidence.json 主体。逐字段给出置信度。"""
387
+
388
+ contract_name: FieldConfidence = Field(default_factory=FieldConfidence)
389
+ party_a: FieldConfidence = Field(default_factory=FieldConfidence)
390
+ party_b: FieldConfidence = Field(default_factory=FieldConfidence)
391
+ amount: FieldConfidence = Field(default_factory=FieldConfidence)
392
+ sign_date: FieldConfidence = Field(default_factory=FieldConfidence)
393
+ expire_date: FieldConfidence = Field(default_factory=FieldConfidence)
394
+ auto_renewal: FieldConfidence = Field(default_factory=FieldConfidence)
395
+ risk_clauses: FieldConfidence = Field(default_factory=FieldConfidence)
396
+ overall: float = 0.0
397
+
398
+
399
+ # -------- 文件名常量 --------
400
+
401
+ FILE_RAW_TEXT = "raw_text.txt"
402
+ FILE_MARKDOWN = "markdown.md"
403
+ FILE_STRUCTURED = "structured.json"
404
+ FILE_LAYOUT = "layout.json"
405
+ FILE_PIPELINE_META = "pipeline_meta.json"
406
+ FILE_EXTRACTION = "extraction_result.json"
407
+ FILE_EXTRACTION_CONF = "extraction_confidence.json"
408
+ PREVIEW_DIR = "preview_images"
@@ -0,0 +1,27 @@
1
+ from .device import Device, describe_device, select_device
2
+ from .pdf import (
3
+ PageImage,
4
+ PdfPageInfo,
5
+ TextLayerStats,
6
+ analyze_text_layer,
7
+ extract_text_layer,
8
+ inspect_pdf_pages,
9
+ is_scanned_pdf,
10
+ is_text_layer_usable,
11
+ render_pdf_to_images,
12
+ )
13
+
14
+ __all__ = [
15
+ "Device",
16
+ "select_device",
17
+ "describe_device",
18
+ "PageImage",
19
+ "PdfPageInfo",
20
+ "TextLayerStats",
21
+ "inspect_pdf_pages",
22
+ "render_pdf_to_images",
23
+ "extract_text_layer",
24
+ "analyze_text_layer",
25
+ "is_text_layer_usable",
26
+ "is_scanned_pdf",
27
+ ]