File size: 5,513 Bytes
599eb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915c925
 
 
 
 
 
 
599eb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915c925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599eb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
145
146
147
148
149
150
151
152
153
154
155
156
"""
memory/retrieve.py β€” Retrieval-before-decision.

Queries the `lessons` table ONLY (never raw `decisions` at inference time).
Embeds the current state signature β†’ cosine similarity search β†’
returns top-k lessons above threshold.

Logs no-match events when nothing clears the similarity threshold.
This no-match rate is the key trigger metric for deciding if DPO is needed.
"""
from __future__ import annotations

import json
import time
from typing import Any, Dict, List, Optional, Tuple

import numpy as np
import structlog
from sqlalchemy import select, text

from config import settings
from memory.db import get_db_session
from memory.models import Lesson, LessonSchema

log = structlog.get_logger(__name__)

# Module-level embedding model (lazy-loaded, shared across calls)
_embed_model = None


def _get_embed_model():
    global _embed_model
    if _embed_model is None:
        from sentence_transformers import SentenceTransformer
        _embed_model = SentenceTransformer(settings.embed_model)
        log.info("retriever.embed_model_loaded", model=settings.embed_model)
    return _embed_model


def embed_state(state_signature: str) -> List[float]:
    """Embed a state signature string into a 384-dim vector."""
    try:
        model = _get_embed_model()
        vec = model.encode(state_signature, normalize_embeddings=True)
        return vec.tolist() if hasattr(vec, "tolist") else list(vec)
    except Exception as exc:
        log.warning("retriever.embed_fallback", error=str(exc))
        return [0.0] * 384


async def retrieve_lessons(
    state_signature: str,
    task_id: Optional[str] = None,
    episode_id: Optional[str] = None,
    step_index: Optional[int] = None,
) -> Tuple[List[LessonSchema], bool]:
    """
    Retrieve the top-k most similar lessons from the lessons table.

    Returns:
      (lessons, no_match)
      - lessons: list of LessonSchema with similarity field populated
      - no_match: True if no lesson cleared the similarity threshold (log this!)

    The no_match flag is the trigger metric (Β§7) β€” log it with context.
    """
    embedding = embed_state(state_signature)
    embedding_str = "[" + ",".join(f"{v:.6f}" for v in embedding) + "]"

    lessons: List[LessonSchema] = []
    no_match = True

    try:
        async with get_db_session() as session:
            # pgvector cosine similarity: 1 - cosine_distance
            result = await session.execute(
                text("""
                    SELECT
                        lesson_id,
                        best_action,
                        outcome_summary,
                        sample_count,
                        success_rate,
                        last_updated,
                        1 - (state_cluster_embedding <=> :embedding ::vector) AS similarity
                    FROM lessons
                    WHERE state_cluster_embedding IS NOT NULL
                    ORDER BY state_cluster_embedding <=> :embedding ::vector
                    LIMIT :top_k
                """),
                {"embedding": embedding_str, "top_k": settings.retrieval_top_k},
            )
            rows = result.fetchall()
    except Exception as exc:
        log.warning("retriever.db_unavailable", error=str(exc), note="Continuing without memory retrieval")
        rows = []

    for row in rows:
        similarity = float(row.similarity)
        if similarity >= settings.similarity_threshold:
            no_match = False
            lessons.append(
                LessonSchema(
                    lesson_id=str(row.lesson_id),
                    best_action=row.best_action,
                    outcome_summary=row.outcome_summary,
                    sample_count=row.sample_count,
                    success_rate=row.success_rate,
                    similarity=round(similarity, 4),
                )
            )

    # ── Log no-match event (Β§7 trigger metric) ────────────────────────────────
    if no_match:
        log.info(
            "retrieval.no_match",
            task_id=task_id,
            episode_id=episode_id,
            step_index=step_index,
            state_signature_len=len(state_signature),
            total_lessons_checked=len(rows),
            threshold=settings.similarity_threshold,
        )
    else:
        log.debug(
            "retrieval.matched",
            task_id=task_id,
            episode_id=episode_id,
            step_index=step_index,
            matched_count=len(lessons),
            top_similarity=lessons[0].similarity if lessons else None,
        )

    return lessons, no_match


def format_lessons_for_context(lessons: List[LessonSchema]) -> str:
    """
    Format retrieved lessons as a context string for injection into the
    agent's reasoning prompt.
    """
    if not lessons:
        return ""

    lines = ["## Relevant Past Experience (retrieved from memory)\n"]
    for i, lesson in enumerate(lessons, 1):
        lines.append(f"### Lesson {i} (similarity={lesson.similarity:.2f}, success_rate={lesson.success_rate:.0%})")
        if lesson.outcome_summary:
            lines.append(f"**Situation**: {lesson.outcome_summary}")
        if lesson.best_action:
            action_str = json.dumps(lesson.best_action, indent=2)
            lines.append(f"**Best action that worked**:\n```json\n{action_str}\n```")
        lines.append(f"*Based on {lesson.sample_count} past episodes.*\n")

    return "\n".join(lines)