File size: 4,854 Bytes
48b5418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0d952a
 
48b5418
 
 
b0d952a
 
 
 
 
 
48b5418
 
 
b0d952a
48b5418
 
 
 
 
 
 
 
 
 
 
 
 
 
b0d952a
 
48b5418
 
 
 
 
 
 
b0d952a
 
48b5418
b0d952a
48b5418
 
 
 
 
 
 
 
 
 
 
 
 
b0d952a
48b5418
 
 
 
b0d952a
 
48b5418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0d952a
48b5418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0d952a
 
48b5418
 
 
 
b0d952a
 
48b5418
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""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,
        )