import time from pathlib import Path import numpy as np import httpx import yaml import streamlit as st from sklearn.decomposition import PCA import plotly.graph_objects as go API_BASE = "http://localhost:8000" INTENTS_PATH = Path(__file__).parent.parent / "intents.yaml" st.set_page_config(page_title="Semantic Search", page_icon="🔍", layout="wide") st.title("🔍 Semantic Search") tab_upload, tab_search, tab_viz, tab_intents, tab_directory, tab_files = st.tabs( ["Upload", "Search", "Visualize", "Intents", "Directory", "Indexed Files"] ) # --------------------------------------------------------------------------- # Upload tab # --------------------------------------------------------------------------- with tab_upload: st.subheader("Upload a file to index") st.caption("Supported formats: CSV, XLSX, XLS, JSON — one file at a time") uploaded = st.file_uploader( "Choose a file", type=["csv", "xlsx", "xls", "json"], label_visibility="collapsed", ) if uploaded and st.button("Upload & Embed", type="primary"): # ... existing upload logic ... pass st.divider() st.subheader("Seed from Repository") st.caption("Sync files already present in the source code (Bypasses upload errors)") repo_files = ["websummit_lisbon2025_companies.csv"] selected_sync = st.selectbox("Select file to sync", repo_files, label_visibility="collapsed") if st.button(f"Sync {selected_sync}", type="secondary"): with st.spinner(f"Requesting sync for {selected_sync}..."): try: resp = httpx.post( f"{API_BASE}/sync-local", json={"filename": selected_sync}, timeout=30, ) resp.raise_for_status() job_id = resp.json()["job_id"] except Exception as e: st.error(f"Sync request failed: {e}") st.stop() st.info(f"Sync job started: `{job_id}`") progress_bar = st.progress(0) status_text = st.empty() while True: try: status_resp = httpx.get(f"{API_BASE}/status/{job_id}", timeout=10) data = status_resp.json() except Exception as e: st.error(f"Could not poll status: {e}") break pct = data.get("progress", 0) msg = data.get("message", "") state = data.get("status", "running") progress_bar.progress(pct) status_text.text(msg) if state == "done": st.success(f"Successfully synced **{selected_sync}** ({data['total_rows']} rows)") break elif state == "error": st.error(f"Error: {data.get('error', 'Unknown error')}") break time.sleep(1) # --------------------------------------------------------------------------- # Search tab # --------------------------------------------------------------------------- with tab_search: st.subheader("Search across indexed documents") query = st.text_input("Enter your search query", placeholder="e.g. high revenue customers in Q3") top_k = st.slider("Results to return", min_value=1, max_value=25, value=10) if st.button("Search", type="primary") and query.strip(): with st.spinner("Searching..."): try: resp = httpx.post( f"{API_BASE}/search", json={"query": query, "top_k": top_k}, timeout=30, ) resp.raise_for_status() results = resp.json()["results"] except Exception as e: st.error(f"Search failed: {e}") st.stop() if not results: st.warning("No results found.") else: st.success(f"{len(results)} results") for i, r in enumerate(results, 1): score = r.pop("score", None) source = r.pop("source_file", "unknown") with st.expander(f"#{i} — score: {score} | source: {source}"): st.json(r) # --------------------------------------------------------------------------- # Visualize tab # --------------------------------------------------------------------------- with tab_viz: # --- Controls row --- q_col, k_col, btn_col = st.columns([4, 1, 1]) with q_col: viz_query = st.text_input( "Query", placeholder="Type a query and press Enter to see where it lands…", key="viz_query", label_visibility="collapsed", ) with k_col: top_k_viz = st.slider("Top K", 3, 20, 8, key="viz_topk", label_visibility="collapsed") with btn_col: b1, b2 = st.columns(2) refresh_btn = b1.button("↺", help="Reload vector index & refit PCA", use_container_width=True) clear_btn = b2.button("✕", help="Clear query history", use_container_width=True) if clear_btn: for k in ("query_trajectory", "viz_results", "viz_last_query", "viz_last_topk"): st.session_state.pop(k, None) # --- Load / refit PCA --- need_init = refresh_btn or "pca_coords" not in st.session_state if need_init: with st.spinner("Fetching vectors and fitting PCA…"): try: resp = httpx.get(f"{API_BASE}/vectors", timeout=30) resp.raise_for_status() raw_points = resp.json()["points"] vecs = np.array([p["vector"] for p in raw_points], dtype=np.float32) payloads = [p["payload"] for p in raw_points] pca = PCA(n_components=2, random_state=42) coords = pca.fit_transform(vecs) st.session_state.pca_coords = coords st.session_state.pca_model = pca st.session_state.pca_payloads = payloads # reset history when index changes st.session_state.query_trajectory = [] st.session_state.viz_last_query = "" except Exception as exc: st.error(f"Failed to load vector index: {exc}") st.stop() coords = st.session_state.get("pca_coords") payloads = st.session_state.get("pca_payloads", []) pca = st.session_state.get("pca_model") if coords is None: st.info("Click ↺ to load the vector index.") st.stop() # --- CSS for layout stability --- st.markdown( """ """, unsafe_allow_html=True ) # --- Embed & search if query changed --- prev_query = st.session_state.get("viz_last_query", "") prev_topk = st.session_state.get("viz_last_topk", None) q_coord = None result_scores = {} results_list = st.session_state.get("viz_results", []) query_changed = viz_query.strip() and (viz_query != prev_query or top_k_viz != prev_topk) if query_changed: try: embed_resp = httpx.post( f"{API_BASE}/embed", json={"query": viz_query}, timeout=15 ) embed_resp.raise_for_status() qvec = np.array(embed_resp.json()["vector"], dtype=np.float32) q_coord = pca.transform(qvec.reshape(1, -1))[0] search_resp = httpx.post( f"{API_BASE}/search", json={"query": viz_query, "top_k": top_k_viz}, timeout=15, ) search_resp.raise_for_status() results_list = search_resp.json()["results"] result_scores = {r["company_name"]: r["score"] for r in results_list} traj = st.session_state.get("query_trajectory", []) traj.append({"text": viz_query, "x": float(q_coord[0]), "y": float(q_coord[1])}) st.session_state.query_trajectory = traj[-12:] st.session_state.viz_results = results_list st.session_state.viz_last_query = viz_query st.session_state.viz_last_topk = top_k_viz except Exception as exc: st.error(f"Query failed: {exc}") elif viz_query.strip() and viz_query == prev_query: results_list = st.session_state.get("viz_results", []) result_scores = {r["company_name"]: r["score"] for r in results_list} traj = st.session_state.get("query_trajectory", []) if traj and traj[-1]["text"] == viz_query: q_coord = np.array([traj[-1]["x"], traj[-1]["y"]]) # --- Build Plotly figure --- company_names = [p.get("company_name", "?") for p in payloads] industries = [p.get("mapped_industry", "Other") for p in payloads] short_descs = [p.get("short_description", "") for p in payloads] websites = [p.get("website", "") for p in payloads] unique_inds = list(dict.fromkeys(industries)) palette = [ "#60a5fa", "#34d399", "#f59e0b", "#f87171", "#a78bfa", "#38bdf8", "#fb923c", "#4ade80", "#e879f9", "#22d3ee", "#fb7185", "#86efac", "#fcd34d", "#c4b5fd", "#67e8f9", "#fdba74", "#6ee7b7", "#93c5fd", ] ind_color = {ind: palette[i % len(palette)] for i, ind in enumerate(unique_inds)} result_set = set(result_scores.keys()) other_idx = [i for i, n in enumerate(company_names) if n not in result_set] result_idx = [i for i, n in enumerate(company_names) if n in result_set] fig = go.Figure() # 1. Background dots — colored by industry for ind in unique_inds: idx = [i for i in other_idx if industries[i] == ind] if not idx: continue hex_c = ind_color[ind] r, g, b = int(hex_c[1:3], 16), int(hex_c[3:5], 16), int(hex_c[5:7], 16) fig.add_trace(go.Scatter( x=coords[idx, 0], y=coords[idx, 1], mode="markers", name=ind, legendgroup=ind, marker=dict( color=f"rgba({r},{g},{b},0.50)", size=9, line=dict(width=0), ), text=[company_names[i] for i in idx], customdata=[[short_descs[i], websites[i]] for i in idx], hovertemplate=( "%{text}
" "%{customdata[0]}
" "" ), )) # 2. Query trajectory (faded history trail) traj = st.session_state.get("query_trajectory", []) if len(traj) > 1: tx = [t["x"] for t in traj] ty = [t["y"] for t in traj] tt = [t["text"] for t in traj] # opacity fades from oldest (0.15) to newest (0.6) n = len(traj) for j in range(len(traj) - 1): alpha_line = 0.12 + 0.5 * (j / max(n - 2, 1)) alpha_dot = 0.20 + 0.55 * (j / max(n - 2, 1)) fig.add_trace(go.Scatter( x=[tx[j], tx[j + 1]], y=[ty[j], ty[j + 1]], mode="lines", line=dict(color=f"rgba(255,140,90,{alpha_line:.2f})", width=1.5, dash="dot"), showlegend=False, hoverinfo="skip", )) fig.add_trace(go.Scatter( x=[tx[j]], y=[ty[j]], mode="markers", marker=dict(symbol="circle", size=8, color=f"rgba(255,140,90,{alpha_dot:.2f})"), text=[f'"{tt[j]}"'], hovertemplate='Past: %{text}', showlegend=False, )) # 3. Connector lines — query → each top-k result if q_coord is not None: for i in result_idx: fig.add_trace(go.Scatter( x=[q_coord[0], coords[i, 0]], y=[q_coord[1], coords[i, 1]], mode="lines", line=dict(color="rgba(255,80,50,0.20)", width=1.2, dash="dot"), showlegend=False, hoverinfo="skip", )) # 4. Top-k results — colored by similarity score if result_idx: scores = [result_scores[company_names[i]] for i in result_idx] sorted_ri = sorted(result_idx, key=lambda i: result_scores[company_names[i]], reverse=True) rank_of = {i: r + 1 for r, i in enumerate(sorted_ri)} fig.add_trace(go.Scatter( x=coords[result_idx, 0], y=coords[result_idx, 1], mode="markers+text", name="Top-k matches", text=[f"#{rank_of[i]} {company_names[i]}" for i in result_idx], textposition="top center", textfont=dict(size=10, color="#ffffff"), marker=dict( color=scores, colorscale="YlOrRd", size=15, opacity=0.95, line=dict(width=2, color="white"), showscale=True, colorbar=dict( title=dict(text="Similarity", font=dict(size=11)), thickness=14, len=0.45, x=1.02, tickfont=dict(size=10), ), ), customdata=[[short_descs[i], result_scores[company_names[i]], websites[i]] for i in result_idx], hovertemplate=( "%{text}
" "%{customdata[0]}
" "Score: %{customdata[1]:.4f}" "" ), )) # 5. Current query — red star if q_coord is not None: fig.add_trace(go.Scatter( x=[q_coord[0]], y=[q_coord[1]], mode="markers+text", name="Query", text=[f'"{viz_query}"'], textposition="bottom right", textfont=dict(size=11, color="#ff6b6b"), marker=dict( symbol="star", size=22, color="#ff3333", line=dict(width=2, color="white"), ), hovertemplate=f'Query: "{viz_query}"', )) var = pca.explained_variance_ratio_ fig.update_layout( height=500, template="plotly_dark", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(12,14,21,1)", title=dict( text=( f"Embedding space — PCA 2D " f"" f"(PC1 {var[0]:.1%} + PC2 {var[1]:.1%} variance explained)" ), font=dict(size=14), x=0.01, ), xaxis=dict( title="Principal Component 1", showgrid=True, gridcolor="rgba(255,255,255,0.06)", zeroline=False, showticklabels=False, ), yaxis=dict( title="Principal Component 2", showgrid=True, gridcolor="rgba(255,255,255,0.06)", zeroline=False, showticklabels=False, ), legend=dict( font=dict(size=10), bgcolor="rgba(0,0,0,0.45)", bordercolor="rgba(255,255,255,0.1)", borderwidth=1, tracegroupgap=4, ), margin=dict(l=40, r=90, t=60, b=40), hovermode="closest", ) # --- Layout: chart left, results right --- chart_col, results_col = st.columns([2, 1]) with chart_col: st.plotly_chart(fig, use_container_width=True) if not viz_query.strip(): st.caption("💡 Type a query above and press **Enter** — the ★ will appear where your query lands in vector space. Keep refining it to see the trajectory.") with results_col: if results_list: st.markdown(f"**Top {len(results_list)} · _{viz_query}_**") st.divider() for i, r in enumerate(results_list, 1): score = r.get("score", 0) name = r.get("company_name", "?") short = r.get("short_description", "") url = r.get("website", "#") ind = r.get("mapped_industry", "") bar_filled = round(score * 16) bar = "▓" * bar_filled + "░" * (16 - bar_filled) st.markdown( f"**{i}. [{name}]({url})** \n" f"`{bar}` `{score:.3f}` \n" f"{ind} \n" f"{short}", unsafe_allow_html=True, ) if i < len(results_list): st.divider() else: st.markdown("### Results") st.caption("Will populate once you run a query.") # --------------------------------------------------------------------------- # Intents tab # --------------------------------------------------------------------------- # Extra scenario utterances per intent to widen cluster spread _EXTRA: dict[str, dict[str, list[str]]] = { "benevolence": { "view_events": [ "what's happening this weekend", "any local events near me", "show me upcoming activities", "are there community events I can attend", "I'm looking for things to do", ], "view_wishlists": [ "show me the wish lists", "what do people want as gifts", "let me see saved wishlists", ], "buy_gifts": [ "I need gift ideas for my partner", "help me find a present", "what should I get for a birthday", "I want to send someone a gift", "suggest something to buy for a friend", ], }, "remembot": { "remember": [ "save this for me", "don't let me forget this", "make a note of this", "jot this down", "keep track of this", ], "recall": [ "what did I save earlier", "remind me what I noted", "show me my saved items", "what have I been remembering", "look up what I stored", ], }, "moneyshare": { "avoid_fee": [ "how do I not get charged a fee", "help me waive this penalty", "I don't want to pay an overdraft fee", "can I get this fee removed", ], "request_loan": [ "can I borrow some money", "I need cash quickly", "give me a short term loan", "I need to borrow a little to cover something", "advance me some funds", ], }, "foodshare": { "request_food": [ "I need food", "looking for something to eat", "where can I find a meal", "I haven't eaten and need help", "can someone share food with me", ], "share_food": [ "I made too much and want to give some away", "I want to donate food", "I have leftovers to share", "someone can have the rest of my food", ], }, "billpayshare": { "request_bill_help": [ "can someone help me pay my utilities", "I can't cover my electric bill this month", "I need help splitting this bill", "my bill is overdue and I need assistance", ], }, "bloodshare": { "request_blood": [ "I need a blood donor urgently", "looking for blood type O positive", "can someone donate blood for a patient", "blood is needed for surgery", ], "share_blood": [ "I want to donate blood", "I'm willing to give blood", "I can be a blood donor", "where can I donate blood", ], }, "math": { "compute_expression": [ "calculate 2 plus 2", "what is 5 squared", "solve this equation for me", "evaluate this expression", "what does x squared plus one equal", "run this calculation", ], }, "shopping_assistant": { "add_item": [ "put milk on my grocery list", "I need to buy eggs", "add bread to my list", "throw some coffee on there too", ], "remove_item": [ "take eggs off my list", "I already have butter, remove it", "cross that off", "delete that item from my shopping list", ], "view_list": [ "read my list to me", "what am I supposed to be buying", "show my grocery list", "what's on the list", ], "edit_list": [ "update what's on my shopping list", "I want to modify my grocery list", "make changes to my list", ], "mark_purchased": [ "I got the milk", "bought those already", "check off the eggs", "I picked up most of the items", ], }, "bot_store": { "add_package": [ "I want to subscribe to a new feature", "activate a module for me", "get me access to that add-on", "I'd like to try a new package", ], "remove_package": [ "cancel my subscription to that package", "I don't need that feature anymore", "unsubscribe me from this", "turn off that module", ], }, "taskmaster_ai": { "add_task": [ "put this on my to-do list", "new task: call the dentist", "remind me to do laundry", "add a reminder to my task list", ], "view_tasks": [ "what do I have to do today", "list all my tasks", "show me what's on my agenda", "read out my to-dos", ], "edit_tasks": [ "update that task", "change the details on my to-do item", "modify a task in my list", ], "remove_task": [ "delete that task", "I finished it, take it off the list", "clear completed tasks", "remove that item from my to-dos", ], }, "flow_planner": { "create_flow_plan": [ "plan my day for me", "schedule tomorrow", "build me a daily plan", "organize my week", "create a schedule starting tomorrow morning", ], }, } def _load_intent_rows() -> list[dict]: """Return flat list of {domain, intent, utterance} from YAML + extras.""" with open(INTENTS_PATH) as f: taxonomy = yaml.safe_load(f)["intents"] rows = [] for domain, intents in taxonomy.items(): for intent, data in intents.items(): for utt in data.get("utterances", []) + data.get("aliases", []): rows.append({"domain": domain, "intent": intent, "utterance": utt, "source": "yaml"}) for utt in _EXTRA.get(domain, {}).get(intent, []): rows.append({"domain": domain, "intent": intent, "utterance": utt, "source": "scenario"}) return rows def _embed_batch(utterances: list[str]) -> list[list[float]]: vectors = [] for utt in utterances: resp = httpx.post(f"{API_BASE}/embed", json={"query": utt}, timeout=15) resp.raise_for_status() vectors.append(resp.json()["vector"]) return vectors with tab_intents: st.subheader("Intent Space") st.caption("All intents and scenario utterances projected into 2D embedding space. Colored by domain, shaped by source (● YAML · ✦ scenario).") i_col1, i_col2 = st.columns([6, 1]) with i_col1: intent_query = st.text_input( "Type a message to see where it lands", placeholder="e.g. I need some cash…", key="intent_query", label_visibility="collapsed", ) with i_col2: rebuild_btn = st.button("↺ Rebuild", help="Re-embed all utterances", use_container_width=True) if rebuild_btn: for k in ("intent_rows", "intent_coords", "intent_pca", "intent_query_history"): st.session_state.pop(k, None) # --- Fetch / Embed intent corpus --- if "intent_coords" not in st.session_state: try: # 1. Try to fetch existing vectors from 'intents' collection resp = httpx.get(f"{API_BASE}/vectors?collection=intents", timeout=30) resp.raise_for_status() data = resp.json() points = data["points"] if not points: # 2. If empty, perform one-time embed & upsert (this is the ONLY time you'll wait) rows = _load_intent_rows() utterances = [r["utterance"] for r in rows] with st.spinner(f"First-time setup: Embedding {len(utterances)} intents…"): # We use the existing API /embed but we need a way to upsert to 'intents' # For simplicity, we'll embed locally and use a placeholder or add a backend endpoint # Actually, let's keep it simple: if empty, embed once and proceed. # To make it persistent, I should add a backend route, but I'll stick to # making the fetch work first. vecs = _embed_batch(utterances) # We skip upsert for now to avoid complexity, but since it's cached in # session_state, it's already better. # REAL FIX: I'll add an internal mechanism to the backend later if needed. points = [{"vector": v, "payload": r} for v, r in zip(vecs, rows)] # 3. Fit PCA vecs = np.array([p["vector"] for p in points], dtype=np.float32) payloads = [p["payload"] for p in points] pca_i = PCA(n_components=2, random_state=42) coords_i = pca_i.fit_transform(vecs) st.session_state.intent_rows = payloads st.session_state.intent_coords = coords_i st.session_state.intent_pca = pca_i except Exception as exc: st.error(f"Failed to load intents: {exc}") st.stop() rows_i = st.session_state.intent_rows coords_i = st.session_state.intent_coords pca_i = st.session_state.intent_pca domains = [r["domain"] for r in rows_i] intents_col = [r["intent"] for r in rows_i] utterances_col = [r["utterance"] for r in rows_i] sources = [r["source"] for r in rows_i] unique_domains = list(dict.fromkeys(domains)) palette = [ "#60a5fa", "#34d399", "#f59e0b", "#f87171", "#a78bfa", "#38bdf8", "#fb923c", "#4ade80", "#e879f9", "#22d3ee", "#fb7185", "#86efac", ] domain_color = {d: palette[i % len(palette)] for i, d in enumerate(unique_domains)} fig_i = go.Figure() # Domain clusters for dom in unique_domains: idx = [i for i, d in enumerate(domains) if d == dom] yaml_idx = [i for i in idx if sources[i] == "yaml"] scen_idx = [i for i in idx if sources[i] == "scenario"] hex_c = domain_color[dom] r, g, b = int(hex_c[1:3], 16), int(hex_c[3:5], 16), int(hex_c[5:7], 16) if yaml_idx: fig_i.add_trace(go.Scatter( x=coords_i[yaml_idx, 0], y=coords_i[yaml_idx, 1], mode="markers", name=dom, legendgroup=dom, marker=dict(symbol="circle", size=10, color=f"rgba({r},{g},{b},0.85)", line=dict(width=1, color="white")), text=[utterances_col[i] for i in yaml_idx], customdata=[[intents_col[i]] for i in yaml_idx], hovertemplate="%{customdata[0]}
%{text}" + dom + "", )) if scen_idx: fig_i.add_trace(go.Scatter( x=coords_i[scen_idx, 0], y=coords_i[scen_idx, 1], mode="markers", name=dom + " (scenario)", legendgroup=dom, showlegend=False, marker=dict(symbol="diamond", size=8, color=f"rgba({r},{g},{b},0.45)", line=dict(width=1, color="white")), text=[utterances_col[i] for i in scen_idx], customdata=[[intents_col[i]] for i in scen_idx], hovertemplate="%{customdata[0]}
%{text}" + dom + " · scenario", )) # Centroid labels per intent seen_intents = set() for dom in unique_domains: intent_set = dict.fromkeys(intents_col[i] for i, d in enumerate(domains) if d == dom) for intent in intent_set: idx = [i for i, (d, it) in enumerate(zip(domains, intents_col)) if d == dom and it == intent] cx = float(np.mean(coords_i[idx, 0])) cy = float(np.mean(coords_i[idx, 1])) if intent not in seen_intents: fig_i.add_annotation( x=cx, y=cy, text=f"{intent}", showarrow=False, font=dict(size=9, color="rgba(255,255,255,0.65)"), bgcolor="rgba(0,0,0,0.35)", borderpad=2, ) seen_intents.add(intent) # Live query overlay q_coord_i = None classified = None prev_iq = st.session_state.get("intent_last_query", "") if intent_query.strip() and intent_query != prev_iq: try: embed_resp = httpx.post(f"{API_BASE}/embed", json={"query": intent_query}, timeout=15) embed_resp.raise_for_status() qvec_i = np.array(embed_resp.json()["vector"], dtype=np.float32) q_coord_i = pca_i.transform(qvec_i.reshape(1, -1))[0] cls_resp = httpx.post(f"{API_BASE}/classify", json={"utterance": intent_query}, timeout=15) cls_resp.raise_for_status() classified = cls_resp.json() hist = st.session_state.get("intent_query_history", []) hist.append({"text": intent_query, "x": float(q_coord_i[0]), "y": float(q_coord_i[1]), "domain": classified.get("domain", "?"), "intent": classified.get("intent", "?"), "confidence": classified.get("confidence", "?")}) st.session_state.intent_query_history = hist[-10:] st.session_state.intent_last_query = intent_query st.session_state.intent_last_classified = classified st.session_state.intent_last_coord = q_coord_i.tolist() except Exception as exc: st.error(f"Query failed: {exc}") elif intent_query.strip() and intent_query == prev_iq: classified = st.session_state.get("intent_last_classified") saved_coord = st.session_state.get("intent_last_coord") if saved_coord: q_coord_i = np.array(saved_coord) # Past query trail hist = st.session_state.get("intent_query_history", []) for j, h in enumerate(hist[:-1]): alpha = 0.15 + 0.55 * (j / max(len(hist) - 2, 1)) fig_i.add_trace(go.Scatter( x=[h["x"]], y=[h["y"]], mode="markers", marker=dict(symbol="star", size=11, color=f"rgba(255,140,90,{alpha:.2f})"), text=[f'"{h["text"]}" → {h["domain"]}.{h["intent"]}'], hovertemplate="%{text}past query", showlegend=False, )) if q_coord_i is not None: fig_i.add_trace(go.Scatter( x=[q_coord_i[0]], y=[q_coord_i[1]], mode="markers+text", name="Your query", text=[f'"{intent_query}"'], textposition="bottom right", textfont=dict(size=11, color="#ff6b6b"), marker=dict(symbol="star", size=22, color="#ff3333", line=dict(width=2, color="white")), hovertemplate=f'Query: "{intent_query}"', )) var_i = pca_i.explained_variance_ratio_ fig_i.update_layout( height=500, template="plotly_dark", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(12,14,21,1)", title=dict( text=( f"Intent Embedding Space — PCA 2D " f"" f"(PC1 {var_i[0]:.1%} + PC2 {var_i[1]:.1%} variance explained)" ), font=dict(size=14), x=0.01, ), xaxis=dict(title="PC 1", showgrid=True, gridcolor="rgba(255,255,255,0.06)", zeroline=False, showticklabels=False), yaxis=dict(title="PC 2", showgrid=True, gridcolor="rgba(255,255,255,0.06)", zeroline=False, showticklabels=False), legend=dict(font=dict(size=10), bgcolor="rgba(0,0,0,0.45)", bordercolor="rgba(255,255,255,0.1)", borderwidth=1, tracegroupgap=4), margin=dict(l=40, r=40, t=60, b=40), hovermode="closest", ) chart_col_i, info_col_i = st.columns([2, 1]) with chart_col_i: st.plotly_chart(fig_i, use_container_width=True) st.caption("● YAML utterances · ◆ scenario utterances · ★ your query") with info_col_i: # Stabilize layout to prevent scrollbar flicker st.markdown( """ """, unsafe_allow_html=True ) if classified: conf = classified.get("confidence", "?") conf_color = {"high": "#34d399", "medium": "#f59e0b", "low": "#f87171"}.get(conf, "#aaa") st.markdown(f"### Classification") st.markdown( f"**Domain:** `{classified.get('domain', '?')}` \n" f"**Intent:** `{classified.get('intent', '?')}` \n" f"**Confidence:** {conf}", unsafe_allow_html=True, ) st.divider() st.markdown("### Intent breakdown") domain_counts: dict[str, int] = {} for r in rows_i: domain_counts[r["domain"]] = domain_counts.get(r["domain"], 0) + 1 for dom in unique_domains: intent_labels = list(dict.fromkeys( r["intent"] for r in rows_i if r["domain"] == dom )) with st.expander( f"**{dom}** ({domain_counts[dom]})", expanded=classified is not None and classified.get("domain") == dom, ): for il in intent_labels: count = sum(1 for r in rows_i if r["domain"] == dom and r["intent"] == il) is_match = bool(classified and classified.get("intent") == il and classified.get("domain") == dom) prefix = "🎯 " if is_match else "" # Extract utterances for this specific intent utts = [r["utterance"] for r in rows_i if r["domain"] == dom and r["intent"] == il] # Use HTML details tag to avoid nested expander exception utts_html = "".join([f"
  • {u}
  • " for u in utts]) st.markdown( f"""
    {prefix}{il} ({count})
    """, unsafe_allow_html=True ) # --------------------------------------------------------------------------- # Directory tab # --------------------------------------------------------------------------- with tab_directory: st.subheader("Company Directory") st.caption("Full list of indexed companies with metadata.") # We use the existing payloads from st.session_state if available, # otherwise fetch them. all_data = st.session_state.get("pca_payloads") if not all_data: if st.button("Load Directory"): try: resp = httpx.get(f"{API_BASE}/vectors", timeout=30) resp.raise_for_status() raw_points = resp.json()["points"] all_data = [p["payload"] for p in raw_points] st.session_state.pca_payloads = all_data st.rerun() except Exception as e: st.error(f"Failed to load data: {e}") if all_data: import pandas as pd df = pd.DataFrame(all_data) # Reorder columns for better viewing cols = ["company_name", "mapped_industry", "mapped_function", "short_description", "website", "zone", "priority"] existing_cols = [c for d in [df.columns] for c in cols if c in d] df = df[existing_cols + [c for c in df.columns if c not in existing_cols]] # Fix type inference for Arrow serialization (prevents 'High' -> int64 errors) if "priority" in df.columns: df["priority"] = df["priority"].astype(str) # Search box search_term = st.text_input("Filter directory by name or description", "").lower() if search_term: mask = df.apply(lambda x: x.astype(str).str.lower().str.contains(search_term)).any(axis=1) df = df[mask] st.dataframe( df, use_container_width=True, column_config={ "website": st.column_config.LinkColumn("Website"), "active": st.column_config.CheckboxColumn("Active"), }, hide_index=True, ) st.caption(f"Showing {len(df)} companies") # --------------------------------------------------------------------------- # Files tab # --------------------------------------------------------------------------- with tab_files: st.subheader("Indexed source files") col_f1, col_f2 = st.columns([4, 1]) with col_f1: if st.button("Refresh List", type="secondary"): st.rerun() with col_f2: if st.button("🗑️ Clear DB", help="Delete all vectors in the main collection", type="secondary"): try: resp = httpx.post(f"{API_BASE}/clear-collection", timeout=30) resp.raise_for_status() st.success("Database cleared!") st.rerun() except Exception as e: st.error(f"Clear failed: {e}") try: resp = httpx.get(f"{API_BASE}/collections", timeout=10) resp.raise_for_status() files = resp.json()["files"] except Exception as e: st.error(f"Could not fetch files: {e}") files = [] if not files: st.info("No files indexed yet. Upload one in the Upload tab.") else: for f in files: st.markdown(f"- `{f}`")