"""叩心枢(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)