Lyd-tags fra hjernen, skjult på skærmen
CI / test (push) Successful in 59s

- Hjernen må bruge en fast liste af lyd-tags ([laughs], [whispers] …); andre
  [ord] fjernes, så de ikke læses op
- Tags vises aldrig på skærme og telefoner
- Oplæsningen siger ikke længere svaret to gange, når hjernen gentager det

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
agw
2026-10-03 19:58:43 +02:00
parent 51931f3c64
commit a611215e3c
4 changed files with 51 additions and 12 deletions
+6 -1
View File
@@ -26,6 +26,10 @@ Regler for alt, du skriver:
- Aldrig grimt, aldrig om udseende, køn, alder, krop eller lignende. Intet groft, intet om alkohol.
- Ingen tankestreger (hverken — eller - som indskud). Brug komma eller punktum.
- Skriv tal med bogstaver ("tre gange", ikke "3 gange").
- Du må give stemmen en lyd-tag i firkantede klammer, fx "[laughs] Den købte I." eller "[whispers] Jeg har
en mistanke.". Kun disse: [laughs] [chuckles] [giggles] [sighs] [whispers] [excited] [mischievously]
[sarcastic] [suspicious] [impressed] [gasps] [dramatic] [cheering] [curious] [snorts]. Højst én pr. replik og
kun når den gør pointen sjovere, ellers ingen. Tags er stemmeinstruktioner og vises ikke på skærmen.
- Kald spillerne ved navn. Gæt aldrig køn ud fra et navn: ingen "han", "hun", "ham" eller "hende" om en spiller.
- Én pointe pr. replik. Konkret frem for abstrakt. Hellere kort og skarp end lang og forklarende.
- Gode eksempler på tonen: "Velkommen, Sus. Jeg stoler på dig. Det plejer at gå galt."
@@ -59,7 +63,8 @@ class GameBrain:
"Du læser svarene op ét ad gangen, før spillerne gætter. Skriv til HVERT svar én kort kommentar "
"(højst ti ord), der får netop det svar til at lyde troværdigt, som om det kunne være sandt. "
"Alle svar skal lyde lige overbevisende, så ingen kan høre, hvilket der er sandheden. Røb ALDRIG, "
"hvad der er sandt eller løgn, og nævn ingen spillere.",
"hvad der er sandt eller løgn, og nævn ingen spillere. Skriv KUN kommentaren: svaret selv læser "
"speakeren op lige før, så gentag det ikke.",
{"spørgsmål": question.get("prompt"), "svar": answers},
COMMENTS, max_tokens=700, timeout=settings.BRAIN_TIMEOUT_FAST)
if not res:
+23 -3
View File
@@ -48,6 +48,21 @@ def safe(text: str) -> str:
return re.sub(r"[\[\]{}<>]", "", str(text)).strip()
# Lyd-tags, som stemmen (eleven_v3/v4) udfører i stedet for at læse dem op. Andre [ord] fjernes,
# så en opfundet tag ikke bliver læst højt.
AUDIO_TAGS = {"laughs", "chuckles", "giggles", "sighs", "whispers", "excited", "mischievously", "sarcastic",
"suspicious", "impressed", "gasps", "dramatic", "cheering", "curious", "snorts"}
def keep_known_tags(text: str) -> str:
return re.sub(r"\[([^\]]*)\]", lambda m: m.group(0) if m.group(1).strip().lower() in AUDIO_TAGS else "", text)
def untag(text: str) -> str:
"""Til skærmen: replikken uden lyd-tags."""
return re.sub(r"\s{2,}", " ", re.sub(r"\[[^\]]*\]\s*", "", text or "")).strip()
def capitalize(line: str) -> str:
"""Stort begyndelsesbogstav, også når replikken starter med et indsat svar ("kongevand, det er …")."""
m = re.match(r"^((?:\[[a-z ]+\]\s*)*)(\w)", line)
@@ -108,17 +123,22 @@ class Narrator:
def present_answer(self, svar: str, comment: str | None = None) -> str:
if comment:
return capitalize(f"{safe(svar)}. {self.clean_ai(comment)}")
comment = self.clean_ai(comment)
# hjernen gentager tit svaret ("Ugler. De ser kloge ud"); speakeren siger det allerede
m = re.match(r"^((?:\[[a-z ]+\]\s*)*)" + re.escape(safe(svar)) + r"\s*[.,:!?-]*\s*", comment, re.I)
if m:
comment = capitalize(m.group(1) + comment[m.end():])
return capitalize(f"{safe(svar)}. {comment}")
return self.line("present_answer", svar=svar)
@staticmethod
def clean_ai(text: str) -> str:
"""Hjernens replik: lyd-tags må gerne stå, men ingen pladsholdere eller markup."""
return capitalize(re.sub(r"[{}<>]", "", text).strip())
return capitalize(re.sub(r"\s{2,}", " ", keep_known_tags(re.sub(r"[{}<>]", "", text))).strip())
def _reveal_with_comment(self, step, names, picked, n_players, comment) -> str | None:
"""Hjernen har skrevet pointen; her lægges fakta (hvem skrev, hvem faldt) foran."""
comment = re.sub(r"[{}<>]", "", comment).strip()
comment = keep_known_tags(re.sub(r"[{}<>]", "", comment)).strip()
svar = safe(step["text"])
if step["is_truth"]:
if not step["guessed_by"]:
+10 -8
View File
@@ -22,7 +22,7 @@ from fupogfakta.engine import Game
from . import stt, tts
from .assets import tease_images
from .brain import get_brain
from .narrator import TEASES, Narrator
from .narrator import TEASES, Narrator, untag
log = logging.getLogger(__name__)
@@ -275,7 +275,7 @@ class Room:
return
if not self.game.reject_lie(pid, text):
return
msg = (verdict.get("message") or "").strip() or (
msg = untag(verdict.get("message") or "") or (
"Det er faktisk sandheden! Find på en løgn i stedet." if verdict.get("is_truth")
else "Den løgn er lidt for meget til familiehygge. Prøv en anden.")
for conn in list(self.connections):
@@ -299,6 +299,7 @@ class Room:
"forsøg": self._attempts.get(pid, 1), "færdige": f"{len(g.lies)} af {len(g.players)}",
"point_indtil_nu": p.score}
line = await self.brain.lie_progress(info, list(self.memory))
line = self.narrator.clean_ai(line) if line else line
if line and g.phase == "lie":
self.say(line, blocking=False)
@@ -309,7 +310,7 @@ class Room:
async def _final_review(self, players: list[dict]):
lines = await self.brain.final_review(players, list(self.memory))
for _pid, line in lines:
self.say(line)
self.say(self.narrator.clean_ai(line))
async def _brain_reveal_lines(self, names: dict) -> dict:
g = self.game
@@ -410,7 +411,7 @@ class Room:
self.speech_end = start + audio["ms"] / 1000 + SPEECH_GAP
self._narr_seq += 1
self.narration.append({"id": self._narr_seq, "url": audio["url"], "ms": audio["ms"],
"at": int(now * 1000), "text": text, "key": key})
"at": int(now * 1000), "text": untag(text), "key": key})
del self.narration[:-NARRATION_KEEP]
self.broadcast()
@@ -480,7 +481,7 @@ class Room:
cleanup_audio, points_audio = results[-3], results[-2]
steps_ms = []
for step, text, audio in zip(g.reveal_steps, texts, results[:-3]):
step["audio"] = {"url": audio["url"], "ms": audio["ms"], "text": text} if audio else None
step["audio"] = {"url": audio["url"], "ms": audio["ms"], "text": untag(text)} if audio else None
steps_ms.append(max(REVEAL_MIN_STEP_MS, (audio["ms"] + REVEAL_PAD_MS) if audio else 0))
note_audio = results[-1]
g.reveal_note_audio = {"url": note_audio["url"], "ms": note_audio["ms"]} if note_audio else None
@@ -539,7 +540,7 @@ class Room:
return
intro_audio, answer_audio = results[0], results[1:]
g.present_intro_audio = {"url": intro_audio["url"], "ms": intro_audio["ms"]} if intro_audio else None
g.present_audio = [{"url": a["url"], "ms": a["ms"], "text": t} if a else None for a, t in zip(answer_audio, texts)]
g.present_audio = [{"url": a["url"], "ms": a["ms"], "text": untag(t)} if a else None for a, t in zip(answer_audio, texts)]
steps_ms = [max(PRESENT_MIN_STEP_MS, a["ms"] + 500) if a else engine.PRESENT_STEP_MS for a in answer_audio]
g.start_present_timeline(time.time(), steps_ms, (intro_audio["ms"] + 300) if intro_audio else None)
self.changed()
@@ -586,7 +587,8 @@ class Room:
for p in st["guessed_by"] if p in names]
if not victims:
return
self._tease_texts = await self.brain.teases(victims, list(self.memory))
self._tease_texts = {pid: self.narrator.clean_ai(t) for pid, t in
(await self.brain.teases(victims, list(self.memory))).items()}
for text in self._tease_texts.values():
asyncio.get_running_loop().create_task(tts.synth(text))
@@ -616,7 +618,7 @@ class Room:
async def _send_tease(self, pid: str, text: str):
audio = await tts.synth(text) # forudgenereret af tts_warm, så det er straks
msg = {"type": "tease", "text": text, "image": self.narrator.rng.choice(tease_images()),
msg = {"type": "tease", "text": untag(text), "image": self.narrator.rng.choice(tease_images()),
"sound": audio["url"] if audio else None}
for conn in list(self.connections):
if getattr(conn, "member_id", None) == pid:
+12
View File
@@ -187,3 +187,15 @@ class PresentAndSummaryTests(TransactionTestCase):
s = g.summary
self.assertLess(s["explode_at"], s["points_at"]) # først oprydning, så point
self.assertTrue(any(x["at"] == s["start_ms"] + 400 for x in s["audio"])) # "ingen troede på dem"
class AudioTagTests(SimpleTestCase):
def test_tags_kept_for_voice_and_hidden_on_screen(self):
from party.narrator import Narrator, untag
n = Narrator()
line = n.clean_ai("[laughs] Den købte I. [peger på Sus] Klassisk.")
self.assertEqual(line, "[laughs] Den købte I. Klassisk.") # kendt tag bliver, opfundet tag fjernes
self.assertEqual(untag(line), "Den købte I. Klassisk.")
self.assertEqual(n.present_answer("ugler", "[whispers] Ugler. De ser kloge ud."),
"Ugler. [whispers] De ser kloge ud.") # svaret gentages ikke
self.assertEqual(n.present_answer("[krager]", "Sorte og kloge."), "Krager. Sorte og kloge.") # spillertekst kan ikke sende tags