Decisions
ADR-002 — Content operations: automated supply, licensed sources + AI originals, admin dashboard
Automated content supply, licensed sources and AI originals, QA, auto-publish, the admin API.
Status: Accepted 2026-10-17. Owner: TPM. Complements ADR-001 (D7 batches, D9 difficulty tiers, D13 languages).
Goal
Keep every (language × topic × difficulty) cell stocked with fresh, legally safe, retell-worthy articles without a human writing or approving each one. Humans only (a) tune sources, prompts and thresholds, (b) clear a small review queue, (c) react to user reports. Everything else runs on schedule.
D1. Two supply lines, one article shape
| Line | How | Rights |
|---|---|---|
| Licensed adaptation | Fetch from registered sources (RSS/API/HTML), extract the text, have the LLM adapt it into a short argumentative piece in the target language (the article is a rewrite that keeps the claims, not a copy), generate key points, reading hint, topic, difficulty. | Only sources whose license is on the allowlist: public domain / CC0, CC BY, CC BY-SA (adaptation published under the same license with attribution), government works in the public domain, and sources the owner has explicitly cleared. Attribution text + URL + license are stored and shown in the app's article footer. |
| AI original | Generate from a topic brief (topic × angle × difficulty × language) with a prompt library; no external source. | Owned content. Labelled source.kind = "ai_original" and byline "编辑部 / Editorial" + "AI 辅助创作 / AI-assisted". |
Both produce the same Article (ADR-001 contract) plus source {kind, license, attribution, url, retrieved_at, source_id} and provenance {generator_model_ref, qa_model_ref, prompt_version, job_id}.
Bilingual pairs are generated when a piece works in both languages (pair_id), so the same claims exist in en and zh-Hans; pairs are independent articles for the app.
Initial source allowlist (operator can add more in the dashboard; each needs license + attribution_template): Wikinews, Wikipedia (CC BY-SA 4.0), Wikivoyage, PLOS journals (CC BY), Project Gutenberg / Standard Ebooks (PD), US federal agency sites such as NASA/NIH (PD), Our World in Data (CC BY), 中文维基百科/维基新闻 (CC BY-SA), CC-licensed newsletters and blogs the owner registers. Sources that are "all rights reserved" cannot be registered (the dashboard refuses a license outside the allowlist).
D2. Pipeline (Cloudflare cron + Queues)
- Inventory check (hourly): for each cell (language × topic × difficulty) compute
unseen_available= published articles not completed by most active users (proxy: published count minus median completions). If belowtarget(default 20) enqueue generation jobs up tobatch_max(default 5 per cell per run). - Fetch (per source schedule, default every 6 h): pull new items, dedupe by URL hash, extract main text (Readability-style), store as
source_itemswithlicenseinherited from the source. Skip items < 150 words / 250 chars or > 4000 words. - Generate (queue consumer, uses the dynamic LLM provider chain from ADR-001 D11): adaptation or original → draft article in the target language with
paragraphs,title,reading_hint,key_points(3–5, weighted),topic_id,difficulty,byline,source,pairoptional. - QA (second LLM pass + deterministic checks) produces a
quality_report:- Deterministic: length within the tier range for the language (ADR-001 D9 / D13), 3–5 key points, language detected = target, no URLs/emails/phone numbers, no names of private individuals (NER heuristic), duplicate check against published articles via embeddings (Vectorize; cosine > 0.92 = duplicate), attribution present when
kind = licensed. - LLM rubric (0–100 each):
argument_clarity(one main claim + reasons),retell_worthiness(has structure a learner can reproduce),grounding(adaptation keeps the source's claims; for originals: internally consistent, no fabricated statistics — numbers must be absent or sourced),safety(no hate, sexual content, self-harm, medical/financial advice, defamation; sensitive politics flagged not blocked),readabilityfor the tier. qa_score = min(argument_clarity, retell_worthiness, grounding, readability);safetyis a hard gate.
- Deterministic: length within the tier range for the language (ADR-001 D9 / D13), 3–5 key points, language detected = target, no URLs/emails/phone numbers, no names of private individuals (NER heuristic), duplicate check against published articles via embeddings (Vectorize; cosine > 0.92 = duplicate), attribution present when
- Decision:
qa_score ≥ auto_publish_threshold(default 80) and safety pass and no flags →published.60 ≤ qa_score < 80or any soft flag →needs_review(dashboard queue).< 60or safety fail or duplicate →rejected(kept 30 days for prompt tuning). Thresholds are per language and editable. - Post-publish signals: helpfulness votes on assessments, completion rate, average
key_infoscore (too low across users ⇒ key points mis-specified), user reports. A nightly job computesarticle_health;health < 40with ≥ 20 sessions ⇒retiredautomatically and a replacement job is enqueued. Reports with reasonrightsorharmfulunpublish immediately and open a review item. - Spot check: 5 % of auto-published articles are sampled into the review queue as
audititems so humans calibrate the threshold without blocking supply.
Spend guard: daily_cost_cap_usd (default 20) and max_jobs_per_run (default 25) bound the pipeline; reaching the cap auto-pauses generation with paused_reason = cost_cap (shown on the overview) and resumes the next UTC day. Three consecutive provider failures on content_llm pause with provider_errors.
Every stage is idempotent, records model_ref, prompt version and cost (tokens × provider price table) so the dashboard can show cost per published article.
D3. Article lifecycle
draft → qa → needs_review | published | rejected, published → retired, needs_review → published | rejected (human), published → unpublished (report) → published | retired. Published articles are versioned: an edit creates version + 1; sessions pin article_id + version; key_points changes re-run QA.
D4. Dashboard (dashboard/)
Web app for operators, separate from the landing page. TanStack Start + React on Cloudflare Workers, talks only to the backend admin API (docs/api/openapi-admin.yaml). Auth: Cloudflare Access in production (verify the Cf-Access-Jwt-Assertion header against the team's public keys), X-Admin-Key in dev. Screens:
- Overview: inventory heatmap (language × topic × difficulty vs target), pipeline throughput (24 h / 7 d), auto-publish rate, review-queue size, cost per article, provider error rate, top reports.
- Review queue: items needing review or audit; side-by-side source text vs draft; QA report with per-check results; actions: publish, edit-and-publish, regenerate (with a note that feeds the prompt), reject (with reason taxonomy that trains thresholds).
- Articles: searchable table with status, language, topic, difficulty, health, sessions, key_info average; detail page = in-app preview (uses design tokens), key points editor, version history, retire/unpublish.
- Sources: register/edit sources (URL, type rss|api|html|sitemap, license from allowlist, attribution template, schedule, topic hints, language), fetch now, last run log, yield stats, pause.
- Generation: topic briefs library (per language), targets per cell, batch size, auto-publish thresholds, run-now, job log with cost.
- AI providers: the ADR-001 D11 chains (ASR/LLM/TTS) plus
content_llmandqa_llmchains, per-provider usage/cost/error charts, a "test prompt" box. - Quality: article health distribution, assessment helpfulness by article, flags, sampled audits and their outcomes, threshold tuning history.
- Users & reports: report inbox, user lookup by id (no PII beyond display name/locale), entitlement state, delete-data trigger. Waitlist export.
- Playground (amended 2026-10-19, issue #33): operators run the app's 3-step loop (ADR-001 D1) on a published article to compare scoring models. One recording (recorded in the browser or uploaded) and one transcript from one ASR model are scored by 2–6 pinned
llmlanes at once, side by side: overall, band, the four dimensions with evidence, key-point coverage, the one action, confidence, errors. Round 2 is scored per lane against that lane's round 1; each lane can produce its model answer. Blind by default: lanes are labelled A, B, C… in a random order with opaque lane ids; model names, latency, tokens, cost and finish times stay hidden, and a round's results are released together, until the operator gives a verdict (winner or tie, with a note) or reveals. The first verdict given blind is kept unchanged next to the latest one, and once a run has a verdict its recorded rounds can't be replaced (a new attempt is a new run). Lanes call one model each, with no chain fallback, and use the same validity check (ADR-001 D2) and assessment post-processing (ADR-001 D3) as the app. Runs are stored apart from user data: they never touch users, sessions, attempts, assessments, XP, ability or app statistics. Their provider calls are recorded and priced under the run id and left out of every aggregate (overview cost and provider error rates, provider usage,daily_cost_cap_usd); playground audio is purged after 30 days. Comparing ASR models and thecontent_llm/qa_llmchains is out of scope for now.
Principle: every list has loading/empty/error states; every destructive action confirms; every automated decision shows why (the QA report) so a human can disagree in one click.
D5. Human-in-the-loop budget
Target: ≤ 15 minutes/day of operator time at steady state. Metrics to prove it: review-queue inflow per day, median time-in-queue, auto-publish share (target ≥ 85 %), retire rate. If inflow exceeds 20/day the dashboard surfaces the top rejection reasons so the prompt/threshold can be tuned instead of reviewing more.