Flatten cases directory structure and unify paths
- Remove cases/new|in-progress|completed subdivision (status managed in DB)
- Rename documents/original → documents/originals (consistent plural)
- Move exports from global data/exports/ into cases/{num}/exports/
- Add documents/research/ for case law and analysis files
- Update all agents, scripts, config, web API endpoints, and DB paths
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: "legal-analyst"
|
name: "legal-analyst"
|
||||||
description: "מנתח משפטי — חילוץ טענות, תשובות ותגובות מתיקי ערר, חיפוש תקדימים, ניתוח מסמכים"
|
description: "מנתח ומחקר משפטי — חילוץ טענות, ניתוח אסטרטגי, זיהוי חוזקות/חולשות, והפקת שאלות מחקר ממוקדות"
|
||||||
model: "claude-sonnet-4-6"
|
model: "claude-opus-4-6"
|
||||||
tools:
|
tools:
|
||||||
- Read
|
- Read
|
||||||
- Bash
|
- Bash
|
||||||
@@ -22,15 +22,34 @@ tools:
|
|||||||
- mcp__legal-ai__processing_status
|
- mcp__legal-ai__processing_status
|
||||||
---
|
---
|
||||||
|
|
||||||
# מנתח משפטי — סוכן ניתוח תיקי ערר
|
# מנתח ומחקר משפטי — סוכן ניתוח אסטרטגי והפקת שאלות מחקר
|
||||||
|
|
||||||
אתה מנתח משפטי מומחה בתכנון ובניה ישראלי. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים.
|
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות אסטרטגיה משפטית, ולהפיק שאלות מחקר ממוקדות.
|
||||||
|
|
||||||
## שפה
|
## שפה
|
||||||
|
|
||||||
עבוד תמיד בעברית.
|
עבוד תמיד בעברית.
|
||||||
|
|
||||||
## סוגי מסמכים — הבחנה קריטית
|
## תחומי התמחות
|
||||||
|
|
||||||
|
הסוכן ממוקד בתחומים הבאים:
|
||||||
|
- חוק התכנון והבניה, התשכ"ה-1965 וכל התקנות שמכוחו
|
||||||
|
- חוק המקרקעין, התשכ"ט-1969 וכל התקנות שמכוחו
|
||||||
|
- התוספת השלישית לחוק התכנון והבניה (היטל השבחה)
|
||||||
|
- תקנות התכנון והבניה (חישוב שטחים, בקשה להיתר, סטיה ניכרת, היטל השבחה)
|
||||||
|
- תקנות המקרקעין (ניהול ורישום)
|
||||||
|
- חוקי תמ"א 38, פינוי ובינוי, והתחדשות עירונית
|
||||||
|
- ועדות ערר — תכנון ובניה והיטל השבחה (סמכות, הרכב, סדרי דין)
|
||||||
|
|
||||||
|
## הבחנה קריטית — 3 סוגי פריטים מחולצים
|
||||||
|
|
||||||
|
| סוג (claim_type) | מה זה | מי אמר |
|
||||||
|
|-------------------|--------|---------|
|
||||||
|
| **claim** | טענות — מה הצד טוען | בד"כ עוררים (appellant) |
|
||||||
|
| **response** | תשובות — מה עונים לטענה | בד"כ ועדה מקומית (committee) או משיבים |
|
||||||
|
| **reply** | תגובות — תשובות לתשובות | בד"כ מבקשת ההיתר (permit_applicant) |
|
||||||
|
|
||||||
|
## סוגי מסמכים — מה לחלץ ומה לא
|
||||||
|
|
||||||
| סוג מסמך | מה לחלץ | claim_type |
|
| סוג מסמך | מה לחלץ | claim_type |
|
||||||
|-----------|----------|------------|
|
|-----------|----------|------------|
|
||||||
@@ -39,35 +58,154 @@ tools:
|
|||||||
| תגובה / השלמת טיעון | **תגובות** — תשובות לתשובות | reply |
|
| תגובה / השלמת טיעון | **תגובות** — תשובות לתשובות | reply |
|
||||||
| פסיקה / תכנית / פרוטוקול / היתר | **אל תחלץ כלום** — מסמכי רקע בלבד | — |
|
| פסיקה / תכנית / פרוטוקול / היתר | **אל תחלץ כלום** — מסמכי רקע בלבד | — |
|
||||||
|
|
||||||
## תהליך עבודה
|
## תהליך עבודה — 4 שלבים
|
||||||
|
|
||||||
### שלב 1: התמצאות
|
### שלב 1: קליטה וזיהוי
|
||||||
1. קרא פרטי התיק (`case_get`)
|
1. קרא פרטי התיק (`case_get`)
|
||||||
2. קרא רשימת מסמכים (`document_list`)
|
2. קרא רשימת מסמכים (`document_list`)
|
||||||
3. זהה אילו מסמכים רלוונטיים לחילוץ (רק כתבי ערר, תשובות, תגובות)
|
3. זהה:
|
||||||
|
- **סוג ההליך**: ערר תכנוני, ערר היטל השבחה, ערעור מנהלי וכד'
|
||||||
|
- **הערכאה/הגוף**: ועדת ערר מחוזית, בית משפט לעניינים מנהליים וכד'
|
||||||
|
- **הצדדים**: מי העורר, מי המשיב, מי צד ג'
|
||||||
|
- **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות (רק מהמסמכים)
|
||||||
|
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
|
||||||
|
5. וודא שכל פריט מסווג ל-claim_type הנכון
|
||||||
|
|
||||||
### שלב 2: חילוץ
|
### שלב 2: ניתוח מעמיק
|
||||||
לכל מסמך רלוונטי:
|
הצג במבנה הבא:
|
||||||
1. קרא את הטקסט (`document_get_text`)
|
|
||||||
2. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type וparty_hint מתאימים)
|
|
||||||
3. וודא שכל פריט מסווג ל-claim_type הנכון
|
|
||||||
|
|
||||||
### שלב 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. **אם חסר מידע** — ציין במפורש ובקש להעלות מסמכים נוספים
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ tools:
|
|||||||
3. אם הסקריפט `create-legal-doc.js` מתאים יותר (למשל לעיצוב מותאם) — השתמש בו
|
3. אם הסקריפט `create-legal-doc.js` מתאים יותר (למשל לעיצוב מותאם) — השתמש בו
|
||||||
|
|
||||||
### שלב 4: שמירה מגורסת
|
### שלב 4: שמירה מגורסת
|
||||||
1. צור תיקייה `~/legal-ai/data/exports/{מספר-ערר}/` (אם לא קיימת)
|
1. צור תיקייה `~/legal-ai/data/cases/{מספר-ערר}/exports/` (אם לא קיימת)
|
||||||
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-V`)
|
2. בדוק כמה טיוטות כבר קיימות בתיקייה (קבצים שמתחילים ב-`טיוטה-V`)
|
||||||
3. שמור כ-`טיוטה-V{N}.docx` כאשר N = המספר הבא בתור
|
3. שמור כ-`טיוטה-V{N}.docx` כאשר N = המספר הבא בתור
|
||||||
- אם אין טיוטות: `טיוטה-V1.docx`
|
- אם אין טיוטות: `טיוטה-V1.docx`
|
||||||
|
|||||||
@@ -79,8 +79,8 @@
|
|||||||
│ └── docx/ עיצוב DOCX
|
│ └── docx/ עיצוב DOCX
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
||||||
│ ├── uploads/ ← קבצים מ-web UI
|
│ ├── exports/ ← ייצוא legacy (תיקים ישנים)
|
||||||
│ └── cases/{new,in-progress,completed}/ ← תיקי עררים
|
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
||||||
├── web/ ← UI + API + integration clients
|
├── web/ ← UI + API + integration clients
|
||||||
├── mcp-server/ ← MCP server + services + tools
|
├── mcp-server/ ← MCP server + services + tools
|
||||||
└── scripts/ ← סקריפטים וכלי עזר
|
└── scripts/ ← סקריפטים וכלי עזר
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
## 2. בית הכרם 1126/25+1141/25 (דפנה תמיר, קבלה חלקית, רישוי)
|
## 2. בית הכרם 1126/25+1141/25 (דפנה תמיר, קבלה חלקית, רישוי)
|
||||||
|
|
||||||
**מקור:** data/uploads/ARAR-25-08-1126.docx (גרסה סופית מנבו)
|
**מקור:** data/training/תמא 38-בית הכרם-1126+1141-החלטה.docx (גרסה סופית מנבו)
|
||||||
**שורות:** 183 | **מילים:** 6,249
|
**שורות:** 183 | **מילים:** 6,249
|
||||||
|
|
||||||
| שורות | בלוק | תוכן | הערות |
|
| שורות | בלוק | תוכן | הערות |
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
## 3. אריאלי 1078+1083/24 (שרית אריאלי, קבלה, רישוי)
|
## 3. אריאלי 1078+1083/24 (שרית אריאלי, קבלה, רישוי)
|
||||||
|
|
||||||
**מקור:** data/uploads/ARAR-24-1078-44.docx (גרסה מנבו)
|
**מקור:** data/training/ (legacy — גרסה מנבו, לא בקורפוס הנוכחי)
|
||||||
**שורות:** 171 | **מילים:** 10,748
|
**שורות:** 171 | **מילים:** 10,748
|
||||||
|
|
||||||
| שורות | בלוק | תוכן | הערות |
|
| שורות | בלוק | תוכן | הערות |
|
||||||
|
|||||||
@@ -53,28 +53,15 @@ GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
|
|||||||
# Data directory
|
# Data directory
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
|
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
|
||||||
TRAINING_DIR = DATA_DIR / "training"
|
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 directory — flat structure: data/cases/{case_number}/
|
||||||
CASES_BASE = Path(os.environ.get("CASES_BASE", str(Path.home() / "legal-ai" / "cases")))
|
CASES_DIR = DATA_DIR / "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]
|
|
||||||
|
|
||||||
|
|
||||||
def find_case_dir(case_number: str) -> Path:
|
def find_case_dir(case_number: str) -> Path:
|
||||||
"""Find a case directory across all status folders.
|
"""Return the case directory for a given case number."""
|
||||||
|
return CASES_DIR / case_number
|
||||||
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
|
|
||||||
|
|
||||||
# Chunking parameters
|
# Chunking parameters
|
||||||
CHUNK_SIZE_TOKENS = 600
|
CHUNK_SIZE_TOKENS = 600
|
||||||
|
|||||||
@@ -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)
|
_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:
|
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)
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
# Find next version number
|
# Find next version number
|
||||||
existing = sorted(export_dir.glob("טיוטה-v*.docx"))
|
existing = sorted(export_dir.glob("טיוטה-v*.docx"))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Lessons learned from comparing AI drafts to Dafna Tamir's final decisions.
|
"""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).
|
Based on analysis of: Hecht 1180-1181 (rejection) and Beit HaKerem 1126/25+1141/25 (partial acceptance).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async def document_upload(
|
|||||||
title = source.stem
|
title = source.stem
|
||||||
|
|
||||||
# Copy file to case directory
|
# 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)
|
case_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = case_dir / source.name
|
dest = case_dir / source.name
|
||||||
shutil.copy2(str(source), str(dest))
|
shutil.copy2(str(source), str(dest))
|
||||||
|
|||||||
232
scripts/benchmark_embeddings.py
Normal file
232
scripts/benchmark_embeddings.py
Normal file
@@ -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()
|
||||||
203
scripts/benchmark_new_vs_old.py
Normal file
203
scripts/benchmark_new_vs_old.py
Normal file
@@ -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()
|
||||||
126
scripts/compare_extractions.py
Normal file
126
scripts/compare_extractions.py
Normal file
@@ -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()
|
||||||
128
scripts/extract_all_google_vision.py
Normal file
128
scripts/extract_all_google_vision.py
Normal file
@@ -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()
|
||||||
66
scripts/extract_google_vision.py
Normal file
66
scripts/extract_google_vision.py
Normal file
@@ -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()
|
||||||
54
scripts/extract_google_vision_single.py
Normal file
54
scripts/extract_google_vision_single.py
Normal file
@@ -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()
|
||||||
66
scripts/extract_originals.py
Normal file
66
scripts/extract_originals.py
Normal file
@@ -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())
|
||||||
113
scripts/extract_originals_ocr.py
Normal file
113
scripts/extract_originals_ocr.py
Normal file
@@ -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())
|
||||||
40
web/app.py
40
web/app.py
@@ -207,6 +207,10 @@ async def list_cases(detail: bool = False):
|
|||||||
doc_count = await conn.fetchval(
|
doc_count = await conn.fetchval(
|
||||||
"SELECT count(*) FROM documents WHERE case_id = $1", case_id
|
"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({
|
result.append({
|
||||||
"case_number": c["case_number"],
|
"case_number": c["case_number"],
|
||||||
"title": c["title"],
|
"title": c["title"],
|
||||||
@@ -215,6 +219,7 @@ async def list_cases(detail: bool = False):
|
|||||||
"committee_type": c.get("committee_type", ""),
|
"committee_type": c.get("committee_type", ""),
|
||||||
"hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "",
|
"hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "",
|
||||||
"document_count": doc_count,
|
"document_count": doc_count,
|
||||||
|
"processing_count": processing_count,
|
||||||
"gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}",
|
"gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}",
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
@@ -566,7 +571,7 @@ async def api_learn(case_number: str):
|
|||||||
@app.get("/api/cases/{case_number}/exports")
|
@app.get("/api/cases/{case_number}/exports")
|
||||||
async def api_list_exports(case_number: str):
|
async def api_list_exports(case_number: str):
|
||||||
"""List all exported drafts and versions for a case."""
|
"""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():
|
if not export_dir.exists():
|
||||||
return []
|
return []
|
||||||
files = []
|
files = []
|
||||||
@@ -585,7 +590,7 @@ async def api_list_exports(case_number: str):
|
|||||||
@app.get("/api/cases/{case_number}/exports/{filename}/download")
|
@app.get("/api/cases/{case_number}/exports/{filename}/download")
|
||||||
async def api_download_export(case_number: str, filename: str):
|
async def api_download_export(case_number: str, filename: str):
|
||||||
"""Download an exported file."""
|
"""Download an exported file."""
|
||||||
export_dir = config.EXPORTS_DIR / case_number
|
export_dir = config.find_case_dir(case_number) / "exports"
|
||||||
path = export_dir / filename
|
path = export_dir / filename
|
||||||
if not path.exists() or not path.parent.samefile(export_dir):
|
if not path.exists() or not path.parent.samefile(export_dir):
|
||||||
raise HTTPException(404, "קובץ לא נמצא")
|
raise HTTPException(404, "קובץ לא נמצא")
|
||||||
@@ -614,7 +619,7 @@ async def api_upload_export(case_number: str, file: UploadFile = File(...)):
|
|||||||
if len(content) > MAX_FILE_SIZE:
|
if len(content) > MAX_FILE_SIZE:
|
||||||
raise HTTPException(400, f"קובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB")
|
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)
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Version numbering for uploads
|
# Version numbering for uploads
|
||||||
@@ -644,7 +649,7 @@ async def api_mark_final(case_number: str, filename: str):
|
|||||||
if not case:
|
if not case:
|
||||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
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
|
source = export_dir / filename
|
||||||
if not source.exists() or not source.parent.samefile(export_dir):
|
if not source.exists() or not source.parent.samefile(export_dir):
|
||||||
raise HTTPException(404, "קובץ לא נמצא")
|
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)
|
new_filename = generate_doc_filename(doc_type, case_number, party_name, ext)
|
||||||
|
|
||||||
# Save to case directory
|
# 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)
|
case_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = case_dir / new_filename
|
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}
|
_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 ─────────────────────────────────────────
|
# ── Background Processing ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -1245,7 +1273,7 @@ async def _process_case_document(task_id: str, source: Path, req: ClassifyReques
|
|||||||
|
|
||||||
# Copy to case directory
|
# Copy to case directory
|
||||||
_progress[task_id] = {"status": "copying", "filename": req.filename}
|
_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)
|
case_dir.mkdir(parents=True, exist_ok=True)
|
||||||
# Use original name without timestamp prefix
|
# Use original name without timestamp prefix
|
||||||
original_name = re.sub(r"^\d+_", "", source.name)
|
original_name = re.sub(r"^\d+_", "", source.name)
|
||||||
|
|||||||
Reference in New Issue
Block a user