diff --git a/src/puzzle/ClueGenerator.java b/src/puzzle/ClueGenerator.java index fb660a8..d04a4a2 100644 --- a/src/puzzle/ClueGenerator.java +++ b/src/puzzle/ClueGenerator.java @@ -5,216 +5,218 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import static puzzle.ExportFormat.*; public class ClueGenerator { - - private static final String OLLAMA_URL = "http://localhost:11434/api/chat"; - private static final String MODEL = "qwen2.5:14b"; - private static final String HINTS_FILE = "/data/puzzle/export_with_hints.csv"; - private static Map prebuiltClues = null; - - private static synchronized void ensurePrebuiltCluesLoaded() { - if (prebuiltClues != null) return; - prebuiltClues = new HashMap<>(); - try { - List lines = Files.readAllLines(Path.of(HINTS_FILE), StandardCharsets.UTF_8); - for (String line : lines) { - String[] parts = line.split(",", 3); - if (parts.length >= 3) { - String word = parts[0].trim().toUpperCase(Locale.ROOT); - String rawClue = parts[2].trim(); - if (rawClue.startsWith("\"") && rawClue.endsWith("\"")) { - rawClue = rawClue.substring(1, rawClue.length() - 1).replace("\"\"", "\""); - } - if (!word.isEmpty() && !rawClue.isEmpty()) { - prebuiltClues.put(word, rawClue); - } - } + + private static final String OLLAMA_URL = "http://localhost:11434/api/chat"; + private static final String MODEL = "qwen2.5:14b"; + private static final String HINTS_FILE = "/home/mike/dev/puzzle-generator/nl_score_hints.csv"; + private static Map prebuiltClues = null; + + private static synchronized void ensurePrebuiltCluesLoaded() { + if (prebuiltClues != null) return; + prebuiltClues = new HashMap<>(); + try { + var lines = Files.readAllLines(Path.of(HINTS_FILE), StandardCharsets.UTF_8); + for (var line : lines) { + var parts = line.split(",", 3); + if (parts.length >= 3) { + var word = parts[0].trim().toUpperCase(Locale.ROOT); + var rawClue = parts[2].trim(); + if (rawClue.startsWith("\"") && rawClue.endsWith("\"")) { + rawClue = rawClue.substring(1, rawClue.length() - 1).replace("\"\"", "\""); + } + if (!word.isEmpty() && !rawClue.isEmpty()) { + prebuiltClues.put(word, rawClue); + } } - } catch (IOException e) { - System.err.println("Warning: " + HINTS_FILE + " not found or could not be read."); - } - } - - public static ExportFormat.ExportedPuzzle applyClues(ExportFormat.ExportedPuzzle puzzle) { - if (puzzle == null || puzzle.words().isEmpty()) { - return puzzle; - } - - ensurePrebuiltCluesLoaded(); - - Map finalClueMap = new HashMap<>(); - List wordsMissingClues = new ArrayList<>(); - - for (var w : puzzle.words()) { - String wordUpper = w.word().toUpperCase(Locale.ROOT); - if (prebuiltClues.containsKey(wordUpper)) { - finalClueMap.put(w.word(), prebuiltClues.get(wordUpper)); - } else { - wordsMissingClues.add(w.word()); - } - } - - if (!wordsMissingClues.isEmpty()) { - Map generatedClues = generateClues(wordsMissingClues); - finalClueMap.putAll(generatedClues); - } - - List wordsWithClues = new ArrayList<>(); - for (var w : puzzle.words()) { - String clue = finalClueMap.getOrDefault(w.word(), w.word()); - wordsWithClues.add(new ExportFormat.WordOut( - w.word(), - clue, - w.startRow(), - w.startCol(), - w.direction(), - w.answer(), - w.arrowRow(), - w.arrowCol(), - w.isReversed() - )); - } - - return new ExportFormat.ExportedPuzzle(puzzle.gridv2(), wordsWithClues, puzzle.difficulty(), puzzle.rewards()); - } - - public static Map generateClues(List words) { - if (words == null || words.isEmpty()) { + } + } catch (IOException e) { + System.err.println("Warning: " + HINTS_FILE + " not found or could not be read."); + } + } + + public static ExportedPuzzle applyClues(ExportedPuzzle puzzle) { + if (puzzle == null || puzzle.words().isEmpty()) { + return puzzle; + } + + ensurePrebuiltCluesLoaded(); + + Map finalClueMap = new HashMap<>(); + List wordsMissingClues = new ArrayList<>(); + + for (var w : puzzle.words()) { + var wordUpper = w.word().toUpperCase(Locale.ROOT); + if (prebuiltClues.containsKey(wordUpper)) { + finalClueMap.put(w.word(), prebuiltClues.get(wordUpper)); + } else { + wordsMissingClues.add(w.word()); + } + } + + if (!wordsMissingClues.isEmpty()) { + var generatedClues = generateClues(wordsMissingClues); + finalClueMap.putAll(generatedClues); + } + + List wordsWithClues = new ArrayList<>(); + for (var w : puzzle.words()) { + var clue = finalClueMap.getOrDefault(w.word(), w.word()); + wordsWithClues.add(new WordOut( + w.word(), + clue, + w.startRow(), + w.startCol(), + w.direction(), + w.answer(), + w.arrowRow(), + w.arrowCol(), + w.isReversed(), + w.complex() + )); + } + + return new ExportedPuzzle(puzzle.gridv2(), wordsWithClues, puzzle.difficulty(), puzzle.rewards()); + } + + public static Map generateClues(List words) { + if (words == null || words.isEmpty()) { + return Collections.emptyMap(); + } + + var prompt = createCluePrompt(words); + try { + var jsonRequest = String.format( + "{\"model\":\"%s\",\"messages\":[{\"role\":\"user\",\"content\":\"%s\"}],\"stream\":false,\"temperature\":0.7}", + MODEL, escapeJson(prompt) + ); + + var responseBody = curlPostJson(OLLAMA_URL, jsonRequest, 120); + var content = extractChatContent(responseBody); + + if (content == null || content.isEmpty()) { return Collections.emptyMap(); - } - - String prompt = createCluePrompt(words); - try { - String jsonRequest = String.format( - "{\"model\":\"%s\",\"messages\":[{\"role\":\"user\",\"content\":\"%s\"}],\"stream\":false,\"temperature\":0.7}", - MODEL, escapeJson(prompt) - ); - - String responseBody = curlPostJson(OLLAMA_URL, jsonRequest, 120); - String content = extractChatContent(responseBody); - - if (content == null || content.isEmpty()) { - return Collections.emptyMap(); + } + + return parseCluesFromReply(words, content); + } catch (Exception e) { + System.err.println("Failed to generate clues: " + e.getMessage()); + return Collections.emptyMap(); + } + } + + private static String createCluePrompt(List words) { + return "Je bent een expert in het maken van kruiswoordpuzzels. Geef voor elk van de onderstaande woorden een korte, uitdagende maar duidelijke cryptische of beschrijvende aanwijzing in het Nederlands.\n\n" + + "Output ALLEEN in dit formaat:\n" + + "woord1:aanwijzing\n" + + "woord2:aanwijzing\n\n" + + "GEEN andere tekst of uitleg. Sla GEEN woorden over.\n\n" + + "Lijst:\n" + + String.join("\n", words); + } + + private static Map parseCluesFromReply(List expectedWords, String reply) { + Map wordClueMap = new HashMap<>(); + var lines = reply.split("\n"); + + for (var line : lines) { + line = line.trim(); + if (line.contains(":")) { + var parts = line.split(":", 2); + if (parts.length == 2) { + var wordPart = parts[0].trim().replaceAll("^[\\d+.)*\\-\\s]+", "").toLowerCase(); + var clue = parts[1].trim(); + if (!clue.isEmpty()) { + wordClueMap.put(wordPart, clue); + } } - - return parseCluesFromReply(words, content); - } catch (Exception e) { - System.err.println("Failed to generate clues: " + e.getMessage()); - return Collections.emptyMap(); - } - } - - private static String createCluePrompt(List words) { - return "Je bent een expert in het maken van kruiswoordpuzzels. Geef voor elk van de onderstaande woorden een korte, uitdagende maar duidelijke cryptische of beschrijvende aanwijzing in het Nederlands.\n\n" + - "Output ALLEEN in dit formaat:\n" + - "woord1:aanwijzing\n" + - "woord2:aanwijzing\n\n" + - "GEEN andere tekst of uitleg. Sla GEEN woorden over.\n\n" + - "Lijst:\n" + - String.join("\n", words); - } - - private static Map parseCluesFromReply(List expectedWords, String reply) { - Map wordClueMap = new HashMap<>(); - String[] lines = reply.split("\n"); - - for (String line : lines) { - line = line.trim(); - if (line.contains(":")) { - String[] parts = line.split(":", 2); - if (parts.length == 2) { - String wordPart = parts[0].trim().replaceAll("^[\\d+.)*\\-\\s]+", "").toLowerCase(); - String clue = parts[1].trim(); - if (!clue.isEmpty()) { - wordClueMap.put(wordPart, clue); - } - } - } - } - - Map results = new HashMap<>(); - for (String word : expectedWords) { - String clue = wordClueMap.get(word.toLowerCase()); - if (clue != null) { - results.put(word, clue); - } - } - return results; - } - - private static String curlPostJson(String url, String jsonBody, int timeoutSeconds) throws Exception { - var tempFile = Files.createTempFile("clue-request-", ".json"); - try { - Files.writeString(tempFile, jsonBody, StandardCharsets.UTF_8); - List cmd = new ArrayList<>(); - cmd.add("curl"); - cmd.add("-fsSL"); - cmd.add("--connect-timeout"); - cmd.add("10"); - cmd.add("--max-time"); - cmd.add(String.valueOf(timeoutSeconds)); - cmd.add("-H"); - cmd.add("Content-Type: application/json"); - cmd.add("-d"); - cmd.add("@" + tempFile); - cmd.add(url); - - var p = new ProcessBuilder(cmd) - .redirectErrorStream(true) - .start(); - - var bytes = p.getInputStream().readAllBytes(); - var code = p.waitFor(); - - if (code != 0) { - throw new IOException("curl POST failed (" + code + ") url=" + url + "\nOutput:\n" + - new String(bytes, StandardCharsets.UTF_8)); - } - - return new String(bytes, StandardCharsets.UTF_8); - } finally { - Files.deleteIfExists(tempFile); - } - } - - private static String extractChatContent(String json) { - if (json == null) return null; - var choices = json.indexOf("\"choices\""); - var p = (choices >= 0) ? choices : 0; - var i = json.indexOf("\"content\"", p); - if (i < 0) { - // Fallback for Ollama non-chat format if needed, but we used /api/chat - // Ollama /api/chat returns {"model":"...","message":{"role":"assistant","content":"..."}} - i = json.indexOf("\"content\""); - if (i < 0) return null; - } - var colon = json.indexOf(':', i); - if (colon < 0) return null; - var q = json.indexOf('"', colon + 1); - if (q < 0) return null; - var sb = new StringBuilder(); - var esc = false; - for (var k = q + 1; k < json.length(); k++) { - var ch = json.charAt(k); - if (esc) { - if (ch == 'n') sb.append('\n'); - else if (ch == 't') sb.append('\t'); - else if (ch == 'r') sb.append('\r'); - else sb.append(ch); - esc = false; - } else { - if (ch == '\\') esc = true; - else if (ch == '"') break; - else sb.append(ch); - } - } - return sb.toString(); - } - - private static String escapeJson(String str) { - return str.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n"); - } + } + } + + Map results = new HashMap<>(); + for (var word : expectedWords) { + var clue = wordClueMap.get(word.toLowerCase()); + if (clue != null) { + results.put(word, clue); + } + } + return results; + } + + private static String curlPostJson(String url, String jsonBody, int timeoutSeconds) throws Exception { + var tempFile = Files.createTempFile("clue-request-", ".json"); + try { + Files.writeString(tempFile, jsonBody, StandardCharsets.UTF_8); + List cmd = new ArrayList<>(); + cmd.add("curl"); + cmd.add("-fsSL"); + cmd.add("--connect-timeout"); + cmd.add("10"); + cmd.add("--max-time"); + cmd.add(String.valueOf(timeoutSeconds)); + cmd.add("-H"); + cmd.add("Content-Type: application/json"); + cmd.add("-d"); + cmd.add("@" + tempFile); + cmd.add(url); + + var p = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .start(); + + var bytes = p.getInputStream().readAllBytes(); + var code = p.waitFor(); + + if (code != 0) { + throw new IOException("curl POST failed (" + code + ") url=" + url + "\nOutput:\n" + + new String(bytes, StandardCharsets.UTF_8)); + } + + return new String(bytes, StandardCharsets.UTF_8); + } finally { + Files.deleteIfExists(tempFile); + } + } + + private static String extractChatContent(String json) { + if (json == null) return null; + var choices = json.indexOf("\"choices\""); + var p = (choices >= 0) ? choices : 0; + var i = json.indexOf("\"content\"", p); + if (i < 0) { + // Fallback for Ollama non-chat format if needed, but we used /api/chat + // Ollama /api/chat returns {"model":"...","message":{"role":"assistant","content":"..."}} + i = json.indexOf("\"content\""); + if (i < 0) return null; + } + var colon = json.indexOf(':', i); + if (colon < 0) return null; + var q = json.indexOf('"', colon + 1); + if (q < 0) return null; + var sb = new StringBuilder(); + var esc = false; + for (var k = q + 1; k < json.length(); k++) { + var ch = json.charAt(k); + if (esc) { + if (ch == 'n') sb.append('\n'); + else if (ch == 't') sb.append('\t'); + else if (ch == 'r') sb.append('\r'); + else sb.append(ch); + esc = false; + } else { + if (ch == '\\') esc = true; + else if (ch == '"') break; + else sb.append(ch); + } + } + return sb.toString(); + } + + private static String escapeJson(String str) { + return str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n"); + } } diff --git a/src/puzzle/DailyGenerator.java b/src/puzzle/DailyGenerator.java deleted file mode 100644 index ead7727..0000000 --- a/src/puzzle/DailyGenerator.java +++ /dev/null @@ -1,260 +0,0 @@ -package puzzle; - -import java.io.*; -import java.nio.charset.StandardCharsets; -import java.nio.file.*; -import java.time.LocalDate; -import java.util.*; - -/** - * DailyGenerator - Generates daily themed puzzles with JSON output - */ -public class DailyGenerator { - - private static String env(String name, String defaultValue) { - var val = System.getenv(name); - return (val == null || val.isEmpty()) ? defaultValue : val; - } - - private static int envInt(String name, int defaultValue) { - try { - return Integer.parseInt(env(name, String.valueOf(defaultValue))); - } catch (NumberFormatException e) { - return defaultValue; - } - } - - private static boolean envBool(String name, boolean defaultValue) { - var val = env(name, String.valueOf(defaultValue)); - return "true".equalsIgnoreCase(val) || "1".equals(val); - } - - static void main(String[] args) { - var outDir = env("OUT_DIR", "/home/mike/dev/puzzle-generator/data/"); - var wordsPath = env("WORDS_PATH", "/data/puzzle/export_with_hints.csv"); - var puzzlesPerDay = envInt("PUZZLES_PER_DAY", 3); - var seed = envInt("SEED", (int) System.currentTimeMillis()); - var themeFilter = envBool("THEME_FILTER", true); - var themeMinScore = Double.parseDouble(env("THEME_MIN_SCORE", "0.0")); - - var today = LocalDate.now(); - var dateStr = today.toString(); - - System.out.println("=== Daily Puzzle Generator ==="); - System.out.println("Date: " + dateStr); - System.out.println("Output: " + outDir); - System.out.println("Puzzles per day: " + puzzlesPerDay); - System.out.println("Theme filtering: " + themeFilter); - System.out.println(); - - // Load word list - SwedishGenerator.Dict dict; - var llmScores = SwedishGenerator.loadScores(); - try { - dict = SwedishGenerator.loadWords(wordsPath, llmScores); - System.out.println("Loaded " + dict.words.size() + " words"); - } catch (Exception e) { - System.err.println("Failed to load words: " + e.getMessage()); - System.exit(1); - return; - } - - // Create output directory - try { - Files.createDirectories(Paths.get(outDir)); - } catch (IOException e) { - System.err.println("Failed to create output dir: " + e.getMessage()); - System.exit(1); - return; - } - - // Generate puzzles - List generatedFiles = new ArrayList<>(); - var themes = new String[]{ "algemeen", "nieuws", "technologie", "sport", "weer", "economie" }; - - for (var i = 1; i <= puzzlesPerDay; i++) { - System.out.println("\n--- Generating puzzle " + i + "/" + puzzlesPerDay + " ---"); - - // Select theme - var theme = themes[new Random(seed + i).nextInt(themes.length)]; - System.out.println("Theme: " + theme); - - // Filter word list by theme - List filteredWords = dict.words; - if (themeFilter && !theme.equals("algemeen")) { - System.out.println("Filtered to " + filteredWords.size() + " words for theme '" + theme + "'"); - - // If too few words, fall back to general - if (filteredWords.size() < 50) { - System.out.println("Not enough themed words, using general list"); - filteredWords = dict.words; - theme = "algemeen"; - } - } - - // Create filtered dict - var themedDict = filterDict(dict, filteredWords); - - // Generate puzzle - var opts = new Main.Opts(); - opts.seed = seed + i; - opts.pop = 18; - opts.gens = 100; - opts.tries = 50; - opts.wordsPath = wordsPath; - opts.minSimplicity = 0; // default - - var result = generateWithFilteredDict(opts, themedDict, llmScores); - - if (result == null) { - System.out.println("Failed to generate puzzle " + i); - continue; - } - - System.out.println("Generated puzzle with " + result.filled().clueMap.size() + " words"); - - // Export to JSON - var exported = ExportFormat.exportFormatFromFilled( - result, 1, new ExportFormat.Rewards(50, 2, 1) - ); - - // Generate clues via LLM - System.out.println("Generating clues for " + exported.words().size() + " words..."); - exported = ClueGenerator.applyClues(exported); - - // Write to JSON file - var filename = String.format("crossword_%s_%02d_%s.json", dateStr, i, safeSlug(theme)); - var outputPath = Paths.get(outDir, filename); - - try { - var json = toJson(exported, dateStr, theme); - Files.writeString(outputPath, json, StandardCharsets.UTF_8); - generatedFiles.add(filename); - System.out.println("Saved: " + filename); - } catch (IOException e) { - System.err.println("Failed to write " + filename + ": " + e.getMessage()); - } - } - - // Write index.json - try { - var indexJson = toIndexJson(dateStr, generatedFiles); - Files.writeString(Paths.get(outDir, "index.json"), indexJson, StandardCharsets.UTF_8); - System.out.println("\n✓ Generated " + generatedFiles.size() + " puzzles for " + dateStr); - } catch (IOException e) { - System.err.println("Failed to write index.json: " + e.getMessage()); - } - } - - private static SwedishGenerator.Dict filterDict(SwedishGenerator.Dict dict, List allowedWords) { - Set allowed = new HashSet<>(allowedWords); - var newIndex = new HashMap(); - var newLenCounts = new HashMap(); - - for (var word : dict.words) { - if (!allowed.contains(word)) continue; - - var L = word.length(); - newLenCounts.put(L, newLenCounts.getOrDefault(L, 0) + 1); - - var entry = newIndex.get(L); - if (entry == null) { - entry = new SwedishGenerator.DictEntry(L); - newIndex.put(L, entry); - } - - var idx = entry.words.size(); - entry.words.add(word); - - for (var i = 0; i < L; i++) { - var letter = word.charAt(i) - 'A'; - if (letter >= 0 && letter < 26) { - entry.pos[i][letter].add(idx); - } - } - } - - return new SwedishGenerator.Dict(new ArrayList<>(allowed), newIndex, newLenCounts); - } - - private static SwedishGenerator.PuzzleResult generateWithFilteredDict(Main.Opts opts, SwedishGenerator.Dict dict, Map llmScores) { - var rng = new SwedishGenerator.Rng(opts.seed); - - for (var attempt = 1; attempt <= opts.tries; attempt++) { - var mask = SwedishGenerator.generateMask(rng, dict.lenCounts, opts.pop, opts.gens, true); - var filled = SwedishGenerator.fillMask(rng, mask, dict.index, llmScores, 200, 30000, true); - - if (filled.ok) { - return new SwedishGenerator.PuzzleResult(mask, filled); - } - } - return null; - } - - private static String toJson(ExportFormat.ExportedPuzzle puzzle, String date, String theme) { - var sb = new StringBuilder(); - sb.append("{\n"); - sb.append(" \"date\": \"").append(escapeJson(date)).append("\",\n"); - sb.append(" \"theme\": \"").append(escapeJson(theme)).append("\",\n"); - sb.append(" \"difficulty\": ").append(puzzle.difficulty()).append(",\n"); - sb.append(" \"rewards\": {\n"); - sb.append(" \"coins\": ").append(puzzle.rewards().coins()).append(",\n"); - sb.append(" \"stars\": ").append(puzzle.rewards().stars()).append(",\n"); - sb.append(" \"hints\": ").append(puzzle.rewards().hints()).append("\n"); - sb.append(" },\n"); - sb.append(" \"gridv2\": [\n"); - for (var i = 0; i < puzzle.gridv2().size(); i++) { - sb.append(" \"").append(escapeJson(puzzle.gridv2().get(i))).append("\""); - if (i < puzzle.gridv2().size() - 1) sb.append(","); - sb.append("\n"); - } - sb.append(" ],\n"); - sb.append(" \"words\": [\n"); - for (var i = 0; i < puzzle.words().size(); i++) { - var w = puzzle.words().get(i); - sb.append(" {\n"); - sb.append(" \"word\": \"").append(escapeJson(w.word())).append("\",\n"); - sb.append(" \"clue\": \"").append(escapeJson(w.clue())).append("\",\n"); - sb.append(" \"startRow\": ").append(w.startRow()).append(",\n"); - sb.append(" \"startCol\": ").append(w.startCol()).append(",\n"); - sb.append(" \"direction\": \"").append(escapeJson(w.direction())).append("\",\n"); - sb.append(" \"answer\": \"").append(escapeJson(w.answer())).append("\",\n"); - sb.append(" \"arrowRow\": ").append(w.arrowRow()).append(",\n"); - sb.append(" \"arrowCol\": ").append(w.arrowCol()).append(",\n"); - sb.append(" \"isReversed\": ").append(w.isReversed()).append("\n"); - sb.append(" }"); - if (i < puzzle.words().size() - 1) sb.append(","); - sb.append("\n"); - } - sb.append(" ]\n"); - sb.append("}\n"); - return sb.toString(); - } - - private static String toIndexJson(String date, List files) { - var sb = new StringBuilder(); - sb.append("{\n"); - sb.append(" \"date\": \"").append(escapeJson(date)).append("\",\n"); - sb.append(" \"files\": [\n"); - for (var i = 0; i < files.size(); i++) { - sb.append(" \"").append(escapeJson(files.get(i))).append("\""); - if (i < files.size() - 1) sb.append(","); - sb.append("\n"); - } - sb.append(" ]\n"); - sb.append("}\n"); - return sb.toString(); - } - - private static String escapeJson(String s) { - return s.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); - } - - private static String safeSlug(String s) { - return s.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", ""); - } -} diff --git a/src/puzzle/ExportFormat.java b/src/puzzle/ExportFormat.java index 7ddec70..220c22b 100644 --- a/src/puzzle/ExportFormat.java +++ b/src/puzzle/ExportFormat.java @@ -1,6 +1,7 @@ package puzzle; import java.util.*; +import static puzzle.SwedishGenerator.*; /** * ExportFormat.java @@ -13,7 +14,7 @@ import java.util.*; */ public final class ExportFormat { - private ExportFormat() { } + private ExportFormat() { } private static boolean isLetter(char ch) { return ch >= 'A' && ch <= 'Z'; } @@ -23,20 +24,16 @@ public final class ExportFormat { // ---------- Public API ---------- - public static ExportedPuzzle exportFormatFromFilled(SwedishGenerator.PuzzleResult puz) { - return exportFormatFromFilled(puz, 1, new Rewards(50, 2, 1)); - } - - public static ExportedPuzzle exportFormatFromFilled(SwedishGenerator.PuzzleResult puz, int difficulty, Rewards rewards) { + public static ExportedPuzzle exportFormatFromFilled(PuzzleResult puz, int difficulty, Rewards rewards) { Objects.requireNonNull(puz, "puz"); var g = puz.filled().grid; var H = g.length; var W = g[0].length; // 1) extract "placed" list from all clue digits in the filled grid - List placed = new ArrayList<>(); - var allSlots = SwedishGenerator.extractSlots(g); - var clueMap = puz.filled().clueMap; + List placed = new ArrayList<>(); + var allSlots = extractSlots(g); + var clueMap = puz.filled().clueMap; for (var s : allSlots) { var word = clueMap.get(s.key()); @@ -113,24 +110,25 @@ public final class ExportFormat { p.word, // answer p.arrowRow - minR, p.arrowCol - minC, - p.isReversed + p.isReversed, + puz.dict().words().get(p.word).difficulty() )); } return new ExportedPuzzle(gridv2, wordsOut, difficulty, rewards); } - + static final String HORIZONTAL = "h", VERTICAL = "v"; /** * Convert a generator Slot + assigned word into a Placed object for export. */ - private static Placed extractPlacedFromSlot(SwedishGenerator.Slot s, String word) { - int r = s.clueR; - int c = s.clueC; - char d = s.dir; + private static Placed extractPlacedFromSlot(Slot s, String word) { + int r = s.clueR(); + int c = s.clueC(); + char d = s.dir(); List cells = new ArrayList<>(); - for (int i = 0; i < s.len; i++) { - cells.add(new int[]{ s.rs[i], s.cs[i] }); + for (int i = 0; i < s.len(); i++) { + cells.add(new int[]{ s.rs()[i], s.cs()[i] }); } // Canonicalize: always output right/down @@ -139,26 +137,26 @@ public final class ExportFormat { boolean isReversed = false; if (d == '2') { // right -> horizontal - direction = "horizontal"; + direction = HORIZONTAL; startRow = cells.get(0)[0]; startCol = cells.get(0)[1]; arrowRow = r; arrowCol = c; } else if (d == '3' || d == '5') { // down or down-bent -> vertical - direction = "vertical"; + direction = VERTICAL; startRow = cells.get(0)[0]; startCol = cells.get(0)[1]; arrowRow = r; arrowCol = c; } else if (d == '4') { // left -> horizontal (REVERSED) - direction = "horizontal"; + direction = HORIZONTAL; isReversed = true; startRow = cells.get(0)[0]; startCol = cells.get(0)[1]; arrowRow = r; arrowCol = c; } else if (d == '1') { // up -> vertical (REVERSED) - direction = "vertical"; + direction = VERTICAL; isReversed = true; startRow = cells.get(0)[0]; startCol = cells.get(0)[1]; @@ -195,21 +193,13 @@ public final class ExportFormat { * @param cells word cells * @param arrow [arrowRow, arrowCol] */ private record Placed(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol, List cells, int[] arrow, - boolean isReversed) { - - } + boolean isReversed) { } - public record Rewards(int coins, int stars, int hints) { - - } + public record Rewards(int coins, int stars, int hints) { } /** * @param direction "horizontal" | "vertical" */ - public record WordOut(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol, boolean isReversed) { - - } + public record WordOut(String word, String clue, int startRow, int startCol, String direction, String answer, int arrowRow, int arrowCol, boolean isReversed, int complex) { } - public record ExportedPuzzle(List gridv2, List words, int difficulty, Rewards rewards) { - - } + public record ExportedPuzzle(List gridv2, List words, int difficulty, Rewards rewards) { } } diff --git a/src/puzzle/Main.java b/src/puzzle/Main.java index 80d01d7..ce9bad5 100644 --- a/src/puzzle/Main.java +++ b/src/puzzle/Main.java @@ -2,195 +2,317 @@ package puzzle; import puzzle.SwedishGenerator.PuzzleResult; import puzzle.SwedishGenerator.Rng; + import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; +import java.nio.file.*; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Locale; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; +import java.util.*; +import java.util.concurrent.*; + import static puzzle.SwedishGenerator.fillMask; import static puzzle.SwedishGenerator.generateMask; import static puzzle.SwedishGenerator.loadScores; import static puzzle.SwedishGenerator.loadWords; public class Main { - // ---------------- CLI ---------------- - + + final static String OUT_DIR = envOrDefault("OUT_DIR", "/data/puzzle"); + final static Path PUZZLE_DIR = Paths.get(OUT_DIR, "puzzles"); + static final Path INDEX_FILE = PUZZLE_DIR.resolve("index.json"); + static final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + static final String CREATED_AT = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")); + static final String FILE_ID = CREATED_AT.replace(":", "-") + "_" + (System.currentTimeMillis() / 1000); + static final String FILE_NAME = FILE_ID + ".json"; + static final Path OUTPUT_PATH = PUZZLE_DIR.resolve(FILE_NAME); + static final String DATE_STRING = now.toLocalDate().toString(); + public static class Opts { - public int seed = 1234; - public int pop = 18; - public int gens = 300; - public String wordsPath = "/data/puzzle/pool.txt"; - public double minSimplicity = 0; // 0 means no limit - public int threads = Math.max(1, Runtime.getRuntime().availableProcessors()); - public int tries = threads; + + public int seed = (int) (System.nanoTime() ^ System.currentTimeMillis()); + public int pop = 18; + public int gens = 500; + public String wordsPath = "nl_score_hints.csv"; + public double minSimplicity = 0; // 0 means no limit + public int threads = Math.max(1, Runtime.getRuntime().availableProcessors()); + public int tries = threads; + public boolean reindex = false; + } + + public void main(String[] args) { + var opts = parseArgs(args); + + if (opts.reindex) { + + section("Reindex"); + info("OutputDir : " + OUT_DIR); + rebuildIndex(); + return; + } + + section("Puzzle Generator"); + info("OutputDir : " + OUT_DIR); + info("WordsFile : " + opts.wordsPath); + + section("Settings"); + printSettings(opts); + + var res = generatePuzzle(opts); + if (res == null) { + err("Search status : UNSOLVED"); + err("Reason : No solution found within tries."); + System.exit(1); + return; + } + + section("Result"); + info(String.format(Locale.ROOT, "simplicity : %.2f", res.filled().simplicity)); + + section("Mask"); + System.out.print(indentLines(SwedishGenerator.gridToString(res.mask()), " ")); + + section("Grid (raw)"); + System.out.print(indentLines(SwedishGenerator.gridToString(res.filled().grid), " ")); + + section("Grid (human)"); + System.out.print(indentLines(SwedishGenerator.renderHuman(res.filled().grid), " ")); + + var exported = ExportFormat.exportFormatFromFilled(res, 1, new ExportFormat.Rewards(50, 2, 1)); + + section("Clues"); + info("status : generating..."); + info("generatedFor : " + exported.words().size()); + exported = ClueGenerator.applyClues(exported); + info("status : done"); + + section("Words"); + printWordsTable(exported.words()); + + section("Gridv2"); + for (var row : exported.gridv2()) System.out.println(" " + row); + + // Export to JSON file + + var theme = "algemeen"; + + section("Export"); + info("file : " + OUTPUT_PATH); + + try { + Files.createDirectories(PUZZLE_DIR); + var json = toJson(exported, DATE_STRING, theme); + Files.writeString(OUTPUT_PATH, json, StandardCharsets.UTF_8); + + // Update index.json + var pathInIndex = "/puzzles/" + FILE_NAME; + var indexRecord = toIndexRecordJson(FILE_ID, pathInIndex, DATE_STRING, theme, exported.difficulty(), CREATED_AT); + if (1 != 1) updateIndex(PUZZLE_DIR.toString(), indexRecord); + else rebuildIndex(); + info("indexUpdated : " + INDEX_FILE); + } catch (IOException e) { + err("Failed to write: " + FILE_NAME); + err("Reason : " + e.getMessage()); + System.exit(2); + } + } + + // ---------------- Output helpers ---------------- + + private static void info(String msg) { System.out.println("[INFO ] " + msg); } + private static void warn(String msg) { System.out.println("[WARN ] " + msg); } + private static void err(String msg) { System.err.println("[ERROR] " + msg); } + + private static void section(String title) { + System.out.println(); + System.out.println(title); + } + + private static String envOrDefault(String key, String def) { + var v = System.getenv(key); + return (v == null || v.isBlank()) ? def : v; + } + + private static void printSettings(Opts o) { + System.out.printf(Locale.ROOT, " %-14s: %d%n", "seed", o.seed); + System.out.printf(Locale.ROOT, " %-14s: %d%n", "population", o.pop); + System.out.printf(Locale.ROOT, " %-14s: %d%n", "generations", o.gens); + System.out.printf(Locale.ROOT, " %-14s: %s%n", "wordsPath", o.wordsPath); + System.out.printf(Locale.ROOT, " %-14s: %.2f%n", "minSimplicity", o.minSimplicity); + System.out.printf(Locale.ROOT, " %-14s: %d%n", "threads", o.threads); + System.out.printf(Locale.ROOT, " %-14s: %d%n", "maxTries", o.tries); + } + + private static String fmtPoint(int r, int c) { return String.format(Locale.ROOT, "(%d,%d)", r, c); } + + private static void printWordsTable(List words) { + System.out.println(" # WORD CX DIR START ARROW CLUE"); + var i = 1; + for (var w : words) { + System.out.printf( + Locale.ROOT, + " %-2d %-12s %-3s %-3s %-9s %-9s %s%n", + i++, + safe(w.word(), 12), + safe("" + w.complex(), 3), + safe(w.direction(), 3), + fmtPoint(w.startRow(), w.startCol()), + fmtPoint(w.arrowRow(), w.arrowCol()), + w.clue() == null ? "" : w.clue() + ); + } + } + + private static String safe(String s, int max) { + if (s == null) return ""; + if (s.length() <= max) return s; + return s.substring(0, Math.max(0, max - 1)) + "…"; + } + + private static String indentLines(String s, String indent) { + if (s == null || s.isEmpty()) return ""; + var lines = s.split("\\R", -1); + var sb = new StringBuilder(); + for (var line : lines) sb.append(indent).append(line).append('\n'); + return sb.toString(); } static void usage() { System.out.println(""" - Usage: - java SwedishGenerator [--seed N] [--pop N] [--gens N] [--tries N] [--words word-list.txt] [--min-simplicity N.N] [--threads N] - - Defaults: - --seed 1234 - --pop 18 - --gens 1000 - --tries 5 - --words /data/puzzle/pool.txt - --min-simplicity 0 (no limit) - --threads %d - """.formatted(Math.max(1, Runtime.getRuntime().availableProcessors()))); + Usage: + java puzzle.Main [--seed N] [--pop N] [--gens N] [--tries N] [--words FILE] [--min-simplicity N.N] [--threads N] [--reindex] + + Defaults: + --pop 18 + --gens 600 + --tries = threads + --words nl_score_hints.csv + --min-simplicity 0 (no limit) + --threads %d + """.formatted(Math.max(1, Runtime.getRuntime().availableProcessors()))); } static Opts parseArgs(String[] argv) { var out = new Opts(); - for (int i = 0; i < argv.length; i++) { - String a = argv[i]; - String v = (i + 1 < argv.length) ? argv[i + 1] : null; + for (var i = 0; i < argv.length; i++) { + var a = argv[i]; + var v = (i + 1 < argv.length) ? argv[i + 1] : null; + if (a.equals("--help") || a.equals("-h")) { usage(); System.exit(0); } - if (a.equals("--seed")) { out.seed = Integer.parseInt(v); i++; } - else if (a.equals("--pop")) { out.pop = Integer.parseInt(v); i++; } - else if (a.equals("--gens")) { out.gens = Integer.parseInt(v); i++; } - else if (a.equals("--tries")) { out.tries = Integer.parseInt(v); i++; } - else if (a.equals("--words")) { out.wordsPath = v; i++; } - else if (a.equals("--min-simplicity")) { out.minSimplicity = Double.parseDouble(v); i++; } - else if (a.equals("--threads")) { out.threads = Integer.parseInt(v); i++; } - else throw new IllegalArgumentException("Unknown arg: " + a); + + if (a.equals("--seed")) { + out.seed = Integer.parseInt(v); + i++; + } else if (a.equals("--pop")) { + out.pop = Integer.parseInt(v); + i++; + } else if (a.equals("--gens")) { + out.gens = Integer.parseInt(v); + i++; + } else if (a.equals("--tries")) { + out.tries = Integer.parseInt(v); + i++; + } else if (a.equals("--words")) { + out.wordsPath = v; + i++; + } else if (a.equals("--min-simplicity")) { + out.minSimplicity = Double.parseDouble(v); + i++; + } else if (a.equals("--threads")) { + out.threads = Integer.parseInt(v); + i++; + } else if (a.equals("--reindex")) { + out.reindex = true; + } else { + throw new IllegalArgumentException("Unknown arg: " + a); + } } return out; } - public static void main(String[] args) { - var opts = parseArgs(args); - var res = generatePuzzle(opts); - if (res == null) { - System.out.println("No solution found within tries."); - System.exit(1); - } - - System.out.println("\n=== GENERATED MASK ==="); - System.out.println(SwedishGenerator.gridToString(res.mask())); - - System.out.println("\n=== FILLED PUZZLE (RAW) ==="); - System.out.println(SwedishGenerator.gridToString(res.filled().grid)); - - System.out.println("\n=== FILLED PUZZLE (HUMAN) ==="); - System.out.println(SwedishGenerator.renderHuman(res.filled().grid)); - System.out.printf(Locale.ROOT, "Puzzle Simplicity: %.2f%n", res.filled().simplicity); - var out = ExportFormat.exportFormatFromFilled(res, 1, new ExportFormat.Rewards(50, 2, 1)); - - // Generate clues via LLM - System.out.println("Generating clues for " + out.words().size() + " words..."); - out = ClueGenerator.applyClues(out); - - System.out.println("gridv2:"); - for (String row : out.gridv2()) System.out.println(row); - System.out.println("words: " + out.words().size()); - for (var w : out.words()) { - var simplicityOfWord = - System.out.printf("%s %s start=(%d,%d) arrow=(%d,%d)%n", - w.word(), w.direction(), w.startRow(), w.startCol(), w.arrowRow(), w.arrowCol()); - } - - // Export to JSON file - var now = OffsetDateTime.now(ZoneOffset.UTC); - var createdAt = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")); - var dateStr = now.toLocalDate().toString(); - var id = createdAt.replace(":", "-") + "_" + (System.currentTimeMillis() / 1000); - var theme = "algemeen"; - var filename = id + ".json"; - var outDir = "/data/puzzle/puzzles"; - var outputPath = Paths.get(outDir, filename); - - try { - Files.createDirectories(Paths.get(outDir)); - var json = toJson(out, dateStr, theme); - Files.writeString(outputPath, json, StandardCharsets.UTF_8); - System.out.println("\nSaved to: " + outputPath); - - // Update index.json - var pathInIndex = "/puzzles/" + filename; - var indexRecord = toIndexRecordJson(id, pathInIndex, dateStr, theme, out.difficulty(), createdAt); - updateIndex(outDir, indexRecord); - } catch (IOException e) { - System.err.println("Failed to write " + filename + ": " + e.getMessage()); - } - } - + // ---------------- Generation ---------------- + // Package-private method for testing - static PuzzleResult generatePuzzle(Opts opts) { + PuzzleResult generatePuzzle(Opts opts) { var llmScores = loadScores(); - var tLoad0 = System.nanoTime(); - var dict = loadWords(opts.wordsPath, llmScores); - var tLoad1 = System.nanoTime(); - System.out.printf(Locale.ROOT, "LOAD_WORDS: %.3fs%n %s words%n", (tLoad1 - tLoad0) / 1e9, dict.words.size()); + + var tLoad0 = System.nanoTime(); + var dict = loadWords(opts.wordsPath, llmScores); + var tLoad1 = System.nanoTime(); + + section("Load"); + info(String.format(Locale.ROOT, "words : %,d", dict.words().size())); + info(String.format(Locale.ROOT, "loadTime : %.3f s", (tLoad1 - tLoad0) / 1e9)); + + section("Search"); if (opts.threads > 1) { - System.out.println("Running in multi-threaded mode with " + opts.threads + " threads..."); + info("mode : multi-threaded (" + opts.threads + ")"); var executor = Executors.newFixedThreadPool(opts.threads); try { var tasks = new ArrayList>(); - for (int i = 1; i <= opts.tries; i++) { - final int attempt = i; + for (var i = 1; i <= opts.tries; i++) { + final var attempt = i; tasks.add(() -> { var threadRng = new Rng(opts.seed + attempt); - var mask = generateMask(threadRng, dict.lenCounts, opts.pop, opts.gens, false); - var filled = fillMask(threadRng, mask, dict.index, llmScores, 200, 60000, false); + var mask = generateMask(threadRng, dict.lenCounts(), opts.pop, opts.gens, false); + var filled = fillMask(threadRng, mask, dict.index(), llmScores, 200, 60000, false); if (filled.ok && (opts.minSimplicity <= 0 || filled.simplicity >= opts.minSimplicity)) { - System.out.println("\nSolution found on attempt " + attempt); - return new PuzzleResult(mask, filled); + info("status : SOLVED"); + info("foundAtTry : " + attempt); + return new PuzzleResult(dict, mask, filled); } - throw new RuntimeException("No solution found in attempt " + attempt); + throw new RuntimeException("No solution in try " + attempt); }); } return executor.invokeAny(tasks); } catch (InterruptedException e) { Thread.currentThread().interrupt(); + warn("status : INTERRUPTED"); } catch (ExecutionException e) { // all failed + warn("status : UNSOLVED"); } finally { executor.shutdownNow(); } return null; + } else { + info("mode : single-threaded"); var rng = new Rng(opts.seed); + for (var attempt = 1; attempt <= opts.tries; attempt++) { - System.out.println("\nAttempt " + attempt + "/" + opts.tries); + info("try : " + attempt + "/" + opts.tries); - var tMask0 = System.nanoTime(); - var mask = generateMask(rng, dict.lenCounts, opts.pop, opts.gens, true); - var tMask1 = System.nanoTime(); - System.out.printf(Locale.ROOT, "MASK: %.3fs%n", (tMask1 - tMask0) / 1e9); - - var tFill0 = System.nanoTime(); - var filled = fillMask(rng, mask, dict.index, llmScores, 200, 60000, true); - var tFill1 = System.nanoTime(); - System.out.printf(Locale.ROOT, "FILL: %.3fms | Simplicity: %.2f%n", (tFill1 - tFill0) / 1e6, filled.simplicity); + var mask = generateMask(rng, dict.lenCounts(), opts.pop, opts.gens, true); + var filled = fillMask(rng, mask, dict.index(), llmScores, 200, 60000, true); if (filled.ok && (opts.minSimplicity <= 0 || filled.simplicity >= opts.minSimplicity)) { - return new PuzzleResult(mask, filled); + info("status : SOLVED"); + info("foundAtTry : " + attempt); + return new PuzzleResult(dict, mask, filled); } + if (filled.ok) { - System.out.printf(Locale.ROOT, "Puzzle simplicity %.2f is below min %.2f, retrying...%n", - filled.simplicity, opts.minSimplicity); + warn(String.format(Locale.ROOT, + "simplicity : %.2f (below min %.2f)", + filled.simplicity, opts.minSimplicity + )); } } + + info("status : UNSOLVED"); + return null; } - - return null; } - + + // ---------------- Export (unchanged logic) ---------------- + private static String toJson(ExportFormat.ExportedPuzzle puzzle, String date, String theme) { var sb = new StringBuilder(); sb.append("{\n"); @@ -230,7 +352,7 @@ public class Main { sb.append("}\n"); return sb.toString(); } - + private static String escapeJson(String s) { return s.replace("\\", "\\\\") .replace("\"", "\\\"") @@ -238,42 +360,96 @@ public class Main { .replace("\r", "\\r") .replace("\t", "\\t"); } - - private static String safeSlug(String s) { - return s.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", ""); - } - + private static String toIndexRecordJson(String id, String path, String date, String theme, int difficulty, String createdAt) { return String.format( - "{\"id\":\"%s\",\"path\":\"%s\",\"date\":\"%s\",\"theme\":\"%s\",\"difficulty\":%d,\"createdAt\":\"%s\"}", - escapeJson(id), escapeJson(path), escapeJson(date), escapeJson(theme), difficulty, escapeJson(createdAt) - ); + Locale.ROOT, + "{\"id\":\"%s\",\"path\":\"%s\",\"date\":\"%s\",\"theme\":\"%s\",\"difficulty\":%d,\"createdAt\":\"%s\"}", + escapeJson(id), escapeJson(path), escapeJson(date), escapeJson(theme), difficulty, escapeJson(createdAt) + ); } - + private static void updateIndex(String outDir, String newRecordJson) { - Path indexPath = Paths.get(outDir, "index.json"); + var indexPath = Paths.get(outDir, "index.json"); try { - String content; - if (Files.exists(indexPath)) { - content = Files.readString(indexPath, StandardCharsets.UTF_8).trim(); - } else { - content = ""; - } - + var content = Files.exists(indexPath) ? Files.readString(indexPath, StandardCharsets.UTF_8).trim() : ""; + if (content.isEmpty() || content.equals("[]")) { content = "[\n " + newRecordJson + "\n]"; } else { - int firstBracket = content.indexOf('['); - if (firstBracket != -1) { - content = content.substring(0, firstBracket + 1) + "\n " + newRecordJson + "," + content.substring(firstBracket + 1); - } else { - content = "[\n " + newRecordJson + "\n]"; - } + var firstBracket = content.indexOf('['); + if (firstBracket != -1) { + content = content.substring(0, firstBracket + 1) + "\n " + newRecordJson + "," + content.substring(firstBracket + 1); + } else { + content = "[\n " + newRecordJson + "\n]"; + } } + Files.writeString(indexPath, content, StandardCharsets.UTF_8); - System.out.println("Updated index.json at: " + indexPath); + info("indexUpdated : " + indexPath); } catch (IOException e) { - System.err.println("Failed to update index.json: " + e.getMessage()); + err("Failed to update index.json: " + e.getMessage()); } } + + private void rebuildIndex() { + + if (!Files.exists(PUZZLE_DIR)) { + err("Puzzles directory does not exist: " + PUZZLE_DIR); + return; + } + + info("Rebuilding index from: " + PUZZLE_DIR); + + List records = new ArrayList<>(); + try (var stream = Files.list(PUZZLE_DIR)) { + stream.filter(p -> p.toString().endsWith(".json") && !p.getFileName().toString().equals("index.json")) + .sorted(Comparator.comparing(Path::getFileName).reversed()) + .forEach(path -> { + try { + var filename = path.getFileName().toString(); + var id = filename.substring(0, filename.length() - 5); + var content = Files.readString(path, StandardCharsets.UTF_8); + + var date = extractValue(content, "date"); + var theme = extractValue(content, "theme"); + var difficulty = 1; + try { difficulty = Integer.parseInt(extractValue(content, "difficulty")); } catch (Exception ignored) { } + + var createdAt = id; + if (id.length() >= 20 && id.charAt(10) == 'T') { + var parts = id.split("_"); + var dtPart = parts[0]; // 2025-12-24T04-25-06Z + if (dtPart.length() >= 19) { + createdAt = dtPart.substring(0, 13) + ":" + dtPart.substring(14, 16) + ":" + dtPart.substring(17); + } + } + + var pathInIndex = "/puzzles/" + filename; + records.add(toIndexRecordJson(id, pathInIndex, date, theme, difficulty, createdAt)); + } catch (IOException e) { + err("Failed to read " + path + ": " + e.getMessage()); + } + }); + } catch (IOException e) { + err("Failed to list puzzles: " + e.getMessage()); + return; + } + + var indexPath = PUZZLE_DIR.resolve("index.json"); + var content = "[\n " + String.join(",\n ", records) + "\n]"; + try { + Files.writeString(indexPath, content, StandardCharsets.UTF_8); + info("Successfully rebuilt index.json with " + records.size() + " records."); + } catch (IOException e) { + err("Failed to write index.json: " + e.getMessage()); + } + } + + private static String extractValue(String json, String key) { + var pattern = java.util.regex.Pattern.compile("\"" + key + "\":\\s*\"?([^\",\\n\\r}]*)\"?"); + var matcher = pattern.matcher(json); + if (matcher.find()) return matcher.group(1).trim(); + return ""; + } } diff --git a/src/puzzle/MainTest.java b/src/puzzle/MainTest.java index d908843..b45c4d8 100644 --- a/src/puzzle/MainTest.java +++ b/src/puzzle/MainTest.java @@ -21,7 +21,7 @@ public class MainTest { opts.tries = 1; // Act - var result = Main.generatePuzzle(opts); + var result = new Main().generatePuzzle(opts); // Assert /* assertNotNull(result); diff --git a/src/puzzle/SwedishGenerator.java b/src/puzzle/SwedishGenerator.java index d761854..2e1600b 100644 --- a/src/puzzle/SwedishGenerator.java +++ b/src/puzzle/SwedishGenerator.java @@ -6,8 +6,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.IntStream; +import java.util.stream.Collectors; /** * SwedishGenerator.java @@ -145,35 +144,31 @@ public class SwedishGenerator { } } - static class WordDifficulty { - - final String word; - final int difficulty; - final int score; + static record WordDifficulty(String word, int difficulty, int score) { public WordDifficulty(String word, int score) { - this.word = word; - this.score = score; + var difficulty1 = 0 + ((8 - word.length()) * 30) + ((10 - score) * 15); + this(word, difficulty1, score); // We want LONGER and SIMPLER words to be tried earlier (lower difficulty value). // word.length() is 2 to 8. // score is 1 to 10. // Base difficulty starts high and decreases with length and score. // Length impact: up to 8 * 10 = 80 // Score impact: up to 10 * 15 = 150 - var difficulty1 = 0 + ((8 - word.length()) * 30) + ((10 - score) * 15); - this.difficulty = difficulty1; } } static Map loadScores() { var scores = new HashMap(); try { - var lines = Files.readAllLines(Path.of("/data/puzzle/export_with_hints.csv"), StandardCharsets.UTF_8); + var scoresPath = System.getenv("SCORES_PATH"); + if (scoresPath == null || scoresPath.isBlank()) scoresPath = "export_real_words_with_hints.csv"; + var lines = Files.readAllLines(Path.of(scoresPath), StandardCharsets.UTF_8); var first = true; for (var line : lines) { if (first) { first = false; - continue; + if (line.startsWith("WOORD")) continue; } var parts = line.split(",", 3); if (parts.length >= 2) { @@ -182,10 +177,7 @@ public class SwedishGenerator { var score = 10 - Integer.parseInt(parts[1].trim()); scores.put(word, score); } catch (NumberFormatException ignored) { - System.err.println("Illegal number format: " + line); } - } else { - System.err.println("Illegal word: " + line); } } } catch (IOException e) { @@ -194,48 +186,55 @@ public class SwedishGenerator { return scores; } - static final class Dict { - - final ArrayList words; - final HashMap index; // len -> DictEntry - final HashMap lenCounts; // len -> count - Dict(ArrayList words, HashMap index, HashMap lenCounts) { - this.words = words; - this.index = index; - this.lenCounts = lenCounts; - } - } + public static record Dict(Map words, + HashMap index, + HashMap lenCounts) { } static Dict loadWords(String wordsPath, Map llmScores) { String raw; try { raw = Files.readString(Path.of(wordsPath), StandardCharsets.UTF_8); } catch (IOException e) { - raw = "EU\nUUR\nAUTO\nBOOM\nHUIS\nKAT\nZEE\nRODE\nDRAAD\nKENNIS\nNETWERK\nPAKTE\n"; + raw = "WOORD,level_1_to_10,hint\nEU,2,hint\nUUR,2,hint\nAUTO,2,hint\nBOOM,2,hint\nHUIS,2,hint\nKAT,2,hint\nZEE,2,hint\nRODE,2,hint\nDRAAD,2,hint\nKENNIS,2,hint\nNETWERK,2,hint\nPAKTE,2,hint\n"; } - var words = new ArrayList(); + var map = new HashMap(); + boolean first = true; for (var line : raw.split("\\R")) { - var word = line.split(",", 3)[0].trim(); - var s = word.trim().toUpperCase(Locale.ROOT); + if (line.isBlank()) continue; + var parts = line.split(",", 3); + var word = parts[0].trim(); + if (first && word.equalsIgnoreCase("WOORD")) { + first = false; + continue; + } + first = false; + var s = word.toUpperCase(Locale.ROOT); if (s.matches("^[A-Z]{2,8}$")) { - var score = llmScores.getOrDefault(s, SIMPLICITY_DEFAULT_SCORE); // Default to middle - words.add(new WordDifficulty(s, score)); + int score = SIMPLICITY_DEFAULT_SCORE; + if (parts.length >= 2) { + try { + // CSV has level 1-10. llmScores use 10-level. + score = 10 - Integer.parseInt(parts[1].trim()); + } catch (NumberFormatException e) { + score = llmScores.getOrDefault(s, SIMPLICITY_DEFAULT_SCORE); + System.err.println("Warning: " + word + " csv not found, using default scores."); + } + } else { + score = llmScores.getOrDefault(s, SIMPLICITY_DEFAULT_SCORE); + System.err.println("Warning: " + word + " csv not found, using default scores."); + } + map.put(s, new WordDifficulty(s, score)); } } - + var words = map.values().stream().collect(Collectors.toCollection(ArrayList::new)); // Sort words by difficulty in ascending order words.sort(Comparator.comparingInt(wd -> wd.difficulty)); - var dictWords = new ArrayList(); - for (var wd : words) { - dictWords.add(wd.word); - } - var index = new HashMap(); var lenCounts = new HashMap(); - for (var w : dictWords) { - var L = w.length(); + for (var w : words) { + var L = w.word.length(); lenCounts.put(L, lenCounts.getOrDefault(L, 0) + 1); var entry = index.get(L); @@ -245,15 +244,15 @@ public class SwedishGenerator { } var idx = entry.words.size(); - entry.words.add(w); + entry.words.add(w.word); for (var i = 0; i < L; i++) { - var letter = w.charAt(i) - 'A'; + var letter = w.word.charAt(i) - 'A'; if (letter >= 0 && letter < 26) entry.pos[i][letter].add(idx); } } - return new Dict(dictWords, index, lenCounts); + return new Dict(map, index, lenCounts); } static int[] intersectSorted(int[] a, int aLen, int[] b, int bLen) { @@ -271,10 +270,8 @@ public class SwedishGenerator { return Arrays.copyOf(out, k); } - static final class CandidateInfo { + static final record CandidateInfo(int[] indices, int count) { - int[] indices; // null => unconstrained - int count; } static CandidateInfo candidateInfoForPattern(DictEntry entry, char[] pattern /* 0 means null */) { var lists = new ArrayList(); @@ -285,11 +282,8 @@ public class SwedishGenerator { } } - var ci = new CandidateInfo(); if (lists.isEmpty()) { - ci.indices = null; - ci.count = entry.words.size(); - return ci; + return new CandidateInfo(null, entry.words.size()); } var first = lists.get(0); @@ -305,9 +299,7 @@ public class SwedishGenerator { if (curLen == 0) break; } - ci.indices = cur; - ci.count = curLen; - return ci; + return new CandidateInfo(cur, curLen); } static int indexToDifficulty(DictEntry entry, int index, Map llmScores) { var word = entry.words.get(index); @@ -317,19 +309,10 @@ public class SwedishGenerator { // ---------------- Slots ---------------- - static final class Slot { + static record Slot(int clueR, int clueC, char dir, int[] rs, int[] cs, int len) { - final int clueR, clueC; - final char dir; // '1'..'5' - final int[] rs, cs; // cells - final int len; - Slot(int clueR, int clueC, char dir, int[] rs, int[] cs) { - this.clueR = clueR; - this.clueC = clueC; - this.dir = dir; - this.rs = rs; - this.cs = cs; - this.len = rs.length; + public Slot(int clueR, int clueC, char dir, int[] rs, int[] cs) { + this(clueR, clueC, dir, rs, cs, rs.length); } String key() { return clueR + "," + clueC + ":" + dir; } } @@ -917,7 +900,7 @@ public class SwedishGenerator { } // ---------------- Top-level generatePuzzle ---------------- - public record PuzzleResult(char[][] mask, FillResult filled) { } + public record PuzzleResult(Dict dict, char[][] mask, FillResult filled) { } public static PuzzleResult generatePuzzle(Main.Opts opts) { var llmScores = loadScores(); @@ -940,7 +923,7 @@ public class SwedishGenerator { if (filled.ok && (opts.minSimplicity <= 0 || filled.simplicity >= opts.minSimplicity)) { System.out.println("\nSolution found on attempt " + attempt); - return new PuzzleResult(mask, filled); + return new PuzzleResult(dict, mask, filled); } throw new RuntimeException("No solution found in attempt " + attempt); }); @@ -970,7 +953,7 @@ public class SwedishGenerator { System.out.printf(Locale.ROOT, "FILL: %.3fms | Simplicity: %.2f%n", (tFill1 - tFill0) / 1e6, filled.simplicity); if (filled.ok && (opts.minSimplicity <= 0 || filled.simplicity >= opts.minSimplicity)) { - return new PuzzleResult(mask, filled); + return new PuzzleResult(dict, mask, filled); } if (filled.ok) { System.out.printf(Locale.ROOT, "Puzzle simplicity %.2f is below min %.2f, retrying...%n", diff --git a/src/puzzle/ThemePoolBuilderLength.java b/src/puzzle/ThemePoolBuilderLength.java index df099ac..e995e9d 100644 --- a/src/puzzle/ThemePoolBuilderLength.java +++ b/src/puzzle/ThemePoolBuilderLength.java @@ -65,7 +65,7 @@ public class ThemePoolBuilderLength { String endpoint = "https://jarvis-lan.appmodel.nl/api/ollama/"; List feeds = new ArrayList<>(DEFAULT_FEEDS); - String outDir = "/data/puzzle"; + String outDir = System.getenv("OUT_DIR") != null ? System.getenv("OUT_DIR") : "/data/puzzle"; int bridgeN = 30000; int themeN = 800; int relatedN = 2200;