File size: 6,996 Bytes
3283b8e 5da2e4d beed52f 3283b8e beed52f 3283b8e beed52f 3283b8e 5da2e4d beed52f 5da2e4d 3283b8e 5da2e4d 3283b8e beed52f 3283b8e 556a7c0 3283b8e 556a7c0 3283b8e beed52f 5da2e4d 3283b8e 5da2e4d 93203e3 3283b8e beed52f 93203e3 3283b8e 5da2e4d 3283b8e 5da2e4d 3283b8e beed52f 3283b8e beed52f 3283b8e beed52f 3283b8e beed52f 3283b8e beed52f 93203e3 beed52f 93203e3 5da2e4d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | """
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", "")
# ---- TTS Voice Direction Tags ----
# The LLM picks from this list only — it should NOT invent its own.
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:]
# Strip tags for UI display
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'
}
# Backward compat
def generate_darwin_response(user_input: str) -> dict:
return generate_response(user_input) |