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,293 @@
1
+ """
2
+ CLI 基础设施(被各命令模块共享的叶子模块,不依赖任何 cli_* 命令模块)。
3
+
4
+ 放这里的都是"框架级"共享件,与具体命令无关:
5
+ - app 主 Typer 实例 + 全局 callback(命令在别处用 @app.command 挂上来)
6
+ - 参数 Enum parse-time 校验,坏值由 typer 报 exit 2,不漏到数据层
7
+ - 双 console 数据走 stdout(可管道)/ 诊断走 stderr
8
+ - _resolve_* 档案库路径解析、ident 消歧、空库守卫(多个命令共用)
9
+
10
+ 依赖方向(保持 DAG,禁止反向):cli_common ← cli_query / cli(写命令)。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json as _json
15
+ import logging
16
+ import os
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import typer
22
+ from dotenv import load_dotenv
23
+ from rich.console import Console
24
+
25
+ from . import __version__
26
+ from .archive import (
27
+ ArchivePaths,
28
+ default_archive_root,
29
+ find_by_sha_prefix,
30
+ get_document,
31
+ )
32
+ from .config import load_settings
33
+
34
+ # ---------- 参数枚举(parse-time 校验:坏值由 typer 报 exit 2,不再漏到数据层 ValueError)----------
35
+
36
+
37
+ class OutputFormat(str, Enum):
38
+ """--format:人类表格 or 机器 JSON。"""
39
+
40
+ table = "table"
41
+ json = "json"
42
+
43
+
44
+ class ColorWhen(str, Enum):
45
+ """--color:auto=仅 TTY 上色(管道纯文本);always=强制(配 less -R);never=禁用。"""
46
+
47
+ auto = "auto"
48
+ always = "always"
49
+ never = "never"
50
+
51
+
52
+ class ProgressFormat(str, Enum):
53
+ """--progress:none=现状(汇总在末尾);ndjson=逐文件向 stdout 吐事件流,供 agent 流式消费。"""
54
+
55
+ none = "none"
56
+ ndjson = "ndjson"
57
+
58
+
59
+ class OrderBy(str, Enum):
60
+ """list --order-by。成员必须与 repository.list_documents 的 allowed_order 白名单一致。"""
61
+
62
+ ingested_at = "ingested_at"
63
+ primary_date = "primary_date"
64
+ primary_amount_cents = "primary_amount_cents"
65
+ sign_date = "sign_date"
66
+ expire_date = "expire_date"
67
+ amount_cents = "amount_cents"
68
+
69
+
70
+ class DocStatus(str, Enum):
71
+ """--status:入库状态。"""
72
+
73
+ ok = "ok"
74
+ partial = "partial"
75
+ failed = "failed"
76
+
77
+
78
+ class DocType(str, Enum):
79
+ """list --type:文档类型。值即 CLI choice,与抽取信封的类型枚举一致。"""
80
+
81
+ contract = "合同协议"
82
+ insurance = "保险凭证"
83
+ travel = "旅行资料"
84
+ proof = "证明"
85
+ invoice = "发票票据"
86
+ report = "报告"
87
+ certificate = "证件"
88
+ other = "其他"
89
+
90
+
91
+ class Actor(str, Enum):
92
+ """--actor:义务主体。成员必须与 repository 的 party_a/party_b/both 校验一致。"""
93
+
94
+ party_a = "party_a"
95
+ party_b = "party_b"
96
+ both = "both"
97
+
98
+
99
+ # ---------- 双 console:数据走 stdout(可管道),诊断/进度/错误走 stderr ----------
100
+
101
+ console = Console() # 主数据:表格 + JSON
102
+ err_console = Console(stderr=True) # 人类消息:状态/进度/错误/确认
103
+
104
+
105
+ def color_disabled() -> bool:
106
+ """
107
+ 全局是否应禁用颜色:--no-color flag(落到 console.no_color)或 NO_COLOR 环境变量。
108
+
109
+ rich console 自身已尊重 NO_COLOR + 被 callback 的 --no-color 置过 no_color;但 raw 命令的
110
+ 高亮不经 console(直写 ANSI 转义码),故需显式查这个开关。NO_COLOR 规范:非空即禁用
111
+ (空串不算),故 bool(os.environ.get("NO_COLOR")) 恰好对(空串 falsy)。
112
+ """
113
+ return bool(console.no_color) or bool(os.environ.get("NO_COLOR"))
114
+
115
+
116
+ def _version_cb(value: bool) -> None:
117
+ """--version 的 eager 回调:版本号打到 stdout(机器可消费),随即退出。"""
118
+ if value:
119
+ print(f"contract-archive {__version__}")
120
+ raise typer.Exit()
121
+
122
+
123
+ app = typer.Typer(
124
+ help="本地文档档案库 CLI(合同/证明/发票等,OCR + qwen3.7-max)",
125
+ # clig.dev:无参数应展示帮助,而非报 "Missing command" 错误框。
126
+ no_args_is_help=True,
127
+ context_settings={"help_option_names": ["-h", "--help"]},
128
+ # 关掉 typer 自带的 rich traceback 接管:未预期异常改由 cli.main_entry 的顶层钩子翻成
129
+ # 人话(默认一行错误 + 提示 -v 展开),别直接 dump 一坨实现细节给用户/agent。
130
+ pretty_exceptions_enable=False,
131
+ # show_locals=False 留作防御纵深:万一将来 enable 翻开,也不把局部变量(可能含 secret)dump 出。
132
+ pretty_exceptions_show_locals=False,
133
+ epilog=(
134
+ "示例:\n"
135
+ " contract-archive ingest ./input # 扫描目录入库\n"
136
+ " contract-archive list --format json | jq # 机器可读,管道友好\n"
137
+ " contract-archive todo --within-days 30 # 近 30 天待办义务\n"
138
+ "\n文档:https://github.com/crhan/contract-archive-cli"
139
+ ),
140
+ )
141
+
142
+
143
+ @app.callback()
144
+ def main(
145
+ version: bool = typer.Option(
146
+ False,
147
+ "--version",
148
+ "-V",
149
+ is_eager=True,
150
+ callback=_version_cb,
151
+ help="打印版本并退出",
152
+ ),
153
+ no_color: bool = typer.Option(
154
+ False, "--no-color", help="禁用彩色输出(管道/日志归档时用)"
155
+ ),
156
+ verbose: bool = typer.Option(
157
+ False, "--verbose", "-v", help="DEBUG 级日志(更啰嗦,排查用)"
158
+ ),
159
+ quiet: bool = typer.Option(
160
+ False, "--quiet", "-q", help="仅 WARNING 及以上(更安静)"
161
+ ),
162
+ ) -> None:
163
+ """
164
+ 全局选项在所有子命令前生效。flag 优先级高于环境变量:
165
+ --no-color 覆盖 NO_COLOR/TTY 自动探测;--verbose/--quiet 覆盖 LOG_LEVEL。
166
+ """
167
+ # dotenv 放到这里加载——保证 flag 解析后再读 env,且 CONTRACT_ARCHIVE_DIR 等及时可用。
168
+ # override=False 显式声明:shell 已 export 的变量压过 .env(即 env > 项目 .env),
169
+ # 与 config 层 env>file>default 的优先级语义一致。
170
+ load_dotenv(override=False)
171
+
172
+ if no_color:
173
+ console.no_color = True
174
+ err_console.no_color = True
175
+
176
+ # 日志默认 stderr;--verbose/--quiet 胜过 LOG_LEVEL env。
177
+ if verbose:
178
+ level: str | int = "DEBUG"
179
+ elif quiet:
180
+ level = "WARNING"
181
+ else:
182
+ level = _resolve_log_level(os.getenv("LOG_LEVEL", "INFO"))
183
+ logging.basicConfig(
184
+ level=level,
185
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
186
+ )
187
+
188
+
189
+ _VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
190
+
191
+
192
+ def _resolve_log_level(raw: str) -> str | int:
193
+ """
194
+ LOG_LEVEL 白名单归一:合法名(大小写不敏感)原样、纯数字转 int,其余降级 INFO 并 warning。
195
+
196
+ 此前把原始字符串直喂 logging.basicConfig,`LOG_LEVEL=bogus` 会抛 ValueError 把所有命令打挂
197
+ (连只读的 list/show 都崩)。坏 env 不该让命令崩——与 config 层「坏配置不崩、warning 后降级」
198
+ 一个取向,消除「文件配置坏了优雅、env 坏了硬崩」这个特殊情况。
199
+ """
200
+ norm = (raw or "").strip().upper()
201
+ if norm in _VALID_LOG_LEVELS:
202
+ return norm
203
+ if norm.isdigit():
204
+ return int(norm)
205
+ err_console.print(f"[yellow]无效 LOG_LEVEL={raw!r},降级为 INFO[/yellow]")
206
+ return "INFO"
207
+
208
+
209
+ # ---------- 全局 archive 路径解析 ----------
210
+
211
+
212
+ def _resolve_archive(archive_opt: Optional[Path]) -> ArchivePaths:
213
+ """
214
+ --archive flag > CONTRACT_ARCHIVE_DIR env > config archive.dir > XDG 默认。
215
+
216
+ env 与 config 的合并交给 load_settings()(其 archive_dir 已是 env>config 短路结果,
217
+ env 严格优先、空串当未设),这里只在 flag 之后接住它,再回退 XDG 默认。
218
+ 统一 expanduser:修掉历史上 CONTRACT_ARCHIVE_DIR=~/x 不展开 ~ 的坑。
219
+ """
220
+ if archive_opt:
221
+ root = archive_opt
222
+ else:
223
+ configured = load_settings().archive_dir
224
+ root = Path(configured) if configured else default_archive_root()
225
+ return ArchivePaths(root=root.expanduser().resolve())
226
+
227
+
228
+ _archive_opt = typer.Option(
229
+ None,
230
+ "--archive",
231
+ "-a",
232
+ help="档案库根目录;不传则用 CONTRACT_ARCHIVE_DIR 或 XDG 默认 ~/.local/share/contract-archive",
233
+ )
234
+
235
+
236
+ def _archive_empty(paths: ArchivePaths, fmt: OutputFormat) -> bool:
237
+ """
238
+ 读命令统一空库守卫。返回 True 表示库不存在、调用方应直接 return。
239
+ - json 模式:往 stdout 打 `[]`,保证管道消费者(jq)拿到合法 JSON
240
+ - table 模式:往 stderr 打人类提示,不污染 stdout
241
+ """
242
+ if paths.db_path.exists():
243
+ return False
244
+ if fmt is OutputFormat.json:
245
+ print("[]")
246
+ else:
247
+ err_console.print(f"[yellow]archive empty: {paths.db_path} not found[/yellow]")
248
+ return True
249
+
250
+
251
+ def not_found_json(ident: str) -> None:
252
+ """
253
+ show/extract 的 json 模式未命中时吐合法 JSON 错误信封到 stdout(调用方随后 Exit(1))。
254
+
255
+ 与空集合命令吐 `[]`、stats 吐零值对象同一套 JSON 契约:json 模式永不让 stdout 为空,
256
+ 保证 `| jq` / json.loads 拿到可解析对象。退出码仍非零,让 shell 也能判失败。
257
+ """
258
+ print(_json.dumps({"error": "not_found", "ident": ident}, ensure_ascii=False))
259
+
260
+
261
+ def _resolve_ident(conn, ident: str):
262
+ """
263
+ show/extract/delete 共用:ident 可能是 id 或 sha 前缀。
264
+ 消歧规则:
265
+ - 全数字且 <= 18 位 → 先按 id 查;查不到再按 sha 前缀
266
+ - 含非数字字符 → 按 sha 前缀(必须 >=4 字符)
267
+ - sha 前缀多匹配 → 报错列候选
268
+ """
269
+ if ident.isdigit() and len(ident) <= 18:
270
+ try:
271
+ doc_id = int(ident)
272
+ row = get_document(conn, doc_id)
273
+ if row:
274
+ return row
275
+ except ValueError:
276
+ pass
277
+ # 数字也可能是 sha 前缀(罕见但合法),fallthrough
278
+ if len(ident) < 4:
279
+ err_console.print(
280
+ f"[red]ident {ident!r} 不是有效 id;如要按 sha 前缀查询请提供 ≥4 字符[/red]"
281
+ )
282
+ return None
283
+ matches = find_by_sha_prefix(conn, ident.lower())
284
+ if not matches:
285
+ return None
286
+ if len(matches) > 1:
287
+ err_console.print(f"[red]sha prefix {ident!r} 命中 {len(matches)} 条,请提供更长前缀:[/red]")
288
+ for m in matches[:10]:
289
+ err_console.print(
290
+ f" id={m.id} sha={m.short_sha} name={m.contract_name or '-'}"
291
+ )
292
+ return None
293
+ return matches[0]
@@ -0,0 +1,96 @@
1
+ """
2
+ `config` 子命令:查看 / 设置 / 删除全局配置。
3
+
4
+ 独立文件——cli.py 已逼近 1000 行红线,config 命令组不能再往里塞。
5
+ 只做 show / set / unset 三个核心命令(砍掉 meeting-asr 的 keys/path/import-env/--json:
6
+ 单用户本地工具用不上,path 并进 show 抬头即可)。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json as _json
11
+
12
+ import typer
13
+ from rich.table import Table
14
+
15
+ # 复用 cli_common 的全局 console(别各建实例):全局 --no-color 在 callback 里只改 cli_common
16
+ # 那对实例,子组自建 Console 会让 --no-color 对 config 命令全失效。cli_common 是叶子模块,
17
+ # import 它不成环(cli_query 已用同款)。
18
+ from .cli_common import OutputFormat, console, err_console
19
+ from .config import (
20
+ config_path,
21
+ describe_items,
22
+ find_key,
23
+ set_value,
24
+ unset_value,
25
+ visible_items,
26
+ )
27
+
28
+ # pretty_exceptions_show_locals=False:防 traceback 把 api_key 等局部变量 dump 到终端。
29
+ config_app = typer.Typer(
30
+ help="查看/设置全局配置(XDG ~/.config/contract-archive/config.json)",
31
+ pretty_exceptions_show_locals=False,
32
+ no_args_is_help=True, # clig.dev:裸 `config` 列出 show/set/unset,而非报 Missing command
33
+ context_settings={"help_option_names": ["-h", "--help"]},
34
+ )
35
+
36
+
37
+ @config_app.command("show")
38
+ def show(
39
+ reveal: bool = typer.Option(False, "--reveal", help="显示 secret 明文(默认掩码)"),
40
+ fmt: OutputFormat = typer.Option(
41
+ OutputFormat.table, "--format", help="table | json(json 含 key/env/secret/default/value/source)"
42
+ ),
43
+ ) -> None:
44
+ """列出各配置项当前生效值(优先级:环境变量 > 配置文件 > 默认)。"""
45
+ if fmt is OutputFormat.json:
46
+ # 机器发现:让 agent 程序化拿到「支持哪些配置键、对应哪个 env、是否 secret、当前值与来源」。
47
+ print(_json.dumps(describe_items(reveal=reveal), ensure_ascii=False, indent=2))
48
+ return
49
+ cfg = config_path()
50
+ table = Table(
51
+ title=f"Config · {cfg}",
52
+ caption="值来源优先级:环境变量(含 .env) > 配置文件 > 默认值",
53
+ caption_justify="left",
54
+ )
55
+ table.add_column("key", style="cyan")
56
+ table.add_column("value", overflow="fold")
57
+ for name, shown in visible_items(reveal=reveal):
58
+ table.add_row(name, shown)
59
+ console.print(table)
60
+ if not cfg.exists():
61
+ err_console.print(
62
+ "[dim]配置文件尚不存在;`config set` 后自动创建。当前值来自环境变量/默认。[/dim]"
63
+ )
64
+
65
+
66
+ @config_app.command("set")
67
+ def set_cmd(
68
+ key: str = typer.Argument(..., help="配置键,如 dashscope.api_key"),
69
+ value: str = typer.Argument(..., help="配置值"),
70
+ ) -> None:
71
+ """设置一个配置项,写入 ~/.config/contract-archive/config.json(文件权限 0600)。"""
72
+ try:
73
+ cfg = set_value(key, value)
74
+ except ValueError as e:
75
+ err_console.print(f"[red]{e}[/red]")
76
+ raise typer.Exit(1)
77
+ k = find_key(key)
78
+ # 状态变更确认是「消息」,走 stderr(与 delete/vacuum 的 ✓ 一致);stdout 留给数据。
79
+ err_console.print(f"[green]已设置[/green] {k.name} → {cfg}")
80
+ if k.secret:
81
+ err_console.print(
82
+ "[yellow]注意:该文件明文存储 secret,已设为仅本人可读(0600);请勿提交或分享。[/yellow]"
83
+ )
84
+
85
+
86
+ @config_app.command("unset")
87
+ def unset_cmd(
88
+ key: str = typer.Argument(..., help="配置键,如 dashscope.api_key"),
89
+ ) -> None:
90
+ """从配置文件删除一个配置项(不影响环境变量/默认值)。"""
91
+ try:
92
+ cfg = unset_value(key)
93
+ except ValueError as e:
94
+ err_console.print(f"[red]{e}[/red]")
95
+ raise typer.Exit(1)
96
+ err_console.print(f"[green]已删除[/green] {key.strip()} ← {cfg}")
@@ -0,0 +1,204 @@
1
+ """
2
+ introspection 命令:让机器(Agent / MCP wrapper)发现能力与结构,无需读源码或解析 --help。
3
+
4
+ - capabilities 列所有命令 + 副作用/破坏性/幂等元数据(自动遍历 typer app + 安全表)
5
+ - describe <cmd> 单命令的参数 schema(名称/类型/必填/默认/可选值/帮助)
6
+ - schema <type> 核心数据结构的 JSON Schema(pydantic 直出)
7
+
8
+ 输出一律 JSON 到 stdout,可 `| jq` 消费。为什么手工维护安全元数据:
9
+ side_effects / destructive 无法从函数签名推断,必须人为声明——这恰是 Agent 调用前最该知道的
10
+ (这命令花不花钱?会不会删数据?能不能安全重试?)。命令清单本身由 click 内省自动生成,
11
+ 新增命令不会漏,只是缺省按只读安全值兜底,提醒维护者补 META。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json as _json
16
+ from enum import Enum
17
+ from typing import Any
18
+
19
+ import click
20
+ import typer
21
+
22
+ from . import __version__
23
+ from .errors import ErrorInfo
24
+ from .schemas import ContractExtraction, DocumentExtraction, ExtractionConfidence
25
+
26
+ # 输出 schema 版本:未来字段演进时 +1,消费方据此判断兼容性。
27
+ INTROSPECT_SCHEMA_VERSION = "1"
28
+
29
+ # 命令安全元数据。side_effects 取值:
30
+ # read / fs_write / db_write / network / cost(消耗付费 API token)
31
+ # destructive=True 表示会删除/不可逆覆盖已有数据;idempotent=True 表示重复执行结果一致。
32
+ # 未列出的命令按「只读、非破坏、幂等」兜底(见 _command_entry)。
33
+ COMMAND_META: dict[str, dict[str, Any]] = {
34
+ "ingest": {
35
+ "summary": "跑 OCR + 抽取,把文档入库",
36
+ "side_effects": ["fs_write", "db_write", "network", "cost"],
37
+ "destructive": False, "idempotent": True,
38
+ },
39
+ "extract": {
40
+ "summary": "只重跑抽取(不重跑 OCR),修复 partial / 改 prompt 后重抽",
41
+ "side_effects": ["fs_write", "db_write", "network", "cost"],
42
+ "destructive": False, "idempotent": True,
43
+ },
44
+ "list": {"summary": "列出档案", "side_effects": ["read"], "destructive": False, "idempotent": True},
45
+ "search": {"summary": "多字段过滤查询", "side_effects": ["read"], "destructive": False, "idempotent": True},
46
+ "show": {"summary": "查看单条详情", "side_effects": ["read"], "destructive": False, "idempotent": True},
47
+ "raw": {"summary": "打印文档原文(OCR 文本)", "side_effects": ["read"], "destructive": False, "idempotent": True},
48
+ "stats": {"summary": "档案库统计", "side_effects": ["read"], "destructive": False, "idempotent": True},
49
+ "todo": {"summary": "跨合同列待办义务", "side_effects": ["read"], "destructive": False, "idempotent": True},
50
+ "seals": {"summary": "跨文档列印章", "side_effects": ["read"], "destructive": False, "idempotent": True},
51
+ "delete": {
52
+ "summary": "删除单条档案(--purge-files 同时删文件)",
53
+ "side_effects": ["fs_write", "db_write"],
54
+ "destructive": True, "idempotent": True, "requires_confirmation": True,
55
+ },
56
+ "vacuum": {
57
+ "summary": "VACUUM 数据库(碎片整理)",
58
+ "side_effects": ["db_write"], "destructive": False, "idempotent": True,
59
+ },
60
+ "config": {"summary": "查看/设置全局配置", "side_effects": ["read", "fs_write"], "destructive": False, "idempotent": True},
61
+ "party": {
62
+ "summary": "管理 known_parties 身份基准库(list/show 读,set/rm 改)",
63
+ "side_effects": ["read", "fs_write"],
64
+ # group 入口本身非破坏;破坏在子命令 party rm。capabilities 暂只列顶层、不递归
65
+ # 展开 group 子命令——精确表达 party rm 的破坏性记入 backlog。
66
+ "destructive": False,
67
+ "idempotent": True,
68
+ },
69
+ "capabilities": {"summary": "列命令能力与副作用元数据", "side_effects": ["read"], "destructive": False, "idempotent": True},
70
+ "describe": {"summary": "打印单命令参数 schema", "side_effects": ["read"], "destructive": False, "idempotent": True},
71
+ "schema": {"summary": "打印核心数据结构 JSON Schema", "side_effects": ["read"], "destructive": False, "idempotent": True},
72
+ }
73
+
74
+ # 可经 `schema <type>` 暴露的数据结构(pydantic 直出 JSON Schema)。
75
+ SCHEMA_TYPES: dict[str, type] = {
76
+ "document": DocumentExtraction, # 通用抽取信封(list/show --json 的核心内容)
77
+ "contract": ContractExtraction, # 合同专属字段
78
+ "confidence": ExtractionConfidence,
79
+ "error": ErrorInfo, # 失败结果里的结构化错误
80
+ }
81
+
82
+ # register() 时由 cli.py 注入主 app;capabilities/describe 据此内省命令树(避免循环 import)。
83
+ _APP: typer.Typer | None = None
84
+
85
+
86
+ def _click_group() -> click.Group:
87
+ """主 app 的 click group——命令内省的入口。"""
88
+ if _APP is None: # 理论上 register() 必先于命令执行;防御性报错而非静默
89
+ raise RuntimeError("introspect 未注册到 app")
90
+ return typer.main.get_command(_APP) # type: ignore[return-value]
91
+
92
+
93
+ def _param_to_dict(p: click.Parameter) -> dict[str, Any]:
94
+ """click 参数 → JSON 友好 dict:名称/种类/类型/必填/可选值/默认/帮助。"""
95
+ is_arg = isinstance(p, click.Argument)
96
+ entry: dict[str, Any] = {
97
+ "name": p.name,
98
+ "kind": "argument" if is_arg else "option",
99
+ "type": getattr(p.type, "name", "string"),
100
+ "required": bool(p.required),
101
+ }
102
+ if not is_arg:
103
+ entry["flags"] = list(p.opts) + list(p.secondary_opts or [])
104
+ choices = list(getattr(p.type, "choices", []) or [])
105
+ if choices:
106
+ entry["choices"] = choices
107
+ default = p.default
108
+ if isinstance(default, Enum):
109
+ default = default.value
110
+ if default is not None and not is_arg and isinstance(default, (str, int, float, bool)):
111
+ entry["default"] = default
112
+ help_text = getattr(p, "help", None)
113
+ if help_text:
114
+ entry["help"] = help_text
115
+ return entry
116
+
117
+
118
+ def _command_params(cmd: click.Command) -> list[dict[str, Any]]:
119
+ """命令的全部参数(剔除 click 自动加的 --help)。"""
120
+ return [_param_to_dict(p) for p in cmd.params if p.name != "help"]
121
+
122
+
123
+ def _command_entry(name: str, cmd: click.Command, *, with_params: bool) -> dict[str, Any]:
124
+ """组装单命令的能力描述。META 缺失时按只读安全值兜底。"""
125
+ meta = COMMAND_META.get(name, {})
126
+ # 命令摘要:优先用 META 登记的,回退命令 docstring 首行(纯空白 help 时为空,不取 [0] 防 IndexError)。
127
+ help_lines = (cmd.help or "").strip().splitlines()
128
+ summary = meta.get("summary") or (help_lines[0] if help_lines else "")
129
+ entry = {
130
+ "name": name,
131
+ "summary": summary,
132
+ "side_effects": meta.get("side_effects", ["read"]),
133
+ "destructive": meta.get("destructive", False),
134
+ "idempotent": meta.get("idempotent", True),
135
+ "requires_confirmation": meta.get("requires_confirmation", False),
136
+ }
137
+ if with_params:
138
+ entry["params"] = _command_params(cmd)
139
+ return entry
140
+
141
+
142
+ def _emit(payload: dict[str, Any]) -> None:
143
+ """结构化输出统一走 stdout(机器消费),保证可 `| jq`。"""
144
+ print(_json.dumps(payload, ensure_ascii=False, indent=2))
145
+
146
+
147
+ def capabilities_cmd() -> None:
148
+ """列出所有命令及其副作用/破坏性/幂等元数据(机器可读 JSON)。"""
149
+ group = _click_group()
150
+ commands = [
151
+ _command_entry(name, cmd, with_params=False)
152
+ for name, cmd in sorted(group.commands.items())
153
+ ]
154
+ _emit({
155
+ "schema_version": INTROSPECT_SCHEMA_VERSION,
156
+ "tool": "contract-archive",
157
+ "version": __version__,
158
+ "commands": commands,
159
+ })
160
+
161
+
162
+ def describe_cmd(
163
+ command: str = typer.Argument(..., help="要描述的命令名,如 ingest / list"),
164
+ ) -> None:
165
+ """打印单个命令的参数 schema(名称/类型/必填/默认/可选值/帮助)。"""
166
+ group = _click_group()
167
+ cmd = group.commands.get(command)
168
+ if cmd is None:
169
+ # 未知命令是用户错——提示走 stderr,退出码 2(与 typer 参数错一致)。
170
+ # 列出可选命令(与同模块 schema 对未知 type 的处理对齐),让用户/agent 少一步摸索。
171
+ names = ", ".join(sorted(group.commands))
172
+ typer.echo(
173
+ f"unknown command: {command};可选: {names}(或跑 capabilities 看全部)", err=True
174
+ )
175
+ raise typer.Exit(2)
176
+ entry = _command_entry(command, cmd, with_params=True)
177
+ entry["schema_version"] = INTROSPECT_SCHEMA_VERSION
178
+ entry["help"] = (cmd.help or "").strip()
179
+ _emit(entry)
180
+
181
+
182
+ def schema_cmd(
183
+ type_name: str = typer.Argument(
184
+ ..., help=f"数据结构名,可选:{', '.join(sorted(SCHEMA_TYPES))}"
185
+ ),
186
+ ) -> None:
187
+ """打印核心数据结构的 JSON Schema(pydantic 直出)。"""
188
+ model = SCHEMA_TYPES.get(type_name)
189
+ if model is None:
190
+ typer.echo(
191
+ f"unknown type: {type_name}; available: {', '.join(sorted(SCHEMA_TYPES))}",
192
+ err=True,
193
+ )
194
+ raise typer.Exit(2)
195
+ _emit(model.model_json_schema())
196
+
197
+
198
+ def register(app: typer.Typer) -> None:
199
+ """把 introspection 命令挂到主 app。cli.py 在创建 app 后调用,避免循环 import。"""
200
+ global _APP
201
+ _APP = app
202
+ app.command("capabilities")(capabilities_cmd)
203
+ app.command("describe")(describe_cmd)
204
+ app.command("schema")(schema_cmd)