use serde_json::json;
use super::common::{
current_section_heading, current_section_level, extract_heading_text, heading_level,
heading_path_strings, parse_markdown_blocks, strip_block_content, update_heading_stack,
ChunkRecordInput, ContentType, MdBlockType,
};
fn ends_with_title_abbreviation(prefix: &str) -> bool {
let lower = prefix.trim_end().to_ascii_lowercase();
["mr.", "mrs.", "dr.", "prof.", "sr.", "jr.", "vs.", "etc.", "approx.", "est."]
.iter()
.any(|abbr| lower.ends_with(abbr))
}
fn ends_with_numeric_marker(prefix: &str) -> bool {
let trimmed = prefix.trim_end();
let without_dot = trimmed.strip_suffix('.').unwrap_or(trimmed);
let token = without_dot
.split_whitespace()
.last()
.unwrap_or_default()
.trim();
!token.is_empty() && token.chars().all(|c| c.is_ascii_digit())
}
fn ends_with_initials(prefix: &str) -> bool {
let trimmed = prefix.trim_end();
let bytes = trimmed.as_bytes();
if bytes.len() < 4 || *bytes.last().unwrap_or(&b' ') != b'.' {
return false;
}
let mut i = bytes.len();
let mut groups = 0usize;
while i >= 2 {
if bytes[i - 1] != b'.' || !bytes[i - 2].is_ascii_alphabetic() {
break;
}
groups += 1;
if i < 3 {
break;
}
i -= 2;
if i == 0 || bytes[i - 1] != b'.' {
break;
}
i -= 1;
}
groups >= 2
}
fn should_split_at(chars: &[char], punct_idx: usize, punct: char) -> bool {
if punct_idx + 2 >= chars.len() {
return false;
}
if chars[punct_idx + 1] != ' ' || !chars[punct_idx + 2].is_uppercase() {
return false;
}
if punct == '.' {
let prefix: String = chars[..=punct_idx].iter().collect();
if ends_with_title_abbreviation(&prefix)
|| ends_with_numeric_marker(&prefix)
|| ends_with_initials(&prefix)
{
return false;
}
}
true
}
fn split_into_sentences(text: &str) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
let mut out = Vec::new();
let mut start = 0usize;
let len = chars.len();
let mut i = 0usize;
while i < len {
let ch = chars[i];
if matches!(ch, '.' | '?' | '!') && should_split_at(&chars, i, ch) {
let sentence: String = chars[start..=i].iter().collect();
let sentence = sentence.split_whitespace().collect::<Vec<_>>().join(" ");
if !sentence.is_empty() {
out.push(sentence);
}
let mut next = i + 1;
while next < len && chars[next].is_whitespace() {
next += 1;
}
start = next;
i = next;
continue;
}
i += 1;
}
if start < len {
let tail: String = chars[start..].iter().collect();
let tail = tail.split_whitespace().collect::<Vec<_>>().join(" ");
if !tail.is_empty() {
out.push(tail);
}
}
out
}
struct IndexedSentence {
text: String,
paragraph_index: usize,
section_heading: Option<String>,
heading_path: Vec<String>,
section_level: u8,
}
pub fn build_sentence_chunks(
bytes: &[u8],
sentences_per_chunk: usize,
) -> Result<Vec<ChunkRecordInput>, String> {
if sentences_per_chunk == 0 {
return Err("sentences_per_chunk must be greater than 0".to_string());
}
let text = std::str::from_utf8(bytes)
.map(|s| s.to_string())
.unwrap_or_else(|_| String::from_utf8_lossy(bytes).to_string());
if text.trim().is_empty() {
return Err("Markdown file is empty".to_string());
}
let blocks = parse_markdown_blocks(&text);
let total_input_blocks = blocks.len();
let mut result: Vec<ChunkRecordInput> = Vec::new();
let mut heading_stack: Vec<(u8, String)> = Vec::new();
let mut sentences: Vec<IndexedSentence> = Vec::new();
let mut para_index = 0usize;
let mut chunk_index = 0usize;
let flush_sentences =
|sentences: &mut Vec<IndexedSentence>,
result: &mut Vec<ChunkRecordInput>,
chunk_index: &mut usize,
spc: usize,
total: usize| {
let mut i = 0usize;
while i < sentences.len() {
let end = (i + spc).min(sentences.len());
let window = &sentences[i..end];
let content = window.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join(" ");
if !content.is_empty() {
result.push(ChunkRecordInput {
content_type: ContentType::Sentence,
content,
metadata: json!({
"sentences_per_chunk": spc,
"actual_sentence_count": window.len(),
"chunk_index": *chunk_index,
"source_paragraph_index": window[0].paragraph_index,
"section_heading": window[0].section_heading,
"heading_path": window[0].heading_path,
"section_level": window[0].section_level,
"document_metadata": {
"source_type": "md",
"total_input_blocks": total,
}
}),
});
*chunk_index += 1;
}
i = end;
}
sentences.clear();
};
for block in blocks {
match block.block_type {
MdBlockType::Heading => {
flush_sentences(
&mut sentences,
&mut result,
&mut chunk_index,
sentences_per_chunk,
total_input_blocks,
);
let level = heading_level(&block.content);
let text = extract_heading_text(&block.content);
update_heading_stack(&mut heading_stack, level, text.clone());
result.push(ChunkRecordInput {
content_type: ContentType::HeadingSection,
content: text.clone(),
metadata: json!({
"section_heading": text,
"section_level": level,
"heading_path": heading_path_strings(&heading_stack),
"chunk_index": chunk_index,
"document_metadata": {
"source_type": "md",
"total_input_blocks": total_input_blocks,
}
}),
});
chunk_index += 1;
}
MdBlockType::Code => {
flush_sentences(
&mut sentences,
&mut result,
&mut chunk_index,
sentences_per_chunk,
total_input_blocks,
);
result.push(ChunkRecordInput {
content_type: ContentType::CodeBlock,
content: block.content.clone(),
metadata: json!({
"section_heading": current_section_heading(&heading_stack),
"heading_path": heading_path_strings(&heading_stack),
"section_level": current_section_level(&heading_stack),
"chunk_index": chunk_index,
"document_metadata": {
"source_type": "md",
"total_input_blocks": total_input_blocks,
}
}),
});
chunk_index += 1;
para_index += 1;
}
MdBlockType::Table => {
flush_sentences(
&mut sentences,
&mut result,
&mut chunk_index,
sentences_per_chunk,
total_input_blocks,
);
result.push(ChunkRecordInput {
content_type: ContentType::Table,
content: block.content.clone(),
metadata: json!({
"section_heading": current_section_heading(&heading_stack),
"heading_path": heading_path_strings(&heading_stack),
"section_level": current_section_level(&heading_stack),
"chunk_index": chunk_index,
"document_metadata": {
"source_type": "md",
"total_input_blocks": total_input_blocks,
}
}),
});
chunk_index += 1;
para_index += 1;
}
MdBlockType::Paragraph => {
let clean = strip_block_content(&block.content, false);
if clean.is_empty() {
continue;
}
let sh = current_section_heading(&heading_stack);
let hp = heading_path_strings(&heading_stack);
let sl = current_section_level(&heading_stack);
let current_para_index = para_index;
for s in split_into_sentences(&clean) {
sentences.push(IndexedSentence {
text: s,
paragraph_index: current_para_index,
section_heading: sh.clone(),
heading_path: hp.clone(),
section_level: sl,
});
}
para_index += 1;
}
MdBlockType::List => {
let clean = strip_block_content(&block.content, true);
if clean.is_empty() {
continue;
}
flush_sentences(
&mut sentences,
&mut result,
&mut chunk_index,
sentences_per_chunk,
total_input_blocks,
);
result.push(ChunkRecordInput {
content_type: ContentType::BulletNumberedList,
content: clean,
metadata: json!({
"sentences_per_chunk": sentences_per_chunk,
"actual_sentence_count": 0,
"chunk_index": chunk_index,
"source_paragraph_index": para_index,
"section_heading": current_section_heading(&heading_stack),
"heading_path": heading_path_strings(&heading_stack),
"section_level": current_section_level(&heading_stack),
"document_metadata": {
"source_type": "md",
"total_input_blocks": total_input_blocks,
}
}),
});
chunk_index += 1;
para_index += 1;
}
}
}
flush_sentences(
&mut sentences,
&mut result,
&mut chunk_index,
sentences_per_chunk,
total_input_blocks,
);
if result.is_empty() {
return Err("No sentence chunks generated".to_string());
}
Ok(result)
}