from __future__ import annotations import argparse import json from pathlib import Path from typing import Dict, List from build_vocabulary import ( FILTERED_OUTPUT_PATH, METADATA_OUTPUT_PATH, OUTPUT_PATH, build_vocabulary, ) from build_lexicon import LEXICON_OUTPUT_PATH, build_lexicon from build_semantic_lexicon import SEMANTIC_LEXICON_OUTPUT_PATH, build_semantic_lexicon from crossword_filler import CrosswordFiller, load_vocabulary, load_vocabulary_metadata from crossword_generator import CrosswordGenerator, WORDS, render_grid DIFFICULTY_ALIASES: Dict[str, int] = { "easy": 1, "medium": 2, "hard": 4, "expert": 5, } DEFAULT_TOPIC = "general" DEFAULT_INITIAL_WORD_COUNT = len(WORDS) ABSTRACTISH_SUFFIXES = ("zione", "zioni", "mento", "menti", "ita", "ezza", "anza", "enza", "ismo") FILL_ALLOWED_POS = {"NOUN", "VERB", "ADJ", "ADV", "PREP", "CONJ"} GENERAL_FILL_MIN_QUALITY = 6 GENERAL_FILL_MAX_LENGTH = 10 SOFT_RELATED_FILL_LIMIT = 120 DEFAULT_THEMED_FILL_WORD_COUNT = 10 CONCRETE_TOPICS = { "animals", "plants", "nature", "ecology", "geography", "weather", "sea", "mountain", "health", "science", "sport", "history", "school", "cinema", "literature", "food", "city", "transport", "work", "home", } TOPIC_SEED_REQUIRED_SUBSTRINGS: Dict[str, tuple[str, ...]] = { "transport": ( "auto", "mot", "tren", "nav", "barc", "port", "pist", "vol", "aer", "bici", "cicl", "rimorch", "reattor", "vettur", "ambul", "imbarc", "trattor", "carr", "vap", "rota", "ruot", ), "animals": ( "can", "gatt", "lup", "ors", "pesc", "aquil", "anatr", "cavall", "serpent", "tig", "leon", "volp", "cerv", "capr", "pecor", ), "nature": ( "mar", "lag", "fium", "vent", "bosch", "mont", "collin", "isol", "rocc", "terra", "acqu", "fiore", "fogli", "radic", "affluent", "litoral", "piogg", "nev", "onda", "clim", ), "cinema": ( "film", "cin", "teatr", "attor", "scen", "reg", "doppi", "dialog", "comic", "div", "docu", "pellic", "spettacol", ), } TOPIC_SEED_BLOCKED_SUBSTRINGS: Dict[str, tuple[str, ...]] = { "transport": ( "intervist", "intratten", "speriment", "stermin", "investig", "intervent", "centometr", "sintetizz", "erot", "adoraz", "esalt", "eccit", "traduz", "fluttu", "sollecit", ), "animals": ( "assicur", "finanz", "coediz", "camerier", "servitor", "indic", "estens", "diffus", "difensor", "spessor", "maggior", ), "cinema": ( "manifest", "riediz", "dissimul", "diffus", "difensor", "estens", "malumor", "eversor", ), } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generatore e filler di cruciverba.") parser.add_argument( "--build-vocabulary", action="store_true", help="Rigenera i file lessicali intermedi: vocabolario esteso, filtrato e metadati.", ) parser.add_argument( "--build-lexicon", action="store_true", help="Rigenera `lexicon_it.json` prima dell'esecuzione.", ) parser.add_argument( "--skip-fill", action="store_true", help="Genera solo la griglia iniziale e salta il riempimento con il filler.", ) parser.add_argument( "--build-semantic-lexicon", action="store_true", help="Rigenera `lexicon_it_semantic.json` arricchendo il lessico con IWN-OMW/ItalWordNet.", ) parser.add_argument( "--vocabulary", type=Path, default=None, help="Percorso opzionale a un vocabolario testuale personalizzato da usare al posto di quello di default.", ) parser.add_argument( "--target-empty-ratio", type=float, default=1 / 6, help="Rapporto target di celle vuote residue dopo il filler. Esempio: 0.1667 lascia circa un sesto di celle vuote.", ) parser.add_argument( "--time-limit", type=float, default=8.0, help="Tempo massimo in secondi per la fase di generazione iniziale della griglia.", ) parser.add_argument( "--max-candidates", type=int, default=12, help="Numero massimo di candidati esplorati per parola nella generazione iniziale.", ) parser.add_argument( "--diffxy", type=int, default=7, help="Differenza massima preferita tra larghezza e altezza della griglia iniziale.", ) parser.add_argument( "--seed", type=int, default=None, help="Seed casuale per ottenere varianti riproducibili del cruciverba: stesso seed, stesso risultato.", ) parser.add_argument( "--difficulty", default="medium", help="Difficolta lessicale del filler. Alias testuali: easy, medium, hard, expert. Internamente mappati a livelli numerici 1-5.", ) parser.add_argument( "--topic", default=DEFAULT_TOPIC, help="Tema del cruciverba. Attualmente supporta i topic presenti nel lessico, ad esempio: general, nature, animals, actions, abstract.", ) parser.add_argument( "--initial-word-count", type=int, default=DEFAULT_INITIAL_WORD_COUNT, help="Numero di parole-seme usate per costruire la griglia iniziale prima del filler.", ) parser.add_argument( "--themed-fill-count", type=int, default=DEFAULT_THEMED_FILL_WORD_COUNT, help="Numero massimo indicativo di parole aggiunte dal filler da mantenere fortemente legate al tema.", ) return parser.parse_args() def ensure_vocabulary(args: argparse.Namespace) -> None: needs_build = args.build_vocabulary or not FILTERED_OUTPUT_PATH.exists() or not METADATA_OUTPUT_PATH.exists() if not needs_build: return totals = build_vocabulary() print("Vocabolario rigenerato") print(f"- esteso: {OUTPUT_PATH}") print(f"- filtrato: {FILTERED_OUTPUT_PATH}") print(f"- metadati: {METADATA_OUTPUT_PATH}") print(f"- parole estese: {totals['extended_words']}") print(f"- parole filtrate: {totals['filtered_words']}") def ensure_lexicon(args: argparse.Namespace) -> None: needs_build = args.build_lexicon or not LEXICON_OUTPUT_PATH.exists() if not needs_build: return lexicon = build_lexicon() LEXICON_OUTPUT_PATH.write_text( json.dumps(lexicon, ensure_ascii=False, indent=2), encoding="utf-8", ) print("Lessico rigenerato") print(f"- file: {LEXICON_OUTPUT_PATH}") print(f"- voci: {lexicon['meta']['entry_count']}") def ensure_semantic_lexicon(args: argparse.Namespace) -> None: needs_build = args.build_semantic_lexicon or not SEMANTIC_LEXICON_OUTPUT_PATH.exists() if not needs_build: return lexicon = build_semantic_lexicon() SEMANTIC_LEXICON_OUTPUT_PATH.write_text( json.dumps(lexicon, ensure_ascii=False, indent=2), encoding="utf-8", ) matched = sum(1 for entry in lexicon["entries"] if entry.get("semantic", {}).get("matched")) print("Lessico semantico rigenerato") print(f"- file: {SEMANTIC_LEXICON_OUTPUT_PATH}") print(f"- voci: {lexicon['meta']['entry_count']}") print(f"- match semantici: {matched}") def parse_difficulty(value: str) -> int: text = str(value).strip().lower() if text in DIFFICULTY_ALIASES: return DIFFICULTY_ALIASES[text] try: level = int(text) except ValueError as exc: raise SystemExit( "Valore non valido per --difficulty. Usa easy, medium, hard, expert oppure un intero tra 1 e 5." ) from exc if not 1 <= level <= 5: raise SystemExit("Il valore numerico di --difficulty deve essere compreso tra 1 e 5.") return level def load_selected_vocabulary(path: Path | None) -> List[str]: if path is None: return load_vocabulary() return path.read_text(encoding="utf-8").splitlines() def load_semantic_payload() -> Dict[str, object]: if not SEMANTIC_LEXICON_OUTPUT_PATH.exists(): lexicon = build_semantic_lexicon() SEMANTIC_LEXICON_OUTPUT_PATH.write_text( json.dumps(lexicon, ensure_ascii=False, indent=2), encoding="utf-8", ) return json.loads(SEMANTIC_LEXICON_OUTPUT_PATH.read_text(encoding="utf-8")) def entry_topics(entry: Dict[str, object]) -> tuple[set[str], set[str]]: topics = {str(item).lower() for item in entry.get("topics", [])} semantic_topics = { str(item).lower() for item in entry.get("semantic", {}).get("semantic_topics", []) } return topics, semantic_topics def matches_topic_roots(word: str, selected_topic: str) -> bool: roots = TOPIC_SEED_REQUIRED_SUBSTRINGS.get(selected_topic, ()) blocked = TOPIC_SEED_BLOCKED_SUBSTRINGS.get(selected_topic, ()) if any(part in word for part in blocked): return False return bool(roots) and any(part in word for part in roots) def topic_relevance(entry: Dict[str, object], topic: str) -> int: selected_topic = topic.strip().lower() if selected_topic == DEFAULT_TOPIC: return 20 word = str(entry.get("form", "")) topics, semantic_topics = entry_topics(entry) score = 0 if selected_topic in topics: score += 100 if selected_topic in semantic_topics: score += 45 if matches_topic_roots(word, selected_topic): score += 35 if "general" in topics: score += 5 if any(part in word for part in TOPIC_SEED_BLOCKED_SUBSTRINGS.get(selected_topic, ())): score -= 80 if selected_topic in CONCRETE_TOPICS and word.endswith(ABSTRACTISH_SUFFIXES): score -= 15 return score def strong_topic_relevance(entry: Dict[str, object], topic: str) -> int: selected_topic = topic.strip().lower() if selected_topic == DEFAULT_TOPIC: return 20 topics, _ = entry_topics(entry) return 100 if selected_topic in topics else 0 def lexical_fill_score(entry: Dict[str, object], topic: str) -> tuple[int, int, int, int, int, str]: word = str(entry.get("form", "")) quality = int(entry.get("quality_score", 0)) pos = str(entry.get("pos", "")) semantic = entry.get("semantic", {}) pos_bonus = { "NOUN": 12, "VERB": 8, "ADJ": 6, "ADV": 4, "PREP": 2, "CONJ": 2, }.get(pos, 0) semantic_bonus = 3 if semantic.get("matched") else 0 length = len(word) length_bonus = 3 if 4 <= length <= 10 else 1 if 2 <= length <= 13 else -4 return ( topic_relevance(entry, topic), quality, pos_bonus, semantic_bonus, length_bonus, word, ) def is_general_fill_support(entry: Dict[str, object]) -> bool: word = str(entry.get("form", "")) if int(entry.get("quality_score", 0)) < GENERAL_FILL_MIN_QUALITY: return False if len(word) > GENERAL_FILL_MAX_LENGTH: return False if word.endswith(ABSTRACTISH_SUFFIXES): return False return DEFAULT_TOPIC in {str(item).lower() for item in entry.get("topics", [])} def load_filtered_entries(level: int, topic: str) -> List[Dict[str, object]]: payload = load_semantic_payload() normalized_topic = topic.strip().lower() eligible = [ entry for entry in payload.get("entries", []) if entry.get("allowed_in_crossword", False) and int(entry.get("difficulty_word", 5)) <= level and str(entry.get("pos", "")) in FILL_ALLOWED_POS ] if normalized_topic == DEFAULT_TOPIC: selected = eligible else: strong_topic = [entry for entry in eligible if strong_topic_relevance(entry, normalized_topic) > 0] soft_related = [ entry for entry in eligible if entry not in strong_topic and topic_relevance(entry, normalized_topic) > 0 and int(entry.get("quality_score", 0)) >= GENERAL_FILL_MIN_QUALITY and len(str(entry.get("form", ""))) <= GENERAL_FILL_MAX_LENGTH and not str(entry.get("form", "")).endswith(ABSTRACTISH_SUFFIXES) ] soft_related.sort(key=lambda entry: lexical_fill_score(entry, normalized_topic), reverse=True) general_support = [ entry for entry in eligible if entry not in strong_topic and is_general_fill_support(entry) ] general_support.sort(key=lambda entry: lexical_fill_score(entry, DEFAULT_TOPIC), reverse=True) selected = strong_topic + soft_related[:SOFT_RELATED_FILL_LIMIT] selected += [entry for entry in general_support if entry not in selected] selected.sort(key=lambda entry: lexical_fill_score(entry, normalized_topic), reverse=True) return selected def load_filtered_vocabulary(level: int, topic: str) -> List[str]: return [str(entry["form"]) for entry in load_filtered_entries(level, topic)] def load_semantic_metadata_for_vocabulary(words: List[str], topic: str) -> Dict[str, Dict[str, object]]: payload = load_semantic_payload() selected = set(words) metadata: Dict[str, Dict[str, object]] = {} for entry in payload.get("entries", []): word = str(entry.get("form", "")) if word not in selected: continue enriched = dict(entry) enriched["_topic_relevance"] = topic_relevance(enriched, topic) enriched["_strong_topic_relevance"] = strong_topic_relevance(enriched, topic) metadata[word] = enriched return metadata def select_initial_words(level: int, topic: str, count: int) -> List[str]: payload = load_semantic_payload() normalized_topic = topic.strip().lower() abstract_like_topics = {"abstract", "actions"} def matches(entry: Dict[str, object], selected_topic: str) -> bool: topics, semantic_topics = entry_topics(entry) return selected_topic in topics def word_score(entry: Dict[str, object], selected_topic: str) -> tuple[int, int, int, int, int, int, str]: topics, semantic_topics = entry_topics(entry) quality = int(entry.get("quality_score", 0)) semantic = entry.get("semantic", {}) semantic_match = 1 if semantic.get("matched") else 0 glossary_bonus = min(3, len(semantic.get("glosses", []))) word = str(entry.get("form", "")) length = len(word) topical_concreteness_penalty = 0 topic_bonus = 0 pos_bonus = 0 if selected_topic in topics: topic_bonus += 4 if "general" in topics: topic_bonus += 1 if str(entry.get("pos", "")) == "NOUN": pos_bonus += 4 elif str(entry.get("pos", "")) == "ADJ": pos_bonus += 1 if selected_topic not in abstract_like_topics and selected_topic != DEFAULT_TOPIC: if "abstract" in topics and selected_topic not in topics: topical_concreteness_penalty -= 3 if "actions" in topics and selected_topic not in topics: topical_concreteness_penalty -= 2 if word.endswith(ABSTRACTISH_SUFFIXES): topical_concreteness_penalty -= 4 if str(entry.get("pos", "")) != "NOUN": topical_concreteness_penalty -= 3 if 5 <= length <= 10: length_bonus = 3 elif 4 <= length <= 12: length_bonus = 1 else: length_bonus = -2 return ( topic_bonus, pos_bonus, topical_concreteness_penalty, quality, semantic_match, glossary_bonus, length_bonus, word, ) def is_seed_friendly(entry: Dict[str, object], selected_topic: str) -> bool: word = str(entry.get("form", "")) pos = str(entry.get("pos", "")) topics, semantic_topics = entry_topics(entry) topic_hit = selected_topic in topics if len(word) < 4 or len(word) > 13: return False if selected_topic in CONCRETE_TOPICS and pos != "NOUN": return False if selected_topic in CONCRETE_TOPICS and word.endswith(ABSTRACTISH_SUFFIXES): return False blocked_substrings = TOPIC_SEED_BLOCKED_SUBSTRINGS.get(selected_topic, ()) if any(part in word for part in blocked_substrings): return False required_substrings = TOPIC_SEED_REQUIRED_SUBSTRINGS.get(selected_topic) if ( selected_topic in CONCRETE_TOPICS and required_substrings and selected_topic != DEFAULT_TOPIC and not any(part in word for part in required_substrings) ): return False if selected_topic != DEFAULT_TOPIC and not topic_hit: return False return True def overlap_score(left: str, right: str) -> int: shared = set(left) & set(right) return sum(min(left.count(ch), right.count(ch)) for ch in shared) def pick_seed_set(entries: List[Dict[str, object]], selected_topic: str, target_count: int) -> List[str]: if not entries: return [] ranked = sorted(entries, key=lambda entry: word_score(entry, selected_topic), reverse=True) chosen: List[str] = [] chosen_entries: List[Dict[str, object]] = [] first = ranked[0] chosen.append(str(first["form"])) chosen_entries.append(first) while len(chosen) < target_count: best_entry = None best_key = None for entry in ranked: word = str(entry.get("form", "")) if word in chosen: continue overlap_total = sum(overlap_score(word, existing) for existing in chosen) max_overlap = max((overlap_score(word, existing) for existing in chosen), default=0) distinct_letters = len(set(word)) same_length_penalty = -sum(1 for existing in chosen if len(existing) == len(word)) key = ( 1 if max_overlap >= 2 else 0, overlap_total, max_overlap, same_length_penalty, distinct_letters, word_score(entry, selected_topic), ) if best_key is None or key > best_key: best_key = key best_entry = entry if best_entry is None: break chosen.append(str(best_entry["form"])) chosen_entries.append(best_entry) return chosen eligible = [ entry for entry in payload.get("entries", []) if entry.get("allowed_in_crossword", False) and int(entry.get("difficulty_word", 5)) <= level ] lexical_topical = [] for entry in eligible: topics, semantic_topics = entry_topics(entry) if normalized_topic in topics: lexical_topical.append(entry) fallback = [entry for entry in eligible if matches(entry, DEFAULT_TOPIC)] if normalized_topic == DEFAULT_TOPIC: pool = fallback else: pool = list(lexical_topical) if not pool: pool = fallback strict_pool = [entry for entry in pool if is_seed_friendly(entry, normalized_topic)] relaxed_pool = sorted(pool, key=lambda entry: word_score(entry, normalized_topic), reverse=True) selected = pick_seed_set(strict_pool, normalized_topic, count) if len(selected) < count and normalized_topic == DEFAULT_TOPIC: relaxed_selected = pick_seed_set(relaxed_pool, normalized_topic, count) for word in relaxed_selected: if word not in selected: selected.append(word) if len(selected) >= count: break if len(selected) < count and normalized_topic == DEFAULT_TOPIC: for word in WORDS: if word in selected: continue selected.append(word) if len(selected) >= count: break return selected[:count] def main() -> None: args = parse_args() ensure_vocabulary(args) ensure_lexicon(args) ensure_semantic_lexicon(args) difficulty_level = parse_difficulty(args.difficulty) initial_words = select_initial_words(difficulty_level, args.topic, args.initial_word_count) generator = CrosswordGenerator( initial_words, diffxy=args.diffxy, time_limit_seconds=args.time_limit, max_candidates_per_word=args.max_candidates, seed=args.seed, ) initial_state = generator.solve() print("Griglia iniziale") print(f"Parole-seme richieste: {len(initial_words)}") print(f"Parole inserite: {initial_state.placed_words}/{len(generator.words)}") print(f"Intersezioni: {initial_state.intersections}") print(f"Dimensioni: {initial_state.width()} x {initial_state.height()} (diff={initial_state.shape_difference()})") print(f"Difficolta filler: {args.difficulty} -> livello {difficulty_level}") print(f"Tema filler: {args.topic}") if args.seed is not None: print(f"Seed: {args.seed}") print() print(render_grid(initial_state.grid, initial_state.placements)) print() print("Parole-seme selezionate:") print(", ".join(initial_words)) if args.skip_fill: return vocabulary = load_selected_vocabulary(args.vocabulary) if args.vocabulary else load_filtered_vocabulary(difficulty_level, args.topic) metadata = load_vocabulary_metadata() semantic_metadata = load_semantic_metadata_for_vocabulary(vocabulary, args.topic) if not args.vocabulary else {} filler = CrosswordFiller( initial_state, vocabulary, target_empty_ratio=args.target_empty_ratio, vocabulary_metadata=metadata, semantic_metadata=semantic_metadata, selected_topic=args.topic, max_themed_fill_words=args.themed_fill_count, seed=args.seed, ) final_state = filler.fill() print() print("Griglia riempita") print(f"Parole totali: {final_state.placed_words}") print(f"Intersezioni totali: {final_state.intersections}") print(f"Dimensioni: {final_state.width()} x {final_state.height()} (diff={final_state.shape_difference()})") print() print(render_grid(final_state.grid, final_state.placements)) if filler.added_words: print() print("Parole aggiunte dal filler:") for index, placement in enumerate(filler.added_words, start=1): direction = "orizzontale" if placement.direction == "H" else "verticale" print(f"{index:>2}. {placement.word} ({placement.x}, {placement.y}) {direction}") if __name__ == "__main__": main()