初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+432
View File
@@ -0,0 +1,432 @@
"""人格服务。"""
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)