ruthran commited on
Commit
b7cd519
verified
1 Parent(s): 7784a27

Create chatbot_gemini.py

Browse files
Files changed (1) hide show
  1. 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