ZENLLC commited on
Commit
9f787a4
·
verified ·
1 Parent(s): da3de1d

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +481 -0
app.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import math
3
+ import requests
4
+ from typing import List, Dict, Any, Tuple
5
+
6
+ import gradio as gr
7
+ from openai import OpenAI
8
+
9
+ # -------------------- CONFIG --------------------
10
+
11
+ CHAT_MODEL = "gpt-5.1" # Change here if your OpenAI model ID differs
12
+ EMBED_MODEL = "text-embedding-3-large"
13
+
14
+ DEFAULT_SYSTEM_PROMPT = """You are a Retrieval-Augmented Generation (RAG) assistant.
15
+
16
+ Rules:
17
+ - Answer ONLY using the provided knowledge base context and system instructions.
18
+ - If the answer is not clearly supported by the context, say "I don’t know based on the current knowledge base."
19
+ - Do not invent sources, statistics, or facts that are not present in the context.
20
+ - When applicable, cite which source you used (e.g., "According to the uploaded PDF" or "Based on zenai.world").
21
+ - Be clear, concise, and structured.
22
+ """
23
+
24
+ PRESET_CONFIGS = {
25
+ "None (manual setup)": {
26
+ "system": DEFAULT_SYSTEM_PROMPT,
27
+ "urls": "",
28
+ "text": "",
29
+ },
30
+ "ZEN Sites Deep QA (zenai.world + AI Arena)": {
31
+ "system": DEFAULT_SYSTEM_PROMPT
32
+ + "\n\nYou specialize in answering questions about ZEN AI’s mission, programs, and AI Arena.",
33
+ "urls": "https://zenai.world\nhttps://us.zenai.biz",
34
+ "text": "ZEN AI builds the first global AI × Web3 literacy movement with youth, homeschool, and professional tracks.",
35
+ },
36
+ "Policy Explainer (external PDFs / links)": {
37
+ "system": DEFAULT_SYSTEM_PROMPT
38
+ + "\n\nYou act as a neutral policy explainer. Summarize clearly, highlight key risks and opportunities.",
39
+ "urls": "",
40
+ "text": "This preset is for uploading AI policy PDFs, legal texts, and reports.",
41
+ },
42
+ "Research Notebook / Personal RAG Sandbox": {
43
+ "system": DEFAULT_SYSTEM_PROMPT
44
+ + "\n\nYou help the user explore, connect, and synthesize insights from their personal notes and documents.",
45
+ "urls": "",
46
+ "text": "Use this as a sandbox for notebooks, transcripts, and long-form notes.",
47
+ },
48
+ }
49
+
50
+
51
+ # -------------------- HELPER FUNCTIONS --------------------
52
+
53
+ def chunk_text(text: str, max_chars: int = 2000, overlap: int = 200) -> List[str]:
54
+ """Simple character-based chunking with overlap."""
55
+ text = (text or "").strip()
56
+ if not text:
57
+ return []
58
+ chunks = []
59
+ start = 0
60
+ length = len(text)
61
+ while start < length:
62
+ end = min(start + max_chars, length)
63
+ chunk = text[start:end]
64
+ chunks.append(chunk)
65
+ if end >= length:
66
+ break
67
+ start = max(0, end - overlap)
68
+ return chunks
69
+
70
+
71
+ def cosine_similarity(a: List[float], b: List[float]) -> float:
72
+ """Compute cosine similarity between two vectors."""
73
+ if not a or not b:
74
+ return 0.0
75
+ dot = sum(x * y for x, y in zip(a, b))
76
+ norm_a = math.sqrt(sum(x * x for x in a))
77
+ norm_b = math.sqrt(sum(y * y for y in b))
78
+ if norm_a == 0 or norm_b == 0:
79
+ return 0.0
80
+ return dot / (norm_a * norm_b)
81
+
82
+
83
+ def fetch_url_text(url: str) -> str:
84
+ """Fetch text from a URL in a very lightweight way."""
85
+ try:
86
+ resp = requests.get(url, timeout=10)
87
+ resp.raise_for_status()
88
+ # crude HTML stripping: keep text only
89
+ text = resp.text
90
+ # Remove basic tags
91
+ for tag in ["<script", "<style"]:
92
+ if tag in text:
93
+ # Truncate at first occurrence of script/style to avoid junk
94
+ text = text.split(tag)[0]
95
+ # Replace angle brackets
96
+ text = text.replace("<", " ").replace(">", " ")
97
+ return text
98
+ except Exception as e:
99
+ return f"[Error fetching {url}: {e}]"
100
+
101
+
102
+ def read_file_text(path: str) -> str:
103
+ """Read text from simple text-based files; skip others safely."""
104
+ if not path:
105
+ return ""
106
+ path_lower = path.lower()
107
+ try:
108
+ if any(path_lower.endswith(ext) for ext in [".txt", ".md", ".csv", ".json"]):
109
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
110
+ return f.read()
111
+ # If you want to support PDFs or DOCX, you can add optional parsing here,
112
+ # but we avoid extra dependencies to keep the app robust.
113
+ return f"[Unsupported file type for RAG content: {os.path.basename(path)}]"
114
+ except Exception as e:
115
+ return f"[Error reading file {os.path.basename(path)}: {e}]"
116
+
117
+
118
+ def build_embeddings(
119
+ api_key: str,
120
+ docs: List[Dict[str, Any]],
121
+ ) -> Tuple[List[Dict[str, Any]], str]:
122
+ """Embed all document chunks and return them as KB docs with embeddings."""
123
+ if not docs:
124
+ return [], "⚠️ No documents to index."
125
+
126
+ client = OpenAI(api_key=api_key)
127
+ kb_chunks = []
128
+ total_chunks = 0
129
+
130
+ for d in docs:
131
+ source = d.get("source", "unknown")
132
+ text = d.get("text", "")
133
+ chunks = chunk_text(text, max_chars=2000, overlap=200)
134
+ for idx, ch in enumerate(chunks):
135
+ try:
136
+ emb_resp = client.embeddings.create(
137
+ model=EMBED_MODEL,
138
+ input=ch,
139
+ )
140
+ emb = emb_resp.data[0].embedding
141
+ kb_chunks.append(
142
+ {
143
+ "id": f"{source}_{idx}",
144
+ "source": source,
145
+ "text": ch,
146
+ "embedding": emb,
147
+ }
148
+ )
149
+ total_chunks += 1
150
+ except Exception as e:
151
+ # Keep going even if one embedding fails
152
+ kb_chunks.append(
153
+ {
154
+ "id": f"{source}_{idx}_error",
155
+ "source": source,
156
+ "text": f"[Error embedding chunk: {e}]",
157
+ "embedding": [],
158
+ }
159
+ )
160
+
161
+ status = f"✅ Knowledge base built with {len(docs)} documents and {total_chunks} chunks."
162
+ return kb_chunks, status
163
+
164
+
165
+ def retrieve_context(
166
+ api_key: str,
167
+ kb: List[Dict[str, Any]],
168
+ query: str,
169
+ top_k: int = 5,
170
+ similarity_threshold: float = 0.25,
171
+ ) -> Tuple[str, str]:
172
+ """Retrieve top-k relevant chunks from KB for the query."""
173
+ if not kb:
174
+ return "", "ℹ️ No knowledge base yet. The model will answer from instructions only."
175
+
176
+ client = OpenAI(api_key=api_key)
177
+ try:
178
+ q_emb_resp = client.embeddings.create(
179
+ model=EMBED_MODEL,
180
+ input=query,
181
+ )
182
+ q_emb = q_emb_resp.data[0].embedding
183
+ except Exception as e:
184
+ return "", f"⚠️ Error creating query embedding: {e}"
185
+
186
+ scored = []
187
+ for d in kb:
188
+ emb = d.get("embedding") or []
189
+ if not emb:
190
+ continue
191
+ sim = cosine_similarity(q_emb, emb)
192
+ scored.append((sim, d))
193
+
194
+ if not scored:
195
+ return "", "⚠️ No valid embeddings in KB; cannot retrieve context."
196
+
197
+ scored.sort(key=lambda x: x[0], reverse=True)
198
+ top = [d for (sim, d) in scored[:top_k] if sim >= similarity_threshold]
199
+
200
+ if not top:
201
+ return "", "ℹ️ No chunks passed the similarity threshold; answering from instructions only."
202
+
203
+ context_parts = []
204
+ for idx, d in enumerate(top, start=1):
205
+ src = d.get("source", "unknown")
206
+ txt = d.get("text", "")
207
+ context_parts.append(
208
+ f"[Chunk {idx} | Source: {src}]\n{txt}\n"
209
+ )
210
+
211
+ context = "\n\n---\n\n".join(context_parts)
212
+ debug = f"📚 Retrieved {len(top)} chunks from KB (top_k={top_k}, threshold={similarity_threshold})."
213
+ return context, debug
214
+
215
+
216
+ # -------------------- GRADIO CALLBACKS --------------------
217
+
218
+ def save_api_key(api_key: str):
219
+ api_key = (api_key or "").strip()
220
+ if not api_key:
221
+ return "❌ No API key provided.", ""
222
+ masked = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) >= 8 else "******"
223
+ status = f"✅ API key saved for this session: `{masked}`"
224
+ return status, api_key
225
+
226
+
227
+ def apply_preset(preset_name: str):
228
+ cfg = PRESET_CONFIGS.get(preset_name) or PRESET_CONFIGS["None (manual setup)"]
229
+ return cfg["system"], cfg["urls"], cfg["text"]
230
+
231
+
232
+ def build_knowledge_base(
233
+ api_key: str,
234
+ urls_text: str,
235
+ raw_text: str,
236
+ file_paths: List[str] | None,
237
+ ):
238
+ api_key = (api_key or "").strip()
239
+ if not api_key:
240
+ return "❌ Please save your OpenAI API key first.", []
241
+
242
+ docs = []
243
+
244
+ # URLs
245
+ urls = [u.strip() for u in (urls_text or "").splitlines() if u.strip()]
246
+ for u in urls:
247
+ txt = fetch_url_text(u)
248
+ docs.append({"source": u, "text": txt})
249
+
250
+ # Raw text
251
+ if raw_text and raw_text.strip():
252
+ docs.append({"source": "Raw Text Block", "text": raw_text})
253
+
254
+ # Files
255
+ if file_paths is not None:
256
+ if isinstance(file_paths, str):
257
+ file_paths = [file_paths]
258
+ for p in file_paths:
259
+ if not p:
260
+ continue
261
+ txt = read_file_text(p)
262
+ src_name = os.path.basename(p)
263
+ docs.append({"source": f"File: {src_name}", "text": txt})
264
+
265
+ if not docs:
266
+ return "⚠️ No knowledge sources provided (URLs, text, or files).", []
267
+
268
+ kb, status = build_embeddings(api_key, docs)
269
+ return status, kb
270
+
271
+
272
+ def chat_with_rag(
273
+ user_message: str,
274
+ api_key: str,
275
+ kb: List[Dict[str, Any]],
276
+ system_prompt: str,
277
+ history: List[Dict[str, str]],
278
+ ):
279
+ user_message = (user_message or "").strip()
280
+ api_key = (api_key or "").strip()
281
+ system_prompt = (system_prompt or "").strip()
282
+
283
+ if not user_message:
284
+ return history, history, "❌ Please enter a question."
285
+
286
+ if not api_key:
287
+ return history, history, "❌ Please save your OpenAI API key first."
288
+
289
+ if not system_prompt:
290
+ system_prompt = DEFAULT_SYSTEM_PROMPT
291
+
292
+ # Retrieve context from KB
293
+ context, debug_retrieval = retrieve_context(api_key, kb, user_message)
294
+
295
+ client = OpenAI(api_key=api_key)
296
+
297
+ # Assemble messages for OpenAI
298
+ messages = []
299
+ combined_system = (
300
+ DEFAULT_SYSTEM_PROMPT.strip()
301
+ + "\n\n---\n\nUser System Instructions:\n"
302
+ + system_prompt.strip()
303
+ )
304
+ messages.append({"role": "system", "content": combined_system})
305
+
306
+ if context:
307
+ context_block = (
308
+ "You have access to the following knowledge base context.\n"
309
+ "You MUST base your answer ONLY on this context and the system instructions.\n"
310
+ "If the answer is not supported by the context, say you don’t know.\n\n"
311
+ f"{context}"
312
+ )
313
+ messages.append({"role": "system", "content": context_block})
314
+
315
+ # Add truncated history for conversational continuity
316
+ recent_history = history[-10:] if history else []
317
+ for msg in recent_history:
318
+ if msg.get("role") in ("user", "assistant"):
319
+ messages.append(msg)
320
+
321
+ # Current user message
322
+ messages.append({"role": "user", "content": user_message})
323
+
324
+ try:
325
+ resp = client.chat.completions.create(
326
+ model=CHAT_MODEL,
327
+ messages=messages,
328
+ temperature=0.3,
329
+ max_tokens=900,
330
+ )
331
+ answer = resp.choices[0].message.content
332
+ except Exception as e:
333
+ answer = f"⚠️ OpenAI API error: {e}"
334
+
335
+ # Update history for display and next turn
336
+ new_history = history + [
337
+ {"role": "user", "content": user_message},
338
+ {"role": "assistant", "content": answer},
339
+ ]
340
+
341
+ return new_history, new_history, debug_retrieval
342
+
343
+
344
+ def clear_chat():
345
+ return [], [], ""
346
+
347
+
348
+ # -------------------- UI LAYOUT --------------------
349
+
350
+ with gr.Blocks(title="RAG Chatbot — GPT-5.1 + URLs / Files / Text") as demo:
351
+ gr.Markdown(
352
+ """
353
+ # 🔍 RAG Chatbot — GPT-5.1 + URLs / Files / Text
354
+
355
+ 1. Enter your **OpenAI API key** and click **Save**.
356
+ 2. Add knowledge via **URLs**, **uploaded files**, and/or **raw text**.
357
+ 3. Click **Build / Refresh Knowledge Base**.
358
+ 4. Ask questions — the bot will answer **only** from your knowledge and system instructions.
359
+ """
360
+ )
361
+
362
+ api_key_state = gr.State("")
363
+ kb_state = gr.State([])
364
+ chat_state = gr.State([])
365
+
366
+ with gr.Row():
367
+ with gr.Column(scale=1):
368
+ gr.Markdown("### 🔑 API & System")
369
+
370
+ api_key_box = gr.Textbox(
371
+ label="OpenAI API Key",
372
+ placeholder="sk-...",
373
+ type="password",
374
+ )
375
+ save_api_btn = gr.Button("Save API Key", variant="primary")
376
+ save_status = gr.Markdown("API key not set.")
377
+
378
+ preset_dropdown = gr.Dropdown(
379
+ label="Presets",
380
+ choices=list(PRESET_CONFIGS.keys()),
381
+ value="None (manual setup)",
382
+ )
383
+
384
+ system_box = gr.Textbox(
385
+ label="System Instructions",
386
+ lines=8,
387
+ value=DEFAULT_SYSTEM_PROMPT,
388
+ )
389
+
390
+ gr.Markdown("### 📚 Knowledge Sources")
391
+
392
+ urls_box = gr.Textbox(
393
+ label="Knowledge URLs (one per line)",
394
+ lines=4,
395
+ placeholder="https://example.com/docs\nhttps://zenai.world",
396
+ )
397
+
398
+ raw_text_box = gr.Textbox(
399
+ label="Additional Knowledge Text",
400
+ lines=6,
401
+ placeholder="Paste any notes, docs, or reference text here...",
402
+ )
403
+
404
+ files_input = gr.File(
405
+ label="Upload Knowledge Files (.txt, .md, .csv, .json)",
406
+ file_count="multiple",
407
+ type="filepath",
408
+ )
409
+
410
+ build_kb_btn = gr.Button(
411
+ "Build / Refresh Knowledge Base",
412
+ variant="secondary",
413
+ )
414
+ kb_status_md = gr.Markdown("ℹ️ No knowledge base built yet.")
415
+
416
+ with gr.Column(scale=2):
417
+ gr.Markdown("### 💬 RAG Chat")
418
+
419
+ chatbot = gr.Chatbot(
420
+ label="RAG Chatbot (GPT-5.1)",
421
+ type="messages",
422
+ height=450,
423
+ )
424
+
425
+ user_input = gr.Textbox(
426
+ label="Ask a question",
427
+ lines=3,
428
+ placeholder="Ask about the content of your URLs, files, or pasted text...",
429
+ )
430
+
431
+ with gr.Row():
432
+ send_btn = gr.Button("Send", variant="primary")
433
+ clear_btn = gr.Button("Clear Chat")
434
+
435
+ debug_md = gr.Markdown(
436
+ "ℹ️ Retrieval debug info will appear here after each answer."
437
+ )
438
+
439
+ # Wiring: save API key
440
+ save_api_btn.click(
441
+ fn=save_api_key,
442
+ inputs=[api_key_box],
443
+ outputs=[save_status, api_key_state],
444
+ )
445
+
446
+ # Wiring: presets
447
+ preset_dropdown.change(
448
+ fn=apply_preset,
449
+ inputs=[preset_dropdown],
450
+ outputs=[system_box, urls_box, raw_text_box],
451
+ )
452
+
453
+ # Wiring: build knowledge base
454
+ build_kb_btn.click(
455
+ fn=build_knowledge_base,
456
+ inputs=[api_key_state, urls_box, raw_text_box, files_input],
457
+ outputs=[kb_status_md, kb_state],
458
+ )
459
+
460
+ # Wiring: chat send
461
+ send_btn.click(
462
+ fn=chat_with_rag,
463
+ inputs=[user_input, api_key_state, kb_state, system_box, chat_state],
464
+ outputs=[chatbot, chat_state, debug_md],
465
+ )
466
+
467
+ user_input.submit(
468
+ fn=chat_with_rag,
469
+ inputs=[user_input, api_key_state, kb_state, system_box, chat_state],
470
+ outputs=[chatbot, chat_state, debug_md],
471
+ )
472
+
473
+ # Wiring: clear chat
474
+ clear_btn.click(
475
+ fn=clear_chat,
476
+ inputs=[],
477
+ outputs=[chatbot, chat_state, debug_md],
478
+ )
479
+
480
+ if __name__ == "__main__":
481
+ demo.launch()