abc123 / crossword-app /backend-py /src /services /crossword_generator.py
vimalk78's picture
feat: add frontend controls for similarity temperature and difficulty weight
5686111
"""
Fixed Crossword Generator - Ported from working JavaScript implementation.
"""
import asyncio
import json
import logging
import random
import time
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple
logger = logging.getLogger(__name__)
class CrosswordGenerator:
def __init__(self, thematic_service=None):
self.max_attempts = 100
self.min_words = 6
self.thematic_service = thematic_service
async def generate_puzzle(self, topics: List[str], difficulty: str = "medium", custom_sentence: str = None, multi_theme: bool = True, requested_words: int = 10, advanced_params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
"""
Generate a complete crossword puzzle.
"""
try:
sentence_info = f", custom sentence: '{custom_sentence}'" if custom_sentence else ""
logger.info(f"🎯 Generating puzzle for topics: {topics}, difficulty: {difficulty}{sentence_info}, requested words: {requested_words}")
# Get words from thematic AI service
words, debug_data = await self._select_words(topics, difficulty, custom_sentence, multi_theme, requested_words, advanced_params)
if len(words) < self.min_words:
logger.error(f"❌ Not enough words: {len(words)} < {self.min_words}")
raise Exception(f"Not enough words generated: {len(words)} < {self.min_words}")
# Create grid
grid_result = self._create_grid(words)
if not grid_result:
logger.error("❌ Grid creation failed")
raise Exception("Could not create crossword grid")
logger.info(f"✅ Generated crossword with {len(grid_result['placed_words'])} words")
# Build result with optional debug data
result = {
"grid": grid_result["grid"],
"clues": grid_result["clues"],
"metadata": {
"topics": topics,
"difficulty": difficulty,
"wordCount": len(grid_result["placed_words"]),
"size": len(grid_result["grid"]),
"aiGenerated": True
}
}
# Add debug data if available
if debug_data is not None:
result["debug"] = debug_data
logger.info(f"🐛 Debug data included in puzzle response")
return result
except Exception as e:
logger.error(f"❌ Error generating puzzle: {e}")
raise
async def _select_words(self, topics: List[str], difficulty: str, custom_sentence: str = None, multi_theme: bool = True, requested_words: int = 10, advanced_params: Dict[str, Any] = None) -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Select words for the crossword using thematic AI service.
Returns:
Tuple of (words, debug_data) where debug_data is None if debug is disabled
"""
if not self.thematic_service:
raise Exception("Thematic service is required for word generation")
logger.info(f"🎯 Using thematic AI service for word generation with {requested_words} requested words")
# Use the dedicated crossword method for better word selection
result = await self.thematic_service.find_words_for_crossword(topics, difficulty, requested_words, custom_sentence, multi_theme, advanced_params)
# Extract words and debug data from new format
words = result["words"]
debug_data = result.get("debug", None)
if len(words) < self.min_words:
raise Exception(f"Thematic service generated insufficient words: {len(words)} < {self.min_words}")
logger.info(f"✅ Thematic service generated {len(words)} words")
return self._sort_words_for_crossword(words), debug_data
def _sort_words_for_crossword(self, words: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Sort words by crossword suitability."""
scored_words = []
for word_obj in words:
word = word_obj["word"].upper()
score = 0
# Strongly prefer shorter words for crossword viability
if 3 <= len(word) <= 5:
score += 20 # Short words get highest priority
elif 6 <= len(word) <= 7:
score += 15 # Medium words get good priority
elif len(word) == 8:
score += 8 # Long words get lower priority
elif len(word) == 9:
score += 4 # Very long words get much lower priority
elif len(word) >= 10:
score += 1 # Extremely long words get minimal priority
# Bonus for common letters
common_letters = ['E', 'A', 'R', 'I', 'O', 'T', 'N', 'S']
for letter in word:
if letter in common_letters:
score += 1
# Vowel distribution bonus
vowels = ['A', 'E', 'I', 'O', 'U']
vowel_count = sum(1 for letter in word if letter in vowels)
score += vowel_count
# Penalty for very long words to discourage their selection
if len(word) >= 9:
score -= 5
scored_words.append({**word_obj, "crossword_score": score})
# Sort by score with some randomization
scored_words.sort(key=lambda w: w["crossword_score"] + random.randint(-2, 2), reverse=True)
return scored_words
def _create_grid(self, words: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Create crossword grid using backtracking algorithm."""
if not words:
logger.error(f"❌ No words provided to grid generator")
return None
logger.info(f"🎯 Creating crossword grid with {len(words)} words")
# Debug: log the structure of words
logger.info(f"🔍 Word structures: {[type(w) for w in words[:3]]}")
if words:
logger.info(f"🔍 First word sample: {words[0]}")
# Sort words by length (longest first) - keeping objects aligned
try:
# Create paired list of (word_string, word_object)
word_pairs = []
for i, w in enumerate(words):
if isinstance(w, dict) and "word" in w:
word_pairs.append((w["word"].upper(), w))
elif isinstance(w, str):
# Create dict for string-only words
word_obj = {"word": w.upper(), "clue": f"Clue for {w.upper()}"}
word_pairs.append((w.upper(), word_obj))
else:
logger.warning(f"⚠️ Unexpected word format at index {i}: {w}")
# Sort pairs by word length (longest first)
word_pairs.sort(key=lambda pair: len(pair[0]), reverse=True)
# Extract sorted lists
word_list = [pair[0] for pair in word_pairs]
sorted_word_objs = [pair[1] for pair in word_pairs]
logger.info(f"🎯 Processed {len(word_list)} words for grid: {word_list[:5]}")
except Exception as e:
logger.error(f"❌ Error processing words: {e}")
return None
size = self._calculate_grid_size(word_list)
# Try multiple attempts
for attempt in range(3):
current_size = size + attempt
try:
logger.info(f"🔧 Attempt {attempt + 1}: word_list length={len(word_list)}, sorted_word_objs length={len(sorted_word_objs)}")
result = self._place_words_in_grid(word_list, sorted_word_objs, current_size)
if result:
return result
except Exception as e:
logger.error(f"❌ Grid placement attempt {attempt + 1} failed: {e}")
import traceback
traceback.print_exc()
# Try with fewer words
if len(word_list) > 7:
reduced_words = word_list[:len(word_list) - 1]
reduced_word_objs = sorted_word_objs[:len(reduced_words)]
try:
logger.info(f"🔧 Reduced attempt {attempt + 1}: reduced_words length={len(reduced_words)}, reduced_word_objs length={len(reduced_word_objs)}")
result = self._place_words_in_grid(reduced_words, reduced_word_objs, current_size)
if result:
return result
except Exception as e:
logger.error(f"❌ Reduced grid placement attempt {attempt + 1} failed: {e}")
import traceback
traceback.print_exc()
# Last resort: simple cross with 2 words
if len(word_list) >= 2:
return self._create_simple_cross(word_list[:2], sorted_word_objs[:2])
return None
def _calculate_grid_size(self, words: List[str]) -> int:
"""Calculate appropriate grid size with more generous spacing."""
total_chars = sum(len(word) for word in words)
longest_word = max(len(word) for word in words) if words else 8
# More generous grid size calculation
base_size = int((total_chars * 2.0) ** 0.5) # Increased multiplier from 1.5 to 2.0
return max(
base_size,
longest_word + 4, # Add padding to longest word
12 # Minimum grid size increased from 8 to 12
)
def _place_words_in_grid(self, word_list: List[str], word_objs: List[Dict[str, Any]], size: int) -> Optional[Dict[str, Any]]:
"""Place words in grid using backtracking."""
logger.info(f"🔧 _place_words_in_grid: word_list={len(word_list)}, word_objs={len(word_objs)}, size={size}")
grid = [["." for _ in range(size)] for _ in range(size)]
placed_words = []
start_time = time.time()
timeout = 5.0 # 5 second timeout
try:
if self._backtrack_placement(grid, word_list, word_objs, 0, placed_words, start_time, timeout):
logger.info(f"🔧 Backtrack successful, trimming grid...")
trimmed = self._trim_grid(grid, placed_words)
logger.info(f"🔧 Grid trimmed, generating clues...")
# Generate clues first so we can display them with positions
clues_data = self._generate_clues_data(word_objs, trimmed["placed_words"])
logger.info(f"🔧 Clues generated, assigning proper crossword numbers...")
# Fix numbering based on grid position (reading order) and log with clues
numbered_words, clues = self._assign_numbers_and_clues(trimmed["placed_words"], clues_data)
return {
"grid": trimmed["grid"],
"placed_words": numbered_words,
"clues": clues
}
else:
logger.info(f"🔧 Backtrack failed")
return None
except Exception as e:
logger.error(f"❌ Error in _place_words_in_grid: {e}")
import traceback
traceback.print_exc()
return None
def _backtrack_placement(self, grid: List[List[str]], word_list: List[str], word_objs: List[Dict[str, Any]],
word_index: int, placed_words: List[Dict[str, Any]], start_time: float,
timeout: float, call_count: int = 0) -> bool:
"""Backtracking algorithm for word placement."""
# Timeout check
if call_count % 50 == 0 and time.time() - start_time > timeout:
return False
if word_index >= len(word_list):
return True
word = word_list[word_index]
size = len(grid)
# First word: place horizontally in center
if word_index == 0:
center_row = size // 2
center_col = (size - len(word)) // 2
if self._can_place_word(grid, word, center_row, center_col, "horizontal"):
original_state = self._place_word(grid, word, center_row, center_col, "horizontal")
placed_words.append({
"word": word,
"row": center_row,
"col": center_col,
"direction": "horizontal",
"number": 1
})
if self._backtrack_placement(grid, word_list, word_objs, word_index + 1, placed_words, start_time, timeout, call_count + 1):
return True
self._remove_word(grid, original_state)
placed_words.pop()
return False
# Subsequent words: find intersections
all_placements = self._find_all_intersection_placements(grid, word, placed_words)
all_placements.sort(key=lambda p: p["score"], reverse=True)
for placement in all_placements:
row, col, direction = placement["row"], placement["col"], placement["direction"]
if self._can_place_word(grid, word, row, col, direction):
original_state = self._place_word(grid, word, row, col, direction)
placed_words.append({
"word": word,
"row": row,
"col": col,
"direction": direction,
"number": word_index + 1
})
if self._backtrack_placement(grid, word_list, word_objs, word_index + 1, placed_words, start_time, timeout, call_count + 1):
return True
self._remove_word(grid, original_state)
placed_words.pop()
return False
def _can_place_word(self, grid: List[List[str]], word: str, row: int, col: int, direction: str) -> bool:
"""Check if word can be placed at position."""
size = len(grid)
# Check boundaries
if row < 0 or col < 0 or row >= size or col >= size:
return False
if direction == "horizontal":
if col + len(word) > size:
return False
# CRITICAL: Check word boundaries - no letters immediately before/after
if col > 0 and grid[row][col - 1] != ".":
return False # Word would have a preceding letter
if col + len(word) < size and grid[row][col + len(word)] != ".":
return False # Word would have a trailing letter
# Check each letter position
for i, letter in enumerate(word):
check_row = row
check_col = col + i
if check_row >= size or check_col >= size or check_row < 0 or check_col < 0:
return False
current_cell = grid[check_row][check_col]
if current_cell != "." and current_cell != letter:
return False
# For empty cells, check perpendicular validity
if current_cell == ".":
if not self._is_valid_perpendicular_placement(grid, letter, check_row, check_col, "vertical"):
return False
else: # vertical
if row + len(word) > size:
return False
# CRITICAL: Check word boundaries - no letters immediately before/after
if row > 0 and grid[row - 1][col] != ".":
return False # Word would have a preceding letter
if row + len(word) < size and grid[row + len(word)][col] != ".":
return False # Word would have a trailing letter
# Check each letter position
for i, letter in enumerate(word):
check_row = row + i
check_col = col
if check_row >= size or check_col >= size or check_row < 0 or check_col < 0:
return False
current_cell = grid[check_row][check_col]
if current_cell != "." and current_cell != letter:
return False
# For empty cells, check perpendicular validity
if current_cell == ".":
if not self._is_valid_perpendicular_placement(grid, letter, check_row, check_col, "horizontal"):
return False
return True
def _is_valid_perpendicular_placement(self, grid: List[List[str]], letter: str, row: int, col: int, check_direction: str) -> bool:
"""Check if placing a letter would create valid perpendicular word boundaries."""
size = len(grid)
if check_direction == "vertical":
# Check if placing this letter would create an invalid vertical sequence
has_above = row > 0 and grid[row - 1][col] != "."
has_below = row < size - 1 and grid[row + 1][col] != "."
# Don't allow this letter to extend an existing vertical word
# unless it's exactly at an intersection point with matching letters
if has_above or has_below:
return grid[row][col] == letter
else: # horizontal
# Check if placing this letter would create an invalid horizontal sequence
has_left = col > 0 and grid[row][col - 1] != "."
has_right = col < size - 1 and grid[row][col + 1] != "."
# Don't allow this letter to extend an existing horizontal word
# unless it's exactly at an intersection point with matching letters
if has_left or has_right:
return grid[row][col] == letter
return True
def _place_word(self, grid: List[List[str]], word: str, row: int, col: int, direction: str) -> List[Dict[str, Any]]:
"""Place word in grid and return original state."""
original_state = []
size = len(grid)
if direction == "horizontal":
for i, letter in enumerate(word):
check_row = row
check_col = col + i
if check_row >= size or check_col >= size or check_row < 0 or check_col < 0:
raise IndexError(f"Grid index out of range: [{check_row}][{check_col}] in grid of size {size}")
original_state.append({
"row": check_row,
"col": check_col,
"value": grid[check_row][check_col]
})
grid[check_row][check_col] = letter
else:
for i, letter in enumerate(word):
check_row = row + i
check_col = col
if check_row >= size or check_col >= size or check_row < 0 or check_col < 0:
raise IndexError(f"Grid index out of range: [{check_row}][{check_col}] in grid of size {size}")
original_state.append({
"row": check_row,
"col": check_col,
"value": grid[check_row][check_col]
})
grid[check_row][check_col] = letter
return original_state
def _remove_word(self, grid: List[List[str]], original_state: List[Dict[str, Any]]):
"""Remove word from grid."""
size = len(grid)
for state in original_state:
check_row = state["row"]
check_col = state["col"]
if check_row >= size or check_col >= size or check_row < 0 or check_col < 0:
raise IndexError(f"Grid index out of range: [{check_row}][{check_col}] in grid of size {size}")
grid[check_row][check_col] = state["value"]
def _find_all_intersection_placements(self, grid: List[List[str]], word: str, placed_words: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Find all possible intersection placements for a word."""
placements = []
for placed_word in placed_words:
intersections = self._find_word_intersections(word, placed_word["word"])
for intersection in intersections:
word_pos, placed_pos = intersection["word_pos"], intersection["placed_pos"]
placement_info = self._calculate_intersection_placement(word, word_pos, placed_word, placed_pos)
if placement_info:
score = self._calculate_placement_score(grid, word, placement_info, placed_words)
placements.append({
**placement_info,
"score": score
})
return placements
def _find_word_intersections(self, word1: str, word2: str) -> List[Dict[str, int]]:
"""Find letter intersections between two words."""
intersections = []
for i, letter1 in enumerate(word1):
for j, letter2 in enumerate(word2):
if letter1 == letter2:
intersections.append({
"word_pos": i,
"placed_pos": j
})
return intersections
def _calculate_intersection_placement(self, new_word: str, new_word_pos: int,
placed_word: Dict[str, Any], placed_word_pos: int) -> Optional[Dict[str, Any]]:
"""Calculate where new word should be placed for intersection."""
placed_row, placed_col = placed_word["row"], placed_word["col"]
placed_direction = placed_word["direction"]
# Find intersection point in grid
if placed_direction == "horizontal":
intersection_row = placed_row
intersection_col = placed_col + placed_word_pos
else:
intersection_row = placed_row + placed_word_pos
intersection_col = placed_col
# Calculate new word position
new_direction = "vertical" if placed_direction == "horizontal" else "horizontal"
if new_direction == "horizontal":
new_row = intersection_row
new_col = intersection_col - new_word_pos
else:
new_row = intersection_row - new_word_pos
new_col = intersection_col
return {
"row": new_row,
"col": new_col,
"direction": new_direction
}
def _calculate_placement_score(self, grid: List[List[str]], word: str, placement: Dict[str, Any],
placed_words: List[Dict[str, Any]]) -> int:
"""Score a placement for quality."""
row, col, direction = placement["row"], placement["col"], placement["direction"]
grid_size = len(grid)
score = 100 # Base score for intersection
# Count intersections - with bounds checking
intersection_count = 0
if direction == "horizontal":
for i, letter in enumerate(word):
target_row = row
target_col = col + i
# Check bounds before accessing grid
if (0 <= target_row < grid_size and
0 <= target_col < grid_size and
grid[target_row][target_col] == letter):
intersection_count += 1
else: # vertical
for i, letter in enumerate(word):
target_row = row + i
target_col = col
# Check bounds before accessing grid
if (0 <= target_row < grid_size and
0 <= target_col < grid_size and
grid[target_row][target_col] == letter):
intersection_count += 1
score += intersection_count * 200
# Bonus for central placement
center = grid_size // 2
distance_from_center = abs(row - center) + abs(col - center)
score -= distance_from_center * 5
return score
def _trim_grid(self, grid: List[List[str]], placed_words: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Trim grid to remove excess empty space."""
if not placed_words:
return {"grid": grid, "placed_words": placed_words}
# Find bounds
min_row = min_col = len(grid)
max_row = max_col = -1
for word in placed_words:
row, col, direction, word_text = word["row"], word["col"], word["direction"], word["word"]
min_row = min(min_row, row)
min_col = min(min_col, col)
if direction == "horizontal":
max_row = max(max_row, row)
max_col = max(max_col, col + len(word_text) - 1)
else:
max_row = max(max_row, row + len(word_text) - 1)
max_col = max(max_col, col)
# Add padding with proper bounds checking
min_row = max(0, min_row - 1)
min_col = max(0, min_col - 1)
max_row = min(len(grid) - 1, max_row + 1)
max_col = min(len(grid[0]) - 1, max_col + 1)
# Ensure bounds are valid
max_row = min(max_row, len(grid) - 1)
max_col = min(max_col, len(grid[0]) - 1)
# Create trimmed grid
trimmed_grid = []
for r in range(min_row, max_row + 1):
row = []
for c in range(min_col, max_col + 1):
# Double-check bounds before accessing
if r < 0 or r >= len(grid) or c < 0 or c >= len(grid[0]):
logger.error(f"Invalid bounds: r={r}, c={c}, grid_size={len(grid)}x{len(grid[0])}")
continue
row.append(grid[r][c])
trimmed_grid.append(row)
# Update word positions
updated_words = []
for word in placed_words:
updated_words.append({
**word,
"row": word["row"] - min_row,
"col": word["col"] - min_col
})
return {"grid": trimmed_grid, "placed_words": updated_words}
def _assign_crossword_numbers(self, placed_words: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Assign proper crossword numbers based on grid position (reading order).
Crossword numbering rules:
1. Numbers are assigned to word starting positions
2. Reading order: top-to-bottom, then left-to-right
3. A single number can be shared by both across and down words starting at the same cell
"""
if not placed_words:
return placed_words
# Collect all unique starting positions
starting_positions = {} # (row, col) -> list of words starting at that position
for word in placed_words:
pos_key = (word["row"], word["col"])
if pos_key not in starting_positions:
starting_positions[pos_key] = []
starting_positions[pos_key].append(word)
# Sort positions by reading order (top-to-bottom, left-to-right)
sorted_positions = sorted(starting_positions.keys(), key=lambda pos: (pos[0], pos[1]))
# Assign numbers
numbered_words = []
for i, pos in enumerate(sorted_positions):
number = i + 1 # Crossword numbers start at 1
# Assign this number to all words starting at this position
for word in starting_positions[pos]:
numbered_word = word.copy()
numbered_word["number"] = number
numbered_words.append(numbered_word)
logger.info(f"🔢 Assigned crossword numbers: {len(sorted_positions)} unique starting positions (legacy function)")
return numbered_words
def _generate_clues_data(self, word_objs: List[Dict[str, Any]], placed_words: List[Dict[str, Any]]) -> Dict[str, str]:
"""Generate a mapping of words to their clues."""
clues_map = {}
for placed_word in placed_words:
# Find matching word object
word_obj = next((w for w in word_objs if w["word"].upper() == placed_word["word"]), None)
if word_obj and "clue" in word_obj:
clues_map[placed_word["word"]] = word_obj["clue"]
else:
clues_map[placed_word["word"]] = f"Clue for {placed_word['word']}"
return clues_map
def _assign_numbers_and_clues(self, placed_words: List[Dict[str, Any]], clues_data: Dict[str, str]) -> tuple:
"""
Assign proper crossword numbers based on grid position and create clues with enhanced logging.
Returns: (numbered_words, clues_list)
"""
if not placed_words:
return placed_words, []
# Collect all unique starting positions
starting_positions = {} # (row, col) -> list of words starting at that position
for word in placed_words:
pos_key = (word["row"], word["col"])
if pos_key not in starting_positions:
starting_positions[pos_key] = []
starting_positions[pos_key].append(word)
# Sort positions by reading order (top-to-bottom, left-to-right)
sorted_positions = sorted(starting_positions.keys(), key=lambda pos: (pos[0], pos[1]))
# Assign numbers and create both numbered words and clues
numbered_words = []
clues = []
logger.info(f"🔢 Assigned crossword numbers: {len(sorted_positions)} unique starting positions")
for i, pos in enumerate(sorted_positions):
number = i + 1 # Crossword numbers start at 1
# Process all words starting at this position
for word in starting_positions[pos]:
numbered_word = word.copy()
numbered_word["number"] = number
numbered_words.append(numbered_word)
# Create clue object
clue_text = clues_data.get(word["word"], f"Clue for {word['word']}")
direction = "across" if word["direction"] == "horizontal" else "down"
clue = {
"number": number,
"word": word["word"],
"text": clue_text,
"direction": direction,
"position": {"row": word["row"], "col": word["col"]}
}
clues.append(clue)
# Enhanced logging with clues
logger.info(f" {number} {direction}: {word['word']} at ({word['row']}, {word['col']}) - \"{clue_text}\"")
return numbered_words, clues
def _create_simple_cross(self, word_list: List[str], word_objs: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Create simple cross with two words."""
if len(word_list) < 2:
return None
word1, word2 = word_list[0], word_list[1]
intersections = self._find_word_intersections(word1, word2)
if not intersections:
return None
# Use first intersection
intersection = intersections[0]
size = max(len(word1), len(word2)) + 4
grid = [["." for _ in range(size)] for _ in range(size)]
# Place first word horizontally in center
center_row = size // 2
center_col = (size - len(word1)) // 2
for i, letter in enumerate(word1):
check_row = center_row
check_col = center_col + i
if check_row >= size or check_col >= size or check_row < 0 or check_col < 0:
raise IndexError(f"Grid index out of range: [{check_row}][{check_col}] in grid of size {size}")
grid[check_row][check_col] = letter
# Place second word vertically at intersection
intersection_col = center_col + intersection["word_pos"]
word2_start_row = center_row - intersection["placed_pos"]
for i, letter in enumerate(word2):
check_row = word2_start_row + i
check_col = intersection_col
if check_row >= size or check_col >= size or check_row < 0 or check_col < 0:
raise IndexError(f"Grid index out of range: [{check_row}][{check_col}] in grid of size {size}")
grid[check_row][check_col] = letter
placed_words = [
{"word": word1, "row": center_row, "col": center_col, "direction": "horizontal", "number": 1},
{"word": word2, "row": word2_start_row, "col": intersection_col, "direction": "vertical", "number": 2}
]
trimmed = self._trim_grid(grid, placed_words)
# Generate clues first, then assign numbers with enhanced logging
clues_data = self._generate_clues_data(word_objs[:2], trimmed["placed_words"])
numbered_words, clues = self._assign_numbers_and_clues(trimmed["placed_words"], clues_data)
return {
"grid": trimmed["grid"],
"placed_words": numbered_words,
"clues": clues
}
def _generate_clues(self, word_objs: List[Dict[str, Any]], placed_words: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Generate clues for placed words (legacy function - use _assign_numbers_and_clues for better logging)."""
clues = []
try:
for placed_word in placed_words:
# Find matching word object
word_obj = next((w for w in word_objs if w["word"].upper() == placed_word["word"]), None)
if word_obj and "clue" in word_obj:
clue_text = word_obj["clue"]
else:
clue_text = f"Clue for {placed_word['word']}"
clues.append({
"number": placed_word["number"],
"word": placed_word["word"],
"text": clue_text,
"direction": "across" if placed_word["direction"] == "horizontal" else "down",
"position": {"row": placed_word["row"], "col": placed_word["col"]}
})
return clues
except Exception as e:
logger.error(f"❌ Error in _generate_clues: {e}")
import traceback
traceback.print_exc()
raise