Ouadou's picture
Update app.py
e221296 verified
Raw
History Blame Contribute Delete
13.9 kB
import hashlib
import io
import numpy as np
import pandas as pd
import plotly.express as px
import streamlit as st
from sklearn.cluster import KMeans
from sklearn.metrics.pairwise import cosine_similarity
# ----------------------------------------------------------------------------
# Page config
# ----------------------------------------------------------------------------
st.set_page_config(
page_title="Document Intelligence Explorer",
layout="wide",
)
st.title(
"Document Intelligence Explorer",
help=(
"Upload a document and this tool will split it into chunks, embed each "
"chunk with a sentence-transformer model, then show you two views of the "
"same data side by side: an unsupervised K-Means clustering of the chunks, "
"and a zero-shot classification of the chunks against labels you define. "
"Use this to compare how the document organizes itself naturally versus "
"how it maps onto categories you care about."
),
)
st.caption(
"Upload a document, configure the parameters in the sidebar, then click "
"Run Analysis to compare zero-shot classification against unsupervised "
"semantic clustering on the same embedding space."
)
# ----------------------------------------------------------------------------
# Cached resource loaders
# ----------------------------------------------------------------------------
@st.cache_resource(show_spinner="Loading embedding model...")
def load_embedding_model():
from sentence_transformers import SentenceTransformer
return SentenceTransformer("all-MiniLM-L6-v2")
# ----------------------------------------------------------------------------
# Text extraction
# ----------------------------------------------------------------------------
def extract_text(uploaded_file) -> str:
name = uploaded_file.name.lower()
raw_bytes = uploaded_file.getvalue()
if name.endswith(".txt"):
return raw_bytes.decode("utf-8", errors="ignore")
if name.endswith(".pdf"):
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(raw_bytes))
pages_text = []
for page in reader.pages:
try:
pages_text.append(page.extract_text() or "")
except Exception:
pages_text.append("")
return "\n".join(pages_text)
raise ValueError("Unsupported file type. Please upload a .txt or .pdf file.")
def chunk_text(text: str, chunk_size: int, overlap: int = 50) -> list[str]:
"""Word-based chunking with light overlap for context continuity."""
words = text.split()
if not words:
return []
chunks = []
step = max(chunk_size - overlap, 1)
for start in range(0, len(words), step):
chunk_words = words[start:start + chunk_size]
if chunk_words:
chunks.append(" ".join(chunk_words))
if start + chunk_size >= len(words):
break
return chunks
class _DummyUpload:
"""Lightweight wrapper so cached function can reuse extract_text()."""
def __init__(self, data: bytes, name: str):
self._data = data
self.name = name
def getvalue(self):
return self._data
# ----------------------------------------------------------------------------
# Cached pipeline functions
# ----------------------------------------------------------------------------
@st.cache_data(show_spinner="Generating embeddings...")
def compute_chunks_and_embeddings(file_bytes: bytes, file_name: str, chunk_size: int):
file_hash = hashlib.md5(file_bytes).hexdigest() # noqa: S324 - cache key only
_ = file_hash
text = extract_text(_DummyUpload(file_bytes, file_name))
chunks = chunk_text(text, chunk_size=chunk_size)
if not chunks:
return [], np.empty((0, 384))
model = load_embedding_model()
embeddings = model.encode(chunks, show_progress_bar=False, normalize_embeddings=True)
return chunks, np.array(embeddings)
@st.cache_data(show_spinner="Computing 2D projection (t-SNE)...")
def compute_2d_projection(embeddings: np.ndarray, seed: int = 42):
n_samples = embeddings.shape[0]
if n_samples < 2:
return np.zeros((n_samples, 2))
from sklearn.manifold import TSNE
perplexity = max(2, min(30, n_samples - 1))
reducer = TSNE(n_components=2, perplexity=perplexity, random_state=seed, init="pca")
return reducer.fit_transform(embeddings)
@st.cache_data(show_spinner="Running K-Means clustering...")
def compute_clusters(embeddings: np.ndarray, k: int, seed: int = 42):
k = min(k, max(1, embeddings.shape[0]))
model = KMeans(n_clusters=k, random_state=seed, n_init="auto")
labels = model.fit_predict(embeddings)
return labels
@st.cache_data(show_spinner="Classifying chunks against labels...")
def compute_classification(_embeddings_key: str, embeddings: np.ndarray, labels_tuple: tuple):
model = load_embedding_model()
label_embeddings = model.encode(list(labels_tuple), normalize_embeddings=True)
sims = cosine_similarity(embeddings, label_embeddings)
predicted_idx = sims.argmax(axis=1)
predicted_labels = [labels_tuple[i] for i in predicted_idx]
confidence = sims.max(axis=1)
return predicted_labels, confidence
# ----------------------------------------------------------------------------
# Session state init
# ----------------------------------------------------------------------------
if "result_df" not in st.session_state:
st.session_state.result_df = None
if "chunks" not in st.session_state:
st.session_state.chunks = None
if "has_labels" not in st.session_state:
st.session_state.has_labels = False
# ----------------------------------------------------------------------------
# Sidebar controls
# ----------------------------------------------------------------------------
with st.sidebar:
st.header("Configuration")
uploaded_file = st.file_uploader(
"Upload a document", type=["txt", "pdf"], accept_multiple_files=False
)
st.subheader(
"Chunking",
help=(
"The document is split into smaller pieces of text (chunks) before "
"embedding. Larger chunks give each piece more context but reduce the "
"number of data points available for clustering and classification."
),
)
chunk_size = st.slider(
"Chunk size (words)", min_value=50, max_value=500, value=150, step=10
)
st.subheader(
"Clustering",
help=(
"K-Means groups chunks into K clusters based on embedding similarity, "
"with no labels involved. Increasing K produces more, finer-grained "
"groups; decreasing it produces fewer, broader groups."
),
)
n_clusters = st.slider("Number of clusters (K)", min_value=2, max_value=15, value=4)
st.subheader("Classification labels")
labels_input = st.text_input(
"Comma-separated target labels",
value="",
placeholder="e.g. Technical, Billing, General, Feedback",
)
if st.session_state.result_df is not None:
st.divider()
st.subheader("Plot options")
has_labels_sidebar = st.session_state.has_labels
view_options = ["Unsupervised Clusters"]
if has_labels_sidebar:
view_options.insert(0, "Predicted Classification Labels")
view_mode = st.radio("Color points by:", view_options)
else:
view_mode = "Unsupervised Clusters"
st.divider()
run_clicked = st.button("Run Analysis", type="primary", use_container_width=True)
# ----------------------------------------------------------------------------
# Run pipeline only on button click
# ----------------------------------------------------------------------------
if run_clicked:
if uploaded_file is None:
st.warning("Please upload a document first.")
else:
file_bytes = uploaded_file.getvalue()
try:
chunks, embeddings = compute_chunks_and_embeddings(
file_bytes, uploaded_file.name, chunk_size
)
except Exception as e:
st.error(f"Failed to process file: {e}")
st.stop()
if len(chunks) == 0:
st.warning("No extractable text was found in this document.")
st.stop()
if len(chunks) < 3:
st.warning(
f"Only {len(chunks)} chunk(s) were generated. "
"Try a smaller chunk size or a longer document for meaningful "
"clustering and projection."
)
st.stop()
raw_labels = [lbl.strip() for lbl in labels_input.split(",") if lbl.strip()]
has_labels = len(raw_labels) >= 2
coords = compute_2d_projection(embeddings)
df = pd.DataFrame(coords, columns=["x", "y"])
df["chunk_index"] = np.arange(len(chunks))
df["snippet"] = [c[:120] + ("..." if len(c) > 120 else "") for c in chunks]
cluster_labels = compute_clusters(embeddings, n_clusters)
df["cluster"] = [f"Cluster {c}" for c in cluster_labels]
if has_labels:
cache_key = "|".join(raw_labels)
predicted_labels, confidence = compute_classification(
cache_key, embeddings, tuple(raw_labels)
)
df["classification"] = predicted_labels
df["confidence"] = confidence
else:
df["classification"] = "N/A"
df["confidence"] = 0.0
st.session_state.result_df = df
st.session_state.chunks = chunks
st.session_state.has_labels = has_labels
st.rerun()
# ----------------------------------------------------------------------------
# Main area
# ----------------------------------------------------------------------------
if st.session_state.result_df is None:
st.info("Please upload a .txt or .pdf file and click Run Analysis in the sidebar to begin.")
else:
df = st.session_state.result_df
chunks = st.session_state.chunks
has_labels = st.session_state.has_labels
color_col = "classification" if view_mode == "Predicted Classification Labels" else "cluster"
if not has_labels and view_mode == "Predicted Classification Labels":
st.caption(
"Enter at least two comma-separated labels in the sidebar and rerun "
"to enable zero-shot classification coloring."
)
# ------------------------------------------------------------------------
# Scatter plot
# ------------------------------------------------------------------------
fig = px.scatter(
df,
x="x",
y="y",
color=color_col,
hover_data={"snippet": True, "chunk_index": True, "x": False, "y": False},
custom_data=["chunk_index"],
title=f"Semantic Map (t-SNE) - colored by {view_mode}",
template="plotly_dark",
height=560,
)
fig.update_traces(
marker=dict(size=10, opacity=0.8, line=dict(width=1, color="DarkSlateGrey"))
)
fig.update_layout(
legend_title_text=view_mode,
xaxis_title="Dimension 1",
yaxis_title="Dimension 2",
margin=dict(l=10, r=10, t=50, b=10),
)
event = st.plotly_chart(
fig,
use_container_width=True,
on_select="rerun",
key="semantic_scatter_chart",
selection_mode="points",
)
clicked_index = None
if event and "selection" in event and event["selection"].get("points"):
point = event["selection"]["points"][0]
try:
clicked_index = int(point["customdata"][0])
except (KeyError, IndexError, TypeError, ValueError):
clicked_index = None
# ------------------------------------------------------------------------
# Side-by-side: distribution + snippet viewer
# ------------------------------------------------------------------------
st.divider()
col_left, col_right = st.columns([1, 1.4])
with col_left:
st.subheader("Group Distribution")
group_counts = df[color_col].value_counts(normalize=True).sort_index()
for group_name, pct in group_counts.items():
st.write(f"**{group_name}** - {pct * 100:.1f}%")
st.progress(min(max(pct, 0.0), 1.0))
if color_col == "classification" and has_labels:
avg_conf = df["confidence"].mean()
st.metric("Avg. classification confidence", f"{avg_conf:.2f}")
with col_right:
st.subheader("Text Snippets")
if clicked_index is None:
st.info("Click a data point on the scatter plot to inspect its text chunk.")
elif clicked_index >= len(chunks):
st.warning("Could not resolve the selected point. Please click another point.")
else:
clicked_group = df.loc[df["chunk_index"] == clicked_index, color_col].values[0]
with st.expander(
f"Selected Chunk {clicked_index} (Group: {clicked_group})", expanded=True
):
st.write(chunks[clicked_index])
if color_col == "classification" and has_labels:
conf_val = df.loc[
df["chunk_index"] == clicked_index, "confidence"
].values[0]
st.caption(f"Classification confidence: {conf_val:.3f}")
st.markdown(f"**Other chunks in '{clicked_group}':**")
same_group_df = df[
(df[color_col] == clicked_group) & (df["chunk_index"] != clicked_index)
]
if same_group_df.empty:
st.caption("No other chunks share this group.")
else:
for _, row in same_group_df.iterrows():
idx = int(row["chunk_index"])
with st.expander(f"Chunk {idx}"):
st.write(chunks[idx])