dynamics-persona-explorer / dynamics_inference.py
JasonDDuke's picture
Upload dynamics_inference.py with huggingface_hub
fddf165 verified
Raw
History Blame Contribute Delete
17.4 kB
"""
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),
}