lambda-watcher 0.5.0__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.
Files changed (74) hide show
  1. {lambda_watcher-0.5.0/src/lambda_watcher.egg-info → lambda_watcher-0.6.0}/PKG-INFO +51 -1
  2. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/README.md +50 -0
  3. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/pyproject.toml +7 -1
  4. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/__init__.py +1 -1
  5. lambda_watcher-0.6.0/src/lambda_watcher/ai/__init__.py +26 -0
  6. lambda_watcher-0.6.0/src/lambda_watcher/ai/explanation.py +471 -0
  7. lambda_watcher-0.6.0/src/lambda_watcher/ai/prompt.py +434 -0
  8. lambda_watcher-0.6.0/src/lambda_watcher/ai/providers.py +814 -0
  9. lambda_watcher-0.6.0/src/lambda_watcher/ai/report.py +138 -0
  10. lambda_watcher-0.6.0/src/lambda_watcher/ai/run.py +306 -0
  11. lambda_watcher-0.6.0/src/lambda_watcher/ai/settings.py +565 -0
  12. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/cli.py +1092 -20
  13. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/build.py +21 -6
  14. lambda_watcher-0.6.0/src/lambda_watcher/diffing/filetree.py +163 -0
  15. lambda_watcher-0.6.0/src/lambda_watcher/diffing/fonts/CascadiaMono-Regular.woff2 +0 -0
  16. lambda_watcher-0.6.0/src/lambda_watcher/diffing/fonts/CascadiaMono-SemiBold.woff2 +0 -0
  17. lambda_watcher-0.6.0/src/lambda_watcher/diffing/fonts/OFL.txt +94 -0
  18. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/icons.py +12 -0
  19. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/render_html.py +945 -110
  20. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/render_text.py +61 -0
  21. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/helptext.py +210 -6
  22. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/ingest.py +50 -5
  23. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/templates.py +5 -0
  24. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0/src/lambda_watcher.egg-info}/PKG-INFO +51 -1
  25. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/SOURCES.txt +12 -0
  26. lambda_watcher-0.6.0/tests/test_ai.py +901 -0
  27. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_cli.py +4 -0
  28. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_diff.py +44 -0
  29. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_docs.py +6 -8
  30. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_helptext.py +41 -3
  31. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_render_html.py +211 -2
  32. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/LICENSE +0 -0
  33. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/setup.cfg +0 -0
  34. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/__main__.py +0 -0
  35. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/__init__.py +0 -0
  36. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/deps.py +0 -0
  37. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/envvars.py +0 -0
  38. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/handler.py +0 -0
  39. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/inventory.py +0 -0
  40. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/runtime.py +0 -0
  41. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/secrets.py +0 -0
  42. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/analysis/services.py +0 -0
  43. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/config.py +0 -0
  44. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/db.py +0 -0
  45. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/demo.py +0 -0
  46. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/__init__.py +0 -0
  47. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/compare.py +0 -0
  48. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/highlight.py +0 -0
  49. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/diffing/intraline.py +0 -0
  50. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/extract.py +0 -0
  51. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/gitmirror.py +0 -0
  52. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/heartbeat.py +0 -0
  53. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/identify.py +0 -0
  54. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/notify.py +0 -0
  55. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/reindex.py +0 -0
  56. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/service.py +0 -0
  57. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/store.py +0 -0
  58. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/utils.py +0 -0
  59. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher/watcher.py +0 -0
  60. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/dependency_links.txt +0 -0
  61. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/entry_points.txt +0 -0
  62. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/requires.txt +0 -0
  63. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/src/lambda_watcher.egg-info/top_level.txt +0 -0
  64. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_analysis.py +0 -0
  65. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_config.py +0 -0
  66. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_extract.py +0 -0
  67. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_heartbeat.py +0 -0
  68. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_identify.py +0 -0
  69. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_ingest.py +0 -0
  70. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_no_window.py +0 -0
  71. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_reindex.py +0 -0
  72. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_service.py +0 -0
  73. {lambda_watcher-0.5.0 → lambda_watcher-0.6.0}/tests/test_utils.py +0 -0
  74. {lambda_watcher-0.5.0 → 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.5.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.5.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"
@@ -1,4 +1,4 @@
1
1
  """Watch a downloads folder for AWS Lambda deployment packages and version them."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
4
4
  __all__ = ["__version__"]
@@ -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)