"""Split a monthly "עו"ד על נדל"ן" bulletin into typed radar items (X12). The monthly bulletin (a SEPARATE publication from the daily "כל יום" digest) is multi-topic: it bundles a featured ARTICLE, a list of legislative updates, and a set of CASE-LAW pointers grouped by topic. The chair chose to catalog the **case-law pointers** (each → a digest, like the daily issue) and the **articles** (deep-context background) — legislative updates are skipped. This module is the LLM splitter only. ``bulletin_library.ingest_bulletin`` turns its output into digest rows. Like the daily extractor it is LOCAL-ONLY (claude CLI) and MUST NOT be imported from the FastAPI container path. """ from __future__ import annotations import logging from legal_mcp import config logger = logging.getLogger(__name__) _VALID_PRACTICE_AREAS = {"rishuy_uvniya", "betterment_levy", "compensation_197"} BULLETIN_SPLIT_PROMPT = """\ אתה מקבל טקסט מלא של **עלון חודשי "עו"ד על נדל"ן"** (פרסום מקצועי רב-נושאי בתחום תכנון ובנייה, מקרקעין, היטל השבחה, פיצויים והתחדשות עירונית). פצל אותו לפריטים. העלון בנוי משלושה חלקים: (א) **מאמר** מקצועי ארוך אחד או יותר; (ב) **עדכוני חקיקה** (תיקוני-חוק, אישורי-תכניות, חוזרים) — **התעלם מהם, אל תחלץ**; (ג) **עדכוני פסיקה** מקובצים לפי נושא — כל פריט = מראה-מקום של פסק דין/החלטה + שורת-תקציר. **אל תמציא** — חלץ רק מה שמופיע בטקסט. שדה חסר → מחרוזת ריקה. ## פלט נדרש החזר JSON אחד (object), ללא markdown: { "cases": [ { "underlying_citation": "מראה-המקום המלא של הפסק כפי שמופיע, מילה במילה (למשל 'ערר 8018-02-22 הועדה המקומית בת ים נ' קבוצת מזרחי ובניו השקעות בע\\"מ'). השדה הקריטי.", "concept_tag": "הנושא/הכותרת שתחתיה מופיע הפריט (למשל 'היטל השבחה', 'הפקעות', 'פירוק שיתוף').", "headline_holding": "שורת-התקציר/הכותרת של הפריט — מה נקבע/השאלה (למשל 'חוסר וודאות בין תכנית קודמת לבין ההקלה').", "summary": "תקציר ניטרלי קצר אם יש פירוט נוסף בגוף; אחרת חזור על headline_holding.", "underlying_court": "הערכאה אם מצוינת (למשל 'בית המשפט המחוזי', 'ועדת ערר').", "practice_area": "אחד מ: 'rishuy_uvniya' / 'betterment_levy' / 'compensation_197' — אם ברור מהנושא; אחרת ריק.", "subject_tags": ["2-5 תגיות snake_case בעברית"] } ], "articles": [ { "title": "כותרת המאמר (למשל 'הפקעת קרקעות כיום - על המחוקק לתקן את העיוות שנוצר').", "authors": "שמות המחברים (למשל 'עו\\"ד צבי שוב, עו\\"ד רונית אלפר').", "summary": "2-4 משפטים: על מה המאמר ומה הטענה המרכזית.", "body": "הטקסט המלא של המאמר (כל הפסקאות), לצורך embedding וחיפוש-עומק.", "practice_area": "אחד מ-3 אם ברור; אחרת ריק.", "subject_tags": ["2-5 תגיות snake_case"] } ] } ## כללים 1. **underlying_citation** — חלץ במלואו ובדיוק; הוא הגשר לפסק. פריט-פסיקה בלי מראה-מקום ברור → דלג עליו. 2. **cases** — כל מצביעי-הפסיקה בעלון, גם אם תחת נושאים שונים. אל תאחד פריטים נפרדים. 3. **articles** — רק מאמרי-עומק (לא רשימת עדכונים). body = הטקסט המלא. 4. **עדכוני חקיקה/אישורי-תכניות/חוזרים — לא לחלץ כלל.** 5. אם אין מאמר או אין פסיקה — החזר מערך ריק לאותו מפתח. """ def _norm_str(d: dict, key: str) -> str: v = d.get(key) return v.strip() if isinstance(v, str) else "" def _norm_tags(d: dict) -> list[str]: tags = d.get("subject_tags") if not isinstance(tags, list): return [] return [str(t).strip() for t in tags if str(t).strip()][:8] def _norm_pa(d: dict) -> str: pa = _norm_str(d, "practice_area") return pa if pa in _VALID_PRACTICE_AREAS else "" async def split(raw_text: str, model: str | None = None) -> dict: """Return ``{"cases": [...], "articles": [...]}`` extracted from a bulletin. Empty lists on any failure (surfaced as a warning, never raised) so the batch keeps going. Each item is type-normalized; malformed items are dropped. """ from legal_mcp.services import claude_session text = (raw_text or "").strip() if not text: return {"cases": [], "articles": []} try: result = await claude_session.query_json( text, system=BULLETIN_SPLIT_PROMPT, model=(model or config.DIGEST_EXTRACT_MODEL or None), tools="", # pure text→JSON; disable tools (avoids error_max_turns) ) except Exception as e: # §6 — surfaced, not swallowed logger.warning("bulletin_splitter: query failed: %s", e) return {"cases": [], "articles": []} if not isinstance(result, dict): logger.warning("bulletin_splitter: expected dict, got %s", type(result).__name__) return {"cases": [], "articles": []} cases: list[dict] = [] for c in result.get("cases") or []: if not isinstance(c, dict): continue citation = _norm_str(c, "underlying_citation") if not citation: # rule 1: no anchor → skip continue cases.append({ "underlying_citation": citation, "concept_tag": _norm_str(c, "concept_tag"), "headline_holding": _norm_str(c, "headline_holding"), "summary": _norm_str(c, "summary") or _norm_str(c, "headline_holding"), "underlying_court": _norm_str(c, "underlying_court"), "practice_area": _norm_pa(c), "subject_tags": _norm_tags(c), }) articles: list[dict] = [] for a in result.get("articles") or []: if not isinstance(a, dict): continue title = _norm_str(a, "title") body = _norm_str(a, "body") if not (title or body): continue articles.append({ "title": title, "authors": _norm_str(a, "authors"), "summary": _norm_str(a, "summary"), "body": body, "practice_area": _norm_pa(a), "subject_tags": _norm_tags(a), }) return {"cases": cases, "articles": articles}