prompt-flamegraph 0.2.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.
- prompt_flamegraph/__init__.py +32 -0
- prompt_flamegraph/__main__.py +20 -0
- prompt_flamegraph/cli.py +233 -0
- prompt_flamegraph/core.py +180 -0
- prompt_flamegraph/diff.py +131 -0
- prompt_flamegraph/export.py +170 -0
- prompt_flamegraph/render.py +257 -0
- prompt_flamegraph/terminal.py +189 -0
- prompt_flamegraph/waste.py +138 -0
- prompt_flamegraph-0.2.0.dist-info/METADATA +129 -0
- prompt_flamegraph-0.2.0.dist-info/RECORD +14 -0
- prompt_flamegraph-0.2.0.dist-info/WHEEL +4 -0
- prompt_flamegraph-0.2.0.dist-info/entry_points.txt +2 -0
- prompt_flamegraph-0.2.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
|
|
2
|
+
# Copyright (C) 2025 fjjjuv
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""prompt_flamegraph: lightweight prompt context flamegraph generator for LLMs."""
|
|
18
|
+
|
|
19
|
+
from .core import build_tree, count_tokens, profile_prompt
|
|
20
|
+
from .diff import diff_prompts
|
|
21
|
+
from .waste import detect_waste, WasteReport, Finding
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"build_tree",
|
|
26
|
+
"count_tokens",
|
|
27
|
+
"diff_prompts",
|
|
28
|
+
"detect_waste",
|
|
29
|
+
"Finding",
|
|
30
|
+
"profile_prompt",
|
|
31
|
+
"WasteReport",
|
|
32
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
|
|
2
|
+
# Copyright (C) 2025 fjjjuv
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
from .cli import main
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
sys.exit(main())
|
prompt_flamegraph/cli.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
|
|
2
|
+
# Copyright (C) 2025 fjjjuv
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Command-line interface for prompt-flamegraph."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
SAMPLE_PROMPT = {
|
|
27
|
+
"system_prompt": (
|
|
28
|
+
"You are a helpful coding assistant. Be concise. "
|
|
29
|
+
"Always think step by step before answering."
|
|
30
|
+
),
|
|
31
|
+
"tools": [
|
|
32
|
+
{
|
|
33
|
+
"name": "read_file",
|
|
34
|
+
"description": "Read a file from disk. Accepts a path argument.",
|
|
35
|
+
"schema": "{\"path\": \"string\"}",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "run_command",
|
|
39
|
+
"description": "Execute a shell command and return stdout/stderr.",
|
|
40
|
+
"schema": "{\"command\": \"string\"}",
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
"rag_context": {
|
|
44
|
+
"doc_1.py": "import os\n\ndef hello():\n return 'hello'\n",
|
|
45
|
+
"doc_2.py": "import sys\n\ndef world():\n return 'world'\n",
|
|
46
|
+
},
|
|
47
|
+
"chat_history": [
|
|
48
|
+
{"role": "user", "content": "Help me profile my prompt tokens."},
|
|
49
|
+
{"role": "assistant", "content": "Sure, I can help you understand where your tokens go."},
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_input(value: str) -> dict:
|
|
55
|
+
path = Path(value)
|
|
56
|
+
if path.exists():
|
|
57
|
+
raw = path.read_text(encoding="utf-8")
|
|
58
|
+
else:
|
|
59
|
+
raw = value
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(raw)
|
|
62
|
+
except json.JSONDecodeError as exc:
|
|
63
|
+
raise SystemExit(f"Invalid JSON input: {exc}") from exc
|
|
64
|
+
if not isinstance(data, (dict, list)):
|
|
65
|
+
raise SystemExit("Input JSON must be a dict or a list.")
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _detect_output(args: argparse.Namespace) -> str:
|
|
70
|
+
if args.output:
|
|
71
|
+
return args.output
|
|
72
|
+
ext = {"html": ".html", "svg": ".svg", "md": ".md"}.get(args.format, ".html")
|
|
73
|
+
return f"prompt_flamegraph{ext}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: list[str] | None = None) -> int:
|
|
77
|
+
parser = argparse.ArgumentParser(
|
|
78
|
+
prog="prompt-flamegraph",
|
|
79
|
+
description="Generate a lightweight flamegraph of your LLM prompt tokens.",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"input",
|
|
83
|
+
nargs="?",
|
|
84
|
+
help="JSON file (or JSON string) representing the prompt structure.",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"-o",
|
|
88
|
+
"--output",
|
|
89
|
+
default=None,
|
|
90
|
+
help="Output file (inferred from --format if not given).",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"-t",
|
|
94
|
+
"--title",
|
|
95
|
+
default=None,
|
|
96
|
+
help="Title of the output.",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--format",
|
|
100
|
+
choices=["html", "svg", "md"],
|
|
101
|
+
default="html",
|
|
102
|
+
help="Output format (default: html).",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--tokenizer",
|
|
106
|
+
default=None,
|
|
107
|
+
help="Tokenizer to use: 'tiktoken', 'words', or a callable.",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--cost",
|
|
111
|
+
type=float,
|
|
112
|
+
default=None,
|
|
113
|
+
help="Cost per token (e.g. 1.5e-6 for $1.5 per million tokens).",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--diff",
|
|
117
|
+
metavar="FILE",
|
|
118
|
+
default=None,
|
|
119
|
+
help="Compare INPUT with another JSON file and output a diff flamegraph.",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--terminal",
|
|
123
|
+
action="store_true",
|
|
124
|
+
help="Print a terminal bar chart instead of writing a file.",
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--no-waste",
|
|
128
|
+
action="store_true",
|
|
129
|
+
help="Disable waste detection for HTML output.",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--demo",
|
|
133
|
+
action="store_true",
|
|
134
|
+
help="Use the built-in sample prompt.",
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--width",
|
|
138
|
+
type=int,
|
|
139
|
+
default=1200,
|
|
140
|
+
help="Graph width in pixels (SVG/HTML).",
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"--height",
|
|
144
|
+
type=int,
|
|
145
|
+
default=720,
|
|
146
|
+
help="Graph max height in pixels (HTML).",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
args = parser.parse_args(argv)
|
|
150
|
+
|
|
151
|
+
if args.demo:
|
|
152
|
+
data = SAMPLE_PROMPT
|
|
153
|
+
elif args.input:
|
|
154
|
+
data = _load_input(args.input)
|
|
155
|
+
else:
|
|
156
|
+
parser.print_help()
|
|
157
|
+
return 1
|
|
158
|
+
|
|
159
|
+
output = _detect_output(args)
|
|
160
|
+
|
|
161
|
+
if args.terminal:
|
|
162
|
+
from .core import build_tree
|
|
163
|
+
from .terminal import to_terminal
|
|
164
|
+
|
|
165
|
+
tree = build_tree(data, tokenizer=args.tokenizer)
|
|
166
|
+
to_terminal(tree, title=args.title, cost_per_token=args.cost)
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
if args.diff:
|
|
170
|
+
from .core import build_tree
|
|
171
|
+
from .diff import build_diff_tree
|
|
172
|
+
from .render import to_html
|
|
173
|
+
from .export import to_svg, to_markdown
|
|
174
|
+
|
|
175
|
+
v2 = _load_input(args.diff)
|
|
176
|
+
t1 = build_tree(data, tokenizer=args.tokenizer)
|
|
177
|
+
t2 = build_tree(v2, tokenizer=args.tokenizer)
|
|
178
|
+
diff_tree = build_diff_tree(t1, t2)
|
|
179
|
+
|
|
180
|
+
if args.format == "html":
|
|
181
|
+
html = to_html(diff_tree, title=args.title or "Prompt Diff", cost_per_token=args.cost, width=args.width, height=args.height)
|
|
182
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
183
|
+
f.write(html)
|
|
184
|
+
elif args.format == "svg":
|
|
185
|
+
from .export import to_svg
|
|
186
|
+
svg = to_svg(diff_tree, title=args.title or "Prompt Diff", width=args.width)
|
|
187
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
188
|
+
f.write(svg)
|
|
189
|
+
elif args.format == "md":
|
|
190
|
+
from .export import to_markdown
|
|
191
|
+
md = to_markdown(diff_tree, title=args.title or "Prompt Diff", cost_per_token=args.cost)
|
|
192
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
193
|
+
f.write(md)
|
|
194
|
+
|
|
195
|
+
print(f"Diff written to: {output}")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
if args.format == "html":
|
|
199
|
+
from .core import profile_prompt
|
|
200
|
+
|
|
201
|
+
profile_prompt(
|
|
202
|
+
data,
|
|
203
|
+
output=output,
|
|
204
|
+
title=args.title,
|
|
205
|
+
tokenizer=args.tokenizer,
|
|
206
|
+
cost_per_token=args.cost,
|
|
207
|
+
detect_waste=not args.no_waste,
|
|
208
|
+
width=args.width,
|
|
209
|
+
height=args.height,
|
|
210
|
+
)
|
|
211
|
+
elif args.format == "svg":
|
|
212
|
+
from .core import build_tree
|
|
213
|
+
from .export import to_svg
|
|
214
|
+
|
|
215
|
+
tree = build_tree(data, tokenizer=args.tokenizer)
|
|
216
|
+
svg = to_svg(tree, title=args.title, width=args.width)
|
|
217
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
218
|
+
f.write(svg)
|
|
219
|
+
elif args.format == "md":
|
|
220
|
+
from .core import build_tree
|
|
221
|
+
from .export import to_markdown
|
|
222
|
+
|
|
223
|
+
tree = build_tree(data, tokenizer=args.tokenizer)
|
|
224
|
+
md = to_markdown(tree, title=args.title, cost_per_token=args.cost)
|
|
225
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
226
|
+
f.write(md)
|
|
227
|
+
|
|
228
|
+
print(f"Flamegraph written to: {output}")
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
sys.exit(main())
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
|
|
2
|
+
# Copyright (C) 2025 fjjjuv
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Core logic: token counting and tree building."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, Callable, Iterable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Node:
|
|
29
|
+
name: str
|
|
30
|
+
tokens: int
|
|
31
|
+
children: list[Node] = field(default_factory=list)
|
|
32
|
+
text: str | None = None
|
|
33
|
+
change: str | None = None # 'added', 'removed', 'same', 'changed'
|
|
34
|
+
delta: int = 0
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_leaf(self) -> bool:
|
|
38
|
+
return not self.children
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
Tokenizer = Callable[[str], int]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _default_count(text: str) -> int:
|
|
45
|
+
"""Fallback token estimator when tiktoken is not installed."""
|
|
46
|
+
text = text.strip()
|
|
47
|
+
if not text:
|
|
48
|
+
return 0
|
|
49
|
+
# Word-ish tokens plus isolated punctuation. Rough but stable and fast.
|
|
50
|
+
return len(re.findall(r"\w+|[^\w\s]", text, flags=re.UNICODE))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_tiktoken() -> Tokenizer | None:
|
|
54
|
+
try:
|
|
55
|
+
import tiktoken
|
|
56
|
+
except ImportError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
enc = tiktoken.get_encoding("cl100k_base")
|
|
60
|
+
|
|
61
|
+
def count(text: str) -> int:
|
|
62
|
+
return len(enc.encode(text))
|
|
63
|
+
|
|
64
|
+
return count
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_tokenizer(
|
|
68
|
+
tokenizer: Tokenizer | str | None = None,
|
|
69
|
+
) -> Tokenizer:
|
|
70
|
+
"""Resolve a tokenizer from a name, callable, or default."""
|
|
71
|
+
if tokenizer is None:
|
|
72
|
+
tiktoken_count = _load_tiktoken()
|
|
73
|
+
if tiktoken_count is not None:
|
|
74
|
+
return tiktoken_count
|
|
75
|
+
return _default_count
|
|
76
|
+
|
|
77
|
+
if callable(tokenizer):
|
|
78
|
+
return tokenizer
|
|
79
|
+
|
|
80
|
+
if tokenizer == "tiktoken" or tokenizer.startswith("cl100k"):
|
|
81
|
+
tiktoken_count = _load_tiktoken()
|
|
82
|
+
if tiktoken_count is None:
|
|
83
|
+
raise ImportError(
|
|
84
|
+
"tiktoken is not installed. Run 'pip install prompt-flamegraph[tiktoken]'"
|
|
85
|
+
)
|
|
86
|
+
return tiktoken_count
|
|
87
|
+
|
|
88
|
+
if tokenizer == "words":
|
|
89
|
+
return _default_count
|
|
90
|
+
|
|
91
|
+
raise ValueError(f"Unknown tokenizer: {tokenizer}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def count_tokens(text: str, tokenizer: Tokenizer | str | None = None) -> int:
|
|
95
|
+
"""Count tokens in a string using the selected tokenizer."""
|
|
96
|
+
return get_tokenizer(tokenizer)(text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _guess_name(value: Any, index: int) -> str:
|
|
100
|
+
if isinstance(value, dict):
|
|
101
|
+
for key in ("name", "title", "id", "role", "filename", "file", "source"):
|
|
102
|
+
candidate = value.get(key)
|
|
103
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
104
|
+
return candidate
|
|
105
|
+
return f"item_{index}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _to_text(value: Any) -> str:
|
|
109
|
+
if isinstance(value, str):
|
|
110
|
+
return value
|
|
111
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_tree(
|
|
115
|
+
data: Any,
|
|
116
|
+
name: str = "prompt",
|
|
117
|
+
tokenizer: Tokenizer | str | None = None,
|
|
118
|
+
_count_fn: Callable[[str], int] | None = None,
|
|
119
|
+
) -> Node:
|
|
120
|
+
"""Recursively build a token tree from a nested dict/list/string."""
|
|
121
|
+
count_fn = _count_fn or get_tokenizer(tokenizer)
|
|
122
|
+
|
|
123
|
+
if isinstance(data, dict):
|
|
124
|
+
children = [
|
|
125
|
+
build_tree(value, name=key, _count_fn=count_fn)
|
|
126
|
+
for key, value in data.items()
|
|
127
|
+
]
|
|
128
|
+
return Node(name=name, tokens=sum(c.tokens for c in children), children=children)
|
|
129
|
+
|
|
130
|
+
if isinstance(data, (list, tuple)):
|
|
131
|
+
children = [
|
|
132
|
+
build_tree(value, name=_guess_name(value, i), _count_fn=count_fn)
|
|
133
|
+
for i, value in enumerate(data)
|
|
134
|
+
]
|
|
135
|
+
return Node(name=name, tokens=sum(c.tokens for c in children), children=children)
|
|
136
|
+
|
|
137
|
+
text = _to_text(data)
|
|
138
|
+
return Node(name=name, tokens=count_fn(text), text=text)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def flatten_tree(node: Node) -> list[Node]:
|
|
142
|
+
"""Return a flat list of all nodes in depth-first order."""
|
|
143
|
+
out = [node]
|
|
144
|
+
for child in node.children:
|
|
145
|
+
out.extend(flatten_tree(child))
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def profile_prompt(
|
|
150
|
+
data: Any,
|
|
151
|
+
output: str | None = "prompt_flamegraph.html",
|
|
152
|
+
title: str | None = None,
|
|
153
|
+
tokenizer: Tokenizer | str | None = None,
|
|
154
|
+
cost_per_token: float | None = None,
|
|
155
|
+
detect_waste: bool = True,
|
|
156
|
+
width: int = 1200,
|
|
157
|
+
height: int = 720,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Build a prompt token tree and render it to a standalone HTML flamegraph."""
|
|
160
|
+
from . import render
|
|
161
|
+
|
|
162
|
+
tree = build_tree(data, tokenizer=tokenizer)
|
|
163
|
+
waste_report = None
|
|
164
|
+
if detect_waste:
|
|
165
|
+
from .waste import detect_waste
|
|
166
|
+
|
|
167
|
+
waste_report = detect_waste(tree)
|
|
168
|
+
|
|
169
|
+
html = render.to_html(
|
|
170
|
+
tree,
|
|
171
|
+
title=title or "Prompt Flamegraph",
|
|
172
|
+
cost_per_token=cost_per_token,
|
|
173
|
+
waste_report=waste_report,
|
|
174
|
+
width=width,
|
|
175
|
+
height=height,
|
|
176
|
+
)
|
|
177
|
+
if output:
|
|
178
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
179
|
+
f.write(html)
|
|
180
|
+
return html
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
|
|
2
|
+
# Copyright (C) 2025 fjjjuv
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Compare two prompt trees and build a diff flamegraph."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .core import Node
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _all_paths(node: Node, path: tuple[str, ...]) -> dict[tuple[str, ...], tuple[int, str | None, list[str]]]:
|
|
27
|
+
"""Return a dict path -> (tokens, text, child_names) for every node."""
|
|
28
|
+
current = (*path, node.name)
|
|
29
|
+
result = {current: (node.tokens, node.text, [c.name for c in node.children])}
|
|
30
|
+
for child in node.children:
|
|
31
|
+
result.update(_all_paths(child, current))
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_subtree(
|
|
36
|
+
name: str,
|
|
37
|
+
path: tuple[str, ...],
|
|
38
|
+
v1_map: dict,
|
|
39
|
+
v2_map: dict,
|
|
40
|
+
) -> Node:
|
|
41
|
+
"""Recursively build the unified diff tree for a given path prefix."""
|
|
42
|
+
in_v1 = path in v1_map
|
|
43
|
+
in_v2 = path in v2_map
|
|
44
|
+
|
|
45
|
+
if in_v2:
|
|
46
|
+
tokens_v2, text_v2, children_v2 = v2_map[path]
|
|
47
|
+
else:
|
|
48
|
+
tokens_v2, text_v2, children_v2 = 0, None, []
|
|
49
|
+
|
|
50
|
+
if in_v1:
|
|
51
|
+
tokens_v1, text_v1, children_v1 = v1_map[path]
|
|
52
|
+
else:
|
|
53
|
+
tokens_v1, text_v1, children_v1 = 0, None, []
|
|
54
|
+
|
|
55
|
+
if in_v1 and in_v2:
|
|
56
|
+
if text_v1 == text_v2 and tokens_v1 == tokens_v2:
|
|
57
|
+
change = "same"
|
|
58
|
+
else:
|
|
59
|
+
change = "changed"
|
|
60
|
+
delta = tokens_v2 - tokens_v1
|
|
61
|
+
elif in_v2 and not in_v1:
|
|
62
|
+
change = "added"
|
|
63
|
+
delta = tokens_v2
|
|
64
|
+
elif in_v1 and not in_v2:
|
|
65
|
+
change = "removed"
|
|
66
|
+
delta = -tokens_v1
|
|
67
|
+
else:
|
|
68
|
+
# Should not happen
|
|
69
|
+
change = "same"
|
|
70
|
+
delta = 0
|
|
71
|
+
|
|
72
|
+
# Child union
|
|
73
|
+
child_names = sorted(set(children_v1) | set(children_v2))
|
|
74
|
+
children: list[Node] = []
|
|
75
|
+
for child_name in child_names:
|
|
76
|
+
child_path = (*path, child_name)
|
|
77
|
+
children.append(_build_subtree(child_name, child_path, v1_map, v2_map))
|
|
78
|
+
|
|
79
|
+
return Node(
|
|
80
|
+
name=name,
|
|
81
|
+
tokens=tokens_v2 or tokens_v1,
|
|
82
|
+
children=children,
|
|
83
|
+
text=text_v2 or text_v1,
|
|
84
|
+
change=change,
|
|
85
|
+
delta=delta,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_diff_tree(v1: Node, v2: Node) -> Node:
|
|
90
|
+
"""Build a unified diff tree from two prompt trees."""
|
|
91
|
+
v1_map = _all_paths(v1, ())
|
|
92
|
+
v2_map = _all_paths(v2, ())
|
|
93
|
+
root_name = v2.name if v2.name == v1.name else "diff"
|
|
94
|
+
root_path = (root_name,)
|
|
95
|
+
# Ensure both maps contain the chosen root
|
|
96
|
+
if (v1.name,) in v1_map and (v2.name,) in v2_map and v1.name == v2.name:
|
|
97
|
+
v1_map[root_path] = v1_map.pop((v1.name,))
|
|
98
|
+
v2_map[root_path] = v2_map.pop((v2.name,))
|
|
99
|
+
tree = _build_subtree(root_name, root_path, v1_map, v2_map)
|
|
100
|
+
tree.tokens = max(v1.tokens, v2.tokens, 1)
|
|
101
|
+
return tree
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def diff_prompts(
|
|
105
|
+
v1: Any,
|
|
106
|
+
v2: Any,
|
|
107
|
+
output: str | None = "prompt_diff.html",
|
|
108
|
+
title: str | None = None,
|
|
109
|
+
tokenizer: Any = None,
|
|
110
|
+
cost_per_token: float | None = None,
|
|
111
|
+
width: int = 1200,
|
|
112
|
+
height: int = 720,
|
|
113
|
+
) -> str:
|
|
114
|
+
"""Build and render a diff flamegraph between two prompts."""
|
|
115
|
+
from .core import build_tree
|
|
116
|
+
from .render import to_html
|
|
117
|
+
|
|
118
|
+
tree1 = build_tree(v1, name="v1", tokenizer=tokenizer)
|
|
119
|
+
tree2 = build_tree(v2, name="v2", tokenizer=tokenizer)
|
|
120
|
+
diff_tree = build_diff_tree(tree1, tree2)
|
|
121
|
+
html = to_html(
|
|
122
|
+
diff_tree,
|
|
123
|
+
title=title or "Prompt Diff",
|
|
124
|
+
cost_per_token=cost_per_token,
|
|
125
|
+
width=width,
|
|
126
|
+
height=height,
|
|
127
|
+
)
|
|
128
|
+
if output:
|
|
129
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
130
|
+
f.write(html)
|
|
131
|
+
return html
|