Spaces:
Sleeping
Sleeping
| 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 | |
| # ---------------------------------------------------------------------------- | |
| 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 | |
| # ---------------------------------------------------------------------------- | |
| 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) | |
| 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) | |
| 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 | |
| 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]) | |