django-explain-errors 0.11.1__tar.gz → 0.12.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 (45) hide show
  1. {django_explain_errors-0.11.1/django_explain_errors.egg-info → django_explain_errors-0.12.0}/PKG-INFO +9 -10
  2. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/README.md +8 -9
  3. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0/django_explain_errors.egg-info}/PKG-INFO +9 -10
  4. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/middleware.py +1 -1
  5. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/setup.py +1 -1
  6. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_debug_page.py +1 -1
  7. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_eval_run.py +264 -3
  8. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_middleware.py +2 -2
  9. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_print_stdout.py +3 -3
  10. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/LICENSE +0 -0
  11. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/MANIFEST.in +0 -0
  12. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/SOURCES.txt +0 -0
  13. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/dependency_links.txt +0 -0
  14. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/requires.txt +0 -0
  15. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/django_explain_errors.egg-info/top_level.txt +0 -0
  16. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/__init__.py +0 -0
  17. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/client.py +0 -0
  18. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/debug_page.py +0 -0
  19. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/management/__init__.py +0 -0
  20. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/management/commands/__init__.py +0 -0
  21. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/management/commands/build_error_index.py +0 -0
  22. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/rag/__init__.py +0 -0
  23. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/rag/indexer.py +0 -0
  24. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/rag/retriever.py +0 -0
  25. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/rag/store.py +0 -0
  26. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/sanitize.py +0 -0
  27. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/throttle.py +0 -0
  28. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/explain_errors/tracebacks.py +0 -0
  29. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/setup.cfg +0 -0
  30. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_async_marking.py +0 -0
  31. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_build_error_index.py +0 -0
  32. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_client.py +0 -0
  33. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_dedup.py +0 -0
  34. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_eval_fixtures.py +0 -0
  35. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_eval_judge.py +0 -0
  36. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_language.py +0 -0
  37. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_local_embeddings.py +0 -0
  38. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_process_exception_split.py +0 -0
  39. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_rag.py +0 -0
  40. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_sanitize.py +0 -0
  41. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_signals.py +0 -0
  42. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_throttle.py +0 -0
  43. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_token_param_fallback.py +0 -0
  44. {django_explain_errors-0.11.1 → django_explain_errors-0.12.0}/tests/test_tracebacks.py +0 -0
  45. {django_explain_errors-0.11.1 → django_explain_errors-0.12.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.11.1
3
+ Version: 0.12.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
@@ -68,8 +68,8 @@ Explains Django exceptions on the debug page, pointing at the real fix in your o
68
68
  </p>
69
69
 
70
70
  *The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
71
- failing function (`clone_latest_post`) and gives the one-line fix. Real `gpt-4o-mini` output from
72
- the eval harness.*
71
+ failing function (`clone_latest_post`) and gives the one-line fix. Real output from the eval
72
+ harness, generated with `gpt-4o-mini` (the default before `gpt-6-luna`).*
73
73
 
74
74
  - **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
75
75
  - Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
@@ -196,7 +196,7 @@ python manage.py check --deploy --fail-level WARNING
196
196
  In the terminal, the explanation is printed with the model that produced it:
197
197
 
198
198
  ```
199
- Error explanation (gpt-4o-mini):
199
+ Error explanation (gpt-6-luna):
200
200
  The view raised a ValueError because ...
201
201
  ```
202
202
 
@@ -282,7 +282,7 @@ and keeps using that for later calls. Other providers keep receiving `max_tokens
282
282
  | ------------------ | -------- | ----------- |
283
283
  | `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`. |
284
284
  | `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
285
- | `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini`. |
285
+ | `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-6-luna` (was `gpt-4o-mini`). If you set `OPENAI_MODEL` yourself, the default change does not affect you, but OpenAI retires `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, and `gpt-4.1-mini` on 2027-01-14, after which calls to them fail. |
286
286
  | `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. |
287
287
  | `OPENAI_TIMEOUT` | No | Request timeout in seconds for model API calls. Defaults to `10`. |
288
288
  | `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`. |
@@ -327,7 +327,7 @@ OPENAI_API_KEY = "your_anthropic_api_key_here"
327
327
  OPENAI_MODEL = "..." # see Anthropic's current model list below
328
328
  ```
329
329
 
330
- `OPENAI_MODEL` is mandatory here: the default (`gpt-4o-mini`) doesn't exist on Anthropic's API
330
+ `OPENAI_MODEL` is mandatory here: the default (`gpt-6-luna`) doesn't exist on Anthropic's API
331
331
  and will 404. Use one of the model names from
332
332
  [Anthropic's model overview](https://platform.claude.com/docs/en/about-claude/models/overview);
333
333
  model names are versioned and retired over time, so check that page rather than relying on a
@@ -571,9 +571,8 @@ generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TO
571
571
  explicitly always overrides this scaling, at any value, including one lower than the
572
572
  unscaled 1,000 default. The multiplier itself is deliberately generous rather than
573
573
  precise: the underlying tokens-per-word figures are estimates, not direct
574
- measurements, and the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
575
- which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
576
- these estimates lean on.
574
+ measurements, and tokenizers differ across models and providers, so the
575
+ multiplier is not calibrated to any one of them.
577
576
 
578
577
  ## Codebase-aware explanations (RAG)
579
578
 
@@ -796,7 +795,7 @@ Most of the gap comes from two checks, measured on the 10 fixtures whose cause i
796
795
  - **Points to the right file and function:** RAG-on 26, RAG-off 13
797
796
  - **Avoids invented details:** RAG-on 26, RAG-off 17
798
797
 
799
- Without your source, gpt-4o-mini tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
798
+ Without your source, `gpt-4o-mini` (the generator in these runs, the default before `gpt-6-luna`) tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
800
799
 
801
800
  **Limitations:**
802
801
 
@@ -16,8 +16,8 @@ Explains Django exceptions on the debug page, pointing at the real fix in your o
16
16
  </p>
17
17
 
18
18
  *The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
19
- failing function (`clone_latest_post`) and gives the one-line fix. Real `gpt-4o-mini` output from
20
- the eval harness.*
19
+ failing function (`clone_latest_post`) and gives the one-line fix. Real output from the eval
20
+ harness, generated with `gpt-4o-mini` (the default before `gpt-6-luna`).*
21
21
 
22
22
  - **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
23
23
  - Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
@@ -144,7 +144,7 @@ python manage.py check --deploy --fail-level WARNING
144
144
  In the terminal, the explanation is printed with the model that produced it:
145
145
 
146
146
  ```
147
- Error explanation (gpt-4o-mini):
147
+ Error explanation (gpt-6-luna):
148
148
  The view raised a ValueError because ...
149
149
  ```
150
150
 
@@ -230,7 +230,7 @@ and keeps using that for later calls. Other providers keep receiving `max_tokens
230
230
  | ------------------ | -------- | ----------- |
231
231
  | `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`. |
232
232
  | `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
233
- | `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini`. |
233
+ | `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-6-luna` (was `gpt-4o-mini`). If you set `OPENAI_MODEL` yourself, the default change does not affect you, but OpenAI retires `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, and `gpt-4.1-mini` on 2027-01-14, after which calls to them fail. |
234
234
  | `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. |
235
235
  | `OPENAI_TIMEOUT` | No | Request timeout in seconds for model API calls. Defaults to `10`. |
236
236
  | `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`. |
@@ -275,7 +275,7 @@ OPENAI_API_KEY = "your_anthropic_api_key_here"
275
275
  OPENAI_MODEL = "..." # see Anthropic's current model list below
276
276
  ```
277
277
 
278
- `OPENAI_MODEL` is mandatory here: the default (`gpt-4o-mini`) doesn't exist on Anthropic's API
278
+ `OPENAI_MODEL` is mandatory here: the default (`gpt-6-luna`) doesn't exist on Anthropic's API
279
279
  and will 404. Use one of the model names from
280
280
  [Anthropic's model overview](https://platform.claude.com/docs/en/about-claude/models/overview);
281
281
  model names are versioned and retired over time, so check that page rather than relying on a
@@ -519,9 +519,8 @@ generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TO
519
519
  explicitly always overrides this scaling, at any value, including one lower than the
520
520
  unscaled 1,000 default. The multiplier itself is deliberately generous rather than
521
521
  precise: the underlying tokens-per-word figures are estimates, not direct
522
- measurements, and the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
523
- which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
524
- these estimates lean on.
522
+ measurements, and tokenizers differ across models and providers, so the
523
+ multiplier is not calibrated to any one of them.
525
524
 
526
525
  ## Codebase-aware explanations (RAG)
527
526
 
@@ -744,7 +743,7 @@ Most of the gap comes from two checks, measured on the 10 fixtures whose cause i
744
743
  - **Points to the right file and function:** RAG-on 26, RAG-off 13
745
744
  - **Avoids invented details:** RAG-on 26, RAG-off 17
746
745
 
747
- Without your source, gpt-4o-mini tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
746
+ Without your source, `gpt-4o-mini` (the generator in these runs, the default before `gpt-6-luna`) tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
748
747
 
749
748
  **Limitations:**
750
749
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-explain-errors
3
- Version: 0.11.1
3
+ Version: 0.12.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
@@ -68,8 +68,8 @@ Explains Django exceptions on the debug page, pointing at the real fix in your o
68
68
  </p>
69
69
 
70
70
  *The traceback held only Django and SQLite internals. With RAG enabled, the explanation names the
71
- failing function (`clone_latest_post`) and gives the one-line fix. Real `gpt-4o-mini` output from
72
- the eval harness.*
71
+ failing function (`clone_latest_post`) and gives the one-line fix. Real output from the eval
72
+ harness, generated with `gpt-4o-mini` (the default before `gpt-6-luna`).*
73
73
 
74
74
  - **Strongly recommended:** enable RAG over your project, so explanations name the actual file and function instead of guessing ([measured in the eval harness](#does-rag-actually-help)). It is off by default only because [setup](#codebase-aware-explanations-rag) takes three steps.
75
75
  - Works with OpenAI, Claude, or any OpenAI-compatible endpoint, including local models via Ollama
@@ -196,7 +196,7 @@ python manage.py check --deploy --fail-level WARNING
196
196
  In the terminal, the explanation is printed with the model that produced it:
197
197
 
198
198
  ```
199
- Error explanation (gpt-4o-mini):
199
+ Error explanation (gpt-6-luna):
200
200
  The view raised a ValueError because ...
201
201
  ```
202
202
 
@@ -282,7 +282,7 @@ and keeps using that for later calls. Other providers keep receiving `max_tokens
282
282
  | ------------------ | -------- | ----------- |
283
283
  | `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`. |
284
284
  | `DEBUG` | Yes | The middleware is only active when `DEBUG=True`. When `False`, requests pass through untouched. |
285
- | `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-4o-mini`. |
285
+ | `OPENAI_MODEL` | No | Model used for explanations. Defaults to `gpt-6-luna` (was `gpt-4o-mini`). If you set `OPENAI_MODEL` yourself, the default change does not affect you, but OpenAI retires `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, and `gpt-4.1-mini` on 2027-01-14, after which calls to them fail. |
286
286
  | `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. |
287
287
  | `OPENAI_TIMEOUT` | No | Request timeout in seconds for model API calls. Defaults to `10`. |
288
288
  | `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`. |
@@ -327,7 +327,7 @@ OPENAI_API_KEY = "your_anthropic_api_key_here"
327
327
  OPENAI_MODEL = "..." # see Anthropic's current model list below
328
328
  ```
329
329
 
330
- `OPENAI_MODEL` is mandatory here: the default (`gpt-4o-mini`) doesn't exist on Anthropic's API
330
+ `OPENAI_MODEL` is mandatory here: the default (`gpt-6-luna`) doesn't exist on Anthropic's API
331
331
  and will 404. Use one of the model names from
332
332
  [Anthropic's model overview](https://platform.claude.com/docs/en/about-claude/models/overview);
333
333
  model names are versioned and retired over time, so check that page rather than relying on a
@@ -571,9 +571,8 @@ generated, so the extra headroom costs nothing if unused. Setting `OPENAI_MAX_TO
571
571
  explicitly always overrides this scaling, at any value, including one lower than the
572
572
  unscaled 1,000 default. The multiplier itself is deliberately generous rather than
573
573
  precise: the underlying tokens-per-word figures are estimates, not direct
574
- measurements, and the default model (`gpt-4o-mini`) uses the `o200k_base` tokenizer,
575
- which handles non-Latin scripts considerably better than the `cl100k_base`-era ratios
576
- these estimates lean on.
574
+ measurements, and tokenizers differ across models and providers, so the
575
+ multiplier is not calibrated to any one of them.
577
576
 
578
577
  ## Codebase-aware explanations (RAG)
579
578
 
@@ -796,7 +795,7 @@ Most of the gap comes from two checks, measured on the 10 fixtures whose cause i
796
795
  - **Points to the right file and function:** RAG-on 26, RAG-off 13
797
796
  - **Avoids invented details:** RAG-on 26, RAG-off 17
798
797
 
799
- Without your source, gpt-4o-mini tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
798
+ Without your source, `gpt-4o-mini` (the generator in these runs, the default before `gpt-6-luna`) tends to invent a plausible function name or parameter rather than admit it doesn't know. With the real code in front of it, it mostly doesn't.
800
799
 
801
800
  **Limitations:**
802
801
 
@@ -97,7 +97,7 @@ class ExplainErrorsMiddleware:
97
97
  self.throttle = SlidingWindowThrottle(max_calls, window_seconds)
98
98
 
99
99
  # Configurable via settings, with sensible defaults.
100
- self.model = getattr(settings, "OPENAI_MODEL", "gpt-4o-mini")
100
+ self.model = getattr(settings, "OPENAI_MODEL", "gpt-6-luna")
101
101
  self.language = getattr(settings, "EXPLAIN_ERRORS_LANGUAGE", None)
102
102
 
103
103
  configured_max_tokens = getattr(settings, "OPENAI_MAX_TOKENS", None)
@@ -10,7 +10,7 @@ with open('README.md', encoding='utf-8') as f:
10
10
 
11
11
  setup(
12
12
  name='django-explain-errors',
13
- version='0.11.1',
13
+ version='0.12.0',
14
14
  packages=find_packages(exclude=['tests', 'tests.*', 'evals', 'evals.*']),
15
15
  description='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.',
16
16
  long_description_content_type='text/markdown',
@@ -165,7 +165,7 @@ class DebugPageRequestCycleTest(SimpleTestCase):
165
165
  content = response.content.decode()
166
166
  self.assertIn('id="explain-errors"', content)
167
167
  self.assertIn("Mocked explanation.", content)
168
- mock_print.assert_any_call("Error explanation (gpt-4o-mini):\n", "Mocked explanation.")
168
+ mock_print.assert_any_call("Error explanation (gpt-6-luna):\n", "Mocked explanation.")
169
169
 
170
170
  @override_settings(EXPLAIN_ERRORS_INJECT_DEBUG_PAGE=False)
171
171
  def test_inject_debug_page_off_no_banner(self):
@@ -17,8 +17,13 @@ from unittest.mock import patch
17
17
 
18
18
  from evals.fixtures import FIXTURES_BY_NAME
19
19
  from evals.run import (
20
+ _capture_call,
20
21
  estimate_cost_usd,
22
+ is_generation_failure,
21
23
  judge_all,
24
+ parse_generation_error,
25
+ run_fixture,
26
+ tally_generation,
22
27
  tally_judgments,
23
28
  tally_latency,
24
29
  tally_retries,
@@ -422,6 +427,258 @@ class JudgeAllConcurrencyTest(unittest.TestCase):
422
427
  self.assertLessEqual(active["peak"], 4)
423
428
 
424
429
 
430
+ def _skipped(group, sides):
431
+ return {
432
+ "group": group,
433
+ "judge_failure": False,
434
+ "judgment_skipped": True,
435
+ "generation_failed_sides": sides,
436
+ "rag_on_is_a": True,
437
+ "retries": 0,
438
+ }
439
+
440
+
441
+ class _FakeResponse:
442
+ def __init__(self, status_code, body):
443
+ self.status_code = status_code
444
+ self._body = body
445
+
446
+ def json(self):
447
+ if isinstance(self._body, ValueError):
448
+ raise self._body
449
+ return self._body
450
+
451
+
452
+ class GenerationFailureRecordingTest(unittest.TestCase):
453
+ """run_fixture flags calls with no usable explanation."""
454
+
455
+ def _run(self, response, generation_error=None):
456
+ fixture = FIXTURES_BY_NAME["none_attribute"]
457
+ captured = {
458
+ "exc_type": AttributeError,
459
+ "exception": None,
460
+ "traceback": "tb",
461
+ "prompt_tokens": None,
462
+ "completion_tokens": None,
463
+ "generation_error": generation_error,
464
+ }
465
+ with patch("evals.run._capture_call", return_value=(response, captured)):
466
+ call, _ = run_fixture(object(), fixture, False, 0)
467
+ return call
468
+
469
+ def test_null_explanation_is_a_failure(self):
470
+ call = self._run(
471
+ _FakeResponse(500, {"message": None}), generation_error="the model returned an empty response"
472
+ )
473
+ self.assertTrue(call["generation_failed"])
474
+ self.assertEqual(call["generation_error"], "the model returned an empty response")
475
+ self.assertIsNone(call["explanation"])
476
+
477
+ def test_whitespace_only_explanation_is_a_failure(self):
478
+ call = self._run(_FakeResponse(500, {"message": " \n\t "}))
479
+ self.assertTrue(call["generation_failed"])
480
+ self.assertIsNone(call["generation_error"])
481
+
482
+ def test_empty_string_and_non_json_bodies_are_failures(self):
483
+ self.assertTrue(self._run(_FakeResponse(500, {"message": ""}))["generation_failed"])
484
+ self.assertTrue(self._run(_FakeResponse(500, ValueError("bad")))["generation_failed"])
485
+
486
+ def test_real_explanation_is_not_a_failure(self):
487
+ call = self._run(_FakeResponse(500, {"message": "Because None has no attribute."}))
488
+ self.assertFalse(call["generation_failed"])
489
+ self.assertIsNone(call["generation_error"])
490
+
491
+
492
+ class GenerationErrorCaptureTest(unittest.TestCase):
493
+ def test_parses_reason_from_middleware_line(self):
494
+ text = "noise\nFailed to get an explanation from the model API: Error code: 404 - model not found\n"
495
+ self.assertEqual(
496
+ parse_generation_error(text), "Error code: 404 - model not found"
497
+ )
498
+
499
+ def test_no_failure_line_is_none(self):
500
+ self.assertIsNone(parse_generation_error("Error explanation (m):\n fine\n"))
501
+ self.assertIsNone(parse_generation_error(""))
502
+
503
+ def test_capture_call_records_reason_and_still_prints(self):
504
+ import contextlib
505
+ import io
506
+
507
+ outer = io.StringIO()
508
+
509
+ def fn():
510
+ print("Failed to get an explanation from the model API:", ValueError("boom"))
511
+ return "ok"
512
+
513
+ with contextlib.redirect_stdout(outer):
514
+ result, captured = _capture_call(fn)
515
+ self.assertEqual(result, "ok")
516
+ self.assertEqual(captured["generation_error"], "boom")
517
+ self.assertIn("boom", outer.getvalue())
518
+
519
+ def test_capture_call_without_failure_leaves_error_null(self):
520
+ _, captured = _capture_call(lambda: None)
521
+ self.assertIsNone(captured["generation_error"])
522
+
523
+
524
+ class IsGenerationFailureTest(unittest.TestCase):
525
+ def test_flag_wins_when_present(self):
526
+ self.assertTrue(is_generation_failure({"generation_failed": True, "explanation": "x"}))
527
+ self.assertFalse(is_generation_failure({"generation_failed": False, "explanation": None}))
528
+
529
+ def test_older_records_without_the_flag_fall_back_to_the_explanation(self):
530
+ self.assertTrue(is_generation_failure({"explanation": None}))
531
+ self.assertTrue(is_generation_failure({"explanation": " "}))
532
+ self.assertFalse(is_generation_failure({"explanation": "real"}))
533
+
534
+
535
+ class JudgeSkipTest(unittest.TestCase):
536
+ """judge_all never calls the judge for a pair with a failed side."""
537
+
538
+ def _judge(self, off_explanation, on_explanation, **extra):
539
+ fixture = FIXTURES_BY_NAME["none_attribute"]
540
+
541
+ def call(rag, explanation):
542
+ record = {
543
+ "fixture": fixture.name,
544
+ "rag_enabled": rag,
545
+ "run_index": 0,
546
+ "traceback": "tb",
547
+ "explanation": explanation,
548
+ }
549
+ record.update(extra.get("on" if rag else "off", {}))
550
+ return record
551
+
552
+ calls = [call(False, off_explanation), call(True, on_explanation)]
553
+ with patch("evals.judge.get_judge_client", return_value=object()), patch(
554
+ "evals.judge.get_judge_model", return_value="m"
555
+ ), patch("evals.judge.compare", return_value={"fixture": fixture.name}) as compare, patch(
556
+ "evals.run._build_judge_source", return_value=[]
557
+ ):
558
+ judgments = judge_all([fixture], calls, {}, seed=1)
559
+ return judgments, compare
560
+
561
+ def test_neither_fails_judges_as_before(self):
562
+ judgments, compare = self._judge("off text", "on text")
563
+ compare.assert_called_once()
564
+ self.assertEqual(len(judgments), 1)
565
+ self.assertNotIn("judgment_skipped", judgments[0])
566
+
567
+ def test_rag_on_failure_skips_the_judge(self):
568
+ judgments, compare = self._judge("off text", None)
569
+ compare.assert_not_called()
570
+ self.assertTrue(judgments[0]["judgment_skipped"])
571
+ self.assertEqual(judgments[0]["generation_failed_sides"], ["rag_on"])
572
+ self.assertFalse(judgments[0]["judge_failure"])
573
+
574
+ def test_rag_off_failure_skips_the_judge(self):
575
+ judgments, compare = self._judge("", "on text")
576
+ compare.assert_not_called()
577
+ self.assertEqual(judgments[0]["generation_failed_sides"], ["rag_off"])
578
+
579
+ def test_both_failing_skips_and_names_both_sides(self):
580
+ judgments, compare = self._judge(None, None)
581
+ compare.assert_not_called()
582
+ self.assertEqual(
583
+ sorted(judgments[0]["generation_failed_sides"]), ["rag_off", "rag_on"]
584
+ )
585
+
586
+ def test_whitespace_only_explanation_skips_the_judge(self):
587
+ judgments, compare = self._judge(" \n ", "on text")
588
+ compare.assert_not_called()
589
+ self.assertEqual(judgments[0]["generation_failed_sides"], ["rag_off"])
590
+
591
+ def test_recorded_flag_skips_even_with_text_present(self):
592
+ judgments, compare = self._judge("off", "on", on={"generation_failed": True})
593
+ compare.assert_not_called()
594
+ self.assertEqual(judgments[0]["generation_failed_sides"], ["rag_on"])
595
+
596
+ def test_skipped_record_keeps_identifying_fields(self):
597
+ judgments, _ = self._judge(None, "on text")
598
+ j = judgments[0]
599
+ self.assertEqual((j["fixture"], j["group"], j["run_index"]), ("none_attribute", FIXTURES_BY_NAME["none_attribute"].group, 0))
600
+
601
+
602
+ class SkippedPairTallyTest(unittest.TestCase):
603
+ def test_skipped_pairs_count_toward_no_group(self):
604
+ judgments = [
605
+ _judgment("A", "rag_on", rag_on_is_a=True),
606
+ _skipped("A", ["rag_on"]),
607
+ _skipped("B", ["rag_on", "rag_off"]),
608
+ ]
609
+ result = tally_judgments(judgments)
610
+ self.assertEqual(result["skipped_pairs"], 2)
611
+ self.assertEqual(result["judge_failures"], 0)
612
+ self.assertEqual(result["wins_by_group"]["A"], {"rag_on": 1, "rag_off": 0, "tie": 0})
613
+ self.assertEqual(result["wins_by_group"]["B"], {"rag_on": 0, "rag_off": 0, "tie": 0})
614
+ self.assertEqual(
615
+ sum(result["question_yes_counts_by_group"]["B"]["rag_on"].values()), 0
616
+ )
617
+
618
+ def test_no_skips_reports_zero(self):
619
+ self.assertEqual(tally_judgments([_judgment("A", "tie")])["skipped_pairs"], 0)
620
+
621
+ def test_tally_generation_counts_failures(self):
622
+ calls = [
623
+ {"explanation": "ok", "generation_failed": False},
624
+ {"explanation": None, "generation_failed": True},
625
+ {"explanation": " "}, # older record, no flag
626
+ ]
627
+ self.assertEqual(tally_generation(calls), {"failures": 2, "calls": 3})
628
+
629
+ def test_summary_prints_generation_lines_and_excludes_skips_from_incomplete(self):
630
+ import contextlib
631
+ import io
632
+
633
+ from evals.run import print_summary
634
+
635
+ calls = [
636
+ {"explanation": None, "generation_failed": True, "rag_enabled": False,
637
+ "latency_seconds": 1.0, "prompt_tokens": None, "completion_tokens": None},
638
+ {"explanation": None, "generation_failed": True, "rag_enabled": True,
639
+ "latency_seconds": 1.0, "prompt_tokens": None, "completion_tokens": None},
640
+ ]
641
+ # Skipped pairs stay out of the judge-failure ratio: 1 judged, 0 failed.
642
+ judgments = [_judgment("A", "tie"), _skipped("A", ["rag_on", "rag_off"])]
643
+ out = io.StringIO()
644
+ with contextlib.redirect_stdout(out):
645
+ print_summary([], calls, judgments, "g", "j")
646
+ text = out.getvalue()
647
+ self.assertIn("Generation failures: 2 of 2 calls", text)
648
+ self.assertIn("Skipped pairs (generation failure, not judged): 1", text)
649
+ self.assertNotIn("RUN INCOMPLETE", text)
650
+
651
+ def test_all_pairs_skipped_prints_run_incomplete_without_dividing_by_zero(self):
652
+ import contextlib
653
+ import io
654
+
655
+ from evals.run import print_summary
656
+
657
+ calls = [
658
+ {"explanation": None, "generation_failed": True, "rag_enabled": flag,
659
+ "latency_seconds": 1.0, "prompt_tokens": None, "completion_tokens": None}
660
+ for flag in (False, True)
661
+ ]
662
+ judgments = [_skipped("A", ["rag_on", "rag_off"]), _skipped("B", ["rag_on"])]
663
+ out = io.StringIO()
664
+ with contextlib.redirect_stdout(out):
665
+ print_summary([], calls, judgments, "g", "j")
666
+ text = out.getvalue()
667
+ self.assertIn("RUN INCOMPLETE: all 2 pair(s) were skipped", text)
668
+ self.assertNotIn("Wins by group", text)
669
+
670
+ def test_no_judgments_at_all_is_not_flagged_incomplete(self):
671
+ import contextlib
672
+ import io
673
+
674
+ from evals.run import print_summary
675
+
676
+ out = io.StringIO()
677
+ with contextlib.redirect_stdout(out):
678
+ print_summary([], [], [], "g", "j")
679
+ self.assertNotIn("RUN INCOMPLETE", out.getvalue())
680
+
681
+
425
682
  class TallyRetriesTest(unittest.TestCase):
426
683
 
427
684
  def test_sums_per_judgment_retries_and_tolerates_missing(self):
@@ -446,10 +703,10 @@ class FixtureSettingsModelTest(unittest.TestCase):
446
703
  self.assertEqual(self._model(OPENAI_MODEL="some-other-model"), "some-other-model")
447
704
 
448
705
  def test_unset_falls_back_to_default(self):
449
- self.assertEqual(self._model(), "gpt-4o-mini")
706
+ self.assertEqual(self._model(), "gpt-6-luna")
450
707
 
451
708
  def test_empty_string_falls_back_to_default(self):
452
- self.assertEqual(self._model(OPENAI_MODEL=""), "gpt-4o-mini")
709
+ self.assertEqual(self._model(OPENAI_MODEL=""), "gpt-6-luna")
453
710
 
454
711
 
455
712
  class EvalHarnessEndToEndTest(unittest.TestCase):
@@ -511,7 +768,7 @@ class EvalHarnessEndToEndTest(unittest.TestCase):
511
768
  self.assertGreaterEqual(data["timing"]["generator_seconds"], 0)
512
769
  self.assertGreaterEqual(data["timing"]["judge_seconds"], 0)
513
770
 
514
- self.assertEqual(data["generator_model"], "gpt-4o-mini")
771
+ self.assertEqual(data["generator_model"], "gpt-6-luna")
515
772
  self.assertEqual(data["judge_model"], "judge-test-model")
516
773
  self.assertEqual(len(data["calls"]), 30) # 15 fixtures x 2 RAG states x 1 run
517
774
  self.assertEqual(len(data["judgments"]), 15)
@@ -530,10 +787,14 @@ class EvalHarnessEndToEndTest(unittest.TestCase):
530
787
  "prompt_tokens",
531
788
  "completion_tokens",
532
789
  "status_code",
790
+ "generation_failed",
791
+ "generation_error",
533
792
  }
534
793
  for call in data["calls"]:
535
794
  self.assertTrue(call_keys.issubset(call.keys()), call)
536
795
  self.assertEqual(call["status_code"], 500)
796
+ self.assertFalse(call["generation_failed"])
797
+ self.assertIsNone(call["generation_error"])
537
798
  self.assertEqual(call["explanation"], "Mocked generator explanation.")
538
799
  self.assertEqual(call["prompt_tokens"], 10)
539
800
  self.assertEqual(call["completion_tokens"], 5)
@@ -268,7 +268,7 @@ class ExplainErrorsMiddlewarePreserveDebugPageTest(SimpleTestCase):
268
268
  with patch("builtins.print") as mock_print:
269
269
  with self.assertRaises(ValueError):
270
270
  mw(self.factory.get("/"))
271
- mock_print.assert_any_call("Error explanation (gpt-4o-mini):\n", "Mocked explanation.")
271
+ mock_print.assert_any_call("Error explanation (gpt-6-luna):\n", "Mocked explanation.")
272
272
 
273
273
  @override_settings(EXPLAIN_ERRORS_PRESERVE_DEBUG_PAGE=False)
274
274
  def test_preserve_flag_explicitly_off_returns_json_500(self):
@@ -321,7 +321,7 @@ class OpenAICallConfigTest(SimpleTestCase):
321
321
  mw = ExplainErrorsMiddleware(lambda r: None)
322
322
  mw.process_exception(self.factory.get("/"), Exception("x"))
323
323
  kwargs = self.client.chat.completions.create.call_args.kwargs
324
- self.assertEqual(kwargs["model"], "gpt-4o-mini")
324
+ self.assertEqual(kwargs["model"], "gpt-6-luna")
325
325
  self.assertEqual(kwargs["max_tokens"], DEFAULT_MAX_TOKENS)
326
326
 
327
327
  @override_settings(OPENAI_MODEL="gpt-5-mini", OPENAI_MAX_TOKENS=50)
@@ -33,7 +33,7 @@ class PrintStdoutRequestCycleTest(SimpleTestCase):
33
33
  response = client.get("/boom/")
34
34
 
35
35
  self.assertEqual(response.status_code, 500)
36
- mock_print.assert_any_call("Error explanation (gpt-4o-mini):\n", "Mocked explanation.")
36
+ mock_print.assert_any_call("Error explanation (gpt-6-luna):\n", "Mocked explanation.")
37
37
 
38
38
  @override_settings(OPENAI_MODEL="some-model")
39
39
  def test_configured_model_appears_in_stdout_prefix(self):
@@ -52,7 +52,7 @@ class PrintStdoutRequestCycleTest(SimpleTestCase):
52
52
 
53
53
  self.assertEqual(response.status_code, 500)
54
54
  for call in mock_print.call_args_list:
55
- self.assertNotEqual(call.args[0], "Error explanation (gpt-4o-mini):\n")
55
+ self.assertNotEqual(call.args[0], "Error explanation (gpt-6-luna):\n")
56
56
  self.assertIn('id="explain-errors"', response.content.decode())
57
57
 
58
58
  @override_settings(EXPLAIN_ERRORS_PRINT_STDOUT=False)
@@ -83,7 +83,7 @@ class PrintStdoutRequestCycleTest(SimpleTestCase):
83
83
 
84
84
  self.assertEqual(response.status_code, 500)
85
85
  for call in mock_print.call_args_list:
86
- self.assertNotEqual(call.args[0], "Error explanation (gpt-4o-mini):\n")
86
+ self.assertNotEqual(call.args[0], "Error explanation (gpt-6-luna):\n")
87
87
  self.assertIn('id="explain-errors"', response.content.decode())
88
88
 
89
89