Spaces:
Running on Zero
Running on Zero
File size: 7,611 Bytes
e1ac23c | 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 | """
RoBacTutor ask-mode -- HF Spaces (ZeroGPU) deployment.
ZeroGPU Spaces only support the Gradio SDK, not plain FastAPI routes
(confirmed against current HF docs/forums, not assumed) -- so /ask is
wrapped as a Gradio function decorated with @spaces.GPU instead of a
FastAPI route. The frontend calls it via Gradio's HTTP API
(POST /call/ask, then GET /call/ask/<event_id> for the SSE result), not a
plain fetch -- see frontend/src/api.js.
This is a self-contained deployment: it does NOT import anything from
backend/app/, since this file lives in its own HF Spaces git repo,
separate from the main project repo. Retrieval logic is duplicated from
backend/app/rag_common.py on purpose -- see that file's docstring for why
each deployable service keeps its own copy instead of sharing imports
across two different git remotes.
Before pushing to your Space, copy these in next to this file:
artifacts/lora_adapter/ <- same contents as backend/artifacts/lora_adapter/
artifacts/rag/ <- same contents as backend/artifacts/rag/
See docs/deployment.md for the exact steps.
/demo/grade-comparison is NOT included here -- it stays local-dev only,
per the deployment plan (never deployed publicly, on this Space or
anywhere else).
"""
import json
import re
from pathlib import Path
from typing import Optional
import faiss
import gradio as gr
import spaces
import torch
from sentence_transformers import SentenceTransformer
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import PeftModel
ARTIFACTS_DIR = Path(__file__).resolve().parent / "artifacts"
BASE_MODEL_NAME = "OpenLLM-Ro/RoMistral-7B-Instruct"
ADAPTER_PATH = str(ARTIFACTS_DIR / "lora_adapter")
RAG_INDEX_PATH = str(ARTIFACTS_DIR / "rag" / "robactutor_faiss.index")
RAG_CHUNKS_PATH = str(ARTIFACTS_DIR / "rag" / "robactutor_chunks.json")
RAG_EMBEDDER_NAME = "all-MiniLM-L6-v2"
SUBJECTS = ["matematica", "limba_romana", "limba_engleza", "istorie"]
SYSTEM_PROMPT = (
"Esti un tutor pentru Bacalaureatul din Republica Moldova. "
"Raspunde folosind DOAR informatia din materialul de referinta de mai jos. "
"Daca materialul nu contine raspunsul, spune ca nu ai suficiente informatii."
)
# Ask mode: validated across all 4 subjects via ask_mode_colab.ipynb, adapter
# disabled. Adapter-enabled generation failed all 4 subjects differently
# (barem-format intrusion, fabricated citation + wrong historical date,
# off-topic non-sequitur, degenerate repetition loop). See dissertation Ch5.
# Do not set this True without new evidence.
ASK_MODE_USE_ADAPTER = False
# ---------------------------------------------------------------------------
# RAG retrieval (same logic as backend/app/rag_common.py)
# ---------------------------------------------------------------------------
MIN_READABILITY = 0.25
_word_re = re.compile(r"^[A-Za-zĂÂÎȘȚăâîșț]{2,}$")
def readability_score(text: str) -> float:
words = text.split()
if not words:
return 0.0
real_words = sum(1 for w in words if _word_re.match(w))
return real_words / len(words)
print("[hf_space] Loading RAG index + embedder...")
rag_index = faiss.read_index(RAG_INDEX_PATH)
with open(RAG_CHUNKS_PATH, encoding="utf-8") as f:
chunk_metadata = json.load(f)
rag_embedder = SentenceTransformer(RAG_EMBEDDER_NAME)
def retrieve(query: str, subject: Optional[str] = None,
source_type: Optional[str] = None, top_k: int = 3) -> list[dict]:
query_embedding = rag_embedder.encode(
[query], convert_to_numpy=True, normalize_embeddings=True
).astype("float32")
scores, indices = rag_index.search(query_embedding, top_k * 8)
results = []
for score, idx in zip(scores[0], indices[0]):
if idx < 0:
continue
meta = chunk_metadata[idx]
if subject and meta["subject"] != subject:
continue
if source_type and meta.get("source_type") != source_type:
continue
if readability_score(meta["text"]) < MIN_READABILITY:
continue
results.append({
"text": meta["text"],
"filename": meta.get("filename", "necunoscut"),
"score": float(score),
})
if len(results) >= top_k:
break
return results
def get_reference_material(instruction: str, subject: str, top_k: int = 3) -> list[dict]:
retrieved = retrieve(instruction, subject=subject, source_type="manual", top_k=top_k)
if not retrieved:
retrieved = retrieve(instruction, subject=subject, top_k=top_k)
return retrieved
# ---------------------------------------------------------------------------
# Generation model -- loaded lazily on first call, inside the @spaces.GPU
# function (ZeroGPU only attaches a physical GPU for the duration of a
# decorated call, so CUDA/4-bit loading can't happen at plain import time).
# ---------------------------------------------------------------------------
_gen_model = None
_gen_tokenizer = None
def _get_generation_model():
global _gen_model, _gen_tokenizer
if _gen_model is None:
print("[hf_space] Loading generation model + adapter...")
_gen_tokenizer = AutoTokenizer.from_pretrained(ADAPTER_PATH)
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
base = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_NAME, quantization_config=bnb_config, device_map={"": 0},
)
_gen_model = PeftModel.from_pretrained(base, ADAPTER_PATH)
_gen_model.eval()
return _gen_model, _gen_tokenizer
@spaces.GPU
def ask(question: str, subject: str) -> dict:
"""The public API function -- called by the main frontend as
POST /call/ask, matching the api_name set on the Blocks event below."""
if subject not in SUBJECTS:
return {"error": f"Unknown subject: {subject}"}
reference = get_reference_material(question, subject, top_k=3)
context = "\n\n".join(r["text"] for r in reference)
full_instruction = f"Material de referinta:\n{context}\n\nIntrebare: {question}" if context else question
prompt = f"<s>[INST] {SYSTEM_PROMPT}\n\n{full_instruction} [/INST]"
model, tokenizer = _get_generation_model()
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
with model.disable_adapter():
output_ids = model.generate(
**inputs, max_new_tokens=400, temperature=0.3,
do_sample=True, top_p=0.9, pad_token_id=tokenizer.eos_token_id,
)
new_tokens = output_ids[0][inputs["input_ids"].shape[1]:]
answer = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
return {
"answer": answer,
"experimental": True,
"material_referinta": reference,
}
with gr.Blocks(title="RoBacTutor -- Ask mode (internal API)") as demo:
gr.Markdown(
"## RoBacTutor ask-mode API\n"
"This Space is called internally by the RoBacTutor web app's "
"**Întreabă** screen -- it isn't meant to be used directly here. "
"Open the main app instead."
)
question_in = gr.Textbox(label="Question")
subject_in = gr.Dropdown(choices=SUBJECTS, value="matematica", label="Subject")
output = gr.JSON(label="Response")
btn = gr.Button("Ask")
btn.click(fn=ask, inputs=[question_in, subject_in], outputs=output, api_name="ask")
if __name__ == "__main__":
demo.launch()
|