1use crate::router::{self, PromptFrame, RouteDecision, RouteOptions, RouteTarget};
51use cortiq_core::knowledge::{
52 LOOKUP_MAX_NGRAM, LookupInfo, lookup_leaf, lookup_policy, lookup_tensor_name, normalize_key,
53 normalized_key_hash, read_u32_le, read_u64_le, skill_kind,
54};
55use cortiq_core::{CmfModel, SkillRecord};
56use std::collections::BTreeMap;
57use std::sync::{Arc, Mutex};
58
59pub const LOOKUP_MODE_ENV: &str = "CMF_LOOKUP_MODE";
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum LookupMode {
67 #[default]
70 Answer,
71 Context,
74 Off,
76}
77
78impl LookupMode {
79 pub fn parse(s: &str) -> Result<Self, String> {
81 match s.trim().to_ascii_lowercase().as_str() {
82 "answer" => Ok(Self::Answer),
83 "context" => Ok(Self::Context),
84 "off" | "none" => Ok(Self::Off),
85 other => Err(format!(
86 "lookup mode '{other}': expected answer | context | off"
87 )),
88 }
89 }
90
91 pub fn from_env() -> Result<Self, String> {
93 match std::env::var(LOOKUP_MODE_ENV) {
94 Ok(v) if !v.trim().is_empty() => {
95 Self::parse(&v).map_err(|e| format!("{LOOKUP_MODE_ENV}: {e}"))
96 }
97 _ => Ok(Self::Answer),
98 }
99 }
100
101 pub fn resolve(flag: Option<&str>) -> Result<Self, String> {
103 match flag {
104 Some(f) => Self::parse(f).map_err(|e| format!("--lookup-mode: {e}")),
105 None => Self::from_env(),
106 }
107 }
108
109 pub fn label(self) -> &'static str {
110 match self {
111 Self::Answer => "answer",
112 Self::Context => "context",
113 Self::Off => "off",
114 }
115 }
116}
117
118pub const CONTEXT_HEADER: &str = "Справочная карточка / Reference card:";
120
121pub fn context_prompt(card: &str, user_text: &str) -> String {
123 format!("{CONTEXT_HEADER}\n{card}\n\n{user_text}")
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub enum LookupPolicy {
132 #[default]
137 RouterAndKey,
138 KeyFirst,
143}
144
145impl LookupPolicy {
146 pub fn parse(s: &str) -> Result<Self, String> {
148 match s {
149 lookup_policy::ROUTER_AND_KEY => Ok(Self::RouterAndKey),
150 lookup_policy::KEY_FIRST => Ok(Self::KeyFirst),
151 other => Err(format!(
152 "lookup policy '{other}': expected {}",
153 lookup_policy::ALL.join(" | ")
154 )),
155 }
156 }
157
158 pub fn of(info: &LookupInfo) -> Self {
165 Self::parse(info.policy_label()).unwrap_or_default()
166 }
167
168 pub fn is_known(info: &LookupInfo) -> bool {
171 Self::parse(info.policy_label()).is_ok()
172 }
173
174 pub fn label(self) -> &'static str {
175 match self {
176 Self::RouterAndKey => lookup_policy::ROUTER_AND_KEY,
177 Self::KeyFirst => lookup_policy::KEY_FIRST,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub enum DecidedBy {
186 #[default]
189 Router,
190 KeyFirst,
193}
194
195impl DecidedBy {
196 pub fn label(self) -> &'static str {
197 match self {
198 Self::Router => "router",
199 Self::KeyFirst => "key_first",
200 }
201 }
202}
203
204pub const FIELDS: &[&str] = &[
211 "family",
212 "parts",
213 "compounds",
214 "safety",
215 "evidence",
216 "preparations",
217 "uses",
218];
219
220pub fn select_field(question: &str) -> Option<&'static str> {
235 let norm = normalize_key(question);
236 let words: Vec<&str> = norm.split(' ').filter(|w| !w.is_empty()).collect();
237 let prefix = |p: &str| words.iter().any(|w| w.starts_with(p));
238 let word = |x: &str| words.iter().any(|w| *w == x);
239 let bigram = |a: &str, b: &str| words.windows(2).any(|w| w[0] == a && w[1].starts_with(b));
240 if prefix("семейств") || word("family") || word("families") {
241 return Some("family");
242 }
243 if ["части", "часть", "частей", "частям", "частях", "частью", "частями"]
244 .iter()
245 .any(|w| word(w))
246 || word("part")
247 || word("parts")
248 {
249 return Some("parts");
250 }
251 if prefix("веществ")
252 || prefix("соединен")
253 || prefix("состав")
254 || prefix("компонент")
255 || prefix("ингредиент")
256 || prefix("compound")
257 || prefix("constituent")
258 || prefix("ingredient")
259 || prefix("chemical")
260 {
261 return Some("compounds");
262 }
263 if prefix("противопоказ")
264 || prefix("безопас")
265 || prefix("побочн")
266 || prefix("опасн")
267 || prefix("ядовит")
268 || prefix("токсич")
269 || prefix("contraindic")
270 || prefix("toxic")
271 || prefix("poison")
272 || word("safety")
273 || word("safe")
274 || word("risk")
275 || word("risks")
276 || bigram("side", "effect")
277 {
278 return Some("safety");
279 }
280 if prefix("доказ")
281 || prefix("исследован")
282 || word("evidence")
283 || word("study")
284 || word("studies")
285 || word("research")
286 || word("clinical")
287 || word("trial")
288 || word("trials")
289 {
290 return Some("evidence");
291 }
292 if prefix("форм")
293 || prefix("препарат")
294 || prefix("дозиров")
295 || prefix("preparation")
296 || prefix("dosage")
297 || word("form")
298 || word("forms")
299 || word("dose")
300 || word("doses")
301 {
302 return Some("preparations");
303 }
304 if prefix("примен") || word("use") || word("uses") || word("used") || word("usage") {
305 return Some("uses");
306 }
307 None
308}
309
310pub fn has_cyrillic(s: &str) -> bool {
312 s.chars().any(|c| ('\u{0400}'..='\u{052F}').contains(&c))
313}
314
315pub fn pick_lang(question: &str, langs: &[String]) -> usize {
319 let want = if has_cyrillic(question) { "ru" } else { "en" };
320 langs
321 .iter()
322 .position(|l| l == want)
323 .or_else(|| langs.iter().position(|l| l == "en"))
324 .unwrap_or(0)
325}
326
327pub const MAX_NGRAM: usize = LOOKUP_MAX_NGRAM;
333
334pub const MEMORY_TURNS: usize = 6;
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum KeySource {
342 Parenthesised,
344 Binomial,
347 Ngram,
349}
350
351impl KeySource {
352 pub fn label(self) -> &'static str {
353 match self {
354 Self::Parenthesised => "parenthesised",
355 Self::Binomial => "binomial",
356 Self::Ngram => "n-gram",
357 }
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum MatchVia {
366 Exact,
367 Stem,
368}
369
370impl MatchVia {
371 pub fn label(self) -> &'static str {
372 match self {
373 Self::Exact => "exact",
374 Self::Stem => "stem",
375 }
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct KeyHit {
382 pub key: String,
385 pub hash: u64,
388 pub entry: u32,
390 pub words: usize,
392 pub source: KeySource,
393 pub via: MatchVia,
394 pub stem: Option<String>,
396 pub turn: usize,
399 pub strong: bool,
402}
403
404fn parenthesised(s: &str) -> Vec<&str> {
410 let mut out = Vec::new();
411 let mut rest = s;
412 while let Some(open) = rest.find('(') {
413 let after = &rest[open + 1..];
414 let Some(close) = after.find(')') else {
415 break;
416 };
417 let group = &after[..close];
418 if let Some(inner) = group.rfind('(') {
419 out.push(&group[inner + 1..]);
420 }
421 out.push(group);
422 rest = &after[close + 1..];
423 }
424 out
425}
426
427fn is_latin_char(c: char) -> bool {
430 c.is_ascii_alphabetic()
431 || (c.is_alphabetic() && matches!(c, '\u{00C0}'..='\u{024F}' | '\u{1E00}'..='\u{1EFF}'))
432}
433
434fn is_latin_word(w: &str) -> bool {
438 !w.is_empty() && w.chars().all(is_latin_char)
439}
440
441fn lower_latin_word(t: &str, min: usize) -> bool {
443 t.chars().count() >= min && t.chars().all(|c| c.is_lowercase() && is_latin_char(c))
444}
445
446pub fn capitalised_binomials(s: &str) -> Vec<String> {
455 let genus = |t: &str| {
456 let mut cs = t.chars();
457 matches!(cs.next(), Some(c) if c.is_uppercase() && is_latin_char(c))
458 && lower_latin_word(cs.as_str(), 2)
459 };
460 let epithet = |t: &str| match t.split_once('-') {
461 None => lower_latin_word(t, 3),
462 Some((a, b)) => lower_latin_word(a, 3) && lower_latin_word(b, 2),
463 };
464 let toks: Vec<&str> = s
465 .split_whitespace()
466 .map(|t| t.trim_matches(|c: char| !is_latin_char(c)))
467 .collect();
468 toks.windows(2)
469 .filter(|w| genus(w[0]) && epithet(w[1]))
470 .map(|w| format!("{} {}", w[0], w[1]))
471 .collect()
472}
473
474pub const RU_SUFFIXES: &[&str] = &[
480 "ого", "его", "ому", "ему", "ыми", "ими", "ами", "ями", "ая", "яя", "ой", "ей", "ий", "ый", "ое", "ее", "ые", "ых", "их", "ую", "юю", "ою", "ею",
482 "ам", "ям", "ах", "ях", "ом", "ем", "ым", "им", "ов", "ев", "ии", "ия", "ие", "ье", "ья",
483 "ью", "а", "я", "ы", "и", "у", "ю", "о", "е", "ь", "й", ];
486pub const RU_STEM_MIN: usize = 3;
489pub const EN_STEM_MIN: usize = 4;
491pub const LATIN_STEM_MIN_WORD: usize = 5;
494pub const STEM_UNIGRAM_MIN: usize = 5;
498
499pub fn stem_word(w: &str) -> String {
512 if w.is_empty() || !w.chars().all(char::is_alphabetic) {
513 return w.to_string();
514 }
515 if has_cyrillic(w) {
516 stem_cyrillic(w)
517 } else {
518 stem_latin(w)
519 }
520}
521
522fn stem_cyrillic(w: &str) -> String {
523 let mut s = w.to_string();
524 let mut n = s.chars().count();
525 'strip: loop {
526 for suf in RU_SUFFIXES {
527 let k = suf.chars().count();
528 if n >= k + RU_STEM_MIN && s.ends_with(suf) {
529 s.truncate(s.len() - suf.len());
530 n -= k;
531 continue 'strip;
532 }
533 }
534 return s;
535 }
536}
537
538fn stem_latin(w: &str) -> String {
539 let n = w.chars().count();
540 if n < LATIN_STEM_MIN_WORD {
541 return w.to_string();
542 }
543 let mut s = w.to_string();
544 if s.ends_with("ies") && n - 3 >= EN_STEM_MIN {
545 s.truncate(s.len() - 3);
546 s.push('i');
547 } else if ["sses", "xes", "zes", "ches", "shes"]
548 .iter()
549 .any(|e| s.ends_with(e))
550 && n - 2 >= EN_STEM_MIN
551 {
552 s.truncate(s.len() - 2);
553 } else if s.ends_with('s')
554 && !["ss", "us", "is"].iter().any(|e| s.ends_with(e))
555 && n - 1 >= EN_STEM_MIN
556 {
557 s.pop();
558 } else if s.ends_with('y') {
559 s.pop();
560 s.push('i');
561 }
562 s
563}
564
565pub fn stem_key(norm: &str) -> String {
568 let mut out = String::with_capacity(norm.len());
569 for w in norm.split(' ').filter(|w| !w.is_empty()) {
570 if !out.is_empty() {
571 out.push(' ');
572 }
573 out.push_str(&stem_word(w));
574 }
575 out
576}
577
578fn word_spans(norm: &str) -> Vec<(usize, usize)> {
580 let mut out = Vec::new();
581 let mut start = None;
582 for (i, c) in norm.char_indices() {
583 if c == ' ' {
584 if let Some(s) = start.take() {
585 out.push((s, i));
586 }
587 } else if start.is_none() {
588 start = Some(i);
589 }
590 }
591 if let Some(s) = start {
592 out.push((s, norm.len()));
593 }
594 out
595}
596
597#[derive(Debug, Clone, Default, PartialEq, Eq)]
604pub struct StemIndex {
605 hashes: Vec<u64>,
606 entry_of: Vec<u32>,
607 pub ambiguous: usize,
609 pub keys_in: usize,
611}
612
613impl StemIndex {
614 pub fn from_keys<'a>(keys: impl IntoIterator<Item = (&'a str, u32)>) -> Self {
616 let mut pairs: Vec<(u64, u32)> = keys
617 .into_iter()
618 .map(|(k, e)| (normalized_key_hash(&stem_key(k)), e))
619 .collect();
620 let keys_in = pairs.len();
621 pairs.sort_unstable();
622 pairs.dedup();
623 let mut hashes = Vec::with_capacity(pairs.len());
624 let mut entry_of = Vec::with_capacity(pairs.len());
625 let mut ambiguous = 0;
626 let mut i = 0;
627 while i < pairs.len() {
628 let mut j = i;
629 while j < pairs.len() && pairs[j].0 == pairs[i].0 {
630 j += 1;
631 }
632 if j - i == 1 {
633 hashes.push(pairs[i].0);
634 entry_of.push(pairs[i].1);
635 } else {
636 ambiguous += 1;
637 }
638 i = j;
639 }
640 Self {
641 hashes,
642 entry_of,
643 ambiguous,
644 keys_in,
645 }
646 }
647
648 pub fn find(&self, h: u64) -> Option<u32> {
650 self.hashes
651 .binary_search(&h)
652 .ok()
653 .map(|i| self.entry_of[i])
654 }
655
656 pub fn len(&self) -> usize {
658 self.hashes.len()
659 }
660
661 pub fn is_empty(&self) -> bool {
662 self.hashes.is_empty()
663 }
664}
665
666pub fn recover_key_texts(
675 hashes: &[u64],
676 entry_of: &[u32],
677 blob: &[u8],
678 offsets: &[u64],
679) -> Vec<(String, u32)> {
680 let find = |h: u64| hashes.binary_search(&h).ok().map(|i| entry_of[i]);
681 let mut seen: BTreeMap<u64, (String, u32)> = BTreeMap::new();
682 'slots: for w in offsets.windows(2) {
683 let (a, b) = (w[0] as usize, w[1] as usize);
684 if a > b || b > blob.len() {
685 continue;
686 }
687 let Ok(text) = std::str::from_utf8(&blob[a..b]) else {
688 continue;
689 };
690 let norm = normalize_key(text);
691 let spans = word_spans(&norm);
692 for n in 1..=MAX_NGRAM.min(spans.len()) {
693 for start in 0..=spans.len() - n {
694 let g = &norm[spans[start].0..spans[start + n - 1].1];
695 let h = normalized_key_hash(g);
696 if seen.contains_key(&h) {
697 continue;
698 }
699 if let Some(e) = find(h) {
700 seen.insert(h, (g.to_string(), e));
701 if seen.len() == hashes.len() {
702 break 'slots;
703 }
704 }
705 }
706 }
707 }
708 seen.into_values().collect()
709}
710
711pub fn extract_key(message: &str, find: &dyn Fn(u64) -> Option<u32>) -> Option<KeyHit> {
713 extract_key_with(message, find, None)
714}
715
716pub fn extract_key_with(
737 message: &str,
738 find: &dyn Fn(u64) -> Option<u32>,
739 find_stem: Option<&dyn Fn(u64) -> Option<u32>>,
740) -> Option<KeyHit> {
741 extract(message, find, find_stem, false)
742}
743
744pub const STRONG_KEY_MIN_WORDS: usize = 2;
747
748pub const STRONG_WORD_MIN_LETTERS: usize = 3;
751
752pub fn is_strong_key_text(norm: &str) -> bool {
763 let mut words = 0usize;
764 for w in norm.split(' ').filter(|w| !w.is_empty()) {
765 if w.chars().any(char::is_numeric) {
766 return false;
767 }
768 if w.chars().count() >= STRONG_WORD_MIN_LETTERS && w.chars().all(char::is_alphabetic) {
769 words += 1;
770 }
771 }
772 words >= STRONG_KEY_MIN_WORDS
773}
774
775pub fn is_strong_key(hit: &KeyHit) -> bool {
792 hit.strong
793}
794
795pub fn extract_strong_key_with(
803 message: &str,
804 find: &dyn Fn(u64) -> Option<u32>,
805 find_stem: Option<&dyn Fn(u64) -> Option<u32>>,
806) -> Option<KeyHit> {
807 extract(message, find, find_stem, true)
808}
809
810fn offer(fallback: &mut Option<KeyHit>, hit: KeyHit, strong_only: bool) -> Option<KeyHit> {
813 if hit.strong {
814 return Some(hit);
815 }
816 if !strong_only && fallback.is_none() {
817 *fallback = Some(hit);
818 }
819 None
820}
821
822fn extract(
828 message: &str,
829 find: &dyn Fn(u64) -> Option<u32>,
830 find_stem: Option<&dyn Fn(u64) -> Option<u32>>,
831 strong_only: bool,
832) -> Option<KeyHit> {
833 let mut fallback: Option<KeyHit> = None;
834 let exact_hit = |key: String, hash: u64, entry: u32, words: usize, source, strong| KeyHit {
835 key,
836 hash,
837 entry,
838 words,
839 source,
840 via: MatchVia::Exact,
841 stem: None,
842 turn: 0,
843 strong,
844 };
845 for group in parenthesised(message) {
846 let norm = normalize_key(group);
847 let words: Vec<&str> = norm.split(' ').filter(|w| !w.is_empty()).collect();
848 if words.is_empty() || words.len() > MAX_NGRAM || !words.iter().all(|w| is_latin_word(w)) {
849 continue;
850 }
851 let n_words = words.len();
852 let strong = is_strong_key_text(&norm);
853 if !strong && (strong_only || fallback.is_some()) {
854 continue;
855 }
856 let hash = normalized_key_hash(&norm);
857 if let Some(entry) = find(hash) {
858 let hit = exact_hit(norm, hash, entry, n_words, KeySource::Parenthesised, strong);
859 if let Some(h) = offer(&mut fallback, hit, strong_only) {
860 return Some(h);
861 }
862 }
863 }
864 for pair in capitalised_binomials(message) {
865 let norm = normalize_key(&pair);
866 let n_words = norm.split(' ').filter(|w| !w.is_empty()).count();
867 if n_words == 0 || n_words > MAX_NGRAM {
868 continue;
869 }
870 let strong = is_strong_key_text(&norm);
871 if !strong && (strong_only || fallback.is_some()) {
872 continue;
873 }
874 let hash = normalized_key_hash(&norm);
875 if let Some(entry) = find(hash) {
876 let hit = exact_hit(norm, hash, entry, n_words, KeySource::Binomial, strong);
877 if let Some(h) = offer(&mut fallback, hit, strong_only) {
878 return Some(h);
879 }
880 }
881 }
882 let norm = normalize_key(message);
883 let spans = word_spans(&norm);
884 if spans.is_empty() {
885 return fallback;
886 }
887 let text = |start: usize, n: usize| &norm[spans[start].0..spans[start + n - 1].1];
888 for n in (1..=MAX_NGRAM.min(spans.len())).rev() {
890 for start in 0..=spans.len() - n {
891 let g = text(start, n);
892 let strong = n >= STRONG_KEY_MIN_WORDS && is_strong_key_text(g);
893 if !strong && (strong_only || fallback.is_some()) {
894 continue;
895 }
896 let hash = normalized_key_hash(g);
897 if let Some(entry) = find(hash) {
898 let hit = exact_hit(g.to_string(), hash, entry, n, KeySource::Ngram, strong);
899 if let Some(h) = offer(&mut fallback, hit, strong_only) {
900 return Some(h);
901 }
902 }
903 }
904 }
905 let Some(find_stem) = find_stem else {
906 return fallback;
907 };
908 let stemmed = stem_key(&norm);
909 let sspans = word_spans(&stemmed);
910 debug_assert_eq!(sspans.len(), spans.len());
913 if sspans.len() != spans.len() {
914 return fallback;
915 }
916 for n in (1..=MAX_NGRAM.min(sspans.len())).rev() {
919 for start in 0..=sspans.len() - n {
920 let strong = n >= STRONG_KEY_MIN_WORDS && is_strong_key_text(text(start, n));
922 if !strong && (strong_only || fallback.is_some()) {
923 continue;
924 }
925 let g = &stemmed[sspans[start].0..sspans[start + n - 1].1];
926 if n == 1 && g.chars().count() < STEM_UNIGRAM_MIN {
927 continue;
928 }
929 let hash = normalized_key_hash(g);
930 if let Some(entry) = find_stem(hash) {
931 let hit = KeyHit {
932 key: text(start, n).to_string(),
933 hash,
934 entry,
935 words: n,
936 source: KeySource::Ngram,
937 via: MatchVia::Stem,
938 stem: Some(g.to_string()),
939 turn: 0,
940 strong,
941 };
942 if let Some(h) = offer(&mut fallback, hit, strong_only) {
943 return Some(h);
944 }
945 }
946 }
947 }
948 fallback
949}
950
951#[derive(Debug, Clone, Default, PartialEq, Eq)]
955pub struct Card {
956 pub card: String,
957 pub fields: BTreeMap<String, String>,
958}
959
960impl Card {
961 pub fn parse(text: &str) -> Result<Self, String> {
965 let v: serde_json::Value =
966 serde_json::from_str(text).map_err(|e| format!("card is not JSON: {e}"))?;
967 let obj = v.as_object().ok_or("card is not a JSON object")?;
968 let card = obj
969 .get("card")
970 .map(|c| match c {
971 serde_json::Value::String(s) => s.clone(),
972 other => other.to_string(),
973 })
974 .unwrap_or_default();
975 let mut fields = BTreeMap::new();
976 if let Some(serde_json::Value::Object(f)) = obj.get("fields") {
977 for (k, v) in f {
978 let text = match v {
979 serde_json::Value::String(s) => s.clone(),
980 serde_json::Value::Null => continue,
981 other => other.to_string(),
982 };
983 fields.insert(k.clone(), text);
984 }
985 }
986 Ok(Self { card, fields })
987 }
988
989 pub fn is_empty(&self) -> bool {
992 self.card.trim().is_empty() && self.fields.values().all(|t| t.trim().is_empty())
993 }
994
995 pub fn first_sentence(&self) -> &str {
1002 first_sentence(&self.card)
1003 }
1004}
1005
1006pub const FIRST_SENTENCE_MIN: usize = 12;
1008pub const FIRST_SENTENCE_MAX: usize = 240;
1010
1011fn first_sentence(card: &str) -> &str {
1012 let card = card.trim();
1013 let bytes = card.as_bytes();
1014 let mut chars_seen = 0usize;
1015 let mut word_start = 0usize;
1016 for (i, c) in card.char_indices() {
1017 chars_seen += 1;
1018 if c.is_whitespace() {
1019 word_start = i + c.len_utf8();
1020 continue;
1021 }
1022 if matches!(c, '.' | '!' | '?') {
1023 let next = bytes.get(i + 1).copied();
1024 let ends = next.is_none_or(|b| b.is_ascii_whitespace());
1025 let word = &card[word_start..i];
1028 let abbrev = word.chars().count() == 1 && word.chars().all(char::is_alphabetic);
1029 if ends && !abbrev && chars_seen >= FIRST_SENTENCE_MIN {
1030 return &card[..i + 1];
1031 }
1032 }
1033 }
1034 if card.chars().count() <= FIRST_SENTENCE_MAX {
1035 return card;
1036 }
1037 let cut = card
1038 .char_indices()
1039 .nth(FIRST_SENTENCE_MAX)
1040 .map_or(card.len(), |(i, _)| i);
1041 let head = &card[..cut];
1042 head.rfind(char::is_whitespace)
1043 .map_or(head, |w| &head[..w])
1044 .trim_end()
1045}
1046
1047pub fn field_label(field: &str, lang: &str) -> String {
1051 let ru = match field {
1052 "family" => "Семейство",
1053 "parts" => "Части",
1054 "compounds" => "Действующие вещества",
1055 "uses" => "Применение",
1056 "preparations" => "Формы и препараты",
1057 "safety" => "Безопасность",
1058 "evidence" => "Доказательства",
1059 _ => "",
1060 };
1061 let en = match field {
1062 "family" => "Family",
1063 "parts" => "Parts",
1064 "compounds" => "Compounds",
1065 "uses" => "Uses",
1066 "preparations" => "Preparations",
1067 "safety" => "Safety",
1068 "evidence" => "Evidence",
1069 _ => "",
1070 };
1071 match (lang, ru, en) {
1072 ("ru", r, _) if !r.is_empty() => r.to_string(),
1073 ("en", _, e) if !e.is_empty() => e.to_string(),
1074 _ => {
1075 let mut c = field.chars();
1076 match c.next() {
1077 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1078 None => String::new(),
1079 }
1080 }
1081 }
1082}
1083
1084pub fn context_excerpt(card: &Card, field: Option<&str>, lang: &str) -> String {
1091 match field.and_then(|f| card.fields.get(f).map(|t| (f, t))) {
1092 Some((f, text)) => {
1093 let head = card.first_sentence();
1094 let label = field_label(f, lang);
1095 if head.is_empty() {
1096 format!("{label}: {}", text.trim())
1097 } else {
1098 format!("{head}\n{label}: {}", text.trim())
1099 }
1100 }
1101 None => card.card.clone(),
1102 }
1103}
1104
1105pub struct LookupTable {
1109 pub id: String,
1110 pub info: LookupInfo,
1111 hashes: Vec<u64>,
1113 entry_of: Vec<u32>,
1115 offsets: Vec<u64>,
1117 stems: StemIndex,
1120 recovered: usize,
1122 model: Arc<CmfModel>,
1123 text_name: String,
1124}
1125
1126impl LookupTable {
1127 pub fn record<'a>(model: &'a CmfModel, id: &str) -> Option<&'a SkillRecord> {
1129 model
1130 .header
1131 .skills
1132 .iter()
1133 .find(|s| s.id == id && s.kind.as_deref() == Some(skill_kind::LOOKUP))
1134 }
1135
1136 pub fn is_lookup(model: &CmfModel, id: &str) -> bool {
1137 Self::record(model, id).is_some()
1138 }
1139
1140 pub fn lookup_ids(model: &CmfModel) -> Vec<String> {
1142 model
1143 .header
1144 .skills
1145 .iter()
1146 .filter(|s| s.kind.as_deref() == Some(skill_kind::LOOKUP))
1147 .map(|s| s.id.clone())
1148 .collect()
1149 }
1150
1151 pub fn open(model: &Arc<CmfModel>, id: &str) -> Result<Self, String> {
1155 let rec = Self::record(model, id).ok_or_else(|| {
1156 format!(
1157 "skill '{id}' is not a lookup record (header.skills: {:?})",
1158 model
1159 .header
1160 .skills
1161 .iter()
1162 .map(|s| format!("{}={}", s.id, s.kind.as_deref().unwrap_or("v1")))
1163 .collect::<Vec<_>>()
1164 )
1165 })?;
1166 let info = rec
1167 .lookup
1168 .clone()
1169 .ok_or_else(|| format!("skill '{id}': lookup record without `lookup`"))?;
1170 fn bytes<'m>(model: &'m CmfModel, id: &str, leaf: &str) -> Result<&'m [u8], String> {
1171 let name = lookup_tensor_name(id, leaf);
1172 model
1173 .tensor_bytes(&name)
1174 .map_err(|e| format!("skill '{id}': tensor '{name}': {e}"))
1175 }
1176 let hashes = read_u64_le(bytes(model, id, lookup_leaf::KEYS_HASH)?);
1177 let entry_of = read_u32_le(bytes(model, id, lookup_leaf::KEYS_ENTRY)?);
1178 let offsets = read_u64_le(bytes(model, id, lookup_leaf::ENTRIES_OFF)?);
1179 let text_name = lookup_tensor_name(id, lookup_leaf::TEXT);
1180 let blob = bytes(model, id, lookup_leaf::TEXT)?;
1181 let blob_len = blob.len();
1182 if hashes.len() != info.keys || entry_of.len() != info.keys {
1183 return Err(format!(
1184 "skill '{id}': {} hashes / {} entries for lookup.keys {}",
1185 hashes.len(),
1186 entry_of.len(),
1187 info.keys
1188 ));
1189 }
1190 let slots = info
1191 .slots()
1192 .ok_or_else(|| format!("skill '{id}': entries × langs overflows"))?;
1193 if offsets.len() != slots + 1 {
1194 return Err(format!(
1195 "skill '{id}': {} offsets for {slots} slots (+1)",
1196 offsets.len()
1197 ));
1198 }
1199 if offsets.last().is_some_and(|&l| l as usize > blob_len) {
1200 return Err(format!(
1201 "skill '{id}': offsets end beyond the text blob ({blob_len} bytes)"
1202 ));
1203 }
1204 let recovered_keys = recover_key_texts(&hashes, &entry_of, blob, &offsets);
1207 let stems = StemIndex::from_keys(recovered_keys.iter().map(|(k, e)| (k.as_str(), *e)));
1208 Ok(Self {
1209 id: id.to_string(),
1210 info,
1211 hashes,
1212 entry_of,
1213 offsets,
1214 stems,
1215 recovered: recovered_keys.len(),
1216 model: model.clone(),
1217 text_name,
1218 })
1219 }
1220
1221 pub fn keys(&self) -> usize {
1222 self.hashes.len()
1223 }
1224
1225 pub fn recovered_keys(&self) -> usize {
1228 self.recovered
1229 }
1230
1231 pub fn stems(&self) -> &StemIndex {
1233 &self.stems
1234 }
1235
1236 pub fn entries(&self) -> usize {
1237 self.info.entries
1238 }
1239
1240 pub fn langs(&self) -> &[String] {
1241 &self.info.langs
1242 }
1243
1244 pub fn fields(&self) -> &[String] {
1245 &self.info.fields
1246 }
1247
1248 pub fn blob(&self) -> &[u8] {
1250 self.model
1251 .tensor_bytes(&self.text_name)
1252 .expect("lookup text tensor was read at open")
1253 }
1254
1255 pub fn find_hash(&self, h: u64) -> Option<u32> {
1257 self.hashes
1258 .binary_search(&h)
1259 .ok()
1260 .map(|i| self.entry_of[i])
1261 }
1262
1263 pub fn find_stem_hash(&self, h: u64) -> Option<u32> {
1265 self.stems.find(h)
1266 }
1267
1268 pub fn slot_text(&self, entry: u32, lang: usize) -> Option<&str> {
1270 let s = self.info.slot(entry as usize, lang)?;
1271 let (a, b) = (self.offsets[s] as usize, self.offsets[s + 1] as usize);
1272 std::str::from_utf8(&self.blob()[a..b]).ok()
1273 }
1274
1275 pub fn card(&self, entry: u32, lang: usize) -> Result<Card, String> {
1277 let text = self.slot_text(entry, lang).ok_or_else(|| {
1278 format!(
1279 "skill '{}': no slot for entry {entry}, lang {lang} (entries {}, langs {:?})",
1280 self.id, self.info.entries, self.info.langs
1281 )
1282 })?;
1283 Card::parse(text).map_err(|e| format!("skill '{}': entry {entry}: {e}", self.id))
1284 }
1285
1286 pub fn find_key(&self, message: &str) -> Option<KeyHit> {
1291 extract_key_with(message, &|h| self.find_hash(h), Some(&|h| self.find_stem_hash(h)))
1292 }
1293
1294 pub fn find_strong_key(&self, message: &str) -> Option<KeyHit> {
1297 extract_strong_key_with(message, &|h| self.find_hash(h), Some(&|h| self.find_stem_hash(h)))
1298 }
1299
1300 pub fn policy(&self) -> LookupPolicy {
1303 LookupPolicy::of(&self.info)
1304 }
1305
1306 pub fn answer(&self, message: &str) -> Result<Option<LookupAnswer>, String> {
1313 Ok(match self.find_answer(message)? {
1314 Found::Answer(a) => Some(a),
1315 Found::NoKey | Found::EmptyCard(_) => None,
1316 })
1317 }
1318
1319 pub fn find_answer(&self, message: &str) -> Result<Found, String> {
1322 self.find_answer_turns(&[message])
1323 }
1324
1325 pub fn find_answer_turns(&self, turns: &[&str]) -> Result<Found, String> {
1335 let Some((turn, mut key)) = turns
1336 .iter()
1337 .enumerate()
1338 .find_map(|(i, t)| self.find_key(t).map(|k| (i, k)))
1339 else {
1340 return Ok(Found::NoKey);
1341 };
1342 key.turn = turn;
1343 self.answer_for_key(key, turns[0])
1344 }
1345
1346 pub fn answer_for_key(&self, key: KeyHit, message: &str) -> Result<Found, String> {
1352 let want = pick_lang(message, &self.info.langs);
1353 let order = std::iter::once(want).chain((0..self.info.langs.len()).filter(|&l| l != want));
1354 let mut chosen = None;
1355 for lang_i in order {
1356 let card = self.card(key.entry, lang_i)?;
1357 if !card.is_empty() {
1358 chosen = Some((lang_i, card));
1359 break;
1360 }
1361 }
1362 let Some((lang_i, card)) = chosen else {
1363 return Ok(Found::EmptyCard(key));
1364 };
1365 let lang = self.info.langs[lang_i].clone();
1366 let field = select_field(message)
1367 .filter(|f| card.fields.get(*f).is_some_and(|t| !t.trim().is_empty()))
1368 .map(str::to_string);
1369 let text = match &field {
1370 Some(f) => card.fields[f].clone(),
1371 None => card.card.clone(),
1372 };
1373 let context = context_excerpt(&card, field.as_deref(), &lang);
1374 Ok(Found::Answer(LookupAnswer {
1375 id: self.id.clone(),
1376 key,
1377 lang,
1378 field,
1379 text,
1380 card: context,
1381 full_card: card.card,
1382 decided_by: DecidedBy::Router,
1383 }))
1384 }
1385}
1386
1387#[derive(Debug, Clone, PartialEq, Eq)]
1389pub enum Found {
1390 NoKey,
1392 EmptyCard(KeyHit),
1396 Answer(LookupAnswer),
1397}
1398
1399#[derive(Debug, Clone, PartialEq, Eq)]
1401pub struct LookupAnswer {
1402 pub id: String,
1404 pub key: KeyHit,
1405 pub lang: String,
1407 pub field: Option<String>,
1409 pub text: String,
1411 pub card: String,
1414 pub full_card: String,
1416 pub decided_by: DecidedBy,
1419}
1420
1421impl LookupAnswer {
1422 pub fn describe(&self) -> String {
1426 format!(
1427 "lookup: {} | key {:?}{} → entry {} ({} words, {}, {}){} | {} | field {} | decided by {}",
1428 self.id,
1429 self.key.key,
1430 self.key
1431 .stem
1432 .as_deref()
1433 .map(|s| format!(" (stem {s:?})"))
1434 .unwrap_or_default(),
1435 self.key.entry,
1436 self.key.words,
1437 self.key.source.label(),
1438 self.key.via.label(),
1439 if self.key.turn > 0 {
1440 format!(" | {} turns back", self.key.turn)
1441 } else {
1442 String::new()
1443 },
1444 self.lang,
1445 self.field.as_deref().unwrap_or("— (whole card)"),
1446 self.decided_by.label()
1447 )
1448 }
1449}
1450
1451pub struct LookupTables {
1454 model: Arc<CmfModel>,
1455 tables: Mutex<BTreeMap<String, Arc<LookupTable>>>,
1456}
1457
1458impl LookupTables {
1459 pub fn new(model: Arc<CmfModel>) -> Self {
1460 Self {
1461 model,
1462 tables: Mutex::new(BTreeMap::new()),
1463 }
1464 }
1465
1466 pub fn model(&self) -> &Arc<CmfModel> {
1467 &self.model
1468 }
1469
1470 pub fn any(&self) -> bool {
1472 !LookupTable::lookup_ids(&self.model).is_empty()
1473 }
1474
1475 pub fn ids(&self) -> Vec<String> {
1477 LookupTable::lookup_ids(&self.model)
1478 }
1479
1480 pub fn get(&self, id: &str) -> Result<Option<Arc<LookupTable>>, String> {
1482 if !LookupTable::is_lookup(&self.model, id) {
1483 return Ok(None);
1484 }
1485 let mut tables = self.tables.lock().unwrap_or_else(|e| e.into_inner());
1486 if let Some(t) = tables.get(id) {
1487 return Ok(Some(t.clone()));
1488 }
1489 let t = Arc::new(LookupTable::open(&self.model, id)?);
1490 tables.insert(id.to_string(), t.clone());
1491 Ok(Some(t))
1492 }
1493}
1494
1495#[derive(Debug, Clone, PartialEq, Eq)]
1499pub enum LookupOutcome {
1500 NotLookup,
1502 Off { id: String },
1505 Miss { id: String },
1508 Answer(LookupAnswer),
1510 Context(LookupAnswer),
1512}
1513
1514impl LookupOutcome {
1515 pub fn hit(&self) -> Option<&LookupAnswer> {
1517 match self {
1518 Self::Answer(a) | Self::Context(a) => Some(a),
1519 _ => None,
1520 }
1521 }
1522
1523 pub fn is_hit(&self) -> bool {
1524 self.hit().is_some()
1525 }
1526
1527 pub fn generation_text(&self, user_text: &str) -> String {
1530 match self {
1531 Self::Context(a) => context_prompt(&a.card, user_text),
1532 _ => user_text.to_string(),
1533 }
1534 }
1535
1536 pub fn describe(&self) -> Option<String> {
1538 Some(match self {
1539 Self::NotLookup => return None,
1540 Self::Off { id } => format!("lookup: {id} | mode off — the backbone runs"),
1541 Self::Miss { id } => {
1542 format!("lookup: {id} | no key in the message — the backbone runs unchanged")
1543 }
1544 Self::Answer(a) => format!("{} | mode answer", a.describe()),
1545 Self::Context(a) => format!("{} | mode context", a.describe()),
1546 })
1547 }
1548
1549 pub fn lookup_id(&self) -> Option<&str> {
1552 match self {
1553 Self::NotLookup => None,
1554 Self::Off { id } | Self::Miss { id } => Some(id),
1555 Self::Answer(a) | Self::Context(a) => Some(&a.id),
1556 }
1557 }
1558
1559 pub fn decided_by(&self) -> Option<DecidedBy> {
1564 match self {
1565 Self::NotLookup => None,
1566 Self::Off { .. } | Self::Miss { .. } => Some(DecidedBy::Router),
1567 Self::Answer(a) | Self::Context(a) => Some(a.decided_by),
1568 }
1569 }
1570
1571 pub fn annotate(&self, summary: &mut serde_json::Value, mode: LookupMode) {
1581 let serde_json::Value::Object(m) = summary else {
1582 return;
1583 };
1584 m.insert("lookup_hit".into(), serde_json::json!(self.is_hit()));
1585 if let Some(id) = self.lookup_id() {
1586 m.insert("lookup_mode".into(), serde_json::json!(mode.label()));
1587 m.insert("decided_target".into(), serde_json::json!(id));
1588 }
1589 if let Some(by) = self.decided_by() {
1590 m.insert("decided_by".into(), serde_json::json!(by.label()));
1591 }
1592 if let Some(a) = self.hit() {
1593 m.insert("lookup_key".into(), serde_json::json!(a.key.key));
1594 m.insert("lookup_entry".into(), serde_json::json!(a.key.entry));
1595 m.insert("lookup_lang".into(), serde_json::json!(a.lang));
1596 m.insert("field".into(), serde_json::json!(a.field));
1597 m.insert("lookup_match".into(), serde_json::json!(a.key.via.label()));
1598 m.insert("lookup_turn".into(), serde_json::json!(a.key.turn));
1599 m.insert("lookup_key_words".into(), serde_json::json!(a.key.words));
1600 }
1601 }
1602}
1603
1604pub fn resolve_lookup(
1612 tables: &LookupTables,
1613 decision: RouteDecision,
1614 user_text: &str,
1615 mode: LookupMode,
1616) -> Result<(RouteDecision, LookupOutcome), String> {
1617 resolve_lookup_turns(tables, decision, &[user_text], mode)
1618}
1619
1620pub fn resolve_lookup_turns(
1625 tables: &LookupTables,
1626 decision: RouteDecision,
1627 turns: &[&str],
1628 mode: LookupMode,
1629) -> Result<(RouteDecision, LookupOutcome), String> {
1630 let Some(id) = decision.skill().map(str::to_string) else {
1631 return Ok((decision, LookupOutcome::NotLookup));
1632 };
1633 let Some(table) = tables.get(&id)? else {
1634 return Ok((decision, LookupOutcome::NotLookup));
1635 };
1636 let to_backbone = |d: RouteDecision, why: &str| RouteDecision {
1637 target: RouteTarget::Backbone,
1638 routing: d.routing,
1639 reason: format!("{why} [decision was: {}]", d.reason),
1640 };
1641 if mode == LookupMode::Off {
1642 let d = to_backbone(
1643 decision,
1644 &format!("lookup '{id}' ignored (lookup mode off): the backbone runs"),
1645 );
1646 return Ok((d, LookupOutcome::Off { id }));
1647 }
1648 let answer = match table.find_answer_turns(turns)? {
1649 Found::Answer(a) => a,
1650 Found::NoKey => {
1651 let d = to_backbone(
1652 decision,
1653 &format!("lookup '{id}': no key in the message — the backbone runs unchanged"),
1654 );
1655 return Ok((d, LookupOutcome::Miss { id }));
1656 }
1657 Found::EmptyCard(key) => {
1658 let d = to_backbone(
1659 decision,
1660 &format!(
1661 "lookup '{id}': key {:?} → entry {} has no card in any language — the \
1662 backbone runs unchanged",
1663 key.key, key.entry
1664 ),
1665 );
1666 return Ok((d, LookupOutcome::Miss { id }));
1667 }
1668 };
1669 let mut d = decision;
1670 d.reason = format!(
1671 "{}; lookup key {:?} → entry {}{}{}{}",
1672 d.reason,
1673 answer.key.key,
1674 answer.key.entry,
1675 match answer.key.via {
1676 MatchVia::Exact => "",
1677 MatchVia::Stem => " (stem)",
1678 },
1679 if answer.key.turn > 0 {
1680 format!(" ({} turns back)", answer.key.turn)
1681 } else {
1682 String::new()
1683 },
1684 answer
1685 .field
1686 .as_deref()
1687 .map(|f| format!(", field {f}"))
1688 .unwrap_or_default()
1689 );
1690 let outcome = match mode {
1691 LookupMode::Answer => LookupOutcome::Answer(answer),
1692 LookupMode::Context => LookupOutcome::Context(answer),
1693 LookupMode::Off => unreachable!("handled above"),
1694 };
1695 Ok((d, outcome))
1696}
1697
1698#[derive(Debug, Clone, Copy)]
1704pub struct KeyFirstGate {
1705 pub opts: RouteOptions,
1710 pub frame: PromptFrame,
1715 pub can_render: bool,
1716}
1717
1718impl KeyFirstGate {
1719 pub fn new(opts: RouteOptions, frame: PromptFrame) -> Self {
1722 Self {
1723 opts,
1724 frame,
1725 can_render: false,
1726 }
1727 }
1728
1729 pub fn can_render(mut self, on: bool) -> Self {
1731 self.can_render = on;
1732 self
1733 }
1734}
1735
1736pub fn resolve_lookup_gated(
1758 tables: &LookupTables,
1759 decision: RouteDecision,
1760 key_first: Option<KeyFirstGate>,
1761 turns: &[&str],
1762 mode: LookupMode,
1763) -> Result<(RouteDecision, LookupOutcome), String> {
1764 if let (Some(gate), RouteTarget::Backbone, false, Some(message)) = (
1765 key_first,
1766 &decision.target,
1767 mode == LookupMode::Off,
1768 turns.first(),
1769 ) {
1770 if let Some(taken) = key_first_take(tables, &decision, gate, message, mode)? {
1771 return Ok(taken);
1772 }
1773 }
1774 resolve_lookup_turns(tables, decision, turns, mode)
1775}
1776
1777fn key_first_take(
1780 tables: &LookupTables,
1781 decision: &RouteDecision,
1782 gate: KeyFirstGate,
1783 message: &str,
1784 mode: LookupMode,
1785) -> Result<Option<(RouteDecision, LookupOutcome)>, String> {
1786 let model = tables.model().clone();
1787 let mut router_ok: Option<bool> = None;
1792 for id in tables.ids() {
1793 let Some(rec) = LookupTable::record(&model, &id) else {
1794 continue;
1795 };
1796 let Some(info) = rec.lookup.as_ref() else {
1797 continue;
1798 };
1799 if LookupPolicy::of(info) != LookupPolicy::KeyFirst {
1800 continue;
1801 }
1802 let ok = *router_ok.get_or_insert_with(|| key_first_router_ok(&model, gate.opts));
1803 if !ok {
1804 return Ok(None);
1805 }
1806 if !router::is_routable(rec, gate.opts) {
1807 continue;
1808 }
1809 let pick = RouteDecision::forced(RouteTarget::Skill(id.clone()), "key_first");
1810 let (pick, _) =
1811 router::enforce_prompt_contract(&model.header, pick, gate.frame, gate.can_render);
1812 if pick.skill().is_none() {
1813 continue;
1814 }
1815 let Some(table) = tables.get(&id)? else {
1816 continue;
1817 };
1818 let Some(key) = table.find_strong_key(message) else {
1819 continue;
1820 };
1821 let mut answer = match table.answer_for_key(key, message)? {
1822 Found::Answer(a) => a,
1823 Found::NoKey | Found::EmptyCard(_) => continue,
1824 };
1825 answer.decided_by = DecidedBy::KeyFirst;
1826 let reason = format!(
1827 "key_first: lookup '{id}' takes the request on the strong key {:?} ({} words, {}, \
1828 {}) [router decision: {}]; lookup key {:?} → entry {}{}",
1829 answer.key.key,
1830 answer.key.words,
1831 answer.key.source.label(),
1832 answer.key.via.label(),
1833 decision.reason,
1834 answer.key.key,
1835 answer.key.entry,
1836 answer
1837 .field
1838 .as_deref()
1839 .map(|f| format!(", field {f}"))
1840 .unwrap_or_default()
1841 );
1842 let d = RouteDecision {
1843 target: RouteTarget::Skill(id),
1844 routing: decision.routing.clone(),
1845 reason,
1846 };
1847 let outcome = match mode {
1848 LookupMode::Answer => LookupOutcome::Answer(answer),
1849 LookupMode::Context => LookupOutcome::Context(answer),
1850 LookupMode::Off => unreachable!("key_first never acts in mode off"),
1851 };
1852 return Ok(Some((d, outcome)));
1853 }
1854 Ok(None)
1855}
1856
1857fn key_first_router_ok(model: &CmfModel, opts: RouteOptions) -> bool {
1863 router::is_router_v2(model) && router::no_candidate_reason(&model.header, opts).is_none()
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868 use super::*;
1869 use cortiq_core::knowledge::key_hash;
1870
1871 fn finder(keys: &[(&str, u32)]) -> impl Fn(u64) -> Option<u32> {
1874 let m: BTreeMap<u64, u32> = keys.iter().map(|(k, e)| (key_hash(k), *e)).collect();
1875 move |h| m.get(&h).copied()
1876 }
1877
1878 #[test]
1879 fn mode_parses_flag_and_env_spellings() {
1880 assert_eq!(LookupMode::parse("answer").unwrap(), LookupMode::Answer);
1881 assert_eq!(LookupMode::parse(" Context ").unwrap(), LookupMode::Context);
1882 assert_eq!(LookupMode::parse("OFF").unwrap(), LookupMode::Off);
1883 assert_eq!(LookupMode::parse("none").unwrap(), LookupMode::Off);
1884 assert!(LookupMode::parse("maybe").unwrap_err().contains("answer | context | off"));
1885 assert_eq!(LookupMode::resolve(Some("off")).unwrap(), LookupMode::Off);
1886 assert!(LookupMode::resolve(Some("x")).unwrap_err().starts_with("--lookup-mode"));
1887 assert_eq!(LookupMode::default().label(), "answer");
1888 }
1889
1890 #[test]
1891 fn ngram_extraction_takes_the_longest_match_cyrillic_and_latin() {
1892 let find = finder(&[
1893 ("Пихта бальзамическая", 0),
1894 ("пихта", 7),
1895 ("Abies balsamea", 0),
1896 ("balsam fir", 0),
1897 ("Ромашка аптечная", 1),
1898 ("chamomile", 1),
1899 ]);
1900 let h = extract_key("Какое семейство у растения Пихта бальзамическая?", &find).unwrap();
1902 assert_eq!(h.key, "пихта бальзамическая");
1903 assert_eq!((h.entry, h.words, h.source), (0, 2, KeySource::Ngram));
1904 let h = extract_key("пихта — что это?", &find).unwrap();
1906 assert_eq!((h.key.as_str(), h.entry, h.words), ("пихта", 7, 1));
1907 let h = extract_key("What is BALSAM-FIR used for?", &find).unwrap();
1909 assert_eq!((h.key.as_str(), h.entry), ("balsam fir", 0));
1910 let h = extract_key("Tell me about chamomile.", &find).unwrap();
1911 assert_eq!((h.key.as_str(), h.entry), ("chamomile", 1));
1912 assert_eq!(extract_key("What is the capital of France?", &find), None);
1914 assert_eq!(extract_key("", &find), None);
1915 assert_eq!(extract_key(" ... ", &find), None);
1916 }
1917
1918 #[test]
1919 fn ties_go_to_the_first_occurrence() {
1920 let find = finder(&[("balsam fir", 0), ("chamomile", 1)]);
1921 let h = extract_key("chamomile or balsam fir?", &find).unwrap();
1922 assert_eq!(h.entry, 0, "the longer key wins over the earlier shorter one");
1923 let find = finder(&[("balsam fir", 0), ("red pine", 2)]);
1924 let h = extract_key("red pine and balsam fir", &find).unwrap();
1925 assert_eq!(h.entry, 2, "equal length: the first occurrence");
1926 }
1927
1928 #[test]
1929 fn parenthesised_binomial_is_tried_first() {
1930 let find = finder(&[("Abies balsamea", 0), ("пихты", 3)]);
1931 let h = extract_key("Какие части пихты (Abies balsamea) используют?", &find).unwrap();
1934 assert_eq!(h.key, "abies balsamea");
1935 assert_eq!((h.entry, h.source), (0, KeySource::Parenthesised));
1936 let h = extract_key("Какие части пихты (см. выше) используют?", &find).unwrap();
1938 assert_eq!((h.key.as_str(), h.source), ("пихты", KeySource::Ngram));
1939 let find = finder(&[("abies balsamea", 0)]);
1941 let h = extract_key("fir (Pinus sylvestris) vs abies balsamea", &find).unwrap();
1942 assert_eq!((h.entry, h.source), (0, KeySource::Ngram));
1943 assert_eq!(parenthesised("a (b) c (d e) (f"), vec!["b", "d e"]);
1944 assert_eq!(
1947 parenthesised("Репешок (репешок аптечный (Agrimonia eupatoria)?"),
1948 vec!["Agrimonia eupatoria", "репешок аптечный (Agrimonia eupatoria"]
1949 );
1950 assert_eq!(parenthesised("x (a (b (c)) d)"), vec!["c", "a (b (c"]);
1951 }
1952
1953 #[test]
1954 fn nested_and_accented_binomials_resolve_through_the_parentheses() {
1955 let find = finder(&[
1958 ("Oxycóccus", 0),
1959 ("клюква", 9),
1960 ("Agrimonia eupatoria", 1),
1961 ("репешок обыкновенный репешок аптечный", 8),
1962 ("Pinus", 2),
1963 ("сосна", 7),
1964 ("Strychnos nux-vomica", 3),
1965 ("чилибуха", 6),
1966 ]);
1967 let h = extract_key("К какому семейству относится Клюква (Oxycóccus)?", &find).unwrap();
1968 assert_eq!((h.entry, h.source), (0, KeySource::Parenthesised));
1969 assert_eq!(h.key, "oxycoccus");
1970 let h = extract_key("Сосна (Pínus) — семейство?", &find).unwrap();
1971 assert_eq!((h.entry, h.key.as_str()), (2, "pinus"));
1972 let h = extract_key(
1973 "К какому семейству относится Репешок обыкновенный (репешок аптечный (Agrimonia eupatoria)?",
1974 &find,
1975 )
1976 .unwrap();
1977 assert_eq!((h.entry, h.source), (1, KeySource::Parenthesised));
1978 let h = extract_key(
1979 "Чилибуха (Чилибуха обыкновенная (Strychnos nux-vomica) — что это?",
1980 &find,
1981 )
1982 .unwrap();
1983 assert_eq!((h.entry, h.key.as_str()), (3, "strychnos nux vomica"));
1984 assert!(is_latin_word("æsculus") && is_latin_word("øst") && is_latin_word("straße"));
1987 assert!(!is_latin_word("клюква") && !is_latin_word("l2") && !is_latin_word(""));
1988 let find = finder(&[("2024", 5), ("клюква", 9)]);
1989 let h = extract_key("Клюква (2024)", &find).unwrap();
1990 assert_eq!((h.entry, h.source), (9, KeySource::Ngram));
1991 }
1992
1993 #[test]
1994 fn field_selection_by_keywords() {
1995 assert_eq!(select_field("К какому семейству относится пихта?"), Some("family"));
1996 assert_eq!(select_field("What family is balsam fir in?"), Some("family"));
1997 assert_eq!(select_field("Какие части растения используют?"), Some("parts"));
1998 assert_eq!(select_field("Which part of the plant is used?"), Some("parts"));
1999 assert_eq!(select_field("Какие действующие вещества?"), Some("compounds"));
2000 assert_eq!(select_field("Main constituents?"), Some("compounds"));
2001 assert_eq!(select_field("Где применяется пихта?"), Some("uses"));
2002 assert_eq!(select_field("What is it used for?"), Some("uses"));
2003 assert_eq!(select_field("Какие формы препаратов бывают?"), Some("preparations"));
2004 assert_eq!(select_field("In what form is it taken?"), Some("preparations"));
2005 assert_eq!(select_field("Есть ли противопоказания?"), Some("safety"));
2006 assert_eq!(select_field("Any side effects?"), Some("safety"));
2007 assert_eq!(select_field("Is it safe?"), Some("safety"));
2008 assert_eq!(select_field("Какая доказательная база?"), Some("evidence"));
2009 assert_eq!(select_field("Is there a study on it?"), Some("evidence"));
2010 assert_eq!(select_field("Because information matters"), None);
2012 assert_eq!(select_field("Что такое пихта?"), None);
2013 assert_eq!(select_field("Семейство и части растения"), Some("family"));
2015 assert_eq!(select_field("Is lungwort safe to use today?"), Some("safety"));
2018 assert_eq!(select_field("What safety measures apply to digoxin use?"), Some("safety"));
2019 assert_eq!(
2020 select_field("Is the plant safe, and is its use supported by evidence?"),
2021 Some("safety")
2022 );
2023 assert_eq!(select_field("Насколько безопасно применение жимолости?"), Some("safety"));
2024 assert_eq!(
2025 select_field("Каковы противопоказания и меры безопасности при применении алтея?"),
2026 Some("safety")
2027 );
2028 assert_eq!(select_field("In what form is it used?"), Some("preparations"));
2029 assert_eq!(select_field("Какие части растения используются?"), Some("parts"));
2030 assert_eq!(select_field("Как выглядит частуха обыкновенная?"), None);
2032 assert_eq!(select_field("Где растёт частуха обыкновенная?"), None);
2033 assert_eq!(
2034 select_field("Какие сведения о безопасности частухи приводятся?"),
2035 Some("safety")
2036 );
2037 assert_eq!(
2038 select_field("Каково научное название частухи обыкновенной и к какому семейству она относится?"),
2039 Some("family")
2040 );
2041 assert_eq!(select_field("Часто ли её применяют?"), Some("uses"));
2042 assert_eq!(select_field("Какую часть растения собирают?"), Some("parts"));
2043 assert_eq!(select_field("Опасно ли это растение?"), Some("safety"));
2045 assert_eq!(select_field("Чем опасна наперстянка шерстистая?"), Some("safety"));
2046 assert_eq!(
2047 select_field("Каковы побочные эффекты и лекарственные взаимодействия галантамина?"),
2048 Some("safety")
2049 );
2050 assert_eq!(select_field("How toxic is Podophyllum peltatum?"), Some("safety"));
2051 assert_eq!(select_field("Is it poisonous to cats?"), Some("safety"));
2052 assert_eq!(select_field("What risks are mentioned?"), Some("safety"));
2053 assert_eq!(
2054 select_field("Какие биологически активные соединения найдены в растении?"),
2055 Some("compounds")
2056 );
2057 assert_eq!(select_field("Каков химический состав?"), Some("compounds"));
2058 assert_eq!(select_field("What are the active ingredients?"), Some("compounds"));
2059 assert_eq!(select_field("Which chemicals does it contain?"), Some("compounds"));
2060 assert_eq!(
2061 select_field("Какие данные исследований приводятся в источнике?"),
2062 Some("evidence")
2063 );
2064 assert_eq!(select_field("Are there clinical trials?"), Some("evidence"));
2065 assert_eq!(select_field("What does the research say?"), Some("evidence"));
2066 assert_eq!(select_field("Какая дозировка?"), Some("preparations"));
2067 assert_eq!(select_field("What is the usual dose?"), Some("preparations"));
2068 assert_eq!(select_field("Recommended dosage?"), Some("preparations"));
2069 assert_eq!(FIELDS, &["family", "parts", "compounds", "safety", "evidence", "preparations", "uses"]);
2070 }
2071
2072 #[test]
2073 fn context_excerpt_keeps_the_first_sentence_and_the_field() {
2074 let c = Card::parse(
2075 r#"{"card":"Пихта бальзамическая (Abies balsamea) — хвойное дерево. Растёт в Канаде. Смола ароматна.","fields":{"family":"Сосновые (Pinaceae)","parts":"хвоя, смола"}}"#,
2076 )
2077 .unwrap();
2078 assert_eq!(c.first_sentence(), "Пихта бальзамическая (Abies balsamea) — хвойное дерево.");
2079 assert_eq!(
2080 context_excerpt(&c, Some("family"), "ru"),
2081 "Пихта бальзамическая (Abies balsamea) — хвойное дерево.\nСемейство: Сосновые (Pinaceae)"
2082 );
2083 assert_eq!(context_excerpt(&c, Some("parts"), "en"), format!("{}\nParts: хвоя, смола", c.first_sentence()));
2084 assert_eq!(context_excerpt(&c, Some("uses"), "ru"), c.card, "a field the card lacks: the whole card");
2085 assert_eq!(context_excerpt(&c, None, "ru"), c.card);
2086 assert_eq!(
2087 context_excerpt(&c, Some("family"), "de"),
2088 format!("{}\nFamily: Сосновые (Pinaceae)", c.first_sentence()),
2089 "an unknown slot language labels in English"
2090 );
2091 assert_eq!(first_sentence("Hypericum perforatum L. is a herb. More."), "Hypericum perforatum L. is a herb.");
2094 assert_eq!(first_sentence("Abies balsamea? yes"), "Abies balsamea?");
2095 assert_eq!(first_sentence("a.b.c end"), "a.b.c end");
2096 let long: String = "слово ".repeat(100);
2097 let head = first_sentence(&long);
2098 assert!(head.chars().count() <= FIRST_SENTENCE_MAX && head.ends_with("слово"), "{head:?}");
2099 assert_eq!(first_sentence(" "), "");
2100 assert_eq!(field_label("family", "ru"), "Семейство");
2101 assert_eq!(field_label("family", "en"), "Family");
2102 assert_eq!(field_label("origin", "ru"), "Origin");
2103 assert!(Card::parse(r#"{"card":"","fields":{}}"#).unwrap().is_empty());
2104 assert!(Card::parse(r#"{"card":" ","fields":{"family":""}}"#).unwrap().is_empty());
2105 assert!(!Card::parse(r#"{"card":"","fields":{"family":"x"}}"#).unwrap().is_empty());
2106 }
2107
2108 #[test]
2109 fn language_by_script_with_fallbacks() {
2110 let ru_en = vec!["ru".to_string(), "en".to_string()];
2111 assert_eq!(pick_lang("Что такое пихта?", &ru_en), 0);
2112 assert_eq!(pick_lang("What is fir?", &ru_en), 1);
2113 let en_only = vec!["en".to_string()];
2114 assert_eq!(pick_lang("Что такое пихта?", &en_only), 0);
2115 let de_ru = vec!["de".to_string(), "ru".to_string()];
2116 assert_eq!(pick_lang("What is fir?", &de_ru), 0, "no en: the first language");
2117 assert_eq!(pick_lang("Пихта", &de_ru), 1);
2118 assert!(has_cyrillic("Ё"));
2119 assert!(!has_cyrillic("fir"));
2120 }
2121
2122 #[test]
2123 fn card_parse_and_context_prompt() {
2124 let c = Card::parse(r#"{"card":"Abies balsamea — a fir.","fields":{"family":"Pinaceae","n":3,"x":null}}"#)
2125 .unwrap();
2126 assert_eq!(c.card, "Abies balsamea — a fir.");
2127 assert_eq!(c.fields["family"], "Pinaceae");
2128 assert_eq!(c.fields["n"], "3");
2129 assert!(!c.fields.contains_key("x"));
2130 assert!(Card::parse("[1]").is_err());
2131 assert_eq!(Card::parse("{}").unwrap(), Card::default());
2132 assert_eq!(
2133 context_prompt("CARD", "Q?"),
2134 "Справочная карточка / Reference card:\nCARD\n\nQ?"
2135 );
2136 }
2137
2138 #[test]
2139 fn outcome_annotation_and_generation_text() {
2140 let a = LookupAnswer {
2141 id: "herbs".into(),
2142 key: KeyHit {
2143 key: "abies balsamea".into(),
2144 hash: 1,
2145 entry: 4,
2146 words: 2,
2147 source: KeySource::Ngram,
2148 via: MatchVia::Exact,
2149 stem: None,
2150 turn: 0,
2151 strong: true,
2152 },
2153 lang: "en".into(),
2154 field: Some("family".into()),
2155 text: "Pinaceae".into(),
2156 card: "CARD".into(),
2157 full_card: "CARD. MORE.".into(),
2158 decided_by: DecidedBy::Router,
2159 };
2160 let mut s = serde_json::json!({"target": "herbs"});
2161 LookupOutcome::Context(a.clone()).annotate(&mut s, LookupMode::Context);
2162 assert_eq!(s["lookup_hit"], true);
2163 assert_eq!(s["lookup_key"], "abies balsamea");
2164 assert_eq!(s["lookup_entry"], 4);
2165 assert_eq!(s["lookup_lang"], "en");
2166 assert_eq!(s["field"], "family");
2167 assert_eq!(s["lookup_mode"], "context");
2168 assert_eq!(s["decided_target"], "herbs");
2169 assert_eq!(s["decided_by"], "router");
2170 assert_eq!(s["lookup_key_words"], 2);
2171 assert_eq!(
2172 LookupOutcome::Context(a.clone()).generation_text("Q?"),
2173 context_prompt("CARD", "Q?")
2174 );
2175 assert_eq!(LookupOutcome::Answer(a.clone()).generation_text("Q?"), "Q?");
2176 let mut s = serde_json::json!({"target": "backbone"});
2177 LookupOutcome::NotLookup.annotate(&mut s, LookupMode::Answer);
2178 assert_eq!(s["lookup_hit"], false);
2179 assert!(s.get("lookup_mode").is_none());
2180 assert!(s.get("decided_target").is_none());
2181 assert!(s.get("decided_by").is_none());
2182 let mut kf = a.clone();
2184 kf.decided_by = DecidedBy::KeyFirst;
2185 let mut s = serde_json::json!({"target": "herbs"});
2186 LookupOutcome::Answer(kf.clone()).annotate(&mut s, LookupMode::Answer);
2187 assert_eq!(s["decided_by"], "key_first");
2188 assert!(kf.describe().ends_with("decided by key_first"), "{}", kf.describe());
2189 assert_eq!(
2190 LookupOutcome::Miss { id: "herbs".into() }.decided_by(),
2191 Some(DecidedBy::Router)
2192 );
2193 assert_eq!(LookupOutcome::NotLookup.decided_by(), None);
2194 let mut s = serde_json::json!({"target": "backbone"});
2195 LookupOutcome::Miss { id: "herbs".into() }.annotate(&mut s, LookupMode::Answer);
2196 assert_eq!(s["lookup_hit"], false);
2197 assert_eq!(s["lookup_mode"], "answer");
2198 assert_eq!(s["decided_target"], "herbs", "the router's choice survives the miss");
2199 assert_eq!(s["decided_by"], "router");
2200 assert_eq!(s["target"], "backbone", "the lane that ran");
2201 assert_eq!(LookupOutcome::Off { id: "h".into() }.lookup_id(), Some("h"));
2202 assert_eq!(LookupOutcome::NotLookup.lookup_id(), None);
2203 assert!(LookupOutcome::NotLookup.describe().is_none());
2204 assert!(
2205 LookupOutcome::Miss { id: "herbs".into() }
2206 .describe()
2207 .unwrap()
2208 .contains("no key")
2209 );
2210 let mut s = serde_json::json!({"target": "herbs"});
2213 let mut b = a.clone();
2214 b.key.via = MatchVia::Stem;
2215 b.key.stem = Some("abie balsamea".into());
2216 b.key.turn = 2;
2217 LookupOutcome::Answer(b.clone()).annotate(&mut s, LookupMode::Answer);
2218 assert_eq!(s["lookup_match"], "stem");
2219 assert_eq!(s["lookup_turn"], 2);
2220 let line = b.describe();
2221 assert!(line.contains("stem \"abie balsamea\"") && line.contains("2 turns back"), "{line}");
2222 let mut s = serde_json::json!({});
2223 LookupOutcome::Answer(a).annotate(&mut s, LookupMode::Answer);
2224 assert_eq!(s["lookup_match"], "exact");
2225 assert_eq!(s["lookup_turn"], 0);
2226 }
2227
2228 fn stem_finder(keys: &[(&str, u32)]) -> (StemIndex, impl Fn(u64) -> Option<u32>) {
2231 let norm: Vec<(String, u32)> = keys.iter().map(|(k, e)| (normalize_key(k), *e)).collect();
2232 let idx = StemIndex::from_keys(norm.iter().map(|(k, e)| (k.as_str(), *e)));
2233 let idx2 = idx.clone();
2234 (idx, move |h| idx2.find(h))
2235 }
2236
2237 #[test]
2238 fn stemmer_folds_russian_and_english_inflections() {
2239 assert_eq!(stem_word("тойона"), "тойон");
2241 assert_eq!(stem_word("тойон"), "тойон");
2242 assert_eq!(stem_key("магнолии лекарственной"), "магнол лекарственн");
2243 assert_eq!(stem_key("магнолия лекарственная"), "магнол лекарственн");
2244 assert_eq!(stem_key("кора магнолии лекарственной"), "кор магнол лекарственн");
2245 assert_eq!(stem_word("горопито"), "горопит");
2246 assert_eq!(stem_word("горопито"), stem_word("горопито"));
2247 for w in ["ромашка", "ромашки", "ромашке", "ромашку", "ромашкой", "ромашками", "ромашках"] {
2249 assert_eq!(stem_word(w), "ромашк", "{w}");
2250 }
2251 for w in ["лекарственный", "лекарственная", "лекарственное", "лекарственного", "лекарственному", "лекарственным", "лекарственными", "лекарственных", "лекарственные", "лекарственную"] {
2252 assert_eq!(stem_word(w), "лекарственн", "{w}");
2253 }
2254 assert_eq!(stem_word("зверобой"), stem_word("зверобоя"));
2256 assert_eq!(stem_word("зверобоя"), "звероб");
2257 assert_eq!(stem_word("шалфей"), stem_word("шалфея"));
2258 assert_eq!(stem_word("полынь"), stem_word("полыни"));
2259 assert_eq!(stem_word("растения"), "растен");
2260 assert_eq!(stem_word("чай"), "чай");
2262 assert_eq!(stem_word("чая"), "чая");
2263 assert_eq!(stem_word("вид"), "вид");
2264 assert_eq!(stem_word("виды"), "вид");
2265 assert_eq!(stem_word("дуба"), "дуб");
2266 assert_eq!(stem_word("herbs"), "herb");
2269 assert_eq!(stem_word("roses"), "rose");
2270 assert_eq!(stem_word("berries"), "berri");
2271 assert_eq!(stem_word("berry"), "berri");
2272 assert_eq!(stem_word("daisies"), stem_word("daisy"));
2273 assert_eq!(stem_word("grasses"), "grass");
2274 assert_eq!(stem_word("grass"), "grass");
2275 assert_eq!(stem_word("sage"), "sage");
2276 assert_eq!(stem_word("uses"), "uses");
2277 assert_eq!(stem_word("wort"), "wort");
2278 assert_eq!(stem_word("pinus"), "pinus");
2279 assert_eq!(stem_word("officinalis"), "officinalis");
2280 assert_eq!(stem_word("balsamea"), "balsamea");
2281 assert_eq!(stem_key("st john s wort"), "st john s wort");
2282 assert_eq!(stem_word("2024"), "2024");
2284 assert_eq!(stem_word("l2"), "l2");
2285 assert_eq!(stem_word(""), "");
2286 assert_eq!(stem_key(" "), "");
2287 let lens: Vec<usize> = RU_SUFFIXES.iter().map(|s| s.chars().count()).collect();
2290 assert!(lens.windows(2).all(|w| w[0] >= w[1]), "{lens:?}");
2291 }
2292
2293 #[test]
2294 fn stem_index_serves_inflected_names_after_the_exact_index_fails() {
2295 let keys = [
2296 ("тойон", 0u32),
2297 ("магнолия лекарственная", 1),
2298 ("магнолия", 2),
2299 ("чай", 3),
2300 ("вид", 4),
2301 ("укроп", 5),
2302 ("Abies balsamea", 6),
2303 ];
2304 let find = finder(&keys);
2305 let (idx, find_stem) = stem_finder(&keys);
2306 assert_eq!((idx.len(), idx.ambiguous, idx.keys_in), (7, 0, 7));
2307 let h = extract_key_with("традиционные применения тойона", &find, Some(&find_stem)).unwrap();
2309 assert_eq!((h.entry, h.via, h.source, h.words), (0, MatchVia::Stem, KeySource::Ngram, 1));
2310 assert_eq!((h.key.as_str(), h.stem.as_deref()), ("тойона", Some("тойон")));
2311 assert_eq!(h.hash, key_hash("тойон"), "the stem's hash");
2312 let h = extract_key_with("кора магнолии лекарственной", &find, Some(&find_stem)).unwrap();
2315 assert_eq!((h.entry, h.via, h.words), (1, MatchVia::Stem, 2));
2316 assert_eq!((h.key.as_str(), h.stem.as_deref()), ("магнолии лекарственной", Some("магнол лекарственн")));
2317 let h = extract_key_with("настойка магнолии", &find, Some(&find_stem)).unwrap();
2318 assert_eq!((h.entry, h.via), (2, MatchVia::Stem));
2319 let h = extract_key_with("семена укропа", &find, Some(&find_stem)).unwrap();
2320 assert_eq!((h.entry, h.via), (5, MatchVia::Stem));
2321 let h = extract_key_with("магнолия лекарственной", &find, Some(&find_stem)).unwrap();
2325 assert_eq!((h.entry, h.via, h.words), (1, MatchVia::Stem, 2));
2326 assert!(is_strong_key(&h));
2327 let h = extract_key_with("магнолия и тойона", &find, Some(&find_stem)).unwrap();
2329 assert_eq!((h.entry, h.via, h.words), (2, MatchVia::Exact, 1));
2330 let h = extract_key_with("тойон — что это?", &find, Some(&find_stem)).unwrap();
2331 assert_eq!((h.entry, h.via, h.stem), (0, MatchVia::Exact, None));
2332 assert_eq!(extract_key_with("какие виды бывают?", &find, Some(&find_stem)), None);
2335 assert_eq!(extract_key_with("чашка чая и чаёв", &find, Some(&find_stem)), None);
2336 assert_eq!(extract_key_with("вида", &find, Some(&find_stem)), None);
2337 let h = extract_key_with("какой вид чая лучше?", &find, Some(&find_stem)).unwrap();
2338 assert_eq!((h.entry, h.via), (4, MatchVia::Exact));
2339 assert_eq!(extract_key("кора магнолии лекарственной", &find), None);
2341 assert_eq!(extract_key_with("кора магнолии лекарственной", &find, None), None);
2342 assert_eq!(extract_key_with("What is the capital of France?", &find, Some(&find_stem)), None);
2344 assert_eq!(extract_key_with("", &find, Some(&find_stem)), None);
2345 let (idx, find_stem) = stem_finder(&[("пихта", 7), ("пихты", 3), ("ромашка", 1), ("ромашки", 1)]);
2347 assert_eq!((idx.len(), idx.ambiguous, idx.keys_in), (1, 1, 4));
2348 assert_eq!(find_stem(key_hash("пихт")), None);
2349 assert_eq!(find_stem(key_hash("ромашк")), Some(1));
2350 assert!(!idx.is_empty() && StemIndex::default().is_empty());
2351 }
2352
2353 #[test]
2354 fn capitalised_binomial_anywhere_is_tried_before_the_ngrams() {
2355 let find = finder(&[
2356 ("Abies balsamea", 0),
2357 ("Strychnos nux-vomica", 3),
2358 ("пихта", 7),
2359 ("balsam fir", 8),
2360 ]);
2361 let h = extract_key("Чем полезна Abies balsamea?", &find).unwrap();
2362 assert_eq!((h.entry, h.source, h.via), (0, KeySource::Binomial, MatchVia::Exact));
2363 assert_eq!((h.key.as_str(), h.words), ("abies balsamea", 2));
2364 let h = extract_key("Is Strychnos nux-vomica toxic?", &find).unwrap();
2365 assert_eq!((h.entry, h.key.as_str(), h.words), (3, "strychnos nux vomica", 3));
2366 let h = extract_key("Пихта, то есть Abies balsamea, — семейство?", &find).unwrap();
2370 assert_eq!((h.entry, h.source), (0, KeySource::Binomial));
2371 let h = extract_key("Пихта (Abies balsamea) — семейство?", &find).unwrap();
2372 assert_eq!((h.entry, h.source), (0, KeySource::Parenthesised));
2373 assert_eq!(extract_key("What plant is Abies?", &find), None);
2376 let h = extract_key("what is abies balsamea", &find).unwrap();
2377 assert_eq!(h.source, KeySource::Ngram);
2378 let h = extract_key("Balsam fir — what is it?", &find).unwrap();
2379 assert_eq!((h.entry, h.source), (8, KeySource::Binomial));
2380 assert_eq!(
2381 capitalised_binomials("Tell me about Abies balsamea and Pinus sylvestris (Pínus)."),
2382 vec!["Abies balsamea", "Pinus sylvestris"],
2383 "`me` is too short for an epithet; a lowercase genus is none"
2384 );
2385 assert_eq!(capitalised_binomials("Strychnos nux-vomica) L."), vec!["Strychnos nux-vomica"]);
2386 assert_eq!(capitalised_binomials("St. John's wort; ABIES balsamea; Abies B."), Vec::<String>::new());
2387 assert_eq!(capitalised_binomials("Пихта бальзамическая"), Vec::<String>::new());
2388 assert_eq!(capitalised_binomials("Abies -balsamea- x"), vec!["Abies balsamea"]);
2389 }
2390
2391 #[test]
2392 fn policy_parses_the_two_values_and_defaults_to_router_and_key() {
2393 assert_eq!(LookupPolicy::default(), LookupPolicy::RouterAndKey);
2394 assert_eq!(LookupPolicy::parse("router_and_key").unwrap(), LookupPolicy::RouterAndKey);
2395 assert_eq!(LookupPolicy::parse("key_first").unwrap(), LookupPolicy::KeyFirst);
2396 assert!(LookupPolicy::parse("Key_First").unwrap_err().contains("router_and_key | key_first"));
2397 assert!(LookupPolicy::parse("").is_err());
2398 let mut info = LookupInfo {
2399 entries: 1,
2400 keys: 1,
2401 key_norm: cortiq_core::knowledge::KEY_NORM.into(),
2402 langs: vec!["ru".into()],
2403 fields: Vec::new(),
2404 policy: None,
2405 };
2406 assert_eq!(LookupPolicy::of(&info), LookupPolicy::RouterAndKey);
2407 assert!(LookupPolicy::is_known(&info));
2408 info.policy = Some("key_first".into());
2409 assert_eq!(LookupPolicy::of(&info), LookupPolicy::KeyFirst);
2410 for unknown in ["key_only", "key_first ", "KEY_FIRST", ""] {
2413 info.policy = Some(unknown.into());
2414 assert_eq!(LookupPolicy::of(&info), LookupPolicy::RouterAndKey, "{unknown:?}");
2415 assert!(!LookupPolicy::is_known(&info), "{unknown:?}");
2416 }
2417 assert_eq!(LookupPolicy::KeyFirst.label(), "key_first");
2418 assert_eq!(LookupPolicy::RouterAndKey.label(), "router_and_key");
2419 assert_eq!(DecidedBy::default().label(), "router");
2420 assert_eq!(DecidedBy::KeyFirst.label(), "key_first");
2421 }
2422
2423 #[test]
2424 fn strong_keys_are_two_words_or_a_binomial_and_one_word_keys_never_are() {
2425 let keys = [
2426 ("ромашка аптечная", 0u32),
2427 ("ромашки аптечной", 0),
2428 ("Matricaria chamomilla", 0),
2429 ("chamomile", 0),
2430 ("календула", 1),
2431 ("календулы", 1),
2432 ("Calendula officinalis", 1),
2433 ("calendula", 1),
2434 ("pot marigold", 1),
2435 ("чай", 2),
2436 ("мята", 3),
2437 ("мята перечная", 3),
2438 ("магнолия лекарственная", 4),
2439 ("Strychnos nux-vomica", 5),
2440 ];
2441 let find = finder(&keys);
2442 let (_, find_stem) = stem_finder(&keys);
2443 let strong = |m: &str| extract_strong_key_with(m, &find, Some(&find_stem));
2444 let any = |m: &str| extract_key_with(m, &find, Some(&find_stem));
2445 let h = strong("Какие лечебные свойства у ромашки аптечной?").unwrap();
2447 assert_eq!((h.entry, h.words, h.via, h.source), (0, 2, MatchVia::Exact, KeySource::Ngram));
2448 assert!(is_strong_key(&h));
2449 let h = strong("Tell me about pot marigold tea").unwrap();
2450 assert_eq!((h.entry, h.words), (1, 2));
2451 let h = strong("Кора магнолии лекарственной — от чего?").unwrap();
2453 assert_eq!((h.entry, h.words, h.via), (4, 2, MatchVia::Stem));
2454 assert!(is_strong_key(&h));
2455 let h = strong("What family is Matricaria chamomilla in?").unwrap();
2458 assert_eq!((h.entry, h.source, h.words), (0, KeySource::Binomial, 2));
2459 let h = strong("Ноготки (Calendula officinalis): семейство?").unwrap();
2460 assert_eq!((h.entry, h.source, h.words), (1, KeySource::Parenthesised, 2));
2461 let h = strong("Is Strychnos nux-vomica toxic?").unwrap();
2462 assert_eq!((h.entry, h.words), (5, 3));
2463 for m in [
2466 "Какое семейство у календулы?",
2467 "Хочу чай с мятой",
2468 "Налей чай",
2469 "мята",
2470 "What is calendula?",
2471 "A tea of chamomile, please",
2472 "Ноготки (Calendula) — что это?",
2473 "What is the capital of France?",
2474 "",
2475 ] {
2476 assert_eq!(strong(m), None, "{m:?}");
2477 }
2478 let h = any("Какое семейство у календулы?").unwrap();
2479 assert!(!is_strong_key(&h) && h.words == 1);
2480 let h = any("Ноготки (Calendula) — что это?").unwrap();
2481 assert!(!is_strong_key(&h));
2482 assert_eq!(any("Налей чай").unwrap().entry, 2);
2483 for m in ["чай из ромашки аптечная", "Как заварить чай из ромашки аптечная?"] {
2487 let h = strong(m).unwrap();
2488 assert_eq!((h.entry, h.words, h.via), (0, 2, MatchVia::Stem), "{m}");
2489 assert_eq!(any(m), Some(h), "{m}");
2490 }
2491 let h = any("чай с мятой перечная").unwrap();
2492 assert_eq!((h.entry, h.via), (3, MatchVia::Stem), "not the exact `чай`");
2493 let h = strong("мята перечная и ромашка аптечная").unwrap();
2495 assert_eq!((h.entry, h.key.as_str()), (3, "мята перечная"), "ties → first occurrence");
2496 }
2497
2498 #[test]
2499 fn strong_keys_need_two_real_words_without_digits() {
2500 for k in ["sts 135", "pti 2", "a 41988", "5f pb 22", "thc b", "чай", "b 12 vitamin"] {
2504 assert!(!is_strong_key_text(k), "{k}");
2505 }
2506 for k in ["st john s wort", "pot marigold", "ромашки аптечной", "strychnos nux vomica", "five finger"] {
2507 assert!(is_strong_key_text(k), "{k}");
2508 }
2509 let keys = [("STS-135", 0u32), ("PTI-2", 1), ("5F-PB-22", 2), ("THC-B", 3), ("St. John's wort", 4), ("мята", 5)];
2510 let find = finder(&keys);
2511 let (_, find_stem) = stem_finder(&keys);
2512 let msg = "Tell me about the STS-135 mission of Space Shuttle Atlantis";
2513 assert_eq!(extract_strong_key_with(msg, &find, Some(&find_stem)), None);
2514 let h = extract_key_with(msg, &find, Some(&find_stem)).unwrap();
2515 assert_eq!((h.entry, h.key.as_str(), h.words, h.strong), (0, "sts 135", 2, false));
2516 for m in ["Is PTI-2 legal?", "5F-PB-22 effects", "What is THC-B?"] {
2517 assert_eq!(extract_strong_key_with(m, &find, Some(&find_stem)), None, "{m}");
2518 assert!(extract_key_with(m, &find, Some(&find_stem)).is_some_and(|h| !h.strong), "{m}");
2519 }
2520 let h = extract_strong_key_with("Is St. John's wort safe?", &find, Some(&find_stem)).unwrap();
2521 assert_eq!((h.entry, h.words, h.strong), (4, 4, true));
2522 let h = extract_key_with("мята (St. John's wort)", &find, Some(&find_stem)).unwrap();
2524 assert_eq!((h.entry, h.source), (4, KeySource::Parenthesised));
2525 }
2526
2527 #[test]
2528 fn a_capitalised_pair_is_judged_by_its_words_like_an_n_gram() {
2529 let keys = [("Lathyrus vernus", 0u32), ("Spring vetchling", 1), ("Common box", 2)];
2534 let find = finder(&keys);
2535 let m = "Which plant family does Spring vetchling (Lathyrus vernus (L.) Bernh.) belong to?";
2536 let h = extract_strong_key_with(m, &find, None).unwrap();
2537 assert_eq!((h.entry, h.source, h.strong), (1, KeySource::Binomial, true));
2538 assert_eq!(extract_key_with(m, &find, None), Some(h));
2539 let h = extract_strong_key_with("Why is Common box cutter so popular?", &find, None).unwrap();
2543 assert_eq!((h.entry, h.source), (2, KeySource::Binomial));
2544 let h = extract_strong_key_with("why is common box cutter so popular", &find, None).unwrap();
2545 assert_eq!((h.entry, h.source), (2, KeySource::Ngram));
2546 let find = finder(&[("Vitamin b", 3)]);
2548 assert_eq!(extract_strong_key_with("Is Vitamin b safe?", &find, None), None);
2549 let h = extract_key_with("Is Vitamin b safe?", &find, None);
2550 assert_eq!(h.map(|k| (k.entry, k.strong, k.source)), Some((3, false, KeySource::Ngram)));
2551 }
2552
2553 #[test]
2554 fn extraction_stays_linear_in_the_message() {
2555 use std::cell::Cell;
2556 let exact_calls = Cell::new(0usize);
2557 let stem_calls = Cell::new(0usize);
2558 let find = |_h: u64| {
2559 exact_calls.set(exact_calls.get() + 1);
2560 None
2561 };
2562 let find_stem = |_h: u64| {
2563 stem_calls.set(stem_calls.get() + 1);
2564 None
2565 };
2566 let words = 300usize;
2567 let msg: String = (0..words)
2568 .map(|i| if i % 3 == 0 { "Magnolia" } else { "лекарственной" })
2569 .collect::<Vec<_>>()
2570 .join(" ");
2571 assert_eq!(extract_key_with(&msg, &find, Some(&find_stem)), None);
2572 assert!(exact_calls.get() <= (MAX_NGRAM + 1) * words, "{}", exact_calls.get());
2575 assert!(stem_calls.get() <= MAX_NGRAM * words, "{}", stem_calls.get());
2576 assert!(stem_calls.get() >= words, "the stem pass ran: {}", stem_calls.get());
2577 }
2578
2579 #[test]
2580 fn key_texts_are_recovered_from_the_cards() {
2581 let keys = [("ромашка аптечная", 0u32), ("chamomile", 0), ("шалфей", 1), ("mint", 2)];
2585 let mut pairs: Vec<(u64, u32)> = keys.iter().map(|(k, e)| (key_hash(k), *e)).collect();
2586 pairs.sort_unstable();
2587 let hashes: Vec<u64> = pairs.iter().map(|p| p.0).collect();
2588 let entry_of: Vec<u32> = pairs.iter().map(|p| p.1).collect();
2589 let slots = [
2590 r#"{"card":"Ромашка аптечная — однолетник.","fields":{"uses":"чай"}}"#,
2591 r#"{"card":"Chamomile is an annual.","fields":{}}"#,
2592 r#"{"card":"Шалфей — полукустарник; сочетают с ромашкой аптечной.","fields":{}}"#,
2593 r#"{"card":"","fields":{}}"#,
2594 r#"{"card":"","fields":{}}"#,
2595 r#"{"card":"","fields":{}}"#,
2596 ];
2597 let mut blob = Vec::new();
2598 let mut offsets = vec![0u64];
2599 for s in slots {
2600 blob.extend_from_slice(s.as_bytes());
2601 offsets.push(blob.len() as u64);
2602 }
2603 let mut got = recover_key_texts(&hashes, &entry_of, &blob, &offsets);
2604 got.sort();
2605 assert_eq!(
2606 got,
2607 vec![
2608 ("chamomile".to_string(), 0),
2609 ("ромашка аптечная".to_string(), 0),
2610 ("шалфей".to_string(), 1),
2611 ]
2612 );
2613 let idx = StemIndex::from_keys(got.iter().map(|(k, e)| (k.as_str(), *e)));
2614 assert_eq!(idx.find(key_hash("ромашк аптечн")), Some(0));
2615 assert_eq!(idx.find(key_hash("шалф")), Some(1));
2616 assert_eq!(idx.find(key_hash("mint")), None, "spelled by no card: no stem");
2617 let bad = [0u64, 5, 3, blob.len() as u64 + 10];
2619 let _ = recover_key_texts(&hashes, &entry_of, &blob, &bad);
2620 assert_eq!(recover_key_texts(&hashes, &entry_of, &[0xFF, 0xFE], &[0, 2]), vec![]);
2621 }
2622}