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 ("\u{51b3}\u{5b9a}", "determ"), ("\u{786e}\u{5b9a}", "determ"), ("\u{62c5}\u{5fc3}", "anx"), ("\u{7126}\u{8651}", "anx"), ("\u{5174}\u{594b}", "excite"), ("\u{6cae}\u{4e27}", "frust"), ("\u{56f0}\u{60d1}", "confuse"), ("\u{5f00}\u{5fc3}", "joy"), ("\u{9ad8}\u{5174}", "joy"), ("\u{60b2}\u{4f24}", "grief"), ("\u{60ca}\u{8bb6}", "surpr"), ("\u{611f}\u{6069}", "grat"), ("\u{611f}\u{8c22}", "grat"), ("\u{597d}\u{5947}", "curious"), ("\u{4fe1}\u{4efb}", "trust"), ("\u{5e0c}\u{671b}", "hope"), ("\u{6050}\u{60e7}", "fear"), ("\u{5bb3}\u{6015}", "fear"), ("\u{6ee1}\u{610f}", "satis"), ("\u{5931}\u{671b}", "grief"), ("\u{8f7b}\u{677e}", "relief"), ("\u{653e}\u{5fc3}", "relief"), ("\u{7231}", "love"), ("\u{6068}", "rage"), ("\u{6016}\u{60e7}", "fear"), ("\u{5e73}\u{9759}", "peace"), ("\u{7edd}\u{671b}", "despair"), ("\u{70ed}\u{60c5}", "passion"), ("\u{6000}\u{7591}", "doubt"), ("\u{75b2}\u{60eb}", "exhaust"), ];
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 ("\u{51b3}\u{5b9a}", "DECISION"), ("\u{9009}\u{62e9}", "DECISION"), ("\u{5207}\u{6362}", "DECISION"), ("\u{8fc1}\u{79fb}", "DECISION"), ("\u{66ff}\u{6362}", "DECISION"), ("\u{63a8}\u{8350}", "DECISION"), ("\u{56e0}\u{4e3a}", "DECISION"), ("\u{521b}\u{5efa}", "ORIGIN"), ("\u{521b}\u{7acb}", "ORIGIN"), ("\u{5f00}\u{59cb}", "ORIGIN"), ("\u{7b2c}\u{4e00}\u{6b21}", "ORIGIN"), ("\u{6838}\u{5fc3}", "CORE"), ("\u{57fa}\u{672c}", "CORE"), ("\u{539f}\u{5219}", "CORE"), ("\u{4fe1}\u{5ff5}", "CORE"), ("\u{8f6c}\u{6298}", "PIVOT"), ("\u{7a81}\u{7834}", "PIVOT"), ("\u{987f}\u{609f}", "PIVOT"), ("\u{63a5}\u{53e3}", "TECHNICAL"), ("\u{6570}\u{636e}\u{5e93}", "TECHNICAL"), ("\u{67b6}\u{6784}", "TECHNICAL"), ("\u{90e8}\u{7f72}", "TECHNICAL"), ("\u{6846}\u{67b6}", "TECHNICAL"), ("\u{670d}\u{52a1}\u{5668}", "TECHNICAL"), ("\u{914d}\u{7f6e}", "TECHNICAL"), ("\u{8ba4}\u{8bc1}", "TECHNICAL"), ("\u{5bc6}\u{7801}", "SENSITIVE"), ("\u{5bc6}\u{94a5}", "SENSITIVE"), ("\u{51ed}\u{8bc1}", "SENSITIVE"), ("\u{9690}\u{79c1}", "SENSITIVE"), ];
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 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 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("e, 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}', '\u{FF01}', '\u{FF1F}', '\u{FF1B}', '\u{FF0C}', ])
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 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}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}' )
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
579fn 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 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
611fn 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 if tag.tag.starts_with("nr") || tag.tag == "ns" || tag.tag == "nt" || tag.tag == "nz" {
623 continue;
624 }
625 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 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 matches!(
649 word,
650 "\u{6211}\u{4eec}" | "\u{4f60}\u{4eec}" | "\u{4ed6}\u{4eec}" | "\u{5979}\u{4eec}" | "\u{5b83}\u{4eec}" | "\u{8fd9}\u{4e2a}" | "\u{90a3}\u{4e2a}" | "\u{8fd9}\u{4e9b}" | "\u{90a3}\u{4e9b}" | "\u{4e3a}\u{4ec0}\u{4e48}" | "\u{600e}\u{4e48}" | "\u{4ec0}\u{4e48}" | "\u{56e0}\u{4e3a}" | "\u{6240}\u{4ee5}" | "\u{4f46}\u{662f}" )
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}