Commit Graph

114 Commits

Author SHA1 Message Date
2871da14f4 feat(parse-pdf): #232 스캔 PDF 를 페이지 렌더링으로 OCR
`extract_dctdecode_page_image` 는 페이지의 image XObject 중 `/Filter` 가
정확히 DCTDecode 인 것 하나만 받는다. 실제 스캔본에서 흔한 CCITTFaxDecode·
JBIG2Decode·FlateDecode·JPXDecode, `[FlateDecode, DCTDecode]` 같은 체인,
Internet Archive 계열의 "배경 + /ImageMask" 분리 구조가 전부 걸러진다.

텍스트 게이트는 정상 동작했다. `needs_ocr` 판정을 통과했다는 건 "이 페이지는
스캔본이라 OCR 이 필요하다" 고 올바르게 본 것이다. 판정은 맞았고 래스터를 못
꺼냈을 뿐인데, 결과가 조용한 내용 손실이었다 — 색인은 성공으로 끝나고,
검색이 안 되는 시점에야 알게 되며, 그때 원인이 PDF 인코더라는 걸 역추적할
방법이 없다.

페이지를 렌더링한다 (`page_render::PageRenderer`, pdfium). 지원할 필터도,
고를 XObject 도 없고, 벡터와 이미지가 섞인 페이지도 리더가 보는 대로 나온다.
이슈가 지적한 "image XObject 선택이 비결정적" 문제도 이 경로에서는 성립하지
않는다.

이슈는 교체를 권했지만 렌더러 우선 + DCTDecode 폴백으로 갔다. 배포 형태
때문이다 — pdfium 은 공유 라이브러리로만 배포되고 정적 빌드가 없어서,
링크하면 CLAUDE.md 가 규정한 단일 바이너리가 깨진다. 사용자와 상의해 정했다.

  - 런타임 바인딩. 있으면 전 인코딩 커버, 없으면 오늘 동작 + 왜 건너뛰었는지.
  - `[ingest.pdf.ocr] render_library` 로 경로 지정, 비우면 로더 경로 탐색.
  - `kebab doctor` 의 `pdf_render` 가 어느 쪽인지 보고.
  - 바이너리 392.9 → 399.3 MB (+6.4 MB 글루). ldd 에 pdfium 없음.

조용한 손실을 시끄럽게 (이슈 부수 제안 2·3):

`failure_reason` 이 CLI 에서 `..` 로 버려지고 있었다. wire 이벤트는 원인을
구분해 싣는데 사람이 보는 출력이 "no DCTDecode or engine fail" 로 뭉갰다.
이제 no_renderer / render_error / ocr_error 를 구분해 찍는다.
`IngestReport.ocr_skipped_pages` 를 추가하고(additive) 사람용 요약에도
`ocr-skipped N` 으로 낸다 — stderr 한 줄로 흘리면 대량 ingest 에서 지나간다.

parser_version cascade: pdf-text-v1 → pdf-text-v2. 안 올리면 이미 색인된
스캔본에 적용되지 않는다 (파일이 안 바뀌었으니 해시가 같고 Unchanged 로
건너뛴다). 사용자가 --force-reingest 를 떠올려야만 고쳐지는 수정은 고쳐진 게
아니다. 스냅샷 둘이 따라 움직였고 바뀐 것이 파생 식별자뿐임을 확인했다 —
본문 텍스트·inlines·source_span·metadata 는 동일.

구현 중 발견: pdfium 은 동시 사용이 안전하지 않다. 테스트를 병렬로 돌리자
`double free or corruption` 으로 프로세스가 죽었고, `thread_safe` 기능만으로는
부족했다. ingest 는 PDF 를 하나씩 처리하니 오늘은 문제가 없지만 `Arc` 는
공유해도 된다고 광고하는 타입이라, `PageRenderer` 안에 뮤텍스를 두고
`RenderedPdf` 가 문서 수명 동안 잡게 했다 (필드 선언 순서가 load-bearing —
doc 이 guard 보다 먼저 드롭돼야 한다). 지금 비용 0, 병렬화되는 날 메모리
손상 대신 대기가 된다. `set_target_width` 만 주면 긴 스캔에서 pdfium 이 C++
length_error 로 프로세스를 죽여서(exceptions 비활성 빌드라 Err 로 못 받는다)
양변을 set_maximum_* 으로 묶었다. 바인딩도 run 당 1회여야 한다.

실측 (govdocs1-000157-ccitt.pdf, 22쪽 중 1쪽이 CCITT 스캔, gemma3:4b):

                  렌더러 없음                        렌더러 있음
  OCR        ⊘ 건너뜀 — 인코딩을 읽을 수 없다    ✓ 101 chars, 6489ms
  chunk                35                              36
  글자 수            35,994                          36,095
  요약           ocr-skipped 1                        (없음)

렌더링 자체는 여섯 필터 계열 전부 확인 — CCITT / JBIG2 / Flate / JPX /
혼합(DCT+CCITT+JBIG2+Flate) / DCT, 300dpi 페이지당 40~145 ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-17 01:53:46 +09:00
74e28293ba refactor(app): #231 derivation cache 배칭 + 계측 — 가설은 재현 안 됨
#231 은 "캐시가 히트하는데도 우회하고 전량 재임베딩하는 편이 더 빠르다" 고
보고하면서 본문에 "⚠️ 정량 실측 보완 필요 … 수치 미기록" 이라고 표를 비워
뒀다. 그 표를 채우는 것이 이 커밋의 핵심이다.

측정 (나무위키 792문서 / 16,379 chunk, ollama arctic-embed2 1024-dim,
--force-reingest 로 전 문서 full re-process):

  캐시 히트 경로              139.1초   236 chunk/초
  캐시 우회(전량 재임베딩)   1179.6초    28 chunk/초

캐시 히트가 8.5배 빠르다. 가설은 이 환경에서 재현되지 않는다.

새로 넣은 계측으로 139초의 내역을 보면 더 분명하다 (히트 32,758 / 미스 0):

  Lance upsert + 레코드 구성   74.6초  53%
  SQLite 문서·청크 기록        56.7초  41%
  chunk                         4.4초   3%
  캐시 경로 전체(조회+삽입+touch) 0.6초   0.4%

이슈가 지목한 여섯 원인이 전부 합쳐 run 의 0.4% 다.

다만 "보고가 틀렸다" 로 읽으면 안 된다. 보고 이후 #229#230 이 머지됐고,
#231 본문 스스로 #229 를 "같은 Mutex<Connection> 을 공유하므로 상호 증폭"
이라고 적었다. 원 보고 환경에서는 캐시 조회 52,000회가 그 뮤텍스를 잡았다
놓는데 같은 뮤텍스 위에서 chunk 삭제가 FTS5 전체 스캔을 돌리고 있었다.
"#229 를 먼저 고치면 체감이 줄어든다" 도 이슈의 예측이다. 이 머신이 61 GB
RAM 이라 DB 가 통째로 페이지 캐시에 올라간다는 점도 함께 적어 둔다.

반영한 것:

제안 1·2·4 는 실측과 무관하게 왕복이 줄 뿐 잃는 게 없어 넣었다. 다만 A/B
벽시계는 139.1초 → 139.3초로 측정 오차 안이다. 이 코퍼스에서는 체감이 없다.

  - derivation_cache_get_many — `WHERE cache_key IN (…)` 배치 조회.
    문서 하나가 평균 21 chunk 이라 왕복이 21회에서 1회가 된다.
  - derivation_cache_put_many — 미스 벡터를 한 트랜잭션에. 기존 단건 put 은
    명시 트랜잭션 밖이라 행마다 암묵 커밋이었다.
  - prepare_cached — get/put/touch 셋 다. query_row 는 호출마다 SQL 을
    다시 파싱한다.

제안 5(계측 노출)가 실질 산출물이다. `asset_timings` 에 cache_hit /
cache_miss / cache_ms 를 additive 로 실었다. 이전에는 hit/miss 가
tracing::info! 로 stderr 에만 나가 run 이 끝나면 사라졌고, "내 코퍼스에서
캐시가 이득인가" 를 확인할 방법이 없었다. cache_ms 에는 touch 도 포함한다 —
이슈의 가장 날카로운 지적이 "읽기 전용이어야 할 히트 경로가 쓰기를 만든다"
인데, touch 를 빼고 재는 지표로는 그 주장을 검증할 수 없다.

네 out-param 은 CacheStats 구조체로 묶었다. 함께 읽히고 함께 보고되는
값들이고, 셋만 갱신하고 하나를 빠뜨리면 캐시가 공짜인 것처럼 보고된다.

반영하지 않은 것:

  - 제안 3(touch 를 히트 경로에서 분리). 캐시 경로 전체가 0.6초라 touch 만
    떼어낼 이유가 없고, 권한 (c)안은 LRU 를 age 기반 축출로 바꾸는 의미
    변경이다. 근거 없이 할 변경이 아니다.
  - 제안 6(캐시 우회 스위치). 이슈 스스로 "1~4 로 해결되면 불필요 —
    플래그부터 만들지 말 것" 이라고 적었다.
  - 원인 6(4 KB BLOB overflow). page_size 변경은 기존 DB 에서 VACUUM 을
    요구하는데 kebab 은 VACUUM 을 실행하지 않는다. 0.4% 에 낼 비용이 아니다.

곁다리로 #228 에서 내가 넣은 flaky test 를 고쳤다.
`ingest_log_records_the_deleted_file_sweep` 이 두 run 의 로그 중 뒤엣것을
파일명 정렬로 골랐는데, run id 가 `<초 단위 타임스탬프>-<난수 hex>` 라 같은
초에 끝난 두 run 은 난수 쪽으로 정렬된다. 이번 전체 테스트에서 우연히 터져
잡았다. 첫 run 의 로그 집합을 기록해 두고 차집합으로 고르도록 바꿨다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-16 23:39:07 +09:00
d794c7a55f chore: PR #236 회차 3 리뷰 반영 — 빈 스캔 취소 오보고 + 테스트 탐지력
3회차 리뷰가 취소 처리에 CRITICAL/HIGH 가 없음을 확인하고(특히 break 후에도
`flush_vector_deletes` 가 돌아 고아 벡터가 안 생기는 것, `examined` 가 네
경계에서 모두 맞는 것) 머지 가능으로 결론냈다. 남은 지적 다섯을 반영한다.

1) 빈 스캔 + sweep 취소가 Aborted 가 아니라 Completed 로 보고됐다 (MEDIUM)

   `was_cancelled` 는 asset 루프 **본문**에서만 세팅되는데, 스캔이 0건이면
   본문이 한 번도 안 돈다. 하필 그게 sweep 이 가장 커지는 경우다 — 스캔이
   0건이면 저장된 모든 경로가 후보이므로, "색인된 디렉토리를 통째로 지우고
   재색인 → 긴 sweep → Ctrl-C" 가 정확히 이 구멍에 들어간다. 사용자는
   취소했는데 "완료"를 본다.

   플래그로 시드한다. `cancelling_a_sweep_with_nothing_left_to_scan_still_
   reports_aborted` 로 고정했는데, 처음 쓴 버전은 워크스페이스 상위만
   지워서 픽스처가 하위 디렉토리에 남았고 asset 루프가 한 번 돌아 **되돌려도
   통과했다**. 재귀 삭제로 고치고, 전제(`ScanCompleted { total: 0 }`)를
   어서션으로 박았다. 시드를 되돌리면 실패하는 것을 확인했다.

2) CLI 테스트의 position 어서션에 탐지력이 없었다 (LOW)

   두 시나리오를 따로 돌려서 두 번째에 `SweepProgress` 가 없었고, position 은
   `SweepStarted` 의 `set_position(0)` 이후 계속 0 이었다. 즉
   `dress_bar_for_assets` 안의 `set_position(0)` 을 지워도 통과했다.
   하나로 합쳐 sweep 이 position 을 3 까지 올린 뒤 복구를 본다.

3) 그 테스트 주석이 실제 범위보다 넓게 주장했다 (LOW)

   "하트비트 키까지 고정한다" 고 적었는데 실제로는 length/position 만 본다.
   indicatif 가 스타일을 되읽을 방법을 주지 않으므로, 그 절반은
   `dress_bar_for_assets` 가 양쪽 phase 의 유일한 옷 입히는 자리라는 사실에
   기댄다 — 주석을 그렇게 고쳐 적었다.

4) 취소 계약 독 주석이 stale 했다 (LOW)

   `ingest_with_config` 의 §10 계약에 "in-flight asset 이 끝나고 이후는
   스킵" 만 있고 sweep 이 이제 취소를 본다는 사실이 없었다.

5) DOGFOOD §1.8 이 자기 모순이었다 (LOW)

   `sweep_completed.checked == total` 을 단정하고 바로 다음 줄에서 취소 시엔
   다르다고 했다. 앞줄에 "취소 없이 완주하면" 을 붙였다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-16 22:02:02 +09:00
6f9011eb1d chore: PR #236 회차 2 리뷰 반영 — sweep 취소 협조 + 바 복구 회귀 가드
2회차 리뷰가 1회차 지적 6건 모두 실질 해결됐음을 확인하고 머지 가능으로
결론냈다. 새로 나온 것 중 값이 있는 것을 반영한다.

1) sweep 이 취소 플래그를 보지 않았다 (MEDIUM)

   CLI 의 첫 Ctrl-C 는 "aborting after current asset" 을 찍고 AtomicBool 을
   세운다. 그런데 `sweep_deleted_files` 는 그걸 인자로 받지도 검사하지도
   않았다. 즉 긴 sweep 중에는 그 안내가 사실이 아니었고, 사용자에게 남은
   수단은 두 번째 Ctrl-C 뿐인데 그건 `exit(130)` 이라 버퍼에 쌓인 벡터
   삭제(최대 5,000)가 고아로 남는다.

   이슈 #228 자체가 "sweep 중 Ctrl-C 를 세 번 눌러 죽였다" 는 보고다.
   눌렀을 때의 동작을 그대로 둔 채 표시만 고치는 건 절반만 고친 것이다.

   루프 상단에서 검사하고 break 한다. `sweep_completed.checked` 는 예고한
   `total` 이 아니라 실제 검사한 수로 나간다 — total 을 그대로 쓰면 하지
   않은 일을 했다고 보고하는 셈이다. `a_cancelled_sweep_stops_and_reports_
   only_what_it_examined` 로 고정.

2) 1회차 HIGH 가 테스트로 고정되지 않았다 (MEDIUM)

   바 상태 오염은 "phase 를 하나 더 추가하면 또 밟는" 유형인데 회귀 가드가
   없었다. 실제로 한 번 배포될 뻔했다. `sweep_hands_the_bar_back_to_the_
   asset_loop` 이 sweep 중에는 후보 수를, 끝난 뒤에는 asset 수를 세는지
   본다. `ProgressMode::Human { tty: false, quiet: true }` 면 바가 hidden
   draw target 으로 살아 있어 터미널 없이도 상태를 볼 수 있다.

3) 잔가지 (LOW)

   - `SweepProgress` 독 주석이 `removed` 를 두 경우로만 설명했다. 이번
     PR 이 만든 세 번째 경우(purge 실패)가 빠져 있었다. 스키마 쪽은 이미
     맞게 적혀 있었다.
   - `sweep_deleted_files` 의 기존 독 주석이 "purge 실패가 per-file 레벨
     에서 error 로 집계된다" 고 적었는데 사실이 아니다. 어떤 카운터에도
     안 들어가고 `IngestReport.errors` 에도 반영되지 않는다. 실패 경로를
     손댄 김에 고쳤다.
   - HOTFIXES 와 DOGFOOD 가 `purge_failed` 와 취소 동작을 언급하지 않았다.
     HOTFIXES 가 live SoT 인데 실제 표면보다 좁게 적힌 상태였다.

미반영: `SweepCompleted` 가 TTY 에서 `bar.println` 대신 stderr 로 직접
쓰는 것(기존 `AssetTimings`/`PdfOcr*` 과 같은 패턴이라 신규 회귀가 아니고,
바꾸려면 셋을 같이 옮겨야 한다). `ScanCompleted` 에서 스타일을 길이보다
먼저 세팅하는 순서가 이론상 `0/u64::MAX` 프레임을 허용한다는 지적 — 창이
마이크로초이고 `SweepCompleted` 쪽에서는 새 순서가 더 낫다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-16 21:40:21 +09:00
2c2cbd2b94 chore: PR #236 회차 1 리뷰 반영 — sweep 뒤 진행바 복구
리뷰 두 건에서 나온 지적을 반영한다.

1) sweep 이 끝나도 asset 진행바가 복구되지 않았다 (HIGH)

   sweep 은 asset 진행바를 빌려 쓰면서 자기 라벨과 자기(더 작은) 총계를
   씌운다. 그런데 `AssetStarted` 는 위치와 메시지만 세팅하고 길이·스타일은
   건드리지 않는다. 그래서 sweep 이 한 번 돌면 그 뒤 색인 구간 전체가
   `sweep [====] 4213/21` 로 그려졌다 — 라벨도 분모도 틀린다.

   더 나쁜 건 스타일 교체가 v0.26.1 의 커스텀 키 `{asset_elapsed}` 를 같이
   날린다는 점이다. 느린 asset 에서 `(Ns)` 가 도는 게 "멈춘 게 아님"의 유일한
   신호인데 sweep 이 그걸 없앤다. 이 PR 이 sweep 구간에서 없앤 "hang 처럼
   보임" 을 asset 구간에 새로 만드는 셈이었다.

   바 세팅을 `dress_bar_for_assets` 로 빼고 `ScanCompleted` 와
   `SweepCompleted` 양쪽에서 부른다. 스타일을 길이보다 먼저 세팅하는데,
   indicatif 가 두 호출 사이에 다시 그릴 수 있어 과도기 프레임이 최소한
   올바른 라벨을 달게 하기 위해서다.

   TTY 전용이라 비-TTY 실측만 보고 있어서 놓쳤다. pty 로 재현해 고친 뒤
   `ingest [====] 16/17 doc9.md` 로 나오는 것을 확인했다.

2) docs/DOGFOOD.md §1.8 이 갱신되지 않았다 (MEDIUM)

   `--json` 이벤트 순서 목록에 sweep 3종이 빠져 있었다. verify 항목에
   sweep 분모·연속성과 "sweep 뒤 진행바가 asset 분모로 돌아오는가" 를 넣었다
   — 1번이 정확히 그 항목이 있었으면 잡혔을 결함이다.

3) ingest_progress.rs 의 ordering invariant 주석이 stale 했다 (MEDIUM)

   §2.4a 순서 블록에 sweep 이 없었다. 설계 문서 자체는 frozen baseline 이고
   HOTFIXES dated entry 가 있으니 규약상 문제없지만 코드 주석은 living 이다.

4) 잔가지 (LOW)

   - purge 실패가 "디스크에 남아 있어 그냥 뒀다" 와 구분되지 않았다. 둘 다
     `removed: false` 이고 ndjson 에는 아무것도 안 남아,
     `sweep_summary` 의 `checked - purged` 차이로도 못 가른다. 사후 기록이
     로그뿐이라는 게 이 PR 의 전제이므로 `purge_failed` 를 추가했다.
   - `SweepProgress` 가 purge 할 때만 바 메시지를 세팅하고 비우지 않아,
     마지막 purge 경로가 이후 후보를 훑는 내내 남았다. 안 지울 때는 비운다.
   - 주석과 스키마가 신규 이벤트를 `v0.33.0` 이라고 적었는데, CLAUDE.md 의
     bump 규칙상 이 변경은 patch 다 (additive-only wire + 관측성 개선, 새
     명령·플래그·config 없음, 검색·색인 결과 불변 — 선례가 asset_phase).
     `v0.32.1` 로 고쳤다.

미반영: `SweepCompleted` 가 TTY 에서 `bar.println` 대신 stderr 에 직접
쓰는 것 — 기존 `AssetTimings`/`PdfOcr*` 이 같은 패턴이라 신규 회귀가 아니고,
바꾸려면 그 셋을 같이 옮겨야 해서 별 건이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-16 21:18:15 +09:00
1626a40a28 feat(app): #228 삭제 sweep 을 진행바·ndjson 로그에 노출
`sweep_deleted_files` 는 walker 가 끝난 직후 asset 루프 전에 도는데, 이
구간이 관측 가능한 신호를 하나도 내지 않았다. 진행바는 walker 총계
(`0/12115`)를 표시한 채 멈춰 보이고 ndjson 로그는 0바이트로 남는다.
`tracing::info!` 은 나가지만 wire 이벤트가 아니라 사용자가 볼 산출물이
없다. 프로세스는 CPU 100% 로 정상 동작 중인데 밖에서는 hang 과 구별할 수
없고, 실제 도그푸딩에서 세 번 연속 Ctrl-C 로 죽였다.

`ingest_progress.v1` 에 세 이벤트를 추가한다 (additive — 기존 소비자는
모르는 kind 를 무시한다).

  sweep_started   { total }
  sweep_progress  { idx, total, path, removed }
  sweep_completed { checked, purged, ms }

`total` 은 `all_workspace_paths()` 에서 이번 스캔이 덮은 경로를 뺀 값이라
루프 진입 전에 확정 분모가 나온다 (이슈 제안 1). `removed` 는 "정말 없어서
문서를 지웠다" 와 "아직 디스크에 있어 그대로 뒀다" 를 가른다 — 수천 건을
훑고 하나도 안 지우는 sweep 이 있으므로, 지울 때만 움직이는 진행바는 바로
그 경우에 다시 멈춰 보인다.

`purged` 를 두 이벤트에서 다른 타입으로 쓰지 않으려고 sweep_progress 쪽은
`removed`(bool), sweep_completed 쪽은 `purged`(정수) 로 이름을 나눴다.
같은 wire 키가 두 타입을 갖는 건 소비자 입장에서 함정이다.

ndjson 로그에는 `purge { ts, doc_path }` 와 `sweep_summary { ts, checked,
purged, ms }` 를 추가한다 (이슈 제안 2 가 요청한 형태). 로그가 유일한 사후
기록이다 — tracing 은 stderr 로 흘러가고 남지 않는다.

CLI 는 sweep 을 asset 진행바와 별 phase 로 그린다. 후보 12k 를 훑는 일과
asset 12k 를 색인하는 일은 분모가 다른 별개의 작업이라 카운터를 공유하면
두 번째 구간이 처음부터 다시 시작하는 것처럼 보인다. 비-TTY 는 실제로
지운 것만 줄로 찍는다 — 검사한 후보마다 한 줄이면 그대로 둔 경로들이
run 의 진짜 출력을 덮는다.

실측 (문서 30건 색인 → 21건 삭제 → 재색인):

  ingest: sweeping 21 deleted-file candidates…
    purged doc11.md
    … (21줄)
  ingest: sweep complete (checked=21 purged=21 in 134ms)

`--json` 은 세 이벤트를 ingest_progress.v1 로 내보내고, ndjson 로그에는
purge 21줄 + sweep_summary 1줄이 남는다.

이슈가 참고로 적은 `reset --orphans-only` 는 그대로 뒀다. reset 에는 진행
채널 자체가 없어 sweep 하나를 위해 배선을 새로 깔아야 하는데, #229#230 이 머지된 지금 이 경로의 문서당 비용이 약 800배 떨어져 "몇 시간
무표시" 상황이 애초에 안 나온다.

곁다리 — clippy 게이트가 붉었다:

`cargo clippy --workspace --all-targets -- -D warnings` 가 main 에서
실패하고 있었다. 툴체인이 올라가면서 새 lint 둘(`question_mark`,
`manual_assert_eq`)이 기존 코드에 걸린 것이고 각각 한 줄이다. 방치하면 안
되는 이유를 이번에 겪었다 — `kebab-parse-code` 가 먼저 실패해 뒤 크레이트가
아예 컴파일되지 않았고, 그 그늘에 PR #235 에서 내가 넣은
`unnested_or_patterns` 위반이 숨어 있었다. 셋 다 여기서 고친다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-16 20:57:34 +09:00
8537e5765e chore: PR #235 회차 3 리뷰 반영 — 판정 보류 분기 + vector_store env
3회차 리뷰가 2회차 지적 8건이 동작 수준에서 해결됐음을 확인하고 머지
가능으로 결론냈다. 남은 LOW 중 값이 있는 셋을 반영한다.

1) 마이그레이션 이력을 못 읽었을 때 파괴적 조언으로 떨어졌다

   `Some(Some((None, Ok(어긋남))))` — 버전은 모르는데 표본은 읽힌 경우 —
   가 `ok: false` + "`kebab reset`" 분기로 갔다. 버전을 모르는 것은 KB 를
   날리라고 말할 근거가 못 된다. 판정을 "V016 이상임이 확인된 스토어"로
   한정하고, 이력을 못 읽으면 `ok: true` + "판정 보류" 로 둔다. 실제
   도달 경로는 사실상 없지만(chunks_fts 가 있으면 이력도 있다) 분기의
   기본값이 파괴적인 쪽인 게 문제였다.

2) "점검하지 못했다" hint 가 가장 흔한 원인을 안 적었다

   2회차에서 고친 HIGH — 이전 버전 doctor 가 남긴 0바이트 kebab.sqlite —
   가 정확히 이 분기로 온다. 테이블이 없어 읽기에 실패한다. "아직 색인
   전이거나" 를 앞에 넣었다.

3) vector_store 블록이 아직 env 를 안 얹고 있었다

   2회차에서 fts_shadow 에 대해 고친 것과 같은 결함이 #234 에서 들어온
   바로 아래 블록에 그대로 남아 있었다. 정보성 체크라 종료 코드에는
   영향이 없지만 detail 이 엉뚱한 디렉토리를 가리킨다. 같이 맞췄다.

미반영: shadow 의 chunk_id 가 NULL 이면 분자에서 빠지는 과소 계수 —
트리거가 NOT NULL 컬럼을 미러링하므로 도달 불가이고 방향도 오탐이 아닌
과소 쪽이다. 새 테스트가 `KEBAB_STORAGE_DATA_DIR` 이 export 된 셸에서
실패하는 것은 점검이 제대로 동작하는 결과라, 원인을 빨리 찾도록 테스트
주석에 적어 두는 선에서 끝냈다.

실측 확인: KB 없음 / V015 / V016 / env override 네 경로 모두 의도한 문구가
나오고, KB 없는 경로는 파일을 남기지 않는다. env override 를 주면
fts_shadow 와 vector_store 가 같은 디렉토리를 본다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-16 20:17:15 +09:00
8dbfa89b57 chore: PR #235 회차 2 리뷰 반영 — doctor 점검의 오탐·부작용 제거
2회차 리뷰가 1회차 지적 6건은 반영됐다고 확인했고, 대신 **1회차 대응으로
새로 넣은 `fts_shadow` 점검 자체에** HIGH 1건 + MEDIUM 2건을 찾았다.

1) 진단 명령이 없던 스토어를 만들었다 (HIGH)

   `SqliteStore::open` 은 마이그레이션은 안 돌리지만 `Connection::open` 이
   파일을 만든다. KB 없는 머신에서 `kebab doctor` 한 번이
   `<data_dir>/kebab.sqlite` 를 남겼고, 그러면 이후 `open_existing` 이
   성공해 버려 `not_indexed` 로 갈렸어야 할 경로가 일반 오류로 바뀐다.
   `--readonly` 규약도 진단 명령이 깬다.

   `SQLITE_FILE` 을 공개하고 파일 존재를 먼저 확인한 뒤에만 연다.
   `doctor_does_not_create_a_store_where_none_exists` 로 고정.

2) V016 미적용 스토어를 고장 났다고 하고, 파괴적 조치를 권했다 (MEDIUM)

   doctor 는 마이그레이션을 돌리지 않으므로 V015 스토어를 그대로 읽는다.
   그런데 V009 백필이 rowid 를 명시하지 않아서, 그 시점 `chunks.rowid` 에
   구멍이 있던 스토어는 V015 에서 **이미 어긋나 있다**. 거기서는 삭제가
   `chunk_id` 기준이라 무해한데, 새 점검이 `ok: false` → exit 3 + "`kebab
   reset` 후 재색인" 을 냈다. 바이너리를 올리고 doctor 부터 돌리는 자연스러운
   순서에서 멀쩡한 KB 를 날리라고 안내한 셈이다.

   `migration_version()` 을 보고 V016 미만이면 `ok: true` + "마이그레이션
   후 점검된다"로 간다. 실제 해법이 그것이다 — V016 의 명시 rowid
   repopulate 가 이 어긋남을 고쳐 준다.
   `doctor_does_not_call_a_pre_v016_store_broken` 으로 고정.

3) env override 를 무시해 엉뚱한 DB 를 검사했다 (MEDIUM)

   바로 위 `data_dir_writable` 은 "Config::load 와 같은 precedence 유지"를
   이유로 env 를 다시 얹는데 이 블록은 안 했다. `KEBAB_STORAGE_DATA_DIR` 을
   쓰면 data_dir 은 A 로 보고하면서 인덱스는 B 를 검사한다. 같은 방식으로
   맞췄다.

4) 잔가지 (LOW)

   - `None` 분기가 "스토어 없음 / 열기 실패 / 읽기 실패"를 전부 "KB 없음"
     으로 뭉갰다. 조용한 실패를 드러내려는 점검이 자기 실패를 삼키면 안
     되므로 "점검하지 못했다" 를 따로 뒀다 (hint 가 있으므로 CLI 가 `!`
     로 찍는다).
   - "표본 400행" 이 chunk 400개 미만인 스토어에서 거짓이었다. ASC/DESC
     두 창이 겹치면 분자와 분모를 둘 다 두 번 셌다. UNION 으로 바꾸고
     실제 표본 수를 함께 돌려준다 — 반환형이 `(checked, misaligned)` 다.
   - `pub const SQLITE_FILE` 위에 "Kept private" 이라는 옛 독 주석이
     남아 있었다.
   - README 의 doctor 행에 `fts_shadow` 가 exit 3 을 낼 수 있다고 적었다.
     새 플래그도 config 키도 없지만 **doctor 가 실패하는 새 사유**는
     스크립트와 에이전트가 분기하는 사용자 표면이다.
   - `tasks/phase-2-lexical-search.md` 가 `kebab index --rebuild-fts` 를
     산출물로 나열하고 있었다. 배선된 적 없는 명령이고 이 PR 이 "CLI
     경로 없음"이라고 못박은 것과 어긋나서 정정했다.

실측 확인: env override 를 준 doctor 가 지정한 KB 를 검사하고("양끝 400행
표본"), V015 실제 KB(28,427 문서)는 "V016 적용 전 (현재 V015)" 로 나오며,
KB 없는 경로에서는 파일을 남기지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-16 20:00:22 +09:00
8dfeb007e0 chore(search): PR #219 회차 1 리뷰 반영 — 잔존 dead-cache 주석/help text 정리
리뷰어 지적 6건 + 전체-트리 grep 으로 추가 발견한 동일 부류 4건:
- search --trace help text(CLI main.rs + MCP search tool) "Bypasses cache"
  → "always a fresh retriever run" (없는 캐시 광고 제거)
- corpus_revision 의 실제 소비자는 incremental ingest 가 아니라 cursor
  pagination(stale_cursor) 임을 반영 — HANDOFF 노트 사실 정정 + ingest.rs
  bump 주석/warn 로그 + corpus_revision/search_lexical 테스트 주석 재서술
  (검증: cursor.rs + app.rs:327/333/498 가 유일 소비자, ingest 는 bump 만)
- bulk.rs "LRU cache amortize" → embedder cold-start 만 amortize
- app.rs lexical_index_version "search cache 무효화" 제거
- app-facade README 의 search cache bullet 을 "(제거됨 — 이력)" 표시

잔여 dead-cache 개념 참조 0건(active tree), clippy -D warnings 클린.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-26 17:25:03 +00:00
db9090c87b refactor(search): 죽은 search-cache + search --explain scaffold 제거
in-process LRU search cache 는 spine 재작성(#214)에서 이미 삭제됐는데
그 비계(scaffold)만 남아 있었다. 잔존물 제거:

- App::search/search_uncached 붕괴 (search() 는 1줄 위임자였음)
- search_uncached_with_config facade + 그 테스트 삭제
- 죽은 `search --no-cache` / `search --explain` CLI 플래그 제거
  (ask --explain 은 live — 건드리지 않음)
- 안 읽히던 RagCfg.explain_default config 필드 + fixture 제거
- 제거된 표면을 가리키던 stale 주석/문서(citation_helper/hybrid/
  search·app-facade README/DOGFOOD/HANDOFF) 정정

코어 검색 출력은 byte-identical (search_uncached 본문이 search() 로
verbatim 이동). `search_cache: false` wire capability 는 유지 — 제거 시
schema.v1 breaking bump 이라 손해. 적대적 검증 2렌즈(correctness-preserved
+ wire/config-safe) 통과, dead-symbol-complete 가 잡은 5건 주석/문서 정정 완료.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-26 17:12:55 +00:00
602d752d37 test(app): caption 캐시 b2 헬퍼 추출 + mock-LLM 결정적 게이트 (재실행=LLM 미호출) 2026-06-25 04:43:38 +00:00
68138049ad test(app): paddle 실엔진 이미지 OCR 캐시 #[ignore] 게이트 — 재인덱싱=히트 (b1 e2e) 2026-06-25 01:23:17 +00:00
f375b694b6 test(app): OCR 캐시 mock-engine 결정적 검증 — 재실행=엔진 미호출 + byte-identical + 버전 무효화 2026-06-25 01:13:02 +00:00
e64a56a321 feat(app): PDF 페이지 OCR 캐시 — PdfOcrOpts 캐시 핸들 (b3, parse-crate 의존 무변경) 2026-06-25 01:06:47 +00:00
ec2ee25a89 test(app): 캐시-히트 테스트에 단일-자산 격리 단언 추가 (report.new==1) — PR #216 회차 1 2026-06-24 23:53:38 +00:00
7310a8a30a test(app): code 재인덱싱 임베딩 캐시 히트 + byte-identical 검증 (AVX-gated) 2026-06-24 23:38:03 +00:00
2bbe2f8ace refactor(app): markdown을 extractor registry 경유로 통일 (extract stage 대칭화)
markdown ingest arm 이 그동안 유일하게 `App::extract_for` extractor
registry 를 우회하고 `kebab_parse_md::{parse_frontmatter, parse_blocks,
build_canonical_document}` free function 을 직접 호출했다 ("the single
biggest asymmetry"). 이를 image/pdf/code 와 동일하게 registry 경유로
통일.

- `kebab-parse-md` 에 `MarkdownExtractor` 신설 — 기존 free function 3종을
  동일 순서·동일 인자로 감싸 `bytes → CanonicalDocument` 생산만 담당.
  fm_span_end / count_lines_in / build_body_hints 헬퍼도 함께 이식.
- `App.extractors` registry 에 등록 (11 → 12 entry), markdown 이 `supports`
  로 발견되도록 첫 entry 로 배치.
- `ExtractContext` 에 `source_id` / `source_trust` 필드 추가 — markdown
  frontmatter 가 per-source trust 기본값을 override 하고 그 precedence 가
  `parse_frontmatter` *내부*에서 결정되므로 ctx 가 carry 해야 함. 다른
  extractor 는 None (post-extract 에서 source_id stamp 유지).
- 핸들러는 추출 stage 만 registry 로 이전 — version stamping / chunking /
  embedding / store 는 그대로. IngestItem.warnings 는 pdf/code 처럼
  `canonical.provenance` 의 Warning 이벤트에서 도출.

byte-identical 검증: parity gate-ingest (all-markdown 183 doc / 7676 chunk)
CHUNKS / SEARCH / ASK 모두 IDENTICAL. clippy 0, kebab-app + kebab-parse-md
test 전체 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-24 13:30:54 +00:00
d501ce2626 refactor(app): ingest API 6변종 → 2 (ingest + ingest_with_config{IngestOpts})
summary_only를 IngestOpts에 흡수, progress/cancellable/opts 변종 제거.
재인덱싱 게이트 CHUNKS/SEARCH/ASK byte-IDENTICAL, clippy --all-targets 0.
2026-06-24 12:36:20 +00:00
2dfbbe4f43 refactor(config): consumer들이 &Config 대신 타입 슬라이스 수령 (god-struct 결합 해소) 2026-06-24 11:55:10 +00:00
da323af87b refactor(config): OCR 중복 제거 — 공유 [ingest.ocr] 엔진 블록 + v4→v5 마이그레이션
Phase 2 Unit 1. OcrCfg(image) 13필드가 PdfOcrCfg(pdf) 와 전부 중복(image 고유
0, pdf 고유 4) + apply_env 에 KEBAB_IMAGE_OCR_*/KEBAB_PDF_OCR_* 27 arm 복제를
제거한다.

- 신규 SharedOcrEngineCfg(13 공유 필드, 전부 Option, default None) = [ingest.ocr].
  엔진 설정 단일 출처. image/pdf 블록은 on/off 토글 + override.
- load-time resolution(Config::resolve_ocr, from_file 호출): 공유 필드가 Some 이고
  미디어 블록이 그 키 미명시면 concrete OcrCfg/PdfOcrCfg 로 overlay(presence 는
  toml::Value 로 판정; 미디어 > 공유 > 내장 default). struct 필드는 그대로 두고
  엔진 필드에 #[serde(default)] 만 추가(slim 블록 파싱) → image(gemma4:e4b/1600)
  vs pdf(qwen2.5vl:3b/2048) 미디어별 기본값 보존.
- resolver Config::image_ocr()/pdf_ocr() 추가. consumer(kebab-parse-image,
  kebab-app build_*_ocr_engine·ingest gate·pdf_ocr_apply·ingest_config_signature)가
  전부 경유 → god-struct 직접 read 제거.
- apply_env: 27 arm → 공유 KEBAB_OCR_* 12 arm(image+pdf 동시) + pdf 고유 4 arm +
  미디어별 KEBAB_IMAGE_OCR_ENABLED/KEBAB_PDF_OCR_ENABLED.
- step_4_to_5: [ingest.image.ocr] 12 엔진 키를 [ingest.ocr] 로 move_table(enabled
  제외). pdf 블록 무손상(reconcile 이 채워 공유 overlay 오염 X). annotated_default
  도 동일 통합으로 v5 canonical 형상. CURRENT_SCHEMA_VERSION=5.
- v4→v5 round-trip 테스트(비-default image engine 보존 + pdf 오염 X + 멱등). effective
  OCR 바이트 동일 → ingest_config_signature 불변 → 강제 재색인 없음.

검증: clippy --workspace --all-targets 0 / kebab-config·kebab-parse-image·
kebab-parse-pdf·kebab-app 테스트 pass. surface: README [ingest.ocr] 절 + SMOKE
config 블록 + DOGFOOD env + HOTFIXES dated entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-24 11:03:39 +00:00
1d32aa645a refactor: multi-turn 세션 제거 (ask --session, chat_sessions/turns) + V015 drop migration 2026-06-24 09:57:12 +00:00
ed8ab7cdbe feat(chunk): pdf-page-v1.2 — PDF 페이지 oversize 분할 + 공유 oversize 모듈
md-heading-v2(PR #209)의 oversize 분할을 PDF 청커에도 적용. v1.1 의 chunk_page 는
문장/문단 경계로만 잘라서 경계 없는 거대 페이지(빽빽한 scanned page 한 줄 OCR)가
통째로 한 청크 → strict 임베더 실패. v1.2 는 2-tier: tier-1(문장/문단 greedy +
overlap) 후 segment 가 max_chunk_tokens 초과면 tier-2 가 공유 text_pieces 로
재분할 → 모든 PDF 청크 ≤ 예산.

- 신규 공유 모듈 crate::oversize (text_pieces/char_pieces/BYTES_PER_TOKEN) — md 와
  PDF 가 공유(단일 진실 공급원). md-heading-v2 는 호출만, 출력 byte-identical(md
  라벨·동작 불변, parity 테스트 전부 통과).
- PdfPageV1Chunker { max_chunk_tokens } + policy_hash budget fold(md 동형) +
  pdf_chunker_from_config(kebab-app). 신규 config 키 없음.
- 분할 조각 chunk_id 는 #c{segment_start}s{i}(미분할 단일 segment 는 bare
  #c{segment_start} 유지 → 공통 경우 v1.1 동일).
- Page span: 분할 조각은 부모 segment 의 char 범위를 그대로 가짐(segment-granular,
  md 동형). per-piece narrowing 은 text_pieces 의 줄 구분자 소실로 drift 하는
  버그라 코드 리뷰 후 제거 — 회귀 테스트
  oversize_pdf_page_with_newlines_splits_without_span_drift 로 잠금.
- chunker_version v1.1→v1.2 → 다음 ingest 에서 PDF 1회 자동 재청크(md/code 무영향).

검증: kebab-chunk lib 93 pass(span 회귀 포함), kebab-app green, clippy 0. 도그푸딩
(실험 KB, scanned PDF + arctic@Lemonade, budget 200): 625/625 errors=0,
scanned_page1 1→3 청크·scanned_page2 3→7 청크(둘 다 pdf-page-v1.2), 전 코퍼스
10215 청크 전부 ≤200. 버전 bump 은 follow-up 들과 함께 배치 릴리스에서 일괄.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-24 05:47:09 +00:00
58386c4445 feat(chunk): md-heading-v2 — 예산 초과 청크 일반 분할 (oversize-chunk split)
v1 의 "블록 미분할" 한계를 일반화. 거대 list/code/table/paragraph 가 한 청크로
임베더 컨텍스트를 초과해 임베딩이 통째로 실패하던 문제를 해소한다. v2 는 v1 과
모든 출력이 동일하되, 청크의 실제 임베드 text 크기(`text.len()/3`)가
`max_chunk_tokens`(신규 config, byte/3, default 4000)를 넘는 청크만 줄(`\n`)
경계로, 단일 거대 줄은 UTF-8 char 경계로 잘라 각 조각이 예산 이하가 되게 한다.

- 판정 기준은 저장 `token_estimate` 가 아니라 실제 `text` 길이: ImageRef/AudioRef
  청크는 image-only 규약으로 token_estimate=0 이지만 OCR/caption text 는 클 수
  있다(빽빽한 스크린샷). 도그푸딩 일치 재테스트에서 이 image-OCR 구멍 발견·수정.
- 분할 조각 chunk_id 는 동일 block_ids 를 공유하므로 id-input 해시에 `#seg{i}`
  접미사로 충돌 회피(저장 policy_hash 는 bare — pdf-page-v1 의 `#L` 레시피 동형).
- `max_chunk_tokens` 는 v2 의 policy_hash 에만 fold(공유 ChunkPolicy 미변경 →
  코드/PDF 청커 cascade 무영향). 값 변경 시 markdown 만 재청크.
- 미분할 청크는 v1 과 byte-identical. `chunker_version` v1→v2 → 다음 plain
  `kebab ingest` 에서 markdown 1회 자동 재청크(--force 불필요, 코드/PDF 무영향).

동기: strict 임베더(AMD Lemonade `/api/embed`)는 oversize 입력을 truncate 아닌
거부(`500 too large`) — ollama 가 조용히 truncate 하던 걸 청커가 애초에 안 만들게.

검증(실험 KB, arctic-embed-l-v2 @ Lemonade): v2 전 620 중 2 doc(거대 jira list
블록) 임베드 실패 → v2 후 620/620 errors=0, 7114 청크 전부 ≤ 4000. 사용자 실
config 일치 재테스트(이미지 OCR + PDF OCR paddle-onnx ON)에서 image-OCR 구멍
발견·수정 후 dense 이미지 OCR 텍스트가 budget 초과 시 분할(token_estimate=0 →
1청크였던 것이 실제 text 기준 다중 청크로) 실증. frozen 설계 doc / frozen p1-5
spec 미변경(설계 §9 가 md-heading-v2 라벨 bump 를 변경 메커니즘으로 명시).

known limitation: PDF 는 별도 청커 pdf-page-v1.1 이라 이 split 미적용(후속 후보).

Cargo.toml 0.29.0 → 0.30.0 (신규 config 키 + 청커 동작 변경 = pre-1.0 minor +
도그푸딩 트리거). docs cascade: HOTFIXES / release-notes-v0.30.0-draft /
plan(2026-06-24) / normalize-chunk README / README / SMOKE / HANDOFF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-24 01:10:53 +00:00
58ac62d53a feat(search): provenance 출처 필터 — [[workspace.sources]] 멀티소스 + --source/--source-type
혼합 출처 KB(위키+jira 등)에서 색인은 전부 하되 질의 시 출처로 좁히는 provenance
레버. 전역 trust 곱셈가중(weighted-RRF)은 A/B 에서 반증(θ=0.85 만으로 incident MRR
0.918→0.340 절벽, 점수 압축) — 필터가 see-saw 없는 올바른 레버.

- config [[workspace.sources]] (각 id/root/exclude/trust_level/source_type);
  단일 root 는 implicit `default` source 로 정규화. validate: id 유일·비어있지 않음.
- config schema v3→v4 (step_3_to_4, root→[[workspace.sources]] id=default 미러, 멱등)
- V014 documents.source_id 컬럼+인덱스 (additive, DEFAULT 'default', 재색인 0)
- Metadata.source_id + BodyHints trust precedence(frontmatter > source 기본값 > Primary)
- ingest: --root 미지정 시 resolved_sources() 순회 + doc 마다 source_id/trust stamp
- 검색 SearchFilters.source_type/source_id → lexical + vector 두 site (IN, OR)
- CLI kebab search --source <id> / --source-type <type> (repeatable/comma-sep)

도그푸딩(620 doc, jira400+wiki220): --source wiki 로 개념 질의 MRR 0.780→0.810,
--source jira 로 incident 0.918→0.975. trust precedence 실측(jira=secondary 기본값).

version bump 0.28.0 → 0.29.0 (신규 CLI flag + config 키 + V014 migration → minor).
follow-up: MCP search 필터 미노출 · kebab list source_id 미표시 · RAG provenance 라벨.

자세한 내용: tasks/HOTFIXES.md (2026-06-21), docs/release-notes/v0.29.0-draft.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-21 08:35:19 +00:00
3d45994693 refactor(config): signature paddle 경로 미디어화 + 바이트 불변 골든
ocr_engine_version_for_sig 가 det/rec/dict 를 호출자(미디어별)로부터 받도록
인자화 — image 는 [ingest.image.ocr], pdf 는 [ingest.pdf.ocr]. v2 의 pdf↔image
paddle 비대칭 제거. engine_version_for_paths 신설(kebab-parse-image). 출력
문자열은 값 기반이라 v2 와 바이트 동일(불변식 #1). test seam + 골든 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:44:27 +00:00
d5c69f6715 refactor(config): v3 경로 call-site sweep (kebab-app/kebab-eval/kebab-parse-image)
부모 경로에 .ingest 삽입(leaf 구조체 불변). src + 테스트 call-site 전부.
kebab-cli 테스트의 v2 TOML fixture 는 from_file 자동변환(T6) 경로 검증용으로 유지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:40:06 +00:00
375a0693e4 chore(ocr): T11/T12 — clippy clean + docs + v0.27.0 bump
T11: fix 12 clippy lints in paddle_onnx.rs/paddle_e2e.rs (doc overindent,
finish_non_exhaustive, map_or_else, RangeInclusive::contains, cast_lossless,
is_some_and, usize::from). Full-workspace clippy -D warnings = 0.

Smoke (paddle-onnx, real binary): clean_paragraph OCR verbatim-correct, real
per-region confidence (0.99/0.96/0.95), FTS5 lexical hit on Korean(검색)+
English(embedding), parser_version folds |ocr:1:paddle-onnx:<ver>. Big page
<4s inference (5.6s ingest incl. one-time session load).

T12: README [image.ocr].engine + ARCHITECTURE OCR row + SMOKE paddle-onnx config
+ HANDOFF + HOTFIXES dated entry. Workspace version 0.26.2 → 0.27.0 (minor:
new engine value + config keys). .gitattributes: onnx as plain blobs (no git-lfs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:36:19 +00:00
901416d8e9 feat(ocr): T7-T9 — config overrides + engine factory + signature cascade
T7: OcrCfg gains det_model/rec_model/dict overrides + score_thresh/
unclip_ratio/max_boxes (serde default, KEBAB_IMAGE_OCR_* env). OnnxPaddleOcr::new
threads them via ModelPaths::from_config.
T8: build_image_ocr_engine / build_pdf_ocr_engine factories return
Box<dyn OcrEngine>; match on engine string (ollama-vision|paddle-onnx|err).
ImagePipeline.ocr_engine + pdf_ocr_engine signatures switched to &dyn OcrEngine.
OcrEngine gains model() for the progress label.
T9: ingest_config_signature image/pdf branches emit |ocr:1:{engine}:{engine_version}
(memoized blake3 per asset-triple, m3-safe). Unit tests (a)(b)(c) added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:15:30 +00:00
03b0745e9d test(ingest): config invalidation e2e + parser_version assert 갱신
- config_invalidation.rs(신규): 동일config=전skip / 청킹변경=md+code재색인 /
  [ingest.code]변경=코드만 / search변경=재색인0 (회귀가드) end-to-end.
- code_ingest_smoke / pdf_pipeline: 저장 parser_version 이 이제
  "{base}|{sig}" composite 라, exact assert 를 base 접두사(split('|').next()) 비교로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:14:15 +00:00
e03d03cb26 test: 별칭 전용 테스트 삭제 + 영향 테스트/fixture 갱신
kebab-search/tests/lexical.rs 의 alias 채널 테스트 + insert_chunk_with_aliases
헬퍼 제거(body 회수 회귀 테스트로 대체). Chunk 리터럴 aliases: None 제거
(embedding_records_fk/idempotency/inspect). chunk 스냅샷 fixture 의 aliases
키 제거. config_migrate 는 ingest.code 앵커로, corpus_revision/search_lexical
주석은 V013 비-bump 명시로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:37:58 +00:00
a48b055358 feat(ingest): asset 내부 phase 진행 로깅 (asset_chunked/expansion_progress/asset_timings) + v0.24.0
asset(문서) 단위뿐이던 ingest 진행 이벤트에 문서 내부 phase 가시성을 추가.
큰 문서가 expansion(별칭 LLM, 청크당 순차)으로 수십 분 걸려도 진행바가
1/N 에 멈춘 듯 보이던 문제 해결.

wire ingest_progress.v1 additive (backward-compat):
- asset_chunked {idx,total,chunks} — 청킹 직후, markdown/image/pdf 전 경로
- expansion_progress {idx,total,done,chunks} — expansion 루프 스로틀
  (25청크 또는 1s, 종료 시 done==chunks). 캐시 히트도 done 에 포함
- asset_timings {idx,total,parse_ms,chunk_ms,expansion_ms,embed_ms,store_ms}
  — markdown 경로 phase별 wall-clock

설계: timing 은 kebab_core::IngestItem(wire-stable) 변경을 피해 신규
AssetTimings 이벤트로 ingest_one_asset 가 직접 emit (AssetFinished 무변경).

CLI(progress.rs): 진행바 sub-message(→ N chunks / 별칭 확장 done/chunks) +
asset 종료 시 phase timing 한 줄(fmt_ms). TUI reducer no-op arm.

검증: clippy -D warnings exit 0; cargo test -p kebab-app -p kebab-cli
312 passed/0 failed. ordering-invariant 테스트 재작성 + 신규 직렬화 테스트.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:58:27 +00:00
b7e022a5e3 feat(app): config migrate facade + init 주석 공유 + doctor 체크
- config_migrate_with_config_path: 백업(.bak)+atomic write(tmp→rename)+dry-run,
  round-trip 검증으로 실패 시 원본 보존. ConfigMigrationReport 반환.
- init_workspace 가 annotated_default_document() 사용(섹션 주석 포함).
- doctor 에 config_migration 체크 추가(미동기 시 ok=false + hint).
- tests/config_migrate.rs 4개(백업/atomic/dry-run/멱등/doctor) 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:09:31 +00:00
a8fd76499c feat(expansion): doc-side expansion 별칭 개별 dense 벡터 + 파생물 캐시(V012)
별칭을 줄별 개별 dense 벡터(sentinel `{chunk}#alias#N`)로 색인하고
boilerplate 청크는 별칭 생성을 skip. 묶음 1벡터 방식은 평균화로 특정
표현이 희석돼 오히려 회귀(13/18)했던 것을 폐기. 변형 일관성 14/18 →
16/18, mean_spread@10 0.222 → 0.111 (나무위키 ~1000 문서 CS corpus).
`kebab-core::strip_alias_suffix` 가 suffix 형과 per-alias 형 둘 다 처리.

파생물 캐시(V012): embedding 벡터 + 별칭 LLM 결과를 청크 내용 해시
키로 캐싱해 재색인 시 내용 불변 청크의 재계산을 skip. cache_key =
blake3(kind ‖ text_blake3 ‖ version_key)[:32], version_key 에
model/prompt/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동
miss). 측정: 정답 3개 cold 1879s → warm 13s ≈ 145배. 순수 가산이라
corpus_revision bump 없음. search/ask 는 kebab.sqlite+lancedb 만으로
동작 → 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능.

V012 schema migration + 신규 surface 로 workspace version 0.20.2 →
0.21.0 (minor) bump. README/HANDOFF/ARCHITECTURE/HOTFIXES sync.
known limitation: stack·svm 설명형 2개 잔존 + grounded 판정이 부분
인용을 grounded 로 오분류(후속 후보).

측정 상세: docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:24:04 +00:00
cde4d75f6b feat(app): ingest 별칭 생성 hook (flag off 기본, fail-soft) 2026-05-30 03:03:09 +00:00
21b52bc285 style: cargo fmt --all (S3+S4+S5+S7 follow-up)
V009 morphological tokenizer 작업 (S3 chunk + S4 backfill + S5
short_query_hint 제거 + S7 신규 tests) 의 형식 정리. 동작 변경 없음.

Spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md
Plan: docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md (S11)
2026-05-28 12:06:01 +00:00
c5de5f812b test(fts,app): V009 morphological tokenizer integration tests
신규 4 test 추가:

- crates/kebab-store-sqlite/tests/fts.rs:
  - fts_v009_korean_morphological_2char_query_hits: tokenized_korean_text
    column 이 채워진 chunk 의 '한국' 2-char query hit.
  - fts_v009_english_whole_token_only: V007 trigram substring 매칭
    회귀 (Path A) — 'token' query 가 'tokenizer' chunk 에서 0-hit.
- crates/kebab-app/tests/search_korean.rs:
  - korean_morphological_2char_query_lexical_mode: end-to-end
    한국어 wiki fixture ingest → '한국' / '서울' query hit.
  - korean_morphological_mixed_english_korean_query: 'Rust' English
    whole-token + '최적화' Korean morpheme hit.

crates/kebab-search/src/lexical.rs:
  - build_match_string() 의 MIN_TRIGRAM_CHARS(3) → MIN_QUERY_CHARS(2).
    V009 unicode61 은 최소 token 길이 제한 없어 2자 한국어 morpheme
    query 가 통과되어야 함. 1자 단독은 여전히 필터.
  - 관련 unit test 2개 V009 동작으로 갱신.

fixture text 는 lindera ko-dic 의 실제 segmentation 동작에 의존
(spec Appendix B prior-knowledge 예측). 실측 시 fixture 조정 가능.

Spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md §9.1, §9.2
Plan: docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md (S7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:38:52 +00:00
b63af20b72 feat(app): first-boot eager backfill for tokenized_korean_text
V007 → V009 업그레이드 시 기존 chunks 의 tokenized_korean_text 가
NULL — 첫 App::open_with_config 호출 시 자동으로 lindera ko-dic
으로 분해 후 UPDATE. chunks_au trigger 가 chunks_fts 를 자동 재-index.
사용자 재-ingest 불필요.

- crates/kebab-store-sqlite/src/store.rs:
  backfill_tokenized_korean_text(progress_cb, tokenize) API. 1000 row 마다
  commit + progress 콜백. idempotent (IS NULL 필터로 partial
  completion 재실행 안전). tokenizer 를 파라미터로 받아 §8 dep 경계 유지.
- crates/kebab-app/src/app.rs::open_with_config: run_migrations 직후
  backfill 호출. 실패 시 warn log 만 (App open 은 성공 — vector/hybrid
  mode 계속 가능). 500 row 마다 info log progress.
- crates/kebab-store-sqlite/tests/fts.rs:
  backfill_tokenized_korean_text_populates_nullable_rows 단위 test
  (idempotency 포함).
- clippy pre-existing 오류 수정 (redundant_closure, map_unwrap_or,
  cast_lossless, uninlined_format_args — kebab-app/ingest_log.rs,
  pdf_ocr_apply.rs, app.rs, tests/ocr_inspect_smoke.rs).

Spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md §8.1, §8.2
Plan: docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md (S4)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:01:00 +00:00
e8f44a57e3 fix(test): update first_ingest_bumps_corpus_revision baseline for V009
V004 seeds corpus_revision=0, V009 migration bumps to 1 (spec §5.2 —
LRU cache invalidation). Test previously asserted fresh store = 0;
now reads post-migration baseline dynamically and verifies that the
ingest commit increments past it.

Spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md §5.2
Plan: docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md (S3 follow-up)
2026-05-28 10:47:09 +00:00
9a36a06f97 style: cargo fmt --all (v0.20.x logging r2 feature follow-up)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 06:34:01 +00:00
35c987df1c feat(app): log retention — keep_recent_runs + retention_days (Enhancement 4)
LoggingCfg gains two fields with serde defaults: keep_recent_runs
(default 100, top-N file retention) and retention_days (default 30,
time-based retention for both ndjson files and the SQLite mirror).

IngestLogWriter::open now runs cleanup_old_logs before creating a new
ingest-*.ndjson — delete iff (idx >= keep_recent) OR (modified <=
cutoff). ingest_with_config_opts also calls
SqliteStore::prune_pdf_ocr_events(retention_days) at ingest start so
the SQLite mirror tracks the same retention window.

Backward compat (AC-9): both new fields use #[serde(default = ...)],
so a pre-v0.20.x config with only [logging] ingest_log_enabled +
ingest_log_dir parses unchanged. kebab init writes the new defaults
automatically via Config::default() -> toml::to_string_pretty (AC-12).

docs/SMOKE.md config example synced.

Closure r1 F5: explicit OR-on-stale comment inside cleanup_old_logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 06:17:47 +00:00
d9ec7b8dc3 feat(cli): kebab inspect ocr-stats + ocr-failures (Enhancement 3 + wire schema additive minor)
Two new wire schemas land as additive minor: ocr_stats.v1 (corpus-wide
aggregate — total_events, success_rate, p50/p90/p99/max_ms, by_engine,
top-10 by_doc by failure count) and ocr_failures.v1 (per-doc or
corpus-wide recent failures, with --doc-id + --limit). Both ship via
new CLI subcommands `kebab inspect ocr-stats` / `inspect ocr-failures`.

App gains four facade methods: inspect_ocr_stats /
inspect_ocr_failures plus their *_with_config companions — required by
CLAUDE.md "the facade rule" so `--config <path>` is honored. The CLI
dispatch arms thread cfg explicitly into the _with_config form.

Runtime introspection emit (WIRE_SCHEMAS in schema.rs) gains two
entries; the meta JSON Schema (schema.schema.json) is untouched
because its wire.schemas is pattern-based, not enum-based.

ingest_log::percentiles extended to (p50, p90, p99, max). p99 surfaces
only via inspect ocr-stats; IngestSummary (round 1) stays 3-percentile.

SKILL.md synced with the two new schemas (AC-13).

Closure r2 G2 (facade *_with_config pair) + G3 (runtime emit, not
meta schema file) + closure r1 F4 (p99) resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 06:13:08 +00:00
4e451c9f7c feat(app): dual-write PDF OCR events to SQLite + ndjson (Enhancement 2 wiring)
Pre-capture canonical.doc_id and Arc<SqliteStore> before the OCR
emit_progress closure so both the ndjson file and the SQLite mirror
carry the same doc_id for every event. File write is durable
(errors propagate); SQLite insert is non-critical (tracing::warn on
failure, ingest does not abort) per spec R-1.

LogEvent::Ocr gains a doc_id: Option<&str> field as an additive
Serde change — round 1 ndjson logs deserialize with doc_id=None.

Closure r1 F1: doc_id NULL in dual-write resolved via
let doc_id_for_log = canonical.doc_id.0.clone() pre-capture.
Closure r2 G1: Arc::clone(&app.sqlite) reused instead of opening a
second SqliteStore — eliminates double-open lock contention and
duplicate migration runs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 06:06:03 +00:00
685007789a style: cargo fmt --all (round 4 ingest log feature follow-up)
Phase C4 executor 의 마지막 `fix(test): clippy + fmt fixes` commit 이
test file 부분만 fmt 적용. workspace 전체 fmt 누락 발견 → cargo fmt --all
적용. 모든 import alphabetical reorder + line wrapping 정합.

추가 untracked artifact 동시 commit:
- docs/superpowers/specs/2026-05-28-v0.20-ingest-log-spec.md (491 line, ACCEPT)
- docs/superpowers/plans/2026-05-28-v0.20-ingest-log-plan.md (616 line, ACCEPT)

workspace test: 1370 passed / 0 failed / 50 ignored, ingest_log_smoke green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:18:40 +00:00
445b096215 fix(test): clippy + fmt fixes for logging_roundtrip and ingest_log_smoke
* kebab-config/tests/logging_roundtrip.rs: r#"..."# → plain string
    (clippy::unnecessary_hashes).
  * kebab-app/tests/ingest_log_smoke.rs: |e| e.ok() → Result::ok,
    |s| s.as_u64() → Value::as_u64 (clippy::redundant_closure).
  * cargo fmt --all applied to pre-existing formatting drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:26:42 +00:00
415227bf76 test(app): ingest_log_smoke integration test (AC-9)
crates/kebab-app/tests/ingest_log_smoke.rs 신규:

  * ingest_log_smoke (AC-9): tempdir + 1 md + 1 scanned PDF →
    ingest → assert log file exists + 각 line valid JSON +
    각 kind ∈ {ocr,parse_error,skip,error,summary} + last
    line kind=summary + scanned>0.

  * ingest_log_disabled_emits_no_file (AC-6): enabled=false 일
    때 log_dir 안 ingest-*.ndjson 파일 0개 verify.

fixture: ../kebab-parse-pdf/tests/fixtures/scanned_page1.pdf
재사용 (OCR disabled — Ollama 없이 smoke 실행).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:06:43 +00:00
9b44e27dfe test(app): update schema_report assertion for streaming_ask=true (Bug #9 follow-up)
schema_report_reflects_freshly_ingested_kb 가 `!streaming_ask` 를 assert 했으나
Bug #9 fix (760eee8) 로 streaming_ask 가 true 로 정정됨. assertion 을 반전.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:58:10 +00:00
d9c7aabce1 feat(schema): add active_parsers + active_chunkers arrays to schema.v1.models (Bug #13)
이전: schema.v1.models 가 parser_version / chunker_version 단일 값만 보고 →
multi-medium corpus (md + pdf + code Rust/Python + dockerfile + k8s + manifest)
의 version cascade audit 누락 risk.

이후: additive minor — Models struct 에 active_parsers + active_chunkers Vec<String>
추가. backward compat: 기존 단일 field 보존 (markdown default), 신규 array 는
optional (#[serde(default)] + JSON schema required 미포함).

source:
- kebab_store_sqlite::fetch_distinct_parser_versions() 가
  documents.parser_version DISTINCT + ORDER BY 반환.
- fetch_distinct_chunker_versions() 가 chunks.chunker_version 동일 pattern.
- collect_models 가 매 schema 호출마다 재계산 (cache 없음 — R-3 자동 해결).

wire schema additive only — 메이저 bump 불필요. v0.20.1 minor 로 충분.
integrations/claude-code/kebab/SKILL.md 동기 갱신.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:15:58 +00:00
241ded59df test(app): multi-scanned PDF chunk_id collision-free integration test (Bug #3 regression)
v0.20.0 sub-item 1 bugfix Step 3 (Group C) — integration-level regression
for Bug #3 (intra-doc chunk_id collision under aggressive overlap).

- `crates/kebab-app/tests/common/mod.rs`: `pub mod mock_ocr;` 1 line append.
- `crates/kebab-app/tests/common/mock_ocr.rs` (new): MockOcrEngine lift +
  `single` / `per_page` ctor (backward-compat single + per-page cursor).
- `crates/kebab-app/tests/pdf_ocr_apply.rs`: inline MockOcrEngine 제거 +
  `mod common; use common::mock_ocr::MockOcrEngine;` import. 10 ctor call
  site migration (`MockOcrEngine { .. }` → `MockOcrEngine::single(...)`).
- `crates/kebab-app/tests/multi_scanned_pdf_ingest_no_chunk_id_collision.rs`
  (new): F1 + F2 scanned PDF + Bug #3 trigger shape (10 char "가" + ". " +
  500 char "나") via mock OCR. assertion: chunk_id global uniqueness (HashSet
  dedup) across F1 + F2; F2 trigger text produces ≥2 chunks (collision shape).
- C1 decision: Option A (share via tests/common/mock_ocr.rs). Facade mock
  injection unavailable (OllamaVisionOcr hardcoded) — helper-level chain test
  (apply_ocr_to_pdf_pages → PdfPageV1Chunker) adds value beyond unit B5.

spec: docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix-spec.md (§4.5)
plan: docs/superpowers/plans/2026-05-27-v0.20-sub1-bugfix-plan.md (Step 3)
prior: 436fd01 (Step 2 Bug #3 chunk_id fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:45:38 +00:00
436fd015a2 fix(chunk): chunk_id collision under aggressive overlap; bump pdf-page-v1 → pdf-page-v1.1 (Bug #3)
v0.20.0 sub-item 1 dogfood report 의 Bug #3 (Critical). scanned_page2.pdf
(1580 char OCR text) ingest 시 `chunks.chunk_id` PRIMARY KEY violation —
`per_chunk_hash = #c{char_start}` 가 post-overlap `actual_start` 사용 +
overlap walk floor 가 `prev_min` 으로 collapse → segment 1/2 동일 `#c0`.

- `crates/kebab-chunk/src/pdf_page_v1.rs`: `chunk_page` returns 4-tuple
  (segment_start, actual_start, chunk_end, slice); caller `per_chunk_hash`
  suffix uses `segment_start` (pre-overlap boundary, strictly increasing)
  instead of `char_start` (post-overlap, may collapse to prev_min).
- VERSION_LABEL `"pdf-page-v1"` → `"pdf-page-v1.1"` (design §9 cascade,
  explicit user-facing audit trail). `crates/kebab-app/tests/pdf_pipeline.rs:
  168, 368` 의 hardcoded literal 도 v1.1 로 갱신.
- module docs (`pdf_page_v1.rs:47-60`): workaround description 의
  `#c{char_start}` reference 를 `#c{segment_start}` 로 갱신 + segment_start
  invariant 명문 + HOTFIXES.md cross-ref.
- `pdf_page_v1.rs::tests`: `multi_chunk_page_with_aggressive_overlap_produces_unique_chunk_ids`
  regression pin (10 char "가" + ". " + 500 char "나" — multi-chunk +
  overlap walk collapse trigger).
- `tasks/HOTFIXES.md`: 2026-05-27 entry (symptom F2 1580 char OCR,
  intra-doc collision root cause, second-iteration patch rationale).

spec:  docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix-spec.md (§4)
plan:  docs/superpowers/plans/2026-05-27-v0.20-sub1-bugfix-plan.md (Step 2)
prior: d9acda5 (Step 1 Bug #2 walker fix)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:32:09 +00:00
48197687b7 test(pdf): integration smoke (w/ search + cancel) + vector regression + alnum e2e (#[ignore]) for v0.20 sub-item 1
Step 9 (Group I) of v0.20.0 sub-item 1 (scanned PDF OCR) plan.

I3 — crates/kebab-app/tests/ingest_pdf_ocr_smoke.rs (신규):
- ingest_with_mock_ocr_yields_pdf_ocr_summary — `#[ignore]` real Ollama,
  ingest_with_config production path + IngestItem.pdf_ocr_pages verify.
- ocr_text_indexed_and_searchable — `#[ignore]` real Ollama, app.search
  의 OCR text indexed verify (§ Acceptance #2).
- ingest_with_cancel_aborts_mid_pdf — production cancel chain (pre-set
  cancel=true + dummy endpoint, no panic/deadlock verify).

I4 — crates/kebab-parse-pdf/tests/text_extractor_regression.rs (신규):
- vector_pdf_extract_byte_identical_to_baseline — F4 mojibake.pdf 의 vector
  PDF path canonical 의 byte-identical 보존 (Step 1-8 모든 변경 전후 invariant).
- baseline 신규 = tests/snapshots/vector_pdf_canonical.json (first run create).
- normalize_provenance_timestamps inline helper (R-3 mitigation, workspace
  전체 부재 — 신규 12-line).

I5 — crates/kebab-parse-pdf/tests/ocr_e2e.rs (신규):
- f1_alnum_accuracy_ge_85 / f2_alnum_accuracy_ge_70 — `#[ignore]` real
  Ollama qwen2.5vl:3b, § Acceptance §9 #3 의 implementation.
- alnum metric = strsim::levenshtein (dev-dep 추가).
- truth file copy from PoC scratch (page1.txt + page2-batchim.txt) →
  scanned_page1_truth.txt + scanned_page2_truth.txt.
- kebab-parse-image dev-dep 추가 (OllamaVisionOcr::from_parts 호출용).
  parser isolation invariant 의 dev-dep exception (spec §3.1, dep graph
  baseline -e normal 보존).

spec:  docs/superpowers/specs/2026-05-27-pdf-scanned-ocr-spec.md
plan:  docs/superpowers/plans/2026-05-27-pdf-scanned-ocr-plan.md (Step 9 I3+I4+I5)
prior: c9e0594 (Step 8 CLI printer)
contract: §9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:10:58 +00:00