use serde_json::{json, Value};
use super::common::{
classify_prose, extract_heading_text, heading_level, parse_markdown_blocks, split_at_sentences,
extend_span, strip_block_content, union_span, BlockSpan, ChunkRecordInput, ContentType, MdBlock,
MdBlockType, SpannedRecord, MAX_CHUNK_CHARS, MIN_CHUNK_CHARS,
};
fn is_prose(content_type: ContentType) -> bool {
matches!(
content_type,
ContentType::PlainParagraph
| ContentType::ShortDisconnectedParagraph
| ContentType::LongSingleParagraph
)
}
#[derive(Default)]
pub(crate) struct ProseMerger {
held: Option<SpannedRecord>,
}
impl ProseMerger {
pub fn new() -> ProseMerger {
ProseMerger::default()
}
pub fn push(&mut self, next: SpannedRecord, min_chars: usize) -> Option<SpannedRecord> {
let soft_max = MAX_CHUNK_CHARS + min_chars;
let SpannedRecord { record: next, blocks } = next;
if is_prose(next.content_type) && next.content.len() < min_chars {
if let Some(previous) = self.held.as_mut() {
let prev = &mut previous.record;
if is_prose(prev.content_type)
&& prev.content.len() + next.content.len() + 1 <= soft_max
{
prev.content = format!("{}\n{}", prev.content, next.content).trim().to_string();
prev.content_type = classify_prose(&prev.content);
previous.blocks = union_span(previous.blocks, blocks);
return None;
}
}
}
self.held.replace(SpannedRecord::spanning(next, blocks))
}
pub fn finish(&mut self) -> Option<SpannedRecord> {
self.held.take()
}
}
fn merge_short_prose(chunks: Vec<SpannedRecord>, min_chars: usize) -> Vec<SpannedRecord> {
let mut merger = ProseMerger::new();
let mut result: Vec<SpannedRecord> =
chunks.into_iter().filter_map(|chunk| merger.push(chunk, min_chars)).collect();
result.extend(merger.finish());
result
}
fn md_metadata(section_heading: Option<String>, section_level: u8) -> Value {
json!({
"section_heading": section_heading,
"section_level": section_level,
"document_metadata": { "source_type": "md" }
})
}
#[derive(Default)]
pub(crate) struct StructuralBuilder {
ready: Vec<SpannedRecord>,
heading: Option<String>,
section_level: u8,
prose_parts: Vec<String>,
prose_len: usize,
prose_span: Option<BlockSpan>,
}
impl StructuralBuilder {
pub fn new() -> StructuralBuilder {
StructuralBuilder::default()
}
pub fn take(&mut self) -> Vec<SpannedRecord> {
std::mem::take(&mut self.ready)
}
pub fn finish(&mut self) -> Vec<SpannedRecord> {
self.flush_prose();
self.take()
}
fn flush_prose(&mut self) {
if self.prose_parts.is_empty() {
return;
}
let content = self.prose_parts.join("\n").trim().to_string();
self.prose_parts.clear();
self.prose_len = 0;
let blocks = self.prose_span.take();
if content.is_empty() {
return;
}
self.ready.push(SpannedRecord::spanning(
ChunkRecordInput {
content_type: classify_prose(&content),
content,
metadata: md_metadata(self.heading.clone(), self.section_level),
},
blocks,
));
}
fn push_at(&mut self, content_type: ContentType, content: String, index: usize) {
let metadata = md_metadata(self.heading.clone(), self.section_level);
self.ready
.push(SpannedRecord::at(ChunkRecordInput { content_type, content, metadata }, index));
}
pub fn advance(&mut self, block: MdBlock) {
match block.block_type {
MdBlockType::Heading => {
self.flush_prose();
let heading_text = extract_heading_text(&block.content);
let level = heading_level(&block.content);
self.heading = Some(heading_text.clone());
self.section_level = level;
self.ready.push(SpannedRecord::at(
ChunkRecordInput {
content_type: ContentType::HeadingSection,
content: heading_text,
metadata: json!({
"section_heading": serde_json::Value::Null,
"section_level": level,
"document_metadata": { "source_type": "md" }
}),
},
block.index,
));
}
MdBlockType::Code => {
self.flush_prose();
self.push_at(ContentType::CodeBlock, block.content, block.index);
}
MdBlockType::Table => {
self.flush_prose();
self.push_at(ContentType::Table, block.content, block.index);
}
MdBlockType::List => {
self.flush_prose();
let clean = strip_block_content(&block.content, true);
if !clean.is_empty() {
self.push_at(ContentType::BulletNumberedList, clean, block.index);
}
}
MdBlockType::Paragraph => self.advance_paragraph(&block),
}
}
fn advance_paragraph(&mut self, block: &MdBlock) {
let clean = strip_block_content(&block.content, false);
if clean.is_empty() {
return;
}
let sub_blocks = if clean.len() > MAX_CHUNK_CHARS {
split_at_sentences(&clean, MAX_CHUNK_CHARS)
} else {
vec![clean]
};
for sub in sub_blocks {
let add = sub.len() + 1;
if self.prose_len + add > MAX_CHUNK_CHARS && !self.prose_parts.is_empty() {
self.flush_prose();
}
self.prose_len += add;
self.prose_parts.push(sub);
extend_span(&mut self.prose_span, block.index);
}
}
}
pub fn build_chunks_from_md_bytes(bytes: &[u8]) -> Result<Vec<SpannedRecord>, String> {
let text = crate::text_encoding::decode_utf8_document(bytes);
if text.trim().is_empty() {
return Err("Markdown file is empty after decoding".to_string());
}
let mut builder = StructuralBuilder::new();
for block in parse_markdown_blocks(&text) {
builder.advance(block);
}
let chunks = merge_short_prose(builder.finish(), MIN_CHUNK_CHARS);
if chunks.is_empty() {
return Err("No chunks generated from Markdown document".to_string());
}
Ok(chunks)
}