← Files Vocabloot Deck KitARCHIVED FILE

scripts/text.mjs

8.32 KB · Oct 2, 2026 · 00:34 UTC

↓ Download file

#!/usr/bin/env node
// /deck-text: every app-ready field for every word, through the creator's model,
// into cards.json and localizations/<known>.json, with a report (spec section 6).
//
//   node scripts/text.mjs <deck-folder> [--verify] [--refresh <key>] [--model gpt-4.1] [--prompt-file <path>]
import path from "node:path";
import { fileURLToPath } from "node:url";

import { ResponseCache, cacheKey } from "./lib/cache.mjs";
import { apiKey } from "./lib/env.mjs";
import { loadFolder, today, writeJson } from "./lib/folder.mjs";
import { describeOpen } from "./lib/agent.mjs";
import { DEFAULT_MODEL, makeClient } from "./lib/openai.mjs";
import { CARD_SCHEMA, VERIFY_SCHEMA, fill, grammarRules, loadPrompt } from "./lib/prompts.mjs";
import { loadLanguageFacts, nameOf } from "./lib/registry.mjs";
import { renderReport } from "./lib/report.mjs";
import { keyOf } from "./lib/schema.mjs";
import { assignOffsets, tokenProblems } from "./lib/tokens.mjs";

/** One model answer (CARD_SCHEMA) becomes a card plus its localization entry; token problems become flags. */
export function splitAnswer(answer, { word }) {
  const flags = [];
  const card = {
    key: "",
    text: answer.text?.trim() || word,
    article: answer.article || null,
    gender: answer.gender || null,
    plural: answer.plural || null,
    partOfSpeech: answer.partOfSpeech,
    phonetic: answer.phonetic ?? "",
    romanization: answer.romanization || null,
    sticker: {
      mode: answer.stickerMode,
      file: null,
      concept: answer.stickerMode === "text-first" ? "" : answer.stickerConcept,
    },
    examples: [],
    review: { confidence: answer.confidence, safe: answer.childSafe, flags, notes: answer.notes ?? "" },
  };
  card.key = keyOf(card);
  const loc = { key: card.key, translation: answer.translation, examples: [] };
  for (const [i, ex] of (answer.examples ?? []).entries()) {
    const withOffsets = assignOffsets(
      ex.source,
      ex.tokens.map((t) => ({ ...t })),
    );
    const problems = tokenProblems(ex.source, withOffsets);
    if (problems.length) flags.push(`example ${i + 1}: ${problems[0]}`);
    card.examples.push({
      source: ex.source,
      tokens: withOffsets.map((t) => ({
        text: t.text,
        start: t.start,
        end: t.end,
        lemma: t.lemma,
        partOfSpeech: t.partOfSpeech,
        grammarCodes: t.grammarCodes ?? [],
        article: t.article || null,
        gender: t.gender || null,
        plural: t.plural || null,
      })),
    });
    loc.examples.push({ target: ex.target, tokens: ex.tokens.map((t) => ({ meanings: t.meanings })) });
  }
  if (!card.phonetic) flags.push("no-phonetic");
  if (!answer.childSafe) flags.push("unsafe");
  if (answer.confidence === "low") flags.push("low-confidence");
  return { card, loc };
}

export async function generateText(
  dir,
  {
    client,
    model = DEFAULT_MODEL,
    verify = false,
    refresh = [],
    promptFile = null,
    facts = loadLanguageFacts(),
    log = console.log,
  } = {},
) {
  const folder = loadFolder(dir);
  const { deck, words } = folder;
  if (!words?.length) throw new Error(`${dir}/words.json is empty; run /deck-create first`);
  const prompt = loadPrompt("card", { file: promptFile });
  const verifyPrompt = verify ? loadPrompt("verify") : null;
  const cache = new ResponseCache(dir);
  const values = {
    known: deck.knownLanguage,
    knownName: nameOf(deck.knownLanguage, facts),
    learning: deck.learningLanguage,
    learningName: nameOf(deck.learningLanguage, facts),
    level: deck.level,
    topic: deck.topic ?? deck.name,
    ipa: facts[deck.learningLanguage]?.ipa ?? "standard pronunciation",
    grammarRules: grammarRules(facts, deck.learningLanguage),
  };
  const system = fill(prompt.body, values);
  if (refresh.length) cache.drop((note) => refresh.some((r) => note === `card:${r}`));

  const cards = [];
  const localization = [];
  const verification = {};
  for (const entry of words) {
    if (entry.include === false) continue;
    const word = entry.article ? `${entry.article} ${entry.word}` : entry.word;
    const user = JSON.stringify({
      headword: entry.word,
      article: entry.article ?? null,
      hint: entry.hint ?? "",
      topic: values.topic,
      level: deck.level,
    });
    const key = cacheKey({ model, promptVersion: prompt.version, system, user });
    let answer = cache.get(key)?.value;
    if (!answer) {
      log(`text: ${word}`);
      answer = await client.chatJson({
        key: `text:${deck.id}:${entry.word}`,
        model,
        system,
        user,
        schema: CARD_SCHEMA,
        schemaName: "vocabloot_card",
      });
      if (!answer) continue; // the agent has a request to answer; nothing is written this run
      cache.put(key, answer, `card:${entry.word}`);
    }
    const { card, loc } = splitAnswer(answer, { word: entry.word });
    // A sticker already reviewed for this key survives a text rerun; its mode was the reviewer's decision.
    const previous = folder.cards?.find((c) => c.key === card.key);
    if (previous?.sticker?.file && previous.sticker.mode !== "text-first") {
      card.sticker = {
        mode: previous.sticker.mode,
        file: previous.sticker.file,
        concept: previous.sticker.concept || card.sticker.concept,
      };
      card.review.flags.push(...(previous.review?.flags ?? []).filter((f) => f.startsWith("sticker")));
    }
    if (cards.some((c) => c.key === card.key)) {
      card.review.flags.push("duplicate-key");
    }
    if (verify) {
      const vUser = JSON.stringify({ card, localization: loc });
      const vKey = cacheKey({ model, promptVersion: verifyPrompt.version, vUser });
      let v = cache.get(vKey)?.value;
      if (!v) {
        v = await client.chatJson({
          key: `verify:${deck.id}:${entry.word}`,
          model,
          system: fill(verifyPrompt.body, values),
          user: vUser,
          schema: VERIFY_SCHEMA,
          schemaName: "vocabloot_verify",
        });
        if (!v) continue;
        cache.put(vKey, v, `verify:${entry.word}`);
      }
      verification[card.key] = v;
      if (!(v.natural && v.levelOk && v.correct && v.safe)) card.review.flags.push(`verify: ${v.issue || "problem"}`);
    }
    cards.push(card);
    localization.push(loc);
  }
  if (client.isAgent && (client.missing.length || client.rejected.length)) {
    return { cards: 0, flagged: 0, report: null, open: describeOpen(client, dir) };
  }
  deck.updatedAt = today();
  deck.promptVersion = prompt.version;
  deck.wordCount = cards.length;
  writeJson(path.join(dir, "deck.json"), deck);
  writeJson(path.join(dir, "cards.json"), cards);
  writeJson(path.join(dir, "localizations", `${deck.knownLanguage}.json`), localization);
  const report = renderReport({ deck, cards, localization, verification });
  writeJson(path.join(dir, "review", "verification.json"), verification);
  const { writeFileSync, mkdirSync } = await import("node:fs");
  mkdirSync(path.join(dir, "review"), { recursive: true });
  writeFileSync(path.join(dir, "review", "report.md"), report);
  const flagged = cards.filter((c) => c.review.flags.length).length;
  return { cards: cards.length, flagged, report: path.join(dir, "review", "report.md") };
}

if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
  const args = process.argv.slice(2);
  const dir = args.find((a) => !a.startsWith("--"));
  if (!dir) {
    console.error(
      "usage: node scripts/text.mjs <deck-folder> [--agent] [--verify] [--refresh <key>] [--model <model>] [--prompt-file <path>]",
    );
    process.exit(2);
  }
  const opt = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
  const refresh = args.flatMap((a, i) => (a === "--refresh" ? [args[i + 1]] : []));
  try {
    const client = makeClient({
      apiKey: apiKey({ deckDir: dir }),
      agent: args.includes("--agent"),
      workDir: path.join(dir, "work"),
    });
    const result = await generateText(dir, {
      client,
      model: opt("--model") ?? DEFAULT_MODEL,
      verify: args.includes("--verify"),
      refresh,
      promptFile: opt("--prompt-file") ?? null,
    });
    if (result.open) {
      console.log(result.open.join("\n"));
      process.exit(3);
    }
    console.log(
      `${result.cards} cards written, ${result.flagged} flagged. Read ${result.report} before /deck-stickers.`,
    );
  } catch (error) {
    console.error(error.message);
    process.exit(1);
  }
}

SHA-256: f42c09f12ea80a24c3a2cf0683fc04d6fe50169a376a51cc9a04beee334dfbb6