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.
Files changed (37) hide show
  1. {django_explain_errors-0.5.0/django_explain_errors.egg-info → django_explain_errors-0.7.0}/PKG-INFO +147 -40
  2. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/README.md +145 -32
  3. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0/django_explain_errors.egg-info}/PKG-INFO +147 -40
  4. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/django_explain_errors.egg-info/SOURCES.txt +6 -0
  5. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/middleware.py +51 -8
  6. django_explain_errors-0.7.0/explain_errors/tracebacks.py +105 -0
  7. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/setup.py +3 -9
  8. django_explain_errors-0.7.0/tests/test_eval_fixtures.py +110 -0
  9. django_explain_errors-0.7.0/tests/test_eval_judge.py +393 -0
  10. django_explain_errors-0.7.0/tests/test_eval_run.py +389 -0
  11. django_explain_errors-0.7.0/tests/test_language.py +158 -0
  12. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_middleware.py +46 -0
  13. django_explain_errors-0.7.0/tests/test_tracebacks.py +129 -0
  14. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/LICENSE +0 -0
  15. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/MANIFEST.in +0 -0
  16. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
  17. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/django_explain_errors.egg-info/requires.txt +0 -0
  18. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/django_explain_errors.egg-info/top_level.txt +0 -0
  19. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/__init__.py +0 -0
  20. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/client.py +0 -0
  21. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/management/__init__.py +0 -0
  22. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/management/commands/__init__.py +0 -0
  23. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/management/commands/build_error_index.py +0 -0
  24. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/rag/__init__.py +0 -0
  25. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/rag/indexer.py +0 -0
  26. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/rag/retriever.py +0 -0
  27. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/rag/store.py +0 -0
  28. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/sanitize.py +0 -0
  29. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/explain_errors/throttle.py +0 -0
  30. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/setup.cfg +0 -0
  31. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_build_error_index.py +0 -0
  32. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_client.py +0 -0
  33. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_rag.py +0 -0
  34. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_sanitize.py +0 -0
  35. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_signals.py +0 -0
  36. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_throttle.py +0 -0
  37. {django_explain_errors-0.5.0 → django_explain_errors-0.7.0}/tests/test_truncation.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-explain-errors
3
- Version: 0.5.0
4
- Summary: Django middleware that captures errors and exceptions, sends them to OpenAI for a detailed explanation, and prints the explanation to stdout when debug mode is enabled. Supports both sync and async views.
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 | Traceback is trimmed to its last N characters before being sent. Defaults to `3000`. |
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
- **Without RAG**, traceback only:
337
-
338
- > Your `ValueError` is raised because the value passed to `foo()` couldn't
339
- > be converted to an integer. Check where `foo()` is called and make sure
340
- > you're passing a numeric string.
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
- ## Example
351
-
352
- Here is an example of how to use the middleware in a Django project:
353
-
354
- ```python
355
- # settings.py
356
-
357
- DEBUG = True
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
- MIDDLEWARE = [
360
- ...
361
- 'explain_errors.middleware.ExplainErrorsMiddleware',
362
- ]
397
+ **Without RAG**, traceback only:
363
398
 
364
- # .env
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
- OPENAI_API_KEY=your_openai_api_key_here
367
- ```
423
+ **With RAG**, grounded in the actual function:
368
424
 
369
- When an error occurs, you will see an explanation printed to stdout.
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 | Traceback is trimmed to its last N characters before being sent. Defaults to `3000`. |
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
- **Without RAG**, traceback only:
287
-
288
- > Your `ValueError` is raised because the value passed to `foo()` couldn't
289
- > be converted to an integer. Check where `foo()` is called and make sure
290
- > you're passing a numeric string.
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
- ## Example
301
-
302
- Here is an example of how to use the middleware in a Django project:
303
-
304
- ```python
305
- # settings.py
306
-
307
- DEBUG = True
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
- MIDDLEWARE = [
310
- ...
311
- 'explain_errors.middleware.ExplainErrorsMiddleware',
312
- ]
353
+ **Without RAG**, traceback only:
313
354
 
314
- # .env
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
- OPENAI_API_KEY=your_openai_api_key_here
317
- ```
379
+ **With RAG**, grounded in the actual function:
318
380
 
319
- When an error occurs, you will see an explanation printed to stdout.
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