feat(config): step_2_to_3 — 미디어 테이블 [ingest.*] relocation + pdf paddle 값 보존
move_table(decor 포함 통째 이동) + move_indexing_keys(병렬도 키) + copy_image_paddle_to_pdf(v2 비대칭 보존). CURRENT_SCHEMA_VERSION=3. section_comment 를 ingest.* 경로로 갱신. 멱등. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,11 @@
|
|||||||
//! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec
|
//! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec
|
||||||
//! `docs/superpowers/specs/2026-05-31-config-migration-design.md`.
|
//! `docs/superpowers/specs/2026-05-31-config-migration-design.md`.
|
||||||
|
|
||||||
use toml_edit::DocumentMut;
|
use toml_edit::{DocumentMut, Item};
|
||||||
|
|
||||||
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
|
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
|
||||||
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
|
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
|
||||||
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
|
pub const CURRENT_SCHEMA_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// 한 번의 마이그레이션에서 발생한 개별 변경.
|
/// 한 번의 마이그레이션에서 발생한 개별 변경.
|
||||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||||
@@ -77,14 +77,15 @@ fn section_comment(path: &str) -> Option<&'static str> {
|
|||||||
"models.nli" => "# NLI(groundedness) 모델.",
|
"models.nli" => "# NLI(groundedness) 모델.",
|
||||||
"search" => "# 검색 기본 k·stale 기준·fusion.",
|
"search" => "# 검색 기본 k·stale 기준·fusion.",
|
||||||
"rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.",
|
"rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.",
|
||||||
"image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).",
|
|
||||||
"image.ocr" => "# 이미지 OCR(기본 off).",
|
|
||||||
"image.caption" => "# 이미지 캡션(기본 off).",
|
|
||||||
"ui" => "# TUI 팔레트·role 스타일.",
|
"ui" => "# TUI 팔레트·role 스타일.",
|
||||||
"ingest" => "# ingest 정책(code skip 등).",
|
"ingest" => "# 모든 형식 ingest 우산: 병렬도 + chunking/code/image/pdf.",
|
||||||
|
"ingest.chunking" => "# 청크 크기·오버랩·heading 존중(전 형식 공통).",
|
||||||
"ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).",
|
"ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).",
|
||||||
"pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).",
|
"ingest.image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).",
|
||||||
"pdf.ocr" => "# scanned PDF page-단위 OCR(기본 off).",
|
"ingest.image.ocr" => "# 이미지 OCR(기본 off).",
|
||||||
|
"ingest.image.caption" => "# 이미지 캡션(기본 off).",
|
||||||
|
"ingest.pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).",
|
||||||
|
"ingest.pdf.ocr" => "# scanned PDF page-단위 OCR(기본 off).",
|
||||||
"logging" => "# ingest 로그(기본 on, ~/.local/state/kebab/logs).",
|
"logging" => "# ingest 로그(기본 on, ~/.local/state/kebab/logs).",
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
@@ -237,12 +238,152 @@ pub fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `from_path` 의 마지막 키를 통째(decor 포함) remove 해 `to_path` 의 dotted
|
||||||
|
/// 경로에 삽입한다(중간 테이블 자동 생성). 대상 키가 이미 있으면 덮어쓰지
|
||||||
|
/// 않는다(사용자 명시 우선). 원본이 없으면 no-op(멱등).
|
||||||
|
fn move_table(
|
||||||
|
doc: &mut DocumentMut,
|
||||||
|
from_path: &[&str],
|
||||||
|
to_path: &[&str],
|
||||||
|
changes: &mut Vec<MigrationChange>,
|
||||||
|
) {
|
||||||
|
// from 의 부모까지 내려가 마지막 키를 remove.
|
||||||
|
let (from_parent, from_key) = from_path.split_at(from_path.len() - 1);
|
||||||
|
let mut cur = doc.as_table_mut();
|
||||||
|
for k in from_parent {
|
||||||
|
match cur.get_mut(k).and_then(Item::as_table_mut) {
|
||||||
|
Some(t) => cur = t,
|
||||||
|
None => return, // 원본 없음 → no-op.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(item) = cur.remove(from_key[0]) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// to 경로의 부모 테이블 확보(없으면 생성), 마지막 키에 삽입.
|
||||||
|
let (to_parent, to_key) = to_path.split_at(to_path.len() - 1);
|
||||||
|
let mut cur = doc.as_table_mut();
|
||||||
|
for k in to_parent {
|
||||||
|
if cur.get(k).is_none() {
|
||||||
|
cur.insert(k, Item::Table(toml_edit::Table::new()));
|
||||||
|
}
|
||||||
|
cur = cur
|
||||||
|
.get_mut(k)
|
||||||
|
.and_then(Item::as_table_mut)
|
||||||
|
.expect("just inserted");
|
||||||
|
}
|
||||||
|
if cur.get(to_key[0]).is_none() {
|
||||||
|
cur.insert(to_key[0], item);
|
||||||
|
changes.push(MigrationChange {
|
||||||
|
kind: ChangeKind::AddedSection,
|
||||||
|
path: to_path.join("."),
|
||||||
|
detail: format!("{} → {}", from_path.join("."), to_path.join(".")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 옛 `[indexing]` 의 bare 스칼라 키들을 `[ingest]` 로 옮긴다(테이블 자체가
|
||||||
|
/// 아니라 키 단위). 대상에 이미 있는 키는 덮어쓰지 않는다.
|
||||||
|
fn move_indexing_keys(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||||
|
let Some(idx) = doc.as_table_mut().remove("indexing") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(idx_tbl) = idx.as_table().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if doc.get("ingest").is_none() {
|
||||||
|
doc["ingest"] = Item::Table(toml_edit::Table::new());
|
||||||
|
}
|
||||||
|
let ingest = doc["ingest"].as_table_mut().expect("ingest table");
|
||||||
|
for (k, v) in idx_tbl.iter() {
|
||||||
|
if ingest.get(k).is_none() {
|
||||||
|
ingest.insert(k, v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes.push(MigrationChange {
|
||||||
|
kind: ChangeKind::AddedKey,
|
||||||
|
path: "ingest".to_string(),
|
||||||
|
detail: "indexing → ingest (병렬도 키)".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v3: pdf paddle 동작 보존. v2 는 pdf paddle 이 `[image.ocr]` 의 모델 경로를
|
||||||
|
/// 빌려썼다. relocation 후 image.ocr 의 paddle 6키 실제 값을 pdf.ocr 대칭
|
||||||
|
/// 키로 복사한다(pdf 가 이미 명시한 키는 덮어쓰지 않음, pdf 가 paddle 일 때만).
|
||||||
|
fn copy_image_paddle_to_pdf(doc: &mut DocumentMut) {
|
||||||
|
const PADDLE_KEYS: [&str; 6] = [
|
||||||
|
"det_model",
|
||||||
|
"rec_model",
|
||||||
|
"dict",
|
||||||
|
"score_thresh",
|
||||||
|
"unclip_ratio",
|
||||||
|
"max_boxes",
|
||||||
|
];
|
||||||
|
let img = doc
|
||||||
|
.get("ingest")
|
||||||
|
.and_then(|i| i.get("image"))
|
||||||
|
.and_then(|i| i.get("ocr"))
|
||||||
|
.and_then(Item::as_table)
|
||||||
|
.cloned();
|
||||||
|
let Some(img) = img else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let pdf_is_paddle = doc
|
||||||
|
.get("ingest")
|
||||||
|
.and_then(|i| i.get("pdf"))
|
||||||
|
.and_then(|i| i.get("ocr"))
|
||||||
|
.and_then(|o| o.get("engine"))
|
||||||
|
.and_then(Item::as_str)
|
||||||
|
== Some("paddle-onnx");
|
||||||
|
if !pdf_is_paddle {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(pdf) = doc["ingest"]["pdf"]["ocr"].as_table_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for k in PADDLE_KEYS {
|
||||||
|
if pdf.get(k).is_none() {
|
||||||
|
if let Some(v) = img.get(k) {
|
||||||
|
pdf.insert(k, v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v2 → v3: 미디어 테이블을 `[ingest.*]` 로 relocation(값·주석 보존) + pdf
|
||||||
|
/// paddle 값 보존. 멱등(이미 v3 면 원본 테이블이 없어 전부 no-op).
|
||||||
|
pub fn step_2_to_3(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||||
|
move_indexing_keys(doc, changes);
|
||||||
|
move_table(doc, &["chunking"], &["ingest", "chunking"], changes);
|
||||||
|
move_table(doc, &["image", "ocr"], &["ingest", "image", "ocr"], changes);
|
||||||
|
move_table(
|
||||||
|
doc,
|
||||||
|
&["image", "caption"],
|
||||||
|
&["ingest", "image", "caption"],
|
||||||
|
changes,
|
||||||
|
);
|
||||||
|
move_table(doc, &["pdf", "ocr"], &["ingest", "pdf", "ocr"], changes);
|
||||||
|
|
||||||
|
// 빈 껍데기 [image] / [pdf] 제거.
|
||||||
|
for empty in ["image", "pdf"] {
|
||||||
|
if let Some(t) = doc.get(empty).and_then(Item::as_table) {
|
||||||
|
if t.is_empty() {
|
||||||
|
doc.as_table_mut().remove(empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_image_paddle_to_pdf(doc);
|
||||||
|
}
|
||||||
|
|
||||||
/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용.
|
/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용.
|
||||||
fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange>) {
|
fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange>) {
|
||||||
if from < 2 {
|
if from < 2 {
|
||||||
step_1_to_2(doc, changes);
|
step_1_to_2(doc, changes);
|
||||||
}
|
}
|
||||||
// 미래 step: if from < 3 { step_2_to_3(...) } ...
|
if from < 3 {
|
||||||
|
step_2_to_3(doc, changes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version
|
/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version
|
||||||
@@ -447,6 +588,66 @@ include = [\"*.md\"]
|
|||||||
assert_eq!(again.new_text, outcome.new_text);
|
assert_eq!(again.new_text, outcome.new_text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn changes_after_second_pass(text: &str) -> Vec<MigrationChange> {
|
||||||
|
let mut doc: DocumentMut = text.parse().unwrap();
|
||||||
|
let mut ch = Vec::new();
|
||||||
|
step_2_to_3(&mut doc, &mut ch);
|
||||||
|
ch
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_2_to_3_relocates_media_tables() {
|
||||||
|
let v2 = "\
|
||||||
|
schema_version = 2
|
||||||
|
|
||||||
|
[indexing]
|
||||||
|
max_parallel_extractors = 4
|
||||||
|
watch_filesystem = true
|
||||||
|
|
||||||
|
[chunking]
|
||||||
|
target_tokens = 700
|
||||||
|
|
||||||
|
[image.ocr]
|
||||||
|
enabled = true
|
||||||
|
engine = \"paddle-onnx\"
|
||||||
|
det_model = \"/custom/det.onnx\"
|
||||||
|
|
||||||
|
[image.caption]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[pdf.ocr]
|
||||||
|
enabled = false
|
||||||
|
engine = \"paddle-onnx\"
|
||||||
|
";
|
||||||
|
let mut doc: DocumentMut = v2.parse().unwrap();
|
||||||
|
let mut changes = Vec::new();
|
||||||
|
step_2_to_3(&mut doc, &mut changes);
|
||||||
|
let out = doc.to_string();
|
||||||
|
// 새 위치 존재.
|
||||||
|
assert!(out.contains("[ingest]"), "{out}");
|
||||||
|
assert!(out.contains("max_parallel_extractors = 4"));
|
||||||
|
assert!(out.contains("watch_filesystem = true"));
|
||||||
|
assert!(out.contains("[ingest.chunking]"));
|
||||||
|
assert!(out.contains("target_tokens = 700"));
|
||||||
|
assert!(out.contains("[ingest.image.ocr]"));
|
||||||
|
assert!(out.contains("det_model = \"/custom/det.onnx\""));
|
||||||
|
assert!(out.contains("[ingest.image.caption]"));
|
||||||
|
assert!(out.contains("[ingest.pdf.ocr]"));
|
||||||
|
// 옛 위치 제거.
|
||||||
|
assert!(!out.contains("[indexing]"));
|
||||||
|
assert!(!out.contains("\n[chunking]"));
|
||||||
|
assert!(!out.contains("\n[image.ocr]"));
|
||||||
|
assert!(!out.contains("\n[image.caption]"));
|
||||||
|
assert!(!out.contains("\n[pdf.ocr]"));
|
||||||
|
// pdf paddle 동작 보존: image paddle det_model 이 pdf 대칭 키로 복사.
|
||||||
|
let reparsed: DocumentMut = out.parse().unwrap();
|
||||||
|
let pdf_det = reparsed["ingest"]["pdf"]["ocr"].get("det_model");
|
||||||
|
assert_eq!(pdf_det.and_then(|v| v.as_str()), Some("/custom/det.onnx"));
|
||||||
|
// 멱등.
|
||||||
|
let again = changes_after_second_pass(&out);
|
||||||
|
assert!(again.is_empty(), "not idempotent: {again:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migrate_document_missing_schema_version_treated_as_v1() {
|
fn migrate_document_missing_schema_version_treated_as_v1() {
|
||||||
let old = "[workspace]\nroot = \"/n\"\n";
|
let old = "[workspace]\nroot = \"/n\"\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user