初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+678
View File
@@ -0,0 +1,678 @@
"""人物服务。"""
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