icelake 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.
- icelake/__init__.py +172 -0
- icelake/_json.py +125 -0
- icelake/adapters/__init__.py +1 -0
- icelake/adapters/embedders/__init__.py +126 -0
- icelake/adapters/embedders/cached.py +74 -0
- icelake/adapters/embedders/local.py +51 -0
- icelake/adapters/in_memory/__init__.py +10 -0
- icelake/adapters/in_memory/queue.py +245 -0
- icelake/adapters/in_memory/store.py +819 -0
- icelake/adapters/in_memory/vectors.py +58 -0
- icelake/adapters/llm_cache.py +51 -0
- icelake/adapters/llm_openai_compat.py +235 -0
- icelake/adapters/llm_openrouter.py +41 -0
- icelake/adapters/meter.py +163 -0
- icelake/adapters/mongo/__init__.py +1108 -0
- icelake/adapters/mongo/mapping.py +269 -0
- icelake/adapters/mongo/queue.py +300 -0
- icelake/adapters/mongo/vectors.py +102 -0
- icelake/adapters/sqlite/connection.py +311 -0
- icelake/adapters/sqlite/llm_cache.py +40 -0
- icelake/adapters/sqlite/queue.py +315 -0
- icelake/adapters/sqlite/store.py +68 -0
- icelake/adapters/sqlite/store_facts.py +827 -0
- icelake/adapters/sqlite/store_graph.py +465 -0
- icelake/adapters/sqlite/vectors.py +121 -0
- icelake/api/classify.py +85 -0
- icelake/api/client.py +809 -0
- icelake/api/events.py +77 -0
- icelake/api/facts_api.py +350 -0
- icelake/api/groups.py +274 -0
- icelake/config.py +321 -0
- icelake/consolidation/service.py +162 -0
- icelake/errors.py +72 -0
- icelake/graph/__init__.py +5 -0
- icelake/graph/relations.py +88 -0
- icelake/graph/traversal.py +102 -0
- icelake/graph/writes.py +213 -0
- icelake/identity/__init__.py +6 -0
- icelake/identity/aliases.py +162 -0
- icelake/identity/guards.py +63 -0
- icelake/identity/resolver.py +96 -0
- icelake/ids.py +27 -0
- icelake/ingest/__init__.py +5 -0
- icelake/ingest/context_builder.py +60 -0
- icelake/ingest/executor.py +292 -0
- icelake/ingest/extraction.py +149 -0
- icelake/ingest/gates.py +186 -0
- icelake/ingest/pipeline.py +684 -0
- icelake/ingest/reconcile.py +269 -0
- icelake/ingest/roster.py +98 -0
- icelake/integrations/__init__.py +5 -0
- icelake/integrations/discord_py.py +166 -0
- icelake/lifecycle/__init__.py +13 -0
- icelake/lifecycle/maintenance.py +92 -0
- icelake/lifecycle/prune.py +50 -0
- icelake/lifecycle/strength.py +54 -0
- icelake/lifecycle/tiers.py +86 -0
- icelake/models/__init__.py +117 -0
- icelake/models/admin.py +96 -0
- icelake/models/common.py +46 -0
- icelake/models/events.py +137 -0
- icelake/models/facts.py +166 -0
- icelake/models/graph.py +111 -0
- icelake/models/identity.py +75 -0
- icelake/models/operations.py +119 -0
- icelake/models/retrieval.py +175 -0
- icelake/ports/__init__.py +39 -0
- icelake/ports/clock.py +51 -0
- icelake/ports/llm.py +103 -0
- icelake/ports/queue.py +133 -0
- icelake/ports/store.py +351 -0
- icelake/ports/vectors.py +64 -0
- icelake/prompts/__init__.py +1 -0
- icelake/prompts/extraction.py +105 -0
- icelake/py.typed +0 -0
- icelake/retrieval/__init__.py +5 -0
- icelake/retrieval/channels.py +220 -0
- icelake/retrieval/injection.py +171 -0
- icelake/retrieval/service.py +388 -0
- icelake/scoring/__init__.py +5 -0
- icelake/scoring/fusion.py +105 -0
- icelake/structured.py +75 -0
- icelake-0.1.0.dist-info/METADATA +513 -0
- icelake-0.1.0.dist-info/RECORD +86 -0
- icelake-0.1.0.dist-info/WHEEL +4 -0
- icelake-0.1.0.dist-info/licenses/LICENSE +21 -0
icelake/__init__.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""icelake: accurate, scalable agentic memory for Discord bots.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from icelake import DiscordMemory, MemoryConfig, MessageEvent
|
|
6
|
+
|
|
7
|
+
memory = DiscordMemory(MemoryConfig(
|
|
8
|
+
storage="sqlite:///memory.db",
|
|
9
|
+
llm="openai://$KEY@openrouter.ai/api/v1?model=google/gemini-2.5-flash",
|
|
10
|
+
))
|
|
11
|
+
async with memory:
|
|
12
|
+
await memory.observe(event)
|
|
13
|
+
ctx = await memory.prompt_context(guild_id=g, asker_id=u, text=msg)
|
|
14
|
+
system_prompt += ctx.injection_block
|
|
15
|
+
|
|
16
|
+
See docs/API.md for the complete consumer contract and docs/PLAN.md for design.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from icelake.api.classify import CommandAction, UserMemoryCommand
|
|
20
|
+
from icelake.api.client import DiscordMemory
|
|
21
|
+
from icelake.config import (
|
|
22
|
+
BatchingConfig,
|
|
23
|
+
BudgetsConfig,
|
|
24
|
+
EmbeddingsConfig,
|
|
25
|
+
ExtractionConfig,
|
|
26
|
+
LifecycleConfig,
|
|
27
|
+
LlmConfig,
|
|
28
|
+
MemoryConfig,
|
|
29
|
+
MeterConfig,
|
|
30
|
+
ObserveConfig,
|
|
31
|
+
PrivacyConfig,
|
|
32
|
+
RetrievalConfig,
|
|
33
|
+
StorageConfig,
|
|
34
|
+
WorkersConfig,
|
|
35
|
+
)
|
|
36
|
+
from icelake.errors import (
|
|
37
|
+
BudgetExceededError,
|
|
38
|
+
ConfigError,
|
|
39
|
+
DiscordMemoryError,
|
|
40
|
+
FactNotFoundError,
|
|
41
|
+
IdentityAmbiguousError,
|
|
42
|
+
LlmCapabilityError,
|
|
43
|
+
SchemaValidationError,
|
|
44
|
+
StorageUnavailableError,
|
|
45
|
+
StructuredOutputError,
|
|
46
|
+
SubjectNotAllowedError,
|
|
47
|
+
WorkerNotRunningError,
|
|
48
|
+
)
|
|
49
|
+
from icelake.models import (
|
|
50
|
+
CHANNELS_ALL,
|
|
51
|
+
CHANNELS_DEFAULT,
|
|
52
|
+
CHANNELS_DISCOVERY,
|
|
53
|
+
Attribution,
|
|
54
|
+
AttributionType,
|
|
55
|
+
ChannelName,
|
|
56
|
+
Citation,
|
|
57
|
+
EdgeKind,
|
|
58
|
+
EntityRecord,
|
|
59
|
+
FactCategory,
|
|
60
|
+
FactHistoryEntry,
|
|
61
|
+
FactRecord,
|
|
62
|
+
GuildStats,
|
|
63
|
+
HealthReport,
|
|
64
|
+
IgnoreReason,
|
|
65
|
+
MemoryExport,
|
|
66
|
+
MemoryTier,
|
|
67
|
+
MessageEvent,
|
|
68
|
+
NeighborInfo,
|
|
69
|
+
NodeType,
|
|
70
|
+
ObserveReceipt,
|
|
71
|
+
ObserveStatus,
|
|
72
|
+
Polarity,
|
|
73
|
+
PromptContext,
|
|
74
|
+
PurgeReport,
|
|
75
|
+
RecallQuery,
|
|
76
|
+
RecallResult,
|
|
77
|
+
RecallWarning,
|
|
78
|
+
RejectReason,
|
|
79
|
+
RelationEdge,
|
|
80
|
+
Resolution,
|
|
81
|
+
ResolvedCandidate,
|
|
82
|
+
Scope,
|
|
83
|
+
ScoreComponents,
|
|
84
|
+
ScoredFact,
|
|
85
|
+
SourceRef,
|
|
86
|
+
StanceSummary,
|
|
87
|
+
channels,
|
|
88
|
+
)
|
|
89
|
+
from icelake.models.events import (
|
|
90
|
+
BatchCompleted,
|
|
91
|
+
BudgetWarning,
|
|
92
|
+
ComponentDegraded,
|
|
93
|
+
ExtractionFailed,
|
|
94
|
+
FactCommitted,
|
|
95
|
+
FactSupersededEvent,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
__version__ = "0.1.0"
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"CHANNELS_ALL",
|
|
102
|
+
"CHANNELS_DEFAULT",
|
|
103
|
+
"CHANNELS_DISCOVERY",
|
|
104
|
+
"Attribution",
|
|
105
|
+
"AttributionType",
|
|
106
|
+
"BatchCompleted",
|
|
107
|
+
"BatchingConfig",
|
|
108
|
+
"BudgetExceededError",
|
|
109
|
+
"BudgetWarning",
|
|
110
|
+
"BudgetsConfig",
|
|
111
|
+
"ChannelName",
|
|
112
|
+
"Citation",
|
|
113
|
+
"CommandAction",
|
|
114
|
+
"ComponentDegraded",
|
|
115
|
+
"ConfigError",
|
|
116
|
+
"DiscordMemory",
|
|
117
|
+
"DiscordMemoryError",
|
|
118
|
+
"EdgeKind",
|
|
119
|
+
"EmbeddingsConfig",
|
|
120
|
+
"EntityRecord",
|
|
121
|
+
"ExtractionConfig",
|
|
122
|
+
"ExtractionFailed",
|
|
123
|
+
"FactCategory",
|
|
124
|
+
"FactCommitted",
|
|
125
|
+
"FactHistoryEntry",
|
|
126
|
+
"FactNotFoundError",
|
|
127
|
+
"FactRecord",
|
|
128
|
+
"FactSupersededEvent",
|
|
129
|
+
"GuildStats",
|
|
130
|
+
"HealthReport",
|
|
131
|
+
"IdentityAmbiguousError",
|
|
132
|
+
"IgnoreReason",
|
|
133
|
+
"LifecycleConfig",
|
|
134
|
+
"LlmCapabilityError",
|
|
135
|
+
"LlmConfig",
|
|
136
|
+
"MemoryConfig",
|
|
137
|
+
"MemoryExport",
|
|
138
|
+
"MemoryTier",
|
|
139
|
+
"MessageEvent",
|
|
140
|
+
"MeterConfig",
|
|
141
|
+
"NeighborInfo",
|
|
142
|
+
"NodeType",
|
|
143
|
+
"ObserveConfig",
|
|
144
|
+
"ObserveReceipt",
|
|
145
|
+
"ObserveStatus",
|
|
146
|
+
"Polarity",
|
|
147
|
+
"PrivacyConfig",
|
|
148
|
+
"PromptContext",
|
|
149
|
+
"PurgeReport",
|
|
150
|
+
"RecallQuery",
|
|
151
|
+
"RecallResult",
|
|
152
|
+
"RecallWarning",
|
|
153
|
+
"RejectReason",
|
|
154
|
+
"RelationEdge",
|
|
155
|
+
"Resolution",
|
|
156
|
+
"ResolvedCandidate",
|
|
157
|
+
"RetrievalConfig",
|
|
158
|
+
"SchemaValidationError",
|
|
159
|
+
"Scope",
|
|
160
|
+
"ScoreComponents",
|
|
161
|
+
"ScoredFact",
|
|
162
|
+
"SourceRef",
|
|
163
|
+
"StanceSummary",
|
|
164
|
+
"StorageConfig",
|
|
165
|
+
"StorageUnavailableError",
|
|
166
|
+
"StructuredOutputError",
|
|
167
|
+
"SubjectNotAllowedError",
|
|
168
|
+
"UserMemoryCommand",
|
|
169
|
+
"WorkerNotRunningError",
|
|
170
|
+
"WorkersConfig",
|
|
171
|
+
"channels",
|
|
172
|
+
]
|
icelake/_json.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""LLM JSON extraction with staged repair (ported from bot utils/llm_json.py).
|
|
2
|
+
|
|
3
|
+
Ladder: fence strip -> balanced-object scan (string/escape-aware) -> truncation
|
|
4
|
+
repair (closes open strings + bracket stack) -> candidate slicing. A truncated
|
|
5
|
+
extraction response should lose nothing recoverable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
_FENCES: Final = (("```json", "```"), ("```JSON", "```"), ("```", "```"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _strip_fence(text: str) -> str:
|
|
17
|
+
stripped = text.strip()
|
|
18
|
+
for opening, closing in _FENCES:
|
|
19
|
+
if stripped.startswith(opening):
|
|
20
|
+
inner = stripped[len(opening) :]
|
|
21
|
+
end = inner.rfind(closing)
|
|
22
|
+
return (inner[:end] if end != -1 else inner).strip()
|
|
23
|
+
return stripped
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _scan_balanced(text: str, start: int) -> str | None:
|
|
27
|
+
"""Extract one balanced JSON object starting at ``text[start] == '{'``."""
|
|
28
|
+
depth = 0
|
|
29
|
+
in_string = False
|
|
30
|
+
escaped = False
|
|
31
|
+
for index in range(start, len(text)):
|
|
32
|
+
char = text[index]
|
|
33
|
+
if in_string:
|
|
34
|
+
if escaped:
|
|
35
|
+
escaped = False
|
|
36
|
+
elif char == "\\":
|
|
37
|
+
escaped = True
|
|
38
|
+
elif char == '"':
|
|
39
|
+
in_string = False
|
|
40
|
+
continue
|
|
41
|
+
if char == '"':
|
|
42
|
+
in_string = True
|
|
43
|
+
elif char == "{":
|
|
44
|
+
depth += 1
|
|
45
|
+
elif char == "}":
|
|
46
|
+
depth -= 1
|
|
47
|
+
if depth == 0:
|
|
48
|
+
return text[start : index + 1]
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _close_open_constructs(candidate: str) -> str:
|
|
53
|
+
"""Repair truncated JSON: close an open string literal, then brackets."""
|
|
54
|
+
in_string = False
|
|
55
|
+
escaped = False
|
|
56
|
+
stack: list[str] = []
|
|
57
|
+
for char in candidate:
|
|
58
|
+
if in_string:
|
|
59
|
+
if escaped:
|
|
60
|
+
escaped = False
|
|
61
|
+
elif char == "\\":
|
|
62
|
+
escaped = True
|
|
63
|
+
elif char == '"':
|
|
64
|
+
in_string = False
|
|
65
|
+
continue
|
|
66
|
+
if char == '"':
|
|
67
|
+
in_string = True
|
|
68
|
+
elif char == "{":
|
|
69
|
+
stack.append("}")
|
|
70
|
+
elif char == "[":
|
|
71
|
+
stack.append("]")
|
|
72
|
+
elif char in {"}", "]"} and stack and stack[-1] == char:
|
|
73
|
+
stack.pop()
|
|
74
|
+
repaired = candidate
|
|
75
|
+
if escaped:
|
|
76
|
+
repaired += '"'
|
|
77
|
+
if in_string:
|
|
78
|
+
repaired += '"'
|
|
79
|
+
while stack:
|
|
80
|
+
repaired += stack.pop()
|
|
81
|
+
return repaired
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_json_object(text: str) -> dict[str, object]:
|
|
85
|
+
"""Parse the first JSON object in ``text`` with staged repair.
|
|
86
|
+
|
|
87
|
+
Raises ``ValueError`` only when no object can be recovered at all.
|
|
88
|
+
"""
|
|
89
|
+
cleaned = _strip_fence(text)
|
|
90
|
+
|
|
91
|
+
direct_start = cleaned.find("{")
|
|
92
|
+
direct_end = cleaned.rfind("}")
|
|
93
|
+
if direct_start != -1 and direct_end > direct_start:
|
|
94
|
+
try:
|
|
95
|
+
parsed = json.loads(cleaned[direct_start : direct_end + 1])
|
|
96
|
+
if isinstance(parsed, dict):
|
|
97
|
+
return parsed
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
scanner_start = max(direct_start, 0)
|
|
102
|
+
while scanner_start != -1:
|
|
103
|
+
balanced = _scan_balanced(cleaned, scanner_start)
|
|
104
|
+
if balanced is not None:
|
|
105
|
+
try:
|
|
106
|
+
parsed = json.loads(balanced)
|
|
107
|
+
if isinstance(parsed, dict):
|
|
108
|
+
return parsed
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
pass
|
|
111
|
+
scanner_start = cleaned.find("{", scanner_start + 1)
|
|
112
|
+
|
|
113
|
+
if direct_start != -1:
|
|
114
|
+
truncated = _close_open_constructs(cleaned[direct_start:])
|
|
115
|
+
try:
|
|
116
|
+
parsed = json.loads(truncated)
|
|
117
|
+
if isinstance(parsed, dict):
|
|
118
|
+
return parsed
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
raise ValueError("no recoverable JSON object in response")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["parse_json_object"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapter packages. Only adapters import vendor SDKs (import-linter enforced)."""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Embedding adapters: deterministic hashing (default) and OpenAI-compatible API.
|
|
2
|
+
|
|
3
|
+
The hashing embedder is the zero-dependency default: signed feature-hashing over word
|
|
4
|
+
and char n-grams, L2-normalized. Deterministic, fast, free — good lexical-ish
|
|
5
|
+
semantics for small deployments and perfectly reproducible in tests. Swap to a real
|
|
6
|
+
model via config without touching any other code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import math
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from icelake.config import EmbeddingsConfig, EmbeddingsProvider
|
|
19
|
+
from icelake.errors import ConfigError
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from icelake.ports.llm import Embedder
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HashingEmbedder:
|
|
26
|
+
"""Signed feature-hashing embedder; stable across processes."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, dimensions: int = 256) -> None:
|
|
29
|
+
if dimensions < 32:
|
|
30
|
+
raise ConfigError("hashing embedder needs at least 32 dimensions")
|
|
31
|
+
self._dimensions = dimensions
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def dimensions(self) -> int:
|
|
35
|
+
return self._dimensions
|
|
36
|
+
|
|
37
|
+
async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
|
|
38
|
+
return tuple(self._embed_one(text) for text in texts)
|
|
39
|
+
|
|
40
|
+
def _embed_one(self, text: str) -> tuple[float, ...]:
|
|
41
|
+
vector = [0.0] * self._dimensions
|
|
42
|
+
lowered = " ".join(text.lower().split())
|
|
43
|
+
tokens = lowered.split()
|
|
44
|
+
for token in tokens:
|
|
45
|
+
self._add_feature(vector, token, 1.0)
|
|
46
|
+
for i in range(len(tokens) - 1):
|
|
47
|
+
bigram = f"{tokens[i]}_{tokens[i + 1]}"
|
|
48
|
+
self._add_feature(vector, bigram, 0.7)
|
|
49
|
+
for i in range(max(0, len(lowered) - 3)):
|
|
50
|
+
self._add_feature(vector, "c:" + lowered[i : i + 4], 0.25)
|
|
51
|
+
norm = math.sqrt(sum(v * v for v in vector))
|
|
52
|
+
if norm == 0.0:
|
|
53
|
+
return tuple(vector)
|
|
54
|
+
return tuple(round(v / norm, 8) for v in vector)
|
|
55
|
+
|
|
56
|
+
def _add_feature(self, vector: list[float], feature: str, weight: float) -> None:
|
|
57
|
+
digest = hashlib.md5(feature.encode("utf-8")).digest()
|
|
58
|
+
index = int.from_bytes(digest[:4], "little") % self._dimensions
|
|
59
|
+
sign = 1.0 if digest[4] % 2 == 0 else -1.0
|
|
60
|
+
vector[index] += sign * weight
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OpenAICompatEmbedder:
|
|
64
|
+
"""OpenAI ``/embeddings``-compatible adapter (OpenRouter/OpenAI/vLLM/Ollama)."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, config: EmbeddingsConfig) -> None:
|
|
67
|
+
if not config.base_url or not config.model:
|
|
68
|
+
raise ConfigError("openai embeddings require base_url and model")
|
|
69
|
+
self._config = config
|
|
70
|
+
self._client = httpx.AsyncClient(timeout=30.0)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def dimensions(self) -> int:
|
|
74
|
+
return self._config.dimensions
|
|
75
|
+
|
|
76
|
+
async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
|
|
77
|
+
out: list[tuple[float, ...]] = []
|
|
78
|
+
batch_size = 64
|
|
79
|
+
for start in range(0, len(texts), batch_size):
|
|
80
|
+
chunk = texts[start : start + batch_size]
|
|
81
|
+
response = await self._client.post(
|
|
82
|
+
f"{self._config.base_url}/embeddings",
|
|
83
|
+
headers=self._headers(),
|
|
84
|
+
json={"model": self._config.model, "input": list(chunk)},
|
|
85
|
+
)
|
|
86
|
+
response.raise_for_status()
|
|
87
|
+
payload = response.json()
|
|
88
|
+
data = sorted(payload["data"], key=lambda item: item["index"])
|
|
89
|
+
out.extend(tuple(float(x) for x in item["embedding"]) for item in data)
|
|
90
|
+
return tuple(out)
|
|
91
|
+
|
|
92
|
+
def _headers(self) -> dict[str, str]:
|
|
93
|
+
headers = {"Content-Type": "application/json"}
|
|
94
|
+
if self._config.api_key:
|
|
95
|
+
headers["Authorization"] = f"Bearer {self._config.api_key}"
|
|
96
|
+
return headers
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_embedder(config: EmbeddingsConfig) -> Embedder:
|
|
100
|
+
"""Factory honoring the configured provider with a graceful local fallback."""
|
|
101
|
+
inner = _build_inner(config)
|
|
102
|
+
if config.cache_enabled:
|
|
103
|
+
from icelake.adapters.embedders.cached import CachedEmbedder
|
|
104
|
+
|
|
105
|
+
return CachedEmbedder(inner, max_entries=config.cache_max_entries)
|
|
106
|
+
return inner
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_inner(config: EmbeddingsConfig) -> Embedder:
|
|
110
|
+
if config.provider is EmbeddingsProvider.HASHING:
|
|
111
|
+
return HashingEmbedder(config.dimensions)
|
|
112
|
+
if config.provider is EmbeddingsProvider.OPENAI:
|
|
113
|
+
return OpenAICompatEmbedder(config)
|
|
114
|
+
import importlib.util
|
|
115
|
+
|
|
116
|
+
if importlib.util.find_spec("sentence_transformers") is None:
|
|
117
|
+
raise ConfigError(
|
|
118
|
+
"local embeddings require the 'local-embeddings' extra "
|
|
119
|
+
"(pip install icelake[local-embeddings])",
|
|
120
|
+
)
|
|
121
|
+
from icelake.adapters.embedders.local import LocalEmbedder
|
|
122
|
+
|
|
123
|
+
return LocalEmbedder(config)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["HashingEmbedder", "OpenAICompatEmbedder", "build_embedder"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Embedding cache: content-hash LRU over any inner Embedder.
|
|
2
|
+
|
|
3
|
+
Cuts recall-path latency and API cost when the same or similar texts are
|
|
4
|
+
embedded repeatedly (query-embedding reuse, reconcile collision checks,
|
|
5
|
+
consolidation sanity checks). Thread-safe; bounded by ``max_entries``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import threading
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from icelake.ports.llm import Embedder
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CachedEmbedder:
|
|
19
|
+
"""LRU-cached wrapper implementing the Embedder protocol."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, inner: Embedder, *, max_entries: int = 50_000) -> None:
|
|
22
|
+
self._inner = inner
|
|
23
|
+
self._max_entries = max_entries
|
|
24
|
+
self._cache: OrderedDict[str, tuple[float, ...]] = OrderedDict()
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
self.hits = 0
|
|
27
|
+
self.misses = 0
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def dimensions(self) -> int:
|
|
31
|
+
return self._inner.dimensions
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def hit_rate(self) -> float:
|
|
35
|
+
total = self.hits + self.misses
|
|
36
|
+
return self.hits / total if total else 0.0
|
|
37
|
+
|
|
38
|
+
async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
|
|
39
|
+
keys = [self._key(text) for text in texts]
|
|
40
|
+
with self._lock:
|
|
41
|
+
cached: dict[int, tuple[float, ...]] = {}
|
|
42
|
+
missing_indices: list[int] = []
|
|
43
|
+
for index, key in enumerate(keys):
|
|
44
|
+
if key in self._cache:
|
|
45
|
+
cached[index] = self._cache[key]
|
|
46
|
+
self.hits += 1
|
|
47
|
+
else:
|
|
48
|
+
missing_indices.append(index)
|
|
49
|
+
self.misses += 1
|
|
50
|
+
# LRU touch for hits
|
|
51
|
+
for key in (keys[i] for i in cached):
|
|
52
|
+
self._cache.move_to_end(key)
|
|
53
|
+
|
|
54
|
+
if missing_indices:
|
|
55
|
+
uncached_texts = [texts[i] for i in missing_indices]
|
|
56
|
+
new_vectors = await self._inner.embed(uncached_texts)
|
|
57
|
+
for local_idx, vector in zip(missing_indices, new_vectors, strict=True):
|
|
58
|
+
cached[local_idx] = vector
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._cache[keys[local_idx]] = vector
|
|
61
|
+
self._evict_if_needed()
|
|
62
|
+
|
|
63
|
+
return tuple(cached[i] for i in range(len(texts)))
|
|
64
|
+
|
|
65
|
+
def _evict_if_needed(self) -> None:
|
|
66
|
+
while len(self._cache) > self._max_entries:
|
|
67
|
+
self._cache.popitem(last=False)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _key(text: str) -> str:
|
|
71
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["CachedEmbedder"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Optional sentence-transformers embedder (extra: ``local-embeddings``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
8
|
+
|
|
9
|
+
from icelake.config import EmbeddingsConfig
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Model(Protocol):
|
|
16
|
+
def encode(self, sentences: list[str], show_progress_bar: bool) -> list[list[float]]: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LocalEmbedder:
|
|
20
|
+
"""Runs a local sentence-transformers model off-loop in a single-thread executor."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: EmbeddingsConfig) -> None:
|
|
23
|
+
self._config = config
|
|
24
|
+
self._model: _Model | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def dimensions(self) -> int:
|
|
28
|
+
return self._config.dimensions
|
|
29
|
+
|
|
30
|
+
def _load(self) -> _Model:
|
|
31
|
+
if self._model is None:
|
|
32
|
+
from sentence_transformers import SentenceTransformer
|
|
33
|
+
|
|
34
|
+
name = (self._config.model or "").removeprefix("sentence-transformers/")
|
|
35
|
+
model: Any = SentenceTransformer(name)
|
|
36
|
+
self._model = model
|
|
37
|
+
return self._model
|
|
38
|
+
|
|
39
|
+
async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
|
|
40
|
+
if not texts:
|
|
41
|
+
return ()
|
|
42
|
+
model = await asyncio.to_thread(self._load)
|
|
43
|
+
vectors = await asyncio.to_thread(
|
|
44
|
+
model.encode,
|
|
45
|
+
list(texts),
|
|
46
|
+
False,
|
|
47
|
+
)
|
|
48
|
+
return tuple(tuple(float(x) for x in vector) for vector in vectors)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["LocalEmbedder"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""In-memory adapter package exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from icelake.adapters.in_memory.queue import InMemoryIngestQueue
|
|
6
|
+
from icelake.adapters.in_memory.store import InMemoryStore
|
|
7
|
+
from icelake.adapters.in_memory.vectors import InMemoryVectorIndex
|
|
8
|
+
from icelake.ports.vectors import cosine
|
|
9
|
+
|
|
10
|
+
__all__ = ["InMemoryIngestQueue", "InMemoryStore", "InMemoryVectorIndex", "cosine"]
|