diffprompt 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.
- diffprompt/__init__.py +2 -0
- diffprompt/cli.py +246 -0
- diffprompt/core/__init__.py +0 -0
- diffprompt/core/clusterer.py +130 -0
- diffprompt/core/embedder.py +63 -0
- diffprompt/core/generator.py +123 -0
- diffprompt/core/judge.py +94 -0
- diffprompt/core/ontology.py +160 -0
- diffprompt/core/runner.py +65 -0
- diffprompt/core/scorer.py +102 -0
- diffprompt/core/slicer.py +152 -0
- diffprompt/models/__init__.py +105 -0
- diffprompt/models/cascade.py +144 -0
- diffprompt/output/__init__.py +0 -0
- diffprompt/output/exporter.py +227 -0
- diffprompt/output/terminal.py +154 -0
- diffprompt-0.1.0.dist-info/METADATA +249 -0
- diffprompt-0.1.0.dist-info/RECORD +20 -0
- diffprompt-0.1.0.dist-info/WHEEL +4 -0
- diffprompt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cascade model client.
|
|
3
|
+
Tries Ollama first (local, free), falls back to Groq.
|
|
4
|
+
|
|
5
|
+
Key design decisions:
|
|
6
|
+
- Exponential backoff on transient failures (not just silently returning None)
|
|
7
|
+
- call_groq_only() used by judge escalation so it doesn't re-try Ollama
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import asyncio
|
|
11
|
+
import httpx
|
|
12
|
+
import os
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
OLLAMA_BASE = "http://localhost:11434"
|
|
17
|
+
GROQ_BASE = "https://api.groq.com/openai/v1"
|
|
18
|
+
|
|
19
|
+
_MAX_RETRIES = 2
|
|
20
|
+
_RETRY_DELAY = 1.0 # seconds
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def call_ollama(
|
|
24
|
+
model: str,
|
|
25
|
+
prompt: str,
|
|
26
|
+
system: Optional[str] = None,
|
|
27
|
+
) -> Optional[str]:
|
|
28
|
+
"""Call a local Ollama model. Returns None if Ollama is not running."""
|
|
29
|
+
messages = []
|
|
30
|
+
if system:
|
|
31
|
+
messages.append({"role": "system", "content": system})
|
|
32
|
+
messages.append({"role": "user", "content": prompt})
|
|
33
|
+
|
|
34
|
+
for attempt in range(_MAX_RETRIES):
|
|
35
|
+
try:
|
|
36
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
37
|
+
r = await client.post(
|
|
38
|
+
f"{OLLAMA_BASE}/api/chat",
|
|
39
|
+
json={"model": model, "messages": messages, "stream": False},
|
|
40
|
+
)
|
|
41
|
+
r.raise_for_status()
|
|
42
|
+
return r.json()["message"]["content"]
|
|
43
|
+
except httpx.ConnectError:
|
|
44
|
+
return None # Ollama not running — don't retry
|
|
45
|
+
except Exception:
|
|
46
|
+
if attempt < _MAX_RETRIES - 1:
|
|
47
|
+
await asyncio.sleep(_RETRY_DELAY * (attempt + 1))
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
async def call_groq(
|
|
51
|
+
model: str,
|
|
52
|
+
prompt: str,
|
|
53
|
+
system: Optional[str] = None,
|
|
54
|
+
) -> Optional[str]:
|
|
55
|
+
"""Call Groq API with loud error logging and backoff."""
|
|
56
|
+
api_key = os.getenv("GROQ_API_KEY")
|
|
57
|
+
if not api_key:
|
|
58
|
+
print("\n[DEBUG] The code cannot find your GROQ_API_KEY. VS Code is not loading it.")
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
messages = []
|
|
62
|
+
if system:
|
|
63
|
+
messages.append({"role": "system", "content": system})
|
|
64
|
+
messages.append({"role": "user", "content": prompt})
|
|
65
|
+
|
|
66
|
+
_MAX_RETRIES = 5 # Increased from 2 to handle rate limits better
|
|
67
|
+
|
|
68
|
+
for attempt in range(_MAX_RETRIES):
|
|
69
|
+
try:
|
|
70
|
+
# We keep your trust_env=False fix here
|
|
71
|
+
async with httpx.AsyncClient(timeout=60.0, trust_env=False) as client:
|
|
72
|
+
r = await client.post(
|
|
73
|
+
f"{GROQ_BASE}/chat/completions",
|
|
74
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
75
|
+
json={"model": model, "messages": messages, "max_tokens": 2000},
|
|
76
|
+
)
|
|
77
|
+
r.raise_for_status()
|
|
78
|
+
return r.json()["choices"][0]["message"]["content"]
|
|
79
|
+
|
|
80
|
+
except httpx.HTTPStatusError as e:
|
|
81
|
+
if e.response.status_code == 401:
|
|
82
|
+
print(f"\n[DEBUG] HTTP 401: Your Groq API key is rejected.")
|
|
83
|
+
return None
|
|
84
|
+
elif e.response.status_code in (429, 503):
|
|
85
|
+
# We read the exact header Groq sends to know how long to wait
|
|
86
|
+
wait_time = float(e.response.headers.get("retry-after", _RETRY_DELAY * (2 ** attempt)))
|
|
87
|
+
print(f"\n[DEBUG] Rate limit hit. Waiting {wait_time}s...")
|
|
88
|
+
await asyncio.sleep(wait_time)
|
|
89
|
+
else:
|
|
90
|
+
print(f"\n[DEBUG] Groq API returned HTTP {e.response.status_code}: {e.response.text}")
|
|
91
|
+
return None
|
|
92
|
+
except httpx.ConnectError:
|
|
93
|
+
print("\n[DEBUG] ConnectError: Still blocked from the internet. Are you behind a corporate firewall?")
|
|
94
|
+
return None
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f"\n[DEBUG] Unexpected error: {str(e)}")
|
|
97
|
+
if attempt < _MAX_RETRIES - 1:
|
|
98
|
+
await asyncio.sleep(_RETRY_DELAY)
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def call_cascade(
|
|
104
|
+
prompt: str,
|
|
105
|
+
system: Optional[str] = None,
|
|
106
|
+
local_model: str = "qwen2.5:7b",
|
|
107
|
+
groq_model: str = "llama-3.1-8b-instant",
|
|
108
|
+
local_only: bool = False,
|
|
109
|
+
) -> tuple[str, str]:
|
|
110
|
+
"""
|
|
111
|
+
Try models in order: Ollama → Groq → error.
|
|
112
|
+
Returns (output, model_used).
|
|
113
|
+
"""
|
|
114
|
+
result = await call_ollama(local_model, prompt, system)
|
|
115
|
+
if result:
|
|
116
|
+
return result, f"local/{local_model}"
|
|
117
|
+
|
|
118
|
+
if local_only:
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
"Ollama unavailable and --local-only is set. Is Ollama running?"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
result = await call_groq(groq_model, prompt, system)
|
|
124
|
+
if result:
|
|
125
|
+
return result, f"groq/{groq_model}"
|
|
126
|
+
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
"All models failed. Check: is Ollama running? Is GROQ_API_KEY set?\n"
|
|
129
|
+
" → Install Ollama: https://ollama.ai\n"
|
|
130
|
+
" → Get free Groq key: https://console.groq.com"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def call_groq_only(
|
|
135
|
+
prompt: str,
|
|
136
|
+
system: Optional[str] = None,
|
|
137
|
+
groq_model: str = "llama-3.3-70b-versatile",
|
|
138
|
+
) -> Optional[str]:
|
|
139
|
+
"""
|
|
140
|
+
Directly calls Groq, skipping Ollama.
|
|
141
|
+
Used by judge escalation — we explicitly want the larger cloud model,
|
|
142
|
+
not a retry of the same local model that gave a low-confidence result.
|
|
143
|
+
"""
|
|
144
|
+
return await call_groq(groq_model, prompt, system)
|
|
File without changes
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTML export for diffprompt reports.
|
|
3
|
+
Self-contained single-file output, no external dependencies.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from diffprompt.models import DiffReport, Verdict
|
|
8
|
+
|
|
9
|
+
_VERDICT_COLOR = {
|
|
10
|
+
Verdict.IMPROVEMENT: "#22c55e",
|
|
11
|
+
Verdict.REGRESSION: "#ef4444",
|
|
12
|
+
Verdict.NEUTRAL: "#94a3b8",
|
|
13
|
+
}
|
|
14
|
+
_VERDICT_LABEL = {
|
|
15
|
+
Verdict.IMPROVEMENT: "SHIP IT",
|
|
16
|
+
Verdict.NEUTRAL: "CONDITIONAL",
|
|
17
|
+
Verdict.REGRESSION: "DO NOT SHIP",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_html(report: DiffReport) -> str:
|
|
22
|
+
score = report.regression_score
|
|
23
|
+
score_color = "#22c55e" if score >= 80 else "#f59e0b" if score >= 60 else "#ef4444"
|
|
24
|
+
verdict_color = _VERDICT_COLOR[report.verdict]
|
|
25
|
+
verdict_label = _VERDICT_LABEL[report.verdict]
|
|
26
|
+
|
|
27
|
+
slices_html = _render_slices(report)
|
|
28
|
+
examples_html = _render_examples(report)
|
|
29
|
+
clusters_html = _render_clusters(report)
|
|
30
|
+
|
|
31
|
+
return f"""<!DOCTYPE html>
|
|
32
|
+
<html lang="en">
|
|
33
|
+
<head>
|
|
34
|
+
<meta charset="UTF-8">
|
|
35
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
36
|
+
<title>diffprompt report</title>
|
|
37
|
+
<style>
|
|
38
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
39
|
+
body {{ font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; background: #0f172a; color: #e2e8f0; padding: 2rem; line-height: 1.6; max-width: 960px; margin: 0 auto; }}
|
|
40
|
+
h1 {{ font-size: 1.4rem; color: #f8fafc; margin-bottom: 0.25rem; letter-spacing: -0.02em; }}
|
|
41
|
+
h2 {{ font-size: 0.75rem; color: #475569; text-transform: uppercase; letter-spacing: 0.12em; margin: 2rem 0 0.75rem; border-bottom: 1px solid #1e293b; padding-bottom: 0.4rem; }}
|
|
42
|
+
.meta {{ color: #475569; font-size: 0.78rem; margin-bottom: 2rem; }}
|
|
43
|
+
.meta span {{ margin-right: 1.5rem; }}
|
|
44
|
+
.score-block {{ display: flex; align-items: center; gap: 1.5rem; margin: 1rem 0; }}
|
|
45
|
+
.score {{ font-size: 2.8rem; font-weight: 700; color: {score_color}; letter-spacing: -0.03em; line-height: 1; }}
|
|
46
|
+
.score sup {{ font-size: 1rem; color: #475569; font-weight: 400; }}
|
|
47
|
+
.bar-wrap {{ flex: 1; background: #1e293b; border-radius: 4px; height: 6px; }}
|
|
48
|
+
.bar-fill {{ height: 6px; border-radius: 4px; background: {score_color}; width: {score}%; transition: width 0.3s; }}
|
|
49
|
+
.counts {{ display: flex; gap: 1.5rem; font-size: 0.82rem; margin-top: 0.6rem; }}
|
|
50
|
+
.improved {{ color: #22c55e; }} .regressed {{ color: #ef4444; }} .neutral {{ color: #475569; }}
|
|
51
|
+
.slice-table {{ width: 100%; border-collapse: collapse; }}
|
|
52
|
+
.slice-table td {{ padding: 0.35rem 0; font-size: 0.82rem; vertical-align: middle; }}
|
|
53
|
+
.slice-label {{ width: 200px; padding-right: 1rem; }}
|
|
54
|
+
.slice-bar-cell {{ width: 200px; }}
|
|
55
|
+
.slice-bar-wrap {{ background: #1e293b; border-radius: 2px; height: 5px; }}
|
|
56
|
+
.slice-score {{ width: 3rem; text-align: right; padding: 0 0.75rem; font-variant-numeric: tabular-nums; }}
|
|
57
|
+
.slice-n {{ color: #334155; font-size: 0.75rem; }}
|
|
58
|
+
.example {{ background: #0d1829; border: 1px solid #1e293b; border-radius: 8px; padding: 1.1rem 1.25rem; margin: 0.75rem 0; }}
|
|
59
|
+
.example + .example {{ margin-top: 0.5rem; }}
|
|
60
|
+
.example-slot {{ font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: #334155; margin-bottom: 0.6rem; }}
|
|
61
|
+
.example-tags {{ font-size: 0.75rem; color: #475569; margin-bottom: 0.6rem; }}
|
|
62
|
+
.example-tags .tag-key {{ color: #334155; }}
|
|
63
|
+
.example-tags .tag-val {{ color: #64748b; }}
|
|
64
|
+
.example-row {{ display: grid; grid-template-columns: 3rem 1fr; gap: 0.5rem; font-size: 0.82rem; margin: 0.25rem 0; align-items: baseline; }}
|
|
65
|
+
.row-key {{ color: #334155; font-size: 0.75rem; }}
|
|
66
|
+
.row-v1 {{ color: #64748b; }}
|
|
67
|
+
.row-why {{ color: #475569; font-style: italic; font-size: 0.8rem; }}
|
|
68
|
+
.divider {{ border: none; border-top: 1px solid #1e293b; margin: 2rem 0; }}
|
|
69
|
+
.verdict-block {{ border-radius: 8px; padding: 1.25rem 1.5rem; margin-top: 1rem; border-left: 3px solid {verdict_color}; background: #0d1829; }}
|
|
70
|
+
.verdict-label {{ font-size: 1.1rem; font-weight: 700; color: {verdict_color}; letter-spacing: 0.04em; }}
|
|
71
|
+
.verdict-rec {{ color: #64748b; font-size: 0.82rem; margin-top: 0.5rem; line-height: 1.6; }}
|
|
72
|
+
.cluster-wrap {{ display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.25rem; }}
|
|
73
|
+
.cluster {{ background: #1e293b; border: 1px solid #2d3f55; border-radius: 4px; padding: 0.2rem 0.65rem; font-size: 0.78rem; color: #64748b; }}
|
|
74
|
+
.prompt-box {{ background: #0d1829; border: 1px solid #1e293b; border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.8rem; color: #94a3b8; white-space: pre-wrap; word-break: break-word; max-height: 100px; overflow: auto; }}
|
|
75
|
+
.prompt-label {{ font-size: 0.72rem; color: #334155; text-transform: uppercase; letter-spacing: 0.08em; margin: 0.75rem 0 0.3rem; }}
|
|
76
|
+
</style>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
|
|
80
|
+
<h1>diffprompt</h1>
|
|
81
|
+
<div class="meta">
|
|
82
|
+
<span>model: {_esc(report.model)}</span>
|
|
83
|
+
<span>judge: {_esc(report.judge)}</span>
|
|
84
|
+
<span>tests: {len(report.test_cases)}</span>
|
|
85
|
+
<span>diversity: {report.diversity_score:.2f}</span>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<h2>Prompts</h2>
|
|
89
|
+
<div class="prompt-label">v1 — baseline</div>
|
|
90
|
+
<div class="prompt-box">{_esc(report.prompt_v1)}</div>
|
|
91
|
+
<div class="prompt-label">v2 — candidate</div>
|
|
92
|
+
<div class="prompt-box">{_esc(report.prompt_v2)}</div>
|
|
93
|
+
|
|
94
|
+
<h2>Summary</h2>
|
|
95
|
+
<div class="score-block">
|
|
96
|
+
<div class="score">{score}<sup>/100</sup></div>
|
|
97
|
+
<div style="flex:1">
|
|
98
|
+
<div class="bar-wrap"><div class="bar-fill"></div></div>
|
|
99
|
+
<div class="counts">
|
|
100
|
+
<span class="improved">▲ {report.n_improved} improved</span>
|
|
101
|
+
<span class="regressed">▼ {report.n_regressed} regressed</span>
|
|
102
|
+
<span class="neutral">→ {report.n_neutral} neutral</span>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{slices_html}
|
|
108
|
+
{examples_html}
|
|
109
|
+
{clusters_html}
|
|
110
|
+
|
|
111
|
+
<h2>Verdict</h2>
|
|
112
|
+
<div class="verdict-block">
|
|
113
|
+
<div class="verdict-label">{verdict_label}</div>
|
|
114
|
+
<div class="verdict-rec">{_esc(report.recommendation)}</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
</body>
|
|
118
|
+
</html>"""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _render_slices(report: DiffReport) -> str:
|
|
122
|
+
slices = [s for s in report.slices if s.depth == 1]
|
|
123
|
+
if not slices:
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
# Normalize bar widths to the actual min/max range so differences are visible
|
|
127
|
+
scores = [s.mean_similarity for s in slices]
|
|
128
|
+
lo, hi = min(scores), max(scores)
|
|
129
|
+
span = (hi - lo) or 0.01
|
|
130
|
+
|
|
131
|
+
def bar_pct(sim: float) -> int:
|
|
132
|
+
# 15–95% range so bars are always visible and differences readable
|
|
133
|
+
return int(15 + ((sim - lo) / span) * 80)
|
|
134
|
+
|
|
135
|
+
wins = [s for s in slices if s.verdict == Verdict.IMPROVEMENT]
|
|
136
|
+
fails = [s for s in slices if s.verdict == Verdict.REGRESSION]
|
|
137
|
+
|
|
138
|
+
rows = []
|
|
139
|
+
if wins:
|
|
140
|
+
rows.append('<tr><td colspan="4" style="padding-top:0.5rem;font-size:0.75rem;color:#22c55e;padding-bottom:0.2rem">v2 performs well when…</td></tr>')
|
|
141
|
+
for s in wins[:4]:
|
|
142
|
+
rows.append(_slice_row(s, "#22c55e", bar_pct(s.mean_similarity)))
|
|
143
|
+
if fails:
|
|
144
|
+
rows.append('<tr><td colspan="4" style="padding-top:0.75rem;font-size:0.75rem;color:#ef4444;padding-bottom:0.2rem">v2 struggles when…</td></tr>')
|
|
145
|
+
for s in fails[:6]:
|
|
146
|
+
rows.append(_slice_row(s, "#ef4444", bar_pct(s.mean_similarity)))
|
|
147
|
+
|
|
148
|
+
return f"<h2>Behavioral Profile</h2><table class='slice-table'>{''.join(rows)}</table>"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _slice_row(s, color: str, bar_pct: int) -> str:
|
|
152
|
+
warn = ""
|
|
153
|
+
if s.n < 3:
|
|
154
|
+
warn = '<span style="color:#f59e0b;font-size:0.72rem"> ⚠ low n</span>'
|
|
155
|
+
elif s.typical_ratio == 0:
|
|
156
|
+
warn = '<span style="color:#f59e0b;font-size:0.72rem"> ⚠ no typical tests</span>'
|
|
157
|
+
return f"""<tr>
|
|
158
|
+
<td class="slice-label" style="color:{color}">{_esc(s.label)}</td>
|
|
159
|
+
<td class="slice-bar-cell">
|
|
160
|
+
<div class="slice-bar-wrap">
|
|
161
|
+
<div style="height:5px;border-radius:2px;background:{color};width:{bar_pct}%"></div>
|
|
162
|
+
</div>
|
|
163
|
+
</td>
|
|
164
|
+
<td class="slice-score" style="color:{color}">{s.mean_similarity:.2f}</td>
|
|
165
|
+
<td class="slice-n">{s.n} tests{warn}</td>
|
|
166
|
+
</tr>"""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _render_examples(report: DiffReport) -> str:
|
|
170
|
+
if not report.key_examples:
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
slot_label = {
|
|
174
|
+
"most_important": "Most Important",
|
|
175
|
+
"best_improvement": "Best Improvement",
|
|
176
|
+
"most_surprising": "Most Surprising",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
cards = []
|
|
180
|
+
for ex in report.key_examples:
|
|
181
|
+
d = ex.diff
|
|
182
|
+
color = _VERDICT_COLOR[d.verdict]
|
|
183
|
+
label = slot_label.get(ex.slot, ex.slot.replace("_", " ").title())
|
|
184
|
+
tags = " ".join(
|
|
185
|
+
f'<span class="tag-key">{_esc(k)}:</span><span class="tag-val">{_esc(v)}</span>'
|
|
186
|
+
for k, v in d.test_case.tags.items()
|
|
187
|
+
)
|
|
188
|
+
div_color = _VERDICT_COLOR[d.verdict]
|
|
189
|
+
cards.append(f"""
|
|
190
|
+
<div class="example" style="border-left: 3px solid {div_color}">
|
|
191
|
+
<div class="example-slot">{label}</div>
|
|
192
|
+
<div class="example-tags">{tags} · divergence <span style="color:{div_color}">{d.divergence:.2f}</span></div>
|
|
193
|
+
<div class="example-row"><span class="row-key">input</span><span>{_esc(d.test_case.input[:140])}</span></div>
|
|
194
|
+
<div class="example-row"><span class="row-key">v1</span><span class="row-v1">{_esc(d.v1_output[:220])}</span></div>
|
|
195
|
+
<div class="example-row"><span class="row-key">v2</span><span style="color:{div_color}">{_esc(d.v2_output[:220])}</span></div>
|
|
196
|
+
<div class="example-row"><span class="row-key">why</span><span class="row-why">{_esc(ex.why_it_matters)}</span></div>
|
|
197
|
+
</div>""")
|
|
198
|
+
|
|
199
|
+
return f"<h2>Key Examples</h2>{''.join(cards)}"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _render_clusters(report: DiffReport) -> str:
|
|
203
|
+
if not report.clusters:
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
# Deduplicate: merge clusters with the same name, summing their counts
|
|
207
|
+
merged: dict[str, int] = defaultdict(int)
|
|
208
|
+
for c in report.clusters:
|
|
209
|
+
merged[c.name] += c.n
|
|
210
|
+
sorted_clusters = sorted(merged.items(), key=lambda x: x[1], reverse=True)
|
|
211
|
+
|
|
212
|
+
tags = "".join(
|
|
213
|
+
f'<span class="cluster">{_esc(name)} <span style="color:#475569">({n})</span></span>'
|
|
214
|
+
for name, n in sorted_clusters
|
|
215
|
+
)
|
|
216
|
+
return f"<h2>Failure Mode Clusters</h2><div class='cluster-wrap'>{tags}</div>"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _esc(text: str) -> str:
|
|
220
|
+
return (
|
|
221
|
+
str(text)
|
|
222
|
+
.replace("&", "&")
|
|
223
|
+
.replace("<", "<")
|
|
224
|
+
.replace(">", ">")
|
|
225
|
+
.replace('"', """)
|
|
226
|
+
.lstrip("\ufeff") # strip BOM if file was read with UTF-8-BOM
|
|
227
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rich terminal output renderer.
|
|
3
|
+
Clean, minimal, fits in one screen.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from rich.rule import Rule
|
|
9
|
+
from diffprompt.models import DiffReport, Verdict, SliceResult
|
|
10
|
+
|
|
11
|
+
console = Console(highlight=False)
|
|
12
|
+
|
|
13
|
+
VERDICT_COLOR = {
|
|
14
|
+
Verdict.IMPROVEMENT: "green",
|
|
15
|
+
Verdict.REGRESSION: "red",
|
|
16
|
+
Verdict.NEUTRAL: "dim",
|
|
17
|
+
}
|
|
18
|
+
VERDICT_ICON = {
|
|
19
|
+
Verdict.IMPROVEMENT: "✓",
|
|
20
|
+
Verdict.REGRESSION: "✗",
|
|
21
|
+
Verdict.NEUTRAL: "→",
|
|
22
|
+
}
|
|
23
|
+
VERDICT_LABEL = {
|
|
24
|
+
Verdict.IMPROVEMENT: "SHIP IT",
|
|
25
|
+
Verdict.NEUTRAL: "CONDITIONAL",
|
|
26
|
+
Verdict.REGRESSION: "DO NOT SHIP",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render(report: DiffReport) -> None:
|
|
31
|
+
console.print()
|
|
32
|
+
_header(report)
|
|
33
|
+
_summary(report)
|
|
34
|
+
_behavioral_profile(report)
|
|
35
|
+
_key_examples(report)
|
|
36
|
+
_verdict(report)
|
|
37
|
+
console.print()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _header(report: DiffReport) -> None:
|
|
41
|
+
console.print(
|
|
42
|
+
f"[bold white]diffprompt[/bold white] [dim]v0.1.0[/dim] "
|
|
43
|
+
f"[dim]model:[/dim] [dim]{report.model}[/dim] "
|
|
44
|
+
f"[dim]judge:[/dim] [dim]{report.judge}[/dim] "
|
|
45
|
+
f"[dim]tests:[/dim] [dim]{len(report.test_cases)}[/dim]"
|
|
46
|
+
)
|
|
47
|
+
console.print()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _summary(report: DiffReport) -> None:
|
|
51
|
+
score = report.regression_score
|
|
52
|
+
filled = int(score / 5)
|
|
53
|
+
bar = "█" * filled + "░" * (20 - filled)
|
|
54
|
+
color = "green" if score >= 80 else "yellow" if score >= 60 else "red"
|
|
55
|
+
|
|
56
|
+
console.print(f"[bold]━━ SUMMARY[/bold]")
|
|
57
|
+
console.print(
|
|
58
|
+
f" [{color}]{score}/100[/{color}] [{color}]{bar}[/{color}] "
|
|
59
|
+
f"[green]{report.n_improved} improved[/green] "
|
|
60
|
+
f"[red]{report.n_regressed} regressed[/red] "
|
|
61
|
+
f"[dim]{report.n_neutral} neutral[/dim]"
|
|
62
|
+
)
|
|
63
|
+
console.print()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _behavioral_profile(report: DiffReport) -> None:
|
|
67
|
+
if not report.slices:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
depth1 = [s for s in report.slices if s.depth == 1]
|
|
71
|
+
wins = [s for s in depth1 if s.verdict == Verdict.IMPROVEMENT]
|
|
72
|
+
fails = [s for s in depth1 if s.verdict == Verdict.REGRESSION]
|
|
73
|
+
|
|
74
|
+
if not wins and not fails:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
console.print("[bold]━━ BEHAVIORAL PROFILE[/bold]")
|
|
78
|
+
|
|
79
|
+
if wins:
|
|
80
|
+
console.print(" [green]v2 performs well when...[/green]")
|
|
81
|
+
for s in wins[:3]:
|
|
82
|
+
_slice_row(s, "green")
|
|
83
|
+
|
|
84
|
+
if fails:
|
|
85
|
+
console.print(" [red]v2 struggles when...[/red]")
|
|
86
|
+
for s in fails[:3]:
|
|
87
|
+
_slice_row(s, "red")
|
|
88
|
+
|
|
89
|
+
console.print()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _slice_row(s: SliceResult, color: str) -> None:
|
|
93
|
+
icon = "✓" if color == "green" else "✗"
|
|
94
|
+
warn = _warn(s)
|
|
95
|
+
console.print(
|
|
96
|
+
f" [{color}]{icon}[/{color}] [bold]{s.label:<38}[/bold] "
|
|
97
|
+
f"[dim]score [/dim][{color}]{s.mean_similarity:.2f}[/{color}]"
|
|
98
|
+
f"[dim] {s.n} tests[/dim]"
|
|
99
|
+
+ (f" {warn}" if warn else "")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _warn(s: SliceResult) -> str:
|
|
104
|
+
if s.confidence >= 0.7:
|
|
105
|
+
return ""
|
|
106
|
+
if s.n < 3:
|
|
107
|
+
return "[yellow]⚠ low n[/yellow]"
|
|
108
|
+
if s.typical_ratio == 0:
|
|
109
|
+
return "[yellow]⚠ no typical tests[/yellow]"
|
|
110
|
+
if s.variance > 0.15:
|
|
111
|
+
return "[yellow]⚠ high variance[/yellow]"
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _key_examples(report: DiffReport) -> None:
|
|
116
|
+
if not report.key_examples:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
console.print("[bold]━━ KEY EXAMPLES[/bold]")
|
|
120
|
+
|
|
121
|
+
slot_labels = {
|
|
122
|
+
"most_important": "MOST IMPORTANT",
|
|
123
|
+
"best_improvement": "BEST IMPROVEMENT",
|
|
124
|
+
"most_surprising": "MOST SURPRISING",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for ex in report.key_examples:
|
|
128
|
+
d = ex.diff
|
|
129
|
+
label = slot_labels.get(ex.slot, ex.slot.upper())
|
|
130
|
+
color = VERDICT_COLOR[d.verdict]
|
|
131
|
+
tags = " ".join(f"[dim]{k}:[/dim][cyan]{v}[/cyan]" for k, v in d.test_case.tags.items())
|
|
132
|
+
|
|
133
|
+
console.print(f"\n [bold]{label}[/bold] {tags} [dim]divergence[/dim] [{color}]{d.divergence:.2f}[/{color}]")
|
|
134
|
+
console.print(f" [dim]input[/dim] {_clip(d.test_case.input, 90)}")
|
|
135
|
+
console.print(f" [dim]v1 [/dim] [dim]{_clip(d.v1_output, 100)}[/dim]")
|
|
136
|
+
console.print(f" [dim]v2 [/dim] [{color}]{_clip(d.v2_output, 100)}[/{color}]")
|
|
137
|
+
console.print(f" [dim]why [/dim] [italic dim]{_clip(ex.why_it_matters, 160)}[/italic dim]")
|
|
138
|
+
|
|
139
|
+
console.print()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _verdict(report: DiffReport) -> None:
|
|
143
|
+
color = VERDICT_COLOR[report.verdict]
|
|
144
|
+
icon = VERDICT_ICON[report.verdict]
|
|
145
|
+
label = VERDICT_LABEL[report.verdict]
|
|
146
|
+
|
|
147
|
+
console.print("[bold]━━ VERDICT[/bold]")
|
|
148
|
+
console.print(f" [{color}]{icon} {label}[/{color}]")
|
|
149
|
+
console.print(f" [dim]{report.recommendation}[/dim]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _clip(text: str, n: int) -> str:
|
|
153
|
+
text = text.replace("\n", " ").strip()
|
|
154
|
+
return text[:n] + "…" if len(text) > n else text
|