django-explain-errors 0.5.0__tar.gz → 0.7.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.
- {django_explain_errors-0.5.0/django_explain_errors.egg-info → django_explain_errors-0.7.0}/PKG-INFO +147 -40
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/README.md +145 -32
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0/django_explain_errors.egg-info}/PKG-INFO +147 -40
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/django_explain_errors.egg-info/SOURCES.txt +6 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/middleware.py +51 -8
- django_explain_errors-0.7.0/explain_errors/tracebacks.py +105 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/setup.py +3 -9
- django_explain_errors-0.7.0/tests/test_eval_fixtures.py +110 -0
- django_explain_errors-0.7.0/tests/test_eval_judge.py +393 -0
- django_explain_errors-0.7.0/tests/test_eval_run.py +389 -0
- django_explain_errors-0.7.0/tests/test_language.py +158 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_middleware.py +46 -0
- django_explain_errors-0.7.0/tests/test_tracebacks.py +129 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/LICENSE +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/MANIFEST.in +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/django_explain_errors.egg-info/requires.txt +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/django_explain_errors.egg-info/top_level.txt +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/__init__.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/client.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/management/__init__.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/management/commands/__init__.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/management/commands/build_error_index.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/rag/__init__.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/rag/indexer.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/rag/retriever.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/rag/store.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/sanitize.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/throttle.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/setup.cfg +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_build_error_index.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_client.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_rag.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_sanitize.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_signals.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_throttle.py +0 -0
- {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_truncation.py +0 -0
{django_explain_errors-0.5.0/django_explain_errors.egg-info → django_explain_errors-0.7.0}/PKG-INFO
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-explain-errors
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Django middleware that
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Django middleware that explains unhandled exceptions in DEBUG using an LLM, optionally grounded in your own project source via a local vector index. Works with OpenAI, Claude, or any OpenAI-compatible endpoint including local models.
|
|
5
5
|
Home-page: https://github.com/topunix/django-explain-errors
|
|
6
6
|
Author: topunix
|
|
7
7
|
Author-email: topunixguy@gmail.com
|
|
@@ -9,12 +9,6 @@ License: MIT
|
|
|
9
9
|
Classifier: Development Status :: 4 - Beta
|
|
10
10
|
Classifier: Environment :: Web Environment
|
|
11
11
|
Classifier: Framework :: Django
|
|
12
|
-
Classifier: Framework :: Django :: 4.2
|
|
13
|
-
Classifier: Framework :: Django :: 5.0
|
|
14
|
-
Classifier: Framework :: Django :: 5.1
|
|
15
|
-
Classifier: Framework :: Django :: 5.2
|
|
16
|
-
Classifier: Framework :: Django :: 6.0
|
|
17
|
-
Classifier: Framework :: Django :: 6.1
|
|
18
12
|
Classifier: Intended Audience :: Developers
|
|
19
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
20
14
|
Classifier: Operating System :: OS Independent
|
|
@@ -60,7 +54,8 @@ a local model if you prefer not to send code off your machine.
|
|
|
60
54
|
|
|
61
55
|
It can optionally ground explanations in your own project source using a
|
|
62
56
|
local vector index (RAG), so explanations reference the actual code that
|
|
63
|
-
failed instead of staying generic
|
|
57
|
+
failed instead of staying generic (measured — see "Does RAG actually
|
|
58
|
+
help?" below).
|
|
64
59
|
|
|
65
60
|
The middleware supports both synchronous (WSGI) and asynchronous (ASGI)
|
|
66
61
|
views. It auto-detects the view chain at startup and routes requests through
|
|
@@ -86,6 +81,8 @@ Local development only. It requires `DEBUG = True` and is inert otherwise.
|
|
|
86
81
|
- Explains errors using OpenAI, Anthropic's Claude models, or any other
|
|
87
82
|
OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`
|
|
88
83
|
- Optional codebase-aware explanations (RAG) backed by a local sqlite-vec index (see the RAG section below)
|
|
84
|
+
- Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with exception names,
|
|
85
|
+
identifiers, and code kept in English
|
|
89
86
|
- Redacts secrets, tokens, and emails from tracebacks before sending
|
|
90
87
|
- Rate limits API calls with a configurable sliding window
|
|
91
88
|
- Works with both sync (WSGI) and async (ASGI) views
|
|
@@ -185,9 +182,9 @@ developer is actually investigating.
|
|
|
185
182
|
| `OPENAI_API_KEY` (env or settings) | Yes, unless `OPENAI_BASE_URL` points at an endpoint that does not authenticate | API key used to authenticate with the configured endpoint. Read first from the environment, then from `settings`. |
|
|
186
183
|
| `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
|
|
187
184
|
| `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini`. |
|
|
188
|
-
| `OPENAI_MAX_TOKENS` | No | Ceiling on tokens generated for the explanation, not a target — the system prompt itself asks for a concise answer. Defaults to `1000
|
|
185
|
+
| `OPENAI_MAX_TOKENS` | No | Ceiling on tokens generated for the explanation, not a target — the system prompt itself asks for a concise answer. Defaults to `1000`; scales up automatically when `EXPLAIN_ERRORS_LANGUAGE` is set (see below), unless you set this explicitly, which always overrides the scaling. |
|
|
189
186
|
| `OPENAI_TIMEOUT` | No | Request timeout in seconds for the OpenAI client. Defaults to `10`. |
|
|
190
|
-
| `OPENAI_MAX_TRACEBACK_CHARS` | No |
|
|
187
|
+
| `OPENAI_MAX_TRACEBACK_CHARS` | No | Total character budget for the traceback sent to the model. Application frames (your own code, as opposed to Django, the standard library, or installed packages) are always kept; library frames fill whatever budget remains, nearest the raise point first, with an `... N library frames omitted ...` line where frames are dropped. If the application frames alone exceed the budget, falls back to keeping the last N characters of the raw traceback. Defaults to `3000`. |
|
|
191
188
|
| `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE` | No | When `True` (the default), the middleware prints the explanation to stdout and returns `None`, so exception handling continues normally and Django renders its standard debug page. Set to `False` to instead return a JSON 500 response, which ends exception handling early (see Compatibility above). |
|
|
192
189
|
| `OPENAI_BASE_URL` (env or settings) | No | Base URL for any OpenAI-compatible API (for example Ollama at `http://localhost:11434/v1`). When set, a missing API key is replaced with a placeholder since local servers do not require one. |
|
|
193
190
|
| `EXPLAIN_ERRORS_MAX_CALLS` | No | Together with `EXPLAIN_ERRORS_WINDOW_SECONDS`, caps API spend to at most this many explanations within a rolling window; once the cap is hit, further errors in that window are not sent for explanation until an earlier call ages out. Defaults to `5`. |
|
|
@@ -195,6 +192,7 @@ developer is actually investigating.
|
|
|
195
192
|
| `EXPLAIN_ERRORS_REDACT_PATTERNS` | No | Extra regex pattern strings (each passed to `re.compile`) applied to the traceback, appended after the built-in secret/token/email patterns. An invalid pattern is skipped with a warning rather than raising. Defaults to `[]`. |
|
|
196
193
|
| `EXPLAIN_ERRORS_REDACT_DISABLE_DEFAULTS` | No | When `True`, skips the built-in secret/token/email redaction patterns entirely and redacts only what `EXPLAIN_ERRORS_REDACT_PATTERNS` specifies. Turning this on removes the default protection against leaking secrets and PII in tracebacks. Defaults to `False`. |
|
|
197
194
|
| `EXPLAIN_ERRORS_REDACT_REPLACEMENT` | No | Replacement string substituted for anything matched by the redaction patterns. Defaults to `"[REDACTED]"`. |
|
|
195
|
+
| `EXPLAIN_ERRORS_LANGUAGE` | No | Language the explanation prose is written in, as a plain name or code (for example `"Spanish"` or `"es"`). Defaults to `None`, meaning English. Exception names, identifiers, code, file paths, and tracebacks always stay in English regardless of this setting. |
|
|
198
196
|
|
|
199
197
|
## Using local models (Ollama)
|
|
200
198
|
|
|
@@ -250,6 +248,49 @@ and rejected, so a missing `OPENAI_API_KEY` shows up as an opaque `401 Unauthori
|
|
|
250
248
|
than a clear configuration error. If you see a 401 with `OPENAI_BASE_URL` pointed at a remote
|
|
251
249
|
provider, check that `OPENAI_API_KEY` — not a provider-specific variable — is actually set.
|
|
252
250
|
|
|
251
|
+
## Explanation language
|
|
252
|
+
|
|
253
|
+
By default, explanations are written in English. Set `EXPLAIN_ERRORS_LANGUAGE` to
|
|
254
|
+
read them in another language instead:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
EXPLAIN_ERRORS_LANGUAGE = "Spanish" # or the code form, "es"
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
There is no fixed list of supported languages. `EXPLAIN_ERRORS_LANGUAGE` accepts
|
|
261
|
+
any language the configured model can write, because the setting adds one clause
|
|
262
|
+
to the system prompt (see `LANGUAGE_CLAUSE_TEMPLATE` in
|
|
263
|
+
`explain_errors/middleware.py`), not a translation catalog with its own
|
|
264
|
+
maintained language list. How well it works varies by model; see the known
|
|
265
|
+
limitation below.
|
|
266
|
+
|
|
267
|
+
This is independent of Django's own `LANGUAGE_CODE`, which controls the language your
|
|
268
|
+
site serves to its users, not the language you read explanations in. Regardless of
|
|
269
|
+
`EXPLAIN_ERRORS_LANGUAGE`, exception type names, Django and Python identifiers, code,
|
|
270
|
+
file paths, and tracebacks are always kept in English, so they stay greppable and
|
|
271
|
+
matchable against documentation and search results. Only the explanatory prose is
|
|
272
|
+
translated.
|
|
273
|
+
|
|
274
|
+
**Known limitation:** small local models behind `OPENAI_BASE_URL` (see "Using local
|
|
275
|
+
models" above) tend to degrade sharply outside English. Output quality with
|
|
276
|
+
`EXPLAIN_ERRORS_LANGUAGE` set does not transfer uniformly across providers — it is
|
|
277
|
+
generally solid against OpenAI and Anthropic's APIs, but a small local model that
|
|
278
|
+
writes fluent English explanations may produce broken or mixed-language output once
|
|
279
|
+
asked to switch languages.
|
|
280
|
+
|
|
281
|
+
When a language is configured and `OPENAI_MAX_TOKENS` isn't set explicitly, the
|
|
282
|
+
token ceiling defaults to 3,000 instead of 1,000, a flat 3x multiplier applied the
|
|
283
|
+
same way regardless of how well or poorly a given language is known to tokenize, so
|
|
284
|
+
explanations in languages that use more tokens per word than English aren't cut off
|
|
285
|
+
mid-sentence. This is a ceiling, not a target: billing follows tokens actually
|
|
286
|
+
generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TOKENS`
|
|
287
|
+
explicitly always overrides this scaling, at any value, including one lower than the
|
|
288
|
+
unscaled 1,000 default. The multiplier itself is deliberately generous rather than
|
|
289
|
+
precise: the underlying tokens-per-word figures are estimates, not direct
|
|
290
|
+
measurements, and the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
|
|
291
|
+
which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
|
|
292
|
+
these estimates lean on.
|
|
293
|
+
|
|
253
294
|
## Codebase-aware explanations (RAG)
|
|
254
295
|
|
|
255
296
|
By default, explanations are generated from the traceback alone. With the
|
|
@@ -333,40 +374,106 @@ else.)
|
|
|
333
374
|
|
|
334
375
|
### Before / after
|
|
335
376
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
**With RAG**, grounded in the actual function:
|
|
343
|
-
|
|
344
|
-
> In `myapp/utils.py`, `foo()` calls `int(value)` on line 12 without a
|
|
345
|
-
> `try`/`except`, so any non-numeric `value` raises `ValueError` straight
|
|
346
|
-
> through to the caller. Since `foo()` is called from `myapp/views.py` with
|
|
347
|
-
> unvalidated form input, add validation there or wrap the `int()` call in
|
|
348
|
-
> `foo()` with a clear error message.
|
|
377
|
+
A real result from the eval harness (`missing_fk`, one of the fixtures in
|
|
378
|
+
`evals/fixtures.py`): a view creates a new `Post` without setting the
|
|
379
|
+
required `author` foreign key. The traceback the model actually received
|
|
380
|
+
was already truncated to `OPENAI_MAX_TRACEBACK_CHARS`, so it contains no
|
|
381
|
+
application code at all, only Django/SQLite internals:
|
|
349
382
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
383
|
+
```
|
|
384
|
+
...(truncated)...
|
|
385
|
+
File ".../django/db/models/base.py", line 904, in save
|
|
386
|
+
self.save_base(
|
|
387
|
+
...
|
|
388
|
+
File ".../django/db/backends/utils.py", line 105, in _execute
|
|
389
|
+
return self.cursor.execute(sql, params)
|
|
390
|
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
391
|
+
File ".../django/db/backends/sqlite3/base.py", line 359, in execute
|
|
392
|
+
return super().execute(query, params)
|
|
393
|
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
394
|
+
django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id
|
|
395
|
+
```
|
|
358
396
|
|
|
359
|
-
|
|
360
|
-
...
|
|
361
|
-
'explain_errors.middleware.ExplainErrorsMiddleware',
|
|
362
|
-
]
|
|
397
|
+
**Without RAG**, traceback only:
|
|
363
398
|
|
|
364
|
-
|
|
399
|
+
> The error "django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id" occurs when you attempt to save a model instance but the `author_id` field does not receive a value, even though it requires one (is defined as `NOT NULL`).
|
|
400
|
+
>
|
|
401
|
+
> This typically indicates that the `author` field in your `Post` model (or similar) is mandatory, but you did not provide an `author` when creating or updating the post.
|
|
402
|
+
>
|
|
403
|
+
> To fix this:
|
|
404
|
+
>
|
|
405
|
+
> 1. Ensure that you are assigning a valid user or author instance to the `author` field before saving the `Post` model. For example:
|
|
406
|
+
> ```python
|
|
407
|
+
> post.author = some_user_instance
|
|
408
|
+
> ```
|
|
409
|
+
>
|
|
410
|
+
> 2. If you intended for `author_id` to be optional, adjust the model definition to allow null values by modifying the field:
|
|
411
|
+
> ```python
|
|
412
|
+
> author = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
|
|
413
|
+
> ```
|
|
414
|
+
>
|
|
415
|
+
> 3. If the migration is necessary (if you did change the model), run:
|
|
416
|
+
> ```bash
|
|
417
|
+
> python manage.py makemigrations
|
|
418
|
+
> python manage.py migrate
|
|
419
|
+
> ```
|
|
420
|
+
>
|
|
421
|
+
> Make sure that every time you save a `Post`, it has a valid author assigned.
|
|
365
422
|
|
|
366
|
-
|
|
367
|
-
```
|
|
423
|
+
**With RAG**, grounded in the actual function:
|
|
368
424
|
|
|
369
|
-
|
|
425
|
+
> The error `django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id` occurs because when you're creating a new `Post` instance in `clone_latest_post`, you're not providing an `author` for the new draft. The `author` field in the `Post` model is a ForeignKey that cannot be null, and thus must be populated.
|
|
426
|
+
>
|
|
427
|
+
> To fix this, modify the `clone_latest_post` function to include an `author` when creating the draft. You could retrieve the author from the latest post and use it like so:
|
|
428
|
+
>
|
|
429
|
+
> ```python
|
|
430
|
+
> def clone_latest_post(request):
|
|
431
|
+
> """Duplicate the most recent post as a new draft, ready for editing."""
|
|
432
|
+
> latest = Post.objects.order_by("-id").first()
|
|
433
|
+
> draft = Post.objects.create(
|
|
434
|
+
> title=f"Copy of {latest.title}",
|
|
435
|
+
> slug=f"copy-of-{latest.slug}",
|
|
436
|
+
> body=latest.body,
|
|
437
|
+
> author=latest.author, # Add this line to specify the author
|
|
438
|
+
> )
|
|
439
|
+
> return HttpResponse(f"Created draft #{draft.id}")
|
|
440
|
+
> ```
|
|
441
|
+
>
|
|
442
|
+
> This ensures the `draft` has a valid `author`, satisfying the NOT NULL constraint.
|
|
443
|
+
|
|
444
|
+
### Does RAG actually help?
|
|
445
|
+
|
|
446
|
+
To check whether RAG-grounded explanations are actually better, not just
|
|
447
|
+
longer, the package ships an eval harness (`evals/`): fifteen deliberately
|
|
448
|
+
broken Django views, each explained twice (once from the traceback alone,
|
|
449
|
+
once with RAG enabled) and judged by a separate model, blind to which
|
|
450
|
+
explanation is which, against the error's known cause and correct fix
|
|
451
|
+
location. Which side the judge sees as "A" is randomized per comparison so
|
|
452
|
+
position can't bias the result.
|
|
453
|
+
|
|
454
|
+
Across three runs (45 judged comparisons, 2 judge failures, 43 scored),
|
|
455
|
+
RAG-on won 35, RAG-off 5, and 3 tied. The gap isn't spread evenly across
|
|
456
|
+
everything the judge checks. It's concentrated in whether the explanation
|
|
457
|
+
names the right file and function, and whether it invents details along
|
|
458
|
+
the way: on `points_to_fix_location`, RAG-on answered yes in 26 of the
|
|
459
|
+
group-A comparisons against RAG-off's 13; on `no_fabrication`, 26 against
|
|
460
|
+
17. Without source access, `gpt-4o-mini` tends to invent a
|
|
461
|
+
plausible-sounding function name or parameter rather than say it doesn't
|
|
462
|
+
know; given the actual code via RAG, it mostly does not.
|
|
463
|
+
|
|
464
|
+
Three limitations are worth knowing before trusting this uncritically: RAG
|
|
465
|
+
can anchor on the wrong retrieved chunk, as it did in one fixture
|
|
466
|
+
(`missing_post_key`) where the fix got redirected to a retrieved template
|
|
467
|
+
instead of the view; the judge is shown the failing function's own
|
|
468
|
+
source, which is the same source RAG-on's retriever draws from, so part
|
|
469
|
+
of RAG-on's `no_fabrication` advantage may be judge and generator
|
|
470
|
+
overlapping on material RAG-off never sees rather than RAG-on being more
|
|
471
|
+
careful; and claim statuses are spot-checked, not exhaustively audited --
|
|
472
|
+
a script that flagged 14 of 363 claims on one run, all correct on manual
|
|
473
|
+
inspection, is a sample that turned up no false positive, not a proof
|
|
474
|
+
that none exists. Full per-fixture results, the judge prompt, and how to
|
|
475
|
+
reproduce this (about $1.37 for a `--runs 3` pass, most of it judge cost)
|
|
476
|
+
are in [`evals/README.md`](evals/README.md).
|
|
370
477
|
|
|
371
478
|
## License
|
|
372
479
|
|
|
@@ -10,7 +10,8 @@ a local model if you prefer not to send code off your machine.
|
|
|
10
10
|
|
|
11
11
|
It can optionally ground explanations in your own project source using a
|
|
12
12
|
local vector index (RAG), so explanations reference the actual code that
|
|
13
|
-
failed instead of staying generic
|
|
13
|
+
failed instead of staying generic (measured — see "Does RAG actually
|
|
14
|
+
help?" below).
|
|
14
15
|
|
|
15
16
|
The middleware supports both synchronous (WSGI) and asynchronous (ASGI)
|
|
16
17
|
views. It auto-detects the view chain at startup and routes requests through
|
|
@@ -36,6 +37,8 @@ Local development only. It requires `DEBUG = True` and is inert otherwise.
|
|
|
36
37
|
- Explains errors using OpenAI, Anthropic's Claude models, or any other
|
|
37
38
|
OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`
|
|
38
39
|
- Optional codebase-aware explanations (RAG) backed by a local sqlite-vec index (see the RAG section below)
|
|
40
|
+
- Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with exception names,
|
|
41
|
+
identifiers, and code kept in English
|
|
39
42
|
- Redacts secrets, tokens, and emails from tracebacks before sending
|
|
40
43
|
- Rate limits API calls with a configurable sliding window
|
|
41
44
|
- Works with both sync (WSGI) and async (ASGI) views
|
|
@@ -135,9 +138,9 @@ developer is actually investigating.
|
|
|
135
138
|
| `OPENAI_API_KEY` (env or settings) | Yes, unless `OPENAI_BASE_URL` points at an endpoint that does not authenticate | API key used to authenticate with the configured endpoint. Read first from the environment, then from `settings`. |
|
|
136
139
|
| `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
|
|
137
140
|
| `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini`. |
|
|
138
|
-
| `OPENAI_MAX_TOKENS` | No | Ceiling on tokens generated for the explanation, not a target — the system prompt itself asks for a concise answer. Defaults to `1000
|
|
141
|
+
| `OPENAI_MAX_TOKENS` | No | Ceiling on tokens generated for the explanation, not a target — the system prompt itself asks for a concise answer. Defaults to `1000`; scales up automatically when `EXPLAIN_ERRORS_LANGUAGE` is set (see below), unless you set this explicitly, which always overrides the scaling. |
|
|
139
142
|
| `OPENAI_TIMEOUT` | No | Request timeout in seconds for the OpenAI client. Defaults to `10`. |
|
|
140
|
-
| `OPENAI_MAX_TRACEBACK_CHARS` | No |
|
|
143
|
+
| `OPENAI_MAX_TRACEBACK_CHARS` | No | Total character budget for the traceback sent to the model. Application frames (your own code, as opposed to Django, the standard library, or installed packages) are always kept; library frames fill whatever budget remains, nearest the raise point first, with an `... N library frames omitted ...` line where frames are dropped. If the application frames alone exceed the budget, falls back to keeping the last N characters of the raw traceback. Defaults to `3000`. |
|
|
141
144
|
| `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE` | No | When `True` (the default), the middleware prints the explanation to stdout and returns `None`, so exception handling continues normally and Django renders its standard debug page. Set to `False` to instead return a JSON 500 response, which ends exception handling early (see Compatibility above). |
|
|
142
145
|
| `OPENAI_BASE_URL` (env or settings) | No | Base URL for any OpenAI-compatible API (for example Ollama at `http://localhost:11434/v1`). When set, a missing API key is replaced with a placeholder since local servers do not require one. |
|
|
143
146
|
| `EXPLAIN_ERRORS_MAX_CALLS` | No | Together with `EXPLAIN_ERRORS_WINDOW_SECONDS`, caps API spend to at most this many explanations within a rolling window; once the cap is hit, further errors in that window are not sent for explanation until an earlier call ages out. Defaults to `5`. |
|
|
@@ -145,6 +148,7 @@ developer is actually investigating.
|
|
|
145
148
|
| `EXPLAIN_ERRORS_REDACT_PATTERNS` | No | Extra regex pattern strings (each passed to `re.compile`) applied to the traceback, appended after the built-in secret/token/email patterns. An invalid pattern is skipped with a warning rather than raising. Defaults to `[]`. |
|
|
146
149
|
| `EXPLAIN_ERRORS_REDACT_DISABLE_DEFAULTS` | No | When `True`, skips the built-in secret/token/email redaction patterns entirely and redacts only what `EXPLAIN_ERRORS_REDACT_PATTERNS` specifies. Turning this on removes the default protection against leaking secrets and PII in tracebacks. Defaults to `False`. |
|
|
147
150
|
| `EXPLAIN_ERRORS_REDACT_REPLACEMENT` | No | Replacement string substituted for anything matched by the redaction patterns. Defaults to `"[REDACTED]"`. |
|
|
151
|
+
| `EXPLAIN_ERRORS_LANGUAGE` | No | Language the explanation prose is written in, as a plain name or code (for example `"Spanish"` or `"es"`). Defaults to `None`, meaning English. Exception names, identifiers, code, file paths, and tracebacks always stay in English regardless of this setting. |
|
|
148
152
|
|
|
149
153
|
## Using local models (Ollama)
|
|
150
154
|
|
|
@@ -200,6 +204,49 @@ and rejected, so a missing `OPENAI_API_KEY` shows up as an opaque `401 Unauthori
|
|
|
200
204
|
than a clear configuration error. If you see a 401 with `OPENAI_BASE_URL` pointed at a remote
|
|
201
205
|
provider, check that `OPENAI_API_KEY` — not a provider-specific variable — is actually set.
|
|
202
206
|
|
|
207
|
+
## Explanation language
|
|
208
|
+
|
|
209
|
+
By default, explanations are written in English. Set `EXPLAIN_ERRORS_LANGUAGE` to
|
|
210
|
+
read them in another language instead:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
EXPLAIN_ERRORS_LANGUAGE = "Spanish" # or the code form, "es"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
There is no fixed list of supported languages. `EXPLAIN_ERRORS_LANGUAGE` accepts
|
|
217
|
+
any language the configured model can write, because the setting adds one clause
|
|
218
|
+
to the system prompt (see `LANGUAGE_CLAUSE_TEMPLATE` in
|
|
219
|
+
`explain_errors/middleware.py`), not a translation catalog with its own
|
|
220
|
+
maintained language list. How well it works varies by model; see the known
|
|
221
|
+
limitation below.
|
|
222
|
+
|
|
223
|
+
This is independent of Django's own `LANGUAGE_CODE`, which controls the language your
|
|
224
|
+
site serves to its users, not the language you read explanations in. Regardless of
|
|
225
|
+
`EXPLAIN_ERRORS_LANGUAGE`, exception type names, Django and Python identifiers, code,
|
|
226
|
+
file paths, and tracebacks are always kept in English, so they stay greppable and
|
|
227
|
+
matchable against documentation and search results. Only the explanatory prose is
|
|
228
|
+
translated.
|
|
229
|
+
|
|
230
|
+
**Known limitation:** small local models behind `OPENAI_BASE_URL` (see "Using local
|
|
231
|
+
models" above) tend to degrade sharply outside English. Output quality with
|
|
232
|
+
`EXPLAIN_ERRORS_LANGUAGE` set does not transfer uniformly across providers — it is
|
|
233
|
+
generally solid against OpenAI and Anthropic's APIs, but a small local model that
|
|
234
|
+
writes fluent English explanations may produce broken or mixed-language output once
|
|
235
|
+
asked to switch languages.
|
|
236
|
+
|
|
237
|
+
When a language is configured and `OPENAI_MAX_TOKENS` isn't set explicitly, the
|
|
238
|
+
token ceiling defaults to 3,000 instead of 1,000, a flat 3x multiplier applied the
|
|
239
|
+
same way regardless of how well or poorly a given language is known to tokenize, so
|
|
240
|
+
explanations in languages that use more tokens per word than English aren't cut off
|
|
241
|
+
mid-sentence. This is a ceiling, not a target: billing follows tokens actually
|
|
242
|
+
generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TOKENS`
|
|
243
|
+
explicitly always overrides this scaling, at any value, including one lower than the
|
|
244
|
+
unscaled 1,000 default. The multiplier itself is deliberately generous rather than
|
|
245
|
+
precise: the underlying tokens-per-word figures are estimates, not direct
|
|
246
|
+
measurements, and the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
|
|
247
|
+
which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
|
|
248
|
+
these estimates lean on.
|
|
249
|
+
|
|
203
250
|
## Codebase-aware explanations (RAG)
|
|
204
251
|
|
|
205
252
|
By default, explanations are generated from the traceback alone. With the
|
|
@@ -283,40 +330,106 @@ else.)
|
|
|
283
330
|
|
|
284
331
|
### Before / after
|
|
285
332
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
**With RAG**, grounded in the actual function:
|
|
293
|
-
|
|
294
|
-
> In `myapp/utils.py`, `foo()` calls `int(value)` on line 12 without a
|
|
295
|
-
> `try`/`except`, so any non-numeric `value` raises `ValueError` straight
|
|
296
|
-
> through to the caller. Since `foo()` is called from `myapp/views.py` with
|
|
297
|
-
> unvalidated form input, add validation there or wrap the `int()` call in
|
|
298
|
-
> `foo()` with a clear error message.
|
|
333
|
+
A real result from the eval harness (`missing_fk`, one of the fixtures in
|
|
334
|
+
`evals/fixtures.py`): a view creates a new `Post` without setting the
|
|
335
|
+
required `author` foreign key. The traceback the model actually received
|
|
336
|
+
was already truncated to `OPENAI_MAX_TRACEBACK_CHARS`, so it contains no
|
|
337
|
+
application code at all, only Django/SQLite internals:
|
|
299
338
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
339
|
+
```
|
|
340
|
+
...(truncated)...
|
|
341
|
+
File ".../django/db/models/base.py", line 904, in save
|
|
342
|
+
self.save_base(
|
|
343
|
+
...
|
|
344
|
+
File ".../django/db/backends/utils.py", line 105, in _execute
|
|
345
|
+
return self.cursor.execute(sql, params)
|
|
346
|
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
347
|
+
File ".../django/db/backends/sqlite3/base.py", line 359, in execute
|
|
348
|
+
return super().execute(query, params)
|
|
349
|
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
350
|
+
django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id
|
|
351
|
+
```
|
|
308
352
|
|
|
309
|
-
|
|
310
|
-
...
|
|
311
|
-
'explain_errors.middleware.ExplainErrorsMiddleware',
|
|
312
|
-
]
|
|
353
|
+
**Without RAG**, traceback only:
|
|
313
354
|
|
|
314
|
-
|
|
355
|
+
> The error "django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id" occurs when you attempt to save a model instance but the `author_id` field does not receive a value, even though it requires one (is defined as `NOT NULL`).
|
|
356
|
+
>
|
|
357
|
+
> This typically indicates that the `author` field in your `Post` model (or similar) is mandatory, but you did not provide an `author` when creating or updating the post.
|
|
358
|
+
>
|
|
359
|
+
> To fix this:
|
|
360
|
+
>
|
|
361
|
+
> 1. Ensure that you are assigning a valid user or author instance to the `author` field before saving the `Post` model. For example:
|
|
362
|
+
> ```python
|
|
363
|
+
> post.author = some_user_instance
|
|
364
|
+
> ```
|
|
365
|
+
>
|
|
366
|
+
> 2. If you intended for `author_id` to be optional, adjust the model definition to allow null values by modifying the field:
|
|
367
|
+
> ```python
|
|
368
|
+
> author = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
|
|
369
|
+
> ```
|
|
370
|
+
>
|
|
371
|
+
> 3. If the migration is necessary (if you did change the model), run:
|
|
372
|
+
> ```bash
|
|
373
|
+
> python manage.py makemigrations
|
|
374
|
+
> python manage.py migrate
|
|
375
|
+
> ```
|
|
376
|
+
>
|
|
377
|
+
> Make sure that every time you save a `Post`, it has a valid author assigned.
|
|
315
378
|
|
|
316
|
-
|
|
317
|
-
```
|
|
379
|
+
**With RAG**, grounded in the actual function:
|
|
318
380
|
|
|
319
|
-
|
|
381
|
+
> The error `django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id` occurs because when you're creating a new `Post` instance in `clone_latest_post`, you're not providing an `author` for the new draft. The `author` field in the `Post` model is a ForeignKey that cannot be null, and thus must be populated.
|
|
382
|
+
>
|
|
383
|
+
> To fix this, modify the `clone_latest_post` function to include an `author` when creating the draft. You could retrieve the author from the latest post and use it like so:
|
|
384
|
+
>
|
|
385
|
+
> ```python
|
|
386
|
+
> def clone_latest_post(request):
|
|
387
|
+
> """Duplicate the most recent post as a new draft, ready for editing."""
|
|
388
|
+
> latest = Post.objects.order_by("-id").first()
|
|
389
|
+
> draft = Post.objects.create(
|
|
390
|
+
> title=f"Copy of {latest.title}",
|
|
391
|
+
> slug=f"copy-of-{latest.slug}",
|
|
392
|
+
> body=latest.body,
|
|
393
|
+
> author=latest.author, # Add this line to specify the author
|
|
394
|
+
> )
|
|
395
|
+
> return HttpResponse(f"Created draft #{draft.id}")
|
|
396
|
+
> ```
|
|
397
|
+
>
|
|
398
|
+
> This ensures the `draft` has a valid `author`, satisfying the NOT NULL constraint.
|
|
399
|
+
|
|
400
|
+
### Does RAG actually help?
|
|
401
|
+
|
|
402
|
+
To check whether RAG-grounded explanations are actually better, not just
|
|
403
|
+
longer, the package ships an eval harness (`evals/`): fifteen deliberately
|
|
404
|
+
broken Django views, each explained twice (once from the traceback alone,
|
|
405
|
+
once with RAG enabled) and judged by a separate model, blind to which
|
|
406
|
+
explanation is which, against the error's known cause and correct fix
|
|
407
|
+
location. Which side the judge sees as "A" is randomized per comparison so
|
|
408
|
+
position can't bias the result.
|
|
409
|
+
|
|
410
|
+
Across three runs (45 judged comparisons, 2 judge failures, 43 scored),
|
|
411
|
+
RAG-on won 35, RAG-off 5, and 3 tied. The gap isn't spread evenly across
|
|
412
|
+
everything the judge checks. It's concentrated in whether the explanation
|
|
413
|
+
names the right file and function, and whether it invents details along
|
|
414
|
+
the way: on `points_to_fix_location`, RAG-on answered yes in 26 of the
|
|
415
|
+
group-A comparisons against RAG-off's 13; on `no_fabrication`, 26 against
|
|
416
|
+
17. Without source access, `gpt-4o-mini` tends to invent a
|
|
417
|
+
plausible-sounding function name or parameter rather than say it doesn't
|
|
418
|
+
know; given the actual code via RAG, it mostly does not.
|
|
419
|
+
|
|
420
|
+
Three limitations are worth knowing before trusting this uncritically: RAG
|
|
421
|
+
can anchor on the wrong retrieved chunk, as it did in one fixture
|
|
422
|
+
(`missing_post_key`) where the fix got redirected to a retrieved template
|
|
423
|
+
instead of the view; the judge is shown the failing function's own
|
|
424
|
+
source, which is the same source RAG-on's retriever draws from, so part
|
|
425
|
+
of RAG-on's `no_fabrication` advantage may be judge and generator
|
|
426
|
+
overlapping on material RAG-off never sees rather than RAG-on being more
|
|
427
|
+
careful; and claim statuses are spot-checked, not exhaustively audited --
|
|
428
|
+
a script that flagged 14 of 363 claims on one run, all correct on manual
|
|
429
|
+
inspection, is a sample that turned up no false positive, not a proof
|
|
430
|
+
that none exists. Full per-fixture results, the judge prompt, and how to
|
|
431
|
+
reproduce this (about $1.37 for a `--runs 3` pass, most of it judge cost)
|
|
432
|
+
are in [`evals/README.md`](evals/README.md).
|
|
320
433
|
|
|
321
434
|
## License
|
|
322
435
|
|