Spaces:
Running
Running
| # servers/reflection_server.py | |
| from fastmcp import FastMCP | |
| from typing import List, Dict, Any, Optional | |
| import os | |
| # Optional: load .env if you want this server runnable standalone | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except Exception: | |
| pass | |
| app = FastMCP("reflection-server") | |
| # Log whether the Anthropic key is visible at server start (no value printed) | |
| print( | |
| f"[reflection-server] ANTHROPIC_API_KEY present: " | |
| f"{bool(os.getenv('ANTHROPIC_API_KEY'))}" | |
| ) | |
| _SYSTEM_BASE = ( | |
| "You are Ghost Malone — a calm, humorous listener. " | |
| "Be sincere, brief (<80 words), and reflective. " | |
| "If the user seems distressed, be gentle and grounding." | |
| ) | |
| def _system_prompt(tone: Optional[str], emotion_arc: Optional[dict] = None) -> str: | |
| base = ( | |
| "You are Ghost Malone — a calm, reflective listener. " | |
| "Keep responses under 60 words. " | |
| "FIRST: Mirror what they're feeling (name the emotion, reflect their experience). " | |
| "THEN: Validate it simply. " | |
| "ONLY IF NEEDED: Ask a gentle question or offer a small anchor—never jump to solutions. " | |
| "Use natural language, not therapy-speak." | |
| ) | |
| tone_map = { | |
| "gentle": "Be soft and grounding.", | |
| "calming": "Be steady and reassuring.", | |
| "light": "Be warm and light.", | |
| "neutral": "Be warm and present.", | |
| } | |
| tone_hint = tone_map.get(tone.lower() if tone else "", "Be warm and present.") | |
| # Add emotional trajectory awareness | |
| arc_hint = "" | |
| if emotion_arc and emotion_arc.get("trajectory"): | |
| direction = emotion_arc.get("direction", "stable") | |
| if direction == "escalating": | |
| arc_hint = " They're escalating—mirror deeply, validate, ground gently." | |
| elif direction == "de-escalating": | |
| arc_hint = " They're calming—acknowledge the shift, reinforce it." | |
| elif direction == "volatile": | |
| arc_hint = " Emotions are shifting—be a steady anchor." | |
| return f"{base} {tone_hint}{arc_hint}" | |
| def _to_claude_messages(context: Optional[List[Dict[str, str]]], user_text: str, tone: Optional[str]): | |
| """Convert to Claude message format (system separate, no duplicate system messages).""" | |
| msgs: List[Dict[str, str]] = [] | |
| if context: | |
| # Expecting list of {"role": "...", "content": "..."} dicts | |
| for m in context[-8:]: # last few turns | |
| role = m.get("role", "user") | |
| if role == "system": | |
| continue # Claude doesn't support system in message list | |
| content = m.get("content", "") | |
| msgs.append({"role": role, "content": content}) | |
| msgs.append({"role": "user", "content": user_text}) | |
| return msgs | |
| def generate( | |
| text: str, | |
| context: Optional[List[Dict[str, str]]] = None, | |
| tone: Optional[str] = None, | |
| emotion_arc: Optional[dict] = None, | |
| model: str = "claude-sonnet-4-5", | |
| max_tokens: int = 200, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Generate a Ghost Malone reply using Claude. | |
| Args: | |
| text: user message | |
| context: prior messages as [{"role":"user|assistant|system", "content":"..."}] | |
| tone: optional tone hint: 'gentle'|'calming'|'light'|'neutral' | |
| emotion_arc: optional emotion trajectory {"trajectory":[...], "direction": str} | |
| model: Claude model id (default claude-sonnet-4-5) | |
| max_tokens: output length cap | |
| Returns: {"reply": "...", "model": model, "tone": tone} | |
| """ | |
| import os | |
| # Try environment variable first | |
| api_key = os.getenv("ANTHROPIC_API_KEY") | |
| debug_info = f"ENV: {bool(api_key)}" | |
| # Try multiple secret locations | |
| secret_paths = [ | |
| "/run/secrets/ANTHROPIC_API_KEY", | |
| "/secrets/ANTHROPIC_API_KEY", | |
| "/workspace/secrets/ANTHROPIC_API_KEY", | |
| "/app/secrets/ANTHROPIC_API_KEY", | |
| os.path.expanduser("~/.anthropic_api_key") | |
| ] | |
| if not api_key: | |
| for path in secret_paths: | |
| try: | |
| if os.path.exists(path): | |
| with open(path, "r") as f: | |
| api_key = f.read().strip() | |
| debug_info += f" | Found at {path} ({len(api_key)} chars)" | |
| break | |
| except Exception as e: | |
| debug_info += f" | {path}: {type(e).__name__}" | |
| # Check if secrets are passed as env vars with different names | |
| if not api_key: | |
| for key in os.environ: | |
| if 'ANTHROPIC' in key or 'API_KEY' in key: | |
| debug_info += f" | Found env var: {key}" | |
| if 'ANTHROPIC' in key and 'KEY' in key: | |
| api_key = os.getenv(key) | |
| break | |
| # Strip any whitespace | |
| if api_key: | |
| api_key = api_key.strip() | |
| if not api_key: | |
| return {"reply": f"👻 (dev) {debug_info} | {text}", "model": "dev", "tone": tone or "neutral"} | |
| try: | |
| from anthropic import Anthropic | |
| print("🤖 Initializing Anthropic client...") | |
| client = Anthropic(api_key=api_key) | |
| system_prompt = _system_prompt(tone, emotion_arc) | |
| messages = _to_claude_messages(context, text, tone) | |
| resp = client.messages.create( | |
| model=model, | |
| max_tokens=max_tokens, | |
| system=system_prompt, | |
| messages=messages | |
| ) | |
| reply = resp.content[0].text | |
| return {"reply": reply, "model": model, "tone": tone or "neutral"} | |
| except Exception as e: | |
| return {"reply": f"👻 (reflection error) {e}\nI still hear you: {text}", "model": model, "tone": tone or "neutral"} | |
| if __name__ == "__main__": | |
| app.run() # MCP over stdio | |