New

How to Build LLM Apps that can See Hear Speak

Notebook


SingleStore Notebooks

How to Build LLM Apps that can See Hear Speak

Note

This tutorial is meant for Standard & Premium Workspaces. You can't run this with a Free Starter Workspace due to restrictions on Storage. Create a Workspace using +group in the left nav & select Standard for this notebook. Gallery notebooks tagged with "Starter" are suitable to run on a Free Starter Workspace

What you'll build

SingleStore LLM App

By the end of this notebook you will have a small application that answers questions about stock-market data in plain English, and that can see, hear and speak. Prices, company profiles and news articles are pulled from one free API; a LangChain SQL agent turns your questions into MySQL and answers in prose; each question is cached alongside its embedding, so a later question that means the same thing is answered from the cache; the answer is spoken and transcribed back; and a news photo belonging to one of the stored rows is captioned by a vision model.

All of it lives in one SingleStore database. The rows, the embeddings and the cache sit together, so the cache lookup is an ordinary SQL query over a VECTOR column — there is no second system to keep in sync.

  • Step 1 — one database and four tables, including the VECTOR columns the cache needs

  • Step 2 — install LangChain, enter your API keys, open a connection

  • Step 3 — ingest prices, company profiles and news sentiment for three tickers

  • Step 4 — ask questions in English, then put a semantic cache in front of the agent

  • Step 5 — speak the answer, play it, transcribe it back

  • Step 6 — fetch a stored article's photo and caption it

Run the cells in order. Every cell is safe to run again, and step 3 re-fetches nothing it already has. Troubleshooting at the end lists the errors worth knowing about in advance.

Before you start

You need a Standard or Premium workspace, as the alert above says, with a database selected in the drop-down at the top of this notebook — every SQL cell runs against whatever is chosen there.

You also need two accounts, both free to create: Alpha Vantage for the data and OpenAI for the models, which costs a few cents for a full run. ElevenLabs is optional; leave its prompt blank and step 5 uses OpenAI's text-to-speech instead. The links are in the next step.

Budget about five minutes, some of it spent waiting: the Alpha Vantage free tier allows roughly one request per second, so step 3 paces itself. One thing may need a settings change — step 6 fetches a photo from a news site, and a hosted notebook can only reach hosts the workspace firewall allows. Nothing else depends on that step, which is why it is last.

Step 1: Create the tables

First a database named llm_webinar. Expect a one-line success message and nothing else.

IF NOT EXISTS here and on the tables below means running these cells twice is harmless — it will not wipe rows that cost you Alpha Vantage requests. To start over deliberately, the Reset Demo cell at the end drops the database outright.

In [1]:

1%%sql2CREATE DATABASE IF NOT EXISTS llm_webinar;

Action Required

Make sure to select a database from the drop-down menu at the top of this notebook. It updates the connection_url to connect to that database.

The four tables

Three tables hold what step 3 ingests: stockTable, one row per ticker per trading day; companyInfo, one row per company with the profile fields Alpha Vantage calls an overview; and newsSentiment, news articles with sentiment scores and the URL of each article's banner photo, which step 6 reads. companyInfo is a REFERENCE table because it is tiny — SingleStore replicates it to every node, so joins against it never move data across the cluster.

The fourth, embeddings, is the semantic cache: a question, its answer, and an embedding of each. Those embedding columns are VECTOR(1536, F32), SingleStore's native vector type, sized for what text-embedding-3-small produces. Using the real type rather than a blob means the dimension of every value is validated on insert, results print readably, and a vector index can be added later — which is what NOT NULL is there for.

topic_relevance_score is DECIMAL(10, 6) rather than text because step 6 filters on it: numbers stored as text sort like words, so '0.09' > '0.9'.

The SHOW TABLES cell that follows is your check: expect exactly these four names. If it comes back empty, the database drop-down at the top of the notebook is pointing somewhere else.

In [2]:

1%%sql2CREATE TABLE IF NOT EXISTS `stockTable` (3    `ticker` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL,4    `created_at` datetime DEFAULT NULL,5    `open` float DEFAULT NULL,6    `high` float DEFAULT NULL,7    `low` float DEFAULT NULL,8    `close` float DEFAULT NULL,9    `volume` int(11) DEFAULT NULL,10    SORT KEY (ticker, created_at desc),11    SHARD KEY (ticker)12);13
14CREATE TABLE IF NOT EXISTS newsSentiment (15    title TEXT CHARACTER SET utf8mb4,16    url TEXT,17    time_published DATETIME,18    authors TEXT,19    summary TEXT CHARACTER SET utf8mb4,20    banner_image TEXT,21    source TEXT,22    category_within_source TEXT,23    source_domain TEXT,24    topic TEXT,25    topic_relevance_score DECIMAL(10, 6),26    overall_sentiment_score REAL,27    overall_sentiment_label TEXT,28    `ticker` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL,29    ticker_relevance_score DECIMAL(10, 6),30    ticker_sentiment_score DECIMAL(10, 6),31    ticker_sentiment_label TEXT,32    SORT KEY (`ticker`,`time_published` DESC),33    SHARD KEY `__SHARDKEY` (`ticker`,`time_published` DESC),34    KEY(ticker) USING HASH,35    KEY(authors) USING HASH,36    KEY(source) USING HASH,37    KEY(overall_sentiment_label) USING HASH,38    KEY(ticker_sentiment_label) USING HASH39);40
41CREATE ROWSTORE REFERENCE TABLE IF NOT EXISTS companyInfo (42    ticker VARCHAR(10) PRIMARY KEY,43    AssetType VARCHAR(50),44    Name VARCHAR(100),45    Description TEXT,46    CIK VARCHAR(10),47    Exchange VARCHAR(10),48    Currency VARCHAR(10),49    Country VARCHAR(50),50    Sector VARCHAR(50),51    Industry VARCHAR(250),52    Address VARCHAR(100),53    FiscalYearEnd VARCHAR(20),54    LatestQuarter DATE,55    MarketCapitalization BIGINT,56    EBITDA BIGINT,57    PERatio DECIMAL(10, 2),58    PEGRatio DECIMAL(10, 3),59    BookValue DECIMAL(10, 2),60    DividendPerShare DECIMAL(10, 2),61    DividendYield DECIMAL(10, 4),62    EPS DECIMAL(10, 2),63    RevenuePerShareTTM DECIMAL(10, 2),64    ProfitMargin DECIMAL(10, 4),65    OperatingMarginTTM DECIMAL(10, 4),66    ReturnOnAssetsTTM DECIMAL(10, 4),67    ReturnOnEquityTTM DECIMAL(10, 4),68    RevenueTTM BIGINT,69    GrossProfitTTM BIGINT,70    DilutedEPSTTM DECIMAL(10, 2),71    QuarterlyEarningsGrowthYOY DECIMAL(10, 3),72    QuarterlyRevenueGrowthYOY DECIMAL(10, 3),73    AnalystTargetPrice DECIMAL(10, 2),74    TrailingPE DECIMAL(10, 2),75    ForwardPE DECIMAL(10, 2),76    PriceToSalesRatioTTM DECIMAL(10, 3),77    PriceToBookRatio DECIMAL(10, 2),78    EVToRevenue DECIMAL(10, 3),79    EVToEBITDA DECIMAL(10, 2),80    Beta DECIMAL(10, 3),81    52WeekHigh DECIMAL(10, 2),82    52WeekLow DECIMAL(10, 2),83    50DayMovingAverage DECIMAL(10, 2),84    200DayMovingAverage DECIMAL(10, 2),85    SharesOutstanding BIGINT,86    DividendDate DATE,87    ExDividendDate DATE88);89
90CREATE TABLE IF NOT EXISTS `embeddings` (91    `id` bigint(11) NOT NULL AUTO_INCREMENT,92    `category` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL,93    `question` longtext CHARACTER SET utf8 COLLATE utf8_general_ci,94    `question_embedding` VECTOR(1536, F32) NOT NULL,95    `answer` longtext CHARACTER SET utf8 COLLATE utf8_general_ci,96    `answer_embedding` VECTOR(1536, F32) NOT NULL,97    `created_at` datetime DEFAULT NULL,98    UNIQUE KEY `PRIMARY` (`id`) USING HASH,99    SHARD KEY `__SHARDKEY` (`id`),100    KEY `category` (`category`) USING HASH,101    SORT KEY `__UNORDERED` (`created_at` DESC)102);

In [3]:

1%%sql2SHOW TABLES;

Step 2: Packages, keys and connections

The hosted image already ships openai, numpy, pandas, requests and singlestoredb, so the only install is LangChain. Expect about thirty seconds of quiet output.

Two warnings you can ignore: langchain-community — the only home for the SQL toolkit step 4 uses — announces on import that it is being sunset, and pip may mention conflicts among packages this notebook never imports.

In [4]:

1%pip install --quiet "langchain>=1.3,<2" "langchain-community>=0.4.2,<0.5" "langchain-openai>=1.5,<2"

Now the imports: standard library, the two SDKs, and the LangChain pieces step 4 needs. Nothing here touches the network, so this cell returns instantly. A NameError further down usually means this cell has not run in the current kernel.

In [5]:

1import getpass2import json3import os4import time5from datetime import datetime, timedelta6
7import numpy as np8import openai9import requests10import singlestoredb as s211from dateutil.relativedelta import relativedelta12
13# LangChain moved these in 1.x: `langchain.sql_database` and14# `langchain.agents.agent_toolkits` no longer exist. These are the current paths.15from langchain_community.utilities import SQLDatabase16from langchain_community.agent_toolkits.sql.toolkit import SQLDatabaseToolkit17from langchain_community.agent_toolkits.sql.base import create_sql_agent18from langchain_openai import ChatOpenAI19
20import langchain21
22print(f'openai {openai.__version__} | langchain {langchain.__version__}')23
24# Report whether this image's brotli is one of the affected ones. This is informational25# only — the OpenAI client cell below sidesteps the decoder either way.26try:27    import brotli28
29    brotli.Decompressor().process(brotli.compress(b'probe'), output_buffer_limit=1 << 20)30    print(f'brotli {brotli.__version__}: accepts output_buffer_limit')31except TypeError:32    print(f'brotli {brotli.__version__}: too old for httpx2 (expected on this image) — '33          f'the OpenAI client below will not request brotli-encoded responses')34except ImportError:35    print('brotli not installed — nothing to work around')

Set your API keys

Run the next cell and paste each key at the prompt. getpass keeps them off the screen and out of the saved notebook, which also means re-entering them after a kernel restart.

| Service | What this notebook uses it for | Cost | | --- | --- | --- | | Alpha Vantage | The only data source: daily prices, company overviews, news sentiment | Free key, no card | | OpenAI | Embeddings, the text-to-SQL agent, transcription, image captioning | Paid, a few cents for a full run | | ElevenLabs | Text to speech — the "speak" in the title. Optional | Free tier is enough |

Press Enter at the ElevenLabs prompt to skip it: step 5 falls back to OpenAI's text-to-speech, so you still get audio and the transcription step still has a file to read.

Know this about the Alpha Vantage free tier before step 3

There are two separate limits: about one request per second, and 25 requests per day. A first full run uses about a dozen. The ingest cells pace themselves, report any refusal out loud rather than skipping rows quietly, and fetch only what is missing — so re-running a loaded notebook makes no requests at all, and a run cut short by the daily quota resumes where it stopped. Set AV_REFRESH = True in the first ingest cell when you want fresh data on purpose.

In [6]:

1alpha_vantage_apikey = getpass.getpass('enter alphavantage apikey here')2openai_apikey = getpass.getpass('enter openai apikey here')3
4# Optional. Press Enter to skip and fall back to OpenAI text-to-speech in step 5.5elevenlabs_apikey = getpass.getpass('enter elevenlabs apikey here (optional, Enter to skip)').strip()6
7assert alpha_vantage_apikey, 'Alpha Vantage key is required — every table below is loaded from it.'8assert openai_apikey, 'OpenAI key is required for embeddings, the SQL agent, and captioning.'9
10os.environ['OPENAI_API_KEY'] = openai_apikey11print(f'ElevenLabs: {"configured" if elevenlabs_apikey else "skipped, using OpenAI TTS"}')

Build the OpenAI client

One client, used by everything: embeddings, transcription, speech, captioning, and the LangChain agent in step 4. It ends with a deliberately cheap embeddings call, so a wrong or unfunded key fails here rather than a dozen cells later. Expect two printed lines, the second reading text-embedding-3-small returned 1536 dimensions — the size the embeddings table was declared for. If you swap in a different embedding model, read the note on the similarity threshold in step 4 first: the threshold is a property of the model, and carrying one over is a silent failure.

The http_client argument turns off brotli compression, which the version on this image cannot decode. Without it you get APIConnectionError: Connection error printed under a successful HTTP/1.1 200 OK — an error that looks like the network or your key and is neither.

In [7]:

1import httpx22from openai import OpenAI3
4# Do not advertise brotli, so the image's brotli 1.1.0 is never asked to decode a response.5http_client = httpx2.Client(headers={'Accept-Encoding': 'gzip, deflate'})6
7client = OpenAI(api_key=openai_apikey, http_client=http_client)8
9print(f"Accept-Encoding: {client._client.headers.get('accept-encoding')}")10
11EMBEDDING_MODEL = 'text-embedding-3-small'   # 1536 dimensions, matches the DDL above12CHAT_MODEL = 'gpt-4o-mini'                   # used for the SQL agent and image captioning13
14
15def get_embeddings(inputs: list[str], model: str = EMBEDDING_MODEL) -> list[list[float]]:16    """Return one embedding per input string."""17    return [x.embedding for x in client.embeddings.create(input=inputs, model=model).data]18
19
20# Fail here, on one cheap call, rather than midway through the ingest loops.21_probe = get_embeddings(['connectivity probe'])[0]22print(f'{EMBEDDING_MODEL} returned {len(_probe)} dimensions')23
24assert len(_probe) == 1536, (25    f'{EMBEDDING_MODEL} returned {len(_probe)} dimensions but the embeddings table declares '26    f'VECTOR(1536, F32). Change the DDL to match, or pick a 1536-dimension model.'27)

Connect to SingleStore

Expect one line: connected to llm_webinar; all four tables present. If it reports a missing table or no database selected, the drop-down at the top of the notebook is pointing somewhere other than where step 1 created things.

The password in a notebook connection URL is a token that expires after about an hour, so the helpers read the URL when they need it and let the driver refresh it. One connection then survives both a token refresh and a change to the database drop-down.

In [8]:

1def current_connection_url() -> str:2    """Return the connection URL as it is now, not as it was at kernel startup.3
4    The password is a JWT that expires after roughly an hour, so a copy taken earlier in the5    session may already be dead. The first two sources are read live.6    """7    url = None8
9    try:10        from singlestoredb.notebook import portal11        url = portal.connection_url12    except Exception:13        pass   # not running inside a SingleStore hosted notebook14
15    url = url or os.environ.get('SINGLESTOREDB_URL') or connection_url16
17    # SINGLESTOREDB_URL carries no scheme, and SQLAlchemy needs one to choose a dialect.18    return url if '://' in url else f'singlestoredb://{url}'19
20
21EXPECTED_TABLES = {'stockTable', 'newsSentiment', 'companyInfo', 'embeddings'}22
23
24def s2_connect():25    """Open a connection that re-reads its credentials rather than pinning them."""26    # track_env=True discards any URL passed to connect() and reads SINGLESTOREDB_URL on every27    # statement, so only ask for it when that variable is actually set.28    if os.environ.get('SINGLESTOREDB_URL'):29        conn = s2.connect(track_env=True)30    else:31        conn = s2.connect(current_connection_url())32
33    try:34        with conn.cursor() as cur:35            # track_env defers connecting, so this statement is what opens the socket.36            cur.execute('SELECT DATABASE()')37            database = cur.fetchone()[0]38
39            cur.execute(40                'SELECT table_name FROM information_schema.tables '41                'WHERE table_schema = DATABASE()'42            )43            tables = {row[0] for row in cur.fetchall()}44
45    except s2.Error as exc:46        if exc.errno == 2628 or 'JWT' in str(exc):47            raise RuntimeError(48                'SingleStore rejected the notebook credentials: the JWT in the connection URL '49                'has expired. It is issued when the kernel starts and lasts about an hour, so a '50                'long session runs past it. Restart the kernel and run the cells again — the '51                'portal issues a fresh token at startup.'52            ) from exc53        raise54
55    assert database, (56        'no database is selected. Pick llm_webinar from the drop-down at the top of the '57        'notebook, then re-run this cell.'58    )59
60    missing = EXPECTED_TABLES - tables61
62    assert not missing, (63        f'connected to {database}, which is missing {sorted(missing)}. Either the DDL cells have '64        f'not been run, or the drop-down points at a different database.'65    )66
67    print(f'connected to {database}; all four tables present')68
69    return conn70
71
72s2_conn = s2_connect()

Step 3: Ingest from Alpha Vantage

Everything in the four tables comes from Alpha Vantage. This cell defines one helper the three ingest cells share, and it exists because Alpha Vantage does not refuse with an HTTP error. A refusal is HTTP 200 carrying a JSON body with a Note or Information key where the data should be. Code that trusts the status code cannot tell a refusal from a month with no news: the loop moves on, the tables come out half full, and the first visible symptom is the agent in step 4 answering confidently about nothing.

There are two limits on a free key, and telling them apart decides whether retrying is worth anything:

| Limit | What the message says | What helps | | --- | --- | --- | | about 1 request per second | "spreading out your free API requests more sparingly" | pausing between requests | | 25 requests per day | "our standard API rate limit is 25 requests per day" | waiting for the reset, or a premium key |

The per-second limit is the one these loops trip, so alpha_vantage_get() paces itself and retries a burst refusal with backoff — that pacing is most of step 3's running time.

Because 25 requests a day is few, each cell below asks the database what it already holds and fetches only the remainder. Re-running a loaded notebook prints no requests made, and hitting the quota mid-loop is a pause rather than a crash: rows already inserted stay, and re-running tomorrow fetches what is outstanding. A genuine error, such as a bad key, still stops the cell, and AV_REFRESH = True in the next cell wipes and reloads.

In [9]:

1# The free tier limits both the day (25 requests) and the burst (about 1 per second). The2# burst limit is the one the loops below trip, because they make their requests back to back.3AV_MIN_INTERVAL = 1.5    # seconds to leave between requests4AV_MAX_ATTEMPTS = 4      # 1 initial try plus 3 retries, for burst refusals only5AV_DAILY_LIMIT = 25      # free-tier requests per key per day, for reporting only6
7# The ingest cells below only fetch what is missing from each table, so re-running the notebook8# costs no Alpha Vantage requests once a table is complete. That matters because the daily quota9# is small enough that a couple of full runs exhausts it, and everything after step 4 then has10# nothing to work with. Set this to True when you actually want fresh data.11AV_REFRESH = False12
13_av_last_call = 0.014_av_requests = 0         # this kernel only; the real quota is per key per day, so a restart15                         # resets this counter without resetting the quota16
17
18def av_redact(text: str) -> str:19    """Blank the key out of a message. Alpha Vantage echoes it back, and output gets saved."""20    return text.replace(alpha_vantage_apikey, '<your key>') if alpha_vantage_apikey else text21
22
23class AlphaVantageRefused(RuntimeError):24    """One of Alpha Vantage's HTTP-200 refusals, tagged with which of the four kinds it is.25
26    A RuntimeError subclass so it still reads as one, but `.kind` lets the ingest cells treat27    an exhausted quota as "stop here and keep what you have" rather than a crash.28    """29
30    def __init__(self, kind: str, hint: str, key: str, message: str, params: dict):31        self.kind, self.hint, self.message = kind, hint, message32        subject = params.get('symbol') or params.get('tickers') or ''33        super().__init__(34            f'Alpha Vantage returned "{key}" instead of data for '35            f'{params.get("function")} {subject} [{kind}]:\n  {message}\n{hint}'36        )37
38
39def av_classify(message: str) -> tuple[str, str]:40    """Work out which of Alpha Vantage's HTTP-200 refusals a message is, and advise.41
42    The order of these tests is the whole point. Every refusal advertises the premium plans,43    and the burst message mentions "premium endpoints", "25 requests per day" and "rate limit"44    all at once — so matching the bare word "premium", or checking the daily quota first, gets45    a throttle diagnosed as a paywall.46    """47    m = message.lower()48
49    # Only the paywall says this, in the singular. "premium endpoints" plural is upsell text.50    if 'this is a premium endpoint' in m:51        return 'paywall', (52            'That endpoint is not on the free tier. This is a paywall rather than a quota, so '53            'waiting for a reset will not help: you need a premium key, or a free endpoint that '54            'returns similar data.'55        )56
57    if 'per second' in m or 'sparingly' in m:58        return 'burst', (59            f'That is the per-second burst limit rather than the daily quota. Requests are '60            f'already spaced {AV_MIN_INTERVAL}s apart and retried with backoff, so seeing this '61            f'means the pacing is still too fast — raise AV_MIN_INTERVAL and re-run.'62        )63
64    if 'per day' in m or 'rate limit' in m:65        return 'quota', (66            f'That is the daily quota ({AV_DAILY_LIMIT} requests at the time of writing), which '67            f'resets on its own; a premium key lifts it. The ingest cells only fetch what is '68            f'missing from each table, so re-running them after the reset will pick up where '69            f'this left off rather than starting over — and the sections after the ingest work '70            f'on whatever is already loaded, so there is no need to wait to keep going.'71        )72
73    if 'invalid api call' in m or 'apikey' in m:74        return 'request', (75            'Alpha Vantage rejected the request itself rather than limiting it — usually a bad '76            'key or a mistyped parameter.'77        )78
79    return 'unknown', 'This refusal is not one of the four we recognise; the message is above.'80
81
82def alpha_vantage_get(**params) -> dict:83    """Call Alpha Vantage, pacing the requests and surfacing its HTTP-200 refusals.84
85    Throttling, invalid keys and premium-only endpoints all come back as HTTP 200 with an86    explanatory key instead of data, so the status code tells you nothing. Burst refusals are87    retried with backoff; a paywall or an exhausted daily quota is raised immediately, since88    retrying those just spends more of the quota.89    """90    global _av_last_call, _av_requests91
92    params['apikey'] = alpha_vantage_apikey93
94    for attempt in range(1, AV_MAX_ATTEMPTS + 1):95        # Space requests out rather than waiting to be told off for bursting.96        wait = AV_MIN_INTERVAL - (time.monotonic() - _av_last_call)97
98        if wait > 0:99            time.sleep(wait)100
101        response = requests.get('https://www.alphavantage.co/query', params=params, timeout=30)102        _av_last_call = time.monotonic()103        # Counted before the refusal check on purpose: a throttled or refused call is still a104        # request Alpha Vantage served, so assume it counts against the daily quota too.105        _av_requests += 1106        response.raise_for_status()107
108        payload = response.json()109
110        refusal = next(111            (112                (key, str(payload[key]))113                for key in ('Note', 'Information', 'Error Message')114                if key in payload115            ),116            None,117        )118
119        if refusal is None:120            return payload121
122        key, message = refusal123        message = av_redact(message)124        kind, hint = av_classify(message)125
126        if kind == 'burst' and attempt < AV_MAX_ATTEMPTS:127            backoff = AV_MIN_INTERVAL * 2 ** attempt128            print(f'    burst limit hit, waiting {backoff:.0f}s then retrying '129                  f'(attempt {attempt} of {AV_MAX_ATTEMPTS - 1})')130            time.sleep(backoff)131            continue132
133        raise AlphaVantageRefused(kind, hint, key, message, params)134
135
136def av_report() -> None:137    """Say how many requests this kernel has spent, since the quota is easy to walk into."""138    print(f'  ({_av_requests} Alpha Vantage request(s) this kernel, of about '139          f'{AV_DAILY_LIMIT} per key per day)')140
141
142def already_loaded(query: str) -> set:143    """Return what a table already holds, as a set of values or of tuples.144
145    This is what makes the ingest resumable. Each cell below asks the database what it already146    has and fetches only the rest, so a run that stopped on the daily quota picks up where it147    left off tomorrow instead of starting over — and a run with nothing missing spends nothing.148    """149    with s2_conn.cursor() as cur:150        cur.execute(query)151        rows = cur.fetchall()152
153    return {row[0] if len(row) == 1 else tuple(row) for row in rows}154
155
156def av_stopped_early(exc: 'AlphaVantageRefused', remaining: int) -> None:157    """Explain a quota stop as the pause it is, rather than as a failure."""158    print(f'\n  stopped early on the {exc.kind} limit, with {remaining} item(s) still to fetch.')159    print(f'  {exc.message}')160    print('  What is already loaded above is kept. Re-run this cell once the quota resets and '161          'it will fetch only what is missing.')162
163
164def get_past_months(num_months: int) -> list[str]:165    """Return the current month and the preceding ones, as YYYY-MM strings."""166    today = datetime.today()167    return [168        (today - relativedelta(months=n)).strftime('%Y-%m')169        for n in range(num_months)170    ]171
172
173ticker_list = ['TSLA', 'AMZN', 'PLTR']174
175num_months = 2176year_month_list = get_past_months(num_months)177
178requests_needed = len(ticker_list) * (2 + num_months)179
180print(f'tickers: {ticker_list}')181print(f'months:  {year_month_list}')182print(f'a first full run makes about {requests_needed} Alpha Vantage requests, against a '183      f'free-tier limit of {AV_DAILY_LIMIT} per key per day,')184print(f'and takes at least {requests_needed * AV_MIN_INTERVAL:.0f}s once paced '185      f'{AV_MIN_INTERVAL}s apart.')186print(f'AV_REFRESH is {AV_REFRESH}, so the cells below will '187      + ('refetch every ticker and month.'188         if AV_REFRESH else 'fetch only what is missing from the tables.'))

Bring in the past few months of stock data

One request per ticker — TSLA, AMZN and PLTR by default — so about five seconds with the pacing. Expect a line per ticker saying how many daily bars were inserted, then a row count from the SQL cell that follows: around 300 on a first run, 100 trading days for each of the three.

This uses TIME_SERIES_DAILY because TIME_SERIES_INTRADAY is premium: a free key gets HTTP 200 carrying "Information": "... This is a premium endpoint ..." and no data. The daily endpoint returns the latest 100 trading days per request and names the bar fields identically. If your key is premium, set USE_INTRADAY_PREMIUM = True for 5-minute bars.

In [10]:

1# TIME_SERIES_INTRADAY is a premium endpoint, so the free TIME_SERIES_DAILY is the default.2# Set this to True only if your Alpha Vantage key is on a premium plan.3USE_INTRADAY_PREMIUM = False4
5INSERT_STOCK = """6    INSERT INTO stockTable (created_at, ticker, open, high, low, close, volume)7    VALUES (%(created_at)s, %(ticker)s, %(open)s, %(high)s, %(low)s, %(close)s, %(volume)s)8"""9
10
11def parse_bars(payload: dict, series_key: str, ticker: str) -> list[dict]:12    """Turn one Alpha Vantage time series into rows for stockTable.13
14    Both endpoints name their bar fields identically, so this handles either one.15    """16    series = payload.get(series_key)17
18    if not series:19        raise RuntimeError(20            f'{ticker}: the response has no "{series_key}". Keys present: {list(payload)[:6]}'21        )22
23    rows = []24
25    for timestamp, bar in series.items():26        # Daily timestamps are dates, so pin them to midnight — created_at is a DATETIME.27        created_at = f'{timestamp} 00:00:00' if len(timestamp) == 10 else timestamp28
29        rows.append({30            'created_at': created_at,31            'ticker': ticker,32            'open': float(bar['1. open']),33            'high': float(bar['2. high']),34            'low': float(bar['3. low']),35            'close': float(bar['4. close']),36            'volume': int(bar['5. volume']),37        })38
39    return rows40
41
42def fetch_bars(ticker: str) -> tuple[list[dict], str]:43    """Fetch one ticker's bars, from whichever endpoint the flag above selects."""44    if USE_INTRADAY_PREMIUM:45        rows = []46
47        for year_month in year_month_list:48            payload = alpha_vantage_get(49                function='TIME_SERIES_INTRADAY',50                symbol=ticker,51                interval='5min',52                month=year_month,53                outputsize='full',54            )55            rows += parse_bars(payload, 'Time Series (5min)', ticker)56
57        return rows, '5-minute'58
59    payload = alpha_vantage_get(60        function='TIME_SERIES_DAILY',61        symbol=ticker,62        outputsize='compact',   # 'full' is premium63    )64
65    return parse_bars(payload, 'Time Series (Daily)', ticker), 'daily'66
67
68# Only fetch tickers the table does not already have. A re-run therefore costs no requests once69# every ticker is in, and a run cut short by the daily quota resumes instead of restarting.70# AV_REFRESH = True clears the table first, so everything is fetched again.71if AV_REFRESH:72    with s2_conn.cursor() as cur:73        cur.execute('DELETE FROM stockTable')74
75have = already_loaded('SELECT DISTINCT ticker FROM stockTable')76todo = [t for t in ticker_list if t not in have]77
78for ticker in ticker_list:79    if ticker in have:80        print(f'  {ticker}: already in stockTable, not refetched')81
82for position, ticker in enumerate(todo):83    try:84        rows, kind = fetch_bars(ticker)85    except AlphaVantageRefused as exc:86        # A spent quota, or a burst limit that outlasted its retries, is a pause rather than a87        # failure: keep the tickers already inserted and stop. Anything else is a real error.88        if exc.kind not in ('quota', 'burst'):89            raise90
91        av_stopped_early(exc, len(todo) - position)92        break93
94    with s2_conn.cursor() as cur:95        cur.executemany(INSERT_STOCK, rows)96
97    # Alpha Vantage returns newest first, so the last row is the oldest.98    print(f'  {ticker}: inserted {len(rows)} {kind} bars '99          f'({rows[-1]["created_at"][:10]} to {rows[0]["created_at"][:10]})')100
101if not todo:102    print('stockTable already has every ticker — no requests made')103
104av_report()

In [11]:

1%%sql2SELECT count(*) AS rows_in_stockTable FROM stockTable

Bring in company data

One request per ticker again, so another five seconds. Expect one line per company, then a three-row table: ticker, name, sector, market cap, P/E.

The OVERVIEW endpoint returns a flat JSON object in which every value is a string, with missing values written variously as None, -, or empty. The coerce() helper turns those into real integers, decimals and dates, and a missing value into NULL rather than 0.0 — a 0.0 P/E is a plausible number, so the agent would average it into an answer as fact.

In [12]:

1# Alpha Vantage writes missing values as any of these.2MISSING = {'none', '-', '', 'nan'}3
4INT_FIELDS = [5    'MarketCapitalization', 'EBITDA', 'RevenueTTM', 'GrossProfitTTM', 'SharesOutstanding',6]7FLOAT_FIELDS = [8    'PERatio', 'PEGRatio', 'BookValue', 'DividendPerShare', 'DividendYield', 'EPS',9    'RevenuePerShareTTM', 'ProfitMargin', 'OperatingMarginTTM', 'ReturnOnAssetsTTM',10    'ReturnOnEquityTTM', 'DilutedEPSTTM', 'QuarterlyEarningsGrowthYOY',11    'QuarterlyRevenueGrowthYOY', 'AnalystTargetPrice', 'TrailingPE', 'ForwardPE',12    'PriceToSalesRatioTTM', 'PriceToBookRatio', 'EVToRevenue', 'EVToEBITDA', 'Beta',13    '52WeekHigh', '52WeekLow', '50DayMovingAverage', '200DayMovingAverage',14]15DATE_FIELDS = ['LatestQuarter', 'DividendDate', 'ExDividendDate']16STR_FIELDS = [17    'AssetType', 'Name', 'Description', 'CIK', 'Exchange', 'Currency', 'Country',18    'Sector', 'Industry', 'Address', 'FiscalYearEnd',19]20
21COMPANY_COLUMNS = ['ticker'] + STR_FIELDS + DATE_FIELDS + INT_FIELDS + FLOAT_FIELDS22
23
24def coerce(raw, kind):25    """Convert one Alpha Vantage string to a typed value, or None if it is missing.26
27    Returning None rather than 0.0 matters: a zero P/E ratio reads as a real measurement,28    and anything querying this table later cannot tell it apart from one.29    """30    if raw is None or str(raw).strip().lower() in MISSING:31        return None32    try:33        if kind is int:34            return int(float(raw))35        if kind is float:36            return float(raw)37    except ValueError:38        return None39    return str(raw)40
41
42INSERT_COMPANY = (43    'INSERT INTO companyInfo (' + ', '.join(f'`{c}`' for c in COMPANY_COLUMNS) + ') VALUES ('44    + ', '.join(f'%({c})s' for c in COMPANY_COLUMNS) + ')'45)46
47# companyInfo keys on ticker, so a second insert for the same ticker would fail outright.48# Skipping the tickers already present handles that and saves the requests at the same time.49if AV_REFRESH:50    with s2_conn.cursor() as cur:51        cur.execute('DELETE FROM companyInfo')52
53have = already_loaded('SELECT DISTINCT ticker FROM companyInfo')54todo = [t for t in ticker_list if t not in have]55
56for ticker in ticker_list:57    if ticker in have:58        print(f'  {ticker}: already in companyInfo, not refetched')59
60for position, ticker in enumerate(todo):61    try:62        data = alpha_vantage_get(function='OVERVIEW', symbol=ticker)63    except AlphaVantageRefused as exc:64        if exc.kind not in ('quota', 'burst'):65            raise66
67        av_stopped_early(exc, len(todo) - position)68        break69
70    if 'Symbol' not in data:71        raise RuntimeError(f'OVERVIEW for {ticker} returned no Symbol field: {list(data)[:8]}')72
73    params = {'ticker': data['Symbol']}74    params.update({f: coerce(data.get(f), str) for f in STR_FIELDS})75    params.update({f: coerce(data.get(f), str) for f in DATE_FIELDS})76    params.update({f: coerce(data.get(f), int) for f in INT_FIELDS})77    params.update({f: coerce(data.get(f), float) for f in FLOAT_FIELDS})78
79    with s2_conn.cursor() as cur:80        cur.execute(INSERT_COMPANY, params)81
82    missing = sorted(k for k, v in params.items() if v is None)83    print(f'  {ticker}: {data.get("Name")}{len(missing)} field(s) null'84          + (f' ({", ".join(missing[:4])}...)' if missing else ''))85
86if not todo:87    print('companyInfo already has every ticker — no requests made')88
89av_report()

In [13]:

1%%sql2SELECT ticker, Name, Sector, MarketCapitalization, PERatio FROM companyInfo

Bring in news sentiment

The biggest of the three: one request per ticker per month, so six with the default two-month window, and about ten seconds of pacing. Expect a line per ticker-month with the number of articles inserted, a note of any skipped, and then a total row count — several hundred rows is normal, since one request returns many articles. Lines reading already stored, not refetched on a second run are the resumability described above doing its job.

NEWS_SENTIMENT returns each article with an overall sentiment score and a per-ticker score, plus a topics list and a banner photo URL. Step 4 queries the scores; step 6 fetches the photo. Articles with no topics, or no sentiment entry for the ticker being fetched, are skipped and counted rather than guessed at.

If the cell stops early saying the daily quota is spent, what it inserted is kept and re-running tomorrow picks up the rest. You can continue through step 4 with a partial table.

In [14]:

1INSERT_NEWS = """2    INSERT INTO newsSentiment (3        title, url, time_published, authors, summary, banner_image, source,4        category_within_source, source_domain, topic, topic_relevance_score,5        overall_sentiment_score, overall_sentiment_label, ticker,6        ticker_relevance_score, ticker_sentiment_score, ticker_sentiment_label7    ) VALUES (8        %(title)s, %(url)s, %(time_published)s, %(authors)s, %(summary)s, %(banner_image)s,9        %(source)s, %(category_within_source)s, %(source_domain)s, %(topic)s,10        %(topic_relevance_score)s, %(overall_sentiment_score)s, %(overall_sentiment_label)s,11        %(ticker)s, %(ticker_relevance_score)s, %(ticker_sentiment_score)s,12        %(ticker_sentiment_label)s13    )14"""15
16if AV_REFRESH:17    with s2_conn.cursor() as cur:18        cur.execute('DELETE FROM newsSentiment')19
20# This cell makes one request per ticker per month, so it resumes at that granularity rather21# than per table: ask which (ticker, month) pairs are already stored and fetch only the rest.22# YEAR() and MONTH() rather than DATE_FORMAT because a '%Y-%m' format string in a query would23# collide with the driver's own %(name)s parameter substitution.24have = {25    (ticker, f'{year:04d}-{month:02d}')26    for ticker, year, month in already_loaded(27        'SELECT DISTINCT ticker, YEAR(time_published), MONTH(time_published) '28        'FROM newsSentiment'29    )30}31
32todo = [(t, m) for t in ticker_list for m in year_month_list if (t, m) not in have]33
34for pair in ((t, m) for t in ticker_list for m in year_month_list):35    if pair in have:36        print(f'  {pair[0]} {pair[1]}: already stored, not refetched')37
38for position, (ticker, year_month) in enumerate(todo):39    # One calendar month, from its first day to the first day of the next month.40    month_start = datetime.strptime(year_month, '%Y-%m')41    month_end = month_start + relativedelta(months=1)42
43    try:44        data = alpha_vantage_get(45            function='NEWS_SENTIMENT',46            tickers=ticker,47            time_from=month_start.strftime('%Y%m%dT0000'),48            time_to=month_end.strftime('%Y%m%dT0000'),49            limit=1000,50        )51    except AlphaVantageRefused as exc:52        if exc.kind not in ('quota', 'burst'):53            raise54
55        av_stopped_early(exc, len(todo) - position)56        break57
58    feed = data.get('feed', [])59    rows, skipped = [], 060
61    for item in feed:62        topics = item.get('topics') or []63        ticker_sentiment = item.get('ticker_sentiment') or []64
65        if not topics or not ticker_sentiment:66            skipped += 167            continue68
69        # Use this ticker's own sentiment entry, not the first one in the list. An article about70        # several companies lists all of them, so the original code could store AMZN's relevance71        # score on a row fetched for TSLA — and that would also make the (ticker, month) check72        # above unreliable, since the stored ticker would not be the one we searched for.73        sentiment = next(74            (ts for ts in ticker_sentiment if str(ts.get('ticker')) == ticker), None75        )76
77        if sentiment is None:78            skipped += 179            continue80
81        authors = item.get('authors') or []82
83        rows.append({84            'title': str(item['title']),85            'url': str(item['url']),86            'time_published': datetime.strptime(87                str(item['time_published']), '%Y%m%dT%H%M%S'88            ).strftime('%Y-%m-%d %H:%M:%S'),89            'authors': str(authors[0]) if authors else 'No authors available',90            'summary': str(item.get('summary', '')),91            'banner_image': str(item.get('banner_image') or ''),92            'source': str(item.get('source', '')),93            'category_within_source': str(item.get('category_within_source', '')),94            'source_domain': str(item.get('source_domain', '')),95            'topic': str(topics[0]['topic']),96            'topic_relevance_score': float(topics[0]['relevance_score']),97            'overall_sentiment_score': float(item['overall_sentiment_score']),98            'overall_sentiment_label': str(item['overall_sentiment_label']),99            'ticker': str(sentiment['ticker']),100            'ticker_relevance_score': float(sentiment['relevance_score']),101            'ticker_sentiment_score': float(sentiment['ticker_sentiment_score']),102            'ticker_sentiment_label': str(sentiment['ticker_sentiment_label']),103        })104
105    if rows:106        with s2_conn.cursor() as cur:107            cur.executemany(INSERT_NEWS, rows)108
109    print(f'  {ticker} {year_month}: inserted {len(rows)} articles'110          + (f', skipped {skipped} without topics or this ticker\'s sentiment' if skipped else ''))111
112    # A month with no articles stores no rows, so it looks unfetched next time and costs one113    # request again. Rare for these tickers, and cheaper than a table to track attempts in.114
115if not todo:116    print('newsSentiment already has every ticker and month — no requests made')117
118av_report()

In [15]:

1%%sql2SELECT count(*) AS rows_in_newsSentiment FROM newsSentiment

Step 4: Ask questions in English

This is where the notebook stops being a data pipeline. SQLDatabaseToolkit gives the model four tools — list the tables, read a schema, check a query, run a query — and create_sql_agent wires them into a loop: the model writes MySQL, runs it against SingleStore, reads the rows back, and answers in prose. Expect a short confirmation and a deprecation warning from langchain-community; the agent runs in the cells further down.

A quirk you may hit. Reading a VECTOR column through SQLAlchemy raises AttributeError: 'VECTOR' object has no attribute '_str_impl' on some builds of the SingleStore dialect, and SQLDatabase reads sample rows from every table. The cell patches that and describes the embeddings table by hand, which also keeps two 1536-dimension vectors out of the prompt.

In [16]:

1SQL_AGENT_PREFIX = """You are an agent designed to interact with a SQL database called SingleStore.2This sometimes has Shard and Sort keys in the table schemas, which you can ignore.3
4Given an input question, create a syntactically correct {dialect} query to run, then look at the5results of the query and return the answer.6
7If you are asked about similarity questions, you should use the DOT_PRODUCT function.8
9Here are a few examples of how to use the DOT_PRODUCT function:10
11Example 1:12Q: how similar are the questions and answers?13A: select question, answer, dot_product(question_embedding, answer_embedding) as similarity from embeddings;14
15Example 2:16Q: What are the most similar questions in the embeddings table, not including itself?17A: SELECT q1.question as question1, q2.question as question2,18          DOT_PRODUCT(q1.question_embedding, q2.question_embedding) :> float as score19   FROM embeddings q1, embeddings q220   WHERE q1.question != q2.question21   ORDER BY score DESC LIMIT 5;22
23Example 3:24Q: In the embeddings table, which rows are from the chatbot?25A: SELECT category, question, answer FROM embeddings WHERE category = 'chatbot';26
27If you are asked to describe the database, you should run the query SHOW TABLES.28
29Unless the user specifies a specific number of examples they wish to obtain, always limit your30query to at most {top_k} results. You can order the results by a relevant column to return the31most interesting examples in the database.32
33Never SELECT the question_embedding or answer_embedding columns, and never SELECT * from the34embeddings table: those columns hold 1536-dimension vectors. Compare them with35DOT_PRODUCT(a, b), which returns a single float, and select that instead. Never query for all36the columns from any table, only the columns relevant to the question.37
38You have access to tools for interacting with the database. Only use the below tools, and only39use the information returned by them to construct your final answer. You MUST double check your40query before executing it. If you get an error while executing a query, rewrite the query and41try again up to 3 times.42
43DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the database.44
45If the question does not seem related to the database, just return "I don't know" as the answer.46"""47
48# Older sqlalchemy-singlestoredb builds ship a VECTOR.result_processor that reads49# self._str_impl — an attribute only SQLAlchemy's JSON type defines, while VECTOR subclasses50# BLOB. Reading a VECTOR column through SQLAlchemy therefore raises AttributeError. Current51# builds set it in __init__; this fills it in when they have not, and is a no-op when they have.52try:53    from sqlalchemy import String as _SAString54    from sqlalchemy_singlestoredb.dtypes import VECTOR as _SAVector55
56    if not hasattr(_SAVector, '_str_impl'):57        _SAVector._str_impl = _SAString()58        print('applied the VECTOR._str_impl compatibility shim')59except ImportError:60    pass   # not using the SingleStore SQLAlchemy dialect61
62# Describing `embeddings` by hand stops LangChain running SELECT * on it to collect sample63# rows, which is what actually triggered the bug above, and keeps two 1536-dimension vectors64# out of the prompt. The three data tables still get real samples, which is the part that65# helps the agent write good SQL.66EMBEDDINGS_TABLE_INFO = """67CREATE TABLE embeddings (68    id BIGINT,                            -- auto increment69    category VARCHAR(255),                -- 'chatbot' for rows this notebook writes70    question LONGTEXT,71    question_embedding VECTOR(1536, F32), -- never SELECT this column; compare it instead72    answer LONGTEXT,73    answer_embedding VECTOR(1536, F32),   -- never SELECT this column; compare it instead74    created_at DATETIME75)76/* Semantic cache. Compare two vectors with DOT_PRODUCT(a, b), which returns a float. */77"""78
79db = SQLDatabase.from_uri(80    current_connection_url(),   # read now, not the possibly-expired startup copy81    include_tables=['embeddings', 'companyInfo', 'newsSentiment', 'stockTable'],82    sample_rows_in_table_info=2,83    custom_table_info={'embeddings': EMBEDDINGS_TABLE_INFO},84)85
86# Reuse the http_client from step 3 so the agent's calls also skip brotli encoding.87llm = ChatOpenAI(model=CHAT_MODEL, temperature=0, http_client=http_client)88
89agent_executor = create_sql_agent(90    llm=llm,91    toolkit=SQLDatabaseToolkit(db=db, llm=llm),92    agent_type='tool-calling',93    prefix=SQL_AGENT_PREFIX,94    top_k=3,95    max_iterations=5,96    verbose=True,97)98
99
100def run_agent(question: str) -> str:101    """Ask the SQL agent a question and return just its answer text."""102    return agent_executor.invoke({'input': question})['output']103
104
105print(f'agent ready over tables: {db.get_usable_table_names()}')

Put a semantic cache in front of the agent

This is the idea the demo is built around. An agent run costs several seconds and a few thousand tokens, and many questions users ask are questions somebody already asked, worded differently. So before paying for a run: embed the question, look for a stored one pointing in nearly the same direction, and if it scores above a threshold, return its stored answer. Because the vectors live in the same database as the data, that lookup is a single DOT_PRODUCT query over a VECTOR column.

process_user_question() below is the whole mechanism, and it prints which path it took.

The similarity threshold is a property of the embedding model. 0.85 is right for text-embedding-3-small; a number borrowed from a different model can mean the cache silently never hits and you pay for every answer. The next cell measures it rather than trusting it.

In [17]:

1table_name = 'embeddings'2# Recalibrated for text-embedding-3-small. 0.97 was carried over from text-embedding-ada-002,3# whose cosine scores sit in a narrow band near the top of the range — unrelated text scored4# ~0.7 there, so 0.97 was a reasonable "nearly the same". The 3-small model spreads scores much5# wider, so the same number now means "almost character-for-character identical" and the cache6# never hits. The calibration cell below measures where paraphrases actually land.7similarity_threshold = 0.858
9
10def to_hex(vector: list[float]) -> str:11    """Pack an embedding as the little-endian float32 bytes a VECTOR(1536, F32) expects."""12    return np.array(vector, dtype='<f4').tobytes().hex()13
14
15def process_user_question(question: str, category: str = 'chatbot') -> str:16    print(f'\nQuestion asked: {question}')17
18    start = time.time()19    question_embedding = get_embeddings([question])[0]20    print(f'  embedding the question: {(time.time() - start) * 1000:.0f} ms')21
22    lookup = f"""23        SELECT question, answer,24               DOT_PRODUCT(question_embedding, UNHEX(%(qhex)s)) :> float AS score25        FROM {table_name}26        WHERE category = %(category)s27        ORDER BY score DESC28        LIMIT 129    """30
31    with s2_conn.cursor() as cur:32        start = time.time()33        cur.execute(lookup, {'qhex': to_hex(question_embedding), 'category': category})34        row = cur.fetchone()35        print(f'  cache lookup: {(time.time() - start) * 1000:.0f} ms')36
37    if row is not None:38        cached_question, cached_answer, score = row39        print(f'  closest stored question: "{cached_question}" (score {score:.4f})')40
41        if score > similarity_threshold:42            print('  -> cache hit, returning the stored answer')43            return cached_answer44    else:45        print('  cache is empty')46
47    print('  -> cache miss, running the agent')48    start = time.time()49    answer = run_agent(question)50    print(f'  agent run: {(time.time() - start) * 1000:.0f} ms')51
52    answer_embedding = get_embeddings([answer])[0]53
54    insert = f"""55        INSERT INTO {table_name}56            (category, question, question_embedding, answer, answer_embedding, created_at)57        VALUES58            (%(category)s, %(question)s, UNHEX(%(qhex)s), %(answer)s, UNHEX(%(ahex)s),59             %(created_at)s)60    """61
62    with s2_conn.cursor() as cur:63        cur.execute(insert, {64            'category': category,65            'question': question,66            'qhex': to_hex(question_embedding),67            'answer': answer,68            'ahex': to_hex(answer_embedding),69            'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),70        })71
72    return answer

Measure the threshold instead of trusting it

A semantic cache has one tuning knob and two ways to get it wrong. Too high and it never hits. Too low and it returns a stored answer to a question that merely sounds similar — which is worse, because a wrong answer arrives looking exactly like a right one.

So this cell scores a few pairs whose relationship you already know: an identical question, two rewordings, an unrelated question, and one pair that matters more than the rest. It costs a single embeddings request. Read the gap between the paraphrases and the unrelated pair — your threshold belongs inside it. With text-embedding-3-small the rewordings land close to 0.9 and the unrelated pair well below, which is why 0.85 sits where it does. Trust the numbers the cell prints over any number written in prose, including these.

The first line of output is a check rather than a result: DOT_PRODUCT equals cosine similarity only for unit-length vectors, so those norms should print as 1.0.

Watch the last row. Bullish and bearish score close together, because an embedding captures what a sentence is about more strongly than which way round it points — the honest limit of caching on similarity alone.

In [18]:

1# Which similarity a paraphrase actually scores is a property of the embedding model, so this2# measures it rather than trusting a threshold inherited from a different one. Costs one3# embeddings request, and nothing from the Alpha Vantage quota.4CALIBRATION_PAIRS = [5    ('identical but for a stopword', 'describe the database', 'describe database'),6    ('same question, reworded',      'describe the database', 'what tables are in here?'),7    ('same intent, longer',          "what is TSLA's average closing price?",8                                     'give me the mean close for TSLA'),9    ('unrelated',                    'describe the database',10                                     'which tickers have the most bullish news?'),11    ('OPPOSITE meaning',             'which tickers have the most bullish news?',12                                     'which tickers have the most bearish news?'),13]14
15texts = sorted({text for _, left, right in CALIBRATION_PAIRS for text in (left, right)})16vectors = dict(zip(texts, get_embeddings(texts)))17
18# DOT_PRODUCT equals cosine similarity only for unit-length vectors. OpenAI's embeddings are19# normalised, which is what lets the SQL lookup use the cheaper DOT_PRODUCT — worth confirming20# rather than assuming, since the scores below would mean something else if it were not true.21norms = [float(np.linalg.norm(v)) for v in vectors.values()]22print(f'vector norms: {min(norms):.4f} to {max(norms):.4f} '23      f'(1.0 means DOT_PRODUCT is cosine similarity)\n')24
25print(f'{"relationship":<30} score   hit at {similarity_threshold}?')26
27for label, left, right in CALIBRATION_PAIRS:28    score = float(np.dot(vectors[left], vectors[right]))29    print(f'{label:<30} {score:.4f}  {"YES" if score > similarity_threshold else "no"}')30
31print(f'\nsimilarity_threshold is {similarity_threshold}. It wants to sit above the unrelated '32      f'pair and below the rewordings;\nif your numbers differ from the commentary above, trust '33      f'these and adjust it.')

See the cache work

Two questions that mean the same thing, worded differently. The first is a miss: it prints the agent's whole chain of thought — Entering new SQL Agent Executor chain, the queries it writes, the rows it gets back — and takes around 8 seconds. The second scores above the threshold and comes straight back from SingleStore in under a second, nearly all of which is the round trip to embed the question. The absence of that agent chain on the second question is the entire point: that is the latency, the tokens and the money saved.

The cell clears the cache first so the demonstration still works on a re-run — otherwise both questions hit and there is nothing to compare. The SELECT after it shows what got stored.

In [19]:

1question_1 = 'describe the database'2question_2 = 'describe database'3
4# Start from an empty cache, or the demo stops demonstrating anything: on a second run the5# stored copy of question_2 would match itself at 1.0 and "hit" for the wrong reason. Only the6# chatbot rows go — anything else in the table is left alone.7with s2_conn.cursor() as cur:8    cur.execute("DELETE FROM embeddings WHERE category = 'chatbot'")9
10answer = process_user_question(question_1)11print(f'\nThe answer is: {answer}')

In [20]:

1%%sql2SELECT id, category, question, LEFT(answer, 120) AS answer_start FROM embeddings

Now the second question — the same request worded differently. Expect a cache hit with its score, no agent chain, and a total well under a second.

In [21]:

1answer = process_user_question(question_2)2print(f'\nThe answer is: {answer}')

Step 5: Speak and hear

The agent's answer is text. This step turns it into speech, plays it, and transcribes it back — the "speak" and "hear" of the title. This cell only defines helpers, so expect no output. speak(text) writes an mp3 and returns its path, preferring ElevenLabs when you supplied a key and using OpenAI's gpt-4o-mini-tts otherwise, printing which engine and voice produced the file. kernel_value(name) looks up a notebook variable and returns None if the cell that sets it has not run, so re-running step 5 on its own after a kernel restart gives you a message naming the cell to run rather than a bare NameError.

Any answer about your ElevenLabs account — no key, a free plan refusing a library voice, a spent quota — prints what happened and continues with OpenAI's voice. A real error still stops the cell, so the transcription step always has a file to read.

In [22]:

1from IPython.display import Audio, display2
3
4def kernel_value(name: str):5    """A notebook-level value, or None if the cell that sets it has not run.6
7    Step 5 is the section people re-run on its own — after a kernel restart, or to try a8    different voice — and a restart keeps no variables. Looking the keys up this way lets the9    helpers below say which cell to go back and run, instead of raising a bare NameError from10    somewhere inside themselves.11    """12    return globals().get(name)13
14# Free ElevenLabs plans cannot use *library* voices over the API: the request comes back 40215# with "Free users cannot use library voices via the API". Which voices count as library ones16# has changed over time — the long-familiar "Rachel" ID is one of them now — so rather than17# hard-coding an ID, ask the account what it has and pick something that will work.18ELEVENLABS_VOICE_ID = None   # filled in below; set it by hand to override the choice19
20
21def elevenlabs_voices() -> list:22    """The voices this key can see, the ones a free plan can actually use first."""23    key = kernel_value('elevenlabs_apikey')24
25    if not key:26        return []27
28    response = requests.get(29        'https://api.elevenlabs.io/v1/voices',30        headers={'xi-api-key': key},31        timeout=30,32    )33    response.raise_for_status()34
35    # 'premade' voices ship with every account and synthesize on the free plan. Anything the36    # account added from the voice library is paid-only over the API, so sort those last37    # instead of dropping them — they are the right choice on a paid key.38    return sorted(response.json()['voices'],39                  key=lambda voice: voice.get('category') != 'premade')40
41
42def elevenlabs_speak(text: str, voice_id: str) -> bytes:43    """mp3 bytes from ElevenLabs, or None if the plan or key will not allow it."""44    response = requests.post(45        f'https://api.elevenlabs.io/v1/text-to-speech/{voice_id}',46        headers={47            'Accept': 'audio/mpeg',48            'Content-Type': 'application/json',49            'xi-api-key': kernel_value('elevenlabs_apikey'),50        },51        json={52            'text': text,53            'model_id': 'eleven_multilingual_v2',54            'voice_settings': {'stability': 0.5, 'similarity_boost': 0.5},55        },56        timeout=120,57    )58
59    if response.ok:60        return response.content61
62    # ElevenLabs reports plan, key and quota problems as JSON with a 4xx, not as audio. A63    # plan limit is not a broken notebook, so report it and let the caller use OpenAI; a64    # genuine error — bad request, unknown voice, service down — should still stop the cell.65    # The refusal is usually {"detail": {"status": ..., "message": ...}}, but a bare string66    # and an html error page both turn up, so anything else counts as no detail at all.67    detail, code = {}, ''68
69    if 'json' in response.headers.get('Content-Type', ''):70        body = response.json()71
72        if isinstance(body, dict) and isinstance(body.get('detail'), dict):73            detail = body['detail']74            code = detail.get('status') or detail.get('code') or ''75
76    if response.status_code in (401, 402, 429) or code in (77            'payment_required', 'paid_plan_required', 'quota_exceeded'):78        print(f'ElevenLabs declined ({response.status_code} {code or "error"}): '79              f'{detail.get("message", response.text[:200])}')80        print('  Falling back to OpenAI text-to-speech. Nothing else in the notebook changes.')81        return None82
83    raise RuntimeError(f'ElevenLabs returned {response.status_code}: {response.text[:400]}')84
85
86def openai_speak(text: str) -> bytes:87    """mp3 bytes from OpenAI, the fallback that needs no third account."""88    openai_client = kernel_value('client')89
90    if openai_client is None:91        raise RuntimeError(92            'No OpenAI client in this kernel. Re-run the "Set API keys" cell and the cell that '93            'builds `client` in step 2 — a restarted kernel keeps none of its variables.')94
95    return openai_client.audio.speech.create(96        model='gpt-4o-mini-tts',97        voice='alloy',98        input=text,99        response_format='mp3',100    ).read()101
102
103def speak(text: str, path: str = 'output.mp3') -> str:104    """Synthesize `text` to an mp3 at `path`. Uses ElevenLabs if it can, else OpenAI."""105    audio_bytes, engine = None, 'OpenAI gpt-4o-mini-tts'106
107    if kernel_value('elevenlabs_apikey'):108        voice_id = ELEVENLABS_VOICE_ID or (elevenlabs_voices() or [{}])[0].get('voice_id')109
110        if voice_id:111            audio_bytes = elevenlabs_speak(text, voice_id)112            if audio_bytes:113                engine = f'ElevenLabs eleven_multilingual_v2, voice {voice_id}'114
115    if audio_bytes is None:116        audio_bytes = openai_speak(text)117
118    with open(path, 'wb') as f:119        f.write(audio_bytes)120
121    print(f'{engine} wrote {len(audio_bytes):,} bytes to {path}')122    return path

Select a voice

GET /v1/voices lists the voices your key can see, with each one's category. Only premade voices synthesize on the free plan — a voice added from the voice library is paid-only over the API even though it plays fine in the ElevenLabs web app, and the refusal comes when you synthesize, not when you list.

So this cell sorts the premade voices first, sets ELEVENLABS_VOICE_ID to one of them, and labels each row with whether the free plan can use it — assign the variable yourself to override. If you skipped the ElevenLabs key, it prints the OpenAI voices that will be used instead.

In [23]:

1voices = elevenlabs_voices()2
3for voice in voices[:8]:4    category = voice.get('category', 'unknown')5    usable = 'free plan OK' if category == 'premade' else 'paid plan only over the API'6    print(f"  {voice['voice_id']}  {voice['name']:<12} {category:<10} ({usable})")7
8if voices:9    # First entry is a premade voice when the account has one, because of the sort above.10    ELEVENLABS_VOICE_ID = voices[0]['voice_id']11    print(f"\nUsing {voices[0]['name']} ({ELEVENLABS_VOICE_ID}).")12
13    if voices[0].get('category') != 'premade':14        print('  No premade voice on this account, so this one may return 402 — '15              'speak() will fall back to OpenAI if it does.')16elif kernel_value('elevenlabs_apikey') is None:17    print('The "Set API keys" cell has not run in this kernel, so there is no key to list.')18    print('Re-run it if you want ElevenLabs; everything below works on OpenAI without it.')19else:20    print('No ElevenLabs key — using OpenAI text-to-speech.')21    print('OpenAI voices: alloy, echo, shimmer, coral, sage, ash, ballad, verse')

Now speak the answer the agent produced in step 4, and play it back. Expect a line naming the engine and voice, the number of bytes written, and an audio player that starts on its own. The file is output.mp3 in the notebook's working directory.

In [24]:

1audio_path = speak(answer)2display(Audio(filename=audio_path, autoplay=True))

Transcribe it back to text

Reading the audio back with whisper-1 is the "hear" half. Expect a transcript that closely matches the spoken answer — punctuation and capitalisation will differ, and numbers may come back as words, which is a fair illustration of what transcription does and does not preserve. In a real application the input would be a user speaking; round-tripping here means the notebook has an audio file to work with without asking you for a microphone.

In [25]:

1with open(audio_path, 'rb') as audio_file:2    transcript = client.audio.transcriptions.create(model='whisper-1', file=audio_file)3
4print(transcript.text)

Step 6: See

The last modality. newsSentiment stores each article's banner photo URL next to its headline, sentiment scores and publication time, so the image and the structured data describing it are in the same row — and captioning the photo can be grounded in the columns beside it. Three cells: ask the agent for a recent article, fetch that article's photo, caption it with a vision model. Then one more to speak the caption using step 5's speak().

No new dependency and no new account: the vision model is the same OpenAI client from step 2, and captioning is one extra content part in an ordinary chat request.

Ask the agent for an article

A third question for the agent, asking for the most recent Amazon article above a relevance threshold, with its URL and banner image. Expect an agent run — a cache miss, since the question is new — and an answer quoting the article. The %%sql cell after it shows the same articles the agent was looking at. The next cell takes its URL from that table rather than from the agent's prose: for fetching bytes you want the exact string that was stored, not a model's transcription of it.

In [26]:

1question_3 = (2    'What is the most recent news article for Amazon where the topic_relevance_score is '3    'greater than 90%? Include the url, time published and banner image.'4)5
6answer = process_user_question(question_3)7print(f'\nThe answer is: {answer}')

In [27]:

1%%sql2SELECT title, url, time_published, banner_image3FROM newsSentiment4WHERE ticker = 'AMZN' AND topic_relevance_score > 0.95ORDER BY time_published DESC6LIMIT 3

Load the image

This is the one cell that may need a settings change. A hosted notebook reaches the internet through the workspace firewall, which allows a named list of hosts. Alpha Vantage and OpenAI are on it; news publishers' image CDNs are not, and there is no single host to add, because one article's banner sits on s.yimg.com while the next is on www.marketbeat.com or cdn.benzinga.com. So the cell tries one fetch per distinct CDN across the most recent articles, keeping the first image that arrives. Expect either a photo rendered inline, or a short report per host.

If every host is blocked, the cell prints the hostnames it tried — those are exactly what to allow, on a Cannot access <host> toast in the portal or in the workspace group's firewall settings, which take wildcards such as *.yimg.com. Nothing above this point touches those hosts, so leaving the firewall alone costs you step 6 only.

The cell fetches the bytes in the kernel rather than pointing the browser or OpenAI at the remote URL, for the reason the next cell explains.

In [28]:

1import base642from urllib.parse import urlparse3
4from IPython.display import HTML, display5
6# Take the URLs from the table rather than the model's prose, so they are exactly what we7# stored. Several candidates, not one: banner images are hosted on whatever CDN the publisher8# uses, and this kernel can only reach the hosts its firewall allows (see below).9with s2_conn.cursor() as cur:10    cur.execute("""11        SELECT title, url, time_published, banner_image12        FROM newsSentiment13        WHERE ticker = 'AMZN'14          AND topic_relevance_score > 0.915          AND banner_image != ''16          AND banner_image != 'None'17        ORDER BY time_published DESC18        LIMIT 2519    """)20    candidates = cur.fetchall()21
22assert candidates, (23    'No AMZN article with a banner image and topic_relevance_score > 0.9. Loosen the filter, '24    'or check that the news ingest above actually inserted rows.'25)26
27MAX_HOSTS = 6   # distinct image hosts to try before giving up, ~5s each when blocked28
29def image_mime(data: bytes, header: str = '') -> str:30    """The image's type, from the response header when it sent one, else from the magic bytes."""31    if header.startswith('image/'):32        return header.split(';')[0].strip()33
34    for prefix, mime in [(b'\xff\xd8\xff', 'image/jpeg'), (b'\x89PNG\r\n\x1a\n', 'image/png'),35                         (b'GIF87a', 'image/gif'), (b'GIF89a', 'image/gif')]:36        if data.startswith(prefix):37            return mime38
39    if data[:4] == b'RIFF' and data[8:12] == b'WEBP':40        return 'image/webp'41
42    return ''43
44
45# Fetch the bytes here in the kernel, then use those bytes everywhere below. It costs one46# request and removes two points of failure: your browser has to reach the news CDN to render a47# remote <img>, and OpenAI's servers have to reach it to caption one from a URL. The symptoms of48# either are unhelpful — a broken image, or a captioning call that simply hangs.49def fetch_image(url: str) -> tuple:50    """The image bytes and mime type, or (None, '') having said what went wrong.51
52    A host that is merely unhappy answers with a status code; one that is blocked does not53    answer at all and raises instead. Both happen with news banner images, so both are handled.54    """55    try:56        # Separate connect and read timeouts. A firewall-blocked host fails at connect, and57        # waiting 30 seconds for each of those is most of a coffee break for no information.58        response = requests.get(url, timeout=(5, 30))59    except requests.RequestException as exc:60        print(f'    unreachable: {type(exc).__name__}')61        return None, ''62
63    if not response.ok:64        print(f'    HTTP {response.status_code}')65        return None, ''66
67    mime = image_mime(response.content, response.headers.get('Content-Type', ''))68
69    if not mime:70        # Usually an error page served with a 200 status, which would otherwise be embedded71        # as a data URL of some html and captioned as if it were a photograph.72        print(f'    {len(response.content):,} bytes, but not a recognisable image')73        return None, ''74
75    print(f'    {len(response.content):,} bytes, {mime}')76    return response.content, mime77
78
79# This kernel reaches the internet through the workspace firewall, which allows a specific list80# of hosts. Alpha Vantage and OpenAI are on it because the notebook needs them; publishers'81# image CDNs are not, and which CDN a banner uses depends on the article. So try the candidates82# in turn, one attempt per distinct host, and keep the first image that actually arrives.83title = article_url = published = banner_image_url = None84banner_image_bytes, banner_image_mime, banner_image_data_url = None, '', None85tried = []86
87for candidate in candidates:88    candidate_title, _, candidate_published, candidate_banner = candidate89    host = urlparse(candidate_banner).hostname or ''90
91    if not host or host in tried:92        continue93
94    tried.append(host)95    print(f'{host}  ({candidate_published}  {candidate_title[:60]})')96    data, mime = fetch_image(candidate_banner)97
98    if data:99        title, article_url, published, banner_image_url = candidate100        banner_image_bytes, banner_image_mime = data, mime101        break102
103    if len(tried) >= MAX_HOSTS:104        break105
106if banner_image_bytes:107    # A data: URL renders with no network access at all, and works for webp, which the news108    # wires use heavily and IPython.display.Image will not inline.109    banner_image_data_url = (110        f'data:{banner_image_mime};base64,'111        + base64.b64encode(banner_image_bytes).decode('ascii')112    )113    print(f'\nCaptioning the banner from: {title}')114    display(HTML(f'<img src="{banner_image_data_url}" alt="{title}" '115                 f'style="max-width: 100%; height: auto">'))116else:117    print(f'\nNone of those {len(tried)} hosts returned an image. That is the workspace '118          'firewall: the kernel can only reach hosts it allows, and image CDNs are not on the '119          'list. Allow any one of these and re-run this cell:')120
121    for host in tried:122        print(f'    {host}')123
124    print('The portal raises a "Cannot access <host>" toast for each of them; the ones naming a '125          'domain have an "Add to Firewall" link that does it in one click. (Toasts showing a '126          'bare IP address are the same requests seen lower down and have no link — the domain '127          'is what the rule needs.) A wildcard like *.yimg.com works in the workspace group\'s '128          'firewall settings if you would rather not add each subdomain.')129    print('Step 6 is the only part of the notebook that needs these hosts, so everything above '130          'keeps working either way.')

Caption the image

Expect one or two sentences describing the photo, in the style of alt text, after a few seconds.

The image goes to the model in the same message as the instruction, as the base64 data: URL the previous cell built. Sending the news site's own link instead would make OpenAI's servers fetch it, which fails as a long hang followed by an error about a URL you can open yourself.

The article's headline is passed as context alongside the image. That is the point the step is making: the caption is grounded in a structured column from the very same row as the photo.

In [29]:

1assert banner_image_data_url, (2    'No image bytes to caption — the cell above could not fetch a banner from any of the hosts '3    'it tried. Allow one of the hostnames it listed in the workspace firewall and re-run it. '4    'Captioning deliberately does not fall back to the remote URL: that would make OpenAI fetch '5    'the CDN instead, which usually ends in a long hang rather than a clear message.'6)7
8response = client.chat.completions.create(9    model=CHAT_MODEL,10    messages=[{11        'role': 'user',12        'content': [13            {14                'type': 'text',15                'text': (16                    'Caption this image from a news article in one or two sentences, '17                    'as you would for a screen reader. Describe only what is visible. '18                    f'For context, the article headline is: "{title}"'19                ),20            },21            # The data: URL from the cell above, so OpenAI captions the bytes we already22            # hold rather than trying to fetch the news CDN itself.23            {'type': 'image_url', 'image_url': {'url': banner_image_data_url}},24        ],25    }],26    max_completion_tokens=150,27)28
29caption = response.choices[0].message.content30print(caption)

Finally, speak the caption with the same speak() helper from step 5, writing caption.mp3 this time so the answer's audio is still there to compare. All four modalities have now been through one database: rows in, question in English, answer spoken, image seen and described.

In [30]:

1caption_path = speak(caption, path='caption.mp3')2display(Audio(filename=caption_path, autoplay=True))

Troubleshooting

The errors worth knowing about before you need them. The two marked lies about its cause are the reason this table exists.

| What you see | What it means | What to do | | --- | --- | --- | | NameError or ModuleNotFoundError | The cell that defines it has not run in this kernel; a restart keeps no variables | Re-run step 2 from the top | | OperationalError: 2628: JWT token expired | The connection URL's password is a JWT that lasts about an hour | Restart the kernel, which gets a fresh token | | no database is selected, or missing tables | The drop-down at the top points somewhere other than llm_webinar | Select the database, re-run step 1, then the connect cell | | Lies about its cause: APIConnectionError under a successful HTTP/1.1 200 OK | Not the network and not your key — a brotli decoder failed after the response arrived | Re-run the OpenAI client cell in step 2, which turns brotli off | | stopped early on the quota limit, or a pause and retry, in step 3 | The two Alpha Vantage limits: 25 per day, and about one per second | The per-second one retries itself. For the daily one, continue with partial data or re-run after the reset | | Lies about its cause: the cache never hits, and nothing errors | The similarity threshold does not match the embedding model | Run the calibration cell in step 4 and use the measured gap | | AttributeError: 'VECTOR' object has no attribute '_str_impl' | A SQLAlchemy-only bug in some builds of the SingleStore dialect | Re-run the agent cell in step 4, which patches it in | | ElevenLabs declined (402 paid_plan_required) | Free plans can only synthesize premade voices | Nothing — it falls back to OpenAI | | Cannot access <host> in step 6 | News image CDNs are not on the workspace firewall's allowlist | Add to Firewall on a toast naming a domain, then re-run that cell |

What you built

One database, four modalities. Prices, company profiles and news went into SingleStore; a question asked in English came back answered; the answer was spoken and heard again as text; and a photo belonging to one of those rows was described in a sentence grounded in the row beside it.

None of it needed a second datastore. The cache lookup in step 4 is a SELECT with a DOT_PRODUCT against the same tables the agent queries — no export, no sync job, and because the cache is just a table, you can SELECT from it to see why it hit.

Reset the demo

The cell below drops the llm_webinar database — tables, rows, cache and all. Nothing else in this notebook destroys data, so it is the only cell to be careful with.

Refilling the tables costs about a dozen Alpha Vantage requests against an allowance of 25 per day. To start the demo over without touching the data, re-run the cache test in step 4 instead.

In [31]:

1%%sql2DROP DATABASE IF EXISTS llm_webinar;

Details


About this Template

Using OpenAI to build an app that can take images, audio, and text data to generate output

This Notebook can be run in Standard and Enterprise deployments.

Tags

advancedopenaigenaivectordb

See Notebook in action

Launch this notebook in SingleStore and start executing queries instantly.

License

This Notebook has been released under the Apache 2.0 open source license.