Spaces:
Paused
Paused
File size: 7,013 Bytes
e99dd36 d70ebd4 e99dd36 d70ebd4 e99dd36 7cc2412 e99dd36 c6ae51e 7cc2412 c7cbd46 e99dd36 7cc2412 e99dd36 7cc2412 e99dd36 7cc2412 e99dd36 7cc2412 e99dd36 c6ae51e e99dd36 c6ae51e e99dd36 c6ae51e e99dd36 c6ae51e e99dd36 | 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 | """
Inference module for ASR (Whisper-small) and story Q&A (Qwen2.5-3B-Instruct).
Models are loaded on-demand and cached globally for reuse.
"""
import logging
import torch
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# ASR — Whisper-small (loaded on demand)
# ---------------------------------------------------------------------------
_asr_pipe = None
def get_asr_pipeline():
"""Load Whisper-small pipeline on first call, cache thereafter."""
global _asr_pipe
if _asr_pipe is None:
from transformers import pipeline
logger.info("Loading Whisper-small for ASR...")
_asr_pipe = pipeline(
"automatic-speech-recognition",
model="openai/whisper-small",
device="cuda" if torch.cuda.is_available() else "cpu",
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
)
logger.info("Whisper-small loaded.")
return _asr_pipe
def transcribe_audio(audio_path: str) -> str:
"""Transcribe an audio file to text using Whisper-small."""
if not audio_path:
return ""
import soundfile as sf
import numpy as np
audio_data, sample_rate = sf.read(audio_path, dtype="float32")
# Convert stereo to mono if needed
if len(audio_data.shape) > 1:
audio_data = audio_data.mean(axis=1)
pipe = get_asr_pipeline()
result = pipe({"raw": audio_data, "sampling_rate": sample_rate}, generate_kwargs={"language": "en"})
return result.get("text", "").strip()
# ---------------------------------------------------------------------------
# Q&A — Qwen2.5-3B-Instruct (always loaded after first call)
# ---------------------------------------------------------------------------
_qa_tokenizer = None
_qa_model = None
def get_qa_model():
"""Load Qwen2.5-3B-Instruct on first call, cache thereafter."""
global _qa_tokenizer, _qa_model
if _qa_model is None:
from transformers import AutoTokenizer, AutoModelForCausalLM
model_id = "Qwen/Qwen2.5-3B-Instruct"
logger.info("Loading %s...", model_id)
_qa_tokenizer = AutoTokenizer.from_pretrained(model_id)
# Check available VRAM — if less than 3GB free, use CPU
use_gpu = False
if torch.cuda.is_available():
free_vram = torch.cuda.get_device_properties(0).total_memory - torch.cuda.memory_allocated(0)
free_vram_gb = free_vram / (1024**3)
logger.info("Free VRAM: %.1f GB", free_vram_gb)
if free_vram_gb >= 3.0:
use_gpu = True
if use_gpu:
load_kwargs = {"device_map": "auto", "torch_dtype": torch.float16}
# Enable FlashAttention-2 if available, else SDPA
try:
import flash_attn # noqa: F401
load_kwargs["attn_implementation"] = "flash_attention_2"
logger.info("Using FlashAttention-2 for Q&A model.")
except ImportError:
load_kwargs["attn_implementation"] = "sdpa"
try:
from transformers import BitsAndBytesConfig
load_kwargs["quantization_config"] = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_quant_type="nf4",
)
logger.info("Using 4-bit quantization on GPU.")
except Exception:
logger.info("bitsandbytes unavailable, using float16 on GPU.")
else:
logger.info("Insufficient VRAM — loading Q&A model on CPU (float32).")
load_kwargs = {"device_map": "cpu", "torch_dtype": torch.float32}
_qa_model = AutoModelForCausalLM.from_pretrained(model_id, **load_kwargs)
logger.info("Qwen2.5-3B-Instruct loaded on %s.", "GPU" if use_gpu else "CPU")
return _qa_tokenizer, _qa_model
def _get_relevant_context(paragraphs: list[str], current_idx: int, question: str) -> str:
"""Return full story with emphasis on current section for context."""
if not paragraphs:
return ""
# Build context: full story (truncated if too long) with current paragraph highlighted
total_text = "\n\n".join(paragraphs)
# If story is short enough (< 2000 chars), use it all
if len(total_text) <= 2000:
current_marker = f"\n\n[Currently reading]: {paragraphs[current_idx]}" if current_idx < len(paragraphs) else ""
return total_text + current_marker
# For longer stories: use top relevant paragraphs + surrounding context
question_words = set(question.lower().split())
scored = []
for i, para in enumerate(paragraphs):
para_words = set(para.lower().split())
overlap = len(question_words & para_words)
# Boost paragraphs near current position
proximity_bonus = max(0, 5 - abs(i - current_idx))
scored.append((overlap + proximity_bonus, i, para))
scored.sort(key=lambda x: x[0], reverse=True)
# Take top 5 most relevant paragraphs
top_paras = sorted(scored[:5], key=lambda x: x[1]) # sort by position
context = "\n\n".join(s[2] for s in top_paras)
# Add current paragraph marker
if current_idx < len(paragraphs):
context += f"\n\n[Currently reading]: {paragraphs[current_idx]}"
return context
def answer_story_question(
question: str,
paragraphs: list[str],
current_idx: int = 0,
) -> str:
"""
Generate a short, grounded answer to a child's question about the story.
Returns the answer text (1-2 sentences).
"""
if not question.strip():
return ""
tokenizer, model = get_qa_model()
context = _get_relevant_context(paragraphs, current_idx, question)
if not context:
context = "\n\n".join(paragraphs[:5])
messages = [
{
"role": "system",
"content": (
"You are a friendly storyteller answering a child's question about a bedtime story. "
"Answer in 1-2 short, simple sentences using ONLY information from the story context below. "
"If the story doesn't contain the answer, say so gently. "
"Use warm, age-appropriate language."
),
},
{
"role": "user",
"content": f"Story context:\n{context}\n\nChild's question: {question}",
},
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=80,
temperature=0.7,
do_sample=True,
pad_token_id=tokenizer.eos_token_id,
)
answer_tokens = outputs[0][inputs["input_ids"].shape[1]:]
answer = tokenizer.decode(answer_tokens, skip_special_tokens=True).strip()
return answer
|