use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Deserialize, Serialize)]
struct GazEntry {
surface: String,
token: String,
}
pub struct GazHit {
pub token: String,
pub span: (usize, usize),
}
pub struct Gazetteer {
map: HashMap<String, String>,
max_words: usize,
learned: HashMap<String, String>,
}
impl Gazetteer {
pub fn load(path: &Path) -> Result<Gazetteer, Box<dyn std::error::Error + Send + Sync>> {
let entries: Vec<GazEntry> = serde_json::from_slice(&std::fs::read(path)?)?;
let mut g = Gazetteer::empty();
for e in entries {
g.insert(&e.surface, e.token, false);
}
Ok(g)
}
pub fn empty() -> Gazetteer {
Gazetteer { map: HashMap::new(), max_words: 1, learned: HashMap::new() }
}
fn insert(&mut self, surface: &str, token: String, learned: bool) -> bool {
let key = normalize(surface);
if key.is_empty() || token.is_empty() {
return false;
}
self.max_words = self.max_words.max(key.split(' ').count());
let is_new = !self.map.contains_key(&key);
self.map.entry(key.clone()).or_insert_with(|| token.clone());
if learned {
self.learned.entry(key).or_insert(token);
}
is_new
}
pub fn learn(&mut self, surface: &str, token: impl Into<String>) -> bool {
self.insert(surface, token.into(), true)
}
pub fn register(&mut self, surface: &str, facet: &str) -> Option<String> {
let core = canonical_core(surface)?;
let token = format!("{facet}/{core}");
self.insert(surface, token.clone(), true);
Some(token)
}
pub fn merge_overlay(&mut self, path: &Path) {
if let Ok(bytes) = std::fs::read(path) {
if let Ok(entries) = serde_json::from_slice::<Vec<GazEntry>>(&bytes) {
for e in entries {
self.insert(&e.surface, e.token, true);
}
}
}
}
pub fn save_overlay(&self, path: &Path) -> std::io::Result<()> {
let mut merged: HashMap<String, String> = HashMap::new();
if let Ok(bytes) = std::fs::read(path) {
if let Ok(prev) = serde_json::from_slice::<Vec<GazEntry>>(&bytes) {
for e in prev {
merged.insert(normalize(&e.surface), e.token);
}
}
}
for (k, v) in &self.learned {
merged.insert(k.clone(), v.clone());
}
let mut entries: Vec<GazEntry> = merged.into_iter().map(|(surface, token)| GazEntry { surface, token }).collect();
entries.sort_by(|a, b| a.surface.cmp(&b.surface));
let json = serde_json::to_vec_pretty(&entries).map_err(std::io::Error::other)?;
std::fs::write(path, json)
}
pub fn learned_len(&self) -> usize {
self.learned.len()
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn extract(&self, text: &str) -> Vec<GazHit> {
let words = words_with_spans(text);
let mut out = Vec::new();
let mut i = 0;
while i < words.len() {
let mut matched = false;
let hi = (self.max_words).min(words.len() - i);
for n in (1..=hi).rev() {
let phrase: Vec<&str> = words[i..i + n].iter().map(|(w, _, _)| w.as_str()).collect();
let key = phrase.join(" ");
if let Some(tok) = self.map.get(&key) {
let span = (words[i].1, words[i + n - 1].2);
out.push(GazHit { token: tok.clone(), span });
i += n;
matched = true;
break;
}
}
if !matched {
i += 1;
}
}
out
}
}
fn normalize(s: &str) -> String {
words_with_spans(s).into_iter().map(|(w, _, _)| w).collect::<Vec<_>>().join(" ")
}
const LEADING_DROP: &[&str] =
&["the", "a", "an", "our", "your", "its", "their", "this", "that", "these", "those", "my", "his", "her"];
const TRAILING_DROP: &[&str] =
&["outage", "failure", "error", "issue", "incident", "problem", "alert", "warning", "downtime"];
fn canonical_core(surface: &str) -> Option<String> {
let mut words: Vec<String> = words_with_spans(surface).into_iter().map(|(w, _, _)| w).collect();
while words.first().map(|w| LEADING_DROP.contains(&w.as_str())).unwrap_or(false) {
words.remove(0);
}
while words.len() > 1 && words.last().map(|w| TRAILING_DROP.contains(&w.as_str())).unwrap_or(false) {
words.pop();
}
if words.is_empty() {
return None;
}
Some(words.join("-"))
}
fn words_with_spans(text: &str) -> Vec<(String, usize, usize)> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
let mut buf = String::new();
for (i, ch) in text.char_indices() {
if ch.is_alphanumeric() {
if start.is_none() {
start = Some(i);
}
buf.extend(ch.to_lowercase());
} else if let Some(s) = start.take() {
out.push((std::mem::take(&mut buf), s, i));
}
}
if let Some(s) = start.take() {
out.push((buf, s, text.len()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn learn_extract_and_overlay_roundtrip() {
let dir = std::env::temp_dir().join(format!("steeldb-gaz-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("overlay.json");
let _ = std::fs::remove_file(&path);
let mut g = Gazetteer::empty();
assert!(g.learn("Model X", "ent/model-x"));
assert!(g.learn("Direct Connect Gateway", "aws-service/direct-connect-gateway"));
assert!(!g.learn("model x", "ent/model-x")); assert_eq!(g.learned_len(), 2);
let hits = g.extract("the Model X uses a Direct Connect Gateway");
let toks: Vec<&str> = hits.iter().map(|h| h.token.as_str()).collect();
assert!(toks.contains(&"ent/model-x"));
assert!(toks.contains(&"aws-service/direct-connect-gateway"));
g.save_overlay(&path).unwrap();
let mut g2 = Gazetteer::empty();
g2.merge_overlay(&path);
let toks2: Vec<String> = g2.extract("Model X").into_iter().map(|h| h.token).collect();
assert_eq!(toks2, vec!["ent/model-x".to_string()]);
g2.save_overlay(&path).unwrap();
let entries: Vec<GazEntry> = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(entries.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn register_collapses_alias_variants_to_canonical() {
let mut g = Gazetteer::empty();
let a = g.register("The Payment Gateway", "ent").unwrap();
let b = g.register("payment gateway", "ent").unwrap();
let c = g.register("Our Payment Gateway", "ent").unwrap();
let d = g.register("Payment Gateway outage", "ent").unwrap();
assert_eq!(a, "ent/payment-gateway");
assert_eq!(b, a);
assert_eq!(c, a);
assert_eq!(d, a, "trailing event word should be stripped");
let other = g.register("Payment Processor", "ent").unwrap();
assert_ne!(other, a);
let toks: Vec<String> = g.extract("we upgraded our payment gateway last week").into_iter().map(|h| h.token).collect();
assert!(toks.contains(&"ent/payment-gateway".to_string()));
}
}