Files
legal-ai/docs/superpowers/specs/2026-05-30-fu2a-idempotent-ingest-design.md
Chaim a8b780765d docs(spec): FU-2a idempotent-ingest design + split FU-2b migration to #67
FU-2 split (chair decision 2026-05-30): FU-2a = pure-code (GAP-03 ON CONFLICT
upsert, GAP-06 write-time type-aware normalization, GAP-13 materialized
searchable flag); FU-2b (#67) = data-migration for GAP-07/08 (identifier
reconciliation + dedup) deferred as separate chair-involved task.

DB check 2026-05-30: ~52/56 internal_committee rows hold full citation in
case_number, >=1 duplicate (8047-23). Architecture verified vs 3+ sources
(PostgreSQL ON CONFLICT, DDD write-boundary normalization, materialized
validity flag). No identifier migration in FU-2a.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:56:07 +00:00

12 KiB
Raw Blame History

FU-2a — Idempotent Ingest + Write-Time Normalization + searchable Flag — עיצוב

סטטוס: מאושר-לעיצוב · תאריך: 2026-05-30 · ענף: TBD מכסה: GAP-03, GAP-06, GAP-13 · מספק: INV-ING2, INV-G3, INV-G1, INV-ID1, INV-DM1 מקורות: 01-ingest.md, 02-data-model.md, X1-identifiers.md, gap-audit.md משימה: TaskMaster #60 · תלוי ב: FU-1 (#59) · סוג: pure-code (schema-additive) מיגרציה: אין מיגרציית-מזהים (GAP-07/08 פוצלו ל-#67 / FU-2b). דגל searchable נגזר ו-recompute-בלבד.


1. היקף ומה מחוץ להיקף

FU-2 פוצל (החלטת-יו"ר 2026-05-30) לאחר שבדיקת-DB גילתה נתונים מבולגנים מהצפוי: ~52/56 רשומות internal_committee מחזיקות ציטוט מלא ב-case_number, יש ≥1 כפילות (8047-23), ו-GAP-07 ("with-month canonical") דורש את המספר הרשמי שהוקצה — ידע-יו"ר.

  • בהיקף (FU-2a, כאן): GAP-03 (upsert idempotent), GAP-06 (נרמול-בכתיבה), GAP-13 (searchable). הכל pure-code / schema-additive, משנה התנהגות קדימה, אפס מוטציה של מזהים קיימים.
  • מחוץ להיקף (FU-2b, #67): GAP-07 (תיאום מזהים מעורבים), GAP-08 (ניקוי ציטוט-כמזהה) — מיגרציית-נתונים שמערבת dedup, סקירת-יו"ר per-record, גיבוי, reversibility.

אינטראקציה FU-2a↔FU-2b (מתועד): נרמול-בכתיבה חל רק על כתיבות חדשות. רשומות-עבר עם ציטוט-מלא לא משתנות עד FU-2b. קליטה-חוזרת של רשומת-עבר מבולגנת תיצור רשומה נקייה חדשה (לא תתנגש על המחרוזת השונה) — FU-2b יאחד. זה עקבי עם forward-only של FU-1.

2. הכרעות אדריכליות (מאומתות ≥3 מקורות)

החלטה נימוק מקורות
INSERT … ON CONFLICT DO UPDATE במקום SELECT-then-INSERT/UPDATE אטומי, נטול race תחת Read-Committed; ה-SELECT-then-write הוא read-modify-write קלאסי PostgreSQL INSERT docs; QueryPlane; on-systems.tech
לחזור על predicate של ה-partial-index ב-ON CONFLICT V15 משתמש ב-partial unique indexes; Postgres דורש את ה-predicate ב-conflict target PostgreSQL INSERT docs (§ON CONFLICT); QueryPlane gotchas
נרמול case_number בכתיבה, type-aware נרמול הוא אחריות-גבול-קלט; ערכים שווי-משמעות → פלט זהה. פסיקה-חיצונית: הציטוט הוא המזהה → לא לחתוך DDD value-objects (Medium/dev.to); gojko.net
דגל searchable materialized ונגזר-מחדש, לא מוסק בכל query reify את חוזה-השלמות; חייב להיות נראה ל-health-check (לא הסקה סמויה) DevIQ MISU; functional-architecture.org; Stemmler

3. הקבצים

  • Modify mcp-server/src/legal_mcp/services/db.py:
    • create_external_case_law — להמיר ל-ON CONFLICT (target: (case_number) WHERE source_kind <> 'internal_committee'); זה גם מטפל בקידום cited_onlyexternal_upload (אותו partial-index). לא לחתוך את ה-citation (זהו המזהה).
    • create_internal_committee_decision — להמיר ל-ON CONFLICT (case_number, proceeding_type) WHERE source_kind = 'internal_committee'; לנרמל case_number בכניסה.
    • create_case — לנרמל case_number בכניסה (כתיבה).
    • הוספת helper _canonical_case_number(s) (שם מפורש; עוטף את הטרנספורם הדטרמיניסטי trim·prefix-strip·/→- של X1). _normalize_case_number הקיים (read-time) נשאר כ-shim.
    • מיגרציית-schema V21: ALTER TABLE case_law ADD COLUMN searchable boolean NOT NULL DEFAULT false.
    • פונקציה recompute_searchable(case_law_id|all) — נגזרת מחוזה-השלמות; נקראת בסיום-קליטה ובסיום-חילוץ-metadata.
  • Modify mcp-server/src/legal_mcp/services/ingest.py — בסיום הצלחת הקליטה, לקרוא db.recompute_searchable(case_law_id) (אחיד לכל סוג; אחרי setting statuses).
  • Test mcp-server/tests/test_idempotent_ingest.py (חדש) — offline, monkeypatched.

גבול: אין שינוי לחתימות הציבוריות של ingest_precedent/ingest_internal_decision (FU-1). הנרמול וה-upsert יושבים בשכבת-ה-DB (גבול-הכתיבה), שקופים לקוראים.

4. נרמול type-aware (GAP-06)

_canonical_case_number(s) — דטרמיניסטי, תואם X1 §1, לא מוסיף/מסיר חודש:

trim → strip prefix לפני הספרה הראשונה → להחליף '/' ב-'-'
נקודת-כתיבה מדיניות נימוק
create_internal_committee_decision _canonical_case_number(case_number) המזהה הקנוני = מספר-בסיס מנורמל
create_case _canonical_case_number(case_number) תיק פעיל — אותו כלל
create_external_case_law .strip() בלבד (ללא prefix-strip) פסיקה חיצונית: ה-citation הוא המזהה הקנוני (X1 §1); חיתוך היה הורס אותו

נרמול מטפל ב-prefix+separator בלבד. קלט שהוא ציטוט-מלא (party names, נבו) לא מנוקה ל-bare ע"י הנרמול — זה GAP-08/FU-2b. FU-2a מבטיח שקלט נקי-יחסית נשמר בצורה קנונית.

5. Idempotent upsert (GAP-03)

שתי פונקציות-ה-create עוברות מ-SELECT-then-INSERT/UPDATE ל-INSERT … ON CONFLICT … DO UPDATE, עם חזרה על ה-predicate של ה-partial-index (V15):

  • internal: ON CONFLICT (case_number, proceeding_type) WHERE source_kind = 'internal_committee' DO UPDATE SET …
  • external: ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee' DO UPDATE SET … — מחליף את לוגיקת ה-SELECT הקיימת, כולל קידום cited_onlyexternal_upload (אותה partial- index חלה על שניהם; ה-DO UPDATE מקדם את source_kind וממלא שדות חסרים).

DO UPDATE ממוקד: רק שדות-קלט לא-ריקים דורסים (לשמר ערכים קיימים; COALESCE(EXCLUDED.x, case_law.x)), ולא לדרוס מטא-דאטה שמולא ע"י חילוץ-LLM. אם ל-case_law יש טריגרי-updated_at — לסנן עם WHERE על שינוי בפועל (gotcha מהמחקר). re-embed בקליטה-חוזרת = INV-ING4, שייך ל-FU-3 — כאן רק upsert-הרשומה.

6. דגל searchable (GAP-13)

עמודה חדשה case_law.searchable boolean NOT NULL DEFAULT false. נגזרת מחוזה-השלמות (02-data-model §2a / INV-DM1), לא מוסקת ב-query:

searchable = (
  case_number/citation קנוני לא-ריק
  AND case_name<>'' AND practice_area<>'' AND source_kind<>''
  AND EXISTS(precedent_chunk עם embedding NOT NULL)
  AND extraction_status='completed'
  AND (headnote<>'' OR summary<>'' OR jsonb_array_length(subject_tags)>0)
)
  • recompute_searchable(case_law_id) נקראת בסיום-קליטה (ingest.py) ובסיום precedent_metadata_extractor.
  • Backfill (recompute-בלבד, הפיך): מיגרציה V21 מריצה recompute_searchable(all) פעם אחת על רשומות קיימות. זו גזירה הפיכה (ניתן להריץ שוב כל רגע) — אינה נוגעת במזהים, לא חלק מ-FU-2b.
  • שכבת-החיפוש (search_*) תסונן ל-searchable=trueשינוי-התנהגות מתועד (ראה §7).
  • health-check יחשוף count(*) FILTER (WHERE NOT searchable) (זרע ל-GAP-14/FU-5).

7. שינויי-התנהגות וסיכון

שינוי השפעה סיכון
upsert ON CONFLICT קליטה-חוזרת = update אטומי, לא כפילות; קידום cited_only נשמר נמוך — מאומת מול partial-index הקיים
נרמול-בכתיבה (internal/cases) קלט חדש נשמר כ-bare מנורמל נמוך — type-aware; external לא נחתך
searchable מסנן חיפוש רשומות שלא עומדות בחוזה-השלמות לא יוחזרו ⚠️ בינוני — backfill עלול לסמן רשומות-עבר כ-non-searchable. אימות: להריץ recompute ב-dry-run ולדווח כמה ירדו מהחיפוש לפני הפעלת הסינון
backfill searchable דגל נגזר על רשומות קיימות נמוך — הפיך, recompute-בלבד, לא נוגע במזהים

אזהרת-backlog: ה-rows עם ציטוט-מלא-כמזהה (FU-2b) עשויים בכל זאת לעמוד בחוזה-השלמות (יש להם chunks+metadata), כך שהסינון לא בהכרח מפיל אותם. ה-dry-run ב-§7 יכמת זאת לפני הפעלה.

8. אסטרטגיית בדיקה

tests/test_idempotent_ingest.py — offline, monkeypatch ל-DB pool (או בדיקת-SQL מול sqlite-fallback אם קיים בפרויקט; אחרת monkeypatch כמו FU-1). מקרים:

  1. _canonical_case_number: "ערר 8137/24""8137-24", "8126-03-25""8126-03-25" (חודש נשמר), " עע\"מ 1/20 ""1-20".
  2. נרמול type-aware: internal מנרמל; external לא חותך citation.
  3. upsert: קליטה כפולה של אותו (case_number, proceeding_type) internal = רשומה אחת (לא שתיים).
  4. upsert: קידום cited_onlyexternal_upload על אותו case_number = עדכון, לא כפילות.
  5. DO UPDATE ממוקד: מטא-דאטה קיים לא נדרס ע"י קלט ריק (COALESCE).
  6. recompute_searchable: רשומה מלאה→true; חסרת-embedding/metadata/extraction→false.
  7. ingest קורא recompute_searchable בסיום (שני הסוגים).

בדיקת ON CONFLICT האמיתית דורשת Postgres. אם אין מסלול-בדיקה מול DB אמיתי בפרויקט, הבדיקות יאמתו את בניית-ה-SQL ואת הלוגיקה הטהורה (normalize, completeness predicate) ב-offline, ושכבת-ה-SQL תיבדק ב-smoke מול ה-DB המקומי (5433) ידנית בסיום, מתועד בתוכנית.

9. סדר-ביצוע

  1. בדיקות אדומות (test_idempotent_ingest.py).
  2. _canonical_case_number + נרמול-בכתיבה ב-3 פונקציות ה-create.
  3. המרת שתי create ל-ON CONFLICT … DO UPDATE (עם predicate חוזר + COALESCE ממוקד).
  4. מיגרציה V21: עמודה searchable + recompute_searchable + backfill recompute.
  5. קריאה ל-recompute_searchable מ-ingest.py; חשיפת count FILTER (WHERE NOT searchable) ב-health-check.
  6. dry-run של backfill מול DB 5433 → לדווח כמה רשומות יסומנו searchable=false ומאילו source_kind.
  7. שער החלטה (gated): סינון searchable=true בשכבת-החיפוש מופעל רק אם ה-dry-run מראה שאף רשומה לגיטימית לא יורדת מהחיפוש. אם רשומות-עבר לגיטימיות היו נופלות (למשל מבולגנות-FU-2b שעדיין שמישות) — לדחות את הפעלת-הסינון לפולואו-אפ אחרי FU-2b, ולהשאיר את העמודה+health-check בלבד. (להציף את ממצא ה-dry-run למשתמש לפני הפעלה — שינוי חיפוש הוא פעולה גלויה.)
  8. בדיקות ירוקות + smoke מול DB מקומי + lint.