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/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
+ ]