docs(components): per-group contributor reference (12 그룹)
docs/components/<group>/README.md 12 페이지 + 인덱스 작성. 각 그룹 페이지가 구성 crate 표 + 구조 mermaid + data flow mermaid + 주요 type/trait/함수 시그니처 + 외부 의존 + 핵심 결정 (HOTFIXES + spec 의 "왜" 통합) + 관련 spec/HOTFIXES 링크. 인덱스가 그룹 wiring 다이어그램 + 진입 가이드 보유. ARCHITECTURE.md 의 ASCII crate 의존 그래프를 mermaid flowchart 로 교체 (등가 정보, Gitea/GitHub 자동 렌더). docs/components/ 진입 링크 추가. 이 layer 는 contributor 향 — 사용자 향 grand picture 는 README.md 의 logical-architecture diagram 그대로 유지. 진척도는 HANDOFF.md, per-task spec 은 tasks/INDEX.md 가 기존대로 source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<br/>(P9-5)"]
|
||||
end
|
||||
app["kebab-app<br/>(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<br/>(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<br/>(trait)"]
|
||||
embedlocal["kebab-embed-local<br/>(fastembed)"]
|
||||
llm["kebab-llm<br/>(trait)"]
|
||||
llmlocal["kebab-llm-local<br/>(Ollama)"]
|
||||
search["kebab-search"]
|
||||
rag["kebab-rag"]
|
||||
end
|
||||
eval["kebab-eval"]
|
||||
config["kebab-config"]
|
||||
core["kebab-core<br/>(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 <path>` flag 를 honor 하려면 `kebab_app::*_with_config(cfg, …)` companion 을 통해 Config 을 명시적으로 thread 하는 패턴 — 자세한 이유는 [tasks/HOTFIXES.md](../tasks/HOTFIXES.md) 의 `--config` 항목.
|
||||
|
||||
103
docs/components/README.md
Normal file
103
docs/components/README.md
Normal file
@@ -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<br/>(cli + tui)"]
|
||||
end
|
||||
subgraph Orchestration ["orchestration"]
|
||||
AppFacade["App facade<br/>(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<br/>(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<Chunk>` (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-<provider>` crate → App facade (config provider switch).
|
||||
- **TUI 신규 pane** — UI 만. Mode + Theme + InputBuffer 재사용.
|
||||
|
||||
## 다이어그램 제약
|
||||
|
||||
각 그룹 페이지의 다이어그램은 `mermaid` (Gitea / GitHub 자동 렌더). 페이지 별 최소 2개 — **구조** (type/trait/struct 관계) + **data flow** (입출력 흐름). 실제 코드와 시그니처 일치 — 작성 시 `crates/kebab-<name>/src/lib.rs` 직접 읽음 (추측 금지).
|
||||
182
docs/components/app-facade/README.md
Normal file
182
docs/components/app-facade/README.md
Normal file
@@ -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 {
|
||||
<<top-level pub fn>>
|
||||
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 {
|
||||
<<enum>>
|
||||
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<br/>(또는 TUI worker)"]
|
||||
Cfg["Config::load(--config)"]
|
||||
App2["App::open_with_config<br/>(SQLite, lazy embedder/vector)"]
|
||||
Walk["FsSourceConnector.scan<br/>(Source 그룹)"]
|
||||
Loop["per asset 루프<br/>(cancel poll 매 iter)"]
|
||||
Route["MediaType 라우팅<br/>md / pdf / image"]
|
||||
Parse["Parse 그룹"]
|
||||
Norm["Normalize+Chunk 그룹"]
|
||||
Emb["Embed 그룹 (선택)"]
|
||||
Store["Store 그룹<br/>two-phase upsert"]
|
||||
Bump["bump_corpus_revision<br/>(p9-fb-19)"]
|
||||
Event["IngestEvent::*<br/>→ Sender~IngestEvent~"]
|
||||
Report["IngestReport<br/>(stage_counts, warnings)"]
|
||||
Aborted["IngestEvent::Aborted<br/>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 <id>` (multi-turn)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q["query + session_id"]
|
||||
Repo["ChatSessionRepo<br/>get_session / list_turns"]
|
||||
History["Vec~Turn~<br/>(newest-first)"]
|
||||
RAG["RagPipeline.ask_with_history"]
|
||||
Append["append_turn<br/>(parent updated_at bump)"]
|
||||
Out["Answer<br/>(conversation_id + turn_index)"]
|
||||
Q --> Repo --> History --> RAG --> Append --> Out
|
||||
Q -.first call.-> CreateSession["create_session<br/>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<Sender<IngestEvent>>)` — TTY 진행 표시 / `--json` line-delimited 용.
|
||||
- `ingest_with_config_cancellable(cfg, scope, progress, cancel: Option<Arc<AtomicBool>>)` — 위 + 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<Self>` — 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<Sender>` + `Option<AtomicBool>` 다 받게 하면 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)
|
||||
119
docs/components/embed/README.md
Normal file
119
docs/components/embed/README.md
Normal file
@@ -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 {
|
||||
<<trait kebab-core>>
|
||||
model_id() EmbeddingModelId
|
||||
model_version() EmbeddingVersion
|
||||
dimensions() usize
|
||||
embed(inputs) Vec~Vec~f32~~
|
||||
}
|
||||
class EmbeddingInput {
|
||||
text: &str
|
||||
kind: EmbeddingKind
|
||||
}
|
||||
class EmbeddingKind {
|
||||
<<enum>>
|
||||
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~<br/>(kebab-chunk)"]
|
||||
Inputs["EmbeddingInput<br/>{text, kind}"]
|
||||
Prefix["E5 prefix<br/>Document → 'passage: '<br/>Query → 'query: '"]
|
||||
Batch["batch by config.batch_size"]
|
||||
Onnx["fastembed TextEmbedding<br/>(ONNX session, Mutex)"]
|
||||
L2["L2 정규화<br/>(fastembed 내장)"]
|
||||
Vec["Vec~Vec~f32~~<br/>unit norm, finite"]
|
||||
Inputs --> Prefix --> Batch --> Onnx --> L2 --> Vec
|
||||
Chunks -.text.-> Inputs
|
||||
Query["사용자 query string<br/>(kebab-search)"] -.text+Query.-> Inputs
|
||||
Vec --> VStore["kebab-store-vector"]
|
||||
Vec --> Search["kebab-search<br/>(query 경로)"]
|
||||
```
|
||||
|
||||
## 주요 type / trait / 함수
|
||||
|
||||
**Trait** (`kebab-core`, re-export `kebab-embed`):
|
||||
- `Embedder::embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result<Vec<Vec<f32>>>` — 출력 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<Self>` — 모델 파일 캐시 위치 = `{model_dir}/fastembed/`. 첫 호출 시 ONNX + tokenizer 다운로드. `config.models.embedding.dimensions` 가 실제 모델 차원과 다르면 즉시 `Err` (런타임 silent mismatch 회피).
|
||||
- `Mutex<TextEmbedding>` 으로 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<f32>], expected_dims)` — 길이 + finite 검증.
|
||||
- `assert_unit_norm(&[Vec<f32>], 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-<provider>` 로.
|
||||
|
||||
- **`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<TextEmbedding>` 보수적 직렬화**.
|
||||
**왜**: 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 없음.
|
||||
171
docs/components/eval/README.md
Normal file
171
docs/components/eval/README.md
Normal file
@@ -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 {
|
||||
<<enum>>
|
||||
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<br/>(timestamp-ordered)"]
|
||||
Snapshot["5-version snapshot<br/>(parser/chunker/embedding/<br/>index/prompt_template)"]
|
||||
Loop["per query 루프"]
|
||||
Search["kebab_app::search_with_config"]
|
||||
Ask["kebab_app::ask_with_config<br/>(query.ask == true 시)"]
|
||||
MustContain["must_contain<br/>contains_all 검사"]
|
||||
Result["QueryResult"]
|
||||
Aggregate["compute_aggregate<br/>recall@k / precision@k /<br/>no_hit_rate / refusal_rate /<br/>ask_grounded_rate"]
|
||||
SQLiteRow["eval_runs row +<br/>eval_query_results rows"]
|
||||
Jsonl["runs_dir/<run_id>/<br/>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<br/>rows 로드"]
|
||||
Diff["per query 비교<br/>(contains_all flip,<br/>top_score delta,<br/>refusal flip)"]
|
||||
Kind["ComparisonKind<br/>(Better / Same / Regressed)"]
|
||||
MD["render_report_md<br/>(human-friendly)"]
|
||||
Base --> LoadRows
|
||||
Cand --> LoadRows --> Diff --> Kind --> MD
|
||||
```
|
||||
|
||||
## 주요 type / trait / 함수
|
||||
|
||||
**Loader** (`kebab-eval::loader`):
|
||||
- `load_golden_set(path: &Path) -> Result<Vec<GoldenQuery>>` — `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<EvalRun>` / `run_eval_with_config(cfg, opts) -> Result<EvalRun>`.
|
||||
- `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/<run_id>/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<CompareReport>` / `_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/<run_id>/` 가 자연 정렬 시 시간 순.
|
||||
|
||||
- **`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)
|
||||
151
docs/components/foundation/README.md
Normal file
151
docs/components/foundation/README.md
Normal file
@@ -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<br/>(kind, key fields)"]
|
||||
JSON["canonical JSON<br/>(JCS, alphabetical key order)"]
|
||||
Hash["blake3 32 byte digest"]
|
||||
Hex["hex string 앞 32자"]
|
||||
ID["AssetId/DocumentId/<br/>BlockId/ChunkId/<br/>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<RefusalReason>, 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<Self>` — 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_<SECTION>_<KEY>` (예: `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)
|
||||
151
docs/components/llm/README.md
Normal file
151
docs/components/llm/README.md
Normal file
@@ -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 {
|
||||
<<trait kebab-core>>
|
||||
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 {
|
||||
<<enum>>
|
||||
Token(String)
|
||||
Done {finish_reason, usage}
|
||||
}
|
||||
class FinishReason {
|
||||
<<enum>>
|
||||
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 {
|
||||
<<error>>
|
||||
ConnectionRefused
|
||||
HttpStatus
|
||||
Decode
|
||||
...
|
||||
}
|
||||
LanguageModel <|.. OllamaLanguageModel
|
||||
LanguageModel <|.. MockLanguageModel
|
||||
LanguageModel ..> GenerateRequest
|
||||
LanguageModel ..> TokenChunk
|
||||
TokenChunk ..> FinishReason
|
||||
OllamaLanguageModel ..> LlmError
|
||||
```
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Req["GenerateRequest<br/>{system, user, stop,<br/>max_tokens, temp, seed, images}"]
|
||||
Wire["JSON wire<br/>POST /api/generate<br/>stream: true"]
|
||||
Lines["line-delimited JSON<br/>frames"]
|
||||
Token["TokenChunk::Token(...)"]
|
||||
DoneOk["TokenChunk::Done<br/>{finish_reason: Stop|Length, usage}"]
|
||||
DoneErr["TokenChunk::Done<br/>{finish_reason: Error/Aborted, usage}"]
|
||||
Iter["Iterator~Result~TokenChunk~~<br/>(lazy, Send)"]
|
||||
Req --> Wire --> Lines
|
||||
Lines -->|frame| Token --> Iter
|
||||
Lines -->|done frame| DoneOk --> Iter
|
||||
Lines -->|error/abort| DoneErr --> Iter
|
||||
Caller["caller (kebab-rag)<br/>+ 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<Box<dyn Iterator<Item = Result<TokenChunk>> + Send>>` — async 안 됨, 매 next() 가 blocking. 모든 stream 이 마지막에 `TokenChunk::Done` 으로 끝남 (error 케이스 포함, §0 Q5).
|
||||
|
||||
**`GenerateRequest`** (`kebab-core::traits`):
|
||||
- `images: Vec<String>` (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<Self>` — `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<String>` 추가 (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)
|
||||
144
docs/components/normalize-chunk/README.md
Normal file
144
docs/components/normalize-chunk/README.md
Normal file
@@ -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<Chunk>`. v1 두 변종: `md-heading-v1` (markdown + image), `pdf-page-v1` (PDF). |
|
||||
|
||||
## 구조
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Normalize {
|
||||
<<kebab-normalize>>
|
||||
build_canonical_document(asset, metadata, blocks, parser_version, warnings) CanonicalDocument
|
||||
derive_title(frontmatter, blocks, file_stem) String
|
||||
nfc(s) String
|
||||
}
|
||||
class Chunker {
|
||||
<<trait kebab-core>>
|
||||
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~<br/>(kebab-parse-md)"]
|
||||
Asset["RawAsset<br/>(kebab-source-fs)"]
|
||||
Meta["Metadata<br/>(frontmatter)"]
|
||||
Pv["ParserVersion"]
|
||||
Norm["build_canonical_document<br/>NFC heading_path<br/>+ ordinal per (path, kind)<br/>+ derive_title fallback chain<br/>+ Provenance accumulator"]
|
||||
CDoc["CanonicalDocument<br/>(blocks + metadata + provenance)"]
|
||||
DirectCDoc["CanonicalDocument<br/>(kebab-parse-pdf / kebab-parse-image)"]
|
||||
MdC["MdHeadingV1Chunker<br/>(markdown + image)"]
|
||||
PdfC["PdfPageV1Chunker<br/>(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<ParsedBlock>, parser_version: &ParserVersion, warnings: Vec<Warning>) -> Result<CanonicalDocument>`.
|
||||
- `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<WorkspacePath>` — 재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<Vec<Chunk>>`.
|
||||
|
||||
**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)
|
||||
154
docs/components/parse/README.md
Normal file
154
docs/components/parse/README.md
Normal file
@@ -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<ParsedBlock>` + `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 {
|
||||
<<trait kebab-core>>
|
||||
supports(MediaType) bool
|
||||
parser_version() ParserVersion
|
||||
extract(ctx, bytes) CanonicalDocument
|
||||
}
|
||||
class MdParser {
|
||||
<<pure functions>>
|
||||
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 {
|
||||
<<trait kebab-parse-image>>
|
||||
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<br/>(RawAsset)"]
|
||||
subgraph MD ["Markdown"]
|
||||
MdFM["parse_frontmatter<br/>(YAML/TOML)"]
|
||||
MdBlocks["parse_blocks<br/>(pulldown-cmark + line span)"]
|
||||
Pblock["Vec~ParsedBlock~<br/>+ Metadata"]
|
||||
end
|
||||
subgraph PDF ["PDF"]
|
||||
PdfLoad["lopdf::Document::load_mem<br/>encrypted/corrupt 거부"]
|
||||
PdfPages["per-page extract_text<br/>SourceSpan::Page"]
|
||||
PdfDoc["CanonicalDocument<br/>(page = paragraph block)"]
|
||||
end
|
||||
subgraph IMG ["Image"]
|
||||
ImgDims["dims::probe<br/>(format + WxH, ≤ 16384)"]
|
||||
ImgExif["exif_extract<br/>(whitelist)"]
|
||||
ImgBlock["ImageRefBlock<br/>(ocr=None, caption=None)"]
|
||||
ImgOcr["OcrEngine.run<br/>(p6-2)"]
|
||||
ImgCap["caption_image<br/>(p6-3, LanguageModel)"]
|
||||
ImgDoc["CanonicalDocument<br/>(single block)"]
|
||||
end
|
||||
Bytes --> MdFM --> Pblock
|
||||
Bytes --> MdBlocks --> Pblock
|
||||
Pblock --> Normalize["kebab-normalize<br/>(다음 그룹)"]
|
||||
Bytes --> PdfLoad --> PdfPages --> PdfDoc
|
||||
Bytes --> ImgDims --> ImgBlock
|
||||
Bytes --> ImgExif --> ImgBlock
|
||||
ImgBlock --> ImgOcr -.optional.-> ImgDoc
|
||||
ImgBlock --> ImgCap -.optional.-> ImgDoc
|
||||
ImgBlock --> ImgDoc
|
||||
PdfDoc --> Chunk["kebab-chunk<br/>(다음 그룹)"]
|
||||
ImgDoc --> Chunk
|
||||
Normalize --> Chunk
|
||||
```
|
||||
|
||||
## 주요 type / trait / 함수
|
||||
|
||||
**Markdown** (`kebab-parse-md`):
|
||||
- `parse_frontmatter(bytes) -> (Metadata, Option<FrontmatterSpan>, Vec<Warning>)` — YAML/TOML 둘 다 인식. 파싱 실패 → `WarningKind::MalformedFrontmatter`.
|
||||
- `parse_blocks(body) -> (Vec<ParsedBlock>, Vec<Warning>)` — `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<ModelCaption>` — `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<String>` 필드 신설**.
|
||||
**왜**: 기존 `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)
|
||||
148
docs/components/rag/README.md
Normal file
148
docs/components/rag/README.md
Normal file
@@ -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 {
|
||||
<<enum>>
|
||||
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<br/>(history 의 직전 answer<br/>첫 200 chars concat)"]
|
||||
Retrieve["2. Retriever.search<br/>(k = max(opts.k, default_k))"]
|
||||
Gate["3. score gate<br/>top_score >= rag.score_gate?"]
|
||||
Pack["4. pack context<br/>chunks 를 [근거 N] 블록으로<br/>+ Citation 보존"]
|
||||
History["5. prepend [이전 대화]<br/>(p9-fb-15, char budget 내<br/>oldest drop)"]
|
||||
Render["6. render prompt<br/>system + [이전 대화]<br/>+ query + [근거 N..N+m]"]
|
||||
Gen["7. LanguageModel<br/>.generate_stream"]
|
||||
Stream["stream_sink 로 token 전송<br/>(sink drop = silent swallow)"]
|
||||
Validate["8. cite-validate<br/>본문의 [N] 마커 →<br/>AnswerCitation 매핑"]
|
||||
Persist["9. answers 행 INSERT<br/>(refusal 도 항상 persist)"]
|
||||
Out["Answer<br/>+ 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<dyn Retriever>, llm: Arc<dyn LanguageModel>, docs: Arc<SqliteStore>) -> Self` — caller (kebab-app) 가 wire.
|
||||
- `RagPipeline::ask(&self, query: &str, opts: AskOpts) -> Result<Answer>` — single-shot 또는 history 가 빈 multi-turn 첫 호출.
|
||||
- `RagPipeline::ask_with_history(&self, query, history, conversation_id, turn_index, opts) -> Result<Answer>` — 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<mpsc::Sender<String>>` — 매 `TokenChunk::Token` 동기 forward. receiver drop 시 `SendError` silent swallow + 생성 계속 (answers 행 보존).
|
||||
- `history: Vec<Turn>` (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<AnswerCitation>` + `refusal_reason: Option<RefusalReason>` + `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)
|
||||
137
docs/components/search/README.md
Normal file
137
docs/components/search/README.md
Normal file
@@ -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 {
|
||||
<<trait kebab-core>>
|
||||
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 {
|
||||
<<enum>>
|
||||
Rrf{k_rrf}
|
||||
}
|
||||
class SearchMode {
|
||||
<<enum>>
|
||||
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<br/>{text, mode, k, filters}"]
|
||||
Disp["mode dispatch"]
|
||||
Lex["LexicalRetriever<br/>SQLite FTS5 + bm25<br/>+ snippet/highlight"]
|
||||
Vec["VectorRetriever<br/>Embedder.embed(query)<br/>→ VectorStore.search<br/>→ SQLite hydrate"]
|
||||
Fan["k × HYBRID_FANOUT_MULTIPLIER (2)<br/>각 측 fanout"]
|
||||
RRF["RRF fusion<br/>score = Σ 1 / (k_rrf + rank_m)<br/>fusion_score / (2 / (k_rrf+1))<br/>→ [0,1]"]
|
||||
Merge["chunk 양측 등장 시<br/>lexical snippet 우선<br/>(FTS5 highlight 가 사용자 친화)"]
|
||||
Hits["Vec~SearchHit~<br/>(snippet, citation,<br/>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<Vec<SearchHit>>`.
|
||||
- `Retriever::index_version() -> IndexVersion` — hybrid 가 두 측 version 다르면 stale-index 경고.
|
||||
|
||||
**LexicalRetriever** (`kebab-search::lexical`):
|
||||
- `LexicalRetriever::new(store: Arc<SqliteStore>, 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<SqliteStore>, vector_store: Arc<dyn VectorStore>, embedder: Arc<dyn Embedder>, ...)`.
|
||||
- 구현: query text → `embedder.embed(EmbeddingKind::Query, ...)` → `vector_store.search(vec, k, filters)` → SQLite 로 hydrate (snippet, citation 등은 vector hit 의 `chunk_id` 로 SELECT).
|
||||
- `Arc<dyn Embedder>` runtime injection — concrete adapter (`kebab-embed-local::FastembedEmbedder`) 는 caller 가 wire.
|
||||
|
||||
**HybridRetriever** (`kebab-search::hybrid`):
|
||||
- `HybridRetriever::new(&Config, Arc<dyn Retriever> lex, Arc<dyn Retriever> 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<dyn Retriever>` 두 개 받으니 lexical/vector 가 자기들도 trait object 가능. test 가 `CannedRetriever` mock 으로 두 측 inject 가능 — RRF 만 검증할 때 SQLite/Lance 부재해도 됨.
|
||||
|
||||
- **`HybridRetriever` 가 `Arc<dyn Embedder>` 직접 안 받음**.
|
||||
**왜**: 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)
|
||||
107
docs/components/source/README.md
Normal file
107
docs/components/source/README.md
Normal file
@@ -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 {
|
||||
<<trait kebab-core>>
|
||||
scan(scope) Vec~RawAsset~
|
||||
}
|
||||
SourceConnector <|.. FsSourceConnector
|
||||
FsSourceConnector --> WalkerModule
|
||||
FsSourceConnector --> HashModule
|
||||
FsSourceConnector --> MediaModule
|
||||
```
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Cfg["Config<br/>(workspace.root, exclude, copy_threshold_mb)"]
|
||||
Scope["SourceScope<br/>(per-call lens)"]
|
||||
Walk["walk_files<br/>walkdir + gitignore overrides<br/>+ symlink cycle guard"]
|
||||
KBI[".kebabignore<br/>(매 scan 재읽음)"]
|
||||
Default["DEFAULT_EXCLUDES<br/>.DS_Store / ._*"]
|
||||
Hash["blake3 file hash<br/>(byte_len + hex)"]
|
||||
Media["media_type_for<br/>(extension → MediaType)"]
|
||||
Posix["to_posix<br/>NFC + leading ./ strip<br/># 거부"]
|
||||
Asset["RawAsset<br/>{asset_id, workspace_path,<br/>media_type, byte_len,<br/>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<Self>` — `Config::resolve_workspace_root()` 호출 (p9-fb-05 path policy 통일), `copy_threshold_mb * 1 MiB` 미리 곱해 둠.
|
||||
- `FsSourceConnector::scan(&SourceScope) -> Result<Vec<RawAsset>>` — 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<String>` — `<root>/.kebabignore` 매 scan 재읽음 (long-running process 가 file edit 즉시 반영).
|
||||
- `walker::walk_files(root, overrides) -> Vec<PathBuf>` — `walkdir::WalkDir::follow_links(true)` + 별도 `visited: HashSet<canonical PathBuf>` 으로 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<RawAsset>` 가 `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)
|
||||
152
docs/components/store/README.md
Normal file
152
docs/components/store/README.md
Normal file
@@ -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_<model>_<dim>.lance/` 테이블. SQLite 와 two-phase write 협조. |
|
||||
|
||||
## 구조
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class DocumentStore {
|
||||
<<trait kebab-core>>
|
||||
put_asset(a)
|
||||
put_document(d)
|
||||
put_blocks(doc, blocks)
|
||||
put_chunks(doc, chunks)
|
||||
get_document(id)
|
||||
list_documents(filter)
|
||||
}
|
||||
class VectorStore {
|
||||
<<trait kebab-core>>
|
||||
ensure_table(model, dim) IndexId
|
||||
upsert(recs)
|
||||
search(query_vec, k, filters) Vec~VectorHit~
|
||||
delete_by_chunk_ids(ids)
|
||||
}
|
||||
class JobRepo {
|
||||
<<trait kebab-core>>
|
||||
create / update_progress / finish / list
|
||||
}
|
||||
class ChatSessionRepo {
|
||||
<<trait kebab-core>>
|
||||
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<br/>embedding_records<br/>status='pending'"]
|
||||
LanceWrite["2. Lance MergeInsert<br/>(keyed on chunk_id)"]
|
||||
CommitFlip["3. SQLite UPDATE<br/>status='committed'"]
|
||||
SearchJoin["search() join<br/>WHERE status='committed'<br/>(pending 행 안 보임)"]
|
||||
Caller --> PendingIns --> LanceWrite --> CommitFlip
|
||||
SearchJoin -.- CommitFlip
|
||||
Crash["프로세스 crash<br/>(phase 2 또는 phase 3 사이)"]
|
||||
Retry["다음 upsert 가<br/>idempotent 재시도<br/>(MergeInsert 가 chunk_id 로 dedupe)"]
|
||||
LanceWrite -.crash.-> Crash --> Retry
|
||||
```
|
||||
|
||||
## Data flow — re-ingest orphan cleanup (delete 경로)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Bytes["asset bytes 변경<br/>(같은 workspace_path)"]
|
||||
Purge["purge_orphan_at_workspace_path<br/>(SQLite 의 stale chunk_id 수집)"]
|
||||
LanceDel["VectorStore::delete_by_chunk_ids<br/>(LanceDB 행 삭제)"]
|
||||
Reingest["새 doc_id / chunk_id 로 정상 ingest"]
|
||||
Bytes --> Purge --> LanceDel --> Reingest
|
||||
```
|
||||
|
||||
## 주요 type / trait / 함수
|
||||
|
||||
**`SqliteStore`** (`kebab-store-sqlite::store`):
|
||||
- `SqliteStore::open(&kebab_config::Config) -> Result<Self>` — `data_dir/sqlite` 파일 열고 `Mutex<Connection>` 으로 wrap.
|
||||
- `SqliteStore::run_migrations()` — `refinery` 가 `migrations/V001..V005` 적용.
|
||||
- `put_asset_with_bytes(asset, bytes)` — content-addressable 저장 (`{asset_dir}/<aa>/<bb>/<full_hex>` blob), `assets.workspace_path` UNIQUE 충돌 시 `purge_orphan_at_workspace_path` 후 재삽입 (HOTFIXES P7-3).
|
||||
- `corpus_revision() -> u64` / `bump_corpus_revision() -> Result<u64>` — `kv` 테이블의 monotonic counter (p9-fb-19, search cache invalidation key).
|
||||
- `stale_chunk_ids_at(&WorkspacePath) -> Vec<ChunkId>` — 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<SqliteStore>) -> Result<Self>` — `Arc<SqliteStore>` 공유로 two-phase coord.
|
||||
- `VectorStore` trait 구현. 모든 메서드 안에서 private current-thread `tokio::runtime::Runtime` 위 `block_on` (sync trait → async LanceDB 브리지).
|
||||
- 테이블 명: `chunk_embeddings_<sanitized_model>_<dim>.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 가능. 테이블 명에 `<model>_<dim>` 인코딩 → `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<Connection>` 공유. 별 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)
|
||||
183
docs/components/ui/README.md
Normal file
183
docs/components/ui/README.md
Normal file
@@ -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 {
|
||||
<<enum>>
|
||||
Library
|
||||
Search
|
||||
Ask
|
||||
Inspect
|
||||
Jobs
|
||||
}
|
||||
class Mode {
|
||||
<<enum>>
|
||||
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<br/>main"]
|
||||
Setup["enable_raw_mode<br/>+ EnterAlternateScreen<br/>+ Hide cursor"]
|
||||
Loop["run loop"]
|
||||
Tick["매 tick"]
|
||||
Drain["drain progress channel<br/>(ingest worker)"]
|
||||
Poll["search worker poll<br/>(generation match?)"]
|
||||
Render["render pane (focus)<br/>+ overlay (error/cheatsheet)"]
|
||||
Event["crossterm event"]
|
||||
CIntercept["cheatsheet_intercept<br/>(F1 toggle, Esc close)"]
|
||||
MIntercept["mode_intercept<br/>(i/Esc Normal↔Insert)"]
|
||||
Dispatch["pane dispatch<br/>(library/search/ask/inspect)"]
|
||||
EditorReq["EditorRequest enqueue"]
|
||||
EditorSpawn["with_external_program<br/>(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<PathBuf>, 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<Line<'static>>` (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)
|
||||
Reference in New Issue
Block a user