sdpy-kit 0.6.7__tar.gz → 0.6.8__tar.gz

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 (55) hide show
  1. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/PKG-INFO +79 -11
  2. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/README.md +78 -10
  3. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/base_service.py +10 -0
  5. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/chat_service.py +151 -3
  6. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/failover.py +27 -3
  7. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/pool.py +5 -0
  8. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/profiles.py +88 -2
  9. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/runner.py +90 -11
  10. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/config/kit_config.py +8 -0
  11. sdpy_kit-0.6.8/kit/laya_kit.py +552 -0
  12. sdpy_kit-0.6.7/kit/laya_kit.py +0 -302
  13. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/.gitignore +0 -0
  14. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/__init__.py +0 -0
  15. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/embedding_service.py +0 -0
  16. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/exceptions.py +0 -0
  17. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/image_service.py +0 -0
  18. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/model_builder.py +0 -0
  19. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/model_client.py +0 -0
  20. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/rerank_service.py +0 -0
  21. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/ai/types.py +0 -0
  22. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/common_kit.py +0 -0
  23. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/config/__init__.py +0 -0
  24. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/config/log_config.py +0 -0
  25. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/crawl/__init__.py +0 -0
  26. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/crawl/article_io.py +0 -0
  27. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/crawl/article_models.py +0 -0
  28. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/crawl/deepseek_kit.py +0 -0
  29. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/crawl/web_article_kit.py +0 -0
  30. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/crawl/wx_article_kit.py +0 -0
  31. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/__init__.py +0 -0
  32. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/audit.py +0 -0
  33. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/base_db_kit.py +0 -0
  34. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/crud.py +0 -0
  35. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/engine.py +0 -0
  36. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/exceptions.py +0 -0
  37. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/models/__init__.py +0 -0
  38. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/models/base.py +0 -0
  39. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/mysql_kit.py +0 -0
  40. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/pg_kit.py +0 -0
  41. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/query.py +0 -0
  42. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/session.py +0 -0
  43. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/sqlite_kit.py +0 -0
  44. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/db/transaction.py +0 -0
  45. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/docs/__init__.py +0 -0
  46. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/docs/anydoc_kit.py +0 -0
  47. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/docs/docx_kit.py +0 -0
  48. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/docs/marker_kit.py +0 -0
  49. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/docs/md2doc_kit.py +0 -0
  50. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/docs/mineru_kit.py +0 -0
  51. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/docs/rc_docparser_kit.py +0 -0
  52. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/file_kit.py +0 -0
  53. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/license_kit.py +0 -0
  54. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/kit/redis_kit.py +0 -0
  55. {sdpy_kit-0.6.7 → sdpy_kit-0.6.8}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.6.7
3
+ Version: 0.6.8
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -101,7 +101,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
101
101
  | `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML(含图片) | `md2doc` |
102
102
  | `kit.docs.mineru_kit` | `MinerUKit` | MinerU 文档解析(精准 + Agent 轻量) | `mineru` |
103
103
  | `kit.docs.marker_kit` | `MarkerKit` | 本地解析(PDF / 图片 → Markdown + Images) | `marker` |
104
- | `kit.laya_kit` | `LayaKit` | 类型化决策:choice / score / noul,返回判断结果 + 置信度 | `laya` |
104
+ | `kit.laya_kit` | `LayaKit` | 类型化决策:choice / multi_choice 多选 / score / noul,另含 decide 多字段 / decide_batch 批量 / predict_long 长文本 | `laya` |
105
105
  | `kit.crawl.article_models` | `ArticleInfo` / `ArticleResult` / `SavedOutput` 等 | 文章抓取通用模型(三个 `*ArticleKit` 共用,含 `reason` 分类与非致命 `warnings`) | 无 |
106
106
  | `kit.crawl.article_io` | `resolve_stem` / `save_article` 等 | 落盘公共层(目录命名 / 图片本地化 / YAML 头 / `meta.json`),三个 `*ArticleKit` 共用 | 无 |
107
107
  | `kit.crawl.wx_article_kit` | `WxArticleKit` | 微信公众号文章 → Markdown + 图片转存 | 无 |
@@ -131,7 +131,7 @@ uv add sdpy-kit
131
131
  | `docx-parse` | RcDocParserKit 离线 docx 解析(rc-docparser → Markdown + images/) | `pip install "sdpy-kit[docx-parse]"` |
132
132
  | `mineru` | MinerUKit 解析 API(精准解析 + Agent 轻量解析) | `pip install "sdpy-kit[mineru]"` |
133
133
  | `marker` | MarkerKit 本地解析(marker-pdf → Markdown + Images;PDF + 图片,依赖体积大) | `pip install "sdpy-kit[marker]"` |
134
- | `laya` | LayaKit 类型化决策(laya + torch,本地小模型 choice / score / noul) | `pip install "sdpy-kit[laya]"` |
134
+ | `laya` | LayaKit 类型化决策(laya + torch,本地小模型 choice / multi_choice / score / noul;支持多字段 / 批量 / 长文本) | `pip install "sdpy-kit[laya]"` |
135
135
  | `web-article` | WebArticleKit 通用网页文章抓取转存(trafilatura,纯 HTTP 无浏览器) | `pip install "sdpy-kit[web-article]"` |
136
136
  | `all` | 全部能力 | `pip install "sdpy-kit[all]"` |
137
137
 
@@ -184,6 +184,8 @@ SQLITE_DB_PATH=./data/sqlite.db
184
184
  # LAYA_DEVICE= # 留空由 laya 自动选择
185
185
  # LAYA_HF_ENDPOINT=https://hf-mirror.com
186
186
  # LAYA_HF_DISABLE_XET=true
187
+ # LAYA_PRELOAD= # true 时只预热当前 LAYA_MODEL
188
+ # LAYA_MAX_LOADED= # 同时驻留检查点上限,留空用 laya 默认 2
187
189
  ```
188
190
 
189
191
  ### 环境变量 ↔ 能力对照表
@@ -269,9 +271,18 @@ SQLITE_DB_PATH=./data/sqlite.db
269
271
  | `LAYA_DEVICE` | 无(不写入 config) | 推理设备(`cpu` / `cuda`);不配置则由 laya 自动选择 |
270
272
  | `LAYA_HF_ENDPOINT` | `https://hf-mirror.com` | 检查点权重下载入口(国内镜像);置空则用 HuggingFace 官方源 |
271
273
  | `LAYA_HF_DISABLE_XET` | `true` | 禁用 HF Xet 存储后端(国内镜像不支持,开启会导致权重下载挂起) |
274
+ | `LAYA_PRELOAD` | 无(不写入 config) | `true` 时创建 Router 后预热当前 `LAYA_MODEL`(只预热这一份) |
275
+ | `LAYA_MAX_LOADED` | 无(不写入 config) | Router 同时驻留的检查点上限;不配置则用 laya 默认 2 |
276
+
277
+ > 能力边界:`decide` 的 schema 仅支持 `enum` / `boolean` / 带 `minimum`+`maximum` 的数值字段
278
+ > (数组、嵌套对象、联合类型不支持);`decide` 中的 `score` 投影为最置信档位(argmax),
279
+ > 不是序数期望值;`decide` 返回 `DecisionResult` 的 7 个键,`min_confidence` 低于阈值时字段值为 `None`;
280
+ > 长文本用 `predict_long`,批量用 `decide_batch`(上游没有 `decide_long`)。
281
+ > 多选用 `multi_choice`:每个候选标签独立做 `noul` 判断,返回 0-N 个标签;
282
+ > `threshold`(默认 0.5)由调用方给定且未标定,可能出现 0 个或全部命中,请按 `probabilities` 复核。
272
283
 
273
284
  > `LAYA_HF_*` 同样由 kit 在**导入 laya 之前**经 `os.environ.setdefault` 应用,不覆盖显式配置;
274
- > `LAYA_MODEL` / `LAYA_DEVICE` 由 `Settings` 读取(进程内缓存,改后需重启进程)。
285
+ > `LAYA_MODEL` / `LAYA_DEVICE` / `LAYA_PRELOAD` / `LAYA_MAX_LOADED` 由 `Settings` 读取(进程内缓存,改后需重启进程)。
275
286
 
276
287
  **DeepSeek Search / Fetch** → `kit.crawl.deepseek_kit`
277
288
 
@@ -783,9 +794,9 @@ shutdown() # 应用退出前
783
794
 
784
795
  | 分组 | 方法 | 说明 |
785
796
  |------|------|------|
786
- | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None) -> str` | 单轮对话(仅 user 消息) |
787
- | chat | `chat_ask(messages, *, max_tokens=None, thinking=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
788
- | chat | `chat_stream(messages, *, max_tokens=None, thinking=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` / `thinking_applied` |
797
+ | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 单轮对话(仅 user 消息) |
798
+ | chat | `chat_ask(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
799
+ | chat | `chat_stream(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` / `thinking_applied` / `json_mode_applied` |
789
800
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
790
801
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
791
802
  | rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
@@ -804,9 +815,10 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
804
815
 
805
816
  #### 流式收尾统计(思考型模型必备)
806
817
 
807
- 迭代器在流结束后可读五个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
808
- `truncated` / `thinking_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
809
- token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
818
+ 迭代器在流结束后可读六个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
819
+ `truncated` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
820
+ token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」(同一套字段也可用于非流式 `ask` / `complete`,
821
+ 见下文「结构化输出」):
810
822
 
811
823
  ```python
812
824
  with AI.chat_stream(messages, max_tokens=450) as stream:
@@ -852,6 +864,52 @@ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明
852
864
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
853
865
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
854
866
 
867
+ #### 结构化输出(JSON 模式,0.6.8)
868
+
869
+ 只要「合法 JSON 对象」用 `json_mode=True`;要约束字段用协议的 `response_format` 原样透传:
870
+
871
+ ```python
872
+ answer = AI.chat_ask(messages, json_mode=True) # 要求返回 JSON 对象
873
+
874
+ fmt = {
875
+ "type": "json_schema",
876
+ "json_schema": {
877
+ "name": "extract",
878
+ "strict": True,
879
+ "schema": {
880
+ "type": "object",
881
+ "properties": {"tags": {"type": "array", "items": {"type": "string"}}},
882
+ "required": ["tags"],
883
+ "additionalProperties": False,
884
+ },
885
+ },
886
+ }
887
+ answer = AI.chat_ask(messages, response_format=fmt) # 严格模式:schema 由你负责
888
+ ```
889
+
890
+ - **缺省即注入(开箱即用)**:档案未声明 `structured_output` 时,`json_mode=True` 按 OpenAI 兼容默认写
891
+ `{"response_format": {"type": "json_object"}}`;**实测不支持该字段的端点必须在档案写
892
+ `"structured_output": false`**,否则会 400 并触发冷却。
893
+ - **kit 不校验 JSON**:`json_mode=True` 只是「要求」,返回可能仍是散文或被截断的 JSON;schema 的生成、
894
+ 校验、修补都不在 kit(`response_format` 写错即端点 400)。
895
+ - **互斥**:`json_mode` 与 `response_format` 同时给 → `ValueError`;`json_mode=False` 与 `None` 等效(不注入)。
896
+ - **跨候选降级**:切到已写 `false` 的备胎时本次调用退回普通文本,只在日志与 `json_mode_applied` 中体现。
897
+ - **`stream=True` 兼容性未知**:部分端点会静默忽略 `response_format`,kit 不探测;流式 JSON 只能是半成品,kit 不做增量解析。
898
+
899
+ `json_mode_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求注入 / `True` 已注入 / `False` 要求了但该档案已关闭。
900
+
901
+ **非流式收尾统计**:`ask` / `complete` 也接受 `stats=StreamStats()`(类名沿用历史,流式与非流式共用同一累加器),
902
+ 可读 `finish_reason` / `truncated` / `content_chars` / `reasoning_chars` / `json_mode_applied`:
903
+
904
+ ```python
905
+ from kit.ai import StreamStats
906
+
907
+ stats = StreamStats()
908
+ body = AI.chat_ask(messages, json_mode=True, stats=stats)
909
+ if stats.truncated: # finish_reason 为 length / max_tokens
910
+ ... # 半截 JSON:直接重试或调大 max_tokens,不要尝试修复
911
+ ```
912
+
855
913
  > **非流式(`ask` / `complete`)的同款诊断**:provider 在截断时可能完全不返回 `content` 键,
856
914
  > 此时 kit 抛 `AIResponseError`,消息里带 `finish_reason` 与推理字符数(不再只报「content
857
915
  > 类型异常」,可直接判断是否被思考吃光);正文为空串时返回 `""` 并打 `WARNING`。
@@ -876,7 +934,7 @@ await service.close()
876
934
 
877
935
  | 类 | 方法 |
878
936
  |----|------|
879
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, thinking=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., thinking=None, stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计);`thinking` 语义与门面一致 |
937
+ | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats: StreamStats \| None = None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats \| None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计);`thinking` / `json_mode` / `response_format` 语义与门面一致 |
880
938
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
881
939
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
882
940
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1411,6 +1469,7 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1411
1469
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1412
1470
  | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。逐调用 `thinking` 与之同键时**以逐调用为准**并打 `WARNING`。改文件后需 `reload_config(reload_file=True)` 生效 |
1413
1471
  | 档案级 | `thinking` | 思考开关的注入声明(0.6.7):`{"arg": "thinking", "off": {"type": "disabled"}, "on": {"type": "enabled"}, "default": false}`。`arg` 是请求体顶层键名(不得占用 `model` / `messages` / `stream` / `max_tokens` / `temperature` 等保留键,配置期与运行期双重校验);`off` / `on` 可为**任意 JSON 值**(双方都是 dict 时浅合并,否则整键替换);`on` 缺省表示不能显式开启;`default` 为档案默认三态(`null` 不干预 / `false` 默认关 / `true` 默认开)。**未声明 = 不支持按调用切换**:逐调用 `thinking` 被忽略并告警(不报错、不影响候选链冷却)。kit 只做翻译,不含任何厂商名 |
1472
+ | 档案级 | `structured_output` | 结构化输出的注入声明(0.6.8):`{"arg": "response_format", "json_mode": {"type": "json_object"}}`。`arg` 是请求体顶层键名(不得占用保留键);`json_mode` 是 `json_mode=True` 时写入该键的取值。**未声明 = 用内置 OpenAI 兼容默认**(`response_format: {"type":"json_object"}`,开箱即用);`"structured_output": false` 表示该档案不支持(`json_mode=True` 被忽略 + 告警,不发任何字段)。逐调用 `response_format` 原样透传优先于本声明。kit 只做翻译,不含任何厂商名 |
1414
1473
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1415
1474
 
1416
1475
  `thinking` 声明两种实测形态(按平台各自写入档案):
@@ -1424,10 +1483,19 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1424
1483
  - **强制思考模型**(如只接受 `low/high/max` 的平台)不写 `thinking` 声明,逐调用开关会被忽略并告警;
1425
1484
  - 声明写错(`arg` 拼错、值形态不符)由厂商判定,可能 400 → 冷却;语义正确性由使用方的模型能力清单保证。
1426
1485
 
1486
+ `structured_output` 声明与关闭形态:
1487
+
1488
+ ```json
1489
+ "structured_output": {"arg": "response_format", "json_mode": {"type": "json_object"}}
1490
+ "structured_output": false // 该端点实测不支持:json_mode=True 时忽略并告警
1491
+ ```
1492
+
1427
1493
  > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1428
1494
  > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1429
1495
  > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1430
1496
  > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1497
+ > 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
1498
+ > 逐调用 `response_format`(0.6.8 起)。
1431
1499
 
1432
1500
  ### 候选链切换语义(两类,勿混淆)
1433
1501
 
@@ -25,7 +25,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
25
25
  | `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML(含图片) | `md2doc` |
26
26
  | `kit.docs.mineru_kit` | `MinerUKit` | MinerU 文档解析(精准 + Agent 轻量) | `mineru` |
27
27
  | `kit.docs.marker_kit` | `MarkerKit` | 本地解析(PDF / 图片 → Markdown + Images) | `marker` |
28
- | `kit.laya_kit` | `LayaKit` | 类型化决策:choice / score / noul,返回判断结果 + 置信度 | `laya` |
28
+ | `kit.laya_kit` | `LayaKit` | 类型化决策:choice / multi_choice 多选 / score / noul,另含 decide 多字段 / decide_batch 批量 / predict_long 长文本 | `laya` |
29
29
  | `kit.crawl.article_models` | `ArticleInfo` / `ArticleResult` / `SavedOutput` 等 | 文章抓取通用模型(三个 `*ArticleKit` 共用,含 `reason` 分类与非致命 `warnings`) | 无 |
30
30
  | `kit.crawl.article_io` | `resolve_stem` / `save_article` 等 | 落盘公共层(目录命名 / 图片本地化 / YAML 头 / `meta.json`),三个 `*ArticleKit` 共用 | 无 |
31
31
  | `kit.crawl.wx_article_kit` | `WxArticleKit` | 微信公众号文章 → Markdown + 图片转存 | 无 |
@@ -55,7 +55,7 @@ uv add sdpy-kit
55
55
  | `docx-parse` | RcDocParserKit 离线 docx 解析(rc-docparser → Markdown + images/) | `pip install "sdpy-kit[docx-parse]"` |
56
56
  | `mineru` | MinerUKit 解析 API(精准解析 + Agent 轻量解析) | `pip install "sdpy-kit[mineru]"` |
57
57
  | `marker` | MarkerKit 本地解析(marker-pdf → Markdown + Images;PDF + 图片,依赖体积大) | `pip install "sdpy-kit[marker]"` |
58
- | `laya` | LayaKit 类型化决策(laya + torch,本地小模型 choice / score / noul) | `pip install "sdpy-kit[laya]"` |
58
+ | `laya` | LayaKit 类型化决策(laya + torch,本地小模型 choice / multi_choice / score / noul;支持多字段 / 批量 / 长文本) | `pip install "sdpy-kit[laya]"` |
59
59
  | `web-article` | WebArticleKit 通用网页文章抓取转存(trafilatura,纯 HTTP 无浏览器) | `pip install "sdpy-kit[web-article]"` |
60
60
  | `all` | 全部能力 | `pip install "sdpy-kit[all]"` |
61
61
 
@@ -108,6 +108,8 @@ SQLITE_DB_PATH=./data/sqlite.db
108
108
  # LAYA_DEVICE= # 留空由 laya 自动选择
109
109
  # LAYA_HF_ENDPOINT=https://hf-mirror.com
110
110
  # LAYA_HF_DISABLE_XET=true
111
+ # LAYA_PRELOAD= # true 时只预热当前 LAYA_MODEL
112
+ # LAYA_MAX_LOADED= # 同时驻留检查点上限,留空用 laya 默认 2
111
113
  ```
112
114
 
113
115
  ### 环境变量 ↔ 能力对照表
@@ -193,9 +195,18 @@ SQLITE_DB_PATH=./data/sqlite.db
193
195
  | `LAYA_DEVICE` | 无(不写入 config) | 推理设备(`cpu` / `cuda`);不配置则由 laya 自动选择 |
194
196
  | `LAYA_HF_ENDPOINT` | `https://hf-mirror.com` | 检查点权重下载入口(国内镜像);置空则用 HuggingFace 官方源 |
195
197
  | `LAYA_HF_DISABLE_XET` | `true` | 禁用 HF Xet 存储后端(国内镜像不支持,开启会导致权重下载挂起) |
198
+ | `LAYA_PRELOAD` | 无(不写入 config) | `true` 时创建 Router 后预热当前 `LAYA_MODEL`(只预热这一份) |
199
+ | `LAYA_MAX_LOADED` | 无(不写入 config) | Router 同时驻留的检查点上限;不配置则用 laya 默认 2 |
200
+
201
+ > 能力边界:`decide` 的 schema 仅支持 `enum` / `boolean` / 带 `minimum`+`maximum` 的数值字段
202
+ > (数组、嵌套对象、联合类型不支持);`decide` 中的 `score` 投影为最置信档位(argmax),
203
+ > 不是序数期望值;`decide` 返回 `DecisionResult` 的 7 个键,`min_confidence` 低于阈值时字段值为 `None`;
204
+ > 长文本用 `predict_long`,批量用 `decide_batch`(上游没有 `decide_long`)。
205
+ > 多选用 `multi_choice`:每个候选标签独立做 `noul` 判断,返回 0-N 个标签;
206
+ > `threshold`(默认 0.5)由调用方给定且未标定,可能出现 0 个或全部命中,请按 `probabilities` 复核。
196
207
 
197
208
  > `LAYA_HF_*` 同样由 kit 在**导入 laya 之前**经 `os.environ.setdefault` 应用,不覆盖显式配置;
198
- > `LAYA_MODEL` / `LAYA_DEVICE` 由 `Settings` 读取(进程内缓存,改后需重启进程)。
209
+ > `LAYA_MODEL` / `LAYA_DEVICE` / `LAYA_PRELOAD` / `LAYA_MAX_LOADED` 由 `Settings` 读取(进程内缓存,改后需重启进程)。
199
210
 
200
211
  **DeepSeek Search / Fetch** → `kit.crawl.deepseek_kit`
201
212
 
@@ -707,9 +718,9 @@ shutdown() # 应用退出前
707
718
 
708
719
  | 分组 | 方法 | 说明 |
709
720
  |------|------|------|
710
- | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None) -> str` | 单轮对话(仅 user 消息) |
711
- | chat | `chat_ask(messages, *, max_tokens=None, thinking=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
712
- | chat | `chat_stream(messages, *, max_tokens=None, thinking=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` / `thinking_applied` |
721
+ | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 单轮对话(仅 user 消息) |
722
+ | chat | `chat_ask(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
723
+ | chat | `chat_stream(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` / `thinking_applied` / `json_mode_applied` |
713
724
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
714
725
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
715
726
  | rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
@@ -728,9 +739,10 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
728
739
 
729
740
  #### 流式收尾统计(思考型模型必备)
730
741
 
731
- 迭代器在流结束后可读五个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
732
- `truncated` / `thinking_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
733
- token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
742
+ 迭代器在流结束后可读六个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
743
+ `truncated` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
744
+ token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」(同一套字段也可用于非流式 `ask` / `complete`,
745
+ 见下文「结构化输出」):
734
746
 
735
747
  ```python
736
748
  with AI.chat_stream(messages, max_tokens=450) as stream:
@@ -776,6 +788,52 @@ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明
776
788
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
777
789
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
778
790
 
791
+ #### 结构化输出(JSON 模式,0.6.8)
792
+
793
+ 只要「合法 JSON 对象」用 `json_mode=True`;要约束字段用协议的 `response_format` 原样透传:
794
+
795
+ ```python
796
+ answer = AI.chat_ask(messages, json_mode=True) # 要求返回 JSON 对象
797
+
798
+ fmt = {
799
+ "type": "json_schema",
800
+ "json_schema": {
801
+ "name": "extract",
802
+ "strict": True,
803
+ "schema": {
804
+ "type": "object",
805
+ "properties": {"tags": {"type": "array", "items": {"type": "string"}}},
806
+ "required": ["tags"],
807
+ "additionalProperties": False,
808
+ },
809
+ },
810
+ }
811
+ answer = AI.chat_ask(messages, response_format=fmt) # 严格模式:schema 由你负责
812
+ ```
813
+
814
+ - **缺省即注入(开箱即用)**:档案未声明 `structured_output` 时,`json_mode=True` 按 OpenAI 兼容默认写
815
+ `{"response_format": {"type": "json_object"}}`;**实测不支持该字段的端点必须在档案写
816
+ `"structured_output": false`**,否则会 400 并触发冷却。
817
+ - **kit 不校验 JSON**:`json_mode=True` 只是「要求」,返回可能仍是散文或被截断的 JSON;schema 的生成、
818
+ 校验、修补都不在 kit(`response_format` 写错即端点 400)。
819
+ - **互斥**:`json_mode` 与 `response_format` 同时给 → `ValueError`;`json_mode=False` 与 `None` 等效(不注入)。
820
+ - **跨候选降级**:切到已写 `false` 的备胎时本次调用退回普通文本,只在日志与 `json_mode_applied` 中体现。
821
+ - **`stream=True` 兼容性未知**:部分端点会静默忽略 `response_format`,kit 不探测;流式 JSON 只能是半成品,kit 不做增量解析。
822
+
823
+ `json_mode_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求注入 / `True` 已注入 / `False` 要求了但该档案已关闭。
824
+
825
+ **非流式收尾统计**:`ask` / `complete` 也接受 `stats=StreamStats()`(类名沿用历史,流式与非流式共用同一累加器),
826
+ 可读 `finish_reason` / `truncated` / `content_chars` / `reasoning_chars` / `json_mode_applied`:
827
+
828
+ ```python
829
+ from kit.ai import StreamStats
830
+
831
+ stats = StreamStats()
832
+ body = AI.chat_ask(messages, json_mode=True, stats=stats)
833
+ if stats.truncated: # finish_reason 为 length / max_tokens
834
+ ... # 半截 JSON:直接重试或调大 max_tokens,不要尝试修复
835
+ ```
836
+
779
837
  > **非流式(`ask` / `complete`)的同款诊断**:provider 在截断时可能完全不返回 `content` 键,
780
838
  > 此时 kit 抛 `AIResponseError`,消息里带 `finish_reason` 与推理字符数(不再只报「content
781
839
  > 类型异常」,可直接判断是否被思考吃光);正文为空串时返回 `""` 并打 `WARNING`。
@@ -800,7 +858,7 @@ await service.close()
800
858
 
801
859
  | 类 | 方法 |
802
860
  |----|------|
803
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, thinking=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., thinking=None, stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计);`thinking` 语义与门面一致 |
861
+ | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats: StreamStats \| None = None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats \| None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计);`thinking` / `json_mode` / `response_format` 语义与门面一致 |
804
862
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
805
863
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
806
864
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1335,6 +1393,7 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1335
1393
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1336
1394
  | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。逐调用 `thinking` 与之同键时**以逐调用为准**并打 `WARNING`。改文件后需 `reload_config(reload_file=True)` 生效 |
1337
1395
  | 档案级 | `thinking` | 思考开关的注入声明(0.6.7):`{"arg": "thinking", "off": {"type": "disabled"}, "on": {"type": "enabled"}, "default": false}`。`arg` 是请求体顶层键名(不得占用 `model` / `messages` / `stream` / `max_tokens` / `temperature` 等保留键,配置期与运行期双重校验);`off` / `on` 可为**任意 JSON 值**(双方都是 dict 时浅合并,否则整键替换);`on` 缺省表示不能显式开启;`default` 为档案默认三态(`null` 不干预 / `false` 默认关 / `true` 默认开)。**未声明 = 不支持按调用切换**:逐调用 `thinking` 被忽略并告警(不报错、不影响候选链冷却)。kit 只做翻译,不含任何厂商名 |
1396
+ | 档案级 | `structured_output` | 结构化输出的注入声明(0.6.8):`{"arg": "response_format", "json_mode": {"type": "json_object"}}`。`arg` 是请求体顶层键名(不得占用保留键);`json_mode` 是 `json_mode=True` 时写入该键的取值。**未声明 = 用内置 OpenAI 兼容默认**(`response_format: {"type":"json_object"}`,开箱即用);`"structured_output": false` 表示该档案不支持(`json_mode=True` 被忽略 + 告警,不发任何字段)。逐调用 `response_format` 原样透传优先于本声明。kit 只做翻译,不含任何厂商名 |
1338
1397
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1339
1398
 
1340
1399
  `thinking` 声明两种实测形态(按平台各自写入档案):
@@ -1348,10 +1407,19 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1348
1407
  - **强制思考模型**(如只接受 `low/high/max` 的平台)不写 `thinking` 声明,逐调用开关会被忽略并告警;
1349
1408
  - 声明写错(`arg` 拼错、值形态不符)由厂商判定,可能 400 → 冷却;语义正确性由使用方的模型能力清单保证。
1350
1409
 
1410
+ `structured_output` 声明与关闭形态:
1411
+
1412
+ ```json
1413
+ "structured_output": {"arg": "response_format", "json_mode": {"type": "json_object"}}
1414
+ "structured_output": false // 该端点实测不支持:json_mode=True 时忽略并告警
1415
+ ```
1416
+
1351
1417
  > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1352
1418
  > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1353
1419
  > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1354
1420
  > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1421
+ > 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
1422
+ > 逐调用 `response_format`(0.6.8 起)。
1355
1423
 
1356
1424
  ### 候选链切换语义(两类,勿混淆)
1357
1425
 
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.6.7"
26
+ __version__ = "0.6.8"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -140,6 +140,15 @@ class BaseAIService[RequestT, ResponseT](ABC):
140
140
  def _parse_response(self, data: JSONObject) -> ResponseT:
141
141
  """将 JSON 响应体解析为业务结果。"""
142
142
 
143
+ def _collect_meta(self, data: JSONObject, request: RequestT) -> None:
144
+ """收尾元信息钩子(默认无操作)。
145
+
146
+ 在 :meth:`_parse_response` **之前**调用:解析可能抛错(如输出被截断到
147
+ 没有正文),元信息(``finish_reason`` 等)需先落地;子类可写入
148
+ ``request.stats``。
149
+ """
150
+ return None
151
+
143
152
  async def _call(
144
153
  self, request: RequestT, *, deadline: float | None = None
145
154
  ) -> ResponseT:
@@ -162,6 +171,7 @@ class BaseAIService[RequestT, ResponseT](ABC):
162
171
  start = time.perf_counter()
163
172
  try:
164
173
  data = await self._client.post_json(payload, deadline=deadline)
174
+ self._collect_meta(data, request)
165
175
  result = self._parse_response(data)
166
176
  except AIError as exc:
167
177
  logger.error(