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,62 @@
|
|
|
1
|
+
-- 档案库初始 schema (version 1)
|
|
2
|
+
--
|
|
3
|
+
-- 设计要点(来自多轮 review 仲裁):
|
|
4
|
+
-- 1. status / severity 不加 CHECK constraint:未来加状态要重建表(SQLite 限制),
|
|
5
|
+
-- 校验下沉到 Pydantic 层更灵活
|
|
6
|
+
-- 2. amount 用 INTEGER 分(amount_cents),避免 REAL 累加精度漂移;
|
|
7
|
+
-- 同时保留 amount_text 原文供人工核对
|
|
8
|
+
-- 3. 不用 FTS5:典型档案库规模千级,LIKE '%关键词%' 全表扫毫秒级;
|
|
9
|
+
-- trigram 要求 ≥3 字符匹配,2 字中文人名/词("车位"/"张三")会全部 miss,
|
|
10
|
+
-- unicode61 单字切分对中文精度太差。务实选择 LIKE。
|
|
11
|
+
-- 4. ingested_at 加 DESC 索引:list 命令默认排序走索引
|
|
12
|
+
-- 5. AUTOINCREMENT 保留:防止 delete 后 rowid 复用指向新合同
|
|
13
|
+
|
|
14
|
+
CREATE TABLE schema_version (
|
|
15
|
+
version INTEGER PRIMARY KEY,
|
|
16
|
+
applied_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE documents (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
sha256 TEXT NOT NULL UNIQUE,
|
|
22
|
+
source_path TEXT NOT NULL, -- 原始 PDF 绝对路径(用户引用)
|
|
23
|
+
output_dir TEXT NOT NULL, -- archive/documents/<sha-short>/ 绝对路径
|
|
24
|
+
ingested_at TEXT NOT NULL, -- ISO8601 UTC 'YYYY-MM-DDTHH:MM:SSZ'
|
|
25
|
+
mineru_duration_s REAL,
|
|
26
|
+
llm_duration_s REAL,
|
|
27
|
+
status TEXT NOT NULL, -- 'ok' | 'partial' | 'failed'
|
|
28
|
+
error_message TEXT,
|
|
29
|
+
|
|
30
|
+
-- 合同字段(rule + LLM hybrid 抽取结果)
|
|
31
|
+
contract_name TEXT,
|
|
32
|
+
party_a TEXT,
|
|
33
|
+
party_b TEXT,
|
|
34
|
+
amount_text TEXT, -- "人民币壹佰万元整",原文
|
|
35
|
+
amount_cents INTEGER, -- 1000000 * 100 = 100000000,精确分
|
|
36
|
+
sign_date TEXT, -- ISO 'YYYY-MM-DD'
|
|
37
|
+
expire_date TEXT,
|
|
38
|
+
auto_renewal INTEGER, -- 0/1/NULL
|
|
39
|
+
overall_confidence REAL -- [0, 1]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE INDEX idx_doc_party_a ON documents(party_a);
|
|
43
|
+
CREATE INDEX idx_doc_party_b ON documents(party_b);
|
|
44
|
+
CREATE INDEX idx_doc_sign_date ON documents(sign_date);
|
|
45
|
+
CREATE INDEX idx_doc_expire ON documents(expire_date);
|
|
46
|
+
CREATE INDEX idx_doc_amount ON documents(amount_cents);
|
|
47
|
+
CREATE INDEX idx_doc_status ON documents(status);
|
|
48
|
+
CREATE INDEX idx_doc_ingested ON documents(ingested_at DESC);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE risk_clauses (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
53
|
+
clause_text TEXT NOT NULL,
|
|
54
|
+
severity TEXT -- 'low' | 'med' | 'high' | NULL
|
|
55
|
+
);
|
|
56
|
+
CREATE INDEX idx_risk_doc ON risk_clauses(doc_id);
|
|
57
|
+
|
|
58
|
+
-- name / party 字符串字段加索引,加速 LIKE '%xxx%' 之外的等值/前缀查询。
|
|
59
|
+
-- LIKE '%xxx%' 本身无法走 B-tree 索引(前置通配),但合同档案库规模小,
|
|
60
|
+
-- 全表扫描完全可接受(1 万条 < 10ms)。
|
|
61
|
+
|
|
62
|
+
INSERT INTO schema_version(version, applied_at) VALUES(1, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- 档案库 schema v2:合同双方义务/动作清单
|
|
2
|
+
--
|
|
3
|
+
-- 设计要点:
|
|
4
|
+
-- 1. 独立表:典型合同有 5-15 条义务,每条带 actor + deadline,独立表才能走
|
|
5
|
+
-- `WHERE deadline < ?` 索引做"近 30 天待办看板"
|
|
6
|
+
-- 2. 不加 actor/severity 的 CHECK constraint(v1 同样的理由:未来加新值
|
|
7
|
+
-- 免重建表,Pydantic 层校验更灵活)
|
|
8
|
+
-- 3. ordering 列保留原文出现顺序,show 命令展示时按顺序好读
|
|
9
|
+
|
|
10
|
+
CREATE TABLE obligations (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
13
|
+
actor TEXT NOT NULL, -- 'party_a' | 'party_b' | 'both'
|
|
14
|
+
action TEXT NOT NULL, -- "递交审贷资料"
|
|
15
|
+
deadline TEXT, -- ISO 'YYYY-MM-DD' 或 NULL
|
|
16
|
+
evidence TEXT, -- 原文片段
|
|
17
|
+
ordering INTEGER NOT NULL DEFAULT 0
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX idx_obligations_doc ON obligations(doc_id);
|
|
21
|
+
CREATE INDEX idx_obligations_deadline ON obligations(deadline);
|
|
22
|
+
CREATE INDEX idx_obligations_actor ON obligations(actor);
|
|
23
|
+
|
|
24
|
+
INSERT INTO schema_version(version, applied_at)
|
|
25
|
+
VALUES(2, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- 档案库 schema v3:多文档类型支持(合同 → 通用文档档案库)
|
|
2
|
+
--
|
|
3
|
+
-- 设计要点:
|
|
4
|
+
-- 1. 加法式迁移——保留全部合同列(party_a/b、amount_*、sign/expire_date、
|
|
5
|
+
-- auto_renewal、risk_clauses、obligations 不动),合同的查询/统计照常工作。
|
|
6
|
+
-- 2. 新增"通用信封"列:任何文档类型都填这几列,list/show/搜索走通用列即可
|
|
7
|
+
-- 跨类型统一展示,类型专属字段整体存 details_json。
|
|
8
|
+
-- 3. doc_type 不加 CHECK constraint(沿用 v1/v2 理由:未来加类型免重建表,
|
|
9
|
+
-- 校验下沉到 Pydantic/LLM 层)。规范取值见 schemas.DOC_TYPES。
|
|
10
|
+
-- 4. 回填:现存行都是历史合同,把合同字段映射到通用列,保证老数据在新
|
|
11
|
+
-- list/show 里不空白。
|
|
12
|
+
|
|
13
|
+
ALTER TABLE documents ADD COLUMN doc_type TEXT NOT NULL DEFAULT '合同协议';
|
|
14
|
+
ALTER TABLE documents ADD COLUMN title TEXT; -- 通用标题(合同名/证明抬头/发票号…)
|
|
15
|
+
ALTER TABLE documents ADD COLUMN summary TEXT; -- 一句话摘要(可追溯钩子)
|
|
16
|
+
ALTER TABLE documents ADD COLUMN details_json TEXT; -- 类型专属字段(parties/amounts/fields/key_dates)整体 JSON
|
|
17
|
+
ALTER TABLE documents ADD COLUMN primary_date TEXT; -- 主日期 ISO(合同=签订日,证明=出具日)
|
|
18
|
+
ALTER TABLE documents ADD COLUMN primary_amount_cents INTEGER; -- 主金额(分),跨类型金额排序/过滤
|
|
19
|
+
|
|
20
|
+
-- 回填历史合同行 → 通用列
|
|
21
|
+
UPDATE documents SET title = contract_name WHERE title IS NULL;
|
|
22
|
+
UPDATE documents SET summary = contract_name WHERE summary IS NULL AND contract_name IS NOT NULL;
|
|
23
|
+
UPDATE documents SET primary_date = sign_date WHERE primary_date IS NULL;
|
|
24
|
+
UPDATE documents SET primary_amount_cents = amount_cents WHERE primary_amount_cents IS NULL;
|
|
25
|
+
|
|
26
|
+
CREATE INDEX idx_doc_type ON documents(doc_type);
|
|
27
|
+
CREATE INDEX idx_doc_primary_date ON documents(primary_date);
|
|
28
|
+
CREATE INDEX idx_doc_primary_amt ON documents(primary_amount_cents);
|
|
29
|
+
|
|
30
|
+
INSERT INTO schema_version(version, applied_at)
|
|
31
|
+
VALUES(3, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- 档案库 schema v4:印章(seals) + 主体(subjects) 可索引子表
|
|
2
|
+
--
|
|
3
|
+
-- 设计要点(沿用 002/003 的既定取向):
|
|
4
|
+
-- 1. 一类一表:印章和主体各自独立子表,承载各自的可索引列。这是项目既有风格
|
|
5
|
+
-- (risk_clauses / obligations 同构),让数据结构贴合查询,不上泛化 entities 表。
|
|
6
|
+
-- 2. 双存合理冗余:seals/subjects 既进 details_json(envelope 整体 dump,供展示),
|
|
7
|
+
-- 又进子表(供 EXISTS 过滤 / 聚合)。写库三处(insert/update/replace)先 DELETE
|
|
8
|
+
-- 再批量 INSERT 保证一致——和 obligations/risk_clauses 完全一致。
|
|
9
|
+
-- 3. ON DELETE CASCADE:删主表行时子表自动清,依赖 connect() 里的 PRAGMA foreign_keys=ON。
|
|
10
|
+
-- 4. subjects 来源是信封 parties(合同另并入 party_a/b),让"按主体检索"覆盖所有文档类型,
|
|
11
|
+
-- 补上"证明类主体此前搜不到"的缺口。
|
|
12
|
+
-- 5. 不加 CHECK constraint(沿用 v1-v3 理由:未来加值免重建表,校验下沉到 Pydantic/LLM)。
|
|
13
|
+
|
|
14
|
+
CREATE TABLE document_seals (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
17
|
+
owner TEXT, -- 盖章主体(公司/机构全称),认不出为 NULL
|
|
18
|
+
seal_type TEXT, -- "公章" / "合同专用章" / "财务专用章" ...
|
|
19
|
+
raw_text TEXT NOT NULL, -- 印章 OCR 原文(可能残缺),可追溯
|
|
20
|
+
ordering INTEGER NOT NULL DEFAULT 0
|
|
21
|
+
);
|
|
22
|
+
CREATE INDEX idx_seals_doc ON document_seals(doc_id);
|
|
23
|
+
CREATE INDEX idx_seals_owner ON document_seals(owner);
|
|
24
|
+
CREATE INDEX idx_seals_type ON document_seals(seal_type);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE document_subjects (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
29
|
+
subject TEXT NOT NULL, -- 主体名(人/机构全称)
|
|
30
|
+
ordering INTEGER NOT NULL DEFAULT 0
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX idx_subjects_doc ON document_subjects(doc_id);
|
|
33
|
+
CREATE INDEX idx_subjects_name ON document_subjects(subject);
|
|
34
|
+
|
|
35
|
+
INSERT INTO schema_version(version, applied_at)
|
|
36
|
+
VALUES(4, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- 档案库 schema v5:合同完整性核查状态(可索引)
|
|
2
|
+
--
|
|
3
|
+
-- 设计要点(沿用 003/004 的既定取向):
|
|
4
|
+
-- 1. 加法式:只新增一列,不动任何现有列/表,老数据照常工作。
|
|
5
|
+
-- 2. 双存合理冗余:完整性详情(status + issues)已随 envelope 整体进 details_json
|
|
6
|
+
-- (供 show 展示),这里只把 status 镜像成一列,让 `list --incomplete` 能走
|
|
7
|
+
-- WHERE 过滤——details_json 无法高效查询。和 003 的 primary_date 同构。
|
|
8
|
+
-- 3. 取值:NULL=未判定/非合同(老数据、证明发票等),'complete'|'incomplete'|'unknown'。
|
|
9
|
+
-- 不加 CHECK constraint(沿用 v1-v4 理由:未来加值免重建表,校验下沉到 Pydantic/LLM)。
|
|
10
|
+
-- 4. 加索引:--incomplete 是等值过滤,索引命中即可;档案库规模虽小,但这是查询
|
|
11
|
+
-- 入口列,加索引零代价(与 003 给 primary_date 加索引同理)。
|
|
12
|
+
|
|
13
|
+
ALTER TABLE documents ADD COLUMN completeness_status TEXT; -- NULL=未判定/非合同, 'complete'|'incomplete'|'unknown'
|
|
14
|
+
|
|
15
|
+
CREATE INDEX idx_doc_completeness ON documents(completeness_status);
|
|
16
|
+
|
|
17
|
+
INSERT INTO schema_version(version, applied_at)
|
|
18
|
+
VALUES(5, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
主体身份基准库(known_parties):跨文档核对主体的固有标识。
|
|
3
|
+
|
|
4
|
+
为什么独立于 db.sqlite:这是"基本信息基准"而非"文档档案"——它跨文档累积
|
|
5
|
+
(某主体的身份证号一次录入、之后每份文档都拿来核对),生命周期与单份文档解耦。
|
|
6
|
+
存档案库根目录 known_parties.json,含真实 PII,故文件权限 0600、列入 .gitignore。
|
|
7
|
+
|
|
8
|
+
核对模型(用户要的"首见入库、再见校对"):
|
|
9
|
+
- 某主体的某标识首次出现 → 录入为基准,记首见出处。
|
|
10
|
+
- 之后同主体同标识再出现 → 与基准比对,不一致即报 identity 缺陷(不覆盖基准,
|
|
11
|
+
基准保持稳定;要修正基准用 `party set`)。
|
|
12
|
+
- 不分自然人/机构:身份证、电话、银行账号、开户行、税号一律核对。
|
|
13
|
+
|
|
14
|
+
归一化:比较时去除空白与常见分隔符(OCR 把";"读成":"、夹空格等不算差异),
|
|
15
|
+
但保留真实数字差异(多一位/少一位/改一位)——后者正是要抓的 OCR 读错/篡改。
|
|
16
|
+
|
|
17
|
+
实体对齐(key 不是字面 name,而是"实体"):同一实体在不同文档/同一文档内常被
|
|
18
|
+
识别成不同名字(LLM 幻觉改字、称谓差异、OCR 误读),若按字面 name 作 key 就会
|
|
19
|
+
分裂、跨文档核对不到一起。故归位规则:
|
|
20
|
+
- 主体名先规范化(剥离"甲方:/出卖人:"等分隔符门控的称谓前缀)。
|
|
21
|
+
- 强标识(身份证/银行账号/印章/统一社会信用代码/税号)实体唯一,同值必同实体——
|
|
22
|
+
本次某强标识值若已登记在另一 name 下,即归并到那个已有 key,并把本次 name 记入
|
|
23
|
+
别名表,今后即使只带弱标识也能归位。
|
|
24
|
+
- 弱标识(电话、开户行)多人共用(公司总机、银行支行),绝不据此合并,避免误并。
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import re
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
from ..schemas import CompletenessIssue, PersonIdentity
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
REGISTRY_VERSION = 2 # v2 起新增 aliases(实体归并别名表);v1 文件仍可读,缺则按空表
|
|
40
|
+
|
|
41
|
+
# 比较前剥离的噪声字符:空白 + 常见分隔/标点。不动数字、字母、汉字本身。
|
|
42
|
+
_NOISE_RE = re.compile(r"[\s;;,,、::.。\-—_//]")
|
|
43
|
+
|
|
44
|
+
# 称谓前缀:name 开头的"甲方/乙方/出卖人…"+ 分隔符,规范化时剥离,使
|
|
45
|
+
# "甲方:示例置业"与"示例置业"归到同一 key。仅当前缀后紧跟分隔符才剥离,
|
|
46
|
+
# 避免误伤"甲方物流有限公司"这类前缀恰是名字一部分的合法名。
|
|
47
|
+
_ROLE_PREFIXES = (
|
|
48
|
+
"甲方", "乙方", "丙方", "出卖人", "买受人", "出租方", "承租方",
|
|
49
|
+
"转让方", "受让方", "委托方", "受托方", "持证人", "卖方", "买方",
|
|
50
|
+
)
|
|
51
|
+
# 前缀后须接"分隔标点"或"一段空白"才剥离;二者皆无(如"甲方物流")则前缀属名字本身,不剥。
|
|
52
|
+
_ROLE_PREFIX_RE = re.compile(
|
|
53
|
+
r"^(?:" + "|".join(_ROLE_PREFIXES) + r")(?:\s*[::、|/\\\-]+\s*|\s+)"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# 强标识 label 关键字:这些标识实体唯一(同值必同实体),可据此把同实体的不同
|
|
57
|
+
# name 变体归并到一个 key。电话/开户行是弱标识(多人共用),故意不在此列。
|
|
58
|
+
_STRONG_LABEL_KEYS = ("身份证", "银行账", "印章", "信用代码", "税号")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _canon(value: str) -> str:
|
|
62
|
+
"""归一化用于比较:去首尾空白 + 剥离分隔/标点噪声。
|
|
63
|
+
|
|
64
|
+
使 OCR 分隔符差异(空格、";"读成":"等)不误报;但多一位/少一位/改一位
|
|
65
|
+
这类真实数字差异会保留下来——那正是要抓的 OCR 读错或信息被改。
|
|
66
|
+
"""
|
|
67
|
+
return _NOISE_RE.sub("", value.strip())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _canon_name(name: str) -> str:
|
|
71
|
+
"""主体名规范化作 registry key:去首尾空白 + 剥离分隔符门控的称谓前缀。"""
|
|
72
|
+
return _ROLE_PREFIX_RE.sub("", name.strip())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def group_by_value(ids: dict) -> list[tuple[str, dict]]:
|
|
76
|
+
"""把同一主体下『归一化值相同』的多个 label 折叠成一组,供人读展示去冗余。
|
|
77
|
+
|
|
78
|
+
同一个号被不同文档写成不同 label(如『电话』『联系电话』)时,在 party list/show
|
|
79
|
+
里并排堆着是纯噪声、零信息。canon 值相等即视为同一事实,标签并成『电话/联系电话』,
|
|
80
|
+
rec 取首个(即基准首见那条)。值不同的 label(如公司总机 vs 联系人线)各自独立、
|
|
81
|
+
绝不合并——与 reconcile『弱标识不据此并实体』同一立场:这里只折叠展示、不动数据。
|
|
82
|
+
|
|
83
|
+
判等用 reconcile 同一套 _canon,保证『展示折叠』与『一致性校对』口径一致:
|
|
84
|
+
凡 reconcile 视作"无差异"的两值,这里才折叠;有真实数字差异的不会被并掉。
|
|
85
|
+
保持各组首次出现的插入顺序。
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
ids: 某主体的标识基准,label -> rec(rec 含 value/first_seen_doc/role 等)。
|
|
89
|
+
Returns:
|
|
90
|
+
[(合并后标签, 首个rec), ...],按各组首次出现顺序排列。
|
|
91
|
+
"""
|
|
92
|
+
labels_of: dict[str, list[str]] = {} # canon(value) -> 同值的 label 列表
|
|
93
|
+
rep_rec: dict[str, dict] = {} # canon(value) -> 首个 rec(基准首见那条)
|
|
94
|
+
order: list[str] = [] # canon(value) 首次出现顺序,定输出顺序
|
|
95
|
+
for label, rec in ids.items():
|
|
96
|
+
key = _canon(rec.get("value") or "")
|
|
97
|
+
if key not in labels_of:
|
|
98
|
+
labels_of[key] = []
|
|
99
|
+
rep_rec[key] = rec
|
|
100
|
+
order.append(key)
|
|
101
|
+
labels_of[key].append(label)
|
|
102
|
+
return [("/".join(labels_of[key]), rep_rec[key]) for key in order]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_strong_label(label: str) -> bool:
|
|
106
|
+
"""该标识是否为实体唯一的强标识(可据此把不同 name 归并为同一实体)。"""
|
|
107
|
+
return any(k in label for k in _STRONG_LABEL_KEYS)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _now_iso() -> str:
|
|
111
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PartyRegistry:
|
|
115
|
+
"""known_parties.json 的读写 + 首见入库/再见校对。"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, path: Path, data: Optional[dict] = None) -> None:
|
|
118
|
+
self._path = path
|
|
119
|
+
self._data = data if data is not None else {
|
|
120
|
+
"version": REGISTRY_VERSION, "parties": {}, "aliases": {},
|
|
121
|
+
}
|
|
122
|
+
self._dirty = False
|
|
123
|
+
|
|
124
|
+
# ---------- 加载 / 保存 ----------
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def load(cls, path: Path) -> "PartyRegistry":
|
|
128
|
+
"""读基准库;文件不存在/损坏/结构非法一律返回空库——只读路径必须健壮,坏文件不能让入库崩。"""
|
|
129
|
+
if not path.exists():
|
|
130
|
+
return cls(path)
|
|
131
|
+
try:
|
|
132
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
133
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
134
|
+
logger.warning("known_parties 读取失败,按空库处理: %s", e)
|
|
135
|
+
return cls(path)
|
|
136
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("parties"), dict):
|
|
137
|
+
logger.warning("known_parties 结构非法,按空库处理: %s", path)
|
|
138
|
+
return cls(path)
|
|
139
|
+
return cls(path, payload)
|
|
140
|
+
|
|
141
|
+
def save(self) -> Path:
|
|
142
|
+
"""写基准库;文件 0600(含 PII,仅本人可读,每次都 chmod 防 umask 宽松)。"""
|
|
143
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
self._path.write_text(
|
|
145
|
+
json.dumps(self._data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
146
|
+
)
|
|
147
|
+
self._path.chmod(0o600)
|
|
148
|
+
self._dirty = False
|
|
149
|
+
return self._path
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def dirty(self) -> bool:
|
|
153
|
+
"""自上次 load/save 以来是否有录入改动(调用方据此决定要不要 save)。"""
|
|
154
|
+
return self._dirty
|
|
155
|
+
|
|
156
|
+
# ---------- 核对(首见入库 / 再见校对)----------
|
|
157
|
+
|
|
158
|
+
def reconcile(self, identities: list[PersonIdentity], doc_sha: str) -> list[CompletenessIssue]:
|
|
159
|
+
"""
|
|
160
|
+
把一份文档抽到的 person_identities 与基准库比对(按实体对齐,非字面 name)。
|
|
161
|
+
|
|
162
|
+
每个主体先经 _resolve_entity 定位 canonical key(别名/强标识归并):
|
|
163
|
+
首见(canonical 无此标识)→ 录入基准并记首见出处;
|
|
164
|
+
再见(canonical 已有)→ 比对,不一致返回 identity 缺陷(不覆盖基准)。
|
|
165
|
+
就地修改基准库(录入首见 + 学到的别名),是否落盘由调用方 save 决定(看 dirty)。
|
|
166
|
+
"""
|
|
167
|
+
issues: list[CompletenessIssue] = []
|
|
168
|
+
parties = self._data["parties"]
|
|
169
|
+
aliases = self._data.setdefault("aliases", {}) # name 变体 → canonical(v1 文件无此键)
|
|
170
|
+
for person in identities:
|
|
171
|
+
if not person.name.strip():
|
|
172
|
+
continue
|
|
173
|
+
name = _canon_name(person.name)
|
|
174
|
+
canonical = self._resolve_entity(name, person, parties, aliases)
|
|
175
|
+
if canonical != name and aliases.get(name) != canonical:
|
|
176
|
+
aliases[name] = canonical # 记下别名,今后只带弱标识也能归位
|
|
177
|
+
self._dirty = True
|
|
178
|
+
slot = parties.setdefault(canonical, {})
|
|
179
|
+
for idv in person.identifiers:
|
|
180
|
+
label, value = idv.label.strip(), idv.value.strip()
|
|
181
|
+
if not label or not value:
|
|
182
|
+
continue
|
|
183
|
+
known = slot.get(label)
|
|
184
|
+
if known is None:
|
|
185
|
+
slot[label] = {
|
|
186
|
+
"value": value,
|
|
187
|
+
"first_seen_doc": doc_sha,
|
|
188
|
+
"first_seen_at": _now_iso(),
|
|
189
|
+
"role": person.role or "",
|
|
190
|
+
}
|
|
191
|
+
self._dirty = True
|
|
192
|
+
elif _canon(known.get("value", "")) != _canon(value):
|
|
193
|
+
base = known.get("value", "")
|
|
194
|
+
src = str(known.get("first_seen_doc", ""))[:12]
|
|
195
|
+
issues.append(CompletenessIssue(
|
|
196
|
+
item=f"{canonical}·{label}",
|
|
197
|
+
category="identity",
|
|
198
|
+
detail=(
|
|
199
|
+
f"与基准不一致:基准『{base}』(首见于 {src}),"
|
|
200
|
+
f"本次『{value}』——疑似 OCR 读错或信息被改,请人工核对"
|
|
201
|
+
),
|
|
202
|
+
evidence=f"本次文档 {doc_sha[:12]}",
|
|
203
|
+
))
|
|
204
|
+
return issues
|
|
205
|
+
|
|
206
|
+
def _resolve_entity(
|
|
207
|
+
self, name: str, person: PersonIdentity, parties: dict, aliases: dict
|
|
208
|
+
) -> str:
|
|
209
|
+
"""
|
|
210
|
+
定位本主体应归入的 canonical key(实体对齐,而非字面 name):
|
|
211
|
+
1. 已是已知别名 → 直达其 canonical;
|
|
212
|
+
2. 已是现有 key → 用自己;
|
|
213
|
+
3. 本次某强标识值已登记在另一 name 下 → 同实体,归并到那个已有 key;
|
|
214
|
+
4. 都不是 → 新实体,用规范化后的 name。
|
|
215
|
+
|
|
216
|
+
只用强标识(身份证/银行账号/印章/信用代码/税号)归并——它们实体唯一,
|
|
217
|
+
同值必同实体;弱标识(电话/开户行)多人共用,绝不据此合并。
|
|
218
|
+
"""
|
|
219
|
+
if name in aliases:
|
|
220
|
+
return aliases[name]
|
|
221
|
+
if name in parties:
|
|
222
|
+
return name
|
|
223
|
+
for idv in person.identifiers:
|
|
224
|
+
if not _is_strong_label(idv.label):
|
|
225
|
+
continue
|
|
226
|
+
value = _canon(idv.value.strip())
|
|
227
|
+
if not value:
|
|
228
|
+
continue
|
|
229
|
+
for existing_name, slot in parties.items():
|
|
230
|
+
for label, info in slot.items():
|
|
231
|
+
if _is_strong_label(label) and _canon(info.get("value", "")) == value:
|
|
232
|
+
return existing_name
|
|
233
|
+
return name
|
|
234
|
+
|
|
235
|
+
# ---------- 管理(party 命令组用)----------
|
|
236
|
+
|
|
237
|
+
def all_parties(self) -> dict:
|
|
238
|
+
"""全部基准:name → {label → {value, first_seen_doc, first_seen_at, role}}。"""
|
|
239
|
+
return self._data["parties"]
|
|
240
|
+
|
|
241
|
+
def get(self, name: str) -> Optional[dict]:
|
|
242
|
+
"""某主体的全部标识基准;无则 None。"""
|
|
243
|
+
return self._data["parties"].get(name.strip())
|
|
244
|
+
|
|
245
|
+
def set(self, name: str, label: str, value: str) -> None:
|
|
246
|
+
"""手动录入/修正基准(覆盖既有值,来源标记为 manual,便于 show 区分)。"""
|
|
247
|
+
name, label, value = name.strip(), label.strip(), value.strip()
|
|
248
|
+
if not name or not label or not value:
|
|
249
|
+
raise ValueError("name/label/value 均不能为空")
|
|
250
|
+
slot = self._data["parties"].setdefault(name, {})
|
|
251
|
+
slot[label] = {
|
|
252
|
+
"value": value,
|
|
253
|
+
"first_seen_doc": "(manual)",
|
|
254
|
+
"first_seen_at": _now_iso(),
|
|
255
|
+
"role": slot.get(label, {}).get("role", ""),
|
|
256
|
+
}
|
|
257
|
+
self._dirty = True
|
|
258
|
+
|
|
259
|
+
def remove(self, name: str, label: Optional[str] = None) -> bool:
|
|
260
|
+
"""删某主体的某标识;label 省略则删整个主体。返回是否真的删到。"""
|
|
261
|
+
name = name.strip()
|
|
262
|
+
parties = self._data["parties"]
|
|
263
|
+
if name not in parties:
|
|
264
|
+
return False
|
|
265
|
+
if label is None:
|
|
266
|
+
del parties[name]
|
|
267
|
+
self._dirty = True
|
|
268
|
+
return True
|
|
269
|
+
label = label.strip()
|
|
270
|
+
if label in parties[name]:
|
|
271
|
+
del parties[name][label]
|
|
272
|
+
if not parties[name]: # 主体下已无任何标识,清掉空壳
|
|
273
|
+
del parties[name]
|
|
274
|
+
self._dirty = True
|
|
275
|
+
return True
|
|
276
|
+
return False
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
档案库路径约定 + 文件操作小工具。
|
|
3
|
+
|
|
4
|
+
archive/
|
|
5
|
+
db.sqlite (+ -wal, -shm)
|
|
6
|
+
ingest.jsonl # 总 log,每次 ingest 一行 JSON
|
|
7
|
+
documents/
|
|
8
|
+
<sha-short>/ # sha256 前 12 位
|
|
9
|
+
source.pdf # 留档源 PDF(硬链接优先,跨盘 fallback copy)
|
|
10
|
+
mineru/ # MinerU 原始产物(markdown.md / layout.json / images/...)
|
|
11
|
+
extracted.json # 抽取结果 + 置信度(复跑 extract 命令的输入)
|
|
12
|
+
ingest.log # 单合同详细 stderr
|
|
13
|
+
tmp/ # ingest 过程暂存区,全成功后 os.rename 到 documents/
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SHA_SHORT_LEN = 12
|
|
25
|
+
|
|
26
|
+
# XDG 数据目录下的应用子目录名(跟 CLI / repo 名对齐)
|
|
27
|
+
APP_DIR_NAME = "contract-archive"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default_archive_root() -> Path:
|
|
31
|
+
"""
|
|
32
|
+
无 --archive / CONTRACT_ARCHIVE_DIR 时的默认档案库根,遵循 XDG Base Directory 约定。
|
|
33
|
+
|
|
34
|
+
数据类文件(db + 文档产物 + 日志)属于 XDG "data":
|
|
35
|
+
$XDG_DATA_HOME/contract-archive (默认 ~/.local/share/contract-archive)
|
|
36
|
+
|
|
37
|
+
按 XDG 规范:$XDG_DATA_HOME 仅在被设置且为绝对路径时生效,否则回退默认值。
|
|
38
|
+
"""
|
|
39
|
+
xdg_data = os.getenv("XDG_DATA_HOME", "").strip()
|
|
40
|
+
base = Path(xdg_data) if xdg_data and os.path.isabs(xdg_data) else Path.home() / ".local" / "share"
|
|
41
|
+
return base / APP_DIR_NAME
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ArchivePaths:
|
|
46
|
+
"""档案库根目录 + 派生路径。"""
|
|
47
|
+
|
|
48
|
+
root: Path
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def db_path(self) -> Path:
|
|
52
|
+
return self.root / "db.sqlite"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def documents_dir(self) -> Path:
|
|
56
|
+
return self.root / "documents"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def tmp_dir(self) -> Path:
|
|
60
|
+
return self.root / "tmp"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def ingest_log(self) -> Path:
|
|
64
|
+
return self.root / "ingest.jsonl"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def known_parties_path(self) -> Path:
|
|
68
|
+
"""主体身份基准库(known_parties.json)。含真实 PII,存档案库根、权限 0600。"""
|
|
69
|
+
return self.root / "known_parties.json"
|
|
70
|
+
|
|
71
|
+
def doc_dir(self, sha256: str) -> Path:
|
|
72
|
+
return self.documents_dir / sha256[:SHA_SHORT_LEN]
|
|
73
|
+
|
|
74
|
+
def ensure(self) -> None:
|
|
75
|
+
"""启动时调用:建立根目录骨架。tmp/ 不立即建(按需创建并清理)。"""
|
|
76
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
self.documents_dir.mkdir(exist_ok=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sha256_of_file(path: Path, chunk_size: int = 1 << 20) -> str:
|
|
81
|
+
"""流式 SHA256(避免大文件全文件读入)。"""
|
|
82
|
+
h = hashlib.sha256()
|
|
83
|
+
with open(path, "rb") as f:
|
|
84
|
+
while True:
|
|
85
|
+
chunk = f.read(chunk_size)
|
|
86
|
+
if not chunk:
|
|
87
|
+
break
|
|
88
|
+
h.update(chunk)
|
|
89
|
+
return h.hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def link_or_copy(src: Path, dst: Path) -> str:
|
|
93
|
+
"""
|
|
94
|
+
优先硬链接(省空间,inode 共享),跨盘失败回退到 copy。
|
|
95
|
+
返回实际使用的策略,"link" 或 "copy"。
|
|
96
|
+
dst 已存在则先删除(reingest 场景)。
|
|
97
|
+
"""
|
|
98
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
if dst.exists() or dst.is_symlink():
|
|
100
|
+
dst.unlink()
|
|
101
|
+
try:
|
|
102
|
+
os.link(src, dst)
|
|
103
|
+
return "link"
|
|
104
|
+
except OSError:
|
|
105
|
+
# 跨盘 / 不支持硬链接的文件系统(exFAT 等)
|
|
106
|
+
shutil.copy2(src, dst)
|
|
107
|
+
return "copy"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def safe_rmtree(path: Path) -> None:
|
|
111
|
+
"""删除目录(不存在则忽略)。仅用于已知是本工具创建的目录。"""
|
|
112
|
+
if path.exists():
|
|
113
|
+
shutil.rmtree(path)
|