1use crate::projector::{slug, CorpusKind, Projector, Situation};
7use crate::text::{Gazetteer, SpladeProjector, SpoTagger};
8use std::path::{Path, PathBuf};
9
10type Res<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
11
12const SPLADE_NOISE: &[&str] = &[
16 "amazon", "aws", "cloud", "architecture", "pattern", "patterns", "management", "data", "time",
17 "work", "works", "operations", "service", "services", "enterprise", "application", "applications",
18 "production", "configuration", "solution", "solutions", "customer", "customers", "use", "uses",
19 "using", "system", "systems", "platform", "platforms", "capability", "capabilities", "feature",
20 "features", "support", "level", "provide", "provides", "available", "new", "business", "team",
21 "teams", "organization", "organizations", "user", "users",
22];
23
24fn leaf_of(t: &str) -> &str {
25 match t.find('/') {
26 Some(i) => &t[i + 1..],
27 None => t,
28 }
29}
30
31pub struct TextEngine {
34 tagger: SpoTagger,
35 splade: Option<SpladeProjector>,
36 gaz: Option<Gazetteer>,
37 overlay_path: Option<PathBuf>,
40 #[cfg(feature = "native")]
45 tuned: Option<crate::tagger_train::TunedTagger>,
46}
47
48impl TextEngine {
49 pub fn load(ml_bundle: &Path, splade_dir: Option<&Path>) -> Res<TextEngine> {
50 let tagger = SpoTagger::load(ml_bundle)?;
51 let splade = match splade_dir {
52 Some(d) => Some(SpladeProjector::load(d, false)?),
53 None => None,
54 };
55 let gaz = gazetteer_path(splade_dir).and_then(|p| Gazetteer::load(&p).ok());
58 Ok(TextEngine {
59 tagger,
60 splade,
61 gaz,
62 overlay_path: None,
63 #[cfg(feature = "native")]
64 tuned: None,
65 })
66 }
67
68 pub fn enable_growth(&mut self, path: impl Into<PathBuf>) {
71 let path = path.into();
72 let mut gaz = self.gaz.take().unwrap_or_else(Gazetteer::empty);
73 gaz.merge_overlay(&path);
74 self.gaz = Some(gaz);
75 self.overlay_path = Some(path);
76 }
77
78 pub fn save_overlay(&self) -> usize {
80 if let (Some(gaz), Some(path)) = (self.gaz.as_ref(), self.overlay_path.as_ref()) {
81 let _ = gaz.save_overlay(path);
82 gaz.learned_len()
83 } else {
84 0
85 }
86 }
87
88 #[cfg(feature = "native")]
91 pub fn enable_tuned(&mut self, dir: &Path, base_dir: &Path, tokenizer: &Path, max_len: usize) -> Res<()> {
92 self.tuned = Some(crate::tagger_train::TunedTagger::load(dir, base_dir, tokenizer, max_len)?);
93 Ok(())
94 }
95
96 #[cfg(feature = "native")]
98 pub fn enable_relations(&mut self, dir: &Path, spec: &crate::vocabulary::VocabularySpace) -> Res<()> {
99 match self.tuned.as_mut() {
100 Some(t) => {
101 t.enable_relations(dir, spec)?;
102 Ok(())
103 }
104 None => Err("no tuned tagger — call enable_tuned first".into()),
105 }
106 }
107
108 pub fn is_tuned(&self) -> bool {
110 #[cfg(feature = "native")]
111 {
112 return self.tuned.is_some();
113 }
114 #[allow(unreachable_code)]
115 false
116 }
117
118 pub fn project_situation(&mut self, sentence: &str) -> Situation {
122 #[cfg(feature = "native")]
123 if let Some(tt) = self.tuned.as_ref() {
124 if let Ok(mut sit) = tt.project(sentence) {
125 if let Some(splade) = self.splade.as_mut() {
127 if let Ok(terms) = splade.project(sentence, 8, 0.3) {
128 for t in terms {
129 if !SPLADE_NOISE.contains(&leaf_of(&t.token)) {
130 sit.tokens.push(t.token);
131 }
132 }
133 }
134 }
135 if let Some(gaz) = self.gaz.as_ref() {
136 for h in gaz.extract(sentence) {
137 sit.tokens.push(h.token);
138 }
139 }
140 sit.tokens.sort();
141 sit.tokens.dedup();
142 if let Some((_, level)) = sit.beliefs.first().map(|(k, v)| (k.clone(), *v)) {
144 sit.beliefs = sit.tokens.iter().map(|t| (t.clone(), level)).collect();
145 }
146 return sit;
147 }
148 }
149 let (tokens, numbers) = self.project_sentence(sentence);
150 Situation { tokens, display: vec![sentence.to_string()], numbers, beliefs: Vec::new() }
151 }
152
153 pub fn project_sentence(&mut self, sentence: &str) -> (Vec<String>, Vec<(String, f64)>) {
156 let mut tokens: Vec<String> = Vec::new();
157 let mut numbers: Vec<(String, f64)> = Vec::new();
158 if let Ok(spans) = self.tagger.tag(sentence) {
159 for s in spans {
160 let v = slug(&s.text);
161 if !v.is_empty() {
162 let facet = facet_for(&s.kind);
163 let raw = format!("{facet}/{v}");
164 let mut token = raw;
170 if self.overlay_path.is_some() && matches!(s.kind.as_str(), "ENT" | "GEO") && s.text.split_whitespace().count() >= 2 {
171 if let Some(gaz) = self.gaz.as_mut() {
172 if let Some(canon) = gaz.register(&s.text, &facet) {
173 token = canon;
174 }
175 }
176 }
177 tokens.push(token);
178 }
179 if s.kind == "QTY" {
180 if let Some(nf) = crate::units::parse_quantity(&s.text) {
181 numbers.push(nf);
182 }
183 }
184 }
185 }
186 if let Some(splade) = self.splade.as_mut() {
187 if let Ok(terms) = splade.project(sentence, 8, 0.3) {
188 for t in terms {
189 if SPLADE_NOISE.contains(&leaf_of(&t.token)) {
191 continue;
192 }
193 tokens.push(t.token);
194 }
195 }
196 }
197 if let Some(gaz) = self.gaz.as_ref() {
201 for h in gaz.extract(sentence) {
202 tokens.push(h.token);
203 }
204 }
205 tokens.sort();
206 tokens.dedup();
207 (tokens, numbers)
208 }
209
210 pub fn tokens_for(&mut self, sentence: &str) -> Vec<String> {
212 self.project_sentence(sentence).0
213 }
214
215 pub fn project_text(&mut self, text: &str, sink: &mut dyn FnMut(Situation)) {
217 for sent in sentences(text) {
218 sink(self.project_situation(sent));
219 }
220 }
221}
222
223pub struct TextProjector {
224 path: PathBuf,
225 engine: TextEngine,
226}
227
228impl TextProjector {
229 pub fn open(path: impl Into<PathBuf>, ml_bundle: &Path, splade_dir: Option<&Path>) -> Res<TextProjector> {
230 Ok(TextProjector { path: path.into(), engine: TextEngine::load(ml_bundle, splade_dir)? })
231 }
232}
233
234pub fn sentences(text: &str) -> Vec<&str> {
236 let mut out = Vec::new();
237 let mut start = 0;
238 for (i, ch) in text.char_indices() {
239 if matches!(ch, '.' | '!' | '?' | '。' | '!' | '?' | '\n') {
240 let end = i + ch.len_utf8();
241 let s = text[start..end].trim();
242 if s.len() >= 2 {
243 out.push(s);
244 }
245 start = end;
246 }
247 }
248 let tail = text[start..].trim();
249 if tail.len() >= 2 {
250 out.push(tail);
251 }
252 out
253}
254
255fn gazetteer_path(splade_dir: Option<&Path>) -> Option<PathBuf> {
257 if let Ok(p) = std::env::var("STEELDB_GAZETTEER") {
258 let p = PathBuf::from(p);
259 if p.exists() {
260 return Some(p);
261 }
262 }
263 splade_dir.map(|d| d.join("gazetteer.json")).filter(|p| p.exists())
264}
265
266fn facet_for(kind: &str) -> String {
272 match kind {
273 "ENT" => "ent".to_string(),
274 "REL" => "rel".to_string(),
275 "GEO" => "geo".to_string(),
276 "TIME" => "time".to_string(),
277 "QTY" => "qty".to_string(),
278 "" | "IGNORE" | "O" => "misc".to_string(),
279 other => other.chars().filter(|c| c.is_alphanumeric() || *c == '-').flat_map(|c| c.to_lowercase()).collect(),
281 }
282}
283
284impl Projector for TextProjector {
285 fn columns(&self) -> Vec<String> {
286 vec!["sentence".to_string()]
287 }
288 fn kind(&self) -> CorpusKind {
289 CorpusKind::Text
290 }
291 fn source(&self) -> String {
292 self.path.display().to_string()
293 }
294 fn project(mut self: Box<Self>, sink: &mut dyn FnMut(Situation)) -> std::io::Result<()> {
295 let text = std::fs::read_to_string(&self.path)?;
296 self.engine.project_text(&text, sink);
297 Ok(())
298 }
299}