Spaces:
Running
Running
| """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 | |
| 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 | |
| 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 [] | |
| 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, | |
| ) | |