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
|
||||
Reference in new issue
Block a user