use crate::projector::{slug, CorpusKind, Projector, Situation};
use crate::text::{Gazetteer, SpladeProjector, SpoTagger};
use std::path::{Path, PathBuf};
type Res<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
const SPLADE_NOISE: &[&str] = &[
"amazon", "aws", "cloud", "architecture", "pattern", "patterns", "management", "data", "time",
"work", "works", "operations", "service", "services", "enterprise", "application", "applications",
"production", "configuration", "solution", "solutions", "customer", "customers", "use", "uses",
"using", "system", "systems", "platform", "platforms", "capability", "capabilities", "feature",
"features", "support", "level", "provide", "provides", "available", "new", "business", "team",
"teams", "organization", "organizations", "user", "users",
];
fn leaf_of(t: &str) -> &str {
match t.find('/') {
Some(i) => &t[i + 1..],
None => t,
}
}
pub struct TextEngine {
tagger: SpoTagger,
splade: Option<SpladeProjector>,
gaz: Option<Gazetteer>,
overlay_path: Option<PathBuf>,
#[cfg(feature = "native")]
tuned: Option<crate::tagger_train::TunedTagger>,
}
impl TextEngine {
pub fn load(ml_bundle: &Path, splade_dir: Option<&Path>) -> Res<TextEngine> {
let tagger = SpoTagger::load(ml_bundle)?;
let splade = match splade_dir {
Some(d) => Some(SpladeProjector::load(d, false)?),
None => None,
};
let gaz = gazetteer_path(splade_dir).and_then(|p| Gazetteer::load(&p).ok());
Ok(TextEngine {
tagger,
splade,
gaz,
overlay_path: None,
#[cfg(feature = "native")]
tuned: None,
})
}
pub fn enable_growth(&mut self, path: impl Into<PathBuf>) {
let path = path.into();
let mut gaz = self.gaz.take().unwrap_or_else(Gazetteer::empty);
gaz.merge_overlay(&path);
self.gaz = Some(gaz);
self.overlay_path = Some(path);
}
pub fn save_overlay(&self) -> usize {
if let (Some(gaz), Some(path)) = (self.gaz.as_ref(), self.overlay_path.as_ref()) {
let _ = gaz.save_overlay(path);
gaz.learned_len()
} else {
0
}
}
#[cfg(feature = "native")]
pub fn enable_tuned(&mut self, dir: &Path, base_dir: &Path, tokenizer: &Path, max_len: usize) -> Res<()> {
self.tuned = Some(crate::tagger_train::TunedTagger::load(dir, base_dir, tokenizer, max_len)?);
Ok(())
}
#[cfg(feature = "native")]
pub fn enable_relations(&mut self, dir: &Path, spec: &crate::vocabulary::VocabularySpace) -> Res<()> {
match self.tuned.as_mut() {
Some(t) => {
t.enable_relations(dir, spec)?;
Ok(())
}
None => Err("no tuned tagger — call enable_tuned first".into()),
}
}
pub fn is_tuned(&self) -> bool {
#[cfg(feature = "native")]
{
return self.tuned.is_some();
}
#[allow(unreachable_code)]
false
}
pub fn project_situation(&mut self, sentence: &str) -> Situation {
#[cfg(feature = "native")]
if let Some(tt) = self.tuned.as_ref() {
if let Ok(mut sit) = tt.project(sentence) {
if let Some(splade) = self.splade.as_mut() {
if let Ok(terms) = splade.project(sentence, 8, 0.3) {
for t in terms {
if !SPLADE_NOISE.contains(&leaf_of(&t.token)) {
sit.tokens.push(t.token);
}
}
}
}
if let Some(gaz) = self.gaz.as_ref() {
for h in gaz.extract(sentence) {
sit.tokens.push(h.token);
}
}
sit.tokens.sort();
sit.tokens.dedup();
if let Some((_, level)) = sit.beliefs.first().map(|(k, v)| (k.clone(), *v)) {
sit.beliefs = sit.tokens.iter().map(|t| (t.clone(), level)).collect();
}
return sit;
}
}
let (tokens, numbers) = self.project_sentence(sentence);
Situation { tokens, display: vec![sentence.to_string()], numbers, beliefs: Vec::new() }
}
pub fn project_sentence(&mut self, sentence: &str) -> (Vec<String>, Vec<(String, f64)>) {
let mut tokens: Vec<String> = Vec::new();
let mut numbers: Vec<(String, f64)> = Vec::new();
if let Ok(spans) = self.tagger.tag(sentence) {
for s in spans {
let v = slug(&s.text);
if !v.is_empty() {
let facet = facet_for(&s.kind);
let raw = format!("{facet}/{v}");
let mut token = raw;
if self.overlay_path.is_some() && matches!(s.kind.as_str(), "ENT" | "GEO") && s.text.split_whitespace().count() >= 2 {
if let Some(gaz) = self.gaz.as_mut() {
if let Some(canon) = gaz.register(&s.text, &facet) {
token = canon;
}
}
}
tokens.push(token);
}
if s.kind == "QTY" {
if let Some(nf) = crate::units::parse_quantity(&s.text) {
numbers.push(nf);
}
}
}
}
if let Some(splade) = self.splade.as_mut() {
if let Ok(terms) = splade.project(sentence, 8, 0.3) {
for t in terms {
if SPLADE_NOISE.contains(&leaf_of(&t.token)) {
continue;
}
tokens.push(t.token);
}
}
}
if let Some(gaz) = self.gaz.as_ref() {
for h in gaz.extract(sentence) {
tokens.push(h.token);
}
}
tokens.sort();
tokens.dedup();
(tokens, numbers)
}
pub fn tokens_for(&mut self, sentence: &str) -> Vec<String> {
self.project_sentence(sentence).0
}
pub fn project_text(&mut self, text: &str, sink: &mut dyn FnMut(Situation)) {
for sent in sentences(text) {
sink(self.project_situation(sent));
}
}
}
pub struct TextProjector {
path: PathBuf,
engine: TextEngine,
}
impl TextProjector {
pub fn open(path: impl Into<PathBuf>, ml_bundle: &Path, splade_dir: Option<&Path>) -> Res<TextProjector> {
Ok(TextProjector { path: path.into(), engine: TextEngine::load(ml_bundle, splade_dir)? })
}
}
pub fn sentences(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
for (i, ch) in text.char_indices() {
if matches!(ch, '.' | '!' | '?' | '。' | '!' | '?' | '\n') {
let end = i + ch.len_utf8();
let s = text[start..end].trim();
if s.len() >= 2 {
out.push(s);
}
start = end;
}
}
let tail = text[start..].trim();
if tail.len() >= 2 {
out.push(tail);
}
out
}
fn gazetteer_path(splade_dir: Option<&Path>) -> Option<PathBuf> {
if let Ok(p) = std::env::var("STEELDB_GAZETTEER") {
let p = PathBuf::from(p);
if p.exists() {
return Some(p);
}
}
splade_dir.map(|d| d.join("gazetteer.json")).filter(|p| p.exists())
}
fn facet_for(kind: &str) -> String {
match kind {
"ENT" => "ent".to_string(),
"REL" => "rel".to_string(),
"GEO" => "geo".to_string(),
"TIME" => "time".to_string(),
"QTY" => "qty".to_string(),
"" | "IGNORE" | "O" => "misc".to_string(),
other => other.chars().filter(|c| c.is_alphanumeric() || *c == '-').flat_map(|c| c.to_lowercase()).collect(),
}
}
impl Projector for TextProjector {
fn columns(&self) -> Vec<String> {
vec!["sentence".to_string()]
}
fn kind(&self) -> CorpusKind {
CorpusKind::Text
}
fn source(&self) -> String {
self.path.display().to_string()
}
fn project(mut self: Box<Self>, sink: &mut dyn FnMut(Situation)) -> std::io::Result<()> {
let text = std::fs::read_to_string(&self.path)?;
self.engine.project_text(&text, sink);
Ok(())
}
}