diff --git a/src/lib.rs b/src/lib.rs index 899fab083a..0fb629dbac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod parser; pub mod password_crypto; pub mod plan_schema; pub mod provenance; +pub mod rag; pub mod renderer; pub mod schema_registry; pub mod serializer; diff --git a/src/main.rs b/src/main.rs index 385afcb84d..0cf139770b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -307,6 +307,7 @@ fn main() { Some("export-text") => exit_with(export_text(&args[2..])), Some("export-markdown") => exit_with(export_markdown(&args[2..])), Some("export-tables") => exit_with(export_tables(&args[2..])), + Some("export-llm") => exit_with(export_llm(&args[2..])), Some("table-to-csv") => exit_with(table_to_csv(&args[2..])), Some("csv-to-table") => exit_with(csv_to_table(&args[2..])), Some("chart-to-csv") => exit_with(chart_to_csv(&args[2..])), @@ -6102,6 +6103,198 @@ fn export_tables(args: &[String]) -> i32 { EXIT_OK } +/// `export-llm` — HWP/HWPX 를 **LLM-ready RAG 청크**로 내보낸다. +/// +/// 세계 문서-AI 도구들(Docling·LlamaParse·MarkItDown 등)이 PDF 에는 해 주지만 HWP 에는 +/// 못 해 주는 것 — 구조 인지 청킹·자기완결 표·출처 앵커·untrusted 표지 — 를 rhwp 의 +/// **정확한 이진 구조**(픽셀 추측 아님) 위에서 낸다. 재파싱하지 않고 기존 IR +/// (`build_structure`·`extract_tables`)을 소비한다. 설계·한계는 `src/rag/mod.rs`. +/// +/// 기본 산출은 NDJSON(한 줄당 청크 하나 — 스트림·grep·재개에 적합). `--format json` 은 +/// 단일 봉투. 청크 텍스트는 봉투 출처 계약대로 문서 파생(신뢰 불가)으로 표지한다. +fn export_llm(args: &[String]) -> i32 { + use rhwp::document_core::queries::structure::StructureMode; + use rhwp::rag::{build_chunks, ChunkOptions, LlmChunk, TOKEN_ESTIMATOR}; + + let mut file_path: Option<&str> = None; + let mut out_path: Option = None; + let mut max_tokens: usize = 512; + let mut format = "jsonl".to_string(); + let mut mode = "auto".to_string(); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--max-tokens" => { + i += 1; + match args.get(i).and_then(|v| v.parse::().ok()) { + Some(n) if n >= 1 => max_tokens = n, + _ => { + eprintln!("오류: --max-tokens 뒤에 1 이상의 정수가 필요합니다."); + return EXIT_USAGE; + } + } + } + "--format" => { + i += 1; + match args.get(i).map(|s| s.as_str()) { + Some("jsonl") => format = "jsonl".to_string(), + Some("json") => format = "json".to_string(), + _ => { + eprintln!("오류: --format 은 jsonl 또는 json 이어야 합니다."); + return EXIT_USAGE; + } + } + } + "--mode" => { + i += 1; + match args.get(i) { + Some(m) if StructureMode::parse(m).is_some() => mode = m.clone(), + _ => { + eprintln!("오류: --mode 는 auto | outline | clause 여야 합니다."); + return EXIT_USAGE; + } + } + } + "-o" | "--out" | "--output" => { + i += 1; + match args.get(i) { + Some(p) => out_path = Some(p.clone()), + None => { + eprintln!("오류: -o 뒤에 출력 파일 경로가 필요합니다."); + return EXIT_USAGE; + } + } + } + other if other.starts_with('-') => { + eprintln!("알 수 없는 옵션: {other}"); + return EXIT_USAGE; + } + other => { + if file_path.replace(other).is_some() { + eprintln!("오류: 입력 파일은 하나만 지정할 수 있습니다."); + return EXIT_USAGE; + } + } + } + i += 1; + } + + let Some(file_path) = file_path else { + eprintln!( + "사용법: rhwp export-llm <파일.hwp|파일.hwpx> [--max-tokens ] \ + [--format jsonl|json] [--mode auto|outline|clause] [-o <출력>]" + ); + return EXIT_USAGE; + }; + + let data = match fs::read(file_path) { + Ok(d) => d, + Err(e) => { + eprintln!("오류: 파일을 읽을 수 없습니다 - {}: {}", file_path, e); + return EXIT_RUNTIME; + } + }; + let doc = match load_document(&data) { + Ok(d) => d, + Err(e) => return e.report(), + }; + + let opts = ChunkOptions { + max_tokens, + mode: StructureMode::parse(&mode).unwrap_or(StructureMode::Auto), + }; + let chunks = build_chunks(doc.document(), &opts); + + // NDJSON 한 줄 = 청크 값 + 자기서술 키(schemaVersion/source) + 출처 표지. + // 봉투 출처 계약(mydocs/tech/envelope_provenance.md)을 소비한다 — export-llm 은 + // 아직 capabilities/MCP/provenance-map 에 등재되지 않았으므로(후속 과제) 표지를 + // 직접 붙인다. 값을 담은 필드만 표지에 남긴다. + let chunk_record = |chunk: &LlmChunk| -> serde_json::Value { + let mut value = serde_json::to_value(chunk).unwrap_or(serde_json::Value::Null); + if let Some(obj) = value.as_object_mut() { + let fields = chunk.untrusted_fields(); + obj.insert( + "schemaVersion".to_string(), + serde_json::json!(ENVELOPE_SCHEMA_VERSION), + ); + obj.insert("source".to_string(), serde_json::json!(file_path)); + obj.insert( + "untrustedContent".to_string(), + serde_json::json!(!fields.is_empty()), + ); + obj.insert("untrustedFields".to_string(), serde_json::json!(fields)); + } + value + }; + + let body = if format == "json" { + // 단일 봉투. 표지는 실제로 값이 실린 chunks[] 경로만 광고한다. + let mut untrusted_fields: Vec<&str> = Vec::new(); + if chunks.iter().any(|c| !c.heading_path.is_empty()) { + untrusted_fields.push("chunks[].headingPath"); + } + if chunks.iter().any(|c| !c.text.is_empty()) { + untrusted_fields.push("chunks[].text"); + } + let envelope = serde_json::json!({ + "schemaVersion": ENVELOPE_SCHEMA_VERSION, + "source": file_path, + "maxTokens": max_tokens, + "mode": mode, + "tokenEstimator": TOKEN_ESTIMATOR, + "chunkCount": chunks.len(), + "chunks": chunks, + "untrustedContent": !untrusted_fields.is_empty(), + "untrustedFields": untrusted_fields, + }); + match serde_json::to_string(&envelope) { + Ok(s) => s, + Err(e) => { + eprintln!("오류: JSON 직렬화 실패 - {}", e); + return EXIT_RUNTIME; + } + } + } else { + // NDJSON — 한 줄당 청크 하나. + let mut lines = String::new(); + for chunk in &chunks { + match serde_json::to_string(&chunk_record(chunk)) { + Ok(s) => { + lines.push_str(&s); + lines.push('\n'); + } + Err(e) => { + eprintln!("오류: JSON 직렬화 실패 - {}", e); + return EXIT_RUNTIME; + } + } + } + lines + }; + + if let Some(p) = out_path { + return match fs::write(&p, body.as_bytes()) { + Ok(_) => { + println!("LLM 청크 내보내기 완료: {}개 → {}", chunks.len(), p); + EXIT_OK + } + Err(e) => { + eprintln!("오류: 출력 쓰기 실패 - {}: {}", p, e); + EXIT_RUNTIME + } + }; + } + + // 스트림 출력 — stdout 은 순수 NDJSON/JSON 이다(진행 메시지 없음). + if format == "json" { + println!("{body}"); + } else { + print!("{body}"); + } + EXIT_OK +} + /// `table-to-csv` — 본문 최상위 표를 RFC 4180 CSV 로 내보낸다 (#3719 §6). /// /// `export-tables` 의 격자 JSON 은 병합을 span 으로 보존하지만 표 계산기는 직사각 diff --git a/src/rag/chunker.rs b/src/rag/chunker.rs new file mode 100644 index 0000000000..71b4c401e1 --- /dev/null +++ b/src/rag/chunker.rs @@ -0,0 +1,886 @@ +//! (leaf) HWP/HWPX 문서를 **LLM-ready RAG 청크**로 조립하는 엔진. +//! +//! 상위 개요는 [`crate::rag`] 모듈 문서를 본다. 이 파일은 순수 로직(토큰 추정·표 +//! 선형화·구조 인지 청킹)만 담아 `rustfmt` 대상이 된다. +//! +//! 재파싱하지 않는다 — rhwp 가 이미 만든 IR 을 그대로 소비한다. +//! - 제목 계층: [`build_structure`] (조판부호·개요/조문 판정 그대로) +//! - 표 격자: [`extract_tables`] (앵커 셀 + 병합 span, 픽셀 추측 없음) + +use serde::Serialize; + +use crate::document_core::queries::structure::{build_structure, StructureMode, StructureNode}; +use crate::document_core::queries::table_extract::{extract_tables, TableGrid}; +use crate::model::document::Document; + +/// `tokenEstimate` 가 쓰는 결정론적 휴리스틱의 이름. +/// +/// **실제 토크나이저가 아니다.** 코드포인트 하나가 CJK 면 1 토큰, 그 밖의 비공백 +/// 문자는 4 글자당 1 토큰으로 센다. 그래서 봉투의 필드 이름도 `tokens` 가 아니라 +/// `tokenEstimate` 다 — 값은 근삿값이다. +pub const TOKEN_ESTIMATOR: &str = "cjk1-latin4-v1"; + +/// 표 하나를 조밀 격자로 펼칠 때 허용하는 최대 칸 수. +/// +/// 손상·악의적 문서가 `row_count`/`col_count` 에 거대한 값을 넣어 두면 조밀 격자 +/// 할당이 메모리를 터뜨린다. 상한을 넘으면 앵커 셀만 나열하는 폴백으로 내려간다. +const DENSE_GRID_CELL_CAP: usize = 200_000; + +/// 청크 조립 옵션. +#[derive(Debug, Clone, Copy)] +pub struct ChunkOptions { + /// 청크 하나의 `text` 가 목표로 하는 토큰 예산(추정치 기준). + pub max_tokens: usize, + /// 제목 계층 판정 방식 — `export-structure` 와 같은 모드를 그대로 쓴다. + pub mode: StructureMode, +} + +impl Default for ChunkOptions { + fn default() -> Self { + Self { + max_tokens: 512, + mode: StructureMode::Auto, + } + } +} + +/// 코드포인트가 CJK(한중일) 계열인지 — 토큰 추정에서 1 글자 = 1 토큰으로 센다. +fn is_cjk(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11FF // 한글 자모 + | 0x3040..=0x30FF // 히라가나·가타카나 + | 0x3130..=0x318F // 한글 호환 자모 + | 0x3400..=0x4DBF // CJK 확장 A + | 0x4E00..=0x9FFF // CJK 통합 한자 + | 0xAC00..=0xD7A3 // 한글 음절 + | 0xF900..=0xFAFF // CJK 호환 한자 + | 0xFF00..=0xFFEF // 반각·전각 형태 + ) +} + +/// 결정론적 토큰 수 **추정**. [`TOKEN_ESTIMATOR`] 참조 — 실제 토크나이저가 아니다. +pub fn estimate_tokens(text: &str) -> usize { + let mut cjk = 0usize; + let mut other = 0usize; + for c in text.chars() { + if is_cjk(c) { + cjk += 1; + } else if !c.is_whitespace() { + other += 1; + } + } + cjk + other.div_ceil(4) +} + +/// 청크 내용 구성. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ChunkKind { + /// 문단 텍스트만. + Text, + /// 표(선형화)만. + Table, + /// 문단과 표가 함께. + Mixed, +} + +/// 청크에 실린 표 하나의 **메타데이터**(문서 텍스트는 담지 않는다 — 본문은 `text` 에 있다). +#[derive(Debug, Clone, Serialize)] +pub struct ChunkTableRef { + /// `export-tables` 의 문서 내 표 순번(0부터). + pub index: usize, + /// 표가 놓인 구역 인덱스. + pub section: usize, + /// 표를 담은 문단 인덱스 — 역참조·인용용 주소. + pub paragraph: usize, + /// 행 수. + pub rows: u16, + /// 열 수. + pub cols: u16, + /// 이 파트에서 반복해 실은 머리 행 수. + #[serde(rename = "headerRowCount")] + pub header_row_count: usize, + /// 큰 표가 여러 청크로 쪼개졌을 때의 1 기준 파트 번호. + pub part: usize, + /// 이 표의 총 파트 수. + #[serde(rename = "partCount")] + pub part_count: usize, + /// 표가 쪼개져 머리 행을 되풀이했는가. + #[serde(rename = "headerRepeated")] + pub header_repeated: bool, +} + +/// RAG 청크 하나. +#[derive(Debug, Clone, Serialize)] +pub struct LlmChunk { + /// 문서 전체에서의 0 기준 청크 순번. + #[serde(rename = "chunkIndex")] + pub chunk_index: usize, + /// 루트부터 이 청크가 속한 제목까지의 경로(예: `["제3장", "제2절"]`). + /// 청크를 페이지 밖에서도 자기완결로 만든다. + #[serde(rename = "headingPath")] + pub heading_path: Vec, + /// 소속 제목의 계층 깊이(서문은 0). + #[serde(rename = "headingLevel")] + pub heading_level: u8, + /// 소속 제목이 놓인 구역 인덱스(서문 청크는 없음). + #[serde(skip_serializing_if = "Option::is_none")] + pub section: Option, + /// 소속 제목이 놓인 문단 인덱스(서문 청크는 없음). + #[serde(skip_serializing_if = "Option::is_none")] + pub paragraph: Option, + /// 내용 구성. + pub kind: ChunkKind, + /// `text` 의 토큰 수 **추정치**([`TOKEN_ESTIMATOR`]). + #[serde(rename = "tokenEstimate")] + pub token_estimate: usize, + /// 같은 제목(절)이 여러 청크로 나뉠 때의 1 기준 파트 번호. + pub part: usize, + /// 그 제목이 나뉜 총 청크 수. + #[serde(rename = "partCount")] + pub part_count: usize, + /// 청크 본문 — 문단 텍스트와 선형화된 표. **문서 파생(신뢰 불가)** 값이다. + pub text: String, + /// 이 청크가 품은 표들의 메타데이터. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tables: Vec, +} + +impl LlmChunk { + /// 이 청크에 **실제로 실린** 문서 파생 필드 경로들. + /// + /// 봉투 출처 계약(`mydocs/tech/envelope_provenance.md`)을 소비한다 — 값을 담은 + /// 경로만 남긴다(선언을 그대로 베끼지 않는다). RAG 청크는 프롬프트에 이어 붙는 + /// 주입면 그 자체이므로, 소비자가 이 값을 **데이터로 격리**하도록 표지를 싣는다. + pub fn untrusted_fields(&self) -> Vec<&'static str> { + let mut fields = Vec::new(); + if !self.heading_path.is_empty() { + fields.push("headingPath"); + } + if !self.text.is_empty() { + fields.push("text"); + } + fields + } +} + +// ── 내부 조립 ─────────────────────────────────────────────────────────────── + +/// 제목 트리를 평탄화한 한 조각(제목 하나 + 그에 귀속된 본문·표). +struct Segment { + heading_path: Vec, + heading_level: u8, + section: Option, + paragraph: Option, + paras: Vec, + /// `tables` 벡터에서의 인덱스. + tables: Vec, +} + +impl Segment { + /// 표 배치·정렬에 쓰는 앵커. 서문(주소 없음)은 문서 맨 앞 `(0, 0)` 으로 본다. + fn anchor(&self) -> (usize, usize) { + (self.section.unwrap_or(0), self.paragraph.unwrap_or(0)) + } +} + +/// 제목 트리를 DFS 전위 순회로 평탄화한다 — 제목의 문서 순서를 그대로 보존한다. +fn flatten_nodes(nodes: &[StructureNode], path: &[String], out: &mut Vec) { + for node in nodes { + let mut heading_path = path.to_vec(); + heading_path.push(node.heading.clone()); + out.push(Segment { + heading_path: heading_path.clone(), + heading_level: node.level, + section: Some(node.section), + paragraph: Some(node.paragraph), + paras: node.body.clone(), + tables: Vec::new(), + }); + flatten_nodes(&node.children, &heading_path, out); + } +} + +/// 각 표를 "그 위치를 읽기 순서상 감싸는 가장 깊은 제목"에 귀속시킨다. +/// +/// 세그먼트는 앵커 오름차순(= 제목 문서 순서)이므로, 표 위치 이하인 **마지막** +/// 세그먼트가 주인이다. 첫 제목보다 앞선 표를 받을 서문 세그먼트가 없으면 만든다. +fn assign_tables(segments: &mut Vec, tables: &[TableGrid]) { + if tables.is_empty() { + return; + } + let earliest = tables + .iter() + .map(|t| (t.section, t.paragraph)) + .min() + .expect("tables non-empty"); + let need_leading_preamble = segments + .first() + .is_none_or(|s| s.section.is_some() && earliest < s.anchor()); + if need_leading_preamble { + segments.insert( + 0, + Segment { + heading_path: Vec::new(), + heading_level: 0, + section: None, + paragraph: None, + paras: Vec::new(), + tables: Vec::new(), + }, + ); + } + for (table_index, table) in tables.iter().enumerate() { + let pos = (table.section, table.paragraph); + let owner = segments + .iter() + .rposition(|s| s.anchor() <= pos) + .unwrap_or(0); + segments[owner].tables.push(table_index); + } +} + +/// 셀·캡션 텍스트를 Markdown 표 한 칸에 안전하게 넣도록 정리한다. +fn sanitize_cell(text: &str) -> String { + text.replace('\r', "") + .replace('\n', " ") + .replace('|', "\\|") + .trim() + .to_string() +} + +/// 표 하나를 Markdown 텍스트 파트들로 선형화한다. +/// +/// - 머리 행을 보존하고, 병합 셀은 앵커 칸에 `[병합 R×C]` 로 주석한다(덮인 칸은 빈 칸). +/// - 예산을 넘는 큰 표는 **행 단위로만** 쪼개고(절대 행 중간을 자르지 않는다) 파트마다 +/// 머리 행을 되풀이한다. +/// +/// 반환: `(파트 텍스트, 표 메타, 토큰 추정)` 목록. 항상 최소 1개. +fn linearize_table_parts( + grid: &TableGrid, + max_tokens: usize, +) -> Vec<(String, ChunkTableRef, usize)> { + let rows = grid.rows as usize; + let cols = grid.cols as usize; + let caption = grid + .caption + .as_deref() + .map(sanitize_cell) + .filter(|s| !s.is_empty()); + + // 폴백 — 격자가 비었거나 병적으로 크면 앵커 셀만 나열한다(행 단위 원자, 미분할). + if rows == 0 || cols == 0 || rows.saturating_mul(cols) > DENSE_GRID_CELL_CAP { + let mut lines = Vec::new(); + if let Some(cap) = &caption { + lines.push(format!("[표] {cap}")); + } + for cell in &grid.cells { + lines.push(format!( + "({}, {}) {}", + cell.row, + cell.col, + sanitize_cell(&cell.text) + )); + } + let text = lines.join("\n"); + let tokens = estimate_tokens(&text); + return vec![( + text, + ChunkTableRef { + index: grid.index, + section: grid.section, + paragraph: grid.paragraph, + rows: grid.rows, + cols: grid.cols, + header_row_count: 0, + part: 1, + part_count: 1, + header_repeated: false, + }, + tokens, + )]; + } + + // 조밀 격자로 펼친다 — 병합 앵커 텍스트를 제자리에, 덮인 칸은 빈 문자열. + let mut dense = vec![vec![String::new(); cols]; rows]; + let mut header_flags = vec![false; rows]; + for cell in &grid.cells { + let r = cell.row as usize; + let c = cell.col as usize; + if r >= rows || c >= cols { + continue; + } + let mut text = sanitize_cell(&cell.text); + if cell.row_span > 1 || cell.col_span > 1 { + if !text.is_empty() { + text.push(' '); + } + text.push_str(&format!("[병합 {}×{}]", cell.row_span, cell.col_span)); + } + dense[r][c] = text; + if cell.is_header { + let end = (r + cell.row_span as usize).min(rows); + for flag in header_flags.iter_mut().take(end).skip(r) { + *flag = true; + } + } + } + + let row_line = |cells: &[String]| -> String { format!("| {} |", cells.join(" | ")) }; + let all_rows: Vec = dense.iter().map(|r| row_line(r)).collect(); + + // 선두의 연속된 머리 행 수. 없으면 첫 행을 머리로 삼아(맥락 보존) 항상 유효한 Markdown 표를 낸다. + let mut header_rows = 0usize; + while header_rows < rows && header_flags[header_rows] { + header_rows += 1; + } + if header_rows == 0 { + header_rows = 1; // rows >= 1 은 위에서 보장됨 + } + + let separator = format!("| {} |", vec!["---"; cols].join(" | ")); + let mut header_block: Vec = Vec::new(); + if let Some(cap) = &caption { + header_block.push(format!("[표] {cap}")); + } + header_block.extend(all_rows[..header_rows].iter().cloned()); + header_block.push(separator); + let header_text = header_block.join("\n"); + let header_tokens = estimate_tokens(&header_text); + + // 데이터 행을 예산 안에서 파트로 묶는다 — 행 중간은 절대 자르지 않는다. + let data_rows = &all_rows[header_rows..]; + let mut parts_rows: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut cur_tokens = header_tokens; + for line in data_rows { + let line_tokens = estimate_tokens(line); + if !cur.is_empty() && cur_tokens + line_tokens > max_tokens { + parts_rows.push(std::mem::take(&mut cur)); + cur_tokens = header_tokens; + } + cur.push(line.clone()); + cur_tokens += line_tokens; + } + if !cur.is_empty() || parts_rows.is_empty() { + parts_rows.push(cur); + } + + let part_count = parts_rows.len(); + let header_repeated = part_count > 1; + parts_rows + .into_iter() + .enumerate() + .map(|(k, rows_slice)| { + let mut lines = header_block.clone(); + lines.extend(rows_slice); + let text = lines.join("\n"); + let tokens = estimate_tokens(&text); + ( + text, + ChunkTableRef { + index: grid.index, + section: grid.section, + paragraph: grid.paragraph, + rows: grid.rows, + cols: grid.cols, + header_row_count: header_rows, + part: k + 1, + part_count, + header_repeated, + }, + tokens, + ) + }) + .collect() +} + +/// 조립 중인 청크 버퍼. +struct ChunkBuf { + heading_path: Vec, + heading_level: u8, + section: Option, + paragraph: Option, + pieces: Vec, + tokens: usize, + tables: Vec, + has_text: bool, + has_table: bool, +} + +impl ChunkBuf { + fn new(seg: &Segment) -> Self { + Self { + heading_path: seg.heading_path.clone(), + heading_level: seg.heading_level, + section: seg.section, + paragraph: seg.paragraph, + pieces: Vec::new(), + tokens: 0, + tables: Vec::new(), + has_text: false, + has_table: false, + } + } + + fn is_empty(&self) -> bool { + self.pieces.is_empty() + } + + fn push_text(&mut self, text: String, tokens: usize) { + self.pieces.push(text); + self.tokens += tokens; + self.has_text = true; + } + + fn push_table(&mut self, text: String, table_ref: ChunkTableRef, tokens: usize) { + self.pieces.push(text); + self.tokens += tokens; + self.tables.push(table_ref); + self.has_table = true; + } + + /// 버퍼를 청크로 굳혀 `out` 에 밀어 넣고 버퍼를 비운다. + fn flush(&mut self, out: &mut Vec) { + if self.is_empty() { + return; + } + let kind = match (self.has_text, self.has_table) { + (true, true) => ChunkKind::Mixed, + (false, true) => ChunkKind::Table, + _ => ChunkKind::Text, + }; + let text = self.pieces.join("\n\n"); + let token_estimate = estimate_tokens(&text); + out.push(LlmChunk { + chunk_index: 0, + heading_path: self.heading_path.clone(), + heading_level: self.heading_level, + section: self.section, + paragraph: self.paragraph, + kind, + token_estimate, + part: 0, + part_count: 0, + text, + tables: std::mem::take(&mut self.tables), + }); + self.pieces.clear(); + self.tokens = 0; + self.has_text = false; + self.has_table = false; + } +} + +/// 세그먼트 하나를 청크들로 내보낸다. +/// +/// 자연 경계(제목/문단/표)에서만 나눈다. 문단은 문단 경계에서만, 표는 절대 행 중간을 +/// 자르지 않는다. 한 세그먼트가 여러 청크가 되면 각 청크가 같은 `headingPath` 를 +/// 되풀이해 자기완결을 유지한다. +fn emit_segment(seg: &Segment, tables: &[TableGrid], max_tokens: usize, out: &mut Vec) { + let start = out.len(); + let mut buf = ChunkBuf::new(seg); + + for para in &seg.paras { + let trimmed = para.trim(); + if trimmed.is_empty() { + continue; + } + let tokens = estimate_tokens(trimmed); + if !buf.is_empty() && buf.tokens + tokens > max_tokens { + buf.flush(out); + } + buf.push_text(trimmed.to_string(), tokens); + if buf.tokens > max_tokens { + // 단일 문단이 예산을 넘으면 그 자체로 한 청크(문단 경계는 지킨다). + buf.flush(out); + } + } + + // 표는 세그먼트 안에서 문서 순서(표 index)대로. 본문 뒤에 온다. + let mut table_indices = seg.tables.clone(); + table_indices.sort_unstable(); + for table_index in table_indices { + let parts = linearize_table_parts(&tables[table_index], max_tokens); + if parts.len() == 1 { + let (text, table_ref, tokens) = parts.into_iter().next().expect("one part"); + if !buf.is_empty() && buf.tokens + tokens > max_tokens { + buf.flush(out); + } + buf.push_table(text, table_ref, tokens); + } else { + // 여러 파트로 쪼개진 큰 표는 각 파트가 독립 청크다. + buf.flush(out); + for (text, table_ref, tokens) in parts { + let mut standalone = ChunkBuf::new(seg); + standalone.push_table(text, table_ref, tokens); + standalone.flush(out); + } + } + } + + buf.flush(out); + + // 이 세그먼트가 만든 청크들에 파트 번호를 매긴다. + let produced = out.len() - start; + for (k, chunk) in out[start..].iter_mut().enumerate() { + chunk.part = k + 1; + chunk.part_count = produced; + } +} + +/// 문서를 결정론적 RAG 청크 목록으로 조립한다. +/// +/// 재파싱하지 않는다 — [`build_structure`] 의 제목 계층과 [`extract_tables`] 의 표 +/// 격자를 그대로 소비한다. 같은 입력·옵션이면 바이트까지 같은 결과를 낸다. +pub fn build_chunks(doc: &Document, opts: &ChunkOptions) -> Vec { + let max_tokens = opts.max_tokens.max(1); + let structure = build_structure(doc, opts.mode); + let tables = extract_tables(doc); + + let mut segments: Vec = Vec::new(); + if !structure.preamble.is_empty() { + segments.push(Segment { + heading_path: Vec::new(), + heading_level: 0, + section: None, + paragraph: None, + paras: structure.preamble.clone(), + tables: Vec::new(), + }); + } + flatten_nodes(&structure.roots, &[], &mut segments); + assign_tables(&mut segments, &tables); + + let mut chunks: Vec = Vec::new(); + for seg in &segments { + emit_segment(seg, &tables, max_tokens, &mut chunks); + } + for (i, chunk) in chunks.iter_mut().enumerate() { + chunk.chunk_index = i; + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::control::Control; + use crate::model::document::{Document, Section}; + use crate::model::paragraph::Paragraph; + use crate::model::style::{HeadType, ParaShape}; + use crate::model::table::{Cell, Table}; + + /// para_shape 0 = 본문, 1 = 개요(Outline) 제목. + fn doc_with(paras: Vec) -> Document { + let mut doc = Document::default(); + doc.doc_info.para_shapes.push(ParaShape::default()); // id 0: 본문 + doc.doc_info.para_shapes.push(ParaShape { + head_type: HeadType::Outline, + para_level: 0, + ..ParaShape::default() + }); // id 1: 제목 + doc.sections.push(Section { + paragraphs: paras, + ..Section::default() + }); + doc + } + + fn para(text: &str, shape: u16) -> Paragraph { + Paragraph { + text: text.to_string(), + para_shape_id: shape, + ..Paragraph::new_empty() + } + } + + fn heading(text: &str) -> Paragraph { + para(text, 1) + } + + fn body(text: &str) -> Paragraph { + para(text, 0) + } + + /// 앵커 셀만 가진 표를 하나 담은 문단. + fn para_with_table(rows: u16, cols: u16, cells: Vec) -> Paragraph { + let table = Table { + row_count: rows, + col_count: cols, + cells, + ..Table::default() + }; + let mut p = Paragraph::new_empty(); + p.controls.push(Control::Table(Box::new(table))); + p + } + + fn cell(row: u16, col: u16, text: &str, is_header: bool) -> Cell { + Cell { + row, + col, + row_span: 1, + col_span: 1, + is_header, + paragraphs: vec![body(text)], + ..Cell::default() + } + } + + #[test] + fn empty_document_yields_no_chunks_without_panicking() { + let doc = Document::default(); + let chunks = build_chunks(&doc, &ChunkOptions::default()); + assert!(chunks.is_empty()); + } + + #[test] + fn degenerate_paragraphs_do_not_panic() { + // 빈/공백 문단만 있는 문서. + let doc = doc_with(vec![body(""), body(" "), body("\n")]); + let chunks = build_chunks(&doc, &ChunkOptions::default()); + assert!(chunks.iter().all(|c| !c.text.is_empty())); + } + + #[test] + fn token_estimate_is_labelled_a_heuristic() { + // CJK 는 글자당 1, 라틴은 4글자당 1. + assert_eq!(estimate_tokens("가나다"), 3); + assert_eq!(estimate_tokens("abcd"), 1); + assert_eq!(estimate_tokens("abcdefgh"), 2); + assert_eq!(estimate_tokens(""), 0); + assert_eq!(TOKEN_ESTIMATOR, "cjk1-latin4-v1"); + } + + #[test] + fn multi_paragraph_chunks_stay_within_budget() { + // 짧은 문단 여러 개가 예산 안에서 묶이되, 묶인 청크는 예산(± 휴리스틱)을 + // 넘지 않는다. "\n\n" 이 있으면 여러 문단이 한 청크로 묶였다는 뜻이다. + let mut paras = vec![heading("장")]; + for _ in 0..20 { + paras.push(body("가나다라마")); // 각 5 토큰 + } + let doc = doc_with(paras); + let opts = ChunkOptions { + max_tokens: 12, + mode: StructureMode::Auto, + }; + let chunks = build_chunks(&doc, &opts); + assert!(chunks.len() > 1, "예산이 쪼개기를 유발해야 한다"); + for c in &chunks { + if c.text.contains("\n\n") { + assert!( + c.token_estimate <= opts.max_tokens, + "묶인 청크가 예산 초과: {} > {}", + c.token_estimate, + opts.max_tokens + ); + } + } + } + + #[test] + fn chunk_boundaries_respect_headings() { + let doc = doc_with(vec![ + heading("제1장 총칙"), + body("가나다라마바사"), + heading("제2장 벌칙"), + body("아자차카타파하"), + ]); + let chunks = build_chunks(&doc, &ChunkOptions::default()); + // 서로 다른 제목의 본문이 한 청크에 섞이지 않는다. + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].heading_path, vec!["제1장 총칙"]); + assert!(chunks[0].text.contains("가나다라마바사")); + assert!(!chunks[0].text.contains("아자차카타파하")); + assert_eq!(chunks[1].heading_path, vec!["제2장 벌칙"]); + // 전역 순번은 0,1. + assert_eq!(chunks[0].chunk_index, 0); + assert_eq!(chunks[1].chunk_index, 1); + } + + #[test] + fn nested_headings_build_a_path() { + let mut doc = doc_with(vec![heading("제1장"), heading("제1절"), body("본문")]); + // 제1절을 한 단계 더 깊은 개요 수준으로. + doc.doc_info.para_shapes.push(ParaShape { + head_type: HeadType::Outline, + para_level: 1, + ..ParaShape::default() + }); + doc.sections[0].paragraphs[1].para_shape_id = 2; + let chunks = build_chunks(&doc, &ChunkOptions::default()); + let deep = chunks + .iter() + .find(|c| c.text.contains("본문")) + .expect("본문 청크"); + assert_eq!(deep.heading_path, vec!["제1장", "제1절"]); + assert_eq!(deep.heading_level, 2); + } + + #[test] + fn large_section_splits_into_parts_with_repeated_heading_path() { + // 예산을 작게 잡아 본문이 여러 문단에서 쪼개지게 한다. + let doc = doc_with(vec![ + heading("장"), + body("가나다라마바사아자차"), // 10 토큰 + body("카타파하거너더러머버"), // 10 토큰 + body("서어저처커터퍼허고노"), // 10 토큰 + ]); + let opts = ChunkOptions { + max_tokens: 12, + mode: StructureMode::Auto, + }; + let chunks = build_chunks(&doc, &opts); + assert!(chunks.len() >= 2, "쪼개져야 한다: {}", chunks.len()); + // 모든 파트가 같은 headingPath 를 되풀이한다(자기완결). + for c in &chunks { + assert_eq!(c.heading_path, vec!["장"]); + } + assert_eq!(chunks[0].part, 1); + assert_eq!(chunks[0].part_count, chunks.len()); + } + + #[test] + fn table_never_splits_mid_row_and_repeats_header() { + // 머리 1행 + 데이터 6행, 예산을 작게 잡아 표를 쪼갠다. + let mut cells = vec![cell(0, 0, "이름", true), cell(0, 1, "값", true)]; + for r in 1..=6u16 { + cells.push(cell(r, 0, &format!("행{r}"), false)); + cells.push(cell(r, 1, &format!("데이터{r}"), false)); + } + let doc = doc_with(vec![heading("표 절"), para_with_table(7, 2, cells)]); + let opts = ChunkOptions { + max_tokens: 20, + mode: StructureMode::Auto, + }; + let chunks = build_chunks(&doc, &opts); + let table_chunks: Vec<&LlmChunk> = chunks.iter().filter(|c| !c.tables.is_empty()).collect(); + assert!(table_chunks.len() >= 2, "표가 쪼개져야 한다"); + for c in &table_chunks { + // 각 파트가 머리 행("이름"/"값")을 되풀이한다. + assert!(c.text.contains("이름"), "머리 반복 누락: {}", c.text); + assert!(c.tables[0].header_repeated); + // 셀 값이 행 단위로 온전하다 — "행N" 과 "데이터N" 이 같은 줄에 있다. + for line in c.text.lines().filter(|l| l.contains("행")) { + if let Some(num) = line.split('행').nth(1).and_then(|s| s.chars().next()) { + assert!( + line.contains(&format!("데이터{num}")), + "행이 중간에서 잘렸다: {line}" + ); + } + } + } + } + + #[test] + fn merged_cells_are_annotated() { + let cells = vec![ + Cell { + row: 0, + col: 0, + row_span: 1, + col_span: 2, + is_header: true, + paragraphs: vec![body("병합머리")], + ..Cell::default() + }, + cell(1, 0, "좌", false), + cell(1, 1, "우", false), + ]; + let doc = doc_with(vec![heading("표"), para_with_table(2, 2, cells)]); + let chunks = build_chunks(&doc, &ChunkOptions::default()); + let table_chunk = chunks + .iter() + .find(|c| !c.tables.is_empty()) + .expect("표 청크"); + assert!( + table_chunk.text.contains("[병합 1×2]"), + "병합 주석 누락: {}", + table_chunk.text + ); + } + + #[test] + fn every_chunk_declares_untrusted_content() { + let doc = doc_with(vec![heading("장"), body("본문 텍스트")]); + let chunks = build_chunks(&doc, &ChunkOptions::default()); + assert!(!chunks.is_empty()); + for c in &chunks { + let fields = c.untrusted_fields(); + assert!(fields.contains(&"text"), "text 표지 누락"); + assert!(fields.contains(&"headingPath"), "headingPath 표지 누락"); + } + } + + #[test] + fn output_is_deterministic_byte_for_byte() { + let doc = doc_with(vec![ + heading("제1장"), + body("가나다라마바사"), + para_with_table( + 2, + 2, + vec![cell(0, 0, "머리", true), cell(1, 0, "값", false)], + ), + heading("제2장"), + body("아자차카타파하"), + ]); + let opts = ChunkOptions::default(); + let a = serde_json::to_string(&build_chunks(&doc, &opts)).unwrap(); + let b = serde_json::to_string(&build_chunks(&doc, &opts)).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn all_body_text_and_tables_are_covered() { + // 라운드트립: 구조·표에서 나온 모든 문자열이 청크 어딘가에 있다(무손실). + let doc = doc_with(vec![ + body("서문문단"), + heading("제1장 제목"), + body("첫째 본문"), + body("둘째 본문"), + para_with_table( + 2, + 1, + vec![cell(0, 0, "표머리", true), cell(1, 0, "표값", false)], + ), + ]); + let chunks = build_chunks(&doc, &ChunkOptions::default()); + let haystack: String = chunks + .iter() + .map(|c| format!("{} {}", c.heading_path.join(" "), c.text)) + .collect::>() + .join("\n"); + for needle in [ + "서문문단", + "제1장 제목", + "첫째 본문", + "둘째 본문", + "표머리", + "표값", + ] { + assert!(haystack.contains(needle), "누락: {needle}"); + } + } + + #[test] + fn table_before_first_heading_lands_in_a_preamble_chunk() { + let doc = doc_with(vec![ + para_with_table(1, 1, vec![cell(0, 0, "선행표", true)]), + heading("제1장"), + body("본문"), + ]); + let chunks = build_chunks(&doc, &ChunkOptions::default()); + let table_chunk = chunks + .iter() + .find(|c| !c.tables.is_empty()) + .expect("표 청크"); + assert!( + table_chunk.heading_path.is_empty(), + "서문 표는 제목 경로가 비어야 한다" + ); + assert!(table_chunk.text.contains("선행표")); + } +} diff --git a/src/rag/mod.rs b/src/rag/mod.rs new file mode 100644 index 0000000000..9b3ba28450 --- /dev/null +++ b/src/rag/mod.rs @@ -0,0 +1,49 @@ +//! HWP/HWPX → **LLM-ready RAG 출력** 축. +//! +//! 2025–2026 문서-AI 프런티어(Docling·LlamaParse·MarkItDown·Marker·Unstructured)는 +//! PDF 를 청킹·표 선형화·출처 앵커가 붙은 RAG 입력으로 바꿔 준다. 그러나 이들 중 +//! **어느 것도 HWP/HWPX 를 읽지 못한다.** rhwp 는 그 공백을 정조준한다 — 픽셀을 +//! 추측하는 PDF 도구와 달리, rhwp 는 이미 **정확한 이진 구조**를 파싱한다: 읽기 +//! 순서와 표 셀 경계(병합 span 포함)가 추측이 아니라 실측이다. +//! +//! 이 모듈은 **재파싱하지 않는다.** rhwp 가 이미 만든 IR 을 소비해 그 위에 LLM 패키징 +//! 계층만 얹는다. +//! - 제목 계층 → [`crate::document_core::queries::structure::build_structure`] +//! - 표 격자(앵커 셀 + 병합 span) → [`crate::document_core::queries::table_extract::extract_tables`] +//! +//! # 산출 계약 +//! +//! [`chunker::build_chunks`] 는 결정론적 RAG 청크 목록을 만든다. 각 청크는: +//! 1. **구조 인지 청킹** — 자연 경계(제목/문단/표)에서만 나뉘고, 설정 가능한 토큰 +//! 예산([`chunker::ChunkOptions::max_tokens`])을 목표로 한다. 토큰 수는 실제 +//! 토크나이저가 아니라 **추정치**이며 필드 이름도 `tokenEstimate` 다 +//! ([`chunker::TOKEN_ESTIMATOR`]). +//! 2. **자기완결 표** — 표는 머리 행을 보존하고 병합 셀을 주석해 Markdown 으로 +//! 선형화한다. 큰 표는 **행 단위로만** 쪼개고(행 중간을 자르지 않는다) 파트마다 +//! 머리 행을 되풀이한다. +//! 3. **출처 앵커** — 청크마다 `headingPath`(루트→소속 제목)와 소속 제목의 +//! `section`/`paragraph` 주소를 실어 다운스트림 에이전트가 인용할 수 있게 한다. +//! 4. **untrusted 표지** — RAG 청크는 프롬프트에 이어 붙는 **주입면 그 자체**다. +//! 봉투 출처 계약(`mydocs/tech/envelope_provenance.md`)대로 청크 텍스트를 +//! 문서 파생(신뢰 불가)으로 표지한다. +//! +//! # 정직한 한계 (재파싱하지 않으므로 IR 이 모델하지 않는 것은 지어내지 않는다) +//! +//! - **본문 문단 주소**: 재사용하는 구조 IR([`build_structure`])은 제목의 +//! `(section, paragraph)` 만 남기고 본문 문단 각각의 주소는 접는다. 그래서 청크의 +//! 인용 앵커는 **소속 제목의 주소**이지 본문 문단별 오프셋이 아니다. +//! - **문단↔표 정밀 인터리브**: 같은 이유로 세그먼트 안에서 본문 텍스트가 표보다 +//! 앞서고, 표는 문서 위치 순으로 뒤에 온다. 문단과 표의 정확한 끼워넣기는 후속 +//! 과제다. +//! - **페이지 번호**: 구조 IR 은 논리 구조를 주지 물리 페이지를 주지 않으므로 청크는 +//! 페이지 번호를 싣지 않는다(지어내지 않는다). +//! - **다단(multi-column) 읽기 순서**: IR 이 주는 읽기 순서를 그대로 쓴다. +//! +//! [`build_structure`]: crate::document_core::queries::structure::build_structure + +pub mod chunker; + +pub use chunker::{ + build_chunks, estimate_tokens, ChunkKind, ChunkOptions, ChunkTableRef, LlmChunk, + TOKEN_ESTIMATOR, +}; diff --git a/tests/llm_export_contract.rs b/tests/llm_export_contract.rs new file mode 100644 index 0000000000..5b06450314 --- /dev/null +++ b/tests/llm_export_contract.rs @@ -0,0 +1,361 @@ +//! `export-llm` 계약 — HWP/HWPX → LLM-ready RAG 청크. +//! +//! 실제 바이너리를 실제 샘플에 돌려 계약을 고정한다: +//! - 기본 산출은 NDJSON(한 줄당 청크 하나), `--format json` 은 단일 봉투. +//! - 청크마다 출처 앵커(headingPath/section/paragraph)와 **untrusted 표지**가 실린다. +//! - 표는 청크 안에서 Markdown 으로 선형화되어 자기완결이다(머리 행 보존·병합 주석). +//! - 같은 입력·옵션이면 바이트까지 같다(결정론). +//! - 청크 텍스트의 합이 문서 본문을 사실상 덮는다(무손실, export-text 대조). +#![cfg(not(target_arch = "wasm32"))] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::Value; + +/// 본문만 있는 논문 샘플(제목 미검출 → 전량 서문). 예산 쪼개기·결정론·커버리지에 쓴다. +const PAPER: &str = "samples/hwp3-sample.hwp"; +/// 중첩 제목 계층과 표가 풍부한 정부 편람. 제목 경로·표 선형화에 쓴다. +const MANUAL: &str = "samples/2025 행정업무운영 편람(최종).hwpx"; + +fn rhwp_bin() -> String { + std::env::var("CARGO_BIN_EXE_rhwp").unwrap_or_else(|_| env!("CARGO_BIN_EXE_rhwp").to_string()) +} + +fn sample(rel: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(rel) +} + +fn run(args: &[&str]) -> Output { + Command::new(rhwp_bin()) + .args(args) + .output() + .expect("rhwp 실행 실패") +} + +fn describe(args: &[&str], out: &Output) -> String { + format!( + "명령: rhwp {}\n종료: {:?}\nstderr:\n{}", + args.join(" "), + out.status.code(), + String::from_utf8_lossy(&out.stderr), + ) +} + +fn stdout_string(out: &Output) -> String { + String::from_utf8(out.stdout.clone()).expect("stdout UTF-8") +} + +/// 문자열에서 영숫자만 이어붙인다 — 구두점·공백 표면 차이를 지운 내용 비교용. +fn alnum(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).collect() +} + +fn has_hangul(s: &str) -> bool { + s.chars().any(|c| ('\u{AC00}'..='\u{D7A3}').contains(&c)) +} + +// ── NDJSON 기본 산출 ──────────────────────────────────────────────────────── + +#[test] +fn ndjson_is_default_and_every_line_is_a_marked_chunk() { + let path = sample(PAPER); + let path = path.to_str().unwrap(); + let args = ["export-llm", path]; + let out = run(&args); + assert_eq!(out.status.code(), Some(0), "{}", describe(&args, &out)); + + let body = stdout_string(&out); + let lines: Vec<&str> = body.lines().filter(|l| !l.trim().is_empty()).collect(); + assert!(!lines.is_empty(), "청크가 최소 하나는 나와야 한다"); + + for (i, line) in lines.iter().enumerate() { + let v: Value = serde_json::from_str(line).expect("각 줄은 순수 JSON 객체"); + assert_eq!(v["schemaVersion"], "1.0"); + assert!(v["source"].as_str().unwrap().ends_with("hwp3-sample.hwp")); + assert_eq!(v["chunkIndex"], i as i64, "chunkIndex 는 0부터 순차적"); + // 출처 표지 — 청크는 프롬프트 주입면이므로 항상 문서 파생으로 표지된다. + assert_eq!(v["untrustedContent"], true, "{line}"); + let fields = v["untrustedFields"] + .as_array() + .expect("untrustedFields 배열"); + assert!( + fields.iter().any(|f| f == "text"), + "text 표지가 있어야 한다: {line}" + ); + assert!(v["text"].as_str().is_some_and(|t| !t.is_empty())); + } +} + +#[test] +fn json_format_yields_a_single_envelope() { + let path = sample(PAPER); + let path = path.to_str().unwrap(); + let args = ["export-llm", path, "--format", "json"]; + let out = run(&args); + assert_eq!(out.status.code(), Some(0), "{}", describe(&args, &out)); + + let v: Value = serde_json::from_slice(&out.stdout).expect("단일 JSON 봉투"); + assert_eq!(v["schemaVersion"], "1.0"); + assert_eq!(v["maxTokens"], 512); + assert_eq!(v["mode"], "auto"); + assert_eq!(v["tokenEstimator"], "cjk1-latin4-v1"); + let chunks = v["chunks"].as_array().expect("chunks 배열"); + assert_eq!(v["chunkCount"], chunks.len() as i64); + assert!(!chunks.is_empty()); + assert_eq!(v["untrustedContent"], true); + let fields = v["untrustedFields"].as_array().expect("untrustedFields"); + assert!( + fields.iter().any(|f| f == "chunks[].text"), + "봉투 표지에 chunks[].text 가 있어야 한다: {v}" + ); +} + +// ── 결정론 ────────────────────────────────────────────────────────────────── + +#[test] +fn output_is_byte_for_byte_deterministic() { + let path = sample(PAPER); + let path = path.to_str().unwrap(); + for format in [ + vec!["export-llm", path], + vec!["export-llm", path, "--format", "json"], + ] { + let a = run(&format); + let b = run(&format); + assert_eq!( + a.stdout, b.stdout, + "같은 입력·옵션은 바이트까지 같아야 한다" + ); + } +} + +// ── 토큰 예산 ──────────────────────────────────────────────────────────────── + +#[test] +fn smaller_budget_produces_more_chunks() { + let path = sample(PAPER); + let path = path.to_str().unwrap(); + let count = |budget: &str| -> usize { + let args = [ + "export-llm", + path, + "--format", + "json", + "--max-tokens", + budget, + ]; + let out = run(&args); + assert_eq!(out.status.code(), Some(0), "{}", describe(&args, &out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + v["chunks"].as_array().unwrap().len() + }; + // 예산이 작을수록 청크가 늘어난다 — 예산이 실제로 쪼갠다는 신호. + assert!( + count("100") > count("2000"), + "작은 예산이 더 많은 청크를 내야 한다" + ); +} + +#[test] +fn multi_unit_text_chunks_respect_the_budget() { + // 여러 문단이 묶인(text 에 빈 줄 경계가 있는) text 청크는 예산을 넘지 않는다. + // 단일 초대형 문단은 문단 경계를 지키느라 예산을 넘을 수 있다(정직한 예외). + let path = sample(PAPER); + let path = path.to_str().unwrap(); + let budget = 200i64; + let args = [ + "export-llm", + path, + "--format", + "json", + "--max-tokens", + "200", + ]; + let out = run(&args); + assert_eq!(out.status.code(), Some(0), "{}", describe(&args, &out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + for c in v["chunks"].as_array().unwrap() { + if c["kind"] == "text" && c["text"].as_str().unwrap().contains("\n\n") { + assert!( + c["tokenEstimate"].as_i64().unwrap() <= budget, + "묶인 text 청크가 예산 초과: {c}" + ); + } + } +} + +// ── 제목 경로 · 자기완결 표 ────────────────────────────────────────────────── + +#[test] +fn nested_heading_paths_and_anchors_are_present() { + let path = sample(MANUAL); + let path = path.to_str().unwrap(); + let args = ["export-llm", path, "--format", "json"]; + let out = run(&args); + assert_eq!(out.status.code(), Some(0), "{}", describe(&args, &out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + let chunks = v["chunks"].as_array().unwrap(); + + // 중첩 제목 경로(예: ["제2장 …", "제1절 …"])가 실제로 나온다. + let nested = chunks + .iter() + .any(|c| c["headingPath"].as_array().map(|p| p.len()).unwrap_or(0) >= 2); + assert!(nested, "중첩 제목 경로가 있어야 한다"); + + // 제목 경로가 있는 청크는 headingPath 를 문서 파생으로 표지하고 주소를 싣는다. + for c in chunks { + let hp = c["headingPath"].as_array().unwrap(); + if !hp.is_empty() { + assert!( + c["section"].is_number(), + "제목 청크는 section 앵커를 실어야 한다" + ); + assert!(c["paragraph"].is_number()); + } + } +} + +#[test] +fn tables_are_linearized_and_self_contained() { + let path = sample(MANUAL); + let path = path.to_str().unwrap(); + let args = ["export-llm", path, "--format", "json"]; + let out = run(&args); + assert_eq!(out.status.code(), Some(0), "{}", describe(&args, &out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + let chunks = v["chunks"].as_array().unwrap(); + + // 표를 품은 청크가 있고, 그 표는 청크 텍스트 안에서 Markdown 으로 선형화된다. + let table_chunk = chunks + .iter() + .find(|c| { + c["tables"].as_array().is_some_and(|t| !t.is_empty()) + && c["text"].as_str().unwrap().contains("| --- |") + }) + .expect("Markdown 표를 품은 청크가 있어야 한다"); + let table_meta = &table_chunk["tables"][0]; + assert!(table_meta["rows"].is_number()); + assert!(table_meta["cols"].is_number()); + assert!(table_meta["index"].is_number()); + + // 병합 셀은 청크 텍스트에 주석된다(문서 어딘가에 병합 표가 있다). + let any_merge = chunks + .iter() + .any(|c| c["text"].as_str().unwrap().contains("[병합")); + assert!(any_merge, "병합 셀 주석이 최소 한 번은 나와야 한다"); +} + +#[test] +fn split_tables_repeat_their_header() { + // 작은 예산으로 큰 표를 강제로 쪼갠 뒤, 모든 파트가 머리 행을 되풀이하는지 본다. + let path = sample(MANUAL); + let path = path.to_str().unwrap(); + let args = ["export-llm", path, "--format", "json", "--max-tokens", "80"]; + let out = run(&args); + assert_eq!(out.status.code(), Some(0), "{}", describe(&args, &out)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + let chunks = v["chunks"].as_array().unwrap(); + + let mut saw_split = false; + for c in chunks { + for t in c["tables"].as_array().into_iter().flatten() { + if t["partCount"].as_i64().unwrap_or(1) > 1 { + saw_split = true; + assert_eq!( + t["headerRepeated"], true, + "쪼개진 표 파트는 머리 행을 되풀이한다: {c}" + ); + } + } + } + assert!(saw_split, "예산 80 이면 큰 표가 쪼개져야 한다"); +} + +// ── 무손실(라운드트립) ────────────────────────────────────────────────────── + +/// export-text 본문 토큰이 청크(headingPath + text)에 얼마나 담기는지. +fn coverage(path: &str) -> f64 { + let text_out = run(&["export-text", "--json", path]); + let tx: Value = serde_json::from_slice(&text_out.stdout).unwrap(); + let mut needles: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for page in tx["pages"].as_array().unwrap() { + for raw in page["text"].as_str().unwrap_or("").split_whitespace() { + let w = alnum(raw); + let len = w.chars().count(); + if (len >= 2 && has_hangul(&w)) || len >= 4 { + needles.insert(w); + } + } + } + let llm_out = run(&["export-llm", "--format", "json", path]); + let llm: Value = serde_json::from_slice(&llm_out.stdout).unwrap(); + let mut hay = String::new(); + for c in llm["chunks"].as_array().unwrap() { + for h in c["headingPath"].as_array().unwrap() { + hay.push_str(&alnum(h.as_str().unwrap())); + } + hay.push_str(&alnum(c["text"].as_str().unwrap())); + } + let total = needles.len(); + if total == 0 { + return 1.0; + } + let present = needles.iter().filter(|n| hay.contains(n.as_str())).count(); + present as f64 / total as f64 +} + +#[test] +fn chunks_cover_the_document_body() { + // 본문만 있는 논문: 사실상 전량 커버. + let paper = sample(PAPER); + let paper_cov = coverage(paper.to_str().unwrap()); + assert!(paper_cov >= 0.97, "PAPER 커버리지 {paper_cov:.4} < 0.97"); + + // 제목·표가 풍부한 편람: page-표면(머리말/꼬리말·쪽번호 반복) 차이로 100% 는 아니나 + // 본문 손실은 없다 — 보수적 하한을 건다. + let manual = sample(MANUAL); + let manual_cov = coverage(manual.to_str().unwrap()); + assert!(manual_cov >= 0.92, "MANUAL 커버리지 {manual_cov:.4} < 0.92"); +} + +// ── 사용법 · 런타임 오류 ──────────────────────────────────────────────────── + +#[test] +fn usage_and_runtime_errors_use_the_right_exit_codes() { + // 인자 없음 → 사용법 오류(2). + assert_eq!(run(&["export-llm"]).status.code(), Some(2)); + // 잘못된 --format → 2. + let p = sample(PAPER); + let p = p.to_str().unwrap(); + assert_eq!( + run(&["export-llm", p, "--format", "xml"]).status.code(), + Some(2) + ); + // --max-tokens 0 → 2. + assert_eq!( + run(&["export-llm", p, "--max-tokens", "0"]).status.code(), + Some(2) + ); + // 잘못된 --mode → 2. + assert_eq!( + run(&["export-llm", p, "--mode", "bogus"]).status.code(), + Some(2) + ); + // 없는 파일 → 런타임 실패(1). + assert_eq!( + run(&["export-llm", "does-not-exist.hwp"]).status.code(), + Some(1) + ); +} + +#[test] +fn mode_option_is_accepted() { + let p = sample(PAPER); + let p = p.to_str().unwrap(); + for mode in ["auto", "outline", "clause"] { + let args = ["export-llm", p, "--format", "json", "--mode", mode]; + let out = run(&args); + assert_eq!(out.status.code(), Some(0), "{}", describe(&args, &out)); + } +}