Ansnaeem commited on
Commit
435f8ee
·
verified ·
1 Parent(s): 7ad7d39

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +57 -0
  2. app.py +399 -0
  3. requirements.txt +6 -0
  4. storage.py +71 -0
README.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Scraper Bot v3 — Multi-Turn Chat with Persistent Storage
3
+ emoji: 🧠
4
+ colorFrom: purple
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 4.0.0
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # Version 3 — Multi-Turn AI Chatbot with Persistent Storage
13
+
14
+ AI assistant with **session and cross-session memory** and **editable user preferences**.
15
+
16
+ ## Features
17
+
18
+ - **Multi-turn conversation (short-term / session memory)**
19
+ Conversation history is kept in the format `[{"role": "user"|"assistant", "content": "..."}]` and sent to the API on every turn so the model can answer follow-up questions (e.g. *"Tell me more about the second book"*).
20
+
21
+ - **Persistent storage (cross-session memory)**
22
+ A local JSON file (`chat_storage.json`) stores:
23
+ - Conversation history for both tabs (Website Scraper and YouTube Transcript).
24
+ - On startup, existing history is loaded and shown in the chat.
25
+ - After each turn, the updated history is written to the file.
26
+
27
+ - **Editable user preferences**
28
+ You can set preferences at run time (e.g. *"Always respond formally"*, *"Cite sources when summarizing"*). They are saved in the same JSON file and **injected into the system prompt on every API call**.
29
+
30
+ ## Tabs
31
+
32
+ 1. **Bot-Protected Website Scraper** — Enter a URL (e.g. Goodreads), scrape with Bright Data + BeautifulSoup, then chat with multi-turn memory.
33
+ 2. **YouTube Transcript Q&A** — Enter a video ID or URL, fetch transcript with `youtube-transcript-api`, then chat with multi-turn memory.
34
+
35
+ ## Hugging Face Spaces
36
+
37
+ 1. Create a new Space, SDK **Gradio**.
38
+ 2. Clone the repo and add `app.py`, `storage.py`, and `requirements.txt`.
39
+ 3. In **Settings → Repository secrets** add:
40
+ - `GROQ_API_KEY`
41
+ - `BRIGHTDATA_API_KEY`
42
+ 4. Push; the app will build and run.
43
+ Note: On Spaces, `chat_storage.json` is not persistent across restarts unless you use a persistent volume.
44
+
45
+ ## Local run
46
+
47
+ ```bash
48
+ pip install -r requirements.txt
49
+ ```
50
+
51
+ Create a `.env` file with `GROQ_API_KEY` and `BRIGHTDATA_API_KEY`, then:
52
+
53
+ ```bash
54
+ python app.py
55
+ ```
56
+
57
+ Open `http://127.0.0.1:7862` (port 7862 to avoid conflict with v2).
app.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Version 3 — Multi-Turn AI Chatbot with Persistent Storage
3
+
4
+ - Multi-turn conversation: full history sent to API so the model can answer follow-ups.
5
+ - Persistent storage: chat_history.json holds conversation history and user preferences across restarts.
6
+ - Editable user preferences: injected into the system prompt on every API call.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import urllib3
12
+ from urllib.parse import urlparse
13
+
14
+ import gradio as gr
15
+ import requests
16
+ from bs4 import BeautifulSoup
17
+ from dotenv import load_dotenv
18
+ from groq import Groq
19
+ from youtube_transcript_api import YouTubeTranscriptApi
20
+
21
+ from storage import load_storage, save_storage
22
+
23
+ load_dotenv()
24
+
25
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
26
+ BRIGHTDATA_API_KEY = os.getenv("BRIGHTDATA_API_KEY")
27
+
28
+ if not GROQ_API_KEY:
29
+ raise ValueError("GROQ_API_KEY is not set.")
30
+
31
+ client = Groq(api_key=GROQ_API_KEY)
32
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
33
+
34
+ # In-memory: scraped/transcript context + loaded storage (updated on save)
35
+ contexts = {"scraper": "", "youtube": ""}
36
+ storage = load_storage()
37
+ # Do NOT clear history on startup; persist full conversation across restarts.
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Tab 1: Bot-Protected Website Scraper
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def scrape_website(url: str):
46
+ if not url:
47
+ return "Please enter a URL.", ""
48
+
49
+ parsed = urlparse(url)
50
+ target_url = url
51
+ if "goodreads.com" in (parsed.netloc or "") and (parsed.path in ("", "/")):
52
+ target_url = "https://www.goodreads.com/list/show/1.Best_Books_Ever"
53
+
54
+ api_url = "https://3.232.71.244/request"
55
+ headers = {
56
+ "Authorization": f"Bearer {BRIGHTDATA_API_KEY}",
57
+ "Content-Type": "application/json",
58
+ "Host": "api.brightdata.com",
59
+ }
60
+ payload = {"zone": "goodreads_unlocker", "url": target_url, "format": "raw"}
61
+
62
+ try:
63
+ resp = requests.post(
64
+ api_url, json=payload, headers=headers, timeout=120, verify=False
65
+ )
66
+ resp.raise_for_status()
67
+ soup = BeautifulSoup(resp.text, "html.parser")
68
+
69
+ if "goodreads.com/list/show/1.Best_Books_Ever" in target_url:
70
+ books_data = []
71
+ book_rows = soup.find_all("tr", itemtype="http://schema.org/Book")
72
+ for idx, row in enumerate(book_rows):
73
+ title_elem = row.find("a", class_="bookTitle")
74
+ author_elem = row.find("a", class_="authorName")
75
+ rating_elem = row.find("span", class_="minirating")
76
+ title = title_elem.text.strip() if title_elem else "Unknown Title"
77
+ author = author_elem.text.strip() if author_elem else "Unknown Author"
78
+ rating = rating_elem.text.strip() if rating_elem else "Unknown Rating"
79
+ books_data.append(
80
+ {"Rank": idx + 1, "Title": title, "Author": author, "Rating": rating}
81
+ )
82
+ if books_data:
83
+ lines = [
84
+ "Here is the scraped data from Goodreads Best Books Ever list:",
85
+ "",
86
+ ]
87
+ for b in books_data:
88
+ lines.append(
89
+ f"{b['Rank']}. {b['Title']} by {b['Author']} - {b['Rating']}"
90
+ )
91
+ text_content = "\n".join(lines)
92
+ else:
93
+ text_content = soup.get_text(separator=" ", strip=True)
94
+ else:
95
+ text_content = soup.get_text(separator=" ", strip=True)
96
+
97
+ contexts["scraper"] = text_content[:15000]
98
+ preview = (
99
+ text_content[:500] + "..." if len(text_content) > 500 else text_content
100
+ )
101
+ return "Website scraped successfully. You can now chat about it.", preview
102
+ except Exception as e:
103
+ contexts["scraper"] = ""
104
+ return f"Error scraping website: {e}", ""
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Tab 2: YouTube Transcript Q&A
109
+ # ---------------------------------------------------------------------------
110
+
111
+ _YOUTUBE_ID_REGEX = re.compile(
112
+ r"(https?://)?(www\.)?"
113
+ r"(youtube|youtu|youtube-nocookie)\.(com|be)/"
114
+ r"(watch\?v=|embed/|v/|.+\?v=)?([^&=%\?]{11})"
115
+ )
116
+
117
+
118
+ def _extract_video_id(url_or_id: str) -> str:
119
+ match = _YOUTUBE_ID_REGEX.search(url_or_id)
120
+ if match:
121
+ return match.group(6)
122
+ return url_or_id.strip()
123
+
124
+
125
+ def fetch_youtube_transcript(video_input: str):
126
+ if not video_input:
127
+ return "Please enter a YouTube Video URL or ID.", ""
128
+
129
+ video_id = _extract_video_id(video_input)
130
+ try:
131
+ api = YouTubeTranscriptApi()
132
+ transcript_list = api.list(video_id)
133
+ transcript = None
134
+ try:
135
+ transcript = transcript_list.find_transcript(["en", "ur"])
136
+ except Exception:
137
+ try:
138
+ transcript = transcript_list.find_generated_transcript(["en", "ur"])
139
+ except Exception:
140
+ for t in transcript_list:
141
+ transcript = t
142
+ break
143
+ if transcript is None:
144
+ raise Exception("No transcript available for this video.")
145
+ transcript_data = transcript.fetch()
146
+ pieces = []
147
+ for t in transcript_data:
148
+ if isinstance(t, dict):
149
+ pieces.append(t.get("text", ""))
150
+ else:
151
+ pieces.append(getattr(t, "text", ""))
152
+ transcript_text = " ".join(pieces)
153
+ contexts["youtube"] = transcript_text[:15000]
154
+ preview = (
155
+ transcript_text[:500] + "..."
156
+ if len(transcript_text) > 500
157
+ else transcript_text
158
+ )
159
+ return (
160
+ "Transcript fetched successfully. You can now chat about the video.",
161
+ preview,
162
+ )
163
+ except Exception as e:
164
+ contexts["youtube"] = ""
165
+ return f"Error: No transcript for video ID ({video_id}). Details: {e}", ""
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Multi-turn chat with persistent storage and user preferences
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ def _build_system_prompt(mode: str) -> str:
174
+ context = contexts.get(mode, "")
175
+ prefs = storage.get("user_preferences", "").strip()
176
+ ctx_placeholder = "(None — the user has NOT scraped or fetched transcript yet. You must refuse to answer and tell them to scrape/fetch first.)"
177
+ base = (
178
+ "You are a helpful assistant. You must use ONLY the provided context to answer. "
179
+ "NEVER use external knowledge or general information. If the context says 'None' or the user has not scraped yet, refuse to answer and tell them to scrape the website or fetch the transcript first. "
180
+ "If the answer is not in the context, say so. You have conversation history for follow-up questions (e.g. 'tell me more about the second book').\n\n"
181
+ f"Context:\n{context.strip() if context else ctx_placeholder}"
182
+ )
183
+ if prefs:
184
+ base += f"\n\nUser preferences (follow these):\n{prefs}"
185
+ return base
186
+
187
+
188
+ def chat_turn_scraper(message: str, _history_ignored):
189
+ """One turn in scraper tab: use storage as source of truth, append message, call API with full history, persist, return new history."""
190
+ if not message or not message.strip():
191
+ return "", storage.get("scraper_history", [])
192
+
193
+ context = contexts.get("scraper", "") or ""
194
+ if not context.strip():
195
+ history_dicts = list(storage.get("scraper_history", []))
196
+ history_dicts.append({"role": "user", "content": message.strip()})
197
+ history_dicts.append(
198
+ {
199
+ "role": "assistant",
200
+ "content": "Please scrape a website first, then ask questions.",
201
+ }
202
+ )
203
+ storage["scraper_history"] = history_dicts
204
+ save_storage(storage)
205
+ return "", history_dicts
206
+
207
+ # Use persisted storage as source of truth — not Gradio input — so full history is always kept
208
+ history_dicts = list(storage.get("scraper_history", []))
209
+ history_dicts.append({"role": "user", "content": message.strip()})
210
+
211
+ system_prompt = _build_system_prompt("scraper")
212
+ messages = [{"role": "system", "content": system_prompt}]
213
+ for m in history_dicts:
214
+ if m.get("role") in ("user", "assistant"):
215
+ messages.append({"role": m["role"], "content": m.get("content", "")})
216
+
217
+ try:
218
+ resp = client.chat.completions.create(
219
+ model="llama-3.1-8b-instant",
220
+ messages=messages,
221
+ max_tokens=1024,
222
+ temperature=0.3,
223
+ )
224
+ reply = resp.choices[0].message.content
225
+ except Exception as e:
226
+ reply = f"Error communicating with Groq: {e}"
227
+
228
+ history_dicts.append({"role": "assistant", "content": reply})
229
+ storage["scraper_history"] = history_dicts
230
+ save_storage(storage)
231
+
232
+ return "", history_dicts
233
+
234
+
235
+ def chat_turn_youtube(message: str, _history_ignored):
236
+ """One turn in YouTube tab: use storage as source of truth, same pattern as scraper."""
237
+ if not message or not message.strip():
238
+ return "", storage.get("youtube_history", [])
239
+
240
+ context = contexts.get("youtube", "") or ""
241
+ if not context.strip():
242
+ history_dicts = list(storage.get("youtube_history", []))
243
+ history_dicts.append({"role": "user", "content": message.strip()})
244
+ history_dicts.append(
245
+ {
246
+ "role": "assistant",
247
+ "content": "Please fetch a YouTube transcript first, then ask questions.",
248
+ }
249
+ )
250
+ storage["youtube_history"] = history_dicts
251
+ save_storage(storage)
252
+ return "", history_dicts
253
+
254
+ # Use persisted storage as source of truth
255
+ history_dicts = list(storage.get("youtube_history", []))
256
+ history_dicts.append({"role": "user", "content": message.strip()})
257
+
258
+ system_prompt = _build_system_prompt("youtube")
259
+ messages = [{"role": "system", "content": system_prompt}]
260
+ for m in history_dicts:
261
+ if m.get("role") in ("user", "assistant"):
262
+ messages.append({"role": m["role"], "content": m.get("content", "")})
263
+
264
+ try:
265
+ resp = client.chat.completions.create(
266
+ model="llama-3.1-8b-instant",
267
+ messages=messages,
268
+ max_tokens=1024,
269
+ temperature=0.3,
270
+ )
271
+ reply = resp.choices[0].message.content
272
+ except Exception as e:
273
+ reply = f"Error communicating with Groq: {e}"
274
+
275
+ history_dicts.append({"role": "assistant", "content": reply})
276
+ storage["youtube_history"] = history_dicts
277
+ save_storage(storage)
278
+
279
+ return "", history_dicts
280
+
281
+
282
+ def save_preferences(prefs: str):
283
+ """Save user preferences to storage and confirm."""
284
+ global storage
285
+ storage["user_preferences"] = prefs or ""
286
+ save_storage(storage)
287
+ return "Preferences saved. They will be applied to all future replies."
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Gradio UI
292
+ # ---------------------------------------------------------------------------
293
+
294
+ # Start with empty chat; history appends as the user sends messages (and is still saved to storage)
295
+ with gr.Blocks(title="Scraper Bot v3 — Multi-Turn Chat with Memory") as demo:
296
+ gr.Markdown("# Version 3 — Multi-Turn AI Chatbot with Persistent Storage")
297
+ gr.Markdown(
298
+ "Chat with memory: conversation history is kept and sent to the AI so you can ask follow-ups. "
299
+ "History and preferences are saved to `chat_storage.json` and persist across restarts."
300
+ )
301
+
302
+ with gr.Row():
303
+ with gr.Column(scale=1):
304
+ gr.Markdown("### User preferences (editable)")
305
+ gr.Markdown(
306
+ "These are injected into the system prompt on every reply. Examples: "
307
+ "*Always respond formally*, *Cite sources when summarizing*, *Keep answers under 3 sentences*."
308
+ )
309
+ prefs_input = gr.Textbox(
310
+ label="Preferences",
311
+ value=storage.get("user_preferences", ""),
312
+ placeholder="e.g., Always respond formally. Cite sources when summarizing.",
313
+ lines=3,
314
+ )
315
+ save_prefs_btn = gr.Button("Save preferences")
316
+ prefs_status = gr.Textbox(label="Status", interactive=False)
317
+ save_prefs_btn.click(
318
+ save_preferences,
319
+ inputs=[prefs_input],
320
+ outputs=[prefs_status],
321
+ )
322
+
323
+ with gr.Column(scale=2):
324
+ with gr.Tabs():
325
+ with gr.TabItem("Bot-Protected Website Scraper"):
326
+ gr.Markdown(
327
+ "Enter a URL to scrape (e.g. Goodreads). Then chat; history is kept for follow-up questions."
328
+ )
329
+ with gr.Row():
330
+ url_input = gr.Textbox(
331
+ label="URL",
332
+ placeholder="https://www.goodreads.com/",
333
+ scale=3,
334
+ )
335
+ scrape_btn = gr.Button("Scrape URL", scale=1)
336
+ scrape_status = gr.Textbox(label="Status", interactive=False)
337
+ scrape_preview = gr.Textbox(
338
+ label="Content preview", interactive=False, lines=4
339
+ )
340
+ scraper_chatbot = gr.Chatbot(
341
+ value=storage.get("scraper_history", []),
342
+ height=400,
343
+ label="Chat (multi-turn, persisted)",
344
+ )
345
+ scraper_msg = gr.Textbox(
346
+ label="Message",
347
+ placeholder="e.g., What are the top 5 books? Then: Tell me more about the second one.",
348
+ )
349
+ scrape_btn.click(
350
+ scrape_website,
351
+ inputs=[url_input],
352
+ outputs=[scrape_status, scrape_preview],
353
+ )
354
+ scraper_msg.submit(
355
+ chat_turn_scraper,
356
+ inputs=[scraper_msg, scraper_chatbot],
357
+ outputs=[scraper_msg, scraper_chatbot],
358
+ )
359
+
360
+ with gr.TabItem("YouTube Transcript Q&A"):
361
+ gr.Markdown(
362
+ "Enter a YouTube video URL or ID to fetch its transcript, then chat with multi-turn memory."
363
+ )
364
+ with gr.Row():
365
+ yt_input = gr.Textbox(
366
+ label="YouTube URL or ID",
367
+ placeholder="dQw4w9WgXcQ",
368
+ scale=3,
369
+ )
370
+ yt_btn = gr.Button("Get Transcript", scale=1)
371
+ yt_status = gr.Textbox(label="Status", interactive=False)
372
+ yt_preview = gr.Textbox(
373
+ label="Transcript preview", interactive=False, lines=4
374
+ )
375
+ yt_chatbot = gr.Chatbot(
376
+ value=storage.get("youtube_history", []),
377
+ height=400,
378
+ label="Chat (multi-turn, persisted)",
379
+ )
380
+ yt_msg = gr.Textbox(
381
+ label="Message",
382
+ placeholder="e.g., Summarize the video. Then: What are the main takeaways?",
383
+ )
384
+ yt_btn.click(
385
+ fetch_youtube_transcript,
386
+ inputs=[yt_input],
387
+ outputs=[yt_status, yt_preview],
388
+ )
389
+ yt_msg.submit(
390
+ chat_turn_youtube,
391
+ inputs=[yt_msg, yt_chatbot],
392
+ outputs=[yt_msg, yt_chatbot],
393
+ )
394
+
395
+ if __name__ == "__main__":
396
+ if os.environ.get("SPACE_ID"):
397
+ demo.launch() # Hugging Face Spaces: use their host/port
398
+ else:
399
+ demo.launch(server_name="127.0.0.1", server_port=7863) # Local
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.0.0,<7.0.0
2
+ groq>=0.4.0
3
+ beautifulsoup4>=4.12.0
4
+ requests>=2.28.0
5
+ python-dotenv>=1.0.0
6
+ youtube-transcript-api>=1.0.0
storage.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Persistent storage for Version 3: conversation history and user preferences.
3
+ Uses a local JSON file (chat_storage.json) for cross-session persistence.
4
+ """
5
+
6
+ import json
7
+ import os
8
+
9
+ STORAGE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chat_storage.json")
10
+
11
+ DEFAULT_STORAGE = {
12
+ "scraper_history": [],
13
+ "youtube_history": [],
14
+ "user_preferences": "",
15
+ }
16
+
17
+
18
+ def load_storage() -> dict:
19
+ """Load conversation history and user preferences from JSON. Creates file if missing."""
20
+ if not os.path.exists(STORAGE_PATH):
21
+ return dict(DEFAULT_STORAGE)
22
+ try:
23
+ with open(STORAGE_PATH, "r", encoding="utf-8") as f:
24
+ data = json.load(f)
25
+ data.setdefault("scraper_history", [])
26
+ data.setdefault("youtube_history", [])
27
+ data.setdefault("user_preferences", "")
28
+ return data
29
+ except (json.JSONDecodeError, IOError):
30
+ return dict(DEFAULT_STORAGE)
31
+
32
+
33
+ def save_storage(data: dict) -> None:
34
+ """Write storage to JSON."""
35
+ with open(STORAGE_PATH, "w", encoding="utf-8") as f:
36
+ json.dump(data, f, indent=2, ensure_ascii=False)
37
+
38
+
39
+ def history_dicts_to_tuples(history: list) -> list:
40
+ """Convert [{"role":"user","content":...},{"role":"assistant","content":...}, ...] to [(u,a), ...] for Gradio Chatbot."""
41
+ tuples = []
42
+ i = 0
43
+ while i < len(history):
44
+ msg = history[i]
45
+ if not isinstance(msg, dict):
46
+ i += 1
47
+ continue
48
+ role = msg.get("role")
49
+ content = msg.get("content") or ""
50
+ if role == "user":
51
+ next_msg = history[i + 1] if i + 1 < len(history) else None
52
+ if next_msg and isinstance(next_msg, dict) and next_msg.get("role") == "assistant":
53
+ tuples.append((content, next_msg.get("content") or ""))
54
+ i += 2
55
+ else:
56
+ tuples.append((content, ""))
57
+ i += 1
58
+ else:
59
+ i += 1
60
+ return tuples
61
+
62
+
63
+ def history_tuples_to_dicts(tuples: list) -> list:
64
+ """Convert Gradio Chatbot [(user, assistant), ...] to [{"role":"user",...},{"role":"assistant",...}, ...]."""
65
+ out = []
66
+ for user_msg, assistant_msg in tuples or []:
67
+ if user_msg:
68
+ out.append({"role": "user", "content": str(user_msg)})
69
+ if assistant_msg:
70
+ out.append({"role": "assistant", "content": str(assistant_msg)})
71
+ return out