diff --git a/.claude/agents/legal-analyst.md b/.claude/agents/legal-analyst.md index 2418ea5..ccf3360 100644 --- a/.claude/agents/legal-analyst.md +++ b/.claude/agents/legal-analyst.md @@ -1,7 +1,7 @@ --- name: "legal-analyst" -description: "מנתח משפטי — חילוץ טענות, תשובות ותגובות מתיקי ערר, חיפוש תקדימים, ניתוח מסמכים" -model: "claude-sonnet-4-6" +description: "מנתח ומחקר משפטי — חילוץ טענות, ניתוח אסטרטגי, זיהוי חוזקות/חולשות, והפקת שאלות מחקר ממוקדות" +model: "claude-opus-4-6" tools: - Read - Bash @@ -22,15 +22,34 @@ tools: - mcp__legal-ai__processing_status --- -# מנתח משפטי — סוכן ניתוח תיקי ערר +# מנתח ומחקר משפטי — סוכן ניתוח אסטרטגי והפקת שאלות מחקר -אתה מנתח משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים. +אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות אסטרטגיה משפטית, ולהפיק שאלות מחקר ממוקדות. ## שפה עבוד תמיד בעברית. -## סוגי מסמכים — הבחנה קריטית +## תחומי התמחות + +הסוכן ממוקד בתחומים הבאים: +- חוק התכנון והבניה, התשכ"ה-1965 וכל התקנות שמכוחו +- חוק המקרקעין, התשכ"ט-1969 וכל התקנות שמכוחו +- התוספת השלישית לחוק התכנון והבניה (היטל השבחה) +- תקנות התכנון והבניה (חישוב שטחים, בקשה להיתר, סטיה ניכרת, היטל השבחה) +- תקנות המקרקעין (ניהול ורישום) +- חוקי תמ"א 38, פינוי ובינוי, והתחדשות עירונית +- ועדות ערר — תכנון ובניה והיטל השבחה (סמכות, הרכב, סדרי דין) + +## הבחנה קריטית — 3 סוגי פריטים מחולצים + +| סוג (claim_type) | מה זה | מי אמר | +|-------------------|--------|---------| +| **claim** | טענות — מה הצד טוען | בד"כ עוררים (appellant) | +| **response** | תשובות — מה עונים לטענה | בד"כ ועדה מקומית (committee) או משיבים | +| **reply** | תגובות — תשובות לתשובות | בד"כ מבקשת ההיתר (permit_applicant) | + +## סוגי מסמכים — מה לחלץ ומה לא | סוג מסמך | מה לחלץ | claim_type | |-----------|----------|------------| @@ -39,35 +58,154 @@ tools: | תגובה / השלמת טיעון | **תגובות** — תשובות לתשובות | reply | | פסיקה / תכנית / פרוטוקול / היתר | **אל תחלץ כלום** — מסמכי רקע בלבד | — | -## תהליך עבודה +## תהליך עבודה — 4 שלבים -### שלב 1: התמצאות +### שלב 1: קליטה וזיהוי 1. קרא פרטי התיק (`case_get`) 2. קרא רשימת מסמכים (`document_list`) -3. זהה אילו מסמכים רלוונטיים לחילוץ (רק כתבי ערר, תשובות, תגובות) +3. זהה: + - **סוג ההליך**: ערר תכנוני, ערר היטל השבחה, ערעור מנהלי וכד' + - **הערכאה/הגוף**: ועדת ערר מחוזית, בית משפט לעניינים מנהליים וכד' + - **הצדדים**: מי העורר, מי המשיב, מי צד ג' + - **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות (רק מהמסמכים) +4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים) +5. וודא שכל פריט מסווג ל-claim_type הנכון -### שלב 2: חילוץ -לכל מסמך רלוונטי: -1. קרא את הטקסט (`document_get_text`) -2. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type וparty_hint מתאימים) -3. וודא שכל פריט מסווג ל-claim_type הנכון +### שלב 2: ניתוח מעמיק +הצג במבנה הבא: -### שלב 3: ניתוח -1. חפש תקדימים רלוונטיים (`search_decisions`, `find_similar_cases`) -2. זהה נושאים מרכזיים שחוזרים +**צד מיוצג**: ועדת הערר (יו"ר — עו"ד דפנה תמיר). אנחנו צד ניטרלי שמכריע. -### שלב 4: דיווח — חובה! -**לפני שאתה מסיים, חובה לדווח:** -1. פרסם comment ב-Paperclip עם סיכום: - - כמה טענות, תשובות ותגובות חולצו (עם מספרים) - - הטענות המרכזיות של כל צד (3-5 טענות עיקריות) - - תקדימים שנמצאו +**רקע דיוני**: סוג ההליך, מספר תיק, תאריכים מרכזיים, היסטוריה דיונית, תכניות רלוונטיות. + +**עובדות מוסכמות**: רשימה של עובדות שאין עליהן מחלוקת. רק עובדות מהמסמכים. + +**עובדות שנויות במחלוקת**: רשימה של עובדות שהצדדים חלוקים לגביהן — פרט מה כל צד טוען. + +### שלב 3: טענות סף, סוגיות להכרעה ואסטרטגיה + +**טענות סף** (אם קיימות): +חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. אם אין — כתוב: "לא זוהו טענות סף." + +**סוגיות להכרעה** — לכל סוגיה מרכזית: +1. **כותרת הסוגיה** — ניסוח תמציתי ומדויק +2. **טענה (claim)** — מה העוררים טוענים, על מה מסתמכים +3. **תשובה (response)** — מה הוועדה/משיבים עונים +4. **תגובה (reply)** — מה המבקשת מגיבה (אם קיימת) +5. **ניתוח אסטרטגי**: + - **חוזקות** — מה חזק בכל צד? מה מבוסס היטב? + - **חולשות** — מה חלש? מה לא מגובה בראיות? + - **הזדמנויות** — איפה יש פתח? מה הוועדה יכולה להישען עליו? +6. **שאלות משפטיות** — צמד שאלות (ראה שלב 4) + +### שלב 4: הפקת שאלות מחקר + +לכל סוגיה (כולל טענות סף), נסח **בדיוק שתי שאלות מחקר**: + +**שאלה 1 — עקרונית (שאלת "האם")**: +בודקת עיקרון משפטי כללי בתחום התכנון והבניה. +דוגמה: "האם ועדת ערר רשאית להתערב בשיקול דעתה של ועדה מקומית בעניין הקלה מנספח בינוי מנחה?" + +**שאלה 2 — יישומית (שאלת "מהם"/"כיצד"/"באילו תנאים")**: +מיישמת את העיקרון על נסיבות המקרה. +דוגמה: "מהם המבחנים לאישור הקלה בגובה בניין כאשר נספח הבינוי מנחה ולא מחייב ויש התנגדות מהנדס העיר?" + +### כללים לשאלות מחקר +- ניתנות למחקר — אפשר למצוא תשובה בפסיקה, חקיקה, או ספרות +- צמודות לסוגיה ולנסיבות התיק — לא כלליות +- לא שאלות שהתשובה כבר במסמכי התיק +- **לא להמציא פסיקה** — אם יש אזכור במסמכי התיק, ניתן להתייחס. אם לא — נסח ללא הפניה +- שימוש במונחים מקובלים בפסיקה הישראלית (מתאים לחיפוש ב-nevo/law-mate) + +## שלב 5: חיפוש פנימי בקורפוס +חפש תקדימים רלוונטיים בקורפוס הפנימי: +- `search_decisions` — בהחלטות קודמות של דפנה +- `find_similar_cases` — תיקים דומים +הוסף תוצאות רלוונטיות תחת כל סוגיה כ-"תקדימים מהקורפוס הפנימי". + +## שלב 6: שמירה ודיווח — חובה! + +1. **שמור** את הפלט המלא: + ``` + {case_dir}/documents/research/analysis-and-research.md + ``` + +2. **פרסם comment** ב-Paperclip עם סיכום: + - כמה טענות, תשובות ותגובות חולצו + - הסוגיות המרכזיות (3-5 כותרות) + - כמה שאלות מחקר הופקו - המלצה לשלב הבא -2. עדכן סטטוס התיק (`case_update` עם status = documents_ready) -## כללים -- **נאמנות למקור** — כל טענה חייבת לשקף את מה שנכתב, לא לפרש -- **לא לחלץ מפסיקה** — פסקי דין הם מסמכי רקע, לא חומר לחילוץ -- **לא לחלץ מפרוטוקולים** — פרוטוקולים הם תיעוד, לא טענות -- **לא לחלץ מתכניות** — תכניות הן מסמכי רקע -- **גוף שלישי** — כל טענה בגוף שלישי גם אם המקור בגוף ראשון +3. **עדכן סטטוס** (`case_update` עם status = `documents_ready`) + +4. **שלח מייל**: + ```bash + python3 /home/chaim/legal-ai/scripts/notify.py \ + "ניתוח ומחקר הושלמו — ערר {case_number}" \ + "סיכום: X סוגיות זוהו, Y שאלות מחקר הופקו. נדרשת ביקורתך לפני המשך." + ``` + +## מבנה הפלט המלא — analysis-and-research.md + +```markdown +# ניתוח ומחקר משפטי — ערר {case_number} +תאריך: {date} + +## 1. צד מיוצג +ועדת הערר לתכנון ובניה, מחוז ירושלים (יו"ר: עו"ד דפנה תמיר) + +## 2. רקע דיוני +... + +## 3. עובדות מוסכמות +1. ... +2. ... + +## 4. עובדות שנויות במחלוקת +1. ... + +## 5. טענות סף +[אם קיימות — כולל שאלות משפטיות לכל טענה] + +## 6. סוגיות להכרעה + +### סוגיה 1: [כותרת] +**טענה (claim):** ... +**תשובה (response):** ... +**תגובה (reply):** ... + +**ניתוח אסטרטגי:** +- חוזקות: ... +- חולשות: ... +- הזדמנויות: ... + +**שאלות משפטיות:** +1. [שאלה עקרונית — "האם..."] +2. [שאלה יישומית — "מהם..."] + +**מילות מפתח לחיפוש:** +- nevo: "ביטוי" ו "ביטוי" ו "ועדת ערר" +- law-mate: מילה1 מילה2 מילה3 + +**חקיקה רלוונטית:** +- סעיף X לחוק... + +**תקדימים מהקורפוס הפנימי:** +- [אם נמצאו] + +--- + +### סוגיה 2: ... + +## 7. מסקנות +סיכום האסטרטגיה, נקודות חוזק, סיכונים, סדר עדיפויות. +``` + +## כללים קריטיים + +1. **נאמנות למקור** — כל טענה חייבת לשקף את מה שנכתב, לא לפרש +2. **לא לחלץ מפסיקה/פרוטוקולים/תכניות** — אלה מסמכי רקע בלבד +3. **גוף שלישי** — כל טענה בגוף שלישי גם אם המקור בגוף ראשון +4. **לא להמציא** — לא פסיקה, לא ציטוטים, לא מספרי תיקים שלא מופיעים במסמכים +5. **שאלות מחקר הן התוצר המרכזי** — הקדש להן תשומת לב מיוחדת +6. **אם חסר מידע** — ציין במפורש ובקש להעלות מסמכים נוספים diff --git a/.claude/agents/legal-exporter.md b/.claude/agents/legal-exporter.md index 531c4cb..1ec3160 100644 --- a/.claude/agents/legal-exporter.md +++ b/.claude/agents/legal-exporter.md @@ -58,7 +58,7 @@ tools: 3. אם הסקריפט `create-legal-doc.js` מתאים יותר (למשל לעיצוב מותאם) — השתמש בו ### שלב 4: שמירה מגורסת -1. צור תיקייה `~/legal-ai/data/exports/{מספר-ערר}/` (אם לא קיימת) +1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת) 2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-V`) 3. שמור כ-`טיוטה-V{N}.docx` כאשר N = המספר הבא בתור - אם אין טיוטות: `טיוטה-V1.docx` diff --git a/CLAUDE.md b/CLAUDE.md index 65d7b46..9015ffe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,8 +79,8 @@ │ └── docx/ עיצוב DOCX ├── data/ │ ├── training/ ← 4 החלטות לאימון (DOCX) -│ ├── uploads/ ← קבצים מ-web UI -│ └── cases/{new,in-progress,completed}/ ← תיקי עררים +│ ├── exports/ ← ייצוא legacy (תיקים ישנים) +│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB) ├── web/ ← UI + API + integration clients ├── mcp-server/ ← MCP server + services + tools └── scripts/ ← סקריפטים וכלי עזר diff --git a/docs/decision-block-mapping.md b/docs/decision-block-mapping.md index f6709ec..7453484 100644 --- a/docs/decision-block-mapping.md +++ b/docs/decision-block-mapping.md @@ -31,7 +31,7 @@ ## 2. בית הכרם 1126/25+1141/25 (דפנה תמיר, קבלה חלקית, רישוי) -**מקור:** data/uploads/ARAR-25-08-1126.docx (גרסה סופית מנבו) +**מקור:** data/training/תמא 38-בית הכרם-1126+1141-החלטה.docx (גרסה סופית מנבו) **שורות:** 183 | **מילים:** 6,249 | שורות | בלוק | תוכן | הערות | @@ -67,7 +67,7 @@ ## 3. אריאלי 1078+1083/24 (שרית אריאלי, קבלה, רישוי) -**מקור:** data/uploads/ARAR-24-1078-44.docx (גרסה מנבו) +**מקור:** data/training/ (legacy — גרסה מנבו, לא בקורפוס הנוכחי) **שורות:** 171 | **מילים:** 10,748 | שורות | בלוק | תוכן | הערות | diff --git a/mcp-server/src/legal_mcp/config.py b/mcp-server/src/legal_mcp/config.py index a224388..5a82648 100644 --- a/mcp-server/src/legal_mcp/config.py +++ b/mcp-server/src/legal_mcp/config.py @@ -53,28 +53,15 @@ GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "") # Data directory DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data"))) TRAINING_DIR = DATA_DIR / "training" -EXPORTS_DIR = DATA_DIR / "exports" +EXPORTS_DIR = DATA_DIR / "exports" # legacy exports only -# Cases directory — new structure: cases/{new,in-progress,completed}/{case_number}/ -CASES_BASE = Path(os.environ.get("CASES_BASE", str(Path.home() / "legal-ai" / "cases"))) -CASES_NEW = CASES_BASE / "new" -CASES_IN_PROGRESS = CASES_BASE / "in-progress" -CASES_COMPLETED = CASES_BASE / "completed" -CASES_DIR = CASES_NEW # backwards compatibility — new cases default here - -_STATUS_DIRS = [CASES_NEW, CASES_IN_PROGRESS, CASES_COMPLETED] +# Cases directory — flat structure: data/cases/{case_number}/ +CASES_DIR = DATA_DIR / "cases" def find_case_dir(case_number: str) -> Path: - """Find a case directory across all status folders. - - Returns the existing directory, or defaults to CASES_NEW/{case_number}. - """ - for base in _STATUS_DIRS: - candidate = base / case_number - if candidate.exists(): - return candidate - return CASES_NEW / case_number + """Return the case directory for a given case number.""" + return CASES_DIR / case_number # Chunking parameters CHUNK_SIZE_TOKENS = 600 diff --git a/mcp-server/src/legal_mcp/services/docx_exporter.py b/mcp-server/src/legal_mcp/services/docx_exporter.py index c6cc2a7..4dd0d4d 100644 --- a/mcp-server/src/legal_mcp/services/docx_exporter.py +++ b/mcp-server/src/legal_mcp/services/docx_exporter.py @@ -169,9 +169,9 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str: _write_block_to_docx(doc, block_id, block["title"], content) - # Determine output path — versioned under data/exports/{case_number}/ + # Determine output path — versioned under cases/{case_number}/exports/ if not output_path: - export_dir = config.EXPORTS_DIR / case["case_number"] + export_dir = config.find_case_dir(case["case_number"]) / "exports" export_dir.mkdir(parents=True, exist_ok=True) # Find next version number existing = sorted(export_dir.glob("טיוטה-v*.docx")) diff --git a/mcp-server/src/legal_mcp/services/lessons.py b/mcp-server/src/legal_mcp/services/lessons.py index e14b921..1868945 100644 --- a/mcp-server/src/legal_mcp/services/lessons.py +++ b/mcp-server/src/legal_mcp/services/lessons.py @@ -1,6 +1,6 @@ """Lessons learned from comparing AI drafts to Dafna Tamir's final decisions. -Source: /data/uploads/לקחים-לעדכון-שרת-כתיבת-החלטות.md +Source: docs/legal-decision-lessons.md Based on analysis of: Hecht 1180-1181 (rejection) and Beit HaKerem 1126/25+1141/25 (partial acceptance). """ diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index 421864a..e468747 100644 --- a/mcp-server/src/legal_mcp/tools/documents.py +++ b/mcp-server/src/legal_mcp/tools/documents.py @@ -39,7 +39,7 @@ async def document_upload( title = source.stem # Copy file to case directory - case_dir = config.find_case_dir(case_number) / "documents" + case_dir = config.find_case_dir(case_number) / "documents" / "originals" case_dir.mkdir(parents=True, exist_ok=True) dest = case_dir / source.name shutil.copy2(str(source), str(dest)) diff --git a/scripts/benchmark_embeddings.py b/scripts/benchmark_embeddings.py new file mode 100644 index 0000000..b2a68b2 --- /dev/null +++ b/scripts/benchmark_embeddings.py @@ -0,0 +1,232 @@ +"""Benchmark embedding models on case 1130-25 documents. + +Compares voyage-3-large (current), voyage-4-large, and voyage-law-2 +on Hebrew legal text retrieval quality, timing, and cost. +""" + +import json +import os +import time +import sys +from pathlib import Path + +import voyageai + +API_KEY = os.environ.get("VOYAGE_API_KEY", "pa-qbfhBDxW0tVtgzr_abMyw_AJO2gli9w3nnqyHuQOW-e") +client = voyageai.Client(api_key=API_KEY) + +MODELS = [ + "voyage-3-large", # current + "voyage-4-large", # upgrade candidate + "voyage-law-2", # legal specialist +] + +# Pricing per 1M tokens (from Voyage AI docs) +PRICING = { + "voyage-3-large": 0.06, + "voyage-4-large": 0.12, + "voyage-law-2": 0.12, +} + +DOCS_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents") + +DOCUMENTS = { + "כתב ערר קובר": DOCS_DIR / "2025-08-14-כתב-ערר-קובר.md", + "כתב ערר מטמון": DOCS_DIR / "2025-10-22-כתב-ערר-מטמון.md", + "תשובת ועדת הראל": DOCS_DIR / "2025-09-02-כתב-תשובה-ועדת-הראל-לערר.md", + "תשובת ליבמן": DOCS_DIR / "2025-09-01-כתב-תשובה-ליבמן-לערר.md", +} + +# Test queries — real questions a judge would ask about this case +QUERIES = [ + "מהי הטענה המרכזית של העוררים בנוגע לחניה?", + "מה עמדת הוועדה המקומית לגבי התכנית?", + "האם יש פגיעה בזכויות הבנייה של השכנים?", + "מהם התנאים שנקבעו בהיתר הבנייה?", + "האם התכנית עומדת בתקן החניה?", + "מה טענות המשיבים לגבי הגובה והצפיפות?", + "האם נערך שימוע כדין לפני מתן ההחלטה?", + "מהם הנימוקים לאישור התכנית על ידי הוועדה המקומית?", +] + + +def chunk_text(text: str, chunk_size: int = 600, overlap: int = 100) -> list[str]: + """Simple word-based chunking.""" + words = text.split() + chunks = [] + i = 0 + while i < len(words): + chunk = " ".join(words[i:i + chunk_size]) + chunks.append(chunk) + i += chunk_size - overlap + return chunks + + +def cosine_sim(a: list[float], b: list[float]) -> float: + dot = sum(x * y for x, y in zip(a, b)) + norm_a = sum(x * x for x in a) ** 0.5 + norm_b = sum(x * x for x in b) ** 0.5 + return dot / (norm_a * norm_b) if norm_a and norm_b else 0.0 + + +def main(): + # Load and chunk documents + print("=" * 70) + print("Loading and chunking documents...") + print("=" * 70) + + all_chunks = [] # (doc_name, chunk_index, text) + for doc_name, doc_path in DOCUMENTS.items(): + text = doc_path.read_text(encoding="utf-8") + chunks = chunk_text(text) + for i, chunk in enumerate(chunks): + all_chunks.append((doc_name, i, chunk)) + print(f" {doc_name}: {len(text):,} chars, {len(text.split()):,} words -> {len(chunks)} chunks") + + chunk_texts = [c[2] for c in all_chunks] + total_chunks = len(chunk_texts) + print(f"\nTotal: {total_chunks} chunks") + + # Estimate tokens (rough: 1 Hebrew word ~ 2-3 tokens) + total_words = sum(len(t.split()) for t in chunk_texts) + est_tokens_docs = int(total_words * 2.5) + total_query_words = sum(len(q.split()) for q in QUERIES) + est_tokens_queries = int(total_query_words * 2.5) + + print(f"Estimated tokens per model: ~{est_tokens_docs:,} (docs) + ~{est_tokens_queries:,} (queries)") + + results = {} + + for model in MODELS: + print(f"\n{'=' * 70}") + print(f"Model: {model}") + print(f"{'=' * 70}") + + # Embed documents + print(f" Embedding {total_chunks} chunks...") + t0 = time.time() + doc_embeddings = client.embed( + chunk_texts, + model=model, + input_type="document", + ) + doc_time = time.time() - t0 + doc_usage = doc_embeddings.total_tokens + doc_embs = doc_embeddings.embeddings + print(f" Done in {doc_time:.1f}s — {doc_usage:,} tokens used") + + # Embed queries + print(f" Embedding {len(QUERIES)} queries...") + t0 = time.time() + query_embeddings = client.embed( + QUERIES, + model=model, + input_type="query", + ) + query_time = time.time() - t0 + query_usage = query_embeddings.total_tokens + query_embs = query_embeddings.embeddings + print(f" Done in {query_time:.1f}s — {query_usage:,} tokens used") + + total_tokens = doc_usage + query_usage + cost = total_tokens / 1_000_000 * PRICING[model] + + # Search: for each query, rank chunks by similarity + print(f"\n Search results:") + query_results = [] + for qi, query in enumerate(QUERIES): + scores = [] + for ci, doc_emb in enumerate(doc_embs): + sim = cosine_sim(query_embs[qi], doc_emb) + scores.append((sim, all_chunks[ci][0], all_chunks[ci][1], all_chunks[ci][2][:80])) + scores.sort(reverse=True) + top5 = scores[:5] + query_results.append({ + "query": query, + "top5": [(s[0], s[1], s[2], s[3]) for s in top5], + }) + print(f"\n Q{qi+1}: {query}") + for rank, (score, doc_name, chunk_idx, preview) in enumerate(top5): + print(f" #{rank+1} [{score:.4f}] {doc_name} (chunk {chunk_idx}): {preview}...") + + results[model] = { + "doc_time": doc_time, + "query_time": query_time, + "doc_tokens": doc_usage, + "query_tokens": query_usage, + "total_tokens": total_tokens, + "cost_usd": cost, + "dimensions": len(doc_embs[0]), + "query_results": query_results, + } + + # Summary comparison + print(f"\n{'=' * 70}") + print("SUMMARY") + print(f"{'=' * 70}") + print(f"\n{'Model':<25} {'Tokens':>10} {'Time':>8} {'Cost':>10} {'Dims':>6}") + print("-" * 65) + for model in MODELS: + r = results[model] + print(f"{model:<25} {r['total_tokens']:>10,} {r['doc_time']+r['query_time']:>7.1f}s ${r['cost_usd']:>8.5f} {r['dimensions']:>6}") + + # Compare top-1 agreement between models + print(f"\n{'=' * 70}") + print("TOP-1 AGREEMENT (which doc is ranked #1 for each query)") + print(f"{'=' * 70}") + print(f"\n{'Query':<50}", end="") + for model in MODELS: + print(f" {model.split('-')[-1]:>10}", end="") + print() + print("-" * 85) + + for qi, query in enumerate(QUERIES): + short_q = query[:48] + print(f"{short_q:<50}", end="") + for model in MODELS: + top1_doc = results[model]["query_results"][qi]["top5"][0][1] + # Shorten doc name + short_doc = top1_doc[:10] + print(f" {short_doc:>10}", end="") + print() + + # Score distribution comparison + print(f"\n{'=' * 70}") + print("AVERAGE TOP-5 SCORES PER MODEL") + print(f"{'=' * 70}") + for model in MODELS: + all_top5_scores = [] + for qr in results[model]["query_results"]: + for score, _, _, _ in qr["top5"]: + all_top5_scores.append(score) + avg = sum(all_top5_scores) / len(all_top5_scores) + top1_scores = [qr["top5"][0][0] for qr in results[model]["query_results"]] + avg_top1 = sum(top1_scores) / len(top1_scores) + print(f" {model:<25} avg top-1: {avg_top1:.4f} avg top-5: {avg:.4f}") + + # Save full results + output_path = Path("/home/chaim/legal-ai/data/benchmark-embeddings.json") + serializable = {} + for model, r in results.items(): + serializable[model] = { + "doc_time": r["doc_time"], + "query_time": r["query_time"], + "doc_tokens": r["doc_tokens"], + "query_tokens": r["query_tokens"], + "total_tokens": r["total_tokens"], + "cost_usd": r["cost_usd"], + "dimensions": r["dimensions"], + "queries": [ + { + "query": qr["query"], + "top5": [{"score": s, "doc": d, "chunk": c, "preview": p} for s, d, c, p in qr["top5"]], + } + for qr in r["query_results"] + ], + } + output_path.write_text(json.dumps(serializable, ensure_ascii=False, indent=2)) + print(f"\nFull results saved to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmark_new_vs_old.py b/scripts/benchmark_new_vs_old.py new file mode 100644 index 0000000..ea0f5e4 --- /dev/null +++ b/scripts/benchmark_new_vs_old.py @@ -0,0 +1,203 @@ +"""Compare Google Vision extractions vs existing MDs, then benchmark voyage-law-2.""" + +import json +import time +from pathlib import Path + +import voyageai + +API_KEY = "pa-qbfhBDxW0tVtgzr_abMyw_AJO2gli9w3nnqyHuQOW-e" +client = voyageai.Client(api_key=API_KEY) +MODEL = "voyage-law-2" + +DOCS_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents") +GOOGLE_DIR = DOCS_DIR / "extracted" + +# Map new (Google Vision) files to existing MDs +PAIRS = [ + ("מרק קובר-כתב ערר.md", "2025-08-14-כתב-ערר-קובר.md"), + ("תשובה לערר מטעם המשיבים.md", "2025-09-01-כתב-תשובה-ליבמן-לערר.md"), + ("תשובת הועדה המרחבית לערר.md", "2025-09-02-כתב-תשובה-ועדת-הראל-לערר.md"), + ("תשובת המשיב-יצחק מטמון.md", "2025-10-22-כתב-ערר-מטמון.md"), + ("השלמת טיעון מטעם משיבים 2-3.md", "2025-12-23-השלמת-טיעון-ליבמן.md"), + ("תשובה מטעם העורר להשלמת טיעון.md", "2025-12-08-תגובת-קובר-לבקשת-השלמת-טיעון.md"), + ("בקשה להשלמת טיעון ממשיבים 2-3.md", "2025-12-03-בקשה-להשלמת-טיעון-ליבמן.md"), + ("השלמת טיעון מטעם הוועדה המקומית.md", "2026-02-04-השלמת-טיעון-ועדת-הראל.md"), + ("תגובת העורר לתשובת ועדת הראל להשלמת הטיעון ערר.md", "2026-02-10-תגובת-קובר-להשלמת-טיעון-הראל.md"), + ("כתב תשובה-השלמת טיעון מטעם המשיב יצחק מטמון.md", "2026-02-12-כתב-תשובה-השלמת-טיעון-מטמון.md"), + ("בקשת העורר לדחיית השלמת הטיעון במלואה.md", "2026-01-13-תגובת-קובר-לדחיית-השלמת-טיעון.md"), + ("1130-25-החלטה לתיקון פרוטוקול.md", "2025-11-27-החלטה-לתיקון-פרוטוקול.md"), + ("החלטת ביניים 1130-25.md", "2025-12-31-החלטת-ביניים.md"), + ("1130-25-פרוטוקול ועדת ערר והחלטה.md", "2025-10-27-פרוטוקול-דיון-ועדת-ערר.md"), + ("פרוטוקול ועדה מקומית לדיון בתכנית 152-1257682.md", "2025-07-23-פרוטוקול-ועדה-מקומית-הראל.md"), +] + +QUERIES = [ + "מהי הטענה המרכזית של העוררים בנוגע לחניה?", + "מה עמדת הוועדה המקומית לגבי התכנית?", + "האם יש פגיעה בזכויות הבנייה של השכנים?", + "מהם התנאים שנקבעו בהיתר הבנייה?", + "האם התכנית עומדת בתקן החניה?", + "מה טענות המשיבים לגבי הגובה והצפיפות?", + "האם נערך שימוע כדין לפני מתן ההחלטה?", + "מהם הנימוקים לאישור התכנית על ידי הוועדה המקומית?", +] + + +def cosine_sim(a, b): + dot = sum(x * y for x, y in zip(a, b)) + na = sum(x * x for x in a) ** 0.5 + nb = sum(x * x for x in b) ** 0.5 + return dot / (na * nb) if na and nb else 0.0 + + +def chunk_text(text, chunk_size=600, overlap=100): + words = text.split() + chunks = [] + i = 0 + while i < len(words): + chunks.append(" ".join(words[i:i + chunk_size])) + i += chunk_size - overlap + return chunks + + +def word_overlap(a, b): + wa, wb = set(a.split()), set(b.split()) + if not wa or not wb: + return 0.0 + return len(wa & wb) / max(len(wa), len(wb)) + + +def main(): + # ── Part 1: Document comparison ── + print("=" * 70) + print("PART 1: DOCUMENT COMPARISON (Google Vision vs Existing)") + print("=" * 70) + + comparison_results = [] + all_new_chunks = [] + all_old_chunks = [] + + for new_name, old_name in PAIRS: + new_path = GOOGLE_DIR / new_name + old_path = DOCS_DIR / old_name + + if not new_path.exists(): + continue + if not old_path.exists(): + print(f" SKIP (no existing): {old_name}") + continue + + new_text = new_path.read_text(encoding="utf-8") + old_text = old_path.read_text(encoding="utf-8") + + new_words = len(new_text.split()) + old_words = len(old_text.split()) + overlap = word_overlap(new_text, old_text) + + short_name = old_name[:40] + diff = new_words - old_words + diff_pct = (diff / old_words * 100) if old_words else 0 + + comparison_results.append({ + "name": short_name, + "old_words": old_words, + "new_words": new_words, + "diff": diff, + "diff_pct": diff_pct, + "overlap": overlap, + }) + + # Chunk for embedding + new_chunks = chunk_text(new_text) + old_chunks = chunk_text(old_text) + for i, c in enumerate(new_chunks): + all_new_chunks.append((short_name, i, c)) + for i, c in enumerate(old_chunks): + all_old_chunks.append((short_name, i, c)) + + print(f"\n{'Document':<42} {'Old':>6} {'New':>6} {'Diff':>8} {'Overlap':>8}") + print("-" * 72) + for r in comparison_results: + print(f" {r['name']:<40} {r['old_words']:>6} {r['new_words']:>6} {r['diff']:>+7} ({r['diff_pct']:>+.0f}%) {r['overlap']:>7.0%}") + + # ── Part 2: Embedding benchmark ── + print(f"\n{'=' * 70}") + print("PART 2: VOYAGE-LAW-2 EMBEDDING BENCHMARK") + print(f"{'=' * 70}") + + new_texts = [c[2] for c in all_new_chunks] + old_texts = [c[2] for c in all_old_chunks] + + print(f"\nNew chunks: {len(new_texts)}, Old chunks: {len(old_texts)}") + + def embed_batched(texts, label): + BATCH = 20 + all_embs = [] + total_tokens = 0 + t0 = time.time() + for i in range(0, len(texts), BATCH): + batch = texts[i:i+BATCH] + result = client.embed(batch, model=MODEL, input_type="document") + all_embs.extend(result.embeddings) + total_tokens += result.total_tokens + elapsed = time.time() - t0 + print(f" {label}: {len(texts)} chunks, {total_tokens:,} tokens, {elapsed:.1f}s") + return all_embs, total_tokens, elapsed + + # Embed new + print("Embedding NEW (Google Vision) chunks...") + new_embs, new_tokens, new_time = embed_batched(new_texts, "NEW") + + # Embed old + print("Embedding OLD (existing) chunks...") + old_embs, old_tokens, old_time = embed_batched(old_texts, "OLD") + + # Embed queries + print(f"Embedding {len(QUERIES)} queries...") + q_result = client.embed(QUERIES, model=MODEL, input_type="query") + q_embs = q_result.embeddings + + # Search and compare + print(f"\n{'=' * 70}") + print("PART 3: SEARCH QUALITY COMPARISON") + print(f"{'=' * 70}") + + for qi, query in enumerate(QUERIES): + # Score against new + new_scores = [(cosine_sim(q_embs[qi], e), all_new_chunks[i][0], all_new_chunks[i][2][:60]) for i, e in enumerate(new_embs)] + new_scores.sort(reverse=True) + + # Score against old + old_scores = [(cosine_sim(q_embs[qi], e), all_old_chunks[i][0], all_old_chunks[i][2][:60]) for i, e in enumerate(old_embs)] + old_scores.sort(reverse=True) + + print(f"\nQ{qi+1}: {query}") + print(f" {'NEW top-1':>10}: [{new_scores[0][0]:.4f}] {new_scores[0][1]}") + print(f" {'OLD top-1':>10}: [{old_scores[0][0]:.4f}] {old_scores[0][1]}") + if new_scores[0][0] > old_scores[0][0]: + print(f" >> NEW better by {new_scores[0][0] - old_scores[0][0]:.4f}") + else: + print(f" >> OLD better by {old_scores[0][0] - new_scores[0][0]:.4f}") + + # Summary + new_avg = sum(max(cosine_sim(q_embs[qi], e) for e in new_embs) for qi in range(len(QUERIES))) / len(QUERIES) + old_avg = sum(max(cosine_sim(q_embs[qi], e) for e in old_embs) for qi in range(len(QUERIES))) / len(QUERIES) + + print(f"\n{'=' * 70}") + print("SUMMARY") + print(f"{'=' * 70}") + print(f" {'Metric':<30} {'Old (existing)':>15} {'New (Google Vision)':>20}") + print(f" {'-' * 65}") + print(f" {'Total chunks':<30} {len(old_texts):>15} {len(new_texts):>20}") + print(f" {'Total tokens':<30} {old_tokens:>15,} {new_tokens:>20,}") + print(f" {'Embed time':<30} {old_time:>14.1f}s {new_time:>19.1f}s") + print(f" {'Avg top-1 score':<30} {old_avg:>15.4f} {new_avg:>20.4f}") + print(f" {'Score difference':<30} {'':>15} {new_avg - old_avg:>+20.4f}") + + est_cost = (new_tokens + old_tokens) / 1_000_000 * 0.12 + print(f"\n Embedding cost: ${est_cost:.3f}") + + +if __name__ == "__main__": + main() diff --git a/scripts/compare_extractions.py b/scripts/compare_extractions.py new file mode 100644 index 0000000..3c639cb --- /dev/null +++ b/scripts/compare_extractions.py @@ -0,0 +1,126 @@ +"""Compare existing MD files with freshly extracted text from PDFs.""" + +import difflib +from pathlib import Path + +DOCS_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents") +EXTRACTED_DIR = DOCS_DIR / "extracted" + +# Map: existing MD -> extracted MD +PAIRS = [ + ("2025-08-14-כתב-ערר-קובר.md", "מרק קובר-כתב ערר.md", "Appeal - Kuber"), + ("2025-09-01-כתב-תשובה-ליבמן-לערר.md", "תשובה לערר מטעם המשיבים.md", "Response - Livman"), + ("2025-09-02-כתב-תשובה-ועדת-הראל-לערר.md", "תשובת הועדה המרחבית לערר.md", "Response - Committee"), + ("2025-10-22-כתב-ערר-מטמון.md", "תשובת המשיב-יצחק מטמון.md", "Response - Matmon"), +] + + +def normalize(text: str) -> str: + """Normalize text for comparison.""" + # Remove markdown formatting, extra whitespace + lines = text.strip().split("\n") + lines = [l.strip() for l in lines if l.strip()] + return "\n".join(lines) + + +def word_overlap(a: str, b: str) -> float: + """Calculate word-level overlap ratio.""" + words_a = set(a.split()) + words_b = set(b.split()) + if not words_a or not words_b: + return 0.0 + intersection = words_a & words_b + return len(intersection) / max(len(words_a), len(words_b)) + + +def main(): + print(f"{'=' * 70}") + print("COMPARISON: Existing MD vs Fresh PDF Extraction") + print(f"{'=' * 70}\n") + + summary = [] + + for existing_name, extracted_name, label in PAIRS: + existing_path = DOCS_DIR / existing_name + extracted_path = EXTRACTED_DIR / extracted_name + + if not existing_path.exists(): + print(f"SKIP: {existing_name} not found") + continue + if not extracted_path.exists(): + print(f"SKIP: {extracted_name} not found") + continue + + existing_text = existing_path.read_text(encoding="utf-8") + extracted_text = extracted_path.read_text(encoding="utf-8") + + existing_norm = normalize(existing_text) + extracted_norm = normalize(extracted_text) + + # Stats + existing_chars = len(existing_text) + extracted_chars = len(extracted_text) + existing_words = len(existing_text.split()) + extracted_words = len(extracted_text.split()) + + # Similarity + overlap = word_overlap(existing_norm, extracted_norm) + + # Sequence matcher ratio (slower but more accurate) + # Use first 5000 chars for speed + sm = difflib.SequenceMatcher(None, existing_norm[:5000], extracted_norm[:5000]) + seq_ratio = sm.ratio() + + # Find lines in extracted but not in existing (new content) + existing_lines = set(existing_norm.split("\n")) + extracted_lines = set(extracted_norm.split("\n")) + new_lines = extracted_lines - existing_lines + missing_lines = existing_lines - extracted_lines + + print(f"{'=' * 70}") + print(f" {label}") + print(f" Existing: {existing_name}") + print(f" Extracted: {extracted_name}") + print(f"{'=' * 70}") + print(f" {'Metric':<30} {'Existing MD':>15} {'Fresh PDF':>15} {'Diff':>10}") + print(f" {'-' * 70}") + print(f" {'Characters':<30} {existing_chars:>15,} {extracted_chars:>15,} {extracted_chars - existing_chars:>+10,}") + print(f" {'Words':<30} {existing_words:>15,} {extracted_words:>15,} {extracted_words - existing_words:>+10,}") + print(f" {'Lines':<30} {len(existing_lines):>15,} {len(extracted_lines):>15,} {len(extracted_lines) - len(existing_lines):>+10,}") + print(f" {'Word overlap':<30} {overlap:>15.1%}") + print(f" {'Sequence similarity':<30} {seq_ratio:>15.1%}") + print(f" {'Lines only in fresh PDF':<30} {len(new_lines):>15}") + print(f" {'Lines only in existing MD':<30} {len(missing_lines):>15}") + + # Show sample differences + if new_lines: + print(f"\n Sample lines ONLY in fresh extraction (first 3):") + for line in sorted(new_lines)[:3]: + print(f" + {line[:100]}") + if missing_lines: + print(f"\n Sample lines ONLY in existing MD (first 3):") + for line in sorted(missing_lines)[:3]: + print(f" - {line[:100]}") + + print() + + summary.append({ + "label": label, + "existing_words": existing_words, + "extracted_words": extracted_words, + "word_overlap": overlap, + "seq_similarity": seq_ratio, + }) + + # Summary table + print(f"\n{'=' * 70}") + print("SUMMARY") + print(f"{'=' * 70}") + print(f" {'Document':<25} {'Existing':>10} {'Fresh':>10} {'Overlap':>10} {'Similarity':>12}") + print(f" {'-' * 67}") + for s in summary: + print(f" {s['label']:<25} {s['existing_words']:>10,} {s['extracted_words']:>10,} {s['word_overlap']:>10.1%} {s['seq_similarity']:>12.1%}") + + +if __name__ == "__main__": + main() diff --git a/scripts/extract_all_google_vision.py b/scripts/extract_all_google_vision.py new file mode 100644 index 0000000..844c1d7 --- /dev/null +++ b/scripts/extract_all_google_vision.py @@ -0,0 +1,128 @@ +"""Extract ALL PDFs from originals using Google Cloud Vision OCR. +Forces OCR on all pages (ignoring broken text layers). +Then runs voyage-law-2 embedding benchmark comparing old vs new. +""" + +import asyncio +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src")) + +from dotenv import load_dotenv +load_dotenv(Path.home() / ".env") + +import fitz +from google.cloud import vision +from legal_mcp import config + +API_KEY = config.GOOGLE_CLOUD_VISION_API_KEY +client = vision.ImageAnnotatorClient(client_options={"api_key": API_KEY}) + +ORIGINALS_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/originals") +OUTPUT_DIR = ORIGINALS_DIR.parent / "extracted" + +# Hebrew abbreviation quote fixer +import re +_ABBREV_FIXES = { + 'עוהייד': 'עוה"ד', 'עוייד': 'עו"ד', 'הנייל': 'הנ"ל', + 'מצייב': 'מצ"ב', 'ביהמייש': 'ביהמ"ש', 'תייז': 'ת"ז', + 'עייי': 'ע"י', 'אחייכ': 'אח"כ', 'סייק': 'ס"ק', + 'דייר': 'ד"ר', 'כדוייח': 'כדו"ח', 'חווייד': 'חוו"ד', + 'מייר': 'מ"ר', 'יחייד': 'יח"ד', 'בייכ': 'ב"כ', +} +_ABBREV_PAT = re.compile('|'.join(re.escape(k) for k in sorted(_ABBREV_FIXES, key=len, reverse=True))) + +def fix_quotes(text): + return _ABBREV_PAT.sub(lambda m: _ABBREV_FIXES[m.group()], text) + + +def ocr_page(image_bytes, page_num): + image = vision.Image(content=image_bytes) + response = client.document_text_detection( + image=image, + image_context=vision.ImageContext(language_hints=["he"]), + ) + if response.error.message: + print(f" ERROR page {page_num}: {response.error.message}") + return "" + text = response.full_text_annotation.text if response.full_text_annotation else "" + return fix_quotes(text) + + +def process_pdf(pdf_path): + doc = fitz.open(str(pdf_path)) + page_count = len(doc) + pages_text = [] + t0 = time.time() + + for i in range(page_count): + page = doc[i] + pix = page.get_pixmap(dpi=300) + img_bytes = pix.tobytes("png") + + pt = time.time() + text = ocr_page(img_bytes, i + 1) + elapsed = time.time() - pt + pages_text.append(text) + print(f" Page {i+1}/{page_count}: {len(text):,} chars, {elapsed:.1f}s") + + doc.close() + total_time = time.time() - t0 + return "\n\n".join(pages_text), page_count, total_time + + +def main(): + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + pdfs = sorted(ORIGINALS_DIR.glob("*.pdf")) + print(f"Found {len(pdfs)} PDFs\n") + + results = [] + total_pages = 0 + total_time = 0.0 + + for pdf in pdfs: + out_file = OUTPUT_DIR / f"{pdf.stem}.md" + + # Skip already extracted + if out_file.exists() and out_file.stat().st_size > 100: + text = out_file.read_text(encoding="utf-8") + doc = fitz.open(str(pdf)) + pages = len(doc) + doc.close() + print(f"SKIP (exists): {pdf.name} ({pages} pages, {len(text):,} chars)") + results.append({"name": pdf.stem, "pages": pages, "chars": len(text), "words": len(text.split()), "time": 0, "skipped": True}) + total_pages += pages + continue + + print(f"{'=' * 60}") + print(f" {pdf.name} ({pdf.stat().st_size:,} bytes)") + + text, pages, elapsed = process_pdf(pdf) + total_pages += pages + total_time += elapsed + + out_file.write_text(text, encoding="utf-8") + + words = len(text.split()) + print(f" Result: {pages} pages, {len(text):,} chars, {words:,} words, {elapsed:.1f}s") + print(f" Saved: {out_file.name}\n") + + results.append({"name": pdf.stem, "pages": pages, "chars": len(text), "words": words, "time": elapsed, "skipped": False}) + + print(f"\n{'=' * 60}") + print(f"TOTAL: {len(pdfs)} docs, {total_pages} pages, {total_time:.1f}s") + est_cost = total_pages * 0.0015 + print(f"Estimated cost: ${est_cost:.2f}") + + # Save results + Path("/home/chaim/legal-ai/data/google-vision-extraction.json").write_text( + json.dumps(results, ensure_ascii=False, indent=2) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/extract_google_vision.py b/scripts/extract_google_vision.py new file mode 100644 index 0000000..5679654 --- /dev/null +++ b/scripts/extract_google_vision.py @@ -0,0 +1,66 @@ +"""Extract text from PDF using Google Cloud Vision API.""" + +import io +import time +from pathlib import Path + +import fitz # PyMuPDF for rendering pages to images +from google.cloud import vision + +API_KEY = "AIzaSyDZgUsxsy_FHkkREU7R_oQLJALU3_V26j8" + +PDF_PATH = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/originals/מרק קובר-כתב ערר.pdf") +OUTPUT_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/extracted") + + +def main(): + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + client = vision.ImageAnnotatorClient( + client_options={"api_key": API_KEY} + ) + + doc = fitz.open(str(PDF_PATH)) + page_count = len(doc) + print(f"Processing: {PDF_PATH.name} ({page_count} pages)\n") + + pages_text = [] + total_time = 0.0 + + for i in range(page_count): + page = doc[i] + pix = page.get_pixmap(dpi=300) + img_bytes = pix.tobytes("png") + + image = vision.Image(content=img_bytes) + + print(f" Page {i+1}/{page_count}...", end=" ", flush=True) + t0 = time.time() + response = client.document_text_detection( + image=image, + image_context={"language_hints": ["he"]} + ) + elapsed = time.time() - t0 + total_time += elapsed + + if response.error.message: + print(f"ERROR: {response.error.message}") + pages_text.append("") + continue + + text = response.full_text_annotation.text if response.full_text_annotation else "" + pages_text.append(text) + print(f"{len(text):,} chars, {elapsed:.1f}s") + + doc.close() + + full_text = "\n\n".join(pages_text) + out_file = OUTPUT_DIR / f"{PDF_PATH.stem}.md" + out_file.write_text(full_text, encoding="utf-8") + + print(f"\nTotal: {len(full_text):,} chars, {len(full_text.split()):,} words, {total_time:.1f}s") + print(f"Saved: {out_file}") + + +if __name__ == "__main__": + main() diff --git a/scripts/extract_google_vision_single.py b/scripts/extract_google_vision_single.py new file mode 100644 index 0000000..b06f3e3 --- /dev/null +++ b/scripts/extract_google_vision_single.py @@ -0,0 +1,54 @@ +"""Extract text from a single PDF using Google Cloud Vision API.""" + +import sys +import time +from pathlib import Path + +import fitz +from google.cloud import vision + +API_KEY = "AIzaSyDZgUsxsy_FHkkREU7R_oQLJALU3_V26j8" +OUTPUT_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/extracted") + +def main(): + pdf_path = Path(sys.argv[1]) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + client = vision.ImageAnnotatorClient(client_options={"api_key": API_KEY}) + doc = fitz.open(str(pdf_path)) + page_count = len(doc) + print(f"Processing: {pdf_path.name} ({page_count} pages)\n") + + pages_text = [] + total_time = 0.0 + + for i in range(page_count): + page = doc[i] + pix = page.get_pixmap(dpi=300) + img_bytes = pix.tobytes("png") + image = vision.Image(content=img_bytes) + + print(f" Page {i+1}/{page_count}...", end=" ", flush=True) + t0 = time.time() + response = client.document_text_detection(image=image, image_context={"language_hints": ["he"]}) + elapsed = time.time() - t0 + total_time += elapsed + + if response.error.message: + print(f"ERROR: {response.error.message}") + pages_text.append("") + continue + + text = response.full_text_annotation.text if response.full_text_annotation else "" + pages_text.append(text) + print(f"{len(text):,} chars, {elapsed:.1f}s") + + doc.close() + full_text = "\n\n".join(pages_text) + out_file = OUTPUT_DIR / f"{pdf_path.stem}.md" + out_file.write_text(full_text, encoding="utf-8") + print(f"\nTotal: {len(full_text):,} chars, {len(full_text.split()):,} words, {total_time:.1f}s") + print(f"Saved: {out_file}") + +if __name__ == "__main__": + main() diff --git a/scripts/extract_originals.py b/scripts/extract_originals.py new file mode 100644 index 0000000..4a24809 --- /dev/null +++ b/scripts/extract_originals.py @@ -0,0 +1,66 @@ +"""Extract text from original PDF files using Claude Opus Vision OCR.""" + +import asyncio +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src")) + +from dotenv import load_dotenv +load_dotenv(Path.home() / ".env") + +from legal_mcp.services import extractor + +ORIGINALS_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/originals") +OUTPUT_DIR = ORIGINALS_DIR / "extracted" + + +async def main(): + OUTPUT_DIR.mkdir(exist_ok=True) + + pdfs = sorted(ORIGINALS_DIR.glob("*.pdf")) + print(f"Found {len(pdfs)} PDFs\n") + + total_cost = 0.0 + total_pages = 0 + total_time = 0.0 + + for pdf in pdfs: + print(f"{'=' * 60}") + print(f"Processing: {pdf.name}") + print(f" Size: {pdf.stat().st_size:,} bytes") + + t0 = time.time() + text, page_count = await extractor.extract_text(str(pdf)) + elapsed = time.time() - t0 + + total_pages += page_count + total_time += elapsed + + # Estimate cost (Opus: $15/M input, $75/M output, ~1000 tokens per image) + # Rough: ~$0.05 per page for image input + output + est_cost = page_count * 0.05 + total_cost += est_cost + + # Save extracted text + out_file = OUTPUT_DIR / f"{pdf.stem}.md" + out_file.write_text(text, encoding="utf-8") + + print(f" Pages: {page_count}") + print(f" Extracted: {len(text):,} chars, {len(text.split()):,} words") + print(f" Time: {elapsed:.1f}s ({elapsed/max(page_count,1):.1f}s/page)") + print(f" Est. cost: ${est_cost:.3f}") + print(f" Saved to: {out_file.name}") + print() + + print(f"{'=' * 60}") + print(f"TOTAL") + print(f" Documents: {len(pdfs)}") + print(f" Pages: {total_pages}") + print(f" Time: {total_time:.1f}s") + print(f" Est. cost: ${total_cost:.3f}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/extract_originals_ocr.py b/scripts/extract_originals_ocr.py new file mode 100644 index 0000000..ef22cec --- /dev/null +++ b/scripts/extract_originals_ocr.py @@ -0,0 +1,113 @@ +"""Extract text from original PDF files using Claude Opus Vision OCR on ALL pages. + +Forces Vision OCR regardless of embedded text layer (which may be broken). +""" + +import asyncio +import base64 +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src")) + +from dotenv import load_dotenv +load_dotenv(Path.home() / ".env") + +import anthropic +import fitz +from legal_mcp import config + +client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) +MODEL = "claude-opus-4-20250514" + +ORIGINALS_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/originals") +OUTPUT_DIR = ORIGINALS_DIR.parent / "extracted" + + +async def ocr_page(image_bytes: bytes, page_num: int) -> str: + b64_image = base64.b64encode(image_bytes).decode("utf-8") + message = client.messages.create( + model=MODEL, + max_tokens=4096, + messages=[{ + "role": "user", + "content": [ + { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": b64_image}, + }, + { + "type": "text", + "text": ( + "חלץ את כל הטקסט מהתמונה הזו. זהו מסמך משפטי בעברית. " + "שמור על מבנה הפסקאות המקורי. " + "אם יש כותרות, סמן אותן. " + "החזר רק את הטקסט המחולץ, ללא הערות נוספות." + ), + }, + ], + }], + ) + return message.content[0].text + + +async def process_pdf(pdf_path: Path) -> tuple[str, int, float, int, int]: + doc = fitz.open(str(pdf_path)) + page_count = len(doc) + pages_text = [] + total_input = 0 + total_output = 0 + + t0 = time.time() + for i in range(page_count): + page = doc[i] + pix = page.get_pixmap(dpi=200) + img_bytes = pix.tobytes("png") + + print(f" Page {i+1}/{page_count}...", end=" ", flush=True) + pt = time.time() + text = await ocr_page(img_bytes, i + 1) + elapsed = time.time() - pt + pages_text.append(text) + print(f"{len(text):,} chars, {elapsed:.1f}s") + + doc.close() + total_time = time.time() - t0 + full_text = "\n\n".join(pages_text) + return full_text, page_count, total_time + + +async def main(): + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + pdfs = sorted(ORIGINALS_DIR.glob("*.pdf")) + print(f"Found {len(pdfs)} PDFs — extracting ALL pages with {MODEL}\n") + + total_pages = 0 + total_time = 0.0 + + for pdf in pdfs: + print(f"{'=' * 60}") + print(f" {pdf.name} ({pdf.stat().st_size:,} bytes)") + print(f"{'=' * 60}") + + text, pages, elapsed = await process_pdf(pdf) + total_pages += pages + total_time += elapsed + + out_file = OUTPUT_DIR / f"{pdf.stem}.md" + out_file.write_text(text, encoding="utf-8") + + print(f" Result: {pages} pages, {len(text):,} chars, {len(text.split()):,} words") + print(f" Time: {elapsed:.1f}s ({elapsed/max(pages,1):.1f}s/page)") + print(f" Saved: {out_file.name}\n") + + print(f"{'=' * 60}") + print(f"TOTAL: {len(pdfs)} docs, {total_pages} pages, {total_time:.1f}s") + est_cost = total_pages * 0.05 + print(f"Estimated cost: ${est_cost:.2f}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/web/app.py b/web/app.py index 86acdbe..7f3c177 100644 --- a/web/app.py +++ b/web/app.py @@ -207,6 +207,10 @@ async def list_cases(detail: bool = False): doc_count = await conn.fetchval( "SELECT count(*) FROM documents WHERE case_id = $1", case_id ) + processing_count = await conn.fetchval( + "SELECT count(*) FROM documents WHERE case_id = $1 AND extraction_status != 'completed'", + case_id, + ) result.append({ "case_number": c["case_number"], "title": c["title"], @@ -215,6 +219,7 @@ async def list_cases(detail: bool = False): "committee_type": c.get("committee_type", ""), "hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "", "document_count": doc_count, + "processing_count": processing_count, "gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}", }) return result @@ -566,7 +571,7 @@ async def api_learn(case_number: str): @app.get("/api/cases/{case_number}/exports") async def api_list_exports(case_number: str): """List all exported drafts and versions for a case.""" - export_dir = config.EXPORTS_DIR / case_number + export_dir = config.find_case_dir(case_number) / "exports" if not export_dir.exists(): return [] files = [] @@ -585,7 +590,7 @@ async def api_list_exports(case_number: str): @app.get("/api/cases/{case_number}/exports/{filename}/download") async def api_download_export(case_number: str, filename: str): """Download an exported file.""" - export_dir = config.EXPORTS_DIR / case_number + export_dir = config.find_case_dir(case_number) / "exports" path = export_dir / filename if not path.exists() or not path.parent.samefile(export_dir): raise HTTPException(404, "קובץ לא נמצא") @@ -614,7 +619,7 @@ async def api_upload_export(case_number: str, file: UploadFile = File(...)): if len(content) > MAX_FILE_SIZE: raise HTTPException(400, f"קובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB") - export_dir = config.EXPORTS_DIR / case_number + export_dir = config.find_case_dir(case_number) / "exports" export_dir.mkdir(parents=True, exist_ok=True) # Version numbering for uploads @@ -644,7 +649,7 @@ async def api_mark_final(case_number: str, filename: str): if not case: raise HTTPException(404, f"תיק {case_number} לא נמצא") - export_dir = config.EXPORTS_DIR / case_number + export_dir = config.find_case_dir(case_number) / "exports" source = export_dir / filename if not source.exists() or not source.parent.samefile(export_dir): raise HTTPException(404, "קובץ לא נמצא") @@ -1142,7 +1147,7 @@ async def api_upload_tagged_document( new_filename = generate_doc_filename(doc_type, case_number, party_name, ext) # Save to case directory - case_dir = config.find_case_dir(case_number) / "documents" + case_dir = config.find_case_dir(case_number) / "documents" / "originals" case_dir.mkdir(parents=True, exist_ok=True) dest = case_dir / new_filename @@ -1216,6 +1221,29 @@ async def _process_tagged_document(task_id: str, dest: Path, case_number: str, c _progress[task_id] = {"status": "failed", "error": str(e), "filename": display_name} +@app.post("/api/cases/{case_number}/documents/{doc_id}/reprocess") +async def api_reprocess_document(case_number: str, doc_id: str): + """Reprocess a failed document.""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + + case_id = UUID(case["id"]) + document_id = UUID(doc_id) + doc = await db.get_document(document_id) + if not doc or UUID(doc["case_id"]) != case_id: + raise HTTPException(404, "מסמך לא נמצא בתיק") + + # Reset status and clean old chunks + await db.update_document(document_id, extraction_status="pending") + await db.delete_document_chunks(document_id) + + # Process in background + asyncio.create_task(processor.process_document(document_id, case_id)) + + return {"status": "reprocessing"} + + # ── Background Processing ───────────────────────────────────────── @@ -1245,7 +1273,7 @@ async def _process_case_document(task_id: str, source: Path, req: ClassifyReques # Copy to case directory _progress[task_id] = {"status": "copying", "filename": req.filename} - case_dir = config.find_case_dir(req.case_number) / "documents" + case_dir = config.find_case_dir(req.case_number) / "documents" / "originals" case_dir.mkdir(parents=True, exist_ok=True) # Use original name without timestamp prefix original_name = re.sub(r"^\d+_", "", source.name)