Files
cruciverba_1/main.py

638 lines
22 KiB
Python

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()