analog-town / model_client.py
thangvip's picture
Add Creative Mode + browser TTS + multi-day timeline + emotion heatmap + 3 new towns
ed19734 verified
Raw
History Blame Contribute Delete
4.84 kB
"""Model client for Analog Town using Hugging Face Inference API."""
import io
import json
import logging
import os
import re
from dotenv import load_dotenv
from huggingface_hub import InferenceClient
from prompts import REPAIR_PROMPT
logger = logging.getLogger(__name__)
TTS_MODELS = [
"microsoft/speecht5_tts",
"facebook/mms-tts-eng",
]
def synthesize_speech(text: str, token: str | None = None) -> bytes | None:
"""Call HF Inference TTS and return raw WAV bytes, or None on failure."""
_token = token or os.getenv("HF_TOKEN")
if not _token:
return None
client = InferenceClient(token=_token)
for model in TTS_MODELS:
try:
result = client.text_to_speech(text, model=model)
if hasattr(result, "read"):
return result.read()
if isinstance(result, (bytes, bytearray)):
return bytes(result)
except Exception as exc:
logger.warning("TTS model %s failed: %s", model, exc)
continue
return None
load_dotenv()
# Model fallback chain (ensuring all models are under 32B parameters for hackathon rules)
MODELS = [
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2.5-14B-Instruct",
]
class ModelClient:
"""Wrapper for HF Inference API with JSON validation and retry logic."""
def __init__(
self,
model_id: str | None = None,
token: str | None = None,
temperature: float = 0.3,
top_p: float = 0.8,
max_tokens: int = 900,
):
self.token = token or os.getenv("HF_TOKEN")
if not self.token:
raise ValueError("HF_TOKEN not found. Set it in .env or pass it directly.")
self.model_id = model_id or MODELS[0]
self.temperature = temperature
self.top_p = top_p
self.max_tokens = max_tokens
self.client = InferenceClient(token=self.token)
def generate(self, system_prompt: str, user_prompt: str) -> str:
"""Generate text from the model. Returns raw string response."""
# Try each model in fallback chain
models_to_try = [self.model_id] + [m for m in MODELS if m != self.model_id]
last_error = None
for model in models_to_try:
try:
response = self.client.chat_completion(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
max_tokens=self.max_tokens,
temperature=self.temperature,
top_p=self.top_p,
)
return response.choices[0].message.content
except Exception as e:
last_error = e
continue
raise RuntimeError(f"All models failed. Last error: {last_error}")
def _extract_json(self, text: str) -> dict:
"""Extract JSON from model output, handling markdown code blocks."""
# Try direct parse first
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Try extracting from markdown code block
patterns = [
r'```json\s*\n(.*?)\n\s*```',
r'```\s*\n(.*?)\n\s*```',
r'\{[\s\S]*\}',
]
for pattern in patterns:
match = re.search(pattern, text, re.DOTALL)
if match:
try:
candidate = match.group(1) if '```' in pattern else match.group(0)
return json.loads(candidate)
except (json.JSONDecodeError, IndexError):
continue
raise json.JSONDecodeError("No valid JSON found in output", text, 0)
def generate_json(self, system_prompt: str, user_prompt: str) -> dict:
"""Generate and parse JSON from model. Includes retry with repair prompt."""
# First attempt
raw_output = self.generate(system_prompt, user_prompt)
try:
return self._extract_json(raw_output)
except json.JSONDecodeError:
pass
# Repair attempt
repair_prompt = REPAIR_PROMPT.format(
bad_output=raw_output,
schema="See the required JSON structure in the original prompt."
)
try:
repaired_output = self.generate(system_prompt, repair_prompt)
return self._extract_json(repaired_output)
except (json.JSONDecodeError, Exception) as e:
raise RuntimeError(
f"JSON generation failed after repair attempt. "
f"Original output: {raw_output[:200]}... "
f"Error: {e}"
)