fastapi_hf / routes /Memory_Upload.py
looh2's picture
Increase expiration time for public document URLs to 3600 seconds and enhance URL handling for website-sourced documents
90adbc9
Raw
History Blame Contribute Delete
17.4 kB
import socket
from ipaddress import ip_address, ip_network
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException, Request
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool
from routes.limiter import limiter
from dotenv import load_dotenv
import json
import os
import uuid
from pathlib import Path
from urllib.parse import urlparse
from .auth import verify_token
_BLOCKED_NETS = [
ip_network("127.0.0.0/8"), # Loopback
ip_network("10.0.0.0/8"), # RFC 1918 private
ip_network("172.16.0.0/12"), # RFC 1918 private
ip_network("192.168.0.0/16"), # RFC 1918 private
ip_network("169.254.0.0/16"), # Link-local / cloud metadata (AWS, GCP, Azure)
ip_network("100.64.0.0/10"), # Shared address space
ip_network("0.0.0.0/8"), # This network
ip_network("::1/128"), # IPv6 loopback
ip_network("fc00::/7"), # IPv6 private (ULA)
ip_network("fe80::/10"), # IPv6 link-local
]
def _assert_ssrf_safe(url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise HTTPException(status_code=400, detail="Invalid URL: missing hostname")
try:
infos = socket.getaddrinfo(hostname, None)
if not infos:
raise HTTPException(status_code=400, detail="Could not resolve hostname")
ip = ip_address(infos[0][4][0])
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=400, detail="Could not resolve hostname")
if any(ip in net for net in _BLOCKED_NETS):
raise HTTPException(status_code=400, detail="URL resolves to a blocked/private address")
from langchain_core.documents import Document
from typing import Any
from .rag_chunking import (
CHROMA_COLLECTION_NAME,
CHROMA_PATH,
build_embedding_function,
enrich_documents,
scrape_page,
split_documents,
)
load_dotenv()
try:
from supabase import create_client
except Exception:
create_client = None
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
if not SUPABASE_URL or not SUPABASE_SERVICE_ROLE_KEY:
raise RuntimeError("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set in environment")
# File upload hard limits and allowed MIME types (can be overridden via env)
MAX_UPLOAD_SIZE_BYTES = int(os.getenv("MAX_UPLOAD_SIZE_BYTES", str(10 * 1024 * 1024))) # 10 MB default
ALLOWED_MIME_TYPES = ["application/pdf"]
if create_client is None:
raise RuntimeError("supabase-py is not installed. Please install the 'supabase' package to use storage features")
supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
router = APIRouter(tags=["Embeddings and RAG"])
# Number of chunks to embed+insert per batch. Kept small so streamed progress
# updates arrive frequently on the client.
EMBED_BATCH_SIZE = 32
def _ndjson(obj: dict) -> str:
"""Serialize a progress/result event as a single newline-delimited JSON line."""
return json.dumps(obj) + "\n"
def _extract_doc_id(inserted, db_res):
"""Best-effort extraction of the documents.id for the freshly-inserted row."""
if inserted:
if isinstance(inserted, list) and inserted and isinstance(inserted[0], dict):
return inserted[0].get("id")
if isinstance(inserted, dict):
return inserted.get("id")
try:
db_data = getattr(db_res, "data", None)
if isinstance(db_data, list) and db_data and isinstance(db_data[0], dict):
return db_data[0].get("id")
if isinstance(db_data, dict):
return db_data.get("id")
except Exception:
pass
return None
def _parse_pdf_to_docs(tmp_path: str, original_filename: str) -> list[Document]:
"""Extract page text (pdfplumber) and tables (camelot) into LangChain Documents.
Falls back to PyMuPDFLoader when pdfplumber is unavailable. Runs synchronously;
call via run_in_threadpool from async contexts.
"""
pdfplumber: Any = None
camelot: Any = None
try:
import pdfplumber as _pdfplumber
pdfplumber = _pdfplumber
except Exception:
pdfplumber = None
try:
import camelot as _camelot
camelot = _camelot
except Exception:
camelot = None
docs: list[Document] = []
if pdfplumber:
with pdfplumber.open(tmp_path) as pdf:
for i, page in enumerate(pdf.pages):
text = page.extract_text() or ""
docs.append(Document(page_content=text, metadata={"source": original_filename, "page": i}))
if camelot:
tables = []
try:
tables = camelot.read_pdf(tmp_path, pages="all", flavor="lattice")
if not tables or len(tables) == 0:
tables = camelot.read_pdf(tmp_path, pages="all", flavor="stream")
except Exception:
tables = []
for tbl in tables:
page_idx = None
page_attr = getattr(tbl, "page", None)
if isinstance(page_attr, (str, int)):
try:
page_idx = int(page_attr) - 1
except Exception:
page_idx = None
table_text = tbl.df.to_csv(sep="\t", index=False)
meta: dict[str, Any] = {"source": original_filename, "chunk_type": "table"}
if page_idx is not None:
meta["page"] = page_idx
docs.append(Document(page_content=table_text, metadata=meta))
else:
try:
from langchain_community.document_loaders import PyMuPDFLoader
docs = PyMuPDFLoader(str(tmp_path)).load()
except Exception as e:
raise RuntimeError(f"PDF parsing requires either pdfplumber or PyMuPDFLoader: {e}")
return docs
async def _embed_and_store(splits, *, user_uuid, doc_id_val, source_fields: dict):
"""Async generator: embed `splits` in batches, insert rows, yielding NDJSON
progress events (55→90%). Yields the total inserted-row count as the last item
via a final event with key `inserted_rows`.
"""
total = len(splits)
if total == 0:
yield {"inserted_rows": 0}
return
done_count = 0
embedder = await run_in_threadpool(build_embedding_function)
for i in range(0, total, EMBED_BATCH_SIZE):
batch_docs = splits[i : i + EMBED_BATCH_SIZE]
batch_texts = [d.page_content for d in batch_docs]
batch_embs = await run_in_threadpool(embedder.embed_documents, batch_texts)
rows = []
for doc_chunk, emb in zip(batch_docs, batch_embs):
row_item = {
"user_id": str(user_uuid),
"content": doc_chunk.page_content,
"embedding": emb,
"chunk_type": doc_chunk.metadata.get("chunk_type", "text"),
**source_fields,
}
if doc_id_val:
row_item["doc_id"] = doc_id_val
rows.append(row_item)
def _insert(batch=rows):
resp = supabase.table("rag_user_documents").insert(batch).execute()
resp_error = getattr(resp, "error", None)
resp_status = getattr(resp, "status_code", None)
if resp_error or (resp_status is not None and int(resp_status) >= 400):
raise RuntimeError(f"Supabase insert failed: {resp_error or resp_status}")
await run_in_threadpool(_insert)
done_count += len(batch_docs)
progress = 55 + int(35 * done_count / total) # 55 → 90
yield {
"stage": "embedding",
"message": f"Embedding chunks ({done_count}/{total})…",
"progress": progress,
}
yield {"inserted_rows": total}
@router.post("/chunk_pdf")
@limiter.limit("5/minute")
async def chunk_pdf(request: Request, file: UploadFile = File(...), user_id: str = Form(...), token_user_id: str = Depends(verify_token)):
# Basic MIME check (client-provided); perform stronger validation after reading bytes
if file.content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
# validate user_id is a proper UUID (Supabase `auth.users.id`)
try:
user_uuid = uuid.UUID(user_id)
except Exception:
raise HTTPException(status_code=400, detail="user_id must be a valid UUID corresponding to auth.users.id")
if str(user_uuid) != token_user_id:
raise HTTPException(status_code=403, detail="Forbidden: user_id does not match authenticated user")
contents = await file.read()
# Size check
if len(contents) > MAX_UPLOAD_SIZE_BYTES:
raise HTTPException(status_code=413, detail=f"File too large. Max size: {MAX_UPLOAD_SIZE_BYTES} bytes")
# Quick PDF header/magic check
if not isinstance(contents, (bytes, bytearray)) or not contents.startswith(b"%PDF"):
raise HTTPException(status_code=400, detail="Uploaded file does not appear to be a valid PDF")
# Sanitize filename
original_filename = Path(file.filename or "upload.pdf").name
unique_name = f"{uuid.uuid4().hex}_{original_filename}"
# store under user folder to match RLS storage policies
storage_path = f"{user_uuid}/{unique_name}"
async def event_stream():
tmp_path = None
try:
yield _ndjson({"stage": "uploading", "message": "Uploading file to storage…", "progress": 10})
# upload bytes to Supabase storage (bucket: documents)
try:
await run_in_threadpool(lambda: supabase.storage.from_('documents').upload(
storage_path,
contents,
{"content-type": "application/pdf"},
))
except Exception as e:
err_text = str(e)
if 'Bucket not found' in err_text or "statusCode': 404" in err_text:
err_text = (
"Supabase bucket 'documents' was not found. Please create a storage bucket "
"named 'documents' in your Supabase project. Original error: " + err_text
)
yield _ndjson({"stage": "error", "error": f"Storage upload failed: {err_text}"})
return
yield _ndjson({"stage": "saving", "message": "Saving document record…", "progress": 20})
row = {"user_id": str(user_uuid), "filename": original_filename, "storage_path": storage_path}
try:
db_res = await run_in_threadpool(lambda: supabase.table("documents").insert(row).execute())
except Exception as e:
yield _ndjson({"stage": "error", "error": f"DB insert failed: {e}"})
return
try:
sel = await run_in_threadpool(
lambda: supabase.table("documents").select("*").eq("storage_path", storage_path).execute()
)
sel_data = getattr(sel, "data", None)
inserted = sel_data[0] if isinstance(sel_data, list) and len(sel_data) > 0 else sel_data
except Exception:
inserted = None
# Embedding phase. Failures here are non-fatal: the document is already stored.
inserted_rows = 0
embed_error = None
try:
yield _ndjson({"stage": "parsing", "message": "Extracting text & tables…", "progress": 30})
import tempfile
suffix = Path(original_filename).suffix or ".pdf"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(contents)
tmp_path = tmp.name
docs = await run_in_threadpool(_parse_pdf_to_docs, tmp_path, original_filename)
docs = await run_in_threadpool(enrich_documents, docs)
yield _ndjson({"stage": "chunking", "message": "Splitting into chunks…", "progress": 45})
embedder = await run_in_threadpool(build_embedding_function)
splits = await run_in_threadpool(lambda: split_documents(docs, embedder=embedder))
doc_id_val = _extract_doc_id(inserted, db_res)
async for evt in _embed_and_store(
splits, user_uuid=user_uuid, doc_id_val=doc_id_val, source_fields={"source_type": "pdf"}
):
if "inserted_rows" in evt:
inserted_rows = evt["inserted_rows"]
else:
yield _ndjson(evt)
except Exception as e:
embed_error = str(e)
finally:
if tmp_path:
try:
os.remove(tmp_path)
except Exception:
pass
result = {
"status": "ok",
"storage_path": storage_path,
"document": inserted,
"db_result": getattr(db_res, "data", db_res),
"embeddings": {"inserted_rows": inserted_rows, "error": embed_error},
}
yield _ndjson({"stage": "done", "message": "Done", "progress": 100, "result": result})
except Exception as e:
yield _ndjson({"stage": "error", "error": str(e)})
return StreamingResponse(event_stream(), media_type="application/x-ndjson")
@router.post("/chunk_url")
@limiter.limit("5/minute")
async def chunk_url(request: Request, url: str = Form(...), user_id: str = Form(...), token_user_id: str = Depends(verify_token)):
# Validate URL scheme and guard against SSRF
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="URL must start with http:// or https://")
_assert_ssrf_safe(url)
try:
user_uuid = uuid.UUID(user_id)
except Exception:
raise HTTPException(status_code=400, detail="user_id must be a valid UUID corresponding to auth.users.id")
if str(user_uuid) != token_user_id:
raise HTTPException(status_code=403, detail="Forbidden: user_id does not match authenticated user")
original_filename = Path(parsed.path).name or parsed.netloc
storage_path = url # store the original URL in the documents row for reference
async def event_stream():
try:
yield _ndjson({"stage": "fetching", "message": "Fetching page…", "progress": 10})
try:
content = await run_in_threadpool(scrape_page, url)
except Exception as e:
yield _ndjson({"stage": "error", "error": f"Failed to fetch/parse URL: {e}"})
return
yield _ndjson({"stage": "saving", "message": "Saving document record…", "progress": 25})
row = {"user_id": str(user_uuid), "filename": original_filename, "storage_path": storage_path}
try:
db_res = await run_in_threadpool(lambda: supabase.table("documents").insert(row).execute())
except Exception as e:
yield _ndjson({"stage": "error", "error": f"DB insert failed: {e}"})
return
try:
sel = await run_in_threadpool(
lambda: supabase.table("documents").select("*").eq("storage_path", storage_path).execute()
)
sel_data = getattr(sel, "data", None)
inserted = sel_data[0] if isinstance(sel_data, list) and len(sel_data) > 0 else sel_data
except Exception:
inserted = None
inserted_rows = 0
embed_error = None
try:
yield _ndjson({"stage": "chunking", "message": "Splitting into chunks…", "progress": 40})
docs: list[Document] = [Document(page_content=content, metadata={"source": original_filename})]
docs = await run_in_threadpool(enrich_documents, docs)
embedder = await run_in_threadpool(build_embedding_function)
splits = await run_in_threadpool(lambda: split_documents(docs, embedder=embedder))
doc_id_val = _extract_doc_id(inserted, db_res)
async for evt in _embed_and_store(
splits, user_uuid=user_uuid, doc_id_val=doc_id_val,
source_fields={"source_type": "url", "url": url},
):
if "inserted_rows" in evt:
inserted_rows = evt["inserted_rows"]
else:
yield _ndjson(evt)
except Exception as e:
embed_error = str(e)
result = {
"status": "ok",
"url": storage_path,
"document": inserted,
"db_result": getattr(db_res, "data", db_res),
"embeddings": {"inserted_rows": inserted_rows, "error": embed_error},
}
yield _ndjson({"stage": "done", "message": "Done", "progress": 100, "result": result})
except Exception as e:
yield _ndjson({"stage": "error", "error": str(e)})
return StreamingResponse(event_stream(), media_type="application/x-ndjson")