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:
chenxin committed 2026-08-27 20:10:11 +08:00
1 parent 684261f4b4
commit 7e23d6fa84
108 files changed
+2600 -9732

No files matched your search

+9 -6
View File
@@ -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)
-31
View File
@@ -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 洛书枢 · 计划 ——
-73
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
"""河图世界路由(域实现见 app/domains/hetu/api.py)。"""
from app.domains.hetu.api import world_router
__all__ = ["world_router"]
-122
View File
@@ -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
)
+72
View File
@@ -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)
-95
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
"""缘契路由(域实现见 app/domains/yuanqi/api.py)。"""
from app.domains.yuanqi.api import cov_router
__all__ = ["cov_router"]
-70
View File
@@ -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:
+5
View File
@@ -0,0 +1,5 @@
"""河图世界域:境界(persistent_worlds)、世界实体与关系、境界正史(M6)。"""
from app.domains.hetu import models, service # noqa: F401
__all__ = ["models", "service"]
+108
View File
@@ -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)
+74
View File
@@ -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)
+238
View File
@@ -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 "{}"),
}
+212
View File
@@ -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,
}
+49 -8
View File
@@ -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
# ── 移栽 ──────────────────────────────────────────────
+6
View File
@@ -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
+10
View File
@@ -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"]
+108
View File
@@ -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)
+59
View File
@@ -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)
+278
View File
@@ -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,
}
+11 -14
View File
@@ -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",
]
+1 -2
View File
@@ -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)
-54
View File
@@ -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)
-50
View File
@@ -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
)
-18
View File
@@ -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 关联太一枢目标,可空。"""
+22 -1
View File
@@ -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))
-55
View File
@@ -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)
-58
View File
@@ -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
)
+2
View File
@@ -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
View File
@@ -1 +0,0 @@
"""大周天内驱力领域纯逻辑(造消息 / 解析好奇问题与次生目标草稿,禁止 IO)。"""
-127
View File
@@ -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))
-55
View File
@@ -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)
-18
View File
@@ -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",
]
-57
View File
@@ -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.
-149
View File
@@ -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}%)"
-97
View File
@@ -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())
-44
View File
@@ -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)
-16
View File
@@ -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()))
-84
View File
@@ -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
-104
View File
@@ -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())
-2
View File
@@ -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。")
-41
View File
@@ -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
-101
View File
@@ -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 字符串。")
+4 -3
View File
@@ -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())
+5 -64
View File
@@ -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)
-143
View File
@@ -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,
)
-73
View File
@@ -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()
# —— 洛书枢 · 计划 ——
-432
View File
@@ -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)
-88
View File
@@ -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:
-198
View File
@@ -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,
)