jmisak commited on
Commit
f35855c
·
verified ·
1 Parent(s): 357caaa

Upload responder.py

Browse files
Files changed (1) hide show
  1. responder.py +692 -0
responder.py ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ import threading
5
+ import torch
6
+ from engine.drift import get_current_mode, apply_response_effects, generate_teaching_note
7
+
8
+ from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer
9
+
10
+ # -----------------------------
11
+ # Dispatcher
12
+ # -----------------------------
13
+
14
+ def generate_response(student_prompt, persona, conversation_history, force_mode=None):
15
+ """
16
+ Generate a response from the client persona using AI or fallback logic.
17
+ Priority (when not forced): HF (local transformers) > Claude API > Local Templates
18
+ Returns: (response_text, updated_state, teaching_note)
19
+ """
20
+ try:
21
+ # Explicitly forced to local templates
22
+ if force_mode == "Templates (Local)":
23
+ print("FORCED: Using local templates")
24
+ return generate_response_local(student_prompt, persona, conversation_history)
25
+
26
+ # Explicitly forced to AI (local transformers)
27
+ if force_mode == "AI":
28
+ print("FORCED: Using Hugging Face transformers (AI)")
29
+ return generate_response_hf(student_prompt, persona, conversation_history)
30
+
31
+ # Default priority order if no force_mode
32
+ if os.getenv("HF_TOKEN"):
33
+ print("DEBUG: Attempting Hugging Face transformers generation...")
34
+ return generate_response_hf(student_prompt, persona, conversation_history)
35
+
36
+ if os.getenv("ANTHROPIC_API_KEY"):
37
+ print("DEBUG: Attempting Claude API generation...")
38
+ return generate_response_claude(student_prompt, persona, conversation_history)
39
+
40
+ print("DEBUG: Falling back to local templates")
41
+ return generate_response_local(student_prompt, persona, conversation_history)
42
+
43
+ except Exception as e:
44
+ from engine.utils import safe_log
45
+ safe_log("Response generation error", str(e))
46
+ # If user explicitly asked for AI, don’t silently fall back
47
+ if force_mode == "AI":
48
+ raise
49
+ return generate_response_local(student_prompt, persona, conversation_history)
50
+
51
+ # -----------------------------
52
+ # Local Transformers Generation
53
+ # -----------------------------
54
+
55
+ # Candidate models optimized for HF Spaces (smaller, faster models)
56
+ # These models are specifically chosen for:
57
+ # - Small size (< 1GB) for fast loading
58
+ # - Good instruction following
59
+ # - Fast inference on CPU
60
+ # PRIORITIZED FOR SPEED: TinyLlama first (1.1B = 2.5x faster than Phi-2)
61
+ MODEL_CANDIDATES = [
62
+ "TinyLlama/TinyLlama-1.1B-Chat-v1.0", # 1.1B params, very fast - PRIORITY FOR SPEED
63
+ "microsoft/phi-2", # 2.7B params, excellent quality but slower
64
+ "facebook/opt-350m", # 350M params, fast fallback
65
+ "distilgpt2", # 82M params, extremely fast
66
+ ]
67
+
68
+ _TOKENIZER = None
69
+ _MODEL = None
70
+ _MODEL_NAME = None
71
+
72
+ def _select_dtype():
73
+ """Select appropriate dtype based on available hardware."""
74
+ if torch.cuda.is_available():
75
+ return torch.float16 # Use float16 for GPU (faster than bfloat16 on most GPUs)
76
+ return torch.float32 # CPU uses float32
77
+
78
+ def _ensure_model_loaded():
79
+ """Load the most suitable model for the current environment."""
80
+ global _TOKENIZER, _MODEL, _MODEL_NAME
81
+ if _TOKENIZER is not None:
82
+ return
83
+
84
+ last_error = None
85
+ for model_name in MODEL_CANDIDATES:
86
+ try:
87
+ print(f"Loading model: {model_name}")
88
+ _TOKENIZER = AutoTokenizer.from_pretrained(
89
+ model_name,
90
+ use_fast=True,
91
+ trust_remote_code=True # Some models like Phi-2 need this
92
+ )
93
+
94
+ # Add padding token if not present
95
+ if _TOKENIZER.pad_token is None:
96
+ _TOKENIZER.pad_token = _TOKENIZER.eos_token
97
+
98
+ # Load model with optimizations for HF Spaces
99
+ _MODEL = AutoModelForCausalLM.from_pretrained(
100
+ model_name,
101
+ torch_dtype=_select_dtype(),
102
+ device_map="auto",
103
+ low_cpu_mem_usage=True, # Optimize memory usage
104
+ trust_remote_code=True # Some models need this
105
+ )
106
+
107
+ # Set to eval mode for inference
108
+ _MODEL.eval()
109
+
110
+ _MODEL_NAME = model_name
111
+ print(f"✓ Loaded {model_name} successfully")
112
+ return
113
+ except Exception as e:
114
+ last_error = e
115
+ print(f"✗ Failed to load {model_name}: {str(e)[:200]}")
116
+ continue
117
+ raise RuntimeError(f"Could not load any candidate model. Last error: {last_error}")
118
+
119
+ import re
120
+ import threading
121
+ import torch
122
+ from transformers import TextIteratorStreamer
123
+
124
+ def _select_relevant_facts(facts, prompt, count=5):
125
+ """
126
+ Select most relevant facts based on prompt content.
127
+ Returns a mix of always-relevant facts and prompt-specific ones.
128
+ """
129
+ if not facts:
130
+ return []
131
+
132
+ prompt_lower = prompt.lower()
133
+ scored_facts = []
134
+
135
+ # Keywords to look for in prompt
136
+ keywords = {
137
+ 'work': ['work', 'job', 'boss', 'career', 'coworker', 'supervisor', 'shift', 'office', 'construction'],
138
+ 'family': ['family', 'dad', 'mom', 'brother', 'sister', 'parent', 'son', 'daughter', 'wife', 'husband'],
139
+ 'pain': ['pain', 'hurt', 'ache', 'injury', 'physical', 'body', 'knee', 'back'],
140
+ 'mental': ['feel', 'stress', 'anxiety', 'panic', 'worry', 'scared', 'overwhelm'],
141
+ 'social': ['friend', 'people', 'social', 'lonely', 'isolated', 'relationship'],
142
+ 'leisure': ['hobby', 'fun', 'enjoy', 'free time', 'weekend', 'relax', 'game', 'gaming'],
143
+ 'future': ['future', 'plan', 'goal', 'retirement', 'college', 'next', 'change'],
144
+ 'money': ['money', 'afford', 'cost', 'expensive', 'financial', 'save', 'pay']
145
+ }
146
+
147
+ for fact in facts:
148
+ fact_str = str(fact)
149
+ fact_lower = fact_str.lower()
150
+ score = 1 # Base score
151
+
152
+ # Check for keyword matches
153
+ for category, words in keywords.items():
154
+ if any(word in prompt_lower for word in words):
155
+ if any(word in fact_lower for word in words):
156
+ score += 2
157
+
158
+ scored_facts.append((score, fact_str))
159
+
160
+ # Sort by relevance, take top facts
161
+ scored_facts.sort(reverse=True, key=lambda x: x[0])
162
+ return [fact for score, fact in scored_facts[:count]]
163
+
164
+ def _check_triggers(prompt, triggers):
165
+ """
166
+ Check if prompt contains potentially triggering content.
167
+ Returns True if triggers detected.
168
+ """
169
+ if not triggers:
170
+ return False
171
+
172
+ prompt_lower = prompt.lower()
173
+ for trigger in triggers:
174
+ trigger_lower = str(trigger).lower()
175
+ # Check for key phrases from trigger
176
+ trigger_words = trigger_lower.split()[:3] # First few words often most relevant
177
+ if any(word in prompt_lower for word in trigger_words if len(word) > 3):
178
+ return True
179
+ return False
180
+
181
+ def generate_response_hf(prompt, persona, conversation_history, stream_callback=None):
182
+ """
183
+ Generate a deeply persona-grounded response using local transformers.
184
+ Leverages rich persona data for authentic, psychologically complex responses.
185
+ Supports optional streaming via stream_callback.
186
+ """
187
+ _ensure_model_loaded()
188
+
189
+ name = persona.get("persona_name", "Client")
190
+ age = persona.get("age", "")
191
+ role = persona.get("role", "")
192
+ state = persona.get("default_state", {}) or {}
193
+ mode = get_current_mode(state)
194
+
195
+ # Apply response effects
196
+ state = apply_response_effects(state, prompt)
197
+ mode = get_current_mode(state)
198
+
199
+ # Extract rich persona elements
200
+ system_prompt = persona.get("system_prompt", "").strip()
201
+ facts = persona.get("facts", [])
202
+ triggers = persona.get("triggers", [])
203
+ reasoning_style = persona.get("reasoning_style", "").strip()
204
+ resilience_hooks = persona.get("resilience_hooks", [])
205
+
206
+ # Get tone guidance for current mode
207
+ tone_guidance = persona.get("tone_guidance", {}).get(mode, {})
208
+ tone_voice = tone_guidance.get("voice", "Natural and authentic")
209
+ tone_example = tone_guidance.get("example", "")
210
+
211
+ # Select most relevant facts (mix of general and specific to prompt)
212
+ selected_facts = _select_relevant_facts(facts, prompt, count=3) # Reduced from 5 for faster processing
213
+
214
+ # Check if prompt might trigger defensive response
215
+ is_potentially_triggering = _check_triggers(prompt, triggers)
216
+
217
+ # Extract current situation from emotional memory or conversation history
218
+ current_situation = "Normal day, no specific external stressors right now"
219
+ if state.get("emotional_memory"):
220
+ for memory in reversed(state["emotional_memory"]):
221
+ if memory.startswith("context:"):
222
+ current_situation = memory.replace("context:", "").strip()
223
+ break
224
+
225
+ # Build conversation context (last 2 turns for faster processing)
226
+ context = ""
227
+ if conversation_history:
228
+ for turn in conversation_history[-2:]: # Reduced from 3 to 2 for speed
229
+ if "student" in turn and "client" in turn:
230
+ context += f"Student: {turn['student']}\n{name}: {turn['client']}\n\n"
231
+
232
+ # Build optimized instruction (reduced tokens for speed)
233
+ instruction = f"""You are {name}, {age}, {role}. In OT therapy session.
234
+
235
+ RESPOND as {name} only. 5-6 sentences. Be authentic. NO analysis or questions.
236
+
237
+ BACKGROUND: {system_prompt}
238
+
239
+ LIFE CONTEXT:
240
+ {chr(10).join(f'• {fact}' for fact in selected_facts)}
241
+
242
+ CURRENT SITUATION: {current_situation}
243
+
244
+ EMOTIONAL STATE ({mode}): Anxiety {state.get('anxiety', 0.5):.2f}, Trust {state.get('trust', 0.5):.2f}, Openness {state.get('openness', 0.5):.2f}
245
+
246
+ TONE: {tone_voice} Example: "{tone_example}"
247
+
248
+ """
249
+
250
+ if context:
251
+ instruction += f"""CONVERSATION SO FAR:
252
+ {context}"""
253
+
254
+ instruction += f"""Student: {prompt}
255
+ {name}:"""
256
+
257
+
258
+ # Tokenize
259
+ inputs = _TOKENIZER(instruction, return_tensors="pt", padding=True, truncation=True).to(_MODEL.device)
260
+
261
+ # Streaming setup
262
+ streamer = TextIteratorStreamer(_TOKENIZER, skip_prompt=True, skip_special_tokens=True) if stream_callback else None
263
+
264
+ generation_kwargs = {
265
+ "input_ids": inputs["input_ids"],
266
+ "attention_mask": inputs["attention_mask"],
267
+ "max_new_tokens": 70, # Optimized for 5-6 sentences (10-12 words each) - SPEED PRIORITY
268
+ "min_length": 40, # Ensure minimum response quality
269
+ "temperature": 0.7, # Optimized for speed while maintaining variety
270
+ "top_p": 0.85, # Faster sampling, still good quality
271
+ "do_sample": True,
272
+ "use_cache": True, # Reuse attention computations for speed
273
+ "streamer": streamer,
274
+ "pad_token_id": _TOKENIZER.eos_token_id or _TOKENIZER.pad_token_id,
275
+ "eos_token_id": _TOKENIZER.eos_token_id, # Explicit early stopping
276
+ "repetition_penalty": 1.1, # Reduced from 1.15 for faster generation
277
+ }
278
+
279
+ response_text = ""
280
+
281
+ # Use inference mode for better performance
282
+ with torch.inference_mode():
283
+ if streamer:
284
+ def _consume():
285
+ nonlocal response_text
286
+ for token_text in streamer:
287
+ response_text += token_text
288
+ try:
289
+ stream_callback(token_text)
290
+ except Exception:
291
+ pass
292
+ thread = threading.Thread(target=_consume, daemon=True)
293
+ thread.start()
294
+ _MODEL.generate(**generation_kwargs)
295
+ thread.join()
296
+ else:
297
+ outputs = _MODEL.generate(**generation_kwargs)
298
+ raw_text = _TOKENIZER.decode(outputs[0], skip_special_tokens=True)
299
+ # Strip any echoed instruction
300
+ response_text = raw_text.replace(instruction, "").strip()
301
+
302
+ # Clean response
303
+ response_text = response_text.strip()
304
+ response_text = re.sub(r'---.*?---', '', response_text) # remove separators
305
+ response_text = re.sub(r'\[.*?\]', '', response_text) # remove bracketed notes
306
+ response_text = re.sub(r'^(Student:|' + re.escape(name) + ':)', '', response_text).strip()
307
+
308
+ # Truncate at first sign of role switch
309
+ for stop_token in [f"Student:", f"\nStudent:", f"\n\nStudent:", f"\n{name}:", f"\n\n{name}:"]:
310
+ if stop_token in response_text:
311
+ response_text = response_text.split(stop_token)[0].strip()
312
+ break
313
+
314
+ # Remove meta-commentary (questions for students, analysis, etc.)
315
+ # Stop at any meta-questions or analysis markers
316
+ meta_markers = [
317
+ "<|Question|>", "<|Answer|>", "<|Analysis|>",
318
+ "<|beginning", "<|end", "<|template", "<|conversation", # Template markers
319
+ "\n(a)", "\n(b)", "\n(c)", # Lettered questions
320
+ " : ", ": Identify", ": What", ": How", ": Why", ": Describe", # Colon-separated analysis
321
+ "[Answer:", "[Question:", "[Analysis:", # Bracketed sections
322
+ "What emotions", "How might", "Why do you think", # Question stems
323
+ "This response shows", "Notice how", "Observe that", # Analysis stems
324
+ "Identify the elements", "What possible factors", "Consider how" # More analysis patterns
325
+ ]
326
+ for marker in meta_markers:
327
+ if marker in response_text:
328
+ response_text = response_text.split(marker)[0].strip()
329
+ break
330
+
331
+ # Additional cleanup: remove anything after double colon or bracket patterns
332
+ response_text = re.sub(r'\s*:\s*[A-Z][^.!?]*\?.*$', '', response_text, flags=re.DOTALL) # Remove ": Question..." patterns
333
+ response_text = re.sub(r'\[Answer:.*$', '', response_text, flags=re.DOTALL) # Remove [Answer: ...] patterns
334
+ response_text = re.sub(r'\[Question:.*$', '', response_text, flags=re.DOTALL) # Remove [Question: ...] patterns
335
+ response_text = re.sub(r'<\|[^|]*\|>.*$', '', response_text, flags=re.DOTALL) # Remove <|anything|> patterns
336
+
337
+ # Guard against instruction leakage
338
+ if response_text.lower().startswith("be sure to") or "use correct" in response_text.lower():
339
+ response_text = "I'm doing alright today. Just keeping things running, like always."
340
+
341
+ if not response_text:
342
+ response_text = "Sorry, I didn’t catch that. Could you rephrase?"
343
+
344
+ # Update emotional memory
345
+ if "emotional_memory" in state:
346
+ if not isinstance(state["emotional_memory"], list):
347
+ state["emotional_memory"] = []
348
+ tag = f"{mode}:neutral"
349
+ state["emotional_memory"].append(tag)
350
+ state["emotional_memory"] = state["emotional_memory"][-5:]
351
+
352
+ # Teaching note
353
+ teaching_note = generate_teaching_note(state, prompt, mode)
354
+ teaching_note += f"\n\n💡 Response generated locally with Transformers ({_MODEL_NAME})."
355
+
356
+ return response_text, state, teaching_note
357
+
358
+
359
+ def generate_response_claude(student_prompt, persona, conversation_history):
360
+ """
361
+ Generate response using Claude API (optional premium feature).
362
+ """
363
+ try:
364
+ import anthropic
365
+
366
+ state = persona.get("default_state", {})
367
+ mode = get_current_mode(state)
368
+
369
+ # Apply response effects to state
370
+ state = apply_response_effects(state, student_prompt)
371
+ mode = get_current_mode(state)
372
+
373
+ # Build prompts
374
+ system_prompt = build_system_prompt_for_ai(persona, state, mode)
375
+ conversation_context = build_conversation_context(conversation_history)
376
+
377
+ # Call Claude API
378
+ client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
379
+ message = client.messages.create(
380
+ model="claude-3-5-sonnet-20241022",
381
+ max_tokens=400,
382
+ system=system_prompt,
383
+ messages=[
384
+ {"role": "user", "content": f"{conversation_context}\n\nOT Student: {student_prompt}"}
385
+ ]
386
+ )
387
+
388
+ response_text = message.content[0].text
389
+
390
+ # Update emotional memory
391
+ if "emotional_memory" in state:
392
+ if not isinstance(state["emotional_memory"], list):
393
+ state["emotional_memory"] = []
394
+ memory_tag = determine_memory_tag(student_prompt, mode, state)
395
+ state["emotional_memory"].append(memory_tag)
396
+ state["emotional_memory"] = state["emotional_memory"][-5:]
397
+
398
+ teaching_note = generate_teaching_note(state, student_prompt, mode)
399
+ teaching_note += "\n\n✨ Response generated using Claude AI (Premium)"
400
+
401
+ return response_text, state, teaching_note
402
+
403
+ except Exception as e:
404
+ from engine.utils import safe_log
405
+ safe_log("Claude API error", str(e))
406
+ return generate_response_local(student_prompt, persona, conversation_history)
407
+
408
+
409
+ def generate_response_local(student_prompt, persona, conversation_history):
410
+ """
411
+ Local response generation using persona templates and state-based selection.
412
+ Fallback when no AI available or as primary mode.
413
+ """
414
+ state = persona.get("default_state", {})
415
+ mode = get_current_mode(state)
416
+ name = persona.get("persona_name", "Client")
417
+
418
+ # Apply response effects to state
419
+ state = apply_response_effects(state, student_prompt)
420
+
421
+ # Update mode after response effects
422
+ mode = get_current_mode(state)
423
+
424
+ # Select response based on mode and prompt analysis
425
+ response = select_response_template(
426
+ student_prompt,
427
+ name,
428
+ mode,
429
+ state,
430
+ persona,
431
+ conversation_history
432
+ )
433
+
434
+ # Update emotional memory
435
+ if "emotional_memory" in state:
436
+ if not isinstance(state["emotional_memory"], list):
437
+ state["emotional_memory"] = []
438
+
439
+ memory_tag = determine_memory_tag(student_prompt, mode, state)
440
+ state["emotional_memory"].append(memory_tag)
441
+ state["emotional_memory"] = state["emotional_memory"][-5:]
442
+
443
+ # Generate teaching note
444
+ teaching_note = generate_teaching_note(state, student_prompt, mode)
445
+ teaching_note += "\n\n🔧 Response generated using template system (Local)"
446
+
447
+ return response, state, teaching_note
448
+
449
+
450
+ def build_system_prompt_for_ai(persona, state, mode, student_input):
451
+ """
452
+ Build a detailed system prompt for AI models to generate in-character responses.
453
+ """
454
+ name = persona.get("persona_name", "Client")
455
+ age = persona.get("age", "")
456
+ role = persona.get("role", "")
457
+
458
+ # Get tone guidance for current mode
459
+ tone_guidance = persona.get("tone_guidance", {}).get(mode, {})
460
+ tone_voice = tone_guidance.get("voice", "Natural and authentic")
461
+ tone_example = tone_guidance.get("example", "")
462
+
463
+ # Get some facts about the persona
464
+ facts = persona.get("facts", [])
465
+ key_facts = facts[:5] if isinstance(facts, list) else []
466
+
467
+ # Build system prompt
468
+ system_prompt = f"""You are {name}, a {age}-year-old {role}. You are talking to an occupational therapy student.
469
+
470
+ CRITICAL INSTRUCTIONS:
471
+ - Respond ONLY as {name} – ONE response, then STOP
472
+ - Do NOT generate both sides of the conversation
473
+ - Do NOT include multiple turns or dialogue
474
+ - Your response should be 2–5 sentences maximum
475
+ - Stay completely in character
476
+
477
+ YOUR BACKGROUND:
478
+ {chr(10).join(f"- {fact}" for fact in key_facts)}
479
+
480
+ CURRENT EMOTIONAL STATE:
481
+ - Anxiety: {state.get('anxiety', 0):.2f}/1.0
482
+ - Trust: {state.get('trust', 0):.2f}/1.0
483
+ - Openness: {state.get('openness', 0):.2f}/1.0
484
+
485
+ HOW TO RESPOND ({mode} mode):
486
+ {tone_voice}
487
+ Example: "{tone_example}"
488
+
489
+ Now begin the conversation:
490
+ Student: {student_input}
491
+ {name}:"""
492
+
493
+ return prompt
494
+
495
+
496
+ def build_conversation_context(history):
497
+ """Build context from conversation history for AI models."""
498
+ if not history:
499
+ return "This is the beginning of the conversation."
500
+
501
+ context = "Previous conversation:\n"
502
+ for i, turn in enumerate(history[-3:], 1): # Last 3 turns
503
+ if "student" in turn:
504
+ context += f"Student: {turn['student']}\n"
505
+ if "client" in turn:
506
+ context += f"You: {turn['client']}\n"
507
+
508
+ return context
509
+
510
+ def handle_greeting(name, mode, state, persona):
511
+ """Generate responses for initial greetings."""
512
+ if name == "Jack":
513
+ if mode == "guarded":
514
+ return "Hey. So... what exactly are we doing here?"
515
+ else:
516
+ return "Hi. I'm Jack. Not really sure what to expect from this, but... yeah, here I am."
517
+ else: # Maya
518
+ if mode == "anxious_but_functional":
519
+ return "Hi. Um, thanks for meeting with me. I've been... well, things have been a lot lately."
520
+ else:
521
+ return "Hello. I'm Maya. I appreciate you taking the time to talk with me."
522
+
523
+ def select_response_template(prompt, name, mode, state, persona, history):
524
+ """
525
+ Select and customize a response based on the current mode and prompt content.
526
+ Used for local fallback when AI is unavailable.
527
+ """
528
+ prompt_lower = prompt.lower()
529
+
530
+ # Handle greetings/introductions FIRST
531
+ if not history and any(word in prompt_lower for word in ["hi", "hello", "hey", "good morning", "good afternoon"]):
532
+ return handle_greeting(name, mode, state, persona)
533
+
534
+ # Check for specific scenario triggers
535
+ if is_crisis_query(prompt_lower) and mode == "decompensating":
536
+ scripts = persona.get("scripts", {})
537
+ return scripts.get("crisis", "I don't feel safe right now. I need to pause.")
538
+
539
+ # Check if prompt is about specific topics
540
+ if any(word in prompt_lower for word in ["work", "job", "boss", "brother", "supervisor"]):
541
+ return handle_work_topic(name, mode, state, persona, prompt_lower)
542
+
543
+ if any(word in prompt_lower for word in ["pain", "hurt", "physical", "body"]):
544
+ return handle_pain_topic(name, mode, state, persona)
545
+
546
+ if any(word in prompt_lower for word in ["feel", "feeling", "emotion"]):
547
+ return handle_feelings_topic(name, mode, state, persona, prompt_lower)
548
+
549
+ if any(word in prompt_lower for word in ["family", "dad", "sister", "parent"]):
550
+ return handle_family_topic(name, mode, state, persona)
551
+
552
+ # Default mode-based responses
553
+ return get_mode_based_response(name, mode, state, persona)
554
+
555
+
556
+ def is_crisis_query(prompt_lower):
557
+ """Check if the prompt is asking about crisis/safety."""
558
+ crisis_terms = ["safe", "hurt yourself", "suicide", "end", "can't take"]
559
+ return any(term in prompt_lower for term in crisis_terms)
560
+
561
+
562
+ def handle_work_topic(name, mode, state, persona, prompt_lower):
563
+ """Generate responses about work-related topics."""
564
+ if name == "Jack":
565
+ if mode == "triggered" or mode == "guarded":
566
+ return "I'd rather not get into it. Work is work, you know?"
567
+ elif mode == "trusting":
568
+ return "My brother's been on my case all week. It's like... I can't do anything right in his eyes. And my dad just backs him up because 'he's the foreman.' It's frustrating."
569
+ else:
570
+ return "Work's... fine. Same stuff, different day. Framing houses, dealing with Mike being Mike."
571
+ else: # Maya
572
+ if mode == "triggered" or mode == "guarded":
573
+ return "It's just work stress. Everyone deals with it, right?"
574
+ elif mode == "trusting":
575
+ return "Honestly? I feel like I'm drowning. Between agency work and freelance projects, I'm just... constantly behind. And my review is coming up, so there's that pressure too."
576
+ else:
577
+ return "Work's been busy. Lots of deadlines. The usual design agency chaos."
578
+
579
+
580
+ def handle_pain_topic(name, mode, state, persona):
581
+ """Generate responses about physical pain."""
582
+ pain_level = state.get("physical_discomfort", 0.5)
583
+
584
+ if name == "Jack":
585
+ if pain_level > 0.6:
586
+ if mode == "trusting":
587
+ return "My knee's been killing me lately. Some days I'm limping by noon. I used to be able to do so much more physically, and now... yeah, it's frustrating."
588
+ else:
589
+ return "It's whatever. I just take some ibuprofen and push through. Not like I have a choice."
590
+ else:
591
+ return "Knee's okay today. Manageable."
592
+ else: # Maya
593
+ if pain_level > 0.6:
594
+ if mode == "trusting":
595
+ return "The headaches are almost daily now, and my wrists hurt when I'm working. I keep thinking, what if I'm doing permanent damage? But I can't afford to stop working."
596
+ else:
597
+ return "I get headaches sometimes. Probably just from staring at screens all day. Everyone in design deals with it."
598
+ else:
599
+ return "Physically I'm okay. Just the usual screen fatigue."
600
+
601
+
602
+ def handle_feelings_topic(name, mode, state, persona, prompt_lower):
603
+ """Generate responses about emotions and feelings."""
604
+ anxiety = state.get("anxiety", 0.5)
605
+
606
+ if mode == "decompensating":
607
+ return "I don't... everything's just a lot right now. I can't really explain it. I'm just overwhelmed."
608
+
609
+ if mode == "triggered" or mode == "guarded":
610
+ if "about" in prompt_lower:
611
+ return "I don't know. Fine, I guess?"
612
+ else:
613
+ return "I'm fine. Just tired."
614
+
615
+ if mode == "trusting":
616
+ if name == "Jack":
617
+ if anxiety > 0.6:
618
+ return "Honestly? Anxious. Like there's this constant pressure I can't shake. Work, family expectations, feeling stuck... it all just builds up."
619
+ else:
620
+ return "Better than I have been, actually. Still stressed, but like... manageable stress?"
621
+ else: # Maya
622
+ if anxiety > 0.6:
623
+ return "Overwhelmed, mostly. And scared that I'm not good enough for this. Everyone else seems to handle everything so much better than me."
624
+ else:
625
+ return "I'm doing okay. Some days are harder than others, but I'm managing."
626
+
627
+ return "I'm alright. Just dealing with the usual stuff."
628
+
629
+
630
+ def handle_family_topic(name, mode, state, persona):
631
+ """Generate responses about family relationships."""
632
+ if name == "Jack":
633
+ if mode == "triggered":
634
+ return "Can we talk about something else?"
635
+ elif mode == "trusting":
636
+ return "My dad and I mostly just coexist. He works a lot, I work a lot. My brother... that's complicated since he's also my boss. Mom moved to Arizona years ago."
637
+ else:
638
+ return "Family's fine. Nothing new there."
639
+ else: # Maya
640
+ if mode == "triggered":
641
+ return "I don't really want to get into family stuff right now."
642
+ elif mode == "trusting":
643
+ return "My parents are supportive but they don't really understand creative work. My sister's a nurse practitioner and everyone's always comparing us. It's... yeah, it's a thing."
644
+ else:
645
+ return "Family's good. I talk to them pretty regularly."
646
+
647
+
648
+ def get_mode_based_response(name, mode, state, persona):
649
+ """Generate generic response based on current emotional mode."""
650
+ resilience_hooks = persona.get("resilience_hooks", [])
651
+ scripts = persona.get("scripts", {})
652
+
653
+ if mode == "decompensating":
654
+ return scripts.get("crisis", "I need to step away. This is too much right now.")
655
+
656
+ if mode == "triggered":
657
+ return scripts.get("resistance", "I'm not really in the mood to talk about this.")
658
+
659
+ if mode == "guarded":
660
+ return scripts.get("deflection", "It's not that deep. I'm just tired.")
661
+
662
+ if mode == "trusting" and resilience_hooks:
663
+ return f"You know what? {resilience_hooks[0]}"
664
+
665
+ if mode == "recovering":
666
+ return "I'm feeling a bit better actually. Still working through things, but... yeah, better."
667
+
668
+ # Baseline
669
+ return "I'm doing okay. What did you want to talk about?"
670
+
671
+
672
+ def determine_memory_tag(prompt, mode, state):
673
+ """Generate an emotional memory tag based on the interaction."""
674
+ prompt_lower = prompt.lower()
675
+
676
+ if mode == "trusting":
677
+ if any(word in prompt_lower for word in ["understand", "hear you", "makes sense"]):
678
+ return "felt validated"
679
+ return "felt safe to open up"
680
+
681
+ if mode == "triggered":
682
+ if any(word in prompt_lower for word in ["should", "need to", "why don't"]):
683
+ return "felt criticized"
684
+ return "felt defensive"
685
+
686
+ if mode == "guarded":
687
+ return "felt cautious"
688
+
689
+ if mode == "decompensating":
690
+ return "felt overwhelmed"
691
+
692
+ return "shared thoughts"