feat(fb-35): verbatim fetch (chunk / doc / span) #126
Reference in New Issue
Block a user
Delete Branch "feat/fb-35-verbatim-fetch"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
kebab fetch chunk|doc|spanCLI subcommand +mcp__kebab__fetchMCP toolfetch_result.v1(kind discriminator). chunk mode optional--context Nreturns ±N ordinal-adjacent chunks. doc mode serializesCanonicalDocumentto markdown. span mode slices line range (1-based inclusive)error.v1.code = span_not_supportedStructuredErrorwrapper —chunk_not_found/doc_not_found/span_not_supported/invalid_inputreach the wireTest plan
cargo test --workspace --no-fail-fast -j 1— green (aftercli_mcp_smoketool count bumped 6 → 7)cargo clippy --workspace --all-targets -- -D warnings— cleanfetch_integration(kebab-app): 9 tests covering chunk / chunk+context / chunk_not_found / doc / doc_not_found / doc+budget / span / span clamp / invalid_inputwire_fetch(kebab-cli): 3 lexical-only integration tests (chunk JSON shape, doc truncation, chunk_not_found error path)tools_call_fetch(kebab-mcp): 3 tests (chunk happy path + invalid_kind + missing chunk_id)cli_mcp_smokeupdated for new tool countArchitectural notes
App::fetchis the new public method;App::search/App::search_with_opts/App::askunchangedfmt_canonical_to_markdownis a private helper inkebab-app::fetch— round-trip is best-effort (inline styling collapsed); good enough for an agent reading verbatim contextchunks.text/CanonicalDocument. Raw bytes (assets.storage_path) NOT exposed — user can read directly viacat $WORKSPACE_ROOT/<storage_path>RawAsset.media_type(Pdf / Audio) — actual location of media-type signal — instead ofSourceType(which is Markdown/Note/Paper/Reference/Inbox; user category, not file format)SqliteStore::list_chunk_ids_for_docadded to keep rusqlite usage out of kebab-appFiles of interest
docs/superpowers/specs/2026-05-09-p9-fb-35-verbatim-fetch-design.mddocs/superpowers/plans/2026-05-09-p9-fb-35-verbatim-fetch.mdcrates/kebab-core/src/fetch.rscrates/kebab-app/src/fetch.rscrates/kebab-cli/src/main.rs+wire.rs::wire_fetch_resultcrates/kebab-mcp/src/tools/fetch.rsdocs/wire-schema/v1/fetch_result.schema.json회차 1 — fb-35 verbatim fetch 의 surface 자체는 잘 깎였고 facade rule + StructuredError 재사용 + decoupling (rusqlite 를 store 안에 가두는
list_chunk_ids_for_doc헬퍼) 모두 깔끔. App-level 9개 + CLI 3개 + MCP 3개 테스트 커버리지도 happy path 위주로 충실. 다만fetch_span의effective_end_raw = line_end.min(total).max(line_start)클램프가line_start > total또는 빈 doc 케이스에서lines[lo..hi]panic 으로 빠지는 진짜 버그가 있어 차단 대상. spec 의 "PDF / image" 거절 의도 vs 구현의 "PDF / audio" 거절 (Image variant 미커버),truncated의미 (clamp 와 budget 두 트리거 혼재), chunk_id hash 기반 정렬이 doc 내 위치를 보장 못 하는 구조적 이슈 등 spec/문서 정합성 + edge case 이슈 9개를 함께 인라인으로 남김. 패닉 경로 + spec 일치 두 가지 수정한 뒤 회차 2 진행 권장.@@ -0,0 +196,4 @@)? {if matches!(asset.media_type,kebab_core::MediaType::Pdf | kebab_core::MediaType::Audio(_)spec §Goal 에는 "PDF / image" 거절이라고 되어있는데 실제 거절은
Pdf | Audio(_)만.MediaType::Image(_)(OCR text doc) 의 line-based span 은 OCR 결과의 의미상 안정성이 낮아서 reject 하는 게 spec 의도로 보임. plan §Mode 동작은 "PDF/audio" 라 두 문서가 어긋남.둘 중 하나로 정리:
Image(_)도span_not_supported추가현재 상태로는 image OCR doc 에 span 호출하면 OCR 텍스트 위에서 동작 — agent 가 안정적인 line 번호를 받기 어려움.
@@ -0,0 +227,4 @@let full = fmt_canonical_to_markdown(&doc);let lines: Vec<&str> = full.lines().collect();let total = lines.len() as u32;let effective_end_raw = line_end.min(total).max(line_start);버그:
line_start > total일 때 panic. 예: doc=3 줄,line_start=10, line_end=20이면effective_end_raw = min(20,3).max(10) = 10, 다음 줄에서lines[9..10]슬라이싱하다 out-of-bounds. 빈 doc (total=0) 도 동일 —line_start=1이면effective_end_raw=1,lines[0..1]panic.수정안:
또는
line_start = line_start.min(total)로 추가 clamp.@@ -0,0 +232,4 @@let hi = effective_end_raw as usize;let mut text = lines[lo..hi].join("\n");let mut truncated = effective_end_raw != line_end;nit:
truncated = effective_end_raw != line_end는 clamp 가 일어났을 때만 true. 그런데line_end > total인 "clamp" 는 실제로 데이터가 잘린 게 아니라 사용자가 실제 doc 보다 큰 범위를 요청했을 뿐 —effective_end가 그 사실을 이미 표현.truncated는 budget(max_tokens)에 의한 잘림 의미로 reserve 하는 게 의미가 명확함.clamp 는
line_end != effective_end로 agent 가 인지 가능. wire schema 의truncated설명도 "budget forced text truncation" 이라 일관성도 맞음.@@ -0,0 +285,4 @@.ok_or_else(|| anyhow::anyhow!("chunk not found in doc chunk list"))?;let n = n as usize;let lo = target_idx.saturating_sub(n);let hi = (target_idx + n + 1).min(chunks.len());nit:
target_idx + n + 1이 32-bit usize 환경에서 overflow 가능 (n 이 u32::MAX 일 때). 64-bit 머신에선 무해하나 방어적 산술이 더 안전:n: u32upper bound 도 CLI 단에서 검증 (예:--context 10이상은 거절)하면 합리적.@@ -0,0 +336,4 @@Block::Paragraph(t) => out.push_str(&t.text),Block::Quote(t) => {// Prefix every line with `> ` so block-quote round-trips.for (li, line) in t.text.split('\n').enumerate() {nit: 현재 markdown table separator 가
---|를 N 번 반복 →---|---|---|형태. 선두|가 없어서 일부 strict CommonMark 렌더러에서 table 로 인식 안 될 수 있음. 또 헤더 행이header.join(" | ")인데 separator 행과 cell 갯수가 같다는 보장도 명시 안 됨. "verbatim re-serialization" 이라는 주석 의도 충분하지만, 좀 더 안전하게:실 사용 영향은 작음 (renderer tolerant). 후속 작업으로 미루는 것도 OK.
negative-path 커버리지가 부족. 추가하면 좋을 케이스:
line_start > totalpanic 회피 검증 (위 fetch.rs:230 버그가 수정되면 invalid_input 또는 빈 text 응답을 검증)RawAsset.media_type을 fixture 로 wire 하는 helper 가 필요.fetch_chunk_with_contextdoc 경계 clamp — 현재 테스트는total <= 4로만 검증. target 이 첫/마지막 chunk 일 때 한 쪽만 채워지는 케이스가 spec §Mode 동작 chunk mode #2 ("doc 경계 넘기지 않음 (clamp)") 와 직접 대응.4개 모두 추가가 부담되면 적어도 #1 + #3 은 이번 PR 안에 들어오는 게 좋음.
@@ -1115,0 +1206,4 @@if let Some(c) = &r.chunk {println!("\n=== target ===");let heading = c.heading_path.last().map(|s| s.as_str()).unwrap_or("");println!("[{} § {}]\n{}\n", c.chunk_id.0, heading, c.text);nit: target chunk 출력 시
\n=== target ===가 항상 출력됨 (chunk 가 None 인 경우는 사실상 없으므로 OK), 그러나=== before ===/=== after ===는 비어있을 때 생략. 일관성 위해 target 도 헤더 없이 raw 출력하거나, 모두 헤더 출력하는 게 사용자 mental model 에 더 쉬움. 또[chunk_id § heading]\n<text>\n끝의 trailing\n+ 그 다음 section 의 leading\n이 합쳐져 빈 줄 2개 — 좀 빽빽하지 않게 보이긴 하지만 의도적으로 빈 줄 2개인지 review 필요.또
c.chunk_id.0가 64-char blake3 prefix 라 한 줄이 너무 길어짐 — agent 입장에선 OK 지만 CLI 사람 사용자에는 처음 12자 정도만 ���여주는 truncation 도 고려.@@ -0,0 +53,4 @@"unknown kind '{other}'; expected chunk|doc|span"));}};nit: chunk mode 일 때
max_tokens/line_start/line_end, doc/span 일 때context처럼 해당 mode 와 무관한 필드 가 입력에 있어도 silently 무시됨. agent 가 잘못된 조합 (kind=doc+context=2) 을 보내도 invalid_input 안 뜨고 context 만 무시. spec §Error codes 의invalid_input는 "필수 필드 누락" 만 다루지만, 잘못 사용한 필드를 알려주는 친절도가 좋음.선택 사항 — strict 한 입력 검증 vs lenient API 정책 결정 필요.
주석에서 "chunks 가 explicit ordinal column 이 없어서 (created_at, chunk_id) 로 정렬" 이라 했는데,
created_at가 같은 transaction 안 모든 chunk 에 같은 timestamp 를 갖는다면 chunk_id (blake3 hash) 정렬은 문서 내 위치 와 무관 — 그냥 hex lexicographic. 이게 chunker 가 emit 한 "insertion order" 와 일치한다는 보장이 어디에 있는지 명시되지 않음.realistic 시나리오: doc 의 chunk 1, 2, 3 이 hash 의 lexicographic 순서로는 2, 3, 1 이 될 수 있음 →
--context로 "앞뒤 chunk" 가 의미상 앞뒤가 아니게 됨.진짜 fix 는
chunks.ordinalcolumn 추가 (또는chunks.source_span.start_byte같은 doc 내 offset 정렬) 이지만 spec/migration 영향이 큼. 최소한 후속 task 로 트래킹하고, 주석에 "⚠ chunk_id 가 hash 라서 doc 내 위치를 보장 안 함, ordinal 도입 전 임시 동작" 이라고 한 줄 추가 권장.@@ -72,0 +83,4 @@- `chunk` mode: `context: N` returns ordinal-adjacent chunks before/after for surrounding paragraphs.- `doc` mode: full normalized markdown. `max_tokens` (chars/4) caps the response — `truncated: true` when applied.- `span` mode: 1-based inclusive line range. PDF / audio docs reject as `error.v1.code = span_not_supported` (use `chunk` mode instead — PDF chunks are page-aligned).nit: span mode 거절 대상이 "PDF / audio" 라 명시 — 하지만 design doc §Goal 은 "PDF / image" 라 표현. SKILL.md 와 spec §Goal 사이 표현 불일치. 현재 코드는 PDF + Audio 만 거절 (Image OCR 은 통과). 둘 중 어느 쪽이 의도인지 결정 후 spec/skill/code 셋 다 일치시키는 게 좋음 — 위의 fetch.rs:199 코멘트 참조.
회차 2 — round-1 7개 fix 는 코드 차원에선 정확하게 들어갔다 (panic-fix 의 모든 FetchResult 필드 보존, image OCR span 허용 branch 미발동,
truncatedbudget-only 의미 일관,saturating_add× 2 적용, 신규 테스트 2종 통과). 다만 panic-fix 의 부수효과로 wire schema 가 깨진 새 이슈가 발견됐다 —line_start = 1일 때effective_end: Some(0)이 직렬화되는데fetch_result.schema.json의"effective_end.minimum": 1이 이를 reject 하고, description 도 line-clamp / out-of-range sentinel 의미가 빠진 채로 남아 있다. 추가로list_chunk_ids_for_doc경고문의 "good enough for sequentially chunked markdown" 표현이 바로 위 "hash sort dominates" 와 약간 모순으로 읽히는 점,fetch_chunk_context_at_first_chunk_clamps_lower_bound테스트가 상한 (<= 4) 만 검사하고 정작 "lower bound clamp" 의 핵심인context_before.is_empty()를 안 잡는 점은 nit. deferred 3개 (table separator strict CommonMark / MCP per-mode strict validation / CLI chunk_id truncation) 는 모두 의미적 정확성과 무관해서 follow-up 으로 미뤄도 합리적.@@ -0,0 +196,4 @@)? {if matches!(asset.media_type,kebab_core::MediaType::Pdf | kebab_core::MediaType::Audio(_)회차 2 — rejection branch 가
Pdf | Audio(_)로만 한정되어 image OCR span fetch 는 normal path 를 그대로 탄다 (회차 1 에서 요구한 "image span 허용" 정책과 일치). spec / SKILL / README / 코드 모두 "PDF / audio" 로 통일되어 round-1 mismatch 해소.@@ -0,0 +255,4 @@line_end: Some(line_end),// saturating_sub: when line_start = 1 we end at 0, signaling// "no lines fetched" without underflowing u32.effective_end: Some(line_start.saturating_sub(1)),회차 2 —
effective_end: Some(line_start.saturating_sub(1))의 sentinel 의미는 좋지만line_start = 1일 때Some(0)가 되고,fetch_result.schema.json의"effective_end.minimum": 1과 충돌한다 (위 schema 댓글 참조). caller 가 "no lines fetched" 를 detect 하기엔text.is_empty()만으로도 충분하니, schema 충돌 해소 차원에서effective_end: None으로 보내는 것도 고려해볼 것.@@ -0,0 +269,4 @@// budget-driven truncation only. Line-range clamp (line_end >// total) is signaled via `effective_end < line_end`, not via// `truncated`.let mut truncated = false;회차 2 — 좋은 fix.
truncated가 budget-only flag 라는 정의가 코드 + 주석 + 테스트 (fetch_span_line_start_beyond_total_returns_empty_text의!result.truncated단언) 모두에서 일관되게 잡혔다. line clamp 는effective_end < line_end로만 신호하는 것이 contract 로 명확해짐.@@ -0,0 +323,4 @@let n = n as usize;let lo = target_idx.saturating_sub(n);let hi = target_idx.saturating_add(n)회차 2 —
saturating_add× 2 +.min(chunks.len())조합으로 u32::MAX 같은 edge 에서도 panic 안 나도록 잘 막았다. (target_idx + n) 와 (+1) 두 단계 모두 saturate 시켜서 32-bit usize 환경까지 커버.@@ -0,0 +315,4 @@assert!(result.context_before.len() + result.context_after.len() <= 4,"doc boundary should clamp ±N to fit chunk count");회차 2 — nit. 테스트 이름은
at_first_chunk_clamps_lower_bound인데 실제 단언은before + after <= 4(상한만 검사). 첫 청크가 target 일 때context_before.is_empty()와context_after.len() <= 2를 따로 잡아주면 "clamp lower bound" 의도가 명확해짐. 또"First"검색 hit 가 항상 첫 청크라는 보장도 없으므로chunks[0].chunk_id같은 식으로 명시적으로 첫 청크를 잡으면 더 robust. 현재 테스트는 "<= 4만 통과하면 OK" 라 회귀 잡기엔 약함.@@ -378,0 +386,4 @@/// all chunks share `created_at` to the millisecond, so the/// secondary `chunk_id` sort dominates and the "neighbors"/// returned here may not be document-adjacent.///회차 2 — nit. 위에서는 "Within one ingest transaction all chunks share
created_atto the millisecond" 라 hash sort 가 dominant 라고 명시했는데, 아래current behavior is good enough for sequentially chunked markdown where created_at uniqueness varies부분이 약간 모순적으로 읽힌다 (created_at이 같은 ms 안에서는 markdown 도 PDF 도 동일하게 hash sort 가 지배). 메시지를"good enough for short docs where chunk_id hash collisions with doc-position happen to align — large markdown / PDF can re-order"정도로 다듬으면 의도가 더 분명해짐.@@ -0,0 +18,4 @@"text": { "type": "string", "description": "kind=doc/span: markdown text (truncated if budget tripped)" },"line_start": { "type": ["integer", "null"], "minimum": 1, "description": "kind=span: requested start line (1-based)" },"line_end": { "type": ["integer", "null"], "minimum": 1, "description": "kind=span: requested end line (1-based, inclusive)" },"effective_end": { "type": ["integer", "null"], "minimum": 1, "description": "kind=span: actual emitted end line after budget truncation" },회차 2 — round-1 panic-fix 부수효과로 wire schema 가 깨졌다. 새 early-return 은
line_start = 1일 때effective_end = Some(0)을 직렬화하는데, schema 의"minimum": 1이 이를 거절. 또 description 도"after budget truncation"만 언급해서 line-clamp / out-of-range sentinel (effective_end < line_start) 의미가 빠짐. 둘 중 하나로 정리: (a)"minimum": 0+ description 갱신 (line-clamp + out-of-range sentinel 의미 추가), 또는 (b) out-of-range 시effective_end: None직렬화 (이미 nullable). (b) 가 schema 표면 유지 + 의미도 자연스러움 ("no lines fetched" → 값 없음). MCP / 외부 통합이 schema 를 strict validate 하면 무조건 reject 되니 머지 전에 정리 권장.회차 3 — R2 지적 3건 모두 깔끔히 반영됨. 스키마
effective_end.minimum: 0완화는 fetch.rs:258 의Some(line_start.saturating_sub(1))방출 (line_start=1 out-of-range) 과 일치하고 description 이 line-clamp / budget-trim / empty-text sentinel 세 케이스를 명시. 첫-청크 boundary 테스트는 3-chunk + N=2 → 이웃 ≤ 2 로 산술적으로 타이트하면서도 hash-order 한계를 V007 ordinal 마이그레이션 follow-up 으로 정직하게 명시. store 코멘트 모순 해소 완료. 새로 발견된 이슈 없음.@@ -0,0 +323,4 @@// follow-up: V007 chunks.ordinal migration.let total = result.context_before.len() + result.context_after.len();assert!(total <= 2,타이트닝 적절. 3-chunk doc + N=2 → 이웃 ≤ 2 는 산술적으로 정확하고 (target 제외), 코멘트가 "왜 strict first-chunk-empty 못 박는지" 를 hash-order vs ordinal 차이로 정직하게 설명. 과도하지 않고 V007 ordinal 마이그레이션 이후 강화 여지 남김.
@@ -378,0 +393,4 @@/// they may or may not align with document position depending on/// whether `chunk_id` hash order happens to match insertion order/// for that particular doc. Large markdown / PDF (page-aligned/// chunks) likely re-orders. See `tasks/HOTFIXES.md` if escalated.모순 제거 깔끔. "hash sort dominates" 문단과 일치하게 "best-effort, may or may not align" 으로 정리됨 — 위 문단이 "chunk_id sort 가 지배한다" 고 단정해놓고 아래에서 "sequentially chunked markdown 에서는 괜찮다" 고 했던 R2 지적사항이 해소.
@@ -0,0 +18,4 @@"text": { "type": "string", "description": "kind=doc/span: markdown text (truncated if budget tripped)" },"line_start": { "type": ["integer", "null"], "minimum": 1, "description": "kind=span: requested start line (1-based)" },"line_end": { "type": ["integer", "null"], "minimum": 1, "description": "kind=span: requested end line (1-based, inclusive)" },"effective_end": { "type": ["integer", "null"], "minimum": 0, "description": "kind=span: actual end line of emitted text (1-based, inclusive). Equals `line_end` on full slice; less than `line_end` when (a) requested range exceeded total lines (line clamp) or (b) `--max-tokens` budget trimmed the tail. Special case: `line_start - 1` (which is 0 when line_start=1) signals the entire requested range was beyond doc end — returned `text` is empty." },스키마-구현 일치 깔끔.
minimum: 0이Some(line_start.saturating_sub(1))(fetch.rs:258, line_start=1 out-of-range case) 을 정확히 수용하고, description 이 (a) line clamp / (b) budget trim / (c) empty-text sentinel 세 케이스를 모두 명시해서 외부 통합자가 분기 규칙을 코드 보지 않고도 알 수 있음.