M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)
This commit is contained in:
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')
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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 ""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)。"""
|
|
||||||
@@ -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))
|
|
||||||
@@ -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.
@@ -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)。"""
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -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
|
|
||||||
@@ -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="过期时间;永不过期时为空。")
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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 部分路径。"""
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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 引用供调试断言使用
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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}`);
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user