skillgate-ai 0.3.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.
@@ -0,0 +1,3 @@
1
+ """Skill Gate — skill-native sandbox evals + CI."""
2
+
3
+ __version__ = "0.3.0"
skillgate_ai/advice.py ADDED
@@ -0,0 +1,486 @@
1
+ """Copy-paste fix loop: fail → why → a description that would actually trigger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from skillgate_ai.harness import select_skill_from_descriptions, tokenize
13
+ from skillgate_ai.ingest import FRONTMATTER_RE, Skill
14
+ from skillgate_ai.scorers import Score
15
+ from skillgate_ai.suite import TaskSpec
16
+
17
+ _MEAL_NOISE = {
18
+ "dinner",
19
+ "dinners",
20
+ "recipe",
21
+ "recipes",
22
+ "grocery",
23
+ "groceries",
24
+ "pantry",
25
+ "meal",
26
+ "meals",
27
+ "shopping",
28
+ }
29
+
30
+
31
+ @dataclass
32
+ class FixAdvice:
33
+ headline: str
34
+ why: str
35
+ change: str
36
+ paste: str
37
+ target: str # description | body
38
+ description: str | None = None
39
+ demo_note: str | None = None
40
+ apply_label: str = "Run"
41
+ restore_scripts_from: str | None = None
42
+
43
+
44
+ def advise_task(
45
+ task: TaskSpec,
46
+ scores: list[Score],
47
+ skill: Skill,
48
+ selected: str | None,
49
+ *,
50
+ reference: Skill | None = None,
51
+ sibling_prompts: list[str] | None = None,
52
+ ) -> FixAdvice | None:
53
+ failed = [s for s in scores if not s.passed]
54
+ if not failed:
55
+ return None
56
+ trigger = next((s for s in failed if s.scorer == "skill_trigger"), None)
57
+ completion = next((s for s in failed if s.scorer == "task_completion"), None)
58
+ rubric = next((s for s in failed if s.scorer == "rubric"), None)
59
+ prompts = list(sibling_prompts or [task.prompt])
60
+
61
+ if trigger and task.expect_skill and (trigger.actual or "none") == "none":
62
+ description = synthesize_trigger_description(skill, prompts, reference)
63
+ return _trigger_fix(skill, task.prompt, description, reference)
64
+
65
+ if trigger and task.expect_skill is None and trigger.actual not in {None, "none"}:
66
+ description = _tighten_description(skill, task.prompt)
67
+ return FixAdvice(
68
+ headline="Skill triggered when it shouldn’t",
69
+ why=(
70
+ f"The description for `{skill.name}` matched a request that should use no skill: "
71
+ f"“{task.prompt}”."
72
+ ),
73
+ change=(
74
+ "Tighten `description:` so it lists when to use the skill — and implicitly excludes everything else."
75
+ ),
76
+ paste=_frontmatter(skill.name, description),
77
+ target="description",
78
+ description=description,
79
+ )
80
+
81
+ if trigger and task.expect_skill and trigger.actual not in {None, "none", task.expect_skill}:
82
+ description = synthesize_trigger_description(skill, prompts, reference)
83
+ return FixAdvice(
84
+ headline=f"Wrong skill triggered ({trigger.actual})",
85
+ why=f"Expected `{task.expect_skill}` but the agent picked `{trigger.actual}`.",
86
+ change="Make this skill’s description more specific, using keywords from the failed requests.",
87
+ paste=_frontmatter(skill.name, description),
88
+ target="description",
89
+ description=description,
90
+ )
91
+
92
+ missing = (completion.detail if completion else "") or (rubric.detail if rubric else "missing output")
93
+ skill_named = _named_output_path(skill.description or "", skill.body or "")
94
+ check_path = _task_output_path(task)
95
+ if skill_named and check_path and skill_named != check_path:
96
+ change = (
97
+ f"Align this check's output_path to `{skill_named}` from SKILL.md. "
98
+ f"Gate looked for `{check_path}`; do not rewrite the skill to that path."
99
+ )
100
+ paste = _suggested_steps(task, missing, write_to=skill_named)
101
+ else:
102
+ change = (
103
+ "Add a numbered step in SKILL.md that writes the exact output path, "
104
+ "or bundle a script under scripts/ and tell the agent to run it."
105
+ )
106
+ paste = _suggested_steps(task, missing)
107
+ return FixAdvice(
108
+ headline=completion.headline if completion else (rubric.headline if rubric else "Task failed"),
109
+ why=(
110
+ f"The skill triggered (or was expected to), but the work didn’t land. {missing}. "
111
+ f"Request was: “{task.prompt}”."
112
+ ),
113
+ change=change,
114
+ paste=paste,
115
+ target="body",
116
+ )
117
+
118
+
119
+ def advise_suite(
120
+ skill: Skill,
121
+ tasks: list,
122
+ *,
123
+ reference: Skill | None = None,
124
+ ) -> FixAdvice | None:
125
+ failed = [t for t in tasks if not t.passed]
126
+ if not failed:
127
+ return None
128
+ trigger_misses = [
129
+ t
130
+ for t in failed
131
+ if t.expect_skill
132
+ and any(s.scorer == "skill_trigger" and not s.passed for s in t.scores)
133
+ and (t.selected_skill in {None, "none"} or t.selected_skill is None)
134
+ ]
135
+ if trigger_misses:
136
+ prompts = [t.prompt for t in trigger_misses]
137
+ description = synthesize_trigger_description(skill, prompts, reference)
138
+ first = trigger_misses[0]
139
+ return _trigger_fix(skill, first.prompt, description, reference)
140
+ return failed[0].advice
141
+
142
+
143
+ def synthesize_trigger_description(
144
+ skill: Skill,
145
+ failed_prompts: list[str],
146
+ reference: Skill | None = None,
147
+ ) -> str:
148
+ """Build a description that would fire on the failed prompts — not a remix of the bad one."""
149
+ prompts = [p for p in failed_prompts if p.strip()]
150
+ blob = " ".join(prompts).lower()
151
+ pdf_shaped = "pdf" in blob or bool(re.search(r"\.pdf\b", blob))
152
+ if pdf_shaped and reference and "pdf" in reference.description.lower():
153
+ desc = _retarget_description(reference.description, skill.name)
154
+ else:
155
+ desc = _description_from_prompts(skill.name, prompts)
156
+ desc = _clip(desc, 1024)
157
+ probe = Skill(path=skill.path, name=skill.name, description=desc, body=skill.body)
158
+ for prompt in prompts:
159
+ picked = select_skill_from_descriptions(prompt, [probe])
160
+ if picked != skill.name:
161
+ desc = _force_keywords(desc, prompt)
162
+ probe.description = desc
163
+ return _clip(desc, 1024)
164
+
165
+
166
+ _VAGUE_TOKENS = {
167
+ "answers",
168
+ "anything",
169
+ "asks",
170
+ "assistance",
171
+ "blah",
172
+ "complete",
173
+ "data",
174
+ "does",
175
+ "doing",
176
+ "dummy",
177
+ "everything",
178
+ "extra",
179
+ "foo",
180
+ "general",
181
+ "generic",
182
+ "guidance",
183
+ "handle",
184
+ "handles",
185
+ "help",
186
+ "helper",
187
+ "helping",
188
+ "helps",
189
+ "info",
190
+ "information",
191
+ "ipsum",
192
+ "item",
193
+ "items",
194
+ "lorem",
195
+ "need",
196
+ "needed",
197
+ "needs",
198
+ "people",
199
+ "person",
200
+ "please",
201
+ "random",
202
+ "request",
203
+ "requests",
204
+ "skill",
205
+ "skills",
206
+ "someone",
207
+ "something",
208
+ "stuff",
209
+ "support",
210
+ "task",
211
+ "tasks",
212
+ "their",
213
+ "they",
214
+ "thing",
215
+ "things",
216
+ "useful",
217
+ "various",
218
+ "want",
219
+ "wanted",
220
+ "whatever",
221
+ "work",
222
+ "working",
223
+ "works",
224
+ }
225
+
226
+ _THIN_HINT = (
227
+ "That description is too thin to generate checks. Write what the skill does "
228
+ "and when to use it, with keywords from real requests (file types, verbs, product names)."
229
+ )
230
+ _VAGUE_HINT = (
231
+ "That description is too vague to generate useful checks. Name the work, the objects "
232
+ "(file types, products), and when to trigger — not filler like “helps with things.”"
233
+ )
234
+ _WHEN_HINT = (
235
+ "Add a “Use when …” clause so the agent knows when to trigger the skill. Example:\n\n"
236
+ "description: Extract text and merge PDFs. Use when the user mentions a .pdf file, invoices, or combining documents."
237
+ )
238
+ _PADDED_TOKEN = re.compile(r"^(.)\1{3,}$")
239
+ _WHEN_SPLIT = re.compile(
240
+ r"\b(use when|whenever|when the user|if the user|use this skill)\b",
241
+ re.IGNORECASE,
242
+ )
243
+
244
+
245
+ def description_is_usable(description: str, *, require_when: bool = True) -> tuple[bool, str]:
246
+ text = " ".join((description or "").split())
247
+ if len(text) < 60:
248
+ return False, _THIN_HINT
249
+ compact = re.sub(r"\s+", "", text)
250
+ if re.search(r"(.)\1{9,}", compact):
251
+ return False, _VAGUE_HINT
252
+ tokens = tokenize(text)
253
+ if len(tokens) < 8:
254
+ return (
255
+ False,
256
+ "Add more concrete words to the description — what it does, and the phrases a user would type.",
257
+ )
258
+ content = _content_tokens(text)
259
+ if len(content) < 4:
260
+ return False, _VAGUE_HINT
261
+ if require_when:
262
+ has_when = bool(_WHEN_SPLIT.search(text))
263
+ if not has_when:
264
+ return False, _WHEN_HINT
265
+ parts = _WHEN_SPLIT.split(text)
266
+ tail = parts[-1] if len(parts) > 1 else ""
267
+ if len(_content_tokens(tail)) < 2:
268
+ return False, _VAGUE_HINT
269
+ return True, ""
270
+
271
+
272
+ def _content_tokens(text: str) -> set[str]:
273
+ return {
274
+ token
275
+ for token in tokenize(text)
276
+ if token not in _VAGUE_TOKENS and len(token) > 2 and not _PADDED_TOKEN.match(token)
277
+ }
278
+
279
+
280
+ def rewrite_skill_description(skill_md: Path, new_description: str) -> None:
281
+ text = skill_md.read_text(encoding="utf-8")
282
+ match = FRONTMATTER_RE.match(text)
283
+ if not match:
284
+ raise ValueError("SKILL.md has no YAML frontmatter to patch.")
285
+ parsed = yaml.safe_load(match.group(1)) or {}
286
+ if not isinstance(parsed, dict):
287
+ raise ValueError("SKILL.md frontmatter is not a mapping.")
288
+ parsed["description"] = new_description
289
+ body = text[match.end() :]
290
+ dumped = yaml.safe_dump(parsed, sort_keys=False, allow_unicode=True)
291
+ skill_md.write_text(f"---\n{dumped}---\n{body}", encoding="utf-8")
292
+
293
+
294
+ def description_from_paste(markdown: str) -> str:
295
+ text = (markdown or "").strip()
296
+ if text and not text.endswith("\n"):
297
+ text += "\n"
298
+ match = FRONTMATTER_RE.match(text)
299
+ if not match:
300
+ raise ValueError("Paste a SKILL.md frontmatter block with a description.")
301
+ parsed = yaml.safe_load(match.group(1)) or {}
302
+ if not isinstance(parsed, dict):
303
+ raise ValueError("Paste a SKILL.md frontmatter block with a description.")
304
+ desc = str(parsed.get("description") or "").strip()
305
+ if not desc:
306
+ raise ValueError("The pasted frontmatter has no description.")
307
+ return desc
308
+
309
+
310
+ def apply_description_fix(
311
+ *,
312
+ source: Path,
313
+ dest_root: Path,
314
+ new_description: str,
315
+ restore_scripts_from: Path | None = None,
316
+ ) -> Path:
317
+ """Copy the skill to a working folder, write the corrected description, keep the same name."""
318
+ dest = dest_root / source.name
319
+ source = source.resolve()
320
+ dest_root.mkdir(parents=True, exist_ok=True)
321
+ if dest.exists() and dest.resolve() == source:
322
+ rewrite_skill_description(dest / "SKILL.md", new_description)
323
+ else:
324
+ if dest.exists():
325
+ shutil.rmtree(dest)
326
+ shutil.copytree(source, dest)
327
+ rewrite_skill_description(dest / "SKILL.md", new_description)
328
+ if restore_scripts_from and restore_scripts_from.is_dir():
329
+ src_scripts = restore_scripts_from / "scripts"
330
+ if src_scripts.is_dir():
331
+ dest_scripts = dest / "scripts"
332
+ if dest_scripts.exists():
333
+ shutil.rmtree(dest_scripts)
334
+ shutil.copytree(src_scripts, dest_scripts)
335
+ return dest
336
+
337
+
338
+ def _trigger_fix(
339
+ skill: Skill,
340
+ example_prompt: str,
341
+ description: str,
342
+ reference: Skill | None,
343
+ ) -> FixAdvice:
344
+ restore = None
345
+ if reference and reference.name != skill.name and (reference.path / "scripts").is_dir():
346
+ restore = reference.name
347
+ note = (
348
+ "Copy this into SKILL.md below, then run `skillgate check` on this same skill — "
349
+ "don’t switch to the good pdf twin."
350
+ if restore or "sabotag" in skill.name
351
+ else "Copy this into SKILL.md below, then run `skillgate check`."
352
+ )
353
+ return FixAdvice(
354
+ headline="Skill didn’t trigger",
355
+ why=(
356
+ f"The agent only sees name + description before opening SKILL.md. "
357
+ f"This request was: “{example_prompt}” — and `{skill.name}`’s current description "
358
+ f"never matched (it talks about something else)."
359
+ ),
360
+ change=(
361
+ "Replace the `description:` in SKILL.md with the block below, then run "
362
+ "`skillgate check` on this same skill."
363
+ ),
364
+ paste=_frontmatter(skill.name, description),
365
+ target="description",
366
+ description=description,
367
+ demo_note=note,
368
+ restore_scripts_from=restore,
369
+ )
370
+
371
+
372
+ def _retarget_description(source: str, name: str) -> str:
373
+ del name
374
+ return " ".join(source.split())
375
+
376
+
377
+ def _description_from_prompts(name: str, prompts: list[str]) -> str:
378
+ verbs = []
379
+ for verb in (
380
+ "extract",
381
+ "merge",
382
+ "combine",
383
+ "create",
384
+ "rotate",
385
+ "split",
386
+ "dump",
387
+ "fill",
388
+ "summarize",
389
+ "write",
390
+ ):
391
+ if any(verb in p.lower() for p in prompts) and verb not in verbs:
392
+ verbs.append(verb)
393
+ nouns = []
394
+ for prompt in prompts:
395
+ for token in sorted(tokenize(prompt)):
396
+ if token in _MEAL_NOISE:
397
+ continue
398
+ if token.endswith("pdf") or token in {
399
+ "pdf",
400
+ "invoice",
401
+ "invoices",
402
+ "document",
403
+ "documents",
404
+ "deck",
405
+ "decks",
406
+ }:
407
+ if token not in nouns:
408
+ nouns.append(token)
409
+ what = ", ".join(verbs[:4]) or "complete the requested files"
410
+ objects = ", ".join(nouns[:6]) or name.replace("-", " ")
411
+ when = _when_clause(prompts)
412
+ return (
413
+ f"{what.capitalize()} {objects}. Use this skill whenever the user asks to "
414
+ f"{when}."
415
+ )
416
+
417
+
418
+ def _when_clause(prompts: list[str]) -> str:
419
+ bits = []
420
+ for prompt in prompts[:3]:
421
+ cleaned = re.sub(r"\s+", " ", prompt).strip().rstrip(".")
422
+ if len(cleaned) > 90:
423
+ cleaned = cleaned[:90].rsplit(" ", 1)[0]
424
+ if cleaned and cleaned.lower() not in {b.lower() for b in bits}:
425
+ bits.append(cleaned[0].lower() + cleaned[1:] if cleaned else cleaned)
426
+ if not bits:
427
+ return "do the work described in their request"
428
+ if len(bits) == 1:
429
+ return bits[0]
430
+ return ", or ".join(bits)
431
+
432
+
433
+ def _force_keywords(desc: str, prompt: str) -> str:
434
+ extra = []
435
+ for token in ("pdf", "extract", "merge", "rotate", "invoice"):
436
+ if token in prompt.lower() and token not in desc.lower():
437
+ extra.append(token)
438
+ if not extra:
439
+ return desc
440
+ suffix = " Use when the user mentions " + ", ".join(extra) + "."
441
+ return _clip(desc.rstrip(".") + "." + suffix, 1024)
442
+
443
+
444
+ def _tighten_description(skill: Skill, negative_prompt: str) -> str:
445
+ del negative_prompt
446
+ base = skill.description.strip()
447
+ clause = " Do not use this skill for unrelated questions (weather, unit tests, general chat)."
448
+ if clause.strip().lower() in base.lower():
449
+ return base
450
+ return _clip(base + clause, 1024)
451
+
452
+
453
+ def _frontmatter(name: str, description: str) -> str:
454
+ return f"---\nname: {name}\ndescription: {description}\n---\n"
455
+
456
+
457
+ def _clip(text: str, limit: int) -> str:
458
+ text = " ".join(text.split())
459
+ if len(text) <= limit:
460
+ return text
461
+ return text[: limit - 3].rsplit(" ", 1)[0] + "..."
462
+
463
+
464
+ def _named_output_path(description: str, body: str) -> str | None:
465
+ match = re.search(r"(output/[A-Za-z0-9_.-]+)", f"{description}\n{body}")
466
+ return match.group(1) if match else None
467
+
468
+
469
+ def _task_output_path(task: TaskSpec) -> str | None:
470
+ for spec in task.scorers:
471
+ for item in spec.files:
472
+ path = str(item.path or "").strip()
473
+ if path:
474
+ return path
475
+ return None
476
+
477
+
478
+ def _suggested_steps(task: TaskSpec, missing: str, *, write_to: str | None = None) -> str:
479
+ out = write_to or _task_output_path(task) or "output/result.txt"
480
+ return (
481
+ f"## {task.id}\n\n"
482
+ f"When the user asks: {task.prompt}\n\n"
483
+ f"1. Use this skill’s scripts if they exist (`scripts/`).\n"
484
+ f"2. Write the result to `{out}`.\n"
485
+ f"3. Do not finish until that file exists. ({missing})\n"
486
+ )