use async_trait::async_trait;
use ndarray::Array2;
use ort::session::Session;
use ort::value::Value;
use std::path::Path;
use std::sync::Arc;
use tokenizers::Tokenizer;
use tokio::sync::Mutex;
use crate::processor::{Correction, CorrectionKind, ProcessError, ProcessResult, TextProcessor};
use crate::types::ContextSnapshot;
pub struct OnnxPunctuationRestorer {
session: Arc<Mutex<Session>>,
tokenizer: Arc<Tokenizer>,
labels: Vec<String>,
}
impl OnnxPunctuationRestorer {
pub fn load(
model_path: impl AsRef<Path>,
tokenizer_path: impl AsRef<Path>,
labels: Vec<String>,
) -> Result<Self, ProcessError> {
let session = Session::builder()
.and_then(|mut b| b.commit_from_file(model_path.as_ref()))
.map_err(|e| ProcessError::Unavailable(format!("load model: {e}")))?;
let tokenizer =
Tokenizer::from_file(tokenizer_path.as_ref()).map_err(|e| ProcessError::Unavailable(format!("load tokenizer: {e}")))?;
Ok(Self {
session: Arc::new(Mutex::new(session)),
tokenizer: Arc::new(tokenizer),
labels,
})
}
pub fn default_labels() -> Vec<String> {
[
"OU", "OO", ".O", "!O", ",O", ".U", "!U", ",U", ":O", ":U", ";O", ";U", "'O", "'U",
"-O",
]
.iter()
.map(|s| s.to_string())
.collect()
}
fn parse_label(label: &str) -> (Option<char>, bool) {
let chars: Vec<char> = label.chars().collect();
if chars.len() < 2 {
return (None, false);
}
let punct = if chars[0] == 'O' {
None
} else {
Some(chars[0])
};
let uppercase = chars[1] == 'U';
(punct, uppercase)
}
}
#[async_trait]
impl TextProcessor for OnnxPunctuationRestorer {
async fn process(
&self,
text: &str,
_ctx: &ContextSnapshot,
) -> Result<ProcessResult, ProcessError> {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return Ok(ProcessResult {
text: String::new(),
corrections: vec![],
});
}
let mut session = self.session.lock().await;
let enc = self
.tokenizer
.encode(text, true)
.map_err(|e| ProcessError::Failed(format!("tokenize: {e}")))?;
let len = enc.get_ids().len();
let ids =
Array2::from_shape_vec((1, len), enc.get_ids().iter().map(|&x| x as i64).collect())
.unwrap();
let mask = Array2::from_shape_vec(
(1, len),
enc.get_attention_mask().iter().map(|&x| x as i64).collect(),
)
.unwrap();
let ids_val = Value::from_array(ids).map_err(|e| ProcessError::Failed(format!("{e}")))?;
let mask_val = Value::from_array(mask).map_err(|e| ProcessError::Failed(format!("{e}")))?;
let outputs = session
.run(vec![
("input_ids", ids_val.into_dyn()),
("attention_mask", mask_val.into_dyn()),
])
.map_err(|e| ProcessError::Inference(format!("inference: {e}")))?;
let logits = outputs[0]
.try_extract_array::<f32>()
.map_err(|e| ProcessError::Failed(format!("extract: {e}")))?;
let view = logits.view();
let seq_len = view.shape()[1];
let num_labels = view.shape()[2];
let logits_owned: Vec<f32> = view.iter().copied().collect();
let word_ids: Vec<Option<u32>> = enc.get_word_ids().to_vec();
drop(outputs);
drop(session);
let mut word_labels: Vec<String> = vec!["O".into(); words.len()];
let mut seen = vec![false; words.len()];
for (ti, wid_opt) in word_ids.iter().enumerate() {
if let Some(wid) = wid_opt {
let w = *wid as usize;
if w < words.len() && !seen[w] && ti < seq_len {
seen[w] = true;
let offset = ti * num_labels;
let best = (0..num_labels)
.max_by(|&a, &b| {
logits_owned[offset + a]
.partial_cmp(&logits_owned[offset + b])
.unwrap()
})
.unwrap();
if best < self.labels.len() {
word_labels[w] = self.labels[best].clone();
}
}
}
}
let mut result = String::with_capacity(text.len() + words.len());
let mut corrections = Vec::new();
for (i, word) in words.iter().enumerate() {
if !result.is_empty() {
result.push(' ');
}
let (punct, should_upper) = Self::parse_label(&word_labels[i]);
let mut w = word.to_string();
if should_upper && w.chars().next().is_some_and(|c| c.is_alphabetic()) {
let first_len = w.chars().next().unwrap().len_utf8();
let first: String = w.chars().next().unwrap().to_uppercase().collect();
if first != w[..first_len] {
corrections.push(Correction {
kind: CorrectionKind::Capitalized,
original: w[..first_len].to_string(),
replacement: first.clone(),
});
}
w = format!("{}{}", first, &w[first_len..]);
}
result.push_str(&w);
if let Some(p) = punct {
let ps = p.to_string();
corrections.push(Correction {
kind: CorrectionKind::PunctuationInserted,
original: String::new(),
replacement: ps.clone(),
});
result.push_str(&ps);
}
}
Ok(ProcessResult {
text: result,
corrections,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntityLabel {
Person,
Location,
Organisation,
Misc,
}
impl EntityLabel {
fn from_class(class: &str) -> Option<Self> {
match class {
"PER" => Some(Self::Person),
"LOC" => Some(Self::Location),
"ORG" => Some(Self::Organisation),
"MISC" => Some(Self::Misc),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Person => "PER",
Self::Location => "LOC",
Self::Organisation => "ORG",
Self::Misc => "MISC",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entity {
pub label: EntityLabel,
pub start: usize,
pub end: usize,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct WordSpan {
start: usize,
end: usize,
}
fn word_spans(text: &str) -> Vec<WordSpan> {
let mut out = Vec::new();
let mut start = None;
for (i, c) in text.chars().enumerate() {
match (c.is_whitespace(), start) {
(false, None) => start = Some(i),
(true, Some(s)) => {
out.push(WordSpan { start: s, end: i });
start = None;
}
_ => {}
}
}
if let Some(s) = start {
out.push(WordSpan {
start: s,
end: text.chars().count(),
});
}
out
}
fn merge_bio(text: &str, words: &[WordSpan], tags: &[&str]) -> Vec<Entity> {
let chars: Vec<char> = text.chars().collect();
let mut out: Vec<Entity> = Vec::new();
let mut open: Option<(EntityLabel, usize, usize)> = None;
let flush = |open: &mut Option<(EntityLabel, usize, usize)>, out: &mut Vec<Entity>| {
if let Some((label, start, end)) = open.take() {
out.push(Entity {
label,
start,
end,
text: chars[start..end].iter().collect(),
});
}
};
for (i, word) in words.iter().enumerate() {
let tag = tags.get(i).copied().unwrap_or("O");
let (prefix, class) = match tag.split_once('-') {
Some((p, c)) => (p, c),
None => {
flush(&mut open, &mut out);
continue;
}
};
let Some(label) = EntityLabel::from_class(class) else {
flush(&mut open, &mut out);
continue;
};
match (prefix, open) {
("I", Some((cur, start, _))) if cur == label => {
open = Some((label, start, word.end));
}
_ => {
flush(&mut open, &mut out);
open = Some((label, word.start, word.end));
}
}
}
flush(&mut open, &mut out);
out
}
pub struct OnnxEntityRecognizer {
session: Arc<Mutex<Session>>,
tokenizer: Arc<Tokenizer>,
labels: Vec<String>,
}
impl OnnxEntityRecognizer {
pub fn load(
model_path: impl AsRef<Path>,
tokenizer_path: impl AsRef<Path>,
labels: Vec<String>,
) -> Result<Self, ProcessError> {
let session = Session::builder()
.and_then(|mut b| b.commit_from_file(model_path.as_ref()))
.map_err(|e| ProcessError::Unavailable(format!("load model: {e}")))?;
let tokenizer = Tokenizer::from_file(tokenizer_path.as_ref())
.map_err(|e| ProcessError::Unavailable(format!("load tokenizer: {e}")))?;
Ok(Self {
session: Arc::new(Mutex::new(session)),
tokenizer: Arc::new(tokenizer),
labels,
})
}
pub fn default_labels() -> Vec<String> {
[
"O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC", "B-MISC", "I-MISC",
]
.iter()
.map(|s| s.to_string())
.collect()
}
pub async fn detect(&self, text: &str) -> Result<Vec<Entity>, ProcessError> {
let words = word_spans(text);
if words.is_empty() {
return Ok(Vec::new());
}
let enc = self
.tokenizer
.encode(text, true)
.map_err(|e| ProcessError::Failed(format!("tokenize: {e}")))?;
let len = enc.get_ids().len();
let ids = Array2::from_shape_vec(
(1, len),
enc.get_ids().iter().map(|&x| x as i64).collect::<Vec<_>>(),
)
.map_err(|e| ProcessError::Failed(format!("input_ids: {e}")))?;
let mask = Array2::from_shape_vec(
(1, len),
enc.get_attention_mask()
.iter()
.map(|&x| x as i64)
.collect::<Vec<_>>(),
)
.map_err(|e| ProcessError::Failed(format!("attention_mask: {e}")))?;
let ids_val = Value::from_array(ids).map_err(|e| ProcessError::Failed(format!("{e}")))?;
let mask_val = Value::from_array(mask).map_err(|e| ProcessError::Failed(format!("{e}")))?;
let mut session = self.session.lock().await;
let outputs = session
.run(vec![
("input_ids", ids_val.into_dyn()),
("attention_mask", mask_val.into_dyn()),
])
.map_err(|e| ProcessError::Inference(format!("inference: {e}")))?;
let logits = outputs[0]
.try_extract_array::<f32>()
.map_err(|e| ProcessError::Failed(format!("extract: {e}")))?;
let view = logits.view();
let seq_len = view.shape()[1];
let num_labels = view.shape()[2];
let logits_owned: Vec<f32> = view.iter().copied().collect();
let word_ids: Vec<Option<u32>> = enc.get_word_ids().to_vec();
drop(outputs);
drop(session);
let mut word_tags: Vec<String> = vec!["O".into(); words.len()];
let mut seen = vec![false; words.len()];
for (ti, wid) in word_ids.iter().enumerate() {
let Some(w) = wid.map(|w| w as usize) else {
continue;
};
if w >= words.len() || seen[w] || ti >= seq_len {
continue;
}
seen[w] = true;
let offset = ti * num_labels;
let best = (0..num_labels)
.max_by(|&a, &b| {
logits_owned[offset + a].total_cmp(&logits_owned[offset + b])
})
.unwrap_or(0);
if let Some(label) = self.labels.get(best) {
word_tags[w] = label.clone();
}
}
let tags: Vec<&str> = word_tags.iter().map(|s| s.as_str()).collect();
Ok(merge_bio(text, &words, &tags))
}
}
#[async_trait]
impl TextProcessor for OnnxEntityRecognizer {
async fn process(
&self,
text: &str,
_ctx: &ContextSnapshot,
) -> Result<ProcessResult, ProcessError> {
let entities = self.detect(text).await?;
Ok(ProcessResult {
text: text.to_string(),
corrections: entities
.into_iter()
.map(|e| Correction {
kind: CorrectionKind::EntityDetected,
original: e.text.clone(),
replacement: e.text,
})
.collect(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spans(text: &str, tags: &[&str]) -> Vec<Entity> {
let words = word_spans(text);
assert_eq!(
words.len(),
tags.len(),
"test gave {} tags for {} words",
tags.len(),
words.len()
);
merge_bio(text, &words, tags)
}
#[test]
fn word_spans_are_codepoint_offsets() {
let text = "café de flore";
let w = word_spans(text);
let chars: Vec<char> = text.chars().collect();
let surfaces: Vec<String> = w
.iter()
.map(|s| chars[s.start..s.end].iter().collect())
.collect();
assert_eq!(surfaces, ["café", "de", "flore"]);
assert_eq!(w[1].start, 5);
}
#[test]
fn word_spans_handle_leading_trailing_and_repeated_whitespace() {
assert_eq!(word_spans("").len(), 0);
assert_eq!(word_spans(" ").len(), 0);
let w = word_spans(" a b ");
assert_eq!(w.len(), 2);
assert_eq!((w[0].start, w[0].end), (2, 3));
assert_eq!((w[1].start, w[1].end), (6, 7));
}
#[test]
fn single_word_entity() {
let e = spans("I met Alice today", &["O", "O", "B-PER", "O"]);
assert_eq!(e.len(), 1);
assert_eq!(e[0].label, EntityLabel::Person);
assert_eq!(e[0].text, "Alice");
assert_eq!((e[0].start, e[0].end), (6, 11));
}
#[test]
fn multi_word_entity_merges() {
let e = spans(
"we deployed to New York City",
&["O", "O", "O", "B-LOC", "I-LOC", "I-LOC"],
);
assert_eq!(e.len(), 1);
assert_eq!(e[0].text, "New York City");
assert_eq!(e[0].label, EntityLabel::Location);
}
#[test]
fn adjacent_entities_of_the_same_class_do_not_merge() {
let e = spans("Alice Bob talked", &["B-PER", "B-PER", "O"]);
assert_eq!(e.len(), 2);
assert_eq!(e[0].text, "Alice");
assert_eq!(e[1].text, "Bob");
}
#[test]
fn class_change_closes_the_open_entity() {
let e = spans("Paris Hilton stayed", &["B-LOC", "I-PER", "O"]);
assert_eq!(e.len(), 2);
assert_eq!((e[0].label, e[0].text.as_str()), (EntityLabel::Location, "Paris"));
assert_eq!((e[1].label, e[1].text.as_str()), (EntityLabel::Person, "Hilton"));
}
#[test]
fn stray_i_tag_opens_an_entity() {
let e = spans("call Kubernetes now", &["O", "I-ORG", "O"]);
assert_eq!(e.len(), 1);
assert_eq!(e[0].text, "Kubernetes");
assert_eq!(e[0].label, EntityLabel::Organisation);
}
#[test]
fn entity_running_to_end_of_text_is_closed() {
let e = spans("deployed to Kubernetes", &["O", "O", "B-ORG"]);
assert_eq!(e.len(), 1);
assert_eq!(e[0].text, "Kubernetes");
assert_eq!(e[0].end, "deployed to Kubernetes".chars().count());
}
#[test]
fn unknown_class_is_dropped_and_closes_the_span() {
let e = spans("Alice met Bob", &["B-PER", "B-GPE", "B-PER"]);
assert_eq!(e.len(), 2);
assert_eq!(e[0].text, "Alice");
assert_eq!(e[1].text, "Bob");
}
#[test]
fn all_outside_yields_nothing() {
assert!(spans("nothing to see here", &["O", "O", "O", "O"]).is_empty());
}
#[test]
fn multibyte_entity_surface_is_intact() {
let e = spans("私は東京に行った", &["B-LOC"]);
assert_eq!(e.len(), 1);
assert_eq!(e[0].text, "私は東京に行った");
}
#[test]
fn missing_trailing_tags_are_outside() {
let words = word_spans("Alice met Bob");
let e = merge_bio("Alice met Bob", &words, &["B-PER"]);
assert_eq!(e.len(), 1);
assert_eq!(e[0].text, "Alice");
}
#[test]
fn default_labels_cover_the_conll_classes() {
let labels = OnnxEntityRecognizer::default_labels();
assert_eq!(labels.len(), 9);
assert_eq!(labels[0], "O");
for class in ["PER", "LOC", "ORG", "MISC"] {
assert!(labels.contains(&format!("B-{class}")));
assert!(labels.contains(&format!("I-{class}")));
assert!(EntityLabel::from_class(class).is_some());
}
}
}