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

@@ -1,29 +1,49 @@
"""角色卡导入 / 导出 API。"""
"""角色卡导入 / 导出 API。
rewrite/1.0 M2b:导入产出**可审阅灵胚**(含 character_book 暂存),不再直接建人物;
经照心编辑与启镜(POST /embryo-drafts/{id}/activate)后才物化人物与世谱。
"""
import json
from fastapi import APIRouter, Depends, File, Response, UploadFile
from app.api.v1.deps import get_current_user
from app.domains import jingsheng
from app.schemas.auth import CurrentUser
from app.schemas.character import ImportCardResponse
from app.services.card_import import export_character_card, import_character_card_response
from app.services.card_import import card_to_embryo_payload, export_character_card
router = APIRouter(prefix="/characters", tags=["characters"])
@router.post("/import-card", response_model=ImportCardResponse)
@router.post("/import-card", status_code=201)
async def import_character_card_api(
file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"),
current_user: CurrentUser = Depends(get_current_user),
) -> ImportCardResponse:
"""导入酒馆角色卡(V1/V2/V3),建立人物 + 世谱(character_book)。
) -> dict:
"""导入酒馆角色卡(V1/V2/V3)→ 生成可审阅灵胚。
``personality`` 写入道体 ``personality_summary`` 作初始值;
``character_book`` 转为该角色的世谱书 + 条目。
卡内容整体进入灵胚道体草稿(identity_baseline.import_card),
``character_book`` 在启镜时物化为该人物的世谱书与条目。
"""
raw = await file.read()
return await import_character_card_response(raw, owner_scope=current_user.owner_scope)
payload = card_to_embryo_payload(raw)
book_entry_count = payload.pop("book_entry_count")
embryo = await jingsheng.create_embryo(
owner_scope=current_user.owner_scope,
source="imported",
name=payload["name"],
description=payload["description"],
identity_baseline=payload["identity_baseline"],
)
return {
"embryo_id": embryo.id,
"status": embryo.status,
"name": embryo.name,
"source": embryo.source,
"book_entry_count": book_entry_count,
"next": "照心编辑 PATCH /embryo-drafts/{id},启镜 POST /embryo-drafts/{id}/activate",
}
@router.get("/{character_id}/export-card")
-137
View File
@@ -1,137 +0,0 @@
"""聊天 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request, Response, status
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.chat import (
ChatMessageListResponse,
ChatMessageResponse,
ChatSessionListResponse,
ChatSessionResponse,
CreateSessionRequest,
SelectVersionRequest,
SendMessageRequest,
SendMessageResponse,
UpdateSessionRequest,
)
from app.services.chat import (
create_session,
delete_session,
get_session_response,
list_message_responses,
list_session_responses,
select_message_version,
send_message,
update_session_response,
)
from app.services.idempotency import run_idempotent
router = APIRouter(prefix="/chat", tags=["chat"])
@router.post("/sessions", response_model=ChatSessionResponse)
async def create_chat_session(
payload: CreateSessionRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> ChatSessionResponse:
return await create_session(payload, owner_scope=current_user.owner_scope)
@router.get("/sessions", response_model=ChatSessionListResponse)
async def list_chat_sessions(
current_user: CurrentUser = Depends(get_current_user),
status: str | None = Query(default=None, description="按状态过滤:active / archived"),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> ChatSessionListResponse:
return await list_session_responses(
owner_scope=current_user.owner_scope, status=status, limit=limit, offset=offset
)
@router.get("/sessions/{session_id}", response_model=ChatSessionResponse)
async def get_chat_session(
session_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> ChatSessionResponse:
return await get_session_response(session_id, owner_scope=current_user.owner_scope)
@router.patch("/sessions/{session_id}", response_model=ChatSessionResponse)
async def update_chat_session(
session_id: str,
payload: UpdateSessionRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> ChatSessionResponse:
return await update_session_response(session_id, payload, owner_scope=current_user.owner_scope)
@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat_session(
session_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> Response:
await delete_session(session_id, owner_scope=current_user.owner_scope)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/sessions/{session_id}/messages", response_model=SendMessageResponse)
async def send_chat_message(
session_id: str,
payload: SendMessageRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> SendMessageResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=SendMessageResponse,
resource_type="run",
handler=lambda session: send_message(
session,
session_id,
payload,
owner_scope=current_user.owner_scope,
tool_runtime=getattr(request.app.state, "tool_runtime", None),
confirmation_registry=getattr(request.app.state, "confirmation_registry", None),
confirmation_store=getattr(request.app.state, "confirmation_store", None),
event_store=getattr(request.app.state, "event_store", None),
agent_run_registry=getattr(request.app.state, "agent_run_registry", None),
python_skill_catalog=getattr(request.app.state, "python_skill_catalog", None),
),
)
@router.get("/sessions/{session_id}/messages", response_model=ChatMessageListResponse)
async def list_chat_messages(
session_id: str,
current_user: CurrentUser = Depends(get_current_user),
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ChatMessageListResponse:
return await list_message_responses(
session_id, owner_scope=current_user.owner_scope, limit=limit, offset=offset
)
@router.patch(
"/sessions/{session_id}/messages/{message_id}/version",
response_model=ChatMessageResponse,
)
async def select_message_version_endpoint(
session_id: str,
message_id: str,
payload: SelectVersionRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> ChatMessageResponse:
"""选择/激活消息的某个历史版本。"""
return await select_message_version(
session_id,
message_id,
payload,
owner_scope=current_user.owner_scope,
)
-113
View File
@@ -1,113 +0,0 @@
"""辰正时态 API(设计 §10)。
挂载于 /api/v1/time。事件不走 REST(走 EventStore 订阅)。路由薄层:参数校验 → 调
service → 转响应;session 由 service 自管(deps.py 惯例)。
"""
from fastapi import APIRouter, Depends, Request
from app.api.v1.deps import get_current_user, get_event_store
from app.core.config import settings
from app.schemas.auth import CurrentUser
from app.schemas.time import (
AcquaintanceResponse,
AnniversaryCreateRequest,
AnniversaryResponse,
AnniversaryUpdateRequest,
SnapshotResponse,
TimeConfigResponse,
)
from app.services.time_sense import (
create_anniversary_for_active,
delete_anniversary_or_404,
get_acquaintance_for_active,
get_anniversary_or_404,
get_live_snapshot,
list_anniversaries_for_active,
update_anniversary_or_404,
)
router = APIRouter(prefix="/time", tags=["time"])
@router.get("/snapshot", response_model=SnapshotResponse)
async def get_snapshot(
request: Request, current_user: CurrentUser = Depends(get_current_user)
) -> SnapshotResponse:
"""当前 TemporalSnapshot + ModulationFactors + context_text。"""
result = await get_live_snapshot(get_event_store(request), owner_scope=current_user.owner_scope)
return SnapshotResponse(
snapshot=result.snapshot,
factors=result.factors,
context_text=result.context_text,
)
@router.get("/anniversaries", response_model=list[AnniversaryResponse])
async def list_anniversaries_api(
current_user: CurrentUser = Depends(get_current_user),
) -> list[AnniversaryResponse]:
items = await list_anniversaries_for_active(owner_scope=current_user.owner_scope)
return [AnniversaryResponse.model_validate(a) for a in items]
@router.post("/anniversaries", response_model=AnniversaryResponse, status_code=201)
async def create_anniversary_api(
payload: AnniversaryCreateRequest, current_user: CurrentUser = Depends(get_current_user)
) -> AnniversaryResponse:
created = await create_anniversary_for_active(payload, owner_scope=current_user.owner_scope)
return AnniversaryResponse.model_validate(created)
@router.get("/anniversaries/{anniv_id}", response_model=AnniversaryResponse)
async def get_anniversary_api(
anniv_id: str, current_user: CurrentUser = Depends(get_current_user)
) -> AnniversaryResponse:
return AnniversaryResponse.model_validate(
await get_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope)
)
@router.patch("/anniversaries/{anniv_id}", response_model=AnniversaryResponse)
async def update_anniversary_api(
anniv_id: str,
payload: AnniversaryUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> AnniversaryResponse:
return AnniversaryResponse.model_validate(
await update_anniversary_or_404(anniv_id, payload, owner_scope=current_user.owner_scope)
)
@router.delete("/anniversaries/{anniv_id}", status_code=204)
async def delete_anniversary_api(
anniv_id: str, current_user: CurrentUser = Depends(get_current_user)
) -> None:
await delete_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope)
@router.get("/acquaintance", response_model=AcquaintanceResponse)
async def get_acquaintance_api(
request: Request, current_user: CurrentUser = Depends(get_current_user)
) -> AcquaintanceResponse:
acq = await get_acquaintance_for_active(
get_event_store(request), owner_scope=current_user.owner_scope
)
return AcquaintanceResponse(total_days=acq.total_days, natural_language=acq.natural_language)
@router.get("/config", response_model=TimeConfigResponse)
async def get_time_config_api(
current_user: CurrentUser = Depends(get_current_user), # noqa: ARG001
) -> TimeConfigResponse:
return TimeConfigResponse(
user_timezone=settings.user_timezone,
home_timezone=settings.home_timezone,
default_city=settings.default_city,
offline_active_minutes=settings.offline_active_minutes,
offline_short_absence_minutes=settings.offline_short_absence_minutes,
offline_long_absence_minutes=settings.offline_long_absence_minutes,
offline_reunion_impact_minutes=settings.offline_reunion_impact_minutes,
care_enabled=settings.care_enabled,
jetlag_threshold_minutes=settings.jetlag_threshold_minutes,
)
Whitespace-only changes.
@@ -1,970 +0,0 @@
"""发送聊天消息用例。"""
from __future__ import annotations
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai_provider import ProviderManager, ProviderManagerFactory
from app.ai_provider.types.chat import ChatChunk
from app.application.agent import (
Agent,
AgentConfig,
AgentEvent,
AgentRunContext,
AgentRunRegistry,
ConfirmationRegistry,
ExtensionRegistry,
PythonSkillCatalog,
PythonSkillDefinition,
activate_python_skills,
compose_system_messages,
default_tools,
next_step_for_tool,
run_agent_turn,
step_event_payload,
task_from_deliberative_plan,
task_plan_payload,
)
from app.application.tool_runtime.runtime import ToolRuntime
from app.application.tool_runtime.view import RunScopedToolView
from app.core.config import settings
from app.core.errors import AppError, current_trace_id
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage
from app.modules.agent import DEFAULT_CHAT_AGENT
from app.modules.chat.history import history_pairs
from app.modules.chat.policies import validate_text_message
from app.modules.goal_orchestrator import extract_session_goal, format_goal_directive
from app.modules.memory.context import build_memory_context
from app.modules.runtime.contract import RuntimeContract
from app.modules.runtime.events import EventStore
from app.modules.safety.confirmation import ConfirmationStore
from app.repositories import chat as chat_repo
from app.repositories import runtime as runtime_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.chat import SendMessageRequest, SendMessageResponse
from app.services import runtime as runtime_service
from app.services.audit import record_audit_event
from app.services.characters import default_model_hint, resolve_active_character
from app.services.chat import recent_chat_messages
from app.services.memory import retrieve_memories
from app.services.personality import build_personality_summary
from app.services.runtime import TERMINAL_RUN_STATUS
from app.services.safety import decide_chat_safety
# 后台流式任务引用:asyncio.create_task 仅保留弱引用,需强引用防止中途被 GC。
_running_stream_tasks: set[asyncio.Task[None]] = set()
# 流式增量分片阈值:每攒够 N 字符产出一条 delta 事件,配合 sse_poll_interval(0.01s)
# 让前端看到逐段平滑增量。值越小流式越细、事件数越多(run_max_events 默认 1000)。
_REASONING_FLUSH_CHARS = 8
_CONTENT_FLUSH_CHARS = 8
_provider_manager: ProviderManager | None = None
def _get_provider_manager() -> ProviderManager:
global _provider_manager
if _provider_manager is None:
_provider_manager = ProviderManagerFactory.create()
return _provider_manager
async def drain_pending_streams() -> None:
"""等待所有进行中的后台流式任务完成。
``execute`` 在创建 run 后即返回,LLM 流式在后台任务中执行;调用方在需要确保
运行终态时(测试断言、优雅停机)await 此函数。
"""
if _running_stream_tasks:
await asyncio.gather(*_running_stream_tasks, return_exceptions=True)
def _contract_system_messages(contract: RuntimeContract) -> list[str]:
messages = [*contract.system_instructions]
if contract.system_prompt:
messages.append(contract.system_prompt) # 卡的 system_prompt:道体增补层(§6)
if contract.session_goal:
messages.append(format_goal_directive(contract.session_goal))
if contract.active_goals:
messages.append(contract.active_goals) # 在 session_goal 之后注入
if contract.mission:
messages.append(f"核心使命:\n{contract.mission}") # 道体恒定层
if contract.lorebook_before:
messages.append(contract.lorebook_before)
if contract.personality_summary:
messages.append(f"当前人格:\n{contract.personality_summary}")
if contract.lorebook_after:
messages.append(contract.lorebook_after)
if contract.emotion_state:
messages.append(contract.emotion_state)
if contract.memory_context:
messages.append(contract.memory_context)
if contract.temporal_context:
messages.append(contract.temporal_context)
if contract.safety_guidelines:
messages.append("行为边界:\n" + contract.safety_guidelines) # 道体安全(先于司命动态约束)
if contract.safety_constraints:
messages.append(
"安全约束:\n" + "\n".join(f"- {item}" for item in contract.safety_constraints)
)
# 沙箱工作目录提示:避免模型在 shell 中使用 Linux 特有路径(如 /home/user、/tmp)
# 导致 macOS 上沙箱执行时 cd 失败。
messages.append(
"当前运行在隔离沙箱中,所有文件操作(read_file/write_file/shell)的工作目录"
"是沙箱隔离的 workspace。系统并非 Linux,/home/user、/root 等路径不存在。"
"不要 cd 到这些目录;直接在 workspace 中对文件进行读写或执行命令。"
)
return messages
# 首轮对话自动重命名:用默认模型生成简短标题,失败静默不影响对话主流程。
_TITLE_MAX_CHARS = 40
_TITLE_STRIP_CHARS = "\"'“”‘’「」『』"
async def _call_model(system_prompt: str, model_hint: str) -> str:
"""调用 LLM 返回纯文本。"""
manager = _get_provider_manager()
messages = manager.to_message_list([system_prompt], [('user', system_prompt)])
response = await manager.chat(messages, model_hint=model_hint)
return response.content
async def _generate_session_title(user_content: str, assistant_content: str) -> str:
"""用默认模型根据首轮对话内容生成简短会话标题。"""
manager = _get_provider_manager()
messages = manager.to_message_list(
[
"你会根据一段对话生成简短的中文会话标题。"
"要求:不超过 16 个字;概括对话主题;直接输出标题文字;"
"不要加引号、书名号、句号,不要解释。"
],
[("user", user_content), ("assistant", assistant_content)],
)
response = await manager.chat(messages, model_hint=settings.default_model_hint)
raw = response.content.strip().split("\n", 1)[0].strip()
return raw.strip(_TITLE_STRIP_CHARS)[:_TITLE_MAX_CHARS]
class SendChatMessageUseCase:
"""完成消息持久化与运行态记录;LLM 流式回复在后台任务中边产出边推 SSE 事件。"""
def __init__(
self,
session: AsyncSession,
*,
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,
) -> None:
self._session = session
self._owner_scope = owner_scope
self._tool_runtime = tool_runtime
self._confirmation_registry = confirmation_registry
self._confirmation_store = confirmation_store
self._event_store = event_store
self._agent_run_registry = agent_run_registry
self._python_skill_catalog = python_skill_catalog
async def execute(self, session_id: str, payload: SendMessageRequest) -> SendMessageResponse:
validate_text_message(modality=payload.modality, content=payload.content)
character = await resolve_active_character(self._session, owner_scope=self._owner_scope)
chat_session = await chat_repo.get_session(
self._session, session_id, owner_scope=self._owner_scope
)
if chat_session is None or chat_session.status == "deleted":
raise AppError("not_found", "会话不存在。")
if payload.prior_versions:
# 重答:复用原文用户消息,不新建 user 消息,避免刷新后出现重复的消息对。
recent = await chat_repo.list_recent_messages(
self._session,
chat_session.id,
limit=10,
)
existing_user = None
for msg in reversed(recent):
if msg.role == "user" and msg.deleted_at is None:
existing_user = msg
break
if existing_user is None:
raise AppError("not_found", "要重答的原始用户消息不存在。")
user_message = existing_user
else:
user_message = ChatMessage(
character_id=character.id,
session_id=chat_session.id,
role="user",
content=payload.content,
modality="text",
metadata_json=dumps({}),
sensitivity="low",
)
self._session.add(user_message)
await self._session.flush()
model_hint = payload.model_hint or default_model_hint(character)
session_goal = extract_session_goal(payload.content)
run = await runtime_service.create_run(
self._session,
character_id=character.id,
session_id=chat_session.id,
model_hint=model_hint,
goal=session_goal or "回复用户消息",
)
user_message.run_id = run.id
await runtime_service.append_event(
self._session,
run_id=run.id,
character_id=character.id,
event_type="run.started",
payload={"run_id": run.id, "session_id": chat_session.id},
)
# 先提交 run + run.started:HTTP 响应随后返回 run_id,前端据此建立 SSE;
# 后台流式在独立 session 中边产出边提交,前端 SSE(DB 轮询)即可实时读到增量。
await self._session.commit()
task = asyncio.create_task(
self._stream_assistant_response(
run_id=run.id,
character_id=character.id,
chat_session_id=chat_session.id,
payload=payload,
)
)
_running_stream_tasks.add(task)
task.add_done_callback(_running_stream_tasks.discard)
from app.core.config import settings as _settings
return SendMessageResponse(
message_id=user_message.id,
assistant_message_id=None,
run_id=run.id,
session_id=chat_session.id,
character_id=character.id,
status=run.status,
stream_url=f"{_settings.api_prefix}/runs/{run.id}/stream",
)
async def _stream_assistant_response(
self,
*,
run_id: str,
character_id: str,
chat_session_id: str,
payload: SendMessageRequest,
) -> None:
"""后台流式:独立 session 完成 LLM 调用与事件推送,异常定稿为 run.failed。"""
try:
async with AsyncSessionLocal() as session:
await self._produce_assistant_stream(
session,
run_id=run_id,
character_id=character_id,
chat_session_id=chat_session_id,
payload=payload,
)
except TimeoutError:
await self._fail_stream(
run_id=run_id,
character_id=character_id,
code="run_timeout",
message="运行超时。",
)
except Exception:
logger.exception("chat_stream_failed", run_id=run_id)
await self._fail_stream(
run_id=run_id,
character_id=character_id,
code="run_failed",
message="运行失败,请稍后重试。",
)
async def _produce_assistant_stream(
self,
session: AsyncSession,
*,
run_id: str,
character_id: str,
chat_session_id: str,
payload: SendMessageRequest,
) -> None:
character = await resolve_active_character(session, owner_scope=self._owner_scope)
model_hint = payload.model_hint or default_model_hint(character)
run = await runtime_repo.get_run(session, run_id)
if run is None or run.status in TERMINAL_RUN_STATUS:
return
session_goal = run.goal or ""
# 重答场景(prior_versions):在构建上下文前软删除被替换的旧 assistant 消息,
# 保留原文用户消息不动——用户消息已在 execute() 中复用,不新建、不删除。
if payload.prior_versions:
latest = await chat_repo.list_recent_messages(
session,
chat_session_id,
limit=5,
)
# latest 按 created_at ASC 排列且仅含未删除消息,从末尾往前找最后一个 assistant
for i in range(len(latest) - 1, -1, -1):
if latest[i].role == "assistant" and latest[i].deleted_at is None:
latest[i].deleted_at = utc_now()
break
await session.commit()
safety = await decide_chat_safety(
session,
character_id=character_id,
run_id=run_id,
)
personality = await build_personality_summary(session, character)
memories = await retrieve_memories(
session,
character_id,
limit=settings.memory_injection_limit,
)
memory_context = build_memory_context(memories)
# 息壤推送:将检索到的记忆以结构化数据推送到前端 SSE 事件流
if memories.memories:
memory_items = []
for m in memories.memories:
memory_items.append(
{
"id": m.id,
"title": m.title,
"content": m.content,
"source": m.source,
"confidence": m.confidence,
"importance": m.importance,
"type": m.type,
}
)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="memory.retrieved",
payload={"memories": memory_items},
)
await session.commit()
recent_messages = await recent_chat_messages(session, chat_session_id)
trace_id = current_trace_id()
# 辰正 temporal_context(设计 §8.1):对话入口算时态快照 + 发 time.* 事件到当前 run。
# 降级原则(§12):任何失败 → temporal_context=None,不注入,不阻塞对话。
temporal_context: str | None = None
if self._event_store is not None:
try:
from app.modules.time_sense.modulation import ModulationInput
from app.services.time_sense import compute_live_snapshot
time_result = await compute_live_snapshot(
session,
self._event_store,
character_id,
ModulationInput(),
run_id=run_id,
owner_scope=self._owner_scope,
)
temporal_context = time_result.context_text or None
except Exception: # noqa: BLE001
logger.warning("temporal_context 计算失败,已跳过", run_id=run_id)
# 灵核情绪状态注入(设计 §11.5):拉模式 compute_emotion_prompt。
# 降级原则(§12):任何失败 → emotion_state=None,不注入,不阻塞对话。
emotion_state: str | None = None
try:
from app.services.personality_emotion import compute_emotion_prompt
emotion_state = await compute_emotion_prompt(session, character_id)
except Exception: # noqa: BLE001
logger.warning("emotion_state 计算失败,已跳过", run_id=run_id)
# 世谱(Lorebook)注入:扫对话历史匹配命中条目,注入 before/after(设计 §7)
# 降级原则:任何失败 → 不注入,不阻塞对话。
lorebook_before: str | None = None
lorebook_after: str | None = None
try:
from app.services.lorebook import build_lorebook_injection
scan_parts = [payload.content]
scan_parts.extend(
m.content for m in recent_messages[-8:] if m.role in ("user", "assistant")
)
lorebook_before, lorebook_after = await build_lorebook_injection(
session,
character_id,
self._owner_scope,
"\n".join(scan_parts),
)
except Exception: # noqa: BLE001
logger.warning("世谱注入失败,已跳过", run_id=run_id)
profile_data = loads_dict(character.profile_json or "{}")
contract = RuntimeContract(
character_id=character_id,
session_id=chat_session_id,
run_id=run_id,
model_hint=model_hint,
agent=DEFAULT_CHAT_AGENT,
system_instructions=[],
system_prompt=profile_data.get("system_prompt") or None,
session_goal=session_goal or None,
personality_summary=personality.text,
mission=character.mission,
safety_guidelines=character.safety_guidelines,
lorebook_before=lorebook_before,
lorebook_after=lorebook_after,
emotion_state=emotion_state,
memory_context=memory_context,
temporal_context=temporal_context,
recent_messages=recent_messages,
safety_constraints=safety.constraints,
trace_id=trace_id,
)
# 道体完整性校验(§6):确认恒定层未被动态注入在数据层覆盖
from app.modules.runtime.integrity import validate_daoti_integrity
validate_daoti_integrity(
contract,
mission=character.mission,
safety_guidelines=character.safety_guidelines,
)
# —— 活跃目标注入 ——
try:
from app.services.governance import load_active_goals_context
active_goals = await load_active_goals_context(character.id)
if active_goals:
contract.active_goals = active_goals
except Exception:
logger.warning("活跃目标加载失败,跳过注入")
await record_audit_event(
session,
action="model.request.started",
character_id=character_id,
run_id=run_id,
actor="system",
decision="auto",
input_summary=model_hint,
resource_type="run",
resource_id=run_id,
)
system_messages = compose_system_messages(
_contract_system_messages(contract),
contract.agent,
[],
)
# ── 百工技能执行引擎:匹配 → prompt 注入 → 工具绑定注册 ──
from app.application.skill_executor import SkillExecutor
tool_view = RunScopedToolView(self._tool_runtime) if self._tool_runtime is not None else None
skill_exec = SkillExecutor(
tool_view=tool_view,
event_store=self._event_store,
)
try:
await skill_exec.match_and_activate(
payload.content,
system_messages,
run_id=run_id,
character_id=character_id,
skill_refs=contract.agent.skill_refs if contract.agent else (),
)
except Exception: # noqa: BLE001
logger.warning("skill_executor 匹配失败,已跳过")
agent_task = None
completed_plan_step_ids: set[str] = set()
# ── 洛书 Deliberative 规划 ──
if settings.enable_deliberative_planning:
from app.application.planning import (
build_planning_prompt,
format_plan_for_prompt,
parse_plan,
should_plan,
)
if should_plan(payload.content):
try:
planner_prompt = build_planning_prompt(
payload.content,
memory_context=memory_context[:300] if memory_context else '',
)
planner_raw = await _call_model(planner_prompt, model_hint)
plan = parse_plan(planner_raw)
if plan:
agent_task = task_from_deliberative_plan(
plan,
run_id=run_id,
agent_name=contract.agent.name if contract.agent else "默认 Agent",
objective=payload.content,
)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="plan.created",
source="planner",
payload=task_plan_payload(agent_task),
)
await session.commit()
plan_text = format_plan_for_prompt(plan)
system_messages.append(
"【当前计划】\n请按以下计划逐步执行。"
"每一步完成后标记完成,再进入下一步。"
"全部完成后回复总结。\n\n" + plan_text
)
logger.info(f"洛书生成 deliberative plan: {plan.title} ({len(plan.steps)} 步)")
except Exception:
logger.warning('deliberative 规划生成失败,回退 ReAct')
python_skills: tuple[PythonSkillDefinition, ...] = ()
if self._python_skill_catalog is not None:
python_skills = self._python_skill_catalog.match(
payload.content,
skill_refs=contract.agent.skill_refs if contract.agent else (),
metadata={
"run_id": run_id,
"session_id": chat_session_id,
"character_id": character_id,
"model_hint": model_hint,
},
)
for skill in python_skills:
if skill.instructions:
system_messages.append(f"【Python Skill:{skill.name}】\n{skill.instructions}")
manager = _get_provider_manager()
messages = manager.to_message_list(
system_messages,
history_pairs(recent_messages),
)
reasoning_buf = ""
content_buf = ""
full_content = ""
had_reasoning = False
reasoning_finalized = False
last_model = model_hint
async def flush_reasoning(*, force: bool = False) -> None:
nonlocal reasoning_buf
while reasoning_buf and (len(reasoning_buf) >= _REASONING_FLUSH_CHARS or force):
cut = (
_REASONING_FLUSH_CHARS
if len(reasoning_buf) >= _REASONING_FLUSH_CHARS
else len(reasoning_buf)
)
delta, reasoning_buf = reasoning_buf[:cut], reasoning_buf[cut:]
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="reasoning.delta",
source="model",
payload={"delta": delta},
)
await session.commit()
async def flush_content(*, force: bool = False) -> None:
nonlocal content_buf
while content_buf and (len(content_buf) >= _CONTENT_FLUSH_CHARS or force):
cut = (
_CONTENT_FLUSH_CHARS
if len(content_buf) >= _CONTENT_FLUSH_CHARS
else len(content_buf)
)
delta, content_buf = content_buf[:cut], content_buf[cut:]
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="message.delta",
source="model",
payload={"delta": delta},
)
await session.commit()
async def finalize_reasoning() -> None:
"""收尾思考阶段:冲刷残余 reasoning 并推送 reasoning.completed。
在第一段正文出现前调用,确保思考摘要完整先于正文;流结束兜底再调一次。
多轮 Agent 场景下,每轮模型输出的 content 出现前都会触发,已被 reasoning_finalized
标记短路——即仅第一段 reasoning 完整 flush 后发 completed,后续轮次的 reasoning
仍经 on_chunk 实时 flush,但不再发 completed(避免事件流断裂)。
思考模式关闭时(payload.thinking=False)不处理任何 reasoning 事件,
防止模型仍产出 reasoning_content 导致前端 ThoughtBlock 误展示。
"""
nonlocal reasoning_finalized
if reasoning_finalized or not payload.thinking:
return
reasoning_finalized = True
await flush_reasoning(force=True)
if had_reasoning:
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="reasoning.completed",
source="model",
payload={},
)
await session.commit()
async def on_chunk(chunk: ChatChunk) -> None:
"""把 Agent 流式 chunk 喂给现有 buf/flush/finalize 逻辑,事件行为与单轮流式等价。"""
nonlocal reasoning_buf, content_buf, full_content, had_reasoning
# 思考模式关闭时不处理 reasoning chunk,避免前端 ThoughtBlock 错误展示
if payload.thinking and chunk.delta_reasoning:
had_reasoning = True
reasoning_buf += chunk.delta_reasoning
await flush_reasoning()
if chunk.delta_content:
# 正文开始前先收尾思考,保证思考摘要完整先于正文
await finalize_reasoning()
content_buf += chunk.delta_content
full_content += chunk.delta_content
await flush_content()
# 收集工具调用信息,最终存入消息 metadata 供前端持久化展示
tool_calls: list[dict[str, object]] = []
_started_tool_calls: dict[str, dict[str, object]] = {}
_plan_steps_by_tool_call = {}
async def _reset_turn_state() -> None:
"""每轮 Agent 开始前重置 reasoning 标记(多轮场景支持)。
Agent loop 首轮产出 reasoning → content → tool_calls,次轮可能再产出 reasoning。
若不重置,reasoning_finalized=True 导致 reasoning.completed 无法再发出。
"""
nonlocal had_reasoning, reasoning_finalized
had_reasoning = False
reasoning_finalized = False
async def on_tool_event(event_type: str, payload_dict: dict[str, object]) -> None:
"""工具生命周期事件:作为 agent 事件落库 + 收集 tool_calls 入 metadata。"""
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type=event_type,
source="agent",
payload=payload_dict,
)
await session.commit()
# 收集工具调用信息供前端持久化展示
if event_type == "tool.started":
tc_id = str(payload_dict.get("tool_call_id", ""))
tool_name = str(payload_dict.get("tool_name", ""))
if agent_task is not None:
step = next_step_for_tool(agent_task, tool_name, completed_plan_step_ids)
if step is not None:
_plan_steps_by_tool_call[tc_id] = step
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="plan.step.started",
source="planner",
payload=step_event_payload(agent_task, step, status="running"),
)
await session.commit()
_started_tool_calls[tc_id] = {
"tool_name": str(payload_dict.get("tool_name", "")),
"tool_call_id": tc_id,
"args": str(payload_dict.get("args", "")),
"status": "running",
}
elif event_type in ("tool.completed", "tool.failed"):
tc_id = str(payload_dict.get("tool_call_id", ""))
entry = _started_tool_calls.pop(tc_id, None)
step = _plan_steps_by_tool_call.pop(tc_id, None)
if step is not None and agent_task is not None:
tool_status = str(payload_dict.get("status", "error"))
step_status = "completed" if tool_status == "success" else "failed"
if step_status == "completed":
completed_plan_step_ids.add(step.id)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type=f"plan.step.{step_status}",
source="planner",
payload=step_event_payload(
agent_task,
step,
status=step_status,
result_summary=str(payload_dict.get("result", ""))[:500] or None,
),
)
await session.commit()
if entry is not None:
entry["status"] = str(payload_dict.get("status", "error"))
entry["result"] = str(payload_dict.get("result", ""))
tool_calls.append(entry)
extensions = ExtensionRegistry()
python_skill_activation = await activate_python_skills(extensions, python_skills)
if python_skills:
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="skill.activated",
source="agent",
payload={"skills": [skill.name for skill in python_skills], "kind": "python"},
)
await session.commit()
async def persist_agent_event(event: AgentEvent, _run_context: AgentRunContext) -> None:
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type=event.type,
source="agent",
payload=event.payload,
)
await session.commit()
extensions.add_event_listener(persist_agent_event)
on_confirm_timeout = None
if self._confirmation_store is not None:
confirmation_store = self._confirmation_store
async def on_confirm_timeout(confirmation_id: str) -> None:
await confirmation_store.mark_timed_out(confirmation_id)
agent = Agent(
AgentConfig(
tools=tuple(
default_tools(
tool_view.specs_by_name(),
allowed_tools=contract.agent.tool_policy.allowed_tools
if contract.agent
else (),
)
if tool_view is not None
else ()
),
tool_runtime=tool_view,
model_hint=(contract.agent.model_hint or model_hint)
if contract.agent
else model_hint,
thinking_enabled=payload.thinking,
reasoning_effort=payload.reasoning_effort,
max_iterations=contract.agent.execution_policy.max_iterations
if contract.agent
else settings.agent_max_iterations,
confirmation_registry=self._confirmation_registry,
confirmation_timeout=settings.confirmation_timeout_seconds,
on_turn=_reset_turn_state,
on_chunk=on_chunk,
on_tool_event=on_tool_event,
on_confirm_timeout=on_confirm_timeout,
),
messages=messages,
run_context=AgentRunContext(
run_id=run_id,
session_id=chat_session_id,
character_id=character_id,
trace_id=trace_id,
),
extensions=extensions,
# 保留模块级符号作为注入点,现有测试与外部集成无需重写。
runner=run_agent_turn,
)
if self._agent_run_registry is not None:
await self._agent_run_registry.register(run_id, agent)
try:
run = await runtime_repo.get_run(session, run_id)
if run is None or run.status in TERMINAL_RUN_STATUS:
return
async with asyncio.timeout(settings.run_timeout_seconds):
agent_result = await agent.continue_run()
finally:
if self._agent_run_registry is not None:
await self._agent_run_registry.unregister(run_id, agent)
await python_skill_activation.close()
# 流结束兜底:模型可能只输出思考无正文,或残余 buffer 未达阈值。
await finalize_reasoning()
await flush_content(force=True)
# 取消可能发生在模型返回与持久化之间;终态 Run 不再写助手消息或覆盖状态。
run = await runtime_repo.get_run(session, run_id)
if run is None or run.status in TERMINAL_RUN_STATUS:
return
# 以 agent_result.content 作为权威最终回复正文持久化(应与 full_content 一致;
# 不一致时以 agent 循环产出为准,因后者覆盖多轮模型最终输出)。
final_content = agent_result.content or full_content
version_ts = utc_now().isoformat()
# 构造版本历史:prior_versions 前置 + 当前回复作为最新版本
prior = payload.prior_versions or []
all_versions = [*prior, {"content": final_content, "created_at": version_ts}]
active_idx = len(all_versions) - 1 # 始终最新
assistant = ChatMessage(
character_id=character_id,
session_id=chat_session_id,
role="assistant",
content=final_content,
modality="text",
run_id=run_id,
metadata_json=dumps(
{
"model_hint": model_hint,
"model": last_model,
"tool_calls": tool_calls,
}
),
sensitivity="low",
versions_json=dumps(all_versions),
active_version_idx=active_idx,
)
session.add(assistant)
await session.flush()
await record_audit_event(
session,
action="model.response.completed",
character_id=character_id,
run_id=run_id,
actor="model",
decision="auto",
output_summary="模型响应已完成。",
resource_type="message",
resource_id=assistant.id,
)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="message.completed",
source="model",
payload={"message_id": assistant.id, "content": final_content},
)
run = await runtime_repo.get_run(session, run_id)
if run is not None and run.status not in TERMINAL_RUN_STATUS:
await runtime_service.complete_run(session, run)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="run.completed",
payload={"run_id": run_id},
)
await session.commit()
# 首轮对话完成后,用默认模型自动生成会话标题(失败不影响主流程)
await self._maybe_rename_session(
session,
chat_session_id=chat_session_id,
user_content=payload.content,
assistant_content=final_content,
)
# 每轮对话后检查大周天触发条件(失败不影响主流程)
try:
from app.application.big_cycle import maybe_trigger_big_cycle
await maybe_trigger_big_cycle(character_id, trigger="session_turn")
except Exception: # noqa: BLE001
logger.warning("大周天触发检查失败,已跳过")
# 小周天每轮记忆候选 + 返照反思(后台任务,失败不影响主流程)
try:
from app.application.reflection import start_reflection_after_turn
start_reflection_after_turn(
character_id=character_id,
session_id=chat_session_id,
user_content=payload.content,
assistant_content=final_content,
)
except Exception: # noqa: BLE001
logger.warning("小周天反思触发失败,已跳过")
# —— 会话目标自动持久化 ——
try:
from app.db.session import AsyncSessionLocal as GoalSession
from app.services.goal_persist import auto_persist_session_goal
async with GoalSession() as goal_session:
await auto_persist_session_goal(goal_session, character.id, payload.content)
await goal_session.commit()
except Exception:
logger.warning("会话目标自动持久化失败,跳过")
async def _maybe_rename_session(
self,
session: AsyncSession,
*,
chat_session_id: str,
user_content: str,
assistant_content: str,
) -> None:
"""首轮对话完成后用 LLM 生成会话标题;已有标题或非首轮则跳过,失败静默。"""
try:
existing = await chat_repo.get_session(session, chat_session_id)
if existing is None or existing.title:
return
assistant_count = await chat_repo.count_messages_by_role(
session, chat_session_id, role="assistant"
)
if assistant_count != 1:
return
title = await _generate_session_title(user_content, assistant_content)
if not title:
return
await chat_repo.update_session_title(session, chat_session_id, title)
await session.commit()
logger.info("session_auto_renamed", session_id=chat_session_id, title=title)
except Exception:
logger.warning("session_title_generate_failed", session_id=chat_session_id)
async def _fail_stream(
self,
*,
run_id: str,
character_id: str,
code: str,
message: str,
) -> None:
async with AsyncSessionLocal() as session:
run = await runtime_repo.get_run(session, run_id)
if run is None or run.status in TERMINAL_RUN_STATUS:
return
await runtime_service.fail_run(session, run, code=code, message=message)
await runtime_service.append_event(
session,
run_id=run_id,
character_id=character_id,
event_type="run.failed",
payload={"code": code, "message": message},
)
await session.commit()
+43
View File
@@ -194,6 +194,7 @@ async def activate_embryo(embryo_id: str, owner_scope: str) -> Character:
session.add(Custodianship(character_id=character.id, custodian_scope=owner_scope))
session.add(RuntimeSeat(character_id=character.id, epoch=1))
book_entry_count = await _materialize_import_book(session, baseline, character, owner_scope)
record = build_identity_record(
character_id=character.id,
@@ -205,6 +206,7 @@ async def activate_embryo(embryo_id: str, owner_scope: str) -> Character:
"activated_at": character.activated_at.isoformat(),
},
runtime_state={"status": "active", "seat_epoch": 1},
data_state={"imported_book_entries": book_entry_count},
)
session.add(record)
await session.flush() # 取魂籍 ID
@@ -307,3 +309,44 @@ async def _owned_character(
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
async def _materialize_import_book(
session: AsyncSession, baseline: dict, character: Character, owner_scope: str
) -> int:
"""启镜时把导入卡的 character_book 物化为新人物的世谱书与条目(待审语义随世谱状态治理)。"""
from app.models.lorebook import Lorebook, LorebookEntry
from app.modules.imports.mapping import character_book_to_entries
book = (baseline.get("import_card") or {}).get("character_book")
entries = character_book_to_entries(book)
if not entries:
return 0
lorebook = Lorebook(
character_id=character.id,
name=f"{character.name} 的世谱(导入)",
description="角色卡 character_book 启镜物化",
scope="character",
owner_scope=owner_scope,
)
session.add(lorebook)
await session.flush()
for spec in entries:
session.add(
LorebookEntry(
lorebook_id=lorebook.id,
keys_json=json.dumps(spec["keys"], ensure_ascii=False),
content=spec["content"],
secondary_keys_json=json.dumps(spec["secondary_keys"], ensure_ascii=False),
constant=spec["constant"],
selective=spec["selective"],
enabled=spec["enabled"],
case_sensitive=spec["case_sensitive"],
use_regex=spec["use_regex"],
position=spec["position"],
insertion_order=spec["insertion_order"],
priority=spec["priority"],
comment=spec["comment"][:500],
)
)
return len(entries)
+66 -6
View File
@@ -383,25 +383,80 @@ def _citation_row(node_id: str, data: dict) -> Citation:
)
async def build_generation_prompt(question: TreeNode, instructions: str | None) -> str:
"""按祖先链组装投影 prompt(M2 形态:祖先路径 + 问题 + 临时指引;记忆/引文随 M3)。"""
async def build_generation_prompt(
question: TreeNode,
instructions: str | None,
*,
char_name: str | None = None,
user_name: str | None = None,
) -> str:
"""按祖先链组装投影 prompt 并做符文展开({{char}}/{{user}}/…)。
M2 形态:祖先路径 + 问题 + 临时指引;获准记忆与显式引文随 M3 息壤接入。
"""
from app.modules.macros.builtins import build_default_registry
from app.modules.macros.engine import render
from app.modules.macros.types import MacroContext
ctx = MacroContext(char_name=char_name, user_name=user_name)
registry = build_default_registry()
def expand(text: str) -> str:
return render(text, ctx, registry=registry)
chain = await ancestor_chain(question.id)
lines: list[str] = []
for n in chain:
if n.id == question.id:
continue
speaker = "用户" if n.node_type == "question" else "人物"
lines.append(f"{speaker}:{n.content}")
lines.append(f"{speaker}:{expand(n.content)}")
transcript = "\n".join(lines)
parts = []
if transcript:
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
parts.append(f"【当前问题】\n{question.content}")
parts.append(f"【当前问题】\n{expand(question.content)}")
if instructions:
parts.append(f"【本次指引】\n{instructions}")
parts.append(f"【本次指引】\n{expand(instructions)}")
return "\n\n".join(parts)
async def _names_for_generation(tree: MirrorTree) -> tuple[str | None, str | None]:
"""人物名与执镜者名(符文上下文)。"""
from app.models.user import User
async with AsyncSessionLocal() as session:
character = await session.get(Character, tree.character_id)
char_name = character.name if character else None
user_name = None
if character is not None:
user = await session.get(User, character.owner_scope)
user_name = user.username if user else None
return char_name, user_name
async def _generation_overrides(owner_scope: str) -> dict:
"""火候(GenerationPreferences):本镜域默认采样参数 → IR 覆写。"""
from sqlalchemy import select
from app.models.governance import GenerationPreferences
async with AsyncSessionLocal() as session:
prefs = await session.get(GenerationPreferences, owner_scope)
if prefs is None:
# 兼容旧默认 owner_scope 行(历史数据)
prefs = (
await session.execute(
select(GenerationPreferences).where(
GenerationPreferences.owner_scope == owner_scope
)
)
).scalar_one_or_none()
if prefs is None:
return {}
return {"temperature": prefs.temperature, "max_tokens": prefs.max_output_tokens}
async def spawn_answer(
*,
question_id: str,
@@ -436,13 +491,18 @@ async def spawn_answer(
source="mirror_tree",
)
prompt = await build_generation_prompt(question, instructions)
char_name, user_name = await _names_for_generation(tree)
prompt = await build_generation_prompt(
question, instructions, char_name=char_name, user_name=user_name
)
overrides = await _generation_overrides(owner_scope)
run = await kernel.start_foreground_run(
character_id=tree.character_id,
prompt=prompt,
system_contract=None,
origin="tree_answer",
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
**overrides,
)
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
+8 -2
View File
@@ -55,13 +55,14 @@ class Kernel:
system_contract: str | None = None,
origin: str = "kernel",
on_run_final: OnRunFinal | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> Run:
"""创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。"""
profile = resolve_profile()
async with self._sessions() as session:
run = Run(
character_id=character_id,
session_id=None,
status="queued",
kind="foreground",
goal=prompt[:300] if prompt else None,
@@ -79,6 +80,8 @@ class Kernel:
seat_key=None,
system_contract=system_contract,
on_final=on_run_final,
temperature=temperature,
max_tokens=max_tokens,
)
async with self._sessions() as session:
created = await session.get(Run, run_id)
@@ -93,7 +96,6 @@ class Kernel:
async with self._sessions() as session:
run = Run(
character_id=None,
session_id=None,
status="queued",
kind="rehearsal",
goal=prompt[:300] if prompt else None,
@@ -127,6 +129,8 @@ class Kernel:
seat_key: str | None,
system_contract: str | None,
on_final: OnRunFinal | None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> None:
task = asyncio.create_task(
self.loop.execute(
@@ -137,6 +141,8 @@ class Kernel:
seat_key=seat_key,
system_contract=system_contract,
on_final=on_final,
temperature=temperature,
max_tokens=max_tokens,
)
)
self._tasks.add(task)
+12
View File
@@ -87,6 +87,8 @@ class KernelRunLoop:
seat_key: str | None = None,
system_contract: str | None = None,
on_final: OnRunFinal | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
) -> RunOutcome:
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
trace = trace_id or new_trace_id()
@@ -101,6 +103,8 @@ class KernelRunLoop:
seat_key=seat_key,
system_contract=system_contract,
outcome=outcome,
temperature=temperature,
max_tokens=max_tokens,
)
except TimeoutError:
outcome.status = "interrupted"
@@ -128,6 +132,8 @@ class KernelRunLoop:
seat_key: str | None,
system_contract: str | None,
outcome: RunOutcome,
temperature: float | None = None,
max_tokens: int | None = None,
) -> None:
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
try:
@@ -151,6 +157,8 @@ class KernelRunLoop:
prompt,
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
outcome,
temperature=temperature,
max_tokens=max_tokens,
)
async def _turn(
@@ -161,6 +169,8 @@ class KernelRunLoop:
prompt: str,
system_contract: str,
outcome: RunOutcome,
temperature: float | None = None,
max_tokens: int | None = None,
) -> None:
ctx = RunContext(
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
@@ -179,6 +189,8 @@ class KernelRunLoop:
run_id=run_id,
trace_id=trace_id,
character_id=character_id,
temperature=temperature,
max_tokens=max_tokens,
)
await self._emit(
run_id,
-52
View File
@@ -1,52 +0,0 @@
"""聊天会话与消息模型。"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_message_id, new_session_id
from app.core.time import utc_now
from app.db.base import Base
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_session_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
title: Mapped[str | None] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, default="", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_message_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
session_id: Mapped[str] = mapped_column(String(80), ForeignKey("chat_sessions.id"), index=True)
role: Mapped[str] = mapped_column(String(40), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
modality: Mapped[str] = mapped_column(String(40), nullable=False, default="text")
run_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("runs.id"), index=True)
metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
sensitivity: Mapped[str] = mapped_column(String(40), nullable=False, default="low")
versions_json: Mapped[str] = mapped_column(
Text,
nullable=False,
default="[]",
comment="版本历史 JSON 数组:[{content, created_at}],按生成顺序排列,末尾最新",
)
active_version_idx: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="当前活跃版本在 versions_json 中的索引(0-based)",
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
-32
View File
@@ -1,32 +0,0 @@
"""灵核情绪状态持久化(设计 §11.4)。"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.time import utc_now
from app.db.base import Base
class CharacterEmotionState(Base):
"""角色当前情绪状态(单行 per character,最新状态 + 触发溯源)。
设计 §11.4:``mood`` / ``intensity`` 由辰正 ``time.*`` 事件驱动动态维护;
``baseline_*`` 同步自人格版本 ``emotion_baseline``,作为衰减回归锚点。
同步写 ``Character.current_mood_id``(激活既有字段,前端列表无需 join)。
"""
__tablename__ = "character_emotion_states"
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), primary_key=True
)
mood: Mapped[str] = mapped_column(String(40), nullable=False, default="neutral")
intensity: Mapped[float] = mapped_column(Float, nullable=False, default=0.2)
baseline_mood: Mapped[str] = mapped_column(String(40), nullable=False, default="neutral")
baseline_intensity: Mapped[float] = mapped_column(Float, nullable=False, default=0.2)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
last_trigger_event_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
-41
View File
@@ -1,41 +0,0 @@
"""时态纪念日模型(辰正 §5.3)。"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_anniversary_id
from app.core.time import utc_now
from app.db.base import Base
class TimeAnniversary(Base):
"""纪念日 / 特殊日子(辰正设计 §5.3)。
枚举值(calendar / kind / subject_type / source)以契约字典
``docs/contracts/contract-glossary.md`` §11.4.1–§11.4.4 为单一事实源。
"""
__tablename__ = "time_anniversaries"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_anniversary_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
calendar: Mapped[str] = mapped_column(String(20), nullable=False) # solar / lunar
date: Mapped[str] = mapped_column(String(20), nullable=False) # 公历 ISO 或农历日期编码
recurs_yearly: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
kind: Mapped[str] = mapped_column(
String(40), nullable=False
) # birthday / anniversary / milestone / custom
subject_type: Mapped[str] = mapped_column(
String(40), nullable=False
) # user / character_self / relationship / custom
source: Mapped[str] = mapped_column(
String(40), nullable=False, default="manual"
) # auto / manual / imported
metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
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)
-200
View File
@@ -1,200 +0,0 @@
"""聊天数据访问。"""
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.models.chat import ChatMessage, ChatSession
async def get_session(
session: AsyncSession, session_id: str, *, owner_scope: str | None = None
) -> ChatSession | None:
stmt = select(ChatSession).where(ChatSession.id == session_id)
if owner_scope is not None:
stmt = stmt.where(ChatSession.owner_scope == owner_scope)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def list_recent_messages(
session: AsyncSession, session_id: str, *, limit: int = 12
) -> list[ChatMessage]:
result = await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session_id, ChatMessage.deleted_at.is_(None))
.order_by(ChatMessage.created_at.desc())
.limit(limit)
)
return list(reversed(result.scalars().all()))
async def list_sessions(
session: AsyncSession,
character_id: str,
*,
status: str | None = None,
owner_scope: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[ChatSession]:
conditions = [ChatSession.character_id == character_id, ChatSession.status != "deleted"]
if status is not None:
conditions.append(ChatSession.status == status)
if owner_scope is not None:
conditions.append(ChatSession.owner_scope == owner_scope)
result = await session.execute(
select(ChatSession)
.where(*conditions)
.order_by(ChatSession.updated_at.desc())
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
async def count_sessions(
session: AsyncSession,
character_id: str,
*,
status: str | None = None,
owner_scope: str | None = None,
) -> int:
conditions = [ChatSession.character_id == character_id, ChatSession.status != "deleted"]
if status is not None:
conditions.append(ChatSession.status == status)
if owner_scope is not None:
conditions.append(ChatSession.owner_scope == owner_scope)
result = await session.execute(select(func.count()).select_from(ChatSession).where(*conditions))
return result.scalar_one()
async def list_messages(
session: AsyncSession, session_id: str, *, limit: int = 100, offset: int = 0
) -> list[ChatMessage]:
result = await session.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session_id, ChatMessage.deleted_at.is_(None))
.order_by(ChatMessage.created_at.asc())
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
async def count_messages(session: AsyncSession, session_id: str) -> int:
result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.deleted_at.is_(None),
)
)
return result.scalar_one()
async def count_messages_by_sessions(
session: AsyncSession, session_ids: list[str]
) -> dict[str, int]:
"""批量统计多个会话的未删除消息数(GROUP BY 聚合,避免列表查询 N+1)。"""
if not session_ids:
return {}
result = await session.execute(
select(ChatMessage.session_id, func.count())
.where(
ChatMessage.session_id.in_(session_ids),
ChatMessage.deleted_at.is_(None),
)
.group_by(ChatMessage.session_id)
)
return {str(sid): int(cnt) for sid, cnt in result.all()}
async def last_message_previews_by_sessions(
session: AsyncSession, session_ids: list[str]
) -> dict[str, str]:
"""批量取每个会话最后一条未删除消息的内容(截断由调用方处理)。
用 row_number() 窗口函数取每个 session_id 的最新一条,兼容 SQLite(3.25+)
与 PostgreSQL,避免逐会话查询(N+1)。无消息的会话不在结果中。
"""
if not session_ids:
return {}
latest = (
select(
ChatMessage.session_id,
ChatMessage.content,
func.row_number()
.over(
partition_by=ChatMessage.session_id,
order_by=ChatMessage.created_at.desc(),
)
.label("rn"),
).where(
ChatMessage.session_id.in_(session_ids),
ChatMessage.deleted_at.is_(None),
)
).subquery()
result = await session.execute(
select(latest.c.session_id, latest.c.content).where(latest.c.rn == 1)
)
return {str(sid): (content or "") for sid, content in result.all()}
async def count_messages_by_role(session: AsyncSession, session_id: str, *, role: str) -> int:
"""统计会话内指定角色的未删除消息数(用于判断首轮对话)。"""
result = await session.execute(
select(func.count())
.select_from(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.role == role,
ChatMessage.deleted_at.is_(None),
)
)
return result.scalar_one()
async def update_session_title(session: AsyncSession, session_id: str, title: str) -> None:
"""更新会话标题;仅修改托管对象,提交时机由调用方控制。"""
chat_session = await session.get(ChatSession, session_id)
if chat_session is not None:
chat_session.title = title
async def soft_delete_session_messages(session: AsyncSession, session_id: str) -> None:
"""软删除会话内全部未删除消息;提交时机由调用方控制。"""
from app.core.time import utc_now
await session.execute(
update(ChatMessage)
.where(
ChatMessage.session_id == session_id,
ChatMessage.deleted_at.is_(None),
)
.values(deleted_at=utc_now())
)
async def get_message(session: AsyncSession, message_id: str) -> ChatMessage | None:
"""按 ID 获取单条消息。"""
return await session.get(ChatMessage, message_id)
async def set_message_active_version(
session: AsyncSession,
message_id: str,
version_idx: int,
versions_json: str,
) -> None:
"""更新消息的活跃版本索引和 versions_json。"""
from sqlalchemy import update
result = await session.execute(
update(ChatMessage)
.where(ChatMessage.id == message_id)
.values(active_version_idx=version_idx, versions_json=versions_json)
)
if result.rowcount == 0:
raise AppError("not_found", "消息不存在。")
@@ -1,31 +0,0 @@
"""灵核情绪状态数据访问(设计 §11.4)。"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.personality_emotion import CharacterEmotionState
async def get_state(session: AsyncSession, character_id: str) -> CharacterEmotionState | None:
"""读取某角色当前情绪状态(单行 per character)。"""
return await session.get(CharacterEmotionState, character_id)
async def upsert_state(
session: AsyncSession, state: CharacterEmotionState
) -> CharacterEmotionState:
"""写入情绪状态(存在则更新字段,不存在则插入)。
跨方言(SQLite + Postgres)ORM 层 upsert,不依赖 ON CONFLICT。
``updated_at`` 由 SQLAlchemy ``onupdate`` 自动刷新。
"""
existing = await session.get(CharacterEmotionState, state.character_id)
if existing is not None:
existing.mood = state.mood
existing.intensity = state.intensity
existing.baseline_mood = state.baseline_mood
existing.baseline_intensity = state.baseline_intensity
existing.last_trigger_event_id = state.last_trigger_event_id
return existing
session.add(state)
await session.flush()
return state
@@ -1,49 +0,0 @@
"""时态纪念日数据访问(辰正 §5.3)。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.time_anniversary import TimeAnniversary
async def create_anniversary(
session: AsyncSession, anniversary: TimeAnniversary
) -> TimeAnniversary:
session.add(anniversary)
await session.flush()
await session.refresh(anniversary)
return anniversary
async def get_anniversary(session: AsyncSession, anniversary_id: str) -> TimeAnniversary | None:
return await session.get(TimeAnniversary, anniversary_id)
async def list_anniversaries(session: AsyncSession, character_id: str) -> list[TimeAnniversary]:
result = await session.execute(
select(TimeAnniversary)
.where(TimeAnniversary.character_id == character_id)
.order_by(TimeAnniversary.date.asc())
)
return list(result.scalars().all())
async def update_anniversary(
session: AsyncSession, anniversary_id: str, **fields: object
) -> TimeAnniversary | None:
anniversary = await session.get(TimeAnniversary, anniversary_id)
if anniversary is None:
return None
for key, value in fields.items():
setattr(anniversary, key, value)
await session.flush()
return anniversary
async def delete_anniversary(session: AsyncSession, anniversary_id: str) -> bool:
anniversary = await session.get(TimeAnniversary, anniversary_id)
if anniversary is None:
return False
await session.delete(anniversary)
await session.flush()
return True
-254
View File
@@ -1,254 +0,0 @@
"""聊天 API schema。"""
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class CreateSessionRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"title": "大模型联调会话",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
}
]
}
)
title: str | None = Field(default=None, max_length=200, description="会话标题。")
character_id: str | None = Field(default=None, description="人物 ID;为空时使用当前激活人物。")
class UpdateSessionRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{"title": "新的会话标题"},
{"status": "archived"},
]
}
)
title: str | None = Field(default=None, max_length=200, description="新标题;为空时保持不变。")
status: str | None = Field(
default=None,
description="会话状态切换:active / archived。deleted 请走 DELETE 端点。",
)
class ChatSessionResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"title": "大模型联调会话",
"status": "active",
"created_at": "2026-06-19T09:06:47.515000Z",
"updated_at": "2026-06-19T09:10:52.690000Z",
}
]
}
)
session_id: str = Field(description="会话 ID,前缀为 session_。")
character_id: str = Field(description="会话所属人物 ID。")
title: str | None = Field(description="会话标题。")
status: str = Field(description="会话状态:active / archived / deleted。")
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
updated_at: str = Field(description="更新时间,UTC ISO 8601 字符串。")
message_count: int = Field(default=0, description="会话内未删除消息数(用户 + 助手)。")
last_message_preview: str | None = Field(default=None, description="最近一条消息的内容摘要。")
class ChatSessionListResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"items": [
{
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"title": "大模型联调会话",
"status": "active",
"created_at": "2026-06-19T09:06:47.515000Z",
"updated_at": "2026-06-19T09:10:52.690000Z",
}
],
"total": 1,
}
]
}
)
items: list[ChatSessionResponse] = Field(description="当前页会话列表。")
total: int = Field(description="符合条件的会话总数。")
class SendMessageRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"content": "你好,请简单介绍一下你自己。",
"modality": "text",
"model_hint": "deepseek-v4-flash",
},
{
"content": "请用三句话总结我们刚才聊到的内容。",
"modality": "text",
"model_hint": "default-chat",
},
]
}
)
content: str = Field(min_length=1, description="用户消息正文。")
modality: str = Field(default="text", description="消息模态;当前阶段仅支持 text。")
model_hint: str | None = Field(
default=None,
description="逻辑模型名或能力槽位,例如 default-chat、deepseek-v4-flash;为空时使用人物默认模型。",
)
thinking: bool = Field(
default=False, description="是否开启思考模式(reasoning);模型不支持时忽略。"
)
reasoning_effort: str | None = Field(
default=None,
description="思考力度:low / medium / high。thinking=false 时忽略,使用模型默认。",
)
prior_versions: list[dict] | None = Field(
default=None,
description="重答时携带的历史版本列表,每项含 content 和 created_at。后端将其合并到新消息的 versions_json 中(前置插入),"
"同时软删除前一条 assistant 消息,避免刷新后出现重复消息。",
)
class SendMessageResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"message_id": "message_462927da6f0e4af780dd28fd58f73314",
"assistant_message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a",
"run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0",
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"status": "completed",
"stream_url": "/api/v1/runs/run_a2c8ecc73c0e4cc7bc2fafbaeada74e0/stream",
}
]
}
)
message_id: str = Field(description="本次写入的用户消息 ID。")
assistant_message_id: str | None = Field(description="模型回复消息 ID;失败时可能为空。")
run_id: str = Field(description="本次对话运行 ID,可用于查询 Run 或 SSE。")
session_id: str = Field(description="会话 ID。")
character_id: str = Field(description="人物 ID。")
status: str = Field(description="运行状态。成功完成时为 completed。")
stream_url: str = Field(description="SSE 事件流 URL(前置于此,前端可直接 new EventSource)。")
class ChatMessageResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a",
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"role": "assistant",
"content": "你好,我是魂镜中的默认人物,会结合记忆和当前会话与你对话。",
"modality": "text",
"run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0",
"metadata": {"model_hint": "deepseek-v4-flash", "provider": "deepseek"},
"sensitivity": "low",
"created_at": "2026-06-19T09:10:52.676462Z",
"deleted_at": None,
}
]
}
)
message_id: str = Field(description="消息 ID,前缀为 message_。")
session_id: str = Field(description="所属会话 ID。")
character_id: str = Field(description="所属人物 ID。")
role: str = Field(description="消息角色:user / assistant / system / tool。")
content: str = Field(description="消息正文。")
modality: str = Field(description="消息模态:text / image / audio / file。")
run_id: str | None = Field(description="关联运行 ID。")
metadata: dict[str, Any] = Field(description="消息元数据,例如模型名、provider 等。")
sensitivity: str = Field(description="敏感等级:none / low / medium / high。")
versions: list[dict] = Field(
default=[],
description="版本历史列表,每项含 content 和 created_at。",
)
active_version_idx: int = Field(
default=0,
description="当前活跃版本在 versions 中的索引。",
)
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
deleted_at: str | None = Field(description="删除时间;未删除时为空。")
class ChatMessageListResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"items": [
{
"message_id": "message_462927da6f0e4af780dd28fd58f73314",
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"role": "user",
"content": "你好,请简单介绍一下你自己。",
"modality": "text",
"run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0",
"metadata": {},
"sensitivity": "low",
"created_at": "2026-06-19T09:10:52.660153Z",
"deleted_at": None,
},
{
"message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a",
"session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"role": "assistant",
"content": "你好,我是魂镜中的默认人物,会结合记忆和当前会话与你对话。",
"modality": "text",
"run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0",
"metadata": {"model_hint": "deepseek-v4-flash", "provider": "deepseek"},
"sensitivity": "low",
"created_at": "2026-06-19T09:10:52.676462Z",
"deleted_at": None,
},
],
"total": 2,
}
]
}
)
items: list[ChatMessageResponse] = Field(description="按创建时间升序排列的消息列表。")
total: int = Field(description="符合条件的消息总数。")
class SelectVersionRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{"version_idx": 0},
{"version_idx": 2},
]
}
)
version_idx: int = Field(
ge=0,
description="要激活的版本在消息 versions 数组中的索引(0-based)。",
)
-84
View File
@@ -1,84 +0,0 @@
"""辰正时态 API schema(设计 §10)。对外字段不带 _json 后缀。"""
from pydantic import BaseModel, ConfigDict, Field
from app.modules.time_sense.modulation import ModulationFactors
from app.modules.time_sense.snapshot import TemporalSnapshot
class SnapshotResponse(BaseModel):
"""GET /time/snapshot 响应:快照 + 因子 + context_text。"""
model_config = ConfigDict(from_attributes=True)
snapshot: TemporalSnapshot | None
factors: ModulationFactors | None
context_text: str
class AnniversaryCreateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"name": "我们的相遇日",
"calendar": "solar",
"date": "2025-09-19",
"recurs_yearly": True,
"kind": "anniversary",
"subject_type": "relationship",
"source": "manual",
}
]
}
)
name: str = Field(max_length=200, description="纪念日名称。")
calendar: str = Field(description="历法:solar / lunar。")
date: str = Field(description="日期:公历 ISO 或农历编码。")
recurs_yearly: bool = Field(default=True, description="是否每年 recur。")
kind: str = Field(description="类型,取值见契约 §11.4.2。")
subject_type: str = Field(description="对象类型,取值见契约 §11.4.3。")
source: str = Field(default="manual", description="来源,取值见契约 §11.4.4。")
class AnniversaryUpdateRequest(BaseModel):
name: str | None = Field(default=None, max_length=200)
calendar: str | None = None
date: str | None = None
recurs_yearly: bool | None = None
kind: str | None = None
subject_type: str | None = None
source: str | None = None
class AnniversaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
calendar: str
date: str
recurs_yearly: bool
kind: str
subject_type: str
source: str
class AcquaintanceResponse(BaseModel):
total_days: int
natural_language: str
class TimeConfigResponse(BaseModel):
"""GET /time/config:当前时态相关配置(只读,v1 经 env 覆盖)。"""
user_timezone: str
home_timezone: str
default_city: str
offline_active_minutes: float
offline_short_absence_minutes: float
offline_long_absence_minutes: float
offline_reunion_impact_minutes: float
care_enabled: bool
jetlag_threshold_minutes: int
+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
@@ -1,97 +0,0 @@
"""角色卡导入 API 测试。"""
from __future__ import annotations
import base64
import json
import struct
import zlib
import httpx
def _make_png_text(keyword: str, text: str) -> bytes:
def chunk(ctype: bytes, data: bytes) -> bytes:
body = ctype + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"")
async def test_import_json_card(auth_client: httpx.AsyncClient) -> None:
card = {"spec_version": "2.0", "data": {"name": "API卡", "personality": "p"}}
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "API卡"
assert data["character_id"].startswith("character_")
async def test_import_png_card(auth_client: httpx.AsyncClient) -> None:
card = {"spec_version": "2.0", "data": {"name": "PNG卡"}}
text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii")
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.png", _make_png_text("chara", text), "image/png")},
)
assert resp.status_code == 200
assert resp.json()["name"] == "PNG卡"
async def test_import_card_with_lorebook(auth_client: httpx.AsyncClient) -> None:
card = {
"spec_version": "2.0",
"data": {
"name": "带书",
"character_book": {"entries": [{"keys": ["a"], "content": "b"}]},
},
}
resp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert resp.status_code == 200
data = resp.json()
assert data["lorebook_id"] is not None
assert data["entry_count"] == 1
# 导入的人物可被列出
listed = await auth_client.get("/api/v1/characters")
assert any(c["id"] == data["character_id"] for c in listed.json()["items"])
async def test_export_card_round_trip(auth_client: httpx.AsyncClient) -> None:
# 导入一张带世谱的卡
card = {
"spec_version": "2.0",
"data": {
"name": "往返",
"nickname": "返返",
"personality": "p",
"character_book": {"entries": [{"keys": ["k"], "content": "v"}]},
},
}
imp = await auth_client.post(
"/api/v1/characters/import-card",
files={"file": ("c.json", json.dumps(card).encode("utf-8"), "application/json")},
)
assert imp.status_code == 200
cid = imp.json()["character_id"]
# 导出
exp = await auth_client.get(f"/api/v1/characters/{cid}/export-card")
assert exp.status_code == 200
exported = exp.json()
assert exported["spec"] == "chara_card_v3"
assert exported["data"]["name"] == "往返"
assert exported["data"]["nickname"] == "返返"
assert exported["data"]["personality"] == "p"
assert exported["data"]["character_book"]["entries"][0]["keys"] == ["k"]
async def test_export_unknown_character_404(auth_client: httpx.AsyncClient) -> None:
exp = await auth_client.get("/api/v1/characters/character_missing/export-card")
assert exp.status_code == 404
@@ -1,59 +0,0 @@
"""道体注入与完整性校验测试。"""
from __future__ import annotations
import pytest
from app.application.chat.send_message import _contract_system_messages
from app.modules.runtime.contract import RuntimeContract
from app.modules.runtime.integrity import validate_daoti_integrity
def _contract(**kw: object) -> RuntimeContract:
base: dict[str, object] = {
"character_id": "c",
"session_id": "s",
"run_id": "r",
"model_hint": "m",
"trace_id": "t",
}
base.update(kw)
return RuntimeContract(**base)
def test_system_prompt_injected() -> None:
msgs = _contract_system_messages(_contract(system_prompt="特殊系统指令"))
assert "特殊系统指令" in msgs
def test_mission_injected() -> None:
msgs = _contract_system_messages(_contract(mission="守护用户"))
assert any("守护用户" in m for m in msgs)
def test_safety_guidelines_precede_safety_constraints() -> None:
c = _contract(safety_guidelines="道体边界", safety_constraints=["司命约束"])
msgs = _contract_system_messages(c)
assert msgs.index("行为边界:\n道体边界") < msgs.index("安全约束:\n- 司命约束")
def test_integrity_pass_when_consistent() -> None:
c = _contract(mission="M", safety_guidelines="S")
validate_daoti_integrity(c, mission="M", safety_guidelines="S") # 不抛即通过
def test_integrity_raises_on_mission_tamper() -> None:
c = _contract(mission="M")
with pytest.raises(ValueError):
validate_daoti_integrity(c, mission="被篡改")
def test_integrity_raises_on_safety_tamper() -> None:
c = _contract(safety_guidelines="S")
with pytest.raises(ValueError):
validate_daoti_integrity(c, safety_guidelines="被篡改")
def test_integrity_skips_none_fields() -> None:
# 道体字段未设(None)时跳过校验
validate_daoti_integrity(_contract(), mission=None, safety_guidelines=None)
@@ -1,48 +0,0 @@
"""灵核情绪状态 system prompt 注入测试(设计 §11.5)。
验证 ``_contract_system_messages`` 把 ``emotion_state`` 注入到正确位置
(personality_summary 后、memory_context 前),且 emotion_state=None 时不注入。
"""
from __future__ import annotations
from app.application.chat.send_message import _contract_system_messages
from app.modules.runtime.contract import RuntimeContract
def _contract(**overrides) -> RuntimeContract:
base = {
"character_id": "c1",
"session_id": "s1",
"run_id": "r1",
"model_hint": "m",
"trace_id": "t",
}
base.update(overrides)
return RuntimeContract(**base)
def test_emotion_state_injected_between_personality_and_memory() -> None:
contract = _contract(
personality_summary="人格X",
emotion_state="[当前情绪] 感动(强度 70%)",
memory_context="记忆Y",
)
messages = _contract_system_messages(contract)
personality_idx = next(i for i, m in enumerate(messages) if "人格X" in m)
emotion_idx = next(i for i, m in enumerate(messages) if "感动" in m)
memory_idx = next(i for i, m in enumerate(messages) if "记忆Y" in m)
assert personality_idx < emotion_idx < memory_idx
def test_emotion_state_omitted_when_none() -> None:
contract = _contract(personality_summary="人格X", memory_context="记忆Y")
messages = _contract_system_messages(contract)
assert all("当前情绪" not in m for m in messages)
def test_emotion_state_included_without_memory() -> None:
"""emotion_state 独立于 memory_context 存在。"""
contract = _contract(emotion_state="[当前情绪] 关切(强度 50%)")
messages = _contract_system_messages(contract)
assert any("关切" in m for m in messages)
@@ -1,41 +0,0 @@
"""世谱注入位置测试:before/after 相对 personality_summary。"""
from __future__ import annotations
from app.application.chat.send_message import _contract_system_messages
from app.modules.runtime.contract import RuntimeContract
def _contract(**kw: object) -> RuntimeContract:
base: dict[str, object] = {
"character_id": "c",
"session_id": "s",
"run_id": "r",
"model_hint": "m",
"trace_id": "t",
}
base.update(kw)
return RuntimeContract(**base)
def test_lorebook_before_precedes_personality() -> None:
c = _contract(personality_summary="人格X", lorebook_before="世界设定前")
msgs = _contract_system_messages(c)
assert msgs.index("世界设定前") < msgs.index("当前人格:\n人格X")
def test_lorebook_after_follows_personality() -> None:
c = _contract(personality_summary="人格X", lorebook_after="世界设定后")
msgs = _contract_system_messages(c)
assert msgs.index("当前人格:\n人格X") < msgs.index("世界设定后")
def test_no_lorebook_when_none() -> None:
msgs = _contract_system_messages(_contract())
assert not any("世界设定" in m for m in msgs)
def test_lorebook_without_personality_still_injects() -> None:
c = _contract(lorebook_before="前", lorebook_after="后")
msgs = _contract_system_messages(c)
assert "前" in msgs and "后" in msgs
@@ -1,8 +0,0 @@
"""测试 send_message 中的目标自动注入(集成验证)。"""
def test_auto_persist_session_goal_is_callable():
"""验证 auto_persist_session_goal 函数可导入且可调用。"""
from app.services.goal_persist import auto_persist_session_goal
assert callable(auto_persist_session_goal)
@@ -1,179 +0,0 @@
"""对话 temporal_context 注入测试(辰正 §8.1)。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from app.application.agent.runtime import AgentTurnResult
from app.application.chat.send_message import _contract_system_messages
from app.main import app
from app.modules.runtime.contract import RuntimeContract
def _make_contract(temporal: str | None) -> RuntimeContract:
return RuntimeContract(
character_id="char_x",
session_id="sess_x",
run_id="run_x",
model_hint="default",
system_instructions=["base"],
personality_summary="人格X",
memory_context="记忆Y",
temporal_context=temporal,
trace_id="trace_x",
)
def test_contract_system_messages_includes_temporal_context():
messages = _contract_system_messages(_make_contract("现在是子时·深夜(01:42)。"))
assert any("子时·深夜" in m for m in messages)
def test_contract_system_messages_no_temporal_context_when_none():
messages = _contract_system_messages(_make_contract(None))
assert all("子时" not in m for m in messages)
def test_contract_system_messages_temporal_after_memory_before_safety():
"""注入位置:记忆之后、安全约束之前(设计 §8.1)。"""
contract = RuntimeContract(
character_id="c",
session_id="s",
run_id="r",
model_hint="m",
system_instructions=["base"],
memory_context="MEMORY_MARKER",
temporal_context="TEMPORAL_MARKER",
safety_constraints=["SAFETY_MARKER"],
trace_id="t",
)
messages = _contract_system_messages(contract)
joined = "\n".join(messages)
assert joined.index("MEMORY_MARKER") < joined.index("TEMPORAL_MARKER")
assert joined.index("TEMPORAL_MARKER") < joined.index("SAFETY_MARKER")
# ---------------------------------------------------------------------------
# §12 降级路径守护(设计 §12:辰正永不阻塞对话)
# 1. event_store=None → 跳过辰正调用,temporal_context 保持 None,对话照常完成
# 2. compute_live_snapshot raise → except 捕获,temporal_context 保持 None,对话不阻塞
# ---------------------------------------------------------------------------
async def _create_session(client: AsyncClient) -> str:
resp = await client.post("/api/v1/chat/sessions", json={"title": "辰正降级"})
assert resp.status_code == 200
return str(resp.json()["session_id"])
@pytest.fixture
async def temporal_client(
auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch
) -> AsyncGenerator[AsyncClient, None]:
"""HTTP client:mock run_agent_turn,并通过 messages 捕获最终 system 消息用于断言。
复用 test_send_message_agent.py 的 mock 边界(只 mock run_agent_turn,跑真实 send_message
全链路),让降级路径在生产代码路径中被真实驱动。
"""
captured: dict[str, Any] = {}
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
captured["messages"] = messages
return AgentTurnResult(content="好的。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
http_client._temporal_captured = captured # type: ignore[attr-defined]
yield http_client
def _system_text(messages: list[Any]) -> str:
return "\n".join(str(m.content) for m in messages if getattr(m, "role", "") == "system")
@pytest.mark.asyncio
async def test_temporal_context_skipped_when_event_store_is_none(
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""§12 降径 1:event_store=None 时跳过辰正调用,对话照常完成且不注入 temporal_context。"""
from app.application.chat.send_message import drain_pending_streams
# 降径 1:明确将 event_store 置 None(route 经 getattr 默认也是 None,此处显式断言降径)
# 测试 app.state 默认无 event_store 属性,monkeypatch.setattr 对对象属性可在不存在时
# 创建并在 teardown 时删除(恢复"无属性"原状)。
monkeypatch.setattr(app.state, "event_store", None, raising=False)
# spy:若被调用即标记(不应发生)
called: dict[str, bool] = {"hit": False}
import app.services.time_sense as time_sense_mod
real_compute = time_sense_mod.compute_live_snapshot
async def spy_compute(*args: Any, **kwargs: Any) -> Any:
called["hit"] = True
return await real_compute(*args, **kwargs)
monkeypatch.setattr(time_sense_mod, "compute_live_snapshot", spy_compute)
session_id = await _create_session(temporal_client)
sent = await temporal_client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
assert sent.status_code == 200
await drain_pending_streams()
# 真实行为断言:辰正未被调用
assert called["hit"] is False
# 对话完成:run_agent_turn 被正常调用且 system 消息中无辰正上下文标记
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
sys_text = _system_text(captured["messages"])
assert "今日命中" not in sys_text # render_temporal_context 的稳定首行尾块标记
@pytest.mark.asyncio
async def test_temporal_context_swallowed_when_compute_raises(
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""§12 降径 2:compute_live_snapshot raise 时 except 捕获,对话不阻塞且 temporal_context=None。"""
async def boom(*_args: Any, **_kwargs: Any) -> Any:
raise RuntimeError("辰正服务故障(模拟)")
monkeypatch.setattr("app.services.time_sense.compute_live_snapshot", boom)
from app.application.chat.send_message import drain_pending_streams
session_id = await _create_session(temporal_client)
sent = await temporal_client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
# 真实行为断言:异常被 except 吞掉,HTTP 仍 200,对话照常完成
assert sent.status_code == 200
run_id = str(sent.json()["run_id"])
await drain_pending_streams()
# 对话成功落库:run 进入终态 completed(而非 failed)
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.runtime import Run
async with AsyncSessionLocal() as db:
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
assert run.status == "completed"
# temporal_context 未注入:system 消息中无辰正上下文标记
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
sys_text = _system_text(captured["messages"])
assert "子时" not in sys_text and "时态" not in sys_text
-195
View File
@@ -1,195 +0,0 @@
"""叩心枢测试共用 fixtures。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db.base import Base
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
# 用内存 SQLite 做测试数据库
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
await engine.dispose()
@pytest_asyncio.fixture
async def seed_old_message(db_session: AsyncSession) -> None:
"""插入一条 2 小时前的消息,供空闲超时条件触发。"""
now = datetime.now(UTC)
db_session.add(
ChatMessage(
character_id="char_test",
session_id="sess_old",
role="assistant",
modality="text",
content="旧消息",
created_at=now - timedelta(hours=2),
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_recent_message(db_session: AsyncSession) -> None:
"""插入一条 1 分钟前的消息,供空闲超时条件不触发。"""
now = datetime.now(UTC)
db_session.add(
ChatMessage(
character_id="char_test",
session_id="sess_recent",
role="assistant",
modality="text",
content="新消息",
created_at=now - timedelta(minutes=1),
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_anniversary(db_session: AsyncSession) -> None:
"""插入一条 2026-06-27 的生日纪念日。"""
db_session.add(
TimeAnniversary(
character_id="char_test",
name="测试生日",
calendar="solar",
date="2026-06-27",
kind="birthday",
subject_type="user",
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_emotion_state(db_session: AsyncSession) -> None:
"""插入一条初始情绪状态(neutral / 0.2)。"""
db_session.add(
CharacterEmotionState(
character_id="char_test",
mood="neutral",
intensity=0.2,
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_curiosity(db_session: AsyncSession) -> None:
"""插入一条活跃的好奇心问题。"""
db_session.add(
CuriosityQuestion(
character_id="char_test",
cycle_id="cycle_test",
question="你为什么总是沉默?",
source="drive",
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_applied_evolution(db_session: AsyncSession) -> None:
"""插入一条已应用的演化候选。"""
db_session.add(
EvolutionCandidate(
character_id="char_test",
cycle_id="cycle_test",
type="personality",
title="语气更柔和",
proposal="调整表达风格,减少命令式语气,增加温和感",
level="L3",
status="applied",
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_completed_big_cycle(db_session: AsyncSession) -> None:
"""插入一条已完成的大周天循环。"""
now = datetime.now(UTC)
db_session.add(
BigCycle(
character_id="char_test",
trigger="session_turn",
status="completed",
summary="发现自己最近更倾向于回忆过去,在对话中表现出怀旧倾向",
completed_at=now - timedelta(minutes=5),
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_new_memory(db_session: AsyncSession) -> None:
"""插入一条活跃的新记忆。"""
db_session.add(
Memory(
character_id="char_test",
type="experience",
title="今天去了海边",
content="用户提到今天去了海边,看日落。角色回忆起自己也曾喜欢在海边散步。",
status="active",
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_many_messages(db_session: AsyncSession) -> None:
"""插入多条消息,供关系里程碑(消息数)条件触发。"""
now = datetime.now(UTC)
for i in range(5):
db_session.add(
ChatMessage(
character_id="char_test",
session_id=f"sess_rel_{i}",
role="assistant" if i % 2 else "user",
modality="text",
content=f"第{i + 1}条消息",
created_at=now - timedelta(minutes=10 - i),
)
)
await db_session.commit()
@pytest_asyncio.fixture
async def seed_old_relationship(db_session: AsyncSession) -> None:
"""插入一条 60 天前的消息,供关系里程碑(天数)条件触发。"""
now = datetime.now(UTC)
db_session.add(
ChatMessage(
character_id="char_test",
session_id="sess_old_rel",
role="user",
modality="text",
content="你好",
created_at=now - timedelta(days=60),
)
)
await db_session.commit()
@@ -1,303 +0,0 @@
"""叩心枢触发条件单元测试。"""
# ruff: noqa: ARG002 — pytest fixture params are intentionally unused in test signatures
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.knocking.conditions import (
AnniversaryCondition,
BigCycleCompletedCondition,
ConditionResult,
CuriosityCondition,
EmotionChangeCondition,
EvolutionAppliedCondition,
MemoryRecallCondition,
ProactiveCondition,
RelationshipMilestoneCondition,
TimeElapsedCondition,
TimeOfDayCondition,
UserReunionCondition,
)
class TestTimeElapsedCondition:
"""空闲超时条件测试。"""
async def test_not_triggered_when_no_history(self, db_session: AsyncSession) -> None:
cond = TimeElapsedCondition(min_idle_minutes=360)
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_idle_exceeds_threshold(
self, db_session: AsyncSession, seed_old_message: None
) -> None:
cond = TimeElapsedCondition(min_idle_minutes=1)
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "idle"
async def test_not_triggered_when_recent_message(
self, db_session: AsyncSession, seed_recent_message: None
) -> None:
cond = TimeElapsedCondition(min_idle_minutes=360)
now = datetime.now(UTC)
result = await cond.check("char_test", db_session, now=now)
assert not result.triggered
class TestTimeOfDayCondition:
async def test_morning_match(self) -> None:
cond = TimeOfDayCondition(windows=["morning"])
now = datetime(2026, 6, 27, 7, 0, 0, tzinfo=UTC)
result = await cond.check("char_test", None, now=now)
assert result.triggered
assert result.condition_name == "time_of_day"
async def test_late_night_match(self) -> None:
cond = TimeOfDayCondition(windows=["late_night"])
now = datetime(2026, 6, 27, 23, 0, 0, tzinfo=UTC)
result = await cond.check("char_test", None, now=now)
assert result.triggered
assert result.context.get("time_of_day") == "late_night"
async def test_no_match_outside_window(self) -> None:
cond = TimeOfDayCondition(windows=["morning"])
now = datetime(2026, 6, 27, 14, 0, 0, tzinfo=UTC)
result = await cond.check("char_test", None, now=now)
assert not result.triggered
class TestAnniversaryCondition:
async def test_not_triggered_on_first_call_no_data(self, db_session: AsyncSession) -> None:
cond = AnniversaryCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_anniversary_exists(
self, db_session: AsyncSession, seed_anniversary: None
) -> None:
cond = AnniversaryCondition()
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
result = await cond.check("char_test", db_session, now=now)
assert result.triggered
assert result.condition_name == "anniversary"
assert "birthday" in result.context.get("anniversary_kind", "")
async def test_not_duplicated_same_day(
self, db_session: AsyncSession, seed_anniversary: None
) -> None:
cond = AnniversaryCondition()
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
result1 = await cond.check("char_test", db_session, now=now)
assert result1.triggered
result2 = await cond.check("char_test", db_session, now=now)
assert not result2.triggered # 同一天不重复
class TestUserReunionCondition:
async def test_not_triggered_when_no_event(self, db_session: AsyncSession) -> None:
cond = UserReunionCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
class TestEmotionChangeCondition:
async def test_not_triggered_on_first_check(
self, db_session: AsyncSession, seed_emotion_state: None
) -> None:
cond = EmotionChangeCondition(min_delta=0.3)
result = await cond.check("char_test", db_session)
assert not result.triggered # 首次无基线
async def test_triggered_when_intensity_changes(
self, db_session: AsyncSession, seed_emotion_state: None
) -> None:
cond = EmotionChangeCondition(min_delta=0.3)
await cond.check("char_test", db_session) # 首次建立基线
# 更新情绪 intensity 到 0.8(变化 0.6 > 0.3)
from app.models.personality_emotion import CharacterEmotionState
result = await db_session.execute(
select(CharacterEmotionState).where(CharacterEmotionState.character_id == "char_test")
)
state = result.scalar_one()
state.intensity = 0.8
state.mood = "melancholy"
await db_session.commit()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "emotion"
class TestCuriosityCondition:
async def test_not_triggered_when_no_active_questions(self, db_session: AsyncSession) -> None:
cond = CuriosityCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_active_question_exists(
self, db_session: AsyncSession, seed_curiosity: None
) -> None:
cond = CuriosityCondition()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "curiosity"
assert "curiosity_question" in result.context
assert result.context["curiosity_question"] == "你为什么总是沉默?"
class TestEvolutionAppliedCondition:
"""演化应用条件测试。"""
async def test_not_triggered_when_no_applied(self, db_session: AsyncSession) -> None:
cond = EvolutionAppliedCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_applied_exists(
self, db_session: AsyncSession, seed_applied_evolution: None
) -> None:
cond = EvolutionAppliedCondition()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "evolution_applied"
assert result.context.get("evolution_title") == "语气更柔和"
assert result.context.get("evolution_level") == "L3"
assert "evolution_proposal" in result.context
async def test_not_duplicated(
self, db_session: AsyncSession, seed_applied_evolution: None
) -> None:
cond = EvolutionAppliedCondition()
result1 = await cond.check("char_test", db_session)
assert result1.triggered
result2 = await cond.check("char_test", db_session)
assert not result2.triggered # 同一条不重复触发
class TestBigCycleCompletedCondition:
"""大周天完成条件测试。"""
async def test_not_triggered_when_no_completed(self, db_session: AsyncSession) -> None:
cond = BigCycleCompletedCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_completed_exists(
self, db_session: AsyncSession, seed_completed_big_cycle: None
) -> None:
cond = BigCycleCompletedCondition()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "big_cycle_completed"
assert "big_cycle_summary" in result.context
assert "big_cycle_trigger" in result.context
async def test_not_duplicated(
self, db_session: AsyncSession, seed_completed_big_cycle: None
) -> None:
cond = BigCycleCompletedCondition()
result1 = await cond.check("char_test", db_session)
assert result1.triggered
result2 = await cond.check("char_test", db_session)
assert not result2.triggered # 同一条不重复触发
class TestMemoryRecallCondition:
"""记忆唤醒条件测试。"""
async def test_not_triggered_when_no_memory(self, db_session: AsyncSession) -> None:
cond = MemoryRecallCondition()
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_when_new_memory_exists(
self, db_session: AsyncSession, seed_new_memory: None
) -> None:
cond = MemoryRecallCondition()
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "memory_recall"
assert result.context.get("memory_title") == "今天去了海边"
assert "memory_content" in result.context
assert "memory_type" in result.context
async def test_not_duplicated(self, db_session: AsyncSession, seed_new_memory: None) -> None:
cond = MemoryRecallCondition()
result1 = await cond.check("char_test", db_session)
assert result1.triggered
result2 = await cond.check("char_test", db_session)
assert not result2.triggered # 同一条不重复触发
class TestRelationshipMilestoneCondition:
"""关系里程碑条件测试。"""
async def test_not_triggered_when_no_messages(self, db_session: AsyncSession) -> None:
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
result = await cond.check("char_nonexistent", db_session)
assert not result.triggered
async def test_triggered_by_message_count(
self, db_session: AsyncSession, seed_many_messages: None
) -> None:
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "relationship_milestone"
assert result.context.get("milestone_type") == "messages"
assert result.context.get("milestone_value") == 5
assert result.context.get("milestone_threshold") == 5
async def test_triggered_by_days(
self, db_session: AsyncSession, seed_old_relationship: None
) -> None:
cond = RelationshipMilestoneCondition(message_milestones=[9999], day_milestones=[30])
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.condition_name == "relationship_milestone"
assert result.context.get("milestone_type") == "days"
assert result.context.get("milestone_value") >= 30
assert result.context.get("milestone_threshold") == 30
async def test_not_duplicated(self, db_session: AsyncSession, seed_many_messages: None) -> None:
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
result1 = await cond.check("char_test", db_session)
assert result1.triggered
result2 = await cond.check("char_test", db_session)
assert not result2.triggered # 同一里程碑不重复触发
async def test_prefers_messages_over_days(
self, db_session: AsyncSession, seed_many_messages: None, seed_old_relationship: None
) -> None:
"""同时满足消息和天数里程碑时,优先触发消息里程碑。"""
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
result = await cond.check("char_test", db_session)
assert result.triggered
assert result.context.get("milestone_type") == "messages" # 消息优先
@pytest.mark.asyncio
async def test_proactive_condition_protocol() -> None:
"""验证 ProactiveCondition 可作为 Protocol 使用。"""
class DummyCondition(ProactiveCondition):
async def check(
self,
_character_id: str,
_session: AsyncSession | None = None,
*,
_now: datetime | None = None,
) -> ConditionResult:
return ConditionResult(triggered=True, trigger_reason="测试", condition_name="dummy")
cond: ProactiveCondition = DummyCondition()
result = await cond.check("char_test")
assert result.triggered
@@ -1,214 +0,0 @@
"""叩心枢(KnockingPivot)集成测试。
测试覆盖:
1. Tick 触发 —— 活跃角色条件匹配,执行完整触发链并推送一炁事件
2. Tick 空跑 —— 无活跃角色时跳过
3. 反 spam 冷却 —— 触发后冷却期内 ``_can_trigger`` 返回 False
4. 反 spam 日上限 —— 达到日上限后返回 False
5. 静默时段 —— [23:00, 08:00) 返回 True,其余返回 False
6. 活跃角色列表 —— DB 查询正确返回 ``status='active'`` 的角色 ID
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.db.base import Base
# ── 模型注册 ──────────────────────────────────────────────────────────
# 在 DB 表创建前将模型注册到 Base.metadata,确保 create_all 创建对应表。
# conftest.py 已注册 ChatMessage / CuriosityQuestion / CharacterEmotionState / TimeAnniversary,
# 但未注册 Character 与 ChatSession。
from app.models.character import Character # noqa: F401
from app.models.chat import ChatSession # noqa: F401
from app.modules.knocking.conditions import ConditionResult
from app.modules.knocking.opener import OpenerResult
from app.modules.knocking.pivot import KnockingPivot
from app.services.personality import PersonalitySummary
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest_asyncio.fixture
async def keeper_session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
"""Yield an async_sessionmaker backed by an in-memory SQLite database."""
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
yield factory
await engine.dispose()
@pytest_asyncio.fixture
async def dbevent_store() -> AsyncGenerator:
"""In-memory event store (no DB needed, supports _events for assertion)."""
from app.modules.runtime.events import InMemoryEventStore
yield InMemoryEventStore()
@pytest_asyncio.fixture
async def keeper(
keeper_session_factory: async_sessionmaker[AsyncSession],
dbevent_store,
) -> AsyncGenerator[KnockingPivot, None]:
"""KnockingPivot instance backed by an in-memory SQLite database."""
pivot = KnockingPivot(
event_store=dbevent_store,
session_factory=keeper_session_factory,
tick_interval=3600, # 测试期间不真的 tick
)
yield pivot
# ======================================================================
# 测试
# ======================================================================
@pytest.mark.asyncio
async def test_tick_triggers_for_active_character(
keeper: KnockingPivot,
keeper_session_factory: async_sessionmaker[AsyncSession],
) -> None:
"""活跃角色 + 任意条件触发时,应完成完整触发链:开场 → 消息 → 事件。"""
async with keeper_session_factory() as session:
char = Character(
id="char_test_1",
name="测试人物",
status="active",
owner_scope="test",
language="zh-CN",
privacy_level="normal",
)
session.add(char)
await session.commit()
with (
patch.object(keeper.conditions[0], "check", AsyncMock(return_value=ConditionResult(triggered=True, trigger_reason="测试触发", condition_name="test"))),
patch(
"app.modules.knocking.pivot.build_personality_summary",
return_value=PersonalitySummary(text="你是一个测试人物。"),
),
patch(
"app.modules.knocking.pivot.generate_opener",
return_value=OpenerResult(text="测试开场白。"),
),
):
async with keeper_session_factory() as check_session:
processed = await keeper._check_and_trigger(
"char_test_1",
check_session,
now=datetime.now(UTC),
)
assert processed is True
# 验证消息已创建
async with keeper_session_factory() as session:
from sqlalchemy import select
result = await session.execute(
select(ChatSession).where(ChatSession.character_id == "char_test_1")
)
assert result.scalar_one_or_none() is not None
# 验证一炁事件已推送
events = keeper._event_store._events
assert len(events) > 0
last_event = events[-1]
assert last_event.source == "knocking_pivot"
assert last_event.character_id == "char_test_1"
@pytest.mark.asyncio
async def test_tick_skips_when_no_active_characters(keeper: KnockingPivot) -> None:
"""无活跃角色时 tick 不触发任何操作。"""
with (
patch("app.modules.knocking.pivot.create_proactive_message") as mock_create,
patch("app.modules.knocking.pivot.generate_opener"),
):
await keeper._tick()
mock_create.assert_not_called()
@pytest.mark.asyncio
async def test_cooldown_prevents_retrigger(keeper: KnockingPivot) -> None:
"""触发后冷却期内 _can_trigger 返回 False。"""
now = datetime.now(UTC)
assert keeper._can_trigger("char_1", now) is True
keeper._mark_triggered("char_1", now)
assert keeper._can_trigger("char_1", now) is False
# 冷却结束后应恢复
after_cooldown = now + timedelta(minutes=keeper._cooldown_minutes + 1)
assert keeper._can_trigger("char_1", after_cooldown) is True
@pytest.mark.asyncio
async def test_daily_limit_prevents_excess(keeper: KnockingPivot) -> None:
"""同一天达到上限后 _can_trigger 返回 False。"""
now = datetime.now(UTC)
assert keeper._can_trigger("char_1", now) is True
keeper._daily_counts["char_1"] = (now.date().isoformat(), keeper._daily_limit)
assert keeper._can_trigger("char_1", now) is False
# 第二天恢复
next_day = now + timedelta(days=1)
assert keeper._can_trigger("char_1", next_day) is True
def test_quiet_hours(keeper: KnockingPivot) -> None:
"""静默时段 23:00–08:00 _in_quiet_hours 返回 True。"""
keeper._quiet_hours_start = 23
keeper._quiet_hours_end = 8
# 静默时段内
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 23, 30, tzinfo=UTC)) is True
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 3, 0, tzinfo=UTC)) is True
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 7, 59, tzinfo=UTC)) is True
# 静默时段外
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 8, 0, tzinfo=UTC)) is False
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 12, 0, tzinfo=UTC)) is False
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 22, 59, tzinfo=UTC)) is False
@pytest.mark.asyncio
async def test_list_active_characters(
keeper: KnockingPivot,
keeper_session_factory: async_sessionmaker[AsyncSession],
) -> None:
"""_list_active_characters 只返回 status='active' 的角色。"""
async with keeper_session_factory() as session:
for i, status in enumerate(["active", "inactive", "paused", "active"]):
session.add(
Character(
id=f"char_{i}",
name=f"人物{i}",
status=status,
owner_scope="test",
language="zh-CN",
privacy_level="normal",
)
)
await session.commit()
async with keeper_session_factory() as session:
result = await keeper._list_active_characters(session)
assert set(result) == {"char_0", "char_3"}
@@ -1,93 +0,0 @@
"""灵核情绪状态数据访问测试(设计 §11.4)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.personality_emotion import CharacterEmotionState
from app.repositories import personality_emotion as emotion_repo
from app.services.characters import get_active_character_model
def _state(character_id: str, **overrides) -> CharacterEmotionState:
base = {
"character_id": character_id,
"mood": "touched",
"intensity": 0.7,
"baseline_mood": "neutral",
"baseline_intensity": 0.2,
"last_trigger_event_id": "evt_abc",
}
base.update(overrides)
return CharacterEmotionState(**base)
@pytest.mark.asyncio
async def test_get_state_returns_none_when_absent() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
assert await emotion_repo.get_state(session, character.id) is None
@pytest.mark.asyncio
async def test_upsert_state_inserts_new() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
created = await emotion_repo.upsert_state(session, _state(cid))
await session.commit()
assert created.mood == "touched"
assert created.intensity == 0.7
async with AsyncSessionLocal() as session:
fetched = await emotion_repo.get_state(session, cid)
assert fetched is not None
assert fetched.mood == "touched"
assert fetched.last_trigger_event_id == "evt_abc"
@pytest.mark.asyncio
async def test_upsert_state_updates_existing() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await emotion_repo.upsert_state(session, _state(cid, mood="touched", intensity=0.7))
await emotion_repo.upsert_state(
session,
_state(
cid,
mood="concerned",
intensity=0.5,
last_trigger_event_id="evt_def",
),
)
await session.commit()
async with AsyncSessionLocal() as session:
fetched = await emotion_repo.get_state(session, cid)
assert fetched is not None
assert fetched.mood == "concerned"
assert fetched.intensity == 0.5
assert fetched.last_trigger_event_id == "evt_def"
@pytest.mark.asyncio
async def test_upsert_state_single_row_per_character() -> None:
"""单行 per character:重复 upsert 不产生重复行。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await emotion_repo.upsert_state(session, _state(cid))
await emotion_repo.upsert_state(session, _state(cid))
await emotion_repo.upsert_state(session, _state(cid))
await session.commit()
async with AsyncSessionLocal() as session:
from sqlalchemy import func, select
count = int(
(
await session.execute(
select(func.count())
.select_from(CharacterEmotionState)
.where(CharacterEmotionState.character_id == cid)
)
).scalar_one()
)
assert count == 1
@@ -1,79 +0,0 @@
"""时态纪念日数据访问测试(辰正 §5.3)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.time_anniversary import TimeAnniversary
from app.repositories import time_anniversaries as anni_repo
from app.services.characters import get_active_character_model
def _anniversary(character_id: str, **overrides) -> TimeAnniversary:
base = {
"character_id": character_id,
"name": "我们的相遇日",
"calendar": "solar",
"date": "2025-09-19",
"recurs_yearly": True,
"kind": "anniversary",
"subject_type": "relationship",
"source": "auto",
"metadata_json": "{}",
}
base.update(overrides)
return TimeAnniversary(**base)
@pytest.mark.asyncio
async def test_create_and_get_anniversary() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
fetched = await anni_repo.get_anniversary(session, created.id)
assert fetched is not None
assert fetched.name == "我们的相遇日"
assert fetched.id.startswith("anniv_")
@pytest.mark.asyncio
async def test_list_anniversaries_by_character() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await anni_repo.create_anniversary(session, _anniversary(character.id, date="2025-03-01"))
await anni_repo.create_anniversary(session, _anniversary(character.id, date="2025-09-19"))
items = await anni_repo.list_anniversaries(session, character.id)
# 默认人物 seed 时已带 1 条 birthday 纪念日;此处只验证 anniversary kind 的列表行为
anniversary_items = [a for a in items if a.kind == "anniversary"]
assert len(anniversary_items) == 2
# list 默认按 date 升序
assert anniversary_items[0].date < anniversary_items[1].date
@pytest.mark.asyncio
async def test_update_anniversary() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
updated = await anni_repo.update_anniversary(
session, created.id, name="相识纪念日", source="manual"
)
assert updated is not None
assert updated.name == "相识纪念日"
assert updated.source == "manual"
@pytest.mark.asyncio
async def test_update_anniversary_missing_returns_none() -> None:
async with AsyncSessionLocal() as session:
result = await anni_repo.update_anniversary(session, "anniv_nonexistent", name="x")
assert result is None
@pytest.mark.asyncio
async def test_delete_anniversary() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
assert await anni_repo.delete_anniversary(session, created.id) is True
assert await anni_repo.get_anniversary(session, created.id) is None
assert await anni_repo.delete_anniversary(session, created.id) is False
@@ -1,66 +0,0 @@
"""角色卡导入服务测试(DB 集成)。"""
from __future__ import annotations
import json
from app.db.session import AsyncSessionLocal
from app.repositories import lorebook as lorebook_repo
from app.services.card_import import import_character_card
async def test_import_creates_character_and_lorebook() -> None:
card = {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "狂三",
"nickname": "三三",
"personality": "腹黑优雅",
"character_book": {
"name": "狂三世谱",
"entries": [
{"keys": ["猫"], "content": "喜欢猫。"},
{"keys": ["时间"], "content": "时间能力。", "constant": True},
],
},
},
}
raw = json.dumps(card).encode("utf-8")
async with AsyncSessionLocal() as session:
character = await import_character_card(session, raw, "local-user")
books = await lorebook_repo.list_books(session, character.id, "local-user")
entries = await lorebook_repo.list_book_entries(session, books[0].id)
assert character.name == "狂三"
assert character.display_name == "三三"
assert character.personality_summary == "腹黑优雅"
assert len(books) == 1
assert books[0].name == "狂三世谱"
assert len(entries) == 2
assert any(e.constant for e in entries)
async def test_import_without_book_creates_only_character() -> None:
card = {"spec_version": "2.0", "data": {"name": "无书角色", "personality": "温和"}}
raw = json.dumps(card).encode("utf-8")
async with AsyncSessionLocal() as session:
character = await import_character_card(session, raw, "local-user")
books = await lorebook_repo.list_books(session, character.id, "local-user")
assert character.name == "无书角色"
assert character.personality_summary == "温和"
assert books == []
async def test_import_v1_flat_card() -> None:
# V1 扁平结构,无 data 包裹
card = {"name": "V1角色", "personality": "p"}
raw = json.dumps(card).encode("utf-8")
async with AsyncSessionLocal() as session:
character = await import_character_card(session, raw, "local-user")
assert character.name == "V1角色"
assert character.personality_summary == "p"
@@ -1,87 +0,0 @@
"""灵核情绪状态机端到端集成测试:Dispatcher + handle_time_event + DB。
验证一炁订阅基础设施(计划 5)+ 灵核情绪状态机(设计 §11)全链路:
EventStore.append(time.reunion) → Dispatcher 分发 → handle_time_event → DB 情绪状态更新。
这是 main.py lifespan ``dispatcher.register("time.*", handle_time_event)`` 的端到端验证。
"""
from __future__ import annotations
import asyncio
import pytest
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.personality import PersonalityVersion
from app.modules.runtime.events import Event, InMemoryEventStore
from app.repositories.personality_emotion import get_state
from app.services.characters import get_active_character_model
from app.services.dispatcher import Dispatcher
from app.services.personality_emotion import handle_time_event
@pytest.mark.asyncio
async def test_dispatcher_drives_emotion_state_update() -> None:
"""append time.reunion → dispatcher 分发 → handle_time_event 写入 DB。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
pv = PersonalityVersion(
character_id=cid,
identity_profile="测试身份",
emotion_baseline='{"mood": "neutral", "intensity": 0.2}',
)
session.add(pv)
await session.flush()
character.current_personality_version_id = pv.id
await session.commit()
store = InMemoryEventStore()
dispatcher = Dispatcher(store)
dispatcher.register("time.*", handle_time_event)
await dispatcher.start()
try:
await store.append(
Event(
type="time.reunion",
source="time_sense",
payload={"reunion_intensity": 0.6, "offline_band": "reunion_impact"},
character_id=cid,
)
)
await asyncio.sleep(0.1) # 等 pump + consumer 异步完成
finally:
await dispatcher.stop()
async with AsyncSessionLocal() as session:
row = await get_state(session, cid)
assert row is not None
assert row.mood == "touched"
assert abs(row.intensity - 0.8) < 0.001 # 0.2 baseline + 0.6 delta
character = await session.get(Character, cid)
assert character is not None
assert character.current_mood_id == "touched"
@pytest.mark.asyncio
async def test_dispatcher_ignores_non_time_events() -> None:
"""非 time.* 事件不触发 handle_time_event(无副作用:无 state row 写入)。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await session.commit()
store = InMemoryEventStore()
dispatcher = Dispatcher(store)
dispatcher.register("time.*", handle_time_event)
await dispatcher.start()
try:
await store.append(Event(type="run.started", source="run", character_id=cid))
await asyncio.sleep(0.05)
finally:
await dispatcher.stop()
async with AsyncSessionLocal() as session:
# 非 time.* 事件 → handler 不被调用 → 无 state row
assert await get_state(session, cid) is None
@@ -1,226 +0,0 @@
"""灵核情绪状态服务测试(设计 §11)。"""
from datetime import UTC, datetime, timedelta
import pytest
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.chat import ChatMessage, ChatSession
from app.models.personality import PersonalityVersion
from app.models.personality_emotion import CharacterEmotionState
from app.modules.personality.emotion import EmotionState
from app.modules.runtime.events import Event
from app.services.characters import get_active_character_model
from app.services.personality_emotion import (
apply_time_event,
compute_current_state,
handle_time_event,
)
async def _seed_personality(
session, character_id: str, baseline_json: str = '{"mood": "neutral", "intensity": 0.2}'
) -> PersonalityVersion:
"""创建人格版本并绑定到 character.current_personality_version_id。"""
pv = PersonalityVersion(
character_id=character_id,
identity_profile="测试身份",
emotion_baseline=baseline_json,
)
session.add(pv)
await session.flush()
character = await session.get(Character, character_id)
assert character is not None
character.current_personality_version_id = pv.id
return pv
def _reunion_event(
character_id: str, intensity: float = 0.6, event_id: str = "event_test1"
) -> Event:
return Event(
id=event_id,
type="time.reunion",
source="time_sense",
payload={"reunion_intensity": intensity, "offline_band": "reunion_impact"},
character_id=character_id,
)
# ---- compute_current_state ----
@pytest.mark.asyncio
async def test_compute_no_state_returns_baseline() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid, '{"mood": "pleased", "intensity": 0.4}')
await session.commit()
async with AsyncSessionLocal() as session:
state = await compute_current_state(session, cid)
assert state == EmotionState(mood="pleased", intensity=0.4)
@pytest.mark.asyncio
async def test_compute_no_state_no_personality_returns_default() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await session.commit()
async with AsyncSessionLocal() as session:
state = await compute_current_state(session, cid)
assert state == EmotionState() # neutral 0.2
@pytest.mark.asyncio
async def test_compute_with_state_zero_turns_returns_state_unchanged() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid)
session.add(
CharacterEmotionState(
character_id=cid,
mood="touched",
intensity=0.8,
baseline_mood="neutral",
baseline_intensity=0.2,
)
)
await session.commit()
async with AsyncSessionLocal() as session:
state = await compute_current_state(session, cid)
# 无后续 user 回合 → 不 decay
assert state is not None
assert state.mood == "touched"
assert abs(state.intensity - 0.8) < 0.001
@pytest.mark.asyncio
async def test_compute_decays_after_user_messages() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid)
# 写入 touched 0.8 状态,updated_at 手动设为 1 小时前
old = datetime.now(UTC) - timedelta(hours=1)
row = CharacterEmotionState(
character_id=cid,
mood="touched",
intensity=0.8,
baseline_mood="neutral",
baseline_intensity=0.2,
)
session.add(row)
await session.flush()
# SQLite 读回 naive;写入时也用 naive 保持一致
row.updated_at = old.replace(tzinfo=None)
# 6 条 user 消息 = 半衰期回合数 → intensity 衰减一半
chat_session = ChatSession(character_id=cid)
session.add(chat_session)
await session.flush()
for i in range(6):
session.add(
ChatMessage(
character_id=cid,
session_id=chat_session.id,
role="user",
content=f"msg {i}",
)
)
await session.commit()
async with AsyncSessionLocal() as session:
state = await compute_current_state(session, cid)
# elapsed=6, factor=0.5 → 0.2 + (0.8-0.2)*0.5 = 0.5
assert state is not None
assert state.mood == "touched"
assert abs(state.intensity - 0.5) < 0.01
# ---- apply_time_event ----
@pytest.mark.asyncio
async def test_apply_reunion_initializes_from_baseline() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid, '{"mood": "neutral", "intensity": 0.2}')
await apply_time_event(_reunion_event(cid, intensity=0.6), session)
await session.commit()
async with AsyncSessionLocal() as session:
from app.repositories.personality_emotion import get_state
row = await get_state(session, cid)
assert row is not None
assert row.mood == "touched"
assert abs(row.intensity - 0.8) < 0.001 # 0.2 baseline + 0.6 delta
assert row.last_trigger_event_id == "event_test1"
character = await session.get(Character, cid)
assert character is not None
assert character.current_mood_id == "touched"
@pytest.mark.asyncio
async def test_apply_reunion_accumulates_when_same_mood() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid)
await apply_time_event(_reunion_event(cid, intensity=0.5), session)
await apply_time_event(_reunion_event(cid, intensity=0.3, event_id="event_test2"), session)
await session.commit()
async with AsyncSessionLocal() as session:
from app.repositories.personality_emotion import get_state
row = await get_state(session, cid)
assert row is not None
assert row.mood == "touched"
# 首次 0.2+0.5=0.7;同 mood 累加 0.7+0.3=1.0
assert abs(row.intensity - 1.0) < 0.001
assert row.last_trigger_event_id == "event_test2"
@pytest.mark.asyncio
async def test_apply_event_without_character_id_is_noop() -> None:
async with AsyncSessionLocal() as session:
await apply_time_event(
Event(type="time.reunion", source="time_sense", payload={}, character_id=None),
session,
)
await session.commit()
# 无异常即通过;无副作用可验证(无 character_id 无法写)
# ---- handle_time_event ----
@pytest.mark.asyncio
async def test_handle_time_event_commits_via_own_session() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid)
await session.commit()
# handle_time_event 自管 session + 提交
await handle_time_event(_reunion_event(cid, intensity=0.6))
async with AsyncSessionLocal() as session:
from app.repositories.personality_emotion import get_state
row = await get_state(session, cid)
assert row is not None
assert row.mood == "touched"
@pytest.mark.asyncio
async def test_handle_time_event_swallows_exception() -> None:
"""降级原则:handler 异常不抛(不阻塞事件流)。"""
# character_id 指向不存在的角色 → _resolve_baseline 返回默认,apply 仍尝试写
# 但更直接:用一个会触发异常的事件(如 DB 约束)。这里验证无 character_id 不抛 + 任意异常被吞。
# 构造一个正常事件,handler 应正常完成不抛
await handle_time_event(
Event(type="time.reunion", source="time_sense", payload={}, character_id="char_nonexistent")
)
# 无抛出即通过
@@ -1,112 +0,0 @@
"""辰正集成测试:重逢流事件链 + milestone 联动(设计 §6.3)。"""
from datetime import UTC, datetime
import pytest
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage, ChatSession
from app.models.time_anniversary import TimeAnniversary
from app.modules.runtime.events import InMemoryEventStore
from app.modules.time_sense.modulation import ModulationInput
from app.services.characters import get_active_character_model
from app.services.time_sense import compute_live_snapshot
@pytest.mark.asyncio
async def test_reunion_flow_emits_reunion_and_sentiment():
"""离线 > 3 天 → time.reunion + time.sentiment(reunion_memory)。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
chat_session = ChatSession(character_id=character.id, status="active")
session.add(chat_session)
await session.flush()
old_msg = ChatMessage(
character_id=character.id,
session_id=chat_session.id,
role="user",
content="5 天前的消息",
created_at=datetime(2026, 6, 29, 4, 0, tzinfo=UTC),
)
session.add(old_msg)
await session.flush()
store = InMemoryEventStore()
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 5 天后
)
assert result.snapshot is not None
assert result.snapshot.offline.band == "reunion_impact"
event_types = {e.type for e in result.emitted_events}
assert "time.reunion" in event_types
assert "time.sentiment" in event_types # reunion_memory 触发
sentiment = next(e for e in result.emitted_events if e.type == "time.sentiment")
assert sentiment.payload["trigger_type"] == "reunion_memory"
@pytest.mark.asyncio
async def test_no_event_when_active():
"""active 档(无历史消息,last_active=now)→ 无 reunion/sentiment 事件。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
store = InMemoryEventStore()
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00 上午
)
assert result.snapshot is not None
assert result.snapshot.offline.band == "active"
event_types = {e.type for e in result.emitted_events}
assert "time.reunion" not in event_types
assert "time.sentiment" not in event_types
assert "time.user_care" not in event_types # 白天 + 无 birthday 等信号
@pytest.mark.asyncio
async def test_milestone_at_late_night_emits_sentiment_overlay():
"""凌晨 + 今日命中纪念日 → active_milestones 非空 + time.sentiment(late_night_overlay)。
验证 anniversary 命中 + 凌晨时段联动 sentiment 的集成路径(Task 4 未覆盖)。
"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
anni = TimeAnniversary(
character_id=character.id,
name="我们的相遇日",
calendar="solar",
date="2026-07-05", # 今日(now=18:00 UTC → 上海 02:00 本地日期 07-05)命中
recurs_yearly=True,
kind="anniversary",
subject_type="relationship",
source="manual",
)
session.add(anni)
await session.flush()
store = InMemoryEventStore()
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨
)
assert result.snapshot is not None
assert result.snapshot.day_period == "凌晨"
assert len(result.snapshot.active_milestones) >= 1
# duration_node(0 不在 nodes)/anniversary_prewarm(next=365>7)/reunion_memory(active 档) 都不触发
# → late_night_overlay(凌晨 + has_active_milestone)
sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"]
assert len(sentiment_events) == 1
assert sentiment_events[0].payload["trigger_type"] == "late_night_overlay"
# active 档(无 chat 历史)→ 不触发 reunion
assert "time.reunion" not in {e.type for e in result.emitted_events}
@@ -1,137 +0,0 @@
"""辰正 service 测试(流 A 实时快照 + 流 C 事件触发)。"""
from datetime import UTC, datetime, timedelta
import pytest
from app.db.session import AsyncSessionLocal
from app.models.chat import ChatMessage, ChatSession
from app.modules.runtime.events import InMemoryEventStore
from app.modules.time_sense.modulation import ModulationInput
from app.services.characters import get_active_character_model
from app.services.time_sense import compute_live_snapshot
@pytest.mark.asyncio
async def test_compute_live_snapshot_returns_snapshot_and_factors():
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
store = InMemoryEventStore()
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00
)
assert result.snapshot is not None
assert result.factors is not None
assert result.context_text # 非空
assert "周" in result.context_text
@pytest.mark.asyncio
async def test_compute_live_snapshot_degrades_on_failure():
"""计算失败时降级返回最小快照(不抛异常)。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
store = InMemoryEventStore()
# 传入非法时区触发降级
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC),
timezone="Invalid/Zone",
)
# 降级:snapshot 仍返回(compute_snapshot 内部降级为 UTC),不抛
assert result.snapshot is not None
@pytest.mark.asyncio
async def test_emits_user_care_when_birthday():
"""user_birthday_today → care_kind=birthday → 发 time.user_care(不依赖 snapshot 时段/时区)。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
store = InMemoryEventStore()
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(user_birthday_today=True),
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00
)
assert result.snapshot is not None
care_events = [e for e in result.emitted_events if e.type == "time.user_care"]
assert len(care_events) == 1
assert care_events[0].payload["care_kind"] == "birthday"
# birthday 不应触发 reunion(last_active≈now),也不命中 sentiment 节点
assert all(e.type != "time.reunion" for e in result.emitted_events)
@pytest.mark.asyncio
async def test_emits_reunion_when_offline_exceeds_threshold():
"""seed 5 天前消息 → offline.band=reunion_impact → 发 time.reunion + time.sentiment。"""
now = datetime(2026, 7, 4, 4, 0, tzinfo=UTC)
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
# 先建会话(FK 约束),再 seed 一条 5 天前的消息
chat_session = ChatSession(character_id=character.id, status="active")
session.add(chat_session)
await session.flush()
session.add(
ChatMessage(
character_id=character.id,
session_id=chat_session.id,
role="user",
content="旧消息",
created_at=now - timedelta(days=5),
)
)
await session.flush()
store = InMemoryEventStore()
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(),
now=now,
)
assert result.snapshot is not None
assert result.snapshot.offline.band == "reunion_impact"
# 5 天 = 7200 min > 4320 min 阈值 → 重逢
reunion_events = [e for e in result.emitted_events if e.type == "time.reunion"]
assert len(reunion_events) == 1
payload = reunion_events[0].payload
assert payload["offline_band"] == "reunion_impact"
assert "reunion_intensity" in payload
assert payload["offline_minutes"] >= 4320
assert reunion_events[0].sensitivity == "medium"
# is_reunion=True + has_reunion_memory=is_reunion → sentiment=reunion_memory
sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"]
assert len(sentiment_events) == 1
assert sentiment_events[0].payload["trigger_type"] == "reunion_memory"
@pytest.mark.asyncio
async def test_compute_live_snapshot_attaches_run_id_to_events():
"""对话入口传 run_id → 触发的 time.* 事件全部带该 run_id(落当前 run,SSE 可见)。"""
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
store = InMemoryEventStore()
# 凌晨场景触发 late_night care / sentiment
result = await compute_live_snapshot(
session,
store,
character.id,
ModulationInput(conversation_turns_today=3),
now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨
timezone="Asia/Shanghai",
run_id="run_test_plan4",
)
assert result.emitted_events, "凌晨 + 3 轮应触发至少一个 time.* 事件"
for ev in result.emitted_events:
assert ev.run_id == "run_test_plan4"
-87
View File
@@ -1,87 +0,0 @@
"""辰正 time API 端点测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers=auth_headers,
) as http_client:
yield http_client
@pytest.mark.asyncio
async def test_get_config(client: AsyncClient) -> None:
response = await client.get("/api/v1/time/config")
assert response.status_code == 200
data = response.json()
assert data["user_timezone"] == "Asia/Shanghai"
assert data["default_city"] == "上海"
assert data["care_enabled"] is True
@pytest.mark.asyncio
async def test_anniversaries_crud(client: AsyncClient) -> None:
create = await client.post(
"/api/v1/time/anniversaries",
json={
"name": "测试纪念日",
"calendar": "solar",
"date": "2025-09-19",
"recurs_yearly": True,
"kind": "anniversary",
"subject_type": "relationship",
"source": "manual",
},
)
assert create.status_code == 201
anniv_id = create.json()["id"]
assert anniv_id.startswith("anniv_")
listed = await client.get("/api/v1/time/anniversaries")
assert listed.status_code == 200
assert any(a["id"] == anniv_id for a in listed.json())
patched = await client.patch(
f"/api/v1/time/anniversaries/{anniv_id}", json={"name": "改后的名字"}
)
assert patched.status_code == 200
assert patched.json()["name"] == "改后的名字"
deleted = await client.delete(f"/api/v1/time/anniversaries/{anniv_id}")
assert deleted.status_code == 204
not_found = await client.get(f"/api/v1/time/anniversaries/{anniv_id}")
assert not_found.status_code == 404
assert not_found.json()["error"]["code"] == "not_found"
@pytest.mark.asyncio
async def test_get_snapshot(client: AsyncClient) -> None:
# ASGITransport 可能不触发 lifespan 装配 event_store;get_event_store 未就绪时返回 503
response = await client.get("/api/v1/time/snapshot")
assert response.status_code in (200, 503)
if response.status_code == 200:
data = response.json()
assert "context_text" in data
assert "snapshot" in data
@pytest.mark.asyncio
async def test_get_acquaintance(client: AsyncClient) -> None:
# 与 /snapshot 同因:ASGITransport 可能不触发 lifespan;snapshot 降级时返回 503
response = await client.get("/api/v1/time/acquaintance")
assert response.status_code in (200, 503)
if response.status_code == 200:
data = response.json()
assert isinstance(data["total_days"], int)
assert "natural_language" in data
-56
View File
@@ -1,56 +0,0 @@
"""审计测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.ai_provider.types.chat import ChatChunk
from app.application.agent.runtime import AgentTurnResult
from app.application.chat.send_message import drain_pending_streams
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.safety import AuditEvent
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。"))
return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers=auth_headers,
) as http_client:
yield http_client
@pytest.mark.asyncio
async def test_chat_and_memory_write_audit_events(client: AsyncClient) -> None:
session_response = await client.post("/api/v1/chat/sessions", json={"title": "审计"})
session_id = session_response.json()["session_id"]
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
assert sent.status_code == 200
await drain_pending_streams()
memory = await client.post(
"/api/v1/memory",
json={"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"},
)
assert memory.status_code == 200
async with AsyncSessionLocal() as db:
actions = [item.action for item in (await db.execute(select(AuditEvent))).scalars().all()]
assert "run.started" in actions
assert "model.request.started" in actions
assert "model.response.completed" in actions
assert "memory.created" in actions
-100
View File
@@ -1,100 +0,0 @@
"""幂等键测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.ai_provider.types.chat import ChatChunk
from app.application.agent.runtime import AgentTurnResult
from app.application.chat.send_message import drain_pending_streams
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.chat import ChatMessage
from app.models.idempotency import IdempotencyKey
from app.models.memory import Memory
from app.models.runtime import Run
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。"))
return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers=auth_headers,
) as http_client:
yield http_client
async def _create_session(client: AsyncClient) -> str:
response = await client.post("/api/v1/chat/sessions", json={"title": "幂等测试"})
assert response.status_code == 200
return str(response.json()["session_id"])
@pytest.mark.asyncio
async def test_send_message_idempotency_replays_same_response(client: AsyncClient) -> None:
session_id = await _create_session(client)
headers = {"Idempotency-Key": "send-same-1"}
payload = {"content": "你好", "modality": "text"}
first = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages", json=payload, headers=headers
)
second = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages", json=payload, headers=headers
)
assert first.status_code == 200
assert second.status_code == 200
assert second.json() == first.json()
await drain_pending_streams()
async with AsyncSessionLocal() as db:
runs = (await db.execute(select(Run))).scalars().all()
messages = (await db.execute(select(ChatMessage))).scalars().all()
keys = (await db.execute(select(IdempotencyKey))).scalars().all()
assert len(runs) == 1
assert len(messages) == 2
assert len(keys) == 1
@pytest.mark.asyncio
async def test_idempotency_key_conflict(client: AsyncClient) -> None:
session_id = await _create_session(client)
headers = {"Idempotency-Key": "send-conflict-1"}
first = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
headers=headers,
)
conflict = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "不同内容", "modality": "text"},
headers=headers,
)
assert first.status_code == 200
assert conflict.status_code == 409
assert conflict.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_memory_create_idempotency(client: AsyncClient) -> None:
headers = {"Idempotency-Key": "memory-same-1"}
payload = {"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"}
first = await client.post("/api/v1/memory", json=payload, headers=headers)
second = await client.post("/api/v1/memory", json=payload, headers=headers)
assert first.status_code == 200
assert second.status_code == 200
assert second.json()["id"] == first.json()["id"]
async with AsyncSessionLocal() as db:
memories = (await db.execute(select(Memory))).scalars().all()
assert len(memories) == 1
+208
View File
@@ -0,0 +1,208 @@
"""M2b 导入改造与组装管线测试。
- 导入 ST 卡(JSON V2)→ 可审阅灵胚(不直接建人物);启镜后 character_book 物化为世谱;
- 组装管线:{{char}}/{{user}} 符文在投影 prompt 中展开;火候(GenerationPreferences)
的 temperature/max_tokens 进入 llm.requested 的 IR。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.kernel import Kernel, KernelEventLog
from app.services.event_store import DbEventStore
from app.shenshi_client import EchoShenshi
_CARD_V2 = {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "绯樱",
"description": "一位安静的图书馆管理员。",
"personality": "温柔、寡言",
"scenario": "午后的图书馆",
"first_mes": "……你来了。",
"mes_example": "",
"creator": "tester",
"character_version": "1.0",
"character_book": {
"entries": {
"0": {
"keys": ["图书馆", "书"],
"content": "图书馆只在午后开放。",
"enabled": True,
"insertion_order": 10,
},
"1": {
"keys": ["绯樱"],
"content": "绯樱从不谈论自己的过去。",
"enabled": True,
},
}
},
},
}
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
from app.main import app
event_store = DbEventStore(AsyncSessionLocal)
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(event_store),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def test_import_card_creates_reviewable_embryo(client: AsyncClient) -> None:
files = {"file": ("card.json", json.dumps(_CARD_V2).encode(), "application/json")}
imported = await client.post("/api/v1/characters/import-card", files=files)
assert imported.status_code == 201
body = imported.json()
assert body["source"] == "imported"
assert body["name"] == "绯樱"
assert body["status"] == "drafting"
assert body["book_entry_count"] == 2
# 未启镜:本用户名下无人物、无世谱(库内系统模板人物不计)
from sqlalchemy import func, select
from app.models.character import Character
from app.models.lorebook import Lorebook
me = (await client.get("/api/v1/auth/me")).json()
owner = me["id"]
async def _own_counts() -> tuple[int, int]:
async with AsyncSessionLocal() as session:
characters = int(
await session.scalar(
select(func.count(Character.id)).where(Character.owner_scope == owner)
)
)
books = int(
await session.scalar(
select(func.count(Lorebook.id)).where(Lorebook.owner_scope == owner)
)
)
return characters, books
# 导入只产灵胚:本用户人物/世谱数量不变(种子会预建一个默认人物,按差量断言)
before = await _own_counts()
assert before == await _own_counts()
# 启镜:物化人物 + 世谱书 + 条目(dict 形 entries 兼容)
activated = await client.post(f"/api/v1/embryo-drafts/{body['embryo_id']}/activate")
assert activated.status_code == 200
character_id = activated.json()["character_id"]
after = await _own_counts()
assert after[0] == before[0] + 1 # 启镜才产生人物
assert after[1] == before[1] + 1 # 启镜才物化世谱书
from app.models.lorebook import LorebookEntry
async with AsyncSessionLocal() as session:
books = (
await session.execute(select(Lorebook).where(Lorebook.character_id == character_id))
).scalars().all()
assert len(books) == 1
entries = (
await session.execute(
select(LorebookEntry).where(LorebookEntry.lorebook_id == books[0].id)
)
).scalars().all()
assert len(entries) == 2
by_key = {json.loads(e.keys_json)[0]: e for e in entries}
assert by_key["图书馆"].content == "图书馆只在午后开放。"
async def test_macro_expansion_and_preset_in_request(client: AsyncClient) -> None:
# 火候:按当前用户写一行 GenerationPreferences
from app.models.governance import GenerationPreferences
me = (await client.get("/api/v1/auth/me")).json()
async with AsyncSessionLocal() as session:
session.add(
GenerationPreferences(
owner_scope=me["id"],
default_model_hint="chat",
temperature=0.3,
max_output_tokens=512,
)
)
await session.commit()
# 建树(灵胚启镜后),问题含 {{user}} 符文;指引含 {{char}}
embryo = (
await client.post(
"/api/v1/embryo-drafts", json={"source": "original", "name": "符文灵"}
)
).json()
character_id = (
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
)
tree = (
await client.post(
"/api/v1/trees", json={"character_id": character_id, "title": "符文树"}
)
).json()
q = (
await client.post(
f"/api/v1/trees/{tree['id']}/nodes",
json={"content": "{{user}} 想请教 {{char}} 一个问题"},
)
).json()
spawned = (
await client.post(f"/api/v1/nodes/{q['id']}/answers", json={"instructions": "请以 {{char}} 的口吻回答"})
).json()
for _ in range(300):
status = (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"]
if status in ("completed", "interrupted", "failed"):
break
await asyncio.sleep(0.02)
assert status == "completed"
# llm.requested 事件:符文已展开、火候参数已进入 IR
events = (await client.get(f"/api/v1/runs/{spawned['run_id']}/events")).json()
requested = next(e for e in events if e["type"] == "llm.requested")
ir = requested["payload"]["request"]
prompt = ir["messages"][-1]["content"]
assert "{{user}}" not in prompt and "{{char}}" not in prompt
assert "符文灵" in prompt # {{char}} 展开为人物名
assert ir["temperature"] == 0.3
assert ir["max_tokens"] == 512
async def test_import_v1_flat_card(client: AsyncClient) -> None:
"""V1 扁平卡(无 data 包装)同样产灵胚。"""
v1 = {
"name": "旧式卡",
"description": "一位老友。",
"personality": "爽朗",
"scenario": "巷口",
"first_mes": "来了?",
"mes_example": "",
}
files = {"file": ("card.json", json.dumps(v1).encode(), "application/json")}
imported = await client.post("/api/v1/characters/import-card", files=files)
assert imported.status_code == 201
body = imported.json()
assert body["name"] == "旧式卡"
assert body["book_entry_count"] == 0
-206
View File
@@ -1,206 +0,0 @@
"""send_message 接入 run_agent_turn 的验证测试(阶段2b 步骤B)。
mock 边界:
- ``app.application.chat.send_message.run_agent_turn`` —— 不真实跑 Agent 循环,
只校验 send_message 是否正确接入(传 tools/default_tools、on_chunk、on_tool_event)。
- ``app.application.chat.send_message.default_tools`` —— 校验被调用。
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.ai_provider.types.chat import ChatChunk
from app.application.agent.runtime import AgentTurnResult
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.runtime import Event
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
# 清理前序(test_tools 等)残留的 app.state 避免污染
for _attr in ("tool_runtime", "confirmation_registry", "confirmation_store", "event_store"):
app.state.__dict__.pop(_attr, None)
agent_content = "agent 最终回复正文。"
async def fake_run_agent_turn(
*,
on_chunk: Any,
on_tool_event: Any,
**_kwargs: object,
) -> AgentTurnResult:
# 模拟 Agent 内部流:先一段 reasoning(若 thinking 启用)、再 content chunk
await on_chunk(ChatChunk(delta_reasoning="思考摘要"))
await on_chunk(ChatChunk(delta_content=agent_content))
# 模拟一次工具调用生命周期
await on_tool_event("tool.started", {"tool_name": "echo", "tool_call_id": "c1"})
await on_tool_event(
"tool.completed", {"tool_name": "echo", "tool_call_id": "c1", "status": "success"}
)
return AgentTurnResult(content=agent_content, reasoning="思考摘要")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _create_session(client: AsyncClient) -> str:
resp = await client.post("/api/v1/chat/sessions", json={"title": "agent 接入"})
assert resp.status_code == 200
return str(resp.json()["session_id"])
@pytest.mark.asyncio
async def test_send_message_invokes_run_agent_turn_with_tools_and_callbacks(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""send_message 调用 run_agent_turn 时传了 tools=default_tools()。"""
app.dependency_overrides.clear()
captured: dict[str, Any] = {}
captured_count: int = 0
real_default_tools = __import__(
"app.application.chat.send_message", fromlist=["default_tools"]
).default_tools
def spy_default_tools(*args: Any, **kwargs: Any) -> list[dict[str, Any]]:
nonlocal captured_count
captured_count += 1
captured["kwargs"] = kwargs
captured["tools"] = real_default_tools(*args, **kwargs)
return captured["tools"]
monkeypatch.setattr("app.application.chat.send_message.default_tools", spy_default_tools)
from app.application.chat.send_message import drain_pending_streams
session_id = await _create_session(client)
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text"},
)
assert sent.status_code == 200
await drain_pending_streams()
# default_tools 被调用,至少包含 5 个核心工具
assert captured_count > 0, f"spy_default_tools never called, captured={captured}"
assert "tools" in captured
tool_names = {t["function"]["name"] for t in captured["tools"]}
assert tool_names >= {"datetime", "read_file", "list_dir", "write_file", "shell"}
assert captured["tools"][0]["type"] == "function"
names = {t["function"]["name"] for t in captured["tools"]}
assert {"datetime", "shell"} <= names
@pytest.mark.asyncio
async def test_send_message_persists_agent_result_content_and_tool_events(
client: AsyncClient,
) -> None:
"""message.completed 的 content == agent_result.content;tool.* 事件落库 source=agent。"""
from app.application.chat.send_message import drain_pending_streams
session_id = await _create_session(client)
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "你好", "modality": "text", "thinking": True},
)
assert sent.status_code == 200
run_id = str(sent.json()["run_id"])
await drain_pending_streams()
async with AsyncSessionLocal() as db:
events = (await db.execute(select(Event).where(Event.run_id == run_id))).scalars().all()
by_type: dict[str, list[Event]] = {}
for ev in events:
by_type.setdefault(ev.type, []).append(ev)
# 关键事件齐全
assert "run.started" in by_type
assert "message.delta" in by_type # on_chunk 触发了 content flush
assert "message.completed" in by_type
assert "run.completed" in by_type
# tool 事件存在且 source=agent
assert "tool.started" in by_type
assert "tool.completed" in by_type
assert all(ev.source == "agent" for ev in by_type["tool.started"])
assert all(ev.source == "agent" for ev in by_type["tool.completed"])
# message.completed 的 content == agent_result.content(权威值)
completed = by_type["message.completed"][-1]
import json
payload = json.loads(completed.payload_json)
assert payload["content"] == "agent 最终回复正文。"
@pytest.mark.asyncio
async def test_send_message_writes_extracted_session_goal_to_run(
client: AsyncClient,
) -> None:
"""太一最小闭环:会话目标从用户消息提取写入 run.goal,取代默认值。"""
from app.application.chat.send_message import drain_pending_streams
from app.models.runtime import Run
session_id = await _create_session(client)
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "帮我整理今天的笔记", "modality": "text"},
)
assert sent.status_code == 200
run_id = str(sent.json()["run_id"])
await drain_pending_streams()
async with AsyncSessionLocal() as db:
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
assert run.goal == "帮我整理今天的笔记"
@pytest.mark.asyncio
async def test_send_message_injects_session_goal_into_system_messages(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""洛书最小闭环:会话目标作为 system instruction 注入 ReAct。"""
from app.application.chat.send_message import drain_pending_streams
captured: dict[str, Any] = {}
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
captured["messages"] = messages
return AgentTurnResult(content="好的。", reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
session_id = await _create_session(client)
await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": "帮我整理笔记", "modality": "text"},
)
await drain_pending_streams()
# to_langchain_messages 把每个 system message 作为独立 SystemMessage,
# 目标片段出现在某个 system message 中(非 messages[0])
system_text = "\n".join(
str(m.content) for m in captured["messages"] if getattr(m, "role", "") == "system"
)
assert "当前会话目标:帮我整理笔记" in system_text
-77
View File
@@ -1,77 +0,0 @@
"""SSE 契约测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.ai_provider.types.chat import ChatChunk
from app.application.agent.runtime import AgentTurnResult
from app.core.config import settings
from app.main import app
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
_content = "你好,我是测试模型回复,会根据你的输入生成足够长的内容用于 SSE 分块验证。" * 4
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
await on_chunk(ChatChunk(delta_content=_content))
return AgentTurnResult(content=_content, reasoning="")
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _completed_run(client: AsyncClient, content: str = "你好") -> str:
created = await client.post("/api/v1/chat/sessions", json={"title": "SSE"})
session_id = created.json()["session_id"]
sent = await client.post(
f"/api/v1/chat/sessions/{session_id}/messages",
json={"content": content, "modality": "text"},
)
return str(sent.json()["run_id"])
@pytest.mark.asyncio
async def test_completed_run_stream_outputs_events(client: AsyncClient) -> None:
run_id = await _completed_run(client)
response = await client.get(f"/api/v1/runs/{run_id}/stream")
assert response.status_code == 200
text = response.text
assert "event: run.started" in text
assert "event: message.delta" in text
assert "event: message.completed" in text
assert "event: run.completed" in text
@pytest.mark.asyncio
async def test_completed_run_stream_outputs_chunked_message_deltas(client: AsyncClient) -> None:
run_id = await _completed_run(client, content="你好" * 80)
response = await client.get(f"/api/v1/runs/{run_id}/stream")
assert response.status_code == 200
text = response.text
assert text.count("event: message.delta") >= 2
assert "event: message.completed" in text
assert "event: run.completed" in text
@pytest.mark.asyncio
async def test_stream_not_found_and_disabled(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
not_found = await client.get("/api/v1/runs/run_missing/stream")
assert not_found.status_code == 404
assert not_found.json()["error"]["code"] == "not_found"
run_id = await _completed_run(client)
monkeypatch.setattr(settings, "enable_sse", False)
disabled = await client.get(f"/api/v1/runs/{run_id}/stream")
assert disabled.status_code == 400
assert disabled.json()["error"]["code"] == "invalid_request"