Skip to main content

mempal_runtime/aaak/
codec.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::OnceLock;
3
4use bimap::BiHashMap;
5use jieba_rs::Jieba;
6
7fn jieba() -> &'static Jieba {
8    static INSTANCE: OnceLock<Jieba> = OnceLock::new();
9    INSTANCE.get_or_init(Jieba::new)
10}
11
12use super::model::{
13    AaakDocument, AaakHeader, AaakLine, AaakMeta, EncodeOutput, EncodeReport, RoundtripReport,
14    Zettel,
15};
16
17const DEFAULT_MAX_TOPICS: usize = 3;
18const DEFAULT_EMOTION: &str = "determ";
19const DEFAULT_ENTITY_CODE: &str = "UNK";
20pub(crate) const EMOTION_SIGNALS: &[(&str, &str)] = &[
21    ("decided", "determ"),
22    ("determined", "determ"),
23    ("prefer", "convict"),
24    ("confident", "convict"),
25    ("worried", "anx"),
26    ("anxious", "anx"),
27    ("concern", "anx"),
28    ("excited", "excite"),
29    ("frustrated", "frust"),
30    ("confused", "confuse"),
31    ("love", "love"),
32    ("hate", "rage"),
33    ("hope", "hope"),
34    ("fear", "fear"),
35    ("trust", "trust"),
36    ("happy", "joy"),
37    ("joy", "joy"),
38    ("sad", "grief"),
39    ("grief", "grief"),
40    ("surprised", "surpr"),
41    ("grateful", "grat"),
42    ("curious", "curious"),
43    ("wonder", "wonder"),
44    ("relieved", "relief"),
45    ("satisf", "satis"),
46    ("disappoint", "grief"),
47    ("vulnerable", "vul"),
48    ("tender", "tender"),
49    ("honest", "raw"),
50    ("doubt", "doubt"),
51    ("exhaust", "exhaust"),
52    ("warm", "warmth"),
53    ("humor", "humor"),
54    ("funny", "humor"),
55    ("peace", "peace"),
56    ("despair", "despair"),
57    ("passion", "passion"),
58    // Chinese
59    ("\u{51b3}\u{5b9a}", "determ"),  // 决定
60    ("\u{786e}\u{5b9a}", "determ"),  // 确定
61    ("\u{62c5}\u{5fc3}", "anx"),     // 担心
62    ("\u{7126}\u{8651}", "anx"),     // 焦虑
63    ("\u{5174}\u{594b}", "excite"),  // 兴奋
64    ("\u{6cae}\u{4e27}", "frust"),   // 沮丧
65    ("\u{56f0}\u{60d1}", "confuse"), // 困惑
66    ("\u{5f00}\u{5fc3}", "joy"),     // 开心
67    ("\u{9ad8}\u{5174}", "joy"),     // 高兴
68    ("\u{60b2}\u{4f24}", "grief"),   // 悲伤
69    ("\u{60ca}\u{8bb6}", "surpr"),   // 惊讶
70    ("\u{611f}\u{6069}", "grat"),    // 感恩
71    ("\u{611f}\u{8c22}", "grat"),    // 感谢
72    ("\u{597d}\u{5947}", "curious"), // 好奇
73    ("\u{4fe1}\u{4efb}", "trust"),   // 信任
74    ("\u{5e0c}\u{671b}", "hope"),    // 希望
75    ("\u{6050}\u{60e7}", "fear"),    // 恐惧
76    ("\u{5bb3}\u{6015}", "fear"),    // 害怕
77    ("\u{6ee1}\u{610f}", "satis"),   // 满意
78    ("\u{5931}\u{671b}", "grief"),   // 失望
79    ("\u{8f7b}\u{677e}", "relief"),  // 轻松
80    ("\u{653e}\u{5fc3}", "relief"),  // 放心
81    ("\u{7231}", "love"),            // 爱
82    ("\u{6068}", "rage"),            // 恨
83    ("\u{6016}\u{60e7}", "fear"),    // 恐惧
84    ("\u{5e73}\u{9759}", "peace"),   // 平静
85    ("\u{7edd}\u{671b}", "despair"), // 绝望
86    ("\u{70ed}\u{60c5}", "passion"), // 热情
87    ("\u{6000}\u{7591}", "doubt"),   // 怀疑
88    ("\u{75b2}\u{60eb}", "exhaust"), // 疲惫
89];
90pub(crate) const FLAG_SIGNALS: &[(&str, &str)] = &[
91    ("decid", "DECISION"),
92    ("chose", "DECISION"),
93    ("switch", "DECISION"),
94    ("migrat", "DECISION"),
95    ("replace", "DECISION"),
96    ("recommend", "DECISION"),
97    ("because", "DECISION"),
98    ("found", "ORIGIN"),
99    ("create", "ORIGIN"),
100    ("start", "ORIGIN"),
101    ("born", "ORIGIN"),
102    ("launch", "ORIGIN"),
103    ("first time", "ORIGIN"),
104    ("core", "CORE"),
105    ("fundamental", "CORE"),
106    ("essential", "CORE"),
107    ("principle", "CORE"),
108    ("belief", "CORE"),
109    ("always", "CORE"),
110    ("turning point", "PIVOT"),
111    ("changed everything", "PIVOT"),
112    ("realized", "PIVOT"),
113    ("breakthrough", "PIVOT"),
114    ("epiphany", "PIVOT"),
115    ("api", "TECHNICAL"),
116    ("database", "TECHNICAL"),
117    ("architecture", "TECHNICAL"),
118    ("deploy", "TECHNICAL"),
119    ("infrastructure", "TECHNICAL"),
120    ("framework", "TECHNICAL"),
121    ("server", "TECHNICAL"),
122    ("config", "TECHNICAL"),
123    ("auth", "TECHNICAL"),
124    ("token", "SENSITIVE"),
125    ("password", "SENSITIVE"),
126    ("secret", "SENSITIVE"),
127    ("credential", "SENSITIVE"),
128    ("private", "SENSITIVE"),
129    ("sensitive", "SENSITIVE"),
130    ("pii", "SENSITIVE"),
131    // Chinese
132    ("\u{51b3}\u{5b9a}", "DECISION"),          // 决定
133    ("\u{9009}\u{62e9}", "DECISION"),          // 选择
134    ("\u{5207}\u{6362}", "DECISION"),          // 切换
135    ("\u{8fc1}\u{79fb}", "DECISION"),          // 迁移
136    ("\u{66ff}\u{6362}", "DECISION"),          // 替换
137    ("\u{63a8}\u{8350}", "DECISION"),          // 推荐
138    ("\u{56e0}\u{4e3a}", "DECISION"),          // 因为
139    ("\u{521b}\u{5efa}", "ORIGIN"),            // 创建
140    ("\u{521b}\u{7acb}", "ORIGIN"),            // 创立
141    ("\u{5f00}\u{59cb}", "ORIGIN"),            // 开始
142    ("\u{7b2c}\u{4e00}\u{6b21}", "ORIGIN"),    // 第一次
143    ("\u{6838}\u{5fc3}", "CORE"),              // 核心
144    ("\u{57fa}\u{672c}", "CORE"),              // 基本
145    ("\u{539f}\u{5219}", "CORE"),              // 原则
146    ("\u{4fe1}\u{5ff5}", "CORE"),              // 信念
147    ("\u{8f6c}\u{6298}", "PIVOT"),             // 转折
148    ("\u{7a81}\u{7834}", "PIVOT"),             // 突破
149    ("\u{987f}\u{609f}", "PIVOT"),             // 顿悟
150    ("\u{63a5}\u{53e3}", "TECHNICAL"),         // 接口
151    ("\u{6570}\u{636e}\u{5e93}", "TECHNICAL"), // 数据库
152    ("\u{67b6}\u{6784}", "TECHNICAL"),         // 架构
153    ("\u{90e8}\u{7f72}", "TECHNICAL"),         // 部署
154    ("\u{6846}\u{67b6}", "TECHNICAL"),         // 框架
155    ("\u{670d}\u{52a1}\u{5668}", "TECHNICAL"), // 服务器
156    ("\u{914d}\u{7f6e}", "TECHNICAL"),         // 配置
157    ("\u{8ba4}\u{8bc1}", "TECHNICAL"),         // 认证
158    ("\u{5bc6}\u{7801}", "SENSITIVE"),         // 密码
159    ("\u{5bc6}\u{94a5}", "SENSITIVE"),         // 密钥
160    ("\u{51ed}\u{8bc1}", "SENSITIVE"),         // 凭证
161    ("\u{9690}\u{79c1}", "SENSITIVE"),         // 隐私
162];
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165enum TextSegmentKind {
166    AsciiWord,
167    Cjk,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
171struct TextSegment {
172    kind: TextSegmentKind,
173    text: String,
174}
175
176#[derive(Debug, Clone)]
177pub struct AaakCodec {
178    entity_map: BiHashMap<String, String>,
179    max_topics: usize,
180}
181
182impl Default for AaakCodec {
183    fn default() -> Self {
184        Self {
185            entity_map: BiHashMap::new(),
186            max_topics: DEFAULT_MAX_TOPICS,
187        }
188    }
189}
190
191impl AaakCodec {
192    pub fn with_entity_aliases(aliases: BTreeMap<String, String>) -> Self {
193        let entity_map = aliases.into_iter().collect::<BiHashMap<_, _>>();
194        Self {
195            entity_map,
196            max_topics: DEFAULT_MAX_TOPICS,
197        }
198    }
199
200    pub fn encode(&self, text: &str, meta: &AaakMeta) -> EncodeOutput {
201        let normalized = normalize_whitespace(text);
202        let all_topics = extract_topics(&normalized);
203        let topics = all_topics
204            .iter()
205            .take(self.max_topics)
206            .cloned()
207            .collect::<Vec<_>>();
208        let topics_truncated = all_topics.len().saturating_sub(self.max_topics);
209        let mut entity_codes = Vec::new();
210        let mut seen_codes = BTreeSet::new();
211
212        // First: match known entity_map keys against text
213        for (name, code) in self.entity_map.iter() {
214            if normalized.contains(name.as_str()) && seen_codes.insert(code.clone()) {
215                entity_codes.push(code.clone());
216            }
217        }
218
219        // Then: auto-detect entities from text
220        for entity in extract_entities(&normalized) {
221            let code = self.entity_code(&entity);
222            if seen_codes.insert(code.clone()) {
223                entity_codes.push(code);
224            }
225        }
226
227        let entities = if entity_codes.is_empty() {
228            vec![DEFAULT_ENTITY_CODE.to_string()]
229        } else {
230            entity_codes
231        };
232        let flags = detect_flags(&normalized);
233        let emotions = detect_emotions(&normalized);
234
235        let zettel = Zettel {
236            id: 0,
237            entities,
238            topics: if topics.is_empty() {
239                vec!["note".to_string()]
240            } else {
241                topics
242            },
243            quote: normalized,
244            weight: infer_weight(&flags),
245            emotions,
246            flags,
247        };
248        let document = AaakDocument {
249            header: AaakHeader {
250                version: 1,
251                wing: meta.wing.clone(),
252                room: meta.room.clone(),
253                date: meta.date.clone(),
254                source: meta.source.clone(),
255            },
256            body: vec![AaakLine::Zettel(zettel.clone())],
257            zettels: vec![zettel],
258        };
259        let roundtrip = self.verify_roundtrip(text, &document);
260
261        EncodeOutput {
262            document,
263            report: EncodeReport {
264                topics_truncated,
265                key_sentence_truncated: false,
266                coverage: roundtrip.coverage,
267                lost_assertions: roundtrip.lost,
268            },
269        }
270    }
271
272    pub fn decode(&self, document: &AaakDocument) -> String {
273        document
274            .zettel_lines()
275            .iter()
276            .map(|zettel| self.decode_zettel(zettel))
277            .collect::<Vec<_>>()
278            .join("\n")
279    }
280
281    pub fn verify_roundtrip(&self, original: &str, document: &AaakDocument) -> RoundtripReport {
282        let decoded = normalize_whitespace(&self.decode(document)).to_lowercase();
283        let assertions = split_assertions(original);
284        if assertions.is_empty() {
285            return RoundtripReport {
286                preserved: Vec::new(),
287                lost: Vec::new(),
288                coverage: 1.0,
289            };
290        }
291
292        let (preserved, lost): (Vec<_>, Vec<_>) = assertions
293            .into_iter()
294            .partition(|assertion| decoded.contains(&assertion.to_lowercase()));
295        let coverage = preserved.len() as f32 / (preserved.len() + lost.len()) as f32;
296
297        RoundtripReport {
298            preserved,
299            lost,
300            coverage,
301        }
302    }
303
304    fn decode_zettel(&self, zettel: &Zettel) -> String {
305        let mut quote = zettel.quote.clone();
306        for entity in &zettel.entities {
307            if let Some(name) = self.entity_map.get_by_right(entity) {
308                quote = replace_code(&quote, entity, name);
309            }
310        }
311
312        if quote.is_empty() {
313            return zettel
314                .entities
315                .iter()
316                .map(|entity| {
317                    self.entity_map
318                        .get_by_right(entity)
319                        .cloned()
320                        .unwrap_or_else(|| entity.clone())
321                })
322                .collect::<Vec<_>>()
323                .join(" ");
324        }
325
326        quote
327    }
328
329    fn entity_code(&self, entity: &str) -> String {
330        self.entity_map
331            .get_by_left(entity)
332            .cloned()
333            .unwrap_or_else(|| default_entity_code(entity))
334    }
335}
336
337pub(crate) fn normalize_whitespace(text: &str) -> String {
338    text.split_whitespace()
339        .collect::<Vec<_>>()
340        .join(" ")
341        .replace('"', "'")
342        .trim()
343        .to_string()
344}
345
346pub(crate) fn extract_entities(text: &str) -> Vec<String> {
347    let mut seen = BTreeSet::new();
348    let mut entities = Vec::new();
349
350    for segment in text_segments(text) {
351        match segment.kind {
352            TextSegmentKind::AsciiWord => {
353                if looks_like_ascii_entity(&segment.text) && seen.insert(segment.text.clone()) {
354                    entities.push(segment.text);
355                }
356            }
357            TextSegmentKind::Cjk => {
358                for candidate in extract_cjk_entities_from_segment(&segment.text) {
359                    if seen.insert(candidate.clone()) {
360                        entities.push(candidate);
361                    }
362                }
363            }
364        }
365    }
366
367    entities
368}
369
370pub(crate) fn extract_topics(text: &str) -> Vec<String> {
371    const STOP_WORDS: &[&str] = &[
372        "the", "and", "for", "with", "over", "based", "this", "that", "was", "use", "why",
373    ];
374
375    let mut seen = BTreeSet::new();
376    let mut topics = Vec::new();
377
378    for segment in text_segments(text) {
379        match segment.kind {
380            TextSegmentKind::AsciiWord => {
381                let token = segment.text.to_lowercase();
382                if token.is_empty() || STOP_WORDS.contains(&token.as_str()) {
383                    continue;
384                }
385
386                if seen.insert(token.clone()) {
387                    topics.push(token);
388                }
389            }
390            TextSegmentKind::Cjk => {
391                for topic in extract_cjk_topics_from_segment(&segment.text) {
392                    if seen.insert(topic.clone()) {
393                        topics.push(topic);
394                    }
395                }
396            }
397        }
398    }
399
400    topics
401}
402
403pub(crate) fn detect_flags(text: &str) -> Vec<String> {
404    let lower = text.to_lowercase();
405    let mut flags = Vec::new();
406
407    for (needle, flag) in FLAG_SIGNALS {
408        if lower.contains(needle) && !flags.iter().any(|existing| existing == flag) {
409            flags.push((*flag).to_string());
410        }
411    }
412    if flags.is_empty() {
413        flags.push("CORE".to_string());
414    }
415
416    flags
417}
418
419pub(crate) fn detect_emotions(text: &str) -> Vec<String> {
420    let lower = text.to_lowercase();
421    let mut emotions = Vec::new();
422
423    for (needle, emotion) in EMOTION_SIGNALS {
424        if lower.contains(needle) && !emotions.iter().any(|existing| existing == emotion) {
425            emotions.push((*emotion).to_string());
426        }
427    }
428
429    if emotions.is_empty() {
430        emotions.push(DEFAULT_EMOTION.to_string());
431    }
432
433    emotions
434}
435
436pub(crate) fn infer_weight(flags: &[String]) -> u8 {
437    if flags
438        .iter()
439        .any(|flag| flag == "DECISION" || flag == "PIVOT")
440    {
441        4
442    } else if flags.iter().any(|flag| flag == "TECHNICAL") {
443        3
444    } else {
445        2
446    }
447}
448
449fn split_assertions(text: &str) -> Vec<String> {
450    text.split([
451        '.', '!', '?', ';', '\u{3002}', // 。
452        '\u{FF01}', // !
453        '\u{FF1F}', // ?
454        '\u{FF1B}', // ;
455        '\u{FF0C}', // ,
456    ])
457    .map(normalize_whitespace)
458    .filter(|item| !item.is_empty())
459    .collect()
460}
461
462fn replace_code(quote: &str, code: &str, name: &str) -> String {
463    let mut replaced = String::with_capacity(quote.len());
464    let mut token = String::new();
465
466    for ch in quote.chars() {
467        if ch.is_ascii_alphanumeric() {
468            token.push(ch);
469            continue;
470        }
471
472        push_replacement(&mut replaced, &mut token, code, name);
473        replaced.push(ch);
474    }
475
476    push_replacement(&mut replaced, &mut token, code, name);
477    replaced
478}
479
480pub(crate) fn default_entity_code(entity: &str) -> String {
481    let ascii_code: String = entity
482        .chars()
483        .filter(|ch| ch.is_ascii_alphabetic())
484        .take(3)
485        .collect::<String>()
486        .to_uppercase();
487
488    if ascii_code.len() >= 3 {
489        return ascii_code;
490    }
491
492    // For non-ASCII entities (e.g., Chinese), generate a stable hash-based code
493    let hash = stable_hash(entity);
494    let mut code = String::with_capacity(3);
495    for i in 0..3u64 {
496        let byte = ((hash >> (i * 5)) & 0x1F) as u8;
497        code.push((b'A' + byte % 26) as char);
498    }
499    code
500}
501
502fn stable_hash(s: &str) -> u64 {
503    let mut h: u64 = 0;
504    for byte in s.bytes() {
505        h = h.wrapping_mul(31).wrapping_add(u64::from(byte));
506    }
507    h
508}
509
510fn is_cjk_ideograph(ch: char) -> bool {
511    matches!(ch,
512        '\u{4E00}'..='\u{9FFF}'   // CJK Unified Ideographs
513        | '\u{3400}'..='\u{4DBF}' // CJK Extension A
514        | '\u{F900}'..='\u{FAFF}' // CJK Compatibility Ideographs
515    )
516}
517
518fn text_segments(text: &str) -> Vec<TextSegment> {
519    let mut segments = Vec::new();
520    let mut current = String::new();
521    let mut current_kind = None;
522
523    for ch in text.chars() {
524        let next_kind = if ch.is_ascii_alphanumeric() {
525            Some(TextSegmentKind::AsciiWord)
526        } else if is_cjk_ideograph(ch) {
527            Some(TextSegmentKind::Cjk)
528        } else {
529            None
530        };
531
532        match (current_kind, next_kind) {
533            (Some(kind), Some(next)) if kind == next => current.push(ch),
534            (Some(kind), Some(next)) => {
535                segments.push(TextSegment {
536                    kind,
537                    text: std::mem::take(&mut current),
538                });
539                current.push(ch);
540                current_kind = Some(next);
541            }
542            (Some(kind), None) => {
543                if !current.is_empty() {
544                    segments.push(TextSegment {
545                        kind,
546                        text: std::mem::take(&mut current),
547                    });
548                }
549                current_kind = None;
550            }
551            (None, Some(next)) => {
552                current.push(ch);
553                current_kind = Some(next);
554            }
555            (None, None) => {}
556        }
557    }
558
559    if let Some(kind) = current_kind
560        && !current.is_empty()
561    {
562        segments.push(TextSegment {
563            kind,
564            text: current,
565        });
566    }
567
568    segments
569}
570
571fn looks_like_ascii_entity(token: &str) -> bool {
572    token.len() >= 2
573        && token
574            .chars()
575            .next()
576            .is_some_and(|first| first.is_ascii_uppercase())
577}
578
579/// Extract CJK entities using jieba POS tagging.
580///
581/// Identifies proper nouns via part-of-speech tags:
582/// - `nr` = person name
583/// - `ns` = place name
584/// - `nt` = organization name
585/// - `nz` = other proper noun
586///
587/// This replaces the earlier 2-4 char heuristic and can handle names of
588/// arbitrary length that jieba's default dictionary knows about.
589fn extract_cjk_entities_from_segment(segment: &str) -> Vec<String> {
590    let mut entities = Vec::new();
591    let mut seen = BTreeSet::new();
592
593    for tag in jieba().tag(segment, true) {
594        // nr/nrfg/nrt = person name variants, ns = place, nt = org, nz = other proper
595        let is_entity =
596            tag.tag.starts_with("nr") || tag.tag == "ns" || tag.tag == "nt" || tag.tag == "nz";
597        if !is_entity {
598            continue;
599        }
600        if tag.word.chars().count() < 2 {
601            continue;
602        }
603        if seen.insert(tag.word.to_string()) {
604            entities.push(tag.word.to_string());
605        }
606    }
607
608    entities
609}
610
611/// Extract CJK topics using jieba word segmentation with POS filtering.
612///
613/// Keeps content words (nouns, verbs, adjectives) of length >= 2, filters
614/// out function words (particles, conjunctions, pronouns) and proper nouns
615/// (which are handled by `extract_cjk_entities_from_segment` instead).
616fn extract_cjk_topics_from_segment(segment: &str) -> Vec<String> {
617    let mut topics = Vec::new();
618    let mut seen = BTreeSet::new();
619
620    for tag in jieba().tag(segment, true) {
621        // Skip proper nouns — they go to entities, not topics
622        if tag.tag.starts_with("nr") || tag.tag == "ns" || tag.tag == "nt" || tag.tag == "nz" {
623            continue;
624        }
625        // Keep content-bearing POS: nouns (n*), verbs (v*), adjectives (a*)
626        let first = tag.tag.chars().next().unwrap_or(' ');
627        if !matches!(first, 'n' | 'v' | 'a') {
628            continue;
629        }
630        if tag.word.chars().count() < 2 {
631            continue;
632        }
633        // Skip pronouns like 我们/你们/这个 (POS = r, but some dicts tag them as n)
634        if is_cjk_function_word(tag.word) {
635            continue;
636        }
637        if seen.insert(tag.word.to_string()) {
638            topics.push(tag.word.to_string());
639        }
640    }
641
642    topics
643}
644
645fn is_cjk_function_word(word: &str) -> bool {
646    // Fallback filter for words jieba tags as nominal but are actually
647    // pronouns/demonstratives/conjunctions.
648    matches!(
649        word,
650        "\u{6211}\u{4eec}" // 我们
651            | "\u{4f60}\u{4eec}" // 你们
652            | "\u{4ed6}\u{4eec}" // 他们
653            | "\u{5979}\u{4eec}" // 她们
654            | "\u{5b83}\u{4eec}" // 它们
655            | "\u{8fd9}\u{4e2a}" // 这个
656            | "\u{90a3}\u{4e2a}" // 那个
657            | "\u{8fd9}\u{4e9b}" // 这些
658            | "\u{90a3}\u{4e9b}" // 那些
659            | "\u{4e3a}\u{4ec0}\u{4e48}" // 为什么
660            | "\u{600e}\u{4e48}" // 怎么
661            | "\u{4ec0}\u{4e48}" // 什么
662            | "\u{56e0}\u{4e3a}" // 因为
663            | "\u{6240}\u{4ee5}" // 所以
664            | "\u{4f46}\u{662f}" // 但是
665    )
666}
667
668fn push_replacement(output: &mut String, token: &mut String, code: &str, name: &str) {
669    if token.is_empty() {
670        return;
671    }
672
673    if token == code {
674        output.push_str(name);
675    } else {
676        output.push_str(token);
677    }
678    token.clear();
679}