chatapp1 / app /main.py
dreamthehacker's picture
Upload 6 files
b7ea387 verified
Raw
History Blame Contribute Delete
32.7 kB
from __future__ import annotations
import json
import math
import os
import re
import traceback
from collections import Counter
from pathlib import Path
from typing import Any, Optional
from fastapi import FastAPI
from openai import OpenAI
from pydantic import BaseModel, Field
from langchain_community.vectorstores import FAISS
from langchain_qdrant import QdrantVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
from qdrant_client import QdrantClient
from qdrant_client.http import models as qmodels
from qdrant_client.http.models import Distance, VectorParams
EMBED_DIM = 384
FAISS_DIR = Path(os.getenv("FAISS_DIR", "faiss_store"))
PAGE_RE = re.compile(r"\[PAGE\s+(\d+)\]", re.IGNORECASE)
FIGURE_REF_RE = re.compile(r"\b(fig(?:ure)?\.?)\s*(\d+)\b", re.IGNORECASE)
TABLE_REF_RE = re.compile(r"\b(tab(?:le)?\.?)\s*(\d+)\b", re.IGNORECASE)
CITATION_REF_RE = re.compile(r"\[(S\d+)\]")
VISUAL_TYPES = {
"figure_explain",
"figure_ocr",
"figure_caption",
"table_explain",
"table_ocr",
"table_caption",
"page_ocr",
"table_pdfplumber",
"table_unstructured",
"tesseract_ocr",
}
_VECTORSTORE: Optional[Any] = None
_EMBEDDINGS: Optional[Any] = None
STOPWORDS = {
"the",
"and",
"for",
"with",
"that",
"this",
"from",
"into",
"your",
"about",
"what",
"which",
"where",
"when",
"how",
"why",
"does",
"did",
"are",
"is",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"can",
"could",
"should",
"would",
"may",
"might",
"will",
"shall",
"a",
"an",
"of",
"to",
"in",
"on",
"at",
"by",
"as",
"it",
"its",
"or",
"if",
"but",
"not",
"we",
"our",
"you",
"they",
"their",
"them",
}
DOC_GROUNDED_RE = re.compile(
r"\b(document|doc|pdf|page|citation|snippet|source|context|table|figure|selected)\b",
flags=re.IGNORECASE,
)
DEFAULT_MODEL_ID = "llama-3.1-8b-instant"
DEFAULT_MODEL_CANDIDATES = [
"llama-3.1-8b-instant",
"llama-3.3-70b-versatile",
"openai/gpt-oss-20b",
]
class HistoryMessage(BaseModel):
role: str
content: str
class DocumentContext(BaseModel):
documentName: Optional[str] = None
documentKind: Optional[str] = None
documentText: Optional[str] = None
class QueryRequest(BaseModel):
sessionId: str
message: str
userId: Optional[str] = None
history: list[HistoryMessage] = Field(default_factory=list)
topK: int = 6
document: Optional[DocumentContext] = None
class RetrievedChunk(BaseModel):
id: str
page: int
chunkType: str
text: str
startOffset: Optional[int] = None
endOffset: Optional[int] = None
sourceDocument: Optional[str] = None
score: Optional[float] = None
class Citation(BaseModel):
id: str
page: int
chunkType: str
text: str
startOffset: Optional[int] = None
endOffset: Optional[int] = None
sourceDocument: Optional[str] = None
class QueryResponse(BaseModel):
answer: str
retrievedChunks: list[RetrievedChunk]
citations: list[Citation]
app = FastAPI(title="chatqna-rag-service", version="0.2.0")
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
return os.getenv(name, default)
def get_bool_secret(name: str, default: bool = False) -> bool:
raw = (get_secret(name, str(default)) or "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def normalize_text(text: str) -> str:
return (text or "").strip()
def get_embeddings() -> Any:
"""
Lazy-load sentence-transformers stack to keep startup memory low.
"""
global _EMBEDDINGS
if _EMBEDDINGS is None:
from langchain_huggingface import HuggingFaceEmbeddings
_EMBEDDINGS = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
return _EMBEDDINGS
def get_retrieval_mode() -> str:
mode = (get_secret("RAG_RETRIEVAL_MODE", "semantic") or "semantic").strip().lower()
return mode if mode in {"lexical", "semantic"} else "semantic"
def get_rerank_enabled() -> bool:
return get_bool_secret("RAG_LLM_RERANK", True)
def get_rerank_candidate_k(top_k: int) -> int:
cfg = int(get_secret("RAG_RERANK_CANDIDATES", "8") or "8")
cfg = max(3, min(cfg, 16))
return max(int(top_k), cfg)
def hf_routed_model(model_id: str) -> str:
return model_id if ":" in model_id else f"{model_id}:groq"
def get_model_candidates(model_id: str) -> list[str]:
preferred = normalize_text(model_id) or DEFAULT_MODEL_ID
raw = (get_secret("RAG_MODEL_CANDIDATES", "") or "").strip()
if raw:
candidates = [normalize_text(part) for part in raw.split(",")]
candidates = [c for c in candidates if c]
else:
candidates = [preferred] + [m for m in DEFAULT_MODEL_CANDIDATES if m != preferred]
deduped: list[str] = []
seen: set[str] = set()
for candidate in candidates:
key = candidate.lower()
if key in seen:
continue
seen.add(key)
deduped.append(candidate)
return deduped or [DEFAULT_MODEL_ID]
def openai_chat(
client: OpenAI,
model: str,
messages: list[dict[str, Any]],
temperature: float,
max_tokens: int,
) -> tuple[str, Any]:
resp = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
n=1,
)
content = resp.choices[0].message.content if resp and resp.choices else ""
return content or "", resp
def llm_chat_with_fallback(
model_id: str,
messages: list[dict[str, Any]],
temperature: float,
max_tokens: int,
hf_token: Optional[str],
groq_key: Optional[str],
) -> dict[str, Any]:
result: dict[str, Any] = {
"content": "",
"primary_used": False,
"raw": None,
"used_model": None,
"error_primary": None,
"error_fallback": None,
}
candidate_models = get_model_candidates(model_id)
if hf_token:
primary_errors: list[str] = []
try:
hf_client = OpenAI(base_url="https://router.huggingface.co/v1", api_key=hf_token)
for candidate in candidate_models:
try:
routed = hf_routed_model(candidate)
content, raw = openai_chat(hf_client, routed, messages, temperature, max_tokens)
if normalize_text(content):
result.update(
{
"content": content,
"primary_used": True,
"raw": raw,
"used_model": candidate,
}
)
return result
primary_errors.append(f"{candidate}: empty content")
except Exception as exc: # pragma: no cover
primary_errors.append(f"{candidate}: {type(exc).__name__}: {exc}")
except Exception as exc: # pragma: no cover
primary_errors.append(f"hf_client_init: {type(exc).__name__}: {exc}")
result["error_primary"] = " | ".join(primary_errors[:4]) or "Primary returned empty content."
else:
result["error_primary"] = "Missing HUGGINGFACE_API_TOKEN"
if not groq_key:
result["error_fallback"] = "Missing GROQ_API_KEY (fallback not available)"
return result
fallback_errors: list[str] = []
try:
groq_client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=groq_key)
for candidate in candidate_models:
try:
content, raw = openai_chat(groq_client, candidate, messages, temperature, max_tokens)
if normalize_text(content):
result.update({"content": content, "raw": raw, "used_model": candidate})
return result
fallback_errors.append(f"{candidate}: empty content")
except Exception as exc: # pragma: no cover
fallback_errors.append(f"{candidate}: {type(exc).__name__}: {exc}")
except Exception as exc: # pragma: no cover
fallback_errors.append(f"groq_client_init: {type(exc).__name__}: {exc}")
result["error_fallback"] = " | ".join(fallback_errors[:4]) or "Fallback returned empty content."
return result
def load_faiss(embeddings: Any, path: Path = FAISS_DIR) -> Optional[FAISS]:
if (path / "index.faiss").exists() and (path / "index.pkl").exists():
return FAISS.load_local(str(path), embeddings, allow_dangerous_deserialization=True)
return None
def init_qdrant_vectorstore() -> tuple[Optional[QdrantVectorStore], Optional[str]]:
url = get_secret("QDRANT_URL")
api_key = get_secret("QDRANT_API_KEY")
collection_name = get_secret("QDRANT_COLLECTION", "doc_kb")
if not url or not api_key:
return None, "Missing QDRANT_URL or QDRANT_API_KEY"
try:
client = QdrantClient(url=url, api_key=api_key)
try:
client.get_collection(collection_name=collection_name)
except Exception:
client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=EMBED_DIM, distance=Distance.COSINE),
)
store = QdrantVectorStore(client=client, collection_name=collection_name, embedding=get_embeddings())
return store, None
except Exception as exc:
return None, f"{type(exc).__name__}: {exc}"
def ensure_vectorstore() -> Optional[Any]:
global _VECTORSTORE
if _VECTORSTORE is not None:
return _VECTORSTORE
backend = (get_secret("KB_BACKEND", "qdrant") or "qdrant").lower()
if backend.startswith("qdrant"):
store, _ = init_qdrant_vectorstore()
if store is not None:
_VECTORSTORE = store
return _VECTORSTORE
_VECTORSTORE = load_faiss(get_embeddings())
return _VECTORSTORE
def qdrant_filter_for_chunk_types(types: list[str]) -> qmodels.Filter:
should: list[qmodels.FieldCondition] = []
for path in ("metadata.chunk_type", "chunk_type"):
should.append(qmodels.FieldCondition(key=path, match=qmodels.MatchAny(any=types)))
return qmodels.Filter(should=should)
def is_visual_question(question: str) -> bool:
return bool(re.search(r"\b(fig|figure|diagram|chart|graph|image|table|tab)\b", question or "", flags=re.I))
def extract_ref(question: str) -> Optional[str]:
m = FIGURE_REF_RE.search(question or "")
if m:
return f"figure {m.group(2)}"
m = TABLE_REF_RE.search(question or "")
if m:
return f"table {m.group(2)}"
return None
def retrieve_docs(vectorstore: Any, query: str, k: int, prefer_visual: bool) -> list[Any]:
docs_all: list[Any] = []
if prefer_visual and isinstance(vectorstore, QdrantVectorStore):
try:
flt = qdrant_filter_for_chunk_types(list(VISUAL_TYPES))
docs_vis = vectorstore.similarity_search(query, k=min(12, max(k, 10)), filter=flt)
docs_all.extend(docs_vis)
except Exception:
pass
try:
docs_gen = vectorstore.similarity_search(query, k=max(k, 6))
docs_all.extend(docs_gen)
except Exception:
return []
seen: set[str] = set()
uniq: list[Any] = []
for doc in docs_all:
text = getattr(doc, "page_content", "") or ""
key = text.strip()[:2000]
if key and key not in seen:
seen.add(key)
uniq.append(doc)
if prefer_visual and not isinstance(vectorstore, QdrantVectorStore):
vis_first: list[Any] = []
rest: list[Any] = []
for doc in uniq:
md = getattr(doc, "metadata", {}) or {}
chunk_type = (md.get("chunk_type") or "").lower()
if chunk_type in VISUAL_TYPES:
vis_first.append(doc)
else:
rest.append(doc)
uniq = vis_first + rest
return uniq[: max(k, 10) if prefer_visual else k]
def infer_page_from_text(text: str) -> int:
match = PAGE_RE.search(text or "")
if match:
try:
return max(1, int(match.group(1)))
except ValueError:
return 1
return 1
def infer_page_from_offset(text: str, offset: int) -> int:
if not text:
return 1
before = text[: max(0, min(len(text), offset))]
matches = list(PAGE_RE.finditer(before))
if not matches:
return 1
try:
return max(1, int(matches[-1].group(1)))
except ValueError:
return 1
def dot(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b))
def cosine_similarity(a: list[float], b: list[float]) -> float:
na = math.sqrt(dot(a, a))
nb = math.sqrt(dot(b, b))
if na == 0.0 or nb == 0.0:
return 0.0
return dot(a, b) / (na * nb)
def tokenize_retrieval(text: str) -> list[str]:
toks = re.findall(r"[A-Za-z0-9][A-Za-z0-9\-_]{1,}", text or "")
return [t.lower() for t in toks if t.lower() not in STOPWORDS]
def lexical_overlap_score(query: str, text: str) -> float:
q_tokens = tokenize_retrieval(query)
t_tokens = tokenize_retrieval(text)
if not q_tokens or not t_tokens:
return 0.0
q_count = Counter(q_tokens)
t_count = Counter(t_tokens)
overlap = sum(min(cnt, t_count.get(tok, 0)) for tok, cnt in q_count.items())
if overlap <= 0:
return 0.0
# Lightweight score favoring chunks with better query coverage.
return overlap / float(len(q_tokens) + 0.35 * len(t_tokens))
def split_text_with_offsets(text: str, chunk_size: int, chunk_overlap: int) -> list[dict[str, Any]]:
splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
parts = splitter.split_text(text or "")
chunks: list[dict[str, Any]] = []
cursor = 0
for part in parts:
probe = part[:220]
if not probe:
continue
idx = text.find(probe, max(0, cursor - chunk_overlap - 500))
if idx < 0:
idx = text.find(probe)
if idx < 0:
continue
end = min(len(text), idx + len(part))
cursor = end
chunks.append(
{
"text": part,
"startOffset": idx,
"endOffset": end,
"page": infer_page_from_offset(text, idx),
"chunkType": "main_text",
}
)
return chunks
def retrieve_from_uploaded_document(payload: QueryRequest) -> list[dict[str, Any]]:
document = payload.document
document_text = (document.documentText if document else "") or ""
if not document_text.strip():
return []
chunk_size = int(get_secret("UPLOAD_CHUNK_SIZE", "1000") or "1000")
chunk_overlap = int(get_secret("UPLOAD_CHUNK_OVERLAP", "200") or "200")
chunks = split_text_with_offsets(document_text, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
if not chunks:
return []
ranked: list[dict[str, Any]] = []
if get_retrieval_mode() == "semantic":
embeddings = get_embeddings()
query_vec = embeddings.embed_query(payload.message)
doc_vecs = embeddings.embed_documents([c["text"] for c in chunks])
for chunk, vec in zip(chunks, doc_vecs):
score = cosine_similarity(query_vec, vec)
ranked.append({**chunk, "score": float(score)})
else:
for chunk in chunks:
score = lexical_overlap_score(payload.message, str(chunk.get("text") or ""))
ranked.append({**chunk, "score": float(score)})
ranked.sort(key=lambda c: c["score"], reverse=True)
candidate_k = get_rerank_candidate_k(payload.topK) if get_rerank_enabled() else int(payload.topK)
top_k = max(1, min(candidate_k, len(ranked)))
out: list[dict[str, Any]] = []
for idx, item in enumerate(ranked[:top_k], start=1):
out.append(
{
"id": f"S{idx}",
"page": int(item["page"]),
"chunkType": str(item.get("chunkType") or "main_text"),
"text": str(item["text"]),
"startOffset": int(item["startOffset"]),
"endOffset": int(item["endOffset"]),
"sourceDocument": document.documentName if document else None,
"score": round(float(item.get("score", 0.0)), 5),
}
)
return out
def locate_offsets_in_document(text: str, snippet: str) -> tuple[Optional[int], Optional[int]]:
source = text or ""
needle = (snippet or "").strip()
if not source or not needle:
return None, None
probe = needle[:260]
idx = source.lower().find(probe.lower())
if idx < 0:
return None, None
return idx, min(len(source), idx + len(needle))
def retrieve_from_vectorstore(payload: QueryRequest) -> list[dict[str, Any]]:
vectorstore = ensure_vectorstore()
if vectorstore is None:
return []
prefer_visual = is_visual_question(payload.message)
requested_k = get_rerank_candidate_k(payload.topK) if get_rerank_enabled() else int(payload.topK)
k = max(int(requested_k), 10) if prefer_visual else int(requested_k)
docs = retrieve_docs(vectorstore, payload.message, k=k, prefer_visual=prefer_visual)
if not docs:
return []
document_text = ((payload.document.documentText if payload.document else "") or "").strip()
source_document = payload.document.documentName if payload.document else None
out: list[dict[str, Any]] = []
for idx, doc in enumerate(docs, start=1):
snippet = (getattr(doc, "page_content", "") or "").strip()
if not snippet:
continue
md = getattr(doc, "metadata", {}) or {}
page = md.get("page") or md.get("page_number") or infer_page_from_text(snippet)
try:
page_num = max(1, int(page))
except Exception:
page_num = 1
start_offset = md.get("start_offset")
end_offset = md.get("end_offset")
if start_offset is None or end_offset is None:
start_offset, end_offset = locate_offsets_in_document(document_text, snippet)
out.append(
{
"id": f"S{idx}",
"page": page_num,
"chunkType": str(md.get("chunk_type") or "main_text"),
"text": snippet,
"startOffset": int(start_offset) if isinstance(start_offset, int) else None,
"endOffset": int(end_offset) if isinstance(end_offset, int) else None,
"sourceDocument": source_document or md.get("filename"),
"score": None,
}
)
return out
def extract_json_object(text: str) -> Optional[dict[str, Any]]:
raw = (text or "").strip()
if not raw:
return None
try:
data = json.loads(raw)
return data if isinstance(data, dict) else None
except Exception:
pass
fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", raw, flags=re.IGNORECASE)
if fenced:
block = fenced.group(1).strip()
try:
data = json.loads(block)
return data if isinstance(data, dict) else None
except Exception:
pass
start = raw.find("{")
end = raw.rfind("}")
if start >= 0 and end > start:
try:
data = json.loads(raw[start : end + 1])
return data if isinstance(data, dict) else None
except Exception:
return None
return None
def llm_rerank_chunks(
question: str,
history: list[dict[str, str]],
chunks: list[dict[str, Any]],
model_id: str,
hf_token: Optional[str],
groq_key: Optional[str],
) -> tuple[list[dict[str, Any]], Optional[str]]:
if not get_rerank_enabled() or len(chunks) < 2:
return chunks, None
candidate_limit = min(len(chunks), get_rerank_candidate_k(len(chunks)))
candidates = chunks[:candidate_limit]
by_id = {str(c.get("id")): c for c in candidates if c.get("id")}
if len(by_id) < 2:
return chunks, None
history_tail = history[-4:] if history else []
history_text = "\n".join(f"{m.get('role', 'user')}: {(m.get('content') or '')[:240]}" for m in history_tail)
candidate_lines: list[str] = []
for c in candidates:
sid = str(c.get("id") or "")
page = c.get("page") or 1
ctype = c.get("chunkType") or "main_text"
txt = (c.get("text") or "").strip().replace("\n", " ")
if len(txt) > 800:
txt = txt[:800] + "..."
candidate_lines.append(f"[{sid}] page={page} type={ctype} text={txt}")
rerank_messages: list[dict[str, str]] = [
{
"role": "system",
"content": (
"You are a retrieval ranker. Return ONLY JSON with keys: "
"ordered_ids (array of snippet IDs sorted by relevance), "
"needs_clarification (boolean), clarification_question (string). "
"Do not answer the user question directly. "
"If the user query is ambiguous relative to snippets, set needs_clarification=true and ask one concise question."
),
},
{
"role": "user",
"content": (
f"Conversation tail:\n{history_text or '(none)'}\n\n"
f"Question:\n{question}\n\n"
f"Candidate snippets:\n" + "\n".join(candidate_lines)
),
},
]
rerank_max_tokens = int(get_secret("RAG_RERANK_MAX_TOKENS", "280") or "280")
rerank_result = llm_chat_with_fallback(
model_id=model_id,
messages=rerank_messages,
temperature=0.0,
max_tokens=max(96, min(rerank_max_tokens, 512)),
hf_token=hf_token,
groq_key=groq_key,
)
rerank_content = normalize_text(rerank_result.get("content", ""))
parsed = extract_json_object(rerank_content)
if not parsed:
return chunks, None
needs_clarification = bool(parsed.get("needs_clarification"))
clarification_question = normalize_text(str(parsed.get("clarification_question") or ""))
if needs_clarification and clarification_question:
return chunks, clarification_question
ordered_ids_raw = parsed.get("ordered_ids") or []
if not isinstance(ordered_ids_raw, list):
return chunks, None
ordered_ids: list[str] = []
for item in ordered_ids_raw:
sid = str(item or "").strip()
if sid and sid in by_id and sid not in ordered_ids:
ordered_ids.append(sid)
if not ordered_ids:
return chunks, None
re_ranked_candidates = [by_id[sid] for sid in ordered_ids]
remaining_candidates = [c for c in candidates if str(c.get("id")) not in ordered_ids]
return re_ranked_candidates + remaining_candidates + chunks[candidate_limit:], None
def build_qa_prompt_with_history(
history: list[dict[str, str]],
context_blocks: list[str],
question: str,
max_history_turns: int = 8,
) -> list[dict[str, str]]:
msgs = [m for m in history if m.get("role") in ("user", "assistant")]
if len(msgs) > max_history_turns * 2:
msgs = msgs[-max_history_turns * 2 :]
messages: list[dict[str, str]] = [
{
"role": "system",
"content": (
"You are a precise assistant. Prefer answers grounded in the provided snippets. "
"If the query is ambiguous (for example, 'explain this') and target passage is unclear, "
"ask one concise clarifying question instead of guessing. "
"If the answer is not present in snippets, say: 'Not found in the knowledge base.' "
"When you use snippets, cite snippet IDs like [S1], [S2]. "
"For TABLE questions, prioritize [TABLE_EXPLAIN], [TABLE_OCR], [TABLE_CAPTION], "
"[TABLE_PDFPLUMBER], [TABLE_UNSTRUCTURED], [PAGE_OCR] snippets. "
"For FIGURE questions, prioritize [FIGURE_EXPLAIN], [FIGURE_OCR], [FIGURE_CAPTION], [PAGE_OCR] snippets."
),
}
]
messages.extend(msgs)
context_blob = "\n\n".join(context_blocks)
messages.append({"role": "user", "content": f"Snippets:\n{context_blob}\n\nQuestion: {question}\nAnswer:"})
return messages
def build_general_chat_prompt(history: list[dict[str, str]], question: str, max_history_turns: int = 8) -> list[dict[str, str]]:
msgs = [m for m in history if m.get("role") in ("user", "assistant")]
if len(msgs) > max_history_turns * 2:
msgs = msgs[-max_history_turns * 2 :]
messages: list[dict[str, str]] = [
{
"role": "system",
"content": (
"You are ChatQnA, a concise and helpful assistant. "
"Answer naturally. If user asks document-specific questions without available context, "
"ask them to upload/select the relevant document section."
),
}
]
messages.extend(msgs)
messages.append({"role": "user", "content": question})
return messages
def is_doc_grounded_query(question: str) -> bool:
return bool(DOC_GROUNDED_RE.search(question or ""))
def build_local_fallback_answer(chunks: list[dict[str, Any]]) -> str:
if not chunks:
return "No relevant content found in the knowledge base."
lines = []
for chunk in chunks[:3]:
summary = (chunk.get("text") or "").strip().replace("\n", " ")
if len(summary) > 220:
summary = summary[:217] + "..."
lines.append(f"- {summary} [{chunk['id']}]")
return "Based on retrieved context:\n" + "\n".join(lines)
def build_citations(answer: str, chunks: list[dict[str, Any]]) -> list[Citation]:
by_id = {str(c["id"]): c for c in chunks}
ids: list[str] = []
for sid in CITATION_REF_RE.findall(answer or ""):
if sid in by_id and sid not in ids:
ids.append(sid)
if not ids:
return []
citations: list[Citation] = []
for sid in ids:
chunk = by_id.get(sid)
if not chunk:
continue
citations.append(
Citation(
id=sid,
page=int(chunk.get("page") or 1),
chunkType=str(chunk.get("chunkType") or "main_text"),
text=str(chunk.get("text") or ""),
startOffset=chunk.get("startOffset"),
endOffset=chunk.get("endOffset"),
sourceDocument=chunk.get("sourceDocument"),
)
)
return citations
@app.get("/health")
def health() -> dict[str, str]:
backend = type(_VECTORSTORE).__name__ if _VECTORSTORE is not None else "lazy_uninitialized"
return {"status": "ok", "service": "rag-service", "vectorstore": backend}
@app.post("/query", response_model=QueryResponse)
def query(payload: QueryRequest) -> QueryResponse:
try:
model_id = get_secret("RAG_MODEL_ID", DEFAULT_MODEL_ID) or DEFAULT_MODEL_ID
temperature = float(get_secret("RAG_TEMPERATURE", "0.2") or "0.2")
max_tokens = int(get_secret("RAG_MAX_TOKENS", "512") or "512")
hf_token = get_secret("HUGGINGFACE_API_TOKEN")
groq_key = get_secret("GROQ_API_KEY")
history = [m.model_dump() for m in payload.history]
retrieved = retrieve_from_uploaded_document(payload)
if not retrieved:
retrieved = retrieve_from_vectorstore(payload)
if not retrieved:
if not is_doc_grounded_query(payload.message):
general_messages = build_general_chat_prompt(history, payload.message, max_history_turns=8)
general = llm_chat_with_fallback(
model_id=model_id,
messages=general_messages,
temperature=temperature,
max_tokens=max_tokens,
hf_token=hf_token,
groq_key=groq_key,
)
general_answer = normalize_text(general.get("content", ""))
if general_answer:
return QueryResponse(answer=general_answer, retrievedChunks=[], citations=[])
return QueryResponse(
answer=(
"I can answer this, but the answer model is currently unavailable. "
"Please retry shortly."
),
retrievedChunks=[],
citations=[],
)
return QueryResponse(answer="No relevant content found in the knowledge base.", retrievedChunks=[], citations=[])
retrieved, clarifying_question = llm_rerank_chunks(
question=payload.message,
history=history,
chunks=retrieved,
model_id=model_id,
hf_token=hf_token,
groq_key=groq_key,
)
if clarifying_question:
return QueryResponse(answer=clarifying_question, retrievedChunks=[], citations=[])
retrieved = retrieved[: max(1, int(payload.topK))]
context_blocks = [f"[{c['id']}] {c['text']}" for c in retrieved]
messages = build_qa_prompt_with_history(history, context_blocks, payload.message, max_history_turns=8)
result = llm_chat_with_fallback(
model_id=model_id,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
hf_token=hf_token,
groq_key=groq_key,
)
answer = normalize_text(result.get("content", ""))
if not answer:
answer = build_local_fallback_answer(retrieved)
if result.get("error_primary") or result.get("error_fallback"):
print(
"llm_unavailable:",
{
"model_candidates": get_model_candidates(model_id),
"error_primary": result.get("error_primary"),
"error_fallback": result.get("error_fallback"),
},
)
answer += "\n\n(LLM unavailable right now; showing highest-signal retrieved context.)"
if "not found in the knowledge base" in answer.lower() and context_blocks:
retry_messages = [
{
"role": "system",
"content": "You MUST answer using the snippets below. Do NOT say 'Not found' if any relevant content exists.",
},
{"role": "user", "content": f"Snippets:\n{'\n\n'.join(context_blocks)}\n\nQuestion: {payload.message}\nAnswer:"},
]
retry = llm_chat_with_fallback(
model_id=model_id,
messages=retry_messages,
temperature=temperature,
max_tokens=max_tokens,
hf_token=hf_token,
groq_key=groq_key,
)
retry_text = normalize_text(retry.get("content", ""))
if retry_text:
answer = retry_text
citations = build_citations(answer, retrieved)
response_chunks = [RetrievedChunk(**chunk) for chunk in retrieved]
return QueryResponse(answer=answer, retrievedChunks=response_chunks, citations=citations)
except Exception as exc:
trace = traceback.format_exc(limit=4)
print("query_exception:", trace)
fallback = RetrievedChunk(
id="S1",
page=1,
chunkType="main_text",
text=f"rag-service exception: {type(exc).__name__}: {exc}",
)
return QueryResponse(
answer=(
"RAG query failed and returned a guarded fallback response. "
"Check rag-service logs for details."
),
retrievedChunks=[fallback],
citations=[Citation(id="S1", page=1, chunkType="main_text", text=fallback.text)],
)