Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
193 changes: 193 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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..])),
Expand Down Expand Up @@ -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<String> = 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::<usize>().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 <N>] \
[--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 으로 보존하지만 표 계산기는 직사각
Expand Down
Loading