Spaces:
Running
Running
Prompt cleanup: stop title:/artist: leaks, harden sanitizer, fix caller/broadcast paths
Browse files- .pytest_cache/v/cache/nodeids +3 -0
- arc.py +19 -6
- content.py +9 -0
- proxy.py +51 -26
- server.py +15 -19
- 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
|
| 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 |
'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 |
-
"
|
| 298 |
-
"
|
| 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 |
-
|
| 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 |
-
|
| 198 |
if ctx.get("city"):
|
| 199 |
-
|
| 200 |
if ctx.get("temp_f") is not None:
|
| 201 |
-
|
| 202 |
if ctx.get("sky"):
|
| 203 |
-
|
| 204 |
if ctx.get("local_time"):
|
| 205 |
-
|
| 206 |
if ctx.get("sunrise"):
|
| 207 |
-
|
| 208 |
-
facts = "
|
| 209 |
return (
|
| 210 |
-
"
|
| 211 |
-
+ ". Weave
|
| 212 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" / "
|
| 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?
|
| 289 |
-
if not t or _PLACEHOLDER_RE.search(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.
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
speak = call_speak(text)
|
| 346 |
return {
|
| 347 |
"text": text,
|
| 348 |
-
"mood":
|
| 349 |
-
"arc_cue":
|
| 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 |
-
|
| 376 |
-
+
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 439 |
-
"
|
|
|
|
|
|
|
| 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 |
-
"
|
| 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 |
-
"
|
| 67 |
-
"you. Let's let this next
|
| 68 |
-
"
|
| 69 |
-
"me a
|
| 70 |
-
"
|
| 71 |
-
"
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 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":
|
| 83 |
-
"mood":
|
| 84 |
-
"arc_cue": "
|
| 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"
|