433 lines
17 KiB
Python
433 lines
17 KiB
Python
"""人格服务。"""
|
||
|
||
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)
|