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 ADDED
@@ -0,0 +1,2 @@
1
+ """diffprompt — git diff for your prompt's behavior"""
2
+ __version__ = "0.1.0"
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)
@@ -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