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.
- runtime_narrative/__init__.py +23 -0
- runtime_narrative/analyzers/__init__.py +3 -0
- runtime_narrative/analyzers/ollama.py +179 -0
- runtime_narrative/context.py +11 -0
- runtime_narrative/decorators.py +69 -0
- runtime_narrative/events.py +66 -0
- runtime_narrative/failure.py +63 -0
- runtime_narrative/middleware.py +58 -0
- runtime_narrative/renderer/__init__.py +11 -0
- runtime_narrative/renderer/console.py +218 -0
- runtime_narrative/renderer/json_renderer.py +90 -0
- runtime_narrative/stage.py +58 -0
- runtime_narrative/story.py +162 -0
- runtime_narrative-0.1.0.dist-info/METADATA +195 -0
- runtime_narrative-0.1.0.dist-info/RECORD +18 -0
- runtime_narrative-0.1.0.dist-info/WHEEL +5 -0
- runtime_narrative-0.1.0.dist-info/licenses/LICENSE +21 -0
- runtime_narrative-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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,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,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
|