M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路
This commit is contained in:
1 parent
e6a3b7121f
commit
5f36e3785c
86 files changed
+424
-14455
No files matched your search
Whitespace-only changes.
@@ -1,18 +0,0 @@
|
||||
"""聊天历史纯组装函数(IO 查询已迁 services/chat)。
|
||||
|
||||
``history_pairs`` 把已加载的消息列表转成 ``(role, content)`` 对,供上下文组装使用。
|
||||
本模块只接收已加载数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.chat import ChatMessage
|
||||
|
||||
|
||||
def history_pairs(messages: list[ChatMessage]) -> list[tuple[str, str]]:
|
||||
"""把消息列表转成 (role, content) 对,供上下文组装使用。"""
|
||||
return [
|
||||
(message.role, message.content)
|
||||
for message in messages
|
||||
if message.role in {"user", "assistant", "system"}
|
||||
]
|
||||
@@ -1,11 +0,0 @@
|
||||
"""聊天策略模块。"""
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
|
||||
|
||||
def validate_text_message(*, modality: str, content: str) -> None:
|
||||
if modality != "text":
|
||||
raise AppError("unsupported_media_type", "当前阶段仅支持文本消息。")
|
||||
if len(content.encode("utf-8")) > settings.max_text_message_bytes:
|
||||
raise AppError("payload_too_large", "单条文本消息超过大小限制。")
|
||||
@@ -44,8 +44,11 @@ def character_book_to_entries(book: dict | None) -> list[dict]:
|
||||
"""ST character_book entries → 世谱条目构造参数。"""
|
||||
if not book:
|
||||
return []
|
||||
raw_entries = book.get("entries") or []
|
||||
if isinstance(raw_entries, dict): # ST V2 规范:entries 以 uid 为键的对象
|
||||
raw_entries = list(raw_entries.values())
|
||||
result = []
|
||||
for e in book.get("entries") or []:
|
||||
for e in raw_entries:
|
||||
enabled_raw = e.get("enabled", True)
|
||||
result.append(
|
||||
{
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""叩心枢(KnockingPivot):人物主动对话守护模块。
|
||||
|
||||
设计见 docs/domain/core-domains.md。
|
||||
|
||||
叩心枢与辰正(时感)、灵核(情绪)并列——辰正提供时态语境,
|
||||
灵核产出情绪状态,叩心判断「此时该不该开口」。
|
||||
记忆浮现、情绪波动、时光流逝 → 叩动心弦 → 自然想说话。
|
||||
"""
|
||||
|
||||
from app.modules.knocking.conditions import (
|
||||
AnniversaryCondition,
|
||||
BigCycleCompletedCondition,
|
||||
ConditionResult,
|
||||
CuriosityCondition,
|
||||
EmotionChangeCondition,
|
||||
EvolutionAppliedCondition,
|
||||
MemoryRecallCondition,
|
||||
ProactiveCondition,
|
||||
RelationshipMilestoneCondition,
|
||||
TimeElapsedCondition,
|
||||
TimeOfDayCondition,
|
||||
UserReunionCondition,
|
||||
)
|
||||
from app.modules.knocking.pivot import KnockingPivot
|
||||
|
||||
__all__ = [
|
||||
"AnniversaryCondition",
|
||||
"BigCycleCompletedCondition",
|
||||
"ConditionResult",
|
||||
"CuriosityCondition",
|
||||
"EmotionChangeCondition",
|
||||
"EvolutionAppliedCondition",
|
||||
"KnockingPivot",
|
||||
"MemoryRecallCondition",
|
||||
"ProactiveCondition",
|
||||
"RelationshipMilestoneCondition",
|
||||
"TimeElapsedCondition",
|
||||
"TimeOfDayCondition",
|
||||
"UserReunionCondition",
|
||||
]
|
||||
@@ -1,573 +0,0 @@
|
||||
"""叩心枢触发条件引擎:接口与实现。
|
||||
|
||||
设计 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()
|
||||
@@ -1,99 +0,0 @@
|
||||
"""叩心枢会话/消息创建与一炁事件推送。
|
||||
|
||||
设计 docs/domain/core-domains.md§7。
|
||||
|
||||
主动会话优先复用已有活跃会话,避免每次触发都新建会话。
|
||||
只有在角色无活跃会话时才会创建新会话。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import logger
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.modules.runtime.events import Event
|
||||
from app.services.event_store import DbEventStore
|
||||
|
||||
|
||||
async def create_proactive_message(
|
||||
session: AsyncSession,
|
||||
event_store: DbEventStore,
|
||||
character_id: str,
|
||||
opener_text: str,
|
||||
condition_name: str,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""创建叩心枢主动发起的消息,可追加到现有会话或新建会话。
|
||||
|
||||
策略:
|
||||
- 若 ``session_id`` 不为空,追加消息到该会话(不创建新会话)
|
||||
- 若 ``session_id`` 为空,新建会话 + 消息
|
||||
|
||||
Returns:
|
||||
{"session_id": str, "message_id": str}: 实际写入的会话 ID 和消息 ID。
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
is_new = session_id is None
|
||||
|
||||
if is_new:
|
||||
# 创建新会话(status 默认 'active')
|
||||
chat_session = ChatSession(
|
||||
character_id=character_id,
|
||||
title=opener_text[:60],
|
||||
)
|
||||
session.add(chat_session)
|
||||
await session.flush()
|
||||
session_id = chat_session.id
|
||||
event_type = "proactive.session"
|
||||
else:
|
||||
event_type = "proactive.message"
|
||||
|
||||
# 创建开场消息(无 run_id——非流式、无推理过程)
|
||||
message = ChatMessage(
|
||||
character_id=character_id,
|
||||
session_id=session_id,
|
||||
role="assistant",
|
||||
modality="text",
|
||||
content=opener_text,
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
|
||||
# 推一炁事件
|
||||
payload: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"message_id": message.id,
|
||||
"character_id": character_id,
|
||||
"preview": opener_text[:100],
|
||||
"trigger": condition_name,
|
||||
"is_new": is_new,
|
||||
}
|
||||
if is_new:
|
||||
payload["title_preview"] = opener_text[:60]
|
||||
|
||||
event = Event(
|
||||
type=event_type,
|
||||
source="knocking_pivot",
|
||||
character_id=character_id,
|
||||
payload=payload,
|
||||
visibility="public",
|
||||
created_at=now.isoformat(),
|
||||
)
|
||||
# 复用调用方的 session——同一事务写 events 表,避免 session A 写 chat 表、
|
||||
# session B 写 events 表时两条连接争写导致锁冲突
|
||||
await event_store.append(event, session=session)
|
||||
|
||||
logger.info(
|
||||
f"knocking_pivot_{'session_created' if is_new else 'message_appended'}",
|
||||
character_id=character_id,
|
||||
session_id=session_id,
|
||||
message_id=message.id,
|
||||
trigger=condition_name,
|
||||
)
|
||||
return {"session_id": session_id, "message_id": message.id}
|
||||
@@ -1,190 +0,0 @@
|
||||
"""叩心枢开场消息生成器。
|
||||
|
||||
设计 docs/domain/core-domains.md §6。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.ai_provider.manager import ProviderManagerFactory
|
||||
from app.core.config import settings
|
||||
from app.core.logging import logger
|
||||
from app.modules.knocking.conditions import ConditionResult
|
||||
from app.modules.personality.emotion import EmotionState, to_prompt_text
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 回退模板
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FALLBACK_TEMPLATES: dict[str, str] = {
|
||||
"idle": "{{name}}默默地看了你一眼。",
|
||||
"time_of_day": "{{name}}正望着窗外出神。",
|
||||
"anniversary": "今日似乎是个特别的日子。",
|
||||
"reunion": "{{name}}注意到你回来了。",
|
||||
"emotion": "{{name}}轻轻叹了口气。",
|
||||
"curiosity": "{{name}}似乎有什么话想说。",
|
||||
"memory_recall": "{{name}}若有所思地望向远方。",
|
||||
"big_cycle_completed": "{{name}}似乎有所感悟。",
|
||||
"evolution_applied": "{{name}}看上去有些不一样了。",
|
||||
"relationship_milestone": "{{name}}望着你,嘴角泛起一丝笑意。",
|
||||
"default": "{{name}}若有所思。",
|
||||
}
|
||||
|
||||
_FALLBACK = "{{name}}正望着窗外出神。"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatMessageBrief:
|
||||
"""简略对话记录(只携带必要字段,避免依赖 ORM)。"""
|
||||
|
||||
role: str = ""
|
||||
content: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenerResult:
|
||||
"""开场消息生成结果。"""
|
||||
|
||||
text: str
|
||||
used_fallback: bool = False
|
||||
|
||||
|
||||
async def generate_opener(
|
||||
character_id: str,
|
||||
character_name: str,
|
||||
personality_summary: str | None,
|
||||
emotion: EmotionState | None,
|
||||
condition_result: ConditionResult,
|
||||
*,
|
||||
chat_history: list[ChatMessageBrief] | None = None,
|
||||
memory_context: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> OpenerResult:
|
||||
"""根据条件触发结果生成一句开场白。
|
||||
|
||||
依次尝试:
|
||||
1. 调用神识(LLM)生成带人格注入的开场白
|
||||
2. 失败时使用模板化回退
|
||||
|
||||
若提供 ``chat_history``,会将其拼入 prompt 使开场更贴合最近对话脉络。
|
||||
若提供 ``memory_context``,会将其作为角色的长期记忆注入 prompt。
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
timestamp = now.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 构造 prompt
|
||||
emotion_text = to_prompt_text(emotion) if emotion else "平静"
|
||||
summary = personality_summary or f"你是一个名叫{character_name}的角色。{_DEFAULT_PERSONA}"
|
||||
trigger_reason = condition_result.trigger_reason or ""
|
||||
|
||||
context_parts = _build_context_parts(condition_result)
|
||||
|
||||
system_prompt = _build_system_prompt(summary, emotion_text, timestamp)
|
||||
user_prompt = _build_user_prompt(
|
||||
character_name, trigger_reason, context_parts, chat_history, memory_context
|
||||
)
|
||||
|
||||
try:
|
||||
manager = ProviderManagerFactory.create()
|
||||
result = await manager.chat(
|
||||
manager.to_message_list([system_prompt], [("user", user_prompt)]),
|
||||
model_hint=settings.big_cycle_model,
|
||||
)
|
||||
text = result.content.strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回空开场文本")
|
||||
return OpenerResult(text=text, used_fallback=False)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"knocking_pivot_opener_fallback",
|
||||
character_id=character_id,
|
||||
condition=condition_result.condition_name,
|
||||
error=str(exc),
|
||||
)
|
||||
template = _FALLBACK_TEMPLATES.get(condition_result.condition_name, _FALLBACK)
|
||||
fallback = template.replace("{{name}}", character_name)
|
||||
return OpenerResult(text=fallback, used_fallback=True)
|
||||
|
||||
|
||||
def _build_context_parts(condition_result: ConditionResult) -> list[str]:
|
||||
"""组装条件相关的上下文片段。"""
|
||||
parts: list[str] = []
|
||||
if condition_result.context.get("anniversary_kind"):
|
||||
kind_label = condition_result.context.get("anniversary_label", "")
|
||||
parts.append(f"今天是一个特别的日子:{kind_label}。")
|
||||
if condition_result.context.get("curiosity_question"):
|
||||
parts.append(
|
||||
f"你一直想问用户一个问题:「{condition_result.context['curiosity_question']}」"
|
||||
)
|
||||
if condition_result.context.get("time_of_day") == "late_night":
|
||||
parts.append("夜深了,万籁俱寂。")
|
||||
if condition_result.context.get("memory_title"):
|
||||
title = condition_result.context["memory_title"]
|
||||
parts.append(f"你最近想起了一件事——「{title}」。")
|
||||
if condition_result.context.get("big_cycle_summary"):
|
||||
parts.append("你刚刚审视过自己的内心,有了一些新的思考。")
|
||||
if condition_result.context.get("evolution_title"):
|
||||
title = condition_result.context["evolution_title"]
|
||||
parts.append(f"你感觉自己有了一些新的变化:{title}。")
|
||||
if condition_result.context.get("milestone_type") == "messages":
|
||||
count = condition_result.context["milestone_value"]
|
||||
parts.append(f"你和用户的对话已经累积了 {count} 条。")
|
||||
if condition_result.context.get("milestone_type") == "days":
|
||||
days = condition_result.context["milestone_value"]
|
||||
parts.append(f"从你们第一次对话至今,已经过去了 {days} 天。")
|
||||
return parts
|
||||
|
||||
|
||||
def _format_chat_history(history: list[ChatMessageBrief]) -> str:
|
||||
"""将最近对话历史格式化为文本。"""
|
||||
lines = ["\n[最近对话记录]"]
|
||||
for msg in history:
|
||||
speaker = "你" if msg.role == "assistant" else "用户"
|
||||
lines.append(f"{speaker}:{msg.content[:200]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_system_prompt(personality_summary: str, emotion_text: str, timestamp: str) -> str:
|
||||
return (
|
||||
"你是一位角色扮演助手。以下是你的人物设定:\n"
|
||||
f"{personality_summary}\n\n"
|
||||
"[当前状态]\n"
|
||||
f"- 当前时间: {timestamp}\n"
|
||||
f"- 情绪状态: {emotion_text}\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_user_prompt(
|
||||
character_name: str,
|
||||
trigger_reason: str,
|
||||
context_parts: list[str],
|
||||
chat_history: list[ChatMessageBrief] | None = None,
|
||||
memory_context: str | None = None,
|
||||
) -> str:
|
||||
lines = ["[任务]"]
|
||||
lines.append(f"根据以上设定,生成一句自然、贴合{character_name}性格和当前情绪的开场白。")
|
||||
lines.append("要求:")
|
||||
lines.append("1. 一句话,不超过 80 字")
|
||||
lines.append("2. 符合角色的表达风格和说话方式、语气")
|
||||
lines.append("3. 反映当前情绪状态")
|
||||
lines.append("4. 不要问「你还在吗?」之类的问题")
|
||||
lines.append("5. 直接输出开场白,不要任何前缀或引号")
|
||||
if trigger_reason:
|
||||
lines.append(f"\n触发原因:{trigger_reason}")
|
||||
if context_parts:
|
||||
lines.append(f"\n{' '.join(context_parts)}")
|
||||
if memory_context:
|
||||
lines.append(f"\n{memory_context}")
|
||||
if chat_history:
|
||||
lines.append(_format_chat_history(chat_history))
|
||||
lines.append("\n注意:延续上次对话的语境自然地开启新话题,不要重复上一句的内容。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_DEFAULT_PERSONA = (
|
||||
"你对用户保持着温和的关注,"
|
||||
"平日里话不多,但每次开口都带着真诚。"
|
||||
"你善于观察,能从细微之处感知对方的情绪。"
|
||||
)
|
||||
@@ -1,366 +0,0 @@
|
||||
"""叩心枢(KnockingPivot)主循环。
|
||||
|
||||
人物主动对话守护进程:后台常驻循环(~30s tick),
|
||||
遍历活跃角色 → 检查触发条件 → 生成开场 → 创建会话/消息 → 推一炁事件。
|
||||
同时订阅一炁事件流(big_cycle.* / reflection.*)实现近实时快速路径。
|
||||
|
||||
叩心枢与辰正(时感)、灵核(情绪)并列——辰正提供时态语境,
|
||||
灵核产出情绪状态,叩心判断「此时该不该开口」。
|
||||
|
||||
设计 docs/domain/core-domains.md §4。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.core.logging import logger
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
from app.modules.knocking.conditions import (
|
||||
AnniversaryCondition,
|
||||
BigCycleCompletedCondition,
|
||||
CuriosityCondition,
|
||||
EmotionChangeCondition,
|
||||
EvolutionAppliedCondition,
|
||||
MemoryRecallCondition,
|
||||
ProactiveCondition,
|
||||
RelationshipMilestoneCondition,
|
||||
TimeElapsedCondition,
|
||||
TimeOfDayCondition,
|
||||
UserReunionCondition,
|
||||
)
|
||||
from app.modules.knocking.creator import create_proactive_message
|
||||
from app.modules.knocking.opener import ChatMessageBrief, generate_opener
|
||||
from app.modules.personality.emotion import EmotionState
|
||||
from app.modules.runtime.events import Event
|
||||
from app.services.event_store import DbEventStore
|
||||
from app.services.memory import memory_context as _fmt_memory_context
|
||||
from app.services.personality import build_personality_summary
|
||||
|
||||
|
||||
class KnockingPivot:
|
||||
"""叩心枢主动对话守护进程。
|
||||
|
||||
用法:
|
||||
pivot = KnockingPivot(event_store)
|
||||
await pivot.start()
|
||||
...
|
||||
await pivot.stop()
|
||||
"""
|
||||
|
||||
# 条件注册表(第一梯队 + 第二梯队 ▸ 按优先级排列)
|
||||
conditions: list[ProactiveCondition] = [
|
||||
EvolutionAppliedCondition(),
|
||||
BigCycleCompletedCondition(),
|
||||
TimeElapsedCondition(min_idle_minutes=360),
|
||||
TimeOfDayCondition(windows=["morning", "late_night"]),
|
||||
AnniversaryCondition(),
|
||||
UserReunionCondition(),
|
||||
EmotionChangeCondition(min_delta=0.3),
|
||||
MemoryRecallCondition(),
|
||||
RelationshipMilestoneCondition(),
|
||||
CuriosityCondition(),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_store: DbEventStore,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
*,
|
||||
tick_interval: float = 30.0,
|
||||
cooldown_minutes: int = 30,
|
||||
daily_limit: int = 3,
|
||||
quiet_hours_start: int = 23,
|
||||
quiet_hours_end: int = 8,
|
||||
) -> None:
|
||||
self._event_store = event_store
|
||||
self._session_factory = session_factory or AsyncSessionLocal
|
||||
self._tick_interval = tick_interval
|
||||
self._cooldown_minutes = cooldown_minutes
|
||||
self._daily_limit = daily_limit
|
||||
self._quiet_hours_start = quiet_hours_start
|
||||
self._quiet_hours_end = quiet_hours_end
|
||||
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._event_task: asyncio.Task[None] | None = None
|
||||
self._stop_event = asyncio.Event()
|
||||
self._event_queue: asyncio.Queue[Event] | None = None
|
||||
|
||||
# per-character 冷启动冷却(character_id → 下次可触发时间)
|
||||
self._cooldowns: dict[str, datetime] = {}
|
||||
# per-character 日计数(character_id → 今日日期 → 次数)
|
||||
self._daily_counts: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 生命周期
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None:
|
||||
logger.warning("knocking_pivot_already_running")
|
||||
return
|
||||
self._stop_event.clear()
|
||||
|
||||
# 订阅一炁事件流(大周天/小周天完成等),实现近实时快速路径
|
||||
self._event_queue = self._event_store.subscribe_by_pattern("big_cycle.*,reflection.*")
|
||||
self._event_task = asyncio.create_task(self._run_event_consumer())
|
||||
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("knocking_pivot_started", tick_interval=self._tick_interval)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is None:
|
||||
return
|
||||
self._stop_event.set()
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
if self._event_task is not None:
|
||||
self._event_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._event_task
|
||||
self._event_task = None
|
||||
if self._event_queue is not None:
|
||||
self._event_store.unsubscribe_by_pattern("big_cycle.*,reflection.*", self._event_queue)
|
||||
self._event_queue = None
|
||||
logger.info("knocking_pivot_stopped")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主循环
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as exc:
|
||||
err_str = str(exc)
|
||||
if "database is locked" in err_str:
|
||||
logger.warning(
|
||||
"knocking_pivot_db_locked_retry",
|
||||
error=err_str[:120],
|
||||
)
|
||||
else:
|
||||
logger.error("knocking_pivot_tick_error", error=err_str)
|
||||
await asyncio.sleep(self._tick_interval)
|
||||
|
||||
async def _run_event_consumer(self) -> None:
|
||||
"""事件消费者:订阅 big_cycle.* / reflection.* 事件,近实时触发叩心检查。
|
||||
|
||||
与 _run_loop 并行运行,事件驱动而非轮询驱动。
|
||||
每个事件提取 character_id,立即做一次条件检查(跳过冷却/日限等节流逻辑用轮询管理)。
|
||||
"""
|
||||
queue = self._event_queue
|
||||
if queue is None:
|
||||
return
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
try:
|
||||
await self._handle_event(event)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("knocking_pivot_event_error", event_type=event.type)
|
||||
|
||||
async def _handle_event(self, event: Event) -> None:
|
||||
"""响应一炁事件:提取 character_id,尝试用对应条件触发。"""
|
||||
character_id = event.character_id
|
||||
if not character_id:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
if self._in_quiet_hours(now):
|
||||
return
|
||||
if not self._can_trigger(character_id, now):
|
||||
return
|
||||
|
||||
# 大周天完成 → 快速检查 BigCycleCompletedCondition
|
||||
if event.type == "big_cycle.completed":
|
||||
async with self._session_factory() as session:
|
||||
cond = BigCycleCompletedCondition()
|
||||
result = await cond.check(character_id, session, now=now)
|
||||
if result.triggered:
|
||||
await self._execute_trigger(character_id, result, session, now)
|
||||
self._mark_triggered(character_id, now)
|
||||
return
|
||||
|
||||
# 反思完成 → 快速检查 MemoryRecallCondition
|
||||
if event.type == "reflection.completed":
|
||||
async with self._session_factory() as session:
|
||||
cond = MemoryRecallCondition()
|
||||
result = await cond.check(character_id, session, now=now)
|
||||
if result.triggered:
|
||||
await self._execute_trigger(character_id, result, session, now)
|
||||
self._mark_triggered(character_id, now)
|
||||
return
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""一次 tick:找出活跃角色 → 逐角色检查条件。"""
|
||||
now = datetime.now(UTC)
|
||||
if self._in_quiet_hours(now):
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
active_characters = await self._list_active_characters(session)
|
||||
if not active_characters:
|
||||
return
|
||||
|
||||
for character_id in active_characters:
|
||||
if not self._can_trigger(character_id, now):
|
||||
continue
|
||||
if await self._check_and_trigger(character_id, session, now):
|
||||
# 每次 tick 只触发一个角色——命中即停
|
||||
break
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _list_active_characters(self, session: AsyncSession) -> list[str]:
|
||||
"""返回全部 status='active' 的 character_id。"""
|
||||
result = await session.execute(select(Character.id).where(Character.status == "active"))
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
def _in_quiet_hours(self, now: datetime) -> bool:
|
||||
hour = now.hour
|
||||
if self._quiet_hours_start <= self._quiet_hours_end:
|
||||
return self._quiet_hours_start <= hour < self._quiet_hours_end
|
||||
return hour >= self._quiet_hours_start or hour < self._quiet_hours_end
|
||||
|
||||
def _can_trigger(self, character_id: str, now: datetime) -> bool:
|
||||
# 冷却
|
||||
cooldown_until = self._cooldowns.get(character_id)
|
||||
if cooldown_until is not None and now < cooldown_until:
|
||||
return False
|
||||
# 日上限
|
||||
today = now.date().isoformat()
|
||||
last_date, count = self._daily_counts.get(character_id, ("", 0))
|
||||
return not (last_date == today and count >= self._daily_limit)
|
||||
|
||||
def _mark_triggered(self, character_id: str, now: datetime) -> None:
|
||||
self._cooldowns[character_id] = now + timedelta(minutes=self._cooldown_minutes)
|
||||
today = now.date().isoformat()
|
||||
last_date, count = self._daily_counts.get(character_id, ("", 0))
|
||||
if last_date == today:
|
||||
self._daily_counts[character_id] = (today, count + 1)
|
||||
else:
|
||||
self._daily_counts[character_id] = (today, 1)
|
||||
|
||||
async def _check_and_trigger(
|
||||
self, character_id: str, session: AsyncSession, now: datetime
|
||||
) -> bool:
|
||||
"""遍历条件列表,任一触发则执行并返回 True;否则返回 False。"""
|
||||
for condition in self.conditions:
|
||||
result = await condition.check(character_id, session, now=now)
|
||||
if not result.triggered:
|
||||
continue
|
||||
|
||||
await self._execute_trigger(character_id, result, session, now)
|
||||
self._mark_triggered(character_id, now)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _execute_trigger(
|
||||
self,
|
||||
character_id: str,
|
||||
condition_result,
|
||||
session: AsyncSession,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""条件触发后的执行链:人格注入 → 开场(带上下文)→ 追加/新建会话 → 推事件。"""
|
||||
try:
|
||||
# 1. 加载角色对象(供人格摘要使用)
|
||||
result = await session.execute(select(Character).where(Character.id == character_id))
|
||||
character = result.scalar_one_or_none()
|
||||
if character is None:
|
||||
logger.warning(
|
||||
"knocking_pivot_character_not_found",
|
||||
character_id=character_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 2. 人格注入
|
||||
summary = await build_personality_summary(session, character)
|
||||
|
||||
# 3. 读取情绪
|
||||
emotion = await self._get_emotion(session, character_id)
|
||||
|
||||
# 4. 查找该角色已有的活跃会话(优先复用)
|
||||
session_result = await session.execute(
|
||||
select(ChatSession)
|
||||
.where(
|
||||
ChatSession.character_id == character_id,
|
||||
ChatSession.status == "active",
|
||||
)
|
||||
.order_by(ChatSession.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
existing_session = session_result.scalar_one_or_none()
|
||||
|
||||
# 5. 读取最近对话记录作为上下文
|
||||
chat_history: list[ChatMessageBrief] = []
|
||||
if existing_session:
|
||||
msg_result = await session.execute(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.session_id == existing_session.id)
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
raw = list(reversed(msg_result.scalars().all()))
|
||||
chat_history = [ChatMessageBrief(role=m.role, content=m.content) for m in raw]
|
||||
|
||||
# 6. 检索角色的长期记忆作为上下文
|
||||
memory_context: str | None = None
|
||||
mc = await _fmt_memory_context(session, character_id, limit=5)
|
||||
memory_context = mc if mc else None
|
||||
|
||||
# 7. 生成开场(携带上下文与记忆)
|
||||
character_name = character.display_name or character.name or character_id
|
||||
opener = await generate_opener(
|
||||
character_id=character_id,
|
||||
character_name=character_name,
|
||||
personality_summary=summary.text if summary and summary.text else None,
|
||||
emotion=emotion,
|
||||
condition_result=condition_result,
|
||||
chat_history=chat_history,
|
||||
memory_context=memory_context,
|
||||
now=now,
|
||||
)
|
||||
|
||||
# 8. 追加消息(复用已有会话,或创建新会话)
|
||||
existing_id = existing_session.id if existing_session else None
|
||||
await create_proactive_message(
|
||||
session=session,
|
||||
event_store=self._event_store,
|
||||
character_id=character_id,
|
||||
session_id=existing_id,
|
||||
opener_text=opener.text,
|
||||
condition_name=condition_result.condition_name,
|
||||
now=now,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def _get_emotion(self, session: AsyncSession, character_id: str) -> EmotionState | None:
|
||||
result = await session.execute(
|
||||
select(CharacterEmotionState).where(CharacterEmotionState.character_id == character_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return EmotionState(mood=row.mood, intensity=row.intensity)
|
||||
Reference in new issue
Block a user