feat(narrative): add CharacterEngine with emotional state tracking
Character profiles now track a six-dimension emotional state (anger, fear, joy, sadness, trust, surprise) that evolves based on actions taken. Initial deltas for all 13 OASIS actions chosen as a balanced baseline (see module docstring for tuning guidance). Tests: 5/5 passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05aa9f149e
commit
6b9e6eeeb2
|
|
@ -0,0 +1,128 @@
|
|||
"""Extended character profiles, emotional state, and arc detection.
|
||||
|
||||
This module maintains character state beyond what OASIS tracks — backstory,
|
||||
motivations, emotional state (six-dimension vector), relationships, and arc
|
||||
stage. State is updated each round based on actions the character takes.
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
|
||||
EMOTIONS = ["anger", "fear", "joy", "sadness", "trust", "surprise"]
|
||||
|
||||
INITIAL_EMOTIONAL_STATE = {
|
||||
"anger": 0.0,
|
||||
"fear": 0.0,
|
||||
"joy": 0.0,
|
||||
"sadness": 0.0,
|
||||
"trust": 0.5, # neutral-positive baseline; others start at 0
|
||||
"surprise": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def create_initial_character(
|
||||
char_id: str,
|
||||
name: str,
|
||||
backstory: str = "",
|
||||
motivations: list | None = None,
|
||||
personality: list | None = None,
|
||||
) -> dict:
|
||||
"""Build a new character profile with neutral emotional state."""
|
||||
return {
|
||||
"id": char_id,
|
||||
"name": name,
|
||||
"backstory": backstory,
|
||||
"motivations": motivations or [],
|
||||
"personality_traits": personality or [],
|
||||
"emotional_state": {
|
||||
"current": dict(INITIAL_EMOTIONAL_STATE),
|
||||
"history": [],
|
||||
},
|
||||
"relationships": {},
|
||||
"arc": {"archetype": None, "stage": "beginning", "key_moments": []},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# USER CONTRIBUTION POINT — Emotional deltas per action type
|
||||
# ============================================================================
|
||||
#
|
||||
# Each entry maps an OASIS action type to a dict of emotional changes the
|
||||
# acting character experiences when they perform that action. Values are
|
||||
# ADDED to current emotions (clamped to [0.0, 1.0]).
|
||||
#
|
||||
# Guidance:
|
||||
# - Keep individual deltas small (between -0.15 and +0.15) so they
|
||||
# accumulate gradually over many rounds.
|
||||
# - Consider BOTH positive and negative effects per action:
|
||||
# e.g. DISLIKE_POST → anger up, trust down
|
||||
# - Missing actions default to no emotional change (character still acts
|
||||
# but doesn't feel anything different about it).
|
||||
#
|
||||
# Actions to consider filling in (from action_mapper.py):
|
||||
# CREATE_POST, LIKE_POST, REPOST, QUOTE_POST, FOLLOW, DO_NOTHING,
|
||||
# CREATE_COMMENT, DISLIKE_POST, LIKE_COMMENT, DISLIKE_COMMENT,
|
||||
# SEARCH_POSTS, SEARCH_USER, MUTE
|
||||
#
|
||||
# ⬇ REPLACE THE CONTENTS BELOW WITH YOUR CHOICES ⬇
|
||||
# ============================================================================
|
||||
ACTION_EMOTIONAL_DELTAS: Dict[str, Dict[str, float]] = {
|
||||
# Speaking out publicly — small confidence bump
|
||||
"CREATE_POST": {"joy": 0.04},
|
||||
# Agreeing with someone — builds trust and a little joy
|
||||
"LIKE_POST": {"trust": 0.04, "joy": 0.02},
|
||||
# Spreading word of something — mild surprise/stimulation
|
||||
"REPOST": {"surprise": 0.02},
|
||||
# Responding or debating — mild engagement charge
|
||||
"QUOTE_POST": {"joy": 0.02, "surprise": 0.02},
|
||||
# Allying with someone — strong trust + joy bump
|
||||
"FOLLOW": {"trust": 0.08, "joy": 0.04},
|
||||
# Observing in silence — passive sadness/fear creep over time
|
||||
"DO_NOTHING": {"sadness": 0.02, "fear": 0.02},
|
||||
# Dialogue engagement — small positive
|
||||
"CREATE_COMMENT": {"joy": 0.03},
|
||||
# Confronting/opposing — anger up, trust down (classic conflict)
|
||||
"DISLIKE_POST": {"anger": 0.08, "trust": -0.04},
|
||||
# Validating a response — mild trust building
|
||||
"LIKE_COMMENT": {"trust": 0.03},
|
||||
# Mocking/dismissing — mild anger
|
||||
"DISLIKE_COMMENT": {"anger": 0.04},
|
||||
# Investigating — curiosity as surprise
|
||||
"SEARCH_POSTS": {"surprise": 0.03},
|
||||
# Seeking out a specific person — slight fear (implies concern)
|
||||
"SEARCH_USER": {"fear": 0.02, "surprise": 0.02},
|
||||
# Shunning/ignoring — sadness + trust loss
|
||||
"MUTE": {"sadness": 0.03, "trust": -0.03},
|
||||
}
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def apply_action_emotional_delta(character: dict, action_type: str) -> None:
|
||||
"""Mutate character's emotional state based on an action they took.
|
||||
|
||||
Emotions are clamped to [0.0, 1.0]. Unknown action types are a no-op.
|
||||
"""
|
||||
deltas = ACTION_EMOTIONAL_DELTAS.get(action_type, {})
|
||||
current = character["emotional_state"]["current"]
|
||||
for emotion, delta in deltas.items():
|
||||
if emotion in current:
|
||||
current[emotion] = max(0.0, min(1.0, current[emotion] + delta))
|
||||
|
||||
|
||||
class CharacterEngine:
|
||||
"""Manages the character roster for a single simulation."""
|
||||
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def initialize_from_profiles(self, oasis_profiles: list) -> list:
|
||||
"""Bootstrap character roster from existing OASIS profile data."""
|
||||
characters = []
|
||||
for profile in oasis_profiles:
|
||||
char_id = str(profile.get("user_id", profile.get("id", "")))
|
||||
char = create_initial_character(
|
||||
char_id=char_id,
|
||||
name=profile.get("name", "Unknown"),
|
||||
)
|
||||
characters.append(char)
|
||||
self.store.save_characters(characters)
|
||||
return characters
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
from app.services.narrative.character_engine import (
|
||||
CharacterEngine,
|
||||
create_initial_character,
|
||||
apply_action_emotional_delta,
|
||||
)
|
||||
|
||||
|
||||
def test_create_initial_character_has_neutral_emotions():
|
||||
char = create_initial_character(char_id="elena", name="Elena Voss")
|
||||
assert char["emotional_state"]["current"]["anger"] == 0.0
|
||||
assert char["emotional_state"]["current"]["joy"] == 0.0
|
||||
# Trust starts at a neutral-positive baseline, not zero
|
||||
assert char["emotional_state"]["current"]["trust"] == 0.5
|
||||
|
||||
|
||||
def test_create_initial_character_stores_name_and_id():
|
||||
char = create_initial_character(char_id="elena", name="Elena Voss")
|
||||
assert char["id"] == "elena"
|
||||
assert char["name"] == "Elena Voss"
|
||||
|
||||
|
||||
def test_apply_delta_clamps_to_zero_one_range():
|
||||
char = create_initial_character(char_id="x", name="X")
|
||||
# Hammer a single action many times to exceed the clamp
|
||||
for _ in range(20):
|
||||
apply_action_emotional_delta(char, "DISLIKE_POST")
|
||||
for emo, val in char["emotional_state"]["current"].items():
|
||||
assert 0.0 <= val <= 1.0, f"{emo}={val} outside [0,1]"
|
||||
|
||||
|
||||
def test_create_post_increases_confidence_proxy():
|
||||
# Speaking out should bump joy slightly (a proxy for confidence)
|
||||
char = create_initial_character(char_id="x", name="X")
|
||||
baseline_joy = char["emotional_state"]["current"]["joy"]
|
||||
apply_action_emotional_delta(char, "CREATE_POST")
|
||||
assert char["emotional_state"]["current"]["joy"] >= baseline_joy
|
||||
|
||||
|
||||
def test_dislike_post_increases_anger():
|
||||
char = create_initial_character(char_id="x", name="X")
|
||||
apply_action_emotional_delta(char, "DISLIKE_POST")
|
||||
assert char["emotional_state"]["current"]["anger"] > 0.0
|
||||
Loading…
Reference in New Issue