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

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

No files matched your search

+2 -6
View File
@@ -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)
+4 -4
View File
@@ -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),
-60
View File
@@ -1,60 +0,0 @@
"""演化候选审核 API。"""
from fastapi import APIRouter, Query
from app.schemas.common import Page
from app.schemas.evolution import EvolutionCandidateResponse
from app.services.evolution import (
approve_evolution_candidate,
list_evolution_candidates,
reject_evolution_candidate,
)
router = APIRouter(tags=["evolution"])
@router.get(
"/characters/{character_id}/evolution-candidates",
response_model=Page[EvolutionCandidateResponse],
)
async def list_evolution_candidates_endpoint(
character_id: str,
status: str | None = Query(
default=None, description="按状态过滤:pending/approved/rejected/applied"
),
candidate_type: str | None = Query(
default=None, alias="type", description="按候选类型过滤(契约字典 §12.1)"
),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[EvolutionCandidateResponse]:
"""列出某人物的演化候选(大周天生成 + 审核状态)。"""
return await list_evolution_candidates(
character_id=character_id,
status=status,
candidate_type=candidate_type,
page=page,
page_size=page_size,
)
@router.post(
"/evolution-candidates/{candidate_id}/approve",
response_model=EvolutionCandidateResponse,
)
async def approve_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
"""审核通过演化候选:派生到下游(人格版本/程序记忆等),status → applied。"""
return await approve_evolution_candidate(candidate_id)
@router.post(
"/evolution-candidates/{candidate_id}/reject",
response_model=EvolutionCandidateResponse,
)
async def reject_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
"""驳回演化候选:status → rejected,不派生下游。"""
return await reject_evolution_candidate(candidate_id)
@@ -1,90 +0,0 @@
"""大周天抽取候选审核 API(记忆候选 + 世界实体候选)。"""
from fastapi import APIRouter, Query
from app.schemas.candidates import MemoryCandidateResponse
from app.schemas.common import Page
from app.schemas.world import WorldEntityResponse
from app.services.extraction import (
approve_memory_proposal,
approve_world_entity,
list_memory_proposals,
list_world_entities_pending,
reject_memory_proposal,
reject_world_entity,
)
router = APIRouter(tags=["extraction"])
@router.get(
"/characters/{character_id}/memory-candidates",
response_model=Page[MemoryCandidateResponse],
)
async def list_memory_candidates_endpoint(
character_id: str,
source: str | None = Query(
default=None, description="来源过滤:cycle(大周天)/ import(资料导入)"
),
status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[MemoryCandidateResponse]:
"""列出记忆候选(大周天 + 资料导入统一)。"""
return await list_memory_proposals(
character_id=character_id,
source=source,
status=status,
page=page,
page_size=page_size,
)
@router.post(
"/memory-candidates/{candidate_id}/approve",
response_model=MemoryCandidateResponse,
)
async def approve_memory_candidate_endpoint(
candidate_id: str,
) -> MemoryCandidateResponse:
"""通过记忆候选:派生为 memories 记录(source=big_cycle)。"""
return await approve_memory_proposal(candidate_id)
@router.post(
"/memory-candidates/{candidate_id}/reject",
response_model=MemoryCandidateResponse,
)
async def reject_memory_candidate_endpoint(
candidate_id: str,
) -> MemoryCandidateResponse:
"""驳回记忆候选。"""
return await reject_memory_proposal(candidate_id)
@router.get(
"/characters/{character_id}/world/entity-proposals",
response_model=Page[WorldEntityResponse],
)
async def list_world_entity_proposals_endpoint(
character_id: str,
status: str | None = Query(default="pending", description="默认 pending(候选)"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> Page[WorldEntityResponse]:
"""列出世界实体候选(大周天抽取,status=pending 待审核)。"""
return await list_world_entities_pending(
character_id=character_id, status=status, page=page, page_size=page_size
)
@router.post("/world/entities/{entity_id}/approve", response_model=WorldEntityResponse)
async def approve_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
"""通过世界实体候选:status → confirmed 进入世界模型。"""
return await approve_world_entity(entity_id)
@router.post("/world/entities/{entity_id}/reject", response_model=WorldEntityResponse)
async def reject_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
"""驳回世界实体候选。"""
return await reject_world_entity(entity_id)
-73
View File
@@ -1,73 +0,0 @@
"""记忆 API。"""
from fastapi import APIRouter, Depends, Header, Query, Request
from app.api.v1.deps import get_current_user
from app.schemas.auth import CurrentUser
from app.schemas.common import Page
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
from app.services.idempotency import run_idempotent
from app.services.memory import create_memory, delete_memory, list_memory_page, update_memory
router = APIRouter(prefix="/memory", tags=["memory"])
@router.get("", response_model=Page[MemoryResponse])
async def list_memory(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
memory_type: str | None = Query(
default=None,
alias="type",
description="按类型过滤(如 user/assistant/system,契约字典 memory.type)。",
),
source: str | None = Query(
default=None,
description="按来源过滤(如 user_explicit/big_cycle/import,契约字典 memory.source)。",
),
current_user: CurrentUser = Depends(get_current_user),
) -> Page[MemoryResponse]:
return await list_memory_page(
page=page,
page_size=page_size,
memory_type=memory_type,
source=source,
owner_scope=current_user.owner_scope,
)
@router.post("", response_model=MemoryResponse)
async def create_memory_api(
payload: MemoryCreateRequest,
request: Request,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await run_idempotent(
key=idempotency_key,
method="POST",
path=str(request.url.path),
payload=payload,
response_type=MemoryResponse,
resource_type="memory",
handler=lambda session: create_memory(
session, payload, owner_scope=current_user.owner_scope
),
)
@router.patch("/{memory_id}", response_model=MemoryResponse)
async def update_memory_api(
memory_id: str,
payload: MemoryUpdateRequest,
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await update_memory(memory_id, payload, owner_scope=current_user.owner_scope)
@router.delete("/{memory_id}", response_model=MemoryResponse)
async def delete_memory_api(
memory_id: str,
current_user: CurrentUser = Depends(get_current_user),
) -> MemoryResponse:
return await delete_memory(memory_id, owner_scope=current_user.owner_scope)
+157
View File
@@ -0,0 +1,157 @@
"""息壤(记忆域)API(api-contracts §3.5)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains import xirang
from app.models.character import Character
from app.models.xirang import FactualRecord, Memory
from app.schemas.auth import CurrentUser
router = APIRouter(tags=["xirang"])
class MemorizeRequest(BaseModel):
label: str = Field(description="fact / insight / opinion / hypothesis")
class CorrectRequest(BaseModel):
new_content: str = Field(min_length=1, max_length=8000)
def _memory_view(m: Memory, with_content: bool = True) -> dict:
return {
"id": m.id,
"character_id": m.character_id,
"tier": m.tier,
"type": m.type,
"status": m.status,
"source": m.source,
"content": m.content if with_content else "",
"source_refs": json.loads(m.source_refs_json or "[]"),
"sensitivity": m.sensitivity,
"priority": m.priority,
"confidence": m.confidence,
"vitality": round(m.vitality, 3),
"realm": m.realm,
"prev_version_id": m.prev_version_id,
"recall_count": m.recall_count,
"last_recalled_at": m.last_recalled_at.isoformat() if m.last_recalled_at else None,
"created_at": m.created_at.isoformat(),
}
async def _owned_character(character_id: str, owner_scope: str) -> Character:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。")
return character
@router.get("/characters/{character_id}/memories")
async def list_memories(
character_id: str,
tier: str | None = Query(default=None),
status: str | None = Query(default=None),
type_: str | None = Query(default=None, alias="type"),
user: CurrentUser = Depends(get_current_user),
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
stmt = select(Memory).where(Memory.character_id == character_id)
if tier:
stmt = stmt.where(Memory.tier == tier)
if status:
stmt = stmt.where(Memory.status == status)
if type_:
stmt = stmt.where(Memory.type == type_)
stmt = stmt.order_by(Memory.created_at.desc()).limit(200)
rows = (await session.execute(stmt)).scalars().all()
# 高敏不展示原文(sensitivity=high 摘要化);墓碑不承载原文
return [
_memory_view(
m,
with_content=not (m.sensitivity == "high" or m.status == "tombstoned"),
)
for m in rows
]
@router.get("/memories/{memory_id}")
async def get_memory(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
async with AsyncSessionLocal() as session:
memory = await session.get(Memory, memory_id)
if memory is not None:
character = await session.get(Character, memory.character_id)
if memory is None or character is None or character.owner_scope != user.owner_scope:
raise AppError("not_found", "记忆不存在。")
return _memory_view(memory)
@router.post("/memories/{memory_id}/confirm")
async def confirm_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
memory = await xirang.confirm_memory(memory_id, user.owner_scope)
return _memory_view(memory)
@router.post("/memories/{memory_id}/deny")
async def deny_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await xirang.deny_memory(memory_id, user.owner_scope)
@router.post("/memories/{memory_id}/correct")
async def correct_memory_api(
memory_id: str, payload: CorrectRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
memory = await xirang.correct_memory(memory_id, user.owner_scope, payload.new_content)
return _memory_view(memory)
@router.delete("/memories/{memory_id}", status_code=204)
async def delete_memory_api(memory_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
await xirang.delete_memory(memory_id, user.owner_scope)
@router.post("/nodes/{node_id}/memorize", status_code=201)
async def memorize_node_api(
node_id: str, payload: MemorizeRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
memory = await xirang.memorize_node(
node_id=node_id, owner_scope=user.owner_scope, label=payload.label
)
return _memory_view(memory)
@router.get("/characters/{character_id}/factual-records")
async def list_factual_records(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
await _owned_character(character_id, user.owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(FactualRecord)
.where(FactualRecord.character_id == character_id)
.order_by(FactualRecord.occurred_at.desc())
.limit(100)
)
).scalars().all()
return [
{
"id": r.id,
"canonization_id": r.canonization_id,
"summary": r.summary,
"evidence": json.loads(r.evidence_json or "{}"),
"occurred_at": r.occurred_at.isoformat(),
}
for r in rows
]
-602
View File
@@ -1,602 +0,0 @@
"""大周天:长期演化循环(architecture §3.2;v1-scope §3.3)。
双触发:记忆阈值 + 每轮对话后检查。循环阶段化执行(自我评估 → 演化策略 →
候选生成),候选生成调神识模型产出统一 ``evolution_candidates``,按 ``level``
半自动应用——人格特质/程序记忆等已成熟下游立即派生,L4 核心变更留人工审核
(见 [大周天演化候选落地设计] ../../docs/domain/core-domains.md)。
单进程 asyncio,多 worker 需任务队列。
"""
import asyncio
from dataclasses import asdict
from datetime import UTC
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai_provider import ProviderManager, ProviderManagerFactory
from app.core.config import settings
from app.core.errors import AppError
from app.core.ids import new_goal_id
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.models.evolution import EvolutionCandidate
from app.models.governance import Goal
from app.models.memory import Memory
from app.models.world import CharacterWorldEntity
from app.modules.big_cycle.extract import parse_evolution_candidates
from app.modules.big_cycle.prompts import (
EvaluationSnapshot,
build_evolution_extraction_messages,
)
from app.modules.drive.extract import parse_curiosities
from app.modules.drive.prompts import build_drive_extraction_messages
from app.modules.evolution.policies import should_auto_apply
from app.modules.extraction.extract import parse_extraction
from app.modules.extraction.prompts import build_extraction_messages
from app.modules.runtime.events import Event
from app.repositories import big_cycle as big_cycle_repo
from app.repositories import characters as character_repo
from app.repositories import drive as drive_repo
from app.repositories import evolution as evolution_repo
from app.repositories import memory as memory_repo
from app.repositories import runtime as runtime_repo # 新增:活跃 run 检查
from app.repositories import world as world_repo
from app.repositories.json_utils import dumps
from app.services import evolution as evolution_service
from app.services.audit import record_audit_event
from app.services.event_store import DbEventStore
from app.services.personality import build_personality_summary
_running_big_cycle_tasks: set[asyncio.Task[None]] = set()
_RECENT_MEMORY_LIMIT = 10
# Token 预算估算(各阶段大致消耗,用于超预算时跳过非关键阶段)
_ESTIMATED_EVALUATION_COST = 4000
_ESTIMATED_CANDIDATE_COST = 16000
_ESTIMATED_MEMORY_COST = 8000
_provider_manager: ProviderManager | None = None
def _get_provider_manager() -> ProviderManager:
global _provider_manager
if _provider_manager is None:
_provider_manager = ProviderManagerFactory.create()
return _provider_manager
def _get_event_store() -> DbEventStore:
"""创建临时 EventStore 实例用于推送大周天事件(自管 session 工厂)。"""
return DbEventStore(AsyncSessionLocal)
async def _emit_big_cycle_event(
event_type: str,
character_id: str,
cycle_id: str,
*,
payload: dict | None = None,
) -> None:
"""推送大周天事件到 EventStore(自管 Session,失败静默)。"""
try:
store = _get_event_store()
await store.append(
Event(
type=event_type,
source="big_cycle",
visibility="public",
character_id=character_id,
payload={"cycle_id": cycle_id, **(payload or {})},
)
)
except Exception: # noqa: BLE001
logger.warning(f"推送大周天事件失败: {event_type}", cycle_id=cycle_id)
async def drain_pending_big_cycles() -> None:
"""等待所有进行中的大周天循环完成(测试断言 / 优雅停机用)。"""
if _running_big_cycle_tasks:
await asyncio.gather(*_running_big_cycle_tasks, return_exceptions=True)
async def maybe_trigger_big_cycle(character_id: str, trigger: str = "session_turn") -> str | None:
"""检查触发条件,满足则创建 cycle 记录并启动后台循环;返回 cycle_id 或 None。"""
if not settings.enable_big_cycle:
return None
async with AsyncSessionLocal() as session:
# 任务中断防护:如果有活跃运行中的 run,跳过大周天避免干扰对话
if await runtime_repo.has_active_runs(session, character_id):
logger.debug(f"大周天跳过:{character_id} 有活跃 run")
return None
memory_count = await character_repo.count_memories(session, character_id)
if memory_count < settings.big_cycle_memory_threshold:
return None
last = await big_cycle_repo.get_last_completed(session, character_id)
if last is not None and last.completed_at is not None:
last_completed = last.completed_at
if last_completed.tzinfo is None:
last_completed = last_completed.replace(tzinfo=UTC)
if (
utc_now() - last_completed
).total_seconds() < settings.big_cycle_min_interval_seconds:
return None
cycle = BigCycle(
character_id=character_id,
trigger=trigger,
status="pending",
memory_count_at_trigger=memory_count,
)
await big_cycle_repo.add_cycle(session, cycle)
await session.commit()
cycle_id = cycle.id
start_big_cycle(cycle_id, character_id)
return cycle_id
def start_big_cycle(cycle_id: str, character_id: str) -> None:
task = asyncio.create_task(_run_big_cycle(cycle_id, character_id))
_running_big_cycle_tasks.add(task)
task.add_done_callback(_running_big_cycle_tasks.discard)
async def _phase_goal_progress(session: AsyncSession, cycle: BigCycle) -> None:
"""大周天目标进度更新:根据演化结果增加关联目标进度。
当前为简单版本:每完成一轮大周天,character 的活跃 task/session 目标进度 +0.1。
后续可改为 LLM 评估更精确的进度。
"""
from app.repositories import governance as governance_repo
active_goals = await governance_repo.list_goals_by_character(
session, cycle.character_id, status_filter="active"
)
for goal in active_goals:
if goal.level in ("task", "session") and goal.progress < 1.0:
new_progress = min(1.0, goal.progress + 0.1)
await governance_repo.update_goal_progress(session, goal.id, new_progress)
if new_progress >= 1.0 and goal.level == "task":
goal.status = "completed"
from app.services.governance import emit_goal_event
await emit_goal_event(
goal,
"completed",
extra_payload={"trigger": "big_cycle"},
)
def _score_autonomy(subgoal) -> float:
"""按 information_gain / interruption_cost 比值排序。"""
gain = getattr(subgoal, "expected_information_gain", None) or 0.0
cost = getattr(subgoal, "interruption_cost", None) or 1.0
return gain / max(cost, 0.1)
async def _phase_autonomous_exploration(
session: AsyncSession, cycle: BigCycle,
) -> None:
"""自主探索执行:将最高优先级的已批准次生目标标记为待叩心枢执行。
选择 expected_information_gain / interruption_cost 比值最高的已批准次生目标,
标记为 auto_execute_pending,由叩心枢的下一个 tick 拾取。
每次大周天最多标记 2 个。
"""
from app.repositories import drive as drive_repo
approved = await drive_repo.list_approved_subgoals(
session, cycle.character_id, limit=5,
)
if not approved:
return
sorted_subgoals = sorted(approved, key=_score_autonomy, reverse=True)
to_execute = sorted_subgoals[:2]
for sub in to_execute:
sub.status = "auto_execute_pending"
await session.flush()
logger.info(
f"大周天自主探索标记 cycle={cycle.id} "
f"subgoals={len(to_execute)}/{len(approved)}"
)
async def _run_big_cycle(cycle_id: str, character_id: str) -> None:
"""大周天主循环:阶段化执行,超预算时跳过非关键阶段。"""
budget_spent = 0
await _emit_big_cycle_event("big_cycle.started", character_id, cycle_id)
async with AsyncSessionLocal() as session:
try:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
if cycle is None:
return
cycle.status = "running"
cycle.started_at = utc_now()
await session.commit()
# 阶段 1:自我评估(关键阶段,始终执行)
snapshot = await _phase_evaluation(session, cycle)
budget_spent += _ESTIMATED_EVALUATION_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "evaluation", "status": "completed"})
# 阶段 2:演化策略(轻量,始终执行)
await _phase_evolution(session, cycle)
# 预算检查:超过 token 预算则跳过候选/记忆/探索阶段
if budget_spent >= settings.big_cycle_token_budget:
logger.warning(f"大周天 token 预算耗尽 cycle={cycle_id} spent={budget_spent}")
cycle.summary = "token 预算耗尽,跳过后续非关键阶段"
await session.commit()
await _emit_big_cycle_event(
"big_cycle.phase", character_id, cycle_id,
payload={"phase": "candidate", "status": "skipped", "reason": "budget_exhausted"},
)
await _emit_big_cycle_event(
"big_cycle.phase", character_id, cycle_id,
payload={"phase": "memory", "status": "skipped", "reason": "budget_exhausted"},
)
else:
# 阶段 3:候选生成
await _phase_candidates(session, cycle, snapshot)
budget_spent += _ESTIMATED_CANDIDATE_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "candidate", "status": "completed"})
# 阶段 4:记忆/世界抽取
await _phase_memory(session, cycle, snapshot)
budget_spent += _ESTIMATED_MEMORY_COST
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "memory", "status": "completed"})
# 阶段 5:目标进度更新
await _phase_goal_progress(session, cycle)
# 阶段 6:自主探索执行(轻量,始终执行)
await _phase_autonomous_exploration(session, cycle)
cycle.status = "completed"
cycle.phase = "done"
cycle.completed_at = utc_now()
await record_audit_event(
session,
action="big_cycle.completed",
character_id=character_id,
actor="system",
decision="auto",
input_summary=(cycle.summary or "大周天循环完成")[:200],
resource_type="big_cycle",
resource_id=cycle.id,
)
await session.commit()
await _emit_big_cycle_event(
"big_cycle.completed", character_id, cycle_id,
payload={"summary": cycle.summary},
)
except Exception as exc:
await _fail_cycle(session, cycle_id, exc)
await _emit_big_cycle_event(
"big_cycle.failed", character_id, cycle_id,
payload={"error": str(exc)[:200]},
)
async def _phase_evaluation(session: AsyncSession, cycle: BigCycle) -> EvaluationSnapshot:
"""自我评估:汇总记忆/人格/世界态势,写入 cycle 快照供候选生成消费。"""
cycle.phase = "evaluation"
await session.commit()
character = await character_repo.get_character(session, cycle.character_id)
if character is None:
raise AppError("not_found", "人物不存在。")
memory_count = await character_repo.count_memories(session, cycle.character_id)
recent, _ = await memory_repo.list_memories(
session, cycle.character_id, page=1, page_size=_RECENT_MEMORY_LIMIT
)
personality = await build_personality_summary(session, character)
_, world_count = await world_repo.list_entities(
session, cycle.character_id, page=1, page_size=1
)
snapshot = EvaluationSnapshot(
character_id=cycle.character_id,
memory_count=memory_count,
recent_memories=[(m.title, m.content) for m in recent],
personality_summary=personality.text or "",
world_entity_count=world_count,
conclusion=_build_conclusion(memory_count, world_count, personality.text),
evidence_ids=[m.id for m in recent],
)
# 人格权重向量:查询最近大周天的累积漂移,注入态势快照供 LLM 感知
from app.repositories import evolution as evolution_repo
recent_candidates, _ = await evolution_repo.list_candidates(
session, cycle.character_id, status=None, page=1, page_size=100,
)
if recent_candidates:
recent_drift = sum(c.drift_score or 0.0 for c in recent_candidates)
drift_budget_remaining = max(0.0, settings.big_cycle_max_drift - recent_drift)
snapshot.drift_budget_remaining = drift_budget_remaining
snapshot.cumulative_drift = recent_drift
cycle.evaluation_snapshot_json = dumps(asdict(snapshot))
await session.commit()
logger.info(f"大周天自我评估 cycle={cycle.id} memory={memory_count} world={world_count}")
await _generate_drive(session, cycle, snapshot)
return snapshot
async def _generate_drive(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""内驱力生成(C 子阶段):基于态势生成好奇问题与探索性次生目标。"""
system, user = build_drive_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
drafts = parse_curiosities(raw)[: settings.big_cycle_max_candidates]
curiosities = [
CuriosityQuestion(
character_id=cycle.character_id,
cycle_id=cycle.id,
question=draft.question,
source=draft.source,
related_entities_json=dumps(draft.related_entities),
expected_value=draft.expected_value,
urgency=draft.urgency,
risk_level=draft.risk_level,
)
for draft in drafts
]
if curiosities:
await drive_repo.add_curiosities(session, curiosities)
await session.flush()
subgoals: list[ExploratorySubgoal] = []
for curiosity, draft in zip(curiosities, drafts, strict=True):
for sub in draft.subgoals:
subgoals.append(
ExploratorySubgoal(
character_id=cycle.character_id,
cycle_id=cycle.id,
curiosity_question_id=curiosity.id,
title=sub.title,
description=sub.description,
expected_information_gain=sub.expected_information_gain,
interruption_cost=sub.interruption_cost,
risk_level=sub.risk_level,
status="candidate",
)
)
if subgoals:
await drive_repo.add_subgoals(session, subgoals)
await session.flush()
# 自动通过低风险次生目标(L0-L2)→ 创建 Goal
auto_approve_levels = {"L0", "L1", "L2"}
auto_count = 0
for sub in subgoals:
if sub.risk_level in auto_approve_levels:
sub.status = "approved"
goal = Goal(
id=new_goal_id(),
character_id=cycle.character_id,
title=sub.title,
description=sub.description or "由次生目标自动生成",
level="task",
type="exploration",
status="active",
)
session.add(goal)
auto_count += 1
await session.flush()
if auto_count:
logger.info(f"大周天自动通过 {auto_count}/{len(subgoals)} 个低风险次生目标")
logger.info(
f"大周天内驱力生成 cycle={cycle.id} curiosities={len(curiosities)} subgoals={len(subgoals)}"
)
await _emit_big_cycle_event(
"big_cycle.drive_generated", cycle.character_id, cycle.id,
payload={
"curiosity_count": len(curiosities),
"subgoal_count": len(subgoals),
"auto_approved_subgoals": auto_count,
},
)
async def _phase_evolution(session: AsyncSession, cycle: BigCycle) -> None:
"""演化策略阶段(轻量):演化策略融合进候选生成 prompt,此处仅标记阶段。"""
cycle.phase = "evolution"
await session.commit()
logger.info(f"大周天演化策略 cycle={cycle.id}")
async def _phase_candidates(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""候选生成:调神识模型产出演化候选,按 level 半自动应用。"""
cycle.phase = "candidate"
await session.commit()
system, user = build_evolution_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
drafts = parse_evolution_candidates(raw)[: settings.big_cycle_max_candidates]
candidates = [
EvolutionCandidate(
character_id=cycle.character_id,
cycle_id=cycle.id,
type=draft.type,
source=draft.source,
level=draft.level,
scope=draft.scope,
title=draft.title,
proposal=draft.proposal,
delta_json=dumps(draft.delta),
evidence_ids_json=dumps(_resolve_evidence(snapshot, draft.evidence)),
confidence=draft.confidence,
benefit_score=draft.benefit_score,
risk_score=draft.risk_score,
drift_score=draft.drift_score,
auto_apply_allowed=draft.auto_apply_allowed,
status="pending",
)
for draft in drafts
]
await evolution_repo.add_candidates(session, candidates)
await session.flush()
# 反漂移检测:高漂移候选不自动应用 + 累积漂移跟踪
cumulative_drift = 0.0
drift_blocked = 0
for candidate in candidates:
if candidate.drift_score >= settings.big_cycle_drift_threshold:
# 高漂移候选强制标记为 pending(人工审核),不自动应用
candidate.auto_apply_allowed = False
candidate.status = "pending"
candidate.error_message = f"漂移 {candidate.drift_score} >= 阈值 {settings.big_cycle_drift_threshold},需人工审核"
drift_blocked += 1
cumulative_drift += candidate.drift_score
# 累积漂移超过上限 → 全部候选降为 pending
if cumulative_drift >= settings.big_cycle_max_drift:
logger.warning(f"累积漂移 {cumulative_drift:.2f} >= 上限 {settings.big_cycle_max_drift},全部候选降为人工审核")
for candidate in candidates:
if candidate.status != "applied":
candidate.auto_apply_allowed = False
candidate.status = "pending"
candidate.error_message = f"累积漂移 {cumulative_drift:.2f} 超限,暂停自动演化"
for candidate in candidates:
if should_auto_apply(candidate):
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
if target_id is not None:
candidate.status = "applied"
candidate.resulting_target_id = target_id
else:
candidate.error_message = "该类型派生待后续阶段,已降级为人工审核"
applied = sum(1 for c in candidates if c.status == "applied")
pending = sum(1 for c in candidates if c.status == "pending")
cycle.summary = (
f"大周天生成 {len(candidates)} 个演化候选,"
f"{applied} 个已自动应用,{pending} 个待审核"
f"{f'({drift_blocked} 个因漂移拦截)' if drift_blocked else ''}"
f"{f',累积漂移 {cumulative_drift:.2f}' if cumulative_drift > 0 else ''}"
)
if drift_blocked:
await _emit_big_cycle_event(
"big_cycle.drift_blocked", cycle.character_id, cycle.id,
payload={
"drift_blocked": drift_blocked,
"cumulative_drift": round(cumulative_drift, 2),
"threshold": settings.big_cycle_drift_threshold,
},
)
await session.commit()
logger.info(f"大周天候选生成 cycle={cycle.id} count={len(candidates)} applied={applied}")
async def _phase_memory(
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
) -> None:
"""记忆/世界抽取(D 子阶段):从态势抽取记忆候选与世界实体候选。"""
cycle.phase = "memory"
await session.commit()
system, user = build_extraction_messages(snapshot)
raw = await _call_model(system, user, cycle.character_id)
memory_drafts, entity_drafts = parse_extraction(raw)
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
entity_drafts = entity_drafts[: settings.big_cycle_max_candidates]
memory_cands = [
MemoryCandidate(
character_id=cycle.character_id,
cycle_id=cycle.id,
type=draft.type,
title=draft.title,
content=draft.content,
confidence=draft.confidence,
importance=draft.importance,
sensitivity=draft.sensitivity,
reason=draft.reason,
status="pending",
)
for draft in memory_drafts
]
entities = [
CharacterWorldEntity(
character_id=cycle.character_id,
type=draft.type,
name=draft.name,
attributes_json=dumps(draft.attributes),
state_type=draft.state_type,
confidence=draft.confidence,
status="pending",
)
for draft in entity_drafts
]
if memory_cands:
session.add_all(memory_cands)
if entities:
session.add_all(entities)
await session.flush()
# 自动落地高置信度(≥0.7)低敏感度的记忆候选到 memories 表
auto_memories = 0
for cand in memory_cands:
if cand.confidence >= 0.7 and cand.sensitivity in ("low", "none"):
mem = Memory(
character_id=cycle.character_id,
type=cand.type,
title=cand.title,
content=cand.content,
source="big_cycle",
importance=cand.importance,
sensitivity=cand.sensitivity,
confidence=cand.confidence,
)
session.add(mem)
cand.status = "applied"
auto_memories += 1
# 自动落地高置信度世界实体候选
auto_entities = 0
for ent in entities:
if ent.confidence >= 0.7:
ent.status = "active"
auto_entities += 1
await session.flush()
if auto_memories or auto_entities:
logger.info(
f"大周天记忆/世界自动落地 cycle={cycle.id} "
f"memories={auto_memories} entities={auto_entities}"
)
logger.info(
f"大周天记忆/世界抽取 cycle={cycle.id} "
f"memories={len(memory_cands)} entities={len(entities)}"
)
def _resolve_evidence(snapshot: EvaluationSnapshot, indices: list[int]) -> list[str]:
return [snapshot.evidence_ids[i] for i in indices if 0 <= i < len(snapshot.evidence_ids)]
def _build_conclusion(memory_count: int, world_count: int, personality_text: str | None) -> str:
parts = [f"记忆 {memory_count} 条", f"世界实体 {world_count} 个"]
parts.append("已具备人格基线" if personality_text else "尚无人格基线")
return ";".join(parts)
async def _call_model(system: str, user: str, _character_id: str) -> str:
manager = _get_provider_manager()
messages = manager.to_message_list([system], [("user", user)])
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
return response.content
async def _fail_cycle(session: AsyncSession, cycle_id: str, exc: Exception) -> None:
logger.error(f"大周天循环失败 cycle={cycle_id}: {exc}")
try:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
if cycle is not None:
cycle.status = "failed"
cycle.error_code = "big_cycle_failed"
cycle.error_message = str(exc)[:500]
cycle.completed_at = utc_now()
await session.commit()
except Exception: # noqa: BLE001 — 失败处理本身不能再抛
pass
-150
View File
@@ -1,150 +0,0 @@
"""小周天每轮记忆候选 + 返照反思(异步后台任务,跨 modules + ai_provider)。
言灵表达(流式回复完成)后触发:从本轮对话抽取记忆候选(待审核)+ 生成反思
(直接建 memory type=reflection)。失败静默,不影响主流程。仅适用单进程;
多 worker 部署需替换为跨进程任务队列。
"""
import asyncio
from app.ai_provider import ProviderManager, ProviderManagerFactory
from app.core.config import settings
from app.core.logging import logger
from app.db.session import AsyncSessionLocal
from app.models.candidates import MemoryCandidate
from app.models.memory import Memory
from app.modules.reflection.extract import parse_reflection
from app.modules.reflection.prompts import build_reflection_messages
_running_reflection_tasks: set[asyncio.Task[None]] = set()
_provider_manager: ProviderManager | None = None
def _get_provider_manager() -> ProviderManager:
global _provider_manager
if _provider_manager is None:
_provider_manager = ProviderManagerFactory.create()
return _provider_manager
async def drain_pending_reflections() -> None:
"""等待所有进行中的反思任务完成(测试断言 / 优雅停机用)。"""
if _running_reflection_tasks:
await asyncio.gather(*_running_reflection_tasks, return_exceptions=True)
def start_reflection_after_turn(
character_id: str,
session_id: str,
user_content: str,
assistant_content: str,
) -> None:
"""每轮对话后启动反思抽取(后台任务);session_id 留作未来事件回写。"""
task = asyncio.create_task(
_run_reflection(character_id, session_id, user_content, assistant_content)
)
_running_reflection_tasks.add(task)
task.add_done_callback(_running_reflection_tasks.discard)
async def _run_reflection(
character_id: str,
session_id: str,
user_content: str,
assistant_content: str,
) -> None:
async with AsyncSessionLocal() as session:
try:
system, user = build_reflection_messages(user_content, assistant_content)
raw = await _call_model(system, user, character_id)
memory_drafts, reflection_text = parse_reflection(raw)
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
# 小周天记忆候选:高置信度(≥0.7)低敏感度直接落地为 Memory,
# 其余留 pending 待大周天统一审核
auto_memories = 0
pending_count = 0
for draft in memory_drafts:
if draft.confidence >= 0.7 and draft.sensitivity in ("low", "none"):
session.add(
Memory(
character_id=character_id,
type=draft.type,
title=draft.title,
content=draft.content,
source="reflection",
importance=draft.importance,
sensitivity=draft.sensitivity,
confidence=draft.confidence,
)
)
auto_memories += 1
else:
session.add(
MemoryCandidate(
character_id=character_id,
type=draft.type,
title=draft.title,
content=draft.content,
confidence=draft.confidence,
importance=draft.importance,
sensitivity=draft.sensitivity,
reason=draft.reason,
status="pending",
)
)
pending_count += 1
if reflection_text:
title = reflection_text[:60] + ("…" if len(reflection_text) > 60 else "")
session.add(
Memory(
character_id=character_id,
type="reflection",
title=title,
content=reflection_text,
source="reflection",
importance=0.5,
sensitivity="low",
)
)
await session.commit()
logger.info(
f"小周天反思 character={character_id} session={session_id} "
f"auto={auto_memories} pending={pending_count} "
f"reflection={'yes' if reflection_text else 'no'}"
)
# 推送反思完成事件(供叩心枢/前端观测)
try:
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
store = DbEventStore(AsyncSessionLocal)
await store.append(
Event(
type="reflection.completed",
source="reflection",
visibility="public",
character_id=character_id,
payload={
"session_id": session_id,
"auto_memories": auto_memories,
"pending_count": pending_count,
"has_reflection": bool(reflection_text),
"reflection_preview": (reflection_text or "")[:100],
},
)
)
except Exception: # noqa: BLE001
logger.warning("推送 reflection.completed 事件失败")
except Exception as exc: # noqa: BLE001 — 后台任务失败静默
logger.warning(f"小周天反思失败 character={character_id}: {exc}")
async def _call_model(system: str, user: str, _character_id: str) -> str:
manager = _get_provider_manager()
messages = manager.to_message_list([system], [("user", user)])
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
return response.content
+8
View File
@@ -151,6 +151,14 @@ def new_transplant_id() -> str:
return new_id("transplant_")
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_")
@@ -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 ""
+25
View File
@@ -0,0 +1,25 @@
"""息壤(记忆域):三级记忆、实录与召回(data-model.md §3)。"""
from app.domains.xirang.service import (
confirm_memory,
correct_memory,
delete_memory,
deny_memory,
memorize_node,
on_canonized,
place_candidates,
recall_for_generation,
record_factual,
)
__all__ = [
"confirm_memory",
"correct_memory",
"delete_memory",
"deny_memory",
"memorize_node",
"on_canonized",
"place_candidates",
"recall_for_generation",
"record_factual",
]
+504
View File
@@ -0,0 +1,504 @@
"""息壤(记忆域)服务:提取管线、分级落位、确认流、正影、删除、检索与激活。
铁律(memory-model.md §5):只有归真结果(或用户手动入忆)能产生长期记忆候选——
微痕不自动升级;未归真旁枝自动入忆率必须为 0(测试钉死)。
"""
from __future__ import annotations
import asyncio
import json
import math
import re
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.kernel import Kernel
from app.models.mirror_tree import Canonization, TreeNode
from app.models.xirang import FactualRecord, Memory, MemoryTrigger
# 微痕默认半衰期(天);命中强化 +0.08、上限 3.0(借鉴 Memory-Palace 活力模型)
TRACE_HALF_LIFE_DAYS = 7.0
VITALITY_HIT_BONUS = 0.08
VITALITY_MAX = 3.0
# 召回预算:条数与字符上限
RECALL_LIMIT = 5
RECALL_CHAR_BUDGET = 1200
# 明忆白名单(PRD 三级记忆):低敏客观类自动生效带撤销窗;心痕等主观类入候选箱
EXPLICIT_AUTO_TYPES = ("persona", "instruction", "episodic")
# deny 两次退出检索
DENY_EXIT_THRESHOLD = 2
deny_counts: dict[str, int] = {}
# ── 归真触发:实录 + 提取 ──────────────────────────────────────────────
async def on_canonized(canonization_id: str) -> None:
"""归真后置(由 canonize 同步调用):实录落库 + 调度异步提取。"""
await record_factual(canonization_id)
asyncio.get_running_loop().create_task(_extract_guarded(canonization_id))
async def _extract_guarded(canonization_id: str) -> None:
try:
await _extract_after_canonized(canonization_id)
except Exception as exc: # noqa: BLE001 — 后台任务异常必须可见
from app.core.logging import log_with_context
log_with_context(f"息壤提取任务失败:{exc}", level="ERROR")
async def _extract_after_canonized(canonization_id: str) -> None:
async with AsyncSessionLocal() as session:
record = await session.get(Canonization, canonization_id)
if record is None:
return
path_ids = json.loads(record.path_nodes_json)
nodes = []
for nid in path_ids:
node = await session.get(TreeNode, nid)
if node is not None:
nodes.append(node)
transcript = "\n".join(
f"{'用户' if n.node_type == 'question' else '人物'}:{n.content}" for n in nodes
)
character_id = record.character_id
await _extract_candidates(
character_id=character_id, canonization_id=canonization_id, transcript=transcript
)
async def record_factual(canonization_id: str) -> None:
"""实录(同步,随归真事务后立即落库)。"""
async with AsyncSessionLocal() as session:
record = await session.get(Canonization, canonization_id)
if record is None:
return
path_nodes = []
for nid in json.loads(record.path_nodes_json):
node = await session.get(TreeNode, nid)
if node is not None:
path_nodes.append(node)
transcript = "\n".join(
f"{'用户' if n.node_type == 'question' else '人物'}:{n.content}" for n in path_nodes
)
summary = f"[{record.scope}] " + " / ".join(n.content[:40] for n in path_nodes[-2:])
session.add(
FactualRecord(
character_id=record.character_id,
canonization_id=canonization_id,
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
summary=summary,
)
)
character_id = record.character_id
scope = record.scope
await session.commit()
_ = scope
async def _extract_candidates(*, character_id: str, canonization_id: str, transcript: str) -> None:
"""提取管线:LLM 抽取 JSON 候选;解析失败回落为确定性单条摘要候选(不阻塞闭环)。"""
from app.shenshi_client.types import ShenshiChatRequest, ShenshiMessage
kernel: Kernel | None = _kernel_ref()
raw = ""
if kernel is not None:
request = ShenshiChatRequest(
messages=[
ShenshiMessage(
role="system",
content=(
"你是记忆抽取器。从对话实录中抽取长期记忆候选,"
'输出 JSON 数组 [{"content","type","priority","confidence","sensitive"}],'
'type ∈ persona/episodic/instruction/relationship_view;宁缺毋滥,无值得记忆的内容输出 []。'
),
),
ShenshiMessage(role="user", content=transcript[:8000]),
],
run_id=f"extract_{canonization_id}",
)
parts: list[str] = []
try:
async for delta in kernel.shenshi.stream_chat(request):
parts.append(delta.delta)
raw = "".join(parts)
except Exception: # noqa: BLE001 — 提取失败回落确定性路径
raw = ""
candidates = _parse_candidates(raw)
if not candidates:
candidates = [
{
"content": f"共同经历:{transcript[-200:]}",
"type": "episodic",
"priority": 50,
"confidence": 0.6,
"sensitive": False,
}
]
await place_candidates(
character_id=character_id,
candidates=candidates,
source_refs=[canonization_id],
source="self_inferred",
realm="reality",
)
def _parse_candidates(raw: str) -> list[dict]:
match = re.search(r"\[.*\]", raw, re.S)
if not match:
return []
try:
data = json.loads(match.group(0))
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
out: list[dict] = []
for item in data:
if isinstance(item, dict) and item.get("content"):
out.append(item)
return out
async def place_candidates(
*,
character_id: str,
candidates: list[dict],
source_refs: list[str],
source: str,
realm: str = "reality",
) -> list[Memory]:
"""分级落位 + 确定性去重(同类型同内容跳过)。"""
placed: list[Memory] = []
async with AsyncSessionLocal() as session:
existing = (
(
await session.execute(
select(Memory).where(
Memory.character_id == character_id, Memory.status.in_(("active", "pending"))
)
)
)
.scalars()
.all()
)
existing_norms = {_norm(m.content) for m in existing}
for cand in candidates:
content = str(cand.get("content", "")).strip()
if not content or _norm(content) in existing_norms:
continue
sensitive = bool(cand.get("sensitive"))
mtype = str(cand.get("type", "episodic"))
if sensitive:
tier, status = "sensitive", "pending" # 重忆必须确认(HJ-1201 管线侧)
elif mtype in EXPLICIT_AUTO_TYPES:
# 白名单明忆:低敏 persona/instruction/episodic 自动生效,撤销窗=可删除/可正影
tier, status = "explicit", "active"
else:
# 心痕(relationship_view,主观非事实)等入候选箱待审阅
tier, status = "explicit", "candidate"
memory = Memory(
character_id=character_id,
tier=tier,
type=mtype,
status=status,
source=source,
content=content,
source_refs_json=json.dumps(source_refs, ensure_ascii=False),
sensitivity="high" if sensitive else "low",
priority=int(cand.get("priority", 50)),
confidence=float(cand.get("confidence", 0.5)),
realm=realm,
)
session.add(memory)
await session.flush()
placed.append(memory)
for key in cand.get("keys", []) or []:
session.add(MemoryTrigger(memory_id=memory.id, kind="keyword", pattern=str(key)))
await session.commit()
for m in placed:
await one_qi.emit(
"memory.candidate_created",
character_id=character_id,
payload={"memory_id": m.id, "tier": m.tier, "status": m.status},
source="xirang",
)
return placed
def _norm(text: str) -> str:
return re.sub(r"\s+", "", text).lower()
def _kernel_ref() -> Kernel | None:
from app.main import app
return getattr(app.state, "kernel", None)
# ── 手动入忆 ──────────────────────────────────────────────
async def memorize_node(*, node_id: str, owner_scope: str, label: str) -> Memory:
"""手动入忆:须显式标注 fact/insight/opinion/hypothesis;未归真内容只作 fact_candidate。"""
from app.models.character import Character
if label not in ("fact", "insight", "opinion", "hypothesis"):
raise AppError("invalid_request", "手动入忆必须标注 fact/insight/opinion/hypothesis。")
async with AsyncSessionLocal() as session:
node = await session.get(TreeNode, node_id)
if node is None:
raise AppError("not_found", "节点不存在。")
canonized_node_ids: set[str] = set()
canons = (
await session.execute(select(Canonization).where(Canonization.tree_id == node.tree_id))
).scalars().all()
for canon in canons:
canonized_node_ids.update(json.loads(canon.path_nodes_json))
character = await session.get(Character, await _tree_character(session, node.tree_id))
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "节点不存在。")
character_id = character.id
on_canon = node.id in canonized_node_ids
memory = Memory(
character_id=character.id,
tier="explicit",
type="episodic" if (on_canon and label == "fact") else "fact_candidate",
status="active",
source="user_explicit",
content=f"[{label}] {node.content}",
source_refs_json=json.dumps([node_id], ensure_ascii=False),
sensitivity="low",
realm=node.realm,
)
session.add(memory)
await session.flush()
memory_id = memory.id
await session.commit()
await one_qi.emit(
"memory.candidate_created",
character_id=character_id,
payload={"memory_id": memory_id, "manual": True, "label": label},
source="xirang",
)
async with AsyncSessionLocal() as session:
return await session.get(Memory, memory_id) # type: ignore[return-value]
async def _tree_character(session: AsyncSession, tree_id: str) -> str:
from app.models.mirror_tree import MirrorTree
tree = await session.get(MirrorTree, tree_id)
return tree.character_id if tree else ""
# ── 确认流 / 正影 / 删除 ──────────────────────────────────────────────
async def _owned_memory(memory_id: str, owner_scope: str, *, session: AsyncSession) -> Memory:
from app.models.character import Character
memory = await session.get(Memory, memory_id)
if memory is None:
raise AppError("not_found", "记忆不存在。")
character = await session.get(Character, memory.character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "记忆不存在。")
return memory
async def confirm_memory(memory_id: str, owner_scope: str) -> Memory:
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
if memory.status not in ("pending", "candidate"):
raise AppError("HJ-1103", "只有待确认(pending)或候选箱(candidate)的记忆可确认。", status_code=409)
memory.status = "active"
view = _view(memory)
await session.commit()
await one_qi.emit("memory.confirmed", character_id=view["character_id"], payload={"memory_id": memory_id}, source="xirang")
async with AsyncSessionLocal() as session:
return await session.get(Memory, memory_id) # type: ignore[return-value]
async def deny_memory(memory_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
memory.status = "tombstoned"
await session.commit()
character_id = memory.character_id
deny_counts[memory_id] = deny_counts.get(memory_id, 0) + 1
exited = deny_counts[memory_id] >= DENY_EXIT_THRESHOLD
await one_qi.emit(
"memory.denied", character_id=character_id, payload={"memory_id": memory_id, "exited": exited}, source="xirang"
)
return {"memory_id": memory_id, "exited_retrieval": True}
async def correct_memory(memory_id: str, owner_scope: str, new_content: str) -> Memory:
"""正影:新版本指向旧版本;旧版 superseded 停止普通召回,内容不改写。"""
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
if memory.status == "tombstoned":
raise AppError("HJ-1202", "已删除内容不可正影。", status_code=409)
new_version = Memory(
character_id=memory.character_id,
tier=memory.tier,
type=memory.type,
status="active" if memory.status in ("active", "pending") else memory.status,
source=memory.source,
content=new_content,
source_refs_json=memory.source_refs_json,
sensitivity=memory.sensitivity,
priority=memory.priority,
realm=memory.realm,
prev_version_id=memory.id,
)
session.add(new_version)
memory.status = "superseded"
await session.flush()
new_id = new_version.id
await session.commit()
await one_qi.emit(
"memory.superseded",
character_id=None,
payload={"memory_id": memory_id, "new_version_id": new_id},
source="xirang",
)
async with AsyncSessionLocal() as session:
return await session.get(Memory, new_id) # type: ignore[return-value]
async def delete_memory(memory_id: str, owner_scope: str) -> None:
"""删除:墓碑化——内容不再出现在主存储查询/检索/上下文;墓碑不留原文。"""
async with AsyncSessionLocal() as session:
memory = await _owned_memory(memory_id, owner_scope, session=session)
character_id = memory.character_id
memory.status = "tombstoned"
memory.content = "" # 墓碑不承载被删原文(data-model §10)
await session.commit()
await one_qi.emit(
"memory.deleted", character_id=character_id, payload={"memory_id": memory_id}, source="xirang"
)
# ── 检索(双通道 + RRF 融合 + 预算)与激活 ──────────────────────────────────────────────
async def recall_for_generation(
character_id: str, query_text: str, *, realm: str = "reality", limit: int = RECALL_LIMIT
) -> list[Memory]:
"""获准长期记忆召回:关键词通道 + 活力时近通道 → RRF 融合 → 预算裁剪。
通道 A:关键词/触发词命中(content LIKE 或 trigger pattern 命中);
通道 B:活力值 × 时近衰减排序(标准档接入 pgvector 后升级为语义通道)。
过滤:active、界域匹配、非墓碑/被取代。
"""
async with AsyncSessionLocal() as session:
actives = (
(
await session.execute(
select(Memory).where(
Memory.character_id == character_id,
Memory.status == "active",
Memory.realm.in_(("reality", realm)),
)
)
)
.scalars()
.all()
)
if not actives:
return []
by_id = {m.id: m for m in actives}
now = utc_now()
def vitality_of(m: Memory) -> float:
age_days = max((now - _as_utc(m.created_at)).total_seconds() / 86400, 0)
return m.vitality * math.pow(2, -age_days / TRACE_HALF_LIFE_DAYS)
# 通道 A:关键词命中(含触发词)
keywords = {t.memory_id: t.pattern for t in await _triggers(set(by_id))}
channel_a: list[str] = []
text_norm = _norm(query_text)
for m in actives:
hit = _norm(m.content)[:24] in text_norm or any(
_norm(p) in text_norm for p in ([keywords.get(m.id)] if keywords.get(m.id) else [])
)
if hit or any(_norm(w) and _norm(w) in text_norm for w in _split_terms(m.content)):
channel_a.append(m.id)
# 通道 B:活力 × 时近
channel_b = sorted(by_id, key=lambda mid: vitality_of(by_id[mid]), reverse=True)
# RRF 融合
k = 60
scores: dict[str, float] = {}
for rank, mid in enumerate(channel_a):
scores[mid] = scores.get(mid, 0) + 1 / (k + rank + 1)
for rank, mid in enumerate(channel_b):
scores[mid] = scores.get(mid, 0) + 1 / (k + rank + 1)
ranked = sorted(scores, key=lambda mid: scores[mid], reverse=True)[:limit]
# 字符预算
picked: list[Memory] = []
used = 0
for mid in ranked:
m = by_id[mid]
if used + len(m.content) > RECALL_CHAR_BUDGET:
continue
picked.append(m)
used += len(m.content)
# 命中强化 + 召回统计
if picked:
async with AsyncSessionLocal() as session:
for m in picked:
row = await session.get(Memory, m.id)
row.vitality = min(row.vitality + VITALITY_HIT_BONUS, VITALITY_MAX)
row.recall_count += 1
row.last_recalled_at = utc_now()
await session.commit()
return picked
async def _triggers(memory_ids: set[str]) -> list[MemoryTrigger]:
if not memory_ids:
return []
async with AsyncSessionLocal() as session:
result = await session.execute(
select(MemoryTrigger).where(MemoryTrigger.memory_id.in_(tuple(memory_ids)))
)
return list(result.scalars().all())
def _split_terms(content: str) -> list[str]:
return [w for w in re.split(r"[,。!?、,.!?;;\s]+", content) if len(w) >= 2][:6]
def _as_utc(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
def _view(memory: Memory) -> dict:
return {
"id": memory.id,
"character_id": memory.character_id,
"tier": memory.tier,
"type": memory.type,
"status": memory.status,
"content": memory.content,
}
+5
View File
@@ -119,6 +119,11 @@ class Kernel:
assert created is not None
return created
@property
def shenshi(self) -> ShenshiClient:
"""神识客户端(息壤提取等功能任务的受控取用点)。"""
return self.loop._shenshi # noqa: SLF001
def _spawn(
self,
*,
+1 -3
View File
@@ -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",
-46
View File
@@ -1,46 +0,0 @@
"""记忆模型。"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_memory_id, new_memory_link_id
from app.core.time import utc_now
from app.db.base import Base
class Memory(Base):
__tablename__ = "memories"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
type: Mapped[str] = mapped_column(String(60), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str] = mapped_column(String(80), nullable=False, default="user_explicit")
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
importance: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
sensitivity: Mapped[str] = mapped_column(String(40), nullable=False, default="low")
evidence_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
temporal_context_json: Mapped[str] = mapped_column(
Text, nullable=False, default="{}", server_default="{}"
)
class MemoryLink(Base):
__tablename__ = "memory_links"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_link_id)
character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True)
source_memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
target_memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
relation: Mapped[str] = mapped_column(String(80), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+80
View File
@@ -0,0 +1,80 @@
"""息壤(记忆域)模型(data-model.md §3)。
- 三级:微痕 trace(短期)/ 明忆 explicit(低敏长期)/ 重忆 sensitive(须确认);
- 双源:user_explicit / self_inferred;
- 版本链:正影=新行 prev_version_id 指旧行,旧行 superseded 停止普通召回,内容不改写;
- 删除:tombstoned 墓碑不承载原文,主存储/检索/上下文全链路停用。
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_factual_record_id, new_memory_id, new_memory_trigger_id
from app.core.time import utc_now
from app.db.base import Base
class Memory(Base):
"""统一记忆表:三级 × 类型 × 状态 × 双源(契约词典 §10)。"""
__tablename__ = "memories"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_id)
character_id: Mapped[str] = mapped_column(String(80), index=True, nullable=False)
# trace / explicit / sensitive(§10.1)
tier: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
# persona / episodic / instruction / relationship_view / fact_candidate(§10.2)
type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
# candidate / pending / active / superseded / tombstoned(§10.3)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="candidate", index=True)
# user_explicit / self_inferred(§10.4)
source: Mapped[str] = mapped_column(String(20), nullable=False, default="self_inferred")
content: Mapped[str] = mapped_column(Text, nullable=False)
# 可解释性:指向镜树节点/事件(撤销与来源展开的依据)
source_refs_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
sensitivity: Mapped[str] = mapped_column(String(20), nullable=False, default="none")
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
# 活力值:2^(-Δdays/半衰期) 衰减、命中强化(§6.2)
vitality: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
# 微痕有效期;空表示不过期
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 记忆界域:境界记忆不进真界上下文(§5)
realm: Mapped[str] = mapped_column(String(40), nullable=False, default="reality")
prev_version_id: Mapped[str | None] = mapped_column(String(80), index=True)
# 召回统计(召回透明)
recall_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_recalled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class MemoryTrigger(Base):
"""记忆触发条件:keyword(微痕激活管线共用)与 disclosure(单一触发)。"""
__tablename__ = "memory_triggers"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_memory_trigger_id)
memory_id: Mapped[str] = mapped_column(String(80), ForeignKey("memories.id"), index=True)
# keyword / disclosure / vector(§10.5)
kind: Mapped[str] = mapped_column(String(20), nullable=False)
pattern: Mapped[str] = mapped_column(String(200), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class FactualRecord(Base):
"""实录(不可变):发生了什么 + 证据引用;与心痕(relationship_view)分离。"""
__tablename__ = "factual_records"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_factual_record_id)
character_id: Mapped[str] = mapped_column(String(80), index=True, nullable=False)
canonization_id: Mapped[str] = mapped_column(String(80), index=True)
# 证据:路径节点引用 + 摘要
evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
@@ -1 +0,0 @@
"""大周天记忆/世界抽取领域纯逻辑(造消息 / 解析记忆候选与世界实体草稿,禁止 IO)。"""
-140
View File
@@ -1,140 +0,0 @@
"""解析模型返回为记忆/世界抽取草稿(纯函数,无 IO)。
模型输出对象 ``{"memories": [...], "entities": [...]}``。缺省取**安全悲观默认**:
记忆 ``sensitivity=low``、世界 ``state_type=inference``(非 fact,召回降权,见
[世界模型设计] ../../docs/domain/core-domains.md §8)。
"""
import json
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class MemoryCandidateDraft:
type: str
title: str
content: str
confidence: float = 0.5
importance: float = 0.5
sensitivity: str = "low"
reason: str = ""
@dataclass
class WorldEntityDraft:
type: str
name: str
attributes: dict[str, Any] = field(default_factory=dict)
state_type: str = "inference"
confidence: float = 0.5
_MEMORY_TYPES = {
"user",
"preference",
"event",
"relationship",
"skill",
"goal",
"other",
}
_ENTITY_TYPES = {
"self",
"person",
"object",
"place",
"device",
"project",
"task",
"concept",
}
_STATE_TYPES = {"fact", "observation", "inference", "hypothesis", "stale"}
_SENSITIVITY = {"low", "medium", "high"}
def parse_extraction(
raw: str,
) -> tuple[list[MemoryCandidateDraft], list[WorldEntityDraft]]:
data = extract_json_object(raw)
if not isinstance(data, dict):
return [], []
return parse_memory_drafts(data.get("memories")), _parse_entities(data.get("entities"))
def parse_memory_drafts(value: Any) -> list[MemoryCandidateDraft]:
if not isinstance(value, list):
return []
result: list[MemoryCandidateDraft] = []
for item in value:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
content = str(item.get("content", "")).strip()
if not title or not content:
continue
result.append(
MemoryCandidateDraft(
type=_enum(item.get("type"), _MEMORY_TYPES, "other"),
title=title,
content=content,
confidence=_clamp(item.get("confidence"), 0.5),
importance=_clamp(item.get("importance"), 0.5),
sensitivity=_enum(item.get("sensitivity"), _SENSITIVITY, "low"),
reason=str(item.get("reason", "")).strip(),
)
)
return result
def _parse_entities(value: Any) -> list[WorldEntityDraft]:
if not isinstance(value, list):
return []
result: list[WorldEntityDraft] = []
for item in value:
if not isinstance(item, dict):
continue
name = str(item.get("name", "")).strip()
if not name:
continue
attrs = item.get("attributes")
result.append(
WorldEntityDraft(
type=_enum(item.get("type"), _ENTITY_TYPES, "concept"),
name=name,
attributes=attrs if isinstance(attrs, dict) else {},
state_type=_enum(item.get("state_type"), _STATE_TYPES, "inference"),
confidence=_clamp(item.get("confidence"), 0.5),
)
)
return result
def extract_json_object(raw: str) -> dict[str, Any]:
text = raw.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
start = text.find("{")
end = text.rfind("}")
if start != -1 and end > start:
text = text[start : end + 1]
try:
data = json.loads(text)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def _enum(value: Any, valid: set[str], default: str) -> str:
text = str(value or "").strip().lower()
return text if text in valid else default
def _clamp(value: Any, default: float) -> float:
try:
num = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(1.0, num))
-46
View File
@@ -1,46 +0,0 @@
"""大周天记忆/世界抽取 prompt 构建(纯函数,无 IO)。
复用 ``EvaluationSnapshot``(态势快照),产出 ``(system, user)`` 消息对,喂给
模型网关。模型返回的 JSON 对象由 ``extract`` 模块解析。
枚举取值以 [记忆系统设计](../../docs/domain/memory-model.md) §6、
[世界模型设计](../../docs/domain/core-domains.md) §8 与
[数据库 schema](../../docs/contracts/contract-glossary.md) §20 为准。
"""
from app.modules.big_cycle.prompts import EvaluationSnapshot
def build_extraction_messages(
snapshot: EvaluationSnapshot,
) -> tuple[str, str]:
system = (
"你是魂镜的大周天记忆/世界抽取器。基于本轮自我评估的态势快照与近期记忆,"
"抽取值得长期记住的记忆候选与世界实体候选(用户、项目、地点、对象等)。"
"只抽取有长期价值、非琐碎的信息;低置信度或推断信息标注为 inference 而非 fact,"
"不得把推断伪装成已确认事实。"
'以 JSON 对象返回:{"memories": [...], "entities": [...]}。'
'memories 每项:{"type": str(user/preference/event/relationship/skill/goal/fact/other), '
'"title": str, "content": str, "importance": 0~1, '
'"sensitivity": "low|medium|high", "confidence": 0~1, "reason": str}。'
'entities 每项:{"type": str(self/person/object/place/device/project/task/concept), '
'"name": str, "attributes": 对象, '
'"state_type": str(fact/observation/inference/hypothesis/stale), "confidence": 0~1}。'
'无值得抽取的内容时返回 {"memories": [], "entities": []}。'
"只返回 JSON 对象,不要解释或 markdown。"
)
user = (
f"记忆总数:{snapshot.memory_count}\n"
f"世界实体数:{snapshot.world_entity_count}\n"
f"自我评估结论:{snapshot.conclusion or '(无明显态势)'}\n"
f"当前人格摘要:\n{snapshot.personality_summary or '(尚未形成)'}\n"
f"近期记忆摘要:\n{_format_memories(snapshot.recent_memories)}"
)
return system, user
def _format_memories(memories: list[tuple[str, str]]) -> str:
if not memories:
return "(暂无)"
lines = [f"[{idx}] {title}:{content}" for idx, (title, content) in enumerate(memories)]
return "\n".join(lines)
Whitespace-only changes.
-91
View File
@@ -1,91 +0,0 @@
"""记忆上下文组装与检索结果数据结构(纯领域,无 IO)。
``MemoryRetrievalResult`` 持有已加载的 Memory ORM 实例(由 services/memory 检索后传入),
``build_memory_context`` 把它组装成注入上下文的文本。本模块只接收已加载数据、输出结构化对象。
"""
from __future__ import annotations
from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict
from app.core.time import utc_now
from app.models.memory import Memory
class MemoryRetrievalResult(BaseModel):
"""记忆检索结果(持有已加载的 Memory ORM 实例列表)。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
memories: list[Memory]
def _to_aware(dt: datetime | None) -> datetime | None:
"""确保 datetime 有时区信息,naive 视为 UTC。"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
def build_memory_context(result: MemoryRetrievalResult, *, max_length: int = 3000) -> str | None:
"""把检索到的记忆组装成注入上下文的文本;无记忆时返回 None。
按**重要性降序**排列,每条附带重要性标签和相对时间。
超过 ``max_length`` 字符时截断,避免上下文窗口膨胀。
"""
if not result.memories:
return None
now = utc_now()
def _mem_sort_key(m: Memory) -> float:
"""排序键:重要性(越高越优先),同时辅助时间衰减。"""
ca = _to_aware(m.created_at)
hours_ago = (now - ca).total_seconds() / 3600 if ca else 0
decay = max(0.5, 1.0 - hours_ago / 720) # 30 天内缓慢衰减
return (m.importance or 0.5) * decay
sorted_mems = sorted(result.memories, key=_mem_sort_key, reverse=True)
lines: list[str] = []
total_chars = 0
for idx, item in enumerate(sorted_mems):
importance_label = _importance_label(item.importance)
ca = _to_aware(item.created_at)
hours_ago = (now - ca).total_seconds() / 3600 if ca else 0
time_label = _time_ago_str(hours_ago)
line = f"- 「{item.title}」{time_label} [{importance_label}]\n {item.content}"
line_len = len(line) + 1 # +1 for newline
if total_chars + line_len > max_length:
remaining = len(sorted_mems) - idx
if remaining > 0:
lines.append(f"…以及 {remaining} 条其他记忆(已截断)")
break
lines.append(line)
total_chars += line_len
return "相关记忆:\n" + "\n".join(lines)
def _importance_label(importance: float) -> str:
if importance >= 0.8:
return "★重要"
if importance >= 0.5:
return "●一般"
return "○参考"
def _time_ago_str(hours_ago: float) -> str:
if hours_ago < 1:
return "刚刚"
if hours_ago < 24:
return f"{int(hours_ago)}小时前"
days = int(hours_ago / 24)
if days < 30:
return f"{days}天前"
months = int(days / 30)
return f"{months}个月前"
@@ -1 +0,0 @@
"""小周天每轮反思领域纯逻辑(造消息 / 解析记忆候选与反思,禁止 IO)。"""
-22
View File
@@ -1,22 +0,0 @@
"""解析模型返回为小周天记忆候选 + 反思(纯函数,无 IO)。
模型输出对象 ``{"memories": [...], "reflection": str}``。记忆候选解析复用 extraction
的 ``parse_memory_drafts``;``reflection`` 为本轮复盘文本。
"""
from app.modules.extraction.extract import (
MemoryCandidateDraft,
extract_json_object,
parse_memory_drafts,
)
def parse_reflection(
raw: str,
) -> tuple[list[MemoryCandidateDraft], str]:
data = extract_json_object(raw)
if not isinstance(data, dict):
return [], ""
memories = parse_memory_drafts(data.get("memories"))
reflection = str(data.get("reflection", "")).strip()
return memories, reflection
-22
View File
@@ -1,22 +0,0 @@
"""小周天每轮记忆候选 + 反思 prompt 构建(纯函数,无 IO)。"""
def build_reflection_messages(
user_content: str,
assistant_content: str,
) -> tuple[str, str]:
system = (
"你是魂镜的小周天反思器。基于本轮对话(用户消息 + 你的回复),抽取值得长期记住的"
"记忆候选(用户偏好 / 事件 / 事实等),并生成一条简短的本轮反思"
"(做得好 / 不好 / 可改进)。"
"只抽取有长期价值、非琐碎的信息;反思是系统内部复盘,不超过两句。"
'以 JSON 对象返回:{"memories": [{"type": str'
"(user/preference/event/relationship/skill/goal/fact/other), "
'"title": str, "content": str, "importance": 0~1, '
'"sensitivity": "low|medium|high", "confidence": 0~1, "reason": str}], '
'"reflection": str}。'
"无值得记忆的内容时 memories 为空数组;reflection 仍需给出。"
"只返回 JSON 对象,不要解释或 markdown。"
)
user = f"用户消息:\n{user_content}\n\n你的回复:\n{assistant_content}"
return system, user
+2 -2
View File
@@ -4,7 +4,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from 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)
-73
View File
@@ -1,73 +0,0 @@
"""记忆数据访问。"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.memory import Memory
async def get_memory(session: AsyncSession, memory_id: str) -> Memory | None:
return await session.get(Memory, memory_id)
async def list_memories(
session: AsyncSession,
character_id: str,
*,
memory_type: str | None = None,
source: str | None = None,
page: int,
page_size: int,
active_only: bool = True,
) -> tuple[list[Memory], int]:
conditions = [Memory.character_id == character_id]
if active_only:
conditions.append(Memory.status == "active")
conditions.append(Memory.deleted_at.is_(None))
if memory_type is not None:
conditions.append(Memory.type == memory_type)
if source is not None:
conditions.append(Memory.source == source)
total_result = await session.execute(
select(func.count()).select_from(Memory).where(*conditions)
)
total = int(total_result.scalar_one())
result = await session.execute(
select(Memory)
.where(*conditions)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
async def list_injectable_memories(
session: AsyncSession, character_id: str, *, limit: int
) -> list[Memory]:
result = await session.execute(
select(Memory)
.where(
Memory.character_id == character_id,
Memory.status == "active",
Memory.deleted_at.is_(None),
)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def get_memories_by_ids(
session: AsyncSession, memory_ids: list[str]
) -> list[Memory]:
"""按 ID 列表批量获取记忆(按重要性降序排序,而非 ID 传入顺序)。"""
result = await session.execute(
select(Memory).where(
Memory.id.in_(memory_ids),
Memory.status == "active",
Memory.deleted_at.is_(None),
)
.order_by(Memory.importance.desc(), Memory.updated_at.desc())
)
return list(result.scalars().all())
-35
View File
@@ -1,35 +0,0 @@
"""演化候选 API schema(契约字典 §12;api-spec 演化候选审核)。"""
from datetime import datetime
from typing import Any
from pydantic import Field
from app.schemas.common import OrmModel
class EvolutionCandidateResponse(OrmModel):
"""演化候选响应(delta/evidence_ids 已从 JSON 文本解析)。"""
id: str = Field(description="演化候选 ID(evolution_ 前缀)")
character_id: str
cycle_id: str = Field(description="触发本次的大周天循环 ID")
type: str = Field(description="候选类型,取值见契约字典 §12.1")
source: str = Field(description="来源,本期 big_cycle")
level: str = Field(description="影响层级 L0-L4")
scope: str = Field(description="作用域,取值见契约字典 §12.3")
title: str
proposal: str
delta: dict[str, Any] = Field(default_factory=dict, description="演化增量")
evidence_ids: list[str] = Field(default_factory=list)
confidence: float
benefit_score: float
risk_score: float
drift_score: float
auto_apply_allowed: bool
status: str
resulting_target_id: str | None = None
error_message: str | None = None
reviewed_at: datetime | None = None
reviewed_by: str
created_at: datetime
-109
View File
@@ -1,109 +0,0 @@
"""记忆 API schema。"""
from pydantic import BaseModel, ConfigDict, Field
class MemoryCreateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"type": "user",
"title": "回答偏好",
"content": "用户喜欢简洁、直接、带验证结果的中文回答。",
"source": "user_explicit",
"confidence": 1.0,
"importance": 0.8,
"sensitivity": "low",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
}
]
}
)
type: str = Field(
default="user", max_length=60, description="记忆类型,取值见契约字典 memory.type。"
)
title: str = Field(max_length=200, description="记忆标题。")
content: str = Field(min_length=1, description="记忆正文。")
source: str = Field(
default="user_explicit", description="记忆来源,取值见契约字典 memory.source。"
)
confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="置信度,范围 0.0–1.0。")
importance: float = Field(default=0.5, ge=0.0, le=1.0, description="重要性,范围 0.0–1.0。")
sensitivity: str = Field(default="low", description="敏感等级:none / low / medium / high。")
evidence_ids: list[str] = Field(default_factory=list, description="证据 ID 列表。")
character_id: str | None = Field(default=None, description="人物 ID;为空时使用当前激活人物。")
class MemoryUpdateRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"title": "回答偏好(已确认)",
"importance": 0.9,
"status": "active",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
}
]
}
)
type: str | None = Field(default=None, max_length=60, description="记忆类型。")
title: str | None = Field(default=None, max_length=200, description="记忆标题。")
content: str | None = Field(default=None, description="记忆正文。")
confidence: float | None = Field(
default=None, ge=0.0, le=1.0, description="置信度,范围 0.0–1.0。"
)
importance: float | None = Field(
default=None, ge=0.0, le=1.0, description="重要性,范围 0.0–1.0。"
)
sensitivity: str | None = Field(default=None, description="敏感等级。")
status: str | None = Field(
default=None, description="记忆状态:active / archived / deleted / superseded。"
)
evidence_ids: list[str] | None = Field(default=None, description="证据 ID 列表。")
class MemoryResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"id": "memory_4d9c6e3b18bb4ef5a6b8ed2e902d1f1a",
"character_id": "character_ec83d55746c54e47ab6f98d683032626",
"type": "user",
"title": "回答偏好",
"content": "用户喜欢简洁、直接、带验证结果的中文回答。",
"source": "user_explicit",
"confidence": 1.0,
"importance": 0.8,
"sensitivity": "low",
"evidence_ids": ["message_462927da6f0e4af780dd28fd58f73314"],
"status": "active",
"created_at": "2026-06-19T09:12:00.000000Z",
"updated_at": "2026-06-19T09:12:00.000000Z",
"deleted_at": None,
"expires_at": None,
}
]
}
)
id: str = Field(description="记忆 ID,前缀为 memory_。")
character_id: str = Field(description="所属人物 ID。")
type: str = Field(description="记忆类型。")
title: str = Field(description="记忆标题。")
content: str = Field(description="记忆正文。")
source: str = Field(description="记忆来源。")
confidence: float = Field(description="置信度。")
importance: float = Field(description="重要性。")
sensitivity: str = Field(description="敏感等级。")
evidence_ids: list[str] = Field(description="证据 ID 列表。")
status: str = Field(description="记忆状态。")
created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。")
updated_at: str = Field(description="更新时间,UTC ISO 8601 字符串。")
deleted_at: str | None = Field(description="删除时间;未删除时为空。")
expires_at: str | None = Field(description="过期时间;永不过期时为空。")
+2 -2
View File
@@ -21,7 +21,7 @@ from app.models.governance import (
WorldEntity,
)
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
-205
View File
@@ -1,205 +0,0 @@
"""演化候选派生与审核服务。
applier 注册表按 ``type`` 派生到下游(本期 personality→人格版本、procedural→
程序记忆,其余返回 None 降级)。审核闭环 approve/reject 复用派生能力,自管
session 并记审计(见 [大周天演化候选落地设计]
../docs/domain/core-domains.md §6-§7)。
"""
from collections.abc import Awaitable, Callable
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.repositories import evolution as evolution_repo
from app.repositories import personality as personality_repo
from app.repositories.json_utils import loads_dict, loads_list
from app.schemas.common import Page
from app.schemas.evolution import EvolutionCandidateResponse
from app.services.audit import record_audit_event
from app.services.personality import derive_personality_version
Applier = Callable[..., Awaitable[str]]
async def apply_evolution_candidate(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str = "system"
) -> str | None:
"""应用演化候选到下游;返回派生资源 ID,无 applier 时返回 None(降级)。"""
applier = _APPLIERS.get(candidate.type)
if applier is None:
return None
return await applier(session, candidate, actor=actor)
async def _apply_personality(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
) -> str:
base = await personality_repo.get_latest_personality(session, candidate.character_id)
if base is None:
raise AppError("invalid_request", "人物尚无人格版本,无法应用演化候选。")
overrides = loads_dict(candidate.delta_json)
new_version = derive_personality_version(
base, overrides, reason=f"evolution:{candidate.id}", created_by="big_cycle"
)
session.add(new_version)
await session.flush()
character = await session.get(Character, candidate.character_id)
if character is None:
raise AppError("not_found", "人物不存在。")
character.current_personality_version_id = new_version.id
await _record_apply(
session, candidate, f"type=personality version={new_version.id}", actor=actor
)
return new_version.id
async def _apply_procedural(
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
) -> str:
delta = loads_dict(candidate.delta_json)
memory = Memory(
character_id=candidate.character_id,
type="procedural",
title=str(delta.get("title") or candidate.title),
content=str(delta.get("content") or candidate.proposal),
source="big_cycle",
importance=candidate.confidence,
)
session.add(memory)
await session.flush()
await _record_apply(session, candidate, f"type=procedural memory={memory.id}", actor=actor)
return memory.id
async def list_evolution_candidates(
*,
character_id: str,
status: str | None = None,
candidate_type: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[EvolutionCandidateResponse]:
async with AsyncSessionLocal() as session:
items, total = await evolution_repo.list_candidates(
session,
character_id,
status=status,
candidate_type=candidate_type,
page=page,
page_size=page_size,
)
return Page(
items=[_to_response(item) for item in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_evolution_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await evolution_repo.get_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "演化候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
target_id = await apply_evolution_candidate(session, candidate, actor="user")
if target_id is None:
raise AppError("not_implemented", "该类型派生待后续阶段。")
candidate.status = "applied"
candidate.resulting_target_id = target_id
candidate.reviewed_at = utc_now()
candidate.reviewed_by = "user"
await session.commit()
await session.refresh(candidate)
return _to_response(candidate)
async def reject_evolution_candidate(
candidate_id: str,
) -> EvolutionCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await evolution_repo.get_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "演化候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
candidate.status = "rejected"
candidate.reviewed_at = utc_now()
candidate.reviewed_by = "user"
await record_audit_event(
session,
action="evolution.candidate.rejected",
actor="user",
risk_level=candidate.level,
decision="denied",
character_id=candidate.character_id,
input_summary=candidate.proposal[:200],
resource_type="evolution_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return _to_response(candidate)
def _to_response(candidate: EvolutionCandidate) -> EvolutionCandidateResponse:
return EvolutionCandidateResponse(
id=candidate.id,
character_id=candidate.character_id,
cycle_id=candidate.cycle_id,
type=candidate.type,
source=candidate.source,
level=candidate.level,
scope=candidate.scope,
title=candidate.title,
proposal=candidate.proposal,
delta=loads_dict(candidate.delta_json),
evidence_ids=loads_list(candidate.evidence_ids_json),
confidence=candidate.confidence,
benefit_score=candidate.benefit_score,
risk_score=candidate.risk_score,
drift_score=candidate.drift_score,
auto_apply_allowed=candidate.auto_apply_allowed,
status=candidate.status,
resulting_target_id=candidate.resulting_target_id,
error_message=candidate.error_message,
reviewed_at=candidate.reviewed_at,
reviewed_by=candidate.reviewed_by,
created_at=candidate.created_at,
)
async def _record_apply(
session: AsyncSession,
candidate: EvolutionCandidate,
output_summary: str,
*,
actor: str,
) -> None:
await record_audit_event(
session,
action="evolution.candidate.applied",
actor=actor,
risk_level=candidate.level,
decision="auto",
character_id=candidate.character_id,
input_summary=candidate.proposal[:200],
output_summary=output_summary,
resource_type="evolution_candidate",
resource_id=candidate.id,
)
_APPLIERS: dict[str, Applier] = {
"personality": _apply_personality,
"procedural": _apply_procedural,
}
-164
View File
@@ -1,164 +0,0 @@
"""大周天抽取候选审核服务(记忆候选 + 世界实体候选)。
记忆候选 approve 派生为 ``memories``(source=big_cycle);世界实体候选 approve
→ confirmed 进入世界模型。审核仅 pending 可操作(见 [大周天记忆/世界抽取落地设计]
../docs/domain/core-domains.md §5)。
"""
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import candidates as candidates_repo
from app.repositories import world as world_repo
from app.schemas.candidates import MemoryCandidateResponse
from app.schemas.common import Page
from app.schemas.world import WorldEntityResponse
from app.services.audit import record_audit_event
from app.services.serializers import (
character_world_entity_to_response,
memory_candidate_to_response,
)
async def list_memory_proposals(
*,
character_id: str,
source: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[MemoryCandidateResponse]:
async with AsyncSessionLocal() as session:
all_items = await candidates_repo.list_memory_candidates(
session, character_id, source=source, status=status
)
start = max(page - 1, 0) * page_size
items = all_items[start : start + page_size]
return Page(
items=[memory_candidate_to_response(i) for i in items],
page=page,
page_size=page_size,
total=len(all_items),
)
async def approve_memory_proposal(
candidate_id: str,
) -> MemoryCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "记忆候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
session.add(
Memory(
character_id=candidate.character_id,
type=candidate.type,
title=candidate.title,
content=candidate.content,
source="big_cycle",
confidence=candidate.confidence,
importance=candidate.importance,
sensitivity=candidate.sensitivity,
evidence_ids_json=candidate.evidence_ids_json,
status="active",
)
)
candidate.status = "applied"
candidate.reviewed_at = utc_now()
await record_audit_event(
session,
action="extraction.memory.applied",
actor="user",
character_id=candidate.character_id,
input_summary=candidate.title[:200],
resource_type="memory_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return memory_candidate_to_response(candidate)
async def reject_memory_proposal(
candidate_id: str,
) -> MemoryCandidateResponse:
return await _reject_memory(candidate_id)
async def list_world_entities_pending(
*,
character_id: str,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> Page[WorldEntityResponse]:
async with AsyncSessionLocal() as session:
items, total = await world_repo.list_entities(
session, character_id, status=status, page=page, page_size=page_size
)
return Page(
items=[character_world_entity_to_response(i) for i in items],
page=page,
page_size=page_size,
total=total,
)
async def approve_world_entity(entity_id: str) -> WorldEntityResponse:
return await _review_world_entity(entity_id, "confirmed", decision="auto")
async def reject_world_entity(entity_id: str) -> WorldEntityResponse:
return await _review_world_entity(entity_id, "rejected", decision="denied")
async def _reject_memory(candidate_id: str) -> MemoryCandidateResponse:
async with AsyncSessionLocal() as session:
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
if candidate is None:
raise AppError("not_found", "记忆候选不存在。")
if candidate.status != "pending":
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
candidate.status = "rejected"
candidate.reviewed_at = utc_now()
await record_audit_event(
session,
action="extraction.memory.rejected",
actor="user",
decision="denied",
character_id=candidate.character_id,
input_summary=candidate.title[:200],
resource_type="memory_candidate",
resource_id=candidate.id,
)
await session.commit()
await session.refresh(candidate)
return memory_candidate_to_response(candidate)
async def _review_world_entity(
entity_id: str, target_status: str, *, decision: str
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
entity = await world_repo.get_entity(session, entity_id)
if entity is None:
raise AppError("not_found", "世界实体不存在。")
if entity.status != "pending":
raise AppError("conflict", f"实体当前状态为 {entity.status},不可审核。")
entity.status = target_status
await record_audit_event(
session,
action=f"extraction.world.{target_status}",
actor="user",
decision=decision,
character_id=entity.character_id,
input_summary=entity.name[:200],
resource_type="character_world_entity",
resource_id=entity.id,
)
await session.commit()
await session.refresh(entity)
return character_world_entity_to_response(entity)
+5 -22
View File
@@ -15,7 +15,6 @@ from app.core.errors import AppError
from app.core.time import utc_now
from app.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,
-304
View File
@@ -1,304 +0,0 @@
"""记忆服务。
集成 ChromaDB 向量存储:
- create_memory / 大/小周天自动创建的 Memory 写入后异步生成 embedding 存向量
- retrieve_memories 支持向量相似度检索,失败时回退 SQL 排序检索
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import AppError
from app.core.logging import logger
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.modules.memory.context import MemoryRetrievalResult
from app.repositories import memory as memory_repo
from app.repositories.json_utils import dumps
from app.schemas.common import Page
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
from app.services.audit import record_audit_event
from app.services.characters import get_active_character_model
from app.services.serializers import memory_to_response
def _get_embedding_service(manager):
"""尝试创建 EmbeddingService,失败时返回 None。"""
try:
models = manager.model_registry.list_by_capability("embedding")
if not models:
return None
from app.ai_provider.embedding import EmbeddingService
return EmbeddingService(manager)
except Exception: # noqa: BLE001
return None
async def _embed_and_store(memory: Memory) -> None:
"""异步后台任务:为记忆生成 embedding 并存入向量库。
embedding 服务和 ChromaDB 均不可用时静默跳过——不影响主流程。
"""
text = memory.content or memory.title or ""
if not text:
return
try:
from app.ai_provider import ProviderManagerFactory
manager = ProviderManagerFactory.create()
service = _get_embedding_service(manager)
if service is None:
return
import asyncio as _asyncio
response = await _asyncio.wait_for(
service.embed(text),
timeout=10.0,
)
if not response.data or not response.data[0].embedding:
return
embedding = response.data[0].embedding
from app.integrations.vector_store import get_vector_store
store = get_vector_store()
if store is None:
return
store.add_memory(
character_id=memory.character_id,
memory_id=memory.id,
text=text,
embedding=embedding,
metadata={
"type": memory.type,
"source": memory.source,
"title": memory.title or "",
},
)
except Exception: # noqa: BLE001
logger.warning(
"memory_embed_failed",
memory_id=memory.id,
character_id=memory.character_id,
)
async def embed_memory_async(memory: Memory) -> None:
"""公开接口:为单条记忆生成 embedding 并存入向量库(供大/小周天后台任务调用)。"""
await _embed_and_store(memory)
# ── CRUD ──────────────────────────────────────────────────────────────────
async def list_memory_page(
*,
page: int = 1,
page_size: int = 20,
memory_type: str | None = None,
source: str | None = None,
owner_scope: str = settings.default_owner_scope,
) -> Page[MemoryResponse]:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
safe_page_size = min(max(page_size, 1), 100)
safe_page = max(page, 1)
items, total = await memory_repo.list_memories(
session,
character.id,
memory_type=memory_type,
source=source,
page=safe_page,
page_size=safe_page_size,
)
return Page(
items=[memory_to_response(item) for item in items],
page=safe_page,
page_size=safe_page_size,
total=total,
)
async def create_memory(
session: AsyncSession,
payload: MemoryCreateRequest,
*,
owner_scope: str = settings.default_owner_scope,
) -> MemoryResponse:
character = await get_active_character_model(session, owner_scope)
character_id = payload.character_id or character.id
if character_id != character.id:
raise AppError("not_found", "人物不存在。")
memory = Memory(
character_id=character_id,
type=payload.type,
title=payload.title,
content=payload.content,
source=payload.source,
confidence=payload.confidence,
importance=payload.importance,
sensitivity=payload.sensitivity,
evidence_ids_json=dumps(payload.evidence_ids),
status="active",
)
session.add(memory)
await session.flush()
# 异步 embedding
if settings.vector_db_enabled:
import asyncio
asyncio.ensure_future(_embed_and_store(memory))
await record_audit_event(
session,
action="memory.created",
character_id=character_id,
actor="user",
decision="auto",
input_summary=payload.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def update_memory(
memory_id: str, payload: MemoryUpdateRequest, *, owner_scope: str = settings.default_owner_scope
) -> MemoryResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
memory = await memory_repo.get_memory(session, memory_id)
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
raise AppError("not_found", "记忆不存在。")
updates = payload.model_dump(exclude_unset=True)
evidence_ids = updates.pop("evidence_ids", None)
for key, value in updates.items():
setattr(memory, key, value)
if evidence_ids is not None:
memory.evidence_ids_json = dumps(evidence_ids)
memory.updated_at = utc_now()
# 如果内容变了,异步更新向量
if ("content" in updates or "title" in updates) and settings.vector_db_enabled:
import asyncio
asyncio.ensure_future(_embed_and_store(memory))
await record_audit_event(
session,
action="memory.updated",
character_id=character.id,
actor="user",
decision="auto",
input_summary=memory.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def delete_memory(
memory_id: str, *, owner_scope: str = settings.default_owner_scope
) -> MemoryResponse:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session, owner_scope)
memory = await memory_repo.get_memory(session, memory_id)
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
raise AppError("not_found", "记忆不存在。")
memory.status = "deleted"
memory.deleted_at = utc_now()
memory.updated_at = utc_now()
# 同步删除向量
if settings.vector_db_enabled:
try:
from app.integrations.vector_store import get_vector_store
store = get_vector_store()
if store is not None:
store.delete_memory(character.id, memory_id)
except Exception: # noqa: BLE001
pass
await record_audit_event(
session,
action="memory.deleted",
character_id=character.id,
actor="user",
decision="auto",
input_summary=memory.title,
resource_type="memory",
resource_id=memory.id,
)
await session.commit()
await session.refresh(memory)
return memory_to_response(memory)
async def memory_context(
session: AsyncSession,
character_id: str,
*,
limit: int,
_owner_scope: str = settings.default_owner_scope,
) -> str:
"""获取记忆上下文文本(供叩心枢开场、辰正等场景使用)。"""
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
if not memories:
return ""
lines = [f"- {item.title}: {item.content}" for item in memories]
return "相关记忆:\n" + "\n".join(lines)
async def retrieve_memories(
session: AsyncSession,
character_id: str,
*,
limit: int,
_owner_scope: str = settings.default_owner_scope,
) -> MemoryRetrievalResult:
"""检索可注入上下文的相关记忆。
优先尝试向量相似度检索,失败时回退 SQL 排序检索。
"""
# 尝试向量检索(5s 超时,避免 embedding endpoint 不可达时阻塞消息流)
if settings.vector_db_enabled:
try:
from app.ai_provider import ProviderManagerFactory
manager = ProviderManagerFactory.create()
models = manager.model_registry.list_by_capability("embedding")
if models:
import asyncio as _asyncio
from app.ai_provider.embedding import EmbeddingService
from app.integrations.vector_store import get_vector_store
query_text = f"检索 {character_id} 的最新相关记忆"
service = EmbeddingService(manager)
response = await _asyncio.wait_for(
service.embed(query_text),
timeout=5.0,
)
if response.data and response.data[0].embedding:
store = get_vector_store()
if store is not None:
vector_results = store.search(
character_id,
response.data[0].embedding,
limit=limit,
)
if vector_results:
ids = [r["id"] for r in vector_results]
memories = await memory_repo.get_memories_by_ids(session, ids)
if memories:
return MemoryRetrievalResult(memories=memories)
except Exception: # noqa: BLE001
logger.warning("vector_retrieval_failed,回退 SQL 检索")
# 回退 SQL
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
return MemoryRetrievalResult(memories=memories)
+12 -40
View File
@@ -16,7 +16,6 @@ from app.models.governance import (
WorldEntity,
)
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)