from __future__ import annotations import json import uuid from datetime import datetime from pathlib import Path from typing import Dict, Iterable, List, Tuple import main as engine from clue_generator import generate_clues, load_enriched_entries from crossword_filler import CrosswordFiller, load_vocabulary_metadata from crossword_generator import HORIZONTAL, Placement def _normalize_topic_list(raw_topic: object) -> List[str]: if isinstance(raw_topic, list): topics = [str(item).strip().lower() for item in raw_topic if str(item).strip()] return topics or [engine.DEFAULT_TOPIC] return engine.parse_topics(str(raw_topic or engine.DEFAULT_TOPIC)) def _entry_numbering(placements: Iterable[Placement]) -> Tuple[Dict[Tuple[int, int], int], Dict[Tuple[str, int, int, str], int]]: ordered = sorted(placements, key=lambda item: (item.y, item.x, item.direction)) number_by_start: Dict[Tuple[int, int], int] = {} placement_numbers: Dict[Tuple[str, int, int, str], int] = {} next_number = 1 for placement in ordered: start = (placement.x, placement.y) if start not in number_by_start: number_by_start[start] = next_number next_number += 1 placement_numbers[(placement.word, placement.x, placement.y, placement.direction)] = number_by_start[start] return number_by_start, placement_numbers def _clue_lookup(placements: List[Placement], lexicon_path: Path, topic: str, difficulty: str) -> Dict[Tuple[str, int, int, str], Dict[str, object]]: entries = load_enriched_entries(lexicon_path) clues = generate_clues(placements, entries, engine.primary_topic(topic), difficulty) lookup: Dict[Tuple[str, int, int, str], Dict[str, object]] = {} for clue in clues: direction = HORIZONTAL if clue.direction.lower().startswith("o") or clue.direction.lower().startswith("a") else "V" lookup[(clue.word, clue.x, clue.y, direction)] = {"text": clue.text, "source": clue.source} return lookup def _build_grid_payload(state, placements: List[Placement], placement_ids: Dict[Tuple[str, int, int, str], str]) -> Dict[str, object]: x_min, y_min, x_max, y_max = state.bounds() width = x_max - x_min + 1 height = y_max - y_min + 1 across_map: Dict[Tuple[int, int], str] = {} down_map: Dict[Tuple[int, int], str] = {} for placement in placements: entry_id = placement_ids[(placement.word, placement.x, placement.y, placement.direction)] target = across_map if placement.direction == HORIZONTAL else down_map for x, y in placement.cells: target[(x, y)] = entry_id number_by_start, _ = _entry_numbering(placements) cells = [] for row in range(height): for col in range(width): x = x_min + col y = y_min + row letter = state.grid.get((x, y)) if letter is None: cells.append( { "row": row, "col": col, "kind": "block", "solution": None, "display": None, "number": None, "across_entry_id": None, "down_entry_id": None, "is_prefilled": False, } ) continue cells.append( { "row": row, "col": col, "kind": "letter", "solution": letter.upper(), "display": "", "number": number_by_start.get((x, y)), "across_entry_id": across_map.get((x, y)), "down_entry_id": down_map.get((x, y)), "is_prefilled": False, } ) return { "rows": height, "cols": width, "cell_size_hint": 42, "cells": cells, } def _build_entries_payload( placements: List[Placement], state, lexicon_entries: Dict[str, Dict[str, object]], topic: str, difficulty: str, ) -> Tuple[List[Dict[str, object]], Dict[str, List[Dict[str, object]]]]: x_min, y_min, _, _ = state.bounds() clue_lookup = _clue_lookup(placements, engine.resolve_runtime_lexicon_path(None), topic, difficulty) _, placement_numbers = _entry_numbering(placements) entries_payload: List[Dict[str, object]] = [] clues_payload = {"across": [], "down": []} for placement in sorted(placements, key=lambda item: (item.y, item.x, item.direction)): number = placement_numbers[(placement.word, placement.x, placement.y, placement.direction)] direction = "across" if placement.direction == HORIZONTAL else "down" entry_id = ("A" if direction == "across" else "D") + str(number) clue_data = clue_lookup.get((placement.word, placement.x, placement.y, placement.direction), {}) lexicon_entry = lexicon_entries.get(placement.word.lower(), {}) row = placement.y - y_min col = placement.x - x_min cells = [[y - y_min, x - x_min] for x, y in placement.cells] confidence = 1.0 llm_rescue = lexicon_entry.get("llm_rescue") if isinstance(llm_rescue, dict): try: confidence = float(llm_rescue.get("confidence", 1.0) or 1.0) except (TypeError, ValueError): confidence = 1.0 item = { "entry_id": entry_id, "number": number, "direction": direction, "answer": placement.word.upper(), "answer_length": len(placement.word), "row": row, "col": col, "cells": cells, "clue": clue_data.get("text", ""), "clue_source": clue_data.get("source", "fallback"), "topics": lexicon_entry.get("topics", []), "pos": lexicon_entry.get("pos", ""), "is_seed": True, "added_by_filler": False, "confidence": confidence, } entries_payload.append(item) clue_item = { "number": number, "entry_id": entry_id, "text": item["clue"], "enumeration": len(placement.word), "topic_match": bool(lexicon_entry and engine.word_is_on_topic(lexicon_entry, topic)), "source": item["clue_source"], } clues_payload["across" if direction == "across" else "down"].append(clue_item) return entries_payload, clues_payload def _solution_rows(state) -> List[str]: x_min, y_min, x_max, y_max = state.bounds() rows: List[str] = [] for y in range(y_min, y_max + 1): chars = [] for x in range(x_min, x_max + 1): chars.append(state.grid.get((x, y), "#").upper()) rows.append("".join(chars)) return rows def _diagnostics(args, state, entries_by_word: Dict[str, Dict[str, object]], generation_seconds: float) -> Dict[str, object]: words = engine.placement_words(state.placements) unique_words = list(dict.fromkeys(word.lower() for word in words)) total_cells = state.area() filled_cells = len(state.grid) empty_cells = total_cells - filled_cells empty_ratio = empty_cells / total_cells if total_cells else 0.0 topic_words = 0 off_topic_words = 0 pos_counts = { "sostantivi": 0, "aggettivi": 0, "verbi": 0, "avverbi": 0, "preposizioni": 0, "congiunzioni": 0, "altri": 0, } for word in unique_words: entry = entries_by_word.get(word, {}) label = engine.pos_label(str(entry.get("pos", ""))) pos_counts[label] = pos_counts.get(label, 0) + 1 if entry and engine.word_is_on_topic(entry, args.topic): topic_words += 1 else: off_topic_words += 1 return { "seed_words_requested": args.initial_word_count, "seed_words_placed": state.placed_words, "filler_words_added": 0, "filled_cells": filled_cells, "empty_cells": empty_cells, "empty_ratio": round(empty_ratio, 4), "target_empty_ratio": args.target_empty_ratio, "topic_words": topic_words, "off_topic_words": off_topic_words, "pos_counts": pos_counts, "generation_seconds": round(generation_seconds, 2), } def generate_crossword_response(request_payload: Dict[str, object]) -> Dict[str, object]: generator = dict(request_payload.get("generator") or {}) locale = str(generator.get("preferred_output_language", "it") or "it") request_id = str(request_payload.get("request_id") or uuid.uuid4()) topic_list = _normalize_topic_list(generator.get("topic")) topic_string = ",".join(topic_list) lexicon_file = Path(str(generator.get("lexicon_file") or "lexicon_it_curated_llm_aggressive.json")) args = type("Args", (), {})() args.build_vocabulary = False args.build_lexicon = False args.skip_fill = False args.build_semantic_lexicon = False args.babelnet_enrich = False args.babelnet_limit = 0 args.babelnet_sleep = 0.0 args.vocabulary = None args.target_empty_ratio = float(generator.get("target_empty_ratio", 1 / 6)) args.time_limit = float(generator.get("time_limit_seconds", 8.0)) args.max_candidates = int(generator.get("max_candidates_per_word", 12)) args.diffxy = int(generator.get("diffxy", 7)) args.seed = generator.get("seed") args.difficulty = str(generator.get("difficulty", "medium")) args.topic = topic_string args.max_topics = 1 args.initial_word_count = int(generator.get("initial_word_count", engine.DEFAULT_INITIAL_WORD_COUNT)) args.themed_fill_count = int(generator.get("themed_fill_count", engine.DEFAULT_THEMED_FILL_WORD_COUNT)) args.definitions = bool(generator.get("definitions_enabled", True)) args.lexicon = engine.resolve_runtime_lexicon_path(lexicon_file) args.definition_babelnet_limit = 0 args.topic_seed_counts = {} engine.ensure_vocabulary(args) engine.ensure_lexicon(args) engine.ensure_semantic_lexicon(args) difficulty_level = engine.parse_difficulty(args.difficulty) active_topics = engine.resolve_topics(args, difficulty_level) initial_words = engine.select_initial_words(difficulty_level, args.topic, args.initial_word_count) started = datetime.now() generator_engine = engine.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_engine.solve() vocabulary = engine.load_filtered_vocabulary(difficulty_level, args.topic) metadata = load_vocabulary_metadata() semantic_metadata = engine.load_semantic_metadata_for_vocabulary(vocabulary, args.topic) 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() generation_seconds = (datetime.now() - started).total_seconds() lexicon_entries = load_enriched_entries(args.lexicon) placements = list(final_state.placements) placement_numbers = _entry_numbering(placements)[1] placement_ids = { key: (("A" if key[3] == HORIZONTAL else "D") + str(number)) for key, number in placement_numbers.items() } entries_payload, clues_payload = _build_entries_payload(placements, final_state, lexicon_entries, args.topic, args.difficulty) grid_payload = _build_grid_payload(final_state, placements, placement_ids) diagnostics = _diagnostics(args, final_state, lexicon_entries, generation_seconds) diagnostics["filler_words_added"] = len(filler.added_words) topic_label = ", ".join(active_topics) title_map = { "it": f"Cruciverba a tema {topic_label}", "en": f"{topic_label.title()} crossword", "es": f"Crucigrama sobre {topic_label}", } subtitle_map = { "it": "Generato dal motore Python del backoffice", "en": "Generated by the Python backoffice engine", "es": "Generado por el motor Python del backoffice", } return { "schema_version": "1.0", "request_id": request_id, "crossword_id": f"cw-{uuid.uuid4().hex[:12]}", "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "status": "ok", "generator": { "topic": active_topics, "difficulty": args.difficulty, "seed": args.seed, "runtime_lexicon": args.lexicon.name, }, "summary": { "title": title_map.get(locale, title_map["it"]), "subtitle": subtitle_map.get(locale, subtitle_map["it"]), "rows": grid_payload["rows"], "cols": grid_payload["cols"], "total_words": len(placements), "intersections": final_state.intersections, }, "grid": grid_payload, "entries": entries_payload, "clues": clues_payload, "solution": { "grid_rows": _solution_rows(final_state), "words": [placement.word.upper() for placement in placements], }, "diagnostics": diagnostics, "artifacts": { "pdf_player": None, "pdf_solution": None, "thumbnail": None, "html_preview": None, }, } def main() -> None: import sys payload = json.loads(sys.stdin.read()) response = generate_crossword_response(payload) sys.stdout.write(json.dumps(response, ensure_ascii=False)) if __name__ == "__main__": main()