|
|
""" |
|
|
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}") |
|
|
|
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
result = await self.thematic_service.find_words_for_crossword(topics, difficulty, requested_words, custom_sentence, multi_theme, advanced_params) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
if 3 <= len(word) <= 5: |
|
|
score += 20 |
|
|
elif 6 <= len(word) <= 7: |
|
|
score += 15 |
|
|
elif len(word) == 8: |
|
|
score += 8 |
|
|
elif len(word) == 9: |
|
|
score += 4 |
|
|
elif len(word) >= 10: |
|
|
score += 1 |
|
|
|
|
|
|
|
|
common_letters = ['E', 'A', 'R', 'I', 'O', 'T', 'N', 'S'] |
|
|
for letter in word: |
|
|
if letter in common_letters: |
|
|
score += 1 |
|
|
|
|
|
|
|
|
vowels = ['A', 'E', 'I', 'O', 'U'] |
|
|
vowel_count = sum(1 for letter in word if letter in vowels) |
|
|
score += vowel_count |
|
|
|
|
|
|
|
|
if len(word) >= 9: |
|
|
score -= 5 |
|
|
|
|
|
scored_words.append({**word_obj, "crossword_score": score}) |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
logger.info(f"🔍 Word structures: {[type(w) for w in words[:3]]}") |
|
|
if words: |
|
|
logger.info(f"🔍 First word sample: {words[0]}") |
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
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): |
|
|
|
|
|
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}") |
|
|
|
|
|
|
|
|
word_pairs.sort(key=lambda pair: len(pair[0]), reverse=True) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
base_size = int((total_chars * 2.0) ** 0.5) |
|
|
|
|
|
return max( |
|
|
base_size, |
|
|
longest_word + 4, |
|
|
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 |
|
|
|
|
|
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...") |
|
|
|
|
|
|
|
|
clues_data = self._generate_clues_data(word_objs, trimmed["placed_words"]) |
|
|
|
|
|
logger.info(f"🔧 Clues generated, assigning proper crossword numbers...") |
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
if row < 0 or col < 0 or row >= size or col >= size: |
|
|
return False |
|
|
|
|
|
if direction == "horizontal": |
|
|
if col + len(word) > size: |
|
|
return False |
|
|
|
|
|
|
|
|
if col > 0 and grid[row][col - 1] != ".": |
|
|
return False |
|
|
if col + len(word) < size and grid[row][col + len(word)] != ".": |
|
|
return False |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
if current_cell == ".": |
|
|
if not self._is_valid_perpendicular_placement(grid, letter, check_row, check_col, "vertical"): |
|
|
return False |
|
|
|
|
|
else: |
|
|
if row + len(word) > size: |
|
|
return False |
|
|
|
|
|
|
|
|
if row > 0 and grid[row - 1][col] != ".": |
|
|
return False |
|
|
if row + len(word) < size and grid[row + len(word)][col] != ".": |
|
|
return False |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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": |
|
|
|
|
|
has_above = row > 0 and grid[row - 1][col] != "." |
|
|
has_below = row < size - 1 and grid[row + 1][col] != "." |
|
|
|
|
|
|
|
|
|
|
|
if has_above or has_below: |
|
|
return grid[row][col] == letter |
|
|
else: |
|
|
|
|
|
has_left = col > 0 and grid[row][col - 1] != "." |
|
|
has_right = col < size - 1 and grid[row][col + 1] != "." |
|
|
|
|
|
|
|
|
|
|
|
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"] |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
intersection_count = 0 |
|
|
if direction == "horizontal": |
|
|
for i, letter in enumerate(word): |
|
|
target_row = row |
|
|
target_col = col + i |
|
|
|
|
|
if (0 <= target_row < grid_size and |
|
|
0 <= target_col < grid_size and |
|
|
grid[target_row][target_col] == letter): |
|
|
intersection_count += 1 |
|
|
else: |
|
|
for i, letter in enumerate(word): |
|
|
target_row = row + i |
|
|
target_col = col |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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} |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
max_row = min(max_row, len(grid) - 1) |
|
|
max_col = min(max_col, len(grid[0]) - 1) |
|
|
|
|
|
|
|
|
trimmed_grid = [] |
|
|
for r in range(min_row, max_row + 1): |
|
|
row = [] |
|
|
for c in range(min_col, max_col + 1): |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
starting_positions = {} |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
sorted_positions = sorted(starting_positions.keys(), key=lambda pos: (pos[0], pos[1])) |
|
|
|
|
|
|
|
|
numbered_words = [] |
|
|
for i, pos in enumerate(sorted_positions): |
|
|
number = i + 1 |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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, [] |
|
|
|
|
|
|
|
|
starting_positions = {} |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
sorted_positions = sorted(starting_positions.keys(), key=lambda pos: (pos[0], pos[1])) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
for word in starting_positions[pos]: |
|
|
numbered_word = word.copy() |
|
|
numbered_word["number"] = number |
|
|
numbered_words.append(numbered_word) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
intersection = intersections[0] |
|
|
size = max(len(word1), len(word2)) + 4 |
|
|
grid = [["." for _ in range(size)] for _ in range(size)] |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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 |