Coverage for src/lilbee/retrieval/query/memory_extract.py: 100%

51 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-08 09:20 +0000

1"""Auto-extraction of durable memories from a chat turn. 

2 

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""" 

9 

10from __future__ import annotations 

11 

12import logging 

13from collections.abc import Callable 

14from dataclasses import dataclass 

15 

16from lilbee.core.llm_json import first_json_array 

17from lilbee.data.store import MemoryKind 

18 

19log = logging.getLogger(__name__) 

20 

21ChatFn = Callable[..., str] 

22 

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. 

25MEMORY_EXTRACT_MAX_TOKENS = 800 

26_MIN_MEMORY_CHARS = 10 

27_MAX_MEMORY_CHARS = 500 

28 

29_EXTRACT_SYSTEM_PROMPT = ( 

30 "You extract durable, long-term memories about the user from a single chat turn. " 

31 "A memory is a stable fact about the user or their project (not a one-off question) " 

32 "or a standing preference for how they want help. " 

33 "Ignore transient details, the assistant's own content, and anything specific to " 

34 "just this question. " 

35 "Respond with ONLY a JSON array (no prose). Each element is an object " 

36 '{"text": "<the memory, third person>", "kind": "fact" | "preference"}. ' 

37 "Return [] when nothing is worth remembering." 

38) 

39 

40_EXTRACT_USER_TEMPLATE = "User said:\n{question}\n\nAssistant replied:\n{answer}" 

41 

42 

43@dataclass(frozen=True) 

44class ExtractedMemory: 

45 """A single memory proposed by the extraction pass.""" 

46 

47 text: str 

48 kind: MemoryKind 

49 

50 

51def build_extract_messages(question: str, answer: str) -> list[dict[str, str]]: 

52 """Build the system+user message pair for the extraction prompt.""" 

53 return [ 

54 {"role": "system", "content": _EXTRACT_SYSTEM_PROMPT}, 

55 { 

56 "role": "user", 

57 "content": _EXTRACT_USER_TEMPLATE.format(question=question, answer=answer), 

58 }, 

59 ] 

60 

61 

62def _coerce_kind(value: object) -> MemoryKind: 

63 """Decode a kind string, defaulting to FACT for anything unrecognized.""" 

64 if not isinstance(value, str): 

65 return MemoryKind.FACT 

66 try: 

67 return MemoryKind(value) 

68 except ValueError: 

69 return MemoryKind.FACT 

70 

71 

72def parse_extraction(raw: str) -> list[ExtractedMemory]: 

73 """Parse the model's reply into memories; tolerate a non-conforming reply. 

74 

75 Extracts the first JSON array in *raw* (models often wrap it in prose or a 

76 code fence), keeps only objects with a usable-length ``text``, and decodes 

77 the kind. Any parse failure yields an empty list. 

78 """ 

79 items = first_json_array(raw) 

80 if items is None: 

81 return [] 

82 

83 memories: list[ExtractedMemory] = [] 

84 for item in items: 

85 if not isinstance(item, dict): 

86 continue 

87 text = item.get("text") 

88 if not isinstance(text, str): 

89 continue 

90 text = text.strip() 

91 if not _MIN_MEMORY_CHARS <= len(text) <= _MAX_MEMORY_CHARS: 

92 continue 

93 memories.append(ExtractedMemory(text=text, kind=_coerce_kind(item.get("kind")))) 

94 return memories 

95 

96 

97def extract_memories(question: str, answer: str, chat: ChatFn) -> list[ExtractedMemory]: 

98 """Run the extraction pass for one turn; never raises. 

99 

100 *chat* is the provider's non-streaming chat callable. A model or transport 

101 failure logs and yields no memories so a bad extraction never disrupts the 

102 chat session. 

103 """ 

104 if not question.strip() or not answer.strip(): 

105 return [] 

106 try: 

107 raw = chat(build_extract_messages(question, answer), stream=False) 

108 except Exception: 

109 log.debug("Memory extraction call failed", exc_info=True) 

110 return [] 

111 return parse_extraction(raw)