From 9c98c2f85ea4b09509320e2ba8f812e3badb9252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Thu, 27 Aug 2026 09:16:32 +0800 Subject: [PATCH] =?UTF-8?q?M3a=EF=BC=9A=E6=81=AF=E5=A3=A4=E4=B8=89?= =?UTF-8?q?=E7=BA=A7=E8=AE=B0=E5=BF=86=E5=9F=9F=E2=80=94=E2=80=94=E5=BD=92?= =?UTF-8?q?=E7=9C=9F=E8=A7=A6=E5=8F=91=E6=8F=90=E5=8F=96=E7=AE=A1=E7=BA=BF?= =?UTF-8?q?/=E7=99=BD=E5=90=8D=E5=8D=95=E6=98=8E=E5=BF=86/=E9=87=8D?= =?UTF-8?q?=E5=BF=86=E7=A1=AE=E8=AE=A4/=E6=AD=A3=E5=BD=B1=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E9=93=BE/=E5=88=A0=E9=99=A4=E5=A2=93=E7=A2=91/?= =?UTF-8?q?=E5=8F=8C=E9=80=9A=E9=81=93=E5=8F=AC=E5=9B=9E=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=EF=BC=8C=E9=80=80=E5=BD=B9=E6=97=A7=E8=AE=B0=E5=BF=86-?= =?UTF-8?q?=E6=8A=BD=E5=8F=96-=E5=8F=8D=E6=80=9D-=E5=A4=A7=E5=91=A8?= =?UTF-8?q?=E5=A4=A9-=E6=BC=94=E5=8C=96=E7=AE=A1=E7=BA=BF=EF=BC=88?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=20004=EF=BC=8C=E9=AA=8C=E6=94=B6=207=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._三级记忆表重建与触发_实录_退役旧记忆表.py | 102 +++ backend/app/api/router.py | 8 +- backend/app/api/v1/endpoints/admin.py | 8 +- backend/app/api/v1/endpoints/evolution.py | 60 -- backend/app/api/v1/endpoints/extraction.py | 90 --- backend/app/api/v1/endpoints/memory.py | 73 --- backend/app/api/v1/endpoints/xirang.py | 157 +++++ backend/app/application/big_cycle.py | 602 ------------------ backend/app/application/reflection.py | 150 ----- backend/app/core/ids.py | 8 + backend/app/domains/mirror_tree/service.py | 26 + backend/app/domains/xirang/__init__.py | 25 + backend/app/domains/xirang/service.py | 504 +++++++++++++++ backend/app/kernel/__init__.py | 5 + backend/app/models/__init__.py | 4 +- backend/app/models/memory.py | 46 -- backend/app/models/xirang.py | 80 +++ backend/app/modules/extraction/__init__.py | 1 - backend/app/modules/extraction/extract.py | 140 ---- backend/app/modules/extraction/prompts.py | 46 -- backend/app/modules/memory/__init__.py | 0 backend/app/modules/memory/context.py | 91 --- backend/app/modules/reflection/__init__.py | 1 - backend/app/modules/reflection/extract.py | 22 - backend/app/modules/reflection/prompts.py | 22 - backend/app/repositories/characters.py | 4 +- backend/app/repositories/memory.py | 73 --- backend/app/schemas/evolution.py | 35 - backend/app/schemas/memory.py | 109 ---- backend/app/services/admin.py | 4 +- backend/app/services/evolution.py | 205 ------ backend/app/services/extraction.py | 164 ----- backend/app/services/imports.py | 27 +- backend/app/services/memory.py | 304 --------- backend/app/services/serializers.py | 52 +- .../tests/api/test_evolution_candidates.py | 106 --- backend/tests/api/test_import_extraction.py | 176 ----- backend/tests/api/test_import_review.py | 142 ----- backend/tests/application/test_big_cycle.py | 64 -- .../tests/application/test_big_cycle_drive.py | 118 ---- .../application/test_big_cycle_evolution.py | 142 ----- .../application/test_big_cycle_extraction.py | 76 --- .../test_big_cycle_goal_progress.py | 171 ----- backend/tests/application/test_reflection.py | 100 --- .../extraction/test_extraction_extract.py | 110 ---- .../extraction/test_extraction_prompts.py | 34 - .../reflection/test_reflection_extract.py | 66 -- .../reflection/test_reflection_prompts.py | 21 - .../tests/services/test_evolution_service.py | 120 ---- .../tests/services/test_extraction_service.py | 156 ----- backend/tests/test_api.py | 41 -- backend/tests/test_memory_context.py | 114 ---- backend/tests/test_xirang.py | 257 ++++++++ frontend/src/app/(app)/evolution/page.tsx | 166 ----- frontend/src/app/(app)/memory/page.tsx | 41 -- frontend/src/app/admin/memory/page.tsx | 48 -- .../src/app/admin/pivots/[pivot]/page.tsx | 49 -- .../src/components/admin/MemoryAdminList.tsx | 251 -------- .../components/evolution/CandidateCard.tsx | 255 -------- .../components/evolution/EvolutionList.tsx | 248 -------- frontend/src/components/layout/Sidebar.tsx | 8 +- frontend/src/components/memory/MemoryCard.tsx | 169 ----- .../src/components/memory/MemoryEditor.tsx | 205 ------ frontend/src/components/memory/MemoryList.tsx | 336 ---------- frontend/src/lib/api/evolution.test.ts | 37 -- frontend/src/lib/api/evolution.ts | 37 -- frontend/src/lib/api/index.ts | 2 - frontend/src/lib/api/memory.test.ts | 67 -- frontend/src/lib/api/memory.ts | 45 -- 69 files changed, 1193 insertions(+), 6033 deletions(-) create mode 100644 backend/alembic/versions/004_息壤记忆域_三级记忆表重建与触发_实录_退役旧记忆表.py delete mode 100644 backend/app/api/v1/endpoints/evolution.py delete mode 100644 backend/app/api/v1/endpoints/extraction.py delete mode 100644 backend/app/api/v1/endpoints/memory.py create mode 100644 backend/app/api/v1/endpoints/xirang.py delete mode 100644 backend/app/application/big_cycle.py delete mode 100644 backend/app/application/reflection.py create mode 100644 backend/app/domains/xirang/__init__.py create mode 100644 backend/app/domains/xirang/service.py delete mode 100644 backend/app/models/memory.py create mode 100644 backend/app/models/xirang.py delete mode 100644 backend/app/modules/extraction/__init__.py delete mode 100644 backend/app/modules/extraction/extract.py delete mode 100644 backend/app/modules/extraction/prompts.py delete mode 100644 backend/app/modules/memory/__init__.py delete mode 100644 backend/app/modules/memory/context.py delete mode 100644 backend/app/modules/reflection/__init__.py delete mode 100644 backend/app/modules/reflection/extract.py delete mode 100644 backend/app/modules/reflection/prompts.py delete mode 100644 backend/app/repositories/memory.py delete mode 100644 backend/app/schemas/evolution.py delete mode 100644 backend/app/schemas/memory.py delete mode 100644 backend/app/services/evolution.py delete mode 100644 backend/app/services/extraction.py delete mode 100644 backend/app/services/memory.py delete mode 100644 backend/tests/api/test_evolution_candidates.py delete mode 100644 backend/tests/api/test_import_extraction.py delete mode 100644 backend/tests/api/test_import_review.py delete mode 100644 backend/tests/application/test_big_cycle.py delete mode 100644 backend/tests/application/test_big_cycle_drive.py delete mode 100644 backend/tests/application/test_big_cycle_evolution.py delete mode 100644 backend/tests/application/test_big_cycle_extraction.py delete mode 100644 backend/tests/application/test_big_cycle_goal_progress.py delete mode 100644 backend/tests/application/test_reflection.py delete mode 100644 backend/tests/modules/extraction/test_extraction_extract.py delete mode 100644 backend/tests/modules/extraction/test_extraction_prompts.py delete mode 100644 backend/tests/modules/reflection/test_reflection_extract.py delete mode 100644 backend/tests/modules/reflection/test_reflection_prompts.py delete mode 100644 backend/tests/services/test_evolution_service.py delete mode 100644 backend/tests/services/test_extraction_service.py delete mode 100644 backend/tests/test_memory_context.py create mode 100644 backend/tests/test_xirang.py delete mode 100644 frontend/src/app/(app)/evolution/page.tsx delete mode 100644 frontend/src/app/(app)/memory/page.tsx delete mode 100644 frontend/src/app/admin/memory/page.tsx delete mode 100644 frontend/src/components/admin/MemoryAdminList.tsx delete mode 100644 frontend/src/components/evolution/CandidateCard.tsx delete mode 100644 frontend/src/components/evolution/EvolutionList.tsx delete mode 100644 frontend/src/components/memory/MemoryCard.tsx delete mode 100644 frontend/src/components/memory/MemoryEditor.tsx delete mode 100644 frontend/src/components/memory/MemoryList.tsx delete mode 100644 frontend/src/lib/api/evolution.test.ts delete mode 100644 frontend/src/lib/api/evolution.ts delete mode 100644 frontend/src/lib/api/memory.test.ts delete mode 100644 frontend/src/lib/api/memory.ts diff --git a/backend/alembic/versions/004_息壤记忆域_三级记忆表重建与触发_实录_退役旧记忆表.py b/backend/alembic/versions/004_息壤记忆域_三级记忆表重建与触发_实录_退役旧记忆表.py new file mode 100644 index 0000000..f52fc15 --- /dev/null +++ b/backend/alembic/versions/004_息壤记忆域_三级记忆表重建与触发_实录_退役旧记忆表.py @@ -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') + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 0e35dab..bedb5f9 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -11,8 +11,6 @@ from app.api.v1.endpoints import ( characters, drive, events, - evolution, - extraction, goals, health, imports, @@ -22,7 +20,6 @@ from app.api.v1.endpoints import ( lorebooks, mirror_trees, mcp, - memory, personality, runs, safety, @@ -31,6 +28,7 @@ from app.api.v1.endpoints import ( skills, tools, world, + xirang, ) 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(jingsheng_characters.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(memory.router) api_router.include_router(lorebooks.router) api_router.include_router(personality.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(world.router) api_router.include_router(imports.router) -api_router.include_router(evolution.router) api_router.include_router(drive.router) api_router.include_router(events.router) -api_router.include_router(extraction.router) api_router.include_router(goals.router) api_router.include_router(admin.router) api_router.include_router(audio.router) diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index ffbea89..4136e85 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -387,7 +387,7 @@ async def admin_tenants_overview() -> list[_TenantUserOverview]: from app.db.session import AsyncSessionLocal from app.models.character import Character - from app.models.memory import Memory + from app.models.xirang import Memory from app.models.user import User async with AsyncSessionLocal() as session: @@ -406,7 +406,7 @@ async def admin_tenants_overview() -> list[_TenantUserOverview]: mem_count = await session.scalar( select(func.count()) .select_from(Memory) - .join(Character, Memory.character_id == Character.id) + .join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned") .where( Character.owner_scope == user.id, 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.models.character import Character - from app.models.memory import Memory + from app.models.xirang import Memory from app.services.serializers import memory_to_response async with AsyncSessionLocal() as session: result = await session.execute( select(Memory) - .join(Character, Memory.character_id == Character.id) + .join(Character, Memory.character_id == Character.id).where(Memory.status != "tombstoned") .where( Character.owner_scope == user_id, Character.is_system_template.is_(False), diff --git a/backend/app/api/v1/endpoints/evolution.py b/backend/app/api/v1/endpoints/evolution.py deleted file mode 100644 index 7ad02f5..0000000 --- a/backend/app/api/v1/endpoints/evolution.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/endpoints/extraction.py b/backend/app/api/v1/endpoints/extraction.py deleted file mode 100644 index 74e551c..0000000 --- a/backend/app/api/v1/endpoints/extraction.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/endpoints/memory.py b/backend/app/api/v1/endpoints/memory.py deleted file mode 100644 index f1a4a7a..0000000 --- a/backend/app/api/v1/endpoints/memory.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/endpoints/xirang.py b/backend/app/api/v1/endpoints/xirang.py new file mode 100644 index 0000000..34ad553 --- /dev/null +++ b/backend/app/api/v1/endpoints/xirang.py @@ -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 + ] diff --git a/backend/app/application/big_cycle.py b/backend/app/application/big_cycle.py deleted file mode 100644 index b61314f..0000000 --- a/backend/app/application/big_cycle.py +++ /dev/null @@ -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 diff --git a/backend/app/application/reflection.py b/backend/app/application/reflection.py deleted file mode 100644 index 7dc025e..0000000 --- a/backend/app/application/reflection.py +++ /dev/null @@ -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 diff --git a/backend/app/core/ids.py b/backend/app/core/ids.py index e1b549e..8627a26 100644 --- a/backend/app/core/ids.py +++ b/backend/app/core/ids.py @@ -151,6 +151,14 @@ def new_transplant_id() -> str: 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: return new_id("rl_") diff --git a/backend/app/domains/mirror_tree/service.py b/backend/app/domains/mirror_tree/service.py index b1d74ce..8907d2f 100644 --- a/backend/app/domains/mirror_tree/service.py +++ b/backend/app/domains/mirror_tree/service.py @@ -11,6 +11,7 @@ from __future__ import annotations +import asyncio import json from sqlalchemy import select @@ -413,6 +414,18 @@ async def build_generation_prompt( lines.append(f"{speaker}:{expand(n.content)}") transcript = "\n".join(lines) 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: parts.append(f"【共同经历(祖先路径)】\n{transcript}") parts.append(f"【当前问题】\n{expand(question.content)}") @@ -730,6 +743,11 @@ async def canonize( record_id = record.id await session.commit() + # 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回 + from app.domains.xirang import service as xirang_service + + await xirang_service.on_canonized(record_id) + await one_qi.emit( "tree.canonized", character_id=tree.character_id, @@ -873,3 +891,11 @@ async def transplant( source="mirror_tree", ) 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 "" diff --git a/backend/app/domains/xirang/__init__.py b/backend/app/domains/xirang/__init__.py new file mode 100644 index 0000000..b2b0b9b --- /dev/null +++ b/backend/app/domains/xirang/__init__.py @@ -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", +] diff --git a/backend/app/domains/xirang/service.py b/backend/app/domains/xirang/service.py new file mode 100644 index 0000000..37b2669 --- /dev/null +++ b/backend/app/domains/xirang/service.py @@ -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, + } diff --git a/backend/app/kernel/__init__.py b/backend/app/kernel/__init__.py index 9321600..c828ca4 100644 --- a/backend/app/kernel/__init__.py +++ b/backend/app/kernel/__init__.py @@ -119,6 +119,11 @@ class Kernel: assert created is not None return created + @property + def shenshi(self) -> ShenshiClient: + """神识客户端(息壤提取等功能任务的受控取用点)。""" + return self.loop._shenshi # noqa: SLF001 + def _spawn( self, *, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b0bd434..a14e3d2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -25,10 +25,10 @@ from app.models.mirror_tree import ( TreeNode, TreeTransplant, ) -from app.models.memory import Memory, MemoryLink from app.models.personality import PersonalityProposal, PersonalityVersion from app.models.rbac import Permission, Role, RolePermission, UserRole from app.models.runtime import Event, Run +from app.models.xirang import FactualRecord, Memory as XirangMemory, MemoryTrigger from app.models.safety import AuditEvent, PendingConfirmationRecord from app.models.skills import Skill from app.models.user import User @@ -52,9 +52,7 @@ __all__ = [ "Lorebook", "LorebookEntry", "McpServer", - "Memory", "MemoryCandidate", - "MemoryLink", "PendingConfirmationRecord", "PerceptionChannel", "Permission", diff --git a/backend/app/models/memory.py b/backend/app/models/memory.py deleted file mode 100644 index 545be66..0000000 --- a/backend/app/models/memory.py +++ /dev/null @@ -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) diff --git a/backend/app/models/xirang.py b/backend/app/models/xirang.py new file mode 100644 index 0000000..2352f77 --- /dev/null +++ b/backend/app/models/xirang.py @@ -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) diff --git a/backend/app/modules/extraction/__init__.py b/backend/app/modules/extraction/__init__.py deleted file mode 100644 index 2235ad1..0000000 --- a/backend/app/modules/extraction/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""大周天记忆/世界抽取领域纯逻辑(造消息 / 解析记忆候选与世界实体草稿,禁止 IO)。""" diff --git a/backend/app/modules/extraction/extract.py b/backend/app/modules/extraction/extract.py deleted file mode 100644 index 9447636..0000000 --- a/backend/app/modules/extraction/extract.py +++ /dev/null @@ -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)) diff --git a/backend/app/modules/extraction/prompts.py b/backend/app/modules/extraction/prompts.py deleted file mode 100644 index 0c67d87..0000000 --- a/backend/app/modules/extraction/prompts.py +++ /dev/null @@ -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) diff --git a/backend/app/modules/memory/__init__.py b/backend/app/modules/memory/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/modules/memory/context.py b/backend/app/modules/memory/context.py deleted file mode 100644 index a1f218e..0000000 --- a/backend/app/modules/memory/context.py +++ /dev/null @@ -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}个月前" diff --git a/backend/app/modules/reflection/__init__.py b/backend/app/modules/reflection/__init__.py deleted file mode 100644 index f63bf3f..0000000 --- a/backend/app/modules/reflection/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""小周天每轮反思领域纯逻辑(造消息 / 解析记忆候选与反思,禁止 IO)。""" diff --git a/backend/app/modules/reflection/extract.py b/backend/app/modules/reflection/extract.py deleted file mode 100644 index 487c140..0000000 --- a/backend/app/modules/reflection/extract.py +++ /dev/null @@ -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 diff --git a/backend/app/modules/reflection/prompts.py b/backend/app/modules/reflection/prompts.py deleted file mode 100644 index 2493087..0000000 --- a/backend/app/modules/reflection/prompts.py +++ /dev/null @@ -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 diff --git a/backend/app/repositories/characters.py b/backend/app/repositories/characters.py index 47042f3..2fbdf4f 100644 --- a/backend/app/repositories/characters.py +++ b/backend/app/repositories/characters.py @@ -4,7 +4,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession 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: @@ -98,7 +98,7 @@ async def count_memories(session: AsyncSession, character_id: str) -> int: await session.execute( select(func.count()) .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() return int(total or 0) diff --git a/backend/app/repositories/memory.py b/backend/app/repositories/memory.py deleted file mode 100644 index 8a8cdd1..0000000 --- a/backend/app/repositories/memory.py +++ /dev/null @@ -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()) diff --git a/backend/app/schemas/evolution.py b/backend/app/schemas/evolution.py deleted file mode 100644 index 7b7977c..0000000 --- a/backend/app/schemas/evolution.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/memory.py b/backend/app/schemas/memory.py deleted file mode 100644 index 5a19831..0000000 --- a/backend/app/schemas/memory.py +++ /dev/null @@ -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="过期时间;永不过期时为空。") diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py index 29d72e9..dd454fa 100644 --- a/backend/app/services/admin.py +++ b/backend/app/services/admin.py @@ -21,7 +21,7 @@ from app.models.governance import ( WorldEntity, ) 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.safety import AuditEvent 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) perception_n = await governance_repo.count(session, PerceptionChannel) 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) has_personality = bool(character and character.current_personality_version_id) tool_enabled = settings.enable_tool_runtime and tool_runtime is not None diff --git a/backend/app/services/evolution.py b/backend/app/services/evolution.py deleted file mode 100644 index db889c6..0000000 --- a/backend/app/services/evolution.py +++ /dev/null @@ -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, -} diff --git a/backend/app/services/extraction.py b/backend/app/services/extraction.py deleted file mode 100644 index ec1a821..0000000 --- a/backend/app/services/extraction.py +++ /dev/null @@ -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) diff --git a/backend/app/services/imports.py b/backend/app/services/imports.py index f6b4cdd..f915e36 100644 --- a/backend/app/services/imports.py +++ b/backend/app/services/imports.py @@ -15,7 +15,6 @@ from app.core.errors import AppError from app.core.time import utc_now from app.db.session import AsyncSessionLocal from app.models.imports import CharacterImportJob, ImportItem -from app.models.memory import Memory from app.modules.imports import parse_import from app.repositories import candidates as candidates_repo from app.repositories import imports as imports_repo @@ -325,29 +324,13 @@ async def _trigger_extraction( kind: str, owner_scope: str = settings.default_owner_scope, ) -> ImportJobResponse: - """校验 job → 置 extracting_* → 启动后台抽取 → 返回 job 当前状态。""" - async with AsyncSessionLocal() as session: - character = await get_owned_character(session, character_id, owner_scope=owner_scope) - job = await _get_owned_job(session, character.id, import_id) - if job.status not in _EXTRACTABLE_STATUSES: - 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, + """抽取触发已随旧管线退役:记忆候选由息壤归真管线生成(M3),画像/人格抽取随 M6。""" + raise AppError( + "not_implemented", + "该抽取入口已退役:记忆候选经镜树归真生成(息壤 M3);画像/人格抽取随 M6 灵核重建。", + status_code=410, ) - 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( character_id: str, diff --git a/backend/app/services/memory.py b/backend/app/services/memory.py deleted file mode 100644 index 98fa9cf..0000000 --- a/backend/app/services/memory.py +++ /dev/null @@ -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) diff --git a/backend/app/services/serializers.py b/backend/app/services/serializers.py index a21fcb4..a4ad58e 100644 --- a/backend/app/services/serializers.py +++ b/backend/app/services/serializers.py @@ -16,7 +16,6 @@ from app.models.governance import ( WorldEntity, ) from app.models.imports import CharacterImportJob, ImportItem -from app.models.memory import Memory from app.models.personality import PersonalityProposal, PersonalityVersion from app.models.runtime import Event, Run 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.character import CharacterResponse, CharacterSummaryResponse from app.schemas.imports import ImportItemResponse, ImportJobResponse -from app.schemas.memory import MemoryResponse from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse from app.schemas.preferences import GenerationPreferencesResponse 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: return RunResponse( 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, - character_id=candidate.character_id, - import_job_id=candidate.import_job_id, - cycle_id=candidate.cycle_id, - type=candidate.type, - title=candidate.title, - content=candidate.content, - confidence=candidate.confidence, - importance=candidate.importance, - sensitivity=candidate.sensitivity, - 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 "", - ) + + +def memory_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役) + from app.schemas.candidates import MemoryCandidateResponse + + return MemoryCandidateResponse.model_validate(candidate, from_attributes=True) + + +def profile_candidate_to_response(candidate): # noqa: ANN001 — 旧导入候选(M6 退役) + from app.schemas.candidates import ProfileCandidateResponse + + return ProfileCandidateResponse.model_validate(candidate, from_attributes=True) diff --git a/backend/tests/api/test_evolution_candidates.py b/backend/tests/api/test_evolution_candidates.py deleted file mode 100644 index fdbb7d8..0000000 --- a/backend/tests/api/test_evolution_candidates.py +++ /dev/null @@ -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" diff --git a/backend/tests/api/test_import_extraction.py b/backend/tests/api/test_import_extraction.py deleted file mode 100644 index d1643d7..0000000 --- a/backend/tests/api/test_import_extraction.py +++ /dev/null @@ -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" diff --git a/backend/tests/api/test_import_review.py b/backend/tests/api/test_import_review.py deleted file mode 100644 index 5754e62..0000000 --- a/backend/tests/api/test_import_review.py +++ /dev/null @@ -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 diff --git a/backend/tests/application/test_big_cycle.py b/backend/tests/application/test_big_cycle.py deleted file mode 100644 index f1d1748..0000000 --- a/backend/tests/application/test_big_cycle.py +++ /dev/null @@ -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 diff --git a/backend/tests/application/test_big_cycle_drive.py b/backend/tests/application/test_big_cycle_drive.py deleted file mode 100644 index fdc75ec..0000000 --- a/backend/tests/application/test_big_cycle_drive.py +++ /dev/null @@ -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" diff --git a/backend/tests/application/test_big_cycle_evolution.py b/backend/tests/application/test_big_cycle_evolution.py deleted file mode 100644 index f5c58f2..0000000 --- a/backend/tests/application/test_big_cycle_evolution.py +++ /dev/null @@ -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 diff --git a/backend/tests/application/test_big_cycle_extraction.py b/backend/tests/application/test_big_cycle_extraction.py deleted file mode 100644 index cb954af..0000000 --- a/backend/tests/application/test_big_cycle_extraction.py +++ /dev/null @@ -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" diff --git a/backend/tests/application/test_big_cycle_goal_progress.py b/backend/tests/application/test_big_cycle_goal_progress.py deleted file mode 100644 index d533ed6..0000000 --- a/backend/tests/application/test_big_cycle_goal_progress.py +++ /dev/null @@ -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" diff --git a/backend/tests/application/test_reflection.py b/backend/tests/application/test_reflection.py deleted file mode 100644 index d3e5961..0000000 --- a/backend/tests/application/test_reflection.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/extraction/test_extraction_extract.py b/backend/tests/modules/extraction/test_extraction_extract.py deleted file mode 100644 index 8e51259..0000000 --- a/backend/tests/modules/extraction/test_extraction_extract.py +++ /dev/null @@ -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) diff --git a/backend/tests/modules/extraction/test_extraction_prompts.py b/backend/tests/modules/extraction/test_extraction_prompts.py deleted file mode 100644 index cfaee60..0000000 --- a/backend/tests/modules/extraction/test_extraction_prompts.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/reflection/test_reflection_extract.py b/backend/tests/modules/reflection/test_reflection_extract.py deleted file mode 100644 index 8030303..0000000 --- a/backend/tests/modules/reflection/test_reflection_extract.py +++ /dev/null @@ -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] == "带空格的反思" diff --git a/backend/tests/modules/reflection/test_reflection_prompts.py b/backend/tests/modules/reflection/test_reflection_prompts.py deleted file mode 100644 index 44276c1..0000000 --- a/backend/tests/modules/reflection/test_reflection_prompts.py +++ /dev/null @@ -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 diff --git a/backend/tests/services/test_evolution_service.py b/backend/tests/services/test_evolution_service.py deleted file mode 100644 index 315bc30..0000000 --- a/backend/tests/services/test_evolution_service.py +++ /dev/null @@ -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 diff --git a/backend/tests/services/test_extraction_service.py b/backend/tests/services/test_extraction_service.py deleted file mode 100644 index 52ecc48..0000000 --- a/backend/tests/services/test_extraction_service.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 021f829..744a251 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -214,47 +214,6 @@ async def test_world_entities_crud(client: AsyncClient) -> None: 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 async def test_safety_policy_crud(client: AsyncClient) -> None: """安全策略 CRUD——覆盖 services/governance 部分路径。""" diff --git a/backend/tests/test_memory_context.py b/backend/tests/test_memory_context.py deleted file mode 100644 index 097eb43..0000000 --- a/backend/tests/test_memory_context.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_xirang.py b/backend/tests/test_xirang.py new file mode 100644 index 0000000..2de656a --- /dev/null +++ b/backend/tests/test_xirang.py @@ -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 引用供调试断言使用 diff --git a/frontend/src/app/(app)/evolution/page.tsx b/frontend/src/app/(app)/evolution/page.tsx deleted file mode 100644 index a6d5e37..0000000 --- a/frontend/src/app/(app)/evolution/page.tsx +++ /dev/null @@ -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("/characters/active"); - characterId = active.id; - characterName = active.display_name || active.name; - const [pending, applied, rejected] = await Promise.all([ - serverApi.get>( - `/characters/${active.id}/evolution-candidates`, - { status: "pending", page_size: 50 }, - ), - serverApi.get>( - `/characters/${active.id}/evolution-candidates`, - { status: "applied", page_size: 1 }, - ), - serverApi.get>( - `/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 ( - - {characterName && ( - - {characterName.charAt(0)} - {characterName} - - )} - - 待裁 {counts.pending} · 半自动{" "} - {counts.applied} · 已应用{" "} - {counts.applied} - - - } - maxWidth="max-w-5xl" - > - {error ? ( - - ) : characterId ? ( -
- {/* 周天四镜(签名) */} -
-
- 大周天 · 候选阶段 - - cycle_01JZK7Q3B…4F · 触发自会话结束 - - 候选生成中 -
-
-
-
-
评
-
- 评估 - evaluation - 态势 ✓ -
- -
-
-
演
-
- 演化 - evolution - 策略 ✓ -
- -
-
-
候
-
- 候选 - candidate · 当阶 - {counts.pending} 条草拟 -
- -
-
-
藏
-
- 入藏 - memory · 未达 - - -
-
- -
-
-
最近记忆
-
- 12条入参 -
-
-
-
人格版本
-
- v7当前 -
-
-
-
世界实体
-
- —个 -
-
-
-
内驱力
-
- {counts.pending} - 条好奇 -
-
-
- 评估结论:近 12 - 条记忆呈「回应偏长」信号,可收敛风格字段;身份画稳定,无核心变更必要。建议生成 - 1 条风格类半自动候选,其余维持现状。 -
-
-
- - -
- ) : null} -
- ); -} diff --git a/frontend/src/app/(app)/memory/page.tsx b/frontend/src/app/(app)/memory/page.tsx deleted file mode 100644 index 661ac21..0000000 --- a/frontend/src/app/(app)/memory/page.tsx +++ /dev/null @@ -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("/characters/active") - .catch(() => null); - characterId = active?.id; - const data = await serverApi.get>("/memory", { - page_size: 100, - }); - memories = data.items; - } catch (e) { - error = describeError(e); - } - - return ( - - {error ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/app/admin/memory/page.tsx b/frontend/src/app/admin/memory/page.tsx deleted file mode 100644 index 5b5f2e0..0000000 --- a/frontend/src/app/admin/memory/page.tsx +++ /dev/null @@ -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>("/memory", { - page_size: 100, - }); - memories = data.items; - } catch (e) { - error = describeError(e); - } - - return ( - - {error ? ( - - ) : memories.length === 0 ? ( - - ) : ( - - )} - - ); -} diff --git a/frontend/src/app/admin/pivots/[pivot]/page.tsx b/frontend/src/app/admin/pivots/[pivot]/page.tsx index c2ea3ad..2ea0193 100644 --- a/frontend/src/app/admin/pivots/[pivot]/page.tsx +++ b/frontend/src/app/admin/pivots/[pivot]/page.tsx @@ -6,7 +6,6 @@ import { VermilionAction } from "@/components/admin/VermilionAction"; import { PolicyMatrix } from "@/components/admin/PolicyMatrix"; import { AuditConsole } from "@/components/admin/AuditConsole"; import { ToolRegistry } from "@/components/admin/ToolRegistry"; -import { MemoryAdminList } from "@/components/admin/MemoryAdminList"; import { PersonalityEditor } from "@/components/admin/PersonalityEditor"; import { GoalTree } from "@/components/admin/GoalTree"; import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix"; @@ -46,7 +45,6 @@ const VALID_PIVOTS: Pivot[] = [ "perception", "world_model", "planner", - "memory", "tool_runtime", "agent_society", ]; @@ -74,20 +72,6 @@ export default async function PivotDetailPage({ return ; case "personality_core": return ; - case "memory": - return ; - case "goal_orchestrator": - return ; - case "perception": - return ; - case "world_model": - return ; - case "planner": - return ; - case "agent_society": - return ; - 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>("/memory", { - page_size: 100, - }); - memories = data.items; - } catch (e) { - error = describeError(e); - } - - return ( - - {error ? ( - - ) : memories.length === 0 ? ( - - ) : ( - - )} - - ); -} - /** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */ async function GoalOrchestratorDetail() { let goals: GoalNode[] = []; diff --git a/frontend/src/components/admin/MemoryAdminList.tsx b/frontend/src/components/admin/MemoryAdminList.tsx deleted file mode 100644 index af56597..0000000 --- a/frontend/src/components/admin/MemoryAdminList.tsx +++ /dev/null @@ -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(null); - const [draft, setDraft] = useState({ title: "", content: "" }); - const [busyId, setBusyId] = useState(null); - const [error, setError] = useState(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 ? ( -
-
- - - 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" - /> -
-