Spaces:
Sleeping
Sleeping
| """ | |
| FastAPI REST API for the RAG system β fully expanded. | |
| Endpoints: | |
| POST /ingest β ingest a document | |
| POST /ingest/multimodal β ingest PDF with tables + figures (vision LLM) | |
| POST /raptor/ingest β RAPTOR recursive tree ingestion | |
| POST /query β standard Q&A with citations | |
| POST /query/adaptive β adaptive RAG (auto-selects strategy) | |
| POST /query/stream β streaming token-by-token response (SSE) | |
| POST /chat/{session_id} β multi-turn conversation | |
| DELETE /chat/{session_id} β clear a conversation session | |
| GET /chat/{session_id}/history β get conversation history | |
| GET /collections β list all knowledge bases | |
| DELETE /collection/{name} β delete a knowledge base | |
| GET /health β health check | |
| GET /cache/stats β semantic cache stats | |
| DELETE /cache β clear cache | |
| GET /graph/stats β knowledge graph stats | |
| GET /graph/entity/{name} β entity relationships from knowledge graph | |
| GET /metrics β Prometheus metrics (if installed) | |
| POST /route β auto-route a query to the best collection | |
| GET /document/analyze β analyze a document without ingesting | |
| POST /feedback β record user feedback on a response | |
| GET /feedback/summary β feedback analytics | |
| GET /feedback/export β export feedback as JSONL | |
| POST /finetune β trigger embedding fine-tuning pipeline | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import logging | |
| from collections.abc import AsyncGenerator, AsyncIterator | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi import Path as FastAPIPath | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import StreamingResponse | |
| from pydantic import BaseModel, Field | |
| from config import settings | |
| from models import ( | |
| CollectionInfo, | |
| CollectionListResponse, | |
| DeleteCollectionResponse, | |
| IngestRequest, | |
| IngestResult, | |
| QueryRequest, | |
| QueryResponse, | |
| ) | |
| from monitoring import instrument_app, log_ingest_event, log_query_event, record_query | |
| logger = logging.getLogger(__name__) | |
| # ββ Lifespan ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def lifespan(app: FastAPI) -> AsyncIterator[None]: | |
| logger.info("API starting β warming up modelsβ¦") | |
| try: | |
| from core.ingestion import get_embedding_model | |
| get_embedding_model() | |
| except Exception as e: | |
| logger.warning("Embedding warm-up failed: %s", e) | |
| try: | |
| from core.generation import get_backend | |
| get_backend() | |
| except Exception as e: | |
| logger.warning("LLM backend warm-up failed: %s", e) | |
| try: | |
| from core.graph_rag import get_knowledge_graph | |
| get_knowledge_graph() | |
| except Exception as e: | |
| logger.warning("Graph warm-up failed: %s", e) | |
| yield | |
| logger.info("API shutting down.") | |
| # ββ App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="RAG System API", | |
| description=( | |
| "Production-grade Retrieval-Augmented Generation. " | |
| "Multi-backend (Ollama/Claude/OpenAI), hybrid retrieval, cross-encoder reranking, " | |
| "GraphRAG, streaming, multi-turn conversation, semantic caching, RAGAS evaluation, " | |
| "Adaptive RAG (Self-RAG), RAPTOR tree ingestion, multi-modal PDF (tables + vision), " | |
| "user feedback loop, and embedding fine-tuning." | |
| ), | |
| version="3.0.0", | |
| lifespan=lifespan, | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=settings.cors_origins, | |
| allow_credentials=False, | |
| allow_methods=["GET", "POST", "DELETE", "OPTIONS"], | |
| allow_headers=["Content-Type", "Authorization"], | |
| ) | |
| # Attach Prometheus metrics if available | |
| instrument_app(app) | |
| # ββ Health ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class HealthResponse(BaseModel): | |
| status: str | |
| llm_backend: str | |
| embedding_model: str | |
| chroma_path: str | |
| cache_enabled: bool | |
| graph_nodes: int = 0 | |
| graph_edges: int = 0 | |
| version: str = "4.0.0" | |
| async def health_check() -> HealthResponse: | |
| """System health check with component status.""" | |
| graph_stats = {"nodes": 0, "edges": 0} | |
| try: | |
| from core.graph_rag import get_knowledge_graph | |
| graph_stats = get_knowledge_graph().stats() | |
| except Exception: | |
| pass | |
| return HealthResponse( | |
| status="ok", | |
| llm_backend=settings.llm_backend.value, | |
| embedding_model=settings.embedding_model, | |
| chroma_path=str(settings.chroma_persist_dir), | |
| cache_enabled=settings.enable_cache, | |
| graph_nodes=graph_stats.get("nodes", 0), | |
| graph_edges=graph_stats.get("edges", 0), | |
| ) | |
| # ββ Ingest ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class IngestRequestExtended(IngestRequest): | |
| """Extended ingest request with advanced options.""" | |
| chunking_strategy: str = Field( | |
| default="recursive", description="recursive | semantic | hierarchical" | |
| ) | |
| extract_graph: bool = Field( | |
| default=False, description="Extract entities/relations into knowledge graph" | |
| ) | |
| generate_summary: bool = Field(default=False, description="Generate LLM summary at ingest time") | |
| analyze_document: bool = Field(default=True, description="Run document quality analysis") | |
| class IngestResultExtended(IngestResult): | |
| """Extended ingest result with analysis and graph stats.""" | |
| quality_score: float = Field(default=1.0) | |
| language: str = Field(default="unknown") | |
| pii_warnings: list[str] = Field(default_factory=list) | |
| sections_detected: int = Field(default=0) | |
| graph_triples_added: int = Field(default=0) | |
| summary: str = Field(default="") | |
| async def ingest(request: IngestRequestExtended) -> IngestResultExtended: | |
| """ | |
| Ingest a document with optional document analysis and knowledge graph extraction. | |
| Supports PDF, TXT, DOCX, Markdown, and URLs. | |
| """ | |
| from core.document_processor import analyze_document | |
| from core.graph_rag import extract_triples, get_knowledge_graph | |
| from core.ingestion import ingest_document, load_document | |
| # Reject path traversal attempts on local file paths | |
| src = request.file_path | |
| if not src.startswith(("http://", "https://")): | |
| resolved = Path(src).resolve() | |
| allowed = Path(".").resolve() | |
| if not str(resolved).startswith(str(allowed)): | |
| raise HTTPException( | |
| status_code=400, detail="file_path must be within the working directory or a URL" | |
| ) | |
| try: | |
| result = ingest_document( | |
| source=request.file_path, | |
| collection_name=request.collection, | |
| overwrite=request.overwrite, | |
| chunking_strategy=request.chunking_strategy, | |
| ) | |
| except FileNotFoundError as e: | |
| raise HTTPException(status_code=404, detail=str(e)) from e | |
| except ValueError as e: | |
| raise HTTPException(status_code=422, detail=str(e)) from e | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| # Document analysis | |
| quality_score, language, pii_warnings, sections_count, summary = 1.0, "unknown", [], 0, "" | |
| if request.analyze_document or request.generate_summary: | |
| try: | |
| pages, _ = load_document(request.file_path) | |
| full_text = "\n\n".join(text for text, _ in pages) | |
| llm_fn = None | |
| if request.generate_summary: | |
| from core.generation import get_backend | |
| llm_fn = get_backend().complete_raw | |
| analysis = analyze_document( | |
| full_text, | |
| request.file_path, | |
| llm_fn=llm_fn, | |
| generate_summary=request.generate_summary, | |
| ) | |
| quality_score = analysis.quality_score | |
| language = analysis.language | |
| pii_warnings = analysis.pii_warnings | |
| sections_count = len(analysis.detected_sections) | |
| summary = analysis.summary | |
| except Exception as e: | |
| logger.warning("Document analysis failed: %s", e) | |
| # Knowledge graph extraction | |
| graph_triples_added = 0 | |
| if request.extract_graph and result.chunks_added > 0: | |
| try: | |
| from core.generation import get_backend | |
| graph = get_knowledge_graph() | |
| backend = get_backend() | |
| pages, _ = load_document(request.file_path) | |
| # Sample first 5 pages to extract graph triples | |
| for page_text, _ in pages[:5]: | |
| triples = extract_triples(page_text, request.file_path, backend.complete_raw) | |
| graph_triples_added += graph.add_triples(triples) | |
| graph.save() | |
| logger.info("Graph: added %d triples from '%s'", graph_triples_added, request.file_path) | |
| except Exception as e: | |
| logger.warning("Graph extraction failed: %s", e) | |
| log_ingest_event( | |
| request.file_path, request.collection, result.chunks_added, result.elapsed_seconds | |
| ) | |
| return IngestResultExtended( | |
| **result.model_dump(), | |
| quality_score=quality_score, | |
| language=language, | |
| pii_warnings=pii_warnings, | |
| sections_detected=sections_count, | |
| graph_triples_added=graph_triples_added, | |
| summary=summary, | |
| ) | |
| # ββ Query βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class QueryRequestExtended(QueryRequest): | |
| """Extended query with graph RAG and routing options.""" | |
| use_graph: bool = Field(default=False, description="Augment with knowledge graph context") | |
| auto_route: bool = Field(default=False, description="Auto-select collection based on query") | |
| session_id: str | None = Field(default=None, description="Session ID for conversation context") | |
| class QueryResponseExtended(QueryResponse): | |
| """Extended response with graph and routing info.""" | |
| graph_entities_found: list[str] = Field(default_factory=list) | |
| graph_triples_used: int = Field(default=0) | |
| routed_to: str | None = Field(default=None) | |
| async def query(request: QueryRequestExtended) -> QueryResponseExtended: | |
| """ | |
| Query with hybrid retrieval, reranking, optional GraphRAG, and conversation context. | |
| """ | |
| from core.conversation import get_or_create_session | |
| from core.generation import answer_question, get_backend | |
| from core.graph_rag import get_knowledge_graph, retrieve_graph_context | |
| from core.router import get_router | |
| backend = get_backend() | |
| # Auto-routing | |
| routed_to = None | |
| if request.auto_route: | |
| router = get_router() | |
| routed_to = router.route_single(request.question, use_llm=True, llm_fn=backend.complete_raw) | |
| request = request.model_copy(update={"collection": routed_to}) | |
| # Conversation context | |
| if request.session_id: | |
| session = get_or_create_session(request.session_id) | |
| # Resolve references ("it", "that") using conversation history | |
| resolved_q = session.resolve_references(request.question, backend.complete_raw) | |
| request = request.model_copy(update={"question": resolved_q}) | |
| session.build_context_prompt() | |
| try: | |
| response = answer_question(request) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| # Graph RAG augmentation | |
| graph_entities: list[str] = [] | |
| graph_triples_used = 0 | |
| if request.use_graph: | |
| try: | |
| graph = get_knowledge_graph() | |
| graph_ctx = retrieve_graph_context(request.question, graph, hops=2) | |
| graph_entities = graph_ctx.entities_found | |
| graph_triples_used = len(graph_ctx.triples) | |
| if graph_ctx.narrative and not response.answer.startswith("I don't have"): | |
| # Append graph context note to answer | |
| response = response.model_copy( | |
| update={ | |
| "answer": response.answer | |
| + f"\n\n---\n*Graph context: {graph_ctx.narrative[:500]}*" | |
| } | |
| ) | |
| except Exception as e: | |
| logger.warning("GraphRAG augmentation failed: %s", e) | |
| # Store in conversation memory | |
| if request.session_id: | |
| from core.conversation import ConversationTurn, get_or_create_session | |
| session = get_or_create_session(request.session_id) | |
| session.add_turn( | |
| ConversationTurn( | |
| question=request.question, | |
| answer=response.answer, | |
| sources=[s.source for s in response.sources], | |
| collection=request.collection, | |
| tokens_used=response.tokens_used, | |
| latency_ms=response.latency_ms, | |
| ) | |
| ) | |
| # Prometheus metrics | |
| avg_sim = sum(s.similarity_score for s in response.sources) / max(len(response.sources), 1) | |
| record_query( | |
| len(response.sources), | |
| avg_sim, | |
| response.tokens_used, | |
| settings.llm_backend.value, | |
| response.model_used, | |
| response.cache_hit, | |
| ) | |
| log_query_event( | |
| request.question, | |
| request.collection, | |
| len(response.sources), | |
| response.tokens_used, | |
| response.latency_ms, | |
| response.cache_hit, | |
| settings.llm_backend.value, | |
| ) | |
| return QueryResponseExtended( | |
| **response.model_dump(), | |
| graph_entities_found=graph_entities, | |
| graph_triples_used=graph_triples_used, | |
| routed_to=routed_to, | |
| ) | |
| # ββ Streaming query βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def query_stream(request: QueryRequest) -> StreamingResponse: | |
| """ | |
| Streaming RAG query using Server-Sent Events (SSE). | |
| Retrieval happens synchronously upfront, then tokens stream in real-time | |
| from the LLM. Compatible with Ollama streaming and Claude streaming APIs. | |
| Client usage: | |
| const es = new EventSource('/query/stream', {method: 'POST', body: JSON.stringify(req)}) | |
| es.onmessage = (e) => { if (e.data !== '[DONE]') appendToken(JSON.parse(e.data).token) } | |
| """ | |
| import json as _json | |
| from core.generation import SYSTEM_PROMPT, build_user_prompt, extract_sources, get_backend | |
| from core.retrieval import retrieve | |
| async def event_stream() -> AsyncGenerator[str, None]: | |
| try: | |
| backend = get_backend() | |
| # Retrieval (non-streaming) | |
| context = retrieve(request, generate_fn=backend.complete_raw) | |
| # Send retrieval metadata first | |
| sources = extract_sources(context) | |
| meta_event = _json.dumps( | |
| { | |
| "event": "metadata", | |
| "sources": [s.model_dump() for s in sources], | |
| "chunks_retrieved": len(context.results), | |
| } | |
| ) | |
| yield f"data: {meta_event}\n\n" | |
| user_prompt = build_user_prompt(context) | |
| # Stream from Ollama | |
| if settings.llm_backend.value == "ollama": | |
| import requests as _req | |
| payload = { | |
| "model": settings.ollama_model, | |
| "messages": [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| "stream": True, | |
| "options": {"temperature": settings.temperature}, | |
| } | |
| with _req.post( | |
| f"{settings.ollama_base_url}/api/chat", json=payload, stream=True, timeout=120 | |
| ) as resp: | |
| resp.raise_for_status() | |
| for line in resp.iter_lines(): | |
| if line: | |
| chunk = _json.loads(line) | |
| token = chunk.get("message", {}).get("content", "") | |
| if token: | |
| yield f"data: {_json.dumps({'token': token})}\n\n" | |
| if chunk.get("done"): | |
| break | |
| # Stream from Claude | |
| elif settings.llm_backend.value == "claude": | |
| import anthropic | |
| client = anthropic.Anthropic(api_key=settings.anthropic_api_key) | |
| with client.messages.stream( | |
| model=settings.claude_model, | |
| max_tokens=settings.max_tokens, | |
| system=SYSTEM_PROMPT, | |
| messages=[{"role": "user", "content": user_prompt}], | |
| ) as stream: | |
| for text in stream.text_stream: | |
| yield f"data: {_json.dumps({'token': text})}\n\n" | |
| await asyncio.sleep(0) # yield to event loop | |
| else: | |
| # OpenAI streaming | |
| from openai import OpenAI | |
| client = OpenAI(api_key=settings.openai_api_key) | |
| stream = client.chat.completions.create( | |
| model=settings.openai_model, | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| stream=True, | |
| max_tokens=settings.max_tokens, | |
| temperature=settings.temperature, | |
| ) | |
| for chunk in stream: | |
| token = chunk.choices[0].delta.content or "" | |
| if token: | |
| yield f"data: {_json.dumps({'token': token})}\n\n" | |
| await asyncio.sleep(0) | |
| yield "data: [DONE]\n\n" | |
| except Exception as e: | |
| import json as _j | |
| yield f"data: {_j.dumps({'error': str(e)})}\n\n" | |
| yield "data: [DONE]\n\n" | |
| return StreamingResponse( | |
| event_stream(), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) | |
| # ββ Conversation (multi-turn chat) ββββββββββββββββββββββββββββββββββββββββββββ | |
| class ChatRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| collection: str = Field(default="default") | |
| top_k: int = Field(default=6, ge=1, le=50) | |
| class ChatResponse(BaseModel): | |
| session_id: str | |
| question: str | |
| answer: str | |
| sources: list[dict] = Field(default_factory=list) | |
| tokens_used: int = 0 | |
| latency_ms: float = 0.0 | |
| turn_number: int = 0 | |
| async def chat(session_id: str, request: ChatRequest) -> ChatResponse: | |
| """ | |
| Multi-turn conversational RAG with automatic reference resolution. | |
| Maintains conversation history across calls using the session_id. | |
| Automatically resolves references like "it", "that document", "the above". | |
| Compresses history when it grows too long to stay within context limits. | |
| """ | |
| from core.conversation import ConversationTurn, get_or_create_session | |
| from core.generation import answer_question, get_backend | |
| from models import QueryMode | |
| session = get_or_create_session(session_id) | |
| backend = get_backend() | |
| # Resolve ambiguous references | |
| resolved_q = session.resolve_references(request.question, backend.complete_raw) | |
| q_request = QueryRequest( | |
| question=resolved_q, | |
| collection=request.collection, | |
| top_k=request.top_k, | |
| mode=QueryMode.HYBRID, | |
| ) | |
| try: | |
| response = answer_question(q_request) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| # Store in session | |
| session.add_turn( | |
| ConversationTurn( | |
| question=request.question, | |
| answer=response.answer, | |
| sources=[s.source for s in response.sources], | |
| collection=request.collection, | |
| tokens_used=response.tokens_used, | |
| latency_ms=response.latency_ms, | |
| ) | |
| ) | |
| # Auto-compress if needed | |
| if len(session.turns) > session.summarize_after: | |
| try: | |
| session.compress(backend.complete_raw) | |
| except Exception as e: | |
| logger.warning("Session compression failed: %s", e) | |
| return ChatResponse( | |
| session_id=session_id, | |
| question=request.question, | |
| answer=response.answer, | |
| sources=[s.model_dump() for s in response.sources], | |
| tokens_used=response.tokens_used, | |
| latency_ms=response.latency_ms, | |
| turn_number=len(session.turns), | |
| ) | |
| async def get_chat_history(session_id: str) -> dict: | |
| """Get the full conversation history for a session.""" | |
| from core.conversation import get_or_create_session | |
| session = get_or_create_session(session_id) | |
| return session.to_dict() | |
| async def clear_chat(session_id: str) -> dict: | |
| """Clear a conversation session's history.""" | |
| from core.conversation import delete_session | |
| deleted = delete_session(session_id) | |
| return {"session_id": session_id, "cleared": deleted} | |
| # ββ Collections βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_collections() -> CollectionListResponse: | |
| from core.ingestion import list_collections as _list | |
| try: | |
| raw = _list() | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| collections = [ | |
| CollectionInfo( | |
| name=c["name"], document_count=c["document_count"], embedding_model=c["embedding_model"] | |
| ) | |
| for c in raw | |
| ] | |
| return CollectionListResponse(collections=collections, total=len(collections)) | |
| async def delete_collection(name: str = FastAPIPath(..., min_length=1)) -> DeleteCollectionResponse: | |
| from core.ingestion import delete_collection as _delete | |
| try: | |
| deleted = _delete(name) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| if not deleted: | |
| raise HTTPException(status_code=404, detail=f"Collection '{name}' not found.") | |
| return DeleteCollectionResponse( | |
| name=name, deleted=True, message=f"Collection '{name}' deleted." | |
| ) | |
| # ββ Knowledge Graph βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def graph_stats() -> dict: | |
| """Return knowledge graph statistics.""" | |
| from core.graph_rag import get_knowledge_graph | |
| graph = get_knowledge_graph() | |
| return graph.stats() | |
| async def graph_entity(entity_name: str) -> dict: | |
| """Get all relationships for a specific entity from the knowledge graph.""" | |
| from core.graph_rag import get_knowledge_graph | |
| graph = get_knowledge_graph() | |
| return graph.get_entity_summary(entity_name) | |
| # ββ Routing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RouteRequest(BaseModel): | |
| question: str | |
| top_n: int = Field(default=1, ge=1, le=5) | |
| async def route_query(request: RouteRequest) -> dict: | |
| """Auto-select the best collection(s) for a query using embedding similarity.""" | |
| from core.router import get_router | |
| router = get_router() | |
| router.auto_register() | |
| collections = router.route(request.question, top_n=request.top_n) | |
| return {"question": request.question, "recommended_collections": collections} | |
| # ββ Cache βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CacheStats(BaseModel): | |
| enabled: bool | |
| size: int | |
| max_size: int | |
| threshold: float | |
| async def cache_stats() -> CacheStats: | |
| from core.retrieval import get_cache | |
| cache = get_cache() | |
| return CacheStats( | |
| enabled=settings.enable_cache, | |
| size=len(cache) if cache else 0, | |
| max_size=settings.cache_max_size, | |
| threshold=settings.cache_similarity_threshold, | |
| ) | |
| async def clear_cache() -> dict: | |
| from core.retrieval import get_cache | |
| cache = get_cache() | |
| if cache: | |
| size = len(cache) | |
| cache.clear() | |
| return {"message": f"Cleared {size} entries."} | |
| return {"message": "Cache empty or disabled."} | |
| # ββ Multi-modal ingestion βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class MultimodalIngestRequest(BaseModel): | |
| pdf_path: str = Field(..., description="Absolute or relative path to a PDF file") | |
| collection: str = Field(default="default") | |
| extract_tables: bool = Field(default=True, description="Extract and store tables as markdown") | |
| extract_figures: bool = Field(default=True, description="Extract embedded images") | |
| describe_figures: bool = Field( | |
| default=True, description="Use Claude vision to describe figures" | |
| ) | |
| class MultimodalIngestResponse(BaseModel): | |
| pdf: str | |
| tables_found: int | |
| figures_found: int | |
| elements_stored: int | |
| collection: str | |
| async def ingest_multimodal(request: MultimodalIngestRequest) -> MultimodalIngestResponse: | |
| """ | |
| Extract tables (as markdown) and figures (described via vision LLM) from a PDF | |
| and store them as searchable chunks alongside text content. | |
| Requires pdfplumber and pymupdf: pip install pdfplumber pymupdf | |
| """ | |
| from core.multimodal import ingest_pdf_multimodal | |
| try: | |
| summary = ingest_pdf_multimodal( | |
| pdf_path=request.pdf_path, | |
| collection_name=request.collection, | |
| extract_tables=request.extract_tables, | |
| extract_figures=request.extract_figures, | |
| describe_figures=request.describe_figures, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return MultimodalIngestResponse(**summary, collection=request.collection) | |
| # ββ RAPTOR ingestion ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RaptorIngestRequest(BaseModel): | |
| collection: str = Field(..., description="Source collection to build RAPTOR tree from") | |
| output_collection: str | None = Field( | |
| default=None, | |
| description="Target collection for summaries (defaults to <collection>_raptor)", | |
| ) | |
| max_levels: int = Field(default=3, ge=1, le=5) | |
| cluster_size: int = Field(default=10, ge=3, le=50) | |
| class RaptorIngestResponse(BaseModel): | |
| source_collection: str | |
| raptor_collection: str | |
| levels_built: int | |
| summaries_stored: int | |
| total_nodes: int | |
| async def raptor_ingest(request: RaptorIngestRequest) -> RaptorIngestResponse: | |
| """ | |
| Build a RAPTOR recursive tree from an existing collection. | |
| Clusters semantically similar chunks, summarizes each cluster with LLM, | |
| then recursively builds higher-level summaries. Enables multi-granularity | |
| retrieval: specific facts, section summaries, and document-level overviews. | |
| """ | |
| from core.generation import get_backend | |
| from core.raptor import build_raptor_tree, ingest_raptor_tree | |
| backend = get_backend() | |
| out_col = request.output_collection or f"{request.collection}_raptor" | |
| try: | |
| tree = build_raptor_tree( | |
| collection_name=request.collection, | |
| llm_fn=backend.complete_raw, | |
| max_levels=request.max_levels, | |
| cluster_size=request.cluster_size, | |
| ) | |
| stored = ingest_raptor_tree(tree, out_col) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return RaptorIngestResponse( | |
| source_collection=request.collection, | |
| raptor_collection=out_col, | |
| levels_built=len(tree.levels), | |
| summaries_stored=stored, | |
| total_nodes=sum(len(nodes) for nodes in tree.levels.values()), | |
| ) | |
| # ββ Adaptive RAG query ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class AdaptiveQueryRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| collection: str = Field(default="default") | |
| top_k: int = Field(default=6, ge=1, le=50) | |
| use_self_rag: bool = Field(default=True, description="Apply Self-RAG reflection tokens") | |
| max_hops: int = Field(default=3, ge=1, le=5, description="Max iterative retrieval hops") | |
| class AdaptiveQueryResponse(BaseModel): | |
| answer: str | |
| strategy_used: str | |
| queries_used: list[str] | |
| hops: int | |
| latency_ms: float | |
| self_rag_flags: dict | |
| chunks_retrieved: int | |
| async def query_adaptive(request: AdaptiveQueryRequest) -> AdaptiveQueryResponse: | |
| """ | |
| Adaptive RAG query that automatically selects the optimal retrieval strategy: | |
| - **NO_RETRIEVAL**: answers from model knowledge (math, general facts) | |
| - **SINGLE_STEP**: standard single-pass vector retrieval | |
| - **ITERATIVE**: multi-hop chained retrieval for complex questions | |
| Includes Self-RAG reflection: [Retrieve], [IsREL], [IsSUP], [IsUSE] tokens. | |
| """ | |
| from core.adaptive_rag import adaptive_answer | |
| from core.generation import get_backend | |
| from core.retrieval import retrieve | |
| from models import QueryMode, QueryRequest | |
| backend = get_backend() | |
| def _retrieve_fn(q: str, col: str, k: int) -> list: | |
| req = QueryRequest(question=q, collection=col, top_k=k, mode=QueryMode.HYBRID) | |
| ctx = retrieve(req, generate_fn=backend.complete_raw) | |
| return ctx.results | |
| def _generate_fn(system: str, user: str) -> str: | |
| return backend.complete_raw(f"{system}\n\n{user}") | |
| try: | |
| result = adaptive_answer( | |
| question=request.question, | |
| collection=request.collection, | |
| llm_fn=backend.complete_raw, | |
| retrieve_fn=_retrieve_fn, | |
| generate_fn=_generate_fn, | |
| top_k=request.top_k, | |
| use_self_rag=request.use_self_rag, | |
| use_iterative=True, | |
| max_hops=request.max_hops, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return AdaptiveQueryResponse( | |
| answer=result.answer, | |
| strategy_used=result.strategy_used.value, | |
| queries_used=result.queries_used, | |
| hops=result.hops, | |
| latency_ms=result.latency_ms, | |
| self_rag_flags=result.self_rag_flags, | |
| chunks_retrieved=len(result.chunks_retrieved), | |
| ) | |
| # ββ Feedback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class FeedbackRequest(BaseModel): | |
| question: str | |
| answer: str | |
| collection: str = Field(default="default") | |
| feedback_type: str = Field( | |
| ..., | |
| description="thumbs_up | thumbs_down | correction | source_irrelevant | source_helpful | incomplete", | |
| ) | |
| correction: str | None = Field( | |
| default=None, description="Correct answer (for correction feedback)" | |
| ) | |
| source_feedback: str | None = Field( | |
| default=None, description="Specific source file (for source feedback)" | |
| ) | |
| rating: int | None = Field(default=None, ge=1, le=5, description="Optional 1-5 star rating") | |
| sources_used: list[str] = Field(default_factory=list) | |
| session_id: str | None = None | |
| class FeedbackResponse(BaseModel): | |
| feedback_id: str | |
| recorded: bool | |
| async def record_feedback(request: FeedbackRequest) -> FeedbackResponse: | |
| """ | |
| Record user feedback on a RAG response. | |
| Feedback is persisted to SQLite and used for: | |
| - Analytics (satisfaction rate, failing queries, source quality) | |
| - Embedding fine-tuning via contrastive learning | |
| - Retrieval reranking bias (boost good sources, penalize bad ones) | |
| """ | |
| from core.feedback import FeedbackEntry, FeedbackType, get_feedback_store | |
| try: | |
| ft = FeedbackType(request.feedback_type) | |
| except ValueError as exc: | |
| raise HTTPException( | |
| status_code=422, detail=f"Invalid feedback_type: {request.feedback_type}" | |
| ) from exc | |
| store = get_feedback_store() | |
| entry = FeedbackEntry( | |
| question=request.question, | |
| answer=request.answer, | |
| collection=request.collection, | |
| sources_used=request.sources_used, | |
| feedback_type=ft, | |
| correction=request.correction, | |
| source_feedback=request.source_feedback, | |
| rating=request.rating, | |
| session_id=request.session_id, | |
| ) | |
| fid = store.record(entry) | |
| return FeedbackResponse(feedback_id=fid, recorded=True) | |
| async def feedback_summary(collection: str | None = None) -> dict: | |
| """ | |
| Aggregate feedback analytics: satisfaction rate, top failing queries, | |
| best and worst sources. | |
| """ | |
| from core.feedback import get_feedback_store | |
| store = get_feedback_store() | |
| summary = store.get_summary(collection) | |
| return summary.model_dump() | |
| async def feedback_export(collection: str | None = None) -> dict: | |
| """Export all feedback entries as a list (for offline analysis or fine-tuning).""" | |
| import json as _json | |
| import tempfile | |
| from core.feedback import get_feedback_store | |
| store = get_feedback_store() | |
| records = [] | |
| with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".jsonl") as tf: | |
| tmp_path = Path(tf.name) | |
| try: | |
| n = store.export_jsonl(tmp_path, collection) | |
| if tmp_path.exists(): | |
| with open(tmp_path) as f: | |
| for line in f: | |
| records.append(_json.loads(line)) | |
| finally: | |
| tmp_path.unlink(missing_ok=True) | |
| return {"count": n, "records": records} | |
| async def feedback_boost_factors(collection: str = "default") -> dict: | |
| """ | |
| Per-source boost/penalty factors computed from historical feedback. | |
| Sources > 1.0 are boosted (high thumbs-up). Sources < 1.0 are penalized. | |
| """ | |
| from core.feedback import get_feedback_store, get_source_boost_factors | |
| store = get_feedback_store() | |
| factors = get_source_boost_factors(collection, store) | |
| return {"collection": collection, "boost_factors": factors} | |
| # ββ Embedding fine-tuning βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class FinetuneRequest(BaseModel): | |
| collection: str = Field(..., description="Collection to generate synthetic pairs from") | |
| epochs: int = Field(default=3, ge=1, le=20) | |
| use_feedback: bool = Field(default=True) | |
| use_synthetic: bool = Field(default=True) | |
| class FinetuneResponse(BaseModel): | |
| model_path: str | |
| training_pairs: int | |
| test_pairs: int | |
| baseline_mrr: float | |
| finetuned_mrr: float | |
| improvement_pct: float | |
| async def finetune_embeddings(request: FinetuneRequest) -> FinetuneResponse: | |
| """ | |
| Fine-tune the embedding model on domain-specific data. | |
| 1. Collects training pairs from feedback corrections and synthetic LLM-generated Q&A | |
| 2. Mines hard negatives (semantically similar but wrong chunks) | |
| 3. Fine-tunes with MultipleNegativesRankingLoss | |
| 4. Evaluates improvement via MRR | |
| This is a long-running operation (minutes). Consider running async in production. | |
| Requires: pip install sentence-transformers[train] | |
| """ | |
| from core.embedding_finetuner import run_finetuning_pipeline | |
| from core.generation import get_backend | |
| from core.ingestion import embed_texts | |
| backend = get_backend() | |
| try: | |
| results = run_finetuning_pipeline( | |
| collection_name=request.collection, | |
| llm_fn=backend.complete_raw, | |
| embed_fn=embed_texts, | |
| use_feedback=request.use_feedback, | |
| use_synthetic=request.use_synthetic, | |
| epochs=request.epochs, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| if "error" in results: | |
| raise HTTPException(status_code=422, detail=results["error"]) | |
| return FinetuneResponse( | |
| model_path=results.get("model_path", ""), | |
| training_pairs=results.get("training_pairs", 0), | |
| test_pairs=results.get("test_pairs", 0), | |
| baseline_mrr=results.get("baseline_mrr", 0.0), | |
| finetuned_mrr=results.get("finetuned_mrr", 0.0), | |
| improvement_pct=results.get("improvement_pct", 0.0), | |
| ) | |
| # ββ Agentic RAG βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class AgentRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| collection: str = Field(default="default") | |
| max_iterations: int = Field(default=8, ge=1, le=20) | |
| class AgentResponse(BaseModel): | |
| answer: str | |
| tool_calls: list[dict] = Field(default_factory=list) | |
| total_tokens: int = 0 | |
| latency_ms: float = 0.0 | |
| iterations: int = 0 | |
| model_used: str = "" | |
| async def query_agent(request: AgentRequest) -> AgentResponse: | |
| """ | |
| Agentic RAG β the LLM decides which tools to call in sequence. | |
| Available tools: search_docs, search_web, query_sql, calculate, get_date, summarize_collection. | |
| The agent chains tools until it has a complete answer. Requires ANTHROPIC_API_KEY. | |
| """ | |
| from core.agent import run_agent | |
| from core.generation import get_backend | |
| from core.retrieval import retrieve | |
| backend = get_backend() | |
| def _retrieve_fn(req): | |
| return retrieve(req, generate_fn=backend.complete_raw) | |
| try: | |
| result = run_agent( | |
| question=request.question, | |
| collection=request.collection, | |
| retrieve_fn=_retrieve_fn, | |
| max_iterations=request.max_iterations, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return AgentResponse( | |
| answer=result.answer, | |
| tool_calls=[ | |
| { | |
| "tool": tc.tool_name, | |
| "input": tc.tool_input, | |
| "result_preview": tc.result[:200], | |
| "latency_ms": tc.latency_ms, | |
| } | |
| for tc in result.tool_calls | |
| ], | |
| total_tokens=result.total_tokens, | |
| latency_ms=result.latency_ms, | |
| iterations=result.iterations, | |
| model_used=result.model_used, | |
| ) | |
| # ββ Structured extraction βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StructuredQueryRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| collection: str = Field(default="default") | |
| top_k: int = Field(default=6) | |
| output_schema: dict = Field( | |
| default_factory=dict, | |
| description="JSON Schema describing the desired output structure", | |
| examples=[{"revenue": {"type": "number"}, "period": {"type": "string"}}], | |
| ) | |
| async def query_structured(request: StructuredQueryRequest) -> dict: | |
| """ | |
| Extract structured JSON from retrieved context instead of prose. | |
| Useful for dashboards, downstream APIs, or automated pipelines that | |
| need typed data rather than text answers. | |
| Example: revenue figures β {"revenue": 2.3, "unit": "billion", "change": "+15%"} | |
| """ | |
| from core.generation import answer_structured | |
| from models import QueryMode | |
| q_request = QueryRequest( | |
| question=request.question, | |
| collection=request.collection, | |
| top_k=request.top_k, | |
| mode=QueryMode.HYBRID, | |
| ) | |
| try: | |
| result = answer_structured(q_request, request.output_schema) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return result | |
| # ββ SQL / Structured data βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SQLQueryRequest(BaseModel): | |
| question: str = Field(..., description="Natural language question") | |
| database: str | None = Field( | |
| default=None, description="DB URL or path (uses SQL_DATABASE_URL from config if omitted)" | |
| ) | |
| class SQLQueryResponse(BaseModel): | |
| question: str | |
| result: str | |
| database: str | |
| async def sql_query(request: SQLQueryRequest) -> SQLQueryResponse: | |
| """ | |
| Text-to-SQL: generate and execute a SQL query from natural language. | |
| Combines with vector retrieval for hybrid structured+unstructured answers. | |
| """ | |
| from core.sql_retrieval import get_db_url, query_natural_language | |
| try: | |
| result = query_natural_language(request.question, request.database) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return SQLQueryResponse( | |
| question=request.question, result=result, database=get_db_url(request.database) | |
| ) | |
| async def sql_schema(database: str | None = None) -> dict: | |
| """Return the schema of the configured SQL database.""" | |
| from core.sql_retrieval import get_db_url, get_schema | |
| return {"schema": get_schema(database), "database": get_db_url(database)} | |
| async def sql_setup_sample() -> dict: | |
| """Create a sample SQLite database with products/customers/orders for demos.""" | |
| from core.sql_retrieval import create_sample_db | |
| path = create_sample_db() | |
| return { | |
| "message": "Sample database created.", | |
| "path": str(path), | |
| "hint": f"Set SQL_DATABASE_URL=sqlite:///{path} in .env", | |
| } | |
| # ββ Security ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def security_audit(days: int = 7) -> dict: | |
| """Return security audit summary: PII rates, injection attempts, sensitive queries.""" | |
| from core.security import get_audit_summary | |
| return get_audit_summary(days) | |
| class ScanRequest(BaseModel): | |
| text: str | |
| redact: bool = Field(default=False, description="If true, return redacted version of the text") | |
| async def security_scan(request: ScanRequest) -> dict: | |
| """Scan text for PII patterns and prompt injection attempts.""" | |
| from core.security import detect_injection, detect_pii, redact_pii | |
| pii = redact_pii(request.text) if request.redact else detect_pii(request.text) | |
| inj = detect_injection(request.text) | |
| return { | |
| "has_pii": pii.has_pii, | |
| "pii_types": pii.pii_types, | |
| "redacted_text": pii.redacted_text if request.redact else None, | |
| "redaction_count": pii.redaction_count if request.redact else 0, | |
| "is_injection": inj.is_injection, | |
| "injection_patterns": inj.matched_patterns, | |
| "injection_risk_score": inj.risk_score, | |
| } | |
| # ββ GraphRAG Communities ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def graph_communities(summarize: bool = False) -> dict: | |
| """ | |
| Detect entity communities in the knowledge graph using Louvain method. | |
| Optionally generate LLM summaries for each community (Microsoft GraphRAG pattern). | |
| """ | |
| from core.graph_rag import get_knowledge_graph | |
| graph = get_knowledge_graph() | |
| communities = graph.detect_communities() | |
| result = { | |
| "community_count": len(communities), | |
| "communities": { | |
| str(k): v[:10] for k, v in communities.items() | |
| }, # top 10 entities per community | |
| } | |
| if summarize: | |
| from core.generation import get_backend | |
| backend = get_backend() | |
| summaries = graph.build_community_summaries(backend.complete_raw) | |
| result["summaries"] = {str(k): v for k, v in summaries.items()} | |
| return result | |
| class GlobalQueryRequest(BaseModel): | |
| question: str = Field(..., description="High-level question to answer via community summaries") | |
| top_communities: int = Field(default=5, ge=1, le=20) | |
| async def graph_global_query(request: GlobalQueryRequest) -> dict: | |
| """ | |
| Answer a global question using GraphRAG community summaries. | |
| Better than entity lookup for thematic questions like 'What are the main topics?' | |
| """ | |
| from core.generation import get_backend | |
| from core.graph_rag import get_knowledge_graph | |
| graph = get_knowledge_graph() | |
| backend = get_backend() | |
| summaries = graph.build_community_summaries(backend.complete_raw) | |
| answer = graph.global_query( | |
| request.question, summaries, backend.complete_raw, top_communities=request.top_communities | |
| ) | |
| return { | |
| "question": request.question, | |
| "answer": answer, | |
| "communities_used": min(request.top_communities, len(summaries)), | |
| } | |
| # ββ Observability βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def observability_status() -> dict: | |
| """Check if Langfuse tracing is active.""" | |
| from core.observability import is_enabled | |
| return {"langfuse_enabled": is_enabled()} | |
| async def score_trace_endpoint(trace_id: str, score: float, name: str = "user_feedback") -> dict: | |
| """Attach a user feedback score to a Langfuse trace (1.0=thumbs up, 0.0=thumbs down).""" | |
| from core.observability import score_trace | |
| score_trace(trace_id, score, name) | |
| return {"trace_id": trace_id, "score": score, "name": name} | |
| # ββ CoT-RAG (Chain-of-Thought, EMNLP 2025) ββββββββββββββββββββββββββββββββββββ | |
| class CoTRequest(BaseModel): | |
| question: str = Field( | |
| ..., min_length=1, description="Question to answer with chain-of-thought reasoning" | |
| ) | |
| collection: str = Field(default="default") | |
| top_k: int = Field(default=6, ge=1, le=30) | |
| max_steps: int = Field( | |
| default=4, ge=1, le=8, description="Max reasoning steps to decompose into" | |
| ) | |
| top_k_per_step: int = Field(default=3, ge=1, le=10, description="Chunks to retrieve per step") | |
| class CoTStepResponse(BaseModel): | |
| step_number: int | |
| thought: str | |
| sub_query: str | |
| retrieved_count: int | |
| intermediate: str | |
| latency_ms: float | |
| class CoTResponse(BaseModel): | |
| question: str | |
| answer: str | |
| reasoning_steps: list[CoTStepResponse] | |
| all_sources: list[str] | |
| total_chunks: int | |
| tokens_used: int | |
| latency_ms: float | |
| num_steps: int | |
| warnings: list[str] = Field(default_factory=list) | |
| async def query_cot(request: CoTRequest) -> CoTResponse: | |
| """ | |
| Chain-of-Thought RAG (CoT-RAG) β EMNLP 2025. | |
| Decomposes the question into explicit reasoning steps, retrieves targeted | |
| context for each step, and synthesizes with the full reasoning trace. | |
| Best for: multi-hop questions, questions requiring facts from multiple | |
| document sections, complex analytical queries. | |
| Returns both the answer and the step-by-step reasoning trace. | |
| """ | |
| from core.cot_rag import run_cot_rag | |
| from core.generation import get_backend | |
| from core.retrieval import retrieve | |
| backend = get_backend() | |
| try: | |
| result = run_cot_rag( | |
| question=request.question, | |
| collection=request.collection, | |
| retrieve_fn=retrieve, | |
| llm_fn=backend.complete_raw, | |
| max_steps=request.max_steps, | |
| top_k_per_step=request.top_k_per_step, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return CoTResponse( | |
| question=result.question, | |
| answer=result.answer, | |
| reasoning_steps=[ | |
| CoTStepResponse( | |
| step_number=s.step_number, | |
| thought=s.thought, | |
| sub_query=s.sub_query, | |
| retrieved_count=len(s.retrieved), | |
| intermediate=s.intermediate, | |
| latency_ms=s.latency_ms, | |
| ) | |
| for s in result.reasoning_steps | |
| ], | |
| all_sources=result.all_sources, | |
| total_chunks=result.total_chunks, | |
| tokens_used=result.tokens_used, | |
| latency_ms=result.latency_ms, | |
| num_steps=result.num_steps, | |
| warnings=result.warnings, | |
| ) | |
| # ββ TTRAG (ICLR 2025) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TTRAGRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| collection: str = Field(default="default") | |
| max_iterations: int = Field(default=4, ge=1, le=8) | |
| top_k: int = Field(default=6, ge=1, le=20) | |
| sufficiency_threshold: float = Field(default=0.55, ge=0.1, le=0.95) | |
| class TTRAGIterationResponse(BaseModel): | |
| iteration: int | |
| query_used: str | |
| rewrite_reason: str | |
| new_chunks_retrieved: int | |
| sufficiency_score: float | |
| latency_ms: float | |
| class TTRAGResponse(BaseModel): | |
| question: str | |
| answer: str | |
| iterations: list[TTRAGIterationResponse] | |
| num_iterations: int | |
| unique_chunks_used: int | |
| final_sufficiency: float | |
| converged: bool | |
| tokens_used: int | |
| latency_ms: float | |
| async def query_ttrag(request: TTRAGRequest) -> TTRAGResponse: | |
| """ | |
| TTRAG β Test-Time Compute Scaling for RAG (ICLR 2025). | |
| Iteratively rewrites the query and re-retrieves until sufficient context | |
| is found. Each iteration uses LLM-guided query rewriting to target gaps | |
| in the previously retrieved content. | |
| Best for: hard questions where one-shot retrieval misses the right chunks, | |
| queries with ambiguous terminology, and cases where Sufficient Context | |
| would otherwise abstain. | |
| """ | |
| from core.generation import SYSTEM_PROMPT, build_user_prompt, get_backend | |
| from core.retrieval import retrieve | |
| from core.ttrag import run_ttrag | |
| backend = get_backend() | |
| def _generate(q: str, ctx) -> tuple[str, int]: | |
| prompt = build_user_prompt(ctx) | |
| answer, tokens, _ = backend.complete(SYSTEM_PROMPT, prompt) | |
| return answer, tokens | |
| try: | |
| result = run_ttrag( | |
| question=request.question, | |
| collection=request.collection, | |
| retrieve_fn=retrieve, | |
| llm_fn=backend.complete_raw, | |
| generate_fn=_generate, | |
| max_iterations=request.max_iterations, | |
| top_k=request.top_k, | |
| sufficiency_threshold=request.sufficiency_threshold, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return TTRAGResponse( | |
| question=result.question, | |
| answer=result.answer, | |
| iterations=[ | |
| TTRAGIterationResponse( | |
| iteration=it.iteration, | |
| query_used=it.query_used, | |
| rewrite_reason=it.rewrite_reason, | |
| new_chunks_retrieved=len(it.retrieved), | |
| sufficiency_score=it.sufficiency.overall_score, | |
| latency_ms=it.latency_ms, | |
| ) | |
| for it in result.iterations | |
| ], | |
| num_iterations=result.num_iterations, | |
| unique_chunks_used=result.unique_chunks_used, | |
| final_sufficiency=result.final_sufficiency, | |
| converged=result.converged, | |
| tokens_used=result.tokens_used, | |
| latency_ms=result.latency_ms, | |
| ) | |
| # ββ Speculative RAG (Google Research 2024) βββββββββββββββββββββββββββββββββββ | |
| class SpeculativeRAGRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| collection: str = Field(default="default") | |
| num_drafts: int = Field(default=3, ge=2, le=6) | |
| top_k: int = Field(default=9, ge=3, le=30) | |
| class SpeculativeDraftResponse(BaseModel): | |
| draft_id: int | |
| confidence_score: float | |
| answer: str | |
| num_chunks: int | |
| latency_ms: float | |
| class SpeculativeRAGResponse(BaseModel): | |
| question: str | |
| answer: str | |
| selected_draft_id: int | |
| all_drafts: list[SpeculativeDraftResponse] | |
| num_drafts: int | |
| total_chunks_retrieved: int | |
| latency_reduction_pct: float | |
| tokens_used: int | |
| latency_ms: float | |
| async def query_speculative(request: SpeculativeRAGRequest) -> SpeculativeRAGResponse: | |
| """ | |
| Speculative RAG β Google Research (2024). | |
| Generates N independent draft answers from document subsets, scores each | |
| with LLM self-rating, and returns the highest-confidence draft. | |
| Achieves ~51% latency reduction vs. full-context generation. | |
| """ | |
| from core.generation import get_backend | |
| from core.retrieval import retrieve | |
| from core.speculative_rag import run_speculative_rag | |
| backend = get_backend() | |
| try: | |
| result = run_speculative_rag( | |
| question=request.question, | |
| collection=request.collection, | |
| retrieve_fn=retrieve, | |
| llm_complete_fn=backend.complete, | |
| llm_raw_fn=backend.complete_raw, | |
| num_drafts=request.num_drafts, | |
| top_k=request.top_k, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return SpeculativeRAGResponse( | |
| question=result.question, | |
| answer=result.answer, | |
| selected_draft_id=result.selected_draft_id, | |
| all_drafts=[ | |
| SpeculativeDraftResponse( | |
| draft_id=d.draft_id, | |
| confidence_score=d.confidence_score, | |
| answer=d.answer, | |
| num_chunks=len(d.chunks), | |
| latency_ms=d.latency_ms, | |
| ) | |
| for d in result.all_drafts | |
| ], | |
| num_drafts=result.num_drafts, | |
| total_chunks_retrieved=result.total_chunks_retrieved, | |
| latency_reduction_pct=result.latency_reduction_pct, | |
| tokens_used=result.tokens_used, | |
| latency_ms=result.latency_ms, | |
| ) | |
| # ββ A-RAG (Feb 2026) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ARAGRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| collection: str = Field(default="default") | |
| max_steps: int = Field(default=5, ge=1, le=10) | |
| top_k_per_step: int = Field(default=4, ge=1, le=10) | |
| class ARAGStepResponse(BaseModel): | |
| step: int | |
| tool_chosen: str | |
| query: str | |
| reasoning: str | |
| new_chunks_retrieved: int | |
| latency_ms: float | |
| class ARAGResponse(BaseModel): | |
| question: str | |
| answer: str | |
| steps: list[ARAGStepResponse] | |
| num_steps: int | |
| unique_chunks: int | |
| tools_used: list[str] | |
| tokens_used: int | |
| latency_ms: float | |
| async def query_arag(request: ARAGRequest) -> ARAGResponse: | |
| """ | |
| A-RAG β Hierarchical Retrieval Interfaces (Feb 2026). | |
| The agent dynamically selects the retrieval interface at each step: | |
| keyword_search (BM25), semantic_search (dense), hybrid_search, or | |
| read_section (fetch from a specific source). Most cutting-edge agentic | |
| RAG pattern β treats retrieval as a decision, not a fixed pipeline. | |
| """ | |
| from core.arag import run_arag | |
| from core.generation import get_backend | |
| from core.retrieval import retrieve | |
| backend = get_backend() | |
| try: | |
| result = run_arag( | |
| question=request.question, | |
| collection=request.collection, | |
| retrieve_fn=retrieve, | |
| llm_raw_fn=backend.complete_raw, | |
| llm_complete_fn=backend.complete, | |
| max_steps=request.max_steps, | |
| top_k_per_step=request.top_k_per_step, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return ARAGResponse( | |
| question=result.question, | |
| answer=result.answer, | |
| steps=[ | |
| ARAGStepResponse( | |
| step=s.step, | |
| tool_chosen=s.tool_chosen, | |
| query=s.query, | |
| reasoning=s.reasoning, | |
| new_chunks_retrieved=len(s.retrieved), | |
| latency_ms=s.latency_ms, | |
| ) | |
| for s in result.steps | |
| ], | |
| num_steps=result.num_steps, | |
| unique_chunks=result.unique_chunks, | |
| tools_used=result.tools_used, | |
| tokens_used=result.tokens_used, | |
| latency_ms=result.latency_ms, | |
| ) | |
| # ββ LightRAG (EMNLP 2025) βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class LightRAGRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| level: str = Field( | |
| default="auto", | |
| description="Retrieval level: auto | low | high | combined", | |
| pattern="^(auto|low|high|combined)$", | |
| ) | |
| class LightRAGResponse(BaseModel): | |
| question: str | |
| level: str | |
| resolved_level: str | |
| context_chunks: list[str] | |
| entities_used: list[str] | |
| communities_used: list[str] | |
| confidence: float | |
| async def lightrag_query(request: LightRAGRequest) -> LightRAGResponse: | |
| """ | |
| LightRAG dual-level graph retrieval (EMNLP 2025). | |
| Two retrieval modes over the knowledge graph: | |
| - **low**: precise entity/relationship queries (specific facts) | |
| - **high**: thematic community-level queries (broad concepts) | |
| - **auto**: automatically routes based on query type | |
| - **combined**: merges both modes (maximum coverage) | |
| Requires a knowledge graph built via POST /ingest with extract_graph=true. | |
| """ | |
| from core.generation import get_backend | |
| from core.light_rag import get_light_rag | |
| lr = get_light_rag() | |
| backend = get_backend() | |
| try: | |
| if request.level == "low": | |
| chunks = lr.low_level_retrieve(request.question) | |
| entities = lr._match_entities(request.question) | |
| comms: list[str] = [] | |
| resolved = "low" | |
| elif request.level == "high": | |
| chunks = lr.high_level_retrieve(request.question) | |
| entities = [] | |
| comms = [f"community_{i}" for i in range(len(chunks))] | |
| resolved = "high" | |
| elif request.level == "combined": | |
| result = lr.combined_retrieve(request.question) | |
| chunks = result.context_chunks | |
| entities = result.entities_used | |
| comms = result.communities_used | |
| resolved = "combined" | |
| else: # auto | |
| result = lr.auto_retrieve(request.question, llm_fn=backend.complete_raw) | |
| chunks = result.context_chunks | |
| entities = result.entities_used | |
| comms = result.communities_used | |
| resolved = result.resolved_level | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| confidence = min(1.0, len(chunks) / max(10, 1)) | |
| return LightRAGResponse( | |
| question=request.question, | |
| level=request.level, | |
| resolved_level=resolved, | |
| context_chunks=chunks[:10], # cap for API response size | |
| entities_used=entities, | |
| communities_used=comms, | |
| confidence=round(confidence, 3), | |
| ) | |
| async def lightrag_stats() -> dict: | |
| """LightRAG graph and index statistics.""" | |
| from core.light_rag import get_light_rag | |
| return get_light_rag().stats() | |
| # ββ Sufficient Context check (Google ICLR 2025) βββββββββββββββββββββββββββββββ | |
| class SufficiencyRequest(BaseModel): | |
| question: str = Field(..., min_length=1) | |
| collection: str = Field(default="default") | |
| top_k: int = Field(default=6, ge=1, le=30) | |
| enable_self_rating: bool = Field( | |
| default=False, | |
| description="Ask LLM to self-rate confidence (adds ~200ms latency)", | |
| ) | |
| class SufficiencyResponse(BaseModel): | |
| is_sufficient: bool | |
| overall_score: float | |
| density_score: float | |
| coverage_score: float | |
| num_chunks: int | |
| recommendation: str | |
| explanation: str | |
| component_scores: dict | |
| async def check_context_sufficiency(request: SufficiencyRequest) -> SufficiencyResponse: | |
| """ | |
| Sufficient Context check β Google ICLR 2025. | |
| Scores whether retrieved context is sufficient to answer the question | |
| before committing to a full LLM generation call. | |
| Returns a recommendation: generate | retrieve_more | web_search | abstain. | |
| Use this to build confidence-gated UIs, route low-confidence queries | |
| to escalation paths, or implement cost controls. | |
| """ | |
| from core.generation import get_backend, make_crag_evaluator | |
| from core.retrieval import retrieve | |
| from core.sufficient_context import check_sufficiency | |
| from models import QueryMode | |
| backend = get_backend() | |
| req = QueryRequest( | |
| question=request.question, | |
| collection=request.collection, | |
| top_k=request.top_k, | |
| mode=QueryMode.HYBRID, | |
| ) | |
| try: | |
| evaluate_fn = make_crag_evaluator(backend) if settings.use_hybrid_search else None | |
| context = retrieve(req, generate_fn=backend.complete_raw, evaluate_fn=evaluate_fn) | |
| result = check_sufficiency( | |
| question=request.question, | |
| context=context, | |
| llm_fn=backend.complete_raw if request.enable_self_rating else None, | |
| enable_self_rating=request.enable_self_rating, | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) from e | |
| return SufficiencyResponse( | |
| is_sufficient=result.is_sufficient, | |
| overall_score=result.overall_score, | |
| density_score=result.density_score, | |
| coverage_score=result.coverage_score, | |
| num_chunks=result.num_chunks, | |
| recommendation=result.recommendation, | |
| explanation=result.explanation, | |
| component_scores=result.component_scores, | |
| ) | |
| # ββ Token budget diagnostics ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def debug_token_budget(question: str, collection: str = "default", top_k: int = 10) -> dict: | |
| """ | |
| Debug endpoint: show token budget optimization results for a query. | |
| Returns how many chunks were kept/dropped, token savings percentage, | |
| and estimated token counts before/after optimization. | |
| """ | |
| from core.generation import SYSTEM_PROMPT, get_backend | |
| from core.retrieval import retrieve | |
| from core.token_budget import estimate_tokens, optimize_context | |
| from models import QueryMode | |
| backend = get_backend() | |
| req = QueryRequest( | |
| question=question, | |
| collection=collection, | |
| top_k=top_k, | |
| mode=QueryMode.HYBRID, | |
| ) | |
| context = retrieve(req, generate_fn=backend.complete_raw) | |
| before_tokens = sum(estimate_tokens(r.chunk_text) for r in context.results) | |
| optimized, budget_info = optimize_context( | |
| context=context, | |
| question=question, | |
| system_prompt=SYSTEM_PROMPT, | |
| model_name=settings.claude_model | |
| if settings.llm_backend.value == "claude" | |
| else settings.ollama_model, | |
| ) | |
| return { | |
| "question": question, | |
| "chunks_before": len(context.results), | |
| "chunks_after": budget_info.included, | |
| "chunks_dropped": budget_info.excluded, | |
| "tokens_before": before_tokens, | |
| "tokens_after": budget_info.estimated_tokens, | |
| "savings_pct": budget_info.savings_pct, | |
| "budget_tokens": budget_info.budget_tokens, | |
| "truncated": budget_info.truncated, | |
| } | |