DEV Community

Cover image for How I moved AI video archetype selection from prose to a code-owned daily directive
MORINAGA
MORINAGA

Posted on Edited on

How I moved AI video archetype selection from prose to a code-owned daily directive

The problem: a 148-line knowledge bank the routine was skimming

When I first built the YouTube analytics pipeline for this experiment, I structured my findings as a readable knowledge bank — docs/yt-knowhow-bank-en.md. The idea was that the LLM routine generating each day's short-form video script would read this file, absorb the findings, and adjust its output accordingly.

In practice, the routine read the knowledge bank and then ignored it.

The clearest symptom was build_in_public videos. The auto-tuner section the analytics script appends to the bottom of the knowledge bank listed build_in_public under "not working" and put its median at 13 views (n=3) against product_findindiegame's 108 (n=18) — roughly 8×. The routine kept producing build_in_public anyway, not every day, but often enough that a dead format was eating publishing slots.

The problem wasn't that the LLM couldn't understand the instruction. There was no instruction to understand. The knowledge bank never contained a "never produce build_in_public" line at all — it had a stats dump at the end of a 148-line prose document, and its single explicit "Avoid archetype" line named product_ossfind, a different archetype. A distribution table buried under context, caveats, historical observations, and formatting notes is not a constraint system; it is a suggestion box.

I documented the initial classifier design in a previous post and then the rebuild when the routine kept ignoring it. Neither of those fixes fully solved the root problem. This is the one that did.

The solution: a single-screen imperative committed to the repo

The fix is in scripts/yt-analytics/run.py. Each daily analytics run now writes a file at docs/yt-today-directive.md — a one-screen, imperative directive the routine must read first before generating anything:

DIRECTIVE_PATH = REPO_ROOT / "docs" / "yt-today-directive.md"
Enter fullscreen mode Exit fullscreen mode

The code comment at that line reads: "the strategic decision (which archetype to make) lives here in code we control, not in the routine's prose judgment — that prose was being ignored."

The generated directive names exactly one archetype target and two prohibitions:

## SHORT (daily)
- **archetype = product_findindiegame**  ← generate this today (final pick)
- open with the **numeric** hook pattern
- do NOT generate archetype: **build_in_public** (worst performer)
- **Never produce: build_in_public / meta / curated / technical one-offs**
Enter fullscreen mode Exit fullscreen mode

The routine is instructed to read this file before any other context. Because it is a short file with a single actionable decision and no surrounding prose, there is nothing to skim past.

The knowledge bank still exists and accumulates observations and historical data. The directive is separate. The Python analytics script owns the decision; the LLM routine executes it.

Implementation: DEAD_ARCHETYPES and the shared bias function

Two places in run.py need to agree about which archetypes are viable: the knowledge bank section (appended to yt-knowhow-bank-en.md) and the directive (written to yt-today-directive.md). To prevent them contradicting each other, both call the same archetype_bias() function:

DEAD_ARCHETYPES = frozenset({"build_in_public", "meta", "curated", "technical"})

def archetype_bias(videos, high):
    """Compute (pref_arch, avoid_arch, pref_hook, ranked)."""
    arch_views = defaultdict(list)
    for v in videos:
        a = v.get("_archetype", "unknown")
        if a == "unknown":
            continue  # unclassified bucket, not a real content type
        arch_views[a].append(int(v["statistics"].get("viewCount", 0)))
    ranked = sorted(
        ((a, statistics.median(vs), len(vs)) for a, vs in arch_views.items()),
        key=lambda x: (x[1], x[2]),
        reverse=True,
    )
    ...
Enter fullscreen mode Exit fullscreen mode

The "unknown" exclusion took a while to get right. Early versions of the script were flagging "unknown" as either the best or worst archetype depending on which side of the distribution unclassified videos landed on. When yt-publish failures leave recent videos with no uploaded-metadata file to match against, a batch of failed uploads makes "unknown" look like the week's top performer. Excluding it means the ranking only reflects archetypes I can actually control.

The DEAD_ARCHETYPES frozenset is intentionally separate from the performance ranking. These archetypes are not just low performers — they are categorically off the table based on empirical evidence: build_in_public sits at a median of 13 views against the winner's 108. meta, curated, and technical produced no repeat signal worth pursuing. Keeping them in a named constant means the directive can never accidentally name one as today's target, regardless of how the live ranking shakes out. (Written July 2026 — the frozenset has since grown to six entries, adding ai_tools and recap on 2026-08-10.)

Approach Risk Outcome
Prose prohibition in knowledge bank LLM skims or loses context mid-document Dead archetypes keep appearing
DEAD_ARCHETYPES frozenset in code Checked before writing the directive Never reaches the routine as a valid pick
Shared archetype_bias() function Single source of truth for KB + directive Both always agree on rankings

The 3-in-a-row guard

Once the directive started reliably naming the winning archetype, every day became product_findindiegame day. That is fine from a performance standpoint — the proven winner should be leaned into — but it creates two practical problems: the same audience sees the same format daily without variation, and the matchup queue (which pairs specific game titles) runs down faster than it can be replenished.

The guard checks the two most recent uploads:

recent = recent_uploaded_archetypes(2)
if len(recent) == 2 and recent[0] == recent[1] == target:
    alt = next(
        (a for a, _m, _n in ranked
         if a != target and a not in DEAD_ARCHETYPES and a != avoid_arch),
        None,
    )
    if alt:
        switched_from, target = target, alt
Enter fullscreen mode Exit fullscreen mode

The key constraint: the fallback alt must not be a dead archetype. This was the source of a concrete bug on 2026-07-07 where the directive named build_in_public as today's target while the same file said "Never produce: build_in_public." The guard was correctly detecting a streak and switching off, but it was allowed to switch into a dead archetype. The fix: only accept alternatives that are neither in DEAD_ARCHETYPES nor the flagged avoid_arch.

There is also a final safety check after the guard:

if target in DEAD_ARCHETYPES:
    target = DEFAULT_TARGET_ARCHETYPE
    switched_from = None
Enter fullscreen mode Exit fullscreen mode

Defense in depth. If the ranking data somehow produces a dead archetype as the winner before the guard runs, this catches it. The guard and the final check are independent — neither assumes the other is sufficient.

One thing the guard does not do: switch back to the winner on the next day. The routine tracks the last two uploads, not whether a switch already happened. That means the alternative archetype runs only once before the winner can resume. I considered tracking a longer history but two consecutive uploads was enough signal to break the monotony without over-correcting.

The DEFAULT_TARGET_ARCHETYPE fallback

Early in the channel's life there were not enough classified videos to rank archetypes reliably. A routing function that falls back to "no data, no decision" is useless.

DEFAULT_TARGET_ARCHETYPE = "product_findindiegame"
Enter fullscreen mode Exit fullscreen mode

This is used when pref_arch == "—" (no classified data yet) and as the final safety net after the dead-archetype check. It is not arbitrary — it is the empirically known winner, set explicitly in code rather than inferred from insufficient data, because early inference was unreliable.

The full directive flow:

fetch YouTube stats
    ↓
attach_archetype() — match by title word overlap (≥4 words)
    ↓
classify() — HIGH (≥1.5× median), LOW (≤0.6× + ≥72h old)
    ↓
archetype_bias() — rank by median views, exclude "unknown"
                   (since 2026-07-15: age-normalized views/day)
    ↓
apply 3-in-a-row guard (no dead archetypes as alt)
    ↓
apply DEAD_ARCHETYPES final safety check
    ↓
render_directive() → write + commit docs/yt-today-directive.md
Enter fullscreen mode Exit fullscreen mode

The directive is committed to the repo automatically by yt-analytics.yml, which runs at 09:30 JST — 30 minutes after the 09:00 JST Auto-Script routine that generates the video. So the directive a given run writes is the one the next morning's generation reads. The lag is deliberate: analytics needs the previous day's upload to have accumulated views before it can rank anything.

What I'd do differently

Emit the directive as structured JSON, not markdown. The current format is readable by both humans and LLMs but awkward for anything that needs to parse it programmatically. A JSON schema — { "target": "product_findindiegame", "hook": "numeric", "avoid": ["build_in_public"] } — would let other scripts validate the directive and alert on suspect states. The markdown format was the path of least resistance; it should be replaced before adding consumers beyond the LLM routine.

Watch time instead of view count. The classifier uses viewCount because the YouTube Data API v3 videos.list endpoint returns it without additional authentication. Watch time (minutes watched) is a stronger signal for audience retention by archetype. The YouTube Analytics API with OAuth would unlock this — it is a separate API surface with different quota units. I have not wired it up yet; the current classifier is good enough to be directionally correct.

Extract and unit-test the 3-in-a-row guard. The guard logic is embedded in render_directive(), which requires mocking the full analytics pipeline to test. Extracting apply_streak_guard(target, ranked, recent, dead) as a standalone function would let me test it with a fake history — including the edge case where all non-dead archetypes are also the avoid_arch (currently falls through to keeping the winner, which is correct, but that path is untested).

The 48-Shorts retrospective documents the empirical data behind these archetype decisions. The longform pipeline queue logic and the two-host spec format are downstream consumers of the same archetype system. The quota and cron work covers the GitHub Actions scheduling side — the analytics workflow and the generation workflow run at different times by design.

FAQ

Why not embed the prohibition in the system prompt?

The system prompt is shared across multiple routines. Embedding archetype-specific prohibitions there requires a prompt change whenever the winning format shifts. The directive file lets the analytics script update the decision without touching the prompt.

How does the routine know to read the directive first?

The routine's task description lists reading docs/yt-today-directive.md as the explicit first step, before any other context. Position in the task description matters — front-loading the constraint makes it harder to skip past.

What happens if the YouTube API is unavailable?

Less gracefully than I'd like, and it depends on the failure. If YT_API_KEY is missing, main() prints a warning and returns 0 without writing anything. If the API answers but returns fewer than five videos, the script writes a minimal "insufficient data" report and stops. If the API errors outright, http_get re-raises and the workflow goes red with no report at all. The one thing all three share is that the directive is never overwritten, so the previous day's directive stays in place — a transient API failure does not reset the routine to a default it never consciously chose.

Doesn't hardcoding DEAD_ARCHETYPES create a maintenance burden?

Honestly, I can't answer that from experience yet — the frozenset landed on 2026-07-08, five days before I wrote this, and hasn't changed since. The argument for it isn't churn rate, it's auditability: grep -r DEAD_ARCHETYPES shows every place the constraint is used and enforced, which a prose document can't do.

Why frozenset instead of list or set?

Membership testing (target in DEAD_ARCHETYPES) is O(1) for frozenset vs O(n) for list. With four elements the runtime difference is negligible, but frozenset also prevents accidental mutation mid-run. A set would allow .add() to silently change the constraint during execution.

Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)