errlore 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.
- errlore/__init__.py +16 -0
- errlore/errmem/__init__.py +21 -0
- errlore/errmem/classifier.py +54 -0
- errlore/errmem/injector.py +156 -0
- errlore/errmem/patterns.py +193 -0
- errlore/errmem/tracker.py +157 -0
- errlore/facade.py +602 -0
- errlore/io/__init__.py +21 -0
- errlore/io/jsonl_index.py +199 -0
- errlore/io/jsonl_writer.py +342 -0
- errlore/io/repair.py +199 -0
- errlore/lessons/__init__.py +16 -0
- errlore/lessons/models.py +129 -0
- errlore/lessons/store.py +495 -0
- errlore/py.typed +0 -0
- errlore/retrieval/__init__.py +66 -0
- errlore/retrieval/backend.py +117 -0
- errlore/retrieval/index.py +288 -0
- errlore/sanitize.py +117 -0
- errlore/trust/__init__.py +11 -0
- errlore/trust/engine.py +602 -0
- errlore-0.1.0.dist-info/METADATA +214 -0
- errlore-0.1.0.dist-info/RECORD +25 -0
- errlore-0.1.0.dist-info/WHEEL +4 -0
- errlore-0.1.0.dist-info/licenses/LICENSE +21 -0
errlore/lessons/store.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Persistent lesson store backed by JSONL via errlore.io.
|
|
2
|
+
|
|
3
|
+
Manages two files:
|
|
4
|
+
data_dir/errors.jsonl -- error events
|
|
5
|
+
data_dir/lessons.jsonl -- extracted lessons (single-record, mutable via atomic_rewrite)
|
|
6
|
+
|
|
7
|
+
Unlike the NEXUS original, lessons are updated *in-place* via atomic_rewrite
|
|
8
|
+
instead of appending new versions. This eliminates dedup ambiguity at read time.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from errlore.io import JSONLWriter
|
|
19
|
+
from errlore.lessons.models import ErrorRecord, Lesson, _utc_now_iso
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from errlore.retrieval import LessonRetriever
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("errlore.lessons")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LessonStore:
|
|
28
|
+
"""Thread-safe lesson store persisted as JSONL.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
data_dir: Directory for errors.jsonl and lessons.jsonl.
|
|
32
|
+
retriever: Optional semantic retriever implementing
|
|
33
|
+
:class:`~errlore.retrieval.LessonRetriever`. When provided,
|
|
34
|
+
:meth:`search_lessons` uses semantic search with automatic
|
|
35
|
+
fallback to word-overlap when no semantic results are found.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
data_dir: Path,
|
|
41
|
+
*,
|
|
42
|
+
retriever: LessonRetriever | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._data_dir = Path(data_dir)
|
|
45
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self._errors_path = self._data_dir / "errors.jsonl"
|
|
47
|
+
self._lessons_path = self._data_dir / "lessons.jsonl"
|
|
48
|
+
# A1: rotation disabled -- these files use ID-based lookups, so all
|
|
49
|
+
# records must remain visible. Growth will be managed by future
|
|
50
|
+
# log compaction.
|
|
51
|
+
self._writer = JSONLWriter(max_bytes=None)
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
self._retriever = retriever
|
|
54
|
+
|
|
55
|
+
if self._retriever is not None:
|
|
56
|
+
self._sync_retriever()
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Retriever sync
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _sync_retriever(self) -> None:
|
|
63
|
+
"""Ensure all existing lessons are indexed in the retriever.
|
|
64
|
+
|
|
65
|
+
Called once during construction. Each ``add`` is idempotent so
|
|
66
|
+
already-indexed lessons are skipped with only a set-lookup cost.
|
|
67
|
+
"""
|
|
68
|
+
if self._retriever is None:
|
|
69
|
+
return
|
|
70
|
+
for lesson in self._read_lessons():
|
|
71
|
+
self._retriever.add(lesson.id, lesson.pattern)
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Errors
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def log_error(
|
|
78
|
+
self,
|
|
79
|
+
model: str,
|
|
80
|
+
task_type: str,
|
|
81
|
+
error_type: str,
|
|
82
|
+
message: str,
|
|
83
|
+
*,
|
|
84
|
+
context: str = "",
|
|
85
|
+
stacktrace: str = "",
|
|
86
|
+
metadata: dict[str, Any] | None = None,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Record an error event.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The generated error ID (12-char hex).
|
|
92
|
+
"""
|
|
93
|
+
record = ErrorRecord(
|
|
94
|
+
model=model,
|
|
95
|
+
task_type=task_type,
|
|
96
|
+
error_type=error_type,
|
|
97
|
+
message=message,
|
|
98
|
+
context=context,
|
|
99
|
+
stacktrace=stacktrace,
|
|
100
|
+
metadata=metadata,
|
|
101
|
+
)
|
|
102
|
+
self._writer.append(self._errors_path, record.to_dict())
|
|
103
|
+
logger.debug("Logged error %s: %s", record.id, message[:80])
|
|
104
|
+
return record.id
|
|
105
|
+
|
|
106
|
+
def resolve_error(
|
|
107
|
+
self,
|
|
108
|
+
error_id: str,
|
|
109
|
+
resolution: str,
|
|
110
|
+
lesson: str | None = None,
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Resolve an error by ID. Optionally extract a lesson automatically.
|
|
113
|
+
|
|
114
|
+
When *lesson* is provided the store creates a new lesson entry with
|
|
115
|
+
confidence 0.8, linking it to the resolved error via ``source_error_id``.
|
|
116
|
+
|
|
117
|
+
The error record is updated in-place (atomic_rewrite).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if the error was found and resolved, False otherwise.
|
|
121
|
+
"""
|
|
122
|
+
# Race-safe: read-modify-write happens under ONE file lock via
|
|
123
|
+
# atomic_update, so errors appended concurrently by other threads
|
|
124
|
+
# or processes are never lost (they land before or after, intact).
|
|
125
|
+
target: ErrorRecord | None = None
|
|
126
|
+
already_resolved = False
|
|
127
|
+
|
|
128
|
+
def _apply(
|
|
129
|
+
entries: list[dict[str, object]],
|
|
130
|
+
) -> list[dict[str, object]] | None:
|
|
131
|
+
nonlocal target, already_resolved
|
|
132
|
+
for entry in entries:
|
|
133
|
+
if entry.get("id") == error_id:
|
|
134
|
+
rec = ErrorRecord.from_dict(entry)
|
|
135
|
+
if rec.resolved:
|
|
136
|
+
already_resolved = True
|
|
137
|
+
target = rec
|
|
138
|
+
return None # abort write, nothing to change
|
|
139
|
+
rec.resolved = True
|
|
140
|
+
rec.resolution = resolution
|
|
141
|
+
target = rec
|
|
142
|
+
entry.update(rec.to_dict())
|
|
143
|
+
return entries
|
|
144
|
+
return None # not found, abort write
|
|
145
|
+
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._writer.atomic_update(self._errors_path, _apply)
|
|
148
|
+
|
|
149
|
+
if target is None:
|
|
150
|
+
logger.debug("resolve_error: error_id=%s not found", error_id)
|
|
151
|
+
return False
|
|
152
|
+
if already_resolved:
|
|
153
|
+
# B11: warn if caller passed a lesson for an already-resolved error.
|
|
154
|
+
if lesson:
|
|
155
|
+
logger.warning(
|
|
156
|
+
"lesson ignored: error %s already resolved", error_id,
|
|
157
|
+
)
|
|
158
|
+
logger.debug("resolve_error: error_id=%s already resolved", error_id)
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
if lesson:
|
|
162
|
+
self.log_lesson(
|
|
163
|
+
pattern=f"{target.error_type}: {target.message}",
|
|
164
|
+
solution=lesson,
|
|
165
|
+
confidence=0.8,
|
|
166
|
+
task_type=target.task_type,
|
|
167
|
+
error_type=target.error_type,
|
|
168
|
+
source_error_id=error_id,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
logger.info("Resolved error %s: %s", error_id, resolution[:80])
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Lessons
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _word_overlap(a: str, b: str) -> float:
|
|
180
|
+
"""Word-level Jaccard-style overlap (|intersection| / max(|A|, |B|))."""
|
|
181
|
+
words_a = set(a.lower().split())
|
|
182
|
+
words_b = set(b.lower().split())
|
|
183
|
+
if not words_a or not words_b:
|
|
184
|
+
return 0.0
|
|
185
|
+
intersection = words_a & words_b
|
|
186
|
+
return len(intersection) / max(len(words_a), len(words_b))
|
|
187
|
+
|
|
188
|
+
def _find_duplicate_lesson(self, pattern: str) -> Lesson | None:
|
|
189
|
+
"""Check recent lessons for exact or fuzzy (>85% word overlap) duplicate."""
|
|
190
|
+
lessons = self._read_lessons()
|
|
191
|
+
normalized = pattern.lower().strip()
|
|
192
|
+
for lesson in lessons:
|
|
193
|
+
existing = lesson.pattern.lower().strip()
|
|
194
|
+
if existing == normalized:
|
|
195
|
+
return lesson
|
|
196
|
+
if self._word_overlap(normalized, existing) > 0.85:
|
|
197
|
+
return lesson
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def log_lesson(
|
|
201
|
+
self,
|
|
202
|
+
pattern: str,
|
|
203
|
+
solution: str,
|
|
204
|
+
*,
|
|
205
|
+
confidence: float = 0.8,
|
|
206
|
+
task_type: str = "",
|
|
207
|
+
error_type: str = "",
|
|
208
|
+
source_error_id: str = "",
|
|
209
|
+
source_errors: list[str] | None = None,
|
|
210
|
+
metadata: dict[str, Any] | None = None,
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Record a lesson with deduplication.
|
|
213
|
+
|
|
214
|
+
Dedup rules (same as NEXUS source):
|
|
215
|
+
- Exact match on pattern (case-insensitive, stripped)
|
|
216
|
+
- Fuzzy match: word overlap > 85%
|
|
217
|
+
|
|
218
|
+
If a duplicate is found the existing lesson ID is returned and
|
|
219
|
+
no new record is written.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Lesson ID (new or existing duplicate).
|
|
223
|
+
"""
|
|
224
|
+
with self._lock:
|
|
225
|
+
existing = self._find_duplicate_lesson(pattern)
|
|
226
|
+
if existing is not None:
|
|
227
|
+
logger.debug(
|
|
228
|
+
"Duplicate lesson skipped: '%s' matches '%s'",
|
|
229
|
+
pattern[:60],
|
|
230
|
+
existing.pattern[:60],
|
|
231
|
+
)
|
|
232
|
+
return existing.id
|
|
233
|
+
|
|
234
|
+
lesson = Lesson(
|
|
235
|
+
pattern=pattern,
|
|
236
|
+
solution=solution,
|
|
237
|
+
confidence=confidence,
|
|
238
|
+
task_type=task_type,
|
|
239
|
+
error_type=error_type,
|
|
240
|
+
source_error_id=source_error_id,
|
|
241
|
+
source_errors=source_errors or [],
|
|
242
|
+
metadata=metadata,
|
|
243
|
+
)
|
|
244
|
+
self._writer.append(self._lessons_path, lesson.to_dict())
|
|
245
|
+
if self._retriever is not None:
|
|
246
|
+
self._retriever.add(lesson.id, lesson.pattern)
|
|
247
|
+
logger.debug("Logged lesson %s: %s", lesson.id, pattern[:80])
|
|
248
|
+
return lesson.id
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Search
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def search_lessons(
|
|
255
|
+
self,
|
|
256
|
+
*,
|
|
257
|
+
query: str = "",
|
|
258
|
+
task_type: str = "",
|
|
259
|
+
error_type: str = "",
|
|
260
|
+
limit: int = 10,
|
|
261
|
+
) -> list[Lesson]:
|
|
262
|
+
"""Search lessons by structured filters and/or fuzzy text query.
|
|
263
|
+
|
|
264
|
+
When a semantic retriever is configured and *query* is non-empty,
|
|
265
|
+
semantic search runs first. If it yields no results (after
|
|
266
|
+
applying any task_type/error_type filters), the method falls back
|
|
267
|
+
to the original word-overlap logic.
|
|
268
|
+
|
|
269
|
+
Filter priority (word-overlap path):
|
|
270
|
+
1. task_type / error_type exact match (both can combine)
|
|
271
|
+
2. query: word overlap > 30%, fallback to substring match
|
|
272
|
+
3. Results sorted by confidence descending
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Up to *limit* lessons.
|
|
276
|
+
"""
|
|
277
|
+
if not query and not task_type and not error_type:
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
# -- Semantic path (retriever + query) ----------------------------
|
|
281
|
+
if self._retriever is not None and query:
|
|
282
|
+
sem = self._semantic_search(query, task_type, error_type, limit)
|
|
283
|
+
if sem:
|
|
284
|
+
return sem
|
|
285
|
+
# Fall through to word-overlap below.
|
|
286
|
+
|
|
287
|
+
# -- Word-overlap path (original logic) ---------------------------
|
|
288
|
+
return self._word_overlap_search(query, task_type, error_type, limit)
|
|
289
|
+
|
|
290
|
+
def _semantic_search(
|
|
291
|
+
self,
|
|
292
|
+
query: str,
|
|
293
|
+
task_type: str,
|
|
294
|
+
error_type: str,
|
|
295
|
+
limit: int,
|
|
296
|
+
) -> list[Lesson]:
|
|
297
|
+
"""Run semantic search via the configured retriever.
|
|
298
|
+
|
|
299
|
+
Returns an empty list when no relevant lessons are found so the
|
|
300
|
+
caller can fall back to word-overlap.
|
|
301
|
+
"""
|
|
302
|
+
assert self._retriever is not None
|
|
303
|
+
|
|
304
|
+
# Fetch more candidates than needed to allow for post-filtering.
|
|
305
|
+
candidates = self._retriever.search(query, k=limit * 3)
|
|
306
|
+
if not candidates:
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
score_map: dict[str, float] = {cid: score for cid, score in candidates}
|
|
310
|
+
candidate_ids = set(score_map)
|
|
311
|
+
|
|
312
|
+
all_lessons = self._read_lessons()
|
|
313
|
+
matched: list[Lesson] = []
|
|
314
|
+
for lesson in all_lessons:
|
|
315
|
+
if lesson.id not in candidate_ids:
|
|
316
|
+
continue
|
|
317
|
+
if task_type and lesson.task_type != task_type:
|
|
318
|
+
continue
|
|
319
|
+
if error_type and lesson.error_type != error_type:
|
|
320
|
+
continue
|
|
321
|
+
matched.append(lesson)
|
|
322
|
+
|
|
323
|
+
# Rank by semantic score (primary), confidence (secondary).
|
|
324
|
+
matched.sort(
|
|
325
|
+
key=lambda x: (score_map.get(x.id, 0.0), x.confidence),
|
|
326
|
+
reverse=True,
|
|
327
|
+
)
|
|
328
|
+
return matched[:limit]
|
|
329
|
+
|
|
330
|
+
def _word_overlap_search(
|
|
331
|
+
self,
|
|
332
|
+
query: str,
|
|
333
|
+
task_type: str,
|
|
334
|
+
error_type: str,
|
|
335
|
+
limit: int,
|
|
336
|
+
) -> list[Lesson]:
|
|
337
|
+
"""Original word-overlap search logic (preserved for fallback)."""
|
|
338
|
+
all_lessons = self._read_lessons()
|
|
339
|
+
results: list[Lesson] = []
|
|
340
|
+
|
|
341
|
+
if task_type or error_type:
|
|
342
|
+
for lesson in all_lessons:
|
|
343
|
+
if task_type and lesson.task_type != task_type:
|
|
344
|
+
continue
|
|
345
|
+
if error_type and lesson.error_type != error_type:
|
|
346
|
+
continue
|
|
347
|
+
results.append(lesson)
|
|
348
|
+
# If query is also provided, further filter results
|
|
349
|
+
if query and results:
|
|
350
|
+
filtered: list[Lesson] = []
|
|
351
|
+
for lesson in results:
|
|
352
|
+
overlap = self._word_overlap(query, lesson.pattern)
|
|
353
|
+
if overlap > 0.3 or query.lower() in lesson.pattern.lower():
|
|
354
|
+
filtered.append(lesson)
|
|
355
|
+
if filtered:
|
|
356
|
+
results = filtered
|
|
357
|
+
elif query:
|
|
358
|
+
# Pure fuzzy search
|
|
359
|
+
for lesson in all_lessons:
|
|
360
|
+
overlap = self._word_overlap(query, lesson.pattern)
|
|
361
|
+
if overlap > 0.3:
|
|
362
|
+
results.append(lesson)
|
|
363
|
+
# Fallback: substring
|
|
364
|
+
if not results:
|
|
365
|
+
query_lower = query.lower()
|
|
366
|
+
for lesson in all_lessons:
|
|
367
|
+
if query_lower in lesson.pattern.lower():
|
|
368
|
+
results.append(lesson)
|
|
369
|
+
|
|
370
|
+
results.sort(key=lambda x: x.confidence, reverse=True)
|
|
371
|
+
return results[:limit]
|
|
372
|
+
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
# Reinforce / Decay
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def reinforce(self, lesson_id: str, success: bool) -> bool:
|
|
378
|
+
"""Reinforce a lesson: adjust confidence +/-0.1, increment applied_count.
|
|
379
|
+
|
|
380
|
+
Confidence is clamped to [0.1, 1.0]. The lesson record is updated
|
|
381
|
+
in-place via atomic_rewrite (no append of a new version).
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
True if the lesson was found and updated, False otherwise.
|
|
385
|
+
"""
|
|
386
|
+
# Race-safe read-modify-write under one file lock (see resolve_error).
|
|
387
|
+
target: Lesson | None = None
|
|
388
|
+
|
|
389
|
+
def _apply(
|
|
390
|
+
entries: list[dict[str, object]],
|
|
391
|
+
) -> list[dict[str, object]] | None:
|
|
392
|
+
nonlocal target
|
|
393
|
+
for entry in entries:
|
|
394
|
+
if entry.get("id") == lesson_id:
|
|
395
|
+
les = Lesson.from_dict(entry)
|
|
396
|
+
delta = 0.1 if success else -0.1
|
|
397
|
+
les.confidence = round(
|
|
398
|
+
max(0.1, min(1.0, les.confidence + delta)), 2
|
|
399
|
+
)
|
|
400
|
+
les.applied_count += 1
|
|
401
|
+
les.updated_at = _utc_now_iso()
|
|
402
|
+
target = les
|
|
403
|
+
entry.update(les.to_dict())
|
|
404
|
+
return entries
|
|
405
|
+
return None # not found, abort write
|
|
406
|
+
|
|
407
|
+
with self._lock:
|
|
408
|
+
self._writer.atomic_update(self._lessons_path, _apply)
|
|
409
|
+
|
|
410
|
+
if target is None:
|
|
411
|
+
logger.debug("reinforce: lesson_id=%s not found", lesson_id)
|
|
412
|
+
return False
|
|
413
|
+
# No retriever re-sync needed: reinforce never changes pattern text,
|
|
414
|
+
# so the embedding vector is unchanged.
|
|
415
|
+
logger.debug(
|
|
416
|
+
"Reinforced lesson %s: confidence=%.2f applied_count=%d",
|
|
417
|
+
lesson_id,
|
|
418
|
+
target.confidence,
|
|
419
|
+
target.applied_count,
|
|
420
|
+
)
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
def decay_unused(self) -> int:
|
|
424
|
+
"""Decay confidence of unused lessons.
|
|
425
|
+
|
|
426
|
+
Reduces confidence by 0.05 for lessons with applied_count == 0
|
|
427
|
+
and confidence > 0.3. Updated via single atomic_rewrite.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Number of lessons that were decayed.
|
|
431
|
+
"""
|
|
432
|
+
decayed_count = 0
|
|
433
|
+
|
|
434
|
+
def _apply(
|
|
435
|
+
entries: list[dict[str, object]],
|
|
436
|
+
) -> list[dict[str, object]] | None:
|
|
437
|
+
nonlocal decayed_count
|
|
438
|
+
now = _utc_now_iso()
|
|
439
|
+
for entry in entries:
|
|
440
|
+
les = Lesson.from_dict(entry)
|
|
441
|
+
if les.applied_count > 0 or les.confidence <= 0.3:
|
|
442
|
+
continue
|
|
443
|
+
les.confidence = round(max(0.0, les.confidence - 0.05), 2)
|
|
444
|
+
les.updated_at = now
|
|
445
|
+
entry.update(les.to_dict())
|
|
446
|
+
decayed_count += 1
|
|
447
|
+
if decayed_count == 0:
|
|
448
|
+
return None # nothing to change, abort write
|
|
449
|
+
return entries
|
|
450
|
+
|
|
451
|
+
with self._lock:
|
|
452
|
+
self._writer.atomic_update(self._lessons_path, _apply)
|
|
453
|
+
|
|
454
|
+
if decayed_count > 0:
|
|
455
|
+
logger.info("Decayed %d unused lessons", decayed_count)
|
|
456
|
+
return decayed_count
|
|
457
|
+
|
|
458
|
+
# ------------------------------------------------------------------
|
|
459
|
+
# Statistics
|
|
460
|
+
# ------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
def counts(self) -> dict[str, int]:
|
|
463
|
+
"""Return basic statistics about stored errors and lessons.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Dict with keys: errors_total, errors_resolved, errors_unresolved,
|
|
467
|
+
lessons_total, lessons_applied (applied_count > 0).
|
|
468
|
+
"""
|
|
469
|
+
errors = self._read_errors()
|
|
470
|
+
lessons = self._read_lessons()
|
|
471
|
+
|
|
472
|
+
resolved = sum(1 for e in errors if e.resolved)
|
|
473
|
+
applied = sum(1 for le in lessons if le.applied_count > 0)
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
"errors_total": len(errors),
|
|
477
|
+
"errors_resolved": resolved,
|
|
478
|
+
"errors_unresolved": len(errors) - resolved,
|
|
479
|
+
"lessons_total": len(lessons),
|
|
480
|
+
"lessons_applied": applied,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# ------------------------------------------------------------------
|
|
484
|
+
# Internal I/O
|
|
485
|
+
# ------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
def _read_errors(self) -> list[ErrorRecord]:
|
|
488
|
+
"""Read all error records from disk."""
|
|
489
|
+
raw = self._writer.read_all(self._errors_path)
|
|
490
|
+
return [ErrorRecord.from_dict(r) for r in raw]
|
|
491
|
+
|
|
492
|
+
def _read_lessons(self) -> list[Lesson]:
|
|
493
|
+
"""Read all lesson records from disk."""
|
|
494
|
+
raw = self._writer.read_all(self._lessons_path)
|
|
495
|
+
return [Lesson.from_dict(r) for r in raw]
|
errlore/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Semantic retrieval layer for lessons.
|
|
2
|
+
|
|
3
|
+
Provides embedding backends and a vector index for semantic search
|
|
4
|
+
over lessons. Heavy dependencies (numpy, fastembed) live in submodules
|
|
5
|
+
and are imported lazily -- this ``__init__`` only defines lightweight
|
|
6
|
+
Protocol types that can be imported without extras.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
LessonRetriever -- structural Protocol consumed by LessonStore
|
|
10
|
+
EmbeddingBackend -- structural Protocol for embedding providers
|
|
11
|
+
|
|
12
|
+
Concrete implementations (require ``errlore[embeddings]``):
|
|
13
|
+
``errlore.retrieval.backend.FastEmbedBackend``
|
|
14
|
+
``errlore.retrieval.backend.CallableBackend``
|
|
15
|
+
``errlore.retrieval.index.VectorIndex``
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class LessonRetriever(Protocol):
|
|
25
|
+
"""Protocol for semantic retrieval backends used by LessonStore.
|
|
26
|
+
|
|
27
|
+
Any object implementing ``search``, ``add``, and ``remove`` with
|
|
28
|
+
matching signatures satisfies this protocol (structural subtyping).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def search(self, query: str, k: int) -> list[tuple[str, float]]:
|
|
32
|
+
"""Return up to *k* (lesson_id, score) pairs ranked by similarity."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def add(self, lesson_id: str, text: str) -> None:
|
|
36
|
+
"""Index a lesson text under the given ID. Idempotent."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def remove(self, lesson_id: str) -> None:
|
|
40
|
+
"""Remove a lesson from the index. No-op if ID unknown."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@runtime_checkable
|
|
45
|
+
class EmbeddingBackend(Protocol):
|
|
46
|
+
"""Protocol for pluggable embedding providers."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def dim(self) -> int:
|
|
50
|
+
"""Embedding dimensionality."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def model_id(self) -> str:
|
|
55
|
+
"""Unique model identifier (used for index compatibility checks)."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
59
|
+
"""Embed a batch of texts into float vectors."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"EmbeddingBackend",
|
|
65
|
+
"LessonRetriever",
|
|
66
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Concrete embedding backend implementations.
|
|
2
|
+
|
|
3
|
+
``FastEmbedBackend`` -- multilingual embeddings via fastembed (lazy import).
|
|
4
|
+
``CallableBackend`` -- wrap any user-supplied function (ollama, openai, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Default: 384-dim, 50+ languages (RU + EN), 512 tokens, fast.
|
|
14
|
+
_DEFAULT_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FastEmbedBackend:
|
|
18
|
+
"""Multilingual embedding backend powered by fastembed.
|
|
19
|
+
|
|
20
|
+
The model is loaded lazily on first ``embed`` call. If fastembed
|
|
21
|
+
is not installed, a clear ``ImportError`` is raised with install
|
|
22
|
+
instructions.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
model_name: fastembed model identifier. Must appear in
|
|
26
|
+
``TextEmbedding.list_supported_models()``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, model_name: str = _DEFAULT_MODEL) -> None:
|
|
30
|
+
self._model_name = model_name
|
|
31
|
+
self._model: Any = None
|
|
32
|
+
self._dim: int | None = None
|
|
33
|
+
|
|
34
|
+
def _ensure_model(self) -> None:
|
|
35
|
+
"""Lazy-load the fastembed model on first use."""
|
|
36
|
+
if self._model is not None:
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
from fastembed import TextEmbedding
|
|
40
|
+
except ImportError:
|
|
41
|
+
raise ImportError(
|
|
42
|
+
"fastembed is required for embedding-based retrieval. "
|
|
43
|
+
"Install it with: pip install errlore[embeddings]"
|
|
44
|
+
) from None
|
|
45
|
+
# C3: suppress the known "now uses mean pooling instead of CLS"
|
|
46
|
+
# UserWarning emitted by fastembed >= 0.6 for models that changed
|
|
47
|
+
# their default pooling strategy. The warning is informational
|
|
48
|
+
# and cannot be acted upon without pinning to an older version.
|
|
49
|
+
with warnings.catch_warnings():
|
|
50
|
+
warnings.filterwarnings(
|
|
51
|
+
"ignore",
|
|
52
|
+
message=".*mean pooling instead of CLS.*",
|
|
53
|
+
category=UserWarning,
|
|
54
|
+
)
|
|
55
|
+
self._model = TextEmbedding(self._model_name)
|
|
56
|
+
# Probe dimensionality.
|
|
57
|
+
probe = list(self._model.embed(["_dim_probe_"]))
|
|
58
|
+
self._dim = len(probe[0])
|
|
59
|
+
|
|
60
|
+
# -- EmbeddingBackend protocol -----------------------------------------
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def dim(self) -> int:
|
|
64
|
+
"""Embedding dimensionality (discovered on first use)."""
|
|
65
|
+
self._ensure_model()
|
|
66
|
+
assert self._dim is not None
|
|
67
|
+
return self._dim
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def model_id(self) -> str:
|
|
71
|
+
"""Model identifier string."""
|
|
72
|
+
return self._model_name
|
|
73
|
+
|
|
74
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
75
|
+
"""Embed texts into float vectors via fastembed."""
|
|
76
|
+
self._ensure_model()
|
|
77
|
+
assert self._model is not None
|
|
78
|
+
raw = list(self._model.embed(texts))
|
|
79
|
+
return [vec.tolist() for vec in raw]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CallableBackend:
|
|
83
|
+
"""Wrap an arbitrary callable as an ``EmbeddingBackend``.
|
|
84
|
+
|
|
85
|
+
Useful for plugging in OpenAI, Ollama, or any custom embedding
|
|
86
|
+
function without writing a full backend class.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
fn: Callable that takes ``list[str]`` and returns
|
|
90
|
+
``list[list[float]]``.
|
|
91
|
+
dim: Fixed embedding dimensionality.
|
|
92
|
+
model_id: Identifier string (used for index compatibility).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
fn: Callable[[list[str]], list[list[float]]],
|
|
98
|
+
dim: int,
|
|
99
|
+
model_id: str = "custom",
|
|
100
|
+
) -> None:
|
|
101
|
+
self._fn = fn
|
|
102
|
+
self._dim = dim
|
|
103
|
+
self._model_id = model_id
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def dim(self) -> int:
|
|
107
|
+
"""Fixed embedding dimensionality."""
|
|
108
|
+
return self._dim
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def model_id(self) -> str:
|
|
112
|
+
"""Model identifier string."""
|
|
113
|
+
return self._model_id
|
|
114
|
+
|
|
115
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
116
|
+
"""Delegate to the wrapped callable."""
|
|
117
|
+
return self._fn(texts)
|