调换后的顺序

  1. AI 设计网格:模型不写字,只输出黑格坐标。要求 180° 对称、黑格占 40~60%,并避开条纹、长梯子和孤立的岛。
  2. 网格验证与校正:不相信模型给自己打的分,由程序来检查。检查是否对称、白格是否全部相连、每个白格是否都属于两个字以上的单词、最长的格子是否超出限制、四个字以上的格子是否足够。如果不符合,就截断过长的行、填掉过短的行、打通堵住的地方来校正。
  3. 填入单词:从匹配候选最少的格子开始填,卡住时就回退换别的单词。如果 AI 网格不行,就使用预先画好的网格(每种尺寸 8 个);还不行的话,就生成随机网格。
  4. AI 出题:只把网格上已确定的单词列表交给模型。如果它加了列表中没有的单词或改了拼写,就丢弃结果,题目中也不能原样出现正确答案。

模型是在 Mac 的 Ollama 上运行的 gemma4。因为是通过兼容 OpenAI 的地址调用,所以只要修改设置就能使用其他模型。

用常识和时事代替释义

只问单词意思的题目太简单了。所以我们加入了这样的问答式题目。

  • 说过“远看是喜剧,近看是悲剧”的人(卓别林)
  • 在海洋石灰岩地带形成的巨大蓝色洞穴(蓝洞)
  • 旧金山名胜、这座桥的名字(金门)
  • 通过分析梦来讲述无意识的心理学家(弗洛伊德)

也加入了时事题,比如冰川崩塌引发大洪水的国家、美国墨西哥加拿大联合举办的世界杯、米兰-科尔蒂纳冬奥会等。每次生成网格时,都会读取谷歌新闻的头条,并从维基百科的随机条目中再获取一些单词。

词库中目前有 1,102 个单词。

  • 长度:两个字 568 个,三个字 291 个,四个字 168 个,五个字 74 个
  • 领域:日常 810 个,科学 86 个,历史 60 个,技术 49 个,古语 34 个,文化 30 个,地理 21 个,时事 12 个
  • 问答式题目:147 个
  • 按字幕频率排名,把单词分为常用词、普通词、罕见词等级。

在一台设备上最近出现过的 180 个单词会被记下来,在下一局中排除。这是因为之前出现过同样的单词接连出现的问题。

难度

  • 简单:8×8,开始时公开 18% 的白格,答案最多 5 个字
  • 普通:10×10,公开 8%,最多 6 个字
  • 困难:12×12,不公开,最多 8 个字

每种难度的横向、纵向题目数和中间交叉数的最小值也各不相同。困难要求横向 8 道、纵向 6 道、中间交叉 6 处以上。

中途退出也能继续

最初大厅里有一个“继续”按钮。在解困难网格的途中选了简单,就会出现“继续”,搞不清是新网格还是接着解的网格。所以我们改变了大厅的概念。

  • 大厅里只创建新游戏。
  • 如果有正在解的网格,就不停留在大厅,直接进入游戏房间。
  • 在游戏房间里按“大厅”或“新游戏”时,会询问是否放弃当前题目。

接下来的问题是记录会消失。做完最后一次输入、关掉窗口后再进入大厅,正在解的网格就从数据库中被删除了。原来是在进入大厅时清理进行中的网格时把它删掉了。现在只要输入过一格的网格都会作为“中途保存”留下来,只删除什么都没输入的网格。游戏房间里也有保存按钮,从记录列表中重新打开就能接着解。

手机画面

在手机上,虚拟键盘遮住了半个屏幕。题目也用 A1 这样的坐标来显示,很难找。所以我们做了修改。

  • 点击网格上的编号,先显示横向题目,用“横向/纵向”标签切换。
  • 不是一个字一个字地输入,而是输入整个答案后按回车,横向题目就横着填,纵向题目就竖着填。
  • 点击格子时键盘不会马上弹出,可以先看网格。

这一阶段的实际代码

这是生成新网格的 API。网格 → 单词 → 题目的顺序一目了然。

app/api/puzzle/route.tstypescript · 36 行
// 1. 모델이 글자 없는 판 구조를 먼저 제안한다.
const patternCandidates = await generateAiPatternCandidates(difficulty);
// 2. 판의 슬롯 길이가 정해진 뒤 후보 단어를 모아 실제로 채운다.
const dynamicBank = await loadDynamicBank(difficulty, avoidWords, { useLlm: false });
let puzzle: Puzzle | null = null;
let patternSource: "ai" | "curated" = "curated";
// AI의 판 초안을 목표 모양으로 삼고, 연결형 배치기가 실제 단어를 고른다.
for (const rows of patternCandidates) {
  puzzle = generatePuzzleFromRows(difficulty, rows, {
    avoidWords,
    extraBank: dynamicBank.entries,
  });
  if (puzzle) {
    patternSource = "ai";
    break;
  }
}
if (!puzzle) {
  puzzle = generatePuzzle(difficulty, {
    avoidWords,
    extraBank: dynamicBank.entries,
  });
}
if (!puzzleMeets(puzzle, difficulty)) {
  puzzle = generatePuzzle(difficulty, { extraBank: dynamicBank.entries });
}
if (!puzzleMeets(puzzle, difficulty)) {
  puzzle = generatePuzzle(difficulty);
}

// 3. 최종 선택된 정답만 모델에 전달해 문제를 쓴다.
const selected = puzzle.across
  .concat(puzzle.down)
  .map(({ word, clue }) => ({ word, clue }));
const generatedClues = await generateCluesForWords(difficulty, selected);
puzzle = applyGeneratedClues(puzzle, generatedClues);

这是退回大厅时整理网格的部分。空网格删除,输入过的网格作为中途保存留下。

lib/db.tstypescript · 12 行
for (const row of rows) {
  if (isEmptyBoard(row)) {
    d.prepare(`DELETE FROM moves WHERE code = ?`).run(row.code);
    d.prepare(`DELETE FROM games WHERE code = ?`).run(row.code);
    continue;
  }
  d.prepare(
    `UPDATE games
     SET status = 'abandoned', end_text = COALESCE(NULLIF(end_text, ''), '중간 저장'), updated_at = ?
     WHERE code = ?`,
  ).run(now, row.code);
}

下一篇是“3. 用统计重新审视出题过程与问题”。