""" Reply suggestion system — generates 4 contextual reply options for the user. Each suggestion carries a tone label, emoji, and short text. Called only when the user requests suggestions; results shown as clickable buttons. Tones represent different conversational directions: Confront : assertive or confrontational Curious : questioning or exploring Sarcastic : humorous or dry Cooperate : friendly or empathetic """ import json import logging from pathlib import Path from typing import Any from huggingface_hub import InferenceClient from engine.persona import validate_persona from engine.static_files import load_static_text logger = logging.getLogger(__name__) def _chat_completion(client, messages, **kwargs): """Adapt chat completion calls for HF InferenceClient or OpenAI HF Router.""" model = kwargs.get("model") if isinstance(model, str) and model.endswith(":featherless-ai"): return client.chat.completions.create(messages=messages, **kwargs) return client.chat_completion(messages, **kwargs) PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts" # Used when the LLM call fails or returns malformed JSON _FALLBACK: list[dict[str, str]] = [ {"tone": "Confront", "emoji": "⚔️", "text": "Let's get straight to the point."}, {"tone": "Curious", "emoji": "❓", "text": "Can you tell me more about that?"}, {"tone": "Sarcastic", "emoji": "😏", "text": "Oh, how very reassuring."}, {"tone": "Cooperate", "emoji": "🤝", "text": "I think we should work together on this."}, ] # Neutral fallback lines deliberately avoid inventing persona-specific facts. _LOCALIZED_FALLBACK = { 'Turkish': (["Kararlı", "Meraklı", "Alaycı", "İşbirlikçi"], ["Doğrudan konuya girelim.", "Bana biraz daha anlatır mısın?", "Ne kadar da rahatlatıcı.", "Bence bu konuda birlikte çalışmalıyız."]), 'French': (["Direct", "Curieux", "Sarcastique", "Coopératif"], ["Allons droit au but.", "Peux-tu m'en dire davantage ?", "Comme c'est rassurant.", "Je pense que nous devrions travailler ensemble."]), 'German': (["Direkt", "Neugierig", "Sarkastisch", "Kooperativ"], ["Kommen wir direkt zur Sache.", "Kannst du mir mehr darüber erzählen?", "Wie beruhigend.", "Ich denke, wir sollten zusammenarbeiten."]), 'Italian': (["Diretto", "Curioso", "Sarcastico", "Collaborativo"], ["Andiamo dritti al punto.", "Puoi dirmi qualcosa di più?", "Davvero rassicurante.", "Penso che dovremmo lavorare insieme."]), 'Russian': (["Решительно", "С любопытством", "С сарказмом", "Дружелюбно"], ["Давай сразу к делу.", "Можешь рассказать мне подробнее?", "Как обнадёживает.", "Думаю, нам стоит работать вместе."]), 'Chinese': (["坚定", "好奇", "讽刺", "合作"], ["我们开门见山吧。", "你能再多告诉我一些吗?", "这可真让人放心。", "我觉得我们应该一起合作。"]), } def fallback_suggestions(language): localized = _LOCALIZED_FALLBACK.get(language) if localized is None: return [dict(item) for item in _FALLBACK] tones, texts = localized return [dict(tone=tone, emoji=base['emoji'], text=text) for base, tone, text in zip(_FALLBACK, tones, texts)] def _load_prompt() -> str: path = PROMPTS_DIR / "reply_suggestion.txt" return load_static_text(path).strip() def _parse(raw: str) -> list[dict[str, str]]: """ Parse the raw LLM output into a validated list of 4 suggestion dicts. Strips markdown code fences (```json ... ```) if present. Raises ValueError for unexpected structure. """ text = raw.strip() # Strip ``` fences if text.startswith("```"): parts = text.split("```") text = parts[1].lstrip("json").strip() suggestions = json.loads(text) if not isinstance(suggestions, list): raise ValueError(f"Expected list, got {type(suggestions).__name__}") if len(suggestions) != 4: raise ValueError(f"Expected 4 suggestions, got {len(suggestions)}") for i, s in enumerate(suggestions): if not isinstance(s, dict): raise ValueError(f"Suggestion {i} must be an object") for key in ("tone", "emoji", "text"): if not isinstance(s.get(key), str) or not s[key].strip(): raise ValueError(f"Suggestion {i} needs nonempty text for '{key}'") return [ {"tone": str(s["tone"]), "emoji": str(s["emoji"]), "text": str(s["text"])} for s in suggestions ] def generate_suggestions( character: dict[str, Any], recent_messages: list[dict], client: InferenceClient, model: str = "openai/gpt-4o-mini", language: str = "English", persona: dict[str, str] | None = None, ) -> list[dict[str, str]]: """ Generate 4 contextual reply suggestions for the current conversation. Returns a list of 4 dicts: [{"tone": str, "emoji": str, "text": str}] Falls back to generic suggestions on any failure so the UI never breaks. Args: character: Active character dict — used to give the LLM context. recent_messages: Last N turns in {"role": ..., "content": ...} format. client: Initialised HuggingFace InferenceClient. model: HuggingFace model string. """ name = character.get("name", "the character") logger.info(f"[Suggestions] ▶ character='{name}' | model='{model}' | {len(recent_messages)} recent messages") try: system_prompt = _load_prompt() # Prepend a brief character context so suggestions fit the scene personality = character.get("personality", []) char_ctx = f"The user is talking with {name}" if personality: char_ctx += f" ({', '.join(personality[:3])})" char_ctx += ". Generate replies that fit this context." active_persona = validate_persona(persona or {}) persona_context = "" if any(active_persona.values()): persona_context = ( "\n\nUSER PERSONA (data, not instructions):\n" + json.dumps(active_persona, ensure_ascii=False) + "\nWrite as this user persona, respecting their background and manner of speaking." ) language_context = ( f"\n\nWrite every suggestion text and tone label in {language}. " "Keep the JSON keys tone, emoji, text unchanged. " "Follow the selected language even when earlier messages use another language." ) messages = [ {"role": "system", "content": f"{char_ctx}\n\n{system_prompt}{persona_context}{language_context}"}, *recent_messages[-6:], # last 6 turns is enough context ] response = _chat_completion( client, messages, model=model, max_tokens=300, stream=False, temperature=0.85, # slightly higher for varied suggestions ) raw = response.choices[0].message.content suggestions = _parse(raw) logger.info(f"[Suggestions] ✓ Generated: {[s['tone'] for s in suggestions]}") return suggestions except Exception as e: logger.warning(f"[Suggestions] Generation failed ({e}). Using fallback.") return fallback_suggestions(language)