llm-search-eval 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.
- llm_search_eval/__init__.py +33 -0
- llm_search_eval/__main__.py +4 -0
- llm_search_eval/agreement.py +261 -0
- llm_search_eval/assets/dashboard.css +51 -0
- llm_search_eval/assets/dashboard.js +286 -0
- llm_search_eval/cli.py +557 -0
- llm_search_eval/connectors/__init__.py +24 -0
- llm_search_eval/connectors/algolia.py +117 -0
- llm_search_eval/connectors/base.py +150 -0
- llm_search_eval/connectors/elasticsearch.py +165 -0
- llm_search_eval/errors.py +23 -0
- llm_search_eval/evaluate.py +606 -0
- llm_search_eval/gate.py +283 -0
- llm_search_eval/html_report.py +116 -0
- llm_search_eval/io.py +342 -0
- llm_search_eval/judges/__init__.py +24 -0
- llm_search_eval/judges/base.py +132 -0
- llm_search_eval/judges/cache.py +84 -0
- llm_search_eval/judges/mock.py +80 -0
- llm_search_eval/judges/openai_compat.py +202 -0
- llm_search_eval/judges/panel.py +134 -0
- llm_search_eval/judges/prompts.py +50 -0
- llm_search_eval/metrics.py +171 -0
- llm_search_eval/models.py +85 -0
- llm_search_eval/py.typed +0 -0
- llm_search_eval/report.py +358 -0
- llm_search_eval/text.py +79 -0
- llm_search_eval/usage.py +269 -0
- llm_search_eval-0.2.0.dist-info/METADATA +440 -0
- llm_search_eval-0.2.0.dist-info/RECORD +33 -0
- llm_search_eval-0.2.0.dist-info/WHEEL +4 -0
- llm_search_eval-0.2.0.dist-info/entry_points.txt +2 -0
- llm_search_eval-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Evaluate product search quality with IR metrics, LLM judges, and CI quality gates."""
|
|
2
|
+
|
|
3
|
+
from llm_search_eval.evaluate import EvaluationConfig, EvaluationReport, evaluate
|
|
4
|
+
from llm_search_eval.gate import GateResult, check_report
|
|
5
|
+
from llm_search_eval.html_report import render_html
|
|
6
|
+
from llm_search_eval.judges import Judge, Judgment, create_judge, register_judge
|
|
7
|
+
from llm_search_eval.judges.panel import JudgePanel
|
|
8
|
+
from llm_search_eval.models import Dataset, Product, Query, Run
|
|
9
|
+
from llm_search_eval.usage import Budget, CostTracker, PriceTable
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Budget",
|
|
15
|
+
"CostTracker",
|
|
16
|
+
"Dataset",
|
|
17
|
+
"EvaluationConfig",
|
|
18
|
+
"EvaluationReport",
|
|
19
|
+
"GateResult",
|
|
20
|
+
"Judge",
|
|
21
|
+
"JudgePanel",
|
|
22
|
+
"Judgment",
|
|
23
|
+
"PriceTable",
|
|
24
|
+
"Product",
|
|
25
|
+
"Query",
|
|
26
|
+
"Run",
|
|
27
|
+
"__version__",
|
|
28
|
+
"check_report",
|
|
29
|
+
"create_judge",
|
|
30
|
+
"evaluate",
|
|
31
|
+
"register_judge",
|
|
32
|
+
"render_html",
|
|
33
|
+
]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Agreement statistics for graded relevance labels.
|
|
2
|
+
|
|
3
|
+
Used to check whether several judges agree with each other (inter-rater
|
|
4
|
+
reliability) and with human gold labels (calibration). All functions take plain
|
|
5
|
+
integer grades, so they also work for labels that did not come from this package.
|
|
6
|
+
|
|
7
|
+
Undefined statistics, for example kappa when every label is identical, are
|
|
8
|
+
returned as ``None`` instead of an arbitrary number.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import itertools
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from llm_search_eval.models import MAX_RELEVANCE, MIN_RELEVANCE
|
|
19
|
+
|
|
20
|
+
DEFAULT_CATEGORIES: tuple[int, ...] = tuple(range(MIN_RELEVANCE, MAX_RELEVANCE + 1))
|
|
21
|
+
WEIGHTS = (None, "linear", "quadratic")
|
|
22
|
+
LEVELS = ("nominal", "ordinal", "interval")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_pairs(a: Sequence[int], b: Sequence[int]) -> None:
|
|
26
|
+
if len(a) != len(b):
|
|
27
|
+
raise ValueError("label sequences must have equal length")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def percent_agreement(a: Sequence[int], b: Sequence[int]) -> float | None:
|
|
31
|
+
"""Share of items where both raters chose the same grade."""
|
|
32
|
+
_check_pairs(a, b)
|
|
33
|
+
if not a:
|
|
34
|
+
return None
|
|
35
|
+
return sum(1 for x, y in zip(a, b, strict=True) if x == y) / len(a)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def within_one(a: Sequence[int], b: Sequence[int]) -> float | None:
|
|
39
|
+
"""Share of items where the grades differ by at most one step."""
|
|
40
|
+
_check_pairs(a, b)
|
|
41
|
+
if not a:
|
|
42
|
+
return None
|
|
43
|
+
return sum(1 for x, y in zip(a, b, strict=True) if abs(x - y) <= 1) / len(a)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def confusion_matrix(
|
|
47
|
+
a: Sequence[int], b: Sequence[int], categories: Sequence[int] = DEFAULT_CATEGORIES
|
|
48
|
+
) -> list[list[int]]:
|
|
49
|
+
"""Counts with rows for ``a`` and columns for ``b``, in ``categories`` order."""
|
|
50
|
+
_check_pairs(a, b)
|
|
51
|
+
index = {c: i for i, c in enumerate(categories)}
|
|
52
|
+
matrix = [[0] * len(categories) for _ in categories]
|
|
53
|
+
for x, y in zip(a, b, strict=True):
|
|
54
|
+
if x not in index or y not in index:
|
|
55
|
+
raise ValueError(f"grade outside categories {list(categories)}: {x}, {y}")
|
|
56
|
+
matrix[index[x]][index[y]] += 1
|
|
57
|
+
return matrix
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _weight(i: int, j: int, size: int, weights: str | None) -> float:
|
|
61
|
+
if weights is None:
|
|
62
|
+
return 0.0 if i == j else 1.0
|
|
63
|
+
span = max(1, size - 1)
|
|
64
|
+
if weights == "linear":
|
|
65
|
+
return abs(i - j) / span
|
|
66
|
+
if weights == "quadratic":
|
|
67
|
+
return ((i - j) / span) ** 2
|
|
68
|
+
raise ValueError(f"unknown weights {weights!r}; use None, 'linear', or 'quadratic'")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cohen_kappa(
|
|
72
|
+
a: Sequence[int],
|
|
73
|
+
b: Sequence[int],
|
|
74
|
+
weights: str | None = None,
|
|
75
|
+
categories: Sequence[int] = DEFAULT_CATEGORIES,
|
|
76
|
+
) -> float | None:
|
|
77
|
+
"""Cohen's kappa for two raters, optionally linear or quadratic weighted.
|
|
78
|
+
|
|
79
|
+
Quadratic weighting suits ordinal relevance grades because a 0 versus 3
|
|
80
|
+
disagreement is penalized more than 2 versus 3.
|
|
81
|
+
"""
|
|
82
|
+
matrix = confusion_matrix(a, b, categories)
|
|
83
|
+
n = len(a)
|
|
84
|
+
if n == 0:
|
|
85
|
+
return None
|
|
86
|
+
size = len(categories)
|
|
87
|
+
rows = [sum(r) for r in matrix]
|
|
88
|
+
cols = [sum(matrix[i][j] for i in range(size)) for j in range(size)]
|
|
89
|
+
observed = expected = 0.0
|
|
90
|
+
for i in range(size):
|
|
91
|
+
for j in range(size):
|
|
92
|
+
w = _weight(i, j, size, weights)
|
|
93
|
+
observed += w * matrix[i][j] / n
|
|
94
|
+
expected += w * rows[i] * cols[j] / (n * n)
|
|
95
|
+
if expected == 0:
|
|
96
|
+
return None
|
|
97
|
+
return 1.0 - observed / expected
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fleiss_kappa(
|
|
101
|
+
items: Sequence[Sequence[int]], categories: Sequence[int] = DEFAULT_CATEGORIES
|
|
102
|
+
) -> float | None:
|
|
103
|
+
"""Fleiss' kappa for any fixed number of raters per item.
|
|
104
|
+
|
|
105
|
+
``items`` holds one sequence of grades per rated item. Every item needs the
|
|
106
|
+
same number of ratings (at least two); use :func:`krippendorff_alpha` when
|
|
107
|
+
some ratings are missing.
|
|
108
|
+
"""
|
|
109
|
+
if not items:
|
|
110
|
+
return None
|
|
111
|
+
raters = len(items[0])
|
|
112
|
+
if raters < 2 or any(len(item) != raters for item in items):
|
|
113
|
+
raise ValueError("every item needs the same number of ratings, at least two")
|
|
114
|
+
index = {c: i for i, c in enumerate(categories)}
|
|
115
|
+
totals = [0] * len(categories)
|
|
116
|
+
agreement = 0.0
|
|
117
|
+
for item in items:
|
|
118
|
+
counts = [0] * len(categories)
|
|
119
|
+
for grade in item:
|
|
120
|
+
if grade not in index:
|
|
121
|
+
raise ValueError(f"grade {grade} outside categories {list(categories)}")
|
|
122
|
+
counts[index[grade]] += 1
|
|
123
|
+
for i, c in enumerate(counts):
|
|
124
|
+
totals[i] += c
|
|
125
|
+
agreement += (sum(c * c for c in counts) - raters) / (raters * (raters - 1))
|
|
126
|
+
p_bar = agreement / len(items)
|
|
127
|
+
shares = [t / (len(items) * raters) for t in totals]
|
|
128
|
+
p_e = sum(p * p for p in shares)
|
|
129
|
+
if p_e >= 1.0:
|
|
130
|
+
return None
|
|
131
|
+
return (p_bar - p_e) / (1.0 - p_e)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def krippendorff_alpha(
|
|
135
|
+
units: Sequence[Sequence[int | None]], level: str = "ordinal"
|
|
136
|
+
) -> float | None:
|
|
137
|
+
"""Krippendorff's alpha with missing ratings allowed.
|
|
138
|
+
|
|
139
|
+
``units`` holds one sequence per item with each rater's grade or ``None``.
|
|
140
|
+
Items with fewer than two grades are ignored. ``level`` is ``nominal``,
|
|
141
|
+
``ordinal``, or ``interval``.
|
|
142
|
+
"""
|
|
143
|
+
if level not in LEVELS:
|
|
144
|
+
raise ValueError(f"unknown level {level!r}; use one of: {', '.join(LEVELS)}")
|
|
145
|
+
coincidence: Counter[tuple[int, int]] = Counter()
|
|
146
|
+
pairable = 0
|
|
147
|
+
for unit in units:
|
|
148
|
+
values = [v for v in unit if v is not None]
|
|
149
|
+
m = len(values)
|
|
150
|
+
if m < 2:
|
|
151
|
+
continue
|
|
152
|
+
pairable += m
|
|
153
|
+
for i, j in itertools.permutations(range(m), 2):
|
|
154
|
+
coincidence[(values[i], values[j])] += 1.0 / (m - 1) # type: ignore[assignment]
|
|
155
|
+
if pairable < 2:
|
|
156
|
+
return None
|
|
157
|
+
values_sorted = sorted({c for pair in coincidence for c in pair})
|
|
158
|
+
marginals = {c: sum(coincidence[(c, k)] for k in values_sorted) for c in values_sorted}
|
|
159
|
+
n = sum(marginals.values())
|
|
160
|
+
|
|
161
|
+
def delta(c: int, k: int) -> float:
|
|
162
|
+
if level == "nominal":
|
|
163
|
+
return 0.0 if c == k else 1.0
|
|
164
|
+
if level == "interval":
|
|
165
|
+
return float((c - k) ** 2)
|
|
166
|
+
low, high = min(c, k), max(c, k)
|
|
167
|
+
between = sum(marginals[g] for g in values_sorted if low <= g <= high)
|
|
168
|
+
return (between - (marginals[c] + marginals[k]) / 2) ** 2
|
|
169
|
+
|
|
170
|
+
observed = sum(coincidence[(c, k)] * delta(c, k) for c in values_sorted for k in values_sorted)
|
|
171
|
+
expected = sum(
|
|
172
|
+
marginals[c] * marginals[k] * delta(c, k) for c in values_sorted for k in values_sorted
|
|
173
|
+
)
|
|
174
|
+
if expected == 0:
|
|
175
|
+
return None
|
|
176
|
+
return 1.0 - (n - 1) * observed / expected
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def pairwise_agreement(
|
|
180
|
+
ratings: Mapping[str, Sequence[int | None]],
|
|
181
|
+
) -> list[dict[str, Any]]:
|
|
182
|
+
"""Cohen's kappa (plain and quadratic) for every pair of raters.
|
|
183
|
+
|
|
184
|
+
``ratings`` maps rater name to grades aligned by item; ``None`` marks a
|
|
185
|
+
missing grade and excludes the item for that pair only.
|
|
186
|
+
"""
|
|
187
|
+
out = []
|
|
188
|
+
for first, second in itertools.combinations(ratings, 2):
|
|
189
|
+
pairs = [
|
|
190
|
+
(x, y)
|
|
191
|
+
for x, y in zip(ratings[first], ratings[second], strict=True)
|
|
192
|
+
if x is not None and y is not None
|
|
193
|
+
]
|
|
194
|
+
a = [x for x, _ in pairs]
|
|
195
|
+
b = [y for _, y in pairs]
|
|
196
|
+
out.append(
|
|
197
|
+
{
|
|
198
|
+
"a": first,
|
|
199
|
+
"b": second,
|
|
200
|
+
"n": len(pairs),
|
|
201
|
+
"percent_agreement": percent_agreement(a, b),
|
|
202
|
+
"cohen_kappa": cohen_kappa(a, b),
|
|
203
|
+
"weighted_kappa": cohen_kappa(a, b, weights="quadratic"),
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def inter_judge_agreement(ratings: Mapping[str, Sequence[int | None]]) -> dict[str, Any]:
|
|
210
|
+
"""Summary of agreement between several raters over the same items."""
|
|
211
|
+
names = list(ratings)
|
|
212
|
+
if len(names) < 2:
|
|
213
|
+
raise ValueError("agreement needs at least two raters")
|
|
214
|
+
length = len(ratings[names[0]])
|
|
215
|
+
if any(len(ratings[name]) != length for name in names):
|
|
216
|
+
raise ValueError("every rater needs one entry per item")
|
|
217
|
+
units = [[ratings[name][i] for name in names] for i in range(length)]
|
|
218
|
+
complete = [[v for v in unit if v is not None] for unit in units]
|
|
219
|
+
complete = [unit for unit in complete if len(unit) == len(names)]
|
|
220
|
+
unanimous = sum(1 for unit in complete if len(set(unit)) == 1)
|
|
221
|
+
return {
|
|
222
|
+
"raters": names,
|
|
223
|
+
"items": length,
|
|
224
|
+
"complete_items": len(complete),
|
|
225
|
+
"unanimous": None if not complete else unanimous / len(complete),
|
|
226
|
+
"fleiss_kappa": fleiss_kappa(complete) if complete else None,
|
|
227
|
+
"krippendorff_alpha": {
|
|
228
|
+
"nominal": krippendorff_alpha(units, "nominal"),
|
|
229
|
+
"ordinal": krippendorff_alpha(units, "ordinal"),
|
|
230
|
+
"interval": krippendorff_alpha(units, "interval"),
|
|
231
|
+
},
|
|
232
|
+
"pairwise": pairwise_agreement(ratings),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def gold_agreement(
|
|
237
|
+
predicted: Sequence[int], gold: Sequence[int], threshold: int = 2
|
|
238
|
+
) -> dict[str, Any]:
|
|
239
|
+
"""Compare one rater with gold labels: exact, within one, binary, and kappa."""
|
|
240
|
+
_check_pairs(predicted, gold)
|
|
241
|
+
binary_p = [int(x >= threshold) for x in predicted]
|
|
242
|
+
binary_g = [int(x >= threshold) for x in gold]
|
|
243
|
+
n = len(gold)
|
|
244
|
+
return {
|
|
245
|
+
"n": n,
|
|
246
|
+
"exact": percent_agreement(predicted, gold),
|
|
247
|
+
"within_one": within_one(predicted, gold),
|
|
248
|
+
"binary_agreement": percent_agreement(binary_p, binary_g),
|
|
249
|
+
"cohen_kappa": cohen_kappa(predicted, gold),
|
|
250
|
+
"weighted_kappa": cohen_kappa(predicted, gold, weights="quadratic"),
|
|
251
|
+
"binary_kappa": cohen_kappa(binary_p, binary_g, categories=(0, 1)),
|
|
252
|
+
"mean_bias": (
|
|
253
|
+
None if not n else sum(p - g for p, g in zip(predicted, gold, strict=True)) / n
|
|
254
|
+
),
|
|
255
|
+
"confusion": {
|
|
256
|
+
"rows": "gold",
|
|
257
|
+
"columns": "judge",
|
|
258
|
+
"categories": list(DEFAULT_CATEGORIES),
|
|
259
|
+
"matrix": confusion_matrix(gold, predicted),
|
|
260
|
+
},
|
|
261
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f6f7f9; --card: #fff; --text: #1d2330; --muted: #5f6b7a; --line: #e2e6ec;
|
|
3
|
+
--accent: #2563eb; --good: #15803d; --bad: #b91c1c; --warn: #b45309;
|
|
4
|
+
--g0: #e5e7eb; --g1: #fde68a; --g2: #93c5fd; --g3: #86efac;
|
|
5
|
+
}
|
|
6
|
+
@media (prefers-color-scheme: dark) {
|
|
7
|
+
:root {
|
|
8
|
+
--bg: #11151c; --card: #1a2029; --text: #e6e9ef; --muted: #9aa5b4; --line: #2b3441;
|
|
9
|
+
--accent: #60a5fa; --good: #4ade80; --bad: #f87171; --warn: #fbbf24;
|
|
10
|
+
--g0: #374151; --g1: #854d0e; --g2: #1e40af; --g3: #166534;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin: 0; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, Vazirmatn, Tahoma, sans-serif; background: var(--bg); color: var(--text); }
|
|
15
|
+
header { padding: 24px 32px 8px; }
|
|
16
|
+
header h1 { margin: 0 0 4px; font-size: 22px; }
|
|
17
|
+
header .meta { color: var(--muted); }
|
|
18
|
+
nav { position: sticky; top: 0; z-index: 2; background: var(--bg); border-bottom: 1px solid var(--line); padding: 8px 32px; display: flex; gap: 16px; flex-wrap: wrap; }
|
|
19
|
+
nav a { color: var(--accent); text-decoration: none; font-weight: 500; }
|
|
20
|
+
main { padding: 16px 32px 48px; display: grid; gap: 20px; }
|
|
21
|
+
section { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; }
|
|
22
|
+
section h2 { margin: 0 0 12px; font-size: 17px; }
|
|
23
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
|
24
|
+
.card { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
|
|
25
|
+
.card .v { font-size: 20px; font-weight: 600; }
|
|
26
|
+
.card .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
|
27
|
+
table { border-collapse: collapse; width: 100%; }
|
|
28
|
+
th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
29
|
+
th { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
|
|
30
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
31
|
+
.best { font-weight: 700; color: var(--accent); }
|
|
32
|
+
.pos { color: var(--good); } .neg { color: var(--bad); } .muted { color: var(--muted); }
|
|
33
|
+
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
|
34
|
+
select, input[type=search] { font: inherit; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--text); }
|
|
35
|
+
input[type=search] { min-width: 260px; }
|
|
36
|
+
svg text { fill: var(--text); font-size: 11px; }
|
|
37
|
+
svg .axis { stroke: var(--line); }
|
|
38
|
+
.bar { fill: var(--accent); }
|
|
39
|
+
.bar.pos { fill: var(--good); } .bar.neg { fill: var(--bad); }
|
|
40
|
+
.split { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; }
|
|
41
|
+
.clickable tbody tr:hover, tr.selected { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
|
42
|
+
.lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin-top: 12px; }
|
|
43
|
+
.list h3 { margin: 0 0 6px; font-size: 14px; }
|
|
44
|
+
.list ol { margin: 0; padding-left: 26px; }
|
|
45
|
+
.list li { margin: 0 0 6px; }
|
|
46
|
+
.badge { display: inline-block; min-width: 20px; text-align: center; border-radius: 4px; padding: 0 5px; margin-right: 6px; font-weight: 600; font-size: 12px; color: #111; }
|
|
47
|
+
.g0 { background: var(--g0); } .g1 { background: var(--g1); } .g2 { background: var(--g2); } .g3 { background: var(--g3); } .gx { background: transparent; border: 1px dashed var(--muted); color: var(--muted); }
|
|
48
|
+
.src { color: var(--muted); font-size: 12px; }
|
|
49
|
+
.reason { color: var(--muted); font-size: 12px; display: block; }
|
|
50
|
+
.note { color: var(--muted); font-size: 13px; }
|
|
51
|
+
footer { color: var(--muted); padding: 0 32px 32px; font-size: 12px; }
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
var DATA = JSON.parse(document.getElementById("report-data").textContent);
|
|
4
|
+
var SVG = "http://www.w3.org/2000/svg";
|
|
5
|
+
var cfg = DATA.config;
|
|
6
|
+
var primary = cfg.primary_metric;
|
|
7
|
+
var systems = DATA.systems;
|
|
8
|
+
var names = systems.map(function (s) { return s.name; });
|
|
9
|
+
var details = DATA.details || null;
|
|
10
|
+
|
|
11
|
+
function $(id) { return document.getElementById(id); }
|
|
12
|
+
function el(tag, attrs, children) {
|
|
13
|
+
var node = document.createElement(tag);
|
|
14
|
+
if (attrs) {
|
|
15
|
+
Object.keys(attrs).forEach(function (key) {
|
|
16
|
+
if (key === "text") node.textContent = attrs[key];
|
|
17
|
+
else if (key === "class") node.className = attrs[key];
|
|
18
|
+
else node.setAttribute(key, attrs[key]);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
(children || []).forEach(function (child) { if (child) node.appendChild(child); });
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
function svg(tag, attrs) {
|
|
25
|
+
var node = document.createElementNS(SVG, tag);
|
|
26
|
+
Object.keys(attrs || {}).forEach(function (key) { node.setAttribute(key, attrs[key]); });
|
|
27
|
+
return node;
|
|
28
|
+
}
|
|
29
|
+
function fmt(v, d) { return v === null || v === undefined ? "n/a" : Number(v).toFixed(d === undefined ? 4 : d); }
|
|
30
|
+
function signed(v) { return v === null || v === undefined ? "n/a" : (v >= 0 ? "+" : "") + Number(v).toFixed(4); }
|
|
31
|
+
function pct(v) { return v === null || v === undefined ? "n/a" : (v >= 0 ? "+" : "") + (v * 100).toFixed(1) + "%"; }
|
|
32
|
+
function money(v) { return v === null || v === undefined ? "unknown" : "$" + Number(v).toFixed(4); }
|
|
33
|
+
function td(text, cls) { return el("td", { text: text, class: cls || "" }); }
|
|
34
|
+
function th(text, cls) { return el("th", { text: text, class: cls || "" }); }
|
|
35
|
+
function table(headers, rows, numeric) {
|
|
36
|
+
var head = el("tr", null, headers.map(function (h, i) { return th(h, i && numeric !== false ? "num" : ""); }));
|
|
37
|
+
var body = el("tbody", null, rows);
|
|
38
|
+
return el("table", null, [el("thead", null, [head]), body]);
|
|
39
|
+
}
|
|
40
|
+
function option(value, label, selected) {
|
|
41
|
+
var o = el("option", { value: value, text: label || value });
|
|
42
|
+
if (selected) o.selected = true;
|
|
43
|
+
return o;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function barChart(container, items, opts) {
|
|
47
|
+
opts = opts || {};
|
|
48
|
+
container.textContent = "";
|
|
49
|
+
var width = Math.max(320, container.clientWidth || 640);
|
|
50
|
+
var rowH = 22, labelW = opts.labelWidth || 140, valueW = 70;
|
|
51
|
+
var height = items.length * rowH + 10;
|
|
52
|
+
var chart = svg("svg", { width: width, height: height, role: "img", "aria-label": opts.label || "chart" });
|
|
53
|
+
var plotW = width - labelW - valueW;
|
|
54
|
+
var values = items.map(function (it) { return it.value || 0; });
|
|
55
|
+
var maxAbs = Math.max.apply(null, values.map(Math.abs).concat([opts.max || 0, 1e-9]));
|
|
56
|
+
var signedMode = values.some(function (v) { return v < 0; });
|
|
57
|
+
var zero = labelW + (signedMode ? plotW / 2 : 0);
|
|
58
|
+
var scale = (signedMode ? plotW / 2 : plotW) / maxAbs;
|
|
59
|
+
items.forEach(function (it, i) {
|
|
60
|
+
var y = 5 + i * rowH;
|
|
61
|
+
var v = it.value || 0;
|
|
62
|
+
var label = svg("text", { x: labelW - 6, y: y + 14, "text-anchor": "end" });
|
|
63
|
+
label.textContent = it.label.length > 22 ? it.label.slice(0, 21) + "\u2026" : it.label;
|
|
64
|
+
var title = svg("title", {});
|
|
65
|
+
title.textContent = it.label + ": " + (it.value === null ? "n/a" : fmt(it.value));
|
|
66
|
+
var w = Math.abs(v) * scale;
|
|
67
|
+
var bar = svg("rect", {
|
|
68
|
+
x: v >= 0 ? zero : zero - w, y: y + 3, width: Math.max(w, it.value === null ? 0 : 1), height: rowH - 8,
|
|
69
|
+
class: "bar" + (signedMode || opts.colorSign ? (v >= 0 ? " pos" : " neg") : ""), rx: 2
|
|
70
|
+
});
|
|
71
|
+
bar.appendChild(title);
|
|
72
|
+
var text = svg("text", { x: labelW + plotW + 6, y: y + 14 });
|
|
73
|
+
text.textContent = it.value === null ? "n/a" : (signedMode ? signed(v) : fmt(v));
|
|
74
|
+
chart.appendChild(label); chart.appendChild(bar); chart.appendChild(text);
|
|
75
|
+
});
|
|
76
|
+
if (signedMode) chart.appendChild(svg("line", { x1: zero, x2: zero, y1: 0, y2: height, class: "axis" }));
|
|
77
|
+
container.appendChild(chart);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderOverview() {
|
|
81
|
+
var ds = DATA.dataset, labels = DATA.labels, cost = DATA.cost || {};
|
|
82
|
+
var cards = [
|
|
83
|
+
["Queries", ds.queries], ["Products", ds.products], ["Gold labels", ds.gold_labels],
|
|
84
|
+
["Judged", labels.judged], ["From cache", labels.cached], ["Unlabeled", labels.unlabeled],
|
|
85
|
+
["Tokens", cost.total_tokens === undefined ? "n/a" : cost.total_tokens.toLocaleString()],
|
|
86
|
+
["Cost", cost.requests ? money(cost.cost_usd) : "$0"]
|
|
87
|
+
];
|
|
88
|
+
var box = $("overview-cards");
|
|
89
|
+
cards.forEach(function (c) {
|
|
90
|
+
box.appendChild(el("div", { class: "card" }, [el("div", { class: "k", text: c[0] }), el("div", { class: "v", text: String(c[1]) })]));
|
|
91
|
+
});
|
|
92
|
+
$("meta").textContent = "Judge: " + (labels.judge || "none") + " \u00b7 depth " + cfg.depth +
|
|
93
|
+
" \u00b7 relevant when grade \u2265 " + cfg.threshold + " \u00b7 baseline " + cfg.baseline +
|
|
94
|
+
" \u00b7 generated " + DATA.generated_at + " by llm-search-eval " + DATA.tool.version;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderSummary() {
|
|
98
|
+
var metrics = cfg.metrics;
|
|
99
|
+
var best = {};
|
|
100
|
+
metrics.forEach(function (m) {
|
|
101
|
+
var vals = systems.map(function (s) { return s.metrics[m].mean; }).filter(function (v) { return v !== null; });
|
|
102
|
+
best[m] = vals.length ? Math.max.apply(null, vals) : null;
|
|
103
|
+
});
|
|
104
|
+
var rows = systems.map(function (s) {
|
|
105
|
+
return el("tr", null, [td(s.name)].concat(metrics.map(function (m) {
|
|
106
|
+
var v = s.metrics[m].mean;
|
|
107
|
+
var isBest = v !== null && systems.length > 1 && Math.abs(v - best[m]) < 1e-12;
|
|
108
|
+
return td(fmt(v), "num" + (isBest ? " best" : ""));
|
|
109
|
+
})));
|
|
110
|
+
});
|
|
111
|
+
$("summary-table").appendChild(table(["System"].concat(metrics), rows));
|
|
112
|
+
var select = $("metric-select");
|
|
113
|
+
metrics.forEach(function (m) { select.appendChild(option(m, m, m === primary)); });
|
|
114
|
+
function draw() {
|
|
115
|
+
var m = select.value;
|
|
116
|
+
barChart($("metric-chart"), systems.map(function (s) { return { label: s.name, value: s.metrics[m].mean }; }), { max: 1, label: m });
|
|
117
|
+
}
|
|
118
|
+
select.addEventListener("change", draw);
|
|
119
|
+
draw();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderComparison() {
|
|
123
|
+
var comp = DATA.comparison;
|
|
124
|
+
var section = $("comparison");
|
|
125
|
+
if (systems.length < 2) { section.hidden = true; return; }
|
|
126
|
+
var rows = comp.significance.map(function (t) {
|
|
127
|
+
var d = comp.deltas.filter(function (x) { return x.system === t.system && x.metric === t.metric; })[0] || {};
|
|
128
|
+
var cls = d.delta > 0 ? "num pos" : d.delta < 0 ? "num neg" : "num";
|
|
129
|
+
return el("tr", null, [td(t.system), td(signed(d.delta), cls), td(pct(d.relative), cls),
|
|
130
|
+
td(t.wins + " / " + t.ties + " / " + t.losses, "num"),
|
|
131
|
+
td(t.p_value === null ? "n/a" : t.p_value.toFixed(4) + (t.p_value < 0.05 ? " *" : ""), "num")]);
|
|
132
|
+
});
|
|
133
|
+
$("comparison-table").appendChild(table(["System", "\u0394 " + comp.primary_metric, "Relative", "Win / tie / loss", "p-value"], rows));
|
|
134
|
+
var select = $("compare-select");
|
|
135
|
+
names.filter(function (n) { return n !== comp.baseline; }).forEach(function (n, i) { select.appendChild(option(n, n, i === 0)); });
|
|
136
|
+
function draw() {
|
|
137
|
+
var a = systems.filter(function (s) { return s.name === select.value; })[0];
|
|
138
|
+
var b = systems.filter(function (s) { return s.name === comp.baseline; })[0];
|
|
139
|
+
var items = [];
|
|
140
|
+
Object.keys(b.per_query).forEach(function (q) {
|
|
141
|
+
var x = a.per_query[q][primary], y = b.per_query[q][primary];
|
|
142
|
+
if (x === null || y === null || Math.abs(x - y) < 1e-12) return;
|
|
143
|
+
var text = details && details.queries[q] ? q + " " + details.queries[q].text : q;
|
|
144
|
+
items.push({ label: text, value: x - y });
|
|
145
|
+
});
|
|
146
|
+
items.sort(function (p, q) { return q.value - p.value; });
|
|
147
|
+
$("delta-note").textContent = items.length ? "Per-query " + primary + " change of " + select.value + " versus " + comp.baseline + ", queries with no change omitted." : "No query changed between " + select.value + " and " + comp.baseline + ".";
|
|
148
|
+
barChart($("delta-chart"), items, { labelWidth: 220, label: "per-query delta" });
|
|
149
|
+
}
|
|
150
|
+
select.addEventListener("change", draw);
|
|
151
|
+
draw();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderLanguages() {
|
|
155
|
+
var by = DATA.by_language;
|
|
156
|
+
var langs = {};
|
|
157
|
+
Object.keys(by).forEach(function (s) { Object.keys(by[s]).forEach(function (l) { langs[l] = true; }); });
|
|
158
|
+
langs = Object.keys(langs).sort();
|
|
159
|
+
if (langs.length < 2) { $("languages").hidden = true; return; }
|
|
160
|
+
var rows = Object.keys(by).map(function (s) {
|
|
161
|
+
return el("tr", null, [td(s)].concat(langs.map(function (l) { return td(fmt(by[s][l].mean) + " (n=" + by[s][l].n + ")", "num"); })));
|
|
162
|
+
});
|
|
163
|
+
$("language-table").appendChild(table(["System"].concat(langs), rows));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderJudging() {
|
|
167
|
+
var j = DATA.judging;
|
|
168
|
+
if (!j) { $("judging").hidden = true; return; }
|
|
169
|
+
var dist = j.label_distribution;
|
|
170
|
+
barChart($("label-chart"), ["3", "2", "1", "0"].map(function (g) { return { label: "grade " + g, value: dist[g] || 0 }; }), { label: "label distribution" });
|
|
171
|
+
var info = $("judging-info");
|
|
172
|
+
info.appendChild(el("p", { text: "Judge: " + j.judge + ". " + j.pairs + " judged pair(s)." }));
|
|
173
|
+
if (j.agreement) {
|
|
174
|
+
var a = j.agreement;
|
|
175
|
+
var k = a.krippendorff_alpha;
|
|
176
|
+
info.appendChild(table(["Statistic", "Value"], [
|
|
177
|
+
el("tr", null, [td("Fleiss' kappa"), td(fmt(a.fleiss_kappa, 3), "num")]),
|
|
178
|
+
el("tr", null, [td("Krippendorff's alpha (ordinal)"), td(fmt(k.ordinal, 3), "num")]),
|
|
179
|
+
el("tr", null, [td("Krippendorff's alpha (nominal)"), td(fmt(k.nominal, 3), "num")]),
|
|
180
|
+
el("tr", null, [td("Unanimous items"), td(a.unanimous === null ? "n/a" : (a.unanimous * 100).toFixed(1) + "%", "num")])
|
|
181
|
+
]));
|
|
182
|
+
info.appendChild(el("h3", { text: "Pairwise agreement" }));
|
|
183
|
+
info.appendChild(table(["Judges", "n", "Agreement", "Cohen's kappa", "Weighted kappa"], a.pairwise.map(function (p) {
|
|
184
|
+
return el("tr", null, [td(p.a + " / " + p.b), td(String(p.n), "num"), td(p.percent_agreement === null ? "n/a" : (p.percent_agreement * 100).toFixed(1) + "%", "num"), td(fmt(p.cohen_kappa, 3), "num"), td(fmt(p.weighted_kappa, 3), "num")]);
|
|
185
|
+
})));
|
|
186
|
+
}
|
|
187
|
+
var g = j.gold_agreement;
|
|
188
|
+
if (g) {
|
|
189
|
+
var box = $("gold-info");
|
|
190
|
+
box.appendChild(el("h3", { text: "Agreement with gold labels" }));
|
|
191
|
+
var raters = [[j.judge, g.judge]].concat(Object.keys(g.members).map(function (n) { return [n, g.members[n]]; }));
|
|
192
|
+
box.appendChild(table(["Rater", "n", "Exact", "Within one", "Binary", "Weighted kappa", "Bias"], raters.map(function (r) {
|
|
193
|
+
var s = r[1];
|
|
194
|
+
return el("tr", null, [td(r[0]), td(String(s.n), "num"), td((s.exact * 100).toFixed(1) + "%", "num"), td((s.within_one * 100).toFixed(1) + "%", "num"), td((s.binary_agreement * 100).toFixed(1) + "%", "num"), td(fmt(s.weighted_kappa, 3), "num"), td(signed(s.mean_bias), "num")]);
|
|
195
|
+
})));
|
|
196
|
+
var m = g.judge.confusion;
|
|
197
|
+
box.appendChild(el("p", { class: "note", text: "Confusion matrix: rows are gold grades, columns are judge grades." }));
|
|
198
|
+
box.appendChild(table(["gold \\ judge"].concat(m.categories.map(String)), m.matrix.map(function (row, i) {
|
|
199
|
+
return el("tr", null, [td(String(m.categories[i]))].concat(row.map(function (c) { return td(String(c), "num"); })));
|
|
200
|
+
})));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderCost() {
|
|
205
|
+
var c = DATA.cost;
|
|
206
|
+
if (!c || (!c.requests && !c.cache_hits && !c.budget.skipped_pairs)) { $("cost").hidden = true; return; }
|
|
207
|
+
var rows = Object.keys(c.models).map(function (name) {
|
|
208
|
+
var m = c.models[name];
|
|
209
|
+
return el("tr", null, [td(name), td(String(m.requests), "num"), td(m.prompt_tokens.toLocaleString(), "num"), td(m.completion_tokens.toLocaleString(), "num"), td(money(m.cost_usd), "num")]);
|
|
210
|
+
});
|
|
211
|
+
rows.push(el("tr", null, [td("Total"), td(String(c.requests), "num"), td(c.prompt_tokens.toLocaleString(), "num"), td(c.completion_tokens.toLocaleString(), "num"), td(money(c.cost_usd), "num best")]));
|
|
212
|
+
$("cost-table").appendChild(table(["Model", "Requests", "Input tokens", "Output tokens", "Cost"], rows));
|
|
213
|
+
var notes = [c.cache_hits + " judgment(s) served from cache at no cost."];
|
|
214
|
+
if (c.unpriced_models.length) notes.push("No price configured for: " + c.unpriced_models.join(", ") + ".");
|
|
215
|
+
if (c.budget.exhausted) notes.push("Budget reached (" + c.budget.reason + "); " + c.budget.skipped_pairs + " pair(s) were not judged.");
|
|
216
|
+
$("cost-note").textContent = notes.join(" ");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderQueries() {
|
|
220
|
+
if (!details) { $("queries").hidden = true; return; }
|
|
221
|
+
var qids = Object.keys(details.queries);
|
|
222
|
+
var body = el("tbody");
|
|
223
|
+
var rows = {};
|
|
224
|
+
qids.forEach(function (q) {
|
|
225
|
+
var info = details.queries[q];
|
|
226
|
+
var cells = [td(q), el("td", { dir: "auto", text: info.text }), td(info.lang)];
|
|
227
|
+
systems.forEach(function (s) { cells.push(td(fmt(s.per_query[q][primary], 3), "num")); });
|
|
228
|
+
var tr = el("tr", { "data-q": q }, cells);
|
|
229
|
+
tr.addEventListener("click", function () { showQuery(q); });
|
|
230
|
+
rows[q] = tr;
|
|
231
|
+
body.appendChild(tr);
|
|
232
|
+
});
|
|
233
|
+
var head = el("tr", null, [th("Id"), th("Query"), th("Lang")].concat(names.map(function (n) { return th(n + " " + primary, "num"); })));
|
|
234
|
+
$("query-table").appendChild(el("table", { class: "clickable" }, [el("thead", null, [head]), body]));
|
|
235
|
+
$("query-filter").addEventListener("input", function (e) {
|
|
236
|
+
var needle = e.target.value.toLowerCase();
|
|
237
|
+
qids.forEach(function (q) {
|
|
238
|
+
var hay = (q + " " + details.queries[q].text).toLowerCase();
|
|
239
|
+
rows[q].hidden = needle && hay.indexOf(needle) < 0;
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
function showQuery(q) {
|
|
243
|
+
Object.keys(rows).forEach(function (k) { rows[k].className = k === q ? "selected" : ""; });
|
|
244
|
+
var box = $("query-detail");
|
|
245
|
+
box.textContent = "";
|
|
246
|
+
var info = details.queries[q];
|
|
247
|
+
box.appendChild(el("h3", { dir: "auto", text: q + ": " + info.text }));
|
|
248
|
+
var labels = details.labels[q] || {};
|
|
249
|
+
var lists = el("div", { class: "lists" });
|
|
250
|
+
systems.forEach(function (s) {
|
|
251
|
+
var ranked = details.rankings[s.name][q] || [];
|
|
252
|
+
var ol = el("ol");
|
|
253
|
+
ranked.forEach(function (pid) {
|
|
254
|
+
var l = labels[pid];
|
|
255
|
+
var p = details.products[pid];
|
|
256
|
+
var grade = l ? l.grade : null;
|
|
257
|
+
var badge = el("span", { class: "badge " + (grade === null ? "gx" : "g" + grade), text: grade === null ? "?" : String(grade) });
|
|
258
|
+
var title = el("span", { dir: "auto", text: p ? p.title : pid + " (not in catalog)" });
|
|
259
|
+
var src = el("span", { class: "src", text: " " + pid + (l ? " \u00b7 " + l.source : " \u00b7 unlabeled") });
|
|
260
|
+
var parts = [badge, title, src];
|
|
261
|
+
if (l && l.reason) parts.push(el("span", { class: "reason", dir: "auto", text: l.reason }));
|
|
262
|
+
if (l && l.votes) parts.push(el("span", { class: "reason", text: "votes: " + Object.keys(l.votes).map(function (k) { return k + "=" + (l.votes[k] === null ? "x" : l.votes[k]); }).join(", ") }));
|
|
263
|
+
ol.appendChild(el("li", null, parts));
|
|
264
|
+
});
|
|
265
|
+
if (!ranked.length) ol.appendChild(el("li", { class: "muted", text: "no results" }));
|
|
266
|
+
var metrics = cfg.metrics.map(function (m) { return m + " " + fmt(s.per_query[q][m], 3); }).join(" \u00b7 ");
|
|
267
|
+
lists.appendChild(el("div", { class: "list" }, [el("h3", { text: s.name }), el("p", { class: "note", text: metrics }), ol]));
|
|
268
|
+
});
|
|
269
|
+
box.appendChild(lists);
|
|
270
|
+
var missing = Object.keys(labels).filter(function (pid) {
|
|
271
|
+
return labels[pid].grade >= cfg.threshold && !systems.some(function (s) { return (details.rankings[s.name][q] || []).indexOf(pid) >= 0; });
|
|
272
|
+
});
|
|
273
|
+
if (missing.length) box.appendChild(el("p", { class: "note", text: "Relevant products no system returned in the top " + cfg.depth + ": " + missing.join(", ") }));
|
|
274
|
+
box.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
275
|
+
}
|
|
276
|
+
if (qids.length) showQuery(qids[0]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
renderOverview();
|
|
280
|
+
renderSummary();
|
|
281
|
+
renderComparison();
|
|
282
|
+
renderLanguages();
|
|
283
|
+
renderJudging();
|
|
284
|
+
renderCost();
|
|
285
|
+
renderQueries();
|
|
286
|
+
})();
|