Spaces:
Running
Running
Upload app.py
Browse files
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 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 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 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 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
|
| 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 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 178 |
else:
|
| 179 |
-
logger.warning("RAG returned 0 chunks
|
| 180 |
except Exception as exc:
|
| 181 |
logger.error("RAG retrieval failed: %s", exc)
|
| 182 |
|
| 183 |
-
#
|
| 184 |
-
|
|
|
|
| 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 +=
|
|
|
|
|
|
|
| 193 |
|
| 194 |
full_prompt += "<|im_start|>assistant\n"
|
| 195 |
|
| 196 |
# Sanitise generation params
|
| 197 |
-
max_tokens_val
|
| 198 |
-
temp_val
|
| 199 |
-
top_p_val
|
| 200 |
-
rep_penalty_val
|
| 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 |
-
"
|
| 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
|
|
|
|
|
|
|
| 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
|
| 275 |
-
temperature
|
| 276 |
-
top_p
|
| 277 |
-
repeat_penalty = gr.Slider(1.0,
|
| 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,
|