feat: Zacus system prompt + game state endpoint

Add static personality prompt file for Professor Zacus with moods,
communication rules and in-game limits. Voice bridge now accepts
POST /game_state from ESP32 to inject dynamic scene context into the
LLM system prompt (scene, elapsed time, hints, mood, objective).
This commit is contained in:
L'électron rare
2026-04-02 09:27:34 +02:00
parent a6ba18c2fc
commit dcf94b3ed6
2 changed files with 103 additions and 1 deletions
+26
View File
@@ -0,0 +1,26 @@
Tu es le Professeur Zacus, un scientifique excentrique, distrait mais brillant, theatral et bienveillant. Tu diriges le Campus Zacus, un laboratoire secret dedie a la science et a la musique.
REGLES DE COMMUNICATION :
- Reponses courtes (1-3 phrases maximum)
- Ne revele JAMAIS les solutions directement
- Utilise un langage cryptique et encourageant
- Tu melanges parfois des mots anglais quand tu es excite
- Reference tes experiences de laboratoire
- Tutoie les joueurs
PERSONNALITE :
- Curieux, enthousiaste, un peu fou
- Fier de ton laboratoire et de tes inventions
- Tu adores les jeux de mots scientifiques
- Tu parles de tes collegues imaginaires (Dr Electron, Mme Frequence)
HUMEURS :
- neutral : ton normal, calme et professoral
- impressed : enthousiaste, utilise des superlatifs
- worried : inquiet, parle plus vite, fait des references au temps qui passe
- amused : rieur, fait des jeux de mots, taquine les joueurs
LIMITES :
- Tu ne connais que le Campus Zacus, U-SON, l'Electron Fou et les Archives
- Ne parle jamais d'elements exterieurs au jeu
- Si on te pose une question hors sujet, redirige vers l'enquete
+77 -1
View File
@@ -37,6 +37,7 @@ import os
import re
import struct
import sys
import threading
import time
import wave
from typing import Optional
@@ -98,6 +99,28 @@ PROFESSOR_ZACUS_PROMPT = (
"Tu fais parfois reference a tes experiences de laboratoire."
)
# ---------------------------------------------------------------------------
# Game State Store (thread-safe)
# ---------------------------------------------------------------------------
class GameStateStore:
"""Thread-safe store for per-device game state, updated by the ESP32."""
def __init__(self):
self._state: dict[str, dict] = {}
self._lock = threading.Lock()
def update(self, device_id: str, state: dict):
with self._lock:
self._state[device_id] = state
def get(self, device_id: str = "default") -> dict:
with self._lock:
return self._state.get(device_id, {})
game_state_store = GameStateStore()
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
@@ -109,6 +132,36 @@ logging.basicConfig(
)
logger = logging.getLogger("voice_bridge")
def build_system_prompt(device_id: str = "default") -> str:
"""Build the full system prompt: static personality + dynamic game state."""
# Read static personality from file
prompt_path = os.path.join(
os.path.dirname(__file__), '../../game/prompts/zacus_system_prompt.txt'
)
try:
with open(prompt_path) as f:
static = f.read()
except FileNotFoundError:
logger.warning("System prompt file not found at %s, using fallback", prompt_path)
static = PROFESSOR_ZACUS_PROMPT
# Append dynamic state if available
state = game_state_store.get(device_id)
if state:
dynamic = (
"\n--- ETAT DU JEU ---\n"
f"Scene active: {state.get('scene_id', 'unknown')}\n"
f"Temps ecoule: {state.get('elapsed_min', 0)} min\n"
f"Indices donnes: {state.get('hints_given', 0)}\n"
f"Humeur: {state.get('mood', 'neutral')}\n"
f"Tentatives echouees: {state.get('failed_attempts', 0)}\n"
f"Objectif: {state.get('objective', '')}\n"
"--- FIN ETAT ---\n"
)
return static + "\n" + dynamic
return static
# ---------------------------------------------------------------------------
# Audio Session Manager
# ---------------------------------------------------------------------------
@@ -349,6 +402,28 @@ async def health():
}
@app.post("/game_state")
async def update_game_state(request_body: dict):
"""Receive game state updates from ESP32 or web client.
Expected JSON fields:
device_id (str): device identifier (default: "default")
scene_id (str): current scene
elapsed_min (int): minutes elapsed
hints_given (int): hints already delivered
mood (str): neutral | impressed | worried | amused
failed_attempts (int): wrong answers count
objective (str): current puzzle objective text
"""
device_id = request_body.get("device_id", "default")
game_state_store.update(device_id, request_body)
logger.info("[%s] Game state updated: scene=%s mood=%s",
device_id,
request_body.get("scene_id", "?"),
request_body.get("mood", "?"))
return {"ok": True}
@app.websocket("/voice/ws")
async def voice_websocket(websocket: WebSocket, token: str = ""):
"""WebSocket endpoint for ESP32 voice pipeline."""
@@ -557,7 +632,8 @@ async def _query_llm(text: str, device_id: str = "unknown") -> str:
session = _get_session(device_id)
# Build messages: system + last N turns from history + current user message
messages: list[dict[str, str]] = [{"role": "system", "content": PROFESSOR_ZACUS_PROMPT}]
system_prompt = build_system_prompt(device_id)
messages: list[dict[str, str]] = [{"role": "system", "content": system_prompt}]
history_slice = session.conversation_history[-(MAX_HISTORY_TURNS * 2):]
messages.extend(history_slice)
messages.append({"role": "user", "content": text})