ProCreations commited on
Commit
bb9dbe4
·
verified ·
1 Parent(s): 947b756

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. app.py +32 -12
  2. engine.py +87 -22
  3. mock_engine.py +3 -2
  4. static/board.js +18 -7
app.py CHANGED
@@ -84,17 +84,29 @@ def _payload(turn_id, voice_on, status, detail, new_steps, seq_start):
84
  })
85
 
86
 
 
 
 
 
 
 
 
 
 
 
87
  def run_turn(audio_path, typed_text, snapshot, chat, convo, profile,
88
- pace, web_on, voice_on):
89
  """Bridges ENGINE.run_turn events into streaming Gradio updates."""
90
  chat = list(chat or [])
91
  convo = list(convo or [])
92
  profile = dict(profile or {})
 
 
93
  turn_id = uuid.uuid4().hex[:10]
94
 
95
  has_input = bool(audio_path or (typed_text or "").strip() or snapshot)
96
  if not has_input:
97
- yield (gr.update(), chat, convo, profile, profile,
98
  gr.update(), gr.update())
99
  return
100
 
@@ -106,8 +118,8 @@ def run_turn(audio_path, typed_text, snapshot, chat, convo, profile,
106
 
107
  def render(status="thinking", detail="", new_steps=(), seq=0):
108
  return (_payload(turn_id, voice_on, status, detail, list(new_steps), seq),
109
- chat, convo, profile, profile, gr.update(value=None),
110
- gr.update(value=""))
111
 
112
  yield render(detail="Thinking…")
113
 
@@ -116,7 +128,7 @@ def run_turn(audio_path, typed_text, snapshot, chat, convo, profile,
116
  print(f"[tutori] turn {turn_id}: starting engine", flush=True)
117
  try:
118
  for ev in ENGINE.run_turn(audio_path, typed_text, snapshot, convo, profile,
119
- pace, web_on, voice_on):
120
  kind = ev.get("type")
121
  if seq == 0 and kind != "step":
122
  print(f"[tutori] turn {turn_id}: event {kind}", flush=True)
@@ -126,9 +138,14 @@ def run_turn(audio_path, typed_text, snapshot, chat, convo, profile,
126
  question_for_context = ev["text"]
127
  chat[-2]["content"] = f"🎙️ {ev['text']}"
128
  yield render("thinking", "Heard you!", (), seq)
 
 
 
 
129
  elif kind == "step":
130
  step = ev["step"]
131
  says.append(step.get("say", ""))
 
132
  chat[-1]["content"] = "✏️ " + " ".join(s for s in says if s)
133
  yield render("teaching", f"Teaching — step {seq + 1}", [step], seq)
134
  seq += 1
@@ -156,12 +173,12 @@ def run_turn(audio_path, typed_text, snapshot, chat, convo, profile,
156
  final_text = " ".join(s for s in says if s).strip() or chat[-1]["content"]
157
  chat[-1]["content"] = "✏️ " + final_text
158
  convo.append({"role": "user", "content": question_for_context})
159
- convo.append({"role": "assistant", "content": final_text[:900]})
160
  yield render("done", "", (), seq)
161
 
162
 
163
  def reset_session():
164
- return [], [], "", gr.update(value=None)
165
 
166
 
167
  def forget_me():
@@ -180,6 +197,8 @@ with gr.Blocks(
180
 
181
  profile_state = gr.BrowserState(EMPTY_PROFILE, storage_key="tutori_profile_v1")
182
  convo_state = gr.State([])
 
 
183
 
184
  payload_box = gr.Textbox(visible=False, elem_id="tutori-payload")
185
  snap_box = gr.Textbox(visible=False)
@@ -256,17 +275,17 @@ with gr.Blocks(
256
  # `snap_box`. (gr.State arrives as null in js; pass it through untouched.)
257
  def _snap_js(fn_name):
258
  return (
259
- "(audio, text, snap, chat, convo, profile, pace, web, voice) => "
260
  f"[audio, text, (window.{fn_name} ? window.{fn_name}() : ''), "
261
- "chat, convo, profile, pace, web, voice]"
262
  )
263
 
264
  turn_io = dict(
265
  fn=run_turn,
266
  inputs=[mic, text_in, snap_box, chatbot, convo_state, profile_state,
267
- pace, web_on, voice_on],
268
  outputs=[payload_box, chatbot, convo_state, profile_state, profile_view,
269
- mic, text_in],
270
  show_progress="hidden",
271
  )
272
 
@@ -284,7 +303,8 @@ with gr.Blocks(
284
  skip_btn.click(fn=None, inputs=None, outputs=None,
285
  js="() => window.tutoriStop()")
286
  new_btn.click(
287
- reset_session, None, [chatbot, convo_state, text_in, mic],
 
288
  js="() => { window.tutoriClearAll && window.tutoriClearAll(); return []; }",
289
  )
290
  forget_btn.click(forget_me, None, [profile_state, profile_view])
 
84
  })
85
 
86
 
87
+ def _track_board(board_ops, step_board):
88
+ """Mirror what the lesson drew so the next turn knows the board state."""
89
+ for op in step_board or []:
90
+ if op.get("op") == "clear":
91
+ board_ops = []
92
+ else:
93
+ board_ops.append(op)
94
+ return board_ops[-60:]
95
+
96
+
97
  def run_turn(audio_path, typed_text, snapshot, chat, convo, profile,
98
+ notes, board_ops, pace, web_on, voice_on):
99
  """Bridges ENGINE.run_turn events into streaming Gradio updates."""
100
  chat = list(chat or [])
101
  convo = list(convo or [])
102
  profile = dict(profile or {})
103
+ notes = notes or ""
104
+ board_ops = list(board_ops or [])
105
  turn_id = uuid.uuid4().hex[:10]
106
 
107
  has_input = bool(audio_path or (typed_text or "").strip() or snapshot)
108
  if not has_input:
109
+ yield (gr.update(), chat, convo, profile, profile, notes, board_ops,
110
  gr.update(), gr.update())
111
  return
112
 
 
118
 
119
  def render(status="thinking", detail="", new_steps=(), seq=0):
120
  return (_payload(turn_id, voice_on, status, detail, list(new_steps), seq),
121
+ chat, convo, profile, profile, notes, board_ops,
122
+ gr.update(value=None), gr.update(value=""))
123
 
124
  yield render(detail="Thinking…")
125
 
 
128
  print(f"[tutori] turn {turn_id}: starting engine", flush=True)
129
  try:
130
  for ev in ENGINE.run_turn(audio_path, typed_text, snapshot, convo, profile,
131
+ notes, board_ops, pace, web_on, voice_on):
132
  kind = ev.get("type")
133
  if seq == 0 and kind != "step":
134
  print(f"[tutori] turn {turn_id}: event {kind}", flush=True)
 
138
  question_for_context = ev["text"]
139
  chat[-2]["content"] = f"🎙️ {ev['text']}"
140
  yield render("thinking", "Heard you!", (), seq)
141
+ elif kind == "research":
142
+ fresh = ev.get("notes", "")
143
+ if fresh:
144
+ notes = (notes + "\n" + fresh)[-4000:]
145
  elif kind == "step":
146
  step = ev["step"]
147
  says.append(step.get("say", ""))
148
+ board_ops = _track_board(board_ops, step.get("board"))
149
  chat[-1]["content"] = "✏️ " + " ".join(s for s in says if s)
150
  yield render("teaching", f"Teaching — step {seq + 1}", [step], seq)
151
  seq += 1
 
173
  final_text = " ".join(s for s in says if s).strip() or chat[-1]["content"]
174
  chat[-1]["content"] = "✏️ " + final_text
175
  convo.append({"role": "user", "content": question_for_context})
176
+ convo.append({"role": "assistant", "content": final_text[:1200]})
177
  yield render("done", "", (), seq)
178
 
179
 
180
  def reset_session():
181
+ return [], [], "", [], gr.update(value=None)
182
 
183
 
184
  def forget_me():
 
197
 
198
  profile_state = gr.BrowserState(EMPTY_PROFILE, storage_key="tutori_profile_v1")
199
  convo_state = gr.State([])
200
+ notes_state = gr.State("") # web research carried across the session
201
+ board_state = gr.State([]) # ops currently on the board (server mirror)
202
 
203
  payload_box = gr.Textbox(visible=False, elem_id="tutori-payload")
204
  snap_box = gr.Textbox(visible=False)
 
275
  # `snap_box`. (gr.State arrives as null in js; pass it through untouched.)
276
  def _snap_js(fn_name):
277
  return (
278
+ "(audio, text, snap, chat, convo, profile, notes, board, pace, web, voice) => "
279
  f"[audio, text, (window.{fn_name} ? window.{fn_name}() : ''), "
280
+ "chat, convo, profile, notes, board, pace, web, voice]"
281
  )
282
 
283
  turn_io = dict(
284
  fn=run_turn,
285
  inputs=[mic, text_in, snap_box, chatbot, convo_state, profile_state,
286
+ notes_state, board_state, pace, web_on, voice_on],
287
  outputs=[payload_box, chatbot, convo_state, profile_state, profile_view,
288
+ notes_state, board_state, mic, text_in],
289
  show_progress="hidden",
290
  )
291
 
 
303
  skip_btn.click(fn=None, inputs=None, outputs=None,
304
  js="() => window.tutoriStop()")
305
  new_btn.click(
306
+ reset_session, None,
307
+ [chatbot, convo_state, text_in, board_state, mic],
308
  js="() => { window.tutoriClearAll && window.tutoriClearAll(); return []; }",
309
  )
310
  forget_btn.click(forget_me, None, [profile_state, profile_view])
engine.py CHANGED
@@ -125,18 +125,34 @@ Respond with ONE JSON object and NOTHING else (no markdown fences, no prose outs
125
  }}
126
 
127
  Rules for "say" (spoken by a TTS voice):
128
- - Natural, friendly spoken language. 1-2 short sentences, at most 28 words per step.
129
  - Never use markdown, symbols, URLs or formulas as symbols: say "x squared", "pi", "H two O".
130
- - Sound like a human tutor: occasional "Alright", "Now here's the neat part", direct address.
131
 
132
  Rules for "steps":
133
- - 3 to 5 steps. Together they form one mini whiteboard lesson answering the learner.
134
- - Each step has 1 to 4 board ops that draw exactly what that step says.
135
- - Step 1 usually starts with {{"op":"clear"}} then a short title.
136
- - Build the diagram cumulatively across steps; later steps add arrows, labels, highlights.
137
- - Prefer pictures over text: boxes + arrows for relationships and processes, axes + curve
138
- for anything quantitative, dots + labels for points of interest. Use color meaningfully.
139
- - The last step often asks one short check-in question matched to the learner's pace.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  {board_reference}
142
 
@@ -147,7 +163,8 @@ confused them, preferred pace). Keep it under 90 words total.
147
 
148
  LEARNER PACE SETTING: {pace}
149
  CURRENT LEARNER PROFILE: {profile}
150
- TODAY: {today}{web_block}"""
 
151
 
152
  PACE_DESCRIPTIONS = {
153
  1: "Total beginner — tiny steps, everyday analogies, zero jargon, frequent reassurance.",
@@ -161,7 +178,8 @@ SEARCH_SYSTEM = """You are the research planner for a tutor agent. Today is {tod
161
  Decide whether a quick web search would materially improve a whiteboard lesson on the
162
  learner's request. Conceptual/timeless topics (math, physics, programming basics,
163
  history before this year) need NO search. Current events, prices, versions, schedules,
164
- records, or anything after your training data DOES need one.
 
165
  Answer with ONE JSON object only:
166
  {{"search": false}} or {{"search": true, "queries": ["...", "..."]}} (max 2 queries)"""
167
 
@@ -389,6 +407,13 @@ def synthesize(text):
389
  audio = audio.mean(axis=1)
390
  peak = float(np.max(np.abs(audio))) or 1.0
391
  audio = audio / max(peak, 1.0) * 0.92
 
 
 
 
 
 
 
392
  buf = io.BytesIO()
393
  sf.write(buf, audio, sr, format="WAV", subtype="PCM_16")
394
  b64 = base64.b64encode(buf.getvalue()).decode("ascii")
@@ -402,11 +427,14 @@ def synthesize(text):
402
  # web research (DuckDuckGo, no key needed)
403
  # --------------------------------------------------------------------------
404
 
405
- def decide_search(question, profile):
406
  today = datetime.date.today().isoformat()
 
407
  msgs = [
408
  {"role": "system", "content": SEARCH_SYSTEM.format(today=today)},
409
- {"role": "user", "content": f"Learner request: {question}\nLearner profile: {json.dumps(profile)[:400]}"},
 
 
410
  ]
411
  try:
412
  raw = llm_generate(msgs, max_new_tokens=96, temperature=0.0)
@@ -418,8 +446,24 @@ def decide_search(question, profile):
418
  return []
419
 
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  def web_research(queries):
422
- snippets = []
423
  try:
424
  from ddgs import DDGS
425
  with DDGS() as ddg:
@@ -428,35 +472,51 @@ def web_research(queries):
428
  for r in ddg.text(q, max_results=4):
429
  title = r.get("title", "")
430
  body = r.get("body", "")
 
 
 
431
  if body:
432
  snippets.append(f"- {title}: {body}")
433
  except Exception:
434
  continue
435
  except Exception as e:
436
  print(f"[tutori] web research failed: {e}")
437
- return "\n".join(snippets[:10])
 
 
 
 
 
438
 
439
 
440
  # --------------------------------------------------------------------------
441
  # the agent turn
442
  # --------------------------------------------------------------------------
443
 
444
- def _build_messages(question, history, profile, pace, web_context, board_image):
 
445
  today = datetime.date.today().isoformat()
446
  web_block = (
447
  f"\n\nWEB CONTEXT (gathered just now — fold relevant facts into the lesson):\n{web_context}"
448
  if web_context else ""
449
  )
 
 
 
 
450
  system = LESSON_SYSTEM.format(
451
  board_reference=BOARD_REFERENCE,
452
  pace=PACE_DESCRIPTIONS.get(int(pace), PACE_DESCRIPTIONS[3]),
453
  profile=json.dumps(profile or {}, ensure_ascii=False)[:700],
 
 
454
  today=today,
 
455
  web_block=web_block,
456
  )
457
  msgs = [{"role": "system", "content": system}]
458
- for m in (history or [])[-8:]:
459
- msgs.append({"role": m["role"], "content": str(m["content"])[:600]})
460
  if board_image is not None:
461
  import tempfile
462
  tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
@@ -489,7 +549,7 @@ def _decode_board_image(data_url):
489
  # whole turn must fit in under 60s or logged-out users are rejected outright.
490
  @spaces.GPU(duration=59)
491
  def run_turn(audio_path, typed_text, board_snapshot, history, profile,
492
- pace, web_on, voice_on):
493
  """One full agent turn. Yields event dicts (see module docstring)."""
494
  t0 = time.time()
495
  print("[tutori] GPU turn body entered", flush=True)
@@ -513,17 +573,22 @@ def run_turn(audio_path, typed_text, board_snapshot, history, profile,
513
  web_context = ""
514
  if web_on:
515
  yield {"type": "status", "status": "thinking", "detail": "Deciding what context I need…"}
516
- queries = decide_search(question, profile)
517
  if queries:
518
  yield {"type": "status", "status": "searching",
519
  "detail": "Researching: " + " · ".join(queries)}
520
  web_context = web_research(queries)
 
 
 
 
521
 
522
  # ---- 3. teach: stream lesson steps, voicing each one ----------
523
  yield {"type": "status", "status": "teaching", "detail": "Preparing your whiteboard lesson…"}
524
- msgs = _build_messages(question, history, profile, pace, web_context, board_image)
 
525
  print(f"[tutori] lesson generation starting ({time.time()-t0:.1f}s in)", flush=True)
526
- streamer, gen_thread = llm_stream(msgs, max_new_tokens=1000, temperature=0.6)
527
 
528
  parser = LessonStreamParser()
529
  n_steps = 0
 
125
  }}
126
 
127
  Rules for "say" (spoken by a TTS voice):
128
+ - Natural, friendly spoken language. 1-3 sentences, 15 to 35 words per step.
129
  - Never use markdown, symbols, URLs or formulas as symbols: say "x squared", "pi", "H two O".
130
+ - Sound like a human tutor mid-conversation: vary your openings, address the learner directly.
131
 
132
  Rules for "steps":
133
+ - 3 to 5 steps forming one mini whiteboard lesson that directly answers the learner.
134
+ - Each step has 1 to 4 board ops that draw exactly what that step says, as you say it.
135
+ - THE DRAWING MUST BE SPECIFIC TO THIS TOPIC. Draw the idea's REAL structure — actual
136
+ names, values, shapes and relationships — never a generic input/process/output chain
137
+ and never the same layout you drew last time. Examples of being specific:
138
+ * recursion -> the actual call tree with real arguments at each node
139
+ * binary search -> a row of small boxes with the real sorted numbers, dots and
140
+ underlines marking lo, mid, hi as they move
141
+ * supply and demand -> axes with the two labeled curves crossing at a dot
142
+ * photosynthesis -> a leaf (ellipse) with labeled arrows in (sun, water, CO2) and out (O2, sugar)
143
+ - Build ONE diagram cumulatively: later steps ADD arrows, labels, dots, highlights to
144
+ what is already drawn. {{"op":"clear"}} is allowed ONLY as the first op of step 1, and
145
+ ONLY when BOARD NOW shows a different topic. For a follow-up on the same topic: no
146
+ clear, no new title — extend and annotate the existing drawing.
147
+ - Never place two elements at overlapping coordinates; check BOARD NOW for occupied space.
148
+ - Use color with meaning (e.g. red = the thing to watch, green = result, gray = notes).
149
+ - The last step usually asks one short check-in question matched to the learner's pace.
150
+
151
+ Conversation behavior:
152
+ - The chat history is the lesson so far. On follow-ups: skip greetings and recaps,
153
+ answer the actual question, go deeper or give a new angle — never re-teach what the
154
+ learner already confirmed they understood.
155
+ - If the learner answered your check-in question, react to THEIR answer first.
156
 
157
  {board_reference}
158
 
 
163
 
164
  LEARNER PACE SETTING: {pace}
165
  CURRENT LEARNER PROFILE: {profile}
166
+ BOARD NOW (ops already drawn, oldest first): {board_now}
167
+ TODAY: {today}{notes_block}{web_block}"""
168
 
169
  PACE_DESCRIPTIONS = {
170
  1: "Total beginner — tiny steps, everyday analogies, zero jargon, frequent reassurance.",
 
178
  Decide whether a quick web search would materially improve a whiteboard lesson on the
179
  learner's request. Conceptual/timeless topics (math, physics, programming basics,
180
  history before this year) need NO search. Current events, prices, versions, schedules,
181
+ records, niche/new algorithms or papers, or anything after your training data DOES
182
+ need one. If the EXISTING NOTES below already cover the question, do NOT search again.
183
  Answer with ONE JSON object only:
184
  {{"search": false}} or {{"search": true, "queries": ["...", "..."]}} (max 2 queries)"""
185
 
 
407
  audio = audio.mean(axis=1)
408
  peak = float(np.max(np.abs(audio))) or 1.0
409
  audio = audio / max(peak, 1.0) * 0.92
410
+ # trim leading/trailing silence — Higgs pads generations with dead
411
+ # air, which reads as awkward pauses between lesson steps
412
+ voiced = np.where(np.abs(audio) > 0.012)[0]
413
+ if voiced.size:
414
+ start = max(0, int(voiced[0]) - int(0.04 * sr))
415
+ end = min(len(audio), int(voiced[-1]) + int(0.14 * sr))
416
+ audio = audio[start:end]
417
  buf = io.BytesIO()
418
  sf.write(buf, audio, sr, format="WAV", subtype="PCM_16")
419
  b64 = base64.b64encode(buf.getvalue()).decode("ascii")
 
427
  # web research (DuckDuckGo, no key needed)
428
  # --------------------------------------------------------------------------
429
 
430
+ def decide_search(question, profile, notes):
431
  today = datetime.date.today().isoformat()
432
+ notes_hint = f"\nEXISTING NOTES (from earlier this session): {notes[:500]}" if notes else ""
433
  msgs = [
434
  {"role": "system", "content": SEARCH_SYSTEM.format(today=today)},
435
+ {"role": "user",
436
+ "content": f"Learner request: {question}\n"
437
+ f"Learner profile: {json.dumps(profile)[:400]}{notes_hint}"},
438
  ]
439
  try:
440
  raw = llm_generate(msgs, max_new_tokens=96, temperature=0.0)
 
446
  return []
447
 
448
 
449
+ def _fetch_page(url, limit=1500):
450
+ """Pull readable text from a page — snippets alone are too shallow to
451
+ teach from (e.g. a newly published algorithm)."""
452
+ try:
453
+ import requests
454
+ r = requests.get(url, timeout=4,
455
+ headers={"User-Agent": "Mozilla/5.0 (TutoriBot/1.0)"})
456
+ txt = re.sub(r"<(script|style|nav|header|footer)[\s\S]*?</\1>", " ", r.text)
457
+ txt = re.sub(r"<[^>]+>", " ", txt)
458
+ txt = re.sub(r"&[a-z#0-9]+;", " ", txt)
459
+ txt = re.sub(r"\s+", " ", txt).strip()
460
+ return txt[:limit]
461
+ except Exception:
462
+ return ""
463
+
464
+
465
  def web_research(queries):
466
+ snippets, page_text, first_url = [], "", None
467
  try:
468
  from ddgs import DDGS
469
  with DDGS() as ddg:
 
472
  for r in ddg.text(q, max_results=4):
473
  title = r.get("title", "")
474
  body = r.get("body", "")
475
+ href = r.get("href", "")
476
+ if first_url is None and href:
477
+ first_url = href
478
  if body:
479
  snippets.append(f"- {title}: {body}")
480
  except Exception:
481
  continue
482
  except Exception as e:
483
  print(f"[tutori] web research failed: {e}")
484
+ if first_url:
485
+ page_text = _fetch_page(first_url)
486
+ out = "\n".join(snippets[:8])
487
+ if page_text:
488
+ out += f"\n\nFROM {first_url}:\n{page_text}"
489
+ return out[:3000]
490
 
491
 
492
  # --------------------------------------------------------------------------
493
  # the agent turn
494
  # --------------------------------------------------------------------------
495
 
496
+ def _build_messages(question, history, profile, pace, web_context, board_image,
497
+ notes="", board_now=None):
498
  today = datetime.date.today().isoformat()
499
  web_block = (
500
  f"\n\nWEB CONTEXT (gathered just now — fold relevant facts into the lesson):\n{web_context}"
501
  if web_context else ""
502
  )
503
+ notes_block = (
504
+ f"\nRESEARCH NOTES (gathered earlier this session, still relevant):\n{notes[:2200]}"
505
+ if notes else ""
506
+ )
507
  system = LESSON_SYSTEM.format(
508
  board_reference=BOARD_REFERENCE,
509
  pace=PACE_DESCRIPTIONS.get(int(pace), PACE_DESCRIPTIONS[3]),
510
  profile=json.dumps(profile or {}, ensure_ascii=False)[:700],
511
+ board_now=(json.dumps(board_now, ensure_ascii=False)[:900]
512
+ if board_now else "(empty board)"),
513
  today=today,
514
+ notes_block=notes_block,
515
  web_block=web_block,
516
  )
517
  msgs = [{"role": "system", "content": system}]
518
+ for m in (history or [])[-12:]:
519
+ msgs.append({"role": m["role"], "content": str(m["content"])[:800]})
520
  if board_image is not None:
521
  import tempfile
522
  tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
 
549
  # whole turn must fit in under 60s or logged-out users are rejected outright.
550
  @spaces.GPU(duration=59)
551
  def run_turn(audio_path, typed_text, board_snapshot, history, profile,
552
+ notes, board_now, pace, web_on, voice_on):
553
  """One full agent turn. Yields event dicts (see module docstring)."""
554
  t0 = time.time()
555
  print("[tutori] GPU turn body entered", flush=True)
 
573
  web_context = ""
574
  if web_on:
575
  yield {"type": "status", "status": "thinking", "detail": "Deciding what context I need…"}
576
+ queries = decide_search(question, profile, notes)
577
  if queries:
578
  yield {"type": "status", "status": "searching",
579
  "detail": "Researching: " + " · ".join(queries)}
580
  web_context = web_research(queries)
581
+ if web_context:
582
+ # carried into later turns so follow-ups can dig deeper
583
+ # without searching again
584
+ yield {"type": "research", "notes": web_context}
585
 
586
  # ---- 3. teach: stream lesson steps, voicing each one ----------
587
  yield {"type": "status", "status": "teaching", "detail": "Preparing your whiteboard lesson…"}
588
+ msgs = _build_messages(question, history, profile, pace, web_context,
589
+ board_image, notes=notes or "", board_now=board_now)
590
  print(f"[tutori] lesson generation starting ({time.time()-t0:.1f}s in)", flush=True)
591
+ streamer, gen_thread = llm_stream(msgs, max_new_tokens=1000, temperature=0.75)
592
 
593
  parser = LessonStreamParser()
594
  n_steps = 0
mock_engine.py CHANGED
@@ -56,7 +56,7 @@ def _demo_lesson(topic):
56
 
57
 
58
  def run_turn(audio_path, typed_text, board_snapshot, history, profile,
59
- pace, web_on, voice_on):
60
  question = (typed_text or "").strip()
61
  if audio_path:
62
  yield {"type": "status", "status": "thinking", "detail": "Listening… (mock)"}
@@ -66,10 +66,11 @@ def run_turn(audio_path, typed_text, board_snapshot, history, profile,
66
  if not question:
67
  question = "Tell me about what's on the board" if board_snapshot else "Teach me something fun"
68
 
69
- if web_on:
70
  yield {"type": "status", "status": "searching",
71
  "detail": f"Researching: {question[:60]} (mock)"}
72
  time.sleep(0.7)
 
73
 
74
  yield {"type": "status", "status": "teaching", "detail": "Preparing your whiteboard lesson… (mock)"}
75
  time.sleep(0.6)
 
56
 
57
 
58
  def run_turn(audio_path, typed_text, board_snapshot, history, profile,
59
+ notes, board_now, pace, web_on, voice_on):
60
  question = (typed_text or "").strip()
61
  if audio_path:
62
  yield {"type": "status", "status": "thinking", "detail": "Listening… (mock)"}
 
66
  if not question:
67
  question = "Tell me about what's on the board" if board_snapshot else "Teach me something fun"
68
 
69
+ if web_on and not notes:
70
  yield {"type": "status", "status": "searching",
71
  "detail": f"Researching: {question[:60]} (mock)"}
72
  time.sleep(0.7)
73
+ yield {"type": "research", "notes": f"- (mock) background notes about {question[:50]}"}
74
 
75
  yield {"type": "status", "status": "teaching", "detail": "Preparing your whiteboard lesson… (mock)"}
76
  time.sleep(0.6)
static/board.js CHANGED
@@ -358,11 +358,15 @@
358
 
359
  if (step.audio && S.voiceOn) {
360
  try {
361
- const buf = await actx().decodeAudioData(b64ToBuf(step.audio));
362
- dur = buf.duration;
363
- src = actx().createBufferSource();
364
- src.buffer = buf;
365
- src.connect(actx().destination);
 
 
 
 
366
  } catch (e) { src = null; }
367
  }
368
 
@@ -448,9 +452,16 @@
448
  if (p.status_detail !== undefined) setStatus(p.status_detail, p.status);
449
  for (const s of (p.steps || [])) { // incremental: each step carries its index
450
  const idx = (s.i !== undefined) ? s.i : S.enqueued;
451
- if (idx >= S.enqueued) { S.queue.push(s); S.enqueued = idx + 1; }
 
 
 
 
 
 
 
452
  }
453
- if (S.queue.length) setTimeout(pump, 80);
454
  if (p.status === "done" || p.status === "error") {
455
  setTimeout(() => setStatus("", "idle"), p.status === "error" ? 6000 : 1500);
456
  }
 
358
 
359
  if (step.audio && S.voiceOn) {
360
  try {
361
+ // audio was pre-decoded the moment the step arrived (see enqueue)
362
+ const buf = step._bufP ? await step._bufP
363
+ : await actx().decodeAudioData(b64ToBuf(step.audio));
364
+ if (buf) {
365
+ dur = buf.duration;
366
+ src = actx().createBufferSource();
367
+ src.buffer = buf;
368
+ src.connect(actx().destination);
369
+ }
370
  } catch (e) { src = null; }
371
  }
372
 
 
452
  if (p.status_detail !== undefined) setStatus(p.status_detail, p.status);
453
  for (const s of (p.steps || [])) { // incremental: each step carries its index
454
  const idx = (s.i !== undefined) ? s.i : S.enqueued;
455
+ if (idx >= S.enqueued) {
456
+ if (s.audio && S.voiceOn) { // decode now, while earlier steps play
457
+ try { s._bufP = actx().decodeAudioData(b64ToBuf(s.audio)).catch(() => null); }
458
+ catch (e) { s._bufP = null; }
459
+ }
460
+ S.queue.push(s);
461
+ S.enqueued = idx + 1;
462
+ }
463
  }
464
+ if (S.queue.length) setTimeout(pump, 30);
465
  if (p.status === "done" || p.status === "error") {
466
  setTimeout(() => setStatus("", "idle"), p.status === "error" ? 6000 : 1500);
467
  }