use crate::analysis::{self, DefaultSentenceAnalyzer, SentenceAnalyzer, ngrams};
use crate::config::RuntimeConfig;
use crate::parsers::{
ParseResult,
traits::{AdaptiveParallel, build_mining_result_with_footprint, empty_mining_result},
};
use crate::results::{MiningResult, Template};
use anyhow::Result;
use dashmap::DashMap;
use log::debug;
use rayon::prelude::*;
use std::collections::BTreeMap;
fn filter_sentences_before_ngrams(sentences: &mut Vec<String>, config: &RuntimeConfig) {
let before_filter = sentences.len();
sentences.retain(|s| {
let trimmed = s.trim();
let word_count = trimmed.split_whitespace().count();
word_count >= config.min_sentence_words
&& trimmed.chars().any(char::is_alphanumeric)
&& (trimmed.len() >= config.min_sentence_length
|| word_count >= config.min_sentence_words_alt)
});
debug!(
"Filtered sentences: {} kept of {} (min_words={}, min_length={} or min_words_alt={}, alphanumeric required)",
sentences.len(),
before_filter,
config.min_sentence_words,
config.min_sentence_length,
config.min_sentence_words_alt
);
if sentences.is_empty() {
debug!("No sentences after filtering, caller will return empty mining result");
}
}
fn precompute_sentence_tokens(sentences: &[String], config: &RuntimeConfig) -> Vec<Vec<String>> {
let sentence_tokens: Vec<Vec<String>> = sentences
.par_iter_adaptive(config)
.map(|s| {
s.split_whitespace()
.map(std::string::ToString::to_string)
.collect::<Vec<String>>()
})
.collect();
debug!(
"Pre-computed tokens for {} sentences",
sentence_tokens.len()
);
sentence_tokens
}
fn group_sentences_by_pattern(
sentences: &[String],
sentence_tokens: &[Vec<String>],
template_groups: &DashMap<String, Vec<String>>,
frequent_ngrams: &[(String, usize)],
frequent_phrases: &[(String, usize)],
config: &RuntimeConfig,
) {
sentences
.par_iter_adaptive(config)
.zip(sentence_tokens.par_iter_adaptive(config))
.for_each(|(sentence, tokens)| {
let token_refs: Vec<&str> = tokens.iter().map(std::string::String::as_str).collect();
let pattern =
ngrams::build_text_pattern(&token_refs, frequent_ngrams, frequent_phrases, config);
template_groups
.entry(pattern)
.or_default()
.push(sentence.clone());
});
debug!(
"Template grouping (exact pattern grouping): {} sentences in {} pattern groups (each pattern → list of sentences)",
sentences.len(),
template_groups.len()
);
}
fn build_templates_from_groups(
template_groups: &DashMap<String, Vec<String>>,
min_template_count: usize,
avg_sentence_length: usize,
frequent_ngrams: &[(String, usize)],
frequent_phrases: &[(String, usize)],
config: &RuntimeConfig,
) -> Vec<Template> {
let min_count = min_template_count.max(2);
let templates: Vec<Template> = template_groups
.iter()
.filter(|entry| entry.value().len() >= min_count)
.filter_map(|entry| {
let pattern = entry.key().clone();
let matching_sentences = entry.value();
let pattern_word_count = pattern.split_whitespace().count();
if pattern_word_count > avg_sentence_length * 2 {
return None;
}
let mut examples: BTreeMap<String, Vec<String>> = BTreeMap::new();
let example_limit =
(config.max_examples_per_placeholder / 2).max(config.min_examples_per_placeholder);
let sample_size = example_limit.min(config.max_examples_for_entropy);
if analysis::pattern_is_all_placeholders(&pattern) {
analysis::extract_examples_by_position(
matching_sentences,
sample_size,
example_limit,
&mut examples,
);
} else {
for sentence in matching_sentences.iter().take(sample_size) {
let tokens: Vec<&str> = sentence.split_whitespace().collect();
ngrams::extract_text_examples(
&tokens,
frequent_ngrams,
frequent_phrases,
&mut examples,
config,
);
}
for examples_list in examples.values_mut() {
if examples_list.len() > example_limit {
examples_list.truncate(example_limit);
}
}
}
let entropy = if matching_sentences.len() >= config.min_entropy_sample_size {
analysis::calculate_template_entropy(&examples, matching_sentences.len(), config)
} else {
0.0
};
let enriched_pattern =
if entropy > config.min_entropy_display && entropy < config.max_entropy_display {
format!("{pattern} [entropy={entropy:.2}]")
} else {
pattern
};
Some(Template {
pattern: enriched_pattern,
count: matching_sentences.len(),
examples,
})
})
.collect();
debug!(
"Final templates: {} (kept only groups with ≥{} sentences)",
templates.len(),
min_count
);
templates
}
pub fn extract_text_templates(
content: &str,
stats: &ParseResult,
config: &RuntimeConfig,
) -> Result<MiningResult> {
if content.trim().is_empty() {
return Ok(empty_mining_result(stats));
}
let mut sentences = DefaultSentenceAnalyzer::extract_sentences(content);
let original_sentences = sentences.clone();
let pivot_patterns = analysis::extract_pivot_points(&original_sentences, config);
let svo_analysis = Some(analysis::analyze_svo_structure(
&original_sentences,
&pivot_patterns,
config,
));
filter_sentences_before_ngrams(&mut sentences, config);
let total_sentences_after_filter = sentences.len();
if total_sentences_after_filter == 0 {
return Ok(empty_mining_result(stats));
}
let ngram_freq: DashMap<String, usize> = DashMap::new();
let phrase_freq: DashMap<String, usize> = DashMap::new();
ngrams::build_ngram_and_phrase_freq(sentences.as_slice(), &ngram_freq, &phrase_freq, config);
let threshold = (total_sentences_after_filter as f64 * config.text_threshold) as usize;
let (frequent_ngrams, frequent_phrases) = ngrams::filter_frequent_ngrams_and_phrases(
&ngram_freq,
&phrase_freq,
threshold,
total_sentences_after_filter,
config,
);
let sentence_tokens = precompute_sentence_tokens(sentences.as_slice(), config);
let template_groups: DashMap<String, Vec<String>> = DashMap::new();
group_sentences_by_pattern(
sentences.as_slice(),
&sentence_tokens,
&template_groups,
&frequent_ngrams,
&frequent_phrases,
config,
);
let min_template_count =
(total_sentences_after_filter as f64 * config.text_threshold).max(2.0) as usize;
debug!("Min template count: {min_template_count}");
let avg_sentence_length: usize = if sentences.is_empty() {
0
} else {
sentences
.as_slice()
.par_iter_adaptive(config)
.map(|s| s.split_whitespace().count())
.sum::<usize>()
/ sentences.len()
};
debug!("Average sentence length: {avg_sentence_length}");
let mut templates = build_templates_from_groups(
&template_groups,
min_template_count,
avg_sentence_length,
&frequent_ngrams,
&frequent_phrases,
config,
);
if templates.is_empty() {
debug!("Running shape fallback (group by word count + end type)");
template_groups.clear();
analysis::group_sentences_by_shape(sentences.as_slice(), &template_groups, config);
templates = build_templates_from_groups(
&template_groups,
min_template_count,
avg_sentence_length,
&frequent_ngrams,
&frequent_phrases,
config,
);
} else {
debug!(
"Exact pattern produced {} templates; skipping fallback",
templates.len()
);
}
let mut writing_footprint =
analysis::calculate_writing_footprint(&sentences, &templates, content, config);
writing_footprint.svo_analysis = svo_analysis;
let result = build_mining_result_with_footprint(
templates,
total_sentences_after_filter,
stats,
config,
Some(&writing_footprint),
);
Ok(result)
}