1"""ll-verify-triggers: Validate skill description trigger accuracy.

2

3 Empirically validates whether each skill's ``description`` field fires correctly:

4 it should trigger on a set of realistic should-fire phrasings and stay silent on

5 a set of near-miss should-NOT-fire phrasings. Reports per-skill precision/recall

6 and a cross-skill collision matrix. Exits non-zero when any skill falls below

7 threshold or collides with another skill.

8"""

9

10 from __future__ import annotations

11

12 import argparse

13 import json

14 import re

15 import sys

16 from collections import defaultdict

17 from dataclasses import dataclass, field

18 from pathlib import Path

19

20 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

21

22# ---------------------------------------------------------------------------

23# Data types

24# ---------------------------------------------------------------------------

25

26 STOPWORDS: frozenset[str] = frozenset(

27 {

28 "the",

29 "a",

30 "an",

31 "is",

32 "are",

33 "was",

34 "were",

35 "be",

36 "been",

37 "being",

38 "have",

39 "has",

40 "had",

41 "do",

42 "does",

43 "did",

44 "will",

45 "would",

46 "could",

47 "should",

48 "may",

49 "might",

50 "can",

51 "shall",

52 "to",

53 "of",

54 "in",

55 "for",

56 "on",

57 "with",

58 "at",

59 "by",

60 "from",

61 "as",

62 "or",

63 "and",

64 "not",

65 "no",

66 "but",

67 "if",

68 "then",

69 "else",

70 "when",

71 "where",

72 "how",

73 "all",

74 "each",

75 "every",

76 "both",

77 "few",

78 "more",

79 "most",

80 "other",

81 "some",

82 "such",

83 "only",

84 "own",

85 "same",

86 "so",

87 "than",

88 "too",

89 "very",

90 "just",

91 "about",

92 "above",

93 "after",

94 "again",

95 "against",

96 "any",

97 "because",

98 "before",

99 "between",

100 "into",

101 "through",

102 "during",

103 "under",

104 "over",

105 "its",

106 "it",

107 "this",

108 "that",

109 "these",

110 "those",

111 "use",

112 "used",

113 "using",

114 "also",

115 "get",

116 "got",

117 "make",

118 "made",

119 "see",

120 }

121)

122

123_MIN_WORD_LEN = 3

124

125

126@dataclass

127 class TriggerFixtures:

128 """Parsed trigger fixture data from a skill's frontmatter."""

129

130 should_fire: list[str]

131 should_not_fire: list[str]

132

133

134@dataclass

135 class PrecisionRecall:

136 """Precision and recall metrics for a single skill."""

137

138 precision: float

139 recall: float

140

141

142@dataclass

143 class SkillTriggerResult:

144 """Per-skill validation result."""

145

146 skill_name: str

147 description: str

148 tp: int = 0

149 fp: int = 0

150 fn: int = 0

151 precision: float = 0.0

152 recall: float = 0.0

153 false_positive_phrasings: list[str] = field(default_factory=list)

154 false_negative_phrasings: list[str] = field(default_factory=list)

155

156

157# Naming aliases for test compatibility

158 PhrasingFixture = TriggerFixtures

159

160

161# ---------------------------------------------------------------------------

162# Keyword extraction

163# ---------------------------------------------------------------------------

164

165

166 def _extract_keywords(description: str) -> set[str]:

167 """Extract matchable keywords from a skill description.

168

169 Looks for an explicit ``Trigger keywords:`` line first; falls back to

170 extracting all substantive words from the description body, filtering

171 stopwords and short tokens.

172 """

173 for line in description.splitlines():

174 stripped = line.strip()

175 if stripped.lower().startswith("trigger keywords"):

176 # Everything after the colon

177 if ":" in stripped:

178 trigger_text = stripped.split(":", 1)[1].strip()

179 else:

180 trigger_text = ""

181 return _tokenize(trigger_text)

182

183 # Fallback: tokenize the full description

184 return _tokenize(description)

185

186

187 def _tokenize(text: str) -> set[str]:

188 """Tokenize text into a set of normalized keywords."""

189 tokens: set[str] = set()

190 for word in re.findall(r"[a-zA-Z][a-zA-Z0-9_-]*", text.lower()):

191 if len(word) >= _MIN_WORD_LEN and word not in STOPWORDS:

192 tokens.add(word)

193 return tokens

194

195

196# ---------------------------------------------------------------------------

197# Matching

198# ---------------------------------------------------------------------------

199

200

201 def _match_phrasing(phrasing: str, keywords: set[str]) -> bool:

202 """Return True if *phrasing* contains at least one keyword token."""

203 phrasing_tokens = _tokenize(phrasing)

204 return bool(phrasing_tokens & keywords)

205

206

207 def _match_score(phrasing: str, keywords: set[str]) -> int:

208 """Number of keyword tokens found in the phrasing."""

209 phrasing_tokens = _tokenize(phrasing)

210 return len(phrasing_tokens & keywords)

211

212

213# ---------------------------------------------------------------------------

214# Fixture loading

215# ---------------------------------------------------------------------------

216

217

218 def _load_trigger_fixtures(skill_md_path: Path) -> TriggerFixtures | None:

219 """Parse trigger_fixtures from a SKILL.md frontmatter block.

220

221 Returns None if the file has no trigger_fixtures or empty lists.

222 """

223 try:

224 text = skill_md_path.read_text()

225 except OSError:

226 return None

227

228 if not text.startswith("---"):

229 return None

230

231 end = text.find("---", 3)

232 if end == -1:

233 return None

234

235 # Quick substring check before attempting YAML parse

236 fm_text = text[3:end]

237 if "trigger_fixtures" not in fm_text:

238 return None

239

240 import yaml

241

242 try:

243 fm = yaml.safe_load(fm_text)

244 except yaml.YAMLError:

245 return None

246

247 if not isinstance(fm, dict):

248 return None

249

250 fixtures = fm.get("trigger_fixtures")

251 if not isinstance(fixtures, dict):

252 return None

253

254 should_fire = fixtures.get("should_fire", [])

255 should_not_fire = fixtures.get("should_not_fire", [])

256

257 if not isinstance(should_fire, list) or not isinstance(should_not_fire, list):

258 return None

259

260 if not should_fire and not should_not_fire:

261 return None

262

263 return TriggerFixtures(

264 should_fire=[str(s) for s in should_fire],

265 should_not_fire=[str(s) for s in should_not_fire],

266 )

267

268

269# ---------------------------------------------------------------------------

270# Skill description loading

271# ---------------------------------------------------------------------------

272

273

274 def _load_skill_descriptions(skills_dir: Path) -> dict[str, tuple[str, Path]]:

275 """Load (description, path) for all SKILL.md files under *skills_dir*.

276

277 Returns a dict mapping skill name → (description, path).

278 Skills without frontmatter or a description are skipped.

279 """

280 skills: dict[str, tuple[str, Path]] = {}

281

282 if not skills_dir.is_dir():

283 return skills

284

285 for skill_md in sorted(skills_dir.glob("*/SKILL.md")):

286 try:

287 text = skill_md.read_text()

288 except OSError:

289 continue

290

291 if not text.startswith("---"):

292 continue

293

294 end = text.find("---", 3)

295 if end == -1:

296 continue

297

298 fm_text = text[3:end]

299

300 import yaml

301

302 try:

303 fm = yaml.safe_load(fm_text)

304 except yaml.YAMLError:

305 continue

306

307 if not isinstance(fm, dict):

308 continue

309

310 name = fm.get("name") or skill_md.parent.name

311 desc = fm.get("description", "")

312 if desc:

313 skills[name] = (str(desc), skill_md)

314

315 return skills

316

317

318# ---------------------------------------------------------------------------

319# Metrics computation

320# ---------------------------------------------------------------------------

321

322

323 def _compute_precision_recall(tp: int, fp: int, fn: int) -> PrecisionRecall:

324 """Return (precision, recall) from confusion-matrix counts."""

325 precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0

326 recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0

327 return PrecisionRecall(precision=precision, recall=recall)

328

329

330 def _detect_collisions(

331 results: dict[str, SkillTriggerResult],

332 phrasing_matches: dict[str, set[str]],

333) -> list[dict]:

334 """Detect cross-skill collisions from phrase-level match data.

335

336 A collision occurs when a single phrasing triggered more than one skill.

337 """

338 collisions: list[dict] = []

339 for phrasing, matched_skills in sorted(phrasing_matches.items()):

340 if len(matched_skills) > 1:

341 collisions.append(

342 {

343 "phrasing": phrasing,

344 "skills": sorted(matched_skills),

345 }

346 )

347 return collisions

348

349

350# ---------------------------------------------------------------------------

351# Core validation

352# ---------------------------------------------------------------------------

353

354

355 def _run_validation(

356 skills_dir: Path,

357 precision_threshold: float = 0.5,

358 recall_threshold: float = 0.5,

359) -> tuple[dict[str, SkillTriggerResult], list[dict], dict]:

360 """Run full trigger validation across all skills.

361

362 Returns (results_by_skill, collisions, thresholds_dict).

363 """

364 # 1. Load skill descriptions and extract keywords

365 skill_descs = _load_skill_descriptions(skills_dir)

366 skill_keywords: dict[str, set[str]] = {

367 name: _extract_keywords(desc) for name, (desc, _) in skill_descs.items()

368 }

369

370 # 2. Load trigger fixtures per skill

371 skill_fixtures: dict[str, TriggerFixtures] = {}

372 for name, (_, path) in skill_descs.items():

373 fixtures = _load_trigger_fixtures(path)

374 if fixtures is not None:

375 skill_fixtures[name] = fixtures

376

377 # 3. Evaluate each skill with fixtures

378 results: dict[str, SkillTriggerResult] = {}

379 phrasing_matches: dict[str, set[str]] = defaultdict(set)

380

381 for skill_name, (desc, _) in skill_descs.items():

382 fixtures = skill_fixtures.get(skill_name)

383 if fixtures is None:

384 results[skill_name] = SkillTriggerResult(

385 skill_name=skill_name,

386 description=desc,

387 )

388 continue

389

390 tp = 0

391 fp = 0

392 fn = 0

393 fp_phrasings: list[str] = []

394 fn_phrasings: list[str] = []

395

396 # Evaluate should_fire phrasings

397 for phrasing in fixtures.should_fire:

398 # Check which skills this phrasing matches

399 matched: list[str] = []

400 for other_name, other_kw in skill_keywords.items():

401 if _match_phrasing(phrasing, other_kw):

402 matched.append(other_name)

403

404 if matched:

405 phrasing_matches[phrasing] |= set(matched)

406

407 if skill_name in matched:

408 tp += 1

409 else:

410 fn += 1

411 fn_phrasings.append(phrasing)

412

413 # Evaluate should_not_fire phrasings

414 for phrasing in fixtures.should_not_fire:

415 matched_not: list[str] = []

416 for other_name, other_kw in skill_keywords.items():

417 if _match_phrasing(phrasing, other_kw):

418 matched_not.append(other_name)

419

420 if matched_not:

421 phrasing_matches[phrasing] |= set(matched_not)

422

423 if skill_name in matched_not:

424 fp += 1

425 fp_phrasings.append(phrasing)

426

427 pr = _compute_precision_recall(tp, fp, fn)

428 results[skill_name] = SkillTriggerResult(

429 skill_name=skill_name,

430 description=desc,

431 tp=tp,

432 fp=fp,

433 fn=fn,

434 precision=pr.precision,

435 recall=pr.recall,

436 false_positive_phrasings=fp_phrasings,

437 false_negative_phrasings=fn_phrasings,

438 )

439

440 # 4. Detect collisions

441 collisions = _detect_collisions(results, dict(phrasing_matches))

442

443 thresholds = {

444 "precision_threshold": precision_threshold,

445 "recall_threshold": recall_threshold,

446 }

447

448 return results, collisions, thresholds

449

450

451# ---------------------------------------------------------------------------

452# Formatting

453# ---------------------------------------------------------------------------

454

455

456 def _format_text_report(

457 results: dict[str, SkillTriggerResult],

458 collisions: list[dict],

459 thresholds: dict,

460) -> str:

461 """Format a human-readable text report."""

462 lines: list[str] = []

463 lines.append("Skill Trigger Validation Report")

464 lines.append("=" * 50)

465 lines.append(

466 f"Thresholds: precision ≥ {thresholds['precision_threshold']:.0%}, "

467 f"recall ≥ {thresholds['recall_threshold']:.0%}"

468 )

469 lines.append("")

470

471 # Per-skill table

472 if results:

473 lines.append(f"{'Skill':<40} {'Precision':>10} {'Recall':>10}")

474 lines.append(f"{'':-<40} {'':->10} {'':->10}")

475 for name in sorted(results):

476 r = results[name]

477 lines.append(f"{name:<40} {r.precision:>10.0%} {r.recall:>10.0%}")

478 lines.append("")

479

480 # Collisions

481 if collisions:

482 lines.append("Cross-Skill Collisions")

483 lines.append("=" * 50)

484 for c in collisions:

485 skills = ", ".join(c["skills"])

486 lines.append(f' Phrasing: "{c["phrasing"]}"')

487 lines.append(f" Colliding skills: {skills}")

488 lines.append("")

489 else:

490 lines.append("No cross-skill collisions detected.")

491 lines.append("")

492

493 # Failures

494 failures: list[str] = []

495 for name in sorted(results):

496 r = results[name]

497 if r.precision < thresholds["precision_threshold"]:

498 failures.append(

499 f" {name}: precision {r.precision:.0%} < {thresholds['precision_threshold']:.0%}"

500 )

501 if r.recall < thresholds["recall_threshold"]:

502 failures.append(

503 f" {name}: recall {r.recall:.0%} < {thresholds['recall_threshold']:.0%}"

504 )

505

506 if collisions:

507 failures.append(" Collisions detected")

508 if failures:

509 lines.append("FAILURES")

510 lines.append("=" * 50)

511 for f in failures:

512 lines.append(f)

513

514 return "\n".join(lines)

515

516

517 def _format_json_report(

518 results: dict[str, SkillTriggerResult],

519 collisions: list[dict],

520 thresholds: dict,

521) -> str:

522 """Format results as JSON."""

523 skills_list = []

524 for name in sorted(results):

525 r = results[name]

526 skills_list.append(

527 {

528 "name": r.skill_name,

529 "description": r.description,

530 "precision": r.precision,

531 "recall": r.recall,

532 "tp": r.tp,

533 "fp": r.fp,

534 "fn": r.fn,

535 "false_positive_phrasings": r.false_positive_phrasings,

536 "false_negative_phrasings": r.false_negative_phrasings,

537 }

538 )

539

540 return json.dumps(

541 {

542 "thresholds": thresholds,

543 "skills": skills_list,

544 "collisions": collisions,

545 },

546 indent=2,

547 )

548

549

550# ---------------------------------------------------------------------------

551# Orchestration

552# ---------------------------------------------------------------------------

553

554

555 def _any_failures(

556 results: dict[str, SkillTriggerResult],

557 collisions: list[dict],

558 precision_threshold: float,

559 recall_threshold: float,

560) -> bool:

561 """True when any skill is below threshold or collisions exist."""

562 if collisions:

563 return True

564 for r in results.values():

565 if r.precision < precision_threshold:

566 return True

567 if r.recall < recall_threshold:

568 return True

569 return False

570

571

572# ---------------------------------------------------------------------------

573# Entry point

574# ---------------------------------------------------------------------------

575

576

577 def main_verify_triggers() -> int:

578 """Entry point for ll-verify-triggers CLI.

579

580 Returns 0 when all skills meet threshold and there are no collisions;

581 returns 1 otherwise.

582 """

583 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-triggers", sys.argv[1:]):

584 parser = argparse.ArgumentParser(

585 prog="ll-verify-triggers",

586 description=(

587 "Validate skill description trigger accuracy against should-fire "

588 "and should-NOT-fire phrasings. Reports per-skill precision/recall "

589 "and cross-skill collision matrix."

590 ),

591 formatter_class=argparse.RawDescriptionHelpFormatter,

592 epilog="""\

593 Examples:

594 %(prog)s # Validate all skills against default thresholds

595 %(prog)s --json # Machine-readable JSON output

596 %(prog)s --precision-threshold 0.8 --recall-threshold 0.6

597

598 Exit codes:

599 0 - All skills meet thresholds, no collisions

600 1 - One or more skills below threshold, or collisions detected

601""",

602 )

603 parser.add_argument(

604 "-C",

605 "--directory",

606 type=Path,

607 default=None,

608 help="Base directory (default: current directory)",

609 )

610 parser.add_argument(

611 "--json",

612 action="store_true",

613 default=False,

614 help="Output results as JSON",

615 )

616 parser.add_argument(

617 "--precision-threshold",

618 type=float,

619 default=0.5,

620 metavar="F",

621 help="Minimum precision required (default: 0.5)",

622 )

623 parser.add_argument(

624 "--recall-threshold",

625 type=float,

626 default=0.5,

627 metavar="F",

628 help="Minimum recall required (default: 0.5)",

629 )

630

631 args = parser.parse_args()

632

633 base_dir = args.directory or Path.cwd()

634 skills_dir = base_dir / "skills"

635

636 if not skills_dir.exists():

637 print(

638 f"ERROR: skills directory not found: {skills_dir}",

639 file=sys.stderr,

640 )

641 return 1

642

643 results, collisions, thresholds = _run_validation(

644 skills_dir,

645 precision_threshold=args.precision_threshold,

646 recall_threshold=args.recall_threshold,

647 )

648

649 if args.json:

650 print(_format_json_report(results, collisions, thresholds))

651 else:

652 print(_format_text_report(results, collisions, thresholds))

653

654 if _any_failures(

655 results,

656 collisions,

657 args.precision_threshold,

658 args.recall_threshold,

659 ):

660 return 1

661 return 0