Skip to content

Commit 1c7f808

Browse files
committed
feat(ai): 精修输出预算化——档位缩放引导+max_tokens 收敛(REQ-290②)
1 parent c0c6eff commit 1c7f808

3 files changed

Lines changed: 105 additions & 2 deletions

File tree

‎app/src-tauri/src/ai_note_refine.rs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,22 @@ impl AiNoteRefineAdapter {
170170
dims: Option<&ResolvedDims>,
171171
) -> Result<AiRefineResponse, AiClientError> {
172172
let system = self.prompt.build_system(&request.profile, dims);
173+
// REQ-290 ②(v0.19.7):逐片输出预算——档位缩放引导字数 + max_tokens
174+
// 收敛(clone 客户端仅改上限,≤ 原 20000;guidance 段为逐片动态值,
175+
// 不含在静态预览 system 内——refine_budget 注释如实声明)
176+
let budget = crate::refine_budget::output_budget(
177+
dims.map(|d| d.preset_id.as_str()).unwrap_or("standard"),
178+
request.content.chars().count(),
179+
);
180+
let system = format!("{}\n\n{}", system, crate::refine_budget::guidance_suffix(&budget));
181+
let mut client = self.client.clone();
182+
client.config.max_tokens = budget.max_tokens;
173183
let user = serde_json::to_string(request)
174184
.map_err(|e| AiClientError::Parse(format!("精修请求序列化失败: {}", e)))?;
175185
let v = if images.is_empty() {
176-
self.client.chat_json(&system, &user)?
186+
client.chat_json(&system, &user)?
177187
} else {
178-
let raw = self.client.chat_vision(&system, &user, images)?;
188+
let raw = client.chat_vision(&system, &user, images)?;
179189
parse_json_object(&raw)?
180190
};
181191
let mut resp: AiRefineResponse = serde_json::from_value(v)

‎app/src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ mod practice_detect;
366366
mod quality_report;
367367
mod purify_config;
368368
mod refine;
369+
// REQ-290 ②(v0.19.7):精修输出预算化(档位缩放纯函数)
370+
mod refine_budget;
369371
// v0.11.2:间隔重复调度器(FSRS-6;弹性承诺无 streak,ADR-018)
370372
mod scheduler;
371373
// v0.11.3:组结算纯函数(阈值/周期双触发 + 重复合并判据)

‎app/src-tauri/src/refine_budget.rs‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//! 精修输出预算化(REQ-290 ②,v0.19.7)——档位缩放纯函数。
2+
//!
3+
//! @ai-context: 精修"1 分钟+"的物理大头=单片非流式生成(DeepSeek 长输出实测
4+
//! ~110s@20k token,ai_client 注释证据)。原单请求 max_tokens=20000
5+
//! 只为最坏档(≤8000 字输入 → 结构化输出最坏 ~15k token)服务,
6+
//! 常态生成远短——收紧上限 + 提示词引导字数可显著减少长尾空转。
7+
//! 预算只做上限收窄(≤ 原 20000),不改变档位语义与协议校验。
8+
9+
/// 输出预算(按档位从切片输入字符换算;见 output_budget)。
10+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+
pub struct OutputBudget {
12+
/// 单请求 max_tokens(后端硬顶,防长尾空转;≤ 原 20000 上限)
13+
pub max_tokens: u32,
14+
/// 提示词引导字数(中文正文量级——含列表/表格原文长度)
15+
pub guidance_chars: usize,
16+
}
17+
18+
/// 档位输出缩放(相对切片输入长度)与 token 上限。
19+
///
20+
/// @ai-context 依据(2026-08-21 真机 + ai_client 注释证据):单片 ≤8000 字输入、
21+
/// 结构化输出最坏 ~15k token——20k 上限只为最坏档服务,常态生成
22+
/// 远短,模型长尾空转吃掉大量"1 分钟+"。按档位收紧:忠实/标准
23+
/// 保持高上限(防截断毁 JSON 校验),极简提取激进收敛。
24+
fn budget_params(preset_id: &str) -> (f64, u32) {
25+
match preset_id {
26+
// faithful=忠实整理(输出≈输入长度)· deep=深度改写(转述+导语≈输入)
27+
"faithful" => (1.0, 16_000),
28+
"standard" | "" => (0.9, 16_000),
29+
"deep" => (0.95, 16_000),
30+
"minimal" => (0.3, 9_000),
31+
// custom 无档位语义 → 按标准兜底(REQ-279 已声明后端兜底口径)
32+
_ => (0.9, 16_000),
33+
}
34+
}
35+
36+
/// 输出预算换算(纯函数可单测):
37+
/// guidance ≈ 输入字符 × 档位缩放(下限 200,防空输入给出 0 引导);
38+
/// max_tokens = guidance × 2 + 1500(中文 ~1.5–2 token/字 + JSON 包装余量),
39+
/// 下限 4000(结构化响应安全垫,防截断致 Parse 失败),按档位上限截断。
40+
pub fn output_budget(preset_id: &str, content_chars: usize) -> OutputBudget {
41+
let (scale, cap) = budget_params(preset_id);
42+
let guidance_chars = ((content_chars as f64 * scale).round() as usize).max(200);
43+
let tokens = ((guidance_chars as u64) * 2 + 1500).clamp(4_000, cap as u64);
44+
OutputBudget { max_tokens: tokens as u32, guidance_chars }
45+
}
46+
47+
/// 档位输出引导段(追加在 system 提示词尾部——所见即所发预览不含本段:
48+
/// 预算为逐片动态值,预览为静态系统文本,二者不承诺一致,注释如实)。
49+
pub fn guidance_suffix(budget: &OutputBudget) -> String {
50+
format!(
51+
"【输出长度】本片请把内容(含列表/表格原文)控制在约 {} 字以内:删除冗余表达,但保留全部事实、术语与原文结构;宁精炼不堆叠。",
52+
budget.guidance_chars
53+
)
54+
}
55+
56+
#[cfg(test)]
57+
mod tests {
58+
use super::*;
59+
60+
#[test]
61+
fn empty_content_still_gets_safety_floor() {
62+
let b = output_budget("standard", 0);
63+
assert_eq!(b.guidance_chars, 200);
64+
assert!(b.max_tokens >= 4000, "结构化响应安全垫下限");
65+
}
66+
67+
#[test]
68+
fn minimal_preset_converges_hardest() {
69+
// 4000 字输入:standard≈9000 token 上限 vs minimal 显著更紧
70+
let standard = output_budget("standard", 4000);
71+
let minimal = output_budget("minimal", 4000);
72+
assert!(standard.guidance_chars > minimal.guidance_chars);
73+
assert!(standard.max_tokens > minimal.max_tokens);
74+
assert_eq!(minimal.guidance_chars, 1200);
75+
}
76+
77+
#[test]
78+
fn caps_never_exceed_legacy_20000() {
79+
for preset in ["faithful", "standard", "deep", "minimal", "custom", "unknown-x"] {
80+
let b = output_budget(preset, 8000);
81+
assert!(b.max_tokens <= 20000, "preset={} tokens={}", preset, b.max_tokens);
82+
}
83+
}
84+
85+
#[test]
86+
fn guidance_suffix_contains_budget_chars() {
87+
let b = output_budget("minimal", 3000);
88+
let suffix = guidance_suffix(&b);
89+
assert!(suffix.contains(&b.guidance_chars.to_string()));
90+
}
91+
}

0 commit comments

Comments
 (0)