Spaces:
Runtime error
Runtime error
ConstCorrectness Claude Sonnet 4.6 commited on
Commit ·
1f0067f
1
Parent(s): 762a6f5
Add Intents visualization tab with live classification overlay
Browse filesCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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 |
# ---------------------------------------------------------------------------
|