sdpy-kit 0.6.3__tar.gz → 0.6.5__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.3 → sdpy_kit-0.6.5}/PKG-INFO +42 -9
  2. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/README.md +41 -8
  3. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/__init__.py +2 -1
  5. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/base_service.py +15 -2
  6. sdpy_kit-0.6.5/kit/ai/chat_service.py +266 -0
  7. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/failover.py +66 -19
  8. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/profiles.py +9 -1
  9. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/runner.py +48 -3
  10. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/md2doc_kit.py +540 -14
  11. sdpy_kit-0.6.3/kit/ai/chat_service.py +0 -160
  12. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/.gitignore +0 -0
  13. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/embedding_service.py +0 -0
  14. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/exceptions.py +0 -0
  15. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/image_service.py +0 -0
  16. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/model_builder.py +0 -0
  17. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/model_client.py +0 -0
  18. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/pool.py +0 -0
  19. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/rerank_service.py +0 -0
  20. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/types.py +0 -0
  21. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/common_kit.py +0 -0
  22. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/config/__init__.py +0 -0
  23. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/config/kit_config.py +0 -0
  24. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/config/log_config.py +0 -0
  25. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/__init__.py +0 -0
  26. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/article_io.py +0 -0
  27. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/article_models.py +0 -0
  28. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/deepseek_kit.py +0 -0
  29. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/web_article_kit.py +0 -0
  30. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/wx_article_kit.py +0 -0
  31. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/__init__.py +0 -0
  32. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/audit.py +0 -0
  33. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/base_db_kit.py +0 -0
  34. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/crud.py +0 -0
  35. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/engine.py +0 -0
  36. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/exceptions.py +0 -0
  37. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/models/__init__.py +0 -0
  38. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/models/base.py +0 -0
  39. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/mysql_kit.py +0 -0
  40. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/pg_kit.py +0 -0
  41. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/query.py +0 -0
  42. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/session.py +0 -0
  43. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/sqlite_kit.py +0 -0
  44. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/transaction.py +0 -0
  45. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/__init__.py +0 -0
  46. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/anydoc_kit.py +0 -0
  47. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/docx_kit.py +0 -0
  48. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/marker_kit.py +0 -0
  49. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/mineru_kit.py +0 -0
  50. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/rc_docparser_kit.py +0 -0
  51. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/file_kit.py +0 -0
  52. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/laya_kit.py +0 -0
  53. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/license_kit.py +0 -0
  54. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/redis_kit.py +0 -0
  55. {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.6.3
3
+ Version: 0.6.5
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -98,7 +98,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
98
98
  | `kit.docs.anydoc_kit` | `AnyDocKit` | docx / pdf / xlsx 等 → Markdown | `anydoc` |
99
99
  | `kit.docs.docx_kit` | `DocxKit` | Word 模板动态填充(章节 / 列表 / 表格) | `docx` |
100
100
  | `kit.docs.rc_docparser_kit` | `RcDocParserKit` | 离线 docx → Markdown + Images(无模型) | `docx-parse` |
101
- | `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML | `md2doc` |
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
104
  | `kit.laya_kit` | `LayaKit` | 类型化决策:choice / score / noul,返回判断结果 + 置信度 | `laya` |
@@ -125,7 +125,7 @@ uv add sdpy-kit
125
125
  | `sqliteai` | SQLiteKit 向量方法(numpy / sqliteai-vector,SQLite 向量扩展) | `pip install "sdpy-kit[sqliteai]"` |
126
126
  | `redis` | RedisKit | `pip install "sdpy-kit[redis]"` |
127
127
  | `ai` | AI 服务池(httpx + python-dotenv) | `pip install "sdpy-kit[ai]"` |
128
- | `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2) | `pip install "sdpy-kit[md2doc]"` |
128
+ | `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2,含图片) | `pip install "sdpy-kit[md2doc]"` |
129
129
  | `anydoc` | AnyDocKit 文档转 Markdown(firecrawl-anydoc + pikepdf) | `pip install "sdpy-kit[anydoc]"` |
130
130
  | `docx` | DocxKit 模板动态填充(docxtpl) | `pip install "sdpy-kit[docx]"` |
131
131
  | `docx-parse` | RcDocParserKit 离线 docx 解析(rc-docparser → Markdown + images/) | `pip install "sdpy-kit[docx-parse]"` |
@@ -785,7 +785,7 @@ shutdown() # 应用退出前
785
785
  |------|------|------|
786
786
  | chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话(仅 user 消息) |
787
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` 使用) |
788
+ | chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
789
789
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
790
790
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
791
791
  | rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
@@ -802,6 +802,32 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
802
802
  print(token, end="", flush=True)
803
803
  ```
804
804
 
805
+ #### 流式收尾统计(思考型模型必备)
806
+
807
+ 迭代器在流结束后可读四个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
808
+ `truncated`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
809
+ token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
810
+
811
+ ```python
812
+ with AI.chat_stream(messages, max_tokens=450) as stream:
813
+ body = "".join(stream)
814
+ if not body and stream.truncated and stream.reasoning_chars:
815
+ raise ValueError(
816
+ f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
817
+ "请调大 max_tokens 或改用关闭思考的档案"
818
+ )
819
+ ```
820
+
821
+ - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
822
+ - 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
823
+ - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
824
+ - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
825
+ - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
826
+ - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
827
+
828
+ > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
829
+ > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
830
+
805
831
  > 门面仅面向**同步上下文**;若调用点本身在 async 函数内,请直接 `await` 原生异步服务,避免阻塞事件循环线程。
806
832
  > 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。
807
833
 
@@ -811,7 +837,7 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
811
837
  from kit.ai import (
812
838
  ChatService, EmbeddingService, RerankService, ImageService, # 服务类
813
839
  ChatRequest, EmbeddingRequest, RerankRequest, ImageRequest, # 请求模型
814
- RerankHit, ModelBuilder, # 结果类型 / 构建器
840
+ RerankHit, StreamStats, ModelBuilder, # 结果类型 / 构建器
815
841
  )
816
842
 
817
843
  service = ModelBuilder.build_chat() # 老按类型构建:build_chat / build_embedding / build_rerank / build_image
@@ -821,7 +847,7 @@ await service.close()
821
847
 
822
848
  | 类 | 方法 |
823
849
  |----|------|
824
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ...) -> AsyncIterator[str]` |
850
+ | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
825
851
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
826
852
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
827
853
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1076,12 +1102,18 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
1076
1102
 
1077
1103
  | 方法 | 说明 |
1078
1104
  |------|------|
1079
- | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体) |
1080
- | `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件 |
1081
- | `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用) |
1105
+ | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1106
+ | `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
1107
+ | `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
1082
1108
 
1083
1109
  > `dest_path=None` 时输出到源文件同目录;同一源文件不支持并发转换。
1084
1110
 
1111
+ **图片**:`![alt](相对路径)` 以 Markdown 源文件所在目录为基准解析,宽度等比限幅
1112
+ (96dpi 还原自然尺寸、不放大),超高先分页/按版心收紧;缺图、越界路径、非图片
1113
+ 格式一律回落 `alt` 文本,不抛异常。远程地址在 PDF / Word 中不下载(回落 alt),
1114
+ 在 HTML 中直出 `<img src="https://…">` 交给浏览器。HTML 输出保留相对路径,需与
1115
+ `images/` 目录保持同层级才能在浏览器中显示。
1116
+
1085
1117
  ---
1086
1118
 
1087
1119
  ## kit.docs.mineru_kit —— MinerU 文档解析
@@ -1348,6 +1380,7 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1348
1380
  | 档案级 | `timeout` | 请求超时秒数(默认 60) |
1349
1381
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1350
1382
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1383
+ | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。改文件后需 `reload_config(reload_file=True)` 生效 |
1351
1384
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1352
1385
 
1353
1386
  ### 候选链切换语义(两类,勿混淆)
@@ -22,7 +22,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
22
22
  | `kit.docs.anydoc_kit` | `AnyDocKit` | docx / pdf / xlsx 等 → Markdown | `anydoc` |
23
23
  | `kit.docs.docx_kit` | `DocxKit` | Word 模板动态填充(章节 / 列表 / 表格) | `docx` |
24
24
  | `kit.docs.rc_docparser_kit` | `RcDocParserKit` | 离线 docx → Markdown + Images(无模型) | `docx-parse` |
25
- | `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML | `md2doc` |
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
28
  | `kit.laya_kit` | `LayaKit` | 类型化决策:choice / score / noul,返回判断结果 + 置信度 | `laya` |
@@ -49,7 +49,7 @@ uv add sdpy-kit
49
49
  | `sqliteai` | SQLiteKit 向量方法(numpy / sqliteai-vector,SQLite 向量扩展) | `pip install "sdpy-kit[sqliteai]"` |
50
50
  | `redis` | RedisKit | `pip install "sdpy-kit[redis]"` |
51
51
  | `ai` | AI 服务池(httpx + python-dotenv) | `pip install "sdpy-kit[ai]"` |
52
- | `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2) | `pip install "sdpy-kit[md2doc]"` |
52
+ | `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2,含图片) | `pip install "sdpy-kit[md2doc]"` |
53
53
  | `anydoc` | AnyDocKit 文档转 Markdown(firecrawl-anydoc + pikepdf) | `pip install "sdpy-kit[anydoc]"` |
54
54
  | `docx` | DocxKit 模板动态填充(docxtpl) | `pip install "sdpy-kit[docx]"` |
55
55
  | `docx-parse` | RcDocParserKit 离线 docx 解析(rc-docparser → Markdown + images/) | `pip install "sdpy-kit[docx-parse]"` |
@@ -709,7 +709,7 @@ shutdown() # 应用退出前
709
709
  |------|------|------|
710
710
  | chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话(仅 user 消息) |
711
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` 使用) |
712
+ | chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
713
713
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
714
714
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
715
715
  | rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
@@ -726,6 +726,32 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
726
726
  print(token, end="", flush=True)
727
727
  ```
728
728
 
729
+ #### 流式收尾统计(思考型模型必备)
730
+
731
+ 迭代器在流结束后可读四个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
732
+ `truncated`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
733
+ token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
734
+
735
+ ```python
736
+ with AI.chat_stream(messages, max_tokens=450) as stream:
737
+ body = "".join(stream)
738
+ if not body and stream.truncated and stream.reasoning_chars:
739
+ raise ValueError(
740
+ f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
741
+ "请调大 max_tokens 或改用关闭思考的档案"
742
+ )
743
+ ```
744
+
745
+ - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
746
+ - 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
747
+ - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
748
+ - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
749
+ - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
750
+ - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
751
+
752
+ > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
753
+ > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
754
+
729
755
  > 门面仅面向**同步上下文**;若调用点本身在 async 函数内,请直接 `await` 原生异步服务,避免阻塞事件循环线程。
730
756
  > 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。
731
757
 
@@ -735,7 +761,7 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
735
761
  from kit.ai import (
736
762
  ChatService, EmbeddingService, RerankService, ImageService, # 服务类
737
763
  ChatRequest, EmbeddingRequest, RerankRequest, ImageRequest, # 请求模型
738
- RerankHit, ModelBuilder, # 结果类型 / 构建器
764
+ RerankHit, StreamStats, ModelBuilder, # 结果类型 / 构建器
739
765
  )
740
766
 
741
767
  service = ModelBuilder.build_chat() # 老按类型构建:build_chat / build_embedding / build_rerank / build_image
@@ -745,7 +771,7 @@ await service.close()
745
771
 
746
772
  | 类 | 方法 |
747
773
  |----|------|
748
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ...) -> AsyncIterator[str]` |
774
+ | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
749
775
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
750
776
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
751
777
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1000,12 +1026,18 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
1000
1026
 
1001
1027
  | 方法 | 说明 |
1002
1028
  |------|------|
1003
- | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体) |
1004
- | `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件 |
1005
- | `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用) |
1029
+ | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1030
+ | `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
1031
+ | `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
1006
1032
 
1007
1033
  > `dest_path=None` 时输出到源文件同目录;同一源文件不支持并发转换。
1008
1034
 
1035
+ **图片**:`![alt](相对路径)` 以 Markdown 源文件所在目录为基准解析,宽度等比限幅
1036
+ (96dpi 还原自然尺寸、不放大),超高先分页/按版心收紧;缺图、越界路径、非图片
1037
+ 格式一律回落 `alt` 文本,不抛异常。远程地址在 PDF / Word 中不下载(回落 alt),
1038
+ 在 HTML 中直出 `<img src="https://…">` 交给浏览器。HTML 输出保留相对路径,需与
1039
+ `images/` 目录保持同层级才能在浏览器中显示。
1040
+
1009
1041
  ---
1010
1042
 
1011
1043
  ## kit.docs.mineru_kit —— MinerU 文档解析
@@ -1272,6 +1304,7 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1272
1304
  | 档案级 | `timeout` | 请求超时秒数(默认 60) |
1273
1305
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1274
1306
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1307
+ | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。改文件后需 `reload_config(reload_file=True)` 生效 |
1275
1308
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1276
1309
 
1277
1310
  ### 候选链切换语义(两类,勿混淆)
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.6.3"
26
+ __version__ = "0.6.5"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -27,7 +27,7 @@ Author: Clark Chang
27
27
  - ImageService: 文生图
28
28
  """
29
29
 
30
- from kit.ai.chat_service import ChatRequest, ChatService
30
+ from kit.ai.chat_service import ChatRequest, ChatService, StreamStats
31
31
  from kit.ai.embedding_service import EmbeddingRequest, EmbeddingService
32
32
  from kit.ai.image_service import ImageRequest, ImageService
33
33
  from kit.ai.model_builder import ModelBuilder
@@ -44,4 +44,5 @@ __all__ = [
44
44
  "RerankHit",
45
45
  "RerankRequest",
46
46
  "RerankService",
47
+ "StreamStats",
47
48
  ]
@@ -20,7 +20,7 @@ Author: Clark Chang
20
20
  import json
21
21
  import time
22
22
  from abc import ABC, abstractmethod
23
- from typing import ClassVar, Final, Self, override
23
+ from typing import ClassVar, Final, Self, cast, override
24
24
 
25
25
  from kit.ai.exceptions import AIError, AIResponseError
26
26
  from kit.ai.model_client import ModelClient
@@ -109,6 +109,19 @@ class BaseAIService[RequestT, ResponseT](ABC):
109
109
  def _build_payload(self, request: RequestT) -> JSONObject:
110
110
  """将请求模型构造为 JSON 请求体。"""
111
111
 
112
+ def _apply_extra_body(self, payload: JSONObject) -> JSONObject:
113
+ """合并档案级 ``extra_body``(厂商专有请求字段透传)。
114
+
115
+ 同名键以服务构造的值为准——``model`` / ``messages`` / ``stream`` /
116
+ ``input`` / ``prompt`` 等保留键**不可被档案覆盖**,故无需白名单;
117
+ 未配置时原样返回(请求体逐字节不变)。
118
+ """
119
+ extra = self._profile.extra_body
120
+ if not extra:
121
+ return payload
122
+ merged: dict[str, object] = {**extra, **payload}
123
+ return cast(JSONObject, merged)
124
+
112
125
  @abstractmethod
113
126
  def _parse_response(self, data: JSONObject) -> ResponseT:
114
127
  """将 JSON 响应体解析为业务结果。"""
@@ -124,7 +137,7 @@ class BaseAIService[RequestT, ResponseT](ABC):
124
137
  ``post_json`` 的重试预算检查;None 表示不限制
125
138
  """
126
139
  desc = self._log_desc()
127
- payload = self._build_payload(request)
140
+ payload = self._apply_extra_body(self._build_payload(request))
128
141
  params = _truncate(
129
142
  json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
130
143
  )
@@ -0,0 +1,266 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ import json
21
+ import time
22
+ from collections.abc import AsyncIterator
23
+ from dataclasses import dataclass
24
+ from typing import ClassVar, Final, cast, override
25
+
26
+ from kit.ai.base_service import (
27
+ _LOG_PAYLOAD_LIMIT,
28
+ BaseAIService,
29
+ _clip,
30
+ _truncate,
31
+ safe_get,
32
+ )
33
+ from kit.ai.exceptions import AIError, AIResponseError
34
+ from kit.ai.types import JSONObject
35
+ from kit.config.log_config import get_default_logger
36
+
37
+ __all__ = ["ChatRequest", "ChatService", "StreamStats"]
38
+
39
+ logger = get_default_logger(__name__)
40
+
41
+ Message = dict[str, str]
42
+
43
+ # 推理(思考)内容可能的字段名:GLM / DeepSeek / Qwen 用 reasoning_content,
44
+ # sensenova 用 reasoning(流式为 delta.*,非流式为 message.*)
45
+ _REASONING_KEYS: Final[tuple[str, ...]] = ("reasoning_content", "reasoning")
46
+
47
+ # 截断判定:OpenAI 系给 length,部分兼容端点给 max_tokens
48
+ _TRUNCATED_REASONS: Final[frozenset[str]] = frozenset({"length", "max_tokens"})
49
+
50
+
51
+ @dataclass
52
+ class StreamStats:
53
+ """对话收尾统计(可变累加器:调用方传入,kit 累加)。
54
+
55
+ 跨线程共享(后台事件循环写、业务线程读):**流结束后为最终值**,
56
+ 流式过程中读到的是尽力而为的中间值。
57
+
58
+ ``reasoning_chars`` / ``content_chars`` 均为**字符数**,与 ``max_tokens``
59
+ (token)不可换算(实测中文约 2~2.5 字符/token,且各 provider 口径不一),
60
+ 仅用于「是否发生思考 / 是否被长度截断」的判定。
61
+
62
+ Attributes:
63
+ reasoning_chars: 推理(思考)内容字符数;非思考模型恒为 0
64
+ content_chars: 已产出正文字符数(等于调用方收到的字符数)
65
+ finish_reason: 结束原因(stop / length / content_filter / …);
66
+ provider 未给出时为 None
67
+ """
68
+
69
+ reasoning_chars: int = 0
70
+ content_chars: int = 0
71
+ finish_reason: str | None = None
72
+
73
+ @property
74
+ def truncated(self) -> bool:
75
+ """是否因输出长度上限被截断。"""
76
+ return self.finish_reason in _TRUNCATED_REASONS
77
+
78
+ def reset(self) -> None:
79
+ """清零(候选链切换到下一个档案前调用)。"""
80
+ self.reasoning_chars = 0
81
+ self.content_chars = 0
82
+ self.finish_reason = None
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ChatRequest:
87
+ """对话请求模型。"""
88
+
89
+ messages: list[Message]
90
+ temperature: float | None = None
91
+ max_tokens: int | None = None
92
+ stream: bool = False
93
+
94
+
95
+ class ChatService(BaseAIService[ChatRequest, str]):
96
+ """对话类模型服务(全异步)。"""
97
+
98
+ _LABEL: ClassVar[str] = "对话模型"
99
+
100
+ @override
101
+ def _build_payload(self, request: ChatRequest) -> JSONObject:
102
+ payload: dict[str, object] = {
103
+ "model": self._profile.model,
104
+ "messages": request.messages,
105
+ "stream": request.stream,
106
+ }
107
+ if request.temperature is not None:
108
+ payload["temperature"] = request.temperature
109
+ # max_tokens 逐级回落:调用点显式传入 → 档案配置 → 不写(provider 默认)
110
+ max_tokens = (
111
+ request.max_tokens
112
+ if request.max_tokens is not None
113
+ else self._profile.max_tokens
114
+ )
115
+ if max_tokens is not None:
116
+ payload["max_tokens"] = max_tokens
117
+ return cast(JSONObject, payload)
118
+
119
+ @override
120
+ def _parse_response(self, data: JSONObject) -> str:
121
+ choice = safe_get(data, "choices", 0)
122
+ if not isinstance(choice, dict):
123
+ raise AIResponseError(f"choices[0] 类型异常: {type(choice).__name__}")
124
+ message = choice.get("message")
125
+ content = message.get("content") if isinstance(message, dict) else None
126
+ finish = choice.get("finish_reason")
127
+ reasoning = 0
128
+ if isinstance(message, dict):
129
+ for key in _REASONING_KEYS:
130
+ part = message.get(key)
131
+ if isinstance(part, str) and part:
132
+ reasoning = len(part)
133
+ break
134
+ if not isinstance(content, str):
135
+ if finish in _TRUNCATED_REASONS and reasoning:
136
+ # 思考型模型截断时可能完全不返回 content 键(实测 sensenova)
137
+ raise AIResponseError(
138
+ f"输出预算被思考耗尽:finish_reason={finish},"
139
+ f"推理 {reasoning} 字符、正文为空"
140
+ "(请调大 max_tokens 或在档案 extra_body 中关闭思考)"
141
+ )
142
+ raise AIResponseError(f"content 类型异常: {type(content).__name__}")
143
+ if finish in _TRUNCATED_REASONS and (reasoning or not content):
144
+ logger.warning(
145
+ f"{self._log_desc()} 输出被长度截断(finish_reason={finish}):"
146
+ f"推理 {reasoning} 字符 / 正文 {len(content)} 字符"
147
+ "(可调大 max_tokens 或在档案 extra_body 中关闭思考)"
148
+ )
149
+ return content
150
+
151
+ async def ask(
152
+ self,
153
+ messages: list[Message],
154
+ *,
155
+ temperature: float | None = None,
156
+ max_tokens: int | None = None,
157
+ deadline: float | None = None,
158
+ ) -> str:
159
+ """多轮对话,返回模型回答文本。
160
+
161
+ Args:
162
+ deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
163
+ 5xx 重试预算;None 表示不限制
164
+ """
165
+ request = ChatRequest(messages, temperature=temperature, max_tokens=max_tokens)
166
+ return await self._call(request, deadline=deadline)
167
+
168
+ async def complete(
169
+ self,
170
+ prompt: str,
171
+ *,
172
+ temperature: float | None = None,
173
+ max_tokens: int | None = None,
174
+ deadline: float | None = None,
175
+ ) -> str:
176
+ messages: list[Message] = [{"role": "user", "content": prompt}]
177
+ return await self.ask(
178
+ messages, temperature=temperature, max_tokens=max_tokens, deadline=deadline
179
+ )
180
+
181
+ async def stream(
182
+ self,
183
+ messages: list[Message],
184
+ *,
185
+ temperature: float | None = None,
186
+ max_tokens: int | None = None,
187
+ stats: StreamStats | None = None,
188
+ ) -> AsyncIterator[str]:
189
+ """流式对话,逐 token 产出正文。
190
+
191
+ Args:
192
+ stats: 收尾统计累加器(可选);None 时内部创建后丢弃——需要
193
+ ``reasoning_chars`` / ``finish_reason`` / ``truncated`` 时
194
+ 由调用方传入一个实例
195
+
196
+ 推理内容(``delta.reasoning_content`` / ``delta.reasoning``)只计数
197
+ 不产出;正文为空且被截断(或发生过思考)时输出 warning。
198
+ """
199
+ request = ChatRequest(
200
+ messages, temperature=temperature, max_tokens=max_tokens, stream=True
201
+ )
202
+ payload = self._apply_extra_body(self._build_payload(request))
203
+ desc = self._log_desc()
204
+ params = _truncate(
205
+ json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
206
+ )
207
+ stats = stats if stats is not None else StreamStats()
208
+ # 惰性 Start:异步生成器体在首次 __anext__ 前不执行,不消费不打日志
209
+ logger.info(f"********** {desc} ********** Start")
210
+ logger.info(f"请求参数: {params}")
211
+ start = time.perf_counter()
212
+ note = ""
213
+ failed = False
214
+ try:
215
+ async for parsed in self._client.stream_json(payload):
216
+ if parsed is None:
217
+ continue
218
+ choices = parsed.get("choices")
219
+ if not isinstance(choices, list) or not choices:
220
+ continue
221
+ choice = choices[0]
222
+ if not isinstance(choice, dict):
223
+ continue
224
+ finish = choice.get("finish_reason")
225
+ if isinstance(finish, str) and finish:
226
+ stats.finish_reason = finish
227
+ delta = choice.get("delta")
228
+ if not isinstance(delta, dict):
229
+ continue
230
+ for key in _REASONING_KEYS:
231
+ part = delta.get(key)
232
+ if isinstance(part, str) and part:
233
+ stats.reasoning_chars += len(part)
234
+ break
235
+ content = delta.get("content")
236
+ if isinstance(content, str) and content:
237
+ stats.content_chars += len(content)
238
+ yield content
239
+ except AIError as exc:
240
+ note = f"(失败:{exc})"
241
+ failed = True
242
+ raise
243
+ except BaseException: # noqa: BLE001 - 中断路径(提前 break/取消)也需收尾日志
244
+ note = "(中断)"
245
+ raise
246
+ else:
247
+ self._warn_if_budget_eaten(stats, payload.get("max_tokens"))
248
+ finally:
249
+ log = logger.error if failed else logger.info
250
+ log(
251
+ f"********** {desc} ********** "
252
+ f"耗时 {time.perf_counter() - start:.1f}秒{note}"
253
+ )
254
+
255
+ def _warn_if_budget_eaten(
256
+ self, stats: StreamStats, max_tokens: object
257
+ ) -> None:
258
+ """正文为空且(被截断或有推理)时告警:讲清「预算被思考吃掉」。"""
259
+ if stats.content_chars or not (stats.truncated or stats.reasoning_chars):
260
+ return
261
+ logger.warning(
262
+ f"{self._log_desc()} 输出预算疑被思考耗尽:max_tokens={max_tokens},"
263
+ f"finish_reason={stats.finish_reason},"
264
+ f"推理 {stats.reasoning_chars} 字符 / 正文 0 字符"
265
+ "(可调大 max_tokens 或在档案 extra_body 中关闭思考)"
266
+ )