Skip to main content

systemprompt_models/brain/declarations/
synonyms.rs

1//! `synonyms.yaml`: groups of terms the search index treats as one.
2//!
3//! Expansion happens at index time, never at query time: when any member
4//! of a group occurs in a fact's text, every member of that group is
5//! appended to the text that is indexed at the lowest weight, so a query
6//! for one spelling finds a fact that uses another. Matching is on whole
7//! words, case-insensitively, and a multi-word member matches as a
8//! contiguous phrase.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use serde::{Deserialize, Serialize};
14
15/// The declared synonym groups; the first member of a group is its
16/// canonical spelling, which is only a convention for readers.
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields, default)]
19pub struct SynonymsDoc {
20    pub groups: Vec<Vec<String>>,
21}
22
23/// A synonyms document that cannot be applied.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
25pub enum SynonymsError {
26    #[error("synonym group {0} has fewer than two members")]
27    ShortGroup(usize),
28    #[error("synonym group {0} has an empty member")]
29    EmptyMember(usize),
30}
31
32impl SynonymsDoc {
33    pub fn validate(&self) -> Result<(), SynonymsError> {
34        for (index, group) in self.groups.iter().enumerate() {
35            if group.len() < 2 {
36                return Err(SynonymsError::ShortGroup(index));
37            }
38            if group.iter().any(|member| member.trim().is_empty()) {
39                return Err(SynonymsError::EmptyMember(index));
40            }
41        }
42        Ok(())
43    }
44
45    #[must_use]
46    pub fn expansions(&self, text: &str) -> Vec<String> {
47        let words = words_of(text);
48        let mut out: Vec<String> = Vec::new();
49        for group in &self.groups {
50            let present = group.iter().any(|member| contains_phrase(&words, member));
51            if !present {
52                continue;
53            }
54            for member in group {
55                if !out.iter().any(|seen| seen == member) {
56                    out.push(member.clone());
57                }
58            }
59        }
60        out
61    }
62
63    #[must_use]
64    pub fn expansion_text(&self, text: &str) -> String {
65        self.expansions(text).join(" ")
66    }
67}
68
69fn words_of(text: &str) -> Vec<String> {
70    text.split(|c: char| !c.is_alphanumeric())
71        .filter(|word| !word.is_empty())
72        .map(str::to_lowercase)
73        .collect()
74}
75
76fn contains_phrase(words: &[String], phrase: &str) -> bool {
77    let needle = words_of(phrase);
78    if needle.is_empty() || needle.len() > words.len() {
79        return false;
80    }
81    words.windows(needle.len()).any(|window| window == needle)
82}