feat: synthesized-memories service for jump skips (T54)
This commit is contained in:
@@ -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"]
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user