From dece5e89fccbcf908648cf35ab9a37f72e52a9f7 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 29 May 2026 02:16:21 +0000 Subject: [PATCH] feat(bulk): document bulk search input schema + error shape hint (Todo #2) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- crates/kebab-app/src/bulk.rs | 18 ++++- docs/DOGFOOD.md | 9 ++- .../v1/bulk_search_input.schema.json | 74 +++++++++++++++++++ .../v1/bulk_search_item.schema.json | 2 +- 5 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 docs/wire-schema/v1/bulk_search_input.schema.json diff --git a/README.md b/README.md index d7f6008..0686b51 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ kebab doctor |------|------| | `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 | | `kebab ingest []` | Markdown / 이미지 / PDF / Rust 소스코드 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. **지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`), **소스코드** (`.rs` → `code-rust-ast-v1`, `.py` → `code-python-ast-v1`, `.ts`/`.tsx` → `code-ts-ast-v1`, `.js`/`.mjs`/`.cjs`/`.jsx` → `code-js-ast-v1`, `.go` → `code-go-ast-v1`, `.java` → `code-java-ast-v1`, `.kt`/`.kts` → `code-kotlin-ast-v1`, `.c`/`.h` → `code-c-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx` → `code-cpp-ast-v1` — 모두 tree-sitter AST chunker; **Tier 2 리소스 파일**: `.yaml`/`.yml` → `k8s-manifest-resource-v1` (apiVersion+kind 파싱), `Dockerfile`/`Dockerfile.*`/`*.dockerfile` → `dockerfile-file-v1` (전체 파일), `Cargo.toml`/`pyproject.toml`/`.toml`/`package.json`/`tsconfig.json`/`.json`/`pom.xml`/`.xml`/`build.gradle`/`.gradle`/`go.mod` → `manifest-file-v1` (전체 파일) — yaml (k8s) / dockerfile / toml / json / xml / groovy / go-mod 지원); **Tier 3 paragraph fallback** (`.sh`/`.bash`/`.zsh` → `code-text-paragraph-v1`, blank-line paragraph split + 80-line/20-overlap line-window. Tier 1/2 가 0 chunk 또는 Err 시 자동 fallback — 비-k8s YAML 같은 케이스 picked up. symbol = None, lang 은 원본 보존.). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. 코드 chunk 는 `citation.kind = "code"` 에 `citation.lang = ""` + `symbol` + line range 를 담고, SearchHit top-level 에 `code_lang` + `repo` (`.git/` walk-up 의 디렉토리 이름) 가 backfill 됨. `--code-lang rust` / `--code-lang python` / `--code-lang typescript` / `--code-lang javascript` / `--code-lang go` / `--code-lang java` / `--code-lang kotlin` / `--code-lang yaml` / `--code-lang dockerfile` / `--code-lang toml` / `--code-lang json` / `--code-lang xml` / `--code-lang groovy` / `--code-lang go-mod` / `--code-lang shell` / `--code-lang c` / `--code-lang cpp` / `--media code` filter 로 언어별·코드 전용 검색 가능 (p10-1A-1 filter flags). Python symbol 은 workspace 경로 → dotted module path prefix (예: `kebab_eval.metrics.compute_mrr`), TS/JS symbol 은 slash-style module path prefix (예: `src/Foo.Foo.search`), Go symbol 은 `package.Func` / `package.(*Receiver).Method` 형식, Java / Kotlin symbol 은 `com.foo.Foo.bar` 형식 (패키지 + 클래스 + 메서드/필드). | -| `kebab search --mode {lexical,vector,hybrid} "" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor ] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media` 는 `,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min` 은 `primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md` 는 `markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. **v0.20.1 V009 morphological tokenizer (한국어 + 영어 동작 변경):** `chunks_fts` 가 FTS5 `unicode61` + 한국어 lindera ko-dic 형태소 분석 결과를 별 column 으로 prepend. **한국어 2자 query 지원** — '한국', '서울', '지하철' 같은 2자/3자 단어가 형태소 분해 후 hit. **영어는 whole-token 매칭** — V002 동작으로 회귀 (`tokenizer` query 는 `tokenizer` 토큰만 hit, `token` 같은 substring 은 hit X). substring recall 이 필요하면 vector/hybrid mode 권장. `kebab.sqlite` 파일 크기는 lindera ko-dic embedded dict 와 tokenized_korean_text column 의존성으로 다소 증가. V009 자동 backfill (`App::open_with_config` 의 first-boot hook) — re-ingest 불필요. | +| `kebab search --mode {lexical,vector,hybrid} "" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor ] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media` 는 `,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min` 은 `primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md` 는 `markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. 입력은 stdin ndjson — 줄당 한 query object, `{"query":""}` 만 필수 (string; nested object 아님), `mode`/`k`/`trust_min`/`ingested_after`/`media`/`tag`/`lang` optional (`docs/wire-schema/v1/bulk_search_input.schema.json`). 예: `echo '{"query":"한국","mode":"lexical","k":3}' | kebab search --bulk --json`. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. **v0.20.1 V009 morphological tokenizer (한국어 + 영어 동작 변경):** `chunks_fts` 가 FTS5 `unicode61` + 한국어 lindera ko-dic 형태소 분석 결과를 별 column 으로 prepend. **한국어 2자 query 지원** — '한국', '서울', '지하철' 같은 2자/3자 단어가 형태소 분해 후 hit. **영어는 whole-token 매칭** — V002 동작으로 회귀 (`tokenizer` query 는 `tokenizer` 토큰만 hit, `token` 같은 substring 은 hit X). substring recall 이 필요하면 vector/hybrid mode 권장. `kebab.sqlite` 파일 크기는 lindera ko-dic embedded dict 와 tokenized_korean_text column 의존성으로 다소 증가. V009 자동 backfill (`App::open_with_config` 의 first-boot hook) — re-ingest 불필요. | | `kebab list docs` | 색인된 문서 목록. human-readable 출력은 `doc_id \t title \t doc_path` (title 은 heading 기반이라 중복 가능 — doc_path 로 구분). `--json` 은 `doc_summary.v1` array (title / doc_path 모두 포함, wire schema 불변). | | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab fetch chunk [--context N]` / `kebab fetch doc [--max-tokens N]` / `kebab fetch span [--max-tokens N]` | (p9-fb-35) verbatim text fetch from indexed corpus. wire = `fetch_result.v1` (kind discriminator). chunk: target + ±N ordinal-context chunks. doc: full normalized markdown. span: 1-based line range (PDF/audio rejected as `error.v1.code = span_not_supported`). chars/4 budget on doc/span. | diff --git a/crates/kebab-app/src/bulk.rs b/crates/kebab-app/src/bulk.rs index 8954fbe..675d6f0 100644 --- a/crates/kebab-app/src/bulk.rs +++ b/crates/kebab-app/src/bulk.rs @@ -126,7 +126,10 @@ fn parse_one(raw: &Value) -> Result<(SearchQuery, SearchOpts), String> { let text = obj .get("query") .and_then(|v| v.as_str()) - .ok_or("missing required field: query")? + .ok_or( + "missing required field: query \ + (expected {\"query\":\"\",\"mode\":\"lexical|vector|hybrid\",\"k\":3,...})", + )? .to_string(); let mode = match obj.get("mode").and_then(|v| v.as_str()) { @@ -302,4 +305,17 @@ mod tests { assert!(items[1].error.is_some()); assert_eq!(items[1].error.as_ref().unwrap()["code"], "invalid_input"); } + + #[test] + fn missing_query_error_message_includes_shape_hint() { + let cfg = open_temp(); + let raw = vec![serde_json::json!({"mode": "lexical"})]; + let (items, _summary) = bulk_search_with_config(cfg, raw).unwrap(); + let err = items[0].error.as_ref().unwrap(); + let msg = err["message"].as_str().unwrap(); + assert!( + msg.contains("query") && msg.contains("mode"), + "missing shape hint in error message: {msg}" + ); + } } diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index f0af177..0cc6f64 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -418,10 +418,15 @@ $KB search 'tokenizer' --mode lexical --json | jq '.hits | length' # ≥ 1 if co ### §2.7 Bulk search -stdin queries 의 batch: +stdin ndjson — 줄당 하나의 query object (`{"query":""}` 필수, 나머지 optional): ```bash -echo -e "query 1\nquery 2\nquery 3" | "$RELEASE_BIN" search --bulk --json +printf '%s\n' \ + '{"query":"한국","mode":"lexical","k":3}' \ + '{"query":"tokenizer","mode":"hybrid"}' \ + '{"query":"lindera","mode":"vector","k":5}' \ + | "$RELEASE_BIN" search --bulk --json ``` +기대: 줄당 `bulk_search_item.v1` (query echo + response 또는 error). `query` 누락 시 그 item 만 `error.v1` (code `invalid_input`, message 에 shape hint), 나머지 query 계속 진행. Cap 100. --- diff --git a/docs/wire-schema/v1/bulk_search_input.schema.json b/docs/wire-schema/v1/bulk_search_input.schema.json new file mode 100644 index 0000000..9347689 --- /dev/null +++ b/docs/wire-schema/v1/bulk_search_input.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://kb.local/wire/v1/bulk_search_input.schema.json", + "title": "BulkSearchInput v1", + "description": "v0.20.2 (Todo #2): 한 줄(ndjson)당 하나의 bulk search query. `kebab search --bulk` 가 stdin 에서 줄 단위로 받는다. `query` 만 required (string — nested object 아님); 나머지는 optional. `kebab-app::bulk::parse_one` 이 source of truth.", + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "검색 텍스트. required. nested object ({\"text\":...}) 가 아니라 평문 string." + }, + "mode": { + "type": "string", + "enum": ["lexical", "vector", "hybrid"], + "description": "검색 모드. optional, default `hybrid`." + }, + "k": { + "type": "integer", + "minimum": 0, + "description": "반환 hit 수. optional. 생략 또는 0 → app 이 config `search.default_k` (default 10) 로 해석." + }, + "trust_min": { + "type": "string", + "enum": ["primary", "secondary", "generated"], + "description": "최소 trust level (해당 level 이상 포함). optional." + }, + "ingested_after": { + "type": "string", + "format": "date-time", + "description": "RFC3339 date-time. 이 시각 이후 ingest 된 문서만. optional." + }, + "media": { + "type": "array", + "items": { "type": "string" }, + "description": "media kind OR 필터 (`md` → `markdown` alias 정규화). optional." + }, + "tag": { + "type": "array", + "items": { "type": "string" }, + "description": "tag OR 필터. optional." + }, + "lang": { + "type": "string", + "description": "ISO 언어 코드 필터. optional." + }, + "path_glob": { + "type": "string", + "description": "doc_path glob 필터. optional (parse_one 추가 지원 필드)." + }, + "doc_id": { + "type": "string", + "description": "특정 doc_id 로 제한. optional (parse_one 추가 지원 필드)." + }, + "max_tokens": { + "type": "integer", + "minimum": 0, + "description": "응답 JSON token budget (chars/4 근사). optional." + }, + "snippet_chars": { + "type": "integer", + "minimum": 0, + "description": "per-hit snippet 문자 cap. optional." + }, + "cursor": { + "type": "string", + "description": "이전 응답의 opaque base64 cursor (pagination). optional." + }, + "trace": { + "type": "boolean", + "description": "true 시 pipeline trace 캡처 (캐시 우회). optional, default false." + } + } +} diff --git a/docs/wire-schema/v1/bulk_search_item.schema.json b/docs/wire-schema/v1/bulk_search_item.schema.json index eb82731..f825ded 100644 --- a/docs/wire-schema/v1/bulk_search_item.schema.json +++ b/docs/wire-schema/v1/bulk_search_item.schema.json @@ -7,7 +7,7 @@ "required": ["schema_version", "query", "response", "error"], "properties": { "schema_version": { "const": "bulk_search_item.v1" }, - "query": { "type": "object", "description": "Input echo (verbatim JSON object)." }, + "query": { "type": "object", "description": "Input echo (verbatim JSON object). 입력 shape 는 bulk_search_input.schema.json 참조 — `query` (string) 만 required, 나머지 optional." }, "response":{ "type": ["object", "null"], "description": "search_response.v1 payload on success; null when error is non-null."