Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/419bw_docx-agent.json
T

1 line
121 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "419bw/docx-agent", "files": {"README.md": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785768259.2568433, "n": 15145, "h": "4a2bdf9b4d6a155e", "c": "# DOCX Agent\n\nAI 驱动的 Word 文档编辑 Agent,基于 LLM 工具调用 + 直接 OpenXML 操作实现精细化的 `.docx` 编辑。无需 `python-docx`,直接操控底层 XML 和 ZIP 结构。\n\n后端 FastAPI + 前端 Next.js 双服务架构,**会话状态全部持久化到磁盘文件系统**——刷新页面、切 session、跨设备同步都不丢。\n\n## 核心特性\n\n- **三阶段状态机** — 样式审核 → Markdown 草稿 → Word 写入,每阶段 LLM 只能用当前允许的工具集\n- **直接 XML 编辑** — 基于 `lxml` + `zipfile` 直接读写 `word/document.xml`,精确操控段落、run、表格、样式\n- **Markdown 编译器** — `markdown → AST → IR → OpenXML` 多阶段编译管线,支持标题、段落、列表、表格、代码块、公式、图片\n- **图片插入** — 本地图片嵌入,自动处理 DrawingML XML、关系链、Content Type 注册\n- **多模态识图** — 适配多模态视觉模型(SenseNova)分析图表与截图\n- **样式感知** — 分析模板格式后由 AI 自定义各角色的字体、字号、对齐等参数,编辑时保持统一风格\n- **变更验证** — 编辑后自动 diff 对比原文档\n- **会话持久化(v2)** — 每个 session 一个目录 `out/sessions/<id>/`,含 3 个 JSON + 子目录;前端删 IndexedDB,后端是 source of truth\n- **切 session 不掉线(v2)** — WS resume 重建上下文,approval 按钮可继续点\n\n## 三阶段状态机\n\n```\n样式审核 (Style Review) → Markdown 草稿 (MD Draft) → Word 写入编译 (Word Editing)\n```\n\n| 阶段 | 职责 | 可用工具(节选) |\n|------|------|---------|\n| **样式审核** | 分析模板格式 + AI 自定义格式参数 | `analyze_docx_style_samples`, `define_style_profile`, `read_docx_structure`, `ls` |\n| **Markdown 草稿** | 按文档区域生成结构化 Markdown 内容 | `write_markdown_draft`, `read_markdown_draft`, `parse_markdown_draft`, `ls`, `read`, `analyze_image_content` |\n| **Word 写入** | 编译 Markdown 写入 DOCX,diff 验证 | `read_docx_structure`, `write_markdown_draft`, `read_markdown_draft`, `parse_markdown_draft`, `markdown_to_word`, `diff_docx`, `ls`, `read`, `analyze_image_content` |\n\n## 细致编辑模式 (detail_edit)\n\n与三阶段填充模式并列的第二种工作模式,用于对**已完成文档**做精细化编辑。\n\n```\nstart(mode=\"detail_edit\") → 工具选用 agent 选工具子集 → 自由编辑循环 → done\n```\n\n- **工具选用 agent**:内部 LLM 调用(阻塞、无 tools),根据用户请求 + 文档结构从 36+ 工具中选出最合适的子集\n- **基础工具(6 个,始终可用)**:`read_docx_structure`, `find_text`, `ls`, `read`, `diff_docx`, `request_more_tools`\n- **选用工具**:写入/修改类原子工具(`replace_text`, `insert_text_at`, `set_text_format` 等),由选用 agent 按需挑选\n- **工具扩充**:编辑 agent 调 `request_more_tools(reason)` → 选用 agent 重新评估 → 只增不减\n- **无审批**:自由编辑循环,LLM 认为完成即结束\n\n前端在输入框上方提供\"填充模式 / 编辑模式\"切换,仅未连接时可切换。\n\n## v2 架构:HTTP 控制面 + WS 数据面\n\n后端是 **session 状态的 source of truth**,前端只展示不缓存。\n\n### 持久化结构\n\n每个 session 一个目录,白盒可调试(vs pickle 黑盒):\n\n```\nout/sessions/session-20260607-143052/\n├── metadata.json # 会话元数据 (id, title, createdAt, docxPath, workflow_state, pending_approval)\n├── messages.json # 完整 LLM 消息历史 (MessageManager._entries)\n├── workflow.json # 状态机 (workflow_state, stage_called_tools, draft_files_written, _round_index)\n├── logs/\n│ └── agent_<timestamp>.log\n├── drafts/ # write_markdown_draft 写到这里 (沙箱化)\n│ ├── cover.md\n│ └── section1.md\n├── style_profiles/ # analyze_docx_style_samples 写到这里 (沙箱化)\n│ └── 模板_v3_<timestamp>.json\n└── uploads/ # 用户上传的 docx (沙箱化)\n └── 实验报告.docx\n```\n\n### HTTP / WS 边界\n\n| 方向 | 通道 | 用途 |\n|------|------|------|\n| 前→后 | `GET /api/sessions` | 列表(sidebar 打开时拉一次) |\n| 前→后 | `GET /api/sessions/{id}` | 单个 session 快照(可选) |\n| 前→后 | `DELETE /api/sessions/{id}` | 级联删整个 session 目录 |\n| 前→后 | `WS /api/ws/agent` `start` | 新建会话(后端生成 session_id) |\n| 前→后 | `WS /api/ws/agent` `resume` | 恢复会话(从磁盘反序列化) |\n| 后→前 | `WS` `session_created` | start 成功 |\n| 后→前 | `WS` `history` | resume 成功(含 messages + approvalPhase + isWaitingApproval) |\n| 后→前 | `WS` 其它事件 | `round_start` / `reasoning` / `content` / `tool_start` / `tool_end` / `wait_approval` / `done` / `error` / `heartbeat` |\n\n> **关键设计**:WS 协议**不**承载列表/删除查询(避免 in-band 消息 race 与协议分裂)。\n> \n> 注:**v2.1 移除** `POST /api/upload`(前端无上传入口,避开 Next.js dev server multipart rewrites 不稳的风险)— 当前 3 个 HTTP endpoint 全是 JSON。\n\n### 3 个避坑(v2 关键设计)\n\n1. **session_id 永不暴露给 LLM** — 工具 `tools_schema` 保持纯洁,agent dispatcher **反射调用前隐式注入** `self.session_id`,并**用 self 覆盖 LLM 传的值**(防幻觉/越权)\n2. **写盘异步锁** — Agent 类持 `asyncio.Lock()`,同一 session 同一时刻只有一个写盘线程(避免 `tool_start`/`tool_end` 毫秒级间隔时的文件写花)\n3. **HTTP/WS 边界** — 列表/删除走 HTTP(无状态查询),WS 只 `start`/`resume` + 事件流(避免协议分裂)\n\n### 6 Checkpoint 写盘时机\n\nFire-and-forget 后台任务,**不阻塞 stream**:\n\n1. `round_start` 后(用户发消息后,message 已发)\n2. `tool_start` 时(工具\"running\"状态)\n3. `tool_end` 时(工具\"success/error\"状态)\n4. STYLE_REVIEW `wait_approval` yield 前(**关键**:挂起前必须落盘)\n5. MD_DRAFT `wait_approval` yield 前(**关键**:挂起前必须落盘)\n6. `done` yield 前(完结)\n\n`Agent.save_to_disk()` 写 3 个 JSON(`metadata` / `messages` / `workflow`)只 ~10ms,**`start` 后立即同步写盘**让 `/api/sessions` 立即能看到新 session。\n\n## 快速开始\n\n### 环境要求\n\n- Python 3.10+\n- Node.js 18+\n- 后端 Python 依赖(完整见 `requirements.txt`):\n - **核心**:`openai` / `lxml` / `markdown-it-py` / `Pillow`\n - **v2 服务**:`fastapi` / `uvicorn` / `websockets` / `pydantic`\n\n### 配置\n\n复制并编辑 `src/config.json`(gitignored)。**v2 schema**(commit `8d03835` refactor/llm-adapter-registry 落地后,新接入 LLM provider 只需在 `providers` 块加 4 行,无需改任何 .py 代码):\n\n```json\n{\n \"provider\": \"通用接口\",\n \"provider_profiles\": {\n \"openai_compatible\": {\n \"capabilities\": [\"chat\", \"tools\"],\n \"reasoning_field\": \"delta.reasoning_content\",\n \"forward_tool_choice\": false,\n \"quirks\": [],\n \"extra_body_template\": null,\n \"top_level_kwargs\": {}\n }\n },\n \"providers\": {\n \"通用接口\": {\n \"profile\": \"openai_compatible\",\n \"api_key\": \"sk-YOUR_KEY\",\n \"base_url\": \"https://your-endpoint.example.com/v1\",\n \"model\": \"your-model-name\"\n }\n }\n}\n```\n\n完整示例(4 种典型定制形态)见 `src/config.example.json`。\n\n也支持环境变量覆盖(`src/llm_adapter/provider.py` 实现,优先级:env > config):\n\n```powershell\n# 选 provider\n$env:LLM_PROVIDER=\"通用接口\"\n\n# API key (任选其一, LLM_API_KEY 通用 fallback)\n$env:DEEPSEEK_API_KEY=\"your-key\" # 或 SENSENOVA_API_KEY / AGNES_API_KEY / OPENAI_API_KEY\n$env:LLM_API_KEY=\"your-key\" # 通用\n\n# Endpoint / 模型\n$env:DEEPSEEK_BASE_URL=\"https://api.deepseek.com\"\n$env:LLM_MODEL=\"deepseek-chat\" # 或 OPENAI_MODEL / AGNES_MODEL\n\n# Thinking / reasoning\n$env:DOCX_AGENT_THINKING=\"enabled\"\n$env:SENSENOVA_REASONING_EFFORT=\"high\"\n```\n\n### 运行(v2 必起双服务)\n\n```powershell\n# Terminal 1: 后端 FastAPI :8000 (HTTP API + WebSocket)\npip install -r requirements.txt\npython src/server.py\n\n# Terminal 2: 前端 Next.js :3000\ncd frontend\nnpm install\nnpm run dev\n```\n\n浏览器打开 `http://localhost:3000` 即可。\n\n> **前端 fetch `/api/*` 通过 `next.config.ts` rewrites 代理到后端 :8000** ——前端代码不感知后端地址,无 CORS 问题。改了 `next.config.ts` 必须**重启 `npm run dev`**(配置只启动时读一次)。\n>\n> **WebSocket** (`ws://127.0.0.1:8000/api/ws/agent`) 走绝对地址——Next.js dev server 不支持 WS 代理。\n\n### CLI Demo\n\n不想起前端也能跑:\n\n```powershell\npython src/docx_agent_demo.py\n```\n\n## 测试\n\nv2 完整测试套 **138/138** 通过(`tests/`,13 个 file):\n\n| 测试 | 范围 | 通过 |\n|------|------|------|\n| `test_step1.py` | Agent save/load + 异步锁 + 6 Checkpoint | 5/5 |\n| `test_step2.py` | WS `start`/`resume` 协议 + `session_created`/`history` 响应(6 个分支)+ `isolate_sessions_root` fixture 隔离 SESSIONS_ROOT 跨 file 污染 | 6/6 |\n| `test_step3.py` | HTTP 控制面 3 endpoint (sessions list/get/delete) + 旧 draft API 删除 | 8/8 |\n| `test_step4.py` | dispatcher 隐式注入 session_id + 6 工具沙箱化 + 路径守卫 | 10/10 |\n| `test_step5.py` | 前端删 IndexedDB + HTTP fetch + next.config.ts rewrites(10 + 5 fixup,含 `test_page_tsx_startup_does_not_auto_resume` v2.2 新加) | 15/15 |\n| `test_agent_blocking.py` | 非流式路径(`create_chat_completion_blocking`,商汤 SSE stall 修复) | 7/7 |\n| `test_stream_incomplete_retry.py` | B 方案流不完整重试(`finish_reason=None` 防御) | 4/4 |\n| `test_llm_adapter_step1_parity.py` | llm_adapter 请求构建 parity(profile 化前/后等价) | 13/13 |\n| `test_llm_adapter_step2_capability.py` | capability 接口(`has_capability`) | 14/14 |\n| `test_llm_adapter_step3_reasoning.py` | `extract_reasoning` 抽象(`reasoning_field` 路径) | 14/14 |\n| `test_llm_adapter_step4_quirks.py` | quirk 注册与 `apply_quirk` | 12/12 |\n| `test_llm_adapter_step5_profiles.py` | profile 配置化 + 数据驱动注入 | 22/22 |\n| `test_llm_adapter_step6_cleanup.py` | 清理遗留 + `DeprecationWarning` | 8/8 |\n\n```bash\n# 推荐: 跑全部\npython -m pytest tests/\n\n# 单跑 step 文件\npython tests/test_step1.py\npython tests/test_step2.py\npython tests/test_step3.py\npython tests/test_step4.py\npython tests/test_step5.py\n```\n\n## 支持的 LLM\n\nv2 设计:**profile + 任意 OpenAI 兼容 provider**,新接入 4 行(`api_key` / `base_url` / `model` / 可选 `profile`)即可,无需改任何 `.py` 代码。详见 [配置](#配置) 段的 v2 schema 示例与 `src/config.example.json`。\n\n内置 provider 别名(`src/llm_adapter/provider.py` 通过 env var 切换,不需要在 `config.json` 里建块):\n\n| 别名 | 适用 | env var |\n|------|------|---------|\n| DeepSeek | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` / `LLM_MODEL` |\n| SenseNova (商汤) | `https://token.sensenova.cn/v1` | `SENSENOVA_API_KEY` / `SENSENOVA_BASE_URL` / `SENSENOVA_REASONING_EFFORT` |\n| AGNES | AGNES 网关 | `AGNES_API_KEY` / `AGNES_BASE_URL` / `AGNES_MODEL` |\n| OpenAI | `https://api.openai.com/v1` | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL` |\n\n通用 fallback:`LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` / `DOCX_AGENT_THINKING` / `LLM_REASONING_EFFORT`。\n\n## 工具总览(v2 沙箱化)\n\nLLM 看到的 30+ 个工具,**全部**接受业务参数(不感知 `session_id`):\n\n### 基础工具 (`basic_tools/`)\n\n| 工具 | 说明 |\n|------|------|\n| `ls` | 列出目录内容 |\n| `read` | 读取文本/代码/Markdown,支持 offset/limit 分页 |\n| `analyze_image_content` | 多模态图像识别(SenseNova) |\n\n### Markdown 工具 (`md_tools/`) — 沙箱化\n\n工具的 `tools_schema` **不**含 `session_id` 字段,文件写到 `out/sessions/<id>/drafts/`:\n\n| 工具 | 写入路径 | 读取路径 |\n|------|----------|----------|\n| `write_markdown_draft` | `session_dir/drafts/` | — |\n| `read_markdown_draft` | — | `session_dir/drafts/` |\n| `parse_markdown_draft` | — | `session_dir/drafts/` |\n| `markdown_to_word` | — | `session_dir/drafts/`(内部分发到 apply_*) |\n| `apply_markdown_ir_to_table_cell` | — | `session_dir/drafts/` |\n| `apply_markdown_ir_after_paragraph` | — | `session_dir/drafts/`(内部 helper) |\n\n### DOCX 编辑工具 (`docx_tools/`) — 沙箱化\n\n| 工具 | 说明 |\n|------|------|\n| `analyze_docx_style_samples` | 提取文档格式样本 → 写到 `session_dir/style_profiles/` |\n| `read_docx_structure` | 读取段落文本、表格结构、位置坐标 |\n| `define_style_profile` | AI 自定义各角色格式参数(字体/字号/加粗/颜色/对齐/底纹),写入 style_profile.json |\n| `find_text` | 搜索文本,返回段落索引和字符偏移 |\n| `insert_text_at` | 在锚点偏移处插入文本 |\n| `insert_text_in_table_cell` | 在表格单元格内插入文本 |\n| `replace_text` | 替换文档文本(支持跨 run 匹配) |\n| `delete_text` | 删除指定文本 |\n| `insert_paragraph_after` | 在锚点段落后插入新段落 |\n| `insert_image_after_paragraph` | 在段落后插入本地图片 |\n| `set_text_format` | 设置字符格式 |\n| `set_paragraph_indent` | 设置段落缩进 |\n| `diff_docx` | 对比两个 .docx 文件 |\n| `unzip_docx` | 解压 .docx |\n| `replace_text_like_sample` | 按样式样本替换文本 |\n| `replace_table_cell_like_sample` | 按样式样本替换单元格文本 |\n| `replace_table_cell_text` | 替换表格单元格文本 |\n| `insert_paragraph_after_like_sample` | 按样式样本插入段落 |\n| `insert_table_row_after` | 在指定行后插入表格行 |\n| `insert_table_after_paragraph` | 在段落后插入表格 |\n| `insert_table_in_cell` | 在单元格内插入嵌套表格 |\n| `insert_table_column_after` | 在指定列后插入列 |\n| `merge_table_cells_horizontal` | 水平合并单元格 |\n| `clear_table_cell` | 清空单元格内容 |\n| `delete_table_row` | 删除表格行 |\n\n## 编译器架构\n\n```\nMarkdown 文本\n ↓ markdown-it-py 分词\nMarkdownBlock AST(HeadingBlock, ParagraphBlock, TableBlock, ImageBlock...)\n ↓ normalize_block_support + diagnostics\n ↓ lower\nLayout IR(ParagraphIR, TableIR, CodeBlockIR, FormulaIR, ImageIR...)\n ↓ render\nOpenXML lxml 元素树\n ↓ write_document_xml\n输出 .docx\n```\n\n## 上下文管理\n\n`MessageManager` 自动管理消息累积和去重:\n\n- 内部使用追加模式,两遍倒序扫描实现自动去重\n- 去重范围:`write_markdown_draft` 和 `read_markdown_draft`(同一文件的多次写入只保留最新)\n- 配对删除:tool call 和其 tool result 一起删除\n- Token 追踪:累计 `prompt_tokens`,前端 Header 实时显示\n\n## 目录结构\n\n```\nsrc/\n├── docx_agent_demo.py # CLI 入口\n├── server.py # FastAPI + WebSocket + HTTP 控制面\n├── llm_adapter/ # LLM 客户端抽象(DeepSeek / SenseNova / OpenAI 兼容)\n│ ├── __init__.py # 导出 LLMClientAdapter\n│ ├── provider.py # LLMClient 主类(流式 + 非流式 + capability 接口)\n│ ├── config.py # profile 配置加载\n│ ├── registry.py # PROFILES + pick_capable_adapter\n│ ├── request_builder.py # build_request_kwargs (Step 5 数据驱动注入)\n│ ├── response_parser.py # extract_reasoning (Step 3 reasoning_field 抽象)\n│ └── quirks.py # QuirkAction / QuirkDirective / apply_quirk (Step 4)\n├── context_manager.py # 消息管理(去重 + token 追踪)\n├── prompts.py # 状态名常量 + SYSTEM_PROMPT + state_prompt (Step A 拆出)\n├── state_machine.py # WorkflowTransitions 静态评估 + TransitionDirective (Step C 拆出)\n├── session_persistence.py # SessionPersistence 类 (Step B 拆出, weakref 避免循环引用)\n├── agent.py # Agent 核心类(async generator)+ 6 Checkpoint 委托\n├── config.json # API 配置(gitignored)\n├── basic_tools/ # 基础工具\n│ ├── ls.py / read.py / analyze_image_content.py\n├── md_tools/ # Markdown 草稿与编译器\n│ ├── common.py # draft_path() 路径守卫 (拒绝 .. / /etc/)\n│ ├── write_markdown_draft.py\n│ ├── read_markdown_draft.py\n│ ├── parse_markdown_draft.py\n│ ├── markdown_to_word.py\n│ └── apply_markdown_ir_*.py\n├── docx_tools/ # DOCX 编辑工具(30+ 个)\n│ ├── registry.py # TOOLS_SCHEMA + call_tool (反射调用 + kwargs 展开)\n│ ├── common.py\n│ ├── analyze_docx_style_samples.py # profile 写到 session_dir/style_profiles/\n│ ├── define_style_profile.py # AI 自定义格式参数\n│ ├── bind_styles_to_roles.py # (已废弃, 保留兼容)\n│ ├── read_docx_structure.py\n│ ├── find_text.py / replace_text.py / ...\n│ └── diff_docx.py\n├── docx_compiler/ # Markdown → DOCX 编译器\n│ ├── ir.py # 中间表示\n│ ├── markdown_parser.py # Markdown AST\n│ ├── lower.py # AST → IR\n│ ├── render.py # IR → OpenXML\n│ ├── optimizer.py\n│ ├── diagnostics.py\n│ └── table_ops.py\n├── 文档格式测试/ # 测试用例 (before/after DOCX 对)\n└── out/ # 输出(gitignored)\n └── sessions/ # v2: 每个 session 一个目录\n └── session-YYYYMMDD-HHMMSS/\n ├── metadata.json / messages.json / workflow.json\n ├── logs/ / drafts/ / style_profiles/ # uploads/ 在 v2.1 删 (前端无入口)\n\nfrontend/ # Next.js (非标准, 看 frontend/AGENTS.md)\n├── app/\n│ ├── page.tsx # 主交互页面 (~403 行, Step 6-9 拆出 ChatHeader/ChatInput/ChatMessageBlocks/ApprovalCheckpoint)\n│ ├── layout.tsx\n│ ├── globals.css\n│ └── test-stream/ # 早期流式 demo (当前主流程不走)\n├── components/ # 9 个 UI 组件\n│ ├── session-sidebar.tsx # 左侧 session 列表\n│ ├── chat-header.tsx # Header bar (流式/非流式 toggle + sidebar 开关)\n│ ├── chat-input.tsx # 底部 prompt 输入\n│ ├── chat-message-blocks.tsx # 4 种类型化消息块 (user/reasoning/content/toolGroup)\n│ ├── approval-checkpoint.tsx # wait_approval 内联面板\n│ ├── preview-panel.tsx # 右侧草稿预览 (md_draft 阶段自动展开)\n│ ├── animated-live-panel.tsx # 实时流式动画\n│ ├── markdown-renderer.tsx # Markdown 渲染\n│ └── reasoning-panel.tsx # 推理内容折叠展示\n├── hooks/\n│ └── use-agent-session.ts # 核心 hook (Step 4 提取): WS 生命周期 + 实时流 + approval state\n├── lib/\n│ ├── session-types.ts # 共享 SessionMeta type (v2: 删 lib/sessions.ts IndexedDB)\n│ ├── message-types.ts # 共享 Message / RenderBlock type (Step 1 提取)\n│ └── draft-types.ts # 共享 DraftFile type\n└── next.config.ts # rewrites 代理 /api/* -> 后端 :8000\n\ntests/ # 138/138 测试通过 (Step 2 fixture 隔离 SESSIONS_ROOT 跨 file 污染)\n├── test_step1.py # save/load + 锁 + Checkpoint\n├── test_step2.py # WS start/resume 协议\n├── test_step3.py # HTTP 控制面\n├── test_step4.py # dispatcher 注入 session_id\n└── test_step5.py # 前端静态契约 + next.config.ts rewrites\n```\n"}}, "src/llm_adapter/provider.py": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785768263.4768078, "n": 20853, "h": "6df41bf0cc32442b", "c": "\"\"\"LLMClient — 模型适配器主类\n\nStep 1 行为说明:\n 本文件是旧 src/llm_adapter.py 的\"纯搬家\"复刻 — __init__ 和\n create_chat_completion 的 if-else 分支原样保留,不改任何行为。\n 新增的 has_capability / reasoning_field / quirks / raw_config 是\n Step 2-4 要替换 if-else 用的占位接口,Step 1 给出默认实现以保证\n 系统在过渡期能跑。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport json\nimport warnings\nfrom pathlib import Path\nfrom openai import OpenAI\n\n\n# Step 2: provider 名 → 默认 capability 集合(Step 5 升级为完整 PROFILES + auto-migration)\n# 注意:这只是 v1 config 的兼容兜底,config.json 的 providers.<name>.capabilities\n# 字段会覆盖此表 — 新接入的 provider 在 config 里写 capabilities 即可,不需要改这里。\n_DEFAULT_CAPABILITIES: dict[str, frozenset] = {\n \"deepseek\": frozenset({\"chat\", \"tools\", \"reasoning\"}),\n \"sensenova\": frozenset({\"chat\", \"tools\", \"vision\", \"reasoning\"}),\n \"agnes\": frozenset({\"chat\", \"tools\", \"reasoning\"}),\n}\n_FALLBACK_CAPABILITIES = frozenset({\"chat\", \"tools\"}) # 未知 provider 的最低限度\n\n\n# Step 3: provider 名 → 流式响应 reasoning 字段的 JSONPath\n# 同样可被 config.json providers.<name>.reasoning_field 字段覆盖。\n# 历史:旧 agent.py:458-464 用 if-else 在每个 chunk 上判断,Step 3 集中到这里。\n_DEFAULT_REASONING_FIELDS: dict[str, str] = {\n \"deepseek\": \"delta.reasoning_content\", # OpenAI 标准字段\n \"sensenova\": \"delta.model_extra.reasoning\", # 商汤专有扩展(走 model_extra)\n \"agnes\": \"delta.reasoning_content\", # 用 OpenAI 标准\n}\n_FALLBACK_REASONING_FIELD = \"delta.reasoning_content\" # OpenAI 通用兼容\n\n\n# Step 4: provider 名 → 默认启用的 quirks tuple\n# 同样可被 config.json providers.<name>.quirks 字段覆盖(空列表也算覆盖,显式禁用所有 quirk)。\n# 历史:旧 agent.py:540-561 直接 inline 检查 sensenova 行为,Step 4 抽成命名 quirk。\n_DEFAULT_QUIRKS: dict[str, tuple] = {\n \"sensenova\": (\"stream_empty_retry\",), # 复现 session-20260609-205746\n}\n_FALLBACK_QUIRKS: tuple = ()\n\n\n# Step 5: provider 名 → 请求注入字段的默认表(消灭 create_chat_completion 的 if-else)\n# 三个字段共同决定一个 provider 的请求\"形状\":\n# - extra_body_template: JSON 模板,${var} 占位由 _render 渲染;为 None 时不注入 extra_body\n# - top_level_kwargs: 要塞到 chat.completions.create 顶层的 kwarg map(值可为 ${var} 模板)\n# - forward_tool_choice: 是否透传 caller 的 tool_choice kwarg\n# 这三个字段都可在 config.json providers.<name>.{extra_body_template,top_level_kwargs,\n# forward_tool_choice} 显式覆盖默认表。\n# extra_body 仅在 thinking != \"disabled\" 时注入(与旧行为一致)。\n_DEFAULT_EXTRA_BODY_TEMPLATES: dict[str, str] = {\n \"deepseek\": '{\"thinking\": {\"type\": \"${thinking}\"}}',\n \"agnes\": '{\"chat_template_kwargs\": {\"enable_thinking\": true}}',\n # sensenova 不需要 extra_body(reasoning_effort 走顶层)\n}\n_DEFAULT_TOP_LEVEL_KWARGS: dict[str, dict] = {\n \"sensenova\": {\"reasoning_effort\": \"${reasoning_effort}\"},\n}\n_DEFAULT_FORWARD_TOOL_CHOICE: dict[str, bool] = {\n \"sensenova\": True,\n \"agnes\": True,\n # deepseek 不在表里 → 默认 False(与旧 if-else 一致:deepseek 分支不处理 tool_choice)\n # (但 caller 的 tool_choice 仍会走最后的 kwarg 透传,行为不变)\n}\n\n\nclass LLMClient:\n \"\"\"模型适配层:统一管理不同大模型服务商(如 DeepSeek, SenseNova, Agnes)的连接与调用细节。\"\"\"\n\n def __init__(self, config_path=None, *, provider_override: str | None = None):\n if config_path is None:\n config_path = Path(__file__).parent.parent / \"config.json\"\n else:\n config_path = Path(config_path)\n self._config_path = config_path # Step 2: 供 pick_capable_adapter 重新构造用\n\n # 1. 加载 config.json\n self.config = {}\n if config_path.exists():\n try:\n with open(config_path, \"r\", encoding=\"utf-8\") as f:\n self.config = json.load(f)\n except Exception as e:\n print(f\"警告: 加载配置文件 {config_path} 失败: {e}\")\n\n # 2. 判定 provider\n # 优先级: provider_override(显式参数) -> 环境变量 LLM_PROVIDER -> config.json 的 provider 字段\n # -> 根据旧版 root-level 配置自动判定 -> 默认 deepseek\n self.provider = (\n provider_override\n or os.getenv(\"LLM_PROVIDER\")\n or self.config.get(\"provider\")\n )\n\n top_api_key = self.config.get(\"api_key\")\n top_base_url = self.config.get(\"base_url\")\n\n if not self.provider:\n # Step 6: base_url 子串启发式已 deprecated — 显式声明 \"provider\" 字段\n if top_base_url and (\"sensenova\" in top_base_url\n or \"agnes\" in top_base_url\n or \"deepseek\" in top_base_url):\n warnings.warn(\n \"通过 base_url 子串(sensenova/agnes/deepseek)推断 provider 已 deprecated。\"\n \"请在 config.json 顶层显式声明 \\\"provider\\\" 字段(参考 src/config.example.json)。\",\n DeprecationWarning,\n stacklevel=2,\n )\n if top_base_url and \"sensenova\" in top_base_url:\n self.provider = \"sensenova\"\n elif top_base_url and \"agnes\" in top_base_url:\n self.provider = \"agnes\"\n elif top_base_url and \"deepseek\" in top_base_url:\n self.provider = \"deepseek\"\n else:\n self.provider = \"deepseek\" # 默认降级为 deepseek\n\n # 3. 提取所选提供商的具体配置\n provider_config = {}\n if \"providers\" in self.config and self.provider in self.config[\"providers\"]:\n provider_config = self.config[\"providers\"][self.provider]\n\n # 初始化配置参数\n self.api_key = None\n self.base_url = None\n self.model = None\n self.thinking_type = None\n self.reasoning_effort = None\n self.image_model = None # Step 6: 生图模型 (用于 generate_image 工具)\n\n if self.provider == \"deepseek\":\n self.api_key = os.getenv(\"DEEPSEEK_API_KEY\") or os.getenv(\"LLM_API_KEY\")\n self.base_url = os.getenv(\"DEEPSEEK_BASE_URL\") or os.getenv(\"LLM_BASE_URL\")\n self.model = os.getenv(\"LLM_MODEL\") or os.getenv(\"OPENAI_MODEL\")\n self.thinking_type = os.getenv(\"DOCX_AGENT_THINKING\")\n\n self.api_key = self.api_key or provider_config.get(\"api_key\")\n self.base_url = self.base_url or provider_config.get(\"base_url\")\n self.model = self.model or provider_config.get(\"model\")\n if self.thinking_type is None:\n self.thinking_type = provider_config.get(\"thinking\")\n\n if not provider_config:\n self.api_key = self.api_key or top_api_key\n self.base_url = self.base_url or top_base_url\n # Step 6: flat config(顶层 api_key/base_url 无 providers 嵌套块)已 deprecated。\n # deepseek 是默认 provider,大多数旧 flat config 都走这里,warning 在此处触发足够。\n if top_api_key or top_base_url:\n warnings.warn(\n \"顶层 api_key/base_url 的 flat config 已 deprecated;\"\n \"请改用 providers.<name>.{api_key,base_url,model} 嵌套结构\"\n \"(参考 src/config.example.json)。\",\n DeprecationWarning,\n stacklevel=2,\n )\n self.base_url = self.base_url or \"https://api.deepseek.com\"\n self.model = self.model or \"deepseek-v4-flash\"\n if self.thinking_type is None:\n self.thinking_type = \"enabled\"\n\n elif self.provider == \"sensenova\":\n self.api_key = os.getenv(\"SENSENOVA_API_KEY\") or os.getenv(\"LLM_API_KEY\")\n self.base_url = os.getenv(\"SENSENOVA_BASE_URL\") or os.getenv(\"LLM_BASE_URL\")\n self.model = os.getenv(\"LLM_MODEL\")\n self.reasoning_effort = os.getenv(\"SENSENOVA_REASONING_EFFORT\") or os.getenv(\"LLM_REASONING_EFFORT\")\n\n self.api_key = self.api_key or provider_config.get(\"api_key\")\n self.base_url = self.base_url or provider_config.get(\"base_url\")\n self.model = self.model or provider_config.get(\"model\")\n self.reasoning_effort = self.reasoning_effort or provider_config.get(\"reasoning_effort\")\n\n if not provider_config and top_base_url and \"sensenova\" in top_base_url:\n self.api_key = self.api_key or top_api_key\n self.base_url = self.base_url or top_base_url\n\n self.base_url = self.base_url or \"https://token.sensenova.cn/v1\"\n self.model = self.model or \"sensenova-6.7-flash-lite\"\n self.thinking_type = \"disabled\" # 商汤不需要 DeepSeek-style thinking extra_body\n\n elif self.provider == \"agnes\":\n # Agnes-2.0-Flash (Sapiens AI, OpenAI 兼容, 现价免费)\n # thinking 注入方式: extra_body={\"chat_template_kwargs\": {\"enable_thinking\": True}}\n self.api_key = os.getenv(\"AGNES_API_KEY\") or os.getenv(\"LLM_API_KEY\")\n self.base_url = os.getenv(\"AGNES_BASE_URL\") or os.getenv(\"LLM_BASE_URL\")\n self.model = os.getenv(\"AGNES_MODEL\") or os.getenv(\"LLM_MODEL\")\n self.thinking_type = os.getenv(\"AGNES_THINKING\") or os.getenv(\"DOCX_AGENT_THINKING\")\n\n self.api_key = self.api_key or provider_config.get(\"api_key\")\n self.base_url = self.base_url or provider_config.get(\"base_url\")\n self.model = self.model or provider_config.get(\"model\")\n if self.thinking_type is None:\n self.thinking_type = provider_config.get(\"thinking\", \"enabled\")\n\n if not provider_config and top_base_url and \"agnes\" in top_base_url:\n self.api_key = self.api_key or top_api_key\n self.base_url = self.base_url or top_base_url\n\n self.base_url = self.base_url or \"https://apihub.agnes-ai.com/v1\"\n self.model = self.model or \"agnes-2.0-flash\"\n if self.thinking_type is None:\n self.thinking_type = \"enabled\" # 默认开启 thinking (probe 验证有效)\n\n else:\n # 通用 OpenAI 兼容配置\n self.api_key = os.getenv(\"OPENAI_API_KEY\") or os.getenv(\"LLM_API_KEY\") or top_api_key\n self.base_url = os.getenv(\"OPENAI_BASE_URL\") or os.getenv(\"LLM_BASE_URL\") or top_base_url\n self.model = os.getenv(\"LLM_MODEL\") or os.getenv(\"OPENAI_MODEL\")\n\n self.api_key = self.api_key or provider_config.get(\"api_key\")\n self.base_url = self.base_url or provider_config.get(\"base_url\")\n self.model = self.model or provider_config.get(\"model\")\n\n self.base_url = self.base_url or \"https://api.openai.com/v1\"\n self.model = self.model or \"gpt-4o\"\n # Step 5: 从 provider block 读 thinking / reasoning_effort,让 v2 config 对新接入\n # OpenAI 兼容 provider 也能用 extra_body_template / top_level_kwargs 模板渲染。\n # 旧行为:未知 provider 硬编码 thinking=\"disabled\",reasoning_effort=None — 这是限制,\n # 不是契约。修复后已知 3 个 provider 的行为不变(它们走 if/elif 分支),只放宽未知 provider。\n self.thinking_type = provider_config.get(\"thinking\", \"disabled\")\n self.reasoning_effort = self.reasoning_effort or provider_config.get(\"reasoning_effort\")\n\n # 检查 api_key 是否存在\n if not self.api_key:\n raise RuntimeError(\n f\"未设置 LLM API Key。请设置相应的环境变量(如 {self.provider.upper()}_API_KEY)\"\n f\"或在 config.json 中配置该提供商的 api_key。\"\n )\n\n # 4. 创建 OpenAI 客户端\n timeout = float(self.config.get(\"timeout_seconds\", 300))\n self.client = OpenAI(\n api_key=self.api_key,\n base_url=self.base_url,\n timeout=timeout,\n max_retries=0,\n )\n\n # 5. Step 2: 解析 capabilities\n # 优先级:provider block 显式声明 > _DEFAULT_CAPABILITIES 默认表 > _FALLBACK_CAPABILITIES\n # 新接入 provider 在 config.json 写 \"capabilities\": [...] 即可,不必改代码。\n provider_block = (self.config.get(\"providers\") or {}).get(self.provider) or {}\n if \"capabilities\" in provider_block:\n self._capabilities = frozenset(provider_block[\"capabilities\"])\n else:\n self._capabilities = _DEFAULT_CAPABILITIES.get(self.provider, _FALLBACK_CAPABILITIES)\n\n # 6. Step 3: 解析 reasoning_field\n # 优先级:provider block 显式 \"reasoning_field\" > _DEFAULT_REASONING_FIELDS > _FALLBACK\n # __init__ 阶段算一次缓存,property 直接 return — agent.py 流式循环每 chunk 查一次,需热路径性能。\n self._reasoning_field = (\n provider_block.get(\"reasoning_field\")\n or _DEFAULT_REASONING_FIELDS.get(self.provider, _FALLBACK_REASONING_FIELD)\n )\n\n # 7. Step 4: 解析 quirks\n # 优先级:provider block 显式 \"quirks\"(即使空列表 = 显式禁用) > _DEFAULT_QUIRKS > 空 tuple\n # 显式空列表的语义:用户明确告诉系统\"这个 provider 不要启用任何 quirk\"\n # (例如关掉 sensenova 的 stream_empty_retry 自查问题来源)。\n if \"quirks\" in provider_block:\n self._quirks = tuple(provider_block[\"quirks\"])\n else:\n self._quirks = _DEFAULT_QUIRKS.get(self.provider, _FALLBACK_QUIRKS)\n\n # 8. Step 5: 解析请求注入三件套(extra_body_template / top_level_kwargs / forward_tool_choice)\n # 同 capabilities/quirks 模式:provider block 显式 > _DEFAULT_* 表 > 空/False\n self._extra_body_template = (\n provider_block.get(\"extra_body_template\")\n or _DEFAULT_EXTRA_BODY_TEMPLATES.get(self.provider)\n )\n if \"top_level_kwargs\" in provider_block:\n self._top_level_kwargs = dict(provider_block[\"top_level_kwargs\"])\n else:\n self._top_level_kwargs = dict(_DEFAULT_TOP_LEVEL_KWARGS.get(self.provider, {}))\n if \"forward_tool_choice\" in provider_block:\n self._forward_tool_choice = bool(provider_block[\"forward_tool_choice\"])\n else:\n self._forward_tool_choice = _DEFAULT_FORWARD_TOOL_CHOICE.get(self.provider, False)\n\n # 9. Step 6: 解析 image_model(生图模型,generate_image 工具使用)\n # 优先级: provider block 显式 \"image_model\" > <PROVIDER>_IMAGE_MODEL 环境变量 > 默认值\n # 注意: 仅当 provider 声明了 text_to_image capability 时才有意义,\n # create_image_generation 由调用方负责 capability 检查。\n from .constants import DEFAULT_IMAGE_MODEL\n self.image_model = (\n provider_block.get(\"image_model\")\n or os.getenv(f\"{self.provider.upper()}_IMAGE_MODEL\")\n or (DEFAULT_IMAGE_MODEL if self.provider == \"sensenova\" else None)\n )\n\n # ────────────────────────── 公开 getter(旧接口,保留) ──────────────────────────\n\n def get_model_name(self) -> str:\n \"\"\"获取当前激活的模型名称\"\"\"\n return self.model\n\n def get_thinking_type(self):\n \"\"\"获取当前的 thinking 类型配置(仅用于 DeepSeek/Agnes 兼容性)\"\"\"\n return self.thinking_type\n\n def get_provider(self) -> str:\n \"\"\"获取当前所使用的提供商\"\"\"\n return self.provider\n\n def get_reasoning_effort(self):\n \"\"\"获取当前商汤模型的推理强度\"\"\"\n return self.reasoning_effort\n\n # ────────────────────────── 新增占位接口(Step 2-4 替换 if-else 用) ──────────────────────────\n\n def has_capability(self, capability: str) -> bool:\n \"\"\"是否具备某能力(chat/tools/vision/reasoning)。\n\n Step 2 实现:读 self._capabilities,它在 __init__ 阶段一次性解析:\n 1. config.json providers.<name>.capabilities 显式声明优先\n 2. 否则用 _DEFAULT_CAPABILITIES 表(deepseek/sensenova/agnes 三家)\n 3. 都没有则用 _FALLBACK_CAPABILITIES = {\"chat\",\"tools\"}\n\n Step 5 把默认表升级为完整 PROFILES + auto-migration,语义不变。\n \"\"\"\n return capability in self._capabilities\n\n @property\n def reasoning_field(self) -> str:\n \"\"\"响应流 reasoning 字段的 JSONPath。Step 3 实现:\n\n 1. config.json providers.<name>.reasoning_field 显式声明优先\n 2. 否则用 _DEFAULT_REASONING_FIELDS 表(deepseek/agnes→delta.reasoning_content,\n sensenova→delta.model_extra.reasoning)\n 3. 都没有则用 _FALLBACK_REASONING_FIELD = \"delta.reasoning_content\"(OpenAI 标准)\n\n 实例属性 self._reasoning_field 在 __init__ 算一次缓存 — agent.py 流式循环\n 每 chunk 都查一次,property 不能含 dict 查找/getattr 链。\n \"\"\"\n return self._reasoning_field\n\n @property\n def quirks(self) -> tuple:\n \"\"\"启用的 quirk 名 tuple。Step 4 实现:\n\n 1. config.json providers.<name>.quirks 显式声明优先(空列表 = 显式禁用所有)\n 2. 否则用 _DEFAULT_QUIRKS 表(sensenova → stream_empty_retry,其他无)\n 3. 都没有则空 tuple\n\n 实例属性 self._quirks 在 __init__ 缓存。agent.py 流式循环每轮检查一次,\n property 直接 return 避免重复 dict 查找。\n \"\"\"\n return self._quirks\n\n @property\n def extra_body_template(self):\n \"\"\"Step 5: extra_body 的 JSON 模板(${var} 占位由 _render 渲染)。\n\n 优先级:provider block \"extra_body_template\" > _DEFAULT_EXTRA_BODY_TEMPLATES > None\n None 时表示该 provider 不需要注入 extra_body。\n 约定:只在 thinking != \"disabled\" 时实际注入(与旧行为一致)。\n \"\"\"\n return self._extra_body_template\n\n @property\n def top_level_kwargs(self) -> dict:\n \"\"\"Step 5: 注入到 chat.completions.create 顶层的 kwarg map。值可为 ${var} 模板。\n\n 优先级:provider block \"top_level_kwargs\" > _DEFAULT_TOP_LEVEL_KWARGS > {}\n 典型用法:sensenova {\"reasoning_effort\": \"${reasoning_effort}\"}\n \"\"\"\n return self._top_level_kwargs\n\n @property\n def forward_tool_choice(self) -> bool:\n \"\"\"Step 5: 是否透传 caller 的 tool_choice kwarg。\n\n 优先级:provider block \"forward_tool_choice\" > _DEFAULT_FORWARD_TOOL_CHOICE > False\n 注意:即使返回 False,caller 的 tool_choice 仍会走 build_request_kwargs 最后的\n kwarg 透传循环(与旧 if-else 行为一致 — deepseek 没专门处理但 caller 传入仍透传)。\n \"\"\"\n return self._forward_tool_choice\n\n @property\n def raw_config(self) -> dict:\n \"\"\"返回原始 config.json 字典。给 registry.pick_capable_adapter 用,\n 不应该被工具层(basic_tools/*)直接读取。\"\"\"\n return self.config\n\n @property\n def config_path(self) -> Path:\n \"\"\"返回 config.json 的路径(Path 对象)。给 registry.pick_capable_adapter\n 重新构造同 config 下不同 provider 的 LLMClient 用。\"\"\"\n return self._config_path\n\n # ────────────────────────── 请求构建(Step 5: 数据驱动,消灭 if-else) ──────────────────────────\n\n def create_chat_completion(self, messages, tools=None, **kwargs):\n \"\"\"创建对话补全。\n\n Step 5: 通过 build_request_kwargs 数据驱动构造 — 注入逻辑由 3 个 property\n (extra_body_template / top_level_kwargs / forward_tool_choice) 决定,\n provider-specific if-else 已彻底消灭。新接入 OpenAI 兼容模型只需在\n config.json 写 4 行(api_key / base_url / model / 可选 capabilities),\n 其余字段从 _DEFAULT_* 表或 profile 默认值得来。\n \"\"\"\n from .request_builder import build_request_kwargs # 延迟 import 避免循环依赖\n req = build_request_kwargs(self, messages, tools, **kwargs)\n return self.client.chat.completions.create(**req)\n\n def create_chat_completion_blocking(\n self, *, messages, tools=None, **kwargs\n ) -> \"openai.types.ChatCompletion\":\n \"\"\"非流式: 一次性拿完整 response 对象(供 agent.py 流式 stall 时降级用)。\n\n 与 create_chat_completion 的区别:\n - 显式设 stream=False, 服务器一次性返回完整 JSON, 不走 SSE 协议\n - 返回 ChatCompletion 对象(非 iterator), response.choices[0].message 含\n 完整 content / tool_calls / reasoning\n - usage 挂在 response.usage(无需 stream_options.include_usage)\n - 适合 SSE 协议层有 bug 的 provider(如 SenseNova reasoning→content 切换 stall)\n\n 设计: 复用 build_request_kwargs(Step 5 数据驱动), 仅在最后强制覆盖\n stream=False, 不修改 request_builder。\n\n 调用方处理方式: agent.py 把 ChatCompletion 拆成 reasoning / content /\n tool_call 事件 yield 给下游, 主循环对\"流式 / 非流式\"无感。\n \"\"\"\n from .request_builder import build_request_kwargs\n req = build_request_kwargs(self, messages, tools, **kwargs)\n req[\"stream\"] = False\n return self.client.chat.completions.create(**req)\n\n def create_image_generation(\n self,\n prompt: str,\n size: str = \"2752x1536\",\n n: int = 1,\n **kwargs,\n ):\n \"\"\"生图接口: 调用 /v1/images/generations, 复用现有 self.client。\n\n Step 6 引入: 支持 sensenova-u1-fast 等生图模型。\n - size 在调用前做早期校验,避免触发商汤 400 错误\n - model 由 self.image_model 决定(可被 config 或 env 覆盖)\n - 与 chat completions 完全独立,不共用 build_request_kwargs(参数结构不同)\n\n 调用方负责检查 capability:\n if not adapter.has_capability(\"text_to_image\"):\n raise RuntimeError(...)\n \"\"\"\n from .constants import SENSENOVA_U1_VALID_SIZES\n\n # 早期 size 校验 — 避免商汤 API 返 400 后才报错\n if size not in SENSENOVA_U1_VALID_SIZES:\n raise ValueError(\n f\"非法 size: {size!r}。\"\n f\"商汤 sensenova-u1-fast 仅支持 11 种 2K 尺寸: {sorted(SENSENOVA_U1_VALID_SIZES)}\"\n )\n\n if not self.image_model:\n raise RuntimeError(\n f\"Provider {self.provider!r} 未配置生图模型。\"\n f\"请在 config.json 的 providers.{self.provider}.image_model 字段设置,\"\n f\"或设置环境变量 {self.provider.upper()}_IMAGE_MODEL。\"\n )\n\n return self.client.images.generate(\n model=self.image_model,\n prompt=prompt,\n size=size,\n n=n,\n **kwargs,\n )\n\n # ────────────────────────── 内部辅助(Step 1 parity 测试用) ──────────────────────────\n\n def _build_request_kwargs(self, messages, tools=None, **kwargs) -> dict:\n \"\"\"暴露请求 kwargs 构建过程供回归测试比对。不发起真实 API 调用。\n\n Step 5 后:这是 build_request_kwargs 的薄包装,parity 测试因此能验证\n \"数据驱动版与旧 _llm_adapter_legacy 的 if-else 版字节级一致\"。\n \"\"\"\n from .request_builder import build_request_kwargs\n return build_request_kwargs(self, messages, tools, **kwargs)\n"}}, "CLAUDE.md": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785768263.7198915, "n": 9122, "h": "a27a03ecbd146ebd", "c": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nDOCX Agent — AI-driven Word document editor using LLM tool calling + direct OpenXML manipulation. No `python-docx` dependency; operates directly on ZIP-wrapped XML.\n\n## Build Commands\n\nPrimary development surface is the FastAPI + WebSocket backend paired with the Next.js frontend. Run both during development.\n\n```powershell\n# Python backend (primary)\npip install -r requirements.txt\npython src/server.py # API + WebSocket server on http://127.0.0.1:8000\n\n# Frontend (Next.js, primary)\ncd frontend\nnpm install\nnpm run dev # Development server on http://localhost:3000\n```\n\nLegacy entry point (kept for backward compatibility, not the primary development path):\n\n```powershell\npython src/docx_agent_demo.py # Single-shot CLI demo (writes to global logs/)\n```\n\n## Architecture\n\n### Three-Stage State Machine\n\nLLM uses only tools permitted for the current stage:\n\n```\n样式审核 (Style Review) → Markdown 草稿 (MD Draft) → Word 写入编译 (Word Editing)\n```\n\n| Stage | Tools |\n|-------|-------|\n| Style Review | `analyze_docx_style_samples`, `bind_styles_to_roles`, `read_docx_structure`, `ls` |\n| MD Draft | `write_markdown_draft`, `read_markdown_draft`, `parse_markdown_draft`, `ls`, `read`, `analyze_image_content`, `generate_image`, `render_diagram` |\n| Word Editing | `read_docx_structure`, `write_markdown_draft`, `read_markdown_draft`, `parse_markdown_draft`, `markdown_to_word`, `diff_docx`, `ls`, `read`, `analyze_image_content`, `generate_image`, `render_diagram` |\n\nAuthoritative source of truth: `*_TOOL_NAMES` sets in `src/prompts.py` and `tool_schemas_for_state()` filtering.\n\n### Detail Edit Mode (detail_edit)\n\nSecond workflow mode for fine-grained editing of completed documents. Parallel to the three-stage fill mode.\n\n```\nstart(mode=\"detail_edit\") → tool selector agent → free editing loop → done\n```\n\n- **Tool selector** (`src/tool_selector.py`): Internal LLM call (blocking, no tools) that picks a tool subset from the full catalog based on user request + document structure.\n- **Base tools (6, always available)**: `read_docx_structure`, `find_text`, `ls`, `read`, `diff_docx`, `request_more_tools`. Defined in `DETAIL_EDIT_BASE_TOOLS` in `src/prompts.py`.\n- **Selected tools**: Write/modify atomic tools chosen by the selector. Filtered via `tool_schemas_for_detail_edit()` in `src/prompts.py`.\n- **Tool expansion**: Editing agent calls `request_more_tools(reason)` → selector re-evaluates → only-add-never-remove. Handled in `Agent._handle_request_more_tools()` in `src/agent.py`.\n- **No approval**: Free editing loop, ends when LLM produces content without tool calls.\n- **WS protocol**: `start` frame accepts `mode` field; `session_created`/`history` frames include `mode`; new events `tool_selection_start`/`tool_selection_end`.\n- **Persistence**: `mode` in metadata.json, `selected_tools` + `doc_structure_cache` in workflow.json.\n\n### Core Systems\n\n**Tool Registry** (`src/docx_tools/registry.py`): All 35 tools registered here in two parallel structures (`TOOLS` dict for dispatch, `TOOLS_SCHEMA` list for LLM-facing schemas). Tools grouped into `basic_tools/`, `md_tools/`, and `docx_tools/`.\n\n**Adding a new LLM-visible tool — four-place registration** (missing any one produces a different failure mode):\n\n1. `src/docx_tools/registry.py` — append to **both** `TOOLS` dict and `TOOLS_SCHEMA` list. Missing → LLM can't see / can't dispatch.\n2. `src/prompts.py` — add the tool name to the right `*_TOOL_NAMES` set for each stage that should expose it, and (when behavior nudging is needed) add a clause to the matching `state_rule`. Missing → tool is dispatchable but invisible to the LLM in that stage.\n3. `src/agent.py` — add the tool name to `SESSION_TOOLS` if its Python signature takes `session_id`. Missing → tool is visible and selected, but the dispatcher won't inject `session_id`, so the call dies with `missing 1 required positional argument: 'session_id'`.\n4. Tests — at minimum a unit test of the tool function in `tests/test_<tool>.py`. Recommended: a real-Kroki / real-API e2e probe to verify the integration layer beyond the in-process fixture.\n\nThe `SESSION_TOOLS` set in `src/agent.py:24-63` is the hidden fourth registry — its existence is easy to miss because it doesn't appear in any module-level `__all__` or `TOOLS_SCHEMA`. A typo or missing entry here only surfaces when a real WebSocket session calls the tool; unit tests with fixture-injected `session_id` will silently pass.\n\n**Markdown Compiler Pipeline** (`src/docx_compiler/`):\n```\nMarkdown text → markdown-it-py → MarkdownBlock AST → lower → Layout IR → render → OpenXML lxml tree → .docx\n```\nKey files: `ir.py` (ParagraphIR, TableIR, ImageIR, etc.), `markdown_parser.py` (AST), `lower.py` (AST→IR), `render.py` (IR→OpenXML).\n\n**LLM Adapter** (`src/llm_adapter.py`): Abstract layer supporting DeepSeek (with `thinking`), SenseNova (with `reasoning_effort`), and OpenAI-compatible APIs. Model selection via `config.json` or `LLM_PROVIDER`/`DEEPSEEK_API_KEY` env vars.\n\n**Server** (`src/server.py`): FastAPI + WebSocket for browser-based interactive editing with real-time state machine visualization. **Each WebSocket session logs to `out/sessions/<id>/logs/<session>.log` inside its own sandbox** — deleting the session purges its logs. The legacy `docx_agent_demo.py` CLI still writes to a global `logs/docx_agent_<timestamp>.log` (history kept for backward compatibility; not the primary path).\n\n### Key Constraints\n\n- Table operations: `table_index` counts all `//w:tbl` elements including nested tables; verify with `read_docx_structure` (depth, parent coords, `direct_text`) before operating.\n- Paragraph operations: must pass both `paragraph_index` and `anchor_text` to prevent misalignment.\n- Image analysis: always use `analyze_image_content` for visual content; never infer from filenames.\n- Image generation (写实风格): `generate_image` 工具内部封装了 sub-agent 审核循环 (商汤 vision 模型当审核员), 自动迭代重生直到图片质量合格。子 agent 的 reasoning 完整保留在 session log, 主 LLM 只看到最终路径。Schema 用 size enum (11 种 2K 尺寸) 约束 LLM 避免 400 错误;`max_iterations` 上限 5 防止 WS 假死。**仅用于装饰性/写实图;有逻辑结构的图请用 `render_diagram`。**\n- Diagram rendering (结构化图): `render_diagram` 让 LLM 直接写 Graphviz DOT 或 Mermaid 源码,通过 [kroki.io](https://kroki.io) 在线渲染成 PNG,落到 `media/<filename>.png`。流程图、状态机、架构图、组织结构图、时序图、类图等节点-边模型应优先用它,不要用 generate_image(文生图对结构化图节点错位、文字模糊)。**协议陷阱**:Kroki 要完整 zlib 包装(`78 ??` 头 + adler32 尾),不要剥头尾——`test_kroki_encode_keeps_zlib_header_and_checksum` 钉死了这条不变量。\n- Large files: use `read` with `offset`/`limit` (max ~500 lines per call).\n- Run preservation: when inserting text, prefer preserving original run formatting.\n\n## Directory Structure\n\n```\nsrc/\n├── server.py # FastAPI + WebSocket server (primary entry)\n├── docx_agent_demo.py # Legacy single-shot CLI (kept for backward compat)\n├── llm_adapter.py # LLM provider abstraction (re-export shim)\n├── config.json # API keys and provider config (gitignored)\n├── prompts.py # 阶段名常量 + *_TOOL_NAMES 集合 + state_rule\n├── basic_tools/ # ls, read, analyze_image_content, generate_image, render_diagram\n│ ├── _media.py # 共享: 图片下载/编码/消息构造\n│ └── ...\n├── agents/ # 内部 sub-agent 编排层 (主 agent 看不到)\n│ └── image_refiner.py # 生成图审核-重生 sub-agent\n├── md_tools/ # Markdown draft and markdown_to_word compiler\n├── docx_tools/ # 36+ tools + registry\n│ ├── registry.py # TOOLS / TOOLS_SCHEMA / call_tool\n│ ├── common.py # Shared XML/ZIP utilities (incl. word/media/ injection)\n│ ├── diff_docx.py # XML/text diff\n│ └── *.py # Individual tool implementations\n├── docx_compiler/ # Markdown → DOCX compiler\n│ ├── ir.py # Intermediate representation types\n│ ├── markdown_parser.py\n│ ├── lower.py # AST → IR lowering + diagnostics\n│ ├── render.py # IR → OpenXML rendering\n│ ├── optimizer.py # XML optimization\n│ └── table_ops.py # Table operations\n├── llm_adapter/ # LLM provider 抽象\n│ ├── constants.py # SENSENOVA_U1_VALID_SIZES 等模型枚举\n│ ├── provider.py # LLMClient 主类 (含 create_image_generation)\n│ └── ...\n└── workspace/ # 沙箱根、路径解析、配额防御\n └── guard.py # workspace_dir, resolve_workspace_path\nfrontend/\n├── app/ # Next.js app router pages\n├── AGENTS.md # Next.js breaking changes notice (important!)\n└── ...\nout/\n├── sessions/<id>/ # Web session 沙箱 (workspace + logs + drafts + uploads)\n└── drafts/ # Legacy: demo CLI 的草稿输出\n文档格式测试/ # Test cases with before/after DOCX pairs\ntests/ # pytest suite (287 passed @ ~24s)\nlogs/ # Legacy: demo CLI 的全局日志 (Web sessions log inside out/sessions/<id>/logs/)\n```\n\n## Frontend Note\n\nThis project uses a **non-standard Next.js version** with breaking API changes. Before modifying frontend code, read `frontend/AGENTS.md` and the Next.js guides in `node_modules/next/dist/docs/`."}}, "tests/test_llm_adapter_step5_profiles.py": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785846971.9940085, "n": 14498, "h": "8aa5e334e5c96465", "c": "\"\"\"Step 5 回归测试 — PROFILES + build_client + 请求注入三件套数据化 + v2 config\n\n测什么:\n1. **extra_body_template / top_level_kwargs / forward_tool_choice 三个新字段**:\n - 默认表(_DEFAULT_*)对 deepseek/sensenova/agnes 的预期值\n - provider block 显式声明覆盖默认\n - 未知 provider → fallback (None / {} / False)\n\n2. **build_client 工厂**(Step 5 新公开 API):\n - 从 dict 构造 + override_provider\n - 空 config 显式失败(缺 api_key)\n - dict 中 active provider 切换\n\n3. **PROFILES documentation**:\n - openai_compatible 在 PROFILES 中\n\n4. **通用接口 4 行 e2e**(plan §G Step 5 关键验收):\n - 仅 4 个字段(profile + api_key + base_url + model) 就能构造 LLMClient\n - 所有未声明字段 fallback 到 OpenAI 兼容默认\n\n5. **v1 / v2 schema 兼容**:\n - v1 config(无 Step 5 字段) 走 _DEFAULT_* fallback\n - v2 config(显式声明全部 Step 5 字段) 字段全部生效\n\n6. **build_request_kwargs 数据驱动**:\n - 通过显式字段控制 extra_body/top_level/tool_choice 注入\n - 与 _build_request_kwargs 一致(实质同一函数)\n\n7. **静态检查**: src/config.json 已升级到 v2; src/config.example.json 存在\n\"\"\"\n\nimport os\nimport sys\nimport json\nimport tempfile\nimport contextlib\nfrom pathlib import Path\n\nROOT = Path(__file__).parent.parent\nsys.path.insert(0, str(ROOT / \"src\"))\n\nfrom llm_adapter.provider import LLMClient # noqa: E402\nfrom llm_adapter.registry import PROFILES, build_client # noqa: E402\nfrom llm_adapter.request_builder import build_request_kwargs # noqa: E402\n\n\n# ─── 工具 ────────────────────────────────────────────\n\n@contextlib.contextmanager\ndef _clean_env(*keys: str):\n saved = {k: os.environ.pop(k, None) for k in keys}\n try:\n yield\n finally:\n for k, v in saved.items():\n if v is not None:\n os.environ[k] = v\n\n\ndef _write_temp_config(providers_block: dict, active_provider: str) -> str:\n cfg = {\"provider\": active_provider, \"providers\": providers_block}\n path = Path(tempfile.mkdtemp(prefix=\"docx_agent_step5_\")) / \"config.json\"\n path.write_text(json.dumps(cfg, ensure_ascii=False), encoding=\"utf-8\")\n return str(path)\n\n\ndef _block(extra: dict = None) -> dict:\n base = {\"api_key\": \"sk-test\", \"base_url\": \"https://example.com/v1\", \"model\": \"test-model\"}\n if extra:\n base.update(extra)\n return base\n\n\n# ─── 1. 注入三件套默认表 ────────────────────────\n\ndef test_default_extra_body_template_deepseek():\n cfg = _write_temp_config({\"deepseek\": _block()}, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.extra_body_template == '{\"thinking\": {\"type\": \"${thinking}\"}}'\n print(\"[OK] default extra_body_template: deepseek\")\n\n\ndef test_default_extra_body_template_agnes():\n cfg = _write_temp_config({\"agnes\": _block()}, \"agnes\")\n ad = LLMClient(cfg)\n assert \"chat_template_kwargs\" in ad.extra_body_template\n print(\"[OK] default extra_body_template: agnes\")\n\n\ndef test_default_extra_body_template_sensenova_none():\n cfg = _write_temp_config({\"sensenova\": _block()}, \"sensenova\")\n ad = LLMClient(cfg)\n assert ad.extra_body_template is None, \"sensenova 不需要 extra_body\"\n print(\"[OK] default extra_body_template: sensenova None\")\n\n\ndef test_default_top_level_kwargs_sensenova():\n cfg = _write_temp_config({\"sensenova\": _block()}, \"sensenova\")\n ad = LLMClient(cfg)\n assert ad.top_level_kwargs == {\"reasoning_effort\": \"${reasoning_effort}\"}\n print(\"[OK] default top_level_kwargs: sensenova reasoning_effort\")\n\n\ndef test_default_top_level_kwargs_deepseek_empty():\n cfg = _write_temp_config({\"deepseek\": _block()}, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.top_level_kwargs == {}\n print(\"[OK] default top_level_kwargs: deepseek 空\")\n\n\ndef test_default_forward_tool_choice():\n \"\"\"sensenova / agnes 默认 forward_tool_choice = True;deepseek / 通用 = False\"\"\"\n cases = [(\"sensenova\", True), (\"agnes\", True), (\"deepseek\", False)]\n for provider, expected in cases:\n cfg = _write_temp_config({provider: _block()}, provider)\n ad = LLMClient(cfg)\n assert ad.forward_tool_choice == expected, f\"{provider} forward_tool_choice 应为 {expected}\"\n print(\"[OK] default forward_tool_choice (3 providers)\")\n\n\ndef test_default_forward_tool_choice_unknown_provider_false():\n cfg = _write_temp_config({\"通用模型\": _block()}, \"通用模型\")\n with _clean_env(\"OPENAI_API_KEY\", \"LLM_API_KEY\"):\n ad = LLMClient(cfg)\n assert ad.forward_tool_choice is False\n print(\"[OK] default forward_tool_choice: 未知 provider → False\")\n\n\n# ─── 2. 三件套 provider block 覆盖 ──────────────\n\ndef test_extra_body_template_block_override():\n cfg = _write_temp_config({\n \"deepseek\": _block({\"extra_body_template\": '{\"custom\": \"${thinking}\"}'})\n }, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.extra_body_template == '{\"custom\": \"${thinking}\"}'\n print(\"[OK] extra_body_template block override\")\n\n\ndef test_top_level_kwargs_block_override():\n cfg = _write_temp_config({\n \"deepseek\": _block({\"top_level_kwargs\": {\"my_kwarg\": \"fixed\"}})\n }, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.top_level_kwargs == {\"my_kwarg\": \"fixed\"}\n print(\"[OK] top_level_kwargs block override\")\n\n\ndef test_forward_tool_choice_block_override():\n cfg = _write_temp_config({\n \"deepseek\": _block({\"forward_tool_choice\": True}) # deepseek 默认 False,显式开\n }, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.forward_tool_choice is True\n print(\"[OK] forward_tool_choice block override\")\n\n\n# ─── 3. build_client 工厂 ────────────────────\n\ndef test_build_client_from_dict():\n config = {\n \"providers\": {\n \"sensenova\": {\n \"api_key\": \"sk-test\",\n \"base_url\": \"https://example.com/v1\",\n \"model\": \"test-model\",\n }\n }\n }\n client = build_client(config, override_provider=\"sensenova\")\n assert client.get_provider() == \"sensenova\"\n assert client.get_model_name() == \"test-model\"\n # 默认表生效\n assert client.has_capability(\"vision\")\n assert client.reasoning_field == \"delta.model_extra.reasoning\"\n assert client.quirks == (\"stream_empty_retry\",)\n print(\"[OK] build_client 从 dict 构造,默认表生效\")\n\n\ndef test_build_client_empty_config_raises():\n try:\n build_client({})\n raise AssertionError(\"空 config 应抛 RuntimeError\")\n except RuntimeError as e:\n assert \"API Key\" in str(e)\n print(\"[OK] build_client 空 config 显式 fail-loud\")\n\n\ndef test_build_client_override_provider():\n \"\"\"显式 override_provider 覆盖 config['provider'] 字段\"\"\"\n config = {\n \"provider\": \"sensenova\",\n \"providers\": {\n \"sensenova\": _block(),\n \"deepseek\": _block(),\n }\n }\n client = build_client(config, override_provider=\"deepseek\")\n assert client.get_provider() == \"deepseek\", \"override 应覆盖 config['provider']\"\n print(\"[OK] build_client override_provider 工作\")\n\n\n# ─── 4. PROFILES documentation ──────────────────\n\ndef test_profiles_has_openai_compatible():\n assert \"openai_compatible\" in PROFILES\n profile = PROFILES[\"openai_compatible\"]\n # 文档字段应至少含这些\n for key in (\"capabilities\", \"reasoning_field\", \"forward_tool_choice\",\n \"quirks\", \"extra_body_template\", \"top_level_kwargs\"):\n assert key in profile, f\"openai_compatible profile 应有 {key} 字段\"\n print(\"[OK] PROFILES 含 openai_compatible 文档示例\")\n\n\n# ─── 5. ★ 通用接口 4 行 e2e(Step 5 关键验收) ─────\n\ndef test_minimal_4_line_provider_block_works():\n \"\"\"**Plan §G Step 5 关键验收**: 只声明 profile + api_key + base_url + model 4 个字段,\n 其他全部 fallback,能成功构造 LLMClient 且默认表生效。\"\"\"\n cfg = _write_temp_config({\n \"通用接口\": {\n \"profile\": \"openai_compatible\", # 第 1 行\n \"api_key\": \"sk-test\", # 第 2 行\n \"base_url\": \"https://your-endpoint/v1\",# 第 3 行\n \"model\": \"your-model\" # 第 4 行\n }\n }, \"通用接口\")\n with _clean_env(\"OPENAI_API_KEY\", \"LLM_API_KEY\"):\n ad = LLMClient(cfg)\n # 构造成功\n assert ad.get_provider() == \"通用接口\"\n assert ad.get_model_name() == \"your-model\"\n # 所有 Step 5 三件套 fallback 到 OpenAI 兼容默认\n assert ad.extra_body_template is None, \"未声明 → None(不注入)\"\n assert ad.top_level_kwargs == {}, \"未声明 → 空\"\n assert ad.forward_tool_choice is False, \"未声明 → False\"\n # 其他 step fallback\n assert ad.has_capability(\"chat\") and ad.has_capability(\"tools\")\n assert not ad.has_capability(\"vision\"), \"未声明 vision capability\"\n assert ad.reasoning_field == \"delta.reasoning_content\", \"默认 OpenAI 标准\"\n assert ad.quirks == (), \"默认无 quirk\"\n # build_request_kwargs 数据驱动验证 — 一个最简请求\n req = build_request_kwargs(ad, [{\"role\": \"user\", \"content\": \"hi\"}])\n assert req[\"model\"] == \"your-model\"\n assert req[\"messages\"] == [{\"role\": \"user\", \"content\": \"hi\"}]\n assert \"extra_body\" not in req, \"未声明 extra_body_template → 不注入\"\n assert \"reasoning_effort\" not in req, \"未声明 top_level_kwargs → 不注入\"\n print(\"[OK] 通用接口 4 行 config 完整 e2e: 构造 + property + build_request_kwargs\")\n\n\n# ─── 6. v1 / v2 兼容性 ─────────────────────\n\ndef test_v1_config_backward_compat():\n \"\"\"v1 schema(无 Step 5 字段)应继续工作,走 _DEFAULT_* fallback\"\"\"\n cfg = _write_temp_config({\n \"deepseek\": { # v1: 无 extra_body_template / top_level_kwargs / forward_tool_choice\n \"api_key\": \"sk-test\",\n \"base_url\": \"https://api.deepseek.com\",\n \"model\": \"deepseek-v4-flash\",\n \"thinking\": \"enabled\",\n }\n }, \"deepseek\")\n ad = LLMClient(cfg)\n # 默认表 fallback 生效,行为等价于 v2 显式声明\n assert \"thinking\" in ad.extra_body_template\n assert ad.top_level_kwargs == {}\n assert ad.forward_tool_choice is False\n # build_request_kwargs 产生与旧 if-else 一致的 request\n req = build_request_kwargs(ad, [{\"role\": \"user\", \"content\": \"x\"}])\n assert req[\"extra_body\"] == {\"thinking\": {\"type\": \"enabled\"}}, \"v1 config 应得到旧 if-else 等价输出\"\n print(\"[OK] v1 config 向后兼容(走 _DEFAULT_* fallback)\")\n\n\ndef test_v2_config_explicit_fields_take_effect():\n \"\"\"v2 schema 显式声明所有字段,字段直接生效(不依赖默认表)\"\"\"\n cfg = _write_temp_config({\n \"我的新模型\": {\n \"profile\": \"openai_compatible\",\n \"api_key\": \"sk-test\",\n \"base_url\": \"https://my-endpoint/v1\",\n \"model\": \"my-model\",\n \"thinking\": \"enabled\",\n \"reasoning_effort\": \"medium\",\n \"capabilities\": [\"chat\", \"tools\", \"vision\", \"reasoning\"],\n \"reasoning_field\": \"delta.my_path.r\",\n \"forward_tool_choice\": True,\n \"extra_body_template\": '{\"my_param\": \"${thinking}\"}',\n \"top_level_kwargs\": {\"my_kwarg\": \"${reasoning_effort}\"},\n \"quirks\": [],\n }\n }, \"我的新模型\")\n with _clean_env(\"OPENAI_API_KEY\", \"LLM_API_KEY\"):\n ad = LLMClient(cfg)\n # 所有 Step 5 字段生效\n assert ad.extra_body_template == '{\"my_param\": \"${thinking}\"}'\n assert ad.top_level_kwargs == {\"my_kwarg\": \"${reasoning_effort}\"}\n assert ad.forward_tool_choice is True\n # build_request_kwargs 渲染正确\n req = build_request_kwargs(ad, [{\"role\": \"user\", \"content\": \"x\"}])\n assert req[\"extra_body\"] == {\"my_param\": \"enabled\"}\n assert req[\"my_kwarg\"] == \"medium\"\n print(\"[OK] v2 config 显式字段全部生效 + 渲染正确\")\n\n\n# ─── 7. build_request_kwargs 直接调用 ─────\n\ndef test_build_request_kwargs_renders_extra_body_with_thinking():\n cfg = _write_temp_config({\"deepseek\": _block({\"thinking\": \"enabled\"})}, \"deepseek\")\n ad = LLMClient(cfg)\n req = build_request_kwargs(ad, [{\"role\": \"user\", \"content\": \"x\"}])\n assert req[\"extra_body\"] == {\"thinking\": {\"type\": \"enabled\"}}\n print(\"[OK] build_request_kwargs 渲染 deepseek extra_body\")\n\n\ndef test_build_request_kwargs_skips_extra_body_when_thinking_disabled():\n \"\"\"thinking=disabled 时不注入 extra_body(与旧行为一致)\"\"\"\n cfg = _write_temp_config({\"deepseek\": _block({\"thinking\": \"disabled\"})}, \"deepseek\")\n ad = LLMClient(cfg)\n req = build_request_kwargs(ad, [{\"role\": \"user\", \"content\": \"x\"}])\n assert \"extra_body\" not in req\n print(\"[OK] build_request_kwargs thinking=disabled 时跳过 extra_body\")\n\n\ndef test_build_request_kwargs_forwards_tool_choice():\n cfg = _write_temp_config({\"sensenova\": _block()}, \"sensenova\")\n ad = LLMClient(cfg)\n req = build_request_kwargs(ad, [{\"role\": \"user\", \"content\": \"x\"}], tool_choice=\"auto\")\n assert req[\"tool_choice\"] == \"auto\"\n print(\"[OK] build_request_kwargs 转发 tool_choice (sensenova)\")\n\n\n# ─── 8. 静态检查 config 文件 ─────────────────\n\ndef test_src_config_is_v2_schema():\n \"\"\"src/config.json 应已升级到 v2 显式版\"\"\"\n cfg_text = (ROOT / \"src\" / \"config.json\").read_text(encoding=\"utf-8\")\n cfg = json.loads(cfg_text)\n sensenova_block = cfg[\"providers\"][\"sensenova\"]\n assert \"profile\" in sensenova_block, \"v2 应有 profile 字段\"\n assert sensenova_block.get(\"capabilities\"), \"v2 应显式声明 capabilities\"\n assert sensenova_block.get(\"reasoning_field\"), \"v2 应显式声明 reasoning_field\"\n assert sensenova_block.get(\"quirks\"), \"v2 应显式声明 quirks\"\n assert \"top_level_kwargs\" in sensenova_block, \"v2 应显式声明 top_level_kwargs\"\n print(\"[OK] src/config.json 已升级到 v2 显式 schema\")\n\n\ndef test_config_example_exists_with_4_line_template():\n \"\"\"src/config.example.json 应存在且含通用接口 4 行模板示例\"\"\"\n path = ROOT / \"src\" / \"config.example.json\"\n assert path.exists(), \"应新增 src/config.example.json\"\n text = path.read_text(encoding=\"utf-8\")\n assert \"通用接口\" in text, \"示例应含'通用接口'示范\"\n assert '\"profile\": \"openai_compatible\"' in text, \"示例应使用 openai_compatible profile\"\n print(\"[OK] src/config.example.json 含 4 行通用接口示例\")\n\n\nif __name__ == \"__main__\":\n test_default_extra_body_template_deepseek()\n test_default_extra_body_template_agnes()\n test_default_extra_body_template_sensenova_none()\n test_default_top_level_kwargs_sensenova()\n test_default_top_level_kwargs_deepseek_empty()\n test_default_forward_tool_choice()\n test_default_forward_tool_choice_unknown_provider_false()\n test_extra_body_template_block_override()\n test_top_level_kwargs_block_override()\n test_forward_tool_choice_block_override()\n test_build_client_from_dict()\n test_build_client_empty_config_raises()\n test_build_client_override_provider()\n test_profiles_has_openai_compatible()\n test_minimal_4_line_provider_block_works()\n test_v1_config_backward_compat()\n test_v2_config_explicit_fields_take_effect()\n test_build_request_kwargs_renders_extra_body_with_thinking()\n test_build_request_kwargs_skips_extra_body_when_thinking_disabled()\n test_build_request_kwargs_forwards_tool_choice()\n test_src_config_is_v2_schema()\n test_config_example_exists_with_4_line_template()\n\n print()\n print(\"=\" * 60)\n print(\"✓ Step 5 全部 22 个测试通过 — 数据驱动构造 + v2 schema + 通用接口 4 行\")\n print(\"=\" * 60)\n"}}, "tests/test_llm_adapter_step1_parity.py": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785846972.0177581, "n": 8057, "h": "c53ae932f91e8527", "c": "\"\"\"Step 1 单元测试(Step 6 后已退役 parity 部分)\n\n历史:Step 1 commit 7cc259b 引入 _llm_adapter_legacy.py 做\"新版子包 vs 旧单文件\"\n字节级回归对比(parity tests)。Step 1-5 期间共 5 次跑通 parity,验证 Step 5 数据\n驱动版与旧 if-else 字节级一致。Step 6 删除 _llm_adapter_legacy.py,parity 测试\n也随之退役。\n\n本文件保留非 parity 的契约测试:\n- _render JSON-escape (Review #3)\n- QuirkAction / QuirkDirective dataclass 契约 (Review #1)\n- apply_quirk 错误处理\n- 向后兼容 import (LLMClientAdapter is LLMClient)\n- registry build_client 工厂(Step 5 落地)\n- backward-compat 4 getters\n- raw_config / has_capability 与旧 agent 行为一致性\n\"\"\"\n\nimport os\nimport sys\nimport json\nfrom pathlib import Path\n\nROOT = Path(__file__).parent.parent\nsys.path.insert(0, str(ROOT / \"src\"))\n\nimport llm_adapter as new_pkg # noqa: E402 — 新子包(__init__ 重导出 LLMClientAdapter)\n\nCONFIG = str(ROOT / \"src\" / \"config.json\")\n\n\n# ─── Review #3: _render JSON-escape ──────────────────────────────\n\ndef test_render_basic_substitution():\n from llm_adapter.request_builder import _render\n assert _render('{\"type\": \"${x}\"}', {\"x\": \"enabled\"}) == '{\"type\": \"enabled\"}'\n assert _render('plain', {}) == 'plain' # 无占位符\n assert _render('${missing}', {}) == '' # 未提供 → 空\n print(\"[OK] _render basic substitution\")\n\n\ndef test_render_json_escape_special_chars():\n \"\"\"Review #3: 含 \" / \\\\ / 换行 的值不能破坏 extra_body 的 JSON 合法性\"\"\"\n from llm_adapter.request_builder import _render\n for value in ['a\"b', 'a\\\\b', 'a\\nb', '中文\\\\and \"quotes\"']:\n rendered = _render('{\"x\": \"${v}\"}', {\"v\": value})\n # 关键不变量:渲染产物必须是合法 JSON,且 round-trip 等于原值\n obj = json.loads(rendered)\n assert obj[\"x\"] == value, f\"round-trip failed for {value!r}: got {obj['x']!r}\"\n print(\"[OK] _render JSON-escape (4 special-char cases)\")\n\n\n# ─── Review #1: QuirkAction Enum + QuirkDirective dataclass ───────\n\ndef test_quirk_action_enum_distinct():\n from llm_adapter.quirks import QuirkAction\n assert QuirkAction.CONTINUE != QuirkAction.RETRY_REQUEST\n assert QuirkAction.CONTINUE is not QuirkAction.RETRY_REQUEST\n print(\"[OK] QuirkAction distinct values\")\n\n\ndef test_quirk_directive_frozen():\n from llm_adapter.quirks import QuirkAction, QuirkDirective\n d = QuirkDirective(QuirkAction.RETRY_REQUEST, reason=\"test\")\n assert d.action == QuirkAction.RETRY_REQUEST\n assert d.reason == \"test\"\n # frozen — 改字段应该报错\n try:\n d.action = QuirkAction.CONTINUE\n raise AssertionError(\"QuirkDirective 应该是 frozen,但允许了赋值\")\n except (AttributeError, Exception) as e:\n # FrozenInstanceError 继承自 AttributeError\n assert \"frozen\" in str(e).lower() or isinstance(e, AttributeError)\n print(\"[OK] QuirkDirective frozen\")\n\n\ndef test_quirks_registry_has_stream_empty_retry():\n \"\"\"Step 4 已注册 stream_empty_retry\"\"\"\n from llm_adapter.quirks import QUIRKS\n assert \"stream_empty_retry\" in QUIRKS, \"Step 4 后 stream_empty_retry 应已注册\"\n print(\"[OK] QUIRKS 含 Step 4 注册的 stream_empty_retry\")\n\n\ndef test_apply_quirk_unknown_raises():\n from llm_adapter.quirks import apply_quirk\n try:\n apply_quirk(\"nonexistent_quirk\", {})\n raise AssertionError(\"apply_quirk 对未知名应该抛 RuntimeError\")\n except RuntimeError as e:\n assert \"Unknown quirk\" in str(e)\n print(\"[OK] apply_quirk unknown raises\")\n\n\n# ─── 占位接口与旧行为一致性(读 v2 config.json) ──────────────\n\ndef test_has_capability_vision_matches_old_allowlist():\n \"\"\"Step 2 后:vision capability 与旧 analyze_image_content.py:48 白名单\n {sensenova, openai, gemini} 行为一致(sensenova=True, deepseek/agnes=False)\"\"\"\n cases = [(\"sensenova\", True), (\"deepseek\", False), (\"agnes\", False)]\n for provider, expected in cases:\n os.environ[\"LLM_PROVIDER\"] = provider\n try:\n ad = new_pkg.LLMClientAdapter(CONFIG)\n finally:\n os.environ.pop(\"LLM_PROVIDER\", None)\n assert ad.has_capability(\"vision\") == expected, \\\n f\"{provider}.has_capability('vision') 应为 {expected}\"\n assert ad.has_capability(\"chat\") is True\n assert ad.has_capability(\"tools\") is True\n print(\"[OK] has_capability vision 与旧白名单一致\")\n\n\ndef test_reasoning_field_matches_old_agent_if_else():\n \"\"\"Step 3 后:reasoning_field 与旧 agent.py:461-464 if-else 一致\"\"\"\n cases = [\n (\"sensenova\", \"delta.model_extra.reasoning\"),\n (\"deepseek\", \"delta.reasoning_content\"),\n (\"agnes\", \"delta.reasoning_content\"),\n ]\n for provider, expected_path in cases:\n os.environ[\"LLM_PROVIDER\"] = provider\n try:\n ad = new_pkg.LLMClientAdapter(CONFIG)\n finally:\n os.environ.pop(\"LLM_PROVIDER\", None)\n assert ad.reasoning_field == expected_path, \\\n f\"{provider}.reasoning_field = {ad.reasoning_field!r} != {expected_path!r}\"\n print(\"[OK] reasoning_field 与旧 agent if-else 一致\")\n\n\ndef test_quirks_property_deepseek_empty():\n \"\"\"deepseek 默认无 quirk(sensenova 默认有 stream_empty_retry — Step 4 后断言不再适用)\"\"\"\n os.environ[\"LLM_PROVIDER\"] = \"deepseek\"\n try:\n ad = new_pkg.LLMClientAdapter(CONFIG)\n finally:\n os.environ.pop(\"LLM_PROVIDER\", None)\n assert ad.quirks == ()\n print(\"[OK] deepseek.quirks 空\")\n\n\ndef test_raw_config_accessible():\n \"\"\"raw_config 暴露原始 dict 给 registry.pick_capable_adapter 用\"\"\"\n os.environ[\"LLM_PROVIDER\"] = \"deepseek\"\n try:\n ad = new_pkg.LLMClientAdapter(CONFIG)\n finally:\n os.environ.pop(\"LLM_PROVIDER\", None)\n cfg = ad.raw_config\n assert isinstance(cfg, dict)\n assert \"providers\" in cfg\n print(\"[OK] raw_config exposes original dict\")\n\n\n# ─── 向后兼容 ────────────────────────────────────────────\n\ndef test_backward_compat_import():\n from llm_adapter import LLMClientAdapter\n from llm_adapter import LLMClient\n assert LLMClientAdapter is LLMClient, \"LLMClientAdapter 应当是 LLMClient 的别名 (is 关系)\"\n print(\"[OK] backward-compat import: LLMClientAdapter is LLMClient\")\n\n\ndef test_backward_compat_class_methods():\n \"\"\"旧调用方使用的 4 个 getter + create_chat_completion 仍然存在\"\"\"\n os.environ[\"LLM_PROVIDER\"] = \"sensenova\"\n try:\n ad = new_pkg.LLMClientAdapter(CONFIG)\n finally:\n os.environ.pop(\"LLM_PROVIDER\", None)\n assert ad.get_model_name()\n assert ad.get_provider() == \"sensenova\"\n assert ad.get_thinking_type() == \"disabled\"\n assert ad.get_reasoning_effort() == \"high\"\n assert callable(ad.create_chat_completion)\n print(\"[OK] backward-compat: 4 getters + create_chat_completion\")\n\n\n# ─── registry.build_client(Step 5 落地)──────\n\ndef test_registry_build_client_from_dict():\n \"\"\"Step 5 起 build_client 是公开工厂,空 config 显式失败,合法 config 正确构造\"\"\"\n from llm_adapter.registry import build_client\n # 空 config → 缺 api_key → 显式 RuntimeError(fail-loud)\n try:\n build_client({})\n raise AssertionError(\"空 config 应触发 RuntimeError(缺 api_key)\")\n except RuntimeError as e:\n assert \"API Key\" in str(e)\n\n # 合法 config → 能成功构造\n valid_cfg = {\n \"providers\": {\n \"deepseek\": {\n \"api_key\": \"sk-test\",\n \"base_url\": \"https://api.deepseek.com\",\n \"model\": \"deepseek-v4-flash\",\n }\n }\n }\n client = build_client(valid_cfg, override_provider=\"deepseek\")\n assert client.get_provider() == \"deepseek\"\n assert client.get_model_name() == \"deepseek-v4-flash\"\n print(\"[OK] registry.build_client 从 dict 正确构造 + 空 config fail-loud\")\n\n\nif __name__ == \"__main__\":\n test_render_basic_substitution()\n test_render_json_escape_special_chars()\n test_quirk_action_enum_distinct()\n test_quirk_directive_frozen()\n test_quirks_registry_has_stream_empty_retry()\n test_apply_quirk_unknown_raises()\n test_backward_compat_import()\n test_registry_build_client_from_dict()\n\n # 需要 config.json 才能跑的\n test_backward_compat_class_methods()\n test_raw_config_accessible()\n test_has_capability_vision_matches_old_allowlist()\n test_reasoning_field_matches_old_agent_if_else()\n test_quirks_property_deepseek_empty()\n\n print()\n print(\"=\" * 60)\n print(\"✓ Step 1 单元测试 13 个全部通过(parity 部分 Step 6 退役)\")\n print(\"=\" * 60)\n"}}, "tests/test_llm_adapter_step6_cleanup.py": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785846972.0642874, "n": 7176, "h": "f592a22055d37b78", "c": "\"\"\"Step 6 回归测试 — 遗留清理 + DeprecationWarning\n\n测什么:\n1. **DeprecationWarning 触发**:\n - base_url 子串启发式(顶层无 provider 字段但 base_url 含 sensenova/agnes/deepseek)\n - flat config(顶层 api_key/base_url 但 providers 嵌套块缺失)\n2. **遗留文件已删除**:\n - src/_llm_adapter_legacy.py 不存在\n - 不能再 import _llm_adapter_legacy\n3. **server.py 清理**:\n - if provider == \"deepseek\" / \"sensenova\" / \"agnes\" 分支已删除\n - 改为通用读取 thinking_type + reasoning_effort\n4. **依赖兼容**(server.py / agent.py / analyze_image_content.py 的 import 仍工作)\n\"\"\"\n\nimport os\nimport sys\nimport json\nimport tempfile\nimport warnings\nimport contextlib\nfrom pathlib import Path\n\nROOT = Path(__file__).parent.parent\nsys.path.insert(0, str(ROOT / \"src\"))\n\n\n@contextlib.contextmanager\ndef _clean_env(*keys: str):\n saved = {k: os.environ.pop(k, None) for k in keys}\n try:\n yield\n finally:\n for k, v in saved.items():\n if v is not None:\n os.environ[k] = v\n\n\ndef _write_temp_config(data: dict) -> str:\n path = Path(tempfile.mkdtemp(prefix=\"docx_agent_step6_\")) / \"config.json\"\n path.write_text(json.dumps(data, ensure_ascii=False), encoding=\"utf-8\")\n return str(path)\n\n\n# ─── DeprecationWarning 触发 ────────────────────────\n\ndef test_deprecation_warning_on_base_url_heuristic():\n \"\"\"顶层未声明 provider + base_url 含 sensenova → DeprecationWarning\"\"\"\n # flat config(无 providers 嵌套块,只用顶层 base_url + api_key)\n cfg = {\"base_url\": \"https://token.sensenova.cn/v1\", \"api_key\": \"sk-test\"}\n cfg_path = _write_temp_config(cfg)\n with _clean_env(\"LLM_PROVIDER\", \"OPENAI_API_KEY\", \"LLM_API_KEY\"):\n # 必须重置 __warningregistry__ — 同一进程内多次构造同 module 同 lineno 默认只 warn 一次\n from llm_adapter.provider import LLMClient\n if hasattr(LLMClient.__init__.__code__, '__warningregistry__'):\n LLMClient.__init__.__code__.__warningregistry__.clear()\n with warnings.catch_warnings(record=True) as caught:\n warnings.simplefilter(\"always\")\n client = LLMClient(cfg_path)\n # 至少有一个 DeprecationWarning\n deprecation_msgs = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)]\n assert deprecation_msgs, f\"应触发 DeprecationWarning,实际 caught={[str(w.message) for w in caught]}\"\n assert any(\"base_url\" in m and \"deprecated\" in m for m in deprecation_msgs), \\\n \"base_url 启发式的 DeprecationWarning 应明确提到 base_url + deprecated\"\n assert client.get_provider() == \"sensenova\" # 启发式仍然生效(只是 warn)\n print(\"[OK] base_url 启发式触发 DeprecationWarning\")\n\n\ndef test_deprecation_warning_on_flat_config():\n \"\"\"deepseek + flat config(顶层 api_key/base_url + 无 providers.deepseek) → DeprecationWarning\"\"\"\n cfg = {\n \"provider\": \"deepseek\",\n \"api_key\": \"sk-test\",\n \"base_url\": \"https://api.deepseek.com\",\n }\n cfg_path = _write_temp_config(cfg)\n with _clean_env(\"LLM_PROVIDER\", \"DEEPSEEK_API_KEY\", \"LLM_API_KEY\"):\n from llm_adapter.provider import LLMClient\n with warnings.catch_warnings(record=True) as caught:\n warnings.simplefilter(\"always\")\n client = LLMClient(cfg_path)\n deprecation_msgs = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)]\n assert deprecation_msgs, \"flat config 应触发 DeprecationWarning\"\n assert any(\"flat config\" in m and \"deprecated\" in m for m in deprecation_msgs), \\\n \"flat config 的 DeprecationWarning 应明确提到 flat config + deprecated\"\n assert client.get_provider() == \"deepseek\" # flat config 仍然生效\n assert client.api_key == \"sk-test\"\n print(\"[OK] flat config 触发 DeprecationWarning\")\n\n\ndef test_no_warning_on_proper_v2_config():\n \"\"\"v2 config(显式 provider + 嵌套 providers 块) → 不应触发 DeprecationWarning\"\"\"\n cfg = {\n \"provider\": \"deepseek\",\n \"providers\": {\n \"deepseek\": {\n \"api_key\": \"sk-test\",\n \"base_url\": \"https://api.deepseek.com\",\n \"model\": \"deepseek-v4-flash\",\n }\n }\n }\n cfg_path = _write_temp_config(cfg)\n with _clean_env(\"LLM_PROVIDER\", \"DEEPSEEK_API_KEY\", \"LLM_API_KEY\"):\n from llm_adapter.provider import LLMClient\n with warnings.catch_warnings(record=True) as caught:\n warnings.simplefilter(\"always\")\n client = LLMClient(cfg_path)\n deprecation_msgs = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)]\n assert not deprecation_msgs, f\"v2 config 不应触发 DeprecationWarning,实际:{deprecation_msgs}\"\n print(\"[OK] v2 config 不触发 DeprecationWarning\")\n\n\n# ─── 遗留文件已删除 ──────────────────────────────\n\ndef test_legacy_adapter_file_removed():\n \"\"\"src/_llm_adapter_legacy.py 已 Step 6 删除\"\"\"\n legacy = ROOT / \"src\" / \"_llm_adapter_legacy.py\"\n assert not legacy.exists(), f\"src/_llm_adapter_legacy.py 应已删除,但仍存在\"\n print(\"[OK] src/_llm_adapter_legacy.py 已删除\")\n\n\ndef test_legacy_adapter_unimportable():\n \"\"\"import _llm_adapter_legacy 应失败\"\"\"\n try:\n import _llm_adapter_legacy # noqa: F401\n raise AssertionError(\"应该 import 失败\")\n except ImportError:\n pass\n print(\"[OK] import _llm_adapter_legacy 失败(已删除)\")\n\n\n# ─── server.py 清理 ────────────────────────────\n\ndef test_server_no_provider_if_else():\n \"\"\"server.py 的 if-provider 分支(if provider == 'deepseek' / 'sensenova' / 'agnes') 已删除\"\"\"\n src = ROOT / \"src\" / \"server.py\"\n text = src.read_text(encoding=\"utf-8\")\n assert 'if provider == \"deepseek\":' not in text, \\\n \"server.py 应删除 if-provider 分支(start_config 现在用通用读取)\"\n assert 'elif provider == \"sensenova\":' not in text\n assert 'elif provider == \"agnes\":' not in text\n print(\"[OK] server.py 已删除 if-provider 分支\")\n\n\ndef test_server_uses_generic_adapter_state():\n \"\"\"server.py 改用通用 thinking_type / reasoning_effort 读取(任何 provider 自动适应)\"\"\"\n src = ROOT / \"src\" / \"server.py\"\n text = src.read_text(encoding=\"utf-8\")\n assert \"adapter.get_thinking_type()\" in text\n assert \"adapter.get_reasoning_effort()\" in text\n print(\"[OK] server.py 改用通用读取(任何 provider 自适应)\")\n\n\n# ─── 依赖兼容 ────────────────────────────────\n\ndef test_callers_still_import_correctly():\n \"\"\"server.py / agent.py / analyze_image_content.py 的 import 仍工作\"\"\"\n from llm_adapter import LLMClientAdapter\n from llm_adapter.response_parser import extract_reasoning\n from llm_adapter.quirks import apply_quirk, QuirkAction\n from llm_adapter.registry import pick_capable_adapter, build_client\n # 烟雾测试:能 callable\n assert callable(LLMClientAdapter)\n assert callable(extract_reasoning)\n assert callable(apply_quirk)\n assert callable(pick_capable_adapter)\n assert callable(build_client)\n print(\"[OK] 所有调用方 import 路径仍工作\")\n\n\nif __name__ == \"__main__\":\n test_legacy_adapter_file_removed()\n test_legacy_adapter_unimportable()\n test_server_no_provider_if_else()\n test_server_uses_generic_adapter_state()\n test_callers_still_import_correctly()\n test_deprecation_warning_on_base_url_heuristic()\n test_deprecation_warning_on_flat_config()\n test_no_warning_on_proper_v2_config()\n\n print()\n print(\"=\" * 60)\n print(\"✓ Step 6 全部 8 个测试通过 — 遗留清理 + DeprecationWarning 落地\")\n print(\"=\" * 60)\n"}}, "tests/test_llm_adapter_step4_quirks.py": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785846972.3141658, "n": 6941, "h": "8411c35d9ba0f65a", "c": "\"\"\"Step 4 回归测试 — quirks 注册表 + stream_empty_retry + agent.py 改造\n\n测什么:\n1. QUIRKS 注册表:stream_empty_retry 已注册\n2. stream_empty_retry quirk 函数行为:\n - finish_reason=None + 空 tool_calls → RETRY_REQUEST\n - finish_reason 有值 → CONTINUE\n - 有 tool_calls → CONTINUE\n3. provider.quirks property:\n - sensenova → (\"stream_empty_retry\",)(默认表)\n - deepseek/agnes → () (默认表无)\n - provider block 显式 \"quirks\" 覆盖默认表(空列表 = 显式禁用)\n4. static check: agent.py 已迁移到 apply_quirk + QuirkAction.RETRY_REQUEST\n\"\"\"\n\nimport os\nimport sys\nimport json\nimport tempfile\nimport contextlib\nfrom pathlib import Path\n\nROOT = Path(__file__).parent.parent\nsys.path.insert(0, str(ROOT / \"src\"))\n\nfrom llm_adapter.provider import LLMClient # noqa: E402\nfrom llm_adapter.quirks import ( # noqa: E402\n QUIRKS, QuirkAction, QuirkDirective, apply_quirk,\n)\n\n\n# ─── 工具 ───────────────────────────────────────────────\n\n@contextlib.contextmanager\ndef _clean_env(*keys: str):\n saved = {k: os.environ.pop(k, None) for k in keys}\n try:\n yield\n finally:\n for k, v in saved.items():\n if v is not None:\n os.environ[k] = v\n\n\ndef _write_temp_config(providers_block: dict, active_provider: str) -> str:\n cfg = {\"provider\": active_provider, \"providers\": providers_block}\n path = Path(tempfile.mkdtemp(prefix=\"docx_agent_step4_\")) / \"config.json\"\n path.write_text(json.dumps(cfg, ensure_ascii=False), encoding=\"utf-8\")\n return str(path)\n\n\ndef _block(extra: dict = None) -> dict:\n base = {\"api_key\": \"sk-test\", \"base_url\": \"https://example.com/v1\", \"model\": \"test-model\"}\n if extra:\n base.update(extra)\n return base\n\n\n# ─── 注册表 ────────────────────────────────\n\ndef test_stream_empty_retry_registered():\n assert \"stream_empty_retry\" in QUIRKS, \"Step 4 应注册 stream_empty_retry\"\n print(\"[OK] stream_empty_retry 已注册到 QUIRKS\")\n\n\n# ─── stream_empty_retry quirk 函数行为 ─────\n\ndef test_quirk_triggers_on_silent_close():\n \"\"\"finish_reason=None + 空 tool_calls → RETRY_REQUEST\"\"\"\n directive = apply_quirk(\"stream_empty_retry\", {\n \"finish_reason\": None,\n \"tool_calls_map\": {},\n \"accumulated_content\": \"\",\n \"accumulated_reasoning\": \"在想但没产出\",\n })\n assert directive.action == QuirkAction.RETRY_REQUEST\n assert directive.reason == \"stream_incomplete\"\n print(\"[OK] quirk 在 finish_reason=None + tool_calls 空时 → RETRY_REQUEST\")\n\n\ndef test_quirk_passes_when_finish_reason_present():\n \"\"\"finish_reason 有值(正常结束)→ CONTINUE\"\"\"\n directive = apply_quirk(\"stream_empty_retry\", {\n \"finish_reason\": \"stop\",\n \"tool_calls_map\": {},\n \"accumulated_content\": \"正常响应\",\n \"accumulated_reasoning\": \"\",\n })\n assert directive.action == QuirkAction.CONTINUE\n print(\"[OK] quirk 在 finish_reason=stop 时 → CONTINUE\")\n\n\ndef test_quirk_passes_when_tool_calls_present():\n \"\"\"finish_reason=None 但有 tool_calls(server 还在发 tool chunks)→ CONTINUE\"\"\"\n directive = apply_quirk(\"stream_empty_retry\", {\n \"finish_reason\": None,\n \"tool_calls_map\": {0: {\"name\": \"foo\", \"arguments\": \"{}\"}},\n \"accumulated_content\": \"\",\n \"accumulated_reasoning\": \"\",\n })\n assert directive.action == QuirkAction.CONTINUE\n print(\"[OK] quirk 在有 tool_calls 时 → CONTINUE\")\n\n\n# ─── provider.quirks property ─────────────────\n\ndef test_provider_quirks_sensenova_default():\n cfg = _write_temp_config({\"sensenova\": _block()}, \"sensenova\")\n ad = LLMClient(cfg)\n assert ad.quirks == (\"stream_empty_retry\",), f\"sensenova 默认应启用 stream_empty_retry,实际 {ad.quirks}\"\n print(\"[OK] sensenova 默认启用 stream_empty_retry\")\n\n\ndef test_provider_quirks_deepseek_default_empty():\n cfg = _write_temp_config({\"deepseek\": _block()}, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.quirks == (), f\"deepseek 默认无 quirk,实际 {ad.quirks}\"\n print(\"[OK] deepseek 默认无 quirk\")\n\n\ndef test_provider_quirks_agnes_default_empty():\n cfg = _write_temp_config({\"agnes\": _block()}, \"agnes\")\n ad = LLMClient(cfg)\n assert ad.quirks == (), f\"agnes 默认无 quirk,实际 {ad.quirks}\"\n print(\"[OK] agnes 默认无 quirk\")\n\n\ndef test_provider_quirks_block_override():\n \"\"\"provider block 显式 'quirks' 列表覆盖默认表\"\"\"\n cfg = _write_temp_config({\n \"deepseek\": _block({\"quirks\": [\"stream_empty_retry\"]}) # deepseek 强制启用\n }, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.quirks == (\"stream_empty_retry\",), \"block 显式 quirks 应覆盖默认\"\n print(\"[OK] block 显式 quirks 覆盖默认\")\n\n\ndef test_provider_quirks_block_explicit_empty_disables_default():\n \"\"\"显式空列表 = 用户明确禁用 sensenova 默认的 stream_empty_retry(用于 debug)\"\"\"\n cfg = _write_temp_config({\n \"sensenova\": _block({\"quirks\": []})\n }, \"sensenova\")\n ad = LLMClient(cfg)\n assert ad.quirks == (), \"显式 quirks=[] 应清空所有 quirk(包括默认表)\"\n print(\"[OK] 显式空列表禁用默认 quirk\")\n\n\ndef test_provider_quirks_unknown_provider_empty():\n cfg = _write_temp_config({\"我自己的模型\": _block()}, \"我自己的模型\")\n with _clean_env(\"OPENAI_API_KEY\", \"LLM_API_KEY\"):\n ad = LLMClient(cfg)\n assert ad.quirks == ()\n print(\"[OK] 未知 provider 默认无 quirk\")\n\n\n# ─── apply_quirk 错误处理 ─────────────\n\ndef test_apply_unknown_quirk_raises_with_hint():\n \"\"\"未知 quirk 名 → RuntimeError + 列出已注册名(配置错误时 fail-loud)\"\"\"\n try:\n apply_quirk(\"nonexistent_quirk_xyz\", {})\n raise AssertionError(\"apply_quirk 应抛 RuntimeError\")\n except RuntimeError as e:\n msg = str(e)\n assert \"Unknown quirk\" in msg\n assert \"stream_empty_retry\" in msg, \"错误信息应列出当前已注册的 quirks\"\n print(\"[OK] apply_quirk 未知名抛 RuntimeError 并列出已注册\")\n\n\n# ─── agent.py 静态改造检查 ───\n\ndef test_agent_uses_apply_quirk():\n src = ROOT / \"src\" / \"agent.py\"\n text = src.read_text(encoding=\"utf-8\")\n assert \"from llm_adapter.quirks import apply_quirk, QuirkAction\" in text, \\\n \"agent.py 应 import apply_quirk + QuirkAction\"\n assert \"for quirk_name in self.llm.quirks\" in text, \\\n \"agent.py 应遍历 self.llm.quirks\"\n assert \"QuirkAction.RETRY_REQUEST\" in text, \\\n \"agent.py 应用 QuirkAction Enum 判断 action\"\n # 旧 inline if-else 触发条件应已删除\n assert \"if finish_reason is None and not tool_calls_map:\" not in text, \\\n \"旧 inline 触发条件应已抽到 quirk 函数内\"\n print(\"[OK] agent.py 已完成 Step 4 改造(静态检查)\")\n\n\nif __name__ == \"__main__\":\n test_stream_empty_retry_registered()\n test_quirk_triggers_on_silent_close()\n test_quirk_passes_when_finish_reason_present()\n test_quirk_passes_when_tool_calls_present()\n test_provider_quirks_sensenova_default()\n test_provider_quirks_deepseek_default_empty()\n test_provider_quirks_agnes_default_empty()\n test_provider_quirks_block_override()\n test_provider_quirks_block_explicit_empty_disables_default()\n test_provider_quirks_unknown_provider_empty()\n test_apply_unknown_quirk_raises_with_hint()\n test_agent_uses_apply_quirk()\n\n print()\n print(\"=\" * 60)\n print(\"✓ Step 4 全部 12 个测试通过 — quirks 配置化 + agent.py 解耦完毕\")\n print(\"=\" * 60)\n"}}, "tests/test_llm_adapter_step3_reasoning.py": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785846972.4438107, "n": 8193, "h": "d1f98957cbef07d9", "c": "\"\"\"Step 3 回归测试 — reasoning_field 配置化 + extract_reasoning + agent.py 接入\n\n测什么:\n1. reasoning_field 默认表 (sensenova → model_extra.reasoning,deepseek/agnes → reasoning_content)\n2. provider block 显式 \"reasoning_field\" 字段覆盖默认表\n3. 未知 provider → _FALLBACK_REASONING_FIELD\n4. extract_reasoning 两条已知路径行为\n5. extract_reasoning 通用 dotted-path 兜底(让自定义路径可声明)\n6. extract_reasoning 字段缺失返回 None\n7. static check: agent.py 已迁移到 extract_reasoning,旧 if-else 已删除\n\"\"\"\n\nimport os\nimport sys\nimport json\nimport tempfile\nimport contextlib\nfrom pathlib import Path\n\nROOT = Path(__file__).parent.parent\nsys.path.insert(0, str(ROOT / \"src\"))\n\nfrom llm_adapter.provider import LLMClient # noqa: E402\nfrom llm_adapter.response_parser import extract_reasoning # noqa: E402\n\n\n# ─── 工具 ───────────────────────────────────────────────\n\n@contextlib.contextmanager\ndef _clean_env(*keys: str):\n saved = {k: os.environ.pop(k, None) for k in keys}\n try:\n yield\n finally:\n for k, v in saved.items():\n if v is not None:\n os.environ[k] = v\n\n\ndef _write_temp_config(providers_block: dict, active_provider: str) -> str:\n cfg = {\"provider\": active_provider, \"providers\": providers_block}\n path = Path(tempfile.mkdtemp(prefix=\"docx_agent_step3_\")) / \"config.json\"\n path.write_text(json.dumps(cfg, ensure_ascii=False), encoding=\"utf-8\")\n return str(path)\n\n\ndef _block(extra: dict = None) -> dict:\n base = {\"api_key\": \"sk-test\", \"base_url\": \"https://example.com/v1\", \"model\": \"test-model\"}\n if extra:\n base.update(extra)\n return base\n\n\nclass FakeDelta:\n \"\"\"模仿 OpenAI chunk.choices[0].delta 的最小结构。\"\"\"\n def __init__(self, reasoning_content=None, model_extra=None, content=None):\n self.reasoning_content = reasoning_content\n self.model_extra = model_extra or {}\n self.content = content\n\n\n# ─── reasoning_field 默认表 ────────────────────────\n\ndef test_reasoning_field_default_sensenova():\n cfg = _write_temp_config({\"sensenova\": _block()}, \"sensenova\")\n ad = LLMClient(cfg)\n assert ad.reasoning_field == \"delta.model_extra.reasoning\", \\\n f\"sensenova 默认应为 delta.model_extra.reasoning,实际 {ad.reasoning_field}\"\n print(\"[OK] reasoning_field default: sensenova → model_extra.reasoning\")\n\n\ndef test_reasoning_field_default_deepseek():\n cfg = _write_temp_config({\"deepseek\": _block()}, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.reasoning_field == \"delta.reasoning_content\"\n print(\"[OK] reasoning_field default: deepseek → reasoning_content\")\n\n\ndef test_reasoning_field_default_agnes():\n cfg = _write_temp_config({\"agnes\": _block()}, \"agnes\")\n ad = LLMClient(cfg)\n assert ad.reasoning_field == \"delta.reasoning_content\"\n print(\"[OK] reasoning_field default: agnes → reasoning_content\")\n\n\n# ─── reasoning_field 通过 provider block 覆盖 ──────\n\ndef test_reasoning_field_block_override():\n \"\"\"显式 reasoning_field 字段覆盖默认表\"\"\"\n cfg = _write_temp_config({\n \"deepseek\": _block({\"reasoning_field\": \"delta.custom.path\"})\n }, \"deepseek\")\n ad = LLMClient(cfg)\n assert ad.reasoning_field == \"delta.custom.path\"\n print(\"[OK] reasoning_field block override\")\n\n\ndef test_reasoning_field_new_provider_can_declare_path():\n \"\"\"新接入 provider 在 config 里写 reasoning_field 即可,不必改代码\"\"\"\n cfg = _write_temp_config({\n \"通用接口\": _block({\"reasoning_field\": \"delta.model_extra.foo.bar\"})\n }, \"通用接口\")\n with _clean_env(\"OPENAI_API_KEY\", \"LLM_API_KEY\"):\n ad = LLMClient(cfg)\n assert ad.reasoning_field == \"delta.model_extra.foo.bar\"\n print(\"[OK] reasoning_field 新 provider 可自由声明路径\")\n\n\n# ─── reasoning_field 未知 provider → fallback ────\n\ndef test_reasoning_field_unknown_provider_fallback():\n cfg = _write_temp_config({\"我自己的模型\": _block()}, \"我自己的模型\")\n with _clean_env(\"OPENAI_API_KEY\", \"LLM_API_KEY\"):\n ad = LLMClient(cfg)\n assert ad.reasoning_field == \"delta.reasoning_content\", \\\n \"未知 provider 应 fallback 到 OpenAI 标准字段\"\n print(\"[OK] reasoning_field unknown provider → fallback (OpenAI 标准)\")\n\n\n# ─── extract_reasoning 两条已知路径 ─────────────\n\ndef test_extract_reasoning_deepseek_path():\n delta = FakeDelta(reasoning_content=\"思考中...\")\n assert extract_reasoning(delta, \"delta.reasoning_content\") == \"思考中...\"\n print(\"[OK] extract_reasoning deepseek path\")\n\n\ndef test_extract_reasoning_sensenova_path():\n delta = FakeDelta(model_extra={\"reasoning\": \"商汤推理\"})\n assert extract_reasoning(delta, \"delta.model_extra.reasoning\") == \"商汤推理\"\n print(\"[OK] extract_reasoning sensenova path\")\n\n\ndef test_extract_reasoning_paths_isolated():\n \"\"\"关键不变量:Step 3 严格走配置指定的路径,不再像旧 if-else 自动 fallback\"\"\"\n delta = FakeDelta(reasoning_content=\"A\", model_extra={\"reasoning\": \"B\"})\n # 走 deepseek path 只返回 reasoning_content\n assert extract_reasoning(delta, \"delta.reasoning_content\") == \"A\"\n # 走 sensenova path 只返回 model_extra.reasoning\n assert extract_reasoning(delta, \"delta.model_extra.reasoning\") == \"B\"\n print(\"[OK] extract_reasoning 严格按配置路径,互不串扰\")\n\n\n# ─── extract_reasoning 字段缺失 / 类型错误 ──────\n\ndef test_extract_reasoning_missing_field_returns_none():\n delta = FakeDelta() # reasoning_content=None, model_extra={}\n assert extract_reasoning(delta, \"delta.reasoning_content\") is None\n assert extract_reasoning(delta, \"delta.model_extra.reasoning\") is None\n print(\"[OK] extract_reasoning 字段缺失 → None\")\n\n\ndef test_extract_reasoning_non_string_returns_none():\n \"\"\"约定:reasoning 字段必须是 str,否则视为无效返回 None\"\"\"\n delta = FakeDelta(reasoning_content=123) # int 不是 str\n # 注意:快路径直接 return getattr,所以 int 也会返回(快路径不做类型检查);\n # 但通用 dotted-path 做类型检查。这里我们测通用 path 行为。\n class D2:\n nested = type(\"X\", (), {\"deep\": 123})()\n assert extract_reasoning(D2(), \"delta.nested.deep\") is None, \"非 str 通用路径应返回 None\"\n print(\"[OK] extract_reasoning 通用 path 非 str 返回 None\")\n\n\n# ─── extract_reasoning 通用 dotted-path 兜底 ────\n\ndef test_extract_reasoning_generic_dotted_path():\n \"\"\"允许任意 dotted path,让新 provider 在 config 声明自定义路径即可生效\"\"\"\n class Inner:\n def __init__(self): self.deep = \"通用值\"\n class FakeD:\n def __init__(self): self.nested = Inner()\n delta = FakeD()\n assert extract_reasoning(delta, \"delta.nested.deep\") == \"通用值\"\n assert extract_reasoning(delta, \"delta.nested.missing\") is None\n print(\"[OK] extract_reasoning 通用 dotted-path\")\n\n\ndef test_extract_reasoning_dotted_path_dict_intermediate():\n \"\"\"中间节点是 dict 也能走通(模拟 model_extra 是 dict 的情况)\"\"\"\n class FakeD:\n def __init__(self): self.bag = {\"x\": {\"y\": \"深层值\"}}\n delta = FakeD()\n assert extract_reasoning(delta, \"delta.bag.x.y\") == \"深层值\"\n print(\"[OK] extract_reasoning dotted-path 跨 object→dict\")\n\n\n# ─── agent.py 静态改造检查 ──────────────────\n\ndef test_agent_uses_extract_reasoning():\n \"\"\"static check: agent.py 已迁移到 extract_reasoning + self.llm.reasoning_field\"\"\"\n src = ROOT / \"src\" / \"agent.py\"\n text = src.read_text(encoding=\"utf-8\")\n assert \"from llm_adapter.response_parser import extract_reasoning\" in text, \\\n \"agent.py 应 import extract_reasoning\"\n assert \"extract_reasoning(delta, self.llm.reasoning_field)\" in text, \\\n \"agent.py 应调用 extract_reasoning(delta, self.llm.reasoning_field)\"\n # 旧硬编码 if-else 应已删除\n assert 'getattr(delta, \"model_extra\", None) or {}' not in text, \\\n \"agent.py 不应再含旧 if-else fallback 代码\"\n print(\"[OK] agent.py 已完成 Step 3 改造(静态检查)\")\n\n\nif __name__ == \"__main__\":\n test_reasoning_field_default_sensenova()\n test_reasoning_field_default_deepseek()\n test_reasoning_field_default_agnes()\n test_reasoning_field_block_override()\n test_reasoning_field_new_provider_can_declare_path()\n test_reasoning_field_unknown_provider_fallback()\n test_extract_reasoning_deepseek_path()\n test_extract_reasoning_sensenova_path()\n test_extract_reasoning_paths_isolated()\n test_extract_reasoning_missing_field_returns_none()\n test_extract_reasoning_non_string_returns_none()\n test_extract_reasoning_generic_dotted_path()\n test_extract_reasoning_dotted_path_dict_intermediate()\n test_agent_uses_extract_reasoning()\n\n print()\n print(\"=\" * 60)\n print(\"✓ Step 3 全部 14 个测试通过 — reasoning_field 配置化 + agent 提取解耦\")\n print(\"=\" * 60)\n"}}, "tests/test_llm_adapter_step2_capability.py": {"16ebc5c29a98bacad50447cef49ce3abd1882902": {"t": 1785846974.7582169, "n": 10730, "h": "3726b0d3197fb308", "c": "\"\"\"Step 2 回归测试 — capability 系统 + pick_capable_adapter 工厂 + analyze_image_content 改造\n\n测什么:\n1. **has_capability 真实实现**:\n - 默认表(_DEFAULT_CAPABILITIES): deepseek/sensenova/agnes 三家硬编码\n - provider block 显式声明 capabilities 字段 → 覆盖默认表\n - 未知 provider → _FALLBACK_CAPABILITIES = {\"chat\",\"tools\"}\n\n2. **pick_capable_adapter 工厂**(Review #2 落地的核心):\n - current 已具备 → 直接返回 current (is 关系)\n - current 不具备 + config 有 fallback → 返回新 LLMClient\n - current 不具备 + 无 fallback → 返回 None\n - **关键反模式消除**: 不污染 os.environ['LLM_PROVIDER']\n\n3. **analyze_image_content.py 改造** (静态代码检查):\n - 已 import 并调用 pick_capable_adapter\n - 不再含 os.environ mutation\n - 不再含 {\"sensenova\",\"openai\",\"gemini\"} 硬编码白名单\n\n策略:用临时 config.json (写到 tempdir) 隔离测试与项目实际 config,\n避免修改 src/config.json 影响测试 / 反之。\n\"\"\"\n\nimport os\nimport sys\nimport json\nimport tempfile\nimport contextlib\nfrom pathlib import Path\n\nROOT = Path(__file__).parent.parent\nsys.path.insert(0, str(ROOT / \"src\"))\n\nfrom llm_adapter.provider import LLMClient # noqa: E402\nfrom llm_adapter.registry import pick_capable_adapter # noqa: E402\n\n\n# ─── 工具: 隔离 env vars(避免测试受跑测试机器的 shell 影响) ────\n\n@contextlib.contextmanager\ndef _clean_env(*keys: str):\n \"\"\"临时清掉指定 env vars,with 块退出后恢复原值。\n\n 用途:LLMClient 对未知 provider 走 `else` 分支时,会从 OPENAI_API_KEY/\n LLM_API_KEY 等 env vars 抢 api_key — 这让\"坏配置应构造失败\"的测试\n 依赖跑测试机器的 shell 状态。clean 掉后测试可重复。\n \"\"\"\n saved = {k: os.environ.pop(k, None) for k in keys}\n try:\n yield\n finally:\n for k, v in saved.items():\n if v is not None:\n os.environ[k] = v\n\n\n# ─── 工具: 写临时 config.json ──────────────────────────────────\n\ndef _write_temp_config(providers_block: dict, active_provider: str) -> str:\n \"\"\"写一个临时 config.json,返回路径(str)。\"\"\"\n cfg = {\"provider\": active_provider, \"providers\": providers_block}\n tmp_dir = Path(tempfile.mkdtemp(prefix=\"docx_agent_step2_\"))\n path = tmp_dir / \"config.json\"\n path.write_text(json.dumps(cfg, ensure_ascii=False), encoding=\"utf-8\")\n return str(path)\n\n\n# 通用最小 provider block(api_key + base_url + model 三件套,api_key 不能为空否则 __init__ 抛 RuntimeError)\ndef _block(extra: dict = None) -> dict:\n base = {\"api_key\": \"sk-test\", \"base_url\": \"https://example.com/v1\", \"model\": \"test-model\"}\n if extra:\n base.update(extra)\n return base\n\n\n# ─── has_capability:默认表 ───────────────────────────────────\n\ndef test_has_capability_default_table_sensenova_has_vision():\n cfg_path = _write_temp_config({\"sensenova\": _block()}, \"sensenova\")\n ad = LLMClient(cfg_path)\n assert ad.has_capability(\"chat\")\n assert ad.has_capability(\"tools\")\n assert ad.has_capability(\"reasoning\")\n assert ad.has_capability(\"vision\"), \"sensenova 默认应有 vision\"\n print(\"[OK] has_capability default: sensenova 含 vision\")\n\n\ndef test_has_capability_default_table_deepseek_no_vision():\n cfg_path = _write_temp_config({\"deepseek\": _block()}, \"deepseek\")\n ad = LLMClient(cfg_path)\n assert ad.has_capability(\"chat\")\n assert ad.has_capability(\"tools\")\n assert ad.has_capability(\"reasoning\")\n assert not ad.has_capability(\"vision\"), \"deepseek 默认不应有 vision\"\n print(\"[OK] has_capability default: deepseek 无 vision\")\n\n\ndef test_has_capability_default_table_agnes_no_vision():\n cfg_path = _write_temp_config({\"agnes\": _block()}, \"agnes\")\n ad = LLMClient(cfg_path)\n assert ad.has_capability(\"chat\")\n assert ad.has_capability(\"tools\")\n assert ad.has_capability(\"reasoning\")\n assert not ad.has_capability(\"vision\"), \"agnes 默认不应有 vision\"\n print(\"[OK] has_capability default: agnes 无 vision\")\n\n\n# ─── has_capability:provider block 覆盖 ────────────────────────\n\ndef test_has_capability_block_override():\n \"\"\"显式 capabilities 列表完全替换默认表 — 不存在的能力会被去掉\"\"\"\n cfg_path = _write_temp_config({\n \"deepseek\": _block({\"capabilities\": [\"chat\", \"vision\"]}) # 加 vision,但移除 tools/reasoning\n }, \"deepseek\")\n ad = LLMClient(cfg_path)\n assert ad.has_capability(\"chat\")\n assert ad.has_capability(\"vision\"), \"显式声明应覆盖默认表加上 vision\"\n assert not ad.has_capability(\"tools\"), \"显式列表不含 tools → 应失去\"\n assert not ad.has_capability(\"reasoning\"), \"显式列表不含 reasoning → 应失去\"\n print(\"[OK] has_capability block override 完全替换\")\n\n\ndef test_has_capability_block_can_add_new_capability():\n \"\"\"通用接口 provider 可以在 config 里声明任意 capabilities\"\"\"\n cfg_path = _write_temp_config({\n \"通用接口\": _block({\"capabilities\": [\"chat\", \"tools\", \"vision\", \"speech\"]})\n }, \"通用接口\")\n ad = LLMClient(cfg_path)\n assert ad.has_capability(\"speech\"), \"未来扩展能力应能从 config 直接声明\"\n print(\"[OK] has_capability 支持任意未来 capability\")\n\n\n# ─── has_capability:未知 provider fallback ─────────────────────\n\ndef test_has_capability_unknown_provider_minimal_fallback():\n cfg_path = _write_temp_config({\"我自己的模型\": _block()}, \"我自己的模型\")\n ad = LLMClient(cfg_path)\n # _FALLBACK_CAPABILITIES = {\"chat\",\"tools\"}\n assert ad.has_capability(\"chat\")\n assert ad.has_capability(\"tools\")\n assert not ad.has_capability(\"vision\")\n assert not ad.has_capability(\"reasoning\")\n print(\"[OK] has_capability unknown provider → 最低 {chat, tools}\")\n\n\n# ─── pick_capable_adapter:三种路径 ────────────────────────────\n\ndef test_pick_capable_current_already_has():\n \"\"\"current 已具备 → 返回 same instance(is 关系,零开销)\"\"\"\n cfg_path = _write_temp_config({\n \"sensenova\": _block(),\n \"deepseek\": _block(),\n }, \"sensenova\")\n ad = LLMClient(cfg_path)\n result = pick_capable_adapter(ad, \"vision\")\n assert result is ad, \"current 已具备 vision,应直接返回 same instance(节省重新构造开销)\"\n print(\"[OK] pick_capable current 已具备 → is 返回\")\n\n\ndef test_pick_capable_fallback_to_other_provider():\n \"\"\"current 不具备 + 有 fallback → 返回新 LLMClient(provider 名为 fallback)\"\"\"\n cfg_path = _write_temp_config({\n \"deepseek\": _block(), # 无 vision\n \"sensenova\": _block(), # 有 vision\n }, \"deepseek\")\n ad = LLMClient(cfg_path)\n assert ad.get_provider() == \"deepseek\"\n result = pick_capable_adapter(ad, \"vision\")\n assert result is not None, \"应该能 fallback 到 sensenova\"\n assert result is not ad, \"fallback 应是新 client 实例\"\n assert result.get_provider() == \"sensenova\", f\"应路由到 sensenova,实际 {result.get_provider()}\"\n assert result.has_capability(\"vision\")\n print(\"[OK] pick_capable fallback: deepseek → sensenova\")\n\n\ndef test_pick_capable_no_match_returns_none():\n \"\"\"current 不具备 + 无 fallback → None(不抛错)\"\"\"\n cfg_path = _write_temp_config({\n \"deepseek\": _block(),\n \"agnes\": _block(),\n }, \"deepseek\")\n ad = LLMClient(cfg_path)\n result = pick_capable_adapter(ad, \"vision\")\n assert result is None, \"deepseek+agnes 都无 vision → 应返回 None\"\n print(\"[OK] pick_capable no match → None\")\n\n\ndef test_pick_capable_does_not_pollute_os_environ():\n \"\"\"关键反模式消除:不触碰 os.environ['LLM_PROVIDER'](替代旧 analyze_image_content.py:51-59)\"\"\"\n cfg_path = _write_temp_config({\n \"deepseek\": _block(),\n \"sensenova\": _block(),\n }, \"deepseek\")\n before = os.environ.get(\"LLM_PROVIDER\", \"<unset>\")\n ad = LLMClient(cfg_path)\n _ = pick_capable_adapter(ad, \"vision\")\n after = os.environ.get(\"LLM_PROVIDER\", \"<unset>\")\n assert before == after, f\"os.environ['LLM_PROVIDER'] 被污染: {before!r} → {after!r}\"\n print(\"[OK] pick_capable 不污染 os.environ['LLM_PROVIDER']\")\n\n\ndef test_pick_capable_skips_unconfigurable_candidate():\n \"\"\"候选 provider 缺 api_key → 静默跳过,试下一个能构造的。\n\n 用 _clean_env 隔离 OPENAI_API_KEY/LLM_API_KEY 等全局 env vars,\n 确保\"坏配置\"真正构造失败(否则可能从机器 env 抢到 key 误成功)。\n \"\"\"\n with _clean_env(\"OPENAI_API_KEY\", \"LLM_API_KEY\",\n \"DEEPSEEK_API_KEY\", \"SENSENOVA_API_KEY\", \"AGNES_API_KEY\"):\n cfg_path = _write_temp_config({\n \"deepseek\": _block(), # 无 vision\n \"坏配置\": {\"base_url\": \"http://x\", \"model\": \"m\", # 有 vision 显式但无 api_key\n \"capabilities\": [\"chat\", \"tools\", \"vision\"]},\n \"sensenova\": _block(), # 有 vision 且能构造\n }, \"deepseek\")\n ad = LLMClient(cfg_path)\n result = pick_capable_adapter(ad, \"vision\")\n assert result is not None\n assert result.get_provider() == \"sensenova\", f\"应跳过坏配置,落到 sensenova,实际 {result.get_provider()}\"\n print(\"[OK] pick_capable 静默跳过缺 api_key 的候选\")\n\n\ndef test_pick_capable_provider_override_works():\n \"\"\"LLMClient(config_path, provider_override=name) 路径正确选 provider\"\"\"\n cfg_path = _write_temp_config({\n \"deepseek\": _block(),\n \"sensenova\": _block(),\n }, \"deepseek\")\n # 显式 override 不应受 active provider 影响\n ad = LLMClient(cfg_path, provider_override=\"sensenova\")\n assert ad.get_provider() == \"sensenova\"\n print(\"[OK] LLMClient(provider_override=...) 工作\")\n\n\n# ─── analyze_image_content.py 静态改造检查 ───────────────────\n\ndef test_analyze_image_content_uses_pick_capable_adapter():\n \"\"\"static check: analyze_image_content.py 已迁移到 pick_capable_adapter\"\"\"\n src = ROOT / \"src\" / \"basic_tools\" / \"analyze_image_content.py\"\n text = src.read_text(encoding=\"utf-8\")\n assert \"pick_capable_adapter\" in text, \"应该 import 并使用 pick_capable_adapter\"\n assert 'os.environ[\"LLM_PROVIDER\"]' not in text, \"不应再 mutate os.environ['LLM_PROVIDER']\"\n assert 'os.environ.pop(\"LLM_PROVIDER\"' not in text\n assert '{\"sensenova\", \"openai\", \"gemini\"}' not in text, \"硬编码白名单应已删除\"\n print(\"[OK] analyze_image_content.py 完成 Step 2 改造(静态检查)\")\n\n\n# ─── 向后兼容:Step 1 测试仍应全绿(occluded 的兼容性) ──────────\n\ndef test_backward_compat_provider_override_default():\n \"\"\"不传 provider_override 时,行为应与 Step 1 完全一致\"\"\"\n cfg_path = _write_temp_config({\"sensenova\": _block()}, \"sensenova\")\n ad = LLMClient(cfg_path)\n assert ad.get_provider() == \"sensenova\"\n assert ad.has_capability(\"vision\")\n assert isinstance(ad.config_path, Path)\n print(\"[OK] 默认参数下行为与 Step 1 一致\")\n\n\nif __name__ == \"__main__\":\n test_has_capability_default_table_sensenova_has_vision()\n test_has_capability_default_table_deepseek_no_vision()\n test_has_capability_default_table_agnes_no_vision()\n test_has_capability_block_override()\n test_has_capability_block_can_add_new_capability()\n test_has_capability_unknown_provider_minimal_fallback()\n test_pick_capable_current_already_has()\n test_pick_capable_fallback_to_other_provider()\n test_pick_capable_no_match_returns_none()\n test_pick_capable_does_not_pollute_os_environ()\n test_pick_capable_skips_unconfigurable_candidate()\n test_pick_capable_provider_override_works()\n test_analyze_image_content_uses_pick_capable_adapter()\n test_backward_compat_provider_override_default()\n\n print()\n print(\"=\" * 60)\n print(\"✓ Step 2 全部 14 个测试通过 — capability 配置化 + pick_capable_adapter 落地\")\n print(\"=\" * 60)\n"}}}}