|
| 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; |
0 commit comments