Files
hunjing/backend/app/services/characters.py
T
2026-08-21 16:00:13 +08:00

679 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""人物服务。"""
from datetime import datetime
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.character import Character
from app.models.personality import PersonalityVersion
from app.models.time_anniversary import TimeAnniversary
from app.modules.character import (
MODEL_POLICY_CREATE_DEFAULTS,
SYSTEM_TEMPLATE_OWNER_SCOPE,
extract_model_hint,
sanitize_profile,
)
from app.repositories import characters as character_repo
from app.repositories import personality as personality_repo
from app.repositories import time_anniversaries as anniversary_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.character import (
ActivateCharacterResponse,
CharacterResponse,
CharacterSummaryResponse,
CharacterTemplateSummaryResponse,
CreateCharacterRequest,
UpdateModelPolicyRequest,
UpdateProfileRequest,
)
from app.schemas.common import Page
from app.services.audit import record_audit_event
from app.services.character_templates import CharacterTemplateConfig, get_default_template
from app.services.serializers import character_to_response, character_to_summary
async def get_active_character_model(
session: AsyncSession, owner_scope: str = settings.default_owner_scope
) -> Character:
character = await character_repo.get_active_character(session, owner_scope)
if character is None:
raise AppError("not_found", "未找到当前激活人物。")
return character
async def resolve_active_character(
session: AsyncSession,
character_id: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> Character:
"""读取当前激活人物;传入 character_id 时校验归属。"""
character = await get_active_character_model(session, owner_scope)
if character_id is not None and character_id != character.id:
raise AppError("not_found", "人物不存在。")
return character
async def get_active_character(
owner_scope: str = settings.default_owner_scope,
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
return character_to_response(await get_active_character_model(session, owner_scope))
def default_model_hint(character: Character) -> str:
return extract_model_hint(character.model_policy_json, settings.default_model_hint)
async def _create_template_from_config(
session: AsyncSession, config: CharacterTemplateConfig
) -> Character:
"""从配置构建系统模板 ORM(使用 config/character_templates.yaml 内容)。"""
model_policy = dict(config.model_policy)
model_policy.setdefault("default_chat_model_hint", settings.default_model_hint)
profile = dict(config.profile)
sanitize_profile(profile)
character = Character(
name=config.name,
display_name=config.display_name,
status="active",
language=config.language,
profile_json=dumps(profile),
model_policy_json=dumps(model_policy),
privacy_level=config.privacy_level,
owner_scope=SYSTEM_TEMPLATE_OWNER_SCOPE,
is_system_template=True,
source_template_id=None,
template_key=config.key,
)
session.add(character)
await session.flush()
await _ensure_default_birthday(session, character)
return character
async def create_default_character(
session: AsyncSession, owner_scope: str = settings.default_owner_scope
) -> Character:
template = await ensure_default_system_template(session)
return await create_character_from_template(session, template, owner_scope)
async def ensure_character_personality(
session: AsyncSession, character: Character
) -> PersonalityVersion:
from app.services.personality import create_personality_from_profile
personality = None
if character.current_personality_version_id:
personality = await personality_repo.get_personality(
session, character.current_personality_version_id
)
if personality is None:
personality = await personality_repo.get_latest_personality(session, character.id)
if personality is None:
personality = await create_personality_from_profile(session, character)
if character.current_personality_version_id != personality.id:
character.current_personality_version_id = personality.id
await session.flush()
return personality
async def ensure_default_system_template(session: AsyncSession) -> Character:
"""按 config/character_templates.yaml 确保系统模板存在且最新。
Config 为单一事实源:DB 行缺失时 insert,字段不一致时覆盖更新。
子人物(is_system_template=False)不受影响。
"""
config = get_default_template()
template = await character_repo.get_system_template_by_key(session, config.key)
if template is None:
template = await _create_template_from_config(session, config)
else:
await _sync_template_from_config(session, template, config)
# create 路径已由 _create_template_from_config 兜底 birthday,
# sync 路径可能因旧数据缺失 birthday,再兜一次确保存在。
await _ensure_default_birthday(session, template)
await ensure_character_personality(session, template)
return template
async def ensure_all_system_templates(session: AsyncSession) -> list[Character]:
"""确保 ``config/character_templates.yaml`` 中定义的所有系统模板在 DB 中存在且最新。
遍历所有配置的模板(包括 ``key='default'``),
缺失则创建、字段不一致则覆盖更新,并确保 birthday 兜底与人格初始化。
供 ``seed_default_data`` 在应用启动时调用。
"""
from app.services.character_templates import load_character_templates
configs = load_character_templates()
if not configs:
return []
results: list[Character] = []
for key, config in configs.items():
template = await character_repo.get_system_template_by_key(session, key)
if template is None:
template = await _create_template_from_config(session, config)
else:
await _sync_template_from_config(session, template, config)
await _ensure_default_birthday(session, template)
await ensure_character_personality(session, template)
results.append(template)
return results
async def _sync_template_from_config(
session: AsyncSession, template: Character, config: CharacterTemplateConfig
) -> None:
"""比对 DB 字段与 config,不一致则覆盖更新并写入审计事件。
Config 可显式定义 birthday(含有效值时优先),缺失或不合法时
由 ``_ensure_default_birthday`` 用 created_at 兜底。
"""
model_policy = dict(config.model_policy)
model_policy.setdefault("default_chat_model_hint", settings.default_model_hint)
profile = dict(config.profile)
sanitize_profile(profile)
changed = False
if template.name != config.name:
template.name = config.name
changed = True
if template.display_name != config.display_name:
template.display_name = config.display_name
changed = True
if template.language != config.language:
template.language = config.language
changed = True
if template.privacy_level != config.privacy_level:
template.privacy_level = config.privacy_level
changed = True
# profile_json 比对:比较时排除 birthday(config 可能显式设、也可能留空兜底),
# 写入时 config 有值优先,否则保留 DB 端已有值(含 _ensure_default_birthday 兜底值)。
current_profile = loads_dict(template.profile_json)
config_birthday = profile.pop("birthday", None)
existing_birthday = current_profile.pop("birthday", None)
if current_profile != profile:
if config_birthday:
profile["birthday"] = config_birthday
elif existing_birthday:
profile["birthday"] = existing_birthday
template.profile_json = dumps(profile)
changed = True
# model_policy_json 比对
current_policy = loads_dict(template.model_policy_json)
if current_policy != model_policy:
template.model_policy_json = dumps(model_policy)
changed = True
if changed:
await record_audit_event(
session,
action="character.template_resynced",
character_id=template.id,
actor="system",
decision="auto",
input_summary=f"模板 '{config.key}' 由 config 文件覆盖更新",
resource_type="character_template",
resource_id=template.id,
)
async def create_character_from_template(
session: AsyncSession, template: Character, owner_scope: str
) -> Character:
# 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留,
# 由 _ensure_default_birthday 写入子角色的 profile 与 time_anniversaries 表。
profile = loads_dict(template.profile_json)
birthday = profile.get("birthday")
if not (isinstance(birthday, str) and birthday.strip()):
profile.pop("birthday", None)
character = Character(
name=template.name,
display_name=template.display_name,
status="active",
language=template.language,
profile_json=dumps(profile),
model_policy_json=template.model_policy_json,
safety_policy_id=template.safety_policy_id,
privacy_level=template.privacy_level,
owner_scope=owner_scope,
is_system_template=False,
source_template_id=template.id,
template_key=None,
)
session.add(character)
await session.flush()
await _ensure_default_birthday(session, character)
return character
async def ensure_default_character_for_owner(session: AsyncSession, owner_scope: str) -> Character:
"""确保当前 owner 有默认模板对应的用户人物。
只处理 ``character_templates.yaml`` 中标记 ``is_default: true`` 的模板。
不存在任何默认模板时回退到 ``key='default'``。
"""
from app.services.character_templates import load_character_templates
configs = load_character_templates()
if not configs:
template = await ensure_default_system_template(session)
character = await create_character_from_template(session, template, owner_scope)
await _ensure_character_personality_from_template(session, template, character)
return character
# 先确保所有系统模板在 DB 中存在
templates: dict[str, Character] = {}
for key in configs:
tpl = await character_repo.get_system_template_by_key(session, key)
if tpl is None:
config = configs[key]
tpl = await _create_template_from_config(session, config)
await _ensure_default_birthday(session, tpl)
await ensure_character_personality(session, tpl)
templates[key] = tpl
# 筛选 is_default=true 的配置 key
default_keys = [k for k, c in configs.items() if c.is_default]
if not default_keys:
default_keys = ["default"] if "default" in configs else []
primary: Character | None = None
for key in default_keys:
tpl = templates.get(key)
if tpl is None:
continue
existing = await character_repo.get_character_by_source_template(
session, tpl.id, owner_scope
)
if existing is not None:
await ensure_character_personality(session, existing)
if primary is None:
primary = existing
continue
character = await create_character_from_template(session, tpl, owner_scope)
from app.services.personality import copy_personality_to_character
template_personality = await ensure_character_personality(session, tpl)
personality = await copy_personality_to_character(
session, template_personality, character.id
)
character.current_personality_version_id = personality.id
await session.flush()
await record_audit_event(
session,
action="character.created_from_template",
character_id=character.id,
actor="system",
decision="auto",
input_summary=f"启动时从模板 '{key}' 自动创建用户人物",
resource_type="character",
resource_id=character.id,
)
if primary is None:
primary = character
# 兜底:没有找到主人物时从 default 模板创建
if primary is None:
template = await ensure_default_system_template(session)
primary = await create_character_from_template(session, template, owner_scope)
template_personality = await ensure_character_personality(session, template)
from app.services.personality import copy_personality_to_character
personality = await copy_personality_to_character(session, template_personality, primary.id)
primary.current_personality_version_id = personality.id
await session.flush()
return primary
async def _ensure_character_personality_from_template(
session: AsyncSession,
template: Character,
character: Character,
) -> None:
"""从模板复制人格到目标人物。"""
from app.services.personality import copy_personality_to_character
template_personality = await ensure_character_personality(session, template)
personality = await copy_personality_to_character(session, template_personality, character.id)
character.current_personality_version_id = personality.id
await session.flush()
# —— 人物管理 API(api-spec §6)——
async def get_owned_character(
session: AsyncSession, character_id: str, owner_scope: str = settings.default_owner_scope
) -> Character:
"""按 ID 取当前归属范围下的可操作人物;系统模板与跨范围 ID 一律视为不存在。"""
character = await character_repo.get_character(session, character_id)
if character is None or character.is_system_template or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
async def _personality_version_number(session: AsyncSession, character: Character) -> int:
version_id = character.current_personality_version_id
if not version_id:
return 0
personality = await personality_repo.get_personality(session, version_id)
return personality.version if personality else 0
async def _audit_character(session: AsyncSession, character: Character, action: str) -> None:
await record_audit_event(
session,
action=action,
character_id=character.id,
actor="user",
decision="auto",
input_summary=character.name,
resource_type="character",
resource_id=character.id,
)
async def _ensure_default_birthday(session: AsyncSession, character: Character) -> None:
"""birthday 缺失时用 created_at 兜底:写 profile.birthday 并建一条生日纪念日。
- 仅在 profile.birthday 为空/缺失时触发默认兜底;用户或模板已设则不覆盖。
- 无论 birthday 是否已设,只要角色尚无 birthday 纪念日,就建一条。
- profile.birthday 与 TimeAnniversary.date 取 birthday 值(显式)或 created_at 的 ISO 日期。
- 须在 character flush 后调用(依赖 id 与 created_at);同一事务内随外层 commit 落库。
"""
profile = loads_dict(character.profile_json)
birthday = profile.get("birthday")
if isinstance(birthday, str) and birthday.strip():
# birthday 已设但可能无对应纪念日(如从模板继承),仅 ISO 格式生日补建
existing = await anniversary_repo.list_anniversaries(session, character.id)
if not any(a.kind == "birthday" for a in existing):
try:
datetime.strptime(birthday, "%Y-%m-%d")
except (ValueError, TypeError):
pass
else:
anniversary = TimeAnniversary(
character_id=character.id,
name=f"{character.display_name or character.name}·生辰",
calendar="solar",
date=birthday,
recurs_yearly=True,
kind="birthday",
subject_type="character_self",
source="auto",
)
await anniversary_repo.create_anniversary(session, anniversary)
return
birthday_iso = character.created_at.strftime("%Y-%m-%d")
profile["birthday"] = birthday_iso
character.profile_json = dumps(profile)
anniversary = TimeAnniversary(
character_id=character.id,
name=f"{character.display_name or character.name}·生辰",
calendar="solar",
date=birthday_iso,
recurs_yearly=True,
kind="birthday",
subject_type="character_self",
source="auto",
)
await anniversary_repo.create_anniversary(session, anniversary)
async def create_character(
session: AsyncSession,
payload: CreateCharacterRequest,
owner_scope: str = settings.default_owner_scope,
) -> CharacterResponse:
"""API 创建人物。
- 传 ``template_key`` 时从系统模板复制(继承 profile/model_policy,status=active);
- 不传时创建空白人物(status=wuji,不激活、不建人格)。
"""
if payload.template_key:
template = await character_repo.get_system_template_by_key(session, payload.template_key)
if template is None:
raise AppError("not_found", f"模板 '{payload.template_key}' 不存在。")
profile = loads_dict(template.profile_json)
profile.update(dict(payload.profile or {}))
model_policy = loads_dict(template.model_policy_json)
model_policy.update(dict(payload.model_policy or {}))
model_policy.setdefault("default_chat_model_hint", settings.default_model_hint)
character = Character(
name=payload.name,
display_name=payload.display_name or template.display_name,
status="active",
language=template.language,
profile_json=dumps(profile),
model_policy_json=dumps(model_policy),
privacy_level=template.privacy_level,
owner_scope=owner_scope,
is_system_template=False,
source_template_id=template.id,
template_key=None,
)
await character_repo.add_character(session, character)
await _ensure_default_birthday(session, character)
await ensure_character_personality(session, character)
await _audit_character(session, character, "character.created_from_template")
await session.commit()
await session.refresh(character)
return character_to_response(character)
# ── 空白创建(原有逻辑)──
model_policy = MODEL_POLICY_CREATE_DEFAULTS | dict(payload.model_policy or {})
model_policy["default_chat_model_hint"] = (
model_policy.get("default_chat_model_hint") or settings.default_model_hint
)
character = Character(
name=payload.name,
display_name=payload.display_name,
status="wuji",
language="zh-CN",
profile_json=dumps(dict(payload.profile or {})),
model_policy_json=dumps(model_policy),
privacy_level=model_policy.get("privacy_level", "normal"),
owner_scope=owner_scope,
is_system_template=False,
source_template_id=None,
template_key=None,
)
await character_repo.add_character(session, character)
await _ensure_default_birthday(session, character)
await _audit_character(session, character, "character.created")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def list_characters_page(
*,
owner_scope: str = settings.default_owner_scope,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[CharacterSummaryResponse]:
async with AsyncSessionLocal() as session:
safe_page = max(page, 1)
safe_page_size = min(max(page_size, 1), 100)
items, total = await character_repo.list_characters(
session,
owner_scope,
status=status,
page=safe_page,
page_size=safe_page_size,
)
summaries = [
character_to_summary(
character,
memory_count=await character_repo.count_memories(session, character.id),
personality_version=await _personality_version_number(session, character),
)
for character in items
]
return Page(
items=summaries,
page=safe_page,
page_size=safe_page_size,
total=total,
)
async def get_character_by_id(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
return character_to_response(await get_owned_character(session, character_id, owner_scope))
async def update_character_profile(
character_id: str,
payload: UpdateProfileRequest,
owner_scope: str = settings.default_owner_scope,
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
profile = loads_dict(character.profile_json)
for key, value in payload.model_dump(exclude_unset=True).items():
if key == "display_name":
if value is not None:
character.display_name = value
continue
if value is not None:
profile[key] = value
character.profile_json = dumps(profile)
await _audit_character(session, character, "character.profile_updated")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def update_character_model_policy(
character_id: str,
payload: UpdateModelPolicyRequest,
owner_scope: str = settings.default_owner_scope,
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
policy = loads_dict(character.model_policy_json)
for key, value in payload.model_dump(exclude_unset=True).items():
if value is None:
continue
if key == "privacy_level":
character.privacy_level = value
policy[key] = value
character.model_policy_json = dumps(policy)
await _audit_character(session, character, "character.model_policy_updated")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def _deactivate_others(
session: AsyncSession, character: Character, owner_scope: str = settings.default_owner_scope
) -> None:
"""A1 不变量:一个 owner_scope 至多一个 active;激活时把其余改 paused。"""
others = await character_repo.list_active_in_scope(session, owner_scope, except_id=character.id)
for other in others:
other.status = "paused"
async def activate_character(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> ActivateCharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
if character.status == "archived":
raise AppError("conflict", "已归档人物不可直接激活,请先恢复。")
await _deactivate_others(session, character, owner_scope)
character.status = "active"
character.activated_at = utc_now()
await ensure_character_personality(session, character)
await _audit_character(session, character, "character.activated")
await session.commit()
return ActivateCharacterResponse(active_character_id=character.id, status="active")
async def pause_character(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
if character.status not in ("active", "initialized"):
raise AppError("conflict", "仅运行中人物可暂停。")
character.status = "paused"
await _audit_character(session, character, "character.paused")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def resume_character(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
if character.status != "paused":
raise AppError("conflict", "仅暂停状态的人物可恢复。")
await _deactivate_others(session, character, owner_scope)
character.status = "active"
character.activated_at = utc_now()
await ensure_character_personality(session, character)
await _audit_character(session, character, "character.resumed")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def archive_character(
character_id: str, owner_scope: str = settings.default_owner_scope
) -> CharacterResponse:
async with AsyncSessionLocal() as session:
character = await get_owned_character(session, character_id, owner_scope)
if character.status == "archived":
raise AppError("conflict", "人物已归档。")
character.status = "archived"
character.archived_at = utc_now()
await _audit_character(session, character, "character.archived")
await session.commit()
await session.refresh(character)
return character_to_response(character)
async def list_system_templates_page() -> list[CharacterTemplateSummaryResponse]:
"""列出所有系统模板(供前端模板选择器使用)。"""
async with AsyncSessionLocal() as session:
templates = await character_repo.list_system_templates(session)
result: list[CharacterTemplateSummaryResponse] = []
for t in templates:
profile = loads_dict(t.profile_json)
result.append(
CharacterTemplateSummaryResponse(
key=t.template_key or "",
name=t.name,
display_name=t.display_name,
description=profile.get("description"),
temperament=profile.get("temperament"),
speaking_style=profile.get("speaking_style"),
language=t.language,
)
)
return result