liametahi 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.
- liametahi/__init__.py +4 -0
- liametahi/__main__.py +6 -0
- liametahi/backup.py +400 -0
- liametahi/classifier/__init__.py +117 -0
- liametahi/classifier/anthropic.py +180 -0
- liametahi/classifier/jev.py +293 -0
- liametahi/classifier/openai_compatible.py +210 -0
- liametahi/cli.py +474 -0
- liametahi/config.py +1072 -0
- liametahi/domain.py +72 -0
- liametahi/evaluate.py +931 -0
- liametahi/execute.py +777 -0
- liametahi/imap_adapter.py +1010 -0
- liametahi/locks.py +100 -0
- liametahi/logging.py +157 -0
- liametahi/migrations/0001_initial.sql +193 -0
- liametahi/policy.py +223 -0
- liametahi/progress.py +189 -0
- liametahi/prompt.py +523 -0
- liametahi/py.typed +0 -0
- liametahi/report.py +405 -0
- liametahi/rules.py +501 -0
- liametahi/runner.py +1177 -0
- liametahi/state.py +1078 -0
- liametahi-0.1.0.dist-info/METADATA +113 -0
- liametahi-0.1.0.dist-info/RECORD +29 -0
- liametahi-0.1.0.dist-info/WHEEL +4 -0
- liametahi-0.1.0.dist-info/entry_points.txt +2 -0
- liametahi-0.1.0.dist-info/licenses/LICENSE +21 -0
liametahi/__init__.py
ADDED
liametahi/__main__.py
ADDED
liametahi/backup.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Verified `.eml` backup and restore.
|
|
2
|
+
|
|
3
|
+
`MailboxAdapter`, `MailboxStatus`, `RawMetadata`, `UnsupportedCapability`,
|
|
4
|
+
and `MessageVanished` were originally reproduced locally in this module,
|
|
5
|
+
exactly as `tests/fakes/fake_mailbox.py` used to: `imap_adapter.py`
|
|
6
|
+
(Unit 2) was being built concurrently and this unit could not import or
|
|
7
|
+
create it (a work-unit boundary). Now that
|
|
8
|
+
`imap_adapter.py` has landed, this module imports the real definitions
|
|
9
|
+
per the explicit instruction ("Units 2 and 3 must move the
|
|
10
|
+
real definitions into `imap_adapter.py` ... and change the fakes to
|
|
11
|
+
import them, deleting the local copies") -- the same cleanup already
|
|
12
|
+
applied to `tests/fakes/fake_mailbox.py`. `MailboxAdapter` is a
|
|
13
|
+
`Protocol`, so any object shaped like a real mailbox adapter --
|
|
14
|
+
`FakeMailbox` included -- still satisfies it without change.
|
|
15
|
+
|
|
16
|
+
`is_vanished_error`/`is_unsupported_error` below still match by
|
|
17
|
+
exception *name* rather than `isinstance`. That was originally a
|
|
18
|
+
necessary bridge (a `MessageVanished` raised by a fake and one raised by
|
|
19
|
+
the not-yet-existing real adapter were different Python objects); now
|
|
20
|
+
that both sides import the same classes it is merely a harmless
|
|
21
|
+
belt-and-braces choice, kept as-is to minimise this cleanup's diff.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import contextlib
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sqlite3
|
|
29
|
+
import tempfile
|
|
30
|
+
from collections.abc import Collection
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from liametahi import state
|
|
36
|
+
from liametahi.domain import MessageKey
|
|
37
|
+
from liametahi.imap_adapter import (
|
|
38
|
+
MailboxAdapter,
|
|
39
|
+
MailboxStatus,
|
|
40
|
+
MessageVanished,
|
|
41
|
+
RawMetadata,
|
|
42
|
+
UnsupportedCapability,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"BackupError",
|
|
47
|
+
"MailboxAdapter",
|
|
48
|
+
"MailboxStatus",
|
|
49
|
+
"MessageVanished",
|
|
50
|
+
"RawMetadata",
|
|
51
|
+
"UnsupportedCapability",
|
|
52
|
+
"is_unsupported_error",
|
|
53
|
+
"is_vanished_error",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_vanished_error(exc: Exception) -> bool:
|
|
58
|
+
"""True for a `MessageVanished` (by name, see module docstring) or
|
|
59
|
+
any `LookupError` (e.g. the `KeyError` `FakeMailbox.fetch_raw` and
|
|
60
|
+
`fetch_metadata` raise for an absent UID) -- both mean "the message
|
|
61
|
+
is not there for us to act on"."""
|
|
62
|
+
return isinstance(exc, LookupError) or type(exc).__name__ == "MessageVanished"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_unsupported_error(exc: Exception) -> bool:
|
|
66
|
+
return type(exc).__name__ == "UnsupportedCapability"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- Verified backup write --------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BackupError(Exception):
|
|
73
|
+
"""Backup could not be written and verified. Per the specification:
|
|
74
|
+
'If backup verification fails, record the failure and do not modify
|
|
75
|
+
the mailbox' -- callers must not proceed to a mutation when this is
|
|
76
|
+
raised."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class BackupResult:
|
|
81
|
+
backup_id: str
|
|
82
|
+
sha256: str
|
|
83
|
+
byte_count: int
|
|
84
|
+
relative_path: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def backup_relative_path(sha256: str) -> Path:
|
|
88
|
+
"""Content-addressed path, fanned out by the first four hex
|
|
89
|
+
characters so the backup directory never holds a huge flat listing:
|
|
90
|
+
`<aa>/<bb>/<sha256>.eml`."""
|
|
91
|
+
return Path(sha256[0:2]) / sha256[2:4] / f"{sha256}.eml"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ensure_backup_dir(backup_dir: Path) -> Path:
|
|
95
|
+
"""Create the backup directory (and its `.tmp` staging
|
|
96
|
+
subdirectory) with mode 0700."""
|
|
97
|
+
backup_dir = backup_dir.expanduser()
|
|
98
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
backup_dir.chmod(0o700)
|
|
100
|
+
tmp_dir = backup_dir / ".tmp"
|
|
101
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
tmp_dir.chmod(0o700)
|
|
103
|
+
return backup_dir
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _sha256_of_file(path: Path) -> str:
|
|
107
|
+
digest = hashlib.sha256()
|
|
108
|
+
with path.open("rb") as fh:
|
|
109
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
110
|
+
digest.update(chunk)
|
|
111
|
+
return digest.hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _iso(value: datetime) -> str:
|
|
115
|
+
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _from_iso(value: str) -> datetime:
|
|
119
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_verified_backup(
|
|
123
|
+
conn: sqlite3.Connection,
|
|
124
|
+
*,
|
|
125
|
+
mailbox: MailboxAdapter,
|
|
126
|
+
backup_dir: Path,
|
|
127
|
+
key: MessageKey,
|
|
128
|
+
fingerprint: str,
|
|
129
|
+
message_id: str | None,
|
|
130
|
+
original_flags: Collection[str],
|
|
131
|
+
internaldate: datetime,
|
|
132
|
+
run_id: str,
|
|
133
|
+
raw: bytes | None = None,
|
|
134
|
+
) -> BackupResult:
|
|
135
|
+
"""Fetch raw RFC 822 bytes with `BODY.PEEK[]` (the adapter's job --
|
|
136
|
+
this function only calls `fetch_raw`, never touching `\\Seen`),
|
|
137
|
+
write to a unique temporary file in the backup filesystem, fsync,
|
|
138
|
+
checksum the bytes actually on disk, atomically rename to the
|
|
139
|
+
SHA-256 content-addressed final path, then commit the manifest row.
|
|
140
|
+
|
|
141
|
+
Idempotent under a retry with the same key: if a manifest row for
|
|
142
|
+
this exact `(account_id, mailbox, uidvalidity, uid, sha256)`
|
|
143
|
+
already exists (the manifest's own uniqueness constraint), that
|
|
144
|
+
existing row's `backup_id` is returned rather than raising a
|
|
145
|
+
`sqlite3.IntegrityError` or writing a duplicate.
|
|
146
|
+
|
|
147
|
+
Raises `BackupError` -- without ever calling a mutating mailbox
|
|
148
|
+
method -- if the fetch, write, or verification fails. The caller
|
|
149
|
+
must treat `BackupError` as "the mailbox was not touched, do not
|
|
150
|
+
proceed to the mutation."
|
|
151
|
+
|
|
152
|
+
`raw`, when given, is the message's bytes already in hand from an
|
|
153
|
+
earlier fetch of this same key -- the execute phase pulls metadata
|
|
154
|
+
and body in one round trip -- and skips the `fetch_raw` below. It
|
|
155
|
+
changes nothing downstream: the bytes still go through the same
|
|
156
|
+
write, fsync, and read-back-and-checksum verification, so a caller
|
|
157
|
+
passing corrupt bytes is caught exactly as a corrupt fetch would be.
|
|
158
|
+
"""
|
|
159
|
+
if raw is None:
|
|
160
|
+
try:
|
|
161
|
+
raw = mailbox.fetch_raw(key.uid)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
raise BackupError(
|
|
164
|
+
f"could not fetch raw message for backup ({key.render()}): {exc}"
|
|
165
|
+
) from exc
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
backup_dir = ensure_backup_dir(backup_dir)
|
|
169
|
+
tmp_dir = backup_dir / ".tmp"
|
|
170
|
+
fd, tmp_name = tempfile.mkstemp(dir=tmp_dir, suffix=".eml.tmp")
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
raise BackupError(
|
|
173
|
+
f"could not prepare backup directory for {key.render()}: {exc}"
|
|
174
|
+
) from exc
|
|
175
|
+
tmp_path = Path(tmp_name)
|
|
176
|
+
try:
|
|
177
|
+
with os.fdopen(fd, "wb") as fh:
|
|
178
|
+
fh.write(raw)
|
|
179
|
+
fh.flush()
|
|
180
|
+
os.fsync(fh.fileno())
|
|
181
|
+
tmp_path.chmod(0o600)
|
|
182
|
+
# Checksum the bytes actually on disk, not the in-memory
|
|
183
|
+
# buffer, so a write-time corruption is caught rather than
|
|
184
|
+
# silently backed up ("fsync, checksum" as a
|
|
185
|
+
# distinct step after the write).
|
|
186
|
+
sha256 = _sha256_of_file(tmp_path)
|
|
187
|
+
byte_count = tmp_path.stat().st_size
|
|
188
|
+
if byte_count != len(raw):
|
|
189
|
+
raise BackupError(
|
|
190
|
+
f"backup write size mismatch for {key.render()}: "
|
|
191
|
+
f"wrote {len(raw)} bytes, found {byte_count} on disk"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
relative_path = backup_relative_path(sha256)
|
|
195
|
+
final_path = backup_dir / relative_path
|
|
196
|
+
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
final_path.parent.chmod(0o700)
|
|
198
|
+
|
|
199
|
+
if final_path.exists():
|
|
200
|
+
# Content-addressed dedup: identical
|
|
201
|
+
# bytes already backed up (possibly for a different
|
|
202
|
+
# message). Verify the existing file still matches before
|
|
203
|
+
# trusting it.
|
|
204
|
+
if _sha256_of_file(final_path) != sha256:
|
|
205
|
+
raise BackupError(
|
|
206
|
+
f"backup dedup path {final_path} exists with unexpected "
|
|
207
|
+
"content; refusing to overwrite"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
os.replace(tmp_path, final_path)
|
|
211
|
+
final_path.chmod(0o600)
|
|
212
|
+
except BackupError:
|
|
213
|
+
raise
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
raise BackupError(f"backup write failed for {key.render()}: {exc}") from exc
|
|
216
|
+
finally:
|
|
217
|
+
with contextlib.suppress(FileNotFoundError):
|
|
218
|
+
tmp_path.unlink()
|
|
219
|
+
|
|
220
|
+
existing = _find_existing_backup(
|
|
221
|
+
conn,
|
|
222
|
+
account_id=key.account_id,
|
|
223
|
+
mailbox=key.mailbox,
|
|
224
|
+
uidvalidity=key.uidvalidity,
|
|
225
|
+
uid=key.uid,
|
|
226
|
+
sha256=sha256,
|
|
227
|
+
)
|
|
228
|
+
if existing is not None:
|
|
229
|
+
return BackupResult(
|
|
230
|
+
backup_id=str(existing["backup_id"]),
|
|
231
|
+
sha256=sha256,
|
|
232
|
+
byte_count=byte_count,
|
|
233
|
+
relative_path=str(relative_path),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
backup_id = state.new_backup_id()
|
|
237
|
+
try:
|
|
238
|
+
state.insert_backup(
|
|
239
|
+
conn,
|
|
240
|
+
backup_id=backup_id,
|
|
241
|
+
account_id=key.account_id,
|
|
242
|
+
mailbox=key.mailbox,
|
|
243
|
+
uidvalidity=key.uidvalidity,
|
|
244
|
+
uid=key.uid,
|
|
245
|
+
fingerprint=fingerprint,
|
|
246
|
+
message_id=message_id,
|
|
247
|
+
sha256=sha256,
|
|
248
|
+
byte_count=byte_count,
|
|
249
|
+
relative_path=str(relative_path),
|
|
250
|
+
original_mailbox=key.mailbox,
|
|
251
|
+
original_flags=sorted(original_flags),
|
|
252
|
+
internaldate=_iso(internaldate),
|
|
253
|
+
run_id=run_id,
|
|
254
|
+
)
|
|
255
|
+
except sqlite3.IntegrityError:
|
|
256
|
+
# Lost a race against a concurrent insert of the same natural
|
|
257
|
+
# key: re-read rather than fail.
|
|
258
|
+
existing = _find_existing_backup(
|
|
259
|
+
conn,
|
|
260
|
+
account_id=key.account_id,
|
|
261
|
+
mailbox=key.mailbox,
|
|
262
|
+
uidvalidity=key.uidvalidity,
|
|
263
|
+
uid=key.uid,
|
|
264
|
+
sha256=sha256,
|
|
265
|
+
)
|
|
266
|
+
if existing is None:
|
|
267
|
+
raise
|
|
268
|
+
backup_id = str(existing["backup_id"])
|
|
269
|
+
|
|
270
|
+
return BackupResult(
|
|
271
|
+
backup_id=backup_id,
|
|
272
|
+
sha256=sha256,
|
|
273
|
+
byte_count=byte_count,
|
|
274
|
+
relative_path=str(relative_path),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _find_existing_backup(
|
|
279
|
+
conn: sqlite3.Connection,
|
|
280
|
+
*,
|
|
281
|
+
account_id: int,
|
|
282
|
+
mailbox: str,
|
|
283
|
+
uidvalidity: int,
|
|
284
|
+
uid: int,
|
|
285
|
+
sha256: str,
|
|
286
|
+
) -> sqlite3.Row | None:
|
|
287
|
+
"""A natural-key lookup `state.py` does not expose (it only offers
|
|
288
|
+
`get_backup(backup_id)`; its insert-only surface for backups was not
|
|
289
|
+
extended with this query). This is the one read this module cannot
|
|
290
|
+
route through `state.py`'s typed surface without a change to a file
|
|
291
|
+
this unit does not own; it is read-only, scoped to exactly the
|
|
292
|
+
manifest's own uniqueness constraint -- a known gap `state.py` should
|
|
293
|
+
close by adding this query to its own typed surface.
|
|
294
|
+
"""
|
|
295
|
+
return conn.execute( # type: ignore[no-any-return]
|
|
296
|
+
"""
|
|
297
|
+
SELECT * FROM backups
|
|
298
|
+
WHERE account_id = ? AND mailbox = ? AND uidvalidity = ? AND uid = ?
|
|
299
|
+
AND sha256 = ?
|
|
300
|
+
""",
|
|
301
|
+
(account_id, mailbox, uidvalidity, uid, sha256),
|
|
302
|
+
).fetchone()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def find_backups_by_key(
|
|
306
|
+
conn: sqlite3.Connection, *, key: MessageKey
|
|
307
|
+
) -> list[sqlite3.Row]:
|
|
308
|
+
"""All backup manifest rows recorded for a message key, newest
|
|
309
|
+
first. Used by reconcile (`execute.py`); not exposed by `state.py`
|
|
310
|
+
today (see `_find_existing_backup`'s docstring)."""
|
|
311
|
+
return conn.execute(
|
|
312
|
+
"""
|
|
313
|
+
SELECT * FROM backups
|
|
314
|
+
WHERE account_id = ? AND mailbox = ? AND uidvalidity = ? AND uid = ?
|
|
315
|
+
ORDER BY backed_up_at DESC
|
|
316
|
+
""",
|
|
317
|
+
(key.account_id, key.mailbox, key.uidvalidity, key.uid),
|
|
318
|
+
).fetchall()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- Restore -----------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class RestoreError(Exception):
|
|
325
|
+
"""The backup could not be verified or restored."""
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True, slots=True)
|
|
329
|
+
class RestoreResult:
|
|
330
|
+
backup_id: str
|
|
331
|
+
mailbox: str
|
|
332
|
+
byte_count: int
|
|
333
|
+
sha256: str
|
|
334
|
+
dry_run: bool
|
|
335
|
+
appended: bool
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def restore_backup(
|
|
339
|
+
conn: sqlite3.Connection,
|
|
340
|
+
*,
|
|
341
|
+
mailbox: MailboxAdapter,
|
|
342
|
+
backup_dir: Path,
|
|
343
|
+
backup_id: str,
|
|
344
|
+
destination_mailbox: str,
|
|
345
|
+
dry_run: bool,
|
|
346
|
+
) -> RestoreResult:
|
|
347
|
+
"""Verify the backup's checksum against its file,
|
|
348
|
+
then `APPEND` it to `destination_mailbox`, preserving the original
|
|
349
|
+
`INTERNALDATE` and flags minus `\\Deleted` and `\\Recent`. Records
|
|
350
|
+
the new server identity on the manifest row. `--dry-run` verifies
|
|
351
|
+
the checksum and reports what would be appended without calling
|
|
352
|
+
`append`.
|
|
353
|
+
|
|
354
|
+
Restore is best-effort: it does not detect that
|
|
355
|
+
the message already exists in the destination, so restoring an
|
|
356
|
+
already-present message produces a duplicate.
|
|
357
|
+
"""
|
|
358
|
+
row = state.get_backup(conn, backup_id)
|
|
359
|
+
if row is None:
|
|
360
|
+
raise RestoreError(f"no backup found with id {backup_id!r}")
|
|
361
|
+
|
|
362
|
+
file_path = backup_dir.expanduser() / str(row["relative_path"])
|
|
363
|
+
if not file_path.is_file():
|
|
364
|
+
raise RestoreError(
|
|
365
|
+
f"backup file missing for {backup_id}: expected at {file_path}"
|
|
366
|
+
)
|
|
367
|
+
actual_sha256 = _sha256_of_file(file_path)
|
|
368
|
+
expected_sha256 = str(row["sha256"])
|
|
369
|
+
if actual_sha256 != expected_sha256:
|
|
370
|
+
raise RestoreError(
|
|
371
|
+
f"backup {backup_id} failed checksum verification: "
|
|
372
|
+
f"expected {expected_sha256}, found {actual_sha256}"
|
|
373
|
+
)
|
|
374
|
+
raw = file_path.read_bytes()
|
|
375
|
+
byte_count = len(raw)
|
|
376
|
+
|
|
377
|
+
original_flags = set(json.loads(str(row["original_flags"])))
|
|
378
|
+
restore_flags = original_flags - {"\\Deleted", "\\Recent"}
|
|
379
|
+
internaldate = _from_iso(str(row["internaldate"]))
|
|
380
|
+
|
|
381
|
+
if dry_run:
|
|
382
|
+
return RestoreResult(
|
|
383
|
+
backup_id=backup_id,
|
|
384
|
+
mailbox=destination_mailbox,
|
|
385
|
+
byte_count=byte_count,
|
|
386
|
+
sha256=expected_sha256,
|
|
387
|
+
dry_run=True,
|
|
388
|
+
appended=False,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
mailbox.append(destination_mailbox, raw, restore_flags, internaldate)
|
|
392
|
+
state.record_restore(conn, backup_id=backup_id, restored_to=destination_mailbox)
|
|
393
|
+
return RestoreResult(
|
|
394
|
+
backup_id=backup_id,
|
|
395
|
+
mailbox=destination_mailbox,
|
|
396
|
+
byte_count=byte_count,
|
|
397
|
+
sha256=expected_sha256,
|
|
398
|
+
dry_run=False,
|
|
399
|
+
appended=True,
|
|
400
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Classifier protocol and payload/response models.
|
|
2
|
+
|
|
3
|
+
This is the fixed cross-unit interface between the evaluate phase (this
|
|
4
|
+
unit) and the provider adapters (`openai_compatible.py`, `anthropic.py`,
|
|
5
|
+
`jev.py`, also this unit). It was originally reproduced verbatim inside
|
|
6
|
+
`tests/fakes/fake_classifier.py` because this module did not yet exist
|
|
7
|
+
when Unit 1 landed; per the explicit instruction that fake now imports
|
|
8
|
+
these definitions from here instead of declaring local copies.
|
|
9
|
+
|
|
10
|
+
**Validation against the offered processor and candidate vocabulary
|
|
11
|
+
happens in the caller (see `liametahi.evaluate`), never in an adapter.**
|
|
12
|
+
An adapter's `classify()` only has to do transport, structured-output
|
|
13
|
+
negotiation, and JSON parsing; it is free to hand back a `Classification`
|
|
14
|
+
that names a processor or candidate id it was never offered, or an
|
|
15
|
+
answer value outside a processor's declared criteria; the model is
|
|
16
|
+
untrusted and the architecture assumes every adapter response is hostile
|
|
17
|
+
until validated -- an adapter must never let a model response widen what
|
|
18
|
+
an action may do.
|
|
19
|
+
|
|
20
|
+
This retires the old single-`llm`-atom,
|
|
21
|
+
yes/no/unsure vocabulary (`OfferedRule`/`Classification.matches`/
|
|
22
|
+
`needs_content`) in favour of a per-processor answer map: each candidate
|
|
23
|
+
may be asked about several independently-named processors in one batch,
|
|
24
|
+
and each processor answers with a resolved `value` (a probability in
|
|
25
|
+
`[0, 1]` for `noul`, a choice string for `choice`, a level string for
|
|
26
|
+
`score`) plus an optional `confidence` (jev always populates it for
|
|
27
|
+
`choice`/`score`, never for `noul`; a chat processor only if its own
|
|
28
|
+
declared schema asked for it -- which it never does for `noul`, since a
|
|
29
|
+
`noul` processor's `.confidence` is always `None` regardless of backend).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from collections.abc import Mapping, Sequence
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Protocol
|
|
35
|
+
|
|
36
|
+
from liametahi.rules import ProcessorAnswer
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"CandidatePayload",
|
|
40
|
+
"Classification",
|
|
41
|
+
"Classifier",
|
|
42
|
+
"ClassifyOutcome",
|
|
43
|
+
"OfferedProcessor",
|
|
44
|
+
"ProcessorAnswer",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class OfferedProcessor:
|
|
50
|
+
"""One processor offered to the model for a batch: its name and
|
|
51
|
+
enough of its declared shape (`type`, its required `instructions`,
|
|
52
|
+
plus its optional/required `criteria`) for an adapter to compile a
|
|
53
|
+
request/schema from (`jev.py` maps these straight onto its
|
|
54
|
+
`noul`/`choice`/`score` request shape -- jev's own schema, not a
|
|
55
|
+
project invention; `prompt.py` compiles the same fields into a chat
|
|
56
|
+
prompt + JSON schema for the other two providers). `criteria`'s shape
|
|
57
|
+
depends on `type`: a `{"true": ..., "false": ...}` mapping (optional)
|
|
58
|
+
for `noul`, an `{option: description}` mapping (required) for
|
|
59
|
+
`choice`, or an ordered sequence of level names (required) for
|
|
60
|
+
`score`."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
type: str # "noul" | "choice" | "score"
|
|
64
|
+
instructions: str
|
|
65
|
+
criteria: Mapping[str, str] | Sequence[str] | None = None
|
|
66
|
+
include_body: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class CandidatePayload:
|
|
71
|
+
"""One candidate as sent to the model: a batch-local id and its
|
|
72
|
+
already capped-and-sanitised metadata fields."""
|
|
73
|
+
|
|
74
|
+
payload_id: str # batch-local, "c1".."cN"
|
|
75
|
+
fields: Mapping[str, object] # already capped and sanitised by prompt.py
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class Classification:
|
|
80
|
+
"""One raw, unvalidated per-candidate model response item.
|
|
81
|
+
|
|
82
|
+
`answers` maps a processor name to its raw, not-yet-validated
|
|
83
|
+
`ProcessorAnswer` -- only for the processors this response actually
|
|
84
|
+
resolved for this candidate this round; a processor name absent from
|
|
85
|
+
the map means "not answered this round" and the caller must treat it
|
|
86
|
+
the same as invalid/needing retry, never as silently unknown-forever.
|
|
87
|
+
`value` may name an option/level outside what was offered, or the
|
|
88
|
+
wrong type for the processor's declared `type` -- none of that has
|
|
89
|
+
been validated yet.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
payload_id: str
|
|
93
|
+
answers: Mapping[str, ProcessorAnswer]
|
|
94
|
+
reason: str | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class ClassifyOutcome:
|
|
99
|
+
"""The full result of one `classify()` call: parsed items, the
|
|
100
|
+
payload ids that failed structural parsing, the payload ids simply
|
|
101
|
+
absent from the response, and per-call metadata for the audit trail."""
|
|
102
|
+
|
|
103
|
+
results: tuple[Classification, ...] # validated items only
|
|
104
|
+
invalid: tuple[str, ...] # payload_ids that failed validation
|
|
105
|
+
missing: tuple[str, ...] # payload_ids absent from the response
|
|
106
|
+
structured_output_level: str # json_schema|json_object|none
|
|
107
|
+
input_tokens: int | None
|
|
108
|
+
output_tokens: int | None
|
|
109
|
+
latency_ms: int
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Classifier(Protocol):
|
|
113
|
+
def classify(
|
|
114
|
+
self,
|
|
115
|
+
candidates: Sequence[CandidatePayload],
|
|
116
|
+
processors: Sequence[OfferedProcessor],
|
|
117
|
+
) -> ClassifyOutcome: ...
|