chore: fix .gitignore inline comments + add untracked code files
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s

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:
2026-06-27 12:46:32 +00:00
parent b4a68cf5da
commit 4de555367d
17 changed files with 3108 additions and 3 deletions

View 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()

View 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())

View 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()