Fix chatbot

#1
by djcotto - opened
Files changed (6) hide show
  1. .gitignore +8 -0
  2. app.py +333 -37
  3. bedrock_client.py +4 -5
  4. requirements.txt +8 -12
  5. static/deval.css +8 -0
  6. utils.py +0 -10
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ .env
3
+
4
+ CLAUDE.md
5
+
6
+ __pycache__/
7
+ *.pyc
8
+ .gradio/
app.py CHANGED
@@ -1,48 +1,335 @@
 
 
 
 
 
 
 
1
  import gradio as gr
 
 
2
  from bedrock_client import bedrock_llm
3
- from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
4
- import os
5
- from setuptools._distutils.util import strtobool
6
 
7
- MULTIMODAL = os.environ.get("MULTIMODAL", "false")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- # 1) convert common truthy/falsy strings to bool
10
- try:
11
- MULTIMODAL = bool(strtobool(MULTIMODAL))
12
- except ValueError:
13
- # catch unrecognized values
14
- raise ValueError(f"Invalid MULTIMODAL value: Use true/false, 1/0, yes/no.")
15
 
16
- AUTHS = [(os.environ.get('USER'), os.environ.get('PW'))]
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  SYSTEM_PROMPT = os.environ.get('SYSTEM_PROMPT', '')
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- def chat(message, history):
22
 
23
- # 1) start with the system prompt
24
- history_langchain_format: list = [SystemMessage(content=SYSTEM_PROMPT)]
 
 
25
 
26
- # 2) replay the user/assistant turns
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  for msg in history:
 
 
 
 
 
 
 
 
 
 
28
  if msg["role"] == "user":
29
- history_langchain_format.append(HumanMessage(content=msg["content"]))
30
  elif msg["role"] == "assistant":
31
- history_langchain_format.append(AIMessage(content=msg["content"]))
 
 
 
 
 
 
 
 
 
 
32
 
33
- # 3) append the new user message
34
- history_langchain_format.append(HumanMessage(content=message))
 
 
 
 
 
 
 
35
 
36
- stream =bedrock_llm.stream(history_langchain_format)
 
37
 
38
- full = next(stream)
 
39
 
40
- for chunk in stream:
41
- full +=chunk
42
- yield full.content
43
 
 
 
44
 
45
- with gr.Blocks(css_paths=["static/deval.css"],theme = gr.themes.Default(primary_hue="blue", secondary_hue="yellow"),) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  # ── Logo + Header + Logout ────────────────────────────────
47
 
48
 
@@ -67,21 +354,30 @@ with gr.Blocks(css_paths=["static/deval.css"],theme = gr.themes.Default(primary_
67
  "sowie sensible erhobene Daten (wie etwa Interviewtranskripte).", elem_id="header-text"
68
  )
69
 
70
- # inject auto-reload script
71
- gr.HTML(
72
- """
73
- <script>
74
- // Reload the page after 1 minutes (300 000 ms)
75
- setTimeout(() => {
76
- window.location.reload();
77
- }, 1000);
78
- </script>
79
- """
80
- )
81
  gr.ChatInterface(
82
  chat,
83
  type="messages",
84
  multimodal=MULTIMODAL,
 
 
 
 
 
 
 
 
 
 
 
85
  editable=True,
86
  concurrency_limit=20,
87
  save_history=True,
@@ -89,4 +385,4 @@ with gr.Blocks(css_paths=["static/deval.css"],theme = gr.themes.Default(primary_
89
 
90
 
91
 
92
- demo.queue().launch(auth=AUTHS, share=True, ssr_mode=False)
 
1
+ import base64
2
+ import logging
3
+ import mimetypes
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+
8
  import gradio as gr
9
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
10
+
11
  from bedrock_client import bedrock_llm
 
 
 
12
 
13
+ logging.basicConfig(level=logging.INFO)
14
+ logger = logging.getLogger(__name__)
15
+
16
+ ERROR_MESSAGE = (
17
+ "⚠️ Bei der Verarbeitung deiner Nachricht ist ein Fehler aufgetreten. "
18
+ "Bitte versuche es erneut. Falls der Fehler bestehen bleibt, starte eine "
19
+ "neue Unterhaltung."
20
+ )
21
+
22
+ # Upload types Bedrock Converse accepts. Anything else is refused up front rather
23
+ # than failing inside the model call. The model in MODEL_ID also has to support
24
+ # them: claude-sonnet-4-6 handles both images and documents.
25
+ SUPPORTED_IMAGES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
26
+ SUPPORTED_DOCUMENTS = {
27
+ "application/pdf",
28
+ "application/msword",
29
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
30
+ "application/vnd.ms-excel",
31
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
32
+ "text/csv",
33
+ "text/html",
34
+ "text/markdown",
35
+ "text/plain",
36
+ }
37
+ SUPPORTED_UPLOADS = SUPPORTED_IMAGES | SUPPORTED_DOCUMENTS
38
+
39
+ # The extensions the upload button offers, mapped to the MIME type we send. Spelled
40
+ # out rather than left to mimetypes, whose table varies with the interpreter the
41
+ # Space builds; an extension the button offers but upload_block() then refuses
42
+ # looks like a bug to the user. Every value must appear in SUPPORTED_UPLOADS.
43
+ EXTENSION_MIME_TYPES = {
44
+ ".png": "image/png",
45
+ ".jpg": "image/jpeg",
46
+ ".jpeg": "image/jpeg",
47
+ ".gif": "image/gif",
48
+ ".webp": "image/webp",
49
+ ".pdf": "application/pdf",
50
+ ".doc": "application/msword",
51
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
52
+ ".xls": "application/vnd.ms-excel",
53
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
54
+ ".csv": "text/csv",
55
+ ".html": "text/html",
56
+ ".htm": "text/html",
57
+ ".md": "text/markdown",
58
+ ".txt": "text/plain",
59
+ }
60
+ UPLOAD_FILE_TYPES = sorted(EXTENSION_MIME_TYPES)
61
+
62
+ UPLOAD_ERROR = (
63
+ "⚠️ Diese Dateien kann ich nicht verarbeiten: {names}. Unterstützt werden "
64
+ "Bilder (PNG, JPEG, GIF, WebP) sowie PDF, DOC/DOCX, XLS/XLSX, CSV, HTML, "
65
+ "Markdown und Text."
66
+ )
67
+
68
+ # Shown to the model in place of an upload from an earlier turn
69
+ FILE_PLACEHOLDER = "[Datei aus einer frΓΌheren Nachricht: {name}]"
70
+
71
+ # Some models reject a turn that carries files but no text
72
+ DEFAULT_UPLOAD_PROMPT = "Bitte werte die angehΓ€ngten Dateien aus."
73
+
74
+ # Bedrock allows only alphanumerics, whitespace, hyphens, parentheses and square
75
+ # brackets in a document name, and no runs of whitespace
76
+ UNSAFE_DOCUMENT_NAME = re.compile(r"[^A-Za-z0-9\s\-()\[\]]")
77
+
78
+ TRUTHY = {"1", "true", "yes", "y", "on"}
79
+ FALSY = {"0", "false", "no", "n", "off"}
80
+
81
+
82
+ def env_bool(name, default="false"):
83
+ """Read a boolean environment variable, failing loudly on anything else.
84
+
85
+ An empty variable counts as unset, so a blank Space setting cannot crash
86
+ startup.
87
+ """
88
+ raw = (os.environ.get(name) or default).strip().lower()
89
+ if raw in TRUTHY:
90
+ return True
91
+ if raw in FALSY:
92
+ return False
93
+ raise ValueError(f"Invalid {name} value {raw!r}: use true/false, 1/0, yes/no.")
94
 
 
 
 
 
 
 
95
 
96
+ # On by default now that MODEL_ID points at a model that can read the uploads
97
+ MULTIMODAL = env_bool("MULTIMODAL", "true")
98
+
99
+ # DEVALBOT_ prefixed so the shell's own USER variable cannot become the login
100
+ DEVALBOT_USER = os.environ.get("DEVALBOT_USER")
101
+ DEVALBOT_PW = os.environ.get("DEVALBOT_PW")
102
+ if not DEVALBOT_USER or not DEVALBOT_PW:
103
+ raise RuntimeError(
104
+ "DEVALBOT_USER and DEVALBOT_PW must both be set; refusing to start with "
105
+ "incomplete credentials."
106
+ )
107
+ AUTHS = [(DEVALBOT_USER, DEVALBOT_PW)]
108
 
109
  SYSTEM_PROMPT = os.environ.get('SYSTEM_PROMPT', '')
110
 
111
+ # Every turn resends the whole conversation, so it has to stay inside the
112
+ # model's context window. claude-sonnet-4-6 offers 1M tokens; at roughly 4
113
+ # characters per token that is ~4M characters, so the window is not the binding
114
+ # constraint here β€” cost and latency are, because each turn resends everything.
115
+ # 500k characters (~125k tokens, an eighth of the window) buys a very long
116
+ # conversation while keeping per-turn spend bounded. Raise it via the
117
+ # environment if you would rather trade cost for a longer memory, and lower it
118
+ # alongside MODEL_ID if you move to a smaller model.
119
+ MAX_HISTORY_CHARS = int(os.environ.get("MAX_HISTORY_CHARS", "500000"))
120
+
121
+ if len(SYSTEM_PROMPT) > MAX_HISTORY_CHARS // 2:
122
+ logger.warning(
123
+ "SYSTEM_PROMPT uses %d of the %d character budget, leaving little room "
124
+ "for the conversation", len(SYSTEM_PROMPT), MAX_HISTORY_CHARS,
125
+ )
126
+
127
+
128
+ def exchanges(turns):
129
+ """Group a flat turn list into [user, assistant...] exchanges."""
130
+ grouped: list = []
131
+ for msg in turns:
132
+ if isinstance(msg, HumanMessage) or not grouped:
133
+ grouped.append([msg])
134
+ else:
135
+ grouped[-1].append(msg)
136
+ return grouped
137
+
138
+
139
+ def trim_history(turns, budget):
140
+ """Keep the newest complete exchanges that fit into budget characters.
141
+
142
+ Trimming whole exchanges rather than individual messages keeps the result
143
+ well formed: Bedrock Converse rejects a conversation that does not start
144
+ with a user turn, and an assistant turn is worthless without the question
145
+ it answers.
146
+ """
147
+ kept: list = []
148
+ used = 0
149
+
150
+ for exchange in reversed(exchanges(turns)):
151
+ size = sum(len(str(msg.content)) for msg in exchange)
152
+ # always keep the newest exchange, even if it alone blows the budget
153
+ if kept and used + size > budget:
154
+ break
155
+ used += size
156
+ kept.insert(0, exchange)
157
+
158
+ flat = [msg for exchange in kept for msg in exchange]
159
+
160
+ # a history that somehow opens on an assistant turn would still be rejected
161
+ while flat and not isinstance(flat[0], HumanMessage):
162
+ flat.pop(0)
163
+
164
+ return flat
165
 
 
166
 
167
+ def document_name(path):
168
+ """Derive a Bedrock-safe document name from an upload's filename."""
169
+ cleaned = UNSAFE_DOCUMENT_NAME.sub(" ", Path(path).stem)
170
+ return re.sub(r"\s+", " ", cleaned).strip() or "Dokument"
171
 
172
+
173
+ def upload_mime_type(path):
174
+ """An upload's MIME type, preferring our own table over mimetypes."""
175
+ extension = Path(path).suffix.lower()
176
+ if extension in EXTENSION_MIME_TYPES:
177
+ return EXTENSION_MIME_TYPES[extension]
178
+ mime, _ = mimetypes.guess_type(path)
179
+ return mime
180
+
181
+
182
+ def upload_block(path):
183
+ """Build a LangChain data content block for an upload, or None if unusable."""
184
+ mime = upload_mime_type(path)
185
+ if mime not in SUPPORTED_UPLOADS:
186
+ logger.warning("Refusing upload %s with MIME type %s", path, mime)
187
+ return None
188
+
189
+ try:
190
+ data = base64.b64encode(Path(path).read_bytes()).decode()
191
+ except OSError:
192
+ logger.exception("Could not read upload %s", path)
193
+ return None
194
+
195
+ # langchain_aws reads camelCase keys straight off the block
196
+ if mime in SUPPORTED_IMAGES:
197
+ return {"type": "image", "mimeType": mime, "base64": data}
198
+ return {
199
+ "type": "file",
200
+ "mimeType": mime,
201
+ "base64": data,
202
+ "name": document_name(path),
203
+ }
204
+
205
+
206
+ def history_file_path(content):
207
+ """Pull the path out of a history entry that holds a file rather than text.
208
+
209
+ Gradio stores these either as a (path,) tuple or as a {"path": ...} dict.
210
+ """
211
+ if isinstance(content, (tuple, list)):
212
+ return str(content[0]) if content else None
213
+ if isinstance(content, dict):
214
+ return content.get("path")
215
+ return None
216
+
217
+
218
+ def replay_history(history):
219
+ """Convert Gradio's history entries into LangChain messages."""
220
+ turns: list = []
221
  for msg in history:
222
+ content = msg.get("content")
223
+ if not isinstance(content, str):
224
+ # An upload from an earlier turn. Re-sending the bytes on every turn
225
+ # would grow the payload without bound, so reference it by name.
226
+ path = history_file_path(content)
227
+ if path is None:
228
+ logger.warning("Skipping history entry of type %s", type(content))
229
+ continue
230
+ content = FILE_PLACEHOLDER.format(name=Path(path).name)
231
+
232
  if msg["role"] == "user":
233
+ turns.append(HumanMessage(content=content))
234
  elif msg["role"] == "assistant":
235
+ turns.append(AIMessage(content=content))
236
+ return turns
237
+
238
+
239
+ def user_message(message):
240
+ """Build the new HumanMessage, plus the names of any refused uploads.
241
+
242
+ With multimodal=True Gradio passes a {"text", "files"} dict instead of a str.
243
+ """
244
+ if not isinstance(message, dict):
245
+ return HumanMessage(content=message), []
246
 
247
+ text = (message.get("text") or "").strip()
248
+ blocks: list = []
249
+ refused: list = []
250
+ for path in message.get("files") or []:
251
+ block = upload_block(path)
252
+ if block is None:
253
+ refused.append(Path(str(path)).name)
254
+ else:
255
+ blocks.append(block)
256
 
257
+ if not blocks:
258
+ return HumanMessage(content=text), refused
259
 
260
+ blocks.append({"type": "text", "text": text or DEFAULT_UPLOAD_PROMPT})
261
+ return HumanMessage(content=blocks), refused
262
 
 
 
 
263
 
264
+ def chunk_text(msg):
265
+ """Flatten a streamed message's content into plain text.
266
 
267
+ ChatBedrockConverse streams content as a list of blocks
268
+ ([{"type": "text", "text": "...", "index": 0}]), not a string, and Gradio's
269
+ chatbot expects a string. Tool-use or other non-text blocks are ignored.
270
+ """
271
+ if isinstance(msg.content, str):
272
+ return msg.content
273
+ return "".join(
274
+ block.get("text", "")
275
+ for block in msg.content
276
+ if isinstance(block, dict)
277
+ )
278
+
279
+
280
+ def chat(message, history):
281
+
282
+ # 1) replay the user/assistant turns
283
+ turns = replay_history(history)
284
+
285
+ # 2) append the new user message, refusing uploads we cannot send
286
+ new_message, refused = user_message(message)
287
+ if refused:
288
+ yield UPLOAD_ERROR.format(names=", ".join(refused))
289
+ return
290
+ turns.append(new_message)
291
+
292
+ # 3) drop the oldest turns, then prepend the system prompt. The prompt shares
293
+ # the context window with the turns, so it has to come out of the budget.
294
+ kept = trim_history(turns, MAX_HISTORY_CHARS - len(SYSTEM_PROMPT))
295
+ if len(kept) < len(turns):
296
+ logger.info("Dropped %d of %d turns to stay within the context budget",
297
+ len(turns) - len(kept), len(turns))
298
+
299
+ history_langchain_format: list = [SystemMessage(content=SYSTEM_PROMPT)] + kept
300
+
301
+ # 4) stream the answer; never let a backend error surface as a bare "Error"
302
+ text = ""
303
+ try:
304
+ # Do not pull the first chunk with next(): an empty stream raises
305
+ # StopIteration inside a generator (which Python turns into a
306
+ # RuntimeError), and a single-chunk stream never reaches the loop body
307
+ # and so yields nothing at all.
308
+ full = None
309
+ for chunk in bedrock_llm.stream(history_langchain_format):
310
+ full = chunk if full is None else full + chunk
311
+ text = chunk_text(full)
312
+ # the first chunk usually carries no text yet; do not blank the bubble
313
+ if text:
314
+ yield text
315
+
316
+ if not text:
317
+ logger.warning("Bedrock returned an empty stream")
318
+ yield ERROR_MESSAGE
319
+ except Exception:
320
+ # full traceback goes to the Space logs, the user gets a readable hint
321
+ logger.exception("Bedrock streaming failed after %d turns", len(history))
322
+ # keep whatever was already streamed instead of blanking the answer
323
+ yield f"{text}\n\n{ERROR_MESSAGE}" if text else ERROR_MESSAGE
324
+
325
+
326
+ with gr.Blocks(
327
+ css_paths=["static/deval.css"],
328
+ theme=gr.themes.Default(primary_hue="blue", secondary_hue="yellow"),
329
+ # grow top-level children with scale >= 1 to the window height; the header row
330
+ # and the input group have no scale, so only the transcript stretches
331
+ fill_height=True,
332
+ ) as demo:
333
  # ── Logo + Header + Logout ────────────────────────────────
334
 
335
 
 
354
  "sowie sensible erhobene Daten (wie etwa Interviewtranskripte).", elem_id="header-text"
355
  )
356
 
357
+ # Gradio's default MultimodalTextbox takes a single file of any type, which
358
+ # then gets refused by upload_block(). Offer several files and only the types
359
+ # we can actually forward.
360
+ upload_textbox = gr.MultimodalTextbox(
361
+ file_count="multiple",
362
+ file_types=UPLOAD_FILE_TYPES,
363
+ placeholder="Frage stellen oder Dateien anhΓ€ngen …",
364
+ ) if MULTIMODAL else None
365
+
 
 
366
  gr.ChatInterface(
367
  chat,
368
  type="messages",
369
  multimodal=MULTIMODAL,
370
+ textbox=upload_textbox,
371
+ # ChatInterface would otherwise label the transcript "Chatbot". scale=1 is
372
+ # what makes fill_height stretch it: 400px is the floor it grows from, so
373
+ # the transcript takes the window height left over by header and input.
374
+ chatbot=gr.Chatbot(
375
+ type="messages",
376
+ show_label=False,
377
+ scale=1,
378
+ height=400,
379
+ elem_id="chat-window",
380
+ ),
381
  editable=True,
382
  concurrency_limit=20,
383
  save_history=True,
 
385
 
386
 
387
 
388
+ demo.queue().launch(auth=AUTHS, ssr_mode=False, show_api=False)
bedrock_client.py CHANGED
@@ -1,7 +1,5 @@
1
  import os
2
- from anthropic import AnthropicBedrock
3
  from langchain_aws.chat_models import ChatBedrockConverse
4
- from langchain_aws.llms.bedrock import BedrockLLM
5
 
6
 
7
  # Initialize the streaming Bedrock chat model
@@ -9,9 +7,10 @@ bedrock_llm = ChatBedrockConverse(
9
  aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID"),
10
  aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY"),
11
  region_name = os.environ.get("AWS_DEFAULT_REGION", "eu-west-1"),
12
- provider = os.environ.get("PROVIDER", "mistral"),
13
- model_id = os.environ.get("MODEL_ID", "mistral.mistral-large-2402-v1:0"), # or your preferred Bedrock model
14
- temperature = eval(os.environ.get("TEMPERATURE", "0.7"))
 
15
  )
16
 
17
 
 
1
  import os
 
2
  from langchain_aws.chat_models import ChatBedrockConverse
 
3
 
4
 
5
  # Initialize the streaming Bedrock chat model
 
7
  aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID"),
8
  aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY"),
9
  region_name = os.environ.get("AWS_DEFAULT_REGION", "eu-west-1"),
10
+ provider = os.environ.get("PROVIDER", "anthropic"),
11
+ # eu. prefix selects the EU cross-region inference profile
12
+ model_id = os.environ.get("MODEL_ID", "eu.anthropic.claude-sonnet-4-6"),
13
+ temperature = float(os.environ.get("TEMPERATURE", "0.7"))
14
  )
15
 
16
 
requirements.txt CHANGED
@@ -1,12 +1,8 @@
1
- gradio>=4.20.0
2
- boto3>=1.34.0
3
- botocore
4
- fastapi
5
- uvicorn
6
- aiofiles
7
- anthropic[bedrock]>=0.50.0
8
- langchain-core
9
- langchain
10
- langchain-aws>=0.2.17
11
- langchain-community>=0.3.26
12
- setuptools
 
1
+ # Pinned exactly: the Space rebuilds from this file, so an unpinned floor lets a
2
+ # new major release break the deployment with no commit to point at. gradio has
3
+ # to stay in step with sdk_version in README.md.
4
+ # Only direct imports are listed here. boto3/botocore arrive with langchain-aws,
5
+ # and fastapi/uvicorn/aiofiles with gradio.
6
+ gradio==5.34.2
7
+ langchain-core==1.5.3
8
+ langchain-aws==1.6.4
 
 
 
 
static/deval.css CHANGED
@@ -52,4 +52,12 @@ button.gr-button:hover {
52
  #header-text b,
53
  #header-text em {
54
  color: #C1CDDF !important;
 
 
 
 
 
 
 
 
55
  }
 
52
  #header-text b,
53
  #header-text em {
54
  color: #C1CDDF !important;
55
+ }
56
+
57
+ /* ── Hide the Gradio footer ────────────────────────────────── */
58
+ /* "Built with Gradio" and the "Use via API" link. launch(show_api=False)
59
+ drops the API entry; this removes the footer altogether. */
60
+ footer,
61
+ .gradio-container footer {
62
+ display: none !important;
63
  }
utils.py DELETED
@@ -1,10 +0,0 @@
1
- import csv
2
-
3
- def load_users(path):
4
- """
5
- Reads a CSV with header 'users,passwords'
6
- and returns a list of (user, password) tuples.
7
- """
8
- with open(path, newline="", encoding="utf-8") as f:
9
- reader = csv.DictReader(f)
10
- return [(row["users"], row["passwords"]) for row in reader]