Skip to content

Persona-Driven Docs

FCC generates 173 model cards — one per persona, plus category overviews — from YAML source. This pattern transfers to any project where roles, modules, or services are documented. This page shows the pattern end-to-end.

The idea

If a concept has a canonical definition in the code or data, the docs page for it should be generated from that source. Never copy the definition into prose; reference it. Never rephrase it; let the template render it.

Benefits:

  • Zero drift between source and docs.
  • Consistent layout across all instances.
  • Cross-references computed, not hand-maintained.
  • Mass updates happen by changing the template, not every page.

Costs:

  • You need a template engine.
  • You need to resist the urge to hand-edit generated files.
  • Readers sometimes want narrative nuance that templates can't give. Solution: generate a skeleton, append a narrative section.

Source: one YAML per thing

Every persona in FCC has a YAML file with ten R.I.S.C.E.A.R. fields. Example (condensed):

id: knowledge-finder
category: core
risk_category: limited
riscear:
  role: "Retrieves relevant context for a scenario."
  input: "A scenario with search terms."
  style: "Concise, citation-heavy."
  constraints:
    - "Only return items from the scenario's index."
    - "Limit to top 5 results."
  expected_output: "List of (citation, excerpt) pairs."
  archetype: "Knowledge Finder"
  responsibilities:
    - "Rank results by relevance."
    - "Return zero results if no match."
  role_skills:
    - "retrieval"
    - "ranking"
  role_collaborators:
    - "synthesizer"
  role_adoption_checklist:
    - "Confirm index is current."

This file is the source of truth. When the persona changes, this changes first.

Template: one Jinja per doc shape

FCC's model-card template lives at src/fcc/templates/docs/model_card.md.j2. A trimmed version:

---
persona_id: {{ persona.id }}
category: {{ persona.category }}
risk_category: {{ persona.risk_category }}
auto_generated: true
source: src/fcc/data/personas/{{ persona.category }}/{{ persona.id }}.yaml
---

<!-- AUTO-GENERATED. Edit the source YAML or the template, not this file. -->

# Model Card: {{ persona.id }}

## Role

{{ persona.riscear.role }}

## Intended Use

**Input:** {{ persona.riscear.input }}

**Expected Output:** {{ persona.riscear.expected_output }}

## Constraints

{% for c in persona.riscear.constraints %}
- {{ c }}
{% endfor %}

## Responsibilities

{% for r in persona.riscear.responsibilities %}
- {{ r }}
{% endfor %}

## Collaborators

{% for col in persona.riscear.role_collaborators %}
- [`{{ col }}`](../{{ col }}.md)
{% endfor %}

## Adoption Checklist

{% for step in persona.riscear.role_adoption_checklist %}
- [ ] {{ step }}
{% endfor %}

One template drives 164 pages. When the card layout changes, one file is edited.

Generator: the glue

import yaml, jinja2, pathlib

env = jinja2.Environment(
    loader=jinja2.FileSystemLoader("src/fcc/templates/docs"),
    autoescape=False,
    keep_trailing_newline=True,
)
tpl = env.get_template("model_card.md.j2")

out_root = pathlib.Path("docs/model-cards")
out_root.mkdir(exist_ok=True, parents=True)

for ypath in pathlib.Path("src/fcc/data/personas").rglob("*.yaml"):
    persona = yaml.safe_load(ypath.read_text())
    md = tpl.render(persona=persona)
    (out_root / f"{persona['id']}.md").write_text(md)

25 lines. A comparable generator for a non-FCC project might be 20-30 lines.

Front matter as a contract

Note the front matter in the generated card:

auto_generated: true
source: src/fcc/data/personas/core/knowledge-finder.yaml

This front matter drives:

  • A CI check that warns on any PR editing an auto_generated: true file.
  • A footer in the rendered site saying "generated from [source]."
  • A CLI command (fcc generate-docs) that rebuilds everything.

Narrative sections via override file

Readers sometimes want narrative ("why this persona was split from X in v1.5"). Solution: an optional override file per persona at docs/model-cards/_narrative/<id>.md. The template includes it if present:

{% if narrative %}
## Notes

{{ narrative }}
{% endif %}

The generator loads the narrative file alongside the YAML and passes it in. Humans edit narrative; the template renders it; generated and narrative coexist cleanly.

Cross-reference generation

One more pattern: a cross-reference matrix page that indexes relationships across all personas. FCC's lives at docs/cross-reference/ and is regenerated from src/fcc/data/personas/cross_reference.yaml.

A trimmed template:

# Persona Cross-Reference

| Persona | Upstream | Downstream | Peers |
|---------|----------|------------|-------|
{% for entry in matrix %}
| [`{{ entry.id }}`](../model-cards/{{ entry.id }}.md) | {{ entry.upstream | join(", ") }} | {{ entry.downstream | join(", ") }} | {{ entry.peers | join(", ") }} |
{% endfor %}

One page replaces hours of manual updating.

When this pattern breaks down

  • Narrative-heavy docs. If your docs are mostly prose with occasional reference, persona-driven is overkill. Use narrative Markdown + a small generated glossary.
  • Highly variable layouts. If each instance's doc page differs significantly, one template is insufficient. Consider a family of templates plus a router, or accept the hand-authored pages.
  • Non-structured sources. If your YAML is shallow (name: value only), there isn't much to generate. Start with a better source schema.

Migration path for an existing docs site

If your site currently has hand-written reference pages:

  1. Schema. Define the YAML schema for the entity type.
  2. Extract. Write a one-time script that scrapes existing pages into YAML. Acceptable to be lossy; you will clean up later.
  3. Template. Write one Jinja template that reproduces the existing look.
  4. Generate. Run the pipeline. Diff the output against existing pages.
  5. Reconcile. For each diff, decide: does the YAML change, or the template?
  6. Switch over. Delete the old pages; commit the generator; protect generated files in CI.

Total time for a site of 50-100 reference pages: 1-2 weeks for the first one, much less for subsequent entity types.

Governance

  • One template per entity type. Family of page.md.j2, overview.md.j2, index.md.j2 is fine; a zoo of 50 is not.
  • Schema evolution policy. Adding optional fields is safe; renaming or removing needs migration.
  • Template review. PRs that change a template should show a diff of representative outputs before/after.

Ergonomics to add

Once the basic pipeline works, these boost maintainer experience:

  • Watch mode. A file watcher re-runs the generator on YAML or template changes. Under 2 seconds per rebuild for most projects.
  • Lint for orphan generated pages (files in output without a YAML source).
  • Lint for orphan YAML (source without a generated page).
  • Diff preview in PRs. CI renders before/after and posts as a bot comment.

None of these is load-bearing, but combined they convert the pipeline from "works" to "pleasant."

Where next

With generated docs under control, pursue multi-format publishing in Publication Pipeline 101, or tighten vocabulary in Glossary Governance.