From 7210386699c172912cfe65fc4a6d43f52084b367 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sun, 10 May 2026 03:26:40 +0900
Subject: [PATCH] =?UTF-8?q?spec(fb-36):=20search=20filter=20args=20?=
=?UTF-8?q?=E2=80=94=20design?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`kebab search` 에 7 flag 노출 (기존 4 + 신규 3):
- --tag (반복) / --lang / --path-glob / --trust-min (기존 SearchFilters)
- --media (csv) / --ingested-after (RFC3339) / --doc-id (신규)
filter layer = SQLite WHERE (lexical) + over-fetch+post-filter
(vector). AND 결합. wire schema 무변경 (input only).
`SearchFilters` 3 필드 additive (#[serde(default)] 로 backwards-
compat). MCP SearchInput 7 optional 필드 추가. invalid RFC3339 →
error.v1.code = config_invalid.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
...26-05-10-p9-fb-36-search-filters-design.md | 213 ++++++++++++++++++
1 file changed, 213 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-10-p9-fb-36-search-filters-design.md
diff --git a/docs/superpowers/specs/2026-05-10-p9-fb-36-search-filters-design.md b/docs/superpowers/specs/2026-05-10-p9-fb-36-search-filters-design.md
new file mode 100644
index 0000000..cacf1d0
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-10-p9-fb-36-search-filters-design.md
@@ -0,0 +1,213 @@
+---
+title: "p9-fb-36 — Search filter args design"
+phase: P9
+component: kebab-core + kebab-search + kebab-cli + kebab-mcp
+task_id: p9-fb-36
+status: design
+target_version: 0.5.0
+contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
+contract_sections: [§4 search]
+date: 2026-05-10
+---
+
+# p9-fb-36 — Search filter args
+
+## Goal
+
+agent / 사용자가 검색 범위를 좁힐 수 있도록 CLI / MCP 에 filter flag 추가. 기존 `SearchFilters` 도메인 type 의 4 필드 (tags_any / lang / path_glob / trust_min) 를 CLI 표면에 노출하고, 신규 3 필드 (media / ingested_after / doc_id) 추가. wire schema 변경 없음 (input-only). filter 적용 layer = SQLite WHERE (lexical) + over-fetch + post-filter (vector). AND 조합 의미 고정.
+
+## Behavior contract
+
+### CLI flags on `kebab search`
+
+7 flags 추가, 모두 optional. 비어있으면 미적용 (기존 동작 보존):
+
+| flag | 의미 | repeat? |
+|------|------|---------|
+| `--tag ` | doc 의 `metadata.tags` 안에 매칭 (OR-within) | yes (`--tag rust --tag async` = `tag IN (rust,async)`) |
+| `--lang ` | `documents.lang` 정확 매칭 | no |
+| `--path-glob ` | `documents.workspace_path` glob 매칭 | no |
+| `--trust-min ` | `documents.trust_level >= level` (enum 순서) | no |
+| `--media ` | `assets.media_type.kind` IN 리스트 (예: `--media md,pdf`) | csv |
+| `--ingested-after ` | `documents.updated_at >= timestamp` | no |
+| `--doc-id ` | `documents.doc_id = id` | no |
+
+다중 flag 조합 = AND 결합. 각 flag 안 다중 값 (--tag, --media) = OR.
+
+### Filter validation
+
+- `--ingested-after` RFC3339 파싱 실패 → CLI 진입 시 `error.v1.code = config_invalid`, exit 2.
+- `--media` 의 unknown value (예: `--media foo`) → 매칭 0건 (filter unmatch). 명시적 거절 안 함 (lenient).
+- `--trust-min` clap value_enum 검증 (enum 외 거절).
+- `--doc-id` 형식 검증 안 함 (DocumentId 는 단순 string wrapper). 존재하지 않으면 매칭 0건.
+
+### Filter layer
+
+**Lexical (lexical.rs)**:
+- 기존 SQL builder 의 WHERE 절 확장. `media` / `ingested_after` / `doc_id` 모두 SQL 구문 가능.
+- `media`: `JOIN assets a ON a.asset_id = d.asset_id` + `json_extract(a.media_type, '$.kind') IN (?, ?)` (다중 값).
+- `ingested_after`: `d.updated_at >= ?` (RFC3339 lexicographic compare; UTC `Z` 가정).
+- `doc_id`: `d.doc_id = ?`.
+- path_glob 은 기존 post-filter 그대로.
+
+**Vector (vector.rs)**:
+- 기존 over-fetch (k * 2) + `filter_chunks` 헬퍼에서 SQLite chunks JOIN documents JOIN assets.
+- 같은 WHERE 조건 적용. k 부족 시 truncated.
+
+### Wire shape
+
+기존 wire schema 변경 없음.
+
+- `search_response.v1` (output) — 그대로.
+- `search_hit.v1` (개별 hit) — 그대로.
+- 입력 측 (CLI args / MCP `SearchInput`) 만 확장.
+
+MCP `SearchInput` schema 는 `schemars` derive 로 자동 갱신. 수동 schema 파일 X.
+
+### MCP `SearchInput` 확장
+
+```rust
+pub struct SearchInput {
+ pub query: String,
+ pub mode: Option,
+ pub k: Option,
+ pub max_tokens: Option, // fb-34
+ pub snippet_chars: Option, // fb-34
+ pub cursor: Option, // fb-34
+ // p9-fb-36 신규 (모두 optional)
+ pub tags: Option>,
+ pub lang: Option,
+ pub path_glob: Option,
+ pub trust_min: Option, // "low" | "medium" | "high"
+ pub media: Option>,
+ pub ingested_after: Option, // RFC3339
+ pub doc_id: Option,
+}
+```
+
+input → `SearchFilters` 변환 시 위와 동일 검증 (RFC3339 파싱, trust_level enum). 실패 시 `invalid_input` ErrorV1.
+
+## Allowed / forbidden dependencies
+
+- `kebab-core`: 신규 dep 없음. 기존 type 확장만.
+- `kebab-search`: 변경 없음 (SQL builder 안 WHERE 추가만).
+- `kebab-cli`: clap flag 추가, dispatch 변환.
+- `kebab-mcp`: SearchInput 확장.
+- `kebab-tui`: 변경 없음.
+
+`kebab-core` 의 다른 `kebab-*` crate 의존 금지 룰 그대로.
+
+## Public surface delta
+
+### kebab-core
+
+```rust
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+pub struct SearchFilters {
+ pub tags_any: Vec,
+ pub lang: Option,
+ pub path_glob: Option,
+ pub trust_min: Option,
+ /// p9-fb-36: media_type filter — IN-list of `MediaType.kind` strings
+ /// (e.g. `["markdown", "pdf"]`). Empty Vec = no filter.
+ #[serde(default)]
+ pub media: Vec,
+ /// p9-fb-36: hits whose source doc's `documents.updated_at` is at
+ /// or after this timestamp. None = no filter. RFC3339 / UTC.
+ #[serde(default, with = "time::serde::rfc3339::option")]
+ pub ingested_after: Option,
+ /// p9-fb-36: restrict hits to a single document. None = no filter.
+ #[serde(default)]
+ pub doc_id: Option,
+}
+```
+
+`#[serde(default)]` on each new field = backwards-compat (older JSON without these keys deserializes as defaults).
+
+### kebab-search (lexical + vector)
+
+내부 SQL builder 확장만. public API 변경 없음.
+
+### kebab-cli (`Cmd::Search`)
+
+```rust
+Cmd::Search {
+ // 기존
+ query, k, mode, explain, no_cache,
+ max_tokens, snippet_chars, cursor, // fb-34
+ // p9-fb-36 신규
+ #[arg(long)] tag: Vec,
+ #[arg(long)] lang: Option,
+ #[arg(long)] path_glob: Option,
+ #[arg(long, value_enum)] trust_min: Option,
+ #[arg(long, value_delimiter = ',')] media: Vec,
+ #[arg(long)] ingested_after: Option,
+ #[arg(long)] doc_id: Option,
+}
+```
+
+`TrustLevelFlag` 신규 clap value_enum (CLI-internal, kebab-core 의 `TrustLevel` 로 변환).
+
+### kebab-mcp::tools::search
+
+`SearchInput` 7 optional 필드 추가 (위 §MCP `SearchInput` 확장). dispatch 에서 `SearchFilters` 빌드 + 검증.
+
+## Test plan
+
+| kind | description |
+|------|-------------|
+| unit (kebab-core) | `SearchFilters::default()` — 7 필드 모두 비어있음 |
+| unit (kebab-search/lexical) | `media: ["pdf"]` — markdown doc 안 잡힘 |
+| unit (kebab-search/lexical) | `media: ["markdown", "pdf"]` — IN-list 동작 |
+| unit (kebab-search/lexical) | `ingested_after: <어제>` — 어제 이전 doc 안 잡힘 |
+| unit (kebab-search/lexical) | `doc_id: ` — 다른 doc 의 chunk 안 잡힘 |
+| unit (kebab-search/lexical) | 다중 filter AND — 모두 만족하는 hit 만 |
+| unit (kebab-search/lexical) | 빈 filter (default) — 기존 동작과 동일 |
+| unit (kebab-search/vector) | 동일 패턴 — `filter_chunks` post-filter |
+| unit (kebab-search) | 알 수 없는 media 값 (`["foo"]`) — empty result, no error |
+| 통합 (kebab-cli) | `kebab search Q --media md --json` wire shape (search_response.v1 그대로) |
+| 통합 (kebab-cli) | `kebab search Q --ingested-after 2020-01-01 --json` 모든 hit 통과 |
+| 통합 (kebab-cli) | `kebab search Q --ingested-after garbage --json` → `error.v1.code = config_invalid` exit 2 |
+| 통합 (kebab-cli) | `kebab search Q --doc-id --json` 단일 doc 만 |
+| 통합 (kebab-cli) | `kebab search Q --tag rust --tag async --json` IN-list 동작 |
+| 통합 (kebab-mcp) | `mcp__kebab__search` 7 optional 필드 모두 정상 응답 |
+| 통합 (kebab-mcp) | `mcp__kebab__search` invalid `ingested_after` → invalid_input |
+
+## Implementation steps (high-level)
+
+1. `kebab-core::SearchFilters` 3 필드 추가 + 단위 테스트.
+2. `kebab-search/lexical.rs` SQL builder 확장 + 단위 테스트.
+3. `kebab-search/vector.rs` `filter_chunks` 헬퍼 동일 확장 + 단위 테스트.
+4. `kebab-cli::Cmd::Search` 7 flag 추가 + dispatch + RFC3339 파싱.
+5. `kebab-cli` 통합 테스트 (lexical-only, no Ollama).
+6. `kebab-mcp::tools::search::SearchInput` 7 필드 + dispatch + invalid_input 검증.
+7. `kebab-mcp` 통합 테스트.
+8. README + SMOKE — filter 예시.
+9. tasks/INDEX.md / spec status flip.
+10. SKILL.md — `mcp__kebab__search` input shape 갱신.
+
+## Risks / notes
+
+- **`assets.media_type` JSON shape**: `MediaType` enum 의 serde 직렬화 형태가 `{"kind": "markdown"}` 인지, 다른 형태인지 SQLite 저장 형식 확인 필요. `Markdown` 같은 unit variant 는 `"markdown"` 문자열, `Image(...)` / `Audio(...)` 같은 tuple variant 는 `{"image": {...}}` 형태일 가능성. `json_extract` 경로를 그에 맞춰 조정 (e.g. `case when typeof(...) = 'text' then ... else json_extract($.kind) end`).
+- **RFC3339 lexicographic compare**: ingest 시 항상 UTC `Z` 로 저장 (fb-32 ingest path 확인됨). 외부 도구가 다른 offset 으로 강제 update 시 비교 부정확. spec 에 "UTC `Z` 가정" 명시.
+- **path_glob 과 다른 filter 의 ordering**: path_glob 은 post-filter (lexical), 신규 3 개는 SQL — fetch_limit 도달 후 path_glob 으로 추가 cut → final hit 수가 줄 수 있음. 기존 동작과 동일 (path_glob 패턴 유지).
+- **clap `Vec` 의 default**: clap 0.4 에서 미지정 = `Vec::new()`. 자동.
+- **trust_min enum 매핑**: clap value_enum 으로 안전. `TrustLevelFlag` → `TrustLevel` 변환 헬퍼.
+- **SearchFilters serde backwards-compat**: `#[serde(default)]` 로 옛 JSON 무영향. SQLite 안 SearchFilters 직렬 저장 안 함 (request-time only).
+
+## Out of scope
+
+- `--exclude-doc-id` / `--exclude-tag` (exclusion filter).
+- 다중 doc_id (`--doc-id a --doc-id b`) — 단일만.
+- TUI Search 패널 filter UI.
+- Lance metadata pre-filter.
+- tag 시스템 신규 도입 (이미 존재).
+- `--search.default-filter` config (default 값 지정) — agent 가 매번 명시.
+
+## Documentation updates (implementation PR 동시)
+
+- `README.md` — `kebab search` row 의 flag 표기에 7 flag 추가.
+- `docs/SMOKE.md` — filter walkthrough (`--media md --ingested-after 2026-04-01` 예시).
+- `tasks/p9/p9-fb-36-search-filters.md` — `status: open → completed`, design/plan 링크.
+- `tasks/INDEX.md` — fb-36 행 ✅.
+- `integrations/claude-code/kebab/SKILL.md` — `mcp__kebab__search` input shape 갱신 (7 필드 명시 + AND 의미 + lenient unknown media).