use std::sync::Mutex;
use nnsplit::NNSplit;
use super::SentenceSplitter;
use super::unicode::{UnicodeSentenceSplitter, protect_inline_tokens, restore_inline_tokens};
static MODEL_LOAD_LOCK: Mutex<()> = Mutex::new(());
pub struct NeuralSentenceSplitter {
inner: NNSplit,
post: UnicodeSentenceSplitter,
}
impl NeuralSentenceSplitter {
pub fn new(language: &str) -> Result<Self, Box<dyn std::error::Error>> {
Self::with_extras(language, &[])
}
pub fn with_extras(
language: &str,
extras: &[String],
) -> Result<Self, Box<dyn std::error::Error>> {
let options = nnsplit::NNSplitOptions::default();
let inner = {
let _guard = MODEL_LOAD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
NNSplit::load(language, options)?
};
Ok(Self {
inner,
post: UnicodeSentenceSplitter::for_lang(language, extras),
})
}
pub fn from_path(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
Self::from_path_with_extras(path, "en", &[])
}
pub fn from_path_with_extras(
path: &std::path::Path,
language: &str,
extras: &[String],
) -> Result<Self, Box<dyn std::error::Error>> {
let options = nnsplit::NNSplitOptions::default();
let inner = {
let _guard = MODEL_LOAD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
NNSplit::new(path, options)?
};
Ok(Self {
inner,
post: UnicodeSentenceSplitter::for_lang(language, extras),
})
}
}
impl SentenceSplitter for NeuralSentenceSplitter {
fn split(&self, text: &str) -> Vec<String> {
let text = text.trim();
if text.is_empty() {
return vec![];
}
let (protected, placeholders) = protect_inline_tokens(text);
let splits = self.inner.split(&[protected.as_str()]);
let raw: Vec<String> = if splits.is_empty() {
vec![protected.clone()]
} else {
splits[0]
.flatten(0)
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
let restored = restore_inline_tokens(raw, &placeholders);
self.post.refine_segments(restored)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sentence::unicode::UnicodeSentenceSplitter;
#[test]
fn neural_english_basic() {
let splitter = NeuralSentenceSplitter::new("en").unwrap();
let result = splitter.split("Hello world. This is a test. Another sentence.");
assert_eq!(result.len(), 3);
}
#[test]
fn neural_empty_input() {
let splitter = NeuralSentenceSplitter::new("en").unwrap();
assert!(splitter.split("").is_empty());
}
#[test]
fn neural_abbreviation_handling() {
let splitter = NeuralSentenceSplitter::new("en").unwrap();
let result = splitter.split("Dr. Smith went home. He was tired.");
assert!(result.len() >= 2);
}
#[test]
fn neural_dialogue_quote_not_fractured() {
let splitter = NeuralSentenceSplitter::new("en").unwrap();
let result = splitter.split(r#"He said "Hello world. How are you?" Then he left."#);
assert!(
result
.iter()
.any(|s| s.contains("Hello world.") && s.contains("How are you?")),
"expected glued dialogue span, got {result:?}"
);
}
#[test]
fn neural_org_emphasis_matches_rules_protection() {
let neural = NeuralSentenceSplitter::new("en").unwrap();
let rules = UnicodeSentenceSplitter::new();
let input = "End of first. *Bold spans period. Continues* after.";
let n = neural.split(input);
let r = rules.split(input);
assert!(
n.iter()
.any(|s| s.contains("*Bold spans period. Continues*")),
"neural fractured emphasis: {n:?}"
);
assert!(
r.iter()
.any(|s| s.contains("*Bold spans period. Continues*")),
"rules fractured emphasis: {r:?}"
);
}
#[test]
fn neural_org_link_not_split_on_abbrev_in_desc() {
let neural = NeuralSentenceSplitter::new("en").unwrap();
let input = "See [[https://example.com][Ex. Site]] for details. Then continue.";
let n = neural.split(input);
assert!(
n.iter()
.any(|s| s.contains("[[https://example.com][Ex. Site]]")),
"neural split inside org link: {n:?}"
);
assert!(n.len() >= 2, "expected sentence after link: {n:?}");
}
}