bebechien commited on
Commit
da2da03
·
verified ·
1 Parent(s): a304b24

Upload folder using huggingface_hub

Browse files
1_cache/hollow_knight_bosses.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:63698148fb5388bea51df998f0dd7c3a44c485378bc6f027054e543462258c9f
3
+ size 1019434
2_cache/silksong_areas.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:941ad2fefd649820a08f377cd17e76cc55aca6b4e95438fe2a72ab47f3467e5d
3
+ size 340610
2_cache/silksong_bosses.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd8ca945cb1fd248ad50abd3aa8dfc89826fab4acbbcb1d1920432b769b4c306
3
+ size 554238
2_cache/silksong_game.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f16cfbac2c5f4c6f51132c16eafca4608c6ebbcad266ae25788d00025725aacd
3
+ size 12448
2_cache/silksong_hornet.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5798a9c5a1162da9c28db56e482dc952df2b965ebd6c36caf5fa1e56f4f739cd
3
+ size 9765
2_cache/silksong_items.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9cff1185e6c6f2542463e48688fbecbe1ae913e6a09e9c643580be9e39886bd7
3
+ size 319964
2_cache/silksong_npcs.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3f21a378066dcbf00dceafb662cd402662a0d59b28807b4001e417c8df22a979
3
+ size 417322
2_cache/silksong_tasks.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:01ba382313632a863f8be78d9cdb6ab02ecefb1481d81579208d8f531260f5ee
3
+ size 52086
2_cache/silksong_tools_and_skills.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3c2fb17b9eb9e26a93a8b50bb19b8e82d0504b80519620723c6cb05c1fb4b623
3
+ size 260332
app.py CHANGED
@@ -1,439 +1,22 @@
1
- import gradio as gr
2
- import requests
3
- import os
4
- import pickle
5
- import spaces
6
- import torch
7
- from bs4 import BeautifulSoup
8
- from html_to_markdown import convert_to_markdown
9
  from huggingface_hub import login
10
- from sentence_transformers import SentenceTransformer, util
11
- from transformers import pipeline, TextIteratorStreamer
12
- from threading import Thread
13
- from tqdm import tqdm
14
-
15
- # --- 1. CONFIGURATION ---
16
- # Centralized place for all settings and constants.
17
-
18
- # Hugging Face & Model Configuration
19
- HF_TOKEN = os.getenv('HF_TOKEN')
20
- EMBEDDING_MODEL_ID = "google/embeddinggemma-300M"
21
- LLM_MODEL_ID = "google/gemma-3-12B-it"
22
-
23
- # Data Source Configuration
24
- BASE_URL = "https://hollowknight.wiki"
25
-
26
- GAME_KNOWLEDGE_DATA = [
27
- {
28
- "title": "Hollow Knight",
29
- "category_list": [
30
- {
31
- "entry": "/w/Category:Bosses_(Hollow_Knight)",
32
- "cache": "hollow_knight_bosses.pkl",
33
- "label": "Bosses",
34
- },
35
- ],
36
- },
37
- {
38
- "title": "Silksong",
39
- "category_list": [
40
- {
41
- "entry": "/w/Hornet_(Silksong)",
42
- "cache": "silksong_hornet.pkl",
43
- "label": "General",
44
- },
45
- {
46
- "entry": "/w/Hollow_Knight:_Silksong",
47
- "cache": "silksong_game.pkl",
48
- "label": "General",
49
- },
50
- {
51
- "entry": "/w/Category:Areas_(Silksong)",
52
- "cache": "silksong_areas.pkl",
53
- "label": "Areas",
54
- },
55
- {
56
- "entry": "/w/Category:Bosses_(Silksong)",
57
- "cache": "silksong_bosses.pkl",
58
- "label": "Bosses",
59
- },
60
- {
61
- "entry": "/w/Category:Items_(Silksong)",
62
- "cache": "silksong_items.pkl",
63
- "label": "Items",
64
- },
65
- {
66
- "entry": "/w/Category:NPCs_(Silksong)",
67
- "cache": "silksong_npcs.pkl",
68
- "label": "NPCs",
69
- },
70
- {
71
- "entry": "/w/Tasks",
72
- "cache": "silksong_tasks.pkl",
73
- "label": "Tasks",
74
- },
75
- {
76
- "entry": "/w/Category:Tools_and_Skills_(Silksong)",
77
- "cache": "silksong_tools_and_skills.pkl",
78
- "label": "Tools and Skills",
79
- },
80
- ],
81
- },
82
- ]
83
-
84
- # Gradio App Configuration
85
- BASE_SIMILARITY_THRESHOLD = 0.2
86
- FOLLOWUP_SIMILARITY_THRESHOLD = 0.5
87
- DEFAULT_MESSAGE_NO_MATCH = "I'm sorry, I can't find a relevant document to answer that question."
88
-
89
-
90
- # --- 2. HELPER FUNCTIONS ---
91
- # Reusable functions for web scraping and data processing.
92
-
93
- def _get_html(url: str) -> str:
94
- """Fetches HTML content from a URL."""
95
- try:
96
- response = requests.get(url)
97
- response.raise_for_status() # Raises an HTTPError for bad responses (4xx or 5xx)
98
- return response.text
99
- except requests.exceptions.RequestException as e:
100
- print(f"Error fetching {url}: {e}")
101
- return ""
102
-
103
- def _find_wiki_links(html_content: str) -> list[str]:
104
- """Parses HTML to find all boss links within the 'mw-pages' div."""
105
- soup = BeautifulSoup(html_content, 'html.parser')
106
- mw_pages_div = soup.find('div', id='mw-pages')
107
- if not mw_pages_div:
108
- return []
109
- return [a['href'] for a in mw_pages_div.find_all('a', href=True)]
110
-
111
- def _get_markdown_from_html(html: str) -> str:
112
- if not html:
113
- return ""
114
-
115
- soup = BeautifulSoup(html, 'html.parser')
116
- return convert_to_markdown(soup)
117
-
118
- def _get_markdown_from_url(url: str) -> str:
119
- return _get_markdown_from_html(_get_html(url))
120
-
121
-
122
- # --- 3. DATA PROCESSING & CACHING ---
123
- # Scrapes data and generates embeddings, using a cache to avoid re-running.
124
-
125
- def _clean_text(text: str) -> str:
126
- """Removes the references section from the raw text."""
127
- return text.split("References\n----------\n", 1)[0].strip()
128
-
129
- @torch.no_grad()
130
- def _create_data_entry(text: str, doc_path: str, label: str, embedding_model) -> dict | None:
131
- """Creates a single structured data entry with text, metadata, and embedding."""
132
- cleaned_text = _clean_text(text)
133
- if not cleaned_text:
134
- return None
135
-
136
- title = doc_path.split('/')[-1]
137
- # Encode returns a numpy array; convert to tensor for stacking later.
138
- embedding = embedding_model.encode(cleaned_text, prompt=f"title: {title} | text: ")
139
- return {
140
- "text": cleaned_text,
141
- "embedding": torch.tensor(embedding), ### Store as tensor for faster processing
142
- "metadata": {
143
- "category": label,
144
- "source": BASE_URL + doc_path,
145
- "title": title
146
- }
147
- }
148
-
149
- def load_or_process_source(entry_point: str, cache_file: str, label: str, embedding_model):
150
- """
151
- Loads processed data from a cache file if it exists. Otherwise, scrapes,
152
- processes, generates embeddings, and saves to the cache.
153
- """
154
- if os.path.exists(cache_file):
155
- print(f"✅ Found cache for {label}. Loading data from '{cache_file}'...")
156
- with open(cache_file, 'rb') as f:
157
- return pickle.load(f)
158
-
159
- print(f"ℹ️ No cache for {label}. Starting data scraping and processing...")
160
- processed_data = []
161
-
162
- main_page_html = _get_html(BASE_URL + entry_point)
163
- data_entry = _create_data_entry(_get_markdown_from_html(main_page_html), entry_point, label, embedding_model)
164
- if (data_entry):
165
- processed_data.append(data_entry)
166
-
167
- extracted_links = _find_wiki_links(main_page_html)
168
-
169
- for doc_path in tqdm(extracted_links, desc=f"Processing {label} Pages"):
170
- full_url = BASE_URL + doc_path
171
- text = _get_markdown_from_url(full_url)
172
-
173
- data_entry = _create_data_entry(text, doc_path, label, embedding_model)
174
- if data_entry:
175
- processed_data.append(data_entry)
176
-
177
- print(f"✅ {label} processing complete. Saving {len(processed_data)} entries to '{cache_file}'...")
178
- with open(cache_file, 'wb') as f:
179
- pickle.dump(processed_data, f)
180
-
181
- return processed_data
182
-
183
-
184
- # --- 4. CORE AI LOGIC ---
185
- # Functions for finding context and generating a response.
186
-
187
- @torch.no_grad()
188
- def find_best_context(model: SentenceTransformer, query: str, contents: list[dict], similarity_threshold: float) -> int:
189
- """Finds the most relevant document index based on semantic similarity."""
190
- if not query or not contents:
191
- return -1
192
-
193
- query_embedding = model.encode(query, prompt_name="query", convert_to_tensor=True).to(model.device)
194
-
195
- try:
196
- # Stack pre-computed tensors from our knowledge base
197
- contents_embeddings = torch.stack([item["embedding"] for item in contents]).to(model.device)
198
- except (RuntimeError, IndexError, TypeError) as e:
199
- print(f"Warning: Could not stack content embeddings. Error: {e}")
200
- return -1
201
-
202
- # Compute cosine similarity between the 1 query embedding and N content embeddings
203
- similarities = util.pytorch_cos_sim(query_embedding, contents_embeddings)
204
 
205
- if similarities.numel() == 0:
206
- print("Warning: Similarity computation returned an empty tensor.")
207
- return -1
208
-
209
- # Get the index and score of the top match
210
- best_index = similarities.argmax().item()
211
- best_score = similarities[0, best_index].item()
212
-
213
- print(f"Best score: {best_score:.4f} (Threshold: {similarity_threshold})")
214
- if best_score >= similarity_threshold:
215
- print(f"Using \"{contents[best_index]['metadata']['source']}\"...")
216
- return best_index
217
-
218
- print("No context met the similarity threshold.")
219
- return -1
220
-
221
-
222
- class ChatContext(object):
223
- context_index = -1
224
- base_similarity = BASE_SIMILARITY_THRESHOLD
225
- followup_similarity = FOLLOWUP_SIMILARITY_THRESHOLD
226
-
227
- default_context = ChatContext()
228
-
229
- @spaces.GPU
230
- def respond(message: str, history: list, game: str, chat_context: ChatContext):
231
- """Generates a streaming response from the LLM based on the best context found."""
232
- default_threshold = chat_context.base_similarity
233
- followup_threshold = chat_context.followup_similarity
234
-
235
- contents = _select_content(game)
236
- if not contents:
237
- print(f"No content found for {game}")
238
- chat_context.context_index = -1 # Return -1 to reset context
239
- yield DEFAULT_MESSAGE_NO_MATCH, chat_context
240
- return
241
-
242
- if len(history) == 0:
243
- # Clear context on a new conversation
244
- print("New conversation started. Clearing context.")
245
- chat_context.context_index = -1
246
-
247
- # Determine threshold: Use follow-up ONLY if we have a valid previous context.
248
- similarity_threshold = followup_threshold if chat_context.context_index != -1 else default_threshold
249
- print(f"Using {'follow-up' if chat_context.context_index != -1 else 'default'} threshold: {similarity_threshold}")
250
-
251
- # Find the best new context based on the current message
252
- found_context_index = find_best_context(embedding_model, message, contents, similarity_threshold)
253
-
254
- if found_context_index >= 0:
255
- chat_context.context_index = found_context_index # A new, relevant context was found and set
256
- elif chat_context.context_index >= 0:
257
- # PASS: A follow-up question, but no new context. Reuse the old one.
258
- print("No new context found, reusing previous context for follow-up.")
259
- else:
260
- # FAILURE: No new context was found AND no previous context exists.
261
- print("No context found and no previous context. Yielding no match.")
262
- yield DEFAULT_MESSAGE_NO_MATCH, chat_context
263
- return
264
-
265
- system_prompt = f"Answer the following QUESTION based only on the CONTEXT provided. If the answer cannot be found in the CONTEXT, write \"{DEFAULT_MESSAGE_NO_MATCH}\"\n---\nCONTEXT:\n{contents[chat_context.context_index]['text']}\n"
266
- user_prompt = f"QUESTION:\n{message}"
267
-
268
- messages = [{"role": "system", "content": system_prompt}]
269
- # Add previous turns (history) after the system prompt but before the current question
270
- messages.extend(history)
271
- messages.append({"role": "user", "content": user_prompt})
272
-
273
- # Debug print the conversation being sent (excluding the large system prompt)
274
- for item in messages[1:]:
275
- print(f"[{item['role']}] {item['content']}")
276
-
277
- streamer = TextIteratorStreamer(llm_pipeline.tokenizer, skip_prompt=True, skip_special_tokens=True)
278
-
279
- thread = Thread(
280
- target=llm_pipeline,
281
- kwargs=dict(
282
- text_inputs=messages,
283
- streamer=streamer,
284
- max_new_tokens=512,
285
- do_sample=True,
286
- top_p=0.95,
287
- temperature=0.7,
288
- )
289
- )
290
- thread.start()
291
-
292
- response = ""
293
- for new_text in streamer:
294
- response += new_text
295
- # Yield the partial response AND the current state
296
- yield response, chat_context
297
-
298
-
299
- # --- 5. INITIALIZATION ---
300
- # Login, load models, and process data.
301
-
302
- print("Logging into Hugging Face Hub...")
303
- login(token=HF_TOKEN)
304
-
305
- print("Initializing embedding model...")
306
- embedding_model = SentenceTransformer(EMBEDDING_MODEL_ID)
307
-
308
- print("Initializing language model...")
309
- llm_pipeline = pipeline(
310
- "text-generation",
311
- model=LLM_MODEL_ID,
312
- device_map="auto",
313
- dtype="auto",
314
- )
315
-
316
- print("\n--- Processing Game Data ---")
317
- knowledge_base = {}
318
-
319
- for item in GAME_KNOWLEDGE_DATA:
320
- knowledge_base[item['title']] = []
321
- for category in item['category_list']:
322
- knowledge_base[item['title']] += load_or_process_source(category['entry'], category['cache'], category['label'], embedding_model)
323
-
324
- def _select_content(game: str):
325
- return knowledge_base[game]
326
-
327
-
328
- # --- 6. GRADIO UI ---
329
- # Defines the web interface for the chatbot.
330
- gr.set_static_paths(paths=["assets/"])
331
-
332
- # Theme and CSS for the Silksong aesthetic
333
- silksong_theme = gr.themes.Default(
334
- primary_hue=gr.themes.colors.red,
335
- secondary_hue=gr.themes.colors.zinc,
336
- neutral_hue=gr.themes.colors.zinc,
337
- font=[gr.themes.GoogleFont("IM Fell English"), "ui-sans-serif", "system-ui", "sans-serif"],
338
- )
339
-
340
- silksong_css="""
341
- .gradio-container {
342
- background-image: linear-gradient(rgba(255,255,255, 0.5), rgba(255, 255, 255, 1.0)), url("/gradio_api/file=assets/background.jpg");
343
- background-size: 100%;
344
- background-repeat: no-repeat;
345
- background-position: top center;
346
- }
347
- body.dark .gradio-container {
348
- background-image: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 1.0)), url("/gradio_api/file=assets/background.jpg");
349
- }
350
- .header-text { text-align: center; text-shadow: 2px 2px 5px #000; }
351
- .header-text h1 { font-size: 2.5em; color: #dc2626; }
352
- .dark .header-text { text-shadow: 2px 2px 5px #FFF; }
353
- .context { text-align: center; color: var(--body-text-color-subdued); }
354
- .context a { color: #dc2626; }
355
- .disclaimer { text-align: center; color: var(--body-text-color-subdued); font-size: 0.9em; padding: 20px; }
356
- .disclaimer ul { list-style: none; padding: 0; }
357
- .disclaimer a { color: #dc2626; }
358
- """
359
-
360
-
361
- def _index_changed(context_state: ChatContext, game_title: str):
362
- """Updates the HTML context display when the context_index state changes."""
363
- context_index = context_state.context_index
364
- if context_index < 0:
365
- return """<div class="context">Context: None</div>"""
366
-
367
- contents = _select_content(game_title)
368
- if not contents or context_index >= len(contents):
369
- return """<div class="context">Context: Error</div>"""
370
-
371
- url = contents[context_index]['metadata']['source']
372
- title = contents[context_index]['metadata']['title']
373
- return f"""<div class="context">Context: <a href="{url}" target="_blank">{title}</a></div>"""
374
-
375
- def _title_changed(context_state: ChatContext):
376
- """Resets the context display and the context state when the game is changed."""
377
- context_state.context_index = -1
378
- return """<div class="context">Context: None</div>""", context_state
379
-
380
- def _sim_changed(context_state: ChatContext, base_sim: float, followup_sim: float):
381
- context_state.base_similarity = base_sim
382
- context_state.followup_similarity = followup_sim
383
- return context_state
384
-
385
- with gr.Blocks(theme=silksong_theme, css=silksong_css) as demo:
386
- gr.HTML("""
387
- <div class="header-text">
388
- <h1>A Weaver's Counsel</h1>
389
- <p>Speak, little traveler. What secrets of Pharloom do you seek?</p>
390
- <p style="font-style: italic;">(Note: This bot has a limited knowledge.)</p>
391
- </div>
392
- """)
393
-
394
- game_title = gr.Dropdown(["Hollow Knight", "Silksong"], label="Game", value="Silksong")
395
-
396
- output = gr.HTML("""<div class="context">Context: None</div>""")
397
-
398
- # Link the state object to the UI elements
399
- context_state = gr.State(default_context)
400
- context_state.change(_index_changed, [context_state, game_title], output)
401
- game_title.change(_title_changed, context_state, [output, context_state])
402
-
403
- gr.ChatInterface(
404
- respond,
405
- type="messages",
406
- chatbot=gr.Chatbot(type="messages", label=LLM_MODEL_ID),
407
- textbox=gr.Textbox(placeholder="Ask about the haunted kingdom...", container=False, submit_btn=True, scale=7),
408
- additional_inputs=[
409
- game_title,
410
- context_state, ### Pass the state object as an input
411
- ],
412
- additional_outputs=[context_state], ### Receive the updated state as an output
413
- examples=[
414
- ["Where can I find the Moorwing?", "Silksong"],
415
- ["Who is the voice of Lace?", "Silksong"],
416
- ["How can I beat the False Knight?", "Hollow Knight"],
417
- ["Any achievement for Hornet Protector?", "Hollow Knight"],
418
- ],
419
- cache_examples=False,
420
- )
421
-
422
- base_sim = gr.Slider(minimum=0.1, maximum=1.0, value=BASE_SIMILARITY_THRESHOLD, step=0.1, label="Base Similarity Threshold")
423
- followup_sim = gr.Slider(minimum=0.1, maximum=1.0, value=FOLLOWUP_SIMILARITY_THRESHOLD, step=0.1, label="Similarity Threshold with follow-up questions (multi-turn)")
424
- base_sim.release(_sim_changed, [context_state, base_sim, followup_sim], context_state)
425
- followup_sim.release(_sim_changed, [context_state, base_sim, followup_sim], context_state)
426
-
427
- gr.HTML("""
428
- <div class="disclaimer">
429
- <p><strong>Disclaimer:</strong></p>
430
- <ul style="list-style: none; padding: 0;">
431
- <li>This is a fan-made personal demonstration and not affiliated with any organization.<br>The bot is for entertainment purposes only.</li>
432
- <li>Factual information is sourced from the <a href="https://hollowknight.wiki" target="_blank">Hollow Knight Wiki</a>.<br>Content is available under <a href="https://creativecommons.org/licenses/by-sa/3.0/" target="_blank">Commons Attribution-ShareAlike</a> unless otherwise noted.</li>
433
- <li>Built by <a href="https://huggingface.co/bebechien" target="_blank">bebechien</a> with a 💖 for the world of Hollow Knight.</li>
434
- </ul>
435
- </div>
436
- """)
437
 
438
  if __name__ == "__main__":
439
- demo.launch()
 
 
 
 
 
 
 
 
 
1
  from huggingface_hub import login
2
+ from config import HF_TOKEN, GAME_KNOWLEDGE_DATA, EMBEDDING_MODEL_ID, LLM_MODEL_ID
3
+ from rag_service import RAGService
4
+ from ui import build_gradio_ui
5
+
6
+ def main():
7
+ """Main function to initialize and launch the chatbot application."""
8
+ print("Logging into Hugging Face Hub...")
9
+ login(token=HF_TOKEN)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ # 1. Create the single service instance. This loads all models and data.
12
+ rag_service = RAGService(GAME_KNOWLEDGE_DATA, EMBEDDING_MODEL_ID, LLM_MODEL_ID)
13
+
14
+ # 2. Build the UI, passing the service instance to it.
15
+ demo = build_gradio_ui(rag_service)
16
+
17
+ # 3. Launch the application.
18
+ print("Launching Gradio demo...")
19
+ demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  if __name__ == "__main__":
22
+ main()
chat_context.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from config import BASE_SIMILARITY_THRESHOLD, FOLLOWUP_SIMILARITY_THRESHOLD
2
+
3
+ class ChatContext:
4
+ """Holds the conversational state, including the current context and thresholds."""
5
+ def __init__(self):
6
+ self.context_index = -1
7
+ self.base_similarity = BASE_SIMILARITY_THRESHOLD
8
+ self.followup_similarity = FOLLOWUP_SIMILARITY_THRESHOLD
config.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import gradio as gr
3
+ import pickle
4
+ import torch
5
+ from tqdm import tqdm
6
+
7
+ from web_helper import get_html, find_wiki_links, get_markdown_from_html, get_markdown_from_url
8
+
9
+ # --- Hugging Face & Model Configuration ---
10
+ HF_TOKEN = os.getenv('HF_TOKEN')
11
+ EMBEDDING_MODEL_ID = "google/embeddinggemma-300M"
12
+ LLM_MODEL_ID = "google/gemma-3-12B-it"
13
+
14
+ # --- Data Source Configuration ---
15
+ BASE_URL = "https://hollowknight.wiki"
16
+ GAME_KNOWLEDGE_DATA = [
17
+ {
18
+ "title": "Hollow Knight",
19
+ "cache_folder": "1_cache",
20
+ "category_list": [
21
+ {
22
+ "entry": "/w/Category:Bosses_(Hollow_Knight)",
23
+ "cache": "hollow_knight_bosses.pkl",
24
+ "label": "Bosses",
25
+ },
26
+ ],
27
+ },
28
+ {
29
+ "title": "Silksong",
30
+ "cache_folder": "2_cache",
31
+ "category_list": [
32
+ {
33
+ "entry": "/w/Hornet_(Silksong)",
34
+ "cache": "silksong_hornet.pkl",
35
+ "label": "General",
36
+ },
37
+ {
38
+ "entry": "/w/Hollow_Knight:_Silksong",
39
+ "cache": "silksong_game.pkl",
40
+ "label": "General",
41
+ },
42
+ {
43
+ "entry": "/w/Category:Areas_(Silksong)",
44
+ "cache": "silksong_areas.pkl",
45
+ "label": "Areas",
46
+ },
47
+ {
48
+ "entry": "/w/Category:Bosses_(Silksong)",
49
+ "cache": "silksong_bosses.pkl",
50
+ "label": "Bosses",
51
+ },
52
+ {
53
+ "entry": "/w/Category:Items_(Silksong)",
54
+ "cache": "silksong_items.pkl",
55
+ "label": "Items",
56
+ },
57
+ {
58
+ "entry": "/w/Category:NPCs_(Silksong)",
59
+ "cache": "silksong_npcs.pkl",
60
+ "label": "NPCs",
61
+ },
62
+ {
63
+ "entry": "/w/Tasks",
64
+ "cache": "silksong_tasks.pkl",
65
+ "label": "Tasks",
66
+ },
67
+ {
68
+ "entry": "/w/Category:Tools_and_Skills_(Silksong)",
69
+ "cache": "silksong_tools_and_skills.pkl",
70
+ "label": "Tools and Skills",
71
+ },
72
+ ],
73
+ },
74
+ ]
75
+
76
+ def get_all_game_data(embedding_model):
77
+ """Loops through the config and processes/loads all knowledge sources."""
78
+ print("\n--- Processing Game Data ---")
79
+ knowledge_base = {}
80
+
81
+ for item in GAME_KNOWLEDGE_DATA:
82
+ title = item['title']
83
+ knowledge_base[title] = []
84
+ for category in item['category_list']:
85
+ cache_path = f"""{item["cache_folder"]}/{category["cache"]}"""
86
+ knowledge_base[title] += _load_or_process_source(
87
+ category['entry'],
88
+ cache_path,
89
+ category['label'],
90
+ embedding_model
91
+ )
92
+
93
+ return knowledge_base
94
+
95
+ # --- DATA PROCESSING & CACHING ---
96
+ # Scrapes data and generates embeddings, using a cache to avoid re-running.
97
+ def _clean_text(text: str) -> str:
98
+ """Removes the references section from the raw text."""
99
+ return text.split("References\n----------\n", 1)[0].strip()
100
+
101
+ @torch.no_grad()
102
+ def _create_data_entry(text: str, doc_path: str, label: str, embedding_model) -> dict | None:
103
+ """Creates a single structured data entry with text, metadata, and embedding."""
104
+ cleaned_text = _clean_text(text)
105
+ if not cleaned_text:
106
+ return None
107
+
108
+ title = doc_path.split('/')[-1]
109
+ # Encode returns a numpy array; convert to tensor for stacking later.
110
+ embedding = embedding_model.encode(cleaned_text, prompt=f"title: {title} | text: ")
111
+ return {
112
+ "text": cleaned_text,
113
+ "embedding": torch.tensor(embedding), ### Store as tensor for faster processing
114
+ "metadata": {
115
+ "category": label,
116
+ "source": BASE_URL + doc_path,
117
+ "title": title
118
+ }
119
+ }
120
+
121
+ def _load_or_process_source(entry_point: str, cache_file: str, label: str, embedding_model):
122
+ """
123
+ Loads processed data from a cache file if it exists. Otherwise, scrapes,
124
+ processes, generates embeddings, and saves to the cache.
125
+ """
126
+ if os.path.exists(cache_file):
127
+ print(f"✅ Found cache for {label}. Loading data from '{cache_file}'...")
128
+ with open(cache_file, 'rb') as f:
129
+ return pickle.load(f)
130
+
131
+ print(f"ℹ️ No cache for {label}. Starting data scraping and processing...")
132
+ processed_data = []
133
+
134
+ main_page_html = get_html(BASE_URL + entry_point)
135
+ data_entry = _create_data_entry(get_markdown_from_html(main_page_html), entry_point, label, embedding_model)
136
+ if (data_entry):
137
+ processed_data.append(data_entry)
138
+
139
+ extracted_links = find_wiki_links(main_page_html)
140
+
141
+ for doc_path in tqdm(extracted_links, desc=f"Processing {label} Pages"):
142
+ full_url = BASE_URL + doc_path
143
+ text = get_markdown_from_url(full_url)
144
+
145
+ data_entry = _create_data_entry(text, doc_path, label, embedding_model)
146
+ if data_entry:
147
+ processed_data.append(data_entry)
148
+
149
+ print(f"✅ {label} processing complete. Saving {len(processed_data)} entries to '{cache_file}'...")
150
+ with open(cache_file, 'wb') as f:
151
+ pickle.dump(processed_data, f)
152
+
153
+ return processed_data
154
+
155
+ # --- App Logic Configuration ---
156
+ BASE_SIMILARITY_THRESHOLD = 0.2
157
+ FOLLOWUP_SIMILARITY_THRESHOLD = 0.5
158
+ DEFAULT_MESSAGE_NO_MATCH = "I'm sorry, I can't find a relevant document to answer that question."
159
+
160
+
161
+ # --- Gradio UI Configuration ---
162
+ silksong_theme = gr.themes.Default(
163
+ primary_hue=gr.themes.colors.red,
164
+ secondary_hue=gr.themes.colors.zinc,
165
+ neutral_hue=gr.themes.colors.zinc,
166
+ font=[gr.themes.GoogleFont("IM Fell English"), "ui-sans-serif", "system-ui", "sans-serif"],
167
+ )
168
+
169
+ silksong_css="""
170
+ .gradio-container {
171
+ background-image: linear-gradient(rgba(255,255,255, 0.5), rgba(255, 255, 255, 1.0)), url("/gradio_api/file=assets/background.jpg");
172
+ background-size: 100%;
173
+ background-repeat: no-repeat;
174
+ background-position: top center;
175
+ }
176
+ body.dark .gradio-container {
177
+ background-image: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 1.0)), url("/gradio_api/file=assets/background.jpg");
178
+ }
179
+ .header-text { text-align: center; text-shadow: 2px 2px 5px #000; }
180
+ .header-text h1 { font-size: 2.5em; color: #dc2626; }
181
+ .dark .header-text { text-shadow: 2px 2px 5px #FFF; }
182
+ .context { text-align: center; color: var(--body-text-color-subdued); }
183
+ .context a { color: #dc2626; }
184
+ .disclaimer { text-align: center; color: var(--body-text-color-subdued); font-size: 0.9em; padding: 20px; }
185
+ .disclaimer ul { list-style: none; padding: 0; }
186
+ .disclaimer a { color: #dc2626; }
187
+ """
rag_service.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import spaces
2
+ import torch
3
+ from sentence_transformers import SentenceTransformer, util
4
+ from transformers import pipeline, TextIteratorStreamer
5
+ from threading import Thread
6
+
7
+ # Import project-specific modules
8
+ from config import BASE_URL, DEFAULT_MESSAGE_NO_MATCH, get_all_game_data
9
+ from chat_context import ChatContext
10
+
11
+ class RAGService:
12
+ """Manages model loading, data processing, and chat generation logic."""
13
+ def __init__(self, data_config: list[dict], embedding_model_id: str, llm_model_id: str):
14
+ print("Initializing RAG Service...")
15
+ self.data_config = data_config
16
+
17
+ print("Initializing embedding model...")
18
+ self.embedding_model = SentenceTransformer(embedding_model_id)
19
+
20
+ print("Initializing language model...")
21
+ self.llm_pipeline = pipeline(
22
+ "text-generation",
23
+ model=llm_model_id,
24
+ device_map="auto",
25
+ dtype="auto",
26
+ )
27
+
28
+ self.knowledge_base: dict[str, list[dict]] = get_all_game_data(self.embedding_model)
29
+
30
+ def _select_content(self, title: str) -> list[dict]:
31
+ """Helper to safely get the knowledge base for a specific title."""
32
+ return self.knowledge_base.get(title, [])
33
+
34
+ @torch.no_grad()
35
+ def find_best_context(self, query: str, contents: list[dict], similarity_threshold: float) -> int:
36
+ """Finds the most relevant document index based on semantic similarity."""
37
+ if not query or not contents:
38
+ return -1
39
+
40
+ query_embedding = self.embedding_model.encode(query, prompt_name="query", convert_to_tensor=True).to(self.embedding_model.device)
41
+
42
+ try:
43
+ # Stack pre-computed tensors from our knowledge base
44
+ contents_embeddings = torch.stack([item["embedding"] for item in contents]).to(self.embedding_model.device)
45
+ except (RuntimeError, IndexError, TypeError) as e:
46
+ print(f"Warning: Could not stack content embeddings. Error: {e}")
47
+ return -1
48
+
49
+ # Compute cosine similarity between the 1 query embedding and N content embeddings
50
+ similarities = util.pytorch_cos_sim(query_embedding, contents_embeddings)
51
+
52
+ if similarities.numel() == 0:
53
+ print("Warning: Similarity computation returned an empty tensor.")
54
+ return -1
55
+
56
+ # Get the index and score of the top match
57
+ best_index = similarities.argmax().item()
58
+ best_score = similarities[0, best_index].item()
59
+
60
+ print(f"Best score: {best_score:.4f} (Threshold: {similarity_threshold})")
61
+ if best_score >= similarity_threshold:
62
+ print(f"Using \"{contents[best_index]['metadata']['source']}\"...")
63
+ return best_index
64
+
65
+ print("No context met the similarity threshold.")
66
+ return -1
67
+
68
+ @spaces.GPU
69
+ def respond(self, message: str, history: list, title: str, chat_context: ChatContext):
70
+ """Generates a streaming response from the LLM based on the best context found."""
71
+ default_threshold = chat_context.base_similarity
72
+ followup_threshold = chat_context.followup_similarity
73
+
74
+ contents = self._select_content(title)
75
+ if not contents:
76
+ print(f"No content found for {title}")
77
+ chat_context.context_index = -1 # Return -1 to reset context
78
+ yield DEFAULT_MESSAGE_NO_MATCH, chat_context
79
+ return
80
+
81
+ if len(history) == 0:
82
+ # Clear context on a new conversation
83
+ print("New conversation started. Clearing context.")
84
+ chat_context.context_index = -1
85
+
86
+ # Determine threshold: Use follow-up ONLY if we have a valid previous context.
87
+ similarity_threshold = followup_threshold if chat_context.context_index != -1 else default_threshold
88
+ print(f"Using {'follow-up' if chat_context.context_index != -1 else 'default'} threshold: {similarity_threshold}")
89
+
90
+ # Find the best new context based on the current message
91
+ found_context_index = self.find_best_context(message, contents, similarity_threshold)
92
+
93
+ if found_context_index >= 0:
94
+ chat_context.context_index = found_context_index # A new, relevant context was found and set
95
+ elif chat_context.context_index >= 0:
96
+ # PASS: A follow-up question, but no new context. Reuse the old one.
97
+ print("No new context found, reusing previous context for follow-up.")
98
+ else:
99
+ # FAILURE: No new context was found AND no previous context exists.
100
+ print("No context found and no previous context. Yielding no match.")
101
+ yield DEFAULT_MESSAGE_NO_MATCH, chat_context
102
+ return
103
+
104
+ system_prompt = f"Answer the following QUESTION based only on the CONTEXT provided. If the answer cannot be found in the CONTEXT, write \"{DEFAULT_MESSAGE_NO_MATCH}\"\n---\nCONTEXT:\n{contents[chat_context.context_index]['text']}\n"
105
+ user_prompt = f"QUESTION:\n{message}"
106
+
107
+ messages = [{"role": "system", "content": system_prompt}]
108
+ # Add previous turns (history) after the system prompt but before the current question
109
+ messages.extend(history)
110
+ messages.append({"role": "user", "content": user_prompt})
111
+
112
+ # Debug print the conversation being sent (excluding the large system prompt)
113
+ for item in messages[1:]:
114
+ print(f"[{item['role']}] {item['content']}")
115
+
116
+ streamer = TextIteratorStreamer(self.llm_pipeline.tokenizer, skip_prompt=True, skip_special_tokens=True)
117
+
118
+ thread = Thread(
119
+ target=self.llm_pipeline,
120
+ kwargs=dict(
121
+ text_inputs=messages,
122
+ streamer=streamer,
123
+ max_new_tokens=512,
124
+ do_sample=True,
125
+ top_p=0.95,
126
+ temperature=0.7,
127
+ )
128
+ )
129
+ thread.start()
130
+
131
+ response = ""
132
+ for new_text in streamer:
133
+ response += new_text
134
+ # Yield the partial response AND the current state
135
+ yield response, chat_context
136
+
137
+ # --- Gradio UI Callback Methods ---
138
+
139
+ def on_context_changed(self, context_state: ChatContext, title: str) -> str:
140
+ """Updates the HTML context display when the context_index state changes."""
141
+ context_index = context_state.context_index
142
+ if context_index < 0:
143
+ return """<div class="context">Context: None</div>"""
144
+
145
+ contents = self._select_content(title)
146
+ if not contents or context_index >= len(contents):
147
+ return """<div class="context">Context: Error</div>"""
148
+
149
+ url = contents[context_index]['metadata']['source']
150
+ title = contents[context_index]['metadata']['title']
151
+ return f"""<div class="context">Context: <a href="{url}" target="_blank">{title}</a></div>"""
152
+
153
+ @staticmethod
154
+ def on_title_changed(context_state: ChatContext) -> tuple[str, ChatContext]:
155
+ """Resets the context display and state when the game is changed."""
156
+ context_state.context_index = -1
157
+ return """<div class="context">Context: None</div>""", context_state
158
+
159
+ @staticmethod
160
+ def on_sim_changed(context_state: ChatContext, base_sim: float, followup_sim: float) -> ChatContext:
161
+ """Updates the similarity thresholds in the context state."""
162
+ context_state.base_similarity = base_sim
163
+ context_state.followup_similarity = followup_sim
164
+ return context_state
165
+
ui.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from rag_service import RAGService
3
+ from chat_context import ChatContext
4
+ from config import (
5
+ silksong_theme,
6
+ silksong_css,
7
+ LLM_MODEL_ID,
8
+ BASE_SIMILARITY_THRESHOLD,
9
+ FOLLOWUP_SIMILARITY_THRESHOLD
10
+ )
11
+
12
+ def build_gradio_ui(rag_service: RAGService) -> gr.Blocks:
13
+ """Creates and configures the Gradio Blocks UI."""
14
+
15
+ gr.set_static_paths(paths=["assets/"])
16
+
17
+ with gr.Blocks(theme=silksong_theme, css=silksong_css) as demo:
18
+ gr.HTML("""
19
+ <div class="header-text">
20
+ <h1>A Weaver's Counsel</h1>
21
+ <p>Speak, little traveler. What secrets of Pharloom do you seek?</p>
22
+ <p style="font-style: italic;">(Note: This bot has a limited knowledge.)</p>
23
+ </div>
24
+ """)
25
+
26
+ game_title = gr.Dropdown(["Hollow Knight", "Silksong"], label="Game", value="Silksong")
27
+
28
+ output = gr.HTML("""<div class="context">Context: None</div>""")
29
+
30
+ # Link the state object to the UI elements
31
+ context_state = gr.State(ChatContext())
32
+ context_state.change(rag_service.on_context_changed, [context_state, game_title], output)
33
+ game_title.change(rag_service.on_title_changed, context_state, [output, context_state])
34
+
35
+ gr.ChatInterface(
36
+ rag_service.respond,
37
+ type="messages",
38
+ chatbot=gr.Chatbot(type="messages", label=LLM_MODEL_ID),
39
+ textbox=gr.Textbox(placeholder="Ask about the haunted kingdom...", container=False, submit_btn=True, scale=7),
40
+ additional_inputs=[
41
+ game_title,
42
+ context_state, ### Pass the state object as an input
43
+ ],
44
+ additional_outputs=[context_state], ### Receive the updated state as an output
45
+ examples=[
46
+ ["Where can I find the Moorwing?", "Silksong"],
47
+ ["Who is the voice of Lace?", "Silksong"],
48
+ ["How can I beat the False Knight?", "Hollow Knight"],
49
+ ["Any achievement for Hornet Protector?", "Hollow Knight"],
50
+ ],
51
+ cache_examples=False,
52
+ )
53
+
54
+ base_sim = gr.Slider(minimum=0.1, maximum=1.0, value=BASE_SIMILARITY_THRESHOLD, step=0.1, label="Base Similarity Threshold")
55
+ followup_sim = gr.Slider(minimum=0.1, maximum=1.0, value=FOLLOWUP_SIMILARITY_THRESHOLD, step=0.1, label="Similarity Threshold with follow-up questions (multi-turn)")
56
+
57
+ base_sim.release(rag_service.on_sim_changed, [context_state, base_sim, followup_sim], context_state)
58
+ followup_sim.release(rag_service.on_sim_changed, [context_state, base_sim, followup_sim], context_state)
59
+
60
+ gr.HTML("""
61
+ <div class="disclaimer">
62
+ <p><strong>Disclaimer:</strong></p>
63
+ <ul style="list-style: none; padding: 0;">
64
+ <li>This is a fan-made personal demonstration and not affiliated with any organization.<br>The bot is for entertainment purposes only.</li>
65
+ <li>Factual information is sourced from the <a href="https://hollowknight.wiki" target="_blank">Hollow Knight Wiki</a>.<br>Content is available under <a href="https://creativecommons.org/licenses/by-sa/3.0/" target="_blank">Commons Attribution-ShareAlike</a> unless otherwise noted.</li>
66
+ <li>Built by <a href="https://huggingface.co/bebechien" target="_blank">bebechien</a> with a 💖 for the world of Hollow Knight.</li>
67
+ </ul>
68
+ </div>
69
+ """)
70
+
71
+ return demo
web_helper.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+ from html_to_markdown import convert_to_markdown
4
+
5
+ # --- Static Helper Functions (Web Scraping) ---
6
+
7
+ @staticmethod
8
+ def get_html(url: str) -> str:
9
+ """Fetches HTML content from a URL."""
10
+ try:
11
+ response = requests.get(url)
12
+ response.raise_for_status() # Raises an HTTPError for bad responses (4xx or 5xx)
13
+ return response.text
14
+ except requests.exceptions.RequestException as e:
15
+ print(f"Error fetching {url}: {e}")
16
+ return ""
17
+
18
+ @staticmethod
19
+ def find_wiki_links(html_content: str) -> list[str]:
20
+ """Parses HTML to find all boss links within the 'mw-pages' div."""
21
+ soup = BeautifulSoup(html_content, 'html.parser')
22
+ mw_pages_div = soup.find('div', id='mw-pages')
23
+ if not mw_pages_div:
24
+ return []
25
+ return [a['href'] for a in mw_pages_div.find_all('a', href=True)]
26
+
27
+ @staticmethod
28
+ def get_markdown_from_html(html: str) -> str:
29
+ if not html:
30
+ return ""
31
+
32
+ soup = BeautifulSoup(html, 'html.parser')
33
+ return convert_to_markdown(soup)
34
+
35
+ @staticmethod
36
+ def get_markdown_from_url(url: str) -> str:
37
+ return get_markdown_from_html(get_html(url))