Skip to content

Commit 1904c2c

Browse files
committed
feat(asr): REQ-270 LLM 文本校对后端(默认关双闸门+建议制)
1 parent 516f4dc commit 1904c2c

7 files changed

Lines changed: 564 additions & 0 deletions

File tree

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

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//! LLM 文本校对(可选)——逐句建议协议纯逻辑(v0.20.2 / REQ-270)。
2+
//!
3+
//! @ai-context: 建议制逐句(非直改):模型只产出 {original, suggestion, reason}
4+
//! 三元建议;人类裁决后经 session_refine_drafts(origin=proofread)
5+
//! 落采纳(原料 session_segments 永不变,与 REQ-268 同可逆契约)。
6+
//! 仅文本上云(语音不出本机红线)——本模块只处理文本,绝不触音频。
7+
//! @ai-context: 失败/离线降级规则:网络错误 → 命令层报错不触碰原文;模型返回
8+
//! 不可解析/幻觉文本 → 解析层返回空建议(静默降级为无建议,绝不
9+
//! 猜测改写)。original 必须能精确匹配会话候选句(防模型幻觉错位)。
10+
//! @ai-context: 纯函数层无 IO/无网络——分句/分块/提示词组装/解析校验全可单测。
11+
12+
use serde::{Deserialize, Serialize};
13+
14+
/// 单条校对建议(逐句;suggestion 与 original 必须不同才有意义)。
15+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16+
pub struct ProofreadSuggestion {
17+
pub original: String,
18+
pub suggestion: String,
19+
pub reason: String,
20+
}
21+
22+
/// 模型响应容器(兼容 {suggestions:[...]} 与裸数组两种形态——解析层宽容,
23+
/// 语义校验层严格)。
24+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25+
pub struct ProofreadResponse {
26+
#[serde(default)]
27+
pub suggestions: Vec<ProofreadSuggestion>,
28+
}
29+
30+
/// 单次校对请求的候选句上限(成本与延迟护栏;超长会话提示分段使用)。
31+
pub const MAX_SENTENCES_PER_RUN: usize = 240;
32+
/// 单请求候选句数上限(模型输出长度护栏)。
33+
pub const MAX_SENTENCES_PER_CHUNK: usize = 40;
34+
/// 单请求字符预算(含编号/格式开销;保守分隔避免超模型上下文)。
35+
pub const CHUNK_MAX_CHARS: usize = 6000;
36+
/// 参与校对的句子长度带(过短=语气词噪声;过长=非句,跳过)。
37+
pub const SENTENCE_MIN_CHARS: usize = 4;
38+
pub const SENTENCE_MAX_CHARS: usize = 120;
39+
40+
/// 句子切分(纯函数):按中英文句末标点/换行切,去空白;长度带外跳过;
41+
/// 无标点超长文本按护栏取前段(截断仅发生在句末标点缺失处——护栏语义)。
42+
///
43+
/// @ai-context: 不做完整 NLP 分句——ASR 文本无标点常态下按最长 120 字硬切
44+
/// (分段边界=保守护栏,宁多切不跨长);句内标点(,、)不切。
45+
pub fn split_sentences(text: &str) -> Vec<String> {
46+
let mut out = Vec::new();
47+
let mut buf: Vec<char> = Vec::new();
48+
let mut flush = |buf: &mut Vec<char>, out: &mut Vec<String>| {
49+
let s: String = buf.iter().collect::<String>().trim().to_string();
50+
let len = s.chars().count();
51+
if (SENTENCE_MIN_CHARS..=SENTENCE_MAX_CHARS).contains(&len) {
52+
out.push(s);
53+
}
54+
buf.clear();
55+
};
56+
for ch in text.chars() {
57+
buf.push(ch);
58+
let is_end = matches!(ch, '。' | '!' | '?' | '!' | '?' | ';' | ';' | '\n');
59+
if is_end {
60+
flush(&mut buf, &mut out);
61+
} else if buf.len() > SENTENCE_MAX_CHARS {
62+
// 无句末标点硬切:取前护栏长段落,余下继续累积(不丢字、无重叠)
63+
let cut: Vec<char> = buf.drain(..SENTENCE_MAX_CHARS).collect();
64+
let mut piece = cut;
65+
flush(&mut piece, &mut out);
66+
}
67+
}
68+
flush(&mut buf, &mut out);
69+
out
70+
}
71+
72+
/// 分块(纯函数):按句数/字符预算贪心分组——每组 ≤ MAX_SENTENCES_PER_CHUNK
73+
/// 且累计字符 ≤ CHUNK_MAX_CHARS(超长单句已在上游被长度带滤除)。
74+
pub fn chunk_sentences(sentences: &[String]) -> Vec<Vec<usize>> {
75+
let mut chunks: Vec<Vec<usize>> = Vec::new();
76+
let mut cur: Vec<usize> = Vec::new();
77+
let mut cur_chars = 0usize;
78+
for (i, s) in sentences.iter().enumerate() {
79+
let len = s.chars().count() + 4; // 编号 + 换行开销
80+
if (!cur.is_empty() && (cur.len() >= MAX_SENTENCES_PER_CHUNK || cur_chars + len > CHUNK_MAX_CHARS))
81+
{
82+
chunks.push(std::mem::take(&mut cur));
83+
cur_chars = 0;
84+
}
85+
cur.push(i);
86+
cur_chars += len;
87+
}
88+
if !cur.is_empty() {
89+
chunks.push(cur);
90+
}
91+
chunks
92+
}
93+
94+
/// 提示词系统指令(中文 JSON 严格输出;只给文本,不涉语音/画面——红线)。
95+
pub fn build_system_prompt() -> String {
96+
"你是转写文本校对助手。用户给出编号句子列表;对其中存在错别字、同音错、\
97+
语序或明显转写错误的句子输出校对建议;其余句子不输出。\
98+
只输出 JSON:{\"suggestions\":[{\"original\":\"原句原文(必须与输入完全一致)\",\
99+
\"suggestion\":\"校对后句子\",\"reason\":\"改动原因(≤20 字)\"}]}。\
100+
不要输出任何 JSON 以外的内容;original 必须逐字复制输入中的句子。"
101+
.to_string()
102+
}
103+
104+
/// 组装单请求用户文本(编号行;原文随行——模型必须回带原文)。
105+
pub fn build_user_prompt(sentences: &[String]) -> String {
106+
let mut out = String::new();
107+
for (i, s) in sentences.iter().enumerate() {
108+
out.push_str(&format!("{}. {}\n", i + 1, s));
109+
}
110+
out
111+
}
112+
113+
/// 规范化(比对用):去空白与换行——容忍模型对原文的空格微差。
114+
pub fn normalize_for_match(s: &str) -> String {
115+
s.chars().filter(|c| !c.is_whitespace()).collect()
116+
}
117+
118+
/// 解析并校验模型输出(纯函数):容 JSON 容器/裸数组;original 必须能精确
119+
/// 匹配候选句(归一化后);suggestion 必须与 original 不同(空建议丢弃)。
120+
/// 不可解析/零合法 → 返回空(降级=无建议,绝不猜测改写)。
121+
pub fn parse_suggestions(raw: &str, expected: &[String]) -> Vec<ProofreadSuggestion> {
122+
let trimmed = raw.trim();
123+
if trimmed.is_empty() {
124+
return Vec::new();
125+
}
126+
let suggestions = if trimmed.starts_with('[') {
127+
serde_json::from_str::<Vec<ProofreadSuggestion>>(trimmed).unwrap_or_default()
128+
} else {
129+
serde_json::from_str::<ProofreadResponse>(trimmed)
130+
.map(|r| r.suggestions)
131+
.unwrap_or_default()
132+
};
133+
let expected_norm: Vec<String> = expected.iter().map(|e| normalize_for_match(e)).collect();
134+
suggestions
135+
.into_iter()
136+
.filter(|s| {
137+
let orig_norm = normalize_for_match(&s.original);
138+
let sugg = s.suggestion.trim();
139+
!sugg.is_empty()
140+
&& sugg != s.original.trim()
141+
&& expected_norm.iter().any(|e| *e == orig_norm)
142+
})
143+
.collect()
144+
}
145+
146+
#[cfg(test)]
147+
#[path = "ai_proofread_tests.rs"]
148+
mod tests;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//! ai_proofread 纯逻辑单测(v0.20.2 / REQ-270,AAA)。
2+
3+
use super::*;
4+
5+
#[test]
6+
fn split_respects_punctuation_and_band() {
7+
let s = "大家好。今天讲熵减,非常重要!这是测试";
8+
let out = split_sentences(s);
9+
assert_eq!(out, vec!["大家好。", "今天讲熵减,非常重要!", "这是测试"]);
10+
// 短噪声(语气词)与超长(无标点硬切)由长度带处理
11+
assert!(split_sentences("呃。").is_empty(), "过短丢弃");
12+
}
13+
14+
#[test]
15+
fn split_hard_cuts_overlong_no_punct() {
16+
let long = "长".repeat(300);
17+
let out = split_sentences(&long);
18+
assert!(out.len() >= 2, "无标点长文按护栏硬切");
19+
assert!(out.iter().all(|s| s.chars().count() <= SENTENCE_MAX_CHARS + 1));
20+
}
21+
22+
#[test]
23+
fn chunk_respects_limits() {
24+
let sentences: Vec<String> = (0..100).map(|i| format!("句子{}。", i)).collect();
25+
let chunks = chunk_sentences(&sentences);
26+
assert!(chunks.iter().all(|c| c.len() <= MAX_SENTENCES_PER_CHUNK));
27+
let flat: Vec<usize> = chunks.iter().flatten().copied().collect();
28+
assert_eq!(flat, (0..100).collect::<Vec<_>>(), "无遗漏无重复");
29+
}
30+
31+
#[test]
32+
fn parse_accepts_container_and_validates() {
33+
// 候选 = 会话实际句子(含错词原句——模型必须原样回带)
34+
let expected = vec!["毕需掌握。".to_string(), "第二句内容。".to_string()];
35+
let raw = r#"{"suggestions":[{"original":"毕需掌握。","suggestion":"必须掌握。","reason":"同音错"},
36+
{"original":"幻觉句","suggestion":"乱改","reason":"无"}]}"#;
37+
let out = parse_suggestions(raw, &expected);
38+
assert_eq!(out.len(), 1, "幻觉 original 不匹配候选 → 丢弃");
39+
assert_eq!(out[0].original, "毕需掌握。");
40+
assert_eq!(out[0].suggestion, "必须掌握。");
41+
}
42+
43+
#[test]
44+
fn parse_accepts_bare_array_and_drops_equal() {
45+
let expected = vec!["内容一句。".to_string()];
46+
let raw = r#"[{"original":"内容一句。","suggestion":"内容一句。","reason":"x"},
47+
{"original":"内容一句。","suggestion":"内容一句改写。","reason":"顺"}]"#;
48+
let out = parse_suggestions(raw, &expected);
49+
assert_eq!(out.len(), 1, "suggestion 与 original 相同者丢弃");
50+
}
51+
52+
#[test]
53+
fn parse_degrades_empty_on_garbage() {
54+
assert!(parse_suggestions("", &["A句".to_string()]).is_empty());
55+
assert!(parse_suggestions("不是JSON", &["A句".to_string()]).is_empty());
56+
assert!(parse_suggestions("```json\n{\"a\":1}\n```", &["A句".to_string()]).is_empty());
57+
}
58+
59+
#[test]
60+
fn prompts_are_nonempty_and_numbered() {
61+
assert!(build_system_prompt().contains("original"));
62+
let user = build_user_prompt(&["甲。".to_string(), "乙。".to_string()]);
63+
assert!(user.starts_with("1. 甲。"));
64+
assert!(user.contains("2. 乙。"));
65+
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ pub struct AiSettings {
5757
/// v0.19.1(REQ-260):学习库问答片段预算档位(light/standard/deep;
5858
/// 默认 standard——budget_allocator 档位硬顶复用)。
5959
pub kb_qa_tier: String,
60+
/// v0.20.2(REQ-270):可选 LLM 文本校对独立闸门——content_gate 之外的
61+
/// 专用开关(默认关);仅文本上云,语音不出本机红线(建议制·人类裁决)。
62+
pub proofread_enabled: bool,
6063
}
6164

6265
impl Default for AiSettings {
@@ -74,6 +77,7 @@ impl Default for AiSettings {
7477
goal_plan_tier: "standard".to_string(),
7578
kb_qa_enabled: false,
7679
kb_qa_tier: "standard".to_string(),
80+
proofread_enabled: false,
7781
}
7882
}
7983
}
@@ -143,6 +147,19 @@ impl AiSettings {
143147
Ok(())
144148
}
145149

150+
/// 文本校对双闸门(v0.20.2 REQ-270):content_gate + proofread 开关。
151+
///
152+
/// @ai-context: 校对=逐句文本上云(语音/画面永不出本机)——在授权红线之上
153+
/// 再设独立开关(默认关);每次运行仍需命令层 authorized 确认。
154+
/// 失败文案引导设置页「AI 服务」段开关,不静默。
155+
pub fn proofread_gate(&self) -> Result<(), String> {
156+
self.content_gate()?;
157+
if !self.proofread_enabled {
158+
return Err("文本校对未开启(设置→AI 服务→文本校对段打开开关;关闭时转写零影响)".to_string());
159+
}
160+
Ok(())
161+
}
162+
146163
/// 非内容类 AI 调用门控(余额查询/测试连接:仅要求开关开启——避免关闭
147164
/// 状态下误以为功能可用;测试连接为配置验证操作不 gate,见 command 层)。
148165
/// 当前无消费方(预留——若未来余额查询/测试连接要求开启开关时启用),

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ fn save_load_roundtrip() {
6666
// v0.19.1:学习库问答生成独立闸门 + 预算档位 roundtrip 保真
6767
kb_qa_enabled: true,
6868
kb_qa_tier: "light".to_string(),
69+
// v0.20.2(REQ-270):文本校对独立闸门 roundtrip 保真
70+
proofread_enabled: true,
6971
};
7072
s.save(&path).expect("保存成功");
7173
let loaded = AiSettings::load(&path);

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ pub struct AiSettingsView {
4242
pub kb_qa_enabled: bool,
4343
/// v0.19.1(REQ-260):学习库问答片段预算档位(light/standard/deep)
4444
pub kb_qa_tier: String,
45+
/// v0.20.2(REQ-270):可选 LLM 文本校对开关——默认关(content_gate 外
46+
/// 第二闸门;仅文本上云·建议制)
47+
pub proofread_enabled: bool,
4548
/// 是否已配置密钥(env 或凭据库)
4649
pub has_key: bool,
4750
/// 密钥来源:credential | env | none
@@ -83,6 +86,7 @@ pub fn ai_get_settings(state: State<'_, AppState>) -> Result<AiSettingsView, Str
8386
goal_plan_tier: s.goal_plan_tier.clone(),
8487
kb_qa_enabled: s.kb_qa_enabled,
8588
kb_qa_tier: s.kb_qa_tier.clone(),
89+
proofread_enabled: s.proofread_enabled,
8690
has_key,
8791
key_source,
8892
})
@@ -149,6 +153,21 @@ pub fn ai_set_kb_qa(
149153
s.save(&state.ai_settings_path).map_err(|e| e.to_string())
150154
}
151155

156+
/// v0.20.2(REQ-270):可选 LLM 文本校对开关(read-modify-write 最小面——
157+
/// 默认关双闸门之二;开启仍需全局 enabled+authorized)。
158+
#[tauri::command]
159+
pub fn ai_set_proofread(
160+
state: State<'_, AppState>,
161+
enabled: bool,
162+
) -> Result<(), String> {
163+
let mut s = state
164+
.ai_settings
165+
.lock()
166+
.map_err(|e| format!("AI 设置锁中毒: {}", e))?;
167+
s.proofread_enabled = enabled;
168+
s.save(&state.ai_settings_path).map_err(|e| e.to_string())
169+
}
170+
152171
/// 更新 AI 设置(白名单校验后持久化;command 层锁内 read-modify-write)。
153172
///
154173
/// @ai-context: 校验:端点非空且 http(s) 前缀、模型非空、低余额阈值 ∈ (0, 1e6];

0 commit comments

Comments
 (0)