Spaces:
Running on Zero
Running on Zero
Create chatbot_gemini.py
Browse files- chatbot_gemini.py +218 -0
chatbot_gemini.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NOTE: this is the Hugging Face Space copy of the Google AI Studio build. It is
|
| 2 |
+
# the same shape as ../chatbot_ollama.py - only the provider changed. Compare the
|
| 3 |
+
# two side by side: the observability code is identical, which is the point.
|
| 4 |
+
import asyncio # lets Python do jobs that involve waiting, like calls over the internet
|
| 5 |
+
import json # reads and writes data in the JSON text format
|
| 6 |
+
import os # lets us read settings and secrets from the computer
|
| 7 |
+
import re # finds and removes patterns inside a piece of text
|
| 8 |
+
import time # used to measure how long something takes
|
| 9 |
+
import urllib.error # holds the kinds of errors a web request can give back
|
| 10 |
+
import urllib.request # lets us send a request to a website by hand
|
| 11 |
+
import uuid # makes random one-of-a-kind id numbers
|
| 12 |
+
from pathlib import Path # an easy way to point at files and folders
|
| 13 |
+
from typing import Annotated # lets us attach a short description to a value
|
| 14 |
+
|
| 15 |
+
from agent_framework import Agent, Message # the chatbot itself, and one chat message
|
| 16 |
+
from agent_framework.observability import enable_instrumentation # switches on the recording of what the bot does
|
| 17 |
+
from agent_framework.openai import OpenAIChatCompletionClient # the part that talks to the model provider
|
| 18 |
+
from dotenv import load_dotenv # reads secrets out of a .env file
|
| 19 |
+
from langfuse import Langfuse, propagate_attributes # Langfuse keeps the recordings; the second one labels them
|
| 20 |
+
from pydantic import Field # used to describe what a piece of information means
|
| 21 |
+
|
| 22 |
+
load_dotenv(Path(__file__).parent / ".env") # load the secrets from the .env file sitting next to this file
|
| 23 |
+
MODEL = os.getenv("GOOGLE_MODEL", "gemma-4-31b-it") # which model to use, with a sensible default
|
| 24 |
+
API_KEY = os.environ["GOOGLE_API_KEY"] # the secret password for Google AI Studio; the program stops here if it was never set
|
| 25 |
+
|
| 26 |
+
# Google speaks its own protocol, but it also offers an OpenAI-shaped door into the
|
| 27 |
+
# same models. We use that door, so the client below is the very same class the
|
| 28 |
+
# Ollama build uses - only the address and the key are different.
|
| 29 |
+
BASE_URL = os.getenv("GOOGLE_BASE_URL", "https://generativelanguage.googleapis.com/v1beta/openai")
|
| 30 |
+
|
| 31 |
+
langfuse = Langfuse() # open the connection to Langfuse, our recording tool
|
| 32 |
+
enable_instrumentation(enable_sensitive_data=True) # start recording, including the real message text
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# Asks a Google endpoint a question and hands back the parsed JSON answer.
|
| 36 |
+
def google_get(path: str) -> dict: # start of the small helper that calls Google for us
|
| 37 |
+
"""GET one path under BASE_URL, signed with the API key."""
|
| 38 |
+
request = urllib.request.Request( # build the request, because Google needs the key in a header
|
| 39 |
+
f"{BASE_URL}/{path}", headers={"Authorization": f"Bearer {API_KEY}"}
|
| 40 |
+
)
|
| 41 |
+
with urllib.request.urlopen(request, timeout=30) as response: # send it, giving up after 30 seconds
|
| 42 |
+
return json.load(response) # turn the JSON text we got back into Python data
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# Explains an API-key problem in plain words, then stops the program.
|
| 46 |
+
def stop_with_key_help(status: int, detail: str) -> None: # start of the give-up-and-explain helper
|
| 47 |
+
"""Google answers 400 for a bad key and 403 for a key that is not allowed here."""
|
| 48 |
+
env_file = Path(__file__).parent / ".env" # work out where the .env file lives, to mention it below
|
| 49 |
+
raise SystemExit( # stop right now and print something the reader can act on
|
| 50 |
+
f"\n[google] Google AI Studio would not accept GOOGLE_API_KEY "
|
| 51 |
+
f"(HTTP {status}).\n"
|
| 52 |
+
f" The key ends '...{API_KEY[-6:]}' ({len(API_KEY)} chars).\n"
|
| 53 |
+
f" Google said: {detail.strip()[:160]}\n\n"
|
| 54 |
+
f" Get a working key at https://aistudio.google.com/apikey\n"
|
| 55 |
+
f" and put it in {env_file}\n"
|
| 56 |
+
f" On a Hugging Face Space there is no .env - add it under\n"
|
| 57 |
+
f" Settings > Variables and secrets instead.\n"
|
| 58 |
+
) from None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
try: # try to read the model list, and catch the trouble if it goes wrong
|
| 62 |
+
listing = google_get("models") # ask Google which models this key may use
|
| 63 |
+
except urllib.error.HTTPError as error: # this runs only if Google answered with an error
|
| 64 |
+
stop_with_key_help(error.code, error.read().decode(errors="replace")) # explain the key problem and stop
|
| 65 |
+
|
| 66 |
+
# Google returns names like "models/gemma-4-31b-it"; keep only the part after the slash.
|
| 67 |
+
AVAILABLE = [m["id"].split("/")[-1] for m in listing["data"]] # the plain name of every model on offer
|
| 68 |
+
|
| 69 |
+
if MODEL not in AVAILABLE: # if the model we asked for is not on that list
|
| 70 |
+
raise SystemExit( # stop the program right now and print a helpful message instead
|
| 71 |
+
f"[google] '{MODEL}' is not available to this key. Available:\n "
|
| 72 |
+
+ "\n ".join(sorted(AVAILABLE))
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# Sends one tiny message at startup to check that the key really works for chatting.
|
| 77 |
+
def check_key() -> None: # start of the key check; it hands nothing back
|
| 78 |
+
"""Send the cheapest possible chat request just to see if the key is accepted."""
|
| 79 |
+
request = urllib.request.Request( # build a very small chat request by hand
|
| 80 |
+
f"{BASE_URL}/chat/completions",
|
| 81 |
+
data=json.dumps({"model": MODEL, "max_tokens": 1,
|
| 82 |
+
"messages": [{"role": "user", "content": "hi"}]}).encode(),
|
| 83 |
+
headers={"Authorization": f"Bearer {API_KEY}",
|
| 84 |
+
"Content-Type": "application/json"},
|
| 85 |
+
)
|
| 86 |
+
try: # try the next line, and catch the trouble if it goes wrong
|
| 87 |
+
urllib.request.urlopen(request, timeout=30).read() # send it and wait up to 30 seconds for a reply
|
| 88 |
+
except urllib.error.HTTPError as error: # this runs only if Google answered with an error
|
| 89 |
+
if error.code not in (400, 401, 403): # if it is not a key problem and not a permission problem
|
| 90 |
+
raise # pass the error on, because we have no better advice to offer
|
| 91 |
+
stop_with_key_help(error.code, error.read().decode(errors="replace")) # explain and stop
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
check_key() # run that check now, before anything else gets started
|
| 95 |
+
print(f"[google] {MODEL} via AI Studio - key accepted, {len(AVAILABLE)} models offered") # tell the person all is well
|
| 96 |
+
|
| 97 |
+
ACCOUNTS = {"SB-9001": 84_215.50, "SB-9002": 12_430.00, "SB-9003": 3_46_890.25} # a pretend bank: account number and money in it
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# The tool the model can call: gives back the balance for one account number.
|
| 101 |
+
def check_balance( # start of the balance tool that the chatbot is allowed to use
|
| 102 |
+
account_id: Annotated[str, Field(description="Account id, e.g. SB-9001")],
|
| 103 |
+
) -> str:
|
| 104 |
+
"""Look up the balance of a Meridian Bank account."""
|
| 105 |
+
balance = ACCOUNTS.get(account_id.upper()) # look the account up, ignoring small or capital letters
|
| 106 |
+
return f"{account_id}: Rs {balance:,.2f}" if balance else f"No account {account_id}." # give back the money, or say there is no such account
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
agent = Agent( # build the chatbot: who it talks to, its rules, its name, and the tools it may use
|
| 110 |
+
OpenAIChatCompletionClient(model=MODEL, api_key=API_KEY, base_url=BASE_URL),
|
| 111 |
+
"You are Meridian Bank's assistant. Branches open Mon-Fri 9:30-16:30, "
|
| 112 |
+
"Sat 9:30-13:30. Savings pays 2.75%, fixed deposits 7.25%. Use the tool for "
|
| 113 |
+
"any balance - never guess one. Be brief.",
|
| 114 |
+
name="MeridianAssist",
|
| 115 |
+
tools=[check_balance],
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# Gemma thinks out loud before answering, and wraps that thinking in <thought> tags.
|
| 119 |
+
# Google will not let us switch it off for this model ("Thinking budget is not
|
| 120 |
+
# supported for this model"), so we cut the tags out before showing the reply.
|
| 121 |
+
THOUGHTS = re.compile(r"<thought>.*?</thought>", re.DOTALL) # a pattern that matches one whole thinking block
|
| 122 |
+
UNCLOSED = re.compile(r"<thought>.*", re.DOTALL) # a thinking block that was opened and never closed
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# Removes Gemma's thinking blocks, leaving just the answer meant for the reader.
|
| 126 |
+
def strip_thoughts(text: str) -> str: # start of the tidy-up helper for Gemma's replies
|
| 127 |
+
"""Drop Gemma's thinking and leave only the answer.
|
| 128 |
+
|
| 129 |
+
The tags are not always balanced. A reply can end with a stray '</thought>'
|
| 130 |
+
that never had an opener, and a reply that was cut short can open a block it
|
| 131 |
+
never closes - so all three cases are handled, in this order.
|
| 132 |
+
"""
|
| 133 |
+
text = THOUGHTS.sub("", text) # first take out every properly closed thinking block
|
| 134 |
+
text = UNCLOSED.sub("", text) # then drop an opener with no closer, and everything after it
|
| 135 |
+
return text.replace("</thought>", "").strip() # finally remove any orphan closing tag, and trim
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# Turns whatever Gradio hands us as a message into a plain string.
|
| 139 |
+
def plain_text(content: object) -> str: # start of the tidy-up helper; it always hands back plain text
|
| 140 |
+
"""Flatten one Gradio history entry's content down to a string."""
|
| 141 |
+
if isinstance(content, str): # if it is already plain text
|
| 142 |
+
return content # hand it straight back, untouched
|
| 143 |
+
if isinstance(content, list): # if it is a list of several pieces
|
| 144 |
+
return "".join(plain_text(part) for part in content) # tidy up each piece and glue them together
|
| 145 |
+
if isinstance(content, dict): # if it is a labelled box of values
|
| 146 |
+
return str(content.get("text") or content.get("content") or "") # pull the text out of it, or use nothing
|
| 147 |
+
return str(content) # anything else: simply turn it into text
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# Starts a new conversation: a fresh id and counters set back to zero.
|
| 151 |
+
def new_session() -> dict: # start of the new-conversation helper
|
| 152 |
+
"""Fresh, empty running totals for one conversation."""
|
| 153 |
+
return {"id": f"chat-{uuid.uuid4().hex[:12]}", "turns": 0, # hand back a brand new id plus counters starting at zero
|
| 154 |
+
"tokens_in": 0, "tokens_out": 0, "tokens_think": 0, "seconds": 0.0}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# Builds the Langfuse link that shows every turn of one conversation together.
|
| 158 |
+
def session_url(trace_id: str, session_id: str) -> str: # start of the link builder
|
| 159 |
+
"""Link to the session view in Langfuse."""
|
| 160 |
+
trace_url = langfuse.get_trace_url(trace_id=trace_id) or "" # ask Langfuse for the link to this one turn
|
| 161 |
+
return f"{trace_url.rsplit('/traces/', 1)[0]}/sessions/{session_id}" # change the end of that link so it points at the whole chat
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# Runs one chat turn: asks the model, traces it, returns the reply and the token report.
|
| 165 |
+
def ask(message: str, history: list, session: dict | None = None) -> tuple[str, str]: # start of one chat turn
|
| 166 |
+
session = new_session() if session is None else session # begin a fresh conversation if none was handed in
|
| 167 |
+
past = [Message(m["role"], [plain_text(m["content"])]) for m in history] # put the older messages into the shape the bot expects
|
| 168 |
+
started = time.perf_counter() # note the time now, so we can measure how long this takes
|
| 169 |
+
|
| 170 |
+
with propagate_attributes(session_id=session["id"]), \
|
| 171 |
+
langfuse.start_as_current_observation(name="chat turn", as_type="agent") as span:
|
| 172 |
+
trace_id = span.trace_id # remember this recording's id so we can link to it later
|
| 173 |
+
try: # try to answer, and catch the trouble if it goes wrong
|
| 174 |
+
result = asyncio.run(agent.run([*past, Message("user", [message])])) # send the old messages plus the new one, and wait for the answer
|
| 175 |
+
except Exception as error: # this runs only if something went wrong along the way
|
| 176 |
+
span.update(level="ERROR", status_message=str(error)) # mark this recording as failed and save the reason
|
| 177 |
+
langfuse.flush() # push the recording over to Langfuse straight away
|
| 178 |
+
return (f"That turn failed: {type(error).__name__}", # hand back a short apology plus a link to the failed recording
|
| 179 |
+
f"**Error** 路 {error}\n\n"
|
| 180 |
+
f"[See the failed trace in Langfuse]"
|
| 181 |
+
f"({langfuse.get_trace_url(trace_id=trace_id)})")
|
| 182 |
+
|
| 183 |
+
elapsed = time.perf_counter() - started # work out how many seconds the whole turn took
|
| 184 |
+
langfuse.flush() # push the recording over to Langfuse now, rather than whenever it feels like it
|
| 185 |
+
|
| 186 |
+
used = result.usage_details or {} # the token counts, or an empty box if none were reported
|
| 187 |
+
tokens_in = used.get("input_token_count") or 0 # how many tokens we sent to the model
|
| 188 |
+
tokens_out = used.get("output_token_count") or 0 # how many tokens of answer the model sent back
|
| 189 |
+
total = used.get("total_token_count") or 0 # every token the model charged us for, thinking included
|
| 190 |
+
# Google bills the thinking but does not report it as output, so the two never add
|
| 191 |
+
# up. The difference is the thinking, and naming it is the whole lesson here.
|
| 192 |
+
tokens_think = max(total - tokens_in - tokens_out, 0) # whatever is left over is thinking, never below zero
|
| 193 |
+
|
| 194 |
+
session["turns"] += 1 # count this turn in the conversation's running total
|
| 195 |
+
session["tokens_in"] += tokens_in # add the tokens we sent to the running total
|
| 196 |
+
session["tokens_out"] += tokens_out # add the tokens we received to the running total
|
| 197 |
+
session["tokens_think"] += tokens_think # add the hidden thinking tokens to the running total
|
| 198 |
+
session["seconds"] += elapsed # add this turn's seconds to the running total
|
| 199 |
+
|
| 200 |
+
report = (f"**This turn** 路 **{tokens_in:,}** tokens in 路 **{tokens_out:,}** out 路 " # build the little summary shown beside the chat
|
| 201 |
+
f"**{tokens_think:,}** thinking 路 **{elapsed:.2f}s**\n\n"
|
| 202 |
+
f"**All {session['turns']} turn(s)** 路 **{session['tokens_in']:,}** in 路 "
|
| 203 |
+
f"**{session['tokens_out']:,}** out 路 "
|
| 204 |
+
f"**{session['tokens_think']:,}** thinking 路 "
|
| 205 |
+
f"**{session['tokens_in'] + session['tokens_out'] + session['tokens_think']:,}** total 路 "
|
| 206 |
+
f"**{session['seconds']:.2f}s**\n\n"
|
| 207 |
+
f"[This turn's trace]({langfuse.get_trace_url(trace_id=trace_id)}) 路 "
|
| 208 |
+
f"[The whole conversation]({session_url(trace_id, session['id'])})")
|
| 209 |
+
return strip_thoughts(result.text), report # hand back the bot's answer, thinking removed, and that summary
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
if __name__ == "__main__": # only run the little test below when this file is started directly
|
| 213 |
+
session, history = new_session(), [] # begin a fresh conversation with nothing said yet
|
| 214 |
+
for question in ["What's the balance on SB-9001?", "And SB-9003?"]: # ask these two questions, one after the other
|
| 215 |
+
answer, summary = ask(question, history, session) # send the question and collect the answer and summary
|
| 216 |
+
history += [{"role": "user", "content": question}, # remember what we asked and what the bot replied
|
| 217 |
+
{"role": "assistant", "content": answer}]
|
| 218 |
+
print(f"\n> {question}\n{answer}\n\n{summary}") # show the question, the answer and the summary on screen
|