voltmem 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voltmem/__init__.py +68 -0
- voltmem/client.py +239 -0
- voltmem/domains.py +102 -0
- voltmem/embeddings.py +161 -0
- voltmem/extract.py +265 -0
- voltmem/integrations/__init__.py +1 -0
- voltmem/integrations/langchain.py +176 -0
- voltmem/memory.py +591 -0
- voltmem/scoring.py +163 -0
- voltmem/store.py +202 -0
- voltmem-0.1.0.dist-info/METADATA +247 -0
- voltmem-0.1.0.dist-info/RECORD +15 -0
- voltmem-0.1.0.dist-info/WHEEL +5 -0
- voltmem-0.1.0.dist-info/licenses/LICENSE +21 -0
- voltmem-0.1.0.dist-info/top_level.txt +1 -0
voltmem/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VoltMem — Volatility-Adjusted Persistent Memory Layer
|
|
3
|
+
======================================================
|
|
4
|
+
|
|
5
|
+
A pluggable memory layer for LLM applications and any system that needs
|
|
6
|
+
persistent context with principled staleness handling.
|
|
7
|
+
|
|
8
|
+
Core idea: not all memories age at the same rate. Stable knowledge
|
|
9
|
+
(personality traits, core preferences) should be protected strongly
|
|
10
|
+
against overwriting. Volatile knowledge (current tasks, emotional context,
|
|
11
|
+
location) should be held loosely and updated readily.
|
|
12
|
+
|
|
13
|
+
Quick start:
|
|
14
|
+
from voltmem import MemoryLayer
|
|
15
|
+
|
|
16
|
+
mem = MemoryLayer("my_app.db")
|
|
17
|
+
|
|
18
|
+
mem.write("User prefers direct communication", domain="core_preference")
|
|
19
|
+
mem.write("User is currently job hunting", domain="current_project")
|
|
20
|
+
|
|
21
|
+
# New observation — may or may not update existing memory
|
|
22
|
+
result = mem.observe(
|
|
23
|
+
content="User mentioned they accepted a job offer",
|
|
24
|
+
domain="current_project",
|
|
25
|
+
mismatch_magnitude=0.8,
|
|
26
|
+
source="explicit_statement",
|
|
27
|
+
)
|
|
28
|
+
print(result.action) # "audited" — volatile domain, high mismatch → updated
|
|
29
|
+
|
|
30
|
+
# Retrieve relevant memories for a query
|
|
31
|
+
results = mem.retrieve("career and work context")
|
|
32
|
+
for item, score in zip(results.items, results.scores):
|
|
33
|
+
print(f"[{score:.2f}] {item.content}")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from .memory import MemoryLayer, WriteResult, RetrieveResult
|
|
37
|
+
from .client import Memory, create_memory
|
|
38
|
+
from .domains import MemoryItem, DOMAIN_VOLATILITY, SOURCE_RELIABILITY
|
|
39
|
+
from .embeddings import EmbeddingSimilarity
|
|
40
|
+
from .extract import HeuristicExtractor, LLMExtractor, HeuristicFactExtractor, LLMFactExtractor
|
|
41
|
+
from .scoring import (
|
|
42
|
+
escalation_score,
|
|
43
|
+
retrieval_score,
|
|
44
|
+
staleness,
|
|
45
|
+
protection_weight,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Memory",
|
|
50
|
+
"create_memory",
|
|
51
|
+
"MemoryLayer",
|
|
52
|
+
"WriteResult",
|
|
53
|
+
"RetrieveResult",
|
|
54
|
+
"MemoryItem",
|
|
55
|
+
"DOMAIN_VOLATILITY",
|
|
56
|
+
"SOURCE_RELIABILITY",
|
|
57
|
+
"EmbeddingSimilarity",
|
|
58
|
+
"HeuristicExtractor",
|
|
59
|
+
"LLMExtractor",
|
|
60
|
+
"HeuristicFactExtractor",
|
|
61
|
+
"LLMFactExtractor",
|
|
62
|
+
"escalation_score",
|
|
63
|
+
"retrieval_score",
|
|
64
|
+
"staleness",
|
|
65
|
+
"protection_weight",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
__version__ = "0.1.0"
|
voltmem/client.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Product-facing API — Mem0-shaped surface over MemoryLayer.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Optional, Union
|
|
9
|
+
|
|
10
|
+
from .embeddings import EmbeddingSimilarity
|
|
11
|
+
from .extract import HeuristicFactExtractor, LLMFactExtractor
|
|
12
|
+
from .memory import MemoryLayer, RetrieveResult, WriteResult
|
|
13
|
+
|
|
14
|
+
Message = dict[str, str]
|
|
15
|
+
AddInput = Union[str, Message, list[Message]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_memory(
|
|
19
|
+
db_path: str | Path = "voltmem.db",
|
|
20
|
+
user_id: str = "default",
|
|
21
|
+
*,
|
|
22
|
+
embeddings: bool = True,
|
|
23
|
+
verbose: bool = False,
|
|
24
|
+
llm_extract: bool = False,
|
|
25
|
+
llm_domain: bool = False,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> "Memory":
|
|
28
|
+
"""Create a production-ready memory instance with sensible defaults.
|
|
29
|
+
|
|
30
|
+
Auto-detects an embedding backend when ``embeddings=True``
|
|
31
|
+
(sentence-transformers → Ollama → offline hashing fallback).
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
llm_extract : bool
|
|
36
|
+
Use Ollama to extract atomic facts from conversation message lists.
|
|
37
|
+
llm_domain : bool
|
|
38
|
+
Use Ollama for domain classification and contradiction detection
|
|
39
|
+
inside ``remember()`` (passed to ``MemoryLayer`` as ``extractor``).
|
|
40
|
+
"""
|
|
41
|
+
similarity_fn = kwargs.pop("similarity_fn", None)
|
|
42
|
+
if embeddings and similarity_fn is None:
|
|
43
|
+
similarity_fn = EmbeddingSimilarity(verbose=verbose)
|
|
44
|
+
|
|
45
|
+
fact_extractor = LLMFactExtractor() if llm_extract else HeuristicFactExtractor()
|
|
46
|
+
|
|
47
|
+
layer_kwargs = dict(kwargs)
|
|
48
|
+
if llm_domain:
|
|
49
|
+
from .extract import LLMExtractor
|
|
50
|
+
layer_kwargs["extractor"] = LLMExtractor()
|
|
51
|
+
|
|
52
|
+
return Memory(
|
|
53
|
+
user_id=user_id,
|
|
54
|
+
db_path=db_path,
|
|
55
|
+
similarity_fn=similarity_fn,
|
|
56
|
+
fact_extractor=fact_extractor,
|
|
57
|
+
**layer_kwargs,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Memory:
|
|
62
|
+
"""Current-truth memory for LLM agents.
|
|
63
|
+
|
|
64
|
+
Familiar ``add`` / ``search`` surface; VoltMem volatility engine underneath.
|
|
65
|
+
Volatile facts update; stable facts resist corruption; stale volatile memories
|
|
66
|
+
rank lower at retrieval.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
user_id: str = "default",
|
|
72
|
+
db_path: str | Path = ":memory:",
|
|
73
|
+
*,
|
|
74
|
+
layer: Optional[MemoryLayer] = None,
|
|
75
|
+
similarity_fn: Optional[Callable[[str, str], float]] = None,
|
|
76
|
+
fact_extractor: Optional[HeuristicFactExtractor] = None,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
):
|
|
79
|
+
self.user_id = user_id
|
|
80
|
+
self._fact_extractor = fact_extractor or HeuristicFactExtractor()
|
|
81
|
+
if layer is not None:
|
|
82
|
+
self._layer = (
|
|
83
|
+
layer if layer.namespace == user_id else layer.for_user(user_id)
|
|
84
|
+
)
|
|
85
|
+
self._owns_layer = False
|
|
86
|
+
else:
|
|
87
|
+
self._layer = MemoryLayer(
|
|
88
|
+
db_path,
|
|
89
|
+
similarity_fn=similarity_fn,
|
|
90
|
+
namespace=user_id,
|
|
91
|
+
**kwargs,
|
|
92
|
+
)
|
|
93
|
+
self._owns_layer = True
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def layer(self) -> MemoryLayer:
|
|
97
|
+
"""Low-level ``MemoryLayer`` for advanced control."""
|
|
98
|
+
return self._layer
|
|
99
|
+
|
|
100
|
+
def add(
|
|
101
|
+
self,
|
|
102
|
+
data: AddInput,
|
|
103
|
+
*,
|
|
104
|
+
source: str = "explicit_statement",
|
|
105
|
+
extract: bool | None = None,
|
|
106
|
+
) -> Union[dict[str, Any], list[dict[str, Any]]]:
|
|
107
|
+
"""Store a fact or conversation turn(s).
|
|
108
|
+
|
|
109
|
+
Accepts a plain string, one message dict ``{"role": ..., "content": ...}``,
|
|
110
|
+
or a list of message dicts. For message lists, ``extract=True`` (default)
|
|
111
|
+
pulls atomic user facts before storing.
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(data, str):
|
|
114
|
+
return self._format_write(self._layer.remember(data, source=source))
|
|
115
|
+
if isinstance(data, dict):
|
|
116
|
+
return self._add_message(data, source=source)
|
|
117
|
+
if isinstance(data, list):
|
|
118
|
+
if not data:
|
|
119
|
+
return []
|
|
120
|
+
do_extract = extract if extract is not None else True
|
|
121
|
+
if do_extract:
|
|
122
|
+
return self._add_extracted_facts(data, source=source)
|
|
123
|
+
out = []
|
|
124
|
+
for msg in data:
|
|
125
|
+
if not isinstance(msg, dict):
|
|
126
|
+
raise TypeError("each message must be a dict with role/content")
|
|
127
|
+
result = self._add_message(msg, source=source)
|
|
128
|
+
if result is not None:
|
|
129
|
+
out.append(result)
|
|
130
|
+
return out
|
|
131
|
+
raise TypeError("add() expects str, message dict, or list of message dicts")
|
|
132
|
+
|
|
133
|
+
def search(
|
|
134
|
+
self,
|
|
135
|
+
query: str,
|
|
136
|
+
*,
|
|
137
|
+
limit: int = 5,
|
|
138
|
+
min_score: float = 0.0,
|
|
139
|
+
) -> list[dict[str, Any]]:
|
|
140
|
+
"""Retrieve current-truth memories ranked by relevance and freshness."""
|
|
141
|
+
result = self._layer.retrieve(
|
|
142
|
+
query, top_k=limit, min_score=min_score)
|
|
143
|
+
return self._format_retrieve(result)
|
|
144
|
+
|
|
145
|
+
def get_all(self) -> list[dict[str, Any]]:
|
|
146
|
+
"""Return all active memories for this user."""
|
|
147
|
+
return [
|
|
148
|
+
self._format_item(item)
|
|
149
|
+
for item in self._layer._active()
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
def get(self, memory_id: str) -> Optional[dict[str, Any]]:
|
|
153
|
+
"""Return one memory by id, or None."""
|
|
154
|
+
info = self._layer.inspect(memory_id)
|
|
155
|
+
if info.get("error"):
|
|
156
|
+
return None
|
|
157
|
+
return info
|
|
158
|
+
|
|
159
|
+
def delete(self, memory_id: str) -> bool:
|
|
160
|
+
"""Remove a memory by id. Returns True if deleted."""
|
|
161
|
+
item = self._layer._store.get(memory_id)
|
|
162
|
+
if not item or item.namespace != self.user_id:
|
|
163
|
+
return False
|
|
164
|
+
self._layer._store.delete(memory_id, namespace=self.user_id)
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def clear(self) -> None:
|
|
168
|
+
"""Remove all memories for this user."""
|
|
169
|
+
self._layer.clear()
|
|
170
|
+
|
|
171
|
+
def summary(self) -> dict[str, Any]:
|
|
172
|
+
return self._layer.summary()
|
|
173
|
+
|
|
174
|
+
def close(self) -> None:
|
|
175
|
+
if self._owns_layer:
|
|
176
|
+
self._layer.close()
|
|
177
|
+
|
|
178
|
+
def __enter__(self) -> "Memory":
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def __exit__(self, *_: Any) -> None:
|
|
182
|
+
self.close()
|
|
183
|
+
|
|
184
|
+
# ── helpers ───────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
def _add_extracted_facts(
|
|
187
|
+
self, messages: list[Message], *, source: str
|
|
188
|
+
) -> list[dict[str, Any]]:
|
|
189
|
+
facts = self._fact_extractor.extract(messages)
|
|
190
|
+
out: list[dict[str, Any]] = []
|
|
191
|
+
for fact in facts:
|
|
192
|
+
src = fact.source or source
|
|
193
|
+
if fact.domain:
|
|
194
|
+
result = self._layer.remember(
|
|
195
|
+
fact.content, domain=fact.domain, source=src)
|
|
196
|
+
else:
|
|
197
|
+
result = self._layer.remember(fact.content, source=src)
|
|
198
|
+
out.append(self._format_write(result))
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
def _add_message(
|
|
202
|
+
self, msg: Message, *, source: str
|
|
203
|
+
) -> Optional[dict[str, Any]]:
|
|
204
|
+
role = msg.get("role", "user")
|
|
205
|
+
content = str(msg.get("content", "")).strip()
|
|
206
|
+
if not content:
|
|
207
|
+
return None
|
|
208
|
+
src = source if role == "user" else "assistant_response"
|
|
209
|
+
return self._format_write(self._layer.remember(content, source=src))
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _format_write(result: WriteResult) -> dict[str, Any]:
|
|
213
|
+
item = result.item
|
|
214
|
+
return {
|
|
215
|
+
"id": item.id,
|
|
216
|
+
"memory": item.content,
|
|
217
|
+
"action": result.action,
|
|
218
|
+
"domain": item.domain,
|
|
219
|
+
"detail": result.detail,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _format_item(item: Any) -> dict[str, Any]:
|
|
224
|
+
return {
|
|
225
|
+
"id": item.id,
|
|
226
|
+
"memory": item.content,
|
|
227
|
+
"domain": item.domain,
|
|
228
|
+
"source": item.source,
|
|
229
|
+
"created_at": item.created_at,
|
|
230
|
+
"last_confirmed_at": item.last_confirmed_at,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def _format_retrieve(self, result: RetrieveResult) -> list[dict[str, Any]]:
|
|
234
|
+
out = []
|
|
235
|
+
for item, score in zip(result.items, result.scores):
|
|
236
|
+
row = self._format_item(item)
|
|
237
|
+
row["score"] = round(score, 4)
|
|
238
|
+
out.append(row)
|
|
239
|
+
return out
|
voltmem/domains.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Domain volatility priors.
|
|
3
|
+
|
|
4
|
+
V_d is the expected rate of change for a given memory domain.
|
|
5
|
+
Higher = more volatile = weaker protection = lower mismatch threshold
|
|
6
|
+
to trigger an audit/update.
|
|
7
|
+
|
|
8
|
+
Scale: 0.0 (never changes) → 1.0 (changes very fast).
|
|
9
|
+
These are defaults; callers can override per-item or register custom domains.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
# ── built-in domain priors ────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
DOMAIN_VOLATILITY: dict[str, float] = {
|
|
18
|
+
# Slow-changing — protect hard
|
|
19
|
+
"personality_trait": 0.05, # how the user fundamentally operates
|
|
20
|
+
"core_preference": 0.08, # deep aesthetic / communication preferences
|
|
21
|
+
"biographical": 0.10, # birthplace, native language, background
|
|
22
|
+
"long_term_goal": 0.15, # career direction, life goals
|
|
23
|
+
|
|
24
|
+
# Medium — moderate protection
|
|
25
|
+
"professional_context": 0.30, # job, company, role (changes every few years)
|
|
26
|
+
"skill": 0.25, # competencies the user has
|
|
27
|
+
"relationship": 0.35, # people they work with / are close to
|
|
28
|
+
|
|
29
|
+
# Fast-changing — hold loosely
|
|
30
|
+
"current_project": 0.55, # what they're working on right now
|
|
31
|
+
"stated_preference": 0.45, # preferences stated in this period
|
|
32
|
+
"opinion": 0.50, # views that may shift
|
|
33
|
+
"location": 0.60, # where they are / living situation
|
|
34
|
+
"emotional_context": 0.80, # current mood, stress level
|
|
35
|
+
"current_task": 0.90, # immediate to-do
|
|
36
|
+
"transient_fact": 0.95, # anything clearly moment-specific
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Domains that usually hold a single "current truth" slot (mood, city, task).
|
|
40
|
+
# remember() uses a lower in-slot linking threshold for these.
|
|
41
|
+
SLOT_DOMAINS: frozenset[str] = frozenset({
|
|
42
|
+
"emotional_context",
|
|
43
|
+
"location",
|
|
44
|
+
"current_task",
|
|
45
|
+
"transient_fact",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Related domains searched together when linking a new statement to existing
|
|
49
|
+
# memories (e.g. paraphrased prefs split across classifiers).
|
|
50
|
+
DOMAIN_SIBLINGS: dict[str, frozenset[str]] = {
|
|
51
|
+
"core_preference": frozenset({"core_preference", "stated_preference"}),
|
|
52
|
+
"stated_preference": frozenset({"core_preference", "stated_preference"}),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Minimum semantic overlap to link a lone item in a volatile slot when the
|
|
56
|
+
# volatility-scaled threshold is not quite met.
|
|
57
|
+
SLOT_LINK_FLOOR: float = 0.30
|
|
58
|
+
|
|
59
|
+
# Source reliability weights (R_t in the escalation equation)
|
|
60
|
+
# Higher = more trustworthy signal for both write and mismatch detection
|
|
61
|
+
SOURCE_RELIABILITY: dict[str, float] = {
|
|
62
|
+
"explicit_statement": 1.0, # user directly stated it
|
|
63
|
+
"repeated_confirmation":1.2, # confirmed across multiple turns
|
|
64
|
+
"strong_inference": 0.7, # clearly implied
|
|
65
|
+
"weak_inference": 0.4, # loosely inferred from behaviour
|
|
66
|
+
"system_generated": 0.3, # injected by the wrapping system
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class MemoryItem:
|
|
72
|
+
"""A single unit of persistent memory."""
|
|
73
|
+
id: str
|
|
74
|
+
content: str # the actual stored fact/preference
|
|
75
|
+
domain: str # one of DOMAIN_VOLATILITY keys
|
|
76
|
+
source: str # one of SOURCE_RELIABILITY keys
|
|
77
|
+
namespace: str = "default" # tenant/user isolation key
|
|
78
|
+
|
|
79
|
+
# Equation terms — updated over time
|
|
80
|
+
repetition_count: int = 1 # C: how many times confirmed
|
|
81
|
+
volatility_ema: float = -1.0 # V_d EMA; -1 means use domain prior
|
|
82
|
+
mismatch_count: int = 0 # cumulative mismatch events
|
|
83
|
+
goal_delta: float = 0.0 # G_t: positive = helps goal, neg = hurts
|
|
84
|
+
|
|
85
|
+
# Timestamps (unix seconds)
|
|
86
|
+
created_at: float = 0.0
|
|
87
|
+
last_confirmed_at:float = 0.0
|
|
88
|
+
last_audited_at: float = 0.0
|
|
89
|
+
|
|
90
|
+
# Metadata
|
|
91
|
+
tags: list[str] = field(default_factory=list)
|
|
92
|
+
superseded_by: Optional[str] = None # id of item that replaced this one
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_active(self) -> bool:
|
|
96
|
+
return self.superseded_by is None
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def effective_volatility(self) -> float:
|
|
100
|
+
if self.volatility_ema >= 0:
|
|
101
|
+
return self.volatility_ema
|
|
102
|
+
return DOMAIN_VOLATILITY.get(self.domain, 0.5)
|
voltmem/embeddings.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pluggable embedding-based similarity for VoltMem retrieval.
|
|
3
|
+
==========================================================
|
|
4
|
+
|
|
5
|
+
VoltMem's contribution is the volatility/freshness weighting applied *on top of*
|
|
6
|
+
a semantic-similarity signal. The core library ships with a dependency-free
|
|
7
|
+
keyword scorer; this module provides a drop-in embedding scorer for real semantic
|
|
8
|
+
retrieval:
|
|
9
|
+
|
|
10
|
+
from voltmem import MemoryLayer
|
|
11
|
+
from voltmem.embeddings import EmbeddingSimilarity
|
|
12
|
+
|
|
13
|
+
sim = EmbeddingSimilarity() # auto-detects the best available backend
|
|
14
|
+
mem = MemoryLayer(":memory:", similarity_fn=sim)
|
|
15
|
+
|
|
16
|
+
Backend auto-detection (first that works wins), overridable via backend=...:
|
|
17
|
+
|
|
18
|
+
1. "sentence-transformers" — local, high quality (all-MiniLM-L6-v2 by default)
|
|
19
|
+
2. "ollama" — local daemon at http://localhost:11434
|
|
20
|
+
(nomic-embed-text by default)
|
|
21
|
+
3. "hashing" — deterministic, dependency-free fallback so the
|
|
22
|
+
library and benchmarks ALWAYS run offline. Lower
|
|
23
|
+
quality (bag-of-hashed-tokens), but reproducible.
|
|
24
|
+
|
|
25
|
+
The returned callable maps a (query, content) pair to a similarity in [0, 1]
|
|
26
|
+
(cosine similarity rescaled from [-1, 1]). Embeddings are cached per text.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import math
|
|
34
|
+
import os
|
|
35
|
+
import time
|
|
36
|
+
import urllib.error
|
|
37
|
+
import urllib.request
|
|
38
|
+
from typing import Callable, Optional
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
42
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
43
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
44
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
45
|
+
if na == 0.0 or nb == 0.0:
|
|
46
|
+
return 0.0
|
|
47
|
+
return dot / (na * nb)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EmbeddingSimilarity:
|
|
51
|
+
"""Callable (query, content) -> similarity in [0, 1] backed by embeddings."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
backend: Optional[str] = None,
|
|
56
|
+
model: Optional[str] = None,
|
|
57
|
+
ollama_url: str = "http://localhost:11434",
|
|
58
|
+
dim: int = 256,
|
|
59
|
+
verbose: bool = False,
|
|
60
|
+
):
|
|
61
|
+
self.model = model
|
|
62
|
+
self.ollama_url = ollama_url.rstrip("/")
|
|
63
|
+
self.dim = dim
|
|
64
|
+
self.verbose = verbose
|
|
65
|
+
self._cache: dict[str, list[float]] = {}
|
|
66
|
+
self._embed_fn: Callable[[str], list[float]]
|
|
67
|
+
|
|
68
|
+
# Try backends in order; a backend counts as usable only if it can
|
|
69
|
+
# actually produce an embedding (a running Ollama daemon with no embed
|
|
70
|
+
# model pulled, for example, must NOT win — it 404s on first use).
|
|
71
|
+
candidates = [backend] if backend else [
|
|
72
|
+
"sentence-transformers", "ollama", "hashing"]
|
|
73
|
+
errors = []
|
|
74
|
+
user_model = model
|
|
75
|
+
for cand in candidates:
|
|
76
|
+
try:
|
|
77
|
+
self.model = user_model # reset per attempt; don't leak labels
|
|
78
|
+
fn = self._make_backend(cand)
|
|
79
|
+
_ = fn("voltmem backend probe") # trial embed; raises if broken
|
|
80
|
+
self.backend = cand
|
|
81
|
+
self._embed_fn = fn
|
|
82
|
+
if self.verbose:
|
|
83
|
+
print(f"[EmbeddingSimilarity] using backend={cand} "
|
|
84
|
+
f"model={self.model}")
|
|
85
|
+
return
|
|
86
|
+
except Exception as e: # noqa: BLE001
|
|
87
|
+
errors.append(f"{cand}: {type(e).__name__}: {e}")
|
|
88
|
+
if self.verbose:
|
|
89
|
+
print(f"[EmbeddingSimilarity] backend {cand} unavailable "
|
|
90
|
+
f"({type(e).__name__})")
|
|
91
|
+
# hashing never fails, so we only get here if an explicit backend was bad
|
|
92
|
+
raise RuntimeError("No usable embedding backend. Tried:\n " +
|
|
93
|
+
"\n ".join(errors))
|
|
94
|
+
|
|
95
|
+
def _make_backend(self, backend: str) -> Callable[[str], list[float]]:
|
|
96
|
+
if backend == "sentence-transformers":
|
|
97
|
+
from sentence_transformers import SentenceTransformer
|
|
98
|
+
model_name = self.model or "all-MiniLM-L6-v2"
|
|
99
|
+
st = SentenceTransformer(model_name)
|
|
100
|
+
self.model = model_name
|
|
101
|
+
|
|
102
|
+
def embed(text: str) -> list[float]:
|
|
103
|
+
return [float(x) for x in st.encode(text, normalize_embeddings=False)]
|
|
104
|
+
|
|
105
|
+
return embed
|
|
106
|
+
|
|
107
|
+
if backend == "ollama":
|
|
108
|
+
model_name = self.model or "nomic-embed-text"
|
|
109
|
+
self.model = model_name
|
|
110
|
+
url = f"{self.ollama_url}/api/embeddings"
|
|
111
|
+
|
|
112
|
+
def embed(text: str) -> list[float]:
|
|
113
|
+
payload = json.dumps({"model": model_name, "prompt": text}).encode()
|
|
114
|
+
req = urllib.request.Request(
|
|
115
|
+
url, data=payload, headers={"Content-Type": "application/json"})
|
|
116
|
+
last_err: Exception | None = None
|
|
117
|
+
for attempt in range(5):
|
|
118
|
+
try:
|
|
119
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
120
|
+
data = json.loads(resp.read().decode())
|
|
121
|
+
return [float(x) for x in data["embedding"]]
|
|
122
|
+
except urllib.error.HTTPError as e:
|
|
123
|
+
last_err = e
|
|
124
|
+
if e.code in (429, 500, 502, 503) and attempt < 4:
|
|
125
|
+
time.sleep(2 ** attempt)
|
|
126
|
+
continue
|
|
127
|
+
raise
|
|
128
|
+
raise last_err # type: ignore[misc]
|
|
129
|
+
|
|
130
|
+
return embed
|
|
131
|
+
|
|
132
|
+
# ── hashing fallback ──────────────────────────────────────────────────
|
|
133
|
+
self.model = self.model or f"hashing-{self.dim}"
|
|
134
|
+
|
|
135
|
+
def embed(text: str) -> list[float]:
|
|
136
|
+
vec = [0.0] * self.dim
|
|
137
|
+
tokens = text.lower().split()
|
|
138
|
+
for tok in tokens:
|
|
139
|
+
h = int(hashlib.md5(tok.encode()).hexdigest(), 16)
|
|
140
|
+
idx = h % self.dim
|
|
141
|
+
sign = 1.0 if (h >> 8) & 1 else -1.0
|
|
142
|
+
vec[idx] += sign
|
|
143
|
+
return vec
|
|
144
|
+
|
|
145
|
+
return embed
|
|
146
|
+
|
|
147
|
+
# ── public API ────────────────────────────────────────────────────────────
|
|
148
|
+
def embed(self, text: str) -> list[float]:
|
|
149
|
+
if text not in self._cache:
|
|
150
|
+
self._cache[text] = self._embed_fn(text)
|
|
151
|
+
return self._cache[text]
|
|
152
|
+
|
|
153
|
+
def __call__(self, query: str, content: str) -> float:
|
|
154
|
+
if not query or not content:
|
|
155
|
+
return 0.0
|
|
156
|
+
cos = _cosine(self.embed(query), self.embed(content))
|
|
157
|
+
# Return clamped raw cosine. We deliberately do NOT rescale [-1,1]->[0,1]:
|
|
158
|
+
# that compresses the dynamic range and collapses the gap between
|
|
159
|
+
# "related" and "unrelated" (which breaks similarity thresholds used by
|
|
160
|
+
# remember()). Clamping negatives to 0 is fine for retrieval and matching.
|
|
161
|
+
return max(0.0, min(1.0, cos))
|