1use crate::{
7 data::{AlignmentStatus, CharInterval, Extraction},
8 exceptions::LangExtractResult,
9};
10use std::cmp::min;
11
12#[derive(Debug, Clone)]
14pub struct AlignmentConfig {
15 pub enable_fuzzy_alignment: bool,
17 pub fuzzy_alignment_threshold: f32,
19 pub accept_match_lesser: bool,
21 pub case_sensitive: bool,
23 pub max_search_window: usize,
25}
26
27impl Default for AlignmentConfig {
28 fn default() -> Self {
29 Self {
30 enable_fuzzy_alignment: true,
31 fuzzy_alignment_threshold: 0.4, accept_match_lesser: true,
33 case_sensitive: false,
34 max_search_window: 100,
35 }
36 }
37}
38
39pub struct TextAligner {
41 config: AlignmentConfig,
42}
43
44impl TextAligner {
45 pub fn new() -> Self {
47 Self {
48 config: AlignmentConfig::default(),
49 }
50 }
51
52 pub fn with_config(config: AlignmentConfig) -> Self {
54 Self { config }
55 }
56
57 #[tracing::instrument(skip_all, fields(num_extractions = extractions.len(), source_len = source_text.len(), char_offset))]
61 pub fn align_extractions(
62 &self,
63 extractions: &mut [Extraction],
64 source_text: &str,
65 char_offset: usize,
66 ) -> LangExtractResult<usize> {
67 let search_text = if self.config.case_sensitive {
69 source_text.to_string()
70 } else {
71 source_text.to_lowercase()
72 };
73
74 let source_words: Vec<&str> = search_text.split_whitespace().collect();
76 let word_byte_offsets: Vec<(usize, usize)> = search_text
77 .split_whitespace()
78 .map(|word| {
79 let start = word.as_ptr() as usize - search_text.as_ptr() as usize;
80 (start, start + word.len())
81 })
82 .collect();
83
84 let mut aligned_count = 0;
85 for extraction in extractions.iter_mut() {
86 if let Some(interval) = self.align_single_extraction_with_cache(
87 extraction, &search_text, &source_words, &word_byte_offsets, char_offset,
88 )? {
89 extraction.char_interval = Some(interval);
90 aligned_count += 1;
91 }
92 }
93
94 Ok(aligned_count)
95 }
96
97 fn align_single_extraction_with_cache(
99 &self,
100 extraction: &mut Extraction,
101 search_text: &str,
102 source_words: &[&str],
103 word_byte_offsets: &[(usize, usize)],
104 char_offset: usize,
105 ) -> LangExtractResult<Option<CharInterval>> {
106 let extraction_text = if self.config.case_sensitive {
107 extraction.extraction_text.clone()
108 } else {
109 extraction.extraction_text.to_lowercase()
110 };
111
112 if let Some((start, end, status)) = self.find_exact_match(&extraction_text, search_text) {
114 extraction.alignment_status = Some(status);
115 return Ok(Some(CharInterval::new(
116 Some(start + char_offset),
117 Some(end + char_offset),
118 )));
119 }
120
121 if self.config.enable_fuzzy_alignment {
123 if let Some((start, end, status)) = self.find_fuzzy_match_with_words(&extraction_text, search_text, source_words, word_byte_offsets) {
124 extraction.alignment_status = Some(status);
125 return Ok(Some(CharInterval::new(
126 Some(start + char_offset),
127 Some(end + char_offset),
128 )));
129 }
130 }
131
132 extraction.alignment_status = None;
134 Ok(None)
135 }
136
137 pub fn align_single_extraction(
139 &self,
140 extraction: &mut Extraction,
141 source_text: &str,
142 char_offset: usize,
143 ) -> LangExtractResult<Option<CharInterval>> {
144 let search_text = if self.config.case_sensitive {
145 source_text.to_string()
146 } else {
147 source_text.to_lowercase()
148 };
149 let source_words: Vec<&str> = search_text.split_whitespace().collect();
150 let word_byte_offsets: Vec<(usize, usize)> = search_text
151 .split_whitespace()
152 .map(|word| {
153 let start = word.as_ptr() as usize - search_text.as_ptr() as usize;
154 (start, start + word.len())
155 })
156 .collect();
157
158 self.align_single_extraction_with_cache(extraction, &search_text, &source_words, &word_byte_offsets, char_offset)
159 }
160
161 fn find_exact_match(&self, extraction_text: &str, source_text: &str) -> Option<(usize, usize, AlignmentStatus)> {
163 if let Some(start) = source_text.find(extraction_text) {
165 let end = start + extraction_text.len();
166 return Some((start, end, AlignmentStatus::MatchExact));
167 }
168
169 if self.config.accept_match_lesser {
171 let extraction_words: Vec<&str> = extraction_text.split_whitespace().collect();
173 if extraction_words.len() > 1 {
174 if let (Some(first_word), Some(last_word)) = (extraction_words.first(), extraction_words.last()) {
176 if let Some(first_start) = source_text.find(first_word) {
177 if let Some(last_start) = source_text[first_start..].find(last_word) {
178 let last_absolute_start = first_start + last_start;
179 let last_end = last_absolute_start + last_word.len();
180
181 if last_end - first_start < extraction_text.len() * 2 {
183 return Some((first_start, last_end, AlignmentStatus::MatchLesser));
184 }
185 }
186 }
187 }
188 }
189 }
190
191 None
192 }
193
194 fn find_fuzzy_match_with_words(&self, extraction_text: &str, source_text: &str, source_words: &[&str], word_byte_offsets: &[(usize, usize)]) -> Option<(usize, usize, AlignmentStatus)> {
197 let extraction_words: Vec<&str> = extraction_text.split_whitespace().collect();
198
199 if extraction_words.is_empty() || source_words.is_empty() {
200 return None;
201 }
202
203 let mut best_match: Option<(usize, usize, f32)> = None;
204
205 let max_window = min(source_words.len(), self.config.max_search_window);
207 let min_window = extraction_words.len();
208
209 for window_size in min_window..=max_window {
210 for start_idx in 0..=source_words.len().saturating_sub(window_size) {
211 let end_idx = start_idx + window_size;
212 let window = &source_words[start_idx..end_idx];
213
214 let similarity = self.calculate_word_similarity_direct(&extraction_words, window);
216
217 if similarity >= self.config.fuzzy_alignment_threshold {
218 if let Some((_, _, current_best)) = best_match {
219 if similarity > current_best {
220 best_match = Some((start_idx, end_idx, similarity));
221 }
222 } else {
223 best_match = Some((start_idx, end_idx, similarity));
224 }
225 }
226 }
227
228 if best_match.is_some() {
230 break;
231 }
232 }
233
234 if let Some((start_word_idx, end_word_idx, _)) = best_match {
236 let char_start = word_byte_offsets[start_word_idx].0;
237 let char_end = if end_word_idx >= source_words.len() {
238 source_text.len()
239 } else {
240 word_byte_offsets[end_word_idx - 1].1
241 };
242
243 return Some((char_start, char_end, AlignmentStatus::MatchFuzzy));
244 }
245
246 None
247 }
248
249 fn calculate_word_similarity_direct(&self, words1: &[&str], words2: &[&str]) -> f32 {
253 if words1.is_empty() && words2.is_empty() {
254 return 1.0;
255 }
256 if words1.is_empty() || words2.is_empty() {
257 return 0.0;
258 }
259
260 let word_set2: std::collections::HashSet<&str> = words2.iter().copied().collect();
262
263 let found_count = words1.iter().filter(|w| word_set2.contains(**w)).count();
265
266 found_count as f32 / words1.len() as f32
268 }
269
270 pub fn align_chunk_extractions(
272 &self,
273 extractions: &mut [Extraction],
274 chunk_text: &str,
275 chunk_char_offset: usize,
276 ) -> LangExtractResult<usize> {
277 self.align_extractions(extractions, chunk_text, chunk_char_offset)
278 }
279
280 pub fn get_alignment_stats(&self, extractions: &[Extraction]) -> AlignmentStats {
282 let total = extractions.len();
283 let mut exact = 0;
284 let mut fuzzy = 0;
285 let mut lesser = 0;
286 let mut greater = 0;
287 let mut unaligned = 0;
288
289 for extraction in extractions {
290 match extraction.alignment_status {
291 Some(AlignmentStatus::MatchExact) => exact += 1,
292 Some(AlignmentStatus::MatchFuzzy) => fuzzy += 1,
293 Some(AlignmentStatus::MatchLesser) => lesser += 1,
294 Some(AlignmentStatus::MatchGreater) => greater += 1,
295 None => unaligned += 1,
296 }
297 }
298
299 AlignmentStats {
300 total,
301 exact,
302 fuzzy,
303 lesser,
304 greater,
305 unaligned,
306 }
307 }
308}
309
310impl Default for TextAligner {
311 fn default() -> Self {
312 Self::new()
313 }
314}
315
316#[derive(Debug, Clone)]
318pub struct AlignmentStats {
319 pub total: usize,
320 pub exact: usize,
321 pub fuzzy: usize,
322 pub lesser: usize,
323 pub greater: usize,
324 pub unaligned: usize,
325}
326
327impl AlignmentStats {
328 pub fn success_rate(&self) -> f32 {
330 if self.total == 0 {
331 1.0
332 } else {
333 (self.total - self.unaligned) as f32 / self.total as f32
334 }
335 }
336
337 pub fn exact_match_rate(&self) -> f32 {
339 if self.total == 0 {
340 0.0
341 } else {
342 self.exact as f32 / self.total as f32
343 }
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn test_exact_alignment() {
353 let aligner = TextAligner::new();
354 let mut extraction = Extraction::new("person".to_string(), "John Doe".to_string());
355 let source_text = "Hello, John Doe is a software engineer.";
356
357 let result = aligner.align_single_extraction(&mut extraction, source_text, 0).unwrap();
358
359 assert!(result.is_some());
360 let interval = result.unwrap();
361 assert_eq!(interval.start_pos, Some(7));
362 assert_eq!(interval.end_pos, Some(15));
363 assert_eq!(extraction.alignment_status, Some(AlignmentStatus::MatchExact));
364 }
365
366 #[test]
367 fn test_case_insensitive_alignment() {
368 let aligner = TextAligner::new();
369 let mut extraction = Extraction::new("person".to_string(), "JOHN DOE".to_string());
370 let source_text = "Hello, john doe is a software engineer.";
371
372 let result = aligner.align_single_extraction(&mut extraction, source_text, 0).unwrap();
373
374 assert!(result.is_some());
375 let interval = result.unwrap();
376 assert_eq!(interval.start_pos, Some(7));
377 assert_eq!(interval.end_pos, Some(15));
378 assert_eq!(extraction.alignment_status, Some(AlignmentStatus::MatchExact));
379 }
380
381 #[test]
382 fn test_fuzzy_alignment() {
383 let aligner = TextAligner::new();
384 let mut extraction = Extraction::new("person".to_string(), "John Smith".to_string());
385 let source_text = "Hello, John is a software engineer named Smith.";
386
387 let result = aligner.align_single_extraction(&mut extraction, source_text, 0).unwrap();
388
389 assert!(result.is_some());
390 assert_eq!(extraction.alignment_status, Some(AlignmentStatus::MatchFuzzy));
391 }
392
393 #[test]
394 fn test_no_alignment() {
395 let aligner = TextAligner::new();
396 let mut extraction = Extraction::new("person".to_string(), "Jane Doe".to_string());
397 let source_text = "Hello, John Smith is a software engineer.";
398
399 let result = aligner.align_single_extraction(&mut extraction, source_text, 0).unwrap();
400
401 assert!(result.is_none());
402 assert_eq!(extraction.alignment_status, None);
403 }
404
405 #[test]
406 fn test_chunk_offset() {
407 let aligner = TextAligner::new();
408 let mut extraction = Extraction::new("person".to_string(), "John Doe".to_string());
409 let chunk_text = "John Doe is here.";
410 let chunk_offset = 100;
411
412 let result = aligner.align_single_extraction(&mut extraction, chunk_text, chunk_offset).unwrap();
413
414 assert!(result.is_some());
415 let interval = result.unwrap();
416 assert_eq!(interval.start_pos, Some(100)); assert_eq!(interval.end_pos, Some(108)); }
419
420 #[test]
421 fn test_alignment_stats() {
422 let aligner = TextAligner::new();
423 let extractions = vec![
424 Extraction {
425 extraction_class: "test".to_string(),
426 extraction_text: "test".to_string(),
427 alignment_status: Some(AlignmentStatus::MatchExact),
428 ..Default::default()
429 },
430 Extraction {
431 extraction_class: "test".to_string(),
432 extraction_text: "test".to_string(),
433 alignment_status: Some(AlignmentStatus::MatchFuzzy),
434 ..Default::default()
435 },
436 Extraction {
437 extraction_class: "test".to_string(),
438 extraction_text: "test".to_string(),
439 alignment_status: None,
440 ..Default::default()
441 },
442 ];
443
444 let stats = aligner.get_alignment_stats(&extractions);
445 assert_eq!(stats.total, 3);
446 assert_eq!(stats.exact, 1);
447 assert_eq!(stats.fuzzy, 1);
448 assert_eq!(stats.unaligned, 1);
449 assert_eq!(stats.success_rate(), 2.0 / 3.0);
450 assert_eq!(stats.exact_match_rate(), 1.0 / 3.0);
451 }
452}