1use runmat_builtins::{
4 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6 CellArray, ObjectInstance, ResolveContext, StringArray, Type, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::strings::core::compat::scalar_text;
11use crate::builtins::strings::text_analytics::details::add_sentence_details_builtin;
12use crate::builtins::strings::text_analytics::documents::{
13 document_token_type_with_options, documents_from_object, options_from_document_object,
14 replace_tokenized_document_documents, text_analytics_error, tokenized_document_language,
15 DocumentTokenType, TOKENIZED_DOCUMENT_CLASS,
16};
17use crate::{gather_if_needed_async, BuiltinResult};
18
19pub(in crate::builtins::strings::text_analytics) const POS_DETAILS_PROPERTY: &str =
20 "PartOfSpeechDetails";
21
22const OUT_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
23 name: "updatedDocuments",
24 ty: BuiltinParamType::Any,
25 arity: BuiltinParamArity::Required,
26 default: None,
27 description: "Updated tokenized document object.",
28}];
29
30const IN_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31 name: "documents",
32 ty: BuiltinParamType::Any,
33 arity: BuiltinParamArity::Required,
34 default: None,
35 description: "tokenizedDocument object.",
36}];
37
38const IN_DOCUMENTS_REST: [BuiltinParamDescriptor; 2] = [
39 BuiltinParamDescriptor {
40 name: "documents",
41 ty: BuiltinParamType::Any,
42 arity: BuiltinParamArity::Required,
43 default: None,
44 description: "tokenizedDocument object.",
45 },
46 BuiltinParamDescriptor {
47 name: "NameValue",
48 ty: BuiltinParamType::Any,
49 arity: BuiltinParamArity::Variadic,
50 default: None,
51 description: "Name-value options: RetokenizeMethod, Abbreviations, DiscardKnownValues.",
52 },
53];
54
55const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
56 code: "RM.TEXT_ANALYTICS.ADD_PART_OF_SPEECH_DETAILS.INVALID_INPUT",
57 identifier: Some("RunMat:addPartOfSpeechDetails:InvalidInput"),
58 when: "Input is not a supported tokenizedDocument object or option form.",
59 message: "addPartOfSpeechDetails: invalid input",
60};
61
62const ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_INVALID_INPUT];
63
64pub const ADD_PART_OF_SPEECH_DETAILS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
65 signatures: &[
66 BuiltinSignatureDescriptor {
67 label: "updatedDocuments = addPartOfSpeechDetails(documents)",
68 inputs: &IN_DOCUMENTS,
69 outputs: &OUT_DOCUMENTS,
70 },
71 BuiltinSignatureDescriptor {
72 label: "updatedDocuments = addPartOfSpeechDetails(documents,Name,Value)",
73 inputs: &IN_DOCUMENTS_REST,
74 outputs: &OUT_DOCUMENTS,
75 },
76 ],
77 output_mode: BuiltinOutputMode::Fixed,
78 completion_policy: BuiltinCompletionPolicy::Public,
79 errors: &ERRORS,
80};
81
82fn any_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
83 Type::Unknown
84}
85
86#[runtime_builtin(
87 name = "addPartOfSpeechDetails",
88 category = "strings/text_analytics",
89 summary = "Add part-of-speech details to tokenizedDocument objects.",
90 keywords = "addPartOfSpeechDetails,text analytics,tokenizedDocument,part of speech,pos",
91 accel = "sink",
92 type_resolver(any_type),
93 descriptor(
94 crate::builtins::strings::text_analytics::pos::ADD_PART_OF_SPEECH_DETAILS_DESCRIPTOR
95 ),
96 builtin_path = "crate::builtins::strings::text_analytics::pos"
97)]
98pub(in crate::builtins::strings::text_analytics) async fn add_part_of_speech_details_builtin(
99 args: Vec<Value>,
100) -> BuiltinResult<Value> {
101 let gathered = gather_args(args).await?;
102 let (documents, options) = parse_args(gathered)?;
103 let mut object = tokenized_document_object(documents)?;
104 let language = PosLanguage::from_document(&tokenized_document_language(&object))?;
105
106 if options.retokenize_method == RetokenizeMethod::PartOfSpeech {
107 let documents = documents_from_object(&object, "addPartOfSpeechDetails")?;
108 let retokenized = documents
109 .iter()
110 .map(|doc| retokenize_for_pos(doc))
111 .collect::<Vec<_>>();
112 if retokenized != documents {
113 replace_tokenized_document_documents(
114 &mut object,
115 retokenized,
116 "addPartOfSpeechDetails",
117 )?;
118 clear_token_aligned_details(&mut object);
119 }
120 }
121
122 if !object.properties.contains_key("SentenceNumbers") {
123 object = add_sentence_details_for_pos(object, &options).await?;
124 }
125
126 let documents = documents_from_object(&object, "addPartOfSpeechDetails")?;
127 let stored = if options.discard_known_values {
128 None
129 } else {
130 part_of_speech_details_from_object(&object, "addPartOfSpeechDetails")?
131 };
132 let document_options = options_from_document_object(&object);
133 let pos = if options.discard_known_values {
134 part_of_speech_details_cell(&documents, language, &document_options)?
135 } else {
136 part_of_speech_details_cell_preserving_known(
137 &documents,
138 stored.as_deref(),
139 language,
140 &document_options,
141 )?
142 };
143 object
144 .properties
145 .insert(POS_DETAILS_PROPERTY.to_string(), pos);
146 Ok(Value::Object(object))
147}
148
149async fn gather_args(args: Vec<Value>) -> BuiltinResult<Vec<Value>> {
150 let mut out = Vec::with_capacity(args.len());
151 for arg in args {
152 out.push(gather_if_needed_async(&arg).await.map_err(|err| {
153 text_analytics_error(
154 "addPartOfSpeechDetails",
155 format!("addPartOfSpeechDetails: failed to gather input: {err}"),
156 )
157 })?);
158 }
159 Ok(out)
160}
161
162#[derive(Clone, Debug)]
163struct AddPosOptions {
164 discard_known_values: bool,
165 retokenize_method: RetokenizeMethod,
166 abbreviations: Option<Value>,
167}
168
169impl Default for AddPosOptions {
170 fn default() -> Self {
171 Self {
172 discard_known_values: false,
173 retokenize_method: RetokenizeMethod::PartOfSpeech,
174 abbreviations: None,
175 }
176 }
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180enum RetokenizeMethod {
181 PartOfSpeech,
182 None,
183}
184
185impl RetokenizeMethod {
186 fn parse(value: &str) -> BuiltinResult<Self> {
187 match value.trim().to_ascii_lowercase().as_str() {
188 "part-of-speech" | "partofspeech" => Ok(Self::PartOfSpeech),
189 "none" => Ok(Self::None),
190 other => Err(text_analytics_error(
191 "addPartOfSpeechDetails",
192 format!("addPartOfSpeechDetails: unsupported RetokenizeMethod '{other}'"),
193 )),
194 }
195 }
196}
197
198#[derive(Clone, Copy, Debug, PartialEq, Eq)]
199pub(in crate::builtins::strings::text_analytics) enum PosLanguage {
200 English,
201 Japanese,
202 German,
203 Korean,
204}
205
206impl PosLanguage {
207 fn from_document(language: &str) -> BuiltinResult<Self> {
208 Self::from_document_for(language, "addPartOfSpeechDetails")
209 }
210
211 pub(in crate::builtins::strings::text_analytics) fn from_document_for(
212 language: &str,
213 fn_name: &str,
214 ) -> BuiltinResult<Self> {
215 match language.trim().to_ascii_lowercase().as_str() {
216 "en" => Ok(Self::English),
217 "ja" => Ok(Self::Japanese),
218 "de" => Ok(Self::German),
219 "ko" => Ok(Self::Korean),
220 other => Err(text_analytics_error(
221 fn_name,
222 format!("{fn_name}: unsupported document language '{other}'"),
223 )),
224 }
225 }
226}
227
228fn parse_args(args: Vec<Value>) -> BuiltinResult<(Value, AddPosOptions)> {
229 if args.is_empty() {
230 return Err(text_analytics_error(
231 "addPartOfSpeechDetails",
232 "addPartOfSpeechDetails: expected tokenizedDocument input",
233 ));
234 }
235 if !(args.len() - 1).is_multiple_of(2) {
236 return Err(text_analytics_error(
237 "addPartOfSpeechDetails",
238 "addPartOfSpeechDetails: name-value options must appear in pairs",
239 ));
240 }
241 let mut options = AddPosOptions::default();
242 let mut idx = 1usize;
243 while idx < args.len() {
244 let name = scalar_text(&args[idx], "addPartOfSpeechDetails")
245 .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err.to_string()))?;
246 if name.eq_ignore_ascii_case("DiscardKnownValues") {
247 options.discard_known_values = logical_scalar(&args[idx + 1])?;
248 } else if name.eq_ignore_ascii_case("RetokenizeMethod") {
249 let value = scalar_text(&args[idx + 1], "addPartOfSpeechDetails")
250 .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err.to_string()))?;
251 options.retokenize_method = RetokenizeMethod::parse(&value)?;
252 } else if name.eq_ignore_ascii_case("Abbreviations") {
253 options.abbreviations = Some(args[idx + 1].clone());
254 } else {
255 return Err(text_analytics_error(
256 "addPartOfSpeechDetails",
257 format!("addPartOfSpeechDetails: unsupported option '{name}'"),
258 ));
259 }
260 idx += 2;
261 }
262 Ok((args[0].clone(), options))
263}
264
265fn tokenized_document_object(value: Value) -> BuiltinResult<ObjectInstance> {
266 match value {
267 Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => Ok(object),
268 Value::Object(object) => Err(text_analytics_error(
269 "addPartOfSpeechDetails",
270 format!(
271 "addPartOfSpeechDetails: expected tokenizedDocument object, got {}",
272 object.class_name
273 ),
274 )),
275 other => Err(text_analytics_error(
276 "addPartOfSpeechDetails",
277 format!("addPartOfSpeechDetails: expected tokenizedDocument object, got {other:?}"),
278 )),
279 }
280}
281
282fn clear_token_aligned_details(object: &mut ObjectInstance) {
283 for property in [
284 "TypeDetails",
285 "SentenceNumbers",
286 "LemmaDetails",
287 POS_DETAILS_PROPERTY,
288 "EntityDetails",
289 "HeadDetails",
290 "DependencyDetails",
291 ] {
292 object.properties.remove(property);
293 }
294}
295
296async fn add_sentence_details_for_pos(
297 object: ObjectInstance,
298 options: &AddPosOptions,
299) -> BuiltinResult<ObjectInstance> {
300 let mut args = vec![Value::Object(object)];
301 if let Some(abbreviations) = &options.abbreviations {
302 args.push(Value::String("Abbreviations".to_string()));
303 args.push(abbreviations.clone());
304 }
305 let Value::Object(object) = add_sentence_details_builtin(args).await? else {
306 return Err(text_analytics_error(
307 "addPartOfSpeechDetails",
308 "addPartOfSpeechDetails: addSentenceDetails did not return tokenizedDocument",
309 ));
310 };
311 Ok(object)
312}
313
314fn part_of_speech_details_cell(
315 documents: &[Vec<String>],
316 language: PosLanguage,
317 options: &crate::builtins::strings::text_analytics::documents::DocumentOptions,
318) -> BuiltinResult<Value> {
319 let values = documents
320 .iter()
321 .map(|doc| {
322 let tags = doc
323 .iter()
324 .map(|token| part_of_speech_for_token(token, language, options).to_string())
325 .collect::<Vec<_>>();
326 StringArray::new(tags, vec![1, doc.len()])
327 .map(Value::StringArray)
328 .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err))
329 })
330 .collect::<BuiltinResult<Vec<_>>>()?;
331 Ok(Value::Cell(
332 CellArray::new(values, documents.len(), 1)
333 .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err))?,
334 ))
335}
336
337fn part_of_speech_details_cell_preserving_known(
338 documents: &[Vec<String>],
339 stored: Option<&[Vec<String>]>,
340 language: PosLanguage,
341 options: &crate::builtins::strings::text_analytics::documents::DocumentOptions,
342) -> BuiltinResult<Value> {
343 let values = documents
344 .iter()
345 .enumerate()
346 .map(|(doc_idx, doc)| {
347 let tags = doc
348 .iter()
349 .enumerate()
350 .map(|(token_idx, token)| {
351 stored
352 .and_then(|values| values.get(doc_idx))
353 .and_then(|values| values.get(token_idx))
354 .filter(|tag| is_known_part_of_speech(tag))
355 .cloned()
356 .unwrap_or_else(|| {
357 part_of_speech_for_token(token, language, options).to_string()
358 })
359 })
360 .collect::<Vec<_>>();
361 StringArray::new(tags, vec![1, doc.len()])
362 .map(Value::StringArray)
363 .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err))
364 })
365 .collect::<BuiltinResult<Vec<_>>>()?;
366 Ok(Value::Cell(
367 CellArray::new(values, documents.len(), 1)
368 .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err))?,
369 ))
370}
371
372pub(in crate::builtins::strings::text_analytics) fn part_of_speech_for_token(
373 token: &str,
374 language: PosLanguage,
375 options: &crate::builtins::strings::text_analytics::documents::DocumentOptions,
376) -> &'static str {
377 match document_token_type_with_options(token, options) {
378 DocumentTokenType::Punctuation => "punctuation",
379 DocumentTokenType::Digits => "numeral",
380 DocumentTokenType::WebAddress
381 | DocumentTokenType::EmailAddress
382 | DocumentTokenType::Hashtag
383 | DocumentTokenType::AtMention
384 | DocumentTokenType::Emoticon
385 | DocumentTokenType::Emoji
386 | DocumentTokenType::Other => "other",
387 DocumentTokenType::Letters => match language {
388 PosLanguage::English => english_part_of_speech(token),
389 PosLanguage::German => german_part_of_speech(token),
390 PosLanguage::Japanese | PosLanguage::Korean => "noun",
391 },
392 }
393}
394
395fn english_part_of_speech(token: &str) -> &'static str {
396 let lower = token.to_ascii_lowercase();
397 let word = lower.trim_matches('\'');
398 if word.is_empty() {
399 return "other";
400 }
401 if matches!(
402 word,
403 "i" | "me"
404 | "my"
405 | "mine"
406 | "myself"
407 | "we"
408 | "us"
409 | "our"
410 | "ours"
411 | "ourselves"
412 | "you"
413 | "your"
414 | "yours"
415 | "yourself"
416 | "yourselves"
417 | "he"
418 | "him"
419 | "his"
420 | "himself"
421 | "she"
422 | "her"
423 | "hers"
424 | "herself"
425 | "it"
426 | "its"
427 | "itself"
428 | "they"
429 | "them"
430 | "their"
431 | "theirs"
432 | "themselves"
433 | "who"
434 | "whom"
435 | "whose"
436 | "which"
437 | "that"
438 | "this"
439 | "these"
440 | "those"
441 ) {
442 return "pronoun";
443 }
444 if matches!(
445 word,
446 "a" | "an" | "the" | "some" | "any" | "each" | "every" | "no"
447 ) {
448 return "determiner";
449 }
450 if matches!(word, "and" | "or" | "but" | "nor" | "yet" | "so" | "for") {
451 return "coord-conjunction";
452 }
453 if matches!(
454 word,
455 "because" | "although" | "while" | "if" | "when" | "since" | "unless"
456 ) {
457 return "subord-conjunction";
458 }
459 if matches!(
460 word,
461 "in" | "on"
462 | "at"
463 | "by"
464 | "from"
465 | "with"
466 | "without"
467 | "under"
468 | "over"
469 | "into"
470 | "onto"
471 | "through"
472 | "during"
473 | "before"
474 | "after"
475 | "of"
476 | "as"
477 | "than"
478 ) {
479 return "adposition";
480 }
481 if matches!(word, "to" | "n't" | "not") {
482 return "particle";
483 }
484 if matches!(
485 word,
486 "am" | "are"
487 | "is"
488 | "was"
489 | "were"
490 | "be"
491 | "been"
492 | "being"
493 | "have"
494 | "has"
495 | "had"
496 | "do"
497 | "does"
498 | "did"
499 | "can"
500 | "could"
501 | "may"
502 | "might"
503 | "must"
504 | "shall"
505 | "should"
506 | "will"
507 | "would"
508 | "'m"
509 | "'re"
510 | "'ve"
511 | "'ll"
512 | "'d"
513 | "'s"
514 ) {
515 return "auxiliary-verb";
516 }
517 if matches!(
518 word,
519 "run"
520 | "runs"
521 | "ran"
522 | "walk"
523 | "walks"
524 | "go"
525 | "goes"
526 | "went"
527 | "make"
528 | "makes"
529 | "made"
530 | "build"
531 | "builds"
532 | "built"
533 | "read"
534 | "write"
535 | "writes"
536 | "wrote"
537 | "see"
538 | "sees"
539 | "saw"
540 | "want"
541 | "wants"
542 | "chase"
543 | "chases"
544 | "chased"
545 | "attend"
546 | "attends"
547 | "attended"
548 | "going"
549 | "sleep"
550 | "sleeps"
551 | "fly"
552 | "flies"
553 | "using"
554 | "use"
555 | "uses"
556 ) || word.ends_with("ing")
557 || word.ends_with("ed")
558 {
559 return "verb";
560 }
561 if word.ends_with("ly") {
562 return "adverb";
563 }
564 if word.ends_with("ive")
565 || word.ends_with("ous")
566 || word.ends_with("ful")
567 || word.ends_with("less")
568 || word.ends_with("able")
569 || word.ends_with("ible")
570 || matches!(word, "good" | "bad" | "new" | "old" | "large" | "small")
571 {
572 return "adjective";
573 }
574 "noun"
575}
576
577fn german_part_of_speech(token: &str) -> &'static str {
578 let lower = token.to_ascii_lowercase();
579 let word = lower.trim_matches('\'');
580 if word.is_empty() {
581 return "other";
582 }
583 if matches!(
584 word,
585 "ich"
586 | "mich"
587 | "mir"
588 | "du"
589 | "dich"
590 | "dir"
591 | "er"
592 | "ihn"
593 | "ihm"
594 | "sie"
595 | "ihr"
596 | "es"
597 | "wir"
598 | "uns"
599 | "euch"
600 | "mein"
601 | "dein"
602 | "sein"
603 | "dieser"
604 | "diese"
605 | "dieses"
606 | "wer"
607 | "was"
608 ) {
609 return "pronoun";
610 }
611 if matches!(
612 word,
613 "der"
614 | "die"
615 | "das"
616 | "den"
617 | "dem"
618 | "des"
619 | "ein"
620 | "eine"
621 | "einen"
622 | "einem"
623 | "einer"
624 | "eines"
625 | "kein"
626 | "keine"
627 | "jeder"
628 | "jede"
629 | "jedes"
630 ) {
631 return "determiner";
632 }
633 if matches!(word, "und" | "oder" | "aber" | "denn") {
634 return "coord-conjunction";
635 }
636 if matches!(word, "weil" | "wenn" | "dass" | "obwohl" | "während") {
637 return "subord-conjunction";
638 }
639 if matches!(
640 word,
641 "in" | "auf"
642 | "an"
643 | "bei"
644 | "von"
645 | "mit"
646 | "ohne"
647 | "unter"
648 | "über"
649 | "durch"
650 | "vor"
651 | "nach"
652 | "zu"
653 | "aus"
654 | "für"
655 | "gegen"
656 | "als"
657 ) {
658 return "adposition";
659 }
660 if word.ends_with("lich")
661 || word.ends_with("ig")
662 || word.ends_with("isch")
663 || matches!(
664 word,
665 "gut" | "gute" | "guter" | "guten" | "neu" | "alte" | "groß" | "klein"
666 )
667 {
668 return "adjective";
669 }
670 if token.chars().next().is_some_and(char::is_uppercase) {
671 return "noun";
672 }
673 if word.ends_with("weise") || matches!(word, "heute" | "morgen" | "wie" | "gern") {
674 return "adverb";
675 }
676 if matches!(
677 word,
678 "bin"
679 | "bist"
680 | "ist"
681 | "sind"
682 | "seid"
683 | "war"
684 | "waren"
685 | "sein"
686 | "gewesen"
687 | "habe"
688 | "hast"
689 | "hat"
690 | "haben"
691 | "hatte"
692 | "hatten"
693 | "werde"
694 | "wird"
695 | "werden"
696 | "wurde"
697 | "kann"
698 | "können"
699 | "muss"
700 | "müssen"
701 | "soll"
702 | "sollen"
703 ) {
704 return "auxiliary-verb";
705 }
706 if matches!(
707 word,
708 "geht"
709 | "gehen"
710 | "ging"
711 | "laufen"
712 | "läuft"
713 | "machen"
714 | "macht"
715 | "sagen"
716 | "sagt"
717 | "kommt"
718 | "kommen"
719 ) || word.ends_with("en")
720 || word.ends_with("st")
721 || word.ends_with("te")
722 {
723 return "verb";
724 }
725 "noun"
726}
727
728fn retokenize_for_pos(tokens: &[String]) -> Vec<String> {
729 let mut out = Vec::new();
730 let mut idx = 0usize;
731 while idx < tokens.len() {
732 if let Some((merged, consumed)) = dotted_initialism_at(tokens, idx) {
733 out.push(merged);
734 idx += consumed;
735 continue;
736 }
737 if tokens[idx] == "." && tokens.get(idx + 1).is_some_and(|token| token == ".") {
738 let mut consumed = 1usize;
739 while tokens.get(idx + consumed).is_some_and(|token| token == ".") {
740 consumed += 1;
741 }
742 out.push(".".repeat(consumed));
743 idx += consumed;
744 continue;
745 }
746 if let Some(split) = split_pos_token(&tokens[idx]) {
747 out.extend(split);
748 } else {
749 out.push(tokens[idx].clone());
750 }
751 idx += 1;
752 }
753 out
754}
755
756fn dotted_initialism_at(tokens: &[String], idx: usize) -> Option<(String, usize)> {
757 let mut cursor = idx;
758 let mut letters = Vec::new();
759 while cursor + 1 < tokens.len()
760 && is_single_letter(&tokens[cursor])
761 && tokens[cursor + 1] == "."
762 {
763 letters.push(tokens[cursor].clone());
764 cursor += 2;
765 }
766 if letters.len() < 2 {
767 return None;
768 }
769 Some((format!("{}.", letters.join(".")), cursor - idx))
770}
771
772fn is_single_letter(token: &str) -> bool {
773 token.chars().count() == 1 && token.chars().all(char::is_alphabetic)
774}
775
776fn split_pos_token(token: &str) -> Option<Vec<String>> {
777 let lower = token.to_ascii_lowercase();
778 match lower.as_str() {
779 "can't" => Some(vec!["can".to_string(), "n't".to_string()]),
780 "cannot" => Some(vec!["can".to_string(), "not".to_string()]),
781 "won't" => Some(vec!["will".to_string(), "not".to_string()]),
782 "wanna" => Some(vec!["want".to_string(), "to".to_string()]),
783 "gonna" => Some(vec!["going".to_string(), "to".to_string()]),
784 _ => {
785 for suffix in ["n't", "'re", "'ve", "'ll", "'d", "'m", "'s"] {
786 if lower.ends_with(suffix) && lower.len() > suffix.len() {
787 let stem = token[..token.len() - suffix.len()].to_string();
788 return Some(vec![stem, suffix.to_string()]);
789 }
790 }
791 None
792 }
793 }
794}
795
796fn is_known_part_of_speech(value: &str) -> bool {
797 let trimmed = value.trim();
798 !trimmed.is_empty()
799 && trimmed != "unknown"
800 && !crate::builtins::strings::common::is_missing_string(trimmed)
801}
802
803pub(in crate::builtins::strings::text_analytics) fn part_of_speech_details_from_object(
804 object: &ObjectInstance,
805 fn_name: &str,
806) -> BuiltinResult<Option<Vec<Vec<String>>>> {
807 let Some(value) = object.properties.get(POS_DETAILS_PROPERTY) else {
808 return Ok(None);
809 };
810 let Value::Cell(cell) = value else {
811 return Err(text_analytics_error(
812 fn_name,
813 format!("{fn_name}: tokenizedDocument object has invalid PartOfSpeechDetails property"),
814 ));
815 };
816 if cell.cols != 1 {
817 return Err(text_analytics_error(
818 fn_name,
819 format!("{fn_name}: tokenizedDocument object has invalid PartOfSpeechDetails shape"),
820 ));
821 }
822 let mut out = Vec::with_capacity(cell.data.len());
823 for item in &cell.data {
824 let Value::StringArray(array) = item else {
825 return Err(text_analytics_error(
826 fn_name,
827 format!(
828 "{fn_name}: tokenizedDocument object has invalid PartOfSpeechDetails entry"
829 ),
830 ));
831 };
832 if array.rows != 1 {
833 return Err(text_analytics_error(
834 fn_name,
835 format!(
836 "{fn_name}: tokenizedDocument object has invalid PartOfSpeechDetails entry shape"
837 ),
838 ));
839 }
840 out.push(array.data.clone());
841 }
842 Ok(Some(out))
843}
844
845fn logical_scalar(value: &Value) -> BuiltinResult<bool> {
846 match value {
847 Value::Bool(value) => Ok(*value),
848 Value::Num(value) if *value == 0.0 || *value == 1.0 => Ok(*value != 0.0),
849 Value::Tensor(tensor) if tensor.data.len() == 1 => match tensor.data[0] {
850 0.0 => Ok(false),
851 1.0 => Ok(true),
852 other => Err(text_analytics_error(
853 "addPartOfSpeechDetails",
854 format!(
855 "addPartOfSpeechDetails: logical scalar option must be true or false, got {other}"
856 ),
857 )),
858 },
859 Value::LogicalArray(array) if array.data.len() == 1 => Ok(array.data[0] != 0),
860 other => Err(text_analytics_error(
861 "addPartOfSpeechDetails",
862 format!(
863 "addPartOfSpeechDetails: logical scalar option must be true or false, got {other:?}"
864 ),
865 )),
866 }
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use crate::builtins::strings::text_analytics::details::token_details_builtin;
873 use crate::builtins::strings::text_analytics::documents::tokenized_document_builtin;
874 use crate::builtins::table::{table_variable_names_from_object, table_variables};
875 use runmat_builtins::{LogicalArray, Tensor};
876
877 fn run_tokenized(args: Vec<Value>) -> BuiltinResult<Value> {
878 futures::executor::block_on(tokenized_document_builtin(args))
879 }
880
881 fn run_add_pos(args: Vec<Value>) -> BuiltinResult<Value> {
882 futures::executor::block_on(add_part_of_speech_details_builtin(args))
883 }
884
885 fn run_token_details(value: Value) -> BuiltinResult<Value> {
886 futures::executor::block_on(token_details_builtin(value))
887 }
888
889 fn object(value: Value) -> ObjectInstance {
890 let Value::Object(object) = value else {
891 panic!("expected object");
892 };
893 object
894 }
895
896 fn table_column(table: &ObjectInstance, name: &str) -> Value {
897 table_variables(table)
898 .expect("table variables")
899 .fields
900 .get(name)
901 .cloned()
902 .unwrap_or_else(|| panic!("missing table column {name}"))
903 }
904
905 fn string_column(table: &ObjectInstance, name: &str) -> Vec<String> {
906 match table_column(table, name) {
907 Value::StringArray(array) => array.data,
908 other => panic!("expected string column {name}, got {other:?}"),
909 }
910 }
911
912 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
913 #[test]
914 fn add_part_of_speech_details_adds_sentence_and_pos_columns() {
915 let docs =
916 run_tokenized(vec![Value::String("The dogs are running.".into())]).expect("tokenized");
917 let updated = run_add_pos(vec![docs]).expect("pos");
918 let table = object(run_token_details(updated).expect("details"));
919
920 assert_eq!(
921 table_variable_names_from_object(&table).expect("names"),
922 vec![
923 "Token",
924 "DocumentNumber",
925 "SentenceNumber",
926 "LineNumber",
927 "Type",
928 "Language",
929 "PartOfSpeech"
930 ]
931 );
932 assert_eq!(
933 string_column(&table, "PartOfSpeech"),
934 vec![
935 "determiner",
936 "noun",
937 "auxiliary-verb",
938 "verb",
939 "punctuation"
940 ]
941 );
942 }
943
944 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
945 #[test]
946 fn add_part_of_speech_details_retokenizes_common_pos_forms() {
947 let docs = run_tokenized(vec![Value::String(
948 "I can't attend the U.S.A. event.".into(),
949 )])
950 .expect("tokenized");
951 let updated = run_add_pos(vec![docs]).expect("pos");
952 let table = object(run_token_details(updated).expect("details"));
953 assert_eq!(
954 string_column(&table, "Token"),
955 vec!["I", "can", "n't", "attend", "the", "U.S.A.", "event", "."]
956 );
957 assert_eq!(
958 string_column(&table, "PartOfSpeech"),
959 vec![
960 "pronoun",
961 "auxiliary-verb",
962 "particle",
963 "verb",
964 "determiner",
965 "other",
966 "noun",
967 "punctuation"
968 ]
969 );
970 }
971
972 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
973 #[test]
974 fn add_part_of_speech_details_preserves_existing_known_values_unless_discarding() {
975 let docs = object(
976 run_tokenized(vec![
977 Value::StringArray(
978 StringArray::new(vec!["dogs".into(), "run".into()], vec![1, 2]).unwrap(),
979 ),
980 Value::String("TokenizeMethod".into()),
981 Value::String("none".into()),
982 ])
983 .expect("tokenized"),
984 );
985 let mut stale = docs.clone();
986 stale.properties.insert(
987 "SentenceNumbers".to_string(),
988 Value::Cell(
989 CellArray::new(
990 vec![Value::Tensor(
991 Tensor::new(vec![1.0, 1.0], vec![1, 2]).unwrap(),
992 )],
993 1,
994 1,
995 )
996 .unwrap(),
997 ),
998 );
999 stale.properties.insert(
1000 POS_DETAILS_PROPERTY.to_string(),
1001 Value::Cell(
1002 CellArray::new(
1003 vec![Value::StringArray(
1004 StringArray::new(vec!["custom".into(), "".into()], vec![1, 2]).unwrap(),
1005 )],
1006 1,
1007 1,
1008 )
1009 .unwrap(),
1010 ),
1011 );
1012
1013 let preserved = run_add_pos(vec![
1014 Value::Object(stale.clone()),
1015 Value::String("RetokenizeMethod".into()),
1016 Value::String("none".into()),
1017 ])
1018 .expect("preserve");
1019 let table = object(run_token_details(preserved).expect("details"));
1020 assert_eq!(
1021 string_column(&table, "PartOfSpeech"),
1022 vec!["custom", "verb"]
1023 );
1024
1025 let recomputed = run_add_pos(vec![
1026 Value::Object(stale),
1027 Value::String("RetokenizeMethod".into()),
1028 Value::String("none".into()),
1029 Value::String("DiscardKnownValues".into()),
1030 Value::LogicalArray(LogicalArray::new(vec![1], vec![1, 1]).unwrap()),
1031 ])
1032 .expect("recompute");
1033 let table = object(run_token_details(recomputed).expect("details"));
1034 assert_eq!(string_column(&table, "PartOfSpeech"), vec!["noun", "verb"]);
1035 }
1036
1037 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1038 #[test]
1039 fn add_part_of_speech_details_rejects_bad_options_and_languages() {
1040 let docs = run_tokenized(vec![Value::String("dogs run".into())]).expect("tokenized");
1041 let err = run_add_pos(vec![
1042 docs.clone(),
1043 Value::String("Unknown".into()),
1044 Value::Bool(true),
1045 ])
1046 .expect_err("expected bad option");
1047 assert!(err.to_string().contains("unsupported option"));
1048
1049 let err = run_add_pos(vec![
1050 docs.clone(),
1051 Value::String("RetokenizeMethod".into()),
1052 Value::String("bogus".into()),
1053 ])
1054 .expect_err("expected bad retokenizer");
1055 assert!(err.to_string().contains("unsupported RetokenizeMethod"));
1056
1057 let mut unsupported = object(docs);
1058 unsupported
1059 .properties
1060 .insert("Language".into(), Value::String("fr".into()));
1061 let err = run_add_pos(vec![Value::Object(unsupported)]).expect_err("expected bad language");
1062 assert!(err.to_string().contains("unsupported document language"));
1063 }
1064
1065 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1066 #[test]
1067 fn add_part_of_speech_details_supports_documented_tags_and_german() {
1068 assert_eq!(english_part_of_speech("and"), "coord-conjunction");
1069 assert_eq!(english_part_of_speech("because"), "subord-conjunction");
1070
1071 let docs = object(run_tokenized(vec![Value::String("Guten Morgen.".into())]).unwrap());
1072 let mut german = docs;
1073 german
1074 .properties
1075 .insert("Language".into(), Value::String("de".into()));
1076 let updated = run_add_pos(vec![Value::Object(german)]).expect("german pos");
1077 let table = object(run_token_details(updated).expect("details"));
1078 assert_eq!(
1079 string_column(&table, "PartOfSpeech"),
1080 vec!["adjective", "noun", "punctuation"]
1081 );
1082 }
1083
1084 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1085 #[test]
1086 fn add_part_of_speech_details_discard_known_values_ignores_malformed_stored_tags() {
1087 let docs =
1088 object(run_tokenized(vec![Value::String("dogs run".into())]).expect("tokenized"));
1089 let mut malformed = docs.clone();
1090 malformed.properties.insert(
1091 POS_DETAILS_PROPERTY.to_string(),
1092 Value::String("bad".into()),
1093 );
1094
1095 let updated = run_add_pos(vec![
1096 Value::Object(malformed),
1097 Value::String("DiscardKnownValues".into()),
1098 Value::Bool(true),
1099 ])
1100 .expect("discard malformed stored tags");
1101 let table = object(run_token_details(updated).expect("details"));
1102 assert_eq!(string_column(&table, "PartOfSpeech"), vec!["noun", "verb"]);
1103 }
1104
1105 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1106 #[test]
1107 fn token_details_rejects_malformed_part_of_speech_details() {
1108 let docs =
1109 object(run_tokenized(vec![Value::String("dogs run".into())]).expect("tokenized"));
1110 let mut malformed = docs.clone();
1111 malformed.properties.insert(
1112 POS_DETAILS_PROPERTY.to_string(),
1113 Value::Cell(
1114 CellArray::new(
1115 vec![Value::StringArray(
1116 StringArray::new(vec!["noun".into()], vec![1, 1]).unwrap(),
1117 )],
1118 1,
1119 1,
1120 )
1121 .unwrap(),
1122 ),
1123 );
1124 let err = run_token_details(Value::Object(malformed)).expect_err("expected error");
1125 assert!(err.to_string().contains("PartOfSpeechDetails entry"));
1126 }
1127}