runtime-narrative 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,23 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .analyzers import LLMFailureAnalyzer, OllamaFailureAnalyzer
4
+ from .decorators import runtime_narrative_stage, runtime_narrative_story
5
+ from .renderer.json_renderer import JsonRenderer
6
+ from .stage import stage
7
+ from .story import story
8
+
9
+ try:
10
+ from .middleware import RuntimeNarrativeMiddleware
11
+ except ImportError:
12
+ pass
13
+
14
+ __all__ = [
15
+ "story",
16
+ "stage",
17
+ "runtime_narrative_story",
18
+ "runtime_narrative_stage",
19
+ "LLMFailureAnalyzer",
20
+ "OllamaFailureAnalyzer",
21
+ "RuntimeNarrativeMiddleware",
22
+ "JsonRenderer",
23
+ ]
@@ -0,0 +1,3 @@
1
+ from .ollama import LLMFailureAnalyzer, OllamaFailureAnalyzer
2
+
3
+ __all__ = ["LLMFailureAnalyzer", "OllamaFailureAnalyzer"]
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+ from urllib.error import URLError
7
+ from urllib.request import Request, urlopen
8
+
9
+ from ..failure import FailureSummary
10
+
11
+
12
+ def _build_prompt(
13
+ *,
14
+ story_name: str,
15
+ stage_name: str,
16
+ failure: FailureSummary,
17
+ stage_timeline: str,
18
+ progress_percent: int,
19
+ include_traceback_lines: int,
20
+ ) -> str:
21
+ traceback_lines = failure.traceback_text.strip().splitlines()
22
+ traceback_excerpt = "\n".join(traceback_lines[-include_traceback_lines:])
23
+ return (
24
+ "You are debugging a Python runtime stage failure.\n"
25
+ "Return concise markdown with exactly four sections:\n"
26
+ "1) Exact Why\n"
27
+ "2) Evidence\n"
28
+ "3) Targeted Fix\n\n"
29
+ "4) Code Changes\n\n"
30
+ "Constraints:\n"
31
+ "- Do not be generic.\n"
32
+ "- Point to the exact failing statement and mechanism.\n"
33
+ "- Mention assumptions only if uncertain.\n\n"
34
+ "Code Changes format:\n"
35
+ "- Provide minimal edit-ready snippets.\n"
36
+ "- Include file path, old line, and new line when possible.\n"
37
+ "- Prefer small targeted diffs over full-file rewrites.\n\n"
38
+ f"Story: {story_name}\n"
39
+ f"Stage: {stage_name}\n"
40
+ f"Error Type: {failure.error_type}\n"
41
+ f"Error Message: {failure.error_message}\n"
42
+ f"Location: {failure.filename}:{failure.lineno} ({failure.function})\n"
43
+ f"Failing Code: {failure.source_line}\n"
44
+ f"Exception Chain: {failure.exception_chain}\n"
45
+ f"Progress: {progress_percent}%\n"
46
+ f"Recent Stages: {stage_timeline}\n\n"
47
+ "Traceback Excerpt:\n"
48
+ f"{traceback_excerpt}\n"
49
+ )
50
+
51
+
52
+ @dataclass
53
+ class LLMFailureAnalyzer:
54
+ """
55
+ Failure analyzer for OpenAI-compatible endpoints.
56
+
57
+ Works with any backend that serves the /v1/chat/completions API,
58
+ including vLLM, llama.cpp (--server), LM Studio, Ollama (OpenAI mode), etc.
59
+
60
+ Example::
61
+
62
+ LLMFailureAnalyzer(
63
+ model="llama3",
64
+ endpoint="http://localhost:8000/v1/chat/completions",
65
+ )
66
+ """
67
+
68
+ model: str
69
+ endpoint: str
70
+ timeout_seconds: float = 12.0
71
+ include_traceback_lines: int = 30
72
+
73
+ def analyze_failure(
74
+ self,
75
+ *,
76
+ story_name: str,
77
+ stage_name: str,
78
+ failure: FailureSummary,
79
+ stage_timeline: str,
80
+ progress_percent: int,
81
+ ) -> Optional[str]:
82
+ prompt = _build_prompt(
83
+ story_name=story_name,
84
+ stage_name=stage_name,
85
+ failure=failure,
86
+ stage_timeline=stage_timeline,
87
+ progress_percent=progress_percent,
88
+ include_traceback_lines=self.include_traceback_lines,
89
+ )
90
+ payload = {
91
+ "model": self.model,
92
+ "messages": [{"role": "user", "content": prompt}],
93
+ "temperature": 0,
94
+ "stream": False,
95
+ }
96
+ request = Request(
97
+ self.endpoint,
98
+ data=json.dumps(payload).encode("utf-8"),
99
+ headers={"Content-Type": "application/json"},
100
+ method="POST",
101
+ )
102
+ try:
103
+ with urlopen(request, timeout=self.timeout_seconds) as response:
104
+ body = response.read().decode("utf-8")
105
+ except (URLError, TimeoutError, Exception):
106
+ return None
107
+
108
+ try:
109
+ parsed = json.loads(body)
110
+ text = parsed["choices"][0]["message"]["content"].strip()
111
+ except Exception:
112
+ return None
113
+
114
+ return text or None
115
+
116
+
117
+ @dataclass
118
+ class OllamaFailureAnalyzer:
119
+ """
120
+ Failure analyzer using Ollama's native /api/generate endpoint.
121
+
122
+ For OpenAI-compatible mode (Ollama >= 0.1.24), prefer LLMFailureAnalyzer
123
+ with endpoint="http://localhost:11434/v1/chat/completions".
124
+
125
+ Example::
126
+
127
+ OllamaFailureAnalyzer(
128
+ model="llama3",
129
+ endpoint="http://localhost:11434/api/generate",
130
+ )
131
+ """
132
+
133
+ model: str
134
+ endpoint: str = "http://127.0.0.1:11434/api/generate"
135
+ timeout_seconds: float = 12.0
136
+ include_traceback_lines: int = 30
137
+
138
+ def analyze_failure(
139
+ self,
140
+ *,
141
+ story_name: str,
142
+ stage_name: str,
143
+ failure: FailureSummary,
144
+ stage_timeline: str,
145
+ progress_percent: int,
146
+ ) -> Optional[str]:
147
+ prompt = _build_prompt(
148
+ story_name=story_name,
149
+ stage_name=stage_name,
150
+ failure=failure,
151
+ stage_timeline=stage_timeline,
152
+ progress_percent=progress_percent,
153
+ include_traceback_lines=self.include_traceback_lines,
154
+ )
155
+ payload = {
156
+ "model": self.model,
157
+ "prompt": prompt,
158
+ "stream": False,
159
+ "options": {"temperature": 0},
160
+ }
161
+ request = Request(
162
+ self.endpoint,
163
+ data=json.dumps(payload).encode("utf-8"),
164
+ headers={"Content-Type": "application/json"},
165
+ method="POST",
166
+ )
167
+ try:
168
+ with urlopen(request, timeout=self.timeout_seconds) as response:
169
+ body = response.read().decode("utf-8")
170
+ except (URLError, TimeoutError, Exception):
171
+ return None
172
+
173
+ try:
174
+ parsed = json.loads(body)
175
+ text = parsed.get("response", "").strip()
176
+ except Exception:
177
+ return None
178
+
179
+ return text or None
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from contextvars import ContextVar
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from .stage import StageRecord
8
+ from .story import StoryRuntime
9
+
10
+ current_story: ContextVar[StoryRuntime | None] = ContextVar("current_story", default=None)
11
+ current_stage_stack: ContextVar[list[StageRecord]] = ContextVar("current_stage_stack", default=[])
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from functools import wraps
5
+ from typing import Any, Callable, Sequence, TypeVar
6
+
7
+ from .stage import stage
8
+ from .story import story
9
+
10
+ F = TypeVar("F", bound=Callable[..., Any])
11
+
12
+
13
+ def _default_name(func: Callable[..., Any]) -> str:
14
+ return func.__name__.replace("_", " ").strip().title()
15
+
16
+
17
+ def runtime_narrative_story(
18
+ name: str | None = None,
19
+ *,
20
+ renderers: Sequence[object] | None = None,
21
+ failure_analyzer: Any | None = None,
22
+ ) -> Callable[[F], F]:
23
+ """Decorator to wrap a function in a story context (sync or async)."""
24
+
25
+ def decorator(func: F) -> F:
26
+ story_name = name or _default_name(func)
27
+
28
+ if inspect.iscoroutinefunction(func):
29
+
30
+ @wraps(func)
31
+ async def async_wrapper(*args: Any, **kwargs: Any):
32
+ with story(story_name, renderers=renderers, failure_analyzer=failure_analyzer):
33
+ return await func(*args, **kwargs)
34
+
35
+ return async_wrapper # type: ignore[return-value]
36
+
37
+ @wraps(func)
38
+ def sync_wrapper(*args: Any, **kwargs: Any):
39
+ with story(story_name, renderers=renderers, failure_analyzer=failure_analyzer):
40
+ return func(*args, **kwargs)
41
+
42
+ return sync_wrapper # type: ignore[return-value]
43
+
44
+ return decorator
45
+
46
+
47
+ def runtime_narrative_stage(name: str | None = None) -> Callable[[F], F]:
48
+ """Decorator to wrap a function in a stage context (sync or async)."""
49
+
50
+ def decorator(func: F) -> F:
51
+ stage_name = name or _default_name(func)
52
+
53
+ if inspect.iscoroutinefunction(func):
54
+
55
+ @wraps(func)
56
+ async def async_wrapper(*args: Any, **kwargs: Any):
57
+ with stage(stage_name):
58
+ return await func(*args, **kwargs)
59
+
60
+ return async_wrapper # type: ignore[return-value]
61
+
62
+ @wraps(func)
63
+ def sync_wrapper(*args: Any, **kwargs: Any):
64
+ with stage(stage_name):
65
+ return func(*args, **kwargs)
66
+
67
+ return sync_wrapper # type: ignore[return-value]
68
+
69
+ return decorator
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class StoryStarted:
10
+ story_id: str
11
+ story_name: str
12
+ timestamp: datetime
13
+
14
+
15
+ @dataclass
16
+ class StageStarted:
17
+ story_id: str
18
+ stage_name: str
19
+ timestamp: datetime
20
+
21
+
22
+ @dataclass
23
+ class StageCompleted:
24
+ story_id: str
25
+ stage_name: str
26
+ timestamp: datetime
27
+ duration_seconds: float
28
+
29
+
30
+ @dataclass
31
+ class FailureOccurred:
32
+ story_id: str
33
+ story_name: str
34
+ stage_name: str
35
+ error_type: str
36
+ error_message: str
37
+ filename: str
38
+ lineno: int
39
+ function: str
40
+ source_line: str
41
+ exception_chain: str
42
+ exact_cause: str
43
+ llm_analysis: str | None
44
+ stage_timeline: str
45
+ progress_percent: int
46
+ completed_stages: int
47
+ total_stages: int
48
+ timestamp: datetime
49
+ traceback_text: str
50
+
51
+
52
+ @dataclass
53
+ class StoryCompleted:
54
+ story_id: str
55
+ story_name: str
56
+ success: bool
57
+ progress_percent: int
58
+ completed_stages: int
59
+ total_stages: int
60
+ timestamp: datetime
61
+
62
+
63
+ from typing import Union
64
+
65
+ Event = Union[StoryStarted, StageStarted, StageCompleted, FailureOccurred, StoryCompleted]
66
+ Renderer = Any
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import traceback
4
+ from dataclasses import dataclass
5
+ from types import TracebackType
6
+
7
+
8
+ @dataclass
9
+ class FailureSummary:
10
+ error_type: str
11
+ error_message: str
12
+ filename: str
13
+ lineno: int
14
+ function: str
15
+ source_line: str
16
+ exception_chain: str
17
+ exact_cause: str
18
+ traceback_text: str
19
+
20
+
21
+ def _build_exception_chain(exc: BaseException) -> str:
22
+ parts: list[str] = []
23
+ current: BaseException | None = exc
24
+ depth = 0
25
+ while current is not None and depth < 5:
26
+ parts.append(f"{type(current).__name__}: {current}")
27
+ current = current.__cause__ or current.__context__
28
+ depth += 1
29
+ return " <- ".join(parts)
30
+
31
+
32
+ def _infer_exact_cause(error_type: str, message: str, source_line: str) -> str:
33
+ line = source_line.strip()
34
+ if line.startswith("raise "):
35
+ return f"The code explicitly raises {error_type} here with message: {message}"
36
+ return f"The statement `{line}` raised {error_type}: {message}"
37
+
38
+
39
+ def summarize_exception(exc_type: type[BaseException], exc: BaseException, tb: TracebackType | None) -> FailureSummary:
40
+ extracted = traceback.extract_tb(tb) if tb else []
41
+ if extracted:
42
+ frame = extracted[-1]
43
+ filename = frame.filename
44
+ lineno = frame.lineno
45
+ function = frame.name
46
+ source_line = frame.line or "<source unavailable>"
47
+ else:
48
+ filename = "<unknown>"
49
+ lineno = 0
50
+ function = "<unknown>"
51
+ source_line = "<source unavailable>"
52
+
53
+ return FailureSummary(
54
+ error_type=exc_type.__name__,
55
+ error_message=str(exc),
56
+ filename=filename,
57
+ lineno=lineno,
58
+ function=function,
59
+ source_line=source_line,
60
+ exception_chain=_build_exception_chain(exc),
61
+ exact_cause=_infer_exact_cause(exc_type.__name__, str(exc), source_line),
62
+ traceback_text="".join(traceback.format_exception(exc_type, exc, tb)),
63
+ )
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ from starlette.middleware.base import BaseHTTPMiddleware
6
+ from starlette.requests import Request
7
+ from starlette.responses import Response
8
+
9
+ from .story import story
10
+
11
+
12
+ class RuntimeNarrativeMiddleware(BaseHTTPMiddleware):
13
+ """
14
+ FastAPI/Starlette middleware that wraps every HTTP request in a runtime_narrative story.
15
+
16
+ Each request gets a story named "<METHOD> <path>" (e.g. "POST /customers").
17
+ The story context is available to all stages declared inside route handlers.
18
+
19
+ Usage::
20
+
21
+ from runtime_narrative.middleware import RuntimeNarrativeMiddleware
22
+ from runtime_narrative.renderer.json_renderer import JsonRenderer
23
+
24
+ app.add_middleware(
25
+ RuntimeNarrativeMiddleware,
26
+ renderers=[JsonRenderer()], # optional, defaults to ConsoleRenderer
27
+ failure_analyzer=None, # optional OllamaFailureAnalyzer
28
+ )
29
+
30
+ Once added, individual route handlers no longer need to create their own
31
+ ``story()`` context — they can use ``stage()`` directly::
32
+
33
+ @app.post("/orders")
34
+ async def create_order(payload: OrderIn):
35
+ with stage("Validate Input"):
36
+ ...
37
+ with stage("Persist Order"):
38
+ ...
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ app,
44
+ renderers: Sequence[object] | None = None,
45
+ failure_analyzer: Any | None = None,
46
+ ):
47
+ super().__init__(app)
48
+ self._renderers = renderers
49
+ self._failure_analyzer = failure_analyzer
50
+
51
+ async def dispatch(self, request: Request, call_next) -> Response:
52
+ story_name = f"{request.method} {request.url.path}"
53
+ with story(story_name, renderers=self._renderers, failure_analyzer=self._failure_analyzer):
54
+ response = await call_next(request)
55
+ return response
56
+
57
+
58
+ __all__ = ["RuntimeNarrativeMiddleware"]
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class RenderProtocol(Protocol):
7
+ def handle(self, event: object) -> None:
8
+ ...
9
+
10
+
11
+ __all__ = ["RenderProtocol"]
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+ import re
3
+ import shutil
4
+ import textwrap
5
+
6
+ try:
7
+ import typer
8
+ except ImportError: # pragma: no cover
9
+ typer = None
10
+
11
+ from . import RenderProtocol
12
+
13
+
14
+ class ConsoleRenderer:
15
+ @property
16
+ def _success_color(self):
17
+ if typer:
18
+ return typer.colors.GREEN
19
+ return None
20
+
21
+ @property
22
+ def _success_value_color(self):
23
+ if typer:
24
+ return getattr(typer.colors, "BRIGHT_WHITE", typer.colors.WHITE)
25
+ return None
26
+
27
+ @property
28
+ def _failure_color(self):
29
+ if typer:
30
+ return getattr(typer.colors, "BRIGHT_RED", typer.colors.RED)
31
+ return None
32
+
33
+ @property
34
+ def _failure_value_color(self):
35
+ if typer:
36
+ return getattr(typer.colors, "BRIGHT_YELLOW", typer.colors.YELLOW)
37
+ return None
38
+
39
+ @property
40
+ def _failure_heading_color(self):
41
+ if typer:
42
+ return getattr(typer.colors, "BRIGHT_WHITE", typer.colors.WHITE)
43
+ return None
44
+
45
+ @property
46
+ def _fix_heading_color(self):
47
+ if typer:
48
+ return getattr(typer.colors, "BRIGHT_GREEN", typer.colors.GREEN)
49
+ return None
50
+
51
+ @staticmethod
52
+ def _secho(text: str, *, fg=None, bold: bool = False, nl: bool = True) -> None:
53
+ if typer is None:
54
+ if nl:
55
+ print(text)
56
+ else:
57
+ print(text, end="")
58
+ return
59
+ typer.secho(text, fg=fg, bold=bold, nl=nl)
60
+
61
+ def _label(self, label: str, value: str, *, label_fg=None, value_fg=None) -> None:
62
+ self._secho(f"{label} ", fg=label_fg, bold=True, nl=False)
63
+ self._secho(value, fg=value_fg)
64
+
65
+ @staticmethod
66
+ def _strip_markdown(text: str) -> str:
67
+ fence_languages = {"python", "bash", "json", "yaml", "yml", "text"}
68
+ cleaned_lines: list[str] = []
69
+ for raw_line in text.splitlines():
70
+ line = raw_line.strip()
71
+ line = re.sub(r"^#{1,6}\s*", "", line)
72
+ line = re.sub(r"^\s*[-*+]\s+", "", line)
73
+ line = re.sub(r"^\s*\d+[.)]\s+", "", line)
74
+ line = line.replace("**", "").replace("__", "").replace("`", "")
75
+ if line.lower() in fence_languages:
76
+ continue
77
+ if line:
78
+ cleaned_lines.append(line)
79
+ return "\n".join(cleaned_lines)
80
+
81
+ @staticmethod
82
+ def _is_heading_line(line: str) -> bool:
83
+ normalized = line.strip().rstrip(":")
84
+ if not normalized:
85
+ return False
86
+ known = {"exact why", "evidence", "targeted fix", "targetted fix", "code changes"}
87
+ if normalized.lower() in known:
88
+ return True
89
+ if len(normalized.split()) <= 4 and re.match(r"^[A-Za-z][A-Za-z\s/-]*$", normalized):
90
+ return True
91
+ return False
92
+
93
+ @staticmethod
94
+ def _is_fix_heading_line(line: str) -> bool:
95
+ normalized = line.strip().rstrip(":").lower()
96
+ return normalized in {"targeted fix", "targetted fix", "code changes"}
97
+
98
+ def _render_box(self, title: str, text: str, *, border_fg=None, text_fg=None, heading_fg=None) -> None:
99
+ terminal_width = shutil.get_terminal_size((100, 20)).columns
100
+ width = max(60, min(110, terminal_width - 2))
101
+ inner = width - 2
102
+ content_width = max(10, inner - 2)
103
+
104
+ title_chunk = f" {title} "
105
+ if len(title_chunk) < inner:
106
+ top = "+" + title_chunk + ("-" * (inner - len(title_chunk))) + "+"
107
+ else:
108
+ top = "+" + ("-" * inner) + "+"
109
+ bottom = "+" + ("-" * inner) + "+"
110
+
111
+ self._secho(top, fg=border_fg, bold=True)
112
+ plain_text = self._strip_markdown(text)
113
+ lines = plain_text.splitlines()
114
+ first_section = True
115
+ for paragraph in lines:
116
+ is_heading = self._is_heading_line(paragraph)
117
+ if is_heading and not first_section:
118
+ self._secho("|", fg=border_fg, bold=True, nl=False)
119
+ self._secho(f" {' '.ljust(content_width)} ", fg=text_fg, nl=False)
120
+ self._secho("|", fg=border_fg, bold=True)
121
+ if is_heading:
122
+ first_section = False
123
+ is_fix_heading = self._is_fix_heading_line(paragraph)
124
+ line_fg = heading_fg if is_heading else text_fg
125
+ if is_fix_heading:
126
+ line_fg = self._fix_heading_color or heading_fg
127
+ line_bold = is_heading
128
+ wrapped = textwrap.wrap(paragraph, width=content_width) or [""]
129
+ for line in wrapped:
130
+ self._secho("|", fg=border_fg, bold=True, nl=False)
131
+ display_line = f">> {line}" if is_fix_heading else line
132
+ self._secho(f" {display_line.ljust(content_width)} ", fg=line_fg, bold=line_bold, nl=False)
133
+ self._secho("|", fg=border_fg, bold=True)
134
+ self._secho(bottom, fg=border_fg, bold=True)
135
+
136
+ def handle(self, event: object) -> None:
137
+ event_name = event.__class__.__name__
138
+
139
+ if event_name == "StoryStarted":
140
+ self._secho("▶ Stage started: ", fg=self._success_color, bold=True, nl=False)
141
+ self._secho(event.story_name, fg=self._success_value_color, bold=True)
142
+ return
143
+
144
+ if event_name == "StageStarted":
145
+ self._secho("▶ Stage started: ", fg=self._success_color, bold=True, nl=False)
146
+ self._secho(event.stage_name, fg=self._success_value_color)
147
+ return
148
+
149
+ if event_name == "StageCompleted":
150
+ self._secho("✔ Stage completed: ", fg=self._success_color, bold=True, nl=False)
151
+ self._secho(
152
+ f"{event.stage_name} ({event.duration_seconds:.3f}s)",
153
+ fg=self._success_value_color,
154
+ )
155
+ return
156
+
157
+ if event_name == "FailureOccurred":
158
+ self._secho("\n❌ Failure detected", fg=self._failure_color, bold=True)
159
+ self._label("Story:", event.story_name, label_fg=self._failure_color, value_fg=self._failure_value_color)
160
+ self._label("Stage:", event.stage_name, label_fg=self._failure_color, value_fg=self._failure_value_color)
161
+ self._label(
162
+ "Error:",
163
+ f"{event.error_type} - {event.error_message}",
164
+ label_fg=self._failure_color,
165
+ value_fg=self._failure_value_color,
166
+ )
167
+ self._label(
168
+ "Location:",
169
+ f"{event.filename}:{event.lineno} ({event.function})",
170
+ label_fg=self._failure_color,
171
+ value_fg=self._failure_value_color,
172
+ )
173
+ self._label("Code:", event.source_line, label_fg=self._failure_color, value_fg=self._failure_value_color)
174
+ if not event.llm_analysis:
175
+ self._label(
176
+ "What happened:",
177
+ event.exception_chain,
178
+ label_fg=self._failure_color,
179
+ value_fg=self._failure_value_color,
180
+ )
181
+ self._label(
182
+ "Why (exact):",
183
+ event.exact_cause,
184
+ label_fg=self._failure_color,
185
+ value_fg=self._failure_value_color,
186
+ )
187
+ if event.llm_analysis:
188
+ self._secho("")
189
+ self._render_box(
190
+ "LLM Debug",
191
+ event.llm_analysis,
192
+ border_fg=self._failure_color,
193
+ text_fg=self._failure_value_color,
194
+ heading_fg=self._failure_heading_color,
195
+ )
196
+ self._label(
197
+ "Recent stages:",
198
+ event.stage_timeline,
199
+ label_fg=self._failure_color,
200
+ value_fg=self._failure_value_color,
201
+ )
202
+ self._label(
203
+ "Progress:",
204
+ f"{event.progress_percent}% ({event.completed_stages} / {event.total_stages})",
205
+ label_fg=self._failure_color,
206
+ value_fg=self._failure_value_color,
207
+ )
208
+ return
209
+
210
+ if event_name == "StoryCompleted":
211
+ state = "SUCCESS" if event.success else "FAILED"
212
+ color = self._success_color if event.success else self._failure_color
213
+ value_color = self._success_value_color if event.success else self._failure_value_color
214
+ self._secho("▶ Story ended: ", fg=color, bold=True, nl=False)
215
+ self._secho(state, fg=value_color, bold=True)
216
+
217
+
218
+ __all__ = ["ConsoleRenderer"]
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from datetime import datetime
6
+ from typing import IO
7
+
8
+
9
+ class JsonRenderer:
10
+ """Renderer that emits one JSON object per event to a file-like output (default: stdout)."""
11
+
12
+ def __init__(self, output: IO[str] | None = None, indent: int | None = None):
13
+ self._output = output or sys.stdout
14
+ self._indent = indent
15
+
16
+ def _dump(self, data: dict) -> None:
17
+ self._output.write(json.dumps(data, default=str, indent=self._indent) + "\n")
18
+ if hasattr(self._output, "flush"):
19
+ self._output.flush()
20
+
21
+ def handle(self, event: object) -> None:
22
+ event_name = event.__class__.__name__
23
+
24
+ if event_name == "StoryStarted":
25
+ self._dump({
26
+ "event": "StoryStarted",
27
+ "story_id": event.story_id,
28
+ "story_name": event.story_name,
29
+ "timestamp": event.timestamp.isoformat(),
30
+ })
31
+
32
+ elif event_name == "StageStarted":
33
+ self._dump({
34
+ "event": "StageStarted",
35
+ "story_id": event.story_id,
36
+ "stage_name": event.stage_name,
37
+ "timestamp": event.timestamp.isoformat(),
38
+ })
39
+
40
+ elif event_name == "StageCompleted":
41
+ self._dump({
42
+ "event": "StageCompleted",
43
+ "story_id": event.story_id,
44
+ "stage_name": event.stage_name,
45
+ "duration_seconds": event.duration_seconds,
46
+ "timestamp": event.timestamp.isoformat(),
47
+ })
48
+
49
+ elif event_name == "FailureOccurred":
50
+ self._dump({
51
+ "event": "FailureOccurred",
52
+ "story_id": event.story_id,
53
+ "story_name": event.story_name,
54
+ "stage_name": event.stage_name,
55
+ "error_type": event.error_type,
56
+ "error_message": event.error_message,
57
+ "location": {
58
+ "filename": event.filename,
59
+ "lineno": event.lineno,
60
+ "function": event.function,
61
+ "source_line": event.source_line,
62
+ },
63
+ "exception_chain": event.exception_chain,
64
+ "exact_cause": event.exact_cause,
65
+ "llm_analysis": event.llm_analysis,
66
+ "stage_timeline": event.stage_timeline,
67
+ "progress": {
68
+ "percent": event.progress_percent,
69
+ "completed_stages": event.completed_stages,
70
+ "total_stages": event.total_stages,
71
+ },
72
+ "timestamp": event.timestamp.isoformat(),
73
+ })
74
+
75
+ elif event_name == "StoryCompleted":
76
+ self._dump({
77
+ "event": "StoryCompleted",
78
+ "story_id": event.story_id,
79
+ "story_name": event.story_name,
80
+ "success": event.success,
81
+ "progress": {
82
+ "percent": event.progress_percent,
83
+ "completed_stages": event.completed_stages,
84
+ "total_stages": event.total_stages,
85
+ },
86
+ "timestamp": event.timestamp.isoformat(),
87
+ })
88
+
89
+
90
+ __all__ = ["JsonRenderer"]
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+
6
+ from .context import current_stage_stack, current_story
7
+
8
+
9
+ @dataclass
10
+ class StageRecord:
11
+ name: str
12
+ started_at: datetime = field(default_factory=datetime.now)
13
+ ended_at: datetime | None = None
14
+ duration_seconds: float | None = None
15
+ completed: bool = False
16
+ failed: bool = False
17
+
18
+
19
+ class stage:
20
+ def __init__(self, name: str):
21
+ self.name = name
22
+ self.record = StageRecord(name=name)
23
+
24
+ def __enter__(self) -> StageRecord:
25
+ story_runtime = current_story.get()
26
+ if story_runtime is None:
27
+ raise RuntimeError("stage() must run inside an active story() context")
28
+
29
+ story_runtime.register_stage(self.record)
30
+ stack = list(current_stage_stack.get())
31
+ stack.append(self.record)
32
+ current_stage_stack.set(stack)
33
+ story_runtime.on_stage_started(self.record)
34
+ return self.record
35
+
36
+ def __exit__(self, exc_type, exc, tb) -> bool:
37
+ ended_at = datetime.now()
38
+ self.record.ended_at = ended_at
39
+ self.record.duration_seconds = (ended_at - self.record.started_at).total_seconds()
40
+
41
+ story_runtime = current_story.get()
42
+ if story_runtime is not None and exc_type is not None:
43
+ story_runtime.failed_stage_name = self.record.name
44
+
45
+ stack = list(current_stage_stack.get())
46
+ if stack:
47
+ stack.pop()
48
+ current_stage_stack.set(stack)
49
+
50
+ if story_runtime is None:
51
+ return False
52
+
53
+ if exc_type is None:
54
+ self.record.completed = True
55
+ story_runtime.on_stage_completed(self.record)
56
+ else:
57
+ self.record.failed = True
58
+ return False
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Sequence
6
+ from uuid import uuid4
7
+
8
+ from .context import current_stage_stack, current_story
9
+ from .events import FailureOccurred, StoryCompleted, StoryStarted, StageCompleted, StageStarted
10
+ from .failure import summarize_exception
11
+ from .stage import StageRecord
12
+
13
+
14
+ @dataclass
15
+ class StoryRuntime:
16
+ name: str
17
+ story_id: str = field(default_factory=lambda: str(uuid4()))
18
+ started_at: datetime = field(default_factory=datetime.now)
19
+ stages: list[StageRecord] = field(default_factory=list)
20
+ renderers: Sequence[object] = field(default_factory=tuple)
21
+ failed_stage_name: str | None = None
22
+
23
+ def emit(self, event: object) -> None:
24
+ for renderer in self.renderers:
25
+ renderer.handle(event)
26
+
27
+ def register_stage(self, stage: StageRecord) -> None:
28
+ self.stages.append(stage)
29
+
30
+ def on_stage_started(self, stage: StageRecord) -> None:
31
+ self.emit(StageStarted(story_id=self.story_id, stage_name=stage.name, timestamp=datetime.now()))
32
+
33
+ def on_stage_completed(self, stage: StageRecord) -> None:
34
+ completed_at = datetime.now()
35
+ duration_seconds = stage.duration_seconds
36
+ if duration_seconds is None:
37
+ duration_seconds = (completed_at - stage.started_at).total_seconds()
38
+ self.emit(
39
+ StageCompleted(
40
+ story_id=self.story_id,
41
+ stage_name=stage.name,
42
+ timestamp=completed_at,
43
+ duration_seconds=duration_seconds,
44
+ )
45
+ )
46
+
47
+ def build_stage_timeline(self) -> str:
48
+ if not self.stages:
49
+ return "<no stages>"
50
+
51
+ rendered: list[str] = []
52
+ for stage in self.stages[-5:]:
53
+ if stage.completed:
54
+ duration = f"{stage.duration_seconds:.3f}s" if stage.duration_seconds is not None else "n/a"
55
+ rendered.append(f"{stage.name}=completed ({duration})")
56
+ elif stage.failed:
57
+ duration = f"{stage.duration_seconds:.3f}s" if stage.duration_seconds is not None else "n/a"
58
+ rendered.append(f"{stage.name}=failed ({duration})")
59
+ else:
60
+ rendered.append(f"{stage.name}=in-progress")
61
+ return " | ".join(rendered)
62
+
63
+ @property
64
+ def completed_stages(self) -> int:
65
+ return sum(1 for s in self.stages if s.completed)
66
+
67
+ @property
68
+ def total_stages(self) -> int:
69
+ return len(self.stages)
70
+
71
+ @property
72
+ def progress_percent(self) -> int:
73
+ if not self.total_stages:
74
+ return 0
75
+ return int((self.completed_stages / self.total_stages) * 100)
76
+
77
+
78
+ class story:
79
+ def __init__(
80
+ self,
81
+ name: str,
82
+ *,
83
+ renderers: Sequence[object] | None = None,
84
+ failure_analyzer: Any | None = None,
85
+ ):
86
+ from .renderer.console import ConsoleRenderer
87
+
88
+ self.runtime = StoryRuntime(name=name, renderers=renderers or (ConsoleRenderer(),))
89
+ self.failure_analyzer = failure_analyzer
90
+ self._story_token = None
91
+ self._stack_token = None
92
+
93
+ def __enter__(self) -> StoryRuntime:
94
+ self._story_token = current_story.set(self.runtime)
95
+ self._stack_token = current_stage_stack.set([])
96
+ self.runtime.emit(
97
+ StoryStarted(
98
+ story_id=self.runtime.story_id,
99
+ story_name=self.runtime.name,
100
+ timestamp=datetime.now(),
101
+ )
102
+ )
103
+ return self.runtime
104
+
105
+ def __exit__(self, exc_type, exc, tb) -> bool:
106
+ if exc_type is not None and exc is not None:
107
+ failure = summarize_exception(exc_type, exc, tb)
108
+ failed_stage_name = self.runtime.failed_stage_name or (
109
+ current_stage_stack.get()[-1].name if current_stage_stack.get() else "<unknown>"
110
+ )
111
+ stage_timeline = self.runtime.build_stage_timeline()
112
+ llm_analysis = None
113
+ if self.failure_analyzer is not None:
114
+ analyze_fn = getattr(self.failure_analyzer, "analyze_failure", None)
115
+ if callable(analyze_fn):
116
+ llm_analysis = analyze_fn(
117
+ story_name=self.runtime.name,
118
+ stage_name=failed_stage_name,
119
+ failure=failure,
120
+ stage_timeline=stage_timeline,
121
+ progress_percent=self.runtime.progress_percent,
122
+ )
123
+ self.runtime.emit(
124
+ FailureOccurred(
125
+ story_id=self.runtime.story_id,
126
+ story_name=self.runtime.name,
127
+ stage_name=failed_stage_name,
128
+ error_type=failure.error_type,
129
+ error_message=failure.error_message,
130
+ filename=failure.filename,
131
+ lineno=failure.lineno,
132
+ function=failure.function,
133
+ source_line=failure.source_line,
134
+ exception_chain=failure.exception_chain,
135
+ exact_cause=failure.exact_cause,
136
+ llm_analysis=llm_analysis,
137
+ stage_timeline=stage_timeline,
138
+ progress_percent=self.runtime.progress_percent,
139
+ completed_stages=self.runtime.completed_stages,
140
+ total_stages=self.runtime.total_stages,
141
+ timestamp=datetime.now(),
142
+ traceback_text=failure.traceback_text,
143
+ )
144
+ )
145
+
146
+ self.runtime.emit(
147
+ StoryCompleted(
148
+ story_id=self.runtime.story_id,
149
+ story_name=self.runtime.name,
150
+ success=exc_type is None,
151
+ progress_percent=self.runtime.progress_percent,
152
+ completed_stages=self.runtime.completed_stages,
153
+ total_stages=self.runtime.total_stages,
154
+ timestamp=datetime.now(),
155
+ )
156
+ )
157
+
158
+ if self._stack_token is not None:
159
+ current_stage_stack.reset(self._stack_token)
160
+ if self._story_token is not None:
161
+ current_story.reset(self._story_token)
162
+ return False
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: runtime-narrative
3
+ Version: 0.1.0
4
+ Summary: runtime-narrative — model execution as human-readable stories
5
+ Author-email: Shashank Raj <shashank.raj28@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sraj0501/runtime_narrative
8
+ Project-URL: Repository, https://github.com/sraj0501/runtime_narrative
9
+ Project-URL: Bug Tracker, https://github.com/sraj0501/runtime_narrative/issues
10
+ Keywords: logging,observability,tracing,fastapi,debugging,runtime_narrative
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: System :: Logging
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: console
28
+ Requires-Dist: typer>=0.9.0; extra == "console"
29
+ Provides-Extra: fastapi
30
+ Requires-Dist: starlette>=0.27.0; extra == "fastapi"
31
+ Provides-Extra: all
32
+ Requires-Dist: typer>=0.9.0; extra == "all"
33
+ Requires-Dist: starlette>=0.27.0; extra == "all"
34
+ Dynamic: license-file
35
+
36
+ # runtime_narrative
37
+
38
+ Model your application's execution as **human-readable stories** instead of scattered log lines.
39
+
40
+ When something fails, instead of a raw traceback you get:
41
+
42
+ ```
43
+ ❌ Failure detected
44
+ Story: Create Customer
45
+ Stage: Validate Input
46
+ Error: ValueError - invalid email
47
+ Location: handlers.py:24 (validate_customer)
48
+ Code: if "@" not in payload.email:
49
+ Progress: 33% (1 / 3 stages completed)
50
+ Recent stages: Validate Input=failed (0.001s)
51
+ ```
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ # Core only (zero dependencies)
57
+ pip install runtime-narrative
58
+
59
+ # With colored console output
60
+ pip install "runtime-narrative[console]"
61
+
62
+ # With FastAPI middleware
63
+ pip install "runtime-narrative[fastapi]"
64
+
65
+ # Everything
66
+ pip install "runtime-narrative[all]"
67
+ ```
68
+
69
+ ## Quick start
70
+
71
+ ```python
72
+ from runtime_narrative import story, stage
73
+
74
+ with story("Import Customers"):
75
+ with stage("Load CSV"):
76
+ load_file()
77
+
78
+ with stage("Validate Data"):
79
+ validate()
80
+
81
+ with stage("Insert Records"):
82
+ insert()
83
+ ```
84
+
85
+ ## FastAPI — middleware (recommended)
86
+
87
+ Add middleware once and every request gets a story automatically.
88
+ Route handlers only need to declare stages:
89
+
90
+ ```python
91
+ from runtime_narrative import RuntimeNarrativeMiddleware, stage
92
+
93
+ app.add_middleware(RuntimeNarrativeMiddleware)
94
+
95
+ @app.post("/customers")
96
+ async def create_customer(payload: CustomerIn):
97
+ with stage("Validate Input"):
98
+ ...
99
+ with stage("Insert Into Database"):
100
+ ...
101
+ with stage("Build Response"):
102
+ return {"ok": True}
103
+ ```
104
+
105
+ ## FastAPI — decorator style
106
+
107
+ ```python
108
+ from runtime_narrative import runtime_narrative_story, runtime_narrative_stage
109
+
110
+ @app.post("/customers")
111
+ @runtime_narrative_story("Create Customer")
112
+ async def create_customer(payload: CustomerIn):
113
+ return await _save(payload)
114
+
115
+ @runtime_narrative_stage("Save to Database")
116
+ async def _save(payload):
117
+ ...
118
+ ```
119
+
120
+ ## Structured JSON output
121
+
122
+ Pipe one JSON object per lifecycle event to stdout, a file, or any stream:
123
+
124
+ ```python
125
+ from runtime_narrative import RuntimeNarrativeMiddleware, JsonRenderer
126
+
127
+ app.add_middleware(RuntimeNarrativeMiddleware, renderers=[JsonRenderer()])
128
+ ```
129
+
130
+ Output (one line per event):
131
+ ```json
132
+ {"event": "StoryStarted", "story_id": "...", "story_name": "POST /customers", "timestamp": "..."}
133
+ {"event": "StageStarted", "story_id": "...", "stage_name": "Validate Input", "timestamp": "..."}
134
+ {"event": "StageCompleted", "story_id": "...", "stage_name": "Validate Input", "duration_seconds": 0.001, "timestamp": "..."}
135
+ {"event": "StoryCompleted", "story_id": "...", "success": true, "progress": {"percent": 100, ...}, "timestamp": "..."}
136
+ ```
137
+
138
+ Write to a file instead of stdout:
139
+
140
+ ```python
141
+ JsonRenderer(output=open("runtime_narrative.log", "a"))
142
+ ```
143
+
144
+ ## LLM failure analysis
145
+
146
+ Plug in any LLM backend — model name and endpoint are always required (no defaults).
147
+
148
+ **OpenAI-compatible backends** (vLLM, llama.cpp `--server`, LM Studio, Ollama OpenAI mode):
149
+
150
+ ```python
151
+ from runtime_narrative import LLMFailureAnalyzer, story
152
+
153
+ analyzer = LLMFailureAnalyzer(
154
+ model="llama3",
155
+ endpoint="http://localhost:8000/v1/chat/completions",
156
+ )
157
+
158
+ with story("Process Orders", failure_analyzer=analyzer):
159
+ ...
160
+ ```
161
+
162
+ **Ollama native API:**
163
+
164
+ ```python
165
+ from runtime_narrative import OllamaFailureAnalyzer, story
166
+
167
+ analyzer = OllamaFailureAnalyzer(
168
+ model="llama3",
169
+ endpoint="http://localhost:11434/api/generate",
170
+ )
171
+
172
+ with story("Process Orders", failure_analyzer=analyzer):
173
+ ...
174
+ ```
175
+
176
+ Falls back silently if the endpoint is unavailable.
177
+
178
+ ## Custom renderer
179
+
180
+ Implement a single `handle` method to receive all lifecycle events:
181
+
182
+ ```python
183
+ class MyRenderer:
184
+ def handle(self, event):
185
+ print(event.__class__.__name__, event.__dict__)
186
+
187
+ with story("My Story", renderers=[MyRenderer()]):
188
+ ...
189
+ ```
190
+
191
+ Events: `StoryStarted`, `StageStarted`, `StageCompleted`, `FailureOccurred`, `StoryCompleted`.
192
+
193
+ ## License
194
+
195
+ MIT
@@ -0,0 +1,18 @@
1
+ runtime_narrative/__init__.py,sha256=XKhfk-UfLwrzAEWE0g66XVoo1vcrcnOAefsvoNyK21k,562
2
+ runtime_narrative/context.py,sha256=9Zxfm0_hQHntO4Fkv1uyHVHLdxsQMrF7Yxt8wV6UXWk,385
3
+ runtime_narrative/decorators.py,sha256=2-n_vt7g8fFlIcWW-tgBEHW4vUSZUUtcmrrHl82hj5U,2087
4
+ runtime_narrative/events.py,sha256=TAzvtBI1h88EZoXjP3tZ6OZvqplYHZjBlVj4A7mBUsw,1192
5
+ runtime_narrative/failure.py,sha256=0s2jCdBgvuIjVd3_ucpcjSblL5S0N2d0Y_M3WG0ZSOQ,1935
6
+ runtime_narrative/middleware.py,sha256=SWlH66TR0vOKn6tUveJDhPWd6QVSv0p_IDbR00dVR3k,1917
7
+ runtime_narrative/stage.py,sha256=w3c6h5zLDHxyDwVWodMJcO-yP4qFl8g4_o_HOpMyWlk,1759
8
+ runtime_narrative/story.py,sha256=B7v-J2RrvNCZv3qPfL7RNNxKDJG9Hy2nk4RaTV5F9DY,6221
9
+ runtime_narrative/analyzers/__init__.py,sha256=Nf7O3tHKbd1CUcSQ-mla6EGdY_rl3yDhZ8PIa4WW4Eg,121
10
+ runtime_narrative/analyzers/ollama.py,sha256=YCHJKoqJ4x6ChX5bnb1yygcI77VraxWDbM81SVMQimw,5417
11
+ runtime_narrative/renderer/__init__.py,sha256=EP3bq4H44d6RYsX0tkQA69pwJEhYILwx5KB7ndLOQ4U,186
12
+ runtime_narrative/renderer/console.py,sha256=rBy5WqvDhfoSyHo7EFM4OirpmnukpVu9LCtMFiS0Tbs,8446
13
+ runtime_narrative/renderer/json_renderer.py,sha256=9IlhbjrQpHXME-H1PvjOXV_GygNDZsqRg8PaBj6JFF4,3258
14
+ runtime_narrative-0.1.0.dist-info/licenses/LICENSE,sha256=AnFni0sUptWdCh6Pfj-Llrls83LIp0ySaATuR8fq3Vg,1069
15
+ runtime_narrative-0.1.0.dist-info/METADATA,sha256=vZHhthWuS4eD4tA_In-HqtZWFRNWEd26KLf6VmK3-eQ,5383
16
+ runtime_narrative-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
+ runtime_narrative-0.1.0.dist-info/top_level.txt,sha256=3WFs84Eqwjm7qQAyTrrrkbkXT9fGkZvpy3IgscQ9F98,18
18
+ runtime_narrative-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Shashank Raj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ runtime_narrative