docs-qa-backend / app /rag.py
capcut8128's picture
Upload 12 files
0a19322 verified
Raw History Blame Contribute Delete
5.09 kB
"""Orchestrates: understand question -> retrieve -> ask the LLM -> return answer + sources."""
import logging
from . import llm, query, retrieval
from .config import settings
from .registry import get_registry
log = logging.getLogger("rag.rag")
SYSTEM_PROMPT = """You answer questions about the user's uploaded documents using ONLY the provided context.
- Cite sources inline as [1], [2] using the numbers in the context.
- If the context does not contain the answer, say clearly that you could not find it in the documents. Never guess.
- If the user asks what is on a specific page, describe or summarise that page from the context.
- If the context lists notes about missing pages, mention them briefly.
- Reply in the same language the user wrote in (English, Hindi or Hinglish).
- Be concise and well organised."""
class NoReadyDocuments(Exception):
pass
def _merge_pages(chunks: list[dict]) -> list[dict]:
"""For page lookups: one source per (document, page) instead of one per chunk."""
merged: dict[tuple, dict] = {}
for c in chunks:
key = (c["doc_id"], c["page"])
if key in merged:
merged[key]["text"] += "\n\n" + c["text"]
else:
merged[key] = {**c}
return list(merged.values())
def _page_notes(scope: list[dict], pages: list[int], found: set[tuple]) -> list[str]:
notes = []
for d in scope:
for p in pages:
if (d["doc_id"], p) in found:
continue
if d.get("pages") and p > d["pages"]:
notes.append(f'"{d["filename"]}" has only {d["pages"]} pages, so page {p} does not exist.')
else:
notes.append(
f'Page {p} of "{d["filename"]}" has no extractable text (blank page or a scanned image).'
)
return notes
def _build_context(sources: list[dict]) -> str:
parts, used = [], 0
for i, s in enumerate(sources):
block = f"[{i + 1}] ({s['filename']}, page {s['page']})\n{s['text']}"
room = settings.max_context_chars - used
if room <= 200:
break
block = block[:room]
parts.append(block)
used += len(block)
return "\n\n".join(parts)
def _shape(sources: list[dict]) -> list[dict]:
return [
{
"doc_id": s["doc_id"],
"filename": s["filename"],
"page": s["page"],
"score": round(float(s.get("score", 0.0)), 4),
"text": s["text"],
}
for s in sources
]
def answer_question(question: str, doc_ids: list[str] | None = None, history: list[dict] | None = None) -> dict:
history = history or []
ready = get_registry().ready()
if not ready:
raise NoReadyDocuments()
scope = [d for d in ready if d["doc_id"] in doc_ids] if doc_ids else ready
if not scope:
scope = ready
plan = query.analyze(question, scope, history)
explicit = len(scope) == 1
if plan.doc_ids: # the question names a document -> narrow to it
scope = [d for d in scope if d["doc_id"] in plan.doc_ids] or scope
explicit = True
scope_ids = [d["doc_id"] for d in scope]
notes: list[str] = []
note_for_user = ""
if plan.intent == "page_lookup" and plan.pages:
pages = plan.pages
if len(pages) > settings.max_lookup_pages:
note_for_user = f"\n\n(I read the first {settings.max_lookup_pages} of the {len(pages)} pages you asked about.)"
pages = pages[: settings.max_lookup_pages]
chunks = retrieval.fetch_pages(scope_ids, pages)
sources = _merge_pages(chunks)
found = {(s["doc_id"], s["page"]) for s in sources}
notes = _page_notes(scope, pages, found)
if sources and len(scope) > 1 and not explicit:
notes = [] # don't nag about documents that simply don't have that page
mode = "page_lookup"
shown_pages = pages
else:
sources = retrieval.hybrid_search(plan.search_query, scope_ids, plan.pages or None)
mode = "search"
shown_pages = plan.pages
meta = {
"mode": mode,
"search_query": plan.search_query if mode == "search" else None,
"pages": shown_pages or None,
"documents": [d["filename"] for d in scope],
}
if not sources:
msg = " ".join(notes) if notes else "I couldn't find anything relevant to this in the selected documents."
return {"answer": msg, "sources": [], **meta}
context = _build_context(sources)
note_block = ("\n\nNotes:\n- " + "\n- ".join(notes)) if notes else ""
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
for h in history[-4:]:
messages.append({"role": h["role"], "content": h["content"][:600]})
messages.append({"role": "user", "content": f"Context:\n{context}{note_block}\n\nQuestion: {question}"})
answer = llm.chat(messages)
if not answer:
answer = "The model returned an empty reply. Please try asking again."
return {"answer": answer + note_for_user, "sources": _shape(sources), **meta}