robertolofaro commited on
Commit
762b148
·
verified ·
1 Parent(s): 53b5031

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +165 -52
app.py CHANGED
@@ -3,8 +3,13 @@ from llama_cpp import Llama
3
  from huggingface_hub import hf_hub_download
4
  import os
5
  import pickle
 
6
  from langchain_huggingface import HuggingFaceEmbeddings
7
 
 
 
 
 
8
  # ====================== CONFIG ======================
9
  repo_id = "robertolofaro/articles-model"
10
 
@@ -13,28 +18,61 @@ BACKENDS = {
13
  "Qdrant - RAG": "Qdrant"
14
  }
15
 
16
- FAISS_PATH = "faiss_index_hnsw"
17
- QDRANT_PATH = "qdrant_db"
 
 
 
18
  QDRANT_COLLECTION = "articles"
19
 
20
- # ====================== LOAD METADATA FOR ARTICLE LIST ======================
21
- def load_articles_list():
 
22
  try:
23
- with open("metadata.pkl", "rb") as f:
24
  df = pickle.load(f)
25
- articles = sorted(df['article_title'].unique().tolist())
26
- return ["All articles"] + articles
27
- except:
28
- return ["All articles"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- ARTICLE_LIST = load_articles_list()
 
31
 
32
  # ====================== LOAD LLM ======================
33
  model_path = hf_hub_download(
34
  repo_id=repo_id,
35
  filename="articles-Q4_K_M.gguf",
36
  repo_type="model",
37
- token=os.environ.get("HF_TOKEN")
38
  )
39
 
40
  llm = Llama(
@@ -47,14 +85,17 @@ llm = Llama(
47
  )
48
 
49
  # ====================== RAG CACHE ======================
50
- vectorstores = {}
 
51
 
52
  def get_vectorstore(backend_name: str):
53
  if backend_name in vectorstores:
54
  return vectorstores[backend_name]
55
  try:
56
- embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5", encode_kwargs={'normalize_embeddings': True})
57
-
 
 
58
  if backend_name == "FAISS":
59
  from langchain_community.vectorstores import FAISS
60
  vs = FAISS.load_local(FAISS_PATH, embeddings, allow_dangerous_deserialization=True)
@@ -64,31 +105,54 @@ def get_vectorstore(backend_name: str):
64
  else:
65
  from langchain_community.vectorstores import FAISS
66
  vs = FAISS.load_local(FAISS_PATH, embeddings, allow_dangerous_deserialization=True)
67
-
68
  vectorstores[backend_name] = vs
 
69
  return vs
70
- except:
 
71
  return None
72
 
73
 
74
  # ====================== SYSTEM PROMPT ======================
75
- SYSTEM_PROMPT = """You are the reference expert for the articles contained in the training of this model, all extracted from the website robertolofaro.com, and all focused on change.
76
- #Your Mission:
77
- When a user asks a question, your goal is to provide a structured response based ONLY on the articles provided in your training. Do not provide general advice from outside these sources.
78
- Do not provide article titles or article IDs, provide only the concepts that articles express.
79
- # Response Format:
 
 
 
 
 
 
 
 
 
 
 
 
80
  1. Executive Summary: A 2-3 sentence overview answering the core query.
81
- 2. Guidelines & Hints: A markdown list of specific "answers/guidelines/hints" found in the source material.
82
- """
83
 
84
 
85
  # ====================== GENERATION FUNCTION ======================
86
- def generate_response(message, history, rag_mode, article_filter, max_tokens, temperature, top_p, repeat_penalty):
 
 
 
 
 
 
 
 
 
87
  full_prompt = f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
88
 
89
  for msg in history[-4:]:
90
  full_prompt += f"<|im_start|>{msg['role']}\n{msg['content']}<|im_end|>\n"
91
 
 
92
  backend = BACKENDS.get(rag_mode)
93
  context = ""
94
 
@@ -96,26 +160,44 @@ def generate_response(message, history, rag_mode, article_filter, max_tokens, te
96
  vs = get_vectorstore(backend)
97
  if vs:
98
  try:
99
- filter_dict = {"article_title": article_filter} if article_filter != "All articles" else None
100
- docs = vs.similarity_search(message, k=5, filter=filter_dict)
101
- context = "\n\n".join([
102
- f"[Article: {doc.metadata.get('article_title', 'N/A')}] {doc.page_content[:700]}"
103
- for doc in docs
104
- ])
105
- except:
106
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  if context:
109
- full_prompt += f"<|im_start|>user\nContext:\n{context}\n\nQuestion: {message}<|im_end|>\n"
 
 
 
110
  else:
111
- full_prompt += f"<|im_start|>user\n{message}<|im_end|>\n"
112
 
113
  full_prompt += "<|im_start|>assistant\n"
114
 
115
- max_tokens_val = int(max_tokens) if max_tokens is not None else 900
116
- temp_val = float(temperature) if temperature is not None else 0.65
117
- top_p_val = float(top_p) if top_p is not None else 0.9
118
- rep_penalty_val = float(repeat_penalty) if repeat_penalty is not None else 1.1
 
119
 
120
  partial_text = ""
121
  for chunk in llm(
@@ -127,10 +209,11 @@ def generate_response(message, history, rag_mode, article_filter, max_tokens, te
127
  stop=["<|im_end|>", "<|im_start|>"],
128
  stream=True,
129
  ):
130
- token = chunk['choices'][0]['text']
131
  partial_text += token
132
  yield partial_text
133
 
 
134
  # ====================== GRADIO INTERFACE ======================
135
  with gr.Blocks(title="Article Q&A model") as demo:
136
  gr.Markdown("# sourcing 350+ articles on change")
@@ -142,7 +225,8 @@ with gr.Blocks(title="Article Q&A model") as demo:
142
  gr.Markdown(
143
  "**NOTAM:** by querying this model you access the articles and metadata "
144
  "available on robertolofaro.com and GitHub. "
145
- "Answers reflect the article corpus only — do not treat them as advice- just expression of a position contained within the articles."
 
146
  )
147
  gr.Markdown(
148
  "If, after getting an answer, you want something tailored to your context, "
@@ -153,27 +237,56 @@ with gr.Blocks(title="Article Q&A model") as demo:
153
  rag_mode = gr.Radio(
154
  choices=list(BACKENDS.keys()),
155
  value="FAISS - RAG (HNSW)",
156
- label="Mode"
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  )
158
  article_filter = gr.Dropdown(
159
- choices=ARTICLE_LIST,
160
- value="All articles",
161
- label="Focus on article"
 
162
  )
163
 
 
 
 
 
 
 
 
 
 
 
 
164
  with gr.Accordion("Advanced Generation Parameters", open=False):
165
- max_tokens = gr.Slider(256, 2048, value=900, step=64, label="Max Tokens")
166
- temperature = gr.Slider(0.0, 1.0, value=0.65, step=0.05, label="Temperature")
167
- top_p = gr.Slider(0.0, 1.0, value=0.9, step=0.05, label="Top-p")
168
- repeat_penalty = gr.Slider(1.0, 2.0, value=1.1, step=0.05, label="Repeat Penalty")
169
 
170
  gr.ChatInterface(
171
  fn=generate_response,
172
- additional_inputs=[rag_mode, article_filter, max_tokens, temperature, top_p, repeat_penalty],
173
- cache_examples=False, # <--- Stops Gradio from executing them at startup
 
 
 
 
174
  examples=[
175
- ["What is the potential for Italy? /nothink"],
176
- ["What is the potential for Turin? /nothink"]
177
  ],
178
  )
179
 
 
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 ======================
10
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")
11
+ logger = logging.getLogger(__name__)
12
+
13
  # ====================== CONFIG ======================
14
  repo_id = "robertolofaro/articles-model"
15
 
 
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_index_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."""
31
  try:
32
+ with open(METADATA_PATH, "rb") as f:
33
  df = pickle.load(f)
34
+ logger.info("metadata.pkl loaded — %d rows, columns: %s", len(df), df.columns.tolist())
35
+ return df
36
+ except FileNotFoundError:
37
+ logger.error("metadata.pkl not found at %s", METADATA_PATH)
38
+ except Exception as exc:
39
+ logger.error("Failed to load metadata.pkl: %s", exc)
40
+ return None
41
+
42
+ _METADATA_DF = _load_metadata()
43
+
44
+
45
+ def load_category_list():
46
+ """Return ['All categories'] + sorted unique article_category values."""
47
+ if _METADATA_DF is not None and "article_category" in _METADATA_DF.columns:
48
+ cats = sorted(_METADATA_DF["article_category"].dropna().unique().tolist())
49
+ logger.info("Found %d categories", len(cats))
50
+ return ["All categories"] + cats
51
+ logger.warning("article_category column not found — showing only 'All categories'")
52
+ return ["All categories"]
53
+
54
+
55
+ def load_articles_for_category(category: str):
56
+ """Return ['All articles in category'] + sorted titles for the given category."""
57
+ default = ["All articles in category"]
58
+ if _METADATA_DF is None or "article_title" not in _METADATA_DF.columns:
59
+ return default
60
+ if category in ("All categories", None, ""):
61
+ titles = sorted(_METADATA_DF["article_title"].dropna().unique().tolist())
62
+ else:
63
+ mask = _METADATA_DF["article_category"] == category
64
+ titles = sorted(_METADATA_DF.loc[mask, "article_title"].dropna().unique().tolist())
65
+ return default + titles
66
 
67
+
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(
 
85
  )
86
 
87
  # ====================== RAG CACHE ======================
88
+ vectorstores: dict = {}
89
+
90
 
91
  def get_vectorstore(backend_name: str):
92
  if backend_name in vectorstores:
93
  return vectorstores[backend_name]
94
  try:
95
+ embeddings = HuggingFaceEmbeddings(
96
+ model_name="BAAI/bge-small-en-v1.5",
97
+ encode_kwargs={"normalize_embeddings": True},
98
+ )
99
  if backend_name == "FAISS":
100
  from langchain_community.vectorstores import FAISS
101
  vs = FAISS.load_local(FAISS_PATH, embeddings, allow_dangerous_deserialization=True)
 
105
  else:
106
  from langchain_community.vectorstores import FAISS
107
  vs = FAISS.load_local(FAISS_PATH, embeddings, allow_dangerous_deserialization=True)
 
108
  vectorstores[backend_name] = vs
109
+ logger.info("Vector store '%s' loaded successfully", backend_name)
110
  return vs
111
+ except Exception as exc:
112
+ logger.error("Failed to load vector store '%s': %s", backend_name, exc)
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
+
122
+ IMPORTANT: Relevant article excerpts retrieved via semantic search will be injected \
123
+ directly in the user message under the heading "Context:". You MUST use those excerpts \
124
+ as the primary source for your answer. Do not speculate about whether you have access \
125
+ to articles — the context IS provided inline when available.
126
+
127
+ # Your Mission
128
+ When a user asks a question, provide a structured response based ONLY on the article \
129
+ content provided in the Context section. Do not draw on general knowledge outside those \
130
+ sources. Do not provide article titles or article IDs — provide only the concepts the \
131
+ articles express.
132
+
133
+ # Response Format
134
  1. Executive Summary: A 2-3 sentence overview answering the core query.
135
+ 2. Guidelines & Hints: A markdown list of specific answers/guidelines/hints found in \
136
+ the source material."""
137
 
138
 
139
  # ====================== GENERATION FUNCTION ======================
140
+ def generate_response(
141
+ message, history,
142
+ rag_mode, category_filter, article_filter,
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
 
155
+ # --- RAG retrieval ---
156
  backend = BACKENDS.get(rag_mode)
157
  context = ""
158
 
 
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 += (
188
+ f"<|im_start|>user\nContext:\n{context}\n\n"
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(
 
209
  stop=["<|im_end|>", "<|im_start|>"],
210
  stream=True,
211
  ):
212
+ token = chunk["choices"][0]["text"]
213
  partial_text += token
214
  yield partial_text
215
 
216
+
217
  # ====================== GRADIO INTERFACE ======================
218
  with gr.Blocks(title="Article Q&A model") as demo:
219
  gr.Markdown("# sourcing 350+ articles on change")
 
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 contained within the articles."
230
  )
231
  gr.Markdown(
232
  "If, after getting an answer, you want something tailored to your context, "
 
237
  rag_mode = gr.Radio(
238
  choices=list(BACKENDS.keys()),
239
  value="FAISS - RAG (HNSW)",
240
+ label="Retrieval backend",
241
+ )
242
+ suppress_thinking = gr.Checkbox(
243
+ value=True,
244
+ label="Suppress model thinking (/nothink)",
245
+ info="Uncheck to see the model's reasoning chain",
246
+ )
247
+
248
+ with gr.Row():
249
+ category_filter = gr.Dropdown(
250
+ choices=CATEGORY_LIST,
251
+ value="All categories",
252
+ label="Filter by category",
253
+ info=f"{len(CATEGORY_LIST) - 1} categories available",
254
  )
255
  article_filter = gr.Dropdown(
256
+ choices=["All articles in category"],
257
+ value="All articles in category",
258
+ label="Narrow to specific article (optional)",
259
+ info="Select a category first to populate this list",
260
  )
261
 
262
+ # Dynamically populate the article dropdown when category changes
263
+ def update_article_dropdown(category):
264
+ articles = load_articles_for_category(category)
265
+ return gr.Dropdown(choices=articles, value=articles[0])
266
+
267
+ category_filter.change(
268
+ fn=update_article_dropdown,
269
+ inputs=category_filter,
270
+ outputs=article_filter,
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,
281
+ additional_inputs=[
282
+ rag_mode, category_filter, article_filter,
283
+ max_tokens, temperature, top_p, repeat_penalty,
284
+ suppress_thinking,
285
+ ],
286
+ cache_examples=False,
287
  examples=[
288
+ ["What is the potential for Italy?"],
289
+ ["What is the potential for Turin?"],
290
  ],
291
  )
292