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