M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路
This commit is contained in:
1 parent
e6a3b7121f
commit
5f36e3785c
86 files changed
+424
-14455
No files matched your search
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user