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/io/repair.py ADDED
@@ -0,0 +1,199 @@
1
+ """Auto-repair for corrupted JSONL files.
2
+
3
+ Scans JSONL files, recovers glued JSON records (missing newlines),
4
+ drops unrecoverable lines, and rebuilds the sidecar index.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import json
11
+ import logging
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from errlore.io.jsonl_index import JSONLIndex
16
+ from errlore.io.jsonl_writer import JSONLWriter
17
+
18
+ logger = logging.getLogger("errlore.io")
19
+
20
+ # Pattern for glued JSON objects: `}{` without a newline between them
21
+ _GLUED_PATTERN = re.compile(r"\}\s*\{")
22
+
23
+
24
+ def _try_parse(line: str) -> list[dict[str, object]]:
25
+ """Attempt to parse a line as JSON. Handles glued records."""
26
+ line = line.strip()
27
+ if not line:
28
+ return []
29
+
30
+ # Attempt 1: standard JSON
31
+ try:
32
+ obj = json.loads(line)
33
+ if isinstance(obj, dict):
34
+ return [obj]
35
+ return []
36
+ except json.JSONDecodeError:
37
+ pass
38
+
39
+ # Attempt 2: glued JSON (`}{` -> `}\n{`)
40
+ if _GLUED_PATTERN.search(line):
41
+ parts = _GLUED_PATTERN.sub("}\n{", line).split("\n")
42
+ results: list[dict[str, object]] = []
43
+ for part in parts:
44
+ part = part.strip()
45
+ if not part:
46
+ continue
47
+ try:
48
+ obj = json.loads(part)
49
+ if isinstance(obj, dict):
50
+ results.append(obj)
51
+ except json.JSONDecodeError:
52
+ pass
53
+ if results:
54
+ return results
55
+
56
+ return []
57
+
58
+
59
+ class RepairStats:
60
+ """Statistics from a JSONL repair operation."""
61
+
62
+ __slots__ = ("dropped", "fixed", "index_rebuilt", "ok", "total_lines")
63
+
64
+ def __init__(self) -> None:
65
+ self.ok: int = 0
66
+ self.fixed: int = 0
67
+ self.dropped: int = 0
68
+ self.total_lines: int = 0
69
+ self.index_rebuilt: int = 0
70
+
71
+ def as_dict(self) -> dict[str, int]:
72
+ """Return stats as a plain dict."""
73
+ return {
74
+ "ok": self.ok,
75
+ "fixed": self.fixed,
76
+ "dropped": self.dropped,
77
+ "total_lines": self.total_lines,
78
+ "index_rebuilt": self.index_rebuilt,
79
+ }
80
+
81
+
82
+ def repair_file(
83
+ path: Path,
84
+ *,
85
+ dry_run: bool = False,
86
+ writer: JSONLWriter | None = None,
87
+ ) -> RepairStats:
88
+ """Repair a single JSONL file.
89
+
90
+ Recovers glued records, drops unrecoverable lines, then atomically
91
+ rewrites the file and rebuilds the sidecar index.
92
+
93
+ Args:
94
+ path: Path to the JSONL file.
95
+ dry_run: If True, only report what would happen.
96
+ writer: Optional JSONLWriter instance (created internally if None).
97
+
98
+ Returns:
99
+ RepairStats with counts of ok/fixed/dropped lines.
100
+ """
101
+ stats = RepairStats()
102
+ repaired: list[dict[str, object]] = []
103
+
104
+ if not path.exists():
105
+ # Clean up orphan index if JSONL is gone
106
+ idx = JSONLIndex(path)
107
+ if not dry_run and idx.idx_path.exists():
108
+ with contextlib.suppress(OSError):
109
+ idx.idx_path.unlink()
110
+ return stats
111
+
112
+ w = writer or JSONLWriter()
113
+
114
+ # A3: hold the file lock for the entire read -> parse -> rewrite cycle
115
+ # so that concurrent appends are not lost between read and rewrite.
116
+ with w.lock(path):
117
+ try:
118
+ raw = path.read_bytes()
119
+ except OSError as exc:
120
+ logger.error("Cannot read %s: %s", path, exc)
121
+ return stats
122
+
123
+ for line_bytes in raw.split(b"\n"):
124
+ stats.total_lines += 1
125
+ try:
126
+ line = line_bytes.decode("utf-8").strip()
127
+ except UnicodeDecodeError:
128
+ stats.dropped += 1
129
+ continue
130
+
131
+ if not line:
132
+ continue
133
+
134
+ parsed = _try_parse(line)
135
+ if not parsed:
136
+ stats.dropped += 1
137
+ continue
138
+
139
+ if len(parsed) == 1:
140
+ # Single JSON per line
141
+ try:
142
+ json.loads(line)
143
+ stats.ok += 1
144
+ except json.JSONDecodeError:
145
+ stats.fixed += 1
146
+ else:
147
+ # Glued records separated
148
+ stats.fixed += len(parsed)
149
+
150
+ repaired.extend(parsed)
151
+
152
+ if not dry_run and (stats.fixed > 0 or stats.dropped > 0):
153
+ w.atomic_rewrite(path, repaired)
154
+
155
+ if not dry_run:
156
+ idx = JSONLIndex(path)
157
+ stats.index_rebuilt = idx.rebuild()
158
+
159
+ return stats
160
+
161
+
162
+ def repair_directory(
163
+ directory: Path,
164
+ *,
165
+ dry_run: bool = False,
166
+ recursive: bool = True,
167
+ ) -> dict[str, RepairStats]:
168
+ """Repair all JSONL files in a directory.
169
+
170
+ Args:
171
+ directory: Directory to scan.
172
+ dry_run: If True, only report what would happen.
173
+ recursive: If True, scan subdirectories too.
174
+
175
+ Returns:
176
+ Mapping of filename to RepairStats.
177
+ """
178
+ if not directory.is_dir():
179
+ logger.error("Not a directory: %s", directory)
180
+ return {}
181
+
182
+ pattern = "**/*.jsonl" if recursive else "*.jsonl"
183
+ files = sorted(directory.glob(pattern))
184
+
185
+ # Deduplicate resolved paths
186
+ seen: set[str] = set()
187
+ unique: list[Path] = []
188
+ for f in files:
189
+ key = str(f.resolve())
190
+ if key not in seen:
191
+ seen.add(key)
192
+ unique.append(f)
193
+
194
+ writer = JSONLWriter()
195
+ results: dict[str, RepairStats] = {}
196
+ for path in unique:
197
+ results[str(path)] = repair_file(path, dry_run=dry_run, writer=writer)
198
+
199
+ return results
@@ -0,0 +1,16 @@
1
+ """Lesson subsystem: error tracking, lesson extraction, reinforcement, and decay.
2
+
3
+ Public API:
4
+ ErrorRecord -- dataclass for logged errors
5
+ Lesson -- dataclass for extracted lessons
6
+ LessonStore -- persistent store backed by JSONL via errlore.io
7
+ """
8
+
9
+ from errlore.lessons.models import ErrorRecord, Lesson
10
+ from errlore.lessons.store import LessonStore
11
+
12
+ __all__ = [
13
+ "ErrorRecord",
14
+ "Lesson",
15
+ "LessonStore",
16
+ ]
@@ -0,0 +1,129 @@
1
+ """Data models for the lesson subsystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+
11
+ def _short_id() -> str:
12
+ """Generate a 12-char hex ID from uuid4."""
13
+ return uuid.uuid4().hex[:12]
14
+
15
+
16
+ def _utc_now_iso() -> str:
17
+ """Return timezone-aware UTC ISO timestamp."""
18
+ return datetime.now(UTC).isoformat()
19
+
20
+
21
+ @dataclass
22
+ class ErrorRecord:
23
+ """A recorded error event."""
24
+
25
+ model: str
26
+ task_type: str
27
+ error_type: str
28
+ message: str
29
+ id: str = field(default_factory=_short_id)
30
+ timestamp: str = field(default_factory=_utc_now_iso)
31
+ resolved: bool = False
32
+ resolution: str = ""
33
+ context: str = ""
34
+ stacktrace: str = ""
35
+ metadata: dict[str, Any] | None = None
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ """Serialize to a plain dict for JSONL storage."""
39
+ d: dict[str, Any] = {
40
+ "id": self.id,
41
+ "model": self.model,
42
+ "task_type": self.task_type,
43
+ "error_type": self.error_type,
44
+ "message": self.message,
45
+ "timestamp": self.timestamp,
46
+ "resolved": self.resolved,
47
+ "resolution": self.resolution,
48
+ "context": self.context,
49
+ "stacktrace": self.stacktrace,
50
+ }
51
+ if self.metadata is not None:
52
+ d["metadata"] = self.metadata
53
+ return d
54
+
55
+ @classmethod
56
+ def from_dict(cls, d: dict[str, Any]) -> ErrorRecord:
57
+ """Deserialize from a JSONL dict."""
58
+ return cls(
59
+ id=str(d.get("id", _short_id())),
60
+ model=str(d.get("model", "")),
61
+ task_type=str(d.get("task_type", "")),
62
+ error_type=str(d.get("error_type", "")),
63
+ message=str(d.get("message", "")),
64
+ timestamp=str(d.get("timestamp", _utc_now_iso())),
65
+ resolved=bool(d.get("resolved", False)),
66
+ resolution=str(d.get("resolution", "")),
67
+ context=str(d.get("context", "")),
68
+ stacktrace=str(d.get("stacktrace", "")),
69
+ metadata=d.get("metadata"),
70
+ )
71
+
72
+
73
+ @dataclass
74
+ class Lesson:
75
+ """An extracted lesson linking a problem pattern to its solution."""
76
+
77
+ pattern: str
78
+ solution: str
79
+ id: str = field(default_factory=_short_id)
80
+ task_type: str = ""
81
+ error_type: str = ""
82
+ confidence: float = 0.8
83
+ applied_count: int = 0
84
+ created_at: str = field(default_factory=_utc_now_iso)
85
+ updated_at: str = field(default_factory=_utc_now_iso)
86
+ source_error_id: str = ""
87
+ source_errors: list[str] = field(default_factory=list)
88
+ metadata: dict[str, Any] | None = None
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ """Serialize to a plain dict for JSONL storage."""
92
+ d: dict[str, Any] = {
93
+ "id": self.id,
94
+ "pattern": self.pattern,
95
+ "solution": self.solution,
96
+ "task_type": self.task_type,
97
+ "error_type": self.error_type,
98
+ "confidence": self.confidence,
99
+ "applied_count": self.applied_count,
100
+ "created_at": self.created_at,
101
+ "updated_at": self.updated_at,
102
+ "source_error_id": self.source_error_id,
103
+ "source_errors": self.source_errors,
104
+ }
105
+ if self.metadata is not None:
106
+ d["metadata"] = self.metadata
107
+ return d
108
+
109
+ @classmethod
110
+ def from_dict(cls, d: dict[str, Any]) -> Lesson:
111
+ """Deserialize from a JSONL dict."""
112
+ raw_source_errors = d.get("source_errors")
113
+ source_errors: list[str] = (
114
+ [str(e) for e in raw_source_errors] if isinstance(raw_source_errors, list) else []
115
+ )
116
+ return cls(
117
+ id=str(d.get("id", _short_id())),
118
+ pattern=str(d.get("pattern", "")),
119
+ solution=str(d.get("solution", "")),
120
+ task_type=str(d.get("task_type", "")),
121
+ error_type=str(d.get("error_type", "")),
122
+ confidence=float(d.get("confidence", 0.8)),
123
+ applied_count=int(d.get("applied_count", 0)),
124
+ created_at=str(d.get("created_at", _utc_now_iso())),
125
+ updated_at=str(d.get("updated_at", _utc_now_iso())),
126
+ source_error_id=str(d.get("source_error_id", "")),
127
+ source_errors=source_errors,
128
+ metadata=d.get("metadata"),
129
+ )