|
|
|
|
|
|
|
|
|
|
|
|
| import os
|
| import io
|
| import time
|
| import sqlite3
|
| import hashlib
|
| import json
|
| from datetime import datetime, timezone
|
| from typing import List, Tuple, Dict
|
| from difflib import SequenceMatcher
|
| import numpy as np
|
| import re
|
| import gc
|
|
|
| import streamlit as st
|
| from pathlib import Path
|
| import pandas as pd
|
| from sentence_transformers import SentenceTransformer
|
| from transformers import pipeline, AutoModelForSeq2SeqLM, AutoTokenizer
|
| import torch
|
|
|
|
|
| import chromadb
|
| from chromadb import PersistentClient
|
|
|
|
|
| import fitz
|
| from pypdf import PdfReader
|
|
|
|
|
| from pdf2image import convert_from_bytes
|
| import pytesseract
|
|
|
|
|
| import camelot
|
|
|
|
|
| from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
|
| from rouge_score import rouge_scorer
|
|
|
|
|
| import nltk
|
|
|
|
|
| try:
|
| nltk.data.find('tokenizers/punkt')
|
| nltk.data.find('tokenizers/punkt_tab')
|
| except LookupError:
|
| nltk.download('punkt')
|
| nltk.download('punkt_tab')
|
|
|
|
|
| from persistence.restore_once import (
|
| restore_chroma_once,
|
| restore_sqlite_once
|
| )
|
|
|
| restore_chroma_once()
|
| restore_sqlite_once()
|
|
|
|
|
| from persistence.chroma_backup import backup_chroma
|
| from persistence.sqlite_backup import backup_sqlite
|
|
|
|
|
| from persistence.sqlite_mgmt import manage_cache_limit
|
|
|
|
|
|
|
|
|
| combined_style = """
|
| <style>
|
| /* Hide Streamlit menu and footer */
|
| #MainMenu {visibility: hidden;}
|
| footer {visibility: hidden;}
|
| header {visibility: hidden;} /* Optional: Hides the top header bar entirely */
|
|
|
| /* Custom Header Font Sizes */
|
| .header1 {
|
| font-size: 20px !important;
|
| font-weight: bold;
|
| }
|
| .header2 {
|
| font-size: 10px !important;
|
| }
|
| </style>
|
| """
|
| st.markdown(combined_style, unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
| st.set_page_config(page_title="Enterprise PDF → Vector DB (with Cache)", layout="wide")
|
| st.title("📚 DocIQ - Query your knowledge base")
|
|
|
|
|
| ADMIN_USER = "shikari"
|
| ADMIN_PASS = "shambu1983"
|
|
|
|
|
| VIEWER_USER = "viewer"
|
| VIEWER_PASS = "view123"
|
|
|
| DATA_DIR = "data_enterprise"
|
| CHROMA_DIR = os.path.join(DATA_DIR, "chroma_db")
|
| UPLOADS_DIR = os.path.join(DATA_DIR, "uploads")
|
| CACHE_DB_PATH = os.path.join(DATA_DIR, "cache_store.db")
|
|
|
| os.makedirs(DATA_DIR, exist_ok=True)
|
| os.makedirs(UPLOADS_DIR, exist_ok=True)
|
| os.makedirs(CHROMA_DIR, exist_ok=True)
|
|
|
| torch.cuda.empty_cache()
|
| gc.collect()
|
|
|
|
|
|
|
|
|
| def derive_subtopic_from_filename(filename: str) -> str:
|
| if not filename:
|
| return "general"
|
| name = os.path.splitext(filename)[0].replace(" ", "_").strip()
|
| name = re.sub(r'[-_](?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)[A-Z]*\d{2,4}$', '', name, flags=re.IGNORECASE)
|
| name = re.sub(r'[-_]\d{4,8}$', '', name)
|
| parts = [p for p in re.split(r'[_\-]+', name) if p.strip()]
|
| if len(parts) >= 3:
|
| candidate = "_".join(parts[-3:])
|
| elif parts:
|
| candidate = "_".join(parts[-2:])
|
| else:
|
| candidate = "general"
|
| candidate = re.sub(r'[^A-Za-z0-9_]+', '', candidate).strip("_").lower()
|
| return candidate if candidate else "general"
|
|
|
|
|
|
|
|
|
| def init_cache_db(path: str = CACHE_DB_PATH):
|
| conn = sqlite3.connect(path, check_same_thread=False)
|
| cur = conn.cursor()
|
|
|
| cur.execute("""
|
| CREATE TABLE IF NOT EXISTS qa_cache (
|
| id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| topic TEXT NOT NULL,
|
| sub_topic TEXT DEFAULT 'all',
|
| question TEXT NOT NULL,
|
| answer TEXT NOT NULL,
|
| created_at TEXT NOT NULL,
|
| feedback_status TEXT CHECK(feedback_status IN ('Y','N')) DEFAULT NULL,
|
| cosine_score REAL,
|
| rouge2_score REAL,
|
| rougeL_score REAL,
|
| source_docs TEXT,
|
| relevance_threshold REAL,
|
| top_k INTEGER
|
| );
|
| """)
|
| cur.execute("CREATE INDEX IF NOT EXISTS idx_topic ON qa_cache(topic);")
|
| cur.execute("CREATE INDEX IF NOT EXISTS idx_sub_topic ON qa_cache(sub_topic);")
|
| conn.commit()
|
|
|
|
|
| cur.execute("PRAGMA table_info(qa_cache);")
|
| existing_cols = [r[1] for r in cur.fetchall()]
|
|
|
| expected_cols = {
|
| "cosine_score": "REAL",
|
| "rouge2_score": "REAL",
|
| "rougeL_score": "REAL",
|
| "source_docs": "TEXT",
|
| "relevance_threshold": "REAL",
|
| "top_k": "INTEGER"
|
| }
|
| for col, col_type in expected_cols.items():
|
| if col not in existing_cols:
|
| try:
|
| cur.execute(f"ALTER TABLE qa_cache ADD COLUMN {col} {col_type};")
|
| conn.commit()
|
| except Exception as e:
|
|
|
| st.warning(f"Could not add column {col}: {e}")
|
| pass
|
| return conn
|
|
|
| _cache_conn = init_cache_db()
|
|
|
| def fetch_cache_by_topic(topic: str, only_helpful: bool = False):
|
| """
|
| Returns rows with columns:
|
| id, question, answer, created_at, feedback_status, sub_topic, cosine_score, rouge2_score, rougeL_score, source_docs, relevance_threshold, top_k
|
| """
|
| cur = _cache_conn.cursor()
|
|
|
| cols = "id, question, answer, created_at, feedback_status, sub_topic, cosine_score, rouge2_score, rougeL_score, source_docs, relevance_threshold, top_k"
|
|
|
| if only_helpful:
|
| cur.execute(
|
| f"SELECT {cols} FROM qa_cache WHERE topic = ? AND feedback_status = 'Y' ORDER BY created_at DESC",
|
| (topic,)
|
| )
|
| else:
|
| cur.execute(
|
| f"SELECT {cols} FROM qa_cache WHERE topic = ? ORDER BY created_at DESC",
|
| (topic,)
|
| )
|
| rows = cur.fetchall()
|
|
|
| processed = []
|
| for r in rows:
|
| r = list(r)
|
| sd_idx = 9
|
| try:
|
| sd_val = r[sd_idx]
|
| if sd_val:
|
| r[sd_idx] = json.loads(sd_val)
|
| else:
|
| r[sd_idx] = []
|
| except Exception:
|
| r[sd_idx] = []
|
| processed.append(tuple(r))
|
| return processed
|
|
|
| def get_best_fuzzy_match(topic: str, question: str, threshold: float = 0.75):
|
| """
|
| Return (id, stored_question, stored_answer, created_at, feedback_status, sub_topic, cosine_score, rouge2_score, rougeL_score, score, source_docs, relevance_threshold, top_k)
|
| or None if no match above threshold.
|
| IMPORTANT: Only consider rows with feedback_status='Y' (helpful).
|
| """
|
| rows = fetch_cache_by_topic(topic, only_helpful=True)
|
| if not rows:
|
| return None
|
|
|
| q_lower = question.lower().strip()
|
| best = None
|
| best_score = 0.0
|
| for row in rows:
|
|
|
| rid, stored_q, stored_a, created_at, fb, sub_topic, cos_m, r2_m, rL_m, source_docs, rel_th, k = row
|
| score = SequenceMatcher(None, q_lower, (stored_q or "").lower().strip()).ratio()
|
| if score > best_score:
|
| best_score = score
|
| best = (rid, stored_q, stored_a, created_at, fb, sub_topic, cos_m, r2_m, rL_m, score, source_docs, rel_th, k)
|
|
|
| if best and best[9] >= threshold:
|
| return best
|
| return None
|
|
|
| def insert_cache(topic: str, question: str, answer: str, feedback_status: str = None, sub_topic: str = "all",
|
| cosine_score: float = None, rouge2_score: float = None, rougeL_score: float = None,
|
| source_docs: List[str] = None, relevance_threshold: float = None, top_k: int = None):
|
| now = datetime.now(timezone.utc).isoformat()
|
| cur = _cache_conn.cursor()
|
| sd_text = json.dumps(source_docs or [])
|
| cur.execute("""
|
| INSERT INTO qa_cache (topic, sub_topic, question, answer, created_at, feedback_status, cosine_score, rouge2_score, rougeL_score, source_docs, relevance_threshold, top_k)
|
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| """, (topic, sub_topic, question, answer, now, feedback_status, cosine_score, rouge2_score, rougeL_score, sd_text, relevance_threshold, top_k))
|
| _cache_conn.commit()
|
|
|
|
|
| manage_cache_limit(CACHE_DB_PATH, limit=50)
|
| backup_sqlite()
|
| return cur.lastrowid
|
|
|
| def upsert_cache(topic: str, question: str, answer: str, feedback_status: str = None, sub_topic: str = "all",
|
| cosine_score: float = None, rouge2_score: float = None, rougeL_score: float = None,
|
| source_docs: List[str] = None, relevance_threshold: float = None, top_k: int = None):
|
| """
|
| Upsert identified by topic, question, AND sub_topic.
|
| """
|
| now = datetime.now(timezone.utc).isoformat()
|
| sd_text = json.dumps(source_docs or [])
|
| cur = _cache_conn.cursor()
|
| cur.execute("SELECT id FROM qa_cache WHERE topic = ? AND question = ? AND sub_topic = ?", (topic, question, sub_topic))
|
| row = cur.fetchone()
|
| if row:
|
| cur.execute(
|
| "UPDATE qa_cache SET answer = ?, created_at = ?, feedback_status = ?, sub_topic = ?, cosine_score = ?, rouge2_score = ?, rougeL_score = ?, source_docs = ?, relevance_threshold = ?, top_k = ? WHERE id = ?",
|
| (answer, now, feedback_status, sub_topic, cosine_score, rouge2_score, rougeL_score, sd_text, relevance_threshold, top_k, row[0])
|
| )
|
| _cache_conn.commit()
|
| backup_sqlite()
|
| return row[0]
|
| else:
|
| return insert_cache(topic, question, answer, feedback_status, sub_topic, cosine_score, rouge2_score, rougeL_score, source_docs, relevance_threshold, top_k)
|
|
|
| def update_feedback(entry_id: int, feedback_status: str):
|
| try:
|
| with sqlite3.connect(CACHE_DB_PATH, check_same_thread=False) as conn:
|
| cur = conn.cursor()
|
| cur.execute("UPDATE qa_cache SET feedback_status = ? WHERE id = ?", (feedback_status, entry_id))
|
| conn.commit()
|
| affected = cur.rowcount
|
| _cache_conn.commit()
|
| backup_sqlite()
|
| if affected > 0:
|
| st.toast(f"✓ Feedback updated (id={entry_id}, status={feedback_status})")
|
| else:
|
| st.warning(f"[Info] No row found for id={entry_id}.")
|
| except Exception as e:
|
| st.error(f"[Debug] Failed to update feedback for id={entry_id}: {e}")
|
|
|
| def delete_cache_entry(entry_id: int):
|
| cur = _cache_conn.cursor()
|
| cur.execute("DELETE FROM qa_cache WHERE id = ?", (entry_id,))
|
| _cache_conn.commit()
|
| backup_sqlite()
|
|
|
| def clear_cache_db():
|
| cur = _cache_conn.cursor()
|
| cur.execute("DELETE FROM qa_cache;")
|
| _cache_conn.commit()
|
| backup_sqlite()
|
|
|
|
|
|
|
|
|
| EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
|
| _embed_model = None
|
|
|
| def get_embed_model():
|
| global _embed_model
|
| if _embed_model is None:
|
| gc.collect()
|
| torch.cuda.empty_cache()
|
| _embed_model = SentenceTransformer(EMBED_MODEL_NAME, device="cpu")
|
| _ = _embed_model.encode(["warmup"], convert_to_numpy=True, normalize_embeddings=True)
|
| return _embed_model
|
|
|
| def embed_texts(texts: List[str]):
|
| if not texts:
|
| return np.array([])
|
| model = get_embed_model()
|
| with torch.no_grad():
|
| return model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
|
|
|
|
|
|
|
|
|
| chroma_client = PersistentClient(path=CHROMA_DIR)
|
|
|
|
|
|
|
| def get_or_create_collection(topic_name: str):
|
| try:
|
| return chroma_client.get_collection(name=topic_name)
|
| except Exception:
|
| return chroma_client.create_collection(name=topic_name, embedding_function=None)
|
|
|
|
|
|
|
|
|
| def extract_text_from_pdf_bytes(pdf_bytes: bytes) -> str:
|
| text = ""
|
| try:
|
| doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| text = "\n".join([p.get_text("text") for p in doc])
|
| except Exception:
|
| pass
|
| if not text.strip():
|
| try:
|
| reader = PdfReader(io.BytesIO(pdf_bytes))
|
| text = "\n".join([p.extract_text() or "" for p in reader.pages])
|
| except Exception:
|
| text = ""
|
| return text
|
|
|
| def extract_text_with_ocr(pdf_bytes: bytes) -> str:
|
| pages = convert_from_bytes(pdf_bytes)
|
| texts = [pytesseract.image_to_string(p) for p in pages]
|
| return "\n".join(texts)
|
|
|
| def extract_tables_as_text(pdf_path: str) -> str:
|
| table_texts = []
|
| try:
|
| tables = camelot.read_pdf(pdf_path, pages='all', flavor='stream')
|
| for t in tables:
|
| table_texts.append(t.df.to_string(index=False))
|
| except Exception:
|
| pass
|
| return "\n\n".join(table_texts)
|
|
|
|
|
|
|
|
|
| def chunk_text_fixed(text: str, chunk_size=1000, overlap=200) -> List[str]:
|
| if not text:
|
| return []
|
| if chunk_size <= overlap:
|
| raise ValueError("chunk_size must be greater than overlap.")
|
| chunks, start, L = [], 0, len(text)
|
| while start < L:
|
| end = min(start + chunk_size, L)
|
| chunk = text[start:end].strip()
|
| if chunk:
|
| chunks.append(chunk)
|
| start += chunk_size - overlap
|
| return chunks
|
|
|
| def chunk_text_recursive(text: str, chunk_size=1500, overlap=200) -> List[str]:
|
| """
|
| Recursive sentence-aware chunking (Regex based):
|
| """
|
| if not text:
|
| return []
|
|
|
| if chunk_size <= overlap:
|
| raise ValueError("chunk_size must be greater than overlap.")
|
|
|
|
|
| sentences = [
|
| s.strip()
|
| for s in re.split(r'(?<=[.!?])\s+', text)
|
| if s.strip()
|
| ]
|
|
|
| chunks = []
|
| current = []
|
| current_len = 0
|
|
|
| for sent in sentences:
|
| s_len = len(sent)
|
|
|
|
|
| if current and (current_len + s_len + 1 > chunk_size):
|
| chunk = " ".join(current).strip()
|
| chunks.append(chunk)
|
|
|
|
|
| overlap_sentences = []
|
| ov_len = 0
|
| for ss in reversed(current):
|
| overlap_sentences.insert(0, ss)
|
| ov_len += len(ss) + 1
|
| if ov_len >= overlap:
|
| break
|
|
|
| current = overlap_sentences.copy()
|
| current_len = sum(len(x) + 1 for x in current)
|
|
|
|
|
| current.append(sent)
|
| current_len += s_len + 1
|
|
|
|
|
| if current:
|
| chunk = " ".join(current).strip()
|
| if chunk:
|
| chunks.append(chunk)
|
|
|
| return chunks
|
|
|
| def chunk_text_adaptive_nltk(text: str, chunk_size=1200, overlap=200) -> List[str]:
|
| """
|
| Adaptive Chunking (NLTK):
|
| Uses NLTK's robust sentence tokenizer to split text, then groups sentences
|
| into chunks respecting the token limit. Extremely efficient for 2 vCPUs.
|
| """
|
| if not text:
|
| return []
|
|
|
|
|
| try:
|
| sentences = nltk.sent_tokenize(text)
|
| except Exception:
|
|
|
| return chunk_text_recursive(text, chunk_size, overlap)
|
|
|
| chunks = []
|
| current_chunk = []
|
| current_len = 0
|
|
|
| for sent in sentences:
|
| sent = sent.strip()
|
| if not sent:
|
| continue
|
|
|
| sent_len = len(sent)
|
|
|
|
|
| if current_len + sent_len + 1 > chunk_size:
|
|
|
| if current_chunk:
|
| chunks.append(" ".join(current_chunk))
|
|
|
|
|
|
|
|
|
| overlap_buffer = []
|
| overlap_len = 0
|
|
|
| for prev_sent in reversed(current_chunk):
|
| if overlap_len + len(prev_sent) + 1 <= overlap:
|
| overlap_buffer.insert(0, prev_sent)
|
| overlap_len += len(prev_sent) + 1
|
| else:
|
| break
|
|
|
|
|
| current_chunk = overlap_buffer + [sent]
|
| current_len = overlap_len + sent_len + 1
|
| else:
|
| current_chunk.append(sent)
|
| current_len += sent_len + 1
|
|
|
|
|
| if current_chunk:
|
| chunks.append(" ".join(current_chunk))
|
|
|
| return chunks
|
|
|
| def chunk_text(text: str, chunk_size=1000, overlap=200, strategy: str = "fixed") -> List[str]:
|
| """
|
| General chunk_text wrapper supporting strategies:
|
| - strategy='fixed' : original fixed sliding-window
|
| - strategy='recursive' : regex sentence-aware
|
| - strategy='adaptive' : NLTK sentence-aware (New)
|
| """
|
|
|
| strategy = (strategy or "fixed").lower()
|
| try:
|
| if strategy == "adaptive":
|
| return chunk_text_adaptive_nltk(text, chunk_size=chunk_size, overlap=overlap)
|
| elif strategy == "recursive":
|
| return chunk_text_recursive(text, chunk_size=chunk_size, overlap=overlap)
|
| elif strategy == "fixed":
|
| return chunk_text_fixed(text, chunk_size=chunk_size, overlap=overlap)
|
| else:
|
| return chunk_text_fixed(text, chunk_size=chunk_size, overlap=overlap)
|
| except Exception as e:
|
| st.warning(f"Chunking error using '{strategy}', fallback to recursive. Error: {e}")
|
| try:
|
| return chunk_text_recursive(text, chunk_size=chunk_size, overlap=overlap)
|
| except Exception:
|
| return [text]
|
|
|
|
|
|
|
|
|
|
|
| GENERATOR_MODEL_NAME = "MBZUAI/LaMini-Flan-T5-248M"
|
|
|
| @st.cache_resource(show_spinner="Loading QA model...")
|
| def load_generator():
|
| gc.collect()
|
| torch.cuda.empty_cache()
|
| tokenizer = AutoTokenizer.from_pretrained(GENERATOR_MODEL_NAME)
|
| model = AutoModelForSeq2SeqLM.from_pretrained(
|
| GENERATOR_MODEL_NAME,
|
| torch_dtype=torch.float32,
|
| low_cpu_mem_usage=False,
|
| device_map=None,
|
| )
|
|
|
| return pipeline("text2text-generation", model=model, tokenizer=tokenizer, device=-1)
|
|
|
| generator = load_generator()
|
|
|
| def generate_answer_from_context(
|
| combined_context: str,
|
| question: str,
|
| support_threshold: float = 0.60,
|
| top_k: int = 4
|
| ) -> (str, float, float, float):
|
| """
|
| New function replacing chunked_summarize.
|
| Instead of summarizing chunks individually, this takes the combined top-k retrieval
|
| and asks the model to answer the question based on that context.
|
| """
|
| start_time = time.perf_counter()
|
|
|
|
|
|
|
| max_char_limit = 3000
|
| safe_context = combined_context[:max_char_limit]
|
|
|
|
|
|
|
| input_prompt = f"""Identify the answer to the following question using only the context provided.
|
| If the answer is not found in the context, respond with "Not stated in context".
|
|
|
| Context:
|
| {safe_context}
|
|
|
| Question:
|
| {question}
|
|
|
| Answer:"""
|
|
|
|
|
| try:
|
| output = generator(
|
| input_prompt,
|
| max_length=512,
|
| do_sample=False,
|
| temperature=0.0,
|
| truncation=True
|
| )
|
| final_answer = output[0]['generated_text'].strip()
|
| except Exception as e:
|
| final_answer = f"[Generation failed: {e}]"
|
|
|
|
|
| if "not stated in context" in final_answer.lower():
|
|
|
| pass
|
|
|
|
|
|
|
| def is_supported_sentence(sentence: str, support_source_text: str, model_threshold: float = support_threshold) -> (bool, float):
|
| try:
|
| emb_model = get_embed_model()
|
| sent_emb = emb_model.encode([sentence], convert_to_numpy=True, normalize_embeddings=True)[0]
|
|
|
|
|
| candidates = [support_source_text]
|
| cand_embs = emb_model.encode(candidates, convert_to_numpy=True, normalize_embeddings=True)
|
| sims = (cand_embs @ sent_emb).tolist()
|
| max_sim = max(sims) if sims else 0.0
|
| max_sim_scaled = round((float(max_sim) + 1) / 2, 3)
|
| return (max_sim_scaled >= model_threshold, max_sim_scaled)
|
| except Exception as e:
|
| return (False, 0.0)
|
|
|
|
|
| if "not stated" not in final_answer.lower() and len(final_answer) > 10:
|
| sentence_candidates = [s.strip() for s in re.split(r'(?<=[.!?])\s+', final_answer) if s.strip()]
|
| for s in sentence_candidates:
|
| supported, score = is_supported_sentence(s, combined_context, model_threshold=support_threshold)
|
| if not supported:
|
|
|
|
|
| pass
|
|
|
|
|
| cosine_score = None
|
| try:
|
| emb_model = get_embed_model()
|
| text_emb = emb_model.encode([safe_context], convert_to_numpy=True, normalize_embeddings=True)[0]
|
| summary_emb = emb_model.encode([final_answer], convert_to_numpy=True, normalize_embeddings=True)[0]
|
| cosine_sim = float(np.dot(text_emb, summary_emb))
|
| cosine_score = round((cosine_sim + 1) / 2, 3)
|
| except Exception:
|
| cosine_score = 0.0
|
|
|
| rouge2_score = None
|
| rougeL_score = None
|
| try:
|
| scorer = rouge_scorer.RougeScorer(['rouge2', 'rougeL'], use_stemmer=True)
|
| scores = scorer.score(safe_context, final_answer)
|
| rouge2_score = round(scores['rouge2'].fmeasure, 3)
|
| rougeL_score = round(scores['rougeL'].fmeasure, 3)
|
| except Exception:
|
| pass
|
|
|
|
|
| def rating_label(score, high_thr, med_thr):
|
| if score is None: return ("-", "-", "-")
|
| if score >= high_thr: return (f"{score:.3f}", "-", "-")
|
| elif score >= med_thr: return ("-", f"{score:.3f}", "-")
|
| else: return ("-", "-", f"{score:.3f}")
|
|
|
| cosine_high, cosine_med = 0.85, 0.70
|
| rouge_high, rouge_med = 0.6, 0.4
|
| cos_high, cos_med, cos_low = rating_label(cosine_score, cosine_high, cosine_med)
|
| r2_high, r2_med, r2_low = rating_label(rouge2_score, rouge_high, rouge_med)
|
| rL_high, rL_med, rL_low = rating_label(rougeL_score, rouge_high, rouge_med)
|
|
|
| with st.expander("📊 Evaluation Metrics Summary", expanded=False):
|
| small_table_html = f"""
|
| <style>
|
| .small-table {{ border-collapse: collapse; width: 60%; font-size: 13px; margin-top: 6px; }}
|
| .small-table th, .small-table td {{ border: 1px solid #ddd; padding: 6px 8px; text-align: center; }}
|
| .small-table th {{ background-color: #f9f9f9; font-weight: bold; }}
|
| .small-table td {{ font-family: monospace; }}
|
| .green {{ color: green; font-weight: bold; }}
|
| .orange {{ color: orange; font-weight: bold; }}
|
| .red {{ color: red; font-weight: bold; }}
|
| </style>
|
| <table class='small-table'>
|
| <tr><th>Metric</th><th>Description</th><th>High (🟢)</th><th>Medium (🟠)</th><th>Low (🔴)</th></tr>
|
| <tr><td><b>Cosine Similarity</b></td><td>Context Similarity</td><td class='green'>{cos_high}</td><td class='orange'>{cos_med}</td><td class='red'>{cos_low}</td></tr>
|
| <tr><td><b>ROUGE-2</b></td><td>Bigram Overlap with Context</td><td class='green'>{r2_high}</td><td class='orange'>{r2_med}</td><td class='red'>{r2_low}</td></tr>
|
| <tr><td><b>ROUGE-L</b></td><td>Longest Common Sequence Match</td><td class='green'>{rL_high}</td><td class='orange'>{rL_med}</td><td class='red'>{rL_low}</td></tr>
|
| </table>
|
| """
|
| st.markdown(small_table_html, unsafe_allow_html=True)
|
|
|
| total_time = time.perf_counter() - start_time
|
| st.success(f"✓ Answer generated in **{total_time:.2f} seconds**.")
|
|
|
| return final_answer, cosine_score, rouge2_score, rougeL_score
|
|
|
|
|
|
|
|
|
| def add_document_to_topic(topic_name: str, pdf_path: str, pdf_filename: str, full_text: str, table_text: str,
|
| chunk_strategy: str = "recursive", chunk_size: int = 1000, overlap: int = 200):
|
| st.write("🔹 Starting document ingestion...")
|
| sub_topic = derive_subtopic_from_filename(pdf_filename)
|
| st.info(f"Detected sub-topic: **{sub_topic}**")
|
| col = get_or_create_collection(topic_name)
|
| combined_text = full_text
|
| if table_text.strip():
|
| combined_text += "\n\n[TABLES]\n\n" + table_text
|
| st.write(f"Length of combined text: {len(combined_text)} characters")
|
| chunks = chunk_text(combined_text, chunk_size=chunk_size, overlap=overlap, strategy=chunk_strategy)
|
| st.write(f"✓ Chunking complete — {len(chunks)} chunks created using strategy **{chunk_strategy}**.")
|
| if not chunks:
|
| return {"error": "No text chunks extracted."}
|
| st.write("🧬 Generating embeddings...")
|
| embs = embed_texts(chunks)
|
| st.write(f"✓ Embeddings generated — shape: {embs.shape}")
|
| timestamp = int(time.time())
|
| ids = [f"{pdf_filename}__{i}_{timestamp}" for i in range(len(chunks))]
|
| metadatas = [
|
| {
|
| "pdf_filename": pdf_filename,
|
| "uploaded_at": datetime.now(timezone.utc).isoformat(),
|
| "chunk_index": i,
|
| "topic": topic_name,
|
| "sub_topic": sub_topic,
|
| "chunking_strategy": chunk_strategy,
|
| }
|
| for i in range(len(chunks))
|
| ]
|
| st.write("⎌ Adding to Chroma collection...")
|
| col.add(documents=chunks, embeddings=embs.tolist(), ids=ids, metadatas=metadatas)
|
| st.write("✓ Added to Chroma (PersistentClient).")
|
| backup_chroma()
|
| return {"count": len(chunks), "ids": ids, "collection": topic_name, "sub_topic": sub_topic, "chunking_strategy": chunk_strategy}
|
|
|
|
|
|
|
|
|
| def query_collection(topic: str, query: str, top_k=3, relevance_threshold=0.35, sub_topic: str = None):
|
| """
|
| Query a Chroma collection and optionally filter by sub_topic.
|
| If sub_topic is None or equals 'All' -> no where filter is applied.
|
| """
|
| col = get_or_create_collection(topic)
|
| q_embs = embed_texts([query])
|
| if q_embs.size == 0:
|
| return []
|
| q_emb = q_embs[0].tolist()
|
| where = None
|
| if sub_topic and sub_topic.lower() != "all":
|
| where = {"sub_topic": sub_topic}
|
| results = col.query(
|
| query_embeddings=[q_emb],
|
| n_results=top_k,
|
| include=["documents", "metadatas", "distances"],
|
| where=where
|
| )
|
| docs = results.get("documents", [[]])[0]
|
| metas = results.get("metadatas", [[]])[0]
|
| dists = results.get("distances", [[]])[0]
|
| ids = results.get("ids", [[]])[0]
|
| relevant = []
|
| for doc, meta, dist, id_ in zip(docs, metas, dists, ids):
|
| if dist <= relevance_threshold:
|
| meta_sub = meta.get("sub_topic") if isinstance(meta, dict) else None
|
| if not meta_sub:
|
| meta_sub = "general"
|
| if isinstance(meta, dict):
|
| meta["sub_topic"] = meta_sub
|
| relevant.append({"id": id_, "text": doc, "metadata": meta, "distance": dist})
|
| return sorted(relevant, key=lambda x: x["distance"])
|
|
|
|
|
|
|
|
|
| def tab_login_gate(tab_key: str, display_name: str) -> bool:
|
| """
|
| Simple login gate. Returns True if authenticated for this tab_key, otherwise shows UI and returns False.
|
| tab_key: short string used for session key, e.g. "upload" or "manage"
|
| Also stores user role in st.session_state[f"role_{tab_key}"] ('admin' or 'viewer').
|
| """
|
| sess_key = f"auth_{tab_key}"
|
| role_key = f"role_{tab_key}"
|
|
|
| if st.session_state.get(sess_key, False):
|
| col1, col2 = st.columns([1, 5])
|
| role = st.session_state.get(role_key, "admin")
|
| with col1:
|
| if st.button(f"⏻ Logout {display_name}", key=f"logout_{tab_key}"):
|
| st.session_state[sess_key] = False
|
| st.session_state.pop(role_key, None)
|
| st.toast("Logged out.")
|
| st.rerun()
|
| with col2:
|
| st.markdown(f"**Authenticated for {display_name}** ({role})")
|
| return True
|
|
|
| st.info(f"🔐 {display_name} requires login.")
|
| user = st.text_input(f"{display_name} - UserID:", key=f"user_{tab_key}")
|
| pwd = st.text_input(f"{display_name} - Password:", key=f"pass_{tab_key}", type="password")
|
| if st.button(f"⇥ Login to {display_name}", key=f"login_{tab_key}"):
|
|
|
| if (user == ADMIN_USER) and (pwd == ADMIN_PASS):
|
| st.session_state[sess_key] = True
|
| st.session_state[role_key] = "admin"
|
| st.success("Login successful (Admin).")
|
| st.rerun()
|
|
|
| elif (user == VIEWER_USER) and (pwd == VIEWER_PASS):
|
| st.session_state[sess_key] = True
|
| st.session_state[role_key] = "viewer"
|
| st.success("Login successful (Viewer).")
|
| st.rerun()
|
| else:
|
| st.error("Invalid credentials.")
|
| return False
|
|
|
|
|
|
|
|
|
|
|
| if "active_tab" not in st.session_state:
|
| st.session_state.active_tab = "Ask Questions"
|
|
|
|
|
|
|
| SIDEBAR_OPTIONS = ["Ask Questions", "Upload PDFs", "DocIQ-Admin", "User-Guide", "DocIQ Overview", "Knowledge Catalogue"]
|
| if st.session_state.active_tab == "Manage Topics":
|
| st.session_state.active_tab = "DocIQ-Admin"
|
|
|
|
|
| if st.session_state.active_tab not in SIDEBAR_OPTIONS:
|
| st.session_state.active_tab = "Ask Questions"
|
|
|
| selected_tab = st.sidebar.radio(
|
| "Select Section:",
|
| SIDEBAR_OPTIONS,
|
| index=SIDEBAR_OPTIONS.index(st.session_state.active_tab),
|
| key="tab_selector")
|
|
|
| st.session_state.active_tab = selected_tab
|
|
|
|
|
|
|
|
|
| if selected_tab == "DocIQ Overview":
|
| st.session_state["active_tab"] = "DocIQ Overview"
|
| st.header("DocIQ Overview")
|
|
|
|
|
| current_dir = os.path.dirname(os.path.abspath(__file__))
|
| file_path = os.path.join(current_dir, "docIQ_overview.md")
|
|
|
| try:
|
| if os.path.exists(file_path):
|
| with open(file_path, "r", encoding="utf-8") as f:
|
| md_content = f.read()
|
| st.markdown(md_content, unsafe_allow_html=True)
|
| else:
|
| st.warning(f"File 'docIQ_overview.md' not found at expected path: {file_path}")
|
| except Exception as e:
|
| st.error(f"Error reading 'docIQ_overview.md': {e}")
|
|
|
|
|
|
|
|
|
| if selected_tab == "User-Guide":
|
| st.session_state["active_tab"] = "User-Guide"
|
| st.header("User Guide")
|
|
|
|
|
| current_dir = os.path.dirname(os.path.abspath(__file__))
|
| file_path = os.path.join(current_dir, "docIQ_userguide.md")
|
|
|
| try:
|
| if os.path.exists(file_path):
|
| with open(file_path, "r", encoding="utf-8") as f:
|
| md_content = f.read()
|
| st.markdown(md_content, unsafe_allow_html=True)
|
| else:
|
| st.warning(f"File 'docIQ_userguide.md' not found at expected path: {file_path}")
|
| except Exception as e:
|
| st.error(f"Error reading 'docIQ_userguide.md': {e}")
|
|
|
|
|
|
|
|
|
| if selected_tab == "Knowledge Catalogue":
|
| st.session_state["active_tab"] = "Knowledge Catalogue"
|
| st.header("Knowledge Catalogue")
|
|
|
|
|
| current_dir = os.path.dirname(os.path.abspath(__file__))
|
| file_path = os.path.join(current_dir, "knowledge_catalog.md")
|
|
|
| try:
|
| if os.path.exists(file_path):
|
| with open(file_path, "r", encoding="utf-8") as f:
|
| md_content = f.read()
|
| st.markdown(md_content, unsafe_allow_html=True)
|
| else:
|
| st.warning(f"File 'knowledge_catalog.md' not found at expected path: {file_path}")
|
| except Exception as e:
|
| st.error(f"Error reading 'knowledge_catalog.md': {e}")
|
|
|
|
|
|
|
|
|
| if selected_tab == "Upload PDFs":
|
| st.session_state["active_tab"] = "Upload PDFs"
|
|
|
| if not tab_login_gate("upload", "Upload PDFs"):
|
| st.stop()
|
|
|
|
|
| current_role = st.session_state.get("role_upload", "admin")
|
| is_viewer = (current_role == "viewer")
|
|
|
| st.header("Upload a PDF with text, tables, or charts/images")
|
| if is_viewer:
|
| st.info("ℹ You are logged in as Viewer. Uploads are disabled.")
|
|
|
| try:
|
| existing_collections = [c.name for c in chroma_client.list_collections()]
|
| except Exception:
|
| existing_collections = []
|
|
|
| topic_mode = st.radio("Select how you want to assign a topic:", ["Select existing topic", "Create new topic"])
|
| topic = None
|
| if topic_mode == "Select existing topic":
|
| if existing_collections:
|
| topic = st.selectbox("Choose an existing topic:", existing_collections)
|
| else:
|
| st.info("No existing topics found. Please create a new one.")
|
| else:
|
| topic = st.text_input("Enter a new topic name:", value="")
|
|
|
| uploaded_file = st.file_uploader("Choose a PDF file", type=["pdf"])
|
| use_ocr = st.checkbox("Use OCR for images/charts if text not found", value=True)
|
|
|
|
|
| st.markdown("**Choose chunking strategy to use for this document**")
|
|
|
| chunk_option = st.radio(
|
| "Chunking strategy:",
|
| ["Adaptive Chunking (NLTK)", "Recursive Sentence Chunking (Regex)", "Sliding Window Chunking"],
|
| index=0,
|
| help="Adaptive = NLTK-based intelligent sentence grouping; Recursive = Regex-based; Sliding-Window = fixed size."
|
| )
|
|
|
|
|
| chunk_controls = st.container()
|
|
|
| with chunk_controls:
|
| if "Adaptive" in chunk_option:
|
|
|
| st.markdown("#### Adaptive Chunking (NLTK) Settings")
|
| user_chunk_size = st.number_input(
|
| "Target Chunk Size (characters)",
|
| min_value=300, max_value=8000, value=1200, step=100,
|
| help="Target size. The chunker groups sentences until this limit is reached."
|
| )
|
| user_overlap = st.number_input(
|
| "Overlap (characters)",
|
| min_value=0, max_value=2000, value=200, step=50,
|
| help="Character count of sentences carried over from the end of the previous chunk."
|
| )
|
| elif "Recursive" in chunk_option:
|
| st.markdown("#### Recursive Sentence Chunking Settings")
|
| user_chunk_size = st.number_input(
|
| "Chunk Size (characters)",
|
| min_value=300, max_value=8000, value=1500, step=100,
|
| help="Upper size limit of each chunk. The recursive chunker splits on sentence boundaries and merges until this limit."
|
| )
|
| user_overlap = st.number_input(
|
| "Overlap (characters)",
|
| min_value=0, max_value=2000, value=200, step=50,
|
| help="How many characters from the end of the previous chunk are carried forward into the next chunk for context continuity."
|
| )
|
| else:
|
|
|
| st.markdown("#### Sliding Window Chunking Settings")
|
| user_chunk_size = st.number_input(
|
| "Chunk Size (characters)",
|
| min_value=300, max_value=8000, value=1000, step=100,
|
| help="Fixed window size used for chunking. This was the original chunking method in your app."
|
| )
|
| user_overlap = st.number_input(
|
| "Overlap (characters)",
|
| min_value=0, max_value=2000, value=200, step=50,
|
| help="Characters included from the previous window to maintain continuity."
|
| )
|
|
|
|
|
| if "Adaptive" in chunk_option:
|
| chunk_strategy_internal = "adaptive"
|
| elif "Recursive" in chunk_option:
|
| chunk_strategy_internal = "recursive"
|
| else:
|
| chunk_strategy_internal = "fixed"
|
|
|
|
|
| if st.button("Upload and Process", disabled=is_viewer):
|
| if not uploaded_file or not topic.strip():
|
| st.warning("Please upload a PDF and select or create a topic name.")
|
| else:
|
| pdf_bytes = uploaded_file.read()
|
| pdf_filename = uploaded_file.name
|
| pdf_path = os.path.join(UPLOADS_DIR, f"{int(time.time())}_{pdf_filename}")
|
| with open(pdf_path, "wb") as f:
|
| f.write(pdf_bytes)
|
|
|
| with st.spinner("Extracting text from PDF..."):
|
| text = extract_text_from_pdf_bytes(pdf_bytes)
|
| if not text.strip() and use_ocr:
|
| st.info("No text found in PDF, trying OCR...")
|
| text = extract_text_with_ocr(pdf_bytes)
|
|
|
| with st.spinner("Extracting tables..."):
|
| try:
|
| table_text = extract_tables_as_text(pdf_path)
|
| except Exception as e:
|
| table_text = ""
|
| st.warning(f"Table extraction failed: {e}")
|
|
|
| if not text.strip() and not table_text.strip():
|
| st.error("❌ No text or tables found in PDF.")
|
| else:
|
| with st.spinner("Chunking, embedding, and storing..."):
|
| result = add_document_to_topic(topic.strip(), pdf_path, pdf_filename, text, table_text,
|
| chunk_strategy=chunk_strategy_internal,
|
| chunk_size=int(user_chunk_size), overlap=int(user_overlap))
|
|
|
| st.subheader("Upload Result")
|
| st.json(result)
|
|
|
| if "error" not in result:
|
| st.success(f"✓ Added {result['count']} chunks to collection '{topic.strip()}'. (strategy: {result.get('chunking_strategy')})")
|
| col = get_or_create_collection(topic.strip())
|
| try:
|
| col_size = col.count()
|
| except Exception:
|
| col_size = "unknown"
|
| st.write(f"Collection total chunks: {col_size}")
|
|
|
| try:
|
| data = col.get(include=["documents", "metadatas"])
|
| docs = data.get("documents", [])
|
| metas = data.get("metadatas", [])
|
| ids = data.get("ids", [])
|
| st.subheader("Sample Chunks (First 3)")
|
| for i in range(min(3, len(docs))):
|
| st.markdown(f"**ID:** `{ids[i]}`")
|
| st.write("Metadata:", metas[i])
|
| st.write("Excerpt:", docs[i][:500])
|
| st.markdown("---")
|
| except Exception as e:
|
| st.warning(f"Could not fetch sample chunks: {e}")
|
|
|
|
|
|
|
|
|
| if selected_tab == "Ask Questions":
|
| st.session_state["active_tab"] = "Ask Questions"
|
| st.markdown('<h1 class="header1">Query your knowledge base</h1>', unsafe_allow_html=True)
|
|
|
| MAX_INACTIVE_MINUTES = 15
|
| now_ts = time.time()
|
| last_active = st.session_state.get("last_active_time", now_ts)
|
| if now_ts - last_active > MAX_INACTIVE_MINUTES * 60:
|
| st.session_state.clear()
|
| st.info(f"🕒 Session cleared due to **{MAX_INACTIVE_MINUTES}** minutes of inactivity.")
|
| st.session_state["last_active_time"] = now_ts
|
|
|
| try:
|
| available_topics = [c.name for c in chroma_client.list_collections()]
|
| except Exception:
|
| available_topics = []
|
|
|
| if not available_topics:
|
| st.warning("No topics found. Please upload a document first.")
|
| else:
|
| topic_q = st.selectbox("Select topic to query:", available_topics, key="query_topic")
|
| subtopic_options = ["All"]
|
| try:
|
| col_for_topic = get_or_create_collection(topic_q)
|
| try:
|
| data_meta = col_for_topic.get(include=["metadatas"])
|
| metas_all = data_meta.get("metadatas", [])
|
| sth = set()
|
| for m in metas_all:
|
| if isinstance(m, dict):
|
| stv = m.get("sub_topic") or "general"
|
| sth.add(stv)
|
| subtopic_options = ["All"] + sorted([s for s in sth if s and s.lower() != "all"])
|
| except Exception:
|
| subtopic_options = ["All"]
|
| except Exception:
|
| subtopic_options = ["All"]
|
|
|
| selected_subtopic = st.selectbox("Select sub-topic (optional):", subtopic_options, index=0, key="query_subtopic")
|
|
|
| query_text = st.text_input("Enter your question:", key="query_text", value=st.session_state.get("query_text", ""), placeholder="Type your new question here...")
|
|
|
| relevance_threshold = st.slider(
|
| "Set relevance threshold (lower = more strict, higher = more inclusive)",
|
| min_value=0.1, max_value=1.0, value=0.6, step=0.05
|
| )
|
| top_k = st.slider("Number of top results to retrieve (top_k)", min_value=1, max_value=20, value=5, step=1)
|
|
|
| fuzzy_threshold = st.slider(
|
| "Fuzzy match threshold for cached questions (0.0–1.0). Higher = stricter exactness",
|
| min_value=0.5, max_value=0.95, value=0.78, step=0.01
|
| )
|
|
|
| if st.button("Search"):
|
| if not query_text.strip():
|
| st.warning("Please enter a question.")
|
| else:
|
| try:
|
| topic_norm = topic_q.strip()
|
| cache_start = time.time()
|
| best = get_best_fuzzy_match(topic_norm, query_text.strip(), threshold=fuzzy_threshold)
|
| cache_elapsed = time.time() - cache_start
|
|
|
| if best:
|
|
|
|
|
| rid, stored_q, stored_a, created_at, fb, sub_topic, cos_m, r2_m, rL_m, score, source_docs, rel_th, k = best
|
| st.session_state["last_query"] = {
|
| "topic": topic_norm,
|
| "sub_topic": sub_topic or "all",
|
| "question": query_text.strip(),
|
| "cached": True,
|
| "entry_id": rid,
|
| "answer": stored_a,
|
| "feedback_status": fb,
|
| "meta": {
|
| "score": score,
|
| "created_at": created_at,
|
| "cache_time": cache_elapsed,
|
| "cosine_score": cos_m,
|
| "rouge2_score": r2_m,
|
| "rougeL_score": rL_m
|
| },
|
| "source_docs": source_docs or []
|
| }
|
| else:
|
| vec_start = time.time()
|
|
|
| subtopic_filter = selected_subtopic if selected_subtopic and selected_subtopic != "All" else None
|
| docs = query_collection(topic_norm, query_text.strip(), top_k=top_k, relevance_threshold=relevance_threshold, sub_topic=subtopic_filter)
|
| vec_elapsed = time.time() - vec_start
|
|
|
| if not docs:
|
| st.warning("No relevant results found. Try using a higher threshold (more inclusive) or choose 'All' sub-topics.")
|
| else:
|
| st.subheader("Top Results")
|
|
|
| rows_for_df = []
|
| for d in docs:
|
| meta = d.get("metadata") or {}
|
| sub_t = meta.get("sub_topic") if isinstance(meta, dict) else "general"
|
| rows_for_df.append([d["id"], sub_t, str(meta), d["text"][:500].replace("\n", " "), f"{d['distance']:.4f}"])
|
| df = pd.DataFrame(rows_for_df, columns=["ID", "sub_topic", "Metadata", "Excerpt", "Distance"])
|
| st.dataframe(df, use_container_width=True)
|
|
|
| st.subheader("Generative Answer")
|
|
|
|
|
| combined_text = "\n\n".join([d["text"] for d in docs])
|
|
|
| sum_start = time.perf_counter()
|
| with st.spinner("Generating answer from context..."):
|
| summary, cosine_score, rouge2_score, rougeL_score = generate_answer_from_context(combined_text, question=query_text.strip(), top_k=top_k)
|
| sum_elapsed = time.perf_counter() - sum_start
|
|
|
|
|
|
|
| source_docs = []
|
| for d in docs:
|
| try:
|
| raw_id = d.get("id", "")
|
| if "__" in raw_id:
|
| pdfname = raw_id.split("__", 1)[0]
|
| source_docs.append(pdfname)
|
| except Exception:
|
| pass
|
| source_docs = sorted(list(dict.fromkeys([s for s in source_docs if s])))
|
|
|
| cache_subtopic_value = subtopic_filter if subtopic_filter else "all"
|
| inserted_id = insert_cache(topic_norm, query_text.strip(), summary, feedback_status=None,
|
| sub_topic=cache_subtopic_value,
|
| cosine_score=cosine_score,
|
| rouge2_score=rouge2_score,
|
| rougeL_score=rougeL_score,
|
| source_docs=source_docs,
|
| relevance_threshold=relevance_threshold,
|
| top_k=top_k)
|
|
|
| st.session_state["last_query"] = {
|
| "topic": topic_norm,
|
| "sub_topic": cache_subtopic_value,
|
| "question": query_text.strip(),
|
| "cached": False,
|
| "entry_id": inserted_id,
|
| "answer": summary,
|
| "feedback_status": None,
|
| "meta": {
|
| "vec_time": vec_elapsed,
|
| "sum_time": sum_elapsed,
|
| "cosine_score": cosine_score,
|
| "rouge2_score": rouge2_score,
|
| "rougeL_score": rougeL_score
|
| },
|
|
|
| "source_docs": source_docs
|
| }
|
|
|
| st.session_state["question_count"] = st.session_state.get("question_count", 0) + 1
|
|
|
| except Exception as e:
|
| st.error(f"Query failed: {e}")
|
|
|
|
|
| if "last_query" in st.session_state:
|
| last = st.session_state["last_query"]
|
| rid = last["entry_id"]
|
|
|
| st.subheader(f"Answer for: {last['question']} — *(Topic: {last['topic']}, Sub-topic: {last.get('sub_topic','all')})*")
|
| st.write(last["answer"])
|
|
|
|
|
| meta_metrics = last.get("meta", {})
|
| if any(k in meta_metrics for k in ["cosine_score", "rouge2_score", "rougeL_score"]):
|
| metrics_html = f"""
|
| <div style="display:flex; gap:12px; align-items:center;">
|
| <div><b>Cosine:</b> {meta_metrics.get('cosine_score', '-')}</div>
|
| <div><b>ROUGE-2:</b> {meta_metrics.get('rouge2_score', '-')}</div>
|
| <div><b>ROUGE-L:</b> {meta_metrics.get('rougeL_score', '-')}</div>
|
| </div>
|
| """
|
| st.markdown(metrics_html, unsafe_allow_html=True)
|
|
|
| if last["cached"]:
|
| st.caption(f"⚡ Retrieved from cache (score={last['meta']['score']:.3f}, cached at {last['meta']['created_at']})")
|
| else:
|
| st.caption(f"Vector fetch took: **{last['meta']['vec_time']:.2f}s**, Generation took: **{last['meta']['sum_time']:.2f}s**")
|
|
|
|
|
| srcs = last.get("source_docs") or []
|
| if srcs:
|
|
|
| st.info(
|
| f"This answer was generated from document(s): {json.dumps(srcs)}\n\n"
|
| "⚠ *Note: This answer is generated by an AI model based on the retrieved content. "
|
| "Always check original document(s).*"
|
| )
|
|
|
| def show_feedback_controls_and_apply(entry_id: int):
|
| col_yes, col_no = st.columns(2)
|
| def apply_feedback(status):
|
| try:
|
| with sqlite3.connect(CACHE_DB_PATH) as conn:
|
| conn.execute("UPDATE qa_cache SET feedback_status = ? WHERE id = ?", (status, entry_id))
|
| conn.commit()
|
| backup_sqlite()
|
| st.session_state["last_query"]["feedback_status"] = status
|
| st.toast(f"✓ Feedback updated → {status}")
|
| except Exception as e:
|
| st.error(f"Update failed: {e}")
|
| with col_yes:
|
| if st.button("👍 Helpful (mark Y)", key=f"fb_yes_{entry_id}"):
|
| apply_feedback("Y")
|
| with col_no:
|
| if st.button("👎 Not helpful (mark N)", key=f"fb_no_{entry_id}"):
|
| apply_feedback("N")
|
| with sqlite3.connect(CACHE_DB_PATH) as conn:
|
| cur = conn.execute("SELECT feedback_status FROM qa_cache WHERE id = ?", (entry_id,))
|
| val = cur.fetchone()
|
| fb_now = val[0] if val and val[0] else "Pending"
|
| st.caption(f"Current feedback status: **{fb_now}**")
|
|
|
| show_feedback_controls_and_apply(rid)
|
|
|
| def reset_query():
|
| for key in ["last_query", "query_text", "final_summary",
|
| "rouge2_score", "rougeL_score", "cosine_score"]:
|
| st.session_state.pop(key, None)
|
| st.session_state["query_text"] = ""
|
| st.session_state["active_tab"] = "Ask Questions"
|
| st.toast("🧹 Cleared previous question. Ready for a new query!")
|
| st.session_state["should_rerun"] = True
|
|
|
| if st.session_state.get("question_count", 0) >= 1:
|
| st.markdown("---")
|
| st.button("✧ Ask another question", on_click=reset_query)
|
|
|
|
|
|
|
|
|
| if selected_tab == "DocIQ-Admin":
|
| st.session_state["active_tab"] = "DocIQ-Admin"
|
|
|
| if not tab_login_gate("admin", "DocIQ-Admin"):
|
| st.stop()
|
|
|
|
|
| current_role = st.session_state.get("role_admin", "admin")
|
| is_viewer = (current_role == "viewer")
|
|
|
| st.header("⚿ DocIQ-Admin Console")
|
|
|
|
|
| ADMIN_SUB_MENU_OPTIONS = ["Chunking Strategies Overview", "Manage Topics", "Manage Cached DB"]
|
| admin_sub_menu = st.radio(
|
| "Admin Task:",
|
| ADMIN_SUB_MENU_OPTIONS,
|
| key="admin_sub_menu_radio",
|
| horizontal=True
|
| )
|
|
|
|
|
| if admin_sub_menu == "Chunking Strategies Overview":
|
|
|
| st.subheader("⌗ Chunking Strategies")
|
|
|
| st.markdown("View which chunking strategy was used for each uploaded document and how many vector records exist per document.")
|
|
|
| if st.button("View Chunking Strategies"):
|
| try:
|
| all_collections = [c.name for c in chroma_client.list_collections()]
|
| rows = []
|
| for col_name in all_collections:
|
| col = get_or_create_collection(col_name)
|
| data = col.get(include=["metadatas"])
|
| metas = data.get("metadatas", [])
|
|
|
| for meta in metas:
|
| if not isinstance(meta, dict):
|
| continue
|
| pdf = meta.get("pdf_filename", "unknown")
|
| subtopic = meta.get("sub_topic", "unknown")
|
| strategy = meta.get("chunking_strategy", "unknown")
|
| rows.append((pdf, col_name, subtopic, strategy))
|
| if not rows:
|
| st.warning("No chunking metadata found (No collections available in Chroma DB).")
|
| else:
|
| df = pd.DataFrame(rows, columns=["Document", "Topic", "Subtopic", "Strategy"])
|
| grouped = df.groupby(["Document", "Topic", "Subtopic", "Strategy"]).size().reset_index(name="Total Records")
|
| grouped = grouped.sort_values(["Document", "Topic", "Subtopic"])
|
| st.dataframe(grouped, use_container_width=True)
|
| except Exception as e:
|
| st.error(f"Failed to build chunking strategies overview: {e}")
|
|
|
| st.markdown("---")
|
| st.info("Select 'Manage Topics' to delete vector collections or 'Manage Cached DB' to clear the Q&A log.")
|
|
|
|
|
|
|
| elif admin_sub_menu == "Manage Topics":
|
|
|
| try:
|
| all_topics = [c.name for c in chroma_client.list_collections()]
|
| except Exception:
|
| all_topics = []
|
|
|
| if "delete_entire_topic" not in st.session_state:
|
| st.session_state.delete_entire_topic = None
|
| if "total_before" not in st.session_state:
|
| st.session_state.total_before = None
|
| if "delete_sub_topics" not in st.session_state:
|
| st.session_state.delete_sub_topics = []
|
| if "total_before_sub" not in st.session_state:
|
| st.session_state.total_before_sub = 0
|
| if "confirm_delete" not in st.session_state:
|
| st.session_state.confirm_delete = False
|
| if "confirm_delete_subs" not in st.session_state:
|
| st.session_state.confirm_delete_subs = False
|
|
|
| if not all_topics:
|
| st.warning("No topics available in Chroma DB to manage.")
|
| else:
|
| st.subheader("🗑 Manage topics and sub-topics")
|
| topic_to_manage = st.selectbox("🗑 Select a topic to delete:", all_topics, key="manage_topic_select")
|
| try:
|
| col = get_or_create_collection(topic_to_manage)
|
| metas_all = col.get(include=["metadatas"]).get("metadatas", [])
|
| sublist = []
|
| for m in metas_all:
|
| if isinstance(m, dict):
|
| sublist.append(m.get("sub_topic", "general"))
|
| sublist_unique = sorted(set([s or "general" for s in sublist]))
|
| except Exception:
|
| sublist_unique = []
|
| st.write("Sub-topics under this topic:", ", ".join(sublist_unique) if sublist_unique else "— none found —")
|
| delete_option = st.radio("Delete options:", ["Delete entire topic", "Delete select sub-topic(s)"], key="delete_option_radio")
|
|
|
|
|
|
|
|
|
|
|
| if delete_option == "Delete entire topic":
|
| st.write("This will delete the entire Chroma collection and all its records.")
|
|
|
| if st.button("Prepare to delete entire topic", disabled=is_viewer):
|
| try:
|
| before_count = col.count()
|
| except Exception:
|
| before_count = "unknown"
|
| st.session_state.delete_entire_topic = topic_to_manage
|
| st.session_state.total_before = before_count
|
| st.session_state.confirm_delete = True
|
| st.session_state.confirm_delete_subs = False
|
|
|
| if st.session_state.get("confirm_delete", False) and st.session_state.delete_entire_topic == topic_to_manage:
|
| st.warning(f"About to delete topic **{st.session_state.delete_entire_topic}** with **{st.session_state.total_before}** records.")
|
| c1, c2 = st.columns(2)
|
| with c1:
|
| if st.button("✓ Confirm Delete Entire Topic"):
|
| with st.spinner(f"Deleting topic '{topic_to_manage}'..."):
|
| try:
|
| chroma_client.delete_collection(name=topic_to_manage)
|
| backup_chroma()
|
| time.sleep(0.5)
|
| except Exception as e:
|
| st.error(f"Deletion failed: {e}")
|
| st.stop()
|
| try:
|
| remaining_topics = [c.name for c in chroma_client.list_collections()]
|
| after_exists = topic_to_manage in remaining_topics
|
| except Exception:
|
| after_exists = None
|
| audit_data = {
|
| "Topic Name": [topic_to_manage],
|
| "Records Before Delete": [st.session_state.total_before],
|
| "Records After Delete": [0 if not after_exists else "Still Exists"],
|
| "Deletion Timestamp": [datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")]
|
| }
|
| audit_df = pd.DataFrame(audit_data)
|
| st.subheader("🧾 Deletion Audit Report")
|
| st.dataframe(audit_df, use_container_width=True)
|
| if not after_exists:
|
| st.success(f"✓ Topic '{topic_to_manage}' successfully deleted.")
|
| else:
|
| st.error(f"⨯ Deletion failed — topic still exists.")
|
| st.session_state.confirm_delete = False
|
| st.session_state.delete_entire_topic = None
|
| st.session_state.total_before = None
|
|
|
| with c2:
|
| if st.button("✗ Cancel Delete Entire Topic"):
|
| st.session_state.confirm_delete = False
|
| st.session_state.delete_entire_topic = None
|
| st.session_state.total_before = None
|
| st.info("Deletion cancelled. Selections cleared.")
|
| else:
|
| st.write("🗑 Choose one or more sub-topic(s) to delete only their documents (keeps other sub-topics intact).")
|
| if sublist_unique:
|
| chosen_subs = st.multiselect("Select sub-topic(s) to delete:", sublist_unique, key="manage_select_subs")
|
| else:
|
| chosen_subs = []
|
|
|
| if st.button("Delete Selected sub-topic(s)", disabled=is_viewer):
|
| if not chosen_subs:
|
| st.warning("Please select at least one sub-topic to delete.")
|
| else:
|
| total_before_sub = 0
|
| for ss in chosen_subs:
|
| try:
|
| data = col.get(include=["metadatas"], where={"sub_topic": ss})
|
| ids_for = data.get("ids", [])
|
| total_before_sub += len(ids_for)
|
| except Exception:
|
| pass
|
| st.session_state.delete_sub_topics = chosen_subs
|
| st.session_state.total_before_sub = total_before_sub
|
| st.session_state.confirm_delete_subs = True
|
| st.session_state.confirm_delete = False
|
|
|
| if st.session_state.get("confirm_delete_subs", False) and st.session_state.delete_sub_topics:
|
| st.warning(f"About to delete sub-topic(s) {', '.join(st.session_state.delete_sub_topics)} containing {st.session_state.total_before_sub} records.")
|
| c1, c2 = st.columns(2)
|
| with c1:
|
| if st.button("✓ Confirm Delete Selected sub-topic(s)"):
|
| deleted_counts = {}
|
| for ss in list(st.session_state.delete_sub_topics):
|
| try:
|
| data_before = col.get(include=["metadatas"], where={"sub_topic": ss})
|
| ids_before = data_before.get("ids", [])
|
| before_ct = len(ids_before)
|
| try:
|
| col.delete(where={"sub_topic": ss})
|
| backup_chroma()
|
| except Exception:
|
| if ids_before:
|
| col.delete(ids=ids_before)
|
| deleted_counts[ss] = before_ct
|
| except Exception as e:
|
| deleted_counts[ss] = f"error: {e}"
|
| audit_data = {
|
| "Sub-topic": list(deleted_counts.keys()),
|
| "Records Deleted": list(deleted_counts.values()),
|
| "Deletion Timestamp": [datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")] * len(deleted_counts)
|
| }
|
| audit_df = pd.DataFrame(audit_data)
|
| st.subheader("🧾 Sub-topic Deletion Audit Report")
|
| st.dataframe(audit_df, use_container_width=True)
|
| st.success("✓ Selected sub-topic(s) deleted. See audit for details.")
|
| st.session_state.delete_sub_topics = []
|
| st.session_state.total_before_sub = 0
|
| st.session_state.confirm_delete_subs = False
|
|
|
| with c2:
|
| if st.button("✗ Cancel Delete Selected sub-topic(s)"):
|
| st.session_state.delete_sub_topics = []
|
| st.session_state.total_before_sub = 0
|
| st.session_state.confirm_delete_subs = False
|
| st.info("Deletion cancelled. Selections cleared.")
|
|
|
|
|
|
|
| elif admin_sub_menu == "Manage Cached DB":
|
| st.subheader("View / Delete cached Q&A entries (cache DB)")
|
| try:
|
| topics_in_cache = _cache_conn.execute("SELECT DISTINCT topic FROM qa_cache ORDER BY topic").fetchall()
|
| topics_in_cache = [t[0] for t in topics_in_cache]
|
| except Exception:
|
| topics_in_cache = []
|
|
|
| if not topics_in_cache:
|
| st.warning("No cached topics in cache DB yet.")
|
| else:
|
| if "manage_topic_open" not in st.session_state:
|
| st.session_state.manage_topic_open = {}
|
| if "manage_filter_open" not in st.session_state:
|
| st.session_state.manage_filter_open = {}
|
|
|
| for t in topics_in_cache:
|
| cols = st.columns([6, 2, 4])
|
| cols[0].write(f"**{t}**")
|
| manage_key = f"manage_qna_{t}"
|
| filter_key = f"filter_qna_{t}"
|
| if cols[1].button("Manage Q&A", key=manage_key):
|
| st.session_state.manage_topic_open[t] = not st.session_state.manage_topic_open.get(t, False)
|
| if cols[2].button("Filter & Sort cached Q&A by metric values", key=filter_key):
|
| st.session_state.manage_filter_open[t] = not st.session_state.manage_filter_open.get(t, False)
|
|
|
| if st.session_state.manage_topic_open.get(t, False):
|
| with st.expander(f"Manage Q&A — Full list for topic: {t}", expanded=True):
|
| rows = fetch_cache_by_topic(t, only_helpful=False)
|
| if not rows:
|
| st.info("No cached Q&A for this topic.")
|
| else:
|
| df_rows = []
|
| for row in rows:
|
|
|
| rid, q_text, a_text, created_at, fb, sub_topic, cos_m, r2_m, rL_m, source_docs_list, rel_th, k = row
|
| df_rows.append({
|
| "id": rid,
|
| "question": q_text,
|
| "answer": a_text,
|
| "created_at": created_at,
|
| "feedback_status": fb or "",
|
| "sub_topic": sub_topic or "all",
|
| "cosine_score": cos_m,
|
| "rouge2_score": r2_m,
|
| "rougeL_score": rL_m,
|
| "source_docs": json.dumps(source_docs_list),
|
| "relevance_threshold": rel_th,
|
| "top_k": k
|
| })
|
| df_cache = pd.DataFrame(df_rows)
|
| st.markdown("**Individual cached entries (full list)**")
|
| for _, r in df_cache.sort_values(by="created_at", ascending=False).iterrows():
|
| rid = int(r["id"])
|
| q_text = r["question"]
|
| a_text = r["answer"]
|
| created_at = r["created_at"]
|
| fb = r["feedback_status"]
|
| sub_topic = r["sub_topic"]
|
| cos_m = r["cosine_score"]
|
| r2_m = r["rouge2_score"]
|
| rL_m = r["rougeL_score"]
|
| srcs = r["source_docs"]
|
| rel_th = r["relevance_threshold"]
|
| k = r["top_k"]
|
| header = f"Q: {q_text} (id: {rid}, created: {created_at}, sub_topic: {sub_topic}, feedback: {fb})"
|
| with st.expander(header):
|
| st.write(a_text)
|
|
|
| st.markdown(f"**RAG Settings:** Relevance Threshold={rel_th}, top_k (Retrieval Count)={k}")
|
| st.markdown(f"**Metrics:** Cosine={cos_m}, ROUGE-2={r2_m}, ROUGE-L={rL_m}")
|
| st.markdown(f"**Source docs:** {srcs}")
|
| col1, col2, col3 = st.columns([1,1,4])
|
| with col1:
|
|
|
| if st.button(f"Delete id {rid}", key=f"del_full_{rid}", disabled=is_viewer):
|
| delete_cache_entry(rid)
|
| st.success(f"Deleted cache entry id {rid}. Please reopen topic view to refresh.")
|
| with col2:
|
|
|
| if st.button(f"Mark Helpful (Y) id {rid}", key=f"help_full_{rid}", disabled=is_viewer):
|
| update_feedback(rid, "Y")
|
| st.success(f"Marked id {rid} as Helpful (Y).")
|
| with col3:
|
|
|
| if st.button(f"Mark Not Helpful (N) id {rid}", key=f"nothelp_full_{rid}", disabled=is_viewer):
|
| update_feedback(rid, "N")
|
| st.success(f"Marked id {rid} as Not Helpful (N).")
|
|
|
| if st.session_state.manage_filter_open.get(t, False):
|
| with st.expander(f"Filter & Sort cached Q&A by metric values — Topic: {t}", expanded=True):
|
| rows = fetch_cache_by_topic(t, only_helpful=False)
|
| if not rows:
|
| st.info("No cached Q&A for this topic.")
|
| else:
|
| df_rows = []
|
| for row in rows:
|
| rid, q_text, a_text, created_at, fb, sub_topic, cos_m, r2_m, rL_m, source_docs_list, rel_th, k = row
|
| df_rows.append({
|
| "id": rid,
|
| "question": q_text,
|
| "answer": a_text,
|
| "created_at": created_at,
|
| "feedback_status": fb or "",
|
| "sub_topic": sub_topic or "all",
|
| "cosine_score": cos_m,
|
| "rouge2_score": r2_m,
|
| "rougeL_score": rL_m,
|
| "source_docs": json.dumps(source_docs_list),
|
| "relevance_threshold": rel_th,
|
| "top_k": k
|
| })
|
| df_cache = pd.DataFrame(df_rows)
|
| st.markdown("**Filter controls**")
|
| filter_cols = st.columns(4)
|
|
|
| enable_cos = filter_cols[0].checkbox("Filter Cosine", value=False, key=f"chk_cos_{t}")
|
| enable_r2 = filter_cols[1].checkbox("Filter ROUGE-2", value=False, key=f"chk_r2_{t}")
|
| enable_rL = filter_cols[2].checkbox("Filter ROUGE-L", value=False, key=f"chk_rL_{t}")
|
|
|
|
|
| with st.container():
|
| if enable_cos:
|
| cos_min, cos_max = st.slider("Cosine range", 0.0, 1.0, (0.0,1.0), key=f"cos_range_{t}")
|
| else:
|
| cos_min, cos_max = 0.0, 1.0
|
| if enable_r2:
|
| r2_min, r2_max = st.slider("ROUGE-2 range", 0.0, 1.0, (0.0,1.0), key=f"r2_range_{t}")
|
| else:
|
| r2_min, r2_max = 0.0, 1.0
|
| if enable_rL:
|
| rL_min, rL_max = st.slider("ROUGE-L range", 0.0, 1.0, (0.0,1.0), key=f"rL_range_{t}")
|
| else:
|
| rL_min, rL_max = 0.0, 1.0
|
|
|
| df_shown = df_cache[
|
| (df_cache["cosine_score"].fillna(0.0) >= cos_min) & (df_cache["cosine_score"].fillna(0.0) <= cos_max) &
|
| (df_cache["rouge2_score"].fillna(0.0) >= r2_min) & (df_cache["rouge2_score"].fillna(0.0) <= r2_max) &
|
| (df_cache["rougeL_score"].fillna(0.0) >= rL_min) & (df_cache["rougeL_score"].fillna(0.0) <= rL_max)
|
| ]
|
| if df_shown.empty:
|
| st.info("No cached records match the selected filter criteria.")
|
| else:
|
| df_display = df_shown.copy()
|
| df_display["answer_excerpt"] = df_display["answer"].str.slice(0, 500).str.replace("\n", " ")
|
| display_cols = ["id", "created_at", "sub_topic", "feedback_status", "cosine_score", "rouge2_score", "rougeL_score", "source_docs", "question", "answer_excerpt"]
|
| row_count = len(df_display)
|
| table_height = max(160, min(800, 40 * row_count))
|
| st.dataframe(df_display[display_cols].reset_index(drop=True), use_container_width=True, height=table_height)
|
| st.markdown("**Note:** This filtered panel is read-only (no delete or feedback buttons). Use 'Manage Q&A' to operate on individual entries.")
|
|
|
| st.markdown("---")
|
| col_clear1, col_clear2 = st.columns(2)
|
| with col_clear1:
|
| if st.button("↺ Clear summarization cache"):
|
| st.cache_data.clear()
|
| st.session_state.pop("cache_hits", None)
|
| st.success("✓ Cleared in-memory cache for chunk summaries.")
|
| with col_clear2:
|
|
|
| if st.button("↺ Clear cache DB (delete ALL Q&A)", disabled=is_viewer):
|
| clear_cache_db()
|
| st.success("✓ cache DB cleared (all Q&A removed).")
|
|
|
|
|
| if st.session_state.get("should_rerun", False):
|
| st.session_state["should_rerun"] = False
|
| st.rerun() |