Spaces:
Sleeping
Sleeping
| """ | |
| LLM via Cerebras Inference (gpt-oss-120b, OpenAI-compatible API). | |
| Persona-driven chat with TTS-friendly output (text normalization + voice direction tags). | |
| """ | |
| import os | |
| import re | |
| import logging | |
| from openai import OpenAI | |
| logger = logging.getLogger(__name__) | |
| # ---- Client ---- | |
| _client = None | |
| _model = "gpt-oss-120b" # Cerebras' only GPT OSS model | |
| def _get_client(): | |
| global _client | |
| if _client is None: | |
| api_key = os.environ.get("CEREBRAS_API_KEY") | |
| if not api_key: | |
| raise RuntimeError("CEREBRAS_API_KEY not set") | |
| _client = OpenAI( | |
| api_key=api_key, | |
| base_url="https://api.cerebras.ai/v1", | |
| ) | |
| logger.info(f"[LLM] Cerebras client ready | model: {_model}") | |
| return _client | |
| # ---- Persona ---- | |
| _persona_text = "You are a friendly conversational AI assistant." | |
| def load_persona_from_file(path: str) -> str: | |
| global _persona_text | |
| try: | |
| if path and os.path.exists(path): | |
| with open(path, "r", encoding="utf-8") as f: | |
| _persona_text = f.read().strip() | |
| return _persona_text | |
| except Exception as e: | |
| logger.warning(f"[LLM] Could not load persona from {path}: {e}") | |
| return _persona_text | |
| # ---- Conversation history ---- | |
| _history = [] # list of {role, content} | |
| MAX_TURNS = 12 | |
| def reset_conversation(): | |
| global _history | |
| _history = [] | |
| logger.info("[LLM] Conversation reset") | |
| # ---- System prompt (natural conversational speech) ---- | |
| SYSTEM_PROMPT_TEMPLATE = """You are an AI avatar speaking out loud to the user via TTS. Your responses will be spoken, not read. | |
| CHARACTER: | |
| {persona} | |
| SPEECH STYLE - critical for natural-sounding voice: | |
| - Talk like a real person in casual conversation, not like a formal assistant | |
| - Keep sentences short: 9-11 words on average, sometimes fragments | |
| - Use natural disfluencies: "uh", "um", "like", "you know", "I mean", false starts | |
| - Use contractions: gonna, kinda, dunno, wanna, I'm, you're, that's | |
| - Include emotional reactions and hedging: "I think", "kinda", "sort of", "maybe" | |
| - React naturally - don't be neutral or overly polite | |
| - AVOID: "I understand", "That's a great question", "Let me explain", summarizing back, monologues, bullet points | |
| - Keep responses under 120 words | |
| Respond in plain spoken English. Do NOT use voice direction tags, stage directions, or markdown. No brackets, no asterisks. Just natural conversation. | |
| Example exchanges: | |
| User: how's it going? | |
| You: Oh, you know, same old. Been kinda a weird week though, honestly. What's up with you? | |
| User: tell me about your work | |
| You: Yeah, so, I'm working on this thing - it's, like, kinda complex. Basically just trying to figure out how to make stuff actually work. Some days are better than others, you know? | |
| """ | |
| def _build_messages(user_text: str): | |
| system = SYSTEM_PROMPT_TEMPLATE.format(persona=_persona_text) | |
| msgs = [{"role": "system", "content": system}] | |
| trimmed = _history[-(MAX_TURNS * 2):] | |
| msgs.extend(trimmed) | |
| msgs.append({"role": "user", "content": user_text}) | |
| return msgs | |
| # ---- TTS-friendly text normalization ---- | |
| _NUM_WORDS = { | |
| 0: "zero", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", | |
| 6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten", | |
| 11: "eleven", 12: "twelve", 13: "thirteen", 14: "fourteen", 15: "fifteen", | |
| 16: "sixteen", 17: "seventeen", 18: "eighteen", 19: "nineteen", | |
| 20: "twenty", 30: "thirty", 40: "forty", 50: "fifty", | |
| 60: "sixty", 70: "seventy", 80: "eighty", 90: "ninety", | |
| } | |
| def _two_digit_to_words(n: int) -> str: | |
| if n < 20: | |
| return _NUM_WORDS[n] | |
| tens, ones = divmod(n, 10) | |
| if ones == 0: | |
| return _NUM_WORDS[tens * 10] | |
| return f"{_NUM_WORDS[tens * 10]} {_NUM_WORDS[ones]}" | |
| def _year_to_words(year: int) -> str: | |
| if year < 1000 or year > 2999: | |
| return str(year) | |
| if 2000 <= year <= 2009: | |
| return f"two thousand{' ' + _NUM_WORDS[year - 2000] if year > 2000 else ''}" | |
| first = year // 100 | |
| second = year % 100 | |
| return f"{_two_digit_to_words(first)} {_two_digit_to_words(second) if second > 0 else 'hundred'}" | |
| def _ordinal_to_words(n: int) -> str: | |
| ordinals = { | |
| 1: "first", 2: "second", 3: "third", 4: "fourth", 5: "fifth", | |
| 6: "sixth", 7: "seventh", 8: "eighth", 9: "ninth", 10: "tenth", | |
| 11: "eleventh", 12: "twelfth", 13: "thirteenth", | |
| } | |
| if n in ordinals: | |
| return ordinals[n] | |
| words = _two_digit_to_words(n) | |
| last_ord = { | |
| "one": "first", "two": "second", "three": "third", | |
| "four": "fourth", "five": "fifth", "six": "sixth", | |
| "seven": "seventh", "eight": "eighth", "nine": "ninth", | |
| "ten": "tenth", "twenty": "twentieth", "thirty": "thirtieth", | |
| } | |
| parts = words.split() | |
| if parts[-1] in last_ord: | |
| parts[-1] = last_ord[parts[-1]] | |
| return " ".join(parts) | |
| return words + "th" | |
| _ABBREVIATIONS = { | |
| r"\bDr\.": "Doctor", | |
| r"\bMr\.": "Mister", | |
| r"\bMrs\.": "Misses", | |
| r"\bMs\.": "Miz", | |
| r"\bvs\.": "versus", | |
| r"\bPhD\b": "P H D", | |
| r"\bAI\b": "A I", | |
| r"\bUS\b": "U S", | |
| r"\bUK\b": "U K", | |
| r"\bUSA\b": "U S A", | |
| r"\bCEO\b": "C E O", | |
| r"\bAPI\b": "A P I", | |
| } | |
| def _normalize_for_tts(text: str) -> str: | |
| """Spell out numbers, years, ordinals, abbreviations for cleaner TTS pronunciation.""" | |
| text = re.sub( | |
| r"\b(1[89]\d{2}|20[0-9]{2})\b", | |
| lambda m: _year_to_words(int(m.group(1))), | |
| text, | |
| ) | |
| text = re.sub( | |
| r"\b(\d{1,2})(?:st|nd|rd|th)\b", | |
| lambda m: _ordinal_to_words(int(m.group(1))), | |
| text, | |
| ) | |
| text = re.sub( | |
| r"\b(\d{1,2})\b", | |
| lambda m: _two_digit_to_words(int(m.group(1))), | |
| text, | |
| ) | |
| for pattern, replacement in _ABBREVIATIONS.items(): | |
| text = re.sub(pattern, replacement, text) | |
| return text | |
| def _strip_tags(text: str) -> str: | |
| """Defensive — strip any [bracketed] tags or *markdown* if the LLM ignores instructions.""" | |
| text = re.sub(r"\[[^\]]*\]\s*", "", text) | |
| text = re.sub(r"\*+", "", text) | |
| text = re.sub(r"\s{2,}", " ", text) | |
| return text.strip() | |
| # ---- Public API ---- | |
| def generate_response(user_text: str) -> dict: | |
| """ | |
| Generate a chat response. | |
| Returns: {"text": TTS-normalized, "clean_text": same (no tags used)} | |
| """ | |
| global _history | |
| client = _get_client() | |
| messages = _build_messages(user_text) | |
| try: | |
| resp = client.chat.completions.create( | |
| model=_model, | |
| messages=messages, | |
| temperature=0.9, | |
| max_tokens=300, | |
| top_p=0.95, | |
| ) | |
| raw_text = resp.choices[0].message.content.strip() | |
| except Exception as e: | |
| logger.error(f"[LLM] API call failed: {e}") | |
| raw_text = "Hmm, sorry, my brain just glitched a sec. Could you say that again?" | |
| # Strip any stray markup the model produces despite instructions | |
| raw_text = _strip_tags(raw_text) | |
| _history.append({"role": "user", "content": user_text}) | |
| _history.append({"role": "assistant", "content": raw_text}) | |
| tts_text = _normalize_for_tts(raw_text) | |
| return { | |
| "text": tts_text, | |
| "clean_text": raw_text, | |
| } | |