runtime-narrative 1.5.2__tar.gz → 1.5.3__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 (85) hide show
  1. {runtime_narrative-1.5.2/runtime_narrative.egg-info → runtime_narrative-1.5.3}/PKG-INFO +2 -1
  2. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/README.md +1 -0
  3. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/pyproject.toml +1 -1
  4. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/__init__.py +1 -1
  5. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/analyzers/anthropic.py +23 -1
  6. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/analyzers/ollama.py +54 -2
  7. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3/runtime_narrative.egg-info}/PKG-INFO +2 -1
  8. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_analyzers.py +44 -0
  9. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_anthropic_analyzer.py +28 -0
  10. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/LICENSE +0 -0
  11. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/analyzers/__init__.py +0 -0
  12. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/analyzers/base.py +0 -0
  13. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/analyzers/deduplication.py +0 -0
  14. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/celery.py +0 -0
  15. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/cli.py +0 -0
  16. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/context.py +0 -0
  17. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/decorators.py +0 -0
  18. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/diagnostics.py +0 -0
  19. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/events.py +0 -0
  20. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/failure.py +0 -0
  21. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/grpc_interceptor.py +0 -0
  22. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/instrumentation.py +0 -0
  23. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/logging_bridge.py +0 -0
  24. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/middleware.py +0 -0
  25. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/middleware_django.py +0 -0
  26. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/outcome.py +0 -0
  27. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/__init__.py +0 -0
  28. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/alert_renderer.py +0 -0
  29. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/coalescing_renderer.py +0 -0
  30. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/console.py +0 -0
  31. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/filter_renderer.py +0 -0
  32. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/html_renderer.py +0 -0
  33. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/json_renderer.py +0 -0
  34. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/otel_log_renderer.py +0 -0
  35. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/otel_metrics_renderer.py +0 -0
  36. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/otel_renderer.py +0 -0
  37. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/persistence_renderer.py +0 -0
  38. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer/prometheus_renderer.py +0 -0
  39. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/renderer_defaults.py +0 -0
  40. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/stage.py +0 -0
  41. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/story.py +0 -0
  42. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/task_group.py +0 -0
  43. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative/testing.py +0 -0
  44. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative.egg-info/SOURCES.txt +0 -0
  45. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative.egg-info/dependency_links.txt +0 -0
  46. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative.egg-info/entry_points.txt +0 -0
  47. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative.egg-info/requires.txt +0 -0
  48. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/runtime_narrative.egg-info/top_level.txt +0 -0
  49. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/setup.cfg +0 -0
  50. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_alert_renderer.py +0 -0
  51. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_async_renderer.py +0 -0
  52. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_celery.py +0 -0
  53. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_coalescing_renderer.py +0 -0
  54. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_console_renderer.py +0 -0
  55. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_decorators.py +0 -0
  56. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_deduplication.py +0 -0
  57. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_diagnostics.py +0 -0
  58. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_dry_run.py +0 -0
  59. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_failure.py +0 -0
  60. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_filter_renderer.py +0 -0
  61. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_grpc_interceptor.py +0 -0
  62. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_html_renderer.py +0 -0
  63. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_instrumentation.py +0 -0
  64. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_instrumentation_phase2.py +0 -0
  65. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_issues.py +0 -0
  66. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_json_renderer.py +0 -0
  67. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_logging_bridge.py +0 -0
  68. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_middleware.py +0 -0
  69. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_middleware_django.py +0 -0
  70. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_middleware_propagation.py +0 -0
  71. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_module_capture.py +0 -0
  72. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_otel_log_renderer.py +0 -0
  73. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_otel_metrics_renderer.py +0 -0
  74. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_otel_renderer.py +0 -0
  75. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_outcome.py +0 -0
  76. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_persistence_renderer.py +0 -0
  77. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_prometheus_renderer.py +0 -0
  78. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_redaction_extended.py +0 -0
  79. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_renderer_defaults.py +0 -0
  80. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_stage.py +0 -0
  81. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_stage_metadata.py +0 -0
  82. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_story.py +0 -0
  83. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_structured_analysis.py +0 -0
  84. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_task_group.py +0 -0
  85. {runtime_narrative-1.5.2 → runtime_narrative-1.5.3}/tests/test_testing_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-narrative
3
- Version: 1.5.2
3
+ Version: 1.5.3
4
4
  Summary: Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis
5
5
  Author-email: Shashank Raj <shashank.raj28@gmail.com>
6
6
  License-Expression: MIT
@@ -377,6 +377,7 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
377
377
  | `ANTHROPIC_API_KEY` | API key | — | Required by `AnthropicFailureAnalyzer`; read automatically if `api_key=` is not passed |
378
378
  | `RUNTIME_NARRATIVE_RICH_LOG_FILE` | file path | — | Adds a file-backed `ConsoleRenderer` writing rich, human-readable output to this path, on top of whichever renderer TTY detection selects. Read by every auto-instrumentation entry point (FastAPI/Starlette, Django, Celery, gRPC) when `renderers=` is omitted |
379
379
  | `RUNTIME_NARRATIVE_RICH_LOG_CONSOLE` | `1`, `0` | `1` | With `RUNTIME_NARRATIVE_RICH_LOG_FILE` set and stdout a TTY, `0` suppresses the terminal copy so the narrative goes to the file only |
380
+ | `RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS` | seconds (float) | `12` (`OllamaFailureAnalyzer`/`LLMFailureAnalyzer`), `30` (`AnthropicFailureAnalyzer`) | Request timeout for the built-in failure analyzers. Raise it for slower or cold-loading local models (e.g. Ollama loading a multi-GB model from disk); on timeout or any other request failure the analyzer logs a `logging.warning` (module `runtime_narrative.analyzers.*`) and falls back to no LLM analysis rather than failing the story |
380
381
 
381
382
  ---
382
383
 
@@ -318,6 +318,7 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
318
318
  | `ANTHROPIC_API_KEY` | API key | — | Required by `AnthropicFailureAnalyzer`; read automatically if `api_key=` is not passed |
319
319
  | `RUNTIME_NARRATIVE_RICH_LOG_FILE` | file path | — | Adds a file-backed `ConsoleRenderer` writing rich, human-readable output to this path, on top of whichever renderer TTY detection selects. Read by every auto-instrumentation entry point (FastAPI/Starlette, Django, Celery, gRPC) when `renderers=` is omitted |
320
320
  | `RUNTIME_NARRATIVE_RICH_LOG_CONSOLE` | `1`, `0` | `1` | With `RUNTIME_NARRATIVE_RICH_LOG_FILE` set and stdout a TTY, `0` suppresses the terminal copy so the narrative goes to the file only |
321
+ | `RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS` | seconds (float) | `12` (`OllamaFailureAnalyzer`/`LLMFailureAnalyzer`), `30` (`AnthropicFailureAnalyzer`) | Request timeout for the built-in failure analyzers. Raise it for slower or cold-loading local models (e.g. Ollama loading a multi-GB model from disk); on timeout or any other request failure the analyzer logs a `logging.warning` (module `runtime_narrative.analyzers.*`) and falls back to no LLM analysis rather than failing the story |
321
322
 
322
323
  ---
323
324
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "runtime-narrative"
7
- version = "1.5.2"
7
+ version = "1.5.3"
8
8
  description = "Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,4 +1,4 @@
1
- __version__ = "1.5.2"
1
+ __version__ = "1.5.3"
2
2
 
3
3
  from .analyzers import FailureAnalyzer, LLMFailureAnalyzer, OllamaFailureAnalyzer, DeduplicatingAnalyzer
4
4
  from .context import has_active_story
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
  import os
5
6
  import re
6
7
  from dataclasses import dataclass, field
@@ -14,9 +15,12 @@ except ImportError:
14
15
  _ANTHROPIC_AVAILABLE = False
15
16
 
16
17
  from ..failure import FailureSummary
18
+ from .ollama import _default_analyzer_timeout_seconds
17
19
 
18
20
  __all__ = ["AnthropicFailureAnalyzer"]
19
21
 
22
+ logger = logging.getLogger(__name__)
23
+
20
24
  _DEFAULT_MODEL = "claude-haiku-4-5-20251001"
21
25
 
22
26
 
@@ -24,7 +28,7 @@ _DEFAULT_MODEL = "claude-haiku-4-5-20251001"
24
28
  class AnthropicFailureAnalyzer:
25
29
  api_key: str = field(default_factory=lambda: os.environ.get("ANTHROPIC_API_KEY", ""))
26
30
  model: str = field(default_factory=lambda: os.environ.get("RUNTIME_NARRATIVE_MODEL", _DEFAULT_MODEL))
27
- timeout_seconds: float = 30.0
31
+ timeout_seconds: float = field(default_factory=lambda: _default_analyzer_timeout_seconds(fallback=30.0))
28
32
  max_tokens: int = 1024
29
33
  system_prompt: str = "You are an expert Python debugging assistant. Be concise and specific."
30
34
 
@@ -120,6 +124,15 @@ class AnthropicFailureAnalyzer:
120
124
  )
121
125
  text = response.content[0].text.strip()
122
126
  except Exception:
127
+ logger.warning(
128
+ "AnthropicFailureAnalyzer: request to model %s timed out or "
129
+ "failed (timeout_seconds=%s) -- no LLM analysis for this "
130
+ "failure. Raise RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS "
131
+ "if the API just needs more time.",
132
+ self.model,
133
+ self.timeout_seconds,
134
+ exc_info=True,
135
+ )
123
136
  return None
124
137
  return self._parse_response(text) or None
125
138
 
@@ -150,5 +163,14 @@ class AnthropicFailureAnalyzer:
150
163
  )
151
164
  text = response.content[0].text.strip()
152
165
  except Exception:
166
+ logger.warning(
167
+ "AnthropicFailureAnalyzer: async request to model %s timed "
168
+ "out or failed (timeout_seconds=%s) -- no LLM analysis for "
169
+ "this failure. Raise RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS "
170
+ "if the API just needs more time.",
171
+ self.model,
172
+ self.timeout_seconds,
173
+ exc_info=True,
174
+ )
153
175
  return None
154
176
  return self._parse_response(text) or None
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import logging
6
+ import os
5
7
  import re
6
8
  from dataclasses import dataclass, field
7
9
  from functools import partial
@@ -11,6 +13,26 @@ from urllib.request import Request, urlopen
11
13
 
12
14
  from ..failure import FailureSummary
13
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _default_analyzer_timeout_seconds(fallback: float = 12.0) -> float:
20
+ """Default request timeout for LLM failure analyzers.
21
+
22
+ Reads `RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS` so deployments with
23
+ slower/cold-loading local models (e.g. Ollama loading a multi-GB model
24
+ from disk) can raise it without subclassing or passing timeout_seconds=
25
+ at every call site. Falls back to `fallback` (each analyzer's own
26
+ previous hardcoded default) when the env var is unset or invalid.
27
+ """
28
+ raw = os.getenv("RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS")
29
+ if not raw:
30
+ return fallback
31
+ try:
32
+ return float(raw)
33
+ except ValueError:
34
+ return fallback
35
+
14
36
 
15
37
  _SECTION_LABELS = {
16
38
  "exact_why": "Exact Why",
@@ -114,7 +136,7 @@ class LLMFailureAnalyzer:
114
136
 
115
137
  model: str
116
138
  endpoint: str
117
- timeout_seconds: float = 12.0
139
+ timeout_seconds: float = field(default_factory=_default_analyzer_timeout_seconds)
118
140
  include_traceback_lines: int = 30
119
141
  max_context_chars: int = 8000
120
142
 
@@ -152,12 +174,27 @@ class LLMFailureAnalyzer:
152
174
  with urlopen(request, timeout=self.timeout_seconds) as response:
153
175
  body = response.read().decode("utf-8")
154
176
  except (URLError, TimeoutError, Exception):
177
+ logger.warning(
178
+ "LLMFailureAnalyzer: request to %s timed out or failed "
179
+ "(timeout_seconds=%s) -- no LLM analysis for this failure. "
180
+ "Raise RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS if the "
181
+ "endpoint just needs more time (e.g. a cold model load).",
182
+ self.endpoint,
183
+ self.timeout_seconds,
184
+ exc_info=True,
185
+ )
155
186
  return None
156
187
 
157
188
  try:
158
189
  parsed = json.loads(body)
159
190
  text = parsed["choices"][0]["message"]["content"].strip()
160
191
  except Exception:
192
+ logger.warning(
193
+ "LLMFailureAnalyzer: response from %s was not the expected "
194
+ "chat-completions JSON shape -- no LLM analysis for this failure.",
195
+ self.endpoint,
196
+ exc_info=True,
197
+ )
161
198
  return None
162
199
 
163
200
  return _parse_structured_response(text) or None
@@ -200,7 +237,7 @@ class OllamaFailureAnalyzer:
200
237
 
201
238
  model: str
202
239
  endpoint: str = "http://127.0.0.1:11434/api/generate"
203
- timeout_seconds: float = 12.0
240
+ timeout_seconds: float = field(default_factory=_default_analyzer_timeout_seconds)
204
241
  include_traceback_lines: int = 30
205
242
  max_context_chars: int = 8000
206
243
 
@@ -238,12 +275,27 @@ class OllamaFailureAnalyzer:
238
275
  with urlopen(request, timeout=self.timeout_seconds) as response:
239
276
  body = response.read().decode("utf-8")
240
277
  except (URLError, TimeoutError, Exception):
278
+ logger.warning(
279
+ "OllamaFailureAnalyzer: request to %s timed out or failed "
280
+ "(timeout_seconds=%s) -- no LLM analysis for this failure. "
281
+ "Raise RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS if the "
282
+ "endpoint just needs more time (e.g. a cold model load).",
283
+ self.endpoint,
284
+ self.timeout_seconds,
285
+ exc_info=True,
286
+ )
241
287
  return None
242
288
 
243
289
  try:
244
290
  parsed = json.loads(body)
245
291
  text = parsed.get("response", "").strip()
246
292
  except Exception:
293
+ logger.warning(
294
+ "OllamaFailureAnalyzer: response from %s was not the expected "
295
+ "/api/generate JSON shape -- no LLM analysis for this failure.",
296
+ self.endpoint,
297
+ exc_info=True,
298
+ )
247
299
  return None
248
300
 
249
301
  return _parse_structured_response(text) or None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-narrative
3
- Version: 1.5.2
3
+ Version: 1.5.3
4
4
  Summary: Model execution as human-readable stories with lean/rich failure diagnostics and optional LLM analysis
5
5
  Author-email: Shashank Raj <shashank.raj28@gmail.com>
6
6
  License-Expression: MIT
@@ -377,6 +377,7 @@ Every script under `examples/` is runnable as-is: `uv run python examples/<name>
377
377
  | `ANTHROPIC_API_KEY` | API key | — | Required by `AnthropicFailureAnalyzer`; read automatically if `api_key=` is not passed |
378
378
  | `RUNTIME_NARRATIVE_RICH_LOG_FILE` | file path | — | Adds a file-backed `ConsoleRenderer` writing rich, human-readable output to this path, on top of whichever renderer TTY detection selects. Read by every auto-instrumentation entry point (FastAPI/Starlette, Django, Celery, gRPC) when `renderers=` is omitted |
379
379
  | `RUNTIME_NARRATIVE_RICH_LOG_CONSOLE` | `1`, `0` | `1` | With `RUNTIME_NARRATIVE_RICH_LOG_FILE` set and stdout a TTY, `0` suppresses the terminal copy so the narrative goes to the file only |
380
+ | `RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS` | seconds (float) | `12` (`OllamaFailureAnalyzer`/`LLMFailureAnalyzer`), `30` (`AnthropicFailureAnalyzer`) | Request timeout for the built-in failure analyzers. Raise it for slower or cold-loading local models (e.g. Ollama loading a multi-GB model from disk); on timeout or any other request failure the analyzer logs a `logging.warning` (module `runtime_narrative.analyzers.*`) and falls back to no LLM analysis rather than failing the story |
380
381
 
381
382
  ---
382
383
 
@@ -131,3 +131,47 @@ def test_ollama_analyzer_returns_none_on_url_error():
131
131
  with patch("runtime_narrative.analyzers.ollama.urlopen", side_effect=URLError("no route")):
132
132
  result = analyzer.analyze_failure(**_KWARGS)
133
133
  assert result is None
134
+
135
+
136
+ # ── Configurable timeout + failure visibility ─────────────────────────────────
137
+
138
+ def test_default_timeout_seconds_falls_back_when_env_unset(monkeypatch):
139
+ monkeypatch.delenv("RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS", raising=False)
140
+ assert OllamaFailureAnalyzer(model="llama3").timeout_seconds == 12.0
141
+ assert LLMFailureAnalyzer(model="gpt-4", endpoint="http://x/v1/chat/completions").timeout_seconds == 12.0
142
+
143
+
144
+ def test_default_timeout_seconds_reads_env(monkeypatch):
145
+ monkeypatch.setenv("RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS", "45")
146
+ assert OllamaFailureAnalyzer(model="llama3").timeout_seconds == 45.0
147
+
148
+
149
+ def test_default_timeout_seconds_falls_back_on_invalid_env(monkeypatch):
150
+ monkeypatch.setenv("RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS", "not-a-number")
151
+ assert OllamaFailureAnalyzer(model="llama3").timeout_seconds == 12.0
152
+
153
+
154
+ def test_explicit_timeout_seconds_overrides_env(monkeypatch):
155
+ monkeypatch.setenv("RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS", "45")
156
+ assert OllamaFailureAnalyzer(model="llama3", timeout_seconds=5.0).timeout_seconds == 5.0
157
+
158
+
159
+ def test_ollama_analyzer_logs_warning_on_request_failure(caplog):
160
+ from urllib.error import URLError
161
+ analyzer = OllamaFailureAnalyzer(model="llama3", timeout_seconds=12.0)
162
+ with caplog.at_level("WARNING", logger="runtime_narrative.analyzers.ollama"):
163
+ with patch("runtime_narrative.analyzers.ollama.urlopen", side_effect=URLError("no route")):
164
+ result = analyzer.analyze_failure(**_KWARGS)
165
+ assert result is None
166
+ assert any("no LLM analysis" in record.message for record in caplog.records)
167
+ assert any("12.0" in record.message for record in caplog.records)
168
+
169
+
170
+ def test_llm_analyzer_logs_warning_on_malformed_response(caplog):
171
+ body = b"not json at all"
172
+ analyzer = LLMFailureAnalyzer(model="gpt-4", endpoint="http://localhost/v1/chat/completions")
173
+ with caplog.at_level("WARNING", logger="runtime_narrative.analyzers.ollama"):
174
+ with patch("runtime_narrative.analyzers.ollama.urlopen", return_value=_mock_http_response(body)):
175
+ result = analyzer.analyze_failure(**_KWARGS)
176
+ assert result is None
177
+ assert any("no LLM analysis" in record.message for record in caplog.records)
@@ -132,3 +132,31 @@ def test_parse_response_valid_json() -> None:
132
132
  def test_parse_response_fallback() -> None:
133
133
  result = _make_analyzer()._parse_response("plain text")
134
134
  assert result == "plain text"
135
+
136
+
137
+ def test_default_timeout_falls_back_to_thirty_seconds(monkeypatch: pytest.MonkeyPatch) -> None:
138
+ monkeypatch.delenv("RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS", raising=False)
139
+ assert _make_analyzer().timeout_seconds == 30.0
140
+
141
+
142
+ def test_default_timeout_reads_env(monkeypatch: pytest.MonkeyPatch) -> None:
143
+ monkeypatch.setenv("RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS", "45")
144
+ assert _make_analyzer().timeout_seconds == 45.0
145
+
146
+
147
+ def test_explicit_timeout_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None:
148
+ monkeypatch.setenv("RUNTIME_NARRATIVE_ANALYZER_TIMEOUT_SECONDS", "45")
149
+ assert _make_analyzer(timeout_seconds=5.0).timeout_seconds == 5.0
150
+
151
+
152
+ def test_analyze_failure_logs_warning_on_exception(caplog: pytest.LogCaptureFixture) -> None:
153
+ mock_client = mock.MagicMock()
154
+ mock_client.messages.create.side_effect = Exception("network error")
155
+
156
+ with mock.patch.object(_anthropic_mod, "_anthropic") as patched:
157
+ patched.Anthropic.return_value = mock_client
158
+ with caplog.at_level("WARNING", logger="runtime_narrative.analyzers.anthropic"):
159
+ result = _make_analyzer().analyze_failure(**_CALL_KWARGS)
160
+
161
+ assert result is None
162
+ assert any("no LLM analysis" in record.message for record in caplog.records)