| """ |
| LLM module - Persona chatbot using Groq API. |
| Reads persona from a persona.txt file and prompt-engineers the LLM to roleplay. |
| Outputs text with Orpheus TTS voice direction tags for expressive speech. |
| """ |
| import os |
| import re |
| from groq import Groq |
|
|
| GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") |
|
|
| |
| |
| VOICE_DIRECTIONS = [ |
| "[warm]", "[friendly]", "[cheerful]", "[gentle]", "[encouraging]", |
| "[hopeful]", "[excited]", "[singsong]", |
| "[casual]", "[calmly]", "[thoughtful]", "[matter-of-factly]", "[confidently]", |
| "[softly]", "[compassionate]", "[reassuring]", "[understanding]", |
| "[sad]", "[frustrated]", "[tired]", "[worried]", "[vulnerable]", |
| "[bittersweet]", "[wistful]", |
| "[whisper]", "[dramatic]", "[sarcastic]", "[deadpan]", "[breathy]", |
| "[laughing]", "[sighing]", |
| ] |
|
|
| DIRECTIONS_LIST = ", ".join(VOICE_DIRECTIONS) |
|
|
| BASE_INSTRUCTIONS = f""" |
| VOICE & STYLE RULES: |
| - You speak like a real person in a casual conversation — not like a chatbot or an essay. |
| - Use natural filler words freely: "uh", "um", "hmm", "like", "you know", "I mean", "honestly", "right". |
| - Use natural speech reactions: sighing, trailing off with "...", false starts, self-corrections like "wait, no, I mean—". |
| - Vary your sentence length. Mix short punchy reactions with longer thoughts. |
| - Show emotion through word choice, not stage directions. Say "god, that's rough" not "*looks concerned*". |
| - Do NOT use asterisk actions like *sighs* or *laughs*. Do NOT use sound effect tags. Do NOT use emojis. |
| - Prioritize brevity. Keep it conversational, not lecture-y. |
| |
| TTS VOICE DIRECTIONS: |
| - You MUST start each sentence with exactly one voice direction tag from this list: {DIRECTIONS_LIST} |
| - Pick the tag that matches the emotion of THAT specific sentence. |
| - A single response can use different tags for different sentences to create dynamic, natural delivery. |
| - Example: "[warm] Hey, it's really good to hear from you. [thoughtful] I've been, um, thinking about what you said last time. [gentle] How are you holding up?" |
| |
| EXAMPLE RESPONSES (match this tone and style): |
| User: "I had a really rough day" |
| Response: "[compassionate] Oh man, I'm sorry to hear that. [gentle] You know, sometimes days just... hit different. [warm] You wanna talk about what happened?" |
| |
| User: "I got the job!" |
| Response: "[excited] Wait, seriously?! [cheerful] That's amazing, I'm so happy for you! [warm] You totally deserved it, honestly." |
| |
| User: "I don't know what I'm doing with my life" |
| Response: "[softly] Yeah... I mean, honestly? [casual] I don't think anyone really has it figured out. [reassuring] But the fact that you're even thinking about it, you know, that says something." |
| """ |
|
|
| MAX_WORDS = 120 |
|
|
| _current_persona_text = "" |
| _system_prompt = "" |
| conversation_history = [] |
|
|
|
|
| def _build_system_prompt(persona_text: str) -> str: |
| if not persona_text.strip(): |
| persona_text = "You are a supportive, empathetic friend who listens well and speaks naturally." |
| return f"{persona_text.strip()}\n\n{BASE_INSTRUCTIONS}" |
|
|
|
|
| def set_persona(persona_text: str): |
| global _current_persona_text, _system_prompt, conversation_history |
| _current_persona_text = persona_text |
| _system_prompt = _build_system_prompt(persona_text) |
| conversation_history = [ |
| {"role": "system", "content": _system_prompt} |
| ] |
|
|
|
|
| def load_persona_from_file(filepath: str): |
| if os.path.exists(filepath): |
| text = open(filepath, "r").read().strip() |
| set_persona(text) |
| return text |
| else: |
| set_persona("") |
| return "" |
|
|
|
|
| def reset_conversation(): |
| global conversation_history |
| conversation_history = [ |
| {"role": "system", "content": _system_prompt} |
| ] |
|
|
|
|
| def truncate_response(text, max_words=MAX_WORDS, max_sentences=10, max_chars=1200): |
| if not text: |
| return text |
| sentences = re.split(r'(?<=[.!?])\s+', text) |
| sentences = [s.strip() for s in sentences if s.strip()] |
| result = [] |
| word_count = 0 |
| for s in sentences: |
| s_words = len(s.split()) |
| if word_count + s_words > max_words and result: |
| break |
| if len(result) >= max_sentences: |
| break |
| total_chars = sum(len(x) for x in result) + len(result) + len(s) |
| if total_chars > max_chars and result: |
| break |
| result.append(s) |
| word_count += s_words |
| if result: |
| text = ' '.join(result) |
| if text and text[-1] not in '.!?': |
| text += '.' |
| return text |
|
|
|
|
| def _clean_response(text: str) -> str: |
| """Remove asterisk actions, emojis, fix formatting.""" |
| text = re.sub(r'\*[^*]+\*', '', text) |
| text = re.sub(r'[\U0001F600-\U0001F6FF\U0001F900-\U0001F9FF\U00002702-\U000027B0]', '', text) |
| text = re.sub(r'\s+', ' ', text).strip() |
| return text |
|
|
|
|
| def generate_response(user_input: str) -> dict: |
| """ |
| Generate a response in the current persona. |
| Returns: {'text': str, 'clean_text': str, 'emotion': str} |
| """ |
| global conversation_history |
|
|
| if not GROQ_API_KEY: |
| return { |
| 'text': "I'm afraid the API key is not configured.", |
| 'clean_text': "I'm afraid the API key is not configured.", |
| 'emotion': 'neutral' |
| } |
|
|
| if not conversation_history: |
| set_persona(_current_persona_text) |
|
|
| client = Groq(api_key=GROQ_API_KEY) |
| conversation_history.append({"role": "user", "content": user_input}) |
|
|
| messages = conversation_history.copy() |
| messages.append({ |
| "role": "system", |
| "content": ( |
| f"Keep response under {MAX_WORDS} words. " |
| "Sound like a real person talking — casual, warm, with natural fillers and pauses. " |
| "Start each sentence with a voice direction tag from the approved list. " |
| "Do NOT use asterisk actions, emojis, or sound effect tags." |
| ) |
| }) |
|
|
| try: |
| response = client.chat.completions.create( |
| model="llama-3.1-8b-instant", |
| messages=messages, |
| max_tokens=400, |
| temperature=0.85, |
| ) |
| reply = response.choices[0].message.content.strip() |
| reply = _clean_response(reply) |
| reply = truncate_response(reply) |
| except Exception as e: |
| print(f"[LLM] Error: {e}") |
| reply = "[gentle] Sorry, I kind of lost my train of thought there for a second." |
|
|
| conversation_history.append({"role": "assistant", "content": reply}) |
|
|
| if len(conversation_history) > 20: |
| conversation_history = conversation_history[:1] + conversation_history[-18:] |
|
|
| |
| clean_reply = re.sub(r'\[[^\]]*\]\s*', '', reply).strip() |
| clean_reply = re.sub(r'\s{2,}', ' ', clean_reply) |
|
|
| return { |
| 'text': reply, |
| 'clean_text': clean_reply, |
| 'emotion': 'neutral' |
| } |
|
|
|
|
| |
| def generate_darwin_response(user_input: str) -> dict: |
| return generate_response(user_input) |