use std::collections::{HashSet, VecDeque};
use super::helpers::normalize_loop_text;
pub const TEXT_LOOP_RING_CAP: usize = 8;
pub const TEXT_LOOP_TRIP_AT: usize = 3;
const NEAR_DUPLICATE_JACCARD: f64 = 0.8;
const NEAR_DUPLICATE_OVERLAP: f64 = 0.8;
const NEAR_DUPLICATE_MIN_WORDS: usize = 3;
pub fn near_duplicate(a: &str, b: &str) -> bool {
let na = normalize_loop_text(a);
let nb = normalize_loop_text(b);
if na.is_empty() || nb.is_empty() {
return false;
}
if na == nb {
return true;
}
match similarity_scores(&na, &nb) {
Some((jaccard, overlap)) => {
jaccard >= NEAR_DUPLICATE_JACCARD || overlap >= NEAR_DUPLICATE_OVERLAP
}
None => false,
}
}
pub(crate) fn similarity_scores(na: &str, nb: &str) -> Option<(f64, f64)> {
let wa: HashSet<&str> = na.split(' ').collect();
let wb: HashSet<&str> = nb.split(' ').collect();
if wa.len() < NEAR_DUPLICATE_MIN_WORDS || wb.len() < NEAR_DUPLICATE_MIN_WORDS {
return None;
}
let inter = wa.intersection(&wb).count() as f64;
let union = wa.union(&wb).count() as f64;
let min_len = wa.len().min(wb.len()) as f64;
let jaccard = if union > 0.0 { inter / union } else { 0.0 };
let overlap = if min_len > 0.0 { inter / min_len } else { 0.0 };
Some((jaccard, overlap))
}
const TRIP_LOG_EXCERPT_CHARS: usize = 240;
fn log_trip_pairs(text: &str, ring: &VecDeque<String>) {
let current = normalize_loop_text(text);
for prior in ring.iter() {
let prior_norm = normalize_loop_text(prior);
if !near_duplicate(text, prior) {
continue;
}
let (jaccard, overlap) = if current == prior_norm {
(1.0, 1.0)
} else {
similarity_scores(¤t, &prior_norm).unwrap_or((0.0, 0.0))
};
tracing::debug!(
current_words = current.split(' ').count(),
prior_words = prior_norm.split(' ').count(),
exact = current == prior_norm,
jaccard = format!("{jaccard:.2}"),
overlap = format!("{overlap:.2}"),
current_text = %excerpt(text),
prior_text = %excerpt(prior),
"announcement_loop: near-duplicate pair at trip"
);
}
}
fn excerpt(text: &str) -> String {
let mut out: String = text.chars().take(TRIP_LOG_EXCERPT_CHARS).collect();
if text.chars().count() > TRIP_LOG_EXCERPT_CHARS {
out.push('…');
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextLoopAction {
Continue,
Nudge,
Abort,
}
#[derive(Debug, Default)]
pub struct OutgoingTextRing {
ring: VecDeque<String>,
nudged: bool,
}
impl OutgoingTextRing {
pub fn record_and_check(&mut self, text: &str) -> TextLoopAction {
self.ring.push_back(text.to_string());
while self.ring.len() > TEXT_LOOP_RING_CAP {
self.ring.pop_front();
}
let hits = self.ring.iter().filter(|t| near_duplicate(text, t)).count();
if hits >= TEXT_LOOP_TRIP_AT {
log_trip_pairs(text, &self.ring);
if self.nudged {
TextLoopAction::Abort
} else {
self.nudged = true;
TextLoopAction::Nudge
}
} else {
TextLoopAction::Continue
}
}
pub fn last_recorded(&self) -> Option<&str> {
self.ring.back().map(String::as_str)
}
}