feat(storage): #106.5 — read-wiring via serve_blob (presigned + dual disk-fallback)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
חיווט-קריאה של 4 endpoints מגישי-קבצים (api_read_local_file · research/analysis download · analysis export-docx · exports download) דרך helper serve_blob יחיד (INV-STG6). מיישם את אסטרטגיית-ה-cutover שהפאנל התלת-מודלי (Opus+DeepSeek+Gemini) אישר פה-אחד 2026-06-11: - filesystem → FileResponse מדיסק (משמר-התנהגות; ה-backend הפעיל בייצור — אפס שינוי). - s3/dual → 302 ל-presigned-URL כשהאובייקט ב-MinIO (bytes browser↔MinIO, לא דרך FastAPI). - dual + miss → **fallback-לדיסק** — מכסה שקוף קבצים שמחוץ לסט-ההגירה מתויג-ה-DB (analysis-and-research.md, DOCX דינמי, proofread). זו רשת-הביטחון שהפאנל דרש. - s3 + miss + ללא-דיסק → 404. כשל normalize_key/presign → fallback-לדיסק, לעולם לא 500 (לא נשבר בשקט — logger.exception). ה-cutover (#106.6 flip ל-s3) + WORM (#106.7) **נשארים נעולים מאחורי אישור-אדם** — הכרעת-הפאנל פה-אחד (proceed_autonomously=false). PR זה הפיך: תחת filesystem אין שינוי- התנהגות, וה-helper מוכן להפעלה כשיוחלט flip מפוקח + curl-ירוק per-endpoint. invariants: INV-STG6 (presigned) · INV-STG1 (storage layer יחיד) · G2 (serve_blob יחיד, לא 4 העתקי-לוגיקה) · INV-G10 (אפס שינוי-התנהגות בייצור filesystem). tests: 4 חדשות (web/tests/test_serve_blob.py — filesystem/dual-S3/dual-fallback/s3-404), עוברות. py_compile OK. מקור: פאנל תלת-מודלי (תיעוד ב-TaskMaster #106.6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
45
web/app.py
45
web/app.py
@@ -23,7 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
|
||||
import zipfile
|
||||
|
||||
from fastapi import BackgroundTasks, Body, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
||||
from typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -31,7 +31,7 @@ import asyncpg
|
||||
import httpx
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, git_sync, metrics as metrics_service, processor, proofreader, research_md
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, git_sync, metrics as metrics_service, processor, proofreader, research_md, storage
|
||||
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools, precedents as precedents_tools
|
||||
from legal_mcp.tools.envelope import envelope_unwrap
|
||||
|
||||
@@ -2769,6 +2769,39 @@ async def api_local_files(case_number: str):
|
||||
return result
|
||||
|
||||
|
||||
async def serve_blob(
|
||||
path, *, media_type: str, filename: str,
|
||||
bucket=storage.Bucket.DOCUMENTS,
|
||||
):
|
||||
"""Serve a case blob, routing through the storage layer (#106.5, INV-STG6).
|
||||
|
||||
- ``filesystem`` backend → stream the file from disk (unchanged behaviour).
|
||||
- ``s3``/``dual`` backend → if the object is in MinIO, 302-redirect to a
|
||||
short-lived presigned URL (bytes go browser↔MinIO, never through FastAPI);
|
||||
under ``dual`` a not-yet-migrated file (e.g. the dynamically-built analysis
|
||||
DOCX or research markdown) transparently falls back to its disk copy. This
|
||||
is the tri-model panel's agreed cutover-safety design (2026-06-11): dual +
|
||||
disk-fallback covers files outside the DB-tracked migration set.
|
||||
|
||||
The caller still owns its own existence/path-safety checks; this only
|
||||
decides HOW to serve a path it already validated.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
backend = storage.get_storage()
|
||||
if backend.name != "filesystem":
|
||||
try:
|
||||
key = storage.normalize_key(path)
|
||||
if await backend.exists(key, bucket=bucket):
|
||||
url = await backend.presign_get(key, bucket=bucket, download_name=filename)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
except Exception: # noqa: BLE001 — never 500 on a key/presign hiccup; fall back to disk
|
||||
logger.exception("serve_blob: presign failed for %s; falling back to disk", path)
|
||||
# not in object storage (dual fallback) — serve the disk copy if present
|
||||
if not _Path(path).exists():
|
||||
raise HTTPException(404, "קובץ לא נמצא")
|
||||
return FileResponse(path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
||||
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||
"""Read contents of a local case file."""
|
||||
@@ -2781,7 +2814,7 @@ async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||
path = case_dir / "documents" / folder / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(404, "קובץ לא נמצא")
|
||||
return FileResponse(path, media_type="text/plain; charset=utf-8", filename=filename)
|
||||
return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename)
|
||||
|
||||
|
||||
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
||||
@@ -2812,7 +2845,7 @@ async def api_research_analysis_download(case_number: str):
|
||||
path = _research_file_path(case_number)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "טרם בוצע ניתוח משפטי לתיק זה")
|
||||
return FileResponse(
|
||||
return await serve_blob(
|
||||
path,
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
filename=f"analysis-{case_number}.md",
|
||||
@@ -2833,7 +2866,7 @@ async def api_research_analysis_export_docx(case_number: str):
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
commit_and_push(case_dir, f"ניתוח משפטי: {path.name}")
|
||||
return FileResponse(
|
||||
return await serve_blob(
|
||||
path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
filename=path.name,
|
||||
@@ -3095,7 +3128,7 @@ async def api_download_export(case_number: str, filename: str):
|
||||
path = export_dir / filename
|
||||
if not path.exists() or not path.parent.samefile(export_dir):
|
||||
raise HTTPException(404, "קובץ לא נמצא")
|
||||
return FileResponse(
|
||||
return await serve_blob(
|
||||
path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
filename=filename,
|
||||
|
||||
Reference in New Issue
Block a user