1use std::collections::HashMap;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum KeyCategory {
10 Person,
11 Date,
12 Location,
13 Organization,
14 Domain,
15 Preference,
16 Entity,
17 Quantity,
18 ProperNoun,
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct EpisodicKey {
24 pub category: KeyCategory,
25 pub term: String,
26 pub surface: String,
27 pub start: usize,
28 pub end: usize,
29 pub confidence: f32,
30}
31
32impl EpisodicKey {
33 fn new(
34 category: KeyCategory,
35 term: impl Into<String>,
36 surface: impl Into<String>,
37 start: usize,
38 end: usize,
39 confidence: f32,
40 ) -> Self {
41 Self {
42 category,
43 term: term.into(),
44 surface: surface.into(),
45 start,
46 end,
47 confidence: confidence.clamp(0.0, 1.0),
48 }
49 }
50}
51
52#[must_use]
54pub fn extract_episodic_keys(text: &str) -> Vec<EpisodicKey> {
55 let mut keys = Vec::new();
56 extract_person_keys(text, &mut keys);
57 extract_date_keys(text, &mut keys);
58 extract_place_org_keys(text, &mut keys);
59 extract_domain_keys(text, &mut keys);
60 extract_preference_keys(text, &mut keys);
61 extract_entity_keys(text, &mut keys);
62 extract_numeric_keys(text, &mut keys);
63 extract_selective_entities(text, &mut keys);
64 keys.sort_by(|left, right| {
65 left.start
66 .cmp(&right.start)
67 .then_with(|| left.term.cmp(&right.term))
68 .then_with(|| (left.category as u8).cmp(&(right.category as u8)))
69 });
70 keys.dedup_by(|left, right| left.category == right.category && left.term == right.term);
71 keys
72}
73
74#[must_use]
76pub fn key_index_terms(text: &str) -> Vec<String> {
77 extract_episodic_keys(text)
78 .into_iter()
79 .map(|key| key.term)
80 .fold(Vec::new(), |mut terms, term| {
81 if !terms.contains(&term) {
82 terms.push(term);
83 }
84 terms
85 })
86}
87
88#[derive(Debug, Clone, Default)]
93pub struct AdaptiveAliases {
94 aliases: HashMap<String, String>,
96}
97
98impl AdaptiveAliases {
99 pub fn from_file(path: &std::path::Path) -> std::io::Result<Self> {
103 let content = std::fs::read_to_string(path)?;
104 Self::from_json(&content)
105 }
106
107 pub fn from_json(json: &str) -> std::io::Result<Self> {
109 #[derive(serde::Deserialize)]
110 struct AliasEntry {
111 surface: String,
112 canonical: String,
113 #[allow(dead_code)]
114 confidence: Option<f32>,
115 }
116 #[derive(serde::Deserialize)]
117 struct AliasFile {
118 entries: Vec<AliasEntry>,
119 }
120
121 let parsed: AliasFile = serde_json::from_str(json)
122 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
123 let mut aliases = HashMap::new();
124 for entry in parsed.entries {
125 aliases.insert(entry.surface.to_ascii_lowercase(), entry.canonical);
126 }
127 Ok(Self { aliases })
128 }
129
130 #[must_use]
132 pub const fn from_map(map: HashMap<String, String>) -> Self {
133 Self { aliases: map }
134 }
135
136 pub fn lookup(&self, surface: &str) -> Option<&str> {
138 self.aliases
139 .get(&surface.to_ascii_lowercase())
140 .map(String::as_str)
141 }
142
143 #[must_use]
145 pub fn len(&self) -> usize {
146 self.aliases.len()
147 }
148
149 #[must_use]
151 pub fn is_empty(&self) -> bool {
152 self.aliases.is_empty()
153 }
154}
155
156#[must_use]
158pub fn extract_episodic_keys_with_aliases(
159 text: &str,
160 aliases: Option<&AdaptiveAliases>,
161) -> Vec<EpisodicKey> {
162 let mut keys = extract_episodic_keys(text);
163 if let Some(aliases) = aliases {
164 if !aliases.is_empty() {
165 extract_adaptive_entity_keys(text, aliases, &mut keys);
166 }
167 }
168 keys.sort_by(|left, right| {
169 left.start
170 .cmp(&right.start)
171 .then_with(|| left.term.cmp(&right.term))
172 .then_with(|| (left.category as u8).cmp(&(right.category as u8)))
173 });
174 keys.dedup_by(|left, right| left.category == right.category && left.term == right.term);
175 keys
176}
177
178#[must_use]
180pub fn key_index_terms_with_aliases(text: &str, aliases: Option<&AdaptiveAliases>) -> Vec<String> {
181 extract_episodic_keys_with_aliases(text, aliases)
182 .into_iter()
183 .map(|key| key.term)
184 .fold(Vec::new(), |mut terms, term| {
185 if !terms.contains(&term) {
186 terms.push(term);
187 }
188 terms
189 })
190}
191
192#[must_use]
194pub fn entity_key_terms(text: &str) -> Vec<String> {
195 extract_episodic_keys(text)
196 .into_iter()
197 .filter(|k| k.category == KeyCategory::ProperNoun)
198 .map(|k| k.term)
199 .fold(Vec::new(), |mut terms, term| {
200 if !terms.contains(&term) {
201 terms.push(term);
202 }
203 terms
204 })
205}
206
207fn extract_adaptive_entity_keys(
209 text: &str,
210 aliases: &AdaptiveAliases,
211 keys: &mut Vec<EpisodicKey>,
212) {
213 let lower = text.to_ascii_lowercase();
214 for (surface, canonical) in &aliases.aliases {
215 for start in find_word_starts(&lower, surface) {
216 let end = start + surface.len();
217 keys.push(EpisodicKey::new(
218 KeyCategory::Entity,
219 canonical.clone(),
220 &text[start..end.min(text.len())],
221 start,
222 end.min(text.len()),
223 0.85,
224 ));
225 }
226 }
227}
228
229fn extract_person_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
230 const TITLES: &[(&str, &str, f32)] = &[
231 ("dr.", "doctor", 0.95),
232 ("doctor", "doctor", 0.9),
233 ("physician", "doctor", 0.85),
234 ("dermatologist", "doctor", 0.85),
235 ("professor", "professor", 0.9),
236 ("prof.", "professor", 0.9),
237 ];
238 let lower = text.to_ascii_lowercase();
239 for (surface, term, confidence) in TITLES {
240 for start in find_word_starts(&lower, surface) {
241 let end = start + surface.len();
242 keys.push(EpisodicKey::new(
243 KeyCategory::Person,
244 *term,
245 &text[start..end.min(text.len())],
246 start,
247 end.min(text.len()),
248 *confidence,
249 ));
250 if let Some((name, name_end)) = following_proper_name(text, end) {
251 keys.push(EpisodicKey::new(
252 KeyCategory::Person,
253 name.to_ascii_lowercase(),
254 name,
255 end,
256 name_end,
257 (*confidence - 0.05).max(0.7),
258 ));
259 }
260 }
261 }
262}
263
264fn extract_date_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
265 const MONTHS: &[(&str, &str)] = &[
266 ("january", "01"),
267 ("february", "02"),
268 ("march", "03"),
269 ("april", "04"),
270 ("may", "05"),
271 ("june", "06"),
272 ("july", "07"),
273 ("august", "08"),
274 ("september", "09"),
275 ("october", "10"),
276 ("november", "11"),
277 ("december", "12"),
278 ];
279 let lower = text.to_ascii_lowercase();
280 for (name, month) in MONTHS {
281 for start in find_word_starts(&lower, name) {
282 let mut end = start + name.len();
283 let mut term = format!("date-{month}");
284 if let Some((day, day_end)) = following_day(&lower, end) {
285 term = format!("date-{month}-{day:02}");
286 end = day_end;
287 }
288 keys.push(EpisodicKey::new(
289 KeyCategory::Date,
290 term,
291 &text[start..end.min(text.len())],
292 start,
293 end.min(text.len()),
294 0.9,
295 ));
296 }
297 }
298 for (start, end, year, month, day) in find_iso_dates(&lower) {
299 keys.push(EpisodicKey::new(
300 KeyCategory::Date,
301 format!("date-{month}-{day:02}"),
302 &text[start..end],
303 start,
304 end,
305 0.95,
306 ));
307 keys.push(EpisodicKey::new(
308 KeyCategory::Date,
309 format!("date-{year}"),
310 &text[start..end],
311 start,
312 end,
313 0.85,
314 ));
315 }
316}
317
318fn extract_place_org_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
319 const PLACES: &[(&str, &str, KeyCategory, f32)] = &[
320 (
321 "university of california, los angeles",
322 "ucla",
323 KeyCategory::Organization,
324 0.95,
325 ),
326 (
327 "university of california los angeles",
328 "ucla",
329 KeyCategory::Organization,
330 0.95,
331 ),
332 ("los angeles", "los-angeles", KeyCategory::Location, 0.85),
333 ("ucla", "university", KeyCategory::Organization, 0.9),
334 ("ikea", "ikea", KeyCategory::Organization, 0.95),
335 ("hawaii", "hawaii", KeyCategory::Location, 0.9),
336 ("japan", "japan", KeyCategory::Location, 0.9),
337 ("lake michigan", "michigan", KeyCategory::Location, 0.9),
338 ("serenity yoga", "yoga", KeyCategory::Organization, 0.9),
339 ("spotify", "spotify", KeyCategory::Organization, 0.95),
340 ];
341 let lower = text.to_ascii_lowercase();
342 for (surface, term, category, confidence) in PLACES {
343 for start in find_word_starts(&lower, surface) {
344 let end = start + surface.len();
345 keys.push(EpisodicKey::new(
346 *category,
347 *term,
348 &text[start..end.min(text.len())],
349 start,
350 end.min(text.len()),
351 *confidence,
352 ));
353 }
354 }
355 if let Some(start) = find_word_starts(&lower, "university of").into_iter().next() {
356 keys.push(EpisodicKey::new(
357 KeyCategory::Organization,
358 "university",
359 &text[start..(start + "university of".len()).min(text.len())],
360 start,
361 (start + "university of".len()).min(text.len()),
362 0.8,
363 ));
364 }
365}
366
367fn extract_domain_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
368 const DOMAINS: &[(&str, &[&str], f32)] = &[
369 (
370 "medicine",
371 &[
372 "doctor",
373 "physician",
374 "prescription",
375 "appointment",
376 "dermatolog",
377 ],
378 0.8,
379 ),
380 (
381 "education",
382 &[
383 "degree",
384 "bachelor",
385 "graduate",
386 "undergrad",
387 "university",
388 "college",
389 "computer science",
390 ],
391 0.8,
392 ),
393 (
394 "travel",
395 &["trip", "commute", "hawaii", "japan", "vacation"],
396 0.75,
397 ),
398 (
399 "pets",
400 &["dog", "cat", "retriever", "animal shelter", "puppy"],
401 0.85,
402 ),
403 ("finance", &["paid", "worth", "mbps", "internet plan"], 0.7),
404 ("food", &["bake", "recipe", "cook", "dinner"], 0.7),
405 (
406 "music",
407 &["spotify", "streaming", "concert", "playlist"],
408 0.85,
409 ),
410 (
411 "sports",
412 &["tennis", "bike", "bicycle", "yoga", "fishing"],
413 0.8,
414 ),
415 ];
416 let lower = text.to_ascii_lowercase();
417 for (domain, cues, confidence) in DOMAINS {
418 if let Some(cue) = cues.iter().copied().find(|cue| contains_word(&lower, cue)) {
419 let start = lower.find(cue).unwrap_or(0);
420 keys.push(EpisodicKey::new(
421 KeyCategory::Domain,
422 *domain,
423 cue,
424 start,
425 start + cue.len(),
426 *confidence,
427 ));
428 }
429 }
430}
431
432fn extract_preference_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
433 const MARKERS: &[&str] = &[
434 "my favorite",
435 "i prefer",
436 "i like",
437 "i enjoy",
438 "i've been using",
439 "have i been using",
440 ];
441 let lower = text.to_ascii_lowercase();
442 for marker in MARKERS {
443 for start in find_word_starts(&lower, marker) {
444 keys.push(EpisodicKey::new(
445 KeyCategory::Preference,
446 "preference",
447 &text[start..(start + marker.len()).min(text.len())],
448 start,
449 (start + marker.len()).min(text.len()),
450 0.85,
451 ));
452 }
453 }
454}
455
456fn extract_entity_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
457 const ENTITIES: &[(&str, &str, f32)] = &[
458 ("golden retriever", "dog", 0.95),
459 ("labrador", "dog", 0.9),
460 ("dog", "dog", 0.85),
461 ("cat", "cat", 0.85),
462 ("music streaming service", "spotify", 0.8),
463 ("streaming service", "spotify", 0.75),
464 ("community theater", "play", 0.8),
465 ("the glass menagerie", "play", 0.95),
466 ("play", "play", 0.7),
467 ("tennis racket", "tennis", 0.9),
468 ("daily commute", "commute", 0.9),
469 ("commute", "commute", 0.85),
470 ("computer science", "degree", 0.8),
471 ("bachelor's degree", "degree", 0.9),
472 ("bachelors degree", "degree", 0.9),
473 ("undergrad", "degree", 0.85),
474 ("undergraduate", "degree", 0.85),
475 ("cs", "degree", 0.7),
476 ("bookshelf", "bookshelf", 0.8),
477 ("internet plan", "internet-plan", 0.85),
478 ("yoga", "yoga", 0.85),
479 ("valentine's day", "date-02-14", 0.9),
481 ("valentines day", "date-02-14", 0.9),
482 ("valentine day", "date-02-14", 0.85),
483 ("strut your mutt", "animal-shelter", 0.85),
484 ("animal shelter", "animal-shelter", 0.9),
485 ("animal welfare", "animal-shelter", 0.8),
486 ("audition", "play", 0.8),
487 ("down dog", "yoga", 0.85),
488 ("production", "play", 0.75),
489 ("serenity yoga", "yoga", 0.95),
490 ("vinyasa", "yoga", 0.8),
491 ("love is in the air", "fundraising", 0.85),
492 ("fundraising dinner", "fundraising", 0.9),
493 ("silent auction", "fundraising", 0.8),
494 ];
495 let lower = text.to_ascii_lowercase();
496 for (surface, term, confidence) in ENTITIES {
497 for start in find_word_starts(&lower, surface) {
498 let end = start + surface.len();
499 keys.push(EpisodicKey::new(
500 KeyCategory::Entity,
501 *term,
502 &text[start..end.min(text.len())],
503 start,
504 end.min(text.len()),
505 *confidence,
506 ));
507 }
508 }
509}
510
511fn extract_numeric_keys(text: &str, keys: &mut Vec<EpisodicKey>) {
516 let bytes = text.as_bytes();
517 let mut i = 0;
518 while i < bytes.len() {
519 if bytes[i].is_ascii_digit() {
520 let start = i;
521 while i < bytes.len() && bytes[i].is_ascii_digit() {
522 i += 1;
523 }
524 let end = i;
525 let num = &text[start..end];
526 keys.push(EpisodicKey::new(
527 KeyCategory::Quantity,
528 num.to_string(),
529 num,
530 start,
531 end,
532 0.8,
533 ));
534 } else {
535 i += 1;
536 }
537 }
538}
539
540const PHASE_STOPWORDS: &[&str] = &[
542 "The", "A", "An", "Of", "And", "Or", "In", "On", "At", "To", "For", "By", "With", "Is", "Are",
543 "Was", "Were", "Be", "Been", "Have", "Has", "Had", "Do", "Did", "My", "Your", "His", "Her",
544 "Our", "Their", "Its", "This", "That", "I", "We", "They", "He", "She", "It",
545];
546
547fn extract_selective_entities(text: &str, keys: &mut Vec<EpisodicKey>) {
552 let bytes = text.as_bytes();
553 let mut i = 0;
554
555 while i < bytes.len() {
556 if !bytes[i].is_ascii_alphanumeric() {
557 i += 1;
558 continue;
559 }
560
561 let word_start = i;
562 while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
563 i += 1;
564 }
565 let word_end = i;
566 let word = &text[word_start..word_end];
567
568 let is_capitalized = word.chars().next().is_some_and(|c| c.is_ascii_uppercase());
569 let is_all_caps = word.len() > 1 && word.chars().all(|c| c.is_ascii_uppercase());
570
571 if is_capitalized {
572 let mut phrase_end = word_end;
574 let mut phrase_words: Vec<&str> = vec![word];
575
576 loop {
577 let mut j = phrase_end;
578 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
579 j += 1;
580 }
581 if j < bytes.len() && bytes[j].is_ascii_uppercase() {
582 let next_start = j;
583 while j < bytes.len() && bytes[j].is_ascii_alphanumeric() {
584 j += 1;
585 }
586 let next_word = &text[next_start..j];
587 if PHASE_STOPWORDS.contains(&next_word) {
588 break;
589 }
590 phrase_words.push(next_word);
591 phrase_end = j;
592 } else {
593 break;
594 }
595 }
596
597 if phrase_words.len() >= 2 {
598 let phrase: String = phrase_words.join(" ");
600 let term = phrase.to_ascii_lowercase().replace(' ', "-");
601 keys.push(EpisodicKey::new(
602 KeyCategory::ProperNoun,
603 term,
604 &phrase,
605 word_start,
606 phrase_end,
607 0.85,
608 ));
609 i = phrase_end;
610 } else if is_all_caps {
611 keys.push(EpisodicKey::new(
613 KeyCategory::ProperNoun,
614 word.to_ascii_lowercase(),
615 word,
616 word_start,
617 word_end,
618 0.85,
619 ));
620 }
621 }
623 }
624}
625
626fn find_word_starts(haystack: &str, needle: &str) -> Vec<usize> {
627 if needle.is_empty() {
628 return Vec::new();
629 }
630 let bytes = haystack.as_bytes();
631 let needle_bytes = needle.as_bytes();
632 let mut starts = Vec::new();
633 let mut offset = 0;
634 while offset + needle_bytes.len() <= bytes.len() {
635 if bytes[offset..].starts_with(needle_bytes)
636 && is_boundary_before(bytes, offset)
637 && is_boundary_after(bytes, offset + needle_bytes.len())
638 {
639 starts.push(offset);
640 offset += needle_bytes.len();
641 } else {
642 offset += 1;
643 }
644 }
645 starts
646}
647
648fn contains_word(haystack: &str, needle: &str) -> bool {
649 !find_word_starts(haystack, needle).is_empty()
650}
651
652const fn is_boundary_before(bytes: &[u8], offset: usize) -> bool {
653 offset == 0 || !bytes[offset - 1].is_ascii_alphanumeric()
654}
655
656const fn is_boundary_after(bytes: &[u8], offset: usize) -> bool {
657 offset >= bytes.len() || !bytes[offset].is_ascii_alphanumeric()
658}
659
660fn following_proper_name(text: &str, mut offset: usize) -> Option<(&str, usize)> {
661 while offset < text.len() && text.as_bytes()[offset].is_ascii_whitespace() {
662 offset += 1;
663 }
664 let start = offset;
665 while offset < text.len() && text.as_bytes()[offset].is_ascii_alphabetic() {
666 offset += 1;
667 }
668 if offset <= start {
669 return None;
670 }
671 let name = &text[start..offset];
672 if name
673 .chars()
674 .next()
675 .is_some_and(|ch| ch.is_ascii_uppercase())
676 {
677 Some((name, offset))
678 } else {
679 None
680 }
681}
682
683fn following_day(lower: &str, mut offset: usize) -> Option<(u8, usize)> {
684 while offset < lower.len() && lower.as_bytes()[offset].is_ascii_whitespace() {
685 offset += 1;
686 }
687 let start = offset;
688 while offset < lower.len() && lower.as_bytes()[offset].is_ascii_digit() {
689 offset += 1;
690 }
691 if offset == start {
692 return None;
693 }
694 let day = lower[start..offset].parse::<u8>().ok()?;
695 if !(1..=31).contains(&day) {
696 return None;
697 }
698 for suffix in ["st", "nd", "rd", "th"] {
699 if lower[offset..].starts_with(suffix) {
700 offset += suffix.len();
701 break;
702 }
703 }
704 Some((day, offset))
705}
706
707fn find_iso_dates(lower: &str) -> Vec<(usize, usize, i32, &str, u8)> {
708 let bytes = lower.as_bytes();
709 let mut dates = Vec::new();
710 let mut offset = 0;
711 while offset + 10 <= bytes.len() {
712 if bytes[offset].is_ascii_digit()
713 && bytes[offset + 1].is_ascii_digit()
714 && bytes[offset + 2].is_ascii_digit()
715 && bytes[offset + 3].is_ascii_digit()
716 && bytes[offset + 4] == b'-'
717 && bytes[offset + 5].is_ascii_digit()
718 && bytes[offset + 6].is_ascii_digit()
719 && bytes[offset + 7] == b'-'
720 && bytes[offset + 8].is_ascii_digit()
721 && bytes[offset + 9].is_ascii_digit()
722 && is_boundary_before(bytes, offset)
723 && is_boundary_after(bytes, offset + 10)
724 {
725 if let (Ok(year), Ok(month), Ok(day)) = (
726 lower[offset..offset + 4].parse::<i32>(),
727 lower[offset + 5..offset + 7].parse::<u8>(),
728 lower[offset + 8..offset + 10].parse::<u8>(),
729 ) {
730 if (1..=12).contains(&month) && (1..=31).contains(&day) {
731 let month_term = match month {
732 1 => "01",
733 2 => "02",
734 3 => "03",
735 4 => "04",
736 5 => "05",
737 6 => "06",
738 7 => "07",
739 8 => "08",
740 9 => "09",
741 10 => "10",
742 11 => "11",
743 _ => "12",
744 };
745 dates.push((offset, offset + 10, year, month_term, day));
746 }
747 }
748 offset += 10;
749 } else {
750 offset += 1;
751 }
752 }
753 dates
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 fn terms(text: &str) -> Vec<String> {
761 key_index_terms(text)
762 }
763
764 #[test]
765 fn person_title_maps_doctor_to_name() {
766 let keys = extract_episodic_keys("I saw Dr. Patel yesterday");
767 assert!(
768 keys.iter()
769 .any(|key| key.category == KeyCategory::Person && key.term == "doctor")
770 );
771 assert!(
772 keys.iter()
773 .any(|key| key.category == KeyCategory::Person && key.term == "patel")
774 );
775 let doctor = keys
776 .iter()
777 .find(|key| key.term == "doctor")
778 .expect("doctor key");
779 assert!(doctor.start < doctor.end);
780 assert!(doctor.confidence > 0.9);
781 }
782
783 #[test]
784 fn date_normalizes_month_and_day() {
785 let keys = extract_episodic_keys("I volunteered on February 14th");
786 assert!(keys.iter().any(|key| key.term == "date-02-14"));
787 assert!(
788 extract_episodic_keys("due 2024-02-14")
789 .iter()
790 .any(|key| key.term == "date-02-14")
791 );
792 }
793
794 #[test]
795 fn organization_aliases_ucla() {
796 let keys = extract_episodic_keys(
797 "I completed my degree at the University of California, Los Angeles",
798 );
799 assert!(keys.iter().any(|key| key.term == "ucla"));
800 assert!(terms("UCLA").contains(&"university".to_string()));
801 }
802
803 #[test]
804 fn pet_breed_maps_to_dog() {
805 let keys = extract_episodic_keys("My Golden Retriever loves the park");
806 assert!(
807 keys.iter()
808 .any(|key| key.category == KeyCategory::Entity && key.term == "dog")
809 );
810 assert!(
811 keys.iter()
812 .any(|key| key.category == KeyCategory::Domain && key.term == "pets")
813 );
814 }
815
816 #[test]
817 fn preference_and_streaming_service_have_regression_cases() {
818 assert!(terms("Spotify is what I use").contains(&"spotify".to_string()));
819 assert!(
820 extract_episodic_keys(
821 "What is the name of the music streaming service have I been using lately?"
822 )
823 .iter()
824 .any(|key| key.term == "spotify" || key.term == "preference")
825 );
826 }
827
828 #[test]
829 fn empty_and_stopword_text_yields_no_keys() {
830 assert!(extract_episodic_keys("the and of").is_empty());
831 }
832
833 #[test]
834 fn numeric_keys_extracted_from_digits() {
835 let keys = extract_episodic_keys("It took 4 hours to finish");
836 assert!(
837 keys.iter()
838 .any(|k| k.category == KeyCategory::Quantity && k.term == "4")
839 );
840 let keys2 = extract_episodic_keys("I paid 200 dollars");
841 assert!(
842 keys2
843 .iter()
844 .any(|k| k.category == KeyCategory::Quantity && k.term == "200")
845 );
846 }
847
848 #[test]
849 fn selective_multi_word_phrase_extracted() {
850 let keys = extract_episodic_keys("I take classes at Serenity Yoga downtown");
851 assert!(
852 keys.iter()
853 .any(|k| k.category == KeyCategory::ProperNoun && k.term == "serenity-yoga")
854 );
855 }
856
857 #[test]
858 fn selective_all_caps_extracted() {
859 let keys = extract_episodic_keys("I bought it from IKEA last week");
860 assert!(
861 keys.iter()
862 .any(|k| k.category == KeyCategory::ProperNoun && k.term == "ikea")
863 );
864 }
865
866 #[test]
867 fn selective_single_capitalized_not_extracted() {
868 let keys = extract_episodic_keys("I bought it from Amazon last week");
869 assert!(
870 !keys
871 .iter()
872 .any(|k| k.category == KeyCategory::ProperNoun && k.term == "amazon")
873 );
874 }
875}