docs: v0.20 image+pdf handoff + sub-item 3 spec/plan backfill #188
@@ -0,0 +1,540 @@
|
||||
---
|
||||
title: v0.20.0 — Image + PDF normalize integration handoff
|
||||
created: 2026-05-26
|
||||
status: ready-for-spec
|
||||
target_version: 0.20.0
|
||||
related_specs:
|
||||
- docs/superpowers/specs/2026-05-26-normalize-absorption-spec.md (sub-item 2, §11 future-work)
|
||||
- docs/superpowers/specs/2026-05-26-extractor-dispatch-unification-spec.md (sub-item 3, §11 future-work)
|
||||
- docs/superpowers/specs/2026-04-27-kebab-final-form-design.md (frozen design §3.7b 재작성 후)
|
||||
---
|
||||
|
||||
# v0.20.0 핸드오프 — Image + PDF normalize integration
|
||||
|
||||
본 문서는 새 Claude session 에 v0.20.0 작업 인계하는 self-contained context.
|
||||
PR #185 / #186 / #187 머지 + 도그푸딩 완료 시점의 상태를 그대로 전달.
|
||||
|
||||
---
|
||||
|
||||
## 1. 컨텍스트 요약 (새 session 이 알아야 할 것)
|
||||
|
||||
### 1.1 머지된 v0.19.0 sub-item 3 PR
|
||||
|
||||
| PR | sub-item | scope | 영향 |
|
||||
|----|----------|-------|------|
|
||||
| #185 | 1 | `kebab-source-fs` 의 `kebab-parse-code` dep 제거 — 9 tree-sitter grammars drag 정리. 4 leaf helper → `kebab-source-fs::code_meta` 로 이전 | dep graph 정리. 사용자 visible surface 변경 0. |
|
||||
| #186 | 2 | `kebab-normalize` + `kebab-parse-types` 흡수 → `kebab-parse-md`. 24 → 22 crates. design §3.7b 4-단락 재작성. workspace.version 0.18.0 → 0.19.0 bump | frozen design contract 변경 (`tasks/INDEX.md` "Future work / deferred" 섹션 신설). |
|
||||
| #187 | 3 | Extractor dispatch polymorphism — `App.extractors: Vec<Box<dyn Extractor + Send + Sync>>` registry + `App::extract_for(...)` helper. 11 hardcoded callsite + 9 AST arm → 1 callsite + 4 arm | sub-item 4 의 base. registry 가 v0.20.0 의 모든 dispatch 통합 work 의 시작점. |
|
||||
|
||||
main HEAD = `c1e82cc` (PR #187 머지 후). branch base 는 `main` 에서 분기.
|
||||
|
||||
### 1.2 도그푸딩 결과 (v0.19.0)
|
||||
|
||||
- 통합 KB: 1781 doc / 9050 chunks (기존 1770 + 새 fixture 11). 본 KB 가 v0.20.0 도그푸딩 baseline.
|
||||
- 위치: `/home/altair823/KnowledgeBase/` (user workspace root) + 새 fixture `_dogfood-v0.19.0/`
|
||||
- markdown 1004 + code 7 lang (rust 13 + python 13 + go 10 + java 10 + kotlin 8 + ts 6 + js 5)
|
||||
- image 770 file (기존 KB) — OCR + caption disabled (config default)
|
||||
- pdf 0 file
|
||||
- workspace.version 0.19.0 cascade 적용
|
||||
|
||||
### 1.3 사용자 user memory (필수 follow)
|
||||
|
||||
`~/.claude/projects/-home-altair823-kebab/memory/MEMORY.md` 확인:
|
||||
|
||||
- **PR workflow**: gitea-pr + 리뷰 루프 모드 default (단발 모드 묻지 마)
|
||||
- **Phase priorities**: P8 audio 보류, P9 UI 우선 (책 + PDF 위주)
|
||||
- **LLM default**: gemma4 계열 (gemma4:e4b local + gemma4:26b remote)
|
||||
- **Docs split**: README / HANDOFF / ARCHITECTURE 세 사이블링 동시 갱신 (implementation PR)
|
||||
- **Ranking bias**: 자동 heuristic deferred (1주+ 실사용 후 재 brainstorm)
|
||||
- **Skip user review gates**: brainstorming / writing-plans confirm 단계 skip. 핵심 trade-off 만 AskUserQuestion.
|
||||
- **Serial cargo, -j 4 default**: cargo 동시 background 금지. -j 4 default, -j 8 fast mode, -j 1 OOM fallback only
|
||||
- **No caveman style**: 자연스러운 한국어 산문체
|
||||
- **Teammate model routing**: executor + initial draft + round 1 review = opus, closure verify / micro-patch round = sonnet
|
||||
|
||||
### 1.4 OMC team workflow 패턴
|
||||
|
||||
sub-item 1/2/3 모두 다음 패턴 사용:
|
||||
|
||||
- **Phase A (spec)**: planner (opus) 가 spec drafting → critic (opus) round 1 thorough → critic (sonnet) round 2+ closure verify → APPROVE
|
||||
- **Phase B (plan)**: planner (opus) 가 plan decompose → critic-plan + verifier-plan (opus) round 1 parallel → round 2+ sonnet closure verify → ACCEPT
|
||||
- **Phase C (executor)**: executor (opus) 가 plan step 정확 따라 구현 + 1 clean commit
|
||||
- **Phase D (PR)**: team-lead 가 gitea-pr 생성 + 회차 1 self-review APPROVE
|
||||
|
||||
수렴 실패 감지: round N finding 이 round N+1 closure 도 동일 위치 재등장 시 NEEDS_DISCUSSION.
|
||||
|
||||
### 1.5 빌드 / 도그푸딩 환경
|
||||
|
||||
- working directory: `/home/altair823/kebab`
|
||||
- branch convention: `refactor/<sub-item-name>` 또는 `feat/<feature-name>`
|
||||
- `CARGO_TARGET_DIR=/build/out/cargo-target/target`
|
||||
- release binary: `/build/out/cargo-target/target/release/kebab` (v0.19.0)
|
||||
- dogfood KB: `/home/altair823/KnowledgeBase/` (user XDG default workspace)
|
||||
- merged config (include 확장): `/tmp/kebab-dogfood-merged.toml`
|
||||
- Ollama: `http://localhost:11434` (gemma3:4b + gemma4:e4b local), `http://192.168.0.47:11434` (remote, user config default)
|
||||
- 도그푸딩 fixture: `/home/altair823/KnowledgeBase/_dogfood-v0.19.0/` (4 markdown + 7 code lang)
|
||||
|
||||
---
|
||||
|
||||
## 1.6 현재 구현 상태 (v0.19.0 기준, image + pdf)
|
||||
|
||||
새 session 이 변경 작업 시작 전 reference 로 사용. 모든 file:line 은 main HEAD = `c1e82cc` 기준.
|
||||
|
||||
### 1.6.1 Image — 구현된 기능
|
||||
|
||||
**`crates/kebab-parse-image/src/lib.rs`**:
|
||||
- `pub const PARSER_VERSION: &str = "image-meta-v1";` (line 47)
|
||||
- `pub const MAX_DECODE_DIM: u32 = 16_384;` (line 51)
|
||||
- `pub struct ImageExtractor;` (line 55, unit struct)
|
||||
- `impl ImageExtractor` (line 57-61): `pub fn new() -> Self { Self }`
|
||||
- `impl Default for ImageExtractor` (line 64)
|
||||
- `impl Extractor for ImageExtractor` (line 69-120):
|
||||
- `fn supports(&self, m: &MediaType) -> bool { matches!(m, MediaType::Image(_)) }`
|
||||
- `fn parser_version(&self) -> ParserVersion { ParserVersion("image-meta-v1".to_string()) }`
|
||||
- `fn extract(&self, ctx: &ExtractContext<'_>, bytes: &[u8]) -> Result<CanonicalDocument>`:
|
||||
- `dims::probe(bytes)` — width / height / format 추출
|
||||
- `exif_extract::extract_whitelisted(bytes)` — DateTimeOriginal / Make / Model / GPS 등 화이트리스트 only (privacy)
|
||||
- `SourceSpan::Region { x: 0, y: 0, w: width, h: height }` span
|
||||
- `id_for_doc(&asset.workspace_path, &asset.asset_id, &parser_version)` 으로 doc_id 생성
|
||||
- return 1 image-meta block + (warnings = corrupt EXIF 등)
|
||||
|
||||
**`crates/kebab-parse-image/src/ocr.rs`**:
|
||||
- `pub fn apply_ocr(...)` (line 82): vision LLM 호출로 image → OCR text. doc 에 OCR text block 추가 (mutating helper).
|
||||
- `pub struct OllamaVisionOcr` — Ollama vision endpoint binding. `OllamaVisionOcr::new(&app.config)` 으로 build (lib.rs:340).
|
||||
- `pub trait OcrEngine` — 미래 다른 OCR backend (Tesseract / PaddleOCR) 위한 trait. 현재 `OllamaVisionOcr` 만 impl.
|
||||
|
||||
**`crates/kebab-parse-image/src/caption.rs`**:
|
||||
- `pub fn apply_caption(...)` (line 162): vision LLM 호출로 image → descriptive caption. doc 에 caption block 추가.
|
||||
- `pub fn caption_image(...)` — internal helper.
|
||||
|
||||
**`crates/kebab-parse-image/src/lib.rs` re-exports**:
|
||||
- `pub mod caption;` (line 31)
|
||||
- `pub mod ocr;` (line 32)
|
||||
- `pub use caption::{apply_caption, caption_image};` (line 34)
|
||||
- `pub use ocr::{OcrEngine, OllamaVisionOcr, apply_ocr};` (line 35)
|
||||
|
||||
**`crates/kebab-app/src/lib.rs` (image ingest wiring)**:
|
||||
- `use kebab_parse_image::{OllamaVisionOcr, apply_caption, apply_ocr};` (line 51)
|
||||
- `let ocr_engine: Option<OllamaVisionOcr> = if app.config.image.ocr.enabled { Some(OllamaVisionOcr::new(&app.config)?) } else { None };` (line 338-347)
|
||||
- `let image_pipeline = ImagePipeline { ocr_engine: ocr_engine.as_ref(), caption_llm: caption_llm.as_ref() };` (line 354-361)
|
||||
- `struct ImagePipeline<'a> { ocr_engine: Option<&'a OllamaVisionOcr>, caption_llm: Option<...> }` (line 756-758)
|
||||
- **PR #187 후 ImagePipeline.extractor field 제거됨** (sub-item 3 의 Option c)
|
||||
- `fn ingest_one_image_asset(..., image_pipeline: &ImagePipeline<'_>) -> ...` (line 1227)
|
||||
- ingest flow (line 1283-1336):
|
||||
1. `let ctx = ExtractContext { asset: &asset, ... };` (1283)
|
||||
2. `let mut canonical = app.extract_for(&asset.media_type, &ctx, &bytes)?;` (1296, **PR #187 적용**)
|
||||
3. `if image_pipeline.ocr_engine.is_some() { apply_ocr(&mut canonical, ocr_engine, &bytes)? }` (1308)
|
||||
4. `if image_pipeline.caption_llm.is_some() { apply_caption(&mut canonical, caption_llm, &bytes)? }` (1326)
|
||||
5. chunker (`kebab-chunk::image_meta_v1`) → embedder
|
||||
|
||||
**`crates/kebab-app/src/app.rs:227`**:
|
||||
- `Box::new(ImageExtractor::new()),` — `App.extractors` registry entry 1 of 11 (PR #187 의 polymorphic dispatch).
|
||||
|
||||
**`crates/kebab-config/src/lib.rs` (image config)**:
|
||||
- `image.ocr.enabled` (default `false`, opt-in) — line 1194 assertion
|
||||
- `image.ocr.engine` = `"ollama-vision"` (default) — line 1195
|
||||
- `image.ocr.model` — `gemma4:e4b` (user config default 추정, env override `KEBAB_IMAGE_OCR_MODEL`)
|
||||
- `image.ocr.languages` = `["eng", "kor"]` (default)
|
||||
- `image.ocr.max_pixels` = `1600` (default)
|
||||
- `image.caption.enabled` (default `false`, opt-in) — line 1406
|
||||
- `image.caption.max_pixels` = `768` (default) — line 1407
|
||||
- `image.caption.prompt_template_version` = `"caption-v1"`
|
||||
|
||||
**`crates/kebab-chunk/src/image_meta_v1.rs`** (chunker):
|
||||
- chunker_version = `"image-meta-v1"`
|
||||
- 1 image-meta block + (optional) 1 OCR text block + (optional) 1 caption block 을 chunk 로 변환
|
||||
|
||||
### 1.6.2 PDF — 구현된 기능
|
||||
|
||||
**`crates/kebab-parse-pdf/src/lib.rs`**:
|
||||
- `pub const PARSER_VERSION: &str = "pdf-text-v1";` (line 32)
|
||||
- `pub struct PdfTextExtractor;` (line 37, unit struct)
|
||||
- `impl PdfTextExtractor` (line 39-43): `pub fn new() -> Self { Self }`
|
||||
- `impl Default for PdfTextExtractor` (line 45)
|
||||
- `impl Extractor for PdfTextExtractor` (line 51-200+):
|
||||
- `fn supports(&self, m: &MediaType) -> bool { matches!(m, MediaType::Pdf) }`
|
||||
- `fn parser_version(&self) -> ParserVersion { ParserVersion("pdf-text-v1".to_string()) }`
|
||||
- `fn extract(&self, ctx: &ExtractContext<'_>, bytes: &[u8]) -> Result<CanonicalDocument>`:
|
||||
- `lopdf::Document::load_mem(bytes)?` — catastrophic decode guard (corrupt PDF 거부)
|
||||
- **encrypted PDF 거부**: `anyhow::bail!("encrypted PDF; remove encryption (e.g. qpdf --decrypt) before ingest")`
|
||||
- `info::extract_info(&pdf_doc)` — Title / Author / Subject / Keywords 등 PDF metadata
|
||||
- `pdf_doc.get_pages()` BTreeMap (1-based, deterministic ordering)
|
||||
- per-page text 추출 + per-page `ProvenanceEvent`
|
||||
- return `Vec<Block>` (1 page = 1 block, text-only)
|
||||
|
||||
**`crates/kebab-parse-pdf/src/info.rs`**:
|
||||
- `pub fn extract_info(pdf_doc: &lopdf::Document) -> Map<String, Value>` — PDF metadata 추출 helper.
|
||||
|
||||
**`crates/kebab-app/src/lib.rs` (pdf ingest wiring)**:
|
||||
- `use kebab_parse_pdf::PdfTextExtractor;` (line 54)
|
||||
- `fn ingest_one_pdf_asset(...)` (line ~1699)
|
||||
- ingest flow (line 1777-1850):
|
||||
1. `let mut canonical = app.extract_for(&asset.media_type, &ctx, &bytes)?;` (1783, **PR #187 적용**)
|
||||
2. chunker (`kebab-chunk::pdf_page_v1`) → embedder
|
||||
- **PDF 의 OCR / caption / image extract / table extract 모두 미구현** (TODO #1, #2, #4 의 대상)
|
||||
|
||||
**`crates/kebab-app/src/app.rs:228`**:
|
||||
- `Box::new(PdfTextExtractor::new()),` — `App.extractors` registry entry 2 of 11 (PR #187 의 polymorphic dispatch).
|
||||
|
||||
**`crates/kebab-chunk/src/pdf_page_v1.rs`** (chunker):
|
||||
- chunker_version = `"pdf-page-v1"` (verified line 303 의 `ParserVersion("pdf-text-v1".into())` 매칭)
|
||||
- per-page chunk (1 page = 1 chunk, large page 는 token budget 으로 분할 가능)
|
||||
|
||||
### 1.6.3 Image + PDF 의 polymorphic dispatch (PR #187 적용)
|
||||
|
||||
**`crates/kebab-app/src/app.rs` (registry init)**:
|
||||
```rust
|
||||
// crates/kebab-app/src/app.rs:225-240 (Step 3 of PR #187 plan)
|
||||
let extractors: Vec<Box<dyn Extractor + Send + Sync>> = vec![
|
||||
Box::new(ImageExtractor::new()), // entry 1
|
||||
Box::new(PdfTextExtractor::new()), // entry 2
|
||||
Box::new(RustAstExtractor::new()), // entry 3
|
||||
Box::new(PythonAstExtractor::new()), // entry 4
|
||||
Box::new(TypescriptAstExtractor::new()), // entry 5
|
||||
Box::new(JavascriptAstExtractor::new()), // entry 6
|
||||
Box::new(GoAstExtractor::new()), // entry 7
|
||||
Box::new(JavaAstExtractor::new()), // entry 8
|
||||
Box::new(KotlinAstExtractor::new()), // entry 9
|
||||
Box::new(CAstExtractor::new()), // entry 10
|
||||
Box::new(CppAstExtractor::new()), // entry 11
|
||||
];
|
||||
```
|
||||
|
||||
**`crates/kebab-app/src/app.rs` (`App.extract_for` helper)**:
|
||||
```rust
|
||||
// pub(crate) fn extract_for(...)
|
||||
pub(crate) fn extract_for(
|
||||
&self,
|
||||
media: &MediaType,
|
||||
ctx: &ExtractContext<'_>,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<CanonicalDocument> {
|
||||
self.extractors
|
||||
.iter()
|
||||
.find(|e| e.supports(media))
|
||||
.ok_or_else(|| anyhow::anyhow!("no matching extractor for media {:?}", media))?
|
||||
.extract(ctx, bytes)
|
||||
}
|
||||
```
|
||||
|
||||
**`crates/kebab-app/src/app.rs` (in-crate unit test, mod tests_extractor_dispatch)**:
|
||||
- registry length = 11 test
|
||||
- mutually-exclusive supports() grid 16 sample test
|
||||
- extract_for "no matching extractor" error path (Audio(Wav) MediaType)
|
||||
|
||||
### 1.6.4 Image + PDF 의 wire schema (현재 v0.19.0)
|
||||
|
||||
- `ingest_progress.v1`: `media` field 가 `"image"` / `"pdf"` 명시. `asset_started` / `asset_finished` event 가 chunks count 명시.
|
||||
- `ingest_report.v1.IngestItem`: `parser_version` (`"image-meta-v1"` / `"pdf-text-v1"`), `chunker_version` (`"image-meta-v1"` / `"pdf-page-v1"`), `block_count`, `chunk_count`, `warnings` 모두 carry.
|
||||
- `search_response.v1.SearchHit.chunker_version`: 정확 dispatch (`"image-meta-v1"` / `"pdf-page-v1"`).
|
||||
- `citation.v1.citation`: `kind: "image"` / `"pdf"`, `path`, `line_start`/`line_end` (PDF 의 경우 page 번호).
|
||||
- `chunk_inspection.v1.canonical_document`: `parser_version`, `last_chunker_version`, `last_embedding_version`, `blocks`, `provenance.events` 모두 dump.
|
||||
|
||||
### 1.6.5 MediaType / dispatch boundary
|
||||
|
||||
**`crates/kebab-core/src/media.rs`**:
|
||||
- `pub enum MediaType { Markdown, Pdf, Image(ImageType), Audio(AudioType), Code(String), Other }`
|
||||
- `ImageType` variant: `Jpeg / Png / Webp / Heic / Gif / Bmp / Tiff / Svg` 등 (실 enum 확인 필요)
|
||||
- `AudioType` — P8 deferred, 현재 production caller 0
|
||||
|
||||
**`crates/kebab-source-fs/src/media.rs`** (file extension → MediaType detect):
|
||||
- `image_extensions` (jpg / jpeg / png / webp / heic / heif / gif / bmp / tiff / svg)
|
||||
- `pdf_extension` (pdf only)
|
||||
- 다른 extension 은 markdown / code / other 로 dispatch
|
||||
|
||||
### 1.6.6 Dead struct 3 — future surface (PR #186 머지 후 보존)
|
||||
|
||||
**`crates/kebab-parse-md/src/types.rs`** (sub-item 2 의 absorption 후):
|
||||
- `pub struct ParsedImageRegion;` (line ~85, **production caller 0** — future surface for TODO #2 multi-region)
|
||||
- `pub struct ParsedPdfPage { ... }` (line ~88, **production caller 0** — future surface for TODO #3 PDF normalize integration)
|
||||
- `pub struct ParsedAudioSegment { ... }` (line ~94, **production caller 0** — future surface for P8 audio parser)
|
||||
|
||||
세 struct 의 정의는 모두 `kebab-parse-md` 안에 정확히 보존됨. v0.20.0 의 TODO #2 / #3 에서 첫 production caller 등장 시 spec § 11 의 raison d'être 재활성화.
|
||||
|
||||
---
|
||||
|
||||
## 2. v0.20.0 의 TODO (우선순위 순)
|
||||
|
||||
### 2.1 TODO #1 — PDF scanned OCR path (HIGHEST PRIORITY — 사용자 P9 의 책+PDF 위주 정합)
|
||||
|
||||
**문제**: 현재 `PdfTextExtractor` (`crates/kebab-parse-pdf/src/lib.rs`) 는 lopdf 기반 **text-only** — embedded text 없는 scanned PDF (book scan / 논문 scan / 영수증 scan) 는 빈 chunk → search 결과 0.
|
||||
|
||||
**Scope**:
|
||||
- Scanned PDF detect: per-page text 추출량이 threshold (e.g. 50 char / page) 미만이면 scanned 로 판정
|
||||
- Render scanned page → image (pdfium-render 또는 mupdf-rs 등 의존성 검토)
|
||||
- Render 된 image 를 `OllamaVisionOcr` 으로 OCR → text block
|
||||
- pdf-page-v1 chunker 가 OCR text 도 cover
|
||||
|
||||
**Affected files**:
|
||||
- `crates/kebab-parse-pdf/src/lib.rs` — PdfTextExtractor 의 scanned detect logic
|
||||
- `crates/kebab-parse-pdf/Cargo.toml` — pdfium-render 또는 mupdf 의존성 추가
|
||||
- `crates/kebab-parse-pdf/src/scanned_ocr.rs` (신규) — render + OCR pipeline
|
||||
- `crates/kebab-app/src/lib.rs:1696-1850` — ingest_one_pdf_asset 의 scanned-path 분기 또는 PdfTextExtractor 내부 통합
|
||||
- config: `pdf.ocr.enabled` + `pdf.ocr.engine` (image.ocr 와 sibling)
|
||||
|
||||
**Risk**: PDF rendering 의존성 (pdfium 등) 의 binary size + cross-platform.
|
||||
|
||||
**Trigger 조건**: 책 / 논문 PDF 가 indexed 됐는데 search 결과 0 인 경우 (사용자 dogfood 시 실측).
|
||||
|
||||
### 2.2 TODO #2 — Multi-region image dispatch (medium priority)
|
||||
|
||||
**문제**: 현재 ImageExtractor 는 한 이미지 = 1 image-meta block + (optional) 1 OCR text block + (optional) 1 caption block. 이미지 안의 multi-region (다른 text bbox / face bbox / figure bbox 등) 분리 0.
|
||||
|
||||
**Scope**:
|
||||
- `ParsedImageRegion` struct (이미 `crates/kebab-parse-md/src/types.rs:85` 정의됨, production caller 0)
|
||||
- `ImageExtractor::extract` 가 `Vec<ParsedImageRegion>` emit 으로 변경 (multi-region detect)
|
||||
- region 분리 source: OCR bounding box (Tesseract 또는 PaddleOCR layout detection)
|
||||
- `kebab-parse-md::build_canonical_document_from_image_regions(...)` lift 신설
|
||||
- region-별 chunk → search granularity 향상
|
||||
|
||||
**Affected files**:
|
||||
- `crates/kebab-parse-image/src/lib.rs` — ImageExtractor 의 emit 변경
|
||||
- `crates/kebab-parse-image/src/regions.rs` (신규) — multi-region detection logic
|
||||
- `crates/kebab-parse-md/src/normalize.rs` — `build_canonical_document_from_image_regions` 신설
|
||||
- `crates/kebab-chunk/src/image_region_v1.rs` (신규) — region-별 chunker
|
||||
- `crates/kebab-app/src/lib.rs:1209-1336` — ingest_one_image_asset 변경
|
||||
|
||||
**Risk**: region detection 의 false positive (overlap region 다중 detection, chunker dedup 필요).
|
||||
|
||||
**Trigger 조건**: image multi-region 사용 사례 (예: 명함 scan 의 name region + email region 분리 search).
|
||||
|
||||
### 2.3 TODO #3 — PDF normalize integration (medium priority)
|
||||
|
||||
**문제**: 현재 `PdfTextExtractor` 가 CanonicalDocument 직접 emit (normalize 우회). cross-page reference + page-level metadata 의 doc-level aggregation 어려움.
|
||||
|
||||
**Scope**:
|
||||
- `ParsedPdfPage` struct (이미 `crates/kebab-parse-md/src/types.rs:88` 정의됨, production caller 0)
|
||||
- `PdfTextExtractor::extract` 가 `Vec<ParsedPdfPage>` emit 으로 변경
|
||||
- `kebab-parse-md::build_canonical_document_from_pdf_pages(...)` lift 신설 — page-별 provenance + cross-page reference graph + page-level doc-summary
|
||||
- `pdf-page-v1` chunker 가 page-level metadata 도 cover
|
||||
|
||||
**Affected files**:
|
||||
- `crates/kebab-parse-pdf/src/lib.rs` — PdfTextExtractor emit 변경
|
||||
- `crates/kebab-parse-md/src/normalize.rs` — `build_canonical_document_from_pdf_pages` 신설
|
||||
- `crates/kebab-chunk/src/pdf_page_v1.rs` — chunker 의 multi-page handling
|
||||
- `crates/kebab-app/src/lib.rs:1696-1850` — ingest_one_pdf_asset 의 lift path
|
||||
|
||||
**Risk**: cross-page reference graph 의 complexity (page N → page M 의 figure 참조). false positive 시 doc-summary 가 spurious entry 가짐.
|
||||
|
||||
**Trigger 조건**: PDF cross-page navigation (e.g. "page 7 의 Figure 3 이 page 12 에서 설명됨") 의 search/ask 필요 시.
|
||||
|
||||
### 2.4 TODO #4 — Per-page image / table extraction (low-medium priority)
|
||||
|
||||
**문제**: PDF 안의 embedded image / table 추출 0. text-only flow.
|
||||
|
||||
**Scope**:
|
||||
- PDF page 안의 figure / table extract (lopdf 의 image stream 추출 + table detect)
|
||||
- Figure: image block 으로 변환 → `ParsedImageRegion` (TODO #2 의존)
|
||||
- Table: structured Block (예: markdown table 또는 CSV-like) 으로 변환
|
||||
- `pdf-table-v1` chunker (신규)
|
||||
|
||||
**Affected files**:
|
||||
- `crates/kebab-parse-pdf/src/figure.rs` + `table.rs` (신규)
|
||||
- `crates/kebab-chunk/src/pdf_table_v1.rs` (신규)
|
||||
|
||||
**Risk**: PDF figure / table 의 다양한 encoding (vector vs raster, table 의 cell merging) — robust detection 어려움.
|
||||
|
||||
**Trigger 조건**: 논문 / 보고서 PDF 의 figure / table search 필요 (현재 사용자 use case 우선순위 P9 의 책 PDF 와 정합).
|
||||
|
||||
### 2.5 TODO #5 — OCR / caption 의 Extractor trait 통합 (low priority)
|
||||
|
||||
**문제**: 현재 `apply_ocr` / `apply_caption` (`crates/kebab-parse-image/src/{ocr,caption}.rs`) 가 별 free function. Extractor trait 미사용. `app.extract_for(image, ...)` 가 base ImageExtractor 만 호출 + OCR/caption 은 callsite (`ingest_one_image_asset`) 후처리.
|
||||
|
||||
**Scope (옵션 A)**: OCR / caption 을 별 `Enricher` trait 으로 모델링. `App.enrichers: Vec<Box<dyn Enricher + Send + Sync>>` registry 신설. `app.enrich_for(doc, &media_type, &bytes)` polymorphic call.
|
||||
|
||||
**Scope (옵션 B)**: OCR / caption 도 Extractor 로 통합 — `OcrEnricherExtractor` + `CaptionEnricherExtractor` 가 Extractor::supports() 별 dispatch + `Extractor::extract()` 가 doc 의 일부만 변경. 단 spec contract violation 가능.
|
||||
|
||||
**Affected files**:
|
||||
- `crates/kebab-parse-image/src/ocr.rs` + `caption.rs` — Enricher trait impl 추가
|
||||
- `crates/kebab-core/src/traits.rs` — Enricher trait 정의 (옵션 A)
|
||||
- `crates/kebab-app/src/app.rs` — `enrichers` field + `enrich_for` helper
|
||||
- `crates/kebab-app/src/lib.rs:1209-1336` — ingest_one_image_asset 의 enrichment dispatch
|
||||
|
||||
**Risk**: trait 신설 = design §7.2 갱신 동반 → frozen contract 변경 → workspace.version bump trigger.
|
||||
|
||||
**Trigger 조건**: 새 enrichment 추가 시 (e.g. embedding-based image classification, depth estimation, OCR + translate)에 polymorphic registry 가 절실해질 때.
|
||||
|
||||
### 2.6 TODO #6 — MarkdownExtractor 신설 (low priority, sub-item 3 §11 future-work)
|
||||
|
||||
**문제**: 현재 markdown 만 free function path (`parse_frontmatter` + `parse_blocks` + `build_canonical_document`). 다른 4 parser (pdf/image/code) 는 Extractor impl 보유. markdown 만 outer 4-arm match 의 markdown arm 에서 free function 호출.
|
||||
|
||||
**Scope**:
|
||||
- `kebab-parse-md::MarkdownExtractor` struct + Extractor trait impl
|
||||
- `supports(MediaType::Markdown)`
|
||||
- `extract(ctx, bytes) -> CanonicalDocument` 내부에서 `parse_frontmatter` + `parse_blocks` + `build_canonical_document` 통합 호출
|
||||
- `App.extractors` registry 의 12 entry (markdown 추가) — `extract_for` 가 markdown 도 dispatch
|
||||
|
||||
**Challenge** (sub-item 3 의 round 1 critic 발견): markdown path 의 warning channel handover. 현재 `parse_frontmatter` + `parse_blocks` 의 `Vec<Warning>` 이 `ingest_one_markdown_asset` 안 별도 snap → `IngestItem.warnings` (wire `ingest_report.v1`) 로 흐름. `MarkdownExtractor::extract(&ctx, &bytes) -> Result<CanonicalDocument>` signature 가 별 channel 부재 → wire schema 영향 가능.
|
||||
|
||||
**Affected files**:
|
||||
- `crates/kebab-parse-md/src/extractor.rs` (신규)
|
||||
- `crates/kebab-parse-md/src/lib.rs` — `pub mod extractor;` + `pub use crate::extractor::MarkdownExtractor;`
|
||||
- `crates/kebab-app/src/app.rs:225-240` — registry entry 추가
|
||||
- `crates/kebab-app/src/lib.rs:1083-1170` — markdown ingest path 의 helper signature 변경 폭 정확 measurement
|
||||
|
||||
**Risk**: wire schema (`ingest_report.v1.IngestItem.warnings`) 변경 위험. wire schema additive minor bump v1.x 또는 trait signature 변경 v2.
|
||||
|
||||
**Trigger 조건**: markdown 의 polymorphic dispatch 가 절실해지는 use case (예: 새 markdown variant — kramdown / CommonMark + extensions).
|
||||
|
||||
### 2.7 TODO #7 — Chunker dispatch unification (low priority, sub-item 3 §11 future-work)
|
||||
|
||||
**문제**: 현재 `Chunker` trait 에 `supports()` method 없음. `kebab-app/src/lib.rs` 의 chunker dispatch (parser_version 11-arm + chunker_version 11-arm + tier3_fallback_cv 2-arm + chunk dispatch 13-arm) 가 inner match.
|
||||
|
||||
**Scope**:
|
||||
- `Chunker` trait 에 `supports(media: &MediaType) -> bool` 또는 `supports(chunker_version: &str) -> bool` method 추가
|
||||
- `App.chunkers: Vec<Box<dyn Chunker + Send + Sync>>` registry
|
||||
- `app.chunk_for(...)` polymorphic call
|
||||
- inner 4-arm match 제거
|
||||
|
||||
**Affected files**:
|
||||
- `crates/kebab-core/src/traits.rs` — Chunker trait 의 method 추가
|
||||
- 모든 Chunker impl (15곳, `kebab-chunk` 안 md-heading / pdf-page / 9 code-ast / code-text-paragraph / manifest-file / dockerfile-file / k8s-manifest-resource) 의 supports() 추가
|
||||
- `crates/kebab-app/src/app.rs` — chunkers field + chunk_for helper
|
||||
- `crates/kebab-app/src/lib.rs:1935-2128` — 4 inner match 제거
|
||||
|
||||
**Risk**: design §7.2 갱신 동반 (frozen contract 변경, workspace.version bump).
|
||||
|
||||
**Trigger 조건**: 새 chunker variant 추가 시 (e.g. semantic chunker, sentence-level chunker) 의 polymorphic registry 가 절실해질 때.
|
||||
|
||||
### 2.8 TODO #8 — outer 4-arm match 통합 (low priority, sub-item 3 §11)
|
||||
|
||||
**문제**: `ingest_one_asset` (lib.rs:961-1040) 의 `match &asset.media_type` 4-arm (markdown / pdf / image / code) 이 hardcoded helper 호출 (`ingest_one_markdown_asset` / `ingest_one_pdf_asset` / `ingest_one_image_asset` / `ingest_one_code_asset`).
|
||||
|
||||
**Scope**:
|
||||
- 각 medium 별 helper 의 unified signature 도입 — `IngestEnv` 같은 context object 로 helper 의 signature 통일
|
||||
- `app.ingest_one(...)` polymorphic call
|
||||
|
||||
**Risk**: helper 별 다른 enrichment (image 의 OCR/caption + pdf 의 lopdf + code 의 lang detect) 의 통합 어려움 → 큰 refactor.
|
||||
|
||||
**Trigger 조건**: 새 medium 추가 시 (e.g. audio P8 또는 video P+) 의 통합 dispatch 가 절실해질 때.
|
||||
|
||||
---
|
||||
|
||||
## 3. 우선순위 + Sequencing 권장
|
||||
|
||||
| 순서 | TODO | 효과 | 의존성 |
|
||||
|------|------|------|--------|
|
||||
| 1 | TODO #1 (PDF scanned OCR) | 사용자 P9 책+PDF use case 직접 unblock | OllamaVisionOcr (이미 구현됨) |
|
||||
| 2 | TODO #3 (PDF normalize integration) | cross-page reference + doc-summary 가능 | TODO #1 와 분리 가능 |
|
||||
| 3 | TODO #2 (multi-region image) | image search granularity 향상 | OCR bbox detection 필요 |
|
||||
| 4 | TODO #4 (PDF figure/table) | 논문/보고서 PDF 의 figure/table search | TODO #2 + #3 |
|
||||
| 5 | TODO #5 (OCR/caption Enricher trait) | architecture cleanup | TODO #1, #2 의 enrichment 가 stable 후 |
|
||||
| 6 | TODO #6 (MarkdownExtractor 신설) | dispatch unification | wire schema 변경 필요 |
|
||||
| 7 | TODO #7 (Chunker dispatch) | dispatch unification | design §7.2 갱신 |
|
||||
| 8 | TODO #8 (outer 4-arm 통합) | full polymorphic dispatch | TODO #6 + #7 |
|
||||
|
||||
**권장**: TODO #1 + #3 을 v0.20.0 의 첫 두 sub-item 으로 시작 (사용자 use case 직접 unblock). TODO #2 + #4 는 v0.20.0 의 sub-item 3, 4. TODO #5 ~ #8 은 v0.21+ defer (sub-item 3 의 §11 future-work 에서 이미 별 PR defer 명시).
|
||||
|
||||
---
|
||||
|
||||
## 4. 새 session 의 첫 단계 (제안)
|
||||
|
||||
1. **상태 확인**:
|
||||
- `git status` + `git log --oneline -5` 확인 (HEAD = c1e82cc 또는 그 이후)
|
||||
- `cat ~/.claude/projects/-home-altair823-kebab/memory/MEMORY.md`
|
||||
- `cat docs/superpowers/handoffs/2026-05-26-v0.20-image-pdf-normalize-handoff.md` (본 문서)
|
||||
|
||||
2. **사용자에게 첫 sub-item 선택 확인**:
|
||||
- "TODO #1 (PDF scanned OCR) 부터 시작?" — 가장 사용자 use case 직접
|
||||
- 또는 "다른 우선순위?"
|
||||
|
||||
3. **선택된 TODO 의 spec drafting**:
|
||||
- 새 team 생성 (`gitea-pr` workflow 의 branch convention 따라)
|
||||
- planner (opus) spawn
|
||||
- sub-item 1/2/3 의 spec/plan 패턴 그대로
|
||||
- critic round 1 = opus, round 2+ = sonnet (model routing 정책)
|
||||
|
||||
4. **spec → plan → executor → PR + 리뷰 루프** 진행 (이전 sub-item 패턴).
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 파일 / 자료 / 의존성 위치
|
||||
|
||||
### Codebase 위치 (변경 대상)
|
||||
|
||||
- `crates/kebab-parse-image/src/lib.rs` — ImageExtractor (line 55)
|
||||
- `crates/kebab-parse-image/src/ocr.rs` — OllamaVisionOcr + apply_ocr
|
||||
- `crates/kebab-parse-image/src/caption.rs` — apply_caption
|
||||
- `crates/kebab-parse-pdf/src/lib.rs` — PdfTextExtractor (line 37)
|
||||
- `crates/kebab-parse-md/src/types.rs` — ParsedImageRegion + ParsedPdfPage + ParsedAudioSegment (dead struct 3, future surface)
|
||||
- `crates/kebab-parse-md/src/normalize.rs` — build_canonical_document (line 60)
|
||||
- `crates/kebab-core/src/traits.rs` — Extractor + Chunker trait (line 115-132)
|
||||
- `crates/kebab-app/src/app.rs:225-240` — App.extractors registry (sub-item 3 의 11 entry)
|
||||
- `crates/kebab-app/src/lib.rs:961-1040` — ingest_one_asset outer 4-arm match
|
||||
- `crates/kebab-app/src/lib.rs:1209-1336` — ingest_one_image_asset (OCR / caption flow)
|
||||
- `crates/kebab-app/src/lib.rs:1696-1850` — ingest_one_pdf_asset
|
||||
- `crates/kebab-chunk/src/pdf_page_v1.rs` — PDF chunker
|
||||
- `crates/kebab-config/src/lib.rs:861-1430` — image.ocr / image.caption config
|
||||
|
||||
### Spec / Plan / Doc 위치 (참조 대상)
|
||||
|
||||
- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` — frozen design contract (§3.7b 4-단락 재작성, §7.2 trait, §8 dep graph)
|
||||
- `docs/superpowers/specs/2026-05-26-normalize-absorption-spec.md` — sub-item 2 의 §11 future-work
|
||||
- `docs/superpowers/specs/2026-05-26-extractor-dispatch-unification-spec.md` — sub-item 3 의 §11 future-work
|
||||
- `docs/superpowers/plans/2026-05-26-*-plan.md` — 3 sub-item 의 plan
|
||||
- `tasks/INDEX.md` — "Future work / deferred" 섹션 (sub-item 2 머지 시 신설)
|
||||
- `tasks/HOTFIXES.md` — design deviation entry (sub-item 2)
|
||||
- `docs/SMOKE.md` — 도그푸딩 절차
|
||||
- `docs/ARCHITECTURE.md` — crate dependency graph + locked-in decisions
|
||||
- `HANDOFF.md` — phase-level progress dashboard
|
||||
- `README.md` — end-user facing surface
|
||||
|
||||
### 외부 의존성 후보 (TODO #1 의 PDF rendering)
|
||||
|
||||
- `pdfium-render` crate — Chrome PDFium binding, robust
|
||||
- `mupdf` crate — MuPDF binding, lighter
|
||||
- `lopdf` (이미 사용) 의 image stream 추출 (TODO #4)
|
||||
|
||||
---
|
||||
|
||||
## 6. 모델 routing 정책 (사용자 결정 적용)
|
||||
|
||||
새 session 의 모든 teammate spawn 시:
|
||||
|
||||
| Phase | Role | Model |
|
||||
|-------|------|-------|
|
||||
| Phase A | planner (spec drafter) | opus |
|
||||
| Phase A | critic round 1 (thorough) | opus |
|
||||
| Phase A | critic round 2+ (closure verify) | sonnet |
|
||||
| Phase B | planner (plan decompose) | opus |
|
||||
| Phase B | critic-plan + verifier-plan round 1 | opus |
|
||||
| Phase B | critic-plan + verifier-plan round 2+ | sonnet |
|
||||
| Phase C | executor | opus |
|
||||
| Phase D | team-lead (self) | — |
|
||||
|
||||
memory `feedback_teammate_model_routing` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 7. release 계획
|
||||
|
||||
- 현 v0.19.0 binary 가 main HEAD c1e82cc 기반 (PR #185 + #186 + #187 머지 후). 도그푸딩 완료.
|
||||
- `gitea-release v0.19.0` 진행 권장 (sub-item 1/2/3 통합) — 사용자가 release 명시 안 했으면 본 session 의 v0.20.0 sub-item 시작 전에 release tag 컷.
|
||||
- v0.20.0 의 TODO #1, #3 머지 후 다음 release 검토.
|
||||
|
||||
---
|
||||
|
||||
## 8. 검증 invariant (모든 v0.20.0 sub-item 의 acceptance)
|
||||
|
||||
- workspace test 회귀 0 (baseline = 1316 + 새 sub-item 의 신규 test 만큼 + delta)
|
||||
- wire schema 변경 0 또는 minor additive bump (`*.v1`)
|
||||
- design contract 변경 시 frozen task spec 모두 갱신 또는 HOTFIXES live source 추가
|
||||
- v0.20.0 minor bump (frozen design contract 변경 시) 또는 patch bump (refactor only)
|
||||
- 도그푸딩 KB 의 byte-identical 결과 보존 (success path)
|
||||
- `cargo clippy --workspace --all-targets -- -D warnings` clean
|
||||
- `cargo build --release -p kebab-cli -j 4` clean
|
||||
|
||||
---
|
||||
|
||||
## 9. 본 handoff 문서의 위치 + 의도
|
||||
|
||||
`docs/superpowers/handoffs/2026-05-26-v0.20-image-pdf-normalize-handoff.md`
|
||||
|
||||
새 Claude session 이 본 file 만 read 해도 sub-item 시작 가능. 이 session 의 모든 context (sub-item 1/2/3 결과 + 도그푸딩 + user memory + OMC workflow) 가 self-contained.
|
||||
|
||||
새 session 시작 시:
|
||||
|
||||
```bash
|
||||
cat /home/altair823/kebab/docs/superpowers/handoffs/2026-05-26-v0.20-image-pdf-normalize-handoff.md
|
||||
```
|
||||
|
||||
후 사용자에게 첫 sub-item 우선순위 확인.
|
||||
@@ -0,0 +1,935 @@
|
||||
---
|
||||
status: open
|
||||
target_version: 0.18.0
|
||||
spec: docs/superpowers/specs/2026-05-26-extractor-dispatch-unification-spec.md
|
||||
contract_sections: []
|
||||
related_specs:
|
||||
- docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
- docs/superpowers/specs/2026-05-26-source-fs-dep-lightening-spec.md
|
||||
- docs/superpowers/specs/2026-05-26-normalize-absorption-spec.md
|
||||
sibling_plans:
|
||||
- docs/superpowers/plans/2026-05-26-source-fs-dep-lightening-plan.md # PR #185 merged
|
||||
- docs/superpowers/plans/2026-05-26-normalize-absorption-plan.md # PR #186 merged
|
||||
---
|
||||
|
||||
# Extractor dispatch unification — implementation plan (v3 — reflection round 2)
|
||||
|
||||
> plan round 1 의 16 finding (1 CRITICAL + 5 MAJOR + 6 MINOR + 1 NIT + 4 Missing + 1 Ambiguity) 흡수. spec §5.5 + §7 의 error path wire-scope risk acceptance 동반 갱신. scope = AST 9-arm extract dispatch + image/pdf extract callsite. 11 step, atomic block 1 (Step 4-5-6) + mutually independent (Step 6/7/8). v2 의 7 step plan rewrite 가 instruction 의 fine-grained sequencing 정합 — round 1 finding 의 actual codebase grep 결과로 다수 정정.
|
||||
|
||||
## §0 Pre-flight + branch state
|
||||
|
||||
- **Branch**: `refactor/extractor-dispatch-unification` (현재 위치).
|
||||
- **Base SHA**: `9676640` (PR #186 sibling — normalize-absorption 머지 직후, v0.18.0 cut 시점).
|
||||
- **Working dir**: `/home/altair823/kebab`.
|
||||
- **Env 강제** (`~/.claude/CLAUDE.md` 의 "Disk Layout — 루트 디스크 보호가 최우선" 룰):
|
||||
- `export CARGO_TARGET_DIR=/build/out/cargo-target/target` — 본 plan 의 모든 cargo 명령 적용. repo root 의 `target/` 생성 방지 (16 GiB RAM 머신의 `/` 250 G 보호).
|
||||
- `export TMPDIR=/build/cache/tmp` — 대용량 임시 파일 발생 시 보호.
|
||||
- **Cargo build 직렬화** (MEMORY.md `feedback_serial_build_only.md` — 사용자 결정 2026-05-26):
|
||||
- **per-crate cargo**: `-j 4` default (예: `cargo build -p kebab-app -j 4`).
|
||||
- **full workspace** (`cargo test --workspace`, `cargo clippy --workspace`): `-j 1` 강제. 18 integration-test binary 동시 link 시 OOM (linker SIGKILL).
|
||||
- cargo test / clippy / build 동시 background 실행 금지. 직렬 진행.
|
||||
- **`target/` clean policy**: full workspace test 직전 `cargo clean` 1회 (Step 11). 중간 step (Step 2-10) 은 per-crate incremental build — `cargo clean` 불필요.
|
||||
- **HOTFIXES.md / HANDOFF.md / README.md / docs/ARCHITECTURE.md 변경 0** (spec §7 + §1.9 verified).
|
||||
- **21+ frozen task spec 변경 0** (spec §1.8 verified).
|
||||
- **wire schema 변경 0 — success path** (spec §5.5). **error path = `error.v1.message` 의 internal context wording 변경 가능** — `error.v1.code` + `error.v1.schema_version` 보존 (spec §5.5 의 risk acceptance + §7 row 동반 갱신).
|
||||
- **workspace `Cargo.toml` version bump 0** (`target_version: 0.18.0` 유지, CLAUDE.md §Release 룰 3 트리거 미충족).
|
||||
- **design contract 변경 0** (`contract_sections: []`).
|
||||
- **doc-test 포함 여부 (MINOR #2 fix)**: Step 1 + Step 11 의 baseline / after awk sum 이 doc-test (예: `running 0 tests` 의 doc-test result 라인) 도 cover. doc-test 가 0 이면 sum 영향 0; 가산 결과는 baseline + after 모두에 동일 가산되어 delta 보존.
|
||||
|
||||
## §1 Approach summary
|
||||
|
||||
Spec §3 의 결정을 단계별 atomic step 으로 decompose. destination = `App` field (Option A, spec §3.1). 핵심 sequencing:
|
||||
|
||||
1. **Pre-flight + 무변경 baseline** (Step 1) — 측정 only.
|
||||
2. **Registry surface 부터 build-up** (Step 2-3) — `App.extractors` field + `App::extract_for` helper 신설 (Step 2: struct/method shape + placeholder init) → `App::open_with_config` 의 11-entry init (Step 3: replace placeholder + lib.rs:1235 explicit cleanup). 두 step 합쳐 helper 가 사용 가능 상태 — callsite migration 의 전제 조건.
|
||||
3. **image dispatch migration** (Step 4-6, logical atomic block) — local 제거 (Step 4) → ImagePipeline 갱신 (Step 5) → dispatch callsite 교체 (Step 6). 세 step 의 intermediate state 에서 build red 가능, Step 6 후 build green.
|
||||
4. **pdf dispatch migration** (Step 7) — 단일 callsite 교체. 가장 작은 atomic step.
|
||||
5. **code AST 9-arm hoist** (Step 8) — 가장 risk 큰 step. **12 arm (11 explicit + 1 wildcard)** → **4 arm** (9-AST-group + manifest-group + shell + wildcard) [round 1 MINOR GAP #5 정정].
|
||||
6. **dead code 정리** (Step 9) — Step 4-8 의 결과로 사용 안 되는 use statement / 임시 `#[allow(dead_code)]` 정리.
|
||||
7. **unit tests 추가** (Step 10) — spec §5.1 의 3 test class. **in-crate `#[cfg(test)] mod tests` in app.rs** (round 1 CRITICAL #1 — `pub(crate)` access).
|
||||
8. **workspace 회귀 + clean commit** (Step 11) — 7 cargo gate + 4 wire diff + 3 callsite-count verify + numeric delta gate + single commit.
|
||||
|
||||
ordering 의 핵심 invariant:
|
||||
|
||||
- **Step 2-3 < Step 4-8**: registry + helper 가 사용 가능한 후에 callsite 교체. Step 2 후 build green (additive — placeholder), Step 3 후 build green (real init + lib.rs:1235 cleanup).
|
||||
- **Step 4-6 는 logical atomic — single commit 단위**: 중간 state 에서 build red 가능. team-lead 의 fine-grained split 은 review/closure granularity 위함이지 commit 단위 분리 의도 아님.
|
||||
- **Step 7 + Step 8 mutually independent** — pdf 와 code 의 dispatch site 가 별 helper 함수.
|
||||
- **Step 9 < Step 10**: dead code 정리 후 unit test 추가 (clippy clean 상태에서 test 작성).
|
||||
- **Step 10 < Step 11**: unit test 가 먼저 + full workspace test 다음.
|
||||
|
||||
## §2 Steps (11 steps)
|
||||
|
||||
### Step 1: Pre-flight baseline 측정 + env 확인
|
||||
|
||||
- **Files affected**: 변경 0 (측정 only).
|
||||
- **Action**:
|
||||
- `cd /home/altair823/kebab && git rev-parse HEAD` → `9676640` 또는 그 위 commit 확인.
|
||||
- env 확인: `echo $CARGO_TARGET_DIR` 가 `/build/out/cargo-target/target` 인지. 비어있으면 §0 의 export 적용.
|
||||
- workspace baseline crate count: `cargo metadata --no-deps --format-version 1 | jq '.workspace_members | length'` → **22** (PR #186 머지 후).
|
||||
- baseline test 함수 수 persist (spec §5.2 의 1313 baseline — Step 11 의 numeric compare gate):
|
||||
```bash
|
||||
$ mkdir -p .omc/state
|
||||
$ cargo test --workspace --no-fail-fast -j 1 2>&1 \
|
||||
| awk '/^test result: ok\./ {for(i=1;i<=NF;i++) if($i=="passed;") sum += $(i-1)} END {print sum}' \
|
||||
> .omc/state/extractor-dispatch-baseline.txt
|
||||
$ cat .omc/state/extractor-dispatch-baseline.txt
|
||||
1313 # 예상. doc-test 의 `running 0 tests` 라인도 awk 의 `test result: ok.` 매칭에 합쳐짐 — delta 보존 (MINOR #2 fix).
|
||||
```
|
||||
- hardcoded callsite count baseline 측정 (Step 11 의 callsite-count verify 비교 source). MINOR GAP #6 의 instance-method pattern 보강:
|
||||
```bash
|
||||
$ grep -nE "ImageExtractor::new|PdfTextExtractor::new|(Rust|Python|Typescript|Javascript|Go|Java|Kotlin|C|Cpp)AstExtractor::new" crates/kebab-app/src/lib.rs
|
||||
# 예상: 11 hit (image 1 + pdf 1 + 9 AST — type-direct call).
|
||||
$ grep -nE "image_extractor\.extract|image_pipeline\.extractor\.extract" crates/kebab-app/src/lib.rs
|
||||
# 예상: 1 hit (lib.rs:1296 의 instance-method call).
|
||||
$ grep -c "image_extractor" crates/kebab-app/src/lib.rs
|
||||
# 예상: ≥ 3 hit (lib.rs:356 local + lib.rs:1235 alias + lib.rs:1296 dispatch).
|
||||
```
|
||||
- **wire baseline snapshot — falsifiable cmd (round 1 MAJOR #4 fix)**:
|
||||
```bash
|
||||
$ mkdir -p .omc/state/wire-baseline /tmp/kb-wire-baseline
|
||||
# config.toml 생성 — docs/SMOKE.md 의 isolated TempDir KB 절차 정합.
|
||||
$ cat > /tmp/kb-wire-baseline/config.toml <<'EOF'
|
||||
[workspace]
|
||||
root = "/tmp/kb-wire-baseline/ws"
|
||||
data_dir = "/tmp/kb-wire-baseline/data"
|
||||
exclude = []
|
||||
|
||||
[search]
|
||||
cache_capacity = 0
|
||||
|
||||
[rag]
|
||||
nli_threshold = 0.0
|
||||
EOF
|
||||
$ mkdir -p /tmp/kb-wire-baseline/ws /tmp/kb-wire-baseline/data
|
||||
# 4-medium fixture (markdown / pdf / png / rust) 의 ingest + search + ask:
|
||||
$ cp crates/kebab-app/src/lib.rs /tmp/kb-wire-baseline/ws/lib.rs # rust code fixture
|
||||
$ cp README.md /tmp/kb-wire-baseline/ws/ # markdown fixture
|
||||
$ cargo run --release --bin kebab -- --config /tmp/kb-wire-baseline/config.toml ingest --json \
|
||||
> .omc/state/wire-baseline/ingest_report.json
|
||||
$ cargo run --release --bin kebab -- --config /tmp/kb-wire-baseline/config.toml search "polymorphic dispatch" --json \
|
||||
> .omc/state/wire-baseline/search.json
|
||||
$ cargo run --release --bin kebab -- --config /tmp/kb-wire-baseline/config.toml ask "what is extract_for" --json \
|
||||
> .omc/state/wire-baseline/answer.json
|
||||
```
|
||||
PDF / PNG fixture 가 repo 에 없으면 markdown + rust 의 2-medium 만으로도 wire diff 검증 충분 (success path 의 4 medium 중 2 만 cover, 나머지 image/pdf 는 §4.3 의 callsite-count verify 로 covered). 본 plan 의 fixture path 명시 (Missing #4 fix).
|
||||
- **Exit gate**:
|
||||
- `cargo metadata --no-deps --format-version 1 | jq '.workspace_members | length'` = **22**.
|
||||
- `cargo build --workspace -j 1 2>&1 | tail -3` 의 마지막 라인 = `Finished` (현 시점 baseline green).
|
||||
- `cat .omc/state/extractor-dispatch-baseline.txt` = 1313 (또는 실측치).
|
||||
- `ls -1 .omc/state/wire-baseline/*.json | wc -l` = **3** (ingest_report.json + search.json + answer.json).
|
||||
- **Spec 참조**: §5.2 (baseline), §1.3 (callsite enumeration), §5.4 (SMOKE).
|
||||
|
||||
### Step 2: `App.extractors` field + `App::extract_for` helper method shape 신설 (placeholder init)
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/app.rs` (단일 — struct + impl method + use statement).
|
||||
- **Action**:
|
||||
- **(a) use statement 추가** — `app.rs` head 의 use 부에 다음 추가 (round 1 MAJOR #3 의 use 정책 정합 — short-name 사용):
|
||||
```rust
|
||||
use kebab_parse_image::ImageExtractor;
|
||||
use kebab_parse_pdf::PdfTextExtractor;
|
||||
use kebab_parse_code::{
|
||||
CAstExtractor, CppAstExtractor, GoAstExtractor, JavaAstExtractor,
|
||||
JavascriptAstExtractor, KotlinAstExtractor, PythonAstExtractor,
|
||||
RustAstExtractor, TypescriptAstExtractor,
|
||||
};
|
||||
```
|
||||
(이미 use 가 있으면 skip).
|
||||
- **(b) `App` struct 갱신** — `crates/kebab-app/src/app.rs:115` 의 struct 에 field 추가:
|
||||
```rust
|
||||
pub struct App {
|
||||
pub(crate) config: kebab_config::Config,
|
||||
pub(crate) sqlite: Arc<SqliteStore>,
|
||||
/// post-v0.18.0: inner-AST 9-arm extract dispatch + image/pdf
|
||||
/// extract callsite 통합. App init 시 1회 등록. markdown 은 별 PR.
|
||||
pub(crate) extractors: Vec<Box<dyn kebab_core::Extractor + Send + Sync>>,
|
||||
embedder: OnceLock<Arc<dyn Embedder + Send + Sync>>,
|
||||
vector: OnceLock<Arc<LanceVectorStore>>,
|
||||
llm: OnceLock<Arc<dyn LanguageModel>>,
|
||||
search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>,
|
||||
pipeline_verifier: Option<Arc<dyn kebab_nli::NliVerifier>>,
|
||||
}
|
||||
```
|
||||
- **(c) `App::extract_for` helper method 추가** — `impl App { ... }` 안에 spec §3.6 의 코드 그대로:
|
||||
```rust
|
||||
/// Polymorphic dispatcher for the Extractor trait. Looks up the first
|
||||
/// Extractor whose `supports(media)` returns true and invokes
|
||||
/// `extract(ctx, bytes)` on it.
|
||||
///
|
||||
/// Errors with `anyhow!("no Extractor for media_type {media:?}")`
|
||||
/// when no matching Extractor is registered — caller (e.g.
|
||||
/// `ingest_one_*_asset`) should treat this as a programming error
|
||||
/// (unreachable in the post-outer-dispatch branches), NOT as a
|
||||
/// user-facing skip.
|
||||
pub(crate) fn extract_for(
|
||||
&self,
|
||||
media: &kebab_core::MediaType,
|
||||
ctx: &kebab_core::ExtractContext<'_>,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<kebab_core::CanonicalDocument> {
|
||||
let extractor = self.extractors.iter()
|
||||
.find(|e| e.supports(media))
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"no Extractor for media_type {:?}", media
|
||||
))?;
|
||||
extractor.extract(ctx, bytes)
|
||||
}
|
||||
```
|
||||
- **(d) `App::open_with_config` 의 constructor placeholder** — field missing 회피 위해 `extractors: Vec::new()` 임시 placeholder:
|
||||
```rust
|
||||
Ok(Self {
|
||||
config,
|
||||
sqlite: Arc::new(sqlite),
|
||||
extractors: Vec::new(), // Step 3 에서 real init 으로 replace
|
||||
embedder: OnceLock::new(),
|
||||
...
|
||||
})
|
||||
```
|
||||
- clippy 의 dead-code warn 발생 가능 (extract_for unused + extractors always-empty) — Step 3 머지 시 자동 해소. fail 시 `#[allow(dead_code)]` 임시 부착 (Step 9 의 cleanup checklist 항목).
|
||||
- **Exit gate**:
|
||||
- `cargo build -p kebab-app -j 4 2>&1 | tail -3` 의 마지막 라인 = `Finished`.
|
||||
- `grep -c "pub(crate) extractors:" crates/kebab-app/src/app.rs` = **1** (struct field 등장).
|
||||
- `grep -c "fn extract_for" crates/kebab-app/src/app.rs` = **1** (method 정의 등장).
|
||||
- `grep -c "extractors: Vec::new()" crates/kebab-app/src/app.rs` = **1** (placeholder).
|
||||
- **Spec 참조**: §3.5 (struct), §3.6 (helper).
|
||||
|
||||
### Step 3: `App::open_with_config` 의 registry init (11 Extractor) + lib.rs:1235 alias 제거
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/app.rs` (단일 — open_with_config body).
|
||||
- `crates/kebab-app/src/lib.rs` (단일 — :1235 alias line 삭제, round 1 MAJOR #5 fix).
|
||||
- **Action**:
|
||||
- **(a) `App::open_with_config` 의 placeholder 교체** — `pipeline_verifier` init 의 직전 (Missing #2 fix — init order 자연 위치, state-less + side-effect 0 추가) 에 real init:
|
||||
```rust
|
||||
// pipeline_verifier init 직전:
|
||||
let extractors: Vec<Box<dyn kebab_core::Extractor + Send + Sync>> = vec![
|
||||
Box::new(ImageExtractor::new()),
|
||||
Box::new(PdfTextExtractor::new()),
|
||||
Box::new(RustAstExtractor::new()),
|
||||
Box::new(PythonAstExtractor::new()),
|
||||
Box::new(TypescriptAstExtractor::new()),
|
||||
Box::new(JavascriptAstExtractor::new()),
|
||||
Box::new(GoAstExtractor::new()),
|
||||
Box::new(JavaAstExtractor::new()),
|
||||
Box::new(KotlinAstExtractor::new()),
|
||||
Box::new(CAstExtractor::new()),
|
||||
Box::new(CppAstExtractor::new()),
|
||||
];
|
||||
|
||||
// (기존 pipeline_verifier init ...)
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
sqlite: Arc::new(sqlite),
|
||||
extractors, // placeholder Vec::new() → real init replace.
|
||||
embedder: OnceLock::new(),
|
||||
...
|
||||
})
|
||||
```
|
||||
init order rationale (Missing #2): sqlite (heavy I/O) → search_cache (light) → pipeline_verifier (가능한 fallible NLI build) → extractors (state-less, cheap, infallible) → `Ok(Self)`. extractors 의 자연 위치 = `pipeline_verifier` 직전 또는 직후 — `pipeline_verifier` 가 fallible (`?`) 이므로 그 직전 (init order 가 fail-fast 의 cost 와 정합) 또는 직후. **본 plan 은 `pipeline_verifier` 직전** 으로 정합.
|
||||
|
||||
state-less + side-effect 0 (Missing #3): 모든 11 impl 의 `new()` = unit-struct 또는 zero-field. `pipeline_verifier` 의 `Err` 가 발생해도 `extractors` lifetime 가 `Ok(Self)` 까지만 — drop 시 cost 0. side-effect 0.
|
||||
|
||||
- **(b) lib.rs:1235 의 alias line 삭제** (round 1 MAJOR #5 + MINOR GAP #4 fix):
|
||||
```rust
|
||||
// BEFORE (lib.rs:1235-1237):
|
||||
let image_extractor = image_pipeline.extractor; // ← 삭제 (struct field 가 Step 5 에서 제거됨)
|
||||
let ocr_engine = image_pipeline.ocr_engine; // 유지
|
||||
let caption_llm = image_pipeline.caption_llm; // 유지
|
||||
|
||||
// AFTER:
|
||||
let ocr_engine = image_pipeline.ocr_engine;
|
||||
let caption_llm = image_pipeline.caption_llm;
|
||||
```
|
||||
**타이밍**: 본 step (Step 3) 에서 alias line 1235 만 삭제. ImagePipeline.extractor field 는 Step 5 에서 제거. 본 step 후 lib.rs:1296 의 `image_extractor.extract(...)` 가 unresolved → build red. Step 4 + 5 + 6 atomic block 의 일부 — Step 6 후에야 build green.
|
||||
|
||||
이 순서가 ImagePipeline.extractor field 삭제 (Step 5) 이전에 alias line 1235 삭제 (Step 3) — interpretation A vs B 의 명시 (Ambiguity #1 fix). **본 plan 의 interpretation = "lib.rs:1235 alias 를 Step 3 에서 먼저 삭제, ImagePipeline struct + init block 은 Step 5 에서 재작성"**.
|
||||
|
||||
- **(c) pre-flight: `grep "kebab-parse-" crates/kebab-app/Cargo.toml`** → 4 dep (md/pdf/image/code) 모두 보유 verify. spec §1.8 의 dep graph 보존.
|
||||
- **Exit gate**:
|
||||
- `grep -c "Box::new(.*Extractor::new())" crates/kebab-app/src/app.rs` = **11** (11 entry).
|
||||
- per-line breakdown verify:
|
||||
```bash
|
||||
$ grep -c "Box::new(ImageExtractor::new" crates/kebab-app/src/app.rs
|
||||
1
|
||||
$ grep -c "Box::new(PdfTextExtractor::new" crates/kebab-app/src/app.rs
|
||||
1
|
||||
$ grep -cE "Box::new\((Rust|Python|Typescript|Javascript|Go|Java|Kotlin|C|Cpp)AstExtractor::new\(\)\)" crates/kebab-app/src/app.rs
|
||||
9
|
||||
```
|
||||
- `grep -c "extractors: Vec::new()" crates/kebab-app/src/app.rs` = **0** (placeholder 제거).
|
||||
- `grep -n "let image_extractor = image_pipeline.extractor" crates/kebab-app/src/lib.rs` = **0 hit** (alias 삭제).
|
||||
- `cargo build -p kebab-app -j 4 2>&1 | tail -3` — **build red 예상** (lib.rs:1296 의 `image_extractor.extract` 가 unresolved). Step 6 후 종합 verify. **본 step 의 build verify 는 next exit gate 의 Step 6 에서**.
|
||||
- **Spec 참조**: §3.5 (registry init 11 entry), §3.5.1 (lib.rs:1235 alias 정리).
|
||||
|
||||
### Step 4: lib.rs:356 의 local `image_extractor` 제거 (atomic block 1 시작)
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/lib.rs` (단일 — :356 부근).
|
||||
- **Action**:
|
||||
- lib.rs:356 의 `let image_extractor = ImageExtractor::new();` 1 줄 삭제.
|
||||
- lib.rs:357 부근 의 `ImagePipeline { extractor: &image_extractor, ... }` 의 `extractor: &image_extractor,` line 도 동시 삭제 (Step 5 의 struct field 제거와 정합 — interpretation A 의 단순화).
|
||||
- 본 step 단독으로는 ImagePipeline struct 의 `extractor` field 가 아직 존재 (Step 5 에서 제거) → init block 의 `extractor: ...` 없는 상태가 missing-field error → **build red**. Step 5 + 6 와 atomic block close.
|
||||
- **Exit gate**:
|
||||
- `grep -c "let image_extractor = ImageExtractor::new" crates/kebab-app/src/lib.rs` = **0**.
|
||||
- `grep -A3 "let image_pipeline = ImagePipeline" crates/kebab-app/src/lib.rs | grep -c "extractor:"` = **0**.
|
||||
- `cargo build -p kebab-app -j 4 2>&1 | tail -3` — **red 가능** (Step 5 + 6 와 atomic). Step 6 의 exit gate 에서 종합 verify.
|
||||
- **Spec 참조**: §3.5.1 (lib.rs:356 local 제거).
|
||||
|
||||
### Step 5: `ImagePipeline.extractor` field 제거 + struct/사용 site 갱신
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/lib.rs` (단일 — :760-764 struct).
|
||||
- **Action**:
|
||||
- lib.rs:760-764 의 struct 갱신 (spec §3.5.1 Option c):
|
||||
```rust
|
||||
// BEFORE
|
||||
struct ImagePipeline<'a> {
|
||||
extractor: &'a ImageExtractor,
|
||||
ocr_engine: Option<&'a OllamaVisionOcr>,
|
||||
caption_llm: Option<&'a dyn LanguageModel>,
|
||||
}
|
||||
|
||||
// AFTER
|
||||
struct ImagePipeline<'a> {
|
||||
ocr_engine: Option<&'a OllamaVisionOcr>,
|
||||
caption_llm: Option<&'a dyn LanguageModel>,
|
||||
}
|
||||
```
|
||||
- Step 3 (b) 의 lib.rs:1235 alias 가 이미 삭제됨 + Step 4 의 init block `extractor: &image_extractor,` 가 이미 삭제됨 → 본 step 후 `image_pipeline.extractor` 의 모든 reference 제거 완료.
|
||||
- **본 step 단독** 으로는 lib.rs:1296 의 `image_extractor.extract(...)` 가 still unresolved (Step 4 의 local 삭제 + Step 3 의 alias 삭제 후) → **build red**. Step 6 후 close.
|
||||
- **Exit gate**:
|
||||
- `grep -c "extractor: &" crates/kebab-app/src/lib.rs` = **0** (struct field + init block 모두 제거).
|
||||
- `grep -A3 "struct ImagePipeline" crates/kebab-app/src/lib.rs | grep -c "extractor:"` = **0**.
|
||||
- `cargo build -p kebab-app -j 4 2>&1 | tail -3` — **red 가능** (Step 6 의 exit gate 에서 종합 verify).
|
||||
- **Spec 참조**: §3.5.1 (ImagePipeline Option c — field 제거).
|
||||
|
||||
### Step 6: lib.rs:1296 image extract callsite — `extract_for` 로 교체 (atomic block 1 close)
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/lib.rs` (단일 — :1296 부근).
|
||||
- **Action**:
|
||||
- lib.rs:1296 의 dispatch callsite 교체:
|
||||
```rust
|
||||
// BEFORE
|
||||
let mut canonical = image_extractor
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-image::ImageExtractor::extract")?;
|
||||
|
||||
// AFTER
|
||||
let mut canonical = app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.context("kb-app::extract_for (image)")?;
|
||||
```
|
||||
- 본 step 후 Step 3-4-5-6 의 4-step block close — build green 보장.
|
||||
- additional grep (혹시 다른 `image_extractor` ref 가 남아있는지):
|
||||
```bash
|
||||
$ grep -n "image_extractor" crates/kebab-app/src/lib.rs
|
||||
# 예상: 0 hit (Step 3 alias + Step 4 local + Step 6 dispatch 모두 제거).
|
||||
```
|
||||
- **Exit gate (Step 3-4-5-6 atomic block 종합)**:
|
||||
- `grep -c "image_extractor" crates/kebab-app/src/lib.rs` = **0**.
|
||||
- `grep -c "image_pipeline.extractor" crates/kebab-app/src/lib.rs` = **0**.
|
||||
- `grep -c "app.extract_for" crates/kebab-app/src/lib.rs` ≥ **1** (image dispatch).
|
||||
- `cargo build -p kebab-app -j 4 2>&1 | tail -3` = `Finished` (atomic block close 후 build green).
|
||||
- `cargo test -p kebab-app -j 4 --no-fail-fast 2>&1 | tail -10` — 기존 image ingest test 가 모두 pass.
|
||||
- **Spec 참조**: §3.6 (Pattern β image), §3.7 (image row).
|
||||
|
||||
### Step 7: lib.rs:1783 pdf extract callsite — `extract_for` 로 교체
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/lib.rs` (단일 — :1783 부근).
|
||||
- **Action**:
|
||||
- lib.rs:1783 의 dispatch callsite 교체:
|
||||
```rust
|
||||
// BEFORE
|
||||
let mut canonical = PdfTextExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-pdf::PdfTextExtractor::extract")?;
|
||||
|
||||
// AFTER
|
||||
let mut canonical = app
|
||||
.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.context("kb-app::extract_for (pdf)")?;
|
||||
```
|
||||
- use 선언 `use kebab_parse_pdf::PdfTextExtractor;` (lib.rs:53) — 본 step 후 `PdfTextExtractor` 의 short-name 참조가 lib.rs 안에 0 (registry init 은 app.rs 안에 short-name 사용). 따라서 lib.rs 의 use 가 unused → clippy warn. **Step 9 의 dead-code 정리에서 처리**.
|
||||
- **wire diff scope** (round 1 MAJOR verifier #2 + spec §5.5 갱신 정합): error path 의 `.context("kb-parse-pdf::PdfTextExtractor::extract")` → `"kb-app::extract_for (pdf)"` wording 변경. `error.v1.code` 보존 (downcast_ref 기반 exit code branching 영향 0). spec §5.5 risk acceptance 가 정합.
|
||||
- **Exit gate**:
|
||||
- `grep -nE "PdfTextExtractor::new\(\)\.extract" crates/kebab-app/src/lib.rs` = **0 hit** (dispatch callsite 교체).
|
||||
- `grep -c "app.extract_for" crates/kebab-app/src/lib.rs` ≥ **2** (image + pdf).
|
||||
- `cargo build -p kebab-app -j 4 2>&1 | tail -3` = `Finished`. clippy 의 unused-import warn 발생 가능 — Step 9 에서 정리.
|
||||
- `cargo test -p kebab-app -j 4 --no-fail-fast 2>&1 | tail -10` — 기존 pdf ingest test 가 모두 pass.
|
||||
- **Spec 참조**: §3.6 (Pattern β pdf), §3.7 (pdf row).
|
||||
|
||||
### Step 8: lib.rs:2012-2047 9 AST arm — `extract_for` 로 hoist (가장 큰 atomic edit)
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/lib.rs` (단일 region — :2012-2047 부근).
|
||||
- **Action**: spec §3.7 의 table — actual arm count = **12 (11 explicit + 1 wildcard)** (round 1 MINOR GAP #5 정정) → 본 step 후 **4 arm** (9 AST group + manifest group + shell + wildcard). 정확한 diff:
|
||||
- **BEFORE** (lib.rs:2012-2047, 12 arm):
|
||||
```rust
|
||||
let canonical_result: anyhow::Result<kebab_core::CanonicalDocument> = match code_lang {
|
||||
"rust" => RustAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::RustAstExtractor::extract (code:rust)"),
|
||||
"python" => PythonAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::PythonAstExtractor::extract (code:python)"),
|
||||
"typescript" => TypescriptAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::TypescriptAstExtractor::extract (code:typescript)"),
|
||||
"javascript" => JavascriptAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::JavascriptAstExtractor::extract (code:javascript)"),
|
||||
"go" => GoAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::GoAstExtractor::extract (code:go)"),
|
||||
"java" => JavaAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::JavaAstExtractor::extract (code:java)"),
|
||||
"kotlin" => KotlinAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kb-parse-code::KotlinAstExtractor::extract (code:kotlin)"),
|
||||
"yaml" | "dockerfile" | "toml" | "json" | "xml" | "groovy" | "go-mod" => {
|
||||
synthesize_tier2_document(asset, &bytes, code_lang, &parser_version)
|
||||
}
|
||||
"shell" => synthesize_tier2_document(asset, &bytes, "shell", &parser_version),
|
||||
"c" => CAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kebab-parse-code::CAstExtractor::extract (code:c)"),
|
||||
"cpp" => CppAstExtractor::new()
|
||||
.extract(&ctx, &bytes)
|
||||
.context("kebab-parse-code::CppAstExtractor::extract (code:cpp)"),
|
||||
other => anyhow::bail!("unreachable (extract): {other}"),
|
||||
};
|
||||
```
|
||||
- **AFTER** (4 arm):
|
||||
```rust
|
||||
// p10-1b Task D/G/J/L + post-v0.18.0 extractor-dispatch-unification:
|
||||
// 9 AST lang 의 dispatch 가 polymorphic — App.extractors registry 의
|
||||
// `*AstExtractor` entry 가 lang string 으로 disjoint `supports()` 비교 후
|
||||
// 단일 hit. Tier 2 (manifest) + Tier 3 (shell) 은 free-function
|
||||
// `synthesize_tier2_document` 유지 (Extractor impl 아님, 별 PR future work).
|
||||
// p10-3: capture Result so Tier 1 extractor errors can fall back to Tier 3.
|
||||
let canonical_result: anyhow::Result<kebab_core::CanonicalDocument> = match code_lang {
|
||||
// 9 AST lang: rust / python / typescript / javascript / go / java / kotlin / c / cpp
|
||||
"rust" | "python" | "typescript" | "javascript"
|
||||
| "go" | "java" | "kotlin" | "c" | "cpp" => {
|
||||
app.extract_for(&asset.media_type, &ctx, &bytes)
|
||||
.with_context(|| format!("kb-app::extract_for (code:{code_lang})"))
|
||||
}
|
||||
// p10-2 Tier 2: no extractor — synthesize Document directly from raw bytes.
|
||||
"yaml" | "dockerfile" | "toml" | "json" | "xml" | "groovy" | "go-mod" => {
|
||||
synthesize_tier2_document(asset, &bytes, code_lang, &parser_version)
|
||||
}
|
||||
// p10-3: shell reuses the same synthesizer.
|
||||
"shell" => synthesize_tier2_document(asset, &bytes, "shell", &parser_version),
|
||||
other => anyhow::bail!("unreachable (extract): {other}"),
|
||||
};
|
||||
```
|
||||
- net: **12 arm → 4 arm** (9 AST individual arm 통합 + manifest group + shell + wildcard). 9 callsite 의 `*Extractor::new().extract(…)` 가 1 callsite `app.extract_for(...)` 로 hoist.
|
||||
- **후속 control flow 보존 trace (Missing #1 fix)**:
|
||||
- lib.rs:2050 부근 `match canonical_result { Err(e) if code_lang == "shell" || matches!(...) => return Err(e).context(...) }` 가 Err 의 root cause 변별 — `code_lang` 의 lang string 으로 분기 (NOT anyhow chain 의 message 내용). `app.extract_for(...)` 의 Err 가 `*Extractor::extract(...)` 의 Err 와 동일 chain 구조 + `with_context(...)` 으로 outer wrap → Err variant matching 영향 0.
|
||||
- lib.rs:2050+ 의 Tier 1 → Tier 3 fallback 분기 `Err(e) => { tracing::warn!(...); chunker_version = CodeTextParagraphV1Chunker.chunker_version(); ...; synthesize_tier2_document(...) }` — anyhow chain 의 인용 (`error = %e`) 만 사용, 변별 의미 없음. **fallback control flow 보존 검증** = Step 11 의 `cargo test --workspace` 의 `p10_*` tier1-fallback test pass (예: `tests/p10_3_*.rs` 의 tier1 fail → tier3 recover test).
|
||||
- use 선언 (lib.rs:52 `use kebab_parse_code::{...}` 9 type) — 9 type 모두 lib.rs 안에 short-name 참조 0 (registry init 은 app.rs 의 short-name). lib.rs 의 use 가 unused → clippy warn. **Step 9 에서 정리**.
|
||||
- **Exit gate**:
|
||||
- `grep -cE "(Rust|Python|Typescript|Javascript|Go|Java|Kotlin|C|Cpp)AstExtractor::new\(\)\.extract" crates/kebab-app/src/lib.rs` = **0 hit** (9 AST dispatch callsite 모두 제거).
|
||||
- `grep -c "app.extract_for" crates/kebab-app/src/lib.rs` ≥ **3** (image + pdf + code 의 3 dispatch site).
|
||||
- `grep -c "synthesize_tier2_document" crates/kebab-app/src/lib.rs` ≥ **3** (manifest arm + shell arm + 다른 callsite — Tier 2/3 유지).
|
||||
- arm count post-state verify (round 1 MINOR GAP #5 정정 — actual 12 → 4):
|
||||
```bash
|
||||
$ awk '/let canonical_result.*= match code_lang/,/^ \};$/' crates/kebab-app/src/lib.rs \
|
||||
| grep -cE "^\s+\"[^\"]+\"[^=>]*=>|^\s+other\s*=>"
|
||||
# 예상: 4 (9-AST-group + manifest-group + shell + wildcard).
|
||||
```
|
||||
- `cargo build -p kebab-app -j 4 2>&1 | tail -3` = `Finished`. clippy 의 unused-import warn 발생 가능 — Step 9 정리.
|
||||
- `cargo test -p kebab-app -j 4 --no-fail-fast 2>&1 | tail -20` — 기존 code ingest test (`tests/p10_*.rs` 등) 가 모두 pass — **fallback control flow 보존** 검증.
|
||||
- **Spec 참조**: §3.6 (Pattern β code), §3.7 (9 AST arm row — 12 → 4 arm diff).
|
||||
|
||||
### Step 9: dead code 정리 (unused use statement + 임시 `#[allow(dead_code)]` cleanup checklist)
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/lib.rs` (use statement — :51-53 부근).
|
||||
- `crates/kebab-app/src/app.rs` (Step 2 의 임시 `#[allow(dead_code)]` 가 있으면 제거).
|
||||
- **Action**:
|
||||
- **(a) clippy 실행하여 unused-import warn 식별**:
|
||||
```bash
|
||||
$ cargo clippy -p kebab-app --all-targets -j 4 -- -D warnings 2>&1 | grep -E "unused_imports|unused-imports|warning"
|
||||
```
|
||||
예상 warn 후보:
|
||||
- `use kebab_parse_image::ImageExtractor` (Step 4 후 short-name 참조 0).
|
||||
- `use kebab_parse_pdf::PdfTextExtractor` (Step 7 후 short-name 참조 0).
|
||||
- `use kebab_parse_code::{CAstExtractor, ..., TypescriptAstExtractor}` 9 type (Step 8 후 short-name 참조 0).
|
||||
- **(b) lib.rs:51-53 의 use statement 갱신** — short-name 참조 없는 type 만 제거 (round 1 MAJOR #6 fix — destructure 의 비 AST type 보존):
|
||||
```rust
|
||||
// BEFORE (lib.rs:51-53):
|
||||
use kebab_parse_image::{ImageExtractor, OllamaVisionOcr, apply_caption, apply_ocr};
|
||||
use kebab_parse_code::{CAstExtractor, CppAstExtractor, GoAstExtractor, JavaAstExtractor, JavascriptAstExtractor, KotlinAstExtractor, PythonAstExtractor, RustAstExtractor, TypescriptAstExtractor};
|
||||
use kebab_parse_pdf::PdfTextExtractor;
|
||||
|
||||
// AFTER:
|
||||
use kebab_parse_image::{OllamaVisionOcr, apply_caption, apply_ocr};
|
||||
// kebab-parse-code 의 9 AST type 은 app.rs 의 registry init 에서만 사용 → lib.rs 의 use 제거.
|
||||
// kebab-parse-pdf::PdfTextExtractor 는 app.rs 의 registry init 에서만 사용 → lib.rs 의 use 제거.
|
||||
// 단 lib.rs 안에 kebab_parse_code 의 다른 type 호출 (e.g. `kebab_parse_code::detect_repo`) 이 있으면 보존:
|
||||
// - lib.rs:2334 의 `kebab_parse_code::detect_repo(...)` 는 fully-qualified — use 갱신 영향 0.
|
||||
```
|
||||
추가 grep 검증:
|
||||
```bash
|
||||
$ grep -cE "^use kebab_parse_(image|pdf|code)::" crates/kebab-app/src/lib.rs
|
||||
# 예상: 1 (image 의 OllamaVisionOcr + apply_* 만 보존).
|
||||
```
|
||||
- **(c) cleanup checklist (round 1 MINOR #1 fix)** — Step 2 (d) 또는 Step 3 (a) 의 임시 attribute 제거:
|
||||
| 위치 | 부착됐는가 | 제거 여부 |
|
||||
|---|---|---|
|
||||
| `app.rs` 의 `#[allow(dead_code)]` (extract_for 또는 extractors field) | Step 2 placeholder 시 부착 가능 | Step 3 의 real init 후 모두 제거 |
|
||||
| `lib.rs` 의 임시 `#[allow(unused_imports)]` | Step 4/7/8 시 부착 가능 | Step 9 (b) 의 use 갱신 후 모두 제거 |
|
||||
|
||||
`grep -n "#\[allow(dead_code)\]\|#\[allow(unused_imports)\]" crates/kebab-app/src/{app,lib}.rs` → 본 step 후 0 hit.
|
||||
- **Exit gate**:
|
||||
- `cargo clippy -p kebab-app --all-targets -j 4 -- -D warnings 2>&1 | tail -5` clean (warn 0).
|
||||
- `grep -c "use kebab_parse_code::" crates/kebab-app/src/lib.rs` = **0** (or ≤ 1 if `detect_repo` 같은 other type 의 별 use line 이 있는 경우 — pre-flight grep 결과로 결정).
|
||||
- `grep -c "ImageExtractor\|PdfTextExtractor" crates/kebab-app/src/lib.rs` = **0** (short-name 참조 0).
|
||||
- `grep -cE "(Rust|Python|Typescript|Javascript|Go|Java|Kotlin|C|Cpp)AstExtractor" crates/kebab-app/src/lib.rs` = **0**.
|
||||
- `grep -cE "#\[allow\(dead_code\)\]|#\[allow\(unused_imports\)\]" crates/kebab-app/src/{app,lib}.rs` = **0** (임시 attribute 모두 제거).
|
||||
- `cargo build -p kebab-app -j 4 2>&1 | tail -3` = `Finished`.
|
||||
- **Spec 참조**: §3.5 (registry init source-of-truth), §3.7 (use statement 갱신).
|
||||
|
||||
### Step 10: unit tests 추가 (in-crate `#[cfg(test)] mod tests` in app.rs)
|
||||
|
||||
- **Files affected**:
|
||||
- `crates/kebab-app/src/app.rs` (단일 — 기존 `impl App { ... }` 의 아래에 `#[cfg(test)] mod tests { ... }` 추가).
|
||||
- **Action**: spec §5.1 의 3 test class 를 in-crate unit test 로 작성 (round 1 CRITICAL #1 fix — `pub(crate)` access 위해 integration test 가 아닌 in-crate test). `crates/kebab-app/src/app.rs` 의 마지막 (impl App 끝나는 지점 아래) 에 추가:
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kebab_core::{ExtractContext, MediaType, AudioType};
|
||||
|
||||
/// helper: tempdir-isolated App for tests.
|
||||
fn open_test_app() -> App {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = kebab_config::Config::default();
|
||||
cfg.workspace.root = tmp.path().join("workspace");
|
||||
cfg.workspace.data_dir = tmp.path().join("data");
|
||||
std::fs::create_dir_all(&cfg.workspace.root).expect("mkdir workspace");
|
||||
std::fs::create_dir_all(&cfg.workspace.data_dir).expect("mkdir data");
|
||||
let app = App::open_with_config(cfg).expect("App::open_with_config");
|
||||
std::mem::forget(tmp); // tempdir 의 drop 후 KB 가 사라지면 안 됨 (App 이 sqlite 점유)
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_has_eleven_extractors() {
|
||||
let app = open_test_app();
|
||||
assert_eq!(app.extractors.len(), 11,
|
||||
"registry must hold 11 Extractors (image + pdf + 9 AST). \
|
||||
markdown 은 별 PR.");
|
||||
}
|
||||
|
||||
/// 11 Extractor 의 `supports()` 가 16 sample MediaType 에 대해
|
||||
/// mutually exclusive (어떤 두 Extractor 도 동일 MediaType 에 대해 true 반환 0).
|
||||
#[test]
|
||||
fn supports_grid_is_mutually_exclusive() {
|
||||
let app = open_test_app();
|
||||
let samples = vec![
|
||||
MediaType::Markdown,
|
||||
MediaType::Pdf,
|
||||
MediaType::Image(kebab_core::ImageType::Png),
|
||||
MediaType::Image(kebab_core::ImageType::Jpeg),
|
||||
MediaType::Code("rust".into()),
|
||||
MediaType::Code("python".into()),
|
||||
MediaType::Code("typescript".into()),
|
||||
MediaType::Code("javascript".into()),
|
||||
MediaType::Code("go".into()),
|
||||
MediaType::Code("java".into()),
|
||||
MediaType::Code("kotlin".into()),
|
||||
MediaType::Code("c".into()),
|
||||
MediaType::Code("cpp".into()),
|
||||
MediaType::Code("yaml".into()), // registry NOT cover
|
||||
MediaType::Code("shell".into()), // registry NOT cover
|
||||
MediaType::Audio(AudioType::Wav), // registry NOT cover
|
||||
];
|
||||
for sample in &samples {
|
||||
let hits: Vec<_> = app.extractors.iter()
|
||||
.filter(|e| e.supports(sample))
|
||||
.collect();
|
||||
assert!(hits.len() <= 1,
|
||||
"mutually exclusive violated for {sample:?}: {} hits", hits.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// `extract_for` 가 registry NOT cover MediaType 에 대해
|
||||
/// `Err("no Extractor for media_type ...")` 반환.
|
||||
/// MAJOR #2 simpler suggestion: Audio MediaType 사용으로 RawAsset 의존성 회피 —
|
||||
/// extract_for 는 dispatch loop 만 검증, RawAsset 의 actual content 는 무관.
|
||||
#[test]
|
||||
fn extract_for_unsupported_media_errors() {
|
||||
let app = open_test_app();
|
||||
|
||||
// Minimal RawAsset — actual content 는 dispatch 까지 도달 안 함
|
||||
// (Audio MediaType → registry NOT cover → 즉시 Err).
|
||||
// RawAsset 의 actual field (asset.rs:63-73): asset_id / source_uri /
|
||||
// workspace_path / media_type / byte_len / checksum / discovered_at / stored.
|
||||
let asset = kebab_core::RawAsset {
|
||||
asset_id: kebab_core::AssetId("dummy-blake3-12".to_string()),
|
||||
source_uri: kebab_core::SourceUri::File("/tmp/dummy.wav".into()),
|
||||
workspace_path: kebab_core::WorkspacePath("dummy.wav".to_string()),
|
||||
media_type: MediaType::Audio(AudioType::Wav),
|
||||
byte_len: 0,
|
||||
checksum: kebab_core::Checksum("00".repeat(32)),
|
||||
discovered_at: time::OffsetDateTime::now_utc(),
|
||||
stored: kebab_core::AssetStorage::Inline,
|
||||
};
|
||||
|
||||
// MAJOR #1 fix: workspace_root 를 owned PathBuf 로 binding 한 후 borrow.
|
||||
let workspace_root: std::path::PathBuf = std::path::PathBuf::from("/tmp");
|
||||
let cfg = kebab_core::ExtractConfig::default();
|
||||
let ctx = ExtractContext {
|
||||
asset: &asset,
|
||||
workspace_root: &workspace_root,
|
||||
config: &cfg,
|
||||
};
|
||||
let result = app.extract_for(&MediaType::Audio(AudioType::Wav), &ctx, &[]);
|
||||
assert!(result.is_err(), "Audio 는 registry 미포함 → Err 기대");
|
||||
let err_msg = format!("{:#}", result.unwrap_err());
|
||||
assert!(err_msg.contains("no Extractor"), "unexpected err: {err_msg}");
|
||||
}
|
||||
}
|
||||
```
|
||||
주의: RawAsset 의 `Checksum` / `AssetStorage` field 의 actual type 이 위 sample 과 다를 수 있음 — executor 가 `crates/kebab-core/src/asset.rs:63-73` (Step 1 의 pre-flight grep 결과) + `checksum.rs` / `stored.rs` 의 actual struct 확인 후 정합화. plan 의 sample 은 의도 명시 — 정확한 field 값은 executor 가 정합.
|
||||
|
||||
`tempfile` dev-dep 확인:
|
||||
```bash
|
||||
$ grep -A20 "\[dev-dependencies\]" crates/kebab-app/Cargo.toml | grep -E "^tempfile\s*="
|
||||
```
|
||||
없으면 추가:
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
```
|
||||
- **Exit gate**:
|
||||
- `cargo test -p kebab-app --lib -j 4 2>&1 | tail -10` 의 결과 — `mod tests` 의 3 test pass.
|
||||
- 3 test 함수 등장: `grep -cE "^\s+fn (registry_has_eleven_extractors|supports_grid_is_mutually_exclusive|extract_for_unsupported_media_errors)" crates/kebab-app/src/app.rs` = **3**.
|
||||
- **Spec 참조**: §5.1 (3 test class), §4.2 (mutually-exclusive verified).
|
||||
|
||||
### Step 11: workspace 회귀 + 7 cargo gate + wire diff 0 verify + clean commit
|
||||
|
||||
- **Files affected**: production code 변경 0 (verification + commit).
|
||||
- **Action**:
|
||||
- **(a) `cargo clean`** — full workspace test 직전 1회.
|
||||
- **(b) 7 cargo gate**:
|
||||
```bash
|
||||
$ cargo build --workspace -j 1 # gate 1
|
||||
$ cargo clippy --workspace --all-targets -j 1 -- -D warnings # gate 2
|
||||
$ cargo test --workspace --no-fail-fast -j 1 \
|
||||
2>&1 | tee .omc/state/extractor-dispatch-after.log # gate 3
|
||||
|
||||
# gate 4 — numeric net-delta compare (round 1 MINOR GAP #9 fix):
|
||||
$ BASELINE=$(cat .omc/state/extractor-dispatch-baseline.txt)
|
||||
$ AFTER=$(awk '/^test result: ok\./ {for(i=1;i<=NF;i++) if($i=="passed;") sum += $(i-1)} END {print sum}' \
|
||||
.omc/state/extractor-dispatch-after.log)
|
||||
$ DELTA=$((AFTER - BASELINE))
|
||||
$ test "$DELTA" -eq 3 || { echo "test count delta $DELTA != +3"; exit 1; }
|
||||
$ echo "test delta = +$DELTA ✓"
|
||||
|
||||
$ cargo tree -p kebab-app -e normal | grep "kebab-parse-" | wc -l # gate 5 — 4
|
||||
$ cargo build --release # gate 6
|
||||
$ cargo metadata --no-deps --format-version 1 | jq '.workspace_members | length' # gate 7 — 22
|
||||
```
|
||||
- **(c) wire diff 0 verify** (success path — spec §5.5 의 risk acceptance 정합 — error path scope 외):
|
||||
```bash
|
||||
# Step 1 의 baseline 과 동일 cmd sequence 로 after snapshot 생성:
|
||||
$ rm -rf /tmp/kb-wire-after && mkdir -p /tmp/kb-wire-after/ws /tmp/kb-wire-after/data
|
||||
$ cp /tmp/kb-wire-baseline/config.toml /tmp/kb-wire-after/config.toml
|
||||
$ sed -i 's|/tmp/kb-wire-baseline|/tmp/kb-wire-after|g' /tmp/kb-wire-after/config.toml
|
||||
$ cp crates/kebab-app/src/lib.rs /tmp/kb-wire-after/ws/lib.rs
|
||||
$ cp README.md /tmp/kb-wire-after/ws/
|
||||
$ mkdir -p .omc/state/wire-after
|
||||
$ cargo run --release --bin kebab -- --config /tmp/kb-wire-after/config.toml ingest --json \
|
||||
> .omc/state/wire-after/ingest_report.json
|
||||
$ cargo run --release --bin kebab -- --config /tmp/kb-wire-after/config.toml search "polymorphic dispatch" --json \
|
||||
> .omc/state/wire-after/search.json
|
||||
$ cargo run --release --bin kebab -- --config /tmp/kb-wire-after/config.toml ask "what is extract_for" --json \
|
||||
> .omc/state/wire-after/answer.json
|
||||
$ diff -u .omc/state/wire-baseline/search.json .omc/state/wire-after/search.json | head
|
||||
$ diff -u .omc/state/wire-baseline/answer.json .omc/state/wire-after/answer.json | head
|
||||
$ diff -u .omc/state/wire-baseline/ingest_report.json .omc/state/wire-after/ingest_report.json | head
|
||||
# 모두 빈 출력 (diff 0) 기대.
|
||||
```
|
||||
error path 의 wire diff 는 본 plan 의 scope 외 (spec §5.5 risk acceptance + §7 row).
|
||||
- **(d) 3 callsite-count post-state verify** (spec §3.7 net effect):
|
||||
```bash
|
||||
$ grep -c "app.extract_for" crates/kebab-app/src/lib.rs
|
||||
# 기대: ≥ 3.
|
||||
$ grep -cE "(Rust|Python|Typescript|Javascript|Go|Java|Kotlin|C|Cpp)AstExtractor::new\(\)\.extract" crates/kebab-app/src/lib.rs
|
||||
# 기대: 0.
|
||||
$ grep -c "image_extractor" crates/kebab-app/src/lib.rs
|
||||
# 기대: 0.
|
||||
```
|
||||
- **(e) code dispatch arm count**:
|
||||
```bash
|
||||
$ awk '/let canonical_result.*= match code_lang/,/^ \};$/' crates/kebab-app/src/lib.rs \
|
||||
| grep -cE "^\s+\"[^\"]+\"[^=>]*=>|^\s+other\s*=>"
|
||||
# 기대: 4 (9-AST-group + manifest-group + shell + wildcard).
|
||||
```
|
||||
- **(f) clean commit**:
|
||||
```
|
||||
modified: crates/kebab-app/src/app.rs # struct + extract_for + registry init + mod tests
|
||||
modified: crates/kebab-app/src/lib.rs # 5 변경 site + use 갱신
|
||||
modified: crates/kebab-app/Cargo.toml # (optional) tempfile dev-dep
|
||||
```
|
||||
commit message:
|
||||
```
|
||||
refactor(app): AST 9-arm extract dispatch → App.extract_for polymorphic
|
||||
|
||||
9 AST + image + pdf 의 11 `*Extractor::new().extract(…)` callsite 가
|
||||
App.extractors registry + extract_for helper 의 3 dispatch site 로 통합
|
||||
(9 AST 는 1 callsite 로 hoist). lib.rs:2012-2047 의 12 arm (11 explicit +
|
||||
1 wildcard) → 4 arm (9-AST-group + manifest-group + shell + wildcard).
|
||||
wire schema success path 변경 0 + design contract 변경 0 + frozen task spec
|
||||
변경 0. workspace.version bump 0. error path 의 anyhow context wording
|
||||
diff 는 user-visible surface 외 (spec §5.5 risk acceptance).
|
||||
|
||||
MarkdownExtractor 신설 + Tier 2/3 Extractor 화 + Chunker registry +
|
||||
inner 4-match 통합 + outer 4-arm helper 통합 + dual-source
|
||||
parser_version 정리 + ExtractorRegistry plugin system 의 7 follow-up
|
||||
은 별 PR (spec §11).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-26-extractor-dispatch-unification-spec.md
|
||||
Plan: docs/superpowers/plans/2026-05-26-extractor-dispatch-unification-plan.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||||
```
|
||||
- **Exit gate**:
|
||||
- 7 cargo gate 모두 clean.
|
||||
- gate 4 의 numeric delta = +3 (정확 매칭, 더도 덜도 아님).
|
||||
- 3 wire diff (success path: search / answer / ingest_report) = 0 line.
|
||||
- 3 callsite-count + 1 arm count = expected (위 (d) (e)).
|
||||
- git status clean (single commit 후).
|
||||
- **Spec 참조**: §5.2 (회귀), §5.3 (cargo gate), §5.4 (SMOKE), §5.5 (wire diff 0 success + error path scope 외), §5.6 (integration delta 0), §3.7 (net effect).
|
||||
|
||||
## §3 Step dependency graph
|
||||
|
||||
각 step 의 ordering invariant + atomic block 정의:
|
||||
|
||||
```text
|
||||
Step 1 (baseline 측정 + wire snapshot)
|
||||
│ (변경 0 — single observation)
|
||||
▼
|
||||
Step 2 (App struct + extract_for shape + placeholder Vec::new())
|
||||
│ (additive — build green, dead-code warn 가능)
|
||||
▼
|
||||
Step 3 (registry init 11 entry + lib.rs:1235 alias 삭제)
|
||||
│ (init close — app.rs build green, lib.rs build red 시작 — atomic block 1 enter)
|
||||
▼
|
||||
Step 4 ─┐
|
||||
│ (lib.rs:356 local 제거 — intermediate build red)
|
||||
Step 5 ─┤ ATOMIC BLOCK 1 (Step 3-4-5-6 의 single commit)
|
||||
│ (ImagePipeline struct field 제거 — intermediate build red)
|
||||
Step 6 ─┘ (lib.rs:1296 callsite 교체 — atomic close, build green)
|
||||
│
|
||||
▼
|
||||
Step 7 (pdf callsite 교체 — atomic single-edit, build green)
|
||||
│ (independent of Step 4-6; image와 mutually independent)
|
||||
▼
|
||||
Step 8 (9 AST hoist — atomic single-region, build green)
|
||||
│ (independent of Step 4-7; code dispatch site 가 별 helper 함수)
|
||||
▼
|
||||
Step 9 (dead code 정리 — clippy clean, build green)
|
||||
│ (Step 6/7/8 모두 완료 후 unused use 식별 가능)
|
||||
▼
|
||||
Step 10 (unit tests 추가 — in-crate, Step 9 clippy clean 위에 작성)
|
||||
│
|
||||
▼
|
||||
Step 11 (회귀 + 7 cargo gate + wire diff + commit — closure)
|
||||
```
|
||||
|
||||
### §3.1 Atomic block 1 (Step 3-4-5-6) 의 invariant (round 1 Ambiguity #1 fix)
|
||||
|
||||
본 4 step 은 **single commit 단위로 묶임**. 중간 state 에서 build red 허용:
|
||||
|
||||
- Step 3 후: app.rs build green (registry init), lib.rs build red (lib.rs:1235 alias 삭제 → lib.rs:1296 의 `image_extractor` 가 unresolved).
|
||||
- Step 4 후: lib.rs:356 local 도 삭제 → 동일 red.
|
||||
- Step 5 후: ImagePipeline struct field 도 제거 → init block 의 missing-field error (Step 4 에서 이미 init line 제거되었지만 struct field 존재 시 동일).
|
||||
- Step 6 후: lib.rs:1296 callsite 교체 → atomic close, build green 보장.
|
||||
|
||||
**executor 가 Step 3-4-5-6 을 한 working session 안에서 진행 + Step 6 후에야 첫 `cargo build` 실행**. Step 3-5 단독 build 시도 금지.
|
||||
|
||||
### §3.2 mutually independent step (Step 6 / 7 / 8)
|
||||
|
||||
본 3 dispatch migration 은 lib.rs 의 3 다른 helper 함수 의 head — 어느 순서로 진행하든 동등. plan 의 ordering (image → pdf → code) = risk gradient reverse.
|
||||
|
||||
### §3.3 Step 9 의 의존성
|
||||
|
||||
Step 9 의 dead-code 식별이 Step 6 + 7 + 8 모두 완료된 후에야 가능. Step 9 는 Step 6-8 모두에 의존.
|
||||
|
||||
## §4 Verification gate (acceptance)
|
||||
|
||||
verifier 가 다음 모두 verify 후에만 plan 을 `status: completed`:
|
||||
|
||||
### §4.1 Cargo gate (7) + numeric delta gate
|
||||
|
||||
| gate | 명령 | 기대 |
|
||||
|---|---|---|
|
||||
| 1 | `cargo build --workspace -j 1` | `Finished` |
|
||||
| 2 | `cargo clippy --workspace --all-targets -j 1 -- -D warnings` | warn 0 |
|
||||
| 3 | `cargo test --workspace --no-fail-fast -j 1` | 모든 test pass |
|
||||
| 4 | numeric delta = `AFTER - BASELINE` | **= 3** (정확 매칭) |
|
||||
| 5 | `cargo tree -p kebab-app -e normal \| grep "kebab-parse-" \| wc -l` | **4** |
|
||||
| 6 | `cargo build --release` | `Finished` |
|
||||
| 7 | `cargo metadata --no-deps --format-version 1 \| jq '.workspace_members \| length'` | **22** |
|
||||
|
||||
### §4.2 Wire diff 0 (success path only)
|
||||
|
||||
| diff | source vs target | 기대 |
|
||||
|---|---|---|
|
||||
| 1 | `.omc/state/wire-baseline/search.json` vs after | 0 line |
|
||||
| 2 | `.omc/state/wire-baseline/answer.json` vs after | 0 line |
|
||||
| 3 | `.omc/state/wire-baseline/ingest_report.json` vs after | 0 line |
|
||||
|
||||
`schema_version` field = `*.v1` 유지. error path 의 wire diff 는 spec §5.5 의 risk acceptance — scope 외.
|
||||
|
||||
### §4.3 Callsite-count post-state (3)
|
||||
|
||||
| metric | grep | 기대 |
|
||||
|---|---|---|
|
||||
| polymorphic dispatch site | `grep -c "app.extract_for" lib.rs` | ≥ **3** |
|
||||
| 9 AST direct callsite | `grep -cE "(Rust\|...\|Cpp)AstExtractor::new\(\)\.extract" lib.rs` | **0** |
|
||||
| local image_extractor 잔존 | `grep -c "image_extractor" lib.rs` | **0** |
|
||||
|
||||
### §4.4 Code dispatch arm count
|
||||
|
||||
```bash
|
||||
$ awk '/let canonical_result.*= match code_lang/,/^ \};$/' lib.rs \
|
||||
| grep -cE "^\s+\"[^\"]+\"[^=>]*=>|^\s+other\s*=>"
|
||||
```
|
||||
기대: **4** (9-AST-group + manifest-group + shell + wildcard).
|
||||
|
||||
## §5 Commit strategy
|
||||
|
||||
### §5.1 Single clean commit
|
||||
|
||||
본 plan 전체 작업이 single commit 으로 closure. 이유 = (a) Step 3-4-5-6 의 atomic block 이 single commit 단위 강제, (b) wire schema 변경 0 + design contract 변경 0 → 하나의 logical change, (c) sub-item 1/2 (PR #185 / #186) 패턴 정합.
|
||||
|
||||
executor 가 Step 1-11 모두 완료 + verifier 가 §4 의 14 gate 통과 후 단일 commit. Step 별 partial commit 금지 (atomic block 1 의 build-red intermediate state 가 git history 에 들어가면 bisect 불가).
|
||||
|
||||
### §5.2 commit message
|
||||
|
||||
Step 11 의 (f) sub-action 의 template. CLAUDE.md commit style + Co-Authored-By 트레일러.
|
||||
|
||||
### §5.3 push + PR (out-of-plan)
|
||||
|
||||
`git push origin refactor/extractor-dispatch-unification` + gitea PR 생성은 team-lead 의 work — 본 plan 의 step 아님.
|
||||
|
||||
## §6 Risks + mitigation
|
||||
|
||||
### §6.1 Step 3-4-5-6 atomic block 의 intermediate build red
|
||||
|
||||
- **risk**: executor 가 Step 3 단독으로 `cargo build` 시도 시 build red.
|
||||
- **mitigation**: §3.1 의 atomic block 명시 — Step 3 → 4 → 5 → 6 한 working session 안에서 진행 + Step 6 후에야 첫 build verify.
|
||||
|
||||
### §6.2 Step 8 의 lang string source-of-truth mismatch
|
||||
|
||||
- **risk**: `app.extract_for(&asset.media_type, ...)` 의 `asset.media_type` vs `code_lang: &str` 의 source mismatch.
|
||||
- **mitigation**: `code_lang` 는 `ingest_one_code_asset` 의 8번째 arg (lib.rs:1903 signature). caller (`ingest_one_asset` lib.rs:961-1040) 가 `lang.as_str()` 으로 전달 → 동일 source. unit test (Step 10 grid-search) 가 disjoint 검증.
|
||||
|
||||
### §6.3 Step 8 의 Tier 1 → Tier 3 fallback control flow 단절 (Missing #1 trace)
|
||||
|
||||
- **risk**: lib.rs:2050 부근 `match canonical_result { Err(e) if ... => ... }` fallback 이 `app.extract_for(...)` 의 Err 와 동일 형태로 전파 안 됨.
|
||||
- **mitigation**: `app.extract_for` 의 body 가 `.extract(ctx, bytes)` 결과 그대로 반환 — Err 의 anyhow chain 형태 동일. `with_context(|| format!("kb-app::extract_for (code:{code_lang})"))` 의 outer context 추가가 root cause variant matching 영향 0 (`downcast_ref` 패턴). fallback 분기 `Err(e) if code_lang == "shell" || matches!(...)` 가 lang string 기반 — Err message 미사용. **검증** = Step 11 `cargo test --workspace` 의 `tests/p10_3_*.rs` (tier1 fail → tier3 recover) pass.
|
||||
|
||||
### §6.4 Step 11 의 wire diff > 0 (success path)
|
||||
|
||||
- **risk**: trait dispatch 의 vtable lookup 차이가 silent regression.
|
||||
- **mitigation**: `Box<dyn Extractor>` 의 `extract` 호출이 본질적으로 `*Extractor::extract` 와 동일 (Rust trait object dispatch semantic preservation). diff > 0 발생 시 §4.2 의 first mismatch line → `ExtractContext<'_>` lifetime 또는 `&MediaType` enum variant 비교 차이 식별.
|
||||
|
||||
### §6.5 Step 10 의 `App::open_with_config` SQLite migration cost
|
||||
|
||||
- **risk**: 3 unit test 가 각각 fresh tempdir + SQLite open + migration → test 무거움 (~수 초).
|
||||
- **mitigation**: light-weight constructor 신설 = spec §11 future work. test 시간 < 30s 면 acceptable.
|
||||
|
||||
### §6.6 Step 9 의 use statement 갱신 시 reference 보존 (round 1 MAJOR #6)
|
||||
|
||||
- **risk**: lib.rs:51 `use kebab_parse_image::{ImageExtractor, OllamaVisionOcr, apply_caption, apply_ocr};` 의 4 type 중 `ImageExtractor` 만 제거 — `OllamaVisionOcr / apply_caption / apply_ocr` 은 lib.rs 안에서 계속 사용.
|
||||
- **mitigation**: Step 9 (b) sub-action 의 명시 — `use kebab_parse_image::{OllamaVisionOcr, apply_caption, apply_ocr};` 갱신. 동일 패턴 for `kebab_parse_code` (`detect_repo` 같은 다른 type 의 fully-qualified call 은 use 갱신 영향 0).
|
||||
|
||||
### §6.7 error path wire scope (spec §5.5 risk acceptance)
|
||||
|
||||
- **risk**: `.context("...")` wording 변경이 `error.v1.message` 또는 `IngestReport.v1.items[].error` String 에 surface.
|
||||
- **mitigation**: spec §5.5 의 risk acceptance — internal Rust error chain wording 변경, `error.v1.code` 보존, message chain detail 은 user-visible surface 외. claude-code-skill / mcp consumer 의 wire contract 가 `error.v1.code` finite enumeration 의존 — message chain wording 의존 0. error path 의 wire diff 는 본 plan 의 success-path scope 외 (§4.2 의 3 diff 만 verify).
|
||||
|
||||
## §7 Out of scope (plan-level)
|
||||
|
||||
본 plan 이 다루지 않는 work — spec §2.2 의 non-goal inherit + plan-level deferred:
|
||||
|
||||
1. **markdown ingest path 의 변경** — MarkdownExtractor defer.
|
||||
2. **Chunker dispatch unification** — design §7.2 갱신 동반.
|
||||
3. **Tier 2/3 free-function path 의 Extractor 화**.
|
||||
4. **inner 4 위치 match 통합**.
|
||||
5. **outer 4-arm helper 통합**.
|
||||
6. **dual-source `parser_version` 정리**.
|
||||
7. **ExtractorRegistry plugin system**.
|
||||
8. **light-weight `App` constructor** (test 전용).
|
||||
9. **HOTFIXES.md / HANDOFF.md / ARCHITECTURE.md 갱신** — sibling pattern 따라 본 PR 머지 후 optional.
|
||||
10. **push + PR creation** — team-lead 의 work.
|
||||
11. **error path wire diff verify** — spec §5.5 risk acceptance.
|
||||
|
||||
## §8 Open questions
|
||||
|
||||
**없음**. round 1-2 의 모든 OQ 가 resolved (round 1 의 3 OQ + round 2 의 16 finding 모두 §9 closure table 에).
|
||||
|
||||
## §9 Round 1 finding closure status table
|
||||
|
||||
| round 1 finding | severity | source | closure 위치 |
|
||||
|---|---|---|---|
|
||||
| CRITICAL #1 (integration test → in-crate unit test) | CRITICAL | critic | Step 10 의 `crates/kebab-app/src/app.rs` 의 `#[cfg(test)] mod tests` 로 이동. `pub(crate)` access 보존. |
|
||||
| MAJOR #1 (workspace_root 타입/lifetime) | MAJOR | critic | Step 10 의 `let workspace_root: PathBuf = PathBuf::from("/tmp"); ... workspace_root: &workspace_root` — owned binding 후 borrow. |
|
||||
| MAJOR #2 (test_fixtures helper 부재) | MAJOR | critic | Step 10 의 `extract_for_unsupported_media_errors` 가 inline `kebab_core::RawAsset { ... }` 생성. Audio MediaType 사용으로 fixture 의존성 회피 (MAJOR #2 의 simpler suggestion 채택). |
|
||||
| MAJOR #3 (Step 4 retroactive 수정) | MAJOR | critic | Step 2 (a) 의 use statement 추가 + Step 2 (d) / Step 3 (a) 의 vec![] 부터 short-name 으로 작성. Step 4 의 option α/β 토론 삭제. |
|
||||
| MAJOR #4 (wire baseline cmd) | MAJOR | critic | Step 1 의 wire baseline snapshot section — `cargo run --release --bin kebab -- --config /tmp/kb-wire-baseline/config.toml ingest --json > ...` 의 falsifiable cmd 명시. Step 11 의 after snapshot 도 동일 cmd sequence. |
|
||||
| MAJOR #5 (Step 3 lib.rs:1235 alias 삭제) | MAJOR | critic | Step 3 (b) — `lib.rs:1235 의 alias line 을 본 step 에서 명시적 삭제`. ImagePipeline struct field 제거 (Step 5) 이전. |
|
||||
| MAJOR verifier #2 (error.v1 wire scope) | MAJOR | verifier | spec §5.5 갱신 — internal error context wording risk acceptance + plan §6.7 의 risk 명시. error path wire diff 는 success-path verify scope 외. |
|
||||
| MINOR #1 (`#[allow(dead_code)]` cleanup) | MINOR | critic | Step 9 (c) — 임시 attribute 의 cleanup checklist table. |
|
||||
| MINOR #2 (Step 1 awk doc-test 포함) | MINOR | critic | §0 의 "doc-test 포함 여부" 보강 — awk 의 `test result: ok.` 매칭이 doc-test 도 cover. baseline + after delta 보존. |
|
||||
| MINOR GAP #4 (Step 3 lib.rs:1235 alias edit) | MINOR | verifier | MAJOR #5 와 중복 — Step 3 (b). |
|
||||
| MINOR GAP #5 (arm count 13 → **12**) | MINOR | verifier | Step 8 + §4.4 + §1 approach summary — "12 (11 explicit + 1 wildcard) → 4 arm" 일관 명시. |
|
||||
| MINOR GAP #6 (instance-method pattern) | MINOR | verifier | Step 1 의 baseline grep 추가 — `image_extractor\.extract\|image_pipeline\.extractor\.extract`. |
|
||||
| MINOR GAP #7 (use-prefix policy) | MINOR | verifier | MAJOR #3 와 중복 — Step 2 (a) + Step 9 (b). |
|
||||
| MINOR GAP #8 (pub(crate) test access) | MINOR | verifier | CRITICAL #1 와 중복 — Step 10 의 in-crate test. |
|
||||
| MINOR GAP #9 (numeric net-delta gate) | MINOR | verifier | Step 11 (b) gate 4 — `BASELINE=$(cat ...); AFTER=$(awk ...); DELTA=$((...)); test "$DELTA" -eq 3 || exit 1`. |
|
||||
| NIT #1 (visibility wording 일관) | NIT | critic | CRITICAL #1 와 동시 — Step 10 의 in-crate test 가 spec §3.5 + §3.6 의 `pub(crate)` 와 정합. |
|
||||
| Missing #1 (Tier1→Tier3 fallback trace) | Missing | critic | Step 8 의 "후속 control flow 보존 trace" + §6.3 risk + Step 11 의 `tests/p10_3_*.rs` pass 검증. |
|
||||
| Missing #2 (open_with_config init order) | Missing | critic | Step 3 (a) — extractors init 위치 = `pipeline_verifier` 직전 (sqlite → search_cache → pipeline_verifier → extractors → Ok(Self)). |
|
||||
| Missing #3 (pipeline_verifier Err 시 extractors lifetime) | Missing | critic | Step 3 (a) 의 rationale — state-less, side-effect 0. drop cost 0. |
|
||||
| Missing #4 (Step 6 happy-path fixture path) | Missing | critic | Step 1 의 wire baseline section — `cp crates/kebab-app/src/lib.rs ...` + `cp README.md ...` 의 2-medium fixture 명시. PDF/PNG fixture 부재 시 §4.3 callsite-count 로 covered. |
|
||||
| Ambiguity #1 (ImagePipeline 제거 interpretation A vs B) | Ambiguity | critic | Step 3 (b) — "lib.rs:1235 alias 를 Step 3 에서 먼저 삭제, ImagePipeline struct + init block 은 Step 5 에서 재작성" 명시. Step 4 가 lib.rs:357 의 init block `extractor: &image_extractor,` 도 동시 삭제. |
|
||||
|
||||
### §9.1 Round closure status
|
||||
|
||||
| round | reviewer | mode | status | notes |
|
||||
|---|---|---|---|---|
|
||||
| 0 (drafting) | planner (self) | full | drafted | 11 step decompose. |
|
||||
| 1 | critic-plan (opus) | full | REQUEST_CHANGES | 1 CRITICAL + 5 MAJOR + 2 MINOR + 1 NIT + 4 Missing + 1 Ambiguity. |
|
||||
| 1 | verifier-plan (opus) | full | ACCEPT_WITH_RESERVATIONS | 3 MAJOR + 6 MINOR (overlap 일부). |
|
||||
| 2 (reflection) | planner (self) | full rewrite | reflected | 16 finding closure (위 status table). spec §5.5 + §7 갱신 동반 (MAJOR verifier #2). plan v2 → v3. |
|
||||
| 2 | critic-plan + verifier-plan (opus) | full | REQUEST_CHANGES (수렴 실패 보고) | 보고 = CRITICAL #1 NOT CLOSED + MAJOR #5 NOT CLOSED + MAJOR #6 PARTIAL. 단 round 3 의 grep cross-check 결과 = **CRITICAL #1 / MAJOR #5 모두 v3 에서 closure 완료** (Step 10 line 498-501 이 in-crate `mod tests`, RawAsset field 가 line 580-582 의 `checksum + stored` 정합, Step 3 (b) line 233-236 이 lib.rs:1235 alias 명시적 삭제). **round 2 critic/verifier report 가 v2 baseline 으로 misread** 한 false negative. MAJOR #6 만 실제 잔존 — spec §3.7 line 110/398/407 의 "13 arm" 잔존. |
|
||||
| 3 (reflection) | planner (self) | spec micro-patch | reflected | spec §3.7 의 "13 arm" 3 location → "12 arm" + "13 → 5" → "12 → 4" 정정 (round 3 의 유일한 actual 정정). plan §9 의 closure table 에 round 2 의 false-negative cross-check 결과 추가. |
|
||||
| 4 | critic-plan (sonnet) | **closure verify only** | pending | round 3 의 spec micro-patch + round 2 의 v3 plan content 가 round 1 finding 모두 closure 검증. grep cross-check 가 효율적. |
|
||||
| 4 | verifier-plan (sonnet) | closure verify only | pending | 동일. |
|
||||
| 5+ | as needed | — | pending | — |
|
||||
|
||||
### §9.2 Round 2 의 false-negative finding 의 grep cross-check
|
||||
|
||||
round 2 의 critic + verifier 양쪽이 100% 일치 finding 보고 — "CRITICAL #1 NOT CLOSED + MAJOR #5 NOT CLOSED" — 단 v3 plan 의 actual content 와 mismatch. round 3 의 grep evidence:
|
||||
|
||||
| round 2 finding | v3 plan actual | verdict |
|
||||
|---|---|---|
|
||||
| CRITICAL #1 — "test 가 여전히 tests/extract_for_dispatch.rs (integration)" | line 498 = `### Step 10: unit tests 추가 (in-crate #[cfg(test)] mod tests in app.rs)` + line 501 = `crates/kebab-app/src/app.rs (단일 — 기존 impl App { ... } 의 아래에 #[cfg(test)] mod tests { ... } 추가)` | **false-negative** — v3 가 이미 in-crate. v2 baseline 으로 misread. |
|
||||
| CRITICAL #1 — "RawAsset 의 content_hash 잔존" | line 580 = `checksum: kebab_core::Checksum("00".repeat(32))` + line 582 = `stored: kebab_core::AssetStorage::Inline` | **false-negative** — v3 가 이미 actual asset.rs:63-73 의 field name 정합 (checksum + stored). v2 의 content_hash 오기는 round 2 reflection 에서 정정 완료. |
|
||||
| MAJOR #5 — "lib.rs:1235 alias 삭제 의무 부재" | line 233-236 = `(b) lib.rs:1235 의 alias line 삭제 ... `let image_extractor = image_pipeline.extractor;` ← 삭제` + Ambiguity #1 closure (line 246) 이 atomic block ordering 명시 | **false-negative** — v3 의 Step 3 (b) 가 이미 명시. team-lead 가 가정한 "Step 5 (b)" position 은 본 plan 의 sequencing 과 다름 (atomic block 1 의 Step 3 = alias 삭제, Step 5 = struct field 제거 — interpretation A). |
|
||||
| MAJOR #6 — "spec §3.7 + plan §3.7 narrative 의 13/5 잔존" | spec line 110, 398, 407 = "13 arm cover 17 lang" / "13 → 5 arm" 잔존 (round 3 정정 대상) + plan §3.7 narrative = grep 결과 0 hit (v3 이미 12/4 일관 — Step 8 + §4.4 + summary) | **partial** — spec 만 정정 필요. round 3 의 spec 3 site edit 으로 closure. |
|
||||
|
||||
cross-check 결론: round 2 의 critic + verifier 의 100% 일치 finding 의 2 항목 (CRITICAL #1, MAJOR #5) 이 v2 baseline mis-read. plan v3 의 actual content 가 round 1 finding 의 closure 를 이미 정합. round 3 의 단일 정정 = spec §3.7 의 "13 arm" 3 site → "12 arm (11 explicit + 1 wildcard)" + "12 → 4 arm" 정정.
|
||||
|
||||
→ Phase C (executor opus) 진입 준비됨.
|
||||
@@ -0,0 +1,702 @@
|
||||
---
|
||||
status: drafting
|
||||
target_version: 0.18.0 # 0.18.0 release 의 후속 internal-refactor PR — workspace.version bump 없음 (CLAUDE.md §Release 룰 3 트리거 미충족: frozen design contract 변경 0, wire schema 변경 0, V00X migration 0).
|
||||
contract_sections: [] # design §7.2 의 Extractor trait 정의가 이미 `supports(&MediaType)` 포함 — trait surface 변경 0. §8 dep graph 변경 0. 갱신 필요한 frozen section 없음.
|
||||
related_specs:
|
||||
- docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
- docs/superpowers/specs/2026-05-26-source-fs-dep-lightening-spec.md # sibling sub-item 1 (PR #185 merged)
|
||||
- docs/superpowers/specs/2026-05-26-normalize-absorption-spec.md # sibling sub-item 2 (PR #186 merged)
|
||||
related_plans: []
|
||||
hotfix_links: []
|
||||
---
|
||||
|
||||
# kebab-app 의 AST 9-arm extract dispatch 통합 — `*Extractor::new().extract(…)` → `app.extract_for(...)` polymorphic dispatch
|
||||
|
||||
## §1 Background + evidence chain
|
||||
|
||||
### §1.1 현재 `Extractor` trait + impl 위치 (investigation step 1, 3)
|
||||
|
||||
`crates/kebab-core/src/traits.rs:115-122` 의 trait 정의 인용 (round 1 CRITICAL #2 보강: design `:1416-1420` 의 `Result<>` + elided lifetime 약식 표기와 semantically identical 이지만 syntactic byte-identical 은 아님 — 어느 쪽도 본 refactor 가 변경하지 않음):
|
||||
|
||||
```rust
|
||||
pub trait Extractor: Send + Sync {
|
||||
fn supports(&self, media_type: &MediaType) -> bool;
|
||||
fn parser_version(&self) -> ParserVersion;
|
||||
fn extract(
|
||||
&self,
|
||||
ctx: &ExtractContext<'_>,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<CanonicalDocument>;
|
||||
}
|
||||
```
|
||||
|
||||
핵심 사실: `supports(&MediaType) -> bool` 가 **이미 trait method 로 존재**한다. 본 refactor 가 새 method 를 추가하는 것이 아니라, 이미 존재하는 polymorphic surface 를 활용하지 못하고 있는 dead polymorphism 상태를 부분 해소한다.
|
||||
|
||||
production `impl Extractor for ...` 11곳 (`grep -rn "impl.*Extractor for\|impl Extractor for" crates/ --include="*.rs"` 결과):
|
||||
|
||||
| crate | type | 위치 | `supports()` 조건 |
|
||||
|---|---|---|---|
|
||||
| `kebab-parse-image` | `ImageExtractor` | `src/lib.rs:69` | `matches!(m, MediaType::Image(_))` |
|
||||
| `kebab-parse-pdf` | `PdfTextExtractor` | `src/lib.rs:51` | `matches!(m, MediaType::Pdf)` |
|
||||
| `kebab-parse-code` | `RustAstExtractor` | `src/rust.rs:53` | `matches!(m, MediaType::Code(l) if l == "rust")` |
|
||||
| `kebab-parse-code` | `PythonAstExtractor` | `src/python.rs:49` | `matches!(m, MediaType::Code(l) if l == "python")` |
|
||||
| `kebab-parse-code` | `TypescriptAstExtractor` | `src/typescript.rs:59` | `… "typescript"` |
|
||||
| `kebab-parse-code` | `JavascriptAstExtractor` | `src/javascript.rs:66` | `… "javascript"` |
|
||||
| `kebab-parse-code` | `GoAstExtractor` | `src/go.rs:51` | `… "go"` |
|
||||
| `kebab-parse-code` | `JavaAstExtractor` | `src/java.rs:61` | `… "java"` |
|
||||
| `kebab-parse-code` | `KotlinAstExtractor` | `src/kotlin.rs:66` | `… "kotlin"` |
|
||||
| `kebab-parse-code` | `CAstExtractor` | `src/c.rs:52` | `… "c"` |
|
||||
| `kebab-parse-code` | `CppAstExtractor` | `src/cpp.rs:76` | `… "cpp"` |
|
||||
|
||||
**누락**: `kebab-parse-md` 는 `impl Extractor` 가 **없다**. Markdown 의 ingest path 는 `parse_frontmatter` / `parse_blocks` / `build_canonical_document` 세 자유 함수의 직접 호출로 처리된다 (lib.rs:1085-1118). 본 refactor 는 round 1 reflection 의 MAJOR #2 Option (ii) 채택에 따라 **`MarkdownExtractor` 신설을 별 PR 로 defer** — 본 PR scope 는 AST 9-arm extract dispatch only. §2 + §3.4 + §11 참조.
|
||||
|
||||
### §1.2 현재 `Chunker` trait + impl 위치 (investigation step 2, 4)
|
||||
|
||||
`crates/kebab-core/src/traits.rs:125-132` 의 trait 정의 인용:
|
||||
|
||||
```rust
|
||||
pub trait Chunker: Send + Sync {
|
||||
fn chunker_version(&self) -> ChunkerVersion;
|
||||
fn policy_hash(&self, policy: &ChunkPolicy) -> String;
|
||||
fn chunk(
|
||||
&self,
|
||||
doc: &CanonicalDocument,
|
||||
policy: &ChunkPolicy,
|
||||
) -> anyhow::Result<Vec<Chunk>>;
|
||||
}
|
||||
```
|
||||
|
||||
핵심 사실: `Chunker` trait 은 **`supports()` 또는 그에 준하는 dispatch discriminator method 가 없다**. Extractor 와 비대칭. 본 refactor 가 Chunker 까지 polymorphic dispatch 로 통합하려면 trait 에 새 method 신설이 필요하고, design §7.2 의 trait 정의 갱신 (= frozen contract 갱신) 도 필요해진다. → 별 PR scope (§8 / §11).
|
||||
|
||||
production `impl Chunker for ...` 15곳 — `kebab-chunk` 한 crate 안에서 다음 15 type:
|
||||
|
||||
| 위치 | type | 적용 lang/media |
|
||||
|---|---|---|
|
||||
| `src/md_heading_v1.rs:77` | `MdHeadingV1Chunker` | Markdown |
|
||||
| `src/pdf_page_v1.rs:76` | `PdfPageV1Chunker` | PDF |
|
||||
| `src/code_rust_ast_v1.rs:30` | `CodeRustAstV1Chunker` | code:rust |
|
||||
| `src/code_python_ast_v1.rs:30` | `CodePythonAstV1Chunker` | code:python |
|
||||
| `src/code_ts_ast_v1.rs:30` | `CodeTsAstV1Chunker` | code:typescript |
|
||||
| `src/code_js_ast_v1.rs:30` | `CodeJsAstV1Chunker` | code:javascript |
|
||||
| `src/code_go_ast_v1.rs:30` | `CodeGoAstV1Chunker` | code:go |
|
||||
| `src/code_java_ast_v1.rs:30` | `CodeJavaAstV1Chunker` | code:java |
|
||||
| `src/code_kotlin_ast_v1.rs:30` | `CodeKotlinAstV1Chunker` | code:kotlin |
|
||||
| `src/code_c_ast_v1.rs:30` | `CodeCAstV1Chunker` | code:c |
|
||||
| `src/code_cpp_ast_v1.rs:30` | `CodeCppAstV1Chunker` | code:cpp |
|
||||
| `src/code_text_paragraph_v1.rs:25` | `CodeTextParagraphV1Chunker` | code:shell + Tier 3 fallback |
|
||||
| `src/manifest_file_v1.rs:18` | `ManifestFileV1Chunker` | toml/json/xml/groovy/go-mod |
|
||||
| `src/dockerfile_file_v1.rs:17` | `DockerfileFileV1Chunker` | dockerfile |
|
||||
| `src/k8s_manifest_resource_v1.rs:18` | `K8sManifestResourceV1Chunker` | yaml |
|
||||
|
||||
### §1.3 kebab-app hardcoded callsite enumeration (investigation step 5)
|
||||
|
||||
`grep -nE "match.*code_lang|ImageExtractor::|PdfTextExtractor::|MarkdownParser::|kebab_parse_(md|pdf|image|code)::" crates/kebab-app/src/lib.rs` 결과 중 본 refactor 가 건드릴 site (use statement / version constant 인용 제외):
|
||||
|
||||
| line | site | 종류 | 본 PR 변경 여부 |
|
||||
|---|---|---|---|
|
||||
| `51` | `use kebab_parse_image::{ImageExtractor, OllamaVisionOcr, apply_caption, apply_ocr};` | use 선언 | **유지** (registry 가 동일 type 을 Box 로 감싸므로 use 필요) |
|
||||
| `52` | `use kebab_parse_code::{CAstExtractor, …, TypescriptAstExtractor};` (9 type) | use 선언 | **유지** (registry init 에서 9 type 모두 instantiate) |
|
||||
| `53` | `use kebab_parse_pdf::PdfTextExtractor;` | use 선언 | **유지** |
|
||||
| `54` | `use kebab_parse_md::{BodyHints, build_canonical_document, parse_blocks, parse_frontmatter};` | use 선언 | **유지** (MarkdownExtractor defer — 자유 함수 그대로) |
|
||||
| `356` | `let image_extractor = ImageExtractor::new();` | App init (local var) | **제거** (MAJOR #4 의 Option c) |
|
||||
| `1089` | `parse_frontmatter(&bytes, &body_hints)` | Markdown ingest path | **변경 0** (MarkdownExtractor defer) |
|
||||
| `1097` | `parse_blocks(&bytes[fm_span_end(fm_span)..], body_offset_lines)` | Markdown ingest path | **변경 0** |
|
||||
| `1111` | `build_canonical_document(asset, …)` | Markdown ingest path | **변경 0** |
|
||||
| `1296` | `image_extractor.extract(&ctx, &bytes)` | Image ingest path (instance call) | **교체** → `app.extract_for(&asset.media_type, &ctx, &bytes)?` |
|
||||
| `1783` | `let mut canonical = PdfTextExtractor::new().extract(&ctx, &bytes)` | PDF ingest path (typed) | **교체** → `app.extract_for(&asset.media_type, &ctx, &bytes)?` |
|
||||
| `1935-1953` | `let parser_version = match code_lang { … }` (11 explicit arms cover 17 lang) | code parser_version 결정 | **변경 0** (Chunker registry + Tier 2/3 통합과 묶임 — 별 PR) |
|
||||
| `1955-1974` | `let mut chunker_version = match code_lang { … }` (11 explicit arms cover 17 lang) | code chunker_version 결정 | **변경 0** |
|
||||
| `1979-1988` | `let tier3_fallback_cv: Option<ChunkerVersion> = match code_lang { … }` (2 arm: positive 16-lang sum + `_ => None`) | tier3 fallback CV 결정 | **변경 0** |
|
||||
| `2012-2049` | `let canonical_result = match code_lang { "rust" => RustAstExtractor::new().extract(…), …, "yaml" \| … => synthesize_tier2_document(…), "shell" => synthesize_tier2_document(…), "c" \| "cpp" => *AstExtractor::new().extract(…) }` (12 arm = 11 explicit + 1 wildcard, cover 17 lang) | code extract dispatch | **부분 교체** — 9 AST arm 의 `*Extractor::new().extract(…)` → `app.extract_for(&asset.media_type, &ctx, &bytes)?` 단일 호출 (위로 hoist). 7 manifest + 1 shell arm 의 `synthesize_tier2_document(…)` 는 유지 (Extractor 아님). |
|
||||
| `2087-2128` | `match code_lang { "rust" => CodeRustAstV1Chunker.chunk(…), …, "shell" => CodeTextParagraphV1Chunker.chunk(…), "c" \| "cpp" => Code*AstV1Chunker.chunk(…) }` (14 explicit arms) | code chunk dispatch | **변경 0** (Chunker registry 별 PR) |
|
||||
|
||||
### §1.4 lang 분기 정확한 count + 17 code_lang 값 (investigation step 6, 12)
|
||||
|
||||
round 1 MAJOR #5 보강: `crates/kebab-app/src/lib.rs:1009-1013` 의 outer guard 가 다음 17 lang 을 enumerate (인용):
|
||||
|
||||
```rust
|
||||
MediaType::Code(lang)
|
||||
if matches!(lang.as_str(),
|
||||
"rust" | "python" | "typescript" | "javascript" | "go" | "java" | "kotlin"
|
||||
| "yaml" | "dockerfile" | "toml" | "json" | "xml" | "groovy" | "go-mod"
|
||||
| "shell" | "c" | "cpp")
|
||||
=> return ingest_one_code_asset(…),
|
||||
```
|
||||
|
||||
- **AST lang 9개**: rust / python / typescript / javascript / go / java / kotlin / c / cpp — 각 lang 의 `*AstExtractor` + `Code*AstV1Chunker` 호출.
|
||||
- **Tier 2 (manifest) lang 7개**: yaml / dockerfile / toml / json / xml / groovy / go-mod — `synthesize_tier2_document` (free function) + chunker (K8sManifestResourceV1 / DockerfileFileV1 / ManifestFileV1) 호출.
|
||||
- **Tier 3 lang 1개**: shell — `synthesize_tier2_document(..., "shell", ...)` + `CodeTextParagraphV1Chunker` 호출.
|
||||
|
||||
총 **17 code_lang**. AST lang 9개만 Extractor impl 이 있고, Tier 2/3 의 8 lang 은 Extractor impl 없이 `synthesize_tier2_document` 라는 자유 함수가 대신 emit 한다. 본 PR 의 scope = **9 AST arm only**.
|
||||
|
||||
### §1.5 ingest entry point + first dispatch (investigation step 8)
|
||||
|
||||
`kebab-app::ingest_with_config*` 의 entry chain (lib.rs:219 / :234 / :250 / :281 / :720) 모두 동일한 inner loop (`ingest_one_asset` per asset) 로 수렴. `ingest_one_asset` 의 lib.rs:961-1040 head 가 **first dispatch** — `match &asset.media_type` 의 4-arm (Markdown 자체 fall-through / Image / Pdf / Code(lang) + 1-arm catch-all skip).
|
||||
|
||||
본 dispatch 는 **2-layer** 구조:
|
||||
|
||||
1. **outer dispatch** — `ingest_one_asset` 의 `match &asset.media_type` (4-arm + 1-skip). **본 PR 에서 그대로 유지** — helper 함수 분기 (`ingest_one_image_asset` / `ingest_one_pdf_asset` / `ingest_one_code_asset`) 가 each medium 의 post-extract pipeline (OCR / page-chunker / tier3-fallback / try-skip-unchanged) 을 들고 있어서 통합 비용이 큼. 별 PR scope.
|
||||
2. **inner dispatch** — `ingest_one_code_asset` 안의 5 위치 `match code_lang` (위 §1.3 의 5 site). **본 PR 에서 lib.rs:2012-2049 의 9 AST arm 만 polymorphic 교체**. 나머지 4 위치 (parser_version / chunker_version / tier3_fallback_cv / chunk dispatch) 는 Chunker registry + Tier 2/3 통합과 묶여 별 PR.
|
||||
|
||||
### §1.6 App struct 현재 state (investigation step 9)
|
||||
|
||||
`crates/kebab-app/src/app.rs:115` 의 struct 인용. App 의 lifecycle 은 `App::open_with_config(config) -> Result<Self>` 에서 시작, SQLite store 를 open + migrate 한 뒤 embedder/vector/llm 은 lazy `OnceLock` 으로 deferred init (round 1 MINOR #1 보강 — App 의 lifecycle 의 lazy/eager 라인을 명시):
|
||||
|
||||
```rust
|
||||
pub struct App {
|
||||
pub(crate) config: kebab_config::Config,
|
||||
pub(crate) sqlite: Arc<SqliteStore>,
|
||||
embedder: OnceLock<Arc<dyn Embedder + Send + Sync>>, // lazy
|
||||
vector: OnceLock<Arc<LanceVectorStore>>, // lazy
|
||||
llm: OnceLock<Arc<dyn LanguageModel>>, // lazy
|
||||
search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>,
|
||||
pipeline_verifier: Option<Arc<dyn kebab_nli::NliVerifier>>, // eager
|
||||
}
|
||||
```
|
||||
|
||||
기존 trait-object pattern 은 **단일 `Arc<dyn Trait>`** (`embedder` / `llm` / `pipeline_verifier`). `Vec<Box<dyn Extractor>>` 는 새 패턴 — 다중 trait-object collection. `OnceLock` 으로 lazy init 할 필요 없음 — 모든 11 Extractor impl 이 state-less (§1.7).
|
||||
|
||||
`ingest_with_config*` 는 `App::open_with_config` 를 통해 인스턴스를 얻은 뒤 inner ingest loop 를 돈다. App field 에 registry 가 들어가면 `ingest_one_*_asset` 의 `app: &App` 인자를 통해 자동으로 접근 가능 — 추가 wiring 0.
|
||||
|
||||
### §1.7 state-less Extractor 확인 (investigation step 7 보강)
|
||||
|
||||
모든 11개 Extractor impl 의 `new()` signature 가 `pub fn new() -> Self` 이고 struct body 는 unit-struct 또는 zero-field. 인용 예 (`crates/kebab-parse-pdf/src/lib.rs:51-58`):
|
||||
|
||||
```rust
|
||||
pub struct PdfTextExtractor;
|
||||
impl PdfTextExtractor {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
impl Default for PdfTextExtractor { fn default() -> Self { Self::new() } }
|
||||
```
|
||||
|
||||
`ImageExtractor` / `RustAstExtractor` / … 동일 패턴. `OllamaVisionOcr` 는 LLM client 를 들고 있는 state-ful type 이지만 **Extractor 가 아니다** — OCR adapter (별 trait). 본 refactor 의 registry 는 Extractor 만 담는다 — 모든 entry 가 state-less + zero-cost `new()` → init 비용 사실상 0.
|
||||
|
||||
### §1.8 design contract 영향 (investigation step 10) + referencing task spec (investigation step 11)
|
||||
|
||||
design `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 의 영향 분석:
|
||||
|
||||
- **§7.2 (트레잇)**: `:1416-1420` 의 `Extractor` trait 정의가 이미 `supports(&MediaType)` 포함 — design 의 약식 표기 (`Result<>` + elided lifetime) 가 actual `crates/kebab-core/src/traits.rs:115-122` 의 `anyhow::Result<>` + `ExtractContext<'_>` 와 semantically identical. 어느 쪽도 본 refactor 가 변경하지 않음. **갱신 불필요**.
|
||||
- **§8 (모듈 경계)**: dep graph 의 `kebab-app -> kebab-parse-md / kebab-parse-pdf / kebab-parse-image / kebab-parse-code` 4 line 그대로. 본 refactor 가 새 crate 를 추가하거나 기존 crate dep 를 끊지 않음. **갱신 불필요**.
|
||||
- **§6 (filesystem layout)**: 본 refactor 와 관련 0 — workspace path / config / XDG 영향 0. **갱신 불필요**.
|
||||
|
||||
frozen task spec 의 영향 (`grep -rln "Extractor\|Chunker" tasks/`):
|
||||
|
||||
- `tasks/phase-{0,1,6,7,8}-*.md`, `tasks/p0/p0-1-skeleton.md`, `tasks/p1/p1-5-chunk.md`, `tasks/p6/p6-{1,4}*.md`, `tasks/p7/p7-{1,2,3}*.md`, `tasks/p8/p8-{1,2}*.md`, `tasks/p10/*.md`, `tasks/p3/p3-5-app-wiring.md`, `tasks/p5/p5-2-metrics-compare.md` — 모두 frozen historical contract. 본 refactor 가 trait signature 변경 0 + impl 추가 0 (MarkdownExtractor defer) → frozen task spec 의 contract 침범 0.
|
||||
|
||||
**결론**: design contract 변경 0, frozen task spec 변경 0 → `contract_sections: []` + `target_version: 0.18.0` (workspace.version bump 불필요).
|
||||
|
||||
### §1.9 ARCHITECTURE.md 의 dispatch flow 묘사 부재 (round 1 Missing 1)
|
||||
|
||||
`grep -n "dispatch\|registry\|polymorphic\|ingest flow" docs/ARCHITECTURE.md` 결과 = `line 25` 의 "code parser" table 의 chunker / parser version 묘사만 존재. **"ingest dispatch flow" section 없음**. → 본 PR 이 ARCHITECTURE.md 의 dispatch 묘사를 신설하지 않는 것이 정합 (변경 0). 단 line 25 의 code parser table 의 wording 은 그대로 — 본 refactor 가 parser/chunker family 를 건드리지 않으므로.
|
||||
|
||||
---
|
||||
|
||||
## §2 Goals + non-goals
|
||||
|
||||
### §2.1 Goals
|
||||
|
||||
1. **inner AST 9-arm extract dispatch 통합** — `ingest_one_code_asset` 의 lib.rs:2012-2049 9 AST arm 의 `*Extractor::new().extract(…)` 호출을 `app.extract_for(&asset.media_type, &ctx, &bytes)?` 단일 polymorphic call 로 교체. outer 4-arm match (helper 분기) + Tier 2/3 의 `synthesize_tier2_document` free-function path + Chunker dispatch 는 §2.2 / §2.3 의 non-goal.
|
||||
2. **image / pdf path 의 hardcoded Extractor 호출 교체** — lib.rs:1296 (`image_extractor.extract(…)`) + lib.rs:1783 (`PdfTextExtractor::new().extract(…)`) 두 callsite 를 동일 `app.extract_for(&asset.media_type, &ctx, &bytes)?` 단일 호출로 교체. lib.rs:356 의 local `image_extractor` 변수 + `ImagePipeline.extractor` field 모두 제거 (§3.5.1 의 Option c).
|
||||
3. **`Vec<Box<dyn Extractor>>` registry 도입** — `App` 에 새 field `extractors: Vec<Box<dyn Extractor + Send + Sync>>` 추가. `App::open_with_config` 에서 11 Extractor impl (ImageExtractor + PdfTextExtractor + 9 AST) 등록. `App::extract_for(&MediaType, &ExtractContext, &[u8]) -> Result<CanonicalDocument>` helper method 추가.
|
||||
4. **wire schema 변경 0** — `CanonicalDocument` / `IngestReport.v1` / `error.v1` 출력 byte-identical. `IngestItem.warnings` (round 1 MAJOR #2 의 risk) 의 channel 보존 — markdown path 가 본 PR 에서 변경되지 않으므로 risk 자동 해소. `--json` smoke 의 diff = 0.
|
||||
5. **workspace.version bump 불필요** — frozen design contract 변경 0, wire schema 변경 0, V00X migration 0 → CLAUDE.md §Release 룰 3 트리거 미충족.
|
||||
6. **workspace test net delta = small positive** — 현재 baseline 1313 test 가 본 refactor 후 1313 + N (registry init 의 mutually-exclusive `supports()` grid + `App::extract_for` 의 4-medium happy-path unit test 만큼). 기존 ingest happy path test 가 byte-identical pass.
|
||||
|
||||
### §2.2 Non-goals
|
||||
|
||||
1. **MarkdownExtractor 신설** — round 1 MAJOR #2 Option (ii) 채택. `IngestItem.warnings` channel 의 `parse_frontmatter` + `parse_blocks` warning sink 가 `MarkdownExtractor::extract(&ExtractContext, &[u8]) -> Result<CanonicalDocument>` signature 에는 흐를 수 없는 구조 — `CanonicalDocument.provenance` 의 ProvenanceEvent 는 WarningKind enum 의 Debug 형식을 보존 안 함. wire schema diff 0 보장 위해 markdown path 를 그대로 유지하고 MarkdownExtractor 는 별 PR 에서 처리 (§11). **즉 `kebab-parse-md` 는 본 PR 에서 변경 0**.
|
||||
2. **ExtractorRegistry 별 type / plugin system** — `App` field 가 아닌 별도 `ExtractorRegistry` struct + dynamic-loading hook 의 도입은 본 PR 의 scope 가 아니다 (Option B in §3.1). future defer.
|
||||
3. **enum-based dispatch** — `enum AnyExtractor { Md, Pdf, … }` 의 zero-cost static dispatch (Option C in §3.1) 는 trait polymorphism 의 의도와 conflict — 본 PR 의 scope 가 아니다.
|
||||
4. **Tier 2/3 free-function path 의 Extractor 화** — `synthesize_tier2_document` 의 7 manifest + 1 shell lang 의 Extractor impl 승격은 별 PR.
|
||||
5. **Chunker dispatch unification** — `Chunker` trait 에 `supports()` 신설 + `App.chunkers` registry 도입은 design §7.2 갱신 동반. 별 spec + 별 PR (`2026-05-?? -chunker-dispatch-unification-spec.md` follow-up — §11).
|
||||
6. **inner 4 위치 match 의 polymorphic 통합** — parser_version (lib.rs:1935-1953) / chunker_version (1955-1974) / tier3_fallback_cv (1979-1988) / chunk dispatch (2087-2128) 의 통합. parser_version 은 Extractor::parser_version() method 로 가져올 수 있지만 Tier 2/3 의 sentinel `"none-v1"` 가 hardcoded → free-function path 의 Extractor 화 (#4) 와 묶여야 함.
|
||||
|
||||
### §2.3 Scope 축소 이유 (round 1 MAJOR #2)
|
||||
|
||||
본 refactor 의 mission 은 "dead polymorphism 해소". 본 PR scope = "AST 9-arm extract dispatch + image + pdf extract callsite" 의 polymorphic 교체. 이것만으로:
|
||||
|
||||
- Extractor trait 의 `supports()` 가 실제 호출되어 polymorphism 이 살아난다 (lib.rs:1296 / :1783 / :2012-2049 의 9 AST arm 의 11 callsite 가 단일 `app.extract_for(...)` 로 수렴).
|
||||
- `App.extractors` registry 가 도입되어 향후 (a) MarkdownExtractor 추가, (b) Chunker registry, (c) Tier 2/3 Extractor 화 등의 follow-up 시 확장 지점이 명확해진다.
|
||||
- wire schema diff 0 (markdown warning channel 미손) + design contract 변경 0 (Chunker / MarkdownExtractor defer) → release cycle 영향 0.
|
||||
|
||||
markdown / inner-4-match / Chunker / Tier 2/3 통합은 모두 별 PR 에서 처리하는 편이 (a) risk 분리, (b) review surface 축소, (c) design §7.2 갱신 동반 시 별 release cycle 정합. §11 future work 에 명시.
|
||||
|
||||
---
|
||||
|
||||
## §3 Design
|
||||
|
||||
### §3.1 Destination = Option A (`App` 의 field)
|
||||
|
||||
3 option 비교:
|
||||
|
||||
| option | 설명 | trade-off |
|
||||
|---|---|---|
|
||||
| **A. `App.extractors: Vec<Box<dyn Extractor>>`** | App field 로 11 impl 등록. dispatch = `app.extractors.iter().find(\|e\| e.supports(media)).ok_or_else(...)?.extract(&ctx, &bytes)`. | + 가장 단순 + 변경 surface 최소.<br>− App field 증가 (1 line).<br>− registry 의 ownership 이 App 에 강결합. |
|
||||
| **B. 별 `ExtractorRegistry` struct** | App 의 field 가 아닌 별도 type. App 이 owner 인 점은 동일하지만 type 이 분리. | + 미래 plugin 가능성 (defer).<br>− 본 PR 의 변경 surface 증가 (새 type + 새 file).<br>− 현재 caller 가 App 단일 — 분리 가치 0. |
|
||||
| **C. enum-based dispatch** | `enum AnyExtractor { Md, Pdf, … }` + static match. | + zero-cost dispatch.<br>− trait polymorphism 의 의도와 conflict.<br>− 신 Extractor 추가 = enum variant 변경 (API 표면 확대). |
|
||||
|
||||
**결정: Option A**. 근거 = §1.6 의 App single-owner pattern + §1.7 의 state-less Extractor 사실. `Vec<Box<dyn Extractor + Send + Sync>>` 가 정합.
|
||||
|
||||
trait object vtable overhead 의 performance 측면: dispatch 가 per-asset 1회 (extract 안의 hot loop 0 회) → 측정 불가 수준. ingest throughput 영향 0.
|
||||
|
||||
### §3.2 `Extractor` trait surface — 변경 0
|
||||
|
||||
`crates/kebab-core/src/traits.rs` 의 `Extractor` trait 정의 변경 **불필요**. 이미 `supports(&MediaType)` + `parser_version()` + `extract(&ExtractContext, &[u8])` 의 3 method 가 충분.
|
||||
|
||||
**critical invariant**: trait byte-identical 보존. trait file (`crates/kebab-core/src/traits.rs`) 의 변경 0 — 만일 trait 갱신이 발생하면 sub-item 2 의 CRITICAL #1 (trait signature drift) 재발 risk. 본 PR 의 diff 에서 `crates/kebab-core/src/traits.rs` 가 변경되면 안 됨 (verifier 검증 지점).
|
||||
|
||||
### §3.3 기존 11 Extractor impl — 변경 0
|
||||
|
||||
`ImageExtractor` / `PdfTextExtractor` / 9 `*AstExtractor` 의 `impl Extractor for ...` block 전체 변경 **불필요**. `supports()` / `parser_version()` / `extract()` 의 3 method 가 이미 구현되어 있고 (§1.1 의 grep 결과), 본 refactor 가 호출 site 만 변경.
|
||||
|
||||
**critical invariant**: 11 impl 의 method body byte-identical 보존. lib.rs 의 callsite 가 변하더라도 Extractor impl 의 결과 (`CanonicalDocument` 의 모든 field) 가 동일해야 wire schema diff 0 보장.
|
||||
|
||||
### §3.4 `MarkdownExtractor` 신설 — 본 PR 에서 defer (round 1 MAJOR #2 Option (ii))
|
||||
|
||||
본 PR 에서 `MarkdownExtractor` 를 **신설하지 않는다**. 근거:
|
||||
|
||||
`lib.rs:1085-1118` 의 markdown ingest path 가 `parse_frontmatter` + `parse_blocks` 의 `Vec<Warning>` 두 stream 을 합쳐 다음 두 sink 로 분배:
|
||||
|
||||
1. **`warning_notes: Vec<String>`** (lib.rs:1100-1109) → `IngestItem.warnings` (wire `ingest_report.v1.IngestItem.warnings`).
|
||||
2. **`all_warnings: Vec<Warning>`** → `build_canonical_document(asset, metadata, blocks, parser_version, warnings)` (crates/kebab-parse-md/src/normalize.rs:60-65 의 signature 인용 — `warnings: Vec<Warning>` 의 5번째 arg).
|
||||
|
||||
Pattern β 의 `MarkdownExtractor::extract(&ExtractContext, &[u8]) -> Result<CanonicalDocument>` signature 는 `Vec<Warning>` 의 caller-visible channel 이 없음. `CanonicalDocument.provenance` 의 ProvenanceEvent 로 만들어도 wire schema 의 `IngestItem.warnings` 필드와 다른 형태 + WarningKind enum 의 Debug 출력 (`format!("{:?}: {}", w.kind, w.note)`) 보존 mechanic 미흡 → wire diff > 0 risk.
|
||||
|
||||
본 PR 의 §2.1 #4 (wire schema 변경 0) 와 모순 → MarkdownExtractor 신설은 **별 PR 로 defer**. 별 PR 에서 처리될 work:
|
||||
|
||||
- `kebab-parse-md/src/extractor.rs` 신규 + `impl Extractor for MarkdownExtractor`.
|
||||
- `kebab-parse-md/src/lib.rs` 의 `pub mod extractor;` + `pub use crate::extractor::MarkdownExtractor;` 추가 (re-export).
|
||||
- `Vec<Warning>` channel 의 새 surface 설계 — `CanonicalDocument.provenance` 의 Warning event 로 lift 하거나 wire schema 의 `IngestItem.warnings` 필드 추가 surface (additive minor bump).
|
||||
- `build_body_hints` (kebab-app/src/lib.rs:2422-2429) 의 MarkdownExtractor 안으로 이동 — `&RawAsset` 단독 input + first_h1/fallback_lang None 하드코딩 + fs_ctime/fs_mtime ← asset.discovered_at 의 mechanic 보존.
|
||||
|
||||
본 PR 의 §11 (Future work) 에 명시.
|
||||
|
||||
### §3.5 `App::open_with_config` 의 registry 초기화 — 11 Extractor
|
||||
|
||||
`crates/kebab-app/src/app.rs` 의 `App` struct 갱신:
|
||||
|
||||
```rust
|
||||
pub struct App {
|
||||
pub(crate) config: kebab_config::Config,
|
||||
pub(crate) sqlite: Arc<SqliteStore>,
|
||||
/// post-v0.18.0: inner-AST 9-arm extract dispatch + image/pdf extract
|
||||
/// callsite 통합. App init 시 1회 등록 — markdown 은 별 PR 에서 추가.
|
||||
pub(crate) extractors: Vec<Box<dyn Extractor + Send + Sync>>,
|
||||
embedder: OnceLock<Arc<dyn Embedder + Send + Sync>>,
|
||||
vector: OnceLock<Arc<LanceVectorStore>>,
|
||||
llm: OnceLock<Arc<dyn LanguageModel>>,
|
||||
search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>,
|
||||
pipeline_verifier: Option<Arc<dyn kebab_nli::NliVerifier>>,
|
||||
}
|
||||
```
|
||||
|
||||
`App::open_with_config` 안의 init 코드 추가 (round 1 NIT #2: trailing comma 정리):
|
||||
|
||||
```rust
|
||||
let extractors: Vec<Box<dyn Extractor + Send + Sync>> = vec![
|
||||
Box::new(kebab_parse_image::ImageExtractor::new()),
|
||||
Box::new(kebab_parse_pdf::PdfTextExtractor::new()),
|
||||
Box::new(kebab_parse_code::RustAstExtractor::new()),
|
||||
Box::new(kebab_parse_code::PythonAstExtractor::new()),
|
||||
Box::new(kebab_parse_code::TypescriptAstExtractor::new()),
|
||||
Box::new(kebab_parse_code::JavascriptAstExtractor::new()),
|
||||
Box::new(kebab_parse_code::GoAstExtractor::new()),
|
||||
Box::new(kebab_parse_code::JavaAstExtractor::new()),
|
||||
Box::new(kebab_parse_code::KotlinAstExtractor::new()),
|
||||
Box::new(kebab_parse_code::CAstExtractor::new()),
|
||||
Box::new(kebab_parse_code::CppAstExtractor::new()),
|
||||
];
|
||||
```
|
||||
|
||||
**ordering invariant** (round 1 Ambiguity 1 해소 — (A) safety guard 의미만):
|
||||
|
||||
11 Extractor 의 `supports()` 가 mutually exclusive 한 한 ordering 무관. registry 의 ordering 은 wire contract 가 아니며 (외부에 노출되지 않음 + serialize 되지 않음), `find()` 의 first-match optimization 의 안정성을 위한 **safety guard** 일 뿐 — verifier 의 unit test (§5.1) 가 mutually-exclusive grid 로 검증.
|
||||
|
||||
현재 11 + 1 (markdown 별 PR 후) impl 의 `supports()` 가 disjoint:
|
||||
- `MediaType::Markdown` / `MediaType::Pdf` / `MediaType::Image(_)` 는 enum variant 단위 disjoint.
|
||||
- `MediaType::Code(l)` 의 9 AST lang 의 `supports()` 도 lang string equality 비교로 disjoint (rust ≠ python ≠ … ≠ cpp).
|
||||
|
||||
#### §3.5.1 `ImagePipeline.extractor` lifecycle (round 1 MAJOR #4 Option c)
|
||||
|
||||
actual `crates/kebab-app/src/lib.rs:760-764` 의 `ImagePipeline` struct 인용:
|
||||
|
||||
```rust
|
||||
struct ImagePipeline<'a> {
|
||||
extractor: &'a ImageExtractor,
|
||||
ocr_engine: Option<&'a OllamaVisionOcr>,
|
||||
caption_llm: Option<&'a dyn LanguageModel>,
|
||||
}
|
||||
```
|
||||
|
||||
3 option:
|
||||
|
||||
| option | 방법 | trade-off |
|
||||
|---|---|---|
|
||||
| (a) parallel state | local `image_extractor` (lib.rs:356) 유지 + `App.extractors` 도 별도 보유 | + 변경 surface 최소.<br>− 두 source-of-truth — silent drift risk. |
|
||||
| (b) trait object 로 변경 | `extractor: &'a (dyn Extractor + Send + Sync)` 로 field type 변경 | + registry 의 entry 를 `&dyn` 로 borrow.<br>− concrete `image_extractor.extract(...)` callsite 의 type 추론 깨질 risk + lifetime gymnastics. |
|
||||
| **(c) field 제거** | `ImagePipeline.extractor` field 자체 제거. `ingest_one_image_asset` (lib.rs:1296) 이 직접 `app.extract_for(&asset.media_type, &ctx, &bytes)?` 호출. | + single source-of-truth.<br>+ lib.rs:356 의 local 도 제거.<br>− ImagePipeline 의 의미가 OCR + caption 만 남음 (의도와 정합 — image-specific post-extract adapter 만 carry). |
|
||||
|
||||
**결정: Option c**. 근거 = sub-item 2 의 CRITICAL #5 (sole-source-of-truth) 원칙 + ImagePipeline 의 의미가 OCR + caption pipeline (post-extract) 로 정확히 한정 + lib.rs:356 의 local 제거.
|
||||
|
||||
본 PR 의 ImagePipeline 갱신 후 모습:
|
||||
|
||||
```rust
|
||||
struct ImagePipeline<'a> {
|
||||
ocr_engine: Option<&'a OllamaVisionOcr>,
|
||||
caption_llm: Option<&'a dyn LanguageModel>,
|
||||
}
|
||||
```
|
||||
|
||||
callsite `ingest_one_image_asset` (lib.rs:1232 의 head — image_pipeline arg 가 들어오는 곳) 에서 `image_pipeline.extractor.extract(...)` 의 호출이 `app.extract_for(&asset.media_type, &ctx, &bytes)?` 로 교체.
|
||||
|
||||
### §3.6 ingest entry dispatch loop 패턴 (Pattern β — extract-only polymorphism)
|
||||
|
||||
**Pattern β** 채택. 즉 outer 4-arm match (helper 함수 분기) 는 본 PR 에서 그대로 유지하고, **medium-internal `*Extractor::new().extract(…)` 호출만** polymorphic dispatch 로 교체. 영향 callsite 3 군:
|
||||
|
||||
1. lib.rs:1296 — `image_extractor.extract(&ctx, &bytes)` → `app.extract_for(&asset.media_type, &ctx, &bytes)?`.
|
||||
2. lib.rs:1783 — `PdfTextExtractor::new().extract(&ctx, &bytes)` → 동일 helper.
|
||||
3. lib.rs:2012-2049 — 9 AST arm 의 `*AstExtractor::new().extract(&ctx, &bytes)` → 9 callsite 가 1 callsite 로 hoist + 단일 `app.extract_for(&asset.media_type, &ctx, &bytes)?` 호출.
|
||||
|
||||
마크다운 path (lib.rs:1085-1118 의 `parse_frontmatter` / `parse_blocks` / `build_canonical_document` 3-step) + Tier 2/3 path (lib.rs:2012-2049 의 7 manifest + 1 shell arm 의 `synthesize_tier2_document(...)`) 은 **변경 0**.
|
||||
|
||||
**round 1 의 design tension 재인용**: Pattern β 가 outer 4-arm match 의 helper 분기는 유지 → "dead polymorphism 해소" 의 의미가 부분적. 본 PR 의 net scope = `*Extractor::new().extract(…)` callsite (12 callsite — image 1 + pdf 1 + AST 9) 를 1 callsite 로 통합. outer 4-arm helper 분기는 별 PR (markdown extractor 신설 + Chunker registry + Tier 2/3 통합) 의 work — 그 때 전체 dispatch flow 가 통합 가능.
|
||||
|
||||
helper signature:
|
||||
|
||||
```rust
|
||||
impl App {
|
||||
/// Polymorphic dispatcher for the Extractor trait. Looks up the
|
||||
/// first Extractor whose `supports(media)` returns true and invokes
|
||||
/// `extract(ctx, bytes)` on it.
|
||||
///
|
||||
/// Errors with `anyhow!("no Extractor for media_type {media:?}")`
|
||||
/// when no matching Extractor is registered — caller (e.g.
|
||||
/// `ingest_one_*_asset`) should treat this as a programming error
|
||||
/// (unreachable in the post-outer-dispatch branches), NOT as a
|
||||
/// user-facing skip.
|
||||
pub(crate) fn extract_for(
|
||||
&self,
|
||||
media: &MediaType,
|
||||
ctx: &ExtractContext<'_>,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<CanonicalDocument> {
|
||||
let extractor = self.extractors.iter()
|
||||
.find(|e| e.supports(media))
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"no Extractor for media_type {:?}", media
|
||||
))?;
|
||||
extractor.extract(ctx, bytes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### §3.7 5 위치 match 의 본 PR 정리 scope (round 1 MAJOR #6 — arm count 정확 표기)
|
||||
|
||||
| 위치 | explicit arm 수 (lang cover) | 본 PR 정리 | 이유 |
|
||||
|---|---|---|---|
|
||||
| `lib.rs:2012-2049` 의 9 AST arm | 12 arm = 11 explicit + 1 wildcard, cover 17 lang (그 중 9 AST arm) | **정리** | Pattern β 로 `app.extract_for(...)` 단일 호출로 교체. 12 arm 중 9 AST arm 의 `*Extractor::new().extract(…)` 가 사라지고, 7 manifest arm + 1 shell arm + 1 other-bail 만 남는다 (post-state = 4 arm). |
|
||||
| `lib.rs:2012-2049` 의 7 Tier-2 + 1 Tier-3 arm | (위와 동일 region 의 나머지 4 arm) | **유지** | `synthesize_tier2_document` 가 Extractor 아닌 free function. Extractor 화는 별 PR. |
|
||||
| `lib.rs:1935-1953` 의 parser_version | 11 explicit arm cover 17 lang | **유지** | `Extractor::parser_version()` method 로 가져올 수 있지만 Tier 2/3 의 sentinel `"none-v1"` 가 hardcoded. inner 통합과 묶임. |
|
||||
| `lib.rs:1955-1974` 의 chunker_version | 11 explicit arm cover 17 lang | **유지** | Chunker registry 도입 = 별 PR (§2.2 non-goal). |
|
||||
| `lib.rs:1979-1988` 의 tier3_fallback_cv | 2 arm (positive 16-lang sum + `_ => None`) | **유지** | 동일. |
|
||||
| `lib.rs:2087-2128` 의 chunk dispatch | 14 explicit arm cover 17 lang | **유지** | 동일. |
|
||||
|
||||
본 PR 의 net 효과:
|
||||
|
||||
- **lib.rs:2012-2049 region**: **12 arm (11 explicit + 1 wildcard) → 4 arm** [round 3 정정 — plan critic round 2 verifier GAP #5 의 actual count]. 9 AST arm 의 사라짐 → 그 자리에 dispatch loop entry 의 단일 9-AST-group arm 1 줄; 7 manifest arm + 1 shell arm + 1 other-bail wildcard 유지.
|
||||
- **lib.rs:1296 region (image)**: 1 callsite → 1 callsite (`image_extractor.extract` → `app.extract_for`).
|
||||
- **lib.rs:1783 region (pdf)**: 1 callsite → 1 callsite (`PdfTextExtractor::new().extract` → `app.extract_for`).
|
||||
- **lib.rs:356**: 1 local 제거 (`let image_extractor = …`).
|
||||
- **lib.rs:760 region (ImagePipeline)**: 1 field 제거 (`extractor: &'a ImageExtractor`).
|
||||
- **lib.rs:1232 region (ingest_one_image_asset signature)**: image_pipeline arg 의 destructure 갱신.
|
||||
|
||||
총 변경 site: 5 위치 (image / pdf / 9-AST extract / image_extractor local / ImagePipeline field).
|
||||
|
||||
### §3.8 inner 4 위치 match + Chunker dispatch — 본 PR 의 명시적 defer
|
||||
|
||||
§2.2 + §2.3 + §3.7 의 종합. 본 PR 은 9 AST extract callsite + image + pdf extract callsite 만 정리. inner 4 위치 match (parser_version / chunker_version / tier3_fallback_cv / chunk dispatch) + Chunker registry 도입 + Tier 2/3 Extractor 화 + MarkdownExtractor 신설은 모두 별 spec 의 work. §11 future work 에 follow-up 후보 명시.
|
||||
|
||||
---
|
||||
|
||||
## §4 Open questions (closure status — round 2 inline 해소)
|
||||
|
||||
round 1 reflection 의 3 OQ + drafting 단계 5 OQ 를 inline 해소.
|
||||
|
||||
### §4.1 `build_body_hints` 의 input dependency — **resolved**
|
||||
|
||||
`crates/kebab-app/src/lib.rs:2422-2429` 의 actual signature + body 인용:
|
||||
|
||||
```rust
|
||||
fn build_body_hints(asset: &RawAsset) -> BodyHints {
|
||||
BodyHints {
|
||||
first_h1: None,
|
||||
fs_ctime: asset.discovered_at,
|
||||
fs_mtime: asset.discovered_at,
|
||||
fallback_lang: None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
input = `&RawAsset` 단독. App-side state (config / sqlite / embedder / …) 침투 0. → 미래 MarkdownExtractor 신설 시 `extract(&ctx, &bytes)` 안에서 `ctx.asset` 으로부터 동일 derive 가능. 본 PR 의 scope 외 (MarkdownExtractor defer) — 별 PR 에서 활용.
|
||||
|
||||
### §4.2 `supports()` 의 mutually exclusive 보장 — **resolved**
|
||||
|
||||
11 Extractor impl 의 `supports()` 가 mutually exclusive. `MediaType::Markdown` / `MediaType::Pdf` / `MediaType::Image(_)` / `MediaType::Code(l)` 의 4 variant 중 첫 3개는 명백히 disjoint. `MediaType::Code(l)` 의 9 AST lang 의 `supports()` 도 lang string 비교 (rust ≠ python ≠ … ≠ cpp) 로 disjoint. → mutually exclusive. unit test 로 검증 (§5.1 의 grid-search).
|
||||
|
||||
### §4.3 markdown warning channel — **resolved (MarkdownExtractor defer 로 자동 해소)**
|
||||
|
||||
`lib.rs:1100-1109` 의 `warning_notes: Vec<String>` snapshot + `all_warnings: Vec<Warning>` 의 `build_canonical_document(..., warnings)` 마지막 arg 의 dual sink 가 본 PR 에서 변경 0. → `IngestItem.warnings` 의 wire 형태 변경 0. risk 0.
|
||||
|
||||
`crates/kebab-parse-md/src/normalize.rs:60-65` 의 actual signature (round 1 OQ-2):
|
||||
|
||||
```rust
|
||||
pub fn build_canonical_document(
|
||||
asset: &RawAsset,
|
||||
metadata: Metadata,
|
||||
blocks: Vec<ParsedBlock>,
|
||||
parser_version: &ParserVersion,
|
||||
warnings: Vec<Warning>,
|
||||
) -> Result<CanonicalDocument> { ... }
|
||||
```
|
||||
|
||||
`warnings: Vec<Warning>` arg 가 함수 body 안에서 `CanonicalDocument.provenance` 의 Warning event 로 lift — 본 PR 에서 변경 0.
|
||||
|
||||
### §4.4 Pattern β 의 markdown helper signature 변경 폭 — **resolved (MarkdownExtractor defer)**
|
||||
|
||||
본 PR 에서 markdown path 변경 0 → diff line count 0. 미래 MarkdownExtractor 신설 PR 의 work.
|
||||
|
||||
### §4.5 verifier 의 wire-identity 검증 방법 — **resolved**
|
||||
|
||||
§5.4 에서 명시. `docs/SMOKE.md` 의 isolated TempDir KB ingest + `kebab search/ask --json` output diff = baseline (main HEAD = 9676640) 과 byte-identical. 4 medium fixture table 은 §5.4.1.
|
||||
|
||||
### §4.6 `parser_version` source-of-truth dual drift (round 1 MAJOR #3 → OQ-1) — **resolved**
|
||||
|
||||
`ingest_with_config_opts` (lib.rs:281-360) 의 chain 추적:
|
||||
|
||||
- lib.rs:331 — `let parser_version = ParserVersion(kebab_parse_md::PARSER_VERSION.to_string());` — **markdown 전용**.
|
||||
- lib.rs:380 (`ingest_with_config_cancellable`) → `ingest_one_asset(app, asset, parser_version: &ParserVersion, ...)` — 이 `parser_version` 이 markdown path 의 lib.rs:1111 `build_canonical_document(asset, metadata, parsed_blocks, parser_version, all_warnings)` 의 4번째 arg 로 흐름.
|
||||
- `ingest_one_image_asset` (lib.rs:1264) — caller-arg `parser_version` 무시, 자체 `let image_parser_version = ParserVersion(kebab_parse_image::PARSER_VERSION.to_string());` 으로 재build.
|
||||
- `ingest_one_pdf_asset` (lib.rs:1758) — caller-arg `parser_version` 무시, 자체 `let pdf_parser_version = ParserVersion(kebab_parse_pdf::PARSER_VERSION.to_string());` 재build.
|
||||
- `ingest_one_code_asset` (lib.rs:1935) — caller-arg 무시, 자체 9-arm match 로 per-lang `RUST_PARSER_VERSION` / `PYTHON_PARSER_VERSION` / ... 재build.
|
||||
|
||||
**결론**: `parser_version` caller-arg 의 source-of-truth 는 **markdown path 전용**. image / pdf / code path 모두 자체 const 로 재build → `Extractor::parser_version()` method 와의 dual-drift risk 가 있다. 본 PR 은 `extract_for` 가 `Extractor::extract` 만 호출하고 `parser_version()` 은 호출 안 함 → `CanonicalDocument.parser_version` 의 wire form 은 Extractor 의 `extract` body 안에서 결정 (e.g. ImageExtractor body 의 `let parser_version = self.parser_version();`) — 본 PR 에서 변경 0. dual-source 의 정리는 별 PR (MarkdownExtractor 신설 + Tier 2/3 Extractor 화 + inner-match 통합) 의 work.
|
||||
|
||||
### §4.7 ARCHITECTURE.md dispatch flow section (round 1 OQ-3, Missing 1) — **resolved**
|
||||
|
||||
`grep -n "dispatch\|registry\|polymorphic\|ingest flow" docs/ARCHITECTURE.md` 결과 = `line 25` 의 "code parser" table 의 chunker / parser version 묘사만 존재. "ingest dispatch flow" section **없음**. → 본 PR 이 ARCHITECTURE.md 갱신 0. §7 의 docs/ARCHITECTURE.md row = "변경 0".
|
||||
|
||||
---
|
||||
|
||||
## §5 Verification plan
|
||||
|
||||
### §5.1 Unit tests (per crate)
|
||||
|
||||
- **`kebab-app`** (registry coverage):
|
||||
- `App::open_with_config` 호출 후 `app.extractors.len() == 11`.
|
||||
- grid-search: 11 Extractor 의 `supports()` 가 `MediaType::Markdown` / `Pdf` / `Image(_)` / `Code("rust"|"python"|...|"cpp")` / `Code("yaml")` / `Code("shell")` / `Audio(_)` / `Other(_)` 의 16 sample MediaType 에 대해 mutually exclusive (어떤 두 Extractor 도 동일 MediaType 에 대해 true 반환 0).
|
||||
- `Code("yaml")` / `Code("shell")` / `Code("ruby")` 처럼 registry 가 cover 안 하는 MediaType → `app.extract_for(...)` 가 `Err("no Extractor for media_type ...")` 반환.
|
||||
- **`kebab-app`** (smoke):
|
||||
- `app.extract_for(&MediaType::Markdown, ...)` → markdown path 본 PR 에서 미사용 (별 PR 추가). `Err` 가 정상.
|
||||
- `app.extract_for(&MediaType::Image(ImageType::Png), &ctx, &bytes)` → existing ImageExtractor 와 byte-identical result.
|
||||
- `app.extract_for(&MediaType::Pdf, &ctx, &bytes)` → existing PdfTextExtractor result.
|
||||
- `app.extract_for(&MediaType::Code("rust".into()), &ctx, &bytes)` → existing RustAstExtractor result.
|
||||
|
||||
### §5.2 Workspace 회귀 (1313 baseline)
|
||||
|
||||
`cargo test --workspace --no-fail-fast -j 1` 의 net delta = +N (registry coverage + grid-search + 4-medium happy-path smoke test 만큼). 기존 ingest happy path test (특히 `kebab-app/tests/ingest_*.rs`, `kebab-app/tests/p10_*.rs`) 전수 pass.
|
||||
|
||||
### §5.3 Clippy + build + cargo tree
|
||||
|
||||
- `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||||
- `cargo build --release` clean.
|
||||
- `cargo tree -p kebab-app -e normal` 의 결과가 본 refactor 전후로 동일 (4 parser crate 그대로) — `kebab-parse-md / kebab-parse-pdf / kebab-parse-image / kebab-parse-code` 4 line 보존.
|
||||
|
||||
### §5.4 ingest happy path manual smoke
|
||||
|
||||
`docs/SMOKE.md` 의 isolated TempDir KB 절차 실행.
|
||||
|
||||
#### §5.4.1 SMOKE fixture table (round 1 MINOR #2)
|
||||
|
||||
| medium | fixture path 후보 | 기대 `--json` schema_version | baseline snapshot |
|
||||
|---|---|---|---|
|
||||
| Markdown | `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` (self-ingest) | `ingest_report.v1` + `search_hit.v1` + `answer.v1` | main HEAD (9676640) 동일 input 결과 |
|
||||
| PDF | lopdf-decodable fixture (예: `tests/fixtures/sample.pdf` 가 있으면 사용; 없으면 verifier 가 생성) | 동일 | 동일 |
|
||||
| Image | PNG fixture (`tests/fixtures/sample.png` 또는 verifier 생성) | 동일 (OCR / caption 옵션 default off) | 동일 |
|
||||
| Code:rust | `crates/kebab-app/src/lib.rs` self-ingest 또는 verifier 가 작은 fixture rust 파일 생성 | 동일 | 동일 |
|
||||
|
||||
별 fixture path 가 repo 에 없으면 verifier 가 `_external/` 의 `kebab ingest-file` flow 로 생성 — round 1 Missing 2 의 관심사 (§5.4.2).
|
||||
|
||||
#### §5.4.2 `_external/` single-file ingest path (round 1 Missing 2)
|
||||
|
||||
`crates/kebab-app/src/lib.rs:2689-2753` 의 `ingest_file_with_config` 가 외부 파일을 `_external/<blake3-12>.<ext>` 로 copy 한 뒤 `ingest_with_config_opts` 로 재진입. → 본 PR 의 polymorphic dispatch 가 동일하게 적용된다 (entry point 통일 → outer 4-arm match → 본 PR 의 `app.extract_for(...)` 단일 호출). `_external/` path 영향 0 — wire schema diff 0 보장에 포함.
|
||||
|
||||
### §5.5 wire schema diff = 0 (success path) + error path 의 internal context 예외
|
||||
|
||||
§5.4 의 `--json` output 의 `schema_version` field 가 모두 `*.v1` 유지. `IngestReport.v1` / `IngestItem` (특히 `warnings`) / `search_hit.v1` / `answer.v1` 의 field 추가 / 삭제 / 의미 변경 0.
|
||||
|
||||
**Exception (round 2 verifier MAJOR #2 of plan): error context string 변경**
|
||||
|
||||
본 PR 의 callsite migration 이 `.context("kb-parse-image::ImageExtractor::extract")` → `.context("kb-app::extract_for (image)")` 등의 anyhow context string 을 변경. 이는 `error.v1.message` 의 surface 에 영향 가능 (현재 stderr ndjson 또는 `--json` mode 의 fatal err 출력 surface).
|
||||
|
||||
본 변경은 **internal Rust error chain wording 의 변경** — `error.v1.code` (exit code branching 의 source) + `error.v1.schema_version` 보존. message chain 의 internal detail (어느 Rust function 이 anyhow context 를 chained 했는가 의 trace) 변경은 **user-visible surface 정의 외**. claude-code-skill / mcp consumer 의 wire contract 가 `error.v1.code` 의 finite enumeration 에 의존 (e.g. `RefusalSignal` / `NoHitSignal` / `DoctorUnhealthy`) — message chain 의 wording diff 에 의존 0.
|
||||
|
||||
risk acceptance: 본 PR 의 error context wording diff 가 `IngestReport.v1.items[].error` field 의 String 표현에 surface 시 diff > 0 surface 가능하나, 본 PR 은 error path 의 분기 의미를 바꾸지 않음 (success path 만 polymorphic dispatch 로 통합 — error 종류 + code + branch 모두 보존). plan 의 verifier 가 success path 의 wire diff 만 verify + error path 의 schema diff 는 manual 검증 (`error.v1.code` 보존 확인).
|
||||
|
||||
### §5.6 Integration 통합 영향 (round 1 Missing 3)
|
||||
|
||||
`integrations/claude-code/kebab/` (Claude Code skill — `kebab search/ask --json` consumer) 의 wire schema delta 0 → **integration 갱신 0**. CLAUDE.md "Wire schema v1" rule 의 v1→v2 major bump 시 cascade 갱신 의무에 본 PR 미해당 (additive 변경조차 없음).
|
||||
|
||||
---
|
||||
|
||||
## §6 Risks
|
||||
|
||||
### §6.1 ingest happy path 의 runtime regression — Medium mitigation
|
||||
|
||||
본 refactor 의 risk = `app.extract_for(...)` 가 `ImageExtractor::extract` / `PdfTextExtractor::extract` / 9 `*AstExtractor::extract` 의 호출 결과를 byte-identical 로 재현하지 못하는 경우. trait dispatch 의 self-method 의 결과는 본질적으로 동일하지만, `Box<dyn Extractor>` 의 vtable lookup 또는 `ExtractContext<'_>` 의 lifetime 처리 차이가 silent regression 으로 surface 할 risk.
|
||||
|
||||
**mitigation**: §5.4 의 4-medium SMOKE manual diff + §5.2 의 1313 + N test pass + §5.1 의 grid-search.
|
||||
|
||||
본 risk 는 round 1 의 §6.1 risk (markdown warning channel) 와 **다르다**. round 1 risk 는 MarkdownExtractor defer 로 자동 해소 — markdown path 가 본 PR 에서 변경 0 이므로 `Vec<Warning>` channel + `IngestItem.warnings` wire form 영향 0.
|
||||
|
||||
### §6.2 registry 의 wrong dispatch — Low mitigation
|
||||
|
||||
§4.2 의 mutually-exclusive 검증 + §5.1 의 grid-search. risk 가 깨지면 `find()` 의 first-match 가 ordering-dependent → wire result 가 ordering 의존이 됨 — verifier 가 unit test 로 fail-fast.
|
||||
|
||||
### §6.3 state-ful Extractor 의 미래 추가 — Low impact (round 1 MINOR #3 보강)
|
||||
|
||||
본 PR 에서는 모든 11 Extractor 가 state-less → init cost 0. 미래에 state-ful Extractor (e.g. LLM-backed image OCR 이 Extractor trait 으로 합쳐질 경우) 가 추가되면 두 migration 패턴:
|
||||
|
||||
- **Pattern α**: `OnceLock<Box<dyn Extractor>>` 같은 lazy init wrapper. App init 시 lazy slot 만 등록, first dispatch 시 build.
|
||||
- **Pattern β**: eager init 시 `Result<Box<dyn Extractor>>` 의 fallible — config 의 enable flag 가 off 면 `None` 으로 skip + dispatch 시 `Err`.
|
||||
|
||||
본 PR 의 scope 아님 — 미래 PR 의 design 결정. 본 PR 의 `Vec<Box<dyn Extractor>>` field 는 두 패턴 모두 수용 가능 (Vec entry type 만 swap).
|
||||
|
||||
### §6.4 trait object vtable overhead — Negligible
|
||||
|
||||
dispatch 가 per-asset 1회 (extract 의 hot loop 0회) → 측정 불가. ingest throughput 영향 0.
|
||||
|
||||
### §6.5 partial 정리의 인지 부담 — Medium
|
||||
|
||||
본 PR 이 9 AST extract callsite + image + pdf extract callsite 만 정리 → 코드 reader 가 markdown path (자유 함수) + Tier 2/3 path (free-function `synthesize_tier2_document`) + AST extract path (`app.extract_for`) 의 3 비대칭에 confusion. **mitigation**: §3.7 의 table 을 코드 comment 또는 PR description 에 인용 + §11 의 follow-up 명시.
|
||||
|
||||
### §6.6 frozen task spec / design contract 침범 — None (verified)
|
||||
|
||||
§1.8 의 분석으로 design §7.2 + §8 + §6 모두 변경 0 + frozen task spec 21 file 모두 변경 0 검증. risk 0.
|
||||
|
||||
### §6.7 dual-source parser_version drift (round 1 MAJOR #3) — Low impact (정리는 별 PR)
|
||||
|
||||
§4.6 의 결론 — `Extractor::parser_version()` method 의 결과 vs caller-arg / 자체 `let *_parser_version` 의 dual-source 가 본 PR 에서 정리되지 않음. 단 본 PR 이 새로운 dual-source 를 도입하지도 않음 → silent regression risk 0 (기존 wire form 그대로). 정리는 별 PR (MarkdownExtractor + Tier 2/3 + inner-match 통합) 의 work — §11 명시.
|
||||
|
||||
### §6.8 cargo features 영향 (round 1 Missing 4) — None
|
||||
|
||||
`crates/kebab-app/Cargo.toml` + 11 Extractor 의 source crate `Cargo.toml` 의 `[features]` section 검사 — 현재 no feature gate 가 Extractor impl 의 visibility 를 토글하지 않음 (future `vision-ocr` 또는 `audio` feature gate 도입 시 본 PR 의 registry init 이 `#[cfg(feature = "...")]` 의 `vec![]` push 분기로 자연스럽게 적응 가능). 본 PR 에서 feature 신설 0.
|
||||
|
||||
---
|
||||
|
||||
## §7 Wire / surface impact
|
||||
|
||||
| 항목 | 변경 |
|
||||
|---|---|
|
||||
| wire schema (`*.v1`) | **success path = 변경 0** — `IngestReport.v1` / `IngestItem.warnings` / `search_hit.v1` / `answer.v1` / `chunk_inspection.v1` / `citation.v1` / `doc_summary.v1` 모두 byte-identical. **error path = `error.v1.message` 의 internal context string wording 변경 가능 (예: `"kb-parse-image::ImageExtractor::extract"` → `"kb-app::extract_for (image)"`)** — `error.v1.code` + `error.v1.schema_version` 보존, message chain 의 wording diff 는 user-visible surface 정의 외 (§5.5 risk acceptance 참조). |
|
||||
| CLI / TUI / MCP surface | **변경 0** — `kebab ingest` / `search` / `ask` / `doctor` / `reset` / `inspect-chunk` 의 argv + `--json` field 그대로. |
|
||||
| Cargo `workspace.version` | **bump 불필요** — frozen design contract 변경 0, wire schema 변경 0, V00X migration 0 → CLAUDE.md §Release 룰 3 트리거 미충족. |
|
||||
| Internal Rust crate-API | `kebab-app::App.extractors` field 추가 (pub(crate), 외부 영향 0) + `App::extract_for(...)` method 추가 (pub(crate)). 기존 `kebab-parse-md / kebab-parse-pdf / kebab-parse-image / kebab-parse-code` 의 `pub` surface 모두 보존. **`kebab-parse-md` 변경 0**. |
|
||||
| README | **변경 0** — dispatch 통합은 사용자 가시 surface 가 아님. |
|
||||
| HANDOFF.md | **변경 0** — phase epic 완료 아님 (sub-item 3 의 internal refactor). |
|
||||
| docs/ARCHITECTURE.md | **변경 0** — §1.9 의 grep 결과 "ingest dispatch flow" section 부재 → 본 PR 이 신설하지 않는 것이 정합. line 25 의 code parser table 은 lang / version family 묘사 — 본 refactor 가 family 를 건드리지 않음. |
|
||||
| docs/superpowers/specs/2026-04-27-kebab-final-form-design.md | **변경 0** — §7.2 Extractor trait 정의 semantically identical, §8 dep graph 그대로. |
|
||||
| `integrations/claude-code/kebab/` | **변경 0** — §5.6 의 wire delta 0. |
|
||||
| tasks/HOTFIXES.md | **append 가능** — refactor 머지 후 한 줄 dated entry (sub-item 1 / 2 와 동일 pattern). |
|
||||
|
||||
---
|
||||
|
||||
## §8 Out of scope (별 PR / future defer)
|
||||
|
||||
1. **MarkdownExtractor 신설** — `kebab-parse-md::MarkdownExtractor` 의 `impl Extractor` + `build_body_hints` 이동 + `Vec<Warning>` channel 의 새 surface 설계. 별 spec.
|
||||
2. **Tier 2/3 free-function path 의 Extractor 화** — `synthesize_tier2_document` 의 7 manifest + 1 shell lang 을 `*Extractor` impl 로 승격.
|
||||
3. **Chunker dispatch unification** — `Chunker` trait 에 `supports()` 신설 + `App.chunkers: Vec<Box<dyn Chunker>>` registry + `lib.rs:2087-2128` 의 chunk dispatch 통합. design §7.2 갱신 동반 → 별 spec.
|
||||
4. **inner 4 위치 match (parser_version / chunker_version / tier3_fallback_cv / chunk dispatch) 의 polymorphic 통합** — Chunker registry + Tier 2/3 Extractor 화와 묶임.
|
||||
5. **ExtractorRegistry plugin system** — App field 가 아닌 별 type + dynamic-loading.
|
||||
6. **dual-source `parser_version` 정리** — §6.7 의 risk. 별 PR.
|
||||
|
||||
---
|
||||
|
||||
## §9 References
|
||||
|
||||
- `crates/kebab-core/src/traits.rs:115-132` — Extractor + Chunker trait 정의 (§1.1, §1.2).
|
||||
- `crates/kebab-app/src/lib.rs:961-1040` — `ingest_one_asset` outer dispatch (§1.5).
|
||||
- `crates/kebab-app/src/lib.rs:281-360` — `ingest_with_config_opts` (§4.6).
|
||||
- `crates/kebab-app/src/lib.rs:760-764` — ImagePipeline struct (§3.5.1).
|
||||
- `crates/kebab-app/src/lib.rs:1232` — `ingest_one_image_asset` signature head.
|
||||
- `crates/kebab-app/src/lib.rs:1296` — image extract callsite (§3.7).
|
||||
- `crates/kebab-app/src/lib.rs:1783` — pdf extract callsite (§3.7).
|
||||
- `crates/kebab-app/src/lib.rs:1935-2128` — code dispatch 5 위치 match (§1.3, §1.4, §3.7).
|
||||
- `crates/kebab-app/src/lib.rs:2422-2429` — `build_body_hints` (§4.1).
|
||||
- `crates/kebab-app/src/lib.rs:2689-2753` — `ingest_file_with_config` (§5.4.2).
|
||||
- `crates/kebab-app/src/app.rs:115` — App struct (§1.6).
|
||||
- `crates/kebab-parse-md/src/normalize.rs:60-65` — `build_canonical_document` signature (§4.3).
|
||||
- `crates/kebab-parse-md/src/frontmatter.rs:34-44` — `BodyHints` struct (§4.1).
|
||||
- 11 Extractor impl: §1.1 의 table.
|
||||
- 15 Chunker impl: §1.2 의 table.
|
||||
- design `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §7.2 (`:1416-1420`) / §8 (`:1475+`) (§1.8).
|
||||
- sibling spec: `2026-05-26-source-fs-dep-lightening-spec.md` (sub-item 1, PR #185 merged).
|
||||
- sibling spec: `2026-05-26-normalize-absorption-spec.md` (sub-item 2, PR #186 merged).
|
||||
|
||||
---
|
||||
|
||||
## §10 Round closure status table
|
||||
|
||||
| round 1 finding | severity | closure | reflection 위치 |
|
||||
|---|---|---|---|
|
||||
| CRITICAL #1 (BodyHints field 부정확) | CRITICAL | resolved | §3.4 (defer note) + §4.1 (resolved 의 actual signature 인용) — `first_h1 / fs_ctime / fs_mtime / fallback_lang` 4 field 명시 + `&RawAsset` 단독 derive. |
|
||||
| CRITICAL #2 (byte-identical 인용 과장) | CRITICAL | resolved | §1.1 (인용 표현 → "trait 정의 인용") + §1.8 ("semantically identical" 로 약화) + §3.2 (trait 갱신 0 보존 invariant 만 유지). |
|
||||
| MAJOR #1 (§2.1 Goal #1 vs §3.6/§3.7 모순) | MAJOR | resolved | §2.1 Goal #1 재작성 — "inner AST 9-arm extract dispatch 통합" 으로 명확화. spec title 도 "AST 9-arm extract dispatch" 로 변경. |
|
||||
| MAJOR #2 (Pattern β warning channel 미해결) | MAJOR | resolved (Option (ii) 채택) | §2.2 #1 + §3.4 + §6.1 + §11 — MarkdownExtractor defer. wire risk 0. |
|
||||
| MAJOR #3 (parser_version dual-source) | MAJOR | resolved | §4.6 (OQ-1 inline 해소) + §6.7 (risk 명시 + 정리는 별 PR). |
|
||||
| MAJOR #4 (ImagePipeline.extractor lifecycle) | MAJOR | resolved (Option c 채택) | §3.5.1 + §2.1 Goal #2 — field 제거 + local 제거. |
|
||||
| MAJOR #5 (code_lang count off-by-one) | MAJOR | resolved | §1.4 ("16" → "17") + §1.3 (lib.rs:1009-1013 outer guard 인용). |
|
||||
| MAJOR #6 (5 위치 arm count 부정확) | MAJOR | resolved | §3.7 table — explicit arm 수 (lang cover) 형식 통일 + 1935/1955/1979/2012/2087 실 lib.rs 인용 검증. |
|
||||
| MAJOR #7 (kebab-parse-md re-export 누락) | MAJOR | resolved | §3.4 defer note 안에 future PR work 로 `pub mod extractor;` + `pub use ...` 명시. |
|
||||
| MINOR #1 (App struct lifecycle 보강) | MINOR | resolved | §1.6 — embedder/vector/llm lazy + pipeline_verifier eager 주석. |
|
||||
| MINOR #2 (SMOKE fixture 3-column table) | MINOR | resolved | §5.4.1 의 4-medium table. |
|
||||
| MINOR #3 (state-ful Extractor migration 보강) | MINOR | resolved | §6.3 의 Pattern α/β 두 wrapper 패턴 명시. |
|
||||
| MINOR #4 (round 2 sonnet closure verify only) | MINOR | resolved | §10 status table (이 표) 의 round 2 row 의 mode 명시. |
|
||||
| NIT #1 (spec title "9-arm") | NIT | resolved | spec title 재작성 (위). |
|
||||
| NIT #2 (sample code trailing comma) | NIT | resolved | §3.5 의 vec![] block 의 trailing comma 정리. |
|
||||
| Missing 1 (ARCHITECTURE.md dispatch flow grep) | Missing | resolved | §1.9 + §4.7 — grep 결과 = section 부재 → 변경 0. |
|
||||
| Missing 2 (`_external/` ingest path 영향) | Missing | resolved | §5.4.2 — `ingest_file_with_config` 가 `ingest_with_config_opts` 재진입 → 영향 0. |
|
||||
| Missing 3 (integration update 0 명시) | Missing | resolved | §5.6 — wire delta 0 → integration 갱신 0. |
|
||||
| Missing 4 (cargo features 영향) | Missing | resolved | §6.8 — 현재 no feature gate. |
|
||||
| Ambiguity 1 (§3.5 ordering invariant 의미) | Ambiguity | resolved | §3.5 의 ordering invariant 보강 — (A) safety guard only, (B) wire contract NOT. |
|
||||
| Ambiguity 2 (ingest_one_image_asset polymorphic dispatch) | Ambiguity | resolved | §3.5.1 + §3.7 + §3.6 의 Pattern β 명시. |
|
||||
| OQ-1 (`parser_version` source-of-truth grep) | OQ | resolved | §4.6. |
|
||||
| OQ-2 (`build_canonical_document` signature grep) | OQ | resolved | §4.3. |
|
||||
| OQ-3 (ARCHITECTURE.md dispatch grep) | OQ | resolved | §4.7 + §1.9. |
|
||||
|
||||
| round | reviewer | mode | status | notes |
|
||||
|---|---|---|---|---|
|
||||
| 0 (drafting) | planner (self) | full | drafted | spec body 작성 완료. |
|
||||
| 1 | critic (opus) | full | REQUEST_CHANGES | 2 CRITICAL + 7 MAJOR + 4 MINOR + 2 NIT + 4 Missing + 2 Ambiguity + 3 OQ. |
|
||||
| 2 (reflection) | planner (self) | full rewrite | reflected | 위 status table 의 모든 finding closure. MarkdownExtractor defer (Option (ii)) 핵심 결정 — scope 가 "AST 9-arm extract dispatch + image + pdf extract" 로 축소. |
|
||||
| 3 | critic (sonnet) | **closure verify only** | pending | round 2 의 reflection 이 round 1 finding 을 모두 closure 했는지 검증. |
|
||||
| 4+ | as needed | — | pending | — |
|
||||
|
||||
---
|
||||
|
||||
## §11 Future work (별 PR / sibling spec 후보)
|
||||
|
||||
본 PR 머지 후의 follow-up. 우선순위 + 의존성 순서:
|
||||
|
||||
1. **MarkdownExtractor 신설** — `kebab-parse-md` 에 `impl Extractor for MarkdownExtractor` 추가. `build_body_hints` 의 이동. `Vec<Warning>` channel 의 새 surface 설계 — 두 option:
|
||||
- **(α) wire schema additive minor bump** — `IngestItem.warnings` 의 source 가 `CanonicalDocument.provenance.warnings` 가 되도록 lift. wire form `additive` 변경 (workspace.version minor bump 트리거).
|
||||
- **(β) `extract()` 의 별 channel** — `Extractor::extract` signature 갱신 → design §7.2 갱신 → frozen contract 변경. release cycle 영향 큼.
|
||||
spec 작성 시 option 선정.
|
||||
2. **Tier 2/3 free-function path 의 Extractor 화** — `synthesize_tier2_document` 를 `K8sManifestExtractor` / `DockerfileExtractor` / `ManifestFileExtractor` / `ShellExtractor` 의 4 impl 로 분리. App.extractors 에 4 entry 추가.
|
||||
3. **Chunker dispatch unification** — `Chunker::supports(&CanonicalDocument or &ChunkerVersion)` 신설 (design §7.2 갱신) + `App.chunkers: Vec<Box<dyn Chunker>>` registry + `lib.rs:2087-2128` chunk dispatch 통합 + `chunker_version` 결정도 chunker.chunker_version() polymorphic.
|
||||
4. **inner 4 위치 match 전체 통합** — parser_version / chunker_version / tier3_fallback_cv / chunk dispatch — Tier 2/3 Extractor 화 + Chunker registry 완료 후 자연스럽게 단일 dispatch loop 으로 통합 가능.
|
||||
5. **outer 4-arm helper 통합** — `ingest_one_image_asset` / `ingest_one_pdf_asset` / `ingest_one_code_asset` 의 helper 분기를 단일 dispatch loop 으로 흡수. post-extract pipeline (OCR / page-chunker / tier3-fallback / try-skip-unchanged) 의 trait 화 동반.
|
||||
6. **dual-source `parser_version` 정리** — `Extractor::parser_version()` method 의 결과를 single source-of-truth 로 강제. caller-arg 의 markdown 전용 hardcoded 제거.
|
||||
7. **ExtractorRegistry plugin system** — App field 가 아닌 별 type + dynamic-loading. (low priority, design-only)
|
||||
|
||||
#1 + #2 + #3 이 본 PR 머지 후 다음 milestone (v0.19.0 minor bump 동반 가능).
|
||||
Reference in New Issue
Block a user