Skip to main content

p_memory/
text.rs

1use regex::Regex;
2use std::collections::HashSet;
3use std::sync::LazyLock;
4use sha2::{Digest, Sha256};
5
6pub fn normalize_text(text: &str) -> String {
7    text.trim().chars().flat_map(|ch| {
8        let c = match ch {
9            '\u{3000}' => ' ',
10            '\u{ff01}'..='\u{ff5e}' => char::from_u32(ch as u32 - 0xfee0).unwrap_or(ch),
11            '\u{2018}' | '\u{2019}' => '\'',
12            '\u{201c}' | '\u{201d}' => '"',
13            _ => ch,
14        };
15        c.to_lowercase()
16    }).collect::<String>()
17}
18
19pub fn is_cjk(ch: char) -> bool {
20    matches!(ch as u32, 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x323af)
21}
22
23/// Keep document token frequencies; query callers can deduplicate separately.
24pub fn tokenize(text: &str) -> Vec<String> {
25    let normalized = normalize_text(text);
26    let mut tokens = Vec::new();
27    let mut word = String::new();
28    let mut run = Vec::new();
29    fn flush(word: &mut String, run: &mut Vec<char>, tokens: &mut Vec<String>) {
30        if !word.is_empty() { tokens.push(std::mem::take(word)); }
31        tokens.extend(run.iter().map(char::to_string));
32        tokens.extend(run.windows(2).map(|p| format!("{}{}", p[0], p[1])));
33        run.clear();
34    }
35    for ch in normalized.chars() {
36        if is_cjk(ch) {
37            if !word.is_empty() { tokens.push(std::mem::take(&mut word)); }
38            run.push(ch);
39        } else if ch.is_alphanumeric() || ch == '_' {
40            if !run.is_empty() { flush(&mut word, &mut run, &mut tokens); }
41            word.push(ch);
42        } else { flush(&mut word, &mut run, &mut tokens); }
43    }
44    flush(&mut word, &mut run, &mut tokens);
45    tokens
46}
47
48pub(crate) fn query_terms(text: &str, strict: bool) -> Vec<String> {
49    let tokens = tokenize(text);
50    let mut seen = HashSet::new();
51    tokens.into_iter().filter(|t| {
52        // Retaining unigrams also preserves standalone characters in mixed
53        // queries such as "上海 茶". Bigrams enforce adjacency in strict mode.
54        (strict || !(t.chars().count() == 2 && t.chars().all(is_cjk))) && seen.insert(t.clone())
55    }).collect()
56}
57
58/// token 计数的字符密度,单位 1/100 token:ASCII ≈ 4 字符/token,非 ASCII(CJK 等)≈ 2 字符/token。
59/// 取值照抄天使之魂在大样本上校准过的 0.25 / 0.48,不另行估计。
60const ASCII_UNITS: u64 = 25;
61const NON_ASCII_UNITS: u64 = 48;
62
63/// 估算文本的 token 数:按字符密度累加、向上取整。
64/// 不做分词、不调模型,成本是一次字符遍历。
65pub(crate) fn count_tokens(text: &str) -> usize {
66    let units: u64 = text.chars().map(|ch| if (ch as u32) < 128 { ASCII_UNITS } else { NON_ASCII_UNITS }).sum();
67    units.div_ceil(100) as usize
68}
69
70/// 按 token 预算把文本截断到不超过 `budget` 个 token,口径与 `count_tokens` 同源。
71/// 返回原文前缀,不改写内容。
72pub(crate) fn truncate_to_tokens(text: &str, budget: usize) -> String {
73    if budget == 0 { return String::new(); }
74    let limit = budget as u64 * 100;
75    let mut units: u64 = 0;
76    let mut end = 0usize;
77    for (index, ch) in text.char_indices() {
78        let cost = if (ch as u32) < 128 { ASCII_UNITS } else { NON_ASCII_UNITS };
79        if units + cost > limit { break; }
80        units += cost;
81        end = index + ch.len_utf8();
82    }
83    text[..end].to_string()
84}
85
86pub(crate) fn digest(text: &str) -> String { format!("{:x}", Sha256::digest(text.as_bytes())) }pub(crate) fn normalized_tag(text: &str) -> String {
87    normalize_text(text).split_whitespace().collect::<Vec<_>>().join(" ")
88}
89
90// ── 统一 Markdown 清洗 ────────────────────────────────────────────────
91//
92// 规则:去掉标记、只留可读文本。切片与正文
93// 照旧保留原文,清洗只作用于送进全文索引的文本,检索命中因此不被标记符干扰。
94
95fn pattern(source: &str) -> Regex { Regex::new(source).expect("static markdown pattern compiles") }
96
97static MD_HTML: LazyLock<Regex> = LazyLock::new(|| pattern(r"<[^>]+>"));
98static MD_HEADING: LazyLock<Regex> = LazyLock::new(|| pattern(r"#+\s?"));
99static MD_BOLD: LazyLock<Regex> = LazyLock::new(|| pattern(r"(\*\*|__)(.*?)(\*\*|__)"));
100static MD_ITALIC: LazyLock<Regex> = LazyLock::new(|| pattern(r"(\*|_)(.*?)(\*|_)"));
101static MD_LINK: LazyLock<Regex> = LazyLock::new(|| pattern(r"\[(.*?)\]\(.*?\)"));
102static MD_IMAGE: LazyLock<Regex> = LazyLock::new(|| pattern(r"!\[.*?\]\(.*?\)"));
103static MD_FENCE: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?s)```.*?```"));
104static MD_CODE: LazyLock<Regex> = LazyLock::new(|| pattern(r"`([^`]+)`"));
105static MD_BULLET: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^[-*+]\s+"));
106static MD_ORDERED: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^\d+\.\s+"));
107static MD_QUOTE: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^>\s+"));
108static MD_RULE: LazyLock<Regex> = LazyLock::new(|| pattern(r"---+"));
109static MD_PIPE: LazyLock<Regex> = LazyLock::new(|| pattern(r"\|"));
110
111/// 把 Markdown 清洗成纯文本。输入输出都是原文以外的
112/// 派生文本,调用方负责决定拿它做什么(库只拿它喂索引分词)。
113pub fn clean_markdown(input: &str) -> String {
114    let text = input.to_string();
115    let text = MD_HTML.replace_all(&text, "");
116    let text = MD_HEADING.replace_all(&text, "");
117    let text = MD_BOLD.replace_all(&text, "$2");
118    let text = MD_ITALIC.replace_all(&text, "$2");
119    let text = MD_LINK.replace_all(&text, "$1");
120    let text = MD_IMAGE.replace_all(&text, "");
121    let text = MD_FENCE.replace_all(&text, "");
122    let text = MD_CODE.replace_all(&text, "$1");
123    let text = MD_BULLET.replace_all(&text, "");
124    let text = MD_ORDERED.replace_all(&text, "");
125    let text = MD_QUOTE.replace_all(&text, "");
126    let text = MD_RULE.replace_all(&text, "");
127    let text = MD_PIPE.replace_all(&text, " ");
128    text.trim().to_string()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::clean_markdown;
134
135    /// 期望值逐条对拍得到。
136    #[test]
137    fn clean_markdown_matches_reference() {
138        let cases = [
139            ("# 标题\n\n正文 **粗体** 与 *斜体* 和 _下划线_", "标题\n\n正文 粗体 与 斜体 和 下划线"),
140            ("见 [链接](http://a.b) 与 ![图片](http://c.d)", "见 链接 与 !图片"),
141            ("```py\nprint(1)\n```\n后面 `行内` 文字", "后面 行内 文字"),
142            ("- 项目一\n- 项目二\n1. 有序\n> 引用\n\n---", "项目一\n项目二\n有序\n引用"),
143            ("| 列A | 列B |\n|---|---|\n| 1 | 2 |", "列A   列B  \n   \n  1   2"),
144            ("<div>标签</div> 普通文本", "标签 普通文本"),
145            ("混合 **粗** [链](u) `码` 尾", "混合 粗 链 码 尾"),
146            ("  ## 缩进标题  ", "缩进标题"),
147        ];
148        for (input, expected) in cases {
149            assert_eq!(clean_markdown(input), expected, "input={input:?}");
150        }
151    }
152}