M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路

This commit is contained in:
chenxin committed 2026-08-21 23:43:29 +08:00
1 parent e6a3b7121f
commit 5f36e3785c
86 files changed
+424 -14455

No files matched your search

Whitespace-only changes.
-18
View File
@@ -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"}
]
-11
View File
@@ -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", "单条文本消息超过大小限制。")
+4 -1
View File
@@ -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(
{
-40
View File
@@ -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",
]
-573
View File
@@ -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()
-99
View File
@@ -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}
-190
View File
@@ -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 = (
"你对用户保持着温和的关注,"
"平日里话不多,但每次开口都带着真诚。"
"你善于观察,能从细微之处感知对方的情绪。"
)
-366
View File
@@ -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)