ratandeep commited on
Commit
7ee0711
·
verified ·
1 Parent(s): 5b815d3

Prompt cleanup: stop title:/artist: leaks, harden sanitizer, fix caller/broadcast paths

Browse files
Files changed (6) hide show
  1. .pytest_cache/v/cache/nodeids +3 -0
  2. arc.py +19 -6
  3. content.py +9 -0
  4. proxy.py +51 -26
  5. server.py +15 -19
  6. tests/test_proxy.py +43 -0
.pytest_cache/v/cache/nodeids CHANGED
@@ -25,10 +25,13 @@
25
  "tests/test_content.py::test_song_recs_well_formed",
26
  "tests/test_content.py::test_songs_have_required_fields_and_valid_enums",
27
  "tests/test_proxy.py::test_broadcast_turn_mock_shape_and_sanitized_text",
 
28
  "tests/test_proxy.py::test_call_speak_normalizes_nightwave_before_tts",
29
  "tests/test_proxy.py::test_call_turn_answers_without_echoing_the_question",
 
30
  "tests/test_proxy.py::test_call_turn_mock_returns_caller_text_and_meter_delta",
31
  "tests/test_proxy.py::test_clean_dj_text_rejects_degenerate_text_as_none",
 
32
  "tests/test_proxy.py::test_clean_dj_text_strips_label_leakage",
33
  "tests/test_proxy.py::test_clean_dj_text_unwraps_quotes_and_drops_markdown",
34
  "tests/test_proxy.py::test_fmt_clock",
 
25
  "tests/test_content.py::test_song_recs_well_formed",
26
  "tests/test_content.py::test_songs_have_required_fields_and_valid_enums",
27
  "tests/test_proxy.py::test_broadcast_turn_mock_shape_and_sanitized_text",
28
+ "tests/test_proxy.py::test_broadcast_turn_uses_host_path_and_sanitizes",
29
  "tests/test_proxy.py::test_call_speak_normalizes_nightwave_before_tts",
30
  "tests/test_proxy.py::test_call_turn_answers_without_echoing_the_question",
31
+ "tests/test_proxy.py::test_call_turn_degenerate_uses_caller_fallback_not_crash",
32
  "tests/test_proxy.py::test_call_turn_mock_returns_caller_text_and_meter_delta",
33
  "tests/test_proxy.py::test_clean_dj_text_rejects_degenerate_text_as_none",
34
+ "tests/test_proxy.py::test_clean_dj_text_rejects_label_leakage",
35
  "tests/test_proxy.py::test_clean_dj_text_strips_label_leakage",
36
  "tests/test_proxy.py::test_clean_dj_text_unwraps_quotes_and_drops_markdown",
37
  "tests/test_proxy.py::test_fmt_clock",
arc.py CHANGED
@@ -191,13 +191,17 @@ def _output_contract_block() -> str:
191
  "OUTPUT FORMAT -- READ CAREFULLY:\n"
192
  "Respond with ONE JSON object and NOTHING else. No preface, no "
193
  "explanation, no code fences. The object MUST have exactly these keys:\n"
194
- ' "text": what NIGHTWAVE says aloud, as spoken radio patter.\n'
195
  ' "mood": one of [' + moods + "].\n"
196
  ' "arc_cue": one of [' + cues + "].\n"
197
  "Rules for \"text\": 1 to 3 short, spoken sentences. It is read aloud on "
198
  "the air, so it must SOUND like speech. Absolutely NO markdown, NO "
199
  "bullet points or lists, NO headings, NO emoji, NO stage directions, NO "
200
- "asterisks. Plain spoken English only.\n"
 
 
 
 
201
  'Example shape: {"text": "...", "mood": "warm", "arc_cue": "none"}'
202
  )
203
 
@@ -293,26 +297,35 @@ _HOST_KIND = {
293
  "Offer one short, warm or quietly thoughtful late-night reflection -- one or two "
294
  "spoken sentences, like you're talking to a single listener who can't sleep."
295
  ),
 
 
 
 
 
296
  "song_intro": (
297
- "Introduce the next record warmly and naturally -- name the song and the artist, "
298
- "with a touch of flavor. One or two spoken sentences."
299
  ),
300
  "caller": (
301
  "A caller is live on the air. Answer them directly, warmly, with wit and heart, in "
302
  "your own words -- do NOT repeat their question. One or two spoken sentences, then "
303
- "a small beat back to the show."
304
  ),
305
  "local_weather": (
306
  "Give the listener tonight's sky for their own town. Real facts will be "
307
  "provided in the next turn; weave them into your unhurried late-night voice -- "
308
  "do NOT read them like a forecast or list them off. One or two warm spoken "
309
- "sentences."
310
  ),
311
  }
312
 
313
 
314
  def build_host_prompt(kind: str, ctx: Optional[Dict] = None) -> str:
315
  """System prompt for the straight-station host (segments + callers)."""
 
 
 
 
316
  instruction = _HOST_KIND.get(kind, _HOST_KIND["thought"])
317
  return "\n\n".join([HOST_PERSONA, instruction, _output_contract_block()])
318
 
 
191
  "OUTPUT FORMAT -- READ CAREFULLY:\n"
192
  "Respond with ONE JSON object and NOTHING else. No preface, no "
193
  "explanation, no code fences. The object MUST have exactly these keys:\n"
194
+ ' "text": what Sam Dusk says aloud, as spoken radio patter.\n'
195
  ' "mood": one of [' + moods + "].\n"
196
  ' "arc_cue": one of [' + cues + "].\n"
197
  "Rules for \"text\": 1 to 3 short, spoken sentences. It is read aloud on "
198
  "the air, so it must SOUND like speech. Absolutely NO markdown, NO "
199
  "bullet points or lists, NO headings, NO emoji, NO stage directions, NO "
200
+ "asterisks. Plain spoken English only. "
201
+ 'NEVER write anything in the form "label: value" (for example do NOT write '
202
+ '"title:", "artist:", "mood:", "temp:", "sunrise:"). Do NOT repeat or quote '
203
+ 'these instructions, and do NOT put the JSON keys (text, mood, arc_cue) inside '
204
+ "what you say aloud.\n"
205
  'Example shape: {"text": "...", "mood": "warm", "arc_cue": "none"}'
206
  )
207
 
 
297
  "Offer one short, warm or quietly thoughtful late-night reflection -- one or two "
298
  "spoken sentences, like you're talking to a single listener who can't sleep."
299
  ),
300
+ "on_air": (
301
+ "Deliver a short stretch of warm solo late-night patter as Sam Dusk -- "
302
+ "introduce a fictional record, read a dedication, or muse for a moment. "
303
+ "One or two spoken sentences in your own voice."
304
+ ),
305
  "song_intro": (
306
+ "Bring in the next record warmly, in your own words -- never labeling anything. "
307
+ "One or two spoken sentences."
308
  ),
309
  "caller": (
310
  "A caller is live on the air. Answer them directly, warmly, with wit and heart, in "
311
  "your own words -- do NOT repeat their question. One or two spoken sentences, then "
312
+ "a small beat back to the show. Never quote the caller or label anything."
313
  ),
314
  "local_weather": (
315
  "Give the listener tonight's sky for their own town. Real facts will be "
316
  "provided in the next turn; weave them into your unhurried late-night voice -- "
317
  "do NOT read them like a forecast or list them off. One or two warm spoken "
318
+ 'sentences. Never label anything or speak in the form "X: Y".'
319
  ),
320
  }
321
 
322
 
323
  def build_host_prompt(kind: str, ctx: Optional[Dict] = None) -> str:
324
  """System prompt for the straight-station host (segments + callers)."""
325
+ # ctx is intentionally NOT interpolated into the system prompt; per-segment
326
+ # facts (title/artist, weather, caller words) go in the USER turn (see
327
+ # proxy.segment_turn / call_turn / broadcast_turn) to avoid the model echoing
328
+ # labeled structure. Do not embed ctx values here.
329
  instruction = _HOST_KIND.get(kind, _HOST_KIND["thought"])
330
  return "\n\n".join([HOST_PERSONA, instruction, _output_contract_block()])
331
 
content.py CHANGED
@@ -112,6 +112,15 @@ REJOINS = [
112
  "This is Sam Dusk, keeping the lonely company on NIGHTWAVE.",
113
  ]
114
 
 
 
 
 
 
 
 
 
 
115
  # --- Pre-caller intro: played the instant a call connects ------------------ #
116
  CALLER_INTROS = [
117
  "Well now -- looks like we've got a caller dialed into the station. Go ahead, "
 
112
  "This is Sam Dusk, keeping the lonely company on NIGHTWAVE.",
113
  ]
114
 
115
+ # Warm fallback lines for a caller turn when the model output is degenerate
116
+ # (replaces the undefined arc.sample_line the caller path used to fall back to).
117
+ CALLER_FALLBACKS = [
118
+ "That's a good one to sit with at this hour, friend. Stay right there with me.",
119
+ "I hear you, caller. Some questions are just good company on a long night. Thanks for calling in.",
120
+ "Glad you dialed in tonight. Let's let the next record answer for a while.",
121
+ "You take care out there, friend -- and thanks for keeping me company on the air.",
122
+ ]
123
+
124
  # --- Pre-caller intro: played the instant a call connects ------------------ #
125
  CALLER_INTROS = [
126
  "Well now -- looks like we've got a caller dialed into the station. Go ahead, "
proxy.py CHANGED
@@ -194,22 +194,22 @@ def resolve_locale(lat: float, lon: float) -> Dict[str, Any]:
194
  def _local_weather_user(ctx: Optional[Dict[str, Any]] = None) -> str:
195
  """Build the USER-turn fact string for a local_weather segment."""
196
  ctx = ctx or {}
197
- bits: List[str] = []
198
  if ctx.get("city"):
199
- bits.append("the town of %s" % ctx["city"])
200
  if ctx.get("temp_f") is not None:
201
- bits.append("%d degrees" % int(ctx["temp_f"]))
202
  if ctx.get("sky"):
203
- bits.append(str(ctx["sky"]))
204
  if ctx.get("local_time"):
205
- bits.append("local time %s" % ctx["local_time"])
206
  if ctx.get("sunrise"):
207
- bits.append("sunrise around %s" % ctx["sunrise"])
208
- facts = "; ".join(bits) if bits else "a still, clear night out their way"
209
  return (
210
- "Give tonight's sky for the listener's own town. Real facts: " + facts
211
- + ". Weave these into your late-night voice; one or two sentences; "
212
- "do not list them."
213
  )
214
 
215
 
@@ -271,22 +271,35 @@ def call_speak(text: str, voice: str = arc.VOICE) -> Dict[str, Any]:
271
  # is degenerate, fall back to a clean stage-appropriate canned line.
272
  # ---------------------------------------------------------------------------
273
  _LABEL_RE = re.compile(
274
- r"[\(\[]?\s*\b(?:mood|arc[_ ]?cue)\b\s*[:=]\s*[a-z_]+\s*[\)\]]?", re.IGNORECASE
 
 
 
 
 
 
275
  )
276
- _PLACEHOLDER_RE = re.compile(r"<[^>]{0,40}>|\bsentence\s*\d+\b", re.IGNORECASE)
277
 
278
 
279
  def _clean_dj_text(text: Optional[str]) -> Optional[str]:
280
  """Strip label leakage / markdown; return cleaned spoken text or None if junk."""
281
  t = text or ""
282
- t = _LABEL_RE.sub("", t) # drop "mood: warm" / "arc_cue: none"
283
  t = re.sub(r"[*#`]+", "", t) # drop markdown emphasis/headings
284
  t = t.strip()
285
  if len(t) >= 2 and t[0] in "\"'" and t[-1] in "\"'":
286
  t = t[1:-1].strip() # unwrap a fully-quoted line
287
  t = re.sub(r"\s+", " ", t).strip() # it's spoken: collapse whitespace
288
- # Degenerate? empty / placeholder / angle-bracket template / too few letters.
289
- if not t or _PLACEHOLDER_RE.search(t) or ("<" in t and ">" in t):
 
 
 
 
 
 
 
 
290
  return None
291
  if len(re.sub(r"[^a-zA-Z]", "", t)) < 6:
292
  return None
@@ -339,14 +352,21 @@ def broadcast_turn(
339
  -> /api/broadcast response shape:
340
  {text, mood, arc_cue, audio_b64, words, wtimes, wdurations}
341
  """
342
- system = arc.build_system_prompt(stage, "broadcast", topic=topic)
343
- brain = call_brain(system, [{"role": "user", "content": "Go on air."}])
344
- text = brain.get("text", "")
 
 
 
 
 
 
 
345
  speak = call_speak(text)
346
  return {
347
  "text": text,
348
- "mood": brain.get("mood", arc.stage_default_mood(stage)),
349
- "arc_cue": brain.get("arc_cue", "none"),
350
  "audio_b64": speak.get("audio_b64", ""),
351
  "words": speak.get("words", []),
352
  "wtimes": speak.get("wtimes", []),
@@ -372,13 +392,16 @@ def call_turn(stage: str, meter: int, audio_b64: str) -> Dict[str, Any]:
372
  system = arc.build_host_prompt("caller", {"caller_text": caller_text})
373
  if caller_text and caller_text.strip():
374
  user_msg = (
375
- 'A caller just asked you on the air: "' + caller_text.strip()
376
- + '". Answer them directly, in your own words. Do NOT repeat the question.'
 
 
 
377
  )
378
  else:
379
  user_msg = "(the caller's line is too crackly to make out)"
380
  brain = call_brain(system, [{"role": "user", "content": user_msg}])
381
- text = _clean_dj_text(brain.get("text", "")) or arc.sample_line(stage, meter)
382
  speak = call_speak(text)
383
  return {
384
  "caller_text": caller_text,
@@ -435,8 +458,10 @@ def segment_turn(kind: str, ctx: Optional[Dict[str, Any]] = None) -> Dict[str, A
435
  system = arc.build_host_prompt(kind, ctx)
436
  if kind == "song_intro":
437
  user = (
438
- 'Introduce this record on air now: "%s" by %s (a %s tune). Say the title '
439
- "and artist naturally; one or two sentences; do not repeat this instruction."
 
 
440
  % (ctx.get("title", "this next one"),
441
  ctx.get("artist", "a friend of the show"),
442
  ctx.get("vibe", "late-night"))
@@ -444,7 +469,7 @@ def segment_turn(kind: str, ctx: Optional[Dict[str, Any]] = None) -> Dict[str, A
444
  rb = ctx.get("recommended_by")
445
  if rb:
446
  user += (
447
- " This one was sent in by %s on X -- give them a warm little shout "
448
  "as you bring it in." % rb
449
  )
450
  elif kind == "local_weather":
 
194
  def _local_weather_user(ctx: Optional[Dict[str, Any]] = None) -> str:
195
  """Build the USER-turn fact string for a local_weather segment."""
196
  ctx = ctx or {}
197
+ parts: List[str] = []
198
  if ctx.get("city"):
199
+ parts.append("out %s way" % ctx["city"])
200
  if ctx.get("temp_f") is not None:
201
+ parts.append("it is about %d degrees" % int(ctx["temp_f"]))
202
  if ctx.get("sky"):
203
+ parts.append("the sky is %s" % ctx["sky"])
204
  if ctx.get("local_time"):
205
+ parts.append("the clock just read %s" % ctx["local_time"])
206
  if ctx.get("sunrise"):
207
+ parts.append("the sun comes up around %s" % ctx["sunrise"])
208
+ facts = ", and ".join(parts) if parts else "it is a still, clear night out their way"
209
  return (
210
+ "Tell the listener about their own sky tonight, gently. Here is what is true "
211
+ "right now: " + facts + ". Weave it into one or two warm spoken sentences. Do "
212
+ 'not read it as a forecast, do not list it, and never write anything as "label: value".'
213
  )
214
 
215
 
 
271
  # is degenerate, fall back to a clean stage-appropriate canned line.
272
  # ---------------------------------------------------------------------------
273
  _LABEL_RE = re.compile(
274
+ r"[\(\[]?\s*\b(?:mood|arc[_ ]?cue|title|artist|song|track|vibe|genre|by|city|town|"
275
+ r"temp(?:erature)?|sky|weather|conditions?|forecast|time|local[_ ]?time|sunrise|sunset|"
276
+ r"handle|requested[_ ]?by|recommended[_ ]?by|dedication|name)\b\s*[:=]\s*[^\n,;]*",
277
+ re.IGNORECASE,
278
+ )
279
+ _PLACEHOLDER_RE = re.compile(
280
+ r"<[^>]{0,40}>|\[[^\]]{0,40}\]|\{[^}]{0,40}\}|\bsentence\s*\d+\b", re.IGNORECASE
281
  )
 
282
 
283
 
284
  def _clean_dj_text(text: Optional[str]) -> Optional[str]:
285
  """Strip label leakage / markdown; return cleaned spoken text or None if junk."""
286
  t = text or ""
287
+ t = _LABEL_RE.sub("", t) # drop "mood: warm" / "title: X" / "artist: Y" runs
288
  t = re.sub(r"[*#`]+", "", t) # drop markdown emphasis/headings
289
  t = t.strip()
290
  if len(t) >= 2 and t[0] in "\"'" and t[-1] in "\"'":
291
  t = t[1:-1].strip() # unwrap a fully-quoted line
292
  t = re.sub(r"\s+", " ", t).strip() # it's spoken: collapse whitespace
293
+ # Degenerate? empty / placeholder / bracketed / structured key:value / echo.
294
+ if not t or _PLACEHOLDER_RE.search(t):
295
+ return None
296
+ if any(c in t for c in "<>[]{}|"):
297
+ return None
298
+ if re.search(r"\b(?:title|artist|mood|arc_cue|temp|sky|sunrise|vibe)\b\s*[:=]", t, re.IGNORECASE):
299
+ return None
300
+ if t.count(":") >= 2:
301
+ return None
302
+ if re.search(r"\b(one or two sentences|do not repeat|in your own words|spoken sentences|stage direction)\b", t, re.IGNORECASE):
303
  return None
304
  if len(re.sub(r"[^a-zA-Z]", "", t)) < 6:
305
  return None
 
352
  -> /api/broadcast response shape:
353
  {text, mood, arc_cue, audio_b64, words, wtimes, wdurations}
354
  """
355
+ system = arc.build_host_prompt("on_air", {"topic": topic})
356
+ user = (topic.strip() if (topic and topic.strip())
357
+ else "Go on the air now with a short stretch of warm late-night patter.")
358
+ try:
359
+ brain = call_brain(system, [{"role": "user", "content": user}])
360
+ text = _clean_dj_text(brain.get("text", "")) or _templated_text("thought")
361
+ mood = brain.get("mood", "warm")
362
+ arc_cue = brain.get("arc_cue", "none")
363
+ except Exception:
364
+ text, mood, arc_cue = _templated_text("thought"), "warm", "none"
365
  speak = call_speak(text)
366
  return {
367
  "text": text,
368
+ "mood": mood,
369
+ "arc_cue": arc_cue,
370
  "audio_b64": speak.get("audio_b64", ""),
371
  "words": speak.get("words", []),
372
  "wtimes": speak.get("wtimes", []),
 
392
  system = arc.build_host_prompt("caller", {"caller_text": caller_text})
393
  if caller_text and caller_text.strip():
394
  user_msg = (
395
+ "The caller is asking about this: " + caller_text.strip()
396
+ + "\nRespond by speaking ONLY your warm reply in your own words -- a fresh "
397
+ "sentence that answers them. Begin your reply with a word other than the "
398
+ 'caller\'s. Do not quote or restate their words, do not write "X: Y", and do '
399
+ "not repeat this instruction."
400
  )
401
  else:
402
  user_msg = "(the caller's line is too crackly to make out)"
403
  brain = call_brain(system, [{"role": "user", "content": user_msg}])
404
+ text = _clean_dj_text(brain.get("text", "")) or _pick(content.CALLER_FALLBACKS)
405
  speak = call_speak(text)
406
  return {
407
  "caller_text": caller_text,
 
458
  system = arc.build_host_prompt(kind, ctx)
459
  if kind == "song_intro":
460
  user = (
461
+ "Bring in the next song on the air now. It is called %s and it is by %s "
462
+ "-- it has a %s feel. Welcome it in with one or two warm spoken sentences "
463
+ 'in your own voice. Do not use the words "title" or "artist", do not label '
464
+ 'anything, do not write "X: Y", and do not repeat this instruction.'
465
  % (ctx.get("title", "this next one"),
466
  ctx.get("artist", "a friend of the show"),
467
  ctx.get("vibe", "late-night"))
 
469
  rb = ctx.get("recommended_by")
470
  if rb:
471
  user += (
472
+ " A listener, %s, sent this one in -- give them a warm little shout "
473
  "as you bring it in." % rb
474
  )
475
  elif kind == "local_weather":
server.py CHANGED
@@ -62,26 +62,22 @@ class LocaleReq(BaseModel):
62
  # ---------------------------------------------------------------------------
63
  # Graceful fallbacks (never let the radio go silent)
64
  # ---------------------------------------------------------------------------
65
- _FALLBACK_LINES = {
66
- "oblivious": "The signal's a touch thin tonight, but I'm still here with "
67
- "you. Let's let this next record play.",
68
- "uneasy": "Something's crackling on the line -- pay it no mind. Stay with "
69
- "me a moment longer.",
70
- "questioning": "The transmission keeps slipping. Funny, isn't it, how the "
71
- "quiet always finds its way back in.",
72
- "dawning": "The static's rising and I can't quite hold the thread. But "
73
- "you're still listening, and that's enough.",
74
- "acceptance": "Even through the static, I can feel you out there. Stay on "
75
- "the line with me, would you?",
76
- }
77
-
78
-
79
- def _fallback_payload(stage: str, include_caller: bool = False) -> Dict[str, Any]:
80
- line = _FALLBACK_LINES.get(stage, _FALLBACK_LINES["oblivious"])
81
  payload: Dict[str, Any] = {
82
- "text": line,
83
- "mood": arc.stage_default_mood(stage),
84
- "arc_cue": "static_swell",
85
  "audio_b64": proxy._silent_wav_b64(),
86
  "words": [],
87
  "wtimes": [],
 
62
  # ---------------------------------------------------------------------------
63
  # Graceful fallbacks (never let the radio go silent)
64
  # ---------------------------------------------------------------------------
65
+ _FALLBACK_LINES = [
66
+ "It's Sam Dusk -- we had a little hiccup on the signal there, but I'm still with "
67
+ "you. Let's let this next one play.",
68
+ "Sam Dusk here. The line crackled for a second, friend; pay it no mind and stay "
69
+ "with me a while.",
70
+ "You're back with Sam Dusk on NIGHTWAVE. A small bump in the broadcast -- nothing "
71
+ "the night can't smooth over.",
72
+ ]
73
+
74
+
75
+ def _fallback_payload(stage: str = "", include_caller: bool = False) -> Dict[str, Any]:
76
+ import random
 
 
 
 
77
  payload: Dict[str, Any] = {
78
+ "text": random.choice(_FALLBACK_LINES),
79
+ "mood": "warm",
80
+ "arc_cue": "none",
81
  "audio_b64": proxy._silent_wav_b64(),
82
  "words": [],
83
  "wtimes": [],
tests/test_proxy.py CHANGED
@@ -165,3 +165,46 @@ def test_call_turn_answers_without_echoing_the_question(monkeypatch):
165
  assert user.strip() != "are you real?"
166
  assert out["caller_text"] == "are you real?"
167
  assert out["meter_delta"] >= 14 # an identity/reality question pushes the meter
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  assert user.strip() != "are you real?"
166
  assert out["caller_text"] == "are you real?"
167
  assert out["meter_delta"] >= 14 # an identity/reality question pushes the meter
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Prompt-audit fixes: structured field labels (title:/artist:/temp:/...) must
172
+ # never reach TTS; degenerate/echo output is rejected -> templated fallback.
173
+ # ---------------------------------------------------------------------------
174
+ def test_clean_dj_text_rejects_label_leakage():
175
+ assert proxy._clean_dj_text("title: Neon Rain, artist: The Tuesday Ghosts") is None
176
+ assert proxy._clean_dj_text('{"text": "hi", "mood": "warm"}') is None
177
+ assert proxy._clean_dj_text("[song]") is None
178
+ assert proxy._clean_dj_text("temp: 58, sky: clear skies") is None
179
+ assert proxy._clean_dj_text("Coming up next, a quiet one for the night owls.") == \
180
+ "Coming up next, a quiet one for the night owls."
181
+
182
+
183
+ def test_call_turn_degenerate_uses_caller_fallback_not_crash(monkeypatch):
184
+ # The model leaks labels -> _clean_dj_text returns None -> must use a clean
185
+ # caller fallback line (the old arc.sample_line was undefined and crashed here).
186
+ monkeypatch.setattr(proxy, "call_asr", lambda a: {"text": "you there?"})
187
+ monkeypatch.setattr(proxy, "call_brain",
188
+ lambda s, m: {"text": "title: X, artist: Y", "mood": "warm", "arc_cue": "none"})
189
+ monkeypatch.setattr(proxy, "call_speak",
190
+ lambda text, voice=arc.VOICE: {"audio_b64": "", "words": [],
191
+ "wtimes": [], "wdurations": []})
192
+ out = proxy.call_turn("oblivious", 0, "<b64>")
193
+ assert out["text"] in content.CALLER_FALLBACKS
194
+
195
+
196
+ def test_broadcast_turn_uses_host_path_and_sanitizes(monkeypatch):
197
+ captured = {}
198
+
199
+ def _brain(system, messages):
200
+ captured["system"] = system
201
+ return {"text": "artist: X", "mood": "warm", "arc_cue": "none"} # degenerate
202
+
203
+ monkeypatch.setattr(proxy, "call_brain", _brain)
204
+ monkeypatch.setattr(proxy, "call_speak",
205
+ lambda text, voice=arc.VOICE: {"audio_b64": "", "words": [],
206
+ "wtimes": [], "wdurations": []})
207
+ out = proxy.broadcast_turn("oblivious", 0, None)
208
+ assert arc.DJ_NAME in captured["system"] # straight-station host, not arc builder
209
+ assert out["text"] in content.THOUGHTS # degenerate -> templated thought
210
+ assert out["arc_cue"] == "none"