Files
EzVibe/agent/emotion.py
e2hang 2a844e83a8 Initial commit: EzVibe AI 桌宠系统
- EmotionEngine: 5状态马尔可夫情绪机 + 蒙特卡洛转移
- VectorMemory: TF-IDF向量记忆 + SQLite持久化 + RAG检索
- AgentBrain: Ollama/OpenAI/Dummy三后端LLM
- BehaviorScheduler: 优先级/冷却/活跃度调度
- FastAPI服务器 + WebSocket实时推送
- perception: 键鼠监控 + 屏幕截图
- ui/pet_window: PySide6桌宠窗口 + 像素动画
- assets/pet: 5情绪各2帧像素艺术资源
2026-05-01 23:26:43 +08:00

474 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
EzVibe 情绪状态机
=================
设计文档对应章节:核心模块结构 - 情绪系统(状态转移模型)
状态集合
S = {idle, happy, focused, annoyed, sleepy}
状态转移矩阵 P (5×5)
P[i][j] = P(s_{t+1}=j | s_t=i)
按索引顺序: idle, happy, focused, annoyed, sleepy
上下文调制
P'_{ij} = P_{ij} * f(C)
f(C) 为事件驱动增益因子
实现策略
1. 概率归一化 (Softmax)
2. 蒙特卡洛随机采样
3. 最小驻留时间限制(防止情绪频繁抖动)
"""
from __future__ import annotations
import logging
import math
import random
import time
from enum import Enum
from typing import Optional
import numpy as np
logger = logging.getLogger(__name__)
# ================================================================
# 1. 状态定义
# ================================================================
class EmotionState(str, Enum):
"""
五种情绪状态。
idle — 待机/发呆
happy — 开心
focused — 专注(正在工作/思考)
annoyed — 烦躁(被打断/久坐未活动)
sleepy — 困倦/疲劳
"""
IDLE = "idle"
HAPPY = "happy"
FOCUSED = "focused"
ANNOYED = "annoyed"
SLEEPY = "sleepy"
@classmethod
def from_string(cls, s: str) -> "EmotionState":
"""字符串 → EmotionState兼容大小写。"""
s = s.strip().lower()
for state in cls:
if state.value == s:
return state
raise ValueError(f"Unknown emotion state: {s!r}")
@classmethod
def all_values(cls) -> list[str]:
return [s.value for s in cls]
# ================================================================
# 2. 默认转移矩阵(文档原生数据)
# ================================================================
# 顺序: idle, happy, focused, annoyed, sleepy
DEFAULT_TRANSITION_MATRIX: list[list[float]] = [
# idle → idle happy focused annoyed sleepy
[0.4, 0.2, 0.2, 0.1, 0.1 ], # from idle
[0.2, 0.5, 0.1, 0.1, 0.1 ], # from happy
[0.1, 0.2, 0.5, 0.1, 0.1 ], # from focused
[0.1, 0.1, 0.1, 0.6, 0.1 ], # from annoyed
[0.2, 0.1, 0.1, 0.1, 0.5 ], # from sleepy
]
# 状态索引映射(避免魔法数字)
_STATE_INDEX: dict[EmotionState, int] = {
EmotionState.IDLE: 0,
EmotionState.HAPPY: 1,
EmotionState.FOCUSED: 2,
EmotionState.ANNOYED: 3,
EmotionState.SLEEPY: 4,
}
_INDEX_TO_STATE: list[EmotionState] = [
EmotionState.IDLE,
EmotionState.HAPPY,
EmotionState.FOCUSED,
EmotionState.ANNOYED,
EmotionState.SLEEPY,
]
# ================================================================
# 3. 上下文调制因子
# ================================================================
class ContextBoost:
"""
事件驱动权重调整因子。
每次调用 update(event) 时,根据事件类型对转移矩阵
相应行的目标状态概率进行增强。
使用加法增益:
boost_factor[to_state] += gain
然后在采样前用 Softmax 重新归一化该行。
"""
# 事件 → {目标状态: 增益值}
# 增益越大,切换到该状态的概率越高
EVENT_BOOSTS: dict[str, dict[EmotionState, float]] = {
# 长时间高频工作 → sleepy
"long_work_session": {
EmotionState.SLEEPY: 2.0,
EmotionState.FOCUSED: 0.8,
},
# 用户处于专注软件IDE/文档)→ focused
"user_focused": {
EmotionState.FOCUSED: 2.5,
EmotionState.IDLE: 0.3,
EmotionState.ANNOYED: 0.2,
},
# 频繁打扰/无视健康提醒 → annoyed
"reminder_ignored": {
EmotionState.ANNOYED: 3.0,
EmotionState.HAPPY: 0.1,
},
# 用户表扬/夸奖 → happy
"user_praise": {
EmotionState.HAPPY: 3.0,
EmotionState.ANNOYED: 0.1,
},
# 用户主动交互 → 唤醒 idle → happy
"user_interact": {
EmotionState.HAPPY: 1.5,
EmotionState.IDLE: 1.0,
EmotionState.ANNOYED: 0.3,
},
# 久坐检测 → sleepy + annoyed
"sedentary_too_long": {
EmotionState.SLEEPY: 1.5,
EmotionState.ANNOYED: 1.0,
EmotionState.IDLE: 0.5,
},
# 正常待机时间流逝 → idle
"time_passes": {
EmotionState.IDLE: 1.2,
EmotionState.HAPPY: 0.5,
},
# 用户喝了水/运动 → happy
"user_healthy_action": {
EmotionState.HAPPY: 2.0,
EmotionState.ANNOYED: 0.1,
EmotionState.SLEEPY: 0.3,
},
}
@classmethod
def get_boosts(cls, event: str) -> dict[EmotionState, float]:
"""根据事件类型返回增益字典。"""
return cls.EVENT_BOOSTS.get(event, {})
# ================================================================
# 4. 核心引擎
# ================================================================
class EmotionEngine:
"""
情绪状态机引擎。
参数
----
transition_matrix : list[list[float]] | None
5×5 状态转移矩阵,行为当前状态,列为目标状态。
默认使用文档中的矩阵。
min_residence_seconds : float
最小驻留时间(秒)。在此时间内不允许切换状态,防止抖动。
默认 5 秒。
seed : int | None
随机种子,用于单元测试复现。
示例
----
>>> engine = EmotionEngine()
>>> engine.get_state()
'idle'
>>> engine.update("user_praise")
'happy'
>>> engine.get_state()
'happy'
"""
def __init__(
self,
transition_matrix: list[list[float]] | None = None,
min_residence_seconds: float = 5.0,
seed: int | None = None,
) -> None:
# ── 转移矩阵验证 ──────────────────────────────────────
raw = transition_matrix if transition_matrix is not None else DEFAULT_TRANSITION_MATRIX
try:
self._P = np.array(raw, dtype=np.float64)
except Exception as exc:
raise ValueError(
f"transition_matrix must be a 5×5 matrix, got {type(raw).__name__}"
) from exc
if self._P.shape != (5, 5):
raise ValueError(
f"transition_matrix must be shape (5,5), got {self._P.shape}"
)
# 确保每行归一化为概率分布(浮点误差容差 1e-6
row_sums = self._P.sum(axis=1, keepdims=True)
if np.any(row_sums == 0):
raise ValueError("transition_matrix rows must not sum to zero")
self._P = self._P / row_sums
# ── 状态初始化 ──────────────────────────────────────
self._current_state: EmotionState = EmotionState.IDLE
self._state_since: float = time.monotonic() # 当前状态开始的绝对时间
self._min_residence: float = min_residence_seconds
# ── 随机数 ─────────────────────────────────────────
self._rng = random.Random(seed)
self._np_rng = np.random.default_rng(seed)
# ── 事件历史(用于调试/分析)────────────────────────
self._history: list[dict] = []
logger.info(
"EmotionEngine initialized | state=%s | min_residence=%.1fs",
self._current_state.value,
self._min_residence,
)
# ----------------------------------------------------------------
# 公开 API
# ----------------------------------------------------------------
def get_state(self) -> str:
"""返回当前情绪状态字符串。"""
return self._current_state.value
def get_state_object(self) -> EmotionState:
"""返回当前情绪状态EmotionState 枚举)。"""
return self._current_state
def get_display_name(self, state: str | None = None) -> str:
"""
返回情绪状态的中文/英文友好名称。
参数
----
state : str | None
目标状态字符串None 则返回当前状态对应的名称。
"""
display_map: dict[str, tuple[str, str]] = {
"idle": ("待机", "Idle"),
"happy": ("开心", "Happy"),
"focused": ("专注", "Focused"),
"annoyed": ("烦躁", "Annoyed"),
"sleepy": ("困倦", "Sleepy"),
}
key = (state if state is not None else self._current_state.value).lower()
zh, en = display_map.get(key, (key, key))
return f"{zh} ({en})"
def get_all_states(self) -> list[str]:
"""返回所有状态名称列表。"""
return EmotionState.all_values()
def update(self, event: str, context: dict | None = None) -> str:
"""
根据事件触发一次状态转移。
流程
1. 检查驻留时间,若不足则拒绝转移。
2. 查找事件对应的增益因子。
3. 将增益叠加到当前行的转移概率。
4. Softmax 归一化。
5. 蒙特卡洛采样选出下一状态。
6. 更新状态并记录历史。
参数
----
event : str
事件类型,需匹配 ContextBoost.EVENT_BOOSTS 中的 key。
context : dict | None
可选上下文信息(目前预留,供未来扩展)。
返回
----
str
新的情绪状态字符串。
"""
now = time.monotonic()
elapsed = now - self._state_since
# ── 驻留时间检查 ────────────────────────────────────
if elapsed < self._min_residence:
logger.debug(
"State transition blocked by residence time "
"(elapsed=%.2fs < %.2fs, event=%s)",
elapsed, self._min_residence, event,
)
return self._current_state.value
prev_state = self._current_state
row_idx = _STATE_INDEX[prev_state]
# ── 获取事件增益 ────────────────────────────────────
boosts = ContextBoost.get_boosts(event)
# ── 构建带增益的概率向量 ────────────────────────────
probs = self._P[row_idx].copy()
for target_state, gain in boosts.items():
col_idx = _STATE_INDEX[target_state]
probs[col_idx] += gain
# ── Softmax 归一化 ─────────────────────────────────
probs = self._softmax(probs)
# ── 蒙特卡洛采样 ───────────────────────────────────
next_state = self._sample(probs)
# ── 更新状态 ───────────────────────────────────────
if next_state != prev_state:
self._current_state = next_state
self._state_since = now
logger.info(
"State transition: %s%s (event=%s, boosts=%s)",
prev_state.value, next_state.value, event, boosts,
)
else:
logger.debug(
"State unchanged: %s (event=%s, probs=%s)",
prev_state.value, event, probs.round(3).tolist(),
)
# ── 记录历史 ───────────────────────────────────────
self._history.append({
"timestamp": now,
"event": event,
"prev_state": prev_state.value,
"curr_state": self._current_state.value,
"transition": prev_state != self._current_state,
"elapsed": elapsed,
"boosts": boosts,
})
# 历史保留最近 1000 条
if len(self._history) > 1000:
self._history = self._history[-1000:]
return self._current_state.value
def get_residence_time(self) -> float:
"""返回当前状态已持续的时间(秒)。"""
return time.monotonic() - self._state_since
def is_residence_ready(self) -> bool:
"""返回当前状态是否已满足最小驻留时间。"""
return self.get_residence_time() >= self._min_residence
def get_transition_matrix(self) -> list[list[float]]:
"""返回原始转移矩阵(列表形式)。"""
return self._P.tolist()
def get_transition_probabilities(self) -> dict[str, float]:
"""
返回当前状态到所有状态的转移概率。
诊断用。
"""
row_idx = _STATE_INDEX[self._current_state]
probs = self._P[row_idx]
return {
_INDEX_TO_STATE[i].value: float(probs[i])
for i in range(5)
}
def get_history(self, last_n: int = 50) -> list[dict]:
"""返回最近 N 条状态历史。"""
return self._history[-last_n:]
def force_state(self, state: str | EmotionState) -> None:
"""
强制设置状态(用于重置/测试,不触发状态转移逻辑)。
"""
if isinstance(state, str):
state = EmotionState.from_string(state)
self._current_state = state
self._state_since = time.monotonic()
logger.info("State forcibly set to: %s", state.value)
def tick(self) -> str:
"""
无事件驱动的时钟推进。
当无事发生时,每隔一定时间(建议 60s调用一次
触发自然的状态回归/衰减。
"""
return self.update("time_passes")
# ----------------------------------------------------------------
# 内部实现
# ----------------------------------------------------------------
@staticmethod
def _softmax(x: np.ndarray) -> np.ndarray:
"""
Numerically stable Softmax。
softmax_i = exp(x_i - max(x)) / sum(exp(x_j - max(x)))
"""
x = np.asarray(x, dtype=np.float64)
x_shifted = x - x.max()
exp_x = np.exp(x_shifted)
return exp_x / exp_x.sum()
def _sample(self, probs: np.ndarray) -> EmotionState:
"""
蒙特卡洛采样:根据概率向量随机选取下一状态。
算法累计分布函数CDF+ 二分搜索。
等价于 numpy.random.choice效率更高。
"""
probs = np.asarray(probs, dtype=np.float64)
if not np.isclose(probs.sum(), 1.0):
probs = probs / probs.sum() # 容错
cumsum = np.cumsum(probs)
r = self._rng.random() # [0, 1)
idx = int(np.searchsorted(cumsum, r))
# searchsorted 可能返回 n越界取模安全兜底
idx = min(idx, len(_INDEX_TO_STATE) - 1)
return _INDEX_TO_STATE[idx]
# ----------------------------------------------------------------
# 序列化(用于持久化)
# ----------------------------------------------------------------
def to_dict(self) -> dict:
"""将当前状态导出为可序列化字典。"""
return {
"current_state": self._current_state.value,
"state_since": self._state_since,
"min_residence": self._min_residence,
"transition_matrix": self._P.tolist(),
}
@classmethod
def from_dict(cls, d: dict) -> "EmotionEngine":
"""从字典恢复引擎实例(保留状态,矩阵可能不同)。"""
engine = cls(
transition_matrix=d.get("transition_matrix", DEFAULT_TRANSITION_MATRIX),
min_residence_seconds=d.get("min_residence", 5.0),
)
engine.force_state(d["current_state"])
engine._state_since = d.get("state_since", time.monotonic())
return engine