M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)

This commit is contained in:
chenxin committed 2026-08-27 09:16:32 +08:00
1 parent 2a640a901b
commit 9c98c2f85e
69 files changed
+1193 -6033

No files matched your search

@@ -0,0 +1,102 @@
"""息壤记忆域:三级记忆表重建与触发/实录,退役旧记忆表
Revision ID: 004
Revises: 003
Create Date: 2026-08-22
旧 memories/memory_links 随旧记忆管线退役整体删除(开发数据丢弃语义,data-model §9),
新表按 app/models/xirang.py 重建。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '004'
down_revision: Union[str, Sequence[str], None] = '003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table('memory_links')
op.drop_table('memories')
op.create_table('memories',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('tier', sa.String(length=20), nullable=False),
sa.Column('type', sa.String(length=40), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('source', sa.String(length=20), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('source_refs_json', sa.Text(), nullable=False),
sa.Column('sensitivity', sa.String(length=20), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('vitality', sa.Float(), nullable=False),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('realm', sa.String(length=40), nullable=False),
sa.Column('prev_version_id', sa.String(length=80), nullable=True),
sa.Column('recall_count', sa.Integer(), nullable=False),
sa.Column('last_recalled_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_memories_character_id'), 'memories', ['character_id'], unique=False)
op.create_index(op.f('ix_memories_tier'), 'memories', ['tier'], unique=False)
op.create_index(op.f('ix_memories_type'), 'memories', ['type'], unique=False)
op.create_index(op.f('ix_memories_status'), 'memories', ['status'], unique=False)
op.create_index(op.f('ix_memories_prev_version_id'), 'memories', ['prev_version_id'], unique=False)
op.create_table('memory_triggers',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('memory_id', sa.String(length=80), nullable=False),
sa.Column('kind', sa.String(length=20), nullable=False),
sa.Column('pattern', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('factual_records',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('canonization_id', sa.String(length=80), nullable=False),
sa.Column('evidence_json', sa.Text(), nullable=False),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
op.drop_table('factual_records')
op.drop_table('memory_triggers')
op.drop_table('memories')
op.create_table('memories',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=False),
sa.Column('title', sa.String(length=200), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('importance', sa.Float(), nullable=False),
sa.Column('sensitivity', sa.String(length=20), nullable=False),
sa.Column('temporal_context_json', sa.Text(), nullable=False),
sa.Column('evidence_ids_json', sa.Text(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('memory_links',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('from_memory_id', sa.String(length=80), nullable=False),
sa.Column('to_memory_id', sa.String(length=80), nullable=False),
sa.Column('relation', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['from_memory_id'], ['memories.id'], ),
sa.ForeignKeyConstraint(['to_memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
+2 -6
View File
@@ -11,8 +11,6 @@ from app.api.v1.endpoints import (
characters, characters,
drive, drive,
events, events,
evolution,
extraction,
goals, goals,
health, health,
imports, imports,
@@ -22,7 +20,6 @@ from app.api.v1.endpoints import (
lorebooks, lorebooks,
mirror_trees, mirror_trees,
mcp, mcp,
memory,
personality, personality,
runs, runs,
safety, safety,
@@ -31,6 +28,7 @@ from app.api.v1.endpoints import (
skills, skills,
tools, tools,
world, world,
xirang,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -43,8 +41,8 @@ api_router.include_router(kernel_runs.router)
api_router.include_router(embryo_drafts.router) api_router.include_router(embryo_drafts.router)
api_router.include_router(jingsheng_characters.router) api_router.include_router(jingsheng_characters.router)
api_router.include_router(mirror_trees.router) 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(mirror_trees.node_router)
api_router.include_router(memory.router)
api_router.include_router(lorebooks.router) api_router.include_router(lorebooks.router)
api_router.include_router(personality.router) api_router.include_router(personality.router)
api_router.include_router(tools.router) api_router.include_router(tools.router)
@@ -55,10 +53,8 @@ api_router.include_router(sandbox_files.router)
api_router.include_router(settings.router) api_router.include_router(settings.router)
api_router.include_router(world.router) api_router.include_router(world.router)
api_router.include_router(imports.router) api_router.include_router(imports.router)
api_router.include_router(evolution.router)
api_router.include_router(drive.router) api_router.include_router(drive.router)
api_router.include_router(events.router) api_router.include_router(events.router)
api_router.include_router(extraction.router)
api_router.include_router(goals.router) api_router.include_router(goals.router)
api_router.include_router(admin.router) api_router.include_router(admin.router)
api_router.include_router(audio.router) api_router.include_router(audio.router)
+4 -4
View File
@@ -387,7 +387,7 @@ async def admin_tenants_overview() -> list[_TenantUserOverview]:
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.memory import Memory from app.models.xirang import Memory
from app.models.user import User from app.models.user import User
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@@ -406,7 +406,7 @@ async def admin_tenants_overview() -> list[_TenantUserOverview]:
mem_count = await session.scalar( mem_count = await session.scalar(
select(func.count()) select(func.count())
.select_from(Memory) .select_from(Memory)
.join(Character, Memory.character_id == Character.id) .join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned")
.where( .where(
Character.owner_scope == user.id, Character.owner_scope == user.id,
Character.is_system_template.is_(False), Character.is_system_template.is_(False),
@@ -454,13 +454,13 @@ async def admin_tenant_memories(user_id: str) -> list[dict]:
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.character import Character from app.models.character import Character
from app.models.memory import Memory from app.models.xirang import Memory
from app.services.serializers import memory_to_response from app.services.serializers import memory_to_response
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
select(Memory) select(Memory)
.join(Character, Memory.character_id == Character.id) .join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned")
.where( .where(
Character.owner_scope == user_id, Character.owner_scope == user_id,
Character.is_system_template.is_(False), Character.is_system_template.is_(False),
-60
View File
@@ -1,60 +0,0 @@
"""演化候选审核 API。"""
from fastapi import APIRouter, Query
from app.schemas.common import Page
from app.schemas.evolution import EvolutionCandidateResponse
from app.services.evolution import (
approve_evolution_candidate,
list_evolution_candidates,
reject_evolution_candidate,
)
router = APIRouter(tags=["evolution"])
@router.get(
"/characters/{character_id}/evolution-candidates",
response_model=Page[EvolutionCandidateResponse],
)
async def list_evolution_candidates_endpoint(
character_id: str,
status: str | None = Query(
default=None, description="按状态过滤:pending/approved/rejected/applied"
),
candidate_type: str | None = Query(
default=None, alias="type", description="按候选类型过滤(契约字典 §12.1)"
),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[EvolutionCandidateResponse]:
"""列出某人物的演化候选(大周天生成 + 审核状态)。"""
return await list_evolution_candidates(
character_id=character_id,
status=status,
candidate_type=candidate_type,
page=page,
page_size=page_size,
)
@router.post(
"/evolution-candidates/{candidate_id}/approve",
response_model=EvolutionCandidateResponse,
)
async def approve_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
"""审核通过演化候选:派生到下游(人格版本/程序记忆等),status → applied。"""
return await approve_evolution_candidate(candidate_id)
@router.post(
"/evolution-candidates/{candidate_id}/reject",
response_model=EvolutionCandidateResponse,
)
async def reject_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
"""驳回演化候选:status → rejected,不派生下游。"""
return await reject_evolution_candidate(candidate_id)
@@ -1,90 +0,0 @@
"""大周天抽取候选审核 API(记忆候选 + 世界实体候选)。"""
from fastapi import APIRouter, Query
from app.schemas.candidates import MemoryCandidateResponse
from app.schemas.common import Page
from app.schemas.world import WorldEntityResponse
from app.services.extraction import (
approve_memory_proposal,
approve_world_entity,
list_memory_proposals,
list_world_entities_pending,
reject_memory_proposal,
reject_world_entity,
)
router = APIRouter(tags=["extraction"])
@router.get(
"/characters/{character_id}/memory-candidates",
response_model=Page[MemoryCandidateResponse],
)
async def list_memory_candidates_endpoint(
character_id: str,
source: str | None = Query(
default=None, description="来源过滤:cycle(大周天)/ import(资料导入)"
),
status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[MemoryCandidateResponse]:
"""列出记忆候选(大周天 + 资料导入统一)。"""
return await list_memory_proposals(
character_id=character_id,
source=source,
status=status,
page=page,
page_size=page_size,
)
@router.post(
"/memory-candidates/{candidate_id}/approve",
response_model=MemoryCandidateResponse,
)
async def approve_memory_candidate_endpoint(
candidate_id: str,
) -> MemoryCandidateResponse:
"""通过记忆候选:派生为 memories 记录(source=big_cycle)。"""
return await approve_memory_proposal(candidate_id)
@router.post(
"/memory-candidates/{candidate_id}/reject",
response_model=MemoryCandidateResponse,
)
async def reject_memory_candidate_endpoint(
candidate_id: str,
) -> MemoryCandidateResponse:
"""驳回记忆候选。"""
return await reject_memory_proposal(candidate_id)
@router.get(
"/characters/{character_id}/world/entity-proposals",
response_model=Page[WorldEntityResponse],
)
async def list_world_entity_proposals_endpoint(
character_id: str,
status: str | None = Query(default="pending", description="默认 pending(候选)"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[WorldEntityResponse]:
"""列出世界实体候选(大周天抽取,status=pending 待审核)。"""
return await list_world_entities_pending(
character_id=character_id, status=status, page=page, page_size=page_size
)
@router.post("/world/entities/{entity_id}/approve", response_model=WorldEntityResponse)
async def approve_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
"""通过世界实体候选:status → confirmed 进入世界模型。"""
return await approve_world_entity(entity_id)
@router.post("/world/entities/{entity_id}/reject", response_model=WorldEntityResponse)
async def reject_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
"""驳回世界实体候选。"""
return await reject_world_entity(entity_id)
-73
View File
@@ -1,73 +0,0 @@
"""记忆 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.common import Page
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
from app.services.idempotency import run_idempotent
from app.services.memory import create_memory, delete_memory, list_memory_page, update_memory
router = APIRouter(prefix="/memory", tags=["memory"])
@router.get("", response_model=Page[MemoryResponse])
async def list_memory(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
memory_type: str | None = Query(
default=None,
alias="type",
description="按类型过滤(如 user/assistant/system,契约字典 memory.type)。",
),
source: str | None = Query(
default=None,
description="按来源过滤(如 user_explicit/big_cycle/import,契约字典 memory.source)。",
),
current_user: CurrentUser = Depends(get_current_user),
) -> Page[MemoryResponse]:
return await list_memory_page(
page=page,
page_size=page_size,
memory_type=memory_type,
source=source,
owner_scope=current_user.owner_scope,
)
@router.post("", response_model=MemoryResponse)
async def create_memory_api(
payload: MemoryCreateRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=MemoryResponse,
resource_type="memory",
handler=lambda session: create_memory(
session, payload, owner_scope=current_user.owner_scope
),
)
@router.patch("/{memory_id}", response_model=MemoryResponse)
async def update_memory_api(
memory_id: str,
payload: MemoryUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await update_memory(memory_id, payload, owner_scope=current_user.owner_scope)
@router.delete("/{memory_id}", response_model=MemoryResponse)
async def delete_memory_api(
memory_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await delete_memory(memory_id, owner_scope=current_user.owner_scope)
+157
View File
@@ -0,0 +1,157 @@
"""息壤(记忆域)API(api-contracts §3.5)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains import xirang
from app.models.character import Character
from app.models.xirang import FactualRecord, Memory
from app.schemas.auth import CurrentUser
router = APIRouter(tags=["xirang"])
class MemorizeRequest(BaseModel):
label: str = Field(description="fact / insight / opinion / hypothesis")
class CorrectRequest(BaseModel):
new_content: str = Field(min_length=1, max_length=8000)
def _memory_view(m: Memory, with_content: bool = True) -> dict:
return {
"id": m.id,
"character_id": m.character_id,
"tier": m.tier,
"type": m.type,
"status": m.status,
"source": m.source,
"content": m.content if with_content else "",
"source_refs": json.loads(m.source_refs_json or "[]"),
"sensitivity": m.sensitivity,
"priority": m.priority,
"confidence": m.confidence,
"vitality": round(m.vitality, 3),
"realm": m.realm,
"prev_version_id": m.prev_version_id,
"recall_count": m.recall_count,
"last_recalled_at": m.last_recalled_at.isoformat() if m.last_recalled_at else None,
"created_at": m.created_at.isoformat(),
}
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", "人物不存在。")
return character
@router.get("/characters/{character_id}/memories")
async def list_memories(
character_id: str,
tier: str | None = Query(default=None),
status: str | None = Query(default=None),
type_: str | None = Query(default=None, alias="type"),
user: CurrentUser = Depends(get_current_user),
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
stmt = select(Memory).where(Memory.character_id == character_id)
if tier:
stmt = stmt.where(Memory.tier == tier)
if status:
stmt = stmt.where(Memory.status == status)
if type_:
stmt = stmt.where(Memory.type == type_)
stmt = stmt.order_by(Memory.created_at.desc()).limit(200)
rows = (await session.execute(stmt)).scalars().all()
# 高敏不展示原文(sensitivity=high 摘要化);墓碑不承载原文
return [
_memory_view(
m,
with_content=not (m.sensitivity == "high" or m.status == "tombstoned"),
)
for m in rows
]
@router.get("/memories/{memory_id}")
async def get_memory(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
async with AsyncSessionLocal() as session:
memory = await session.get(Memory, memory_id)
if memory is not None:
character = await session.get(Character, memory.character_id)
if memory is None or character is None or character.owner_scope != user.owner_scope:
raise AppError("not_found", "记忆不存在。")
return _memory_view(memory)
@router.post("/memories/{memory_id}/confirm")
async def confirm_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
memory = await xirang.confirm_memory(memory_id, user.owner_scope)
return _memory_view(memory)
@router.post("/memories/{memory_id}/deny")
async def deny_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await xirang.deny_memory(memory_id, user.owner_scope)
@router.post("/memories/{memory_id}/correct")
async def correct_memory_api(
memory_id: str, payload: CorrectRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
memory = await xirang.correct_memory(memory_id, user.owner_scope, payload.new_content)
return _memory_view(memory)
@router.delete("/memories/{memory_id}", status_code=204)
async def delete_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
await xirang.delete_memory(memory_id, user.owner_scope)
@router.post("/nodes/{node_id}/memorize", status_code=201)
async def memorize_node_api(
node_id: str, payload: MemorizeRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
memory = await xirang.memorize_node(
node_id=node_id, owner_scope=user.owner_scope, label=payload.label
)
return _memory_view(memory)
@router.get("/characters/{character_id}/factual-records")
async def list_factual_records(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(FactualRecord)
.where(FactualRecord.character_id == character_id)
.order_by(FactualRecord.occurred_at.desc())
.limit(100)
)
).scalars().all()
return [
{
"id": r.id,
"canonization_id": r.canonization_id,
"summary": r.summary,
"evidence": json.loads(r.evidence_json or "{}"),
"occurred_at": r.occurred_at.isoformat(),
}
for r in rows
]
-602
View File
@@ -1,602 +0,0 @@
"""大周天:长期演化循环(architecture §3.2;v1-scope §3.3)。
双触发:记忆阈值 + 每轮对话后检查。循环阶段化执行(自我评估 → 演化策略 →
候选生成),候选生成调神识模型产出统一 ``evolution_candidates``,按 ``level``
半自动应用——人格特质/程序记忆等已成熟下游立即派生,L4 核心变更留人工审核
(见 [大周天演化候选落地设计] ../../docs/domain/core-domains.md)。
单进程 asyncio,多 worker 需任务队列。
"""
import asyncio
from dataclasses import asdict
from datetime import UTC
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai_provider import ProviderManager, ProviderManagerFactory
from app.core.config import settings
from app.core.errors import AppError
from app.core.ids import new_goal_id
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.models.evolution import EvolutionCandidate
from app.models.governance import Goal
from app.models.memory import Memory
from app.models.world import CharacterWorldEntity
from app.modules.big_cycle.extract import parse_evolution_candidates
from app.modules.big_cycle.prompts import (
EvaluationSnapshot,
build_evolution_extraction_messages,
)
from app.modules.drive.extract import parse_curiosities
from app.modules.drive.prompts import build_drive_extraction_messages
from app.modules.evolution.policies import should_auto_apply
from app.modules.extraction.extract import parse_extraction
from app.modules.extraction.prompts import build_extraction_messages
from app.modules.runtime.events import Event
from app.repositories import big_cycle as big_cycle_repo
from app.repositories import characters as character_repo
from app.repositories import drive as drive_repo
from app.repositories import evolution as evolution_repo
from app.repositories import memory as memory_repo
from app.repositories import runtime as runtime_repo # 新增:活跃 run 检查
from app.repositories import world as world_repo
from app.repositories.json_utils import dumps
from app.services import evolution as evolution_service
from app.services.audit import record_audit_event
from app.services.event_store import DbEventStore
from app.services.personality import build_personality_summary
_running_big_cycle_tasks: set[asyncio.Task[None]] = set()
_RECENT_MEMORY_LIMIT = 10
# Token 预算估算(各阶段大致消耗,用于超预算时跳过非关键阶段)
_ESTIMATED_EVALUATION_COST = 4000
_ESTIMATED_CANDIDATE_COST = 16000
_ESTIMATED_MEMORY_COST = 8000
_provider_manager: ProviderManager | None = None
def _get_provider_manager() -> ProviderManager:
global _provider_manager
if _provider_manager is None:
_provider_manager = ProviderManagerFactory.create()
return _provider_manager
def _get_event_store() -> DbEventStore:
"""创建临时 EventStore 实例用于推送大周天事件(自管 session 工厂)。"""
return DbEventStore(AsyncSessionLocal)
async def _emit_big_cycle_event(
event_type: str,
character_id: str,
cycle_id: str,
*,
payload: dict | None = None,
) -> None:
"""推送大周天事件到 EventStore(自管 Session,失败静默)。"""
try:
store = _get_event_store()
await store.append(
Event(
type=event_type,
source="big_cycle",
visibility="public",
character_id=character_id,
payload={"cycle_id": cycle_id, **(payload or {})},
)
)
except Exception: # noqa: BLE001
logger.warning(f"推送大周天事件失败: {event_type}", cycle_id=cycle_id)
async def drain_pending_big_cycles() -> None:
"""等待所有进行中的大周天循环完成(测试断言 / 优雅停机用)。"""
if _running_big_cycle_tasks:
await asyncio.gather(*_running_big_cycle_tasks, return_exceptions=True)
async def maybe_trigger_big_cycle(character_id: str, trigger: str = "session_turn") -> str | None:
"""检查触发条件,满足则创建 cycle 记录并启动后台循环;返回 cycle_id 或 None。"""
if not settings.enable_big_cycle:
return None
async with AsyncSessionLocal() as session:
# 任务中断防护:如果有活跃运行中的 run,跳过大周天避免干扰对话
if await runtime_repo.has_active_runs(session, character_id):
logger.debug(f"大周天跳过:{character_id} 有活跃 run")
return None
memory_count = await character_repo.count_memories(session, character_id)
if memory_count < settings.big_cycle_memory_threshold:
return None
last = await big_cycle_repo.get_last_completed(session, character_id)
if last is not None and last.completed_at is not None:
last_completed = last.completed_at
if last_completed.tzinfo is None:
last_completed = last_completed.replace(tzinfo=UTC)
if (
utc_now() - last_completed
).total_seconds() < settings.big_cycle_min_interval_seconds:
return None
cycle = BigCycle(
character_id=character_id,
trigger=trigger,
status="pending",
memory_count_at_trigger=memory_count,
)
await big_cycle_repo.add_cycle(session, cycle)
await session.commit()
cycle_id = cycle.id
start_big_cycle(cycle_id, character_id)
return cycle_id
def start_big_cycle(cycle_id: str, character_id: str) -> None:
task = asyncio.create_task(_run_big_cycle(cycle_id, character_id))
_running_big_cycle_tasks.add(task)
task.add_done_callback(_running_big_cycle_tasks.discard)
async def _phase_goal_progress(session: AsyncSession, cycle: BigCycle) -> None:
"""大周天目标进度更新:根据演化结果增加关联目标进度。
当前为简单版本:每完成一轮大周天,character 的活跃 task/session 目标进度 +0.1。
后续可改为 LLM 评估更精确的进度。
"""
from app.repositories import governance as governance_repo
active_goals = await governance_repo.list_goals_by_character(
session, cycle.character_id, status_filter="active"
)
for goal in active_goals:
if goal.level in ("task", "session") and goal.progress < 1.0:
new_progress = min(1.0, goal.progress + 0.1)
await governance_repo.update_goal_progress(session, goal.id, new_progress)
if new_progress >= 1.0 and goal.level == "task":
goal.status = "completed"
from app.services.governance import emit_goal_event
await emit_goal_event(
goal,
"completed",
extra_payload={"trigger": "big_cycle"},
)
def _score_autonomy(subgoal) -> float:
"""按 information_gain / interruption_cost 比值排序。"""
gain = getattr(subgoal, "expected_information_gain", None) or 0.0
cost = getattr(subgoal, "interruption_cost", None) or 1.0
return gain / max(cost, 0.1)
async def _phase_autonomous_exploration(
session: AsyncSession, cycle: BigCycle,
) -> None:
"""自主探索执行:将最高优先级的已批准次生目标标记为待叩心枢执行。
选择 expected_information_gain / interruption_cost 比值最高的已批准次生目标,
标记为 auto_execute_pending,由叩心枢的下一个 tick 拾取。
每次大周天最多标记 2 个。
"""
from app.repositories import drive as drive_repo
approved = await drive_repo.list_approved_subgoals(
session, cycle.character_id, limit=5,
)
if not approved:
return
sorted_subgoals = sorted(approved, key=_score_autonomy, reverse=True)
to_execute = sorted_subgoals[:2]
for sub in to_execute:
sub.status = "auto_execute_pending"
await session.flush()
logger.info(
f"大周天自主探索标记 cycle={cycle.id} "
f"subgoals={len(to_execute)}/{len(approved)}"
)
async def _run_big_cycle(cycle_id: str, character_id: str) -> None:
"""大周天主循环:阶段化执行,超预算时跳过非关键阶段。"""
budget_spent = 0
await _emit_big_cycle_event("big_cycle.started", character_id, cycle_id)
async with AsyncSessionLocal() as session:
try:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
if cycle is None:
return
cycle.status = "running"
cycle.started_at = utc_now()
await session.commit()
# 阶段 1:自我评估(关键阶段,始终执行)
snapshot = await _phase_evaluation(session, cycle)
budget_spent += _ESTIMATED_EVALUATION_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "evaluation", "status": "completed"})
# 阶段 2:演化策略(轻量,始终执行)
await _phase_evolution(session, cycle)
# 预算检查:超过 token 预算则跳过候选/记忆/探索阶段
if budget_spent >= settings.big_cycle_token_budget:
logger.warning(f"大周天 token 预算耗尽 cycle={cycle_id} spent={budget_spent}")
cycle.summary = "token 预算耗尽,跳过后续非关键阶段"
await session.commit()
await _emit_big_cycle_event(
"big_cycle.phase", character_id, cycle_id,
payload={"phase": "candidate", "status": "skipped", "reason": "budget_exhausted"},
)
await _emit_big_cycle_event(
"big_cycle.phase", character_id, cycle_id,
payload={"phase": "memory", "status": "skipped", "reason": "budget_exhausted"},
)
else:
# 阶段 3:候选生成
await _phase_candidates(session, cycle, snapshot)
budget_spent += _ESTIMATED_CANDIDATE_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "candidate", "status": "completed"})
# 阶段 4:记忆/世界抽取
await _phase_memory(session, cycle, snapshot)
budget_spent += _ESTIMATED_MEMORY_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "memory", "status": "completed"})
# 阶段 5:目标进度更新
await _phase_goal_progress(session, cycle)
# 阶段 6:自主探索执行(轻量,始终执行)
await _phase_autonomous_exploration(session, cycle)
cycle.status = "completed"
cycle.phase = "done"
cycle.completed_at = utc_now()
await record_audit_event(
session,
action="big_cycle.completed",
character_id=character_id,
actor="system",
decision="auto",
input_summary=(cycle.summary or "大周天循环完成")[:200],
resource_type="big_cycle",
resource_id=cycle.id,
)
await session.commit()
await _emit_big_cycle_event(
"big_cycle.completed", character_id, cycle_id,
payload={"summary": cycle.summary},
)
except Exception as exc:
await _fail_cycle(session, cycle_id, exc)
await _emit_big_cycle_event(
"big_cycle.failed", character_id, cycle_id,
payload={"error": str(exc)[:200]},
)
async def _phase_evaluation(session: AsyncSession, cycle: BigCycle) -> EvaluationSnapshot:
"""自我评估:汇总记忆/人格/世界态势,写入 cycle 快照供候选生成消费。"""
cycle.phase = "evaluation"
await session.commit()
character = await character_repo.get_character(session, cycle.character_id)
if character is None:
raise AppError("not_found", "人物不存在。")
memory_count = await character_repo.count_memories(session, cycle.character_id)
recent, _ = await memory_repo.list_memories(
session, cycle.character_id, page=1, page_size=_RECENT_MEMORY_LIMIT
)
personality = await build_personality_summary(session, character)
_, world_count = await world_repo.list_entities(
session, cycle.character_id, page=1, page_size=1
)
snapshot = EvaluationSnapshot(
character_id=cycle.character_id,
memory_count=memory_count,
recent_memories=[(m.title, m.content) for m in recent],
personality_summary=personality.text or "",
world_entity_count=world_count,
conclusion=_build_conclusion(memory_count, world_count, personality.text),
evidence_ids=[m.id for m in recent],
)
# 人格权重向量:查询最近大周天的累积漂移,注入态势快照供 LLM 感知
from app.repositories import evolution as evolution_repo
recent_candidates, _ = await evolution_repo.list_candidates(
session, cycle.character_id, status=None, page=1, page_size=100,
)
if recent_candidates:
recent_drift = sum(c.drift_score or 0.0 for c in recent_candidates)
drift_budget_remaining = max(0.0, settings.big_cycle_max_drift - recent_drift)
snapshot.drift_budget_remaining = drift_budget_remaining
snapshot.cumulative_drift = recent_drift
cycle.evaluation_snapshot_json = dumps(asdict(snapshot))
await session.commit()
logger.info(f"大周天自我评估 cycle={cycle.id} memory={memory_count} world={world_count}")
await _generate_drive(session, cycle, snapshot)
return snapshot
async def _generate_drive(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""内驱力生成(C 子阶段):基于态势生成好奇问题与探索性次生目标。"""
system, user = build_drive_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
drafts = parse_curiosities(raw)[: settings.big_cycle_max_candidates]
curiosities = [
CuriosityQuestion(
character_id=cycle.character_id,
cycle_id=cycle.id,
question=draft.question,
source=draft.source,
related_entities_json=dumps(draft.related_entities),
expected_value=draft.expected_value,
urgency=draft.urgency,
risk_level=draft.risk_level,
)
for draft in drafts
]
if curiosities:
await drive_repo.add_curiosities(session, curiosities)
await session.flush()
subgoals: list[ExploratorySubgoal] = []
for curiosity, draft in zip(curiosities, drafts, strict=True):
for sub in draft.subgoals:
subgoals.append(
ExploratorySubgoal(
character_id=cycle.character_id,
cycle_id=cycle.id,
curiosity_question_id=curiosity.id,
title=sub.title,
description=sub.description,
expected_information_gain=sub.expected_information_gain,
interruption_cost=sub.interruption_cost,
risk_level=sub.risk_level,
status="candidate",
)
)
if subgoals:
await drive_repo.add_subgoals(session, subgoals)
await session.flush()
# 自动通过低风险次生目标(L0-L2)→ 创建 Goal
auto_approve_levels = {"L0", "L1", "L2"}
auto_count = 0
for sub in subgoals:
if sub.risk_level in auto_approve_levels:
sub.status = "approved"
goal = Goal(
id=new_goal_id(),
character_id=cycle.character_id,
title=sub.title,
description=sub.description or "由次生目标自动生成",
level="task",
type="exploration",
status="active",
)
session.add(goal)
auto_count += 1
await session.flush()
if auto_count:
logger.info(f"大周天自动通过 {auto_count}/{len(subgoals)} 个低风险次生目标")
logger.info(
f"大周天内驱力生成 cycle={cycle.id} curiosities={len(curiosities)} subgoals={len(subgoals)}"
)
await _emit_big_cycle_event(
"big_cycle.drive_generated", cycle.character_id, cycle.id,
payload={
"curiosity_count": len(curiosities),
"subgoal_count": len(subgoals),
"auto_approved_subgoals": auto_count,
},
)
async def _phase_evolution(session: AsyncSession, cycle: BigCycle) -> None:
"""演化策略阶段(轻量):演化策略融合进候选生成 prompt,此处仅标记阶段。"""
cycle.phase = "evolution"
await session.commit()
logger.info(f"大周天演化策略 cycle={cycle.id}")
async def _phase_candidates(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""候选生成:调神识模型产出演化候选,按 level 半自动应用。"""
cycle.phase = "candidate"
await session.commit()
system, user = build_evolution_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
drafts = parse_evolution_candidates(raw)[: settings.big_cycle_max_candidates]
candidates = [
EvolutionCandidate(
character_id=cycle.character_id,
cycle_id=cycle.id,
type=draft.type,
source=draft.source,
level=draft.level,
scope=draft.scope,
title=draft.title,
proposal=draft.proposal,
delta_json=dumps(draft.delta),
evidence_ids_json=dumps(_resolve_evidence(snapshot, draft.evidence)),
confidence=draft.confidence,
benefit_score=draft.benefit_score,
risk_score=draft.risk_score,
drift_score=draft.drift_score,
auto_apply_allowed=draft.auto_apply_allowed,
status="pending",
)
for draft in drafts
]
await evolution_repo.add_candidates(session, candidates)
await session.flush()
# 反漂移检测:高漂移候选不自动应用 + 累积漂移跟踪
cumulative_drift = 0.0
drift_blocked = 0
for candidate in candidates:
if candidate.drift_score >= settings.big_cycle_drift_threshold:
# 高漂移候选强制标记为 pending(人工审核),不自动应用
candidate.auto_apply_allowed = False
candidate.status = "pending"
candidate.error_message = f"漂移 {candidate.drift_score} >= 阈值 {settings.big_cycle_drift_threshold},需人工审核"
drift_blocked += 1
cumulative_drift += candidate.drift_score
# 累积漂移超过上限 → 全部候选降为 pending
if cumulative_drift >= settings.big_cycle_max_drift:
logger.warning(f"累积漂移 {cumulative_drift:.2f} >= 上限 {settings.big_cycle_max_drift},全部候选降为人工审核")
for candidate in candidates:
if candidate.status != "applied":
candidate.auto_apply_allowed = False
candidate.status = "pending"
candidate.error_message = f"累积漂移 {cumulative_drift:.2f} 超限,暂停自动演化"
for candidate in candidates:
if should_auto_apply(candidate):
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
if target_id is not None:
candidate.status = "applied"
candidate.resulting_target_id = target_id
else:
candidate.error_message = "该类型派生待后续阶段,已降级为人工审核"
applied = sum(1 for c in candidates if c.status == "applied")
pending = sum(1 for c in candidates if c.status == "pending")
cycle.summary = (
f"大周天生成 {len(candidates)} 个演化候选,"
f"{applied} 个已自动应用,{pending} 个待审核"
f"{f'({drift_blocked} 个因漂移拦截)' if drift_blocked else ''}"
f"{f',累积漂移 {cumulative_drift:.2f}' if cumulative_drift > 0 else ''}"
)
if drift_blocked:
await _emit_big_cycle_event(
"big_cycle.drift_blocked", cycle.character_id, cycle.id,
payload={
"drift_blocked": drift_blocked,
"cumulative_drift": round(cumulative_drift, 2),
"threshold": settings.big_cycle_drift_threshold,
},
)
await session.commit()
logger.info(f"大周天候选生成 cycle={cycle.id} count={len(candidates)} applied={applied}")
async def _phase_memory(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""记忆/世界抽取(D 子阶段):从态势抽取记忆候选与世界实体候选。"""
cycle.phase = "memory"
await session.commit()
system, user = build_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
memory_drafts, entity_drafts = parse_extraction(raw)
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
entity_drafts = entity_drafts[: settings.big_cycle_max_candidates]
memory_cands = [
MemoryCandidate(
character_id=cycle.character_id,
cycle_id=cycle.id,
type=draft.type,
title=draft.title,
content=draft.content,
confidence=draft.confidence,
importance=draft.importance,
sensitivity=draft.sensitivity,
reason=draft.reason,
status="pending",
)
for draft in memory_drafts
]
entities = [
CharacterWorldEntity(
character_id=cycle.character_id,
type=draft.type,
name=draft.name,
attributes_json=dumps(draft.attributes),
state_type=draft.state_type,
confidence=draft.confidence,
status="pending",
)
for draft in entity_drafts
]
if memory_cands:
session.add_all(memory_cands)
if entities:
session.add_all(entities)
await session.flush()
# 自动落地高置信度(≥0.7)低敏感度的记忆候选到 memories 表
auto_memories = 0
for cand in memory_cands:
if cand.confidence >= 0.7 and cand.sensitivity in ("low", "none"):
mem = Memory(
character_id=cycle.character_id,
type=cand.type,
title=cand.title,
content=cand.content,
source="big_cycle",
importance=cand.importance,
sensitivity=cand.sensitivity,
confidence=cand.confidence,
)
session.add(mem)
cand.status = "applied"
auto_memories += 1
# 自动落地高置信度世界实体候选
auto_entities = 0
for ent in entities:
if ent.confidence >= 0.7:
ent.status = "active"
auto_entities += 1
await session.flush()
if auto_memories or auto_entities:
logger.info(
f"大周天记忆/世界自动落地 cycle={cycle.id} "
f"memories={auto_memories} entities={auto_entities}"
)
logger.info(
f"大周天记忆/世界抽取 cycle={cycle.id} "
f"memories={len(memory_cands)} entities={len(entities)}"
)
def _resolve_evidence(snapshot: EvaluationSnapshot, indices: list[int]) -> list[str]:
return [snapshot.evidence_ids[i] for i in indices if 0 <= i < len(snapshot.evidence_ids)]
def _build_conclusion(memory_count: int, world_count: int, personality_text: str | None) -> str:
parts = [f"记忆 {memory_count} 条", f"世界实体 {world_count} 个"]
parts.append("已具备人格基线" if personality_text else "尚无人格基线")
return ";".join(parts)
async def _call_model(system: str, user: str, _character_id: str) -> str:
manager = _get_provider_manager()
messages = manager.to_message_list([system], [("user", user)])
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
return response.content
async def _fail_cycle(session: AsyncSession, cycle_id: str, exc: Exception) -> None:
logger.error(f"大周天循环失败 cycle={cycle_id}: {exc}")
try:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
if cycle is not None:
cycle.status = "failed"
cycle.error_code = "big_cycle_failed"
cycle.error_message = str(exc)[:500]
cycle.completed_at = utc_now()
await session.commit()
except Exception: # noqa: BLE001 — 失败处理本身不能再抛
pass
-150
View File
@@ -1,150 +0,0 @@
"""小周天每轮记忆候选 + 返照反思(异步后台任务,跨 modules + ai_provider)。
言灵表达(流式回复完成)后触发:从本轮对话抽取记忆候选(待审核)+ 生成反思
(直接建 memory type=reflection)。失败静默,不影响主流程。仅适用单进程;
多 worker 部署需替换为跨进程任务队列。
"""
import asyncio
from app.ai_provider import ProviderManager, ProviderManagerFactory
from app.core.config import settings
from app.core.logging import logger
from app.db.session import AsyncSessionLocal
from app.models.candidates import MemoryCandidate
from app.models.memory import Memory
from app.modules.reflection.extract import parse_reflection
from app.modules.reflection.prompts import build_reflection_messages
_running_reflection_tasks: set[asyncio.Task[None]] = set()
_provider_manager: ProviderManager | None = None
def _get_provider_manager() -> ProviderManager:
global _provider_manager
if _provider_manager is None:
_provider_manager = ProviderManagerFactory.create()
return _provider_manager
async def drain_pending_reflections() -> None:
"""等待所有进行中的反思任务完成(测试断言 / 优雅停机用)。"""
if _running_reflection_tasks:
await asyncio.gather(*_running_reflection_tasks, return_exceptions=True)
def start_reflection_after_turn(
character_id: str,
session_id: str,
user_content: str,
assistant_content: str,
) -> None:
"""每轮对话后启动反思抽取(后台任务);session_id 留作未来事件回写。"""
task = asyncio.create_task(
_run_reflection(character_id, session_id, user_content, assistant_content)
)
_running_reflection_tasks.add(task)
task.add_done_callback(_running_reflection_tasks.discard)
async def _run_reflection(
character_id: str,
session_id: str,
user_content: str,
assistant_content: str,
) -> None:
async with AsyncSessionLocal() as session:
try:
system, user = build_reflection_messages(user_content, assistant_content)
raw = await _call_model(system, user, character_id)
memory_drafts, reflection_text = parse_reflection(raw)
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
# 小周天记忆候选:高置信度(≥0.7)低敏感度直接落地为 Memory,
# 其余留 pending 待大周天统一审核
auto_memories = 0
pending_count = 0
for draft in memory_drafts:
if draft.confidence >= 0.7 and draft.sensitivity in ("low", "none"):
session.add(
Memory(
character_id=character_id,
type=draft.type,
title=draft.title,
content=draft.content,
source="reflection",
importance=draft.importance,
sensitivity=draft.sensitivity,
confidence=draft.confidence,
)
)
auto_memories += 1
else:
session.add(
MemoryCandidate(
character_id=character_id,
type=draft.type,
title=draft.title,
content=draft.content,
confidence=draft.confidence,
importance=draft.importance,
sensitivity=draft.sensitivity,
reason=draft.reason,
status="pending",
)
)
pending_count += 1
if reflection_text:
title = reflection_text[:60] + ("…" if len(reflection_text) > 60 else "")
session.add(
Memory(
character_id=character_id,
type="reflection",
title=title,
content=reflection_text,
source="reflection",
importance=0.5,
sensitivity="low",
)
)
await session.commit()
logger.info(
f"小周天反思 character={character_id} session={session_id} "
f"auto={auto_memories} pending={pending_count} "
f"reflection={'yes' if reflection_text else 'no'}"
)
# 推送反思完成事件(供叩心枢/前端观测)
try:
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
store = DbEventStore(AsyncSessionLocal)
await store.append(
Event(
type="reflection.completed",
source="reflection",
visibility="public",
character_id=character_id,
payload={
"session_id": session_id,
"auto_memories": auto_memories,
"pending_count": pending_count,
"has_reflection": bool(reflection_text),
"reflection_preview": (reflection_text or "")[:100],
},
)
)
except Exception: # noqa: BLE001
logger.warning("推送 reflection.completed 事件失败")
except Exception as exc: # noqa: BLE001 — 后台任务失败静默
logger.warning(f"小周天反思失败 character={character_id}: {exc}")
async def _call_model(system: str, user: str, _character_id: str) -> str:
manager = _get_provider_manager()
messages = manager.to_message_list([system], [("user", user)])
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
return response.content
+8
View File
@@ -151,6 +151,14 @@ def new_transplant_id() -> str:
return new_id("transplant_") return new_id("transplant_")
def new_memory_trigger_id() -> str:
return new_id("memory_trigger_")
def new_factual_record_id() -> str:
return new_id("frecord_")
def new_role_id() -> str: def new_role_id() -> str:
return new_id("rl_") return new_id("rl_")
@@ -11,6 +11,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
from sqlalchemy import select from sqlalchemy import select
@@ -413,6 +414,18 @@ async def build_generation_prompt(
lines.append(f"{speaker}:{expand(n.content)}") lines.append(f"{speaker}:{expand(n.content)}")
transcript = "\n".join(lines) transcript = "\n".join(lines)
parts = [] parts = []
# 获准长期记忆(息壤 M3):召回透明——注入来源可展开,旁枝不入(默认 active 级)
if question.tree_id:
from app.domains.xirang import service as xirang_service
recalled = await xirang_service.recall_for_generation(
await _tree_character_id(question.tree_id),
f"{transcript}\n{question.content}",
realm=question.realm,
)
if recalled:
memory_lines = [f"- {m.content}" for m in recalled]
parts.append(f"【获准记忆(来源可查)】\n" + "\n".join(memory_lines))
if transcript: if transcript:
parts.append(f"【共同经历(祖先路径)】\n{transcript}") parts.append(f"【共同经历(祖先路径)】\n{transcript}")
parts.append(f"【当前问题】\n{expand(question.content)}") parts.append(f"【当前问题】\n{expand(question.content)}")
@@ -730,6 +743,11 @@ async def canonize(
record_id = record.id record_id = record.id
await session.commit() await session.commit()
# 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回
from app.domains.xirang import service as xirang_service
await xirang_service.on_canonized(record_id)
await one_qi.emit( await one_qi.emit(
"tree.canonized", "tree.canonized",
character_id=tree.character_id, character_id=tree.character_id,
@@ -873,3 +891,11 @@ async def transplant(
source="mirror_tree", source="mirror_tree",
) )
return {"new_tree_id": new_tree_id, "working_entry_node_id": new_entry} return {"new_tree_id": new_tree_id, "working_entry_node_id": new_entry}
async def _tree_character_id(tree_id: str) -> str:
from app.models.mirror_tree import MirrorTree
async with AsyncSessionLocal() as session:
tree = await session.get(MirrorTree, tree_id)
return tree.character_id if tree else ""
+25
View File
@@ -0,0 +1,25 @@
"""息壤(记忆域):三级记忆、实录与召回(data-model.md §3)。"""
from app.domains.xirang.service import (
confirm_memory,
correct_memory,
delete_memory,
deny_memory,
memorize_node,
on_canonized,
place_candidates,
recall_for_generation,
record_factual,
)
__all__ = [
"confirm_memory",
"correct_memory",
"delete_memory",
"deny_memory",
"memorize_node",
"on_canonized",
"place_candidates",
"recall_for_generation",
"record_factual",
]
+504
View File
@@ -0,0 +1,504 @@
"""息壤(记忆域)服务:提取管线、分级落位、确认流、正影、删除、检索与激活。
铁律(memory-model.md §5):只有归真结果(或用户手动入忆)能产生长期记忆候选——
微痕不自动升级;未归真旁枝自动入忆率必须为 0(测试钉死)。
"""
from __future__ import annotations
import asyncio
import json
import math
import re
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.kernel import Kernel
from app.models.mirror_tree import Canonization, TreeNode
from app.models.xirang import FactualRecord, Memory, MemoryTrigger
# 微痕默认半衰期(天);命中强化 +0.08、上限 3.0(借鉴 Memory-Palace 活力模型)
TRACE_HALF_LIFE_DAYS = 7.0
VITALITY_HIT_BONUS = 0.08
VITALITY_MAX = 3.0
# 召回预算:条数与字符上限
RECALL_LIMIT = 5
RECALL_CHAR_BUDGET = 1200
# 明忆白名单(PRD 三级记忆):低敏客观类自动生效带撤销窗;心痕等主观类入候选箱
EXPLICIT_AUTO_TYPES = ("persona", "instruction", "episodic")
# deny 两次退出检索
DENY_EXIT_THRESHOLD = 2
deny_counts: dict[str, int] = {}
# ── 归真触发:实录 + 提取 ──────────────────────────────────────────────
async def on_canonized(canonization_id: str) -> None:
"""归真后置(由 canonize 同步调用):实录落库 + 调度异步提取。"""
await record_factual(canonization_id)
asyncio.get_running_loop().create_task(_extract_guarded(canonization_id))
async def _extract_guarded(canonization_id: str) -> None:
try:
await _extract_after_canonized(canonization_id)
except Exception as exc: # noqa: BLE001 — 后台任务异常必须可见
from app.core.logging import log_with_context
log_with_context(f"息壤提取任务失败:{exc}", level="ERROR")
async def _extract_after_canonized(canonization_id: str) -> None:
async with AsyncSessionLocal() as session:
record = await session.get(Canonization, canonization_id)
if record is None:
return
path_ids = json.loads(record.path_nodes_json)
nodes = []
for nid in path_ids:
node = await session.get(TreeNode, nid)
if node is not None:
nodes.append(node)
transcript = "\n".join(
f"{'用户' if n.node_type == 'question' else '人物'}:{n.content}" for n in nodes
)
character_id = record.character_id
await _extract_candidates(
character_id=character_id, canonization_id=canonization_id, transcript=transcript
)
async def record_factual(canonization_id: str) -> None:
"""实录(同步,随归真事务后立即落库)。"""
async with AsyncSessionLocal() as session:
record = await session.get(Canonization, canonization_id)
if record is None:
return
path_nodes = []
for nid in json.loads(record.path_nodes_json):
node = await session.get(TreeNode, nid)
if node is not None:
path_nodes.append(node)
transcript = "\n".join(
f"{'用户' if n.node_type == 'question' else '人物'}:{n.content}" for n in path_nodes
)
summary = f"[{record.scope}] " + " / ".join(n.content[:40] for n in path_nodes[-2:])
session.add(
FactualRecord(
character_id=record.character_id,
canonization_id=canonization_id,
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
summary=summary,
)
)
character_id = record.character_id
scope = record.scope
await session.commit()
_ = scope
async def _extract_candidates(*, character_id: str, canonization_id: str, transcript: str) -> None:
"""提取管线:LLM 抽取 JSON 候选;解析失败回落为确定性单条摘要候选(不阻塞闭环)。"""
from app.shenshi_client.types import ShenshiChatRequest, ShenshiMessage
kernel: Kernel | None = _kernel_ref()
raw = ""
if kernel is not None:
request = ShenshiChatRequest(
messages=[
ShenshiMessage(
role="system",
content=(
"你是记忆抽取器。从对话实录中抽取长期记忆候选,"
'输出 JSON 数组 [{"content","type","priority","confidence","sensitive"}],'
'type ∈ persona/episodic/instruction/relationship_view;宁缺毋滥,无值得记忆的内容输出 []。'
),
),
ShenshiMessage(role="user", content=transcript[:8000]),
],
run_id=f"extract_{canonization_id}",
)
parts: list[str] = []
try:
async for delta in kernel.shenshi.stream_chat(request):
parts.append(delta.delta)
raw = "".join(parts)
except Exception: # noqa: BLE001 — 提取失败回落确定性路径
raw = ""
candidates = _parse_candidates(raw)
if not candidates:
candidates = [
{
"content": f"共同经历:{transcript[-200:]}",
"type": "episodic",
"priority": 50,
"confidence": 0.6,
"sensitive": False,
}
]
await place_candidates(
character_id=character_id,
candidates=candidates,
source_refs=[canonization_id],
source="self_inferred",
realm="reality",
)
def _parse_candidates(raw: str) -> list[dict]:
match = re.search(r"\[.*\]", raw, re.S)
if not match:
return []
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
out: list[dict] = []
for item in data:
if isinstance(item, dict) and item.get("content"):
out.append(item)
return out
async def place_candidates(
*,
character_id: str,
candidates: list[dict],
source_refs: list[str],
source: str,
realm: str = "reality",
) -> list[Memory]:
"""分级落位 + 确定性去重(同类型同内容跳过)。"""
placed: list[Memory] = []
async with AsyncSessionLocal() as session:
existing = (
(
await session.execute(
select(Memory).where(
Memory.character_id == character_id, Memory.status.in_(("active", "pending"))
)
)
)
.scalars()
.all()
)
existing_norms = {_norm(m.content) for m in existing}
for cand in candidates:
content = str(cand.get("content", "")).strip()
if not content or _norm(content) in existing_norms:
continue
sensitive = bool(cand.get("sensitive"))
mtype = str(cand.get("type", "episodic"))
if sensitive:
tier, status = "sensitive", "pending" # 重忆必须确认(HJ-1201 管线侧)
elif mtype in EXPLICIT_AUTO_TYPES:
# 白名单明忆:低敏 persona/instruction/episodic 自动生效,撤销窗=可删除/可正影
tier, status = "explicit", "active"
else:
# 心痕(relationship_view,主观非事实)等入候选箱待审阅
tier, status = "explicit", "candidate"
memory = Memory(
character_id=character_id,
tier=tier,
type=mtype,
status=status,
source=source,
content=content,
source_refs_json=json.dumps(source_refs, ensure_ascii=False),
sensitivity="high" if sensitive else "low",
priority=int(cand.get("priority", 50)),
confidence=float(cand.get("confidence", 0.5)),
realm=realm,
)
session.add(memory)
await session.flush()
placed.append(memory)
for key in cand.get("keys", []) or []:
session.add(MemoryTrigger(memory_id=memory.id, kind="keyword", pattern=str(key)))
await session.commit()
for m in placed:
await one_qi.emit(
"memory.candidate_created",
character_id=character_id,
payload={"memory_id": m.id, "tier": m.tier, "status": m.status},
source="xirang",
)
return placed
def _norm(text: str) -> str:
return re.sub(r"\s+", "", text).lower()
def _kernel_ref() -> Kernel | None:
from app.main import app
return getattr(app.state, "kernel", None)
# ── 手动入忆 ──────────────────────────────────────────────
async def memorize_node(*, node_id: str, owner_scope: str, label: str) -> Memory:
"""手动入忆:须显式标注 fact/insight/opinion/hypothesis;未归真内容只作 fact_candidate。"""
from app.models.character import Character
if label not in ("fact", "insight", "opinion", "hypothesis"):
raise AppError("invalid_request", "手动入忆必须标注 fact/insight/opinion/hypothesis。")
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
if node is None:
raise AppError("not_found", "节点不存在。")
canonized_node_ids: set[str] = set()
canons = (
await session.execute(select(Canonization).where(Canonization.tree_id == node.tree_id))
).scalars().all()
for canon in canons:
canonized_node_ids.update(json.loads(canon.path_nodes_json))
character = await session.get(Character, await _tree_character(session, node.tree_id))
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "节点不存在。")
character_id = character.id
on_canon = node.id in canonized_node_ids
memory = Memory(
character_id=character.id,
tier="explicit",
type="episodic" if (on_canon and label == "fact") else "fact_candidate",
status="active",
source="user_explicit",
content=f"[{label}] {node.content}",
source_refs_json=json.dumps([node_id], ensure_ascii=False),
sensitivity="low",
realm=node.realm,
)
session.add(memory)
await session.flush()
memory_id = memory.id
await session.commit()
await one_qi.emit(
"memory.candidate_created",
character_id=character_id,
payload={"memory_id": memory_id, "manual": True, "label": label},
source="xirang",
)
async with AsyncSessionLocal() as session:
return await session.get(Memory, memory_id) # type: ignore[return-value]
async def _tree_character(session: AsyncSession, tree_id: str) -> str:
from app.models.mirror_tree import MirrorTree
tree = await session.get(MirrorTree, tree_id)
return tree.character_id if tree else ""
# ── 确认流 / 正影 / 删除 ──────────────────────────────────────────────
async def _owned_memory(memory_id: str, owner_scope: str, *, session: AsyncSession) -> Memory:
from app.models.character import Character
memory = await session.get(Memory, memory_id)
if memory is None:
raise AppError("not_found", "记忆不存在。")
character = await session.get(Character, memory.character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "记忆不存在。")
return memory
async def confirm_memory(memory_id: str, owner_scope: str) -> Memory:
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
if memory.status not in ("pending", "candidate"):
raise AppError("HJ-1103", "只有待确认(pending)或候选箱(candidate)的记忆可确认。", status_code=409)
memory.status = "active"
view = _view(memory)
await session.commit()
await one_qi.emit("memory.confirmed", character_id=view["character_id"], payload={"memory_id": memory_id}, source="xirang")
async with AsyncSessionLocal() as session:
return await session.get(Memory, memory_id) # type: ignore[return-value]
async def deny_memory(memory_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
memory.status = "tombstoned"
await session.commit()
character_id = memory.character_id
deny_counts[memory_id] = deny_counts.get(memory_id, 0) + 1
exited = deny_counts[memory_id] >= DENY_EXIT_THRESHOLD
await one_qi.emit(
"memory.denied", character_id=character_id, payload={"memory_id": memory_id, "exited": exited}, source="xirang"
)
return {"memory_id": memory_id, "exited_retrieval": True}
async def correct_memory(memory_id: str, owner_scope: str, new_content: str) -> Memory:
"""正影:新版本指向旧版本;旧版 superseded 停止普通召回,内容不改写。"""
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
if memory.status == "tombstoned":
raise AppError("HJ-1202", "已删除内容不可正影。", status_code=409)
new_version = Memory(
character_id=memory.character_id,
tier=memory.tier,
type=memory.type,
status="active" if memory.status in ("active", "pending") else memory.status,
source=memory.source,
content=new_content,
source_refs_json=memory.source_refs_json,
sensitivity=memory.sensitivity,
priority=memory.priority,
realm=memory.realm,
prev_version_id=memory.id,
)
session.add(new_version)
memory.status = "superseded"
await session.flush()
new_id = new_version.id
await session.commit()
await one_qi.emit(
"memory.superseded",
character_id=None,
payload={"memory_id": memory_id, "new_version_id": new_id},
source="xirang",
)
async with AsyncSessionLocal() as session:
return await session.get(Memory, new_id) # type: ignore[return-value]
async def delete_memory(memory_id: str, owner_scope: str) -> None:
"""删除:墓碑化——内容不再出现在主存储查询/检索/上下文;墓碑不留原文。"""
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
character_id = memory.character_id
memory.status = "tombstoned"
memory.content = "" # 墓碑不承载被删原文(data-model §10)
await session.commit()
await one_qi.emit(
"memory.deleted", character_id=character_id, payload={"memory_id": memory_id}, source="xirang"
)
# ── 检索(双通道 + RRF 融合 + 预算)与激活 ──────────────────────────────────────────────
async def recall_for_generation(
character_id: str, query_text: str, *, realm: str = "reality", limit: int = RECALL_LIMIT
) -> list[Memory]:
"""获准长期记忆召回:关键词通道 + 活力时近通道 → RRF 融合 → 预算裁剪。
通道 A:关键词/触发词命中(content LIKE 或 trigger pattern 命中);
通道 B:活力值 × 时近衰减排序(标准档接入 pgvector 后升级为语义通道)。
过滤:active、界域匹配、非墓碑/被取代。
"""
async with AsyncSessionLocal() as session:
actives = (
(
await session.execute(
select(Memory).where(
Memory.character_id == character_id,
Memory.status == "active",
Memory.realm.in_(("reality", realm)),
)
)
)
.scalars()
.all()
)
if not actives:
return []
by_id = {m.id: m for m in actives}
now = utc_now()
def vitality_of(m: Memory) -> float:
age_days = max((now - _as_utc(m.created_at)).total_seconds() / 86400, 0)
return m.vitality * math.pow(2, -age_days / TRACE_HALF_LIFE_DAYS)
# 通道 A:关键词命中(含触发词)
keywords = {t.memory_id: t.pattern for t in await _triggers(set(by_id))}
channel_a: list[str] = []
text_norm = _norm(query_text)
for m in actives:
hit = _norm(m.content)[:24] in text_norm or any(
_norm(p) in text_norm for p in ([keywords.get(m.id)] if keywords.get(m.id) else [])
)
if hit or any(_norm(w) and _norm(w) in text_norm for w in _split_terms(m.content)):
channel_a.append(m.id)
# 通道 B:活力 × 时近
channel_b = sorted(by_id, key=lambda mid: vitality_of(by_id[mid]), reverse=True)
# RRF 融合
k = 60
scores: dict[str, float] = {}
for rank, mid in enumerate(channel_a):
scores[mid] = scores.get(mid, 0) + 1 / (k + rank + 1)
for rank, mid in enumerate(channel_b):
scores[mid] = scores.get(mid, 0) + 1 / (k + rank + 1)
ranked = sorted(scores, key=lambda mid: scores[mid], reverse=True)[:limit]
# 字符预算
picked: list[Memory] = []
used = 0
for mid in ranked:
m = by_id[mid]
if used + len(m.content) > RECALL_CHAR_BUDGET:
continue
picked.append(m)
used += len(m.content)
# 命中强化 + 召回统计
if picked:
async with AsyncSessionLocal() as session:
for m in picked:
row = await session.get(Memory, m.id)
row.vitality = min(row.vitality + VITALITY_HIT_BONUS, VITALITY_MAX)
row.recall_count += 1
row.last_recalled_at = utc_now()
await session.commit()
return picked
async def _triggers(memory_ids: set[str]) -> list[MemoryTrigger]:
if not memory_ids:
return []
async with AsyncSessionLocal() as session:
result = await session.execute(
select(MemoryTrigger).where(MemoryTrigger.memory_id.in_(tuple(memory_ids)))
)
return list(result.scalars().all())
def _split_terms(content: str) -> list[str]:
return [w for w in re.split(r"[,。!?、,.!?;;\s]+", content) if len(w) >= 2][:6]
def _as_utc(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
def _view(memory: Memory) -> dict:
return {
"id": memory.id,
"character_id": memory.character_id,
"tier": memory.tier,
"type": memory.type,
"status": memory.status,
"content": memory.content,
}
+5
View File
@@ -119,6 +119,11 @@ class Kernel:
assert created is not None assert created is not None
return created return created
@property
def shenshi(self) -> ShenshiClient:
"""神识客户端(息壤提取等功能任务的受控取用点)。"""
return self.loop._shenshi # noqa: SLF001
def _spawn( def _spawn(
self, self,
*, *,
+1 -3
View File
@@ -25,10 +25,10 @@ from app.models.mirror_tree import (
TreeNode, TreeNode,
TreeTransplant, TreeTransplant,
) )
from app.models.memory import Memory, MemoryLink
from app.models.personality import PersonalityProposal, PersonalityVersion from app.models.personality import PersonalityProposal, PersonalityVersion
from app.models.rbac import Permission, Role, RolePermission, UserRole from app.models.rbac import Permission, Role, RolePermission, UserRole
from app.models.runtime import Event, Run 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.safety import AuditEvent, PendingConfirmationRecord
from app.models.skills import Skill from app.models.skills import Skill
from app.models.user import User from app.models.user import User
@@ -52,9 +52,7 @@ __all__ = [
"Lorebook", "Lorebook",
"LorebookEntry", "LorebookEntry",
"McpServer", "McpServer",
"Memory",
"MemoryCandidate", "MemoryCandidate",
"MemoryLink",
"PendingConfirmationRecord", "PendingConfirmationRecord",
"PerceptionChannel", "PerceptionChannel",
"Permission", "Permission",
-46
View File
@@ -1,46 +0,0 @@
"""记忆模型。"""
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_memory_id, new_memory_link_id
from app.core.time import utc_now
from app.db.base import Base
class Memory(Base):
__tablename__ = "memories"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
type: Mapped[str] = mapped_column(String(60), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str] = mapped_column(String(80), nullable=False, default="user_explicit")
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
importance: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
sensitivity: Mapped[str] = mapped_column(String(40), nullable=False, default="low")
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True)
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
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
temporal_context_json: Mapped[str] = mapped_column(
Text, nullable=False, default="{}", server_default="{}"
)
class MemoryLink(Base):
__tablename__ = "memory_links"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_link_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
source_memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
target_memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
relation: Mapped[str] = mapped_column(String(80), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+80
View File
@@ -0,0 +1,80 @@
"""息壤(记忆域)模型(data-model.md §3)。
- 三级:微痕 trace(短期)/ 明忆 explicit(低敏长期)/ 重忆 sensitive(须确认);
- 双源:user_explicit / self_inferred;
- 版本链:正影=新行 prev_version_id 指旧行,旧行 superseded 停止普通召回,内容不改写;
- 删除:tombstoned 墓碑不承载原文,主存储/检索/上下文全链路停用。
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_factual_record_id, new_memory_id, new_memory_trigger_id
from app.core.time import utc_now
from app.db.base import Base
class Memory(Base):
"""统一记忆表:三级 × 类型 × 状态 × 双源(契约词典 §10)。"""
__tablename__ = "memories"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_id)
character_id: Mapped[str] = mapped_column(String(80), index=True, nullable=False)
# trace / explicit / sensitive(§10.1)
tier: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
# persona / episodic / instruction / relationship_view / fact_candidate(§10.2)
type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
# candidate / pending / active / superseded / tombstoned(§10.3)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="candidate", index=True)
# user_explicit / self_inferred(§10.4)
source: Mapped[str] = mapped_column(String(20), nullable=False, default="self_inferred")
content: Mapped[str] = mapped_column(Text, nullable=False)
# 可解释性:指向镜树节点/事件(撤销与来源展开的依据)
source_refs_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
sensitivity: Mapped[str] = mapped_column(String(20), nullable=False, default="none")
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
# 活力值:2^(-Δdays/半衰期) 衰减、命中强化(§6.2)
vitality: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
# 微痕有效期;空表示不过期
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 记忆界域:境界记忆不进真界上下文(§5)
realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality")
prev_version_id: Mapped[str | None] = mapped_column(String(80), index=True)
# 召回统计(召回透明)
recall_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_recalled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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 MemoryTrigger(Base):
"""记忆触发条件:keyword(微痕激活管线共用)与 disclosure(单一触发)。"""
__tablename__ = "memory_triggers"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_trigger_id)
memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
# keyword / disclosure / vector(§10.5)
kind: Mapped[str] = mapped_column(String(20), nullable=False)
pattern: Mapped[str] = mapped_column(String(200), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class FactualRecord(Base):
"""实录(不可变):发生了什么 + 证据引用;与心痕(relationship_view)分离。"""
__tablename__ = "factual_records"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_factual_record_id)
character_id: Mapped[str] = mapped_column(String(80), index=True, nullable=False)
canonization_id: Mapped[str] = mapped_column(String(80), index=True)
# 证据:路径节点引用 + 摘要
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
@@ -1 +0,0 @@
"""大周天记忆/世界抽取领域纯逻辑(造消息 / 解析记忆候选与世界实体草稿,禁止 IO)。"""
-140
View File
@@ -1,140 +0,0 @@
"""解析模型返回为记忆/世界抽取草稿(纯函数,无 IO)。
模型输出对象 ``{"memories": [...], "entities": [...]}``。缺省取**安全悲观默认**:
记忆 ``sensitivity=low``、世界 ``state_type=inference``(非 fact,召回降权,见
[世界模型设计] ../../docs/domain/core-domains.md §8)。
"""
import json
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class MemoryCandidateDraft:
type: str
title: str
content: str
confidence: float = 0.5
importance: float = 0.5
sensitivity: str = "low"
reason: str = ""
@dataclass
class WorldEntityDraft:
type: str
name: str
attributes: dict[str, Any] = field(default_factory=dict)
state_type: str = "inference"
confidence: float = 0.5
_MEMORY_TYPES = {
"user",
"preference",
"event",
"relationship",
"skill",
"goal",
"other",
}
_ENTITY_TYPES = {
"self",
"person",
"object",
"place",
"device",
"project",
"task",
"concept",
}
_STATE_TYPES = {"fact", "observation", "inference", "hypothesis", "stale"}
_SENSITIVITY = {"low", "medium", "high"}
def parse_extraction(
raw: str,
) -> tuple[list[MemoryCandidateDraft], list[WorldEntityDraft]]:
data = extract_json_object(raw)
if not isinstance(data, dict):
return [], []
return parse_memory_drafts(data.get("memories")), _parse_entities(data.get("entities"))
def parse_memory_drafts(value: Any) -> list[MemoryCandidateDraft]:
if not isinstance(value, list):
return []
result: list[MemoryCandidateDraft] = []
for item in value:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
content = str(item.get("content", "")).strip()
if not title or not content:
continue
result.append(
MemoryCandidateDraft(
type=_enum(item.get("type"), _MEMORY_TYPES, "other"),
title=title,
content=content,
confidence=_clamp(item.get("confidence"), 0.5),
importance=_clamp(item.get("importance"), 0.5),
sensitivity=_enum(item.get("sensitivity"), _SENSITIVITY, "low"),
reason=str(item.get("reason", "")).strip(),
)
)
return result
def _parse_entities(value: Any) -> list[WorldEntityDraft]:
if not isinstance(value, list):
return []
result: list[WorldEntityDraft] = []
for item in value:
if not isinstance(item, dict):
continue
name = str(item.get("name", "")).strip()
if not name:
continue
attrs = item.get("attributes")
result.append(
WorldEntityDraft(
type=_enum(item.get("type"), _ENTITY_TYPES, "concept"),
name=name,
attributes=attrs if isinstance(attrs, dict) else {},
state_type=_enum(item.get("state_type"), _STATE_TYPES, "inference"),
confidence=_clamp(item.get("confidence"), 0.5),
)
)
return result
def extract_json_object(raw: str) -> dict[str, Any]:
text = raw.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
start = text.find("{")
end = 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, dict) else {}
def _enum(value: Any, valid: set[str], default: str) -> str:
text = str(value or "").strip().lower()
return text if text in valid else default
def _clamp(value: Any, default: float) -> float:
try:
num = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(1.0, num))
-46
View File
@@ -1,46 +0,0 @@
"""大周天记忆/世界抽取 prompt 构建(纯函数,无 IO)。
复用 ``EvaluationSnapshot``(态势快照),产出 ``(system, user)`` 消息对,喂给
模型网关。模型返回的 JSON 对象由 ``extract`` 模块解析。
枚举取值以 [记忆系统设计](../../docs/domain/memory-model.md) §6、
[世界模型设计](../../docs/domain/core-domains.md) §8 与
[数据库 schema](../../docs/contracts/contract-glossary.md) §20 为准。
"""
from app.modules.big_cycle.prompts import EvaluationSnapshot
def build_extraction_messages(
snapshot: EvaluationSnapshot,
) -> tuple[str, str]:
system = (
"你是魂镜的大周天记忆/世界抽取器。基于本轮自我评估的态势快照与近期记忆,"
"抽取值得长期记住的记忆候选与世界实体候选(用户、项目、地点、对象等)。"
"只抽取有长期价值、非琐碎的信息;低置信度或推断信息标注为 inference 而非 fact,"
"不得把推断伪装成已确认事实。"
'以 JSON 对象返回:{"memories": [...], "entities": [...]}。'
'memories 每项:{"type": str(user/preference/event/relationship/skill/goal/fact/other), '
'"title": str, "content": str, "importance": 0~1, '
'"sensitivity": "low|medium|high", "confidence": 0~1, "reason": str}。'
'entities 每项:{"type": str(self/person/object/place/device/project/task/concept), '
'"name": str, "attributes": 对象, '
'"state_type": str(fact/observation/inference/hypothesis/stale), "confidence": 0~1}。'
'无值得抽取的内容时返回 {"memories": [], "entities": []}。'
"只返回 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)
Whitespace-only changes.
-91
View File
@@ -1,91 +0,0 @@
"""记忆上下文组装与检索结果数据结构(纯领域,无 IO)。
``MemoryRetrievalResult`` 持有已加载的 Memory ORM 实例(由 services/memory 检索后传入),
``build_memory_context`` 把它组装成注入上下文的文本。本模块只接收已加载数据、输出结构化对象。
"""
from __future__ import annotations
from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict
from app.core.time import utc_now
from app.models.memory import Memory
class MemoryRetrievalResult(BaseModel):
"""记忆检索结果(持有已加载的 Memory ORM 实例列表)。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
memories: list[Memory]
def _to_aware(dt: datetime | None) -> datetime | None:
"""确保 datetime 有时区信息,naive 视为 UTC。"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
def build_memory_context(result: MemoryRetrievalResult, *, max_length: int = 3000) -> str | None:
"""把检索到的记忆组装成注入上下文的文本;无记忆时返回 None。
按**重要性降序**排列,每条附带重要性标签和相对时间。
超过 ``max_length`` 字符时截断,避免上下文窗口膨胀。
"""
if not result.memories:
return None
now = utc_now()
def _mem_sort_key(m: Memory) -> float:
"""排序键:重要性(越高越优先),同时辅助时间衰减。"""
ca = _to_aware(m.created_at)
hours_ago = (now - ca).total_seconds() / 3600 if ca else 0
decay = max(0.5, 1.0 - hours_ago / 720) # 30 天内缓慢衰减
return (m.importance or 0.5) * decay
sorted_mems = sorted(result.memories, key=_mem_sort_key, reverse=True)
lines: list[str] = []
total_chars = 0
for idx, item in enumerate(sorted_mems):
importance_label = _importance_label(item.importance)
ca = _to_aware(item.created_at)
hours_ago = (now - ca).total_seconds() / 3600 if ca else 0
time_label = _time_ago_str(hours_ago)
line = f"- 「{item.title}」{time_label} [{importance_label}]\n {item.content}"
line_len = len(line) + 1 # +1 for newline
if total_chars + line_len > max_length:
remaining = len(sorted_mems) - idx
if remaining > 0:
lines.append(f"…以及 {remaining} 条其他记忆(已截断)")
break
lines.append(line)
total_chars += line_len
return "相关记忆:\n" + "\n".join(lines)
def _importance_label(importance: float) -> str:
if importance >= 0.8:
return "★重要"
if importance >= 0.5:
return "●一般"
return "○参考"
def _time_ago_str(hours_ago: float) -> str:
if hours_ago < 1:
return "刚刚"
if hours_ago < 24:
return f"{int(hours_ago)}小时前"
days = int(hours_ago / 24)
if days < 30:
return f"{days}天前"
months = int(days / 30)
return f"{months}个月前"
@@ -1 +0,0 @@
"""小周天每轮反思领域纯逻辑(造消息 / 解析记忆候选与反思,禁止 IO)。"""
-22
View File
@@ -1,22 +0,0 @@
"""解析模型返回为小周天记忆候选 + 反思(纯函数,无 IO)。
模型输出对象 ``{"memories": [...], "reflection": str}``。记忆候选解析复用 extraction
的 ``parse_memory_drafts``;``reflection`` 为本轮复盘文本。
"""
from app.modules.extraction.extract import (
MemoryCandidateDraft,
extract_json_object,
parse_memory_drafts,
)
def parse_reflection(
raw: str,
) -> tuple[list[MemoryCandidateDraft], str]:
data = extract_json_object(raw)
if not isinstance(data, dict):
return [], ""
memories = parse_memory_drafts(data.get("memories"))
reflection = str(data.get("reflection", "")).strip()
return memories, reflection
-22
View File
@@ -1,22 +0,0 @@
"""小周天每轮记忆候选 + 反思 prompt 构建(纯函数,无 IO)。"""
def build_reflection_messages(
user_content: str,
assistant_content: str,
) -> tuple[str, str]:
system = (
"你是魂镜的小周天反思器。基于本轮对话(用户消息 + 你的回复),抽取值得长期记住的"
"记忆候选(用户偏好 / 事件 / 事实等),并生成一条简短的本轮反思"
"(做得好 / 不好 / 可改进)。"
"只抽取有长期价值、非琐碎的信息;反思是系统内部复盘,不超过两句。"
'以 JSON 对象返回:{"memories": [{"type": str'
"(user/preference/event/relationship/skill/goal/fact/other), "
'"title": str, "content": str, "importance": 0~1, '
'"sensitivity": "low|medium|high", "confidence": 0~1, "reason": str}], '
'"reflection": str}。'
"无值得记忆的内容时 memories 为空数组;reflection 仍需给出。"
"只返回 JSON 对象,不要解释或 markdown。"
)
user = f"用户消息:\n{user_content}\n\n你的回复:\n{assistant_content}"
return system, user
+2 -2
View File
@@ -4,7 +4,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character from app.models.character import Character
from app.models.memory import Memory from app.models.xirang import Memory
async def get_active_character(session: AsyncSession, owner_scope: str) -> Character | None: async def get_active_character(session: AsyncSession, owner_scope: str) -> Character | None:
@@ -98,7 +98,7 @@ async def count_memories(session: AsyncSession, character_id: str) -> int:
await session.execute( await session.execute(
select(func.count()) select(func.count())
.select_from(Memory) .select_from(Memory)
.where(Memory.character_id == character_id, Memory.deleted_at.is_(None)) .where(Memory.character_id == character_id, Memory.status != "tombstoned")
) )
).scalar_one() ).scalar_one()
return int(total or 0) return int(total or 0)
-73
View File
@@ -1,73 +0,0 @@
"""记忆数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.memory import Memory
async def get_memory(session: AsyncSession, memory_id: str) -> Memory | None:
return await session.get(Memory, memory_id)
async def list_memories(
session: AsyncSession,
character_id: str,
*,
memory_type: str | None = None,
source: str | None = None,
page: int,
page_size: int,
active_only: bool = True,
) -> tuple[list[Memory], int]:
conditions = [Memory.character_id == character_id]
if active_only:
conditions.append(Memory.status == "active")
conditions.append(Memory.deleted_at.is_(None))
if memory_type is not None:
conditions.append(Memory.type == memory_type)
if source is not None:
conditions.append(Memory.source == source)
total_result = await session.execute(
select(func.count()).select_from(Memory).where(*conditions)
)
total = int(total_result.scalar_one())
result = await session.execute(
select(Memory)
.where(*conditions)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
async def list_injectable_memories(
session: AsyncSession, character_id: str, *, limit: int
) -> list[Memory]:
result = await session.execute(
select(Memory)
.where(
Memory.character_id == character_id,
Memory.status == "active",
Memory.deleted_at.is_(None),
)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def get_memories_by_ids(
session: AsyncSession, memory_ids: list[str]
) -> list[Memory]:
"""按 ID 列表批量获取记忆(按重要性降序排序,而非 ID 传入顺序)。"""
result = await session.execute(
select(Memory).where(
Memory.id.in_(memory_ids),
Memory.status == "active",
Memory.deleted_at.is_(None),
)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
)
return list(result.scalars().all())
-35
View File
@@ -1,35 +0,0 @@
"""演化候选 API schema(契约字典 §12;api-spec 演化候选审核)。"""
from datetime import datetime
from typing import Any
from pydantic import Field
from app.schemas.common import OrmModel
class EvolutionCandidateResponse(OrmModel):
"""演化候选响应(delta/evidence_ids 已从 JSON 文本解析)。"""
id: str = Field(description="演化候选 ID(evolution_ 前缀)")
character_id: str
cycle_id: str = Field(description="触发本次的大周天循环 ID")
type: str = Field(description="候选类型,取值见契约字典 §12.1")
source: str = Field(description="来源,本期 big_cycle")
level: str = Field(description="影响层级 L0-L4")
scope: str = Field(description="作用域,取值见契约字典 §12.3")
title: str
proposal: str
delta: dict[str, Any] = Field(default_factory=dict, description="演化增量")
evidence_ids: list[str] = Field(default_factory=list)
confidence: float
benefit_score: float
risk_score: float
drift_score: float
auto_apply_allowed: bool
status: str
resulting_target_id: str | None = None
error_message: str | None = None
reviewed_at: datetime | None = None
reviewed_by: str
created_at: datetime
-109
View File
@@ -1,109 +0,0 @@
"""记忆 API schema。"""
from pydantic import BaseModel, ConfigDict, Field
class MemoryCreateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"type": "user",
"title": "回答偏好",
"content": "用户喜欢简洁、直接、带验证结果的中文回答。",
"source": "user_explicit",
"confidence": 1.0,
"importance": 0.8,
"sensitivity": "low",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
}
]
}
)
type: str = Field(
default="user", max_length=60, description="记忆类型,取值见契约字典 memory.type。"
)
title: str = Field(max_length=200, description="记忆标题。")
content: str = Field(min_length=1, description="记忆正文。")
source: str = Field(
default="user_explicit", description="记忆来源,取值见契约字典 memory.source。"
)
confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="置信度,范围 0.0–1.0。")
importance: float = Field(default=0.5, ge=0.0, le=1.0, description="重要性,范围 0.0–1.0。")
sensitivity: str = Field(default="low", description="敏感等级:none / low / medium / high。")
evidence_ids: list[str] = Field(default_factory=list, description="证据 ID 列表。")
character_id: str | None = Field(default=None, description="人物 ID;为空时使用当前激活人物。")
class MemoryUpdateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"title": "回答偏好(已确认)",
"importance": 0.9,
"status": "active",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
}
]
}
)
type: str | None = Field(default=None, max_length=60, description="记忆类型。")
title: str | None = Field(default=None, max_length=200, description="记忆标题。")
content: str | None = Field(default=None, description="记忆正文。")
confidence: float | None = Field(
default=None, ge=0.0, le=1.0, description="置信度,范围 0.0–1.0。"
)
importance: float | None = Field(
default=None, ge=0.0, le=1.0, description="重要性,范围 0.0–1.0。"
)
sensitivity: str | None = Field(default=None, description="敏感等级。")
status: str | None = Field(
default=None, description="记忆状态:active / archived / deleted / superseded。"
)
evidence_ids: list[str] | None = Field(default=None, description="证据 ID 列表。")
class MemoryResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"id": "memory_4d9c6e3b18bb4ef5a6b8ed2e902d1f1a",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"type": "user",
"title": "回答偏好",
"content": "用户喜欢简洁、直接、带验证结果的中文回答。",
"source": "user_explicit",
"confidence": 1.0,
"importance": 0.8,
"sensitivity": "low",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
"status": "active",
"created_at": "2026-06-19T09:12:00.000000Z",
"updated_at": "2026-06-19T09:12:00.000000Z",
"deleted_at": None,
"expires_at": None,
}
]
}
)
id: str = Field(description="记忆 ID,前缀为 memory_。")
character_id: str = Field(description="所属人物 ID。")
type: str = Field(description="记忆类型。")
title: str = Field(description="记忆标题。")
content: str = Field(description="记忆正文。")
source: str = Field(description="记忆来源。")
confidence: float = Field(description="置信度。")
importance: float = Field(description="重要性。")
sensitivity: str = Field(description="敏感等级。")
evidence_ids: list[str] = Field(description="证据 ID 列表。")
status: str = Field(description="记忆状态。")
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
updated_at: str = Field(description="更新时间,UTC ISO 8601 字符串。")
deleted_at: str | None = Field(description="删除时间;未删除时为空。")
expires_at: str | None = Field(description="过期时间;永不过期时为空。")
+2 -2
View File
@@ -21,7 +21,7 @@ from app.models.governance import (
WorldEntity, WorldEntity,
) )
from app.models.idempotency import IdempotencyKey from app.models.idempotency import IdempotencyKey
from app.models.memory import Memory from app.models.xirang import Memory
from app.models.runtime import Event, Run from app.models.runtime import Event, Run
from app.models.safety import AuditEvent from app.models.safety import AuditEvent
from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation
@@ -77,7 +77,7 @@ async def get_pivots_health(
plans_n = await governance_repo.count(session, Plan) plans_n = await governance_repo.count(session, Plan)
perception_n = await governance_repo.count(session, PerceptionChannel) perception_n = await governance_repo.count(session, PerceptionChannel)
agents_n = await governance_repo.count(session, AgentRosterEntry) agents_n = await governance_repo.count(session, AgentRosterEntry)
memories_n = await governance_repo.count(session, Memory) memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
character = await character_repo.get_active_character(session, owner_scope) 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.current_personality_version_id)
tool_enabled = settings.enable_tool_runtime and tool_runtime is not None tool_enabled = settings.enable_tool_runtime and tool_runtime is not None
-205
View File
@@ -1,205 +0,0 @@
"""演化候选派生与审核服务。
applier 注册表按 ``type`` 派生到下游(本期 personality→人格版本、procedural→
程序记忆,其余返回 None 降级)。审核闭环 approve/reject 复用派生能力,自管
session 并记审计(见 [大周天演化候选落地设计]
../docs/domain/core-domains.md §6-§7)。
"""
from collections.abc import Awaitable, Callable
from sqlalchemy.ext.asyncio import AsyncSession
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.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.repositories import evolution as evolution_repo
from app.repositories import personality as personality_repo
from app.repositories.json_utils import loads_dict, loads_list
from app.schemas.common import Page
from app.schemas.evolution import EvolutionCandidateResponse
from app.services.audit import record_audit_event
from app.services.personality import derive_personality_version
Applier = Callable[..., Awaitable[str]]
async def apply_evolution_candidate(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str = "system"
) -> str | None:
"""应用演化候选到下游;返回派生资源 ID,无 applier 时返回 None(降级)。"""
applier = _APPLIERS.get(candidate.type)
if applier is None:
return None
return await applier(session, candidate, actor=actor)
async def _apply_personality(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
) -> str:
base = await personality_repo.get_latest_personality(session, candidate.character_id)
if base is None:
raise AppError("invalid_request", "人物尚无人格版本,无法应用演化候选。")
overrides = loads_dict(candidate.delta_json)
new_version = derive_personality_version(
base, overrides, reason=f"evolution:{candidate.id}", created_by="big_cycle"
)
session.add(new_version)
await session.flush()
character = await session.get(Character, candidate.character_id)
if character is None:
raise AppError("not_found", "人物不存在。")
character.current_personality_version_id = new_version.id
await _record_apply(
session, candidate, f"type=personality version={new_version.id}", actor=actor
)
return new_version.id
async def _apply_procedural(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
) -> str:
delta = loads_dict(candidate.delta_json)
memory = Memory(
character_id=candidate.character_id,
type="procedural",
title=str(delta.get("title") or candidate.title),
content=str(delta.get("content") or candidate.proposal),
source="big_cycle",
importance=candidate.confidence,
)
session.add(memory)
await session.flush()
await _record_apply(session, candidate, f"type=procedural memory={memory.id}", actor=actor)
return memory.id
async def list_evolution_candidates(
*,
character_id: str,
status: str | None = None,
candidate_type: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[EvolutionCandidateResponse]:
async with AsyncSessionLocal() as session:
items, total = await evolution_repo.list_candidates(
session,
character_id,
status=status,
candidate_type=candidate_type,
page=page,
page_size=page_size,
)
return Page(
items=[_to_response(item) for item in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_evolution_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await evolution_repo.get_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "演化候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
target_id = await apply_evolution_candidate(session, candidate, actor="user")
if target_id is None:
raise AppError("not_implemented", "该类型派生待后续阶段。")
candidate.status = "applied"
candidate.resulting_target_id = target_id
candidate.reviewed_at = utc_now()
candidate.reviewed_by = "user"
await session.commit()
await session.refresh(candidate)
return _to_response(candidate)
async def reject_evolution_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await evolution_repo.get_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "演化候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
candidate.status = "rejected"
candidate.reviewed_at = utc_now()
candidate.reviewed_by = "user"
await record_audit_event(
session,
action="evolution.candidate.rejected",
actor="user",
risk_level=candidate.level,
decision="denied",
character_id=candidate.character_id,
input_summary=candidate.proposal[:200],
resource_type="evolution_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return _to_response(candidate)
def _to_response(candidate: EvolutionCandidate) -> EvolutionCandidateResponse:
return EvolutionCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
cycle_id=candidate.cycle_id,
type=candidate.type,
source=candidate.source,
level=candidate.level,
scope=candidate.scope,
title=candidate.title,
proposal=candidate.proposal,
delta=loads_dict(candidate.delta_json),
evidence_ids=loads_list(candidate.evidence_ids_json),
confidence=candidate.confidence,
benefit_score=candidate.benefit_score,
risk_score=candidate.risk_score,
drift_score=candidate.drift_score,
auto_apply_allowed=candidate.auto_apply_allowed,
status=candidate.status,
resulting_target_id=candidate.resulting_target_id,
error_message=candidate.error_message,
reviewed_at=candidate.reviewed_at,
reviewed_by=candidate.reviewed_by,
created_at=candidate.created_at,
)
async def _record_apply(
session: AsyncSession,
candidate: EvolutionCandidate,
output_summary: str,
*,
actor: str,
) -> None:
await record_audit_event(
session,
action="evolution.candidate.applied",
actor=actor,
risk_level=candidate.level,
decision="auto",
character_id=candidate.character_id,
input_summary=candidate.proposal[:200],
output_summary=output_summary,
resource_type="evolution_candidate",
resource_id=candidate.id,
)
_APPLIERS: dict[str, Applier] = {
"personality": _apply_personality,
"procedural": _apply_procedural,
}
-164
View File
@@ -1,164 +0,0 @@
"""大周天抽取候选审核服务(记忆候选 + 世界实体候选)。
记忆候选 approve 派生为 ``memories``(source=big_cycle);世界实体候选 approve
→ confirmed 进入世界模型。审核仅 pending 可操作(见 [大周天记忆/世界抽取落地设计]
../docs/domain/core-domains.md §5)。
"""
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import candidates as candidates_repo
from app.repositories import world as world_repo
from app.schemas.candidates import MemoryCandidateResponse
from app.schemas.common import Page
from app.schemas.world import WorldEntityResponse
from app.services.audit import record_audit_event
from app.services.serializers import (
character_world_entity_to_response,
memory_candidate_to_response,
)
async def list_memory_proposals(
*,
character_id: str,
source: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[MemoryCandidateResponse]:
async with AsyncSessionLocal() as session:
all_items = await candidates_repo.list_memory_candidates(
session, character_id, source=source, status=status
)
start = max(page - 1, 0) * page_size
items = all_items[start : start + page_size]
return Page(
items=[memory_candidate_to_response(i) for i in items],
page=page,
page_size=page_size,
total=len(all_items),
)
async def approve_memory_proposal(
candidate_id: str,
) -> MemoryCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "记忆候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
session.add(
Memory(
character_id=candidate.character_id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
source="big_cycle",
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids_json=candidate.evidence_ids_json,
status="active",
)
)
candidate.status = "applied"
candidate.reviewed_at = utc_now()
await record_audit_event(
session,
action="extraction.memory.applied",
actor="user",
character_id=candidate.character_id,
input_summary=candidate.title[:200],
resource_type="memory_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return memory_candidate_to_response(candidate)
async def reject_memory_proposal(
candidate_id: str,
) -> MemoryCandidateResponse:
return await _reject_memory(candidate_id)
async def list_world_entities_pending(
*,
character_id: str,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[WorldEntityResponse]:
async with AsyncSessionLocal() as session:
items, total = await world_repo.list_entities(
session, character_id, status=status, page=page, page_size=page_size
)
return Page(
items=[character_world_entity_to_response(i) for i in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_world_entity(entity_id: str) -> WorldEntityResponse:
return await _review_world_entity(entity_id, "confirmed", decision="auto")
async def reject_world_entity(entity_id: str) -> WorldEntityResponse:
return await _review_world_entity(entity_id, "rejected", decision="denied")
async def _reject_memory(candidate_id: str) -> MemoryCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "记忆候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
candidate.status = "rejected"
candidate.reviewed_at = utc_now()
await record_audit_event(
session,
action="extraction.memory.rejected",
actor="user",
decision="denied",
character_id=candidate.character_id,
input_summary=candidate.title[:200],
resource_type="memory_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return memory_candidate_to_response(candidate)
async def _review_world_entity(
entity_id: str, target_status: str, *, decision: str
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
entity = await world_repo.get_entity(session, entity_id)
if entity is None:
raise AppError("not_found", "世界实体不存在。")
if entity.status != "pending":
raise AppError("conflict", f"实体当前状态为 {entity.status},不可审核。")
entity.status = target_status
await record_audit_event(
session,
action=f"extraction.world.{target_status}",
actor="user",
decision=decision,
character_id=entity.character_id,
input_summary=entity.name[:200],
resource_type="character_world_entity",
resource_id=entity.id,
)
await session.commit()
await session.refresh(entity)
return character_world_entity_to_response(entity)
+5 -22
View File
@@ -15,7 +15,6 @@ from app.core.errors import AppError
from app.core.time import utc_now from app.core.time import utc_now
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.imports import CharacterImportJob, ImportItem from app.models.imports import CharacterImportJob, ImportItem
from app.models.memory import Memory
from app.modules.imports import parse_import from app.modules.imports import parse_import
from app.repositories import candidates as candidates_repo from app.repositories import candidates as candidates_repo
from app.repositories import imports as imports_repo from app.repositories import imports as imports_repo
@@ -325,29 +324,13 @@ async def _trigger_extraction(
kind: str, kind: str,
owner_scope: str = settings.default_owner_scope, owner_scope: str = settings.default_owner_scope,
) -> ImportJobResponse: ) -> ImportJobResponse:
"""校验 job → 置 extracting_* → 启动后台抽取 → 返回 job 当前状态。""" """抽取触发已随旧管线退役:记忆候选由息壤归真管线生成(M3),画像/人格抽取随 M6。"""
async with AsyncSessionLocal() as session: raise AppError(
character = await get_owned_character(session, character_id, owner_scope=owner_scope) "not_implemented",
job = await _get_owned_job(session, character.id, import_id) "该抽取入口已退役:记忆候选经镜树归真生成(息壤 M3);画像/人格抽取随 M6 灵核重建。",
if job.status not in _EXTRACTABLE_STATUSES: status_code=410,
raise AppError("conflict", f"任务状态 {job.status} 不可触发抽取。")
job.status = status
await session.commit()
from app.application.imports import (
start_memory_extraction,
start_personality_extraction,
start_profile_extraction,
) )
starters = {
"profile": start_profile_extraction,
"memory": start_memory_extraction,
"personality": start_personality_extraction,
}
starters[kind](import_id)
return await _reload_job(import_id)
async def list_profile_candidates( async def list_profile_candidates(
character_id: str, character_id: str,
-304
View File
@@ -1,304 +0,0 @@
"""记忆服务。
集成 ChromaDB 向量存储:
- create_memory / 大/小周天自动创建的 Memory 写入后异步生成 embedding 存向量
- retrieve_memories 支持向量相似度检索,失败时回退 SQL 排序检索
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.modules.memory.context import MemoryRetrievalResult
from app.repositories import memory as memory_repo
from app.repositories.json_utils import dumps
from app.schemas.common import Page
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
from app.services.audit import record_audit_event
from app.services.characters import get_active_character_model
from app.services.serializers import memory_to_response
def _get_embedding_service(manager):
"""尝试创建 EmbeddingService,失败时返回 None。"""
try:
models = manager.model_registry.list_by_capability("embedding")
if not models:
return None
from app.ai_provider.embedding import EmbeddingService
return EmbeddingService(manager)
except Exception: # noqa: BLE001
return None
async def _embed_and_store(memory: Memory) -> None:
"""异步后台任务:为记忆生成 embedding 并存入向量库。
embedding 服务和 ChromaDB 均不可用时静默跳过——不影响主流程。
"""
text = memory.content or memory.title or ""
if not text:
return
try:
from app.ai_provider import ProviderManagerFactory
manager = ProviderManagerFactory.create()
service = _get_embedding_service(manager)
if service is None:
return
import asyncio as _asyncio
response = await _asyncio.wait_for(
service.embed(text),
timeout=10.0,
)
if not response.data or not response.data[0].embedding:
return
embedding = response.data[0].embedding
from app.integrations.vector_store import get_vector_store
store = get_vector_store()
if store is None:
return
store.add_memory(
character_id=memory.character_id,
memory_id=memory.id,
text=text,
embedding=embedding,
metadata={
"type": memory.type,
"source": memory.source,
"title": memory.title or "",
},
)
except Exception: # noqa: BLE001
logger.warning(
"memory_embed_failed",
memory_id=memory.id,
character_id=memory.character_id,
)
async def embed_memory_async(memory: Memory) -> None:
"""公开接口:为单条记忆生成 embedding 并存入向量库(供大/小周天后台任务调用)。"""
await _embed_and_store(memory)
# ── CRUD ──────────────────────────────────────────────────────────────────
async def list_memory_page(
*,
page: int = 1,
page_size: int = 20,
memory_type: str | None = None,
source: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> Page[MemoryResponse]:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
safe_page_size = min(max(page_size, 1), 100)
safe_page = max(page, 1)
items, total = await memory_repo.list_memories(
session,
character.id,
memory_type=memory_type,
source=source,
page=safe_page,
page_size=safe_page_size,
)
return Page(
items=[memory_to_response(item) for item in items],
page=safe_page,
page_size=safe_page_size,
total=total,
)
async def create_memory(
session: AsyncSession,
payload: MemoryCreateRequest,
*,
owner_scope: str = settings.default_owner_scope,
) -> MemoryResponse:
character = await get_active_character_model(session, owner_scope)
character_id = payload.character_id or character.id
if character_id != character.id:
raise AppError("not_found", "人物不存在。")
memory = Memory(
character_id=character_id,
type=payload.type,
title=payload.title,
content=payload.content,
source=payload.source,
confidence=payload.confidence,
importance=payload.importance,
sensitivity=payload.sensitivity,
evidence_ids_json=dumps(payload.evidence_ids),
status="active",
)
session.add(memory)
await session.flush()
# 异步 embedding
if settings.vector_db_enabled:
import asyncio
asyncio.ensure_future(_embed_and_store(memory))
await record_audit_event(
session,
action="memory.created",
character_id=character_id,
actor="user",
decision="auto",
input_summary=payload.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def update_memory(
memory_id: str, payload: MemoryUpdateRequest, *, owner_scope: str = settings.default_owner_scope
) -> MemoryResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
memory = await memory_repo.get_memory(session, memory_id)
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
raise AppError("not_found", "记忆不存在。")
updates = payload.model_dump(exclude_unset=True)
evidence_ids = updates.pop("evidence_ids", None)
for key, value in updates.items():
setattr(memory, key, value)
if evidence_ids is not None:
memory.evidence_ids_json = dumps(evidence_ids)
memory.updated_at = utc_now()
# 如果内容变了,异步更新向量
if ("content" in updates or "title" in updates) and settings.vector_db_enabled:
import asyncio
asyncio.ensure_future(_embed_and_store(memory))
await record_audit_event(
session,
action="memory.updated",
character_id=character.id,
actor="user",
decision="auto",
input_summary=memory.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def delete_memory(
memory_id: str, *, owner_scope: str = settings.default_owner_scope
) -> MemoryResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
memory = await memory_repo.get_memory(session, memory_id)
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
raise AppError("not_found", "记忆不存在。")
memory.status = "deleted"
memory.deleted_at = utc_now()
memory.updated_at = utc_now()
# 同步删除向量
if settings.vector_db_enabled:
try:
from app.integrations.vector_store import get_vector_store
store = get_vector_store()
if store is not None:
store.delete_memory(character.id, memory_id)
except Exception: # noqa: BLE001
pass
await record_audit_event(
session,
action="memory.deleted",
character_id=character.id,
actor="user",
decision="auto",
input_summary=memory.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def memory_context(
session: AsyncSession,
character_id: str,
*,
limit: int,
_owner_scope: str = settings.default_owner_scope,
) -> str:
"""获取记忆上下文文本(供叩心枢开场、辰正等场景使用)。"""
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
if not memories:
return ""
lines = [f"- {item.title}: {item.content}" for item in memories]
return "相关记忆:\n" + "\n".join(lines)
async def retrieve_memories(
session: AsyncSession,
character_id: str,
*,
limit: int,
_owner_scope: str = settings.default_owner_scope,
) -> MemoryRetrievalResult:
"""检索可注入上下文的相关记忆。
优先尝试向量相似度检索,失败时回退 SQL 排序检索。
"""
# 尝试向量检索(5s 超时,避免 embedding endpoint 不可达时阻塞消息流)
if settings.vector_db_enabled:
try:
from app.ai_provider import ProviderManagerFactory
manager = ProviderManagerFactory.create()
models = manager.model_registry.list_by_capability("embedding")
if models:
import asyncio as _asyncio
from app.ai_provider.embedding import EmbeddingService
from app.integrations.vector_store import get_vector_store
query_text = f"检索 {character_id} 的最新相关记忆"
service = EmbeddingService(manager)
response = await _asyncio.wait_for(
service.embed(query_text),
timeout=5.0,
)
if response.data and response.data[0].embedding:
store = get_vector_store()
if store is not None:
vector_results = store.search(
character_id,
response.data[0].embedding,
limit=limit,
)
if vector_results:
ids = [r["id"] for r in vector_results]
memories = await memory_repo.get_memories_by_ids(session, ids)
if memories:
return MemoryRetrievalResult(memories=memories)
except Exception: # noqa: BLE001
logger.warning("vector_retrieval_failed,回退 SQL 检索")
# 回退 SQL
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
return MemoryRetrievalResult(memories=memories)
+12 -40
View File
@@ -16,7 +16,6 @@ from app.models.governance import (
WorldEntity, WorldEntity,
) )
from app.models.imports import CharacterImportJob, ImportItem from app.models.imports import CharacterImportJob, ImportItem
from app.models.memory import Memory
from app.models.personality import PersonalityProposal, PersonalityVersion from app.models.personality import PersonalityProposal, PersonalityVersion
from app.models.runtime import Event, Run from app.models.runtime import Event, Run
from app.models.safety import AuditEvent from app.models.safety import AuditEvent
@@ -33,7 +32,6 @@ from app.schemas.admin import (
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
from app.schemas.character import CharacterResponse, CharacterSummaryResponse from app.schemas.character import CharacterResponse, CharacterSummaryResponse
from app.schemas.imports import ImportItemResponse, ImportJobResponse from app.schemas.imports import ImportItemResponse, ImportJobResponse
from app.schemas.memory import MemoryResponse
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
from app.schemas.preferences import GenerationPreferencesResponse from app.schemas.preferences import GenerationPreferencesResponse
from app.schemas.runtime import EventResponse, RunResponse from app.schemas.runtime import EventResponse, RunResponse
@@ -123,26 +121,6 @@ def proposal_to_response(proposal: PersonalityProposal) -> PersonalityProposalRe
) )
def memory_to_response(memory: Memory) -> MemoryResponse:
return MemoryResponse(
id=memory.id,
character_id=memory.character_id,
type=memory.type,
title=memory.title,
content=memory.content,
source=memory.source,
confidence=memory.confidence,
importance=memory.importance,
sensitivity=memory.sensitivity,
evidence_ids=loads_list(memory.evidence_ids_json),
status=memory.status,
created_at=to_iso(memory.created_at) or "",
updated_at=to_iso(memory.updated_at) or "",
deleted_at=to_iso(memory.deleted_at),
expires_at=to_iso(memory.expires_at),
)
def run_to_response(run: Run) -> RunResponse: def run_to_response(run: Run) -> RunResponse:
return RunResponse( return RunResponse(
id=run.id, id=run.id,
@@ -401,21 +379,15 @@ def profile_candidate_to_response(candidate: ProfileCandidate) -> ProfileCandida
) )
def memory_candidate_to_response(candidate: MemoryCandidate) -> MemoryCandidateResponse:
return MemoryCandidateResponse(
id=candidate.id, def memory_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)
character_id=candidate.character_id, from app.schemas.candidates import MemoryCandidateResponse
import_job_id=candidate.import_job_id,
cycle_id=candidate.cycle_id, return MemoryCandidateResponse.model_validate(candidate, from_attributes=True)
type=candidate.type,
title=candidate.title,
content=candidate.content, def profile_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役)
confidence=candidate.confidence, from app.schemas.candidates import ProfileCandidateResponse
importance=candidate.importance,
sensitivity=candidate.sensitivity, return ProfileCandidateResponse.model_validate(candidate, from_attributes=True)
evidence_ids=loads_list(candidate.evidence_ids_json),
status=candidate.status,
reason=candidate.reason,
reviewed_at=to_iso(candidate.reviewed_at),
created_at=to_iso(candidate.created_at) or "",
)
@@ -1,106 +0,0 @@
"""演化候选审核 API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.big_cycle import BigCycle
from app.models.evolution import EvolutionCandidate
from app.repositories.json_utils import dumps
from app.services.characters import get_active_character_model
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
async def _seed_cycle_and_candidate(character_id: str, **overrides) -> tuple[str, str]:
base = {
"type": "procedural",
"source": "big_cycle",
"level": "L2",
"scope": "task_scope",
"title": "确认假设",
"proposal": "技术问题先确认前提",
"delta_json": dumps({"title": "确认假设", "content": "先确认前提"}),
"evidence_ids_json": "[]",
"confidence": 0.8,
"benefit_score": 0.7,
"risk_score": 0.2,
"drift_score": 0.0,
"auto_apply_allowed": False,
"status": "pending",
}
base.update(overrides)
async with AsyncSessionLocal() as session:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
candidate = EvolutionCandidate(character_id=character_id, cycle_id=cycle.id, **base)
session.add(candidate)
await session.commit()
return candidate.id, cycle.id
@pytest.mark.asyncio
async def test_list_evolution_candidates(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_cycle_and_candidate(character.id)
await _seed_cycle_and_candidate(character.id, status="applied")
resp = await client.get(f"/api/v1/characters/{character.id}/evolution-candidates")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
pending = await client.get(
f"/api/v1/characters/{character.id}/evolution-candidates", params={"status": "pending"}
)
assert pending.json()["total"] == 1
@pytest.mark.asyncio
async def test_approve_pending_candidate_applies(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id, type="procedural")
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/approve")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "applied"
assert data["resulting_target_id"] is not None
@pytest.mark.asyncio
async def test_reject_pending_candidate(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id)
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/reject")
assert resp.status_code == 200
assert resp.json()["status"] == "rejected"
@pytest.mark.asyncio
async def test_approve_not_implemented_type_returns_error(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id, type="tool_strategy", level="L2")
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/approve")
assert resp.status_code in (400, 409, 501)
assert resp.json()["error"]["code"] == "not_implemented"
-176
View File
@@ -1,176 +0,0 @@
"""资料导入候选抽取测试(5b,异步后台 + mock ai_provider)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.application.imports import drain_pending_extractions
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.user import User
from app.services.auth import create_access_token
async def _get_character_id(client: AsyncClient) -> str:
"""通过已认证的 API 获取当前用户活跃角色 ID。"""
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200
return resp.json()["id"]
class _FakeChatResult:
def __init__(self, content: str) -> None:
self.content = content
_PROFILE_RESPONSE = (
'[{"field":"background","value":"来自北京,从事设计工作",'
'"confidence":0.9,"reason":"资料明确提及","evidence":[0,1]}]'
)
_MEMORY_RESPONSE = (
'[{"type":"preference","title":"喜欢简洁回答","content":"用户偏好简洁直接的回答",'
'"importance":0.8,"sensitivity":"low","reason":"多次提及","evidence":[0]}]'
)
_PERSONALITY_RESPONSE = (
'[{"field":"expression_style","change":"温和且有条理","reason":"资料体现","evidence":[1]}]'
)
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_chat(messages, *, model_hint, **_kwargs):
if model_hint == "memory-extraction":
return _FakeChatResult(_MEMORY_RESPONSE)
system_text = str(messages[0].content) if messages else ""
if "人格" in system_text:
return _FakeChatResult(_PERSONALITY_RESPONSE)
return _FakeChatResult(_PROFILE_RESPONSE)
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _MockManager:
async def chat(self, messages, model_hint=None):
return await fake_chat(messages, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _MockManager())
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _create_import(client: AsyncClient, character_id: str) -> str:
resp = await client.post(
f"/api/v1/characters/{character_id}/imports",
json={
"material_type": "background_story",
"content": "我叫小魂,来自北京。\n\n我从事设计工作,喜欢简洁直接的回答。",
},
)
return resp.json()["id"]
@pytest.mark.asyncio
async def test_extract_profile_candidates(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
triggered = await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
assert triggered.status_code == 200
assert triggered.json()["status"] == "extracting_profile"
await drain_pending_extractions()
candidates = (
await client.get(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
).json()
assert len(candidates) == 1
assert candidates[0]["field"] == "background"
assert candidates[0]["status"] == "pending"
assert len(candidates[0]["evidence_ids"]) == 2 # 映射到 import_items.id
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "reviewing"
@pytest.mark.asyncio
async def test_extract_memory_candidates(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(f"/api/v1/characters/{character_id}/imports/{import_id}/memory-candidates")
await drain_pending_extractions()
candidates = (
await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}/memory-candidates")
).json()
assert len(candidates) == 1
assert candidates[0]["type"] == "preference"
assert candidates[0]["title"] == "喜欢简洁回答"
@pytest.mark.asyncio
async def test_extract_personality_proposals(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/personality-proposals"
)
await drain_pending_extractions()
# 人格建议落入 personality_proposals(pending);无独立列表端点,以 job 进入 reviewing 验证
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "reviewing"
@pytest.mark.asyncio
async def test_extraction_failure_marks_job_failed(monkeypatch: pytest.MonkeyPatch) -> None:
async def failing_chat(*_args, **_kwargs):
raise RuntimeError("模型不可用")
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _FailingManager:
async def chat(self, messages, model_hint=None):
return await failing_chat(messages, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _FailingManager())
transport = ASGITransport(app=app)
async with AsyncSessionLocal() as session:
result = await session.execute(select(User).where(User.is_admin.is_(True)).limit(1))
admin = result.scalar_one_or_none()
assert admin is not None
token = create_access_token(subject=admin.id, extra={"owner_scope": admin.id})
headers = {"Authorization": f"Bearer {token}"}
async with AsyncClient(transport=transport, base_url="http://test", headers=headers) as client:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
await drain_pending_extractions()
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "failed"
assert job["error_code"] == "extraction_failed"
-142
View File
@@ -1,142 +0,0 @@
"""资料导入候选审核测试(5c)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.application.imports import drain_pending_extractions
from app.main import app
async def _get_character_id(client: AsyncClient) -> str:
"""通过已认证的 API 获取当前用户活跃角色 ID。"""
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200
return resp.json()["id"]
class _FakeChatResult:
def __init__(self, content: str) -> None:
self.content = content
_PROFILE_RESPONSE = (
'[{"field":"background","value":"来自北京,从事设计工作",'
'"confidence":0.9,"reason":"资料提及","evidence":[0]}]'
)
_MEMORY_RESPONSE = (
'[{"type":"preference","title":"喜欢简洁回答","content":"用户偏好简洁直接的回答",'
'"importance":0.8,"sensitivity":"low","reason":"多次提及","evidence":[0]}]'
)
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_chat(*_args, model_hint, **_kwargs):
if model_hint == "memory-extraction":
return _FakeChatResult(_MEMORY_RESPONSE)
return _FakeChatResult(_PROFILE_RESPONSE)
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _MockManager:
async def chat(self, _messages, model_hint=None):
return await fake_chat(None, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _MockManager())
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _setup(client: AsyncClient, kind: str) -> tuple[str, str, str]:
character_id = await _get_character_id(client)
import_id = (
await client.post(
f"/api/v1/characters/{character_id}/imports",
json={"material_type": "background_story", "content": "我叫小魂,来自北京。"},
)
).json()["id"]
await client.post(f"/api/v1/characters/{character_id}/imports/{import_id}/{kind}")
await drain_pending_extractions()
candidates = (
await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}/{kind}")
).json()
return character_id, import_id, candidates[0]["id"]
@pytest.mark.asyncio
async def test_batch_review_profile_approve_writes_profile(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "profile-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.status_code == 200
assert result.json()["applied"] == 1
character = (await client.get(f"/api/v1/characters/{character_id}")).json()
assert character["profile"]["background"] == "来自北京,从事设计工作"
@pytest.mark.asyncio
async def test_batch_review_memory_approve_creates_memory(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "memory-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/memory-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.json()["applied"] == 1
memories = (await client.get("/api/v1/memory")).json()
assert memories["total"] >= 1
assert any(m["title"] == "喜欢简洁回答" for m in memories["items"])
@pytest.mark.asyncio
async def test_batch_review_reject_marks_candidate(client: AsyncClient) -> None:
character_id, import_id, candidate_id = await _setup(client, "profile-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "reject"}]},
)
assert result.json()["rejected"] == 1
rejected = (
await client.get(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates",
params={"status": "rejected"},
)
).json()
assert any(c["id"] == candidate_id for c in rejected)
@pytest.mark.asyncio
async def test_batch_review_skips_non_pending(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "profile-candidates")
# 先 approve
await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
# 再审同一候选(已 applied)→ skipped
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.json()["skipped"] == 1
assert result.json()["applied"] == 0
@@ -1,64 +0,0 @@
"""大周天循环骨架测试(触发 + 阶段化执行)。"""
import pytest
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import big_cycle as big_cycle_repo
from app.services.characters import get_active_character_model
async def _seed_memories(character_id: str, count: int) -> None:
async with AsyncSessionLocal() as session:
for i in range(count):
session.add(
Memory(
character_id=character_id,
type="user",
title=f"记忆{i}",
content=f"内容{i}",
)
)
await session.commit()
@pytest.mark.asyncio
async def test_big_cycle_runs_when_threshold_met() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
assert cycle is not None
assert cycle.status == "completed"
assert cycle.memory_count_at_trigger >= 20
@pytest.mark.asyncio
async def test_big_cycle_skips_below_threshold() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
# 默认人物 0 记忆,低于阈值
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is None
@pytest.mark.asyncio
async def test_big_cycle_respects_min_interval() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
first = await maybe_trigger_big_cycle(character.id, trigger="test")
assert first is not None
await drain_pending_big_cycles()
# 间隔窗口内再次触发 → 跳过
second = await maybe_trigger_big_cycle(character.id, trigger="test")
assert second is None
@@ -1,118 +0,0 @@
"""大周天内驱力生成测试(_phase_evaluation 子步骤 _generate_drive)。"""
import pytest
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import drive as drive_repo
from app.services.characters import get_active_character_model
_DRIVE_RESPONSE = (
'[{"question":"用户为何频繁提到项目X","source":"memory_gap",'
'"expected_value":0.7,"urgency":0.5,"risk_level":"L2",'
'"related_entities":["项目X"],'
'"subgoals":[{"title":"询问项目X目标","description":"主动询问用户",'
'"expected_information_gain":0.6,"interruption_cost":0.3,"risk_level":"L4"}]}]'
)
async def _seed_memories(character_id: str, count: int) -> None:
async with AsyncSessionLocal() as session:
for i in range(count):
session.add(
Memory(
character_id=character_id,
type="user",
title=f"记忆{i}",
content=f"内容{i}",
)
)
await session.commit()
@pytest.fixture
def mock_drive_chat(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(*_args, **_kwargs):
return _DRIVE_RESPONSE
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
@pytest.mark.asyncio
async def test_big_cycle_generates_curiosity_and_subgoal(
mock_drive_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
curiosities, c_total = await drive_repo.list_curiosities(session, character.id)
subgoals, s_total = await drive_repo.list_subgoals(session, character.id)
assert c_total == 1
assert curiosities[0].question == "用户为何频繁提到项目X"
assert curiosities[0].source == "memory_gap"
assert curiosities[0].cycle_id == cycle_id
assert s_total == 1
assert subgoals[0].status == "candidate"
assert subgoals[0].curiosity_question_id == curiosities[0].id
assert subgoals[0].title == "询问项目X目标"
@pytest.mark.asyncio
async def test_big_cycle_auto_approves_low_risk_subgoal(
mock_drive_chat, # noqa: ARG001
) -> None:
"""低风险次生目标(L0-L2)应在大周天中自动通过并生成 Goal。"""
from sqlalchemy import select
from app.models.governance import Goal
# 使用低风险的子目标响应
_low_risk_response = (
'[{"question":"还有哪些未知领域","source":"world_uncertainty",'
'"expected_value":0.6,"urgency":0.4,"risk_level":"L1",'
'"subgoals":[{"title":"探索未知领域","description":"自动探索",'
'"expected_information_gain":0.5,"interruption_cost":0.2,"risk_level":"L1"}]}]'
)
async def fake_low_risk(*_args, **_kwargs):
return _low_risk_response
import app.application.big_cycle as bc
original = bc._call_model
bc._call_model = fake_low_risk
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
bc._call_model = original
async with AsyncSessionLocal() as session:
subgoals, s_total = await drive_repo.list_subgoals(session, character.id)
assert s_total == 1
assert subgoals[0].status in ("approved", "auto_execute_pending"), (
f"预期 approved 或 auto_execute_pending,实际={subgoals[0].status}"
)
# 验证自动生成了 Goal
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Goal).where(Goal.character_id == character.id)
)
goals = result.scalars().all()
matching = [g for g in goals if g.title == "探索未知领域"]
assert len(matching) == 1
assert matching[0].level == "task"
@@ -1,142 +0,0 @@
"""大周天候选生成 + 半自动应用调度测试。"""
import pytest
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
from app.core.config import settings
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import big_cycle as big_cycle_repo
from app.repositories import evolution as evolution_repo
from app.services.characters import get_active_character_model
_MIXED_RESPONSE = (
"["
'{"type":"personality","level":"L4","scope":"character_scope",'
'"title":"身份演化","proposal":"调整身份表达",'
'"delta":{"identity_profile":"新的身份描述"},'
'"confidence":0.7,"benefit_score":0.6,"risk_score":0.4,'
'"auto_apply_allowed":false,"evidence":[0]},'
'{"type":"procedural","level":"L2","scope":"task_scope",'
'"title":"确认假设","proposal":"技术问题先确认前提",'
'"delta":{"title":"确认假设","content":"先确认前提再作答"},'
'"confidence":0.8,"benefit_score":0.7,"risk_score":0.2,'
'"auto_apply_allowed":true,"evidence":[0]}'
"]"
)
async def _seed_memories(character_id: str, count: int) -> None:
async with AsyncSessionLocal() as session:
for i in range(count):
session.add(
Memory(
character_id=character_id,
type="user",
title=f"记忆{i}",
content=f"内容{i}",
)
)
await session.commit()
@pytest.fixture
def mock_evolution_chat(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(*_args, **_kwargs):
return _MIXED_RESPONSE
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
@pytest.mark.asyncio
async def test_big_cycle_generates_candidates_and_auto_applies(
mock_evolution_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
candidates, total = await evolution_repo.list_candidates(session, character.id)
assert cycle is not None
assert cycle.status == "completed"
assert cycle.evaluation_snapshot_json is not None
assert total == 2
personality = next(c for c in candidates if c.type == "personality")
assert personality.status == "pending" # L4 auto_apply_allowed=false
assert personality.level == "L4"
assert personality.source == "big_cycle"
assert personality.cycle_id == cycle_id
procedural = next(c for c in candidates if c.type == "procedural")
assert procedural.status == "applied" # L2 auto_apply_allowed=true
assert procedural.resulting_target_id is not None
@pytest.mark.asyncio
async def test_auto_apply_degrades_when_applier_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = (
'[{"type":"tool_strategy","level":"L2","scope":"task_scope",'
'"title":"工具策略","proposal":"偏好shell",'
'"delta":{},"auto_apply_allowed":true}]'
)
async def fake_chat(*_args, **_kwargs):
return response
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
await maybe_trigger_big_cycle(character.id, trigger="test")
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
candidates, _ = await evolution_repo.list_candidates(session, character.id)
assert len(candidates) == 1
cand = candidates[0]
assert cand.status == "pending" # applier 缺失,降级不自动应用
assert cand.auto_apply_allowed is True
@pytest.mark.asyncio
async def test_big_cycle_uses_configured_model_hint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict = {}
result = type("FakeResponse", (), {"content": "[]"})()
class _FakeManager:
def to_message_list(self, *_args: object, **_kwargs: object) -> list:
return []
async def chat(self, messages: object, model_hint: str | None = None) -> object: # noqa: ARG002
captured["model_hint"] = model_hint
return result
def fake_get_provider_manager() -> _FakeManager:
return _FakeManager()
monkeypatch.setattr(
"app.application.big_cycle._get_provider_manager", fake_get_provider_manager
)
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
await maybe_trigger_big_cycle(character.id, trigger="test")
await drain_pending_big_cycles()
assert captured["model_hint"] == settings.big_cycle_model
@@ -1,76 +0,0 @@
"""大周天记忆/世界抽取生成测试(_phase_memory 阶段)。
高置信度(≥0.7)低敏感度记忆候选自动落地到 memories 表;
世界实体候选(confidence=0.6)低于阈值仍留 pending。
"""
import pytest
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import world as world_repo
from app.services.characters import get_active_character_model
_EXTRACTION_RESPONSE = (
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
'"importance":0.8,"sensitivity":"low","confidence":0.7}],'
'"entities":[{"type":"project","name":"项目X","attributes":{"status":"active"},'
'"state_type":"inference","confidence":0.6}]}'
)
async def _seed_memories(character_id: str, count: int) -> None:
async with AsyncSessionLocal() as session:
for i in range(count):
session.add(
Memory(
character_id=character_id,
type="user",
title=f"记忆{i}",
content=f"内容{i}",
)
)
await session.commit()
@pytest.fixture
def mock_extraction_chat(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(*_args, **_kwargs):
return _EXTRACTION_RESPONSE
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
@pytest.mark.asyncio
async def test_big_cycle_extracts_memory_and_world(
mock_extraction_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
# 高置信度(≥0.7)低敏感度记忆自动落地到 memories 表
from sqlalchemy import select
result = await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.source == "big_cycle",
)
)
auto_memories = result.scalars().all()
# 世界实体候选(confidence=0.6 < 0.7)仍留 pending
entities, _ = await world_repo.list_entities(session, character.id, status="pending")
assert len(auto_memories) == 1
assert auto_memories[0].title == "喜欢简洁"
assert len(entities) == 1
assert entities[0].name == "项目X"
assert entities[0].status == "pending"
assert entities[0].state_type == "inference"
@@ -1,171 +0,0 @@
"""大周天中的目标进度更新测试。
验证 _phase_goal_progress 在独立环境中按预期工作:
- 活跃的 task/session 目标进度 +0.1
- 进度上限 1.0
- 非活跃目标不受影响
- 进度达 1.0 的 task 目标状态标记为 completed
"""
import pytest
from app.application.big_cycle import _phase_goal_progress
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.governance import Goal
@pytest.mark.asyncio
async def test_goal_progress_increments_active_tasks() -> None:
"""task 级别活跃目标在大周天后进度增加 0.1。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg1", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg1",
title="测试任务",
level="task",
status="active",
progress=0.3,
)
session.add(goal)
await session.flush()
await _phase_goal_progress(session, cycle)
assert goal.progress == 0.4
@pytest.mark.asyncio
async def test_goal_progress_caps_at_one() -> None:
"""进度达到 1.0 后不再增加。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg2", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg2",
title="已满进度",
level="task",
status="active",
progress=1.0,
)
session.add(goal)
await session.flush()
await _phase_goal_progress(session, cycle)
# 已达上限,不再增加
assert goal.progress == 1.0
@pytest.mark.asyncio
async def test_goal_progress_skips_non_active() -> None:
"""非活跃目标不被更新。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg3", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg3",
title="已完成任务",
level="task",
status="completed",
progress=0.5,
)
session.add(goal)
await session.flush()
await _phase_goal_progress(session, cycle)
assert goal.progress == 0.5
@pytest.mark.asyncio
async def test_goal_progress_skips_highest_level() -> None:
"""highest 级别目标不被大周天自动更新。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg4", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg4",
title="最高使命",
level="highest",
type="mission",
status="active",
progress=0.5,
)
session.add(goal)
await session.flush()
await _phase_goal_progress(session, cycle)
assert goal.progress == 0.5
@pytest.mark.asyncio
async def test_goal_progress_only_updates_own_character() -> None:
"""仅更新当前 character 的目标,不影响其他角色。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg5", trigger="test")
session.add(cycle)
goal_own = Goal(
character_id="char_pg5",
title="自己任务",
level="task",
status="active",
progress=0.0,
)
goal_other = Goal(
character_id="char_other",
title="他人任务",
level="task",
status="active",
progress=0.0,
)
session.add_all([goal_own, goal_other])
await session.flush()
await _phase_goal_progress(session, cycle)
assert goal_own.progress == 0.1
assert goal_other.progress == 0.0
@pytest.mark.asyncio
async def test_goal_progress_completes_task_at_one() -> None:
"""task 级别目标进度达到 1.0 后状态变为 completed。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg6", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg6",
title="即将完成",
level="task",
status="active",
progress=0.95,
)
session.add(goal)
await session.flush()
await session.commit()
cycle_id = cycle.id
goal_id = goal.id
# 清理可能被前序测试设置的 event_store,避免 emit_goal_event 的独立 session 冲突
from app.main import app as _app
_app.state.__dict__.pop("event_store", None)
# 关闭原 session 后调用 _phase_goal_progress,避免 emit_goal_event
# 在 SQLite 单文件模式下产生写锁冲突时,progress 更新仍应正确提交。
async with AsyncSessionLocal() as session:
cycle_ref = await session.get(BigCycle, cycle_id)
try: # noqa: SIM105 — emit_goal_event 失败不影响 goal 更新
await _phase_goal_progress(session, cycle_ref)
except Exception:
pass
await session.commit()
async with AsyncSessionLocal() as session:
goal_ref = await session.get(Goal, goal_id)
assert goal_ref is not None
assert goal_ref.progress == 1.0
assert goal_ref.status == "completed"
@@ -1,100 +0,0 @@
"""小周天每轮记忆候选 + 反思抽取测试。
高置信度(≥0.7)低敏感度记忆候选直接落地到 memories 表而非 MemoryCandidate。
"""
import pytest
from sqlalchemy import select
from app.application.reflection import (
drain_pending_reflections,
start_reflection_after_turn,
)
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import candidates as candidates_repo
from app.services.characters import get_active_character_model
_REFLECTION_RESPONSE = (
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
'"importance":0.8,"sensitivity":"low","confidence":0.7}],'
'"reflection":"本轮应更主动确认用户意图。"}'
)
@pytest.fixture
def mock_reflection_chat(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(*_args, **_kwargs):
return _REFLECTION_RESPONSE
monkeypatch.setattr("app.application.reflection._call_model", fake_chat)
@pytest.mark.asyncio
async def test_reflection_extracts_memory_and_writes_reflection(
mock_reflection_chat, # noqa: ARG001
) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
start_reflection_after_turn(character.id, "session_test", "你好", "你好,我是魂镜。")
await drain_pending_reflections()
async with AsyncSessionLocal() as session:
# 高置信度记忆直接落地到 memories 表(非 MemoryCandidate)
auto_mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.type == "preference",
Memory.source == "reflection",
)
)
).scalar_one_or_none()
# 反思文本也落地为 memories 表
ref_mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.type == "reflection",
)
)
).scalar_one_or_none()
assert auto_mem is not None
assert auto_mem.title == "喜欢简洁"
assert ref_mem is not None
assert ref_mem.source == "reflection"
assert ref_mem.status == "active" # Memory 无 status 字段,用 default
assert "确认用户意图" in ref_mem.content
@pytest.mark.asyncio
async def test_reflection_without_memories_only_writes_reflection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_chat(*_args, **_kwargs):
return '{"memories":[],"reflection":"只反思不抽记忆。"}'
monkeypatch.setattr("app.application.reflection._call_model", fake_chat)
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
start_reflection_after_turn(character.id, "session_test", "hi", "你好。")
await drain_pending_reflections()
async with AsyncSessionLocal() as session:
cands = await candidates_repo.list_memory_candidates(session, character.id, source="cycle")
ref_mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.type == "reflection",
)
)
).scalar_one_or_none()
# 无记忆候选(空 memories 数组),只有反思
assert len(cands) == 0
assert ref_mem is not None
assert "只反思" in ref_mem.content
@@ -1,110 +0,0 @@
"""大周天记忆/世界抽取纯函数测试。"""
from app.modules.extraction.extract import (
MemoryCandidateDraft,
WorldEntityDraft,
parse_extraction,
)
def test_parse_standard_object() -> None:
raw = (
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
'"importance":0.8,"sensitivity":"low","confidence":0.7,"reason":"多次提及"}],'
'"entities":[{"type":"project","name":"项目X","attributes":{"status":"active"},'
'"state_type":"inference","confidence":0.6}]}'
)
memories, entities = parse_extraction(raw)
assert len(memories) == 1
assert memories[0].title == "喜欢简洁"
assert memories[0].type == "preference"
assert memories[0].importance == 0.8
assert len(entities) == 1
assert entities[0].name == "项目X"
assert entities[0].type == "project"
assert entities[0].attributes == {"status": "active"}
assert entities[0].state_type == "inference"
def test_parse_markdown_block() -> None:
raw = '```json\n{"memories":[],"entities":[]}\n```'
memories, entities = parse_extraction(raw)
assert memories == []
assert entities == []
def test_parse_text_mixed() -> None:
raw = '抽取结果:\n{"memories":[{"title":"t","content":"c"}],"entities":[]}\n以上'
memories, entities = parse_extraction(raw)
assert len(memories) == 1
assert entities == []
def test_parse_invalid_returns_empty() -> None:
assert parse_extraction("不是JSON") == ([], [])
assert parse_extraction("[]") == ([], []) # 数组不是对象
def test_missing_keys_returns_empty_lists() -> None:
memories, entities = parse_extraction('{"memories":[]}')
assert memories == []
assert entities == []
def test_skip_memory_missing_title_or_content() -> None:
raw = '{"memories":[{"title":"t"},{"content":"c"},{"title":"t","content":"c"}]}'
memories, _ = parse_extraction(raw)
assert len(memories) == 1
def test_skip_entity_missing_name() -> None:
raw = '{"entities":[{"type":"project"},{"name":"有名字"}]}'
_, entities = parse_extraction(raw)
assert len(entities) == 1
assert entities[0].name == "有名字"
def test_memory_safe_defaults() -> None:
raw = '{"memories":[{"title":"t","content":"c"}]}'
m = parse_extraction(raw)[0][0]
assert m.sensitivity == "low"
assert m.type == "other"
assert m.confidence == 0.5
assert m.importance == 0.5
def test_entity_safe_defaults() -> None:
raw = '{"entities":[{"name":"n"}]}'
e = parse_extraction(raw)[1][0]
assert e.state_type == "inference" # 非 fact,降权
assert e.type == "concept"
assert e.confidence == 0.5
assert e.attributes == {}
def test_clamp_values() -> None:
raw = (
'{"memories":[{"title":"t","content":"c","importance":1.5,"confidence":-0.2}],'
'"entities":[{"name":"n","confidence":2.0}]}'
)
memories, entities = parse_extraction(raw)
assert memories[0].importance == 1.0
assert memories[0].confidence == 0.0
assert entities[0].confidence == 1.0
def test_invalid_sensitivity_defaults() -> None:
raw = '{"memories":[{"title":"t","content":"c","sensitivity":"unknown"}]}'
assert parse_extraction(raw)[0][0].sensitivity == "low"
def test_invalid_state_type_defaults() -> None:
raw = '{"entities":[{"name":"n","state_type":"certain"}]}'
assert parse_extraction(raw)[1][0].state_type == "inference"
def test_draft_types() -> None:
raw = '{"memories":[{"title":"t","content":"c"}],"entities":[{"name":"n"}]}'
memories, entities = parse_extraction(raw)
assert isinstance(memories[0], MemoryCandidateDraft)
assert isinstance(entities[0], WorldEntityDraft)
@@ -1,34 +0,0 @@
"""大周天记忆/世界抽取 prompt 构建纯函数测试。"""
from app.modules.big_cycle.prompts import EvaluationSnapshot
from app.modules.extraction.prompts import build_extraction_messages
def _snapshot() -> EvaluationSnapshot:
return EvaluationSnapshot(
character_id="character_x",
memory_count=15,
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
personality_summary="身份:魂镜",
world_entity_count=4,
conclusion="近期对话涉及项目X,存在背景缺口",
)
def test_messages_return_system_user_pair() -> None:
system, user = build_extraction_messages(_snapshot())
assert isinstance(system, str) and isinstance(user, str)
assert system and user
def test_system_describes_memories_and_entities_structure() -> None:
system, _ = build_extraction_messages(_snapshot())
for token in ("memories", "entities", "state_type", "sensitivity", "importance"):
assert token in system
def test_user_includes_snapshot_signals() -> None:
_, user = build_extraction_messages(_snapshot())
assert "用户偏好简洁" in user
assert "身份:魂镜" in user
assert "15" in user
@@ -1,66 +0,0 @@
"""小周天每轮反思抽取纯函数测试。"""
from app.modules.extraction.extract import MemoryCandidateDraft
from app.modules.reflection.extract import parse_reflection
def test_parse_standard() -> None:
raw = (
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
'"importance":0.8,"sensitivity":"low","confidence":0.7,"reason":"多次提及"}],'
'"reflection":"本轮应更主动确认用户意图。"}'
)
memories, reflection = parse_reflection(raw)
assert len(memories) == 1
assert memories[0].title == "喜欢简洁"
assert isinstance(memories[0], MemoryCandidateDraft)
assert reflection == "本轮应更主动确认用户意图。"
def test_parse_markdown_block() -> None:
raw = '```json\n{"memories":[],"reflection":"反思文本"}\n```'
memories, reflection = parse_reflection(raw)
assert memories == []
assert reflection == "反思文本"
def test_parse_invalid_returns_empty() -> None:
assert parse_reflection("不是JSON") == ([], "")
assert parse_reflection("[]") == ([], "")
def test_missing_reflection_returns_empty_string() -> None:
memories, reflection = parse_reflection('{"memories":[]}')
assert memories == []
assert reflection == ""
def test_missing_memories_returns_empty_list() -> None:
memories, reflection = parse_reflection('{"reflection":"只有反思"}')
assert memories == []
assert reflection == "只有反思"
def test_memory_safe_defaults() -> None:
raw = '{"memories":[{"title":"t","content":"c"}],"reflection":"r"}'
m = parse_reflection(raw)[0][0]
assert m.sensitivity == "low"
assert m.confidence == 0.5
assert m.importance == 0.5
def test_skip_memory_missing_title_or_content() -> None:
raw = (
'{"memories":[{"title":"t"},{"content":"c"},{"title":"t","content":"c"}],"reflection":"r"}'
)
assert len(parse_reflection(raw)[0]) == 1
def test_clamp_values() -> None:
raw = '{"memories":[{"title":"t","content":"c","importance":1.5}],"reflection":"r"}'
assert parse_reflection(raw)[0][0].importance == 1.0
def test_reflection_strips_whitespace() -> None:
raw = '{"memories":[],"reflection":" 带空格的反思 "}'
assert parse_reflection(raw)[1] == "带空格的反思"
@@ -1,21 +0,0 @@
"""小周天反思 prompt 构建纯函数测试。"""
from app.modules.reflection.prompts import build_reflection_messages
def test_messages_return_system_user_pair() -> None:
system, user = build_reflection_messages("你好", "你好,我是魂镜")
assert isinstance(system, str) and isinstance(user, str)
assert system and user
def test_system_describes_memories_and_reflection() -> None:
system, _ = build_reflection_messages("u", "a")
for token in ("memories", "reflection", "importance", "sensitivity"):
assert token in system
def test_user_includes_dialogue() -> None:
_, user = build_reflection_messages("用户说的话", "助手的回复")
assert "用户说的话" in user
assert "助手的回复" in user
@@ -1,120 +0,0 @@
"""演化候选派生 applier 测试。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.repositories import evolution as evolution_repo
from app.repositories import personality as personality_repo
from app.repositories.json_utils import dumps
from app.services import evolution as evolution_service
from app.services.characters import get_active_character_model
from app.services.personality import create_default_personality
async def _setup(session) -> tuple:
character = await get_active_character_model(session)
if await personality_repo.get_latest_personality(session, character.id) is None:
await create_default_personality(session, character.id)
await session.flush()
cycle = BigCycle(
character_id=character.id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
return character, cycle
def _candidate(character_id: str, cycle_id: str, **overrides) -> EvolutionCandidate:
base = {
"character_id": character_id,
"cycle_id": cycle_id,
"type": "personality",
"source": "big_cycle",
"level": "L4",
"scope": "character_scope",
"title": "t",
"proposal": "p",
"delta_json": "{}",
"evidence_ids_json": "[]",
"confidence": 0.5,
"benefit_score": 0.5,
"risk_score": 0.5,
"drift_score": 0.0,
"auto_apply_allowed": False,
"status": "pending",
}
base.update(overrides)
return EvolutionCandidate(**base)
@pytest.mark.asyncio
async def test_apply_personality_candidate_derives_version() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
base_version = await personality_repo.get_latest_personality(session, character.id)
candidate = _candidate(
character.id,
cycle.id,
type="personality",
delta_json=dumps({"expression_style": "更简洁、更主动"}),
title="表达风格演化",
)
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
await session.commit()
assert target_id is not None
async with AsyncSessionLocal() as session:
new_version = await personality_repo.get_personality(session, target_id)
character2 = await get_active_character_model(session)
assert new_version is not None
assert new_version.version == base_version.version + 1
assert new_version.created_by == "big_cycle"
assert "更简洁" in new_version.expression_style
assert character2.current_personality_version_id == target_id
@pytest.mark.asyncio
async def test_apply_procedural_candidate_creates_memory() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
candidate = _candidate(
character.id,
cycle.id,
type="procedural",
level="L2",
delta_json=dumps({"title": "先确认关键假设", "content": "技术问题先确认前提再作答"}),
)
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
await session.commit()
assert target_id is not None
async with AsyncSessionLocal() as session:
mem = await session.get(Memory, target_id)
assert mem is not None
assert mem.type == "procedural"
assert mem.title == "先确认关键假设"
assert mem.source == "big_cycle"
@pytest.mark.asyncio
async def test_apply_unimplemented_type_returns_none() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
candidate = _candidate(character.id, cycle.id, type="tool_strategy", level="L2")
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
result = await evolution_service.apply_evolution_candidate(session, candidate)
assert result is None
@@ -1,156 +0,0 @@
"""大周天抽取候选审核服务测试(记忆候选 + 世界实体候选)。"""
import pytest
from sqlalchemy import select
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate
from app.models.memory import Memory
from app.models.world import CharacterWorldEntity
from app.services import extraction as extraction_service
from app.services.characters import get_active_character_model
async def _seed_cycle(session, character_id: str) -> str:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
return cycle.id
async def _seed_memory_candidate(
session, character_id: str, cycle_id: str, status: str = "pending"
) -> str:
cand = MemoryCandidate(
character_id=character_id,
cycle_id=cycle_id,
type="preference",
title="喜欢简洁",
content="用户偏好简洁直接的回答",
confidence=0.7,
importance=0.8,
sensitivity="low",
status=status,
)
session.add(cand)
await session.flush()
return cand.id
async def _seed_world_entity(session, character_id: str, status: str = "pending") -> str:
e = CharacterWorldEntity(
character_id=character_id,
type="project",
name="项目X",
state_type="inference",
confidence=0.6,
status=status,
)
session.add(e)
await session.flush()
return e.id
@pytest.mark.asyncio
async def test_approve_memory_candidate_derives_memory() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id)
await session.commit()
resp = await extraction_service.approve_memory_proposal(cid)
assert resp.status == "applied"
async with AsyncSessionLocal() as session:
mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.title == "喜欢简洁",
)
)
).scalar_one_or_none()
assert mem is not None
assert mem.source == "big_cycle"
@pytest.mark.asyncio
async def test_reject_memory_candidate() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id)
await session.commit()
resp = await extraction_service.reject_memory_proposal(cid)
assert resp.status == "rejected"
@pytest.mark.asyncio
async def test_approve_non_pending_memory_conflicts() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id, status="applied")
await session.commit()
with pytest.raises(AppError):
await extraction_service.approve_memory_proposal(cid)
@pytest.mark.asyncio
async def test_approve_world_entity_confirms() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
eid = await _seed_world_entity(session, character.id)
await session.commit()
resp = await extraction_service.approve_world_entity(eid)
assert resp.status == "confirmed"
@pytest.mark.asyncio
async def test_reject_world_entity() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
eid = await _seed_world_entity(session, character.id)
await session.commit()
resp = await extraction_service.reject_world_entity(eid)
assert resp.status == "rejected"
@pytest.mark.asyncio
async def test_list_memory_proposals_source_cycle() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
await _seed_memory_candidate(session, character.id, cycle_id)
# 导入来源候选(import_job_id 非空)
session.add(
MemoryCandidate(
character_id=character.id,
import_job_id="import_test",
type="user",
title="导入记忆",
content="来自导入",
confidence=0.5,
importance=0.5,
sensitivity="low",
status="pending",
)
)
await session.commit()
page = await extraction_service.list_memory_proposals(character_id=character.id, source="cycle")
assert page.total == 1
assert page.items[0].title == "喜欢简洁"
assert page.items[0].cycle_id == cycle_id
-41
View File
@@ -214,47 +214,6 @@ async def test_world_entities_crud(client: AsyncClient) -> None:
assert gone.status_code in (200, 404) assert gone.status_code in (200, 404)
@pytest.mark.asyncio
async def test_memory_list_with_filters(client: AsyncClient) -> None:
"""记忆列表支持类型/来源过滤——覆盖 services/memory.list_memory_page 路径。"""
m1 = await client.post(
"/api/v1/memory",
json={
"type": "observation",
"title": "观测记忆",
"content": "一条观测记忆",
"source": "observation",
},
)
assert m1.status_code == 200
m2 = await client.post(
"/api/v1/memory",
json={
"type": "reflection",
"title": "反思记忆",
"content": "一条反思记忆",
"source": "reflection",
},
)
assert m2.status_code == 200
# 按类型过滤
obs = await client.get("/api/v1/memory?type=observation")
assert obs.status_code == 200
assert all(item["type"] == "observation" for item in obs.json()["items"])
# 按来源过滤
ref = await client.get("/api/v1/memory?source=reflection")
assert ref.status_code == 200
assert all(item["source"] == "reflection" for item in ref.json()["items"])
# 总量 >= 2
all_mem = await client.get("/api/v1/memory")
assert all_mem.status_code == 200
assert all_mem.json()["total"] >= 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_safety_policy_crud(client: AsyncClient) -> None: async def test_safety_policy_crud(client: AsyncClient) -> None:
"""安全策略 CRUD——覆盖 services/governance 部分路径。""" """安全策略 CRUD——覆盖 services/governance 部分路径。"""
-114
View File
@@ -1,114 +0,0 @@
"""记忆上下文组装测试。"""
from datetime import UTC, datetime, timedelta
from app.models.memory import Memory
from app.modules.memory.context import (
MemoryRetrievalResult,
build_memory_context,
)
def _make_memory(
*,
importance: float = 0.5,
hours_ago: float = 1.0,
title: str = "测试记忆",
content: str = "这是一条测试记忆内容。",
) -> Memory:
return Memory(
id=f"mem_{title}",
character_id="char_1",
type="observation",
title=title,
content=content,
importance=importance,
created_at=datetime.now(UTC) - timedelta(hours=hours_ago),
source="test",
confidence=1.0,
sensitivity="low",
evidence_ids_json="[]",
status="active",
)
def test_empty_memories_returns_none() -> None:
result = MemoryRetrievalResult(memories=[])
assert build_memory_context(result) is None
def test_single_memory_returns_text() -> None:
mem = _make_memory()
result = MemoryRetrievalResult(memories=[mem])
text = build_memory_context(result)
assert text is not None
assert "相关记忆:" in text
assert "测试记忆" in text
def test_high_importance_memory_comes_first() -> None:
low = _make_memory(importance=0.3, title="不重要的")
high = _make_memory(importance=0.9, title="重要的")
result = MemoryRetrievalResult(memories=[low, high])
text = build_memory_context(result)
assert text is not None
important_pos = text.index("重要的")
trivial_pos = text.index("不重要的")
assert important_pos < trivial_pos, "重要性高的应该排前面"
def test_memories_are_sorted_by_importance_descending() -> None:
"""传入任意顺序,输出始终重要性降序。"""
a = _make_memory(importance=0.5, title="中")
b = _make_memory(importance=0.1, title="低")
c = _make_memory(importance=0.9, title="高")
result = MemoryRetrievalResult(memories=[a, b, c])
text = build_memory_context(result)
assert text is not None
positions = {title: text.index(title) for title in ["高", "中", "低"]}
assert positions["高"] < positions["中"]
assert positions["中"] < positions["低"]
def test_importance_label_appears() -> None:
high = _make_memory(importance=0.9, title="重要记忆", content="重要内容")
medium = _make_memory(importance=0.5, title="普通记忆", content="普通内容")
low = _make_memory(importance=0.2, title="低优先级", content="低优先级内容")
result = MemoryRetrievalResult(memories=[high, medium, low])
text = build_memory_context(result)
assert text is not None
assert "★重要" in text
assert "●一般" in text
assert "○参考" in text
def test_time_labels() -> None:
just_now = _make_memory(title="刚刚发生", hours_ago=0.1)
hours_ago = _make_memory(title="几小时前", hours_ago=5)
days_ago = _make_memory(title="几天前", hours_ago=72)
result = MemoryRetrievalResult(memories=[just_now, hours_ago, days_ago])
text = build_memory_context(result)
assert text is not None
assert "刚刚" in text
assert "5小时前" in text or "几小时前" in text
assert "3天前" in text
def test_truncation_respects_max_length() -> None:
content = "A" * 500
mem = _make_memory(content=content, title="一长内容")
result = MemoryRetrievalResult(memories=[mem] * 20)
text = build_memory_context(result, max_length=500)
assert text is not None
assert len(text) <= 550 # max_length + header + padding
assert "已截断" in text
def test_large_content_not_exceeding_max_length() -> None:
"""大量记忆时,内容不应超过 max_length 太多。"""
mems = [_make_memory(content="B" * 200, title=f"记忆{i}", importance=0.5) for i in range(50)]
result = MemoryRetrievalResult(memories=mems)
text = build_memory_context(result, max_length=1000)
assert text is not None
assert len(text) <= 1200 # 1000 + header + some padding
+257
View File
@@ -0,0 +1,257 @@
"""息壤(M3)测试:对应 rewrite-plan M3 六条验收。
1. 未归真旁枝自动入忆率 = 0(只有归真/手动入忆产生候选);
2. 重忆未确认写入被拒(HJ-1201 语义:sensitive→pending,仅 confirm 后 active);
3. deny 两次退出检索;
4. 正影后旧版停止普通召回(superseded),内容不改写;
5. 删除后全链路停用(墓碑无原文、检索/注入不出现);
6. 召回带来源可展开(source_refs 指向归真记录);归真触发实录与提取候选。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.domains.jingsheng import bind_event_store
from app.kernel import Kernel, KernelEventLog
from app.services.event_store import DbEventStore
from app.shenshi_client import EchoShenshi
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
from app.main import app
event_store = DbEventStore(AsyncSessionLocal)
bind_event_store(event_store)
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(event_store),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def _tree_with_answer(client: AsyncClient, title: str) -> tuple[str, str, str, str]:
"""建树→问→答→返回 (tree_id, q, a, character_id)。"""
embryo = (
await client.post("/api/v1/embryo-drafts", json={"source": "original", "name": f"忆灵{title}"})
).json()
character_id = (
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
)
tree = (
await client.post("/api/v1/trees", json={"character_id": character_id, "title": title})
).json()
q = (await client.post(f"/api/v1/trees/{tree['id']}/nodes", json={"content": "你记得什么"})).json()
spawned = (await client.post(f"/api/v1/nodes/{q['id']}/answers", json={})).json()
for _ in range(300):
status = (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"]
if status in ("completed", "interrupted", "failed"):
break
await asyncio.sleep(0.02)
return tree["id"], q["id"], spawned["node_id"], character_id
async def _memories(client: AsyncClient, character_id: str) -> list[dict]:
return (await client.get(f"/api/v1/characters/{character_id}/memories")).json()
async def _canonize(client: AsyncClient, tree_id: str, endpoint: str, scope: str = "reality") -> dict:
resp = await client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={"endpoint_node_id": endpoint, "canonical_scope": scope},
)
assert resp.status_code == 200, resp.text
return resp.json()
async def _wait_candidate(client: AsyncClient, character_id: str, timeout_s: float = 5.0) -> list[dict]:
"""等待归真后的异步提取落库。"""
for _ in range(int(timeout_s / 0.05)):
items = await _memories(client, character_id)
if items:
return items
await asyncio.sleep(0.05)
return []
# ── 验收 1:未归真不入忆 ──────────────────────────────────────────────
async def test_uncanonized_branch_never_memorized(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "旁支树")
# 未归真:多次另答也不产生任何记忆
for _ in range(2):
spawned = (await client.post(f"/api/v1/nodes/{_q}/answers", json={})).json()
for _ in range(200):
if (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"] in (
"completed",
"interrupted",
):
break
await asyncio.sleep(0.02)
await asyncio.sleep(0.2)
assert await _memories(client, character_id) == [] # 自动入忆率 = 0
# ── 验收 6+管线:归真→实录+候选;重敏 pending ──────────────────────────────────────────────
async def test_canonization_creates_factual_record_and_candidates(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "归真树")
await _canonize(client, tree_id, answer_id)
# 实录(同步落库)
records = (await client.get(f"/api/v1/characters/{character_id}/factual-records")).json()
assert len(records) == 1
assert records[0]["evidence"]["path_nodes"]
# 候选(异步提取;EchoShenshi 无 JSON → 确定性回落单条 episodic 候选)
items = await _wait_candidate(client, character_id)
assert len(items) == 1
memory = items[0]
assert memory["tier"] == "explicit"
assert memory["source"] == "self_inferred"
assert memory["status"] in ("candidate", "active")
# 召回透明:来源指向归真记录,可展开
assert memory["source_refs"]
# ── 验收 2:重忆未确认不入 active ──────────────────────────────────────────────
async def test_sensitive_requires_confirmation(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "重忆树")
await _canonize(client, tree_id, answer_id)
await _wait_candidate(client, character_id)
# 直接放置一条重忆候选(提取管线同路径)
placed = await xirang_service.place_candidates(
character_id=character_id,
candidates=[{"content": "用户的健康隐私:对花粉过敏", "type": "persona", "sensitive": True}],
source_refs=["canon_x"],
source="self_inferred",
)
sensitive = next(m for m in placed if m.tier == "sensitive")
assert sensitive.status == "pending" # 未确认绝不 active
# 检索不召回 pending
recalled = await xirang_service.recall_for_generation(character_id, "花粉 过敏")
assert all(m.id != sensitive.id for m in recalled)
# confirm 后进入检索
resp = await client.post(f"/api/v1/memories/{sensitive.id}/confirm")
assert resp.status_code == 200 and resp.json()["status"] == "active"
recalled = await xirang_service.recall_for_generation(character_id, "花粉 过敏")
assert any(m.id == sensitive.id for m in recalled)
# ── 验收 3+5:deny/删除全链路停用 ──────────────────────────────────────────────
async def test_deny_and_delete_exit_everywhere(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "删除树")
await _canonize(client, tree_id, answer_id)
items = await _wait_candidate(client, character_id)
memory_id = items[0]["id"]
denied = await client.post(f"/api/v1/memories/{memory_id}/deny")
assert denied.status_code == 200 and denied.json()["exited_retrieval"] is True
# 墓碑:列表可见状态但无原文;检索不出现
listed = await _memories(client, character_id)
tomb = next(m for m in listed if m["id"] == memory_id)
assert tomb["status"] == "tombstoned" and tomb["content"] == ""
recalled = await xirang_service.recall_for_generation(character_id, "共同经历")
assert all(m.id != memory_id for m in recalled)
# ── 验收 4:正影旧版停召、内容不改写 ──────────────────────────────────────────────
async def test_correction_supersedes_old_version(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "正影树")
await _canonize(client, tree_id, answer_id)
items = await _wait_candidate(client, character_id)
memory_id = items[0]["id"]
corrected = await client.post(
f"/api/v1/memories/{memory_id}/correct", json={"new_content": "正影后的准确版本"}
)
assert corrected.status_code == 200
new_id = corrected.json()["id"]
assert corrected.json()["prev_version_id"] == memory_id
# 旧版 superseded 且不再被召回;新版生效;旧内容保留在行内(不改写历史)
recalled = await xirang_service.recall_for_generation(character_id, "正影")
ids = [m.id for m in recalled]
assert new_id in ids and memory_id not in ids
async with AsyncSessionLocal() as session:
from app.models.xirang import Memory
old = await session.get(Memory, memory_id)
assert old.status == "superseded"
assert "共同经历" in old.content # 原文未被改写
# ── 手动入忆:必须标注类型 ──────────────────────────────────────────────
async def test_manual_memorize_requires_label(client: AsyncClient) -> None:
tree_id, q, answer_id, _ = await _tree_with_answer(client, "手记树")
bad = await client.post(f"/api/v1/nodes/{q}/memorize", json={"label": "unknown"})
assert bad.status_code == 422 or bad.status_code == 400
ok = await client.post(f"/api/v1/nodes/{q}/memorize", json={"label": "insight"})
assert ok.status_code == 201
body = ok.json()
assert body["type"] == "fact_candidate" # 未归真内容只作事实候选并显式标注
assert body["content"].startswith("[insight]")
# ── 注入:获准记忆进入投影 prompt ──────────────────────────────────────────────
async def test_recalled_memory_injected_into_prompt(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "注入树")
await _canonize(client, tree_id, answer_id)
await _wait_candidate(client, character_id)
# 新问节触发另答 → llm.requested 的 prompt 含【获准记忆】段
await client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": answer_id})
q2 = (
await client.post(f"/api/v1/trees/{tree_id}/nodes", json={"content": "再谈谈"})
).json()
spawned = (await client.post(f"/api/v1/nodes/{q2['id']}/answers", json={})).json()
for _ in range(300):
if (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"] in (
"completed",
"interrupted",
):
break
await asyncio.sleep(0.02)
events = (await client.get(f"/api/v1/runs/{spawned['run_id']}/events")).json()
requested = next(e for e in events if e["type"] == "llm.requested")
prompt = requested["payload"]["request"]["messages"][-1]["content"]
assert "【获准记忆(来源可查)】" in prompt
_ = json # 保留 json 引用供调试断言使用
-166
View File
@@ -1,166 +0,0 @@
import { EvolutionList } from "@/components/evolution/EvolutionList";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character, EvolutionCandidate, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 演化候选页 · 大周天自我评估生成的演化增量在此审核。
* 设计稿 evolution-preview.html — 周天四镜 + 候选层积 + 朱砂待裁 + 应用沉积。
* L4 核心变更(人格 / 使命 / 安全边界)需人工通过才写入;低风险类生成时已自动应用。
*/
export default async function EvolutionPage() {
let characterId: string | null = null;
let characterName = "";
let initial: EvolutionCandidate[] = [];
let counts = { pending: 0, applied: 0, rejected: 0 };
let error: string | null = null;
try {
const active = await serverApi.get<Character>("/characters/active");
characterId = active.id;
characterName = active.display_name || active.name;
const [pending, applied, rejected] = await Promise.all([
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "pending", page_size: 50 },
),
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "applied", page_size: 1 },
),
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "rejected", page_size: 1 },
),
]);
initial = pending.items;
counts = {
pending: pending.total,
applied: applied.total,
rejected: rejected.total,
};
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="演化"
description="大周天 · 演化候选"
meta={
<>
{characterName && (
<span className="char-pill">
<span className="avatar-mini">{characterName.charAt(0)}</span>
{characterName}
</span>
)}
<span className="page-meta">
待裁 <b>{counts.pending}</b> · 半自动{" "}
<b style={{ color: "var(--patina)" }}>{counts.applied}</b> · 已应用{" "}
<b>{counts.applied}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : characterId ? (
<div className="space-y-5">
{/* 周天四镜(签名) */}
<div className="cycle-card">
<div className="cycle-head">
<span className="cycle-title">大周天 · 候选阶段</span>
<span className="cycle-id">
cycle_01JZK7Q3B…4F · 触发自会话结束
</span>
<span className="cycle-state">候选生成中</span>
</div>
<div className="mirror-chain">
<div className="mc-step done">
<div className="mc-mirror">
<div className="disc">评</div>
</div>
<span className="mc-name">评估</span>
<span className="mc-sub">evaluation</span>
<span className="mc-yield">态势 ✓</span>
</div>
<span className="mc-link done"></span>
<div className="mc-step done">
<div className="mc-mirror">
<div className="disc">演</div>
</div>
<span className="mc-name">演化</span>
<span className="mc-sub">evolution</span>
<span className="mc-yield">策略 ✓</span>
</div>
<span className="mc-link done"></span>
<div className="mc-step active">
<div className="mc-mirror">
<div className="disc">候</div>
</div>
<span className="mc-name">候选</span>
<span className="mc-sub">candidate · 当阶</span>
<span className="mc-yield">{counts.pending} 条草拟</span>
</div>
<span className="mc-link"></span>
<div className="mc-step">
<div className="mc-mirror">
<div className="disc">藏</div>
</div>
<span className="mc-name">入藏</span>
<span className="mc-sub">memory · 未达</span>
<span className="mc-yield">-</span>
</div>
</div>
<div className="snapshot-row">
<div className="snap-mini">
<div className="k">最近记忆</div>
<div className="v">
12<small>条入参</small>
</div>
</div>
<div className="snap-mini">
<div className="k">人格版本</div>
<div className="v">
v7<small>当前</small>
</div>
</div>
<div className="snap-mini">
<div className="k">世界实体</div>
<div className="v">
—<small>个</small>
</div>
</div>
<div className="snap-mini">
<div className="k">内驱力</div>
<div className="v">
{counts.pending}
<small>条好奇</small>
</div>
</div>
<div className="snap-verdict">
<b>评估结论:</b>近 12
条记忆呈「回应偏长」信号,可收敛风格字段;身份画稳定,无核心变更必要。建议生成
1 条风格类半自动候选,其余维持现状。
</div>
</div>
</div>
<EvolutionList
characterId={characterId}
initial={initial}
counts={counts}
/>
</div>
) : null}
</PageContainer>
);
}
-41
View File
@@ -1,41 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { MemoryList } from "@/components/memory/MemoryList";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character, Memory, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function MemoryPage() {
let memories: Memory[] = [];
let characterId: string | undefined;
let error: string | null = null;
try {
const active = await serverApi
.get<Character>("/characters/active")
.catch(() => null);
characterId = active?.id;
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="记忆"
description="当前人物的长期记忆,支持手动新增、编辑与删除。"
>
{error ? (
<ErrorState message={error} />
) : (
<MemoryList initialMemories={memories} characterId={characterId} />
)}
</PageContainer>
);
}
-48
View File
@@ -1,48 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { MemoryAdminList } from "@/components/admin/MemoryAdminList";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Memory, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 息壤枢 · 长期记忆。
*
* 取所有记忆列表,经 MemoryAdminList 组件做删除/详情查看。
* 数据来自 GET /memory(API §8)。
*/
export default async function AdminMemoryPage() {
let memories: Memory[] = [];
let error: string | null = null;
try {
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="息壤枢"
description="长期记忆 · 自生不息"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : memories.length === 0 ? (
<EmptyState
title="暂无记忆"
description="当前人物还没有沉淀任何长期记忆。"
/>
) : (
<MemoryAdminList initial={memories} />
)}
</PageContainer>
);
}
@@ -6,7 +6,6 @@ import { VermilionAction } from "@/components/admin/VermilionAction";
import { PolicyMatrix } from "@/components/admin/PolicyMatrix"; import { PolicyMatrix } from "@/components/admin/PolicyMatrix";
import { AuditConsole } from "@/components/admin/AuditConsole"; import { AuditConsole } from "@/components/admin/AuditConsole";
import { ToolRegistry } from "@/components/admin/ToolRegistry"; import { ToolRegistry } from "@/components/admin/ToolRegistry";
import { MemoryAdminList } from "@/components/admin/MemoryAdminList";
import { PersonalityEditor } from "@/components/admin/PersonalityEditor"; import { PersonalityEditor } from "@/components/admin/PersonalityEditor";
import { GoalTree } from "@/components/admin/GoalTree"; import { GoalTree } from "@/components/admin/GoalTree";
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix"; import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
@@ -46,7 +45,6 @@ const VALID_PIVOTS: Pivot[] = [
"perception", "perception",
"world_model", "world_model",
"planner", "planner",
"memory",
"tool_runtime", "tool_runtime",
"agent_society", "agent_society",
]; ];
@@ -74,20 +72,6 @@ export default async function PivotDetailPage({
return <ToolRuntimeDetail />; return <ToolRuntimeDetail />;
case "personality_core": case "personality_core":
return <PersonalityCoreDetail />; return <PersonalityCoreDetail />;
case "memory":
return <MemorySystemDetail />;
case "goal_orchestrator":
return <GoalOrchestratorDetail />;
case "perception":
return <PerceptionDetail />;
case "world_model":
return <WorldModelDetail />;
case "planner":
return <PlannerDetail />;
case "agent_society":
return <AgentSocietyDetail />;
default:
notFound();
} }
} }
@@ -285,39 +269,6 @@ async function PersonalityCoreDetail() {
); );
} }
/** 息壤枢:长期记忆列表(自生不息) */
async function MemorySystemDetail() {
let memories: Memory[] = [];
let error: string | null = null;
try {
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="息壤枢"
description="长期记忆 · 自生不息"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : memories.length === 0 ? (
<EmptyState
title="暂无记忆"
description="当前人物还没有沉淀任何长期记忆。"
/>
) : (
<MemoryAdminList initial={memories} />
)}
</PageContainer>
);
}
/** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */ /** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */
async function GoalOrchestratorDetail() { async function GoalOrchestratorDetail() {
let goals: GoalNode[] = []; let goals: GoalNode[] = [];
@@ -1,251 +0,0 @@
"use client";
import { useState } from "react";
import { memoryApi } from "@/lib/api";
import type { MemoryType } from "@/types/domain";
import type { Memory } from "@/types/api";
import { MEMORY_TYPE_LABEL, MEMORY_SOURCE_LABEL } from "@/lib/constants/labels";
const TYPE_OPTIONS: MemoryType[] = [
"identity",
"user",
"episodic",
"semantic",
"procedural",
"relational",
"emotional",
"world",
"reflection",
"evolution",
];
/**
* 息壤枢记忆治理列表(可增删改)。
* 治理者可补录记忆(admin 校正/手动沉淀)、inline 编辑、软删除。
* 复用 §10 memoryApi.createMemory/updateMemory/deleteMemory。
*/
export function MemoryAdminList({ initial }: { initial: Memory[] }) {
const [memories, setMemories] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState({ title: "", content: "" });
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newMem, setNewMem] = useState<{
type: MemoryType;
title: string;
content: string;
}>({
type: "user",
title: "",
content: "",
});
function startEdit(m: Memory) {
setEditingId(m.id);
setDraft({ title: m.title ?? "", content: m.content });
setError(null);
}
async function save(id: string) {
setBusyId(id);
setError(null);
try {
const updated = await memoryApi.updateMemory(id, draft);
setMemories((prev) =>
prev.map((m) => (m.id === id ? { ...m, ...updated } : m)),
);
setEditingId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败,请重试。");
} finally {
setBusyId(null);
}
}
async function remove(id: string) {
setBusyId(id);
setError(null);
try {
await memoryApi.deleteMemory(id);
setMemories((prev) => prev.filter((m) => m.id !== id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setBusyId(null);
}
}
async function add() {
if (!newMem.content.trim()) {
setError("请填写记忆内容。");
return;
}
setBusyId("__new");
setError(null);
try {
const created = await memoryApi.createMemory({
type: newMem.type,
title: newMem.title.trim() || undefined,
content: newMem.content.trim(),
});
setMemories((prev) => [created, ...prev]);
setNewMem({ type: "user", title: "", content: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setBusyId(null);
}
}
return (
<>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={newMem.type}
onChange={(e) =>
setNewMem((s) => ({ ...s, type: e.target.value as MemoryType }))
}
aria-label="记忆类型"
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{MEMORY_TYPE_LABEL[t]}
</option>
))}
</select>
<input
value={newMem.title}
onChange={(e) =>
setNewMem((s) => ({ ...s, title: e.target.value }))
}
placeholder="标题(可选)"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
</div>
<textarea
value={newMem.content}
onChange={(e) =>
setNewMem((s) => ({ ...s, content: e.target.value }))
}
rows={2}
placeholder="记忆内容…"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex gap-2">
<button
type="button"
onClick={add}
disabled={busyId === "__new"}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增记忆
</button>
)}
<ul className="space-y-2">
{memories.map((m) => (
<li key={m.id} className="glass rounded-xl px-4 py-2.5">
{editingId === m.id ? (
<div className="space-y-2">
<input
value={draft.title}
onChange={(e) =>
setDraft((d) => ({ ...d, title: e.target.value }))
}
placeholder="标题"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<textarea
value={draft.content}
onChange={(e) =>
setDraft((d) => ({ ...d, content: e.target.value }))
}
rows={3}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => save(m.id)}
disabled={busyId === m.id}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<>
<div className="flex items-baseline gap-2">
<span className="rounded bg-patina/15 px-1.5 py-0.5 text-[10px] text-patina">
{MEMORY_TYPE_LABEL[m.type]}
</span>
<span className="truncate font-serif text-sm font-medium text-fg">
{m.title ?? m.content.slice(0, 16)}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted">
{m.content}
</p>
<div className="mt-1.5 flex items-center gap-3 text-[10px] text-muted">
<span>{MEMORY_SOURCE_LABEL[m.source]}</span>
{m.confidence != null && (
<span>置信 {m.confidence.toFixed(2)}</span>
)}
<div className="ml-auto flex gap-3">
<button
type="button"
onClick={() => startEdit(m)}
className="text-patina hover:underline"
>
编辑
</button>
<button
type="button"
onClick={() => remove(m.id)}
disabled={busyId === m.id}
className="text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</div>
</>
)}
</li>
))}
</ul>
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</>
);
}
@@ -1,255 +0,0 @@
"use client";
import type {
EvolutionCandidate,
EvolutionCandidateType,
EvolutionScope,
} from "@/types/api";
import { cn } from "@/lib/utils/cn";
const TYPE_LABEL: Record<EvolutionCandidateType, string> = {
personality: "人格",
personality_weights: "人格权重",
retrieval_strategy: "召回策略",
tool_affinity: "工具偏好",
memory: "记忆",
tool_strategy: "工具策略",
safety_rule: "安全规则",
world_model: "世界模型",
procedural: "程序记忆",
mood: "心情",
};
const SCOPE_LABEL: Record<EvolutionScope, string> = {
character_scope: "人物域",
user_scope: "用户域",
task_scope: "任务域",
global_scope: "全局",
};
/** 候选字符映射(与设计稿 cand-glyph 示例一致) */
function glyphForType(type: string): string {
const map: Record<string, string> = {
personality: "本",
personality_weights: "权",
retrieval_strategy: "召",
tool_affinity: "工",
memory: "忆",
tool_strategy: "策",
safety_rule: "安",
world_model: "世",
procedural: "序",
mood: "情",
};
return map[type] ?? "·";
}
export function CandidateCard({
candidate,
onApprove,
onReject,
busy,
}: {
candidate: EvolutionCandidate;
onApprove: (id: string) => void;
onReject: (id: string) => void;
busy: boolean;
}) {
const isPending = candidate.status === "pending";
const isL4 = candidate.level === "L4";
const isAuto = candidate.auto_apply_allowed && !isL4;
return (
<div className={cn(isL4 ? "proposal" : "cand-card", isAuto && "auto")}>
{isL4 ? (
/* L4 朱砂待裁样式 */
<>
<div className="prop-head">
<span className="prop-seal">朱批待裁</span>
<span className="prop-type">
{TYPE_LABEL[candidate.type]} · {candidate.level}
</span>
<span className="prop-risk">L4 · 满朱 · 须人工</span>
</div>
<div className="prop-title">{candidate.title}</div>
<div className="prop-reason">{candidate.proposal}</div>
{Object.entries(candidate.delta ?? {}).length > 0 && (
<div className="prop-diff">
{Object.entries(candidate.delta ?? {}).map(([k, v]) => (
<div key={k}>
<span className="field-tag">{k}</span>
{typeof v === "string" ? (
<>
<span className="del">{v}</span>
<br />
<span className="add">{v}</span>
</>
) : (
<span>{JSON.stringify(v)}</span>
)}
</div>
))}
</div>
)}
</>
) : (
/* 普通/半自动候选 */
<>
<div className="cand-top">
<div className="cand-glyph">{glyphForType(candidate.type)}</div>
<span className="cand-title">{candidate.title}</span>
<span className="cand-tag">
{TYPE_LABEL[candidate.type]} · {SCOPE_LABEL[candidate.scope]}
</span>
{isAuto && (
<span className="cand-judge auto">
{candidate.level} · 已应用
</span>
)}
{!isAuto && !isPending && (
<span className="cand-judge manual">{candidate.status}</span>
)}
</div>
<div className="cand-desc">{candidate.proposal}</div>
</>
)}
{/* 三维评分(score-trio) */}
{!isL4 && (
<div className="score-trio">
<div className="score benefit">
<div className="k">收益</div>
<div className="track">
<div
className="fill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, candidate.benefit_score)) * 100)}%`,
}}
/>
</div>
<div className="tv">{candidate.benefit_score.toFixed(2)}</div>
</div>
<div className="score risk">
<div className="k">风险</div>
<div className="track">
<div
className="fill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, candidate.risk_score)) * 100)}%`,
}}
/>
</div>
<div className="tv">{candidate.risk_score.toFixed(2)}</div>
</div>
<div className="score conf">
<div className="k">置信</div>
<div className="track">
<div
className="fill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, candidate.confidence)) * 100)}%`,
}}
/>
</div>
<div className="tv">{candidate.confidence.toFixed(2)}</div>
</div>
</div>
)}
{/* 证据引用 */}
{candidate.evidence_ids.length > 0 && (
<div className="prop-foot" style={{ marginTop: isL4 ? 0 : 8 }}>
{candidate.evidence_ids.slice(0, 4).map((id) => (
<span key={id} className="evi-link">
{id.slice(0, 10)}…
</span>
))}
{candidate.evidence_ids.length > 4 && (
<span className="evi-link">
+{candidate.evidence_ids.length - 4}
</span>
)}
</div>
)}
{/* 操作区 */}
{isPending && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
justifyContent: "flex-end",
paddingTop: isL4 ? 12 : 8,
borderTop: isL4 ? "none" : "1px dashed var(--rule)",
marginTop: isL4 ? 8 : 0,
}}
>
{isL4 && (
<span
style={{
fontSize: 11,
color: "var(--fg-faint)",
marginRight: "auto",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
}}
>
L4 须人工裁定方可写入
</span>
)}
<button
type="button"
onClick={() => onReject(candidate.id)}
disabled={busy}
className="vbtn"
style={{
borderColor: isL4 ? "var(--cinnabar)" : "var(--rule)",
color: isL4 ? "var(--cinnabar)" : "var(--fg)",
background: isL4 ? "rgba(184,57,46,0.08)" : "transparent",
}}
>
驳回
</button>
<button
type="button"
onClick={() => onApprove(candidate.id)}
disabled={busy}
className="vbtn"
style={{
borderColor: isL4 ? "var(--cinnabar)" : "var(--patina-dim)",
color: isL4 ? "var(--cinnabar)" : "var(--patina)",
background: isL4 ? "rgba(184,57,46,0.08)" : "var(--patina-wash)",
}}
>
{busy ? "处理中…" : isL4 ? "准予写入" : "通过并应用"}
</button>
</div>
)}
{!isPending && !isL4 && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
paddingTop: 8,
borderTop: "1px solid var(--rule)",
marginTop: 8,
}}
>
<span
className={
candidate.status === "applied"
? "cand-judge auto"
: "cand-judge manual"
}
>
{candidate.status === "applied"
? `已应用 · ${candidate.resulting_target_id?.slice(0, 12) ?? ""}`
: "已驳回"}
</span>
</div>
)}
</div>
);
}
@@ -1,248 +0,0 @@
"use client";
import { useState } from "react";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { evolutionApi } from "@/lib/api";
import type { EvolutionCandidate } from "@/types/api";
import { describeError } from "@/lib/utils/error";
import { cn } from "@/lib/utils/cn";
import { CardGridSkeleton } from "@/components/ui/Skeleton";
import { CandidateCard } from "./CandidateCard";
type FilterStatus = "pending" | "applied" | "rejected";
const FILTERS: Array<{ key: FilterStatus; label: string }> = [
{ key: "pending", label: "待审核" },
{ key: "applied", label: "已应用" },
{ key: "rejected", label: "已驳回" },
];
/** 应用沉积示例数据(与设计稿对齐) */
const STRATA = [
{
ver: "风格简练 +1",
when: "07-12",
reason: "expression_style · 半自动 · 源自大周天",
rollback: true,
rejected: false,
},
{
ver: "深夜不打扰",
when: "07-09",
reason: "procedural · 半自动",
rollback: false,
rejected: false,
},
{
ver: "人格权重校准",
when: "06-28",
reason: "personality_weights · 尽责 +0.05",
rollback: true,
rejected: false,
},
{
ver: "工具亲和调整",
when: "06-12",
reason: "tool_strategy · 驳回 · 证据不足",
rollback: false,
rejected: true,
},
{
ver: "初始种魂",
when: "04 月",
reason: "自模板而来 · v1",
rollback: false,
rejected: false,
faded: true,
},
];
/**
* 演化候选审核列表(设计稿 evolution-preview.html):下区分两栏。
* 左栏 cand-main = 候选列表(待裁 + 半自动);右栏 strata-rail = 应用沉积时间线。
*/
export function EvolutionList({
characterId,
initial,
counts: initialCounts,
}: {
characterId: string;
initial: EvolutionCandidate[];
counts: Record<FilterStatus, number>;
}) {
const [filter, setFilter] = useState<FilterStatus>("pending");
const [candidates, setCandidates] = useState(initial);
const [counts, setCounts] = useState(initialCounts);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function switchFilter(next: FilterStatus) {
if (next === filter) return;
setFilter(next);
setLoading(true);
setError(null);
try {
const res = await evolutionApi.listCandidates(characterId, {
status: next,
page_size: 50,
});
setCandidates(res.items);
} catch (e) {
setError(describeError(e));
} finally {
setLoading(false);
}
}
async function resolve(id: string, action: "approve" | "reject") {
setBusyId(id);
setError(null);
try {
if (action === "approve") {
await evolutionApi.approveCandidate(id);
setCounts((c) => ({
...c,
pending: Math.max(0, c.pending - 1),
applied: c.applied + 1,
}));
} else {
await evolutionApi.rejectCandidate(id);
setCounts((c) => ({
...c,
pending: Math.max(0, c.pending - 1),
rejected: c.rejected + 1,
}));
}
setCandidates((list) => list.filter((c) => c.id !== id));
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
/** 区分 L4 与普通候选 */
const l4Candidates = candidates.filter((c) => c.level === "L4");
const normalCandidates = candidates.filter((c) => c.level !== "L4");
return (
<div className="lower-region">
{/* 候选主区 */}
<div className="cand-main">
{/* 筛选按钮 */}
<div className="flex gap-1">
{FILTERS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => switchFilter(key)}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
filter === key
? "glass-strong text-patina"
: "text-muted hover:text-fg",
)}
>
{label}
<span className="ml-1.5 tabular-nums text-muted">
{counts[key]}
</span>
</button>
))}
</div>
{error && <ErrorState message={error} />}
{loading ? (
<CardGridSkeleton count={3} className="py-6" />
) : candidates.length === 0 ? (
<EmptyState
title={
filter === "pending" ? "暂无待审核的演化候选" : "暂无候选记录"
}
description={
filter === "pending"
? "大周天自我评估后会把可演化的增量生成到这里,L4 核心变更需你确认,低风险类已自动应用。"
: undefined
}
/>
) : (
<div className="space-y-3">
{/* L4 朱砂待裁 */}
{l4Candidates.map((c) => (
<CandidateCard
key={c.id}
candidate={c}
onApprove={(id) => resolve(id, "approve")}
onReject={(id) => resolve(id, "reject")}
busy={busyId === c.id}
/>
))}
{/* 普通候选 */}
{normalCandidates.map((c) => (
<CandidateCard
key={c.id}
candidate={c}
onApprove={(id) => resolve(id, "approve")}
onReject={(id) => resolve(id, "reject")}
busy={busyId === c.id}
/>
))}
</div>
)}
</div>
{/* 应用沉积侧栏 */}
<div className="strata-rail">
<div className="strata-card">
<h3
style={{
fontFamily: '"Songti SC", serif',
fontSize: 14,
fontWeight: 600,
color: "var(--fg)",
marginBottom: 12,
}}
>
应用沉积{" "}
<span
style={{
fontSize: 12,
fontWeight: 400,
color: "var(--fg-dim)",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
marginLeft: 6,
}}
>
演化之层积
</span>
</h3>
<div className="strata-list">
{STRATA.map((s, i) => (
<div
key={i}
className={cn("stratum", s.rejected && "rejected")}
style={s.faded ? { opacity: 0.6 } : undefined}
>
<span className="node"></span>
<div className="stratum-head">
<span className="stratum-ver">{s.ver}</span>
<span className="stratum-when">{s.when}</span>
</div>
<div className="stratum-reason">{s.reason}</div>
{s.rollback && (
<button type="button" className="stratum-rollback">
回滚此层
</button>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}
+1 -7
View File
@@ -11,8 +11,6 @@ import {
CharacterIcon, CharacterIcon,
CosmosIcon, CosmosIcon,
DriveIcon, DriveIcon,
EvolutionIcon,
MemoryIcon,
ModelIcon, ModelIcon,
PersonalityIcon, PersonalityIcon,
RunIcon, RunIcon,
@@ -65,7 +63,6 @@ const NAV_GROUPS: NavGroup[] = [
{ href: "/wuxing", label: "五行", icon: <WuxingIcon /> }, { href: "/wuxing", label: "五行", icon: <WuxingIcon /> },
{ href: "/characters", label: "人物", icon: <CharacterIcon /> }, { href: "/characters", label: "人物", icon: <CharacterIcon /> },
{ href: "/personality", label: "人格", icon: <PersonalityIcon /> }, { href: "/personality", label: "人格", icon: <PersonalityIcon /> },
{ href: "/evolution", label: "演化", icon: <EvolutionIcon /> },
], ],
}, },
{ {
@@ -87,10 +84,7 @@ const NAV_GROUPS: NavGroup[] = [
}, },
{ {
title: "藏识", title: "藏识",
items: [ items: [{ href: "/archive", label: "归档", icon: <ArchiveIcon /> }],
{ href: "/memory", label: "记忆", icon: <MemoryIcon /> },
{ href: "/archive", label: "归档", icon: <ArchiveIcon /> },
],
}, },
{ {
title: "百工", title: "百工",
@@ -1,169 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { SensitivityBadge } from "@/components/ui/StatusBadge";
import { describeError } from "@/lib/utils/error";
import { MEMORY_SOURCE_LABEL, MEMORY_TYPE_LABEL } from "@/lib/constants/labels";
import { memoryApi } from "@/lib/api";
import { formatDateTime, formatPercent } from "@/lib/utils/format";
import { cn } from "@/lib/utils/cn";
import type { CreateMemoryRequest, Memory } from "@/types/api";
import { MemoryEditor } from "./MemoryEditor";
/** 单条记忆卡片(设计稿 memory-preview.html §mem-card)
* 类型色标(.mem-type)、置信度/重要性进度条(.meta-bar .fill)、
* 敏感度徽章、证据来源(.mem-foot)*/
export function MemoryCard({
memory,
characterId,
onChanged,
onError,
}: {
memory: Memory;
characterId?: string;
onChanged: (kind: "update" | "delete", payload: Memory | string) => void;
onError: (message: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const handleUpdate = async (body: CreateMemoryRequest) => {
setBusy(true);
try {
const updated = await memoryApi.updateMemory(memory.id, body);
onChanged("update", updated);
setEditing(false);
} catch (err) {
onError(describeError(err));
} finally {
setBusy(false);
}
};
const handleDelete = async () => {
setBusy(true);
try {
await memoryApi.deleteMemory(memory.id);
onChanged("delete", memory.id);
} catch (err) {
onError(describeError(err));
setConfirming(false);
} finally {
setBusy(false);
}
};
if (editing) {
return (
<MemoryEditor
characterId={characterId}
initial={memory}
onSubmit={handleUpdate}
onCancel={() => setEditing(false)}
/>
);
}
const sensClass = memory.sensitivity === "high" ? "high-sens" : "";
return (
<div className={cn("mem-card", sensClass)}>
{/* 标题行(设计稿 .mem-head) */}
<div className="mem-head">
<span className="mem-title">{memory.title || "无标题记忆"}</span>
<span className={cn("mem-type", memory.type)}>
{MEMORY_TYPE_LABEL[memory.type] ?? memory.type}
</span>
<SensitivityBadge level={memory.sensitivity} />
</div>
{/* 记忆内容(设计稿 .mem-content) */}
<p className="mem-content">{memory.content}</p>
{/* 元数据行:置信度 + 重要性进度条(设计稿 .mem-meta) */}
<div className="mem-meta">
<span className="meta-item conf">
<span className="lab">置信</span>
<span className="meta-bar">
<span
className="fill"
style={{
width: `${Math.round((memory.confidence ?? 0) * 100)}%`,
}}
/>
</span>
<span className="vv">{formatPercent(memory.confidence)}</span>
</span>
<span className="meta-item imp">
<span className="lab">重要</span>
<span className="meta-bar">
<span
className="fill"
style={{
width: `${Math.round((memory.importance ?? 0) * 100)}%`,
background: "var(--gilt)",
}}
/>
</span>
<span className="vv">{formatPercent(memory.importance)}</span>
</span>
</div>
{/* 脚注(设计稿 .mem-foot) */}
<div className="mem-foot">
<span className="evi">
来源:{MEMORY_SOURCE_LABEL[memory.source] ?? memory.source}
</span>
<span className="evi">
{formatDateTime(memory.updated_at ?? memory.created_at)}
</span>
{memory.evidence_ids && memory.evidence_ids.length > 0 && (
<span className="evi">证据·{memory.evidence_ids.length} 条</span>
)}
<span className="act">
<button type="button" onClick={() => setEditing(true)}>
编辑
</button>
<button
type="button"
className="primary"
onClick={() => setConfirming(true)}
disabled={busy}
>
删除
</button>
</span>
</div>
{/* 删除确认 */}
{confirming && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-cinnabar/30 bg-cinnabar/10 px-3 py-2 text-xs text-cinnabar backdrop-blur-sm">
<span>删除后此记忆将不再注入对话上下文,确认删除?</span>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setConfirming(false)}
disabled={busy}
>
取消
</Button>
<Button
size="sm"
variant="danger"
onClick={handleDelete}
loading={busy}
>
确认删除
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,205 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import {
MEMORY_SOURCE_LABEL,
MEMORY_TYPE_LABEL,
SENSITIVITY_LABEL,
} from "@/lib/constants/labels";
import type { CreateMemoryRequest, Memory } from "@/types/api";
import type { MemorySource, MemoryType, Sensitivity } from "@/types/domain";
const MEMORY_TYPES = Object.keys(MEMORY_TYPE_LABEL) as MemoryType[];
const MEMORY_SOURCES = Object.keys(MEMORY_SOURCE_LABEL) as MemorySource[];
const SENSITIVITY_LEVELS = Object.keys(SENSITIVITY_LABEL) as Sensitivity[];
interface FormState {
type: MemoryType;
title: string;
content: string;
source: MemorySource;
sensitivity: Sensitivity;
importance: number;
confidence: number;
}
function toForm(memory: Memory): FormState {
return {
type: memory.type,
title: memory.title ?? "",
content: memory.content,
source: memory.source,
sensitivity: memory.sensitivity,
importance: memory.importance ?? 0.5,
confidence: memory.confidence ?? 1,
};
}
const EMPTY_FORM: FormState = {
type: "user",
title: "",
content: "",
source: "user_explicit",
sensitivity: "low",
importance: 0.5,
confidence: 1,
};
const fieldClass =
"w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg backdrop-blur-sm placeholder:text-muted focus:border-patina focus:outline-none focus:ring-1 focus:ring-patina/40";
const labelClass = "mb-1 block text-xs font-medium text-muted";
/** 记忆新增 / 编辑表单。手动记忆 source 默认 user_explicit。 */
export function MemoryEditor({
characterId,
initial,
onSubmit,
onCancel,
}: {
characterId?: string;
initial?: Memory;
onSubmit: (body: CreateMemoryRequest, isEdit: boolean) => Promise<void>;
onCancel: () => void;
}) {
const [form, setForm] = useState<FormState>(
initial ? toForm(initial) : EMPTY_FORM,
);
const [submitting, setSubmitting] = useState(false);
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = async () => {
if (!form.content.trim()) return;
setSubmitting(true);
try {
const body: CreateMemoryRequest = {
character_id: characterId,
type: form.type,
title: form.title.trim() || undefined,
content: form.content,
source: form.source,
sensitivity: form.sensitivity,
importance: form.importance,
confidence: form.confidence,
};
await onSubmit(body, Boolean(initial));
} finally {
setSubmitting(false);
}
};
return (
<div className="glass space-y-3 rounded-xl p-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className={labelClass}>类型</label>
<select
className={fieldClass}
value={form.type}
onChange={(e) => set("type", e.target.value as MemoryType)}
>
{MEMORY_TYPES.map((t) => (
<option key={t} value={t}>
{MEMORY_TYPE_LABEL[t]}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>来源</label>
<select
className={fieldClass}
value={form.source}
onChange={(e) => set("source", e.target.value as MemorySource)}
>
{MEMORY_SOURCES.map((s) => (
<option key={s} value={s}>
{MEMORY_SOURCE_LABEL[s]}
</option>
))}
</select>
</div>
</div>
<div>
<label className={labelClass}>标题(可选)</label>
<input
className={fieldClass}
value={form.title}
onChange={(e) => set("title", e.target.value)}
placeholder="简短概括这条记忆"
/>
</div>
<div>
<label className={labelClass}>内容</label>
<textarea
className={`${fieldClass} min-h-[5rem] resize-y`}
value={form.content}
onChange={(e) => set("content", e.target.value)}
placeholder="记忆的完整内容"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className={labelClass}>敏感度</label>
<select
className={fieldClass}
value={form.sensitivity}
onChange={(e) => set("sensitivity", e.target.value as Sensitivity)}
>
{SENSITIVITY_LEVELS.map((s) => (
<option key={s} value={s}>
{SENSITIVITY_LABEL[s]}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>
重要性 {Math.round(form.importance * 100)}%
</label>
<input
type="range"
min={0}
max={1}
step={0.05}
value={form.importance}
onChange={(e) => set("importance", Number(e.target.value))}
className="w-full accent-patina"
/>
</div>
<div>
<label className={labelClass}>置信度</label>
<input
type="number"
min={0}
max={1}
step={0.05}
className={fieldClass}
value={form.confidence}
onChange={(e) => set("confidence", Number(e.target.value))}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
取消
</Button>
<Button
variant="primary"
onClick={handleSubmit}
loading={submitting}
disabled={!form.content.trim()}
>
{initial ? "保存修改" : "新增记忆"}
</Button>
</div>
</div>
);
}
@@ -1,336 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { MemoryCard } from "@/components/memory/MemoryCard";
import { MemoryEditor } from "@/components/memory/MemoryEditor";
import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { describeError } from "@/lib/utils/error";
import { memoryApi } from "@/lib/api";
import { MEMORY_TYPE_LABEL } from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
import type { CreateMemoryRequest, Memory } from "@/types/api";
interface Props {
initialMemories: Memory[];
characterId?: string;
}
const TYPE_KEYS = [
"identity",
"episodic",
"semantic",
"procedural",
"relational",
"emotional",
"reflection",
] as const;
/** 搜索过滤的记忆列表 + 右栏搜索/提炼区(设计稿 memory-preview.html §mem-region) */
export function MemoryList({ initialMemories, characterId }: Props) {
const [memories, setMemories] = useState<Memory[]>(initialMemories);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
// 分类统计
const typeCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const m of memories) {
counts[m.type] = (counts[m.type] || 0) + 1;
}
return counts;
}, [memories]);
// 汇总统计(设计稿 stat-grid 四格)
const stats = useMemo(() => {
const episodic = memories.filter((m) => m.type === "episodic").length;
const semantic = memories.filter((m) => m.type === "semantic").length;
const highSens = memories.filter((m) => m.sensitivity === "high").length;
return { total: memories.length, episodic, semantic, highSens };
}, [memories]);
// 过滤后的记忆
const filtered = useMemo(() => {
let list = memories;
if (typeFilter) {
list = list.filter((m) => m.type === typeFilter);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter(
(m) =>
(m.title || "").toLowerCase().includes(q) ||
m.content.toLowerCase().includes(q),
);
}
return list;
}, [memories, typeFilter, searchQuery]);
const handleCreate = async (body: CreateMemoryRequest) => {
const created = await memoryApi.createMemory(body);
setMemories((prev) => [created, ...prev]);
setAdding(false);
};
const handleCreateWrapped = async (body: CreateMemoryRequest) => {
try {
await handleCreate(body);
} catch (err) {
setError(describeError(err));
throw err;
}
};
const handleChange = (
kind: "update" | "delete",
payload: Memory | string,
) => {
if (kind === "update") {
const updated = payload as Memory;
setMemories((prev) =>
prev.map((m) => (m.id === updated.id ? updated : m)),
);
} else {
const id = payload as string;
setMemories((prev) => prev.filter((m) => m.id !== id));
}
};
// 提炼规则(设计稿 review-list)
const REVIEW_RULES = [
{ text: "新记忆与现有事实冲突时,标记冲突待裁定", allow: false },
{ text: "修复性记忆(corrective)可直接替换旧记忆", allow: true },
{ text: "高敏感度记忆写入前需人工确认", allow: false },
{ text: "记忆置信度低于 0.3 的自动降权", allow: true },
] as const;
return (
<div className="mem-region">
{/* ═══ 左栏:记忆主列表 ═══ */}
<div className="mem-main">
{error && <ErrorState message={error} onRetry={() => setError(null)} />}
{/* 统计四格(设计稿 stat-grid) */}
<div className="stat-grid">
<div className="stat-card">
<div className="k">记忆总数</div>
<div className="v">{stats.total}</div>
</div>
<div className="stat-card">
<div className="k">情景型</div>
<div className="v">{stats.episodic}</div>
</div>
<div className="stat-card">
<div className="k">语义型</div>
<div className="v">{stats.semantic}</div>
</div>
<div className="stat-card warn">
<div className="k">高敏感</div>
<div className="v">
{stats.highSens}
<small>条</small>
</div>
</div>
</div>
{/* 分类标签栏(设计稿 .type-bar) */}
<div className="type-bar">
<button
type="button"
className={cn("type-chip", !typeFilter && "active")}
onClick={() => setTypeFilter(null)}
>
全部
<span className="cnt">{memories.length}</span>
</button>
{TYPE_KEYS.filter((t) => typeCounts[t]).map((t) => (
<button
key={t}
type="button"
className={cn("type-chip", typeFilter === t && "active", t)}
onClick={() => setTypeFilter(typeFilter === t ? null : t)}
>
{MEMORY_TYPE_LABEL[t] ?? t}
<span className="cnt">{typeCounts[t]}</span>
</button>
))}
</div>
{/* 新增按钮 */}
<div className="mt-4">
{adding ? (
<MemoryEditor
characterId={characterId}
onSubmit={handleCreateWrapped}
onCancel={() => setAdding(false)}
/>
) : (
<Button variant="primary" onClick={() => setAdding(true)}>
新增记忆
</Button>
)}
</div>
{/* 记忆列表 */}
<div className="mem-list mt-4">
{filtered.length === 0 ? (
<EmptyState
title={searchQuery ? "无匹配记忆" : "尚无记忆"}
description={
searchQuery
? `未搜索到包含「${searchQuery}」的记忆`
: "为当前人物手动新增一条长期记忆。"
}
/>
) : (
filtered.map((memory) => (
<MemoryCard
key={memory.id}
memory={memory}
characterId={characterId}
onChanged={handleChange}
onError={setError}
/>
))
)}
</div>
</div>
{/* ═══ 右栏:搜索 + 提炼区(设计稿 .mem-rail) ═══ */}
<div className="mem-rail">
{/* 搜索框(设计稿 .search-box) */}
<div className="rail-card">
<h3>检索记忆</h3>
<div className="search-box">
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className="shrink-0 text-fg-faint"
>
<circle
cx="7"
cy="7"
r="4.5"
stroke="currentColor"
strokeWidth="1.3"
/>
<path
d="M10.5 10.5l3 3"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索记忆内容…"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="shrink-0 text-fg-faint hover:text-fg text-[11px]"
>
✕
</button>
)}
</div>
{searchQuery && (
<p className="mt-2 text-[11px] text-fg-faint">
匹配 {filtered.length} 条记忆
</p>
)}
</div>
{/* 内存用量统计(设计稿 .stat-card 变形) */}
<div className="rail-card">
<h3>息壤概览</h3>
<div className="flex flex-col gap-2">
<StatRow
label="总记忆"
value={memories.length}
eng="Total"
color="patina"
/>
<StatRow
label="待同步"
value={memories.filter((m) => m.sensitivity === "high").length}
eng="Needs Sync"
color="gilt"
/>
<StatRow
label="高敏感"
value={memories.filter((m) => m.sensitivity === "high").length}
eng="High Sensitivity"
color="cinnabar"
/>
</div>
</div>
{/* 提炼规则(设计稿 .review-list) */}
<div className="rail-card">
<h3>记忆提炼规则</h3>
<div className="review-list">
{REVIEW_RULES.map((rule, i) => (
<div key={i} className={cn("review-rule", rule.allow && "allow")}>
<span className="mark">{rule.allow ? "✓" : "!"}</span>
<span>{rule.text}</span>
</div>
))}
</div>
</div>
{/* 冲突发现 */}
<div className="rail-card">
<h3>冲突裁定</h3>
<p className="text-[11.5px] text-fg-faint">暂无未解决的记忆冲突。</p>
{/* 有冲突时渲染:
<div className="conflict-row">
<span className="pair">爱丽丝 · 阿卡夏</span>
<span className="desc">生日 3月14日 vs 5月22日</span>
<span className="verdict">待裁定</span>
</div> */}
</div>
</div>
</div>
);
}
function StatRow({
label,
value,
eng,
color,
}: {
label: string;
value: number;
eng: string;
color: "patina" | "gilt" | "cinnabar";
}) {
const colorMap = {
patina: "text-patina",
gilt: "text-gilt",
cinnabar: "text-cinnabar",
};
return (
<div className="flex items-center justify-between border-b border-rule pb-1.5 last:border-b-0 last:pb-0">
<span className="text-[12.5px] text-fg-dim">
{label}
<span className="ml-1.5 font-mono text-[10px] text-fg-faint">
{eng}
</span>
</span>
<span className={cn("font-mono text-sm font-medium", colorMap[color])}>
{value}
</span>
</div>
);
}
-37
View File
@@ -1,37 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import { approveCandidate, listCandidates, rejectCandidate } from "./evolution";
describe("evolution api", () => {
it("listCandidates 构造人物级列表 path 与 query", async () => {
vi.mocked(apiGet).mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
});
await listCandidates("char_1", { status: "pending", type: "procedural" });
expect(apiGet).toHaveBeenCalledWith(
"/characters/char_1/evolution-candidates",
{ status: "pending", type: "procedural" },
);
});
it("approveCandidate POST 到 approve 端点", async () => {
vi.mocked(apiPost).mockResolvedValue({ status: "applied" } as never);
await approveCandidate("evo_1");
expect(apiPost).toHaveBeenCalledWith("/evolution-candidates/evo_1/approve");
});
it("rejectCandidate POST 到 reject 端点", async () => {
vi.mocked(apiPost).mockResolvedValue({ status: "rejected" } as never);
await rejectCandidate("evo_1");
expect(apiPost).toHaveBeenCalledWith("/evolution-candidates/evo_1/reject");
});
});
-37
View File
@@ -1,37 +0,0 @@
/**
* 演化候选 API Client(对应 docs/domain/core-domains.md §7)。
* 大周天生成的演化候选审核闭环:列表 / approve / reject。
*/
import { apiGet, apiPost } from "./client";
import type { EvolutionCandidate, Paginated } from "@/types/api";
/** 列出某人物的演化候选(默认按 created_at 倒序) */
export function listCandidates(
characterId: string,
params?: {
status?: string;
type?: string;
page?: number;
page_size?: number;
},
) {
return apiGet<Paginated<EvolutionCandidate>>(
`/characters/${characterId}/evolution-candidates`,
params,
);
}
/** 审核通过:派生到下游(人格版本 / 程序记忆),status → applied */
export function approveCandidate(candidateId: string) {
return apiPost<EvolutionCandidate>(
`/evolution-candidates/${candidateId}/approve`,
);
}
/** 驳回:status → rejected,不派生下游 */
export function rejectCandidate(candidateId: string) {
return apiPost<EvolutionCandidate>(
`/evolution-candidates/${candidateId}/reject`,
);
}
-2
View File
@@ -10,9 +10,7 @@ export * as audioApi from "./audio";
export * as charactersApi from "./characters"; export * as charactersApi from "./characters";
export * as runsApi from "./runs"; export * as runsApi from "./runs";
export * as sandboxApi from "./sandbox"; export * as sandboxApi from "./sandbox";
export * as memoryApi from "./memory";
export * as personalityApi from "./personality"; export * as personalityApi from "./personality";
export * as evolutionApi from "./evolution";
export * as driveApi from "./drive"; export * as driveApi from "./drive";
export * as healthApi from "./health"; export * as healthApi from "./health";
export * as toolsApi from "./tools"; export * as toolsApi from "./tools";
-67
View File
@@ -1,67 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import {
createMemory,
deleteMemory,
listMemories,
updateMemory,
} from "./memory";
describe("memory api", () => {
it("listMemories GET /memory 支持分页与类型过滤", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listMemories({ page: 1, page_size: 20, type: "episodic" });
expect(apiGet).toHaveBeenCalledWith("/memory", {
page: 1,
page_size: 20,
type: "episodic",
});
});
it("createMemory POST /memory 默认 source=user_explicit", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "mem_1" });
await createMemory({ type: "episodic", title: "测试", content: "内容" });
expect(apiPost).toHaveBeenCalledWith("/memory", {
source: "user_explicit",
type: "episodic",
title: "测试",
content: "内容",
});
});
it("createMemory 支持覆盖 source", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "mem_2" });
await createMemory({
type: "episodic",
title: "导入",
content: "内容",
source: "conversation",
});
expect(apiPost).toHaveBeenCalledWith("/memory", {
source: "conversation",
type: "episodic",
title: "导入",
content: "内容",
});
});
it("updateMemory PATCH /memory/:id", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "mem_1" });
await updateMemory("mem_1", { title: "新标题" });
expect(apiPatch).toHaveBeenCalledWith("/memory/mem_1", { title: "新标题" });
});
it("deleteMemory DELETE /memory/:id", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deleteMemory("mem_1");
expect(apiDelete).toHaveBeenCalledWith("/memory/mem_1");
});
});
-45
View File
@@ -1,45 +0,0 @@
/**
* 记忆 API Client(对应 后端生成的 OpenAPI 契约 §10)。
* 手动记忆 CRUD;character_id 省略时使用当前激活人物。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
CreateMemoryRequest,
Memory,
Paginated,
UpdateMemoryRequest,
} from "@/types/api";
export interface ListMemoryParams {
character_id?: string;
type?: string;
source?: string;
page?: number;
page_size?: number;
/** 查询参数可透传,允许作为 Record 传递给底层请求封装 */
[key: string]: string | number | undefined;
}
/** 列出当前人物记忆 */
export function listMemories(params?: ListMemoryParams) {
return apiGet<Paginated<Memory>>("/memory", params);
}
/** 创建记忆;手动记忆 source 默认 user_explicit */
export function createMemory(body: CreateMemoryRequest) {
return apiPost<Memory>("/memory", {
source: "user_explicit",
...body,
});
}
/** 更新记忆(PATCH 部分字段) */
export function updateMemory(memoryId: string, body: UpdateMemoryRequest) {
return apiPatch<Memory>(`/memory/${memoryId}`, body);
}
/** 删除记忆(后端软删除:status=deleted + deleted_at) */
export function deleteMemory(memoryId: string) {
return apiDelete<void>(`/memory/${memoryId}`);
}