1 line
121 KiB
JSON
1 line
121 KiB
JSON
{"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"}}}} |