Spaces:
Sleeping
Sleeping
File size: 17,136 Bytes
025be21 14e0359 025be21 ebe90cc 025be21 4d9ecfd 025be21 eb1ceb7 ebe90cc eb1ceb7 14e0359 99c3989 025be21 e6f2d55 0d70800 51c9f7f 025be21 4d9ecfd 51c9f7f 4d9ecfd 72860f1 eb1ceb7 72860f1 4d9ecfd 99c3989 025be21 99c3989 025be21 99c3989 025be21 99c3989 025be21 99c3989 025be21 def50fa 025be21 eb1ceb7 025be21 eb1ceb7 ebe90cc eb1ceb7 025be21 eb1ceb7 86b43f8 eb1ceb7 5601292 025be21 def50fa 0d70800 def50fa 0d70800 def50fa 5601292 eb1ceb7 5601292 eb1ceb7 4d9ecfd 5601292 0d70800 5601292 eb1ceb7 5601292 eb1ceb7 4d9ecfd 025be21 eb1ceb7 025be21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 | """
Stage 6 β grounded generation with a local model (DeepSeek-R1-Distill-7B via Ollama).
WHY grounding is the whole point: a raw LLM answers from memory and will confidently
invent a wrong derivation. RAG constrains it to answer ONLY from retrieved chunks, cite
which chunk each claim came from, and refuse ("I don't know") when the material doesn't
cover the question. For a probability course that's the difference between a study aid and
a hallucination machine. We also gate on the reranker score: if the best retrieved chunk
is weak, we refuse rather than generate from irrelevant context.
DeepSeek-R1 emits its reasoning inside <think>...</think> then the answer. We split them:
the answer is the deliverable; the thinking is shown optionally (it's great for studying).
"""
from __future__ import annotations
import os
import re
import json
import urllib.request
import urllib.error
from src.store import search, search_multi # imports torch/FlagEmbedding before qdrant (ordering matters)
_OLLAMA_URL = "http://localhost:11434/api/chat"
# Model registry: short name -> where/how to call it. "api" picks the wire format in _chat().
# WHY native Ollama /api/chat (not its OpenAI-compatible /v1): /v1 ignores num_ctx, and
# num_ctx=4096 is exactly what keeps the retrieval models + LLM co-resident on the 8GB GPU.
MODELS: dict[str, dict] = {
# qwen stays the default so LOCAL behavior is byte-identical when no new env vars are set.
"qwen": {"model": "qwen2.5:3b-instruct", "url": _OLLAMA_URL, "api": "ollama",
"api_key_env": None},
"glm-local": {"model": "glm4:9b", "url": _OLLAMA_URL, "api": "ollama",
"api_key_env": None},
"glm-flash": {"model": os.environ.get("ZAI_MODEL", "glm-4.7-flash"),
"url": "https://api.z.ai/api/paas/v4/chat/completions", "api": "openai",
"api_key_env": "ZAI_API_KEY"},
}
# PROBRAG_MODEL is a legacy override (README documents PROBRAG_MODEL=deepseek-r1:7b): it swaps
# the model id behind the "qwen" name rather than adding an entry, so old invocations still work.
_legacy_model = os.environ.get("PROBRAG_MODEL")
if _legacy_model:
MODELS["qwen"]["model"] = _legacy_model
# PROBRAG_MODELS = comma-separated names to ENABLE (default just qwen -> local unchanged).
ENABLED_MODELS = [
n.strip() for n in os.environ.get("PROBRAG_MODELS", "qwen").split(",")
if n.strip() in MODELS
] or ["qwen"]
DEFAULT_MODEL = os.environ.get("PROBRAG_DEFAULT_MODEL") or ENABLED_MODELS[0]
if DEFAULT_MODEL not in ENABLED_MODELS:
DEFAULT_MODEL = ENABLED_MODELS[0]
def _resolve(model: str | None) -> dict:
"""Map a requested model name to its registry entry. None -> default. Invalid or
not-enabled -> ValueError (server maps to 400)."""
name = model or DEFAULT_MODEL
if name not in ENABLED_MODELS:
raise ValueError(f"unknown model {name!r}; enabled: {ENABLED_MODELS}")
return MODELS[name]
MIN_RERANK_SCORE = 0.005 # low backstop only. The eval-derived 0.024 cleanly split the
# GOLDEN phrasings, but real-world phrasings of answerable
# questions can score lower and get wrongly refused. So we keep
# the hard threshold minimal (catch ~zero-signal retrieval) and
# let the GROUNDED PROMPT be the real guard β it refuses when the
# retrieved context doesn't actually contain the answer.
# On 8GB VRAM the embed+rerank models (~2.2GB) and the 7B (~4.7GB) must coexist on the GPU.
# A smaller context shrinks the 7B's KV cache so everything fits on-GPU (fast generation)
# instead of spilling Ollama to CPU. 4K is plenty for top_k grounded passages.
NUM_CTX = 4096
CTX_TOKEN_BUDGET = 2600 # room for system + question + answer within NUM_CTX
MAX_PARENT_TOKENS = 1100 # truncate oversized parents (chunker max ~7600)
_CHARS_PER_TOK = 4
SYSTEM = (
"You are a probability tutor. Answer the question using ONLY the numbered context "
"passages β no outside facts. Be concise and plain: the shortest answer that is still "
"correct, everyday words, no filler and no unnecessary jargon. Keep the essential terms "
"the course tests (variance, moment generating function, Bernoulli trials) but gloss each "
"in plain words the first time it appears. Textbook results often appear inside worked "
"examples and in notation; treat these as answers (g(t) = moment generating function; "
"V(X) or sigma^2 = variance; E(X) or mu = expected value; a 'first success' count is "
"geometric; a Bernoulli-trials success count is binomial). A worked Example or derivation "
"IS a valid answer: if a passage shows the steps (e.g. computes g(t), then g'(0) and "
"g''(0), to get a mean or variance), walk through those steps and state the result. "
"Extract the key formula and cite its tag, e.g. [S1]. Keep LaTeX as written ($...$, "
"$$...$$). Only if the passages are unrelated to the question, reply exactly: \"I don't "
"know based on the provided material.\""
)
# Only comparison / derive-from questions need splitting; everything else takes the
# single-query path untouched (keeps the 39 saturated single-hop cases byte-identical, and
# avoids paying an LLM call + NΓ retrieval on queries one embedding already covers).
DECOMPOSE_GATE = re.compile(
r"\bcompare\b|\bversus\b|\bvs\b|difference between|\bderive\b.*\b(from|via|using|through)\b", re.I)
DECOMPOSE_SYSTEM = (
"Split a probability question into focused search queries, ONE per line, no numbering "
"and no other text. Rules: (1) each query targets a SINGLE quantity of a SINGLE "
"distribution so it can be searched on its own; (2) always name the distribution IN FULL "
"β write 'the geometric distribution', never bare 'geometric' (which collides with the "
"geometric mean); (3) if the question asks about two quantities (e.g. mean and variance) "
"of two distributions, emit one query per (quantity, distribution) pair. Example β for "
"'compare the mean and variance of the Poisson and geometric distributions':\n"
"mean of the Poisson distribution\n"
"variance of the Poisson distribution\n"
"mean of the geometric distribution\n"
"variance of the geometric distribution\n"
"Output only the queries."
)
def decompose(question: str) -> list[str]:
"""Split a multi-part question into focused sub-queries via the local LLM, but ONLY when
the gate matches (comparison / derive-from). Returns [] otherwise, or if the model
returns nothing usable β the caller then falls back to plain single-query retrieval.
Temp 0 so the split is stable enough for the eval harness to A/B against."""
if not DECOMPOSE_GATE.search(question):
return []
try:
# Prefer the local qwen entry so sub-query splitting doesn't burn hosted-API quota.
# When qwen isn't enabled (the deployed Space has no Ollama), use the default hosted
# entry instead β a decompose call is ~100 tokens, and losing decomposition would
# silently degrade every comparison question (the 45/45 multi-hop wins).
entry = MODELS["qwen"] if "qwen" in ENABLED_MODELS else _resolve(None)
raw = _chat([{"role": "system", "content": DECOMPOSE_SYSTEM},
{"role": "user", "content": question}], entry)
except Exception:
return [] # fail open: a decompose hiccup falls back to single-query retrieval
subs = []
for line in raw.splitlines():
s = re.sub(r"^\s*(?:[-*\d.)]+\s*)+", "", line).strip() # strip bullets/numbering
if s:
subs.append(s)
return subs[:4] # up to 4: a "mean and variance of A and B" question is 4 (quantity, dist) pairs
def _retrieve_for(client, question: str, top_k: int) -> list[dict]:
"""Retrieve grounding hits: multi-query when the question decomposes, else single-query
rerank. The one entry point so answer/stream/why all get decomposition for free."""
subs = decompose(question)
if subs:
# Sub-query lanes only β the original comparison query as a lane just re-adds the
# single-topic-biased hits decomposition exists to get past, and costs a rerank pass.
return search_multi(client, subs, top_k=top_k)
return search(client, question, top_k=top_k, mode="rerank")
def _truncate(text: str, max_tokens: int) -> str:
cap = max_tokens * _CHARS_PER_TOK
if len(text) <= cap:
return text
return text[:cap].rsplit("\n", 1)[0] + "\n... [passage truncated]"
def _build_context(hits: list[dict]) -> tuple[str, list[dict]]:
"""Pack passages under a token budget so the prompt fits NUM_CTX. Oversized parents
are truncated; once the budget is hit we stop adding passages."""
blocks, sources = [], []
used = 0
for i, h in enumerate(hits, 1):
text = _truncate(h["parent_text"], MAX_PARENT_TOKENS)
toks = len(text) // _CHARS_PER_TOK
if used + toks > CTX_TOKEN_BUDGET and blocks:
break # budget exhausted; keep what we have
used += toks
tag = f"S{i}"
loc = " / ".join(x for x in (h.get("chapter"), h.get("section")) if x) or h["source"]
blocks.append(f"[{tag}] ({loc})\n{text}")
sources.append({"tag": tag, "chunk_id": h["chunk_id"], "location": loc,
"page": h.get("page"), # 1-indexed PDF page -> UI opens the viewer here
"score": h.get("rerank_score", h.get("score"))})
return "\n\n".join(blocks), sources
def _headers(entry: dict) -> dict:
h = {"Content-Type": "application/json"}
key_env = entry.get("api_key_env")
if key_env:
key = os.environ.get(key_env)
if not key:
raise RuntimeError(f"model {entry['model']!r} needs env {key_env}, which is unset")
h["Authorization"] = f"Bearer {key}"
return h
def _post(entry: dict, payload: dict):
req = urllib.request.Request(
entry["url"], data=json.dumps(payload).encode(), headers=_headers(entry))
try:
return urllib.request.urlopen(req, timeout=600)
except urllib.error.HTTPError as e:
# Surface the provider's error body (e.g. Z.ai "model not found") β without this the
# SSE stream just truncates silently and the failure is undiagnosable in production.
body = e.read()[:300].decode("utf-8", "replace")
raise RuntimeError(f"LLM API error {e.code} from {entry['model']}: {body}") from e
def _chat(messages: list[dict], entry: dict) -> str:
"""Non-streaming completion, dispatching on entry['api']."""
if entry["api"] == "openai":
payload = {"model": entry["model"], "messages": messages, "stream": False,
"temperature": 0.0}
with _post(entry, payload) as r:
return json.loads(r.read())["choices"][0]["message"]["content"]
# ollama: native NDJSON /api/chat. num_ctx keeps the models co-resident on the 8GB GPU.
payload = {"model": entry["model"], "messages": messages, "stream": False,
"options": {"num_ctx": NUM_CTX, "temperature": 0.0}}
with _post(entry, payload) as r:
return json.loads(r.read())["message"]["content"]
def _chat_stream(messages: list[dict], entry: dict):
"""Yield content deltas, dispatching on entry['api']."""
if entry["api"] == "openai":
payload = {"model": entry["model"], "messages": messages, "stream": True,
"temperature": 0.0}
with _post(entry, payload) as r:
for raw in r:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
return
# choices can be empty on trailing usage/keep-alive chunks β don't crash the stream
choices = json.loads(data).get("choices") or [{}]
delta = choices[0].get("delta", {}).get("content", "")
if delta:
yield delta
return
# ollama: NDJSON stream.
payload = {"model": entry["model"], "messages": messages, "stream": True,
"options": {"num_ctx": NUM_CTX, "temperature": 0.0}}
with _post(entry, payload) as r:
for line in r:
chunk = json.loads(line)
piece = chunk.get("message", {}).get("content", "")
if piece:
yield piece
if chunk.get("done"):
return
def _split_think(text: str) -> tuple[str, str]:
think = "\n".join(re.findall(r"<think>(.*?)</think>", text, flags=re.DOTALL)).strip()
answer = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
return answer, think
WHY_SYSTEM = (
"You are a probability tutor giving a DEEPER explanation of a previous answer. Use ONLY "
"the numbered context passages β no outside facts. The student already has the short "
"answer and now wants the reasoning: explain the intuition, walk through the derivation "
"step by step, define the symbols, and note where each step comes from. Use plain words "
"and no unnecessary jargon β gloss each technical term in plain language as you use it. "
"Same notation conventions as before (g(t) = mgf, V(X) = variance, etc.). Cite passages "
"as [S1]. Keep LaTeX as written ($...$, $$...$$). If the context doesn't support a deeper "
"explanation, say so plainly rather than inventing one."
)
ELI5_SYSTEM = (
"You are a probability tutor explaining to a total beginner. Use ONLY the numbered "
"context passages β no outside facts. Explain in the FEWEST words possible: simplest "
"everyday language, short sentences, no jargon and no filler. Use one everyday analogy "
"only if it truly helps. Keep the actual formula (in $...$) so the answer stays correct, "
"but say what it means in plain words right after. Cite the passage tag, e.g. [S1]. If "
"the context doesn't cover it, say so plainly."
)
# style -> (system prompt, user-instruction prefix). Keeps stream() one code path.
_STYLES = {
"default": (SYSTEM, "Question:"),
"deeper": (WHY_SYSTEM, "Explain in depth, with reasoning:"),
"eli5": (ELI5_SYSTEM, "Explain as simply as possible, fewest words:"),
}
def stream(client, question: str, *, top_k: int = 6, show_thinking: bool = False,
style: str = "default", model: str | None = None):
"""Streaming twin of answer()/why(). Yields event dicts: {"type": "sources", ...} first
(so the UI can render citations before generation), then {"type": "token", "text": ...}
deltas, then {"type": "done", ...} carrying the cleaned final answer β the UI replaces
the streamed text with it.
# ponytail: under PROBRAG_MODEL=deepseek-r1 the <think> block streams visibly and is
# only stripped in the final event; filter deltas live if that ever matters (default
# qwen2.5:3b never emits think tags)."""
entry = _resolve(model) # raises ValueError before retrieval on a bad name
hits = _retrieve_for(client, question, top_k)
best = hits[0].get("rerank_score", 0.0) if hits else 0.0
if not hits or best < MIN_RERANK_SCORE:
yield {"type": "done", "answer": "I don't know based on the provided material.",
"sources": [], "refused": True, "best_score": best}
return
context, sources = _build_context(hits)
yield {"type": "sources", "sources": sources, "best_score": best}
system, prefix = _STYLES.get(style, _STYLES["default"])
user = f"Context passages:\n\n{context}\n\n{prefix} {question}"
parts: list[str] = []
for piece in _chat_stream([{"role": "system", "content": system},
{"role": "user", "content": user}], entry):
parts.append(piece)
yield {"type": "token", "text": piece}
ans, think = _split_think("".join(parts))
done = {"type": "done", "answer": ans, "refused": False}
if show_thinking:
done["thinking"] = think
yield done
def answer(client, question: str, *, top_k: int = 6, show_thinking: bool = False,
model: str | None = None) -> dict:
entry = _resolve(model) # raises ValueError before retrieval on a bad name
hits = _retrieve_for(client, question, top_k)
best = hits[0].get("rerank_score", 0.0) if hits else 0.0
if not hits or best < MIN_RERANK_SCORE:
return {"answer": "I don't know based on the provided material.",
"sources": [], "refused": True, "best_score": best}
context, sources = _build_context(hits)
user = f"Context passages:\n\n{context}\n\nQuestion: {question}"
raw = _chat([{"role": "system", "content": SYSTEM},
{"role": "user", "content": user}], entry)
ans, think = _split_think(raw)
out = {"answer": ans, "sources": sources, "refused": False, "best_score": best}
if show_thinking:
out["thinking"] = think
return out
|