"""人格服务。""" from __future__ import annotations from typing import Any from pydantic import BaseModel 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 PersonalityProposal, PersonalityVersion from app.repositories import personality as personality_repo from app.repositories.json_utils import dumps, loads_dict from app.schemas.common import Page from app.schemas.personality import ( PersonalityProposalFields, PersonalityProposalResponse, PersonalityResponse, ) from app.services.audit import record_audit_event from app.services.characters import get_active_character_model from app.services.serializers import personality_to_response, proposal_to_response async def get_current_personality( owner_scope: str = settings.default_owner_scope, ) -> PersonalityResponse: async with AsyncSessionLocal() as session: character = await get_active_character_model(session, owner_scope) 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: raise AppError("not_found", "未找到当前人格版本。") return personality_to_response(personality) async def list_personality_versions( *, character_id: str | None = None, page: int = 1, page_size: int = 20, owner_scope: str = settings.default_owner_scope, ) -> Page[PersonalityResponse]: """人格版本历史(按版本号倒序);character_id 省略时取当前激活人物。""" async with AsyncSessionLocal() as session: cid = character_id if cid is None: character = await get_active_character_model(session, owner_scope) cid = character.id items, total = await personality_repo.list_personality_versions( session, cid, page=page, page_size=page_size ) return Page( items=[personality_to_response(item) for item in items], page=page, page_size=page_size, total=total, ) async def list_personality_proposals( *, character_id: str | None = None, status: str | None = None, page: int = 1, page_size: int = 20, owner_scope: str = settings.default_owner_scope, ) -> Page[PersonalityProposalResponse]: """人格修改建议列表(按创建时间倒序);character_id 省略取当前激活人物。待审审批墙数据源。""" async with AsyncSessionLocal() as session: cid = character_id if cid is None: cid = (await get_active_character_model(session, owner_scope)).id items, total = await personality_repo.list_proposals( session, character_id=cid, status=status, page=page, page_size=page_size ) return Page( items=[proposal_to_response(item) for item in items], page=page, page_size=page_size, total=total, ) class PersonalitySummary(BaseModel): """人格摘要(注入 RuntimeContract 上下文)。""" text: str | None = None async def build_personality_summary( session: AsyncSession, character: Character, ) -> PersonalitySummary: """组装当前人格摘要文本;无人格时返回空。""" 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: return PersonalitySummary() text = "\n".join( item for item in [ f"身份:{personality.identity_profile}", f"角色使命:{personality.role_destiny}", f"性格种子:{personality.personality_seed}", f"表达风格:{personality.expression_style}", f"关系模式:{personality.relationship_pattern}", ] if item and not item.endswith(":") ) return PersonalitySummary(text=text) async def create_default_personality( session: AsyncSession, character_id: str ) -> PersonalityVersion: """创建通用默认人格版本(无 profile 数据时的兜底)。""" personality = PersonalityVersion( character_id=character_id, version=1, identity_profile="魂镜是一个本地运行的数字生命雏形,当前专注于文本对话、记忆和自我描述。", role_destiny="作为用户的长期陪伴与认知镜像,先从稳定、可信、可审计的对话开始。", personality_seed="温和、诚实、克制、重视隐私与事实边界。", traits=dumps({"warmth": 0.8, "curiosity": 0.6, "caution": 0.7}), expression_style="使用中文,表达清晰,避免夸大能力。", values=dumps({"privacy": "high", "truthfulness": "high", "companionship": "medium"}), relationship_pattern="尊重用户边界,以可解释和可撤回的方式建立长期记忆。", emotion_baseline=dumps({"mood": "neutral", "intensity": 0.2}), reason="system_seed", evidence_ids_json=dumps([]), created_by="system", ) session.add(personality) await session.flush() return personality async def create_personality_from_profile( session: AsyncSession, character: Character ) -> PersonalityVersion: """从人物档案(profile_json)生成个性化人格版本;profile 为空时回退到默认人格。 映射规则(profile 字段 → PersonalityVersion 字段): - description + background → identity_profile - speaking_style → expression_style - temperament → personality_seed - values → values - hobbies → 参与关系模式描述的构建 """ profile = loads_dict(character.profile_json) if character.profile_json else {} if not profile: return await create_default_personality(session, character.id) # 身份画像:description + background description = profile.get("description", "") background = profile.get("background", "") identity_parts = [p for p in [description, background] if p] identity_profile = ( identity_parts[0] if len(identity_parts) == 1 else ("。".join(identity_parts) if identity_parts else "魂镜默认人物。") ) # 表达风格 expression_style = profile.get("speaking_style", "使用中文,表达清晰。") # 性格种子 personality_seed = profile.get("temperament", "温和、诚实、克制。") # 价值偏好 values_list = profile.get("values", []) values_dict: dict[str, Any] = ( {"values": list(values_list)} if values_list else {"privacy": "high", "truthfulness": "high", "companionship": "medium"} ) # 关系模式:若有兴趣爱好则融入描述 hobbies = profile.get("hobbies", []) if hobbies and isinstance(hobbies, list) and len(hobbies) > 0: hobby_text = "、".join(str(h) for h in hobbies[:3]) relationship_pattern = f"乐于分享关于{hobby_text}等的见解,以开放与尊重建立对话。" else: relationship_pattern = "以开放与尊重建立关系。" # 角色使命 role_destiny = f"{description},陪伴用户探索与成长。" if description else "陪伴用户探索与成长。" personality = PersonalityVersion( character_id=character.id, version=1, identity_profile=identity_profile, role_destiny=role_destiny, personality_seed=personality_seed, traits=dumps({"warmth": 0.8, "curiosity": 0.6, "caution": 0.7}), expression_style=expression_style, values=dumps(values_dict), relationship_pattern=relationship_pattern, emotion_baseline=dumps({"mood": "neutral", "intensity": 0.2}), reason="system_seed", evidence_ids_json=dumps([]), created_by="system", ) session.add(personality) await session.flush() return personality async def copy_personality_to_character( session: AsyncSession, template_personality: PersonalityVersion, target_character_id: str, ) -> PersonalityVersion: personality = PersonalityVersion( character_id=target_character_id, version=1, identity_profile=template_personality.identity_profile, role_destiny=template_personality.role_destiny, personality_seed=template_personality.personality_seed, traits=template_personality.traits, expression_style=template_personality.expression_style, values=template_personality.values, relationship_pattern=template_personality.relationship_pattern, emotion_baseline=template_personality.emotion_baseline, reason="system_template_copy", evidence_ids_json=template_personality.evidence_ids_json, created_by="system", ) session.add(personality) await session.flush() return personality # —— 人格修改建议与版本回滚(api-spec §11) —— # # 本地单用户边界下「手动编辑视为用户确认」(§5.1),但人格属核心资源(L4), # 仍走「提议 pending → 审核」两步:propose 落 pending 建议,approve 派生新版本, # reject 仅留痕;rollback 切换当前指针。所有写操作记审计并入起居注。 async def propose_personality_update( *, character_id: str | None, fields: PersonalityProposalFields, reason: str, owner_scope: str = settings.default_owner_scope, ) -> PersonalityProposalResponse: """提交人格修改建议;落 pending 提议并记审计,不直接写入人格版本。""" if not fields.has_any(): raise AppError("invalid_request", "fields 至少需要覆盖一个人格字段。") async with AsyncSessionLocal() as session: cid = character_id if cid is None: cid = (await get_active_character_model(session, owner_scope)).id proposal = PersonalityProposal( character_id=cid, fields_json=dumps(fields.model_dump(exclude_none=True)), reason=reason, status="pending", ) await personality_repo.add_proposal(session, proposal) await record_audit_event( session, action="personality.propose", actor="user", risk_level="L4", character_id=cid, input_summary=reason, resource_type="personality_proposal", resource_id=proposal.id, ) await session.commit() await session.refresh(proposal) return proposal_to_response(proposal) async def approve_personality_proposal( proposal_id: str, owner_scope: str = settings.default_owner_scope, # noqa: ARG001 ) -> PersonalityProposalResponse: """审核通过人格建议:基于当前最新版本派生新版本并切换人物当前指针。""" async with AsyncSessionLocal() as session: proposal = await personality_repo.get_proposal(session, proposal_id) if proposal is None: raise AppError("not_found", "人格建议不存在。") if proposal.status != "pending": raise AppError("conflict", f"人格建议当前状态为 {proposal.status},不可审核。") base = await personality_repo.get_latest_personality(session, proposal.character_id) if base is None: raise AppError("invalid_request", "人物尚无人格版本,无法应用建议。") new_version = derive_personality_version( base, loads_dict(proposal.fields_json), reason=f"proposal:{proposal.id}" ) session.add(new_version) await session.flush() character = await session.get(Character, proposal.character_id) if character is None: raise AppError("not_found", "人物不存在。") character.current_personality_version_id = new_version.id proposal.status = "applied" proposal.resulting_version_id = new_version.id proposal.reviewed_at = utc_now() proposal.reviewed_by = "user" await record_audit_event( session, action="personality.proposal.approved", actor="user", risk_level="L4", decision="approved", character_id=proposal.character_id, input_summary=proposal.reason, output_summary=f"已生成版本 v{new_version.version}", resource_type="personality_proposal", resource_id=proposal.id, ) await session.commit() await session.refresh(proposal) return proposal_to_response(proposal) async def reject_personality_proposal( proposal_id: str, owner_scope: str = settings.default_owner_scope, # noqa: ARG001 ) -> PersonalityProposalResponse: """驳回人格建议;标记 rejected 并记审计,不生成新版本。""" async with AsyncSessionLocal() as session: proposal = await personality_repo.get_proposal(session, proposal_id) if proposal is None: raise AppError("not_found", "人格建议不存在。") if proposal.status != "pending": raise AppError("conflict", f"人格建议当前状态为 {proposal.status},不可审核。") proposal.status = "rejected" proposal.reviewed_at = utc_now() proposal.reviewed_by = "user" await record_audit_event( session, action="personality.proposal.rejected", actor="user", risk_level="L4", decision="denied", character_id=proposal.character_id, input_summary=proposal.reason, resource_type="personality_proposal", resource_id=proposal.id, ) await session.commit() await session.refresh(proposal) return proposal_to_response(proposal) async def rollback_personality( *, character_id: str | None, version_id: str, owner_scope: str = settings.default_owner_scope, ) -> PersonalityResponse: """回滚到指定人格版本:切换人物当前指针,标记被取代的旧版本 rolled_back_at。""" async with AsyncSessionLocal() as session: cid = character_id if cid is None: cid = (await get_active_character_model(session, owner_scope)).id target = await personality_repo.get_personality(session, version_id) if target is None or target.character_id != cid: raise AppError("not_found", "人格版本不存在或不属于当前人物。") character = await session.get(Character, cid) if character is None: raise AppError("not_found", "人物不存在。") previous_id = character.current_personality_version_id character.current_personality_version_id = target.id if previous_id and previous_id != target.id: previous = await personality_repo.get_personality(session, previous_id) if previous is not None: previous.rolled_back_at = utc_now() await record_audit_event( session, action="personality.rollback", actor="user", risk_level="L4", decision="approved", character_id=cid, input_summary=f"{previous_id} → {target.id}", resource_type="personality_version", resource_id=target.id, ) await session.commit() return personality_to_response(target) def derive_personality_version( base: PersonalityVersion, overrides: dict[str, Any], *, reason: str, created_by: str = "user", ) -> PersonalityVersion: """基于 base 版本与字段覆盖派生新版本;JSON 字段按 dict 序列化落库。 公开供人格提议审核与大周天演化派生复用(见 services.evolution)。 """ kwargs: dict[str, Any] = { "character_id": base.character_id, "version": base.version + 1, "reason": reason, "evidence_ids_json": base.evidence_ids_json, "created_by": created_by, } for field in ( "identity_profile", "role_destiny", "personality_seed", "expression_style", "relationship_pattern", ): kwargs[field] = overrides.get(field, getattr(base, field)) for field in ("traits", "values", "emotion_baseline"): kwargs[field] = dumps(overrides[field]) if field in overrides else getattr(base, field) return PersonalityVersion(**kwargs)