"""人物服务。""" 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.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.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。 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。 纪念日表(time_anniversaries)随旧时间模型退役删除;辰正(M8 重实现)落地后 生日提醒改由时态感知按 profile.birthday 计算,不再预写行。 """ profile = loads_dict(character.profile_json) birthday = profile.get("birthday") if isinstance(birthday, str) and birthday.strip(): return profile["birthday"] = character.created_at.strftime("%Y-%m-%d") character.profile_json = dumps(profile) 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