vector-auditor / src /agents /document_agent.py
AkshayM
Remove forced section headers from SYSTEM_WHITE_BOX — /query returns raw LLM output
d134998
Raw
History Blame Contribute Delete
27.7 kB
"""Lite RAG agent — no LangChain, no LangGraph.
Pipeline: understand → retrieve → evaluate (loop) → generate → verify → gap_analysis
Two modes: white_box (full reasoning), black_box (temperature=0, minimal prompt).
Graceful degradation when LLM is unavailable.
"""
import asyncio
import json
import logging
import os
import re
import time
import uuid
from datetime import datetime
from typing import Optional
from ..models.schemas import (
Citation,
DocumentAnalysis,
MessageHistory,
Mode,
QueryRequest,
QueryResponse,
)
from ..services.cache import CACHE_TTL, cache_key, get_cache
from ..services.circuit_breaker import CircuitBreakerOpenError
from ..services.llm import LLM, LLMError, get_llm
from ..services.token_counter import estimate_cost, estimate_tokens
from ..vectorstore.Qdrant import get_vector_store
logger = logging.getLogger("rga_auditor.agent")
_EXPECTED_KEYS = {"summary", "key_findings", "methodology", "research_gaps",
"contradictions", "open_questions", "limitations", "confidence"}
def _parse_analysis_json(raw: str) -> dict | None:
"""Try to extract a JSON dict from LLM output. Handles code fences and trailing noise."""
s = raw.strip()
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
s = s.strip()
if not s:
return None
try:
data = json.loads(s)
except json.JSONDecodeError:
return None
if not isinstance(data, dict):
return None
if not any(k in data for k in _EXPECTED_KEYS):
return None
if not isinstance(data.get("key_findings"), list):
data["key_findings"] = []
if not isinstance(data.get("research_gaps"), list):
data["research_gaps"] = []
if not isinstance(data.get("contradictions"), list):
data["contradictions"] = []
if not isinstance(data.get("open_questions"), list):
data["open_questions"] = []
if not isinstance(data.get("summary"), str):
data["summary"] = ""
if not isinstance(data.get("methodology"), str):
data["methodology"] = ""
if not isinstance(data.get("limitations"), str):
data["limitations"] = ""
if data.get("confidence") not in ("high", "moderate", "low"):
data["confidence"] = "moderate"
return data
def _sanitize_citations(answer: str, max_citation: int) -> str:
"""Remove citation references [N] where N > max_citation (hallucinated)."""
import re
def _replace(m):
num = int(m.group(1))
return m.group(0) if 1 <= num <= max_citation else m.group(0).replace(f"[{num}]", f"(ref {num})")
return re.sub(r'\[(\d+)\]', _replace, answer)
SYSTEM_WHITE_BOX = (
"You are a meticulous research analyst. Answer the user's question using ONLY "
"the provided context. Cite sources using [n] notation matching the numbered "
"context blocks. CRITICAL: Only valid citation numbers are the ones shown "
"in the Context section below (e.g., [1], [2], [3], ...). Never use citation "
"numbers outside this range. If the context is insufficient, say so explicitly. "
"Be precise and concise."
)
SYSTEM_BLACK_BOX = (
"You are a precise question-answering system. Answer the question using ONLY the "
"provided context. Cite sources using [n] notation. CRITICAL: Only valid citation "
"numbers are the ones shown in the Context section below (e.g., [1], [2], [3], ...). "
"Never use citation numbers outside this range. No commentary, no reasoning, "
"no caveats — just the cited answer."
)
class DocumentAgent:
def __init__(
self,
llm: Optional[LLM] = None,
vector_store=None,
max_hops: Optional[int] = None,
max_citations_total: Optional[int] = None,
retrieve_k: Optional[int] = None,
rerank_top_k: Optional[int] = None,
) -> None:
self.llm = llm or get_llm()
self.vs = vector_store or get_vector_store()
self.max_hops = max_hops or int(os.getenv("MAX_DOCUMENT_HOPS", "5"))
self.max_citations_total = max_citations_total or int(os.getenv("MAX_CITATIONS_TOTAL", "10"))
self.retrieve_k = retrieve_k or int(os.getenv("RETRIEVE_K_PER_QUERY", "15"))
self.rerank_top_k = rerank_top_k or int(os.getenv("RERANK_TOP_K", "10"))
def _truncate_citations(self, citations: list[Citation]) -> list[Citation]:
out: list[Citation] = []
for c in citations:
if len(c.quote) < 50:
continue
out.append(c)
if self.max_citations_total and len(out) >= self.max_citations_total:
break
return out
async def _retrieve(self, user_id: str, question: str, document_ids: Optional[list[str]], k: int) -> list[Citation]:
logger.info("_retrieve: user=%s question=%.100s doc_ids=%s k=%d", user_id, question, document_ids, k)
results = await self.vs.search(user_id=user_id, query=question, k=k, document_ids=document_ids)
logger.info("_retrieve: search returned %d results", len(results))
citations: list[Citation] = []
for r in results:
location = r.get("location") or f"chunk {r.get('chunk_index', 0)}"
citations.append(
Citation(
quote=r.get("text", ""),
source=r.get("filename", ""),
location=location,
page=r.get("page"),
document_id=r.get("document_id"),
)
)
logger.info("_retrieve: built %d citations", len(citations))
return self._truncate_citations(citations)
async def _generate(self, question: str, context: list[Citation], mode: Mode, history: Optional[list[MessageHistory]]) -> tuple[str, int, int]:
sys = SYSTEM_WHITE_BOX if mode == Mode.white_box else SYSTEM_BLACK_BOX
ctx_lines = [f"[{i+1}] (source={c.source}, location={c.location})\n{c.quote}" for i, c in enumerate(context)]
ctx_block = "\n\n".join(ctx_lines) if ctx_lines else "(no context)"
history_block = ""
if history:
turns = "\n".join(f"{m.role}: {m.content}" for m in history[-6:])
history_block = f"\n\nConversation so far:\n{turns}\n"
prompt = f"Context:\n{ctx_block}\n{history_block}\nQuestion: {question}\nAnswer:"
prompt_tokens = estimate_tokens(prompt)
key = cache_key("llm", mode.value, question, str([(c.source, c.location) for c in context]))
cache = get_cache()
cached = await cache.get(key)
if cached:
answer_tokens = estimate_tokens(cached)
return cached, prompt_tokens, answer_tokens
try:
answer = await self.llm.chat(prompt, system=sys, mode=mode.value)
# Strip hallucinated citation numbers outside the valid range
answer = _sanitize_citations(answer, len(context))
await cache.set(key, answer, CACHE_TTL["llm_response"])
answer_tokens = estimate_tokens(answer)
return answer, prompt_tokens, answer_tokens
except (CircuitBreakerOpenError, LLMError) as e:
logger.warning("LLM unavailable for _generate: %s — returning context-only fallback", e)
fallback = self._build_fallback_answer(question, context)
answer_tokens = estimate_tokens(fallback)
return fallback, prompt_tokens, answer_tokens
@staticmethod
def _build_fallback_answer(question: str, context: list[Citation]) -> str:
if not context:
return f"**LLM temporarily unavailable.** I found no context to answer: {question}"
lines = [f"**LLM temporarily unavailable.** Here are relevant excerpts from your documents for: {question}\n"]
for i, c in enumerate(context[:15]):
src = c.source or "unknown"
loc = c.location or ""
pg = f" (p. {c.page})" if c.page else ""
lines.append(f"*[{i+1}]* **{src}**{pg}{loc}:")
lines.append(f" > {c.quote[:500]}")
lines.append("\n*Please retry shortly when the LLM service is restored for a synthesized answer.*")
return "\n".join(lines)
async def _rerank_citations(self, question: str, citations: list[Citation], top_k: int) -> list[Citation]:
if not citations:
return citations
candidates = [{"text": c.quote} for c in citations]
reranked = await self.vs.rerank(question, candidates, top_k)
kept = {c["text"] for c in reranked}
result = [c for c in citations if c.quote in kept]
logger.info("_rerank_citations: %d → %d after reranking", len(citations), len(result))
return result
@staticmethod
def _enrich_bboxes(citations: list[Citation], upload_dir: str = "uploads") -> list[Citation]:
from ..services.bbox_extractor import extract_bboxes_with_dimensions
for c in citations:
if c.page is None or not c.document_id:
continue
bboxes, w, h = extract_bboxes_with_dimensions(c.document_id, c.page, c.quote, upload_dir)
if bboxes:
c.bboxes = bboxes
if w and h:
c.page_width = w
c.page_height = h
return citations
async def _verify(self, question: str, answer: str, context: list[Citation]) -> str:
if not context:
return "no context to verify against"
ctx = "\n".join(f"[{i+1}] {c.quote}" for i, c in enumerate(context))
prompt = (
f"Question: {question}\n\nProposed answer: {answer}\n\nContext:\n{ctx}\n\n"
"Is every claim in the answer supported by the context? Reply with one line: "
"'VERIFIED' or 'ISSUES: <specific unsupported claims>'."
)
try:
return await self.llm.chat(prompt, system="You are a strict fact-checker.", mode="verify")
except (CircuitBreakerOpenError, LLMError) as e:
logger.warning("LLM unavailable for _verify: %s", e)
return "VERIFICATION_SKIPPED: LLM unavailable"
async def _gaps(self, question: str, context: list[Citation]) -> list[str]:
if not context:
return []
ctx = "\n".join(f"[{i+1}] {c.quote}" for i, c in enumerate(context))
prompt = (
f"Question: {question}\n\nContext:\n{ctx}\n\n"
"List up to 3 specific research gaps or missing information in the context. "
"Return as a JSON array of strings, e.g. [\"gap 1\", \"gap 2\"]."
)
try:
raw = await self.llm.chat(prompt, system="You identify research gaps concisely.", mode="gaps")
except (CircuitBreakerOpenError, LLMError) as e:
logger.warning("LLM unavailable for _gaps: %s", e)
return []
m = re.search(r"\[.*?\]", raw, re.DOTALL)
if not m:
return []
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return []
@staticmethod
def _is_greeting(text: str) -> bool:
return bool(re.match(r"^(hi|hello|hey|greetings|good\s*(morning|afternoon|evening)|sup|howdy|yo)\b", text.strip(), re.I))
def apply_profile(self, profile: Optional[str]) -> None:
if profile:
from ..services.llm import LLM
self.llm = LLM(profile=profile)
async def query(self, user_id: str, req: QueryRequest) -> QueryResponse:
self.apply_profile(req.model)
if self._is_greeting(req.question):
return QueryResponse(
answer="Hi there! What would you like to know about your documents?",
citations=[], reasoning_path=[], tokens_used=0, cost_usd=0.0,
query_id=uuid.uuid4().hex, timestamp=datetime.utcnow().isoformat() + "Z",
mode=req.mode,
)
t0 = time.time()
k = self.retrieve_k
all_citations: list[Citation] = []
seen: set[tuple[str, str]] = set()
questions = [req.question]
for hop in range(self.max_hops):
citations = await self._retrieve(user_id, questions[-1], req.document_ids, k)
for c in citations:
key = (c.source, c.location)
if key not in seen:
seen.add(key)
all_citations.append(c)
if self.max_citations_total and len(all_citations) >= self.max_citations_total or not citations:
break
if req.mode == Mode.white_box and citations:
topics = ", ".join(c.quote[:80] for c in citations[:2])
questions.append(f"More context related to: {topics} — regarding {req.question}")
else:
break
all_citations = self._truncate_citations(all_citations)
all_citations = await self._rerank_citations(req.question, all_citations, self.rerank_top_k)
all_citations = self._enrich_bboxes(all_citations)
reasoning_path: list[str] = []
if req.mode == Mode.white_box:
reasoning_path.append("Retrieved %d citations across %d hop(s)" % (len(all_citations), min(len(questions), self.max_hops)))
answer, prompt_tokens, answer_tokens = await self._generate(req.question, all_citations, req.mode, req.conversation_history)
if req.mode == Mode.white_box:
reasoning_path.append("Generated answer with cite-and-explain framing")
verification: Optional[str] = None
gaps: list[str] = []
if req.mode == Mode.white_box:
verification = await self._verify(req.question, answer, all_citations)
gaps = await self._gaps(req.question, all_citations)
reasoning_path.append("Verified answer against source citations")
if gaps:
reasoning_path.append("Identified %d research gap(s)" % len(gaps))
if verification.startswith("VERIFICATION_SKIPPED"):
reasoning_path.append("Verification skipped — LLM unavailable")
else:
reasoning_path.append("VERIFIED" if verification.startswith("VERIFIED") else f"ISSUES: {verification[:120]}")
# Append verification warning to answer so frontend always shows it
if not verification.startswith("VERIFIED"):
answer += f"\n\n---\n**⚠️ Verification:** {verification}"
cost = estimate_cost(prompt_tokens, answer_tokens)
query_id = uuid.uuid4().hex
return QueryResponse(
answer=answer,
citations=all_citations,
reasoning_path=reasoning_path,
tokens_used=prompt_tokens + answer_tokens,
cost_usd=round(cost, 6),
query_id=query_id,
timestamp=datetime.utcnow().isoformat() + "Z",
verification=verification if req.mode == Mode.white_box else None,
mode=req.mode,
)
async def stream_query(self, user_id: str, req: QueryRequest):
self.apply_profile(req.model)
# Async generator yielding SSE-ready dicts.
# Events: citations, token, verification, gap_analysis, done, error
query_id = uuid.uuid4().hex
timestamp = datetime.utcnow().isoformat() + "Z"
if self._is_greeting(req.question):
yield {"type": "citations", "citations": [], "query_id": query_id, "reasoning_path": []}
yield {"type": "token", "content": "Hi there! What would you like to know about your documents?"}
yield {"type": "done", "tokens_used": 0, "cost_usd": 0.0, "mode": req.mode.value, "query_id": query_id, "timestamp": timestamp}
return
try:
k = self.retrieve_k
all_citations: list[Citation] = []
seen: set[tuple[str, str]] = set()
questions = [req.question]
for hop in range(self.max_hops):
citations = await self._retrieve(user_id, questions[-1], req.document_ids, k)
for c in citations:
key = (c.source, c.location)
if key not in seen:
seen.add(key)
all_citations.append(c)
if self.max_citations_total and len(all_citations) >= self.max_citations_total or not citations:
break
if req.mode == Mode.white_box and citations:
topics = ", ".join(c.quote[:80] for c in citations[:2])
questions.append(f"More context related to: {topics} — regarding {req.question}")
else:
break
all_citations = self._truncate_citations(all_citations)
all_citations = await self._rerank_citations(req.question, all_citations, self.rerank_top_k)
all_citations = self._enrich_bboxes(all_citations)
reasoning_path: list[str] = []
if req.mode == Mode.white_box:
reasoning_path.append(f"Retrieved {len(all_citations)} citations across {len(questions)} hop(s)")
yield {
"type": "citations",
"citations": [c.model_dump() for c in all_citations],
"query_id": query_id,
"reasoning_path": reasoning_path,
}
yield {
"type": "status",
"content": "it may take a while to generate cuz it's using Max reasoning on CPU hardware",
"query_id": query_id,
}
sys = SYSTEM_WHITE_BOX if req.mode == Mode.white_box else SYSTEM_BLACK_BOX
ctx_lines = [f"[{i+1}] (source={c.source}, location={c.location})\n{c.quote}" for i, c in enumerate(all_citations)]
ctx_block = "\n\n".join(ctx_lines) if ctx_lines else "(no context)"
prompt = f"Context:\n{ctx_block}\n\nQuestion: {req.question}\nAnswer:"
prompt_tokens = estimate_tokens(prompt)
total_tokens = prompt_tokens
answer_buf: list[str] = []
async for chunk in self.llm.astream(prompt, system=sys, mode=req.mode.value):
total_tokens += estimate_tokens(chunk)
answer_buf.append(chunk)
yield {"type": "token", "content": chunk}
full_answer = "".join(answer_buf)
# Sanitize hallucinated citations before verify/gaps
full_answer = _sanitize_citations(full_answer, len(all_citations))
answer_tokens = total_tokens - prompt_tokens
cost = estimate_cost(prompt_tokens, answer_tokens)
if req.mode == Mode.white_box:
verification = await self._verify(req.question, full_answer, all_citations)
yield {"type": "verification", "content": verification}
reasoning_path.append("Verification: " + ("passed" if verification.startswith("VERIFIED") else "issues found"))
if not verification.startswith("VERIFIED"):
yield {"type": "token", "content": f"\n\n---\n⚠️ Verification: {verification}"}
gaps = await self._gaps(req.question, all_citations)
for g in gaps:
yield {"type": "gap_analysis", "content": g}
if gaps:
reasoning_path.append(f"Identified {len(gaps)} research gap(s)")
yield {
"type": "done",
"tokens_used": total_tokens,
"cost_usd": round(cost, 6),
"mode": req.mode.value,
"query_id": query_id,
"timestamp": timestamp,
}
except Exception as e:
logger.exception("stream_query failed")
yield {"type": "error", "detail": str(e)[:500], "query_id": query_id}
async def analyze_document(self, user_id: str, question: Optional[str], document_ids: Optional[list[str]],
max_citations: Optional[int] = None, model: Optional[str] = None) -> DocumentAnalysis:
self.apply_profile(model)
req_doc_ids = document_ids or []
logger.info("analyze_document: user=%s question=%r doc_ids=%s max_cit=%s",
user_id, question, req_doc_ids, max_citations)
if question and re.match(r"^(hi|hello|hey|greetings|good\s*(morning|afternoon|evening)|sup|howdy|yo)\b", question.strip(), re.I):
logger.info("analyze_document: greeting detected, returning early")
return DocumentAnalysis(
summary="Hi there! Please select documents to analyze, or ask me a question about your documents.",
key_findings=[], methodology="",
research_gaps=[], contradictions=[], open_questions=[],
limitations="", confidence="high",
citations=[], documents_analyzed=[],
)
if not req_doc_ids:
logger.info("analyze_document: no doc_ids provided, fetching all user docs")
from ..database.repository import list_documents
from ..database.session import get_session_factory
sf = get_session_factory()
if sf:
async with sf() as s:
all_docs = await list_documents(s, user_id=user_id)
else:
all_docs = await list_documents(None, user_id=user_id)
ready = [d for d in all_docs if d.get("status") == "ready"]
if not ready:
logger.info("analyze_document: no ready docs found for user")
return DocumentAnalysis(
summary="No processed documents found. Upload a document first.",
key_findings=[], methodology="",
research_gaps=[], contradictions=[], open_questions=[],
limitations="No documents available.", confidence="low",
citations=[], documents_analyzed=[],
)
req_doc_ids = [d["id"] for d in ready]
logger.info("analyze_document: auto-selected %d ready docs: %s", len(req_doc_ids), req_doc_ids)
if len(req_doc_ids) > 1:
per_doc = max(3, 12 // len(req_doc_ids))
logger.info("analyze_document: multi-doc mode (%d docs), per_doc=%d", len(req_doc_ids), per_doc)
tasks = [self._retrieve(user_id, question or "key findings and methodology", [did], per_doc) for did in req_doc_ids]
results = await asyncio.gather(*tasks)
citations: list[Citation] = []
for part, did in zip(results, req_doc_ids):
logger.info("analyze_document: retrieved %d citations for doc=%s", len(part), did)
citations.extend(part)
if citations:
dedup = {}
for c in citations:
dedup.setdefault(c.quote, c)
citations = list(dedup.values())[:max_citations or self.max_citations_total]
else:
logger.info("analyze_document: single-doc mode, doc=%s", req_doc_ids[0])
citations = await self._retrieve(user_id, question or "key findings and methodology", req_doc_ids or None,
self.max_citations_total)
citations = self._truncate_citations(citations)
citations = await self._rerank_citations(question or "key findings and methodology", citations, self.rerank_top_k)
citations = self._enrich_bboxes(citations)
logger.info("analyze_document: total citations after truncation+rerank=%d", len(citations))
if not citations:
logger.info("analyze_document: no citations found, returning empty")
return DocumentAnalysis(
summary="No content found.", key_findings=[], methodology="",
research_gaps=[], contradictions=[], open_questions=[],
limitations="No documents accessible.", confidence="low",
citations=[], documents_analyzed=[],
)
ctx = "\n\n".join(f"[{i+1}] (source={c.source}) {c.quote}" for i, c in enumerate(citations))
focus = question or "Provide a structured analysis comparing all documents."
def _norm(s: str) -> str:
return re.sub(r"[\s_-]+", "_", s.strip().lower())
seen_docs: dict[str, str] = {}
for c in citations:
key = _norm(c.source)
seen_docs.setdefault(key, c.source)
docs_analyzed = sorted({c.source for c in citations})
doc_ids_with_data = sorted({c.document_id for c in citations if c.document_id})
doc_names = sorted({c.source for c in citations})
is_multi = len(doc_names) > 1
prompt = (
f"Based on the following document excerpts, analyze the topic. "
f"Return valid JSON with keys: summary, key_findings (list), methodology (str), "
f"research_gaps (list), contradictions (list), open_questions (list), "
f"limitations (str), confidence (high|moderate|low). "
f"Make each list at least 2-3 items where applicable. "
f"Do NOT wrap in markdown code fences — return raw JSON only."
f"\n\nDocuments analyzed ({len(doc_names)}): {', '.join(doc_names)}"
f"\n\nQuestion/focus: {focus}\n\nContext:\n{ctx}\n\n"
f"JSON:"
)
logger.info("analyze_document: calling LLM with context length=%d chars", len(ctx))
try:
raw = await self.llm.chat(prompt, system="You are a research analyst. Output valid JSON only, no markdown.", mode="analyze")
except (CircuitBreakerOpenError, LLMError) as e:
logger.warning("LLM unavailable for analyze_document: %s — returning raw-context analysis", e)
raw_citations = "\n\n".join(f"[{i+1}] **{c.source}**" + (f" (p. {c.page})" if c.page else "") + f":\n {c.quote[:300]}" for i, c in enumerate(citations[:10]))
return DocumentAnalysis(
summary=f"**LLM temporarily unavailable.** Raw context from {len(citations)} excerpts.",
key_findings=[f"Found {len(citations)} relevant passages across {len(docs_analyzed)} documents."],
methodology="LLM service unavailable — showing raw excerpts only.",
research_gaps=[], contradictions=[], open_questions=[],
limitations="Full analysis requires LLM service. Showing raw excerpts below:\n\n" + raw_citations,
confidence="low",
citations=citations,
documents_analyzed=docs_analyzed if not is_multi else doc_ids_with_data or docs_analyzed,
)
logger.info("analyze_document: LLM raw response length=%d chars", len(raw))
parsed = _parse_analysis_json(raw)
if parsed is None:
logger.warning("analyze_document: failed to parse LLM output as JSON — storing raw in summary")
return DocumentAnalysis(
summary=raw,
citations=citations,
documents_analyzed=doc_ids_with_data or docs_analyzed,
)
return DocumentAnalysis(
summary=parsed.get("summary", raw),
key_findings=parsed.get("key_findings", []),
methodology=parsed.get("methodology", ""),
research_gaps=parsed.get("research_gaps", []),
contradictions=parsed.get("contradictions", []),
open_questions=parsed.get("open_questions", []),
limitations=parsed.get("limitations", ""),
confidence=parsed.get("confidence", "moderate"),
citations=citations,
documents_analyzed=doc_ids_with_data or docs_analyzed,
)