lambda-watcher 0.4.1__tar.gz → 0.6.0__tar.gz
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.
- {lambda_watcher-0.4.1/src/lambda_watcher.egg-info → lambda_watcher-0.6.0}/PKG-INFO +51 -1
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/README.md +50 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/pyproject.toml +7 -1
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/__init__.py +1 -1
- lambda_watcher-0.6.0/src/lambda_watcher/ai/__init__.py +26 -0
- lambda_watcher-0.6.0/src/lambda_watcher/ai/explanation.py +471 -0
- lambda_watcher-0.6.0/src/lambda_watcher/ai/prompt.py +434 -0
- lambda_watcher-0.6.0/src/lambda_watcher/ai/providers.py +814 -0
- lambda_watcher-0.6.0/src/lambda_watcher/ai/report.py +138 -0
- lambda_watcher-0.6.0/src/lambda_watcher/ai/run.py +306 -0
- lambda_watcher-0.6.0/src/lambda_watcher/ai/settings.py +565 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/cli.py +1101 -21
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/build.py +21 -6
- lambda_watcher-0.6.0/src/lambda_watcher/diffing/filetree.py +163 -0
- lambda_watcher-0.6.0/src/lambda_watcher/diffing/fonts/CascadiaMono-Regular.woff2 +0 -0
- lambda_watcher-0.6.0/src/lambda_watcher/diffing/fonts/CascadiaMono-SemiBold.woff2 +0 -0
- lambda_watcher-0.6.0/src/lambda_watcher/diffing/fonts/OFL.txt +94 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/icons.py +12 -0
- lambda_watcher-0.6.0/src/lambda_watcher/diffing/render_html.py +2469 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/render_text.py +61 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/helptext.py +210 -6
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/ingest.py +52 -5
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/templates.py +5 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0/src/lambda_watcher.egg-info}/PKG-INFO +51 -1
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/SOURCES.txt +12 -0
- lambda_watcher-0.6.0/tests/test_ai.py +901 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_cli.py +4 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_diff.py +44 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_docs.py +6 -8
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_helptext.py +41 -3
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_render_html.py +211 -2
- lambda_watcher-0.4.1/src/lambda_watcher/diffing/render_html.py +0 -1345
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/LICENSE +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/setup.cfg +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/__main__.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/__init__.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/deps.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/envvars.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/handler.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/inventory.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/runtime.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/secrets.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/services.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/config.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/db.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/demo.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/__init__.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/compare.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/highlight.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/intraline.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/extract.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/gitmirror.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/heartbeat.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/identify.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/notify.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/reindex.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/service.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/store.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/utils.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher/watcher.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/dependency_links.txt +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/entry_points.txt +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/requires.txt +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/top_level.txt +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_analysis.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_config.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_extract.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_heartbeat.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_identify.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_ingest.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_no_window.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_reindex.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_service.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_utils.py +0 -0
- {lambda_watcher-0.4.1 → lambda_watcher-0.6.0}/tests/test_watcher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-watcher
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions.
|
|
5
5
|
Author: Lambda Watcher contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -254,6 +254,54 @@ the manual recipes.
|
|
|
254
254
|
|
|
255
255
|
</details>
|
|
256
256
|
|
|
257
|
+
## Explained in plain English (optional)
|
|
258
|
+
|
|
259
|
+
Add an AI model once and every change is also explained for you — what the
|
|
260
|
+
function now does differently, what is worth checking before it ships, and a
|
|
261
|
+
deploy checklist:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
lw ai add # pick a service, paste a key, pick a model; it checks the model answers
|
|
265
|
+
lw explain order-processor # explain the latest change, here and in the report
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`lw ai add` works with Anthropic, OpenAI, Azure OpenAI (paste the endpoint or
|
|
269
|
+
the whole Target URI from the portal) and any model on your own machine —
|
|
270
|
+
Ollama, LM Studio, vLLM. There is nothing to install: it uses Python's own
|
|
271
|
+
HTTPS, and a key you already export as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`
|
|
272
|
+
is picked up without asking.
|
|
273
|
+
|
|
274
|
+
From then on the watcher explains each new version in the background, and the
|
|
275
|
+
HTML report opens on the answer: every file it mentions opens that file's diff,
|
|
276
|
+
each changed file gets a one-line note, the deploy checklist remembers what you
|
|
277
|
+
ticked, and the history page lists what each release *did*. A report opened
|
|
278
|
+
while the explanation is being written reloads itself when it lands. Reports
|
|
279
|
+
written before you added a model get one with `lw explain`, and `--all` fills in
|
|
280
|
+
a function's whole history.
|
|
281
|
+
|
|
282
|
+
What leaves the machine, and when, is yours to decide:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
lw explain order-processor --dry-run # print exactly what would be sent; send nothing
|
|
286
|
+
lw ai settings --no-auto # explain only when asked, not every new version
|
|
287
|
+
lw ai settings --no-send-code # send the shape of a change, never a line of code
|
|
288
|
+
lw ai use quick # switch between saved models
|
|
289
|
+
lw ai off # stop entirely, keeping your keys (lw ai on undoes it)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Vendored packages are never sent, files like `.env` and `*.pem` are named but
|
|
293
|
+
never quoted, and anything credential-shaped is replaced before sending — a
|
|
294
|
+
mitigation, not a guarantee, so for code that must not leave your machine use
|
|
295
|
+
`--no-send-code` or a local model. Keys live in `~/.lambda-watcher/ai.json`,
|
|
296
|
+
readable only by you, never in `config.yaml`.
|
|
297
|
+
|
|
298
|
+
Rate limits, overloads, timeouts and dropped connections are retried with
|
|
299
|
+
backoff, honouring the service's own `Retry-After`; an account out of credit is
|
|
300
|
+
told apart from a rate limit and not retried pointlessly; a change too large for
|
|
301
|
+
the model is sent again smaller. If a request still fails, the report says why
|
|
302
|
+
and gives the command that tries again. `lw ai test` checks a model still
|
|
303
|
+
answers, and `lw doctor` flags a saved model whose key has gone missing.
|
|
304
|
+
|
|
257
305
|
## Commands
|
|
258
306
|
|
|
259
307
|
| Command | What it does |
|
|
@@ -271,6 +319,8 @@ the manual recipes.
|
|
|
271
319
|
| `show FN [V]` | Runtime, handler, dependencies, env vars, services and findings for one version. `--files`, `--json`. |
|
|
272
320
|
| `diff FN` | Compare two versions. Defaults to the last two. `--from`/`--to`, `--html`, `--open`, `--vendor`, `--whitespace`, `--no-patch`, `--json`. |
|
|
273
321
|
| `report FN` | Build a browsable HTML history: an index plus a diff for every step, opened in your browser. `--no-open` just writes it. |
|
|
322
|
+
| `explain FN` | Explain a change in plain English with an AI model, here and in the report. `--from`/`--to`, `--model`, `--refresh`, `--all`, `--dry-run`, `--json`, `--open`. |
|
|
323
|
+
| `ai` | Set up and manage the AI models: `ai add`, `ai remove`, `ai use`, `ai test`, `ai settings`, `ai on` / `ai off`. On its own, shows what is set up. |
|
|
274
324
|
| `export FN [V]` | Get a version back out as a deployable zip (`--zip`) or a plain folder (`--tree`). |
|
|
275
325
|
| `open FN [V]` | Open the function's mirror in your editor — every version in one folder, with history. Name a version to open just its files. |
|
|
276
326
|
| `git FN ...` | Run git inside that function's mirror repo: `lw git order-processor log --oneline`. |
|
|
@@ -223,6 +223,54 @@ the manual recipes.
|
|
|
223
223
|
|
|
224
224
|
</details>
|
|
225
225
|
|
|
226
|
+
## Explained in plain English (optional)
|
|
227
|
+
|
|
228
|
+
Add an AI model once and every change is also explained for you — what the
|
|
229
|
+
function now does differently, what is worth checking before it ships, and a
|
|
230
|
+
deploy checklist:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
lw ai add # pick a service, paste a key, pick a model; it checks the model answers
|
|
234
|
+
lw explain order-processor # explain the latest change, here and in the report
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`lw ai add` works with Anthropic, OpenAI, Azure OpenAI (paste the endpoint or
|
|
238
|
+
the whole Target URI from the portal) and any model on your own machine —
|
|
239
|
+
Ollama, LM Studio, vLLM. There is nothing to install: it uses Python's own
|
|
240
|
+
HTTPS, and a key you already export as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`
|
|
241
|
+
is picked up without asking.
|
|
242
|
+
|
|
243
|
+
From then on the watcher explains each new version in the background, and the
|
|
244
|
+
HTML report opens on the answer: every file it mentions opens that file's diff,
|
|
245
|
+
each changed file gets a one-line note, the deploy checklist remembers what you
|
|
246
|
+
ticked, and the history page lists what each release *did*. A report opened
|
|
247
|
+
while the explanation is being written reloads itself when it lands. Reports
|
|
248
|
+
written before you added a model get one with `lw explain`, and `--all` fills in
|
|
249
|
+
a function's whole history.
|
|
250
|
+
|
|
251
|
+
What leaves the machine, and when, is yours to decide:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
lw explain order-processor --dry-run # print exactly what would be sent; send nothing
|
|
255
|
+
lw ai settings --no-auto # explain only when asked, not every new version
|
|
256
|
+
lw ai settings --no-send-code # send the shape of a change, never a line of code
|
|
257
|
+
lw ai use quick # switch between saved models
|
|
258
|
+
lw ai off # stop entirely, keeping your keys (lw ai on undoes it)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Vendored packages are never sent, files like `.env` and `*.pem` are named but
|
|
262
|
+
never quoted, and anything credential-shaped is replaced before sending — a
|
|
263
|
+
mitigation, not a guarantee, so for code that must not leave your machine use
|
|
264
|
+
`--no-send-code` or a local model. Keys live in `~/.lambda-watcher/ai.json`,
|
|
265
|
+
readable only by you, never in `config.yaml`.
|
|
266
|
+
|
|
267
|
+
Rate limits, overloads, timeouts and dropped connections are retried with
|
|
268
|
+
backoff, honouring the service's own `Retry-After`; an account out of credit is
|
|
269
|
+
told apart from a rate limit and not retried pointlessly; a change too large for
|
|
270
|
+
the model is sent again smaller. If a request still fails, the report says why
|
|
271
|
+
and gives the command that tries again. `lw ai test` checks a model still
|
|
272
|
+
answers, and `lw doctor` flags a saved model whose key has gone missing.
|
|
273
|
+
|
|
226
274
|
## Commands
|
|
227
275
|
|
|
228
276
|
| Command | What it does |
|
|
@@ -240,6 +288,8 @@ the manual recipes.
|
|
|
240
288
|
| `show FN [V]` | Runtime, handler, dependencies, env vars, services and findings for one version. `--files`, `--json`. |
|
|
241
289
|
| `diff FN` | Compare two versions. Defaults to the last two. `--from`/`--to`, `--html`, `--open`, `--vendor`, `--whitespace`, `--no-patch`, `--json`. |
|
|
242
290
|
| `report FN` | Build a browsable HTML history: an index plus a diff for every step, opened in your browser. `--no-open` just writes it. |
|
|
291
|
+
| `explain FN` | Explain a change in plain English with an AI model, here and in the report. `--from`/`--to`, `--model`, `--refresh`, `--all`, `--dry-run`, `--json`, `--open`. |
|
|
292
|
+
| `ai` | Set up and manage the AI models: `ai add`, `ai remove`, `ai use`, `ai test`, `ai settings`, `ai on` / `ai off`. On its own, shows what is set up. |
|
|
243
293
|
| `export FN [V]` | Get a version back out as a deployable zip (`--zip`) or a plain folder (`--tree`). |
|
|
244
294
|
| `open FN [V]` | Open the function's mirror in your editor — every version in one folder, with history. Name a version to open just its files. |
|
|
245
295
|
| `git FN ...` | Run git inside that function's mirror repo: `lw git order-processor log --oneline`. |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lambda-watcher"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -42,6 +42,12 @@ Issues = "https://github.com/utkarsh5026/lambwatch/issues"
|
|
|
42
42
|
[tool.setuptools.packages.find]
|
|
43
43
|
where = ["src"]
|
|
44
44
|
|
|
45
|
+
# The code font the HTML report embeds, and its licence, which has to travel
|
|
46
|
+
# with it. Without this the wheel ships neither and the report quietly falls
|
|
47
|
+
# back to a system monospace.
|
|
48
|
+
[tool.setuptools.package-data]
|
|
49
|
+
lambda_watcher = ["diffing/fonts/*.woff2", "diffing/fonts/OFL.txt"]
|
|
50
|
+
|
|
45
51
|
[tool.pytest.ini_options]
|
|
46
52
|
testpaths = ["tests"]
|
|
47
53
|
addopts = "-q"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Plain-English explanations of what changed between two archived versions.
|
|
2
|
+
|
|
3
|
+
Everything here sits on top of the diff engine rather than beside it: the
|
|
4
|
+
comparison in :mod:`lambda_watcher.diffing` already decides *what* changed, and
|
|
5
|
+
a language model's only job is to say what that means for someone about to
|
|
6
|
+
deploy it. So nothing in this package ever reads a zip, and nothing it produces
|
|
7
|
+
goes into ``index.db`` — explanations live beside the version they describe,
|
|
8
|
+
where ``lw reindex`` never has to know about them.
|
|
9
|
+
|
|
10
|
+
The modules, in the order a request flows through them:
|
|
11
|
+
|
|
12
|
+
* :mod:`.settings` — which models the user has set up, their keys, and the
|
|
13
|
+
switches (``ai.json`` in the archive root, managed by ``lw ai``)
|
|
14
|
+
* :mod:`.prompt` — a :class:`~lambda_watcher.diffing.compare.VersionDiff` as
|
|
15
|
+
prompt text, inside a size budget and with credentials redacted
|
|
16
|
+
* :mod:`.providers` — Anthropic, OpenAI, Azure OpenAI and local servers over
|
|
17
|
+
plain HTTPS, with retries
|
|
18
|
+
* :mod:`.explanation` — the model's answer parsed into something both renderers
|
|
19
|
+
can draw, and the file it is saved in
|
|
20
|
+
* :mod:`.run` — the pieces above as one call, plus the background worker the
|
|
21
|
+
watcher hands new versions to
|
|
22
|
+
|
|
23
|
+
Deliberately empty of imports: :mod:`lambda_watcher.diffing.build` reads saved
|
|
24
|
+
explanations while the diffing package is still initialising, and an eager
|
|
25
|
+
import of :mod:`.prompt` from here would import the diffing package back.
|
|
26
|
+
"""
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""A model's answer, made safe to draw, and the file it is kept in.
|
|
2
|
+
|
|
3
|
+
Two halves. :func:`parse_answer` turns whatever the model sent back into an
|
|
4
|
+
:class:`Explanation` — tolerating the fences, the preamble, the missing fields
|
|
5
|
+
and the invented file paths that models produce often enough to plan for. The
|
|
6
|
+
rest reads and writes the :class:`Record` saved beside each version, which is
|
|
7
|
+
what lets a report written without an explanation pick one up later, and a
|
|
8
|
+
page that is open while one is being written say so.
|
|
9
|
+
|
|
10
|
+
Where the record lives, and why there:
|
|
11
|
+
``functions/<slug>/versions/0007-a1b2c3d4/explanations/from-<12 hex>.json``,
|
|
12
|
+
inside the *newer* version's directory and named after the *older* version's
|
|
13
|
+
tree hash. Inside the version directory, so ``lw rename`` moving the function,
|
|
14
|
+
``lw merge`` renumbering it and ``lw rm`` or pruning deleting it all carry the
|
|
15
|
+
explanation along without knowing it exists. Named by tree hash rather than
|
|
16
|
+
version number, because a renumbering changes every number and no hash. And
|
|
17
|
+
never in ``index.db``, which may only hold what a rebuild from the manifests
|
|
18
|
+
can recover — an explanation is neither in a manifest nor reproducible.
|
|
19
|
+
|
|
20
|
+
An archive written before explanations existed simply has no such folders,
|
|
21
|
+
which reads as "not explained yet": nothing to migrate.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import threading
|
|
30
|
+
from dataclasses import asdict, dataclass, field
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
from ..utils import LOG, parse_iso, utc_now_iso
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from ..store import Store
|
|
39
|
+
|
|
40
|
+
#: The folder, inside a version directory, that holds its explanations.
|
|
41
|
+
EXPLANATIONS_DIRNAME = "explanations"
|
|
42
|
+
|
|
43
|
+
#: Bumped only if the record's shape ever has to break; see :meth:`Record.from_dict`.
|
|
44
|
+
RECORD_SCHEMA = 1
|
|
45
|
+
|
|
46
|
+
#: How long a "being written" record is believed without its writer being
|
|
47
|
+
#: checked on. Longer than the worst case of one request — five attempts at
|
|
48
|
+
#: the default timeout, plus backoff — so a slow success is never mistaken
|
|
49
|
+
#: for a crash.
|
|
50
|
+
PENDING_STALE_SECONDS = 20 * 60
|
|
51
|
+
|
|
52
|
+
#: The kinds of change and risk levels the report has colours for. Anything
|
|
53
|
+
#: else the model says is kept, but drawn as ``other``.
|
|
54
|
+
CHANGE_KINDS = ("feature", "fix", "behaviour", "refactor", "dependency", "config", "security",
|
|
55
|
+
"removal", "other")
|
|
56
|
+
LEVELS = ("low", "medium", "high")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Point:
|
|
61
|
+
"""One item in a list of changes or risks: a title, a sentence or two, and the files it is about.
|
|
62
|
+
|
|
63
|
+
``kind`` is a change's category (``feature``, ``fix``…) or a risk's level
|
|
64
|
+
(``high``, ``medium``, ``low``), already normalised to one the report can
|
|
65
|
+
colour. ``files`` holds only paths that really are in the diff; see
|
|
66
|
+
:func:`_match_path`.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
kind: str
|
|
70
|
+
title: str
|
|
71
|
+
detail: str = ""
|
|
72
|
+
files: list[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Explanation:
|
|
77
|
+
"""Everything one explanation says, plus where it came from.
|
|
78
|
+
|
|
79
|
+
The content fields mirror the JSON the prompt asks for (see
|
|
80
|
+
:data:`~.prompt.SYSTEM_PROMPT`). ``structured`` is False when the model
|
|
81
|
+
answered in prose instead of JSON: the prose is kept as the summary rather
|
|
82
|
+
than thrown away, since it is usually still a fair answer.
|
|
83
|
+
|
|
84
|
+
The provenance fields are what the report's footer is written from — "by
|
|
85
|
+
claude-sonnet-5, from 14 of 16 changed files, 3 values redacted" — because
|
|
86
|
+
an explanation is only as trustworthy as what it was shown.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
headline: str = ""
|
|
90
|
+
summary: str = ""
|
|
91
|
+
risk: str = ""
|
|
92
|
+
risk_reason: str = ""
|
|
93
|
+
changes: list[Point] = field(default_factory=list)
|
|
94
|
+
risks: list[Point] = field(default_factory=list)
|
|
95
|
+
checklist: list[str] = field(default_factory=list)
|
|
96
|
+
file_notes: dict[str, str] = field(default_factory=dict)
|
|
97
|
+
structured: bool = True
|
|
98
|
+
# -- provenance ------------------------------------------------------
|
|
99
|
+
provider: str = ""
|
|
100
|
+
model: str = ""
|
|
101
|
+
model_name: str = ""
|
|
102
|
+
created_at: str = ""
|
|
103
|
+
prompt_version: int = 0
|
|
104
|
+
files_sent: int = 0
|
|
105
|
+
files_total: int = 0
|
|
106
|
+
omitted: list[str] = field(default_factory=list)
|
|
107
|
+
withheld: list[str] = field(default_factory=list)
|
|
108
|
+
redactions: int = 0
|
|
109
|
+
send_code: bool = True
|
|
110
|
+
input_tokens: int | None = None
|
|
111
|
+
output_tokens: int | None = None
|
|
112
|
+
seconds: float = 0.0
|
|
113
|
+
attempts: int = 1
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def is_empty(self) -> bool:
|
|
117
|
+
"""True when the answer said nothing a reader could use."""
|
|
118
|
+
return not (self.headline or self.summary or self.changes or self.risks or self.checklist)
|
|
119
|
+
|
|
120
|
+
def as_dict(self) -> dict[str, Any]:
|
|
121
|
+
"""The explanation as the JSON it is saved as, and ``lw explain --json`` prints."""
|
|
122
|
+
return asdict(self)
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_dict(cls, data: dict[str, Any]) -> Explanation:
|
|
126
|
+
"""Read a saved explanation back, defaulting anything missing or malformed.
|
|
127
|
+
|
|
128
|
+
Saved by this release or a later one, so unknown keys are ignored and
|
|
129
|
+
each list is rebuilt item by item, dropping only the items that are
|
|
130
|
+
broken.
|
|
131
|
+
"""
|
|
132
|
+
def points(items: Any) -> list[Point]:
|
|
133
|
+
"""The saved list of changes or risks, as :class:`Point` objects."""
|
|
134
|
+
found = []
|
|
135
|
+
for item in items if isinstance(items, list) else []:
|
|
136
|
+
if isinstance(item, dict) and item.get("title"):
|
|
137
|
+
found.append(Point(kind=str(item.get("kind") or "other"), title=str(item["title"]),
|
|
138
|
+
detail=str(item.get("detail") or ""),
|
|
139
|
+
files=[str(f) for f in item.get("files") or []]))
|
|
140
|
+
return found
|
|
141
|
+
|
|
142
|
+
scalars = {k: data[k] for k in (
|
|
143
|
+
"headline", "summary", "risk", "risk_reason", "structured", "provider", "model",
|
|
144
|
+
"model_name", "created_at", "prompt_version", "files_sent", "files_total", "redactions",
|
|
145
|
+
"send_code", "input_tokens", "output_tokens", "seconds", "attempts",
|
|
146
|
+
) if k in data and data[k] is not None}
|
|
147
|
+
return cls(
|
|
148
|
+
**scalars,
|
|
149
|
+
changes=points(data.get("changes")),
|
|
150
|
+
risks=points(data.get("risks")),
|
|
151
|
+
checklist=[str(s) for s in data.get("checklist") or [] if s],
|
|
152
|
+
file_notes={str(k): str(v) for k, v in (data.get("file_notes") or {}).items()},
|
|
153
|
+
omitted=[str(p) for p in data.get("omitted") or []],
|
|
154
|
+
withheld=[str(p) for p in data.get("withheld") or []],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --------------------------------------------------------------------------- #
|
|
159
|
+
# Reading a model's answer
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
def _json_object(text: str) -> dict[str, Any] | None:
|
|
162
|
+
"""The first JSON object in a model's reply, however it was wrapped.
|
|
163
|
+
|
|
164
|
+
Handles the three wrappings models actually use: nothing, a ```json fence,
|
|
165
|
+
and a sentence of preamble before the object. Tries the outermost braces
|
|
166
|
+
first and falls back to decoding from each ``{`` in turn, so a stray brace
|
|
167
|
+
in the preamble does not sink the whole answer.
|
|
168
|
+
"""
|
|
169
|
+
stripped = text.strip()
|
|
170
|
+
fence = re.search(r"```(?:json)?\s*(\{.*\})\s*```", stripped, re.S)
|
|
171
|
+
if fence:
|
|
172
|
+
stripped = fence.group(1)
|
|
173
|
+
start, end = stripped.find("{"), stripped.rfind("}")
|
|
174
|
+
if start != -1 and end > start:
|
|
175
|
+
try:
|
|
176
|
+
value = json.loads(stripped[start:end + 1])
|
|
177
|
+
if isinstance(value, dict):
|
|
178
|
+
return value
|
|
179
|
+
except ValueError:
|
|
180
|
+
pass
|
|
181
|
+
decoder = json.JSONDecoder()
|
|
182
|
+
for match in re.finditer(r"\{", stripped):
|
|
183
|
+
try:
|
|
184
|
+
value, _ = decoder.raw_decode(stripped[match.start():])
|
|
185
|
+
except ValueError:
|
|
186
|
+
continue
|
|
187
|
+
if isinstance(value, dict):
|
|
188
|
+
return value
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _match_path(cited: Any, known: set[str]) -> str | None:
|
|
193
|
+
"""The real path a model meant, or ``None`` when it named a file the diff does not have.
|
|
194
|
+
|
|
195
|
+
Models cite paths the way diffs print them — ``b/app.py``, ``./app.py``,
|
|
196
|
+
in backticks — or by filename alone. Each of those is resolved to the one
|
|
197
|
+
path in the diff it can mean; a filename shared by two paths, or a path
|
|
198
|
+
that was invented, resolves to nothing, because a link to the wrong file is
|
|
199
|
+
worse than no link.
|
|
200
|
+
"""
|
|
201
|
+
text = str(cited or "").strip().strip("`'\"").strip()
|
|
202
|
+
for prefix in ("a/", "b/", "./"):
|
|
203
|
+
if text.startswith(prefix) and text not in known:
|
|
204
|
+
text = text[len(prefix):]
|
|
205
|
+
text = text.split(":")[0].strip()
|
|
206
|
+
if not text:
|
|
207
|
+
return None
|
|
208
|
+
if text in known:
|
|
209
|
+
return text
|
|
210
|
+
tails = [p for p in known if p.endswith("/" + text)]
|
|
211
|
+
return tails[0] if len(tails) == 1 else None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _clip(value: Any, limit: int) -> str:
|
|
215
|
+
"""A model-supplied string, trimmed of whitespace and cut to ``limit`` characters."""
|
|
216
|
+
text = " ".join(str(value or "").split())
|
|
217
|
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _level(value: Any, default: str = "") -> str:
|
|
221
|
+
"""``High`` → ``high``; anything that is not a level → ``default``."""
|
|
222
|
+
text = str(value or "").strip().lower()
|
|
223
|
+
return text if text in LEVELS else default
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def parse_answer(text: str, known_paths: set[str]) -> Explanation:
|
|
227
|
+
"""Turn a model's reply into an :class:`Explanation`, keeping all it got right.
|
|
228
|
+
|
|
229
|
+
Every field is optional and every list item is checked on its own, so one
|
|
230
|
+
malformed risk does not cost the other five. Lengths are capped, the lists
|
|
231
|
+
are cut to the sizes the prompt asked for, and file references are
|
|
232
|
+
resolved against ``known_paths`` — see :func:`_match_path`. A reply with no
|
|
233
|
+
JSON object at all becomes an unstructured explanation whose summary is
|
|
234
|
+
the reply.
|
|
235
|
+
"""
|
|
236
|
+
data = _json_object(text)
|
|
237
|
+
if data is None:
|
|
238
|
+
prose = text.strip()
|
|
239
|
+
first = re.split(r"(?<=[.!?])\s", prose, maxsplit=1)[0] if prose else ""
|
|
240
|
+
return Explanation(headline=_clip(first, 160), summary=_clip(prose, 4000), structured=False)
|
|
241
|
+
|
|
242
|
+
def files_of(item: dict[str, Any]) -> list[str]:
|
|
243
|
+
"""The item's cited files that exist in the diff, each once, in the order given."""
|
|
244
|
+
found: list[str] = []
|
|
245
|
+
for cited in item.get("files") or []:
|
|
246
|
+
path = _match_path(cited, known_paths)
|
|
247
|
+
if path and path not in found:
|
|
248
|
+
found.append(path)
|
|
249
|
+
return found
|
|
250
|
+
|
|
251
|
+
changes: list[Point] = []
|
|
252
|
+
for item in data.get("changes") or []:
|
|
253
|
+
if isinstance(item, dict) and item.get("title"):
|
|
254
|
+
kind = str(item.get("kind") or "other").strip().lower()
|
|
255
|
+
changes.append(Point(kind=kind if kind in CHANGE_KINDS else "other",
|
|
256
|
+
title=_clip(item["title"], 120), detail=_clip(item.get("detail"), 600),
|
|
257
|
+
files=files_of(item)))
|
|
258
|
+
risks: list[Point] = []
|
|
259
|
+
for item in data.get("risks") or []:
|
|
260
|
+
if isinstance(item, dict) and item.get("title"):
|
|
261
|
+
risks.append(Point(kind=_level(item.get("level"), "medium"), title=_clip(item["title"], 120),
|
|
262
|
+
detail=_clip(item.get("detail"), 600), files=files_of(item)))
|
|
263
|
+
notes: dict[str, str] = {}
|
|
264
|
+
raw_notes = data.get("files")
|
|
265
|
+
if isinstance(raw_notes, dict):
|
|
266
|
+
for cited, note in raw_notes.items():
|
|
267
|
+
path = _match_path(cited, known_paths)
|
|
268
|
+
if path and note:
|
|
269
|
+
notes[path] = _clip(note, 200)
|
|
270
|
+
checklist = [_clip(step, 240) for step in data.get("checklist") or [] if str(step or "").strip()]
|
|
271
|
+
return Explanation(
|
|
272
|
+
headline=_clip(data.get("headline"), 160),
|
|
273
|
+
summary=_clip(data.get("summary"), 1500),
|
|
274
|
+
risk=_level(data.get("risk")),
|
|
275
|
+
risk_reason=_clip(data.get("risk_reason"), 240),
|
|
276
|
+
changes=changes[:8],
|
|
277
|
+
risks=risks[:6],
|
|
278
|
+
checklist=checklist[:8],
|
|
279
|
+
file_notes=notes,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# --------------------------------------------------------------------------- #
|
|
284
|
+
# The saved record
|
|
285
|
+
# --------------------------------------------------------------------------- #
|
|
286
|
+
@dataclass
|
|
287
|
+
class Record:
|
|
288
|
+
"""What is known about explaining one pair of versions: done, being written, or failed.
|
|
289
|
+
|
|
290
|
+
``status`` is one of three things, and the report draws each differently:
|
|
291
|
+
|
|
292
|
+
``done``
|
|
293
|
+
``explanation`` holds the answer.
|
|
294
|
+
``pending``
|
|
295
|
+
A request is in flight, started at ``started_at`` by process ``pid``
|
|
296
|
+
using ``model``. ``explanation`` may still hold an earlier answer that
|
|
297
|
+
this one will replace — a refresh keeps the old answer on screen.
|
|
298
|
+
``failed``
|
|
299
|
+
The last attempt failed; ``error`` says how (kind, message, hint). An
|
|
300
|
+
earlier answer, if there was one, is kept in ``explanation``.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
status: str
|
|
304
|
+
a_seq: int
|
|
305
|
+
b_seq: int
|
|
306
|
+
a_tree: str
|
|
307
|
+
b_tree: str
|
|
308
|
+
explanation: Explanation | None = None
|
|
309
|
+
started_at: str = ""
|
|
310
|
+
pid: int = 0
|
|
311
|
+
model: str = ""
|
|
312
|
+
error: dict[str, Any] | None = None
|
|
313
|
+
updated_at: str = ""
|
|
314
|
+
|
|
315
|
+
def pending_is_stale(self, now: datetime | None = None) -> bool:
|
|
316
|
+
"""Whether a ``pending`` record belongs to a request that can no longer finish.
|
|
317
|
+
|
|
318
|
+
True when the process that started it has gone — the watcher was
|
|
319
|
+
stopped, the terminal closed — or when it has been pending longer than
|
|
320
|
+
:data:`PENDING_STALE_SECONDS` regardless. Without this a page opened
|
|
321
|
+
after an interrupted run would say "being written" for ever.
|
|
322
|
+
"""
|
|
323
|
+
if self.status != "pending":
|
|
324
|
+
return False
|
|
325
|
+
if self.pid and self.pid != os.getpid():
|
|
326
|
+
from ..service import pid_alive
|
|
327
|
+
|
|
328
|
+
if not pid_alive(self.pid):
|
|
329
|
+
return True
|
|
330
|
+
started = parse_iso(self.started_at)
|
|
331
|
+
if started is None:
|
|
332
|
+
return True
|
|
333
|
+
if started.tzinfo is None:
|
|
334
|
+
started = started.replace(tzinfo=timezone.utc)
|
|
335
|
+
now = now or datetime.now(timezone.utc)
|
|
336
|
+
return (now - started).total_seconds() > PENDING_STALE_SECONDS
|
|
337
|
+
|
|
338
|
+
def as_dict(self) -> dict[str, Any]:
|
|
339
|
+
"""The record as the JSON file it is saved in."""
|
|
340
|
+
data = asdict(self)
|
|
341
|
+
data["schema"] = RECORD_SCHEMA
|
|
342
|
+
data["explanation"] = self.explanation.as_dict() if self.explanation else None
|
|
343
|
+
return data
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def from_dict(cls, data: dict[str, Any]) -> Record | None:
|
|
347
|
+
"""Read a record back, or ``None`` when the file is not one."""
|
|
348
|
+
if not isinstance(data, dict) or data.get("status") not in {"done", "pending", "failed"}:
|
|
349
|
+
return None
|
|
350
|
+
explanation = data.get("explanation")
|
|
351
|
+
try:
|
|
352
|
+
return cls(
|
|
353
|
+
status=str(data["status"]),
|
|
354
|
+
a_seq=int(data.get("a_seq") or 0), b_seq=int(data.get("b_seq") or 0),
|
|
355
|
+
a_tree=str(data.get("a_tree") or ""), b_tree=str(data.get("b_tree") or ""),
|
|
356
|
+
explanation=Explanation.from_dict(explanation) if isinstance(explanation, dict) else None,
|
|
357
|
+
started_at=str(data.get("started_at") or ""), pid=int(data.get("pid") or 0),
|
|
358
|
+
model=str(data.get("model") or ""),
|
|
359
|
+
error=data.get("error") if isinstance(data.get("error"), dict) else None,
|
|
360
|
+
updated_at=str(data.get("updated_at") or ""),
|
|
361
|
+
)
|
|
362
|
+
except (TypeError, ValueError):
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def record_path(store: Store, a_meta: dict[str, Any], b_meta: dict[str, Any]) -> Path | None:
|
|
367
|
+
"""Where the record for one pair of versions lives, or ``None`` if the rows cannot say.
|
|
368
|
+
|
|
369
|
+
``a_meta`` and ``b_meta`` are the two version rows as dicts, as
|
|
370
|
+
:class:`~lambda_watcher.diffing.compare.VersionDiff` carries them; the
|
|
371
|
+
directory comes from the newer and the name from the older's tree hash.
|
|
372
|
+
"""
|
|
373
|
+
stored_dir, tree = b_meta.get("dir"), a_meta.get("tree_hash")
|
|
374
|
+
if not stored_dir or not tree:
|
|
375
|
+
return None
|
|
376
|
+
return store.resolve_version_dir(str(stored_dir)) / EXPLANATIONS_DIRNAME / f"from-{str(tree)[:12]}.json"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def load_record(store: Store, a_meta: dict[str, Any], b_meta: dict[str, Any]) -> Record | None:
|
|
380
|
+
"""The saved record for a pair of versions, or ``None`` when there is none (or it is unreadable).
|
|
381
|
+
|
|
382
|
+
An unreadable file is logged and treated as absent: the worst outcome is
|
|
383
|
+
that the pair gets explained again, which is far better than a report that
|
|
384
|
+
will not render.
|
|
385
|
+
"""
|
|
386
|
+
path = record_path(store, a_meta, b_meta)
|
|
387
|
+
if path is None or not path.exists():
|
|
388
|
+
return None
|
|
389
|
+
try:
|
|
390
|
+
return Record.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
|
391
|
+
except (OSError, ValueError) as exc:
|
|
392
|
+
LOG.warning("could not read %s: %s", path, exc)
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _write(path: Path, record: Record) -> None:
|
|
397
|
+
"""Save a record in one step, so a page rendering at the same moment never reads half of it."""
|
|
398
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
399
|
+
record.updated_at = utc_now_iso()
|
|
400
|
+
scratch = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
|
401
|
+
try:
|
|
402
|
+
scratch.write_text(json.dumps(record.as_dict(), indent=2) + "\n", encoding="utf-8")
|
|
403
|
+
os.replace(scratch, path)
|
|
404
|
+
finally:
|
|
405
|
+
scratch.unlink(missing_ok=True)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _fresh(a_meta: dict[str, Any], b_meta: dict[str, Any], status: str) -> Record:
|
|
409
|
+
"""A new record for a pair, carrying both versions' numbers and hashes."""
|
|
410
|
+
return Record(status=status, a_seq=int(a_meta.get("seq") or 0), b_seq=int(b_meta.get("seq") or 0),
|
|
411
|
+
a_tree=str(a_meta.get("tree_hash") or ""), b_tree=str(b_meta.get("tree_hash") or ""))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def save_done(store: Store, a_meta: dict[str, Any], b_meta: dict[str, Any],
|
|
415
|
+
explanation: Explanation) -> Record | None:
|
|
416
|
+
"""Record a finished explanation, replacing whatever was there."""
|
|
417
|
+
path = record_path(store, a_meta, b_meta)
|
|
418
|
+
if path is None:
|
|
419
|
+
return None
|
|
420
|
+
record = _fresh(a_meta, b_meta, "done")
|
|
421
|
+
record.explanation = explanation
|
|
422
|
+
record.model = explanation.model
|
|
423
|
+
_write(path, record)
|
|
424
|
+
return record
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def save_pending(store: Store, a_meta: dict[str, Any], b_meta: dict[str, Any],
|
|
428
|
+
model: str) -> Record | None:
|
|
429
|
+
"""Record that an explanation is being written now, keeping any earlier answer on show."""
|
|
430
|
+
path = record_path(store, a_meta, b_meta)
|
|
431
|
+
if path is None:
|
|
432
|
+
return None
|
|
433
|
+
previous = load_record(store, a_meta, b_meta)
|
|
434
|
+
record = _fresh(a_meta, b_meta, "pending")
|
|
435
|
+
record.explanation = previous.explanation if previous else None
|
|
436
|
+
record.started_at, record.pid, record.model = utc_now_iso(), os.getpid(), model
|
|
437
|
+
_write(path, record)
|
|
438
|
+
return record
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def save_failed(store: Store, a_meta: dict[str, Any], b_meta: dict[str, Any],
|
|
442
|
+
model: str, error: dict[str, Any]) -> Record | None:
|
|
443
|
+
"""Record that the last attempt failed and why, keeping any earlier answer on show."""
|
|
444
|
+
path = record_path(store, a_meta, b_meta)
|
|
445
|
+
if path is None:
|
|
446
|
+
return None
|
|
447
|
+
previous = load_record(store, a_meta, b_meta)
|
|
448
|
+
record = _fresh(a_meta, b_meta, "failed")
|
|
449
|
+
record.explanation = previous.explanation if previous else None
|
|
450
|
+
record.model, record.error = model, error
|
|
451
|
+
_write(path, record)
|
|
452
|
+
return record
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def clear_pending(store: Store, a_meta: dict[str, Any], b_meta: dict[str, Any]) -> None:
|
|
456
|
+
"""Undo a ``pending`` mark for a request that will never be made.
|
|
457
|
+
|
|
458
|
+
Put back to ``done`` when an earlier answer exists, and removed otherwise,
|
|
459
|
+
so the report reads as though the request had never been queued — which is
|
|
460
|
+
the truth when AI was switched off, or the watcher stopped, before its turn
|
|
461
|
+
came.
|
|
462
|
+
"""
|
|
463
|
+
path = record_path(store, a_meta, b_meta)
|
|
464
|
+
record = load_record(store, a_meta, b_meta)
|
|
465
|
+
if path is None or record is None or record.status != "pending":
|
|
466
|
+
return
|
|
467
|
+
if record.explanation is None:
|
|
468
|
+
path.unlink(missing_ok=True)
|
|
469
|
+
return
|
|
470
|
+
record.status, record.started_at, record.pid = "done", "", 0
|
|
471
|
+
_write(path, record)
|