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

+54 -96
View File
@@ -1,117 +1,75 @@
"""角色卡导入 / 导出服务。"""
"""角色卡导入 / 导出。
rewrite/1.0 M2b:导入不再直接建人物——ST V1/V2/V3 卡解析后产出**可审阅灵胚**
(产品 PRD §5.2「导入默认生成可审阅灵胚」);``character_book`` 暂存于灵胚道体草稿,
启镜时物化为新人物的世谱书与条目(见 jingsheng.service.activate_embryo)。
导出方向保持:人物 → ST V3 卡静态快照。
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.lorebook import Lorebook, LorebookEntry
from app.modules.imports.card_parser import parse_character_card
from app.modules.imports.export_mapping import character_to_card
from app.modules.imports.mapping import (
card_to_daoti,
card_to_profile,
character_book_to_entries,
)
from app.repositories import lorebook as lorebook_repo
from app.repositories.json_utils import dumps
from app.schemas.character import ImportCardResponse
async def import_character_card(
session: AsyncSession, raw: bytes, owner_scope: str
) -> Character:
"""解析角色卡字节 → 建 Character + 世谱(character_book)。返回 Character ORM。
def card_to_embryo_payload(raw: bytes) -> dict:
"""解析角色卡字节 → 灵胚创建载荷(name/description/identity_baseline)。
``personality`` 写入道体 ``personality_summary`` 作初始值;
``character_book`` 转为该角色的世谱书 + 条目。
identity_baseline 保存卡的全部有效字段与原始 character_book,
启镜前可经照心编辑(PATCH /embryo-drafts/{id})审阅修改。
"""
card = parse_character_card(raw)
daoti = card_to_daoti(card)
character = Character(
name=card.name or "未命名角色",
display_name=card.nickname or card.name or None,
language="zh-CN",
profile_json=dumps(card_to_profile(card)),
model_policy_json="{}",
personality_summary=daoti["personality_summary"],
privacy_level="normal",
owner_scope=owner_scope,
description = "\n\n".join(
part
for part in (
card.description.strip(),
f"【性格】{card.personality.strip()}" if card.personality.strip() else "",
f"【场景】{card.scenario.strip()}" if card.scenario.strip() else "",
)
if part
)
session.add(character)
await session.flush()
entries_data = character_book_to_entries(card.character_book)
if entries_data:
book = card.character_book or {}
lorebook = Lorebook(
character_id=character.id,
name=book.get("name", "") or card.name,
description=book.get("description", ""),
scan_depth=book.get("scan_depth"),
token_budget=book.get("token_budget"),
recursive_scanning=bool(book.get("recursive_scanning", False)),
scope="character",
owner_scope=owner_scope,
)
session.add(lorebook)
await session.flush()
for entry in entries_data:
session.add(
LorebookEntry(
lorebook_id=lorebook.id,
keys_json=dumps(entry["keys"]),
content=entry["content"],
secondary_keys_json=dumps(entry["secondary_keys"]),
constant=entry["constant"],
selective=entry["selective"],
enabled=entry["enabled"],
case_sensitive=entry["case_sensitive"],
use_regex=entry["use_regex"],
position=entry["position"],
insertion_order=entry["insertion_order"],
priority=entry["priority"],
comment=entry["comment"],
)
)
await session.commit()
await session.refresh(character)
return character
baseline = {
"import_card": {
"spec_version": card.spec_version,
"description": card.description,
"personality": card.personality,
"scenario": card.scenario,
"first_mes": card.first_mes,
"mes_example": card.mes_example,
"system_prompt": card.system_prompt,
"post_history_instructions": card.post_history_instructions,
"alternate_greetings": card.alternate_greetings,
"tags": card.tags,
"creator": card.creator,
"character_version": card.character_version,
"character_book": card.character_book,
}
}
name = card.name.strip() or "未命名灵胚"
return {
"name": name[:120],
"description": description or None,
"identity_baseline": baseline,
"book_entry_count": _count_book_entries(card.character_book),
}
async def import_character_card_response(
raw: bytes, owner_scope: str
) -> ImportCardResponse:
"""导入角色卡 → ImportCardResponse(供 API 调用,自管 session)。"""
async with AsyncSessionLocal() as session:
character = await import_character_card(session, raw, owner_scope)
books = await lorebook_repo.list_books(session, character.id, owner_scope)
book_id = books[0].id if books else None
entries = (
await lorebook_repo.list_book_entries(session, book_id) if book_id else []
)
return ImportCardResponse(
character_id=character.id,
name=character.name,
display_name=character.display_name,
lorebook_id=book_id,
entry_count=len(entries),
)
def _count_book_entries(book: dict | None) -> int:
if not book:
return 0
entries = book.get("entries")
if isinstance(entries, (dict, list)):
return len(entries)
return 0
async def export_character_card(character_id: str, owner_scope: str) -> dict:
"""导出角色为 ST V3 卡静态快照(丢弃演化维度)。"""
"""导出人物为 ST V3 卡静态快照(丢弃演化维度)。"""
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
books = await lorebook_repo.list_books(session, character_id, owner_scope)
book = books[0] if books else None
entries = (
await lorebook_repo.list_book_entries(session, book.id) if book else []
)
return character_to_card(character, book, entries)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character_to_card(character)
-252
View File
@@ -1,252 +0,0 @@
"""聊天服务。"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.modules.safety.confirmation import ConfirmationStore
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage, ChatSession
from app.repositories import chat as chat_repo
from app.repositories.json_utils import dumps, loads_list
from app.schemas.chat import (
ChatMessageListResponse,
ChatMessageResponse,
ChatSessionListResponse,
ChatSessionResponse,
CreateSessionRequest,
SelectVersionRequest,
SendMessageRequest,
SendMessageResponse,
UpdateSessionRequest,
)
from app.services.characters import get_active_character_model
from app.services.serializers import chat_message_to_response, chat_session_to_response
if TYPE_CHECKING:
from app.application.agent.confirmation import ConfirmationRegistry
from app.application.agent.python_skills import PythonSkillCatalog
from app.application.agent.run_registry import AgentRunRegistry
from app.application.tool_runtime.runtime import ToolRuntime
from app.modules.runtime.events import EventStore
async def create_session(
payload: CreateSessionRequest, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
character_id = payload.character_id or character.id
if character_id != character.id:
raise AppError("not_found", "人物不存在。")
chat_session = ChatSession(
character_id=character_id,
title=payload.title,
status="active",
owner_scope=owner_scope,
)
session.add(chat_session)
await session.commit()
await session.refresh(chat_session)
return chat_session_to_response(chat_session)
_PREVIEW_LEN = 80
def _truncate_preview(content: str | None) -> str | None:
"""截断消息内容为列表预览长度;空内容返回 None。"""
if not content:
return None
text = content.strip()
if len(text) <= _PREVIEW_LEN:
return text
return text[:_PREVIEW_LEN].rstrip() + "…"
async def list_session_responses(
*,
owner_scope: str = settings.default_owner_scope,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> ChatSessionListResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
sessions = await chat_repo.list_sessions(
session, character.id, status=status, owner_scope=owner_scope, limit=limit, offset=offset
)
total = await chat_repo.count_sessions(
session, character.id, status=status, owner_scope=owner_scope
)
session_ids = [s.id for s in sessions]
counts = await chat_repo.count_messages_by_sessions(session, session_ids)
previews = await chat_repo.last_message_previews_by_sessions(session, session_ids)
return ChatSessionListResponse(
items=[
chat_session_to_response(
s,
message_count=counts.get(s.id, 0),
last_message_preview=_truncate_preview(previews.get(s.id)),
)
for s in sessions
],
total=total,
)
async def get_session_response(
session_id: str, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
async with AsyncSessionLocal() as session:
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
message_count = await chat_repo.count_messages(session, chat_session.id)
recent = await chat_repo.list_recent_messages(session, chat_session.id, limit=1)
preview = _truncate_preview(recent[0].content if recent else None)
return chat_session_to_response(
chat_session,
message_count=message_count,
last_message_preview=preview,
)
_ALLOWED_SESSION_STATUSES = {"active", "archived"}
async def update_session_response(
session_id: str, payload: UpdateSessionRequest, owner_scope: str = settings.default_owner_scope
) -> ChatSessionResponse:
"""更新会话标题或状态(active/archived);归属校验同 get。
deleted 态由 DELETE 端点承担,此处禁止直接置为 deleted。
"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化,无需重复查 character
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
if payload.title is not None:
chat_session.title = payload.title
if payload.status is not None:
if payload.status not in _ALLOWED_SESSION_STATUSES:
raise AppError(
"invalid_request",
"会话状态仅可切换为 active 或 archived;删除请使用 DELETE。",
)
chat_session.status = payload.status
await session.commit()
await session.refresh(chat_session)
message_count = await chat_repo.count_messages(session, chat_session.id)
return chat_session_to_response(chat_session, message_count=message_count)
async def delete_session(session_id: str, owner_scope: str = settings.default_owner_scope) -> None:
"""软删除会话:session.status=deleted 并软删其全部消息。
Run/Event 为审计/日志型数据,保留不删;消息通过 deleted_at 软删,
与现有查询过滤保持一致。
"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化,无需重复查 character
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
chat_session.status = "deleted"
await chat_repo.soft_delete_session_messages(session, session_id)
await session.commit()
async def list_message_responses(
session_id: str,
*,
owner_scope: str = settings.default_owner_scope,
limit: int = 100,
offset: int = 0,
) -> ChatMessageListResponse:
async with AsyncSessionLocal() as session:
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
messages = await chat_repo.list_messages(session, session_id, limit=limit, offset=offset)
total = await chat_repo.count_messages(session, session_id)
return ChatMessageListResponse(
items=[chat_message_to_response(message) for message in messages],
total=total,
)
async def recent_chat_messages(
session: AsyncSession, session_id: str, *, limit: int = 12
) -> list[ChatMessage]:
"""加载会话最近消息(供上下文组装)——接收外部 session,供 send_message usecase 复用。"""
return await chat_repo.list_recent_messages(session, session_id, limit=limit)
async def send_message(
session: AsyncSession,
session_id: str,
payload: SendMessageRequest,
*,
owner_scope: str = settings.default_owner_scope,
tool_runtime: "ToolRuntime | None" = None,
confirmation_registry: "ConfirmationRegistry | None" = None,
confirmation_store: "ConfirmationStore | None" = None,
event_store: "EventStore | None" = None,
agent_run_registry: "AgentRunRegistry | None" = None,
python_skill_catalog: "PythonSkillCatalog | None" = None,
) -> SendMessageResponse:
"""发送消息——接收外部 session(由 run_idempotent 注入,保证幂等记录与发送同事务)。"""
from app.application.chat.send_message import SendChatMessageUseCase
return await SendChatMessageUseCase(
session,
owner_scope=owner_scope,
tool_runtime=tool_runtime,
confirmation_registry=confirmation_registry,
confirmation_store=confirmation_store,
event_store=event_store,
agent_run_registry=agent_run_registry,
python_skill_catalog=python_skill_catalog,
).execute(session_id, payload)
async def select_message_version(
session_id: str,
message_id: str,
payload: SelectVersionRequest,
*,
owner_scope: str = settings.default_owner_scope,
) -> ChatMessageResponse:
"""选择/激活消息的某个历史版本。"""
async with AsyncSessionLocal() as session:
# owner_scope 校验已由 get_session 内化
chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
message = await chat_repo.get_message(session, message_id)
if message is None or message.session_id != session_id:
raise AppError("not_found", "消息不存在。")
versions = loads_list(message.versions_json)
if payload.version_idx < 0 or payload.version_idx >= len(versions):
raise AppError(
"invalid_request",
f"版本索引 {payload.version_idx} 超出范围 (0-{len(versions) - 1})。",
)
await chat_repo.set_message_active_version(
session,
message_id,
payload.version_idx,
dumps(versions),
)
await session.commit()
await session.refresh(message)
return chat_message_to_response(message)
-141
View File
@@ -1,141 +0,0 @@
"""灵核情绪状态服务(设计 §11):拉模式计算 + 一炁订阅 time.* 处理器。
降级原则(设计 §12 / 辰正 §12):任何失败 → emotion_state=None,不阻塞对话/事件流。
本模块是一炁订阅基础设施(计划 5 ``Dispatcher``)的第一个真实消费者。
"""
from __future__ import annotations
from datetime import UTC, datetime
from loguru import logger
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.chat import ChatMessage
from app.models.personality_emotion import CharacterEmotionState
from app.modules.personality.emotion import (
EmotionState,
apply_signal,
decay,
parse_baseline,
to_prompt_text,
)
from app.modules.runtime.events import Event
from app.repositories.personality import get_latest_personality, get_personality
from app.repositories.personality_emotion import get_state, upsert_state
async def _resolve_baseline(session: AsyncSession, character_id: str) -> EmotionState:
"""从当前人格版本 ``emotion_baseline`` 解析 baseline。
character 不存在 / 无人格版本 / baseline 损坏 → 默认 neutral 0.2(parse_baseline 容错)。
"""
character = await session.get(Character, character_id)
if character is None:
return EmotionState()
pv = None
if character.current_personality_version_id:
pv = await get_personality(session, character.current_personality_version_id)
if pv is None:
pv = await get_latest_personality(session, character_id)
if pv is None:
return EmotionState()
return parse_baseline(pv.emotion_baseline)
async def _count_user_turns_since(
session: AsyncSession, character_id: str, since: datetime
) -> float:
"""自 since 以来该角色未删除的 user 消息数(近似对话回合)。"""
result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(
ChatMessage.character_id == character_id,
ChatMessage.role == "user",
ChatMessage.created_at > since,
ChatMessage.deleted_at.is_(None),
)
)
return float(result.scalar_one())
async def compute_current_state(session: AsyncSession, character_id: str) -> EmotionState | None:
"""拉模式:读最新 state + baseline,按自上次更新以来的 user 回合 decay。
- 无 state row:返回 baseline(不 decay)。
- 有 state row:``decay(state, baseline, elapsed_turns)``。
- 任何异常降级返回 None。
"""
try:
baseline = await _resolve_baseline(session, character_id)
row = await get_state(session, character_id)
if row is None:
return baseline
current = EmotionState(mood=row.mood, intensity=row.intensity)
# SQLite DateTime(timezone=True) 读回可能 naive,与 aware 比较前补 tz(同 time_sense 防御)。
updated = row.updated_at
if updated.tzinfo is None:
updated = updated.replace(tzinfo=UTC)
elapsed = await _count_user_turns_since(session, character_id, updated)
return decay(current, baseline, elapsed)
except Exception:
logger.exception("灵核 compute_current_state 降级 character_id={}", character_id)
return None
async def compute_emotion_prompt(session: AsyncSession, character_id: str) -> str | None:
"""拉模式 → system prompt 文本;失败返回 None(不阻塞对话)。"""
state = await compute_current_state(session, character_id)
if state is None:
return None
return to_prompt_text(state)
async def apply_time_event(event: Event, session: AsyncSession) -> None:
"""订阅处理器核心:辰正 time.* 事件 → 更新情绪状态。
- 读当前 state(无则用 baseline)
- ``apply_signal`` 计算新状态
- ``upsert_state`` + 同步 ``Character.current_mood_id``
无 character_id 的事件直接跳过;DB 异常由 ``handle_time_event`` 兜底。
"""
character_id = event.character_id
if character_id is None:
return
baseline = await _resolve_baseline(session, character_id)
row = await get_state(session, character_id)
current = EmotionState(mood=row.mood, intensity=row.intensity) if row else baseline
new_state = apply_signal(current, event.type, event.payload, baseline)
await upsert_state(
session,
CharacterEmotionState(
character_id=character_id,
mood=new_state.mood,
intensity=new_state.intensity,
baseline_mood=baseline.mood,
baseline_intensity=baseline.intensity,
last_trigger_event_id=event.id,
),
)
character = await session.get(Character, character_id)
if character is not None:
character.current_mood_id = new_state.mood
async def handle_time_event(event: Event) -> None:
"""Dispatcher ``time.*`` 处理器:自管 session + 提交,降级不抛。
一炁订阅基础设施(计划 5)的第一个真实消费者,注册在 ``main.py`` lifespan。
"""
try:
async with AsyncSessionLocal() as session:
await apply_time_event(event, session)
await session.commit()
except Exception:
logger.exception("灵核 handle_time_event 降级 event_id={} type={}", event.id, event.type)
-390
View File
@@ -1,390 +0,0 @@
"""辰正时态服务编排(设计 §6 流 A + 流 C)。
职责:
- 流 A:拉数据 → compute_snapshot + compute_modulation → 组 temporal_context 文本
- 流 C:检测 care / sentiment / reunion → 发 time.* 事件到 EventStore
降级原则(设计 §12):任何计算失败返回最小结果,不阻塞对话。
本 plan 不含流 B(后台 tick)——依赖未实现的 background_task 基础设施。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage
from app.models.time_anniversary import TimeAnniversary
from app.modules.runtime.events import Event, EventStore
from app.modules.time_sense.context_text import render_temporal_context
from app.modules.time_sense.duration import AcquaintanceDuration, OfflineThresholds
from app.modules.time_sense.modulation import ModulationFactors, ModulationInput, compute_modulation
from app.modules.time_sense.sentiment import SentimentInput, detect_sentiment
from app.modules.time_sense.snapshot import (
AnniversaryRef,
LocationContext,
SnapshotInput,
TemporalSnapshot,
compute_snapshot,
)
from app.repositories.time_anniversaries import (
create_anniversary,
delete_anniversary,
get_anniversary,
list_anniversaries,
update_anniversary,
)
from app.schemas.time import AnniversaryCreateRequest, AnniversaryUpdateRequest
from app.services.characters import get_active_character_model
class LiveSnapshotResult(BaseModel):
"""compute_live_snapshot 的返回。"""
snapshot: TemporalSnapshot | None = None
factors: ModulationFactors | None = None
context_text: str = ""
emitted_events: list[Event] = Field(default_factory=list)
async def emit_time_event(
store: EventStore,
event_type: str,
character_id: str,
payload: dict[str, Any],
*,
run_id: str | None = None,
source: str = "time_sense",
sensitivity: str = "low",
) -> Event:
"""构造并 append 一个 time.* 事件。"""
event = Event(
type=event_type,
source=source,
payload=payload,
character_id=character_id,
run_id=run_id,
sensitivity=sensitivity,
)
return await store.append(event)
def _thresholds_from_config() -> OfflineThresholds:
return OfflineThresholds(
active_minutes=settings.offline_active_minutes,
short_absence_minutes=settings.offline_short_absence_minutes,
long_absence_minutes=settings.offline_long_absence_minutes,
reunion_impact_minutes=settings.offline_reunion_impact_minutes,
)
async def _resolve_last_active(
session: AsyncSession, character_id: str, fallback: datetime
) -> datetime:
"""查 chat_messages 最新一条的 created_at;无记录返回 fallback。
DB 异常向上冒泡,由 ``compute_live_snapshot`` 降级处理(设计 §12)。
"""
result = await session.execute(
select(ChatMessage.created_at)
.where(ChatMessage.character_id == character_id)
.order_by(ChatMessage.created_at.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return row if row is not None else fallback
def _anniversary_refs(rows: list[TimeAnniversary]) -> list[AnniversaryRef]:
"""TimeAnniversary ORM → AnniversaryRef(snapshot 输入)。"""
refs: list[AnniversaryRef] = []
for a in rows:
refs.append(
AnniversaryRef(
id=a.id,
name=a.name,
calendar=a.calendar,
date_iso=a.date,
recurs_yearly=a.recurs_yearly,
kind=a.kind,
)
)
return refs
async def compute_live_snapshot(
session: AsyncSession,
store: EventStore,
character_id: str, # noqa: ARG001 v1 单人物:忽略传入 id,使用当前 active 角色(设计已注明)
signals: ModulationInput,
*,
now: datetime | None = None,
timezone: str | None = None,
run_id: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> LiveSnapshotResult:
"""流 A + 流 C:算快照/因子/上下文 + 触发 time.* 事件。
任何异常降级返回最小结果(context_text 含 now_utc,因子/事件空),不抛。
"""
now = now or utc_now()
emitted: list[Event] = []
try:
character = await get_active_character_model(session, owner_scope=owner_scope)
# v1:character_id 应与 active 一致;若不一致仍用 active(v1 单人物)
anniv_rows = await list_anniversaries(session, character.id)
anniv_refs = _anniversary_refs(anniv_rows)
anchor = character.activated_at or character.created_at
# SQLite 的 DateTime(timezone=True) 仍按 naive 读回(Postgres 不受影响);
# 此处与 serializers.py 同样的防御式补 tzinfo,避免与 aware now 做差时 TypeError。
if anchor.tzinfo is None:
anchor = anchor.replace(tzinfo=UTC)
last_active = await _resolve_last_active(session, character.id, fallback=now)
if last_active.tzinfo is None:
last_active = last_active.replace(tzinfo=UTC)
location = LocationContext(
timezone=timezone or settings.user_timezone,
city=settings.default_city,
locale=settings.default_locale,
)
inp = SnapshotInput(
now=now,
timezone=timezone or settings.user_timezone,
location=location,
anniversaries=anniv_refs,
acquaintance_anchor=anchor,
last_active_at=last_active,
home_timezone=settings.home_timezone,
offline_thresholds=_thresholds_from_config(),
)
# v1 阈值可调(设计 §7.3):ModulationInput 阈值默认 None,此处从 settings 注入;
# 保持 modules/time_sense 纯函数(不读 settings),测试可显式传值覆盖。
if signals.jetlag_threshold_minutes is None:
signals = signals.model_copy(
update={"jetlag_threshold_minutes": settings.jetlag_threshold_minutes}
)
if signals.late_night_min_turns is None:
signals = signals.model_copy(
update={"late_night_min_turns": settings.late_night_min_turns}
)
snapshot = compute_snapshot(inp)
factors = compute_modulation(snapshot, signals)
context_text = render_temporal_context(snapshot, factors)
# 流 C 事件触发(Task 4 在此扩展)
await _trigger_events(
store,
snapshot,
factors,
signals,
character.id,
emitted,
anchor,
run_id=run_id,
)
return LiveSnapshotResult(
snapshot=snapshot,
factors=factors,
context_text=context_text,
emitted_events=emitted,
)
except Exception:
# 降级:最小快照(设计 §12)——记日志便于排查,辰正永不阻塞对话
logger.exception("辰正 compute_live_snapshot 降级 character_id={}", character_id)
return LiveSnapshotResult(
snapshot=None,
factors=None,
context_text=f"[时态上下文暂不可用] 当前 UTC:{now.astimezone(UTC).isoformat()}",
emitted_events=emitted,
)
async def _trigger_events(
store: EventStore,
snapshot: TemporalSnapshot,
factors: ModulationFactors,
signals: ModulationInput, # noqa: ARG001 v1 仅 care 用 factors;signals 留 v2 内容信号扩展
character_id: str,
emitted: list[Event],
anchor: datetime, # noqa: ARG001 留 v2 认识天数/里程碑锚点事件
run_id: str | None = None,
) -> None:
"""流 C:care / sentiment / reunion 检测 + emit。"""
# 1. 关怀(基于 compute_modulation 的 care_kind)
if factors.care_kind is not None and settings.care_enabled:
ev = await emit_time_event(
store,
"time.user_care",
character_id,
{
"care_kind": factors.care_kind,
"care_suggest": factors.care_suggest,
"late_night_fragility": factors.late_night_fragility,
"day_period": snapshot.day_period,
"local_hour": snapshot.local_hour,
},
run_id=run_id,
)
emitted.append(ev)
# 2. 感慨(5 触发)
is_reunion = snapshot.offline.band == "reunion_impact"
next_milestone_days = snapshot.next_milestone.days_until if snapshot.next_milestone else None
sentiment_signals = SentimentInput(
duration_days=snapshot.acquaintance.total_days,
next_milestone_days=next_milestone_days,
has_active_milestone=bool(snapshot.active_milestones),
is_reunion=is_reunion,
has_reunion_memory=is_reunion, # v1 简化:重逢即假定有临别记忆;v2 从息壤查
strong_solar_terms=settings.strong_solar_terms,
prewarm_days=settings.sentiment_prewarm_days,
duration_nodes=settings.duration_nodes,
)
trigger = detect_sentiment(snapshot, sentiment_signals)
if trigger is not None:
ev = await emit_time_event(
store,
"time.sentiment",
character_id,
{
"trigger_type": trigger,
"duration_days": snapshot.acquaintance.total_days,
"day_period": snapshot.day_period,
},
run_id=run_id,
)
emitted.append(ev)
# 3. 重逢
if is_reunion:
ev = await emit_time_event(
store,
"time.reunion",
character_id,
{
"offline_minutes": round(snapshot.offline.minutes, 1),
"reunion_intensity": factors.reunion_intensity,
"offline_band": snapshot.offline.band,
},
sensitivity="medium", # 重逢涉及久别记忆,标 medium
run_id=run_id,
)
emitted.append(ev)
# ---- service 包装函数(薄 endpoint 调用;session 在此自管)----
async def get_live_snapshot(
store: EventStore, owner_scope: str = settings.default_owner_scope
) -> LiveSnapshotResult:
"""GET /time/snapshot:当前 active 人物的实时快照。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
return await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
owner_scope=owner_scope,
)
async def list_anniversaries_for_active(
owner_scope: str = settings.default_owner_scope,
) -> list[TimeAnniversary]:
"""GET /time/anniversaries:当前 active 人物的纪念日列表。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
return await list_anniversaries(session, character.id)
async def create_anniversary_for_active(
payload: AnniversaryCreateRequest, owner_scope: str = settings.default_owner_scope
) -> TimeAnniversary:
"""POST /time/anniversaries:为当前 active 人物创建纪念日。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
anni = TimeAnniversary(
character_id=character.id,
name=payload.name,
calendar=payload.calendar,
date=payload.date,
recurs_yearly=payload.recurs_yearly,
kind=payload.kind,
subject_type=payload.subject_type,
source=payload.source,
)
created = await create_anniversary(session, anni)
await session.commit()
return created
async def get_anniversary_or_404(
anniv_id: str,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> TimeAnniversary:
"""GET /time/anniversaries/{id}:取单个,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
anni = await get_anniversary(session, anniv_id)
if anni is None:
raise AppError("not_found", "纪念日不存在。")
return anni
async def update_anniversary_or_404(
anniv_id: str,
payload: AnniversaryUpdateRequest,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> TimeAnniversary:
"""PATCH /time/anniversaries/{id}:部分更新,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
fields = payload.model_dump(exclude_none=True)
updated = await update_anniversary(session, anniv_id, **fields)
if updated is None:
raise AppError("not_found", "纪念日不存在。")
await session.commit()
return updated
async def delete_anniversary_or_404(
anniv_id: str,
owner_scope: str = settings.default_owner_scope, # noqa: ARG001
) -> None:
"""DELETE /time/anniversaries/{id}:删除,不存在抛 AppError(not_found)。"""
async with AsyncSessionLocal() as session:
ok = await delete_anniversary(session, anniv_id)
if not ok:
raise AppError("not_found", "纪念日不存在。")
await session.commit()
async def get_acquaintance_for_active(
store: EventStore, owner_scope: str = settings.default_owner_scope
) -> AcquaintanceDuration:
"""GET /time/acquaintance:当前 active 人物的认识时长。snapshot 降级时抛 503。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope=owner_scope)
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
owner_scope=owner_scope,
)
if result.snapshot is None:
# 契约 §15 / 设计 §12:time_context_missing → invalid_request(默认 400)
raise AppError("time_context_missing", "时态上下文不可用。")
return result.snapshot.acquaintance