melbinjp/DocQA
0
1"""QA RAG application with multi-document user sessions."""2import os3import uuid4import pathlib5import datetime6import asyncio7import threading8from contextlib import asynccontextmanager9from typing import List, Optional, Dict, Any, Union10 11import json12from dotenv import load_dotenv13from fastapi import FastAPI, UploadFile, File, HTTPException, Body, Request14from fastapi.responses import RedirectResponse, StreamingResponse15from fastapi.middleware.cors import CORSMiddleware16from pydantic import BaseModel, Field17from google import genai18from google.genai import errors19import uvicorn20import httpx21import numpy as np22 23# Set Hugging Face Hub download timeout to 120 seconds to prevent ReadTimeoutErrors in Spaces24os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "120"25 26from sentence_transformers import SentenceTransformer27from utils.loaders import load_source, load_source_pages28from utils.url_fetch import (fetch_url_document, is_safe_url_async,29 looks_like_a_bot_wall)30from utils.prompting import build_manifest, label_chunk31from utils.vision import (MAX_VISION_PAGES, pages_for_vision,32 transcribe_pages, wrap_transcript)33from utils.splitter import split_text, split_pages, index_entries34from utils.exceptions import DocumentLoaderError35from rag_session import RAGSession36from user_session import UserSession37 38# Load environment variables39load_dotenv()40 41# --- Configuration ---42SESSION_CLEANUP_INTERVAL_SECONDS = 30043SESSION_TIMEOUT_MINUTES = 1544 45# --- In-Memory Session Storage ---46sessions: Dict[str, UserSession] = {}47_session_lock = threading.Lock()48 49# --- Background Cleanup Logic ---50def _clean_sessions_once():51 now = datetime.datetime.now()52 expiration_time = datetime.timedelta(minutes=SESSION_TIMEOUT_MINUTES)53 54 with _session_lock:55 # Create a copy of the session IDs to avoid modifying the dictionary while iterating56 expired_ids = [57 session_id for session_id, session in sessions.items()58 if now - session.last_accessed > expiration_time59 ]60 for session_id in expired_ids:61 del sessions[session_id]62 print(f"Cleaned up expired user session: {session_id}")63 64async def cleanup_expired_sessions_task():65 while True:66 _clean_sessions_once()67 await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS)68 69# --- FastAPI Lifespan Management ---70@asynccontextmanager71async def lifespan(app: FastAPI):72 print("Loading embedding model...")73 app.state.embedding_model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')74 print("Embedding model loaded.")75 76 print("Initializing HTTP client...")77 headers = {78 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"79 }80 app.state.http_client = httpx.AsyncClient(headers=headers)81 print("HTTP client initialized.")82 83 print("Starting session cleanup task...")84 asyncio.create_task(cleanup_expired_sessions_task())85 yield86 87 print("Closing HTTP client...")88 await app.state.http_client.aclose()89 print("Application shutdown.")90 91# --- App Initialization ---92app = FastAPI(93 title="DocQA",94 description="A RAG application supporting multi-document user sessions.",95 version="2.0.0",96 lifespan=lifespan97)98 99# --- LLM and CORS Configuration ---100GENAI_API_KEY = os.getenv("GOOGLE_API_KEY")101if not GENAI_API_KEY:102 raise RuntimeError("GOOGLE_API_KEY environment variable not set.")103# Initialize the modern google-genai Client104ai_client = genai.Client(api_key=GENAI_API_KEY)105MODEL_NAME = "gemini-3.5-flash"106FALLBACK_MODELS = ["gemini-3.1-flash-lite", "gemini-2.5-flash"]107 108# Two ceilings that were inline literals, named so they are visible in one place.109# 30s is the value the code already used. Warm calls to this Space come back in a110# few seconds; the ones that run past 30s are the ones right after the Space wakes111# from sleep, and those are now retried rather than surfaced. Raising the ceiling112# instead would have made every genuinely stuck call three times slower to report.113LLM_TIMEOUT_SECONDS = 30.0114 115# How many chunks are retrieved per document, and how many survive the merge to116# become the prompt. These were 5 and a bare `[:5]` literal further down, which117# meant raising one silently did nothing because the other still truncated.118#119# Eight rather than five because chunks are now 1500 characters of clean prose120# rather than 500 characters cut mid-sentence, so the marginal chunk is worth121# reading. Eight of them is around 12k characters, which is a small prompt for122# the model receiving it.123#124# The merge across documents sorts by score, and that only became sound when the125# index moved to cosine: the old 1/(1+l2) score was magnitude-biased, so scores126# from two documents with different chunk lengths were not on the same scale.127# Looking at a page costs a model call, so it is off by default for anyone128# running this without the quota for it, and on here because the alternative is129# rejecting every scanned document outright.130VISION_ENABLED = os.getenv("VISION_ENABLED", "1") not in ("0", "false", "False")131 132# Which pages get looked at. Default is scanned pages only, because that is the133# case measurement showed is a hard failure; tables and figures already answer134# correctly from extracted text. VISION_SCOPE=all spends a request on those too.135VISION_SCOPE = os.getenv("VISION_SCOPE", "scanned")136 137RETRIEVE_PER_DOC = 8138CONTEXT_CHUNKS = 8139 140# `wait_for` around `generate_content_stream(...)` bounds getting the iterator, not141# consuming it. This bounds each chunk, so a stream that stalls mid-answer ends142# instead of leaving the browser on an open connection with no error and no end.143STREAM_CHUNK_TIMEOUT = 30.0144 145app.add_middleware(146 CORSMiddleware, allow_origins=["*"], allow_credentials=True,147 allow_methods=["*"], allow_headers=["*"],148)149 150# --- Helper Functions ---151async def generate_rag_response(query: str, context_chunks: List[str], stream: bool = False):152 """Generates a response from the LLM, supports streaming."""153 if not context_chunks:154 if stream:155 yield f"data: {json.dumps({'token': 'No relevant information found.'})}\n\n"156 else:157 yield "No relevant information found."158 return159 160 context = "\n\n".join(context_chunks)161 prompt = (162 "Answer the following question using only the material below. It begins "163 "with the list of documents loaded in this session, then excerpts, each "164 "headed by the document and page it came from. When the question is "165 "about which document says or covers something, answer from the list of "166 "documents, not from excerpts: a reference list inside a paper names "167 "other works and is not itself a document in this session. "168 "If the excerpts do not answer the question, say so instead of guessing. "169 # Ask for the citation in the answer, not only beside it.170 #171 # The page was reaching the reader in the sources list underneath and172 # almost never in the sentence that made the claim, so anyone reading173 # only the answer, which is most people, got an unsourced statement and174 # had to go looking for which of eight excerpts it came from. Citing in175 # the prose is the difference between an answer you can check and an176 # answer you have to trust.177 "Cite where each fact came from, in the sentence that states it, as "178 "(page 4) or (report.pdf, page 4) when more than one document is "179 "loaded. Use only the document names and page numbers written in the "180 "headings above the excerpts. Never invent or guess a page number, and "181 "if an excerpt's heading gives no page, name the document alone. "182 "If the user asks in a language other than English, respond in their language.\n\n"183 f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer:"184 )185 186 max_retries = 3187 retry_delay = 1.0188 emitted = False189 190 for attempt in range(max_retries):191 current_model = MODEL_NAME192 if attempt > 0 and attempt - 1 < len(FALLBACK_MODELS):193 current_model = FALLBACK_MODELS[attempt - 1]194 195 last_attempt = attempt == max_retries - 1196 197 try:198 if stream:199 response = await asyncio.wait_for(200 ai_client.aio.models.generate_content_stream(201 model=current_model,202 contents=prompt203 ),204 timeout=LLM_TIMEOUT_SECONDS205 )206 iterator = response.__aiter__()207 while True:208 try:209 chunk = await asyncio.wait_for(210 iterator.__anext__(), timeout=STREAM_CHUNK_TIMEOUT211 )212 except StopAsyncIteration:213 break214 # Ensure the chunk has content before sending215 if chunk.text:216 emitted = True217 yield f"data: {json.dumps({'token': chunk.text})}\n\n"218 return # Exit generator on success219 else:220 response = await asyncio.wait_for(221 ai_client.aio.models.generate_content(222 model=current_model,223 contents=prompt224 ),225 timeout=LLM_TIMEOUT_SECONDS226 )227 yield response.text.strip()228 return # Exit generator on success229 except (asyncio.TimeoutError, errors.APIError) as e:230 # A timeout and a 429/503 are the same kind of problem: the answer was231 # never produced, so asking again is safe. Anything else is a real232 # error, and retrying it three times only delays telling the user.233 if isinstance(e, errors.APIError) and e.code not in (429, 503):234 error_message = f"LLM generation failed: {e.message}"235 if stream:236 yield f"data: {json.dumps({'error': error_message})}\n\n"237 return238 else:239 raise HTTPException(status_code=500, detail=error_message)240 241 timed_out = isinstance(e, asyncio.TimeoutError)242 243 # Once tokens are on the wire the answer is half delivered, and244 # starting over would repeat what the reader has already seen. A245 # stream that fails mid-answer ends with an error, not a second try.246 if not last_attempt and not emitted:247 reason = "Timed out" if timed_out else "High demand hit"248 print(f"{reason} for {current_model}. Retrying with fallback... "249 f"(Attempt {attempt+1}/{max_retries})")250 await asyncio.sleep(retry_delay)251 retry_delay *= 2252 continue253 254 if timed_out:255 error_message = "LLM generation timed out."256 status_code = 504257 else:258 error_message = "Model is experiencing high demand. Please try again later."259 status_code = 503260 if stream:261 yield f"data: {json.dumps({'error': error_message})}\n\n"262 return263 else:264 raise HTTPException(status_code=status_code, detail=error_message)265 except Exception as e:266 error_message = f"LLM generation failed: {e}"267 if stream:268 yield f"data: {json.dumps({'error': error_message})}\n\n"269 return270 else:271 raise HTTPException(status_code=500, detail=error_message)272 273# --- API Models ---274class SessionResponse(BaseModel):275 session_id: str276 277class IngestResponse(BaseModel):278 doc_id: str279 source: str280 num_chunks: int281 282class QueryPayload(BaseModel):283 q: str284 doc_ids: Optional[List[str]] = None285 stream: Optional[bool] = False286 287class QuerySource(BaseModel):288 text: str289 score: float290 doc_id: str291 source: str292 page: Optional[int] = None293 294class QueryResponse(BaseModel):295 answer: str296 sources: List[QuerySource]297 298class SessionStatusResponse(BaseModel):299 session_id: str300 active: bool301 remaining_minutes: Optional[float] = None302 last_accessed: str303 304class SessionRefreshResponse(BaseModel):305 session_id: str306 refreshed_at: str307 remaining_minutes: float308 309# --- API Endpoints ---310@app.get("/", include_in_schema=False)311async def root():312 return RedirectResponse(url="/docs")313 314@app.post("/sessions", response_model=SessionResponse, summary="Create a new user session")315async def create_session():316 session_id = uuid.uuid4().hex317 with _session_lock:318 sessions[session_id] = UserSession()319 return SessionResponse(session_id=session_id)320 321@app.post("/sessions/{session_id}/ingest", response_model=IngestResponse, summary="Ingest a document into a session")322async def ingest(session_id: str, request: Request):323 with _session_lock:324 user_session = sessions.get(session_id)325 if not user_session:326 raise HTTPException(status_code=404, detail="User session not found.")327 328 file_filename = None329 file_content = None330 url = None331 has_file = False332 333 body_bytes = await request.body()334 335 # 1. Try to parse as JSON URL first336 try:337 body = json.loads(body_bytes)338 url = body.get("url")339 except Exception:340 pass341 342 # 2. If no URL was successfully parsed, try parsing as Form data343 if not url:344 try:345 form = await request.form()346 file = form.get("file")347 if file and hasattr(file, "filename") and file.filename:348 file_filename = file.filename349 file_content = await file.read()350 has_file = True351 except Exception:352 pass353 354 if not has_file and not url:355 content_type = request.headers.get("content-type", "")356 raise HTTPException(357 status_code=400,358 detail=(359 f"Provide either a file (multipart/form-data) or a URL (application/json). "360 f"Received Content-Type: {content_type}."361 )362 )363 if has_file and url:364 raise HTTPException(status_code=400, detail="Provide either a file or a URL, not both.")365 366 source_name = ""367 content = b""368 source_ext = "url"369 370 if has_file:371 source_name = file_filename372 content = file_content373 source_ext = pathlib.Path(source_name).suffix or "url"374 elif url:375 if not await is_safe_url_async(url):376 raise HTTPException(status_code=400, detail="Invalid or restricted URL provided.")377 source_name = url378 content, source_ext = await fetch_url_document(app.state.http_client, url)379 380 try:381 pages = load_source_pages(content, source_ext)382 except DocumentLoaderError as e:383 # Hold the failure rather than raising it. For a PDF this is usually a384 # scan, which has no text layer to extract and which the vision pass385 # below can read. If that finds nothing either, the error is raised then.386 if source_ext.lower().strip(".") != "pdf":387 raise HTTPException(status_code=400, detail=str(e))388 pages = []389 390 # Look at the pages a text extractor cannot read: scans, charts, and tables391 # it mangles. Additive, and never allowed to fail the ingest on its own.392 vision_note = ""393 vision_transcripts = {}394 if source_ext.lower().strip(".") == "pdf" and VISION_ENABLED:395 try:396 targets = await asyncio.to_thread(397 pages_for_vision, content, MAX_VISION_PAGES, VISION_SCOPE398 )399 if targets:400 # The same ladder the answering path walks, so a quota error on401 # the first model is a queue rather than a dead end.402 transcripts, vision_errors = await transcribe_pages(403 ai_client, [MODEL_NAME] + FALLBACK_MODELS, targets404 )405 # Kept apart from the extracted pages rather than concatenated406 # onto them. Concatenating put the marker at the top of the page407 # and the splitter then cut the page into four, so three of the408 # four chunks carried no sign of where they came from. The whole409 # point is that a reader can tell a transcription from the410 # document's own words, so every transcript chunk carries it.411 vision_transcripts = transcripts412 print(f"Vision read {len(transcripts)} of {len(targets)} pages. "413 f"errors={vision_errors[:3]}")414 if not transcripts and vision_errors:415 vision_note = (f" The page reader was tried on {len(targets)} "416 f"pages and returned nothing ({vision_errors[0]}).")417 except Exception as e:418 vision_note = f" The page reader could not run ({type(e).__name__}: {e})."419 print(f"Vision pass skipped: {e}")420 421 # A bot wall is not a document. Extracted text that reads as a challenge422 # page means we fetched the site's gate, not the file, and ingesting it423 # produces a session that claims to hold one thing and holds another.424 if url and pages and looks_like_a_bot_wall("\n".join(t for _, t in pages)):425 raise HTTPException(426 status_code=400,427 detail=("That site returned a bot check rather than the document. "428 "Download the file and upload it here instead."),429 )430 431 if (not pages or not any(t and t.strip() for _, t in pages)) and not vision_transcripts:432 # Say why, when there is a why. A scanned PDF that fails silently is433 # indistinguishable from an unsupported one, and neither the user nor434 # anyone debugging it can tell which they have.435 raise HTTPException(436 status_code=400,437 detail="Could not extract any text from the provided source." + vision_note,438 )439 440 page_chunks = split_pages(pages)441 for chunk in split_pages(sorted(vision_transcripts.items())):442 chunk["text"] = wrap_transcript(chunk["text"])443 page_chunks.append(chunk)444 page_chunks.sort(key=lambda c: (c["page"] is None, c["page"] or 0))445 446 chunks = [c["text"] for c in page_chunks]447 chunk_pages = [c["page"] for c in page_chunks]448 449 # What is matched is not always what is read. A table chunk carries an450 # `embed_text` of just its caption, because a vector built from the whole451 # grid is dominated by the numbers and stops being findable: measured452 # 2026-09-01, the Table 3 chunk was not retrieved for a question its own453 # caption answers, while its neighbours scored 0.36 to 0.47. The full grid454 # is still what goes into the prompt and still what gets cited.455 # One chunk can own several vectors. A long chunk is indexed whole and again456 # window by window, so a fact buried inside a chunk about something else is457 # still reachable; whichever vector matches, the parent chunk is returned.458 entries = index_entries(page_chunks)459 embed_inputs = [text for text, _ in entries]460 vector_parents = [parent for _, parent in entries]461 if not chunks:462 raise HTTPException(status_code=400, detail="The document is too short to be processed.")463 464 # --- Caching and Embedding Logic ---465 # This logic checks the user's session cache for existing chunk embeddings.466 # It only sends chunks that have not been seen before to the embedding model,467 # avoiding redundant, expensive computations.468 ordered_embeddings = [None] * len(embed_inputs)469 chunks_to_encode = []470 indices_of_new_chunks = []471 472 # Identify which chunks are new and which are cached. Keyed on what is473 # actually encoded, not on the chunk text, or a table would be looked up by474 # its grid and stored under its caption.475 for i, chunk in enumerate(embed_inputs):476 if chunk in user_session.embedding_cache:477 ordered_embeddings[i] = user_session.embedding_cache[chunk]478 else:479 chunks_to_encode.append(chunk)480 indices_of_new_chunks.append(i)481 482 # If there are new chunks, encode them in a single batch for efficiency483 if chunks_to_encode:484 # Encode each unique new chunk only once to save computation485 unique_new_chunks = list(dict.fromkeys(chunks_to_encode))486 487 try:488 generated_embeddings = await asyncio.wait_for(489 asyncio.to_thread(490 app.state.embedding_model.encode, unique_new_chunks, convert_to_numpy=True491 ),492 timeout=180.0493 )494 except asyncio.TimeoutError:495 raise HTTPException(status_code=504, detail="Embedding generation timed out.")496 except Exception as e:497 raise HTTPException(status_code=500, detail=f"Embedding generation failed: {e}")498 499 new_embeddings_dict = {500 chunk: emb for chunk, emb in zip(unique_new_chunks, generated_embeddings)501 }502 503 # Add the newly generated embeddings to the session cache for future use504 user_session.embedding_cache.update(new_embeddings_dict)505 506 # Place the new embeddings into the final ordered list507 for i, chunk in enumerate(chunks_to_encode):508 original_index = indices_of_new_chunks[i]509 ordered_embeddings[original_index] = new_embeddings_dict[chunk]510 511 all_embeddings_np = np.array(ordered_embeddings)512 # --- End of Caching and Embedding Logic ---513 514 doc_id = uuid.uuid4().hex515 rag_session = RAGSession(source=source_name, embedding_model=app.state.embedding_model)516 # Pass the pre-computed embeddings to the new ingest method517 rag_session.ingest(chunks, all_embeddings_np, chunk_pages, vector_parents)518 user_session.add_doc(doc_id, rag_session)519 520 return IngestResponse(doc_id=doc_id, source=source_name, num_chunks=len(chunks))521 522@app.post("/sessions/{session_id}/query", summary="Ask a question within a session")523async def query(session_id: str, payload: QueryPayload):524 with _session_lock:525 user_session = sessions.get(session_id)526 if not user_session:527 raise HTTPException(status_code=404, detail="User session not found.")528 529 user_session.touch()530 531 # Determine which documents to query.532 docs_to_query_items = user_session.docs.items()533 if payload.doc_ids:534 docs_to_query_items = [535 (doc_id, user_session.get_doc(doc_id))536 for doc_id in payload.doc_ids537 if user_session.get_doc(doc_id) is not None538 ]539 540 all_chunks = []541 for doc_id, rag_session in docs_to_query_items:542 try:543 retrieved = await rag_session.query(payload.q, k=RETRIEVE_PER_DOC)544 except TimeoutError as e:545 raise HTTPException(status_code=504, detail=str(e))546 except Exception as e:547 raise HTTPException(status_code=500, detail=f"Query search failed: {e}")548 549 for chunk in retrieved:550 chunk['doc_id'] = doc_id551 chunk['source'] = rag_session.source552 all_chunks.extend(retrieved)553 554 all_chunks.sort(key=lambda x: x['score'], reverse=True)555 top_chunks = all_chunks[:CONTEXT_CHUNKS]556 557 relevant_sources = [QuerySource(**chunk) for chunk in top_chunks]558 # Label every excerpt with where it came from.559 #560 # The prompt used to be the chunk texts joined by blank lines and nothing561 # else, so the model was never told which document it was reading. With one562 # document that is merely wasteful. With eight in a session it makes a whole563 # class of question unanswerable, and measured 2026-09-01 it did: asked564 # which of the loaded papers was about image recognition, the answer listed565 # three entries out of ResNet's own bibliography instead of naming ResNet,566 # because a chunk of reference list and a chunk of a paper look identical567 # when neither says what it is. It also risks quietly attributing one568 # document's numbers to another.569 relevant_texts = [label_chunk(chunk) for chunk in top_chunks]570 571 # And always say what is loaded, whatever retrieval happened to return. A572 # question about the set of documents cannot be answered from chunks: the573 # chunks that match "image recognition" best are a reference list, not the574 # abstract of the paper that is about it.575 manifest = build_manifest(576 (rag.source, rag.chunks[0] if rag.chunks else "")577 for rag in user_session.docs.values()578 )579 if manifest:580 relevant_texts = [manifest] + relevant_texts581 582 # If streaming is requested, return a StreamingResponse583 if payload.stream:584 async def stream_generator():585 # First, send an event with the sources586 sources_data = [s.model_dump() for s in relevant_sources]587 yield f"data: {json.dumps({'type': 'sources', 'data': sources_data})}\n\n"588 589 # Then, stream the LLM response tokens590 async for chunk in generate_rag_response(payload.q, relevant_texts, stream=True):591 yield chunk592 593 # Signal the end of the stream594 yield f"data: {json.dumps({'type': 'end'})}\n\n"595 596 return StreamingResponse(597 stream_generator(),598 media_type="text/event-stream",599 headers={600 "Cache-Control": "no-cache",601 "Connection": "keep-alive",602 "X-Accel-Buffering": "no"603 }604 )605 606 # If not streaming, use the original logic607 else:608 answer = ""609 # The async generator yields one result in non-streaming mode610 async for content in generate_rag_response(payload.q, relevant_texts, stream=False):611 answer = content612 return QueryResponse(answer=answer, sources=relevant_sources)613 614@app.get("/sessions/{session_id}/status", response_model=SessionStatusResponse, summary="Get session status and remaining time")615async def get_session_status(session_id: str):616 """Returns session status, activity state, and remaining time before expiration."""617 now = datetime.datetime.now()618 expiration_time = datetime.timedelta(minutes=SESSION_TIMEOUT_MINUTES)619 620 with _session_lock:621 user_session = sessions.get(session_id)622 623 if not user_session:624 return SessionStatusResponse(625 session_id=session_id,626 active=False,627 last_accessed=now.isoformat()628 )629 630 time_since_access = now - user_session.last_accessed631 remaining_time = expiration_time - time_since_access632 633 if remaining_time.total_seconds() <= 0:634 return SessionStatusResponse(635 session_id=session_id,636 active=False,637 last_accessed=user_session.last_accessed.isoformat()638 )639 640 return SessionStatusResponse(641 session_id=session_id,642 active=True,643 remaining_minutes=remaining_time.total_seconds() / 60,644 last_accessed=user_session.last_accessed.isoformat()645 )646 647@app.post("/sessions/{session_id}/refresh", response_model=SessionRefreshResponse, summary="Refresh session to extend timeout")648async def refresh_session(session_id: str):649 """Refreshes a session to extend its timeout period."""650 with _session_lock:651 user_session = sessions.get(session_id)652 653 if not user_session:654 raise HTTPException(status_code=404, detail="User session not found.")655 656 user_session.touch()657 658 return SessionRefreshResponse(659 session_id=session_id,660 refreshed_at=user_session.last_accessed.isoformat(),661 remaining_minutes=SESSION_TIMEOUT_MINUTES662 )663 664@app.get("/sessions/{session_id}/health", summary="Simple session health check")665async def session_health_check(session_id: str):666 """Simple endpoint to check if session exists and is active."""667 now = datetime.datetime.now()668 expiration_time = datetime.timedelta(minutes=SESSION_TIMEOUT_MINUTES)669 670 with _session_lock:671 user_session = sessions.get(session_id)672 673 if not user_session:674 raise HTTPException(status_code=404, detail="Session not found")675 676 time_since_access = now - user_session.last_accessed677 if time_since_access > expiration_time:678 raise HTTPException(status_code=410, detail="Session expired")679 680 return {"status": "active"}681 682@app.delete("/sessions/{session_id}/documents/{doc_id}", status_code=204, summary="Delete a document from a session")683async def delete_document(session_id: str, doc_id: str):684 """Deletes a specific document from a user session."""685 with _session_lock:686 user_session = sessions.get(session_id)687 if not user_session:688 raise HTTPException(status_code=404, detail="User session not found.")689 690 if not user_session.get_doc(doc_id):691 raise HTTPException(status_code=404, detail="Document not found in this session.")692 693 user_session.remove_doc(doc_id)694 return695 696# ... (Main Execution block, no changes)697if __name__ == "__main__":698 uvicorn.run(app, host="0.0.0.0", port=7860)699 