Build a Support Signal Radar

Build a local NLP dashboard that turns support tickets into product insights.

Introduction

30 Second Summary

Customer complaints rarely arrive in neat categories. The same problem can sound completely different from one person to the next.

In this project, you will build Support Signal Radar, a local Streamlit dashboard that turns real BANKING77 support tickets into searchable evidence. The finished dashboard helps you identify which recurring issues deserve attention.

What You'll Build

When you type Why did the ATM take extra money? into the dashboard, you'll see related withdrawal or fee tickets even when customers used different words.

By the end of this project, you'll have:

  • A search comparison that exposes where keyword search misses a paraphrase. Semantic search then retrieves related tickets through sentence embeddings and cosine similarity.
  • A ranked view of 12 issue themes discovered with K-Means clustering. Each theme shows ticket volume, cohesion, a known intent, and a representative customer message.
  • A quality snapshot that uses a silhouette score to show geometric separation. An adjusted Rand index shows how the discovered themes align with the dataset's known intents.
  • Secret Mission: A concise product decision memo that turns the highest-ranked theme into evidence, risk, an owner, an intervention, and a measurable follow-up signal.

Are there any prerequisites?

You only need a Windows device. The setup step covers every required tool and package from scratch.

Before We Start

This opening commitment gives Support Signal Radar a clear purpose before the hands-on work begins. Your one-sentence intention will connect customer-support language to a specific product or support decision.

Set Up the Local NLP Workspace

Support Signal Radar needs a dependable Python runtime before it can analyze customer tickets. Streamlit turns those results into a local dashboard.

A virtual environment keeps this project's packages separate from other Python projects. That separation prevents package conflicts.

Windows PowerShell gives you one command surface for the setup. Visual Studio Code keeps every project file inside the same workspace.

In this step, get ready to:
  • Prepare Python 3.13.16 plus Visual Studio Code.
  • Create an isolated environment with pinned NLP packages.
  • Launch the Streamlit example app in your browser.
Install Python and Visual Studio Code

This project uses Python 3.13.16 so every pinned package runs against one known interpreter. Start by checking what Windows PowerShell can already access.

  • Press the Windows key to open Windows search.
  • Type Windows PowerShell into the search field.
  • Press Enter to open Windows PowerShell.
  • Check the available Python version by running this command:
python --version

What does this command do?

The version flag prints the Python interpreter that the python command currently uses. The result tells you which setup path to follow.

Choose the tab that matches your PowerShell result.

✔️ I see the required version

Your terminal shows Python 3.13.16. Python is ready for the project environment.

ⓧ I see an older version

The pinned package set needs Python 3.13.16. Install that version before creating the virtual environment.

  • Open the official Python release page.
  • Select Windows installer (64-bit) in the Windows downloads table.
  • Run the downloaded installer.
  • Select Add Python to PATH when the installer offers that option.
  • Complete the installation using the default options.
  • Close every open PowerShell window.
  • Repeat the Windows search steps above to open a fresh Windows PowerShell window.
  • Repeat the version check shown above.

PowerShell should now report Python 3.13.16.

ⓧ Command not found

Windows cannot currently find a Python interpreter. Install the recommended 3.13.16 Windows build.

  • Open the official Python release page.
  • Select Windows installer (64-bit) in the Windows downloads table.
  • Run the downloaded installer.
  • Select Add Python to PATH when the installer offers that option.
  • Complete the installation using the default options.
  • Close every open PowerShell window.
  • Repeat the Windows search steps above to open a fresh Windows PowerShell window.
  • Repeat the version check shown above.

PowerShell should now report Python 3.13.16.

Still seeing the wrong Python version?

Close every PowerShell window after installation. A terminal that stayed open can retain the previous command path.

Reopen the installer if Python was not added to your command path. Enable its path option before repeating the version check.

Help me fix the Python version available in Windows PowerShell.

The editor is the second system-level tool in this workspace. Check whether Visual Studio Code is already available before installing it.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter if Visual Studio Code appears in the results.

✔️ Visual Studio Code opens

Visual Studio Code is ready. Keep the window open while you create the project workspace.

ⓧ Visual Studio Code is not installed

The Windows User setup installs Visual Studio Code for your account. It also makes the editor available from PowerShell after you restart the terminal.

  • Open the official Visual Studio Code Windows setup guide.
  • Download the User setup for Windows.
  • Run the downloaded installer.
  • Complete the installation using the default options.
  • Close every open PowerShell window.
  • Press the Windows key to reopen Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.

The project folder gives every file a predictable home on your Desktop. The commands below locate your actual Windows Desktop path.

  • Switch back to Windows PowerShell.
  • Create the support-signal-radar folder on your Desktop by running these commands:
$desktop = [Environment]::GetFolderPath('Desktop')
New-Item -Path $desktop -Name 'support-signal-radar' -ItemType Directory
Set-Location -Path (Join-Path $desktop 'support-signal-radar')
code .

What do these commands do?

  • The first command asks Windows for the current account's Desktop path.
  • The second command creates the support-signal-radar folder in that location.
  • The third command moves PowerShell into the new folder.
  • The final command opens the current folder in Visual Studio Code.
  • Return to Visual Studio Code.
  • Confirm the file sidebar shows support-signal-radar as the open folder.

Did the project folder fail to open?

Restart PowerShell if Visual Studio Code was installed while the terminal was open. This refreshes the editor command added during setup.

Check the PowerShell output for a folder-creation error. The account must be able to create folders on its Desktop.

Help me open support-signal-radar from Windows PowerShell in Visual Studio Code.

Create the isolated project environment

The requirements.txt file records the exact dependency versions for this project. Those pins make the setup reproducible.

  • Select the new-file icon at the top of the Visual Studio Code file sidebar.
  • Enter requirements.txt as the file name.
  • Paste this package list into requirements.txt:
datasets==5.1.0
scikit-learn==1.9.1
sentence-transformers==6.1.0
streamlit==1.65.0

What does each package provide?

  • Hugging Face Datasets loads the BANKING77 ticket data.
  • scikit-learn supplies clustering plus evaluation metrics.
  • Sentence Transformers creates embeddings for semantic search.
  • Streamlit renders the local dashboard.
  • Save requirements.txt.
  • Confirm requirements.txt appears directly inside the support-signal-radar file sidebar.

Is requirements.txt missing or empty?

Check that the file sits directly inside support-signal-radar. A nested file would leave PowerShell unable to find it later.

Make sure the editor tab no longer shows an unsaved-change indicator.

Help me check my requirements.txt file and its location.

✔️ Awesome, I've got everything!

Great. Your dependency file now captures the complete pinned package set.

ⓧ I'd like to double check the full code

datasets==5.1.0
scikit-learn==1.9.1
sentence-transformers==6.1.0
streamlit==1.65.0

The virtual environment now gives those packages an isolated installation location inside support-signal-radar.

  • Switch back to the PowerShell window inside support-signal-radar.
  • Create the .venv environment by running this command:
python -m venv .venv

What does this command do?

Python creates an isolated environment in the .venv folder. Project packages installed after activation stay inside that environment.

  • Return to Visual Studio Code.
  • Confirm a .venv folder now appears in the support-signal-radar file sidebar.

Is the .venv folder missing?

Confirm PowerShell is inside the support-signal-radar folder. The environment is created in the terminal's current location.

Repeat the Python version check if the command reports that Python cannot be found.

Help me create the .venv environment in the correct project folder.

  • Switch back to Windows PowerShell.
  • Activate the project environment by running this command:
.venv\Scripts\Activate.ps1

What does activation change?

Activation makes this terminal use the Python interpreter inside .venv. New package installations now belong to this project.

  • Confirm the PowerShell prompt now includes the virtual environment name.

Did PowerShell block activation?

Confirm the terminal is inside support-signal-radar. The activation path only exists from that folder.

A script-policy warning means Windows blocked the activation script. Use the help link below for guidance that matches your device policy.

Help me activate .venv safely in Windows PowerShell.

The package installation is the slowest command in this step because several machine-learning dependencies must download. Expect it to take several minutes while PowerShell prints progress.

  • Install the pinned dependencies into the active environment by running this command:
python -m pip install -r requirements.txt

What does this command do?

The command reads each pin from requirements.txt. It installs those exact package versions into the active environment.

  • Wait for PowerShell to finish downloading the packages.
  • Confirm the command returns to the activated PowerShell prompt without an installation failure.

Did a package fail to install?

Check that PowerShell still shows the active virtual environment. A closed terminal loses that activation state.

Compare every line in requirements.txt with the full-file tab above. A missing character changes the requested package pin.

Help me diagnose my pinned package installation failure.

Verify the workspace with Streamlit

The Streamlit example app tests the complete setup through one visible result. A successful launch proves the active Python environment can start the installed dashboard framework.

The command keeps PowerShell busy while its local server runs. Your browser may open automatically.

Before you run this, what do you expect the browser page to prove about the active environment?

  • Launch the Streamlit example app by running this command:
streamlit hello

What does this command prove?

Streamlit starts its bundled example as a local web app. The browser result confirms that the package can run from the activated environment.

Your browser opens the Streamlit example app. That is the full local toolchain working from PowerShell to browser.

Did the Streamlit page fail to open?

Read the PowerShell output for the local page address if the browser stays closed. Paste that address into your browser.

Confirm the terminal prompt showed the virtual environment name before you launched Streamlit.

Help me launch Streamlit Hello from my activated environment.

  • Return to Windows PowerShell after taking the screenshot.
  • Press Ctrl+C to stop the Streamlit example server.
  • Confirm the activated PowerShell prompt returns with the virtual environment name still visible.

Your local NLP workspace is ready. Next, you will turn the empty workspace into a visible keyword ticket finder.

Build a Keyword Ticket Finder

Your local workspace is ready. Now your Support Signal Radar needs a visible first search result.

Customer tickets often describe the same issue with different words. In this step, a local Streamlit app loads BANKING77 through Hugging Face Datasets.

You then test a keyword search baseline against that variation.

In this step, get ready to:
  • Create a cached BANKING77 ticket loader.
  • Add exact-term keyword ranking.
  • Test the baseline with an ATM-fee paraphrase.
Prepare the app foundation

The dashboard needs consistent search settings before it can load or rank tickets. These values define the dataset source plus the number of results to keep.

  • Click New File in the Visual Studio Code Explorer sidebar.
  • Enter app.py in the file name field.
  • Press Enter to create the file.
  • Build the search settings by pasting this code into app.py:
import streamlit as st
from datasets import load_dataset

DATASET_ID = "mteb/banking77"
SEARCH_LIMIT = 5
STOP_WORDS = {
    "and",
    "can",
    "did",
    "for",
    "from",
    "has",
    "have",
    "how",
    "the",
    "that",
    "this",
    "was",
    "why",
    "with",
    "you",
    "your",
}

What does this code do?

  • The imports provide the dashboard interface plus the dataset loader.
  • DATASET_ID points the loader at mteb/banking77.
  • SEARCH_LIMIT keeps the visible baseline focused on five results.
  • STOP_WORDS holds common terms that add little search value.

A cached loader prevents the app from fetching and converting the same rows on every rerun. Each converted record keeps the ticket text plus its known intent fields.

  • Complete the first runnable dashboard by pasting this code below the STOP_WORDS set:
@st.cache_resource(
    scope="session",
    show_spinner="Building embeddings and issue themes...",
)
def build_pipeline():
    dataset = load_dataset(DATASET_ID, split="train")
    records = [dict(record) for record in dataset]

    return {
        "records": records,
    }


st.set_page_config(
    page_title="Support Signal Radar",
    layout="wide",
)
st.write("# Support Signal Radar")
st.write(
    "Turn customer-support language into semantic search results, "
    "ranked issue themes, and decision evidence."
)

pipeline = build_pipeline()

How does the loader work?

  • st.cache_resource keeps the loaded records available for the current browser session.
  • load_dataset downloads the train split from the configured dataset.
  • dict(record) converts each row into a record containing text, label, and label_text fields.
  • st.set_page_config gives the browser tab its project title plus a wide layout.

The first run downloads the BANKING77 train data into a local cache. Expect a short wait while that cache fills.

  • Save app.py.
  • Switch back to the activated Windows PowerShell terminal from earlier.
  • Run the first dashboard by using this command:
streamlit run app.py

What does this command do?

The command starts the local dashboard server from app.py. It opens the app in your browser.

Good progress. Your browser now shows the Support Signal Radar title plus its short description.

Dashboard not opening?

Confirm that the terminal is still inside the support-signal-radar folder. Check that the virtual environment remains active.

If another Streamlit process is using the terminal, stop it with Ctrl+C before running the command again.

Help me diagnose why the initial Streamlit dashboard is not opening.

  • Stop the initial server by pressing Ctrl+C in PowerShell.
Add the keyword baseline

The baseline turns the query into useful terms. It scores each ticket by counting how many of those exact terms appear in its text.

  • Add keyword_search above @st.cache_resource by pasting this code:
def keyword_search(query, records, limit=SEARCH_LIMIT):
    terms = [
        term
        for term in query.lower().split()
        if len(term) > 2 and term not in STOP_WORDS
    ]
    ranked = []

    for record in records:
        text = record["text"]
        score = sum(term in text.lower() for term in terms)
        if score:
            ranked.append(
                {
                    "keyword_hits": score,
                    "ticket": text,
                    "known_intent": record["label_text"],
                }
            )

    ranked.sort(key=lambda result: result["keyword_hits"], reverse=True)
    return ranked[:limit]

How does keyword ranking work?

  • The term list converts the query to lowercase before removing short words plus entries from STOP_WORDS.
  • keyword_hits counts how many retained terms occur inside each ticket.
  • known_intent preserves the dataset label for comparison with the ticket wording.
  • The final sort places tickets with the most literal term overlap first.

The search interface supplies a natural-language query to the function. It renders the ranked records as a table when any exact matches survive the filter.

  • Connect the baseline to the visible dashboard by adding this code at the end of app.py:
st.write("## Search a customer problem")
query = st.text_input(
    "Describe the issue in natural language",
    value="Why did the ATM take extra money?",
    type="search",
)

if query.strip():
    keyword_results = keyword_search(query, pipeline["records"])

    st.write("### Keyword baseline")
    if keyword_results:
        st.dataframe(
            keyword_results,
            hide_index=True,
            width="stretch",
        )
    else:
        st.write("No literal keyword matches survived the baseline filter.")

What does the interface show?

  • st.text_input creates a search field with the ATM-fee question as its default value.
  • keyword_search receives the query plus the cached ticket records.
  • st.dataframe displays each result's literal hit count plus its ticket and known intent.
  • The fallback message makes an empty literal result visible.
  • Save the completed app.py file.

✔️ Awesome, I've got everything!

Your saved file now contains the cached loader plus the complete keyword baseline.

ⓧ I'd like to double check the full code

import streamlit as st
from datasets import load_dataset

DATASET_ID = "mteb/banking77"
SEARCH_LIMIT = 5
STOP_WORDS = {
    "and",
    "can",
    "did",
    "for",
    "from",
    "has",
    "have",
    "how",
    "the",
    "that",
    "this",
    "was",
    "why",
    "with",
    "you",
    "your",
}


def keyword_search(query, records, limit=SEARCH_LIMIT):
    terms = [
        term
        for term in query.lower().split()
        if len(term) > 2 and term not in STOP_WORDS
    ]
    ranked = []

    for record in records:
        text = record["text"]
        score = sum(term in text.lower() for term in terms)
        if score:
            ranked.append(
                {
                    "keyword_hits": score,
                    "ticket": text,
                    "known_intent": record["label_text"],
                }
            )

    ranked.sort(key=lambda result: result["keyword_hits"], reverse=True)
    return ranked[:limit]


@st.cache_resource(
    scope="session",
    show_spinner="Building embeddings and issue themes...",
)
def build_pipeline():
    dataset = load_dataset(DATASET_ID, split="train")
    records = [dict(record) for record in dataset]

    return {
        "records": records,
    }


st.set_page_config(
    page_title="Support Signal Radar",
    layout="wide",
)
st.write("# Support Signal Radar")
st.write(
    "Turn customer-support language into semantic search results, "
    "ranked issue themes, and decision evidence."
)

pipeline = build_pipeline()

st.write("## Search a customer problem")
query = st.text_input(
    "Describe the issue in natural language",
    value="Why did the ATM take extra money?",
    type="search",
)

if query.strip():
    keyword_results = keyword_search(query, pipeline["records"])

    st.write("### Keyword baseline")
    if keyword_results:
        st.dataframe(
            keyword_results,
            hide_index=True,
            width="stretch",
        )
    else:
        st.write("No literal keyword matches survived the baseline filter.")
Test the literal baseline

The default question describes an ATM fee without using the formal wording found across every relevant ticket. This is the baseline's real test.

  • Return to the activated PowerShell terminal from earlier.

Before you run the app, predict whether exact term counting will connect the ATM-fee wording to tickets about a cash withdrawal charge.

  • Run the completed keyword dashboard by using this command:
streamlit run app.py

What does this run prove?

Streamlit executes the cached dataset loader before applying keyword_search to the default query. The browser displays the baseline result as soon as the run completes.

You'll see Why did the ATM take extra money? in the search field. The keyword results are empty, sparse, or noisy because customer wording varies.

This shortfall is intentional. You now have direct evidence that literal overlap can miss the meaning behind a customer paraphrase.

  • Describe the relevant ATM-fee meaning that the returned tickets fail to capture in the checkpoint below.

Baseline not appearing?

Confirm that app.py is saved. Refresh the browser after the terminal reports that the local app is running.

If the dataset cannot load, confirm that your internet connection is available for the first download. Restart the command after the connection recovers.

Help me debug why my BANKING77 keyword baseline is not appearing.

Your baseline now proves where exact matching falls short. Next, sentence embeddings plus cosine similarity turn that gap into meaning-based retrieval.

Add Semantic Ticket Search

Your Streamlit dashboard now exposes the weakness of keyword search. Customer paraphrases can describe the same problem with completely different words.

Sentence Transformers turns each ticket into a sentence embedding. Cosine similarity compares those embeddings so semantic search can rank tickets by meaning.

In this step, get ready to:
  • Encode BANKING77 tickets as normalized sentence embeddings.
  • Rank tickets by their similarity to an analyst query.
  • Compare semantic results with the keyword baseline.
Connect the embedding model

The embedding model needs a stable identifier so every rerun uses the same model. Your existing dependency already provides the model-loading interface.

  • In app.py, select the opening setup section from import streamlit as st through the closing brace of STOP_WORDS.
  • Replace the selected setup section with this updated setup:
import streamlit as st
from datasets import load_dataset
from sentence_transformers import SentenceTransformer

DATASET_ID = "mteb/banking77"
MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
SEARCH_LIMIT = 5
STOP_WORDS = {
    "and",
    "can",
    "did",
    "for",
    "from",
    "has",
    "have",
    "how",
    "the",
    "that",
    "this",
    "was",
    "why",
    "with",
    "you",
    "your",
}

What Changed in the Setup?

  • SentenceTransformer gives the app access to the embedding model.
  • MODEL_ID identifies the exact model used for ticket encoding.
  • The remaining constants preserve the keyword baseline from the previous step.
  • Save app.py.

The running dashboard reloads with the keyword baseline still visible. This confirms that the model package imports successfully.

Does the Dashboard Stop Reloading?

Confirm that the import uses sentence_transformers with an underscore. Check that your activated environment still uses the dependencies from requirements.txt.

Help me diagnose the model import in app.py.

Your cached loader currently prepares ticket records for literal matching. The expanded pipeline also creates normalized embeddings that can be reused by every search.

  • Select the complete cached loader from its @st.cache_resource decorator through its return value.
  • Replace the selected loader with this expanded pipeline:
@st.cache_resource(
    scope="session",
    show_spinner="Building embeddings and issue themes...",
)
def build_pipeline():
    dataset = load_dataset(DATASET_ID, split="train")
    records = [dict(record) for record in dataset]
    texts = [record["text"] for record in records]
    true_labels = [record["label"] for record in records]

    model = SentenceTransformer(
        MODEL_ID,
        similarity_fn_name="cosine",
    )
    embeddings = model.encode(
        texts,
        batch_size=64,
        show_progress_bar=False,
        normalize_embeddings=True,
    )

    return {
        "records": records,
        "model": model,
        "embeddings": embeddings,
    }

How Does the Embedding Pipeline Work?

  • texts collects the customer messages that the model encodes.
  • true_labels preserves the numeric known intent for each ticket.
  • normalize_embeddings=True prepares the vectors for consistent cosine comparisons.
  • st.cache_resource reuses the model plus its ticket embeddings within the active session.

The first embedding build can take a few minutes because the model downloads into your local Hugging Face cache. A quiet terminal during part of this wait is normal.

  • Save app.py.

You will see Building embeddings and issue themes... while the pipeline runs. The keyword baseline returns when the ticket embeddings are ready.

Good progress. Your app now converts the full ticket collection into reusable meaning-based vectors.

Is the Embedding Build Stuck?

Keep the network connection active during the first model download. Confirm that MODEL_ID matches the model identifier in the setup section.

Help me troubleshoot the cached embedding pipeline.

Rank tickets by meaning

A query embedding places the analyst's wording in the same vector space as the tickets. Similarity scores can then rank the closest customer messages without requiring exact shared terms.

  • In app.py, locate the closing line of keyword_search().
  • Add this function directly below keyword_search().
def semantic_search(query, pipeline, limit=SEARCH_LIMIT):
    query_embedding = pipeline["model"].encode(
        [query],
        normalize_embeddings=True,
    )
    similarities = pipeline["model"].similarity(
        query_embedding,
        pipeline["embeddings"],
    )[0]
    ranked_indices = sorted(
        range(len(pipeline["records"])),
        key=lambda index: float(similarities[index]),
        reverse=True,
    )[:limit]

    return [
        {
            "similarity": round(float(similarities[index]), 3),
            "ticket": pipeline["records"][index]["text"],
            "known_intent": pipeline["records"][index]["label_text"],
        }
        for index in ranked_indices
    ]

What Does Semantic Ranking Do?

  • query_embedding represents the analyst's full query as one normalized vector.
  • model.similarity() compares that vector with every cached ticket embedding.
  • ranked_indices orders the tickets from highest similarity to lowest similarity.
  • The returned records expose the score plus the ticket text plus its known intent.
  • Save app.py.

The existing keyword baseline reloads successfully. This confirms that the new function has valid Python syntax.

Does the App Fail After the New Function?

Check the indentation inside the returned list. Confirm that every reference uses the existing pipeline keys exactly.

Help me debug semantic_search in app.py.

  • Return to the Windows PowerShell terminal running the dashboard.
  • Stop the current server by pressing Ctrl+C.

You are back at the activated PowerShell prompt. The app files and cached model remain available locally.

The final interface addition calls the semantic function beneath the keyword results. Keeping both tables together makes the retrieval difference visible for the same query.

  • Scroll to the keyword result block near the bottom of app.py.
  • Add this semantic result block directly below the keyword result block:
    st.write("### Semantic search")
    st.dataframe(
        semantic_search(query, pipeline),
        hide_index=True,
        width="stretch",
    )

Why Show Both Tables?

The keyword table reveals what literal overlap can retrieve. The semantic table reveals which tickets carry the closest meaning.

Using one query keeps the comparison fair. The retrieval method becomes the only changing factor.

  • Save app.py.
Compare both search methods

The model download is now stored in the local cache. Encoding the ticket collection can still take a few minutes after the server restarts.

Before you run the dashboard, which table do you think will surface withdrawal or fee tickets more consistently?

  • Start the updated dashboard by running this command:
streamlit run app.py

Your browser opens Support Signal Radar with the default ATM-fee query. The keyword baseline appears first.

The semantic table returns five related withdrawal or fee tickets. Some results use different words from the query.

What Does the Comparison Prove?

Literal matching depends on shared words between the query and each ticket. Customer language often uses paraphrases that weaken those matches.

Embedding similarity preserves more of the query's meaning. The stronger retrieval table makes that improvement visible.

That is the retrieval gap closed. Your dashboard can now connect an analyst's wording with customer tickets that describe the same underlying issue.

Are Semantic Results Missing?

Confirm that the semantic table sits inside the existing if query.strip(): block. Check that the block uses pipeline as its second function argument.

Help me fix missing semantic results in the dashboard.

✔️ Awesome, I've got everything!

Great. Save app.py with both result tables visible in the running dashboard.

ⓧ I'd like to double check the full code

import streamlit as st
from datasets import load_dataset
from sentence_transformers import SentenceTransformer

DATASET_ID = "mteb/banking77"
MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
SEARCH_LIMIT = 5
STOP_WORDS = {
    "and",
    "can",
    "did",
    "for",
    "from",
    "has",
    "have",
    "how",
    "the",
    "that",
    "this",
    "was",
    "why",
    "with",
    "you",
    "your",
}


def keyword_search(query, records, limit=SEARCH_LIMIT):
    terms = [
        term
        for term in query.lower().split()
        if len(term) > 2 and term not in STOP_WORDS
    ]
    ranked = []

    for record in records:
        text = record["text"]
        score = sum(term in text.lower() for term in terms)
        if score:
            ranked.append(
                {
                    "keyword_hits": score,
                    "ticket": text,
                    "known_intent": record["label_text"],
                }
            )

    ranked.sort(key=lambda result: result["keyword_hits"], reverse=True)
    return ranked[:limit]


def semantic_search(query, pipeline, limit=SEARCH_LIMIT):
    query_embedding = pipeline["model"].encode(
        [query],
        normalize_embeddings=True,
    )
    similarities = pipeline["model"].similarity(
        query_embedding,
        pipeline["embeddings"],
    )[0]
    ranked_indices = sorted(
        range(len(pipeline["records"])),
        key=lambda index: float(similarities[index]),
        reverse=True,
    )[:limit]

    return [
        {
            "similarity": round(float(similarities[index]), 3),
            "ticket": pipeline["records"][index]["text"],
            "known_intent": pipeline["records"][index]["label_text"],
        }
        for index in ranked_indices
    ]


@st.cache_resource(
    scope="session",
    show_spinner="Building embeddings and issue themes...",
)
def build_pipeline():
    dataset = load_dataset(DATASET_ID, split="train")
    records = [dict(record) for record in dataset]
    texts = [record["text"] for record in records]
    true_labels = [record["label"] for record in records]

    model = SentenceTransformer(
        MODEL_ID,
        similarity_fn_name="cosine",
    )
    embeddings = model.encode(
        texts,
        batch_size=64,
        show_progress_bar=False,
        normalize_embeddings=True,
    )

    return {
        "records": records,
        "model": model,
        "embeddings": embeddings,
    }


st.set_page_config(
    page_title="Support Signal Radar",
    layout="wide",
)
st.write("# Support Signal Radar")
st.write(
    "Turn customer-support language into semantic search results, "
    "ranked issue themes, and decision evidence."
)

pipeline = build_pipeline()

st.write("## Search a customer problem")
query = st.text_input(
    "Describe the issue in natural language",
    value="Why did the ATM take extra money?",
    type="search",
)

if query.strip():
    keyword_results = keyword_search(query, pipeline["records"])

    st.write("### Keyword baseline")
    if keyword_results:
        st.dataframe(
            keyword_results,
            hide_index=True,
            width="stretch",
        )
    else:
        st.write("No literal keyword matches survived the baseline filter.")

    st.write("### Semantic search")
    st.dataframe(
        semantic_search(query, pipeline),
        hide_index=True,
        width="stretch",
    )

Your meaning-based ticket search is working. Next, you will use the same embeddings to discover recurring issue themes across the full dataset.

Discover and Rank Issue Themes

Your semantic search now answers one customer question at a time. Product teams also need a view of recurring issues they did not know to search for.

K-Means groups the sentence embeddings into 12 themes. A transparent signal score ranks those themes using ticket volume multiplied by cohesion.

In this step, get ready to:
  • Cluster the normalized ticket embeddings into 12 recurring issue themes.
  • Calculate transparent evidence for every theme.
  • Display the themes in signal-score order.
Cluster the ticket embeddings

K-Means assigns every ticket to one of 12 clusters based on its embedding. A fixed random state makes the grouping reproducible across runs.

  • In app.py, replace the imports plus constants at the top with this expanded section:
from collections import Counter

import streamlit as st
from datasets import load_dataset
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans

DATASET_ID = "mteb/banking77"
MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
CLUSTER_COUNT = 12
SEARCH_LIMIT = 5

What does this code prepare?

  • Counter identifies the most common known intent inside each cluster.
  • KMeans provides the clustering algorithm used to discover issue themes.
  • CLUSTER_COUNT keeps the chosen total of 12 themes available throughout the dashboard.
  • In build_pipeline(), place this clustering code below embeddings = model.encode(...):
    clusterer = KMeans(
        n_clusters=CLUSTER_COUNT,
        random_state=42,
        n_init=10,
    )
    cluster_labels = clusterer.fit_predict(embeddings)
    distances = clusterer.transform(embeddings)

What does this code do?

  • clusterer configures the analysis to produce 12 themes.
  • random_state=42 keeps the cluster assignment reproducible.
  • n_init=10 tests 10 starting arrangements before keeping the strongest fit.
  • cluster_labels stores the assigned cluster for every ticket.
  • distances stores each ticket's distance from every cluster center.
  • Save app.py.
  • Return to the dashboard browser tab from earlier.

The Streamlit dashboard rebuilds. Your keyword table plus semantic table return without a Python error.

Dashboard does not finish rebuilding?

Check that the clustering block sits inside build_pipeline() after the embedding calculation. Match the indentation shown in the code block.

Help me troubleshoot the K-Means block in my Streamlit pipeline.

Build evidence for each theme

A cluster number alone cannot support a product decision. Each theme needs volume evidence plus a ticket nearest its centroid.

  • In build_pipeline(), place this code below distances = clusterer.transform(embeddings):
    summaries = []
    for cluster_id in range(CLUSTER_COUNT):
        indices = [
            index
            for index, label in enumerate(cluster_labels)
            if int(label) == cluster_id
        ]
        mean_distance = sum(
            float(distances[index][cluster_id]) for index in indices
        ) / len(indices)
        cohesion = 1 / (1 + mean_distance)
        representative_index = min(
            indices,
            key=lambda index: float(distances[index][cluster_id]),
        )

How is cluster evidence calculated?

  • indices collects the tickets assigned to the current cluster.
  • mean_distance measures how far those tickets sit from their shared center on average.
  • cohesion converts that distance into a higher-is-tighter heuristic.
  • representative_index selects the ticket nearest the cluster center.
  • Place this evidence code inside the cluster loop after the representative_index calculation:
        dominant_intent, dominant_count = Counter(
            records[index]["label_text"] for index in indices
        ).most_common(1)[0]

        summaries.append(
            {
                "cluster": cluster_id,
                "ticket_count": len(indices),
                "share_pct": round(100 * len(indices) / len(records), 1),
                "cohesion": round(cohesion, 3),
                "signal_score": round(len(indices) * cohesion, 1),
                "dominant_known_intent": dominant_intent,
                "intent_purity_pct": round(
                    100 * dominant_count / len(indices),
                    1,
                ),
                "representative_ticket": records[representative_index]["text"],
            }
        )

What goes into each summary?

  • ticket_count plus share_pct show the theme's volume.
  • signal_score multiplies ticket count by cohesion. It acts as a prioritization heuristic.
  • dominant_known_intent names the most common dataset label inside the theme.
  • intent_purity_pct shows how much of the cluster shares that dominant intent.
  • representative_ticket provides a concrete customer message from the center of the theme.
  • Save app.py.
  • Return to the dashboard browser tab.

The search interface returns after the cluster loop evaluates every theme. This confirms that all 12 evidence summaries can be built from the ticket embeddings.

Seeing a cluster summary error?

Check that the dominant_intent calculation stays inside the for cluster_id in range(CLUSTER_COUNT): loop. The summaries.append(...) block must use the same indentation.

Help me fix the issue-theme summary loop.

Display the ranked themes

The summaries now hold evidence for every theme. Sorting by signal score brings large cohesive clusters to the top while preserving the underlying evidence.

  • In build_pipeline(), replace the current return block with this ranked return block:
    summaries.sort(
        key=lambda summary: summary["signal_score"],
        reverse=True,
    )

    return {
        "records": records,
        "model": model,
        "embeddings": embeddings,
        "summaries": summaries,
    }

How does the ranking work?

The descending sort places the highest signal_score first. Returning summaries makes the ranked evidence available to the dashboard interface.

  • Above st.write("## Search a customer problem"), add the ranked issue-theme table:
st.write("## Ranked issue themes")
st.write(
    "Signal score is a transparent heuristic: ticket volume multiplied "
    "by distance-based cohesion."
)
st.dataframe(
    pipeline["summaries"],
    hide_index=True,
    width="stretch",
)

What does this interface add?

The table exposes the ranking together with the evidence behind it. The explanation labels signal score as a transparent heuristic instead of ground truth.

  • Save app.py.
  • Switch back to the Windows PowerShell terminal from earlier.
  • Stop the current Streamlit process by pressing Ctrl+C.

Before you restart, consider which theme you expect to rank first.

  • Restart the dashboard by running this command:
streamlit run app.py

What should I see?

The dashboard shows a ranked table with 12 cluster rows above the customer-problem search interface. Each row includes ticket count, share, cohesion, signal score, dominant known intent, intent purity, plus a representative ticket.

Your keyword baseline plus semantic search remain available below the table. The dashboard now supports discovery as well as retrieval.

Do not see the ranked table?

Confirm that the table block appears after pipeline = build_pipeline() but before the search heading. Check that the returned dictionary includes "summaries": summaries.

Help me find why the ranked issue-theme table is missing.

✔️ Awesome, I've got everything!

Your dashboard now discovers 12 issue themes. It ranks them with transparent supporting evidence.

ⓧ I'd like to double check the full code

from collections import Counter

import streamlit as st
from datasets import load_dataset
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans

DATASET_ID = "mteb/banking77"
MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
CLUSTER_COUNT = 12
SEARCH_LIMIT = 5
STOP_WORDS = {
    "and",
    "can",
    "did",
    "for",
    "from",
    "has",
    "have",
    "how",
    "the",
    "that",
    "this",
    "was",
    "why",
    "with",
    "you",
    "your",
}


def keyword_search(query, records, limit=SEARCH_LIMIT):
    terms = [
        term
        for term in query.lower().split()
        if len(term) > 2 and term not in STOP_WORDS
    ]
    ranked = []

    for record in records:
        text = record["text"]
        score = sum(term in text.lower() for term in terms)
        if score:
            ranked.append(
                {
                    "keyword_hits": score,
                    "ticket": text,
                    "known_intent": record["label_text"],
                }
            )

    ranked.sort(key=lambda result: result["keyword_hits"], reverse=True)
    return ranked[:limit]


def semantic_search(query, pipeline, limit=SEARCH_LIMIT):
    query_embedding = pipeline["model"].encode(
        [query],
        normalize_embeddings=True,
    )
    similarities = pipeline["model"].similarity(
        query_embedding,
        pipeline["embeddings"],
    )[0]
    ranked_indices = sorted(
        range(len(pipeline["records"])),
        key=lambda index: float(similarities[index]),
        reverse=True,
    )[:limit]

    return [
        {
            "similarity": round(float(similarities[index]), 3),
            "ticket": pipeline["records"][index]["text"],
            "known_intent": pipeline["records"][index]["label_text"],
        }
        for index in ranked_indices
    ]


@st.cache_resource(
    scope="session",
    show_spinner="Building embeddings and issue themes...",
)
def build_pipeline():
    dataset = load_dataset(DATASET_ID, split="train")
    records = [dict(record) for record in dataset]
    texts = [record["text"] for record in records]
    true_labels = [record["label"] for record in records]

    model = SentenceTransformer(
        MODEL_ID,
        similarity_fn_name="cosine",
    )
    embeddings = model.encode(
        texts,
        batch_size=64,
        show_progress_bar=False,
        normalize_embeddings=True,
    )

    clusterer = KMeans(
        n_clusters=CLUSTER_COUNT,
        random_state=42,
        n_init=10,
    )
    cluster_labels = clusterer.fit_predict(embeddings)
    distances = clusterer.transform(embeddings)

    summaries = []
    for cluster_id in range(CLUSTER_COUNT):
        indices = [
            index
            for index, label in enumerate(cluster_labels)
            if int(label) == cluster_id
        ]
        mean_distance = sum(
            float(distances[index][cluster_id]) for index in indices
        ) / len(indices)
        cohesion = 1 / (1 + mean_distance)
        representative_index = min(
            indices,
            key=lambda index: float(distances[index][cluster_id]),
        )
        dominant_intent, dominant_count = Counter(
            records[index]["label_text"] for index in indices
        ).most_common(1)[0]

        summaries.append(
            {
                "cluster": cluster_id,
                "ticket_count": len(indices),
                "share_pct": round(100 * len(indices) / len(records), 1),
                "cohesion": round(cohesion, 3),
                "signal_score": round(len(indices) * cohesion, 1),
                "dominant_known_intent": dominant_intent,
                "intent_purity_pct": round(
                    100 * dominant_count / len(indices),
                    1,
                ),
                "representative_ticket": records[representative_index]["text"],
            }
        )

    summaries.sort(
        key=lambda summary: summary["signal_score"],
        reverse=True,
    )

    return {
        "records": records,
        "model": model,
        "embeddings": embeddings,
        "summaries": summaries,
    }


st.set_page_config(
    page_title="Support Signal Radar",
    layout="wide",
)
st.write("# Support Signal Radar")
st.write(
    "Turn customer-support language into semantic search results, "
    "ranked issue themes, and decision evidence."
)

pipeline = build_pipeline()

st.write("## Ranked issue themes")
st.write(
    "Signal score is a transparent heuristic: ticket volume multiplied "
    "by distance-based cohesion."
)
st.dataframe(
    pipeline["summaries"],
    hide_index=True,
    width="stretch",
)

st.write("## Search a customer problem")
query = st.text_input(
    "Describe the issue in natural language",
    value="Why did the ATM take extra money?",
    type="search",
)

if query.strip():
    keyword_results = keyword_search(query, pipeline["records"])

    st.write("### Keyword baseline")
    if keyword_results:
        st.dataframe(
            keyword_results,
            hide_index=True,
            width="stretch",
        )
    else:
        st.write("No literal keyword matches survived the baseline filter.")

    st.write("### Semantic search")
    st.dataframe(
        semantic_search(query, pipeline),
        hide_index=True,
        width="stretch",
    )

You have turned thousands of individual tickets into ranked issue themes with visible supporting evidence. Next, you will test whether those themes are genuinely separated and how closely they align with BANKING77's known intents.

Validate Theme Quality

Your Streamlit dashboard now discovers 12 issue themes. It ranks them with transparent evidence.

A polished cluster table can still mislead. This step adds evidence about geometric separation plus agreement with BANKING77's known intents.

In this step, get ready to:
  • Calculate a reproducible silhouette score from 2,000 ticket embeddings.
  • Compare the discovered K-Means groups with known intents using the adjusted Rand index.
  • Display both quality metrics above the ranked issue themes.
Prepare the validation inputs

Theme validation examines the discovered groups from two angles. The embeddings reveal their geometry.

BANKING77's known labels provide a reference taxonomy. You need to retain those numeric labels before the pipeline can compare them with the discovered clusters.

  • In app.py, locate from sklearn.cluster import KMeans.
  • Add the metric imports directly below that line by copying this code:
from sklearn.metrics import adjusted_rand_score, silhouette_score

What does this import add?

  • The scikit-learn silhouette function evaluates separation using the embedding geometry.
  • The adjusted Rand function compares the discovered cluster assignments with the known intent labels.
  • Inside build_pipeline(), locate texts = [record["text"] for record in records].
  • Add the numeric label list directly below that line by copying this code:
true_labels = [record["label"] for record in records]

Why keep the numeric labels?

Each value in true_labels identifies a ticket's known BANKING77 intent. Keeping the labels in record order makes each one line up with the corresponding value in cluster_labels.

  • Save app.py.
  • Refresh the running dashboard in your browser.

You should see the ranked table reload with all 12 issue themes. That successful reload confirms the metric imports plus label list are valid.

Does the dashboard stop during the reload?

Check that the metrics import sits directly below the KMeans import. Confirm that true_labels uses the singular key label.

Compare the brackets plus quotation marks with the snippets above. Python needs each pair to match.

Help me debug the validation imports or true label list in my dashboard.

Calculate and display quality metrics

The silhouette calculation uses a reproducible 2,000-ticket sample. This avoids an all-pairs calculation across the full training set.

The adjusted Rand calculation uses every ticket. It measures agreement between the 12 discovered groups plus the finer known-intent taxonomy.

  • Inside build_pipeline(), locate the returned dictionary below the summary sorting code.
  • Replace that dictionary with this completed version:
return {
    "records": records,
    "model": model,
    "embeddings": embeddings,
    "summaries": summaries,
    "silhouette": silhouette_score(
        embeddings,
        cluster_labels,
        sample_size=2000,
        random_state=42,
    ),
    "adjusted_rand": adjusted_rand_score(
        true_labels,
        cluster_labels,
    ),
}

What do these calculations measure?

  • The silhouette value summarizes geometric separation between the discovered clusters.
  • The sample_size value limits that calculation to 2,000 ticket embeddings.
  • The random_state value makes the sampled tickets reproducible.
  • The adjusted_rand value summarizes agreement between known intents plus discovered clusters.
  • Below the summary sorting code, locate pipeline = build_pipeline().
  • Replace that line with this snapshot section:
pipeline = build_pipeline()

st.write("## Portfolio snapshot")
st.dataframe(
    [
        {
            "tickets_analyzed": len(pipeline["records"]),
            "clusters": CLUSTER_COUNT,
            "silhouette_score": round(pipeline["silhouette"], 3),
            "adjusted_rand_index": round(pipeline["adjusted_rand"], 3),
        }
    ],
    hide_index=True,
)

What does the snapshot show?

The snapshot places the analyzed ticket count plus cluster count beside both quality metrics. Rounding the scores to three decimal places makes them readable without hiding their scale.

Its position above the ranked themes frames the table as evidence that needs validation. The metrics become part of the analysis instead of decoration.

✔️ Awesome, I've got everything!

Great. Double-check that your latest changes are saved in app.py.

ⓧ I'd like to double check the full code

from collections import Counter

import streamlit as st
from datasets import load_dataset
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score, silhouette_score

DATASET_ID = "mteb/banking77"
MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
CLUSTER_COUNT = 12
SEARCH_LIMIT = 5
STOP_WORDS = {
    "and",
    "can",
    "did",
    "for",
    "from",
    "has",
    "have",
    "how",
    "the",
    "that",
    "this",
    "was",
    "why",
    "with",
    "you",
    "your",
}


def keyword_search(query, records, limit=SEARCH_LIMIT):
    terms = [
        term
        for term in query.lower().split()
        if len(term) > 2 and term not in STOP_WORDS
    ]
    ranked = []

    for record in records:
        text = record["text"]
        score = sum(term in text.lower() for term in terms)
        if score:
            ranked.append(
                {
                    "keyword_hits": score,
                    "ticket": text,
                    "known_intent": record["label_text"],
                }
            )

    ranked.sort(key=lambda result: result["keyword_hits"], reverse=True)
    return ranked[:limit]


def semantic_search(query, pipeline, limit=SEARCH_LIMIT):
    query_embedding = pipeline["model"].encode(
        [query],
        normalize_embeddings=True,
    )
    similarities = pipeline["model"].similarity(
        query_embedding,
        pipeline["embeddings"],
    )[0]
    ranked_indices = sorted(
        range(len(pipeline["records"])),
        key=lambda index: float(similarities[index]),
        reverse=True,
    )[:limit]

    return [
        {
            "similarity": round(float(similarities[index]), 3),
            "ticket": pipeline["records"][index]["text"],
            "known_intent": pipeline["records"][index]["label_text"],
        }
        for index in ranked_indices
    ]


@st.cache_resource(
    scope="session",
    show_spinner="Building embeddings and issue themes...",
)
def build_pipeline():
    dataset = load_dataset(DATASET_ID, split="train")
    records = [dict(record) for record in dataset]
    texts = [record["text"] for record in records]
    true_labels = [record["label"] for record in records]

    model = SentenceTransformer(
        MODEL_ID,
        similarity_fn_name="cosine",
    )
    embeddings = model.encode(
        texts,
        batch_size=64,
        show_progress_bar=False,
        normalize_embeddings=True,
    )

    clusterer = KMeans(
        n_clusters=CLUSTER_COUNT,
        random_state=42,
        n_init=10,
    )
    cluster_labels = clusterer.fit_predict(embeddings)
    distances = clusterer.transform(embeddings)

    summaries = []
    for cluster_id in range(CLUSTER_COUNT):
        indices = [
            index
            for index, label in enumerate(cluster_labels)
            if int(label) == cluster_id
        ]
        mean_distance = sum(
            float(distances[index][cluster_id]) for index in indices
        ) / len(indices)
        cohesion = 1 / (1 + mean_distance)
        representative_index = min(
            indices,
            key=lambda index: float(distances[index][cluster_id]),
        )
        dominant_intent, dominant_count = Counter(
            records[index]["label_text"] for index in indices
        ).most_common(1)[0]

        summaries.append(
            {
                "cluster": cluster_id,
                "ticket_count": len(indices),
                "share_pct": round(100 * len(indices) / len(records), 1),
                "cohesion": round(cohesion, 3),
                "signal_score": round(len(indices) * cohesion, 1),
                "dominant_known_intent": dominant_intent,
                "intent_purity_pct": round(
                    100 * dominant_count / len(indices),
                    1,
                ),
                "representative_ticket": records[representative_index]["text"],
            }
        )

    summaries.sort(
        key=lambda summary: summary["signal_score"],
        reverse=True,
    )

    return {
        "records": records,
        "model": model,
        "embeddings": embeddings,
        "summaries": summaries,
        "silhouette": silhouette_score(
            embeddings,
            cluster_labels,
            sample_size=2000,
            random_state=42,
        ),
        "adjusted_rand": adjusted_rand_score(
            true_labels,
            cluster_labels,
        ),
    }


st.set_page_config(
    page_title="Support Signal Radar",
    layout="wide",
)
st.write("# Support Signal Radar")
st.write(
    "Turn customer-support language into semantic search results, "
    "ranked issue themes, and decision evidence."
)

pipeline = build_pipeline()

st.write("## Portfolio snapshot")
st.dataframe(
    [
        {
            "tickets_analyzed": len(pipeline["records"]),
            "clusters": CLUSTER_COUNT,
            "silhouette_score": round(pipeline["silhouette"], 3),
            "adjusted_rand_index": round(pipeline["adjusted_rand"], 3),
        }
    ],
    hide_index=True,
)

st.write("## Ranked issue themes")
st.write(
    "Signal score is a transparent heuristic: ticket volume multiplied "
    "by distance-based cohesion."
)
st.dataframe(
    pipeline["summaries"],
    hide_index=True,
    width="stretch",
)

st.write("## Search a customer problem")
query = st.text_input(
    "Describe the issue in natural language",
    value="Why did the ATM take extra money?",
    type="search",
)

if query.strip():
    keyword_results = keyword_search(query, pipeline["records"])

    st.write("### Keyword baseline")
    if keyword_results:
        st.dataframe(
            keyword_results,
            hide_index=True,
            width="stretch",
        )
    else:
        st.write("No literal keyword matches survived the baseline filter.")

    st.write("### Semantic search")
    st.dataframe(
        semantic_search(query, pipeline),
        hide_index=True,
        width="stretch",
    )
Run and interpret the completed dashboard

The final check puts both scores beside the evidence table they qualify. You can now judge the themes without treating one metric as a verdict.

  • Save app.py.
  • Switch back to the activated Windows PowerShell terminal from earlier.
  • Press Ctrl+C to stop the current Streamlit server.

The completed pipeline rebuilds the embeddings plus clusters before calculating its metrics. Expect this run to take a few minutes while your local machine processes the tickets.

Before you restart the app, do you expect the two scores to answer the same quality question?

  • Start the completed dashboard by running this command:
streamlit run app.py

What does this command verify?

This command executes the completed pipeline inside the local Streamlit server. A successful browser load proves that clustering, validation metrics, theme ranking, plus semantic search work in one dashboard.

Your browser should display Portfolio snapshot above Ranked issue themes. The snapshot should contain tickets_analyzed, clusters, silhouette_score, plus adjusted_rand_index.

How should I interpret the scores?

Silhouette measures geometric separation. Values closer to 1 indicate stronger separation. Values near 0 indicate overlapping groups. Negative values can indicate incorrect assignments.

Adjusted Rand measures agreement with the known intent labels. Identical assignments score 1. Random-like agreement sits near 0.

Read the metrics together. Twelve broad issue themes can show useful geometric structure without reproducing BANKING77's finer known-intent taxonomy.

Missing the portfolio snapshot?

Confirm that the snapshot block appears immediately after pipeline = build_pipeline(). Check that both metric keys in the snapshot match the keys returned by build_pipeline().

If the terminal reports a name error, compare the metrics import with the first snippet in this step. If the browser keeps an older result, stop the server before running the command again.

Help me troubleshoot missing clustering metrics in my Streamlit dashboard.

That completes the evidence layer. Your dashboard now connects searchable customer language with ranked themes plus quantitative validation.

Secret mission

Write the Product Decision Memo

Your dashboard has ranked the recurring support themes. In this mission, you will turn the strongest coherent theme into a concise product decision memo backed by evidence.

Clean Up Your Resources

Clean Up Your Resources

Everything runs locally on your Windows device, so leaving the files or caches in place creates no ongoing cost. Choose whether to keep the workspace, pause the running process, or delete the local resources.

Resources you used:

  • The support-signal-radar folder containing app.py, requirements.txt, .venv, and decision_memo.md.
  • The local Streamlit process serving the completed dashboard.
  • The locally cached BANKING77 dataset files stored by Hugging Face in ~/.cache/huggingface/datasets.

Keep everything running

No deletion is needed. Choose this option if you want to preserve every local file for another dashboard run.

  • Return to the activated Windows PowerShell terminal panel in Visual Studio Code.
  • Stop Streamlit by pressing Ctrl+C.
  • Exit the virtual environment by running this command:
deactivate

What Does This Command Do?

The deactivate command exits .venv while preserving the environment for your next session.

  • Leave the support-signal-radar folder and downloaded assets in place.

Your local server is stopped. Your completed dashboard remains ready for a fast restart.

Pause - I'll come back to this later

Shut down the running dashboard to free its memory. Your project files and downloaded assets remain available locally.

  • Return to the activated Windows PowerShell terminal panel.
  • Stop Streamlit by pressing Ctrl+C.

Your local server is now stopped. The project files and cached dataset stay ready for your next session.

Delete - I don't want to use this again

Remove the project workspace to start fresh. You can also clear the documented dataset cache to reclaim local storage.

Before You Delete

Deleting support-signal-radar permanently removes your dashboard, virtual environment, dependency list, and decision memo.

This cleanup creates no billing change because the project uses no paid services.

  • Return to the activated Windows PowerShell terminal panel.
  • Stop Streamlit by pressing Ctrl+C.
  • Exit the virtual environment by running this command:
deactivate

What Does This Command Do?

The deactivate command releases the active virtual environment before you remove its folder.

  • Close Visual Studio Code.
  • Press the Windows key to open the search bar.
  • Type File Explorer into the search bar.
  • Press Enter to open File Explorer.
  • Locate the support-signal-radar folder created earlier.
  • Select the support-signal-radar folder.
  • Press Shift+Delete to remove the folder permanently.
  • Confirm the permanent deletion when Windows asks.

The support-signal-radar folder no longer appears in File Explorer. Your dashboard files and virtual environment are now removed.

The dataset cache may include files used by other local projects. Clear it only if you want to remove every locally cached Hugging Face dataset.

  • Locate the ~/.cache/huggingface/datasets folder in File Explorer.
  • Select the datasets folder.
  • Press Shift+Delete to remove the dataset cache permanently.
  • Confirm the permanent deletion when Windows asks.

Your project workspace is now removed. If you also cleared the cache, the locally stored BANKING77 dataset files are gone too.

Nice Work!

Nice Work!

You did it! You built Support Signal Radar as a working local Streamlit dashboard that turns BANKING77 ticket language into evidence for product decisions.

You've learned how to:

  • Compared a keyword search baseline with semantic search powered by sentence embeddings. You used cosine similarity to recover related tickets despite paraphrased wording.
  • Discovered 12 issue themes with K-Means clustering. You ranked them with a transparent signal score based on ticket volume multiplied by cohesion. You surfaced a dominant known intent for each theme. You paired each theme with a representative customer ticket.
  • Evaluated cluster separation with silhouette score on a reproducible sample of 2,000 tickets. You compared the discovered themes with BANKING77's known intents using adjusted Rand index.
  • Secret Mission: Converted the highest-ranked coherent theme into a product decision memo in decision_memo.md. The memo uses cluster evidence to justify one intervention. It names an accountable owner. It defines a measurable follow-up signal. It keeps the signal score framed as a prioritization heuristic.

Ready to quiz yourself?