初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user