Spaces:
Sleeping
Sleeping
File size: 27,783 Bytes
167c070 fc2fe9f 167c070 2c0e3a3 167c070 fc2fe9f 167c070 06a5fdf 167c070 12bcac9 167c070 12bcac9 d134998 167c070 12bcac9 167c070 9147527 167c070 a2a3e4f 542c221 167c070 542c221 167c070 a2a3e4f 167c070 fc2fe9f da3be01 fc2fe9f 167c070 4bec4c4 167c070 fc2fe9f 167c070 da3be01 167c070 da3be01 167c070 da3be01 fc2fe9f 9a38dd4 12bcac9 fc2fe9f 167c070 9147527 49aaae7 2c0e3a3 49aaae7 2c0e3a3 49aaae7 2c0e3a3 49aaae7 167c070 f545531 167c070 fc2fe9f 9a38dd4 fc2fe9f 167c070 13ff565 167c070 fc2fe9f 9a38dd4 fc2fe9f 167c070 b7e6bc7 9a38dd4 167c070 9a38dd4 b7e6bc7 167c070 a2a3e4f 167c070 a2a3e4f 167c070 a2a3e4f da3be01 167c070 9147527 49aaae7 167c070 a2a3e4f 167c070 da3be01 167c070 fc2fe9f 1a0d1f5 167c070 da3be01 167c070 3e047b8 167c070 da3be01 167c070 9a38dd4 167c070 b7e6bc7 167c070 da3be01 a2a3e4f da3be01 a2a3e4f da3be01 9147527 49aaae7 167c070 da3be01 167c070 da3be01 167c070 84db387 167c070 da3be01 167c070 da3be01 9a38dd4 167c070 da3be01 167c070 da3be01 12bcac9 da3be01 167c070 da3be01 167c070 da3be01 1a0d1f5 da3be01 167c070 da3be01 167c070 9a38dd4 15d6801 fc2fe9f a775719 fc2fe9f a775719 fc2fe9f 15d6801 fc2fe9f a0f0ee1 cd3b9ef a0f0ee1 fc2fe9f cd3b9ef a2a3e4f cd3b9ef fc2fe9f 15d6801 542c221 167c070 9147527 49aaae7 9147527 167c070 fc2fe9f 167c070 15d6801 cd3b9ef 4bec4c4 15d6801 4bec4c4 7fdb111 167c070 06a5fdf 4bec4c4 cd3b9ef 06a5fdf 167c070 fc2fe9f 06a5fdf fc2fe9f 7fdb111 06a5fdf 167c070 06a5fdf 167c070 4bec4c4 167c070 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 | """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
get_metrics().query_latency.observe(time.time() - t0)
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,
)
|