django-explain-errors 0.6.0__tar.gz → 0.8.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 (40) hide show
  1. {django_explain_errors-0.6.0/django_explain_errors.egg-info → django_explain_errors-0.8.0}/PKG-INFO +242 -66
  2. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/README.md +241 -65
  3. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0/django_explain_errors.egg-info}/PKG-INFO +242 -66
  4. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/django_explain_errors.egg-info/SOURCES.txt +8 -0
  5. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/client.py +3 -2
  6. django_explain_errors-0.8.0/explain_errors/debug_page.py +172 -0
  7. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/management/commands/build_error_index.py +2 -2
  8. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/middleware.py +77 -17
  9. django_explain_errors-0.8.0/explain_errors/tracebacks.py +105 -0
  10. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/setup.py +2 -2
  11. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_build_error_index.py +1 -1
  12. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_client.py +3 -2
  13. django_explain_errors-0.8.0/tests/test_debug_page.py +402 -0
  14. django_explain_errors-0.8.0/tests/test_eval_fixtures.py +110 -0
  15. django_explain_errors-0.8.0/tests/test_eval_judge.py +393 -0
  16. django_explain_errors-0.8.0/tests/test_eval_run.py +389 -0
  17. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_middleware.py +47 -1
  18. django_explain_errors-0.8.0/tests/test_print_stdout.py +130 -0
  19. django_explain_errors-0.8.0/tests/test_tracebacks.py +129 -0
  20. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/LICENSE +0 -0
  21. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/MANIFEST.in +0 -0
  22. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
  23. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/django_explain_errors.egg-info/requires.txt +0 -0
  24. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/django_explain_errors.egg-info/top_level.txt +0 -0
  25. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/__init__.py +0 -0
  26. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/management/__init__.py +0 -0
  27. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/management/commands/__init__.py +0 -0
  28. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/rag/__init__.py +0 -0
  29. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/rag/indexer.py +0 -0
  30. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/rag/retriever.py +0 -0
  31. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/rag/store.py +0 -0
  32. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/sanitize.py +0 -0
  33. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/explain_errors/throttle.py +0 -0
  34. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/setup.cfg +0 -0
  35. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_language.py +0 -0
  36. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_rag.py +0 -0
  37. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_sanitize.py +0 -0
  38. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_signals.py +0 -0
  39. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_throttle.py +0 -0
  40. {django_explain_errors-0.6.0 → django_explain_errors-0.8.0}/tests/test_truncation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-explain-errors
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
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
@@ -45,16 +45,23 @@ Dynamic: summary
45
45
  # Django Explain Errors Middleware
46
46
 
47
47
  This Django middleware captures unhandled errors and exceptions, sends them
48
- to a language model for explanation, and prints the explanation to stdout
49
- when debug mode is enabled. It works with the OpenAI API out of the box, with
50
- Anthropic's Claude models through Anthropic's OpenAI-compatible endpoint, and
51
- with any other OpenAI-compatible endpoint (Ollama, LM Studio, Azure, or a
52
- corporate gateway) by setting a base URL, so explanations can run entirely on
53
- a local model if you prefer not to send code off your machine.
54
-
55
- It can optionally ground explanations in your own project source using a
56
- local vector index (RAG), so explanations reference the actual code that
57
- failed instead of staying generic.
48
+ to a language model for explanation, and, when debug mode is enabled, shows
49
+ the explanation on Django's debug page directly under the exception
50
+ headline, as well as printing it to stdout. It works with the OpenAI API
51
+ out of the box, with Anthropic's Claude models through Anthropic's
52
+ OpenAI-compatible endpoint, and with any other OpenAI-compatible endpoint
53
+ (Ollama, LM Studio, Azure, or a corporate gateway) by setting a base URL,
54
+ so explanations can run entirely on a local model if you prefer not to send
55
+ code off your machine.
56
+
57
+ Enabling RAG is strongly recommended. With it, the middleware retrieves the
58
+ relevant parts of your own project source from a local vector index, so
59
+ explanations name the actual file and function that failed instead of
60
+ guessing. In the package's eval harness, RAG-grounded explanations won 35
61
+ of 43 blind comparisons, with the gap concentrated in pointing at the right
62
+ fix location and not inventing details (see "Does RAG actually help?"
63
+ below). It is off by default because it needs an optional extra and a
64
+ one-time index build.
58
65
 
59
66
  The middleware supports both synchronous (WSGI) and asynchronous (ASGI)
60
67
  views. It auto-detects the view chain at startup and routes requests through
@@ -62,11 +69,17 @@ the matching path, so no extra configuration is required for either server
62
69
  type. Tracebacks are sanitized before leaving the process, and API calls are
63
70
  rate limited.
64
71
 
72
+ ![Django debug page with the explanation banner under the exception headline](https://raw.githubusercontent.com/topunix/django-explain-errors/main/docs/images/debug-page-banner.png)
73
+
74
+ *Real `gpt-4o-mini` output with RAG enabled, recorded by the eval harness
75
+ on its `missing_fk` fixture.*
76
+
65
77
  ## Scope
66
78
 
67
79
  This package explains errors for a person, not for a coding agent to consume
68
- programmatically. The explanation is written for a human reader, in a terminal or an editor's
69
- integrated terminal, and the output format assumes that reader.
80
+ programmatically. The explanation is written for a human reader, on Django's
81
+ debug page in the browser or in a terminal, and the output format assumes
82
+ that reader.
70
83
 
71
84
  If a coding agent is doing the debugging, it does not need this. Agents read tracebacks directly,
72
85
  and tools that expose live runtime state (debugger-over-MCP servers, `mcp-django`) serve that case
@@ -77,9 +90,16 @@ Local development only. It requires `DEBUG = True` and is inert otherwise.
77
90
  ## Features
78
91
 
79
92
  - Captures Django errors and exceptions
93
+ - Shows the explanation on Django's debug page, directly under the
94
+ exception headline (`EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`, on by default)
95
+ - Prints the explanation to stdout, for terminal workflows and logs
96
+ (`EXPLAIN_ERRORS_PRINT_STDOUT`, on by default)
97
+ - Optional JSON 500 response instead of the debug page
98
+ (`EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = False`)
80
99
  - Explains errors using OpenAI, Anthropic's Claude models, or any other
81
100
  OpenAI-compatible endpoint (Ollama, LM Studio, Azure, gateways) via `OPENAI_BASE_URL`
82
- - Optional codebase-aware explanations (RAG) backed by a local sqlite-vec index (see the RAG section below)
101
+ - Codebase-aware explanations via RAG over a local sqlite-vec index
102
+ (recommended; see "Codebase-aware explanations" below)
83
103
  - Explanations in your language via `EXPLAIN_ERRORS_LANGUAGE`, with exception names,
84
104
  identifiers, and code kept in English
85
105
  - Redacts secrets, tokens, and emails from tracebacks before sending
@@ -96,13 +116,16 @@ pip install django-explain-errors
96
116
 
97
117
  2. **Add the middleware to your Django project**:
98
118
 
99
- - Open your `settings.py` file and add the middleware to the `MIDDLEWARE` list:
119
+ - Open your `settings.py` file and register the middleware only when `DEBUG` is on (see
120
+ Production Safety below for why):
100
121
 
101
122
  ```python
102
123
  MIDDLEWARE = [
103
124
  ...
104
- 'explain_errors.middleware.ExplainErrorsMiddleware',
105
125
  ]
126
+
127
+ if DEBUG:
128
+ MIDDLEWARE.append('explain_errors.middleware.ExplainErrorsMiddleware')
106
129
  ```
107
130
 
108
131
  In the default preserve mode, `process_exception` returns `None`, so exception handling
@@ -116,15 +139,39 @@ pip install django-explain-errors
116
139
 
117
140
  3. **Set up environment variables**:
118
141
 
119
- - Create a `.env` file in your project's root directory and add your OpenAI API key. Alternatively, you can set the API key in `settings.py`:
142
+ - Create a `.env` file in your project's root directory and add your API key. Alternatively, you can set it in `settings.py`:
120
143
 
121
144
  ```plaintext
122
- OPENAI_API_KEY=your_openai_api_key_here
145
+ OPENAI_API_KEY=your_api_key_here
123
146
  ```
124
147
 
125
148
  The API key is not required if you set `OPENAI_BASE_URL` to a local
126
149
  server such as Ollama, which does not authenticate requests.
127
150
 
151
+ ## Production Safety
152
+
153
+ This middleware is intended for local development only. When active, it sends exception
154
+ tracebacks to the configured LLM provider, which may include source code, file paths, and
155
+ local variable values. Keep it out of production.
156
+
157
+ **Primary safeguard:** register the middleware only when `DEBUG` is on, or only in your
158
+ development settings module:
159
+
160
+ ```python
161
+ # settings.py
162
+ if DEBUG:
163
+ MIDDLEWARE.append("explain_errors.middleware.ExplainErrorsMiddleware")
164
+ ```
165
+
166
+ See Installation above for where the middleware can sit in `MIDDLEWARE`.
167
+
168
+ **Secondary safeguard:** run Django's deployment checks in CI against your production settings.
169
+ This fails the build if `DEBUG = True` (`security.W018`):
170
+
171
+ ```bash
172
+ python manage.py check --deploy --fail-level WARNING
173
+ ```
174
+
128
175
  ## Usage
129
176
 
130
177
  1. **Ensure DEBUG is set to True**:
@@ -137,17 +184,59 @@ pip install django-explain-errors
137
184
 
138
185
  2. **Trigger an error in your Django application**:
139
186
 
140
- The middleware captures the error, sends it to the configured model for explanation, and prints the explanation to stdout. By default (`EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=True`), it then lets exception handling continue normally, so Django (or whatever else is watching, such as `runserver_plus` or Sentry — see Compatibility below) renders exactly what it would without this middleware installed. Set `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = False` to instead get a JSON `500` response containing the error message and the explanation.
187
+ The middleware captures the error, sends it to the configured model for explanation, prints the explanation to stdout, and adds it as a banner on Django's debug page. By default (`EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=True`), exception handling then continues normally, so Django (or whatever else is watching, such as `runserver_plus` or Sentry, see Compatibility below) renders its usual response. The only change is the banner on Django's own debug page. Set `EXPLAIN_ERRORS_INJECT_DEBUG_PAGE = False` to remove it, or `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE = False` to instead get a JSON `500` response containing the error message and the explanation.
188
+
189
+ In the terminal, the explanation is printed with the model that produced it:
190
+
191
+ ```
192
+ Error explanation (gpt-4o-mini):
193
+ The view raised a ValueError because ...
194
+ ```
141
195
 
142
196
  ## Async Support
143
197
 
144
198
  The middleware exposes both `sync_capable = True` and `async_capable = True`. At initialization it inspects `get_response` to decide whether it is part of a sync or async chain:
145
199
 
146
200
  - Under WSGI (for example `runserver` with sync views), requests flow through the synchronous handler.
147
- - Under ASGI (for example with async views), requests are awaited through the async handler. The blocking OpenAI call is offloaded with `asgiref.sync.sync_to_async` so the event loop is not blocked.
201
+ - Under ASGI (for example with async views), requests are awaited through the async handler. The blocking model API call is offloaded with `asgiref.sync.sync_to_async` so the event loop is not blocked.
148
202
 
149
203
  No additional settings are needed. See Installation above for where to place the middleware in `MIDDLEWARE`.
150
204
 
205
+ ## Debug page injection
206
+
207
+ When Django renders its debug page for a 500, `explain_errors` also inserts a banner
208
+ containing the explanation directly under the exception headline, in addition to printing
209
+ it to stdout.
210
+
211
+ Controlled by `EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`, default `True`. Injection happens only
212
+ when all of the following hold:
213
+
214
+ - `DEBUG` is `True`
215
+ - `EXPLAIN_ERRORS_INJECT_DEBUG_PAGE` is `True` (the default)
216
+ - `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE` is `True` (the default). With
217
+ `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False`, the JSON 500 path runs instead and there is
218
+ no debug page to inject into.
219
+ - an explanation was actually produced (not throttled, and the API call succeeded)
220
+
221
+ The banner appears only on the HTML debug page, styled inline (bordered box, light
222
+ background) so it reads on Django's page. The explanation is HTML-escaped first, then a small
223
+ Markdown subset (inline code, code blocks, bold, lists) is rendered as HTML. Nothing from the
224
+ model is ever marked safe.
225
+
226
+ **Fails open.** Any problem building or inserting the banner (a missing request, an
227
+ unexpected debug page layout, anything else) logs one warning and leaves Django's normal
228
+ debug page untouched. This code can never turn a working debug page into a broken one.
229
+
230
+ **Custom exception reporters.** If your project already sets a custom
231
+ `DEFAULT_EXCEPTION_REPORTER`, or something earlier in the request sets
232
+ `request.exception_reporter_class`, `explain_errors` leaves it alone and skips injection
233
+ (logged at debug level) rather than overriding it.
234
+
235
+ **Upgrading.** Turning this on, or upgrading to a version where it defaults on, changes
236
+ what Django's debug page looks like: a new section appears above the request metadata
237
+ table. If you rely on the debug page's exact markup (a scraper, a screenshot test), account
238
+ for this.
239
+
151
240
  ## Compatibility
152
241
 
153
242
  How this middleware interacts with other error-handling and debugging tools, in the default
@@ -155,10 +244,10 @@ preserve mode and with `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False`:
155
244
 
156
245
  | Package | Preserve mode (default) | `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False` |
157
246
  | ------- | ------------------------ | ------------------------------------------- |
158
- | Django Debug Toolbar | Works — Django renders its normal debug page, and Debug Toolbar injects into it. | Broken — the JSON 500 response has no HTML to inject into. |
247
+ | Django Debug Toolbar | Works — Django renders its debug page (with the explanation banner, unless `EXPLAIN_ERRORS_INJECT_DEBUG_PAGE = False`), and Debug Toolbar injects into it. | Broken — the JSON 500 response has no HTML to inject into. |
159
248
  | Sentry, Rollbar | Work — the exception propagates and Django re-raises it, so `got_request_exception` fires. | Broken — returning a response ends exception handling before `got_request_exception` fires. |
160
249
  | Django REST Framework | Partial — only exceptions DRF does not already handle itself reach this middleware. | Partial, same reason. |
161
- | Silk and other profiling panels | Timings are inflated by the OpenAI call, since `process_exception` blocks the request path. | Same. |
250
+ | Silk and other profiling panels | Timings are inflated by the model API call, since `process_exception` blocks the request path. | Same. |
162
251
  | CORS, GZip, WhiteNoise | No interaction. | No interaction. |
163
252
  | `runserver_plus` / Werkzeug debugger | Works — returning `None` re-raises the original exception, and `django-extensions` replaces Django's debug-page renderer with one that re-raises instead, so Werkzeug's WSGI wrapper catches it and shows the interactive debugger. | Broken — the JSON 500 response ends exception handling before it reaches `runserver_plus`'s exception hook, so the Werkzeug debugger never appears. |
164
253
 
@@ -182,9 +271,11 @@ developer is actually investigating.
182
271
  | `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
183
272
  | `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini`. |
184
273
  | `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. |
185
- | `OPENAI_TIMEOUT` | No | Request timeout in seconds for the OpenAI client. Defaults to `10`. |
186
- | `OPENAI_MAX_TRACEBACK_CHARS` | No | Traceback is trimmed to its last N characters before being sent. Defaults to `3000`. |
187
- | `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). |
274
+ | `OPENAI_TIMEOUT` | No | Request timeout in seconds for model API calls. Defaults to `10`. |
275
+ | `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`. |
276
+ | `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE` | No | When `True` (the default), the middleware returns `None` (after printing the explanation to stdout, unless `EXPLAIN_ERRORS_PRINT_STDOUT = False`), so exception handling continues normally and Django renders its debug page (with the explanation banner, controlled by `EXPLAIN_ERRORS_INJECT_DEBUG_PAGE`). Set to `False` to instead return a JSON 500 response, which ends exception handling early (see Compatibility above). |
277
+ | `EXPLAIN_ERRORS_INJECT_DEBUG_PAGE` | No | When `True` (the default), also injects the explanation as a banner into Django's debug page, in addition to stdout. Requires `EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=True` (the default); see Debug Page Injection above. |
278
+ | `EXPLAIN_ERRORS_PRINT_STDOUT` | No | When `True` (the default), prints the explanation to stdout. Set to `False` to suppress it, for example when `EXPLAIN_ERRORS_INJECT_DEBUG_PAGE` already shows it in the browser. The failure message (when the model API call itself errors) always prints. For requests where nobody views the debug page (API clients, which get Django's plain-text error response, and `fetch`/HTMX requests, whose HTML response is never displayed), stdout is the only channel that shows the explanation, so turn this off only in browser-first workflows. |
188
279
  | `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. |
189
280
  | `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`. |
190
281
  | `EXPLAIN_ERRORS_WINDOW_SECONDS` | No | Length in seconds of the rolling window `EXPLAIN_ERRORS_MAX_CALLS` is measured against. Defaults to `60` (with the defaults, at most 5 explanations per 60-second window). |
@@ -256,6 +347,13 @@ read them in another language instead:
256
347
  EXPLAIN_ERRORS_LANGUAGE = "Spanish" # or the code form, "es"
257
348
  ```
258
349
 
350
+ There is no fixed list of supported languages. `EXPLAIN_ERRORS_LANGUAGE` accepts
351
+ any language the configured model can write, because the setting adds one clause
352
+ to the system prompt (see `LANGUAGE_CLAUSE_TEMPLATE` in
353
+ `explain_errors/middleware.py`), not a translation catalog with its own
354
+ maintained language list. How well it works varies by model; see the known
355
+ limitation below.
356
+
259
357
  This is independent of Django's own `LANGUAGE_CODE`, which controls the language your
260
358
  site serves to its users, not the language you read explanations in. Regardless of
261
359
  `EXPLAIN_ERRORS_LANGUAGE`, exception type names, Django and Python identifiers, code,
@@ -270,13 +368,18 @@ generally solid against OpenAI and Anthropic's APIs, but a small local model tha
270
368
  writes fluent English explanations may produce broken or mixed-language output once
271
369
  asked to switch languages.
272
370
 
273
- When a language is configured, the token ceiling (`OPENAI_MAX_TOKENS`) is scaled up
274
- automatically so explanations in languages that tokenize less efficiently than English
275
- aren't cut off mid-sentence. This scaling is deliberately generous — billing follows
276
- tokens actually generated, so unused headroom costs nothing — rather than precise: the
277
- underlying tokens-per-word figures are estimates, not direct measurements, and the
278
- default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer, which handles non-Latin
279
- scripts considerably better than the `cl100k_base`-era ratios these estimates lean on.
371
+ When a language is configured and `OPENAI_MAX_TOKENS` isn't set explicitly, the
372
+ token ceiling defaults to 3,000 instead of 1,000, a flat 3x multiplier applied the
373
+ same way regardless of how well or poorly a given language is known to tokenize, so
374
+ explanations in languages that use more tokens per word than English aren't cut off
375
+ mid-sentence. This is a ceiling, not a target: billing follows tokens actually
376
+ generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TOKENS`
377
+ explicitly always overrides this scaling, at any value, including one lower than the
378
+ unscaled 1,000 default. The multiplier itself is deliberately generous rather than
379
+ precise: the underlying tokens-per-word figures are estimates, not direct
380
+ measurements, and the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
381
+ which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
382
+ these estimates lean on.
280
383
 
281
384
  ## Codebase-aware explanations (RAG)
282
385
 
@@ -286,7 +389,9 @@ also retrieves the most relevant chunks of your own project's source code
286
389
  from a local vector index and includes them in the prompt, so explanations
287
390
  can reference your actual functions and classes instead of guessing at them.
288
391
 
289
- This feature is opt-in and adds no dependencies or behavior unless enabled.
392
+ Recommended for most projects. It is opt-in only because setup takes three
393
+ steps (install the extra, build the index, enable it), and it adds no
394
+ dependencies or behavior until you do.
290
395
 
291
396
  ### Install the extra
292
397
 
@@ -309,7 +414,7 @@ python manage.py build_error_index
309
414
 
310
415
  This walks your project, chunks Python files by top-level function/class
311
416
  (and other text files by fixed-size line windows), embeds each chunk with
312
- the OpenAI embeddings API, and writes them to a local index file. Re-run it
417
+ the configured endpoint's embeddings API, and writes them to a local index file. Re-run it
313
418
  whenever your source changes meaningfully. Indexing is not automatic.
314
419
  Rebuilding is idempotent: it builds into a temp file and atomically replaces
315
420
  the previous index.
@@ -329,15 +434,14 @@ EXPLAIN_ERRORS_RAG_ENABLED = True
329
434
  | `EXPLAIN_ERRORS_RAG_ENABLED` | `False` | Master switch for the RAG layer. |
330
435
  | `EXPLAIN_ERRORS_RAG_INDEX_PATH` | `<BASE_DIR>/.explain_errors_index.db` | Path to the local vector index file. |
331
436
  | `EXPLAIN_ERRORS_RAG_TOP_K` | `4` | Number of chunks retrieved and injected into the prompt. |
332
- | `EXPLAIN_ERRORS_RAG_EMBED_MODEL` | `"text-embedding-3-small"` | OpenAI embedding model used for indexing and retrieval. |
437
+ | `EXPLAIN_ERRORS_RAG_EMBED_MODEL` | `"text-embedding-3-small"` | Embedding model used for indexing and retrieval. Must exist on the configured endpoint. |
333
438
  | `EXPLAIN_ERRORS_RAG_INCLUDE` | `None` (defaults to `BASE_DIR`) | List of directories to index. |
334
439
  | `EXPLAIN_ERRORS_RAG_EXCLUDE` | migrations, venvs, `node_modules`, static, media, `.git` | Directory names to skip while indexing. |
335
440
  | `EXPLAIN_ERRORS_RAG_MAX_PROMPT_CHARS` | `6000` | Combined character budget for the traceback + retrieved source sections of the prompt. |
336
441
 
337
442
  Every chunk of source code and every retrieval query is passed through the
338
443
  same `sanitize_traceback()` redaction used for tracebacks, which strips
339
- patterns that look like secrets, tokens, and emails before anything is sent
340
- to OpenAI or written to the index. It's a pattern-based filter, not a
444
+ patterns that look like secrets, tokens, and emails before anything is sent to the model API or written to the index. It's a pattern-based filter, not a
341
445
  guarantee: it catches recognizable secret shapes, not arbitrary sensitive
342
446
  data that doesn't match one.
343
447
 
@@ -361,40 +465,112 @@ else.)
361
465
 
362
466
  ### Before / after
363
467
 
364
- **Without RAG**, traceback only:
365
-
366
- > Your `ValueError` is raised because the value passed to `foo()` couldn't
367
- > be converted to an integer. Check where `foo()` is called and make sure
368
- > you're passing a numeric string.
369
-
370
- **With RAG**, grounded in the actual function:
468
+ A real result from the eval harness (`missing_fk`, one of the fixtures in
469
+ `evals/fixtures.py`): a view creates a new `Post` without setting the
470
+ required `author` foreign key. The traceback the model actually received
471
+ was already truncated to `OPENAI_MAX_TRACEBACK_CHARS`, so it contains no
472
+ application code at all, only Django/SQLite internals:
371
473
 
372
- > In `myapp/utils.py`, `foo()` calls `int(value)` on line 12 without a
373
- > `try`/`except`, so any non-numeric `value` raises `ValueError` straight
374
- > through to the caller. Since `foo()` is called from `myapp/views.py` with
375
- > unvalidated form input, add validation there or wrap the `int()` call in
376
- > `foo()` with a clear error message.
474
+ This example was recorded before 0.7.0, when tracebacks were trimmed to
475
+ their last N characters. Since 0.7.0 your application's frames are always
476
+ kept, so a traceback-only explanation would now see `clone_latest_post`
477
+ too. The measured results in "Does RAG actually help?" below were run
478
+ after that change.
377
479
 
378
- ## Example
379
-
380
- Here is an example of how to use the middleware in a Django project:
381
-
382
- ```python
383
- # settings.py
384
-
385
- DEBUG = True
480
+ ```
481
+ ...(truncated)...
482
+ File ".../django/db/models/base.py", line 904, in save
483
+ self.save_base(
484
+ ...
485
+ File ".../django/db/backends/utils.py", line 105, in _execute
486
+ return self.cursor.execute(sql, params)
487
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
488
+ File ".../django/db/backends/sqlite3/base.py", line 359, in execute
489
+ return super().execute(query, params)
490
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
491
+ django.db.utils.IntegrityError: NOT NULL constraint failed: blog_post.author_id
492
+ ```
386
493
 
387
- MIDDLEWARE = [
388
- ...
389
- 'explain_errors.middleware.ExplainErrorsMiddleware',
390
- ]
494
+ **Without RAG**, traceback only:
391
495
 
392
- # .env
496
+ > 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`).
497
+ >
498
+ > 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.
499
+ >
500
+ > To fix this:
501
+ >
502
+ > 1. Ensure that you are assigning a valid user or author instance to the `author` field before saving the `Post` model. For example:
503
+ > ```python
504
+ > post.author = some_user_instance
505
+ > ```
506
+ >
507
+ > 2. If you intended for `author_id` to be optional, adjust the model definition to allow null values by modifying the field:
508
+ > ```python
509
+ > author = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
510
+ > ```
511
+ >
512
+ > 3. If the migration is necessary (if you did change the model), run:
513
+ > ```bash
514
+ > python manage.py makemigrations
515
+ > python manage.py migrate
516
+ > ```
517
+ >
518
+ > Make sure that every time you save a `Post`, it has a valid author assigned.
393
519
 
394
- OPENAI_API_KEY=your_openai_api_key_here
395
- ```
520
+ **With RAG**, grounded in the actual function:
396
521
 
397
- When an error occurs, you will see an explanation printed to stdout.
522
+ > 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.
523
+ >
524
+ > 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:
525
+ >
526
+ > ```python
527
+ > def clone_latest_post(request):
528
+ > """Duplicate the most recent post as a new draft, ready for editing."""
529
+ > latest = Post.objects.order_by("-id").first()
530
+ > draft = Post.objects.create(
531
+ > title=f"Copy of {latest.title}",
532
+ > slug=f"copy-of-{latest.slug}",
533
+ > body=latest.body,
534
+ > author=latest.author, # Add this line to specify the author
535
+ > )
536
+ > return HttpResponse(f"Created draft #{draft.id}")
537
+ > ```
538
+ >
539
+ > This ensures the `draft` has a valid `author`, satisfying the NOT NULL constraint.
540
+
541
+ ### Does RAG actually help?
542
+
543
+ To check whether RAG-grounded explanations are actually better, not just
544
+ longer, the package ships an eval harness (`evals/`): fifteen deliberately
545
+ broken Django views, each explained twice (once from the traceback alone,
546
+ once with RAG enabled) and judged by a separate model, blind to which
547
+ explanation is which, against the error's known cause and correct fix
548
+ location. Which side the judge sees as "A" is randomized per comparison so
549
+ position can't bias the result.
550
+
551
+ Across three runs (45 judged comparisons, 2 judge failures, 43 scored),
552
+ RAG-on won 35, RAG-off 5, and 3 tied. The gap isn't spread evenly across
553
+ everything the judge checks. It's concentrated in whether the explanation
554
+ names the right file and function, and whether it invents details along
555
+ the way: on `points_to_fix_location`, RAG-on answered yes in 26 of the
556
+ group-A comparisons against RAG-off's 13; on `no_fabrication`, 26 against
557
+ 17. Without source access, `gpt-4o-mini` tends to invent a
558
+ plausible-sounding function name or parameter rather than say it doesn't
559
+ know; given the actual code via RAG, it mostly does not.
560
+
561
+ Three limitations are worth knowing before trusting this uncritically: RAG
562
+ can anchor on the wrong retrieved chunk, as it did in one fixture
563
+ (`missing_post_key`) where the fix got redirected to a retrieved template
564
+ instead of the view; the judge is shown the failing function's own
565
+ source, which is the same source RAG-on's retriever draws from, so part
566
+ of RAG-on's `no_fabrication` advantage may be judge and generator
567
+ overlapping on material RAG-off never sees rather than RAG-on being more
568
+ careful; and claim statuses are spot-checked, not exhaustively audited --
569
+ a script that flagged 14 of 363 claims on one run, all correct on manual
570
+ inspection, is a sample that turned up no false positive, not a proof
571
+ that none exists. Full per-fixture results, the judge prompt, and how to
572
+ reproduce this (about $1.37 for a `--runs 3` pass, most of it judge cost)
573
+ are in [`evals/README.md`](evals/README.md).
398
574
 
399
575
  ## License
400
576
 
@@ -407,5 +583,5 @@ Contributions are welcome! Please open an issue or submit a pull request for any
407
583
  ## Acknowledgements
408
584
 
409
585
  - [Django](https://www.djangoproject.com/)
410
- - [OpenAI](https://www.openai.com/)
586
+ - [OpenAI Python SDK](https://github.com/openai/openai-python)
411
587
  - [python-dotenv](https://github.com/theskumar/python-dotenv)