LearnOS docs
CLI

Course quality

Score a course's intrinsic content quality — citations, structure, content richness, retrieval-readiness, scaffolding, and writing.

course quality scores a course against a six-category rubric. It's intended for generated courses — ones imported from markdown, a directory tree, a URL crawl, or a YouTube transcript — where no learner has interacted yet and there is no engagement data to fall back on. The score is computed from each lesson's markdown and its section breakdown alone, so it's immediately available the moment a course finishes importing.

The command outputs six per-category sub-scores (0–1) plus a single 0–100 composite with a letter grade, and surfaces the worst-offender lessons so you know what to fix first.

Run it

learnos course quality <id-or-title> [--verbose] [--publish] [--format json|text]

<course> is either a UUID or a case-insensitive title substring. Defaults to a text report; --verbose adds per-lesson stats; --format json emits a structured report for downstream tooling. Pass --publish to upsert the report so it shows up in the web UI.

course quality

Score one course on the six-category quality rubric. Reads the course only — no network, no LLM calls.

Parameters
NameTypeDefaultDescription
<course>*stringCourse id or title substring (must resolve to exactly one course).
--verbosebooleanfalseInclude per-lesson stats (word count, sections, links, code blocks) below the report.
--publishbooleanfalsePersist the report so it renders on the course's Quality tab and the Discover badge. Upserts (overwrites the prior report). Owner-only.
--formatjson | texttextOutput format. Text prints a bar-chart report; JSON emits a structured object.
Returns
In text mode: composite score + grade, totals, per-category bars with notes, top findings, and a confirmation of whether the report was published. In JSON mode: a structured object with the same data plus a `published` flag.

Where it shows up

A computed report stays local until you pass --publish. Once published:

  • Quality tab on the course detail page (/courses/[id]) renders the full report — composite score, six category bars (click to expand the notes), and a Top findings list where each entry deep-links to the offending lesson.
  • Quality badge on the Discover detail page (/discover/[slug]) for published courses — a small "B · 73.8" pill in the metadata row, with a hover popover showing the six-category breakdown. Only appears once a report exists.

Recomputing and re-publishing overwrites the prior report — the badge and tab always reflect the most recent publish. There's no separate "publish quality" action in the web UI; computing and publishing are one command in the CLI.

Sample output:

Cosmos 3 - NVIDIA's World Foundation Model  (c5ab37cb-…)
─────────────────────────────────────────────────
composite score: 38.5 / 100  [F]

totals:
  1 lessons · 1 modules · 1,458 words · ~7 min reading
  0 code · 0 mermaid · 0 math · 0 links

citations             ····················  0%
  · 0.00 cites/1k words (target 3+)
  · 1/1 lessons have zero citations

structure             ██████████████······  70%
  · 1 lessons across 1 modules
  · module balance: mean 1.0, stdev 0.0 (cv 0.00)

How the composite works

CategoryWeight
Citations27%
Structure16%
Content richness16%
Retrieval-readiness16%
Scaffolding14%
Writing quality11%

The composite is the weighted sum of the six sub-scores, scaled to 0–100. Citations carry the most weight because uncited generated content is the failure mode this rubric is most concerned with catching.

Grade bands:

ScoreGrade
≥80A
70–79B
60–69C
50–59D
<50F

The rubric

Each category produces a score ∈ [0, 1] and a list of notes. Sub-scores inside a category use one of two mappings:

  • ramp-up — 0 below a floor, 1 at a target, linear between. Used when more of something is better.
  • ramp-down — 1 below a target, 0 above a ceiling, linear between. Used when less of something is better.

1. Citations (27%)

Does the course back up its claims? Are sources diverse?

Sub-scoreMappingTarget
Densityramp-up on links per 1k words, floor 0, target 3≥3 cites per 1000 words
Coverage1 − (lessons with 0 citations / total lessons)every lesson cites
Diversityramp-up on unique domains per lesson, floor 0, target 2≥2 unique domains per lesson

Combined as density × 0.5 + coverage × 0.35 + diversity × 0.15.

Citations are counted from markdown links — [text](https://…), <https://…> autolinks, and bare http(s)://… URLs — found outside fenced code blocks. Frontmatter sources: entries don't count here; they're scored under Scaffolding so the two signals stay independent.

2. Structure (16%)

Is the course well-shaped? Balanced modules, reasonable lesson lengths.

Sub-scoreMappingTarget
Module balanceramp-down on coefficient of variation of lessons-per-module, target 0.3, ceiling 1.0cv ≤0.3
Length outliersramp-down on outlier rate, target 0.05, ceiling 0.3<5% outliers
Minimum sizeramp-up on lesson count (3 → 10) and module count (1 → 3), averaged≥10 lessons, ≥3 modules

Combined as balance × 0.4 + length × 0.3 + size × 0.3.

A lesson is a "short" outlier with <150 words (and >0), "long" with >5000 words, "empty" with 0 words.

3. Content richness (16%)

Code, diagrams, math — the multimodal payload that distinguishes a real lesson from a wall of prose.

Sub-scoreMappingTarget
Code densityramp-up on code blocks per 1k words, floor 0, target 1.5≥1.5 code blocks per 1000 words
Media densityramp-up on diagrams + math per 1k words, floor 0, target 0.4≥0.4 per 1000 words
Language taggingcode blocks with a lang tag / total code blocks100% tagged
Runnable shareramp-up on runnable share, floor 0, target 0.5≥50% of code blocks are runnable

Combined as code × 0.4 + media × 0.25 + tag × 0.2 + runnable × 0.15.

"Runnable" languages are js, javascript, ts, typescript, and py-run — the ones LearnOS lessons can actually execute in the browser. Math is counted from both $$…$$ display blocks and ```math fenced blocks. Mermaid diagrams are detected by their ```mermaid fence tag.

4. Retrieval-readiness (16%)

Is the course chunked well enough for retrieval and citation to work? Pure prose with no H2/H3 sectioning retrieves badly and breaks deep links.

Sub-scoreMappingTarget
Section densityramp-up on average sections per lesson, floor 1, target 4≥4 sections per lesson
Coverage1 − (lessons with 0 sections / total lessons)no dead-spot lessons
Sectioningramp-down on single-section share, target 0.1, ceiling 0.5<10% of lessons collapsed to a single section

Combined as density × 0.45 + coverage × 0.35 + sectioning × 0.2.

"Sections" are the heading-scoped pieces LearnOS splits each lesson into for search and citation. A lesson with no H2/H3/H4 headings collapses to a single "intro" section, which both retrieves poorly and gives chat citations nothing specific to link at.

5. Scaffolding (14%)

Pedagogical bones: frontmatter, objectives, resources, practice.

Sub-scoreWeight in category
Frontmatter sources: populated30%
Frontmatter prerequisites: declared15%
Has a Resources / References / Further Reading section25%
Has a Practice / Exercises / Quiz section20%
Has an Objectives section10%

Each is a simple present / total ratio over lessons. Sections are detected by heading text (case-insensitive, H1–H4). A frontmatter prerequisites: [] array counts even if empty — the act of declaring it is the scaffolding signal.

6. Writing quality (11%)

Lexical variety, sentence rhythm, and the absence of LLM filler phrases.

Sub-scoreMappingTarget
Lexical diversityramp-up on word-weighted TTR, floor 0.25, target 0.45TTR ≥0.45
Filler rateramp-down on filler phrases per 1k words, target 1, ceiling 5≤1 filler phrase per 1000 words
Sentence lengthpiecewise: ramp-up 8→15, ramp-down 22→3515–22 words per sentence

Combined as ttr × 0.45 + filler × 0.35 + sentence × 0.2.

TTR (type-token ratio) is unique alpha-tokens divided by total alpha-tokens, sampled over the first 1000 tokens of each lesson so values are comparable across lesson lengths. Aggregated across the course as a word-count-weighted average.

Filler phrases flagged (case-insensitive substring match):

let's dive in, let's get started, in this lesson, in this section, by the end of this, as we explore, we'll cover, we will cover, let's explore, in conclusion, to wrap up, buckle up, deep dive, without further ado, now that we

Findings

After scoring, the report surfaces up to ~10 worst-offender bullets:

  • uncited lesson — a lesson with >200 words and zero external links
  • weak sectioning — a lesson with ≤1 section despite >400 words
  • no frontmatter sources — a lesson with >200 words but no sources: frontmatter array

These are intentionally narrow — the bullets point at fixable lessons, not abstract scores. If a course scores 60% on citations, the findings tell you which lessons to go cite.

What this rubric doesn't measure

So you don't read the score as more comprehensive than it is:

  • Link health — URLs are counted, not checked. A lesson can cite five dead links and score the same as one citing five live ones.
  • Source authority — every domain counts equally. A citation of a major reference work scores the same as a citation of a random blog.
  • Inter-lesson redundancy — two near-duplicate lessons both score well in isolation; the rubric doesn't compare lessons against each other.
  • Title-vs-body alignment — a "Promises" lesson that's actually about async/await isn't flagged.
  • Concept ladder — whether lesson N introduces terms before lesson N+1 uses them isn't checked.
  • Engagement — citation-hit rate from chat, completion rate, flashcards generated, and highlight density are richer signals, but they only accrue once the course has been used. This rubric scores the artifact, not its reception.

On this page

Command Palette

Search for a command to run...