alpha01 filetti: web app, crossword service and tor batch

This commit is contained in:
2026-06-05 16:22:17 +02:00
parent 47d8957e15
commit 9cb8a5aa8f
29 changed files with 8590 additions and 0 deletions

338
webapp/lib/i18n.ts Normal file
View 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];
}

View 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
View 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;
};
};