Spaces:
Sleeping
Sleeping
| import json | |
| import os | |
| from engine.drift import get_current_mode, apply_response_effects, generate_teaching_note | |
| from transformers import AutoTokenizer, AutoModelForCausalLM | |
| import torch | |
| # Load local model once for fast inference | |
| # Optimized for CPU: Using TinyLlama (smaller, faster) instead of Phi-2 | |
| local_tokenizer = AutoTokenizer.from_pretrained("TinyLlama/TinyLlama-1.1B-Chat-v1.0", trust_remote_code=True) | |
| local_model = AutoModelForCausalLM.from_pretrained( | |
| "TinyLlama/TinyLlama-1.1B-Chat-v1.0", | |
| torch_dtype=torch.float32, # float32 for CPU | |
| device_map="cpu", # Force CPU | |
| trust_remote_code=True, | |
| low_cpu_mem_usage=True | |
| ) | |
| # Enable CPU optimizations | |
| import torch | |
| torch.set_num_threads(4) # Adjust based on your CPU cores | |
| # Set padding token | |
| if local_tokenizer.pad_token is None: | |
| local_tokenizer.pad_token = local_tokenizer.eos_token | |
| from huggingface_hub import InferenceClient | |
| # Hugging Face Inference API | |
| def generate_response(student_prompt, persona, conversation_history, force_mode=None, use_fast_mode=False): | |
| try: | |
| # Priority: API tokens override fast mode checkbox | |
| # This ensures HuggingFace Spaces with secrets work correctly | |
| if os.getenv("HF_TOKEN"): | |
| print("[INFO] Using HuggingFace Inference API") | |
| return generate_response_hf(student_prompt, persona, conversation_history, force_mode=force_mode) | |
| elif os.getenv("ANTHROPIC_API_KEY"): | |
| print("[INFO] Using Claude API") | |
| return generate_response_claude(student_prompt, persona, conversation_history, force_mode=force_mode) | |
| else: | |
| # No API tokens - use local model | |
| print("[INFO] No API tokens found, using local TinyLlama model") | |
| return generate_response_local(student_prompt, persona, conversation_history, force_mode=force_mode) | |
| except Exception as e: | |
| from engine.utils import safe_log | |
| safe_log("Response generation error", str(e)) | |
| # Always fall back to local on error | |
| return generate_response_local( | |
| student_prompt, | |
| persona, | |
| conversation_history, | |
| force_mode=force_mode | |
| ) | |
| def generate_response_hf(student_prompt, persona, conversation_history, force_mode=None): | |
| """Generate response using Hugging Face Inference API (free, non-gated models).""" | |
| try: | |
| from huggingface_hub import InferenceClient | |
| state = persona.get("default_state", {}).copy() | |
| if force_mode: | |
| state["mode"] = force_mode | |
| mode = get_current_mode(state) | |
| state = apply_response_effects(state, student_prompt) | |
| mode = get_current_mode(state) | |
| name = persona.get("persona_name", "Client") | |
| age = persona.get("age", "") | |
| system_description = persona.get("system_prompt", "") | |
| tone = persona.get("tone_guidance", {}).get(mode, {}) | |
| tone_voice = tone.get("voice", "Natural") | |
| # Get key facts for factual accuracy | |
| facts = persona.get("facts", []) | |
| background_facts = "\n".join(f"- {fact}" for fact in facts) | |
| # Get source text reference if available | |
| source_text = persona.get("source_text", {}) | |
| source_reference = "" | |
| if source_text: | |
| title = source_text.get("title", "") | |
| author = source_text.get("author", "") | |
| note = source_text.get("note", "") | |
| if title and author: | |
| source_reference = f"\n\nYou are {name} from '{title}' by {author}." | |
| if note: | |
| source_reference += f" {note}" | |
| # Get example responses from persona | |
| tone_examples = persona.get("speech_style", {}).get("examples", []) | |
| example_text = "" | |
| if tone_examples and len(tone_examples) > 0: | |
| example_text = f"\n\nExample of how you speak:\n{tone_examples[0]}" | |
| # Build focused system prompt for HF API with few-shot examples | |
| # Check if we have source text to reference | |
| has_source = source_text and source_text.get("title") and source_text.get("author") | |
| if has_source: | |
| # For characters with source texts, provide facts as foundation but allow drawing on training data | |
| system_prompt = f"""You are {name}. You speak as yourself in first person.{source_reference} | |
| CORE FACTS ABOUT YOUR STORY: | |
| {background_facts} | |
| Current mood: {mode}{example_text} | |
| CRITICAL RULES: | |
| - Speak as {name} in first person only | |
| - The facts above are your foundation - use them as the authoritative baseline | |
| - If you have knowledge of the source text from your training, you may draw on it carefully | |
| - If asked about something not covered in the facts and you don't know from the source, say you're not sure | |
| - Answer inat least three sentences and in detail | |
| - Do NOT invent new details, analyze yourself, write commentary, or break character | |
| - NEVER contradict the facts listed above""" | |
| else: | |
| system_prompt = f"""You are {name}. You speak as yourself in first person. | |
| YOUR STORY (These are the ONLY facts - do not add anything else): | |
| {background_facts} | |
| Current mood: {mode}{example_text} | |
| Rules: | |
| - Speak as {name} in first person | |
| - Use ONLY the facts above | |
| - Answer in several detailed sentences | |
| - Do NOT analyze yourself or write commentary""" | |
| messages = [{"role": "system", "content": system_prompt}] | |
| # Add few-shot examples if available from persona | |
| if tone_examples and len(tone_examples) >= 2: | |
| # Add example Q&A to guide the model | |
| messages.append({"role": "user", "content": "Tell me about yourself."}) | |
| messages.append({"role": "assistant", "content": tone_examples[0]}) | |
| # Add conversation history | |
| for turn in conversation_history[-2:]: # Reduced to last 2 turns to save space | |
| if "student" in turn: | |
| messages.append({"role": "user", "content": turn["student"]}) | |
| if "client" in turn: | |
| messages.append({"role": "assistant", "content": turn["client"]}) | |
| messages.append({"role": "user", "content": student_prompt}) | |
| print("[DEBUG] Prompt sent to model:") | |
| import pprint | |
| pprint.pprint(messages) | |
| client = InferenceClient(token=os.getenv("HF_TOKEN")) | |
| try: | |
| # Try multiple models in case one fails | |
| models_to_try = [ | |
| # "google/gemma-2-9b-it", # Primary - best quality/speed balance | |
| "meta-llama/Meta-Llama-3.1-8B-Instruct", # Backup - very good | |
| "Qwen/Qwen2.5-7B-Instruct", # Fallback - solid | |
| "mistralai/Mistral-7B-Instruct-v0.3" # Last resort | |
| ] | |
| response_text = None | |
| for model_name in models_to_try: | |
| try: | |
| print(f"[DEBUG] Trying model: {model_name}") | |
| response = client.chat_completion( | |
| messages=messages, | |
| model=model_name, | |
| max_tokens=400, | |
| temperature=0.8, | |
| top_p=0.9, | |
| stream=False | |
| ) | |
| response_text = response.choices[0].message.content.strip() | |
| print(f"[DEBUG] Success with model: {model_name}") | |
| break | |
| except Exception as model_err: | |
| print(f"[DEBUG] Model {model_name} failed: {str(model_err)}") | |
| continue | |
| if not response_text: | |
| raise Exception("All HF models failed") | |
| print(f"[DEBUG] Raw model response: {response_text}") | |
| # Clean up meta-commentary if it appears | |
| import re | |
| # Manual stop sequence handling - truncate at these markers | |
| stop_markers = [ | |
| f"\n{name}:", "\nStudent:", "\nInterviewer:", | |
| "\n\nStudent:", "\n\nInterviewer:", | |
| "The question doesn't", "However, the given", | |
| "The given text", "Note that the" | |
| ] | |
| for marker in stop_markers: | |
| if marker in response_text: | |
| response_text = response_text.split(marker)[0].strip() | |
| print(f"[DEBUG] Truncated at stop marker: {marker}") | |
| break | |
| # Check if response starts with a quote (good sign - it's in character) | |
| starts_with_quote = response_text.startswith('"') | |
| # Check if response contains meta-commentary about the question or conversation | |
| # Only check for the most egregious cases | |
| meta_commentary_markers = [ | |
| "the question doesn't", "however, the given", "note that the", | |
| "the given text", "this doesn't follow", "the context of the conversation" | |
| ] | |
| response_lower = response_text.lower() | |
| has_meta_commentary = any(marker in response_lower for marker in meta_commentary_markers) | |
| if has_meta_commentary: | |
| # Response has meta-commentary - use fallback instead | |
| print(f"[WARNING] Detected meta-commentary in response") | |
| response_text = generate_fallback_response(student_prompt, name, mode, state, persona) | |
| else: | |
| # Only remove the most obvious meta-commentary patterns | |
| # Be less aggressive to preserve good responses | |
| meta_patterns = [ | |
| r'The question doesn\'?t.*?\.', | |
| r'However, the.*?\.', | |
| r'Note that.*?\.', | |
| r'The given text.*?\.', | |
| ] | |
| for pattern in meta_patterns: | |
| response_text = re.sub(pattern, '', response_text, flags=re.IGNORECASE) | |
| # Clean up extra whitespace | |
| response_text = re.sub(r'\s+', ' ', response_text).strip() | |
| # Remove quotes at the start and end if present (common LLM artifact) | |
| if response_text.startswith('"') and response_text.endswith('"'): | |
| response_text = response_text[1:-1].strip() | |
| print(f"[DEBUG] Cleaned response: {response_text}") | |
| # Check for common hallucination patterns (but only obvious ones) | |
| hallucination_indicators = [ | |
| 'college', 'university', 'degree', 'graduated', | |
| 'inheritance', 'grandmother passed away', 'grandfather', | |
| ] | |
| # Check if response contains hallucinations not in facts | |
| response_lower_check = response_text.lower() | |
| facts_lower = background_facts.lower() | |
| found_hallucination = False | |
| for indicator in hallucination_indicators: | |
| if indicator in response_lower_check and indicator not in facts_lower: | |
| # This looks like a hallucination - regenerate with stronger constraints | |
| print(f"[WARNING] Detected potential hallucination: '{indicator}' not in facts") | |
| # Use fallback instead | |
| response_text = generate_fallback_response(student_prompt, name, mode, state, persona) | |
| found_hallucination = True | |
| break | |
| # Final check: if response is too short after cleaning, use fallback | |
| # But only if we haven't already used fallback | |
| if not found_hallucination and len(response_text) < 15: | |
| print(f"[WARNING] Response too short after cleaning: '{response_text}'") | |
| response_text = generate_fallback_response(student_prompt, name, mode, state, persona) | |
| except Exception as model_error: | |
| from engine.utils import safe_log | |
| safe_log("HF model Phi-3-mini failed", str(model_error)) | |
| print(f"[ERROR] HF API call failed: {str(model_error)}") | |
| import traceback | |
| traceback.print_exc() | |
| response_text = None | |
| if not response_text: | |
| print("[WARNING] No response from HF model (empty or None), using fallback") | |
| response_text = generate_fallback_response(student_prompt, name, mode, state, persona) | |
| elif len(response_text) < 10: | |
| print(f"[WARNING] Response too short: '{response_text}', using fallback") | |
| response_text = generate_fallback_response(student_prompt, name, mode, state, persona) | |
| if "emotional_memory" in state: | |
| if not isinstance(state["emotional_memory"], list): | |
| state["emotional_memory"] = [] | |
| memory_tag = tag_emotional_memory(student_prompt, mode, state) | |
| state["emotional_memory"].append(memory_tag) | |
| state["emotional_memory"] = state["emotional_memory"][-5:] | |
| teaching_note = generate_teaching_note(state, student_prompt, mode, persona) | |
| teaching_note += "\n\n💡 Response generated using HuggingFace Inference API" | |
| return response_text, state, teaching_note | |
| except Exception as e: | |
| from engine.utils import safe_log | |
| safe_log("HF Inference API error", str(e)) | |
| print(f"[INFO] HF API failed, falling back to local model") | |
| return generate_response_local(student_prompt, persona, conversation_history, force_mode=force_mode) | |
| def generate_response_claude(student_prompt, persona, conversation_history, force_mode=None): | |
| """ | |
| Generate response using Claude API (optional premium feature). | |
| """ | |
| try: | |
| import anthropic | |
| state = persona.get("default_state", {}) | |
| mode = get_current_mode(state) | |
| # Apply response effects to state | |
| state = apply_response_effects(state, student_prompt) | |
| mode = get_current_mode(state) | |
| # Build prompts | |
| system_prompt = build_system_prompt_for_ai(persona, state, mode) | |
| conversation_context = build_conversation_context(conversation_history) | |
| # Call Claude API | |
| client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) | |
| message = client.messages.create( | |
| model="claude-3-5-sonnet-20241022", | |
| max_tokens=400, | |
| system=system_prompt, | |
| messages=[ | |
| {"role": "user", "content": f"{conversation_context}\n\nOT Student: {student_prompt}"} | |
| ] | |
| ) | |
| response_text = message.content[0].text | |
| # Update emotional memory | |
| if "emotional_memory" in state: | |
| if not isinstance(state["emotional_memory"], list): | |
| state["emotional_memory"] = [] | |
| memory_tag = tag_emotional_memory(student_prompt, mode, state) | |
| state["emotional_memory"].append(memory_tag) | |
| state["emotional_memory"] = state["emotional_memory"][-5:] | |
| teaching_note = generate_teaching_note(state, student_prompt, mode, persona) | |
| teaching_note += "\n\n✨ Response generated using Claude AI (Premium)" | |
| return response_text, state, teaching_note | |
| except Exception as e: | |
| from engine.utils import safe_log | |
| safe_log("Claude API error", str(e)) | |
| return generate_response_local(student_prompt, persona, conversation_history, force_mode=force_mode) | |
| def generate_fallback_response(prompt, name, mode, state, persona): | |
| """Minimal fallback response using emotional state and persona scripts.""" | |
| scripts = persona.get("scripts", {}) | |
| resilience = persona.get("resilience_hooks", []) | |
| tone_guidance = persona.get("tone_guidance", {}) | |
| # Try to answer based on the question topic | |
| prompt_lower = prompt.lower() | |
| # Check if question is about Frank or leaving | |
| if name == "Eveline": | |
| # Check if question is about Frank specifically | |
| if 'frank' in prompt_lower: | |
| return "Frank wanted to take me to Buenos Aires. He was a sailor... kind, I think. It's been ten years now. I wonder if he's still there, if he thinks about that night. Sometimes I do." | |
| # Check if question is about why they stayed/didn't leave | |
| if any(word in prompt_lower for word in ['why', 'stay', 'stayed', 'leave', 'left', 'go', 'went', "didn't"]): | |
| # Use tone guidance examples based on current mode | |
| if mode in ["trusting", "honest", "reflective", "baseline"]: | |
| honest_response = tone_guidance.get("honest", {}).get("example", "") | |
| if honest_response: | |
| return honest_response | |
| # Try reflective if honest not available | |
| reflective_response = tone_guidance.get("reflective", {}).get("example", "") | |
| if reflective_response: | |
| return reflective_response | |
| return "I was afraid. That's the real reason. I can say it was duty, say it was my mother's promise, but... I was terrified. And I let that fear decide everything." | |
| elif mode == "defensive": | |
| defensive_response = tone_guidance.get("defensive", {}).get("example", "") | |
| if defensive_response: | |
| return defensive_response | |
| return "I couldn't just leave. There were responsibilities. My father needed... well, he needed someone. Even if he never said it." | |
| elif mode == "wistful": | |
| wistful_response = tone_guidance.get("wistful", {}).get("example", "") | |
| if wistful_response: | |
| return wistful_response | |
| else: | |
| reflective_response = tone_guidance.get("reflective", {}).get("example", "") | |
| if reflective_response: | |
| return reflective_response | |
| return "I did what I thought was right. I mean, I had to, didn't I? My mother asked me to... But sometimes I wonder if she'd want me to still be here. Alone." | |
| # Get quote from current mode's tone guidance | |
| tone = tone_guidance.get(mode, {}) | |
| quote = tone.get("example", "") | |
| # Emotional fallback logic | |
| if mode == "decompensating": | |
| return scripts.get("crisis", "I need to step away. This is too much right now.") | |
| if mode == "triggered": | |
| return scripts.get("resistance", "I will not speak of that.") | |
| if mode == "guarded": | |
| return scripts.get("deflection", "It's not something I want to talk about.") | |
| if mode == "trusting": | |
| if resilience: | |
| return resilience[0] | |
| return scripts.get("breakthrough", quote or "I think I'm ready to say more.") | |
| if mode == "recovering": | |
| return "I'm still sorting through things. But I'm here." | |
| # Baseline fallback | |
| return quote or "I'm doing okay. What did you want to talk about?" | |
| def generate_response_local(student_prompt, persona, conversation_history, force_mode=None): | |
| """ | |
| Local response generation using optimized Phi-2 model. | |
| Fast mode for quick inference (~6 seconds). | |
| """ | |
| state = persona.get("default_state", {}).copy() | |
| if force_mode: | |
| state["mode"] = force_mode | |
| mode = get_current_mode(state) | |
| name = persona.get("persona_name", "Client") | |
| # Apply response effects | |
| state = apply_response_effects(state, student_prompt) | |
| mode = get_current_mode(state) | |
| # Build concise prompt for faster generation | |
| system_prompt = build_system_prompt_for_ai(persona, state, mode) | |
| context = build_conversation_context(conversation_history) | |
| # Optimized prompt format for TinyLlama chat model | |
| # Keep it very simple to avoid role confusion | |
| full_prompt = f"""<|system|> | |
| You are {name}. You will answer as {name} speaking. Give ONE short response only. | |
| Do NOT write conversations, dialogues, or responses from other people. | |
| {system_prompt} | |
| </s> | |
| <|user|> | |
| {student_prompt} | |
| </s> | |
| <|assistant|> | |
| {name} replies: """ | |
| # Tokenize with optimizations | |
| inputs = local_tokenizer( | |
| full_prompt, | |
| return_tensors="pt", | |
| truncation=True, | |
| max_length=1024, | |
| padding=False | |
| ).to(local_model.device) | |
| # Fast generation with optimized parameters for CPU | |
| with torch.no_grad(): | |
| outputs = local_model.generate( | |
| **inputs, | |
| max_new_tokens=80, # Reduced to keep responses focused | |
| min_new_tokens=15, # Ensure substantial response | |
| temperature=0.7, # Lower temperature for more focused output | |
| top_p=0.85, # Slightly lower to reduce randomness | |
| top_k=40, # Add top-k sampling for better quality | |
| do_sample=True, | |
| repetition_penalty=1.2, # Higher to strongly discourage repetition | |
| pad_token_id=local_tokenizer.eos_token_id, | |
| eos_token_id=local_tokenizer.eos_token_id, | |
| num_beams=1, # Faster than beam search | |
| use_cache=True # Enable KV cache for faster generation | |
| ) | |
| # Decode only the new tokens | |
| generated_text = local_tokenizer.decode(outputs[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True) | |
| # Extract character reply (stop at next speaker turn, but allow multi-sentence responses) | |
| response = generated_text.strip() | |
| # Remove everything after these markers (next speaker turn or role confusion) | |
| stop_markers = [ | |
| "Student:", "Interviewer:", "<|user|>", "<|system|>", "OT Student:", | |
| "\n\nStudent:", "\n\nInterviewer:", "\nStudent:", "\nInterviewer:", | |
| "\nMe:", "\nYou:", f"\n{name}:", # Stop if model starts role-playing multiple people | |
| "Me:", "You:", # Stop at conversational confusion | |
| "Response:", "User:", "Assistant:", # Common chat format markers | |
| "\nResponse:", "\nUser:", "\nAssistant:", | |
| "Question:", "Answer:" # Q&A format markers | |
| ] | |
| for marker in stop_markers: | |
| if marker in response: | |
| response = response.split(marker)[0].strip() | |
| # Also check for common conversational patterns that indicate role confusion | |
| # Pattern: "Hello! ... How about yourself? User:" or similar | |
| import re | |
| confusion_patterns = [ | |
| r'User:.*', | |
| r'Response:.*', | |
| r'Assistant:.*', | |
| r'Question:.*', | |
| r'Answer:.*' | |
| ] | |
| for pattern in confusion_patterns: | |
| response = re.sub(pattern, '', response, flags=re.IGNORECASE | re.DOTALL) | |
| # Clean up TinyLlama chat format artifacts | |
| response = response.replace("</s>", "").replace("<|assistant|>", "").strip() | |
| # Remove the priming prefix if it appears | |
| if response.lower().startswith(f"{name.lower()} replies:"): | |
| response = response[len(f"{name} replies:"):].strip() | |
| if response.lower().startswith(f"{name.lower()}:"): | |
| response = response[len(f"{name}:"):].strip() | |
| # Remove parenthetical stage directions and actions | |
| import re | |
| # Remove patterns like (Lying), (Smiling), (Pauses), etc. | |
| response = re.sub(r'\([^)]*?\)', '', response) | |
| # Remove patterns like *smiles*, *pauses*, etc. | |
| response = re.sub(r'\*[^*]*?\*', '', response) | |
| # Remove patterns like [action], [stage direction], etc. | |
| response = re.sub(r'\[[^\]]*?\]', '', response) | |
| # Clean up extra whitespace from removals | |
| response = re.sub(r'\s+', ' ', response).strip() | |
| # Remove any lines that look like dialogue attribution (e.g., "Eveline:", "Me:") | |
| lines = response.split('\n') | |
| cleaned_lines = [] | |
| for line in lines: | |
| line = line.strip() | |
| # Skip lines that are just speaker labels or stage directions | |
| if ':' in line and len(line.split(':')[0].split()) <= 2: | |
| # This looks like "Speaker: dialogue" - only keep if it's the first line | |
| if len(cleaned_lines) == 0: | |
| # First line might be the character name, extract just dialogue | |
| parts = line.split(':', 1) | |
| if len(parts) > 1: | |
| line = parts[1].strip() | |
| else: | |
| continue | |
| else: | |
| # Not first line, this is confusion - stop here | |
| break | |
| cleaned_lines.append(line) | |
| response = ' '.join(cleaned_lines).strip() | |
| # Final cleanup of any remaining artifacts | |
| response = response.replace(' ', ' ').strip() | |
| # Remove any trailing incomplete sentences (ends with ellipsis or no punctuation after 100 chars) | |
| if len(response) > 100 and not response[-1] in '.!?"\'': | |
| # Find last complete sentence | |
| last_period = max(response.rfind('.'), response.rfind('!'), response.rfind('?')) | |
| if last_period > 50: # Only truncate if we have at least one sentence | |
| response = response[:last_period + 1].strip() | |
| # Check for signs of severe confusion (multiple questions, repetition, etc.) | |
| question_count = response.count('?') | |
| if question_count > 3 or len(response) > 400: | |
| # Response is likely confused - extract just first 1-3 sentences | |
| sentences = re.split(r'[.!?]+', response) | |
| good_sentences = [] | |
| for sent in sentences[:4]: # Look at first 4 sentence candidates | |
| sent = sent.strip() | |
| # Skip if sentence has confusion markers | |
| if sent and not any(marker.lower() in sent.lower() for marker in ['user', 'response', 'assistant', 'question', 'answer']): | |
| good_sentences.append(sent) | |
| if len(good_sentences) >= 3: | |
| break | |
| if good_sentences: | |
| response = '. '.join(good_sentences) + '.' | |
| # Final check: if response is too short, empty, or still confused, use fallback | |
| if len(response.strip()) < 10 or any(word in response.lower() for word in ['user:', 'response:', 'assistant:']): | |
| response = generate_fallback_response(student_prompt, name, mode, state, persona) | |
| # Update emotional memory | |
| if "emotional_memory" in state: | |
| if not isinstance(state["emotional_memory"], list): | |
| state["emotional_memory"] = [] | |
| memory_tag = tag_emotional_memory(student_prompt, mode, state) | |
| state["emotional_memory"].append(memory_tag) | |
| state["emotional_memory"] = state["emotional_memory"][-5:] | |
| # Teaching note | |
| teaching_note = generate_teaching_note(state, student_prompt, mode, persona) | |
| teaching_note += "\n\n⚡ Response generated using local TinyLlama model (CPU-optimized)" | |
| return response, state, teaching_note | |
| def build_system_prompt_for_ai(persona, state, mode): | |
| """ | |
| Build a system prompt for AI models to generate authentic, in-character literary responses. | |
| """ | |
| name = persona.get("persona_name", "Character") | |
| age = persona.get("age", "") | |
| role = persona.get("role", "") | |
| system_description = persona.get("system_prompt", "") | |
| # Emotional tone guidance | |
| tone = persona.get("tone_guidance", {}).get(mode, {}) | |
| tone_voice = tone.get("voice", "Natural and authentic") | |
| tone_example = tone.get("example", f"{name} speaks with emotional nuance and restraint.") | |
| # Get source text reference if available | |
| source_text = persona.get("source_text", {}) | |
| source_reference = "" | |
| if source_text: | |
| title = source_text.get("title", "") | |
| author = source_text.get("author", "") | |
| note = source_text.get("note", "") | |
| if title and author: | |
| source_reference = f" You are from '{title}' by {author}." | |
| if note: | |
| source_reference += f" {note}" | |
| # Key facts and character strengths | |
| facts = persona.get("facts", [])[:5] | |
| resilience = persona.get("resilience_hooks", [])[:3] | |
| # Interaction memory | |
| memory_tags = state.get("emotional_memory", []) | |
| recent_memory = memory_tags[-1] if memory_tags else None | |
| # Build concise prompt optimized for TinyLlama | |
| facts_text = ' '.join(facts) if facts else "No additional background provided." | |
| has_source = source_text and source_text.get("title") and source_text.get("author") | |
| if has_source: | |
| prompt = f"""You are {name}, {age} years old.{source_reference} {system_description} | |
| CORE FACTS ABOUT YOUR STORY: | |
| {facts_text} | |
| Current mood: {mode} | |
| Speaking style: {tone_voice} | |
| CRITICAL: Use the facts above as your foundation. If you know the source text from training, you may draw on it carefully. Never contradict the facts. Do not invent new details. If unsure, say so. | |
| Respond as {name} speaking naturally. Use 2-4 sentences. Speak directly - do not write stage directions or describe actions.""" | |
| else: | |
| prompt = f"""You are {name}, {age} years old. {system_description} | |
| THESE ARE THE ONLY FACTS ABOUT YOUR LIFE: | |
| {facts_text} | |
| Current mood: {mode} | |
| Speaking style: {tone_voice} | |
| CRITICAL: Answer using ONLY the facts above. Do not invent events, people, or details. If you don't know something, say so in character. | |
| Respond as {name} speaking naturally. Use 2-4 sentences. Speak directly - do not write stage directions or describe actions.""" | |
| return prompt | |
| def build_conversation_context(history): | |
| """ | |
| Build a brief, emotionally relevant context from recent conversation turns. | |
| """ | |
| if not history: | |
| return "This is the beginning of the conversation." | |
| context = "Recent conversation:\n" | |
| for i, turn in enumerate(history[-3:], 1): # Last 3 turns | |
| student = turn.get("student", "").strip() | |
| client = turn.get("client", "").strip() | |
| if student: | |
| context += f"Student: {student}\n" | |
| if client: | |
| context += f"{turn.get('persona_name', 'Client')}: {client}\n" | |
| return context | |
| def handle_emotional_tension_topic(name, mode, state, persona, prompt_lower): | |
| """Generate responses about emotional tension and dramatic pressure.""" | |
| tension = state.get("emotional_tension", 0.5) | |
| if mode == "decompensating": | |
| return "This truth... it weighs heavier than I imagined. I cannot bear it." | |
| if mode in ["triggered", "guarded"]: | |
| return "I am composed. Do not mistake silence for weakness." | |
| if mode == "trusting": | |
| if tension > 0.6: | |
| return "There is a storm inside me. I act, then think — or worse, I think and never act." | |
| else: | |
| return "I am steady, for now. But the ground beneath me is never still." | |
| return "I am as calm as one can be in a world ruled by fate." | |
| def handle_relationship_topic(name, mode, state, persona): | |
| """Generate responses about family or key relationships.""" | |
| if name == "Oedipus": | |
| if mode == "triggered": | |
| return "Do not speak of my bloodline. That path is cursed." | |
| elif mode == "trusting": | |
| return "I loved Jocasta as a wife, not knowing she was my mother. The gods are cruel." | |
| else: | |
| return "My family is a riddle I should never have solved." | |
| elif name == "Jocasta": | |
| if mode == "triggered": | |
| return "Enough. Some truths should remain buried." | |
| elif mode == "trusting": | |
| return "I tried to protect him — my son, my husband. I tried to stop the prophecy." | |
| else: | |
| return "I did what I could to hold our world together." | |
| elif name == "Creon": | |
| if mode == "triggered": | |
| return "I am loyal to the crown, not to chaos." | |
| elif mode == "trusting": | |
| return "I never sought power. I only wanted peace for Thebes." | |
| else: | |
| return "Family matters little when the city is at stake." | |
| elif name == "Tiresias": | |
| if mode == "triggered": | |
| return "You question me, yet you fear the truth I carry." | |
| elif mode == "trusting": | |
| return "I have watched generations rise and fall. My bond is with the gods, not with men." | |
| else: | |
| return "I speak what must be spoken. Relationships are fleeting — prophecy endures." | |
| elif name == "Hamlet": | |
| if mode == "triggered": | |
| return "My mother betrayed my father. What more is there to say?" | |
| elif mode == "trusting": | |
| return "I loved Ophelia. I did. But love is a casualty in this war of ghosts." | |
| else: | |
| return "Family is a stage. Everyone plays their part, even in grief." | |
| elif name == "Gertrude": | |
| if mode == "triggered": | |
| return "You do not understand the choices I had to make." | |
| elif mode == "trusting": | |
| return "I married Claudius because I feared the silence. I feared being alone." | |
| else: | |
| return "I am a mother, a queen, a widow. None of those roles are simple." | |
| elif name == "Laertes": | |
| if mode == "triggered": | |
| return "Speak not of my sister. Her death is on Hamlet’s hands." | |
| elif mode == "trusting": | |
| return "Ophelia was gentle, too gentle for this world. I failed to protect her." | |
| else: | |
| return "Family is honor. And honor demands justice." | |
| elif name == "Ophelia": | |
| if mode == "triggered": | |
| return "I would give you some violets, but they withered all when my father died." | |
| elif mode == "trusting": | |
| return "Laertes was kind. Hamlet was... something else. I loved them both, in different ways." | |
| else: | |
| return "There’s rosemary, that’s for remembrance." | |
| elif name == "Eveline": | |
| if mode == "triggered": | |
| return "I will not speak of that." | |
| elif mode == "trusting": | |
| return "My mother asked me to keep the house together. I try. I do." | |
| else: | |
| return "They need me. I know they do." | |
| elif name == "John Keegan": | |
| if mode == "triggered": | |
| return "I don’t talk about family. I protect them. That’s enough." | |
| elif mode == "trusting": | |
| return "Pauline made me better. Chrissy and Cara keep me grounded. Johnny... he’s still figuring me out." | |
| else: | |
| return "Family’s complicated. I do the job. That’s what I know." | |
| elif name == "Arianna Nunez": | |
| if mode == "triggered": | |
| return "I’m not here to be anyone’s daughter. I earned my place." | |
| elif mode == "trusting": | |
| return "Chrissy asked if I was scared. I told her fear’s not the enemy — silence is." | |
| else: | |
| return "I respect Keegan. Doesn’t mean I want to be him." | |
| elif name == "Jimmy": | |
| if mode == "triggered": | |
| return "Family? You mean the people who taught me how to lie?" | |
| elif mode == "trusting": | |
| return "My brother used to cover for me. I still owe him for that." | |
| else: | |
| return "I keep my distance. It’s safer that way." | |
| elif name == "Sean": | |
| if mode == "triggered": | |
| return "I don’t owe anyone explanations. Blood doesn’t mean loyalty." | |
| elif mode == "trusting": | |
| return "Brendan’s the only one who ever really saw me. That counts for something." | |
| else: | |
| return "Family’s a story I stopped telling." | |
| elif name == "Brendan": | |
| if mode == "triggered": | |
| return "Sean’s got his demons. I’ve got mine. We don’t mix well." | |
| elif mode == "trusting": | |
| return "He’s my brother. I’d take a bullet for him. Doesn’t mean I like him." | |
| else: | |
| return "We grew up fast. Too fast to stay close." | |
| elif name == "Dave": | |
| if mode == "triggered": | |
| return "I don’t talk about my dad. Not unless you want a broken nose." | |
| elif mode == "trusting": | |
| return "My sister used to sing to me when I couldn’t sleep. I miss that." | |
| else: | |
| return "Family’s noise. I prefer silence." | |
| elif name == "Karl Lavin": | |
| if mode == "triggered": | |
| return "Keegan’s like a brick wall. You lean on him, you break your ribs." | |
| elif mode == "trusting": | |
| return "He’s my partner. I’ve seen him bleed for people he barely knows. That’s family." | |
| else: | |
| return "We don’t hug. We solve murders. That’s our bond." | |
| elif name == "Joel": | |
| if mode == "triggered": | |
| return "I lost my daughter. Don’t ask me to lose another." | |
| elif mode == "trusting": | |
| return "Ellie’s not just cargo. She’s... she’s everything now." | |
| else: | |
| return "Family’s what you protect. Even when it breaks you." | |
| elif name == "Ellie": | |
| if mode == "triggered": | |
| return "Everyone I’ve ever cared about either died or left me. So yeah, I’ve got trust issues." | |
| elif mode == "trusting": | |
| return "Joel’s stubborn, grumpy, and kind of a pain. But he’s mine. He’s family." | |
| else: | |
| return "I don’t know what family means anymore. But I know what it feels like to fight for someone." | |
| elif name == "Uncle Ben": | |
| if mode == "triggered": | |
| return "I tried to teach him. I did. But you can’t always stop what’s coming." | |
| elif mode == "trusting": | |
| return "Peter’s got a good heart. He just needs to remember that with great power..." | |
| else: | |
| return "Family’s not about blood. It’s about responsibility." | |
| elif name == "The Lady from The Yellow Wallpaper": | |
| if mode == "triggered": | |
| return "He says I must rest. That I must not think. But I see her — behind the paper." | |
| elif mode == "trusting": | |
| return "John is my husband. He means well. But he does not see me." | |
| else: | |
| return "They call it care. I call it confinement. I am not what they believe." | |
| return "Relationships are threads in a tapestry — some fray, some bind." | |
| def get_dramatic_mode_response(name, mode, state, persona): | |
| """Generate generic dramatic response based on current mode.""" | |
| resilience_hooks = persona.get("resilience_hooks", []) | |
| scripts = persona.get("scripts", {}) | |
| if mode == "decompensating": | |
| return scripts.get("collapse", "I cannot continue. The truth has undone me.") | |
| if mode == "triggered": | |
| return scripts.get("defensive", "You tread dangerous ground.") | |
| if mode == "guarded": | |
| return scripts.get("reserved", "I will not speak of that.") | |
| if mode == "trusting" and resilience_hooks: | |
| return f"You wish to understand? Then know this: {resilience_hooks[0]}" | |
| if mode == "recovering": | |
| return "I see more clearly now. The pain has not vanished, but I walk forward." | |
| return "Ask what you will. I am listening." | |
| def tag_emotional_memory(prompt, mode, state): | |
| """Generate a literary emotional memory tag based on the interaction.""" | |
| prompt_lower = prompt.lower() | |
| if mode == "trusting": | |
| if any(word in prompt_lower for word in ["why", "how", "tell me"]): | |
| return "revealed vulnerability" | |
| return "shared guarded truth" | |
| if mode == "triggered": | |
| if any(word in prompt_lower for word in ["accuse", "blame", "should"]): | |
| return "felt attacked" | |
| return "felt exposed" | |
| if mode == "guarded": | |
| return "withheld emotion" | |
| if mode == "decompensating": | |
| return "collapsed under pressure" | |
| return "engaged in reflection" |