Phase 6: Ingest pipeline — CSV, JSON, PDF, text file support

- ingestd crate: detect file type → parse → schema detection → Parquet → catalog
- CSV: auto-detect column types (int, float, bool, string), handles $, %, commas
  Strips dollar signs from amounts, flexible row parsing, sanitized column names
- JSON: array or newline-delimited, nested object flattening (a.b.c → a_b_c)
- PDF: text extraction via lopdf, one row per page (source_file, page_number, text)
- Text/SMS: line-based ingestion with line numbers
- Dedup: SHA-256 content hash, re-ingest same file = no-op
- Gateway: POST /ingest/file multipart upload, 256MB body limit
- Schema detection per ADR-010: ambiguous types default to String
- 12 unit tests passing (CSV parsing, JSON flattening, type inference, dedup)
- Tested: messy CSV with missing data, dollar amounts, N/A values → queryable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-03-27 08:07:31 -05:00
co-authored by Claude Opus 4.6
parent 6740a017c7
commit bb05c4412e
25 changed files with 1261 additions and 78 deletions
+52
View File
@@ -0,0 +1,52 @@
use arrow::array::{ArrayRef, Int32Array, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use std::sync::Arc;
/// Extract text from a PDF file.
/// Returns one row per page: (page_number, text_content).
/// This handles text-based PDFs. Scanned/image PDFs need OCR (not implemented yet).
pub fn parse_pdf(content: &[u8], source_filename: &str) -> Result<(Arc<Schema>, Vec<RecordBatch>), String> {
let doc = lopdf::Document::load_mem(content)
.map_err(|e| format!("PDF load error: {e}"))?;
let pages = doc.get_pages();
let mut page_numbers: Vec<i32> = Vec::new();
let mut page_texts: Vec<String> = Vec::new();
let mut sources: Vec<String> = Vec::new();
for (&page_num, _) in pages.iter() {
let text = doc.extract_text(&[page_num]).unwrap_or_default();
let text = text.trim().to_string();
if !text.is_empty() {
page_numbers.push(page_num as i32);
page_texts.push(text);
sources.push(source_filename.to_string());
}
}
if page_numbers.is_empty() {
// PDF has no extractable text — likely scanned/image
return Err("PDF contains no extractable text (may be scanned/image — OCR not yet supported)".into());
}
tracing::info!("extracted {} pages with text from PDF '{}'", page_numbers.len(), source_filename);
let schema = Arc::new(Schema::new(vec![
Field::new("source_file", DataType::Utf8, false),
Field::new("page_number", DataType::Int32, false),
Field::new("text_content", DataType::Utf8, false),
]));
let arrays: Vec<ArrayRef> = vec![
Arc::new(StringArray::from(sources)),
Arc::new(Int32Array::from(page_numbers)),
Arc::new(StringArray::from(page_texts)),
];
let batch = RecordBatch::try_new(schema.clone(), arrays)
.map_err(|e| format!("RecordBatch error: {e}"))?;
Ok((schema, vec![batch]))
}