CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

jbaruch/speaker-toolkit

Eleven skills for conference speakers covering rhetoric analysis, talk authoring, deck building and export, CFP submissions, publishing, illustrations, shownotes, and screencast production, informed by a 113-entry Presentation Patterns taxonomy (83 observable: 64 patterns + 19 antipatterns; 30 unobservable: 21 patterns + 9 antipatterns).

Quality

90%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

compute-pacing-adherence.pyskills/vault-profile/scripts/

#!/usr/bin/env python3
"""Compute the speaker-profile `pacing.adherence` object from scored talks.

Deterministic helper for vault-profile Step 4 (per `rules/script-delegation.md`):
parses each talk's duration, derives slides-per-minute, classifies it against the
speaker's slide budgets, and emits the corpus-level adherence summary. The skill
orchestrator pipes in the scored talks plus the budget table and copies the result
into `pacing.adherence`.

Contract
--------
Stdin (JSON):
    {
      "talks": [
        {"filename": "...", "date": "YYYY-MM-DD",
         "slide_count": <int>, "talk_duration_estimate": "35 min"}
      ],
      "slide_budgets": [
        {"duration_min": <int>, "max_slides": <int>, "slides_per_min": <float>}
      ],
      "worst_n": 5            # optional, default 5
    }

Stdout (JSON):
    {
      "talks_over_budget": <int>,
      "talks_scored":      <int>,
      "over_budget_rate":  <float>,          # 0.0 when nothing scored
      "trend":             "improving|stable|worsening",
      "worst_offenders": [
        {"filename": "...", "slides_per_minute": <float>,
         "budget_slides_per_minute": <float>, "over_by": "40%"}
      ]
    }

Duration parsing: an hour quantity in `talk_duration_estimate` ("h", "hr",
"hour", decimals allowed) converts to minutes and adds a directly following
minute quantity ("2 h 01 min" -> 121, "2h01m" -> 121, "1.5 hours" -> 90,
".5 hours" -> 30). A quantity longer than six digits is malformed input and
exits 1 with a diagnostic. Otherwise the first
integer is the value in minutes ("~45-50 min" -> 45). Talks with no
parseable minutes, missing/zero slide_count, or zero minutes are skipped (not
scored). Budget band: the entry with the largest duration <= the talk's minutes
(band duration read from `duration_min` or `duration_minutes`), or the smallest
band when the talk is shorter than every band.
Trend compares the over-budget rate of the older half vs. the newer half of the
date-sorted scored talks; fewer than 4 scored talks yields "stable".

Exit codes:
    0   success
    1   malformed stdin JSON or wrong shape. Diagnostic goes to stderr.
"""

from __future__ import annotations

import json
import re
from fractions import Fraction
import sys


TREND_EPSILON = 0.10
MIN_TALKS_FOR_TREND = 4
DEFAULT_WORST_N = 5


# A unit ends where letters end, so compact "2h01m" matches while "5 happy" does not.
# The quantity starts only at a number's first character and has one reading, so
# a long digit run is scanned linearly instead of backtracking.
_HOURS_MINUTES = re.compile(
    r"(?<![\d.])(\d+(?:\.\d+)?|\.\d+)\s*(?:hours?|hrs?|h)(?![a-z])"
    r"(?:\s*(\d+)\s*(?:minutes?|mins?|m)(?![a-z]))?",
    re.IGNORECASE,
)


# No talk runs a million minutes; a longer quantity is malformed input, not a duration.
_MAX_QUANTITY_DIGITS = 6


def _quantity(text: str, estimate: str) -> str:
    if len(text.replace(".", "")) > _MAX_QUANTITY_DIGITS:
        raise ValueError(
            f"talk_duration_estimate quantity {text[:12]}... is longer than "
            f"{_MAX_QUANTITY_DIGITS} digits; write minutes like '45 min' or "
            f"'2 h 01 min' (estimate starts {estimate[:40]!r})"
        )
    return text


def parse_minutes(estimate: object) -> int | None:
    """Minutes in a duration estimate.

    An hour quantity ("2 h", "1.5 hours", "2 h 01 min") converts to minutes and
    adds a directly following minute quantity. Otherwise the first integer is
    read as minutes ("35 min", "45-50 min" -> 45).
    """
    if not isinstance(estimate, str):
        return None
    hours = _HOURS_MINUTES.search(estimate)
    if hours is not None:
        # Fraction keeps decimal hours exact; no float can overflow.
        hour_text = _quantity(hours.group(1), estimate)
        minute_text = _quantity(hours.group(2) or "0", estimate)
        value = round(Fraction(hour_text) * 60) + int(minute_text)
        return value or None
    match = re.search(r"\d+", estimate)
    if match is None:
        return None
    return int(_quantity(match.group(), estimate)) or None


def _band_minutes(band: dict) -> int:
    """Band duration, tolerating `duration_min` or `duration_minutes`.

    Matches the both-key tolerance in presentation-creator/guardrail-check.py.
    """
    value = band.get("duration_min")
    if value is None:
        value = band.get("duration_minutes")
    if value is None:
        raise KeyError("budget band missing duration_min/duration_minutes")
    return value


def budget_for(minutes: int, budgets: list[dict]) -> float | None:
    """slides_per_min of the applicable band (largest duration <= minutes)."""
    if not budgets:
        return None
    ordered = sorted(budgets, key=_band_minutes)
    applicable = [b for b in ordered if _band_minutes(b) <= minutes]
    band = applicable[-1] if applicable else ordered[0]
    value = float(band["slides_per_min"])
    if value <= 0:
        raise ValueError(f"slide budget slides_per_min must be positive, got {value}")
    return value


def _over_budget_rate(talks: list[dict]) -> float:
    if not talks:
        return 0.0
    return sum(1 for t in talks if t["over_budget"]) / len(talks)


def compute(payload: dict) -> dict:
    budgets = payload.get("slide_budgets", [])
    worst_n = payload.get("worst_n", DEFAULT_WORST_N)
    scored: list[dict] = []

    for talk in payload.get("talks", []):
        slide_count = talk.get("slide_count")
        minutes = parse_minutes(talk.get("talk_duration_estimate", ""))
        if not slide_count or not minutes:
            continue
        budget = budget_for(minutes, budgets)
        if budget is None:
            continue
        spm = slide_count / minutes
        scored.append(
            {
                "filename": talk.get("filename"),
                "date": talk.get("date", ""),
                "slides_per_minute": round(spm, 2),
                "budget_slides_per_minute": budget,
                "over_budget": spm > budget,
                "over_by_ratio": (spm / budget) - 1.0,
            }
        )

    over = [t for t in scored if t["over_budget"]]
    worst = sorted(over, key=lambda t: t["over_by_ratio"], reverse=True)[:worst_n]

    trend = "stable"
    if len(scored) >= MIN_TALKS_FOR_TREND:
        chrono = sorted(scored, key=lambda t: t["date"])
        half = len(chrono) // 2
        delta = _over_budget_rate(chrono[half:]) - _over_budget_rate(chrono[:half])
        if delta > TREND_EPSILON:
            trend = "worsening"
        elif delta < -TREND_EPSILON:
            trend = "improving"

    return {
        "talks_over_budget": len(over),
        "talks_scored": len(scored),
        "over_budget_rate": round(_over_budget_rate(scored), 2),
        "trend": trend,
        "worst_offenders": [
            {
                "filename": t["filename"],
                "slides_per_minute": t["slides_per_minute"],
                "budget_slides_per_minute": t["budget_slides_per_minute"],
                "over_by": f"{round(t['over_by_ratio'] * 100)}%",
            }
            for t in worst
        ],
    }


def main() -> int:
    try:
        payload = json.loads(sys.stdin.read())
    except json.JSONDecodeError as exc:
        print(f"ERROR: stdin is not valid JSON: {exc}", file=sys.stderr)
        return 1
    if not isinstance(payload, dict):
        print(
            "ERROR: stdin JSON must be an object with 'talks' and 'slide_budgets'.",
            file=sys.stderr,
        )
        return 1
    if not isinstance(payload.get("talks", []), list) or not isinstance(
        payload.get("slide_budgets", []), list
    ):
        print(
            "ERROR: 'talks' and 'slide_budgets' must be JSON arrays.", file=sys.stderr
        )
        return 1
    try:
        result = compute(payload)
    except (AttributeError, KeyError, TypeError, ValueError) as exc:
        print(f"ERROR: malformed payload shape: {exc}", file=sys.stderr)
        return 1
    print(json.dumps(result, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())

skills

README.md

tile.json