"""叩心枢触发条件引擎:接口与实现。 设计 docs/domain/core-domains.md §5。 """ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass, field from datetime import UTC, datetime from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.big_cycle import BigCycle from app.models.chat import ChatMessage from app.models.drive import CuriosityQuestion from app.models.evolution import EvolutionCandidate from app.models.memory import Memory from app.models.personality_emotion import CharacterEmotionState from app.models.time_anniversary import TimeAnniversary from app.modules.personality.emotion import EmotionState # --------------------------------------------------------------------------- # 条件接口 # --------------------------------------------------------------------------- @dataclass class ConditionResult: """条件检查结果。""" triggered: bool = False """是否触发。""" trigger_reason: str = "" """触发理由描述(传入 opener prompt)。""" context: dict = field(default_factory=dict) """开场上下文数据(可选的纪念日/好奇心问题等)。""" condition_name: str = "" """触发条件的唯一名称(用于事件推送的 trigger 字段)。""" class ProactiveCondition: """主动触发条件检查器基类。 子类重写 check() 方法,返回 ConditionResult。 纯逻辑,不修改 DB——叩心枢主循环在 check 通过后才执行写入。 """ async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, ) -> ConditionResult: raise NotImplementedError # --------------------------------------------------------------------------- # 第一梯队 ▸ 数据已在系统中流动 # --------------------------------------------------------------------------- class TimeElapsedCondition(ProactiveCondition): """空闲超时:查该角色最后一条消息时间,超过 min_idle_minutes 触发。 初次创建的角色(无历史消息)不触发——有意为之,避免新建角色立刻问候。 """ def __init__(self, min_idle_minutes: int = 360) -> None: self.min_idle_minutes = min_idle_minutes async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, ) -> ConditionResult: now = now or datetime.now(UTC) result = await session.execute( select(func.max(ChatMessage.created_at)).where(ChatMessage.character_id == character_id) ) last_time: datetime | None = result.scalar() if last_time is None: return ConditionResult() # 无历史消息,不触发 if last_time.tzinfo is None: last_time = last_time.replace(tzinfo=UTC) idle = (now - last_time).total_seconds() / 60 if idle >= self.min_idle_minutes: hours = int(idle // 60) return ConditionResult( triggered=True, trigger_reason=f"距离上次对话已过去 {hours} 小时", condition_name="idle", ) return ConditionResult() class TimeOfDayCondition(ProactiveCondition): """时段问候:若当前时间在配置的时段窗口内,按窗口名触发。 windows 配置示例:["morning", "late_night"] 默认时段映射: morning 06:00–09:00 afternoon 12:00–14:00 evening 17:00–19:00 late_night 22:00–24:00 """ _WINDOWS: dict[str, tuple[int, int]] = { "morning": (6, 9), "afternoon": (12, 14), "evening": (17, 19), "late_night": (22, 24), } def __init__(self, windows: list[str] | None = None) -> None: self.windows = windows or ["morning", "late_night"] async def check( self, _character_id: str, _session: AsyncSession, *, now: datetime | None = None, ) -> ConditionResult: now = now or datetime.now(UTC) hour = now.hour matched: str | None = None for name in self.windows: start, end = self._WINDOWS.get(name, (0, 0)) if start <= hour < end: matched = name break if matched is None: return ConditionResult() label = { "morning": "早晨", "afternoon": "午后", "evening": "傍晚", "late_night": "深夜", }.get(matched, matched) return ConditionResult( triggered=True, trigger_reason=f"当前是{label}时段", context={"time_of_day": matched}, condition_name="time_of_day", ) class AnniversaryCondition(ProactiveCondition): """纪念日匹配:每日首次 tick 检查 TimeAnniversary 表,匹配当天日期。 已通知标记:每次触发后写一条审计事件或专用记录 key,当天不重复。 使用简单的轮次标记:本实现通过检查该 character 当天是否有 已推送的 AnniversaryCondition 触发记录来判断——非精确消费语义, 但对 30s tick 的场景已足够(最多漏 1 个 tick,不影响)。 """ def __init__(self) -> None: self._checked_today: set[str] = set() """{(character_id, date.isoformat())} 已检查过的组合——防止同一天多次 tick 重复推送。""" async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, ) -> ConditionResult: now = now or datetime.now(UTC) today = now.date().isoformat() key = f"{character_id}:{today}" if key in self._checked_today: return ConditionResult() self._checked_today.add(key) result = await session.execute( select(TimeAnniversary).where( TimeAnniversary.character_id == character_id, TimeAnniversary.date == today, ) ) anniversaries: Sequence[TimeAnniversary] = result.scalars().all() if not anniversaries: return ConditionResult() # 取第一个匹配的纪念日作为上下文 ann = anniversaries[0] kind_labels = {"birthday": "生日", "reunion": "重逢纪念日", "custom": "纪念日"} kind_label = kind_labels.get(ann.kind, ann.kind) return ConditionResult( triggered=True, trigger_reason=f"今天是{kind_label}", context={ "anniversary_kind": ann.kind, "anniversary_label": ann.name or kind_label, "anniversary_date": ann.date, }, condition_name="anniversary", ) class UserReunionCondition(ProactiveCondition): """用户归来检测:通过 audit_event 或辰正事件判断用户是否刚回来。 查询最近一次 time.reunion 类型审计事件,若比上次消费的时间更新则触发。 """ def __init__(self) -> None: self._last_seen_reunion: str | None = None """上次已消费的 reunion 事件的 ID——避免重复触发。""" async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, # noqa: ARG002 ) -> ConditionResult: from app.models.safety import AuditEvent result = await session.execute( select(AuditEvent) .where( AuditEvent.character_id == character_id, AuditEvent.action == "time.reunion", ) .order_by(AuditEvent.created_at.desc()) .limit(1) ) event = result.scalar_one_or_none() if event is None: return ConditionResult() if self._last_seen_reunion == event.id: return ConditionResult() self._last_seen_reunion = event.id return ConditionResult( triggered=True, trigger_reason="用户回到了灵台", condition_name="reunion", ) class EmotionChangeCondition(ProactiveCondition): """情绪变化:对比缓存的上一轮 EmotionState 快照,强度变化超过阈值触发。 同一 mood 变化后至少 recheck_cooldown_ticks 个 tick 内不再检查。 """ def __init__(self, min_delta: float = 0.3, recheck_cooldown_ticks: int = 10) -> None: self.min_delta = min_delta self.recheck_cooldown_ticks = recheck_cooldown_ticks self._previous: dict[str, EmotionState] = {} self._cooldown: dict[str, int] = {} async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, # noqa: ARG002 ) -> ConditionResult: # 冷却检查 if character_id in self._cooldown: remaining = self._cooldown[character_id] if remaining > 0: self._cooldown[character_id] = remaining - 1 return ConditionResult() del self._cooldown[character_id] # 读当前情绪 result = await session.execute( select(CharacterEmotionState).where(CharacterEmotionState.character_id == character_id) ) row = result.scalar_one_or_none() if row is None: return ConditionResult() current = EmotionState(mood=row.mood, intensity=row.intensity) # 对比快照 previous = self._previous.get(character_id) self._previous[character_id] = current # 更新快照 if previous is None: return ConditionResult() # 首次无对比基线 if abs(current.intensity - previous.intensity) < self.min_delta: return ConditionResult() # 触发 mood_labels = { "neutral": "平静", "pleased": "愉悦", "touched": "感动", "wistful": "怅然怀念", "concerned": "关切", "festive": "喜庆", "melancholy": "惆怅", } from_label = mood_labels.get(previous.mood, previous.mood) to_label = mood_labels.get(current.mood, current.mood) intensity_pct = round(current.intensity * 100) self._cooldown[character_id] = self.recheck_cooldown_ticks return ConditionResult( triggered=True, trigger_reason=f"情绪从「{from_label}」变为「{to_label}(强度 {intensity_pct}%)」", context={ "from_mood": previous.mood, "to_mood": current.mood, "from_intensity": previous.intensity, "to_intensity": current.intensity, }, condition_name="emotion", ) class CuriosityCondition(ProactiveCondition): """好奇心触发:查该角色是否有 CuriosityQuestion(status=active)未被用户看到。 取最新一条作为开场上下文。消费后应标记该问题为 'answered' 或 'seen'(由外部驱动, 叩心枢只读不写)。 """ async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, # noqa: ARG002 ) -> ConditionResult: result = await session.execute( select(CuriosityQuestion) .where(CuriosityQuestion.character_id == character_id) .order_by(CuriosityQuestion.created_at.desc()) .limit(1) ) question = result.scalar_one_or_none() if question is None: return ConditionResult() return ConditionResult( triggered=True, trigger_reason="角色有一个想问的问题", context={ "curiosity_question": question.question, "curiosity_id": question.id, }, condition_name="curiosity", ) # --------------------------------------------------------------------------- # 第二梯队 ▸ 需跨模块联动 # --------------------------------------------------------------------------- class EvolutionAppliedCondition(ProactiveCondition): """演化应用:查 evolution_candidates 表,自上次检查后有新 applied 的记录则触发。 取最近一条 status=applied 的记录,用 title 和 proposal 作为开场上下文。 这样角色可以自然地谈起自己的变化。 """ def __init__(self) -> None: self._last_seen_at: datetime | None = None """上次已触发的演化候选的 created_at。""" async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, # noqa: ARG002 ) -> ConditionResult: result = await session.execute( select(EvolutionCandidate) .where( EvolutionCandidate.character_id == character_id, EvolutionCandidate.status == "applied", ) .order_by(EvolutionCandidate.created_at.desc()) .limit(1) ) candidate = result.scalar_one_or_none() if candidate is None: return ConditionResult() if self._last_seen_at is not None and candidate.created_at <= self._last_seen_at: return ConditionResult() self._last_seen_at = candidate.created_at return ConditionResult( triggered=True, trigger_reason=f"发生了新的变化:{candidate.title}", context={ "evolution_title": candidate.title, "evolution_proposal": candidate.proposal[:200], "evolution_level": candidate.level, }, condition_name="evolution_applied", ) class BigCycleCompletedCondition(ProactiveCondition): """大周天完成:查 big_cycles 表,自上次检查后有新完成的循环则触发。 取最近一条 status=completed 的记录,从 summary 提取可分享的体悟。 """ def __init__(self) -> None: self._last_seen_at: datetime | None = None """上次已触发的循环的 completed_at。""" async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, # noqa: ARG002 ) -> ConditionResult: result = await session.execute( select(BigCycle) .where( BigCycle.character_id == character_id, BigCycle.status == "completed", BigCycle.completed_at.isnot(None), ) .order_by(BigCycle.completed_at.desc()) .limit(1) ) cycle = result.scalar_one_or_none() if cycle is None: return ConditionResult() if self._last_seen_at is not None and cycle.completed_at <= self._last_seen_at: return ConditionResult() self._last_seen_at = cycle.completed_at summary = cycle.summary or "回顾了一下最近的记忆" return ConditionResult( triggered=True, trigger_reason="大周天自我评估完成,有了新的体悟", context={ "big_cycle_summary": summary, "big_cycle_trigger": cycle.trigger, }, condition_name="big_cycle_completed", ) class MemoryRecallCondition(ProactiveCondition): """记忆唤醒:查角色的 memories 表,自上次检查后有新记忆则触发。 新记忆的写入源可能是:大周天抽取、资料导入、用户主动创建。 每次 tick 取最新的未消费记忆作为开场素材。 """ def __init__(self) -> None: self._last_seen_at: datetime | None = None """上次已触发的记忆的 created_at。""" async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, # noqa: ARG002 ) -> ConditionResult: result = await session.execute( select(Memory) .where( Memory.character_id == character_id, Memory.status == "active", ) .order_by(Memory.created_at.desc()) .limit(1) ) memory = result.scalar_one_or_none() if memory is None: return ConditionResult() if self._last_seen_at is not None and memory.created_at <= self._last_seen_at: return ConditionResult() self._last_seen_at = memory.created_at return ConditionResult( triggered=True, trigger_reason=f"想起了一件关于「{memory.title}」的事", context={ "memory_title": memory.title, "memory_content": memory.content[:200], "memory_type": memory.type, }, condition_name="memory_recall", ) class RelationshipMilestoneCondition(ProactiveCondition): """关系里程碑:统计对话消息数与认识天数,达到阈值时触发。 每个角色只触发一次同类型的里程碑(记录到 _triggered 集合去重)。 按 message_milestones → day_milestones 顺序检查,命中即停。 """ def __init__( self, message_milestones: list[int] | None = None, day_milestones: list[int] | None = None, ) -> None: self.message_milestones = message_milestones or [100, 500, 1000] self.day_milestones = day_milestones or [30, 180, 365] self._triggered: set[str] = set() """已触发过的标记集合。格式:{character_id}:messages|days:{value}""" async def check( self, character_id: str, session: AsyncSession, *, now: datetime | None = None, ) -> ConditionResult: now = now or datetime.now(UTC) # 1. 消息数里程碑 count_result = await session.execute( select(func.count()) .select_from(ChatMessage) .where(ChatMessage.character_id == character_id) ) total_messages: int = count_result.scalar() or 0 for threshold in sorted(self.message_milestones): key = f"{character_id}:messages:{threshold}" if key in self._triggered: continue if total_messages >= threshold: self._triggered.add(key) return ConditionResult( triggered=True, trigger_reason=f"我们已聊了 {total_messages} 条消息", context={ "milestone_type": "messages", "milestone_value": total_messages, "milestone_threshold": threshold, }, condition_name="relationship_milestone", ) # 2. 天数里程碑 first_result = await session.execute( select(ChatMessage.created_at) .where(ChatMessage.character_id == character_id) .order_by(ChatMessage.created_at.asc()) .limit(1) ) first_msg_at: datetime | None = first_result.scalar() if first_msg_at is not None: if first_msg_at.tzinfo is None: first_msg_at = first_msg_at.replace(tzinfo=UTC) days = (now - first_msg_at).days for threshold in sorted(self.day_milestones): key = f"{character_id}:days:{threshold}" if key in self._triggered: continue if days >= threshold: self._triggered.add(key) return ConditionResult( triggered=True, trigger_reason=f"我们已认识了 {days} 天", context={ "milestone_type": "days", "milestone_value": days, "milestone_threshold": threshold, }, condition_name="relationship_milestone", ) return ConditionResult()