use std::sync::Arc;
use async_trait::async_trait;
use pipecrab_core::{
DataFrame, Decision, Direction, Disposition, Finality, Processor, Role, SystemFrame, Transcript,
};
use pipecrab_runtime::{Outbound, Stage, StageError};
pub struct SentenceChunker {
emitted: usize,
}
impl SentenceChunker {
pub fn new() -> Self {
Self { emitted: 0 }
}
}
impl Default for SentenceChunker {
fn default() -> Self {
Self::new()
}
}
pub struct EmitSentence(Arc<str>);
impl SentenceChunker {
fn drain_sentences(from: usize, text: &str, effects: &mut Vec<EmitSentence>) -> usize {
let mut cut = from;
while let Some(len) = leading_sentence(&text[cut..]) {
let sentence = text[cut..cut + len].trim();
if !sentence.is_empty() {
effects.push(EmitSentence(sentence.into()));
}
cut += len;
}
cut
}
}
impl Processor for SentenceChunker {
type Effect = EmitSentence;
fn decide_data(&mut self, frame: &DataFrame) -> Decision<EmitSentence> {
let (finality, text) = match frame {
DataFrame::Transcript(Transcript {
role: Role::Agent,
finality,
text,
}) => {
assert!(
self.emitted <= text.len(),
"SentenceChunker offset {} outruns agent text of {} bytes: a generation \
was abandoned without an Interrupt",
self.emitted,
text.len(),
);
(*finality, text)
}
_ => return Decision::forward(),
};
let mut effects = Vec::new();
let cut = Self::drain_sentences(self.emitted, text, &mut effects);
match finality {
Finality::Partial { .. } => {
self.emitted = cut;
}
Finality::Final => {
let tail = text[cut..].trim();
if !tail.is_empty() {
effects.push(EmitSentence(tail.into()));
}
self.emitted = 0;
}
}
Decision {
disposition: Disposition::Drop,
effects,
}
}
fn decide_system(&mut self, _dir: Direction, frame: &SystemFrame) -> Decision<EmitSentence> {
if matches!(frame, SystemFrame::Interrupt) {
self.emitted = 0;
}
Decision::forward()
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl Stage for SentenceChunker {
async fn perform(
&self,
EmitSentence(text): EmitSentence,
out: &Outbound,
) -> Result<(), StageError> {
let _ = out.send_data(Transcript::agent_final(text).into()).await;
Ok(())
}
}
const ABBREVIATIONS: &[&str] = &[
"mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "no", "vol", "fig", "gen",
"sen", "rep", "gov", "col", "capt", "lt", "sgt", "rev", "hon",
];
fn leading_sentence(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
for i in 0..bytes.len() {
if matches!(bytes[i], b'.' | b'!' | b'?') {
if bytes[i] == b'.' && ends_with_abbreviation(&s[..i]) {
continue;
}
let mut j = i + 1;
while j < bytes.len()
&& matches!(bytes[j], b'.' | b'!' | b'?' | b'"' | b'\'' | b')' | b']')
{
j += 1;
}
if j < bytes.len() && bytes[j].is_ascii_whitespace() {
return Some(j);
}
}
}
None
}
fn ends_with_abbreviation(before: &str) -> bool {
let bytes = before.as_bytes();
let mut start = bytes.len();
while start > 0 && (bytes[start - 1].is_ascii_alphabetic() || bytes[start - 1] == b'\'') {
start -= 1;
}
let word = &before[start..];
if word.len() == 1 && word.as_bytes()[0].is_ascii_alphabetic() {
return true;
}
ABBREVIATIONS.contains(&word.to_ascii_lowercase().as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leading_sentence_splits_on_terminator_then_whitespace() {
assert_eq!(leading_sentence("one. two"), Some(4));
assert_eq!(leading_sentence("stop! go"), Some(5));
assert_eq!(leading_sentence("what? now"), Some(5));
assert_eq!(leading_sentence("she said \"hi.\" then"), Some(14));
}
#[test]
fn leading_sentence_needs_the_confirming_whitespace() {
assert_eq!(leading_sentence("done."), None);
assert_eq!(leading_sentence("no terminator here"), None);
assert_eq!(leading_sentence("pi is 3.14 today"), None);
}
#[test]
fn leading_sentence_skips_abbreviations_and_initials() {
assert_eq!(leading_sentence("Dr. Smith arrived"), None);
assert_eq!(leading_sentence("see vol. 2 now"), None);
assert_eq!(leading_sentence("J. R. R. Tolkien wrote"), None);
assert_eq!(leading_sentence("the U.S. economy grew"), None);
assert_eq!(leading_sentence("Dr. Smith left. Then"), Some(15));
assert_eq!(leading_sentence("I don't. Really"), Some("I don't.".len()));
assert_eq!(leading_sentence("Wait, Dr! Stop"), Some(9));
}
#[test]
fn ends_with_abbreviation_classifies_the_trailing_word() {
assert!(ends_with_abbreviation("Dr")); assert!(ends_with_abbreviation("hello Mrs")); assert!(ends_with_abbreviation("J")); assert!(!ends_with_abbreviation("hello")); assert!(!ends_with_abbreviation("don't")); assert!(!ends_with_abbreviation("")); }
#[test]
fn drain_sentences_emits_each_complete_sentence_and_returns_the_offset() {
let mut effects = Vec::new();
let cut = SentenceChunker::drain_sentences(0, "one. two. three", &mut effects);
let texts: Vec<&str> = effects.iter().map(|EmitSentence(t)| &**t).collect();
assert_eq!(texts, vec!["one.", "two."]);
assert_eq!(cut, "one. two. ".len() - 1);
assert_eq!(&"one. two. three"[cut..], " three");
}
#[test]
fn drain_sentences_starts_from_the_given_offset() {
let mut effects = Vec::new();
let cut = SentenceChunker::drain_sentences(4, "one. two. rest", &mut effects);
let texts: Vec<&str> = effects.iter().map(|EmitSentence(t)| &**t).collect();
assert_eq!(texts, vec!["two."]);
assert_eq!(&"one. two. rest"[cut..], " rest");
}
fn agent_partial(text: &str) -> DataFrame {
Transcript::agent_partial(text).into()
}
#[test]
#[should_panic(expected = "outruns agent text")]
fn shorter_generation_without_reset_panics() {
let mut chunker = SentenceChunker::new();
let _ = chunker.decide_data(&agent_partial("one two three. four five"));
let _ = chunker.decide_data(&agent_partial("hi"));
}
}