ConstCorrectness Claude Sonnet 4.6 commited on
Commit
1f0067f
·
1 Parent(s): 762a6f5

Add Intents visualization tab with live classification overlay

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. frontend/app.py +455 -2
frontend/app.py CHANGED
@@ -1,17 +1,21 @@
1
  import time
 
 
2
  import numpy as np
3
  import httpx
 
4
  import streamlit as st
5
  from sklearn.decomposition import PCA
6
  import plotly.graph_objects as go
7
 
8
  API_BASE = "http://localhost:8000"
 
9
 
10
  st.set_page_config(page_title="Semantic Search", page_icon="🔍", layout="wide")
11
  st.title("🔍 Semantic Search")
12
 
13
- tab_upload, tab_search, tab_viz, tab_files = st.tabs(
14
- ["Upload", "Search", "Visualize", "Indexed Files"]
15
  )
16
 
17
 
@@ -417,6 +421,455 @@ with tab_viz:
417
  st.caption("Will populate once you run a query.")
418
 
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  # ---------------------------------------------------------------------------
421
  # Files tab
422
  # ---------------------------------------------------------------------------
 
1
  import time
2
+ from pathlib import Path
3
+
4
  import numpy as np
5
  import httpx
6
+ import yaml
7
  import streamlit as st
8
  from sklearn.decomposition import PCA
9
  import plotly.graph_objects as go
10
 
11
  API_BASE = "http://localhost:8000"
12
+ INTENTS_PATH = Path(__file__).parent.parent / "intents.yaml"
13
 
14
  st.set_page_config(page_title="Semantic Search", page_icon="🔍", layout="wide")
15
  st.title("🔍 Semantic Search")
16
 
17
+ tab_upload, tab_search, tab_viz, tab_intents, tab_files = st.tabs(
18
+ ["Upload", "Search", "Visualize", "Intents", "Indexed Files"]
19
  )
20
 
21
 
 
421
  st.caption("Will populate once you run a query.")
422
 
423
 
424
+ # ---------------------------------------------------------------------------
425
+ # Intents tab
426
+ # ---------------------------------------------------------------------------
427
+
428
+ # Extra scenario utterances per intent to widen cluster spread
429
+ _EXTRA: dict[str, dict[str, list[str]]] = {
430
+ "benevolence": {
431
+ "view_events": [
432
+ "what's happening this weekend",
433
+ "any local events near me",
434
+ "show me upcoming activities",
435
+ "are there community events I can attend",
436
+ "I'm looking for things to do",
437
+ ],
438
+ "view_wishlists": [
439
+ "show me the wish lists",
440
+ "what do people want as gifts",
441
+ "let me see saved wishlists",
442
+ ],
443
+ "buy_gifts": [
444
+ "I need gift ideas for my partner",
445
+ "help me find a present",
446
+ "what should I get for a birthday",
447
+ "I want to send someone a gift",
448
+ "suggest something to buy for a friend",
449
+ ],
450
+ },
451
+ "remembot": {
452
+ "remember": [
453
+ "save this for me",
454
+ "don't let me forget this",
455
+ "make a note of this",
456
+ "jot this down",
457
+ "keep track of this",
458
+ ],
459
+ "recall": [
460
+ "what did I save earlier",
461
+ "remind me what I noted",
462
+ "show me my saved items",
463
+ "what have I been remembering",
464
+ "look up what I stored",
465
+ ],
466
+ },
467
+ "moneyshare": {
468
+ "avoid_fee": [
469
+ "how do I not get charged a fee",
470
+ "help me waive this penalty",
471
+ "I don't want to pay an overdraft fee",
472
+ "can I get this fee removed",
473
+ ],
474
+ "request_loan": [
475
+ "can I borrow some money",
476
+ "I need cash quickly",
477
+ "give me a short term loan",
478
+ "I need to borrow a little to cover something",
479
+ "advance me some funds",
480
+ ],
481
+ },
482
+ "foodshare": {
483
+ "request_food": [
484
+ "I need food",
485
+ "looking for something to eat",
486
+ "where can I find a meal",
487
+ "I haven't eaten and need help",
488
+ "can someone share food with me",
489
+ ],
490
+ "share_food": [
491
+ "I made too much and want to give some away",
492
+ "I want to donate food",
493
+ "I have leftovers to share",
494
+ "someone can have the rest of my food",
495
+ ],
496
+ },
497
+ "billpayshare": {
498
+ "request_bill_help": [
499
+ "can someone help me pay my utilities",
500
+ "I can't cover my electric bill this month",
501
+ "I need help splitting this bill",
502
+ "my bill is overdue and I need assistance",
503
+ ],
504
+ },
505
+ "bloodshare": {
506
+ "request_blood": [
507
+ "I need a blood donor urgently",
508
+ "looking for blood type O positive",
509
+ "can someone donate blood for a patient",
510
+ "blood is needed for surgery",
511
+ ],
512
+ "share_blood": [
513
+ "I want to donate blood",
514
+ "I'm willing to give blood",
515
+ "I can be a blood donor",
516
+ "where can I donate blood",
517
+ ],
518
+ },
519
+ "math": {
520
+ "compute_expression": [
521
+ "calculate 2 plus 2",
522
+ "what is 5 squared",
523
+ "solve this equation for me",
524
+ "evaluate this expression",
525
+ "what does x squared plus one equal",
526
+ "run this calculation",
527
+ ],
528
+ },
529
+ "shopping_assistant": {
530
+ "add_item": [
531
+ "put milk on my grocery list",
532
+ "I need to buy eggs",
533
+ "add bread to my list",
534
+ "throw some coffee on there too",
535
+ ],
536
+ "remove_item": [
537
+ "take eggs off my list",
538
+ "I already have butter, remove it",
539
+ "cross that off",
540
+ "delete that item from my shopping list",
541
+ ],
542
+ "view_list": [
543
+ "read my list to me",
544
+ "what am I supposed to be buying",
545
+ "show my grocery list",
546
+ "what's on the list",
547
+ ],
548
+ "edit_list": [
549
+ "update what's on my shopping list",
550
+ "I want to modify my grocery list",
551
+ "make changes to my list",
552
+ ],
553
+ "mark_purchased": [
554
+ "I got the milk",
555
+ "bought those already",
556
+ "check off the eggs",
557
+ "I picked up most of the items",
558
+ ],
559
+ },
560
+ "bot_store": {
561
+ "add_package": [
562
+ "I want to subscribe to a new feature",
563
+ "activate a module for me",
564
+ "get me access to that add-on",
565
+ "I'd like to try a new package",
566
+ ],
567
+ "remove_package": [
568
+ "cancel my subscription to that package",
569
+ "I don't need that feature anymore",
570
+ "unsubscribe me from this",
571
+ "turn off that module",
572
+ ],
573
+ },
574
+ "taskmaster_ai": {
575
+ "add_task": [
576
+ "put this on my to-do list",
577
+ "new task: call the dentist",
578
+ "remind me to do laundry",
579
+ "add a reminder to my task list",
580
+ ],
581
+ "view_tasks": [
582
+ "what do I have to do today",
583
+ "list all my tasks",
584
+ "show me what's on my agenda",
585
+ "read out my to-dos",
586
+ ],
587
+ "edit_tasks": [
588
+ "update that task",
589
+ "change the details on my to-do item",
590
+ "modify a task in my list",
591
+ ],
592
+ "remove_task": [
593
+ "delete that task",
594
+ "I finished it, take it off the list",
595
+ "clear completed tasks",
596
+ "remove that item from my to-dos",
597
+ ],
598
+ },
599
+ "flow_planner": {
600
+ "create_flow_plan": [
601
+ "plan my day for me",
602
+ "schedule tomorrow",
603
+ "build me a daily plan",
604
+ "organize my week",
605
+ "create a schedule starting tomorrow morning",
606
+ ],
607
+ },
608
+ }
609
+
610
+
611
+ def _load_intent_rows() -> list[dict]:
612
+ """Return flat list of {domain, intent, utterance} from YAML + extras."""
613
+ with open(INTENTS_PATH) as f:
614
+ taxonomy = yaml.safe_load(f)["intents"]
615
+
616
+ rows = []
617
+ for domain, intents in taxonomy.items():
618
+ for intent, data in intents.items():
619
+ for utt in data.get("utterances", []) + data.get("aliases", []):
620
+ rows.append({"domain": domain, "intent": intent, "utterance": utt, "source": "yaml"})
621
+ for utt in _EXTRA.get(domain, {}).get(intent, []):
622
+ rows.append({"domain": domain, "intent": intent, "utterance": utt, "source": "scenario"})
623
+ return rows
624
+
625
+
626
+ def _embed_batch(utterances: list[str]) -> list[list[float]]:
627
+ vectors = []
628
+ for utt in utterances:
629
+ resp = httpx.post(f"{API_BASE}/embed", json={"query": utt}, timeout=15)
630
+ resp.raise_for_status()
631
+ vectors.append(resp.json()["vector"])
632
+ return vectors
633
+
634
+
635
+ with tab_intents:
636
+ st.subheader("Intent Space")
637
+ st.caption("All intents and scenario utterances projected into 2D embedding space. Colored by domain, shaped by source (● YAML · ✦ scenario).")
638
+
639
+ i_col1, i_col2 = st.columns([6, 1])
640
+ with i_col1:
641
+ intent_query = st.text_input(
642
+ "Type a message to see where it lands",
643
+ placeholder="e.g. I need some cash…",
644
+ key="intent_query",
645
+ label_visibility="collapsed",
646
+ )
647
+ with i_col2:
648
+ rebuild_btn = st.button("↺ Rebuild", help="Re-embed all utterances", use_container_width=True)
649
+
650
+ if rebuild_btn:
651
+ for k in ("intent_rows", "intent_coords", "intent_pca", "intent_query_history"):
652
+ st.session_state.pop(k, None)
653
+
654
+ # --- Embed intent corpus once ---
655
+ if "intent_coords" not in st.session_state:
656
+ rows = _load_intent_rows()
657
+ utterances = [r["utterance"] for r in rows]
658
+ with st.spinner(f"Embedding {len(utterances)} utterances…"):
659
+ try:
660
+ vecs = _embed_batch(utterances)
661
+ pca_i = PCA(n_components=2, random_state=42)
662
+ coords_i = pca_i.fit_transform(np.array(vecs, dtype=np.float32))
663
+ st.session_state.intent_rows = rows
664
+ st.session_state.intent_coords = coords_i
665
+ st.session_state.intent_pca = pca_i
666
+ except Exception as exc:
667
+ st.error(f"Failed to embed intents: {exc}")
668
+ st.stop()
669
+
670
+ rows_i = st.session_state.intent_rows
671
+ coords_i = st.session_state.intent_coords
672
+ pca_i = st.session_state.intent_pca
673
+
674
+ domains = [r["domain"] for r in rows_i]
675
+ intents_col = [r["intent"] for r in rows_i]
676
+ utterances_col = [r["utterance"] for r in rows_i]
677
+ sources = [r["source"] for r in rows_i]
678
+
679
+ unique_domains = list(dict.fromkeys(domains))
680
+ palette = [
681
+ "#60a5fa", "#34d399", "#f59e0b", "#f87171", "#a78bfa",
682
+ "#38bdf8", "#fb923c", "#4ade80", "#e879f9", "#22d3ee",
683
+ "#fb7185", "#86efac",
684
+ ]
685
+ domain_color = {d: palette[i % len(palette)] for i, d in enumerate(unique_domains)}
686
+
687
+ fig_i = go.Figure()
688
+
689
+ # Domain clusters
690
+ for dom in unique_domains:
691
+ idx = [i for i, d in enumerate(domains) if d == dom]
692
+ yaml_idx = [i for i in idx if sources[i] == "yaml"]
693
+ scen_idx = [i for i in idx if sources[i] == "scenario"]
694
+ hex_c = domain_color[dom]
695
+ r, g, b = int(hex_c[1:3], 16), int(hex_c[3:5], 16), int(hex_c[5:7], 16)
696
+
697
+ if yaml_idx:
698
+ fig_i.add_trace(go.Scatter(
699
+ x=coords_i[yaml_idx, 0], y=coords_i[yaml_idx, 1],
700
+ mode="markers",
701
+ name=dom,
702
+ legendgroup=dom,
703
+ marker=dict(symbol="circle", size=10,
704
+ color=f"rgba({r},{g},{b},0.85)",
705
+ line=dict(width=1, color="white")),
706
+ text=[utterances_col[i] for i in yaml_idx],
707
+ customdata=[[intents_col[i]] for i in yaml_idx],
708
+ hovertemplate="<b>%{customdata[0]}</b><br>%{text}<extra>" + dom + "</extra>",
709
+ ))
710
+
711
+ if scen_idx:
712
+ fig_i.add_trace(go.Scatter(
713
+ x=coords_i[scen_idx, 0], y=coords_i[scen_idx, 1],
714
+ mode="markers",
715
+ name=dom + " (scenario)",
716
+ legendgroup=dom,
717
+ showlegend=False,
718
+ marker=dict(symbol="diamond", size=8,
719
+ color=f"rgba({r},{g},{b},0.45)",
720
+ line=dict(width=1, color="white")),
721
+ text=[utterances_col[i] for i in scen_idx],
722
+ customdata=[[intents_col[i]] for i in scen_idx],
723
+ hovertemplate="<b>%{customdata[0]}</b><br>%{text}<extra>" + dom + " · scenario</extra>",
724
+ ))
725
+
726
+ # Centroid labels per intent
727
+ seen_intents = set()
728
+ for dom in unique_domains:
729
+ intent_set = dict.fromkeys(intents_col[i] for i, d in enumerate(domains) if d == dom)
730
+ for intent in intent_set:
731
+ idx = [i for i, (d, it) in enumerate(zip(domains, intents_col)) if d == dom and it == intent]
732
+ cx = float(np.mean(coords_i[idx, 0]))
733
+ cy = float(np.mean(coords_i[idx, 1]))
734
+ if intent not in seen_intents:
735
+ fig_i.add_annotation(
736
+ x=cx, y=cy,
737
+ text=f"<b>{intent}</b>",
738
+ showarrow=False,
739
+ font=dict(size=9, color="rgba(255,255,255,0.65)"),
740
+ bgcolor="rgba(0,0,0,0.35)",
741
+ borderpad=2,
742
+ )
743
+ seen_intents.add(intent)
744
+
745
+ # Live query overlay
746
+ q_coord_i = None
747
+ classified = None
748
+ prev_iq = st.session_state.get("intent_last_query", "")
749
+
750
+ if intent_query.strip() and intent_query != prev_iq:
751
+ try:
752
+ embed_resp = httpx.post(f"{API_BASE}/embed", json={"query": intent_query}, timeout=15)
753
+ embed_resp.raise_for_status()
754
+ qvec_i = np.array(embed_resp.json()["vector"], dtype=np.float32)
755
+ q_coord_i = pca_i.transform(qvec_i.reshape(1, -1))[0]
756
+
757
+ cls_resp = httpx.post(f"{API_BASE}/classify", json={"utterance": intent_query}, timeout=15)
758
+ cls_resp.raise_for_status()
759
+ classified = cls_resp.json()
760
+
761
+ hist = st.session_state.get("intent_query_history", [])
762
+ hist.append({"text": intent_query, "x": float(q_coord_i[0]), "y": float(q_coord_i[1]),
763
+ "domain": classified.get("domain", "?"), "intent": classified.get("intent", "?"),
764
+ "confidence": classified.get("confidence", "?")})
765
+ st.session_state.intent_query_history = hist[-10:]
766
+ st.session_state.intent_last_query = intent_query
767
+ st.session_state.intent_last_classified = classified
768
+ st.session_state.intent_last_coord = q_coord_i.tolist()
769
+ except Exception as exc:
770
+ st.error(f"Query failed: {exc}")
771
+
772
+ elif intent_query.strip() and intent_query == prev_iq:
773
+ classified = st.session_state.get("intent_last_classified")
774
+ saved_coord = st.session_state.get("intent_last_coord")
775
+ if saved_coord:
776
+ q_coord_i = np.array(saved_coord)
777
+
778
+ # Past query trail
779
+ hist = st.session_state.get("intent_query_history", [])
780
+ for j, h in enumerate(hist[:-1]):
781
+ alpha = 0.15 + 0.55 * (j / max(len(hist) - 2, 1))
782
+ fig_i.add_trace(go.Scatter(
783
+ x=[h["x"]], y=[h["y"]],
784
+ mode="markers",
785
+ marker=dict(symbol="star", size=11,
786
+ color=f"rgba(255,140,90,{alpha:.2f})"),
787
+ text=[f'"{h["text"]}" → {h["domain"]}.{h["intent"]}'],
788
+ hovertemplate="%{text}<extra>past query</extra>",
789
+ showlegend=False,
790
+ ))
791
+
792
+ if q_coord_i is not None:
793
+ fig_i.add_trace(go.Scatter(
794
+ x=[q_coord_i[0]], y=[q_coord_i[1]],
795
+ mode="markers+text",
796
+ name="Your query",
797
+ text=[f'"{intent_query}"'],
798
+ textposition="bottom right",
799
+ textfont=dict(size=11, color="#ff6b6b"),
800
+ marker=dict(symbol="star", size=22, color="#ff3333",
801
+ line=dict(width=2, color="white")),
802
+ hovertemplate=f'<b>Query:</b> "{intent_query}"<extra></extra>',
803
+ ))
804
+
805
+ var_i = pca_i.explained_variance_ratio_
806
+ fig_i.update_layout(
807
+ height=640,
808
+ template="plotly_dark",
809
+ paper_bgcolor="rgba(0,0,0,0)",
810
+ plot_bgcolor="rgba(12,14,21,1)",
811
+ title=dict(
812
+ text=(
813
+ f"Intent Embedding Space — PCA 2D "
814
+ f"<span style='font-size:11px;color:#888'>"
815
+ f"(PC1 {var_i[0]:.1%} + PC2 {var_i[1]:.1%} variance explained)</span>"
816
+ ),
817
+ font=dict(size=14), x=0.01,
818
+ ),
819
+ xaxis=dict(title="PC 1", showgrid=True, gridcolor="rgba(255,255,255,0.06)",
820
+ zeroline=False, showticklabels=False),
821
+ yaxis=dict(title="PC 2", showgrid=True, gridcolor="rgba(255,255,255,0.06)",
822
+ zeroline=False, showticklabels=False),
823
+ legend=dict(font=dict(size=10), bgcolor="rgba(0,0,0,0.45)",
824
+ bordercolor="rgba(255,255,255,0.1)", borderwidth=1, tracegroupgap=4),
825
+ margin=dict(l=40, r=40, t=60, b=40),
826
+ hovermode="closest",
827
+ )
828
+
829
+ chart_col_i, info_col_i = st.columns([5, 2])
830
+
831
+ with chart_col_i:
832
+ st.plotly_chart(fig_i, use_container_width=True)
833
+ st.caption("● YAML utterances · ◆ scenario utterances · ★ your query")
834
+
835
+ with info_col_i:
836
+ if classified:
837
+ conf = classified.get("confidence", "?")
838
+ conf_color = {"high": "#34d399", "medium": "#f59e0b", "low": "#f87171"}.get(conf, "#aaa")
839
+ st.markdown(f"### Classification")
840
+ st.markdown(
841
+ f"**Domain:** `{classified.get('domain', '?')}` \n"
842
+ f"**Intent:** `{classified.get('intent', '?')}` \n"
843
+ f"**Confidence:** <span style='color:{conf_color}'>{conf}</span>",
844
+ unsafe_allow_html=True,
845
+ )
846
+ st.divider()
847
+
848
+ st.markdown("### Intent breakdown")
849
+ domain_counts: dict[str, int] = {}
850
+ for r in rows_i:
851
+ domain_counts[r["domain"]] = domain_counts.get(r["domain"], 0) + 1
852
+
853
+ for dom in unique_domains:
854
+ hex_c = domain_color[dom]
855
+ intent_labels = list(dict.fromkeys(
856
+ r["intent"] for r in rows_i if r["domain"] == dom
857
+ ))
858
+ with st.expander(
859
+ f"**{dom}** — {domain_counts[dom]} utterances",
860
+ expanded=classified is not None and classified.get("domain") == dom,
861
+ ):
862
+ for il in intent_labels:
863
+ count = sum(1 for r in rows_i if r["domain"] == dom and r["intent"] == il)
864
+ is_match = classified and classified.get("intent") == il and classified.get("domain") == dom
865
+ prefix = "→ " if is_match else ""
866
+ weight = "**" if is_match else ""
867
+ st.markdown(
868
+ f"{prefix}{weight}`{il}`{weight} <small style='color:#888'>({count})</small>",
869
+ unsafe_allow_html=True,
870
+ )
871
+
872
+
873
  # ---------------------------------------------------------------------------
874
  # Files tab
875
  # ---------------------------------------------------------------------------