cvpfus Codex commited on
Commit
cf4bbc0
·
1 Parent(s): a95f296

Add section summary reader command

Browse files

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

Files changed (6) hide show
  1. FIELD_NOTES.md +1 -0
  2. README.md +1 -0
  3. app.py +30 -10
  4. scripts/verify.py +14 -0
  5. static/app.js +68 -2
  6. static/index.html +1 -0
FIELD_NOTES.md CHANGED
@@ -37,6 +37,7 @@ Keyboard map:
37
  - `P`: previous item.
38
  - `H`: next heading.
39
  - `I`: next image.
 
40
  - `R`: repeat current item.
41
  - `Esc`: stop audio.
42
 
 
37
  - `P`: previous item.
38
  - `H`: next heading.
39
  - `I`: next image.
40
+ - `S`: summarize the current section.
41
  - `R`: repeat current item.
42
  - `Esc`: stop audio.
43
 
README.md CHANGED
@@ -78,6 +78,7 @@ The frontend builds a reading queue from semantic article nodes. When screen-rea
78
  - `P` moves to the previous item.
79
  - `H` moves to the next heading.
80
  - `I` moves to the next image.
 
81
  - `R` repeats the current item.
82
  - `Esc` stops the current audio.
83
 
 
78
  - `P` moves to the previous item.
79
  - `H` moves to the next heading.
80
  - `I` moves to the next image.
81
+ - `S` summarizes the current section.
82
  - `R` repeats the current item.
83
  - `Esc` stops the current audio.
84
 
app.py CHANGED
@@ -61,6 +61,7 @@ ARTICLE_MANIFEST: dict[str, Any] = {
61
  {"key": "P", "action": "Previous item"},
62
  {"key": "H", "action": "Next heading"},
63
  {"key": "I", "action": "Next image"},
 
64
  {"key": "R", "action": "Repeat current item"},
65
  {"key": "Esc", "action": "Stop audio"},
66
  ],
@@ -101,16 +102,33 @@ def _json(data: dict[str, Any], status_code: int = 200) -> JSONResponse:
101
  return JSONResponse(data, status_code=status_code)
102
 
103
 
 
 
 
 
 
 
 
104
  def reader_brain_core(node_type: str, text: str, position: str | None, mode: str) -> dict[str, Any]:
105
- prompt = (
106
- "Convert this article node into concise screen-reader narration.\n"
107
- f"Mode: {mode}\n"
108
- f"Node type: {node_type}\n"
109
- f"Position: {position or 'unknown'}\n"
110
- f"Content: {text}\n\n"
111
- "Rules: announce the node type only when it helps orientation, keep prose short, "
112
- "and never mention implementation details."
113
- )
 
 
 
 
 
 
 
 
 
 
114
 
115
  try:
116
  body = json.dumps(
@@ -152,12 +170,14 @@ def reader_brain_core(node_type: str, text: str, position: str | None, mode: str
152
  "button": "Control. ",
153
  "quote": "Quote. ",
154
  }.get(node_type, "")
 
 
155
  return {
156
  "ok": True,
157
  "runtime": "fallback",
158
  "model": "rule-based local fallback",
159
  "warning": f"llama.cpp unavailable: {exc.__class__.__name__}",
160
- "narration": f"{prefix}{text}".strip(),
161
  }
162
 
163
 
 
61
  {"key": "P", "action": "Previous item"},
62
  {"key": "H", "action": "Next heading"},
63
  {"key": "I", "action": "Next image"},
64
+ {"key": "S", "action": "Summarize current section"},
65
  {"key": "R", "action": "Repeat current item"},
66
  {"key": "Esc", "action": "Stop audio"},
67
  ],
 
102
  return JSONResponse(data, status_code=status_code)
103
 
104
 
105
+ def _compact_text(text: str, limit: int = 220) -> str:
106
+ normalized = " ".join(text.split())
107
+ if len(normalized) <= limit:
108
+ return normalized
109
+ return f"{normalized[: limit - 1].rstrip()}."
110
+
111
+
112
  def reader_brain_core(node_type: str, text: str, position: str | None, mode: str) -> dict[str, Any]:
113
+ if mode == "summarize":
114
+ prompt = (
115
+ "Summarize this article section for screen-reader navigation.\n"
116
+ f"Node type: {node_type}\n"
117
+ f"Position: {position or 'unknown'}\n"
118
+ f"Content: {text}\n\n"
119
+ "Rules: start with 'Summary.', use one or two short sentences, explain what the "
120
+ "reader can learn in this section, and never mention implementation details."
121
+ )
122
+ else:
123
+ prompt = (
124
+ "Convert this article node into concise screen-reader narration.\n"
125
+ f"Mode: {mode}\n"
126
+ f"Node type: {node_type}\n"
127
+ f"Position: {position or 'unknown'}\n"
128
+ f"Content: {text}\n\n"
129
+ "Rules: announce the node type only when it helps orientation, keep prose short, "
130
+ "and never mention implementation details."
131
+ )
132
 
133
  try:
134
  body = json.dumps(
 
170
  "button": "Control. ",
171
  "quote": "Quote. ",
172
  }.get(node_type, "")
173
+ if mode == "summarize":
174
+ prefix = "Summary. "
175
  return {
176
  "ok": True,
177
  "runtime": "fallback",
178
  "model": "rule-based local fallback",
179
  "warning": f"llama.cpp unavailable: {exc.__class__.__name__}",
180
+ "narration": f"{prefix}{_compact_text(text)}".strip(),
181
  }
182
 
183
 
scripts/verify.py CHANGED
@@ -41,6 +41,15 @@ def verify_core_fallbacks() -> None:
41
  assert_true(narration["ok"], "Reader-brain fallback did not return ok")
42
  assert_true("Heading." in narration["narration"], "Reader-brain fallback lost heading prefix")
43
 
 
 
 
 
 
 
 
 
 
44
  description = app.describe_image_core("model-map", caption=None, prompt=None)
45
  assert_true(description["ok"], "Image description did not return ok")
46
  assert_true("small AI models" in description["alt_text"], "Image description fallback changed unexpectedly")
@@ -61,6 +70,7 @@ def verify_routes() -> None:
61
  assert_true(home.status_code == 200, "Home route should return 200")
62
  assert_true("Tiny Narrator" in home.text, "Home route should include app title")
63
  assert_true("readerToggle" in home.text, "Home route should include reader toggle")
 
64
  assert_true("transcriptLog" in home.text, "Home route should include transcript log")
65
 
66
  health = client.get("/api/health")
@@ -76,6 +86,10 @@ def verify_routes() -> None:
76
  assert_true(manifest.status_code == 200, "Article manifest route should return 200")
77
  manifest_payload = manifest.json()
78
  assert_true("Tiny Titan" in manifest_payload["bonus_targets"], "Manifest should include Tiny Titan target")
 
 
 
 
79
  assert_true(
80
  manifest_payload["models"]["speech"]["id"] == "hexgrad/Kokoro-82M",
81
  "Manifest should document Kokoro speech model",
 
41
  assert_true(narration["ok"], "Reader-brain fallback did not return ok")
42
  assert_true("Heading." in narration["narration"], "Reader-brain fallback lost heading prefix")
43
 
44
+ summary = app.reader_brain_core(
45
+ node_type="section",
46
+ text="Tiny models can run close to the reader. They keep latency low and preserve privacy.",
47
+ position="Why",
48
+ mode="summarize",
49
+ )
50
+ assert_true(summary["ok"], "Reader-brain summary fallback did not return ok")
51
+ assert_true(summary["narration"].startswith("Summary."), "Summary fallback should announce summary mode")
52
+
53
  description = app.describe_image_core("model-map", caption=None, prompt=None)
54
  assert_true(description["ok"], "Image description did not return ok")
55
  assert_true("small AI models" in description["alt_text"], "Image description fallback changed unexpectedly")
 
70
  assert_true(home.status_code == 200, "Home route should return 200")
71
  assert_true("Tiny Narrator" in home.text, "Home route should include app title")
72
  assert_true("readerToggle" in home.text, "Home route should include reader toggle")
73
+ assert_true("summaryButton" in home.text, "Home route should include summary control")
74
  assert_true("transcriptLog" in home.text, "Home route should include transcript log")
75
 
76
  health = client.get("/api/health")
 
86
  assert_true(manifest.status_code == 200, "Article manifest route should return 200")
87
  manifest_payload = manifest.json()
88
  assert_true("Tiny Titan" in manifest_payload["bonus_targets"], "Manifest should include Tiny Titan target")
89
+ assert_true(
90
+ {"key": "S", "action": "Summarize current section"} in manifest_payload["reader_controls"],
91
+ "Manifest should include summary shortcut",
92
+ )
93
  assert_true(
94
  manifest_payload["models"]["speech"]["id"] == "hexgrad/Kokoro-82M",
95
  "Manifest should document Kokoro speech model",
static/app.js CHANGED
@@ -17,6 +17,7 @@ const controls = {
17
  next: document.querySelector("#nextButton"),
18
  heading: document.querySelector("#headingButton"),
19
  image: document.querySelector("#imageButton"),
 
20
  };
21
 
22
  const nodes = [...document.querySelectorAll(".speakable")].map((element, index) => ({
@@ -64,6 +65,21 @@ function addTranscriptEntry(entry) {
64
  renderTranscript();
65
  }
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  async function loadManifest() {
68
  try {
69
  const manifest = await postJson("/api/article-manifest");
@@ -182,11 +198,59 @@ async function narrate(index) {
182
  }
183
  }
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  function announceIntro() {
186
  const headings = nodes.filter((node) => node.type === "heading").length;
187
  const images = nodes.filter((node) => node.type === "image").length;
188
  liveNarration.textContent =
189
- `Screen reader mode on. Article contains ${headings} headings, ${images} images, and ${nodes.length} readable items. Press N for next, H for heading, I for image, or Space to begin.`;
190
  runtimeStatus.textContent = "Ready";
191
  }
192
 
@@ -203,6 +267,7 @@ controls.next.addEventListener("click", () => narrate(Math.min(currentIndex + 1,
203
  controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1, 0)));
204
  controls.heading.addEventListener("click", () => nextByType("heading"));
205
  controls.image.addEventListener("click", () => nextByType("image"));
 
206
  copyTranscriptButton.addEventListener("click", async () => {
207
  const text = transcriptEntries
208
  .slice()
@@ -246,7 +311,7 @@ audio.addEventListener("ended", () => {
246
  document.addEventListener("keydown", (event) => {
247
  if (!enabled) return;
248
  const key = event.key.toLowerCase();
249
- if ([" ", "n", "p", "h", "i", "r", "escape"].includes(key)) {
250
  event.preventDefault();
251
  }
252
  if (key === " ") controls.play.click();
@@ -254,6 +319,7 @@ document.addEventListener("keydown", (event) => {
254
  if (key === "p") controls.prev.click();
255
  if (key === "h") controls.heading.click();
256
  if (key === "i") controls.image.click();
 
257
  if (key === "r" && currentIndex >= 0) narrate(currentIndex);
258
  if (key === "escape") {
259
  stopAudio();
 
17
  next: document.querySelector("#nextButton"),
18
  heading: document.querySelector("#headingButton"),
19
  image: document.querySelector("#imageButton"),
20
+ summary: document.querySelector("#summaryButton"),
21
  };
22
 
23
  const nodes = [...document.querySelectorAll(".speakable")].map((element, index) => ({
 
65
  renderTranscript();
66
  }
67
 
68
+ function currentSection() {
69
+ const activeNode = nodes[currentIndex]?.element;
70
+ return activeNode?.closest("section") || document.querySelector("#article article");
71
+ }
72
+
73
+ function currentSectionPayload() {
74
+ const section = currentSection();
75
+ const title = section.querySelector("h1, h2, h3")?.innerText.trim() || "Article introduction";
76
+ const text = [...section.querySelectorAll(".speakable")]
77
+ .map((element) => element.innerText.replace(/\s+/g, " ").trim())
78
+ .filter(Boolean)
79
+ .join(" ");
80
+ return { title, text };
81
+ }
82
+
83
  async function loadManifest() {
84
  try {
85
  const manifest = await postJson("/api/article-manifest");
 
198
  }
199
  }
200
 
201
+ async function summarizeCurrentSection() {
202
+ if (!enabled) return;
203
+ const serial = ++requestSerial;
204
+ const section = currentSectionPayload();
205
+ runtimeStatus.textContent = "Summarizing";
206
+ controls.summary.disabled = true;
207
+
208
+ try {
209
+ const result = await postJson("/api/reader-brain", {
210
+ node_type: "section",
211
+ text: section.text,
212
+ position: section.title,
213
+ mode: "summarize",
214
+ });
215
+
216
+ if (serial !== requestSerial) return;
217
+
218
+ runtimeStatus.textContent = result.runtime;
219
+ liveNarration.textContent = result.narration;
220
+ addTranscriptEntry({
221
+ type: "summary",
222
+ runtime: result.runtime,
223
+ text: result.narration,
224
+ });
225
+
226
+ const speech = await postJson("/api/speak", {
227
+ text: result.narration,
228
+ speed: Number(speedControl.value),
229
+ });
230
+
231
+ if (serial !== requestSerial) return;
232
+
233
+ if (speech.audio_url) {
234
+ audio.src = speech.audio_url;
235
+ await audio.play().catch(() => {
236
+ liveNarration.textContent = `${result.narration} Audio is ready. Press Play to hear it.`;
237
+ });
238
+ playing = !audio.paused;
239
+ controls.play.textContent = playing ? "Pause" : "Play";
240
+ }
241
+ } catch (error) {
242
+ runtimeStatus.textContent = "Error";
243
+ liveNarration.textContent = `Summary failed: ${error.message}`;
244
+ } finally {
245
+ controls.summary.disabled = false;
246
+ }
247
+ }
248
+
249
  function announceIntro() {
250
  const headings = nodes.filter((node) => node.type === "heading").length;
251
  const images = nodes.filter((node) => node.type === "image").length;
252
  liveNarration.textContent =
253
+ `Screen reader mode on. Article contains ${headings} headings, ${images} images, and ${nodes.length} readable items. Press N for next, H for heading, I for image, S for section summary, or Space to begin.`;
254
  runtimeStatus.textContent = "Ready";
255
  }
256
 
 
267
  controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1, 0)));
268
  controls.heading.addEventListener("click", () => nextByType("heading"));
269
  controls.image.addEventListener("click", () => nextByType("image"));
270
+ controls.summary.addEventListener("click", () => summarizeCurrentSection());
271
  copyTranscriptButton.addEventListener("click", async () => {
272
  const text = transcriptEntries
273
  .slice()
 
311
  document.addEventListener("keydown", (event) => {
312
  if (!enabled) return;
313
  const key = event.key.toLowerCase();
314
+ if ([" ", "n", "p", "h", "i", "s", "r", "escape"].includes(key)) {
315
  event.preventDefault();
316
  }
317
  if (key === " ") controls.play.click();
 
319
  if (key === "p") controls.prev.click();
320
  if (key === "h") controls.heading.click();
321
  if (key === "i") controls.image.click();
322
+ if (key === "s") controls.summary.click();
323
  if (key === "r" && currentIndex >= 0) narrate(currentIndex);
324
  if (key === "escape") {
325
  stopAudio();
static/index.html CHANGED
@@ -115,6 +115,7 @@
115
  <button id="nextButton" type="button" title="Next item">Next</button>
116
  <button id="headingButton" type="button" title="Next heading">Heading</button>
117
  <button id="imageButton" type="button" title="Next image">Image</button>
 
118
  <label>
119
  Speed
120
  <input id="speedControl" type="range" min="0.75" max="1.35" value="1" step="0.05" />
 
115
  <button id="nextButton" type="button" title="Next item">Next</button>
116
  <button id="headingButton" type="button" title="Next heading">Heading</button>
117
  <button id="imageButton" type="button" title="Next image">Image</button>
118
+ <button id="summaryButton" type="button" title="Summarize current section">Summary</button>
119
  <label>
120
  Speed
121
  <input id="speedControl" type="range" min="0.75" max="1.35" value="1" step="0.05" />