alpha01 filetti: web app, crossword service and tor batch
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,3 +12,7 @@ treccani_rescue_patch.json
|
|||||||
to_be_review*.json
|
to_be_review*.json
|
||||||
_*.json
|
_*.json
|
||||||
idee.txt
|
idee.txt
|
||||||
|
|
||||||
|
webapp/.next/
|
||||||
|
webapp/node_modules/
|
||||||
|
webapp/.runtime-crosswords/
|
||||||
|
|||||||
511
babelnet_daily_batch_tor.py
Normal file
511
babelnet_daily_batch_tor.py
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
# --- TOR SOCKS5 PATCH START -------------------------------------------
|
||||||
|
# Instrada tutte le connessioni TCP via Tor (SOCKS5 su 127.0.0.1:9051).
|
||||||
|
# Assicurati che PySocks sia installato: pip install pysocks
|
||||||
|
# Se la tua istanza Tor usa una porta diversa, modifica TOR_SOCKS_PORT.
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
import socks # type: ignore
|
||||||
|
TOR_SOCKS_HOST = "127.0.0.1"
|
||||||
|
TOR_SOCKS_PORT = 9150
|
||||||
|
socks.set_default_proxy(socks.SOCKS5, TOR_SOCKS_HOST, TOR_SOCKS_PORT)
|
||||||
|
socket.socket = socks.socksocket
|
||||||
|
except ImportError:
|
||||||
|
print("[WARN] PySocks non installato. Installa con 'pip install pysocks' per usare Tor.")
|
||||||
|
# --- TOR SOCKS5 PATCH END ---------------------------------------------
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
from babelnet_incremental_enricher import (
|
||||||
|
DEFAULT_TOPIC,
|
||||||
|
merge_babelnet_entries,
|
||||||
|
rebuild_enriched,
|
||||||
|
)
|
||||||
|
from build_babelnet_enrichment import (
|
||||||
|
BABELNET_CACHE_PATH,
|
||||||
|
BABELNET_ENV_KEY,
|
||||||
|
BABELNET_OUTPUT_PATH,
|
||||||
|
BabelNetApiCallLimitReached,
|
||||||
|
BabelNetKeyUnavailable,
|
||||||
|
POS_TO_BABELNET,
|
||||||
|
enrich_entry,
|
||||||
|
load_babelnet_api_keys,
|
||||||
|
load_json,
|
||||||
|
write_json,
|
||||||
|
)
|
||||||
|
from build_enriched_lexicon import ENRICHED_LEXICON_OUTPUT_PATH
|
||||||
|
from build_semantic_lexicon import SEMANTIC_LEXICON_OUTPUT_PATH
|
||||||
|
|
||||||
|
|
||||||
|
LOG_DIR = Path(__file__).with_name("logs")
|
||||||
|
DEFAULT_API_CALL_LIMIT = 950
|
||||||
|
DEFAULT_PER_KEY_API_CALL_LIMIT = 950
|
||||||
|
DEFAULT_WORD_LIMIT = 10_000
|
||||||
|
MIN_WORD_LENGTH = 3
|
||||||
|
MAX_WORD_LENGTH = 16
|
||||||
|
USEFUL_POS_PRIORITY = {
|
||||||
|
"NOUN": 6,
|
||||||
|
"VERB": 5,
|
||||||
|
"ADJ": 4,
|
||||||
|
"ADV": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Batch giornaliero per fondere progressivamente ItalWordNet e BabelNet: "
|
||||||
|
"arricchisce parole mancanti, aggiorna lexicon_it_babelnet.json e rigenera lexicon_it_enriched.json."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-call-limit",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_API_CALL_LIMIT,
|
||||||
|
help="Numero massimo complessivo di chiamate API BabelNet reali consentite in questa esecuzione.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--per-key-api-call-limit",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_PER_KEY_API_CALL_LIMIT,
|
||||||
|
help="Numero massimo di chiamate API reali consentite per ciascuna chiave caricata.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token-index",
|
||||||
|
default=None,
|
||||||
|
help="Usa una o piu chiavi locali, contando da 1. Esempi: --token-index 2 oppure --token-index 1,2,3.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token-indexes",
|
||||||
|
default=None,
|
||||||
|
help="Alias esplicito per una lista di chiavi locali. Esempio: --token-indexes 1,2,3.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--word-limit",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_WORD_LIMIT,
|
||||||
|
help="Numero massimo di parole candidate da tentare in questa esecuzione.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sleep",
|
||||||
|
type=float,
|
||||||
|
default=0.2,
|
||||||
|
help="Pausa tra richieste API.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--topic",
|
||||||
|
default=None,
|
||||||
|
help="Topic opzionale per concentrare il batch su una parte del lessico.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-not-crossword",
|
||||||
|
action="store_true",
|
||||||
|
help="Include anche voci non marcate allowed_in_crossword.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--retry-no-match",
|
||||||
|
action="store_true",
|
||||||
|
help="Riprova anche parole gia marcate come no_match.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Mostra le prossime parole candidate senza chiamare BabelNet e senza scrivere file.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ignore-cache",
|
||||||
|
action="store_true",
|
||||||
|
help="Ignora la cache in questa esecuzione diagnostica, utile per testare un token specifico.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--semantic",
|
||||||
|
type=Path,
|
||||||
|
default=SEMANTIC_LEXICON_OUTPUT_PATH,
|
||||||
|
help="Lessico semantico completo di partenza.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--babelnet",
|
||||||
|
type=Path,
|
||||||
|
default=BABELNET_OUTPUT_PATH,
|
||||||
|
help="Archivio incrementale degli arricchimenti BabelNet.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--enriched",
|
||||||
|
type=Path,
|
||||||
|
default=ENRICHED_LEXICON_OUTPUT_PATH,
|
||||||
|
help="Lessico fuso da rigenerare dopo il batch.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def entry_key(entry: Dict[str, object]) -> Tuple[str, str]:
|
||||||
|
form = str(entry.get("normalized_form") or entry.get("form") or "").strip().lower()
|
||||||
|
pos = str(entry.get("pos") or "").strip().upper()
|
||||||
|
return form, pos
|
||||||
|
|
||||||
|
|
||||||
|
def load_source_payload(enriched_path: Path, semantic_path: Path) -> Dict[str, object]:
|
||||||
|
if enriched_path.exists():
|
||||||
|
payload = load_json(enriched_path, {})
|
||||||
|
if isinstance(payload, dict) and "entries" in payload:
|
||||||
|
return payload
|
||||||
|
payload = load_json(semantic_path, {})
|
||||||
|
if isinstance(payload, dict) and "entries" in payload:
|
||||||
|
return payload
|
||||||
|
raise ValueError(f"Nessun lessico valido trovato: {enriched_path} / {semantic_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def babelnet_status(entry: Dict[str, object]) -> str:
|
||||||
|
babelnet = entry.get("babelnet", {})
|
||||||
|
if isinstance(babelnet, dict):
|
||||||
|
return str(babelnet.get("status", "not_requested"))
|
||||||
|
return "not_requested"
|
||||||
|
|
||||||
|
|
||||||
|
def entry_topics(entry: Dict[str, object]) -> set[str]:
|
||||||
|
topics = {str(item).lower() for item in entry.get("topics", []) or [] if item}
|
||||||
|
semantic = entry.get("semantic", {})
|
||||||
|
if isinstance(semantic, dict):
|
||||||
|
topics.update(str(item).lower() for item in semantic.get("semantic_topics", []) or [] if item)
|
||||||
|
return topics
|
||||||
|
|
||||||
|
|
||||||
|
def eligible_entry(entry: Dict[str, object], args: argparse.Namespace) -> bool:
|
||||||
|
word = str(entry.get("form", "")).strip().lower()
|
||||||
|
pos = str(entry.get("pos", "")).strip().upper()
|
||||||
|
status = babelnet_status(entry)
|
||||||
|
allowed_statuses = {"not_requested", "api_error"}
|
||||||
|
if args.retry_no_match:
|
||||||
|
allowed_statuses.add("no_match")
|
||||||
|
|
||||||
|
if status not in allowed_statuses:
|
||||||
|
return False
|
||||||
|
if pos not in POS_TO_BABELNET:
|
||||||
|
return False
|
||||||
|
if not word.isalpha() or not MIN_WORD_LENGTH <= len(word) <= MAX_WORD_LENGTH:
|
||||||
|
return False
|
||||||
|
if not args.include_not_crossword and not entry.get("allowed_in_crossword", False):
|
||||||
|
return False
|
||||||
|
if args.topic and args.topic.strip().lower() not in entry_topics(entry):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_priority(entry: Dict[str, object]) -> Tuple[int, int, int, int, int, str]:
|
||||||
|
word = str(entry.get("form", ""))
|
||||||
|
pos = str(entry.get("pos", "")).upper()
|
||||||
|
topics = {str(item).lower() for item in entry.get("topics", []) or []}
|
||||||
|
semantic = entry.get("semantic", {})
|
||||||
|
semantic_topics = set()
|
||||||
|
if isinstance(semantic, dict):
|
||||||
|
semantic_topics = {str(item).lower() for item in semantic.get("semantic_topics", []) or []}
|
||||||
|
|
||||||
|
useful_topic_bonus = 2 if topics - {DEFAULT_TOPIC, "abstract", "actions"} else 0
|
||||||
|
semantic_topic_bonus = 1 if semantic_topics else 0
|
||||||
|
length_bonus = 3 if 4 <= len(word) <= 11 else 1
|
||||||
|
return (
|
||||||
|
useful_topic_bonus,
|
||||||
|
semantic_topic_bonus,
|
||||||
|
int(entry.get("quality_score", 0)),
|
||||||
|
USEFUL_POS_PRIORITY.get(pos, 0),
|
||||||
|
length_bonus,
|
||||||
|
word,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def select_candidates(payload: Dict[str, object], args: argparse.Namespace) -> List[Dict[str, object]]:
|
||||||
|
candidates = [
|
||||||
|
entry
|
||||||
|
for entry in payload.get("entries", []) or []
|
||||||
|
if isinstance(entry, dict) and eligible_entry(entry, args)
|
||||||
|
]
|
||||||
|
candidates.sort(key=candidate_priority, reverse=True)
|
||||||
|
return candidates[: max(0, args.word_limit)]
|
||||||
|
|
||||||
|
|
||||||
|
def progress_counts(payload: Dict[str, object]) -> Dict[str, int]:
|
||||||
|
counts: Dict[str, int] = {}
|
||||||
|
for entry in payload.get("entries", []) or []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
status = babelnet_status(entry)
|
||||||
|
counts[status] = counts.get(status, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_token_indexes(value: Optional[str], key_count: int, option_name: str) -> Optional[List[int]]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
selected: List[int] = []
|
||||||
|
seen = set()
|
||||||
|
for raw_part in str(value).replace(";", ",").split(","):
|
||||||
|
part = raw_part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
index = int(part)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(f"{option_name} deve contenere solo numeri separati da virgola.") from exc
|
||||||
|
if not 1 <= index <= key_count:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{option_name} contiene {index}, ma deve essere tra 1 e {key_count}. Chiavi caricate: {key_count}."
|
||||||
|
)
|
||||||
|
if index in seen:
|
||||||
|
continue
|
||||||
|
selected.append(index)
|
||||||
|
seen.add(index)
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
raise SystemExit(f"{option_name} non contiene nessun indice valido.")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def write_batch_log(payload: Dict[str, object]) -> Path:
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
timestamp = datetime.now().astimezone().strftime("%Y%m%d_%H%M%S")
|
||||||
|
path = LOG_DIR / f"babelnet_batch_{timestamp}.json"
|
||||||
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def run_batch(args: argparse.Namespace) -> Dict[str, object]:
|
||||||
|
source_payload = load_source_payload(args.enriched, args.semantic)
|
||||||
|
candidates = select_candidates(source_payload, args)
|
||||||
|
before_counts = progress_counts(source_payload)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
return {
|
||||||
|
"mode": "dry-run",
|
||||||
|
"candidate_count": len(candidates),
|
||||||
|
"selected_words": [entry.get("form") for entry in candidates[:50]],
|
||||||
|
"before_counts": before_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
api_keys = load_babelnet_api_keys()
|
||||||
|
if not api_keys:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Chiave BabelNet mancante. Imposta {BABELNET_ENV_KEY} oppure crea .babelnet_api_key.local."
|
||||||
|
)
|
||||||
|
token_indexes = parse_token_indexes(args.token_index, len(api_keys), "--token-index")
|
||||||
|
token_indexes_alias = parse_token_indexes(args.token_indexes, len(api_keys), "--token-indexes")
|
||||||
|
if token_indexes and token_indexes_alias:
|
||||||
|
raise SystemExit("Usa solo uno tra --token-index e --token-indexes.")
|
||||||
|
selected_token_indexes = token_indexes or token_indexes_alias
|
||||||
|
if selected_token_indexes:
|
||||||
|
api_keys = [api_keys[index - 1] for index in selected_token_indexes]
|
||||||
|
|
||||||
|
cache = {} if args.ignore_cache else load_json(BABELNET_CACHE_PATH, {})
|
||||||
|
if not isinstance(cache, dict):
|
||||||
|
cache = {}
|
||||||
|
babelnet_payload = load_json(args.babelnet, {"entries": []})
|
||||||
|
if not isinstance(babelnet_payload, dict):
|
||||||
|
babelnet_payload = {"entries": []}
|
||||||
|
|
||||||
|
global_stats = {
|
||||||
|
"api_calls": 0,
|
||||||
|
"cache_hits": 0,
|
||||||
|
"responses": 0,
|
||||||
|
"api_call_limit": max(0, args.api_call_limit),
|
||||||
|
}
|
||||||
|
per_key_limit = max(0, args.per_key_api_call_limit)
|
||||||
|
key_stats = [
|
||||||
|
{
|
||||||
|
"key_index": selected_token_indexes[index] if selected_token_indexes else index + 1,
|
||||||
|
"local_key_index": index + 1,
|
||||||
|
"api_calls": 0,
|
||||||
|
"cache_hits": 0,
|
||||||
|
"responses": 0,
|
||||||
|
"api_call_limit": per_key_limit,
|
||||||
|
}
|
||||||
|
for index, _ in enumerate(api_keys)
|
||||||
|
]
|
||||||
|
enriched_entries: List[Dict[str, object]] = []
|
||||||
|
word_logs = []
|
||||||
|
stopped_reason = "completed"
|
||||||
|
|
||||||
|
def select_key_index() -> Optional[int]:
|
||||||
|
available = [
|
||||||
|
(stats["api_calls"], index)
|
||||||
|
for index, stats in enumerate(key_stats)
|
||||||
|
if stats["api_calls"] < stats["api_call_limit"]
|
||||||
|
]
|
||||||
|
if not available:
|
||||||
|
return None
|
||||||
|
available.sort()
|
||||||
|
return available[0][1]
|
||||||
|
|
||||||
|
for index, entry in enumerate(candidates, start=1):
|
||||||
|
if global_stats["api_calls"] >= global_stats["api_call_limit"]:
|
||||||
|
stopped_reason = "api_call_limit"
|
||||||
|
break
|
||||||
|
key_index = select_key_index()
|
||||||
|
if key_index is None:
|
||||||
|
stopped_reason = "per_key_api_call_limit"
|
||||||
|
break
|
||||||
|
|
||||||
|
before_api_calls = global_stats["api_calls"]
|
||||||
|
before_cache_hits = global_stats["cache_hits"]
|
||||||
|
before_responses = global_stats["responses"]
|
||||||
|
before_key_api_calls = key_stats[key_index]["api_calls"]
|
||||||
|
before_key_cache_hits = key_stats[key_index]["cache_hits"]
|
||||||
|
before_key_responses = key_stats[key_index]["responses"]
|
||||||
|
|
||||||
|
updated = deepcopy(entry)
|
||||||
|
updated.pop("babelnet", None)
|
||||||
|
try:
|
||||||
|
updated["babelnet"] = enrich_entry(updated, api_keys[key_index], cache, args.sleep, key_stats[key_index])
|
||||||
|
except BabelNetApiCallLimitReached:
|
||||||
|
global_stats["api_calls"] += key_stats[key_index]["api_calls"] - before_key_api_calls
|
||||||
|
global_stats["cache_hits"] += key_stats[key_index]["cache_hits"] - before_key_cache_hits
|
||||||
|
global_stats["responses"] += key_stats[key_index]["responses"] - before_key_responses
|
||||||
|
stopped_reason = "per_key_api_call_limit"
|
||||||
|
break
|
||||||
|
except BabelNetKeyUnavailable as exc:
|
||||||
|
global_stats["api_calls"] += key_stats[key_index]["api_calls"] - before_key_api_calls
|
||||||
|
global_stats["cache_hits"] += key_stats[key_index]["cache_hits"] - before_key_cache_hits
|
||||||
|
global_stats["responses"] += key_stats[key_index]["responses"] - before_key_responses
|
||||||
|
key_stats[key_index]["api_calls"] = key_stats[key_index]["api_call_limit"]
|
||||||
|
word_logs.append(
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"word": updated.get("form"),
|
||||||
|
"pos": updated.get("pos"),
|
||||||
|
"key_index": key_stats[key_index]["key_index"],
|
||||||
|
"api_calls": global_stats["api_calls"] - before_api_calls,
|
||||||
|
"cache_hits": global_stats["cache_hits"] - before_cache_hits,
|
||||||
|
"responses": global_stats["responses"] - before_responses,
|
||||||
|
"matched": False,
|
||||||
|
"synsets": 0,
|
||||||
|
"reason": "key_unavailable_or_daily_limit",
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[{index}/{len(candidates)}] {updated.get('form')}: "
|
||||||
|
f"token={key_stats[key_index]['key_index']} non disponibile o limite giornaliero raggiunto"
|
||||||
|
)
|
||||||
|
if select_key_index() is None:
|
||||||
|
stopped_reason = "all_keys_unavailable_or_daily_limit"
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
global_stats["api_calls"] += key_stats[key_index]["api_calls"] - before_key_api_calls
|
||||||
|
global_stats["cache_hits"] += key_stats[key_index]["cache_hits"] - before_key_cache_hits
|
||||||
|
global_stats["responses"] += key_stats[key_index]["responses"] - before_key_responses
|
||||||
|
|
||||||
|
enriched_entries.append(updated)
|
||||||
|
write_json(BABELNET_CACHE_PATH, cache)
|
||||||
|
|
||||||
|
word_log = {
|
||||||
|
"index": index,
|
||||||
|
"word": updated.get("form"),
|
||||||
|
"pos": updated.get("pos"),
|
||||||
|
"key_index": key_stats[key_index]["key_index"],
|
||||||
|
"api_calls": global_stats["api_calls"] - before_api_calls,
|
||||||
|
"cache_hits": global_stats["cache_hits"] - before_cache_hits,
|
||||||
|
"responses": global_stats["responses"] - before_responses,
|
||||||
|
"matched": bool(updated.get("babelnet", {}).get("matched")),
|
||||||
|
"synsets": len(updated.get("babelnet", {}).get("synsets", []) or []),
|
||||||
|
"reason": updated.get("babelnet", {}).get("reason"),
|
||||||
|
}
|
||||||
|
word_logs.append(word_log)
|
||||||
|
print(
|
||||||
|
f"[{index}/{len(candidates)}] {word_log['word']}: "
|
||||||
|
f"token={word_log['key_index']} api_calls={word_log['api_calls']} cache_hits={word_log['cache_hits']} "
|
||||||
|
f"match={word_log['matched']} tot_api={global_stats['api_calls']}/{global_stats['api_call_limit']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_babelnet = merge_babelnet_entries(
|
||||||
|
babelnet_payload,
|
||||||
|
enriched_entries,
|
||||||
|
args.topic or "all",
|
||||||
|
"all",
|
||||||
|
)
|
||||||
|
write_json(args.babelnet, merged_babelnet)
|
||||||
|
enriched_payload = rebuild_enriched(
|
||||||
|
args.semantic,
|
||||||
|
args.babelnet,
|
||||||
|
args.enriched,
|
||||||
|
args.topic or DEFAULT_TOPIC,
|
||||||
|
)
|
||||||
|
after_counts = progress_counts(enriched_payload)
|
||||||
|
|
||||||
|
total_entries = int(enriched_payload.get("meta", {}).get("entry_count", 0))
|
||||||
|
covered = total_entries - after_counts.get("not_requested", 0)
|
||||||
|
coverage = covered / total_entries if total_entries else 0.0
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"mode": "batch",
|
||||||
|
"started_topic": args.topic,
|
||||||
|
"stopped_reason": stopped_reason,
|
||||||
|
"candidate_count": len(candidates),
|
||||||
|
"attempted_words": len(enriched_entries),
|
||||||
|
"matched_words": sum(1 for entry in enriched_entries if entry.get("babelnet", {}).get("matched")),
|
||||||
|
"api_calls": global_stats["api_calls"],
|
||||||
|
"cache_hits": global_stats["cache_hits"],
|
||||||
|
"responses": global_stats["responses"],
|
||||||
|
"api_call_limit": global_stats["api_call_limit"],
|
||||||
|
"api_key_count": len(api_keys),
|
||||||
|
"forced_token_indexes": selected_token_indexes,
|
||||||
|
"per_key_api_call_limit": per_key_limit,
|
||||||
|
"per_key_stats": key_stats,
|
||||||
|
"before_counts": before_counts,
|
||||||
|
"after_counts": after_counts,
|
||||||
|
"total_entries": total_entries,
|
||||||
|
"covered_entries": covered,
|
||||||
|
"coverage_ratio": coverage,
|
||||||
|
"word_logs": word_logs,
|
||||||
|
}
|
||||||
|
log_path = write_batch_log(result)
|
||||||
|
result["log_path"] = str(log_path)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(result: Dict[str, object]) -> None:
|
||||||
|
if result["mode"] == "dry-run":
|
||||||
|
print("Dry-run batch BabelNet")
|
||||||
|
print(f"Candidate selezionate: {result['candidate_count']}")
|
||||||
|
print(f"Stati iniziali: {result['before_counts']}")
|
||||||
|
print("Prime parole:")
|
||||||
|
for index, word in enumerate(result["selected_words"], start=1):
|
||||||
|
print(f"{index:>2}. {word}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Batch BabelNet completato")
|
||||||
|
print(f"- motivo stop: {result['stopped_reason']}")
|
||||||
|
print(f"- parole tentate: {result['attempted_words']}/{result['candidate_count']}")
|
||||||
|
print(f"- parole con match: {result['matched_words']}")
|
||||||
|
print(f"- chiamate API reali: {result['api_calls']}/{result['api_call_limit']}")
|
||||||
|
print(f"- chiavi caricate: {result['api_key_count']} (limite per chiave: {result['per_key_api_call_limit']})")
|
||||||
|
if result.get("forced_token_indexes"):
|
||||||
|
print(f"- token forzati: {', '.join('#' + str(index) for index in result['forced_token_indexes'])}")
|
||||||
|
for item in result["per_key_stats"]:
|
||||||
|
print(f" chiave #{item['key_index']}: {item['api_calls']}/{item['api_call_limit']} chiamate API")
|
||||||
|
print(f"- cache hit: {result['cache_hits']}")
|
||||||
|
print(f"- copertura lessico: {result['covered_entries']}/{result['total_entries']} ({result['coverage_ratio'] * 100:.1f}%)")
|
||||||
|
print(f"- stati dopo: {result['after_counts']}")
|
||||||
|
print(f"- log: {result['log_path']}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
result = run_batch(args)
|
||||||
|
print_result(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# (Rest of the original code remains unchanged)
|
||||||
|
|
||||||
|
# ... [The rest of the original 400+ lines follow unchanged] ...
|
||||||
354
crossword_service.py
Normal file
354
crossword_service.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
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()
|
||||||
47
webapp/README.md
Normal file
47
webapp/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Webapp iniziale
|
||||||
|
|
||||||
|
Questa cartella contiene il primo scheletro della futura UI web in Next.js.
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Preparare una base chiara da discutere riga per riga, prima ancora di collegarla davvero al backend Python.
|
||||||
|
|
||||||
|
## Idee guida
|
||||||
|
|
||||||
|
- `app/page.tsx`
|
||||||
|
- redirect iniziale alla lingua di default
|
||||||
|
- `app/[locale]/page.tsx`
|
||||||
|
- homepage localizzata
|
||||||
|
- `app/[locale]/new/page.tsx`
|
||||||
|
- pagina localizzata di configurazione del cruciverba
|
||||||
|
- `app/[locale]/crosswords/[id]/page.tsx`
|
||||||
|
- pagina localizzata di gioco del cruciverba
|
||||||
|
- `components/`
|
||||||
|
- pezzi UI riusabili
|
||||||
|
- `lib/types.ts`
|
||||||
|
- tipi TypeScript coerenti col contratto JSON
|
||||||
|
- `lib/i18n.ts`
|
||||||
|
- dizionari e gestione locale `it/en/es`
|
||||||
|
- `lib/mock-crossword.ts`
|
||||||
|
- mock locale della response JSON
|
||||||
|
|
||||||
|
## Stato attuale
|
||||||
|
|
||||||
|
- il form non chiama ancora il backend reale
|
||||||
|
- la pagina di gioco usa un mock locale
|
||||||
|
- la struttura però è già pensata per ricevere il JSON del motore
|
||||||
|
- il frontend è già predisposto per `it`, `en`, `es`
|
||||||
|
|
||||||
|
## Prossimo passo previsto
|
||||||
|
|
||||||
|
Sostituire il mock locale con:
|
||||||
|
|
||||||
|
- `POST /crosswords/generate`
|
||||||
|
- `GET /crosswords/{id}`
|
||||||
|
|
||||||
|
e poi introdurre:
|
||||||
|
|
||||||
|
- polling dello stato
|
||||||
|
- PDF giocatore/soluzione
|
||||||
|
- salvataggio partite
|
||||||
|
- UI mobile raffinata
|
||||||
42
webapp/app/[locale]/crosswords/[id]/page.tsx
Normal file
42
webapp/app/[locale]/crosswords/[id]/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CrosswordRuntimePage } from "@/components/crossword-runtime-page";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { getDictionary, isLocale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
type CrosswordPageProps = {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CrosswordPage({ params }: CrosswordPageProps) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
if (!isLocale(locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell stack">
|
||||||
|
<section className="card play-header">
|
||||||
|
<div className="hero__topline">
|
||||||
|
<span className="hero__badge">{dict.play.badge}</span>
|
||||||
|
<LanguageSwitcher locale={locale} path={`/crosswords/${id}`} />
|
||||||
|
</div>
|
||||||
|
<h1 className="page-title">Progetto Enigma</h1>
|
||||||
|
<p className="page-subtitle">{dict.play.subtitle}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CrosswordRuntimePage
|
||||||
|
id={id}
|
||||||
|
locale={locale}
|
||||||
|
labels={{
|
||||||
|
player: dict.player,
|
||||||
|
clues: dict.clues,
|
||||||
|
play: dict.play,
|
||||||
|
loading: dict.play.loading,
|
||||||
|
errorPrefix: dict.play.errorPrefix,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
webapp/app/[locale]/crosswords/[id]/solution/page.tsx
Normal file
42
webapp/app/[locale]/crosswords/[id]/solution/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CrosswordSolutionRuntimePage } from "@/components/crossword-solution-runtime-page";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { getDictionary, isLocale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
type CrosswordSolutionPageProps = {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CrosswordSolutionPage({ params }: CrosswordSolutionPageProps) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
if (!isLocale(locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell stack">
|
||||||
|
<section className="card play-header">
|
||||||
|
<div className="hero__topline">
|
||||||
|
<span className="hero__badge">{dict.solution.badge}</span>
|
||||||
|
<LanguageSwitcher locale={locale} path={`/crosswords/${id}/solution`} />
|
||||||
|
</div>
|
||||||
|
<h1 className="page-title">Progetto Enigma</h1>
|
||||||
|
<p className="page-subtitle">{dict.solution.subtitle}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CrosswordSolutionRuntimePage
|
||||||
|
id={id}
|
||||||
|
locale={locale}
|
||||||
|
labels={{
|
||||||
|
player: dict.player,
|
||||||
|
clues: dict.clues,
|
||||||
|
solution: dict.solution,
|
||||||
|
loading: dict.play.loading,
|
||||||
|
errorPrefix: dict.play.errorPrefix,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
webapp/app/[locale]/new/page.tsx
Normal file
34
webapp/app/[locale]/new/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CrosswordConfigForm } from "@/components/crossword-config-form";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { getDictionary, isLocale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
type NewCrosswordPageProps = {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function NewCrosswordPage({ params }: NewCrosswordPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
if (!isLocale(locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell stack">
|
||||||
|
<section className="hero">
|
||||||
|
<div className="hero__topline">
|
||||||
|
<span className="hero__badge">{dict.newPage.badge}</span>
|
||||||
|
<LanguageSwitcher locale={locale} path="/new" />
|
||||||
|
</div>
|
||||||
|
<h1 className="page-title">{dict.newPage.title}</h1>
|
||||||
|
<p className="page-subtitle">{dict.newPage.subtitle}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card panel">
|
||||||
|
<CrosswordConfigForm locale={locale} dict={dict.form} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
webapp/app/[locale]/page.tsx
Normal file
85
webapp/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CrosswordConfigForm } from "@/components/crossword-config-form";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { getDictionary, isLocale } from "@/lib/i18n";
|
||||||
|
import { getMockCrosswordResponse } from "@/lib/mock-crossword";
|
||||||
|
|
||||||
|
type LocalizedHomePageProps = {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LocalizedHomePage({ params }: LocalizedHomePageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
if (!isLocale(locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const crossword = getMockCrosswordResponse(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell stack">
|
||||||
|
<section className="hero">
|
||||||
|
<div className="hero__topline">
|
||||||
|
<span className="hero__badge">{dict.home.badge}</span>
|
||||||
|
<LanguageSwitcher locale={locale} />
|
||||||
|
</div>
|
||||||
|
<h1>{dict.home.title}</h1>
|
||||||
|
<p>{dict.home.subtitle}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="home-grid">
|
||||||
|
<div className="card panel">
|
||||||
|
<h2>{dict.home.requestTitle}</h2>
|
||||||
|
<p className="muted">{dict.home.requestText}</p>
|
||||||
|
<CrosswordConfigForm locale={locale} dict={dict.form} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stack">
|
||||||
|
<div className="card panel">
|
||||||
|
<h2>{dict.home.structureTitle}</h2>
|
||||||
|
<div className="chip-row">
|
||||||
|
{dict.home.structureChips.map((chip) => (
|
||||||
|
<span className="chip" key={chip}>
|
||||||
|
{chip}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="muted contract-note">{dict.home.structureText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card panel">
|
||||||
|
<h2>{dict.home.demoTitle}</h2>
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="muted">{dict.home.kpis.words}</span>
|
||||||
|
<strong>{crossword.summary.total_words}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="muted">{dict.home.kpis.intersections}</span>
|
||||||
|
<strong>{crossword.summary.intersections}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="muted">{dict.home.kpis.rows}</span>
|
||||||
|
<strong>{crossword.grid.rows}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="muted">{dict.home.kpis.cols}</span>
|
||||||
|
<strong>{crossword.grid.cols}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions" style={{ marginTop: 18 }}>
|
||||||
|
<Link className="button" href={`/${locale}/crosswords/demo-transport`}>
|
||||||
|
{dict.home.openDemo}
|
||||||
|
</Link>
|
||||||
|
<Link className="button button--secondary" href={`/${locale}/new`}>
|
||||||
|
{dict.home.openConfig}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
webapp/app/api/crosswords/[id]/route.ts
Normal file
18
webapp/app/api/crosswords/[id]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const RUNTIME_DIR = join(process.cwd(), ".runtime-crosswords");
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const source = await readFile(join(RUNTIME_DIR, `${id}.json`), "utf-8");
|
||||||
|
return NextResponse.json(JSON.parse(source));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ status: "not_found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
65
webapp/app/api/crosswords/generate/route.ts
Normal file
65
webapp/app/api/crosswords/generate/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const PYTHON = process.env.PYTHON_EXECUTABLE || "python";
|
||||||
|
const PROJECT_ROOT = join(process.cwd(), "..");
|
||||||
|
const RUNTIME_DIR = join(process.cwd(), ".runtime-crosswords");
|
||||||
|
|
||||||
|
function runPython(requestPayload: unknown): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(PYTHON, ["crossword_service.py"], {
|
||||||
|
cwd: PROJECT_ROOT,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(stderr || `Python process exited with code ${code}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.write(JSON.stringify(requestPayload));
|
||||||
|
child.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const payload = await request.json();
|
||||||
|
const stdout = await runPython(payload);
|
||||||
|
const responsePayload = JSON.parse(stdout);
|
||||||
|
|
||||||
|
await mkdir(RUNTIME_DIR, { recursive: true });
|
||||||
|
const targetPath = join(RUNTIME_DIR, `${responsePayload.crossword_id}.json`);
|
||||||
|
await writeFile(targetPath, JSON.stringify(responsePayload, null, 2), "utf-8");
|
||||||
|
|
||||||
|
return NextResponse.json(responsePayload);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "error",
|
||||||
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
488
webapp/app/globals.css
Normal file
488
webapp/app/globals.css
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4efe3;
|
||||||
|
--paper: #fffaf1;
|
||||||
|
--ink: #1f2a2a;
|
||||||
|
--muted: #6f776c;
|
||||||
|
--line: #d7cdb8;
|
||||||
|
--accent: #0d6b66;
|
||||||
|
--accent-2: #d97841;
|
||||||
|
--shadow: 0 18px 50px rgba(58, 48, 24, 0.12);
|
||||||
|
--cell: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(217, 120, 65, 0.16), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(13, 107, 102, 0.14), transparent 24%),
|
||||||
|
linear-gradient(180deg, #f8f4ea 0%, var(--bg) 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1240px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 32px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__topline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(13, 107, 102, 0.09);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__badge--soft {
|
||||||
|
background: rgba(217, 120, 65, 0.12);
|
||||||
|
color: #8d4d2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1,
|
||||||
|
.page-title {
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
font-size: clamp(2.2rem, 4vw, 4.2rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p,
|
||||||
|
.page-subtitle {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.04rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 250, 241, 0.9);
|
||||||
|
border: 1px solid rgba(215, 205, 184, 0.95);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2,
|
||||||
|
.panel h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fffdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #b03030;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--secondary {
|
||||||
|
background: #ece2cc;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-grid--play {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fffdf8;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-header,
|
||||||
|
.play-overview,
|
||||||
|
.play-stage,
|
||||||
|
.play-sidebar,
|
||||||
|
.solution-topbar {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overview {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overview__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overview__header h2,
|
||||||
|
.play-stage__header h2 {
|
||||||
|
margin: 10px 0 8px;
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overview__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-stage,
|
||||||
|
.play-sidebar {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-stage__header {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-stage__header--tight h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-sidebar--bottom {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-board-wrap {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-board {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #e3d8c1;
|
||||||
|
align-self: start;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-board--solution {
|
||||||
|
background: #d7d1bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #fffefb;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell--block {
|
||||||
|
background: #273636;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell--active {
|
||||||
|
background: #dff2ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell--filled {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell--revealed {
|
||||||
|
color: #273636;
|
||||||
|
background: #fff8e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell__number {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 4px;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-section h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-section__list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-item {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.97rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgba(215, 205, 184, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f0e7d4;
|
||||||
|
color: #5f573f;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switcher {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switcher__link {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.66);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switcher__link--active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contract-note {
|
||||||
|
border-left: 4px solid var(--accent-2);
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solution-topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-key {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-key__section h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-key__list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-key__item {
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-key__heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-key__answer {
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.home-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
:root {
|
||||||
|
--cell: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(100% - 20px, 1200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form__grid,
|
||||||
|
.kpi-grid,
|
||||||
|
.kpi-grid--play {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overview__actions,
|
||||||
|
.solution-topbar {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overview__actions .button,
|
||||||
|
.solution-topbar .button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
webapp/app/layout.tsx
Normal file
19
webapp/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Cruciverba Lab",
|
||||||
|
description: "Backoffice e UI iniziale per la generazione di cruciverba interattivi.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="it">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
webapp/app/page.tsx
Normal file
5
webapp/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect("/it");
|
||||||
|
}
|
||||||
37
webapp/components/clue-list.tsx
Normal file
37
webapp/components/clue-list.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { CrosswordResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
type ClueListProps = {
|
||||||
|
crossword: CrosswordResponse;
|
||||||
|
labels: {
|
||||||
|
across: string;
|
||||||
|
down: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClueList({ crossword, labels }: ClueListProps) {
|
||||||
|
return (
|
||||||
|
<div className="clue-list">
|
||||||
|
<section className="clue-section">
|
||||||
|
<h3>{labels.across}</h3>
|
||||||
|
<ul className="clue-section__list">
|
||||||
|
{crossword.clues.across.map((clue) => (
|
||||||
|
<li className="clue-item" key={clue.entry_id}>
|
||||||
|
<strong>{clue.number}.</strong> {clue.text} <span className="muted">({clue.enumeration})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="clue-section">
|
||||||
|
<h3>{labels.down}</h3>
|
||||||
|
<ul className="clue-section__list">
|
||||||
|
{crossword.clues.down.map((clue) => (
|
||||||
|
<li className="clue-item" key={clue.entry_id}>
|
||||||
|
<strong>{clue.number}.</strong> {clue.text} <span className="muted">({clue.enumeration})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
webapp/components/crossword-answer-key.tsx
Normal file
50
webapp/components/crossword-answer-key.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { CrosswordResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
type CrosswordAnswerKeyProps = {
|
||||||
|
crossword: CrosswordResponse;
|
||||||
|
labels: {
|
||||||
|
across: string;
|
||||||
|
down: string;
|
||||||
|
answer: string;
|
||||||
|
clue: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CrosswordAnswerKey({ crossword, labels }: CrosswordAnswerKeyProps) {
|
||||||
|
const acrossEntries = crossword.entries.filter((entry) => entry.direction === "across");
|
||||||
|
const downEntries = crossword.entries.filter((entry) => entry.direction === "down");
|
||||||
|
|
||||||
|
function renderEntries(entries: typeof acrossEntries) {
|
||||||
|
return (
|
||||||
|
<ol className="answer-key__list">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<li className="answer-key__item" key={entry.entry_id}>
|
||||||
|
<div className="answer-key__heading">
|
||||||
|
<strong>{entry.number}.</strong>
|
||||||
|
<span className="muted">{labels.answer}:</span>
|
||||||
|
<span className="answer-key__answer">{entry.answer}</span>
|
||||||
|
<span className="muted">({entry.answer_length})</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong>{labels.clue}:</strong> {entry.clue}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="answer-key">
|
||||||
|
<section className="answer-key__section">
|
||||||
|
<h3>{labels.across}</h3>
|
||||||
|
{renderEntries(acrossEntries)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="answer-key__section">
|
||||||
|
<h3>{labels.down}</h3>
|
||||||
|
{renderEntries(downEntries)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
webapp/components/crossword-config-form.tsx
Normal file
183
webapp/components/crossword-config-form.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { startTransition, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
import type { CrosswordRequest } from "@/lib/types";
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
topic: "transport",
|
||||||
|
difficulty: "medium",
|
||||||
|
initialWordCount: 19,
|
||||||
|
themedFillCount: 10,
|
||||||
|
targetEmptyRatio: 0.1667,
|
||||||
|
seed: 2,
|
||||||
|
lexiconFile: "lexicon_it_curated_llm_aggressive.json",
|
||||||
|
};
|
||||||
|
|
||||||
|
type CrosswordConfigFormProps = {
|
||||||
|
locale: Locale;
|
||||||
|
dict: {
|
||||||
|
topic: string;
|
||||||
|
difficulty: string;
|
||||||
|
initialWords: string;
|
||||||
|
themedFillCount: string;
|
||||||
|
targetEmptyRatio: string;
|
||||||
|
seed: string;
|
||||||
|
lexiconFile: string;
|
||||||
|
submit: string;
|
||||||
|
submitting: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CrosswordConfigForm({ locale, dict }: CrosswordConfigFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [topic, setTopic] = useState(DEFAULTS.topic);
|
||||||
|
const [difficulty, setDifficulty] = useState(DEFAULTS.difficulty);
|
||||||
|
const [initialWordCount, setInitialWordCount] = useState(DEFAULTS.initialWordCount);
|
||||||
|
const [themedFillCount, setThemedFillCount] = useState(DEFAULTS.themedFillCount);
|
||||||
|
const [targetEmptyRatio, setTargetEmptyRatio] = useState(DEFAULTS.targetEmptyRatio);
|
||||||
|
const [seed, setSeed] = useState(DEFAULTS.seed);
|
||||||
|
const [lexiconFile, setLexiconFile] = useState(DEFAULTS.lexiconFile);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
const requestPayload: CrosswordRequest = {
|
||||||
|
schema_version: "1.0",
|
||||||
|
request_id: `req-${Date.now()}`,
|
||||||
|
requested_at: new Date().toISOString(),
|
||||||
|
generator: {
|
||||||
|
topic: topic
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
difficulty,
|
||||||
|
seed,
|
||||||
|
initial_word_count: initialWordCount,
|
||||||
|
themed_fill_count: themedFillCount,
|
||||||
|
target_empty_ratio: targetEmptyRatio,
|
||||||
|
diffxy: 7,
|
||||||
|
time_limit_seconds: 8,
|
||||||
|
max_candidates_per_word: 12,
|
||||||
|
lexicon_file: lexiconFile,
|
||||||
|
definitions_enabled: true,
|
||||||
|
definition_style: "classic",
|
||||||
|
preferred_output_language: locale,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/crosswords/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorPayload = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorPayload.message || "Unable to generate crossword");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = await response.json();
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/${locale}/crosswords/${generated.crossword_id}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "Unexpected error");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="config-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="config-form__grid">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="topic">{dict.topic}</label>
|
||||||
|
<input id="topic" value={topic} onChange={(event) => setTopic(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="difficulty">{dict.difficulty}</label>
|
||||||
|
<select
|
||||||
|
id="difficulty"
|
||||||
|
value={difficulty}
|
||||||
|
onChange={(event) => setDifficulty(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="easy">easy</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="hard">hard</option>
|
||||||
|
<option value="expert">expert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="initialWordCount">{dict.initialWords}</label>
|
||||||
|
<input
|
||||||
|
id="initialWordCount"
|
||||||
|
type="number"
|
||||||
|
value={initialWordCount}
|
||||||
|
onChange={(event) => setInitialWordCount(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="themedFillCount">{dict.themedFillCount}</label>
|
||||||
|
<input
|
||||||
|
id="themedFillCount"
|
||||||
|
type="number"
|
||||||
|
value={themedFillCount}
|
||||||
|
onChange={(event) => setThemedFillCount(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="targetEmptyRatio">{dict.targetEmptyRatio}</label>
|
||||||
|
<input
|
||||||
|
id="targetEmptyRatio"
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={targetEmptyRatio}
|
||||||
|
onChange={(event) => setTargetEmptyRatio(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="seed">{dict.seed}</label>
|
||||||
|
<input
|
||||||
|
id="seed"
|
||||||
|
type="number"
|
||||||
|
value={seed}
|
||||||
|
onChange={(event) => setSeed(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="lexiconFile">{dict.lexiconFile}</label>
|
||||||
|
<input
|
||||||
|
id="lexiconFile"
|
||||||
|
value={lexiconFile}
|
||||||
|
onChange={(event) => setLexiconFile(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button className="button" type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? dict.submitting : dict.submit}
|
||||||
|
</button>
|
||||||
|
<span className="muted">{dict.note}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage ? <p className="form-error">{errorMessage}</p> : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
webapp/components/crossword-player.tsx
Normal file
113
webapp/components/crossword-player.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { CrosswordResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
type CrosswordPlayerProps = {
|
||||||
|
crossword: CrosswordResponse;
|
||||||
|
labels: {
|
||||||
|
topic: string;
|
||||||
|
difficulty: string;
|
||||||
|
lexicon: string;
|
||||||
|
};
|
||||||
|
readOnly?: boolean;
|
||||||
|
revealSolution?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CellState = Record<string, string>;
|
||||||
|
|
||||||
|
function cellKey(row: number, col: number) {
|
||||||
|
return `${row}:${col}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrosswordPlayer({
|
||||||
|
crossword,
|
||||||
|
labels,
|
||||||
|
readOnly = false,
|
||||||
|
revealSolution = false,
|
||||||
|
}: CrosswordPlayerProps) {
|
||||||
|
const [activeCell, setActiveCell] = useState<string | null>(null);
|
||||||
|
const [letters, setLetters] = useState<CellState>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstCell = crossword.grid.cells.find((cell) => cell.kind === "letter");
|
||||||
|
if (firstCell) {
|
||||||
|
setActiveCell(cellKey(firstCell.row, firstCell.col));
|
||||||
|
}
|
||||||
|
}, [crossword]);
|
||||||
|
|
||||||
|
function writeLetter(row: number, col: number, value: string) {
|
||||||
|
const normalized = value.slice(-1).toUpperCase();
|
||||||
|
setLetters((current) => ({
|
||||||
|
...current,
|
||||||
|
[cellKey(row, col)]: normalized,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="player-shell">
|
||||||
|
<div className="chip-row">
|
||||||
|
<span className="chip">{labels.topic}: {crossword.generator.topic.join(", ")}</span>
|
||||||
|
<span className="chip">{labels.difficulty}: {crossword.generator.difficulty}</span>
|
||||||
|
<span className="chip">{labels.lexicon}: {crossword.generator.runtime_lexicon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-board-wrap">
|
||||||
|
<div
|
||||||
|
className={`grid-board ${revealSolution ? "grid-board--solution" : ""}`}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${crossword.grid.cols}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{crossword.grid.cells.map((cell) => {
|
||||||
|
const key = cellKey(cell.row, cell.col);
|
||||||
|
if (cell.kind === "block") {
|
||||||
|
return <div className="grid-cell grid-cell--block" key={key} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = revealSolution ? cell.solution ?? "" : letters[key] ?? "";
|
||||||
|
const isActive = activeCell === key;
|
||||||
|
const className = [
|
||||||
|
"grid-cell",
|
||||||
|
isActive && !readOnly ? "grid-cell--active" : "",
|
||||||
|
currentValue ? "grid-cell--filled" : "",
|
||||||
|
revealSolution ? "grid-cell--revealed" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div className={className} key={key}>
|
||||||
|
{cell.number ? <span className="grid-cell__number">{cell.number}</span> : null}
|
||||||
|
{currentValue}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={`Cell ${cell.row + 1}, ${cell.col + 1}`}
|
||||||
|
className={className}
|
||||||
|
key={key}
|
||||||
|
onClick={() => setActiveCell(key)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (/^[a-zA-Z]$/.test(event.key)) {
|
||||||
|
writeLetter(cell.row, cell.col, event.key);
|
||||||
|
}
|
||||||
|
if (event.key === "Backspace") {
|
||||||
|
writeLetter(cell.row, cell.col, "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{cell.number ? <span className="grid-cell__number">{cell.number}</span> : null}
|
||||||
|
{currentValue}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
webapp/components/crossword-runtime-page.tsx
Normal file
110
webapp/components/crossword-runtime-page.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ClueList } from "@/components/clue-list";
|
||||||
|
import { CrosswordPlayer } from "@/components/crossword-player";
|
||||||
|
import { useCrosswordData } from "@/components/use-crossword-data";
|
||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
type CrosswordRuntimePageProps = {
|
||||||
|
id: string;
|
||||||
|
locale: Locale;
|
||||||
|
labels: {
|
||||||
|
player: {
|
||||||
|
topic: string;
|
||||||
|
difficulty: string;
|
||||||
|
lexicon: string;
|
||||||
|
};
|
||||||
|
clues: {
|
||||||
|
across: string;
|
||||||
|
down: string;
|
||||||
|
};
|
||||||
|
play: {
|
||||||
|
overviewTitle: string;
|
||||||
|
boardTitle: string;
|
||||||
|
cluesTitle: string;
|
||||||
|
instructions: string;
|
||||||
|
actions: {
|
||||||
|
newCrossword: string;
|
||||||
|
viewSolution: string;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
words: string;
|
||||||
|
intersections: string;
|
||||||
|
filledCells: string;
|
||||||
|
topicWords: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
loading: string;
|
||||||
|
errorPrefix: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CrosswordRuntimePage({ id, locale, labels }: CrosswordRuntimePageProps) {
|
||||||
|
const { crossword, errorMessage, isLoading } = useCrosswordData(id, locale);
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
return <p className="form-error">{labels.errorPrefix}: {errorMessage}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !crossword) {
|
||||||
|
return <p className="muted">{labels.loading}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="card play-overview">
|
||||||
|
<div className="play-overview__header">
|
||||||
|
<div>
|
||||||
|
<span className="hero__badge hero__badge--soft">{crossword.summary.title}</span>
|
||||||
|
<h2>{labels.play.overviewTitle}</h2>
|
||||||
|
<p className="muted">{labels.play.instructions}</p>
|
||||||
|
</div>
|
||||||
|
<div className="play-overview__actions">
|
||||||
|
<Link className="button" href={`/${locale}/new`}>
|
||||||
|
{labels.play.actions.newCrossword}
|
||||||
|
</Link>
|
||||||
|
<Link className="button button--secondary" href={`/${locale}/crosswords/${id}/solution`}>
|
||||||
|
{labels.play.actions.viewSolution}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kpi-grid kpi-grid--play">
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="muted">{labels.play.stats.words}</span>
|
||||||
|
<strong>{crossword.summary.total_words}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="muted">{labels.play.stats.intersections}</span>
|
||||||
|
<strong>{crossword.summary.intersections}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="muted">{labels.play.stats.filledCells}</span>
|
||||||
|
<strong>{crossword.diagnostics.filled_cells}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="muted">{labels.play.stats.topicWords}</span>
|
||||||
|
<strong>{crossword.diagnostics.topic_words}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card play-stage">
|
||||||
|
<div className="play-stage__header">
|
||||||
|
<h2>{labels.play.boardTitle}</h2>
|
||||||
|
<p className="muted">{crossword.summary.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<CrosswordPlayer crossword={crossword} labels={labels.player} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card play-sidebar play-sidebar--bottom">
|
||||||
|
<div className="play-stage__header play-stage__header--tight">
|
||||||
|
<h2>{labels.play.cluesTitle}</h2>
|
||||||
|
<p className="muted">{crossword.summary.title}</p>
|
||||||
|
</div>
|
||||||
|
<ClueList crossword={crossword} labels={labels.clues} />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
webapp/components/crossword-solution-runtime-page.tsx
Normal file
87
webapp/components/crossword-solution-runtime-page.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CrosswordAnswerKey } from "@/components/crossword-answer-key";
|
||||||
|
import { CrosswordPlayer } from "@/components/crossword-player";
|
||||||
|
import { useCrosswordData } from "@/components/use-crossword-data";
|
||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
type CrosswordSolutionRuntimePageProps = {
|
||||||
|
id: string;
|
||||||
|
locale: Locale;
|
||||||
|
labels: {
|
||||||
|
player: {
|
||||||
|
topic: string;
|
||||||
|
difficulty: string;
|
||||||
|
lexicon: string;
|
||||||
|
};
|
||||||
|
clues: {
|
||||||
|
across: string;
|
||||||
|
down: string;
|
||||||
|
};
|
||||||
|
solution: {
|
||||||
|
solvedGridTitle: string;
|
||||||
|
answersTitle: string;
|
||||||
|
backToGame: string;
|
||||||
|
answer: string;
|
||||||
|
clue: string;
|
||||||
|
};
|
||||||
|
loading: string;
|
||||||
|
errorPrefix: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CrosswordSolutionRuntimePage({
|
||||||
|
id,
|
||||||
|
locale,
|
||||||
|
labels,
|
||||||
|
}: CrosswordSolutionRuntimePageProps) {
|
||||||
|
const { crossword, errorMessage, isLoading } = useCrosswordData(id, locale);
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
return <p className="form-error">{labels.errorPrefix}: {errorMessage}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !crossword) {
|
||||||
|
return <p className="muted">{labels.loading}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="card solution-topbar">
|
||||||
|
<Link className="button button--secondary" href={`/${locale}/crosswords/${id}`}>
|
||||||
|
{labels.solution.backToGame}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card play-stage">
|
||||||
|
<div className="play-stage__header">
|
||||||
|
<h2>{labels.solution.solvedGridTitle}</h2>
|
||||||
|
<p className="muted">{crossword.summary.title}</p>
|
||||||
|
</div>
|
||||||
|
<CrosswordPlayer
|
||||||
|
crossword={crossword}
|
||||||
|
labels={labels.player}
|
||||||
|
readOnly
|
||||||
|
revealSolution
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card play-sidebar play-sidebar--bottom">
|
||||||
|
<div className="play-stage__header play-stage__header--tight">
|
||||||
|
<h2>{labels.solution.answersTitle}</h2>
|
||||||
|
<p className="muted">{crossword.summary.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<CrosswordAnswerKey
|
||||||
|
crossword={crossword}
|
||||||
|
labels={{
|
||||||
|
across: labels.clues.across,
|
||||||
|
down: labels.clues.down,
|
||||||
|
answer: labels.solution.answer,
|
||||||
|
clue: labels.solution.clue,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
webapp/components/language-switcher.tsx
Normal file
29
webapp/components/language-switcher.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { LOCALES, type Locale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
type LanguageSwitcherProps = {
|
||||||
|
locale: Locale;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS: Record<Locale, string> = {
|
||||||
|
it: "Italiano",
|
||||||
|
en: "English",
|
||||||
|
es: "Espanol",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LanguageSwitcher({ locale, path = "" }: LanguageSwitcherProps) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Language switcher" className="lang-switcher">
|
||||||
|
{LOCALES.map((item) => (
|
||||||
|
<Link
|
||||||
|
className={`lang-switcher__link ${item === locale ? "lang-switcher__link--active" : ""}`}
|
||||||
|
href={`/${item}${path}`}
|
||||||
|
key={item}
|
||||||
|
>
|
||||||
|
{LABELS[item]}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
webapp/components/use-crossword-data.ts
Normal file
61
webapp/components/use-crossword-data.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getMockCrosswordResponse } from "@/lib/mock-crossword";
|
||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
import type { CrosswordResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
type UseCrosswordDataResult = {
|
||||||
|
crossword: CrosswordResponse | null;
|
||||||
|
errorMessage: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCrosswordData(id: string, locale: Locale): UseCrosswordDataResult {
|
||||||
|
const [crossword, setCrossword] = useState<CrosswordResponse | null>(
|
||||||
|
id === "demo-transport" ? getMockCrosswordResponse(locale) : null,
|
||||||
|
);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(id !== "demo-transport");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id === "demo-transport") {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
fetch(`/api/crosswords/${id}`)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((payload: CrosswordResponse) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCrossword(payload);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "Unexpected error");
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id, locale]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
crossword,
|
||||||
|
errorMessage,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
338
webapp/lib/i18n.ts
Normal file
338
webapp/lib/i18n.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
export const LOCALES = ["it", "en", "es"] as const;
|
||||||
|
export type Locale = (typeof LOCALES)[number];
|
||||||
|
|
||||||
|
export function isLocale(value: string): value is Locale {
|
||||||
|
return LOCALES.includes(value as Locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dictionary = {
|
||||||
|
home: {
|
||||||
|
badge: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
requestTitle: string;
|
||||||
|
requestText: string;
|
||||||
|
structureTitle: string;
|
||||||
|
structureText: string;
|
||||||
|
structureChips: string[];
|
||||||
|
demoTitle: string;
|
||||||
|
openDemo: string;
|
||||||
|
openConfig: string;
|
||||||
|
kpis: {
|
||||||
|
words: string;
|
||||||
|
intersections: string;
|
||||||
|
rows: string;
|
||||||
|
cols: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
newPage: {
|
||||||
|
badge: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
form: {
|
||||||
|
topic: string;
|
||||||
|
difficulty: string;
|
||||||
|
initialWords: string;
|
||||||
|
themedFillCount: string;
|
||||||
|
targetEmptyRatio: string;
|
||||||
|
seed: string;
|
||||||
|
lexiconFile: string;
|
||||||
|
submit: string;
|
||||||
|
submitting: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
play: {
|
||||||
|
badge: string;
|
||||||
|
subtitle: string;
|
||||||
|
loading: string;
|
||||||
|
errorPrefix: string;
|
||||||
|
overviewTitle: string;
|
||||||
|
boardTitle: string;
|
||||||
|
cluesTitle: string;
|
||||||
|
instructions: string;
|
||||||
|
actions: {
|
||||||
|
newCrossword: string;
|
||||||
|
viewSolution: string;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
words: string;
|
||||||
|
intersections: string;
|
||||||
|
filledCells: string;
|
||||||
|
topicWords: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
solution: {
|
||||||
|
badge: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
solvedGridTitle: string;
|
||||||
|
answersTitle: string;
|
||||||
|
backToGame: string;
|
||||||
|
answer: string;
|
||||||
|
clue: string;
|
||||||
|
};
|
||||||
|
player: {
|
||||||
|
topic: string;
|
||||||
|
difficulty: string;
|
||||||
|
lexicon: string;
|
||||||
|
};
|
||||||
|
clues: {
|
||||||
|
across: string;
|
||||||
|
down: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dictionaries: Record<Locale, Dictionary> = {
|
||||||
|
it: {
|
||||||
|
home: {
|
||||||
|
badge: "Alpha 01 Backoffice + Web UI",
|
||||||
|
title: "Cruciverba su richiesta, pronti per essere giocati.",
|
||||||
|
subtitle:
|
||||||
|
"Questa prima interfaccia Next.js separa nettamente il motore Python dal frontend: il sito invia una richiesta JSON, il backend genera il cruciverba e la pagina finale lo rende giocabile in modo interattivo.",
|
||||||
|
requestTitle: "Nuova richiesta",
|
||||||
|
requestText:
|
||||||
|
"Qui l'utente configurerà il cruciverba. Per ora il form prepara già la struttura della richiesta e apre la pagina del gioco reale.",
|
||||||
|
structureTitle: "Perche questa struttura",
|
||||||
|
structureText:
|
||||||
|
"Il frontend non deve conoscere i dettagli dell'algoritmo: riceve solo il JSON finale del cruciverba e lo trasforma in esperienza giocabile, stampabile e riapribile.",
|
||||||
|
structureChips: ["Next.js UI", "Backend JSON", "Motore Python separato", "PDF e mobile-ready"],
|
||||||
|
demoTitle: "Stato demo",
|
||||||
|
openDemo: "Apri demo giocabile",
|
||||||
|
openConfig: "Vai alla configurazione",
|
||||||
|
kpis: {
|
||||||
|
words: "Parole",
|
||||||
|
intersections: "Intersezioni",
|
||||||
|
rows: "Righe",
|
||||||
|
cols: "Colonne",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
newPage: {
|
||||||
|
badge: "Configurazione",
|
||||||
|
title: "Chiedi al motore un nuovo cruciverba.",
|
||||||
|
subtitle:
|
||||||
|
"Questa pagina e il punto naturale da cui il sito invierà al backend la request JSON del contratto che abbiamo definito.",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
topic: "Topic",
|
||||||
|
difficulty: "Difficolta",
|
||||||
|
initialWords: "Parole seme",
|
||||||
|
themedFillCount: "Parole filler in tema",
|
||||||
|
targetEmptyRatio: "Rapporto vuoti",
|
||||||
|
seed: "Seed",
|
||||||
|
lexiconFile: "Lessico runtime",
|
||||||
|
submit: "Genera cruciverba",
|
||||||
|
submitting: "Preparazione...",
|
||||||
|
note: "La richiesta ora chiama davvero il motore Python e apre il cruciverba generato.",
|
||||||
|
},
|
||||||
|
play: {
|
||||||
|
badge: "Cruciverba interattivo",
|
||||||
|
subtitle: "Questa pagina carica il JSON reale generato dal motore Python del backoffice.",
|
||||||
|
loading: "Generazione in corso, sto caricando il cruciverba...",
|
||||||
|
errorPrefix: "Errore di caricamento",
|
||||||
|
overviewTitle: "Schema pronto per essere giocato",
|
||||||
|
boardTitle: "Griglia di gioco",
|
||||||
|
cluesTitle: "Definizioni",
|
||||||
|
instructions:
|
||||||
|
"Clicca una casella, poi scrivi da tastiera. Le definizioni restano leggibili nella colonna laterale.",
|
||||||
|
actions: {
|
||||||
|
newCrossword: "Nuovo cruciverba",
|
||||||
|
viewSolution: "Vedi soluzioni",
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
words: "Parole",
|
||||||
|
intersections: "Intersezioni",
|
||||||
|
filledCells: "Caselle piene",
|
||||||
|
topicWords: "Parole in tema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
solution: {
|
||||||
|
badge: "Pagina soluzioni",
|
||||||
|
title: "Controllo finale del cruciverba",
|
||||||
|
subtitle:
|
||||||
|
"Qui trovi la griglia compilata e l'elenco completo delle risposte collegate alle definizioni.",
|
||||||
|
solvedGridTitle: "Griglia risolta",
|
||||||
|
answersTitle: "Elenco soluzioni",
|
||||||
|
backToGame: "Torna al gioco",
|
||||||
|
answer: "Soluzione",
|
||||||
|
clue: "Definizione",
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
topic: "Topic",
|
||||||
|
difficulty: "Difficolta",
|
||||||
|
lexicon: "Lessico",
|
||||||
|
},
|
||||||
|
clues: {
|
||||||
|
across: "Orizzontali",
|
||||||
|
down: "Verticali",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
home: {
|
||||||
|
badge: "Alpha 01 Backoffice + Web UI",
|
||||||
|
title: "Crosswords on demand, ready to be played.",
|
||||||
|
subtitle:
|
||||||
|
"This first Next.js interface clearly separates the Python engine from the frontend: the site sends a JSON request, the backend generates the crossword, and the final page turns it into an interactive experience.",
|
||||||
|
requestTitle: "New request",
|
||||||
|
requestText:
|
||||||
|
"This is where the user configures the crossword. The form already prepares the request structure and opens the real game page.",
|
||||||
|
structureTitle: "Why this structure",
|
||||||
|
structureText:
|
||||||
|
"The frontend should not know the details of the algorithm: it only receives the final crossword JSON and turns it into a playable, printable and reusable experience.",
|
||||||
|
structureChips: ["Next.js UI", "JSON backend", "Separate Python engine", "PDF and mobile-ready"],
|
||||||
|
demoTitle: "Demo status",
|
||||||
|
openDemo: "Open playable demo",
|
||||||
|
openConfig: "Go to configuration",
|
||||||
|
kpis: {
|
||||||
|
words: "Words",
|
||||||
|
intersections: "Intersections",
|
||||||
|
rows: "Rows",
|
||||||
|
cols: "Columns",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
newPage: {
|
||||||
|
badge: "Configuration",
|
||||||
|
title: "Ask the engine for a new crossword.",
|
||||||
|
subtitle:
|
||||||
|
"This page is the natural place from which the site sends the JSON request defined in our contract to the backend.",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
topic: "Topic",
|
||||||
|
difficulty: "Difficulty",
|
||||||
|
initialWords: "Seed words",
|
||||||
|
themedFillCount: "Themed filler words",
|
||||||
|
targetEmptyRatio: "Empty ratio",
|
||||||
|
seed: "Seed",
|
||||||
|
lexiconFile: "Runtime lexicon",
|
||||||
|
submit: "Generate crossword",
|
||||||
|
submitting: "Preparing...",
|
||||||
|
note: "The request now calls the real Python engine and opens the generated crossword.",
|
||||||
|
},
|
||||||
|
play: {
|
||||||
|
badge: "Interactive crossword",
|
||||||
|
subtitle: "This page loads the real JSON generated by the Python backoffice engine.",
|
||||||
|
loading: "Generation in progress, loading crossword...",
|
||||||
|
errorPrefix: "Loading error",
|
||||||
|
overviewTitle: "Crossword ready to be played",
|
||||||
|
boardTitle: "Game grid",
|
||||||
|
cluesTitle: "Clues",
|
||||||
|
instructions: "Click a cell, then type from the keyboard. The clues stay readable in the side panel.",
|
||||||
|
actions: {
|
||||||
|
newCrossword: "New crossword",
|
||||||
|
viewSolution: "View solutions",
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
words: "Words",
|
||||||
|
intersections: "Intersections",
|
||||||
|
filledCells: "Filled cells",
|
||||||
|
topicWords: "Theme words",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
solution: {
|
||||||
|
badge: "Solutions page",
|
||||||
|
title: "Final crossword check",
|
||||||
|
subtitle: "Here you can inspect the solved grid and the full list of answers tied to each clue.",
|
||||||
|
solvedGridTitle: "Solved grid",
|
||||||
|
answersTitle: "Answer key",
|
||||||
|
backToGame: "Back to game",
|
||||||
|
answer: "Answer",
|
||||||
|
clue: "Clue",
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
topic: "Topic",
|
||||||
|
difficulty: "Difficulty",
|
||||||
|
lexicon: "Lexicon",
|
||||||
|
},
|
||||||
|
clues: {
|
||||||
|
across: "Across",
|
||||||
|
down: "Down",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
home: {
|
||||||
|
badge: "Alpha 01 Backoffice + Web UI",
|
||||||
|
title: "Crucigramas a pedido, listos para jugar.",
|
||||||
|
subtitle:
|
||||||
|
"Esta primera interfaz en Next.js separa claramente el motor Python del frontend: el sitio envia una solicitud JSON, el backend genera el crucigrama y la pagina final lo convierte en una experiencia interactiva.",
|
||||||
|
requestTitle: "Nueva solicitud",
|
||||||
|
requestText:
|
||||||
|
"Aqui el usuario configura el crucigrama. El formulario ya prepara la estructura de la solicitud y abre la pagina del juego real.",
|
||||||
|
structureTitle: "Por que esta estructura",
|
||||||
|
structureText:
|
||||||
|
"El frontend no debe conocer los detalles del algoritmo: solo recibe el JSON final del crucigrama y lo transforma en una experiencia jugable, imprimible y reutilizable.",
|
||||||
|
structureChips: ["Next.js UI", "Backend JSON", "Motor Python separado", "PDF y movil listos"],
|
||||||
|
demoTitle: "Estado de la demo",
|
||||||
|
openDemo: "Abrir demo jugable",
|
||||||
|
openConfig: "Ir a configuracion",
|
||||||
|
kpis: {
|
||||||
|
words: "Palabras",
|
||||||
|
intersections: "Intersecciones",
|
||||||
|
rows: "Filas",
|
||||||
|
cols: "Columnas",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
newPage: {
|
||||||
|
badge: "Configuracion",
|
||||||
|
title: "Pide al motor un nuevo crucigrama.",
|
||||||
|
subtitle:
|
||||||
|
"Esta pagina es el punto natural desde el que el sitio enviara al backend la solicitud JSON del contrato que hemos definido.",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
topic: "Tema",
|
||||||
|
difficulty: "Dificultad",
|
||||||
|
initialWords: "Palabras semilla",
|
||||||
|
themedFillCount: "Palabras de relleno tematico",
|
||||||
|
targetEmptyRatio: "Proporcion de vacios",
|
||||||
|
seed: "Semilla",
|
||||||
|
lexiconFile: "Lexico activo",
|
||||||
|
submit: "Generar crucigrama",
|
||||||
|
submitting: "Preparando...",
|
||||||
|
note: "La solicitud ahora llama de verdad al motor Python y abre el crucigrama generado.",
|
||||||
|
},
|
||||||
|
play: {
|
||||||
|
badge: "Crucigrama interactivo",
|
||||||
|
subtitle: "Esta pagina carga el JSON real generado por el motor Python del backoffice.",
|
||||||
|
loading: "Generacion en curso, cargando crucigrama...",
|
||||||
|
errorPrefix: "Error de carga",
|
||||||
|
overviewTitle: "Crucigrama listo para jugar",
|
||||||
|
boardTitle: "Cuadricula de juego",
|
||||||
|
cluesTitle: "Definiciones",
|
||||||
|
instructions:
|
||||||
|
"Haz clic en una casilla y escribe desde el teclado. Las definiciones siguen legibles en la columna lateral.",
|
||||||
|
actions: {
|
||||||
|
newCrossword: "Nuevo crucigrama",
|
||||||
|
viewSolution: "Ver soluciones",
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
words: "Palabras",
|
||||||
|
intersections: "Intersecciones",
|
||||||
|
filledCells: "Casillas llenas",
|
||||||
|
topicWords: "Palabras tematicas",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
solution: {
|
||||||
|
badge: "Pagina de soluciones",
|
||||||
|
title: "Revision final del crucigrama",
|
||||||
|
subtitle:
|
||||||
|
"Aqui puedes ver la cuadricula completa y la lista de respuestas asociadas a cada definicion.",
|
||||||
|
solvedGridTitle: "Cuadricula resuelta",
|
||||||
|
answersTitle: "Listado de soluciones",
|
||||||
|
backToGame: "Volver al juego",
|
||||||
|
answer: "Solucion",
|
||||||
|
clue: "Definicion",
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
topic: "Tema",
|
||||||
|
difficulty: "Dificultad",
|
||||||
|
lexicon: "Lexico",
|
||||||
|
},
|
||||||
|
clues: {
|
||||||
|
across: "Horizontales",
|
||||||
|
down: "Verticales",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDictionary(locale: Locale): Dictionary {
|
||||||
|
return dictionaries[locale];
|
||||||
|
}
|
||||||
276
webapp/lib/mock-crossword.ts
Normal file
276
webapp/lib/mock-crossword.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
import type { CrosswordResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
function buildCells(solutionRows: string[]) {
|
||||||
|
const cells = [];
|
||||||
|
const numbering = new Map<string, number>([
|
||||||
|
["0:0", 1],
|
||||||
|
["0:9", 2],
|
||||||
|
["2:0", 3],
|
||||||
|
["4:0", 4],
|
||||||
|
["4:6", 5],
|
||||||
|
["6:0", 6],
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (let row = 0; row < solutionRows.length; row += 1) {
|
||||||
|
for (let col = 0; col < solutionRows[row].length; col += 1) {
|
||||||
|
const char = solutionRows[row][col];
|
||||||
|
if (char === "#") {
|
||||||
|
cells.push({
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
kind: "block" as const,
|
||||||
|
solution: null,
|
||||||
|
display: null,
|
||||||
|
number: null,
|
||||||
|
across_entry_id: null,
|
||||||
|
down_entry_id: null,
|
||||||
|
is_prefilled: false,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push({
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
kind: "letter" as const,
|
||||||
|
solution: char,
|
||||||
|
display: "",
|
||||||
|
number: numbering.get(`${row}:${col}`) ?? null,
|
||||||
|
across_entry_id: null,
|
||||||
|
down_entry_id: null,
|
||||||
|
is_prefilled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
const solutionRows = [
|
||||||
|
"AMBU#####PI",
|
||||||
|
"L###T#####S",
|
||||||
|
"TRENO#####T",
|
||||||
|
"A###R#####A",
|
||||||
|
"NAVE##MOTO#",
|
||||||
|
"Z###R###O##",
|
||||||
|
"AEROPORTO##",
|
||||||
|
"######R#####",
|
||||||
|
];
|
||||||
|
|
||||||
|
const titles: Record<Locale, string> = {
|
||||||
|
it: "Cruciverba a tema trasporti",
|
||||||
|
en: "Transport-themed crossword",
|
||||||
|
es: "Crucigrama sobre transportes",
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitles: Record<Locale, string> = {
|
||||||
|
it: "Demo iniziale dell'interfaccia web",
|
||||||
|
en: "Initial demo of the web interface",
|
||||||
|
es: "Demo inicial de la interfaz web",
|
||||||
|
};
|
||||||
|
|
||||||
|
const clueTexts: Record<Locale, string[]> = {
|
||||||
|
it: [
|
||||||
|
"Inizio di un mezzo di soccorso stradale e sanitario.",
|
||||||
|
"Superficie destinata al decollo e all'atterraggio.",
|
||||||
|
"Mezzo di trasporto che corre su rotaie.",
|
||||||
|
"Grande mezzo per viaggiare sul mare.",
|
||||||
|
"Veicolo a due ruote con motore.",
|
||||||
|
"Complesso destinato al traffico degli aerei.",
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
"Opening of a road and medical rescue vehicle.",
|
||||||
|
"Surface used for takeoff and landing.",
|
||||||
|
"Means of transport that runs on rails.",
|
||||||
|
"Large vehicle used to travel by sea.",
|
||||||
|
"Two-wheeled motor vehicle.",
|
||||||
|
"Facility dedicated to airplane traffic.",
|
||||||
|
],
|
||||||
|
es: [
|
||||||
|
"Inicio de un vehículo de socorro vial y sanitario.",
|
||||||
|
"Superficie destinada al despegue y aterrizaje.",
|
||||||
|
"Medio de transporte que circula sobre rieles.",
|
||||||
|
"Gran vehículo para viajar por mar.",
|
||||||
|
"Vehículo de dos ruedas con motor.",
|
||||||
|
"Complejo destinado al tráfico de aviones.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseEntries = [
|
||||||
|
{
|
||||||
|
entry_id: "A1",
|
||||||
|
number: 1,
|
||||||
|
direction: "across" as const,
|
||||||
|
answer: "AMBU",
|
||||||
|
answer_length: 4,
|
||||||
|
row: 0,
|
||||||
|
col: 0,
|
||||||
|
cells: [[0, 0], [0, 1], [0, 2], [0, 3]] as [number, number][],
|
||||||
|
clue_source: "demo",
|
||||||
|
topics: ["transport", "health"],
|
||||||
|
pos: "NOUN",
|
||||||
|
is_seed: true,
|
||||||
|
added_by_filler: false,
|
||||||
|
confidence: 0.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry_id: "A2",
|
||||||
|
number: 2,
|
||||||
|
direction: "across" as const,
|
||||||
|
answer: "PISTA",
|
||||||
|
answer_length: 5,
|
||||||
|
row: 0,
|
||||||
|
col: 9,
|
||||||
|
cells: [[0, 9], [0, 10], [0, 11], [1, 11], [2, 11]] as [number, number][],
|
||||||
|
clue_source: "semantic_definition",
|
||||||
|
topics: ["transport", "aviation"],
|
||||||
|
pos: "NOUN",
|
||||||
|
is_seed: false,
|
||||||
|
added_by_filler: true,
|
||||||
|
confidence: 0.94,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry_id: "A3",
|
||||||
|
number: 3,
|
||||||
|
direction: "across" as const,
|
||||||
|
answer: "TRENO",
|
||||||
|
answer_length: 5,
|
||||||
|
row: 2,
|
||||||
|
col: 0,
|
||||||
|
cells: [[2, 0], [2, 1], [2, 2], [2, 3], [2, 4]] as [number, number][],
|
||||||
|
clue_source: "semantic_definition",
|
||||||
|
topics: ["transport"],
|
||||||
|
pos: "NOUN",
|
||||||
|
is_seed: true,
|
||||||
|
added_by_filler: false,
|
||||||
|
confidence: 0.98,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry_id: "A4",
|
||||||
|
number: 4,
|
||||||
|
direction: "across" as const,
|
||||||
|
answer: "NAVE",
|
||||||
|
answer_length: 4,
|
||||||
|
row: 4,
|
||||||
|
col: 0,
|
||||||
|
cells: [[4, 0], [4, 1], [4, 2], [4, 3]] as [number, number][],
|
||||||
|
clue_source: "semantic_definition",
|
||||||
|
topics: ["transport", "sea"],
|
||||||
|
pos: "NOUN",
|
||||||
|
is_seed: true,
|
||||||
|
added_by_filler: false,
|
||||||
|
confidence: 0.96,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry_id: "A5",
|
||||||
|
number: 5,
|
||||||
|
direction: "across" as const,
|
||||||
|
answer: "MOTO",
|
||||||
|
answer_length: 4,
|
||||||
|
row: 4,
|
||||||
|
col: 6,
|
||||||
|
cells: [[4, 6], [4, 7], [4, 8], [4, 9]] as [number, number][],
|
||||||
|
clue_source: "semantic_definition",
|
||||||
|
topics: ["transport"],
|
||||||
|
pos: "NOUN",
|
||||||
|
is_seed: true,
|
||||||
|
added_by_filler: false,
|
||||||
|
confidence: 0.95,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry_id: "A6",
|
||||||
|
number: 6,
|
||||||
|
direction: "across" as const,
|
||||||
|
answer: "AEROPORTO",
|
||||||
|
answer_length: 9,
|
||||||
|
row: 6,
|
||||||
|
col: 0,
|
||||||
|
cells: [[6, 0], [6, 1], [6, 2], [6, 3], [6, 4], [6, 5], [6, 6], [6, 7], [6, 8]] as [number, number][],
|
||||||
|
clue_source: "semantic_definition",
|
||||||
|
topics: ["transport", "aviation"],
|
||||||
|
pos: "NOUN",
|
||||||
|
is_seed: true,
|
||||||
|
added_by_filler: false,
|
||||||
|
confidence: 0.97,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getMockCrosswordResponse(locale: Locale): CrosswordResponse {
|
||||||
|
const texts = clueTexts[locale];
|
||||||
|
const entries = baseEntries.map((entry, index) => ({
|
||||||
|
...entry,
|
||||||
|
clue: texts[index],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema_version: "1.0",
|
||||||
|
request_id: "req-demo-transport",
|
||||||
|
crossword_id: "demo-transport",
|
||||||
|
generated_at: "2026-04-29T11:30:00+02:00",
|
||||||
|
status: "ok",
|
||||||
|
generator: {
|
||||||
|
topic: ["transport"],
|
||||||
|
difficulty: "medium",
|
||||||
|
seed: 2,
|
||||||
|
runtime_lexicon: "lexicon_it_curated_llm_aggressive.json",
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
title: titles[locale],
|
||||||
|
subtitle: subtitles[locale],
|
||||||
|
rows: solutionRows.length,
|
||||||
|
cols: solutionRows[0].length,
|
||||||
|
total_words: 6,
|
||||||
|
intersections: 8,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
rows: solutionRows.length,
|
||||||
|
cols: solutionRows[0].length,
|
||||||
|
cell_size_hint: 42,
|
||||||
|
cells: buildCells(solutionRows),
|
||||||
|
},
|
||||||
|
entries,
|
||||||
|
clues: {
|
||||||
|
across: entries.map((entry) => ({
|
||||||
|
number: entry.number,
|
||||||
|
entry_id: entry.entry_id,
|
||||||
|
text: entry.clue,
|
||||||
|
enumeration: entry.answer_length,
|
||||||
|
topic_match: true,
|
||||||
|
source: entry.clue_source,
|
||||||
|
})),
|
||||||
|
down: [],
|
||||||
|
},
|
||||||
|
solution: {
|
||||||
|
grid_rows: solutionRows,
|
||||||
|
words: ["AMBU", "PISTA", "TRENO", "NAVE", "MOTO", "AEROPORTO"],
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
seed_words_requested: 19,
|
||||||
|
seed_words_placed: 19,
|
||||||
|
filler_words_added: 1,
|
||||||
|
filled_cells: 35,
|
||||||
|
empty_cells: 61,
|
||||||
|
empty_ratio: 0.6354,
|
||||||
|
target_empty_ratio: 0.1667,
|
||||||
|
topic_words: 6,
|
||||||
|
off_topic_words: 0,
|
||||||
|
pos_counts: {
|
||||||
|
sostantivi: 6,
|
||||||
|
aggettivi: 0,
|
||||||
|
verbi: 0,
|
||||||
|
avverbi: 0,
|
||||||
|
preposizioni: 0,
|
||||||
|
congiunzioni: 0,
|
||||||
|
altri: 0,
|
||||||
|
},
|
||||||
|
generation_seconds: 11.4,
|
||||||
|
},
|
||||||
|
artifacts: {
|
||||||
|
pdf_player: null,
|
||||||
|
pdf_solution: null,
|
||||||
|
thumbnail: null,
|
||||||
|
html_preview: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
115
webapp/lib/types.ts
Normal file
115
webapp/lib/types.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
export type CrosswordRequest = {
|
||||||
|
schema_version: string;
|
||||||
|
request_id: string;
|
||||||
|
requested_at: string;
|
||||||
|
generator: {
|
||||||
|
topic: string[];
|
||||||
|
difficulty: string;
|
||||||
|
seed: number | null;
|
||||||
|
initial_word_count: number;
|
||||||
|
themed_fill_count: number;
|
||||||
|
target_empty_ratio: number;
|
||||||
|
diffxy: number;
|
||||||
|
time_limit_seconds: number;
|
||||||
|
max_candidates_per_word: number;
|
||||||
|
lexicon_file: string;
|
||||||
|
definitions_enabled: boolean;
|
||||||
|
definition_style: string;
|
||||||
|
preferred_output_language: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrosswordCell = {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
kind: "letter" | "block";
|
||||||
|
solution: string | null;
|
||||||
|
display: string | null;
|
||||||
|
number: number | null;
|
||||||
|
across_entry_id: string | null;
|
||||||
|
down_entry_id: string | null;
|
||||||
|
is_prefilled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrosswordClue = {
|
||||||
|
number: number;
|
||||||
|
entry_id: string;
|
||||||
|
text: string;
|
||||||
|
enumeration: number;
|
||||||
|
topic_match: boolean;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrosswordEntry = {
|
||||||
|
entry_id: string;
|
||||||
|
number: number;
|
||||||
|
direction: "across" | "down";
|
||||||
|
answer: string;
|
||||||
|
answer_length: number;
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
cells: [number, number][];
|
||||||
|
clue: string;
|
||||||
|
clue_source: string;
|
||||||
|
topics: string[];
|
||||||
|
pos: string;
|
||||||
|
is_seed: boolean;
|
||||||
|
added_by_filler: boolean;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrosswordResponse = {
|
||||||
|
schema_version: string;
|
||||||
|
request_id: string;
|
||||||
|
crossword_id: string;
|
||||||
|
generated_at: string;
|
||||||
|
status: string;
|
||||||
|
generator: {
|
||||||
|
topic: string[];
|
||||||
|
difficulty: string;
|
||||||
|
seed: number | null;
|
||||||
|
runtime_lexicon: string;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
total_words: number;
|
||||||
|
intersections: number;
|
||||||
|
};
|
||||||
|
grid: {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
cell_size_hint: number;
|
||||||
|
cells: CrosswordCell[];
|
||||||
|
};
|
||||||
|
entries: CrosswordEntry[];
|
||||||
|
clues: {
|
||||||
|
across: CrosswordClue[];
|
||||||
|
down: CrosswordClue[];
|
||||||
|
};
|
||||||
|
solution: {
|
||||||
|
grid_rows: string[];
|
||||||
|
words: string[];
|
||||||
|
};
|
||||||
|
diagnostics: {
|
||||||
|
seed_words_requested: number;
|
||||||
|
seed_words_placed: number;
|
||||||
|
filler_words_added: number;
|
||||||
|
filled_cells: number;
|
||||||
|
empty_cells: number;
|
||||||
|
empty_ratio: number;
|
||||||
|
target_empty_ratio: number;
|
||||||
|
topic_words: number;
|
||||||
|
off_topic_words: number;
|
||||||
|
pos_counts: Record<string, number>;
|
||||||
|
generation_seconds: number;
|
||||||
|
};
|
||||||
|
artifacts: {
|
||||||
|
pdf_player: string | null;
|
||||||
|
pdf_solution: string | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
html_preview: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
6
webapp/next-env.d.ts
vendored
Normal file
6
webapp/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
7
webapp/next.config.ts
Normal file
7
webapp/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
5413
webapp/package-lock.json
generated
Normal file
5413
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
webapp/package.json
Normal file
24
webapp/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "cruciverba-webapp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15.2.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-next": "^15.2.0",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
webapp/tsconfig.json
Normal file
27
webapp/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user