Files
hunjing/backend/app/services/characters.py
T
chenxin 7e23d6fa84 M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流
- 缘契域 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 干净
2026-08-27 20:10:11 +08:00

584 lines
22 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.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