sdpy-kit 0.7.0__tar.gz → 0.7.1__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 (58) hide show
  1. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/PKG-INFO +55 -28
  2. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/README.md +54 -27
  3. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/chat_service.py +110 -11
  5. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/exceptions.py +10 -0
  6. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/model_builder.py +7 -5
  7. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/model_client.py +5 -22
  8. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/pool.py +16 -3
  9. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/profiles.py +29 -12
  10. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/rerank_service.py +7 -1
  11. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/rerank_transport.py +1 -1
  12. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/runner.py +19 -9
  13. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/docs/md2doc_kit.py +1 -10
  14. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/.gitignore +0 -0
  15. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/__init__.py +0 -0
  16. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/_retry_after.py +0 -0
  17. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/_service_base.py +0 -0
  18. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/embedding_service.py +0 -0
  19. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/failover.py +0 -0
  20. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/image_service.py +0 -0
  21. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/sdk_client.py +0 -0
  22. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/ai/types.py +0 -0
  23. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/common_kit.py +0 -0
  24. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/config/__init__.py +0 -0
  25. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/config/kit_config.py +0 -0
  26. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/config/log_config.py +0 -0
  27. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/crawl/__init__.py +0 -0
  28. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/crawl/article_io.py +0 -0
  29. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/crawl/article_models.py +0 -0
  30. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/crawl/deepseek_kit.py +0 -0
  31. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/crawl/web_article_kit.py +0 -0
  32. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/crawl/wx_article_kit.py +0 -0
  33. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/__init__.py +0 -0
  34. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/audit.py +0 -0
  35. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/base_db_kit.py +0 -0
  36. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/crud.py +0 -0
  37. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/engine.py +0 -0
  38. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/exceptions.py +0 -0
  39. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/models/__init__.py +0 -0
  40. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/models/base.py +0 -0
  41. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/mysql_kit.py +0 -0
  42. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/pg_kit.py +0 -0
  43. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/query.py +0 -0
  44. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/session.py +0 -0
  45. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/sqlite_kit.py +0 -0
  46. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/db/transaction.py +0 -0
  47. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/decima_kit.py +0 -0
  48. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/docs/__init__.py +0 -0
  49. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/docs/anydoc_kit.py +0 -0
  50. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/docs/docx_kit.py +0 -0
  51. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/docs/marker_kit.py +0 -0
  52. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/docs/mineru_kit.py +0 -0
  53. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/docs/rc_docparser_kit.py +0 -0
  54. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/file_kit.py +0 -0
  55. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/laya_kit.py +0 -0
  56. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/license_kit.py +0 -0
  57. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/kit/redis_kit.py +0 -0
  58. {sdpy_kit-0.7.0 → sdpy_kit-0.7.1}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -86,7 +86,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
86
86
 
87
87
  - **统一数据库访问层**:PostgreSQL / MySQL / SQLite,三个 Kit API 完全一致,切换数据库只改一行 import;内置审计字段自动填充(`created_*` / `updated_*` / `deleted`)与软删除查询过滤;
88
88
  - **AI 多模型服务池**:chat / embedding / rerank / image 四类能力,多厂商候选链 + 故障自动转移;
89
- - **工具集**:Redis / 文件 / 雪花 ID / 离线授权签发与校验 / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析(PDF + 图片 → md + images/) / 离线 docx 解析(md + images/) / Laya 类型化决策 / Decima 决策模型(选项乱序不变、置信度可门控) / 微信公众号文章抓取存储 / 通用网页文章抓取存储 / DeepSeek 搜索与抓取。
89
+ - **工具集**:Redis / 文件 / 雪花 ID / 离线授权签发与校验 / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析(0.7.0 起暂停发布,需要请用 `sdpy-kit==0.6.10`) / 离线 docx 解析(md + images/) / Laya 类型化决策 / Decima 决策模型(选项乱序不变、置信度可门控) / 微信公众号文章抓取存储 / 通用网页文章抓取存储 / DeepSeek 搜索与抓取。
90
90
 
91
91
  > 要求 Python 3.13+
92
92
 
@@ -809,7 +809,7 @@ page = qb.paginate(page=1, per_page=20) # {"items", "total", ...}
809
809
 
810
810
  安装:`pip install "sdpy-kit[ai]"`
811
811
 
812
- 传输(0.6.11):chat / embedding / image 走 `openai` SDK(`>=3.44,<4`,
812
+ 传输:chat / embedding / image 走 `openai` SDK(`>=3.44,<4`,
813
813
  自带 `httpx2`),rerank 走 httpx 自研传输(SDK 无 `/rerank` 资源)。
814
814
  档案里的 `endpoint` **对前三类不再生效**——SDK 固定拼 `/chat/completions`、
815
815
  `/embeddings`、`/images/generations`,仅 rerank 与探活仍使用该字段。
@@ -835,14 +835,29 @@ shutdown() # 应用退出前
835
835
  |------|------|------|
836
836
  | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 单轮对话(仅 user 消息) |
837
837
  | chat | `chat_ask(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
838
- | 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` |
838
+ | 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` / `empty_kind` / `thinking_applied` / `json_mode_applied` |
839
839
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
840
840
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
841
- | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表 |
841
+ | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表;**`top_n` 是返回条数上限,由 kit 本地裁剪**(服务端返回超量也按分数截断),`top_n=None` 返回全部命中 |
842
842
  | image | `generate_image(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> ImageResult` | 文生图,返回 `ImageResult`(`url` / `b64` 至少一个非空) |
843
843
  | 管理 | `use(model_type, profile) -> None` | 切换该类型当前档案(`chat` / `embedding` / `rerank` / `image`) |
844
- | 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照(含未配置与被剔除者) |
845
- | 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载,改密钥 / 档案后无需重启 |
844
+ | 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照(含未配置密钥与被剔除者);**类型未在 `ai_models.json` 中配置时返回 `[]`**(不再抛异常),类型名非法仍抛 `AIConfigError` |
845
+ | 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载,改密钥 / 档案后无需重启;同时清空思考/结构化输出的告警去重表(修正档案后同一原因会重新提示) |
846
+
847
+ **可用性查询(`available`)**:`available` 返回的是**状态快照**,不是「可用档案列表」。
848
+ 判断某类型是否已配置用 `if not AI.available(t)`,不要靠捕获异常:
849
+
850
+ ```python
851
+ from kit.ai.runner import AI
852
+
853
+ profiles = AI.available("image") # ai_models.json 无 image 段 → []
854
+ if not profiles:
855
+ raise RuntimeError("尚未配置图像生成模型")
856
+ current = next((p for p in profiles if p.current), None)
857
+ ```
858
+
859
+ > 旧写法 `try: AI.available(t) except AIConfigError: ...` 仍可用(异常分支不再触发);
860
+ > `AI.available(t)[0]` 在类型未配置时会抛 `IndexError`,请改用上面的判空写法。
846
861
 
847
862
  ```python
848
863
  from kit.ai.runner import AI
@@ -854,30 +869,42 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
854
869
 
855
870
  #### 流式收尾统计(思考型模型必备)
856
871
 
857
- 迭代器在流结束后可读六个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
858
- `truncated` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
872
+ 迭代器在流结束后可读七个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
873
+ `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
859
874
  token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」(同一套字段也可用于非流式 `ask` / `complete`,
860
- 见下文「结构化输出」):
875
+ 见下文「结构化输出」)。
876
+
877
+ `empty_kind` 把「正文为空」分成四类,不再一律归因为预算耗尽:
878
+
879
+ | 取值 | 判定 | 建议处置 |
880
+ |---|---|---|
881
+ | `budget_exhausted` | 被长度截断且有推理 | 调大 `max_tokens`,或对该调用传 `thinking=False` |
882
+ | `reasoning_only` | 有推理、`finish_reason=stop`、正文 0 | 提高预算或换模型 |
883
+ | `no_finish` | 有推理但没收到结束原因(链路中断 / 端点未收尾) | 优先重试,连续出现检查端点/代理超时 |
884
+ | `stopped_empty` | 无推理、`finish_reason=stop`、正文 0 | 检查提示词与模型输出 |
885
+
886
+ 正文非空时 `empty_kind is None`。判定只用「是否有推理 + 结束原因」,不依赖 token 数(kit 目前不采集 `usage`):
861
887
 
862
888
  ```python
863
889
  with AI.chat_stream(messages, max_tokens=450) as stream:
864
890
  body = "".join(stream)
865
- if not body and stream.truncated and stream.reasoning_chars:
866
- raise ValueError(
867
- f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
868
- "请调大 max_tokens 或对该调用传 thinking=False"
869
- )
891
+ if not body:
892
+ match stream.empty_kind:
893
+ case "budget_exhausted":
894
+ ... # 调大 max_tokens 或 thinking=False
895
+ case "no_finish":
896
+ ... # 优先重试
870
897
  ```
871
898
 
872
899
  - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
873
- - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` 判定是否被截断;
874
- - 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
900
+ - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` / `empty_kind` 判定;
901
+ - 正文为空时 kit 按 `empty_kind` 分级打 `WARNING`(含模型、`max_tokens`、`finish_reason` 与推荐处置),不再统一归因「预算被思考耗尽」;
875
902
  - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
876
903
  - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
877
904
  - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
878
905
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
879
906
 
880
- #### 逐调用思考开关(0.6.7)
907
+ #### 逐调用思考开关
881
908
 
882
909
  同一档案下按调用点切换思考(入库抽取关、章节生成开),不必复制档案:
883
910
 
@@ -896,14 +923,14 @@ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明
896
923
 
897
924
  `thinking_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求干预 / `True` 已按声明注入 / `False` 要求了但被忽略。
898
925
 
899
- > 这是对 0.6.5「不新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」结论的**有意修订**:
900
- > 当时的否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
926
+ > 设计说明:曾否决「新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」,
927
+ > 该否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
901
928
  > 字段只发给声明了它的候选,未声明者不注入。思考**档位**(`low/medium/high`)仍然不做。
902
929
 
903
930
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
904
931
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
905
932
 
906
- #### 结构化输出(JSON 模式,0.6.8)
933
+ #### 结构化输出(JSON 模式)
907
934
 
908
935
  只要「合法 JSON 对象」用 `json_mode=True`;要约束字段用协议的 `response_format` 原样透传:
909
936
 
@@ -1228,7 +1255,7 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
1228
1255
 
1229
1256
  | 方法 | 说明 |
1230
1257
  |------|------|
1231
- | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1258
+ | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体") -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1232
1259
  | `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
1233
1260
  | `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
1234
1261
 
@@ -1611,13 +1638,13 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1611
1638
 
1612
1639
  | 层级 | 字段 | 说明 |
1613
1640
  |------|------|------|
1614
- | 档案级 | `endpoint` | 请求路径。**0.6.10 起仅 rerank 生效**:chat / embedding / image 由 openai SDK 固定拼路径,本字段被忽略(保留仅为兼容旧配置,不报错) |
1641
+ | 档案级 | `endpoint` | 请求路径。**仅 rerank 生效**:chat / embedding / image 由 openai SDK 固定拼路径,本字段被忽略(保留仅为兼容旧配置,不报错) |
1615
1642
  | 档案级 | `timeout` | 请求超时秒数(默认 60);SDK 侧为单次调用总超时 |
1616
1643
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1617
1644
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1618
1645
  | 档案级 | `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)` 生效 |
1619
- | 档案级 | `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 只做翻译,不含任何厂商名 |
1620
- | 档案级 | `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 只做翻译,不含任何厂商名 |
1646
+ | 档案级 | `thinking` | 思考开关的注入声明:`{"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 只做翻译,不含任何厂商名 |
1647
+ | 档案级 | `structured_output` | 结构化输出的注入声明:`{"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 只做翻译,不含任何厂商名 |
1621
1648
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1622
1649
 
1623
1650
  `thinking` 声明两种实测形态(按平台各自写入档案):
@@ -1639,11 +1666,11 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1639
1666
  ```
1640
1667
 
1641
1668
  > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1642
- > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1643
- > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1669
+ > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`;
1670
+ > 仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1644
1671
  > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1645
1672
  > 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
1646
- > 逐调用 `response_format`(0.6.8 起)。
1673
+ > 逐调用 `response_format`。
1647
1674
 
1648
1675
  ### 候选链切换语义(两类,勿混淆)
1649
1676
 
@@ -4,7 +4,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
4
4
 
5
5
  - **统一数据库访问层**:PostgreSQL / MySQL / SQLite,三个 Kit API 完全一致,切换数据库只改一行 import;内置审计字段自动填充(`created_*` / `updated_*` / `deleted`)与软删除查询过滤;
6
6
  - **AI 多模型服务池**:chat / embedding / rerank / image 四类能力,多厂商候选链 + 故障自动转移;
7
- - **工具集**:Redis / 文件 / 雪花 ID / 离线授权签发与校验 / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析(PDF + 图片 → md + images/) / 离线 docx 解析(md + images/) / Laya 类型化决策 / Decima 决策模型(选项乱序不变、置信度可门控) / 微信公众号文章抓取存储 / 通用网页文章抓取存储 / DeepSeek 搜索与抓取。
7
+ - **工具集**:Redis / 文件 / 雪花 ID / 离线授权签发与校验 / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析(0.7.0 起暂停发布,需要请用 `sdpy-kit==0.6.10`) / 离线 docx 解析(md + images/) / Laya 类型化决策 / Decima 决策模型(选项乱序不变、置信度可门控) / 微信公众号文章抓取存储 / 通用网页文章抓取存储 / DeepSeek 搜索与抓取。
8
8
 
9
9
  > 要求 Python 3.13+
10
10
 
@@ -727,7 +727,7 @@ page = qb.paginate(page=1, per_page=20) # {"items", "total", ...}
727
727
 
728
728
  安装:`pip install "sdpy-kit[ai]"`
729
729
 
730
- 传输(0.6.11):chat / embedding / image 走 `openai` SDK(`>=3.44,<4`,
730
+ 传输:chat / embedding / image 走 `openai` SDK(`>=3.44,<4`,
731
731
  自带 `httpx2`),rerank 走 httpx 自研传输(SDK 无 `/rerank` 资源)。
732
732
  档案里的 `endpoint` **对前三类不再生效**——SDK 固定拼 `/chat/completions`、
733
733
  `/embeddings`、`/images/generations`,仅 rerank 与探活仍使用该字段。
@@ -753,14 +753,29 @@ shutdown() # 应用退出前
753
753
  |------|------|------|
754
754
  | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 单轮对话(仅 user 消息) |
755
755
  | chat | `chat_ask(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
756
- | 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` |
756
+ | 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` / `empty_kind` / `thinking_applied` / `json_mode_applied` |
757
757
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
758
758
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
759
- | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表 |
759
+ | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表;**`top_n` 是返回条数上限,由 kit 本地裁剪**(服务端返回超量也按分数截断),`top_n=None` 返回全部命中 |
760
760
  | image | `generate_image(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> ImageResult` | 文生图,返回 `ImageResult`(`url` / `b64` 至少一个非空) |
761
761
  | 管理 | `use(model_type, profile) -> None` | 切换该类型当前档案(`chat` / `embedding` / `rerank` / `image`) |
762
- | 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照(含未配置与被剔除者) |
763
- | 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载,改密钥 / 档案后无需重启 |
762
+ | 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照(含未配置密钥与被剔除者);**类型未在 `ai_models.json` 中配置时返回 `[]`**(不再抛异常),类型名非法仍抛 `AIConfigError` |
763
+ | 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载,改密钥 / 档案后无需重启;同时清空思考/结构化输出的告警去重表(修正档案后同一原因会重新提示) |
764
+
765
+ **可用性查询(`available`)**:`available` 返回的是**状态快照**,不是「可用档案列表」。
766
+ 判断某类型是否已配置用 `if not AI.available(t)`,不要靠捕获异常:
767
+
768
+ ```python
769
+ from kit.ai.runner import AI
770
+
771
+ profiles = AI.available("image") # ai_models.json 无 image 段 → []
772
+ if not profiles:
773
+ raise RuntimeError("尚未配置图像生成模型")
774
+ current = next((p for p in profiles if p.current), None)
775
+ ```
776
+
777
+ > 旧写法 `try: AI.available(t) except AIConfigError: ...` 仍可用(异常分支不再触发);
778
+ > `AI.available(t)[0]` 在类型未配置时会抛 `IndexError`,请改用上面的判空写法。
764
779
 
765
780
  ```python
766
781
  from kit.ai.runner import AI
@@ -772,30 +787,42 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
772
787
 
773
788
  #### 流式收尾统计(思考型模型必备)
774
789
 
775
- 迭代器在流结束后可读六个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
776
- `truncated` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
790
+ 迭代器在流结束后可读七个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
791
+ `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
777
792
  token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」(同一套字段也可用于非流式 `ask` / `complete`,
778
- 见下文「结构化输出」):
793
+ 见下文「结构化输出」)。
794
+
795
+ `empty_kind` 把「正文为空」分成四类,不再一律归因为预算耗尽:
796
+
797
+ | 取值 | 判定 | 建议处置 |
798
+ |---|---|---|
799
+ | `budget_exhausted` | 被长度截断且有推理 | 调大 `max_tokens`,或对该调用传 `thinking=False` |
800
+ | `reasoning_only` | 有推理、`finish_reason=stop`、正文 0 | 提高预算或换模型 |
801
+ | `no_finish` | 有推理但没收到结束原因(链路中断 / 端点未收尾) | 优先重试,连续出现检查端点/代理超时 |
802
+ | `stopped_empty` | 无推理、`finish_reason=stop`、正文 0 | 检查提示词与模型输出 |
803
+
804
+ 正文非空时 `empty_kind is None`。判定只用「是否有推理 + 结束原因」,不依赖 token 数(kit 目前不采集 `usage`):
779
805
 
780
806
  ```python
781
807
  with AI.chat_stream(messages, max_tokens=450) as stream:
782
808
  body = "".join(stream)
783
- if not body and stream.truncated and stream.reasoning_chars:
784
- raise ValueError(
785
- f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
786
- "请调大 max_tokens 或对该调用传 thinking=False"
787
- )
809
+ if not body:
810
+ match stream.empty_kind:
811
+ case "budget_exhausted":
812
+ ... # 调大 max_tokens 或 thinking=False
813
+ case "no_finish":
814
+ ... # 优先重试
788
815
  ```
789
816
 
790
817
  - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
791
- - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` 判定是否被截断;
792
- - 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
818
+ - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` / `empty_kind` 判定;
819
+ - 正文为空时 kit 按 `empty_kind` 分级打 `WARNING`(含模型、`max_tokens`、`finish_reason` 与推荐处置),不再统一归因「预算被思考耗尽」;
793
820
  - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
794
821
  - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
795
822
  - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
796
823
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
797
824
 
798
- #### 逐调用思考开关(0.6.7)
825
+ #### 逐调用思考开关
799
826
 
800
827
  同一档案下按调用点切换思考(入库抽取关、章节生成开),不必复制档案:
801
828
 
@@ -814,14 +841,14 @@ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明
814
841
 
815
842
  `thinking_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求干预 / `True` 已按声明注入 / `False` 要求了但被忽略。
816
843
 
817
- > 这是对 0.6.5「不新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」结论的**有意修订**:
818
- > 当时的否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
844
+ > 设计说明:曾否决「新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」,
845
+ > 该否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
819
846
  > 字段只发给声明了它的候选,未声明者不注入。思考**档位**(`low/medium/high`)仍然不做。
820
847
 
821
848
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
822
849
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
823
850
 
824
- #### 结构化输出(JSON 模式,0.6.8)
851
+ #### 结构化输出(JSON 模式)
825
852
 
826
853
  只要「合法 JSON 对象」用 `json_mode=True`;要约束字段用协议的 `response_format` 原样透传:
827
854
 
@@ -1146,7 +1173,7 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
1146
1173
 
1147
1174
  | 方法 | 说明 |
1148
1175
  |------|------|
1149
- | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1176
+ | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体") -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1150
1177
  | `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
1151
1178
  | `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
1152
1179
 
@@ -1529,13 +1556,13 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1529
1556
 
1530
1557
  | 层级 | 字段 | 说明 |
1531
1558
  |------|------|------|
1532
- | 档案级 | `endpoint` | 请求路径。**0.6.10 起仅 rerank 生效**:chat / embedding / image 由 openai SDK 固定拼路径,本字段被忽略(保留仅为兼容旧配置,不报错) |
1559
+ | 档案级 | `endpoint` | 请求路径。**仅 rerank 生效**:chat / embedding / image 由 openai SDK 固定拼路径,本字段被忽略(保留仅为兼容旧配置,不报错) |
1533
1560
  | 档案级 | `timeout` | 请求超时秒数(默认 60);SDK 侧为单次调用总超时 |
1534
1561
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1535
1562
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1536
1563
  | 档案级 | `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)` 生效 |
1537
- | 档案级 | `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 只做翻译,不含任何厂商名 |
1538
- | 档案级 | `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 只做翻译,不含任何厂商名 |
1564
+ | 档案级 | `thinking` | 思考开关的注入声明:`{"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 只做翻译,不含任何厂商名 |
1565
+ | 档案级 | `structured_output` | 结构化输出的注入声明:`{"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 只做翻译,不含任何厂商名 |
1539
1566
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1540
1567
 
1541
1568
  `thinking` 声明两种实测形态(按平台各自写入档案):
@@ -1557,11 +1584,11 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1557
1584
  ```
1558
1585
 
1559
1586
  > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1560
- > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1561
- > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1587
+ > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`;
1588
+ > 仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1562
1589
  > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1563
1590
  > 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
1564
- > 逐调用 `response_format`(0.6.8 起)。
1591
+ > 逐调用 `response_format`。
1565
1592
 
1566
1593
  ### 候选链切换语义(两类,勿混淆)
1567
1594
 
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.7.0"
26
+ __version__ = "0.7.1"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -47,6 +47,44 @@ _REASONING_KEYS: Final[tuple[str, ...]] = ("reasoning_content", "reasoning")
47
47
  # 截断判定:OpenAI 系给 length,部分兼容端点给 max_tokens
48
48
  _TRUNCATED_REASONS: Final[frozenset[str]] = frozenset({"length", "max_tokens"})
49
49
 
50
+ # 空正文诊断分级 → 告警文案(正文为 0 时才有分级,见 _classify_empty)
51
+ _EMPTY_HINTS: Final[dict[str, str]] = {
52
+ "budget_exhausted": "输出预算疑被思考耗尽(长度截断)",
53
+ "reasoning_only": "只产出推理、正文为空(finish=stop)",
54
+ "no_finish": "只产出推理、未收到结束原因(链路中断或端点未收尾)",
55
+ "stopped_empty": "无正文输出(finish=stop)",
56
+ }
57
+
58
+ _EMPTY_ACTIONS: Final[dict[str, str]] = {
59
+ "budget_exhausted": "(可调大 max_tokens,或对该调用传 thinking=False 关闭思考)",
60
+ "reasoning_only": "(推理未触顶但正文为空,可提高预算或换模型)",
61
+ "no_finish": "(优先重试;连续出现请检查端点/代理超时)",
62
+ "stopped_empty": "(检查提示词与模型输出)",
63
+ }
64
+
65
+
66
+ def _classify_empty(
67
+ *,
68
+ content_chars: int,
69
+ reasoning_chars: int,
70
+ finish_reason: str | None,
71
+ truncated: bool,
72
+ ) -> str | None:
73
+ """空正文诊断分级;正文非空或无法归因时返回 None。
74
+
75
+ - ``budget_exhausted``:被截断且有推理(预算被思考吃满)
76
+ - ``reasoning_only``:有推理、``finish_reason=stop``
77
+ - ``no_finish``:有推理但结束原因缺失或非 stop
78
+ - ``stopped_empty``:无推理、``finish_reason=stop``
79
+ """
80
+ if content_chars:
81
+ return None
82
+ if truncated and reasoning_chars:
83
+ return "budget_exhausted"
84
+ if reasoning_chars:
85
+ return "reasoning_only" if finish_reason == "stop" else "no_finish"
86
+ return "stopped_empty" if finish_reason == "stop" else None
87
+
50
88
  # 「无注入值」哨兵:区分「不注入」与「注入值为 None」
51
89
  _UNSET: Final[object] = object()
52
90
 
@@ -71,6 +109,15 @@ def _warn_once(
71
109
  logger.log(level, f"{kind}:{reason}(档案 {profile_name})")
72
110
 
73
111
 
112
+ def reset_warned() -> None:
113
+ """清空告警去重表(配置热重载后重新提示,避免旧原因被永久压制)。
114
+
115
+ 供 :func:`kit.ai.runner.reload_config` 调用;线程安全。
116
+ """
117
+ with _WARNED_LOCK:
118
+ _WARNED.clear()
119
+
120
+
74
121
  def _resolve_thinking(
75
122
  profile: ModelProfile, thinking: bool | None
76
123
  ) -> tuple[object, bool | None, str | None, int]:
@@ -125,6 +172,10 @@ class StreamStats:
125
172
  True 已按档案声明注入 / False 要求了但档案完全没有 thinking 声明
126
173
  json_mode_applied: 结构化输出是否生效——None 本次未要求注入 /
127
174
  True 已注入 / False 要求了但档案声明不支持(已忽略)
175
+
176
+ 只读派生属性:``truncated``(是否截断)、``empty_kind``(正文为空时的
177
+ 诊断分级:``budget_exhausted`` / ``reasoning_only`` / ``no_finish`` /
178
+ ``stopped_empty``;正文非空为 None)。
128
179
  """
129
180
 
130
181
  reasoning_chars: int = 0
@@ -138,6 +189,21 @@ class StreamStats:
138
189
  """是否因输出长度上限被截断。"""
139
190
  return self.finish_reason in _TRUNCATED_REASONS
140
191
 
192
+ @property
193
+ def empty_kind(self) -> str | None:
194
+ """正文为空时的诊断分级;正文非空返回 None。
195
+
196
+ 取值:``budget_exhausted`` / ``reasoning_only`` / ``no_finish`` /
197
+ ``stopped_empty``。不依赖 token 数量,用「是否有推理 + 结束原因」
198
+ 区分「预算吃满」「只推理」「链路未收尾」「正常停住」。
199
+ """
200
+ return _classify_empty(
201
+ content_chars=self.content_chars,
202
+ reasoning_chars=self.reasoning_chars,
203
+ finish_reason=self.finish_reason,
204
+ truncated=self.truncated,
205
+ )
206
+
141
207
  def reset(self) -> None:
142
208
  """清零(候选链切换到下一个档案前调用)。"""
143
209
  self.reasoning_chars = 0
@@ -361,7 +427,26 @@ class ChatService(SdkServiceBase):
361
427
  "(请调大 max_tokens,或对该调用传 thinking=False 关闭思考)"
362
428
  )
363
429
  raise AIResponseError(f"content 类型异常: {type(content).__name__}")
364
- if finish in _TRUNCATED_REASONS and (reasoning or not content):
430
+ truncated = finish in _TRUNCATED_REASONS
431
+ kind = _classify_empty(
432
+ content_chars=len(content),
433
+ reasoning_chars=len(reasoning),
434
+ finish_reason=finish,
435
+ truncated=truncated,
436
+ )
437
+ effective_max_tokens = (
438
+ request.max_tokens
439
+ if request.max_tokens is not None
440
+ else self._profile.max_tokens
441
+ )
442
+ if kind is not None:
443
+ self._log_empty(
444
+ kind=kind,
445
+ max_tokens=effective_max_tokens,
446
+ finish=finish,
447
+ reasoning_chars=len(reasoning),
448
+ )
449
+ elif truncated:
365
450
  logger.warning(
366
451
  f"{self._log_desc()} 输出被长度截断(finish_reason={finish}):"
367
452
  f"推理 {len(reasoning)} 字符 / 正文 {len(content)} 字符"
@@ -481,7 +566,7 @@ class ChatService(SdkServiceBase):
481
566
  if request.max_tokens is not None
482
567
  else self._profile.max_tokens
483
568
  )
484
- self._warn_if_budget_eaten(stats, effective_max_tokens)
569
+ self._warn_if_empty(stats, effective_max_tokens)
485
570
  finally:
486
571
  log = logger.error if failed else logger.info
487
572
  log(
@@ -489,15 +574,29 @@ class ChatService(SdkServiceBase):
489
574
  f"耗时 {time.perf_counter() - start:.1f}秒{note}"
490
575
  )
491
576
 
492
- def _warn_if_budget_eaten(
493
- self, stats: StreamStats, max_tokens: object
494
- ) -> None:
495
- """正文为空且(被截断或有推理)时告警:讲清「预算被思考吃掉」。"""
496
- if stats.content_chars or not (stats.truncated or stats.reasoning_chars):
577
+ def _warn_if_empty(self, stats: StreamStats, max_tokens: object) -> None:
578
+ """正文为空时按诊断分级告警(不再一律归因为「预算被思考耗尽」)。"""
579
+ kind = stats.empty_kind
580
+ if kind is None:
497
581
  return
582
+ self._log_empty(
583
+ kind=kind,
584
+ max_tokens=max_tokens,
585
+ finish=stats.finish_reason,
586
+ reasoning_chars=stats.reasoning_chars,
587
+ )
588
+
589
+ def _log_empty(
590
+ self,
591
+ *,
592
+ kind: str,
593
+ max_tokens: object,
594
+ finish: str | None,
595
+ reasoning_chars: int,
596
+ ) -> None:
597
+ """输出空正文诊断日志(``ask`` / ``stream`` 共用同一套文案)。"""
498
598
  logger.warning(
499
- f"{self._log_desc()} 输出预算疑被思考耗尽:max_tokens={max_tokens},"
500
- f"finish_reason={stats.finish_reason},"
501
- f"推理 {stats.reasoning_chars} 字符 / 正文 0 字符"
502
- "(可调大 max_tokens,或对该调用传 thinking=False 关闭思考)"
599
+ f"{self._log_desc()} {_EMPTY_HINTS[kind]}:max_tokens={max_tokens},"
600
+ f"finish_reason={finish},推理 {reasoning_chars} 字符 / 正文 0 字符"
601
+ f"{_EMPTY_ACTIONS[kind]}"
503
602
  )
@@ -23,6 +23,7 @@ Author: Clark Chang
23
23
  继承链:
24
24
  AIError 基类
25
25
  ├── AIConfigError 档案文件缺失/非法、档案不存在、密钥未配置、必填字段缺失
26
+ │ └── AIModelTypeNotConfiguredError 类型名合法但档案未配置该类型段
26
27
  ├── AIRequestError 网络异常、HTTP >= 400
27
28
  │ ├── AITimeoutError 请求超时(可用于重试策略)
28
29
  │ └── AIAggregateError 候选链聚合异常(全部候选失败或整链预算耗尽)
@@ -38,6 +39,15 @@ class AIConfigError(AIError):
38
39
  """模型档案配置异常(文件缺失/非法、档案不存在、密钥未配置、必填字段缺失)"""
39
40
 
40
41
 
42
+ class AIModelTypeNotConfiguredError(AIConfigError):
43
+ """类型名合法但 ``ai_models.json`` 未配置该类型段(可预期的空态)。
44
+
45
+ ``AI.available(type)`` 对这种情况返回空列表;其余 ``AIConfigError``
46
+ (类型名非法、文件缺失、注册表未加载)仍照常抛出。继承 ``AIConfigError``
47
+ 使既有 ``except AIConfigError`` 捕获点零改动。
48
+ """
49
+
50
+
41
51
  class AIRequestError(AIError):
42
52
  """模型服务请求异常(网络异常、HTTP >= 400)。
43
53
 
@@ -20,14 +20,16 @@ Author: Clark Chang
20
20
  from kit.ai.chat_service import ChatService
21
21
  from kit.ai.embedding_service import EmbeddingService
22
22
  from kit.ai.image_service import ImageService
23
- from kit.ai.profiles import ModelProfile, get_model_registry, with_api_key
23
+ from kit.ai.profiles import (
24
+ MODEL_TYPES,
25
+ ModelProfile,
26
+ get_model_registry,
27
+ with_api_key,
28
+ )
24
29
  from kit.ai.rerank_service import RerankService
25
30
 
26
31
  __all__ = ["ModelBuilder"]
27
32
 
28
- # 支持的模型类型
29
- _MODEL_TYPES: tuple[str, ...] = ("chat", "embedding", "rerank", "image")
30
-
31
33
  Service = ChatService | EmbeddingService | RerankService | ImageService
32
34
 
33
35
 
@@ -87,7 +89,7 @@ class ModelBuilder:
87
89
  return ImageService(profile)
88
90
  case _:
89
91
  raise ValueError(
90
- f"未知模型类型: {model_type},支持: {', '.join(_MODEL_TYPES)}"
92
+ f"未知模型类型: {model_type},支持: {', '.join(MODEL_TYPES)}"
91
93
  )
92
94
 
93
95
  @classmethod
@@ -18,18 +18,17 @@ Author: Clark Chang
18
18
  """
19
19
 
20
20
  # date: 2026-11-04
21
- """模型档案轻探测客户端(0.7.0 起更名 ``ProbeClient``)。
21
+ """模型档案轻探测客户端(``ProbeClient``)。
22
22
 
23
23
  破坏性变更:``post_json`` / ``stream_json`` 已删除——请求发送改由
24
24
  :class:`kit.ai.sdk_client.SdkClient` 承担,rerank 走
25
25
  :class:`kit.ai.rerank_transport.RerankTransport`。本类只保留
26
26
  ``list_models()`` / ``close()``,供业务侧做「密钥是否有效」的连通性测试。
27
27
 
28
- ``ModelClient`` 作为弃用别名保留一个版本(访问时触发 ``DeprecationWarning``)。
28
+ 0.7.0 由 ``ModelClient`` 改名;0.7.1 起**弃用别名已删除**,旧名不可导入
29
+ (``from kit.ai.model_client import ModelClient`` 抛 ``ImportError``)。
29
30
  """
30
31
 
31
- import warnings
32
-
33
32
  from kit.ai.profiles import ModelProfile
34
33
  from kit.ai.sdk_client import SdkClient
35
34
  from kit.ai.types import JSONObject
@@ -40,18 +39,14 @@ __all__ = ["ProbeClient"]
40
39
  class ProbeClient:
41
40
  """单档案连通性探测客户端(全异步)。
42
41
 
43
- 0.7.0 起由 ``ModelClient`` 改名:它只做 ``list_models`` 探活,
44
- 真正的传输在 :class:`kit.ai.sdk_client.SdkClient`。
42
+ 它只做 ``list_models`` 探活,真正的传输在
43
+ :class:`kit.ai.sdk_client.SdkClient`。
45
44
  """
46
45
 
47
46
  def __init__(self, profile: ModelProfile) -> None:
48
47
  self._profile: ModelProfile = profile
49
48
  self._client: SdkClient = SdkClient(profile)
50
49
 
51
- @property
52
- def model(self) -> str:
53
- return self._profile.model
54
-
55
50
  async def list_models(self) -> JSONObject:
56
51
  """``GET {base_url}/models`` 轻探测(独立 10 秒超时)。
57
52
 
@@ -65,15 +60,3 @@ class ProbeClient:
65
60
 
66
61
  async def close(self) -> None:
67
62
  await self._client.close()
68
-
69
-
70
- def __getattr__(name: str) -> object:
71
- """0.7.0 起 ``ModelClient`` 更名为 ``ProbeClient``:保留别名并告警。"""
72
- if name == "ModelClient":
73
- warnings.warn(
74
- "ModelClient 已更名为 ProbeClient(0.7.0),请改用 ProbeClient",
75
- DeprecationWarning,
76
- stacklevel=2,
77
- )
78
- return ProbeClient
79
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -42,7 +42,12 @@ from typing import Final
42
42
 
43
43
  from kit.ai.chat_service import ChatService
44
44
  from kit.ai.embedding_service import EmbeddingService
45
- from kit.ai.exceptions import AIConfigError, AIError, AIRequestError
45
+ from kit.ai.exceptions import (
46
+ AIConfigError,
47
+ AIError,
48
+ AIModelTypeNotConfiguredError,
49
+ AIRequestError,
50
+ )
46
51
  from kit.ai.image_service import ImageService
47
52
  from kit.ai.model_builder import Service
48
53
  from kit.ai.model_client import ProbeClient
@@ -369,8 +374,16 @@ class AIServicePool:
369
374
  logger.info(f"已切换 {model_type} 当前使用档案: {profile}")
370
375
 
371
376
  def available(self, model_type: str) -> list[ProfileStatus]:
372
- """该类型全部档案状态快照(含未配置与被剔除者),链头标记 current。"""
373
- chain = self._ensure_chain(model_type)
377
+ """该类型全部档案状态快照(含未配置密钥与被剔除者),链头标记 current。
378
+
379
+ 类型名合法但 ``ai_models.json`` 未配置该段时返回空列表(可预期空态);
380
+ 类型名非法、注册表未加载等真正的配置错误仍抛 ``AIConfigError``。
381
+ """
382
+ try:
383
+ chain = self._ensure_chain(model_type)
384
+ except AIModelTypeNotConfiguredError as exc:
385
+ logger.debug(f"{model_type} 状态快照为空: {exc}")
386
+ return []
374
387
  now = time.monotonic()
375
388
  with self._lock:
376
389
  preferred = self._preferred.get(model_type)
@@ -39,14 +39,14 @@ from typing import Any, Final, Literal, Self
39
39
  from dotenv import load_dotenv
40
40
  from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
41
41
 
42
- from kit.ai.exceptions import AIConfigError
42
+ from kit.ai.exceptions import AIConfigError, AIModelTypeNotConfiguredError
43
43
  from kit.config.kit_config import get_settings
44
44
  from kit.config.log_config import get_default_logger
45
45
 
46
46
  logger = get_default_logger(__name__)
47
47
 
48
48
  # 支持的模型类型
49
- _MODEL_TYPES: Final[tuple[str, ...]] = ("chat", "embedding", "rerank", "image")
49
+ MODEL_TYPES: Final[tuple[str, ...]] = ("chat", "embedding", "rerank", "image")
50
50
 
51
51
  # 文件缺失报错时附带的最小可用模板(api_key 为环境变量名,密钥配置在 .env)
52
52
  _MINIMAL_MODELS_JSON: Final[str] = (
@@ -152,11 +152,6 @@ class ModelProfile:
152
152
  thinking: ThinkingSwitchSpec | None = None
153
153
  structured_output: StructuredOutputSpec | None = None
154
154
 
155
- @property
156
- def url(self) -> str:
157
- """拼接完整请求 URL(base_url 去尾斜杠 + endpoint)"""
158
- return self.base_url.rstrip("/") + self.endpoint
159
-
160
155
 
161
156
  class ProfileState(enum.Enum):
162
157
  """档案运行时状态(候选链与状态快照使用)。"""
@@ -171,7 +166,7 @@ class ProfileState(enum.Enum):
171
166
 
172
167
  @dataclass(frozen=True)
173
168
  class ProfileStatus:
174
- """档案状态快照行(``ModelRegistry.available`` / ``AI.available`` 返回)。
169
+ """档案状态快照行(``AIServicePool.available`` / ``AI.available`` 返回)。
175
170
 
176
171
  Attributes:
177
172
  name: 档案名
@@ -319,6 +314,16 @@ class TypeConfig(BaseModel):
319
314
  chain_timeout: int | None = Field(default=None, gt=0)
320
315
  profiles: dict[str, ProfileConfig]
321
316
 
317
+ @model_validator(mode="after")
318
+ def _validate_default_profile(self) -> Self:
319
+ """默认档案必须在该类型的 profiles 中(加载期即报,避免运行期 KeyError)。"""
320
+ if self.default_profile not in self.profiles:
321
+ raise ValueError(
322
+ f"default_profile 未在 profiles 中定义: {self.default_profile}"
323
+ f"(已知档案: {', '.join(sorted(self.profiles)) or '无'})"
324
+ )
325
+ return self
326
+
322
327
 
323
328
  class RegistryConfig(BaseModel):
324
329
  """模型档案文件整体结构(顶层对象)"""
@@ -329,10 +334,22 @@ class RegistryConfig(BaseModel):
329
334
  image: TypeConfig | None = None
330
335
 
331
336
  def type_config(self, model_type: str) -> TypeConfig:
332
- config = getattr(self, model_type, None)
333
- if not isinstance(config, TypeConfig):
334
- available = ", ".join(_MODEL_TYPES)
337
+ """取某类型的档案集合,区分「类型名非法」与「类型未配置」两种情况。
338
+
339
+ Raises:
340
+ AIConfigError: 类型名不在四类之内(调用方写错)。
341
+ AIModelTypeNotConfiguredError: 类型名合法但 JSON 缺该段(可预期空态,
342
+ ``AI.available`` 据此返回空列表)。
343
+ """
344
+ if model_type not in MODEL_TYPES:
345
+ available = ", ".join(MODEL_TYPES)
335
346
  raise AIConfigError(f"模型类型不存在: {model_type},可用类型: {available}")
347
+ config = getattr(self, model_type)
348
+ if config is None:
349
+ raise AIModelTypeNotConfiguredError(
350
+ f"模型类型未配置: {model_type}"
351
+ f"(ai_models.json 缺少 {model_type} 段)"
352
+ )
336
353
  return config
337
354
 
338
355
 
@@ -384,7 +401,7 @@ class ModelRegistry:
384
401
  except (OSError, ValidationError) as exc:
385
402
  raise AIConfigError(f"模型档案文件解析失败: {config_path}: {exc}") from exc
386
403
 
387
- loaded_types = [t for t in _MODEL_TYPES if getattr(self._config, t) is not None]
404
+ loaded_types = [t for t in MODEL_TYPES if getattr(self._config, t) is not None]
388
405
  logger.info(
389
406
  f"AI 模型档案已加载: {config_path},类型: {', '.join(loaded_types) or '无'}"
390
407
  )
@@ -113,15 +113,21 @@ class RerankService(LoggingServiceBase):
113
113
  """对候选文档重排序,返回按分数降序的命中列表。
114
114
 
115
115
  Args:
116
+ top_n: 返回条数上限。kit 在本地保证(服务端返回超量也按分数
117
+ 截断到 ``top_n``);``None`` 返回服务端全部命中。``top_n <= 0``
118
+ 直接返回空列表,不发请求
116
119
  deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
117
120
  5xx 重试预算;None 表示不限制
118
121
  """
119
122
  if not documents:
120
123
  return []
124
+ if top_n is not None and top_n <= 0:
125
+ return []
121
126
  payload = self._build_payload(RerankRequest(query, documents, top_n=top_n))
122
127
  with self._log_call():
123
128
  data = await self._client.post_json(payload, deadline=deadline)
124
- return self._parse_response(data, documents)
129
+ hits = self._parse_response(data, documents)
130
+ return hits[:top_n] if top_n is not None else hits
125
131
 
126
132
  async def close(self) -> None:
127
133
  await self._client.close()
@@ -22,7 +22,7 @@ Author: Clark Chang
22
22
 
23
23
  `openai` SDK 不提供 ``/rerank`` 资源,故 rerank 保留自研传输:单次 JSON
24
24
  POST、5xx 指数退避重试、429 ``Retry-After`` 解析。语义与 0.6.9 的
25
- ``ModelClient`` 完全一致。
25
+ 通用传输实现完全一致。
26
26
  """
27
27
 
28
28
  import asyncio
@@ -70,7 +70,7 @@ from collections.abc import Callable, Coroutine, Iterable, Iterator
70
70
  from dataclasses import dataclass
71
71
  from typing import Any, Final, TypeVar, cast
72
72
 
73
- from kit.ai.chat_service import StreamStats
73
+ from kit.ai.chat_service import StreamStats, reset_warned
74
74
  from kit.ai.exceptions import AIConfigError, AITimeoutError
75
75
  from kit.ai.failover import (
76
76
  FailoverChatService,
@@ -80,7 +80,7 @@ from kit.ai.failover import (
80
80
  )
81
81
  from kit.ai.model_builder import Service
82
82
  from kit.ai.pool import AIServicePool
83
- from kit.ai.profiles import ProfileStatus, get_model_registry
83
+ from kit.ai.profiles import MODEL_TYPES, ProfileStatus, get_model_registry
84
84
  from kit.ai.image_service import ImageResult
85
85
  from kit.ai.rerank_service import RerankHit
86
86
  from kit.config.kit_config import get_settings
@@ -96,8 +96,8 @@ _EMBEDDING = "embedding"
96
96
  _RERANK = "rerank"
97
97
  _IMAGE = "image"
98
98
 
99
- # 支持的模型类型集合(use/available 校验与错误提示用)
100
- _SERVICE_TYPES: Final[frozenset[str]] = frozenset({_CHAT, _EMBEDDING, _RERANK, _IMAGE})
99
+ # 支持的模型类型集合(use/available 校验与错误提示用,单一数据源见 profiles)
100
+ _SERVICE_TYPES: Final[frozenset[str]] = frozenset(MODEL_TYPES)
101
101
 
102
102
  # 常驻链式服务联合类型(按模型类型缓存于后台线程)
103
103
  FailoverService = (
@@ -279,7 +279,10 @@ class _BackgroundLoop:
279
279
  self._get_pool().prefer(model_type, profile)
280
280
 
281
281
  def available(self, model_type: str) -> list[ProfileStatus]:
282
- """返回该类型全部档案状态快照(含未配置者),链头标记 current。"""
282
+ """返回该类型全部档案状态快照(含未配置密钥者),链头标记 current。
283
+
284
+ 类型未在 ``ai_models.json`` 中配置时返回空列表。
285
+ """
283
286
  return self._get_pool().available(model_type)
284
287
 
285
288
  # ---------- 投递 ----------
@@ -681,7 +684,11 @@ class AI:
681
684
  def rerank(
682
685
  query: str, documents: list[str], top_n: int | None = None
683
686
  ) -> list[RerankHit]:
684
- """对候选文档重排序,返回 ``RerankHit`` 降序列表(原索引、分数、原文)。"""
687
+ """对候选文档重排序,返回 ``RerankHit`` 降序列表(原索引、分数、原文)。
688
+
689
+ ``top_n`` 是返回条数上限,由 kit 本地保证(服务端返回超量也按分数
690
+ 截断);``None`` 返回服务端全部命中。
691
+ """
685
692
  timeout = _facade_timeout(_RERANK)
686
693
  deadline = time.monotonic() + timeout
687
694
  return _background.run(
@@ -737,17 +744,18 @@ class AI:
737
744
 
738
745
  @staticmethod
739
746
  def available(model_type: str) -> list[ProfileStatus]:
740
- """返回某类型全部档案状态快照(含未配置与被剔除者),链头标 current。
747
+ """返回某类型全部档案状态快照(含未配置密钥与被剔除者),链头标 current。
741
748
 
742
749
  Args:
743
750
  model_type: 模型类型(chat / embedding / rerank / image)
744
751
 
745
752
  Returns:
746
753
  ``ProfileStatus`` 列表(name/model/state/current/last_error/
747
- last_ok_at),供业务展示与切换决策。
754
+ last_ok_at),供业务展示与切换决策。**类型在 ``ai_models.json``
755
+ 中未配置时返回空列表**(可预期空态,供界面显示「未配置」)。
748
756
 
749
757
  Raises:
750
- AIConfigError: 类型不存在或注册表未加载。
758
+ AIConfigError: 类型名非法(不在四类之内)或注册表未加载。
751
759
  """
752
760
  return _background.available(model_type)
753
761
 
@@ -1064,6 +1072,8 @@ def reload_config(
1064
1072
  # 4. 后台 loop 内摘链、清常驻服务并调度旧 service 关闭
1065
1073
  types = sorted(_SERVICE_TYPES) if model_type is None else [model_type]
1066
1074
  _background.invalidate(frozenset(types), probe=probe, immediate=immediate)
1075
+ # 4.1 重置告警去重:修正档案后同一原因应能再次提示(去重键不含配置指纹)
1076
+ reset_warned()
1067
1077
  # 5. 快照新 embedding,判定变更(model 或 dimension 任一变化即告警)
1068
1078
  current = _embedding_snapshot()
1069
1079
  changed = previous is not None and current is not None and previous != current
@@ -1724,7 +1724,6 @@ class Md2DocKit:
1724
1724
  *,
1725
1725
  generate_toc: bool = False,
1726
1726
  mainfont: str = "宋体",
1727
- reference_doc: str | Path | None = None,
1728
1727
  ) -> str:
1729
1728
  """将 Markdown 文件转换为 Word(.docx)文件(基于 PiMD)
1730
1729
 
@@ -1732,9 +1731,7 @@ class Md2DocKit:
1732
1731
  生成 docx,支持标题、列表、表格(表头深蓝底加粗)、代码块、链接
1733
1732
  等语法;可通过 generate_toc 在文档开头插入目录。
1734
1733
  全文字体统一为 mainfont(默认宋体):转换后改写 docDefaults 与
1735
- 主题字体(正文/标题/表格全部生效),代码块保持等宽字体;
1736
- reference_doc 参数仅保留签名兼容(PiMD 的 md_to_docx 不接收参考
1737
- 模板,传入不生效)。
1734
+ 主题字体(正文/标题/表格全部生效),代码块保持等宽字体。
1738
1735
 
1739
1736
  图片(`![alt](相对路径)`)以内嵌图片段落回填:PiMD 会把行内图片
1740
1737
  降级为「alt 文本 + 超链接」,此处按占位标记替换为真实图片并居中,
@@ -1751,7 +1748,6 @@ class Md2DocKit:
1751
1748
  dest_path: 目标 Word 文件路径;None 时使用源文件名同目录加 .docx 后缀
1752
1749
  generate_toc: 是否在文档开头插入目录
1753
1750
  mainfont: 文档全局字体名(Word 字体名,如 "宋体"/"黑体"),默认宋体
1754
- reference_doc: 仅保留签名兼容;PiMD 不接收参考模板,传入不生效
1755
1751
 
1756
1752
  Returns:
1757
1753
  str: 实际写入的 Word 文件绝对路径
@@ -1769,11 +1765,6 @@ class Md2DocKit:
1769
1765
  """
1770
1766
  src, dest = _resolve_md_paths(src_path, dest_path, ".docx")
1771
1767
  logger.info(f"开始转换 Markdown 为 Word: {src.name}")
1772
- if reference_doc is not None:
1773
- # 保留签名兼容:PiMD 的 md_to_docx 不接收参考模板,传入不生效
1774
- logger.warning(
1775
- "reference_doc 参数已废弃(PiMD 不支持参考模板),传入不生效"
1776
- )
1777
1768
 
1778
1769
  # CJK 加粗预处理;临时 md 与源文件同目录,保证相对图片路径仍然可用
1779
1770
  processed = _normalize_cjk_bold(src.read_text(encoding="utf-8"))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes