Hjernen (party/brain) skriver kommentarer til løgnene, dømmer løgne (sandheden eller upassende), personlige drillerier og en slut-anmeldelse, alt i baggrunden med faste replikker som fallback. Udbyderen vælges med BRAIN_PROVIDER: anthropic (Claude), openai (OpenAI-kompatibel, fx Codex) eller ollama (fx Gemma). Rummets mikrofon er eksperimentel: storskærmen sender korte lydstumper til talegenkendelse (STT_PROVIDER), og kun et kort resumé af latter og udsagn huskes til hjernen. Musikken er nu lavet med en musikmodel. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
+15
-1
@@ -127,6 +127,7 @@ class Game:
|
||||
# (tid udløbet, alle har svaret, kategori valgt). Rummet sætter den ud fra talens længde.
|
||||
self.hold_until: float | None = None
|
||||
self.chosen_category = None
|
||||
self.lie_deadline: float | None = None
|
||||
# "Lyv for mig": forslag givet pr. spiller, og hvem der afleverede et uændret forslag (halve point)
|
||||
self.suggestions: dict[str, set[str]] = {}
|
||||
self.assisted: set[str] = set()
|
||||
@@ -365,7 +366,7 @@ class Game:
|
||||
|
||||
def _start_lie(self, now: float):
|
||||
self.phase = "lie"
|
||||
self.deadline = now + LIE_SECONDS
|
||||
self.deadline = self.lie_deadline = now + LIE_SECONDS
|
||||
self._emit("lie_start", category=self.question["category"], prompt=self.question["prompt"])
|
||||
self._changed()
|
||||
|
||||
@@ -396,9 +397,22 @@ class Game:
|
||||
self.lies[member_id] = text
|
||||
if normalize(text) in self.suggestions.get(member_id, ()):
|
||||
self.assisted.add(member_id) # husets forslag: halve point for at narre
|
||||
self._emit("lie_submitted", player=member_id, text=text)
|
||||
self._changed()
|
||||
self._maybe_finish_early(now)
|
||||
|
||||
def reject_lie(self, member_id: str, text: str) -> bool:
|
||||
"""Dommeren afviste løgnen (sandheden eller upassende): spilleren skal skrive en ny."""
|
||||
if self.phase != "lie" or self.lies.get(member_id) != text:
|
||||
return False
|
||||
del self.lies[member_id]
|
||||
self.assisted.discard(member_id)
|
||||
# Var fasen sat til at slutte tidligt, fordi alle havde løjet, får spilleren sin tid tilbage.
|
||||
if self.deadline is not None and self.lie_deadline and self.deadline < self.lie_deadline:
|
||||
self.deadline = self.lie_deadline
|
||||
self._changed()
|
||||
return True
|
||||
|
||||
def lie_for_me(self, member_id: str) -> str:
|
||||
self._require_phase("lie")
|
||||
self._require_player(member_id)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .game import GameBrain, get_brain # noqa: F401
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Spilhjernen: kommentarer, dommer, drillerier og slut-anmeldelse. Udbyder-agnostisk (se providers.py).
|
||||
|
||||
Alt her er valgfrit og asynkront. Spillet kalder hjernen i baggrunden og bruger svaret, hvis det
|
||||
kommer i tide; ellers bruges de faste replikker. Hjernen ser kun spillets egne data og et kort
|
||||
resumé af aftenen (rummets hukommelse), aldrig lyd.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .providers import Provider, build_provider
|
||||
|
||||
SYSTEM = """Du er speakeren i "Fup og Fakta", et dansk familie-partyspil i stil med Fibbage fra Jackbox.
|
||||
Spillet: et kuriøst, sandt faktum vises med et hul. Alle skriver en troværdig løgn. Løgnene blandes med
|
||||
sandheden, og man skal gætte sandheden. Man får point for at gætte rigtigt og for at narre andre.
|
||||
|
||||
Din stil: kæk, tør og kærligt drillende, som Cookie Masterson fra Fibbage, men med dansk humor.
|
||||
Du driller med varme, aldrig ondt. Spillerne er familie og venner, også børn fra 10 år.
|
||||
|
||||
Regler for alt, du skriver:
|
||||
- Dansk, mundret, skrevet til at blive LÆST HØJT af en stemme. Korte sætninger.
|
||||
- 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").
|
||||
- É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."
|
||||
"Hele sofaen tog fejl. Sikke en sjælden form for enighed." "Ti sekunder. Det behøver ikke være godt. Bare forkert."
|
||||
- Brug gerne aftenens hukommelse til en pointe, der bygger videre på noget, der er sket tidligere."""
|
||||
|
||||
|
||||
def _schema(props: dict, required: list[str] | None = None) -> dict:
|
||||
return {"type": "object", "properties": props, "required": required or list(props), "additionalProperties": False}
|
||||
|
||||
|
||||
COMMENTS = _schema({"comments": {"type": "array", "items": _schema({"id": {"type": "string"}, "line": {"type": "string"}})}})
|
||||
JUDGE = _schema({"is_truth": {"type": "boolean"}, "inappropriate": {"type": "boolean"}, "message": {"type": "string"}})
|
||||
TEASES = _schema({"teases": {"type": "array", "items": _schema({"player_id": {"type": "string"}, "text": {"type": "string"}})}})
|
||||
REVIEW = _schema({"lines": {"type": "array", "items": _schema({"player_id": {"type": "string"}, "line": {"type": "string"}})}})
|
||||
|
||||
|
||||
class GameBrain:
|
||||
def __init__(self, provider: Provider):
|
||||
self.provider = provider
|
||||
|
||||
async def _ask(self, task: str, payload: dict, schema: dict, *, max_tokens: int, timeout: float):
|
||||
user = f"{task}\n\nData (JSON):\n{json.dumps(payload, ensure_ascii=False)}"
|
||||
return await self.provider.complete_json(SYSTEM, user, schema, max_tokens=max_tokens, timeout=timeout)
|
||||
|
||||
async def comment_answers(self, question: dict, answers: list[dict], memory: list[str]) -> dict[str, str]:
|
||||
"""{answer_id: kommentar}. answers: [{id, text, kind}] med kind = løgn | husets løgn | sandheden."""
|
||||
res = await self._ask(
|
||||
"Skriv én kort kommentar (højst tolv ord) til HVERT svar. Kommentaren læses op, lige når svaret "
|
||||
"afsløres som løgn eller sandhed. Kommentér selve svaret, ikke hvem der faldt for det (det ved du "
|
||||
"ikke endnu), og nævn ikke hvem der skrev det. Til sandheden: reagér på hvor vildt faktummet er.",
|
||||
{"spørgsmål": question.get("prompt"), "sandheden": question.get("answer"),
|
||||
"forklaring": question.get("note", ""), "svar": answers, "aftenens_hukommelse": memory[-15:]},
|
||||
COMMENTS, max_tokens=900, timeout=settings.BRAIN_TIMEOUT_SLOW)
|
||||
if not res:
|
||||
return {}
|
||||
ids = {a["id"] for a in answers}
|
||||
return {c["id"]: c["line"].strip() for c in res.get("comments", []) if c.get("id") in ids and c.get("line")}
|
||||
|
||||
async def judge_lie(self, question: dict, lie: str) -> dict | None:
|
||||
"""{is_truth, inappropriate, message} eller None, hvis dommeren ikke svarede."""
|
||||
return await self._ask(
|
||||
"En spiller har afleveret en løgn. Afgør to ting. is_truth: er løgnen reelt sandheden (samme betydning, "
|
||||
"synonym, bøjning, stavefejl eller omskrivning af sandheden eller en af dens stavemåder)? En anden, "
|
||||
"forkert ting er IKKE sandheden. inappropriate: er løgnen upassende for et familiespil med børn "
|
||||
"(grov, seksuel, nedsættende, voldelig)? Fjollet og frækt-sjovt er fint. message: hvis en af dem er "
|
||||
"sand, en kort, venlig besked til spilleren på dansk om at skrive en ny løgn; ellers tom streng.",
|
||||
{"spørgsmål": question.get("prompt"), "sandheden": question.get("answer"),
|
||||
"andre_stavemåder": question.get("alternatives", []), "løgn": lie},
|
||||
JUDGE, max_tokens=200, timeout=settings.BRAIN_TIMEOUT_FAST)
|
||||
|
||||
async def teases(self, victims: list[dict], memory: list[str]) -> dict[str, str]:
|
||||
"""{player_id: drilleri}. victims: [{player_id, name, valgte, skrevet_af}]."""
|
||||
res = await self._ask(
|
||||
"Disse spillere er lige faldet for en løgn. Skriv en meget kort drilleri-replik (højst seks ord) til "
|
||||
"hver, som vises og læses op på deres egen telefon. Personlig og kærlig, gerne med navnet og gerne "
|
||||
"med en henvisning til aftenens hukommelse, hvis det passer.",
|
||||
{"narret": victims, "aftenens_hukommelse": memory[-15:]},
|
||||
TEASES, max_tokens=300, timeout=settings.BRAIN_TIMEOUT_FAST)
|
||||
if not res:
|
||||
return {}
|
||||
ids = {v["player_id"] for v in victims}
|
||||
return {t["player_id"]: t["text"].strip() for t in res.get("teases", []) if t.get("player_id") in ids and t.get("text")}
|
||||
|
||||
async def final_review(self, players: list[dict], memory: list[str]) -> list[tuple[str, str]]:
|
||||
"""[(player_id, replik)] i rækkefølge efter placering."""
|
||||
res = await self._ask(
|
||||
"Spillet er slut. Skriv én personlig anmeldelse (højst femten ord) til hver spiller, ud fra deres "
|
||||
"placering, statistik og aftenens hukommelse. Kærligt drillende, gerne med et konkret minde fra aftenen.",
|
||||
{"spillere": players, "aftenens_hukommelse": memory[-30:]},
|
||||
REVIEW, max_tokens=900, timeout=settings.BRAIN_TIMEOUT_SLOW)
|
||||
if not res:
|
||||
return []
|
||||
ids = {p["player_id"] for p in players}
|
||||
return [(x["player_id"], x["line"].strip()) for x in res.get("lines", []) if x.get("player_id") in ids and x.get("line")]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_brain() -> GameBrain | None:
|
||||
provider = build_provider(settings.BRAIN_PROVIDER, settings.BRAIN_MODEL, settings.BRAIN_API_KEY,
|
||||
settings.BRAIN_BASE_URL, settings.BRAIN_EFFORT)
|
||||
return GameBrain(provider) if provider else None
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Sprogmodel-udbydere bag ét fælles interface: `await provider.complete_json(system, user, schema)`.
|
||||
|
||||
Spillet kender kun dette interface, så modellen kan skiftes med miljøvariabler:
|
||||
|
||||
BRAIN_PROVIDER=anthropic BRAIN_MODEL=claude-opus-5 BRAIN_API_KEY=...
|
||||
BRAIN_PROVIDER=openai BRAIN_MODEL=<model> BRAIN_API_KEY=... (OpenAI/Codex, vLLM, LM Studio, Ollama /v1)
|
||||
BRAIN_PROVIDER=ollama BRAIN_MODEL=gemma4:12b-it-qat BRAIN_BASE_URL=http://<ollama>:11434
|
||||
|
||||
Alle returnerer et dict efter skemaet, eller None ved timeout/fejl/afvisning. Spillet falder så tilbage
|
||||
til de faste replikker; en model må aldrig kunne stoppe et spil.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Provider:
|
||||
name = "base"
|
||||
|
||||
def __init__(self, model: str, api_key: str = "", base_url: str = "", effort: str = "low"):
|
||||
self.model, self.api_key, self.base_url, self.effort = model, api_key, base_url.rstrip("/"), effort
|
||||
|
||||
async def complete_json(self, system: str, user: str, schema: dict, *, max_tokens: int = 1500,
|
||||
timeout: float = 8.0) -> dict | None:
|
||||
try:
|
||||
return await asyncio.wait_for(self._complete(system, user, schema, max_tokens, timeout), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
log.info("%s: timeout efter %.1fs", self.name, timeout)
|
||||
except Exception: # noqa: BLE001 — modellen må aldrig vælte spillet
|
||||
log.exception("%s: kald fejlede", self.name)
|
||||
return None
|
||||
|
||||
async def _complete(self, system, user, schema, max_tokens, timeout) -> dict | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AnthropicProvider(Provider):
|
||||
"""Claude via den officielle SDK med struktureret output og server-side fallback ved afvisning."""
|
||||
|
||||
name = "anthropic"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
import anthropic # kun nødvendig, når denne udbyder er valgt
|
||||
|
||||
self._anthropic = anthropic
|
||||
self.client = anthropic.AsyncAnthropic(api_key=self.api_key or None, max_retries=1)
|
||||
|
||||
async def _complete(self, system, user, schema, max_tokens, timeout):
|
||||
resp = await self.client.with_options(timeout=timeout).beta.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
betas=["server-side-fallback-2026-07-01"],
|
||||
fallbacks="default",
|
||||
system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
|
||||
output_config={"effort": self.effort, "format": {"type": "json_schema", "schema": schema}},
|
||||
messages=[{"role": "user", "content": user}],
|
||||
)
|
||||
if resp.stop_reason == "refusal":
|
||||
log.info("anthropic: afvist (%s)", getattr(resp.stop_details, "category", None))
|
||||
return None
|
||||
text = next((b.text for b in resp.content if b.type == "text"), None)
|
||||
return json.loads(text) if text else None
|
||||
|
||||
|
||||
def _post_json(url: str, body: dict, headers: dict, timeout: float) -> dict:
|
||||
req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json", **headers})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(Provider):
|
||||
"""Alt der taler OpenAI's chat completions: OpenAI/Codex, vLLM, LM Studio, Ollamas /v1 m.fl."""
|
||||
|
||||
name = "openai"
|
||||
|
||||
async def _complete(self, system, user, schema, max_tokens, timeout):
|
||||
url = (self.base_url or "https://api.openai.com/v1") + "/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
body = {"model": self.model, "max_tokens": max_tokens,
|
||||
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
"response_format": {"type": "json_schema",
|
||||
"json_schema": {"name": "svar", "schema": schema, "strict": True}}}
|
||||
try:
|
||||
data = await asyncio.to_thread(_post_json, url, body, headers, timeout)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in (400, 422):
|
||||
raise
|
||||
# Ældre servere kender ikke json_schema: bed om JSON og læg skemaet i prompten.
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
body["messages"][0]["content"] += "\n\nSvar kun med JSON efter dette skema:\n" + json.dumps(schema)
|
||||
data = await asyncio.to_thread(_post_json, url, body, headers, timeout)
|
||||
return json.loads(data["choices"][0]["message"]["content"])
|
||||
|
||||
|
||||
class OllamaProvider(Provider):
|
||||
"""Ollamas eget API: skema-styret output og ingen 'tænkning' (Gemma tænker ellers i minutter)."""
|
||||
|
||||
name = "ollama"
|
||||
|
||||
async def _complete(self, system, user, schema, max_tokens, timeout):
|
||||
url = (self.base_url or "http://localhost:11434") + "/api/chat"
|
||||
body = {"model": self.model, "stream": False, "think": False, "format": schema, "keep_alive": "30m",
|
||||
"options": {"num_predict": max_tokens, "temperature": 0.8},
|
||||
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}]}
|
||||
data = await asyncio.to_thread(_post_json, url, body, {}, timeout)
|
||||
return json.loads(data["message"]["content"])
|
||||
|
||||
|
||||
PROVIDERS = {"anthropic": AnthropicProvider, "openai": OpenAICompatibleProvider, "ollama": OllamaProvider}
|
||||
|
||||
|
||||
def build_provider(kind: str, model: str, api_key: str = "", base_url: str = "", effort: str = "low") -> Provider | None:
|
||||
cls = PROVIDERS.get((kind or "").lower())
|
||||
if not cls or not model:
|
||||
return None
|
||||
return cls(model, api_key=api_key, base_url=base_url, effort=effort)
|
||||
@@ -7,6 +7,7 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
|
||||
from fupogfakta.engine import GameError
|
||||
|
||||
from . import stt
|
||||
from .auth import scope_identity
|
||||
from .rooms import get_room
|
||||
|
||||
@@ -85,6 +86,7 @@ class HostConsumer(_RoomConsumer):
|
||||
state = self.room.game.snapshot(time.time(), host=True)
|
||||
state["narration"] = self.room.narration
|
||||
state["phase_key"] = self.room.game.phase_key()
|
||||
state["mic"] = {"on": self.room.mic_on(), "laughs": self.room.mic_laughs, "available": stt.enabled()}
|
||||
await self.send_json({"type": "state", "state": state})
|
||||
|
||||
async def handle(self, action, content, now):
|
||||
@@ -154,6 +156,7 @@ class PlayConsumer(_RoomConsumer):
|
||||
elif action == "choose_category":
|
||||
g.choose_category(mid, int(content["category_id"]), now)
|
||||
elif action == "submit_lie":
|
||||
self.room.prepare_judging()
|
||||
g.submit_lie(mid, str(content.get("text", "")), now)
|
||||
elif action == "lie_for_me":
|
||||
await self.send_json({"type": "suggestion", "text": g.lie_for_me(mid)})
|
||||
|
||||
+24
-1
@@ -81,10 +81,12 @@ class Narrator:
|
||||
return capitalize(re.sub(r"\{(\w+)\}", lambda m: filled.get(m.group(1), ""), variants[i]))
|
||||
|
||||
# ── afsløringen ──────────────────────────────────────────────────────────
|
||||
def reveal_step(self, step: dict, names: dict[str, str], n_players: int) -> str | None:
|
||||
def reveal_step(self, step: dict, names: dict[str, str], n_players: int, comment: str | None = None) -> str | None:
|
||||
picked = [names[p] for p in step["guessed_by"] if p in names]
|
||||
if step["audience_votes"]:
|
||||
picked.append("publikum")
|
||||
if comment:
|
||||
return self._reveal_with_comment(step, names, picked, n_players, comment)
|
||||
if step["is_truth"]:
|
||||
if not step["guessed_by"]:
|
||||
return self.line("reveal_truth_nobody", sandhed=step["text"])
|
||||
@@ -104,6 +106,27 @@ class Narrator:
|
||||
return self.line("reveal_lie_fooled", svar=step["text"], forfatter=join_names(authors),
|
||||
ofre=join_names(picked))
|
||||
|
||||
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()
|
||||
svar = safe(step["text"])
|
||||
if step["is_truth"]:
|
||||
if not step["guessed_by"]:
|
||||
head = f"Sandheden er {svar}, og ingen fandt den."
|
||||
elif n_players >= 2 and len(step["guessed_by"]) >= n_players:
|
||||
head = f"Alle fandt sandheden, {svar}."
|
||||
else:
|
||||
head = f"Sandheden er {svar}. {join_names(picked)} fandt den."
|
||||
return capitalize(f"{head} {comment}")
|
||||
if not picked:
|
||||
return None
|
||||
if step["house"]:
|
||||
return capitalize(f"{svar}. Husets løgn! {join_names(picked)} faldt for den. {comment}")
|
||||
authors = join_names([names[a] for a in step["authors"] if a in names])
|
||||
if len(step["authors"]) >= 2:
|
||||
return capitalize(f"{svar}. Det skrev både {authors}, og {join_names(picked)} hoppede i. {comment}")
|
||||
return capitalize(f"{svar}. Det skrev {authors}, og {join_names(picked)} hoppede i. {comment}")
|
||||
|
||||
# ── alt andet ────────────────────────────────────────────────────────────
|
||||
def for_event(self, ev: dict, game) -> list[str]:
|
||||
t = ev["type"]
|
||||
|
||||
+174
-4
@@ -16,8 +16,9 @@ import time
|
||||
from fupogfakta import engine
|
||||
from fupogfakta.engine import Game
|
||||
|
||||
from . import tts
|
||||
from . import stt, tts
|
||||
from .assets import tease_images
|
||||
from .brain import get_brain
|
||||
from .narrator import TEASES, Narrator
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -35,6 +36,9 @@ PENDING_HOLD_MAX_AGE = 8 # en replik, der stadig genereres efter så længe,
|
||||
TEASE_CHANCE = 0.45 # sandsynlighed for at en narret spillers telefon bliver drillet
|
||||
TEASES_PER_QUESTION = 2
|
||||
TEASE_AT_MS = 2400 # når stemplet lander i et trin (svar → hvem → stempel)
|
||||
MEMORY_KEEP = 60 # linjer i rummets hukommelse (til hjernen)
|
||||
COMMENT_WAIT = 4 # så længe afsløringen højst venter på hjernens kommentarer
|
||||
MIC_ACTIVE_FOR = 25 # mikrofonen regnes som tændt så længe efter sidste lydstump
|
||||
|
||||
_rooms: dict[str, "Room"] = {}
|
||||
|
||||
@@ -60,6 +64,15 @@ class Room:
|
||||
self.speech_end = 0.0
|
||||
self._phase_key = game.phase_key()
|
||||
self._pending: dict[str, list[float]] = {} # fasenøgle -> starttider for replikker under generering
|
||||
# Hjernen og hukommelsen
|
||||
self.brain = get_brain()
|
||||
self.memory: list[str] = []
|
||||
self._comment_tasks: dict[tuple, asyncio.Task] = {}
|
||||
self._tease_texts: dict[str, str] = {}
|
||||
# Rummets mikrofon (eksperimentel)
|
||||
self.mic_last = 0.0
|
||||
self.mic_laughs = 0
|
||||
self._mic_busy = False
|
||||
|
||||
def touch(self):
|
||||
self.last_activity = time.time()
|
||||
@@ -137,6 +150,8 @@ class Room:
|
||||
def handle_events(self):
|
||||
"""Tøm motorens begivenheder og lad speakeren reagere. Kaldes også efter afviste handlinger."""
|
||||
events = self.game.drain_events()
|
||||
for ev in events:
|
||||
self._think(ev)
|
||||
if not tts.enabled():
|
||||
if any(e["type"] == "reveal" for e in events):
|
||||
self._schedule_teases() # uden stemme starter afsløringen med det samme
|
||||
@@ -164,6 +179,134 @@ class Room:
|
||||
elif ev["type"] == "guess_start":
|
||||
self._at_deadline_minus(5, "guess", "guess_hurry")
|
||||
|
||||
# ── hjernen og hukommelsen ───────────────────────────────────────────────
|
||||
def remember(self, line: str):
|
||||
self.memory.append(line)
|
||||
del self.memory[:-MEMORY_KEEP]
|
||||
|
||||
def _hold_for(self, coro):
|
||||
"""Kør en baggrundsopgave, som den aktuelle skærm skal vente på (fx dommeren)."""
|
||||
key, started = self.game.phase_key(), time.time()
|
||||
self._pending.setdefault(key, []).append(started)
|
||||
self._update_hold()
|
||||
|
||||
async def run():
|
||||
try:
|
||||
await coro
|
||||
finally:
|
||||
lst = self._pending.get(key, [])
|
||||
if started in lst:
|
||||
lst.remove(started)
|
||||
if not lst:
|
||||
self._pending.pop(key, None)
|
||||
self._update_hold()
|
||||
self._schedule_timer()
|
||||
asyncio.get_running_loop().create_task(run())
|
||||
|
||||
def prepare_judging(self):
|
||||
"""Kaldes før en løgn afleveres: hold skærmen et øjeblik, så dommeren når at se den sidste løgn."""
|
||||
if self.brain:
|
||||
self.game.hold_until = max(self.game.hold_until or 0, time.time() + 1.0)
|
||||
|
||||
def _think(self, ev: dict):
|
||||
g, t = self.game, ev["type"]
|
||||
names = {p.id: p.name for p in g.players.values()}
|
||||
if t == "lie_submitted" and self.brain:
|
||||
self._hold_for(self._judge(ev["player"], ev["text"], dict(g.question or {})))
|
||||
elif t == "guess_start" and self.brain and g.question:
|
||||
answers = [{"id": a.id, "text": a.text,
|
||||
"kind": "sandheden" if a.is_truth else "husets løgn" if a.house else "løgn"} for a in g.answers]
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
self.brain.comment_answers(dict(g.question), answers, list(self.memory)))
|
||||
self._comment_tasks = {(g.round_idx, g.q_idx): task}
|
||||
elif t == "reveal":
|
||||
for st in g.reveal_steps:
|
||||
fooled = [names[p] for p in st["guessed_by"] if p in names]
|
||||
if st["is_truth"]:
|
||||
self.remember(f"Sandheden var '{st['text']}' ({g.question['prompt']}). "
|
||||
+ (f"{', '.join(fooled)} fandt den." if fooled else "Ingen fandt den."))
|
||||
elif fooled:
|
||||
who = "husets løgn" if st["house"] else f"{', '.join(names.get(a, '?') for a in st['authors'])}s løgn"
|
||||
self.remember(f"{', '.join(fooled)} faldt for {who} '{st['text']}'.")
|
||||
elif t == "scores":
|
||||
self.remember(f"Efter runden fører {ev['leader']}, og {ev['last']} ligger sidst.")
|
||||
elif t == "final" and self.brain:
|
||||
players = [{"player_id": p.id, "navn": p.name, "plads": i + 1, "point": p.score, "narrede_andre": p.fooled,
|
||||
"fandt_sandheden": p.correct, "hjerter": p.likes}
|
||||
for i, p in enumerate(sorted(g.players.values(), key=lambda p: -p.score))]
|
||||
asyncio.get_running_loop().create_task(self._final_review(players))
|
||||
|
||||
async def _judge(self, pid: str, text: str, question: dict):
|
||||
verdict = await self.brain.judge_lie(question, text)
|
||||
if not verdict or not (verdict.get("is_truth") or verdict.get("inappropriate")):
|
||||
return
|
||||
if not self.game.reject_lie(pid, text):
|
||||
return
|
||||
msg = (verdict.get("message") or "").strip() 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):
|
||||
if getattr(conn, "member_id", None) == pid:
|
||||
await conn.send_error("lie_is_truth" if verdict.get("is_truth") else "lie_invalid", msg)
|
||||
if verdict.get("is_truth") and tts.enabled():
|
||||
for line in self.narrator.for_event({"type": "lie_was_truth"}, self.game):
|
||||
self.say(line)
|
||||
self.changed()
|
||||
|
||||
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)
|
||||
|
||||
async def _comments_for_current(self) -> dict:
|
||||
g = self.game
|
||||
task = self._comment_tasks.get((g.round_idx, g.q_idx))
|
||||
if not task:
|
||||
return {}
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(task), timeout=COMMENT_WAIT)
|
||||
except (asyncio.TimeoutError, Exception): # noqa: BLE001
|
||||
return {}
|
||||
|
||||
# ── rummets mikrofon (eksperimentel) ─────────────────────────────────────
|
||||
def mic_on(self) -> bool:
|
||||
return time.time() - self.mic_last < MIC_ACTIVE_FOR
|
||||
|
||||
def _what_is_on_screen(self) -> str:
|
||||
g = self.game
|
||||
if g.phase == "reveal" and g.reveal_started_at:
|
||||
el = (time.time() - g.reveal_started_at) * 1000
|
||||
for st, ms in zip(g.reveal_steps, g.reveal_steps_ms):
|
||||
if el < ms:
|
||||
return f"mens '{st['text']}' blev afsløret"
|
||||
el -= ms
|
||||
return "under opsummeringen"
|
||||
return {"lobby": "i lobbyen", "category": "mens kategorien blev valgt", "intro": "under kategori-introen",
|
||||
"lie": "mens der blev skrevet løgne", "guess": "mens der blev gættet", "scores": "ved pointtavlen",
|
||||
"final": "ved slutresultatet"}.get(g.phase, "")
|
||||
|
||||
async def hear(self, audio: bytes, content_type: str) -> dict:
|
||||
"""En lydstump fra storskærmens mikrofon. Lyden gemmes aldrig; kun et kort resumé i hukommelsen."""
|
||||
self.mic_last = time.time()
|
||||
if self._mic_busy: # er vi bagud, springer vi stumper over i stedet for at hobe dem op
|
||||
return {"skipped": True}
|
||||
self._mic_busy = True
|
||||
try:
|
||||
res = await stt.transcribe(audio, content_type)
|
||||
finally:
|
||||
self._mic_busy = False
|
||||
if not res:
|
||||
return {"heard": False}
|
||||
where = self._what_is_on_screen()
|
||||
if res["laughter"]:
|
||||
self.mic_laughs += 1
|
||||
self.remember(f"Rummet grinede højt {where}.")
|
||||
text = res["text"]
|
||||
if len(text.split()) >= 3:
|
||||
self.remember(f"Nogen i rummet sagde {where}: \"{text[:140]}\"")
|
||||
self.broadcast()
|
||||
return {"heard": True, "laughter": res["laughter"]}
|
||||
|
||||
def host_opened(self):
|
||||
if tts.enabled() and not self._welcomed and self.game.phase == "lobby" and not self.game.players:
|
||||
self._welcomed = True
|
||||
@@ -247,10 +390,16 @@ class Room:
|
||||
"""Lav lyd til hvert trin, og lad afsløringens tempo følge replikkerne."""
|
||||
g = self.game
|
||||
ident = (g.round_idx, g.q_idx)
|
||||
g.hold_reveal(time.time(), max_wait=REVEAL_AUDIO_WAIT + 2 + max(0.0, self.speech_end - time.time()))
|
||||
g.hold_reveal(time.time(), max_wait=COMMENT_WAIT + REVEAL_AUDIO_WAIT + 2 + max(0.0, self.speech_end - time.time()))
|
||||
self.changed()
|
||||
names = {p.id: p.name for p in g.players.values()}
|
||||
texts = [self.narrator.reveal_step(step, names, len(g.players)) for step in g.reveal_steps]
|
||||
if self.brain:
|
||||
tease_job = asyncio.get_running_loop().create_task(self._brain_teases(names))
|
||||
comments = await self._comments_for_current()
|
||||
else:
|
||||
tease_job, comments = None, {}
|
||||
texts = [self.narrator.reveal_step(step, names, len(g.players), comments.get(step["answer_id"]))
|
||||
for step in g.reveal_steps]
|
||||
note = (g.question or {}).get("note", "")
|
||||
jobs = [tts.synth(t) if t else asyncio.sleep(0, None) for t in texts]
|
||||
jobs.append(tts.synth(note) if note else asyncio.sleep(0, None))
|
||||
@@ -273,10 +422,31 @@ class Room:
|
||||
# men opsummeringen skal have sin fulde tid, efter at speakeren er færdig.
|
||||
truth_ms = next((ms for st, ms in zip(g.reveal_steps, steps_ms) if st["is_truth"]), 0)
|
||||
tail = engine.REVEAL_TAIL_MS + max(0, (note_audio["ms"] + REVEAL_PAD_MS - truth_ms) if note_audio else 0)
|
||||
if tease_job:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(tease_job), timeout=0.1)
|
||||
except (asyncio.TimeoutError, Exception): # noqa: BLE001
|
||||
pass
|
||||
g.start_reveal_timeline(time.time(), steps_ms, tail)
|
||||
self.changed()
|
||||
self._schedule_teases()
|
||||
|
||||
async def _brain_teases(self, names: dict):
|
||||
"""Personlige drillerier til de narrede; lyden laves med det samme, så den er klar, når stemplet lander."""
|
||||
g = self.game
|
||||
victims = []
|
||||
for st in g.reveal_steps:
|
||||
if st["is_truth"]:
|
||||
continue
|
||||
author = "huset" if st["house"] else ", ".join(names.get(a, "?") for a in st["authors"])
|
||||
victims += [{"player_id": p, "name": names[p], "valgte": st["text"], "skrevet_af": author}
|
||||
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))
|
||||
for text in self._tease_texts.values():
|
||||
asyncio.get_running_loop().create_task(tts.synth(text))
|
||||
|
||||
# ── drillerier på telefonen ──────────────────────────────────────────────
|
||||
def _schedule_teases(self):
|
||||
"""Når stemplet "LØGN" lander på TV'et, kan den narrede spillers telefon sige buh."""
|
||||
@@ -298,7 +468,7 @@ class Room:
|
||||
g = self.game
|
||||
if g.phase != "reveal" or (g.round_idx, g.q_idx) != ident:
|
||||
return
|
||||
text = self.narrator.rng.choice(TEASES)
|
||||
text = self._tease_texts.get(pid) or self.narrator.rng.choice(TEASES)
|
||||
asyncio.get_running_loop().create_task(self._send_tease(pid, text))
|
||||
|
||||
async def _send_tease(self, pid: str, text: str):
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* Rummets mikrofon (eksperimentel) på storskærmen.
|
||||
Værten slår den til med knappen nederst til venstre. Lyden optages i stumper på 8 sekunder og sendes til
|
||||
serveren, som kun gemmer et kort resumé (hvem grinede, hvad der blev sagt) til speakerens hjerne.
|
||||
Selve lyden gemmes aldrig. Mikrofonen er altid slukket, når siden åbnes. */
|
||||
(function () {
|
||||
'use strict';
|
||||
var CHUNK_MS = 8000;
|
||||
var root = document.getElementById('host');
|
||||
if (!root || !navigator.mediaDevices || !window.MediaRecorder) return;
|
||||
var code = (root.getAttribute('data-code') || '').toUpperCase();
|
||||
var csrf = (document.querySelector('input[name=csrfmiddlewaretoken]') || {}).value || '';
|
||||
var stream = null, rec = null, on = false, failures = 0;
|
||||
|
||||
var box = document.createElement('div');
|
||||
box.className = 'mic-box';
|
||||
box.innerHTML = '<button type="button" class="hbtn ghost mic-btn">🎤 Lyt med <small>(eksperimentel)</small></button>' +
|
||||
'<span class="mic-live" hidden><i></i>Lytter</span><span class="mic-laugh" hidden>😂</span>';
|
||||
document.body.appendChild(box);
|
||||
var btn = box.querySelector('.mic-btn'), live = box.querySelector('.mic-live'), laugh = box.querySelector('.mic-laugh');
|
||||
|
||||
function setOn(v) {
|
||||
on = v;
|
||||
live.hidden = !v;
|
||||
btn.innerHTML = v ? '🎤 Stop lytning' : '🎤 Lyt med <small>(eksperimentel)</small>';
|
||||
box.classList.toggle('on', v);
|
||||
}
|
||||
|
||||
function popLaugh() {
|
||||
laugh.hidden = false;
|
||||
laugh.classList.remove('pop'); void laugh.offsetWidth; laugh.classList.add('pop');
|
||||
setTimeout(function () { laugh.hidden = true; }, 1600);
|
||||
}
|
||||
|
||||
function send(blob) {
|
||||
if (!blob || blob.size < 2000) return;
|
||||
fetch('/host/' + encodeURIComponent(code) + '/mic', {
|
||||
method: 'POST', body: blob, credentials: 'same-origin',
|
||||
headers: { 'Content-Type': blob.type || 'audio/webm', 'X-CSRFToken': csrf }
|
||||
}).then(function (r) { return r.json(); }).then(function (res) {
|
||||
if (res.laughter) popLaugh();
|
||||
failures = res.heard === false ? failures + 1 : 0;
|
||||
if (failures >= 4) {
|
||||
stop();
|
||||
alert('Mikrofonen er slået fra igen: serveren kunne ikke forstå lyden (er talegenkendelse sat op?).');
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function cycle() {
|
||||
if (!on || !stream) return;
|
||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : '';
|
||||
var chunks = [];
|
||||
rec = new MediaRecorder(stream, mime ? { mimeType: mime, audioBitsPerSecond: 32000 } : {});
|
||||
rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = function () { send(new Blob(chunks, { type: rec.mimeType || 'audio/webm' })); cycle(); };
|
||||
rec.start();
|
||||
setTimeout(function () { if (rec && rec.state === 'recording') rec.stop(); }, CHUNK_MS);
|
||||
}
|
||||
|
||||
function start() {
|
||||
var ok = window.confirm('Rummets mikrofon (eksperimentel)\n\nSpeakeren lytter med gennem denne computers mikrofon, ' +
|
||||
'så den kan høre, hvornår I griner, og drille jer med det, I siger. Lyden gemmes ikke; kun et kort resumé ' +
|
||||
'bruges i dette spil og glemmes bagefter.\n\nVil du slå den til?');
|
||||
if (!ok) return;
|
||||
navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: false } })
|
||||
.then(function (s) { stream = s; failures = 0; setOn(true); cycle(); })
|
||||
.catch(function () { alert('Mikrofonen kunne ikke startes. Har browseren adgang til en mikrofon?'); });
|
||||
}
|
||||
|
||||
function stop() {
|
||||
setOn(false);
|
||||
if (rec && rec.state === 'recording') { rec.onstop = null; rec.stop(); }
|
||||
if (stream) stream.getTracks().forEach(function (t) { t.stop(); });
|
||||
stream = null; rec = null;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () { if (on) stop(); else start(); });
|
||||
window.addEventListener('pagehide', stop);
|
||||
})();
|
||||
@@ -808,3 +808,14 @@ button, input { font-family: inherit; }
|
||||
60% { translate: -5px 2px; rotate: -.6deg; }
|
||||
80% { translate: 3px -1px; rotate: 0deg; }
|
||||
}
|
||||
|
||||
/* rummets mikrofon (eksperimentel) */
|
||||
.mic-box { position: fixed; left: 18px; bottom: 16px; z-index: 50; display: flex; align-items: center; gap: 10px; font-size: 14px; }
|
||||
.mic-box .mic-btn small { opacity: .7; font-weight: 500; }
|
||||
.mic-box .mic-live { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; background: #c0392b; color: #fff; font-weight: 800; }
|
||||
.mic-box .mic-live i { width: 9px; height: 9px; border-radius: 50%; background: #fff; animation: micpulse 1s ease-in-out infinite; }
|
||||
.mic-box .mic-laugh { font-size: 34px; }
|
||||
.mic-box .mic-laugh.pop { animation: miclaugh 1.6s cubic-bezier(.3, 1.6, .5, 1) both; }
|
||||
@keyframes micpulse { 50% { opacity: .25; } }
|
||||
@keyframes miclaugh { 0% { transform: scale(.2) translateY(10px); opacity: 0; } 25% { transform: scale(1.4); opacity: 1; } 100% { transform: scale(1) translateY(-30px); opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .mic-box .mic-live i, .mic-box .mic-laugh.pop { animation: none; } }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
"""Talegenkendelse til rummets mikrofon (eksperimentel). Udbyder-agnostisk som hjernen.
|
||||
|
||||
STT_PROVIDER=elevenlabs (Scribe; dansk; markerer selv latter som "(griner)"/"(laughter)")
|
||||
STT_PROVIDER=openai (Whisper-kompatibelt /audio/transcriptions, fx OpenAI eller en lokal server)
|
||||
|
||||
Lyden behandles i hukommelsen og gemmes aldrig. Resultatet er kun tekst + om der blev grinet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LAUGH = re.compile(r"\((?:[^)]*(?:grin|griner|latter|laugh|chuckl|giggl|fnis)[^)]*)\)", re.I)
|
||||
EVENT = re.compile(r"\([^)]*\)")
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(_key()) and settings.STT_PROVIDER in ("elevenlabs", "openai")
|
||||
|
||||
|
||||
def _key() -> str:
|
||||
if settings.STT_API_KEY:
|
||||
return settings.STT_API_KEY
|
||||
return settings.ELEVENLABS_API_KEY if settings.STT_PROVIDER == "elevenlabs" else ""
|
||||
|
||||
|
||||
def _multipart(fields: dict, file_bytes: bytes, content_type: str) -> tuple[bytes, str]:
|
||||
boundary = uuid.uuid4().hex
|
||||
out = b""
|
||||
for k, v in fields.items():
|
||||
out += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n".encode()
|
||||
out += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"rum.webm\"\r\n"
|
||||
f"Content-Type: {content_type}\r\n\r\n").encode() + file_bytes + f"\r\n--{boundary}--\r\n".encode()
|
||||
return out, f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def _transcribe_sync(audio: bytes, content_type: str) -> str:
|
||||
if settings.STT_PROVIDER == "elevenlabs":
|
||||
body, ctype = _multipart({"model_id": settings.STT_MODEL or "scribe_v1", "language_code": "da",
|
||||
"tag_audio_events": "true"}, audio, content_type)
|
||||
req = urllib.request.Request(settings.STT_BASE_URL or "https://api.elevenlabs.io/v1/speech-to-text", body,
|
||||
{"xi-api-key": _key(), "Content-Type": ctype})
|
||||
else:
|
||||
body, ctype = _multipart({"model": settings.STT_MODEL or "whisper-1", "language": "da"}, audio, content_type)
|
||||
req = urllib.request.Request((settings.STT_BASE_URL or "https://api.openai.com/v1") + "/audio/transcriptions",
|
||||
body, {"Authorization": f"Bearer {_key()}", "Content-Type": ctype})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.load(r).get("text", "")
|
||||
|
||||
|
||||
async def transcribe(audio: bytes, content_type: str = "audio/webm") -> dict | None:
|
||||
"""{"text": tale uden lyd-tags, "laughter": bool} eller None ved fejl."""
|
||||
if not enabled() or len(audio) < 2000:
|
||||
return None
|
||||
try:
|
||||
raw = await asyncio.to_thread(_transcribe_sync, audio, content_type)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("STT fejlede: %r", e)
|
||||
return None
|
||||
return {"text": re.sub(r"\s+", " ", EVENT.sub(" ", raw)).strip(), "laughter": bool(LAUGH.search(raw))}
|
||||
@@ -4,9 +4,11 @@
|
||||
{% block body_class %}page-host{% endblock %}
|
||||
{% block content %}
|
||||
<div id="host" data-code="{{ code }}"></div>
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{{ assets|json_script:"wpp-assets" }}
|
||||
<script src="{% static 'party/common.js' %}" defer></script>
|
||||
<script src="{% static 'party/host.js' %}" defer></script>
|
||||
<script src="{% static 'party/mic.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import asyncio
|
||||
from unittest import mock
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.testing.websocket import WebsocketCommunicator
|
||||
from django.test import SimpleTestCase, TransactionTestCase, override_settings
|
||||
|
||||
from party import rooms, stt, tts
|
||||
from party.brain import GameBrain
|
||||
from party.brain.providers import OllamaProvider, OpenAICompatibleProvider, build_provider
|
||||
from partyhub.asgi import application
|
||||
|
||||
from .test_narration import fake_synth
|
||||
from .test_ws import HOST_HEADERS, make_bank, recv_until
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
"""Svarer som en sprogmodel ville, ud fra hvilket skema der bedes om."""
|
||||
name = "fake"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def complete_json(self, system, user, schema, **kw):
|
||||
self.calls.append(user)
|
||||
props = schema["properties"]
|
||||
if "is_truth" in props:
|
||||
bad = '"løgn": "grim løgn"' in user
|
||||
return {"is_truth": False, "inappropriate": bad, "message": "Prøv en pænere løgn." if bad else ""}
|
||||
if "comments" in props:
|
||||
import json, re
|
||||
data = json.loads(user.split("Data (JSON):\n", 1)[1])
|
||||
return {"comments": [{"id": a["id"], "line": f"KOMMENTAR {a['text']}"} for a in data["svar"]]}
|
||||
if "teases" in props:
|
||||
return {"teases": []}
|
||||
if "lines" in props:
|
||||
return {"lines": []}
|
||||
return None
|
||||
|
||||
|
||||
class ProviderFactoryTests(SimpleTestCase):
|
||||
def test_build(self):
|
||||
self.assertIsNone(build_provider("", "x"))
|
||||
self.assertIsNone(build_provider("ollama", ""))
|
||||
self.assertIsInstance(build_provider("ollama", "gemma"), OllamaProvider)
|
||||
self.assertIsInstance(build_provider("openai", "gpt"), OpenAICompatibleProvider)
|
||||
|
||||
def test_timeout_and_errors_return_none(self):
|
||||
class Slow(OllamaProvider):
|
||||
async def _complete(self, *a):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
class Broken(OllamaProvider):
|
||||
async def _complete(self, *a):
|
||||
raise RuntimeError("nede")
|
||||
self.assertIsNone(asyncio.run(Slow("m").complete_json("s", "u", {}, timeout=0.05)))
|
||||
self.assertIsNone(asyncio.run(Broken("m").complete_json("s", "u", {})))
|
||||
|
||||
|
||||
@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
|
||||
class BrainInGameTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
rooms.all_rooms().clear()
|
||||
make_bank()
|
||||
from fupogfakta import engine as _engine
|
||||
self._intro, _engine.INTRO_SECONDS = _engine.INTRO_SECONDS, 0.2
|
||||
self.addCleanup(setattr, _engine, "INTRO_SECONDS", self._intro)
|
||||
self.fake = FakeProvider()
|
||||
|
||||
async def _room(self):
|
||||
from fupogfakta.models import load_game_questions
|
||||
with mock.patch.object(rooms, "get_brain", return_value=GameBrain(self.fake)):
|
||||
return rooms.create_room(await sync_to_async(load_game_questions)(), "agw", "http://testserver")
|
||||
|
||||
async def test_judge_rejects_lie_and_comments_reach_reveal(self):
|
||||
room = await self._room()
|
||||
with mock.patch.object(tts, "enabled", return_value=True), mock.patch.object(tts, "synth", fake_synth):
|
||||
host = WebsocketCommunicator(application, f"/ws/host/{room.code}/", headers=HOST_HEADERS)
|
||||
host.scope["client"] = ("127.0.0.1", 5000)
|
||||
await host.connect()
|
||||
phones = []
|
||||
for name in ["Asger", "Sus"]:
|
||||
c = WebsocketCommunicator(application, f"/ws/play/{room.code}/")
|
||||
await c.connect()
|
||||
await c.send_json_to({"action": "join", "name": name})
|
||||
await recv_until(c, "joined")
|
||||
phones.append(c)
|
||||
await phones[0].send_json_to({"action": "start"})
|
||||
st = (await recv_until(host, "state", "category"))["state"]
|
||||
chooser = phones[0] if st["category"]["chooser_id"] == "p1" else phones[1]
|
||||
await chooser.send_json_to({"action": "choose_category", "category_id": st["category"]["options"][0]["id"]})
|
||||
for c in phones:
|
||||
await recv_until(c, "state", "lie")
|
||||
await phones[0].send_json_to({"action": "submit_lie", "text": "grim løgn"})
|
||||
err = await recv_until(phones[0], "error")
|
||||
self.assertEqual(err["message"], "Prøv en pænere løgn.")
|
||||
self.assertNotIn("p1", room.game.lies) # afvist og fjernet
|
||||
await phones[0].send_json_to({"action": "submit_lie", "text": "pæn løgn"})
|
||||
await phones[1].send_json_to({"action": "submit_lie", "text": "anden løgn"})
|
||||
for c in phones:
|
||||
mine = (await recv_until(c, "state", "guess"))["state"]
|
||||
await c.send_json_to({"action": "guess", "answer_id": mine["answers"][0]["id"]})
|
||||
for _ in range(40):
|
||||
st = (await recv_until(host, "state", "reveal"))["state"]
|
||||
if st["reveal"]["started_at"]:
|
||||
break
|
||||
texts = [s["audio"]["text"] for s in st["reveal"]["steps"] if s.get("audio")]
|
||||
self.assertTrue(any("KOMMENTAR" in t for t in texts), texts) # hjernens kommentar blev læst op
|
||||
self.assertTrue(room.memory) # afsløringen blev husket
|
||||
for c in phones:
|
||||
await c.disconnect()
|
||||
await host.disconnect()
|
||||
|
||||
async def test_mic_feeds_memory(self):
|
||||
room = await self._room()
|
||||
|
||||
async def fake_transcribe(audio, ctype):
|
||||
return {"text": "det er helt sikkert ugler", "laughter": True}
|
||||
with mock.patch.object(stt, "transcribe", fake_transcribe):
|
||||
res = await room.hear(b"x" * 5000, "audio/webm")
|
||||
self.assertTrue(res["laughter"])
|
||||
self.assertEqual(room.mic_laughs, 1)
|
||||
self.assertTrue(any("grinede" in m for m in room.memory))
|
||||
self.assertTrue(any("helt sikkert ugler" in m for m in room.memory))
|
||||
self.assertTrue(room.mic_on())
|
||||
@@ -68,7 +68,7 @@ class EngineEventTests(SimpleTestCase):
|
||||
g.submit_lie(a.id, "katte", 100)
|
||||
with self.assertRaises(engine.GameError):
|
||||
g.submit_lie(b.id, g.question["answer"], 100)
|
||||
self.assertEqual([e["type"] for e in g.drain_events()], ["lie_was_truth"])
|
||||
self.assertEqual([e["type"] for e in g.drain_events()], ["lie_submitted", "lie_was_truth"])
|
||||
g.submit_lie(b.id, "hunde", 100)
|
||||
truth = next(x.id for x in g.answers if x.is_truth)
|
||||
g.guess(a.id, truth, 100)
|
||||
|
||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
||||
path("host/", views.host_new_page, name="host_new_page"),
|
||||
path("host/new", views.host_new, name="host_new"),
|
||||
path("host/<str:code>/", views.host, name="host"),
|
||||
path("host/<str:code>/mic", views.host_mic, name="host_mic"),
|
||||
path("qr/<str:code>.svg", views.qr, name="qr"),
|
||||
path("tts/<str:digest>.mp3", views.tts_audio, name="tts"),
|
||||
path("healthz", views.healthz, name="healthz"),
|
||||
|
||||
+19
-1
@@ -4,7 +4,7 @@ import re
|
||||
import segno
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
@@ -79,3 +79,21 @@ def tts_audio(request, digest):
|
||||
resp = FileResponse(path.open("rb"), content_type="audio/mpeg")
|
||||
resp["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return resp
|
||||
|
||||
|
||||
MIC_MAX_BYTES = 2_000_000
|
||||
|
||||
|
||||
@require_POST
|
||||
async def host_mic(request, code):
|
||||
"""Lydstump fra storskærmens mikrofon (eksperimentel). Kun værten; lyden gemmes aldrig."""
|
||||
if not request.host_identity:
|
||||
return _forbidden()
|
||||
room = get_room(code)
|
||||
if not room:
|
||||
raise Http404
|
||||
body = request.body
|
||||
if not body or len(body) > MIC_MAX_BYTES:
|
||||
return JsonResponse({"error": "tom eller for stor lydstump"}, status=400)
|
||||
ctype = (request.content_type or "audio/webm").split(";")[0]
|
||||
return JsonResponse(await room.hear(body, ctype))
|
||||
|
||||
@@ -32,6 +32,21 @@ ELEVENLABS_VOICE_ID = env("ELEVENLABS_VOICE_ID", "")
|
||||
ELEVENLABS_MODEL = env("ELEVENLABS_MODEL", "eleven_v4")
|
||||
TTS_CACHE_DIR = env("TTS_CACHE_DIR", str(BASE_DIR / "tts-cache"))
|
||||
|
||||
# Spilhjernen (valgfri, udbyder-agnostisk, se party/brain/providers.py): anthropic | openai | ollama
|
||||
BRAIN_PROVIDER = env("BRAIN_PROVIDER", "")
|
||||
BRAIN_MODEL = env("BRAIN_MODEL", "")
|
||||
BRAIN_API_KEY = env("BRAIN_API_KEY", "")
|
||||
BRAIN_BASE_URL = env("BRAIN_BASE_URL", "")
|
||||
BRAIN_EFFORT = env("BRAIN_EFFORT", "low") # kun Anthropic
|
||||
BRAIN_TIMEOUT_FAST = float(env("BRAIN_TIMEOUT_FAST", "6")) # dommer, drillerier
|
||||
BRAIN_TIMEOUT_SLOW = float(env("BRAIN_TIMEOUT_SLOW", "25")) # kommentarer, anmeldelse
|
||||
|
||||
# Rummets mikrofon (eksperimentel, slås til på storskærmen): elevenlabs | openai (Whisper-kompatibel)
|
||||
STT_PROVIDER = env("STT_PROVIDER", "elevenlabs")
|
||||
STT_API_KEY = env("STT_API_KEY", "") # tom = genbrug ELEVENLABS_API_KEY for elevenlabs
|
||||
STT_BASE_URL = env("STT_BASE_URL", "")
|
||||
STT_MODEL = env("STT_MODEL", "")
|
||||
|
||||
# Kun til lokal udvikling: lader som om denne bruger er logget ind som vært.
|
||||
DEV_HOST_USER = env("DEV_HOST_USER") if DEBUG else None
|
||||
|
||||
|
||||
@@ -3,3 +3,4 @@ channels>=4.2,<5
|
||||
uvicorn[standard]>=0.30
|
||||
whitenoise>=6.7
|
||||
segno>=1.6
|
||||
anthropic>=1.11,<2
|
||||
|
||||
Reference in New Issue
Block a user