From a611215e3cd22ea9ccedc6a133c79ca46a965a2b Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sat, 3 Oct 2026 19:58:43 +0200 Subject: [PATCH] =?UTF-8?q?Lyd-tags=20fra=20hjernen,=20skjult=20p=C3=A5=20?= =?UTF-8?q?sk=C3=A6rmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- party/brain/game.py | 7 ++++++- party/narrator.py | 26 +++++++++++++++++++++++--- party/rooms.py | 18 ++++++++++-------- party/tests/test_brain.py | 12 ++++++++++++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/party/brain/game.py b/party/brain/game.py index d5efb60..205660c 100644 --- a/party/brain/game.py +++ b/party/brain/game.py @@ -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: diff --git a/party/narrator.py b/party/narrator.py index 034b932..129d26a 100644 --- a/party/narrator.py +++ b/party/narrator.py @@ -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"]: diff --git a/party/rooms.py b/party/rooms.py index 3ce419b..62482db 100644 --- a/party/rooms.py +++ b/party/rooms.py @@ -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: diff --git a/party/tests/test_brain.py b/party/tests/test_brain.py index 3a20b27..6fb6fa3 100644 --- a/party/tests/test_brain.py +++ b/party/tests/test_brain.py @@ -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