CoolFace
Apppublic

melbinjp/DocQA

sourceHugging Faceupdated 25d agoView on Hugging Face
0likes
app.py699 linesDownload Raw Back to root
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