cvpfus Codex commited on
Commit
ce52764
·
1 Parent(s): 0897844

Add article generator route

Browse files

Co-authored-by: Codex <codex@openai.com>

FIELD_NOTES.md CHANGED
@@ -36,6 +36,8 @@ The judge evidence bundle lives at `/api/evidence-bundle`. That endpoint returns
36
 
37
  Image provenance lives in `/api/image-descriptions`. Each article illustration carries the planned FLUX.2 klein model id, prompt, seed, asset URL, and fallback status so the generated-image claim is inspectable.
38
 
 
 
39
  Accessibility evidence lives at `/api/accessibility-audit`. It records the reader-mode choices that matter most for this prototype: semantic reading order, keyboard navigation, reader cursor state, shortcut safety, live narration, image alt text, transcript review, user-controlled playback, and fallback resilience.
40
 
41
  ## Reader Mode Behavior
 
36
 
37
  Image provenance lives in `/api/image-descriptions`. Each article illustration carries the planned FLUX.2 klein model id, prompt, seed, asset URL, and fallback status so the generated-image claim is inspectable.
38
 
39
+ The Generate route uses `/api/generate-article` to turn a user's topic into a short semantic article. It uses the reader-brain model path for article text when llama.cpp is online, keeps a deterministic fallback draft for demos, and attaches thumbnail provenance from `black-forest-labs/FLUX.2-klein-4B`.
40
+
41
  Accessibility evidence lives at `/api/accessibility-audit`. It records the reader-mode choices that matter most for this prototype: semantic reading order, keyboard navigation, reader cursor state, shortcut safety, live narration, image alt text, transcript review, user-controlled playback, and fallback resilience.
42
 
43
  ## Reader Mode Behavior
README.md CHANGED
@@ -12,7 +12,7 @@ license: apache-2.0
12
 
13
  # Tiny Narrator
14
 
15
- Tiny Narrator is a Build Small Hackathon prototype: a custom Gradio Server article app that can switch into a guided screen-reader mode.
16
 
17
  For the hackathon form, see [SUBMISSION.md](SUBMISSION.md).
18
 
@@ -52,7 +52,7 @@ Start the app:
52
  python app.py
53
  ```
54
 
55
- Open the local URL printed by Gradio. The article page calls `/api/reader-brain`, `/api/image-descriptions`, `/api/describe-image`, `/api/speak`, and `/api/model-budget`; the remaining evidence endpoints stay available for docs and submission checks.
56
 
57
  Useful environment variables:
58
 
@@ -76,12 +76,14 @@ The verifier checks syntax, static assets, Space metadata consistency, determini
76
 
77
  `/api/runtime-setup` exposes the commands, environment values, and fallback paths used for the model stack so the demo can be reproduced from the same data the UI displays.
78
 
79
- `/api/demo-script` exposes a compact judge runbook with the visible actions, API checks, sample bodies, curl commands, and PowerShell-friendly `curl.exe` commands that prove the submission claims. The commands use `PUBLIC_BASE_URL` so they can be copied from the API response or submission notes.
80
 
81
  `/api/accessibility-audit` exposes structured evidence for semantic reading order, keyboard navigation, reader cursor state, shortcut safety, live narration, image alt text, transcript review, user-controlled playback, and fallback resilience.
82
 
83
  `/api/image-descriptions` includes image-generation provenance for every article illustration: the planned FLUX.2 klein model, prompt, seed, bundled asset URL, and fallback-ready status.
84
 
 
 
85
  `/api/submission-readiness` aggregates the judging receipts into one pass/fail payload covering model budget, award evidence, custom frontend assets, runtime setup, runtime status, accessibility, image receipts, demo API checks, and command base URL checks.
86
 
87
  The readiness rollup only passes the demo API check when POST entries include executable sample bodies.
@@ -138,3 +140,7 @@ The sidebar model stack reads `/api/model-budget` so judges can see each role's
138
  `/api/runtime-setup` summarizes each model path's runtime, setup command, and fallback, keeping the live demo honest about what is online and what is deterministic.
139
 
140
  `/api/runtime-status` performs a short readiness check for llama.cpp and local speech dependencies, then reports which fallback paths are ready for a live demo.
 
 
 
 
 
12
 
13
  # Tiny Narrator
14
 
15
+ Tiny Narrator is a Build Small Hackathon prototype: a custom Gradio Server app with two routes: a guided screen-reader article and a small-model article generator.
16
 
17
  For the hackathon form, see [SUBMISSION.md](SUBMISSION.md).
18
 
 
52
  python app.py
53
  ```
54
 
55
+ Open the local URL printed by Gradio. The Reader route calls `/api/reader-brain`, `/api/image-descriptions`, `/api/describe-image`, `/api/speak`, and `/api/model-budget`. The Generate route calls `/api/generate-article`, which drafts an article with the reader-brain model path and attaches a Klein thumbnail receipt.
56
 
57
  Useful environment variables:
58
 
 
76
 
77
  `/api/runtime-setup` exposes the commands, environment values, and fallback paths used for the model stack so the demo can be reproduced from the same data the UI displays.
78
 
79
+ `/api/demo-script` exposes a compact judge runbook with the visible actions, API checks, sample bodies, curl commands, and PowerShell-friendly `curl.exe` commands that prove the submission claims, including `/api/generate-article`. The commands use `PUBLIC_BASE_URL` so they can be copied from the API response or submission notes.
80
 
81
  `/api/accessibility-audit` exposes structured evidence for semantic reading order, keyboard navigation, reader cursor state, shortcut safety, live narration, image alt text, transcript review, user-controlled playback, and fallback resilience.
82
 
83
  `/api/image-descriptions` includes image-generation provenance for every article illustration: the planned FLUX.2 klein model, prompt, seed, bundled asset URL, and fallback-ready status.
84
 
85
+ `/api/generate-article` accepts a topic and returns a generated article draft plus thumbnail provenance from `black-forest-labs/FLUX.2-klein-4B`.
86
+
87
  `/api/submission-readiness` aggregates the judging receipts into one pass/fail payload covering model budget, award evidence, custom frontend assets, runtime setup, runtime status, accessibility, image receipts, demo API checks, and command base URL checks.
88
 
89
  The readiness rollup only passes the demo API check when POST entries include executable sample bodies.
 
140
  `/api/runtime-setup` summarizes each model path's runtime, setup command, and fallback, keeping the live demo honest about what is online and what is deterministic.
141
 
142
  `/api/runtime-status` performs a short readiness check for llama.cpp and local speech dependencies, then reports which fallback paths are ready for a live demo.
143
+
144
+ ## Generate Route
145
+
146
+ The header exposes two routes: Reader and Generate. Generate lets a user enter a topic, calls `/api/generate-article`, and renders a short semantic article with a thumbnail receipt. The text path uses the configured llama.cpp reader-brain model when available and falls back to deterministic article structure. The thumbnail path uses the planned Klein image model metadata and bundled fallback assets until the full image adapter is online.
SUBMISSION.md CHANGED
@@ -6,7 +6,7 @@ Tiny Narrator turns an article into a guided screen-reader experience using smal
6
 
7
  ## Short description
8
 
9
- Tiny Narrator is a custom Gradio Server app that looks like an article/blog reader until screen-reader mode is switched on. In reader mode, the app builds an internal semantic reading queue, narrates headings and paragraphs, describes generated images, summarizes the current section, speaks with Kokoro, and keeps a visible transcript of the spoken path with reader position, runtime, and latency.
10
 
11
  The prototype is designed for a live hackathon demo: every model-facing path has a deterministic fallback, runtime readiness is labeled in the UI, and the repo exposes machine-readable evidence for model size, setup, demo flow, and accessibility behavior.
12
 
@@ -33,7 +33,8 @@ The prototype is designed for a live hackathon demo: every model-facing path has
33
  3. Use `Heading`, `Image`, and `Summary` to navigate by article meaning instead of by raw page order.
34
  4. Show the reader-first session panel: current item, live narration, reader queue, transcript, and latency.
35
  5. Point to the model stack panel for model id, runtime, parameter count, and Tiny Titan pass status.
36
- 6. Mention that `/api/demo-script` exposes the judge runbook and API evidence checks as structured data.
 
37
 
38
  ## Evidence endpoints
39
 
@@ -44,10 +45,11 @@ The prototype is designed for a live hackathon demo: every model-facing path has
44
  - `/api/accessibility-audit`: semantic queue, keyboard navigation, reader cursor state, shortcut safety, live narration, alt text, transcript, user control, and fallback evidence.
45
  - `/api/demo-script`: repeatable judge runbook and API checks.
46
  - `/api/image-descriptions`: generated article image descriptions plus prompt, seed, model, asset URL, and fallback status receipts.
 
47
  - `/api/submission-readiness`: one pass/fail rollup for model budget, award evidence, custom frontend, runtime setup, runtime status, accessibility, image receipts, and demo API checks.
48
  - `/api/evidence-bundle`: copyable JSON bundle containing schema version, generation time, core judge evidence receipts, runtime status, and readiness rollup.
49
 
50
- The checks in `/api/demo-script` include copyable curl and PowerShell-friendly `curl.exe` commands generated from `PUBLIC_BASE_URL`, and the POST checks include sample JSON bodies for `/api/reader-brain` and `/api/speak`.
51
 
52
  The accessibility audit also documents reader-mode details that matter during judging: the active item is exposed as a reader cursor with focus, visible outline, stable id, and `aria-current`; global shortcuts ignore form controls so voice, speed, and auto-advance settings remain usable while reader mode is active; reader controls expose `aria-keyshortcuts` plus visible Repeat and Stop commands.
53
 
 
6
 
7
  ## Short description
8
 
9
+ Tiny Narrator is a custom Gradio Server app with a Reader route and a Generate route. Reader looks like an article/blog reader until screen-reader mode is switched on: it builds an internal semantic reading queue, narrates headings and paragraphs, describes generated images, summarizes the current section, speaks with Kokoro, and keeps a visible transcript of the spoken path with reader position, runtime, and latency. Generate creates a short article from a user topic and attaches a Klein thumbnail receipt.
10
 
11
  The prototype is designed for a live hackathon demo: every model-facing path has a deterministic fallback, runtime readiness is labeled in the UI, and the repo exposes machine-readable evidence for model size, setup, demo flow, and accessibility behavior.
12
 
 
33
  3. Use `Heading`, `Image`, and `Summary` to navigate by article meaning instead of by raw page order.
34
  4. Show the reader-first session panel: current item, live narration, reader queue, transcript, and latency.
35
  5. Point to the model stack panel for model id, runtime, parameter count, and Tiny Titan pass status.
36
+ 6. Open the Generate route, enter a topic, and show the generated article plus `black-forest-labs/FLUX.2-klein-4B` thumbnail receipt.
37
+ 7. Mention that `/api/demo-script` exposes the judge runbook and API evidence checks as structured data.
38
 
39
  ## Evidence endpoints
40
 
 
45
  - `/api/accessibility-audit`: semantic queue, keyboard navigation, reader cursor state, shortcut safety, live narration, alt text, transcript, user control, and fallback evidence.
46
  - `/api/demo-script`: repeatable judge runbook and API checks.
47
  - `/api/image-descriptions`: generated article image descriptions plus prompt, seed, model, asset URL, and fallback status receipts.
48
+ - `/api/generate-article`: topic-to-article generation using the reader-brain path plus Klein thumbnail provenance.
49
  - `/api/submission-readiness`: one pass/fail rollup for model budget, award evidence, custom frontend, runtime setup, runtime status, accessibility, image receipts, and demo API checks.
50
  - `/api/evidence-bundle`: copyable JSON bundle containing schema version, generation time, core judge evidence receipts, runtime status, and readiness rollup.
51
 
52
+ The checks in `/api/demo-script` include copyable curl and PowerShell-friendly `curl.exe` commands generated from `PUBLIC_BASE_URL`, and the POST checks include sample JSON bodies for `/api/reader-brain`, `/api/speak`, and `/api/generate-article`.
53
 
54
  The accessibility audit also documents reader-mode details that matter during judging: the active item is exposed as a reader cursor with focus, visible outline, stable id, and `aria-current`; global shortcuts ignore form controls so voice, speed, and auto-advance settings remain usable while reader mode is active; reader controls expose `aria-keyshortcuts` plus visible Repeat and Stop commands.
55
 
app.py CHANGED
@@ -262,6 +262,12 @@ def demo_script_core() -> dict[str, Any]:
262
  "speed": READER_SETTINGS["default_speed"],
263
  },
264
  },
 
 
 
 
 
 
265
  ]
266
  for check in api_checks:
267
  check["curl"] = demo_curl_command(check)
@@ -384,6 +390,7 @@ def submission_readiness_core() -> dict[str, Any]:
384
  "/api/image-descriptions",
385
  "/api/reader-brain",
386
  "/api/speak",
 
387
  }.issubset(api_paths)
388
  and post_samples_ready
389
  else "fail",
@@ -544,6 +551,10 @@ class ImageGenerationRequest(BaseModel):
544
  seed: int | None = None
545
 
546
 
 
 
 
 
547
  def _json(data: dict[str, Any], status_code: int = 200) -> JSONResponse:
548
  return JSONResponse(data, status_code=status_code)
549
 
@@ -617,6 +628,55 @@ def _compact_text(text: str, limit: int = 220) -> str:
617
  return f"{normalized[: limit - 1].rstrip()}."
618
 
619
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  def reader_brain_core(node_type: str, text: str, position: str | None, mode: str) -> dict[str, Any]:
621
  start = time.perf_counter()
622
  if mode == "summarize":
@@ -811,11 +871,90 @@ def generate_image_core(prompt: str, seed: int | None) -> dict[str, Any]:
811
  }
812
 
813
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
  @app.get("/", response_class=HTMLResponse)
815
  async def home() -> str:
816
  return (STATIC_DIR / "index.html").read_text(encoding="utf-8")
817
 
818
 
 
 
 
 
 
819
  @app.get("/api/health")
820
  async def health() -> JSONResponse:
821
  return _json(
@@ -900,6 +1039,11 @@ async def generate_image_endpoint(payload: ImageGenerationRequest) -> JSONRespon
900
  return _json(generate_image_core(payload.prompt, payload.seed))
901
 
902
 
 
 
 
 
 
903
  @app.api(name="reader_brain")
904
  def reader_brain_api(node_type: str, text: str, position: str = "", mode: str = "narrate") -> str:
905
  return json.dumps(reader_brain_core(node_type, text, position, mode))
@@ -955,6 +1099,11 @@ def generate_image_api(prompt: str, seed: int | None = None) -> str:
955
  return json.dumps(generate_image_core(prompt, seed))
956
 
957
 
 
 
 
 
 
958
  @app.exception_handler(Exception)
959
  async def handle_exception(_: Request, exc: Exception) -> JSONResponse:
960
  return _json({"ok": False, "error": exc.__class__.__name__, "detail": str(exc)}, status_code=500)
 
262
  "speed": READER_SETTINGS["default_speed"],
263
  },
264
  },
265
+ {
266
+ "method": "POST",
267
+ "path": "/api/generate-article",
268
+ "expect": "generated article draft with Klein thumbnail receipt",
269
+ "sample_body": {"topic": "accessible robotics for classrooms"},
270
+ },
271
  ]
272
  for check in api_checks:
273
  check["curl"] = demo_curl_command(check)
 
390
  "/api/image-descriptions",
391
  "/api/reader-brain",
392
  "/api/speak",
393
+ "/api/generate-article",
394
  }.issubset(api_paths)
395
  and post_samples_ready
396
  else "fail",
 
551
  seed: int | None = None
552
 
553
 
554
+ class ArticleGenerationRequest(BaseModel):
555
+ topic: str
556
+
557
+
558
  def _json(data: dict[str, Any], status_code: int = 200) -> JSONResponse:
559
  return JSONResponse(data, status_code=status_code)
560
 
 
628
  return f"{normalized[: limit - 1].rstrip()}."
629
 
630
 
631
+ def _topic_seed(topic: str) -> int:
632
+ return (sum(ord(char) for char in topic) % 997) + 1
633
+
634
+
635
+ def _fallback_article(topic: str) -> dict[str, Any]:
636
+ clean_topic = _compact_text(topic, 80).rstrip(".") or "accessible technology"
637
+ title = f"A tiny guide to {clean_topic}"
638
+ return {
639
+ "title": title,
640
+ "dek": (
641
+ f"This generated article introduces {clean_topic} with a short, screen-reader-friendly structure "
642
+ "and practical examples."
643
+ ),
644
+ "sections": [
645
+ {
646
+ "heading": f"Why {clean_topic} matters",
647
+ "body": (
648
+ f"{clean_topic.capitalize()} is easier to understand when the page explains the main idea first, "
649
+ "then moves through examples in a predictable order."
650
+ ),
651
+ },
652
+ {
653
+ "heading": "How a tiny narrator helps",
654
+ "body": (
655
+ "A small reader model can turn each heading, paragraph, and image caption into concise narration "
656
+ "without turning the article into a chatbot."
657
+ ),
658
+ },
659
+ {
660
+ "heading": "What to try next",
661
+ "body": (
662
+ "Use the reader controls to move by heading or image, then compare the transcript with the article "
663
+ "to check whether the generated structure is easy to follow."
664
+ ),
665
+ },
666
+ ],
667
+ }
668
+
669
+
670
+ def _article_generation_prompt(topic: str) -> str:
671
+ return (
672
+ "Write a short accessible article for Tiny Narrator.\n"
673
+ f"Topic: {topic}\n\n"
674
+ "Return strict JSON with keys title, dek, and sections. "
675
+ "sections must contain exactly three objects with heading and body. "
676
+ "Keep every body under 45 words. Do not include markdown."
677
+ )
678
+
679
+
680
  def reader_brain_core(node_type: str, text: str, position: str | None, mode: str) -> dict[str, Any]:
681
  start = time.perf_counter()
682
  if mode == "summarize":
 
871
  }
872
 
873
 
874
+ def generate_article_core(topic: str) -> dict[str, Any]:
875
+ start = time.perf_counter()
876
+ clean_topic = _compact_text(topic, 100).strip()
877
+ if not clean_topic:
878
+ clean_topic = "accessible technology"
879
+
880
+ article = _fallback_article(clean_topic)
881
+ runtime = "fallback"
882
+ warning = None
883
+ try:
884
+ body = json.dumps(
885
+ {
886
+ "model": LLAMA_CPP_MODEL,
887
+ "messages": [
888
+ {
889
+ "role": "system",
890
+ "content": (
891
+ "You are Tiny Narrator's article generator. "
892
+ "Create concise, semantic, accessible article drafts."
893
+ ),
894
+ },
895
+ {"role": "user", "content": _article_generation_prompt(clean_topic)},
896
+ ],
897
+ "temperature": 0.35,
898
+ "max_tokens": 420,
899
+ }
900
+ ).encode("utf-8")
901
+ request = urllib.request.Request(
902
+ f"{LLAMA_CPP_BASE_URL}/chat/completions",
903
+ data=body,
904
+ headers={"Content-Type": "application/json"},
905
+ method="POST",
906
+ )
907
+ with urllib.request.urlopen(request, timeout=45) as response:
908
+ payload = json.loads(response.read().decode("utf-8"))
909
+ generated = json.loads(payload["choices"][0]["message"]["content"].strip())
910
+ sections = generated.get("sections", [])
911
+ if (
912
+ isinstance(generated.get("title"), str)
913
+ and isinstance(generated.get("dek"), str)
914
+ and isinstance(sections, list)
915
+ and len(sections) == 3
916
+ and all(isinstance(item.get("heading"), str) and isinstance(item.get("body"), str) for item in sections)
917
+ ):
918
+ article = {
919
+ "title": _compact_text(generated["title"], 90),
920
+ "dek": _compact_text(generated["dek"], 180),
921
+ "sections": [
922
+ {"heading": _compact_text(item["heading"], 80), "body": _compact_text(item["body"], 260)}
923
+ for item in sections
924
+ ],
925
+ }
926
+ runtime = "llama.cpp"
927
+ except (OSError, urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as exc:
928
+ warning = f"llama.cpp unavailable: {exc.__class__.__name__}"
929
+
930
+ image_prompt = f"Editorial thumbnail for an accessible article about {clean_topic}."
931
+ thumbnail = generate_image_core(image_prompt, seed=_topic_seed(clean_topic))
932
+ return {
933
+ "ok": True,
934
+ "topic": clean_topic,
935
+ "runtime": runtime,
936
+ "model": MODEL_MANIFEST["reader_brain"]["id"],
937
+ "warning": warning,
938
+ "article": article,
939
+ "thumbnail": {
940
+ **thumbnail,
941
+ "role": "thumbnail",
942
+ "generation_model": MODEL_MANIFEST["image_generation"]["id"],
943
+ },
944
+ "elapsed_ms": _elapsed_ms(start),
945
+ }
946
+
947
+
948
  @app.get("/", response_class=HTMLResponse)
949
  async def home() -> str:
950
  return (STATIC_DIR / "index.html").read_text(encoding="utf-8")
951
 
952
 
953
+ @app.get("/generate", response_class=HTMLResponse)
954
+ async def generate_page() -> str:
955
+ return (STATIC_DIR / "generate.html").read_text(encoding="utf-8")
956
+
957
+
958
  @app.get("/api/health")
959
  async def health() -> JSONResponse:
960
  return _json(
 
1039
  return _json(generate_image_core(payload.prompt, payload.seed))
1040
 
1041
 
1042
+ @app.post("/api/generate-article")
1043
+ async def generate_article_endpoint(payload: ArticleGenerationRequest) -> JSONResponse:
1044
+ return _json(generate_article_core(payload.topic))
1045
+
1046
+
1047
  @app.api(name="reader_brain")
1048
  def reader_brain_api(node_type: str, text: str, position: str = "", mode: str = "narrate") -> str:
1049
  return json.dumps(reader_brain_core(node_type, text, position, mode))
 
1099
  return json.dumps(generate_image_core(prompt, seed))
1100
 
1101
 
1102
+ @app.api(name="generate_article")
1103
+ def generate_article_api(topic: str) -> str:
1104
+ return json.dumps(generate_article_core(topic))
1105
+
1106
+
1107
  @app.exception_handler(Exception)
1108
  async def handle_exception(_: Request, exc: Exception) -> JSONResponse:
1109
  return _json({"ok": False, "error": exc.__class__.__name__, "detail": str(exc)}, status_code=500)
scripts/verify.py CHANGED
@@ -23,8 +23,10 @@ def verify_static_assets() -> None:
23
  ROOT / "LICENSE",
24
  ROOT / "SUBMISSION.md",
25
  ROOT / "static" / "index.html",
 
26
  ROOT / "static" / "app.css",
27
  ROOT / "static" / "app.js",
 
28
  ROOT / "static" / "generated" / "desk-reader.svg",
29
  ROOT / "static" / "generated" / "model-map.svg",
30
  ROOT / "static" / "generated" / "field-notes.svg",
@@ -33,7 +35,17 @@ def verify_static_assets() -> None:
33
  assert_true(path.exists(), f"Missing required asset: {path}")
34
 
35
  app_js = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
 
36
  index_html = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
37
  assert_true('data-reader-type="heading"' in index_html, "Article should mark heading reader nodes")
38
  assert_true('data-reader-type="image"' in index_html, "Article should mark image reader nodes")
39
  assert_true('href="#notes"' not in index_html, "Article navigation should not include the Field Notes section")
@@ -104,6 +116,7 @@ def verify_static_assets() -> None:
104
  "/api/demo-script",
105
  "/api/submission-readiness",
106
  "/api/evidence-bundle",
 
107
  ]:
108
  assert_true(endpoint in submission, f"Submission packet should mention {endpoint}")
109
  assert_true(
@@ -207,6 +220,15 @@ def verify_core_fallbacks() -> None:
207
  assert_true(generated["ok"], "Image generation placeholder should return ok")
208
  assert_true(isinstance(generated["elapsed_ms"], int), "Image generation should include elapsed_ms")
209
 
 
 
 
 
 
 
 
 
 
210
 
211
  def verify_output_retention() -> None:
212
  keep_path = app.OUTPUT_DIR / "speech-retention-keep.wav"
@@ -235,6 +257,7 @@ def verify_routes() -> None:
235
  home = client.get("/")
236
  assert_true(home.status_code == 200, "Home route should return 200")
237
  assert_true("Tiny Narrator" in home.text, "Home route should include app title")
 
238
  assert_true("readerToggle" in home.text, "Home route should include reader toggle")
239
  assert_true("summaryButton" in home.text, "Home route should include summary control")
240
  assert_true("imageStatus" in home.text, "Home route should include image status")
@@ -250,6 +273,12 @@ def verify_routes() -> None:
250
  assert_true("/evidence" not in home.text, "Home route should not link to a removed evidence page")
251
  assert_true("copyEvidenceButton" not in home.text, "Home route should keep judge evidence off the reader sidebar")
252
 
 
 
 
 
 
 
253
  health = client.get("/api/health")
254
  assert_true(health.status_code == 200, "Health route should return 200")
255
  payload = health.json()
@@ -425,6 +454,16 @@ def verify_routes() -> None:
425
  "Speech sample payload should return an output audio URL",
426
  )
427
 
 
 
 
 
 
 
 
 
 
 
428
  audit = client.get("/api/accessibility-audit")
429
  assert_true(audit.status_code == 200, "Accessibility audit route should return 200")
430
  audit_payload = audit.json()
 
23
  ROOT / "LICENSE",
24
  ROOT / "SUBMISSION.md",
25
  ROOT / "static" / "index.html",
26
+ ROOT / "static" / "generate.html",
27
  ROOT / "static" / "app.css",
28
  ROOT / "static" / "app.js",
29
+ ROOT / "static" / "generate.js",
30
  ROOT / "static" / "generated" / "desk-reader.svg",
31
  ROOT / "static" / "generated" / "model-map.svg",
32
  ROOT / "static" / "generated" / "field-notes.svg",
 
35
  assert_true(path.exists(), f"Missing required asset: {path}")
36
 
37
  app_js = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
38
+ generate_js = (ROOT / "static" / "generate.js").read_text(encoding="utf-8")
39
  index_html = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
40
+ generate_html = (ROOT / "static" / "generate.html").read_text(encoding="utf-8")
41
+ assert_true('href="/" aria-current="page">Reader' in index_html, "Reader page should mark Reader route current")
42
+ assert_true('href="/generate">Generate' in index_html, "Reader page should link to Generate route")
43
+ assert_true('href="/">Reader' in generate_html, "Generate page should link back to Reader route")
44
+ assert_true('href="/generate" aria-current="page">Generate' in generate_html, "Generate page should mark Generate route current")
45
+ assert_true("articleGeneratorForm" in generate_html, "Generate page should expose the article generator form")
46
+ assert_true("generatedThumbnail" in generate_html, "Generate page should expose a generated thumbnail preview")
47
+ assert_true("/api/generate-article" in generate_js, "Generate frontend should call article generation API")
48
+ assert_true("thumbnail.generation_model" in generate_js, "Generate frontend should render Klein thumbnail receipt")
49
  assert_true('data-reader-type="heading"' in index_html, "Article should mark heading reader nodes")
50
  assert_true('data-reader-type="image"' in index_html, "Article should mark image reader nodes")
51
  assert_true('href="#notes"' not in index_html, "Article navigation should not include the Field Notes section")
 
116
  "/api/demo-script",
117
  "/api/submission-readiness",
118
  "/api/evidence-bundle",
119
+ "/api/generate-article",
120
  ]:
121
  assert_true(endpoint in submission, f"Submission packet should mention {endpoint}")
122
  assert_true(
 
220
  assert_true(generated["ok"], "Image generation placeholder should return ok")
221
  assert_true(isinstance(generated["elapsed_ms"], int), "Image generation should include elapsed_ms")
222
 
223
+ article = app.generate_article_core("tiny classroom robotics")
224
+ assert_true(article["ok"], "Article generation should return ok")
225
+ assert_true(article["model"] == app.MODEL_MANIFEST["reader_brain"]["id"], "Article generation should use reader-brain model")
226
+ assert_true(len(article["article"]["sections"]) == 3, "Article generation should return three sections")
227
+ assert_true(
228
+ article["thumbnail"]["generation_model"] == app.MODEL_MANIFEST["image_generation"]["id"],
229
+ "Article generation should use the Klein image model for thumbnail receipt",
230
+ )
231
+
232
 
233
  def verify_output_retention() -> None:
234
  keep_path = app.OUTPUT_DIR / "speech-retention-keep.wav"
 
257
  home = client.get("/")
258
  assert_true(home.status_code == 200, "Home route should return 200")
259
  assert_true("Tiny Narrator" in home.text, "Home route should include app title")
260
+ assert_true('href="/generate">Generate' in home.text, "Home route should link to Generate route")
261
  assert_true("readerToggle" in home.text, "Home route should include reader toggle")
262
  assert_true("summaryButton" in home.text, "Home route should include summary control")
263
  assert_true("imageStatus" in home.text, "Home route should include image status")
 
273
  assert_true("/evidence" not in home.text, "Home route should not link to a removed evidence page")
274
  assert_true("copyEvidenceButton" not in home.text, "Home route should keep judge evidence off the reader sidebar")
275
 
276
+ generate_page = client.get("/generate")
277
+ assert_true(generate_page.status_code == 200, "Generate route should return 200")
278
+ assert_true("Generate a readable article" in generate_page.text, "Generate route should include generator title")
279
+ assert_true("articleGeneratorForm" in generate_page.text, "Generate route should include article generator form")
280
+ assert_true("generatedThumbnail" in generate_page.text, "Generate route should include generated thumbnail")
281
+
282
  health = client.get("/api/health")
283
  assert_true(health.status_code == 200, "Health route should return 200")
284
  payload = health.json()
 
454
  "Speech sample payload should return an output audio URL",
455
  )
456
 
457
+ article_sample = client.post("/api/generate-article", json={"topic": "accessible classroom robotics"})
458
+ assert_true(article_sample.status_code == 200, "Article generation route should return 200")
459
+ article_sample_payload = article_sample.json()
460
+ assert_true(article_sample_payload["ok"], "Article generation payload should return ok")
461
+ assert_true(len(article_sample_payload["article"]["sections"]) == 3, "Article generation payload should include three sections")
462
+ assert_true(
463
+ article_sample_payload["thumbnail"]["generation_model"] == app.MODEL_MANIFEST["image_generation"]["id"],
464
+ "Article generation payload should include Klein thumbnail provenance",
465
+ )
466
+
467
  audit = client.get("/api/accessibility-audit")
468
  assert_true(audit.status_code == 200, "Accessibility audit route should return 200")
469
  audit_payload = audit.json()
static/app.css CHANGED
@@ -177,6 +177,79 @@ select:focus-visible {
177
 
178
  .article { min-width: 0; }
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  /* ─── ARTICLE TYPOGRAPHY ─── */
181
  .kicker {
182
  display: inline-block;
@@ -692,6 +765,18 @@ dd {
692
  grid-template-columns: 1fr;
693
  }
694
 
 
 
 
 
 
 
 
 
 
 
 
 
695
  h1 {
696
  max-width: 14ch;
697
  font-size: clamp(2.8rem, 15vw, 4.5rem);
 
177
 
178
  .article { min-width: 0; }
179
 
180
+ .generator-shell {
181
+ display: grid;
182
+ grid-template-columns: minmax(280px, 420px) minmax(0, 680px);
183
+ gap: clamp(32px, 6vw, 72px);
184
+ width: min(1180px, calc(100% - 40px));
185
+ margin: 56px auto 120px;
186
+ align-items: start;
187
+ }
188
+
189
+ .generator-controls {
190
+ position: sticky;
191
+ top: 88px;
192
+ }
193
+
194
+ .generator-form {
195
+ display: grid;
196
+ gap: 10px;
197
+ margin-top: 28px;
198
+ }
199
+
200
+ .generator-form label {
201
+ color: var(--muted);
202
+ font-family: var(--mono);
203
+ font-size: 0.72rem;
204
+ font-weight: 700;
205
+ text-transform: uppercase;
206
+ letter-spacing: 0.08em;
207
+ }
208
+
209
+ .generator-input-row {
210
+ display: grid;
211
+ grid-template-columns: 1fr max-content;
212
+ gap: 8px;
213
+ }
214
+
215
+ .generator-input-row input {
216
+ min-height: 44px;
217
+ border: 1px solid var(--line);
218
+ background: var(--surface);
219
+ color: var(--ink);
220
+ padding: 0 12px;
221
+ }
222
+
223
+ .generator-input-row button,
224
+ .route-action {
225
+ min-height: 44px;
226
+ border: 2px solid var(--ink);
227
+ padding: 0 16px;
228
+ font-family: var(--mono);
229
+ font-size: 0.76rem;
230
+ font-weight: 700;
231
+ text-transform: uppercase;
232
+ letter-spacing: 0.06em;
233
+ }
234
+
235
+ .route-action {
236
+ display: inline-flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ color: var(--ink);
240
+ text-decoration: none;
241
+ background: var(--surface);
242
+ }
243
+
244
+ .generated-article {
245
+ min-width: 0;
246
+ }
247
+
248
+ .generated-sections {
249
+ display: grid;
250
+ gap: 8px;
251
+ }
252
+
253
  /* ─── ARTICLE TYPOGRAPHY ─── */
254
  .kicker {
255
  display: inline-block;
 
765
  grid-template-columns: 1fr;
766
  }
767
 
768
+ .generator-shell {
769
+ grid-template-columns: 1fr;
770
+ }
771
+
772
+ .generator-controls {
773
+ position: static;
774
+ }
775
+
776
+ .generator-input-row {
777
+ grid-template-columns: 1fr;
778
+ }
779
+
780
  h1 {
781
  max-width: 14ch;
782
  font-size: clamp(2.8rem, 15vw, 4.5rem);
static/generate.html ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Tiny Narrator Generator</title>
7
+ <link rel="stylesheet" href="/static/app.css" />
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <a class="brand" href="/" aria-label="Tiny Narrator home">
12
+ <span class="brand-mark" aria-hidden="true">TN</span>
13
+ <span>Tiny Narrator</span>
14
+ </a>
15
+ <nav class="topnav" aria-label="Primary routes">
16
+ <a href="/">Reader</a>
17
+ <a href="/generate" aria-current="page">Generate</a>
18
+ </nav>
19
+ <a class="reader-toggle route-action" href="/">Reader App</a>
20
+ </header>
21
+
22
+ <main class="generator-shell" aria-labelledby="generatorTitle">
23
+ <section class="generator-controls">
24
+ <p class="kicker">Small-model article lab</p>
25
+ <h1 id="generatorTitle">Generate a readable article</h1>
26
+ <p class="dek">
27
+ Pick a topic and Tiny Narrator drafts a short semantic article with the llama.cpp reader brain,
28
+ then asks the Klein image path for a thumbnail receipt.
29
+ </p>
30
+ <form id="articleGeneratorForm" class="generator-form">
31
+ <label for="topicInput">Topic</label>
32
+ <div class="generator-input-row">
33
+ <input
34
+ id="topicInput"
35
+ name="topic"
36
+ type="text"
37
+ minlength="3"
38
+ maxlength="100"
39
+ value="accessible robotics for classrooms"
40
+ required
41
+ />
42
+ <button id="generateArticleButton" type="submit">Generate</button>
43
+ </div>
44
+ </form>
45
+ <p id="generatorStatus" class="live-narration" aria-live="polite">Ready to generate.</p>
46
+ </section>
47
+
48
+ <article id="generatedArticle" class="generated-article" aria-labelledby="generatedTitle">
49
+ <figure class="hero-figure">
50
+ <img id="generatedThumbnail" src="/static/generated/model-map.svg" alt="Generated article thumbnail preview." />
51
+ <figcaption id="thumbnailReceipt">
52
+ Thumbnail will use black-forest-labs/FLUX.2-klein-4B.
53
+ </figcaption>
54
+ </figure>
55
+ <p id="generatedKicker" class="kicker">Generated draft</p>
56
+ <h1 id="generatedTitle">Your generated article will appear here</h1>
57
+ <p id="generatedDek" class="dek">
58
+ The result will include a title, summary, three readable sections, model runtime, and thumbnail receipt.
59
+ </p>
60
+ <div id="generatedSections" class="generated-sections"></div>
61
+ </article>
62
+ </main>
63
+
64
+ <script src="/static/generate.js" type="module"></script>
65
+ </body>
66
+ </html>
static/generate.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const form = document.querySelector("#articleGeneratorForm");
2
+ const topicInput = document.querySelector("#topicInput");
3
+ const generateArticleButton = document.querySelector("#generateArticleButton");
4
+ const generatorStatus = document.querySelector("#generatorStatus");
5
+ const generatedKicker = document.querySelector("#generatedKicker");
6
+ const generatedTitle = document.querySelector("#generatedTitle");
7
+ const generatedDek = document.querySelector("#generatedDek");
8
+ const generatedSections = document.querySelector("#generatedSections");
9
+ const generatedThumbnail = document.querySelector("#generatedThumbnail");
10
+ const thumbnailReceipt = document.querySelector("#thumbnailReceipt");
11
+
12
+ function escapeHtml(value) {
13
+ return String(value).replace(/[&<>"']/g, (char) => ({
14
+ "&": "&amp;",
15
+ "<": "&lt;",
16
+ ">": "&gt;",
17
+ '"': "&quot;",
18
+ "'": "&#39;",
19
+ }[char]));
20
+ }
21
+
22
+ function formatElapsed(ms) {
23
+ if (!Number.isFinite(ms)) {
24
+ return "n/a";
25
+ }
26
+ return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;
27
+ }
28
+
29
+ async function postJson(url, payload) {
30
+ const response = await fetch(url, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify(payload),
34
+ });
35
+ if (!response.ok) {
36
+ throw new Error(`${response.status} ${response.statusText}`);
37
+ }
38
+ return response.json();
39
+ }
40
+
41
+ function renderArticle(payload) {
42
+ const article = payload.article;
43
+ const thumbnail = payload.thumbnail;
44
+ generatedKicker.textContent = `${payload.runtime} article`;
45
+ generatedTitle.textContent = article.title;
46
+ generatedDek.textContent = article.dek;
47
+ generatedThumbnail.src = thumbnail.image_url;
48
+ generatedThumbnail.alt = `Generated thumbnail for ${payload.topic}.`;
49
+ thumbnailReceipt.textContent =
50
+ `${thumbnail.generation_model} | seed ${thumbnail.seed} | ${thumbnail.runtime}`;
51
+ generatedSections.innerHTML = article.sections
52
+ .map((section) => `
53
+ <section>
54
+ <h2>${escapeHtml(section.heading)}</h2>
55
+ <p>${escapeHtml(section.body)}</p>
56
+ </section>
57
+ `)
58
+ .join("");
59
+ generatorStatus.textContent =
60
+ `Generated with ${payload.model}; thumbnail from ${thumbnail.generation_model}; ${formatElapsed(payload.elapsed_ms)}.`;
61
+ }
62
+
63
+ form.addEventListener("submit", async (event) => {
64
+ event.preventDefault();
65
+ const topic = topicInput.value.trim();
66
+ if (!topic) {
67
+ generatorStatus.textContent = "Enter a topic first.";
68
+ return;
69
+ }
70
+
71
+ generateArticleButton.disabled = true;
72
+ generatorStatus.textContent = "Generating article and Klein thumbnail.";
73
+ try {
74
+ const payload = await postJson("/api/generate-article", { topic });
75
+ renderArticle(payload);
76
+ } catch (error) {
77
+ generatorStatus.textContent = `Generation failed: ${error.message}`;
78
+ } finally {
79
+ generateArticleButton.disabled = false;
80
+ }
81
+ });
static/index.html CHANGED
@@ -12,9 +12,9 @@
12
  <span class="brand-mark" aria-hidden="true">TN</span>
13
  <span>Tiny Narrator</span>
14
  </a>
15
- <nav class="topnav" aria-label="Article sections">
16
- <a href="#why">Why</a>
17
- <a href="#models">Models</a>
18
  </nav>
19
  <button id="readerToggle" class="reader-toggle" type="button" aria-pressed="false">
20
  <span class="toggle-dot" aria-hidden="true"></span>
 
12
  <span class="brand-mark" aria-hidden="true">TN</span>
13
  <span>Tiny Narrator</span>
14
  </a>
15
+ <nav class="topnav" aria-label="Primary routes">
16
+ <a href="/" aria-current="page">Reader</a>
17
+ <a href="/generate">Generate</a>
18
  </nav>
19
  <button id="readerToggle" class="reader-toggle" type="button" aria-pressed="false">
20
  <span class="toggle-dot" aria-hidden="true"></span>