affective-longing 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- affective_longing/__init__.py +47 -0
- affective_longing/emotion/__init__.py +18 -0
- affective_longing/emotion/engine.py +235 -0
- affective_longing/emotion/vad.py +116 -0
- affective_longing/engine.py +288 -0
- affective_longing/memory/__init__.py +7 -0
- affective_longing/memory/memory_store.py +100 -0
- affective_longing/memory/store.py +87 -0
- affective_longing/memory/trigger_engine.py +78 -0
- affective_longing/relationship/__init__.py +22 -0
- affective_longing/relationship/ou_process.py +94 -0
- affective_longing/relationship/stages.py +79 -0
- affective_longing/relationship/state_machine.py +397 -0
- affective_longing-0.1.0.dist-info/METADATA +260 -0
- affective_longing-0.1.0.dist-info/RECORD +17 -0
- affective_longing-0.1.0.dist-info/WHEEL +5 -0
- affective_longing-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
affective-longing: Emotional extension for AI companions.
|
|
3
|
+
|
|
4
|
+
Built on revive-companion, adds:
|
|
5
|
+
- Memory triggers (embedding similarity)
|
|
6
|
+
- Relationship state machine (HMM + OU process)
|
|
7
|
+
- AI self-emotion modeling (VAD model)
|
|
8
|
+
|
|
9
|
+
Three layers, one decision:
|
|
10
|
+
1. Memory — past memories trigger present longing
|
|
11
|
+
2. Relationship — 6-stage lifecycle with OU decay
|
|
12
|
+
3. Emotion — VAD model for AI's internal state
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .emotion import Emotion, EmotionEngine, EmotionEngineConfig, EmotionalState
|
|
16
|
+
from .engine import AffectiveLonging, AffectiveResult
|
|
17
|
+
from .memory import MemoryStore, TriggerEngine
|
|
18
|
+
from .relationship import (
|
|
19
|
+
Event,
|
|
20
|
+
OUProcess,
|
|
21
|
+
RelationshipStateMachine,
|
|
22
|
+
RelationshipState,
|
|
23
|
+
Stage,
|
|
24
|
+
TransitionConfig,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# Core engine
|
|
29
|
+
"AffectiveLonging",
|
|
30
|
+
"AffectiveResult",
|
|
31
|
+
# Memory
|
|
32
|
+
"MemoryStore",
|
|
33
|
+
"TriggerEngine",
|
|
34
|
+
# Relationship
|
|
35
|
+
"Stage",
|
|
36
|
+
"RelationshipState",
|
|
37
|
+
"Event",
|
|
38
|
+
"RelationshipStateMachine",
|
|
39
|
+
"TransitionConfig",
|
|
40
|
+
"OUProcess",
|
|
41
|
+
# Emotion
|
|
42
|
+
"Emotion",
|
|
43
|
+
"EmotionalState",
|
|
44
|
+
"EmotionEngine",
|
|
45
|
+
"EmotionEngineConfig",
|
|
46
|
+
]
|
|
47
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Emotion module — VAD model for AI companion's internal state.
|
|
3
|
+
|
|
4
|
+
Components:
|
|
5
|
+
- Emotion: Discrete emotion labels (joy, sadness, anger, etc.)
|
|
6
|
+
- EmotionalState: VAD snapshot with emotion label
|
|
7
|
+
- EmotionEngine: Manages emotional state with OU decay + events
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .engine import EmotionEngine, EmotionEngineConfig
|
|
11
|
+
from .vad import Emotion, EmotionalState
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Emotion",
|
|
15
|
+
"EmotionalState",
|
|
16
|
+
"EmotionEngine",
|
|
17
|
+
"EmotionEngineConfig",
|
|
18
|
+
]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Emotion engine — manages the AI companion's emotional state.
|
|
3
|
+
|
|
4
|
+
Combines:
|
|
5
|
+
1. OU processes (one per VAD dimension) for time decay
|
|
6
|
+
2. Event-driven bumps (user actions affect emotions)
|
|
7
|
+
3. Relationship state influence (cold war → low valence)
|
|
8
|
+
|
|
9
|
+
Design:
|
|
10
|
+
- Each VAD dimension has its own OU process
|
|
11
|
+
- Events bump specific dimensions
|
|
12
|
+
- Relationship state modulates baselines (e.g., COLD → lower valence baseline)
|
|
13
|
+
- Emotions decay toward dynamic baselines over time
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from ..relationship.ou_process import OUProcess
|
|
22
|
+
from ..relationship.stages import Stage
|
|
23
|
+
from .vad import Emotion, EmotionalState
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Event → (valence_bump, arousal_bump, dominance_bump)
|
|
29
|
+
EVENT_BUMPS: dict[str, tuple[float, float, float]] = {
|
|
30
|
+
# Positive events
|
|
31
|
+
"reply_fast": (0.08, 0.05, 0.03),
|
|
32
|
+
"affection": (0.12, 0.08, 0.02),
|
|
33
|
+
"initiate": (0.10, 0.06, 0.05),
|
|
34
|
+
"long_message": (0.06, 0.04, 0.01),
|
|
35
|
+
|
|
36
|
+
# Negative events
|
|
37
|
+
"no_reply": (-0.06, 0.08, -0.05),
|
|
38
|
+
"reply_slow": (-0.03, 0.02, -0.02),
|
|
39
|
+
"long_silence": (-0.10, 0.10, -0.08),
|
|
40
|
+
"reject": (-0.12, 0.10, -0.10),
|
|
41
|
+
"fight": (-0.15, 0.15, -0.05),
|
|
42
|
+
|
|
43
|
+
# Recovery
|
|
44
|
+
"apology": (0.05, -0.05, 0.03),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Relationship stage → baseline adjustments (V, A, D)
|
|
48
|
+
STAGE_BASELINES: dict[Stage, tuple[float, float, float]] = {
|
|
49
|
+
Stage.COURTING: (0.2, 0.6, 0.3), # Slightly positive, anxious, uncertain
|
|
50
|
+
Stage.SWEET: (0.7, 0.5, 0.5), # Happy, moderate arousal, balanced
|
|
51
|
+
Stage.PASSIONATE: (0.8, 0.7, 0.6), # Very happy, excited, confident
|
|
52
|
+
Stage.STABLE: (0.5, 0.3, 0.5), # Content, calm, balanced
|
|
53
|
+
Stage.COLD: (-0.5, 0.5, 0.3), # Unhappy, tense, low control
|
|
54
|
+
Stage.REPAIRING: (0.0, 0.5, 0.4), # Neutral, attentive, cautious
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class EmotionEngineConfig:
|
|
60
|
+
"""Configuration for emotion engine."""
|
|
61
|
+
|
|
62
|
+
# Default baselines (overridden by relationship stage)
|
|
63
|
+
valence_baseline: float = 0.3
|
|
64
|
+
arousal_baseline: float = 0.4
|
|
65
|
+
dominance_baseline: float = 0.5
|
|
66
|
+
|
|
67
|
+
# OU parameters
|
|
68
|
+
valence_reversion: float = 0.06 # Slow decay (emotions are sticky)
|
|
69
|
+
arousal_reversion: float = 0.10 # Faster decay (excitement fades)
|
|
70
|
+
dominance_reversion: float = 0.08
|
|
71
|
+
|
|
72
|
+
valence_volatility: float = 0.02
|
|
73
|
+
arousal_volatility: float = 0.03
|
|
74
|
+
dominance_volatility: float = 0.02
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EmotionEngine:
|
|
78
|
+
"""
|
|
79
|
+
Manages the AI companion's emotional state.
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
>>> engine = EmotionEngine(seed=42)
|
|
83
|
+
>>> engine.current_state
|
|
84
|
+
EmotionalState(😐 neutral, V=+0.30, A=0.40, D=0.50)
|
|
85
|
+
|
|
86
|
+
>>> engine.observe("affection")
|
|
87
|
+
>>> engine.current_state
|
|
88
|
+
EmotionalState(😊 joy, V=+0.42, A=0.48, D=0.52)
|
|
89
|
+
|
|
90
|
+
>>> engine.update_relationship_stage(Stage.COLD)
|
|
91
|
+
>>> engine.step_time(24)
|
|
92
|
+
>>> engine.current_state
|
|
93
|
+
EmotionalState(😢 sadness, V=-0.20, ...)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
config: EmotionEngineConfig | None = None,
|
|
99
|
+
seed: int | None = None,
|
|
100
|
+
):
|
|
101
|
+
self.config = config or EmotionEngineConfig()
|
|
102
|
+
|
|
103
|
+
# OU processes for each dimension
|
|
104
|
+
self._valence = OUProcess(
|
|
105
|
+
baseline=self.config.valence_baseline,
|
|
106
|
+
reversion_speed=self.config.valence_reversion,
|
|
107
|
+
volatility=self.config.valence_volatility,
|
|
108
|
+
seed=seed,
|
|
109
|
+
)
|
|
110
|
+
self._arousal = OUProcess(
|
|
111
|
+
baseline=self.config.arousal_baseline,
|
|
112
|
+
reversion_speed=self.config.arousal_reversion,
|
|
113
|
+
volatility=self.config.arousal_volatility,
|
|
114
|
+
seed=(seed + 1) if seed is not None else None,
|
|
115
|
+
)
|
|
116
|
+
self._dominance = OUProcess(
|
|
117
|
+
baseline=self.config.dominance_baseline,
|
|
118
|
+
reversion_speed=self.config.dominance_reversion,
|
|
119
|
+
volatility=self.config.dominance_volatility,
|
|
120
|
+
seed=(seed + 2) if seed is not None else None,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Valence is [-1, 1], not [0, 1], so we need special handling
|
|
124
|
+
self._valence.value = self.config.valence_baseline
|
|
125
|
+
|
|
126
|
+
self._current_stage: Stage | None = None
|
|
127
|
+
|
|
128
|
+
logger.info(
|
|
129
|
+
"EmotionEngine initialized: %s",
|
|
130
|
+
self.current_state,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def current_state(self) -> EmotionalState:
|
|
135
|
+
"""Current emotional snapshot."""
|
|
136
|
+
return EmotionalState(
|
|
137
|
+
valence=self._valence.value,
|
|
138
|
+
arousal=self._arousal.value,
|
|
139
|
+
dominance=self._dominance.value,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def valence(self) -> float:
|
|
144
|
+
return self._valence.value
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def arousal(self) -> float:
|
|
148
|
+
return self._arousal.value
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def dominance(self) -> float:
|
|
152
|
+
return self._dominance.value
|
|
153
|
+
|
|
154
|
+
def observe(self, event: str) -> EmotionalState:
|
|
155
|
+
"""
|
|
156
|
+
React to an event.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
event: Event name (e.g., "reply_fast", "fight", "affection")
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
New emotional state.
|
|
163
|
+
"""
|
|
164
|
+
bumps = EVENT_BUMPS.get(event, (0.0, 0.0, 0.0))
|
|
165
|
+
v_bump, a_bump, d_bump = bumps
|
|
166
|
+
|
|
167
|
+
self._valence.bump(v_bump)
|
|
168
|
+
self._arousal.bump(a_bump)
|
|
169
|
+
self._dominance.bump(d_bump)
|
|
170
|
+
|
|
171
|
+
logger.debug(
|
|
172
|
+
"Emotion event=%s: bumps=(V%+.2f, A%+.2f, D%+.2f) → %s",
|
|
173
|
+
event,
|
|
174
|
+
v_bump,
|
|
175
|
+
a_bump,
|
|
176
|
+
d_bump,
|
|
177
|
+
self.current_state,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return self.current_state
|
|
181
|
+
|
|
182
|
+
def update_relationship_stage(self, stage: Stage) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Update emotional baselines based on relationship stage.
|
|
185
|
+
|
|
186
|
+
Call this when the relationship state machine transitions.
|
|
187
|
+
"""
|
|
188
|
+
if stage == self._current_stage:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
self._current_stage = stage
|
|
192
|
+
v_base, a_base, d_base = STAGE_BASELINES.get(
|
|
193
|
+
stage,
|
|
194
|
+
(self.config.valence_baseline,
|
|
195
|
+
self.config.arousal_baseline,
|
|
196
|
+
self.config.dominance_baseline),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
self._valence.baseline = v_base
|
|
200
|
+
self._arousal.baseline = a_base
|
|
201
|
+
self._dominance.baseline = d_base
|
|
202
|
+
|
|
203
|
+
logger.info(
|
|
204
|
+
"Emotion baselines updated for stage=%s: (V=%.2f, A=%.2f, D=%.2f)",
|
|
205
|
+
stage.value,
|
|
206
|
+
v_base,
|
|
207
|
+
a_base,
|
|
208
|
+
d_base,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def step_time(self, dt: float = 1.0) -> EmotionalState:
|
|
212
|
+
"""
|
|
213
|
+
Advance time — emotions decay toward baselines.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
dt: Time step in hours.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
New emotional state.
|
|
220
|
+
"""
|
|
221
|
+
self._valence.step(dt)
|
|
222
|
+
self._arousal.step(dt)
|
|
223
|
+
self._dominance.step(dt)
|
|
224
|
+
|
|
225
|
+
return self.current_state
|
|
226
|
+
|
|
227
|
+
def reset(self) -> None:
|
|
228
|
+
"""Reset to default emotional state."""
|
|
229
|
+
self._valence.reset()
|
|
230
|
+
self._arousal.reset()
|
|
231
|
+
self._dominance.reset()
|
|
232
|
+
self._current_stage = None
|
|
233
|
+
|
|
234
|
+
def __repr__(self) -> str:
|
|
235
|
+
return f"EmotionEngine({self.current_state})"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VAD emotion model — Valence, Arousal, Dominance.
|
|
3
|
+
|
|
4
|
+
Based on Russell's circumplex model of affect (1980) and
|
|
5
|
+
Mehrabian's PAD (Pleasure-Arousal-Dominance) model (1996).
|
|
6
|
+
|
|
7
|
+
Used in:
|
|
8
|
+
- Sentiment analysis
|
|
9
|
+
- Emotion recognition
|
|
10
|
+
- Affective computing
|
|
11
|
+
- Human-robot interaction
|
|
12
|
+
|
|
13
|
+
Our use: Model the AI companion's "internal emotional state"
|
|
14
|
+
as it interacts with the user over time.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import Enum
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Emotion(Enum):
|
|
24
|
+
"""
|
|
25
|
+
Discrete emotion labels mapped from VAD space.
|
|
26
|
+
|
|
27
|
+
Based on Plutchik's wheel (1980) + modern affective computing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
JOY = "joy" # V+, A+, D+
|
|
31
|
+
TRUST = "trust" # V+, A~, D~
|
|
32
|
+
CONTENTMENT = "contentment" # V+, A-, D~
|
|
33
|
+
EXCITEMENT = "excitement" # V+, A+, D~
|
|
34
|
+
LOVE = "love" # V++, A+, D~
|
|
35
|
+
ANTICIPATION = "anticipation" # V~, A+, D~
|
|
36
|
+
ANXIETY = "anxiety" # V-, A+, D-
|
|
37
|
+
SADNESS = "sadness" # V-, A-, D-
|
|
38
|
+
ANGER = "anger" # V-, A+, D+
|
|
39
|
+
FEAR = "fear" # V--, A++, D--
|
|
40
|
+
NEUTRAL = "neutral" # V~, A~, D~
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def from_vad(valence: float, arousal: float, dominance: float) -> Emotion:
|
|
44
|
+
"""Map continuous VAD to discrete emotion."""
|
|
45
|
+
# Thresholds
|
|
46
|
+
HIGH_V = 0.5
|
|
47
|
+
LOW_V = -0.5
|
|
48
|
+
HIGH_A = 0.6
|
|
49
|
+
LOW_A = 0.4
|
|
50
|
+
HIGH_D = 0.6
|
|
51
|
+
LOW_D = 0.4
|
|
52
|
+
|
|
53
|
+
if valence > HIGH_V:
|
|
54
|
+
if arousal > HIGH_A:
|
|
55
|
+
if dominance > HIGH_D:
|
|
56
|
+
return Emotion.JOY
|
|
57
|
+
return Emotion.EXCITEMENT
|
|
58
|
+
elif arousal < LOW_A:
|
|
59
|
+
return Emotion.CONTENTMENT
|
|
60
|
+
else:
|
|
61
|
+
return Emotion.TRUST
|
|
62
|
+
elif valence < LOW_V:
|
|
63
|
+
if arousal > HIGH_A:
|
|
64
|
+
if dominance > HIGH_D:
|
|
65
|
+
return Emotion.ANGER
|
|
66
|
+
elif dominance < LOW_D:
|
|
67
|
+
return Emotion.FEAR
|
|
68
|
+
return Emotion.ANXIETY
|
|
69
|
+
elif arousal < LOW_A:
|
|
70
|
+
return Emotion.SADNESS
|
|
71
|
+
else:
|
|
72
|
+
return Emotion.ANXIETY
|
|
73
|
+
else:
|
|
74
|
+
# valence near zero
|
|
75
|
+
if arousal > HIGH_A:
|
|
76
|
+
return Emotion.ANTICIPATION
|
|
77
|
+
return Emotion.NEUTRAL
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class EmotionalState:
|
|
82
|
+
"""Full emotional snapshot."""
|
|
83
|
+
|
|
84
|
+
valence: float # -1 (negative) to +1 (positive)
|
|
85
|
+
arousal: float # 0 (calm) to 1 (excited)
|
|
86
|
+
dominance: float # 0 (submissive) to 1 (dominant)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def emotion(self) -> Emotion:
|
|
90
|
+
"""Discrete emotion label."""
|
|
91
|
+
return Emotion.from_vad(self.valence, self.arousal, self.dominance)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def emoji(self) -> str:
|
|
95
|
+
return {
|
|
96
|
+
Emotion.JOY: "😊",
|
|
97
|
+
Emotion.TRUST: "🤝",
|
|
98
|
+
Emotion.CONTENTMENT: "😌",
|
|
99
|
+
Emotion.EXCITEMENT: "🤩",
|
|
100
|
+
Emotion.LOVE: "❤️",
|
|
101
|
+
Emotion.ANTICIPATION: "🤔",
|
|
102
|
+
Emotion.ANXIETY: "😰",
|
|
103
|
+
Emotion.SADNESS: "😢",
|
|
104
|
+
Emotion.ANGER: "😠",
|
|
105
|
+
Emotion.FEAR: "😨",
|
|
106
|
+
Emotion.NEUTRAL: "😐",
|
|
107
|
+
}[self.emotion]
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
return (
|
|
111
|
+
f"EmotionalState("
|
|
112
|
+
f"{self.emoji} {self.emotion.value}, "
|
|
113
|
+
f"V={self.valence:+.2f}, "
|
|
114
|
+
f"A={self.arousal:.2f}, "
|
|
115
|
+
f"D={self.dominance:.2f})"
|
|
116
|
+
)
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AffectiveLonging — unified emotional extension engine.
|
|
3
|
+
|
|
4
|
+
Integrates three modules:
|
|
5
|
+
1. Memory triggers (embedding similarity → longing boost)
|
|
6
|
+
2. Relationship state machine (HMM + OU → stage transitions)
|
|
7
|
+
3. AI self-emotion (VAD model → emotional state)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
engine = AffectiveLonging()
|
|
11
|
+
|
|
12
|
+
# Store memories
|
|
13
|
+
engine.remember("你喜欢下雨天", tags=["weather"])
|
|
14
|
+
|
|
15
|
+
# Main loop
|
|
16
|
+
result = engine.tick(context="今天下雨了")
|
|
17
|
+
if result.should_send:
|
|
18
|
+
send(result.prompt)
|
|
19
|
+
|
|
20
|
+
# Observe events
|
|
21
|
+
engine.observe("reply_fast")
|
|
22
|
+
|
|
23
|
+
# Time passes
|
|
24
|
+
engine.step_time(hours=12)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from revive_my_lover import PoissonLove
|
|
35
|
+
|
|
36
|
+
from .emotion import EmotionEngine, EmotionalState
|
|
37
|
+
from .memory import MemoryStore, TriggerEngine
|
|
38
|
+
from .relationship import Event, RelationshipStateMachine, RelationshipState, Stage
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class AffectiveResult:
|
|
45
|
+
"""Full result of an AffectiveLonging tick."""
|
|
46
|
+
|
|
47
|
+
# Base decision
|
|
48
|
+
should_send: bool
|
|
49
|
+
base_probability: float
|
|
50
|
+
|
|
51
|
+
# Memory trigger
|
|
52
|
+
memory_trigger: str | None = None
|
|
53
|
+
memory_similarity: float = 0.0
|
|
54
|
+
longing_boost: float = 0.0
|
|
55
|
+
boosted_probability: float = 0.0
|
|
56
|
+
|
|
57
|
+
# Relationship
|
|
58
|
+
relationship_stage: Stage = Stage.COURTING
|
|
59
|
+
intimacy: float = 0.5
|
|
60
|
+
conflict: float = 0.1
|
|
61
|
+
|
|
62
|
+
# Emotion
|
|
63
|
+
emotional_state: EmotionalState | None = None
|
|
64
|
+
|
|
65
|
+
# Prompt
|
|
66
|
+
prompt: str = ""
|
|
67
|
+
reason: str = ""
|
|
68
|
+
|
|
69
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AffectiveLonging:
|
|
73
|
+
"""
|
|
74
|
+
Unified emotional engagement engine.
|
|
75
|
+
|
|
76
|
+
Combines three layers:
|
|
77
|
+
1. Memory — past memories trigger present longing
|
|
78
|
+
2. Relationship — 6-stage lifecycle with OU decay
|
|
79
|
+
3. Emotion — VAD model for AI's internal state
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
memory_persist_dir: Where to store memory embeddings.
|
|
83
|
+
relationship_seed: Seed for relationship state machine.
|
|
84
|
+
emotion_seed: Seed for emotion engine.
|
|
85
|
+
**kwargs: Passed to PoissonLove (config, seed, etc.)
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> engine = AffectiveLonging()
|
|
89
|
+
>>> engine.remember("你喜欢下雨天")
|
|
90
|
+
>>> engine.observe("reply_fast")
|
|
91
|
+
>>> result = engine.tick(context="今天下雨了")
|
|
92
|
+
>>> result.emotional_state
|
|
93
|
+
EmotionalState(😊 joy, V=+0.45, A=0.52, D=0.55)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
memory_persist_dir: str = "./companion_memory_db",
|
|
99
|
+
relationship_seed: int | None = None,
|
|
100
|
+
emotion_seed: int | None = None,
|
|
101
|
+
**kwargs,
|
|
102
|
+
):
|
|
103
|
+
# Base engine (revive-companion)
|
|
104
|
+
self._base = PoissonLove(**kwargs)
|
|
105
|
+
|
|
106
|
+
# Memory layer
|
|
107
|
+
self.memory = MemoryStore(persist_dir=memory_persist_dir)
|
|
108
|
+
self.trigger = TriggerEngine(self.memory)
|
|
109
|
+
|
|
110
|
+
# Relationship layer
|
|
111
|
+
self.relationship = RelationshipStateMachine(seed=relationship_seed)
|
|
112
|
+
|
|
113
|
+
# Emotion layer
|
|
114
|
+
self.emotion = EmotionEngine(seed=emotion_seed)
|
|
115
|
+
|
|
116
|
+
logger.info("AffectiveLonging initialized with 3 layers")
|
|
117
|
+
|
|
118
|
+
# ── Memory API ──
|
|
119
|
+
|
|
120
|
+
def remember(
|
|
121
|
+
self, text: str, tags: list[str] | None = None, **metadata
|
|
122
|
+
) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Store a memory for future triggering.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
text: The memory content (e.g., "你喜欢下雨天").
|
|
128
|
+
tags: Optional tags for filtering.
|
|
129
|
+
**metadata: Additional metadata.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Memory ID.
|
|
133
|
+
"""
|
|
134
|
+
meta = dict(metadata)
|
|
135
|
+
if tags:
|
|
136
|
+
meta["tags"] = ",".join(tags)
|
|
137
|
+
return self.memory.add(text, metadata=meta)
|
|
138
|
+
|
|
139
|
+
# ── Event API ──
|
|
140
|
+
|
|
141
|
+
def observe(self, event: str) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Observe an event and update relationship + emotion.
|
|
144
|
+
|
|
145
|
+
Maps string event names to:
|
|
146
|
+
- Relationship Event enum
|
|
147
|
+
- Emotion engine bumps
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
event: One of: reply_fast, reply_slow, no_reply, long_silence,
|
|
151
|
+
affection, fight, apology, initiate, reject, long_message
|
|
152
|
+
"""
|
|
153
|
+
# Update relationship state
|
|
154
|
+
try:
|
|
155
|
+
rel_event = Event(event)
|
|
156
|
+
self.relationship.observe(rel_event)
|
|
157
|
+
except ValueError:
|
|
158
|
+
logger.warning("Unknown relationship event: %s", event)
|
|
159
|
+
|
|
160
|
+
# Update emotion engine
|
|
161
|
+
self.emotion.observe(event)
|
|
162
|
+
|
|
163
|
+
# Sync emotion baselines with relationship stage
|
|
164
|
+
self.emotion.update_relationship_stage(self.relationship.stage)
|
|
165
|
+
|
|
166
|
+
logger.debug(
|
|
167
|
+
"Observed event=%s, stage=%s, emotion=%s",
|
|
168
|
+
event,
|
|
169
|
+
self.relationship.stage.value,
|
|
170
|
+
self.emotion.current_state.emotion.value,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def step_time(self, hours: float = 1.0) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Let time pass — decays relationship intimacy/conflict and emotion.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
hours: Hours to advance.
|
|
179
|
+
"""
|
|
180
|
+
self.relationship.step_time(hours)
|
|
181
|
+
self.emotion.step_time(hours)
|
|
182
|
+
|
|
183
|
+
# Keep emotion baselines in sync
|
|
184
|
+
self.emotion.update_relationship_stage(self.relationship.stage)
|
|
185
|
+
|
|
186
|
+
# ── Tick API ──
|
|
187
|
+
|
|
188
|
+
def tick(
|
|
189
|
+
self,
|
|
190
|
+
now: datetime | None = None,
|
|
191
|
+
context: str = "",
|
|
192
|
+
) -> AffectiveResult:
|
|
193
|
+
"""
|
|
194
|
+
Run one tick of the full pipeline.
|
|
195
|
+
|
|
196
|
+
Flow:
|
|
197
|
+
1. Base Poisson tick (timing decision)
|
|
198
|
+
2. Memory trigger (if context provided)
|
|
199
|
+
3. Package result with relationship + emotion state
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
now: Current time.
|
|
203
|
+
context: Situation text for memory matching.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
AffectiveResult with all layers' state.
|
|
207
|
+
"""
|
|
208
|
+
# 1. Base engine
|
|
209
|
+
base_result = self._base.tick(now=now)
|
|
210
|
+
|
|
211
|
+
# 2. Memory trigger
|
|
212
|
+
trigger_text = None
|
|
213
|
+
trigger_sim = 0.0
|
|
214
|
+
boost = 0.0
|
|
215
|
+
boosted_prob = base_result.probability
|
|
216
|
+
|
|
217
|
+
if context and self.memory.count() > 0:
|
|
218
|
+
trigger_info = self.trigger.compute_trigger_score(context)
|
|
219
|
+
if trigger_info["memories"]:
|
|
220
|
+
trigger_text = trigger_info["memories"][0]["text"]
|
|
221
|
+
trigger_sim = trigger_info["max_similarity"]
|
|
222
|
+
boost = trigger_info["trigger_score"]
|
|
223
|
+
boosted_prob = self.trigger.boost_longing(
|
|
224
|
+
base_result.probability, context
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# 3. Build result
|
|
228
|
+
rel_state = self.relationship.current_state
|
|
229
|
+
emo_state = self.emotion.current_state
|
|
230
|
+
|
|
231
|
+
return AffectiveResult(
|
|
232
|
+
should_send=base_result.should_send,
|
|
233
|
+
base_probability=base_result.probability,
|
|
234
|
+
memory_trigger=trigger_text,
|
|
235
|
+
memory_similarity=trigger_sim,
|
|
236
|
+
longing_boost=boost,
|
|
237
|
+
boosted_probability=boosted_prob,
|
|
238
|
+
relationship_stage=rel_state.stage,
|
|
239
|
+
intimacy=rel_state.intimacy,
|
|
240
|
+
conflict=rel_state.conflict,
|
|
241
|
+
emotional_state=emo_state,
|
|
242
|
+
prompt=base_result.prompt,
|
|
243
|
+
reason=base_result.reason,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# ── Pass-through API ──
|
|
247
|
+
|
|
248
|
+
def record_reply(self, **kwargs) -> None:
|
|
249
|
+
"""Record user reply — passes to base engine."""
|
|
250
|
+
self._base.record_reply(**kwargs)
|
|
251
|
+
|
|
252
|
+
def record_send(self, **kwargs) -> None:
|
|
253
|
+
"""Record that we sent — passes to base engine."""
|
|
254
|
+
self._base.record_send(**kwargs)
|
|
255
|
+
|
|
256
|
+
# ── State API ──
|
|
257
|
+
|
|
258
|
+
def get_state(self) -> dict:
|
|
259
|
+
"""Get full system state snapshot."""
|
|
260
|
+
rel = self.relationship.current_state
|
|
261
|
+
emo = self.emotion.current_state
|
|
262
|
+
return {
|
|
263
|
+
"relationship": {
|
|
264
|
+
"stage": rel.stage.value,
|
|
265
|
+
"stage_display": rel.stage.display_name,
|
|
266
|
+
"intimacy": rel.intimacy,
|
|
267
|
+
"conflict": rel.conflict,
|
|
268
|
+
"confidence": rel.confidence,
|
|
269
|
+
},
|
|
270
|
+
"emotion": {
|
|
271
|
+
"emotion": emo.emotion.value,
|
|
272
|
+
"emoji": emo.emoji,
|
|
273
|
+
"valence": emo.valence,
|
|
274
|
+
"arousal": emo.arousal,
|
|
275
|
+
"dominance": emo.dominance,
|
|
276
|
+
},
|
|
277
|
+
"memory_count": self.memory.count(),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
def __repr__(self) -> str:
|
|
281
|
+
rel = self.relationship.current_state
|
|
282
|
+
emo = self.emotion.current_state
|
|
283
|
+
return (
|
|
284
|
+
f"AffectiveLonging("
|
|
285
|
+
f"stage={rel.stage.display_name}, "
|
|
286
|
+
f"{emo.emoji} {emo.emotion.value}, "
|
|
287
|
+
f"memories={self.memory.count()})"
|
|
288
|
+
)
|