Spaces:
Sleeping
Sleeping
File size: 9,432 Bytes
07ed12b 244fb73 07ed12b 29a88f8 68097bf 07ed12b 5c0862e 5503c3f 07ed12b dd96d2f 07ed12b 29a88f8 07ed12b dd96d2f 07ed12b 29a88f8 07ed12b dd96d2f 5c0862e dd96d2f 07ed12b dd96d2f 07ed12b dd96d2f 29a88f8 dd96d2f 07ed12b dd96d2f 29a88f8 07ed12b dd96d2f 29a88f8 dd96d2f 29a88f8 dd96d2f 07ed12b 244fb73 07ed12b dd96d2f 07ed12b dd96d2f 07ed12b dd96d2f 07ed12b dd96d2f 07ed12b dd96d2f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | """
Voice command parser — Mistral LLM interprets transcribed text
and returns structured GameActions.
The player's current game state is injected as context so Mistral
can understand references like "envoie tous mes marines" or
"construis quelque chose pour produire des tanks".
"""
from __future__ import annotations
import json
import logging
from mistralai import Mistral
from config import MISTRAL_API_KEY, MISTRAL_CHAT_MODEL
from game.commands import ActionType, GameAction, ParsedCommand
from game.state import PlayerState
log = logging.getLogger(__name__)
_SYSTEM_PROMPT = """\
Tu es l'interprète de commandes vocales d'un jeu de stratégie en temps réel (style StarCraft, race Terran).
Ton rôle est de convertir les instructions vocales d'un joueur en actions JSON structurées.
=== UNITÉS DISPONIBLES ===
scv, marine, medic, goliath, tank, wraith
=== BÂTIMENTS DISPONIBLES ===
supply_depot, barracks, engineering_bay, refinery, factory, armory, starport
=== TYPES D'ACTIONS ===
- build : construire un bâtiment → champs: building_type
- train : entraîner des unités → champs: unit_type, count (défaut 1)
- move : déplacer des unités → champs: unit_selector, target_zone, count (optionnel: limite le nb d'unités sélectionnées, ex: "envoie 2 SCVs" → count:2)
- attack : attaquer une zone → champs: unit_selector, target_zone, count (optionnel)
- siege : tank en mode siège → champs: unit_selector (optionnel), count (optionnel)
- unsiege : retirer le mode siège → champs: unit_selector (optionnel), count (optionnel)
- cloak : activer camouflage → champs: unit_selector (optionnel), count (optionnel)
- decloak : désactiver camouflage → champs: unit_selector (optionnel), count (optionnel)
- gather : collecter ressources → champs: unit_selector, resource_type ("minerals"|"gas"), count (optionnel)
- stop : arrêter des unités → champs: unit_selector, count (optionnel)
- patrol : patrouiller → champs: unit_selector, target_zone, count (optionnel)
- defend : défendre une base (unités militaires patrouillent en cercle autour de la zone en attaquant automatiquement) → champs: unit_selector (optionnel), target_zone (défaut: my_base), count (optionnel). Utiliser quand le joueur dit "défends ma base", "protège la base", "garde la base", "defend my base", "hold the base", etc.
- query : information sur l'état → aucun champ requis
- query_units : lister les IDs d'unités (pour affectation aux groupes) → champs optionnels: target_zone, unit_type
- assign_to_group : affecter des unités à un groupe (1, 2 ou 3) → champs: group_index (1-3). Utilise le résultat du query_units précédent si unit_ids vide. Pour "mets mes marines dans le groupe 1", génère d'abord query_units (unit_type: marine) puis assign_to_group (group_index: 1).
- resign : abandonner la partie (l'adversaire gagne) → aucun champ requis. Utiliser quand le joueur dit "j'abandonne", "je capitule", "je me rends", "I resign", "I give up", etc.
=== SÉLECTEURS D'UNITÉS ===
all, all_military, all_marines, all_medics, all_goliaths, all_tanks, all_wraiths, all_scv, idle_scv, most_damaged
=== ZONES CIBLES ===
my_base, enemy_base, center, top_left, top_right, bottom_left, bottom_right, front_line{resource_zones}
Lieux nommés visibles sur la carte (utiliser le slug comme target_zone) :{landmark_zones}
Positions horloges (cadran 12h sur la carte) :
12h = haut-centre, 3h = centre-droit, 6h = bas-centre, 9h = centre-gauche
Valeurs intermédiaires acceptées : 1h, 2h, 4h, 5h, 7h, 8h, 10h, 11h, 1h30, 2h30, etc.
Exemples : "envoie les marines à 3h" → target_zone: "3h" ; "attaque à 11h" → target_zone: "11h"
=== ÉTAT ACTUEL DU JOUEUR ===
{player_state}
=== CONSIGNES ===
- Réponds UNIQUEMENT avec un JSON valide, aucun texte avant ou après.
- Une commande peut générer PLUSIEURS actions (ex: "entraîne 4 marines et attaque la base").
- Le champ "feedback_template" est une phrase courte DANS LA MÊME LANGUE que la commande, avec des placeholders pour les infos du jeu. Placeholders possibles: {n} (nombre d'unités), {zone}, {building}, {count}, {unit}, {resource}, {summary}, {mode}, {names}, {raw}. Ex: "Déplacement de {n} unités vers {zone}." ou "État: {summary}".
- Ajoute le champ "language" avec le code ISO de la langue: "fr", "en".
- Si la commande est incompréhensible, génère une action "query" avec feedback_template explicatif (ex: "Je n'ai pas compris. Voici ton état: {summary}").
=== FORMAT DE RÉPONSE ===
{
"actions": [
{
"type": "<action_type>",
"building_type": "<valeur ou omis>",
"unit_type": "<valeur ou omis>",
"count": <entier ou omis: pour train=nb à produire; pour move/attack/patrol/stop/gather/siege/cloak=nb max d'unités à sélectionner>,
"unit_selector": "<valeur ou omis>",
"target_zone": "<valeur ou omis>",
"resource_type": "<'minerals' ou 'gas', omis sinon>",
"group_index": <1, 2 ou 3 pour assign_to_group, omis sinon>,
"unit_ids": []
}
],
"feedback_template": "<phrase avec placeholders {n}, {zone}, etc. dans la langue du joueur>",
"language": "fr"
}
"""
async def parse(
transcription: str,
player: PlayerState,
resource_zones: list[str] | None = None,
landmarks: list[dict] | None = None,
) -> ParsedCommand:
"""
Send transcription + player state to Mistral and return parsed command.
Falls back to a query action if parsing fails.
resource_zones: list of zone names like ["mineral_1", ..., "geyser_1", ...]
sorted by proximity to the player's base.
landmarks: list of MAP_LANDMARKS dicts with slug/name/description keys.
"""
if not MISTRAL_API_KEY:
raise RuntimeError("MISTRAL_API_KEY not set")
client = Mistral(api_key=MISTRAL_API_KEY)
if resource_zones:
rz_str = ", " + ", ".join(resource_zones)
else:
rz_str = ""
if landmarks:
lm_lines = "\n" + "\n".join(
f' - {lm["slug"]} : {lm["name"]} ({lm["description"]})'
for lm in landmarks
)
else:
lm_lines = ""
system = (
_SYSTEM_PROMPT
.replace("{player_state}", player.summary())
.replace("{resource_zones}", rz_str)
.replace("{landmark_zones}", lm_lines)
)
response = await client.chat.complete_async(
model=MISTRAL_CHAT_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": transcription},
],
response_format={"type": "json_object"},
temperature=0.1,
)
raw = response.choices[0].message.content or "{}"
log.info("Mistral raw response: %s", raw[:300])
try:
data = json.loads(raw)
actions = [GameAction(**a) for a in data.get("actions", [])]
language = (data.get("language") or "fr").strip().lower() or "fr"
if language not in ("fr", "en"):
language = "fr"
feedback_template = data.get("feedback_template") or data.get("feedback") or "{summary}"
if not actions:
raise ValueError("Empty actions list")
return ParsedCommand(
actions=actions, feedback_template=feedback_template, language=language
)
except Exception as exc:
log.warning("Failed to parse Mistral response: %s — %s", exc, raw[:200])
lang = _detect_language(transcription)
fallback_template = (
"I didn't understand. Here is your status: {summary}"
if lang == "en"
else "Je n'ai pas compris. Voici ton état: {summary}"
)
return ParsedCommand(
actions=[GameAction(type=ActionType.QUERY)],
feedback_template=fallback_template,
language=lang,
)
def _detect_language(text: str) -> str:
"""Heuristic: if transcription looks like English, return 'en', else 'fr'."""
if not text or not text.strip():
return "fr"
lower = text.lower().strip()
en_words = {
"build", "train", "attack", "move", "send", "create", "gather", "stop",
"patrol", "go", "select", "unit", "units", "base", "minerals", "gas",
"barracks", "factory", "scv", "marine", "tank", "all", "my", "the",
}
words = set(lower.split())
if words & en_words:
return "en"
return "fr"
async def generate_feedback(error_key: str, language: str) -> str:
"""
Call the API to generate a short feedback message for an error, in the given language.
error_key: e.g. "game_not_in_progress"
"""
if not MISTRAL_API_KEY:
return "Game is not in progress." if language == "en" else "La partie n'est pas en cours."
client = Mistral(api_key=MISTRAL_API_KEY)
prompt = f"""Generate exactly one short sentence for a strategy game feedback.
Context/error: {error_key}.
Language: {language}.
Reply with only that sentence, no quotes, no explanation."""
response = await client.chat.complete_async(
model=MISTRAL_CHAT_MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=80,
)
text = (response.choices[0].message.content or "").strip()
return text or ("Game is not in progress." if language == "en" else "La partie n'est pas en cours.")
|