| """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() |
|
|
| |
| 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.""" |
| |
| 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.""" |
| |
| text = text.strip() |
| try: |
| return json.loads(text) |
| except json.JSONDecodeError: |
| pass |
| |
| |
| 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.""" |
| |
| raw_output = self.generate(system_prompt, user_prompt) |
| |
| try: |
| return self._extract_json(raw_output) |
| except json.JSONDecodeError: |
| pass |
| |
| |
| 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}" |
| ) |
|
|