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)