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/facade.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""Unified AgentMemory facade -- single entry point for error learning.
|
|
2
|
+
|
|
3
|
+
Composes :class:`~errlore.lessons.store.LessonStore`,
|
|
4
|
+
:class:`~errlore.errmem.tracker.ErrorTracker`,
|
|
5
|
+
:class:`~errlore.errmem.patterns.PatternDetector`,
|
|
6
|
+
:class:`~errlore.errmem.injector.WarningInjector`, and
|
|
7
|
+
:class:`~errlore.trust.engine.TrustEngine` into one coherent API with
|
|
8
|
+
a closed reinforcement loop.
|
|
9
|
+
|
|
10
|
+
Injection handles are persisted to ``data_dir/injections.jsonl``
|
|
11
|
+
(append-only), so outcomes can be reported even after process restart.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from errlore.errmem import ErrorTracker, PatternDetector, WarningInjector, classify_error
|
|
23
|
+
from errlore.io import JSONLWriter
|
|
24
|
+
from errlore.lessons.models import _short_id, _utc_now_iso
|
|
25
|
+
from errlore.lessons.store import LessonStore
|
|
26
|
+
from errlore.sanitize import sanitize_lesson_text
|
|
27
|
+
from errlore.trust import FeedbackSignal, TrustEngine
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("errlore.facade")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Injection dataclass
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class Injection:
|
|
39
|
+
"""Result of :meth:`AgentMemory.inject_for`.
|
|
40
|
+
|
|
41
|
+
Carries the prompt-ready text block together with enough metadata
|
|
42
|
+
for the reinforcement loop (:meth:`AgentMemory.report_outcome`).
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
handle_id: Unique 12-char hex ID persisted in injections.jsonl.
|
|
46
|
+
text: Assembled prompt block (lessons + known issues).
|
|
47
|
+
lesson_ids: IDs of lessons included in this injection.
|
|
48
|
+
model: Model the injection was built for.
|
|
49
|
+
domain: Trust domain.
|
|
50
|
+
created_at: ISO-8601 timestamp.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
handle_id: str
|
|
54
|
+
text: str
|
|
55
|
+
lesson_ids: list[str]
|
|
56
|
+
model: str
|
|
57
|
+
domain: str
|
|
58
|
+
created_at: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# AgentMemory facade
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AgentMemory:
|
|
67
|
+
"""Unified facade for error memory, lessons, and trust.
|
|
68
|
+
|
|
69
|
+
Composes all errlore subsystems behind four core methods:
|
|
70
|
+
|
|
71
|
+
* :meth:`log_error` -- record an error.
|
|
72
|
+
* :meth:`resolve` -- mark error as fixed, optionally extract a lesson.
|
|
73
|
+
* :meth:`inject_for` -- build a context block for the next task.
|
|
74
|
+
* :meth:`report_outcome` -- close the reinforcement loop.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
data_dir: Directory for all persistent state files.
|
|
78
|
+
trust: Enable the TrustEngine layer (default True).
|
|
79
|
+
When False, :meth:`report_outcome` skips trust updates and
|
|
80
|
+
:meth:`stats` omits the ``trust`` key.
|
|
81
|
+
max_lessons: Maximum lessons to include in injection text.
|
|
82
|
+
decay_every: Run :meth:`LessonStore.decay_unused` every N
|
|
83
|
+
:meth:`inject_for` calls. Counter is in-process only --
|
|
84
|
+
no cross-restart persistence needed.
|
|
85
|
+
embeddings: Enable semantic retrieval via embeddings (default False).
|
|
86
|
+
Requires ``errlore[embeddings]`` extras (fastembed + numpy).
|
|
87
|
+
When the extras are missing, falls back to word-overlap with
|
|
88
|
+
a warning.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
data_dir: Path | str,
|
|
94
|
+
*,
|
|
95
|
+
trust: bool = True,
|
|
96
|
+
max_lessons: int = 3,
|
|
97
|
+
decay_every: int = 50,
|
|
98
|
+
embeddings: bool = False,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._data_dir = Path(data_dir)
|
|
101
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
self._max_lessons = max_lessons
|
|
103
|
+
self._decay_every = decay_every
|
|
104
|
+
self._decay_counter = 0
|
|
105
|
+
|
|
106
|
+
# Shared writer for injections.jsonl -- rotation disabled because
|
|
107
|
+
# injections are addressed by handle_id and must remain visible.
|
|
108
|
+
self._writer = JSONLWriter(max_bytes=None)
|
|
109
|
+
self._injections_path = self._data_dir / "injections.jsonl"
|
|
110
|
+
|
|
111
|
+
# Subsystems.
|
|
112
|
+
retriever = self._build_retriever(self._data_dir) if embeddings else None
|
|
113
|
+
self._store = LessonStore(self._data_dir, retriever=retriever)
|
|
114
|
+
self._tracker = ErrorTracker(self._data_dir)
|
|
115
|
+
self._detector = PatternDetector()
|
|
116
|
+
self._injector = WarningInjector(
|
|
117
|
+
self._tracker, self._detector, top_n=max_lessons,
|
|
118
|
+
)
|
|
119
|
+
self._trust: TrustEngine | None = None
|
|
120
|
+
if trust:
|
|
121
|
+
trust_path = self._data_dir / "trust.json"
|
|
122
|
+
# TrustEngine() does NOT read an existing state file -- only the
|
|
123
|
+
# load() classmethod does. Without this branch, trust weights
|
|
124
|
+
# silently reset on every process restart.
|
|
125
|
+
if trust_path.exists():
|
|
126
|
+
self._trust = TrustEngine.load(trust_path)
|
|
127
|
+
else:
|
|
128
|
+
self._trust = TrustEngine(state_path=trust_path)
|
|
129
|
+
|
|
130
|
+
# Lock for report_outcome idempotency (within one process).
|
|
131
|
+
self._report_lock = threading.Lock()
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _build_retriever(data_dir: Path) -> Any:
|
|
135
|
+
"""Try to build a FastEmbedBackend + VectorIndex retriever.
|
|
136
|
+
|
|
137
|
+
Returns the VectorIndex or None (with a warning) if the extras
|
|
138
|
+
are not installed.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
from errlore.retrieval.backend import FastEmbedBackend
|
|
142
|
+
from errlore.retrieval.index import VectorIndex
|
|
143
|
+
|
|
144
|
+
backend = FastEmbedBackend()
|
|
145
|
+
return VectorIndex(data_dir, backend)
|
|
146
|
+
except ImportError:
|
|
147
|
+
import warnings
|
|
148
|
+
|
|
149
|
+
warnings.warn(
|
|
150
|
+
"errlore[embeddings] extras not installed; "
|
|
151
|
+
"falling back to word-overlap retrieval. "
|
|
152
|
+
"Install with: pip install errlore[embeddings]",
|
|
153
|
+
UserWarning,
|
|
154
|
+
stacklevel=3,
|
|
155
|
+
)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
# Error lifecycle
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def log_error(
|
|
163
|
+
self,
|
|
164
|
+
model: str,
|
|
165
|
+
task_type: str,
|
|
166
|
+
error: BaseException | str,
|
|
167
|
+
*,
|
|
168
|
+
message: str = "",
|
|
169
|
+
metadata: dict[str, Any] | None = None,
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Record an error and update the model's weakness profile.
|
|
172
|
+
|
|
173
|
+
The error is auto-classified (see :func:`errlore.errmem.classify_error`)
|
|
174
|
+
and written to both :class:`LessonStore` (``errors.jsonl``) and
|
|
175
|
+
:class:`ErrorTracker` (``model_accuracy.jsonl``).
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
model: Model name.
|
|
179
|
+
task_type: Task category (e.g. ``"extraction"``).
|
|
180
|
+
error: Exception object or textual error description.
|
|
181
|
+
message: Optional human-readable override for the stored message.
|
|
182
|
+
metadata: Optional extra data to attach to the error record.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Error ID (12-char hex).
|
|
186
|
+
"""
|
|
187
|
+
if isinstance(error, BaseException):
|
|
188
|
+
error_type = type(error).__name__
|
|
189
|
+
error_message = message or str(error)
|
|
190
|
+
else:
|
|
191
|
+
error_type = classify_error(message=error)
|
|
192
|
+
error_message = message or error
|
|
193
|
+
|
|
194
|
+
# LessonStore: full error record.
|
|
195
|
+
err_id = self._store.log_error(
|
|
196
|
+
model=model,
|
|
197
|
+
task_type=task_type,
|
|
198
|
+
error_type=error_type,
|
|
199
|
+
message=error_message,
|
|
200
|
+
metadata=metadata,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# ErrorTracker: weakness profile.
|
|
204
|
+
self._tracker.record_error(
|
|
205
|
+
model,
|
|
206
|
+
task_type,
|
|
207
|
+
{"type": error_type, "description": error_message, "severity": "medium"},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return err_id
|
|
211
|
+
|
|
212
|
+
def resolve(
|
|
213
|
+
self,
|
|
214
|
+
err_id: str,
|
|
215
|
+
resolution: str,
|
|
216
|
+
lesson: str | None = None,
|
|
217
|
+
) -> bool:
|
|
218
|
+
"""Resolve an error, optionally extracting a lesson.
|
|
219
|
+
|
|
220
|
+
When *lesson* is provided it is sanitized via
|
|
221
|
+
:func:`~errlore.sanitize.sanitize_lesson_text`. If sanitization
|
|
222
|
+
rejects the text (raw JSON, code-only), the lesson is dropped
|
|
223
|
+
with a warning and the error is still resolved.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
err_id: Error ID returned by :meth:`log_error`.
|
|
227
|
+
resolution: Free-text resolution description.
|
|
228
|
+
lesson: Optional lesson text to persist.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if the error was found (and resolved), False if unknown ID.
|
|
232
|
+
"""
|
|
233
|
+
if lesson is not None:
|
|
234
|
+
sanitized = sanitize_lesson_text(lesson)
|
|
235
|
+
if sanitized is None:
|
|
236
|
+
logger.warning(
|
|
237
|
+
"Lesson text rejected by sanitizer for error %s", err_id,
|
|
238
|
+
)
|
|
239
|
+
lesson = None
|
|
240
|
+
else:
|
|
241
|
+
lesson = sanitized
|
|
242
|
+
|
|
243
|
+
return self._store.resolve_error(err_id, resolution, lesson)
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Injection
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def inject_for(
|
|
250
|
+
self,
|
|
251
|
+
task: str,
|
|
252
|
+
model: str,
|
|
253
|
+
*,
|
|
254
|
+
task_type: str | None = None,
|
|
255
|
+
domain: str | None = None,
|
|
256
|
+
) -> Injection:
|
|
257
|
+
"""Build a context injection block for a new task.
|
|
258
|
+
|
|
259
|
+
Searches past lessons and known model weaknesses, assembles them
|
|
260
|
+
into a prompt-ready text block, and persists the injection handle
|
|
261
|
+
so outcomes can be reported later (even after restart).
|
|
262
|
+
|
|
263
|
+
**Lazy decay**: every *decay_every* calls, runs
|
|
264
|
+
:meth:`LessonStore.decay_unused` to lower confidence of
|
|
265
|
+
never-applied lessons. Counter is in-process only.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
task: Task description (used as search query for lessons).
|
|
269
|
+
model: Model name (used for known-issue lookup).
|
|
270
|
+
task_type: Optional task category for narrower lesson search.
|
|
271
|
+
domain: Trust domain (default ``"general"``).
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
:class:`Injection` with text block and handle for
|
|
275
|
+
:meth:`report_outcome`.
|
|
276
|
+
"""
|
|
277
|
+
# Lazy decay.
|
|
278
|
+
self._decay_counter += 1
|
|
279
|
+
if self._decay_counter >= self._decay_every:
|
|
280
|
+
self._decay_counter = 0
|
|
281
|
+
self._store.decay_unused()
|
|
282
|
+
|
|
283
|
+
effective_domain = domain or "general"
|
|
284
|
+
effective_task_type = task_type or ""
|
|
285
|
+
|
|
286
|
+
# Search lessons.
|
|
287
|
+
lessons = self._store.search_lessons(
|
|
288
|
+
query=task,
|
|
289
|
+
task_type=effective_task_type,
|
|
290
|
+
limit=self._max_lessons,
|
|
291
|
+
)
|
|
292
|
+
lesson_ids = [le.id for le in lessons]
|
|
293
|
+
|
|
294
|
+
# Build text block.
|
|
295
|
+
parts: list[str] = []
|
|
296
|
+
|
|
297
|
+
if lessons:
|
|
298
|
+
parts.append("[LESSONS FROM PAST FAILURES]")
|
|
299
|
+
for le in lessons:
|
|
300
|
+
parts.append(f"- {le.pattern} -> {le.solution}")
|
|
301
|
+
|
|
302
|
+
# Known issues (model weaknesses + past errors).
|
|
303
|
+
warning = self._injector.build_warning(model, effective_task_type)
|
|
304
|
+
if warning:
|
|
305
|
+
if parts:
|
|
306
|
+
parts.append("") # blank separator line
|
|
307
|
+
parts.append(warning)
|
|
308
|
+
|
|
309
|
+
text = "\n".join(parts) if parts else ""
|
|
310
|
+
|
|
311
|
+
# Create and persist injection.
|
|
312
|
+
handle_id = _short_id()
|
|
313
|
+
created_at = _utc_now_iso()
|
|
314
|
+
|
|
315
|
+
inj = Injection(
|
|
316
|
+
handle_id=handle_id,
|
|
317
|
+
text=text,
|
|
318
|
+
lesson_ids=lesson_ids,
|
|
319
|
+
model=model,
|
|
320
|
+
domain=effective_domain,
|
|
321
|
+
created_at=created_at,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
self._writer.append(
|
|
325
|
+
self._injections_path,
|
|
326
|
+
{
|
|
327
|
+
"event": "issued",
|
|
328
|
+
"handle_id": handle_id,
|
|
329
|
+
"lesson_ids": lesson_ids,
|
|
330
|
+
"model": model,
|
|
331
|
+
"domain": effective_domain,
|
|
332
|
+
"created_at": created_at,
|
|
333
|
+
"text": text,
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return inj
|
|
338
|
+
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
# Reinforcement loop
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def report_outcome(
|
|
344
|
+
self,
|
|
345
|
+
inj_or_handle_id: Injection | str,
|
|
346
|
+
success: bool,
|
|
347
|
+
*,
|
|
348
|
+
outcome: float | None = None,
|
|
349
|
+
) -> bool:
|
|
350
|
+
"""Close the reinforcement loop for a previously injected context.
|
|
351
|
+
|
|
352
|
+
For every lesson that was part of the injection, calls
|
|
353
|
+
:meth:`LessonStore.reinforce` (adjusting confidence and
|
|
354
|
+
applied_count). If the trust layer is enabled, updates the
|
|
355
|
+
model's trust weight via :class:`TrustEngine`.
|
|
356
|
+
|
|
357
|
+
**Idempotent**: reporting the same handle twice returns ``False``
|
|
358
|
+
with a warning; no double-reinforcement occurs.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
inj_or_handle_id: :class:`Injection` object or its
|
|
362
|
+
``handle_id`` string.
|
|
363
|
+
success: Whether the task succeeded.
|
|
364
|
+
outcome: Optional quality score in ``[0, 1]`` for the trust
|
|
365
|
+
signal. Defaults to ``1.0`` when *success* is True,
|
|
366
|
+
``0.0`` otherwise.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
``True`` on first report, ``False`` on duplicate.
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
KeyError: If the handle_id is unknown.
|
|
373
|
+
ValueError: If *outcome* is outside ``[0, 1]`` or NaN.
|
|
374
|
+
"""
|
|
375
|
+
# A4: validate outcome BEFORE any side effects.
|
|
376
|
+
if outcome is not None:
|
|
377
|
+
import math
|
|
378
|
+
if math.isnan(outcome) or not (0.0 <= outcome <= 1.0):
|
|
379
|
+
raise ValueError(
|
|
380
|
+
f"outcome must be in [0, 1] and not NaN, got {outcome}"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if isinstance(inj_or_handle_id, Injection):
|
|
384
|
+
handle_id = inj_or_handle_id.handle_id
|
|
385
|
+
else:
|
|
386
|
+
handle_id = inj_or_handle_id
|
|
387
|
+
|
|
388
|
+
# A2: cross-process file lock wraps the entire check -> reinforce ->
|
|
389
|
+
# trust -> append path, preventing two processes from doubling up.
|
|
390
|
+
# The threading lock is kept outside as an additional in-process gate.
|
|
391
|
+
with self._report_lock, self._writer.lock(self._injections_path):
|
|
392
|
+
events = self._writer.read_all(self._injections_path)
|
|
393
|
+
|
|
394
|
+
# Find the issued event.
|
|
395
|
+
issued: dict[str, object] | None = None
|
|
396
|
+
for ev in events:
|
|
397
|
+
if (
|
|
398
|
+
ev.get("event") == "issued"
|
|
399
|
+
and ev.get("handle_id") == handle_id
|
|
400
|
+
):
|
|
401
|
+
issued = ev
|
|
402
|
+
break
|
|
403
|
+
|
|
404
|
+
if issued is None:
|
|
405
|
+
raise KeyError(f"Unknown injection handle: {handle_id}")
|
|
406
|
+
|
|
407
|
+
# Idempotency check.
|
|
408
|
+
for ev in events:
|
|
409
|
+
if (
|
|
410
|
+
ev.get("event") == "reported"
|
|
411
|
+
and ev.get("handle_id") == handle_id
|
|
412
|
+
):
|
|
413
|
+
logger.warning(
|
|
414
|
+
"Duplicate report_outcome for handle %s", handle_id,
|
|
415
|
+
)
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
# Reinforce each lesson.
|
|
419
|
+
raw_ids = issued.get("lesson_ids", [])
|
|
420
|
+
lesson_ids: list[str] = (
|
|
421
|
+
[str(x) for x in raw_ids]
|
|
422
|
+
if isinstance(raw_ids, list)
|
|
423
|
+
else []
|
|
424
|
+
)
|
|
425
|
+
for lid in lesson_ids:
|
|
426
|
+
self._store.reinforce(lid, success)
|
|
427
|
+
|
|
428
|
+
# Trust update.
|
|
429
|
+
if self._trust is not None:
|
|
430
|
+
model = str(issued.get("model", ""))
|
|
431
|
+
sig_domain = str(issued.get("domain", "general"))
|
|
432
|
+
outcome_val = (
|
|
433
|
+
outcome
|
|
434
|
+
if outcome is not None
|
|
435
|
+
else (1.0 if success else 0.0)
|
|
436
|
+
)
|
|
437
|
+
signal = FeedbackSignal(outcome=outcome_val, domain=sig_domain)
|
|
438
|
+
self._trust.update(model, signal)
|
|
439
|
+
self._trust.save()
|
|
440
|
+
|
|
441
|
+
# Persist reported event.
|
|
442
|
+
self._writer.append(
|
|
443
|
+
self._injections_path,
|
|
444
|
+
{
|
|
445
|
+
"event": "reported",
|
|
446
|
+
"handle_id": handle_id,
|
|
447
|
+
"success": success,
|
|
448
|
+
"outcome": outcome,
|
|
449
|
+
"reported_at": _utc_now_iso(),
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
# ------------------------------------------------------------------
|
|
456
|
+
# Lesson convenience API
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def add_lesson(
|
|
460
|
+
self,
|
|
461
|
+
pattern: str,
|
|
462
|
+
solution: str,
|
|
463
|
+
*,
|
|
464
|
+
task_type: str = "",
|
|
465
|
+
confidence: float = 0.8,
|
|
466
|
+
) -> str | None:
|
|
467
|
+
"""Add a lesson directly (without an error/resolve cycle).
|
|
468
|
+
|
|
469
|
+
The *pattern* is sanitized via
|
|
470
|
+
:func:`~errlore.sanitize.sanitize_lesson_text`. If sanitization
|
|
471
|
+
rejects the text, ``None`` is returned and no lesson is stored.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
pattern: Problem pattern description.
|
|
475
|
+
solution: How to fix / avoid the problem.
|
|
476
|
+
task_type: Optional task category for narrower search later.
|
|
477
|
+
confidence: Initial confidence (default 0.8).
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
Lesson ID, or ``None`` if the pattern was rejected by the sanitizer.
|
|
481
|
+
"""
|
|
482
|
+
sanitized = sanitize_lesson_text(pattern)
|
|
483
|
+
if sanitized is None:
|
|
484
|
+
logger.warning("add_lesson: pattern rejected by sanitizer")
|
|
485
|
+
return None
|
|
486
|
+
return self._store.log_lesson(
|
|
487
|
+
pattern=sanitized,
|
|
488
|
+
solution=solution,
|
|
489
|
+
task_type=task_type,
|
|
490
|
+
confidence=confidence,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def lessons(self, limit: int | None = None) -> list[Any]:
|
|
494
|
+
"""Return all lessons, optionally capped at *limit*.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
limit: Maximum number of lessons to return (newest first by
|
|
498
|
+
confidence). ``None`` returns all.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
List of :class:`~errlore.lessons.models.Lesson` objects.
|
|
502
|
+
"""
|
|
503
|
+
all_lessons = self._store._read_lessons()
|
|
504
|
+
all_lessons.sort(key=lambda le: le.confidence, reverse=True)
|
|
505
|
+
if limit is not None:
|
|
506
|
+
return all_lessons[:limit]
|
|
507
|
+
return all_lessons
|
|
508
|
+
|
|
509
|
+
# ------------------------------------------------------------------
|
|
510
|
+
# Queries
|
|
511
|
+
# ------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
def pending_injections(self) -> list[Injection]:
|
|
514
|
+
"""Return all injections issued but not yet reported."""
|
|
515
|
+
events = self._writer.read_all(self._injections_path)
|
|
516
|
+
|
|
517
|
+
reported_handles: set[str] = set()
|
|
518
|
+
issued_map: dict[str, dict[str, object]] = {}
|
|
519
|
+
|
|
520
|
+
for ev in events:
|
|
521
|
+
event_type = ev.get("event")
|
|
522
|
+
hid = str(ev.get("handle_id", ""))
|
|
523
|
+
if event_type == "reported":
|
|
524
|
+
reported_handles.add(hid)
|
|
525
|
+
elif event_type == "issued":
|
|
526
|
+
issued_map[hid] = ev
|
|
527
|
+
|
|
528
|
+
result: list[Injection] = []
|
|
529
|
+
for hid, ev in issued_map.items():
|
|
530
|
+
if hid not in reported_handles:
|
|
531
|
+
raw_ids = ev.get("lesson_ids", [])
|
|
532
|
+
lesson_ids = (
|
|
533
|
+
[str(x) for x in raw_ids]
|
|
534
|
+
if isinstance(raw_ids, list)
|
|
535
|
+
else []
|
|
536
|
+
)
|
|
537
|
+
result.append(
|
|
538
|
+
Injection(
|
|
539
|
+
handle_id=hid,
|
|
540
|
+
text=str(ev.get("text", "")),
|
|
541
|
+
lesson_ids=lesson_ids,
|
|
542
|
+
model=str(ev.get("model", "")),
|
|
543
|
+
domain=str(ev.get("domain", "general")),
|
|
544
|
+
created_at=str(ev.get("created_at", "")),
|
|
545
|
+
),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
def stats(self) -> dict[str, Any]:
|
|
551
|
+
"""Return aggregate statistics.
|
|
552
|
+
|
|
553
|
+
Keys: ``errors_total``, ``errors_resolved``, ``errors_unresolved``,
|
|
554
|
+
``lessons_total``, ``lessons_applied``, ``pending_injections``,
|
|
555
|
+
and (when trust is enabled) ``trust`` — a dict of model weights.
|
|
556
|
+
"""
|
|
557
|
+
result: dict[str, Any] = {
|
|
558
|
+
**self._store.counts(),
|
|
559
|
+
"pending_injections": len(self.pending_injections()),
|
|
560
|
+
}
|
|
561
|
+
if self._trust is not None:
|
|
562
|
+
result["trust"] = self._trust.get_weights()
|
|
563
|
+
return result
|
|
564
|
+
|
|
565
|
+
def model_penalty(self, model: str, task_type: str) -> float:
|
|
566
|
+
"""Return the error-history penalty for a model on a task type.
|
|
567
|
+
|
|
568
|
+
Delegates to :meth:`WarningInjector.get_penalty`.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
model: Model name.
|
|
572
|
+
task_type: Task category.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Penalty score in ``[0.0, 1.0]``.
|
|
576
|
+
"""
|
|
577
|
+
return self._injector.get_penalty(model, task_type)
|
|
578
|
+
|
|
579
|
+
@property
|
|
580
|
+
def trust(self) -> TrustEngine | None:
|
|
581
|
+
"""Access the underlying TrustEngine (None when trust=False)."""
|
|
582
|
+
return self._trust
|
|
583
|
+
|
|
584
|
+
def best_model(self, domain: str = "general") -> str | None:
|
|
585
|
+
"""Return the model with the highest trust weight for a domain.
|
|
586
|
+
|
|
587
|
+
Useful for routing: pick the model that historically performs best
|
|
588
|
+
on a given task domain, based on accumulated outcome signals.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
domain: Trust domain (default ``"general"``).
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
Model name with the highest weight, or None if no models are
|
|
595
|
+
registered or trust is disabled.
|
|
596
|
+
"""
|
|
597
|
+
if self._trust is None:
|
|
598
|
+
return None
|
|
599
|
+
weights = self._trust.get_weights(domain)
|
|
600
|
+
if not weights:
|
|
601
|
+
return None
|
|
602
|
+
return max(weights, key=weights.get) # type: ignore[arg-type]
|
errlore/io/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Durable JSONL I/O layer: writer, sidecar index, and auto-repair.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
JSONLWriter — cross-process append, batch append, atomic rewrite, rotation
|
|
5
|
+
JSONLIndex — sidecar .idx with byte offsets, tail-read, rebuild
|
|
6
|
+
repair_file — auto-repair corrupted JSONL (glued records, bad lines)
|
|
7
|
+
repair_directory — batch repair all JSONL in a directory
|
|
8
|
+
RepairStats — statistics returned by repair operations
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from errlore.io.jsonl_index import JSONLIndex
|
|
12
|
+
from errlore.io.jsonl_writer import JSONLWriter
|
|
13
|
+
from errlore.io.repair import RepairStats, repair_directory, repair_file
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"JSONLIndex",
|
|
17
|
+
"JSONLWriter",
|
|
18
|
+
"RepairStats",
|
|
19
|
+
"repair_directory",
|
|
20
|
+
"repair_file",
|
|
21
|
+
]
|