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
contract_archive/cli.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""
|
|
2
|
+
本地合同档案库 CLI —— 入口与组装模块。
|
|
3
|
+
|
|
4
|
+
子命令:
|
|
5
|
+
ingest <path> 扫描 PDF 文件/目录,跑 OCR + 抽取,结果入库
|
|
6
|
+
list 列出档案;status 颜色标注,支持排序;--incomplete 只列疑似不完整合同
|
|
7
|
+
search 按字段过滤(合同名/甲乙方/金额/日期/自动续约/风险)
|
|
8
|
+
show <ident> 查看单条详情(id 或 sha 前缀 >=4 字符)
|
|
9
|
+
raw <ident> 打印文档原文(OCR 文本),与 show 互补,可管道给 grep/less
|
|
10
|
+
extract <id> 只重跑抽取(不重跑 OCR),适合 partial 修复 / 改 prompt 后重抽
|
|
11
|
+
stats 总数 / status 分布 / 按月签订分布 / 近 30 天到期数
|
|
12
|
+
seals 跨文档列印章(某主体有哪些章、各在哪些文档)
|
|
13
|
+
delete <id> 删除档案记录;默认仅删 DB 行,--purge-files 同时删文件
|
|
14
|
+
vacuum VACUUM 数据库(碎片整理)
|
|
15
|
+
config 查看/设置全局配置(XDG ~/.config/contract-archive/config.json)
|
|
16
|
+
|
|
17
|
+
档案库路径优先级:--archive flag > CONTRACT_ARCHIVE_DIR env > config archive.dir > XDG 默认 (~/.local/share/contract-archive)
|
|
18
|
+
|
|
19
|
+
代码组织(命令空间扁平,全挂在同一个 app 上):
|
|
20
|
+
cli_common.py app 实例 + 全局 callback + 参数 Enum + 双 console + 路径/ident 解析(叶子)
|
|
21
|
+
cli_query.py 只读命令:list/search/show/raw/stats/todo/seals
|
|
22
|
+
cli.py(本文件)写命令:ingest/extract/delete/vacuum + 组装 sub-app 与 introspection
|
|
23
|
+
|
|
24
|
+
ingest 留在本模块(而非 cli_query)有硬约束:测试用 monkeypatch.setattr(cli, "MinerUPipeline"/
|
|
25
|
+
"ingest_pdf") 打桩,命令体必须引用本模块全局名才能让桩生效——别图整齐把它挪走。
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json as _json
|
|
30
|
+
import logging
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
import typer
|
|
36
|
+
|
|
37
|
+
from .errors import classify_exception
|
|
38
|
+
from .archive import (
|
|
39
|
+
ArchivePaths,
|
|
40
|
+
checkpoint,
|
|
41
|
+
delete_document,
|
|
42
|
+
discover_pdfs,
|
|
43
|
+
ingest_pdf,
|
|
44
|
+
open_archive_db,
|
|
45
|
+
re_extract,
|
|
46
|
+
)
|
|
47
|
+
from .archive.paths import sha256_of_file
|
|
48
|
+
from .archive.repository import find_by_sha
|
|
49
|
+
from .pipelines import MinerUPipeline
|
|
50
|
+
from .cli_config import config_app
|
|
51
|
+
from .cli_introspect import register as register_introspect
|
|
52
|
+
from .cli_party import party_app
|
|
53
|
+
from .cli_render import ingest_result_to_dict
|
|
54
|
+
from .cli_common import (
|
|
55
|
+
OutputFormat,
|
|
56
|
+
ProgressFormat,
|
|
57
|
+
_archive_opt,
|
|
58
|
+
_resolve_archive,
|
|
59
|
+
_resolve_ident,
|
|
60
|
+
app,
|
|
61
|
+
console, # noqa: F401 —— re-export:历史上有调用方/测试经 cli.console 访问
|
|
62
|
+
err_console,
|
|
63
|
+
not_found_json,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# ---------- ingest ----------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def ingest(
|
|
71
|
+
path: Path = typer.Argument(
|
|
72
|
+
..., exists=True, readable=True, help="PDF 文件或目录(目录会递归扫 *.pdf)"
|
|
73
|
+
),
|
|
74
|
+
archive: Optional[Path] = _archive_opt,
|
|
75
|
+
reingest: bool = typer.Option(
|
|
76
|
+
False, "--reingest", help="忽略 sha256 去重,强制重跑 OCR + 抽取"
|
|
77
|
+
),
|
|
78
|
+
no_llm: bool = typer.Option(
|
|
79
|
+
False, "--no-llm",
|
|
80
|
+
help="跳过 LLM 抽取(无 API key 时也用):仅入库 OCR 产物,抽取字段留空,可后续 extract 补抽",
|
|
81
|
+
),
|
|
82
|
+
limit: int = typer.Option(
|
|
83
|
+
0, "--limit", help="最多处理 N 个文件(0 = 无限制;试跑用)"
|
|
84
|
+
),
|
|
85
|
+
fmt: OutputFormat = typer.Option(
|
|
86
|
+
OutputFormat.table, "--format", help="table | json(json 把汇总+逐条结果打到 stdout)"
|
|
87
|
+
),
|
|
88
|
+
progress: ProgressFormat = typer.Option(
|
|
89
|
+
ProgressFormat.none, "--progress",
|
|
90
|
+
help="none | ndjson(ndjson 逐文件向 stdout 吐 JSON 事件流,供 agent 流式消费)",
|
|
91
|
+
),
|
|
92
|
+
dry_run: bool = typer.Option(
|
|
93
|
+
False, "--dry-run",
|
|
94
|
+
help="只预览将处理哪些文件 + 预计 API 调用,不跑 OCR/不调 LLM/不写库",
|
|
95
|
+
),
|
|
96
|
+
max_files: int = typer.Option(
|
|
97
|
+
0, "--max-files",
|
|
98
|
+
help="最多处理 N 个文件,超过则报错退出(0=不限;防误喂大目录烧钱,agent 应主动设)",
|
|
99
|
+
),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""跑 OCR + 抽取,把合同入库。"""
|
|
102
|
+
paths = _resolve_archive(archive)
|
|
103
|
+
|
|
104
|
+
pdfs = discover_pdfs(path)
|
|
105
|
+
if limit > 0:
|
|
106
|
+
pdfs = pdfs[:limit]
|
|
107
|
+
|
|
108
|
+
# --dry-run:预览不建库/不跑 OCR/不调 LLM,提前返回(预览不受 --max-files 限制)。
|
|
109
|
+
if dry_run:
|
|
110
|
+
_ingest_dry_run(pdfs, paths, fmt)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# --max-files 护栏:超上限直接报错退出,防 agent 误喂大目录烧钱(0=不限,保持兼容)。
|
|
114
|
+
if max_files > 0 and len(pdfs) > max_files:
|
|
115
|
+
err_console.print(
|
|
116
|
+
f"[red]发现 {len(pdfs)} 个 PDF,超过 --max-files {max_files};"
|
|
117
|
+
f"确需处理请调大 --max-files[/red]"
|
|
118
|
+
)
|
|
119
|
+
raise typer.Exit(2)
|
|
120
|
+
|
|
121
|
+
paths.ensure()
|
|
122
|
+
conn = open_archive_db(paths.db_path)
|
|
123
|
+
|
|
124
|
+
summary = {"ok": 0, "partial": 0, "failed": 0, "skipped": 0}
|
|
125
|
+
if not pdfs:
|
|
126
|
+
# 进度/提示走 stderr;json/ndjson 模式仍向 stdout 吐合法 JSON,便于管道消费。
|
|
127
|
+
err_console.print("[yellow]no PDFs found[/yellow]")
|
|
128
|
+
if progress is ProgressFormat.ndjson:
|
|
129
|
+
# 流式消费方即便空输入也应收到终止 summary 事件(与非空路径一致)。
|
|
130
|
+
print(_json.dumps(
|
|
131
|
+
{"event": "summary", "archive": str(paths.root), **summary},
|
|
132
|
+
ensure_ascii=False,
|
|
133
|
+
))
|
|
134
|
+
elif fmt is OutputFormat.json:
|
|
135
|
+
print(_json.dumps(
|
|
136
|
+
{"archive": str(paths.root), "summary": summary, "results": []},
|
|
137
|
+
ensure_ascii=False, indent=2,
|
|
138
|
+
))
|
|
139
|
+
conn.close()
|
|
140
|
+
raise typer.Exit(0)
|
|
141
|
+
|
|
142
|
+
err_console.print(f"[cyan]found {len(pdfs)} PDF(s); archive={paths.root}[/cyan]")
|
|
143
|
+
# 复用一个 OCR pipeline 实例(避免每次重新加载模型)
|
|
144
|
+
pipeline = MinerUPipeline(allow_vl_fallback=not no_llm)
|
|
145
|
+
|
|
146
|
+
results: list[dict] = []
|
|
147
|
+
try:
|
|
148
|
+
for i, pdf in enumerate(pdfs, 1):
|
|
149
|
+
err_console.rule(f"[bold cyan][{i}/{len(pdfs)}] {pdf.name}[/bold cyan]")
|
|
150
|
+
try:
|
|
151
|
+
result = ingest_pdf(
|
|
152
|
+
pdf,
|
|
153
|
+
paths,
|
|
154
|
+
conn,
|
|
155
|
+
reingest=reingest,
|
|
156
|
+
llm_enabled=not no_llm,
|
|
157
|
+
pipeline=pipeline,
|
|
158
|
+
)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
err_console.print(f"[red]✗ unexpected error: {e}[/red]")
|
|
161
|
+
logging.getLogger(__name__).exception("ingest crashed")
|
|
162
|
+
summary["failed"] += 1
|
|
163
|
+
fail_dict = {
|
|
164
|
+
"pdf_path": str(pdf), "sha256": None, "status": "failed",
|
|
165
|
+
"doc_id": None, "mineru_duration_s": None, "llm_duration_s": None,
|
|
166
|
+
"error_message": str(e),
|
|
167
|
+
"error": classify_exception(e).model_dump(),
|
|
168
|
+
"skipped_reason": None,
|
|
169
|
+
}
|
|
170
|
+
results.append(fail_dict)
|
|
171
|
+
if progress is ProgressFormat.ndjson:
|
|
172
|
+
_emit_progress(i, len(pdfs), fail_dict)
|
|
173
|
+
continue
|
|
174
|
+
summary[result.status] = summary.get(result.status, 0) + 1
|
|
175
|
+
result_dict = ingest_result_to_dict(result)
|
|
176
|
+
results.append(result_dict)
|
|
177
|
+
_print_ingest_result(result)
|
|
178
|
+
if progress is ProgressFormat.ndjson:
|
|
179
|
+
_emit_progress(i, len(pdfs), result_dict)
|
|
180
|
+
except KeyboardInterrupt:
|
|
181
|
+
# Ctrl-C:先让 finally 跑 checkpoint+close,再把中断抛出去(退出码 130)。
|
|
182
|
+
err_console.print(
|
|
183
|
+
f"\n[yellow]中断:已处理 {len(results)}/{len(pdfs)} 个,checkpoint 后退出[/yellow]"
|
|
184
|
+
)
|
|
185
|
+
raise
|
|
186
|
+
finally:
|
|
187
|
+
# 正常结束 / 循环内异常 / Ctrl-C 三条退出路径都无条件 checkpoint+close:
|
|
188
|
+
# per-file tmp→rename 已保证数据一致,这里兜 WAL 合并回主库 + 连接关闭的整洁性。
|
|
189
|
+
try:
|
|
190
|
+
checkpoint(conn)
|
|
191
|
+
conn.close()
|
|
192
|
+
except Exception: # noqa: BLE001 — 清理失败不能掩盖原始异常/中断
|
|
193
|
+
logging.getLogger(__name__).debug("ingest 清理失败", exc_info=True)
|
|
194
|
+
|
|
195
|
+
err_console.rule("[bold]summary[/bold]")
|
|
196
|
+
err_console.print(
|
|
197
|
+
f"ok={summary['ok']} partial={summary['partial']} "
|
|
198
|
+
f"failed={summary['failed']} skipped={summary['skipped']}"
|
|
199
|
+
)
|
|
200
|
+
if progress is ProgressFormat.ndjson:
|
|
201
|
+
# 流式模式:末行吐 summary 事件(逐文件事件已在循环里吐过)。
|
|
202
|
+
print(_json.dumps(
|
|
203
|
+
{"event": "summary", "archive": str(paths.root), **summary},
|
|
204
|
+
ensure_ascii=False,
|
|
205
|
+
))
|
|
206
|
+
elif fmt is OutputFormat.json:
|
|
207
|
+
print(_json.dumps(
|
|
208
|
+
{"archive": str(paths.root), "summary": summary, "results": results},
|
|
209
|
+
ensure_ascii=False, indent=2,
|
|
210
|
+
))
|
|
211
|
+
if summary["failed"]:
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _print_ingest_result(r) -> None:
|
|
216
|
+
color = {"ok": "green", "partial": "yellow", "failed": "red", "skipped": "blue"}.get(
|
|
217
|
+
r.status, "white"
|
|
218
|
+
)
|
|
219
|
+
ocr_s = f"{r.mineru_duration_s:.1f}s" if r.mineru_duration_s is not None else "-"
|
|
220
|
+
llm_s = f"{r.llm_duration_s:.1f}s" if r.llm_duration_s is not None else "-"
|
|
221
|
+
msg = (
|
|
222
|
+
f"[{color}]{r.status:8s}[/{color}] id={r.doc_id} "
|
|
223
|
+
f"sha={r.sha256[:12]} ocr={ocr_s} llm={llm_s}"
|
|
224
|
+
)
|
|
225
|
+
if r.error_message:
|
|
226
|
+
msg += f" [red]err={r.error_message[:80]}[/red]"
|
|
227
|
+
if r.skipped_reason:
|
|
228
|
+
msg += f" [blue]{r.skipped_reason}[/blue]"
|
|
229
|
+
err_console.print(msg)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _emit_progress(seq: int, total: int, result_dict: dict) -> None:
|
|
233
|
+
"""--progress ndjson:每处理完一个文件,向 stdout 吐一行 file_done 事件(机器流式消费)。"""
|
|
234
|
+
print(_json.dumps(
|
|
235
|
+
{"event": "file_done", "seq": seq, "total": total, **result_dict},
|
|
236
|
+
ensure_ascii=False,
|
|
237
|
+
))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _ingest_dry_run(pdfs: list[Path], paths: ArchivePaths, fmt: OutputFormat) -> None:
|
|
241
|
+
"""
|
|
242
|
+
预览将处理哪些文件 + 预计 API 调用,不产生任何副作用(不建库/不跑 OCR/不调 LLM)。
|
|
243
|
+
|
|
244
|
+
用已有库做 sha256 去重预览(库不存在则全部视为新增,且不创建库);成本预估:
|
|
245
|
+
每个新增文件至少 1 次 LLM 文本抽取,合同还会有最多 1 次 VL 签章核查。
|
|
246
|
+
"""
|
|
247
|
+
conn = open_archive_db(paths.db_path) if paths.db_path.exists() else None
|
|
248
|
+
files: list[dict] = []
|
|
249
|
+
new_count = 0
|
|
250
|
+
for pdf in pdfs:
|
|
251
|
+
sha = sha256_of_file(pdf)
|
|
252
|
+
existing = find_by_sha(conn, sha) if conn is not None else None
|
|
253
|
+
action = "skip" if existing else "new"
|
|
254
|
+
if action == "new":
|
|
255
|
+
new_count += 1
|
|
256
|
+
files.append({
|
|
257
|
+
"pdf_path": str(pdf), "sha256": sha,
|
|
258
|
+
"action": action, "existing_doc_id": existing,
|
|
259
|
+
})
|
|
260
|
+
if conn is not None:
|
|
261
|
+
conn.close()
|
|
262
|
+
|
|
263
|
+
payload = {
|
|
264
|
+
"dry_run": True,
|
|
265
|
+
"archive": str(paths.root),
|
|
266
|
+
"total": len(pdfs),
|
|
267
|
+
"new": new_count,
|
|
268
|
+
"already_ingested": len(pdfs) - new_count,
|
|
269
|
+
"estimated_llm_calls": new_count,
|
|
270
|
+
"estimated_vl_calls_max": new_count,
|
|
271
|
+
"files": files,
|
|
272
|
+
}
|
|
273
|
+
if fmt is OutputFormat.json:
|
|
274
|
+
print(_json.dumps(payload, ensure_ascii=False, indent=2))
|
|
275
|
+
return
|
|
276
|
+
err_console.print(
|
|
277
|
+
f"[cyan]dry-run: 共 {len(pdfs)} 个 PDF,新增 {new_count},"
|
|
278
|
+
f"已存在 {len(pdfs) - new_count}[/cyan]"
|
|
279
|
+
)
|
|
280
|
+
err_console.print(
|
|
281
|
+
f"预计 LLM 文本抽取 {new_count} 次,VL 签章核查最多 {new_count} 次(仅合同)"
|
|
282
|
+
)
|
|
283
|
+
for f in files:
|
|
284
|
+
mark = "[green]new [/green]" if f["action"] == "new" else "[blue]skip[/blue]"
|
|
285
|
+
err_console.print(f" {mark} {f['sha256'][:12]} {f['pdf_path']}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------- extract ----------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command()
|
|
292
|
+
def extract(
|
|
293
|
+
ident: str = typer.Argument(..., help="档案 id 或 sha 前缀"),
|
|
294
|
+
archive: Optional[Path] = _archive_opt,
|
|
295
|
+
no_llm: bool = typer.Option(
|
|
296
|
+
False, "--no-llm", help="跳过 LLM(抽取字段留空,rule 已退役)"
|
|
297
|
+
),
|
|
298
|
+
fmt: OutputFormat = typer.Option(
|
|
299
|
+
OutputFormat.table, "--format", help="table | json(json 把结构化结果+error 打到 stdout)"
|
|
300
|
+
),
|
|
301
|
+
) -> None:
|
|
302
|
+
"""
|
|
303
|
+
只重跑合同字段抽取(不重跑 OCR)。用于:
|
|
304
|
+
- partial 状态修复
|
|
305
|
+
- 改 prompt 后批量再抽取
|
|
306
|
+
"""
|
|
307
|
+
paths = _resolve_archive(archive)
|
|
308
|
+
conn = open_archive_db(paths.db_path)
|
|
309
|
+
row = _resolve_ident(conn, ident)
|
|
310
|
+
if not row:
|
|
311
|
+
# json 模式吐 not_found 信封到 stdout(与 show 一致,别让 | jq 拿空输入);table 走 stderr。
|
|
312
|
+
if fmt is OutputFormat.json:
|
|
313
|
+
not_found_json(ident)
|
|
314
|
+
else:
|
|
315
|
+
err_console.print(f"[red]not found: {ident}[/red]")
|
|
316
|
+
conn.close()
|
|
317
|
+
raise typer.Exit(1)
|
|
318
|
+
|
|
319
|
+
err_console.print(f"[cyan]re-extracting id={row.id} sha={row.short_sha}[/cyan]")
|
|
320
|
+
result = re_extract(row.id, paths, conn, llm_enabled=not no_llm)
|
|
321
|
+
checkpoint(conn)
|
|
322
|
+
conn.close()
|
|
323
|
+
if fmt is OutputFormat.json:
|
|
324
|
+
# 结构化结果(含 re_extract 的 error/retryable)供 agent 消费,与 ingest --format json 一致。
|
|
325
|
+
print(_json.dumps(ingest_result_to_dict(result), ensure_ascii=False, indent=2))
|
|
326
|
+
else:
|
|
327
|
+
_print_ingest_result(result)
|
|
328
|
+
# 抽取失败(空抽取/LLM 异常,re_extract 返回 status=partial + error)必须以非零退出,
|
|
329
|
+
# 否则纯 shell 调用方靠 $? 完全发现不了 extract 失败(此前一律 exit 0 是 bug)。
|
|
330
|
+
if result.error is not None:
|
|
331
|
+
raise typer.Exit(1)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------- delete ----------
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@app.command()
|
|
338
|
+
def delete(
|
|
339
|
+
ident: str = typer.Argument(..., help="档案 id 或 sha 前缀"),
|
|
340
|
+
archive: Optional[Path] = _archive_opt,
|
|
341
|
+
purge_files: bool = typer.Option(
|
|
342
|
+
False,
|
|
343
|
+
"--purge-files",
|
|
344
|
+
help="同时删除 archive/documents/<sha-short>/(默认只删 DB 行)",
|
|
345
|
+
),
|
|
346
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认提示"),
|
|
347
|
+
) -> None:
|
|
348
|
+
"""
|
|
349
|
+
删除单条档案。默认仅删 DB 记录,保留文件;--purge-files 才删 archive 内的产物。
|
|
350
|
+
源 PDF(用户原文件)任何情况下都不会被删除。
|
|
351
|
+
"""
|
|
352
|
+
paths = _resolve_archive(archive)
|
|
353
|
+
conn = open_archive_db(paths.db_path)
|
|
354
|
+
row = _resolve_ident(conn, ident)
|
|
355
|
+
if not row:
|
|
356
|
+
err_console.print(f"[red]not found: {ident}[/red]")
|
|
357
|
+
conn.close()
|
|
358
|
+
raise typer.Exit(1)
|
|
359
|
+
|
|
360
|
+
# 非交互环境(管道/CI)下不能交互确认,typer.confirm 会读到 EOF 崩。
|
|
361
|
+
# clig.dev:危险动作在非 TTY 下应明确要求显式 --yes,而不是糊涂地中止。
|
|
362
|
+
if not yes and not sys.stdin.isatty():
|
|
363
|
+
err_console.print(
|
|
364
|
+
"[red]拒绝在非交互环境删除:请加 --yes 确认[/red]"
|
|
365
|
+
)
|
|
366
|
+
conn.close()
|
|
367
|
+
raise typer.Exit(1)
|
|
368
|
+
|
|
369
|
+
err_console.print(
|
|
370
|
+
f"about to delete: id={row.id} sha={row.short_sha} name={row.contract_name or '-'}"
|
|
371
|
+
)
|
|
372
|
+
err_console.print(f" source PDF: {row.source_path} [dim](不会被删除)[/dim]")
|
|
373
|
+
err_console.print(
|
|
374
|
+
f" archive dir: {row.output_dir} "
|
|
375
|
+
+ ("[red](会被删除)[/red]" if purge_files else "[dim](保留)[/dim]")
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if not yes:
|
|
379
|
+
confirm = typer.confirm("继续?", default=False)
|
|
380
|
+
if not confirm:
|
|
381
|
+
err_console.print("[yellow]aborted[/yellow]")
|
|
382
|
+
conn.close()
|
|
383
|
+
raise typer.Exit(0)
|
|
384
|
+
|
|
385
|
+
output_dir = delete_document(conn, row.id)
|
|
386
|
+
checkpoint(conn)
|
|
387
|
+
conn.close()
|
|
388
|
+
|
|
389
|
+
if purge_files and output_dir:
|
|
390
|
+
from shutil import rmtree
|
|
391
|
+
|
|
392
|
+
out = Path(output_dir)
|
|
393
|
+
if out.exists():
|
|
394
|
+
rmtree(out)
|
|
395
|
+
err_console.print(f"[green]✓ removed {out}[/green]")
|
|
396
|
+
err_console.print(f"[green]✓ deleted DB row id={row.id}[/green]")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------- vacuum ----------
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@app.command()
|
|
403
|
+
def vacuum(archive: Optional[Path] = _archive_opt) -> None:
|
|
404
|
+
"""VACUUM 数据库(碎片整理,建议大批量 ingest 后跑一次)。"""
|
|
405
|
+
paths = _resolve_archive(archive)
|
|
406
|
+
if not paths.db_path.exists():
|
|
407
|
+
err_console.print(f"[yellow]archive empty: {paths.db_path}[/yellow]")
|
|
408
|
+
raise typer.Exit(0)
|
|
409
|
+
conn = open_archive_db(paths.db_path)
|
|
410
|
+
err_console.print("[cyan]running VACUUM ANALYZE...[/cyan]")
|
|
411
|
+
conn.execute("VACUUM")
|
|
412
|
+
conn.execute("ANALYZE")
|
|
413
|
+
checkpoint(conn)
|
|
414
|
+
conn.close()
|
|
415
|
+
err_console.print("[green]✓ done[/green]")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------- 组装:挂上只读命令、子 app、introspection ----------
|
|
419
|
+
#
|
|
420
|
+
# import cli_query 仅为触发其 @app.command 注册(它只依赖 cli_common,不回头 import 本
|
|
421
|
+
# 模块,无循环)。放写命令之后,让 --help 里 ingest 等写命令仍排在前、贴近历史顺序。
|
|
422
|
+
from . import cli_query # noqa: E402,F401
|
|
423
|
+
|
|
424
|
+
app.add_typer(config_app, name="config")
|
|
425
|
+
app.add_typer(party_app, name="party")
|
|
426
|
+
# introspection 命令(capabilities/describe/schema):给机器发现能力用,见 cli_introspect。
|
|
427
|
+
register_introspect(app)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def main_entry() -> None:
|
|
431
|
+
"""
|
|
432
|
+
console_scripts 入口:在 app() 外包一层顶层异常钩子。
|
|
433
|
+
|
|
434
|
+
受控退出(命令里的 typer.Exit/Abort 在 click standalone 模式下已转成 SystemExit,
|
|
435
|
+
是 BaseException、不被下面 except Exception 接住)原样放行;未预期异常(如底层
|
|
436
|
+
sqlite OperationalError)翻成一行人话错误走 stderr(不污染 stdout 管道),默认不打
|
|
437
|
+
完整 traceback——加 -v/--verbose 才用 rich 展开(show_locals=False 防 secret 泄露)。
|
|
438
|
+
|
|
439
|
+
此前入口直挂裸 app()、typer pretty 异常开着,底层异常直接 dump 一坨 rich traceback,
|
|
440
|
+
用户/agent 看到的是实现细节而非可操作信息。配合 app 的 pretty_exceptions_enable=False。
|
|
441
|
+
"""
|
|
442
|
+
try:
|
|
443
|
+
app()
|
|
444
|
+
except Exception as exc: # noqa: BLE001 — 顶层兜底,把未预期异常翻成人话
|
|
445
|
+
info = classify_exception(exc)
|
|
446
|
+
err_console.print(f"[red]意外错误[/red] [{info.code}] {info.message}")
|
|
447
|
+
if "-v" in sys.argv or "--verbose" in sys.argv:
|
|
448
|
+
err_console.print_exception(show_locals=False)
|
|
449
|
+
else:
|
|
450
|
+
err_console.print("[dim]这可能是 bug;加 -v 看完整 traceback[/dim]")
|
|
451
|
+
raise SystemExit(1)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
if __name__ == "__main__":
|
|
455
|
+
main_entry()
|