//! The course: what the model plans and writes, and how paper-bro assembles
//! it into one self-contained page.
//!
//! The split matters for safety. A course is served from its host's own
//! origin, next to whatever else that host runs, and the model wrote it from
//! untrusted paper text. So the page's only scripts
//! are the shell's own (shell/runtime.js); model prose goes through ammonia;
//! model text in the hero, quiz and glossary is escaped or set as
//! textContent; and every lab runs in a sandboxed iframe with an opaque
//! origin. The CSP is a second layer, not the boundary.
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::LazyLock;
use crate::arxiv::{self, Meta};
use crate::ingest::Figure;
pub const KATEX: &str = "https://cdn.jsdelivr.net/npm/katex@0.16.47/dist";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Outline {
pub course_title: String,
pub tagline: String,
pub lede: String,
pub minutes: u32,
pub chips: Vec,
pub lessons: Vec,
pub quiz: Vec,
pub glossary: Vec,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LessonPlan {
pub title: String,
pub nav_title: String,
pub eyebrow: String,
pub minutes: u32,
pub objectives: Vec,
pub paper_sections: Vec,
pub key_ideas: Vec,
pub figure: String,
pub lab: LabPlan,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LabPlan {
pub title: String,
pub idea: String,
pub controls: String,
pub what_it_shows: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct QuizItem {
pub question: String,
pub options: Vec,
pub answer: u32,
pub explanation: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Term {
pub term: String,
pub definition: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Lesson {
pub prose_html: String,
pub figure: String,
pub figure_note: String,
pub lab_html: String,
pub lab_css: String,
pub lab_js: String,
/// Set when QA gave up on the lab: the lesson ships without it.
#[serde(default)]
pub lab_dropped: bool,
}
pub fn outline_schema() -> Value {
let s = |d: &str| json!({"type": "string", "description": d});
let strings = |d: &str| json!({"type": "array", "items": {"type": "string"}, "description": d});
crate::llm::strict(json!({
"type": "object",
"properties": {
"course_title": s("Short course name, 2 to 5 words, e.g. 'Attention, Explained'. Not the paper's title; no colon or dash."),
"tagline": s("The big hero headline, 4-9 words, may contain one line break as \\n."),
"lede": s("2-3 sentences: why this paper matters and what the reader will build in their head."),
"minutes": {"type": "integer", "description": "Total minutes for the whole course."},
"chips": strings("3-5 short facts for hero chips, each starting with an emoji."),
"lessons": {"type": "array", "description": "6 to 10 lessons, in teaching order.", "items": {
"type": "object",
"properties": {
"title": s("Lesson headline, ending with a full stop."),
"nav_title": s("2-5 word version for the sidebar."),
"eyebrow": s("2-3 word label, e.g. 'The big idea'."),
"minutes": {"type": "integer"},
"objectives": strings("What the reader can do or explain afterwards."),
"paper_sections": strings("Which parts of the paper this lesson draws on."),
"key_ideas": strings("The specific ideas, numbers and claims from the paper to teach."),
"figure": s("Key of a paper figure to show in this lesson (e.g. 'F1'), or ''."),
"lab": {"type": "object", "properties": {
"title": s("Lab title."),
"idea": s("What the reader plays with, concretely."),
"controls": s("The sliders, buttons or selects and what each changes."),
"what_it_shows": s("The insight the reader should get from playing."),
}},
},
}},
"quiz": {"type": "array", "description": "8 questions over the whole course.", "items": {
"type": "object",
"properties": {
"question": s("The question."),
"options": strings("Exactly 4 options."),
"answer": {"type": "integer", "description": "Index (0-3) of the correct option."},
"explanation": s("One or two sentences: why the answer is right."),
},
}},
"glossary": {"type": "array", "description": "8-16 terms the course uses.", "items": {
"type": "object",
"properties": {"term": s("Term."), "definition": s("Plain-words definition.")},
}},
},
}))
}
pub fn lesson_schema() -> Value {
let s = |d: &str| json!({"type": "string", "description": d});
crate::llm::strict(json!({
"type": "object",
"properties": {
"prose_html": s("The lesson's explanation as an HTML fragment (see the contract)."),
"figure": s("The figure key planned for this lesson, or '' for none."),
"figure_note": s("If figure is set: one sentence telling the reader what to look at in it. Otherwise ''."),
"lab_html": s("The lab's markup (see the contract). Never empty."),
"lab_css": s("Extra CSS for the lab only, or ''."),
"lab_js": s("The lab's script (see the contract)."),
},
}))
}
pub fn review_schema() -> Value {
crate::llm::strict(json!({
"type": "object",
"properties": {
"lessons": {"type": "array", "items": {"type": "object", "properties": {
"lesson": {"type": "integer", "description": "Lesson number, from 1."},
"verdict": {"type": "string", "enum": ["pass", "fix"]},
"findings": {"type": "array", "items": {"type": "string"},
"description": "Specific problems to fix. Empty when the verdict is pass."},
}}},
"quiz_findings": {"type": "array", "items": {"type": "string"},
"description": "Quiz questions that are wrong or ambiguous, with the fix. Empty if none."},
},
}))
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Review {
pub lessons: Vec,
pub quiz_findings: Vec,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LessonReview {
pub lesson: u32,
pub verdict: String,
pub findings: Vec,
}
impl Outline {
/// Reject an outline the shell can't render well.
pub fn validate(&self) -> Result<(), String> {
let n = self.lessons.len();
if !(4..=12).contains(&n) {
return Err(format!("{n} lessons; plan 6 to 10"));
}
for (i, q) in self.quiz.iter().enumerate() {
if q.options.len() != 4 {
return Err(format!("quiz question {} has {} options, not 4", i + 1, q.options.len()));
}
if q.answer > 3 {
return Err(format!("quiz question {} has answer index {}", i + 1, q.answer));
}
}
if self.quiz.len() < 4 {
return Err(format!("only {} quiz questions; write 8", self.quiz.len()));
}
Ok(())
}
}
pub fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
/// Model prose, cut down to an allowlist: no scripts, no event handlers, no
/// styles, no images (figures are placed by the assembler), https links only.
/// Any other link (`#`, a path, `//host`) loses its address and is unwrapped to
/// plain text: the page does its own navigation.
pub fn sanitize(html: &str) -> String {
let mut b = ammonia::Builder::empty();
b.add_tags([
"p", "h3", "h4", "strong", "em", "b", "i", "code", "pre", "ul", "ol", "li", "blockquote", "br",
"hr", "span", "div", "table", "thead", "tbody", "tr", "th", "td", "sup", "sub", "small", "a",
])
.add_tag_attributes("a", ["href"])
.url_schemes(["https"].into())
.url_relative(ammonia::UrlRelative::Deny)
.link_rel(Some("noopener noreferrer"))
.add_allowed_classes("div", ["card", "flat", "cols", "cols3", "callout", "deeper", "formula", "emo"])
.add_allowed_classes("span", ["mono", "num", "chip"])
.add_allowed_classes("p", ["lede"])
.add_allowed_classes("ol", ["steps"])
.add_allowed_classes("ul", ["steps"]);
BARE_LINK.replace_all(&b.clean(html).to_string(), "$1").into_owned()
}
/// An `` the sanitiser left without an address.
static BARE_LINK: LazyLock = LazyLock::new(|| Regex::new(r#"(?s)(.*?)"#).unwrap());
static CLOSE_SCRIPT: LazyLock = LazyLock::new(|| Regex::new(r"(?i)(script)").unwrap());
static CLOSE_STYLE: LazyLock = LazyLock::new(|| Regex::new(r"(?i) block.
fn embed_json(v: &Value) -> String {
serde_json::to_string(v).unwrap().replace('<', "\\u003c")
}
pub struct Course<'a> {
pub meta: &'a Meta,
pub outline: &'a Outline,
pub lessons: &'a [Lesson],
pub figures: &'a [Figure],
pub model: &'a str,
pub built: &'a str,
}
fn license_name(url: &str) -> String {
let u = url.to_ascii_lowercase();
if u.contains("nonexclusive-distrib") {
"arXiv non-exclusive license".into()
} else if u.contains("publicdomain/zero") {
"CC0".into()
} else if let Some(rest) = u.split("creativecommons.org/licenses/").nth(1) {
let parts: Vec<&str> = rest.trim_end_matches('/').split('/').collect();
format!("CC {} {}", parts.first().unwrap_or(&"").to_uppercase(), parts.get(1).unwrap_or(&""))
} else {
url.into()
}
}
/// `acme/tiny-model-0425` → `Tiny Model`: the model's name
/// for readers, without the provider path or a build suffix.
fn model_name(slug: &str) -> String {
let base = slug.rsplit('/').next().unwrap_or(slug);
let base = match base.rsplit_once('-') {
Some((head, tail)) if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) => head,
_ => base,
};
base.split('-')
.filter(|w| !w.is_empty())
.map(|w| {
let mut cs = w.chars();
cs.next().map(|f| f.to_uppercase().chain(cs).collect::()).unwrap_or_default()
})
.collect::>()
.join(" ")
}
fn authors_line(authors: &[String]) -> String {
match authors.len() {
0 => "Unknown authors".into(),
1..=4 => authors.join(", "),
_ => format!("{}, {} and others", authors[0], authors[1]),
}
}
/// AERO adds its own Google Analytics tag to every page it serves, right
/// after `` and so before this policy. The loader starts before the
/// policy applies, but the hits it sends later don't, so GA's hosts are
/// allowed here or every course would drop out of analytics silently.
/// Courses carry no GA tag of their own.
fn csp(origin: &str) -> String {
let (ga_script, ga_img, ga_connect) = (
" https://*.googletagmanager.com",
" https://*.google-analytics.com https://*.googletagmanager.com",
" https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com",
);
format!(
"default-src 'none'; script-src 'unsafe-inline' https://cdn.jsdelivr.net/npm/katex@0.16.47/{ga_script}; \
style-src 'unsafe-inline' https://cdn.jsdelivr.net/npm/; font-src https://cdn.jsdelivr.net/npm/; \
img-src data: blob: https://arxiv.org/html/{ga_img}; \
connect-src {origin}/paper-bro/api/lab-errors{ga_connect}; \
frame-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'"
)
}
const LOGO: &str = r##""##;
const FONTS: &str = r#""#;
fn head(c: &Course, origin: &str, title: &str) -> String {
format!(
r#"
{title}
{FONTS}
"#,
csp = csp(origin),
desc = esc(&c.outline.lede),
id = esc(&c.meta.id),
css = include_str!("../shell/course.css"),
)
}
/// The data block the runtime reads: labs, quiz, and where to report errors.
fn data(c: &Course, origin: &str, only: Option) -> String {
let mut labs = serde_json::Map::new();
for (i, l) in c.lessons.iter().enumerate() {
if l.lab_dropped || only.is_some_and(|o| o != i) {
continue;
}
let js = CLOSE_SCRIPT.replace_all(&l.lab_js, "<\\/$1").into_owned();
let tex = js.contains("PB.tex") || l.lab_html.contains("katex");
labs.insert(
format!("l{}", i + 1),
json!({"html": l.lab_html, "css": CLOSE_STYLE.replace_all(&l.lab_css, ""), "js": js, "tex": tex}),
);
}
let quiz: Vec = if only.is_some() {
Vec::new()
} else {
c.outline
.quiz
.iter()
.map(|q| json!({"question": q.question, "options": q.options, "answer": q.answer, "explanation": q.explanation}))
.collect()
};
embed_json(&json!({
"id": c.meta.id,
"labs": labs,
"quiz": quiz,
"errorUrl": format!("{origin}/paper-bro/api/lab-errors"),
"labHead": FONTS,
"labTex": format!(r#""#),
"labCss": include_str!("../shell/labkit.css"),
"labJs": include_str!("../shell/labkit.js"),
}))
}
fn scripts(c: &Course, origin: &str, only: Option) -> String {
format!(
"\n\n\n\n",
data(c, origin, only),
include_str!("../shell/runtime.js")
)
}
fn lab_card(c: &Course, i: usize) -> String {
let plan = &c.outline.lessons[i];
format!(
r#"
Lab · {n:02}
{title}
"#,
n = i + 1,
title = esc(&plan.lab.title)
)
}
/// The figures lessons refer to that `figures` doesn't have, as
/// "lesson N: KEY". `figure()` leaves a missing one out without a word.
pub fn missing_figures(lessons: &[Lesson], figures: &[Figure]) -> Vec {
lessons
.iter()
.enumerate()
.filter(|(_, l)| !l.figure.is_empty() && !figures.iter().any(|f| f.key == l.figure))
.map(|(i, l)| format!("lesson {}: {}", i + 1, l.figure))
.collect()
}
static FIGURE_SRC: LazyLock = LazyLock::new(|| Regex::new(r#" Vec {
FIGURE_SRC.captures_iter(html).map(|c| c[1].to_string()).collect()
}
/// Why two pages' figures differ, if they do. Keys like `F1` count figures in
/// arXiv's rendering, so when that changes, `F1` can become a different image
/// and a lesson would show the wrong one under the same caption.
pub fn figures_changed(was: &[String], now: &[String]) -> Option {
let i = (0..was.len().max(now.len())).find(|&i| was.get(i) != now.get(i))?;
let url = |u: Option<&String>| u.map_or("absent".to_string(), |u| u.clone());
Some(format!("figure {} was {} and is now {}", i + 1, url(was.get(i)), url(now.get(i))))
}
fn figure(c: &Course, lesson: &Lesson) -> String {
let Some(f) = c.figures.iter().find(|f| !lesson.figure.is_empty() && f.key == lesson.figure) else {
return String::new();
};
format!(
r#"{note} {cap} From “{title}”, shown from arXiv."#,
url = esc(&f.url),
cap = esc(&f.caption),
note = esc(&lesson.figure_note),
title = esc(&c.meta.title),
)
}
/// The most HTML AERO takes for a page is about 1 MB; stay clear of it.
pub const MAX_HTML: usize = 950_000;
/// Refuse a page AERO would reject, with the same message wherever it is built.
pub fn check_size(html: &str) -> anyhow::Result<()> {
if html.len() > MAX_HTML {
anyhow::bail!("the course came out at {} KB, over AERO's limit", html.len() / 1024);
}
Ok(())
}
/// The whole course, as served from `origin`.
pub fn assemble(c: &Course, origin: &str) -> String {
let o = c.outline;
let id = &c.meta.id;
let abs = arxiv::abs_url(id);
let n = o.lessons.len();
let mut h = head(c, origin, &format!("{} — an interactive course on arXiv:{id}", esc(&o.course_title)));
let nav: String = o
.lessons
.iter()
.enumerate()
.map(|(i, l)| format!(r##"{n:02}{t}✓"##, n = i + 1, t = esc(&l.nav_title)))
.chain(std::iter::once(format!(r##"{:02}Final quiz✓"##, n + 1)))
.collect();
let chips: String = o.chips.iter().map(|c| format!(r#"{}"#, esc(c))).collect();
let tagline = esc(&o.tagline).replace("\\n", " ").replace('\n', " ");
let year = c.meta.published.map(|d| d.format("%Y").to_string()).unwrap_or_default();
h.push_str(&format!(
r##"
{LOGO}
An AI model ({model}) wrote this course from “{paper}” by {authors} (arXiv:{id}, version {v}). It explains the paper in its own words for curious beginners, so it simplifies, and it can make mistakes. When the course and the paper disagree, the paper is right.
Figures are shown straight from arXiv and belong to the paper’s authors.{license}
"##,
q = n + 1,
nq = o.quiz.len(),
model = esc(&model_name(c.model)),
paper = esc(&c.meta.title),
authors = esc(&authors_line(&c.meta.authors)),
v = c.meta.version,
built = esc(c.built),
));
h.push_str(&scripts(c, origin, None));
h
}
/// A page holding one lesson's lab alone, so QA can blame errors and hangs on
/// the right lab.
pub fn lab_page(c: &Course, origin: &str, i: usize) -> String {
let mut h = head(c, origin, "lab");
h.push_str(&format!(
r#"
{}
"#,
lab_card(c, i)
));
h.push_str(&scripts(c, origin, Some(i)));
h
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_anything_executable() {
let dirty = r#"