robertolofaro commited on
Commit
196a72d
Β·
verified Β·
1 Parent(s): 9d19ad3

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +158 -52
app.py CHANGED
@@ -1,9 +1,11 @@
1
- import gradio as gr
2
- from llama_cpp import Llama
3
- from huggingface_hub import hf_hub_download
4
  import os
5
  import pickle
6
  import logging
 
 
 
 
 
7
  from langchain_huggingface import HuggingFaceEmbeddings
8
 
9
  # ====================== LOGGING ======================
@@ -15,16 +17,39 @@ repo_id = "robertolofaro/articles-model"
15
 
16
  BACKENDS = {
17
  "FAISS - RAG (HNSW)": "FAISS",
18
- "Qdrant - RAG": "Qdrant"
19
  }
20
 
21
- # Resolve paths relative to this file so they work in HF Spaces
22
- _HERE = os.path.dirname(os.path.abspath(__file__))
23
- METADATA_PATH = os.path.join(_HERE, "metadata.pkl")
24
- FAISS_PATH = os.path.join(_HERE, "faiss_hnsw")
25
- QDRANT_PATH = os.path.join(_HERE, "qdrant_db")
26
  QDRANT_COLLECTION = "articles"
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  # ====================== LOAD METADATA ======================
29
  def _load_metadata():
30
  """Load the DataFrame from metadata.pkl; return None on any failure."""
@@ -68,21 +93,39 @@ def load_articles_for_category(category: str):
68
  CATEGORY_LIST = load_category_list()
69
 
70
  # ====================== LOAD LLM ======================
71
- model_path = hf_hub_download(
72
- repo_id=repo_id,
73
- filename="articles-Q4_K_M.gguf",
74
- repo_type="model",
75
- token=os.environ.get("HF_TOKEN"),
76
- )
77
-
78
- llm = Llama(
79
- model_path=model_path,
80
- n_ctx=8192,
81
- n_threads=2,
82
- n_batch=512,
83
- n_ubatch=512,
84
- verbose=False,
85
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  # ====================== RAG CACHE ======================
88
  vectorstores: dict = {}
@@ -113,9 +156,68 @@ def get_vectorstore(backend_name: str):
113
  return None
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  # ====================== SYSTEM PROMPT ======================
117
- # The explicit declaration that context is injected inline prevents the model from
118
- # reasoning "I have no access to the vector store" during its <think> block.
119
  SYSTEM_PROMPT = """You are the reference expert for the articles contained in the training \
120
  of this model, all extracted from the website robertolofaro.com, and all focused on change.
121
 
@@ -143,12 +245,11 @@ def generate_response(
143
  max_tokens, temperature, top_p, repeat_penalty,
144
  suppress_thinking,
145
  ):
146
- # Strip /nothink from the user-visible message if they typed it
147
  clean_message = message.replace("/nothink", "").strip()
148
 
149
- # Build prompt
150
  full_prompt = f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
151
-
152
  for msg in history[-4:]:
153
  full_prompt += f"<|im_start|>{msg['role']}\n{msg['content']}<|im_end|>\n"
154
 
@@ -160,28 +261,29 @@ def generate_response(
160
  vs = get_vectorstore(backend)
161
  if vs:
162
  try:
163
- # Build metadata filter: specific article takes priority over category
164
- filter_dict = None
165
- if article_filter and article_filter != "All articles in category":
166
- filter_dict = {"article_title": article_filter}
167
- elif category_filter and category_filter != "All categories":
168
- filter_dict = {"article_category": category_filter}
169
-
170
- docs = vs.similarity_search(clean_message, k=5, filter=filter_dict)
171
  if docs:
172
  context = "\n\n".join(
173
  f"[Article: {doc.metadata.get('article_title', 'N/A')}] "
174
  f"{doc.page_content[:700]}"
175
  for doc in docs
176
  )
177
- logger.info("RAG retrieved %d chunks (filter=%s)", len(docs), filter_dict)
 
 
 
178
  else:
179
- logger.warning("RAG returned 0 chunks for filter=%s", filter_dict)
180
  except Exception as exc:
181
  logger.error("RAG retrieval failed: %s", exc)
182
 
183
- # Append /nothink to suppress Qwen3 thinking if requested
184
- nothink_suffix = " /nothink" if suppress_thinking else ""
 
185
 
186
  if context:
187
  full_prompt += (
@@ -189,15 +291,17 @@ def generate_response(
189
  f"Question: {clean_message}{nothink_suffix}<|im_end|>\n"
190
  )
191
  else:
192
- full_prompt += f"<|im_start|>user\n{clean_message}{nothink_suffix}<|im_end|>\n"
 
 
193
 
194
  full_prompt += "<|im_start|>assistant\n"
195
 
196
  # Sanitise generation params
197
- max_tokens_val = int(max_tokens) if max_tokens is not None else 900
198
- temp_val = float(temperature) if temperature is not None else 0.65
199
- top_p_val = float(top_p) if top_p is not None else 0.9
200
- rep_penalty_val = float(repeat_penalty) if repeat_penalty is not None else 1.1
201
 
202
  partial_text = ""
203
  for chunk in llm(
@@ -220,13 +324,15 @@ with gr.Blocks(title="Article Q&A model") as demo:
220
  gr.Markdown(
221
  "Qwen3.5-4B DoRA fine-tuned on 350+ articles on change from robertolofaro.com β€” "
222
  "experimental demo on CPU-only, to test embedding methods (takes a few minutes, "
223
- "no selection for the category yet) β€” updated as of 2026-05-05"
224
  )
225
  gr.Markdown(
226
  "**NOTAM:** by querying this model you access the articles and metadata "
227
  "available on robertolofaro.com and GitHub. "
228
  "Answers reflect the article corpus only β€” do not treat them as advice, "
229
- "just expression of a position derived from material contained within the articles- if you want to read actual positions expressed within articles, you can read the articles (see the model repository for all the links the the available options)."
 
 
230
  )
231
  gr.Markdown(
232
  "If, after getting an answer, you want something tailored to your context, "
@@ -271,10 +377,10 @@ with gr.Blocks(title="Article Q&A model") as demo:
271
  )
272
 
273
  with gr.Accordion("Advanced Generation Parameters", open=False):
274
- max_tokens = gr.Slider(256, 2048, value=900, step=64, label="Max Tokens")
275
- temperature = gr.Slider(0.0, 1.0, value=0.65, step=0.05, label="Temperature")
276
- top_p = gr.Slider(0.0, 1.0, value=0.9, step=0.05, label="Top-p")
277
- repeat_penalty = gr.Slider(1.0, 2.0, value=1.1, step=0.05, label="Repeat Penalty")
278
 
279
  gr.ChatInterface(
280
  fn=generate_response,
 
 
 
 
1
  import os
2
  import pickle
3
  import logging
4
+ import platform
5
+
6
+ import gradio as gr
7
+ from llama_cpp import Llama
8
+ from huggingface_hub import hf_hub_download
9
  from langchain_huggingface import HuggingFaceEmbeddings
10
 
11
  # ====================== LOGGING ======================
 
17
 
18
  BACKENDS = {
19
  "FAISS - RAG (HNSW)": "FAISS",
20
+ "Qdrant - RAG": "Qdrant",
21
  }
22
 
23
+ _HERE = os.path.dirname(os.path.abspath(__file__))
24
+ METADATA_PATH = os.path.join(_HERE, "metadata.pkl")
25
+ FAISS_PATH = os.path.join(_HERE, "faiss_hnsw")
26
+ QDRANT_PATH = os.path.join(_HERE, "qdrant_db")
 
27
  QDRANT_COLLECTION = "articles"
28
 
29
+ # ====================== GPU / HARDWARE DETECTION ======================
30
+ # Override everything with N_GPU_LAYERS env var when you need fine control.
31
+ # Otherwise: CUDA β†’ all layers on GPU (-1); Apple Silicon β†’ Metal (-1); else CPU (0).
32
+ def _detect_gpu_layers() -> int:
33
+ override = os.environ.get("N_GPU_LAYERS")
34
+ if override is not None:
35
+ val = int(override)
36
+ logger.info("N_GPU_LAYERS override: %d", val)
37
+ return val
38
+ try:
39
+ import torch
40
+ if torch.cuda.is_available():
41
+ logger.info("CUDA detected β€” offloading all layers to GPU")
42
+ return -1
43
+ except ImportError:
44
+ pass
45
+ if platform.system() == "Darwin" and platform.machine() == "arm64":
46
+ logger.info("Apple Silicon / Metal detected β€” offloading all layers to GPU")
47
+ return -1
48
+ logger.info("No GPU detected β€” running on CPU only")
49
+ return 0
50
+
51
+ N_GPU_LAYERS = _detect_gpu_layers()
52
+
53
  # ====================== LOAD METADATA ======================
54
  def _load_metadata():
55
  """Load the DataFrame from metadata.pkl; return None on any failure."""
 
93
  CATEGORY_LIST = load_category_list()
94
 
95
  # ====================== LOAD LLM ======================
96
+ # LOCAL_MODEL_PATH env var lets you point to a local GGUF and skip the HF download.
97
+ # N_THREADS env var overrides thread count (default: 4 on CPU, 2 on GPU).
98
+ def _load_llm() -> Llama:
99
+ local_model = os.environ.get("LOCAL_MODEL_PATH")
100
+ if local_model and os.path.isfile(local_model):
101
+ model_path = local_model
102
+ logger.info("Using local model at %s", model_path)
103
+ else:
104
+ if local_model:
105
+ logger.warning("LOCAL_MODEL_PATH set but file not found (%s) β€” downloading from HF", local_model)
106
+ logger.info("Downloading model from HF hub (%s)…", repo_id)
107
+ model_path = hf_hub_download(
108
+ repo_id=repo_id,
109
+ filename="articles-Q4_K_M.gguf",
110
+ repo_type="model",
111
+ token=os.environ.get("HF_TOKEN"),
112
+ )
113
+
114
+ default_threads = 2 if N_GPU_LAYERS != 0 else 4
115
+ n_threads = int(os.environ.get("N_THREADS", default_threads))
116
+ logger.info("Llama init: n_gpu_layers=%d, n_threads=%d", N_GPU_LAYERS, n_threads)
117
+
118
+ return Llama(
119
+ model_path=model_path,
120
+ n_ctx=8192,
121
+ n_threads=n_threads,
122
+ n_batch=512,
123
+ n_ubatch=512,
124
+ n_gpu_layers=N_GPU_LAYERS,
125
+ verbose=False,
126
+ )
127
+
128
+ llm = _load_llm()
129
 
130
  # ====================== RAG CACHE ======================
131
  vectorstores: dict = {}
 
156
  return None
157
 
158
 
159
+ def _rag_search(vs, query: str, k: int, article_filter: str, category_filter: str):
160
+ """
161
+ Similarity search with optional metadata filtering.
162
+
163
+ FAISS does not support dict-based server-side metadata filtering reliably
164
+ across langchain versions: it either ignores the filter silently or raises.
165
+ We therefore fetch a generous candidate pool and post-filter in Python.
166
+
167
+ Qdrant supports native dict filtering, so we pass it directly.
168
+ """
169
+ want_title = None if article_filter in (None, "", "All articles in category") else article_filter
170
+ want_category = None if category_filter in (None, "", "All categories") else category_filter
171
+
172
+ backend_type = type(vs).__name__ # "FAISS" or "Qdrant"
173
+
174
+ if backend_type == "FAISS":
175
+ # Fetch a large pool, then filter in Python.
176
+ pool_size = min(k * 10, 80)
177
+ pool = vs.similarity_search(query, k=pool_size)
178
+
179
+ filtered = []
180
+ for doc in pool:
181
+ meta = doc.metadata
182
+ if want_title and meta.get("article_title") != want_title:
183
+ continue
184
+ if want_category and meta.get("article_category") != want_category:
185
+ continue
186
+ filtered.append(doc)
187
+ if len(filtered) >= k:
188
+ break
189
+
190
+ if not filtered and (want_title or want_category):
191
+ # Nothing matched the filter β€” warn and fall back to unfiltered results.
192
+ logger.warning(
193
+ "FAISS post-filter (title=%r, cat=%r) matched 0 docs β€” "
194
+ "returning unfiltered top-%d",
195
+ want_title, want_category, k,
196
+ )
197
+ return pool[:k]
198
+
199
+ logger.info(
200
+ "FAISS post-filter (title=%r, cat=%r) β†’ %d/%d docs kept",
201
+ want_title, want_category, len(filtered), len(pool),
202
+ )
203
+ return filtered
204
+
205
+ else:
206
+ # Qdrant: use its native metadata filter dict.
207
+ filter_dict = None
208
+ if want_title:
209
+ filter_dict = {"article_title": want_title}
210
+ elif want_category:
211
+ filter_dict = {"article_category": want_category}
212
+
213
+ docs = vs.similarity_search(query, k=k, filter=filter_dict)
214
+ logger.info(
215
+ "Qdrant search (filter=%r) β†’ %d docs", filter_dict, len(docs)
216
+ )
217
+ return docs
218
+
219
+
220
  # ====================== SYSTEM PROMPT ======================
 
 
221
  SYSTEM_PROMPT = """You are the reference expert for the articles contained in the training \
222
  of this model, all extracted from the website robertolofaro.com, and all focused on change.
223
 
 
245
  max_tokens, temperature, top_p, repeat_penalty,
246
  suppress_thinking,
247
  ):
248
+ # Strip any /nothink the user may have typed manually
249
  clean_message = message.replace("/nothink", "").strip()
250
 
251
+ # Build prompt with last 4 history turns for context window economy
252
  full_prompt = f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
 
253
  for msg in history[-4:]:
254
  full_prompt += f"<|im_start|>{msg['role']}\n{msg['content']}<|im_end|>\n"
255
 
 
261
  vs = get_vectorstore(backend)
262
  if vs:
263
  try:
264
+ docs = _rag_search(
265
+ vs, clean_message, k=5,
266
+ article_filter=article_filter,
267
+ category_filter=category_filter,
268
+ )
 
 
 
269
  if docs:
270
  context = "\n\n".join(
271
  f"[Article: {doc.metadata.get('article_title', 'N/A')}] "
272
  f"{doc.page_content[:700]}"
273
  for doc in docs
274
  )
275
+ logger.info(
276
+ "RAG: %d chunks injected (article=%r, cat=%r)",
277
+ len(docs), article_filter, category_filter,
278
+ )
279
  else:
280
+ logger.warning("RAG returned 0 chunks β€” answering without context")
281
  except Exception as exc:
282
  logger.error("RAG retrieval failed: %s", exc)
283
 
284
+ # Qwen3 /nothink MUST appear on its own line at the very end of the user turn.
285
+ # A leading space (e.g. " /nothink") is NOT recognised by the tokeniser.
286
+ nothink_suffix = "\n/nothink" if suppress_thinking else ""
287
 
288
  if context:
289
  full_prompt += (
 
291
  f"Question: {clean_message}{nothink_suffix}<|im_end|>\n"
292
  )
293
  else:
294
+ full_prompt += (
295
+ f"<|im_start|>user\n{clean_message}{nothink_suffix}<|im_end|>\n"
296
+ )
297
 
298
  full_prompt += "<|im_start|>assistant\n"
299
 
300
  # Sanitise generation params
301
+ max_tokens_val = int(max_tokens) if max_tokens is not None else 900
302
+ temp_val = float(temperature) if temperature is not None else 0.65
303
+ top_p_val = float(top_p) if top_p is not None else 0.9
304
+ rep_penalty_val = float(repeat_penalty) if repeat_penalty is not None else 1.1
305
 
306
  partial_text = ""
307
  for chunk in llm(
 
324
  gr.Markdown(
325
  "Qwen3.5-4B DoRA fine-tuned on 350+ articles on change from robertolofaro.com β€” "
326
  "experimental demo on CPU-only, to test embedding methods (takes a few minutes, "
327
+ "you can restrict by category, and then a specific article) β€” updated as of 2026-05-05"
328
  )
329
  gr.Markdown(
330
  "**NOTAM:** by querying this model you access the articles and metadata "
331
  "available on robertolofaro.com and GitHub. "
332
  "Answers reflect the article corpus only β€” do not treat them as advice, "
333
+ "just expression of a position derived from material contained within the articles. "
334
+ "If you want to read actual positions expressed within articles, you can read the articles "
335
+ "(see the model repository for all links to the available options)."
336
  )
337
  gr.Markdown(
338
  "If, after getting an answer, you want something tailored to your context, "
 
377
  )
378
 
379
  with gr.Accordion("Advanced Generation Parameters", open=False):
380
+ max_tokens = gr.Slider(256, 2048, value=900, step=64, label="Max Tokens")
381
+ temperature = gr.Slider(0.0, 1.0, value=0.65, step=0.05, label="Temperature")
382
+ top_p = gr.Slider(0.0, 1.0, value=0.9, step=0.05, label="Top-p")
383
+ repeat_penalty = gr.Slider(1.0, 2.0, value=1.1, step=0.05, label="Repeat Penalty")
384
 
385
  gr.ChatInterface(
386
  fn=generate_response,