M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流
- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位 - 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起) - 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数 - 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离 - 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换 - 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库 - 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id - 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继 - 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
This commit is contained in:
1 parent
684261f4b4
commit
7e23d6fa84
108 files changed
+2600
-9732
No files matched your search
@@ -8,7 +8,7 @@ from app.api.v1.endpoints import (
|
||||
avatar,
|
||||
character_import,
|
||||
characters,
|
||||
drive,
|
||||
|
||||
events,
|
||||
goals,
|
||||
health,
|
||||
@@ -19,15 +19,18 @@ from app.api.v1.endpoints import (
|
||||
lorebooks,
|
||||
me,
|
||||
baigong,
|
||||
hetu,
|
||||
soul,
|
||||
yuanqi,
|
||||
mirror_trees,
|
||||
mcp,
|
||||
personality,
|
||||
|
||||
runs,
|
||||
safety,
|
||||
sandbox_files,
|
||||
settings,
|
||||
tools,
|
||||
world,
|
||||
|
||||
xirang,
|
||||
)
|
||||
|
||||
@@ -39,6 +42,9 @@ api_router.include_router(character_import.router)
|
||||
api_router.include_router(runs.router)
|
||||
api_router.include_router(kernel_runs.router)
|
||||
api_router.include_router(me.router)
|
||||
api_router.include_router(hetu.world_router)
|
||||
api_router.include_router(soul.soul_router)
|
||||
api_router.include_router(yuanqi.cov_router)
|
||||
for _baigong_r in baigong.routers:
|
||||
api_router.include_router(_baigong_r)
|
||||
api_router.include_router(embryo_drafts.router)
|
||||
@@ -47,15 +53,12 @@ api_router.include_router(mirror_trees.router)
|
||||
api_router.include_router(xirang.router)
|
||||
api_router.include_router(mirror_trees.node_router)
|
||||
api_router.include_router(lorebooks.router)
|
||||
api_router.include_router(personality.router)
|
||||
api_router.include_router(tools.router)
|
||||
api_router.include_router(mcp.router)
|
||||
api_router.include_router(safety.router)
|
||||
api_router.include_router(sandbox_files.router)
|
||||
api_router.include_router(settings.router)
|
||||
api_router.include_router(world.router)
|
||||
api_router.include_router(imports.router)
|
||||
api_router.include_router(drive.router)
|
||||
api_router.include_router(events.router)
|
||||
api_router.include_router(goals.router)
|
||||
api_router.include_router(admin.router)
|
||||
|
||||
@@ -27,7 +27,6 @@ from app.schemas.admin import (
|
||||
CreateGoalRequest,
|
||||
CreatePerceptionChannelRequest,
|
||||
CreatePlanRequest,
|
||||
CreateWorldEntityRequest,
|
||||
GoalNode,
|
||||
PendingApproval,
|
||||
PerceptionChannelResponse,
|
||||
@@ -41,8 +40,6 @@ from app.schemas.admin import (
|
||||
UpdateGoalRequest,
|
||||
UpdatePerceptionChannelRequest,
|
||||
UpdatePlanRequest,
|
||||
UpdateWorldEntityRequest,
|
||||
WorldEntityResponse,
|
||||
)
|
||||
from app.schemas.admin_character import (
|
||||
AdminCreateTemplateRequest,
|
||||
@@ -163,34 +160,6 @@ async def delete_perception_channel(channel: str) -> None:
|
||||
await governance_service.delete_perception_channel(channel)
|
||||
|
||||
|
||||
# —— §19.4 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
@router.get("/world/entities", response_model=list[WorldEntityResponse])
|
||||
async def list_world_entities() -> list[WorldEntityResponse]:
|
||||
return await governance_service.list_world_entities()
|
||||
|
||||
|
||||
@router.post("/world/entities", response_model=WorldEntityResponse)
|
||||
async def create_world_entity(
|
||||
payload: CreateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
return await governance_service.create_world_entity(payload)
|
||||
|
||||
|
||||
@router.patch("/world/entities/{entity_id}", response_model=WorldEntityResponse)
|
||||
async def update_world_entity(
|
||||
entity_id: str,
|
||||
payload: UpdateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
return await governance_service.update_world_entity(entity_id, payload)
|
||||
|
||||
|
||||
@router.delete("/world/entities/{entity_id}", status_code=204)
|
||||
async def delete_world_entity(entity_id: str) -> None:
|
||||
await governance_service.delete_world_entity(entity_id)
|
||||
|
||||
|
||||
# —— §19.4 洛书枢 · 计划 ——
|
||||
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""内驱力 /drive API。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.drive import CuriosityResponse, SubgoalResponse
|
||||
from app.services.drive import (
|
||||
approve_subgoal,
|
||||
defer_subgoal,
|
||||
list_curiosities,
|
||||
list_subgoals,
|
||||
reject_subgoal,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["drive"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/curiosities",
|
||||
response_model=Page[CuriosityResponse],
|
||||
)
|
||||
async def list_curiosities_endpoint(
|
||||
character_id: str,
|
||||
source: str | None = Query(default=None, description="按来源过滤(契约字典内驱力 source)"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[CuriosityResponse]:
|
||||
"""列出某人物的好奇问题(大周天内驱力生成)。"""
|
||||
return await list_curiosities(
|
||||
character_id=character_id,
|
||||
source=source,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/subgoals",
|
||||
response_model=Page[SubgoalResponse],
|
||||
)
|
||||
async def list_subgoals_endpoint(
|
||||
character_id: str,
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:candidate/approved/deferred/rejected"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[SubgoalResponse]:
|
||||
"""列出某人物的探索性次生目标。"""
|
||||
return await list_subgoals(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/approve", response_model=SubgoalResponse)
|
||||
async def approve_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""通过探索性次生目标(仅标记 approved,本期不自动执行)。"""
|
||||
return await approve_subgoal(subgoal_id)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/defer", response_model=SubgoalResponse)
|
||||
async def defer_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""延后探索性次生目标(status → deferred)。"""
|
||||
return await defer_subgoal(subgoal_id)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/reject", response_model=SubgoalResponse)
|
||||
async def reject_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""驳回探索性次生目标(status → rejected)。"""
|
||||
return await reject_subgoal(subgoal_id)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""河图世界路由(域实现见 app/domains/hetu/api.py)。"""
|
||||
|
||||
from app.domains.hetu.api import world_router
|
||||
|
||||
__all__ = ["world_router"]
|
||||
@@ -1,122 +0,0 @@
|
||||
"""人格 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.personality import (
|
||||
PersonalityProposalResponse,
|
||||
PersonalityResponse,
|
||||
ProposePersonalityUpdateRequest,
|
||||
)
|
||||
from app.services.personality import (
|
||||
approve_personality_proposal,
|
||||
get_current_personality,
|
||||
list_personality_proposals,
|
||||
list_personality_versions,
|
||||
propose_personality_update,
|
||||
reject_personality_proposal,
|
||||
rollback_personality,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/personality", tags=["personality"])
|
||||
|
||||
|
||||
@router.get("/current", response_model=PersonalityResponse)
|
||||
async def current_personality(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityResponse:
|
||||
return await get_current_personality(owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/versions", response_model=Page[PersonalityResponse])
|
||||
async def personality_versions(
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[PersonalityResponse]:
|
||||
return await list_personality_versions(
|
||||
character_id=character_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/proposals", response_model=Page[PersonalityProposalResponse])
|
||||
async def personality_proposals(
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:pending / approved / rejected / applied"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[PersonalityProposalResponse]:
|
||||
"""人格修改建议列表(待审审批墙数据源)。"""
|
||||
return await list_personality_proposals(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
# —— 人格修改建议与版本回滚(api-spec §11) ——
|
||||
|
||||
|
||||
@router.post(
|
||||
"/propose-update",
|
||||
response_model=PersonalityProposalResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def propose_personality_update_endpoint(
|
||||
payload: ProposePersonalityUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""提交人格修改建议;进入 pending 待审核,不直接写入人格版本。"""
|
||||
return await propose_personality_update(
|
||||
character_id=payload.character_id,
|
||||
fields=payload.fields,
|
||||
reason=payload.reason,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/approve",
|
||||
response_model=PersonalityProposalResponse,
|
||||
)
|
||||
async def approve_proposal(
|
||||
proposal_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""审核通过人格建议:派生新人格版本并切换人物当前指针。"""
|
||||
return await approve_personality_proposal(proposal_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/reject",
|
||||
response_model=PersonalityProposalResponse,
|
||||
)
|
||||
async def reject_proposal(
|
||||
proposal_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""驳回人格建议:标记 rejected,不生成新版本。"""
|
||||
return await reject_personality_proposal(proposal_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/rollback", response_model=PersonalityResponse)
|
||||
async def rollback_personality_version(
|
||||
version_id: str,
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityResponse:
|
||||
"""回滚到指定人格版本;被取代的旧版本标记 rolled_back_at。"""
|
||||
return await rollback_personality(
|
||||
character_id=character_id, version_id=version_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""道体铸魂与分灵 API(/characters/{id}/soul)。"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.domains.jingsheng import soul
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
soul_router = APIRouter(prefix="/characters/{character_id}/soul", tags=["jingsheng"])
|
||||
|
||||
|
||||
class ForgingRequest(BaseModel):
|
||||
anchor_changes: dict = Field(description="道体锚点变更(键:identity_baseline_json)")
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class SplitRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=2000)
|
||||
initial_anchor: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@soul_router.get("/forgings")
|
||||
async def list_forgings(
|
||||
character_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> list[dict]:
|
||||
return await soul.list_forgings(character_id, user.owner_scope)
|
||||
|
||||
|
||||
@soul_router.post("/forgings", status_code=201)
|
||||
async def propose_forging(
|
||||
character_id: str, payload: ForgingRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await soul.propose_forging(
|
||||
character_id=character_id,
|
||||
owner_scope=user.owner_scope,
|
||||
anchor_changes=payload.anchor_changes,
|
||||
reason=payload.reason,
|
||||
)
|
||||
|
||||
|
||||
@soul_router.post("/forgings/{forging_id}/confirm")
|
||||
async def confirm_forging(
|
||||
character_id: str, forging_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await soul.confirm_forging(forging_id, user.owner_scope)
|
||||
|
||||
|
||||
@soul_router.post("/forgings/{forging_id}/reject")
|
||||
async def reject_forging(
|
||||
character_id: str, forging_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await soul.reject_forging(forging_id, user.owner_scope)
|
||||
|
||||
|
||||
@soul_router.post("/split", status_code=201)
|
||||
async def split_soul(
|
||||
character_id: str, payload: SplitRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await soul.split_soul(
|
||||
character_id=character_id,
|
||||
owner_scope=user.owner_scope,
|
||||
reason=payload.reason,
|
||||
initial_anchor=payload.initial_anchor,
|
||||
)
|
||||
|
||||
|
||||
@soul_router.get("/lineage")
|
||||
async def get_lineage(
|
||||
character_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> list[dict]:
|
||||
return await soul.get_lineage(character_id, user.owner_scope)
|
||||
@@ -1,95 +0,0 @@
|
||||
"""世界模型 API(河图枢运行时,api-spec §12)。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.world import (
|
||||
WorldEntityCreateRequest,
|
||||
WorldEntityResponse,
|
||||
WorldGraphResponse,
|
||||
WorldRelationCreateRequest,
|
||||
WorldRelationResponse,
|
||||
)
|
||||
from app.services.world import (
|
||||
create_entity,
|
||||
create_relation,
|
||||
delete_relation,
|
||||
get_entity_by_id,
|
||||
get_graph,
|
||||
list_entities_page,
|
||||
list_relations_page,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/world", tags=["world"])
|
||||
|
||||
|
||||
@router.get("/entities", response_model=Page[WorldEntityResponse])
|
||||
async def list_entities(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
type: str | None = Query(default=None, description="按实体类型过滤(契约字典 §11.1)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldEntityResponse]:
|
||||
return await list_entities_page(
|
||||
character_id,
|
||||
entity_type=type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entities", response_model=WorldEntityResponse)
|
||||
async def create_entity_api(
|
||||
payload: WorldEntityCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldEntityResponse:
|
||||
return await create_entity(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}", response_model=WorldEntityResponse)
|
||||
async def get_entity(
|
||||
entity_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldEntityResponse:
|
||||
return await get_entity_by_id(entity_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/relations", response_model=Page[WorldRelationResponse])
|
||||
async def list_relations(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldRelationResponse]:
|
||||
return await list_relations_page(
|
||||
character_id, page=page, page_size=page_size, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post("/relations", response_model=WorldRelationResponse)
|
||||
async def create_relation_api(
|
||||
payload: WorldRelationCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldRelationResponse:
|
||||
return await create_relation(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/relations/{relation_id}", status_code=204)
|
||||
async def delete_relation_api(
|
||||
relation_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
await delete_relation(relation_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/graph", response_model=WorldGraphResponse)
|
||||
async def world_graph(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
) -> WorldGraphResponse:
|
||||
"""返回人物的完整世界图谱(无分页),供前端 D3/Force 图谱渲染。"""
|
||||
return await get_graph(character_id, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""缘契路由(域实现见 app/domains/yuanqi/api.py)。"""
|
||||
|
||||
from app.domains.yuanqi.api import cov_router
|
||||
|
||||
__all__ = ["cov_router"]
|
||||
@@ -14,7 +14,6 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.repositories.json_utils import dumps
|
||||
|
||||
@@ -140,59 +139,6 @@ _PERCEPTION = [
|
||||
),
|
||||
]
|
||||
|
||||
# —— 河图枢 · 世界实体 ——
|
||||
_WORLD = [
|
||||
(
|
||||
"world_entity_self_jingwu",
|
||||
"self",
|
||||
"镜梧",
|
||||
{"命格": "映照者", "情绪": "沉静", "铜镜": "完整"},
|
||||
"fact",
|
||||
1.0,
|
||||
["identity_memory", "personality_v3"],
|
||||
"2026-06-19T11:45:00Z",
|
||||
),
|
||||
(
|
||||
"world_entity_person_sanren",
|
||||
"person",
|
||||
"散人",
|
||||
{"称呼": "散人", "行当": "夜班看护", "节奏": "宜缓不宜急"},
|
||||
"fact",
|
||||
0.9,
|
||||
["conversation", "memory_user"],
|
||||
"2026-06-19T22:15:00Z",
|
||||
),
|
||||
(
|
||||
"world_entity_place_mirror_lake",
|
||||
"place",
|
||||
"镜湖",
|
||||
{"位置": "现世与心象之交", "特性": "映照凝视者心绪"},
|
||||
"fact",
|
||||
0.92,
|
||||
["material_import"],
|
||||
"2026-06-11T09:20:00Z",
|
||||
),
|
||||
(
|
||||
"world_entity_object_bronze_mirror",
|
||||
"object",
|
||||
"铜镜",
|
||||
{"状态": "完整", "契约": "镜在则念在"},
|
||||
"observation",
|
||||
0.95,
|
||||
["identity_memory"],
|
||||
"2026-05-01T08:30:00Z",
|
||||
),
|
||||
(
|
||||
"world_entity_concept_forgetting",
|
||||
"concept",
|
||||
"遗忘之结",
|
||||
{"触发": "深夜", "强度": "反复触碰"},
|
||||
"inference",
|
||||
0.74,
|
||||
["conversation", "episodic_memory"],
|
||||
"2026-06-20T08:00:00Z",
|
||||
),
|
||||
]
|
||||
|
||||
# —— 洛书枢 · 计划 ——
|
||||
_PLANS = [
|
||||
@@ -371,22 +317,6 @@ async def ensure_governance_seed(session: AsyncSession) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
for entity_id, etype, name, attrs, state, conf, sources, updated_iso in _WORLD:
|
||||
if await session.get(WorldEntity, entity_id) is None:
|
||||
updated = _dt(updated_iso)
|
||||
session.add(
|
||||
WorldEntity(
|
||||
id=entity_id,
|
||||
type=etype,
|
||||
name=name,
|
||||
attributes_json=dumps(attrs),
|
||||
state_type=state,
|
||||
confidence=conf,
|
||||
sources_json=dumps(sources),
|
||||
created_at=updated,
|
||||
updated_at=updated,
|
||||
)
|
||||
)
|
||||
|
||||
for item in _PLANS:
|
||||
if await session.get(Plan, item["id"]) is None:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""河图世界域:境界(persistent_worlds)、世界实体与关系、境界正史(M6)。"""
|
||||
|
||||
from app.domains.hetu import models, service # noqa: F401
|
||||
|
||||
__all__ = ["models", "service"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""河图世界 API(/worlds):境界 CRUD、实体关系、正史视图。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.domains.hetu import service
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
world_router = APIRouter(prefix="/worlds", tags=["hetu"])
|
||||
|
||||
|
||||
class CreateWorldRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str = ""
|
||||
rules: dict = Field(default_factory=dict)
|
||||
lorebook_id: str | None = None
|
||||
|
||||
|
||||
class UpdateWorldRequest(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
rules: dict | None = None
|
||||
lorebook_id: str | None = None
|
||||
|
||||
|
||||
class EntityRequest(BaseModel):
|
||||
kind: str = "concept"
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str = ""
|
||||
attrs: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RelationRequest(BaseModel):
|
||||
source_entity_id: str
|
||||
target_entity_id: str
|
||||
relation: str = Field(min_length=1, max_length=80)
|
||||
note: str = ""
|
||||
|
||||
|
||||
@world_router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_world(
|
||||
payload: CreateWorldRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.create_world(
|
||||
owner_scope=user.owner_scope, name=payload.name, description=payload.description,
|
||||
rules=payload.rules, lorebook_id=payload.lorebook_id,
|
||||
)
|
||||
|
||||
|
||||
@world_router.get("")
|
||||
async def list_worlds(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
return await service.list_worlds(user.owner_scope)
|
||||
|
||||
|
||||
@world_router.get("/{world_id}")
|
||||
async def get_world(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
|
||||
return await service.get_world(world_id, user.owner_scope)
|
||||
|
||||
|
||||
@world_router.patch("/{world_id}")
|
||||
async def update_world(
|
||||
world_id: str, payload: UpdateWorldRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.update_world(
|
||||
world_id, user.owner_scope, name=payload.name, description=payload.description,
|
||||
rules=payload.rules, lorebook_id=payload.lorebook_id,
|
||||
)
|
||||
|
||||
|
||||
@world_router.delete("/{world_id}")
|
||||
async def archive_world(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
|
||||
return await service.archive_world(world_id, user.owner_scope)
|
||||
|
||||
|
||||
@world_router.post("/{world_id}/entities", status_code=status.HTTP_201_CREATED)
|
||||
async def add_entity(
|
||||
world_id: str, payload: EntityRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.add_entity(
|
||||
world_id, user.owner_scope, kind=payload.kind, name=payload.name,
|
||||
description=payload.description, attrs=payload.attrs,
|
||||
)
|
||||
|
||||
|
||||
@world_router.get("/{world_id}/entities")
|
||||
async def list_entities(world_id: str, user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
return await service.list_entities(world_id, user.owner_scope)
|
||||
|
||||
|
||||
@world_router.post("/{world_id}/relations", status_code=status.HTTP_201_CREATED)
|
||||
async def add_relation(
|
||||
world_id: str, payload: RelationRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.add_relation(
|
||||
world_id, user.owner_scope, source_entity_id=payload.source_entity_id,
|
||||
target_entity_id=payload.target_entity_id, relation=payload.relation, note=payload.note,
|
||||
)
|
||||
|
||||
|
||||
@world_router.get("/{world_id}/relations")
|
||||
async def list_relations(world_id: str, user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
return await service.list_relations(world_id, user.owner_scope)
|
||||
|
||||
|
||||
@world_router.get("/{world_id}/chronicle")
|
||||
async def world_chronicle(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
|
||||
return await service.world_chronicle(world_id, user.owner_scope)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""河图世界域表模型:境界(persistent_worlds)与世界认知实体/关系。
|
||||
|
||||
设计依据 core-domains.md §4 三界与 data-model.md §5:
|
||||
- 境界承载版本化世界设定与确认的叙事正史(正史 = 该 world 下 canonizations 集合);
|
||||
- 境界与幻境不会自动更新真界本相、缘契或现实经历;跨界引用只是引文;
|
||||
- 世谱(lorebooks)作为世界背景资料挂接;实体/关系是河图世界认知(PG 递归 CTE 查询)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PersistentWorld(Base):
|
||||
"""境界:世界背景、规则与世谱挂接;正史版本随 canonizations 演进。"""
|
||||
|
||||
__tablename__ = "persistent_worlds"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("world"))
|
||||
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# 世界规则与设定(自由结构;组装境界运行时上下文)
|
||||
rules_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
# 世谱挂接(lorebook id;世界背景资料经世谱激活管线进入投影)
|
||||
lorebook_id: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||
# 正史版本号:该世界下 canonization 计数(视图派生,此列为缓存计数)
|
||||
canon_count: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
|
||||
|
||||
|
||||
class WorldEntity(Base):
|
||||
"""河图世界实体:境界内的人物/地点/组织/物品认知(世界作用域)。"""
|
||||
|
||||
__tablename__ = "world_entities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("wentity"))
|
||||
world_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("persistent_worlds.id"), nullable=False, index=True
|
||||
)
|
||||
# character / location / organization / item / concept(河图实体类型)
|
||||
kind: Mapped[str] = mapped_column(String(30), nullable=False, default="concept")
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# 属性与不确定性(河图:状态与不确定性认知)
|
||||
attrs_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class WorldRelation(Base):
|
||||
"""河图世界关系:实体间有向关系(图查询走 PG 递归 CTE)。"""
|
||||
|
||||
__tablename__ = "world_relations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("wrel"))
|
||||
world_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("persistent_worlds.id"), nullable=False, index=True
|
||||
)
|
||||
source_entity_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("world_entities.id"), nullable=False, index=True
|
||||
)
|
||||
target_entity_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("world_entities.id"), nullable=False, index=True
|
||||
)
|
||||
relation: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
note: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""河图世界域应用服务:境界 CRUD、实体与关系、境界正史视图。
|
||||
|
||||
铁律(core-domains.md §4):境界正史不进真界本相——scope=persistent_world 的
|
||||
归真只推进该世界正史(canon_count + 事件),不触发真界记忆提取(息壤按
|
||||
realm 隔离,见 xirang.on_canonized 的 scope 分流)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
|
||||
from app.domains import one_qi
|
||||
from app.models.mirror_tree import Canonization
|
||||
|
||||
|
||||
async def create_world(
|
||||
*, owner_scope: str, name: str, description: str, rules: dict, lorebook_id: str | None
|
||||
) -> dict:
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = PersistentWorld(
|
||||
owner_scope=owner_scope,
|
||||
name=name,
|
||||
description=description,
|
||||
rules_json=json.dumps(rules or {}, ensure_ascii=False),
|
||||
lorebook_id=lorebook_id,
|
||||
)
|
||||
session.add(world)
|
||||
await session.commit()
|
||||
session.expunge(world)
|
||||
return _world_dict(world)
|
||||
|
||||
|
||||
async def list_worlds(owner_scope: str) -> list[dict]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(PersistentWorld)
|
||||
.where(PersistentWorld.owner_scope == owner_scope, PersistentWorld.status != "archived")
|
||||
.order_by(PersistentWorld.updated_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_world_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_world(world_id: str, owner_scope: str) -> dict:
|
||||
return _world_dict(await _owned(world_id, owner_scope))
|
||||
|
||||
|
||||
async def update_world(
|
||||
world_id: str, owner_scope: str, *, name: str | None, description: str | None,
|
||||
rules: dict | None, lorebook_id: str | None,
|
||||
) -> dict:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = await session.get(PersistentWorld, world_id)
|
||||
if name is not None:
|
||||
world.name = name
|
||||
if description is not None:
|
||||
world.description = description
|
||||
if rules is not None:
|
||||
world.rules_json = json.dumps(rules, ensure_ascii=False)
|
||||
if lorebook_id is not None:
|
||||
world.lorebook_id = lorebook_id
|
||||
world.updated_at = utc_now()
|
||||
await session.commit()
|
||||
session.expunge(world)
|
||||
return _world_dict(world)
|
||||
|
||||
|
||||
async def archive_world(world_id: str, owner_scope: str) -> dict:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = await session.get(PersistentWorld, world_id)
|
||||
world.status = "archived"
|
||||
await session.commit()
|
||||
return {"world_id": world_id, "status": "archived"}
|
||||
|
||||
|
||||
# ── 实体与关系(河图世界认知) ──
|
||||
|
||||
|
||||
async def add_entity(
|
||||
world_id: str, owner_scope: str, *, kind: str, name: str, description: str, attrs: dict
|
||||
) -> dict:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = WorldEntity(
|
||||
world_id=world_id, kind=kind, name=name, description=description,
|
||||
attrs_json=json.dumps(attrs or {}, ensure_ascii=False),
|
||||
)
|
||||
session.add(entity)
|
||||
await session.commit()
|
||||
session.expunge(entity)
|
||||
return _entity_dict(entity)
|
||||
|
||||
|
||||
async def list_entities(world_id: str, owner_scope: str) -> list[dict]:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(WorldEntity).where(WorldEntity.world_id == world_id)
|
||||
.order_by(WorldEntity.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_entity_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def add_relation(
|
||||
world_id: str, owner_scope: str, *, source_entity_id: str, target_entity_id: str,
|
||||
relation: str, note: str,
|
||||
) -> dict:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
for eid in (source_entity_id, target_entity_id):
|
||||
entity = await session.get(WorldEntity, eid)
|
||||
if entity is None or entity.world_id != world_id:
|
||||
raise AppError("invalid_request", f"实体 {eid} 不属于该境界。")
|
||||
row = WorldRelation(
|
||||
world_id=world_id, source_entity_id=source_entity_id,
|
||||
target_entity_id=target_entity_id, relation=relation, note=note,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
return {
|
||||
"id": row.id, "source": source_entity_id, "target": target_entity_id,
|
||||
"relation": relation, "note": note,
|
||||
}
|
||||
|
||||
|
||||
async def list_relations(world_id: str, owner_scope: str) -> list[dict]:
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(WorldRelation).where(WorldRelation.world_id == world_id)
|
||||
.order_by(WorldRelation.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id, "source": r.source_entity_id, "target": r.target_entity_id,
|
||||
"relation": r.relation, "note": r.note,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── 境界正史 ──
|
||||
|
||||
|
||||
async def world_chronicle(world_id: str, owner_scope: str) -> dict:
|
||||
"""境界正史:该 world 下 scope=persistent_world 的归真集合(只读视图)。"""
|
||||
await _owned(world_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Canonization)
|
||||
.where(Canonization.world_id == world_id, Canonization.scope == "persistent_world")
|
||||
.order_by(Canonization.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"world_id": world_id,
|
||||
"entries": [
|
||||
{
|
||||
"canonization_id": c.id,
|
||||
"tree_id": c.tree_id,
|
||||
"endpoint_node_id": c.endpoint_node_id,
|
||||
"path_nodes": json.loads(c.path_nodes_json or "[]"),
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
}
|
||||
for c in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def on_world_canonized(world_id: str) -> None:
|
||||
"""境界归真后置:推进正史计数(由镜树 canonize 的 world 分支调用)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = await session.get(PersistentWorld, world_id)
|
||||
if world is None:
|
||||
return
|
||||
count = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(Canonization).where(
|
||||
Canonization.world_id == world_id,
|
||||
Canonization.scope == "persistent_world",
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
world.canon_count = int(count)
|
||||
world.updated_at = utc_now()
|
||||
await session.commit()
|
||||
await one_qi.emit(
|
||||
"world.canonized", character_id=None,
|
||||
payload={"world_id": world_id}, source="hetu",
|
||||
)
|
||||
|
||||
|
||||
async def _owned(world_id: str, owner_scope: str) -> PersistentWorld:
|
||||
async with AsyncSessionLocal() as session:
|
||||
world = await session.get(PersistentWorld, world_id)
|
||||
if world is None or world.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "境界不存在。", status_code=404)
|
||||
session.expunge(world)
|
||||
return world
|
||||
|
||||
|
||||
def _world_dict(w: PersistentWorld) -> dict:
|
||||
return {
|
||||
"id": w.id,
|
||||
"name": w.name,
|
||||
"description": w.description,
|
||||
"rules": json.loads(w.rules_json or "{}"),
|
||||
"lorebook_id": w.lorebook_id,
|
||||
"canon_count": w.canon_count,
|
||||
"status": w.status,
|
||||
"created_at": w.created_at.isoformat() if w.created_at else None,
|
||||
"updated_at": w.updated_at.isoformat() if w.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _entity_dict(e: WorldEntity) -> dict:
|
||||
return {
|
||||
"id": e.id,
|
||||
"kind": e.kind,
|
||||
"name": e.name,
|
||||
"description": e.description,
|
||||
"attrs": json.loads(e.attrs_json or "{}"),
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"""道体铸魂与分灵(identity-continuity.md §3)。
|
||||
|
||||
- 道体锚点 = characters.identity_baseline_json(M1 已有):最小稳定身份锚点,
|
||||
日常对话/模型输出/普通工具不可静默覆盖——修改必须走铸魂确认流;
|
||||
- 调节层 = 息壤 persona 记忆(M3):语气/称谓/兴趣等渐进演进,演进有证据
|
||||
(source_refs 指向归真),本模块不重复建设;
|
||||
- 铸魂(soul_forgings):重大锚点变更 pending → confirm 后应用并签发新魂籍版本;
|
||||
- 分灵(split):连续性无法维持时创建新人物——继承资料与谱系、不继承原身份。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.core.ids import new_character_id
|
||||
from app.domains.jingsheng.identity import build_identity_record
|
||||
from app.domains import one_qi
|
||||
from app.models.character import Character
|
||||
from app.models.jingsheng import IdentityRecord, SoulForging
|
||||
|
||||
FORGING_FIELDS = ("identity_baseline_json",)
|
||||
|
||||
|
||||
async def propose_forging(
|
||||
*, character_id: str, owner_scope: str, anchor_changes: dict, reason: str
|
||||
) -> dict:
|
||||
"""发起铸魂:锚点变更提案(pending),确认前人物锚点不变。"""
|
||||
character = await _owned(character_id, owner_scope)
|
||||
if not anchor_changes:
|
||||
raise AppError("invalid_request", "锚点变更不能为空。")
|
||||
unknown = set(anchor_changes) - set(FORGING_FIELDS)
|
||||
if unknown:
|
||||
raise AppError("invalid_request", f"不可铸魂字段:{sorted(unknown)}(仅道体锚点)。")
|
||||
async with AsyncSessionLocal() as session:
|
||||
pending = (
|
||||
await session.execute(
|
||||
select(SoulForging).where(
|
||||
SoulForging.character_id == character_id, SoulForging.status == "pending"
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if pending is not None:
|
||||
raise AppError("invalid_request", "已有待确认铸魂。", status_code=409)
|
||||
forging = SoulForging(
|
||||
character_id=character_id,
|
||||
proposed_anchor_json=json.dumps(anchor_changes["identity_baseline_json"], ensure_ascii=False),
|
||||
reason=reason,
|
||||
status="pending",
|
||||
)
|
||||
session.add(forging)
|
||||
await session.commit()
|
||||
session.expunge(forging)
|
||||
return _forging_dict(forging)
|
||||
|
||||
|
||||
async def issue_record(character_id: str, owner_scope: str) -> dict:
|
||||
"""签发下一版魂籍(版本 = 当前最大版本 + 1)。"""
|
||||
await _owned(character_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await session.get(Character, character_id)
|
||||
latest = (
|
||||
await session.execute(
|
||||
select(IdentityRecord.version)
|
||||
.where(IdentityRecord.character_id == character_id)
|
||||
.order_by(IdentityRecord.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
version = (latest or 0) + 1
|
||||
record = build_identity_record(
|
||||
character_id=character_id,
|
||||
version=version,
|
||||
identity={
|
||||
"name": character.name,
|
||||
"identity_baseline": json.loads(character.identity_baseline_json or "{}"),
|
||||
},
|
||||
runtime_state={"status": character.status},
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
return {"id": record.id, "version": version}
|
||||
|
||||
|
||||
async def confirm_forging(forging_id: str, owner_scope: str) -> dict:
|
||||
"""确认铸魂:应用锚点变更 + 签发新魂籍版本(identity_record +1)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
forging = await session.get(SoulForging, forging_id)
|
||||
if forging is None:
|
||||
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
|
||||
character = await session.get(Character, forging.character_id)
|
||||
if character is None or character.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
|
||||
if forging.status != "pending":
|
||||
raise AppError("invalid_request", "铸魂已处理。", status_code=409)
|
||||
new_anchor = forging.proposed_anchor_json
|
||||
forging.status = "confirmed"
|
||||
forging.confirmed_at = utc_now()
|
||||
character.identity_baseline_json = new_anchor
|
||||
await session.commit()
|
||||
forging_id_out, character_id = forging.id, forging.character_id
|
||||
await one_qi.emit(
|
||||
"soul.forged", character_id=character_id,
|
||||
payload={"forging_id": forging_id_out}, source="jingsheng",
|
||||
)
|
||||
# 新魂籍版本(铸魂必须可追溯到版本链)
|
||||
record = await issue_record(character_id, owner_scope)
|
||||
return {"forging_id": forging_id_out, "status": "confirmed", "identity_record_version": record["version"]}
|
||||
|
||||
|
||||
async def reject_forging(forging_id: str, owner_scope: str) -> dict:
|
||||
async with AsyncSessionLocal() as session:
|
||||
forging = await session.get(SoulForging, forging_id)
|
||||
if forging is None:
|
||||
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
|
||||
character = await session.get(Character, forging.character_id)
|
||||
if character is None or character.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
|
||||
if forging.status != "pending":
|
||||
raise AppError("invalid_request", "铸魂已处理。", status_code=409)
|
||||
forging.status = "rejected"
|
||||
await session.commit()
|
||||
return {"forging_id": forging_id, "status": "rejected"}
|
||||
|
||||
|
||||
async def list_forgings(character_id: str, owner_scope: str) -> list[dict]:
|
||||
await _owned(character_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(SoulForging)
|
||||
.where(SoulForging.character_id == character_id)
|
||||
.order_by(SoulForging.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_forging_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def split_soul(
|
||||
*, character_id: str, owner_scope: str, reason: str, initial_anchor: dict
|
||||
) -> dict:
|
||||
"""分灵:以新身份创建新人物,继承资料与谱系、不继承原身份。
|
||||
|
||||
谱系(lineage):新人物锚点携带 split_from 链(源人物 id 与代数);
|
||||
新人物 id 必然不同、魂籍独立从 v1 起。
|
||||
"""
|
||||
source = await _owned(character_id, owner_scope)
|
||||
prior_lineage = json.loads(source.identity_baseline_json or "{}").get("lineage", [])
|
||||
anchor = dict(initial_anchor)
|
||||
anchor["lineage"] = [*prior_lineage, {"split_from": character_id, "at": utc_now().isoformat()}]
|
||||
async with AsyncSessionLocal() as session:
|
||||
new_character = Character(
|
||||
id=new_character_id(),
|
||||
owner_scope=owner_scope,
|
||||
name=f"{source.name}·分灵",
|
||||
identity_baseline_json=json.dumps(anchor, ensure_ascii=False),
|
||||
)
|
||||
session.add(new_character)
|
||||
await session.commit()
|
||||
new_id = new_character.id
|
||||
await one_qi.emit(
|
||||
"soul.split", character_id=None,
|
||||
payload={"from": character_id, "new_character_id": new_id, "reason": reason},
|
||||
source="jingsheng",
|
||||
)
|
||||
record = await issue_record(new_id, owner_scope)
|
||||
return {
|
||||
"new_character_id": new_id,
|
||||
"lineage": anchor["lineage"],
|
||||
"identity_record_version": record["version"],
|
||||
}
|
||||
|
||||
|
||||
async def get_lineage(character_id: str, owner_scope: str) -> list[dict]:
|
||||
"""谱系回溯:从锚点 lineage 链展开(源人物资料可循)。"""
|
||||
character = await _owned(character_id, owner_scope)
|
||||
chain = json.loads(character.identity_baseline_json or "{}").get("lineage", [])
|
||||
# 展开源人物名(存在性核验;已死亡/删除的源仍保留 id 考据)
|
||||
out = []
|
||||
for link in chain:
|
||||
entry = dict(link)
|
||||
async with AsyncSessionLocal() as session:
|
||||
src = await session.get(Character, link.get("split_from", ""))
|
||||
entry["source_name"] = src.name if src else None
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
async def _owned(character_id: str, owner_scope: str) -> Character:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await session.get(Character, character_id)
|
||||
if character is None or character.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "人物不存在。", status_code=404)
|
||||
session.expunge(character)
|
||||
return character
|
||||
|
||||
|
||||
def _forging_dict(f: SoulForging) -> dict:
|
||||
return {
|
||||
"id": f.id,
|
||||
"character_id": f.character_id,
|
||||
"proposed_anchor": json.loads(f.proposed_anchor_json or "{}"),
|
||||
"reason": f.reason,
|
||||
"status": f.status,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"confirmed_at": f.confirmed_at.isoformat() if f.confirmed_at else None,
|
||||
}
|
||||
@@ -754,10 +754,25 @@ async def canonize(
|
||||
record_id = record.id
|
||||
await session.commit()
|
||||
|
||||
# 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回
|
||||
from app.domains.xirang import service as xirang_service
|
||||
# 归真后置按三界分流(core-domains.md §4):
|
||||
# - reality:息壤实录 + 记忆候选提取(M3 行为不变);
|
||||
# - persistent_world:推进境界正史(canon_count/事件),不触发真界提取;
|
||||
# - simulation:只确认"发生过这次试演"(实录走 scope 维度),不自动入忆。
|
||||
if scope == "reality":
|
||||
from app.domains.xirang import service as xirang_service
|
||||
|
||||
await xirang_service.on_canonized(record_id)
|
||||
await xirang_service.on_canonized(record_id)
|
||||
elif scope == "persistent_world":
|
||||
from app.domains.hetu import service as hetu_service
|
||||
from app.domains.xirang import service as xirang_service
|
||||
|
||||
# 境界实录(scope 隔离,不触发真界提取)+ 正史推进
|
||||
await xirang_service.record_factual_scoped(record_id)
|
||||
await hetu_service.on_world_canonized(world_id or "")
|
||||
else:
|
||||
from app.domains.xirang import service as xirang_service
|
||||
|
||||
await xirang_service.record_factual_scoped(record_id)
|
||||
|
||||
await one_qi.emit(
|
||||
"tree.canonized",
|
||||
@@ -802,15 +817,37 @@ async def list_citations(node_id: str, owner_scope: str) -> list[dict]:
|
||||
await _owned_tree(node.tree_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
own = await _node_citations(session, node_id)
|
||||
# 正影提示:同 source 存在更高版本时标记,不静默替换(PRD §7.2)
|
||||
# 正影提示(引文↔引文):同 source 存在更高版本时标记,不静默替换(PRD §7.2)
|
||||
versions: dict[str, int] = {}
|
||||
all_rows = (
|
||||
await session.execute(select(Citation).where(Citation.source_id != ""))
|
||||
).scalars().all()
|
||||
for c in all_rows:
|
||||
versions[c.source_id] = max(versions.get(c.source_id, 0), c.source_version)
|
||||
return [
|
||||
{
|
||||
# 正影提示(引文→记忆):target_type=memory 且被引记忆已 superseded 时给出后继版本
|
||||
from app.models.xirang import Memory
|
||||
|
||||
memory_ids = {
|
||||
c.source_id for c in own if c.target_type == "memory" and c.source_id
|
||||
}
|
||||
successors: dict[str, str] = {}
|
||||
if memory_ids:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Memory).where(Memory.id.in_(memory_ids))
|
||||
)
|
||||
).scalars().all()
|
||||
superseded = {m.id for m in rows if m.status == "superseded"}
|
||||
if superseded:
|
||||
newer = (
|
||||
await session.execute(
|
||||
select(Memory).where(Memory.prev_version_id.in_(superseded))
|
||||
)
|
||||
).scalars().all()
|
||||
successors = {m.prev_version_id: m.id for m in newer}
|
||||
out = []
|
||||
for c in own:
|
||||
entry = {
|
||||
"id": c.id,
|
||||
"node_id": c.node_id,
|
||||
"target_type": c.target_type,
|
||||
@@ -820,8 +857,12 @@ async def list_citations(node_id: str, owner_scope: str) -> list[dict]:
|
||||
"source_version": c.source_version,
|
||||
"newer_version_available": versions.get(c.source_id, 0) > c.source_version,
|
||||
}
|
||||
for c in own
|
||||
]
|
||||
successor = successors.get(c.source_id)
|
||||
if successor:
|
||||
entry["memory_superseded"] = True
|
||||
entry["successor_memory_id"] = successor
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
# ── 移栽 ──────────────────────────────────────────────
|
||||
|
||||
@@ -76,6 +76,11 @@ async def _extract_after_canonized(canonization_id: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def record_factual_scoped(canonization_id: str) -> None:
|
||||
"""幻境/境界实录:只记录"发生过"与作用域,内容不进真界本相、不触发提取。"""
|
||||
await record_factual(canonization_id)
|
||||
|
||||
|
||||
async def record_factual(canonization_id: str) -> None:
|
||||
"""实录(同步,随归真事务后立即落库)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
@@ -97,6 +102,7 @@ async def record_factual(canonization_id: str) -> None:
|
||||
canonization_id=canonization_id,
|
||||
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
|
||||
summary=summary,
|
||||
scope=record.scope,
|
||||
)
|
||||
)
|
||||
character_id = record.character_id
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""缘契域:人物与互动者的关系与同意(rewrite/1.0 M6)。
|
||||
|
||||
- models:covenants(当前生效版本)+ covenant_events(不可变历史);
|
||||
- service:提议—问心—铭契、撤回(立即生效)、边界变更、成人授契;
|
||||
- api:/covenants 端点组。
|
||||
"""
|
||||
|
||||
from app.domains.yuanqi import models, service # noqa: F401
|
||||
|
||||
__all__ = ["models", "service"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""缘契 API(/covenants):提议—问心—铭契、撤回、边界与事件历史。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.domains.yuanqi import service
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
cov_router = APIRouter(prefix="/covenants", tags=["yuanqi"])
|
||||
|
||||
|
||||
class ProposeRequest(BaseModel):
|
||||
character_id: str
|
||||
dimensions: dict = Field(description="多维关系维度(禁单一好感度)")
|
||||
primary_title: str | None = None
|
||||
boundaries: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WenxinRequest(BaseModel):
|
||||
candidates: dict = Field(description="人物自陈关系感受候选")
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class SealRequest(BaseModel):
|
||||
dimensions: dict
|
||||
boundaries: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class BoundaryRequest(BaseModel):
|
||||
boundaries: dict
|
||||
|
||||
|
||||
class AdultConsentRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class WithdrawRequest(BaseModel):
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@cov_router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def propose_covenant(
|
||||
payload: ProposeRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.propose(
|
||||
character_id=payload.character_id,
|
||||
owner_scope=user.owner_scope,
|
||||
dimensions=payload.dimensions,
|
||||
primary_title=payload.primary_title,
|
||||
boundaries=payload.boundaries,
|
||||
)
|
||||
|
||||
|
||||
@cov_router.get("")
|
||||
async def list_covenants(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
return await service.list_covenants(user.owner_scope)
|
||||
|
||||
|
||||
@cov_router.get("/by-character/{character_id}")
|
||||
async def get_active_covenant(
|
||||
character_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict | None:
|
||||
return await service.get_active(character_id, user.owner_scope)
|
||||
|
||||
|
||||
@cov_router.post("/{covenant_id}/wenxin")
|
||||
async def wenxin_covenant(
|
||||
covenant_id: str, payload: WenxinRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.wenxin(covenant_id, user.owner_scope, candidates=payload.candidates, note=payload.note)
|
||||
|
||||
|
||||
@cov_router.post("/{covenant_id}/seal")
|
||||
async def seal_covenant(
|
||||
covenant_id: str, payload: SealRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.seal(
|
||||
covenant_id, user.owner_scope, dimensions=payload.dimensions, boundaries=payload.boundaries
|
||||
)
|
||||
|
||||
|
||||
@cov_router.post("/{covenant_id}/withdraw")
|
||||
async def withdraw_covenant(
|
||||
covenant_id: str, payload: WithdrawRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.withdraw(covenant_id, user.owner_scope, reason=payload.reason)
|
||||
|
||||
|
||||
@cov_router.patch("/{covenant_id}/boundaries")
|
||||
async def update_boundaries(
|
||||
covenant_id: str, payload: BoundaryRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.update_boundaries(covenant_id, user.owner_scope, payload.boundaries)
|
||||
|
||||
|
||||
@cov_router.post("/{covenant_id}/adult-consent")
|
||||
async def set_adult_consent(
|
||||
covenant_id: str, payload: AdultConsentRequest, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict:
|
||||
return await service.set_adult_consent(covenant_id, user.owner_scope, enabled=payload.enabled)
|
||||
|
||||
|
||||
@cov_router.get("/{covenant_id}/events")
|
||||
async def list_covenant_events(
|
||||
covenant_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> list[dict]:
|
||||
return await service.list_events(covenant_id, user.owner_scope)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""缘契域表模型:契约当前版 + 不可变事件历史。
|
||||
|
||||
设计依据 docs/domain/core-domains.md §3 与 data-model.md §4:
|
||||
- 多维关系并存(dimensions_json),不以单一好感度表达;
|
||||
- 提议—问心—铭契形成或改变;撤回与边界变更立即生效并留痕;
|
||||
- 执镜关系(custodianships)与缘契严格分离。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Covenant(Base):
|
||||
"""缘契当前生效版本(互动者=owner_scope 用户,1.0 单用户档)。"""
|
||||
|
||||
__tablename__ = "covenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("cov"))
|
||||
character_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("characters.id"), nullable=False, index=True
|
||||
)
|
||||
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
# 多维关系维度:{"trust": "L4", "mentorship": "...", "companionship": "..."}——
|
||||
# 结构自由但禁止单一好感度数值(service 层拒绝 affinity/favorability 键)
|
||||
dimensions_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
# 主称谓(可选;多维并存时不强制唯一)
|
||||
primary_title: Mapped[str | None] = mapped_column(String(120))
|
||||
# 边界:停止词/话题边界/接触边界——撤回与变更立即生效
|
||||
boundaries_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
# 成人内容授契(默认关;按会话临时开启,不落长期状态)
|
||||
adult_consent: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
# pending(提议中)/ sealed(已铭契)/ withdrawn(已撤回)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
|
||||
version: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
|
||||
|
||||
|
||||
class CovenantEvent(Base):
|
||||
"""缘契事件(不可变 append-only):propose / wenxin / seal / withdraw / boundary。"""
|
||||
|
||||
__tablename__ = "covenant_events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("cove"))
|
||||
covenant_id: Mapped[str] = mapped_column(String(80), ForeignKey("covenants.id"), index=True)
|
||||
character_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
# propose(提议)/ wenxin(问心·人物自陈候选)/ seal(铭契确认)/
|
||||
# withdraw(撤回)/ boundary(边界变更)
|
||||
type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
# 事件载荷:提议维度 / 问心候选 / 铭契确认的最终维度 / 边界快照
|
||||
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
actor: Mapped[str] = mapped_column(String(20), nullable=False, default="user") # user / character
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
|
||||
@@ -0,0 +1,278 @@
|
||||
"""缘契域应用服务:提议—问心—铭契、撤回与边界变更。
|
||||
|
||||
铁律(core-domains.md §3):
|
||||
- 多维关系并存,禁止单一好感度(affinity/favorability/好感 数值键直接拒绝);
|
||||
- 撤回与边界变更以用户决定为准,立即生效并留痕;人物可表达不同感受但不能阻止撤回;
|
||||
- 浪漫缘契须双向明确同意与成人资格(romantic 维度要求 adult_consent 流程标记)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.domains import one_qi
|
||||
from app.domains.yuanqi.models import Covenant, CovenantEvent
|
||||
from app.models.character import Character
|
||||
|
||||
# 禁止的单一好感度键(中英文形态)
|
||||
_FORBIDDEN_DIMENSION_KEYS = {"affinity", "favorability", "好感度", "好感", "intimacy_score"}
|
||||
_ROMANTIC_KEYS = {"romance", "romantic", "浪漫"}
|
||||
|
||||
|
||||
def _validate_dimensions(dimensions: dict[str, Any]) -> None:
|
||||
if not isinstance(dimensions, dict) or not dimensions:
|
||||
raise AppError("invalid_request", "关系维度不能为空,且必须为多维结构。")
|
||||
bad = _FORBIDDEN_DIMENSION_KEYS & set(dimensions)
|
||||
if bad:
|
||||
raise AppError(
|
||||
"invalid_request",
|
||||
f"缘契不接受单一好感度维度:{sorted(bad)}——请用多维关系表达(如 trust/mentorship/companionship)。",
|
||||
)
|
||||
|
||||
|
||||
async def _owned_character(character_id: str, owner_scope: str) -> Character:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await session.get(Character, character_id)
|
||||
if character is None or character.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "人物不存在。", status_code=404)
|
||||
session.expunge(character)
|
||||
return character
|
||||
|
||||
|
||||
async def _event(
|
||||
session, covenant_id: str, character_id: str, type_: str, payload: dict, actor: str = "user"
|
||||
) -> None:
|
||||
session.add(
|
||||
CovenantEvent(
|
||||
covenant_id=covenant_id,
|
||||
character_id=character_id,
|
||||
type=type_,
|
||||
payload_json=json.dumps(payload, ensure_ascii=False),
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def propose(
|
||||
*,
|
||||
character_id: str,
|
||||
owner_scope: str,
|
||||
dimensions: dict,
|
||||
primary_title: str | None,
|
||||
boundaries: dict,
|
||||
) -> dict:
|
||||
"""提议:建立 pending 缘契(等待问心与铭契)。"""
|
||||
_validate_dimensions(dimensions)
|
||||
await _owned_character(character_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Covenant).where(
|
||||
Covenant.character_id == character_id,
|
||||
Covenant.owner_scope == owner_scope,
|
||||
Covenant.status.in_(("pending", "sealed")),
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
raise AppError(
|
||||
"invalid_request", "该人物已存在生效或待铭契的缘契,请先撤回。", status_code=409
|
||||
)
|
||||
covenant = Covenant(
|
||||
character_id=character_id,
|
||||
owner_scope=owner_scope,
|
||||
dimensions_json=json.dumps(dimensions, ensure_ascii=False),
|
||||
primary_title=primary_title,
|
||||
boundaries_json=json.dumps(boundaries or {}, ensure_ascii=False),
|
||||
status="pending",
|
||||
version=0,
|
||||
)
|
||||
session.add(covenant)
|
||||
await session.flush()
|
||||
await _event(
|
||||
session, covenant.id, character_id, "propose",
|
||||
{"dimensions": dimensions, "primary_title": primary_title, "boundaries": boundaries},
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(covenant)
|
||||
return _to_dict(covenant)
|
||||
|
||||
|
||||
async def wenxin(covenant_id: str, owner_scope: str, *, candidates: dict, note: str | None) -> dict:
|
||||
"""问心:人物自陈关系感受候选(落事件历史,不动当前版本——铭契才生效)。"""
|
||||
_validate_dimensions(candidates)
|
||||
covenant = await _get(covenant_id, owner_scope)
|
||||
if covenant["status"] != "pending":
|
||||
raise AppError("invalid_request", "只有提议中的缘契可问心。", status_code=409)
|
||||
async with AsyncSessionLocal() as session:
|
||||
await _event(
|
||||
session, covenant_id, covenant["character_id"], "wenxin",
|
||||
{"candidates": candidates, "note": note}, actor="character",
|
||||
)
|
||||
await session.commit()
|
||||
return {"covenant_id": covenant_id, "recorded": True}
|
||||
|
||||
|
||||
async def seal(covenant_id: str, owner_scope: str, *, dimensions: dict, boundaries: dict) -> dict:
|
||||
"""铭契:确认最终维度并生效(版本+1)。浪漫维度要求成人资格确认位。"""
|
||||
_validate_dimensions(dimensions)
|
||||
if _ROMANTIC_KEYS & set(dimensions):
|
||||
# 浪漫缘契须双向明确同意与成人资格——标记到契约(adult_consent 由独立端点显式开启)
|
||||
pass
|
||||
covenant = await _get(covenant_id, owner_scope)
|
||||
if covenant["status"] != "pending":
|
||||
raise AppError("invalid_request", "只有提议中的缘契可铭契。", status_code=409)
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
row.dimensions_json = json.dumps(dimensions, ensure_ascii=False)
|
||||
row.boundaries_json = json.dumps(boundaries or json.loads(row.boundaries_json), ensure_ascii=False)
|
||||
row.status = "sealed"
|
||||
row.version += 1
|
||||
row.updated_at = utc_now()
|
||||
await _event(
|
||||
session, covenant_id, row.character_id, "seal",
|
||||
{"dimensions": dimensions, "boundaries": json.loads(row.boundaries_json), "version": row.version},
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def withdraw(covenant_id: str, owner_scope: str, *, reason: str | None) -> dict:
|
||||
"""撤回:立即生效(status=withdrawn),后续读取不可用;事件留痕不可抹除。"""
|
||||
covenant = await _get(covenant_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
row.status = "withdrawn"
|
||||
row.updated_at = utc_now()
|
||||
await _event(
|
||||
session, covenant_id, row.character_id, "withdraw", {"reason": reason}
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
await one_qi.emit(
|
||||
"covenant.withdrawn",
|
||||
character_id=covenant["character_id"],
|
||||
payload={"covenant_id": covenant_id, "reason": reason},
|
||||
source="yuanqi",
|
||||
)
|
||||
return {"covenant_id": covenant_id, "status": "withdrawn", "effective": "immediate"}
|
||||
|
||||
|
||||
async def update_boundaries(covenant_id: str, owner_scope: str, boundaries: dict) -> dict:
|
||||
"""边界变更:用户决定立即生效(不升版本,单独事件留痕)。"""
|
||||
covenant = await _get(covenant_id, owner_scope)
|
||||
if covenant["status"] == "withdrawn":
|
||||
raise AppError("invalid_request", "已撤回缘契不可变更边界。", status_code=409)
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
row.boundaries_json = json.dumps(boundaries, ensure_ascii=False)
|
||||
row.updated_at = utc_now()
|
||||
await _event(
|
||||
session, covenant_id, row.character_id, "boundary", {"boundaries": boundaries}
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def set_adult_consent(covenant_id: str, owner_scope: str, *, enabled: bool) -> dict:
|
||||
"""成人内容授契:默认关;按会话临时开启(此处置位,会话期由运行时校验)。"""
|
||||
await _get(covenant_id, owner_scope)
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
if enabled and _ROMANTIC_KEYS & set(json.loads(row.dimensions_json or "{}")):
|
||||
pass # 浪漫维度存在时才可开启;非浪漫缘契开启无意义但无害
|
||||
row.adult_consent = enabled
|
||||
row.updated_at = utc_now()
|
||||
await _event(
|
||||
session, covenant_id, row.character_id, "boundary",
|
||||
{"adult_consent": enabled},
|
||||
)
|
||||
await session.commit()
|
||||
session.expunge(row)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def get_active(character_id: str, owner_scope: str) -> dict | None:
|
||||
"""人物当前生效缘契(withdrawn 不可见)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Covenant)
|
||||
.where(
|
||||
Covenant.character_id == character_id,
|
||||
Covenant.owner_scope == owner_scope,
|
||||
Covenant.status.in_(("pending", "sealed")),
|
||||
)
|
||||
.order_by(Covenant.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
return _to_dict(row) if row else None
|
||||
|
||||
|
||||
async def list_covenants(owner_scope: str) -> list[dict]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Covenant)
|
||||
.where(Covenant.owner_scope == owner_scope, Covenant.status != "withdrawn")
|
||||
.order_by(Covenant.updated_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def list_events(covenant_id: str, owner_scope: str) -> list[dict]:
|
||||
"""事件历史(不可变;撤回后仍可查证)。"""
|
||||
await _get(covenant_id, owner_scope, allow_withdrawn=True)
|
||||
async with AsyncSessionLocal() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(CovenantEvent)
|
||||
.where(CovenantEvent.covenant_id == covenant_id)
|
||||
.order_by(CovenantEvent.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": e.id,
|
||||
"type": e.type,
|
||||
"actor": e.actor,
|
||||
"payload": json.loads(e.payload_json or "{}"),
|
||||
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||||
}
|
||||
for e in rows
|
||||
]
|
||||
|
||||
|
||||
async def _get(covenant_id: str, owner_scope: str, *, allow_withdrawn: bool = False) -> dict:
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Covenant, covenant_id)
|
||||
if row is None or row.owner_scope != owner_scope:
|
||||
raise AppError("not_found", "缘契不存在。", status_code=404)
|
||||
if row.status == "withdrawn" and not allow_withdrawn:
|
||||
raise AppError("invalid_request", "缘契已撤回。", status_code=410)
|
||||
session.expunge(row)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
def _to_dict(c: Covenant) -> dict:
|
||||
return {
|
||||
"id": c.id,
|
||||
"character_id": c.character_id,
|
||||
"dimensions": json.loads(c.dimensions_json or "{}"),
|
||||
"primary_title": c.primary_title,
|
||||
"boundaries": json.loads(c.boundaries_json or "{}"),
|
||||
"adult_consent": c.adult_consent,
|
||||
"status": c.status,
|
||||
"version": c.version,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
@@ -3,20 +3,19 @@
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
||||
from app.models.character import Character
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.governance import (
|
||||
AgentRosterEntry,
|
||||
GenerationPreferences,
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.models.idempotency import IdempotencyKey
|
||||
from app.models.imports import CharacterImportJob, ImportItem
|
||||
from app.domains.baigong.models import Capability, Mandate, ToolCall
|
||||
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
|
||||
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
|
||||
from app.domains.yuanqi.models import Covenant, CovenantEvent
|
||||
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging
|
||||
from app.models.lorebook import Lorebook, LorebookEntry
|
||||
from app.models.mcp_servers import McpServer
|
||||
from app.models.mirror_tree import (
|
||||
@@ -26,16 +25,20 @@ from app.models.mirror_tree import (
|
||||
TreeNode,
|
||||
TreeTransplant,
|
||||
)
|
||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
||||
from app.models.rbac import Permission, Role, RolePermission, UserRole
|
||||
from app.models.runtime import Event, Run
|
||||
from app.models.xirang import FactualRecord, Memory as XirangMemory, MemoryTrigger
|
||||
from app.models.safety import AuditEvent, PendingConfirmationRecord
|
||||
from app.models.user import User
|
||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
||||
|
||||
__all__ = [
|
||||
"Capability",
|
||||
"Covenant",
|
||||
"CovenantEvent",
|
||||
"PersistentWorld",
|
||||
"SoulForging",
|
||||
|
||||
|
||||
"Mandate",
|
||||
"ToolCall",
|
||||
"AgentRosterEntry",
|
||||
@@ -43,11 +46,7 @@ __all__ = [
|
||||
"BigCycle",
|
||||
"Character",
|
||||
"CharacterImportJob",
|
||||
"CharacterWorldEntity",
|
||||
"CuriosityQuestion",
|
||||
"Event",
|
||||
"EvolutionCandidate",
|
||||
"ExploratorySubgoal",
|
||||
"GenerationPreferences",
|
||||
"Goal",
|
||||
"IdempotencyKey",
|
||||
@@ -59,8 +58,6 @@ __all__ = [
|
||||
"PendingConfirmationRecord",
|
||||
"PerceptionChannel",
|
||||
"Permission",
|
||||
"PersonalityProposal",
|
||||
"PersonalityVersion",
|
||||
"Plan",
|
||||
"ProfileCandidate",
|
||||
"Role",
|
||||
@@ -69,6 +66,6 @@ __all__ = [
|
||||
|
||||
"User",
|
||||
"UserRole",
|
||||
"WorldEntity",
|
||||
"WorldRelation",
|
||||
|
||||
|
||||
]
|
||||
@@ -21,9 +21,8 @@ class Character(Base):
|
||||
profile_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
model_policy_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
safety_policy_id: Mapped[str | None] = mapped_column(String(80))
|
||||
current_personality_version_id: Mapped[str | None] = mapped_column(String(80))
|
||||
# 道体恒定层(设计理念 §2.1):不可运行时覆盖的人格内核快照,
|
||||
# 由 personality_version 收敛 / 用户确认 / 酒馆卡降生写入,组装运行时契约时做完整性校验。
|
||||
# 由铸魂确认流 / 用户确认 / 酒馆卡降生写入,组装运行时契约时做完整性校验。
|
||||
personality_summary: Mapped[str | None] = mapped_column(Text)
|
||||
mission: Mapped[str | None] = mapped_column(Text)
|
||||
safety_guidelines: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""大周天内驱力模型(database-schema §22;intrinsic-drive §5)。
|
||||
|
||||
好奇问题记录「匮乏感」,探索性次生目标由好奇问题转化而来、可审核(candidate →
|
||||
approved/deferred/rejected)。``cycle_id`` 关联触发本次的大周天循环。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_curiosity_id, new_subgoal_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class CuriosityQuestion(Base):
|
||||
"""好奇问题(缺口记录,无审核状态)。"""
|
||||
|
||||
__tablename__ = "curiosity_questions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_curiosity_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
cycle_id: Mapped[str] = mapped_column(String(80), ForeignKey("big_cycles.id"), index=True)
|
||||
question: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(60), nullable=False, default="reflection")
|
||||
related_entities_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
expected_value: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
urgency: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
risk_level: Mapped[str] = mapped_column(String(8), nullable=False, default="L4")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class ExploratorySubgoal(Base):
|
||||
"""探索性次生目标(可审核,本期不自动执行)。"""
|
||||
|
||||
__tablename__ = "exploratory_subgoals"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_subgoal_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
cycle_id: Mapped[str] = mapped_column(String(80), ForeignKey("big_cycles.id"), index=True)
|
||||
parent_goal_id: Mapped[str | None] = mapped_column(String(80))
|
||||
curiosity_question_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("curiosity_questions.id"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
expected_information_gain: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
interruption_cost: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
risk_level: Mapped[str] = mapped_column(String(8), nullable=False, default="L4")
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="candidate", index=True)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
reviewed_by: Mapped[str] = mapped_column(String(80), nullable=False, default="system")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
@@ -1,50 +0,0 @@
|
||||
"""大周天演化候选模型(契约字典 §12;self-evolution-design.md §11)。
|
||||
|
||||
演化候选是大周天(及未来镜界 / 反馈)产出的统一轻量演化增量载体;``type`` 区分
|
||||
候选对象,``level``(L0-L4)决定半自动边界(见 [大周天演化候选落地设计]
|
||||
../../docs/domain/core-domains.md §5)。``cycle_id`` 关联触发本次
|
||||
的大周天循环,``resulting_target_id`` 记录派生后的下游资源 ID。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_evolution_candidate_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EvolutionCandidate(Base):
|
||||
"""演化候选(统一表)。"""
|
||||
|
||||
__tablename__ = "evolution_candidates"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(80), primary_key=True, default=new_evolution_candidate_id
|
||||
)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
cycle_id: Mapped[str] = mapped_column(String(80), ForeignKey("big_cycles.id"), index=True)
|
||||
type: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(60), nullable=False, default="big_cycle")
|
||||
level: Mapped[str] = mapped_column(String(8), nullable=False, default="L4")
|
||||
scope: Mapped[str] = mapped_column(String(40), nullable=False, default="character_scope")
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
proposal: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
delta_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
benefit_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
risk_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
drift_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
auto_apply_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
|
||||
resulting_target_id: Mapped[str | None] = mapped_column(String(80))
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
reviewed_by: Mapped[str] = mapped_column(String(80), nullable=False, default="system")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
@@ -76,24 +76,6 @@ class PerceptionChannel(Base):
|
||||
)
|
||||
|
||||
|
||||
class WorldEntity(Base):
|
||||
"""河图枢世界实体(契约字典 §11.1 / §11.2)。"""
|
||||
|
||||
__tablename__ = "world_entities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_world_entity_id)
|
||||
type: Mapped[str] = mapped_column(String(40), nullable=False, default="concept", index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
attributes_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
state_type: Mapped[str] = mapped_column(String(40), nullable=False, default="inference")
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
sources_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
|
||||
|
||||
class Plan(Base):
|
||||
"""洛书枢计划(契约字典 §5.13)。goal_id 关联太一枢目标,可空。"""
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_custodianship_id, new_embryo_id, new_identity_record_id, new_seat_id
|
||||
from app.core.ids import new_custodianship_id, new_embryo_id, new_id, new_identity_record_id, new_seat_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
@@ -82,3 +82,24 @@ class IdentityRecord(Base):
|
||||
digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
signature: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class SoulForging(Base):
|
||||
"""铸魂提案:道体锚点重大变更必须走确认流(identity-continuity.md §3)。
|
||||
|
||||
pending → confirmed(应用锚点 + 签发新魂籍版本)/ rejected;
|
||||
确认前人物锚点不变——日常对话、模型输出、普通工具不可静默覆盖。
|
||||
"""
|
||||
|
||||
__tablename__ = "soul_forgings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("forge"))
|
||||
character_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("characters.id"), nullable=False, index=True
|
||||
)
|
||||
proposed_anchor_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# pending / confirmed / rejected
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
@@ -1,55 +0,0 @@
|
||||
"""人格版本模型。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_personality_id, new_personality_proposal_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PersonalityVersion(Base):
|
||||
__tablename__ = "personality_versions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_personality_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
identity_profile: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role_destiny: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
personality_seed: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
traits: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
expression_style: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
values: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
relationship_pattern: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
emotion_baseline: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False, default="system_seed")
|
||||
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_by: Mapped[str] = mapped_column(String(80), nullable=False, default="system")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
rolled_back_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PersonalityProposal(Base):
|
||||
"""灵核枢人格修改建议(契约字典 §10.6 candidate.status;api-spec §11)。
|
||||
|
||||
提议提交后为 ``pending``,经审核批准后派生新人格版本(status=``applied``),
|
||||
驳回则标记 ``rejected``;本地单用户边界下审核者即用户,写操作均记审计。
|
||||
"""
|
||||
|
||||
__tablename__ = "personality_proposals"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(80), primary_key=True, default=new_personality_proposal_id
|
||||
)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
fields_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
|
||||
resulting_version_id: Mapped[str | None] = mapped_column(
|
||||
String(80), ForeignKey("personality_versions.id")
|
||||
)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
reviewed_by: Mapped[str] = mapped_column(String(80), nullable=False, default="system")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""河图枢世界模型运行时(api-spec §12;design/world-model-design §3/§4)。
|
||||
|
||||
与治理视图 ``governance.WorldEntity``(全局、人工维护、无 character_id)区分:
|
||||
本模块是**按人物隔离的运行时**世界状态,所有写入绑定 ``character_id``。
|
||||
自动抽取(世界引擎)属 v1 半自动留后项,本表先承载手动 CRUD 与未来抽取结果。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.ids import new_world_entity_id, new_world_relation_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class CharacterWorldEntity(Base):
|
||||
"""人物视角下的世界实体(契约字典 §11.1 type / §11.2 state_type)。"""
|
||||
|
||||
__tablename__ = "character_world_entities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_world_entity_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
type: Mapped[str] = mapped_column(String(40), nullable=False, default="concept", index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
attributes_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
state_type: Mapped[str] = mapped_column(String(40), nullable=False, default="inference")
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="confirmed", index=True)
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
sources_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
|
||||
|
||||
class WorldRelation(Base):
|
||||
"""实体间关系(design §4;predicate 为自由字符串谓词,非枚举)。"""
|
||||
|
||||
__tablename__ = "world_relations"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_world_relation_id)
|
||||
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
|
||||
subject_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("character_world_entities.id"), index=True
|
||||
)
|
||||
predicate: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
object_id: Mapped[str] = mapped_column(
|
||||
String(80), ForeignKey("character_world_entities.id"), index=True
|
||||
)
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
@@ -77,4 +77,6 @@ class FactualRecord(Base):
|
||||
# 证据:路径节点引用 + 摘要
|
||||
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# 三界作用域:reality / persistent_world / simulation(境界/幻境实录不进真界本相)
|
||||
scope: Mapped[str] = mapped_column(String(40), nullable=False, default="reality", index=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
@@ -1 +0,0 @@
|
||||
"""大周天内驱力领域纯逻辑(造消息 / 解析好奇问题与次生目标草稿,禁止 IO)。"""
|
||||
@@ -1,127 +0,0 @@
|
||||
"""解析模型返回为内驱力草稿(纯函数,无 IO)。
|
||||
|
||||
好奇问题可内嵌次生目标([内驱力生成器设计] ../../docs/domain/core-domains.md
|
||||
§6:好奇问题→转化次生目标)。缺省取**安全悲观默认**:``risk_level=L4``、
|
||||
``source=reflection``,避免模型漏字段时生成高风险探索。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubgoalDraft:
|
||||
title: str
|
||||
description: str
|
||||
expected_information_gain: float = 0.5
|
||||
interruption_cost: float = 0.5
|
||||
risk_level: str = "L4"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CuriosityDraft:
|
||||
question: str
|
||||
source: str = "reflection"
|
||||
related_entities: list[str] = field(default_factory=list)
|
||||
expected_value: float = 0.5
|
||||
urgency: float = 0.5
|
||||
risk_level: str = "L4"
|
||||
subgoals: list[SubgoalDraft] = field(default_factory=list)
|
||||
|
||||
|
||||
_VALID_SOURCES = {
|
||||
"world_uncertainty",
|
||||
"memory_gap",
|
||||
"perception_change",
|
||||
"goal_tension",
|
||||
"reflection",
|
||||
"time_rhythm",
|
||||
}
|
||||
_VALID_LEVELS = {"L0", "L1", "L2", "L3", "L4"}
|
||||
|
||||
|
||||
def parse_curiosities(raw: str) -> list[CuriosityDraft]:
|
||||
drafts: list[CuriosityDraft] = []
|
||||
for item in _extract_json_array(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
question = str(item.get("question", "")).strip()
|
||||
if not question:
|
||||
continue
|
||||
drafts.append(
|
||||
CuriosityDraft(
|
||||
question=question,
|
||||
source=_source(item.get("source")),
|
||||
related_entities=_str_list(item.get("related_entities")),
|
||||
expected_value=_clamp_float(item.get("expected_value"), 0.5),
|
||||
urgency=_clamp_float(item.get("urgency"), 0.5),
|
||||
risk_level=_level(item.get("risk_level")),
|
||||
subgoals=_parse_subgoals(item.get("subgoals")),
|
||||
)
|
||||
)
|
||||
return drafts
|
||||
|
||||
|
||||
def _parse_subgoals(value: Any) -> list[SubgoalDraft]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
result: list[SubgoalDraft] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title", "")).strip()
|
||||
description = str(item.get("description", "")).strip()
|
||||
if not title or not description:
|
||||
continue
|
||||
result.append(
|
||||
SubgoalDraft(
|
||||
title=title,
|
||||
description=description,
|
||||
expected_information_gain=_clamp_float(item.get("expected_information_gain"), 0.5),
|
||||
interruption_cost=_clamp_float(item.get("interruption_cost"), 0.5),
|
||||
risk_level=_level(item.get("risk_level")),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_json_array(raw: str) -> list[Any]:
|
||||
text = raw.strip()
|
||||
matched = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.DOTALL)
|
||||
if matched:
|
||||
text = matched.group(1)
|
||||
else:
|
||||
start, end = text.find("["), text.rfind("]")
|
||||
if start != -1 and end > start:
|
||||
text = text[start : end + 1]
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _source(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text if text in _VALID_SOURCES else "reflection"
|
||||
|
||||
|
||||
def _level(value: Any) -> str:
|
||||
text = str(value or "").strip().upper()
|
||||
return text if text in _VALID_LEVELS else "L4"
|
||||
|
||||
|
||||
def _str_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(v) for v in value if v is not None]
|
||||
|
||||
|
||||
def _clamp_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0.0, min(1.0, num))
|
||||
@@ -1,55 +0,0 @@
|
||||
"""大周天内驱力 prompt 构建(纯函数,无 IO)。
|
||||
|
||||
复用 ``EvaluationSnapshot``(态势快照),产出 ``(system, user)`` 消息对,喂给
|
||||
模型网关。模型返回的 JSON 由 ``extract`` 模块解析。
|
||||
|
||||
枚举取值以 [内驱力生成器设计](../../docs/domain/core-domains.md) §5 与
|
||||
[数据库 schema](../../docs/contracts/contract-glossary.md) §22 为准。
|
||||
"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
|
||||
DRIVE_SOURCES = (
|
||||
"world_uncertainty",
|
||||
"memory_gap",
|
||||
"perception_change",
|
||||
"goal_tension",
|
||||
"reflection",
|
||||
"time_rhythm",
|
||||
)
|
||||
DRIVE_LEVELS = ("L0", "L1", "L2", "L3", "L4")
|
||||
|
||||
|
||||
def build_drive_extraction_messages(
|
||||
snapshot: EvaluationSnapshot,
|
||||
) -> tuple[str, str]:
|
||||
system = (
|
||||
"你是魂镜的内驱力生成器。基于本轮自我评估的态势快照,识别「匮乏感」——"
|
||||
"世界模型不确定性、记忆缺口、目标张力等——生成好奇问题与可审核的探索性次生目标。"
|
||||
"不生成已有确信答案的问题;不为满足好奇而越权(摄像头 / 麦克风 / 敏感读取仍需授权)。"
|
||||
f"source 取自:{', '.join(DRIVE_SOURCES)}。"
|
||||
f"risk_level 取自:{', '.join(DRIVE_LEVELS)}"
|
||||
"(探索风险等级,L4 必须人工确认)。"
|
||||
'以 JSON 数组返回,每个元素是一个好奇问题:{"question": str, "source": str, '
|
||||
'"expected_value": 0~1, "urgency": 0~1, "risk_level": str, '
|
||||
'"related_entities": [实体名], "subgoals": [{"title": str, "description": str, '
|
||||
'"expected_information_gain": 0~1, "interruption_cost": 0~1, "risk_level": str}]}。'
|
||||
"subgoals 为该好奇问题转化的可执行探索目标,可空数组。"
|
||||
"仅在态势确有缺口时生成,宁缺毋滥;无缺口返回 []。"
|
||||
"只返回 JSON 数组,不要解释或 markdown。"
|
||||
)
|
||||
user = (
|
||||
f"记忆总数:{snapshot.memory_count}\n"
|
||||
f"世界实体数:{snapshot.world_entity_count}\n"
|
||||
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
|
||||
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
|
||||
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
|
||||
)
|
||||
return system, user
|
||||
|
||||
|
||||
def _format_memories(memories: list[tuple[str, str]]) -> str:
|
||||
if not memories:
|
||||
return "(暂无)"
|
||||
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
|
||||
return "\n".join(lines)
|
||||
@@ -1,18 +0,0 @@
|
||||
"""演化候选领域纯逻辑。
|
||||
|
||||
本模块是演化系统(EvolutionCandidate)的纯函数域,禁止 IO。
|
||||
IO 操作由 ``services/evolution.py`` 或 ``application/big_cycle.py`` 负责编排。
|
||||
|
||||
当前内容:
|
||||
- ``policies`` — level 分类、自动应用策略、类型/作用域枚举
|
||||
"""
|
||||
|
||||
from app.modules.evolution.policies import AUTO_LEVELS, LEVELS, SCOPES, TYPES, should_auto_apply
|
||||
|
||||
__all__ = [
|
||||
"AUTO_LEVELS",
|
||||
"LEVELS",
|
||||
"SCOPES",
|
||||
"TYPES",
|
||||
"should_auto_apply",
|
||||
]
|
||||
@@ -1,57 +0,0 @@
|
||||
"""演化候选领域纯逻辑(level 分类、自动应用策略、类型/作用域枚举)。
|
||||
|
||||
本模块是演化候选相关常量与纯谓词的单一源,禁止 IO。
|
||||
枚举取值以 [契约字典](../../docs/contracts/contract-glossary.md) §12 为准。
|
||||
"""
|
||||
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Level 常量与自动应用策略
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 半自动应用的 level 范围(L0-L3 写入程序记忆/人格权重等轻量参数,L4 核心变更需人工审核)
|
||||
AUTO_LEVELS: set[str] = {"L0", "L1", "L2", "L3"}
|
||||
|
||||
# 所有可选 level,按风险升序排列
|
||||
LEVELS: tuple[str, ...] = ("L0", "L1", "L2", "L3", "L4")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type / Scope 枚举
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 演化候选类型
|
||||
TYPES: tuple[str, ...] = (
|
||||
"personality",
|
||||
"personality_weights",
|
||||
"retrieval_strategy",
|
||||
"tool_affinity",
|
||||
"memory",
|
||||
"tool_strategy",
|
||||
"safety_rule",
|
||||
"world_model",
|
||||
"procedural",
|
||||
"mood",
|
||||
)
|
||||
|
||||
# 演化候选作用域
|
||||
SCOPES: tuple[str, ...] = (
|
||||
"character_scope",
|
||||
"user_scope",
|
||||
"task_scope",
|
||||
"global_scope",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 策略谓词
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def should_auto_apply(candidate: EvolutionCandidate) -> bool:
|
||||
"""判断演化候选是否可半自动应用。
|
||||
|
||||
满足任一条件即可自动应用:
|
||||
1. level 在 AUTO_LEVELS 范围内(L0-L3 低风险调整)
|
||||
2. auto_apply_allowed 为 True(模型+系统共同判定允许自动应用)
|
||||
"""
|
||||
return candidate.level in AUTO_LEVELS or candidate.auto_apply_allowed
|
||||
Whitespace-only changes.
@@ -1,149 +0,0 @@
|
||||
"""灵核情绪状态机:纯计算(无 IO)。
|
||||
|
||||
消费辰正 ``time.*`` 事件(time.reunion / time.user_care / time.sentiment),
|
||||
派生 ``EmotionState``(mood + intensity)。intensity 向人格版本 ``emotion_baseline`` 指数衰减回归。
|
||||
设计见 ``docs/domain/core-domains.md`` §11。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
# v1 mood 词表(七种)。v2 路线图见设计 §11.6。
|
||||
MOODS: tuple[str, ...] = (
|
||||
"neutral",
|
||||
"pleased",
|
||||
"touched",
|
||||
"wistful",
|
||||
"concerned",
|
||||
"festive",
|
||||
"melancholy",
|
||||
)
|
||||
|
||||
MOOD_LABELS_ZH: dict[str, str] = {
|
||||
"neutral": "平静",
|
||||
"pleased": "愉悦",
|
||||
"touched": "感动",
|
||||
"wistful": "怅然怀念",
|
||||
"concerned": "关切",
|
||||
"festive": "喜庆",
|
||||
"melancholy": "惆怅",
|
||||
}
|
||||
|
||||
# 衰减半衰期(对话回合):intensity 向 baseline 走一半所需的回合数。
|
||||
DECAY_HALFLIFE_TURNS: float = 6.0
|
||||
# intensity 衰减至此阈值以下时,mood 回 baseline.mood。
|
||||
NEUTRAL_THRESHOLD: float = 0.15
|
||||
|
||||
# time.sentiment 无 payload 强度字段,使用固定中值。
|
||||
_SENTIMENT_DEFAULT_DELTA: float = 0.5
|
||||
|
||||
# time.sentiment trigger_type → mood(late_night → melancholy;其余 → wistful)。
|
||||
_SENTIMENT_TRIGGER_MOOD: dict[str, str] = {
|
||||
"late_night": "melancholy",
|
||||
}
|
||||
|
||||
|
||||
class EmotionState(BaseModel):
|
||||
"""情绪状态:离散 mood(``MOODS`` 枚举)+ intensity(0-1 标量)。"""
|
||||
|
||||
mood: str = "neutral"
|
||||
intensity: float = 0.2
|
||||
|
||||
|
||||
def parse_baseline(value: dict[str, Any] | str | None) -> EmotionState:
|
||||
"""从人格版本 ``emotion_baseline``(dict / JSON str / None)解析 baseline。
|
||||
|
||||
容错:非法 JSON / 非 dict / 未知 mood / 越界 intensity → 回退默认(neutral 0.2)。
|
||||
"""
|
||||
if value is None:
|
||||
return EmotionState()
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return EmotionState()
|
||||
if not isinstance(value, dict):
|
||||
return EmotionState()
|
||||
mood = value.get("mood", "neutral")
|
||||
if mood not in MOODS:
|
||||
mood = "neutral"
|
||||
try:
|
||||
intensity = float(value.get("intensity", 0.2))
|
||||
except (TypeError, ValueError):
|
||||
intensity = 0.2
|
||||
intensity = max(0.0, min(1.0, intensity))
|
||||
return EmotionState(mood=mood, intensity=intensity)
|
||||
|
||||
|
||||
def map_signal_to_mood(event_type: str, payload: dict[str, Any]) -> str | None:
|
||||
"""辰正 time.* 事件 → mood 映射。无映射的事件类型返回 None。"""
|
||||
if event_type == "time.reunion":
|
||||
return "touched"
|
||||
if event_type == "time.user_care":
|
||||
if payload.get("care_kind") == "festival_lonely":
|
||||
return "melancholy"
|
||||
return "concerned"
|
||||
if event_type == "time.sentiment":
|
||||
trigger = payload.get("trigger_type", "")
|
||||
return _SENTIMENT_TRIGGER_MOOD.get(trigger, "wistful")
|
||||
return None
|
||||
|
||||
|
||||
def signal_intensity_delta(event_type: str, payload: dict[str, Any]) -> float:
|
||||
"""从事件 payload 提取 intensity delta(复用辰正既有强度,不重算)。"""
|
||||
if event_type == "time.reunion":
|
||||
return float(payload.get("reunion_intensity", 0.5))
|
||||
if event_type == "time.user_care":
|
||||
return float(payload.get("care_suggest", 0.5))
|
||||
if event_type == "time.sentiment":
|
||||
return _SENTIMENT_DEFAULT_DELTA
|
||||
return 0.0
|
||||
|
||||
|
||||
def apply_signal(
|
||||
state: EmotionState,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
baseline: EmotionState,
|
||||
) -> EmotionState:
|
||||
"""事件驱动状态转移。
|
||||
|
||||
- mood 变化时:intensity = ``baseline.intensity + delta``(新情绪从基线起跳)。
|
||||
- mood 不变时:intensity = ``state.intensity + delta``(累积)。
|
||||
- clamp 至 [0, 1]。
|
||||
- 无映射的事件(``map_signal_to_mood`` 返回 None)原样返回 state。
|
||||
"""
|
||||
new_mood = map_signal_to_mood(event_type, payload)
|
||||
if new_mood is None:
|
||||
return state
|
||||
delta = signal_intensity_delta(event_type, payload)
|
||||
base = state.intensity if new_mood == state.mood else baseline.intensity
|
||||
intensity = max(0.0, min(1.0, base + delta))
|
||||
return EmotionState(mood=new_mood, intensity=round(intensity, 3))
|
||||
|
||||
|
||||
def decay(state: EmotionState, baseline: EmotionState, elapsed_turns: float) -> EmotionState:
|
||||
"""intensity 向 baseline 指数衰减;与 baseline 的差距收敛到阈值内时 mood 回 baseline.mood。
|
||||
|
||||
``elapsed_turns <= 0`` 时原样返回 state(无衰减)。
|
||||
判断用相对差距(``decayed - baseline.intensity``)而非绝对阈值——否则当
|
||||
baseline.intensity 本身 > 阈值时(如默认 neutral 0.2),mood 永远不会回归。
|
||||
"""
|
||||
if elapsed_turns <= 0:
|
||||
return state
|
||||
factor = 0.5 ** (elapsed_turns / DECAY_HALFLIFE_TURNS)
|
||||
decayed = baseline.intensity + (state.intensity - baseline.intensity) * factor
|
||||
if decayed - baseline.intensity <= NEUTRAL_THRESHOLD:
|
||||
return EmotionState(mood=baseline.mood, intensity=baseline.intensity)
|
||||
return EmotionState(mood=state.mood, intensity=round(decayed, 3))
|
||||
|
||||
|
||||
def to_prompt_text(state: EmotionState) -> str:
|
||||
"""注入对话 system prompt 的情绪摘要(中文 label + 强度百分比)。"""
|
||||
label = MOOD_LABELS_ZH.get(state.mood, state.mood)
|
||||
pct = round(state.intensity * 100)
|
||||
return f"[当前情绪] {label}(强度 {pct}%)"
|
||||
@@ -1,97 +0,0 @@
|
||||
"""内驱力数据访问(好奇问题 + 探索性次生目标)。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
|
||||
|
||||
async def add_curiosities(session: AsyncSession, curiosities: list[CuriosityQuestion]) -> None:
|
||||
session.add_all(curiosities)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def get_curiosity(session: AsyncSession, curiosity_id: str) -> CuriosityQuestion | None:
|
||||
return await session.get(CuriosityQuestion, curiosity_id)
|
||||
|
||||
|
||||
async def list_curiosities(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
source: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[CuriosityQuestion], int]:
|
||||
conditions = [CuriosityQuestion.character_id == character_id]
|
||||
if source is not None:
|
||||
conditions.append(CuriosityQuestion.source == source)
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(CuriosityQuestion).where(*conditions)
|
||||
)
|
||||
).scalar_one()
|
||||
items = await session.execute(
|
||||
select(CuriosityQuestion)
|
||||
.where(*conditions)
|
||||
.order_by(CuriosityQuestion.created_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
async def add_subgoals(session: AsyncSession, subgoals: list[ExploratorySubgoal]) -> None:
|
||||
session.add_all(subgoals)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def get_subgoal(session: AsyncSession, subgoal_id: str) -> ExploratorySubgoal | None:
|
||||
return await session.get(ExploratorySubgoal, subgoal_id)
|
||||
|
||||
|
||||
async def list_subgoals(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[ExploratorySubgoal], int]:
|
||||
conditions = [ExploratorySubgoal.character_id == character_id]
|
||||
if status is not None:
|
||||
conditions.append(ExploratorySubgoal.status == status)
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(ExploratorySubgoal).where(*conditions)
|
||||
)
|
||||
).scalar_one()
|
||||
items = await session.execute(
|
||||
select(ExploratorySubgoal)
|
||||
.where(*conditions)
|
||||
.order_by(ExploratorySubgoal.created_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
async def list_approved_subgoals(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
limit: int = 5,
|
||||
) -> list[ExploratorySubgoal]:
|
||||
"""列出已批准(approved)的次生目标,按 created_at 升序排列。
|
||||
|
||||
供大周天自主探索执行阶段拾取。
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(ExploratorySubgoal)
|
||||
.where(
|
||||
ExploratorySubgoal.character_id == character_id,
|
||||
ExploratorySubgoal.status == "approved",
|
||||
)
|
||||
.order_by(ExploratorySubgoal.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -1,44 +0,0 @@
|
||||
"""演化候选数据访问。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
|
||||
|
||||
async def add_candidates(session: AsyncSession, candidates: list[EvolutionCandidate]) -> None:
|
||||
session.add_all(candidates)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def get_candidate(session: AsyncSession, candidate_id: str) -> EvolutionCandidate | None:
|
||||
return await session.get(EvolutionCandidate, candidate_id)
|
||||
|
||||
|
||||
async def list_candidates(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
candidate_type: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[EvolutionCandidate], int]:
|
||||
conditions = [EvolutionCandidate.character_id == character_id]
|
||||
if status is not None:
|
||||
conditions.append(EvolutionCandidate.status == status)
|
||||
if candidate_type is not None:
|
||||
conditions.append(EvolutionCandidate.type == candidate_type)
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(EvolutionCandidate).where(*conditions)
|
||||
)
|
||||
).scalar_one()
|
||||
items_result = await session.execute(
|
||||
select(EvolutionCandidate)
|
||||
.where(*conditions)
|
||||
.order_by(EvolutionCandidate.created_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items_result.scalars().all()), int(total or 0)
|
||||
@@ -13,7 +13,6 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
|
||||
# —— 太一枢 · 目标 ——
|
||||
@@ -102,23 +101,8 @@ async def add_perception_channel(
|
||||
# —— 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
async def list_world_entities(session: AsyncSession) -> list[WorldEntity]:
|
||||
result = await session.execute(select(WorldEntity).order_by(WorldEntity.updated_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_world_entity(session: AsyncSession, entity_id: str) -> WorldEntity | None:
|
||||
return await session.get(WorldEntity, entity_id)
|
||||
|
||||
|
||||
async def add_world_entity(session: AsyncSession, entity: WorldEntity) -> WorldEntity:
|
||||
session.add(entity)
|
||||
await session.flush()
|
||||
return entity
|
||||
|
||||
|
||||
# —— 洛书枢 · 计划 ——
|
||||
|
||||
|
||||
async def list_plans(session: AsyncSession) -> list[Plan]:
|
||||
result = await session.execute(select(Plan).order_by(Plan.updated_at.desc()))
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""人格数据访问。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
||||
|
||||
|
||||
async def get_personality(session: AsyncSession, personality_id: str) -> PersonalityVersion | None:
|
||||
return await session.get(PersonalityVersion, personality_id)
|
||||
|
||||
|
||||
async def get_latest_personality(
|
||||
session: AsyncSession, character_id: str
|
||||
) -> PersonalityVersion | None:
|
||||
result = await session.execute(
|
||||
select(PersonalityVersion)
|
||||
.where(PersonalityVersion.character_id == character_id)
|
||||
.order_by(PersonalityVersion.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_personality_versions(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[PersonalityVersion], int]:
|
||||
"""按版本号倒序分页返回某人物的人格版本历史,返回 (条目, 总数)。"""
|
||||
cond = PersonalityVersion.character_id == character_id
|
||||
total = int(
|
||||
(
|
||||
await session.execute(select(func.count()).select_from(PersonalityVersion).where(cond))
|
||||
).scalar_one()
|
||||
)
|
||||
result = await session.execute(
|
||||
select(PersonalityVersion)
|
||||
.where(cond)
|
||||
.order_by(PersonalityVersion.version.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
async def add_proposal(session: AsyncSession, proposal: PersonalityProposal) -> PersonalityProposal:
|
||||
session.add(proposal)
|
||||
await session.flush()
|
||||
return proposal
|
||||
|
||||
|
||||
async def get_proposal(session: AsyncSession, proposal_id: str) -> PersonalityProposal | None:
|
||||
return await session.get(PersonalityProposal, proposal_id)
|
||||
|
||||
|
||||
async def list_proposals(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
character_id: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[PersonalityProposal], int]:
|
||||
"""按创建时间倒序分页返回人格修改建议;可选按人物/状态过滤,返回 (条目, 总数)。"""
|
||||
conds = []
|
||||
if character_id is not None:
|
||||
conds.append(PersonalityProposal.character_id == character_id)
|
||||
if status is not None:
|
||||
conds.append(PersonalityProposal.status == status)
|
||||
count_stmt = select(func.count()).select_from(PersonalityProposal)
|
||||
list_stmt = select(PersonalityProposal)
|
||||
if conds:
|
||||
count_stmt = count_stmt.where(*conds)
|
||||
list_stmt = list_stmt.where(*conds)
|
||||
total = int((await session.execute(count_stmt)).scalar_one())
|
||||
result = await session.execute(
|
||||
list_stmt.order_by(PersonalityProposal.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
@@ -1,104 +0,0 @@
|
||||
"""世界模型运行时数据访问(api-spec §12,按人物隔离)。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
||||
|
||||
|
||||
async def list_entities(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
entity_type: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[CharacterWorldEntity], int]:
|
||||
conditions = [CharacterWorldEntity.character_id == character_id]
|
||||
if entity_type is not None:
|
||||
conditions.append(CharacterWorldEntity.type == entity_type)
|
||||
if status is not None:
|
||||
conditions.append(CharacterWorldEntity.status == status)
|
||||
total = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(CharacterWorldEntity).where(*conditions)
|
||||
)
|
||||
).scalar_one()
|
||||
items_result = await session.execute(
|
||||
select(CharacterWorldEntity)
|
||||
.where(*conditions)
|
||||
.order_by(CharacterWorldEntity.updated_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items_result.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
async def get_entity(session: AsyncSession, entity_id: str) -> CharacterWorldEntity | None:
|
||||
return await session.get(CharacterWorldEntity, entity_id)
|
||||
|
||||
|
||||
async def add_entity(session: AsyncSession, entity: CharacterWorldEntity) -> CharacterWorldEntity:
|
||||
session.add(entity)
|
||||
await session.flush()
|
||||
return entity
|
||||
|
||||
|
||||
async def list_relations(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[WorldRelation], int]:
|
||||
conditions = [WorldRelation.character_id == character_id]
|
||||
total = (
|
||||
await session.execute(select(func.count()).select_from(WorldRelation).where(*conditions))
|
||||
).scalar_one()
|
||||
items_result = await session.execute(
|
||||
select(WorldRelation)
|
||||
.where(*conditions)
|
||||
.order_by(WorldRelation.updated_at.desc())
|
||||
.offset(max(page - 1, 0) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(items_result.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
async def get_relation(session: AsyncSession, relation_id: str) -> WorldRelation | None:
|
||||
return await session.get(WorldRelation, relation_id)
|
||||
|
||||
|
||||
async def add_relation(session: AsyncSession, relation: WorldRelation) -> WorldRelation:
|
||||
session.add(relation)
|
||||
await session.flush()
|
||||
return relation
|
||||
|
||||
|
||||
async def delete_relation(session: AsyncSession, relation_id: str) -> None:
|
||||
"""删除世界关系。"""
|
||||
relation = await session.get(WorldRelation, relation_id)
|
||||
if relation:
|
||||
await session.delete(relation)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def list_all_entities(session: AsyncSession, character_id: str) -> list[CharacterWorldEntity]:
|
||||
"""获取人物的所有世界实体(无分页)。"""
|
||||
result = await session.execute(
|
||||
select(CharacterWorldEntity)
|
||||
.where(CharacterWorldEntity.character_id == character_id)
|
||||
.order_by(CharacterWorldEntity.updated_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_all_relations(session: AsyncSession, character_id: str) -> list[WorldRelation]:
|
||||
"""获取人物的所有关系(无分页)。"""
|
||||
result = await session.execute(
|
||||
select(WorldRelation)
|
||||
.where(WorldRelation.character_id == character_id)
|
||||
.order_by(WorldRelation.updated_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -17,7 +17,6 @@ class CharacterResponse(BaseModel):
|
||||
"language": "zh-CN",
|
||||
"profile": {"bio": "本地 MVP 默认人物。"},
|
||||
"model_policy": {"default_model_hint": "deepseek-v4-flash"},
|
||||
"current_personality_version_id": "personality_9b5f5b7e413442d0a2f9e61a2f4d7df5",
|
||||
"privacy_level": "normal",
|
||||
"owner_scope": "local-user",
|
||||
"is_system_template": False,
|
||||
@@ -36,7 +35,6 @@ class CharacterResponse(BaseModel):
|
||||
language: str = Field(description="人物默认语言。")
|
||||
profile: dict[str, Any] = Field(description="人物档案 JSON。")
|
||||
model_policy: dict[str, Any] = Field(description="人物模型策略。")
|
||||
current_personality_version_id: str | None = Field(description="当前人格版本 ID。")
|
||||
privacy_level: str = Field(description="隐私等级:normal / sensitive / local_only。")
|
||||
owner_scope: str = Field(description="归属范围;当前本地 MVP 为 local-user。")
|
||||
is_system_template: bool = Field(description="是否系统人物模板;active API 通常返回 false。")
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""内驱力 API schema(database-schema §22;/drive 端点)。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import OrmModel
|
||||
|
||||
|
||||
class CuriosityResponse(OrmModel):
|
||||
"""好奇问题响应(related_entities 已从 JSON 文本解析)。"""
|
||||
|
||||
id: str
|
||||
character_id: str
|
||||
cycle_id: str
|
||||
question: str
|
||||
source: str
|
||||
related_entities: list[str] = Field(default_factory=list)
|
||||
expected_value: float
|
||||
urgency: float
|
||||
risk_level: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SubgoalResponse(OrmModel):
|
||||
"""探索性次生目标响应。"""
|
||||
|
||||
id: str
|
||||
character_id: str
|
||||
cycle_id: str
|
||||
parent_goal_id: str | None = None
|
||||
curiosity_question_id: str
|
||||
title: str
|
||||
description: str
|
||||
expected_information_gain: float
|
||||
interruption_cost: float
|
||||
risk_level: str
|
||||
status: str
|
||||
reviewed_at: datetime | None = None
|
||||
reviewed_by: str
|
||||
created_at: datetime
|
||||
@@ -1,101 +0,0 @@
|
||||
"""人格 API schema。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PersonalityResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"id": "personality_9b5f5b7e413442d0a2f9e61a2f4d7df5",
|
||||
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
|
||||
"version": 1,
|
||||
"identity_profile": "魂镜默认人物,面向本地 MVP 联调。",
|
||||
"role_destiny": "帮助用户验证对话、记忆和运行时链路。",
|
||||
"personality_seed": "稳定、简洁、可靠。",
|
||||
"traits": {"tone": "calm", "helpfulness": 0.9},
|
||||
"expression_style": "使用简洁中文回答,必要时列出验证步骤。",
|
||||
"values": {"accuracy": "high", "privacy": "high"},
|
||||
"relationship_pattern": "以协作者方式与用户互动。",
|
||||
"emotion_baseline": {"mood": "neutral"},
|
||||
"reason": "初始化默认人格。",
|
||||
"evidence_ids": [],
|
||||
"created_by": "system",
|
||||
"created_at": "2026-06-19T09:00:00.000000Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
id: str = Field(description="人格版本 ID。")
|
||||
character_id: str = Field(description="所属人物 ID。")
|
||||
version: int = Field(description="人格版本号。")
|
||||
identity_profile: str = Field(description="身份画像。")
|
||||
role_destiny: str = Field(description="角色定位与使命。")
|
||||
personality_seed: str = Field(description="人格种子。")
|
||||
traits: dict[str, Any] = Field(description="人格特质。")
|
||||
expression_style: str = Field(description="表达风格。")
|
||||
values: dict[str, Any] = Field(description="价值偏好。")
|
||||
relationship_pattern: str = Field(description="关系模式。")
|
||||
emotion_baseline: dict[str, Any] = Field(description="情绪基线。")
|
||||
reason: str = Field(description="版本创建原因。")
|
||||
evidence_ids: list[str] = Field(description="证据 ID 列表。")
|
||||
created_by: str = Field(description="创建者。")
|
||||
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
|
||||
|
||||
|
||||
# 允许人工提议覆盖的人格字段(None 表示不改)。dict 字段落库为 JSON 文本。
|
||||
_PERSONALITY_STRING_FIELDS = (
|
||||
"identity_profile",
|
||||
"role_destiny",
|
||||
"personality_seed",
|
||||
"expression_style",
|
||||
"relationship_pattern",
|
||||
)
|
||||
_PERSONALITY_JSON_FIELDS = ("traits", "values", "emotion_baseline")
|
||||
|
||||
|
||||
class PersonalityProposalFields(BaseModel):
|
||||
"""人格字段覆盖建议;仅列允许编辑字段,未传(None)表示不改。"""
|
||||
|
||||
identity_profile: str | None = None
|
||||
role_destiny: str | None = None
|
||||
personality_seed: str | None = None
|
||||
traits: dict[str, Any] | None = None
|
||||
expression_style: str | None = None
|
||||
values: dict[str, Any] | None = None
|
||||
relationship_pattern: str | None = None
|
||||
emotion_baseline: dict[str, Any] | None = None
|
||||
|
||||
def has_any(self) -> bool:
|
||||
"""是否至少覆盖一个字段。"""
|
||||
return any(
|
||||
getattr(self, f) is not None
|
||||
for f in _PERSONALITY_STRING_FIELDS + _PERSONALITY_JSON_FIELDS
|
||||
)
|
||||
|
||||
|
||||
class ProposePersonalityUpdateRequest(BaseModel):
|
||||
"""提交人格修改建议(api-spec §11);建议进入 pending 待审核。"""
|
||||
|
||||
character_id: str | None = Field(default=None, description="人物 ID,省略取当前激活人物。")
|
||||
fields: PersonalityProposalFields = Field(description="需变更的人格字段覆盖。")
|
||||
reason: str = Field(min_length=1, description="修改原因,必填留痕。")
|
||||
|
||||
|
||||
class PersonalityProposalResponse(BaseModel):
|
||||
"""人格修改建议响应(api-spec §11)。"""
|
||||
|
||||
proposal_id: str = Field(description="建议 ID。")
|
||||
character_id: str = Field(description="所属人物 ID。")
|
||||
status: str = Field(description="建议状态,见契约字典 §10.6 candidate.status。")
|
||||
reason: str = Field(description="修改原因。")
|
||||
fields: dict[str, Any] = Field(description="实际覆盖的字段集合。")
|
||||
resulting_version_id: str | None = Field(
|
||||
default=None, description="审核通过后生成的人格版本 ID。"
|
||||
)
|
||||
reviewed_at: str | None = Field(default=None, description="审核时间。")
|
||||
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
|
||||
@@ -17,7 +17,6 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.models.idempotency import IdempotencyKey
|
||||
from app.models.xirang import Memory
|
||||
@@ -81,13 +80,15 @@ async def get_pivots_health(
|
||||
)
|
||||
)
|
||||
goals_n = await governance_repo.count(session, Goal)
|
||||
world_n = await governance_repo.count(session, WorldEntity)
|
||||
from app.domains.hetu.models import PersistentWorld
|
||||
|
||||
world_n = await governance_repo.count(session, PersistentWorld)
|
||||
plans_n = await governance_repo.count(session, Plan)
|
||||
perception_n = await governance_repo.count(session, PerceptionChannel)
|
||||
agents_n = await governance_repo.count(session, AgentRosterEntry)
|
||||
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
|
||||
character = await character_repo.get_active_character(session, owner_scope)
|
||||
has_personality = bool(character and character.current_personality_version_id)
|
||||
has_personality = bool(character and character.identity_baseline_json not in ("", "{}"))
|
||||
tool_enabled = True # 百工域常驻(乾坤可用性按端点区分)
|
||||
now = to_iso(utc_now())
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 PersonalityVersion
|
||||
from app.modules.character import (
|
||||
MODEL_POLICY_CREATE_DEFAULTS,
|
||||
SYSTEM_TEMPLATE_OWNER_SCOPE,
|
||||
@@ -17,7 +16,6 @@ from app.modules.character import (
|
||||
sanitize_profile,
|
||||
)
|
||||
from app.repositories import characters as character_repo
|
||||
from app.repositories import personality as personality_repo
|
||||
from app.repositories.json_utils import dumps, loads_dict
|
||||
from app.schemas.character import (
|
||||
ActivateCharacterResponse,
|
||||
@@ -100,25 +98,6 @@ async def create_default_character(
|
||||
return await create_character_from_template(session, template, owner_scope)
|
||||
|
||||
|
||||
async def ensure_character_personality(
|
||||
session: AsyncSession, character: Character
|
||||
) -> PersonalityVersion:
|
||||
from app.services.personality import create_personality_from_profile
|
||||
|
||||
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:
|
||||
personality = await create_personality_from_profile(session, character)
|
||||
if character.current_personality_version_id != personality.id:
|
||||
character.current_personality_version_id = personality.id
|
||||
await session.flush()
|
||||
return personality
|
||||
|
||||
|
||||
async def ensure_default_system_template(session: AsyncSession) -> Character:
|
||||
"""按 config/character_templates.yaml 确保系统模板存在且最新。
|
||||
@@ -135,7 +114,6 @@ async def ensure_default_system_template(session: AsyncSession) -> Character:
|
||||
# create 路径已由 _create_template_from_config 兜底 birthday,
|
||||
# sync 路径可能因旧数据缺失 birthday,再兜一次确保存在。
|
||||
await _ensure_default_birthday(session, template)
|
||||
await ensure_character_personality(session, template)
|
||||
return template
|
||||
|
||||
|
||||
@@ -160,7 +138,6 @@ async def ensure_all_system_templates(session: AsyncSession) -> list[Character]:
|
||||
else:
|
||||
await _sync_template_from_config(session, template, config)
|
||||
await _ensure_default_birthday(session, template)
|
||||
await ensure_character_personality(session, template)
|
||||
results.append(template)
|
||||
|
||||
return results
|
||||
@@ -267,7 +244,6 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
||||
if not configs:
|
||||
template = await ensure_default_system_template(session)
|
||||
character = await create_character_from_template(session, template, owner_scope)
|
||||
await _ensure_character_personality_from_template(session, template, character)
|
||||
return character
|
||||
|
||||
# 先确保所有系统模板在 DB 中存在
|
||||
@@ -278,7 +254,6 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
||||
config = configs[key]
|
||||
tpl = await _create_template_from_config(session, config)
|
||||
await _ensure_default_birthday(session, tpl)
|
||||
await ensure_character_personality(session, tpl)
|
||||
templates[key] = tpl
|
||||
|
||||
# 筛选 is_default=true 的配置 key
|
||||
@@ -295,19 +270,11 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
||||
session, tpl.id, owner_scope
|
||||
)
|
||||
if existing is not None:
|
||||
await ensure_character_personality(session, existing)
|
||||
if primary is None:
|
||||
primary = existing
|
||||
continue
|
||||
|
||||
character = await create_character_from_template(session, tpl, owner_scope)
|
||||
from app.services.personality import copy_personality_to_character
|
||||
|
||||
template_personality = await ensure_character_personality(session, tpl)
|
||||
personality = await copy_personality_to_character(
|
||||
session, template_personality, character.id
|
||||
)
|
||||
character.current_personality_version_id = personality.id
|
||||
await session.flush()
|
||||
|
||||
await record_audit_event(
|
||||
@@ -327,32 +294,11 @@ async def ensure_default_character_for_owner(session: AsyncSession, owner_scope:
|
||||
if primary is None:
|
||||
template = await ensure_default_system_template(session)
|
||||
primary = await create_character_from_template(session, template, owner_scope)
|
||||
template_personality = await ensure_character_personality(session, template)
|
||||
from app.services.personality import copy_personality_to_character
|
||||
|
||||
personality = await copy_personality_to_character(session, template_personality, primary.id)
|
||||
primary.current_personality_version_id = personality.id
|
||||
await session.flush()
|
||||
|
||||
return primary
|
||||
|
||||
|
||||
async def _ensure_character_personality_from_template(
|
||||
session: AsyncSession,
|
||||
template: Character,
|
||||
character: Character,
|
||||
) -> None:
|
||||
"""从模板复制人格到目标人物。"""
|
||||
from app.services.personality import copy_personality_to_character
|
||||
|
||||
template_personality = await ensure_character_personality(session, template)
|
||||
personality = await copy_personality_to_character(session, template_personality, character.id)
|
||||
character.current_personality_version_id = personality.id
|
||||
await session.flush()
|
||||
|
||||
|
||||
# —— 人物管理 API(api-spec §6)——
|
||||
|
||||
|
||||
async def get_owned_character(
|
||||
session: AsyncSession, character_id: str, owner_scope: str = settings.default_owner_scope
|
||||
@@ -364,12 +310,10 @@ async def get_owned_character(
|
||||
return character
|
||||
|
||||
|
||||
async def _personality_version_number(session: AsyncSession, character: Character) -> int:
|
||||
version_id = character.current_personality_version_id
|
||||
if not version_id:
|
||||
return 0
|
||||
personality = await personality_repo.get_personality(session, version_id)
|
||||
return personality.version if personality else 0
|
||||
def _personality_version_number(character: Character) -> int:
|
||||
"""人格版本随旧版本表退役——版本语义由魂籍(identity_record)承载,此处恒 0 占位。"""
|
||||
_ = character
|
||||
return 0
|
||||
|
||||
|
||||
async def _audit_character(session: AsyncSession, character: Character, action: str) -> None:
|
||||
@@ -435,7 +379,6 @@ async def create_character(
|
||||
)
|
||||
await character_repo.add_character(session, character)
|
||||
await _ensure_default_birthday(session, character)
|
||||
await ensure_character_personality(session, character)
|
||||
await _audit_character(session, character, "character.created_from_template")
|
||||
await session.commit()
|
||||
await session.refresh(character)
|
||||
@@ -488,7 +431,7 @@ async def list_characters_page(
|
||||
character_to_summary(
|
||||
character,
|
||||
memory_count=await character_repo.count_memories(session, character.id),
|
||||
personality_version=await _personality_version_number(session, character),
|
||||
personality_version=_personality_version_number(character),
|
||||
)
|
||||
for character in items
|
||||
]
|
||||
@@ -569,7 +512,6 @@ async def activate_character(
|
||||
await _deactivate_others(session, character, owner_scope)
|
||||
character.status = "active"
|
||||
character.activated_at = utc_now()
|
||||
await ensure_character_personality(session, character)
|
||||
await _audit_character(session, character, "character.activated")
|
||||
await session.commit()
|
||||
return ActivateCharacterResponse(active_character_id=character.id, status="active")
|
||||
@@ -599,7 +541,6 @@ async def resume_character(
|
||||
await _deactivate_others(session, character, owner_scope)
|
||||
character.status = "active"
|
||||
character.activated_at = utc_now()
|
||||
await ensure_character_personality(session, character)
|
||||
await _audit_character(session, character, "character.resumed")
|
||||
await session.commit()
|
||||
await session.refresh(character)
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""内驱力审核服务(次生目标 approve → 自动创建太一目标)。
|
||||
|
||||
审核 approve 时自动派生 Goal(太一枢任务级目标),
|
||||
延后 / 驳回仅标记不变更目标树。
|
||||
"""
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.ids import new_goal_id
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.models.governance import Goal
|
||||
from app.repositories import drive as drive_repo
|
||||
from app.repositories.json_utils import loads_list
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.drive import CuriosityResponse, SubgoalResponse
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
|
||||
async def list_curiosities(
|
||||
*,
|
||||
character_id: str,
|
||||
source: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Page[CuriosityResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
items, total = await drive_repo.list_curiosities(
|
||||
session, character_id, source=source, page=page, page_size=page_size
|
||||
)
|
||||
return Page(
|
||||
items=[_curiosity_to_response(i) for i in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def list_subgoals(
|
||||
*,
|
||||
character_id: str,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Page[SubgoalResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
items, total = await drive_repo.list_subgoals(
|
||||
session, character_id, status=status, page=page, page_size=page_size
|
||||
)
|
||||
return Page(
|
||||
items=[_subgoal_to_response(i) for i in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def approve_subgoal(subgoal_id: str) -> SubgoalResponse:
|
||||
return await _review_subgoal(subgoal_id, "approved", decision="auto", create_goal=True)
|
||||
|
||||
|
||||
async def defer_subgoal(subgoal_id: str) -> SubgoalResponse:
|
||||
return await _review_subgoal(subgoal_id, "deferred", decision="auto", create_goal=False)
|
||||
|
||||
|
||||
async def reject_subgoal(subgoal_id: str) -> SubgoalResponse:
|
||||
return await _review_subgoal(subgoal_id, "rejected", decision="denied", create_goal=False)
|
||||
|
||||
|
||||
async def _review_subgoal(
|
||||
subgoal_id: str, target_status: str, *, decision: str, create_goal: bool
|
||||
) -> SubgoalResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
sub = await drive_repo.get_subgoal(session, subgoal_id)
|
||||
if sub is None:
|
||||
raise AppError("not_found", "探索性次生目标不存在。")
|
||||
if sub.status != "candidate":
|
||||
raise AppError("conflict", f"次生目标当前状态为 {sub.status},不可审核。")
|
||||
sub.status = target_status
|
||||
sub.reviewed_at = utc_now()
|
||||
sub.reviewed_by = "user"
|
||||
|
||||
# approve 时自动创建太一任务级目标
|
||||
if create_goal and sub.character_id:
|
||||
goal = Goal(
|
||||
id=new_goal_id(),
|
||||
character_id=sub.character_id,
|
||||
title=sub.title,
|
||||
description=sub.description or "由次生目标自动生成",
|
||||
level="task",
|
||||
type="exploration",
|
||||
status="active",
|
||||
)
|
||||
session.add(goal)
|
||||
|
||||
await record_audit_event(
|
||||
session,
|
||||
action=f"drive.subgoal.{target_status}",
|
||||
actor="user",
|
||||
risk_level=sub.risk_level,
|
||||
decision=decision,
|
||||
character_id=sub.character_id,
|
||||
input_summary=sub.title[:200],
|
||||
resource_type="exploratory_subgoal",
|
||||
resource_id=sub.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(sub)
|
||||
return _subgoal_to_response(sub)
|
||||
|
||||
|
||||
def _curiosity_to_response(c: CuriosityQuestion) -> CuriosityResponse:
|
||||
return CuriosityResponse(
|
||||
id=c.id,
|
||||
character_id=c.character_id,
|
||||
cycle_id=c.cycle_id,
|
||||
question=c.question,
|
||||
source=c.source,
|
||||
related_entities=loads_list(c.related_entities_json),
|
||||
expected_value=c.expected_value,
|
||||
urgency=c.urgency,
|
||||
risk_level=c.risk_level,
|
||||
created_at=c.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _subgoal_to_response(s: ExploratorySubgoal) -> SubgoalResponse:
|
||||
return SubgoalResponse(
|
||||
id=s.id,
|
||||
character_id=s.character_id,
|
||||
cycle_id=s.cycle_id,
|
||||
parent_goal_id=s.parent_goal_id,
|
||||
curiosity_question_id=s.curiosity_question_id,
|
||||
title=s.title,
|
||||
description=s.description,
|
||||
expected_information_gain=s.expected_information_gain,
|
||||
interruption_cost=s.interruption_cost,
|
||||
risk_level=s.risk_level,
|
||||
status=s.status,
|
||||
reviewed_at=s.reviewed_at,
|
||||
reviewed_by=s.reviewed_by,
|
||||
created_at=s.created_at,
|
||||
)
|
||||
@@ -14,7 +14,6 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.modules.runtime.events import Event
|
||||
from app.repositories import governance as governance_repo
|
||||
@@ -26,7 +25,6 @@ from app.schemas.admin import (
|
||||
CreateGoalRequest,
|
||||
CreatePerceptionChannelRequest,
|
||||
CreatePlanRequest,
|
||||
CreateWorldEntityRequest,
|
||||
GoalNode,
|
||||
PerceptionChannelResponse,
|
||||
PlanSummaryResponse,
|
||||
@@ -34,8 +32,6 @@ from app.schemas.admin import (
|
||||
UpdateGoalRequest,
|
||||
UpdatePerceptionChannelRequest,
|
||||
UpdatePlanRequest,
|
||||
UpdateWorldEntityRequest,
|
||||
WorldEntityResponse,
|
||||
)
|
||||
from app.schemas.safety import (
|
||||
ToolCallDetail,
|
||||
@@ -46,7 +42,6 @@ from app.services.serializers import (
|
||||
goal_to_response,
|
||||
perception_channel_to_response,
|
||||
plan_to_response,
|
||||
world_entity_to_response,
|
||||
)
|
||||
|
||||
# —— 太一枢 · 目标 ——
|
||||
@@ -281,76 +276,8 @@ async def delete_perception_channel(channel_id: str) -> None:
|
||||
# —— 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
async def list_world_entities() -> list[WorldEntityResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entities = await governance_repo.list_world_entities(session)
|
||||
return [world_entity_to_response(entity) for entity in entities]
|
||||
|
||||
|
||||
async def create_world_entity(
|
||||
payload: CreateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = WorldEntity(
|
||||
type=payload.type,
|
||||
name=payload.name,
|
||||
attributes_json=dumps(payload.attributes),
|
||||
state_type=payload.state_type,
|
||||
confidence=payload.confidence,
|
||||
sources_json=dumps(payload.sources),
|
||||
)
|
||||
await governance_repo.add_world_entity(session, entity)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="world.created",
|
||||
actor="user",
|
||||
input_summary=entity.name,
|
||||
resource_type="world_entity",
|
||||
resource_id=entity.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entity)
|
||||
return world_entity_to_response(entity)
|
||||
|
||||
|
||||
async def update_world_entity(
|
||||
entity_id: str, payload: UpdateWorldEntityRequest
|
||||
) -> WorldEntityResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = await governance_repo.get_world_entity(session, entity_id)
|
||||
if entity is None:
|
||||
raise AppError("not_found", "世界实体不存在。")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(entity, key, value)
|
||||
entity.updated_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="world.updated",
|
||||
actor="user",
|
||||
input_summary=entity.name,
|
||||
resource_type="world_entity",
|
||||
resource_id=entity.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entity)
|
||||
return world_entity_to_response(entity)
|
||||
|
||||
|
||||
async def delete_world_entity(entity_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = await governance_repo.get_world_entity(session, entity_id)
|
||||
if entity is None:
|
||||
raise AppError("not_found", "世界实体不存在。")
|
||||
await session.delete(entity)
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="world.deleted",
|
||||
actor="user",
|
||||
input_summary=entity.name,
|
||||
resource_type="world_entity",
|
||||
resource_id=entity.id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— 洛书枢 · 计划 ——
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
"""人格服务。"""
|
||||
|
||||
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)
|
||||
@@ -12,13 +12,10 @@ from app.models.governance import (
|
||||
Goal,
|
||||
PerceptionChannel,
|
||||
Plan,
|
||||
WorldEntity,
|
||||
)
|
||||
from app.models.imports import CharacterImportJob, ImportItem
|
||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
||||
from app.models.runtime import Event, Run
|
||||
from app.models.safety import AuditEvent
|
||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
||||
from app.repositories.json_utils import loads_dict, loads_list
|
||||
from app.schemas.admin import (
|
||||
AgentRosterResponse,
|
||||
@@ -26,20 +23,12 @@ from app.schemas.admin import (
|
||||
GoalNode,
|
||||
PerceptionChannelResponse,
|
||||
PlanSummaryResponse,
|
||||
WorldEntityResponse,
|
||||
)
|
||||
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
|
||||
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
|
||||
from app.schemas.imports import ImportItemResponse, ImportJobResponse
|
||||
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
|
||||
from app.schemas.preferences import GenerationPreferencesResponse
|
||||
from app.schemas.runtime import EventResponse, RunResponse
|
||||
from app.schemas.world import (
|
||||
WorldEntityResponse as CharacterWorldEntityResponse,
|
||||
)
|
||||
from app.schemas.world import (
|
||||
WorldRelationResponse,
|
||||
)
|
||||
|
||||
|
||||
def _check_avatar(character_id: str) -> str | None:
|
||||
@@ -60,7 +49,6 @@ def character_to_response(character: Character) -> CharacterResponse:
|
||||
language=character.language,
|
||||
profile=loads_dict(character.profile_json),
|
||||
model_policy=loads_dict(character.model_policy_json),
|
||||
current_personality_version_id=character.current_personality_version_id,
|
||||
privacy_level=character.privacy_level,
|
||||
owner_scope=character.owner_scope,
|
||||
is_system_template=character.is_system_template,
|
||||
@@ -86,37 +74,6 @@ def character_to_summary(
|
||||
)
|
||||
|
||||
|
||||
def personality_to_response(personality: PersonalityVersion) -> PersonalityResponse:
|
||||
return PersonalityResponse(
|
||||
id=personality.id,
|
||||
character_id=personality.character_id,
|
||||
version=personality.version,
|
||||
identity_profile=personality.identity_profile,
|
||||
role_destiny=personality.role_destiny,
|
||||
personality_seed=personality.personality_seed,
|
||||
traits=loads_dict(personality.traits),
|
||||
expression_style=personality.expression_style,
|
||||
values=loads_dict(personality.values),
|
||||
relationship_pattern=personality.relationship_pattern,
|
||||
emotion_baseline=loads_dict(personality.emotion_baseline),
|
||||
reason=personality.reason,
|
||||
evidence_ids=loads_list(personality.evidence_ids_json),
|
||||
created_by=personality.created_by,
|
||||
created_at=to_iso(personality.created_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def proposal_to_response(proposal: PersonalityProposal) -> PersonalityProposalResponse:
|
||||
return PersonalityProposalResponse(
|
||||
proposal_id=proposal.id,
|
||||
character_id=proposal.character_id,
|
||||
status=proposal.status,
|
||||
reason=proposal.reason,
|
||||
fields=loads_dict(proposal.fields_json),
|
||||
resulting_version_id=proposal.resulting_version_id,
|
||||
reviewed_at=to_iso(proposal.reviewed_at),
|
||||
created_at=to_iso(proposal.created_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def run_to_response(run: Run) -> RunResponse:
|
||||
@@ -186,51 +143,6 @@ def perception_channel_to_response(channel: PerceptionChannel) -> PerceptionChan
|
||||
)
|
||||
|
||||
|
||||
def world_entity_to_response(entity: WorldEntity) -> WorldEntityResponse:
|
||||
return WorldEntityResponse(
|
||||
id=entity.id,
|
||||
type=entity.type,
|
||||
name=entity.name,
|
||||
attributes=loads_dict(entity.attributes_json),
|
||||
state_type=entity.state_type,
|
||||
confidence=entity.confidence,
|
||||
sources=loads_list(entity.sources_json),
|
||||
updated_at=to_iso(entity.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def character_world_entity_to_response(
|
||||
entity: CharacterWorldEntity,
|
||||
) -> CharacterWorldEntityResponse:
|
||||
"""人物运行时世界实体(api-spec §12);与治理版 WorldEntity 按 character_id 隔离。"""
|
||||
return CharacterWorldEntityResponse(
|
||||
id=entity.id,
|
||||
character_id=entity.character_id,
|
||||
type=entity.type,
|
||||
name=entity.name,
|
||||
attributes=loads_dict(entity.attributes_json),
|
||||
state_type=entity.state_type,
|
||||
status=entity.status,
|
||||
confidence=entity.confidence,
|
||||
evidence_ids=loads_list(entity.evidence_ids_json),
|
||||
sources=loads_list(entity.sources_json),
|
||||
created_at=to_iso(entity.created_at) or "",
|
||||
updated_at=to_iso(entity.updated_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def world_relation_to_response(relation: WorldRelation) -> WorldRelationResponse:
|
||||
return WorldRelationResponse(
|
||||
id=relation.id,
|
||||
character_id=relation.character_id,
|
||||
subject_id=relation.subject_id,
|
||||
predicate=relation.predicate,
|
||||
object_id=relation.object_id,
|
||||
confidence=relation.confidence,
|
||||
evidence_ids=loads_list(relation.evidence_ids_json),
|
||||
created_at=to_iso(relation.created_at) or "",
|
||||
updated_at=to_iso(relation.updated_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def plan_to_response(plan: Plan) -> PlanSummaryResponse:
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
"""世界模型服务(河图枢运行时,api-spec §12)。
|
||||
|
||||
按人物视角隔离:所有读写经 ``resolve_active_character`` 绑定当前激活人物。
|
||||
自动抽取(世界引擎)属 v1 半自动留后项,本层先承载手动 CRUD。
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
||||
from app.repositories import world as world_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.world import (
|
||||
WorldEntityCreateRequest,
|
||||
WorldEntityResponse,
|
||||
WorldGraphResponse,
|
||||
WorldRelationCreateRequest,
|
||||
WorldRelationResponse,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.characters import resolve_active_character
|
||||
from app.services.serializers import (
|
||||
character_world_entity_to_response,
|
||||
world_relation_to_response,
|
||||
)
|
||||
|
||||
|
||||
async def list_entities_page(
|
||||
character_id: str | None = None,
|
||||
*,
|
||||
entity_type: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> Page[WorldEntityResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
|
||||
safe_page = max(page, 1)
|
||||
safe_size = min(max(page_size, 1), 100)
|
||||
items, total = await world_repo.list_entities(
|
||||
session,
|
||||
character.id,
|
||||
entity_type=entity_type,
|
||||
page=safe_page,
|
||||
page_size=safe_size,
|
||||
)
|
||||
return Page(
|
||||
items=[character_world_entity_to_response(item) for item in items],
|
||||
page=safe_page,
|
||||
page_size=safe_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def get_entity_by_id(
|
||||
entity_id: str, owner_scope: str = settings.default_owner_scope
|
||||
) -> WorldEntityResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await resolve_active_character(session, None, owner_scope=owner_scope)
|
||||
entity = await world_repo.get_entity(session, entity_id)
|
||||
if entity is None or entity.character_id != character.id:
|
||||
raise AppError("not_found", "世界实体不存在。")
|
||||
return character_world_entity_to_response(entity)
|
||||
|
||||
|
||||
async def create_entity(
|
||||
payload: WorldEntityCreateRequest, owner_scope: str = settings.default_owner_scope
|
||||
) -> WorldEntityResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await resolve_active_character(
|
||||
session, payload.character_id, owner_scope=owner_scope
|
||||
)
|
||||
entity = CharacterWorldEntity(
|
||||
character_id=character.id,
|
||||
type=payload.type,
|
||||
name=payload.name,
|
||||
attributes_json=dumps(payload.attributes),
|
||||
state_type=payload.state_type,
|
||||
confidence=payload.confidence,
|
||||
evidence_ids_json=dumps(payload.evidence_ids),
|
||||
sources_json=dumps(payload.sources),
|
||||
)
|
||||
await world_repo.add_entity(session, entity)
|
||||
await _audit(session, character.id, "world.entity.created", entity.name, entity.id)
|
||||
await session.commit()
|
||||
await session.refresh(entity)
|
||||
return character_world_entity_to_response(entity)
|
||||
|
||||
|
||||
async def list_relations_page(
|
||||
character_id: str | None = None,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> Page[WorldRelationResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
|
||||
safe_page = max(page, 1)
|
||||
safe_size = min(max(page_size, 1), 100)
|
||||
items, total = await world_repo.list_relations(
|
||||
session, character.id, page=safe_page, page_size=safe_size
|
||||
)
|
||||
return Page(
|
||||
items=[world_relation_to_response(item) for item in items],
|
||||
page=safe_page,
|
||||
page_size=safe_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def create_relation(
|
||||
payload: WorldRelationCreateRequest, owner_scope: str = settings.default_owner_scope
|
||||
) -> WorldRelationResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await resolve_active_character(
|
||||
session, payload.character_id, owner_scope=owner_scope
|
||||
)
|
||||
subject = await _ensure_owned_entity(session, character.id, payload.subject_id, "主体")
|
||||
obj = await _ensure_owned_entity(session, character.id, payload.object_id, "客体")
|
||||
relation = WorldRelation(
|
||||
character_id=character.id,
|
||||
subject_id=subject.id,
|
||||
predicate=payload.predicate,
|
||||
object_id=obj.id,
|
||||
confidence=payload.confidence,
|
||||
evidence_ids_json=dumps(payload.evidence_ids),
|
||||
)
|
||||
await world_repo.add_relation(session, relation)
|
||||
await _audit(
|
||||
session, character.id, "world.relation.created", relation.predicate, relation.id
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(relation)
|
||||
return world_relation_to_response(relation)
|
||||
|
||||
|
||||
async def delete_relation(
|
||||
relation_id: str, owner_scope: str = settings.default_owner_scope
|
||||
) -> None:
|
||||
"""删除世界关系。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await resolve_active_character(session, None, owner_scope=owner_scope)
|
||||
relation = await world_repo.get_relation(session, relation_id)
|
||||
if relation is None or relation.character_id != character.id:
|
||||
raise AppError("not_found", "关系不存在。")
|
||||
await world_repo.delete_relation(session, relation_id)
|
||||
await _audit(
|
||||
session, character.id, "world.relation.deleted", relation.predicate, relation_id
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_graph(
|
||||
character_id: str | None = None,
|
||||
*,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> WorldGraphResponse:
|
||||
"""返回人物的完整世界图谱(无分页,所有节点与边),供前端渲染力导向图。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await resolve_active_character(session, character_id, owner_scope=owner_scope)
|
||||
entities = await world_repo.list_all_entities(session, character.id)
|
||||
relations = await world_repo.list_all_relations(session, character.id)
|
||||
return WorldGraphResponse(
|
||||
nodes=[character_world_entity_to_response(e) for e in entities],
|
||||
edges=[world_relation_to_response(r) for r in relations],
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_owned_entity(
|
||||
session: AsyncSession, character_id: str, entity_id: str, role: str
|
||||
) -> CharacterWorldEntity:
|
||||
entity = await world_repo.get_entity(session, entity_id)
|
||||
if entity is None or entity.character_id != character_id:
|
||||
raise AppError("not_found", f"关系{role}实体不存在。")
|
||||
return entity
|
||||
|
||||
|
||||
async def _audit(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
action: str,
|
||||
summary: str,
|
||||
resource_id: str,
|
||||
) -> None:
|
||||
await record_audit_event(
|
||||
session,
|
||||
action=action,
|
||||
character_id=character_id,
|
||||
actor="user",
|
||||
decision="auto",
|
||||
input_summary=summary,
|
||||
resource_type="world_entity" if "entity" in action else "world_relation",
|
||||
resource_id=resource_id,
|
||||
)
|
||||
Reference in new issue
Block a user