Files
Hankan/tools/dict/sources/kaikki.mjs
MechaCat02 7bd8507909 feat(dict): build pipeline, grammar lexicon, and the shipped band files
Closes REVIEW.md §2. 167 of 371 roadmap words had no lexicon entry, so the
word rail silently showed nothing. Now:

  Roadmap words: 371/371 resolve
  Spiral targets: 35/35 resolve
  Deck words: 386/386 resolve

and npm run dict:assert makes it a blocking build failure, not a silent
empty rail.

No runtime morphological analyser ships. lib/conjugation.js surfaceForms()
runs at BUILD time over every verb and adjective, so looking up a conjugated
form is an index hit on the surface table.

The frequency join had to be inverted. A subtitle frequency list holds
surface forms; a dictionary holds lemmas whose -다 citation form barely
occurs in running text, so joining on headword gives verbs a frequency of
roughly zero. Expanding each lemma into the forms it plausibly takes and
summing recovers 하다 from 118 to 89,041. Forms claimed by more than one
lemma are dropped rather than split, so homographs don't inherit each
other's mass. Those expansions score frequency only — the surface table
itself stays strictly surfaceForms() output plus the headword.

Bands are one per curriculum phase. Phase 1 admits no frequency band at
all: during the writing-system phase every word must be phonologically
legal for the unit reached, and a rank ceiling would hand the learner a
겹받침 during unit 1.4. shared/phonology.mjs lifts validate.mjs's own
feature ladder to enforce that; it agrees with the validator on all 371
words.

Sources are chosen automatically — KRDICT when vendored, otherwise the
kaikki.org extract. KRDICT's download is a JS form behind anti-bot
protection, so it cannot be fetched by CI; the derived band files are
committed instead, which the app needs offline regardless. Attribution and
the share-alike terms are in NOTICE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:13:15 +02:00

126 lines
3.4 KiB
JavaScript

/* kaikki.org adapter — English Wiktionary, filtered to Korean.
~64k records / ~34k distinct Hangul headwords, with English glosses written
by and for English speakers. Automatable: a stable URL, no signup.
What it does NOT have is a curated learner level, so bands fall back to
frequency alone for entries from this source (see shared/bands.mjs). That
is the main reason KRDICT is preferred when it has been vendored.
Licence: CC BY-SA 3.0 + GFDL, inherited from Wiktionary. See NOTICE.md. */
import { createReadStream } from "node:fs";
import { createGunzip } from "node:zlib";
import { createInterface } from "node:readline";
/** kaikki part-of-speech → ours. Anything absent is skipped. */
const POS = {
noun: "noun",
verb: "verb",
adj: "adj",
adv: "adv",
pron: "pron",
det: "det",
num: "num",
conj: "conj",
intj: "interj",
particle: "particle",
postp: "particle",
counter: "counter",
suffix: "suffix",
prefix: "prefix",
phrase: "phrase",
proverb: "phrase",
contraction: "contraction",
};
/* Hanja, syllable stubs, proper nouns and romanisations are not words the
learner is reading manhwa to decode. */
const SKIP_POS = new Set([
"character",
"syllable",
"name",
"root",
"symbol",
"romanization",
"punct",
"affix",
"interfix",
]);
/** Senses worth ignoring when a better one exists. */
const WEAK_TAGS = new Set([
"obsolete",
"archaic",
"rare",
"dialectal",
"North-Korea",
"dated",
"historical",
]);
const HANGUL_ONLY = /^[가-힣]+(?: [가-힣]+)*$/;
const isEnglish = (s) => s && !/[가-힣]/.test(s);
/** Pick the most useful English gloss, preferring a plain modern sense. */
function bestGloss(senses) {
const usable = [];
for (const s of senses ?? []) {
const glosses = (s.glosses ?? []).filter(isEnglish);
if (!glosses.length) continue;
// "form-of" senses describe an inflected form; we generate those
// ourselves from surfaceForms(), so they add nothing here.
const tags = new Set(s.tags ?? []);
if (tags.has("form-of") || s.form_of) continue;
usable.push({ text: glosses.join("; "), weak: [...tags].some((t) => WEAK_TAGS.has(t)) });
}
if (!usable.length) return "";
const strong = usable.filter((u) => !u.weak);
const pick = (strong.length ? strong : usable).slice(0, 2);
return pick.map((p) => p.text).join("; ").slice(0, 240);
}
/**
* Yields { headword, pos, gloss_en, gloss_ko, level, source } for every
* usable Korean entry. Streams — the file does not fit comfortably in memory.
*/
export async function* readKaikki(path) {
const rl = createInterface({
input: createReadStream(path).pipe(createGunzip()),
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line) continue;
let o;
try {
o = JSON.parse(line);
} catch {
continue; // a truncated tail line is not worth failing the build over
}
if (o.lang_code !== "ko") continue;
if (SKIP_POS.has(o.pos)) continue;
const pos = POS[o.pos];
if (!pos) continue;
const headword = o.word;
if (!headword || !HANGUL_ONLY.test(headword)) continue;
const gloss = bestGloss(o.senses);
if (!gloss) continue;
yield {
headword,
pos,
gloss_en: gloss,
gloss_ko: "",
level: null, // kaikki does not grade entries
source: "kaikki",
};
}
}
export const KAIKKI_ATTRIBUTION =
"English Wiktionary via kaikki.org / wiktextract — CC BY-SA 3.0 + GFDL";