"""Fire-and-forget inline memory persistence triggered by Soul mid-conversation. Call via ``asyncio.create_task(save_inline_memory(...))``. The coroutine opens its own DB session so the caller's session is already closed by the time this runs. Failures are logged at WARNING — inline memory loss is visible in logs but never propagates to the caller. """ from __future__ import annotations import logging import math from sqlalchemy import select from app.db import SessionLocal from app.memory.embeddings import build_memory_embedding_input, embed_texts from app.models.memory import Memory, MemoryScope, MemoryType from app.schemas.agents import InlineMemoryRequest from app.schemas.memory import MemoryCreate logger = logging.getLogger(__name__) DEDUP_COSINE_THRESHOLD = 0.92 def _cosine_sim(a: list[float], b: list[float]) -> float: dot = sum(x * y for x, y in zip(a, b)) mag_a = math.sqrt(sum(x * x for x in a)) mag_b = math.sqrt(sum(x * x for x in b)) if mag_a == 0.0 or mag_b == 0.0: return 0.0 return dot / (mag_a * mag_b) def _is_near_duplicate( session, *, character_id: str | None = None, agent_id: str | None = None, player_id: str, proposed_embedding: list[float], ) -> bool: if agent_id is not None: scope_filter = Memory.agent_id == agent_id elif character_id is not None: scope_filter = Memory.character_id == character_id else: raise ValueError("_is_near_duplicate requires character_id or agent_id") stmt = ( select(Memory.embedding) .where( scope_filter, Memory.player_id == player_id, Memory.scope == MemoryScope.OPPONENT_SPECIFIC, Memory.embedding.is_not(None), ) ) for (emb,) in session.execute(stmt): if emb and _cosine_sim(proposed_embedding, emb) > DEDUP_COSINE_THRESHOLD: return True return False async def save_inline_memory( request: InlineMemoryRequest, *, character_id: str | None = None, agent_id: str | None = None, player_id: str, match_id: str | None = None, ) -> None: """Embed and persist a Soul-generated inline memory. Fire-and-forget: call as ``asyncio.create_task(save_inline_memory(...))``. Survives client disconnect. Logs and returns silently on any error. Pass either ``character_id`` (character room) or ``agent_id`` (agent room). """ scope_label = agent_id or character_id try: embed_input = build_memory_embedding_input( narrative_text=request.narrative_text, triggers=list(request.triggers), relevance_tags=list(request.relevance_tags), ) vectors = embed_texts([embed_input]) proposed_embedding = vectors[0] with SessionLocal() as session: if _is_near_duplicate( session, character_id=character_id, agent_id=agent_id, player_id=player_id, proposed_embedding=proposed_embedding, ): logger.info( "inline_memory_dedup_skipped player=%s scope=%s match=%s", player_id, scope_label, match_id, ) return item = MemoryCreate( scope=MemoryScope.OPPONENT_SPECIFIC, type=MemoryType(request.type), emotional_valence=request.emotional_valence, triggers=list(request.triggers), narrative_text=request.narrative_text, relevance_tags=list(request.relevance_tags), player_id=player_id, match_id=match_id, ) # bulk_create handles embedding; pass embed=False since we already have it. row = Memory( character_id=character_id, agent_id=agent_id, player_id=item.player_id, match_id=item.match_id, scope=item.scope, type=item.type, emotional_valence=item.emotional_valence, triggers=list(item.triggers), narrative_text=item.narrative_text, relevance_tags=list(item.relevance_tags), embedding=proposed_embedding, surface_count=0, last_surfaced_at=None, ) session.add(row) session.commit() logger.info( "inline_memory_saved player=%s scope=%s match=%s triggers=%s", player_id, scope_label, match_id, list(request.triggers), ) except Exception: logger.warning( "inline_memory_failed player=%s scope=%s match=%s — skipping", player_id, scope_label, match_id, exc_info=True, )