Coverage for src/lilbee/retrieval/query/memory_extract.py: 100%
50 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Auto-extraction of durable memories from a chat turn.
3A small LLM pass over the user's message and the assistant's answer that
4proposes durable facts/preferences worth remembering. The model is asked for
5a strict JSON array; parsing is defensive (a non-conforming reply yields no
6memories rather than an error). Callers store the results, which the user can
7review and remove in ``/memories``.
8"""
10from __future__ import annotations
12import logging
13from collections.abc import Callable
14from dataclasses import dataclass
16from lilbee.core.llm_json import first_json_array
17from lilbee.data.store import MemoryKind
19log = logging.getLogger(__name__)
21ChatFn = Callable[..., str]
23# Bounds mirror the prior-art auto-capture filter: too-short strings carry no
24# durable signal, too-long ones are usually the model restating the answer.
25_MIN_MEMORY_CHARS = 10
26_MAX_MEMORY_CHARS = 500
28_EXTRACT_SYSTEM_PROMPT = (
29 "You extract durable, long-term memories about the user from a single chat turn. "
30 "A memory is a stable fact about the user or their project (not a one-off question) "
31 "or a standing preference for how they want help. "
32 "Ignore transient details, the assistant's own content, and anything specific to "
33 "just this question. "
34 "Respond with ONLY a JSON array (no prose). Each element is an object "
35 '{"text": "<the memory, third person>", "kind": "fact" | "preference"}. '
36 "Return [] when nothing is worth remembering."
37)
39_EXTRACT_USER_TEMPLATE = "User said:\n{question}\n\nAssistant replied:\n{answer}"
42@dataclass(frozen=True)
43class ExtractedMemory:
44 """A single memory proposed by the extraction pass."""
46 text: str
47 kind: MemoryKind
50def build_extract_messages(question: str, answer: str) -> list[dict[str, str]]:
51 """Build the system+user message pair for the extraction prompt."""
52 return [
53 {"role": "system", "content": _EXTRACT_SYSTEM_PROMPT},
54 {
55 "role": "user",
56 "content": _EXTRACT_USER_TEMPLATE.format(question=question, answer=answer),
57 },
58 ]
61def _coerce_kind(value: object) -> MemoryKind:
62 """Decode a kind string, defaulting to FACT for anything unrecognized."""
63 if not isinstance(value, str):
64 return MemoryKind.FACT
65 try:
66 return MemoryKind(value)
67 except ValueError:
68 return MemoryKind.FACT
71def parse_extraction(raw: str) -> list[ExtractedMemory]:
72 """Parse the model's reply into memories; tolerate a non-conforming reply.
74 Extracts the first JSON array in *raw* (models often wrap it in prose or a
75 code fence), keeps only objects with a usable-length ``text``, and decodes
76 the kind. Any parse failure yields an empty list.
77 """
78 items = first_json_array(raw)
79 if items is None:
80 return []
82 memories: list[ExtractedMemory] = []
83 for item in items:
84 if not isinstance(item, dict):
85 continue
86 text = item.get("text")
87 if not isinstance(text, str):
88 continue
89 text = text.strip()
90 if not _MIN_MEMORY_CHARS <= len(text) <= _MAX_MEMORY_CHARS:
91 continue
92 memories.append(ExtractedMemory(text=text, kind=_coerce_kind(item.get("kind"))))
93 return memories
96def extract_memories(question: str, answer: str, chat: ChatFn) -> list[ExtractedMemory]:
97 """Run the extraction pass for one turn; never raises.
99 *chat* is the provider's non-streaming chat callable. A model or transport
100 failure logs and yields no memories so a bad extraction never disrupts the
101 chat session.
102 """
103 if not question.strip() or not answer.strip():
104 return []
105 try:
106 raw = chat(build_extract_messages(question, answer), stream=False)
107 except Exception:
108 log.debug("Memory extraction call failed", exc_info=True)
109 return []
110 return parse_extraction(raw)