chore: fix .gitignore inline comments + add untracked code files
Inline # comments in gitignore are not supported — they were silently breaking three patterns (data/checkpoints/, data/adapter-migration-state.json, .claude/agents/.generated/). Moved comments to their own lines and added missing entries for runtime dirs (data/audit/, data/logs/, etc.) and temp files (.interaction_tmp.json, .design-build/, .taskmaster bak files). Also tracks previously untracked legitimate files: scripts, tests, docs, skills references, .env.example, taskmaster templates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
145
scripts/exec_summary_1043.py
Normal file
145
scripts/exec_summary_1043.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Ad-hoc: executive summary (סיכום מנהלים) DOCX for case 1043-02-26.
|
||||
Reuses the dafna decision template styles (David font + RTL) via the
|
||||
analysis_docx_exporter helpers. One-off prep document for chaim's meeting
|
||||
with the chair — NOT a decision draft.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "mcp-server" / "src"))
|
||||
|
||||
from docx import Document
|
||||
from legal_mcp.services.analysis_docx_exporter import (
|
||||
TEMPLATE_PATH,
|
||||
_clear_body,
|
||||
_add_paragraph,
|
||||
_add_runs_with_inline_bold,
|
||||
_mark_paragraph_rtl,
|
||||
_mark_run_rtl,
|
||||
)
|
||||
|
||||
CASE = "1043-02-26"
|
||||
OUT = Path(f"/home/chaim/legal-ai/data/cases/{CASE}/exports/סיכום-מנהלים-v1.docx")
|
||||
|
||||
|
||||
def H1(doc, t):
|
||||
_add_paragraph(doc, t, "Heading 1")
|
||||
|
||||
|
||||
def H2(doc, t):
|
||||
_add_paragraph(doc, t, "Heading 2")
|
||||
|
||||
|
||||
def P(doc, t):
|
||||
p = doc.add_paragraph(style="Normal")
|
||||
_add_runs_with_inline_bold(p, t)
|
||||
_mark_paragraph_rtl(p)
|
||||
return p
|
||||
|
||||
|
||||
def BULLET(doc, t):
|
||||
p = doc.add_paragraph(style="List Paragraph")
|
||||
_add_runs_with_inline_bold(p, t)
|
||||
_mark_paragraph_rtl(p)
|
||||
return p
|
||||
|
||||
|
||||
def LABEL(doc, label, value):
|
||||
p = doc.add_paragraph(style="Normal")
|
||||
r = p.add_run(label + ": ")
|
||||
r.bold = True
|
||||
_mark_run_rtl(r)
|
||||
r2 = p.add_run(value)
|
||||
_mark_run_rtl(r2)
|
||||
_mark_paragraph_rtl(p)
|
||||
return p
|
||||
|
||||
|
||||
def main():
|
||||
doc = Document(str(TEMPLATE_PATH))
|
||||
_clear_body(doc)
|
||||
|
||||
H1(doc, "סיכום מנהלים — הכנה לדיון")
|
||||
P(doc, "**ערר 1043-02-26 — הקמת מתקן למיון פסולת (אתר \"קומפוסט דלילה\", תכנית מי/1030)**")
|
||||
P(doc, "מסמך הכנה פנימי לקראת דיון עם יו\"ר הוועדה. אינו החלטה ואינו טיוטת החלטה.")
|
||||
|
||||
H2(doc, "פרטי התיק")
|
||||
LABEL(doc, "סוג הערר", "רישוי ובנייה (1xxx) — ערר על סירוב בקשה להיתר")
|
||||
LABEL(doc, "עוררים (מבקשי ההיתר)", "קיבוץ נחשון; חברת אלקטרה אקו גרין פארק")
|
||||
LABEL(doc, "משיבים", "הוועדה המקומית מטה יהודה; מושבי גפן/תירוש/כפר הריף; קיבוצי כפר מנחם/רבדים; מועצה אזורית יואב (מתנגדים)")
|
||||
LABEL(doc, "מושא הערר", "החלטת הוועדה המקומית מטה יהודה מיום 9.2.26 לסרב לבקשה (מס' 20240972)")
|
||||
LABEL(doc, "המקרקעין", "גוש 5093 ח\"ל 4 מגרש 1, אתר \"דלילה\" (~137 דונם), מצפון לכפר מנחם, ליד כביש 383")
|
||||
LABEL(doc, "תקן ביקורת", "ועדת הערר כמוסד תכנון בעל סמכות מקורית — שיקול דעת תכנוני עצמאי; ביקורת רחבה יותר בחלק המשפטי-פרשני")
|
||||
|
||||
H2(doc, "מהות המחלוקת")
|
||||
P(doc, "מבקשי ההיתר מבקשים להקים מתקן מיון פסולת/קומפוסטציה הכולל מבנה קומפוסטציה אחוד (~19,118 מ\"ר עיקרי), משטחי הבשלה פתוחים ובריכה תפעולית, בצירוף שתי הקלות: הגבהת גובה מ-8 ל-20 מ', והגדלת תכסית מ-3.5% ל-22%. הוועדה המקומית סירבה, בקובעה כי השינוי מהותי ומקומו בעדכון התכנית ולא בהליך רישוי. השאלה המרכזית: האם הבקשה תואמת את תכנית מי/1030, או חורגת ממנה באופן המחייב תיקון תכנית.")
|
||||
|
||||
H2(doc, "החלטת הוועדה המקומית (מושא הערר)")
|
||||
P(doc, "**דחתה** את טענת המתנגדים לפקיעת התכנית (בהסתמך על ע\"א 3213/97 נקר) ואת טענת השימושים האסורים.")
|
||||
P(doc, "**קיבלה** והפכה לבסיס הסירוב: (1) היקף הבינוי חורג משלב א' לפי הוראת השלביות; (2) הבינוי המונוליטי שונה דרמטית מנספח הבינוי; (3) משטחי ההבשלה הפתוחים מנוגדים לסעיף 6.9 (טיפול במבנים סגורים) — שהפרתו מוגדרת בתכנית כסטייה ניכרת.")
|
||||
|
||||
H2(doc, "טענות סף (לדיון תמציתי)")
|
||||
BULLET(doc, "**פקיעת תכנית** — סעיף הפקיעה (5 שנים) הושמט מהנוסח המאושר (ראו ממצאי ניתוח-העומק).")
|
||||
BULLET(doc, "**מיצוי התנגדות / \"מועד ב'\"** — האם טענות שהוכרעו בשלב התכנית מועלות מחדש בשלב הרישוי.")
|
||||
BULLET(doc, "**זכות עמידה** — מועד חתימת חוזה החכירה מול מועד פתיחת הבקשה (הוכרע: קיבוץ נחשון חוכר רשום).")
|
||||
BULLET(doc, "**פגמי פרסום / היעדר יידוע** (ס' 149(א)(2א)) — היעדר תשריט, אי-יידוע ועדה מקומית יואב.")
|
||||
BULLET(doc, "**איחור בהגשת ההתנגדויות** — מול אינטרס ההסתמכות (רכישת קרקע ב-35 מיליון ₪).")
|
||||
|
||||
H2(doc, "חמש הסוגיות המהותיות")
|
||||
|
||||
P(doc, "**סוגיה 1 (מכריעה) — שלביות הביצוע: בינוי מול היקף פעילות**")
|
||||
BULLET(doc, "השאלה: האם הוראת השלביות (ס' 7.1) חלה על הבינוי הפיזי או רק על היקף ההפעלה?")
|
||||
BULLET(doc, "עמדות: עוררים — נוגעת להיקף הפעילות; ועדה/משיבים — חלה על הבינוי (4 מבנים ≈ 8,000 מ\"ר מול ~19,118 מבוקשים).")
|
||||
BULLET(doc, "לאן נוטה: **לטובת הוועדה** — לשון \"הקמת 4 מבני קומפוסט\" מתייחסת לבינוי; נספחים 06, 11 מצביעים על מנגנון פיילוט מדורג מהותי.")
|
||||
BULLET(doc, "תקדים: עע\"מ 10089/07 אירוס הגלבוע (אין לעקוף תכנון נדרש דרך היתר).")
|
||||
|
||||
P(doc, "**סוגיה 2 (מכריעה) — משטחי ההבשלה וסעיף 6.9: \"טיפול\" מול \"אחסון\"**")
|
||||
BULLET(doc, "השאלה: האם ההבשלה הפתוחה היא \"טיפול\" החוסה תחת 6.9 (→ סטייה ניכרת החוסמת הקלה, ס' 151), או \"אחסון\"?")
|
||||
BULLET(doc, "עמדות: עוררים (ד\"ר ענבר, נספח 22) — אחסון מוצר סופי נטול ריח; ועדה — חלק בלתי נפרד מהטיפול.")
|
||||
BULLET(doc, "לאן נוטה: **נחלשה לוועדה** — נספח 07 §122-127: ועדת המשנה לעררים אישרה הבשלה פתוחה כ\"נכון וסביר\" (אך כינתה זאת \"שלב אחרון של טיפול\"). מתח פרשני אמיתי.")
|
||||
BULLET(doc, "תקדים: עע\"מ 402/03 עמותת העצמאים (ס' 151 — סטייה ניכרת).")
|
||||
|
||||
P(doc, "**סוגיה 3 — ההקלות בגובה ובתכסית + טענת \"טעות סופר\"**")
|
||||
BULLET(doc, "השאלה: האם ההקלות בגדרי הקלה או סטייה ניכרת? והאם התכסית 3.5% היא \"טעות סופר\"?")
|
||||
BULLET(doc, "לאן נוטה: טענת טעות הסופר **התחזקה מאוד** — נספח 12 (מופקד) מראה ש-3.5% נגזרה מתפיסה שהחממות \"אינן שטח לבניה\"; נספחים 06 §14 ו-07 §130 הורו לתקן; הסכם רמ\"י נוקב ב-30,280 מ\"ר.")
|
||||
BULLET(doc, "תקדים: עמ\"נ 25955-11-22 ברק-רחביה (פרשנות הרמונית); בג\"ץ 2667/17 מטה בנימין (\"פרשנות אפשרית\").")
|
||||
|
||||
P(doc, "**סוגיה 4 — תצורת הבינוי מול נספח בינוי מנחה ונספח נופי מחייב**")
|
||||
BULLET(doc, "השאלה: האם המבנה המונוליטי + הבריכה חורגים ממרחב הגמישות של נספח מנחה, לנוכח הנספח הנופי המחייב?")
|
||||
BULLET(doc, "תקדים: **ערר 1033-25 אבו גוש** (תקדים דפנה ישיר — נספח בינוי מנחה אינו המלצה בלבד); בג\"ץ 6525/15 עמק שווה.")
|
||||
|
||||
P(doc, "**סוגיה 5 — שיקולים זרים / NIMBY בהחלטת הסירוב**")
|
||||
BULLET(doc, "השאלה: האם הסירוב נגוע בשיקולים זרים? (עשוי להתייתר אם הבחינה העצמאית מכריעה).")
|
||||
BULLET(doc, "לאן נוטה: **נתמך בראיות** — תמליל (נספח 19, \"ועדה פוליטית\") + הצוות המקצועי המליץ לאשר (נספח 17). מנגד — בסיס מהותי לא-NIMBY (נספחים 25, 30).")
|
||||
|
||||
H2(doc, "ממצאי ניתוח-העומק — מה התחדש לאחר מיצוי 22 נספחי הרקע")
|
||||
BULLET(doc, "**6.9 (ליבת התיק) נחלש לוועדה** — ועדת המשנה לעררים כבר אישרה הבשלה פתוחה.")
|
||||
BULLET(doc, "**טעות הסופר בתכסית התחזקה** — שתי ערכאות הורו לתקן את חישוב השטח.")
|
||||
BULLET(doc, "**פקיעה — הוכרע עובדתית** — סעיף הפקיעה היה בנוסח המופקד (נספח 12) והושמט מהמאושר; נותרה מחלוקת משפטית בלבד (נקר/לויתן מול חמדת הגליל).")
|
||||
BULLET(doc, "**אופי המתקן מטה למשיבים** — תת\"ל 220 + ויתור על 8 מבני קומפוסט לטובת מתקן תרמי (נספחים 25, 30) → טיעון \"פריסת סלאמי\"/עקיפת תכנון.")
|
||||
BULLET(doc, "**תיקון עובדתי** — פסה\"ד שדחה את העתירה (נספח 10) ניתן נגד תכנית דרך הגישה, לא נגד מי/1030.")
|
||||
BULLET(doc, "**פער תחבורתי** — 80-92 משאיות/יום (מוסדות התכנון) מול 320-400 (ד\"ר לינק) — טעון יישוב.")
|
||||
|
||||
H2(doc, "פסיקה הדורשת אימות חיצוני (אינה בקורפוס הסמכותי)")
|
||||
BULLET(doc, "ע\"א 3213/97 **נקר** — \"הדין המתפרסם ברבים מחייב\" (עוגן הוועדה לדחיית הפקיעה).")
|
||||
BULLET(doc, "עע\"מ 4768/22 **חמדת הגליל** — פקיעת תכנית (עוגן המשיבים).")
|
||||
BULLET(doc, "ע\"א 482/99 בלפוריה; בג\"ץ 5636/13 מתיישבי תימורים; בג\"ץ 9098/01 גניס; דנ\"א 3993/07 איקאפוד.")
|
||||
|
||||
H2(doc, "שאלות פתוחות להכרעת היו\"ר + סדר דיון מומלץ")
|
||||
BULLET(doc, "(1) סיווג ההבשלה — טיפול (6.9) או אחסון (4.1.1)?")
|
||||
BULLET(doc, "(2) דין הפקיעה לאור ההשמטה המוכחת מהנוסח המאושר.")
|
||||
BULLET(doc, "(3) האם התכסית 3.5% היא טעות סופר הניתנת לתיקון פרשני?")
|
||||
BULLET(doc, "(4) האם תת\"ל 220 + הוויתור על מבני הקומפוסט הופכים את הבקשה ל\"עקיפת תכנון\" (אירוס הגלבוע)?")
|
||||
BULLET(doc, "(5) הסעד: סירוב מלא / אישור מותנה בהתאמה לשלב א' / החזרה לוועדה המקומית עם הנחיות.")
|
||||
P(doc, "**סדר דיון מומלץ:** טענות סף (פקיעה → מיצוי/השתק → עמידה/פרסום) ← סוגיה 1 (שלביות) ← סוגיה 2 (6.9) ← סוגיה 3 (הקלות/טעות סופר) ← סוגיה 4 (תצורת בינוי) ← סוגיה 5 (שיקולים זרים).")
|
||||
|
||||
H2(doc, "הערכת תרחישים")
|
||||
P(doc, "התמונה שקולה. לטובת הוועדה: סוגיה 1 (שלביות) וטיעון \"עקיפת תכנון/סלאמי\" נוכח תת\"ל 220 — חזקים. לטובת העוררים: סוגיה 2 (6.9) נחלשה, טעות הסופר התחזקה, ו-NIMBY נתמך בראיות. **התרחיש הסביר ביותר:** קבלה חלקית / החזרה מותנית או דחייה — תלוי בעיקר בשאלת היקף הבינוי בשלב א' ובשאלת \"עקיפת התכנון\". ההכרעה במובהק של יו\"ר הוועדה.")
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.save(str(OUT))
|
||||
print(f"saved: {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
102
scripts/ingest_incoming_batch.py
Normal file
102
scripts/ingest_incoming_batch.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Batch ingest of appeals-committee decisions staged in data/precedents/incoming/.
|
||||
|
||||
Sequential (NOT concurrent — avoids the 2026-05-31 load-spike incident) ingest of
|
||||
each .doc/.docx via the canonical internal pipeline, followed by metadata extraction
|
||||
per case (the internal path does NOT auto-queue metadata — INV-ING3). Halacha is
|
||||
auto-queued by ingest; drain it separately via MCP precedent_process_pending.
|
||||
|
||||
case_number canonical follows the filename/Nevo convention validated against the
|
||||
corpus + missing_precedents list:
|
||||
- מרכז/חיפה/ת"א committees number with month: NNNN/MM/YY → NNNN-MM-YY
|
||||
- ירושלים/צפון committees number without month: NNNN/YY → NNNN-YY
|
||||
decision_date / summary / subject_tags / appeal_subtype are left empty on purpose —
|
||||
the metadata extractor fills them from the full text (more reliable than parsing here).
|
||||
|
||||
Run: mcp-server/.venv/bin/python scripts/ingest_incoming_batch.py
|
||||
Config (POSTGRES_URL, VOYAGE_API_KEY, ANTHROPIC_API_KEY) auto-loads from ~/.env.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||
|
||||
from legal_mcp.services import internal_decisions as svc
|
||||
from legal_mcp.services import precedent_metadata_extractor as meta
|
||||
|
||||
INC = "/home/chaim/legal-ai/data/precedents/incoming"
|
||||
|
||||
# file, case_number(canonical-with-slashes), chair, district, court, practice_area
|
||||
DECISIONS = [
|
||||
("105-07.doc", "105/07", "דרור לביא-אפרת", "צפון", "rishuy_uvniya"),
|
||||
("ARAR-17-105-44.doc", "105/17", "רונית אלפר", "מרכז", "betterment_levy"),
|
||||
("ARAR-18-1029.doc", "1029/18", "אליעד וינשל", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-20-1018-44.doc", "1018/20", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-20-1023-55.doc", "1023/20", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-21-1080-55.doc", "1080/21", "נילי בן משה ידגר", "צפון", "rishuy_uvniya"),
|
||||
("ARAR-21-11-1051.doc", "1051/11/21", "רונית אלפר", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-22-01-1015.doc", "1015/01/22", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-22-06-1029.doc", "1029/06/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-22-08-1044.doc", "1044/08/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-22-10-1050.doc", "1050/10/22", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-22-1079.doc", "1079/22", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-23-04-1010.doc", "1010/04/23", "מאיה אשכנזי", "מרכז", "rishuy_uvniya"),
|
||||
("ARAR-23-08-1074-9.doc", "1074/08/23", "מיכל הלברשטם דגני", "חיפה", "rishuy_uvniya"),
|
||||
("ARAR-23-1034.docx", "1006/23", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-23-1073.doc", "1073/23", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-23-1085.doc", "1085/23", "נילי בן משה ידגר", "צפון", "rishuy_uvniya"),
|
||||
("ARAR-24-01-1009-5.docx","1009/01/24", "מיכל דגני הלברשטם", "תל אביב", "rishuy_uvniya"),
|
||||
("ARAR-24-05-1044.doc", "1044/05/24", "שרית אריאלי בן שמחון", "ירושלים", "rishuy_uvniya"),
|
||||
("ARAR-25-01-10072.docx", "1007/01/25", "יפעת בן אריה שטיינברג", "תל אביב", "rishuy_uvniya"),
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
results = []
|
||||
for fname, case_number, chair, district, parea in DECISIONS:
|
||||
fp = Path(INC) / fname
|
||||
rec = {"file": fname, "case_number": case_number}
|
||||
if not fp.exists():
|
||||
rec["error"] = "file-missing"
|
||||
print(f"✗ {fname}: file missing", flush=True)
|
||||
results.append(rec)
|
||||
continue
|
||||
try:
|
||||
out = await svc.ingest_internal_decision(
|
||||
file_path=fp,
|
||||
case_number=case_number,
|
||||
chair_name=chair,
|
||||
district=district,
|
||||
court=f"ועדת הערר לתכנון ובנייה — מחוז {district}",
|
||||
practice_area=parea,
|
||||
proceeding_type="ערר",
|
||||
is_binding=False,
|
||||
)
|
||||
cid = out.get("case_law_id")
|
||||
rec["case_law_id"] = cid
|
||||
rec["chunks"] = out.get("chunks")
|
||||
print(f"✓ ingest {case_number}: id={cid} chunks={out.get('chunks')}", flush=True)
|
||||
# metadata (internal path does not auto-queue it)
|
||||
m = await meta.extract_and_apply(cid)
|
||||
rec["meta_status"] = m.get("status")
|
||||
sug = m.get("suggested") or {}
|
||||
rec["suggested_case_number"] = sug.get("case_number_clean")
|
||||
rec["citation_formatted"] = sug.get("citation_formatted")
|
||||
rec["meta_date"] = sug.get("decision_date_iso")
|
||||
print(f" meta {case_number}: {m.get('status')} | clean={sug.get('case_number_clean')} | {sug.get('citation_formatted')}", flush=True)
|
||||
except Exception as e:
|
||||
rec["error"] = f"{type(e).__name__}: {e}"
|
||||
print(f"✗ {fname} ({case_number}): {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
results.append(rec)
|
||||
|
||||
print("\n===SUMMARY===", flush=True)
|
||||
for r in results:
|
||||
print(r, flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
392
scripts/ocr_benchmark_mistral.py
Normal file
392
scripts/ocr_benchmark_mistral.py
Normal file
@@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OCR Benchmark: Current system (PyMuPDF + Google Vision) vs Mistral OCR 4.0
|
||||
|
||||
Usage:
|
||||
python scripts/ocr_benchmark_mistral.py [--docs N] [--output PATH]
|
||||
|
||||
Downloads PDFs from MinIO, calls Mistral OCR API, compares against
|
||||
already-extracted text stored in the DB, and writes a Markdown report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
MISTRAL_API_KEY = "UsZjLCX30ev6pox0KgvXyuFP3ktsPYpN"
|
||||
MISTRAL_OCR_MODEL = "mistral-ocr-latest"
|
||||
MISTRAL_OCR_URL = "https://api.mistral.ai/v1/ocr"
|
||||
MINIO_ALIAS = "legalminio"
|
||||
MINIO_BUCKET = "legal-documents"
|
||||
|
||||
# Documents to benchmark — selected for diversity:
|
||||
# - main appeal (40 pp, 4.4 MB, large digital doc)
|
||||
# - permit (9 pp, 1.7 MB, likely partially scanned)
|
||||
# - protocol (5 pp, 178 KB, administrative typed)
|
||||
# - response (12 pp, ~390 KB, digital legal brief)
|
||||
DOCS_TO_BENCHMARK = [
|
||||
{
|
||||
"title": "כתב ערר",
|
||||
"minio_key": "cases/1027-04-26/documents/originals/כתב ערר - מושב נחם נ׳ ועדה מקומית בית שמש.pdf",
|
||||
"doc_type": "appeal",
|
||||
"pages": 40,
|
||||
},
|
||||
{
|
||||
"title": "נספח 1 — היתר הבנייה",
|
||||
"minio_key": "cases/1027-04-26/documents/originals/נספח 1 - היתר הבנייה (פורסם 17.03.26).pdf",
|
||||
"doc_type": "permit",
|
||||
"pages": 9,
|
||||
},
|
||||
{
|
||||
"title": "נספח 14 — פרוטוקול ועדה מחוזית",
|
||||
"minio_key": "cases/1027-04-26/documents/originals/נספח 14 - פרוטוקול דיון ועדה מחוזית - 06.06.23.pdf",
|
||||
"doc_type": "protocol",
|
||||
"pages": 5,
|
||||
},
|
||||
{
|
||||
"title": "תגובת המשיבה 3",
|
||||
"minio_key": "cases/1027-04-26/documents/originals/תגובת המשיבה 3 לערר ולבקשה להתליית היתר בנייה.pdf",
|
||||
"doc_type": "response",
|
||||
"pages": 12,
|
||||
},
|
||||
]
|
||||
|
||||
# ── DB access (read-only: fetch already-extracted text) ──────────────────────
|
||||
|
||||
def _fetch_extracted_texts(case_number: str) -> dict[str, str]:
|
||||
"""Pull extracted_text from DB for all docs in the case."""
|
||||
import psycopg2 # type: ignore
|
||||
conn = psycopg2.connect(
|
||||
host="localhost", port=5433, dbname="legal_ai",
|
||||
user="legal_ai", password="od0ASJZFYibOlWK59krLvvETmgqwlXe8",
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT d.title, d.extracted_text, d.file_path, d.page_count
|
||||
FROM documents d
|
||||
JOIN cases c ON c.id = d.case_id
|
||||
WHERE c.case_number = %s AND d.extracted_text IS NOT NULL
|
||||
""",
|
||||
(case_number,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return {row[0]: {"text": row[1], "file_path": row[2], "pages": row[3]} for row in rows}
|
||||
|
||||
|
||||
# ── MinIO download ────────────────────────────────────────────────────────────
|
||||
|
||||
def download_from_minio(minio_key: str, dest: Path) -> None:
|
||||
"""Download a file from MinIO using mcli."""
|
||||
src = f"{MINIO_ALIAS}/{MINIO_BUCKET}/{minio_key}"
|
||||
result = subprocess.run(
|
||||
["mcli", "cp", src, str(dest)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"mcli cp failed: {result.stderr}")
|
||||
|
||||
|
||||
# ── Text quality metrics ─────────────────────────────────────────────────────
|
||||
|
||||
_HEBREW_RE = re.compile(r'[-]')
|
||||
_WORD_RE = re.compile(r'\S+')
|
||||
|
||||
|
||||
def compute_metrics(text: str) -> dict:
|
||||
"""Compute quality metrics for extracted text."""
|
||||
if not text:
|
||||
return {"chars": 0, "words": 0, "avg_word_len": 0,
|
||||
"single_char_pct": 0, "words_per_line": 0,
|
||||
"hebrew_pct": 0, "quality_ok": False}
|
||||
|
||||
words = _WORD_RE.findall(text)
|
||||
n_words = len(words)
|
||||
if n_words == 0:
|
||||
return {"chars": len(text), "words": 0, "avg_word_len": 0,
|
||||
"single_char_pct": 0, "words_per_line": 0,
|
||||
"hebrew_pct": 0, "quality_ok": False}
|
||||
|
||||
avg_len = sum(len(w) for w in words) / n_words
|
||||
single_char_pct = sum(1 for w in words if len(w) == 1) / n_words
|
||||
|
||||
lines = [ln for ln in text.split("\n") if ln.strip()]
|
||||
words_per_line = n_words / len(lines) if lines else 0
|
||||
|
||||
letters = re.findall(r'[a-zA-Z-]', text)
|
||||
hebrew_pct = (
|
||||
sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
|
||||
if letters else 0
|
||||
)
|
||||
|
||||
quality_ok = (
|
||||
n_words >= 10
|
||||
and avg_len >= 2.5
|
||||
and single_char_pct <= 0.4
|
||||
and words_per_line >= 3.0
|
||||
and hebrew_pct >= 0.5
|
||||
)
|
||||
|
||||
return {
|
||||
"chars": len(text),
|
||||
"words": n_words,
|
||||
"avg_word_len": round(avg_len, 2),
|
||||
"single_char_pct": round(single_char_pct * 100, 1),
|
||||
"words_per_line": round(words_per_line, 1),
|
||||
"hebrew_pct": round(hebrew_pct * 100, 1),
|
||||
"quality_ok": quality_ok,
|
||||
}
|
||||
|
||||
|
||||
def count_known_abbrev_errors(text: str) -> int:
|
||||
"""Count common Hebrew abbreviation OCR errors (pre-fix indicators)."""
|
||||
patterns = ['עוהייד', 'עוייד', 'הנייל', 'ביהמייש', 'עייי', 'בייכ', 'תמייא']
|
||||
return sum(text.count(p) for p in patterns)
|
||||
|
||||
|
||||
def count_correct_abbrevs(text: str) -> int:
|
||||
"""Count correctly rendered Hebrew abbreviations."""
|
||||
correct = ['עו"ד', 'הנ"ל', 'ביהמ"ש', 'ע"י', 'ב"כ', 'תמ"א', 'ס"ק']
|
||||
return sum(text.count(p) for p in correct)
|
||||
|
||||
|
||||
# ── Mistral OCR ───────────────────────────────────────────────────────────────
|
||||
|
||||
def call_mistral_ocr(pdf_path: Path) -> tuple[str, float]:
|
||||
"""Call Mistral OCR API on a PDF. Returns (extracted_text, elapsed_seconds)."""
|
||||
with open(pdf_path, "rb") as f:
|
||||
pdf_b64 = base64.b64encode(f.read()).decode()
|
||||
|
||||
payload = {
|
||||
"model": MISTRAL_OCR_MODEL,
|
||||
"document": {
|
||||
"type": "document_url",
|
||||
"document_url": f"data:application/pdf;base64,{pdf_b64}",
|
||||
},
|
||||
"include_image_base64": False,
|
||||
}
|
||||
|
||||
t0 = time.time()
|
||||
with httpx.Client(timeout=300.0) as client:
|
||||
resp = client.post(
|
||||
MISTRAL_OCR_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {MISTRAL_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Mistral OCR error {resp.status_code}: {resp.text[:500]}")
|
||||
|
||||
data = resp.json()
|
||||
# Response: {"pages": [{"index": 0, "markdown": "..."}, ...]}
|
||||
pages = data.get("pages", [])
|
||||
text = "\n\n".join(p.get("markdown", "") for p in pages)
|
||||
return text, elapsed
|
||||
|
||||
|
||||
# ── Report ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def render_metric_table(current: dict, mistral: dict) -> str:
|
||||
rows = [
|
||||
("תווים", f"{current['chars']:,}", f"{mistral['chars']:,}"),
|
||||
("מילים", f"{current['words']:,}", f"{mistral['words']:,}"),
|
||||
("אורך מילה ממוצע", str(current['avg_word_len']), str(mistral['avg_word_len'])),
|
||||
("% מילים חד-תוויות", f"{current['single_char_pct']}%", f"{mistral['single_char_pct']}%"),
|
||||
("מילים לשורה", str(current['words_per_line']), str(mistral['words_per_line'])),
|
||||
("% תווים עבריים", f"{current['hebrew_pct']}%", f"{mistral['hebrew_pct']}%"),
|
||||
("איכות כוללת", "✅" if current['quality_ok'] else "❌", "✅" if mistral['quality_ok'] else "❌"),
|
||||
]
|
||||
lines = ["| מדד | OCR נוכחי | Mistral OCR |", "|-----|-----------|-------------|"]
|
||||
for label, cur_val, mis_val in rows:
|
||||
lines.append(f"| {label} | {cur_val} | {mis_val} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_report(results: list[dict], output_path: Path) -> None:
|
||||
lines = [
|
||||
"# השוואת OCR: מערכת נוכחית מול Mistral OCR",
|
||||
f"\n**תיק:** 1027-04-26 — בל\"מ מושב נחם מפעל בטון בית שמש ",
|
||||
f"**תאריך:** {time.strftime('%Y-%m-%d %H:%M')} ",
|
||||
f"**מודל Mistral:** `{MISTRAL_OCR_MODEL}` ",
|
||||
f"**מערכת נוכחית:** PyMuPDF (born-digital) + Google Cloud Vision (scanned) ",
|
||||
"\n---\n",
|
||||
"## סיכום מנהלים\n",
|
||||
]
|
||||
|
||||
# Summary table
|
||||
sum_lines = ["| מסמך | עמודים | נוכחי תווים | Mistral תווים | Mistral זמן | עדיפות |",
|
||||
"|------|--------|-------------|---------------|-------------|--------|"]
|
||||
for r in results:
|
||||
if "error" in r:
|
||||
sum_lines.append(f"| {r['title']} | {r['pages']} | — | שגיאה | — | — |")
|
||||
continue
|
||||
cur_chars = r["current_metrics"]["chars"]
|
||||
mis_chars = r["mistral_metrics"]["chars"]
|
||||
winner = "🔵 נוכחי" if cur_chars > mis_chars * 1.05 else (
|
||||
"🟢 Mistral" if mis_chars > cur_chars * 1.05 else "⚖️ שקול")
|
||||
sum_lines.append(
|
||||
f"| {r['title']} | {r['pages']} | {cur_chars:,} | {mis_chars:,} | "
|
||||
f"{r['mistral_elapsed']:.1f}s | {winner} |"
|
||||
)
|
||||
lines.extend(sum_lines)
|
||||
lines.append("\n---\n")
|
||||
|
||||
# Per-document detail
|
||||
for r in results:
|
||||
lines.append(f"## {r['title']} ({r['pages']} עמודים)\n")
|
||||
if "error" in r:
|
||||
lines.append(f"**שגיאה ב-Mistral OCR:** `{r['error']}`\n")
|
||||
continue
|
||||
|
||||
lines.append(render_metric_table(r["current_metrics"], r["mistral_metrics"]))
|
||||
lines.append("")
|
||||
|
||||
cur_abbr_err = count_known_abbrev_errors(r["current_text"])
|
||||
mis_abbr_err = count_known_abbrev_errors(r["mistral_text"])
|
||||
cur_abbr_ok = count_correct_abbrevs(r["current_text"])
|
||||
mis_abbr_ok = count_correct_abbrevs(r["mistral_text"])
|
||||
|
||||
lines.append(f"\n**קיצורים עבריים:**")
|
||||
lines.append(f"- נוכחי: {cur_abbr_ok} נכונים, {cur_abbr_err} שגויים")
|
||||
lines.append(f"- Mistral: {mis_abbr_ok} נכונים, {mis_abbr_err} שגויים")
|
||||
lines.append(f"\n**זמן Mistral:** {r['mistral_elapsed']:.1f} שניות\n")
|
||||
|
||||
# Side-by-side first 600 chars
|
||||
lines.append("### דוגמת טקסט — 600 תווים ראשונים\n")
|
||||
lines.append("**מערכת נוכחית:**")
|
||||
lines.append("```")
|
||||
lines.append((r["current_text"] or "")[:600].replace("```", "'''"))
|
||||
lines.append("```\n")
|
||||
lines.append("**Mistral OCR:**")
|
||||
lines.append("```")
|
||||
lines.append((r["mistral_text"] or "")[:600].replace("```", "'''"))
|
||||
lines.append("```\n")
|
||||
lines.append("---\n")
|
||||
|
||||
output_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f"\n✅ דוח נשמר: {output_path}")
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="OCR benchmark: current vs Mistral")
|
||||
parser.add_argument("--docs", type=int, default=4,
|
||||
help="כמה מסמכים לבדוק (ברירת מחדל: 4)")
|
||||
parser.add_argument("--output", type=str,
|
||||
default="/home/chaim/legal-ai/data/audit/ocr-benchmark-mistral.md",
|
||||
help="נתיב לדוח הפלט")
|
||||
args = parser.parse_args()
|
||||
|
||||
docs = DOCS_TO_BENCHMARK[: args.docs]
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"🔍 שולף טקסטים קיימים מה-DB עבור תיק 1027-04-26...")
|
||||
db_texts = _fetch_extracted_texts("1027-04-26")
|
||||
print(f" נמצאו {len(db_texts)} מסמכים עם טקסט מחולץ")
|
||||
|
||||
results = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
for doc in docs:
|
||||
print(f"\n📄 מעבד: {doc['title']} ({doc['pages']} עמודים)")
|
||||
|
||||
# --- Current OCR text from DB ---
|
||||
current_text = ""
|
||||
for title, info in db_texts.items():
|
||||
if doc["title"].split("—")[0].strip() in title or doc["minio_key"].split("/")[-1] in (info.get("file_path") or ""):
|
||||
current_text = info["text"] or ""
|
||||
break
|
||||
if not current_text:
|
||||
# Try by file_path suffix match
|
||||
key_name = doc["minio_key"].split("/")[-1]
|
||||
for title, info in db_texts.items():
|
||||
if key_name in (info.get("file_path") or ""):
|
||||
current_text = info["text"] or ""
|
||||
break
|
||||
|
||||
if not current_text:
|
||||
print(f" ⚠️ לא נמצא טקסט ב-DB, מחפש לפי שם מסמך...")
|
||||
# fallback: match by doc_type + rough title
|
||||
for title, info in db_texts.items():
|
||||
if doc["doc_type"] in title.lower() or doc["title"][:8] in title:
|
||||
current_text = info["text"] or ""
|
||||
break
|
||||
|
||||
print(f" נוכחי: {len(current_text):,} תווים")
|
||||
|
||||
# --- Download PDF from MinIO ---
|
||||
pdf_name = doc["minio_key"].split("/")[-1]
|
||||
pdf_path = Path(tmp_dir) / pdf_name
|
||||
print(f" מוריד מ-MinIO...")
|
||||
try:
|
||||
download_from_minio(doc["minio_key"], pdf_path)
|
||||
print(f" הורד: {pdf_path.stat().st_size:,} bytes")
|
||||
except Exception as e:
|
||||
print(f" ❌ שגיאה בהורדה: {e}")
|
||||
results.append({"title": doc["title"], "pages": doc["pages"], "error": str(e)})
|
||||
continue
|
||||
|
||||
# --- Mistral OCR ---
|
||||
print(f" קורא Mistral OCR API...")
|
||||
try:
|
||||
mistral_text, elapsed = call_mistral_ocr(pdf_path)
|
||||
print(f" Mistral: {len(mistral_text):,} תווים ({elapsed:.1f}s)")
|
||||
except Exception as e:
|
||||
print(f" ❌ שגיאת Mistral API: {e}")
|
||||
results.append({
|
||||
"title": doc["title"], "pages": doc["pages"],
|
||||
"current_text": current_text,
|
||||
"current_metrics": compute_metrics(current_text),
|
||||
"mistral_text": "", "mistral_metrics": compute_metrics(""),
|
||||
"mistral_elapsed": 0, "error": str(e),
|
||||
})
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"title": doc["title"],
|
||||
"pages": doc["pages"],
|
||||
"current_text": current_text,
|
||||
"current_metrics": compute_metrics(current_text),
|
||||
"mistral_text": mistral_text,
|
||||
"mistral_metrics": compute_metrics(mistral_text),
|
||||
"mistral_elapsed": elapsed,
|
||||
})
|
||||
|
||||
print("\n📊 בונה דוח...")
|
||||
build_report(results, output_path)
|
||||
|
||||
# Also save raw texts for manual inspection
|
||||
raw_dir = output_path.parent / "ocr-benchmark-raw"
|
||||
raw_dir.mkdir(exist_ok=True)
|
||||
for r in results:
|
||||
safe = r["title"].replace("/", "-").replace(" ", "_")[:40]
|
||||
if "current_text" in r:
|
||||
(raw_dir / f"{safe}_current.txt").write_text(r["current_text"], encoding="utf-8")
|
||||
if "mistral_text" in r:
|
||||
(raw_dir / f"{safe}_mistral.txt").write_text(r["mistral_text"], encoding="utf-8")
|
||||
print(f"💾 טקסטים גולמיים נשמרו: {raw_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user