Compare commits
5
Commits
528a17f5fa
...
v0.3.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a215272a9 | ||
|
|
25238fc357 | ||
|
|
b7d0a7335f | ||
|
|
c5afa4b31e | ||
|
|
28ca43ccb2 |
@@ -186,29 +186,64 @@ pub use vector_store::VectorStore;
|
||||
|
||||
## 文档规范
|
||||
|
||||
### 方案规范 (docs/)
|
||||
### 文档编号规范(design/pdd/ + design/prd/)
|
||||
|
||||
**编号规则**:创建新方案前必须先通过 shell 命令确认当前实际最大编号(Unix: `ls docs/` / Windows: `dir docs\`),禁止使用上下文中缓存的编号,如遇冲突自动递增
|
||||
`design/pdd/`(方案文档)和 `design/prd/`(需求文档)使用相同的命名格式,但**各自独立编号**:
|
||||
|
||||
**方案文档结构**(6 项):
|
||||
1. **背景与目标** - 问题描述、预期目标
|
||||
2. **需求分析** - 功能需求、非功能需求
|
||||
3. **方案设计** - 架构设计、模块划分、接口定义
|
||||
4. **实现计划** - 任务拆解、优先级、时间估算
|
||||
5. **风险评估** - 潜在风险、缓解措施
|
||||
6. **验收标准** - 可验证的完成条件
|
||||
```
|
||||
<序号>-<简短描述>.md
|
||||
```
|
||||
|
||||
### 进度同步规范 (docs/roadmap.md)
|
||||
规则:
|
||||
- 序号使用数字,从 1 开始递增。**创建前必须通过 shell 命令确认目标目录当前实际最大编号再加 1:**
|
||||
```bash
|
||||
# 查 design/pdd/ 的最大编号
|
||||
ls design/pdd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
|
||||
# 查 design/prd/ 的最大编号
|
||||
ls design/prd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
|
||||
# 无输出则从 1 开始
|
||||
```
|
||||
禁止使用上下文中缓存的编号。
|
||||
- 描述:中文,简短概括主题
|
||||
- 两个目录各自独立编号——`design/pdd/` 已有 `3-` 时,`design/prd/` 的新文件仍从当前最大号 +1 开始,互不影响
|
||||
|
||||
完成一项实施后,必须检查 `docs/roadmap.md` 是否存在对应内容;若存在,必须同步标记为完成:
|
||||
方案文档(`design/pdd/`)应包含:
|
||||
- 背景与目标
|
||||
- 需求推演概要(需求拆解、边界识别、关键假设的简要推演)
|
||||
- 当前问题分析
|
||||
- 架构决策记录(重大技术选型、架构变更的决策过程与理由)
|
||||
- 设计方案(含架构图/流程图)
|
||||
- 实施步骤
|
||||
- 验证标准
|
||||
- 回滚方案(如适用)
|
||||
|
||||
- **Step / Phase 状态行**:对应 Step 加 ✅ 标记;Phase 章节末尾「状态」行从 ⏳ 改为 ✅ Phase X 全部交付物已完成
|
||||
- **里程碑表**:更新对应里程碑状态从 ⏳ 改为 ✅ + 完成日期
|
||||
- **依赖关系图(Mermaid)**:节点 `class` 从 `pending` / `core` 改为 `done`,必要时更新节点摘要
|
||||
- **文末「已完成 / 进行中阶段」列表**:追加一行 `- ✅ Phase X — 一句话要点`
|
||||
- **顶部「当前状态」**:补充新完成 Phase,更新「下一步」指向
|
||||
示例:
|
||||
- `design/pdd/1-ui-components重构方案.md`
|
||||
- `design/prd/1-用户认证需求.md`
|
||||
- `design/pdd/2-数据库迁移方案.md`
|
||||
|
||||
参考案例:2026-07-05 完成 Phase 7 SqliteStore 时同步更新 6 处(顶部状态 / Phase 章节 / 依赖图 / M3 / 下一步行动 / 已完成列表)。
|
||||
---
|
||||
|
||||
### 设计目录(design/)
|
||||
|
||||
项目根下的 `design/` 目录集中管理所有设计相关的文件,供人类和 agent 共同读写。
|
||||
|
||||
| 子目录 | 内容 | 谁写 | 谁读 |
|
||||
|--------|------|------|------|
|
||||
| `design/pdd/` | 方案设计文档(PDD)→ 架构方案、设计决策、转换方案 | proposal→writer pipeline | Think 参考、Build 实现、Vet 审查 |
|
||||
| `design/prd/` | 需求文档(PRD)→ 功能需求、用户故事、验收标准 | 人写 | Think 分析、Proposal 写方案时参考 |
|
||||
| `design/prototype/` | **OD 导出的原型 HTML** → 视觉稿、交互原型、页面 layout | OD 桌面版导出 | Think 分析结构、Build 对照实现 |
|
||||
| `design/notes/` | 笔记记录 → 零散想法、会议纪要、调研速记 | 人写 | 各 agent 参考 |
|
||||
| `design/roadmap/` | 路线图 → 里程碑规划、版本计划、优先级列表 | 人写 | Proposal 排期参考 |
|
||||
| `design/DESIGN.md` | 设计系统(品牌规范)→ 色板、字体、间距、语气 | OD 导出 / 人维护 | Think 提取 token、Build 同步到 `src/` |
|
||||
| `design/tokens.css` | 设计 Token CSS → 从 DESIGN.md 提取的 CSS 变量 | 人同步 / agent 同步 | 所有 Svelte 组件引用 |
|
||||
|
||||
**访问规则:**
|
||||
- 读:所有 agent 默认可读(`read_file` 不需要额外权限)
|
||||
- 写:writer agent 可通过 `"design/**": allow` 写入 `design/` 下任意子目录
|
||||
- 注意:`prototype/` 由 OD 桌面版导出,agent 只读不写;`DESIGN.md` 和 `tokens.css` 建议手动维护或 agent 写入时确认后再改
|
||||
|
||||
**兜底规则:** 文档类型不在上表时(如教程、接口文档、临时记录),或目标目录不存在时 → **向用户提问确认路径**。不允许自行推断存放位置。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agcore"
|
||||
version = "0.3.5"
|
||||
version = "0.3.7"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# PDD:Usage 字段反序列化容错方案
|
||||
|
||||
**状态**:Draft
|
||||
**作者**:Think Agent
|
||||
**日期**:2026-07-27
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
为 `agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 和 Chat Completions API 的 blocking 调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
|
||||
|
||||
### 触发场景
|
||||
|
||||
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
根因是 `Usage` 的三个 token 字段(`prompt_tokens`、`completion_tokens`、`total_tokens`)定义为必填 `u32`,而火山平台的 Responses API 返回的 `usage` 对象中这些子字段偶发缺失。
|
||||
|
||||
### 影响面
|
||||
|
||||
4/5 的 LLM 命令被阻断:
|
||||
|
||||
| 功能 | 命令 | 状态 |
|
||||
|------|------|------|
|
||||
| AI 探索(产品采集) | `llm_collect` → `workflow.rs` `.chat()` | ❌ 不可用 |
|
||||
| 普通聊天 | `llm_chat` → `llm.rs` `.chat()` | ❌ 不可用 |
|
||||
| 要素值正则化 | `llm_normalize_values` → `normalizer.rs` `.chat()` | ❌ 不可用 |
|
||||
| 数据验证 | `validator.rs` `.chat()` | ❌ 不可用 |
|
||||
| 连接验证 | `verify_llm_connection` → `chat_stream()` | ✅ 幸免(流式) |
|
||||
|
||||
## 2. 需求推演概要
|
||||
|
||||
### 需求拆解
|
||||
|
||||
- **核心需求**:blocking 路径下 `Usage` 反序列化不因缺失子字段而崩溃
|
||||
- **范围边界**:只改 serde 反序列化行为,不改字段类型,不改流式路径
|
||||
- **质量属性**:最小变更(±3 行)、零副作用、测试可验证
|
||||
|
||||
### 关键假设
|
||||
|
||||
| 假设 | 依据 | 验证方式 |
|
||||
|------|------|---------|
|
||||
| `Usage` 已 derive `Default` | 代码确认 | 编译通过 |
|
||||
| 缺失字段默认 0 对所有消费者安全 | `CostTracker::add` 使用 `saturating_add`;流式路径已有 `unwrap_or(0)` 行为 | 代码审查 |
|
||||
| `#[serde(default)]` 不影响序列化 | serde 明确语义 | 代码审查(serde 明确语义) |
|
||||
| `usage: null` 不会出现 | 当前未观察到,留 `ponytail:` 注释 | 生产观察 |
|
||||
| Chat Completions blocking 路径存在同源缺失风险 | 两路径共用火山平台底层 API 基础设施 | 在 §5.4 添加 `#[serde(default)]` 预防性加固(经 PRD 作者推演阶段确认) |
|
||||
|
||||
## 3. 当前问题分析
|
||||
|
||||
### 根因
|
||||
|
||||
- `src/llm/types/usage.rs:4-12`:`Usage` 结构体的 `prompt_tokens`、`completion_tokens`、`total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
|
||||
- `src/llm/provider/openai_response.rs:207`:`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
|
||||
- `src/llm/provider/openai.rs:245`:`OpenaiChatResponse` 内嵌 `usage: Usage`,同样必填
|
||||
- 火山平台 Responses API 返回的 `usage` 对象中,子字段缺失 → serde 反序列化 `missing field` 错误
|
||||
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
|
||||
|
||||
### 已知线索
|
||||
|
||||
- dc-management 的 `verify_llm_connection` 已预见到此问题,注释特意说明使用流式规避
|
||||
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
|
||||
|
||||
## 4. 架构决策记录
|
||||
|
||||
| 决策 | 选项 | 选择 | 理由 |
|
||||
|------|------|------|------|
|
||||
| 容错机制 | `#[serde(default)]` vs 改为 `Option<u32>` vs 自定义 `Deserialize` | `#[serde(default)]` | 最小变更,不改类型语义 |
|
||||
| 覆盖范围 | 仅 Responses API vs 同时覆盖 Chat Completions | 同时覆盖 | 增量成本≈0,防患于未然(经 PRD 作者推演阶段确认) |
|
||||
| 测试范围 | 2 个场景 vs 4 个场景 | 4 个场景 | 必要的边界覆盖(缺失 key / 缺失字段 / 空对象 / 完整回归) |
|
||||
|
||||
## 5. 设计方案
|
||||
|
||||
### 5.1 变更概览
|
||||
|
||||
**3 行 serde attribute + 4 个测试用例,零逻辑变更。**
|
||||
|
||||
### 5.2 变更一:`Usage` 结构体加 struct-level `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/types/usage.rs:3`
|
||||
|
||||
```diff
|
||||
- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[serde(default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:JSON 中缺失 `Usage` 的 **任何** 子字段时,自动使用 `Default::default()` 取值:
|
||||
- 三个 token 字段(`u32`):缺失时默认 `0`
|
||||
- 两个 `Option` 详情字段:缺失时默认 `None`
|
||||
|
||||
**为什么 struct-level 够用**:struct-level 对子字段统一生效,不需要为每个字段单独标注。
|
||||
|
||||
### 5.3 变更二:`OpenaiResponseBody.usage` 加 `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/provider/openai_response.rs:207`
|
||||
|
||||
```diff
|
||||
pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
+ #[serde(default)]
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`。
|
||||
|
||||
### 5.4 变更三:`OpenaiChatResponse.usage` 加 `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/provider/openai.rs:245`
|
||||
|
||||
```diff
|
||||
pub(crate) struct OpenaiChatResponse {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
+ #[serde(default)]
|
||||
pub usage: crate::llm::types::usage::Usage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_fingerprint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_tier: Option<ServiceTier>,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 自动受益路径(无需变更)
|
||||
|
||||
| 路径 | 字段 | 为何已安全 |
|
||||
|------|------|-----------|
|
||||
| `OpenaiChatChunk.usage` | `Option<Usage>` | `Option` 天然兜底 `None`;`Some({不全})` 被 `Usage` struct-level `#[serde(default)]` 兜住。注意:此路径仅用于流式反序列化,API 流式 last chunk 的 usage 通常完整,受益场景概率极低 |
|
||||
| `MessageResponse.usage` | `Usage`(IR 层) | IR 层不直接反序列化 JSON,只从 provider 传递已解析的值 |
|
||||
|
||||
### 5.6 未覆盖的已知边界
|
||||
|
||||
```rust
|
||||
// ponytail: #[serde(default)] handles missing key; null usage not observed from API,
|
||||
// but if it appears, add deserialize_with to map null → Usage::default()
|
||||
```
|
||||
|
||||
`"usage": null` 会导致 serde 尝试将 `null` 反序列化为 `Usage` 结构体,当前方案无法兜底。当前未观察到该行为,暂不处理。
|
||||
|
||||
### `total_tokens` 缺失时的语义不一致
|
||||
|
||||
当 API 返回 `{"prompt_tokens": 10, "completion_tokens": 5}` 但缺失 `total_tokens` 时,struct-level `#[serde(default)]` 使 `total_tokens = 0`,而非 `10 + 5 = 15`。`CostTracker::add` 使用 `saturating_add` 不会崩溃,但聚合统计中 `total_tokens` 可能不等于 `prompt_tokens + completion_tokens` 之和。此行为与流式 `PartialUsage::into_usage()` 的 `unwrap_or(0)` 一致,属于已知的简化取舍。
|
||||
|
||||
### Responses API 流式路径 usage 恒为零(pre-existing)
|
||||
|
||||
`ResponseSseMeta`(`openai_response.rs:335-340`)不包含 `usage` 字段,导致流式 `response.completed` 事件的 token 用量信息未被捕获,`MessageComplete.full_response.usage` 恒为零。这是一个独立于本次变更的 pre-existing 缺陷。阻塞路径加 `#[serde(default)]` 后,两条路径行为一致(皆为零值),不引入新差异。此问题建议作为后续独立跟踪项处理。
|
||||
|
||||
### 5.7 效应链路
|
||||
|
||||
```
|
||||
Usage struct-level #[serde(default)]
|
||||
├─ OpenaiResponseBody.usage: Usage ← 子字段缺失兜住
|
||||
│ └─ field-level #[serde(default)] ← 整个 key 缺失兜住
|
||||
├─ OpenaiChatResponse.usage: Usage ← 子字段缺失兜住(自动受益)
|
||||
│ └─ field-level #[serde(default)] ← 整个 key 缺失兜住(新增)
|
||||
└─ OpenaiChatChunk.usage: Option<Usage> ← 子字段缺失兜住(自动受益,无需改动)
|
||||
└─ Option 已有 skip_serializing_if ← None 时跳过
|
||||
```
|
||||
|
||||
## 6. 实施步骤
|
||||
|
||||
| 步骤 | 文件 | 操作 | 验证 |
|
||||
|------|------|------|------|
|
||||
| 1 | `src/llm/types/usage.rs:3` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 2 | `src/llm/provider/openai_response.rs:207` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 3 | `src/llm/provider/openai.rs:245` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 4 | `openai_response.rs` tests 模块 | 加 4 个测试用例 | `cargo test` |
|
||||
| 5 | 全量检查 | `cargo test && cargo clippy` | 无失败/新增警告 |
|
||||
|
||||
**预计时长**:30 分钟(含测试编写与验证)。
|
||||
|
||||
## 7. 验证标准
|
||||
|
||||
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
|
||||
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时不报错,默认 `Usage::default()`
|
||||
- [ ] `OpenaiChatResponse.usage` 键在 JSON 中完全缺失时不报错,默认 `Usage::default()`
|
||||
- [ ] 序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
|
||||
- [ ] `cargo test` 全部通过
|
||||
- [ ] `cargo clippy` 无新增警告
|
||||
- [ ] dc-management 项目 `llm_collect`(AI 探索)命令正常返回结果(agcore 发布后由 dc-management 侧执行)
|
||||
- [ ] dc-management 项目 `llm_chat`(普通聊天)命令正常响应(agcore 发布后由 dc-management 侧执行)
|
||||
- [ ] 序列化后反序列化 roundtrip 验证字段值不变
|
||||
- [ ] `CostTracker` 和 `session.usage()` 的聚合逻辑不依赖 `total_tokens == prompt_tokens + completion_tokens` 恒等式
|
||||
|
||||
### 测试清单
|
||||
|
||||
| 测试名 | 场景 | 输入 | 断言 |
|
||||
|--------|------|------|------|
|
||||
| `deserialize_missing_usage_fields` | usage 存在但缺子字段 | `"usage": {"prompt_tokens": 5}` | 缺省字段默认 0 |
|
||||
| `deserialize_missing_usage_key` | 整个 usage key 缺失 | 无 `usage` 字段 | `Usage::default()` |
|
||||
| `deserialize_empty_usage_object` | `usage: {}` | 空对象 | 全字段默认 0/None |
|
||||
| `deserialize_full_usage_with_details` | 完整 usage(含 details) | 含所有字段 | 正确解析,类型不变 |
|
||||
| `deserialize_usage_missing_prompt_tokens` | 精确复现报错场景:usage 存在但缺 `prompt_tokens` | `"usage": {"completion_tokens": 5, "total_tokens": 5}` | `prompt_tokens` 默认 0 |
|
||||
| `deserialize_response_body_missing_usage_key` | `OpenaiResponseBody` 上下文中 usage key 完全缺失 | 完整 `OpenaiResponseBody` JSON 无 `usage` | `usage == Usage::default()` |
|
||||
| `deserialize_chat_response_missing_usage_key` | `OpenaiChatResponse` 上下文中 usage key 完全缺失 | 完整 `OpenaiChatResponse` JSON 无 `usage` | `usage == Usage::default()` |
|
||||
| `deserialize_chat_response_missing_usage_fields` | `OpenaiChatResponse` 的 usage 存在但缺子字段 | 完整 `OpenaiChatResponse` JSON,`usage` 仅含 `prompt_tokens` | 缺省字段默认 0 |
|
||||
| `serialize_deserialize_roundtrip` | 序列化后反序列化,验证字段值不变 | 完整 `Usage` 结构体 | roundtrip 后字段值一致 |
|
||||
|
||||
## 8. 回滚方案
|
||||
|
||||
逐个 revert 三个文件中的 `#[serde(default)]` 行,删除对应的测试用例。回滚后功能恢复原状(无数据迁移、无配置变更)。
|
||||
|
||||
## 9. 非目标
|
||||
|
||||
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`)
|
||||
- 不修改流式路径(`PartialUsage` / `ResponseSseMeta` 的 usage 捕获问题单独处理)
|
||||
- 不修改其他 provider(`Anthropic` / `Ollama`,它们有自己的反序列化逻辑)
|
||||
- 不处理 `"usage": null` 边界(未观察到,留 `ponytail:` 注释)
|
||||
|
||||
## 10. 风险评估
|
||||
|
||||
| 风险 | 影响 | 可能性 | 应对方向 |
|
||||
|------|------|--------|---------|
|
||||
| `"usage": null` 反序列化失败 | 崩溃 | 低 | 未观察到;留 ponytail 注释 |
|
||||
| 零值 Usage 掩盖 API 异常 | 计费数据不全 | 低 | `CostTracker::add` 可加 `warn!` 日志 |
|
||||
| `prompt + completion ≠ total` | 聚合语义不一致 | 低 | `PartialUsage` 已有同样行为,接受 |
|
||||
| 下游依赖升级后反序列化行为变化 | 无 | 极低 | 类型不变、字段名不变 |
|
||||
|
||||
## 11. 术语表
|
||||
|
||||
| 术语 | 定义 | 说明 |
|
||||
|------|------|------|
|
||||
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 单次 HTTP 请求,完整 JSON 响应后一次性解析 |
|
||||
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
|
||||
| `#[serde(default)]` | serde 属性宏 | 反序列化时缺失字段使用类型的 `Default` 实现填充 |
|
||||
|
||||
## 12. 历史版本
|
||||
|
||||
| 版本 | 日期 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| v1 | 2026-07-27 | 初始版本,基于 PRD 1 推演 |
|
||||
@@ -0,0 +1,181 @@
|
||||
# PRD:Usage 字段反序列化容错
|
||||
|
||||
**状态**:Draft
|
||||
**作者**:proposal
|
||||
**日期**:2026-07-27
|
||||
|
||||
## 1. 核心目标
|
||||
|
||||
为 `agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 的 blocking(非流式)调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
|
||||
|
||||
## 2. 目标用户与场景
|
||||
|
||||
| 用户角色 | 使用场景 | 核心诉求 |
|
||||
|---------|---------|---------|
|
||||
| dc-management 使用者 | AI 探索(产品信息采集) | 点击「AI 探索」后能正常返回结果,不因 usage 缺失而报错 |
|
||||
| dc-management 使用者 | LLM 聊天 | 普通聊天功能正常响应 |
|
||||
| dc-management 使用者 | 要素值正则化(Phase 2) | 数据归一化流程不因 usage 解析失败而中断 |
|
||||
| agcore 下游 crate | 任何使用 `agcore::llm` 且后端可能不返回完整 usage 的项目 | 反序列化鲁棒性提升 |
|
||||
|
||||
## 3. 问题描述
|
||||
|
||||
### 3.1 报错信息
|
||||
|
||||
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
### 3.2 影响面
|
||||
|
||||
经排查,以下功能被阻断(4/5 的 LLM 命令受影响):
|
||||
|
||||
| 功能 | 命令 | 状态 |
|
||||
|------|------|------|
|
||||
| AI 探索(产品采集) | `llm_collect` → `workflow.rs` `.chat()` | ❌ 不可用 |
|
||||
| 普通聊天 | `llm_chat` → `llm.rs` `.chat()` | ❌ 不可用 |
|
||||
| 要素值正则化 | `llm_normalize_values` → `normalizer.rs` `.chat()` | ❌ 不可用 |
|
||||
| 数据验证 | `validator.rs` `.chat()` | ❌ 不可用 |
|
||||
| 连接验证 | `verify_llm_connection` → `chat_stream()` | ✅ 幸免(流式) |
|
||||
|
||||
### 3.3 根因
|
||||
|
||||
- **`llm/types/usage.rs:4-12`**:`Usage` 结构体的 `prompt_tokens`、`completion_tokens`、`total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
|
||||
- **`llm/provider/openai_response.rs:207`**:`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
|
||||
- 火山平台的 Responses API 返回的 `usage` 对象中,三个必填 token 子字段**缺失**(`usage` 键存在但子字段不完整),触发了 serde 反序列化的 `missing field` 错误
|
||||
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
|
||||
|
||||
### 3.4 已知线索
|
||||
|
||||
- dc-management 的 `verify_llm_connection`(连接验证函数)**已预见到此问题**,注释明确说明特意使用流式来规避(`llm.rs:368-371`)
|
||||
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
|
||||
|
||||
## 4. 功能清单
|
||||
|
||||
### v1 必做
|
||||
|
||||
- **`Usage` 结构体加 struct-level `#[serde(default)]`**(`src/llm/types/usage.rs:3`):
|
||||
```diff
|
||||
- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[serde(default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
- 作用:JSON 中缺失 `Usage` 的**任何**子字段时,自动使用 `Default::default()` 取值
|
||||
- 三个 token 字段:缺失时默认 `0`
|
||||
- 两个 `Option` 详情字段(`completion_tokens_details` / `prompt_tokens_details`):缺失时默认 `None`
|
||||
- **类型全部保持不动**,不改为 `Option`,不丢失语义
|
||||
- 等效代码行数:1 行
|
||||
|
||||
- **`OpenaiResponseBody.usage` 加 `#[serde(default)]`**(`src/llm/provider/openai_response.rs:207`):
|
||||
```diff
|
||||
pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
+ #[serde(default)]
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
```
|
||||
- 作用:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`
|
||||
- 等效代码行数:1 行
|
||||
|
||||
**变更合计:2 行 serde 属性宏,零逻辑变更,零类型变更。**
|
||||
|
||||
### v1 可选
|
||||
|
||||
- 无(上述两项即可完整修复)
|
||||
|
||||
### v2 考虑
|
||||
|
||||
- 无——struct-level `#[serde(default)]` 已覆盖所有子字段,`completion_tokens_details` / `prompt_tokens_details` 的 `#[serde(default)]` 需求已在 v1 中一并解决
|
||||
- 流式路径的 `ResponseSseMeta` 不包含 `usage` 字段(`src/llm/provider/openai_response.rs:335-340`),`response.completed` 事件的 usage 信息未被捕获。这是一个独立的缺陷,与当前问题无关,建议单独处理
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`)
|
||||
- 不修改流式路径(`PartialUsage` 的 usage 捕获问题单独处理)
|
||||
- 不修改其他 provider(`OpenaiChat` / `Anthropic`),它们有自己的反序列化逻辑
|
||||
|
||||
## 5. 边界与假设
|
||||
|
||||
| 边界 / 假设 | 来源 | 说明 |
|
||||
|-------------|------|------|
|
||||
| `Usage` 已 derive `Default` | 代码确认 | `#[serde(default)]` 直接使用 `Default` 实现(全字段 `0`) |
|
||||
| 缺失字段默认 0 对所有消费者安全 | 代码审查 | 所有消费方(`CostTracker::add`、`MessageResponse` 使用处)都在做 `saturating_add`,`0` 是安全值。流式路径的 `PartialUsage.into_usage()` 已经 `unwrap_or(0)`,行为一致 |
|
||||
| 序列化行为不受影响 | serde 语义 | `#[serde(default)]` 仅在反序列化缺失字段时生效,不影响序列化输出 |
|
||||
| 报错提示的 `missing field` 确认是子字段缺失而非整个 usage 缺失 | 错误消息分析 | 错误消息 `missing field 'prompt_tokens'` 说明 JSON 路径 `usage.prompt_tokens` 不存在即键 `usage` 存在但子字段缺失,因此 `#[serde(default)]` 加在子字段上是必要条件 |
|
||||
|
||||
## 6. 术语表
|
||||
|
||||
| 术语 | 定义 | 说明 |
|
||||
|------|------|------|
|
||||
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 发送单个 HTTP 请求,等待完整 JSON 响应后一次性解析 |
|
||||
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
|
||||
| `#[serde(default)]` | serde 属性宏 | 反序列化时如果字段缺失,使用类型的 `Default` 实现填充 |
|
||||
| Responses API | OpenAI 标准 `POST /responses` 协议 | 区别于 Chat Completions(`POST /chat/completions`) |
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
|
||||
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时也不报错,默认值为 `Usage::default()`
|
||||
- [ ] dc-management 的 `llm_collect`(AI 探索)命令正常返回结果
|
||||
- [ ] dc-management 的 `llm_chat`(普通聊天)命令正常响应
|
||||
- [ ] 已有序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
|
||||
- [ ] `cargo test` 全部通过
|
||||
- [ ] `cargo clippy` 无新增警告
|
||||
- [ ] 补两条反序列化测试:
|
||||
- `response_api_missing_usage_fields` — mock 响应中 `usage` 对象缺失 `prompt_tokens` / `completion_tokens` / `total_tokens`,验证不报错且 usage 字段全为 0
|
||||
- `response_api_missing_usage_key` — mock 响应中完全没有 `usage` 键,验证不报错且默认 `Usage::default()`
|
||||
|
||||
## 8. 风险评估
|
||||
|
||||
| 风险 | 影响 | 可能性 | 应对方向 |
|
||||
|------|------|--------|---------|
|
||||
| 下游 crate 升级 agcore 后依赖 usage 字段不缺失 | 无——`#[serde(default)]` 不改变已有行为 | 极低 | 类型不变、字段名不变 |
|
||||
| 序列化时 `#[serde(default)]` 影响输出 | 无——`#[serde(default)]` 只影响反序列化 | 极低 | serde 明确语义 |
|
||||
| 测试覆盖不足 | 当前 mock 响应均携带完整 usage,不会触发新路径 | 中 | 建议加一条 usage 缺失的测试用例 |
|
||||
|
||||
## 9. 发布计划(可选)
|
||||
|
||||
| 阶段 | 范围 | 时间 |
|
||||
|------|------|------|
|
||||
| v1 | 两处变更(usage.rs 3 字段 + openai_response.rs 1 字段) | 即日 |
|
||||
| 发布 | 打 tag(如 `v0.3.6` 或 `v0.3.5-usage-fix`),更新 dc-management 引用 | 即日 |
|
||||
|
||||
## 10. 历史版本
|
||||
|
||||
| 版本 | 日期 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| v1 | 2026-07-27 | 人工种子(原始) |
|
||||
|
||||
### 种子内容
|
||||
|
||||
发起方:dc-management 项目 `src/routes/products/ai-explore/` 页面「AI 探索」功能报错
|
||||
|
||||
报错信息:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
根因:
|
||||
|
||||
- `src/llm/types/usage.rs` 中 `Usage` 结构的 `prompt_tokens`, `completion_tokens`, `total_tokens` 为必填 `u32`
|
||||
- `src/llm/provider/openai_response.rs` 的 `OpenaiResponseBody` 内嵌 `usage: Usage`
|
||||
- 火山平台 Responses API 返回的 usage 子字段缺失 → serde 反序列化失败
|
||||
|
||||
修复方向:
|
||||
|
||||
- `Usage` 三字段加 `#[serde(default)]`(缺失时默认 0)
|
||||
- `OpenaiResponseBody.usage` 加 `#[serde(default)]`(整个缺失时默认 Usage::default())
|
||||
@@ -242,6 +242,7 @@ pub(crate) struct OpenaiChatResponse {
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
#[serde(default)]
|
||||
pub usage: crate::llm::types::usage::Usage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_fingerprint: Option<String>,
|
||||
@@ -1968,4 +1969,49 @@ data: [DONE]\n\n";
|
||||
"非法 header 名应被静默跳过"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== OpenaiChatResponse Usage 容错(v0.3.6 引入)=====
|
||||
|
||||
/// C1: Chat Completions 响应中 usage 键完全缺失 —— 默认 Usage::default()。
|
||||
#[test]
|
||||
fn deserialize_chat_response_missing_usage_key() {
|
||||
let body: OpenaiChatResponse = serde_json::from_value(json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1718000000,
|
||||
"model": "gpt-4o",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hi"},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 0);
|
||||
assert_eq!(body.usage.completion_tokens, 0);
|
||||
assert_eq!(body.usage.total_tokens, 0);
|
||||
assert!(body.usage.completion_tokens_details.is_none());
|
||||
assert!(body.usage.prompt_tokens_details.is_none());
|
||||
}
|
||||
|
||||
/// C2: Chat Completions 响应中 usage 存在但缺子字段 —— 缺省字段默认 0。
|
||||
#[test]
|
||||
fn deserialize_chat_response_missing_usage_fields() {
|
||||
let body: OpenaiChatResponse = serde_json::from_value(json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1718000000,
|
||||
"model": "gpt-4o",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hi"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 8}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 8);
|
||||
assert_eq!(body.usage.completion_tokens, 0);
|
||||
assert_eq!(body.usage.total_tokens, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
#[serde(default)]
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
@@ -2509,4 +2510,92 @@ data: {\"type\":\"response.failed\",\"error\":{\"message\":\"server failed mid-s
|
||||
let wire = serde_json::to_value(&tool).unwrap();
|
||||
assert_eq!(wire, original);
|
||||
}
|
||||
|
||||
// ===== Usage 字段容错(v0.3.6 引入)=====
|
||||
|
||||
/// U1: usage 子字段缺失 —— 缺失字段默认 0/None,结构体反序列化不报错。
|
||||
#[test]
|
||||
fn deserialize_missing_usage_fields() {
|
||||
let body: OpenaiResponseBody = serde_json::from_value(json!({
|
||||
"id": "r_1",
|
||||
"model": "gpt-4o",
|
||||
"output": [],
|
||||
"usage": {"prompt_tokens": 5},
|
||||
"status": "completed"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 5);
|
||||
assert_eq!(body.usage.completion_tokens, 0);
|
||||
assert_eq!(body.usage.total_tokens, 0);
|
||||
assert!(body.usage.completion_tokens_details.is_none());
|
||||
assert!(body.usage.prompt_tokens_details.is_none());
|
||||
}
|
||||
|
||||
/// U2: usage 键完全缺失 —— OpenaiResponseBody.usage 默认 Usage::default()。
|
||||
#[test]
|
||||
fn deserialize_missing_usage_key() {
|
||||
let body: OpenaiResponseBody = serde_json::from_value(json!({
|
||||
"id": "r_1",
|
||||
"model": "gpt-4o",
|
||||
"output": [],
|
||||
"status": "completed"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 0);
|
||||
assert_eq!(body.usage.completion_tokens, 0);
|
||||
assert_eq!(body.usage.total_tokens, 0);
|
||||
assert!(body.usage.completion_tokens_details.is_none());
|
||||
assert!(body.usage.prompt_tokens_details.is_none());
|
||||
}
|
||||
|
||||
/// U3: usage 为空对象 —— 全字段走 Default,无 None panic。
|
||||
#[test]
|
||||
fn deserialize_empty_usage_object() {
|
||||
let body: OpenaiResponseBody = serde_json::from_value(json!({
|
||||
"id": "r_1",
|
||||
"model": "gpt-4o",
|
||||
"output": [],
|
||||
"usage": {},
|
||||
"status": "completed"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 0);
|
||||
assert_eq!(body.usage.completion_tokens, 0);
|
||||
assert_eq!(body.usage.total_tokens, 0);
|
||||
}
|
||||
|
||||
/// U4: 完整 usage(含 details)回归 —— 确保 #[serde(default)] 不破坏正常路径。
|
||||
#[test]
|
||||
fn deserialize_full_usage_with_details() {
|
||||
let body: OpenaiResponseBody = serde_json::from_value(json!({
|
||||
"id": "r_1",
|
||||
"model": "gpt-4o",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
"completion_tokens_details": {"reasoning_tokens": 5}
|
||||
},
|
||||
"status": "completed"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 10);
|
||||
assert_eq!(body.usage.completion_tokens, 20);
|
||||
assert_eq!(body.usage.total_tokens, 30);
|
||||
let details = body.usage.completion_tokens_details.unwrap();
|
||||
assert_eq!(details.reasoning_tokens, Some(5));
|
||||
}
|
||||
|
||||
/// U5: OpenaiResponseBody 上下文中 usage key 完全缺失 —— 与 U2 同场景但明确命名。
|
||||
#[test]
|
||||
fn deserialize_response_body_missing_usage_key() {
|
||||
let json_text = r#"{"id":"r_1","model":"gpt-4o","output":[],"status":"completed"}"#;
|
||||
let body: OpenaiResponseBody = serde_json::from_str(json_text).unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 0);
|
||||
assert_eq!(body.usage.completion_tokens, 0);
|
||||
assert_eq!(body.usage.total_tokens, 0);
|
||||
assert!(body.usage.completion_tokens_details.is_none());
|
||||
assert!(body.usage.prompt_tokens_details.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
@@ -79,3 +80,33 @@ impl Usage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn deserialize_usage_missing_prompt_tokens() {
|
||||
let value = json!({
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 5
|
||||
});
|
||||
let usage: Usage = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(usage.prompt_tokens, 0);
|
||||
assert_eq!(usage.completion_tokens, 5);
|
||||
assert_eq!(usage.total_tokens, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_deserialize_roundtrip() {
|
||||
let original = Usage::from_input_output(10, 20);
|
||||
let serialized = serde_json::to_value(original).unwrap();
|
||||
let deserialized: Usage = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized.prompt_tokens, original.prompt_tokens);
|
||||
assert_eq!(deserialized.completion_tokens, original.completion_tokens);
|
||||
assert_eq!(deserialized.total_tokens, original.total_tokens);
|
||||
assert!(deserialized.completion_tokens_details.is_none());
|
||||
assert!(deserialized.prompt_tokens_details.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+226
-18
@@ -220,7 +220,7 @@ fn compile_fragments(template: &str) -> Result<Vec<Fragment>, PromptError> {
|
||||
fragments.push(Fragment::Literal(literal.clone()));
|
||||
literal.clear();
|
||||
}
|
||||
let (tag_content, end) = parse_tag(bytes, i)?;
|
||||
let (tag_content, end) = parse_tag(template, i)?;
|
||||
i = end;
|
||||
|
||||
let tag = tag_content.trim();
|
||||
@@ -252,8 +252,16 @@ fn compile_fragments(template: &str) -> Result<Vec<Fragment>, PromptError> {
|
||||
fragments.push(Fragment::Variable { name });
|
||||
}
|
||||
} else {
|
||||
literal.push(bytes[i] as char);
|
||||
i += 1;
|
||||
debug_assert!(template.is_char_boundary(i));
|
||||
match template[i..].chars().next() {
|
||||
Some(ch) => {
|
||||
literal.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
None => {
|
||||
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +272,8 @@ fn compile_fragments(template: &str) -> Result<Vec<Fragment>, PromptError> {
|
||||
Ok(fragments)
|
||||
}
|
||||
|
||||
fn parse_tag(bytes: &[u8], start: usize) -> Result<(String, usize), PromptError> {
|
||||
fn parse_tag(template: &str, start: usize) -> Result<(String, usize), PromptError> {
|
||||
let bytes = template.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = start + 2;
|
||||
let mut content = String::new();
|
||||
@@ -272,8 +281,16 @@ fn parse_tag(bytes: &[u8], start: usize) -> Result<(String, usize), PromptError>
|
||||
if bytes[i] == b'}' && i + 1 < len && bytes[i + 1] == b'}' {
|
||||
return Ok((content, i + 2));
|
||||
}
|
||||
content.push(bytes[i] as char);
|
||||
i += 1;
|
||||
debug_assert!(template.is_char_boundary(i));
|
||||
match template[i..].chars().next() {
|
||||
Some(ch) => {
|
||||
content.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
None => {
|
||||
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(PromptError::Parse("未闭合的 {{ 标签".to_string()))
|
||||
}
|
||||
@@ -293,7 +310,7 @@ fn parse_block(
|
||||
|
||||
while i < len && depth > 0 {
|
||||
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
||||
let (tag, end) = parse_tag(bytes, i)?;
|
||||
let (tag, end) = parse_tag(template, i)?;
|
||||
let tag = tag.trim().to_string();
|
||||
if tag == format!("/{kind}") {
|
||||
depth -= 1;
|
||||
@@ -320,12 +337,20 @@ fn parse_block(
|
||||
i = end;
|
||||
}
|
||||
} else {
|
||||
if is_else {
|
||||
else_body.push(bytes[i] as char);
|
||||
} else {
|
||||
body.push(bytes[i] as char);
|
||||
debug_assert!(template.is_char_boundary(i));
|
||||
match template[i..].chars().next() {
|
||||
Some(ch) => {
|
||||
if is_else {
|
||||
else_body.push(ch);
|
||||
} else {
|
||||
body.push(ch);
|
||||
}
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
None => {
|
||||
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +366,7 @@ fn parse_each_block(template: &str, start: usize) -> Result<(Vec<Fragment>, usiz
|
||||
|
||||
while i < len && depth > 0 {
|
||||
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
||||
let (tag, end) = parse_tag(bytes, i)?;
|
||||
let (tag, end) = parse_tag(template, i)?;
|
||||
let tag = tag.trim().to_string();
|
||||
if tag == "/each" {
|
||||
depth -= 1;
|
||||
@@ -361,8 +386,16 @@ fn parse_each_block(template: &str, start: usize) -> Result<(Vec<Fragment>, usiz
|
||||
i = end;
|
||||
}
|
||||
} else {
|
||||
body.push(bytes[i] as char);
|
||||
i += 1;
|
||||
debug_assert!(template.is_char_boundary(i));
|
||||
match template[i..].chars().next() {
|
||||
Some(ch) => {
|
||||
body.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
None => {
|
||||
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +410,7 @@ fn parse_raw_block(template: &str, start: usize) -> Result<(String, usize), Prom
|
||||
|
||||
while i < len {
|
||||
if bytes[i] == b'{' && i + 1 < len && bytes[i + 1] == b'{' {
|
||||
let (tag, end) = parse_tag(bytes, i)?;
|
||||
let (tag, end) = parse_tag(template, i)?;
|
||||
let tag = tag.trim().to_string();
|
||||
if tag == "/raw" {
|
||||
return Ok((content, end));
|
||||
@@ -386,8 +419,16 @@ fn parse_raw_block(template: &str, start: usize) -> Result<(String, usize), Prom
|
||||
i = end;
|
||||
}
|
||||
} else {
|
||||
content.push(bytes[i] as char);
|
||||
i += 1;
|
||||
debug_assert!(template.is_char_boundary(i));
|
||||
match template[i..].chars().next() {
|
||||
Some(ch) => {
|
||||
content.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
None => {
|
||||
return Err(PromptError::Parse("模板包含非法字符序列".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,3 +569,170 @@ impl PromptTemplateRegistry {
|
||||
tpl.render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn preserves_chinese_literal() -> Result<(), PromptError> {
|
||||
let source = "这是一个纯中文模板。";
|
||||
let template = PromptTemplate::compile(source)?;
|
||||
|
||||
assert_eq!(template.render(&TemplateContext::new())?, source);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_real_world_failure_text() -> Result<(), PromptError> {
|
||||
let source = "你是采集策略专家,负责审查已采集的产品编码结果,决策下一轮搜索方向。★ 下一步→「严格校验」,使用全角标点:,;!";
|
||||
let template = PromptTemplate::compile(source)?;
|
||||
|
||||
assert_eq!(template.render(&TemplateContext::new())?, source);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_chinese_with_variable() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("你好,{{ name }}!")?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("name", "小明");
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "你好,小明!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_chinese_in_if_body() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("{{#if enabled}}已启用{{/if}}")?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("enabled", true);
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "已启用");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_chinese_in_else_body() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("{{#if enabled}}已启用{{else}}未启用{{/if}}")?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("enabled", false);
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "未启用");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_chinese_in_each_body() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("{{#each items}}项目:{{item}};{{/each}}")?;
|
||||
let ctx = TemplateContext::from_json(&json!({"items": ["甲", "乙"]}))?;
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "项目:甲;项目:乙;");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_chinese_in_raw_body() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("{{#raw}}原始中文:{{name}}{{/raw}}")?;
|
||||
|
||||
assert_eq!(
|
||||
template.render(&TemplateContext::new())?,
|
||||
"原始中文:{{name}}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_four_byte_characters() -> Result<(), PromptError> {
|
||||
let source = "你好👋🌍";
|
||||
let template = PromptTemplate::compile(source)?;
|
||||
|
||||
assert_eq!(template.render(&TemplateContext::new())?, source);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multibyte_characters_next_to_tag_boundaries() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("前{{name}}后")?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("name", "中");
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "前中后");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_chinese_at_end_of_template() -> Result<(), PromptError> {
|
||||
let source = "template ends with 中文";
|
||||
let template = PromptTemplate::compile(source)?;
|
||||
|
||||
assert_eq!(template.render(&TemplateContext::new())?, source);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_empty_template() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("")?;
|
||||
|
||||
assert_eq!(template.render(&TemplateContext::new())?, "");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unclosed_tag_after_chinese_without_panicking() {
|
||||
assert!(matches!(
|
||||
PromptTemplate::compile("中文{{未闭合"),
|
||||
Err(PromptError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_ascii_template_behavior() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile(
|
||||
"Hello {{name}}! {{#if active}}Active{{else}}Inactive{{/if}} {{#each items}}[{{item}}]{{/each}} {{#raw}}{{raw}}{{/raw}}",
|
||||
)?;
|
||||
let ctx = TemplateContext::from_json(&json!({
|
||||
"name": "Alice",
|
||||
"active": true,
|
||||
"items": ["a", "b"]
|
||||
}))?;
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "Hello Alice! Active [a][b] {{raw}}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_chinese_variable_name() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("{{ 问候 }},世界!")?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("问候", "你好");
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "你好,世界!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_chinese_if_condition() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile("{{#if 已启用}}条件成立{{/if}}")?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("已启用", true);
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "条件成立");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_chinese_in_nested_if_and_each_blocks() -> Result<(), PromptError> {
|
||||
let template = PromptTemplate::compile(
|
||||
"{{#if 已启用}}列表:{{#each 项目}}【{{item}}】{{/each}}{{/if}}",
|
||||
)?;
|
||||
let ctx = TemplateContext::from_json(&json!({
|
||||
"已启用": true,
|
||||
"项目": ["甲", "乙"]
|
||||
}))?;
|
||||
|
||||
assert_eq!(template.render(&ctx)?, "列表:【甲】【乙】");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user