robertolofaro commited on
Commit
285322b
Β·
verified Β·
1 Parent(s): 441e316

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +183 -11
app.py CHANGED
@@ -3,6 +3,14 @@ app.py – Article Q&A chatbot
3
  Runs on:
4
  β€’ Hugging Face Spaces (CPU-only, default)
5
  β€’ Local PC (CPU or CUDA GPU)
 
 
 
 
 
 
 
 
6
  """
7
 
8
  import gradio as gr
@@ -15,18 +23,20 @@ from datetime import datetime, timedelta
15
  from langchain_huggingface import HuggingFaceEmbeddings
16
 
17
  # ====================== ENVIRONMENT DETECTION ======================
 
18
  IS_HF_SPACE = bool(os.environ.get("SPACE_ID"))
19
  IS_LOCAL = (not IS_HF_SPACE) or (os.environ.get("LOCAL_MODE", "0") == "1")
20
 
21
  def _detect_cuda() -> bool:
22
  """Return True only when a CUDA device is actually usable by llama-cpp."""
23
  if not IS_LOCAL:
24
- return False
25
  try:
26
  import torch
27
  return torch.cuda.is_available()
28
  except ImportError:
29
  pass
 
30
  try:
31
  import ctypes
32
  ctypes.cdll.LoadLibrary("libcuda.so.1")
@@ -35,7 +45,9 @@ def _detect_cuda() -> bool:
35
  return False
36
 
37
  CUDA_AVAILABLE = _detect_cuda()
 
38
  N_GPU_LAYERS = -1 if CUDA_AVAILABLE else 0
 
39
  N_THREADS = int(os.environ.get("N_THREADS", os.cpu_count() if IS_LOCAL else 2))
40
 
41
  # ====================== CONFIG ======================
@@ -58,12 +70,13 @@ GH_NEWS_PATH = "MorningNewsAgentTest"
58
  GH_API_ROOT = "https://api.github.com"
59
  GH_RAW_ROOT = "https://raw.githubusercontent.com"
60
  NEWS_ACCEPTED_EXT = (".txt", ".md", ".json")
61
- NEWS_MAX_CHARS_FILE = 2000
62
- NEWS_MAX_CHARS_TOTAL = 3500
63
  NEWS_CACHE_TTL = timedelta(hours=1)
64
 
65
- # Context budget
66
- CONTEXT_BUDGET_CHARS = 2900
 
67
 
68
  # ====================== LOAD METADATA ======================
69
  def load_articles_list() -> list[str]:
@@ -135,9 +148,15 @@ def get_vectorstore(backend_name: str):
135
  _news_cache: dict = {"content": None, "fetched_at": None}
136
 
137
  def fetch_morning_news() -> str:
 
 
 
 
 
138
  global _news_cache
139
  now = datetime.utcnow()
140
 
 
141
  if _news_cache["content"] is not None and _news_cache["fetched_at"]:
142
  if now - _news_cache["fetched_at"] < NEWS_CACHE_TTL:
143
  print("[MorningNews] Serving from cache")
@@ -149,13 +168,16 @@ def fetch_morning_news() -> str:
149
  headers["Authorization"] = f"token {gh_token}"
150
 
151
  try:
 
152
  dir_url = f"{GH_API_ROOT}/repos/{GH_OWNER}/{GH_REPO}/contents/{GH_NEWS_PATH}"
153
  resp = requests.get(dir_url, headers=headers, timeout=10)
154
  resp.raise_for_status()
155
  entries = resp.json()
156
 
 
157
  entries = sorted(
158
- [e for e in entries if e["type"] == "file" and e["name"].lower().endswith(NEWS_ACCEPTED_EXT)],
 
159
  key=lambda e: e["name"],
160
  reverse=True,
161
  )
@@ -181,9 +203,11 @@ def fetch_morning_news() -> str:
181
 
182
  except Exception as e:
183
  print(f"[MorningNews] Directory listing failed: {e}")
 
184
  return _news_cache.get("content") or ""
185
 
186
  # ====================== SYSTEM PROMPTS ======================
 
187
  SYSTEM_PROMPT_BASE = """You are the reference expert for the articles contained in the training of this model, \
188
  all extracted from the website robertolofaro.com, and all focused on change.
189
  # Your Mission
@@ -194,22 +218,34 @@ Do not provide general advice from outside these sources.
194
  2. Guidelines & Hints: A markdown list of specific answers/guidelines/hints found in the source material.
195
  """
196
 
 
197
  SYSTEM_PROMPT_EXTENDED = """You are the reference expert for the articles contained in the training of this model, \
198
  all extracted from the website robertolofaro.com, and all focused on change. \
199
- You have also been provided with supplementary external context (morning news).
200
  # Your Mission
201
  Provide a structured response that integrates all available information. \
202
- Clearly tag each insight with its source label:
203
  [Articles] – insight from the trained article corpus
204
  [MorningNews] – insight from the morning news briefing
205
  # Response Format
206
  1. Executive Summary: A 2-3 sentence overview answering the core query.
207
  2. Guidelines & Hints: A markdown list of tagged insights from the source material.
208
- 3. Additional Context (when MorningNews is present): brief synthesis of external findings relevant to the query.
 
209
  """
210
 
211
  # ====================== CONTEXT BUDGET HELPER ======================
 
 
 
 
212
  def _trim_to_budget(parts: list[tuple[str, str]]) -> str:
 
 
 
 
 
 
213
  totals = [(label, text) for label, text in parts if text.strip()]
214
  if not totals:
215
  return ""
@@ -224,10 +260,146 @@ def _trim_to_budget(parts: list[tuple[str, str]]) -> str:
224
  def generate_response(
225
  message, history,
226
  rag_mode, article_filter,
227
- use_morning_news,
228
  max_tokens, temperature, top_p, repeat_penalty,
229
  ):
230
  has_extra = use_morning_news
231
  system_prompt = SYSTEM_PROMPT_EXTENDED if has_extra else SYSTEM_PROMPT_BASE
232
 
233
- full_prompt = f"<|im_start|>system\n{system_prompt}<|
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  Runs on:
4
  β€’ Hugging Face Spaces (CPU-only, default)
5
  β€’ Local PC (CPU or CUDA GPU)
6
+
7
+ Environment variables
8
+ ---------------------
9
+ HF_TOKEN HuggingFace token for private model repo (required on HF Space)
10
+ LOCAL_MODE Set to "1" to force local-PC behaviour (optional; auto-detected via SPACE_ID)
11
+ LOCAL_MODEL_PATH Absolute path to the .gguf file on disk (optional; skips HF hub download)
12
+ GITHUB_TOKEN GitHub PAT for higher rate-limits (optional; works without it)
13
+ N_THREADS Override CPU thread count (optional)
14
  """
15
 
16
  import gradio as gr
 
23
  from langchain_huggingface import HuggingFaceEmbeddings
24
 
25
  # ====================== ENVIRONMENT DETECTION ======================
26
+ # HuggingFace Spaces always set SPACE_ID; absent β†’ we're running locally.
27
  IS_HF_SPACE = bool(os.environ.get("SPACE_ID"))
28
  IS_LOCAL = (not IS_HF_SPACE) or (os.environ.get("LOCAL_MODE", "0") == "1")
29
 
30
  def _detect_cuda() -> bool:
31
  """Return True only when a CUDA device is actually usable by llama-cpp."""
32
  if not IS_LOCAL:
33
+ return False # HF free tier is CPU-only
34
  try:
35
  import torch
36
  return torch.cuda.is_available()
37
  except ImportError:
38
  pass
39
+ # Fallback: check for libcuda without torch
40
  try:
41
  import ctypes
42
  ctypes.cdll.LoadLibrary("libcuda.so.1")
 
45
  return False
46
 
47
  CUDA_AVAILABLE = _detect_cuda()
48
+ # -1 β†’ offload every layer to GPU; 0 β†’ pure CPU
49
  N_GPU_LAYERS = -1 if CUDA_AVAILABLE else 0
50
+ # Use all available cores locally; HF free tier: keep at 2 to avoid OOM
51
  N_THREADS = int(os.environ.get("N_THREADS", os.cpu_count() if IS_LOCAL else 2))
52
 
53
  # ====================== CONFIG ======================
 
70
  GH_API_ROOT = "https://api.github.com"
71
  GH_RAW_ROOT = "https://raw.githubusercontent.com"
72
  NEWS_ACCEPTED_EXT = (".txt", ".md", ".json")
73
+ NEWS_MAX_CHARS_FILE = 2000 # chars kept per file
74
+ NEWS_MAX_CHARS_TOTAL = 3500 # total chars injected into prompt
75
  NEWS_CACHE_TTL = timedelta(hours=1)
76
 
77
+ # Web search
78
+ WEB_MAX_RESULTS = 5
79
+ WEB_MAX_CHARS = 2500 # total chars from web injected into prompt
80
 
81
  # ====================== LOAD METADATA ======================
82
  def load_articles_list() -> list[str]:
 
148
  _news_cache: dict = {"content": None, "fetched_at": None}
149
 
150
  def fetch_morning_news() -> str:
151
+ """
152
+ Fetch text/md/json files from the MorningNewsAgentTest directory on GitHub.
153
+ Results are cached for NEWS_CACHE_TTL to avoid hammering the API.
154
+ Works with or without a GITHUB_TOKEN (unauthenticated rate-limit: 60 req/hr).
155
+ """
156
  global _news_cache
157
  now = datetime.utcnow()
158
 
159
+ # Serve from cache if still fresh
160
  if _news_cache["content"] is not None and _news_cache["fetched_at"]:
161
  if now - _news_cache["fetched_at"] < NEWS_CACHE_TTL:
162
  print("[MorningNews] Serving from cache")
 
168
  headers["Authorization"] = f"token {gh_token}"
169
 
170
  try:
171
+ # List files in the directory
172
  dir_url = f"{GH_API_ROOT}/repos/{GH_OWNER}/{GH_REPO}/contents/{GH_NEWS_PATH}"
173
  resp = requests.get(dir_url, headers=headers, timeout=10)
174
  resp.raise_for_status()
175
  entries = resp.json()
176
 
177
+ # Sort by name descending so the most recent file (date-prefixed) comes first
178
  entries = sorted(
179
+ [e for e in entries if e["type"] == "file"
180
+ and e["name"].lower().endswith(NEWS_ACCEPTED_EXT)],
181
  key=lambda e: e["name"],
182
  reverse=True,
183
  )
 
203
 
204
  except Exception as e:
205
  print(f"[MorningNews] Directory listing failed: {e}")
206
+ # Return stale cache rather than nothing if available
207
  return _news_cache.get("content") or ""
208
 
209
  # ====================== SYSTEM PROMPTS ======================
210
+ # Base prompt – articles only
211
  SYSTEM_PROMPT_BASE = """You are the reference expert for the articles contained in the training of this model, \
212
  all extracted from the website robertolofaro.com, and all focused on change.
213
  # Your Mission
 
218
  2. Guidelines & Hints: A markdown list of specific answers/guidelines/hints found in the source material.
219
  """
220
 
221
+ # Extended prompt – when extra sources are active
222
  SYSTEM_PROMPT_EXTENDED = """You are the reference expert for the articles contained in the training of this model, \
223
  all extracted from the website robertolofaro.com, and all focused on change. \
224
+ You have also been provided with supplementary external context (morning news results).
225
  # Your Mission
226
  Provide a structured response that integrates all available information. \
227
+ Clearly tag each insight with its source label so the reader can judge its provenance:
228
  [Articles] – insight from the trained article corpus
229
  [MorningNews] – insight from the morning news briefing
230
  # Response Format
231
  1. Executive Summary: A 2-3 sentence overview answering the core query.
232
  2. Guidelines & Hints: A markdown list of tagged insights from the source material.
233
+ 3. Additional Context (when MorningNews are present): \
234
+ brief synthesis of external findings relevant to the query.
235
  """
236
 
237
  # ====================== CONTEXT BUDGET HELPER ======================
238
+ # Rough token estimate: 1 token β‰ˆ 4 chars for English text.
239
+ # n_ctx=4096 β†’ reserve ~800 for answer, ~400 for system+history β†’ ~2900 chars for context.
240
+ CONTEXT_BUDGET_CHARS = 2900
241
+
242
  def _trim_to_budget(parts: list[tuple[str, str]]) -> str:
243
+ """
244
+ parts = [(label, text), ...]
245
+ Allocates the context budget proportionally across available sources,
246
+ then returns a single assembled context string.
247
+ """
248
+ # First pass: measure totals
249
  totals = [(label, text) for label, text in parts if text.strip()]
250
  if not totals:
251
  return ""
 
260
  def generate_response(
261
  message, history,
262
  rag_mode, article_filter,
263
+ use_morning_news,
264
  max_tokens, temperature, top_p, repeat_penalty,
265
  ):
266
  has_extra = use_morning_news
267
  system_prompt = SYSTEM_PROMPT_EXTENDED if has_extra else SYSTEM_PROMPT_BASE
268
 
269
+ full_prompt = f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
270
+
271
+ # Keep the last 4 turns to limit context pressure
272
+ for msg in history[-4:]:
273
+ full_prompt += f"<|im_start|>{msg['role']}\n{msg['content']}<|im_end|>\n"
274
+
275
+ # ---- Gather context from all active sources ----
276
+ context_parts: list[tuple[str, str]] = []
277
+
278
+ # 1. RAG (vectorstore)
279
+ backend = BACKENDS.get(rag_mode)
280
+ if backend:
281
+ vs = get_vectorstore(backend)
282
+ if vs:
283
+ try:
284
+ filt = {"article_category": article_filter} if article_filter != "All categories" else None
285
+ docs = vs.similarity_search(message, k=5, filter=filt)
286
+ rag_text = "\n\n".join(
287
+ f"[Cat: {d.metadata.get('article_category','N/A')}] {d.page_content[:700]}"
288
+ for d in docs
289
+ )
290
+ context_parts.append(("ARTICLES CONTEXT", rag_text))
291
+ except Exception as e:
292
+ print(f"[RAG] similarity_search failed: {e}")
293
+
294
+ # 2. Morning News
295
+ if use_morning_news:
296
+ news = fetch_morning_news()
297
+ if news:
298
+ context_parts.append(("MORNING NEWS BRIEFING", news))
299
+
300
+ # ---- Assemble context within token budget ----
301
+ context = _trim_to_budget(context_parts)
302
+
303
+ if context:
304
+ full_prompt += f"<|im_start|>user\nContext:\n{context}\n\nQuestion: {message}<|im_end|>\n"
305
+ else:
306
+ full_prompt += f"<|im_start|>user\n{message}<|im_end|>\n"
307
+
308
+ full_prompt += "<|im_start|>assistant\n"
309
+
310
+ # ---- Inference parameters ----
311
+ max_tok = int(max_tokens) if max_tokens is not None else 900
312
+ temp = float(temperature) if temperature is not None else 0.65
313
+ tp = float(top_p) if top_p is not None else 0.9
314
+ rep_pen = float(repeat_penalty) if repeat_penalty is not None else 1.1
315
+
316
+ partial = ""
317
+ for chunk in llm(
318
+ full_prompt,
319
+ max_tokens=max_tok,
320
+ temperature=temp,
321
+ top_p=tp,
322
+ repeat_penalty=rep_pen,
323
+ stop=["<|im_end|>", "<|im_start|>"],
324
+ stream=True,
325
+ ):
326
+ partial += chunk["choices"][0]["text"]
327
+ yield partial
328
+
329
+ # ====================== RUNTIME STATUS BADGE ======================
330
+ def _build_status() -> str:
331
+ parts = []
332
+ if IS_HF_SPACE and not IS_LOCAL:
333
+ parts.append("☁️ HuggingFace Space · CPU-only")
334
+ else:
335
+ parts.append("πŸ–₯️ Local mode")
336
+ parts.append("⚑ GPU (CUDA)" if CUDA_AVAILABLE else "🐒 CPU-only")
337
+ parts.append(f"threads={N_THREADS}")
338
+ return " | ".join(parts)
339
+
340
+ STATUS_LINE = _build_status()
341
+
342
+ # ====================== GRADIO INTERFACE ======================
343
+ with gr.Blocks(title="Article Q&A model") as demo:
344
+ gr.Markdown("# sourcing 350+ articles on change")
345
+ gr.Markdown(
346
+ "Qwen3.5-4B DoRA fine-tuned on 350+ articles on change from robertolofaro.com β€” "
347
+ "experimental on CPU-only, to test embedding methods (takes a few minutes, "
348
+ "no selection for the category yet) β€” updated as of 2026-05-05"
349
+ )
350
+ gr.Markdown(f"**Runtime:** {STATUS_LINE}")
351
+ gr.Markdown(
352
+ "**NOTAM:** by querying this model you access the articles and metadata "
353
+ "available on robertolofaro.com and GitHub. "
354
+ "Answers reflect the article corpus only β€” do not treat them as advice specific to your context."
355
+ )
356
+ gr.Markdown(
357
+ "If, after getting an answer, you want something more contextualised, "
358
+ "contact a consultant (myself included)."
359
+ )
360
+
361
+ with gr.Row():
362
+ rag_mode = gr.Radio(
363
+ choices=list(BACKENDS.keys()),
364
+ value="FAISS - RAG (HNSW)",
365
+ label="Retrieval mode",
366
+ )
367
+ article_filter = gr.Dropdown(
368
+ choices=ARTICLE_LIST,
369
+ value="All categories",
370
+ label="Focus on category",
371
+ )
372
+
373
+ with gr.Row():
374
+ use_morning_news = gr.Checkbox(
375
+ value=False,
376
+ label="πŸ“° Read MorningNews",
377
+ info="Supplement with the latest Morning News briefing fetched from GitHub "
378
+ f"(robertolofaro/supportmaterial Β· {GH_NEWS_PATH}). "
379
+ "Results are cached for 1 hour.",
380
+ )
381
+
382
+ with gr.Accordion("Advanced Generation Parameters", open=False):
383
+ max_tokens = gr.Slider(256, 2048, value=900, step=64, label="Max Tokens")
384
+ temperature = gr.Slider(0.0, 1.0, value=0.65, step=0.05, label="Temperature")
385
+ top_p = gr.Slider(0.0, 1.0, value=0.9, step=0.05, label="Top-p")
386
+ repeat_penalty = gr.Slider(1.0, 2.0, value=1.1, step=0.05, label="Repeat Penalty")
387
+
388
+ gr.ChatInterface(
389
+ fn=generate_response,
390
+ additional_inputs=[
391
+ rag_mode, article_filter,
392
+ use_morning_news,
393
+ max_tokens, temperature, top_p, repeat_penalty,
394
+ ],
395
+ cache_examples=False, # prevents Gradio from running examples at startup
396
+ examples=[
397
+ ["What is the potential for Italy? /nothink"],
398
+ ["What is the potential for Turin? /nothink"],
399
+ ],
400
+ )
401
+
402
+ if __name__ == "__main__":
403
+ # Local launch: share=False keeps it on localhost only.
404
+ # Set share=True if you want a temporary public Gradio tunnel.
405
+ demo.queue(default_concurrency_limit=1).launch(share=False)