import os
import time
import json
import hashlib
import tempfile
import csv
import io
import streamlit as st
from dotenv import load_dotenv
from datetime import datetime
# --- LangChain Imports ---
from langchain_groq import ChatGroq
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_community.vectorstores import FAISS
from langchain_community.document_loaders import PyPDFLoader, TextLoader, Docx2txtLoader
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.documents import Document
from langchain.chains import create_history_aware_retriever
from langchain_core.output_parsers import StrOutputParser
# ─────────────────────────────────────────────
# PAGE CONFIG
# ─────────────────────────────────────────────
load_dotenv()
st.set_page_config(
page_title="DocuChat_AI",
page_icon="📄",
layout="wide",
initial_sidebar_state="expanded"
)
st.markdown(
"""
""",
unsafe_allow_html=True,
)
# ─────────────────────────────────────────────
# CONSTANTS
# ─────────────────────────────────────────────
MAX_PAGES = 1000
CHUNK_SIZE = 500 # Changed to tokens (using tiktoken)
CHUNK_OVERLAP = 100
MAX_CONTEXT_CHARS = 12000
MODELS = {
"⚡ Llama 3.1 8B (Fastest)": "llama-3.1-8b-instant",
"🧠 Llama 3.3 70B (Smartest)": "llama-3.3-70b-versatile",
"🌀 Mixtral 8x7B (Balanced)": "mixtral-8x7b-32768",
"💎 Gemma2 9B": "gemma2-9b-it",
}
# ─────────────────────────────────────────────
# SESSION STATE INIT
# ─────────────────────────────────────────────
for key, default in {
"chat_history": [],
"messages": [],
"vectors": None,
"doc_stats": {},
"doc_intelligence": {},
"rag_metrics": {},
"eval_results": [],
"eval_summary": {},
"auth_ok": False,
"last_file_hash": "",
"full_raw_text": "", # BUG FIX: Stores text so summary can use it without reloading
"pending_query": "",
}.items():
if key not in st.session_state:
st.session_state[key] = default
SAMPLE_QUESTIONS = [
"Summarize this document in 6 crisp bullet points.",
"What are the most important facts, dates, names, and numbers?",
"What questions would a reviewer ask about this document?",
"Explain the document like I am new to the topic.",
"Find risks, warnings, limitations, or missing information.",
"Create an action-item checklist from this document.",
]
TASK_PROMPTS = {
"executive_summary": "Create an executive summary with key points, purpose, conclusions, and recommended next steps.",
"key_takeaways": "Extract the top 10 key takeaways from the document and group them by theme.",
"important_terms": "List important terms, names, dates, numbers, and definitions from the document.",
"action_items": "Find every action item, task, owner, deadline, and dependency mentioned in the document.",
"risks": "Analyze risks, gaps, contradictions, assumptions, and possible red flags in the document.",
"decisions": "Identify decisions made or decisions needed, then explain the evidence for each.",
"study_notes": "Turn this document into study notes with sections, bullet points, and likely exam/interview questions.",
"email_brief": "Write a professional email brief summarizing this document for a busy stakeholder.",
}
# ─────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────
@st.cache_resource(show_spinner=False)
def get_embeddings():
"""Cache embeddings model — loads ONCE for the whole session."""
return HuggingFaceEmbeddings(
model_name="all-MiniLM-L6-v2",
model_kwargs={"device": "cpu"},
encode_kwargs={"batch_size": 64, "normalize_embeddings": True},
)
def compute_files_hash(files) -> str:
h = hashlib.md5()
for f in files:
h.update(f.name.encode())
h.update(str(f.size).encode())
return h.hexdigest()
def require_authentication():
app_password = os.getenv("APP_PASSWORD", "").strip()
if not app_password:
st.session_state.auth_ok = True
return
if st.session_state.auth_ok:
return
st.markdown("### 🔐 Private Workspace")
st.caption("This deployment is protected. Enter the app password to continue.")
password = st.text_input("App Password", type="password", placeholder="Enter workspace password")
if st.button("Unlock Workspace", type="primary"):
if password == app_password:
st.session_state.auth_ok = True
st.rerun()
else:
st.error("Incorrect password.")
st.stop()
def ocr_pdf_pages(pdf_path: str, source_name: str, max_pages: int = 8) -> list:
"""Optional OCR for scanned PDFs. Requires pypdfium2, pytesseract, Pillow, and Tesseract binary."""
try:
import pypdfium2 as pdfium
import pytesseract
except Exception as e:
raise RuntimeError("OCR packages are not installed. Install pypdfium2, pytesseract, and Pillow.") from e
docs = []
pdf = pdfium.PdfDocument(pdf_path)
page_count = min(len(pdf), max_pages)
for page_index in range(page_count):
page = pdf[page_index]
bitmap = page.render(scale=2.0)
image = bitmap.to_pil()
text = pytesseract.image_to_string(image)
if text.strip():
docs.append(
Document(
page_content=text,
metadata={
"source": source_name,
"page": page_index,
"extraction": "ocr",
},
)
)
return docs
def has_readable_text(docs: list) -> bool:
return any(doc.page_content and doc.page_content.strip() for doc in docs)
def normalize_source_metadata(docs: list, source_name: str, file_type: str, extraction: str = "text") -> list:
for doc in docs:
doc.metadata["source"] = source_name
doc.metadata["file_type"] = file_type
doc.metadata.setdefault("extraction", extraction)
return docs
def load_documents(files, use_ocr: bool = False, ocr_page_limit: int = 8) -> list:
"""Safely load documents using tempfile (No local folder clutter)."""
docs = []
for file in files:
ext = f".{file.name.split('.')[-1]}"
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file:
temp_file.write(file.getbuffer())
temp_path = temp_file.name
try:
if ext == ".pdf":
loader = PyPDFLoader(temp_path)
elif ext == ".docx":
loader = Docx2txtLoader(temp_path)
else:
loader = TextLoader(temp_path, encoding="utf-8")
loaded = loader.load()
loaded = normalize_source_metadata(loaded, file.name, ext, "text")
if ext == ".pdf" and use_ocr:
extracted_chars = sum(len(doc.page_content.strip()) for doc in loaded)
if extracted_chars < 250:
st.write(f"🔍 Running OCR for scanned PDF: {file.name}")
loaded = ocr_pdf_pages(temp_path, file.name, max_pages=ocr_page_limit)
loaded = [doc for doc in loaded if doc.page_content and doc.page_content.strip()]
docs.extend(loaded[:MAX_PAGES])
except Exception as e:
st.error(f"⚠️ Error loading `{file.name}`: {e}")
finally:
os.remove(temp_path) # Auto-cleanup immediately after reading
return docs
def export_chat() -> str:
lines = [f"# DocuChat_AI Export — {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"]
for m in st.session_state.messages:
role = "👤 User" if m["role"] == "user" else "🤖 Assistant"
lines.append(f"**{role}:** {m['content']}\n")
return "\n".join(lines)
def safe_json_loads(text: str) -> dict:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
cleaned = cleaned.replace("json", "", 1).strip()
start = cleaned.find("{")
end = cleaned.rfind("}")
if start != -1 and end != -1:
cleaned = cleaned[start:end + 1]
try:
return json.loads(cleaned)
except json.JSONDecodeError:
return {}
def ensure_list(value):
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
if isinstance(value, str) and value.strip():
return [value.strip()]
return []
def build_document_intelligence(api_key: str, model_name: str, text: str) -> dict:
sample = text[:10000]
if not sample.strip():
return {}
llm_intel = ChatGroq(
groq_api_key=api_key,
model_name=model_name,
temperature=0,
max_tokens=1800,
)
intel_prompt = ChatPromptTemplate.from_template(
"""
You are a senior document intelligence system.
Analyze the document sample and return ONLY valid JSON.
Allowed document_type values:
Research Paper, Contract, CV, Invoice, Policy, Report, Other
JSON schema:
{{
"document_type": "Research Paper | Contract | CV | Invoice | Policy | Report | Other",
"classification_confidence": 0-100,
"classification_reason": "short reason",
"entities": {{
"people": [],
"organizations": [],
"dates": [],
"money": [],
"locations": []
}},
"risks": {{
"legal_risks": [],
"missing_information": [],
"deadlines": []
}},
"action_items": []
}}
Rules:
- Extract only information visible in the document.
- Keep lists concise and high-signal.
- If nothing is found for a field, return an empty list.
Document sample:
{context}
"""
)
chain = intel_prompt | llm_intel | StrOutputParser()
parsed = safe_json_loads(chain.invoke({"context": sample}))
entities = parsed.get("entities", {}) if isinstance(parsed.get("entities"), dict) else {}
risks = parsed.get("risks", {}) if isinstance(parsed.get("risks"), dict) else {}
return {
"document_type": parsed.get("document_type", "Other"),
"classification_confidence": int(parsed.get("classification_confidence", 0) or 0),
"classification_reason": parsed.get("classification_reason", "Classified from visible document content."),
"entities": {
"people": ensure_list(entities.get("people")),
"organizations": ensure_list(entities.get("organizations")),
"dates": ensure_list(entities.get("dates")),
"money": ensure_list(entities.get("money")),
"locations": ensure_list(entities.get("locations")),
},
"risks": {
"legal_risks": ensure_list(risks.get("legal_risks")),
"missing_information": ensure_list(risks.get("missing_information")),
"deadlines": ensure_list(risks.get("deadlines")),
},
"action_items": ensure_list(parsed.get("action_items")),
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
def calculate_rag_metrics(retrieved_docs, top_k: int, context_chars: int, answer: str) -> dict:
retrieved_count = len(retrieved_docs)
citation_coverage = round(min(100, (retrieved_count / max(top_k, 1)) * 100))
context_utilization = round(min(100, (context_chars / MAX_CONTEXT_CHARS) * 100))
answer_lower = answer.lower()
grounded_penalty = 25 if "isn't in the context" in answer_lower or "not in the context" in answer_lower else 0
confidence_score = round(max(20, min(96, 45 + (citation_coverage * 0.32) + (context_utilization * 0.18) - grounded_penalty)))
unique_sources = {
os.path.basename(doc.metadata.get("source", "Unknown"))
for doc in retrieved_docs
}
return {
"retrieved_chunks": retrieved_count,
"confidence_score": confidence_score,
"citation_coverage": citation_coverage,
"context_utilization": context_utilization,
"unique_sources": len(unique_sources),
"generated_at": datetime.now().strftime("%H:%M:%S"),
}
def build_retriever(llm, top_k: int):
retriever = st.session_state.vectors.as_retriever(
search_type="mmr",
search_kwargs={"k": top_k, "fetch_k": top_k * 3},
)
ctx_prompt = ChatPromptTemplate.from_messages([
("system", "Given the chat history and the latest user question, rephrase it as a standalone search query. Return ONLY the reformulated query."),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
return create_history_aware_retriever(llm, retriever, ctx_prompt)
def format_retrieved_context(retrieved_docs: list) -> tuple[str, int]:
context_parts = []
total_chars = 0
for doc in retrieved_docs:
if total_chars + len(doc.page_content) <= MAX_CONTEXT_CHARS:
context_parts.append(doc.page_content)
total_chars += len(doc.page_content)
else:
remaining = MAX_CONTEXT_CHARS - total_chars
if remaining > 200:
context_parts.append(doc.page_content[:remaining])
total_chars += remaining
break
return "\n\n---\n\n".join(context_parts), total_chars
def answer_from_documents(llm, user_query: str, top_k: int, chat_history=None) -> dict:
history = chat_history if chat_history is not None else st.session_state.chat_history
history_aware_retriever = build_retriever(llm, top_k)
retrieved_docs = history_aware_retriever.invoke({
"input": user_query,
"chat_history": history,
})
formatted_context, total_chars = format_retrieved_context(retrieved_docs)
qa_prompt = ChatPromptTemplate.from_messages([
("system", "You are an expert assistant. Answer using ONLY the provided context. If the answer isn't in the context, say so clearly.\n\nContext:\n{context}"),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
qa_chain = qa_prompt | llm | StrOutputParser()
answer = qa_chain.invoke({
"input": user_query,
"chat_history": history,
"context": formatted_context,
})
return {
"answer": answer,
"retrieved_docs": retrieved_docs,
"context": formatted_context,
"context_chars": total_chars,
"metrics": calculate_rag_metrics(retrieved_docs, top_k, total_chars, answer),
}
def parse_eval_csv(uploaded_file) -> list:
raw = uploaded_file.getvalue().decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(raw))
rows = []
for row in reader:
question = (row.get("question") or row.get("Question") or "").strip()
expected = (row.get("expected_answer") or row.get("Expected Answer") or row.get("answer") or "").strip()
expected_source = (row.get("expected_source") or row.get("source") or "").strip()
if question and expected:
rows.append({
"question": question,
"expected_answer": expected,
"expected_source": expected_source,
})
return rows
def judge_eval_answer(llm, question: str, expected_answer: str, actual_answer: str, context: str) -> dict:
judge_prompt = ChatPromptTemplate.from_template(
"""
You are evaluating a RAG answer. Return ONLY valid JSON.
Score:
- correctness_score: 0-100, how well the actual answer matches the expected answer.
- faithfulness_score: 0-100, whether the actual answer is supported by the retrieved context.
- notes: one short sentence.
JSON schema:
{{
"correctness_score": 0-100,
"faithfulness_score": 0-100,
"notes": "short note"
}}
Question: {question}
Expected answer: {expected_answer}
Actual answer: {actual_answer}
Retrieved context: {context}
"""
)
parsed = safe_json_loads((judge_prompt | llm | StrOutputParser()).invoke({
"question": question,
"expected_answer": expected_answer,
"actual_answer": actual_answer,
"context": context[:6000],
}))
return {
"correctness_score": int(parsed.get("correctness_score", 0) or 0),
"faithfulness_score": int(parsed.get("faithfulness_score", 0) or 0),
"notes": parsed.get("notes", "Evaluation completed."),
}
def run_eval_suite(eval_rows: list, llm, top_k: int) -> tuple[list, dict]:
results = []
for index, row in enumerate(eval_rows, start=1):
rag = answer_from_documents(llm, row["question"], top_k, chat_history=[])
judge = judge_eval_answer(
llm,
row["question"],
row["expected_answer"],
rag["answer"],
rag["context"],
)
results.append({
"test_id": index,
"question": row["question"],
"expected_answer": row["expected_answer"],
"actual_answer": rag["answer"],
"retrieved_chunks": rag["metrics"]["retrieved_chunks"],
"citation_coverage": rag["metrics"]["citation_coverage"],
"confidence_score": rag["metrics"]["confidence_score"],
"correctness_score": judge["correctness_score"],
"faithfulness_score": judge["faithfulness_score"],
"notes": judge["notes"],
})
if not results:
return [], {}
summary = {
"tests": len(results),
"avg_correctness": round(sum(r["correctness_score"] for r in results) / len(results), 1),
"avg_faithfulness": round(sum(r["faithfulness_score"] for r in results) / len(results), 1),
"avg_confidence": round(sum(r["confidence_score"] for r in results) / len(results), 1),
"avg_citation_coverage": round(sum(r["citation_coverage"] for r in results) / len(results), 1),
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
return results, summary
def queue_prompt(prompt: str):
st.session_state.pending_query = prompt
def render_prompt_button(label: str, prompt: str, key: str, help_text: str | None = None):
st.button(label, key=key, use_container_width=True, help=help_text, on_click=queue_prompt, args=(prompt,))
def render_hero():
st.markdown(
"""
Upload documents, generate summaries, extract insights, and ask grounded questions with source citations.DocuChat_AI (Document Intelligence RAG Assistant)
Choose a workflow or start with a suggested prompt. Each button sends a ready-made instruction to the assistant.
', unsafe_allow_html=True) chat_tab, summary_tab, extract_tab, analyze_tab, intel_tab, eval_tab, deliver_tab = st.tabs([ "Chat", "Summaries", "Extract", "Analyze", "Intelligence", "Evaluation", "Deliverables", ]) with chat_tab: st.markdown("#### Sample questions") cols = st.columns(2) for index, prompt in enumerate(SAMPLE_QUESTIONS): with cols[index % 2]: render_prompt_button(prompt, prompt, f"sample_{index}") with summary_tab: st.markdown("#### Summary workflows") c1, c2 = st.columns(2) with c1: render_prompt_button("Executive summary", TASK_PROMPTS["executive_summary"], "task_exec") render_prompt_button("Top 10 takeaways", TASK_PROMPTS["key_takeaways"], "task_takeaways") with c2: render_prompt_button("Study notes", TASK_PROMPTS["study_notes"], "task_study") render_prompt_button("Email brief", TASK_PROMPTS["email_brief"], "task_email") with extract_tab: st.markdown("#### Extraction tools") c1, c2 = st.columns(2) with c1: render_prompt_button("Terms, dates, names", TASK_PROMPTS["important_terms"], "task_terms") with c2: render_prompt_button("Action items", TASK_PROMPTS["action_items"], "task_actions") with analyze_tab: st.markdown("#### Critical analysis") c1, c2 = st.columns(2) with c1: render_prompt_button("Risks and gaps", TASK_PROMPTS["risks"], "task_risks") with c2: render_prompt_button("Decisions needed", TASK_PROMPTS["decisions"], "task_decisions") with intel_tab: st.markdown("#### AI document intelligence") render_intelligence_panel() with eval_tab: st.markdown("#### RAG evaluation dashboard") render_evaluation_panel() with deliver_tab: st.markdown("#### Ready-to-use outputs") c1, c2, c3 = st.columns(3) with c1: render_prompt_button("Meeting notes", "Create concise meeting notes from this document with agenda, key discussion points, decisions, and follow-ups.", "task_meeting") with c2: render_prompt_button("Presentation outline", "Create a polished presentation outline from this document with slide titles and bullet points.", "task_presentation") with c3: render_prompt_button("FAQ", "Create a useful FAQ from this document with clear answers grounded in the content.", "task_faq") # ───────────────────────────────────────────── # SIDEBAR UI # ───────────────────────────────────────────── require_authentication() with st.sidebar: st.title("📄 DocuChat_AI") if os.getenv("APP_PASSWORD", "").strip(): st.success("🔐 Private mode enabled") else: st.caption("Public demo mode") if st.session_state.vectors: st.success("✅ Vector DB Ready", icon="🟢") else: st.info("ℹ️ No documents loaded", icon="🟡") st.divider() st.header("🔑 Configuration") api_key = os.getenv("GROQ_API_KEY", "") if not api_key: api_key = st.text_input("Groq API Key", type="password", placeholder="gsk_...") model_label = st.selectbox("Model", list(MODELS.keys()), index=0) selected_model = MODELS[model_label] with st.expander("⚙️ Advanced Settings"): temperature = st.slider("Temperature", 0.0, 1.0, 0.3, 0.05) top_k = st.slider("Retrieved Chunks (Top-K)", 2, 10, 4) st.divider() st.header("📂 Documents") uploaded_files = st.file_uploader( "Upload PDF, TXT, DOCX", type=["pdf", "txt", "docx"], accept_multiple_files=True, ) st.subheader("🔍 Scanned PDF OCR") use_ocr = st.toggle("Enable OCR for scanned PDFs", value=True, help="Use this for image-based PDFs that have no selectable text.") ocr_page_limit = st.slider("OCR page limit", 1, 25, 8, help="Higher values are slower on CPU Spaces.") st.caption("OCR needs Tesseract. Use Docker Space for the most reliable scanned PDF support.") col1, col2 = st.columns(2) process_btn = col1.button("🔄 Process", type="primary", use_container_width=True) summarize_btn = col2.button("📜 Summary", use_container_width=True) # ── Processing Logic ── if process_btn or summarize_btn: if not api_key: st.error("❌ API Key missing!") elif not uploaded_files: st.warning("⚠️ Upload files first.") else: file_hash = f"{compute_files_hash(uploaded_files)}-ocr-{use_ocr}-{ocr_page_limit}" force_reprocess = (file_hash != st.session_state.last_file_hash) if not force_reprocess and st.session_state.vectors: st.toast("✅ Already processed (using cached vectors)!") else: # Using Streamlit's native status container for cool processing UI with st.status("Processing Documents...", expanded=True) as status: t0 = time.time() st.write("📥 Loading files into memory...") raw_docs = load_documents(uploaded_files, use_ocr=use_ocr, ocr_page_limit=ocr_page_limit) if not raw_docs or not has_readable_text(raw_docs): status.update(label="No content found!", state="error") st.error( "No readable text was found. If this is a scanned PDF, keep OCR enabled and deploy with Docker so Tesseract OCR is installed." ) st.stop() # BUG FIX: Save the full raw text into session state for the summary function st.session_state.full_raw_text = " ".join([d.page_content for d in raw_docs]) st.write("✂️ Splitting into chunks...") # Improved Text Splitter based on Tokens splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, ) chunks = splitter.split_documents(raw_docs) if not chunks: status.update(label="No searchable chunks created!", state="error") st.error( "The document loaded, but no searchable text chunks were created. For scanned PDFs, enable OCR and use Docker deployment with Tesseract." ) st.stop() st.write("🧠 Generating embeddings...") embeddings = get_embeddings() st.write("📦 Building FAISS index...") st.session_state.vectors = FAISS.from_documents(chunks, embeddings) st.session_state.last_file_hash = file_hash elapsed = round(time.time() - t0, 1) st.session_state.doc_stats = { "files": len(uploaded_files), "pages": len(raw_docs), "chunks": len(chunks), "time": elapsed, } st.session_state.rag_metrics = {} st.write("🧾 Classifying document and extracting intelligence...") try: st.session_state.doc_intelligence = build_document_intelligence( api_key, selected_model, st.session_state.full_raw_text, ) except Exception as e: st.session_state.doc_intelligence = {} st.warning(f"Document intelligence could not be generated: {e}") status.update(label=f"Done in {elapsed}s!", state="complete", expanded=False) if summarize_btn: with st.spinner("Generating Summary..."): llm_sum = ChatGroq(groq_api_key=api_key, model_name=selected_model, temperature=0.2) # BUG FIX: Use the saved text instead of raw_docs if not st.session_state.full_raw_text: temp_docs = load_documents(uploaded_files, use_ocr=use_ocr, ocr_page_limit=ocr_page_limit) st.session_state.full_raw_text = " ".join([d.page_content for d in temp_docs]) full_text = st.session_state.full_raw_text[:6000] sum_prompt = ChatPromptTemplate.from_template( "Summarize the following document in exactly 6 clear bullet points:\n\n{context}" ) chain = sum_prompt | llm_sum | StrOutputParser() summary = chain.invoke({"context": full_text}) st.session_state.messages.append({ "role": "assistant", "content": f"📋 **Document Summary**\n\n{summary}" }) st.rerun() # ── Doc Stats Panel (Native Streamlit Metrics) ── if st.session_state.doc_stats: st.divider() st.subheader("📊 Index Stats") s = st.session_state.doc_stats m1, m2 = st.columns(2) m3, m4 = st.columns(2) m1.metric("Files", s['files']) m2.metric("Pages", s['pages']) m3.metric("Chunks", s['chunks']) m4.metric("Time", f"{s['time']}s") if st.session_state.vectors: st.divider() st.subheader("🧠 Intelligence") if st.session_state.doc_intelligence: st.success(f"Type: {st.session_state.doc_intelligence.get('document_type', 'Other')}") else: st.info("Not generated yet.") if st.button("🔎 Refresh Intelligence", use_container_width=True): if not st.session_state.full_raw_text: st.warning("Process documents first.") else: with st.spinner("Classifying and extracting entities..."): st.session_state.doc_intelligence = build_document_intelligence( api_key, selected_model, st.session_state.full_raw_text, ) st.rerun() # ── Actions Panel ── st.divider() st.subheader("🛠 Actions") if st.button("💬 New Chat", use_container_width=True, help="Start a fresh conversation while keeping processed documents ready."): st.session_state.chat_history = [] st.session_state.messages = [] st.session_state.pending_query = "" st.rerun() if st.button("🔁 Reset Workspace", use_container_width=True, help="Clear chat, documents, vector index, and app state."): st.session_state.clear() st.rerun() if st.session_state.messages: st.download_button( "⬇️ Export Chat", data=export_chat(), file_name=f"rag_chat_{datetime.now().strftime('%Y%m%d_%H%M')}.md", mime="text/markdown", use_container_width=True, ) # ───────────────────────────────────────────── # MAIN CHAT UI # ───────────────────────────────────────────── if not api_key: st.warning("👈 Please enter your Groq API key in the sidebar to start.") st.stop() # Initialize LLM llm = ChatGroq( groq_api_key=api_key, model_name=selected_model, temperature=temperature, max_tokens=2048, max_retries=3 # Handles API rate limits automatically ) render_workspace() # Render Chat for msg in st.session_state.messages: with st.chat_message(msg["role"]): st.markdown(msg["content"]) # Chat Input pending_query = st.session_state.get("pending_query", "") if pending_query: user_query = pending_query st.session_state.pending_query = "" else: user_query = st.chat_input("Ask about your documents...") if user_query: st.session_state.messages.append({"role": "user", "content": user_query}) with st.chat_message("user"): st.markdown(user_query) if not st.session_state.vectors: with st.chat_message("assistant"): st.warning("⚠️ No documents processed yet. Upload files and click **Process**.") else: retriever = st.session_state.vectors.as_retriever( search_type="mmr", search_kwargs={"k": top_k, "fetch_k": top_k * 3}, ) ctx_prompt = ChatPromptTemplate.from_messages([ ("system", "Given the chat history and the latest user question, rephrase it as a standalone search query. Return ONLY the reformulated query."), MessagesPlaceholder("chat_history"), ("human", "{input}"), ]) history_aware_retriever = create_history_aware_retriever(llm, retriever, ctx_prompt) qa_prompt = ChatPromptTemplate.from_messages([ ("system", "You are an expert assistant. Answer using ONLY the provided context. If the answer isn't in the context, say so clearly.\n\nContext:\n{context}"), MessagesPlaceholder("chat_history"), ("human", "{input}"), ]) qa_chain = qa_prompt | llm | StrOutputParser() with st.chat_message("assistant"): start_time = time.time() try: # 1. Retrieve Docs retrieved_docs = history_aware_retriever.invoke({ "input": user_query, "chat_history": st.session_state.chat_history, }) # Trim context context_parts = [] total_chars = 0 for doc in retrieved_docs: if total_chars + len(doc.page_content) <= MAX_CONTEXT_CHARS: context_parts.append(doc.page_content) total_chars += len(doc.page_content) else: remaining = MAX_CONTEXT_CHARS - total_chars if remaining > 200: context_parts.append(doc.page_content[:remaining]) break formatted_context = "\n\n---\n\n".join(context_parts) # 2. Native Streamlit Streaming (Replaces the custom for-loop) response_stream = qa_chain.stream({ "input": user_query, "chat_history": st.session_state.chat_history, "context": formatted_context, }) full_response = st.write_stream(response_stream) elapsed = round(time.time() - start_time, 2) st.session_state.rag_metrics = calculate_rag_metrics( retrieved_docs, top_k, total_chars, full_response, ) # Native UI Details col1, col2, col3, col4 = st.columns([1, 1, 1, 2]) col1.caption(f"⏱ {elapsed}s") col2.caption(f"📚 {len(retrieved_docs)} chunks used") col3.caption(f"🎯 {st.session_state.rag_metrics['confidence_score']}% confidence") col4.caption(f"🔗 {st.session_state.rag_metrics['citation_coverage']}% citation coverage") with st.expander("View Source Citations"): for i, doc in enumerate(retrieved_docs): page = doc.metadata.get("page", "N/A") src = os.path.basename(doc.metadata.get("source", "Unknown")) preview = doc.page_content[:200].replace("\n", " ") st.info(f"**{src} (Page {page})**\n\n{preview}...", icon="📄") # Update State st.session_state.messages.append({"role": "assistant", "content": full_response}) st.session_state.chat_history.extend([ HumanMessage(content=user_query), AIMessage(content=full_response), ]) # Keep history bounded if len(st.session_state.chat_history) > 40: st.session_state.chat_history = st.session_state.chat_history[-40:] except Exception as e: st.error(f"❌ Error: {e}")