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:
This front matter drives:
- A CI check that warns on any PR editing an
auto_generated: truefile. - 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:
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: valueonly), 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:
- Schema. Define the YAML schema for the entity type.
- Extract. Write a one-time script that scrapes existing pages into YAML. Acceptable to be lossy; you will clean up later.
- Template. Write one Jinja template that reproduces the existing look.
- Generate. Run the pipeline. Diff the output against existing pages.
- Reconcile. For each diff, decide: does the YAML change, or the template?
- 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.j2is 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.