diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4829e11..1e13e38 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,24 +33,89 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab- ## crate 의존성 그래프 -```text -kebab-cli, kebab-tui, kebab-desktop - └─> kebab-app - ├─> kebab-source-fs - ├─> kebab-parse-md / kebab-parse-pdf / kebab-parse-image / kebab-parse-audio - │ └─> kebab-parse-types - ├─> kebab-normalize - │ └─> kebab-parse-types - ├─> kebab-chunk - ├─> kebab-store-sqlite - ├─> kebab-store-vector - ├─> kebab-embed-local (kebab-embed trait crate) - ├─> kebab-search - ├─> kebab-llm-local (kebab-llm trait crate) - ├─> kebab-rag - ├─> kebab-eval - └─> kebab-config - └─> kebab-core (모두 의존) +> 그룹 단위 view + 컴포넌트별 상세는 [docs/components/](components/). + +```mermaid +flowchart TB + subgraph UI ["UI binary"] + cli["kebab-cli"] + tui["kebab-tui"] + desktop["kebab-desktop
(P9-5)"] + end + app["kebab-app
(facade)"] + subgraph Ingest ["ingest pipeline"] + srcfs["kebab-source-fs"] + pmd["kebab-parse-md"] + ppdf["kebab-parse-pdf"] + pimg["kebab-parse-image"] + paud["kebab-parse-audio
(P8 보류)"] + ptypes["kebab-parse-types"] + norm["kebab-normalize"] + chunk["kebab-chunk"] + end + subgraph Persist ["persistence"] + sqlite["kebab-store-sqlite"] + vector["kebab-store-vector"] + end + subgraph Adapters ["traits + adapters"] + embed["kebab-embed
(trait)"] + embedlocal["kebab-embed-local
(fastembed)"] + llm["kebab-llm
(trait)"] + llmlocal["kebab-llm-local
(Ollama)"] + search["kebab-search"] + rag["kebab-rag"] + end + eval["kebab-eval"] + config["kebab-config"] + core["kebab-core
(domain types)"] + + cli --> app + tui --> app + desktop --> app + + app --> srcfs + app --> pmd + app --> ppdf + app --> pimg + app --> paud + app --> norm + app --> chunk + app --> sqlite + app --> vector + app --> embedlocal + app --> llmlocal + app --> search + app --> rag + app --> eval + app --> config + + pmd --> ptypes + ppdf --> ptypes + pimg --> ptypes + paud --> ptypes + norm --> ptypes + embedlocal --> embed + llmlocal --> llm + rag --> search + rag --> llm + rag --> sqlite + search --> sqlite + search --> vector + search --> embed + eval --> app + + config --> core + embed --> core + llm --> core + sqlite --> core + vector --> core + chunk --> core + norm --> core + ptypes --> core + search --> core + rag --> core + srcfs --> core + eval --> core ``` UI → store/llm/parse 직접 의존 금지. 모든 user-facing 진입은 `kebab-app` facade 만 통한다 (frozen 설계 §8). `kebab-cli` 가 `--config ` flag 를 honor 하려면 `kebab_app::*_with_config(cfg, …)` companion 을 통해 Config 을 명시적으로 thread 하는 패턴 — 자세한 이유는 [tasks/HOTFIXES.md](../tasks/HOTFIXES.md) 의 `--config` 항목. diff --git a/docs/components/README.md b/docs/components/README.md new file mode 100644 index 0000000..cceb30d --- /dev/null +++ b/docs/components/README.md @@ -0,0 +1,103 @@ +# Components + +> 책임 단위 그룹별 contributor 향 상세. 사용자 향 grand picture 는 [README.md](../../README.md), 상위 crate 의존 그래프 + 디렉토리 구조 + locked-in 결정은 [docs/ARCHITECTURE.md](../ARCHITECTURE.md), 진척도는 [HANDOFF.md](../../HANDOFF.md), per-task spec 은 [tasks/INDEX.md](../../tasks/INDEX.md). + +각 그룹 페이지는 동일 템플릿: 구성 crate / 구조 다이어그램 / data flow 다이어그램 / 주요 type / 외부 의존 / 핵심 결정 (HOTFIXES + spec 의 "왜") / 관련 spec / HOTFIXES. + +## 그룹 wiring + +12 그룹 간 호출/의존 흐름. 점선 = `Foundation` 이 모두에 의존. UI 는 `App facade` 만 통해 다른 그룹 도달. + +```mermaid +flowchart TB + subgraph Surfaces ["UI surface"] + UI["UI
(cli + tui)"] + end + subgraph Orchestration ["orchestration"] + AppFacade["App facade
(kebab-app)"] + RAG["RAG"] + Eval["Eval"] + end + subgraph IngestPipe ["ingest pipeline"] + Source["Source"] + Parse["Parse"] + NormChunk["Normalize+Chunk"] + end + subgraph IndexQuery ["index + retrieval"] + Embed["Embed"] + Store["Store"] + Search["Search"] + end + subgraph Generation ["generation"] + LLM["LLM"] + end + Foundation["Foundation
(core + parse-types + config)"] + + UI --> AppFacade + AppFacade --> Source --> Parse --> NormChunk + NormChunk --> Store + NormChunk --> Embed --> Store + AppFacade --> Embed + AppFacade --> Search + Search --> Store + Search --> Embed + AppFacade --> RAG + RAG --> Search + RAG --> LLM + RAG --> Store + AppFacade --> Eval + Eval --> AppFacade + Eval --> Store + + Foundation -.-> Source + Foundation -.-> Parse + Foundation -.-> NormChunk + Foundation -.-> Embed + Foundation -.-> Store + Foundation -.-> Search + Foundation -.-> LLM + Foundation -.-> RAG + Foundation -.-> AppFacade + Foundation -.-> UI + Foundation -.-> Eval +``` + +## 그룹 목록 + +| 그룹 | 역할 | 페이지 | +|------|------|--------| +| **Foundation** | 도메인 type + 설정 + parser IR. 모든 crate 의 zero-dep 토대. | [foundation/](foundation/) | +| **Source** | 워크스페이스 walk + .kebabignore + BLAKE3 checksum → `RawAsset`. | [source/](source/) | +| **Parse** | bytes → `ParsedBlock` (md) 또는 `CanonicalDocument` (pdf/image). OCR + caption 어댑터. | [parse/](parse/) | +| **Normalize+Chunk** | `ParsedBlock` → `CanonicalDocument` lift (markdown only) + 모든 미디어 → `Vec` (md/pdf 변종 chunker). | [normalize-chunk/](normalize-chunk/) | +| **Store** | SQLite (V001-V005, FTS5, jobs, chat sessions) + LanceDB (per-model vector 테이블) two-phase write. | [store/](store/) | +| **Embed** | `Embedder` trait + fastembed-rs 어댑터 (multilingual-e5-small 384d). | [embed/](embed/) | +| **Search** | lexical (FTS5 BM25) + vector (ANN) + hybrid (RRF) — `Retriever` trait 3 변종. | [search/](search/) | +| **LLM** | `LanguageModel` trait + Ollama HTTP 어댑터 (`gemma4:e4b` default). streaming + cancel-safe. | [llm/](llm/) | +| **RAG** | retrieve → gate → pack → generate → cite-validate → persist 9 stage pipeline. multi-turn 지원. | [rag/](rag/) | +| **App facade** | `kebab-app` — 모든 UI binary 의 유일한 진입점. `*_with_config` companion 패턴. | [app-facade/](app-facade/) | +| **UI** | `kebab-cli` (`--json` wire envelope) + `kebab-tui` (4 패널 + Mode machine + cheatsheet). | [ui/](ui/) | +| **Eval** | golden query 회귀 평가 + run-vs-run compare. `must_contain` rule-based. | [eval/](eval/) | + +## 진입 가이드 + +처음 읽는다면 (의존성 따라 bottom-up): + +1. **Foundation** — 다른 모든 페이지가 참조하는 type 정의. `AssetId` / `DocumentId` / `Chunk` / `Citation` / 5 version 등. +2. **Source → Parse → Normalize+Chunk → Store** — ingest pipeline 흐름. +3. **Embed → Search** — retrieval. +4. **LLM → RAG** — generation. +5. **App facade** — 위 전부 wiring. +6. **UI** — facade 위. +7. **Eval** — 독립. + +특정 작업 별 진입: + +- **새 미디어 타입 추가** (예: epub) — Parse → Normalize+Chunk → Store (chunker_version) → App facade (라우팅). +- **새 retrieval 모드** — Search → App facade (mode dispatch) → UI (--mode flag). +- **새 LLM 어댑터** — LLM (trait crate, 새 type 금지) + 새 `kebab-llm-` crate → App facade (config provider switch). +- **TUI 신규 pane** — UI 만. Mode + Theme + InputBuffer 재사용. + +## 다이어그램 제약 + +각 그룹 페이지의 다이어그램은 `mermaid` (Gitea / GitHub 자동 렌더). 페이지 별 최소 2개 — **구조** (type/trait/struct 관계) + **data flow** (입출력 흐름). 실제 코드와 시그니처 일치 — 작성 시 `crates/kebab-/src/lib.rs` 직접 읽음 (추측 금지). diff --git a/docs/components/app-facade/README.md b/docs/components/app-facade/README.md new file mode 100644 index 0000000..41eb1c8 --- /dev/null +++ b/docs/components/app-facade/README.md @@ -0,0 +1,182 @@ +# App facade + +> `kebab-app` 은 모든 UI binary (cli/tui/desktop) 가 의존하는 **유일한** 진입점. 도메인 type 만 반환 (wire envelope 은 UI 측 책임). `*_with_config` companion 패턴으로 explicit Config threading 보장. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-app` | high-level facade. 모든 pipeline crate 를 wire 해서 ingest / search / ask / list / inspect / doctor / reset / init 8개 op 노출. `App` lifecycle struct + `*_with_config` companion + `IngestEvent` streaming + cooperative cancel. | + +## 구조 + +```mermaid +classDiagram + class App { + +open_with_config(cfg) Self + +embedder() Option~Arc~dyn Embedder~~ + +vector() Option~Arc~LanceVectorStore~~ + +llm() Arc~dyn LanguageModel~ + +search(query) Vec~SearchHit~ + +ask_with_session(session_id, query, opts) Answer + -config: Config + -sqlite: Arc~SqliteStore~ + -embedder: OnceLock + -vector: OnceLock + -llm: OnceLock + -search_cache: Option~Mutex~LruCache~~ + } + class FreeFns { + <> + init_workspace(force) + ingest(scope, summary_only) + list_docs(filter) + inspect_doc(id) / inspect_chunk(id) + search(query) + ask(query, opts) + doctor() + } + class WithConfig { + <<#[doc(hidden)] pub fn>> + ingest_with_config + ingest_with_config_progress + ingest_with_config_cancellable + list_docs_with_config / inspect_*_with_config + search_with_config / search_uncached_with_config + ask_with_config / ask_with_session_with_config + doctor_with_config_path + } + class IngestEvent { + <> + Started{total} + AssetStarted{path} + AssetCompleted{counts} + Completed{counts} + Aborted{partial_counts} + } + class ResetReport { + scope: ResetScope + wiped: Vec~PathBuf~ + } + class DoctorReport { + schema_version: u32 + checks: Vec~DoctorCheck~ + } + FreeFns ..> WithConfig : load_config + delegate + App ..> WithConfig : long-lived path + WithConfig ..> IngestEvent : stream channel +``` + +## Data flow — `kebab ingest` (cancellable + progress) + +```mermaid +flowchart LR + CLI["kebab ingest --json
(또는 TUI worker)"] + Cfg["Config::load(--config)"] + App2["App::open_with_config
(SQLite, lazy embedder/vector)"] + Walk["FsSourceConnector.scan
(Source 그룹)"] + Loop["per asset 루프
(cancel poll 매 iter)"] + Route["MediaType 라우팅
md / pdf / image"] + Parse["Parse 그룹"] + Norm["Normalize+Chunk 그룹"] + Emb["Embed 그룹 (선택)"] + Store["Store 그룹
two-phase upsert"] + Bump["bump_corpus_revision
(p9-fb-19)"] + Event["IngestEvent::*
→ Sender~IngestEvent~"] + Report["IngestReport
(stage_counts, warnings)"] + Aborted["IngestEvent::Aborted
partial commit 보존"] + CLI --> Cfg --> App2 --> Walk --> Loop --> Route --> Parse --> Norm --> Emb --> Store + Loop -.cancel=true.-> Aborted + Loop -.progress.-> Event + Store --> Bump --> Report +``` + +## Data flow — `kebab ask --session ` (multi-turn) + +```mermaid +flowchart LR + Q["query + session_id"] + Repo["ChatSessionRepo
get_session / list_turns"] + History["Vec~Turn~
(newest-first)"] + RAG["RagPipeline.ask_with_history"] + Append["append_turn
(parent updated_at bump)"] + Out["Answer
(conversation_id + turn_index)"] + Q --> Repo --> History --> RAG --> Append --> Out + Q -.first call.-> CreateSession["create_session
title = 첫 question 40 chars"] + CreateSession --> Repo +``` + +## 주요 type / trait / 함수 + +**Top-level free fn** (사용자 향, XDG Config 자동 로드): +- `init_workspace(force: bool)` — `~/.config/kebab/config.toml` + `~/.local/share/kebab/` 생성. path policy comment 자동 prepend (p9-fb-05). +- `ingest(scope, summary_only) -> IngestReport` — workspace 전체 ingest. +- `list_docs(filter) / inspect_doc(id) / inspect_chunk(id) / search(query) / ask(query, opts) / doctor()` — 그대로. + +**`*_with_config` companion** (`#[doc(hidden)] pub fn`, but **공식** API): +- `ingest_with_config(cfg, scope, summary_only)` — 가장 단순. +- `ingest_with_config_progress(cfg, scope, progress: Option>)` — TTY 진행 표시 / `--json` line-delimited 용. +- `ingest_with_config_cancellable(cfg, scope, progress, cancel: Option>)` — 위 + cooperative cancel. asset loop iter 시작에서 poll → true 면 break + `Aborted{partial_counts}` + `Ok(IngestReport)` 반환 (Err 아님). 부분 commit 보존. +- `search_with_config / search_uncached_with_config` — 후자는 LRU cache bypass (debug). +- `ask_with_session_with_config(cfg, session_id, query, opts)` — multi-turn (p9-fb-18). +- `doctor_with_config_path(Option<&Path>)` — config 경로 explicit. + +**`App` lifecycle** (long-lived caller 향 — kebab-eval, future TUI session): +- `App::open_with_config(cfg) -> Result` — SQLite open + migration. embedder/vector/llm 은 lazy `OnceLock` 으로 첫 호출에서 build. +- `App::embedder() -> Option<...>` — `provider == "none"` 또는 `dimensions == 0` 이면 `None` (lexical-only fallback). +- `App::ask_with_session(session_id, query, opts)` — repo + RAG ask + 새 turn append 한 묶음 (p9-fb-18). +- `App::search(query)` — LRU cache lookup → miss 시 `search_uncached` → put. cache key = `(query_norm, mode, k, snippet_chars, embedding_version, chunker_version, corpus_revision)`. + +**`IngestEvent`** (`kebab-app::ingest_progress`): +- `Started { total }` / `AssetStarted { workspace_path }` / `AssetCompleted { counts }` / `Completed { counts }` / `Aborted { partial_counts }`. terminal frame 후 sender drop. + +**`ResetReport` / `ResetScope`** (`kebab-app::reset`): +- `--all` / `--data-only` / `--vector-only` / `--config-only` (p9-fb-06). +- TTY 가 아니면 `--yes` 필수 (silent destruction 방어). +- `--vector-only` 가 SQLite `embedding_records` 도 truncate (off-disk Lance dir wipe 시 orphan 방지). + +## 외부 의존 + +- crate dep: 거의 모든 kebab-* (source-fs, parse-md/pdf/image, normalize, chunk, store-sqlite, store-vector, embed-local, llm-local, search, rag, config, core). +- 외부 lib: `lru` (search cache), `serde`, `anyhow`, `tracing`, `time`, `ctrlc` (CLI signal). +- 외부 서비스: 없음 (down-stream adapter 가 가져옴). + +## 핵심 결정 + +- **Facade rule: UI binary 는 `kebab-app` 만 import**. + **왜**: store / llm / parse / search 직접 import 가 boundary 깨짐 → UI 가 SQLite 를 알면 swap 안 됨, ONNX 를 알면 cold start 비대화. `kebab-app` 만 알게 하면 future MCP server / HTTP wrapper 도 같은 contract 위에 build. + +- **`*_with_config` companion = 공식 API (test seam 아님)**. + **왜**: top-level `ingest()` 는 `Config::load(None)` 으로 XDG default 만 읽음 → `kebab-cli --config /tmp/foo.toml` 가 silently bypass 되는 회귀 두 번 (HOTFIXES P3-5, P4-3). `kebab-cli` 는 항상 `*_with_config` 호출 — 이 패턴이 spec literal 을 후행 강화. `#[doc(hidden)]` 는 rustdoc 깨끗함만, public 그대로. + +- **`App.{embedder, vector, llm}` 가 `OnceLock` lazy + memoized**. + **왜**: `kebab list` / `kebab inspect` / `--mode lexical` 가 ONNX (~470 MB) + Lance reopen 비용 0 이어야 함. 매 CLI invocation 가 cold start 부담 = sub-second 가 다 망가짐. 첫 사용 시 build, 같은 `App` 재사용 시 재 build 안 함 (kebab-eval 50 query suite, TUI session). + +- **embedding 비활성 모드 (`provider = "none"` 또는 `dimensions = 0`)**. + **왜**: 사용자 환경 (헤드리스 / OS 미지원) 에서 embedding 끄고 lexical-only 쓰는 escape hatch. `App::embedder()` 가 `None` 반환 → search 가 mode=lexical 에 falls back. config-only switch. + +- **`ingest_with_config*` 3개 함수 (vanilla / progress / cancellable)**. + **왜**: 호출 사이트 별 capability 다름. CLI 가 progress 만 필요 / TUI worker 가 progress + cancel 둘 다. 단일 함수에 `Option` + `Option` 다 받게 하면 caller 시그니처 noisy. 3 layered: vanilla → progress wraps it (cancel=None) → cancellable wraps progress. + +- **`Aborted` = `Ok(IngestReport)`, `Err` 아님**. + **왜**: cancel 은 사용자 의도. partial commit 보존 (다음 ingest idempotent 재개). `Err` 로 처리하면 caller 가 cleanup 강요, partial 도 sweep 됨. wire 측에서 `IngestEvent::Aborted` frame 으로 cancel signal 분명, `IngestReport.partial_counts` 가 진행 상황 보유. + +- **`App.search_cache` LRU + `corpus_revision` snapshot**. + **왜**: TUI 의 매 keystroke debounced search 가 같은 query 반복. capacity 256 (~1.3 MB) cap. cache key 의 `corpus_revision` snapshot 이 ingest commit 후 자동 invalidation — 사용자가 문서 추가하면 다음 search 가 새로 쿼리 (p9-fb-19). + +- **wire-schema envelope = UI 측 (`kebab-cli/wire.rs`) 책임**. + **왜**: `kebab-app` 의 함수가 pure 도메인 type 만 반환 → kebab-tui 가 wire envelope 안 거치고 in-memory 직접 사용. `--json` 출력은 cli 가 `*.v1` envelope 으로 wrap. `DoctorReport` 만 예외 — 자체 `schema_version` 보유 (도메인 측 동등 type 없음). + +- **multi-turn 세션 진입 = `App` 메서드 (`ask_with_session`)**. + **왜**: 세션 복구 + history 빌드 + ChatSessionRepo append 가 한 transaction. caller (CLI / TUI) 가 매번 free fn 으로 호출하면 매번 `App::open` → ONNX cold start. `App` 위 메서드 = 한 번 open 후 N 번 ask. + +## 관련 spec / HOTFIXES + +- frozen 설계 §2.4a (ingest progress wire), §3.8 (Answer / Turn), §5.7a (chat sessions), §6.4 (defaults), §7 (facade), §8 (boundary), §10 (long-running ops), §11 (errors): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - app skeleton + ingest wiring: [`tasks/p3/p3-5-app-wiring.md`](../../../tasks/p3/p3-5-app-wiring.md), [`tasks/p6/p6-4-image-ingest-wiring.md`](../../../tasks/p6/p6-4-image-ingest-wiring.md), [`tasks/p7/p7-3-pdf-ingest-wiring.md`](../../../tasks/p7/p7-3-pdf-ingest-wiring.md) + - reset: [`tasks/p9/p9-fb-06-data-reset-command.md`](../../../tasks/p9/p9-fb-06-data-reset-command.md) + - ingest progress / cancel: [`tasks/p9/p9-fb-03-tui-ingest-background.md`](../../../tasks/p9/p9-fb-03-tui-ingest-background.md), [`tasks/p9/p9-fb-04-ingest-cancellation.md`](../../../tasks/p9/p9-fb-04-ingest-cancellation.md) + - search cache: [`tasks/p9/p9-fb-19-search-cache.md`](../../../tasks/p9/p9-fb-19-search-cache.md) + - chat session CLI: [`tasks/p9/p9-fb-18-cli-ask-session-repl.md`](../../../tasks/p9/p9-fb-18-cli-ask-session-repl.md) +- HOTFIXES (P3-5/P4-3 `--config` 누락 + `*_with_config` 패턴, P7-3 storage UNIQUE bug, p9-fb-* 도그푸딩 후속): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/embed/README.md b/docs/components/embed/README.md new file mode 100644 index 0000000..15975ec --- /dev/null +++ b/docs/components/embed/README.md @@ -0,0 +1,119 @@ +# Embed + +> Chunk text → 단위 정규화된 벡터. trait + impl 패턴으로 future swap (candle / ollama-embed) 가능. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-embed` | `Embedder` trait re-export + 테스트 도구 (`assert_vector_shape`, `assert_unit_norm`) + optional `MockEmbedder` (feature gated). 새 type 추가 **금지** — 순수 facade. | +| `kebab-embed-local` | `FastembedEmbedder` — fastembed-rs 위 ONNX-backed local 임베더. default `multilingual-e5-small` 384d. | + +## 구조 + +```mermaid +classDiagram + class Embedder { + <> + model_id() EmbeddingModelId + model_version() EmbeddingVersion + dimensions() usize + embed(inputs) Vec~Vec~f32~~ + } + class EmbeddingInput { + text: &str + kind: EmbeddingKind + } + class EmbeddingKind { + <> + Document + Query + } + class FastembedEmbedder { + +new(config) Result~Self~ + -inner: Mutex~TextEmbedding~ + -model_id, version, dimensions, batch_size + } + class MockEmbedder { + feature = "mock" + deterministic test double + } + Embedder <|.. FastembedEmbedder + Embedder <|.. MockEmbedder + Embedder ..> EmbeddingInput + EmbeddingInput ..> EmbeddingKind +``` + +## Data flow + +```mermaid +flowchart LR + Chunks["Vec~Chunk~
(kebab-chunk)"] + Inputs["EmbeddingInput
{text, kind}"] + Prefix["E5 prefix
Document → 'passage: '
Query → 'query: '"] + Batch["batch by config.batch_size"] + Onnx["fastembed TextEmbedding
(ONNX session, Mutex)"] + L2["L2 정규화
(fastembed 내장)"] + Vec["Vec~Vec~f32~~
unit norm, finite"] + Inputs --> Prefix --> Batch --> Onnx --> L2 --> Vec + Chunks -.text.-> Inputs + Query["사용자 query string
(kebab-search)"] -.text+Query.-> Inputs + Vec --> VStore["kebab-store-vector"] + Vec --> Search["kebab-search
(query 경로)"] +``` + +## 주요 type / trait / 함수 + +**Trait** (`kebab-core`, re-export `kebab-embed`): +- `Embedder::embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result>>` — 출력 shape `inputs.len()` × `dimensions()`. 결과 벡터 모두 L2 = 1 + finite. +- `EmbeddingInput { text: &str, kind: EmbeddingKind }` — kind = `Document` / `Query` (E5 prefix 분기). +- `EmbeddingModelId(String)`, `EmbeddingVersion(String)` — `model_id × version × dim` 으로 vector store 테이블 분리. + +**FastembedEmbedder** (`kebab-embed-local`): +- `FastembedEmbedder::new(config: &kebab_config::Config) -> Result` — 모델 파일 캐시 위치 = `{model_dir}/fastembed/`. 첫 호출 시 ONNX + tokenizer 다운로드. `config.models.embedding.dimensions` 가 실제 모델 차원과 다르면 즉시 `Err` (런타임 silent mismatch 회피). +- `Mutex` 으로 inner 세션 직렬화 — fastembed 4.9 가 `&self` 지만 보수적 lock. kebab-app 의 indexer 가 어차피 순차 batch 라 contention 없음. +- E5 prefix 자동 적용: `Document` → `"passage: "`, `Query` → `"query: "` (§11.3). +- L2 정규화 = fastembed 내장 (`transformer_with_precedence`). 별도 정규화 안 함, 단 `assert_unit_norm` 테스트로 invariant pin. + +**테스트 도구** (`kebab-embed`): +- `assert_vector_shape(&[Vec], expected_dims)` — 길이 + finite 검증. +- `assert_unit_norm(&[Vec], tolerance)` — L2 norm 이 `1.0 ± tolerance`. f32 384d 권장 tol = `5e-4`. +- `MockEmbedder` (feature `mock`, default OFF) — 테스트용 deterministic double. 실 어댑터는 `kebab-embed-local` 또는 future P+ adapter 가 담당. + +## 외부 의존 + +- `kebab-embed` → `kebab-core` 만 (re-export crate). +- `kebab-embed-local` → `kebab-embed` + `kebab-config`, `fastembed`, `anyhow`. +- 외부 lib: `fastembed-rs` (ONNX wrapper, Hugging Face 모델 다운로드 포함). 로컬 ORT runtime. +- 외부 서비스: 첫 호출 시 모델 다운로드 (Hugging Face). 그 후 오프라인. + +## 핵심 결정 + +- **`kebab-embed` = trait re-export only, **새 type 금지****. + **왜**: `kebab-store-vector`, `kebab-search` 등 downstream 이 `use kebab_embed::Embedder` 안정 surface 의존. `kebab-core` 재구성 시 trait 이동해도 downstream 안 깨짐. spec 가 명시 — 어댑터 코드는 `kebab-embed-local` 또는 future `kebab-embed-` 로. + +- **`multilingual-e5-small` 384d default**. + **왜**: 한국어 + 영어 동시 강함, ONNX 작음 (~120MB), 384d 가 retrieval 정확도/저장 비용 균형 좋음. e5 prefix 컨벤션 (`"passage: "` / `"query: "`) 으로 같은 모델이 doc + query 두 모드 cover. + +- **L2 정규화 = fastembed 내장에 위임**. + **왜**: fastembed 4.x 가 `transformer_with_precedence` 에서 이미 L2. 두 번 정규화 = 비용 + numerical drift. invariant 가 깨지면 `assert_unit_norm` 테스트가 즉시 실패 — fastembed 가 default 바꾸면 회귀 잡힘. + +- **`Mutex` 보수적 직렬화**. + **왜**: fastembed `&self` API 라 in principle 병렬 가능, 그러나 ORT Session 의 thread-safety 가 backend 별로 다름. indexer 가 어차피 순차 batch 라 contention 없음. profiling 에서 병목 보이면 그때 풀음. + +- **dim mismatch = 생성자에서 즉시 fail**. + **왜**: `config.models.embedding.dimensions = 384` 가 실제 모델 차원과 다르면 첫 `embed` 호출에서야 발견 → 운영 시 ingest 절반 진행 후 죽음. 생성자에서 검증 = early exit, 사용자가 즉시 config 수정. + +- **모델 캐시 = `{model_dir}/fastembed/` 고정 서브디렉토리**. + **왜**: spec literal. `model_dir` 가 `{data_dir}/models` default → 사용자가 한 곳에 모든 모델 캐시. fastembed 외 어댑터 (candle / ggml / ...) 는 자기 서브디렉토리 사용해서 충돌 회피. + +- **`MockEmbedder` feature gate (default OFF)**. + **왜**: production binary 가 mock 코드를 포함 안 함. test crate 가 `features = ["mock"]` 로 명시 opt-in. + +## 관련 spec / HOTFIXES + +- frozen 설계 §7.1 (helper input types `EmbeddingInput`/`EmbeddingKind`), §7.2 (`Embedder` trait), §11.3 (E5 prefix), §6.4 (`models.embedding`), §9 (versioning): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - trait crate: [`tasks/p3/p3-1-embed-trait.md`](../../../tasks/p3/p3-1-embed-trait.md) + - fastembed adapter: [`tasks/p3/p3-2-embed-local.md`](../../../tasks/p3/p3-2-embed-local.md) +- HOTFIXES: 이 그룹은 머지 후 deviation 없음. diff --git a/docs/components/eval/README.md b/docs/components/eval/README.md new file mode 100644 index 0000000..ec6a58d --- /dev/null +++ b/docs/components/eval/README.md @@ -0,0 +1,171 @@ +# Eval + +> Golden query 회귀 평가. fixture YAML 로드 → kebab-app facade 통해 실행 → 결과 + 메트릭을 SQLite + per_query.jsonl 로 저장. 두 run 간 비교 (compare). LLM-as-judge 안 함 — rule-based `must_contain`. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-eval` | golden fixture loader + run executor + 메트릭 computer + run-vs-run comparison + markdown 리포트. retrieval/embedding/LLM crate 직접 의존 **금지** — 모두 `kebab-app` facade 통해서만 (P5-1 inheritance). | + +## 구조 + +```mermaid +classDiagram + class GoldenQuery { + id: String + query: String + mode: SearchMode + k: usize + must_contain: Vec~String~ + expected_doc_ids: Option~Vec~String~~ + ask: bool + } + class QueryResult { + query_id + chunks_returned: u32 + top_score + contains_all: bool + ask_grounded: Option~bool~ + ask_refusal_reason + per_query_jsonl_path + } + class EvalRun { + run_id: String + started_at + finished_at + config_snapshot_json: String + results: Vec~QueryResult~ + } + class AggregateMetrics { + recall_at_k: HashMap~k,f32~ + precision_at_k: HashMap~k,f32~ + no_hit_rate: f32 + refusal_rate: f32 + ask_grounded_rate: Option~f32~ + } + class CompareReport { + baseline: EvalRun + candidate: EvalRun + per_query: Vec~QueryComparison~ + kind: ComparisonKind + } + class ComparisonKind { + <> + Better + Same + Regressed + } + EvalRun --> QueryResult + EvalRun --> AggregateMetrics + CompareReport --> EvalRun + CompareReport --> QueryComparison +``` + +## Data flow — eval run + +```mermaid +flowchart LR + YAML["fixtures/golden_queries.yaml"] + Load["load_golden_set"] + QSet["Vec~GoldenQuery~"] + RunId["uuid v7 simple
(timestamp-ordered)"] + Snapshot["5-version snapshot
(parser/chunker/embedding/
index/prompt_template)"] + Loop["per query 루프"] + Search["kebab_app::search_with_config"] + Ask["kebab_app::ask_with_config
(query.ask == true 시)"] + MustContain["must_contain
contains_all 검사"] + Result["QueryResult"] + Aggregate["compute_aggregate
recall@k / precision@k /
no_hit_rate / refusal_rate /
ask_grounded_rate"] + SQLiteRow["eval_runs row +
eval_query_results rows"] + Jsonl["runs_dir/<run_id>/
per_query.jsonl"] + YAML --> Load --> QSet --> Loop + RunId --> Snapshot --> SQLiteRow + Loop --> Search --> Result + Loop --> Ask --> Result + Result --> MustContain --> Aggregate + Aggregate --> SQLiteRow + Result --> Jsonl +``` + +## Data flow — compare two runs + +```mermaid +flowchart LR + Base["baseline run_id"] + Cand["candidate run_id"] + LoadRows["eval_runs + eval_query_results
rows 로드"] + Diff["per query 비교
(contains_all flip,
top_score delta,
refusal flip)"] + Kind["ComparisonKind
(Better / Same / Regressed)"] + MD["render_report_md
(human-friendly)"] + Base --> LoadRows + Cand --> LoadRows --> Diff --> Kind --> MD +``` + +## 주요 type / trait / 함수 + +**Loader** (`kebab-eval::loader`): +- `load_golden_set(path: &Path) -> Result>` — `serde_yaml` 위 YAML 파싱. +- `GoldenQuery { id, query, mode, k, must_contain, expected_doc_ids, ask }` — fixture 한 entry. `must_contain` 가 case-sensitive substring 검사. + +**Runner** (`kebab-eval::runner`): +- `run_eval(opts: EvalRunOpts) -> Result` / `run_eval_with_config(cfg, opts) -> Result`. +- `EvalRunOpts { golden_path, ask_enabled, k_override, ... }`. +- 각 query → `kebab_app::search_with_config` 호출 (모든 retrieval 은 facade 통해). `ask=true` 시 추가로 `ask_with_config`. +- `run_id` = UUID v7 simple (timestamp-ordered, lowercase hex). +- `config_snapshot_json` = 5 version 모두 (`parser_version` / `chunker_version` / `embedding_version` / `index_version` / `prompt_template_version`) 한 번에 capture → 후일 compare 가 정확히 같은 environment 인지 검증. +- `runs_dir//per_query.jsonl` 로 raw search hit + answer 저장 — SQLite 의 `eval_query_results` 는 메트릭 + 요약만, full payload 는 JSONL. + +**Metrics** (`kebab-eval::metrics`): +- `AggregateMetrics { recall_at_k, precision_at_k, no_hit_rate, refusal_rate, ask_grounded_rate }`. +- `TOP_K_VARIANTS` — 표준 k 값 set (1, 3, 5, 10, ...). 한 run 에서 multiple k 측정. +- `compute_aggregate(run: &EvalRun) -> AggregateMetrics` / `_with_config` companion. +- `store_aggregate(...)` — SQLite `eval_runs.aggregate_json` 컬럼에 저장. +- LLM-as-judge 안 함. `ask_grounded_rate` 는 `Answer.refusal_reason.is_none() && answer.citations.len() > 0` rule. + +**Compare** (`kebab-eval::compare`): +- `compare_runs(baseline_id, candidate_id) -> Result` / `_with_config`. +- `CompareOpts { include_unchanged, k_focus }`. +- `QueryComparison { query_id, baseline_result, candidate_result, deltas: ContainsFlipped/TopScoreDelta/RefusalFlipped }`. +- `ComparisonKind { Better, Same, Regressed }` — overall verdict. +- `render_report_md(&CompareReport) -> String` — markdown 본문 (PR 리뷰 첨부 용도). + +## 외부 의존 + +- crate dep: `kebab-core` + `kebab-config` + `kebab-app` (facade only) + `kebab-store-sqlite` (SQLite 직접 read/write — `eval_runs` / `eval_query_results` 측). retrieval / embedding / LLM crate 직접 import **금지**. +- 외부 lib: `serde_yaml` (golden YAML), `serde_json`, `uuid` (v7), `time`, `tracing`, `anyhow`. +- 외부 서비스: 없음 (facade 가 가져옴). + +## 핵심 결정 + +- **runner 가 `kebab-app` facade 통해서만 실행 (직접 retrieval 금지)**. + **왜**: facade rule (P5-1 inheritance). retrieval/embedding/LLM 의 swap 가 eval 의 contract 깨면 안 됨. 같은 facade 호출이 production 에서도 → eval 결과가 production 동작과 등가. + +- **rule-based `must_contain`, LLM-as-judge 거부**. + **왜**: LLM judge 가 stochastic + 비용 발생 + 모델 swap 시 baseline 회귀. `must_contain` substring 검사가 deterministic + 무료 + 사용자가 fixture 작성 시 이미 의도 명시. spec §11 의 비-목표 명시. + +- **5-version `config_snapshot_json`**. + **왜**: 두 eval run 비교 시 environment drift 잡음. parser_version / chunker_version 한 단계라도 다르면 비교 무의미 (다른 chunk_id, 다른 retrieval 결과). compare 가 mismatch 시 경고. + +- **per_query.jsonl off-disk + SQLite metrics-only**. + **왜**: SQLite row 가 raw search hit + answer 본문 다 보관하면 row 가 거대해짐 + FTS5 인덱스 노이즈. JSONL 은 개별 파일 → 디스크 저렴, append-only 안전, jq / fzf 로 ad-hoc 분석 가능. SQLite 는 메트릭 + 요약만. + +- **UUID v7 run_id**. + **왜**: timestamp 순 정렬 (v4 random 은 정렬 안 됨) + collision-free + lowercase hex. `runs_dir//` 가 자연 정렬 시 시간 순. + +- **`ask_grounded_rate` = `refusal == None && citations > 0`**. + **왜**: "grounded" 정의가 spec §3.8 — refusal 아니고 citation 보유. LLM 의 답변 품질 (hallucination 여부) 평가 LLM-judge 없이는 불가능, 가까운 proxy 가 grounded rate. + +- **compare 의 `ComparisonKind` overall verdict**. + **왜**: PR review 가 "regressed 인가?" 한 줄 답이 핵심. per-query delta 모두 봐야 verdict 가 나오면 leverage 안 남. `Better` / `Same` / `Regressed` 단일 verdict + 세부 delta 가 backing. + +- **eval 자체 cancellable 안 함**. + **왜**: 50-query suite 가 ~5 분. 중도 cancel 시 partial run 가 baseline 으로 쓰이면 회귀 검출 부정. CLI Ctrl-C 면 hard exit (소실 OK), partial state 저장 안 함. + +## 관련 spec / HOTFIXES + +- frozen 설계 §5.7 (eval_runs / eval_query_results), §6.3 (runs_dir), §11 (비-목표 = LLM-as-judge 금지), §9 (5-version cascade): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - runner: [`tasks/p5/p5-1-eval-runner.md`](../../../tasks/p5/p5-1-eval-runner.md) + - metrics + compare: [`tasks/p5/p5-2-eval-metrics.md`](../../../tasks/p5/p5-2-eval-metrics.md) +- HOTFIXES (P5-1 facade-inheritance 결정, P5-2 metric definition tweaks): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/foundation/README.md b/docs/components/foundation/README.md new file mode 100644 index 0000000..0421c6d --- /dev/null +++ b/docs/components/foundation/README.md @@ -0,0 +1,151 @@ +# Foundation + +> 도메인 type, 설정, parser 공통 IR — workspace 의 모든 crate 가 의존하는 zero-dependency 토대. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-core` | 도메인 type + ID recipe + trait. 다른 `kebab-*` crate 에 **의존 금지** (frozen 설계 §3, §4, §7). | +| `kebab-parse-types` | parser intermediate (`ParsedBlock`) — `kebab-core` 만 의존. parser library (`pulldown-cmark`, `lopdf` 등) 의존 금지 (§3.7b). | +| `kebab-config` | `Config` 스키마 + XDG path resolver. `defaults → file → env (KEBAB_*)` 3 layer (§6). | + +## 구조 + +```mermaid +classDiagram + class IDs { + AssetId + DocumentId + BlockId + ChunkId + EmbeddingId + IndexId + } + class Versions { + ParserVersion + ChunkerVersion + EmbeddingVersion + IndexVersion + PromptTemplateVersion + } + class DomainTypes { + RawAsset + CanonicalDocument + Block enum + Chunk + Citation + SearchHit + Answer + Turn + } + class Traits { + SourceConnector + Extractor + Chunker + Embedder + Retriever + LanguageModel + DocumentStore + VectorStore + JobRepo + ChatSessionRepo + } + class ParsedIR { + ParsedBlock + ParsedBlockKind + ParsedPayload + Warning + } + class Config { + workspace + storage + indexing + chunking + models + search + rag + image + ui + } + DomainTypes ..> IDs : carries + DomainTypes ..> Versions : stamps + Traits ..> DomainTypes : produce/consume + ParsedIR ..> DomainTypes : Inline + SourceSpan + Config ..> Versions : seeds (parser/chunker/embedding) +``` + +## Data flow — ID recipe + +모든 ID 는 동일한 recipe (§4.2). tuple 의 (kind, key fields) → 정렬된 canonical JSON → `blake3` → hex 앞 32자. + +```mermaid +flowchart LR + Tuple["tuple
(kind, key fields)"] + JSON["canonical JSON
(JCS, alphabetical key order)"] + Hash["blake3 32 byte digest"] + Hex["hex string 앞 32자"] + ID["AssetId/DocumentId/
BlockId/ChunkId/
EmbeddingId/IndexId"] + Tuple --> JSON --> Hash --> Hex --> ID +``` + +핵심 invariant: 같은 tuple → 같은 ID, 무한 idempotent. `id_for_*` helper 가 tuple 조립까지 캡슐화 — caller 는 입력만 넘김. + +## 주요 type / trait / 함수 + +**IDs / 버전** (`ids.rs`, `versions.rs`): +- `AssetId(String)` — 32 hex, `blake3` content addressed. +- `id_for_doc(&WorkspacePath, &AssetId, &ParserVersion) -> DocumentId` — `parser_version` 갈리면 doc 도 갈림 (cascade). +- `id_for_chunk(&DocumentId, &ChunkerVersion, &[BlockId], policy_hash: &str) -> ChunkId` — `policy_hash` 가 chunk-policy 변화 capture. +- `id_for_embedding(&ChunkId, &EmbeddingModelId, &EmbeddingVersion, dims: usize) -> EmbeddingId`. + +**도메인 type** (`document.rs`, `chunk.rs`, `citation.rs`, `answer.rs`): +- `Block` (enum) — `Heading`, `Paragraph`, `Code`, `List`, `Table`, `ImageRef`, `AudioRef`, `Quote`. `CanonicalDocument` 가 보유. +- `Citation` — URI fragment (`path#L12-L34` / `path#p=12` / `path#xywh=…`). +- `Answer { text, citations, refusal_reason: Option, conversation_id, turn_index, ... }` — multi-turn 메타 (p9-fb-15). +- `Turn { question, answer, ... }` — chat history. + +**Trait** (`traits.rs`) — pipeline contract. 자세한 내용은 각 그룹 페이지: +- `Extractor` (→ Parse), `Chunker` (→ Normalize+Chunk), `Embedder` (→ Embed), `Retriever` (→ Search), `LanguageModel` (→ LLM), `DocumentStore` / `VectorStore` (→ Store), `ChatSessionRepo` (→ Store, p9-fb-17). + +**ParsedBlock IR** (`kebab-parse-types`): +- `ParsedBlock { kind, heading_path, source_span, payload: ParsedPayload }` — 모든 parser 의 공통 출력. +- `Warning { kind: WarningKind, note }` — `MalformedFrontmatter` / `MalformedTable` / `EncodingFallback` / `ExtractFailed`. + +**Config** (`kebab-config`): +- `Config::load(Option<&Path>) -> anyhow::Result` — 3 layer merge. +- `Config::resolve_workspace_root(&self) -> PathBuf` — relative `workspace.root` 을 config 파일 디렉토리 기준으로 해석 (p9-fb-05). +- `Config::xdg_config_path() / xdg_data_dir() / xdg_cache_dir() / xdg_state_dir()` — XDG 표준 디렉토리. +- env override 키 패턴: `KEBAB_
_` (예: `KEBAB_RAG_SCORE_GATE`, `KEBAB_SEARCH_DEFAULT_K`). 알려지지 않은 키는 silently ignore. + +## 외부 의존 + +- `kebab-core`: `serde` + `serde_json` + `serde_json_canonicalizer` (JCS) + `blake3` + `time` + `uuid`. parser/store/llm crate 의존 **금지**. +- `kebab-parse-types`: `kebab-core` + `serde` 만. +- `kebab-config`: `kebab-core` + `serde` + `toml` + `dirs` + `tracing`. + +## 핵심 결정 + +- **ID recipe = tuple → JCS → blake3[..32]**. + **왜**: 동일 tuple → 항상 동일 ID. JCS (RFC 8785) 가 key 정렬을 강제해서 struct field 순서가 hash 에 영향 안 미침. 32 hex (128 bit) 가 충돌 무시할 만큼 작고 SQLite TEXT PK 로 다루기 좋음. + **검증**: `tests::id_for_*_pinned` 가 외부 도구 (`b3sum`) 로 hand-computed 한 hex 와 매칭 — JCS / hash pipeline 의 회귀를 즉시 잡음. + +- **`parser_version` / `chunker_version` / `embedding_version` / `index_version` / `prompt_template_version` 5 cascade**. + **왜**: 각 단계 산출물의 ID 가 상위 version 을 tuple 에 포함 → version bump 시 downstream record 가 자동으로 무효화 (frozen 설계 §9). eval runner 가 5 개 모두 `eval_runs.config_snapshot_json` 으로 snapshot. + +- **`Config.source_dir` (`#[serde(skip)]` + `pub(crate)`)**. + **왜**: `--config /tmp/cfg.toml` + `workspace.root = "kb"` 가 `cwd` 무관하게 `/tmp/kb` 로 해석되어야 함. p9-fb-05 의 path policy. `from_file` / `load` 만 stamp 하므로 외부 호출자가 망가뜨릴 수 없음. + +- **`#[serde(default)]` 의 점진적 신설**. + **왜**: pre-P6 config 파일 (`[image]` 섹션 없음) + pre-p9-fb-14 config (`[ui]` 섹션 없음) 가 그대로 load 가능. 사용자가 `kebab init` 매번 재실행 안 해도 됨. + +- **env override 미지의 키 silent ignore**. + **왜**: `KEBAB_NOPE_FOO=garbage` 같은 환경변수가 startup 을 죽이면 안 됨. whitelist 기반 명시 매칭 — grep 으로 모든 매핑 한 눈에 확인 가능. + +## 관련 spec / HOTFIXES + +- frozen 설계 §3 (도메인 type) / §4 (ID) / §6 (Config) / §7 (trait) / §9 (cascade): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- p9-fb-05 (`workspace.root` path policy): [`tasks/p9/p9-fb-05-config-path-policy.md`](../../../tasks/p9/p9-fb-05-config-path-policy.md) +- p9-fb-15 (RAG multi-turn — `Turn`, `Answer.conversation_id`/`turn_index`): [`tasks/p9/p9-fb-15-rag-multi-turn-core.md`](../../../tasks/p9/p9-fb-15-rag-multi-turn-core.md) +- p9-fb-17 (chat session storage — `ChatSessionRow`, `ChatTurnRow`, `ChatSessionRepo`): [`tasks/p9/p9-fb-17-chat-session-storage.md`](../../../tasks/p9/p9-fb-17-chat-session-storage.md) +- HOTFIXES dated 로그 (P3-5/P4-3 `--config` 누락, P6-3 `GenerateRequest.images` 신설 등): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/llm/README.md b/docs/components/llm/README.md new file mode 100644 index 0000000..5c4a27f --- /dev/null +++ b/docs/components/llm/README.md @@ -0,0 +1,151 @@ +# LLM + +> 텍스트 + vision 생성 모델 인터페이스. `LanguageModel` trait + Ollama HTTP 어댑터. streaming 결과 + 항상 `Done` 종료 보장. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-llm` | `LanguageModel` trait re-export + `MockLanguageModel` (feature `mock`, default OFF). 새 type 추가 **금지** — 순수 facade. | +| `kebab-llm-local` | `OllamaLanguageModel` — `reqwest::blocking` 기반 Ollama `POST /api/generate` 어댑터. line-delimited JSON streaming 디코드. | + +## 구조 + +```mermaid +classDiagram + class LanguageModel { + <> + model_ref() ModelRef + context_tokens() usize + generate_stream(req) Iterator~Result~TokenChunk~~ + } + class GenerateRequest { + system: String + user: String + stop: Vec~String~ + max_tokens: usize + temperature: f32 + seed: Option~u64~ + images: Vec~String~ [base64] + } + class TokenChunk { + <> + Token(String) + Done {finish_reason, usage} + } + class FinishReason { + <> + Stop + Length + Aborted + Error(String) + } + class OllamaLanguageModel { + +new(cfg) Result~Self~ + -client: reqwest::blocking::Client + -endpoint: String + -model: String + -context_tokens: usize + } + class MockLanguageModel { + feature = "mock" + deterministic test double + } + class LlmError { + <> + ConnectionRefused + HttpStatus + Decode + ... + } + LanguageModel <|.. OllamaLanguageModel + LanguageModel <|.. MockLanguageModel + LanguageModel ..> GenerateRequest + LanguageModel ..> TokenChunk + TokenChunk ..> FinishReason + OllamaLanguageModel ..> LlmError +``` + +## Data flow + +```mermaid +flowchart LR + Req["GenerateRequest
{system, user, stop,
max_tokens, temp, seed, images}"] + Wire["JSON wire
POST /api/generate
stream: true"] + Lines["line-delimited JSON
frames"] + Token["TokenChunk::Token(...)"] + DoneOk["TokenChunk::Done
{finish_reason: Stop|Length, usage}"] + DoneErr["TokenChunk::Done
{finish_reason: Error/Aborted, usage}"] + Iter["Iterator~Result~TokenChunk~~
(lazy, Send)"] + Req --> Wire --> Lines + Lines -->|frame| Token --> Iter + Lines -->|done frame| DoneOk --> Iter + Lines -->|error/abort| DoneErr --> Iter + Caller["caller (kebab-rag)
+ assert_finish_chunk"] --> Iter +``` + +## 주요 type / trait / 함수 + +**Trait** (`kebab-core`, re-export `kebab-llm`): +- `LanguageModel::model_ref() -> ModelRef` — provider/model/version 식별. `Answer.model_ref` 으로 흘려서 wire payload 가 자가 식별. +- `LanguageModel::context_tokens() -> usize` — 모델 별 max prompt+completion 합. RAG 가 budget 계산에 사용. +- `LanguageModel::generate_stream(req: GenerateRequest) -> Result> + Send>>` — async 안 됨, 매 next() 가 blocking. 모든 stream 이 마지막에 `TokenChunk::Done` 으로 끝남 (error 케이스 포함, §0 Q5). + +**`GenerateRequest`** (`kebab-core::traits`): +- `images: Vec` (base64) — 빈 vec = text-only path. 비어있지 않으면 vision-capable adapter 가 `images: [...]` 로 wire 에 포함 (Ollama). 다른 backend 는 다르게 라우팅. `#[serde(default)]` — older snapshot 호환. + +**`TokenChunk`**: +- `Token(String)` — partial text. 누적은 caller 책임. +- `Done { finish_reason: FinishReason, usage: TokenUsage }` — 항상 마지막. `finish_reason::Aborted` 가 cancel signal, `Error(s)` 가 mid-stream 실패. + +**OllamaLanguageModel** (`kebab-llm-local::ollama`): +- `OllamaLanguageModel::new(&kebab_config::Config) -> anyhow::Result` — `config.models.llm.endpoint` + `model` + `context_tokens` + `temperature` + `seed` 읽음. **lazy connect** — network 안 침, 첫 generate_stream 에서 실패 surface. +- 내부: `reqwest::blocking::Client` — top-level async 표면 없음. (참고: reqwest 0.12 의 blocking 이 private current-thread tokio runtime wrap 해서 `cargo tree` 에 tokio 보임. invariant = "top-level tokio dep 없음 + async surface 노출 안 함".) +- streaming decode: `BufReader::lines()` 위에서 `serde_json::from_str` 로 frame 별 lazy parse → `TokenChunk::Token` yield. + +**`LlmError`** (`kebab-llm-local::error`): +- ConnectionRefused / HttpStatus(code) / Decode(json error) / Timeout / Aborted / 그 외 — `Err` 로 first chunk 전 surface 가능. + +**테스트 도구** (`kebab-llm`): +- `assert_finish_chunk(chunks: &[TokenChunk])` — 마지막이 `Done` 이어야 — 모든 stream contract pin. +- `MockLanguageModel` (feature `mock`, default OFF) — deterministic test double. 실 adapter 만 `Err` 가능, mock 은 항상 stream 시작 후 yield. + +## 외부 의존 + +- `kebab-llm` → `kebab-core` 만 (re-export crate). +- `kebab-llm-local` → `kebab-llm` + `kebab-config`, `reqwest` (`blocking` feature, JSON), `serde` + `serde_json`, `thiserror`, `anyhow`. +- 외부 서비스: **Ollama HTTP** (default `http://127.0.0.1:11434`). default 모델 `gemma4:e4b` (OCR / caption / RAG 모두 같은 family — 단일 모델 다운로드면 전 시스템 동작). + +## 핵심 결정 + +- **`kebab-llm` = trait re-export only, **새 type 금지****. + **왜**: `kebab-rag` 등 downstream 이 `use kebab_llm::LanguageModel` 안정 surface 의존. 어댑터 (Ollama/llama.cpp/candle) 는 별 crate. swap config-only. + +- **synchronous + blocking + stream iterator**. + **왜**: §0 Q5 가 streaming 명시. `async` 가 trait object 와 잘 안 맞음 (Rust async-in-trait 안정성 + Send bound 복잡). `reqwest::blocking` + line-delimited frame 의 `Iterator` 가 caller 코드 단순. RAG 가 동기 소비 + UI thread 가 별도 worker 로 spawn. + +- **모든 stream 이 `Done` 으로 끝남 (error 포함)**. + **왜**: caller 가 partial accumulation 한 텍스트 + finish reason 함께 받음. `Done(Error)` vs `Iterator::next() = None` 차이가 contract 명확. `assert_finish_chunk` 가 invariant pin. + +- **lazy connect (생성자에서 network 안 침)**. + **왜**: `kebab init` / `kebab doctor` 가 Ollama 안 떠도 동작해야 함. 첫 `generate_stream` 에서 `Err` 가 나는 게 사용자 기대 — startup 이 죽으면 진단 어려움. + +- **Ollama 가 default backend**. + **왜**: macOS / Linux 모두 single-binary install, GGUF 모델 다운로드 한 줄. local-first 의 핵심. llama.cpp / candle 어댑터는 future P+ — `LanguageModel` trait 그대로라 swap 가능. + +- **default 모델 `gemma4:e4b` (OCR / caption / RAG 통일)**. + **왜**: OCR (P6-2) + caption (P6-3) + RAG 가 같은 family 사용 → 사용자가 모델 1개만 ollama pull. variant (gemma4:26b 등) 으로 override 가능. (HOTFIXES P6-2.) + +- **`GenerateRequest.images: Vec` 추가 (P6-3)**. + **왜**: 기존 trait 가 text-only 였는데 caption 이 vision 필요. base64 image vec 으로 wire 형식 통일 — Ollama 의 `images` 필드와 1:1. text-only caller 모두 `images: Vec::new()` 마이그레이션 + `#[serde(default)]` 로 snapshot 호환. (HOTFIXES P6-3.) + +- **`MockLanguageModel` 가 `Err` 안 던짐**. + **왜**: mock 은 deterministic — first chunk 전 connection-refused 같은 케이스 시뮬레이션 안 함. 실 adapter (`OllamaLanguageModel`) 가 그 분기 책임. RAG 의 RefusalReason::LlmStreamAborted 분기 (p9-fb-15) 는 실 adapter 만 trigger. + +## 관련 spec / HOTFIXES + +- frozen 설계 §7.1 (`GenerateRequest`/`TokenChunk`/`FinishReason`), §7.2 (`LanguageModel` trait), §0 Q5 (streaming), §3.8 (`ModelRef`/`TokenUsage`), §6.4 (`models.llm`), §10 (errors), §11.2 (Ollama protocol notes): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - trait crate: [`tasks/p4/p4-1-llm-trait.md`](../../../tasks/p4/p4-1-llm-trait.md) + - Ollama adapter: [`tasks/p4/p4-2-llm-ollama.md`](../../../tasks/p4/p4-2-llm-ollama.md) +- HOTFIXES (P6-3 `GenerateRequest.images` 추가, P6-2 OCR 기본을 Ollama vision 으로 통일): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/normalize-chunk/README.md b/docs/components/normalize-chunk/README.md new file mode 100644 index 0000000..08e5b2f --- /dev/null +++ b/docs/components/normalize-chunk/README.md @@ -0,0 +1,144 @@ +# Normalize + Chunk + +> Markdown 의 `ParsedBlock` 을 도메인 `CanonicalDocument` 로 lift 하고, 모든 미디어의 `CanonicalDocument` 를 검색 단위 `Chunk` 로 자른다. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-normalize` | `ParsedBlock` (markdown only) → `CanonicalDocument` lift. NFC + heading-path ordinal + provenance 합성 + title fallback chain (p9-fb-07). | +| `kebab-chunk` | `CanonicalDocument` → `Vec`. v1 두 변종: `md-heading-v1` (markdown + image), `pdf-page-v1` (PDF). | + +## 구조 + +```mermaid +classDiagram + class Normalize { + <> + build_canonical_document(asset, metadata, blocks, parser_version, warnings) CanonicalDocument + derive_title(frontmatter, blocks, file_stem) String + nfc(s) String + } + class Chunker { + <> + chunker_version() ChunkerVersion + policy_hash(policy) String + chunk(doc, policy) Vec~Chunk~ + } + class MdHeadingV1Chunker { + VERSION = "md-heading-v1" + BYTES_PER_TOKEN = 3 + POLICY_HASH_HEX_LEN = 16 + } + class PdfPageV1Chunker { + VERSION = "pdf-page-v1" + BYTES_PER_TOKEN = 3 + POLICY_HASH_HEX_LEN = 16 + } + class ChunkPolicy { + target_tokens + overlap_tokens + respect_markdown_headings + chunker_version + } + Chunker <|.. MdHeadingV1Chunker + Chunker <|.. PdfPageV1Chunker + MdHeadingV1Chunker ..> ChunkPolicy + PdfPageV1Chunker ..> ChunkPolicy +``` + +## Data flow + +```mermaid +flowchart TD + Pblock["Vec~ParsedBlock~
(kebab-parse-md)"] + Asset["RawAsset
(kebab-source-fs)"] + Meta["Metadata
(frontmatter)"] + Pv["ParserVersion"] + Norm["build_canonical_document
NFC heading_path
+ ordinal per (path, kind)
+ derive_title fallback chain
+ Provenance accumulator"] + CDoc["CanonicalDocument
(blocks + metadata + provenance)"] + DirectCDoc["CanonicalDocument
(kebab-parse-pdf / kebab-parse-image)"] + MdC["MdHeadingV1Chunker
(markdown + image)"] + PdfC["PdfPageV1Chunker
(PDF)"] + Chunks["Vec~Chunk~"] + Asset --> Norm + Pblock --> Norm + Meta --> Norm + Pv --> Norm + Norm --> CDoc + CDoc --> MdC + DirectCDoc --> MdC + DirectCDoc --> PdfC + MdC --> Chunks + PdfC --> Chunks + Chunks --> Store["kebab-store-* (다음 그룹)"] +``` + +## 주요 type / trait / 함수 + +**Normalize** (`kebab-normalize`): +- `build_canonical_document(asset: &RawAsset, metadata: Metadata, blocks: Vec, parser_version: &ParserVersion, warnings: Vec) -> Result`. + - `doc_id = id_for_doc(workspace_path, asset_id, parser_version)`. + - 모든 `heading_path` 에 NFC 정규화 적용 (NFD `\u{1100}\u{1161}` 와 NFC `\u{AC00}` = "가" 가 같은 `block_id` 로 hash 되도록). + - ordinal = `(heading_path, block_kind)` 별 0-based, document order (§4.3). + - title 은 `metadata.user["title"]` lift 후, 비어 있으면 `derive_title(frontmatter_title, blocks, file_stem)` chain. + - `lang` 은 `metadata.user["lang"]` lift; non-string 이면 빈 `Lang`. + - `Provenance::events` = `Discovered` (`asset.discovered_at`) + `Parsed` + `Normalized` + 각 warning 1개 + lift-stage warning (e.g. AudioRef pre-P8 drop). +- `derive_title(frontmatter, &[Block], file_stem) -> String` — fallback chain (p9-fb-07): frontmatter title → 첫 H1 → 첫 H2 → 첫 paragraph 80 chars → file stem → `"untitled"` sentinel. +- `nfc(s: &str) -> String`, `to_posix(p: &Path) -> Result` — 재export from `kebab-core`. + +**Chunker trait** (`kebab-core`): +- `Chunker::chunker_version() -> ChunkerVersion`. +- `Chunker::policy_hash(&ChunkPolicy) -> String` — `blake3(canonical_json(policy))[..16]`. v1 두 chunker 가 같은 recipe. +- `Chunker::chunk(&CanonicalDocument, &ChunkPolicy) -> Result>`. + +**MdHeadingV1Chunker** (`kebab-chunk`): +- 우선순위 (§0/§14): heading 경계 → code/table 한 chunk → paragraph greedy + overlap → `heading_path` propagation. +- `BYTES_PER_TOKEN = 3` (한국어 ≈ 3 b/tok 커버, 영어 ≈ 4 b/tok 는 over-estimate). 실제 tokenizer 도입 (P+) 까지 proxy. +- `ImageRef` / `AudioRef` 는 자체 chunk (text = alt/caption preview, `token_estimate = 0`). + +**PdfPageV1Chunker** (`kebab-chunk`): +- 모든 chunk 가 single `SourceSpan::Page { page, char_start, char_end }` — 페이지 cross 금지 (citation locality). +- 페이지가 budget 초과 시 paragraph break (`\n\n`) → sentence end (`.`/`?`/`!` + ws) → 강제 over-size 순서로 split. +- `chunk_id` 충돌 회피: §4.2 가 한 `block_id` 페어 → 한 `chunk_id` 가정인데 PDF 의 한 페이지 (= 한 block) 가 여러 chunk 로 split 됨. policy_hash slot 에 `format!("{base}#c{char_start}")` 변형 주입, `Chunk.policy_hash` 자체는 unmodified base 보존. + +## 외부 의존 + +- crate dep: + - `kebab-normalize` → `kebab-core`, `kebab-parse-types` (`ParsedBlock`/`ParsedPayload`/`Warning`), `unicode-normalization`, `time`. + - `kebab-chunk` → `kebab-core`, `serde_json_canonicalizer`, `blake3`. parser/store/embed 의존 **금지**. +- 외부 lib: `unicode-normalization` (NFC), `blake3` (policy_hash), `serde_json_canonicalizer` (JCS), `time` (provenance timestamps). +- 외부 서비스: 없음. + +## 핵심 결정 + +- **Markdown 만 normalize 거침; PDF / Image 는 우회**. + **왜**: Markdown 의 frontmatter / heading path 추적 + ordinal 부여가 normalize 의 본업. PDF 는 "페이지 = block", Image 는 "single block" 이라 IR 거치는 가치 없음. 결과: 두 path 가 chunker 단계에서 합류. + +- **`heading_path` NFC 정규화 (parsedBlock → canonical 시점)**. + **왜**: `pulldown-cmark` 가 NFC 안 함, `serde_json_canonicalizer` 도 NFC 안 함. 한국어 자모 분리/조합 두 표현이 다른 `block_id` hash 로 가면 idempotent re-ingest 가 깨짐. lift 시 NFC → on-disk `CommonBlock.heading_path` + ID input 동일 보장. + +- **ordinal rule = `(heading_path, block_kind)` 별 0-based, document order**. + **왜**: 한 heading 아래 같은 종류의 블록 (paragraph 0/1/2, code 0/1) 만 ordinal 공유. 다른 heading 으로 가면 ordinal 리셋. 같은 heading 내 paragraph 추가/삭제가 다른 종류 ordinal 안 망가뜨림. + +- **`derive_title` fallback chain (5단계 + sentinel)**. + **왜**: spec literal 의 frontmatter-only title 정책이 실제 사용자 노트 (frontmatter 없이 H1 으로 시작) 와 충돌. 5단계: frontmatter → H1 → H2 → 첫 paragraph 80자 → file stem → `"untitled"`. 각 단계 NFC, 빈 문자열 절대 반환 안 함. `parser_version` 을 `pulldown-cmark-0.x` → `md-frontmatter-v2` bump 해서 기존 doc 자동 재처리. + +- **`BYTES_PER_TOKEN = 3` (spec literal 의 `4` 거부)**. + **왜**: 한국어가 E5/M-BERT 에서 ≈ 3 bytes/token. 영어는 4 b/tok 라 3 으로 잡으면 over-estimate → 실제 tokenizer 가 봤을 때 budget 초과 안 함. 두 chunker (md/pdf) 가 같은 상수 써서 cross-chunker comparable. (HOTFIXES P7-2.) + +- **PDF chunk_id 충돌 회피 = policy_hash slot 에 `#c{char_start}` 변형**. + **왜**: 한 페이지 (= 한 block) 가 여러 chunk 로 split 되면 §4.2 의 (`doc_id`, `chunker_version`, `block_ids`, `policy_hash`) tuple 가 동일 → 같은 `chunk_id` 충돌. chunker `policy_hash` 슬롯에만 변형 주입, `Chunk.policy_hash` 필드는 base 보존 ("어떤 policy 가 active 였는지" 답변 정확). §4.2 recipe 자체는 안 바꿈. (HOTFIXES P7-2.) + +- **chunker 가 store/embed 의존 금지**. + **왜**: 순수 변환 함수. test 에서 `CanonicalDocument` 만 만들어서 chunker 호출 가능. Storage / embedding 부재가 chunker 단위 테스트 막지 않음. + +## 관련 spec / HOTFIXES + +- frozen 설계 §3.4 (`Block` / `CanonicalDocument`), §3.5 (`Chunk`), §3.6 (`Provenance`), §3.7b (`ParsedBlock`), §4.2 (ID recipe), §4.3 (ordinal), §0/§14 (chunking priority): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - normalize: [`tasks/p1/p1-4-normalize.md`](../../../tasks/p1/p1-4-normalize.md) + - chunk md: [`tasks/p1/p1-5-chunk-md.md`](../../../tasks/p1/p1-5-chunk-md.md) + - chunk pdf: [`tasks/p7/p7-2-chunk-pdf.md`](../../../tasks/p7/p7-2-chunk-pdf.md) + - title fallback: [`tasks/p9/p9-fb-07-md-title-fallback.md`](../../../tasks/p9/p9-fb-07-md-title-fallback.md) +- HOTFIXES (P7-2 BYTES_PER_TOKEN/=3, chunk_id 충돌 회피, p9-fb-07 title chain): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/parse/README.md b/docs/components/parse/README.md new file mode 100644 index 0000000..f772a55 --- /dev/null +++ b/docs/components/parse/README.md @@ -0,0 +1,154 @@ +# Parse + +> 미디어 타입별 추출기 — markdown / PDF / image 의 raw bytes 를 다음 단계로 흘려보낼 형태로 변환한다. 세 crate 가 같은 도메인 (`Extractor` 또는 `ParsedBlock` 출력) 에 속하지만 출력 단계가 일관되지 않다는 점이 핵심. + +## 구성 crate + +| Crate | 역할 | 출력 형태 | +|-------|------|-----------| +| `kebab-parse-md` | Markdown frontmatter + body parsing (P1-2/3) | `Vec` + `Metadata` (pure 함수) | +| `kebab-parse-pdf` | text-based PDF per-page 추출 (P7-1) | `CanonicalDocument` 직접 (`Extractor` impl) | +| `kebab-parse-image` | 이미지 메타 + EXIF + 차원 + OCR + caption (P6-1/2/3) | `CanonicalDocument` 직접 (`Extractor` impl) | + +## 구조 + +```mermaid +classDiagram + class Extractor { + <> + supports(MediaType) bool + parser_version() ParserVersion + extract(ctx, bytes) CanonicalDocument + } + class MdParser { + <> + parse_frontmatter(bytes) (Metadata, Span, Warnings) + parse_blocks(body) (Vec~ParsedBlock~, Warnings) + } + class PdfTextExtractor { + PARSER_VERSION = "pdf-text-v1" + new() Self + } + class ImageExtractor { + PARSER_VERSION = "image-meta-v1" + MAX_DECODE_DIM = 16384 + new() Self + } + class OcrEngine { + <> + engine_id() str + run(image_bytes, langs) OcrText + } + class OllamaVisionOcr { + endpoint, model, max_pixels + } + class CaptionFns { + caption_image(lm, prep, opts) ModelCaption + apply_caption(block, lm, opts) + } + Extractor <|.. PdfTextExtractor + Extractor <|.. ImageExtractor + OcrEngine <|.. OllamaVisionOcr + ImageExtractor ..> OcrEngine : applied via apply_ocr + ImageExtractor ..> CaptionFns : applied via apply_caption +``` + +## Data flow + +세 parser 의 출력 stage 가 다른 점이 가장 중요. Markdown 만 `ParsedBlock` IR 을 거쳐 `kebab-normalize` 가 lift; PDF / Image 는 추출기 안에서 `CanonicalDocument` 까지 한 번에. + +```mermaid +flowchart LR + Bytes["raw bytes
(RawAsset)"] + subgraph MD ["Markdown"] + MdFM["parse_frontmatter
(YAML/TOML)"] + MdBlocks["parse_blocks
(pulldown-cmark + line span)"] + Pblock["Vec~ParsedBlock~
+ Metadata"] + end + subgraph PDF ["PDF"] + PdfLoad["lopdf::Document::load_mem
encrypted/corrupt 거부"] + PdfPages["per-page extract_text
SourceSpan::Page"] + PdfDoc["CanonicalDocument
(page = paragraph block)"] + end + subgraph IMG ["Image"] + ImgDims["dims::probe
(format + WxH, ≤ 16384)"] + ImgExif["exif_extract
(whitelist)"] + ImgBlock["ImageRefBlock
(ocr=None, caption=None)"] + ImgOcr["OcrEngine.run
(p6-2)"] + ImgCap["caption_image
(p6-3, LanguageModel)"] + ImgDoc["CanonicalDocument
(single block)"] + end + Bytes --> MdFM --> Pblock + Bytes --> MdBlocks --> Pblock + Pblock --> Normalize["kebab-normalize
(다음 그룹)"] + Bytes --> PdfLoad --> PdfPages --> PdfDoc + Bytes --> ImgDims --> ImgBlock + Bytes --> ImgExif --> ImgBlock + ImgBlock --> ImgOcr -.optional.-> ImgDoc + ImgBlock --> ImgCap -.optional.-> ImgDoc + ImgBlock --> ImgDoc + PdfDoc --> Chunk["kebab-chunk
(다음 그룹)"] + ImgDoc --> Chunk + Normalize --> Chunk +``` + +## 주요 type / trait / 함수 + +**Markdown** (`kebab-parse-md`): +- `parse_frontmatter(bytes) -> (Metadata, Option, Vec)` — YAML/TOML 둘 다 인식. 파싱 실패 → `WarningKind::MalformedFrontmatter`. +- `parse_blocks(body) -> (Vec, Vec)` — `pulldown-cmark` 위에서 heading path 추적 + 1-indexed `SourceSpan::Line`. +- `BodyHints { title, lang }` — frontmatter 누락 시 caller 가 fallback 제공 (p9-fb-07 title fallback chain 의 entry). + +**PDF** (`kebab-parse-pdf`): +- `PdfTextExtractor` — `Extractor` 구현체. `lopdf::Document::load_mem` 로 한 번 파싱, encrypted 면 즉시 bail. +- `PARSER_VERSION = "pdf-text-v1"` — version cascade entry. (HOTFIXES P7-2 의 chunker_version `pdf-page-v1` 와 별개.) +- 빈 페이지 / extract 실패 → `Block::Paragraph` 빈 inlines + `ProvenanceKind::Warning("scanned candidate")`. OCR fallback 미구현. + +**Image** (`kebab-parse-image`): +- `ImageExtractor` — `Extractor` 구현체. `MAX_DECODE_DIM = 16384` 초과 거부 (decode bomb 방어). +- `OcrEngine` (trait) — `engine_id() / run(...) -> OcrText`. `OcrText.engine` 필드로 trust level 분기. +- `OllamaVisionOcr { endpoint, model, max_pixels }` — v1 유일 구현. `apply_ocr(block, engine, langs)` 가 `ImageRefBlock.ocr` 슬롯 채움. +- `caption_image(lm: &dyn LanguageModel, prep, opts) -> Result` — `LanguageModel.generate_stream` 의 vision 입력 (`GenerateRequest.images`) 사용. `apply_caption` 이 block 에 in-place 주입. + +## 외부 의존 + +- crate dep: + - 모든 parser → `kebab-core` (`Extractor` trait, `Block`, `Metadata`, `id_for_*`). + - `kebab-parse-md` → `kebab-parse-types` (`ParsedBlock`/`ParsedPayload`/`Warning`), `pulldown-cmark`, `serde_yaml`. + - `kebab-parse-pdf` → `lopdf`. + - `kebab-parse-image` → `image` (decode), `kamadak-exif` (EXIF), `kebab-core::LanguageModel` (caption). +- 외부 서비스: + - PDF: 없음 (in-process). + - Image OCR / caption: Ollama HTTP (default `gemma4:e4b`). + +## 핵심 결정 + +- **Markdown 만 `ParsedBlock` IR 사용**. + **왜**: §3.7b 가 "parser intermediate" 추상을 markdown 의 frontmatter / heading path 추적용으로 도입. PDF / image 는 source 자체가 단순 (PDF=페이지 평면, image=단일 블록) 이라 IR 거치지 않고 `CanonicalDocument` 바로 만드는 게 자연스러움. 결과: ingest pipeline 의 분기가 비대칭 — `kebab-app` 의 라우팅이 두 path 를 같이 처리 (HOTFIXES P7-3 가 둘의 storage 처리 통일 작업). + +- **PDF encrypted → hard fail (auto-decrypt 안 함)**. + **왜**: 자동 decryption 은 사용자의 키/뷰어 환경 가정. `kebab-parse-pdf` 는 "사용자가 외부에서 `qpdf --decrypt` 후 ingest" 명시. encrypted PDF 가 silently 빈 doc 으로 들어가는 게 더 위험. + +- **PDF 빈 페이지 = `Block::Paragraph` 빈 inlines + Warning provenance**. + **왜**: scanned PDF 식별. 빈 문자열로 chunk 만드는 비용 무시 가능 + OCR fallback (P+) 가 같은 doc 위에 in-place 추가 가능. 페이지 ordinal 보존. + +- **Image OCR 기본 = Ollama vision LM (Tesseract 거부)**. + **왜**: spec literal 의 Tesseract 가 시스템 dep (libtesseract + 언어 모델 다운로드) 를 요구해서 single-binary 약속을 깸. Ollama 가 이미 LLM 으로 깔려 있으면 추가 install 0. `OcrEngine` trait 으로 Tesseract / Apple Vision adapter 가 future swap 가능. (HOTFIXES P6-2 의 결정.) + +- **Caption 기본 OFF (`image.caption.enabled = false`)**. + **왜**: caption 은 model-generated → low trust. 매 이미지마다 모델 호출 비용 (= ingest 시간) 도 큼. opt-in. `ModelCaption.model_version` + `caption.prompt_template_version` 필드가 wire payload 로 흘러서 eval 단계에서 prompt 변화 감지 가능. + +- **`GenerateRequest.images: Vec` 필드 신설**. + **왜**: 기존 `LanguageModel` trait 가 text-only. P6-3 caption 이 vision 입력 필요해서 `images` (base64) 필드 추가. 기존 caller 모두 `images: Vec::new()` 로 마이그레이션 + `#[serde(default)]` 로 snapshot 호환. (HOTFIXES P6-3 의 결정.) + +- **Image decode size 캡 (`MAX_DECODE_DIM = 16384`)**. + **왜**: decode bomb (e.g. 100k×100k PNG) 가 메모리 즉시 OOM. 16384 = 16k px, 사진/문서 스캔 정상 케이스 충분. 초과 시 `dims::DimOutcome::Failed` + warning provenance. + +## 관련 spec / HOTFIXES + +- frozen 설계 §3.4 (`Block` enum), §3.7a (`OcrText` / `ModelCaption`), §3.7b (`ParsedBlock` IR), §9 (parser_version cascade), §9.1 (image policy), §9.2 (PDF text extraction): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - Markdown: [`tasks/p1/p1-2-md-frontmatter.md`](../../../tasks/p1/p1-2-md-frontmatter.md), [`tasks/p1/p1-3-md-blocks.md`](../../../tasks/p1/p1-3-md-blocks.md) + - PDF: [`tasks/p7/p7-1-pdf-text-extractor.md`](../../../tasks/p7/p7-1-pdf-text-extractor.md) + - Image: [`tasks/p6/p6-1-image-extractor.md`](../../../tasks/p6/p6-1-image-extractor.md), [`tasks/p6/p6-2-image-ocr.md`](../../../tasks/p6/p6-2-image-ocr.md), [`tasks/p6/p6-3-image-caption.md`](../../../tasks/p6/p6-3-image-caption.md) +- HOTFIXES (P6-2 OCR 기본, P6-3 caption + `GenerateRequest.images`, P7-2 chunk_id 충돌, P7-3 storage UNIQUE bug, p9-fb-07 title fallback): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/rag/README.md b/docs/components/rag/README.md new file mode 100644 index 0000000..c3ffc4f --- /dev/null +++ b/docs/components/rag/README.md @@ -0,0 +1,148 @@ +# RAG + +> Retrieval-Augmented Generation pipeline. retrieve → gate → pack → generate → cite-validate → persist 단일 orchestrator. multi-turn 지원 (p9-fb-15) + cancel-safe streaming. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-rag` | `RagPipeline` + `AskOpts`. retriever / LLM / docs store 를 trait object 로 inject 받아 single-threaded 실행. concrete adapter 의존 **금지** (`kebab-llm-local` / `kebab-embed-local` / `kebab-store-vector` 직접 사용 금지 — 모두 trait 너머). | + +## 구조 + +```mermaid +classDiagram + class RagPipeline { + +new(cfg, retriever, llm, docs) Self + +ask(query, opts) Answer + +ask_with_history(query, history, conv_id, turn, opts) Answer + -config: Config + -retriever: Arc~dyn Retriever~ + -llm: Arc~dyn LanguageModel~ + -docs: Arc~SqliteStore~ + } + class AskOpts { + k: usize + explain: bool + mode: SearchMode + temperature: Option~f32~ + seed: Option~u64~ + stream_sink: Option~Sender~String~~ + history: Vec~Turn~ + conversation_id: Option~String~ + turn_index: Option~u32~ + } + class Answer { + text + citations: Vec~AnswerCitation~ + refusal_reason: Option~RefusalReason~ + retrieval_summary + conversation_id + turn_index + model_ref + usage + trace_id + } + class RefusalReason { + <> + NoChunks + ScoreGate{top_score, threshold} + LlmStreamAborted + Other(String) + } + RagPipeline ..> AskOpts + RagPipeline ..> Answer + Answer ..> RefusalReason +``` + +## Data flow — pipeline stages + +```mermaid +flowchart LR + Q["query + AskOpts"] + Expand["1. query expansion
(history 의 직전 answer
첫 200 chars concat)"] + Retrieve["2. Retriever.search
(k = max(opts.k, default_k))"] + Gate["3. score gate
top_score >= rag.score_gate?"] + Pack["4. pack context
chunks 를 [근거 N] 블록으로
+ Citation 보존"] + History["5. prepend [이전 대화]
(p9-fb-15, char budget 내
oldest drop)"] + Render["6. render prompt
system + [이전 대화]
+ query + [근거 N..N+m]"] + Gen["7. LanguageModel
.generate_stream"] + Stream["stream_sink 로 token 전송
(sink drop = silent swallow)"] + Validate["8. cite-validate
본문의 [N] 마커 →
AnswerCitation 매핑"] + Persist["9. answers 행 INSERT
(refusal 도 항상 persist)"] + Out["Answer
+ refusal_reason"] + Q --> Expand --> Retrieve --> Gate + Gate -->|empty hits| Refuse1["NoChunks refusal"] --> Persist + Gate -->|top < gate| Refuse2["ScoreGate refusal"] --> Persist + Gate -->|pass| Pack --> History --> Render --> Gen --> Stream + Gen -->|abort/error| Refuse3["LlmStreamAborted"] --> Persist + Gen -->|complete| Validate --> Persist + Persist --> Out +``` + +## 주요 type / trait / 함수 + +**`RagPipeline`** (`kebab-rag::pipeline`): +- `RagPipeline::new(config: Config, retriever: Arc, llm: Arc, docs: Arc) -> Self` — caller (kebab-app) 가 wire. +- `RagPipeline::ask(&self, query: &str, opts: AskOpts) -> Result` — single-shot 또는 history 가 빈 multi-turn 첫 호출. +- `RagPipeline::ask_with_history(&self, query, history, conversation_id, turn_index, opts) -> Result` — convenience: opts 에 3개 필드 stuff 후 `ask` 호출. + +**`AskOpts`** (Clone, Debug, **PartialEq 안 함** — `Sender` 가 PartialEq 구현 안 함): +- `k: usize` — retrieval top-k. 실효는 `max(opts.k, config.search.default_k)` (config default = floor). +- `explain: bool` — true 시 `answers.packed_chunks_json` 에 packed-context JSON 저장. refusal 은 항상 persist. +- `mode: SearchMode` — pipeline 내부에서 mode 안 정함, caller 가 inject (lexical/vector/hybrid). +- `temperature` / `seed: Option<...>` — config 기본값 override per call. +- `stream_sink: Option>` — 매 `TokenChunk::Token` 동기 forward. receiver drop 시 `SendError` silent swallow + 생성 계속 (answers 행 보존). +- `history: Vec` (p9-fb-15) — newest-first prepended `[이전 대화]` 블록. `cfg.rag.max_context_tokens * 4` 문자 budget 초과 시 oldest 부터 drop. +- `conversation_id` / `turn_index` (p9-fb-15) — `Answer.conversation_id` / `turn_index` 로 흘러가서 wire payload 가 same-conversation 식별 가능. + +**`Answer`** (`kebab-core::answer`, re-export `kebab-rag`): +- `text` + `citations: Vec` + `refusal_reason: Option` + `retrieval_summary: AnswerRetrievalSummary` + `conversation_id` + `turn_index` + `model_ref` + `usage` + `trace_id`. +- `RefusalReason`: `NoChunks` (retrieval 비음), `ScoreGate { top_score, threshold }` (낮은 신뢰), `LlmStreamAborted` (mid-stream 중단, p9-fb-15), `Other(String)`. + +**상수 / 헬퍼** (`pipeline.rs`): +- `SYSTEM_PROMPT_RAG_V1` — `prompt_template_version` 가 가리키는 system prompt. 변경 시 cascade per §9. +- `expand_query_with_history(query, &history) -> String` — 직전 answer 첫 200 chars concat. LLM-based standalone-question rewriting 은 out of scope (P+). + +## 외부 의존 + +- crate dep: `kebab-core` + `kebab-config` + `kebab-search` (`Retriever` trait 만) + `kebab-llm` (trait 만) + `kebab-store-sqlite` (`DocumentStore` + `put_answer` helper). +- 외부 lib: `serde`/`serde_json`, `regex` (citation marker `[N]` 매칭), `time` (timestamps), `blake3` (`TraceId` 채굴), `thiserror`, `anyhow`. +- 외부 서비스: 없음 (concrete adapter 가 가져옴). + +## 핵심 결정 + +- **single-threaded synchronous orchestrator**. + **왜**: pipeline 의 9 stages 가 다 sequential dependency. 동시성 가치 없음. async 도입하면 caller (kebab-app, TUI worker) 가 자체 thread spawn — 단순함이 깨짐. `LanguageModel::generate_stream` 의 blocking iterator 가 자연스럽게 fit. + +- **`opts.k = max(opts.k, config.search.default_k)` floor**. + **왜**: 사용자가 `kebab ask --k 0` 같은 실수 시 retrieval starvation. config default 가 floor → "내가 더 넓히려면 높은 값 pass" 만 의미 있게 동작. + +- **`stream_sink` drop = silent swallow (abort 안 함)**. + **왜**: TUI 가 cancel 누르면 receiver drop. pipeline 이 즉시 abort 하면 `answers` 행 persist 안 됨 → debug 어려움. 끝까지 generate 후 row write, sink 만 무시 = answers 보존 + UX 자연스러움. + +- **refusal 도 항상 `answers` 행 INSERT**. + **왜**: 운영 분석 필수 — score gate 가 너무 높아서 거부 비율 분석, ScoreGate top_score 분포 등. row 부재 = 회고 불가능. row write 실패는 `tracing::warn!` 만 (caller 는 in-memory `Answer` 받음). + +- **모든 hit 의 chunk fetch 실패 시 `NoChunks` refusal collapse**. + **왜**: search → pack 사이 chunks 삭제 (다른 process 의 reset 등) 발생 시 빈 `[근거]` 블록을 LLM 에 보내면 self-refusal — 진단 misleading. 구조 원인을 알면 명시적 NoChunks 가 정확. + +- **history query expansion = 직전 answer 첫 200 chars concat**. + **왜**: full LLM-based standalone-question rewriting 이 정확하지만 한 번 더 LLM call → latency 2배 + 비결정. 200 chars concat 이 cheap deterministic, retrieval 확장 효과 충분 (대부분의 follow-up "그것" / "그게 뭐였지" 가 직전 answer 키워드 caret). spec §3.8 가 LLM-based 를 P+ 로 marking. + +- **`conversation_id` / `turn_index` optional**. + **왜**: single-shot ask 가 절반 이상의 사용. 빈 `Vec::new()` history 와 함께 `None / None` 으로 `ask` 호출 = 기존 behavior 동일. multi-turn caller (`ask_with_history`) 만 채움. + +- **prompt budget 초과 시 oldest history drop (newest-first 보존)**. + **왜**: 최근 turn 가 follow-up 컨텍스트 핵심. budget = `cfg.rag.max_context_tokens * 4` (chars-per-token proxy). spec §3.8. + +- **forbidden deps: `kebab-llm-local` / `kebab-embed-local` / `kebab-store-vector` 직접 사용 금지**. + **왜**: pipeline 가 trait 만 의존하면 test 가 mock 으로 swap 가능. 어댑터 직접 import = 테스트가 ONNX 모델 다운로드 / Ollama 서버 / LanceDB 디렉토리 필요. 단위 테스트 격리 보장. + +## 관련 spec / HOTFIXES + +- frozen 설계 §0 Q4 (RAG 9 stages), §1 (cite-validate), §2.3 (`[근거]` 블록 형식), §3.8 (Answer / Turn / RefusalReason), §6.4 (`rag.score_gate` / `max_context_tokens` / `prompt_template_version`), §7.2 (`Retriever` / `LanguageModel` traits), §9 (cascade): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - pipeline: [`tasks/p4/p4-3-rag-pipeline.md`](../../../tasks/p4/p4-3-rag-pipeline.md) + - multi-turn core: [`tasks/p9/p9-fb-15-rag-multi-turn-core.md`](../../../tasks/p9/p9-fb-15-rag-multi-turn-core.md) +- HOTFIXES (P4-3 `--config` 누락, p9-fb-15 multi-turn 위 `Answer`/`Turn` 필드 추가, p9-fb-20 citation 표면): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/search/README.md b/docs/components/search/README.md new file mode 100644 index 0000000..42d9c5c --- /dev/null +++ b/docs/components/search/README.md @@ -0,0 +1,137 @@ +# Search + +> 검색 백엔드 — lexical (FTS5 BM25) + vector (LanceDB ANN) + hybrid (RRF fusion). 같은 `Retriever` trait, `SearchMode` 로 dispatch. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-search` | `LexicalRetriever` (P2-2) + `VectorRetriever` (P3-4) + `HybridRetriever` (P3-4). 모두 `kebab-core::Retriever` 구현. citation hydration 헬퍼 포함. | + +## 구조 + +```mermaid +classDiagram + class Retriever { + <> + search(query) Vec~SearchHit~ + index_version() IndexVersion + } + class LexicalRetriever { + +new(store, index_version) Self + +with_settings(store, snippet_chars, ...) + -store: Arc~SqliteStore~ + } + class VectorRetriever { + +new(store, vector_store, embedder, ...) Self + -store: Arc~SqliteStore~ + -vector_store: Arc~dyn VectorStore~ + -embedder: Arc~dyn Embedder~ + } + class HybridRetriever { + +new(cfg, lexical, vector) Self + +with_policy(lex, vec, FusionPolicy, k) + -lexical: Arc~dyn Retriever~ + -vector: Arc~dyn Retriever~ + -fusion: FusionPolicy + -default_k: usize + } + class FusionPolicy { + <> + Rrf{k_rrf} + } + class SearchMode { + <> + Lexical + Vector + Hybrid + } + Retriever <|.. LexicalRetriever + Retriever <|.. VectorRetriever + Retriever <|.. HybridRetriever + HybridRetriever --> LexicalRetriever + HybridRetriever --> VectorRetriever + HybridRetriever ..> FusionPolicy + HybridRetriever ..> SearchMode : dispatch +``` + +## Data flow + +```mermaid +flowchart LR + Q["SearchQuery
{text, mode, k, filters}"] + Disp["mode dispatch"] + Lex["LexicalRetriever
SQLite FTS5 + bm25
+ snippet/highlight"] + Vec["VectorRetriever
Embedder.embed(query)
→ VectorStore.search
→ SQLite hydrate"] + Fan["k × HYBRID_FANOUT_MULTIPLIER (2)
각 측 fanout"] + RRF["RRF fusion
score = Σ 1 / (k_rrf + rank_m)
fusion_score / (2 / (k_rrf+1))
→ [0,1]"] + Merge["chunk 양측 등장 시
lexical snippet 우선
(FTS5 highlight 가 사용자 친화)"] + Hits["Vec~SearchHit~
(snippet, citation,
heading_path, retrieval)"] + Q --> Disp + Disp -->|Lexical| Lex --> Hits + Disp -->|Vector| Vec --> Hits + Disp -->|Hybrid| Fan --> Lex + Disp -->|Hybrid| Fan --> Vec + Lex -.-> RRF + Vec -.-> RRF + RRF --> Merge --> Hits +``` + +## 주요 type / trait / 함수 + +**Trait** (`kebab-core`): +- `Retriever::search(&SearchQuery) -> Result>`. +- `Retriever::index_version() -> IndexVersion` — hybrid 가 두 측 version 다르면 stale-index 경고. + +**LexicalRetriever** (`kebab-search::lexical`): +- `LexicalRetriever::new(store: Arc, index_version: IndexVersion) -> Self`. +- `LexicalRetriever::with_settings(store, snippet_chars, ...)` — `snippet`, `highlight` SQL 함수 호출. FTS5 `MATCH` 쿼리. +- 구현: `SELECT chunk_id, bm25(...), snippet(...), highlight(...) FROM chunks_fts WHERE chunks_fts MATCH ? ...`. citation_helper 가 `Citation::Line { L_start, L_end }` / `Page { p }` 등 분기. + +**VectorRetriever** (`kebab-search::vector`): +- `VectorRetriever::new(store: Arc, vector_store: Arc, embedder: Arc, ...)`. +- 구현: query text → `embedder.embed(EmbeddingKind::Query, ...)` → `vector_store.search(vec, k, filters)` → SQLite 로 hydrate (snippet, citation 등은 vector hit 의 `chunk_id` 로 SELECT). +- `Arc` runtime injection — concrete adapter (`kebab-embed-local::FastembedEmbedder`) 는 caller 가 wire. + +**HybridRetriever** (`kebab-search::hybrid`): +- `HybridRetriever::new(&Config, Arc lex, Arc vec) -> Self` — `config.search.hybrid_fusion` (`"rrf"`) + `config.search.rrf_k` 읽음. 두 retriever 의 `index_version` 가 다르면 `tracing::warn`. +- `FusionPolicy::Rrf { k_rrf }` — default 60. `with_policy` 헬퍼로 explicit 지정 가능. +- 상수: `DEFAULT_K = 10` (query.k == 0 fallback), `DEFAULT_K_RRF = 60`, `HYBRID_FANOUT_MULTIPLIER = 2`. +- merge rule: 양측 등장 chunk 의 `snippet` / `citation` / `heading_path` 는 lexical 측에서 가져옴 (FTS5 highlight 가 vector 의 truncated text 보다 user-relevant). + +## 외부 의존 + +- crate dep: `kebab-core` + `kebab-config` + `kebab-store-sqlite` + `kebab-store-vector` + `kebab-embed` (trait re-export). `kebab-embed-local` 은 caller 가 inject (forbidden direct dep). +- 외부 lib: `rusqlite` (FTS5 쿼리), `globset` (filter 매칭), `serde_json`, `tracing`. +- 외부 서비스: 없음. + +## 핵심 결정 + +- **세 retriever 모두 같은 `Retriever` trait**. + **왜**: `HybridRetriever` 가 `Arc` 두 개 받으니 lexical/vector 가 자기들도 trait object 가능. test 가 `CannedRetriever` mock 으로 두 측 inject 가능 — RRF 만 검증할 때 SQLite/Lance 부재해도 됨. + +- **`HybridRetriever` 가 `Arc` 직접 안 받음**. + **왜**: vector retriever 가 이미 embedder 보유. hybrid 는 `mode == Hybrid` 시 양측 fanout, 직접 embedding 안 함 → vector 측이 자기 embedder 사용. concrete adapter (`kebab-embed-local`) 가 hybrid 에 노출 안 됨 → forbidden dep 깨끗. + +- **RRF fanout = `k * 2`**. + **왜**: spec literal 의 floor. lexical 과 vector 의 disjoint set (한쪽만 surface 한 chunk) 이 충분히 넓어야 fused top-k 가 의미 있음. 비용 linear, recall 회복 큼. + +- **양측 등장 chunk = lexical snippet 우선**. + **왜**: vector 의 raw chunk text 는 보통 truncated (snippet_chars 짧음, BM25 highlight 없음). FTS5 의 `snippet()` + `highlight()` 가 user-perceived relevance 강함. citation/heading_path 도 lexical 결과가 정확 (vector 측은 SQLite hydrate 값 같지만 lexical 일관성). + +- **`fusion_score` `[0, 1]` 정규화 (post-merge hotfix)**. + **왜**: raw RRF score 는 `Σ 1/(k_rrf + rank)` 라 max 가 `2/(k_rrf+1)` (양측 모두 rank=1). mode 간 (Lexical 0~1 BM25 normalized, Vector cosine 0~1, Hybrid 0~?) 비교 가능하려면 `[0,1]` 정규화 필요. raw 를 `2/(k_rrf+1)` 로 나눔. (HOTFIXES "RRF fusion_score `[0,1]` 정규화" 항목.) + +- **두 측 `index_version` mismatch = warn (not error)**. + **왜**: lexical 이 v2, vector 가 v1 (re-embed 안 했음) 같은 stale state 가 운영 시 일어남. 즉시 fail = ingest 끝나기 전 search 막힘. warning 만 띄우고 계속 동작 = 사용자가 인지하고 re-index 결정. + +- **`kebab-embed` (trait crate) 만 의존, `kebab-embed-local` (concrete) **금지****. + **왜**: future MVP 의 swap 가능성 (candle, ollama-embed 등). `kebab-search` 가 concrete 어댑터 import 하면 `kebab-embed-local` 의 fastembed dep (큰 ONNX runtime) 이 search 에 강제 → unrelated build 비용. caller 가 runtime inject. + +## 관련 spec / HOTFIXES + +- frozen 설계 §3.7 (SearchHit), §6.4 (`search.hybrid_fusion`/`rrf_k`/`default_k`/`snippet_chars`), §0 Q3 (citation), §1.6 (`--explain`), §7.2 (`Retriever` trait): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - lexical: [`tasks/p2/p2-2-search-lexical.md`](../../../tasks/p2/p2-2-search-lexical.md) + - vector + hybrid: [`tasks/p3/p3-4-hybrid-fusion.md`](../../../tasks/p3/p3-4-hybrid-fusion.md) +- HOTFIXES (RRF `fusion_score [0,1]` 정규화): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/source/README.md b/docs/components/source/README.md new file mode 100644 index 0000000..e7508bc --- /dev/null +++ b/docs/components/source/README.md @@ -0,0 +1,107 @@ +# Source + +> 워크스페이스를 walk 하고 gitignore-style 필터로 거른 뒤 BLAKE3 checksum 으로 컨텐츠 어드레스된 `RawAsset` 목록을 만든다. ingest pipeline 의 첫 단계. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-source-fs` | 로컬 파일시스템 `SourceConnector` 단일 구현. `kebab-config` + `kebab-core` 만 의존. | + +## 구조 + +```mermaid +classDiagram + class FsSourceConnector { + +new(config) Result~Self~ + +scan(scope) Result~Vec~RawAsset~~ + -default_root: PathBuf + -default_exclude: Vec~String~ + -copy_threshold_bytes: u64 + } + class WalkerModule { + build_overrides(root, excludes, kbignore) Override + read_kbignore(root) Vec~String~ + walk_files(root, overrides) Vec~PathBuf~ + } + class HashModule { + hash_file(path) (byte_len, hex) + } + class MediaModule { + media_type_for(path) MediaType + } + class SourceConnector { + <> + scan(scope) Vec~RawAsset~ + } + SourceConnector <|.. FsSourceConnector + FsSourceConnector --> WalkerModule + FsSourceConnector --> HashModule + FsSourceConnector --> MediaModule +``` + +## Data flow + +```mermaid +flowchart LR + Cfg["Config
(workspace.root, exclude, copy_threshold_mb)"] + Scope["SourceScope
(per-call lens)"] + Walk["walk_files
walkdir + gitignore overrides
+ symlink cycle guard"] + KBI[".kebabignore
(매 scan 재읽음)"] + Default["DEFAULT_EXCLUDES
.DS_Store / ._*"] + Hash["blake3 file hash
(byte_len + hex)"] + Media["media_type_for
(extension → MediaType)"] + Posix["to_posix
NFC + leading ./ strip
# 거부"] + Asset["RawAsset
{asset_id, workspace_path,
media_type, byte_len,
checksum, stored}"] + Sort["sort by workspace_path"] + Cfg --> Walk + Scope --> Walk + KBI --> Walk + Default --> Walk + Walk --> Hash --> Asset + Walk --> Media --> Asset + Walk --> Posix --> Asset + Asset --> Sort --> Out["Vec~RawAsset~"] +``` + +## 주요 type / trait / 함수 + +- `FsSourceConnector::new(&Config) -> Result` — `Config::resolve_workspace_root()` 호출 (p9-fb-05 path policy 통일), `copy_threshold_mb * 1 MiB` 미리 곱해 둠. +- `FsSourceConnector::scan(&SourceScope) -> Result>` — kebab-core `SourceConnector` trait 구현. 결정성 보장 sort by `workspace_path`. +- `walker::build_overrides(root, config_exclude, kbignore_patterns) -> Override` — `ignore` crate 의 gitignore engine 위에서 union 빌드. 모든 패턴이 `!` prefix (positive override = include 의미라 negate 필요). +- `walker::read_kbignore(root) -> Vec` — `/.kebabignore` 매 scan 재읽음 (long-running process 가 file edit 즉시 반영). +- `walker::walk_files(root, overrides) -> Vec` — `walkdir::WalkDir::follow_links(true)` + 별도 `visited: HashSet` 으로 symlink cycle 방어. +- `hash::hash_file(path) -> Result<(byte_len, hex)>` — streaming BLAKE3, 큰 파일도 메모리 안 쌓음. +- `media::media_type_for(path) -> MediaType` — extension 기반 단순 매핑 (`.md` → `Markdown`, `.pdf` → `Pdf`, `.png/.jpg/...` → `Image(_)` 등). + +## 외부 의존 + +- crate dep: `kebab-core` (`RawAsset`, `Checksum`, `SourceConnector`, `id_for_asset`, `to_posix`), `kebab-config` (`Config`). +- 외부 lib: `walkdir` (디렉토리 walk + symlink follow), `ignore` (gitignore override engine, walker 는 안 씀), `blake3` (file hash), `time` (`OffsetDateTime::now_utc` for `discovered_at`). +- 외부 서비스: 없음. + +## 핵심 결정 + +- **`walkdir` 사용 (not `ignore::WalkBuilder`)**. + **왜**: `ignore::WalkBuilder` 가 gitignore + cycle detection 을 한 번에 묶지만, sibling-subtree symlink (`a -> ../b`) 의 cycle 을 ancestor-only check 가 놓치는 케이스가 있음. `walkdir` + 자체 `visited` set 으로 canonical-path 비교를 명시 제어. + +- **DEFAULT_EXCLUDES = `.DS_Store` + `._*`**. + **왜**: macOS Finder 메타파일 + AppleDouble resource fork. 모든 사용자 `.kebabignore` 에 들어갈 noise — 한 번에 baked-in. 사용자가 끄려면 별 메커니즘 필요 (현재 미제공, 필요 시 P+). + +- **`AssetStorage::{Copied, Reference}` = intent signal, not actual copy**. + **왜**: scan 단계는 byte_len 만 보고 의도만 표시. 실제 디스크 copy 는 P1-6 의 asset writer 책임. `copy_threshold_mb = 100` (default) 미만은 `Copied`, 이상은 `Reference + sha`. 큰 파일 중복 저장 회피. + +- **`SourceScope::include` 무시 (router 책임)**. + **왜**: §6.2 의 `WorkspaceCfg.include` 는 extractor router 가 적용. SourceConnector 가 또 필터링하면 markdown/PDF 가 router 에 도달 전 이중 필터. Connector 는 모든 non-excluded 파일 emit. + +- **Filename `#` 거부 = warn + skip (not abort)**. + **왜**: `to_posix` 가 `#` 포함 path 를 Err 반환 (Citation URI fragment separator 와 충돌). 단일 파일이 10000 파일 ingest 를 죽이면 안 됨 — `tracing::warn` 로 알리고 다음 파일. + +- **scan 결과 sort by `workspace_path`**. + **왜**: 결정성. 두 번 scan 의 `Vec` 가 `discovered_at` 빼고 동일해야 idempotent ingest 가능. test `scan_idempotent_modulo_timestamp` 가 회귀 핀. + +## 관련 spec / HOTFIXES + +- frozen 설계 §3.3 (`RawAsset`), §6.2 (workspace + .kebabignore), §6.6 (POSIX 정규화), §7.1 (`SourceScope`), §7.2 (`SourceConnector`): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: [`tasks/p1/p1-1-source-fs.md`](../../../tasks/p1/p1-1-source-fs.md) +- p9-fb-05 (`expand_tilde` shim 제거 + `Config::resolve_workspace_root` 일원화): [`tasks/p9/p9-fb-05-config-path-policy.md`](../../../tasks/p9/p9-fb-05-config-path-policy.md) diff --git a/docs/components/store/README.md b/docs/components/store/README.md new file mode 100644 index 0000000..e477f48 --- /dev/null +++ b/docs/components/store/README.md @@ -0,0 +1,152 @@ +# Store + +> persistence layer 두 백엔드 — SQLite (메타 + lexical FTS + jobs + chat) + LanceDB (per-model vector 테이블). 둘이 협조해 partial-write Lance 행이 caller 에 노출되지 않게 two-phase 보장. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-store-sqlite` | `DocumentStore` + `JobRepo` + `ChatSessionRepo` + asset writer + FTS5 lexical 쿼리 + eval 저장. refinery migration runner. | +| `kebab-store-vector` | `VectorStore` LanceDB 어댑터. per-model `chunk_embeddings__.lance/` 테이블. SQLite 와 two-phase write 협조. | + +## 구조 + +```mermaid +classDiagram + class DocumentStore { + <> + put_asset(a) + put_document(d) + put_blocks(doc, blocks) + put_chunks(doc, chunks) + get_document(id) + list_documents(filter) + } + class VectorStore { + <> + ensure_table(model, dim) IndexId + upsert(recs) + search(query_vec, k, filters) Vec~VectorHit~ + delete_by_chunk_ids(ids) + } + class JobRepo { + <> + create / update_progress / finish / list + } + class ChatSessionRepo { + <> + create_session / get_session / list_sessions + delete_session / append_turn / list_turns + } + class SqliteStore { + +open(cfg) Self + +run_migrations() + +put_asset_with_bytes(asset, bytes) + +corpus_revision() u64 + +bump_corpus_revision() + +stale_chunk_ids_at(workspace_path) + +rebuild_chunks_fts() + } + class LanceVectorStore { + +new(cfg, Arc~SqliteStore~) Self + -tokio::Runtime current-thread + } + DocumentStore <|.. SqliteStore + JobRepo <|.. SqliteStore + ChatSessionRepo <|.. SqliteStore + VectorStore <|.. LanceVectorStore + LanceVectorStore --> SqliteStore : two-phase coord +``` + +## Data flow — two-phase upsert (write 경로) + +```mermaid +flowchart LR + Caller["kebab-app"] + PendingIns["1. SQLite INSERT
embedding_records
status='pending'"] + LanceWrite["2. Lance MergeInsert
(keyed on chunk_id)"] + CommitFlip["3. SQLite UPDATE
status='committed'"] + SearchJoin["search() join
WHERE status='committed'
(pending 행 안 보임)"] + Caller --> PendingIns --> LanceWrite --> CommitFlip + SearchJoin -.- CommitFlip + Crash["프로세스 crash
(phase 2 또는 phase 3 사이)"] + Retry["다음 upsert 가
idempotent 재시도
(MergeInsert 가 chunk_id 로 dedupe)"] + LanceWrite -.crash.-> Crash --> Retry +``` + +## Data flow — re-ingest orphan cleanup (delete 경로) + +```mermaid +flowchart LR + Bytes["asset bytes 변경
(같은 workspace_path)"] + Purge["purge_orphan_at_workspace_path
(SQLite 의 stale chunk_id 수집)"] + LanceDel["VectorStore::delete_by_chunk_ids
(LanceDB 행 삭제)"] + Reingest["새 doc_id / chunk_id 로 정상 ingest"] + Bytes --> Purge --> LanceDel --> Reingest +``` + +## 주요 type / trait / 함수 + +**`SqliteStore`** (`kebab-store-sqlite::store`): +- `SqliteStore::open(&kebab_config::Config) -> Result` — `data_dir/sqlite` 파일 열고 `Mutex` 으로 wrap. +- `SqliteStore::run_migrations()` — `refinery` 가 `migrations/V001..V005` 적용. +- `put_asset_with_bytes(asset, bytes)` — content-addressable 저장 (`{asset_dir}///` blob), `assets.workspace_path` UNIQUE 충돌 시 `purge_orphan_at_workspace_path` 후 재삽입 (HOTFIXES P7-3). +- `corpus_revision() -> u64` / `bump_corpus_revision() -> Result` — `kv` 테이블의 monotonic counter (p9-fb-19, search cache invalidation key). +- `stale_chunk_ids_at(&WorkspacePath) -> Vec` — re-ingest 시 LanceDB 에서 지울 행 식별. +- `rebuild_chunks_fts()` — FTS5 virtual table 풀 리빌드 (corruption 또는 스키마 변경 시). + +**SQLite migrations** (`migrations/`): +- **V001** — full P1 schema: `assets`, `documents`, `document_tags`, `blocks`, `chunks`, `embedding_records`, `jobs`, `ingest_runs`, `answers`, `eval_runs`, `eval_query_results`. +- **V002** — `chunks_fts` (FTS5 virtual, `unicode61 remove_diacritics 2`) + `chunks_ai`/`ad`/`au` sync triggers. 본문은 frozen 설계 §5.5 verbatim. +- **V003** — `embedding_records.status` lifecycle marker (`pending`/`committed`) + `idx_embed_status`. P3-3 two-phase write 의 SQLite 측. +- **V004** — `kv (key TEXT PK, value TEXT)` + `corpus_revision = '0'` seed (p9-fb-19). +- **V005** — `chat_sessions` + `chat_turns` (FK ON DELETE CASCADE) + `idx_chat_turns_session` (p9-fb-17). spec PR 의 V004 가 p9-fb-19 의 kv 와 충돌해서 V005 로 시프트. + +**`LanceVectorStore`** (`kebab-store-vector::store`): +- `LanceVectorStore::new(&Config, Arc) -> Result` — `Arc` 공유로 two-phase coord. +- `VectorStore` trait 구현. 모든 메서드 안에서 private current-thread `tokio::runtime::Runtime` 위 `block_on` (sync trait → async LanceDB 브리지). +- 테이블 명: `chunk_embeddings__.lance/` (예: `chunk_embeddings_multilingual-e5-small_384.lance/`). 모델/차원 변경 시 자동 새 테이블 = embeddings 분리. +- `delete_by_chunk_ids(&[ChunkId])` — default impl 빈 no-op (P7-3 follow-up 으로 LanceVectorStore 만 override). + +## 외부 의존 + +- `kebab-store-sqlite` → `kebab-core` + `kebab-config`, `rusqlite` (`bundled` feature, libsqlite3 시스템 dep 회피), `refinery`, `serde_json`, `time`, `blake3`, `globset`. +- `kebab-store-vector` → `kebab-core` + `kebab-config` + `kebab-store-sqlite`, `lancedb`, `arrow` (Lance schema), `tokio` (current-thread runtime). +- 외부 서비스: 없음. 모든 저장 on-disk. + +## 핵심 결정 + +- **`rusqlite` `bundled` feature**. + **왜**: 시스템 `libsqlite3` 의존을 single-binary 약속이 거부. `bundled` 가 SQLite source 를 같이 빌드 → `kebab` 깔면 추가 install 0. + +- **per-model Lance 테이블**. + **왜**: 임베딩 모델 swap 시 이전 모델의 vector 가 그대로 남아 있어야 (re-embed 유예) + 같은 chunk 가 두 모델로 동시에 indexed 가능. 테이블 명에 `_` 인코딩 → `EmbeddingModelId` 변경 = 새 테이블, 기존은 read-only 유지. + +- **two-phase write (SQLite first, Lance second, status flip)**. + **왜**: LanceDB 는 transactional commit 없음 (MergeInsert 도 read-after-write 보장 안 함). SQLite 의 `embedding_records.status` 가 truth 역할 — `search` 가 `WHERE status='committed'` 로 join 해서 partial-write Lance 행 보이지 않음. 프로세스 crash 후 재 ingest 가 idempotent (Lance MergeInsert 가 chunk_id dedupe). + +- **`delete_by_chunk_ids` 의 default no-op + Lance override**. + **왜**: `VectorStore` trait 의 default impl 가 빈 no-op 이라 test fake / 미래 다른 backend 가 default 그대로 컴파일됨 (behavioural breaking change 없음). Lance 만 override. + +- **byte-edit re-ingest 시 SQLite + Lance 양쪽 stale 청소**. + **왜**: P7-3 의 hot bug — `assets.workspace_path` UNIQUE 와 `upsert_asset_row` 의 `ON CONFLICT(asset_id)` gap 때문에 byte 가 바뀐 같은 path 의 자산이 ingest 실패. `purge_orphan_at_workspace_path` 가 SQLite 측 stale chunk_id 수집, follow-up PR 이 `VectorStore::delete_by_chunk_ids` 추가해 LanceDB 에서도 같은 chunk_id 삭제. 둘 다 안 청소하면 orphan vector 누적. + +- **chat session storage = V005 (V004 와 충돌)**. + **왜**: spec PR 가 chat_sessions 를 V004 로 잡았는데 같은 시점 p9-fb-19 가 kv 테이블을 V004 로 가져감. refinery 는 numeric 충돌 = hard fail. p9-fb-17 가 V005 로 시프트, HOTFIXES 기록. + +- **`SqliteStore` 가 `JobRepo` + `ChatSessionRepo` 도 구현**. + **왜**: 모두 같은 SQLite 파일 안에 있고 같은 `Mutex` 공유. 별 crate 로 분리하면 connection 두 개 필요 → SQLite 가 single-writer 라 contention 만 늘어남. trait 별로는 분리, impl 은 한 store. + +- **FTS5 본문 spec verbatim + CI diff-check**. + **왜**: tokenizer (`unicode61 remove_diacritics 2`) + trigger 본문이 frozen 설계 §5.5 와 byte-for-byte 동일해야 wire 동작 일치. CI 가 diff 검출. + +## 관련 spec / HOTFIXES + +- frozen 설계 §5.1 (meta), §5.2 (assets), §5.3 (documents), §5.4 (blocks), §5.5 (chunks + FTS5), §5.6 (embedding_records two-phase), §5.7 (jobs/ingest_runs/answers/eval_runs), §5.7a (chat_sessions/turns), §6.3 (lance table naming), §7.2 (DocumentStore/VectorStore/JobRepo/ChatSessionRepo): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - SQLite store: [`tasks/p1/p1-6-store-sqlite.md`](../../../tasks/p1/p1-6-store-sqlite.md) + - FTS5 + V002: [`tasks/p2/p2-1-fts.md`](../../../tasks/p2/p2-1-fts.md) + - V003 + LanceDB + two-phase: [`tasks/p3/p3-3-store-vector.md`](../../../tasks/p3/p3-3-store-vector.md) + - V004 kv: [`tasks/p9/p9-fb-19-search-cache.md`](../../../tasks/p9/p9-fb-19-search-cache.md) + - V005 chat sessions: [`tasks/p9/p9-fb-17-chat-session-storage.md`](../../../tasks/p9/p9-fb-17-chat-session-storage.md) +- HOTFIXES (P7-3 storage UNIQUE bug + delete_by_chunk_ids follow-up, V004→V005 시프트): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md) diff --git a/docs/components/ui/README.md b/docs/components/ui/README.md new file mode 100644 index 0000000..590793a --- /dev/null +++ b/docs/components/ui/README.md @@ -0,0 +1,183 @@ +# UI + +> 사용자 surface — 두 binary, 둘 다 `kebab-app` facade 만 사용. CLI 가 1:1 subcommand → app fn 매핑, TUI 가 4 패널 (Library / Search / Ask / Inspect) ratatui shell. + +## 구성 crate + +| Crate | 역할 | +|-------|------| +| `kebab-cli` | binary `kebab`. clap subcommand → `kebab-app` fn 1:1. wire schema v1 envelope (`--json`). progress bar (indicatif). Ctrl-C cancel handler. exit codes. | +| `kebab-tui` | binary `kebab tui` (그리고 라이브러리). ratatui + crossterm. 4 패널 + cheatsheet popup + error overlay + Vim-style mode machine. async search worker + background ingest worker + external editor jump. | + +## 구조 (TUI 위주, CLI 는 단순) + +```mermaid +classDiagram + class App { + config: Config + sqlite: Arc~SqliteStore~ + kebab_app: app::App + focus: Pane + mode: Mode + theme: Theme + library: LibraryState + search: SearchState + ask: AskState + inspect: InspectState + ingest: Option~IngestState~ + cheatsheet_visible: bool + error_overlay: Option~ErrorOverlay~ + pending_editor: Option~EditorRequest~ + search_cache: ... + } + class Pane { + <> + Library + Search + Ask + Inspect + Jobs + } + class Mode { + <> + Normal + Insert + +auto_for(pane) Mode + +label() &str + } + class LibraryState + class SearchState { + input: InputBuffer + generation: u64 + worker_thread: Option~JoinHandle~ + worker_rx: Option~Receiver~ + } + class AskState { + input: InputBuffer + turns: Vec~Turn~ + conversation_id: Option~String~ + last_answer: Option~Answer~ + } + class InspectState + class IngestState { + cancel: Arc~AtomicBool~ + rx: Receiver~IngestEvent~ + partial_counts + } + class Theme { + +from_name("dark"|"light") + +style(Role) Style + } + class InputBuffer { + content: String + cursor_col: usize + push_char/pop_char/clear/take + } + App --> Pane : focus + App --> Mode + App --> Theme + App --> LibraryState + App --> SearchState + App --> AskState + App --> InspectState + App --> IngestState + SearchState --> InputBuffer + AskState --> InputBuffer +``` + +## Data flow — 전체 ratatui run loop + +```mermaid +flowchart LR + Start["kebab tui
main"] + Setup["enable_raw_mode
+ EnterAlternateScreen
+ Hide cursor"] + Loop["run loop"] + Tick["매 tick"] + Drain["drain progress channel
(ingest worker)"] + Poll["search worker poll
(generation match?)"] + Render["render pane (focus)
+ overlay (error/cheatsheet)"] + Event["crossterm event"] + CIntercept["cheatsheet_intercept
(F1 toggle, Esc close)"] + MIntercept["mode_intercept
(i/Esc Normal↔Insert)"] + Dispatch["pane dispatch
(library/search/ask/inspect)"] + EditorReq["EditorRequest enqueue"] + EditorSpawn["with_external_program
(suspend → spawn → restore)"] + Restore["disable_raw_mode + force_redraw"] + Start --> Setup --> Loop + Loop --> Tick --> Drain --> Poll --> Render + Render --> Event + Event --> CIntercept --> MIntercept --> Dispatch + Dispatch --> EditorReq --> EditorSpawn -.RAII guard.-> Restore -.-> Render + Loop -.quit.-> Setup +``` + +## 주요 type / trait / 함수 + +**`kebab-cli`** (`main.rs`): +- clap `Cli { config: Option, verbose, debug, json, command: Cmd }` — `--config` 가 모든 subcommand 에 전역. +- subcommand 별 호출: `init` → `init_workspace`, `ingest` → `ingest_with_config_cancellable`, `search` → `search_with_config`, `ask` → `ask_with_config` 또는 `ask_with_session_with_config`, `list` / `inspect` / `doctor` / `tui` / `reset` → 대응 `*_with_config`. +- `progress.rs` — `indicatif::ProgressBar` (TTY 시) / non-TTY 한 줄씩 / `--json` line-delimited stdout. +- `cancel.rs` — `ctrlc` SIGINT handler. 1회 → cancel signal, 2회 → hard exit 130. +- `wire.rs` — `*.v1` envelope wrap (`ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1` 등). `DoctorReport` 만 자체 schema_version, 다른 도메인 type 은 cli 에서 wrap. +- exit code: 0 (ok) / 1 (anyhow chain) / 2 (clap usage) / 3 (no-hit signal) / 4 (refusal signal) / 5 (doctor unhealthy) — design §10. + +**`kebab-tui`** (`lib.rs` re-export): +- `App { config, sqlite, kebab_app, focus, mode, theme, library, search, ask, inspect, ingest, cheatsheet_visible, error_overlay, pending_editor, search_cache, ... }` — single-threaded shell state. +- `Pane` enum (Library / Search / Ask / Inspect / Jobs), `Mode` enum (Normal / Insert) + `Mode::auto_for(pane)` (Library/Inspect/Jobs → Normal, Search/Ask → Insert). +- `InputBuffer { content, cursor_col }` — wide-char-aware (한글 = 2 col, ASCII = 1 col, combining = 0). `push_char` / `pop_char` / `clear` / `take`. +- `Theme { from_name, style(Role) }` — 16 Role × 2 palette (dark/light). 모든 pane 의 inline `Style::default().fg(...)` → `theme.style(Role::X)` 격리. +- `render_*(f, area, app, ...)` per pane — `f: &mut Frame<'_>` (ratatui 0.28 backend-agnostic). cursor caret 은 caret-필요 pane (Search / Ask / Filter) 가 `set_cursor_position(...)` 호출. +- `handle_key_*(app, key) -> KeyOutcome` per pane — Mode-authoritative dispatch (p9-fb-12 follow-up): Normal 에서 nav/command 키, Insert 에서 typing. +- `enter_inspect(app, target: InspectTarget, return_to: Pane)` (p9-fb-04) — Library `Enter` (Doc) / Search `o` (Chunk). +- `with_external_program(&mut TuiTerminal, Command)` (p9-fb-09) — RAII guard 가 atomic suspend/restore. +- `cheatsheet_intercept(app, key)` (p9-fb-13) — F1 toggle, mode_intercept 보다 먼저 dispatch. +- `start_ingest / drain_progress / cancel_running_ingest / ready_to_clear / status_line` (p9-fb-03) — Library `r` 가 spawned thread + channel + Esc cancel. +- `markdown::render(text, &Theme) -> Vec>` (p9-fb-11) — pulldown-cmark 위 inline + block + table + code + heading. +- `footer_hints(focus, mode, filter_open) -> &'static str` (p9-fb-13 follow-up) — 한국어 동사구 + mode-aware. 첫 fragment 항상 `F1 도움말`. + +## 외부 의존 + +- `kebab-cli` → `kebab-app` (only), `clap`, `indicatif`, `ctrlc`, `serde_json`, `anyhow`. **forbidden** 직접 import: `kebab-store-*` / `kebab-llm-*` / `kebab-search` / `kebab-rag`. +- `kebab-tui` → `kebab-app` (only) + `kebab-config` + `kebab-core` + `kebab-store-sqlite` (직접 import — App 의 sqlite handle 공유 위함, ChatSessionRepo 호출), `ratatui` (0.28), `crossterm` (0.28), `pulldown-cmark` (0.13), `unicode-width`, `lru`, `anyhow`, `time`. +- 외부 서비스: 없음 (facade 가 가져옴). + +## 핵심 결정 + +- **UI binary 가 facade 만 → swap 가능**. + **왜**: future MCP server / HTTP wrapper 가 같은 contract 위에 build. `kebab-cli` 가 `--json` envelope 으로 wire schema v1 표면 = 외부 통합 (Claude Code skill 등) 이 binary 한 줄 spawn 으로 끝. + +- **`kebab-app::App` 한 번 open → CLI subcommand 처리 후 drop**. + **왜**: per-invocation cold start 단순. 장수 caller (kebab-tui session) 만 retain → memoized embedder/vector/llm 이득. + +- **TUI = single-threaded run loop + 외부 worker thread**. + **왜**: ratatui idiomatic. 매 tick render. search / ingest 처럼 50-200ms+ 작업은 별 thread + channel post 로 freeze 회피. main thread 가 매 tick channel drain + apply. + +- **search worker = generation counter + stale drop** (p9-fb-08). + **왜**: 사용자가 빠르게 타이핑 시 매 keystroke 가 worker spawn. 이전 worker 의 결과가 늦게 도착해도 generation mismatch → silent drop. UI 항상 최신 query 의 결과만 보임. + +- **Vim-style Mode machine** (p9-fb-12). + **왜**: 텍스트 입력 (`e`/`j`/`k`/`i`) 와 command 키 (`e`=explain, `j`=down, `k`=up, `i`=inspect) 충돌. 도그푸딩에서 "explain" / "javascript" 같은 단어 입력이 mode 안 가지고 깨짐. Normal/Insert 명시. Search/Ask 는 자동 Insert (입력 위주), Library/Inspect/Jobs 는 자동 Normal (nav 위주). `i` 가 universal Normal→Insert toggle (p9-fb-21), Search 의 chunk inspect 는 `i`→`o` rebind (vim "open"). + +- **CJK column-aware InputBuffer** (p9-fb-10). + **왜**: ratatui frame 의 `set_cursor_position(...)` 가 column 단위. byte/char 인덱스 시 한글 1 글자가 1 col 만 차지하는 것처럼 cursor 가 박힘. `unicode-width` 위 wide-char 단위 cursor_col 추적 → caret 정확. backspace 는 모든 pane 이 `String::pop()` 으로 char-aware 이미 안전. + +- **Theme module** (p9-fb-14). + **왜**: dark/light palette swap 가 inline `fg(Color::*)` 흩어져 있으면 한 군데 빼먹음. 16 Role × 2 Palette exhaustive match → unknown role compile-time fail. config typo 시 dark fallback (panic 안 함). + +- **External editor jump = RAII guard** (p9-fb-09). + **왜**: ratatui alt-screen + raw mode 가 spawn 사이 깨지면 화면 손상. suspend (LeaveAlt + Show cursor + disable_raw) → spawn → restore (enable_raw + EnterAlt + Hide + clear) 시퀀스를 RAII 로 묶음. 키 핸들러는 enqueue 만, run loop 가 `TuiTerminal` handle 들고 spawn — handle ownership 분리. + +- **Multi-turn Ask UI** (p9-fb-16). + **왜**: 도그푸딩에서 "이 문서 더 자세히" 같은 follow-up 질문이 standalone single-shot 으로 처리되어 retrieval 부정확. answer area 가 transcript (Q1/A1, Q2/A2, ...). 매 Enter 가 prior turns 를 history 로 worker 에 전달. `Ctrl-L` 로 conversation 초기화. + +- **F1 cheatsheet popup** (p9-fb-13). + **왜**: 도그푸딩에서 keybinding discoverability 문제. 단축키 도움말 없으면 사용자가 매번 README 검색. spec 의 `?` trigger 가 Library 의 quick-Ask 와 충돌해서 `F1` rebind. mode_intercept 보다 먼저 dispatch — popup 이 mode flip 발동 안 시킴. + +## 관련 spec / HOTFIXES + +- frozen 설계 §1 (UX scenes), §2.4a (ingest progress wire), §3.7 (SearchHit / DocSummary), §3.8 (Answer / Turn), §8 (boundary), §10 (errors / exit codes): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md) +- task spec: + - CLI: [`tasks/p0/p0-1-skeleton.md`](../../../tasks/p0/p0-1-skeleton.md) (초기) + 모든 phase 의 wiring task. + - TUI: [`tasks/p9/p9-1-tui-library.md`](../../../tasks/p9/p9-1-tui-library.md), [`tasks/p9/p9-2-tui-search.md`](../../../tasks/p9/p9-2-tui-search.md), [`tasks/p9/p9-3-tui-ask.md`](../../../tasks/p9/p9-3-tui-ask.md), [`tasks/p9/p9-4-tui-inspect.md`](../../../tasks/p9/p9-4-tui-inspect.md) + - 도그푸딩 후속 (p9-fb-01..21): [`tasks/p9/`](../../../tasks/p9/) +- HOTFIXES (P9-1 Backend generic 제거, P9-2 workspace_root 인자, P9-3 e/j/k 텍스트 충돌 → mode machine, p9-fb-* 도그푸딩 후속): [`tasks/HOTFIXES.md`](../../../tasks/HOTFIXES.md)