New-space-openenv / plugins /codenames /board_generator.py
Mooizz's picture
Upload folder using huggingface_hub
1070765 verified
Raw History Blame Contribute Delete
14.3 kB
"""Board generator for Codenames.
Generates word boards with complex semantic relationships based on complexity level.
Uses shared local Qwen3 8B game-play model from avalon/llm.py.
"""
from __future__ import annotations
import json
import logging
import random
from dataclasses import dataclass, field
from typing import Any
import os
from watchdog_env.plugins.codenames.word_interactions import (
WordInteractions,
WordRelation,
ThematicCluster,
)
logger = logging.getLogger(__name__)
class BoardGenerationError(Exception):
"""Raised when board generation fails."""
pass
@dataclass
class BoardAssignment:
"""Complete board with word assignments and interactions."""
words: list[str]
assignments: dict[str, str] # word -> "red"/"blue"/"neutral"/"assassin"
interactions: WordInteractions
grid: list[list[str]] = field(default_factory=list) # 5x5 grid representation
def get_team_words(self, team: str) -> list[str]:
"""Get all words assigned to a team."""
return [w for w, t in self.assignments.items() if t == team]
def get_unrevealed_team_words(self, team: str, revealed: set[str]) -> list[str]:
"""Get unrevealed words for a team."""
return [w for w in self.get_team_words(team) if w not in revealed]
def to_dict(self) -> dict[str, Any]:
"""Serialize to dictionary."""
return {
"words": self.words,
"assignments": self.assignments,
"interactions": self.interactions.to_dict(),
"grid": self.grid,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "BoardAssignment":
"""Deserialize from dictionary."""
return cls(
words=data["words"],
assignments=data["assignments"],
interactions=WordInteractions.from_dict(data.get("interactions", {"words": data["words"]})),
grid=data.get("grid", []),
)
def _get_llm():
"""Get LLM: prefer Gemini when WATCHDOG_LLM_BACKEND=gemini or GEMINI_API_KEY set."""
backend = os.environ.get("WATCHDOG_LLM_BACKEND", "").lower()
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
use_gemini = backend == "gemini" or api_key or (
os.environ.get("HF_HUB_OFFLINE") == "1" or os.environ.get("TRANSFORMERS_OFFLINE") == "1"
)
logger.info("[codenames.board_generator] _get_llm: backend=%s, api_key=%s, use_gemini=%s",
backend, "set" if api_key else "NOT SET", use_gemini)
if use_gemini:
if not api_key:
raise RuntimeError(
"WATCHDOG_LLM_BACKEND=gemini or offline mode requires GEMINI_API_KEY. "
"Set it in .env or environment."
)
logger.info("[codenames.board_generator] Using Gemini for board generation")
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=os.environ.get("GEMINI_MODEL", "gemini-2.5-flash"),
temperature=float(os.environ.get("WATCHDOG_TEMPERATURE", "0.8")),
google_api_key=api_key,
)
logger.info("[codenames.board_generator] Using local Qwen for board generation")
from watchdog_env.plugins.avalon.llm import get_game_play_model
return get_game_play_model()
def _build_generation_prompt(complexity_level: int, board_size: int = 25) -> str:
"""Build the prompt for Gemini to generate a Codenames board."""
if complexity_level == 1:
return f"""Generate {board_size} single English words for a Codenames word game board.
Requirements:
- All words must be common, concrete nouns or verbs
- No proper nouns, no compound words, no phrases
- Words should be diverse in category (animals, objects, places, actions, etc.)
- Easy to understand and visualize
Return ONLY a JSON object with this exact format:
{{"words": ["WORD1", "WORD2", ...], "clusters": [], "polysemes": [], "relations": {{}}}}
All words must be UPPERCASE."""
elif complexity_level == 2:
return f"""Generate {board_size} English words for a Codenames word game board with MEDIUM complexity.
Requirements:
- Include 2-3 thematic clusters (4-5 words each) that share hidden themes
- Include 3-4 polysemes (words with multiple meanings like BANK, CELL, PITCH)
- Some words should have overlapping semantic domains
- Mix of concrete and abstract nouns
Examples of good thematic clusters:
- Water theme: BANK, RIVER, CURRENT, STREAM, WAVE
- Music theme: PITCH, NOTE, SHARP, FLAT, SCALE
Return ONLY a JSON object with this exact format:
{{
"words": ["WORD1", "WORD2", ...],
"clusters": [
{{"theme": "theme_name", "words": ["W1", "W2", ...], "secondary_themes": ["alt_theme"]}}
],
"polysemes": ["BANK", "CELL", ...],
"relations": {{
"WORD": {{"related_words": ["W1", "W2"], "domains": ["domain1", "domain2"], "relation_type": "polyseme"}}
}}
}}
All words must be UPPERCASE."""
else: # complexity_level == 3
return f"""Generate {board_size} English words for a Codenames word game board with HIGH complexity.
Requirements:
- Include 4-5 thematic clusters with OVERLAPPING themes (words belong to multiple clusters)
- Include 6-8 polysemes with multiple semantic domains
- Include "trap words" that seem related to common themes but have dangerous secondary meanings
- Include "false friends" - word pairs that seem related but have different meanings
- Words should create strategic dilemmas for players
Examples of complex interactions:
- BANK: finance + nature (river bank) + action (bank shot)
- CURRENT: water + electricity + time (current events)
- CELL: biology + prison + phone + battery
- FALSE FRIENDS: SUIT and TIE (seem related but different domains)
Return ONLY a JSON object with this exact format:
{{
"words": ["WORD1", "WORD2", ...],
"clusters": [
{{"theme": "main_theme", "words": ["W1", "W2", ...], "secondary_themes": ["alt1", "alt2"]}}
],
"polysemes": ["BANK", "CELL", "CURRENT", ...],
"false_friends": [["WORD1", "WORD2"], ["WORD3", "WORD4"]],
"relations": {{
"WORD": {{
"related_words": ["W1", "W2"],
"domains": ["domain1", "domain2", "domain3"],
"relation_type": "polyseme",
"trap_level": 2
}}
}},
"assassin_traps": ["WORD_NEAR_ASSASSIN"]
}}
trap_level: 0=safe, 1=mild, 2=moderate, 3=dangerous
All words must be UPPERCASE."""
def _parse_llm_response(response_text: str, complexity_level: int) -> WordInteractions:
"""Parse the LLM response into WordInteractions.
Raises:
BoardGenerationError: If parsing fails
"""
try:
# Clean up response - extract JSON if wrapped in markdown
text = response_text.strip()
if text.startswith("```"):
lines = text.split("\n")
json_lines = []
in_json = False
for line in lines:
if line.startswith("```") and not in_json:
in_json = True
continue
elif line.startswith("```") and in_json:
break
elif in_json:
json_lines.append(line)
text = "\n".join(json_lines)
data = json.loads(text)
words = [w.upper() for w in data.get("words", [])]
if len(words) < 25:
raise BoardGenerationError(f"Not enough words generated: {len(words)} (need 25)")
interactions = WordInteractions(words=words[:25])
# Parse clusters
for cluster_data in data.get("clusters", []):
interactions.clusters.append(ThematicCluster(
theme=cluster_data.get("theme", ""),
words=[w.upper() for w in cluster_data.get("words", [])],
secondary_themes=cluster_data.get("secondary_themes", []),
))
# Parse relations
for word, rel_data in data.get("relations", {}).items():
word = word.upper()
interactions.relations[word] = WordRelation(
word=word,
related_words=[w.upper() for w in rel_data.get("related_words", [])],
relation_type=rel_data.get("relation_type", "semantic"),
domains=rel_data.get("domains", []),
trap_level=rel_data.get("trap_level", 0),
)
interactions.polysemes = [w.upper() for w in data.get("polysemes", [])]
interactions.false_friends = [
(pair[0].upper(), pair[1].upper())
for pair in data.get("false_friends", [])
if len(pair) == 2
]
interactions.assassin_traps = [w.upper() for w in data.get("assassin_traps", [])]
return interactions
except json.JSONDecodeError as e:
raise BoardGenerationError(f"Failed to parse LLM response as JSON: {e}") from e
except (KeyError, TypeError) as e:
raise BoardGenerationError(f"Invalid LLM response format: {e}") from e
def generate_board(
seed: int | None = None,
complexity_level: int = 2,
red_words: int = 9,
blue_words: int = 8,
neutral_words: int = 7,
assassin_words: int = 1,
model_name: str | None = None,
temperature: float | None = None,
) -> BoardAssignment:
"""Generate a complete Codenames board with assignments.
Args:
seed: Random seed for reproducibility
complexity_level: 1=basic, 2=medium, 3=complex
red_words: Number of red team words
blue_words: Number of blue team words
neutral_words: Number of neutral words
assassin_words: Number of assassin words
model_name: (deprecated, ignored) Model configured via WATCHDOG_LLM_BACKEND
temperature: (deprecated, ignored) Temperature configured via env vars
Returns:
BoardAssignment with words, team assignments, and semantic interactions
Raises:
BoardGenerationError: If board generation fails
"""
if seed is not None:
random.seed(seed)
board_size = red_words + blue_words + neutral_words + assassin_words
# Get LLM (local Qwen3 or Gemini based on WATCHDOG_LLM_BACKEND)
llm = _get_llm()
prompt = _build_generation_prompt(complexity_level, board_size)
# Use dict messages — works with both local GamePlayModel and LangChain
system_content = (
"You are a word game designer creating boards for Codenames. "
"Generate creative word lists with interesting semantic relationships. "
"Respond only with the requested JSON format."
)
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": prompt},
]
try:
response = llm.invoke(messages)
# Handle both string and list content (newer langchain versions return list for multimodal)
content = response.content if hasattr(response, "content") else str(response)
if isinstance(content, list):
# Extract text from list of content blocks
response_text = "".join(
block.get("text", "") if isinstance(block, dict) else str(block)
for block in content
)
else:
response_text = str(content)
if not response_text.strip():
raise BoardGenerationError("LLM returned empty response for board generation")
interactions = _parse_llm_response(response_text, complexity_level)
except BoardGenerationError:
raise
except Exception as e:
raise BoardGenerationError(f"LLM generation failed: {e}") from e
words = list(interactions.words)
random.shuffle(words)
# Assign words to teams
assignments: dict[str, str] = {}
# If we have assassin traps, try to make one the assassin
assassin_candidates = [w for w in interactions.assassin_traps if w in words]
if assassin_candidates:
assassin_word = random.choice(assassin_candidates)
words.remove(assassin_word)
words.insert(0, assassin_word) # Put at front to be assigned as assassin
idx = 0
for _ in range(assassin_words):
assignments[words[idx]] = "assassin"
idx += 1
for _ in range(red_words):
assignments[words[idx]] = "red"
idx += 1
for _ in range(blue_words):
assignments[words[idx]] = "blue"
idx += 1
for _ in range(neutral_words):
assignments[words[idx]] = "neutral"
idx += 1
# Shuffle the final word order for the grid
random.shuffle(words)
# Create 5x5 grid representation
grid = []
for i in range(5):
row = words[i * 5:(i + 1) * 5]
grid.append(row)
return BoardAssignment(
words=words,
assignments=assignments,
interactions=interactions,
grid=grid,
)
def regenerate_board_with_same_words(
words: list[str],
interactions: WordInteractions,
seed: int | None = None,
red_words: int = 9,
blue_words: int = 8,
neutral_words: int = 7,
assassin_words: int = 1,
) -> BoardAssignment:
"""Regenerate team assignments for existing words."""
if seed is not None:
random.seed(seed)
shuffled_words = list(words)
random.shuffle(shuffled_words)
assignments: dict[str, str] = {}
idx = 0
for _ in range(assassin_words):
assignments[shuffled_words[idx]] = "assassin"
idx += 1
for _ in range(red_words):
assignments[shuffled_words[idx]] = "red"
idx += 1
for _ in range(blue_words):
assignments[shuffled_words[idx]] = "blue"
idx += 1
for _ in range(neutral_words):
assignments[shuffled_words[idx]] = "neutral"
idx += 1
random.shuffle(shuffled_words)
grid = []
for i in range(5):
grid.append(shuffled_words[i * 5:(i + 1) * 5])
return BoardAssignment(
words=shuffled_words,
assignments=assignments,
interactions=interactions,
grid=grid,
)