- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位 - 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起) - 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数 - 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离 - 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换 - 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库 - 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id - 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继 - 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
584 lines
22 KiB
Python
584 lines
22 KiB
Python
"""人物服务。"""
|
||
|
||
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.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.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_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)
|
||
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)
|
||
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)
|
||
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)
|
||
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:
|
||
if primary is None:
|
||
primary = existing
|
||
continue
|
||
|
||
character = await create_character_from_template(session, tpl, owner_scope)
|
||
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)
|
||
await session.flush()
|
||
|
||
return primary
|
||
|
||
|
||
|
||
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
|
||
|
||
|
||
def _personality_version_number(character: Character) -> int:
|
||
"""人格版本随旧版本表退役——版本语义由魂籍(identity_record)承载,此处恒 0 占位。"""
|
||
_ = character
|
||
return 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 _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=_personality_version_number(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 _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 _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
|