fix(ingest): read staged file via storage.ensure_local — s3-only upload 500 (INV-STG1)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s

תיקון: העלאת פסיקה/החלטת-ועדה (precedent-library + internal-decisions) נכשלה
תחת backend s3-only עם "Package not found at '/data/...docx'" / "Converted file
not found". השורש: ‎`ingest._stage_file` כותב את הקובץ דרך ‎`storage.put_file`
ומחזיר נתיב‎-DATA_DIR, אבל תחת s3-only ה‎-blob נכתב רק ל‎-MinIO ואין עותק בדיסק —
ואז הצינור קרא את הנתיב ישירות מהדיסק (extract_text) → קובץ לא קיים. מסלול
תיקי‎-המקרה לא נפגע כי הוא שומר עותק‎-דיסק + mirror_file; רק מסלול ‎_stage_file
המשותף קרא את ה‎-key כאילו הוא על הדיסק.

התיקון (נרמול‎-במקור, G1; קריאה דרך שכבת‎-האחסון, INV-STG1):
- ‎`_stage_file` מחזיר עכשיו את ה‎-KEY (נתיב יחסי‎-DATA_DIR), לא Path.
- ‎`ingest_document` ו‎-‎`digest_library` מאתרים נתיב‎-קריאה מקומי דרך
  ‎`storage.ensure_local` (עותק‎-דיסק תחת filesystem/dual; הורדה ל‎-temp תחת
  s3-only) ומנקים את ה‎-temp ב‎-finally — בלי דליפה ל‎-/tmp.
- מולטימודל (PDF) קורא את אותו נתיב מקומי מאומת.

בדיקות: test_unified_ingest::test_ingest_reads_via_ensure_local_when_no_disk_copy
מדמה backend ללא עותק‎-דיסק ומוודא שהצינור משלים (נכשל מול הקוד הישן). 55 עוברות.

Invariants: מקיים INV-STG1 (קריאה/כתיבה רק דרך שכבת‎-האחסון), G1 (נרמול‎-במקור,
לא תיקון‎-בקריאה), G2 (אין מסלול מקביל — תיקון הצינור הקנוני).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:32:04 +00:00
parent ca1a0ddaac
commit 4f7c3733e2
4 changed files with 180 additions and 89 deletions

View File

@@ -30,7 +30,7 @@ from typing import Awaitable, Callable
from uuid import UUID
from legal_mcp import config
from legal_mcp.services import db, embeddings, extractor, ingest
from legal_mcp.services import db, embeddings, extractor, ingest, storage
logger = logging.getLogger(__name__)
@@ -144,12 +144,23 @@ async def create_pending_digest(
raise ValueError(f"file not found: {file_path}")
await progress("staging", 10, "מעתיק קובץ")
staged = await ingest._stage_file(src, DIGEST_LIBRARY_DIR, "incoming")
rel_path = str(staged.relative_to(config.DATA_DIR)) \
if str(staged).startswith(str(config.DATA_DIR)) else str(staged)
# ``_stage_file`` returns the storage KEY (DATA_DIR-relative path). Resolve a
# real local path to read from — on s3-only this downloads to a temp file we
# own and remove after extraction (INV-STG1; the key is not guaranteed to be
# on local disk).
rel_path = await ingest._stage_file(src, DIGEST_LIBRARY_DIR, "incoming")
local = await storage.ensure_local(rel_path, bucket=storage.Bucket.DOCUMENTS)
local_is_tmp = storage.local_path(rel_path, bucket=storage.Bucket.DOCUMENTS) is None
await progress("extracting_text", 50, "מחלץ טקסט")
raw_text, _pc, _off = await extractor.extract_text(str(staged))
try:
raw_text, _pc, _off = await extractor.extract_text(str(local))
finally:
if local_is_tmp:
try:
local.unlink(missing_ok=True)
except OSError as e: # noqa: BLE001 — temp cleanup, never fatal
logger.debug("could not remove temp digest file %s: %s", local, e)
raw_text = (raw_text or "").strip()
if not raw_text:
raise ValueError("no text extracted from digest")