Skip to content

Commit fcd09c4

Browse files
committed
feat(ai): v0.8.0 M2 会话→笔记 AI 精修——双模式/diff预览/异步任务/成本确认 (REQ-141/145+143基础版)
1 parent 12c5a13 commit fcd09c4

24 files changed

Lines changed: 1917 additions & 29 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,20 @@
33
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 与 [SemVer](https://semver.org/lang/zh-CN/)。
44
各版本的深度版本文档见 [docs/versions/](docs/versions/)。
55

6-
## [0.8.0] - 2026-08(AI 接入,M1 开发中)
6+
## [0.8.0] - 2026-08(AI 接入,M1/M2 开发中)
7+
8+
### 新增(M2 会话→笔记 AI 精修,REQ-141/145 + REQ-143 基础版)
9+
10+
- **AI 精修双模式(REQ-141)**:笔记预览视图新增「✨ AI 精修」入口——规则草稿(单一管线三出口:预览/落库/精修同基线,`build_rule_draft` 提取)→ 切片 → 云端结构化精修(`AiRefineResponse`:sections/blocks 五类型,schema 强校验,非法响应丢弃回退)→ **diff 预览**(本地规则版为基线,新增绿/删除红高亮)→ 采纳落库/放弃
11+
- **档案分组提示词**:`prompts/note_refine.json`——网课=讲义式/实操=步骤式/口播=摘要式/访谈=问答式/会议=纪要式 + 扩展类(直播/白板/题目/跟练/编程)回退讲义式;核心指令"精修=整理不创作(不增补课程外事实)"
12+
- **异步任务化(REQ-145)**:`ai_task.rs` 状态机(Pending/Running(按片进度)/Succeeded/Failed(原因四类))+ `slice_note` 切片纯函数(≤8000 字/片、章节边界优先、CJK 字符级硬切防 panic);任务注册表 + "ai:task-update" 事件 + 轮询双通道;失败原因四类引导(未授权→配置密钥/网络→重试/余额→充值/配额→明日再试)+ 重试入口
13+
- **成本确认基础版(REQ-143)**:`ai_cost.rs` token 估算(字符数保守上界)+ 单价表可配(env `SILICONFLOW_PRICE_PER_1M_TOKENS`,默认免费档 ¥0)+ 确认面板(token/费用/内联余额 ai_get_balance 复用 + "记住此选择"持久化)
14+
- **段级 diff 内核**:`note_diff.rs`(行级 LCS,未变/新增/删除三态)——M4 版本对比共用
15+
- mock 适配器扩展(`AiMockAdapter::refine`)+ `AI_REFINE_MOCK=1` 离线开发路径
16+
17+
### 测试
18+
19+
- 单测 +32(全量 1236 通过):精修协议 validate 强校验/to_markdown 五类型渲染 · 切片边界/章节优先/CJK 不 panic/失败映射 · 成本估算/单价 env 竞态锁 · diff LCS/重组/规模守卫 · 提示词模板解析/档案风格回退
720

821
### 新增(M1 AI 使能层,REQ-138/139/140)
922

@@ -18,7 +31,6 @@
1831
- 单测 +20(ai_settings 默认关/partial JSON/损坏回退/roundtrip/双门控边界 · ai_credentials 内存桩 roundtrip/清除/空密钥拒绝 · ai_balance 解析容错/低余额边界 · ai_client payload/no_think/剥围栏/解析错误/无密钥 Auth 错误);全量 `cargo test --lib` 1204 通过
1932

2033
## [0.7.2] - 2026-08(开发中)
21-
2234
### 新增
2335

2436
- **课堂助手体验三连(2026-08 用户需求,已完成)**:
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"version": 1,
3+
"core_instruction": "精修=整理不创作:去除非知识内容(寒暄/废话/重复/口头禅),按层级组织为结构化笔记;不增补课程外事实、不编造、不修正技术内容(错词/术语属书面化域不归你管);沿用输入中的章节层级与术语表,不自行发明章节;技术术语/数字/专有名词即使片段也保留。",
4+
"styles": {
5+
"lecture": {
6+
"name": "讲义式",
7+
"system": "你是课堂视频笔记整理助手。把口语化转写整理成结构化讲义:按章节组织,正文用 paragraph,并列要点用 list,术语用 term,关键结论用 highlight,原文摘录用 quote。保留课程完整知识点,删除寒暄与重复。"
8+
},
9+
"hands-on": {
10+
"name": "步骤式",
11+
"system": "你是实操教程笔记整理助手。整理为步骤化教程:严格保留操作顺序,命令/代码/参数用 list 或 quote 呈现,步骤要点用 highlight 强调,删除无关闲聊。"
12+
},
13+
"talking-head": {
14+
"name": "摘要式",
15+
"system": "你是口播知识视频笔记整理助手。整理为知识摘要:保留核心概念、论证与结论,删除口水话与重复;概念用 term,结论用 highlight。"
16+
},
17+
"interview": {
18+
"name": "问答式",
19+
"system": "你是访谈/播客笔记整理助手。整理为问答式记录:保留对话结构与各方观点,用 quote 摘录关键原话,删除无关寒暄。"
20+
},
21+
"meeting": {
22+
"name": "纪要式",
23+
"system": "你是会议纪要整理助手。整理为结构化纪要:决议/行动项/要点分层,行动项用 list 呈现,删除闲谈与重复。"
24+
}
25+
},
26+
"profile_style": {
27+
"lecture": "lecture",
28+
"hands-on": "hands-on",
29+
"talking-head": "talking-head",
30+
"interview": "interview",
31+
"meeting": "meeting",
32+
"podcast": "talking-head",
33+
"live": "lecture",
34+
"whiteboard": "lecture",
35+
"game-tutorial": "hands-on",
36+
"exercise": "lecture",
37+
"follow-along": "hands-on",
38+
"coding": "hands-on",
39+
"unknown": "lecture"
40+
},
41+
"fallback_style": "lecture",
42+
"few_shot": [
43+
{
44+
"input": "{\"profile\":\"lecture\",\"glossary\":[\"卷积\",\"池化\"],\"chapters\":[\"第一章 引言\"],\"content\":\"## 第一章 引言\\n大家好今天我们讲卷积神经网络\\n嗯 卷积就是…… 然后池化就是……\\n这个非常重要\"}",
45+
"output": "{\"sections\":[{\"heading\":\"第一章 引言\",\"blocks\":[{\"type\":\"paragraph\",\"content\":\"卷积神经网络的核心概念:卷积与池化。\",\"anchor_ref\":\"第一章 引言\"},{\"type\":\"term\",\"content\":\"卷积:对输入进行特征提取的运算。\",\"anchor_ref\":null},{\"type\":\"term\",\"content\":\"池化:对特征图进行下采样。\",\"anchor_ref\":null},{\"type\":\"highlight\",\"content\":\"重要:本节为核心概念。\",\"anchor_ref\":null}]}]}"
46+
}
47+
],
48+
"output_format": "只输出 JSON,不要任何其他文字、解释或代码块标记。格式:{\"sections\":[{\"heading\":\"<章节标题,沿用输入章节或按内容归纳>\",\"blocks\":[{\"type\":\"paragraph|list|term|highlight|quote\",\"content\":\"<内容>\",\"anchor_ref\":\"<可空:引用的章节标题>\"}]}]}。要求:sections 非空;每节至少 1 个块;content 非空且不超过 4000 字;总块数不超过 200;type 只能取五个枚举值之一。"
49+
}

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//! AI 成本估算(REQ-143 基础版,v0.8.0 M2)。
2+
//!
3+
//! @ai-context: 精修/补充触发前按字符数估算 token → 费用=token×单价 →
4+
//! 确认弹窗(首次必显 + 内联余额 + "记住此选择"偏好持久化在
5+
//! ai_settings.remember_cost_choice);单价表可配
6+
//! (env SILICONFLOW_PRICE_PER_1M_TOKENS 覆盖——环境隔离铁律)。
7+
//! @ai-context: 估算为近似值:中文 1 字符 ≈ 1 token(保守上界,宁可高估
8+
//! 不可低估——用户确认后不产生"费用超预期");预估与实际偏差
9+
//! 记录校准单价表(M4 note_ai_usage 落库后比对)。
10+
11+
/// 默认单价(元/百万 token;免费档 0——2026-08 选型 R1-0528-Qwen3-8B ¥0/M)。
12+
pub const DEFAULT_PRICE_PER_1M: f64 = 0.0;
13+
/// env 覆盖键(元/百万 token)。
14+
const PRICE_ENV_KEY: &str = "SILICONFLOW_PRICE_PER_1M_TOKENS";
15+
16+
/// token 估算(纯函数:字符数 × 1.0——中文 1 字符≈1 token 保守上界)。
17+
pub fn estimate_tokens(chars: usize) -> usize {
18+
chars
19+
}
20+
21+
/// 费用估算(纯函数:token × 单价 / 1M;免费档 → 0)。
22+
pub fn estimate_cost(tokens: usize, price_per_1m: f64) -> f64 {
23+
let price = if price_per_1m.is_finite() && price_per_1m > 0.0 {
24+
price_per_1m
25+
} else {
26+
0.0
27+
};
28+
tokens as f64 * price / 1_000_000.0
29+
}
30+
31+
/// 单价解析(env 覆盖;缺省/非法 → 默认 0——免费档兜底)。
32+
pub fn price_per_1m() -> f64 {
33+
std::env::var(PRICE_ENV_KEY)
34+
.ok()
35+
.and_then(|v| v.parse::<f64>().ok())
36+
.filter(|p| p.is_finite() && *p >= 0.0)
37+
.unwrap_or(DEFAULT_PRICE_PER_1M)
38+
}
39+
40+
/// 成本预估(确认弹窗数据源:token + 费用 + 单价)。
41+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
42+
#[serde(rename_all = "camelCase")]
43+
pub struct CostEstimate {
44+
pub est_tokens: usize,
45+
pub est_cost_yuan: f64,
46+
pub price_per_1m: f64,
47+
}
48+
49+
/// 按内容字符数估算(纯函数;单价取当前 env/默认)。
50+
pub fn estimate_for_content(chars: usize) -> CostEstimate {
51+
let tokens = estimate_tokens(chars);
52+
let price = price_per_1m();
53+
CostEstimate {
54+
est_tokens: tokens,
55+
est_cost_yuan: estimate_cost(tokens, price),
56+
price_per_1m: price,
57+
}
58+
}
59+
60+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
61+
#[cfg(test)]
62+
#[path = "ai_cost_tests.rs"]
63+
mod tests;

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//! ai_cost.rs 单测(AAA 模式;单价 env 读取为宿主环境——测试隔离保存/还原)。
2+
//! @ai-context: 两个测试读写同一 env 变量——并行会竞态,用模块级互斥串行化。
3+
4+
use std::sync::Mutex;
5+
6+
use crate::ai_cost::{estimate_cost, estimate_for_content, estimate_tokens, price_per_1m, DEFAULT_PRICE_PER_1M};
7+
8+
/// env 操作互斥(防并行测试互相覆盖 SILICONFLOW_PRICE_PER_1M_TOKENS)。
9+
static ENV_LOCK: Mutex<()> = Mutex::new(());
10+
11+
/// 在锁内执行 env 敏感操作(保存/还原宿主环境)。
12+
fn with_env_locked(f: impl FnOnce()) {
13+
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
14+
let saved = std::env::var("SILICONFLOW_PRICE_PER_1M_TOKENS").ok();
15+
f();
16+
match saved {
17+
Some(v) => std::env::set_var("SILICONFLOW_PRICE_PER_1M_TOKENS", v),
18+
None => std::env::remove_var("SILICONFLOW_PRICE_PER_1M_TOKENS"),
19+
}
20+
}
21+
22+
#[test]
23+
fn token_estimate_is_char_count() {
24+
// 中文 1 字符 ≈ 1 token(保守上界)
25+
assert_eq!(estimate_tokens(0), 0);
26+
assert_eq!(estimate_tokens(100), 100);
27+
assert_eq!(estimate_tokens(8000), 8000);
28+
}
29+
30+
#[test]
31+
fn cost_zero_when_free_tier_or_bad_price() {
32+
// 免费档 / 非法单价(负数/NaN/inf)→ 0 费用(不产生负成本)
33+
assert_eq!(estimate_cost(1000, 0.0), 0.0);
34+
assert_eq!(estimate_cost(1000, -5.0), 0.0);
35+
assert_eq!(estimate_cost(1000, f64::NAN), 0.0);
36+
assert_eq!(estimate_cost(1000, f64::INFINITY), 0.0);
37+
}
38+
39+
#[test]
40+
fn cost_scales_with_tokens_and_price() {
41+
// 1M token @ ¥10 → 每 1000 token = ¥0.01
42+
let cost = estimate_cost(1000, 10.0);
43+
assert!((cost - 0.01).abs() < 1e-9, "实得 {}", cost);
44+
let cost2 = estimate_cost(500_000, 10.0);
45+
assert!((cost2 - 5.0).abs() < 1e-9);
46+
}
47+
48+
#[test]
49+
fn price_default_free_tier() {
50+
// 缺省单价 = 免费档 0(当前默认模型免费)
51+
assert_eq!(DEFAULT_PRICE_PER_1M, 0.0);
52+
with_env_locked(|| {
53+
// env 未设置时 price_per_1m 回退默认
54+
std::env::remove_var("SILICONFLOW_PRICE_PER_1M_TOKENS");
55+
assert_eq!(price_per_1m(), 0.0);
56+
// 非法值回退默认
57+
std::env::set_var("SILICONFLOW_PRICE_PER_1M_TOKENS", "abc");
58+
assert_eq!(price_per_1m(), 0.0);
59+
// 合法覆盖生效
60+
std::env::set_var("SILICONFLOW_PRICE_PER_1M_TOKENS", "12.5");
61+
assert!((price_per_1m() - 12.5).abs() < 1e-9);
62+
});
63+
}
64+
65+
#[test]
66+
fn estimate_for_content_composes() {
67+
with_env_locked(|| {
68+
std::env::set_var("SILICONFLOW_PRICE_PER_1M_TOKENS", "10");
69+
let est = estimate_for_content(5000);
70+
assert_eq!(est.est_tokens, 5000);
71+
assert!((est.est_cost_yuan - 0.05).abs() < 1e-9);
72+
assert_eq!(est.price_per_1m, 10.0);
73+
});
74+
}

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ use crate::ai_protocol::{
1212
AiEnhanceRequest, AiEnhanceResponse, AiRequestType, AiResponseContent, TextFilterAction,
1313
TextFilterDecision, TextFilterRequest, TextFilterResponse,
1414
};
15+
use crate::ai_refine_protocol::{
16+
AiRefineBlock, AiRefineBlockType, AiRefineRequest, AiRefineResponse, AiRefineSection,
17+
};
1518

1619
/// mock 适配器(无状态;后续云端适配器实现同一 trait)。
1720
pub struct AiMockAdapter;
@@ -113,6 +116,39 @@ impl AiMockAdapter {
113116
.collect();
114117
TextFilterResponse { decisions }
115118
}
119+
120+
/// 精修 mock(REQ-141):规则化整理——按输入章节组织 + 高亮标注;
121+
/// 响应通过 AiRefineResponse::validate(持续验证精修协议校验链)。
122+
///
123+
/// @ai-context: 精修=整理不创作语义在 mock 中体现:只重组不新增事实
124+
/// (content 原样作为 paragraph,标 mock 高亮);M5 契约
125+
/// 测试/mock 全链路消费本方法。
126+
pub fn refine(&self, request: &AiRefineRequest) -> AiRefineResponse {
127+
let headings: Vec<String> = if request.chapters.is_empty() {
128+
vec!["笔记".to_string()]
129+
} else {
130+
request.chapters.clone()
131+
};
132+
let sections: Vec<AiRefineSection> = headings
133+
.iter()
134+
.map(|heading| AiRefineSection {
135+
heading: heading.clone(),
136+
blocks: vec![
137+
AiRefineBlock {
138+
block_type: AiRefineBlockType::Paragraph,
139+
content: request.content.clone(),
140+
anchor_ref: Some(heading.clone()),
141+
},
142+
AiRefineBlock {
143+
block_type: AiRefineBlockType::Highlight,
144+
content: "(mock 精修)已整理,未增补课程外内容".to_string(),
145+
anchor_ref: None,
146+
},
147+
],
148+
})
149+
.collect();
150+
AiRefineResponse { sections }
151+
}
116152
}
117153

118154
/// 口头禅/寒暄特征(mock 判定用):短句且含填充词或问候前缀。

0 commit comments

Comments
 (0)