# FU-3 — Re-Index on Content Change — עיצוב **סטטוס:** מאושר-לעיצוב · **תאריך:** 2026-05-30 · **ענף:** TBD **מכסה:** GAP-09 · **מספק:** INV-DM3, INV-G6, INV-ING4 (freshness) · **משימה:** TaskMaster #61 **תלוי ב:** FU-1 (#59) · **סוג:** pure-code + backfill-hash זול (אפס re-embed בריצה רגילה) **מיגרציה:** V23 additive (2 עמודות-hash) + backfill-hash דטרמיניסטי הפיך. אין re-embed המוני. --- ## 1. הבעיה (מאומת בקוד) `embedding` אינו עמודת `GENERATED` (בניגוד ל-tsvectors שמתעדכנים אוטומטית בשינוי-תוכן). חילוץ embedding דורש קריאת-API, ולכן אי-אפשר להפוך אותו ל-GENERATED. הממצא של מיפוי-הקוד: - **re-ingest דרך `ingest_document` כבר מבצע re-index נכון** — `_chunk_embed_store` רץ ללא-תנאי ו-`store_precedent_chunks(_hierarchical)` הן DELETE-then-INSERT. אז המסלול המלא תקין. - **3 פערים אמיתיים:** (א) אין **גילוי שינוי-תוכן** (אין `content_hash`/`updated_at` ב-case_law); (ב) אין **נקודת re-index עצמאית** — כדי להטמיע מחדש חייבים לקלוט מחדש את ה**קובץ**, אך רשומות רבות (למשל 42 החלטות-ועדה) נקלטו מ-`text` בלי קובץ; (ג) אין **גילוי-drift** בין תוכן ל-embeddings. **אכיפת INV-G6** ("re-index בכל שינוי-תוכן") כשהטמעה אינה GENERATED = **גילוי (hash) + כלי-reindex מתוכן-שמור + health-check** — בדיוק כדפוס ה-drift של FU-7 (detect-don't-auto-magic). ## 2. הכרעות אדריכליות (מאומתות ≥3 מקורות) | החלטה | נימוק | מקורות | |-------|--------|--------| | `content_hash` (SHA-256 של full_text) לגילוי-שינוי, לא timestamp | hash תוכן הוא הדפוס המומלץ כשאין timestamp מהימן; דטרמיניסטי + collision-safe | Hash-based change detection (DeepWiki); Andy Dote content-hash; moby#9391 | | re-index **מ-full_text שמור**, לא מ-re-extract/re-OCR | OCR לא-דטרמיניסטי; להשתמש בטקסט השמור (תואם [[feedback_no_reocr_retrofit]]) | RAG re-embed-on-edit (Medium); particula incremental update | | detect→re-embed **רק שהשתנה** (לא rebuild מלא) + health-check staleness | incremental sync; ניטור recall כשהאינדקס מתיישן | apxml RAG updates; Pinecone/Weaviate (gap-audit) | | backfill = hash בלבד (לא re-embed) — רשומות קיימות כבר מוטמעות | זול, הפיך, אפס עלות-API; re-embed רק כשתוכן באמת השתנה | — (נגזר מהמצב: 80 רשומות כבר embedded) | ## 3. הקבצים - **Modify** `services/db.py`: V23 (`content_hash`, `indexed_hash` ב-case_law); `_content_hash(text)`; כתיבת `content_hash` בכניסת `create_external_case_law`/`create_internal_committee_decision`/`create_case`; `mark_indexed(case_law_id)` (מעתיק content_hash→indexed_hash); `recompute_content_hashes()` (backfill); `list_stale_case_law()` (drift query). - **Modify** `services/ingest.py`: אחרי `_chunk_embed_store` המוצלח → `mark_indexed(case_law_id)`; הוספת `reindex_case_law(case_law_id)` — טוען row, chunk+embed+store מ-full_text שמור, ואז `mark_indexed`. - **Modify** `services/metrics.py`: חשיפת `stale_embedding_case_law` count. - **Add** MCP tool `precedent_reindex(case_law_id)` (wrapper דק ל-`ingest.reindex_case_law`) — מאפשר הפעלה ידנית; voyage-API בלבד (אין CLI/LLM → בטוח גם בקונטיינר). - **Test** `tests/test_reindex_on_change.py` (חדש). **גבול:** אין שינוי לחתימות ציבוריות. `reindex_case_law` הוא **תוסף**; המסלול הקיים לא משתנה. ## 4. content_hash + indexed_hash - `_content_hash(text) -> str`: `hashlib.sha256(text.encode()).hexdigest()`; על `""`/None → `""`. - `content_hash` = hash של ה-full_text **הנוכחי**, נכתב בכל כתיבת-row (ב-create_*; גבול-הכתיבה כמו נרמול FU-2a). - `indexed_hash` = ה-hash שעליו נבנו ה-chunks/embeddings **הנוכחיים**, נכתב ב-`mark_indexed` אחרי store מוצלח (ב-ingest + ב-reindex). - **טרי** ⇔ `content_hash = indexed_hash`. **stale** ⇔ `content_hash IS DISTINCT FROM indexed_hash` (כולל indexed_hash=NULL = "מעולם לא הוטמע מהתוכן הזה"). ## 5. `reindex_case_law(case_law_id)` (GAP-09 enforcement) ``` load case_law row → full_text (שמור) → _chunk_embed_store(case_law_id, full_text, page_offsets=None, ...) # אותו מסלול קנוני → mark_indexed(case_law_id) # indexed_hash = content_hash return {chunks, reindexed: true} ``` - **לא** קורא ל-extractor/OCR ולא ל-LLM — רק chunk (טקסט שמור) + embed (voyage) + store. תואם [[feedback_no_reocr_retrofit]] ו-claude_session (אין CLI). - multimodal: מדלג (page-images דורשים PDF; רשומות-טקסט אין להן — ראה §7). אם בעתיד יש קובץ — המסלול המלא של ingest מטפל. - idempotent (store = DELETE-then-INSERT; mark_indexed דטרמיניסטי). ## 6. גילוי-drift + health-check - `list_stale_case_law()` → רשומות עם full_text לא-ריק ו-`content_hash IS DISTINCT FROM indexed_hash`. - health-check (metrics.py) חושף `stale_embedding_case_law` count (INV-G6 observability; אחות ל- `non_searchable_case_law`/`cases_with_stale_blocks` מ-FU-2a/FU-7). ## 7. #61.2 (multimodal backfill) — נסגר כלא-ישים בדיקת-DB (2026-05-30): 42 החלטות-ועדה ללא page-images — **כולן** `document_id=NULL` ו-full_text קיים, ואין PDF מקור בדיסק (`data/internal-decisions/` מכיל קובץ אחד). page-images דורשים **רינדור PDF**; לרשומות-טקסט אין PDF → **בלתי-אפשרי**. לכן #61.2 נסגר כ-not-applicable. (אם יועלה PDF לאחת — מסלול-ה-ingest הרגיל יטפל ב-multimodal.) FU-3 core מטמיע-מחדש את ה**טקסט** של כל 42 במידת-הצורך. ## 8. שינויי-התנהגות וסיכון | שינוי | השפעה | סיכון | |--------|--------|--------| | content_hash בכתיבה | כל קליטה חדשה נושאת hash; טרי-מעצם-הקליטה | נמוך — additive | | mark_indexed ב-ingest | רשומות חדשות = טרי (content=indexed) | נמוך | | reindex_case_law | re-embed מתוכן שמור; עלות-API לפי-בקשה | נמוך — תוסף, ידני/מבוקר; לא רץ אוטומטית בהמוני | | backfill hashes | content_hash לכולם; indexed_hash=content רק אם יש chunks, אחרת NULL | נמוך — הפיך, אפס re-embed | | health-check stale count | חשיפת drift | נמוך — read-only | ## 9. אסטרטגיית בדיקה `tests/test_reindex_on_change.py` — offline, monkeypatch. מקרים: 1. `_content_hash`: דטרמיניסטי; `""`/None→`""`; טקסט שונה→hash שונה. 2. stale-predicate: content≠indexed → stale; שווים → טרי; indexed=NULL → stale. 3. `mark_indexed` מריץ UPDATE שמעתיק content_hash→indexed_hash (monkeypatch conn). 4. `reindex_case_law`: טוען full_text, קורא _chunk_embed_store ו-mark_indexed (monkeypatch), לא קורא extractor/LLM. 5. create_* כותב content_hash (monkeypatch — assert ה-hash מועבר ל-INSERT/upsert). > בדיקות-DB אמיתיות (V23, backfill, drift query) — smoke מול DB מקומי (5433) בסיום, כמו FU-2a/FU-7. ## 10. סדר-ביצוע 1. בדיקות אדומות. 2. V23 (`content_hash`,`indexed_hash`) + `_content_hash` + `mark_indexed` + כתיבת content_hash ב-create_*. 3. `reindex_case_law` ב-ingest.py + קריאת `mark_indexed` אחרי `_chunk_embed_store` בקליטה. 4. `list_stale_case_law` + health-check `stale_embedding_case_law`. 5. MCP tool `precedent_reindex`. 6. backfill (DB smoke): `recompute_content_hashes()` — content_hash לכולם, indexed_hash=content אם יש chunks. 7. בדיקות ירוקות + smoke מול DB + lint + סגירת #61.2 + TaskMaster #61.