Skip to content

Commit 6ca8928

Browse files
RoyLinRoyLin
authored andcommitted
feat(doc): add stable elements[] output for unified element consumption
Phase 1: Add `elements[]` output - a unified indexed array combining blocks, tables, and pages into a single stable machine-readable format. - Add StructuredElementKind enum (Block, Table, Page) - Add StructuredElement struct with index, kind, label, content, page, source, location, attributes, and structured_payload - Add elements field to ParsedDocument - Add build_elements() method to ParsedDocument - Add extract_elements_for_output() for JSON serialization - Include elements in parse result metadata
1 parent 9bb648f commit 6ca8928

5 files changed

Lines changed: 264 additions & 1 deletion

File tree

core/src/doc/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub(crate) mod registry;
1111
pub use model::{
1212
DocumentBlock, DocumentBlockKind, DocumentBlockLocation, DocumentConfidence,
1313
DocumentExtractionMetadata, DocumentMetadata, DocumentProvenance, ExtractedDocument,
14-
ParsedDocument,
14+
ParsedDocument, StructuredElement, StructuredElementKind,
1515
};
1616
pub use parser::DocumentParser;
1717
pub use registry::DocumentParserRegistry;

core/src/doc/model.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,126 @@ impl PageInfo {
242242
}
243243
}
244244

245+
/// Unified element kinds for structured element output.
246+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247+
pub enum StructuredElementKind {
248+
/// A document block (paragraph, heading, etc.)
249+
Block,
250+
/// A table element
251+
Table,
252+
/// A page element
253+
Page,
254+
}
255+
256+
impl StructuredElementKind {
257+
pub fn as_str(&self) -> &'static str {
258+
match self {
259+
Self::Block => "block",
260+
Self::Table => "table",
261+
Self::Page => "page",
262+
}
263+
}
264+
}
265+
266+
/// Unified element representation for stable machine-readable element output.
267+
/// Combines blocks, tables, and pages into a single indexed array for downstream consumption.
268+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269+
pub struct StructuredElement {
270+
/// Element index in the flattened elements array.
271+
pub index: usize,
272+
/// Element type for discrimination.
273+
pub kind: StructuredElementKind,
274+
/// Element label/name if available.
275+
pub label: Option<String>,
276+
/// Element content or summary.
277+
pub content: String,
278+
/// Page number if available.
279+
pub page: Option<usize>,
280+
/// Source/location reference.
281+
pub source: Option<String>,
282+
/// Location metadata.
283+
pub location: Option<DocumentBlockLocation>,
284+
/// Extended attributes for kind-specific data.
285+
pub attributes: BTreeMap<String, String>,
286+
/// Structured payload for tables and other structured content.
287+
pub structured_payload: Option<String>,
288+
}
289+
290+
impl StructuredElement {
291+
/// Create a new structured element from a document block.
292+
pub fn from_block(block: &DocumentBlock, index: usize) -> Self {
293+
Self {
294+
index,
295+
kind: StructuredElementKind::Block,
296+
label: block.label.clone(),
297+
content: block.content.clone(),
298+
page: block.location.as_ref().and_then(|l| l.page),
299+
source: block.location.as_ref().and_then(|l| l.source.clone()),
300+
location: block.location.clone(),
301+
attributes: block.attributes.clone(),
302+
structured_payload: block.structured_payload.clone(),
303+
}
304+
}
305+
306+
/// Create a new structured element from a structured table.
307+
pub fn from_table(table: &StructuredTable, index: usize) -> Self {
308+
let content = if table.rows.is_empty() {
309+
String::new()
310+
} else {
311+
table
312+
.rows
313+
.iter()
314+
.map(|row| row.join("\t"))
315+
.collect::<Vec<_>>()
316+
.join("\n")
317+
};
318+
319+
let mut attributes = BTreeMap::new();
320+
attributes.insert("row_count".to_string(), table.row_count.to_string());
321+
attributes.insert("column_count".to_string(), table.column_count.to_string());
322+
323+
Self {
324+
index,
325+
kind: StructuredElementKind::Table,
326+
label: table.label.clone(),
327+
content,
328+
page: table.page,
329+
source: table.source.clone(),
330+
location: table.location.clone(),
331+
attributes,
332+
structured_payload: None,
333+
}
334+
}
335+
336+
/// Create a new structured element from a page info.
337+
pub fn from_page(page: &PageInfo, index: usize) -> Self {
338+
Self {
339+
index,
340+
kind: StructuredElementKind::Page,
341+
label: None,
342+
content: page.preview.clone().unwrap_or_default(),
343+
page: Some(page.page),
344+
source: page.source.clone(),
345+
location: None,
346+
attributes: {
347+
let mut attrs = BTreeMap::new();
348+
attrs.insert("block_count".to_string(), page.block_count.to_string());
349+
if page.continued_from_previous_page {
350+
attrs.insert(
351+
"continued_from_previous_page".to_string(),
352+
"true".to_string(),
353+
);
354+
}
355+
if page.continued_to_next_page {
356+
attrs.insert("continued_to_next_page".to_string(), "true".to_string());
357+
}
358+
attrs
359+
},
360+
structured_payload: None,
361+
}
362+
}
363+
}
364+
245365
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
246366
pub struct ParsedDocument {
247367
pub title: Option<String>,
@@ -251,6 +371,8 @@ pub struct ParsedDocument {
251371
pub tables: Vec<StructuredTable>,
252372
/// Page-level information for documents with page structure.
253373
pub pages: Vec<PageInfo>,
374+
/// Unified elements array combining blocks, tables, and pages.
375+
pub elements: Vec<StructuredElement>,
254376
}
255377

256378
impl ParsedDocument {
@@ -269,6 +391,7 @@ impl ParsedDocument {
269391
metadata: None,
270392
tables: Vec::new(),
271393
pages: Vec::new(),
394+
elements: Vec::new(),
272395
}
273396
}
274397

@@ -290,6 +413,33 @@ impl ParsedDocument {
290413
self.blocks.len()
291414
}
292415

416+
/// Build the unified elements array from blocks, tables, and pages.
417+
/// Elements are ordered: blocks first, then tables, then pages.
418+
pub fn build_elements(&mut self) {
419+
let mut elements = Vec::new();
420+
let mut index = 0;
421+
422+
// Add blocks
423+
for block in &self.blocks {
424+
elements.push(StructuredElement::from_block(block, index));
425+
index += 1;
426+
}
427+
428+
// Add tables
429+
for table in &self.tables {
430+
elements.push(StructuredElement::from_table(table, index));
431+
index += 1;
432+
}
433+
434+
// Add pages
435+
for page in &self.pages {
436+
elements.push(StructuredElement::from_page(page, index));
437+
index += 1;
438+
}
439+
440+
self.elements = elements;
441+
}
442+
293443
pub fn non_empty_block_count(&self) -> usize {
294444
self.blocks
295445
.iter()

core/src/document/consume.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,7 @@ mod tests {
659659
llm_answer: Some("Key finding"),
660660
tables: &[],
661661
pages: &[],
662+
elements: &[],
662663
};
663664

664665
let built = build_parse_result(&input);
@@ -728,6 +729,7 @@ mod tests {
728729
llm_answer: None,
729730
tables: &[],
730731
pages: &[],
732+
elements: &[],
731733
};
732734

733735
let output = build_parse_tool_output(&input);

core/src/document/consume_parse.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ pub(crate) struct PreparedParseDocument {
201201
pub structured_payloads: Vec<serde_json::Value>,
202202
pub tables: Vec<serde_json::Value>,
203203
pub pages: Vec<serde_json::Value>,
204+
pub elements: Vec<serde_json::Value>,
204205
pub structural_summary: String,
205206
pub document_runtime: Option<serde_json::Value>,
206207
pub document_quality: Option<crate::document_pipeline::DocumentQualityReport>,
@@ -414,6 +415,7 @@ pub(crate) fn prepare_parse_document_from_path(
414415
let structured_payloads = summarize_structured_payloads(&raw_document, block_kind_label);
415416
let tables = extract_tables_for_output(&raw_document);
416417
let pages = summarize_pages_for_parse_report(&raw_document);
418+
let elements = extract_elements_for_output(&raw_document);
417419
let structural_summary = build_structural_summary(
418420
&raw_document,
419421
crate::document_service_types::structural_summary_style_for_strategy_label(&strategy_label),
@@ -455,6 +457,7 @@ pub(crate) fn prepare_parse_document_from_path(
455457
structured_payloads,
456458
tables,
457459
pages,
460+
elements,
458461
structural_summary,
459462
document_runtime,
460463
document_quality,
@@ -1242,6 +1245,7 @@ pub(crate) struct ParseResultInput<'a> {
12421245
pub structured_payloads: &'a [serde_json::Value],
12431246
pub tables: &'a [serde_json::Value],
12441247
pub pages: &'a [serde_json::Value],
1248+
pub elements: &'a [serde_json::Value],
12451249
pub max_chars: usize,
12461250
pub structural_summary: &'a str,
12471251
pub llm_answer: Option<&'a str>,
@@ -1315,6 +1319,7 @@ pub(crate) fn build_parse_result(input: &ParseResultInput<'_>) -> BuiltParseResu
13151319
"structured_payloads": input.structured_payloads,
13161320
"tables": input.tables,
13171321
"pages": input.pages,
1322+
"elements": input.elements,
13181323
"max_chars": input.max_chars,
13191324
"query_aware_selection": input.query_used,
13201325
});
@@ -1356,6 +1361,7 @@ pub(crate) fn build_parse_tool_output_from_prepared(
13561361
structured_payloads: &prepared.structured_payloads,
13571362
tables: &prepared.tables,
13581363
pages: &prepared.pages,
1364+
elements: &prepared.elements,
13591365
max_chars,
13601366
structural_summary: &prepared.structural_summary,
13611367
llm_answer,
@@ -1508,6 +1514,109 @@ fn extract_tables_for_output(doc: &ParsedDocument) -> Vec<serde_json::Value> {
15081514
.collect()
15091515
}
15101516

1517+
/// Extract unified elements for stable machine-readable `elements[]` output.
1518+
/// Combines blocks, tables, and pages into a single indexed array.
1519+
fn extract_elements_for_output(doc: &ParsedDocument) -> Vec<serde_json::Value> {
1520+
use crate::doc::StructuredElementKind;
1521+
1522+
let mut elements = Vec::new();
1523+
let mut index = 0;
1524+
1525+
// Add blocks as elements
1526+
for block in &doc.blocks {
1527+
let location_display = block
1528+
.location
1529+
.as_ref()
1530+
.map(|loc| crate::document_render::format_block_location(loc))
1531+
.unwrap_or_default();
1532+
1533+
elements.push(json!({
1534+
"index": index,
1535+
"kind": "block",
1536+
"kind_detail": match block.kind {
1537+
DocumentBlockKind::Paragraph => "paragraph",
1538+
DocumentBlockKind::Heading => "heading",
1539+
DocumentBlockKind::Table => "table",
1540+
DocumentBlockKind::Section => "section",
1541+
DocumentBlockKind::Metadata => "metadata",
1542+
DocumentBlockKind::Slide => "slide",
1543+
DocumentBlockKind::EmailHeader => "email_header",
1544+
DocumentBlockKind::Code => "code",
1545+
DocumentBlockKind::Raw => "raw",
1546+
},
1547+
"label": block.label,
1548+
"content": block.content,
1549+
"page": block.location.as_ref().and_then(|l| l.page),
1550+
"source": block.location.as_ref().and_then(|l| l.source.clone()),
1551+
"location": block.location.as_ref().map(|location: &DocumentBlockLocation| json!({
1552+
"source": location.source,
1553+
"page": location.page,
1554+
"ordinal": location.ordinal,
1555+
"continued_from_previous_page": location.continued_from_previous_page,
1556+
"continued_to_next_page": location.continued_to_next_page,
1557+
"display": location_display,
1558+
})),
1559+
"attributes": block.attributes,
1560+
"structured_payload": block.structured_payload,
1561+
}));
1562+
index += 1;
1563+
}
1564+
1565+
// Add tables as elements
1566+
for table in &doc.tables {
1567+
elements.push(json!({
1568+
"index": index,
1569+
"kind": "table",
1570+
"kind_detail": "table",
1571+
"label": table.label,
1572+
"content": if table.rows.is_empty() {
1573+
String::new()
1574+
} else {
1575+
table.rows.iter().map(|row| row.join("\t")).collect::<Vec<_>>().join("\n")
1576+
},
1577+
"page": table.page,
1578+
"source": table.source,
1579+
"location": table.location.as_ref().map(|location: &DocumentBlockLocation| json!({
1580+
"source": location.source,
1581+
"page": location.page,
1582+
"ordinal": location.ordinal,
1583+
"continued_from_previous_page": location.continued_from_previous_page,
1584+
"continued_to_next_page": location.continued_to_next_page,
1585+
"display": crate::document_render::format_block_location(location),
1586+
})),
1587+
"attributes": {
1588+
"row_count": table.row_count,
1589+
"column_count": table.column_count,
1590+
},
1591+
"structured_payload": null,
1592+
}));
1593+
index += 1;
1594+
}
1595+
1596+
// Add pages as elements
1597+
for page in &doc.pages {
1598+
elements.push(json!({
1599+
"index": index,
1600+
"kind": "page",
1601+
"kind_detail": "page",
1602+
"label": null,
1603+
"content": page.preview.clone().unwrap_or_default(),
1604+
"page": page.page,
1605+
"source": page.source,
1606+
"location": null,
1607+
"attributes": {
1608+
"block_count": page.block_count,
1609+
"continued_from_previous_page": page.continued_from_previous_page,
1610+
"continued_to_next_page": page.continued_to_next_page,
1611+
},
1612+
"structured_payload": null,
1613+
}));
1614+
index += 1;
1615+
}
1616+
1617+
elements
1618+
}
1619+
15111620
fn summarize_pages_for_parse_report(doc: &ParsedDocument) -> Vec<serde_json::Value> {
15121621
use std::collections::BTreeMap;
15131622

core/src/document/engine_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ fn build_parse_tool_output_preserves_strategy_metadata() {
128128
llm_answer: None,
129129
tables: &[],
130130
pages: &[],
131+
elements: &[],
131132
},
132133
);
133134
assert!(output.success);
@@ -218,6 +219,7 @@ fn build_parse_tool_output_surfaces_structured_payloads() {
218219
llm_answer: None,
219220
tables: &[],
220221
pages: &[],
222+
elements: &[],
221223
},
222224
);
223225

0 commit comments

Comments
 (0)