File size: 13,593 Bytes
a2854ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
"""
memory_manager.py
~~~~~~~~~~~~~~~~~
Production-ready conversational memory for the OSS AI assistant.

Design goals
------------
1. Retain recent history   β€” stores every human/AI exchange as typed dicts.
2. Configurable window     β€” `max_turns` caps how many pairs are kept.
3. Token-overflow guard    β€” a pre-inference check counts real tokens and
                             drops the oldest turn(s) until the prompt fits
                             within `max_context_tokens`.
4. Context preservation    β€” the system prompt is never counted against the
                             history budget; it is always included.
5. Reset support           β€” `clear()` wipes history; model stays loaded.

Memory layout (internal)
------------------------
Each item in `_turns` is a pair:
    {"human": str, "ai": str | None}

`ai` is None between the moment the user sends a message and the moment
the assistant replies β€” this lets the caller inspect in-flight state.

Public interface mirrors the OpenAI / HF chat-template message format:
    [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Dict, List, Optional, TYPE_CHECKING

from models.logger_config import logger

if TYPE_CHECKING:
    # Avoid a hard import of transformers here; it is optional for callers
    # that only use memory without a model.
    from transformers import PreTrainedTokenizerBase


# ─────────────────────────────────────────────────────────────────────────────
# Internal storage type
# ─────────────────────────────────────────────────────────────────────────────

@dataclass
class Turn:
    """One complete human/AI exchange."""
    human: str
    ai: Optional[str] = None          # None until the assistant has replied

    @property
    def is_complete(self) -> bool:
        return self.ai is not None


# ─────────────────────────────────────────────────────────────────────────────
# Memory class
# ─────────────────────────────────────────────────────────────────────────────

class ConversationMemory:
    """
    Sliding-window conversational memory with token-budget enforcement.

    Parameters
    ----------
    max_turns : int
        Hard cap on the number of turns kept in memory. Oldest turns are
        evicted first when the cap is reached. Default: 10.
    max_context_tokens : int
        Soft token budget for the entire prompt (system + history).
        If a tokenizer is provided, oldest turns are dropped until the
        rendered prompt fits within this budget. Default: 1 800 (leaves
        ~200 tokens headroom for a 2 048-token context model like Qwen-0.5B).
    system_prompt : str
        Prepended to every prompt. Never counted against the history window.
    """

    def __init__(
        self,
        max_turns: int = 10,
        max_context_tokens: int = 1_800,
        system_prompt: str = (
            "You are a helpful, harmless, and honest AI assistant. "
            "Answer concisely and accurately."
        ),
    ) -> None:
        if max_turns < 1:
            raise ValueError("max_turns must be >= 1")
        if max_context_tokens < 64:
            raise ValueError("max_context_tokens must be >= 64")

        self.max_turns = max_turns
        self.max_context_tokens = max_context_tokens
        self.system_prompt = system_prompt

        self._turns: List[Turn] = []

        logger.debug(
            f"ConversationMemory created | max_turns={max_turns} "
            f"| max_context_tokens={max_context_tokens}"
        )

    # ── Write API ─────────────────────────────────────────────────────────────

    def add_user_message(self, text: str) -> None:
        """
        Record the user's message.  Opens a new Turn; `ai` is None until
        `add_assistant_reply()` is called.

        Immediately applies the turn-count window so the upcoming prompt
        is already trimmed before inference starts.
        """
        text = text.strip()
        if not text:
            raise ValueError("User message must not be empty.")

        self._turns.append(Turn(human=text))
        self._apply_turn_window()
        logger.debug(f"Memory: user message added | turns={len(self._turns)}")

    def add_assistant_reply(self, text: str) -> None:
        """
        Attach the assistant's reply to the most-recent open Turn.
        Raises RuntimeError if there is no open turn (i.e., add_user_message
        was not called first, or the turn is already complete).
        """
        text = text.strip()
        if not self._turns or self._turns[-1].is_complete:
            raise RuntimeError(
                "Cannot add assistant reply: no open turn found. "
                "Call add_user_message() first."
            )
        self._turns[-1].ai = text
        logger.debug(f"Memory: assistant reply saved | turns={len(self._turns)}")

    def rollback_last_user_message(self) -> None:
        """
        Remove the most-recent incomplete turn (user message without a reply).
        Called by OSSAssistant when inference fails, keeping memory consistent.
        """
        if self._turns and not self._turns[-1].is_complete:
            self._turns.pop()
            logger.warning("Memory: rolled back incomplete turn after inference error.")

    # ── Read API ──────────────────────────────────────────────────────────────

    def as_message_list(self) -> List[Dict[str, str]]:
        """
        Return history as a flat list of chat-template dicts:
            [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}, ...]

        Incomplete turns (no AI reply yet) are included as user-only entries
        so that the model still sees the current user message.
        """
        messages: List[Dict[str, str]] = []
        for turn in self._turns:
            messages.append({"role": "user", "content": turn.human})
            if turn.ai is not None:
                messages.append({"role": "assistant", "content": turn.ai})
        return messages

    def as_prompt_messages(self) -> List[Dict[str, str]]:
        """
        Return the full prompt list: [system] + history.
        This is what you pass to `tokenizer.apply_chat_template()`.
        """
        return [
            {"role": "system", "content": self.system_prompt},
            *self.as_message_list(),
        ]

    def as_plain_text(self) -> str:
        """Human-readable transcript β€” useful for debugging or UI display."""
        lines: List[str] = []
        for i, turn in enumerate(self._turns, 1):
            lines.append(f"[{i}] User: {turn.human}")
            if turn.ai is not None:
                lines.append(f"[{i}]   AI: {turn.ai}")
            else:
                lines.append(f"[{i}]   AI: (awaiting reply)")
        return "\n".join(lines) if lines else "(no history)"

    # ── Token-budget enforcement ───────────────────────────────────────────────

    def enforce_token_budget(
        self, tokenizer: "PreTrainedTokenizerBase"
    ) -> int:
        """
        Drop oldest complete turns until the rendered prompt fits within
        `max_context_tokens`.  Returns the final token count.

        Only complete turns are dropped β€” the most-recent (in-flight) turn
        is always kept so the model sees the current question.

        Call this AFTER `add_user_message()` and BEFORE running inference.
        """
        while True:
            token_count = self._count_tokens(tokenizer)
            if token_count <= self.max_context_tokens:
                break

            # Find the oldest *complete* turn to evict
            evict_idx = next(
                (i for i, t in enumerate(self._turns) if t.is_complete), None
            )
            if evict_idx is None:
                # Nothing left to drop β€” the single current message is too long.
                # Log a warning; the tokenizer's own truncation will handle it.
                logger.warning(
                    f"Token budget exceeded ({token_count} > {self.max_context_tokens}) "
                    "but no complete turns to evict. Tokenizer truncation will apply."
                )
                break

            evicted = self._turns.pop(evict_idx)
            logger.debug(
                f"Token budget: evicted turn (tokens={token_count} > "
                f"{self.max_context_tokens}) | "
                f"human={evicted.human[:40]!r}"
            )

        final_count = self._count_tokens(tokenizer)
        logger.debug(f"Token budget enforced | final_tokens={final_count}")
        return final_count

    def _count_tokens(self, tokenizer: "PreTrainedTokenizerBase") -> int:
        """
        Render the current prompt to a string and count its tokens.
        Uses the model's native chat template for accuracy.
        """
        try:
            prompt_text = tokenizer.apply_chat_template(
                self.as_prompt_messages(),
                tokenize=False,
                add_generation_prompt=True,
            )
            return len(tokenizer.encode(prompt_text))
        except Exception as exc:
            logger.warning(f"Token counting failed ({exc}); skipping budget check.")
            return 0

    # ── Window management ─────────────────────────────────────────────────────

    def _apply_turn_window(self) -> None:
        """
        Enforce `max_turns`: drop oldest complete turns until the count fits.
        The in-flight turn (incomplete) is never evicted here.
        """
        complete_turns = [t for t in self._turns if t.is_complete]
        overflow = len(complete_turns) - self.max_turns

        if overflow <= 0:
            return

        # Remove the `overflow` oldest complete turns
        removed = 0
        i = 0
        while removed < overflow and i < len(self._turns):
            if self._turns[i].is_complete:
                self._turns.pop(i)
                removed += 1
            else:
                i += 1

        logger.debug(f"Turn window: evicted {removed} old turn(s) | remaining={len(self._turns)}")

    # ── Reset ─────────────────────────────────────────────────────────────────

    def clear(self) -> None:
        """Wipe all conversation history. Model stays loaded."""
        count = len(self._turns)
        self._turns.clear()
        logger.info(f"ConversationMemory cleared ({count} turn(s) removed).")

    # ── Introspection ─────────────────────────────────────────────────────────

    @property
    def turn_count(self) -> int:
        """Total turns stored (complete + in-flight)."""
        return len(self._turns)

    @property
    def complete_turn_count(self) -> int:
        """Turns where the assistant has already replied."""
        return sum(1 for t in self._turns if t.is_complete)

    @property
    def is_empty(self) -> bool:
        return len(self._turns) == 0

    def __repr__(self) -> str:
        return (
            f"ConversationMemory("
            f"turns={self.turn_count}, "
            f"max_turns={self.max_turns}, "
            f"max_context_tokens={self.max_context_tokens})"
        )


# ─────────────────────────────────────────────────────────────────────────────
# Backwards-compatible alias (keeps old import from previous memory_manager.py)
# ─────────────────────────────────────────────────────────────────────────────

class ConversationMemoryManager(ConversationMemory):
    """
    Legacy alias kept for backwards compatibility.
    Prefer ConversationMemory in new code.
    """

    def add_turn(self, human_text: str, ai_text: str) -> None:
        """Old-style API: add a complete turn in one call."""
        self.add_user_message(human_text)
        self.add_assistant_reply(ai_text)

    def get_history(self) -> List[Dict[str, str]]:
        return self.as_message_list()

    def get_history_as_text(self) -> str:
        return self.as_plain_text()