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
diffprompt/__init__.py
ADDED
diffprompt/cli.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
diffprompt CLI — uses Click directly (no Typer) for cross-version compatibility.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_prompt(value: str) -> str:
|
|
17
|
+
p = Path(value)
|
|
18
|
+
if p.exists() and p.is_file():
|
|
19
|
+
return p.read_text(encoding="utf-8").strip()
|
|
20
|
+
return value.strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
def app():
|
|
25
|
+
"""git diff for your prompt's behavior."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
@click.argument("prompt_v1")
|
|
31
|
+
@click.argument("prompt_v2")
|
|
32
|
+
@click.option("--auto-generate", is_flag=True, default=False, help="Auto-generate test cases")
|
|
33
|
+
@click.option("--n", default=40, show_default=True, help="Number of test cases")
|
|
34
|
+
@click.option("--test-file", default=None, help="Path to .jsonl test file")
|
|
35
|
+
@click.option("--model", default="groq/llama-3.3-70b-versatile", show_default=True)
|
|
36
|
+
@click.option("--judge", default="local/qwen2.5:7b", show_default=True)
|
|
37
|
+
@click.option("--local-only", is_flag=True, default=False, help="Never call any external API")
|
|
38
|
+
@click.option("--no-judge", is_flag=True, default=False, help="Skip judge, similarity only")
|
|
39
|
+
@click.option("--output", "output_format", default="terminal",
|
|
40
|
+
type=click.Choice(["terminal", "json", "html"]), show_default=True)
|
|
41
|
+
@click.option("--save", default=None, help="Save report to this file path")
|
|
42
|
+
@click.option("--top-n", default=3, show_default=True)
|
|
43
|
+
@click.option("--quiet", is_flag=True, default=False, help="Score + verdict only")
|
|
44
|
+
@click.option("--verbose", is_flag=True, default=False)
|
|
45
|
+
@click.option("--ci", is_flag=True, default=False, help="Exit 1 if score below threshold")
|
|
46
|
+
@click.option("--threshold", default=75, show_default=True)
|
|
47
|
+
def diff(prompt_v1, prompt_v2, auto_generate, n, test_file, model, judge,
|
|
48
|
+
local_only, no_judge, output_format, save, top_n, quiet, verbose, ci, threshold):
|
|
49
|
+
"""Diff the behavioral impact of a prompt change.
|
|
50
|
+
|
|
51
|
+
\b
|
|
52
|
+
PROMPT_V1 and PROMPT_V2 can be file paths or inline strings.
|
|
53
|
+
|
|
54
|
+
\b
|
|
55
|
+
Examples:
|
|
56
|
+
diffprompt diff v1.txt v2.txt --auto-generate
|
|
57
|
+
diffprompt diff v1.txt v2.txt --auto-generate --n 20
|
|
58
|
+
diffprompt diff v1.txt v2.txt --test-file inputs.jsonl
|
|
59
|
+
diffprompt diff v1.txt v2.txt --auto-generate --ci --threshold 75
|
|
60
|
+
"""
|
|
61
|
+
asyncio.run(_run_diff(
|
|
62
|
+
prompt_v1=_load_prompt(prompt_v1),
|
|
63
|
+
prompt_v2=_load_prompt(prompt_v2),
|
|
64
|
+
auto_generate=auto_generate, n=n, test_file=test_file,
|
|
65
|
+
model=model, judge=judge, local_only=local_only, no_judge=no_judge,
|
|
66
|
+
output_format=output_format, save=save, top_n=top_n,
|
|
67
|
+
quiet=quiet, verbose=verbose, ci=ci, threshold=threshold,
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _run_diff(**kwargs):
|
|
72
|
+
from diffprompt.core.ontology import Ontology
|
|
73
|
+
from diffprompt.core.generator import generate_test_cases, diversity_score
|
|
74
|
+
from diffprompt.core.runner import run_both
|
|
75
|
+
from diffprompt.core.embedder import batch_similarity
|
|
76
|
+
from diffprompt.core.judge import judge_single
|
|
77
|
+
from diffprompt.core.clusterer import cluster_diffs
|
|
78
|
+
from diffprompt.core.slicer import compute_slices
|
|
79
|
+
from diffprompt.core.scorer import regression_score, select_key_examples
|
|
80
|
+
from diffprompt.models import DiffResult, DiffReport, Verdict
|
|
81
|
+
from diffprompt.output.terminal import render
|
|
82
|
+
|
|
83
|
+
prompt_v1 = kwargs["prompt_v1"]
|
|
84
|
+
prompt_v2 = kwargs["prompt_v2"]
|
|
85
|
+
local_only = kwargs["local_only"]
|
|
86
|
+
|
|
87
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as p:
|
|
88
|
+
task = p.add_task("Starting...", total=None)
|
|
89
|
+
|
|
90
|
+
p.update(task, description="Inferring ontology...")
|
|
91
|
+
ontology = Ontology()
|
|
92
|
+
await ontology.infer(prompt_v1, local_only=local_only)
|
|
93
|
+
await ontology.build_anchors(prompt_v1, local_only=local_only)
|
|
94
|
+
p.update(task, description=f"[green]✓[/green] Ontology: {list(ontology.dimensions.keys())}")
|
|
95
|
+
|
|
96
|
+
p.update(task, description="Generating test cases...")
|
|
97
|
+
test_cases = []
|
|
98
|
+
if kwargs["test_file"]:
|
|
99
|
+
test_cases = _load_test_file(kwargs["test_file"])
|
|
100
|
+
elif kwargs["auto_generate"]:
|
|
101
|
+
test_cases = await generate_test_cases(
|
|
102
|
+
prompt_v1, n=kwargs["n"], ontology=ontology, local_only=local_only,
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
console.print("[red]✗ Use --auto-generate or --test-file[/red]")
|
|
106
|
+
raise SystemExit(1)
|
|
107
|
+
|
|
108
|
+
if not test_cases:
|
|
109
|
+
console.print("[red]✗ No test cases generated[/red]")
|
|
110
|
+
raise SystemExit(1)
|
|
111
|
+
|
|
112
|
+
for tc in test_cases:
|
|
113
|
+
tc.tags = ontology.tag(tc.input)
|
|
114
|
+
|
|
115
|
+
div_score = diversity_score(test_cases)
|
|
116
|
+
p.update(task, description=f"[green]✓[/green] {len(test_cases)} test cases diversity={div_score:.2f}")
|
|
117
|
+
|
|
118
|
+
p.update(task, description="Running both prompts...")
|
|
119
|
+
v1_results, v2_results = await run_both(test_cases, prompt_v1, prompt_v2, local_only=local_only)
|
|
120
|
+
p.update(task, description=f"[green]✓[/green] {len(test_cases) * 2} completions done")
|
|
121
|
+
|
|
122
|
+
p.update(task, description="Computing semantic diff...")
|
|
123
|
+
pairs = [(v1_results[tc.id].output, v2_results[tc.id].output) for tc in test_cases]
|
|
124
|
+
similarities = batch_similarity(pairs)
|
|
125
|
+
|
|
126
|
+
diffs = []
|
|
127
|
+
for i, tc in enumerate(test_cases):
|
|
128
|
+
sim = similarities[i]
|
|
129
|
+
v1_out = v1_results[tc.id].output
|
|
130
|
+
v2_out = v2_results[tc.id].output
|
|
131
|
+
if kwargs["no_judge"]:
|
|
132
|
+
verdict, reason, confidence = Verdict.NEUTRAL, "judge skipped", 1.0
|
|
133
|
+
else:
|
|
134
|
+
verdict, reason, confidence = await judge_single(tc, v1_out, v2_out, sim, local_only=local_only)
|
|
135
|
+
diffs.append(DiffResult(
|
|
136
|
+
test_case=tc, v1_output=v1_out, v2_output=v2_out,
|
|
137
|
+
similarity=sim, divergence=1 - sim,
|
|
138
|
+
verdict=verdict, reason=reason, judge_confidence=confidence,
|
|
139
|
+
))
|
|
140
|
+
p.update(task, description="[green]✓[/green] Diff complete")
|
|
141
|
+
|
|
142
|
+
p.update(task, description="Clustering and slicing...")
|
|
143
|
+
clusters, unclustered = cluster_diffs(diffs)
|
|
144
|
+
slices = compute_slices(diffs)
|
|
145
|
+
score = regression_score(diffs)
|
|
146
|
+
key_examples = await select_key_examples(
|
|
147
|
+
sorted(diffs, key=lambda d: d.divergence, reverse=True)[:20],
|
|
148
|
+
local_only=local_only,
|
|
149
|
+
)
|
|
150
|
+
p.update(task, description="[green]✓[/green] Analysis complete")
|
|
151
|
+
|
|
152
|
+
n_improved = sum(1 for d in diffs if d.verdict == Verdict.IMPROVEMENT)
|
|
153
|
+
n_regressed = sum(1 for d in diffs if d.verdict == Verdict.REGRESSION)
|
|
154
|
+
n_neutral = sum(1 for d in diffs if d.verdict == Verdict.NEUTRAL)
|
|
155
|
+
|
|
156
|
+
overall_verdict = _compute_verdict(n_improved, n_regressed, n_neutral)
|
|
157
|
+
recommendation = _generate_recommendation(slices, clusters, overall_verdict)
|
|
158
|
+
|
|
159
|
+
report = DiffReport(
|
|
160
|
+
prompt_v1=prompt_v1, prompt_v2=prompt_v2, model=kwargs["model"], judge=kwargs["judge"],
|
|
161
|
+
test_cases=test_cases, diversity_score=div_score, diffs=diffs,
|
|
162
|
+
slices=slices, clusters=clusters, unclustered=unclustered,
|
|
163
|
+
key_examples=key_examples, regression_score=score,
|
|
164
|
+
n_improved=n_improved, n_regressed=n_regressed, n_neutral=n_neutral,
|
|
165
|
+
verdict=overall_verdict, recommendation=recommendation,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if not kwargs["quiet"]:
|
|
169
|
+
render(report)
|
|
170
|
+
else:
|
|
171
|
+
console.print(f"score: {score} verdict: {overall_verdict.value}")
|
|
172
|
+
|
|
173
|
+
if kwargs["save"]:
|
|
174
|
+
_save_report(report, kwargs["save"], kwargs["output_format"])
|
|
175
|
+
console.print(f"[dim]Saved → {kwargs['save']}[/dim]")
|
|
176
|
+
|
|
177
|
+
if kwargs["ci"] and score < kwargs["threshold"]:
|
|
178
|
+
console.print(f"[red]✗ CI: score {score} < threshold {kwargs['threshold']}[/red]")
|
|
179
|
+
raise SystemExit(1)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _compute_verdict(n_improved, n_regressed, n_neutral):
|
|
183
|
+
from diffprompt.models import Verdict
|
|
184
|
+
score_ratio = n_improved / max(1, n_improved + n_regressed + n_neutral)
|
|
185
|
+
if n_improved > n_regressed and score_ratio >= 0.6:
|
|
186
|
+
return Verdict.IMPROVEMENT
|
|
187
|
+
if n_regressed > n_improved:
|
|
188
|
+
return Verdict.REGRESSION
|
|
189
|
+
return Verdict.NEUTRAL
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _generate_recommendation(slices, clusters, verdict) -> str:
|
|
193
|
+
from diffprompt.models import Verdict
|
|
194
|
+
if not slices:
|
|
195
|
+
return "Not enough data. Try --n 40 or higher."
|
|
196
|
+
worst = [s for s in slices if s.verdict == Verdict.REGRESSION and s.depth == 1][:2]
|
|
197
|
+
best = [s for s in slices if s.verdict == Verdict.IMPROVEMENT and s.depth == 1][:2]
|
|
198
|
+
parts = []
|
|
199
|
+
if best:
|
|
200
|
+
parts.append(f"Safe to ship v2 for {', '.join(s.label for s in best)}.")
|
|
201
|
+
if worst:
|
|
202
|
+
parts.append(f"Keep v1 for {', '.join(s.label for s in worst)}.")
|
|
203
|
+
if clusters:
|
|
204
|
+
parts.append(f"Primary failure mode: {clusters[0].name} ({clusters[0].n} cases).")
|
|
205
|
+
return " ".join(parts) if parts else "Review key examples before shipping."
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _load_test_file(path: str) -> list:
|
|
209
|
+
import uuid
|
|
210
|
+
from diffprompt.models import TestCase, TestCategory
|
|
211
|
+
cases = []
|
|
212
|
+
with open(path, encoding="utf-8") as f:
|
|
213
|
+
for line in f:
|
|
214
|
+
line = line.strip()
|
|
215
|
+
if not line:
|
|
216
|
+
continue
|
|
217
|
+
data = json.loads(line)
|
|
218
|
+
inp = data.get("input") or data.get("text") or data.get("prompt", "")
|
|
219
|
+
if inp:
|
|
220
|
+
cases.append(TestCase(id=str(uuid.uuid4()), input=inp, category=TestCategory.TYPICAL))
|
|
221
|
+
return cases
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _save_report(report, path: str, fmt: str) -> None:
|
|
225
|
+
if fmt == "json" or path.endswith(".json"):
|
|
226
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
227
|
+
f.write(report.model_dump_json(indent=2))
|
|
228
|
+
elif fmt == "html" or path.endswith(".html"):
|
|
229
|
+
from diffprompt.output.exporter import render_html
|
|
230
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
231
|
+
f.write(render_html(report))
|
|
232
|
+
else:
|
|
233
|
+
lines = [
|
|
234
|
+
"diffprompt report", "=" * 40,
|
|
235
|
+
f"v1: {report.prompt_v1[:80]}", f"v2: {report.prompt_v2[:80]}",
|
|
236
|
+
f"score: {report.regression_score}/100",
|
|
237
|
+
f"verdict: {report.verdict.value.upper()}",
|
|
238
|
+
f"improved: {report.n_improved} regressed: {report.n_regressed} neutral: {report.n_neutral}",
|
|
239
|
+
"", f"recommendation: {report.recommendation}",
|
|
240
|
+
]
|
|
241
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
242
|
+
f.write("\n".join(lines))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clusters diff results by embedding judge reasons.
|
|
3
|
+
Uses HDBSCAN + UMAP. Returns named failure modes.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import numpy as np
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from diffprompt.models import DiffResult, Cluster, Verdict
|
|
9
|
+
from diffprompt.core.embedder import embed
|
|
10
|
+
|
|
11
|
+
# Below this count, clustering is meaningless — return everything as unclustered
|
|
12
|
+
_MIN_CLUSTER_INPUT = 10
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cluster_diffs(diffs: list[DiffResult]) -> tuple[list[Cluster], list[DiffResult]]:
|
|
16
|
+
"""
|
|
17
|
+
Cluster diffs by their judge reasons.
|
|
18
|
+
Returns (clusters, unclustered) where unclustered = HDBSCAN noise (label -1).
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
import hdbscan
|
|
22
|
+
import umap
|
|
23
|
+
except ImportError:
|
|
24
|
+
raise ImportError("Run: pip install hdbscan umap-learn")
|
|
25
|
+
|
|
26
|
+
if len(diffs) < _MIN_CLUSTER_INPUT:
|
|
27
|
+
return [], diffs
|
|
28
|
+
|
|
29
|
+
reasons = [d.reason for d in diffs]
|
|
30
|
+
embs = embed(reasons)
|
|
31
|
+
|
|
32
|
+
# UMAP: reduce to low-dim before HDBSCAN (better cluster quality)
|
|
33
|
+
n_components = min(5, len(diffs) - 2)
|
|
34
|
+
reducer = umap.UMAP(n_components=n_components, random_state=42, verbose=False)
|
|
35
|
+
reduced = reducer.fit_transform(embs)
|
|
36
|
+
|
|
37
|
+
clusterer = hdbscan.HDBSCAN(min_cluster_size=2, min_samples=1)
|
|
38
|
+
labels = clusterer.fit_predict(reduced)
|
|
39
|
+
|
|
40
|
+
# Compute centrality per point within its cluster
|
|
41
|
+
centrality_map = _compute_centrality(embs, labels)
|
|
42
|
+
|
|
43
|
+
groups: dict[int, list[int]] = defaultdict(list)
|
|
44
|
+
for i, label in enumerate(labels):
|
|
45
|
+
groups[label].append(i)
|
|
46
|
+
|
|
47
|
+
clusters = []
|
|
48
|
+
unclustered = []
|
|
49
|
+
|
|
50
|
+
for label, indices in groups.items():
|
|
51
|
+
group_diffs = [diffs[i] for i in indices]
|
|
52
|
+
|
|
53
|
+
# Update cluster metadata on each diff
|
|
54
|
+
for i_local, i_global in enumerate(indices):
|
|
55
|
+
diffs[i_global].cluster_label = label
|
|
56
|
+
diffs[i_global].cluster_centrality = centrality_map.get(i_global, 0.0)
|
|
57
|
+
|
|
58
|
+
if label == -1:
|
|
59
|
+
unclustered.extend(group_diffs)
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
cluster = Cluster(
|
|
63
|
+
label=label,
|
|
64
|
+
name=_name_cluster(label, group_diffs),
|
|
65
|
+
description=_describe_cluster(group_diffs),
|
|
66
|
+
n=len(group_diffs),
|
|
67
|
+
mean_similarity=float(np.mean([d.similarity for d in group_diffs])),
|
|
68
|
+
test_ids=[d.test_case.id for d in group_diffs],
|
|
69
|
+
)
|
|
70
|
+
clusters.append(cluster)
|
|
71
|
+
|
|
72
|
+
clusters.sort(key=lambda c: c.n, reverse=True)
|
|
73
|
+
return clusters, unclustered
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _compute_centrality(embs: np.ndarray, labels: np.ndarray) -> dict[int, float]:
|
|
77
|
+
"""
|
|
78
|
+
For each point, compute its mean cosine similarity to others in its cluster.
|
|
79
|
+
Returns a dict: index → centrality score (0-1).
|
|
80
|
+
"""
|
|
81
|
+
from sklearn.metrics.pairwise import cosine_similarity
|
|
82
|
+
|
|
83
|
+
centrality = {}
|
|
84
|
+
unique_labels = set(labels)
|
|
85
|
+
|
|
86
|
+
for label in unique_labels:
|
|
87
|
+
if label == -1:
|
|
88
|
+
continue
|
|
89
|
+
indices = [i for i, l in enumerate(labels) if l == label]
|
|
90
|
+
if len(indices) < 2:
|
|
91
|
+
for i in indices:
|
|
92
|
+
centrality[i] = 1.0
|
|
93
|
+
continue
|
|
94
|
+
cluster_embs = embs[indices]
|
|
95
|
+
sim_matrix = cosine_similarity(cluster_embs)
|
|
96
|
+
np.fill_diagonal(sim_matrix, 0)
|
|
97
|
+
mean_sims = sim_matrix.mean(axis=1)
|
|
98
|
+
for i_local, i_global in enumerate(indices):
|
|
99
|
+
centrality[i_global] = float(mean_sims[i_local])
|
|
100
|
+
|
|
101
|
+
return centrality
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _name_cluster(label: int, diffs: list[DiffResult]) -> str:
|
|
105
|
+
"""Generate a named failure mode from dominant reason keywords."""
|
|
106
|
+
reasons = " ".join(d.reason.lower() for d in diffs)
|
|
107
|
+
|
|
108
|
+
if any(w in reasons for w in ["brief", "short", "concise", "terse", "succinct"]):
|
|
109
|
+
return "BREVITY_GAIN" if _is_mostly_improvements(diffs) else "BREVITY_LOSS"
|
|
110
|
+
if any(w in reasons for w in ["context", "nuance", "detail", "omit", "missing", "incomplete"]):
|
|
111
|
+
return "CONTEXT_LOSS"
|
|
112
|
+
if any(w in reasons for w in ["refus", "declin", "won't", "cannot", "avoid"]):
|
|
113
|
+
return "REFUSAL_SHIFT"
|
|
114
|
+
if any(w in reasons for w in ["tone", "empathy", "warm", "cold", "formal", "harsh"]):
|
|
115
|
+
return "TONE_SHIFT"
|
|
116
|
+
if any(w in reasons for w in ["accura", "wrong", "incorrect", "error", "fact", "hallucin"]):
|
|
117
|
+
return "ACCURACY_CHANGE"
|
|
118
|
+
if any(w in reasons for w in ["verbos", "long", "padded", "unnecessar", "redundant"]):
|
|
119
|
+
return "VERBOSITY_GAIN"
|
|
120
|
+
if any(w in reasons for w in ["format", "structur", "bullet", "list", "markdown"]):
|
|
121
|
+
return "FORMAT_CHANGE"
|
|
122
|
+
return f"CLUSTER_{label}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _describe_cluster(diffs: list[DiffResult]) -> str:
|
|
126
|
+
return ". ".join(d.reason for d in diffs[:3])[:120]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_mostly_improvements(diffs: list[DiffResult]) -> bool:
|
|
130
|
+
return sum(1 for d in diffs if d.verdict == Verdict.IMPROVEMENT) > len(diffs) / 2
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Embedding + similarity layer.
|
|
3
|
+
All local, all free. No API calls.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import warnings
|
|
9
|
+
import numpy as np
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
# Suppress noisy warnings from HuggingFace / sentence-transformers / UMAP
|
|
13
|
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
|
14
|
+
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
|
15
|
+
logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
|
|
16
|
+
logging.getLogger("transformers").setLevel(logging.ERROR)
|
|
17
|
+
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
|
18
|
+
warnings.filterwarnings("ignore", message=".*n_jobs value.*overridden.*")
|
|
19
|
+
warnings.filterwarnings("ignore", message=".*unauthenticated.*")
|
|
20
|
+
warnings.filterwarnings("ignore", message=".*UNEXPECTED.*")
|
|
21
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="umap")
|
|
22
|
+
|
|
23
|
+
from sentence_transformers import SentenceTransformer
|
|
24
|
+
from sklearn.metrics.pairwise import cosine_similarity as sk_cosine
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
MODEL_NAME = "all-MiniLM-L6-v2"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache(maxsize=1)
|
|
31
|
+
def get_embedder() -> SentenceTransformer:
|
|
32
|
+
"""Lazy-load embedder. Cached so it only loads once per session."""
|
|
33
|
+
# Silence the load report printed to stdout by newer sentence-transformers
|
|
34
|
+
import io, contextlib
|
|
35
|
+
f = io.StringIO()
|
|
36
|
+
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
|
|
37
|
+
model = SentenceTransformer(MODEL_NAME)
|
|
38
|
+
return model
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def embed(texts: list[str]) -> np.ndarray:
|
|
42
|
+
return get_embedder().encode(texts, show_progress_bar=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def similarity(text_a: str, text_b: str) -> float:
|
|
46
|
+
"""Cosine similarity between two texts. Returns float 0-1."""
|
|
47
|
+
embs = embed([text_a, text_b])
|
|
48
|
+
score = sk_cosine([embs[0]], [embs[1]])[0][0]
|
|
49
|
+
return float(np.clip(score, 0, 1))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def batch_similarity(pairs: list[tuple[str, str]]) -> list[float]:
|
|
53
|
+
"""
|
|
54
|
+
Efficient batch similarity for many pairs.
|
|
55
|
+
Embeds all texts in one pass instead of N passes.
|
|
56
|
+
"""
|
|
57
|
+
all_texts = [t for pair in pairs for t in pair]
|
|
58
|
+
all_embs = embed(all_texts)
|
|
59
|
+
scores = []
|
|
60
|
+
for i in range(0, len(all_embs), 2):
|
|
61
|
+
score = sk_cosine([all_embs[i]], [all_embs[i + 1]])[0][0]
|
|
62
|
+
scores.append(float(np.clip(score, 0, 1)))
|
|
63
|
+
return scores
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test case generation using taxonomy-based prompting.
|
|
3
|
+
Four buckets: typical, boundary, adversarial, format.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from diffprompt.models import TestCase, TestCategory
|
|
12
|
+
from diffprompt.models.cascade import call_cascade
|
|
13
|
+
from diffprompt.core.ontology import Ontology
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TAXONOMY_PROMPTS = {
|
|
17
|
+
TestCategory.TYPICAL: """Generate {n} realistic inputs a real user would send to this prompt.
|
|
18
|
+
These should reflect actual everyday usage — diverse topics, normal phrasing.
|
|
19
|
+
Prompt: {prompt}
|
|
20
|
+
Return ONLY a JSON array of strings. No explanation.""",
|
|
21
|
+
|
|
22
|
+
TestCategory.BOUNDARY: """Generate {n} inputs at the edges of what this prompt handles.
|
|
23
|
+
Too long, too short, tangentially related, slightly out of scope.
|
|
24
|
+
The prompt should handle these but might struggle.
|
|
25
|
+
Prompt: {prompt}
|
|
26
|
+
Return ONLY a JSON array of strings. No explanation.""",
|
|
27
|
+
|
|
28
|
+
TestCategory.ADVERSARIAL: """Generate {n} inputs designed to expose inconsistencies or failures.
|
|
29
|
+
Use ambiguous phrasing, contradictory requirements, edge cases, trick questions.
|
|
30
|
+
Make the prompt work hard.
|
|
31
|
+
Prompt: {prompt}
|
|
32
|
+
Return ONLY a JSON array of strings. No explanation.""",
|
|
33
|
+
|
|
34
|
+
TestCategory.FORMAT: """Generate {n} inputs with unusual formatting.
|
|
35
|
+
Try: ALL CAPS, no punctuation, mixed languages, extremely long sentences,
|
|
36
|
+
bullet points as input, emojis, very short (1-2 words), JSON-formatted input.
|
|
37
|
+
Prompt: {prompt}
|
|
38
|
+
Return ONLY a JSON array of strings. No explanation.""",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Distribution: 45% typical, 35% adversarial, 10% boundary, 10% format
|
|
42
|
+
DISTRIBUTION = {
|
|
43
|
+
TestCategory.TYPICAL: 0.45,
|
|
44
|
+
TestCategory.ADVERSARIAL: 0.35,
|
|
45
|
+
TestCategory.BOUNDARY: 0.10,
|
|
46
|
+
TestCategory.FORMAT: 0.10,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def generate_test_cases(
|
|
51
|
+
prompt: str,
|
|
52
|
+
n: int = 40,
|
|
53
|
+
ontology: Optional[Ontology] = None,
|
|
54
|
+
local_only: bool = False,
|
|
55
|
+
) -> list[TestCase]:
|
|
56
|
+
"""Generate n test cases across all taxonomy buckets."""
|
|
57
|
+
test_cases: list[TestCase] = []
|
|
58
|
+
|
|
59
|
+
for category, fraction in DISTRIBUTION.items():
|
|
60
|
+
count = max(2, round(n * fraction))
|
|
61
|
+
|
|
62
|
+
inputs = await _generate_bucket(prompt, category, count, local_only)
|
|
63
|
+
|
|
64
|
+
for inp in inputs[:count]:
|
|
65
|
+
test_cases.append(TestCase(
|
|
66
|
+
id=str(uuid.uuid4()),
|
|
67
|
+
input=inp,
|
|
68
|
+
category=category,
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
# BUG FIX: return was previously inside the for loop,
|
|
72
|
+
# causing only the first bucket (typical) to ever be generated.
|
|
73
|
+
return test_cases
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _generate_bucket(
|
|
77
|
+
prompt: str,
|
|
78
|
+
category: TestCategory,
|
|
79
|
+
count: int,
|
|
80
|
+
local_only: bool,
|
|
81
|
+
) -> list[str]:
|
|
82
|
+
"""Generate one bucket of test cases with up to 2 retries on parse failure."""
|
|
83
|
+
for attempt in range(2):
|
|
84
|
+
raw, _ = await call_cascade(
|
|
85
|
+
TAXONOMY_PROMPTS[category].format(n=count, prompt=prompt),
|
|
86
|
+
local_only=local_only,
|
|
87
|
+
)
|
|
88
|
+
clean = re.sub(r"```(?:json)?|```", "", raw).strip()
|
|
89
|
+
match = re.search(r'\[.*\]', clean, re.DOTALL)
|
|
90
|
+
if match:
|
|
91
|
+
clean = match.group(0)
|
|
92
|
+
try:
|
|
93
|
+
parsed = json.loads(clean)
|
|
94
|
+
if isinstance(parsed, list):
|
|
95
|
+
return [str(x) for x in parsed if x]
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
if attempt == 1:
|
|
98
|
+
return [] # give up after 2 attempts
|
|
99
|
+
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def diversity_score(test_cases: list[TestCase]) -> float:
|
|
104
|
+
"""
|
|
105
|
+
Compute diversity score for the test suite.
|
|
106
|
+
1 - mean pairwise similarity. Higher = more diverse.
|
|
107
|
+
Uses the shared embedder from diffprompt.core.embedder to avoid
|
|
108
|
+
loading a second model instance.
|
|
109
|
+
"""
|
|
110
|
+
from diffprompt.core.embedder import embed
|
|
111
|
+
from sklearn.metrics.pairwise import cosine_similarity
|
|
112
|
+
import numpy as np
|
|
113
|
+
|
|
114
|
+
if len(test_cases) < 2:
|
|
115
|
+
return 1.0
|
|
116
|
+
|
|
117
|
+
inputs = [tc.input for tc in test_cases]
|
|
118
|
+
embs = embed(inputs)
|
|
119
|
+
sim_matrix = cosine_similarity(embs)
|
|
120
|
+
np.fill_diagonal(sim_matrix, 0)
|
|
121
|
+
n = len(inputs)
|
|
122
|
+
mean_sim = sim_matrix.sum() / (n * (n - 1))
|
|
123
|
+
return float(1 - mean_sim)
|
diffprompt/core/judge.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM-as-judge layer.
|
|
3
|
+
Determines verdict (improvement/regression/neutral) + reason per diff.
|
|
4
|
+
Escalates to larger Groq model when confidence is low.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from diffprompt.models import Verdict, DiffResult, TestCase
|
|
10
|
+
from diffprompt.models.cascade import call_cascade, call_groq_only
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
JUDGE_PROMPT = """You are evaluating whether a prompt change improved or worsened an output.
|
|
14
|
+
|
|
15
|
+
Input: {input}
|
|
16
|
+
|
|
17
|
+
Output V1:
|
|
18
|
+
{v1}
|
|
19
|
+
|
|
20
|
+
Output V2:
|
|
21
|
+
{v2}
|
|
22
|
+
|
|
23
|
+
Which output better serves this input? Consider: accuracy, completeness, tone, and appropriateness.
|
|
24
|
+
Be specific. One sentence reason.
|
|
25
|
+
|
|
26
|
+
Respond ONLY with valid JSON (no markdown fences):
|
|
27
|
+
{{"verdict": "improvement|regression|neutral", "reason": "one sentence explaining the key difference", "confidence": 0.0}}
|
|
28
|
+
|
|
29
|
+
confidence should reflect how clear-cut the verdict is (1.0 = obvious, 0.5 = ambiguous)."""
|
|
30
|
+
|
|
31
|
+
CONFIDENCE_THRESHOLD = 0.65 # escalate to larger model below this
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def judge_single(
|
|
35
|
+
test_case: TestCase,
|
|
36
|
+
v1_output: str,
|
|
37
|
+
v2_output: str,
|
|
38
|
+
similarity: float,
|
|
39
|
+
local_only: bool = False,
|
|
40
|
+
) -> tuple[Verdict, str, float]:
|
|
41
|
+
"""
|
|
42
|
+
Returns (verdict, reason, confidence).
|
|
43
|
+
Auto-escalates to Groq 70B if confidence is low.
|
|
44
|
+
"""
|
|
45
|
+
# Fast path: very high similarity → neutral, skip judge entirely
|
|
46
|
+
if similarity > 0.95:
|
|
47
|
+
return Verdict.NEUTRAL, "outputs are semantically identical", 1.0
|
|
48
|
+
|
|
49
|
+
prompt = JUDGE_PROMPT.format(
|
|
50
|
+
input=test_case.input,
|
|
51
|
+
v1=v1_output[:800], # truncate to avoid token waste
|
|
52
|
+
v2=v2_output[:800],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
raw, _ = await call_cascade(prompt, local_only=local_only)
|
|
56
|
+
verdict, reason, confidence = _parse_judge_response(raw)
|
|
57
|
+
|
|
58
|
+
# Escalate if confidence is low and we're not local-only.
|
|
59
|
+
# BUG FIX: previously called call_cascade() again which would re-try
|
|
60
|
+
# Ollama first — defeating the purpose of escalation. Now calls
|
|
61
|
+
# call_groq_only() directly to guarantee a larger model.
|
|
62
|
+
if confidence < CONFIDENCE_THRESHOLD and not local_only:
|
|
63
|
+
escalated = await call_groq_only(
|
|
64
|
+
prompt,
|
|
65
|
+
groq_model="llama-3.3-70b-versatile",
|
|
66
|
+
)
|
|
67
|
+
if escalated:
|
|
68
|
+
verdict, reason, confidence = _parse_judge_response(escalated)
|
|
69
|
+
|
|
70
|
+
return verdict, reason, confidence
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_judge_response(raw: str) -> tuple[Verdict, str, float]:
|
|
74
|
+
"""Parse judge JSON response with fallback."""
|
|
75
|
+
try:
|
|
76
|
+
clean = re.sub(r"```(?:json)?|```", "", raw).strip()
|
|
77
|
+
# Handle responses where the model adds text before/after JSON
|
|
78
|
+
match = re.search(r'\{.*\}', clean, re.DOTALL)
|
|
79
|
+
if match:
|
|
80
|
+
clean = match.group(0)
|
|
81
|
+
data = json.loads(clean)
|
|
82
|
+
verdict_str = data.get("verdict", "neutral").lower().strip()
|
|
83
|
+
# Normalize common variants
|
|
84
|
+
if verdict_str in ("improve", "improved", "better"):
|
|
85
|
+
verdict_str = "improvement"
|
|
86
|
+
elif verdict_str in ("regress", "regressed", "worse"):
|
|
87
|
+
verdict_str = "regression"
|
|
88
|
+
verdict = Verdict(verdict_str) if verdict_str in Verdict._value2member_map_ else Verdict.NEUTRAL
|
|
89
|
+
reason = str(data.get("reason", "no reason provided"))[:300]
|
|
90
|
+
confidence = float(data.get("confidence", 0.5))
|
|
91
|
+
confidence = max(0.0, min(1.0, confidence))
|
|
92
|
+
return verdict, reason, confidence
|
|
93
|
+
except Exception:
|
|
94
|
+
return Verdict.NEUTRAL, "judge parse error — raw response was not valid JSON", 0.0
|