sdpy-kit 0.6.6__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 (56) hide show
  1. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/PKG-INFO +120 -18
  2. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/README.md +119 -17
  3. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/base_service.py +27 -1
  5. sdpy_kit-0.6.8/kit/ai/chat_service.py +528 -0
  6. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/failover.py +37 -2
  7. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/pool.py +7 -0
  8. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/profiles.py +166 -2
  9. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/runner.py +140 -14
  10. {sdpy_kit-0.6.6 → 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.6/kit/ai/chat_service.py +0 -266
  13. sdpy_kit-0.6.6/kit/laya_kit.py +0 -302
  14. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/.gitignore +0 -0
  15. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/__init__.py +0 -0
  16. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/embedding_service.py +0 -0
  17. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/exceptions.py +0 -0
  18. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/image_service.py +0 -0
  19. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/model_builder.py +0 -0
  20. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/model_client.py +0 -0
  21. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/rerank_service.py +0 -0
  22. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/ai/types.py +0 -0
  23. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/common_kit.py +0 -0
  24. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/config/__init__.py +0 -0
  25. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/config/log_config.py +0 -0
  26. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/crawl/__init__.py +0 -0
  27. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/crawl/article_io.py +0 -0
  28. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/crawl/article_models.py +0 -0
  29. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/crawl/deepseek_kit.py +0 -0
  30. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/crawl/web_article_kit.py +0 -0
  31. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/crawl/wx_article_kit.py +0 -0
  32. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/__init__.py +0 -0
  33. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/audit.py +0 -0
  34. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/base_db_kit.py +0 -0
  35. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/crud.py +0 -0
  36. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/engine.py +0 -0
  37. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/exceptions.py +0 -0
  38. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/models/__init__.py +0 -0
  39. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/models/base.py +0 -0
  40. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/mysql_kit.py +0 -0
  41. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/pg_kit.py +0 -0
  42. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/query.py +0 -0
  43. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/session.py +0 -0
  44. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/sqlite_kit.py +0 -0
  45. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/db/transaction.py +0 -0
  46. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/docs/__init__.py +0 -0
  47. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/docs/anydoc_kit.py +0 -0
  48. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/docs/docx_kit.py +0 -0
  49. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/docs/marker_kit.py +0 -0
  50. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/docs/md2doc_kit.py +0 -0
  51. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/docs/mineru_kit.py +0 -0
  52. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/docs/rc_docparser_kit.py +0 -0
  53. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/file_kit.py +0 -0
  54. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/license_kit.py +0 -0
  55. {sdpy_kit-0.6.6 → sdpy_kit-0.6.8}/kit/redis_kit.py +0 -0
  56. {sdpy_kit-0.6.6 → 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.6
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) -> str` | 单轮对话(仅 user 消息) |
787
- | chat | `chat_ask(messages, *, max_tokens=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
788
- | chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
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`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
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:
@@ -814,7 +826,7 @@ with AI.chat_stream(messages, max_tokens=450) as stream:
814
826
  if not body and stream.truncated and stream.reasoning_chars:
815
827
  raise ValueError(
816
828
  f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
817
- "请调大 max_tokens 或改用关闭思考的档案"
829
+ "请调大 max_tokens 或对该调用传 thinking=False"
818
830
  )
819
831
  ```
820
832
 
@@ -826,9 +838,78 @@ if not body and stream.truncated and stream.reasoning_chars:
826
838
  - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
827
839
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
828
840
 
841
+ #### 逐调用思考开关(0.6.7)
842
+
843
+ 同一档案下按调用点切换思考(入库抽取关、章节生成开),不必复制档案:
844
+
845
+ ```python
846
+ AI.chat_ask(messages, thinking=False) # 关闭思考
847
+ AI.chat_ask(messages, thinking=True) # 开启思考
848
+ AI.chat_ask(messages) # None:不干预(按档案 thinking.default;未配置则请求体逐字节不变)
849
+ ```
850
+
851
+ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明翻译(见「ai_models.json 可选字段」)。
852
+ 三条局限:
853
+
854
+ - **未声明即忽略**:档案没有 `thinking` 声明时,逐调用 `thinking` 被忽略并打 `WARNING`(不报错、不触发冷却),调用照常成功;
855
+ - **跨候选降级**:候选链切到未声明的备胎时,本次调用退回厂商默认(通常是开),只在日志与 `thinking_applied` 中体现;
856
+ - **不提供档位**:只有开/关;`low` / `medium` / `high` 这类分级本轮不支持(可用 `on` 直接给厂商取值)。
857
+
858
+ `thinking_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求干预 / `True` 已按声明注入 / `False` 要求了但被忽略。
859
+
860
+ > 这是对 0.6.5「不新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」结论的**有意修订**:
861
+ > 当时的否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
862
+ > 字段只发给声明了它的候选,未声明者不注入。思考**档位**(`low/medium/high`)仍然不做。
863
+
829
864
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
830
865
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
831
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
+
832
913
  > **非流式(`ask` / `complete`)的同款诊断**:provider 在截断时可能完全不返回 `content` 键,
833
914
  > 此时 kit 抛 `AIResponseError`,消息里带 `finish_reason` 与推理字符数(不再只报「content
834
915
  > 类型异常」,可直接判断是否被思考吃光);正文为空串时返回 `""` 并打 `WARNING`。
@@ -853,7 +934,7 @@ await service.close()
853
934
 
854
935
  | 类 | 方法 |
855
936
  |----|------|
856
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
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` 语义与门面一致 |
857
938
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
858
939
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
859
940
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1386,14 +1467,35 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1386
1467
  | 档案级 | `timeout` | 请求超时秒数(默认 60) |
1387
1468
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1388
1469
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1389
- | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。改文件后需 `reload_config(reload_file=True)` 生效 |
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)` 生效 |
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 只做翻译,不含任何厂商名 |
1390
1473
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1391
1474
 
1392
- > **`extra_body` 的用法边界(档案级开关的坑)**:`AI.chat_* / chat_stream` 固定走**当前候选链头**,
1393
- > 档案级 `extra_body`(如关闭思考)无法按调用点切换。需要「同一模型,长文开思考、短文关思考」
1394
- > 并存时,在 `ai_models.json` 里**复制一份档案**(同 `model`、多加 `extra_body`),再用
1395
- > `ModelBuilder.build_chat("<档案名>")` 取独立异步服务;门面上只能 `AI.use("chat", "<档案名>")`
1396
- > 临时切换(进程级、非并发安全)。
1475
+ `thinking` 声明两种实测形态(按平台各自写入档案):
1476
+
1477
+ ```json
1478
+ "thinking": {"arg": "thinking", "off": {"type": "disabled"}}
1479
+ "thinking": {"arg": "chat_template_kwargs", "off": {"enable_thinking": false}}
1480
+ ```
1481
+
1482
+ - **未知字段的厂商反应不一致**:有的返回 400、有的**静默忽略**(仍照常思考)。所以 kit 只按声明下发,绝不猜厂商默认值;
1483
+ - **强制思考模型**(如只接受 `low/high/max` 的平台)不写 `thinking` 声明,逐调用开关会被忽略并告警;
1484
+ - 声明写错(`arg` 拼错、值形态不符)由厂商判定,可能 400 → 冷却;语义正确性由使用方的模型能力清单保证。
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
+
1493
+ > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1494
+ > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1495
+ > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1496
+ > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1497
+ > 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
1498
+ > 逐调用 `response_format`(0.6.8 起)。
1397
1499
 
1398
1500
  ### 候选链切换语义(两类,勿混淆)
1399
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) -> str` | 单轮对话(仅 user 消息) |
711
- | chat | `chat_ask(messages, *, max_tokens=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
712
- | chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
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`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
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:
@@ -738,7 +750,7 @@ with AI.chat_stream(messages, max_tokens=450) as stream:
738
750
  if not body and stream.truncated and stream.reasoning_chars:
739
751
  raise ValueError(
740
752
  f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
741
- "请调大 max_tokens 或改用关闭思考的档案"
753
+ "请调大 max_tokens 或对该调用传 thinking=False"
742
754
  )
743
755
  ```
744
756
 
@@ -750,9 +762,78 @@ if not body and stream.truncated and stream.reasoning_chars:
750
762
  - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
751
763
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
752
764
 
765
+ #### 逐调用思考开关(0.6.7)
766
+
767
+ 同一档案下按调用点切换思考(入库抽取关、章节生成开),不必复制档案:
768
+
769
+ ```python
770
+ AI.chat_ask(messages, thinking=False) # 关闭思考
771
+ AI.chat_ask(messages, thinking=True) # 开启思考
772
+ AI.chat_ask(messages) # None:不干预(按档案 thinking.default;未配置则请求体逐字节不变)
773
+ ```
774
+
775
+ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明翻译(见「ai_models.json 可选字段」)。
776
+ 三条局限:
777
+
778
+ - **未声明即忽略**:档案没有 `thinking` 声明时,逐调用 `thinking` 被忽略并打 `WARNING`(不报错、不触发冷却),调用照常成功;
779
+ - **跨候选降级**:候选链切到未声明的备胎时,本次调用退回厂商默认(通常是开),只在日志与 `thinking_applied` 中体现;
780
+ - **不提供档位**:只有开/关;`low` / `medium` / `high` 这类分级本轮不支持(可用 `on` 直接给厂商取值)。
781
+
782
+ `thinking_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求干预 / `True` 已按声明注入 / `False` 要求了但被忽略。
783
+
784
+ > 这是对 0.6.5「不新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」结论的**有意修订**:
785
+ > 当时的否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
786
+ > 字段只发给声明了它的候选,未声明者不注入。思考**档位**(`low/medium/high`)仍然不做。
787
+
753
788
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
754
789
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
755
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
+
756
837
  > **非流式(`ask` / `complete`)的同款诊断**:provider 在截断时可能完全不返回 `content` 键,
757
838
  > 此时 kit 抛 `AIResponseError`,消息里带 `finish_reason` 与推理字符数(不再只报「content
758
839
  > 类型异常」,可直接判断是否被思考吃光);正文为空串时返回 `""` 并打 `WARNING`。
@@ -777,7 +858,7 @@ await service.close()
777
858
 
778
859
  | 类 | 方法 |
779
860
  |----|------|
780
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
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` 语义与门面一致 |
781
862
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
782
863
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
783
864
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1310,14 +1391,35 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1310
1391
  | 档案级 | `timeout` | 请求超时秒数(默认 60) |
1311
1392
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1312
1393
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1313
- | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。改文件后需 `reload_config(reload_file=True)` 生效 |
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)` 生效 |
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 只做翻译,不含任何厂商名 |
1314
1397
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1315
1398
 
1316
- > **`extra_body` 的用法边界(档案级开关的坑)**:`AI.chat_* / chat_stream` 固定走**当前候选链头**,
1317
- > 档案级 `extra_body`(如关闭思考)无法按调用点切换。需要「同一模型,长文开思考、短文关思考」
1318
- > 并存时,在 `ai_models.json` 里**复制一份档案**(同 `model`、多加 `extra_body`),再用
1319
- > `ModelBuilder.build_chat("<档案名>")` 取独立异步服务;门面上只能 `AI.use("chat", "<档案名>")`
1320
- > 临时切换(进程级、非并发安全)。
1399
+ `thinking` 声明两种实测形态(按平台各自写入档案):
1400
+
1401
+ ```json
1402
+ "thinking": {"arg": "thinking", "off": {"type": "disabled"}}
1403
+ "thinking": {"arg": "chat_template_kwargs", "off": {"enable_thinking": false}}
1404
+ ```
1405
+
1406
+ - **未知字段的厂商反应不一致**:有的返回 400、有的**静默忽略**(仍照常思考)。所以 kit 只按声明下发,绝不猜厂商默认值;
1407
+ - **强制思考模型**(如只接受 `low/high/max` 的平台)不写 `thinking` 声明,逐调用开关会被忽略并告警;
1408
+ - 声明写错(`arg` 拼错、值形态不符)由厂商判定,可能 400 → 冷却;语义正确性由使用方的模型能力清单保证。
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
+
1417
+ > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1418
+ > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1419
+ > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1420
+ > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1421
+ > 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
1422
+ > 逐调用 `response_format`(0.6.8 起)。
1321
1423
 
1322
1424
  ### 候选链切换语义(两类,勿混淆)
1323
1425
 
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.6.6"
26
+ __version__ = "0.6.8"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -115,6 +115,9 @@ class BaseAIService[RequestT, ResponseT](ABC):
115
115
  同名键以服务构造的值为准——``model`` / ``messages`` / ``stream`` /
116
116
  ``input`` / ``prompt`` 等保留键**不可被档案覆盖**,故无需白名单;
117
117
  未配置时原样返回(请求体逐字节不变)。
118
+
119
+ 优先级:本方法之后还会执行 :meth:`_apply_request_extras`(逐调用
120
+ 增强),故同一键若被逐调用显式覆盖,以逐调用为准(更具体者优先)。
118
121
  """
119
122
  extra = self._profile.extra_body
120
123
  if not extra:
@@ -122,10 +125,30 @@ class BaseAIService[RequestT, ResponseT](ABC):
122
125
  merged: dict[str, object] = {**extra, **payload}
123
126
  return cast(JSONObject, merged)
124
127
 
128
+ def _apply_request_extras(
129
+ self, payload: JSONObject, request: RequestT
130
+ ) -> JSONObject:
131
+ """逐调用请求增强钩子(默认无操作:请求体逐字节不变)。
132
+
133
+ 在 :meth:`_apply_extra_body` 之后执行,用于按本次调用参数改写请求体
134
+ (如 ``ChatService`` 的思考开关)。子类按需覆写;覆写方须自行保证
135
+ 不覆盖 ``_build_payload`` 产出的保留键。
136
+ """
137
+ return payload
138
+
125
139
  @abstractmethod
126
140
  def _parse_response(self, data: JSONObject) -> ResponseT:
127
141
  """将 JSON 响应体解析为业务结果。"""
128
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
+
129
152
  async def _call(
130
153
  self, request: RequestT, *, deadline: float | None = None
131
154
  ) -> ResponseT:
@@ -137,7 +160,9 @@ class BaseAIService[RequestT, ResponseT](ABC):
137
160
  ``post_json`` 的重试预算检查;None 表示不限制
138
161
  """
139
162
  desc = self._log_desc()
140
- payload = self._apply_extra_body(self._build_payload(request))
163
+ payload = self._apply_request_extras(
164
+ self._apply_extra_body(self._build_payload(request)), request
165
+ )
141
166
  params = _truncate(
142
167
  json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
143
168
  )
@@ -146,6 +171,7 @@ class BaseAIService[RequestT, ResponseT](ABC):
146
171
  start = time.perf_counter()
147
172
  try:
148
173
  data = await self._client.post_json(payload, deadline=deadline)
174
+ self._collect_meta(data, request)
149
175
  result = self._parse_response(data)
150
176
  except AIError as exc:
151
177
  logger.error(