File size: 5,473 Bytes
b6783b8
b425bec
 
e18d5ed
 
b6783b8
 
 
e18d5ed
 
b6783b8
 
 
b425bec
 
 
 
 
b6783b8
b425bec
 
 
b6783b8
 
 
 
 
 
 
 
 
b425bec
e18d5ed
 
 
 
 
 
 
 
 
 
b6783b8
 
e18d5ed
 
b6783b8
b425bec
 
b6783b8
b425bec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6783b8
 
b425bec
 
b6783b8
b425bec
 
 
 
 
 
 
b6783b8
 
e18d5ed
b6783b8
 
 
 
 
 
 
e18d5ed
b425bec
 
e18d5ed
b425bec
 
 
 
 
 
b6783b8
b425bec
 
 
 
 
b6783b8
 
 
 
 
 
 
 
e18d5ed
 
 
 
b6783b8
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Umubyeyi fine-tuned generator inference API (Hugging Face Space, Gradio + ZeroGPU).

Loads the real BLOOMZ-560m + LoRA adapter fine-tuned on ESConv (the same
weights committed at models/umubyeyi-bloomz-lora/ in the main repo, merged
into a single checkpoint offline -- see hf_space/README.md for why) and
exposes it as a named Gradio API endpoint. The deployed Vercel app cannot
bundle PyTorch/Transformers within its 500MB serverless function limit, so
it calls this Space instead of falling straight back to retrieval -- this is
the only difference from local inference; the model and prompt format are
identical to src/finetuned_generator.py.

Called over HTTP as: POST {space_url}/call/generate {"data": [query, lang, history_json]}
then GET {space_url}/call/generate/{event_id} (Gradio's async SSE result contract).
"""
import json
import re
from pathlib import Path

import gradio as gr
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

try:
    import spaces
except ImportError:  # running outside a real ZeroGPU Space (local test)
    class _NoOpSpaces:
        @staticmethod
        def GPU(fn):
            return fn
    spaces = _NoOpSpaces()

ROOT = Path(__file__).resolve().parent
MODEL_PATH = ROOT / "merged_model"
ACCEPTED_LANGUAGES = ["en"]

# Loaded (and moved to cuda) at module level, not inside the @spaces.GPU
# function: a CUDA emulation mode is active here even without a real GPU
# attached, and the ZeroGPU docs specifically recommend placement at
# startup over lazy/in-function loading. This is a plain merged checkpoint
# (LoRA folded in offline) rather than a PeftModel -- PEFT's own adapter
# loading path does not tolerate ZeroGPU's tensor-op patching well.
_tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
if _tokenizer.pad_token is None:
    _tokenizer.pad_token = _tokenizer.eos_token
_model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, dtype=torch.float16)
_model.to("cuda")
_model.eval()


def _normalise(text) -> str:
    return " ".join(str(text or "").split()).strip()


def _format_generator_input(query: str, evidence: str, lang: str, history: str) -> str:
    """Mirrors src/generation_data.py's format_generator_input exactly --
    the model was trained on this exact prompt shape."""
    language = "Kinyarwanda" if lang == "rw" else "English"
    evidence_text = _normalise(evidence) or "No external evidence supplied."
    history_text = _normalise(history)
    parts = [
        "Write an empathetic emotional-support response.",
        "Do not diagnose or invent medical facts.",
        f"Language: {language}",
        f"Evidence: {evidence_text}",
    ]
    if history_text:
        parts.append(f"Conversation: {history_text}")
    parts.extend([f"User: {_normalise(query)}", "Response:"])
    return "\n".join(parts)


def _history_text(history_items, max_turns: int = 6) -> str:
    if not history_items:
        return ""
    lines = []
    for item in history_items[-max_turns:]:
        role = "Supporter" if item.get("role") == "bot" else "User"
        content = (item.get("text") or "").strip()
        if content:
            lines.append(f"{role}: {content}")
    return " ".join(lines)


@spaces.GPU
def generate(query: str, lang: str = "en", history_json: str = "[]") -> str:
    if lang not in ACCEPTED_LANGUAGES:
        return ""
    try:
        history_items = json.loads(history_json) if history_json else []
    except (json.JSONDecodeError, TypeError):
        history_items = []

    prompt = _format_generator_input(query, "", lang, _history_text(history_items)) + "\n"
    encoded = _tokenizer(prompt, return_tensors="pt", truncation=True, max_length=384).to("cuda")
    try:
        with torch.inference_mode():
            output = _model.generate(
                **encoded, max_new_tokens=120, do_sample=True, temperature=0.8, top_p=0.9,
                no_repeat_ngram_size=3, pad_token_id=_tokenizer.pad_token_id,
            )
        new_tokens = output[0][encoded["input_ids"].shape[1]:]
        answer = _tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
    except Exception:
        return ""

    # The model was trained on single supporter turns; on rare degenerate
    # continuations it hallucinates a further "User:"/"Supporter:" line.
    answer = re.split(r"\n(?:User|Supporter):", answer)[0].strip()
    if len(answer.split()) < 4:
        return ""
    return answer


with gr.Blocks(title="Umubyeyi -- fine-tuned generator") as demo:
    gr.Markdown(
        "# Umubyeyi -- fine-tuned generator inference API\n"
        "Not a chat UI for end users. Real-time inference for Umubyeyi's own "
        "fine-tuned BLOOMZ-560m generator (LoRA on real ESConv data, merged "
        "offline) -- called by the deployed app's backend, since Vercel's "
        "serverless functions cannot bundle PyTorch/Transformers within "
        "their 500MB limit. Main project: https://github.com/IrutingaboRaissa/UMUBYEYI"
    )
    query_in = gr.Textbox(label="Message")
    lang_in = gr.Textbox(label="Language (en only -- no Kinyarwanda training data yet)", value="en")
    history_in = gr.Textbox(label="Conversation history (JSON list, optional)", value="[]")
    answer_out = gr.Textbox(label="Answer")
    submit_btn = gr.Button("Generate")
    submit_btn.click(
        fn=generate, inputs=[query_in, lang_in, history_in], outputs=answer_out, api_name="generate"
    )

demo.queue()

if __name__ == "__main__":
    demo.launch()