diff --git a/chat/services/synthesized_memories.py b/chat/services/synthesized_memories.py new file mode 100644 index 0000000..9724e31 --- /dev/null +++ b/chat/services/synthesized_memories.py @@ -0,0 +1,74 @@ +"""Synthesized-memories service (T54). + +When the user jump-skips with 'anything notable happen?' prose, parse +that prose into 1-N synthesized memories per present bot. Each memory +carries source="synthesized" and reliability=0.7 (lower than direct). +Caller (T62 skip flow) writes the memories via record_turn_memory_for_present. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from chat.llm.classify import classify +from chat.llm.client import LLMClient + + +class SynthesizedMemory(BaseModel): + text: str + significance: int = 1 # 0..3, default 1 + affinity_delta: int = 0 + trust_delta: int = 0 + + +class SynthesizedDigest(BaseModel): + memories: list[SynthesizedMemory] = Field(default_factory=list) + + +_SYSTEM = ( + "You parse a short user-supplied prose describing 'anything notable' " + "that happened during a time skip into 1-N synthesized memories from " + "a single bot's POV. Each memory has: text (one factual sentence " + "from that bot's perspective), significance (0-3, default 1; only " + "use 2 or 3 for genuinely scene-level or relationship-altering " + "events), affinity_delta and trust_delta (-10..+10, default 0; " + "use small adjustments only when prose explicitly describes a shift). " + "Empty/whitespace prose returns an empty memories list. Output " + "strict JSON matching the schema." +) + + +async def synthesize_memories( + client: LLMClient, + *, + classifier_model: str, + prose: str, + bot_name: str, + bot_persona: str, + you_name: str, + timeout_s: float = 30.0, +) -> SynthesizedDigest: + """Parse 'anything notable' prose into structured memories from a + single bot's POV. Empty/whitespace prose short-circuits to an + empty digest (no LLM call).""" + if not prose or not prose.strip(): + return SynthesizedDigest() + + user = ( + f"Bot: {bot_name}\n" + f"Persona: {bot_persona}\n" + f"Other party: {you_name}\n\n" + f"Prose:\n{prose.strip()}" + ) + return await classify( + client, + model=classifier_model, + system=_SYSTEM, + user=user, + schema=SynthesizedDigest, + default=SynthesizedDigest(), + timeout_s=timeout_s, + ) + + +__all__ = ["SynthesizedMemory", "SynthesizedDigest", "synthesize_memories"] diff --git a/tests/test_synthesized_memories.py b/tests/test_synthesized_memories.py new file mode 100644 index 0000000..23bf7ac --- /dev/null +++ b/tests/test_synthesized_memories.py @@ -0,0 +1,98 @@ +"""Tests for the synthesized-memories service (T54). + +When the user jump-skips ("a week later") they are prompted "anything +notable happen?" If they answer with prose, this service parses it into +1-N synthesized memories per present bot. Each memory carries +``source="synthesized"`` and ``reliability=0.7`` (the caller — T62 skip +flow — applies those tags when persisting; this service just produces +the structured digest). + +These tests cover: + +* The happy path: a canned classifier response parses cleanly into a + populated :class:`SynthesizedDigest` with one memory. +* Empty prose short-circuits before any classifier call — the mock has + no canned responses, so an accidental call would raise + ``IndexError``. +* Classifier failure (3 bad responses, exhausting :func:`classify`'s + retry budget) falls back to an empty default digest. +""" + +from __future__ import annotations + +import json + +import pytest + +from chat.llm.mock import MockLLMClient +from chat.services.synthesized_memories import ( + SynthesizedDigest, + SynthesizedMemory, + synthesize_memories, +) + + +@pytest.mark.asyncio +async def test_synthesize_parses_canned_prose(): + canned = json.dumps( + { + "memories": [ + { + "text": "Maya started a new pottery class.", + "significance": 1, + "affinity_delta": 0, + "trust_delta": 0, + } + ] + } + ) + mock = MockLLMClient(canned=[canned]) + result = await synthesize_memories( + mock, + classifier_model="x", + prose="we saw each other at her pottery class once", + bot_name="Maya", + bot_persona="warm potter, mid-30s", + you_name="Sam", + ) + assert isinstance(result, SynthesizedDigest) + assert len(result.memories) == 1 + mem = result.memories[0] + assert isinstance(mem, SynthesizedMemory) + assert mem.text == "Maya started a new pottery class." + assert mem.significance == 1 + assert mem.affinity_delta == 0 + assert mem.trust_delta == 0 + + +@pytest.mark.asyncio +async def test_empty_prose_returns_empty_digest(): + """Empty prose short-circuits — the classifier must not be called.""" + mock = MockLLMClient(canned=[]) + result = await synthesize_memories( + mock, + classifier_model="x", + prose="", + bot_name="Maya", + bot_persona="warm potter, mid-30s", + you_name="Sam", + ) + assert result == SynthesizedDigest() + assert result.memories == [] + + +@pytest.mark.asyncio +async def test_classifier_failure_returns_empty_default(): + """Three bad responses exhaust the classifier's retry budget; the + service then returns the empty default digest.""" + mock = MockLLMClient(canned=["bad", "bad", "bad"]) + result = await synthesize_memories( + mock, + classifier_model="x", + prose="we saw each other at her pottery class once", + bot_name="Maya", + bot_persona="warm potter, mid-30s", + you_name="Sam", + ) + assert result == SynthesizedDigest() + assert result.memories == []