Spaces:
Sleeping
Sleeping
Optimize RAG evaluation to unified single LLM call, increase timeouts, and handle 429 quota limits
1d7b287 | import asyncio | |
| import logging | |
| import os | |
| import uuid | |
| from copy import deepcopy | |
| from typing import Any, List | |
| from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Body | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from sqlalchemy import select | |
| from pydantic import BaseModel | |
| from app.database import AsyncSessionLocal, get_db | |
| from app.auth import get_current_user | |
| from app.models.user import User | |
| from app.models.document import Document | |
| from app.models.rag_config import RAGConfig | |
| from app.models.chat import ChatMessage, ChatMessageResponse | |
| from app.models.metrics import Metrics | |
| from app.utils.timing import PipelineTimer | |
| from app.services.rate_limiter import DatabaseRateLimiter, get_rate_limiter | |
| from app.services.query_classifier import classify_query | |
| from app.services.summary_service import SummaryService | |
| from app.utils.file_processor import FileProcessor | |
| from app.services.rate_limiter import RateLimitExceededException | |
| from app.services.indexing_jobs import IndexingJobStore | |
| router = APIRouter(prefix="/api/chat", tags=["chat"]) | |
| logger = logging.getLogger(__name__) | |
| GLOBAL_SUMMARY_FALLBACK_QUERY = ( | |
| "Provide a document-level summary, main idea, and key takeaways." | |
| ) | |
| def should_precompute_summaries() -> bool: | |
| return os.getenv("PRECOMPUTE_SUMMARIES_ON_PREPARE", "0").strip().lower() in { | |
| "1", | |
| "true", | |
| "yes", | |
| "on", | |
| } | |
| def _normalize_scores_for_display(scores: List[float]) -> List[float]: | |
| """Normalize retrieval/rerank scores to a stable [0, 1] range for UI display.""" | |
| if not scores: | |
| return [] | |
| cleaned = [float(s) for s in scores] | |
| if all(abs(s) < 1e-12 for s in cleaned): | |
| return [0.0 for _ in cleaned] | |
| # If already in [0, 1], keep as-is. | |
| if all(0.0 <= s <= 1.0 for s in cleaned): | |
| return cleaned | |
| # Fallback for logits or arbitrary scales. | |
| mn = min(cleaned) | |
| mx = max(cleaned) | |
| if abs(mx - mn) < 1e-12: | |
| return [0.5 for _ in cleaned] | |
| return [(s - mn) / (mx - mn) for s in cleaned] | |
| def _fallback_answer_from_chunks(query: str, chunks: List[Any]) -> str: | |
| """Build a deterministic answer from retrieved chunks when LLM is unavailable.""" | |
| if not chunks: | |
| return "No relevant chunks were retrieved for this question." | |
| excerpts = [] | |
| for idx, chunk in enumerate(chunks[:3], start=1): | |
| text = str(getattr(chunk, "text", chunk)).strip().replace("\n", " ") | |
| if len(text) > 240: | |
| text = text[:240] + "..." | |
| excerpts.append(f"{idx}. {text}") | |
| joined = "\n".join(excerpts) | |
| return ( | |
| "LLM is temporarily unavailable, so this answer is based directly on retrieved chunks.\n" | |
| f"Question: {query}\n" | |
| "Relevant excerpts:\n" | |
| f"{joined}" | |
| ) | |
| def _extract_chunks(results: List[Any]) -> List[Any]: | |
| if not results: | |
| return [] | |
| if isinstance(results[0], tuple): | |
| return [res[0] for res in results] | |
| return results | |
| def _serialize_retrieved_chunks(results: List[Any]) -> list[dict[str, Any]]: | |
| if not results: | |
| return [] | |
| raw_scores = [] | |
| for res in results: | |
| if isinstance(res, tuple) and len(res) == 2: | |
| raw_scores.append(float(res[1])) | |
| else: | |
| raw_scores.append(float(getattr(res, "score", 0.0))) | |
| display_scores = _normalize_scores_for_display(raw_scores) | |
| serialized = [] | |
| for idx, res in enumerate(results): | |
| if isinstance(res, tuple) and len(res) == 2: | |
| chunk, score = res | |
| else: | |
| chunk = res | |
| score = getattr(chunk, "score", 0.0) | |
| chunk_metadata = getattr(chunk, "metadata", {}) or {} | |
| serialized.append({ | |
| "id": str(getattr(chunk, "id", idx)), | |
| "text": chunk_metadata.get("window_text") or getattr(chunk, "text", str(chunk)), | |
| "score": float(display_scores[idx]) if idx < len(display_scores) else float(score), | |
| "raw_score": float(score), | |
| "section_heading": chunk_metadata.get("section_heading"), | |
| }) | |
| return serialized | |
| async def _ensure_summary_for_doc(db: AsyncSession, pipeline: Any, doc: Document, user_id: uuid.UUID, config_id: uuid.UUID): | |
| llm_client = getattr(pipeline, "llm_client", None) | |
| if not llm_client or not getattr(llm_client, "llm", None): | |
| return None | |
| chunks = pipeline.chunker.chunk( | |
| text=doc.content, | |
| metadata={"filename": doc.filename, "file_type": doc.file_type}, | |
| ) | |
| return await SummaryService.ensure_precomputed_summary( | |
| db=db, | |
| user_id=user_id, | |
| document_id=doc.id, | |
| config_id=config_id, | |
| chunks=chunks, | |
| llm_client=llm_client, | |
| ) | |
| async def _precompute_summary_background( | |
| user_id: uuid.UUID, | |
| doc_id: uuid.UUID, | |
| config_id: uuid.UUID, | |
| doc_content: str, | |
| doc_filename: str, | |
| doc_file_type: str, | |
| pipeline_config: dict, | |
| ) -> None: | |
| try: | |
| from app.database import AsyncSessionLocal | |
| from app.services.pipeline_factory import PipelineFactory | |
| pipeline = PipelineFactory.create_pipeline(pipeline_config) | |
| chunks = pipeline.chunker.chunk( | |
| text=doc_content, | |
| metadata={"filename": doc_filename, "file_type": doc_file_type}, | |
| ) | |
| async with AsyncSessionLocal() as background_db: | |
| await SummaryService.ensure_precomputed_summary( | |
| db=background_db, | |
| user_id=user_id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| chunks=chunks, | |
| llm_client=getattr(pipeline, "llm_client", None), | |
| ) | |
| await background_db.commit() | |
| except Exception: | |
| logger.exception("Background summary precompute failed for doc_id=%s config_id=%s", doc_id, config_id) | |
| async def _generate_summary_on_the_fly(pipeline: Any) -> tuple[str | None, List[Any], List[Any]]: | |
| fallback_results = await pipeline.aretrieve(query=GLOBAL_SUMMARY_FALLBACK_QUERY, top_k=30) | |
| fallback_chunks = _extract_chunks(fallback_results) | |
| summary = await SummaryService.generate_doc_summary( | |
| chunks=fallback_chunks, | |
| llm_client=getattr(pipeline, "llm_client", None), | |
| ) | |
| return summary, fallback_results, fallback_chunks | |
| class PrepareChatRequest(BaseModel): | |
| document_id: uuid.UUID | |
| document_ids: List[uuid.UUID] | None = None | |
| config_id: uuid.UUID | |
| async def _run_indexing_background( | |
| job_id: str, | |
| docs: list, | |
| pipeline_config: dict, | |
| user_id: str, | |
| config_id: str, | |
| ) -> None: | |
| """Background task: index all documents and update job status (P2.1).""" | |
| from app.services.pipeline_manager import PipelineManager | |
| IndexingJobStore.update(job_id, status="indexing", progress_pct=0) | |
| total = len(docs) | |
| try: | |
| pipeline = PipelineManager.get_pipeline(f"{user_id}:{config_id}", pipeline_config) | |
| indexing_lock = await PipelineManager.get_indexing_lock(f"{user_id}:{config_id}") | |
| async with indexing_lock: | |
| for i, doc in enumerate(docs): | |
| if doc.file_type.lower() == "pdf": | |
| try: | |
| pages = FileProcessor.extract_pdf_pages(doc.content, doc.filename) | |
| await pipeline.aindex_document_with_pages( | |
| pages=pages, | |
| doc_id=str(doc.id), | |
| base_metadata={"filename": doc.filename, "file_type": doc.file_type}, | |
| ) | |
| await pipeline.aindex_pdf_images( | |
| pdf_content=doc.content, | |
| filename=doc.filename, | |
| doc_id=str(doc.id), | |
| base_metadata={"filename": doc.filename, "file_type": doc.file_type}, | |
| ) | |
| logger.info( | |
| "[job=%s] Indexed PDF '%s' (%d pages)", | |
| job_id, doc.filename, len(pages), | |
| ) | |
| except Exception as e: | |
| logger.warning( | |
| "[job=%s] Page-level indexing failed for '%s', using flat fallback: %s", | |
| job_id, doc.filename, e, | |
| ) | |
| fallback_text = FileProcessor.extract_pdf_text_fallback(doc.content, doc.filename) | |
| await pipeline.aindex_document( | |
| text=fallback_text, | |
| doc_id=str(doc.id), | |
| metadata={"filename": doc.filename, "file_type": doc.file_type}, | |
| ) | |
| await pipeline.aindex_pdf_images( | |
| pdf_content=doc.content, | |
| filename=doc.filename, | |
| doc_id=str(doc.id), | |
| base_metadata={"filename": doc.filename, "file_type": doc.file_type}, | |
| ) | |
| else: | |
| await pipeline.aindex_document( | |
| text=doc.content, | |
| doc_id=str(doc.id), | |
| metadata={"filename": doc.filename, "file_type": doc.file_type}, | |
| ) | |
| IndexingJobStore.update(job_id, progress_pct=int((i + 1) / total * 100)) | |
| IndexingJobStore.update(job_id, status="ready", progress_pct=100) | |
| except Exception as exc: | |
| logger.exception("[job=%s] Indexing failed: %s", job_id, exc) | |
| IndexingJobStore.update(job_id, status="failed", error=str(exc)) | |
| async def prepare_chat_session( | |
| payload: PrepareChatRequest, | |
| background_tasks: BackgroundTasks, | |
| current_user: User = Depends(get_current_user), | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Validate doc/config, create an indexing job, and return immediately (P2.1). | |
| The actual indexing runs in a BackgroundTask. Poll | |
| ``GET /api/documents/index-status/{job_id}`` for progress. | |
| """ | |
| from app.services.pipeline_manager import PipelineManager | |
| target_doc_ids = payload.document_ids or [payload.document_id] | |
| normalized_doc_ids = [] | |
| seen = set() | |
| for item in target_doc_ids: | |
| key = str(item) | |
| if key not in seen: | |
| seen.add(key) | |
| normalized_doc_ids.append(item) | |
| doc_stmt = select(Document).where(Document.id.in_(normalized_doc_ids), Document.user_id == current_user.id) | |
| cfg_stmt = select(RAGConfig).where( | |
| RAGConfig.id == payload.config_id, | |
| RAGConfig.user_id == current_user.id, | |
| ) | |
| docs = (await db.execute(doc_stmt)).scalars().all() | |
| config = (await db.execute(cfg_stmt)).scalars().first() | |
| if not docs: | |
| raise HTTPException(status_code=404, detail="Document not found") | |
| if not config: | |
| raise HTTPException(status_code=404, detail="Config not found") | |
| pipeline_config = deepcopy(config.config_json or {}) | |
| vectorstore_cfg = deepcopy(pipeline_config.get("vectorstore", {})) | |
| if vectorstore_cfg.get("type", "chroma") == "chroma": | |
| vectorstore_cfg["collection_name"] = f"user_{current_user.id}_rag_cfg_{config.id}" | |
| vectorstore_cfg["type"] = "chroma" | |
| pipeline_config["vectorstore"] = vectorstore_cfg | |
| # --- P2.1: Create job and fire background indexing ----------------------- | |
| job_id = IndexingJobStore.create( | |
| doc_id=str(payload.document_id), | |
| config_id=str(payload.config_id), | |
| ) | |
| # Eagerly snapshot doc data so the background task doesn't touch the DB session. | |
| docs_snapshot = list(docs) | |
| background_tasks.add_task( | |
| _run_indexing_background, | |
| job_id=job_id, | |
| docs=docs_snapshot, | |
| pipeline_config=pipeline_config, | |
| user_id=str(current_user.id), | |
| config_id=str(config.id), | |
| ) | |
| # ------------------------------------------------------------------------- | |
| if should_precompute_summaries(): | |
| for doc in docs: | |
| summary_text = doc.content | |
| if doc.file_type.lower() == "pdf": | |
| summary_text = FileProcessor.extract_pdf_text_fallback(doc.content, doc.filename) | |
| background_tasks.add_task( | |
| _precompute_summary_background, | |
| user_id=current_user.id, | |
| doc_id=doc.id, | |
| config_id=config.id, | |
| doc_content=summary_text, | |
| doc_filename=doc.filename, | |
| doc_file_type=doc.file_type, | |
| pipeline_config=pipeline_config, | |
| ) | |
| await db.commit() | |
| return { | |
| "status": "indexing", | |
| "job_id": job_id, | |
| "document_id": str(payload.document_id), | |
| "document_ids": [str(item) for item in normalized_doc_ids], | |
| "config_id": str(payload.config_id), | |
| } | |
| async def chat_endpoint( | |
| query: str = Body(...), | |
| doc_id: uuid.UUID = Body(...), | |
| config_id: uuid.UUID = Body(...), | |
| current_user: User = Depends(get_current_user), | |
| db: AsyncSession = Depends(get_db), | |
| rate_limiter: DatabaseRateLimiter = Depends(get_rate_limiter), | |
| ): | |
| from app.api.evaluation import _chunks_from_payload, score_message | |
| from app.services.pipeline_manager import PipelineManager | |
| scope_key = rate_limiter.build_scope_key(user_id=current_user.id) | |
| doc_stmt = select(Document).where(Document.id == doc_id, Document.user_id == current_user.id) | |
| cfg_stmt = select(RAGConfig).where( | |
| RAGConfig.id == config_id, | |
| RAGConfig.user_id == current_user.id, | |
| ) | |
| doc = (await db.execute(doc_stmt)).scalars().first() | |
| config = (await db.execute(cfg_stmt)).scalars().first() | |
| if not doc: | |
| raise HTTPException(status_code=404, detail="Document not found") | |
| if not config: | |
| raise HTTPException(status_code=404, detail="Config not found") | |
| logger.info( | |
| "Chat request received user_id=%s doc_id=%s config_id=%s query=%r", | |
| current_user.id, | |
| doc_id, | |
| config_id, | |
| query[:200], | |
| ) | |
| timer = PipelineTimer() | |
| retrieved_results = [] | |
| retrieved_chunks_only = [] | |
| try: | |
| pipeline_config = deepcopy(config.config_json or {}) | |
| vectorstore_cfg = deepcopy(pipeline_config.get("vectorstore", {})) | |
| if vectorstore_cfg.get("type", "chroma") == "chroma": | |
| vectorstore_cfg["collection_name"] = f"user_{current_user.id}_rag_cfg_{config.id}" | |
| vectorstore_cfg["type"] = "chroma" | |
| pipeline_config["vectorstore"] = vectorstore_cfg | |
| # Pipeline is already retrieved via PipelineManager which now handles caching. | |
| # We NO LONGER re-index everything on every message. | |
| pipeline = PipelineManager.get_pipeline(f"{current_user.id}:{config_id}", pipeline_config) | |
| retrieval_cfg = config.config_json.get("retriever", {}) if config.config_json else {} | |
| top_k = int(retrieval_cfg.get("top_k", 5)) | |
| similarity_threshold = retrieval_cfg.get("similarity_threshold", None) | |
| llm_client = getattr(pipeline, "llm_client", None) | |
| logger.info( | |
| "Chat pipeline ready config_id=%s top_k=%s threshold=%s llm_client=%s llm_ready=%s llm_config=%s", | |
| config_id, | |
| top_k, | |
| similarity_threshold, | |
| type(llm_client).__name__ if llm_client else None, | |
| bool(llm_client and getattr(llm_client, "llm", None)), | |
| pipeline_config.get("llm", {}), | |
| ) | |
| query_mode = classify_query(query) | |
| logger.info("Chat query classified config_id=%s mode=%s", config_id, query_mode) | |
| if query_mode == "global": | |
| answer = await SummaryService.get_summary( | |
| db=db, | |
| user_id=current_user.id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| ) | |
| if not answer: | |
| await rate_limiter.enforce_rate_limit(scope_key, "llm") | |
| timer.start("llm_time_ms") | |
| summary, fallback_results, fallback_chunks = await _generate_summary_on_the_fly(pipeline) | |
| retrieved_results = fallback_results | |
| retrieved_chunks_only = fallback_chunks | |
| if summary: | |
| answer = summary | |
| await SummaryService.upsert_summary( | |
| db=db, | |
| user_id=current_user.id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| summary=summary, | |
| ) | |
| await rate_limiter.record_call(scope_key, "llm", current_user.id) | |
| else: | |
| answer = _fallback_answer_from_chunks(query, retrieved_chunks_only) | |
| timer.stop("llm_time_ms") | |
| else: | |
| timer.start("retrieval_time_ms") | |
| retrieved_results = await pipeline.aretrieve(query, top_k=top_k) | |
| logger.info( | |
| "Chat retrieval completed config_id=%s requested_top_k=%s returned=%d", | |
| config_id, | |
| top_k, | |
| len(retrieved_results or []), | |
| ) | |
| if similarity_threshold is not None and retrieved_results and isinstance(retrieved_results[0], tuple): | |
| retrieved_results = [ | |
| (chunk, score) | |
| for chunk, score in retrieved_results | |
| if score >= float(similarity_threshold) | |
| ] | |
| timer.stop("retrieval_time_ms") | |
| retrieved_chunks_only = _extract_chunks(retrieved_results) | |
| await rate_limiter.enforce_rate_limit(scope_key, "llm") | |
| timer.start("llm_time_ms") | |
| try: | |
| answer = await pipeline.agenerate(query, retrieved_chunks_only) | |
| # Record successful LLM call | |
| await rate_limiter.record_call(scope_key, "llm", current_user.id) | |
| except Exception as exc: | |
| logger.warning( | |
| "LLM generation failed; falling back to retrieved chunks. " | |
| "config_id=%s doc_id=%s llm_config=%s error=%s", | |
| config_id, | |
| doc_id, | |
| (config.config_json or {}).get("llm", {}), | |
| exc, | |
| exc_info=True, | |
| ) | |
| answer = _fallback_answer_from_chunks(query, retrieved_chunks_only) | |
| finally: | |
| timer.stop("llm_time_ms") | |
| except (HTTPException, RateLimitExceededException): | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| retrieved_chunks = _serialize_retrieved_chunks(retrieved_results if 'retrieved_results' in locals() else []) | |
| user_msg = ChatMessage( | |
| user_id=current_user.id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| role="user", | |
| content=query, | |
| retrieved_chunks=None | |
| ) | |
| db.add(user_msg) | |
| assistant_msg = ChatMessage( | |
| user_id=current_user.id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| role="assistant", | |
| content=answer, | |
| retrieved_chunks=retrieved_chunks | |
| ) | |
| db.add(assistant_msg) | |
| timings = timer.to_metrics_dict() if hasattr(timer, "to_metrics_dict") else timer.get_timings() | |
| response_timings = {k: round(float(v), 0) for k, v in timings.items()} | |
| chunk_scores = [float(chunk.get("score", 0.0)) for chunk in retrieved_chunks] | |
| avg_similarity = (sum(chunk_scores) / len(chunk_scores)) if chunk_scores else 0.0 | |
| await db.flush() | |
| metrics_record = Metrics( | |
| message_id=assistant_msg.id, | |
| chunking_time_ms=timings["chunking_time_ms"], | |
| embedding_time_ms=timings.get("embedding_time_ms", 0), | |
| retrieval_time_ms=timings["retrieval_time_ms"], | |
| llm_time_ms=timings["llm_time_ms"], | |
| total_time_ms=timings["total_time_ms"], | |
| avg_similarity=avg_similarity, | |
| token_count=len(answer.split()), | |
| ) | |
| db.add(metrics_record) | |
| llm_client = getattr(pipeline, "llm_client", None) | |
| try: | |
| score_message( | |
| db=db, | |
| assistant_msg=assistant_msg, | |
| query_text=query, | |
| llm_client=llm_client, | |
| chunks=_chunks_from_payload(retrieved_chunks), | |
| ) | |
| except Exception: | |
| # Evaluation should not block normal chat responses. | |
| pass | |
| await db.commit() | |
| await db.refresh(assistant_msg) | |
| return { | |
| "answer": answer, | |
| "retrieved_chunks": retrieved_chunks, | |
| "timings": response_timings, | |
| "message_id": assistant_msg.id | |
| } | |
| from fastapi.responses import StreamingResponse | |
| import json | |
| async def chat_stream_endpoint( | |
| query: str = Body(...), | |
| doc_id: uuid.UUID = Body(...), | |
| doc_ids: List[uuid.UUID] | None = Body(None), | |
| config_id: uuid.UUID = Body(...), | |
| current_user: User = Depends(get_current_user), | |
| ): | |
| """Streaming version for better perceived latency.""" | |
| from app.services.pipeline_manager import PipelineManager | |
| # Check rate limit for LLM calls using a short-lived DB session. | |
| scope_key = f"user:{current_user.id}" | |
| async with AsyncSessionLocal() as limiter_db: | |
| limiter = DatabaseRateLimiter(limiter_db) | |
| await limiter.enforce_rate_limit(scope_key, "llm") | |
| requested_ids = doc_ids or [doc_id] | |
| normalized_ids = [] | |
| seen = set() | |
| for item in requested_ids: | |
| key = str(item) | |
| if key not in seen: | |
| seen.add(key) | |
| normalized_ids.append(item) | |
| doc_stmt = select(Document).where(Document.id.in_(normalized_ids), Document.user_id == current_user.id) | |
| cfg_stmt = select(RAGConfig).where( | |
| RAGConfig.id == config_id, | |
| RAGConfig.user_id == current_user.id, | |
| ) | |
| async with AsyncSessionLocal() as read_db: | |
| docs = (await read_db.execute(doc_stmt)).scalars().all() | |
| config = (await read_db.execute(cfg_stmt)).scalars().first() | |
| if not docs or not config: | |
| raise HTTPException(status_code=404, detail="Resource not found") | |
| logger.info( | |
| "Streaming chat request received user_id=%s doc_ids=%s config_id=%s query=%r", | |
| current_user.id, | |
| [str(item) for item in normalized_ids], | |
| config_id, | |
| query[:200], | |
| ) | |
| pipeline_config = deepcopy(config.config_json or {}) | |
| vectorstore_cfg = deepcopy(pipeline_config.get("vectorstore", {})) | |
| if vectorstore_cfg.get("type", "chroma") == "chroma": | |
| vectorstore_cfg["collection_name"] = f"user_{current_user.id}_rag_cfg_{config_id}" | |
| vectorstore_cfg["type"] = "chroma" | |
| pipeline_config["vectorstore"] = vectorstore_cfg | |
| pipeline = PipelineManager.get_pipeline(f"{current_user.id}:{config_id}", pipeline_config) | |
| retrieval_cfg = pipeline_config.get("retriever", {}) | |
| top_k = int(retrieval_cfg.get("top_k", 5)) | |
| similarity_threshold = float(retrieval_cfg.get("similarity_threshold", 0.0)) | |
| llm_client = getattr(pipeline, "llm_client", None) | |
| query_mode = classify_query(query) | |
| logger.info( | |
| "Streaming chat pipeline ready config_id=%s query_mode=%s top_k=%s threshold=%s llm_client=%s llm_ready=%s llm_config=%s", | |
| config_id, | |
| query_mode, | |
| top_k, | |
| similarity_threshold, | |
| type(llm_client).__name__ if llm_client else None, | |
| bool(llm_client and getattr(llm_client, "llm", None)), | |
| pipeline_config.get("llm", {}), | |
| ) | |
| async def event_generator(): | |
| try: | |
| # 1. Notify immediately | |
| # yield f"data: {json.dumps({'type': 'status', 'message': f'Active on {len(docs)} document(s)…'})}\n\n" | |
| # No longer re-indexing here as it should be handled in /prepare | |
| llm_used = False | |
| if query_mode == "global": | |
| _msg2 = json.dumps({'type': 'status', 'message': 'Loading precomputed summary…'}) | |
| yield f"data: {_msg2}\n\n" | |
| async with AsyncSessionLocal() as summary_db: | |
| summary_text = await SummaryService.get_summary( | |
| db=summary_db, | |
| user_id=current_user.id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| ) | |
| results = [] | |
| chunks = [] | |
| if not summary_text: | |
| _msg3 = json.dumps({'type': 'status', 'message': 'Synthesizing document overview…'}) | |
| yield f"data: {_msg3}\n\n" | |
| generated_summary, fallback_results, fallback_chunks = await _generate_summary_on_the_fly(pipeline) | |
| results = fallback_results | |
| chunks = fallback_chunks | |
| if generated_summary: | |
| summary_text = generated_summary | |
| llm_used = True | |
| async with AsyncSessionLocal() as summary_db: | |
| await SummaryService.upsert_summary( | |
| db=summary_db, | |
| user_id=current_user.id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| summary=generated_summary, | |
| ) | |
| await summary_db.commit() | |
| else: | |
| summary_text = _fallback_answer_from_chunks(query, chunks) | |
| chunk_meta = _serialize_retrieved_chunks(results) | |
| yield f"data: {json.dumps({'type': 'metadata', 'chunks': chunk_meta})}\n\n" | |
| full_response = summary_text | |
| yield f"data: {json.dumps({'type': 'token', 'content': summary_text})}\n\n" | |
| else: | |
| _msg2 = json.dumps({'type': 'status', 'message': 'Retrieving relevant chunks…'}) | |
| yield f"data: {_msg2}\n\n" | |
| results = await pipeline.aretrieve(query, top_k=top_k) | |
| logger.info( | |
| "Streaming chat retrieval completed config_id=%s requested_top_k=%s returned=%d", | |
| config_id, | |
| top_k, | |
| len(results or []), | |
| ) | |
| if similarity_threshold > 0 and results and isinstance(results[0], tuple): | |
| filtered = [(c, s) for c, s in results if s >= similarity_threshold] | |
| # Safety: never filter out everything — fall back to unfiltered top results | |
| results = filtered if filtered else results | |
| chunks = [res[0] if isinstance(res, tuple) else res for res in results] | |
| # 2. Send full chunk text + score | |
| chunk_meta = _serialize_retrieved_chunks(results) | |
| yield f"data: {json.dumps({'type': 'metadata', 'chunks': chunk_meta})}\n\n" | |
| # 3. Stream generation tokens | |
| full_response = "" | |
| try: | |
| async for token in pipeline.agenerate_stream(query, chunks): | |
| llm_used = True | |
| full_response += token | |
| yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n" | |
| except Exception as exc: | |
| logger.warning( | |
| "Streaming LLM generation failed; falling back to retrieved chunks. " | |
| "config_id=%s doc_id=%s llm_config=%s error=%s", | |
| config_id, | |
| doc_id, | |
| (config.config_json or {}).get("llm", {}), | |
| exc, | |
| exc_info=True, | |
| ) | |
| full_response = _fallback_answer_from_chunks(query, chunks) | |
| yield f"data: {json.dumps({'type': 'token', 'content': full_response})}\n\n" | |
| # Record successful LLM call in a short-lived session. | |
| if llm_used: | |
| async with AsyncSessionLocal() as limiter_db: | |
| limiter = DatabaseRateLimiter(limiter_db) | |
| await limiter.record_call(scope_key, "llm", current_user.id) | |
| assistant_message_id = None | |
| # 4. Persist to DB | |
| try: | |
| async with AsyncSessionLocal() as write_db: | |
| user_msg = ChatMessage( | |
| user_id=current_user.id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| role="user", | |
| content=query, | |
| ) | |
| write_db.add(user_msg) | |
| assistant_msg = ChatMessage( | |
| user_id=current_user.id, | |
| document_id=doc_id, | |
| config_id=config_id, | |
| role="assistant", | |
| content=full_response, | |
| retrieved_chunks=chunk_meta, | |
| ) | |
| write_db.add(assistant_msg) | |
| await write_db.commit() | |
| await write_db.refresh(assistant_msg) | |
| assistant_message_id = str(assistant_msg.id) | |
| except Exception: | |
| pass | |
| # Notify UI only after the message is safely persisted so evaluation can load it. | |
| yield f"data: {json.dumps({'type': 'done', 'message_id': assistant_message_id})}\n\n" | |
| except asyncio.CancelledError: | |
| # Client disconnected mid-stream. | |
| raise | |
| except Exception as e: | |
| logger.exception("Stream generator error: %s", str(e)) | |
| yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" | |
| return StreamingResponse(event_generator(), media_type="text/event-stream") | |
| async def get_chat_history(doc_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): | |
| stmt = ( | |
| select(ChatMessage) | |
| .where(ChatMessage.document_id == doc_id, ChatMessage.user_id == current_user.id) | |
| .order_by(ChatMessage.timestamp) | |
| ) | |
| return (await db.execute(stmt)).scalars().all() | |
| async def reset_system( | |
| doc_id: uuid.UUID = Body(...), | |
| config_id: uuid.UUID = Body(...), | |
| current_user: User = Depends(get_current_user), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| """Resets memory, deletes history, and clears vectordb collections.""" | |
| from sqlalchemy import delete | |
| import shutil | |
| import os | |
| # 2. Clear Database conversation history for this document | |
| await db.execute(delete(ChatMessage).where(ChatMessage.document_id == doc_id, ChatMessage.user_id == current_user.id)) | |
| await db.commit() | |
| return {"status": "success", "message": "System reset complete."} | |