cvpfus Codex commited on
Commit ·
ce52764
1
Parent(s): 0897844
Add article generator route
Browse filesCo-authored-by: Codex <codex@openai.com>
- FIELD_NOTES.md +2 -0
- README.md +9 -3
- SUBMISSION.md +5 -3
- app.py +149 -0
- scripts/verify.py +39 -0
- static/app.css +85 -0
- static/generate.html +66 -0
- static/generate.js +81 -0
- static/index.html +3 -3
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
|
| 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
|
| 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
|
| 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.
|
|
|
|
| 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/
|
| 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 |
+
"&": "&",
|
| 15 |
+
"<": "<",
|
| 16 |
+
">": ">",
|
| 17 |
+
'"': """,
|
| 18 |
+
"'": "'",
|
| 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="
|
| 16 |
-
<a href="
|
| 17 |
-
<a href="
|
| 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>
|