Spaces:
Sleeping
Sleeping
| """ | |
| Prompt construction and inference backend integration for the DYNAMICS | |
| Persona Explorer Space. | |
| This module builds a personality-conditioned prompt from DYNAMICS dimensions, | |
| sends it to the best available inference backend, and constructs a | |
| deterministic reasoning trace from DYNAMICS activations. | |
| Backend priority: | |
| 1. Local model (Animus/Imprint) via LOCAL_INFERENCE_URL | |
| 2. Gemini 2.5 Flash via GEMINI_API_KEY (rate-limited: 5 req/min per user) | |
| 3. HF Inference API (public demo model, no key required) | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import time | |
| import threading | |
| from typing import Any | |
| import requests | |
| # --------------------------------------------------------------------------- | |
| # Prompt template (from PREPRINT_OUTLINE.md Appendix A -- safe to publish) | |
| # --------------------------------------------------------------------------- | |
| _PROMPT_TEMPLATE = """\ | |
| You are a synthetic research participant. Your personality profile is as follows: | |
| Discipline: {D:.2f} (scale 0=very low, 1=very high) | |
| Yielding: {Y:.2f} | |
| Novelty: {N:.2f} | |
| Acuity: {A:.2f} | |
| Mercuriality: {M:.2f} | |
| Impulsivity: {I:.2f} | |
| Candour: {C:.2f} | |
| Sociability: {S:.2f} | |
| Your current economic situation: | |
| - Income: approximately {monthly_income} per month | |
| - Current account balance: {current_balance} | |
| - Financial anxiety: {financial_anxiety_label} | |
| Your current emotional state: | |
| - Feeling: {dominant_emotion} | |
| - Mood valence: {valence_label} | |
| You are presented with the following: | |
| "{stimulus}" | |
| Respond as this person would in first person. Be authentic to the personality profile. | |
| Do not mention your DYNAMICS scores or the fact that you are synthetic. | |
| Give a detailed, thoughtful response of 6-10 sentences. Include your reasoning, how you feel about it, what you would actually do, and why. Show how your personality shapes your reaction.""" | |
| def _valence_label(valence: float) -> str: | |
| """Convert numeric valence to a human-readable label.""" | |
| if valence < -0.5: | |
| return "quite negative" | |
| if valence < -0.15: | |
| return "somewhat negative" | |
| if valence < 0.15: | |
| return "neutral" | |
| if valence < 0.5: | |
| return "somewhat positive" | |
| return "quite positive" | |
| def build_prompt( | |
| dynamics: dict[str, float], | |
| income_band: str, | |
| balance: float, | |
| emotional_state: dict[str, Any], | |
| stimulus: str, | |
| monthly_income: float | None = None, | |
| financial_anxiety_label: str = "moderate", | |
| ) -> str: | |
| """Construct the inference prompt from persona parameters. | |
| Parameters | |
| ---------- | |
| dynamics : dict | |
| Eight DYNAMICS dimensions (D, Y, N, A, M, I, C, S) as floats. | |
| income_band : str | |
| One of low, mid, high, wealthy. | |
| balance : float | |
| Current balance in GBP. | |
| emotional_state : dict | |
| Keys: valence (float), dominant_emotion (str). | |
| stimulus : str | |
| The stimulus text presented to the persona. | |
| monthly_income : float or None | |
| Monthly income in GBP. Derived from band if not provided. | |
| financial_anxiety_label : str | |
| One of low, moderate, high. | |
| Returns | |
| ------- | |
| str The fully formatted prompt. | |
| """ | |
| from dynamics_rules import default_income_for_band | |
| mi = monthly_income if monthly_income is not None else default_income_for_band(income_band) | |
| valence = emotional_state.get("valence", 0.0) | |
| dominant_emotion = emotional_state.get("dominant_emotion", "neutral") | |
| return _PROMPT_TEMPLATE.format( | |
| D=dynamics.get("D", 0.5), | |
| Y=dynamics.get("Y", 0.5), | |
| N=dynamics.get("N", 0.5), | |
| A=dynamics.get("A", 0.5), | |
| M=dynamics.get("M", 0.5), | |
| I=dynamics.get("I", 0.5), | |
| C=dynamics.get("C", 0.5), | |
| S=dynamics.get("S", 0.5), | |
| monthly_income=f"\u00a3{mi:,.2f}", | |
| current_balance=f"\u00a3{balance:,.2f}", | |
| financial_anxiety_label=financial_anxiety_label, | |
| dominant_emotion=dominant_emotion, | |
| valence_label=_valence_label(valence), | |
| stimulus=stimulus, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| # Primary: local model endpoint (Animus/Imprint or any OpenAI-compatible API) | |
| _LOCAL_INFERENCE_URL = os.environ.get("LOCAL_INFERENCE_URL", "") | |
| # Fallback: Gemini 2.5 Flash via google-generativeai | |
| _GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") | |
| _GEMINI_MODEL = "gemini-2.5-flash" | |
| # Third tier: HF Inference API (public, no key needed) | |
| _HF_API_URL = "https://api-inference.huggingface.co/models/{model_id}" | |
| _DEFAULT_HF_MODEL = "kronaxis/dynamics-llm-reasoning-demo" | |
| # Gemini rate limit: 5 requests per minute per user | |
| _GEMINI_RATE_LIMIT_PER_MINUTE = 5 | |
| _gemini_request_log: dict[str, list[float]] = {} | |
| _gemini_rate_lock = threading.Lock() | |
| def _gemini_rate_limited(session_id: str) -> bool: | |
| """Check whether the session has exceeded the Gemini per-minute rate limit.""" | |
| now = time.time() | |
| with _gemini_rate_lock: | |
| log = _gemini_request_log.get(session_id, []) | |
| log = [t for t in log if now - t < 60.0] | |
| if len(log) >= _GEMINI_RATE_LIMIT_PER_MINUTE: | |
| _gemini_request_log[session_id] = log | |
| return True | |
| log.append(now) | |
| _gemini_request_log[session_id] = log | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # Lazy Gemini client initialisation | |
| # --------------------------------------------------------------------------- | |
| _gemini_client = None | |
| _gemini_init_attempted = False | |
| def _get_gemini_client(): | |
| """Lazily initialise the Gemini client (new google.genai SDK).""" | |
| global _gemini_client, _gemini_init_attempted | |
| if _gemini_init_attempted: | |
| return _gemini_client | |
| _gemini_init_attempted = True | |
| if not _GEMINI_API_KEY: | |
| return None | |
| try: | |
| from google import genai | |
| _gemini_client = genai.Client(api_key=_GEMINI_API_KEY) | |
| return _gemini_client | |
| except Exception: | |
| # Fallback to old library | |
| try: | |
| import google.generativeai as genai_old | |
| genai_old.configure(api_key=_GEMINI_API_KEY) | |
| _gemini_client = genai_old.GenerativeModel(_GEMINI_MODEL) | |
| return _gemini_client | |
| except Exception: | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # Backend 1: Local model (Animus/Imprint -- OpenAI-compatible endpoint) | |
| # --------------------------------------------------------------------------- | |
| def _call_local(prompt: str) -> dict[str, str] | None: | |
| """Call the local inference endpoint. Returns None on failure.""" | |
| if not _LOCAL_INFERENCE_URL: | |
| return None | |
| url = _LOCAL_INFERENCE_URL.rstrip("/") | |
| # Try OpenAI-compatible /v1/chat/completions first | |
| chat_url = f"{url}/v1/chat/completions" | |
| payload = { | |
| "model": "default", | |
| "messages": [{"role": "user", "content": prompt}], | |
| "max_tokens": 300, | |
| "temperature": 0.7, | |
| } | |
| try: | |
| resp = requests.post(chat_url, json=payload, timeout=30) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| choices = data.get("choices", []) | |
| if choices: | |
| text = choices[0].get("message", {}).get("content", "").strip() | |
| if text: | |
| return {"raw_text": text, "reasoning_trace": ""} | |
| return None | |
| except requests.RequestException: | |
| pass | |
| # Fallback: plain text /generate endpoint | |
| try: | |
| resp = requests.post( | |
| f"{url}/generate", | |
| json={"prompt": prompt, "max_new_tokens": 300, "temperature": 0.7}, | |
| timeout=30, | |
| ) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| text = data.get("text", data.get("generated_text", "")).strip() | |
| if text: | |
| return {"raw_text": text, "reasoning_trace": ""} | |
| return None | |
| except requests.RequestException: | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # Backend 2: Gemini 2.5 Flash via google-generativeai | |
| # --------------------------------------------------------------------------- | |
| def _call_gemini(prompt: str) -> dict[str, str] | None: | |
| """Call Gemini 2.5 Flash. Returns None on failure.""" | |
| client = _get_gemini_client() | |
| if client is None: | |
| return None | |
| try: | |
| # New google.genai SDK | |
| from google import genai | |
| from google.genai import types | |
| response = client.models.generate_content( | |
| model=_GEMINI_MODEL, | |
| contents=prompt, | |
| config=types.GenerateContentConfig( | |
| max_output_tokens=1024, | |
| temperature=0.7, | |
| thinking_config=types.ThinkingConfig(thinking_budget=0), | |
| ), | |
| ) | |
| text = response.text.strip() if response and response.text else "" | |
| if text: | |
| return {"raw_text": text, "reasoning_trace": ""} | |
| return {"raw_text": "No response generated.", "reasoning_trace": ""} | |
| except ImportError: | |
| # Fallback: old google.generativeai SDK (no thinking_config support) | |
| try: | |
| response = client.generate_content( | |
| prompt, | |
| generation_config={ | |
| "max_output_tokens": 1024, | |
| "temperature": 0.7, | |
| }, | |
| ) | |
| text = response.text.strip() if response and response.text else "" | |
| if text: | |
| return {"raw_text": text, "reasoning_trace": ""} | |
| return {"raw_text": "No response generated.", "reasoning_trace": ""} | |
| except Exception as exc: | |
| return {"raw_text": f"Gemini error: {exc}", "reasoning_trace": ""} | |
| except Exception as exc: | |
| return {"raw_text": f"Gemini error: {exc}", "reasoning_trace": ""} | |
| # --------------------------------------------------------------------------- | |
| # Backend 3: HF Inference API (public demo model) | |
| # --------------------------------------------------------------------------- | |
| def _call_hf(prompt: str, model_id: str, max_new_tokens: int, temperature: float) -> dict[str, str] | None: | |
| """Call HF Inference API. Returns None on failure.""" | |
| api_token = os.environ.get("HF_TOKEN", "") | |
| headers: dict[str, str] = {} | |
| if api_token: | |
| headers["Authorization"] = f"Bearer {api_token}" | |
| payload = { | |
| "inputs": prompt, | |
| "parameters": { | |
| "max_new_tokens": max_new_tokens, | |
| "temperature": temperature, | |
| "return_full_text": False, | |
| }, | |
| } | |
| url = _HF_API_URL.format(model_id=model_id) | |
| try: | |
| resp = requests.post(url, headers=headers, json=payload, timeout=60) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| except requests.RequestException: | |
| return None | |
| if isinstance(data, list) and len(data) > 0: | |
| generated = data[0].get("generated_text", "") | |
| elif isinstance(data, dict): | |
| generated = data.get("generated_text", str(data)) | |
| else: | |
| generated = str(data) | |
| parts = re.split(r"\n\s*Reasoning:\s*", generated, maxsplit=1) | |
| raw_text = parts[0].strip() | |
| reasoning = parts[1].strip() if len(parts) > 1 else "" | |
| if raw_text: | |
| return {"raw_text": raw_text, "reasoning_trace": reasoning} | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # Availability check (used by UI to show status) | |
| # --------------------------------------------------------------------------- | |
| def get_backend_status() -> dict[str, bool]: | |
| """Return availability of each backend for display in the UI.""" | |
| return { | |
| "local": bool(_LOCAL_INFERENCE_URL), | |
| "gemini": bool(_GEMINI_API_KEY), | |
| "hf": True, # always available (public endpoint) | |
| } | |
| def get_available_provider_label() -> str: | |
| """Return a human-readable label for the best available provider.""" | |
| if _LOCAL_INFERENCE_URL: | |
| return "local model" | |
| if _GEMINI_API_KEY: | |
| return "Gemini 2.5 Flash (cloud)" | |
| return "HF Inference API (public demo)" | |
| # --------------------------------------------------------------------------- | |
| # Main entry point | |
| # --------------------------------------------------------------------------- | |
| def call_inference( | |
| prompt: str, | |
| model_id: str = _DEFAULT_HF_MODEL, | |
| max_new_tokens: int = 300, | |
| temperature: float = 0.7, | |
| session_id: str = "default", | |
| ) -> dict[str, str]: | |
| """Send a prompt to the best available inference backend. | |
| Priority order: | |
| 1. Local model (LOCAL_INFERENCE_URL) -- primary, no rate limit | |
| 2. Gemini 2.5 Flash (GEMINI_API_KEY) -- fallback, 5 req/min per user | |
| 3. HF Inference API -- public demo model, always available | |
| Returns a dict with keys: raw_text, reasoning_trace, provider. | |
| """ | |
| # 1. Try local model first | |
| if _LOCAL_INFERENCE_URL: | |
| result = _call_local(prompt) | |
| if result is not None: | |
| result["provider"] = "local model" | |
| return result | |
| # 2. Try Gemini if key is set and rate limit not exceeded | |
| if _GEMINI_API_KEY: | |
| if _gemini_rate_limited(session_id): | |
| # Rate limited but Gemini is configured: skip to HF or return error | |
| pass | |
| else: | |
| result = _call_gemini(prompt) | |
| if result is not None: | |
| result["provider"] = "Gemini 2.5 Flash (cloud)" | |
| return result | |
| # 3. Try HF Inference API | |
| result = _call_hf(prompt, model_id, max_new_tokens, temperature) | |
| if result is not None: | |
| result["provider"] = "HF Inference API" | |
| return result | |
| # All backends failed | |
| return { | |
| "raw_text": "All inference backends are currently unavailable. Please try again later.", | |
| "reasoning_trace": "", | |
| "provider": "none", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Deterministic reasoning trace construction | |
| # --------------------------------------------------------------------------- | |
| _DIMENSION_LABELS = { | |
| "D": "Discipline", | |
| "Y": "Yielding", | |
| "N": "Novelty", | |
| "A": "Acuity", | |
| "M": "Mercuriality", | |
| "I": "Impulsivity", | |
| "C": "Candour", | |
| "S": "Sociability", | |
| } | |
| _HIGH_DRIVERS = { | |
| "D": "D_high_cost_benefit", | |
| "Y": "Y_high_compliance", | |
| "N": "N_high_novelty_seeking", | |
| "A": "A_high_analytical", | |
| "M": "M_high_anxiety", | |
| "I": "I_high_spontaneous", | |
| "C": "C_high_ethical", | |
| "S": "S_high_social_engagement", | |
| } | |
| _LOW_DRIVERS = { | |
| "D": "D_low_disorganised", | |
| "Y": "Y_low_confrontational", | |
| "N": "N_low_conservative", | |
| "A": "A_low_surface_reasoning", | |
| "M": "M_low_calm", | |
| "I": "I_low_measured", | |
| "C": "C_low_self_serving", | |
| "S": "S_low_reserved", | |
| } | |
| def build_reasoning_trace( | |
| dynamics: dict[str, float], | |
| response: dict[str, str], | |
| stimulus: str, | |
| financial_anxiety_label: str = "moderate", | |
| income_band: str = "mid", | |
| balance: float = 0.0, | |
| monthly_income: float = 0.0, | |
| ) -> dict[str, Any]: | |
| """Construct a deterministic reasoning trace from DYNAMICS activations. | |
| The trace identifies which dimensions most strongly drove the response | |
| and assigns causal labels consistent with published literature. | |
| Returns a dict matching the reasoning_trace schema from DATASET_SPEC.md. | |
| """ | |
| # Identify the two strongest drivers (furthest from 0.5 midpoint). | |
| deviations = sorted( | |
| ((dim, abs(dynamics.get(dim, 0.5) - 0.5)) for dim in _DIMENSION_LABELS), | |
| key=lambda x: x[1], | |
| reverse=True, | |
| ) | |
| top_drivers: list[str] = [] | |
| driver_explanations: list[str] = [] | |
| for dim, dev in deviations[:3]: | |
| val = dynamics.get(dim, 0.5) | |
| label = _DIMENSION_LABELS[dim] | |
| if val >= 0.5: | |
| top_drivers.append(_HIGH_DRIVERS[dim]) | |
| driver_explanations.append( | |
| f"high {label} ({val:.2f}) promotes " | |
| + _HIGH_DRIVERS[dim].split("_", 1)[1].replace("_", " ") | |
| ) | |
| else: | |
| top_drivers.append(_LOW_DRIVERS[dim]) | |
| driver_explanations.append( | |
| f"low {label} ({val:.2f}) promotes " | |
| + _LOW_DRIVERS[dim].split("_", 1)[1].replace("_", " ") | |
| ) | |
| # Economic driver. | |
| if balance > 0 and monthly_income > 0: | |
| ratio = balance / monthly_income | |
| if ratio < 0.3: | |
| econ_driver = "low_balance_relative_to_income" | |
| elif ratio > 1.5: | |
| econ_driver = "high_savings_buffer" | |
| else: | |
| econ_driver = "moderate_financial_position" | |
| else: | |
| econ_driver = f"income_band_{income_band}" | |
| # Narrative assembly. | |
| narrative = ( | |
| f"The persona ({income_band} income, balance \u00a3{balance:,.2f}, " | |
| f"financial anxiety: {financial_anxiety_label}) responded to the stimulus. " | |
| + "; ".join(driver_explanations) | |
| + "." | |
| ) | |
| # Confidence: higher when top dimensions are far from midpoint. | |
| top_devs = [d[1] for d in deviations[:3]] | |
| confidence = min(0.95, 0.5 + sum(top_devs) / 3.0) | |
| return { | |
| "narrative": narrative, | |
| "dynamics_drivers": top_drivers, | |
| "economic_driver": econ_driver, | |
| "memory_influence": "Not applicable (live demo, no memory history)", | |
| "confidence": round(confidence, 2), | |
| } | |