agent-skill-description-optimizer 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.
- agent_skill_description_optimizer-0.2.0.dist-info/METADATA +361 -0
- agent_skill_description_optimizer-0.2.0.dist-info/RECORD +17 -0
- agent_skill_description_optimizer-0.2.0.dist-info/WHEEL +4 -0
- agent_skill_description_optimizer-0.2.0.dist-info/entry_points.txt +3 -0
- agent_skill_description_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
- skill_optimizer/__init__.py +78 -0
- skill_optimizer/__main__.py +6 -0
- skill_optimizer/_process.py +42 -0
- skill_optimizer/cli.py +1450 -0
- skill_optimizer/evaluation.py +475 -0
- skill_optimizer/improver.py +636 -0
- skill_optimizer/interpreter.py +171 -0
- skill_optimizer/models.py +155 -0
- skill_optimizer/py.typed +0 -0
- skill_optimizer/report.py +297 -0
- skill_optimizer/selection.py +250 -0
- skill_optimizer/skill_md.py +123 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Pure trigger-decision logic over a ``claude -p`` stream-json event sequence.
|
|
2
|
+
|
|
3
|
+
Separated from subprocess transport so it can be tested exhaustively against
|
|
4
|
+
synthetic and recorded event streams. The decision is made from tool-call *intent*,
|
|
5
|
+
so it does not depend on tool execution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Tool names that, when invoked, may reference the injected candidate command.
|
|
14
|
+
_SKILL_TOOLS = ("Skill", "Read")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _scan_assistant(message: Mapping[str, Any], cmd_name: str) -> bool | None:
|
|
18
|
+
"""Decide from a non-streaming ``assistant`` message.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
message: The assistant message object.
|
|
22
|
+
cmd_name: The injected command name to look for.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
``True``/``False`` from the first ``tool_use`` item, or ``None`` if the
|
|
26
|
+
message contains no tool call.
|
|
27
|
+
"""
|
|
28
|
+
tool_use = next(
|
|
29
|
+
(c for c in message.get("content", []) if c.get("type") == "tool_use"),
|
|
30
|
+
None,
|
|
31
|
+
)
|
|
32
|
+
if tool_use is None:
|
|
33
|
+
return None
|
|
34
|
+
if tool_use.get("name") not in _SKILL_TOOLS:
|
|
35
|
+
return False
|
|
36
|
+
return cmd_name in json.dumps(tool_use.get("input", {}))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class _ScanState:
|
|
41
|
+
"""Mutable state for scanning a streamed tool call.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
cmd_name: The injected command name to detect.
|
|
45
|
+
pending: Name of the in-progress skill tool, or ``None`` if none is open.
|
|
46
|
+
acc: Accumulated ``input_json_delta`` text for the pending tool.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
cmd_name: str
|
|
50
|
+
pending: str | None = None
|
|
51
|
+
acc: str = ""
|
|
52
|
+
|
|
53
|
+
def feed_stream_event(self, stream: Mapping[str, Any]) -> bool | None:
|
|
54
|
+
"""Advance the scan with one ``stream_event`` payload.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
stream: The inner ``event`` object of a ``stream_event``.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
``True``/``False`` once decided, or ``None`` to keep scanning.
|
|
61
|
+
"""
|
|
62
|
+
stream_type = stream.get("type", "")
|
|
63
|
+
if stream_type == "content_block_start":
|
|
64
|
+
return self._on_block_start(stream.get("content_block", {}))
|
|
65
|
+
if stream_type == "content_block_delta":
|
|
66
|
+
return self._on_delta(stream.get("delta", {}))
|
|
67
|
+
if stream_type in ("content_block_stop", "message_stop"):
|
|
68
|
+
if self.pending is not None:
|
|
69
|
+
return self.cmd_name in self.acc
|
|
70
|
+
if stream_type == "message_stop":
|
|
71
|
+
return False
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def _on_block_start(self, block: Mapping[str, Any]) -> bool | None:
|
|
75
|
+
"""Handle a ``content_block_start``.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
block: The ``content_block`` object.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
``False`` if a non-skill tool starts first, else ``None``.
|
|
82
|
+
"""
|
|
83
|
+
if block.get("type") == "tool_use":
|
|
84
|
+
if block.get("name") in _SKILL_TOOLS:
|
|
85
|
+
self.pending = block.get("name")
|
|
86
|
+
self.acc = ""
|
|
87
|
+
else:
|
|
88
|
+
return False
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def _on_delta(self, delta: Mapping[str, Any]) -> bool | None:
|
|
92
|
+
"""Handle a ``content_block_delta``, accumulating tool input.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
delta: The ``delta`` object.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
``True`` once ``cmd_name`` appears in the accumulated input, else
|
|
99
|
+
``None``.
|
|
100
|
+
"""
|
|
101
|
+
if self.pending is not None and delta.get("type") == "input_json_delta":
|
|
102
|
+
self.acc += delta.get("partial_json", "")
|
|
103
|
+
if self.cmd_name in self.acc:
|
|
104
|
+
return True
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _interpret_events_status(
|
|
109
|
+
events: Iterable[Mapping[str, Any]], cmd_name: str
|
|
110
|
+
) -> bool | None:
|
|
111
|
+
"""Decide the trigger outcome, distinguishing a decisive result from an
|
|
112
|
+
exhausted stream.
|
|
113
|
+
|
|
114
|
+
Consumes events lazily and returns as soon as the outcome is decided, so a
|
|
115
|
+
generator backed by a live subprocess can be abandoned early. The decision rules:
|
|
116
|
+
|
|
117
|
+
- The first ``tool_use`` block that is **not** ``Skill``/``Read`` means the model
|
|
118
|
+
went elsewhere first: ``False`` (even if a skill is used later).
|
|
119
|
+
- A ``Skill``/``Read`` ``tool_use`` whose streamed input contains ``cmd_name``
|
|
120
|
+
(accumulated across ``input_json_delta`` chunks): ``True``.
|
|
121
|
+
- A block/message stop while a skill tool is pending resolves to whether
|
|
122
|
+
``cmd_name`` was seen in that block's input.
|
|
123
|
+
- A non-streaming ``assistant`` ``tool_use`` is judged on its full input.
|
|
124
|
+
- ``message_stop`` with nothing pending, or a ``result`` event: ``False``.
|
|
125
|
+
|
|
126
|
+
``thinking`` and ``text`` content blocks are ignored.
|
|
127
|
+
|
|
128
|
+
Unlike :func:`interpret_events`, this returns ``None`` when the stream is exhausted
|
|
129
|
+
with **no** decisive terminal event (a timeout-truncated, empty, or otherwise
|
|
130
|
+
incomplete stream), so the caller can treat an unjudgeable probe differently from a
|
|
131
|
+
genuine non-trigger.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
events: Parsed stream-json events, in order.
|
|
135
|
+
cmd_name: The injected command name to look for in tool input.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
``True`` (decisive trigger), ``False`` (decisive non-trigger), or ``None``
|
|
139
|
+
(no decisive terminal event was observed).
|
|
140
|
+
"""
|
|
141
|
+
state = _ScanState(cmd_name)
|
|
142
|
+
for event in events:
|
|
143
|
+
event_type = event.get("type")
|
|
144
|
+
if event_type == "stream_event":
|
|
145
|
+
decision = state.feed_stream_event(event.get("event", {}))
|
|
146
|
+
elif event_type == "assistant":
|
|
147
|
+
decision = _scan_assistant(event.get("message", {}), cmd_name)
|
|
148
|
+
elif event_type == "result":
|
|
149
|
+
decision = False
|
|
150
|
+
else:
|
|
151
|
+
decision = None
|
|
152
|
+
if decision is not None:
|
|
153
|
+
return decision
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def interpret_events(events: Iterable[Mapping[str, Any]], cmd_name: str) -> bool:
|
|
158
|
+
"""Decide whether a stream invokes the injected candidate command.
|
|
159
|
+
|
|
160
|
+
A thin, pure-bool wrapper over :func:`_interpret_events_status` that coerces the
|
|
161
|
+
"no decisive terminal event" case (``None``) to ``False``. See that function for
|
|
162
|
+
the full decision rules.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
events: Parsed stream-json events, in order.
|
|
166
|
+
cmd_name: The injected command name to look for in tool input.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
``True`` if the model invoked the candidate command, else ``False``.
|
|
170
|
+
"""
|
|
171
|
+
return _interpret_events_status(events, cmd_name) is True
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Shared types and constants for the skill-description optimizer."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TypedDict
|
|
5
|
+
|
|
6
|
+
# Convenience aliases so callers can pass ``--models haiku,sonnet,opus``. These are
|
|
7
|
+
# source-pinned to current tier IDs (verified against Anthropic's official model table);
|
|
8
|
+
# any full model id also passes through unchanged. For reproducibility, pass an explicit
|
|
9
|
+
# full ``--model``/``--models`` id rather than relying on these moving-target defaults.
|
|
10
|
+
MODEL_ALIASES: dict[str, str] = {
|
|
11
|
+
"haiku": "claude-haiku-4-5-20251001",
|
|
12
|
+
"sonnet": "claude-sonnet-5",
|
|
13
|
+
"opus": "claude-opus-4-8",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EvalQuery(TypedDict):
|
|
18
|
+
"""A single evaluation query.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
query: The natural-language task shown to the model.
|
|
22
|
+
should_trigger: Whether a correct skill description should fire for it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
query: str
|
|
26
|
+
should_trigger: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ``pass`` is a reserved word, so this member needs the functional TypedDict syntax.
|
|
30
|
+
# ``pass`` is ``None`` when every probe for this ``(query, model)`` cell was
|
|
31
|
+
# unjudgeable (timeout/CLI error), so the cell is excluded from accuracy denominators
|
|
32
|
+
# rather than scored as a miss. ``triggers``/``runs`` are the judged-run counts (so a
|
|
33
|
+
# cell with 2 judged + 1 error is representable), and ``errors`` is the unjudged count.
|
|
34
|
+
ModelResult = TypedDict(
|
|
35
|
+
"ModelResult",
|
|
36
|
+
{
|
|
37
|
+
"trigger_rate": float,
|
|
38
|
+
"pass": bool | None,
|
|
39
|
+
"triggers": int,
|
|
40
|
+
"runs": int,
|
|
41
|
+
"errors": int,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
"""Per-model outcome for one query: trigger rate, pass/fail/unjudged, and counts."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PerQuery(TypedDict):
|
|
48
|
+
"""Per-query roll-up across all evaluated models.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
index: Original positional index into the full eval set (dedup-safe key used
|
|
52
|
+
by train/test slicing and the HTML report, never the query text).
|
|
53
|
+
query: The evaluated query text.
|
|
54
|
+
should_trigger: Whether a correct description should fire for it.
|
|
55
|
+
models: Per-model outcomes keyed by model id.
|
|
56
|
+
all_pass: ``True`` if every judged model passes, ``False`` if at least one
|
|
57
|
+
judged model fails, ``None`` if every model was unjudged for this query.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
index: int
|
|
61
|
+
query: str
|
|
62
|
+
should_trigger: bool
|
|
63
|
+
models: dict[str, ModelResult]
|
|
64
|
+
all_pass: bool | None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ImproverAttempt(TypedDict):
|
|
68
|
+
"""One previously-tried description plus its **train-only** per-query results.
|
|
69
|
+
|
|
70
|
+
Held-out (test) results are deliberately excluded so the improver never sees the
|
|
71
|
+
selection set (blinding), which would otherwise invite overfitting.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
description: The description that was tried.
|
|
75
|
+
train_results: The training-set :data:`PerQuery` roll-ups for that attempt.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
description: str
|
|
79
|
+
train_results: list[PerQuery]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ConfusionMatrix(TypedDict):
|
|
83
|
+
"""Trigger confusion matrix over judged runs, with derived ratios.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
tp: True positives (should-trigger runs that triggered).
|
|
87
|
+
fp: False positives (should-not-trigger runs that triggered).
|
|
88
|
+
tn: True negatives (should-not-trigger runs that stayed silent).
|
|
89
|
+
fn: False negatives (should-trigger runs that stayed silent).
|
|
90
|
+
precision: ``tp / (tp + fp)`` (``1.0`` when there are no positives predicted).
|
|
91
|
+
recall: ``tp / (tp + fn)`` (``1.0`` when there are no positives expected).
|
|
92
|
+
accuracy: ``(tp + tn) / total`` (``0.0`` when there are no judged runs).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
tp: int
|
|
96
|
+
fp: int
|
|
97
|
+
tn: int
|
|
98
|
+
fn: int
|
|
99
|
+
precision: float
|
|
100
|
+
recall: float
|
|
101
|
+
accuracy: float
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class EvalResult(TypedDict):
|
|
105
|
+
"""Aggregated evaluation of one description across queries and models.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
description: The description that was evaluated.
|
|
109
|
+
per_model_accuracy: Accuracy in ``[0, 1]`` keyed by model id; ``None`` for a
|
|
110
|
+
model with zero judged queries in the (sub)set.
|
|
111
|
+
mean_accuracy: Mean of the non-``None`` ``per_model_accuracy`` values.
|
|
112
|
+
min_accuracy: Minimum (weakest-model) non-``None`` accuracy.
|
|
113
|
+
per_query: One :data:`PerQuery` entry per evaluation query.
|
|
114
|
+
errors: Total unjudged probe count across all cells.
|
|
115
|
+
unjudged: Number of unjudged ``query x model`` cells.
|
|
116
|
+
score_valid: ``False`` when no model had any judged query (accuracy unusable
|
|
117
|
+
for selection); ``True`` otherwise.
|
|
118
|
+
confusion: Aggregate confusion matrix over all judged runs.
|
|
119
|
+
per_model_confusion: Per-model confusion matrix keyed by model id.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
description: str
|
|
123
|
+
per_model_accuracy: dict[str, float | None]
|
|
124
|
+
mean_accuracy: float
|
|
125
|
+
min_accuracy: float
|
|
126
|
+
per_query: list[PerQuery]
|
|
127
|
+
errors: int
|
|
128
|
+
unjudged: int
|
|
129
|
+
score_valid: bool
|
|
130
|
+
confusion: ConfusionMatrix
|
|
131
|
+
per_model_confusion: dict[str, ConfusionMatrix]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class EvalConfig:
|
|
136
|
+
"""Settings shared across every query in an evaluation run.
|
|
137
|
+
|
|
138
|
+
Bundling these avoids threading the same long positional-argument list through
|
|
139
|
+
``evaluate`` and its callers.
|
|
140
|
+
|
|
141
|
+
Attributes:
|
|
142
|
+
models: Resolved model ids to evaluate against.
|
|
143
|
+
repeats: Number of runs per ``(query, model)`` pair.
|
|
144
|
+
timeout: Per-run wall-clock budget, in seconds.
|
|
145
|
+
workers: Maximum concurrent ``claude -p`` subprocesses.
|
|
146
|
+
threshold: Trigger-rate at or above which a query counts as triggered.
|
|
147
|
+
settings_json: Optional ``--settings`` JSON blob, or ``None`` to omit it.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
models: tuple[str, ...]
|
|
151
|
+
repeats: int
|
|
152
|
+
timeout: int
|
|
153
|
+
workers: int
|
|
154
|
+
threshold: float
|
|
155
|
+
settings_json: str | None = None
|
skill_optimizer/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""HTML report generation, ported from skill-creator's ``generate_report.py``.
|
|
2
|
+
|
|
3
|
+
Adapted to this tool's multi-model, tri-state history: query cells are anchored by the
|
|
4
|
+
original positional ``index`` (dedup-safe), render ``pass`` as a tri-state
|
|
5
|
+
(green/red/neutral, never a red cross for an unjudged probe), carry per-model detail in
|
|
6
|
+
the cell ``title`` attribute, and highlight the winning row from the ``is_best`` flag
|
|
7
|
+
(never recomputed from a score).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_CSS = """
|
|
14
|
+
body {
|
|
15
|
+
font-family: 'Lora', Georgia, serif;
|
|
16
|
+
max-width: 100%;
|
|
17
|
+
margin: 0 auto;
|
|
18
|
+
padding: 20px;
|
|
19
|
+
background: #faf9f5;
|
|
20
|
+
color: #141413;
|
|
21
|
+
}
|
|
22
|
+
h1 { font-family: 'Poppins', sans-serif; color: #141413; }
|
|
23
|
+
.explainer, .summary {
|
|
24
|
+
background: white;
|
|
25
|
+
padding: 15px;
|
|
26
|
+
border-radius: 6px;
|
|
27
|
+
margin-bottom: 20px;
|
|
28
|
+
border: 1px solid #e8e6dc;
|
|
29
|
+
}
|
|
30
|
+
.explainer { color: #b0aea5; font-size: 0.875rem; line-height: 1.6; }
|
|
31
|
+
.summary p { margin: 5px 0; }
|
|
32
|
+
.best { color: #788c5d; font-weight: bold; }
|
|
33
|
+
.table-container { overflow-x: auto; width: 100%; }
|
|
34
|
+
table {
|
|
35
|
+
border-collapse: collapse;
|
|
36
|
+
background: white;
|
|
37
|
+
border: 1px solid #e8e6dc;
|
|
38
|
+
border-radius: 6px;
|
|
39
|
+
font-size: 12px;
|
|
40
|
+
min-width: 100%;
|
|
41
|
+
}
|
|
42
|
+
th, td {
|
|
43
|
+
padding: 8px;
|
|
44
|
+
text-align: left;
|
|
45
|
+
border: 1px solid #e8e6dc;
|
|
46
|
+
white-space: normal;
|
|
47
|
+
word-wrap: break-word;
|
|
48
|
+
}
|
|
49
|
+
th {
|
|
50
|
+
font-family: 'Poppins', sans-serif;
|
|
51
|
+
background: #141413;
|
|
52
|
+
color: #faf9f5;
|
|
53
|
+
font-weight: 500;
|
|
54
|
+
}
|
|
55
|
+
th.test-col { background: #6a9bcc; }
|
|
56
|
+
th.query-col { min-width: 200px; }
|
|
57
|
+
td.description {
|
|
58
|
+
font-family: monospace;
|
|
59
|
+
font-size: 11px;
|
|
60
|
+
word-wrap: break-word;
|
|
61
|
+
max-width: 400px;
|
|
62
|
+
}
|
|
63
|
+
td.result { text-align: center; font-size: 16px; min-width: 40px; }
|
|
64
|
+
td.test-result { background: #f0f6fc; }
|
|
65
|
+
.pass { color: #788c5d; }
|
|
66
|
+
.fail { color: #c44; }
|
|
67
|
+
.unjudged { color: #b0aea5; }
|
|
68
|
+
.rate { font-size: 9px; color: #b0aea5; display: block; }
|
|
69
|
+
tr:hover { background: #faf9f5; }
|
|
70
|
+
.score {
|
|
71
|
+
display: inline-block;
|
|
72
|
+
padding: 2px 6px;
|
|
73
|
+
border-radius: 4px;
|
|
74
|
+
font-weight: bold;
|
|
75
|
+
font-size: 11px;
|
|
76
|
+
}
|
|
77
|
+
.score-good { background: #eef2e8; color: #788c5d; }
|
|
78
|
+
.score-ok { background: #fef3c7; color: #d97706; }
|
|
79
|
+
.score-bad { background: #fceaea; color: #c44; }
|
|
80
|
+
.best-row { background: #f5f8f2; }
|
|
81
|
+
th.positive-col { border-bottom: 3px solid #788c5d; }
|
|
82
|
+
th.negative-col { border-bottom: 3px solid #c44; }
|
|
83
|
+
.legend {
|
|
84
|
+
font-family: 'Poppins', sans-serif;
|
|
85
|
+
display: flex;
|
|
86
|
+
gap: 20px;
|
|
87
|
+
margin-bottom: 10px;
|
|
88
|
+
font-size: 13px;
|
|
89
|
+
align-items: center;
|
|
90
|
+
}
|
|
91
|
+
.legend-item { display: flex; align-items: center; gap: 6px; }
|
|
92
|
+
.legend-swatch { width: 16px; height: 16px; border-radius: 3px; display: inline-block; }
|
|
93
|
+
.swatch-positive { background: #141413; border-bottom: 3px solid #788c5d; }
|
|
94
|
+
.swatch-negative { background: #141413; border-bottom: 3px solid #c44; }
|
|
95
|
+
.swatch-test { background: #6a9bcc; }
|
|
96
|
+
.swatch-train { background: #141413; }
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _column_headers(results: list[dict[str, Any]], test: bool) -> str:
|
|
101
|
+
"""Render ``<th>`` column headers for a set of query results.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
results: The ordered result entries (from ``history[0]``) to build columns for.
|
|
105
|
+
test: Whether these are held-out (test) columns (adds the test styling).
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The concatenated header cells.
|
|
109
|
+
"""
|
|
110
|
+
parts: list[str] = []
|
|
111
|
+
for r in results:
|
|
112
|
+
polarity = "positive-col" if r.get("should_trigger", True) else "negative-col"
|
|
113
|
+
cls = f"test-col {polarity}" if test else polarity
|
|
114
|
+
parts.append(
|
|
115
|
+
f' <th class="{cls}">{html.escape(r["query"])}</th>\n'
|
|
116
|
+
)
|
|
117
|
+
return "".join(parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _aggregate_runs(results: list[dict[str, Any]]) -> tuple[int, int]:
|
|
121
|
+
"""Sum correct runs and total runs across all query results.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
results: The result entries to aggregate.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A ``(correct, total)`` tuple of run counts.
|
|
128
|
+
"""
|
|
129
|
+
correct = 0
|
|
130
|
+
total = 0
|
|
131
|
+
for r in results:
|
|
132
|
+
runs = r.get("runs", 0)
|
|
133
|
+
triggers = r.get("triggers", 0)
|
|
134
|
+
total += runs
|
|
135
|
+
correct += triggers if r.get("should_trigger", True) else runs - triggers
|
|
136
|
+
return correct, total
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _score_class(correct: int, total: int) -> str:
|
|
140
|
+
"""Map a correct/total ratio to a CSS score class.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
correct: Correct run count.
|
|
144
|
+
total: Total run count.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The CSS class name.
|
|
148
|
+
"""
|
|
149
|
+
if total > 0:
|
|
150
|
+
ratio = correct / total
|
|
151
|
+
if ratio >= 0.8:
|
|
152
|
+
return "score-good"
|
|
153
|
+
if ratio >= 0.5:
|
|
154
|
+
return "score-ok"
|
|
155
|
+
return "score-bad"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _result_cell(r: dict[str, Any], test: bool) -> str:
|
|
159
|
+
"""Render one query result cell, tri-state and per-model annotated.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
r: The result entry (may be empty if the column has no matching index).
|
|
163
|
+
test: Whether this is a held-out (test) cell.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The ``<td>`` cell HTML.
|
|
167
|
+
"""
|
|
168
|
+
did_pass = r.get("pass")
|
|
169
|
+
triggers = r.get("triggers", 0)
|
|
170
|
+
runs = r.get("runs", 0)
|
|
171
|
+
if did_pass is True:
|
|
172
|
+
icon, css = "✓", "pass"
|
|
173
|
+
elif did_pass is False:
|
|
174
|
+
icon, css = "✗", "fail"
|
|
175
|
+
else:
|
|
176
|
+
# Unjudged (all probes errored): a neutral marker, never a red cross.
|
|
177
|
+
icon, css = "–", "unjudged"
|
|
178
|
+
per_model = r.get("models", {})
|
|
179
|
+
title = ", ".join(
|
|
180
|
+
f"{m}: {d.get('triggers', 0)}/{d.get('runs', 0)}" for m, d in per_model.items()
|
|
181
|
+
)
|
|
182
|
+
cls = f"result test-result {css}" if test else f"result {css}"
|
|
183
|
+
return (
|
|
184
|
+
f' <td class="{cls}" title="{html.escape(title)}">{icon}'
|
|
185
|
+
f'<span class="rate">{triggers}/{runs}</span></td>\n'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _row(
|
|
190
|
+
h: dict[str, Any],
|
|
191
|
+
train_cols: list[dict[str, Any]],
|
|
192
|
+
test_cols: list[dict[str, Any]],
|
|
193
|
+
) -> str:
|
|
194
|
+
"""Render one iteration row.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
h: The history entry for this iteration.
|
|
198
|
+
train_cols: The ordered training column definitions (from ``history[0]``).
|
|
199
|
+
test_cols: The ordered held-out column definitions (from ``history[0]``).
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The ``<tr>`` row HTML.
|
|
203
|
+
"""
|
|
204
|
+
train_results = h.get("train_results", h.get("results", []))
|
|
205
|
+
test_results = h.get("test_results", [])
|
|
206
|
+
# Anchor cells by original positional index, never query text (dedup-safe).
|
|
207
|
+
train_by_index = {r["index"]: r for r in train_results}
|
|
208
|
+
test_by_index = {r["index"]: r for r in test_results}
|
|
209
|
+
train_correct, train_runs = _aggregate_runs(train_results)
|
|
210
|
+
test_correct, test_runs = _aggregate_runs(test_results)
|
|
211
|
+
row_class = "best-row" if h.get("is_best") else ""
|
|
212
|
+
cells = [
|
|
213
|
+
f' <tr class="{row_class}">\n',
|
|
214
|
+
f" <td>{h.get('iteration', '?')}</td>\n",
|
|
215
|
+
f' <td><span class="score {_score_class(train_correct, train_runs)}">'
|
|
216
|
+
f"{train_correct}/{train_runs}</span></td>\n",
|
|
217
|
+
f' <td><span class="score {_score_class(test_correct, test_runs)}">'
|
|
218
|
+
f"{test_correct}/{test_runs}</span></td>\n",
|
|
219
|
+
f' <td class="description">{html.escape(h.get("description", ""))}</td>\n',
|
|
220
|
+
]
|
|
221
|
+
cells.extend(
|
|
222
|
+
_result_cell(train_by_index.get(c["index"], {}), False) for c in train_cols
|
|
223
|
+
)
|
|
224
|
+
cells.extend(
|
|
225
|
+
_result_cell(test_by_index.get(c["index"], {}), True) for c in test_cols
|
|
226
|
+
)
|
|
227
|
+
cells.append(" </tr>\n")
|
|
228
|
+
return "".join(cells)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def generate_html(
|
|
232
|
+
data: dict[str, Any], auto_refresh: bool = False, skill_name: str = ""
|
|
233
|
+
) -> str:
|
|
234
|
+
"""Generate the HTML optimization report from loop output data.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
data: The report dict (the same superset written to stdout / ``results.json``).
|
|
238
|
+
auto_refresh: When ``True``, add a 5-second meta-refresh (for the live report).
|
|
239
|
+
skill_name: Skill name for the report title.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The rendered HTML document.
|
|
243
|
+
"""
|
|
244
|
+
history: list[dict[str, Any]] = data.get("history", [])
|
|
245
|
+
title_prefix = html.escape(f"{skill_name} — ") if skill_name else ""
|
|
246
|
+
first = history[0] if history else {}
|
|
247
|
+
train_cols: list[dict[str, Any]] = first.get(
|
|
248
|
+
"train_results", first.get("results", [])
|
|
249
|
+
)
|
|
250
|
+
test_cols: list[dict[str, Any]] = first.get("test_results", [])
|
|
251
|
+
refresh_tag = (
|
|
252
|
+
' <meta http-equiv="refresh" content="5">\n' if auto_refresh else ""
|
|
253
|
+
)
|
|
254
|
+
best_test_score = data.get("best_test_score")
|
|
255
|
+
parts: list[str] = [
|
|
256
|
+
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="utf-8">\n',
|
|
257
|
+
refresh_tag,
|
|
258
|
+
f" <title>{title_prefix}Skill Description Optimization</title>\n",
|
|
259
|
+
f" <style>{_CSS} </style>\n</head>\n<body>\n",
|
|
260
|
+
f" <h1>{title_prefix}Skill Description Optimization</h1>\n",
|
|
261
|
+
' <div class="explainer"><strong>Optimizing your skill’s '
|
|
262
|
+
"description.</strong> Each row is a description attempt. Query columns show "
|
|
263
|
+
"test cases: a green check means the skill triggered correctly (or correctly "
|
|
264
|
+
"stayed silent), a red cross means it got it wrong, a grey dash means the probe "
|
|
265
|
+
"was unjudgeable. The best-performing row is highlighted.</div>\n",
|
|
266
|
+
' <div class="summary">\n'
|
|
267
|
+
f" <p><strong>Original:</strong> {html.escape(str(data.get('original_description', 'N/A')))}</p>\n"
|
|
268
|
+
f' <p class="best"><strong>Best:</strong> {html.escape(str(data.get("best_description", "N/A")))}</p>\n'
|
|
269
|
+
f" <p><strong>Best Score:</strong> {data.get('best_score', 'N/A')} "
|
|
270
|
+
f"{'(test)' if best_test_score else '(train)'}</p>\n"
|
|
271
|
+
f" <p><strong>Iterations:</strong> {data.get('iterations_run', 0)} | "
|
|
272
|
+
f"<strong>Train:</strong> {data.get('train_size', '?')} | "
|
|
273
|
+
f"<strong>Test:</strong> {data.get('test_size', '?')}</p>\n"
|
|
274
|
+
" </div>\n",
|
|
275
|
+
' <div class="legend">\n'
|
|
276
|
+
' <span style="font-weight:600">Query columns:</span>\n'
|
|
277
|
+
' <span class="legend-item"><span class="legend-swatch swatch-positive">'
|
|
278
|
+
"</span> Should trigger</span>\n"
|
|
279
|
+
' <span class="legend-item"><span class="legend-swatch swatch-negative">'
|
|
280
|
+
"</span> Should NOT trigger</span>\n"
|
|
281
|
+
' <span class="legend-item"><span class="legend-swatch swatch-train">'
|
|
282
|
+
"</span> Train</span>\n"
|
|
283
|
+
' <span class="legend-item"><span class="legend-swatch swatch-test">'
|
|
284
|
+
"</span> Test</span>\n"
|
|
285
|
+
" </div>\n",
|
|
286
|
+
' <div class="table-container">\n <table>\n <thead>\n'
|
|
287
|
+
" <tr>\n"
|
|
288
|
+
" <th>Iter</th>\n <th>Train</th>\n"
|
|
289
|
+
' <th>Test</th>\n <th class="query-col">'
|
|
290
|
+
"Description</th>\n",
|
|
291
|
+
_column_headers(train_cols, False),
|
|
292
|
+
_column_headers(test_cols, True),
|
|
293
|
+
" </tr>\n </thead>\n <tbody>\n",
|
|
294
|
+
]
|
|
295
|
+
parts.extend(_row(h, train_cols, test_cols) for h in history)
|
|
296
|
+
parts.append(" </tbody>\n </table>\n </div>\n</body>\n</html>\n")
|
|
297
|
+
return "".join(parts)
|