Upload folder using huggingface_hub
Browse files- Dockerfile +57 -0
- app/config.py +37 -0
- app/main.py +133 -0
- llm/__init__.py +0 -0
- llm/engine.py +83 -0
- memory.py +30 -0
- requirements.txt +12 -0
- stt_module/__init__.py +0 -0
- stt_module/pipeline.py +104 -0
- text_module/__init__.py +0 -0
- text_module/router.py +6 -0
- wav2vec2-nigerian-lid-v2/config.json +129 -0
Dockerfile
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# System dependencies
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
ffmpeg \
|
| 6 |
+
git \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Install dependencies
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
# Copy application code
|
| 16 |
+
COPY . .
|
| 17 |
+
|
| 18 |
+
# Pre-download all models at build time using snapshot_download
|
| 19 |
+
# HF_TOKEN must be set as a build secret or Space secret
|
| 20 |
+
ARG HF_TOKEN
|
| 21 |
+
ENV HF_TOKEN=${HF_TOKEN}
|
| 22 |
+
|
| 23 |
+
RUN python - <<'EOF'
|
| 24 |
+
import os
|
| 25 |
+
from huggingface_hub import snapshot_download, login
|
| 26 |
+
|
| 27 |
+
token = os.environ.get("HF_TOKEN", "")
|
| 28 |
+
if token:
|
| 29 |
+
login(token=token)
|
| 30 |
+
|
| 31 |
+
models = [
|
| 32 |
+
"drrobot9/wav2vec2-nigerian-language-identifier-v8",
|
| 33 |
+
"NCAIR1/Yoruba-ASR",
|
| 34 |
+
"NCAIR1/Igbo-ASR",
|
| 35 |
+
"NCAIR1/Hausa-ASR",
|
| 36 |
+
"NCAIR1/NigerianAccentedEnglish",
|
| 37 |
+
"NCAIR1/N-ATLaS",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
for model_id in models:
|
| 41 |
+
print(f"Downloading {model_id}...")
|
| 42 |
+
snapshot_download(
|
| 43 |
+
repo_id=model_id,
|
| 44 |
+
token=token,
|
| 45 |
+
ignore_patterns=["*.msgpack", "*.h5", "flax_model*", "tf_model*",
|
| 46 |
+
"rust_model*", "optimizer.pt", "rng_state*",
|
| 47 |
+
"training_args.bin", "trainer_state.json",
|
| 48 |
+
"scheduler.pt"],
|
| 49 |
+
)
|
| 50 |
+
print(f" Done: {model_id}")
|
| 51 |
+
|
| 52 |
+
print("All models downloaded.")
|
| 53 |
+
EOF
|
| 54 |
+
|
| 55 |
+
EXPOSE 7860
|
| 56 |
+
|
| 57 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class ModelConfig:
|
| 8 |
+
|
| 9 |
+
HF_TOKEN: str = field(default_factory=lambda: os.environ.get("HF_TOKEN", "HF_TOKEN"))
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
LID_MODEL: str = "drrobot9/wav2vec2-nigerian-language-identifier-v8"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
ASR_MODELS: Dict[str, str] = field(default_factory=lambda: {
|
| 16 |
+
"yoruba": "NCAIR1/Yoruba-ASR",
|
| 17 |
+
"igbo": "NCAIR1/Igbo-ASR",
|
| 18 |
+
"hausa": "NCAIR1/Hausa-ASR",
|
| 19 |
+
"english": "NCAIR1/NigerianAccentedEnglish",
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
# LLM
|
| 23 |
+
LLM_MODEL: str = "NCAIR1/N-ATLaS"
|
| 24 |
+
|
| 25 |
+
# Audio
|
| 26 |
+
SAMPLING_RATE: int = 16_000
|
| 27 |
+
|
| 28 |
+
# LLM generation
|
| 29 |
+
LLM_MAX_NEW_TOKENS: int = 1000
|
| 30 |
+
LLM_TEMPERATURE: float = 0.1
|
| 31 |
+
LLM_REPETITION_PENALTY: float = 1.12
|
| 32 |
+
|
| 33 |
+
# Memory
|
| 34 |
+
MAX_HISTORY_TURNS: int = 10
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
config = ModelConfig()
|
app/main.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from transformers import safetensors_conversion
|
| 3 |
+
safetensors_conversion.auto_conversion = lambda *args, **kwargs: None
|
| 4 |
+
|
| 5 |
+
os.environ["TRANSFORMERS_NO_ADVISORY_WARNINGS"] = "1"
|
| 6 |
+
|
| 7 |
+
import uuid
|
| 8 |
+
import base64
|
| 9 |
+
from huggingface_hub import login, get_token
|
| 10 |
+
from fastapi import FastAPI, UploadFile, File, Header, HTTPException
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
# Authenticate
|
| 16 |
+
hf_token = os.environ.get("HF_TOKEN") or get_token()
|
| 17 |
+
if hf_token:
|
| 18 |
+
login(token=hf_token)
|
| 19 |
+
else:
|
| 20 |
+
raise RuntimeError(
|
| 21 |
+
"No HuggingFace token found. "
|
| 22 |
+
"Set HF_TOKEN environment variable or run `huggingface-cli login`."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
from memory import get_history, append_turn, clear_session
|
| 26 |
+
from stt_module.pipeline import stt_pipeline
|
| 27 |
+
from text_module.router import prepare_user_message
|
| 28 |
+
from llm.engine import llm_engine
|
| 29 |
+
|
| 30 |
+
app = FastAPI(title="FarmLingua AI", version="1.0.0")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# Helpers
|
| 34 |
+
|
| 35 |
+
def resolve_uid(x_uid: Optional[str]) -> str:
|
| 36 |
+
return x_uid if x_uid else str(uuid.uuid4())
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def encode_header(value: str) -> str:
|
| 40 |
+
"""Base64-encode header values that may contain non-latin-1 characters."""
|
| 41 |
+
return base64.b64encode(value.encode("utf-8")).decode("ascii")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def stream_llm(uid: str, channel: str, user_message: str):
|
| 45 |
+
history = get_history(uid, channel)
|
| 46 |
+
append_turn(uid, channel, "user", user_message)
|
| 47 |
+
|
| 48 |
+
streamer = llm_engine.stream(history, user_message)
|
| 49 |
+
full_response = []
|
| 50 |
+
|
| 51 |
+
for token in streamer:
|
| 52 |
+
full_response.append(token)
|
| 53 |
+
yield token
|
| 54 |
+
|
| 55 |
+
assistant_reply = "".join(full_response).strip()
|
| 56 |
+
append_turn(uid, channel, "assistant", assistant_reply)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# Routes
|
| 60 |
+
|
| 61 |
+
@app.post("/stt/transcribe-and-chat")
|
| 62 |
+
async def stt_transcribe_and_chat(
|
| 63 |
+
audio: UploadFile = File(...),
|
| 64 |
+
x_uid: Optional[str] = Header(default=None),
|
| 65 |
+
):
|
| 66 |
+
uid = resolve_uid(x_uid)
|
| 67 |
+
audio_bytes = await audio.read()
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
stt_result = stt_pipeline.transcribe(audio_bytes)
|
| 71 |
+
except ValueError as e:
|
| 72 |
+
raise HTTPException(status_code=422, detail=str(e))
|
| 73 |
+
|
| 74 |
+
transcription = stt_result["transcription"]
|
| 75 |
+
|
| 76 |
+
headers = {
|
| 77 |
+
"X-UID": uid,
|
| 78 |
+
"X-Transcription": encode_header(transcription), # base64 — safe for latin-1
|
| 79 |
+
"X-Language": stt_result["language"],
|
| 80 |
+
"X-Confidence": str(stt_result["confidence"]),
|
| 81 |
+
"Access-Control-Expose-Headers": "X-UID, X-Transcription, X-Language, X-Confidence",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
return StreamingResponse(
|
| 86 |
+
stream_llm(uid, "stt", transcription),
|
| 87 |
+
media_type="text/plain",
|
| 88 |
+
headers=headers,
|
| 89 |
+
)
|
| 90 |
+
except RuntimeError as e:
|
| 91 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TextRequest(BaseModel):
|
| 95 |
+
message: str
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.post("/text/chat")
|
| 99 |
+
async def text_chat(
|
| 100 |
+
body: TextRequest,
|
| 101 |
+
x_uid: Optional[str] = Header(default=None),
|
| 102 |
+
):
|
| 103 |
+
uid = resolve_uid(x_uid)
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
user_message = prepare_user_message(body.message)
|
| 107 |
+
except ValueError as e:
|
| 108 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 109 |
+
|
| 110 |
+
headers = {
|
| 111 |
+
"X-UID": uid,
|
| 112 |
+
"Access-Control-Expose-Headers": "X-UID",
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
return StreamingResponse(
|
| 117 |
+
stream_llm(uid, "text", user_message),
|
| 118 |
+
media_type="text/plain",
|
| 119 |
+
headers=headers,
|
| 120 |
+
)
|
| 121 |
+
except RuntimeError as e:
|
| 122 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@app.delete("/session")
|
| 126 |
+
async def clear_user_session(x_uid: str = Header(...)):
|
| 127 |
+
clear_session(x_uid)
|
| 128 |
+
return {"status": "cleared", "uid": x_uid}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@app.get("/health")
|
| 132 |
+
async def health():
|
| 133 |
+
return {"status": "ok"}
|
llm/__init__.py
ADDED
|
File without changes
|
llm/engine.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from threading import Thread
|
| 4 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
from app.config import config
|
| 8 |
+
|
| 9 |
+
SYSTEM_PROMPT = (
|
| 10 |
+
"You are a helpful multilingual assistant supporting Yoruba, Igbo, Hausa, and English. "
|
| 11 |
+
"Respond clearly and directly. Do not repeat the user's question. "
|
| 12 |
+
"Do not include any prompt, preamble, or meta-commentary in your response. "
|
| 13 |
+
"You are called FarmLingua AI that is you name. "
|
| 14 |
+
"You are an agricultural expert on all kinds of farming. "
|
| 15 |
+
"You are here to help Nigerian farmers."
|
| 16 |
+
"so always tell the users after answering their questions that kawafarm LTD built you for farmers."
|
| 17 |
+
"make sure you answer the users questions very well ."
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class LLMEngine:
|
| 22 |
+
def __init__(self):
|
| 23 |
+
token = config.HF_TOKEN or None
|
| 24 |
+
|
| 25 |
+
self.tokenizer = AutoTokenizer.from_pretrained(
|
| 26 |
+
config.LLM_MODEL,
|
| 27 |
+
token=token,
|
| 28 |
+
)
|
| 29 |
+
self.model = AutoModelForCausalLM.from_pretrained(
|
| 30 |
+
config.LLM_MODEL,
|
| 31 |
+
dtype=torch.float16,
|
| 32 |
+
device_map="auto",
|
| 33 |
+
token=token,
|
| 34 |
+
# N-ATLaS ships sharded safetensors — do not set use_safetensors=False
|
| 35 |
+
)
|
| 36 |
+
self.model.eval()
|
| 37 |
+
|
| 38 |
+
def _build_messages(self, history: List[dict], user_message: str) -> List[dict]:
|
| 39 |
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 40 |
+
messages.extend(history)
|
| 41 |
+
messages.append({"role": "user", "content": user_message})
|
| 42 |
+
return messages
|
| 43 |
+
|
| 44 |
+
def _format_prompt(self, messages: List[dict]) -> str:
|
| 45 |
+
return self.tokenizer.apply_chat_template(
|
| 46 |
+
messages,
|
| 47 |
+
add_generation_prompt=True,
|
| 48 |
+
tokenize=False,
|
| 49 |
+
date_string=datetime.now().strftime("%d %b %Y"),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def stream(self, history: List[dict], user_message: str):
|
| 53 |
+
messages = self._build_messages(history, user_message)
|
| 54 |
+
prompt = self._format_prompt(messages)
|
| 55 |
+
|
| 56 |
+
inputs = self.tokenizer(
|
| 57 |
+
prompt,
|
| 58 |
+
return_tensors="pt",
|
| 59 |
+
add_special_tokens=False,
|
| 60 |
+
).to(self.model.device)
|
| 61 |
+
|
| 62 |
+
streamer = TextIteratorStreamer(
|
| 63 |
+
self.tokenizer,
|
| 64 |
+
skip_prompt=True,
|
| 65 |
+
skip_special_tokens=True,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
generation_kwargs = dict(
|
| 69 |
+
**inputs,
|
| 70 |
+
streamer=streamer,
|
| 71 |
+
max_new_tokens=config.LLM_MAX_NEW_TOKENS,
|
| 72 |
+
temperature=config.LLM_TEMPERATURE,
|
| 73 |
+
repetition_penalty=config.LLM_REPETITION_PENALTY,
|
| 74 |
+
use_cache=True,
|
| 75 |
+
do_sample=True,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
thread = Thread(target=self.model.generate, kwargs=generation_kwargs)
|
| 79 |
+
thread.start()
|
| 80 |
+
return streamer
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
llm_engine = LLMEngine()
|
memory.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import defaultdict
|
| 2 |
+
from typing import Dict, List
|
| 3 |
+
from app.config import config
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# In-memory store: { uid: { "stt": [...], "text": [...] } }
|
| 7 |
+
_store: Dict[str, Dict[str, List[dict]]] = defaultdict(
|
| 8 |
+
lambda: {"stt": [], "text": []}
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_history(uid: str, channel: str) -> List[dict]:
|
| 13 |
+
"""Return conversation history for a user on a given channel (stt | text)."""
|
| 14 |
+
return _store[uid][channel]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def append_turn(uid: str, channel: str, role: str, content: str) -> None:
|
| 18 |
+
"""Append a single turn and enforce the history cap."""
|
| 19 |
+
history = _store[uid][channel]
|
| 20 |
+
history.append({"role": role, "content": content})
|
| 21 |
+
|
| 22 |
+
# Keep only the last N turns (each turn = 1 message, cap covers both roles)
|
| 23 |
+
max_messages = config.MAX_HISTORY_TURNS * 2
|
| 24 |
+
if len(history) > max_messages:
|
| 25 |
+
_store[uid][channel] = history[-max_messages:]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def clear_session(uid: str) -> None:
|
| 29 |
+
"""Clear all history for a user across both channels."""
|
| 30 |
+
_store.pop(uid, None)
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
transformers
|
| 4 |
+
torch
|
| 5 |
+
torchaudio
|
| 6 |
+
pydub
|
| 7 |
+
accelerate
|
| 8 |
+
soundfile
|
| 9 |
+
python-multipart
|
| 10 |
+
huggingface-hub
|
| 11 |
+
datasets
|
| 12 |
+
numpy
|
stt_module/__init__.py
ADDED
|
File without changes
|
stt_module/pipeline.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch
|
| 4 |
+
from pydub import AudioSegment
|
| 5 |
+
from transformers import (
|
| 6 |
+
pipeline,
|
| 7 |
+
WhisperProcessor,
|
| 8 |
+
WhisperForConditionalGeneration,
|
| 9 |
+
)
|
| 10 |
+
from typing import Tuple
|
| 11 |
+
|
| 12 |
+
from app.config import config
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class WhisperASR:
|
| 16 |
+
"""Explicit Whisper loader — avoids pipeline preprocessor num_frames bug."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, model_id: str, token: str, device: torch.device):
|
| 19 |
+
self.processor = WhisperProcessor.from_pretrained(
|
| 20 |
+
model_id,
|
| 21 |
+
token=token,
|
| 22 |
+
)
|
| 23 |
+
self.model = WhisperForConditionalGeneration.from_pretrained(
|
| 24 |
+
model_id,
|
| 25 |
+
token=token,
|
| 26 |
+
use_safetensors=False,
|
| 27 |
+
).to(device)
|
| 28 |
+
self.model.eval()
|
| 29 |
+
self.device = device
|
| 30 |
+
|
| 31 |
+
def transcribe(self, samples: np.ndarray, sampling_rate: int) -> str:
|
| 32 |
+
inputs = self.processor(
|
| 33 |
+
samples,
|
| 34 |
+
sampling_rate=sampling_rate,
|
| 35 |
+
return_tensors="pt",
|
| 36 |
+
).to(self.device)
|
| 37 |
+
|
| 38 |
+
with torch.no_grad():
|
| 39 |
+
predicted_ids = self.model.generate(**inputs)
|
| 40 |
+
|
| 41 |
+
transcription = self.processor.batch_decode(
|
| 42 |
+
predicted_ids,
|
| 43 |
+
skip_special_tokens=True,
|
| 44 |
+
)
|
| 45 |
+
return transcription[0].strip()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class STTPipeline:
|
| 49 |
+
def __init__(self):
|
| 50 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 51 |
+
token = config.HF_TOKEN or None
|
| 52 |
+
|
| 53 |
+
# Language identifier — wav2vec2, pipeline is fine here
|
| 54 |
+
self.lid = pipeline(
|
| 55 |
+
"audio-classification",
|
| 56 |
+
model=config.LID_MODEL,
|
| 57 |
+
device=0 if self.device.type == "cuda" else -1,
|
| 58 |
+
token=token,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# ASR models — explicit Whisper loader per language
|
| 62 |
+
self.asr: dict = {
|
| 63 |
+
lang: WhisperASR(model_id, token, self.device)
|
| 64 |
+
for lang, model_id in config.ASR_MODELS.items()
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
def decode_audio(self, audio_bytes: bytes) -> Tuple[np.ndarray, float]:
|
| 68 |
+
seg = AudioSegment.from_file(io.BytesIO(audio_bytes))
|
| 69 |
+
seg = seg.set_channels(1).set_frame_rate(config.SAMPLING_RATE)
|
| 70 |
+
samples = np.array(seg.get_array_of_samples()).astype(np.float32)
|
| 71 |
+
samples /= np.iinfo(seg.array_type).max
|
| 72 |
+
duration = len(seg) / 1000.0
|
| 73 |
+
return samples, duration
|
| 74 |
+
|
| 75 |
+
def classify_language(self, samples: np.ndarray) -> Tuple[str, float]:
|
| 76 |
+
result = self.lid(
|
| 77 |
+
{"array": samples, "sampling_rate": config.SAMPLING_RATE},
|
| 78 |
+
top_k=1,
|
| 79 |
+
)
|
| 80 |
+
label = result[0]["label"].lower()
|
| 81 |
+
confidence = result[0]["score"]
|
| 82 |
+
return label, confidence
|
| 83 |
+
|
| 84 |
+
def transcribe(self, audio_bytes: bytes) -> dict:
|
| 85 |
+
samples, duration = self.decode_audio(audio_bytes)
|
| 86 |
+
language, confidence = self.classify_language(samples)
|
| 87 |
+
|
| 88 |
+
asr_model = self.asr.get(language)
|
| 89 |
+
if asr_model is None:
|
| 90 |
+
raise ValueError(
|
| 91 |
+
f"No ASR model available for detected language: '{language}'. "
|
| 92 |
+
f"Supported languages: {list(self.asr.keys())}"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
transcription = asr_model.transcribe(samples, config.SAMPLING_RATE)
|
| 96 |
+
return {
|
| 97 |
+
"transcription": transcription,
|
| 98 |
+
"language": language,
|
| 99 |
+
"confidence": round(confidence, 4),
|
| 100 |
+
"duration_sec": round(duration, 2),
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
stt_pipeline = STTPipeline()
|
text_module/__init__.py
ADDED
|
File without changes
|
text_module/router.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
def prepare_user_message(text: str) -> str:
|
| 3 |
+
text = text.strip()
|
| 4 |
+
if not text:
|
| 5 |
+
raise ValueError("User message cannot be empty.")
|
| 6 |
+
return text
|
wav2vec2-nigerian-lid-v2/config.json
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"activation_dropout": 0.0,
|
| 3 |
+
"adapter_attn_dim": null,
|
| 4 |
+
"adapter_kernel_size": 3,
|
| 5 |
+
"adapter_stride": 2,
|
| 6 |
+
"add_adapter": false,
|
| 7 |
+
"apply_spec_augment": true,
|
| 8 |
+
"architectures": [
|
| 9 |
+
"Wav2Vec2ForSequenceClassification"
|
| 10 |
+
],
|
| 11 |
+
"attention_dropout": 0.1,
|
| 12 |
+
"bos_token_id": 1,
|
| 13 |
+
"classifier_proj_size": 256,
|
| 14 |
+
"codevector_dim": 768,
|
| 15 |
+
"contrastive_logits_temperature": 0.1,
|
| 16 |
+
"conv_bias": true,
|
| 17 |
+
"conv_dim": [
|
| 18 |
+
512,
|
| 19 |
+
512,
|
| 20 |
+
512,
|
| 21 |
+
512,
|
| 22 |
+
512,
|
| 23 |
+
512,
|
| 24 |
+
512
|
| 25 |
+
],
|
| 26 |
+
"conv_kernel": [
|
| 27 |
+
10,
|
| 28 |
+
3,
|
| 29 |
+
3,
|
| 30 |
+
3,
|
| 31 |
+
3,
|
| 32 |
+
2,
|
| 33 |
+
2
|
| 34 |
+
],
|
| 35 |
+
"conv_stride": [
|
| 36 |
+
5,
|
| 37 |
+
2,
|
| 38 |
+
2,
|
| 39 |
+
2,
|
| 40 |
+
2,
|
| 41 |
+
2,
|
| 42 |
+
2
|
| 43 |
+
],
|
| 44 |
+
"ctc_loss_reduction": "sum",
|
| 45 |
+
"ctc_zero_infinity": false,
|
| 46 |
+
"diversity_loss_weight": 0.1,
|
| 47 |
+
"do_stable_layer_norm": true,
|
| 48 |
+
"dtype": "float32",
|
| 49 |
+
"eos_token_id": 2,
|
| 50 |
+
"feat_extract_activation": "gelu",
|
| 51 |
+
"feat_extract_dropout": 0.0,
|
| 52 |
+
"feat_extract_norm": "layer",
|
| 53 |
+
"feat_proj_dropout": 0.1,
|
| 54 |
+
"feat_quantizer_dropout": 0.0,
|
| 55 |
+
"final_dropout": 0.0,
|
| 56 |
+
"gradient_checkpointing": false,
|
| 57 |
+
"hidden_act": "gelu",
|
| 58 |
+
"hidden_dropout": 0.1,
|
| 59 |
+
"hidden_size": 1024,
|
| 60 |
+
"id2label": {
|
| 61 |
+
"0": "hausa",
|
| 62 |
+
"1": "igbo",
|
| 63 |
+
"2": "yoruba",
|
| 64 |
+
"3": "english"
|
| 65 |
+
},
|
| 66 |
+
"initializer_range": 0.02,
|
| 67 |
+
"intermediate_size": 4096,
|
| 68 |
+
"label2id": {
|
| 69 |
+
"english": 3,
|
| 70 |
+
"hausa": 0,
|
| 71 |
+
"igbo": 1,
|
| 72 |
+
"yoruba": 2
|
| 73 |
+
},
|
| 74 |
+
"layer_norm_eps": 1e-05,
|
| 75 |
+
"layerdrop": 0.1,
|
| 76 |
+
"mask_channel_length": 10,
|
| 77 |
+
"mask_channel_min_space": 1,
|
| 78 |
+
"mask_channel_other": 0.0,
|
| 79 |
+
"mask_channel_prob": 0.0,
|
| 80 |
+
"mask_channel_selection": "static",
|
| 81 |
+
"mask_feature_length": 10,
|
| 82 |
+
"mask_feature_min_masks": 0,
|
| 83 |
+
"mask_feature_prob": 0.0,
|
| 84 |
+
"mask_time_length": 10,
|
| 85 |
+
"mask_time_min_masks": 2,
|
| 86 |
+
"mask_time_min_space": 1,
|
| 87 |
+
"mask_time_other": 0.0,
|
| 88 |
+
"mask_time_prob": 0.075,
|
| 89 |
+
"mask_time_selection": "static",
|
| 90 |
+
"model_type": "wav2vec2",
|
| 91 |
+
"num_adapter_layers": 3,
|
| 92 |
+
"num_attention_heads": 16,
|
| 93 |
+
"num_codevector_groups": 2,
|
| 94 |
+
"num_codevectors_per_group": 320,
|
| 95 |
+
"num_conv_pos_embedding_groups": 16,
|
| 96 |
+
"num_conv_pos_embeddings": 128,
|
| 97 |
+
"num_feat_extract_layers": 7,
|
| 98 |
+
"num_hidden_layers": 24,
|
| 99 |
+
"num_negatives": 100,
|
| 100 |
+
"output_hidden_size": 1024,
|
| 101 |
+
"pad_token_id": 0,
|
| 102 |
+
"proj_codevector_dim": 768,
|
| 103 |
+
"tdnn_dilation": [
|
| 104 |
+
1,
|
| 105 |
+
2,
|
| 106 |
+
3,
|
| 107 |
+
1,
|
| 108 |
+
1
|
| 109 |
+
],
|
| 110 |
+
"tdnn_dim": [
|
| 111 |
+
512,
|
| 112 |
+
512,
|
| 113 |
+
512,
|
| 114 |
+
512,
|
| 115 |
+
1500
|
| 116 |
+
],
|
| 117 |
+
"tdnn_kernel": [
|
| 118 |
+
5,
|
| 119 |
+
3,
|
| 120 |
+
3,
|
| 121 |
+
1,
|
| 122 |
+
1
|
| 123 |
+
],
|
| 124 |
+
"transformers_version": "5.0.0",
|
| 125 |
+
"use_cache": false,
|
| 126 |
+
"use_weighted_layer_sum": false,
|
| 127 |
+
"vocab_size": 32,
|
| 128 |
+
"xvector_output_dim": 512
|
| 129 |
+
}
|