prepass-prompt-metrics.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.9"
  4. # dependencies = ["tiktoken"]
  5. # ///
  6. """Deterministic prompt-metrics pre-pass for the Analyze scanners.
  7. Reads SKILL.md, root-level prompt files, and references, and emits one compact
  8. JSON object the LLM scanners read instead of the raw files. Length is reported
  9. as tiktoken token counts via count_tokens (cl100k_base, chars//4 fallback);
  10. there is no line-count gate anywhere in this script.
  11. What it surfaces per file:
  12. - token count and the counting method (tiktoken or fallback)
  13. - frontmatter facts (name, description, description length, angle-bracket flag)
  14. - section inventory (heading level + title)
  15. - structural signals scanners care about: tables, fenced blocks, defensive
  16. padding, meta-explanation, back-references, config header, progression cues
  17. Budgets the scanners compare against: SKILL.md ~1500-2500 tokens,
  18. multi-branch reference ~4500, single-purpose reference ~9000.
  19. Usage:
  20. prepass-prompt-metrics.py <skill-dir> [--output FILE]
  21. """
  22. from __future__ import annotations
  23. import argparse
  24. import json
  25. import re
  26. import sys
  27. from datetime import datetime, timezone
  28. from pathlib import Path
  29. # Reuse the single length metric rather than reimplementing token counting.
  30. sys.path.insert(0, str(Path(__file__).resolve().parent))
  31. try:
  32. from count_tokens import count_tokens
  33. except Exception: # pragma: no cover - count_tokens ships alongside this script
  34. def count_tokens(text: str) -> tuple[int, str]:
  35. return len(text) // 4, "fallback"
  36. WASTE_PATTERNS = [
  37. (r"\b[Mm]ake sure (?:to|you)\b", "defensive-padding", 'Defensive: "make sure to/you"'),
  38. (r"\b[Dd]on'?t forget (?:to|that)\b", "defensive-padding", 'Defensive: "don\'t forget"'),
  39. (r"\b[Rr]emember (?:to|that)\b", "defensive-padding", 'Defensive: "remember to/that"'),
  40. (r"\b[Bb]e sure to\b", "defensive-padding", 'Defensive: "be sure to"'),
  41. (r"\b[Pp]lease ensure\b", "defensive-padding", 'Defensive: "please ensure"'),
  42. (r"\b[Ii]t is important (?:to|that)\b", "defensive-padding", 'Defensive: "it is important"'),
  43. (r"\b[Yy]ou are an AI\b", "meta-explanation", 'Meta: "you are an AI"'),
  44. (r"\b[Aa]s a language model\b", "meta-explanation", 'Meta: "as a language model"'),
  45. (r"\b[Aa]s an AI assistant\b", "meta-explanation", 'Meta: "as an AI assistant"'),
  46. (r"\b[Tt]his (?:workflow|skill|process) is designed to\b", "meta-explanation", 'Meta: "this is designed to"'),
  47. (r"\b[Tt]he purpose of this (?:section|step) is\b", "meta-explanation", 'Meta: "the purpose of this is"'),
  48. ]
  49. BACKREF_PATTERNS = [
  50. (r"\bas described above\b", 'Back-reference: "as described above"'),
  51. (r"\bas mentioned (?:above|in|earlier)\b", 'Back-reference: "as mentioned above/earlier"'),
  52. (r"\bsee (?:above|the overview)\b", 'Back-reference: "see above/the overview"'),
  53. (r"\brefer to (?:the )?(?:above|overview|SKILL)\b", 'Back-reference: "refer to above/overview"'),
  54. ]
  55. ALLCAPS_PATTERN = re.compile(r"\b(?:ALWAYS|NEVER|MUST|DO NOT|CRITICAL|REQUIRED)\b")
  56. NUMBERED_PREFIX = re.compile(r"^\d{2}[-_]")
  57. def split_frontmatter(content: str) -> tuple[dict, str]:
  58. """Return (frontmatter dict, body). Empty dict when there is no frontmatter."""
  59. lines = content.splitlines()
  60. if not lines or lines[0].strip() != "---":
  61. return {}, content
  62. end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
  63. if end is None:
  64. return {}, content
  65. meta: dict[str, str] = {}
  66. for line in lines[1:end]:
  67. if ":" in line:
  68. k, v = line.split(":", 1)
  69. meta[k.strip()] = v.strip()
  70. return meta, "\n".join(lines[end + 1:])
  71. def count_tables(content: str) -> tuple[int, int]:
  72. count = rows = 0
  73. in_table = False
  74. for line in content.split("\n"):
  75. if re.match(r"^\s*\|", line):
  76. if not in_table:
  77. count += 1
  78. in_table = True
  79. rows += 1
  80. else:
  81. in_table = False
  82. return count, rows
  83. def count_fenced(content: str) -> int:
  84. blocks = 0
  85. in_block = False
  86. for line in content.split("\n"):
  87. if line.strip().startswith("```"):
  88. in_block = not in_block
  89. if in_block:
  90. blocks += 1
  91. return blocks
  92. def grep(content: str, lines: list[str], patterns, ignore_case: bool = False) -> list[dict]:
  93. flags = re.IGNORECASE if ignore_case else 0
  94. hits = []
  95. for entry in patterns:
  96. pattern, *rest = entry
  97. if len(rest) == 2:
  98. category, label = rest
  99. else:
  100. category, label = None, rest[0]
  101. for m in re.finditer(pattern, content, flags):
  102. ln = content[: m.start()].count("\n") + 1
  103. hit = {"line": ln, "pattern": label, "context": lines[ln - 1].strip()[:100]}
  104. if category:
  105. hit["category"] = category
  106. hits.append(hit)
  107. return hits
  108. def scan_file(filepath: Path, rel_path: str) -> dict:
  109. content = filepath.read_text(encoding="utf-8")
  110. lines = content.split("\n")
  111. meta, body = split_frontmatter(content)
  112. tokens, method = count_tokens(content)
  113. sections = [
  114. {"level": len(m.group(1)), "title": m.group(2).strip()}
  115. for m in (re.match(r"^(#{2,4})\s+(.+)$", ln) for ln in lines)
  116. if m
  117. ]
  118. table_count, table_rows = count_tables(content)
  119. allcaps = len(ALLCAPS_PATTERN.findall(content))
  120. data = {
  121. "file": rel_path,
  122. "tokens": tokens,
  123. "token_method": method,
  124. "sections": sections,
  125. "table_count": table_count,
  126. "table_rows": table_rows,
  127. "fenced_block_count": count_fenced(content),
  128. "allcaps_directive_count": allcaps,
  129. "numbered_prefix_filename": bool(NUMBERED_PREFIX.match(filepath.name)),
  130. "waste_patterns": grep(content, lines, WASTE_PATTERNS),
  131. "back_references": grep(content, lines, BACKREF_PATTERNS, ignore_case=True),
  132. }
  133. if meta:
  134. desc = meta.get("description", "")
  135. data["frontmatter"] = {
  136. "name": meta.get("name", ""),
  137. "description": desc,
  138. "description_chars": len(desc),
  139. "description_has_angle_brackets": "<" in desc or ">" in desc,
  140. "keys": sorted(meta.keys()),
  141. }
  142. return data
  143. def scan(skill_path: Path) -> dict:
  144. files_data = []
  145. skill_md = skill_path / "SKILL.md"
  146. if skill_md.exists():
  147. d = scan_file(skill_md, "SKILL.md")
  148. d["is_skill_md"] = True
  149. files_data.append(d)
  150. for f in sorted(skill_path.iterdir()):
  151. if f.is_file() and f.suffix == ".md" and f.name != "SKILL.md":
  152. d = scan_file(f, f.name)
  153. d["is_skill_md"] = False
  154. files_data.append(d)
  155. references = {}
  156. ref_dir = skill_path / "references"
  157. if ref_dir.exists():
  158. for f in sorted(ref_dir.iterdir()):
  159. if f.is_file() and f.suffix in (".md", ".json", ".yaml", ".yml"):
  160. tokens, method = count_tokens(f.read_text(encoding="utf-8"))
  161. references[f.name] = {
  162. "tokens": tokens,
  163. "token_method": method,
  164. "numbered_prefix_filename": bool(NUMBERED_PREFIX.match(f.name)),
  165. }
  166. skill_md_data = next((f for f in files_data if f.get("is_skill_md")), None)
  167. return {
  168. "scanner": "prompt-metrics-prepass",
  169. "script": "prepass-prompt-metrics.py",
  170. "version": "2.0.0",
  171. "skill_path": str(skill_path),
  172. "timestamp": datetime.now(timezone.utc).isoformat(),
  173. "budgets": {
  174. "skill_md_tokens": [1500, 2500],
  175. "multi_branch_reference_tokens": 4500,
  176. "single_purpose_reference_tokens": 9000,
  177. },
  178. "skill_md": {
  179. "tokens": skill_md_data["tokens"] if skill_md_data else 0,
  180. "token_method": skill_md_data["token_method"] if skill_md_data else "fallback",
  181. "section_count": len(skill_md_data["sections"]) if skill_md_data else 0,
  182. "frontmatter": skill_md_data.get("frontmatter") if skill_md_data else None,
  183. },
  184. "aggregate": {
  185. "total_files_scanned": len(files_data),
  186. "total_tokens": sum(f["tokens"] for f in files_data),
  187. "total_waste_patterns": sum(len(f["waste_patterns"]) for f in files_data),
  188. "total_back_references": sum(len(f["back_references"]) for f in files_data),
  189. "files_with_numbered_prefix": sum(
  190. 1 for f in files_data if f["numbered_prefix_filename"]
  191. ) + sum(1 for r in references.values() if r["numbered_prefix_filename"]),
  192. },
  193. "reference_sizes": references,
  194. "files": files_data,
  195. }
  196. def main(argv: list[str] | None = None) -> int:
  197. p = argparse.ArgumentParser(description="Token-based prompt metrics for the Analyze scanners")
  198. p.add_argument("skill_path", type=Path, help="path to the skill directory to scan")
  199. p.add_argument("--output", "-o", type=Path, help="write JSON to a file instead of stdout")
  200. args = p.parse_args(argv)
  201. if not args.skill_path.is_dir():
  202. print(f"error: {args.skill_path} is not a directory", file=sys.stderr)
  203. return 2
  204. output = json.dumps(scan(args.skill_path), indent=2)
  205. if args.output:
  206. args.output.parent.mkdir(parents=True, exist_ok=True)
  207. args.output.write_text(output)
  208. print(f"results written to {args.output}", file=sys.stderr)
  209. else:
  210. print(output)
  211. return 0
  212. if __name__ == "__main__":
  213. sys.exit(main())