初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,573 @@
|
||||
"""叩心枢触发条件引擎:接口与实现。
|
||||
|
||||
设计 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()
|
||||
Reference in new issue
Block a user