Coverage for merco/memory/recall.py: 81%
108 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
1"""Memory recall interfaces — ABC + FTS5 + Memory + Hybrid recallers."""
3from __future__ import annotations
5import json
6import logging
7from abc import ABC, abstractmethod
8from dataclasses import dataclass, field
9from typing import TYPE_CHECKING
11if TYPE_CHECKING:
12 from .session_search import SessionSearch
14from .store import MemoryStore
17@dataclass
18class RecallResult:
19 """A single recall result from any recaller."""
21 snippet: str
22 session_title: str = ""
23 score: float = 0.0
24 source: str = "memory" # "fts5" | "memory"
27class BaseRecaller(ABC):
28 """Abstract base for all recallers."""
30 name: str = ""
32 @abstractmethod
33 async def recall(self, query: str, limit: int = 10) -> list[RecallResult]:
34 """Return recall results for the given query."""
35 ...
38class FTS5Recaller(BaseRecaller):
39 """Full-text search recaller backed by SessionSearch."""
41 name = "fts5"
43 def __init__(self, session_search: SessionSearch) -> None:
44 self._search = session_search
46 async def recall(self, query: str, limit: int = 10) -> list[RecallResult]:
47 raw = self._search.search(query, limit=limit)
48 total = len(raw)
49 results: list[RecallResult] = []
50 for i, item in enumerate(raw):
51 score = (total - i) / max(total, 1) if total > 0 else 0.0
52 results.append(
53 RecallResult(
54 snippet=item["snippet"],
55 session_title=item.get("session_title", ""),
56 score=round(score, 4),
57 source="fts5",
58 )
59 )
60 return results
63class MemoryRecaller(BaseRecaller):
64 """Key-value memory store recaller backed by MemoryStore."""
66 name = "memory"
68 def __init__(self, memory_store: MemoryStore) -> None:
69 self._store = memory_store
71 async def recall(self, query: str, limit: int = 10) -> list[RecallResult]:
72 raw = self._store.search(query)[:limit]
73 total = len(raw)
74 results: list[RecallResult] = []
75 for i, item in enumerate(raw):
76 score = (total - i) / max(total, 1) if total > 0 else 0.0
77 snippet = item.get("value", "")
78 if isinstance(snippet, (dict, list)):
79 snippet = json.dumps(snippet, ensure_ascii=False)
80 else:
81 snippet = str(snippet)
82 results.append(
83 RecallResult(
84 snippet=snippet,
85 session_title=item.get("key", ""),
86 score=round(score, 4),
87 source="memory",
88 )
89 )
90 return results
93class HybridRecaller(BaseRecaller):
94 """Aggregates multiple recallers, deduplicates, and limits by chars.
96 Caches results by (query, limit) so repeated calls return instantly.
97 """
99 name = "hybrid"
101 def __init__(
102 self,
103 recallers: list[BaseRecaller] | None = None,
104 limit: int = 3,
105 max_chars: int = 300,
106 ) -> None:
107 self._recallers: list[BaseRecaller] = list(recallers) if recallers else []
108 self._limit = limit
109 self._max_chars = max_chars
110 self._cache: dict[tuple[str, int], list[RecallResult]] = {}
112 def add(self, recaller: BaseRecaller) -> HybridRecaller:
113 """Add a recaller (fluent interface)."""
114 self._recallers.append(recaller)
115 return self
117 async def recall(self, query: str, limit: int = 10) -> list[RecallResult]:
118 cache_key = (query, limit)
119 if cache_key in self._cache:
120 return self._cache[cache_key]
122 # Gather from all recallers
123 all_results: list[RecallResult] = []
124 for rec in self._recallers:
125 try:
126 batch = await rec.recall(query, limit=limit)
127 all_results.extend(batch)
128 except Exception:
129 # Swallow individual recaller errors so one failure
130 # doesn't break the whole hybrid pipeline.
131 logger = logging.getLogger(__name__)
132 logger.warning("Recaller %s failed for query %r", rec.name, query, exc_info=True)
133 continue
135 # Sort by score descending
136 all_results.sort(key=lambda r: r.score, reverse=True)
138 # Deduplicate by first 80 chars of snippet
139 seen: set[str] = set()
140 deduped: list[RecallResult] = []
141 for r in all_results:
142 prefix = r.snippet[:80]
143 if prefix not in seen:
144 seen.add(prefix)
145 deduped.append(r)
147 # Truncate by max_chars (cumulative snippet length)
148 total_chars = 0
149 truncated: list[RecallResult] = []
150 for r in deduped:
151 total_chars += len(r.snippet)
152 if total_chars > self._max_chars and truncated:
153 break
154 truncated.append(r)
156 # Apply top-level limit (use _limit as a cap, not a floor)
157 final = truncated[: min(limit, self._limit)]
159 self._cache[cache_key] = final
160 return final
163# ---------------------------------------------------------------------------
164# Backward-compatible synchronous MemoryRecall (existing API)
165# ---------------------------------------------------------------------------
167class MemoryRecall:
168 """Legacy memory recall — kept for backward compatibility.
170 Prefer MemoryRecaller (async) for new code.
171 """
173 def __init__(self, store: MemoryStore) -> None:
174 self.store = store
176 def recall(self, context: str, max_results: int = 5) -> list[dict]:
177 """根据上下文召回相关记忆"""
178 results = self.store.search(context)
179 return results[:max_results]
181 def recall_by_tag(self, tag: str) -> list[dict]:
182 """按标签召回记忆"""
183 keys = self.store.list_keys(tag=tag)
184 memories = []
185 for key in keys:
186 memory = self.store.load(key)
187 if memory:
188 memories.append(memory)
189 return memories
191 def get_relevant_context(self, query: str) -> str:
192 """获取相关上下文文本"""
193 memories = self.recall(query)
194 if not memories:
195 return ""
197 context_parts = []
198 for m in memories:
199 context_parts.append(f"[{m.get('key', '?')}]: {m.get('value', '')}")
201 return "\n".join(context_parts)