feat(storage): #106.4 — סקריפט הגירת בלובים ל-MinIO (DB-driven, dry-run default) #197
@@ -47,6 +47,7 @@
|
|||||||
| `rechunk_legacy_precedents.py` | python | **#57** — re-chunk + re-embed פסיקה שהוטמעה לפני תיקון ה-chunker (#55). בוחר כל `case_law` עם chunk זעיר (`length(trim(content))<50` — טביעת-האצבע של ה-chunker הישן) ומריץ `ingest.reindex_case_law` (re-chunk+re-embed מ-`full_text` שמור בלבד — ללא re-OCR/LLM, feedback_no_reocr_retrofit; idempotent DELETE-then-INSERT). idempotent ברמת-הבאטץ' (שואב מחדש את הסט המושפע בכל ריצה). דגל `--limit N`. רץ עם venv של mcp-server (`cd mcp-server && .venv/bin/python ../scripts/rechunk_legacy_precedents.py`) | חד-פעמי — מיגרציית-נתונים של פסיקה legacy (תוקן 2026-06-03) |
|
| `rechunk_legacy_precedents.py` | python | **#57** — re-chunk + re-embed פסיקה שהוטמעה לפני תיקון ה-chunker (#55). בוחר כל `case_law` עם chunk זעיר (`length(trim(content))<50` — טביעת-האצבע של ה-chunker הישן) ומריץ `ingest.reindex_case_law` (re-chunk+re-embed מ-`full_text` שמור בלבד — ללא re-OCR/LLM, feedback_no_reocr_retrofit; idempotent DELETE-then-INSERT). idempotent ברמת-הבאטץ' (שואב מחדש את הסט המושפע בכל ריצה). דגל `--limit N`. רץ עם venv של mcp-server (`cd mcp-server && .venv/bin/python ../scripts/rechunk_legacy_precedents.py`) | חד-פעמי — מיגרציית-נתונים של פסיקה legacy (תוקן 2026-06-03) |
|
||||||
| `backfill_nevo_preamble.py` | python | **#86.2** — מיגרציית-נתונים: חיתוך preamble/רציו של נבו שדלף לפסיקה שהוטמעה לפני תיקון #86.1. מאתר כל `case_law` ש-`strip_nevo_preamble(full_text)` עדיין מקצר (דליפה היסטורית), ומבצע: (1) לכידת ה-מיני-רציו ל-`case_law.nevo_ratio` (gold-set ל-#86.3); (2) שכתוב `full_text` החתוך + חישוב-מחדש של `content_hash`; (3) `reindex_case_law` (re-chunk+embed, ללא re-OCR/LLM); (4) **סימון (לא מחיקה)** הלכות ש-`supporting_quote` שלהן בתוך ה-preamble שהוסר → `pending_review` + quality_flag `nevo_preamble_leak`. **שומר-בטיחות:** שורות עם keep%<`--min-keep` (ברירת-מחדל 60) מוחרגות מ-`--apply` כחשד over-strip (אלא אם `--include-suspicious`). **dry-run כברירת-מחדל**; `--apply` כותב backup JSON + manifest CSV ל-`data/audit/` תחילה. idempotent. רץ עם venv של mcp-server. **chair-gated** (לאמת manifest לפני apply) | מיגרציית-נתונים — dry-run בוצע (19 פסקים, 27 הלכות מזוהמות); apply ממתין לאישור |
|
| `backfill_nevo_preamble.py` | python | **#86.2** — מיגרציית-נתונים: חיתוך preamble/רציו של נבו שדלף לפסיקה שהוטמעה לפני תיקון #86.1. מאתר כל `case_law` ש-`strip_nevo_preamble(full_text)` עדיין מקצר (דליפה היסטורית), ומבצע: (1) לכידת ה-מיני-רציו ל-`case_law.nevo_ratio` (gold-set ל-#86.3); (2) שכתוב `full_text` החתוך + חישוב-מחדש של `content_hash`; (3) `reindex_case_law` (re-chunk+embed, ללא re-OCR/LLM); (4) **סימון (לא מחיקה)** הלכות ש-`supporting_quote` שלהן בתוך ה-preamble שהוסר → `pending_review` + quality_flag `nevo_preamble_leak`. **שומר-בטיחות:** שורות עם keep%<`--min-keep` (ברירת-מחדל 60) מוחרגות מ-`--apply` כחשד over-strip (אלא אם `--include-suspicious`). **dry-run כברירת-מחדל**; `--apply` כותב backup JSON + manifest CSV ל-`data/audit/` תחילה. idempotent. רץ עם venv של mcp-server. **chair-gated** (לאמת manifest לפני apply) | מיגרציית-נתונים — dry-run בוצע (19 פסקים, 27 הלכות מזוהמות); apply ממתין לאישור |
|
||||||
| `nevo_ratio_benchmark.py` | python | **#86.3** — מדידת איכות חילוץ-הלכות מול ה-מיני-רציו של נבו (gold-set מקצועי חינמי). לכל פסק עם `nevo_ratio` (או נגזר מ-`full_text` אם טרם בוצע backfill): LLM-judge מקומי (`claude_session`, אפס עלות) ממפה סמנטית את הלכות-המערכת מול הלכות-נבו ומפיק **recall** (כיסוי הלכות-נבו), **precision** (אחוז הלכותינו הממופות), **granularity** (יחס פירוק — איתות over-extraction ל-#81.5). `--case <num>` / `--all [--limit N]` / `--model` / `--out`. כותב CSV ל-`data/audit/`. רץ עם venv של mcp-server (דורש Claude CLI מקומי). אומת על בג"ץ 1764/05: recall 0.875, precision 1.0, granularity 1.75x | ידני — מדידת-איכות (CI/ad-hoc) |
|
| `nevo_ratio_benchmark.py` | python | **#86.3** — מדידת איכות חילוץ-הלכות מול ה-מיני-רציו של נבו (gold-set מקצועי חינמי). לכל פסק עם `nevo_ratio` (או נגזר מ-`full_text` אם טרם בוצע backfill): LLM-judge מקומי (`claude_session`, אפס עלות) ממפה סמנטית את הלכות-המערכת מול הלכות-נבו ומפיק **recall** (כיסוי הלכות-נבו), **precision** (אחוז הלכותינו הממופות), **granularity** (יחס פירוק — איתות over-extraction ל-#81.5). `--case <num>` / `--all [--limit N]` / `--model` / `--out`. כותב CSV ל-`data/audit/`. רץ עם venv של mcp-server (דורש Claude CLI מקומי). אומת על בג"ץ 1764/05: recall 0.875, precision 1.0, granularity 1.75x | ידני — מדידת-איכות (CI/ad-hoc) |
|
||||||
|
| `migrate_blobs_to_minio.py` | python | **#106.4 — הגירת בלובים לדיסק→MinIO (DB-driven, dry-run-default).** סורק 6 עמודות-נתיב (documents.file_path · cases.active_draft_path · digests.source_document_path · draft_final_pairs.final_path · *_image_embeddings.image_thumbnail_path), מנרמל 3 פורמטי-נתיב legacy (container-abs `/data/`, host-abs, relative) ל-key יחסי-DATA_DIR, וגוזר bucket per-file-semantic (מסמך→documents, thumbnail→derived). dry-run מפיק תוכנית+מניפסט CSV (data/audit) + מדווח חסרים; `--apply` מעלה דרך mcli ומאמת size (דיסק לא נוגע → הפיך). אומת 2026-06-11: 3404 קבצים/899MB, 0 outside, 28 חסרים. **חובה mcli alias legalminio**. | ידני — הגירת-אחסון X14 |
|
||||||
| `nevo_corpus_audit.py` | python | **#86.2/#86.3 — אודיט קורפוס-נבו (read-only).** `leak` סורק chunks+הלכות למרקרי-preamble של נבו (מיובאים מ-extractor._NEVO_MARKERS), מבחין בין הווקטור המזיק (מרקר בתוך הלכה=רציו-עריכה כהלכה) ל-benign (רשימת-ציטוטים), ומפיק CSV. אומת 2026-06-11: **0 הלכות מזוהמות** (שכבת-הידע נקייה) → אין purge/re-ingest (גם נוגד no-reocr). `leak --apply` מבצע backfill **אדיטיבי** של `case_law.nevo_ratio` מטקסט שמור (extract_nevo_ratio, ללא re-OCR) — captured 16→32. `benchmark` משווה הלכות-שלנו מול ה-מיני-רציו דרך הפאנל התלת-מודלי → recall כיסוי (1110-20: 13 הלכות, recall=1.0). **חובה מקומי** (benchmark). | ידני — ניטור-זיהום / ground-truth |
|
| `nevo_corpus_audit.py` | python | **#86.2/#86.3 — אודיט קורפוס-נבו (read-only).** `leak` סורק chunks+הלכות למרקרי-preamble של נבו (מיובאים מ-extractor._NEVO_MARKERS), מבחין בין הווקטור המזיק (מרקר בתוך הלכה=רציו-עריכה כהלכה) ל-benign (רשימת-ציטוטים), ומפיק CSV. אומת 2026-06-11: **0 הלכות מזוהמות** (שכבת-הידע נקייה) → אין purge/re-ingest (גם נוגד no-reocr). `leak --apply` מבצע backfill **אדיטיבי** של `case_law.nevo_ratio` מטקסט שמור (extract_nevo_ratio, ללא re-OCR) — captured 16→32. `benchmark` משווה הלכות-שלנו מול ה-מיני-רציו דרך הפאנל התלת-מודלי → recall כיסוי (1110-20: 13 הלכות, recall=1.0). **חובה מקומי** (benchmark). | ידני — ניטור-זיהום / ground-truth |
|
||||||
| `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in <csv>` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server. **הערה:** קיים גם דף-תיוג אינטראקטיבי DB-backed (`/goldset`) — זה ה-CSV-fallback | ידני — export→תיוג→score |
|
| `halacha_goldset.py` | python | **#81.7** — הארנס gold-set לאיכות חילוץ-הלכות. `export --n N` מייצא מדגם מרובד (לפי precedent×rule_type) ל-CSV עם עמודות-תיוג ריקות (`is_holding`/`correct_type`/`quote_complete`) לתיוג ידני (חיים/דפנה). `score --in <csv>` קורא את ה-CSV המתויג ומודד כל ולידטור (`compute_quality_flags`/`is_fact_dependent`/`is_quote_truncated`/`is_thin_restatement`) מול אמת-המידה האנושית: P/R/F1 + confusion. בסיס ל-#81.8 (כיול סף האישור). מייבא את אותם ולידטורים שה-extractor מריץ. רץ עם venv של mcp-server. **הערה:** קיים גם דף-תיוג אינטראקטיבי DB-backed (`/goldset`) — זה ה-CSV-fallback | ידני — export→תיוג→score |
|
||||||
| `goldset_panel_label.py` | python | **#81.7 — תיוג ה-gold-set בקונצנזוס תלת-מודלי (ללא man-in-the-loop, הנחיית-יו"ר 2026-06-11).** מריץ את שלושת השופטים העצמאיים (Opus/claude_session · DeepSeek · Gemini, מיובאים מ-`halacha_panel_approve`) עם ה-prompt העשיר (`is_holding`+`type`+נימוק מ-`goldset_ai_recommend`) על כל פריט; **רוב 2/3 נכתב ל-`is_holding`/`correct_type`** עם `tagged_by='panel:opus+deepseek+gemini'` (פיצול→NULL→יו"ר, INV-G10). מודד **Fleiss κ** (3 מעריכים) ומריץ **מבחן-אנונימיזציה** (שמות-תיק ממוסכים→שיפוט-מחדש; flip=שינון). לא מעגלי — הוולידטורים הנמדדים rule-based. כותב per-model+consensus+anon ל-DB ודוח ל-`data/audit/`. **מחליף** תיוג-ידני; `goldset_ai_recommend`/`goldset_independent_judge` נשארים כבדיקות single-model. `--limit`/`--no-anon`/`--force`. **חובה מקומי**. | ידני — לאחר יצירת/הרחבת batch |
|
| `goldset_panel_label.py` | python | **#81.7 — תיוג ה-gold-set בקונצנזוס תלת-מודלי (ללא man-in-the-loop, הנחיית-יו"ר 2026-06-11).** מריץ את שלושת השופטים העצמאיים (Opus/claude_session · DeepSeek · Gemini, מיובאים מ-`halacha_panel_approve`) עם ה-prompt העשיר (`is_holding`+`type`+נימוק מ-`goldset_ai_recommend`) על כל פריט; **רוב 2/3 נכתב ל-`is_holding`/`correct_type`** עם `tagged_by='panel:opus+deepseek+gemini'` (פיצול→NULL→יו"ר, INV-G10). מודד **Fleiss κ** (3 מעריכים) ומריץ **מבחן-אנונימיזציה** (שמות-תיק ממוסכים→שיפוט-מחדש; flip=שינון). לא מעגלי — הוולידטורים הנמדדים rule-based. כותב per-model+consensus+anon ל-DB ודוח ל-`data/audit/`. **מחליף** תיוג-ידני; `goldset_ai_recommend`/`goldset_independent_judge` נשארים כבדיקות single-model. `--limit`/`--no-anon`/`--force`. **חובה מקומי**. | ידני — לאחר יצירת/הרחבת batch |
|
||||||
|
|||||||
170
scripts/migrate_blobs_to_minio.py
Normal file
170
scripts/migrate_blobs_to_minio.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""#106.4 — migrate binary blobs (PDF/DOCX/thumbnails) from disk to MinIO.
|
||||||
|
|
||||||
|
DB-DRIVEN, NOT a wholesale ``mc mirror``: the bucket is chosen per-file-semantic
|
||||||
|
(source/draft → documents, thumbnail → derived), so the migration walks the DB
|
||||||
|
path columns and uploads each referenced file to its correct (bucket, key). The
|
||||||
|
key is the DATA_DIR-relative POSIX path (storage.normalize_key), matching how the
|
||||||
|
write-wiring (#106.3) and read-wiring (#106.5) resolve keys.
|
||||||
|
|
||||||
|
⚠️ The legacy path columns are INCONSISTENT (audited 2026-06-11): three formats
|
||||||
|
coexist — container-absolute ``/data/…``, host-absolute
|
||||||
|
``/home/chaim/legal-ai/data/…``, and DATA_DIR-relative ``digests/…``. This script
|
||||||
|
normalises all three to a host path + a clean key. Files it cannot locate are
|
||||||
|
reported, never silently skipped.
|
||||||
|
|
||||||
|
Buckets (X14 §3.1):
|
||||||
|
documents → originals, drafts/exports, digests sources, finals (finals promote
|
||||||
|
to immutable only at #106.7).
|
||||||
|
derived → page thumbnails.
|
||||||
|
|
||||||
|
DRY-RUN by default: prints the full plan (per table/bucket: found / missing /
|
||||||
|
bytes) and writes a CSV manifest to data/audit/. Touches NOTHING. ``--apply``
|
||||||
|
uploads via the configured MinIO client (mcli alias from --mc-alias), verifying
|
||||||
|
size after each PUT; the disk is never modified, so a re-run is idempotent and
|
||||||
|
the migration is reversible (wipe the buckets, flip STORAGE_BACKEND back).
|
||||||
|
|
||||||
|
DB path-column normalisation to clean keys is a SEPARATE, later step (reads still
|
||||||
|
use the legacy paths until #106.5 deploys) — this script only moves bytes.
|
||||||
|
|
||||||
|
cd ~/legal-ai/mcp-server
|
||||||
|
.venv/bin/python ../scripts/migrate_blobs_to_minio.py # dry-run plan
|
||||||
|
.venv/bin/python ../scripts/migrate_blobs_to_minio.py --apply --mc-alias legalminio
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import db
|
||||||
|
|
||||||
|
DATA_DIR = Path(config.DATA_DIR).resolve()
|
||||||
|
_CONTAINER_DATA = "/data/" # the container bind-mount target seen in legacy rows
|
||||||
|
|
||||||
|
# (table, column, bucket) — only columns that actually exist (audited 2026-06-11).
|
||||||
|
SOURCES = [
|
||||||
|
("documents", "file_path", "documents"),
|
||||||
|
("cases", "active_draft_path", "documents"),
|
||||||
|
("digests", "source_document_path", "documents"),
|
||||||
|
("draft_final_pairs", "final_path", "documents"),
|
||||||
|
("document_image_embeddings", "image_thumbnail_path", "derived"),
|
||||||
|
("precedent_image_embeddings", "image_thumbnail_path", "derived"),
|
||||||
|
]
|
||||||
|
BUCKET_ENV = {
|
||||||
|
"documents": config.MINIO_BUCKET_DOCUMENTS,
|
||||||
|
"derived": config.MINIO_BUCKET_DERIVED,
|
||||||
|
"immutable": config.MINIO_BUCKET_IMMUTABLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_host(stored: str) -> Path | None:
|
||||||
|
"""Normalise one stored path (3 legacy formats) to a host filesystem path."""
|
||||||
|
s = (stored or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s.startswith(_CONTAINER_DATA): # container-absolute /data/…
|
||||||
|
return DATA_DIR / s[len(_CONTAINER_DATA):]
|
||||||
|
p = Path(s)
|
||||||
|
if p.is_absolute(): # host-absolute
|
||||||
|
return p
|
||||||
|
return DATA_DIR / s # DATA_DIR-relative
|
||||||
|
|
||||||
|
|
||||||
|
def to_key(host: Path) -> str | None:
|
||||||
|
"""DATA_DIR-relative POSIX key, or None if the file is outside DATA_DIR."""
|
||||||
|
try:
|
||||||
|
return host.resolve().relative_to(DATA_DIR).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def main(args: argparse.Namespace) -> int:
|
||||||
|
pool = await db.get_pool()
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
audit = Path(config.DATA_DIR) / "audit"
|
||||||
|
audit.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest = audit / f"minio-migration-plan-{ts}.csv"
|
||||||
|
|
||||||
|
totals = {"found": 0, "missing": 0, "outside": 0, "bytes": 0, "uploaded": 0, "failed": 0}
|
||||||
|
per_bucket: dict = {}
|
||||||
|
rows_out = []
|
||||||
|
|
||||||
|
for table, col, bucket in SOURCES:
|
||||||
|
try:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
f"SELECT DISTINCT {col} AS v FROM {table} WHERE COALESCE({col},'') <> ''")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" {table}.{col}: SKIP ({str(e)[:60]})")
|
||||||
|
continue
|
||||||
|
b = per_bucket.setdefault(bucket, {"found": 0, "missing": 0, "bytes": 0})
|
||||||
|
for r in rows:
|
||||||
|
host = resolve_host(r["v"])
|
||||||
|
key = to_key(host) if host else None
|
||||||
|
if host is None or key is None:
|
||||||
|
totals["outside"] += 1
|
||||||
|
rows_out.append([table, col, bucket, r["v"], "", "OUTSIDE_DATA_DIR", 0])
|
||||||
|
continue
|
||||||
|
if not host.exists():
|
||||||
|
totals["missing"] += 1
|
||||||
|
b["missing"] += 1
|
||||||
|
rows_out.append([table, col, bucket, r["v"], key, "MISSING", 0])
|
||||||
|
continue
|
||||||
|
size = host.stat().st_size
|
||||||
|
totals["found"] += 1
|
||||||
|
totals["bytes"] += size
|
||||||
|
b["found"] += 1
|
||||||
|
b["bytes"] += size
|
||||||
|
status = "PLANNED"
|
||||||
|
if args.apply:
|
||||||
|
ok = _upload(args.mc_alias, BUCKET_ENV[bucket], key, host, size)
|
||||||
|
status = "UPLOADED" if ok else "FAILED"
|
||||||
|
totals["uploaded" if ok else "failed"] += 1
|
||||||
|
rows_out.append([table, col, bucket, r["v"], key, status, size])
|
||||||
|
|
||||||
|
with manifest.open("w", encoding="utf-8", newline="") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(["table", "column", "bucket", "stored_path", "key", "status", "bytes"])
|
||||||
|
w.writerows(rows_out)
|
||||||
|
|
||||||
|
print(f"\n{'APPLY' if args.apply else 'DRY-RUN'} — blob migration plan")
|
||||||
|
print("=" * 56)
|
||||||
|
for bucket, b in sorted(per_bucket.items()):
|
||||||
|
print(f" {bucket:10} found={b['found']:5} missing={b['missing']:4} "
|
||||||
|
f"bytes={b['bytes']/1e6:.1f}MB")
|
||||||
|
print("-" * 56)
|
||||||
|
print(f" TOTAL found={totals['found']} missing={totals['missing']} "
|
||||||
|
f"outside-DATA_DIR={totals['outside']} bytes={totals['bytes']/1e6:.1f}MB")
|
||||||
|
if args.apply:
|
||||||
|
print(f" uploaded={totals['uploaded']} failed={totals['failed']}")
|
||||||
|
print(f"\nmanifest → {manifest}")
|
||||||
|
if totals["missing"] or totals["outside"]:
|
||||||
|
print("⚠ some referenced files are missing/outside DATA_DIR — review the "
|
||||||
|
"manifest BEFORE --apply; they will not migrate.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(alias: str, bucket: str, key: str, host: Path, size: int) -> bool:
|
||||||
|
"""Upload one file via mcli and verify the remote size. Disk untouched."""
|
||||||
|
target = f"{alias}/{bucket}/{key}"
|
||||||
|
try:
|
||||||
|
subprocess.run(["mcli", "cp", "-q", str(host), target],
|
||||||
|
check=True, capture_output=True, timeout=300)
|
||||||
|
out = subprocess.run(["mcli", "stat", "--json", target],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
return out.returncode == 0 and str(size) in out.stdout
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" upload FAILED {key}: {str(e)[:80]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--apply", action="store_true", help="upload (default: dry-run plan only)")
|
||||||
|
ap.add_argument("--mc-alias", default="legalminio", help="mcli alias for MinIO")
|
||||||
|
raise SystemExit(asyncio.run(main(ap.parse_args())))
|
||||||
Reference in New Issue
Block a user