""" Gradio frontend for DocVision OCR - Version 3.0 Compatible with Gradio 6.0+ Fixes applied for Gradio 6.0 breaking changes: 1. theme and css moved from gr.Blocks() to demo.launch() 2. gr.update() removed from .click() outputs lists entirely 3. process_documents() returns 2 outputs instead of 3 4. All lambda wrappers replaced with named functions (Gradio 6 does not support progress= inside lambdas) 5. load_metrics() now returns 2 values to match 2 output components Tabs: 1. Chat & Q&A - Streaming Q&A with memory 2. Document Insights - Auto summary, topics, difficulty 3. Smart Notes - Notes and exam questions generator 4. Report & Export Center - Word document downloads 5. RAG Debug Viewer - Retrieval pipeline transparency 6. Evaluation Dashboard - System metrics 7. Settings - Memory/document controls """ import json import logging import os import tempfile import time from pathlib import Path import gradio as gr import requests from config import config logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # FastAPI always runs on localhost:API_PORT in the same container. # This works both locally and on Hugging Face Spaces. API = f"http://127.0.0.1:{config.API_PORT}" # ============================================================================= # API CLIENT HELPERS # ============================================================================= def _upload(files): file_handles = [] try: file_tuples = [] for f in files: fh = open(f.name, "rb") file_handles.append(fh) file_tuples.append(("files", (Path(f.name).name, fh, "application/pdf"))) r = requests.post(f"{API}/upload", files=file_tuples, timeout=300) r.raise_for_status() return True, r.json() except Exception as e: return False, {"message": str(e)} finally: for fh in file_handles: try: fh.close() except Exception: pass def _query(question, use_memory=True, tone="professional", custom_prompt=None): try: payload = { "question": question, "use_memory": use_memory, "tone": tone, "custom_system_prompt": custom_prompt or None, } r = requests.post(f"{API}/query", json=payload, timeout=120) r.raise_for_status() return True, r.json() except Exception as e: return False, { "answer": str(e), "sources": [], "suggestions": [], "metadata": {}, "rag_debug": {}, "hallucination_flag": False, "hallucination_reason": "", "confidence": 0, "processing_time": 0, "query_type": "", "report_filename": "", } def _stream_query(question, tone="professional"): """Yields tokens from the SSE /stream endpoint.""" try: url = f"{API}/stream?question={requests.utils.quote(question)}&tone={tone}" with requests.get(url, stream=True, timeout=120) as r: for line in r.iter_lines(): if line: line_str = line.decode("utf-8") if line_str.startswith("data: "): data = line_str[6:] if data.strip() == "[DONE]": return try: token = json.loads(data).get("token", "") yield token except Exception: pass except Exception as e: yield f"\n[Stream error: {e}]" def _insights(insight_type): try: r = requests.post( f"{API}/insights", json={"insight_type": insight_type}, timeout=120, ) r.raise_for_status() return r.json() except Exception as e: return {"content": str(e), "processing_time": 0} def _get_history(): try: r = requests.get(f"{API}/history", timeout=10) r.raise_for_status() return r.json() except Exception: return [] def _get_rag_debug(): try: r = requests.get(f"{API}/rag-debug", timeout=10) r.raise_for_status() return r.json() except Exception: return {} def _get_metrics(): try: r = requests.get(f"{API}/metrics", timeout=10) r.raise_for_status() return r.json() except Exception: return {} def _clear_memory(): try: r = requests.delete(f"{API}/memory", timeout=10) r.raise_for_status() return "Conversation memory cleared." except Exception as e: return f"Failed: {e}" def _clear_documents(): try: r = requests.delete(f"{API}/documents", timeout=10) r.raise_for_status() return "All documents and memory cleared." except Exception as e: return f"Failed: {e}" def _download_report(filename): if not filename: return None try: r = requests.get(f"{API}/reports/{filename}", timeout=30) r.raise_for_status() # Save into system temp so Gradio 6 can always serve the file tmp_dir = tempfile.gettempdir() out_path = os.path.join(tmp_dir, filename) with open(out_path, "wb") as fh: fh.write(r.content) return out_path except Exception: return None # ============================================================================= # TAB 1: CHAT & Q&A # ============================================================================= def process_documents(files, progress=gr.Progress()): """ Returns (upload_status, doc_summary_state). Only 2 outputs — gr.update() has been removed entirely. """ if not files: return "Please select at least one PDF file.", "" progress(0.2, desc="Uploading files...") ok, data = _upload(files) progress(0.9, desc="Building index...") if not ok: return f"Upload failed: {data.get('message', 'Unknown error')}", "" docs = data.get("documents", []) lines = [ f"Processed {len(docs)} document(s) | " f"{data.get('total_chunks', 0)} chunks indexed", "", ] for i, d in enumerate(docs, 1): ocr = " [OCR]" if d.get("has_ocr") else "" lines.append( f" {i}. {d['name']} | {d['pages']} pages | " f"{d['chunks']} chunks{ocr}" ) progress(1.0) summary = "\n".join(lines) return summary, summary def stream_answer(question, use_memory, tone, custom_prompt, progress=gr.Progress()): """ Single-call answer function. Uses only POST /query — no SSE double-call to avoid doubling token usage. The generator wrapper is kept so Gradio event wiring stays unchanged. """ if not question.strip(): yield "", "", "", "" return progress(0.2, desc="Processing question...") ok, data = _query( question, use_memory=use_memory, tone=tone, custom_prompt=custom_prompt if custom_prompt and custom_prompt.strip() else None, ) progress(0.9, desc="Formatting response...") if not ok: error_msg = data.get("answer", "An error occurred. Please try again.") yield error_msg, "", "", "" return sources_text = _format_sources(data.get("sources", [])) sugg_text = _format_suggestions(data.get("suggestions", [])) meta = data.get("metadata", {}) conf = data.get("confidence", 0) pt = data.get("processing_time", 0) qt = data.get("query_type", "") chunks_used = meta.get("chunks_used", 0) llm_name = meta.get("llm_backend", "") halluc = data.get("hallucination_flag", False) halluc_rsn = data.get("hallucination_reason", "") info_lines = [ f"Query type: {qt}", f"Confidence: {conf:.3f}", f"Processing time: {pt:.2f}s", f"Chunks used: {chunks_used}", f"LLM backend: {llm_name}", ] if halluc: info_lines.append(f"\nHallucination warning: {halluc_rsn}") answer = data.get("answer", "No answer returned.") full_answer = f"{answer}\n\n{'-' * 40}\n" + "\n".join(info_lines) report_filename = data.get("report_filename") or "" progress(1.0) yield full_answer, sources_text, sugg_text, report_filename def _format_sources(sources): if not sources: return "No sources returned." lines = ["Retrieved Sources:\n"] for src in sources: lines.append( f" {src['id']}. {src['document_name']}\n" f" Score: {src.get('score', 0):.3f}\n" f" Preview: {src.get('text_preview', '')[:200]}\n" ) return "\n".join(lines) def _format_suggestions(suggestions): if not suggestions: return "" return "Suggested follow-up questions:\n\n" + "\n".join( f" - {s}" for s in suggestions ) def load_history_tab(): items = _get_history() if not items: return "No conversation history yet." lines = [] for item in reversed(items[-15:]): lines.append(f"[{item['timestamp'][:19]}]") lines.append(f"Q: {item['question']}") lines.append(f"A: {item['answer'][:300]}...") lines.append( f" Type: {item['query_type']} | " f"Confidence: {item['confidence']:.3f} | " f"Hallucination: {item['hallucination_flag']}" ) lines.append("") return "\n".join(lines) # ============================================================================= # TAB 2: DOCUMENT INSIGHTS # Named functions — Gradio 6 does not support progress= in lambdas # ============================================================================= def run_insight_base(insight_type, progress): labels = { "summary": "Generating document summary...", "key_topics": "Extracting key topics...", "difficulty": "Analyzing difficulty levels...", } progress(0.2, desc=labels.get(insight_type, "Processing...")) data = _insights(insight_type) progress(1.0) pt = data.get("processing_time", 0) content = data.get("content", "Failed to generate insight.") return f"{content}\n\n[Generated in {pt:.2f}s]" def run_summary(progress=gr.Progress()): return run_insight_base("summary", progress) def run_key_topics(progress=gr.Progress()): return run_insight_base("key_topics", progress) def run_difficulty(progress=gr.Progress()): return run_insight_base("difficulty", progress) def run_all_insights(progress=gr.Progress()): results = {} types = ["summary", "key_topics", "difficulty"] for i, t in enumerate(types): progress((i + 1) / len(types), desc=f"Generating {t}...") data = _insights(t) results[t] = data.get("content", "") combined = "" if results.get("summary"): combined += f"DOCUMENT SUMMARY\n{'=' * 50}\n{results['summary']}\n\n" if results.get("key_topics"): combined += f"KEY TOPICS\n{'=' * 50}\n{results['key_topics']}\n\n" if results.get("difficulty"): combined += f"DIFFICULTY ANALYSIS\n{'=' * 50}\n{results['difficulty']}" return combined # ============================================================================= # TAB 3: SMART NOTES & EXAM QUESTIONS # ============================================================================= def run_notes(progress=gr.Progress()): progress(0.3, desc="Generating smart notes...") data = _insights("smart_notes") progress(1.0) return data.get("content", "Failed to generate notes.") def run_short_questions(progress=gr.Progress()): progress(0.3, desc="Generating short-answer questions...") data = _insights("short_questions") progress(1.0) return data.get("content", "") def run_long_questions(progress=gr.Progress()): progress(0.3, desc="Generating long-answer questions...") data = _insights("long_questions") progress(1.0) return data.get("content", "") def run_mcq(progress=gr.Progress()): progress(0.3, desc="Generating MCQs...") data = _insights("mcq") progress(1.0) return data.get("content", "") def run_all_questions(progress=gr.Progress()): combined = "" pairs = [ ("Short Answer", "short_questions"), ("Long Answer", "long_questions"), ("MCQ", "mcq"), ] for i, (label, key) in enumerate(pairs): progress((i + 1) / len(pairs), desc=f"Generating {label}...") data = _insights(key) combined += f"{label.upper()} QUESTIONS\n{'=' * 50}\n{data.get('content', '')}\n\n" return combined # ============================================================================= # TAB 4: REPORT & EXPORT CENTER # ============================================================================= def build_and_download_insights_report(progress=gr.Progress()): progress(0.1, desc="Generating all insights...") all_insights = {} types = [ "summary", "key_topics", "smart_notes", "short_questions", "long_questions", "mcq", "difficulty", ] for i, t in enumerate(types): progress((i + 1) / len(types) * 0.8, desc=f"Generating {t}...") data = _insights(t) all_insights[t] = data.get("content", "") # Small delay between calls to avoid hitting Groq TPM rate limits if i < len(types) - 1: time.sleep(8) progress(0.9, desc="Writing Word document...") try: r = requests.post(f"{API}/insights/report", json=all_insights, timeout=60) r.raise_for_status() out_path = os.path.join(tempfile.gettempdir(), "DocVision_Insights_Report.docx") with open(out_path, "wb") as fh: fh.write(r.content) progress(1.0) return out_path, "Full insights report generated." except Exception as e: return None, f"Failed: {e}" def get_last_qa_report(report_filename): if not report_filename or not report_filename.strip(): return None, "No Q&A report available yet. Ask a question first." path = _download_report(report_filename) if path: return path, f"Report ready: {report_filename}" return None, "Report file not found on server." # ============================================================================= # TAB 5: RAG DEBUG VIEWER # ============================================================================= def load_rag_debug(): debug = _get_rag_debug() if not debug or "message" in debug: return "No query has been run yet. Ask a question first.", "" lines = [ f"Retrieved chunks: {debug.get('retrieved_count', 0)}", f"Reranked to: {debug.get('reranked_count', 0)}", f"Reasoning generated: {debug.get('reasoning_generated', False)}", f"Grounding overlap: {debug.get('grounding_overlap', 'N/A')}", "", ] chunk_lines = [] for c in debug.get("top_scores", []): chunk_lines.append( f" Chunk {c.get('chunk_id', '?')} | {c.get('document', '')} | " f"TF-IDF: {c.get('tfidf_score', 0):.4f} | " f"Rerank: {c.get('rerank_score', 0):.4f}\n" f" Preview: {c.get('preview', '')[:120]}" ) return "\n".join(lines), "\n\n".join(chunk_lines) # ============================================================================= # TAB 6: EVALUATION DASHBOARD # Returns 2 values to match 2 output components # ============================================================================= def load_metrics(): m = _get_metrics() if not m: return "Metrics unavailable.", "" metrics_lines = [ "System Metrics", "=" * 40, f"Total queries run: {m.get('total_queries', 0)}", f"Avg response time: {m.get('avg_response_time', 0):.3f}s", f"Documents loaded: {m.get('documents_loaded', 0)}", f"Total chunks: {m.get('total_chunks', 0)}", f"Memory turns: {m.get('memory_turns', 0)}", f"LLM backend: {m.get('llm_backend', 'unknown')}", "", "Retrieval Settings", "=" * 40, f"Lexical weight: {config.LEXICAL_WEIGHT}", f"Semantic weight: {config.SEMANTIC_WEIGHT}", f"Top K after rerank: {config.TOP_K_AFTER_RERANK}", f"Embedding model: {config.EMBEDDING_MODEL}", f"Reranker model: {config.RERANKER_MODEL}", ] items = _get_history() history_lines = [ f"{'#':<4} {'Type':<14} {'Conf':<8} {'Halluc':<8} Question", "-" * 70, ] for i, item in enumerate(items, 1): h = "YES" if item.get("hallucination_flag") else "no" conf = f"{item.get('confidence', 0):.3f}" q = item.get("question", "")[:40] qt = item.get("query_type", "")[:12] history_lines.append(f"{i:<4} {qt:<14} {conf:<8} {h:<8} {q}") return "\n".join(metrics_lines), "\n".join(history_lines) # ============================================================================= # UI CONSTANTS # ============================================================================= HEADER_HTML = """
Multi-Agent AI Document Intelligence Platform v3.0
Groq LLaMA 3 | FAISS Retrieval | OCR | Streaming | Memory | Hallucination Detection