M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)
This commit is contained in:
1 parent
2a640a901b
commit
9c98c2f85e
69 files changed
+1193
-6033
No files matched your search
@@ -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
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
"""演化候选派生与审核服务。
|
||||
|
||||
applier 注册表按 ``type`` 派生到下游(本期 personality→人格版本、procedural→
|
||||
程序记忆,其余返回 None 降级)。审核闭环 approve/reject 复用派生能力,自管
|
||||
session 并记审计(见 [大周天演化候选落地设计]
|
||||
../docs/domain/core-domains.md §6-§7)。
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import evolution as evolution_repo
|
||||
from app.repositories import personality as personality_repo
|
||||
from app.repositories.json_utils import loads_dict, loads_list
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.evolution import EvolutionCandidateResponse
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.personality import derive_personality_version
|
||||
|
||||
Applier = Callable[..., Awaitable[str]]
|
||||
|
||||
|
||||
async def apply_evolution_candidate(
|
||||
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str = "system"
|
||||
) -> str | None:
|
||||
"""应用演化候选到下游;返回派生资源 ID,无 applier 时返回 None(降级)。"""
|
||||
applier = _APPLIERS.get(candidate.type)
|
||||
if applier is None:
|
||||
return None
|
||||
return await applier(session, candidate, actor=actor)
|
||||
|
||||
|
||||
async def _apply_personality(
|
||||
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
|
||||
) -> str:
|
||||
base = await personality_repo.get_latest_personality(session, candidate.character_id)
|
||||
if base is None:
|
||||
raise AppError("invalid_request", "人物尚无人格版本,无法应用演化候选。")
|
||||
overrides = loads_dict(candidate.delta_json)
|
||||
new_version = derive_personality_version(
|
||||
base, overrides, reason=f"evolution:{candidate.id}", created_by="big_cycle"
|
||||
)
|
||||
session.add(new_version)
|
||||
await session.flush()
|
||||
character = await session.get(Character, candidate.character_id)
|
||||
if character is None:
|
||||
raise AppError("not_found", "人物不存在。")
|
||||
character.current_personality_version_id = new_version.id
|
||||
await _record_apply(
|
||||
session, candidate, f"type=personality version={new_version.id}", actor=actor
|
||||
)
|
||||
return new_version.id
|
||||
|
||||
|
||||
async def _apply_procedural(
|
||||
session: AsyncSession, candidate: EvolutionCandidate, *, actor: str
|
||||
) -> str:
|
||||
delta = loads_dict(candidate.delta_json)
|
||||
memory = Memory(
|
||||
character_id=candidate.character_id,
|
||||
type="procedural",
|
||||
title=str(delta.get("title") or candidate.title),
|
||||
content=str(delta.get("content") or candidate.proposal),
|
||||
source="big_cycle",
|
||||
importance=candidate.confidence,
|
||||
)
|
||||
session.add(memory)
|
||||
await session.flush()
|
||||
await _record_apply(session, candidate, f"type=procedural memory={memory.id}", actor=actor)
|
||||
return memory.id
|
||||
|
||||
|
||||
async def list_evolution_candidates(
|
||||
*,
|
||||
character_id: str,
|
||||
status: str | None = None,
|
||||
candidate_type: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Page[EvolutionCandidateResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
items, total = await evolution_repo.list_candidates(
|
||||
session,
|
||||
character_id,
|
||||
status=status,
|
||||
candidate_type=candidate_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return Page(
|
||||
items=[_to_response(item) for item in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def approve_evolution_candidate(
|
||||
candidate_id: str,
|
||||
) -> EvolutionCandidateResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
candidate = await evolution_repo.get_candidate(session, candidate_id)
|
||||
if candidate is None:
|
||||
raise AppError("not_found", "演化候选不存在。")
|
||||
if candidate.status != "pending":
|
||||
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
|
||||
target_id = await apply_evolution_candidate(session, candidate, actor="user")
|
||||
if target_id is None:
|
||||
raise AppError("not_implemented", "该类型派生待后续阶段。")
|
||||
candidate.status = "applied"
|
||||
candidate.resulting_target_id = target_id
|
||||
candidate.reviewed_at = utc_now()
|
||||
candidate.reviewed_by = "user"
|
||||
await session.commit()
|
||||
await session.refresh(candidate)
|
||||
return _to_response(candidate)
|
||||
|
||||
|
||||
async def reject_evolution_candidate(
|
||||
candidate_id: str,
|
||||
) -> EvolutionCandidateResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
candidate = await evolution_repo.get_candidate(session, candidate_id)
|
||||
if candidate is None:
|
||||
raise AppError("not_found", "演化候选不存在。")
|
||||
if candidate.status != "pending":
|
||||
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
|
||||
candidate.status = "rejected"
|
||||
candidate.reviewed_at = utc_now()
|
||||
candidate.reviewed_by = "user"
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="evolution.candidate.rejected",
|
||||
actor="user",
|
||||
risk_level=candidate.level,
|
||||
decision="denied",
|
||||
character_id=candidate.character_id,
|
||||
input_summary=candidate.proposal[:200],
|
||||
resource_type="evolution_candidate",
|
||||
resource_id=candidate.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(candidate)
|
||||
return _to_response(candidate)
|
||||
|
||||
|
||||
def _to_response(candidate: EvolutionCandidate) -> EvolutionCandidateResponse:
|
||||
return EvolutionCandidateResponse(
|
||||
id=candidate.id,
|
||||
character_id=candidate.character_id,
|
||||
cycle_id=candidate.cycle_id,
|
||||
type=candidate.type,
|
||||
source=candidate.source,
|
||||
level=candidate.level,
|
||||
scope=candidate.scope,
|
||||
title=candidate.title,
|
||||
proposal=candidate.proposal,
|
||||
delta=loads_dict(candidate.delta_json),
|
||||
evidence_ids=loads_list(candidate.evidence_ids_json),
|
||||
confidence=candidate.confidence,
|
||||
benefit_score=candidate.benefit_score,
|
||||
risk_score=candidate.risk_score,
|
||||
drift_score=candidate.drift_score,
|
||||
auto_apply_allowed=candidate.auto_apply_allowed,
|
||||
status=candidate.status,
|
||||
resulting_target_id=candidate.resulting_target_id,
|
||||
error_message=candidate.error_message,
|
||||
reviewed_at=candidate.reviewed_at,
|
||||
reviewed_by=candidate.reviewed_by,
|
||||
created_at=candidate.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _record_apply(
|
||||
session: AsyncSession,
|
||||
candidate: EvolutionCandidate,
|
||||
output_summary: str,
|
||||
*,
|
||||
actor: str,
|
||||
) -> None:
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="evolution.candidate.applied",
|
||||
actor=actor,
|
||||
risk_level=candidate.level,
|
||||
decision="auto",
|
||||
character_id=candidate.character_id,
|
||||
input_summary=candidate.proposal[:200],
|
||||
output_summary=output_summary,
|
||||
resource_type="evolution_candidate",
|
||||
resource_id=candidate.id,
|
||||
)
|
||||
|
||||
|
||||
_APPLIERS: dict[str, Applier] = {
|
||||
"personality": _apply_personality,
|
||||
"procedural": _apply_procedural,
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
"""大周天抽取候选审核服务(记忆候选 + 世界实体候选)。
|
||||
|
||||
记忆候选 approve 派生为 ``memories``(source=big_cycle);世界实体候选 approve
|
||||
→ confirmed 进入世界模型。审核仅 pending 可操作(见 [大周天记忆/世界抽取落地设计]
|
||||
../docs/domain/core-domains.md §5)。
|
||||
"""
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import candidates as candidates_repo
|
||||
from app.repositories import world as world_repo
|
||||
from app.schemas.candidates import MemoryCandidateResponse
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.world import WorldEntityResponse
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.serializers import (
|
||||
character_world_entity_to_response,
|
||||
memory_candidate_to_response,
|
||||
)
|
||||
|
||||
|
||||
async def list_memory_proposals(
|
||||
*,
|
||||
character_id: str,
|
||||
source: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Page[MemoryCandidateResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
all_items = await candidates_repo.list_memory_candidates(
|
||||
session, character_id, source=source, status=status
|
||||
)
|
||||
start = max(page - 1, 0) * page_size
|
||||
items = all_items[start : start + page_size]
|
||||
return Page(
|
||||
items=[memory_candidate_to_response(i) for i in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=len(all_items),
|
||||
)
|
||||
|
||||
|
||||
async def approve_memory_proposal(
|
||||
candidate_id: str,
|
||||
) -> MemoryCandidateResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
|
||||
if candidate is None:
|
||||
raise AppError("not_found", "记忆候选不存在。")
|
||||
if candidate.status != "pending":
|
||||
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=candidate.character_id,
|
||||
type=candidate.type,
|
||||
title=candidate.title,
|
||||
content=candidate.content,
|
||||
source="big_cycle",
|
||||
confidence=candidate.confidence,
|
||||
importance=candidate.importance,
|
||||
sensitivity=candidate.sensitivity,
|
||||
evidence_ids_json=candidate.evidence_ids_json,
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
candidate.status = "applied"
|
||||
candidate.reviewed_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="extraction.memory.applied",
|
||||
actor="user",
|
||||
character_id=candidate.character_id,
|
||||
input_summary=candidate.title[:200],
|
||||
resource_type="memory_candidate",
|
||||
resource_id=candidate.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(candidate)
|
||||
return memory_candidate_to_response(candidate)
|
||||
|
||||
|
||||
async def reject_memory_proposal(
|
||||
candidate_id: str,
|
||||
) -> MemoryCandidateResponse:
|
||||
return await _reject_memory(candidate_id)
|
||||
|
||||
|
||||
async def list_world_entities_pending(
|
||||
*,
|
||||
character_id: str,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Page[WorldEntityResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
items, total = await world_repo.list_entities(
|
||||
session, character_id, status=status, page=page, page_size=page_size
|
||||
)
|
||||
return Page(
|
||||
items=[character_world_entity_to_response(i) for i in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def approve_world_entity(entity_id: str) -> WorldEntityResponse:
|
||||
return await _review_world_entity(entity_id, "confirmed", decision="auto")
|
||||
|
||||
|
||||
async def reject_world_entity(entity_id: str) -> WorldEntityResponse:
|
||||
return await _review_world_entity(entity_id, "rejected", decision="denied")
|
||||
|
||||
|
||||
async def _reject_memory(candidate_id: str) -> MemoryCandidateResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
candidate = await candidates_repo.get_memory_candidate(session, candidate_id)
|
||||
if candidate is None:
|
||||
raise AppError("not_found", "记忆候选不存在。")
|
||||
if candidate.status != "pending":
|
||||
raise AppError("conflict", f"候选当前状态为 {candidate.status},不可审核。")
|
||||
candidate.status = "rejected"
|
||||
candidate.reviewed_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="extraction.memory.rejected",
|
||||
actor="user",
|
||||
decision="denied",
|
||||
character_id=candidate.character_id,
|
||||
input_summary=candidate.title[:200],
|
||||
resource_type="memory_candidate",
|
||||
resource_id=candidate.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(candidate)
|
||||
return memory_candidate_to_response(candidate)
|
||||
|
||||
|
||||
async def _review_world_entity(
|
||||
entity_id: str, target_status: str, *, decision: str
|
||||
) -> WorldEntityResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
entity = await world_repo.get_entity(session, entity_id)
|
||||
if entity is None:
|
||||
raise AppError("not_found", "世界实体不存在。")
|
||||
if entity.status != "pending":
|
||||
raise AppError("conflict", f"实体当前状态为 {entity.status},不可审核。")
|
||||
entity.status = target_status
|
||||
await record_audit_event(
|
||||
session,
|
||||
action=f"extraction.world.{target_status}",
|
||||
actor="user",
|
||||
decision=decision,
|
||||
character_id=entity.character_id,
|
||||
input_summary=entity.name[:200],
|
||||
resource_type="character_world_entity",
|
||||
resource_id=entity.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entity)
|
||||
return character_world_entity_to_response(entity)
|
||||
@@ -15,7 +15,6 @@ from app.core.errors import AppError
|
||||
from app.core.time import utc_now
|
||||
from app.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,
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
"""记忆服务。
|
||||
|
||||
集成 ChromaDB 向量存储:
|
||||
- create_memory / 大/小周天自动创建的 Memory 写入后异步生成 embedding 存向量
|
||||
- retrieve_memories 支持向量相似度检索,失败时回退 SQL 排序检索
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.logging import logger
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.modules.memory.context import MemoryRetrievalResult
|
||||
from app.repositories import memory as memory_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.serializers import memory_to_response
|
||||
|
||||
|
||||
def _get_embedding_service(manager):
|
||||
"""尝试创建 EmbeddingService,失败时返回 None。"""
|
||||
try:
|
||||
models = manager.model_registry.list_by_capability("embedding")
|
||||
if not models:
|
||||
return None
|
||||
from app.ai_provider.embedding import EmbeddingService
|
||||
return EmbeddingService(manager)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
async def _embed_and_store(memory: Memory) -> None:
|
||||
"""异步后台任务:为记忆生成 embedding 并存入向量库。
|
||||
|
||||
embedding 服务和 ChromaDB 均不可用时静默跳过——不影响主流程。
|
||||
"""
|
||||
text = memory.content or memory.title or ""
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
from app.ai_provider import ProviderManagerFactory
|
||||
|
||||
manager = ProviderManagerFactory.create()
|
||||
service = _get_embedding_service(manager)
|
||||
if service is None:
|
||||
return
|
||||
|
||||
import asyncio as _asyncio
|
||||
|
||||
response = await _asyncio.wait_for(
|
||||
service.embed(text),
|
||||
timeout=10.0,
|
||||
)
|
||||
if not response.data or not response.data[0].embedding:
|
||||
return
|
||||
embedding = response.data[0].embedding
|
||||
|
||||
from app.integrations.vector_store import get_vector_store
|
||||
|
||||
store = get_vector_store()
|
||||
if store is None:
|
||||
return
|
||||
store.add_memory(
|
||||
character_id=memory.character_id,
|
||||
memory_id=memory.id,
|
||||
text=text,
|
||||
embedding=embedding,
|
||||
metadata={
|
||||
"type": memory.type,
|
||||
"source": memory.source,
|
||||
"title": memory.title or "",
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(
|
||||
"memory_embed_failed",
|
||||
memory_id=memory.id,
|
||||
character_id=memory.character_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
async def embed_memory_async(memory: Memory) -> None:
|
||||
"""公开接口:为单条记忆生成 embedding 并存入向量库(供大/小周天后台任务调用)。"""
|
||||
await _embed_and_store(memory)
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def list_memory_page(
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
memory_type: str | None = None,
|
||||
source: str | None = None,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> Page[MemoryResponse]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session, owner_scope)
|
||||
safe_page_size = min(max(page_size, 1), 100)
|
||||
safe_page = max(page, 1)
|
||||
items, total = await memory_repo.list_memories(
|
||||
session,
|
||||
character.id,
|
||||
memory_type=memory_type,
|
||||
source=source,
|
||||
page=safe_page,
|
||||
page_size=safe_page_size,
|
||||
)
|
||||
return Page(
|
||||
items=[memory_to_response(item) for item in items],
|
||||
page=safe_page,
|
||||
page_size=safe_page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def create_memory(
|
||||
session: AsyncSession,
|
||||
payload: MemoryCreateRequest,
|
||||
*,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
) -> MemoryResponse:
|
||||
character = await get_active_character_model(session, owner_scope)
|
||||
character_id = payload.character_id or character.id
|
||||
if character_id != character.id:
|
||||
raise AppError("not_found", "人物不存在。")
|
||||
memory = Memory(
|
||||
character_id=character_id,
|
||||
type=payload.type,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
source=payload.source,
|
||||
confidence=payload.confidence,
|
||||
importance=payload.importance,
|
||||
sensitivity=payload.sensitivity,
|
||||
evidence_ids_json=dumps(payload.evidence_ids),
|
||||
status="active",
|
||||
)
|
||||
session.add(memory)
|
||||
await session.flush()
|
||||
# 异步 embedding
|
||||
if settings.vector_db_enabled:
|
||||
import asyncio
|
||||
|
||||
asyncio.ensure_future(_embed_and_store(memory))
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="memory.created",
|
||||
character_id=character_id,
|
||||
actor="user",
|
||||
decision="auto",
|
||||
input_summary=payload.title,
|
||||
resource_type="memory",
|
||||
resource_id=memory.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(memory)
|
||||
return memory_to_response(memory)
|
||||
|
||||
|
||||
async def update_memory(
|
||||
memory_id: str, payload: MemoryUpdateRequest, *, owner_scope: str = settings.default_owner_scope
|
||||
) -> MemoryResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session, owner_scope)
|
||||
memory = await memory_repo.get_memory(session, memory_id)
|
||||
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
|
||||
raise AppError("not_found", "记忆不存在。")
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
evidence_ids = updates.pop("evidence_ids", None)
|
||||
for key, value in updates.items():
|
||||
setattr(memory, key, value)
|
||||
if evidence_ids is not None:
|
||||
memory.evidence_ids_json = dumps(evidence_ids)
|
||||
memory.updated_at = utc_now()
|
||||
# 如果内容变了,异步更新向量
|
||||
if ("content" in updates or "title" in updates) and settings.vector_db_enabled:
|
||||
import asyncio
|
||||
|
||||
asyncio.ensure_future(_embed_and_store(memory))
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="memory.updated",
|
||||
character_id=character.id,
|
||||
actor="user",
|
||||
decision="auto",
|
||||
input_summary=memory.title,
|
||||
resource_type="memory",
|
||||
resource_id=memory.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(memory)
|
||||
return memory_to_response(memory)
|
||||
|
||||
|
||||
async def delete_memory(
|
||||
memory_id: str, *, owner_scope: str = settings.default_owner_scope
|
||||
) -> MemoryResponse:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session, owner_scope)
|
||||
memory = await memory_repo.get_memory(session, memory_id)
|
||||
if memory is None or memory.character_id != character.id or memory.deleted_at is not None:
|
||||
raise AppError("not_found", "记忆不存在。")
|
||||
memory.status = "deleted"
|
||||
memory.deleted_at = utc_now()
|
||||
memory.updated_at = utc_now()
|
||||
# 同步删除向量
|
||||
if settings.vector_db_enabled:
|
||||
try:
|
||||
from app.integrations.vector_store import get_vector_store
|
||||
|
||||
store = get_vector_store()
|
||||
if store is not None:
|
||||
store.delete_memory(character.id, memory_id)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="memory.deleted",
|
||||
character_id=character.id,
|
||||
actor="user",
|
||||
decision="auto",
|
||||
input_summary=memory.title,
|
||||
resource_type="memory",
|
||||
resource_id=memory.id,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(memory)
|
||||
return memory_to_response(memory)
|
||||
|
||||
|
||||
async def memory_context(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
limit: int,
|
||||
_owner_scope: str = settings.default_owner_scope,
|
||||
) -> str:
|
||||
"""获取记忆上下文文本(供叩心枢开场、辰正等场景使用)。"""
|
||||
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
|
||||
if not memories:
|
||||
return ""
|
||||
lines = [f"- {item.title}: {item.content}" for item in memories]
|
||||
return "相关记忆:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def retrieve_memories(
|
||||
session: AsyncSession,
|
||||
character_id: str,
|
||||
*,
|
||||
limit: int,
|
||||
_owner_scope: str = settings.default_owner_scope,
|
||||
) -> MemoryRetrievalResult:
|
||||
"""检索可注入上下文的相关记忆。
|
||||
|
||||
优先尝试向量相似度检索,失败时回退 SQL 排序检索。
|
||||
"""
|
||||
# 尝试向量检索(5s 超时,避免 embedding endpoint 不可达时阻塞消息流)
|
||||
if settings.vector_db_enabled:
|
||||
try:
|
||||
from app.ai_provider import ProviderManagerFactory
|
||||
|
||||
manager = ProviderManagerFactory.create()
|
||||
models = manager.model_registry.list_by_capability("embedding")
|
||||
if models:
|
||||
import asyncio as _asyncio
|
||||
|
||||
from app.ai_provider.embedding import EmbeddingService
|
||||
from app.integrations.vector_store import get_vector_store
|
||||
|
||||
query_text = f"检索 {character_id} 的最新相关记忆"
|
||||
service = EmbeddingService(manager)
|
||||
response = await _asyncio.wait_for(
|
||||
service.embed(query_text),
|
||||
timeout=5.0,
|
||||
)
|
||||
if response.data and response.data[0].embedding:
|
||||
store = get_vector_store()
|
||||
if store is not None:
|
||||
vector_results = store.search(
|
||||
character_id,
|
||||
response.data[0].embedding,
|
||||
limit=limit,
|
||||
)
|
||||
if vector_results:
|
||||
ids = [r["id"] for r in vector_results]
|
||||
memories = await memory_repo.get_memories_by_ids(session, ids)
|
||||
if memories:
|
||||
return MemoryRetrievalResult(memories=memories)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("vector_retrieval_failed,回退 SQL 检索")
|
||||
|
||||
# 回退 SQL
|
||||
memories = await memory_repo.list_injectable_memories(session, character_id, limit=limit)
|
||||
return MemoryRetrievalResult(memories=memories)
|
||||
@@ -16,7 +16,6 @@ from app.models.governance import (
|
||||
WorldEntity,
|
||||
)
|
||||
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)
|
||||
Reference in new issue
Block a user