Metadata-Version: 2.5
Name: sdpy-kit
Version: 0.2.3
Summary: SDPY Kit——统一数据库访问层（PG/MySQL/SQLite）与 AI/MCP/Redis/文件/文档/向量工具集
Author-email: Clark <changhongyuan@126.com>
License-Expression: MIT
Keywords: ai,kit,mcp,mysql,postgresql,redis,sdpy,sqlalchemy,sqlite,sqlmodel,向量检索,工具集,开发工具,数据库
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: Chinese (Simplified)
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.13
Requires-Dist: pydantic-settings>=2.15.0
Requires-Dist: pydantic>=2.13.5
Requires-Dist: sqlalchemy~=2.0.54
Requires-Dist: sqlmodel>=0.0.46
Provides-Extra: ai
Requires-Dist: httpx>=0.28.1; extra == 'ai'
Requires-Dist: python-dotenv>=1.2.3; extra == 'ai'
Provides-Extra: all
Requires-Dist: docxtpl>=0.20.2; extra == 'all'
Requires-Dist: fastmcp>=4.0.5; extra == 'all'
Requires-Dist: firecrawl-anydoc>=0.2.4; extra == 'all'
Requires-Dist: httpx>=0.28.1; extra == 'all'
Requires-Dist: markdown-it-py>=4.2.0; extra == 'all'
Requires-Dist: marker-pdf[full]>=2.0.0; extra == 'all'
Requires-Dist: numpy>=2.5.3; extra == 'all'
Requires-Dist: pikepdf>=10.13.0; extra == 'all'
Requires-Dist: pimd[pdfa]>=2.2.5; extra == 'all'
Requires-Dist: psycopg2-binary>=2.9.13; extra == 'all'
Requires-Dist: pymysql>=1.2.3; extra == 'all'
Requires-Dist: python-dotenv>=1.2.3; extra == 'all'
Requires-Dist: redis>=8.1.0; extra == 'all'
Requires-Dist: requests>=2.34.2; extra == 'all'
Requires-Dist: sqliteai-vector>=1.1.2; extra == 'all'
Requires-Dist: zvec>=0.7.0; extra == 'all'
Provides-Extra: anydoc
Requires-Dist: firecrawl-anydoc>=0.2.4; extra == 'anydoc'
Requires-Dist: pikepdf>=10.13.0; extra == 'anydoc'
Provides-Extra: docx
Requires-Dist: docxtpl>=0.20.2; extra == 'docx'
Provides-Extra: marker
Requires-Dist: marker-pdf[full]>=2.0.0; extra == 'marker'
Provides-Extra: mcp
Requires-Dist: fastmcp>=4.0.5; extra == 'mcp'
Provides-Extra: md2doc
Requires-Dist: markdown-it-py>=4.2.0; extra == 'md2doc'
Requires-Dist: pimd[pdfa]>=2.2.5; extra == 'md2doc'
Provides-Extra: mineru
Requires-Dist: requests>=2.34.2; extra == 'mineru'
Provides-Extra: mysql
Requires-Dist: pymysql>=1.2.3; extra == 'mysql'
Provides-Extra: pg
Requires-Dist: psycopg2-binary>=2.9.13; extra == 'pg'
Provides-Extra: redis
Requires-Dist: redis>=8.1.0; extra == 'redis'
Provides-Extra: sqliteai
Requires-Dist: numpy>=2.5.3; extra == 'sqliteai'
Requires-Dist: sqliteai-vector>=1.1.2; extra == 'sqliteai'
Provides-Extra: zvec
Requires-Dist: zvec>=0.7.0; extra == 'zvec'
Description-Content-Type: text/markdown

# sdpy-kit

SDPY 统一工具集（Kit 体系），供 Python 项目开箱即用：

- **统一数据库访问层**：PostgreSQL / MySQL / SQLite，三个 Kit API 完全一致，切换数据库只改一行 import；
- **AI 多模型服务池**：chat / embedding / rerank / image 四类能力，多厂商候选链 + 故障自动转移；
- **MCP 客户端**：streamable-http / sse 双传输，档案驱动多服务器；
- **工具集**：Redis / 文件 / 雪花 ID / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析（md + images） / 向量库 / 微信公众号文章抓取存储。

> 要求 Python 3.13+

## 模块与方法入口

| 模块 | 类 / 入口 | 能力 | 需要 extra |
|------|-----------|------|-----------|
| `kit.config` | `get_settings` / `get_default_logger` | 配置单例与统一日志 | 无 |
| `kit.db` | `SQLiteKit` / `PgKit` / `MySqlKit` / `CRUDBase` | 统一 CRUD、原生 SQL、原生表操作、事务、表管理 | `pg` / `mysql` / `sqliteai` |
| `kit.ai` | `AI`（同步门面）/ 4 个原生异步 Service | 对话 / 向量化 / 重排序 / 文生图 | `ai` |
| `kit.mcp_kit` | `McpKit` | MCP 工具发现与调用 | `mcp` |
| `kit.redis_kit` | `RedisKit` | Redis string / hash / list / set / zset | `redis` |
| `kit.file_kit` | `FileKit` | 文件与目录读写、移动、遍历 | 无 |
| `kit.common_kit` | `CommonKit` | 雪花 ID、时间戳 ID、时间格式化 | 无 |
| `kit.anydoc_kit` | `AnyDocKit` | docx / pdf / xlsx 等 → Markdown | `anydoc` |
| `kit.docx_kit` | `DocxKit` | Word 模板动态填充（章节 / 列表 / 表格） | `docx` |
| `kit.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML | `md2doc` |
| `kit.mineru_kit` | `MinerUKit` | MinerU 文档解析（精准 + Agent 轻量） | `mineru` |
| `kit.marker_kit` | `MarkerKit` | 本地文档解析（PDF / 图片 / Office → Markdown + Images） | `marker` |
| `kit.zvec_kit` | `ZvecKit` | zvec 本地向量库 | `zvec` |
| `kit.wx_article_kit` | `WxArticleKit` | 微信公众号文章 → Markdown + 图片转存 | 无 |

## 安装

```bash
pip install sdpy-kit            # 基础版：config/log + kit.db 核心（SQLite 常规操作零额外依赖）
# 或
uv add sdpy-kit
```

可选能力按 extras 按需安装（均为延迟导入，不装不影响基础使用，缺依赖时报错会提示安装命令）：

| extra | 能力 | 安装 |
|-------|------|------|
| `pg` | PgKit（PostgreSQL 驱动；向量方法另需服务端 pgvector） | `pip install "sdpy-kit[pg]"` |
| `mysql` | MySqlKit（MySQL 驱动；向量仅存储，需 MySQL 9.x） | `pip install "sdpy-kit[mysql]"` |
| `sqliteai` | SQLiteKit 向量方法（numpy / sqliteai-vector，SQLite 向量扩展） | `pip install "sdpy-kit[sqliteai]"` |
| `zvec` | ZvecKit（zvec，阿里独立向量库） | `pip install "sdpy-kit[zvec]"` |
| `redis` | RedisKit | `pip install "sdpy-kit[redis]"` |
| `mcp` | McpKit（fastmcp） | `pip install "sdpy-kit[mcp]"` |
| `ai` | AI 服务池（httpx + python-dotenv） | `pip install "sdpy-kit[ai]"` |
| `md2doc` | Md2DocKit：Markdown → Word / PDF / HTML（pimd / fpdf2） | `pip install "sdpy-kit[md2doc]"` |
| `anydoc` | AnyDocKit 文档转 Markdown（firecrawl-anydoc + pikepdf） | `pip install "sdpy-kit[anydoc]"` |
| `docx` | DocxKit 模板动态填充（docxtpl） | `pip install "sdpy-kit[docx]"` |
| `mineru` | MinerUKit 解析 API（精准解析 + Agent 轻量解析） | `pip install "sdpy-kit[mineru]"` |
| `marker` | MarkerKit 本地文档解析（marker-pdf[full] → Markdown + Images，依赖体积大） | `pip install "sdpy-kit[marker]"` |
| `all` | 全部能力 | `pip install "sdpy-kit[all]"` |

## 配置（.env）

kit 通过 pydantic-settings 读取项目根目录 `.env`；**同名真实环境变量优先于 `.env`**。全部变量都有默认值，按需覆盖即可。

### 最小样例 `.env`

```dotenv
# 日志（可选）
LOG_LEVEL=INFO
LOG_TO_FILE=false

# 数据库（三选一，切换只改 DEFAULT_DB_TYPE：postgresql / mysql / sqlite）
DEFAULT_DB_TYPE=sqlite
SQLITE_DB_PATH=./data/sqlite.db
# DB_HOST=localhost
# DB_PORT=5432
# DB_DATABASE=postgres
# DB_USER=admin
# DB_PASSWORD=
# MYSQL_PORT=3306
# MYSQL_DATABASE=mydb
# MYSQL_USER=root
# MYSQL_CHARSET=utf8mb4

# 按需启用以下能力
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_DB=0
# REDIS_PASSWORD=
# FILE_STORAGE_ROOT=./data
# ZVEC_DATA_DIR=./data/zvec

# AI：档案路径 + 你为各模型档案自定的密钥变量名（变量名在 ai_models.json 里声明）
# AI_MODELS_CONFIG_PATH=./config/ai_models.json
# SILICONFLOW_API_KEY=sk-xxxxxx

# MCP：档案路径 + 你为各服务器自定的 token 变量名（变量名在 mcp_servers.json 里声明）
# MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.json
# AMINER_TOKEN=xxxxxx

# MinerU（仅精准解析需要 Key，Agent 轻量解析免登录）
# MINERU_API_KEY=

# Marker（本地解析，可选依赖 sdpy-kit[marker]）
# MARKER_MODE=fast              # None=按设备自动（GPU→balanced / CPU、MPS→fast）
# MARKER_HF_ENDPOINT=https://hf-mirror.com
# MARKER_HF_DISABLE_XET=true
# MARKER_LLAMA_CPP_BINARY=
```

### 环境变量 ↔ 能力对照表

> 表中未出现的模块（`kit.common_kit`、`kit.docx_kit`、`kit.md2doc_kit`、`kit.wx_article_kit`）不读取任何环境变量。

**基础与日志**（影响 `kit.config` 及所有模块的日志输出）

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `LOG_LEVEL` | `INFO` | kit 统一日志级别 |
| `LOG_TO_CONSOLE` | `true` | 是否输出到控制台 |
| `LOG_TO_FILE` | `false` | 是否输出到文件 |
| `LOG_DIR` | `./logs` | 日志目录 |
| `LOG_FILE` | `app.log` | 日志文件名 |

**数据库** → `kit.db`、`SQLiteKit` / `PgKit` / `MySqlKit`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `DEFAULT_DB_TYPE` | `postgresql` | 默认数据库类型：`postgresql` / `mysql` / `sqlite`；切换数据库只改这一项 |
| `DB_HOST` | `localhost` | PG / MySQL 共用主机 |
| `DB_PORT` | `5432` | PostgreSQL 端口 |
| `DB_DATABASE` | `postgres` | PostgreSQL 库名 |
| `DB_USER` | `admin` | PostgreSQL 用户 |
| `DB_PASSWORD` | `1qaz@WSX3edc` | PG / MySQL 共用密码 |
| `MYSQL_PORT` | `3306` | MySQL 端口（与 PG 不同时覆盖） |
| `MYSQL_DATABASE` | `mydb` | MySQL 库名 |
| `MYSQL_USER` | `root` | MySQL 用户 |
| `MYSQL_CHARSET` | `utf8mb4` | MySQL 字符集 |
| `SQLITE_DB_PATH` | `./data/sqlite.db` | SQLite 文件路径（`:memory:` 时自动共享单连接） |

**Redis** → `kit.redis_kit`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `REDIS_HOST` | `localhost` | Redis 主机 |
| `REDIS_PORT` | `6379` | Redis 端口 |
| `REDIS_DB` | `0` | 数据库编号 |
| `REDIS_PASSWORD` | `None` | 密码，无密码留空 |

**文件存储** → `kit.file_kit`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `FILE_STORAGE_ROOT` | `./data` | FileKit 全部相对路径的根目录。⚠ 该目录**必须已存在**，否则首次调用抛 `RuntimeError`（根路径不存在） |

**向量库** → `kit.zvec_kit`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `ZVEC_DATA_DIR` | `./data/zvec` | Collection 根目录 |
| `ZVEC_DEFAULT_METRIC` | `cosine` | 默认距离度量（`cosine` / `l2` / `ip`） |
| `ZVEC_QUERY_THREADS` | `None`（自适应） | 查询线程数 |
| `ZVEC_OPTIMIZE_THREADS` | `None`（自适应） | 索引构建线程数 |
| `ZVEC_MEMORY_LIMIT_MB` | `None`（自适应） | 内存软上限 |
| `ZVEC_LOG_DIR` | `./logs/zvec` | zvec 引擎独立日志目录 |
| `ZVEC_LOG_LEVEL` | `warn` | zvec 引擎日志级别 |

**AI** → `kit.ai`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `AI_MODELS_CONFIG_PATH` | `./config/ai_models.json` | 档案文件位置；文件不存在时调用抛 `AIConfigError` |
| `KIT_AI_CHAIN_TIMEOUT` | 未设置 | 全局整链预算（秒）= 门面同步等待上限。⚠ **不是 `Settings` 字段**，由代码直读 `os.environ`，无类型/范围校验，非法值仅告警后回退 |
| *（密钥，变量名由档案决定）* | 无 | 名字写在 `ai_models.json` 各档案的 `api_key` 字段（如 `SILICONFLOW_API_KEY`），值放 `.env` 或系统环境变量。未配置的档案会被剔除出候选链；该类型全部未配置则调用失败 |

**MCP** → `kit.mcp_kit`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `MCP_SERVERS_CONFIG_PATH` | `./config/mcp_servers.json` | 服务器档案位置 |
| `MCP_CALL_TIMEOUT` | `60`（1–600） | 档案未声明 `timeout` 时的调用超时 |
| *（令牌，变量名由档案决定）* | 无 | 名字写在 `mcp_servers.json` 各服务器的 `token_env` 字段（如 `AMINER_TOKEN`）；档案声明了却两处都未配置时抛 `McpKitError` |

**MinerU** → `kit.mineru_kit`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `MINERU_API_KEY` | 无 | 精准解析 Token（Agent 轻量解析免登录，不读该变量）；缺失时抛 `MinerUError` |
| `MINERU_BASE_API_URL` | `https://mineru.net` | API 基础地址 |
| `MINERU_TIMEOUT` | `60`（1–600） | 单次 HTTP 请求连接超时 |
| `MINERU_TRANSFER_TIMEOUT` | `300`（1–3600） | 文件上传 / 结果下载读取超时 |
| `MINERU_POLL_INTERVAL` | `2.0`（≥0.5） | 任务轮询间隔 |
| `MINERU_MAX_WAIT` | `600`（≥10） | 轮询总超时，超时抛 `MinerUError` |

**Marker** → `kit.marker_kit`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `MARKER_MODE` | 无（不写入 config） | 解析模式 `balanced` / `fast`；不配置则由 marker 按设备自动选择（GPU→balanced，CPU、MPS→fast） |
| `MARKER_HF_ENDPOINT` | `https://hf-mirror.com` | 模型权重下载入口（国内镜像）；置空则用 HuggingFace 官方源 |
| `MARKER_HF_DISABLE_XET` | `true` | 禁用 HF Xet 存储后端（国内镜像不支持，开启会导致权重下载挂起） |
| `MARKER_LLAMA_CPP_BINARY` | 无 | `llama-server` 路径（非 NVIDIA 平台完整 VLM 能力）；未设置时沿用系统 `LLAMA_CPP_BINARY` |

> 这四项由 kit 在**导入 marker 之前**经 `os.environ.setdefault` 应用：仅当用户未显式设置对应环境变量时生效，不覆盖显式配置。

**云 OCR**（任何 `ocr="hosted"` 的转换）→ `kit.anydoc_kit`

| 环境变量 | 默认值 | 作用 |
|----------|--------|------|
| `FIRECRAWL_API_KEY` | 无 | 扫描件云 OCR 密钥。kit **不读**该变量：仅在方法参数 `api_key=None` 时由 firecrawl-anydoc 自行回落读取 |
| `FIRECRAWL_API_URL` | 无 | 云 OCR 服务地址，回落规则同上 |

### 环境变量的四类来源

| 类别 | 命名方式 | 当前变量 | 读取方与语义 |
|------|----------|----------|--------------|
| ① Settings 变量 | 固定，kit 定义 | 上表中除 `KIT_AI_CHAIN_TIMEOUT` 与密钥类之外的全部 | pydantic-settings 读 `.env` 与真实环境变量（后者优先），带类型与范围校验，缺失用默认值 |
| ② 直读变量 | 固定，kit 代码直接读 `os.environ` | `KIT_AI_CHAIN_TIMEOUT` | 无类型校验，非法值告警后回退 |
| ③ 档案声明变量 | **动态**，名字写在 JSON 里 | AI 的 `api_key`、MCP 的 `token_env` | **名字进 JSON、值进 `.env`**——所以档案文件可以安全入库，密钥留在环境里 |
| ④ 第三方库变量 | 由依赖库定义 | `FIRECRAWL_API_KEY`、`FIRECRAWL_API_URL`（anydoc）；`HF_ENDPOINT` / `HF_HUB_DISABLE_XET` / `LLAMA_CPP_BINARY`（marker） | anydoc：kit 只透传 `None`，由其自行回落；marker：kit 在导入前按 `MARKER_*` 配置做 `os.environ.setdefault`（不覆盖显式设置） |

### 密钥解析优先级

| 能力 | 解析顺序 | 全部缺失时 |
|------|----------|------------|
| AI | 真实环境变量 > 项目根 `.env`（首次加载档案时经 `load_dotenv(override=False)` 注入 `os.environ`） | 该档案被剔除出候选链；该类型无可用档案则调用报 `AIAggregateError` |
| MCP | `os.environ` > 项目根 `.env`（kit 自行解析文件） | 抛 `McpKitError` |
| MinerU | 仅 `Settings`（`.env` / 真实环境变量） | 精准解析接口抛 `MinerUError` |

> AI 的 `load_dotenv(override=False)` 表示已存在的真实环境变量不会被 `.env` 覆盖——生产用容器 / 系统环境变量注入时，可以放心保留开发用的 `.env`。

### 改了配置何时生效

| 改动内容 | 生效时机 |
|----------|----------|
| `LOG_*`、`DB_*`、`MYSQL_*`、`REDIS_*`、`FILE_STORAGE_ROOT`、`ZVEC_*`、`MINERU_*`、`MARKER_MODE` | `get_settings()` 带 `lru_cache`，进程内首次读取后固定 → **重启进程**（或调用 `AI.reload_config()`，它会顺带清空该缓存） |
| `MARKER_HF_ENDPOINT` / `MARKER_HF_DISABLE_XET` / `MARKER_LLAMA_CPP_BINARY` | 仅在 `import kit.marker_kit` 时经 `os.environ.setdefault` 应用 → 改动需**重启进程**重新导入模块 |
| AI 密钥写在 `.env` 里 | `.env` 只在加载档案时注入一次 → 重启，或 `AI.reload_config(reload_file=True)` |
| AI 密钥由自己写入 `os.environ` | `AI.reload_config()`（默认 `reload_file=False`，只重读 `os.environ`） |
| `ai_models.json`（增删档案、换 model） | `AI.reload_config(reload_file=True)` |
| `KIT_AI_CHAIN_TIMEOUT` | 每次调用现读 `os.environ` → 改 `os.environ` 立即生效；写在 `.env` 里的新值要等下一次档案加载 |
| MCP token | 每次调用现读 `os.environ` 并回退解析 `.env` → **改 `.env` 立即生效，无需重启** |

---

## kit.config —— 配置与日志

```python
from kit.config import get_settings, get_default_logger

settings = get_settings()               # Settings 单例（lru_cache，读取 .env）
logger = get_default_logger(__name__)   # 统一日志入口（禁止自建 logging handler）
logger.info("hello %s", settings.FILE_STORAGE_ROOT)
```

| 入口 | 说明 |
|------|------|
| `get_settings() -> Settings` | 返回全局配置单例，字段名与环境变量同名（如 `settings.SQLITE_DB_PATH`、`settings.DEFAULT_DB_TYPE`），完整清单见上文对照表 |
| `get_default_logger(name=None, log_level=None, log_file=None) -> logging.Logger` | 获取统一格式日志器（控制台 + 轮转文件） |
| `setup_logger(name=None, log_level=None, log_file=None, log_format=..., date_format=..., max_bytes=5*1024*1024, backup_count=5) -> logging.Logger` | 完整参数版本，一般无需直接使用 |

---

## kit.db —— 统一数据库访问层

`kit/__init__.py` 直接重导出 `kit.db` 的全部公开符号，下列两种写法等价：

```python
from kit.db import session_scope, transactional, DBError    # 推荐
from kit import session_scope, transactional, DBError       # 等价
```

### 1. 三个数据库 Kit（23 个共享方法 API 完全一致）

| 类 | 导入 | 数据库 | extra |
|----|------|--------|-------|
| `SQLiteKit` | `from kit.db.sqlite_kit import SQLiteKit` | SQLite | 无（常规操作） |
| `PgKit` | `from kit.db.pg_kit import PgKit` | PostgreSQL | `pg` |
| `MySqlKit` | `from kit.db.mysql_kit import MySqlKit` | MySQL | `mysql` |

**共有方法（23 个，全部为 classmethod，内部自动提交）**

| 分组 | 方法 | 说明 |
|------|------|------|
| 连接 | `connect() -> bool` | 建引擎并验证连通 |
| 连接 | `close() -> None` | 关闭引擎 |
| 连接 | `is_connected() -> bool` | 探测当前是否可连 |
| 连接 | `raw_connection()` | 取原生 DBAPI 连接（PG=psycopg2 / MySQL=pymysql / SQLite=sqlite3）；调用方负责 commit 与 close，psycopg2 / pymysql 默认不自动提交 |
| ORM | `create(model, data) -> Model` | 插入单条 |
| ORM | `create_multi(model, data_list) -> list[Model]` | 批量插入 |
| ORM | `get(model, id) -> Model \| None` | 按主键查询 |
| ORM | `get_by(model, field_name, field_value) -> Model \| None` | 按字段等值查询 |
| ORM | `list(model, *, skip=0, limit=100, order_by=None, desc=False) -> list[Model]` | 分页 + 排序查询 |
| ORM | `update(model, id, data) -> Model` | 按主键更新 |
| ORM | `delete(model, id) -> bool` | 按主键删除 |
| ORM | `upsert(model, data, conflict_fields) -> Model` | 原子插入或更新（`conflict_fields` 须为唯一约束） |
| ORM | `count(model) -> int` | 记录总数 |
| 原生 SQL | `query(sql, params=None) -> list[dict]` | 原生查询，返回字典列表 |
| 原生 SQL | `execute(sql, params=None) -> int` | 原生写入，返回影响行数 |
| 原生表 | `create_raw_table(table_name, columns, if_not_exists=True) -> None` | 按「列名 → 列定义」建普通表；⚠ 列定义须按目标库方言书写，不做方言翻译 |
| 原生表 | `raw_insert(table_name, data) -> int` | 插入一行，返回影响行数 |
| 原生表 | `raw_get_by(table_name, column, value) -> dict \| None` | 按单列等值取首行 |
| 事务 | `transaction()` | 事务上下文管理器，`with PgKit.transaction() as session:` |
| 表 | `list_tables() -> list[str]` | 列出全部表名 |
| 表 | `table_exists(table_name) -> bool` | 判断表是否存在 |
| 表 | `create_tables(models) -> None` | 按模型列表建表 |
| 表 | `get_table_info(table_name) -> list[dict]` | 获取列信息（按列序；公共键 4 个：`column_name`/`data_type`/`is_nullable`/`column_default`） |

> **列表参数（`IN`）**：`query` / `execute` 的 `params` 中值为 `list`/`tuple`/`set`/`frozenset`
> 时按 `IN` 展开，可直接写 `WHERE id IN :ids`（三库通用）。`IN` **不保证顺序**；参数列表过大
> 应分批（PG 单语句上限 65535 参数）。
>
> **`LIKE` 的方言差异**：SQLite 对 ASCII 不区分大小写，PostgreSQL 区分（用 `ILIKE`）；
> 跨库一致写法：`LOWER(col) LIKE LOWER(:pattern)`。
>
> **导出面**：`from kit.db import PgKit, SQLiteKit, build_pg_url, to_vector_literal, …`
> （`kit` 顶层与 `kit.db` 导出同一套符号）。

```python
from sqlalchemy import BigInteger
from sqlmodel import SQLModel, Field
from kit.common_kit import CommonKit
from kit.db import SQLiteKit          # 也可 from kit.db.sqlite_kit import SQLiteKit

class User(SQLModel, table=True):
    # 主键：64 位整数 + 雪花 ID（应用侧生成，不用数据库自增）
    id: int = Field(default_factory=CommonKit.generate_snowflake_id,
                    primary_key=True, sa_type=BigInteger)
    name: str

SQLiteKit.connect()
SQLiteKit.create_tables([User])

user = SQLiteKit.create(User, {"name": "张三"})           # 自动提交
SQLiteKit.update(User, user.id, {"name": "李四"})
users = SQLiteKit.list(User, skip=0, limit=10, order_by="id", desc=True)

rows = SQLiteKit.query("SELECT * FROM user WHERE name = :name", {"name": "李四"})
affected = SQLiteKit.execute("UPDATE user SET name = '王五' WHERE id = :id", {"id": user.id})
rows = SQLiteKit.query("SELECT * FROM user WHERE id IN :ids", {"ids": [1, 2, 3]})   # 列表参数

with SQLiteKit.transaction() as session:                  # 多操作原子提交
    SQLiteKit.create(User, {"name": "批量一"})
    SQLiteKit.create(User, {"name": "批量二"})
```

> 切换数据库：把 import 的 `SQLiteKit` 换成 `PgKit` / `MySqlKit` 即可，其余代码不变。
> 注意三点例外：① `create_raw_table` 的列定义文本须按目标库方言改写；② 向量能力
> **三库同名同参但能力不同**（可由 `VECTOR_CAPABILITIES` feature-detect），尤其
> **MySQL 侧检索不可用**；③ `create_vector_table` 的 `dims` 为**必填**参数。

### 2. 向量方法（三库同名同参，能力差异化）

> 签名在 `SQLiteKit` / `PgKit` / `MySqlKit` 上完全一致；能力按后端不同（见下表）。
> extra 前提：SQLite 需 `sdpy-kit[sqliteai]`；PG 需 `sdpy-kit[pg]` **且服务端已装 pgvector**
> （`create_vector_table` 会自动尝试 `CREATE EXTENSION`）；MySQL 需 **9.x**（Community 即可存，
> 但检索需 HeatWave on OCI / MySQL AI）——建表前会校验服务端版本，低于 9.x 抛 `DBError`。

| 方法 | 说明 |
|------|------|
| `create_vector_table(table_name, dims, id_col="id", column="embedding", extra_cols=None) -> None` | 建含向量列的表；**`dims` 必填**（SQLite 用 `CHECK` 落实为数据库层约束；PG 硬约束并回读校验已存在表；MySQL 为上限语义） |
| `insert_vectors(table_name, vectors, ids=None, extra_data=None, id_col="id", column="embedding") -> int` | 批量插入向量（单事务提交）；`ids=None` 时由 Kit 生成雪花 ID；接受 `list[list[float]]` 或二维 numpy 数组 |
| `search_vectors(table_name, query_vector, top_k=10, distance_type="L2", filter_sql=None, exact=True, id_col="id", column="embedding") -> list[dict]` | KNN 检索，按距离升序返回 `{"id", "distance"}`；`exact=True`（默认）禁止近似加速；**MySQL 侧抛 `VectorNotSupportedError`** |
| `vector_version() -> str` | 向量后端版本；不可用时返回 `"unavailable"`（MySQL 侧为服务端版本号） |

**`id_col` / `column`（逃生口）**：让**自建 DDL 的向量表**也能读写/检索。需要复合主键、额外约束、
分区或与既有业务表合表时，用 `execute()` 自建 DDL 再用这两个参数接入；SQLite 侧要求 `id_col`
为 rowid 别名（`INTEGER PRIMARY KEY`）。

**`exact` 参数（默认 `True` = 禁止近似加速）**：

| 后端 | `exact=True`（默认） | `exact=False` | 是否提速 |
|------|---------------------|---------------|----------|
| `PgKit` | 同事务 `SET LOCAL enable_indexscan = off`（有索引也精确；**代价：该查询不使用任何索引**，含过滤列索引） | 不干预查询计划（有索引即近似召回，`distance` 仍为真值） | ✅ 仅"大表 + 已建索引"时 |
| `SQLiteKit` | `vector_full_scan`（**真值 L2**） | `vector_quantize_scan`（量化 L2 = 真值 × 255） | ❌ **不提速**（每次调用重建量化） |
| `MySqlKit` | 不适用 | 同左 | — |

**能力差异（仅 L2 可跨库比较数值；float32 vs float64，需带容差）**：

| 项 | `SQLiteKit`（sqlite-vector） | `PgKit`（pgvector） | `MySqlKit`（9.x 原生） |
|----|------------------------------|---------------------|------------------------|
| 支持的 `distance_type` | **仅 L2** | `COSINE` / `L2` / `L1` / `DOT`（负内积） / `SQUARED_L2` | **无（检索不可用）** |
| 默认 `distance_type` | `"L2"` | `"L2"`（三库统一，不按后端覆写） | `"L2"`（形参保留，调用即抛错） |
| 非法取值 | 抛 `ValueError` | 抛 `ValueError` | `search_vectors` 抛 `VectorNotSupportedError` |
| `distance` 口径 | `exact=True` 真值 L2 / `exact=False` 量化（×255） | 真实距离 | — |
| 维度约束 | `CHECK`（仅新表；旧表靠方法内校验） | 强制（不匹配即报错；建表时回读比对） | 上限约束（超限报错） |
| `filter_sql` 与 `top_k` | **先过滤再取 top_k**（与 PG 一致） | **先过滤再取 top_k** | 不适用 |
| 索引 | 无（精确路径为全表扫描） | kit 不建索引；**自建后须 `exact=False` 才会被使用** | 无法建（B-Tree 拒绝向量列） |

> MySQL `DISTANCE()` / `VECTOR_DISTANCE()` 仅 HeatWave on OCI 与 MySQL AI 提供
> （Community 实测报 `1305 FUNCTION ... does not exist`）；故 `MySqlKit` 只覆盖存储侧。

**主键与 id（三库统一）**：向量表主键均为 `BIGINT`（SQLite 用等价的 64 位 `INTEGER PRIMARY KEY`），
**不使用数据库自增**；`ids=None` 时由 Kit 用 `CommonKit.generate_snowflake_id()` 生成雪花 ID
（18~19 位，int64）。
⚠ 雪花 ID 超过 `2^53`，经 JSON 传给 JavaScript 会丢精度，需按字符串传输；
多实例部署需各自 `CommonKit.configure(datacenter_id, machine_id)`（默认 `(1,1)` 会重复）。

```python
# SQLite（默认精确：distance 为真值 L2）
from kit.db.sqlite_kit import SQLiteKit
SQLiteKit.create_vector_table("images", 384)          # dims 必填
SQLiteKit.insert_vectors("images", [[0.1] * 384, [0.2] * 384])
rows = SQLiteKit.search_vectors("images", [0.1] * 384, top_k=5)
rows = SQLiteKit.search_vectors("images", [0.1] * 384, top_k=5, exact=False)  # 旧量化口径（不提速）

# PostgreSQL（签名相同；可选 COSINE/L2/L1/DOT/SQUARED_L2）
from kit.db.pg_kit import PgKit
PgKit.create_vector_table("images", 384)
PgKit.insert_vectors("images", [[0.1] * 384, [0.2] * 384])
rows = PgKit.search_vectors("images", [0.1] * 384, top_k=5, distance_type="COSINE")
# 大表 + 自建索引时才用 exact=False（否则索引不被使用，见上表）
PgKit.execute("CREATE INDEX ON images USING hnsw (embedding vector_cosine_ops)")
rows = PgKit.search_vectors("images", [0.1] * 384, top_k=5,
                            distance_type="COSINE", exact=False)

# MySQL（仅存储；search_vectors 抛 VectorNotSupportedError）
from kit.db.mysql_kit import MySqlKit
MySqlKit.create_vector_table("images", 384)
MySqlKit.insert_vectors("images", [[0.1] * 384, [0.2] * 384])

# 能力自省（无需 try/except）
PgKit.VECTOR_CAPABILITIES
# {'store': True, 'search': True, 'exact': True,
#  'distances': ('COSINE', 'L2', 'L1', 'DOT', 'SQUARED_L2'),
#  'distance_kind': 'true', 'fast_distance_kind': 'true'}

# 自建 DDL 的表：用 id_col / column 接入
PgKit.execute("CREATE TABLE my_vec (vid BIGINT PRIMARY KEY, vec vector(384))")
PgKit.insert_vectors("my_vec", [[0.1] * 384], ids=[1], id_col="vid", column="vec")
PgKit.search_vectors("my_vec", [0.1] * 384, top_k=5, id_col="vid", column="vec")
```

#### 0.2.2 行为变更（升级须知）

| 编号 | 变更 | 影响 |
|------|------|------|
| B1 | `search_vectors` 新增 `exact: bool = True`，默认**精确** | SQLite 返回的 `distance` 由量化值（≈L2×255）变为**真值 L2**；需旧口径请显式 `exact=False` |
| B2 | `filter_sql` 语义统一为**先过滤、再取 top_k** | 传 `filter_sql` 的调用返回值**变多/变全**（旧结果 ⊆ 新结果）；曾自行放大 `top_k` 兜底的调用可收紧 |
| B3 | 默认 `distance_type` 由 `"COSINE"` 改为 `"L2"` | 依赖默认值的调用方请显式传 `distance_type="COSINE"` |
| B4 | `create_vector_table` 的 `dims` **必填**，参数顺序为 `(table_name, dims, id_col, column, extra_cols)` | `create_vector_table("t")` 类调用需补维度；位置传 `id_col` 的调用改为关键字 |
| B5 | MySQL 的 `search_vectors` 异常由 `NotImplementedError` 改为 `VectorNotSupportedError`（继承 `DBError`） | `except NotImplementedError` 需改为 `except VectorNotSupportedError`（或 `except DBError`） |

> 新增（非破坏性）：`VECTOR_CAPABILITIES` 能力声明、`id_col` / `column` 逃生口参数、
> `insert_vectors` 支持二维 numpy 数组与批量提交。

#### 0.2.3 变更

| 编号 | 变更 | 影响 |
|------|------|------|
| B6 | **`PgKit.get_table_info` 按数据库解析语义定位表**（`search_path` 解析到的那张表） | 多 schema 下同名表**不再混行**；单 schema / `public` 场景**无影响**；只接受非限定表名 |
| — | `query` / `execute` 支持**列表参数**（`IN :ids`，三库通用） | 纯新增；`set`/`frozenset` 自动归一化为 `list` |
| — | 新增 `SQLiteKit.encode_vector` / `decode_vector`（小端 float32） | 纯新增：可在自有事务内写向量表，见下 |
| — | 导出面统一：`from kit.db import PgKit, MySqlKit, SQLiteKit, build_pg_url, …` | 纯新增；既有导出未删除/未改名 |

**两段查询范式（推荐用法）**：`search_vectors` 只返回 `[{"id", "distance"}]`，载荷二次取回：

```python
hits = SQLiteKit.search_vectors("item_vectors", query_vector, top_k=5)
rows = SQLiteKit.query("SELECT id, title FROM items WHERE id IN :ids",
                       {"ids": [h["id"] for h in hits]})   # IN 不保证顺序，需按 hits 重排
```

**自有事务内写向量（SQLite）**：

```python
conn = SQLiteKit.raw_connection()          # DBAPI 连接：位置参数 ? + 手工 commit
try:
    conn.execute("INSERT INTO item_vectors (id, embedding) VALUES (?, ?)",
                 (item_id, SQLiteKit.encode_vector(vec)))
    conn.commit()
finally:
    conn.close()
```

> 相似度换算建议：SQLite 仅支持 L2，若向量入库前已**归一化**，则 `cos = 1 − L2²/2`；
> PG 可直接用 `distance_type="COSINE"`。

### 3. CRUDBase（配合 session_scope，不自动提交）

```python
from kit.db import CRUDBase, session_scope

user_crud = CRUDBase(User)

with session_scope() as session:          # 正常退出提交、异常回滚
    user_crud.create(session, {"name": "张三"})
    user_crud.update(session, user_crud.get(session, 1), {"name": "李四"})
```

| 方法 | 说明 |
|------|------|
| `CRUDBase(model)` | 构造，传入 SQLModel 模型类 |
| `get(session, id) -> Model \| None` | 按主键取单条 |
| `get_or_raise(session, id) -> Model` | 按主键取，缺失抛 `NotFoundError` |
| `get_multi(session, *, skip=0, limit=100, order_by=None, desc=False) -> list[Model]` | 分页 + 排序 |
| `get_by_field(session, field_name, field_value) -> Model \| None` | 按字段等值取单条 |
| `get_multi_by_field(session, field_name, field_value, *, skip=0, limit=100) -> list[Model]` | 按字段等值分页取多条 |
| `create(session, obj_in) -> Model` | 插入单条（add + flush） |
| `create_multi(session, objs_in) -> list[Model]` | 批量插入（add_all + flush） |
| `update(session, db_obj, obj_in) -> Model` | 更新，入参为模型实例或字典 |
| `update_multi(session, ids, obj_in) -> int` | 单条批量 UPDATE，返回影响行数 |
| `delete(session, id) -> Model \| None` | 按主键删除 |
| `delete_multi(session, ids) -> int` | 单条批量 DELETE，返回影响行数 |
| `count(session) -> int` | 记录总数 |
| `exists(session, id) -> bool` | 主键是否存在 |
| `upsert(session, obj_in, conflict_fields) -> Model` | 原生 `INSERT ... ON CONFLICT / ON DUPLICATE KEY` 原子语句 |

> 约定：`SQLiteKit / PgKit / MySqlKit` 方法自动提交；`CRUDBase` 方法**不提交**，事务边界由外层 `session_scope` 或 `@transactional` 决定。

### 4. 引擎 / 会话 / 事务 / 异常

| 符号 | 签名 | 说明 |
|------|------|------|
| `get_engine` | `(db_type=None, **kwargs) -> Engine` | 线程安全引擎单例，`**kwargs` 首次创建时透传 `create_engine` |
| `create_tables` | `(models, db_type=None) -> None` | 按模型列表建表 |
| `list_tables` | `(db_type=None) -> list[str]` | 列出全部表 |
| `table_exists` | `(table_name, db_type=None) -> bool` | 判断表是否存在 |
| `close_engine` | `(db_type) -> None` | 关闭并移除指定类型引擎 |
| `close_all_engines` | `() -> None` | 关闭全部引擎 |
| `session_scope` | `(db_type=None, *, autocommit=True)` | 唯一会话入口：正常提交、异常回滚、最终关闭 |
| `get_session` | `(db_type=None) -> Session` | 裸 Session，调用方自管生命周期 |
| `transactional` | `(db_type=None, *, retry_count=0, retry_delay=0.5)` | 事务装饰器：自动注入 `session` 参数、失败退避重试 |
| `POSTGRESQL` / `MYSQL` / `SQLITE` | `"postgresql"` / `"mysql"` / `"sqlite"` | 数据库类型常量 |
| `DBError` | 继承 `SQLAlchemyError` | 数据库异常基类 |
| `NotFoundError` | 继承 `DBError` | 记录不存在 |

```python
from kit.db import transactional
from sqlmodel import Session

@transactional(retry_count=3)
def transfer(session: Session, from_id: int, to_id: int, amount: float) -> None:
    # 被装饰函数必须接受 session 参数；退出时自动提交，异常自动回滚并重试
    ...
```

### 5. 模型混入类与查询构建器

```python
from sqlalchemy import BigInteger
from sqlmodel import Field, SQLModel

from kit.common_kit import CommonKit
from kit.db.models import TimestampMixin, SoftDeleteMixin, AuditMixin

class Article(TimestampMixin, SoftDeleteMixin, SQLModel, table=True):
    # 主键：64 位整数 + 雪花 ID（应用侧生成，不用数据库自增）
    id: int = Field(default_factory=CommonKit.generate_snowflake_id,
                    primary_key=True, sa_type=BigInteger)
    title: str
```

| 混入类 | 字段 |
|--------|------|
| `TimestampMixin` | `created_at`（UTC）、`updated_at` |
| `SoftDeleteMixin` | `is_deleted`、`deleted_at` |
| `AuditMixin` | 时间戳字段 + `created_by`、`updated_by` |

查询构建器需显式从子模块导入（未进入 `kit.db` 顶层导出）：

```python
from kit.db.query import QueryBuilder, QueryCache, QueryOptimizer, cached_query

qb = QueryBuilder(User, session)
active = qb.filter(User.name == "张三").all()      # 链式不可变，每步返回新实例
page = qb.paginate(page=1, per_page=20)            # {"items", "total", ...}
```

| 类 | 方法 |
|----|------|
| `QueryBuilder(model, session)` | `filter(*conditions)`、`filter_by(**kwargs)`、`join(target, onclause=None, isouter=False)`、`order_by(*columns)`、`limit(n)`、`offset(n)`、`distinct()`、`only(*field_names)`、`all()`、`first()`、`count()`、`exists()`、`paginate(page=1, per_page=20)` |
| `QueryCache(default_ttl=300, maxsize=1000)` | `get(key)`、`set(key, value, ttl=None)`、`delete(key)`、`clear()`、`clean_expired()` |
| `QueryOptimizer(session)` | `explain(statement)`、`suggest_indexes(model, query_fields)`、`measure(statement, params=None)`、`stats`、`clear_stats()` |
| `cached_query(ttl=300, maxsize=1000)` | 查询结果缓存装饰器 |

---

## kit.ai —— AI 多模型服务池

安装：`pip install "sdpy-kit[ai]"`
档案：项目根 `config/ai_models.json`（见文末「AI / MCP 档案文件」）

### 1. 同步门面 `AI`（推荐）

```python
from kit.ai.runner import AI, shutdown

text = AI.chat_complete("用一句话介绍 Python")                 # 单轮对话
hits = AI.rerank("水声通信", ["文档A", "文档B"], top_n=2)      # 重排序
url = AI.generate_image("一只在写代码的猫")                    # 文生图
AI.use("chat", "glm")                                          # 切换当前档案（进程内长期生效）
print(AI.available("chat"))                                    # 查看各档案状态快照
shutdown()                                                     # 应用退出前释放资源（幂等）
```

| 分组 | 方法 | 说明 |
|------|------|------|
| chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话（仅 user 消息） |
| chat | `chat_ask(messages, *, max_tokens=None) -> str` | 多轮对话，`messages` 为 `[{"role": "user", "content": "..."}]` |
| chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器（建议 `with` 使用） |
| embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
| embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
| rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
| image | `generate_image(prompt, *, size=None, n=1) -> str` | 文生图，返回图片 URL |
| 管理 | `use(model_type, profile) -> None` | 切换该类型当前档案（`chat` / `embedding` / `rerank` / `image`） |
| 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照（含未配置与被剔除者） |
| 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载，改密钥 / 档案后无需重启 |

```python
from kit.ai.runner import AI

with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
    for token in stream:      # 逐 token 消费
        print(token, end="", flush=True)
```

> 门面仅面向**同步上下文**；若调用点本身在 async 函数内，请直接 `await` 原生异步服务，避免阻塞事件循环线程。
> 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。

### 2. 原生异步服务

```python
from kit.ai import (
    ChatService, EmbeddingService, RerankService, ImageService,   # 服务类
    ChatRequest, EmbeddingRequest, RerankRequest, ImageRequest,   # 请求模型
    RerankHit, ModelBuilder,                                      # 结果类型 / 构建器
)

service = ModelBuilder.build_chat()                 # 老按类型构建：build_chat / build_embedding / build_rerank / build_image
answer = await service.ask(messages)                # 或 await service.complete(prompt)
await service.close()
```

| 类 | 方法 |
|----|------|
| `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ...) -> AsyncIterator[str]` |
| `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性 |
| `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`（已按 score 降序） |
| `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
| `ModelBuilder` | `build(model_type, model=None, *, api_key=None)` 及各类型专用 `build_chat / build_embedding / build_rerank / build_image` |
| `BaseAIService` | `profile` 属性、`await close()`、支持 `async with` |

### 3. 异常

```python
from kit.ai.exceptions import (
    AIError, AIConfigError, AIRequestError, AITimeoutError,
    AIAggregateError, AIResponseError,
)
```

| 异常 | 触发场景 / 可用属性 |
|------|---------------------|
| `AIError` | 基类 |
| `AIConfigError` | 档案缺失或非法、档案不存在、密钥未配置、必填字段缺失 |
| `AIRequestError` | 单次请求失败；`status_code`、`retry_after`（429 时解析 `Retry-After`） |
| `AITimeoutError` | 请求超时（继承 `AIRequestError`） |
| `AIAggregateError` | 全部候选失败或整链预算耗尽；`tried`、`statuses`、`last_error`、`budget_exhausted` |
| `AIResponseError` | 响应结构解析失败、流式数据无法解析 |

---

## kit.mcp_kit —— 通用 MCP 客户端

安装：`pip install "sdpy-kit[mcp]"`
档案：项目根 `config/mcp_servers.json`；token 值放 `.env`（档案里只写环境变量名）

```python
from kit.mcp_kit import McpKit

tools = McpKit.list_tools("aminer")                                  # [{name, description}]
result = McpKit.call_tool("search_paper_by_title", {"title": "水声通信", "size": 10})
print(result.data, result.elapsed_ms)

# 直连临时服务器（不经档案）
r = McpKit.call_tool_url("https://example.com/mcp", "query", {}, token="...")

# 异步环境 / 一次握手复用多次调用
async with McpKit.session("aminer") as call:
    r1 = await call("search_paper_by_title", {"title": "A"})
    r2 = await call("get_papers_info", {"id": "1"})
```

| 方法 | 说明 |
|------|------|
| `list_tools(server=None) -> list[dict]` | 工具发现；`server=None` 用档案 `default_server` |
| `call_tool(name, arguments, *, server=None) -> McpToolResult` | 同步短连接调用（主路径） |
| `call_tool_async(name, arguments, *, server=None) -> McpToolResult` | 异步版；FastAPI 等已有事件循环的进程内必须用 |
| `session(server=None)` | `async with` 长连接复用，产出 `call(name, arguments)` 协程函数 |
| `call_tool_url(url, name, arguments, *, token=None, transport="streamable-http", timeout=None) -> McpToolResult` | 直连临时服务器，不经档案 |

| 类型 | 说明 |
|------|------|
| `McpToolResult(server, tool, elapsed_ms, data)` | 调用结果；`data` 为 JSON 解析后对象，非 JSON 文本降级为字符串 |
| `McpKitError(message, *, detail=None)` | 业务失败时 `detail` 为服务端原始响应；连接失败时为最后一次传输层错误 |

> 传输方式仅 `streamable-http`（默认）与 `sse`；档案配 `fallback_url` 时主端点传输层失败会自动回退（业务失败不回退）。

---

## kit.redis_kit —— Redis 工具

安装：`pip install "sdpy-kit[redis]"`

```python
from kit.redis_kit import RedisKit

RedisKit.set("user:1", "clark", expire=60)
RedisKit.get("user:1")                       # -> "clark"
RedisKit.zadd("rank", {"alice": 1.0, "bob": 2.0})
RedisKit.zrange("rank", 0, -1, withscores=True)
RedisKit.close()
```

| 分组 | 方法 |
|------|------|
| 通用 | `get_client()`（上下文管理器）、`set(key, value, expire=None) -> bool`、`get(key) -> RedisValue \| None`、`delete(key) -> bool`、`exists(key) -> bool`、`keys(pattern) -> list[str]`、`close() -> None` |
| 批量 | `batch_set(key_value_pairs, expire=None) -> bool`、`batch_get(keys) -> dict`、`batch_delete(keys) -> bool` |
| Hash | `hset(key, field, value) -> bool`、`hget(key, field)`、`hgetall(key) -> dict`、`hdel(key, *fields) -> bool` |
| List | `lpush(key, *values) -> bool`、`rpush(key, *values) -> bool`、`lpop(key)`、`rpop(key)`、`lrange(key, start, stop) -> list` |
| Set | `sadd(key, *members) -> bool`、`srem(key, *members) -> bool`、`smembers(key) -> list`、`sismember(key, member) -> bool` |
| Sorted Set | `zadd(key, mapping) -> bool`、`zrange(key, start, stop, withscores=False)` |

> `RedisValue = str | int | float | bytes`；连接池 `decode_responses=True`，内置指数退避重试。

---

## kit.file_kit —— 文件工具

相对路径均相对于 `FILE_STORAGE_ROOT`（默认 `./data`）。

```python
from kit.file_kit import FileKit

FileKit.write_file("docs/a.txt", "hello")         # mode="overwrite"（默认）/ "append"
text = FileKit.read_file("docs/a.txt")
path = FileKit.get_full_path("docs/a.txt")        # -> Path 绝对路径
for root, dirs, files in FileKit.walk_dir("docs"):
    print(root, files)
FileKit.open_with_default_app(path)               # 系统默认程序打开：-> "opened" / "revealed"
```

| 分组 | 方法 |
|------|------|
| 配置 | `configure(root_path) -> None`、`get_root_path() -> str`、`get_full_path(relative_path) -> Path` |
| 文件 | `read_file(relative_path, encoding="utf-8") -> str`、`write_file(relative_path, content, mode="overwrite", encoding="utf-8") -> bool`、`move_file(src, dest) -> bool`、`copy_file(src, dest) -> bool`、`delete_file(relative_path) -> bool`、`file_exists(relative_path) -> bool` |
| 目录 | `move_dir(src, dest) -> bool`、`copy_dir(src, dest) -> bool`、`delete_dir(relative_path) -> bool`、`walk_dir(relative_path="", top_down=True, on_error=None)` |
| 命名 | `generate_unique_id() -> str`、`generate_filename(prefix="", extension="") -> str` |
| 系统 | `open_with_default_app(path) -> OpenResult`（`"opened"` / `"revealed"`，失败降级为文件管理器定位） |

---

## kit.common_kit —— 通用工具

```python
from kit.common_kit import CommonKit

CommonKit.configure(datacenter_id=1, machine_id=1)      # 各限 1–31
CommonKit.generate_snowflake_id()                       # 63 位雪花 ID（int）
CommonKit.generate_snow_id()                            # 雪花 ID（str）
CommonKit.generate_timestamp_id()                       # 20 位时间戳 ID
CommonKit.format_current_time("yyyy-MM-dd")
```

| 方法 | 说明 |
|------|------|
| `configure(datacenter_id, machine_id) -> None` | 配置雪花算法数据中心 / 机器 ID（越界抛 `ValueError`） |
| `generate_snowflake_id(datacenter_id=None, machine_id=None) -> int` | 生成雪花 ID |
| `generate_snow_id(datacenter_id=None, machine_id=None) -> str` | 生成雪花 ID 字符串 |
| `generate_timestamp_id() -> str` | `YYYYMMDDHHMMSSfff` + 3 位随机 |
| `format_current_time(format_pattern="yyyyMMdd") -> str` | 支持 `yyyyMMdd`、`yyyyMMddhhmm`、`yyyyMMddhhmmss`、`yyyy-MM-dd`、`yyyy-MM-dd hh:mm`、`yyyy-MM-dd hh:mm:ss` |
| `reset() -> None` | 重置 ID 生成器状态 |

---

## kit.anydoc_kit —— 文档转 Markdown

安装：`pip install "sdpy-kit[anydoc]"`（支持 `.doc .docx .ppt .pptx .xls .xlsx .csv .pdf`）

```python
from kit.anydoc_kit import AnyDocKit

markdown = AnyDocKit.convert_to_md("report.docx")     # 直接转换，失败抛异常
result = AnyDocKit.convert("report.pdf")              # 安全转换，异常封装为结果对象
if result.success:
    print(result.markdown)
AnyDocKit.convert_to_file("report.docx", "out/report.md")
md = AnyDocKit.convert_bytes_to_md(file_bytes, "csv") # 字节内容直接转，无需落盘
md = AnyDocKit.convert_to_md("scan.pdf", ocr="hosted")  # 需 FIRECRAWL_API_KEY
```

| 方法 | 说明 |
|------|------|
| `is_supported(file_path) -> bool` | 按扩展名判断是否受支持 |
| `detect_format(file_path) -> str \| None` | 由扩展名推断规范格式名 |
| `convert_to_md(file_path, *, ocr="reject", api_key=None, api_url=None) -> str` | 文件 → Markdown（非标 PDF 自动修复重试） |
| `convert_bytes_to_md(data, fmt=None, *, ocr="reject", api_key=None, api_url=None) -> str` | 字节内容 → Markdown |
| `convert(file_path, *, ocr="reject", api_key=None, api_url=None) -> ConvertResult` | 安全转换 |
| `convert_to_file(src_path, dest_path=None, *, ocr="reject", ...) -> str` | 转换并写入 `.md`，返回目标路径 |

`ConvertResult(success, markdown, file_path, source_format, error)`

---

## kit.docx_kit —— Word 模板动态填充

安装：`pip install "sdpy-kit[docx]"`

```python
from kit.docx_kit import DocxKit, DocxReport, DocxSection, DocxParagraph, DocxTable

DocxKit.create_template("templates/report.docx", title="{{ title }}")   # 生成模板骨架
DocxKit.unify_font("templates/report.docx", font_name="宋体")
DocxKit.unify_heading_color("templates/report.docx")

report = DocxReport(
    sections=(
        DocxSection(
            title="第一章 概述",
            level=1,
            blocks=(
                DocxParagraph("本章介绍项目背景。"),
                DocxTable(headers=("名称", "数值"), rows=(("甲", "1"),)),
            ),
        ),
    ),
    variables={"project": "SDPY"},
)
DocxKit.render("templates/report.docx", "out/report.docx", report)
```

| 方法 | 说明 |
|------|------|
| `create_template(dest_path, *, title=None, leading_paragraphs=(), anchor_style=None) -> str` | 生成标准模板骨架（含章节循环锚点） |
| `unify_font(docx_path, *, font_name="宋体") -> str` | 统一文档默认与全部样式的字体（原地，幂等） |
| `unify_heading_color(docx_path, *, color="000000") -> str` | 统一 `Heading 1..9` 字体颜色 |
| `inspect_template(template_path, *, context=None) -> tuple[str, ...]` | 列出模板中未被上下文满足的变量 |
| `render(template_path, dest_path, report, *, anchor_style=None, strict=True) -> str` | 按结构化数据渲染模板并落盘（核心方法） |

数据结构：`DocxReport(sections, variables)`、`DocxSection(title, level=1, blocks=())`、`DocxParagraph(text)`、`DocxListItem(text, level=1, ordered=False)`、`DocxTable(headers, rows=(), style=None)`

---

## kit.md2doc_kit —— Markdown 转 Word / PDF / HTML

安装：`pip install "sdpy-kit[md2doc]"`（PDF 需系统已安装 `mainfont` 指定字体，默认 `SimSun`）

```python
from kit.md2doc_kit import Md2DocKit

Md2DocKit.convert_md_to_docx("report.md", "out.docx", generate_toc=True)
Md2DocKit.convert_md_to_html("report.md", title="项目报告")
Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
```

| 方法 | 说明 |
|------|------|
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word（可插目录、统一字体） |
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件 |
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF（实验性，内部使用） |

> `dest_path=None` 时输出到源文件同目录；同一源文件不支持并发转换。

---

## kit.mineru_kit —— MinerU 文档解析

安装：`pip install "sdpy-kit[mineru]"`（精准解析需 `MINERU_API_KEY`，Agent 轻量解析免登录）

```python
from kit.mineru_kit import MinerUKit

# 精准解析：公网 URL / 本地文件
result = MinerUKit.parse_url("https://cdn-mineru.openxlab.org.cn/demo/example.pdf", model_version="vlm")
if result.state == "done":
    zip_path = MinerUKit.download_zip(result.full_zip_url, "./output")

results = MinerUKit.parse_files(["report1.pdf", "report2.docx"])

# Agent 轻量解析（免登录、单文件 ≤10MB、≤20 页）
r = MinerUKit.agent_parse_url(url, page_range="1-10")
if r.state == "done":
    md_path = MinerUKit.download_markdown(r.markdown_url, "./output")
```

| 分组 | 方法 |
|------|------|
| 精准解析（高层） | `parse_url(url, **opts) -> TaskResult`、`parse_files(file_paths, **opts) -> list[BatchTaskResult]`、`wait_for_task(task_id, *, poll_interval=None, timeout=None)`、`wait_for_batch(batch_id, *, expected_count=None, ...)`、`download_zip(zip_url, dest_dir) -> str` |
| 精准解析（低层） | `create_task(url, **opts) -> str`、`get_task_result(task_id) -> TaskResult`、`create_batch(files, **opts) -> BatchUploadInfo`、`upload_files(file_paths, upload_urls) -> list[bool]`、`get_batch_results(batch_id) -> list[BatchTaskResult]` |
| Agent 轻量 | `agent_parse_url(url, **opts) -> AgentParseResult`、`agent_parse_file(file_path, **opts) -> AgentParseResult`、`agent_create_url_task(url, **opts) -> str`、`agent_create_file_task(file_name, **opts) -> AgentUploadInfo`、`agent_upload_file(file_path, file_url) -> None`、`agent_get_result(task_id)`、`agent_wait_for_result(task_id, *, poll_interval=None, timeout=None)`、`download_markdown(markdown_url, dest_dir, filename=None) -> str` |
| 校验 | `is_supported(file_path) -> bool`、`is_agent_supported(file_path) -> bool` |

> 可选参数（`model_version` / `is_ocr` / `enable_formula` / `enable_table` / `language` / `page_ranges` / `extra_formats` / `no_cache` 等）详见各方法 docstring。
> 结果类型：`TaskResult`、`BatchTaskResult`、`BatchFile`、`BatchUploadInfo`、`AgentParseResult`、`AgentUploadInfo`。

---

## kit.marker_kit —— 本地文档解析（Markdown + Images）

安装：`pip install "sdpy-kit[marker]"`（依赖 `marker-pdf[full]`，含 torch 等 80+ 包，体积较大）

```python
from kit.marker_kit import MarkerKit

# 落盘 md + images（推荐）：源文件同目录下建同名子目录
md_path = MarkerKit.convert_to_dir("report.pdf")              # -> report/report.md + 图片
md_path = MarkerKit.convert_to_dir("report.pdf", "out/doc")   # 指定产物目录

# 只取 Markdown 文本（图片不落盘）
md = MarkerKit.convert_to_md("report.pdf")

# 安全转换（不抛异常，返回结果对象）
result = MarkerKit.convert("report.pdf", "out/doc")
if result.success:
    print(result.md_path, len(result.image_paths), result.page_count)

# 跳过 VLM（无需 llama.cpp）
md = MarkerKit.convert_to_md("scan.pdf", disable_ocr=True)

MarkerKit.shutdown()   # 释放模型与推理服务（幂等）
```

| 方法 | 说明 |
|------|------|
| `is_supported(file_path) -> bool` | 扩展名白名单（pdf / 图片 / docx / pptx / xlsx / html / epub） |
| `convert_to_md(file_path, *, mode=None, force_ocr=None, disable_ocr=None, page_range=None) -> str` | 解析并返回 Markdown 文本（图片不落盘） |
| `convert_to_dir(src_path, output_dir=None, *, mode=None, force_ocr=None, disable_ocr=None, page_range=None, extract_images=True) -> str` | 解析并落盘 md + images，返回 md 绝对路径 |
| `convert(src_path, output_dir=None, *, ...) -> MarkerResult` | 安全版：失败封装为 `MarkerResult`，不抛异常 |
| `shutdown() -> None` | 释放模型与共享推理服务（幂等） |

> 产物：`<output_dir>/<源文件名>.md` + 图片文件（md 内相对路径引用），**不生成** `_meta.json`。
> 结果类型：`MarkerResult`（`success` / `markdown` / `file_path` / `output_dir` / `md_path` / `image_paths` / `page_count` / `error`）；异常：`MarkerError`。
> `mode` 缺省时不写入配置，交由 marker 按设备自动选择；首次使用会下载模型权重（默认走 `MARKER_HF_ENDPOINT` 镜像）。
> 非 NVIDIA 平台完整 VLM 能力（公式 / 扫描页 OCR）需 llama.cpp 的 `llama-server`，否则用 `disable_ocr=True` 跳过。

---

## kit.zvec_kit —— 本地向量库

安装：`pip install "sdpy-kit[zvec]"`

```python
from kit.zvec_kit import ZvecKit

schema = ZvecKit.make_text_embedding_schema("docs", dim=768)
ZvecKit.create_collection("docs", schema)
ZvecKit.insert("docs", {"id": "d1", "vectors": {"embedding": [0.1] * 768}, "fields": {"year": 2024}})

results = ZvecKit.vector_query("docs", field="embedding", vector=[0.3] * 768, topk=10)
ZvecKit.flush("docs")
```

| 分组 | 方法 |
|------|------|
| 连接 | `connect() -> bool`、`close() -> None`、`is_connected() -> bool` |
| Collection | `create_collection(name_or_path, schema) -> Collection`、`open_collection(name_or_path)`、`collection_exists(name_or_path) -> bool`、`list_collections() -> list[str]`、`destroy_collection(name_or_path) -> bool`、`get_schema(name_or_path)`、`get_stats(name_or_path) -> dict` |
| 文档 CRUD | `insert(name_or_path, data) -> str`、`insert_multi(name_or_path, data_list) -> list[str]`、`upsert(name_or_path, data) -> str`、`update(name_or_path, data) -> str`、`fetch(name_or_path, ids, *, include_vector=True) -> dict`、`delete(name_or_path, ids) -> bool`、`delete_by_filter(name_or_path, filter_expr) -> bool`、`count(name_or_path) -> int`、`flush(name_or_path) -> None` |
| 检索 / 维护 | `vector_query(name_or_path, *, field, vector, topk=10, filter_expr=None, include_vector=False) -> list[dict]`、`optimize(name_or_path) -> None` |
| Schema | `make_text_embedding_schema(name, dim, metric=None, extra_fields=None) -> CollectionSchema` |

> 文档字典键：`id`（必需）、`vectors`（可选）、`fields`（可选）；`filter_expr` 中相等用单等号 `=`。
> 异常：`ZvecError`（独立于 `kit.db` 的 `DBError` 体系）。

---

## kit.wx_article_kit —— 微信公众号文章抓取存储

零第三方依赖（标准库 `urllib` + 内置 HTML→Markdown 转换器），**无需 extra**，按需 import：

```python
from kit.wx_article_kit import WxArticleKit

# 抓取解析（不落盘）
result = WxArticleKit.fetch("https://mp.weixin.qq.com/s/xxxx")
if result.ok:
    print(result.info.title, result.info.author, result.info.publish_time)

# 抓取并存储：<output_dir>/<file_name>/ 下 <file_name>.md + images/
saved = WxArticleKit.save(url, output_dir="./output", file_name="12345")
print(saved.md_path, saved.images_saved)

# 需要结构化元信息时再产出 meta.json（默认不产出）
WxArticleKit.save(url, "./output", "12345", write_meta=True)
```

| 方法 | 说明 |
|------|------|
| `fetch(url, timeout=20.0) -> ArticleResult` | 抓取并解析正文（不落盘）；结果对象字段 `ok` / `error` / `info`（`ArticleInfo`）/ `markdown` |
| `save(url, output_dir, file_name, write_meta=False, timeout=20.0) -> SavedOutput` | 抓取并存储到 `<output_dir>/<file_name>/`；结果对象字段 `ok` / `error` / `output_dir` / `md_path` / `images_dir` / `title` / `images_saved` |

落盘结构：

```
<output_dir>/<file_name>/
├── <file_name>.md     # 正文 Markdown（YAML 头：title / author / publish_time / source_url）
├── images/            # 图片转存（NNN.ext，正文引用已本地化）
└── meta.json          # 可选：结构化元信息（write_meta=True 时产出）
```

- 元信息默认随 Markdown 的 YAML 头落盘（头部 `publish_time` 为中文日期，如 `2026年8月4日`）；
  `ArticleInfo.publish_time` 与 `meta.json` 内仍为 ISO 8601（结构化数据）。
- 无登录态抓取：命中微信风控（环境异常 / 访问过于频繁）时返回 `ok=False` + 原因，不破解验证码；
  抓取或图片下载失败不抛异常，单张图片失败时正文保留原远程 URL。
- 同名子目录**覆盖写**（不追加序号）；`file_name` 带 `.md` 后缀会自动去除，为空则抛 `ValueError`。

---

## AI / MCP 档案文件

AI 与 MCP 功能需要在项目根目录提供两个 JSON 档案文件：

- `config/ai_models.json` —— chat / embedding / rerank / image 四类型多厂商模型档案
- `config/mcp_servers.json` —— MCP 服务器档案

文件缺失时，首次使用相应功能会报错，**报错信息中附带可直接复制的最小模板**；
其中 `api_key` / `token_env` 字段填写环境变量名，真实密钥配置在 `.env`。

### ai_models.json 可选字段

| 层级 | 字段 | 说明 |
|------|------|------|
| 档案级 | `timeout` | 请求超时秒数（默认 60） |
| 档案级 | `max_tokens` | 输出上限（chat 用）；`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参，逐级回落：调用点 → 档案 → provider 默认 |
| 档案级 | `retries` | 5xx 重试次数（默认 2；超时不重试，候选链切换才是有效重试） |
| 类型级 | `chain_timeout` | 整链预算秒数（与 `default_profile` 同级）；门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应，也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |

### 候选链切换语义（两类，勿混淆）

- **跨调用切换**：`AI.use()` 与其它请求产生的失败冷却均只影响**下一次**请求——每次调用的候选计划在开始时定格；
- **一次调用内降级**：本次调用按开始时定格的计划串行尝试（首帧前失败 / 首帧超时切下一候选），中途的 `use` / 冷却变化不影响它。

全部候选失败（或整链预算耗尽）抛 `AIAggregateError`（`AIRequestError` 子类）：
全候选状态码一致时透传（如整链 429 可用 `exc.status_code == 429` 判定），
429 的 `Retry-After` 解析后挂于 `exc.retry_after`（冷却钳制 5–300s）。

### mcp_servers.json 最小模板

```json
{
  "default_server": "aminer",
  "servers": {
    "aminer": {
      "transport": "streamable-http",
      "url": "https://mcp.aminer.cn/mcp",
      "token_env": "AMINER_TOKEN",
      "timeout": 60
    }
  }
}
```

## License

MIT
