From 83d1a8253cf0462fb6601009280f35ba89635c3d Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 8 Jun 2026 06:02:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(digests):=20digest=5Fkind=20classification?= =?UTF-8?q?=20=E2=80=94=20robust=20extraction=20for=20all=20issue=20types?= =?UTF-8?q?=20(X12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~2% מגיליונות "כל יום" הם לא-הכרעות (עדכוני-חקיקה/הודעות/ברכות) ללא ruling → החילוץ ה-decision-centric החזיר ריק → both-empty → מחזורי ב-self-heal. - SCHEMA_V32: `digest_kind` (decision/announcement/other) + backfill legacy בזול (יש citation→decision, אחרת announcement) — לפני שה-self-heal מסתמך עליו. - extractor: prompt מסווג + מחלץ תמיד concept/headline/summary; underlying_* רק ל-decision. extract מנרמל digest_kind. - enrich: שומר digest_kind; חילוץ מוצלח תמיד מסתיים ב-kind לא-ריק (ברירת-מחדל לפי citation אם המודל השמיט). - drain self-heal: הגדרת-כשל = completed עם digest_kind='' (במקום both-empty) → הודעות לא מנוסות-מחדש לנצח. - db: digest_kind ב-_DIGEST_COLS + update-whitelist (זורם ל-search/list/API). - X12 spec: תיעוד digest_kind + הגדרת-הכשל המתוקנת. אומת: V32 סיווג 533 (525 decision + 8 announcement, 0 unclassified — self-heal לא נוגע בהם). extract: 5163→decision+citation · 5060→announcement+concept, citation ריק (לא both-empty). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/spec/X12-digests-radar.md | 7 +++++ mcp-server/src/legal_mcp/services/db.py | 25 +++++++++++++++--- .../src/legal_mcp/services/digest_library.py | 10 +++++++ .../services/digest_metadata_extractor.py | 26 ++++++++++++------- scripts/drain_digests.py | 20 ++++++++------ 5 files changed, 67 insertions(+), 21 deletions(-) diff --git a/docs/spec/X12-digests-radar.md b/docs/spec/X12-digests-radar.md index 055f709..5940f9b 100644 --- a/docs/spec/X12-digests-radar.md +++ b/docs/spec/X12-digests-radar.md @@ -44,6 +44,13 @@ `underlying_date` (מתן הפסק) שונה מ-`digest_date` (גיליון היומון) — מקור-באגים נפוץ; חילוץ-המטא-דאטה מבחין ביניהם מפורשות. +**`digest_kind` (סיווג-גיליון, V32):** רוב הגיליונות הם `decision` (סיכום פס"ד → `underlying_citation`), +אך חלקם `announcement` — עדכון/הודעה ללא הכרעה (חקיקה, נוהל, ברכת-שנה) שאין לו מראה-מקום. החילוץ +מסווג כל גיליון ותמיד מחלץ `concept_tag`/`headline`/`summary` (קיימים לכל סוג); `underlying_citation` +רק ל-`decision`. **שימוש קריטי:** הגדרת-"כשל" של ה-drain self-heal היא `completed` **עם +`digest_kind=''`** (מעולם לא סווג) — כך הודעה (kind=`announcement`, בלי citation) **אינה** נחשבת כשל +ואינה מנוסה-מחדש לנצח. ההיוריסטיקה הישנה ("שני השדות ריקים") טיפלה בהודעות בטעות כ-retry אינסופי. + --- ## 3. למה זה לא קורפוס-ציטוט רביעי (הקושיה המרכזית — G2) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 6b908e5..d57167f 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1383,6 +1383,24 @@ CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_digest ON court_fetch_jobs(diges WHERE digest_id IS NOT NULL; """ +SCHEMA_V32_SQL = """ +-- digest_kind (X12): classify each "כל יום" issue. Most are decision-summaries +-- (point at a ruling → underlying_citation set), but some are non-decision +-- ANNOUNCEMENTS (legislative/planning updates, new-year notices) that legitimately +-- have no ruling. Classifying explicitly lets enrich treat an announcement as a +-- SUCCESS (concept+summary, no citation) instead of a both-empty "failure" that +-- the drain self-heal would retry forever. '' = not yet classified (= a genuine +-- extraction failure once enriched). +ALTER TABLE digests ADD COLUMN IF NOT EXISTS digest_kind TEXT NOT NULL DEFAULT ''; +-- Backfill legacy rows cheaply (no LLM): a row with a citation is a decision, +-- otherwise an announcement. MUST run before the new self-heal keys on +-- digest_kind='' (else it would reset every legacy row). Idempotent. +UPDATE digests SET digest_kind = + CASE WHEN coalesce(underlying_citation,'') <> '' THEN 'decision' ELSE 'announcement' END + WHERE coalesce(digest_kind,'') = '' AND extraction_status = 'completed'; +CREATE INDEX IF NOT EXISTS idx_digests_kind ON digests(digest_kind); +""" + async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async with pool.acquire() as conn: @@ -1418,7 +1436,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None: await conn.execute(SCHEMA_V29_SQL) await conn.execute(SCHEMA_V30_SQL) await conn.execute(SCHEMA_V31_SQL) - logger.info("Database schema initialized (v1-v31)") + await conn.execute(SCHEMA_V32_SQL) + logger.info("Database schema initialized (v1-v32)") async def init_schema() -> None: @@ -3600,7 +3619,7 @@ _DIGEST_COLS = ( "headline_holding, analysis_text, summary, underlying_citation, " "underlying_court, underlying_date, underlying_judge, practice_area, " "appeal_subtype, subject_tags, linked_case_law_id, source_document_path, " - "content_hash, extraction_status, created_at, updated_at" + "content_hash, extraction_status, digest_kind, created_at, updated_at" ) _DIGEST_UPDATE_ALLOWED = { @@ -3608,7 +3627,7 @@ _DIGEST_UPDATE_ALLOWED = { "headline_holding", "analysis_text", "summary", "underlying_citation", "underlying_court", "underlying_date", "underlying_judge", "practice_area", "appeal_subtype", "subject_tags", "source_document_path", "content_hash", - "extraction_status", + "extraction_status", "digest_kind", } diff --git a/mcp-server/src/legal_mcp/services/digest_library.py b/mcp-server/src/legal_mcp/services/digest_library.py index b22fe25..e2e4793 100644 --- a/mcp-server/src/legal_mcp/services/digest_library.py +++ b/mcp-server/src/legal_mcp/services/digest_library.py @@ -211,6 +211,16 @@ async def enrich_digest(digest_id: UUID | str, progress: ProgressCb | None = Non fields["underlying_date"] = extracted["underlying_date"] if not (row.get("subject_tags") or []) and extracted.get("subject_tags"): fields["subject_tags"] = extracted["subject_tags"] + # digest_kind classifies the issue (decision vs announcement). A successful + # extraction (any field returned) must end with a non-empty kind — that is the + # signal the drain self-heal uses to tell "enriched" from "failed". If the + # model omitted it, infer: a ruling citation → decision, else announcement. + if extracted and not (row.get("digest_kind") or "").strip(): + kind = extracted.get("digest_kind") + if kind not in ("decision", "announcement", "other"): + cite = fields.get("underlying_citation") or row.get("underlying_citation") or "" + kind = "decision" if cite.strip() else "announcement" + fields["digest_kind"] = kind if fields: try: diff --git a/mcp-server/src/legal_mcp/services/digest_metadata_extractor.py b/mcp-server/src/legal_mcp/services/digest_metadata_extractor.py index 48cb52b..e836361 100644 --- a/mcp-server/src/legal_mcp/services/digest_metadata_extractor.py +++ b/mcp-server/src/legal_mcp/services/digest_metadata_extractor.py @@ -33,7 +33,7 @@ _VALID_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_1 # below contains '{' / '}' which str.format would treat as placeholders and # crash (same trap documented in precedent_metadata_extractor). DIGEST_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. לפניך "יומון" — סיכום עמוד-אחד של משרד עפר טויסטר (עלון "כל יום") -על פסק דין/החלטה אחת בתחום תכנון ובנייה / היטל השבחה / פיצויים (ס' 197). חלץ ממנו מטא-דאטה לקטלוג. +על **החלטה/פסק דין** אחד, או על **עדכון/הודעה** (חקיקה, נוהל, הודעת-תכנון, ברכת-שנה) בתחום תכנון ובנייה / היטל השבחה / פיצויים (ס' 197). חלץ ממנו מטא-דאטה לקטלוג. **אל תמציא** — שדה שלא מופיע בטקסט → השאר ריק (מחרוזת ריקה / מערך ריק). @@ -41,12 +41,13 @@ DIGEST_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. לפניך החזר JSON אחד (object — לא array), ללא markdown וללא הסברים: { + "digest_kind": "סווג את הגיליון: 'decision' = סיכום פסק דין/החלטה (יש מראה-מקום בתחתית) · 'announcement' = עדכון/הודעה ללא הכרעה (חקיקה, נוהל, הודעת-תכנון, ברכה) · 'other' = אחר. **חובה למלא תמיד.**", "yomon_number": "מספר היומון מהכותרת ('יומון מס' 5163' → '5163'). ספרות בלבד. אם אין — ריק.", "digest_date_iso": "YYYY-MM-DD — תאריך גיליון היומון (בכותרת, למשל '7 ביוני 2026' → '2026-06-07').", - "concept_tag": "תג-המושג שבמרכאות בראש העמוד (למשל 'שיקול הדעת המצומצם', 'Cherry-picking'). ביטוי קצר אחד.", - "headline_holding": "כותרת-ההלכה המודגשת מתחת לתג — משפט אחד שמסכם מה נקבע (למשל 'ביהמ\\"ש - שיקול דעת הוועדה המחוזית אינו מצומצם לטעות חמורה').", - "summary": "תקציר ניטרלי 2-3 משפטים בגוף שלישי: מה הייתה השאלה ומה הוכרע. בלי שיפוט.", - "underlying_citation": "מראה-המקום של פסק הדין/ההחלטה המקורי, כפי שמופיע בתחתית היומון, מילה במילה (למשל 'עת\\"מ 46111-12-22 יכין-אפק בע\\"מ נ' הוועדה המחוזית'). זהו השדה הקריטי — חלץ אותו במלואו ובדיוק.", + "concept_tag": "תג-המושג/הכותרת בראש העמוד (למשל 'שיקול הדעת המצומצם', 'Cherry-picking', או 'עדכונים לשנה החדשה' בעדכון). ביטוי קצר אחד. **חלץ תמיד — קיים לכל סוג גיליון.**", + "headline_holding": "הכותרת המודגשת מתחת לתג — משפט אחד שמסכם את עיקר הגיליון (מה נקבע בהחלטה, או נושא העדכון). **חלץ תמיד.**", + "summary": "תקציר ניטרלי 2-3 משפטים בגוף שלישי: בהחלטה — מה הייתה השאלה ומה הוכרע; בעדכון — מה תוכן/משמעות העדכון. בלי שיפוט. **חלץ תמיד.**", + "underlying_citation": "**רק ל-decision** — מראה-המקום של פסק הדין/ההחלטה המקורי, כפי שמופיע בתחתית היומון, מילה במילה (למשל 'עת\\"מ 46111-12-22 יכין-אפק בע\\"מ נ' הוועדה המחוזית'). בעדכון/הודעה — ריק. זהו השדה הקריטי ל-decision — חלץ אותו במלואו ובדיוק.", "underlying_court": "הערכאה שנתנה את פסק הדין המקורי (למשל 'בית המשפט לעניינים מנהליים מרכז-לוד', 'ועדת הערר מחוז ירושלים').", "underlying_date_iso": "YYYY-MM-DD — תאריך מתן פסק הדין/ההחלטה המקורי (לרוב 'ניתן ביום DD.M.YY' בתחתית). שים לב: זה שונה מתאריך גיליון היומון!", "underlying_judge": "שם השופט/ת או יו\\"ר ההרכב שנתן את פסק הדין המקורי (למשל 'יעל טויסטר ישראלי'). בלי תארים ('עו\\"ד', 'כב' השופט').", @@ -56,11 +57,12 @@ DIGEST_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. לפניך } ## כללי איכות -1. **underlying_citation** — השדה החשוב ביותר; הוא הגשר לפסק הדין המקורי. חלץ מההערות/התחתית, מילה במילה. -2. **הבחן בין שני התאריכים**: digest_date_iso = תאריך גיליון היומון (בכותרת); underlying_date_iso = מועד מתן פסק הדין (בתחתית, 'ניתן ביום...'). אל תבלבל. -3. **summary** — ניטרלי, גוף שלישי, בלי מילות שיפוט. -4. **subject_tags** — snake_case, תחום ועדת ערר תכנון ובנייה בלבד. -5. אם רכיב לא מופיע בבירור — השאר את אותו שדה ריק. אל תנחש. +1. **digest_kind** — חובה. אם יש מראה-מקום של פסק דין/החלטה בתחתית → 'decision'. אם זה עדכון/הודעה/נוהל/ברכה ללא הכרעה → 'announcement'. +2. **concept_tag / headline_holding / summary** — חלץ **תמיד**, לכל סוג גיליון (גם עדכון). אלה לא ייחודיים להחלטות. +3. **underlying_citation** — רק ל-decision; הוא הגשר לפסק הדין. בעדכון — השאר ריק (זה תקין, לא חוסר). +4. **הבחן בין שני התאריכים**: digest_date_iso = תאריך גיליון היומון (בכותרת); underlying_date_iso = מועד מתן פסק הדין (בתחתית, 'ניתן ביום...'). אל תבלבל. +5. **subject_tags** — snake_case, תחום ועדת ערר תכנון ובנייה בלבד. +6. אם רכיב לא מופיע בבירור — השאר את אותו שדה ריק. אל תנחש. """ @@ -125,6 +127,10 @@ async def extract(raw_text: str, model: str | None = None) -> dict: if s: out[key] = s + kind = _norm_str(result, "digest_kind").lower() + if kind in ("decision", "announcement", "other"): + out["digest_kind"] = kind + dd = _norm_date(result, "digest_date_iso") if dd is not None: out["digest_date"] = dd diff --git a/scripts/drain_digests.py b/scripts/drain_digests.py index fafe704..9b8fc32 100644 --- a/scripts/drain_digests.py +++ b/scripts/drain_digests.py @@ -36,20 +36,24 @@ CONCURRENCY = int(os.environ.get("DIGEST_DRAIN_CONCURRENCY", "3")) async def main() -> int: pool = await db.get_pool() - # Self-heal: an enrich that failed mid-LLM (e.g. the local claude - # subscription window was exhausted) can leave a row 'completed' with no - # concept_tag AND no underlying_citation — a real digest always extracts at - # least a citation, so "both empty" means the extraction never landed. Reset - # those to 'pending' so the next run retries (idempotent auto-resume). Safe: - # successfully-enriched rows always have a concept_tag or citation. + # get_pool() runs schema migrations first — incl. the V32 digest_kind backfill + # that classifies legacy rows — so the failure check below is safe from the + # very first run (no legacy row has digest_kind=''). + # + # Self-heal: a successful enrich ALWAYS sets digest_kind (decision/announcement + # /other). So a 'completed' row with digest_kind='' means the extraction never + # landed (e.g. the local claude subscription window was exhausted) — reset to + # 'pending' to retry (idempotent auto-resume). This correctly does NOT touch + # announcements (digest_kind='announcement', legitimately no citation), which + # the old "both fields empty" heuristic wrongly retried forever. healed = await pool.execute( "UPDATE digests SET extraction_status = 'pending' " "WHERE extraction_status = 'completed' " - "AND coalesce(concept_tag,'') = '' AND coalesce(underlying_citation,'') = '' " + "AND coalesce(digest_kind,'') = '' " "AND coalesce(analysis_text,'') <> ''" ) if healed and healed != "UPDATE 0": - print(f"self-heal: reset failed-empty digests → pending ({healed})", flush=True) + print(f"self-heal: reset unclassified (failed) digests → pending ({healed})", flush=True) # Self-heal stale 'processing': flock guarantees a single drainer, so at the # start of THIS run any row left 'processing' is from a previous run that was # killed mid-row (session/quota cutoff). Reset to 'pending' so it is retried. -- 2.49.1