Files
hunjing/backend/app/services/personality.py
T
2026-08-21 16:00:13 +08:00

433 lines
17 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 __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)