DrElaheJ commited on
Commit
086e61b
·
verified ·
1 Parent(s): 89e8589

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +18 -0
  2. README.md +19 -6
  3. app.py +688 -0
  4. requirements.txt +6 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /code
4
+
5
+ COPY ./requirements.txt /code/requirements.txt
6
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
7
+
8
+ RUN useradd -m -u 1000 user
9
+ USER user
10
+
11
+ ENV HOME=/home/user \
12
+ PATH=/home/user/.local/bin:$PATH
13
+
14
+ WORKDIR $HOME/app
15
+
16
+ COPY --chown=user . $HOME/app
17
+
18
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,24 @@
1
  ---
2
- title: CSTR
3
- emoji: 📉
4
- colorFrom: yellow
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CS Trivia Agent
3
+ emoji: 🧠
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
9
  ---
10
 
11
+ # CS Trivia Agent
12
+
13
+ Agentic AI that answers computer science trivia questions with source articles and related images.
14
+
15
+ - **LLM**: Gemini Flash (free tier) with automatic model fallback
16
+ - **Search**: DuckDuckGo (no API key needed)
17
+ - **Images**: DuckDuckGo image search with smart LLM-generated fallback queries
18
+ - **Frontend**: FastAPI + vanilla HTML/CSS/JS with SSE streaming
19
+ - **Cost**: $0
20
+
21
+ ## Setup
22
+
23
+ Set `GEMINI_API_KEY` as a secret in your Space settings.
24
+ Get a free key at: https://aistudio.google.com/app/apikey
app.py ADDED
@@ -0,0 +1,688 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CS Trivia Agent — Single-file FastAPI app for HuggingFace Spaces
3
+ =================================================================
4
+ - Uses NEW google-genai SDK
5
+ - Smart image fallback: LLM suggests related people/companies/concepts
6
+ - Source fallback: tries Grokepedia, then shows Wikipedia logo
7
+ - 50 layman-friendly CS trivia questions
8
+ """
9
+
10
+ from fastapi import FastAPI
11
+ from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
12
+ from google import genai
13
+ from duckduckgo_search import DDGS
14
+ import requests
15
+ from PIL import Image
16
+ from io import BytesIO
17
+ import base64
18
+ import os
19
+ import time
20
+ import json
21
+ import logging
22
+
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger(__name__)
25
+
26
+ app = FastAPI(title="CS Trivia Agent")
27
+
28
+ # ─── Gemini ───
29
+ GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
30
+ gemini_client = None
31
+ if GEMINI_API_KEY:
32
+ gemini_client = genai.Client(api_key=GEMINI_API_KEY)
33
+
34
+ GEMINI_MODELS = [
35
+ "gemini-2.5-flash",
36
+ "gemini-2.0-flash",
37
+ "gemini-2.5-flash-lite",
38
+ "gemini-2.0-flash-lite",
39
+ ]
40
+
41
+ # ─── 50 Trivia Questions (all have 1-4 word answers) ───
42
+ PRESET_QUESTIONS = [
43
+ # Everyday tech origins
44
+ "What was the first web browser called?",
45
+ "What does USB stand for?",
46
+ "What does HTTP stand for?",
47
+ "What does Wi-Fi actually stand for?",
48
+ "What does CPU stand for?",
49
+ "What does GPU stand for?",
50
+ "What does HTML stand for?",
51
+ "What does RAM stand for?",
52
+ "What does SSD stand for?",
53
+ "What does PDF stand for?",
54
+ # Famous people
55
+ "Who invented the World Wide Web?",
56
+ "Who co-founded Apple with Steve Jobs?",
57
+ "Who created Facebook?",
58
+ "Who co-founded Microsoft with Bill Gates?",
59
+ "Who co-founded Google with Sergey Brin?",
60
+ "Who created the Linux operating system?",
61
+ "Who invented the first computer mouse?",
62
+ "Who is known as the father of AI?",
63
+ "Who wrote the first computer program ever?",
64
+ "Who invented copy and paste?",
65
+ # History & firsts
66
+ "What was the first programmable computer called?",
67
+ "What was the first video game console to sell millions?",
68
+ "What year was the first iPhone released?",
69
+ "What year was Google founded?",
70
+ "What was the first computer programming language?",
71
+ "What company built the first personal computer?",
72
+ "What year did Wikipedia launch?",
73
+ "What does the '__(dot) com__' in websites stand for?",
74
+ "What was the first social media site?",
75
+ "What country invented the QR code?",
76
+ # AI & modern tech
77
+ "What does AI stand for?",
78
+ "What does GPT stand for in ChatGPT?",
79
+ "What company created ChatGPT?",
80
+ "What company created AlphaFold?",
81
+ "What game did DeepMind's AI famously beat a human at?",
82
+ "What does VR stand for?",
83
+ "What company owns Instagram?",
84
+ "What programming language is most used for AI?",
85
+ "What does IoT stand for?",
86
+ "What does API stand for?",
87
+ # Concepts & fun facts
88
+ "What number system do computers use?",
89
+ "What does the term 'bug' in software come from?",
90
+ "What language is named after a comedy group?",
91
+ "What does SQL stand for?",
92
+ "What does CAPTCHA prove you are?",
93
+ "How many bits are in one byte?",
94
+ "What does LAN stand for?",
95
+ "What does URL stand for?",
96
+ "What planet lost a spacecraft due to a unit conversion bug?",
97
+ "What does JPEG stand for?",
98
+ # AI & ML — Key figures
99
+ "Who is known as the godfather of deep learning?",
100
+ "Who created the first neural network model in 1943?",
101
+ "Who invented backpropagation for training neural networks?",
102
+ "Who led the team that created AlphaGo?",
103
+ "Who founded OpenAI as its first CEO?",
104
+ "Who is the CEO of OpenAI as of 2024?",
105
+ "Who coined the term 'machine learning' in 1959?",
106
+ "Who invented the transformer architecture used in GPT?",
107
+ "Who created the Python programming language?",
108
+ "Who won the 2024 Nobel Prize for AI contributions?",
109
+ # AI & ML — Key models & systems
110
+ "What does BERT stand for in Google's AI model?",
111
+ "What does DALL-E generate from text prompts?",
112
+ "What AI system beat the world chess champion in 1997?",
113
+ "What does GAN stand for in machine learning?",
114
+ "What is the name of Google's AI chatbot?",
115
+ "What AI model powers Microsoft's Copilot?",
116
+ "What does LLM stand for in AI?",
117
+ "What open-source AI model did Meta release?",
118
+ "What does YOLO stand for in computer vision?",
119
+ "What AI tool generates music from text prompts by Google?",
120
+ # AI & ML — Key concepts
121
+ "What does NLP stand for in AI?",
122
+ "What does CNN stand for in deep learning?",
123
+ "What does RNN stand for in deep learning?",
124
+ "What is the process of training AI with rewards called?",
125
+ "What does epoch mean in machine learning training?",
126
+ "What type of AI learns from labeled examples?",
127
+ "What type of AI finds patterns without labels?",
128
+ "What does overfitting mean in machine learning?",
129
+ "What is a dataset split into for testing AI models?",
130
+ "What does GPU stand for and why is it key for AI?",
131
+ # AI & ML — Key years & milestones
132
+ "What year did ChatGPT launch?",
133
+ "What year did AlphaGo beat Lee Sedol?",
134
+ "What year was the ImageNet competition breakthrough?",
135
+ "What year was the original transformer paper published?",
136
+ "What year did IBM's Watson win Jeopardy?",
137
+ "What year was TensorFlow first released by Google?",
138
+ "What year did AlphaFold solve protein folding?",
139
+ "What year was the term 'artificial intelligence' first coined?",
140
+ "What year was PyTorch released by Facebook?",
141
+ "What year did GPT-4 launch?",
142
+ # AI & ML — Companies & tools
143
+ "What company makes the H100 GPU chip used for AI?",
144
+ "What does TPU stand for (Google's AI chip)?",
145
+ "What company created the Stable Diffusion image model?",
146
+ "What AI company did Elon Musk start in 2023?",
147
+ "What is Hugging Face best known for in AI?",
148
+ "What cloud platform is most popular for training AI models?",
149
+ "What company created the Claude AI assistant?",
150
+ "What AI framework did Google release for machine learning?",
151
+ "What does AutoML do?",
152
+ "What AI company created the Midjourney image generator?",
153
+ ]
154
+
155
+
156
+ # ═══════════════════════════════════════════════════════════════
157
+ # AGENT TOOLS
158
+ # ═══════════════════════════════════════════════════════════════
159
+
160
+ def call_gemini(prompt: str, max_retries: int = 2) -> str:
161
+ if not gemini_client:
162
+ return "GEMINI_API_KEY not configured. Add it in Space Settings > Secrets."
163
+ last_error = ""
164
+ for model_name in GEMINI_MODELS:
165
+ for attempt in range(max_retries):
166
+ try:
167
+ response = gemini_client.models.generate_content(
168
+ model=model_name, contents=prompt,
169
+ )
170
+ return response.text.strip()
171
+ except Exception as e:
172
+ last_error = str(e)
173
+ if "429" in last_error or "quota" in last_error.lower() or "rate" in last_error.lower():
174
+ wait = min(10 * (attempt + 1), 45)
175
+ if attempt < max_retries - 1:
176
+ logger.warning(f"Rate limited on {model_name}, retry in {wait}s")
177
+ time.sleep(wait)
178
+ else:
179
+ break
180
+ else:
181
+ break
182
+ return f"All models failed. Last error: {last_error[:200]}"
183
+
184
+
185
+ def tool_search_article(query: str) -> dict:
186
+ """Search for a source. Falls back to Grokepedia if no non-Wikipedia result."""
187
+ try:
188
+ with DDGS() as ddgs:
189
+ results = list(ddgs.text(f"{query} computer science", max_results=10))
190
+ # Prefer non-Wikipedia
191
+ for r in results:
192
+ url = r.get("href", "")
193
+ if "wikipedia.org" not in url.lower():
194
+ return {"title": r.get("title", ""), "url": url, "snippet": r.get("body", "")}
195
+ # Accept Wikipedia if nothing else
196
+ if results:
197
+ r = results[0]
198
+ return {"title": r.get("title", ""), "url": r.get("href", ""), "snippet": r.get("body", "")}
199
+ except Exception as e:
200
+ logger.error(f"Search error: {e}")
201
+
202
+ # Fallback: try Grokepedia
203
+ try:
204
+ grok_query = query.replace(" ", "+")
205
+ grok_url = f"https://grokepedia.com/search?q={grok_query}"
206
+ return {"title": f"Search on Grokepedia: {query}", "url": grok_url, "snippet": ""}
207
+ except Exception:
208
+ pass
209
+
210
+ return {"title": "No results", "url": "", "snippet": ""}
211
+
212
+
213
+ def _get_image_search_queries(question: str, answer: str) -> list[str]:
214
+ """Use LLM to generate smart image search queries based on Q&A context."""
215
+ prompt = f"""Given this CS trivia Q&A, suggest 4 short image search queries (2-4 words each) that would find a relevant, interesting image. Include the main topic, a key person involved, a related company/logo, and a related concept or invention.
216
+
217
+ Question: {question}
218
+ Answer: {answer}
219
+
220
+ Return ONLY 4 queries, one per line, nothing else:"""
221
+
222
+ try:
223
+ result = call_gemini(prompt)
224
+ queries = [line.strip().strip("-•*").strip() for line in result.split("\n") if line.strip() and len(line.strip()) < 60]
225
+ return queries[:4] if queries else [question]
226
+ except Exception:
227
+ return [question]
228
+
229
+
230
+ def tool_fetch_image(queries: list[str]) -> tuple[str | None, str]:
231
+ """Try multiple search queries. Returns (base64_data, caption)."""
232
+ headers = {
233
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
234
+ "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
235
+ }
236
+ BLOCKED = ["getty", "alamy", "shutterstock", "istockphoto", "dreamstime",
237
+ "123rf", "depositphotos", "adobestock", "stock.adobe"]
238
+
239
+ def try_dl(img_url):
240
+ if not img_url or len(img_url) > 2000:
241
+ return None
242
+ if any(d in img_url.lower() for d in BLOCKED):
243
+ return None
244
+ try:
245
+ resp = requests.get(img_url, headers=headers, timeout=8, allow_redirects=True)
246
+ resp.raise_for_status()
247
+ ct = resp.headers.get("Content-Type", "")
248
+ if "html" in ct or "json" in ct or len(resp.content) < 1000:
249
+ return None
250
+ img = Image.open(BytesIO(resp.content))
251
+ img.load()
252
+ if img.mode in ("RGBA", "LA", "PA", "P"):
253
+ bg = Image.new("RGB", img.size, (255, 255, 255))
254
+ if "A" in img.mode or (img.mode == "P" and "transparency" in img.info):
255
+ img_rgba = img.convert("RGBA")
256
+ bg.paste(img_rgba, mask=img_rgba.split()[-1])
257
+ else:
258
+ bg.paste(img.convert("RGB"))
259
+ img = bg
260
+ elif img.mode != "RGB":
261
+ img = img.convert("RGB")
262
+ if img.size[0] < 80 or img.size[1] < 80:
263
+ return None
264
+ if max(img.size) > 512:
265
+ img.thumbnail((512, 512), Image.LANCZOS)
266
+ buf = BytesIO()
267
+ img.save(buf, format="JPEG", quality=85)
268
+ return f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
269
+ except Exception:
270
+ return None
271
+
272
+ for sq in queries:
273
+ try:
274
+ with DDGS() as ddgs:
275
+ results = list(ddgs.images(sq, max_results=10))
276
+ for r in results:
277
+ img = try_dl(r.get("image", "")) or try_dl(r.get("thumbnail", ""))
278
+ if img:
279
+ return img, sq
280
+ except Exception:
281
+ continue
282
+
283
+ return None, ""
284
+
285
+
286
+ def tool_llm_answer(question: str, context: str = "") -> str:
287
+ prompt = f"""You are a friendly computer science trivia expert explaining things to a curious non-technical person. Answer in 2-3 concise, engaging sentences. Use simple language.
288
+
289
+ Question: {question}
290
+ {"Additional context: " + context if context else ""}
291
+
292
+ Answer:"""
293
+ return call_gemini(prompt)
294
+
295
+
296
+ # ═══════════════════════════════════════════════════════════════
297
+ # WIKIPEDIA FALLBACK IMAGE (embedded tiny PNG)
298
+ # ═══════════════════════════════════════════════════════════════
299
+
300
+ # A small Wikipedia "W" logo placeholder — generated as a simple SVG converted to data URI
301
+ WIKIPEDIA_PLACEHOLDER = "data:image/svg+xml;base64," + base64.b64encode(b'''<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
302
+ <rect width="200" height="200" rx="20" fill="#1a1a2e"/>
303
+ <text x="100" y="85" text-anchor="middle" font-family="Georgia,serif" font-size="72" font-weight="bold" fill="#e2e8f0">W</text>
304
+ <text x="100" y="120" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#64748b">Wikipedia</text>
305
+ <text x="100" y="140" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#475569">No image found</text>
306
+ </svg>''').decode()
307
+
308
+
309
+ # ═══════════════════════════════════════════════════════════════
310
+ # API ROUTES
311
+ # ═══════════════════════════════════════════════════════════════
312
+
313
+ @app.get("/api/questions")
314
+ async def get_questions():
315
+ numbered = [{"num": i+1, "text": q} for i, q in enumerate(PRESET_QUESTIONS)]
316
+ return JSONResponse(numbered)
317
+
318
+
319
+ @app.get("/api/ask")
320
+ async def ask_question(q: str):
321
+ def generate():
322
+ question = q.strip()
323
+ if not question:
324
+ yield f"data: {json.dumps({'type': 'error', 'message': 'Empty question'})}\n\n"
325
+ return
326
+
327
+ # Step 1 — Search for source article
328
+ yield f"data: {json.dumps({'type': 'step', 'step': 1, 'message': 'Searching the web...'})}\n\n"
329
+ article = tool_search_article(question)
330
+ src_url = article.get("url", "")
331
+ src_title = article.get("title", "")
332
+ snippet = article.get("snippet", "")
333
+
334
+ # If no source at all, link to Grokepedia
335
+ if not src_url:
336
+ grok_query = question.replace(" ", "+")
337
+ src_url = f"https://grokepedia.com/search?q={grok_query}"
338
+ src_title = f"Search Grokepedia: {question[:50]}"
339
+
340
+ yield f"data: {json.dumps({'type': 'source', 'title': src_title, 'url': src_url})}\n\n"
341
+
342
+ # Step 2 — Generate answer
343
+ yield f"data: {json.dumps({'type': 'step', 'step': 2, 'message': 'Generating answer with AI...'})}\n\n"
344
+ answer = tool_llm_answer(question, context=snippet)
345
+ yield f"data: {json.dumps({'type': 'answer', 'text': answer})}\n\n"
346
+
347
+ # Step 3 — Smart image search with LLM-generated fallback queries
348
+ yield f"data: {json.dumps({'type': 'step', 'step': 3, 'message': 'Finding the best image...'})}\n\n"
349
+
350
+ # Ask LLM for smart image queries based on the answer
351
+ image_queries = _get_image_search_queries(question, answer)
352
+ image_b64, matched_query = tool_fetch_image(image_queries)
353
+
354
+ if image_b64:
355
+ caption = f"Image: {matched_query}" if matched_query else ""
356
+ yield f"data: {json.dumps({'type': 'image', 'data': image_b64, 'caption': caption})}\n\n"
357
+ else:
358
+ # Final fallback: show Wikipedia placeholder
359
+ yield f"data: {json.dumps({'type': 'image', 'data': WIKIPEDIA_PLACEHOLDER, 'caption': 'No image found — try Wikipedia for visuals'})}\n\n"
360
+
361
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
362
+
363
+ return StreamingResponse(generate(), media_type="text/event-stream")
364
+
365
+
366
+ # ═══════════════════════════════════════════════════════════════
367
+ # HTML — embedded directly
368
+ # ═══════════════════════════════════════════════════════════════
369
+
370
+ INDEX_HTML = """<!DOCTYPE html>
371
+ <html lang="en">
372
+ <head>
373
+ <meta charset="UTF-8">
374
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
375
+ <title>CS Trivia Agent</title>
376
+ <link rel="preconnect" href="https://fonts.googleapis.com">
377
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
378
+ <style>
379
+ :root {
380
+ --bg: #0a0e17;
381
+ --surface: #111827;
382
+ --surface2: #1a2235;
383
+ --border: #1e293b;
384
+ --accent: #22d3ee;
385
+ --accent2: #a78bfa;
386
+ --text: #e2e8f0;
387
+ --text-dim: #64748b;
388
+ --success: #34d399;
389
+ --warn: #fbbf24;
390
+ --radius: 12px;
391
+ }
392
+ * { margin: 0; padding: 0; box-sizing: border-box; }
393
+ body {
394
+ font-family: 'DM Sans', sans-serif;
395
+ background: var(--bg);
396
+ color: var(--text);
397
+ min-height: 100vh;
398
+ overflow-x: hidden;
399
+ }
400
+ body::before {
401
+ content: '';
402
+ position: fixed;
403
+ top: -50%; left: -50%;
404
+ width: 200%; height: 200%;
405
+ background: radial-gradient(ellipse at 30% 20%, rgba(34,211,238,0.04) 0%, transparent 50%),
406
+ radial-gradient(ellipse at 70% 80%, rgba(167,139,250,0.04) 0%, transparent 50%);
407
+ z-index: 0;
408
+ pointer-events: none;
409
+ }
410
+ .container {
411
+ max-width: 880px;
412
+ margin: 0 auto;
413
+ padding: 24px 20px;
414
+ position: relative;
415
+ z-index: 1;
416
+ }
417
+ .header {
418
+ text-align: center;
419
+ padding: 32px 24px;
420
+ margin-bottom: 28px;
421
+ background: linear-gradient(135deg, var(--surface) 0%, var(--surface2) 100%);
422
+ border: 1px solid var(--border);
423
+ border-radius: 16px;
424
+ position: relative;
425
+ overflow: hidden;
426
+ }
427
+ .header::after {
428
+ content: '';
429
+ position: absolute;
430
+ top: 0; left: 0; right: 0;
431
+ height: 2px;
432
+ background: linear-gradient(90deg, var(--accent), var(--accent2), var(--accent));
433
+ }
434
+ .header h1 {
435
+ font-family: 'IBM Plex Mono', monospace;
436
+ font-size: 28px; font-weight: 600;
437
+ letter-spacing: -0.5px; margin-bottom: 6px;
438
+ }
439
+ .header h1 span { color: var(--accent); }
440
+ .header p { color: var(--text-dim); font-size: 13px; letter-spacing: 0.3px; }
441
+ .input-area { display: flex; gap: 10px; margin-bottom: 14px; }
442
+ .input-area input {
443
+ flex: 1;
444
+ background: var(--surface);
445
+ border: 1px solid var(--border);
446
+ border-radius: var(--radius);
447
+ padding: 14px 18px;
448
+ color: var(--text);
449
+ font-size: 15px;
450
+ font-family: 'DM Sans', sans-serif;
451
+ outline: none;
452
+ transition: border-color 0.2s;
453
+ }
454
+ .input-area input:focus { border-color: var(--accent); }
455
+ .input-area input::placeholder { color: var(--text-dim); }
456
+ .ask-btn {
457
+ background: linear-gradient(135deg, #22d3ee, #06b6d4);
458
+ color: #0a0e17; border: none;
459
+ border-radius: var(--radius);
460
+ padding: 14px 28px; font-size: 15px;
461
+ font-weight: 600; font-family: 'DM Sans', sans-serif;
462
+ cursor: pointer; transition: all 0.2s; white-space: nowrap;
463
+ }
464
+ .ask-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(34,211,238,0.25); }
465
+ .ask-btn:active { transform: translateY(0); }
466
+ .ask-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
467
+ .preset-row { margin-bottom: 24px; }
468
+ .preset-row select {
469
+ width: 100%;
470
+ background: var(--surface);
471
+ border: 1px solid var(--border);
472
+ border-radius: var(--radius);
473
+ padding: 12px 16px;
474
+ color: var(--text-dim);
475
+ font-size: 14px;
476
+ font-family: 'DM Sans', sans-serif;
477
+ outline: none; cursor: pointer; appearance: none;
478
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2364748b'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
479
+ background-repeat: no-repeat;
480
+ background-position: right 16px center;
481
+ }
482
+ .preset-row select:focus { border-color: var(--accent); }
483
+ .results { display: none; gap: 20px; margin-top: 8px; }
484
+ .results.visible { display: grid; grid-template-columns: 1fr 1fr; }
485
+ .result-left { display: flex; flex-direction: column; gap: 16px; }
486
+ .card {
487
+ background: var(--surface);
488
+ border: 1px solid var(--border);
489
+ border-radius: var(--radius);
490
+ padding: 20px;
491
+ }
492
+ .card-label {
493
+ font-family: 'IBM Plex Mono', monospace;
494
+ font-size: 11px; text-transform: uppercase;
495
+ letter-spacing: 1.5px; color: var(--accent); margin-bottom: 10px;
496
+ }
497
+ .answer-text { font-size: 15px; line-height: 1.65; }
498
+ .source-link {
499
+ display: inline-flex; align-items: center; gap: 6px;
500
+ color: var(--accent2); text-decoration: none;
501
+ font-size: 14px; line-height: 1.5; word-break: break-word;
502
+ }
503
+ .source-link:hover { color: #c4b5fd; }
504
+ .image-card {
505
+ background: var(--surface);
506
+ border: 1px solid var(--border);
507
+ border-radius: var(--radius);
508
+ padding: 16px;
509
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
510
+ min-height: 280px; gap: 8px;
511
+ }
512
+ .image-card img { max-width: 100%; max-height: 360px; border-radius: 8px; object-fit: contain; }
513
+ .image-caption {
514
+ font-size: 11px; color: var(--text-dim);
515
+ font-family: 'IBM Plex Mono', monospace;
516
+ text-align: center; margin-top: 4px;
517
+ }
518
+ .image-placeholder { color: var(--text-dim); font-size: 13px; text-align: center; }
519
+ .status-bar { margin-top: 16px; display: none; }
520
+ .status-bar.visible { display: block; }
521
+ .steps {
522
+ display: flex; gap: 6px; align-items: center;
523
+ padding: 14px 18px;
524
+ background: var(--surface);
525
+ border: 1px solid var(--border);
526
+ border-radius: var(--radius);
527
+ font-family: 'IBM Plex Mono', monospace; font-size: 13px;
528
+ }
529
+ .step-dot {
530
+ width: 8px; height: 8px; border-radius: 50%;
531
+ background: var(--border); transition: background 0.3s;
532
+ }
533
+ .step-dot.active {
534
+ background: var(--warn);
535
+ box-shadow: 0 0 8px rgba(251,191,36,0.4);
536
+ animation: pulse 1s ease-in-out infinite;
537
+ }
538
+ .step-dot.done {
539
+ background: var(--success);
540
+ box-shadow: 0 0 6px rgba(52,211,153,0.3);
541
+ animation: none;
542
+ }
543
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
544
+ .step-label { color: var(--text-dim); margin-left: 4px; flex: 1; }
545
+ .step-label.highlight { color: var(--text); }
546
+ @media (max-width:700px) {
547
+ .results.visible { grid-template-columns: 1fr; }
548
+ .input-area { flex-direction: column; }
549
+ .ask-btn { width: 100%; }
550
+ .header h1 { font-size: 22px; }
551
+ }
552
+ .shimmer {
553
+ background: linear-gradient(90deg, var(--surface2) 25%, var(--border) 50%, var(--surface2) 75%);
554
+ background-size: 200% 100%;
555
+ animation: shimmer 1.5s infinite;
556
+ border-radius: 6px; height: 16px; margin-bottom: 8px;
557
+ }
558
+ .shimmer:last-child { width: 60%; }
559
+ @keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
560
+ </style>
561
+ </head>
562
+ <body>
563
+ <div class="container">
564
+ <div class="header">
565
+ <h1>&#x1f9e0; <span>CS Trivia</span> Agent</h1>
566
+ <p>Agentic AI &#8212; Gemini Flash &middot; DuckDuckGo Search &amp; Images &middot; Zero paid APIs</p>
567
+ </div>
568
+ <div class="input-area">
569
+ <input type="text" id="questionInput" placeholder="Ask any computer science trivia question..." />
570
+ <button class="ask-btn" id="askBtn" onclick="askAgent()">Ask Agent</button>
571
+ </div>
572
+ <div class="preset-row">
573
+ <select id="presetSelect">
574
+ <option value="">&#x1f4cb; Pick a question by number (1-100)...</option>
575
+ </select>
576
+ </div>
577
+ <div class="status-bar" id="statusBar">
578
+ <div class="steps">
579
+ <div class="step-dot" id="dot1"></div><span>Search</span>
580
+ <div class="step-dot" id="dot2"></div><span>Answer</span>
581
+ <div class="step-dot" id="dot3"></div><span>Image</span>
582
+ <span style="flex:1"></span>
583
+ <span class="step-label" id="stepLabel">Starting...</span>
584
+ </div>
585
+ </div>
586
+ <div class="results" id="results">
587
+ <div class="result-left">
588
+ <div class="card" id="answerCard">
589
+ <div class="card-label">&#x1f4a1; Answer</div>
590
+ <div class="answer-text" id="answerText"></div>
591
+ </div>
592
+ <div class="card" id="sourceCard">
593
+ <div class="card-label">&#x1f4f0; Source</div>
594
+ <div id="sourceContent"></div>
595
+ </div>
596
+ </div>
597
+ <div class="image-card" id="imageCard">
598
+ <div class="image-placeholder">Image will appear here</div>
599
+ </div>
600
+ </div>
601
+ </div>
602
+ <script>
603
+ const input=document.getElementById('questionInput');
604
+ const btn=document.getElementById('askBtn');
605
+ const presetSelect=document.getElementById('presetSelect');
606
+ const statusBar=document.getElementById('statusBar');
607
+ const results=document.getElementById('results');
608
+
609
+ fetch('/api/questions').then(r=>r.json()).then(qs=>{
610
+ qs.forEach(q=>{
611
+ const o=document.createElement('option');o.value=q.text;
612
+ o.textContent='#'+q.num+' — '+q.text;
613
+ presetSelect.appendChild(o);
614
+ });
615
+ });
616
+ presetSelect.addEventListener('change',()=>{if(presetSelect.value)input.value=presetSelect.value;});
617
+ input.addEventListener('keydown',e=>{if(e.key==='Enter')askAgent();});
618
+
619
+ function setDot(n,s){document.getElementById('dot'+n).className='step-dot'+(s?' '+s:'');}
620
+ function escapeHtml(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
621
+
622
+ function askAgent(){
623
+ const question=input.value.trim();
624
+ if(!question)return;
625
+ btn.disabled=true;btn.textContent='Working...';
626
+ statusBar.classList.add('visible');
627
+ results.classList.remove('visible');
628
+ setDot(1,'');setDot(2,'');setDot(3,'');
629
+ document.getElementById('stepLabel').textContent='Starting...';
630
+ document.getElementById('stepLabel').className='step-label highlight';
631
+ document.getElementById('answerText').innerHTML='<div class="shimmer"></div><div class="shimmer"></div><div class="shimmer"></div>';
632
+ document.getElementById('sourceContent').innerHTML='';
633
+ document.getElementById('imageCard').innerHTML='<div class="image-placeholder">Searching for image...</div>';
634
+
635
+ const es=new EventSource('/api/ask?q='+encodeURIComponent(question));
636
+ es.onmessage=function(event){
637
+ const d=JSON.parse(event.data);
638
+ switch(d.type){
639
+ case 'step':
640
+ if(d.step>=1)setDot(1,d.step===1?'active':'done');
641
+ if(d.step>=2)setDot(2,d.step===2?'active':'done');
642
+ if(d.step>=3)setDot(3,d.step===3?'active':'done');
643
+ document.getElementById('stepLabel').textContent=d.message;
644
+ break;
645
+ case 'source':
646
+ results.classList.add('visible');
647
+ document.getElementById('sourceContent').innerHTML=d.url
648
+ ?'<a class="source-link" href="'+escapeHtml(d.url)+'" target="_blank" rel="noopener">&#x1f517; '+escapeHtml(d.title||'Source')+'</a>'
649
+ :'<span style="color:var(--text-dim)">No source found</span>';
650
+ break;
651
+ case 'answer':
652
+ document.getElementById('answerText').textContent=d.text;
653
+ break;
654
+ case 'image':
655
+ var html='';
656
+ if(d.data){
657
+ html='<img src="'+d.data+'" alt="Related image" />';
658
+ if(d.caption) html+='<div class="image-caption">'+escapeHtml(d.caption)+'</div>';
659
+ } else {
660
+ html='<div class="image-placeholder">No image available</div>';
661
+ }
662
+ document.getElementById('imageCard').innerHTML=html;
663
+ break;
664
+ case 'done':
665
+ setDot(1,'done');setDot(2,'done');setDot(3,'done');
666
+ document.getElementById('stepLabel').textContent='All steps complete \\u2713';
667
+ btn.disabled=false;btn.textContent='Ask Agent';
668
+ es.close();break;
669
+ case 'error':
670
+ document.getElementById('answerText').textContent=d.message;
671
+ btn.disabled=false;btn.textContent='Ask Agent';
672
+ es.close();break;
673
+ }
674
+ };
675
+ es.onerror=function(){
676
+ btn.disabled=false;btn.textContent='Ask Agent';
677
+ document.getElementById('stepLabel').textContent='Connection lost \\u2014 try again';
678
+ es.close();
679
+ };
680
+ }
681
+ </script>
682
+ </body>
683
+ </html>"""
684
+
685
+
686
+ @app.get("/", response_class=HTMLResponse)
687
+ async def index():
688
+ return INDEX_HTML
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ google-genai>=1.0
4
+ duckduckgo-search>=6.0
5
+ Pillow>=10.0
6
+ requests>=2.31