chore: scaffold, CI, and the export bundle verbatim
The export bundle is the input to this port, not a sketch: the curriculum, the tutor prompt and the five logic modules are finished and tested. They land here byte-identical and stay that way. diff -r export/data data && diff -r export/lib lib diff -r export/prompt prompt && diff export/validate.mjs validate.mjs data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs runs verbatim with no path edits. All four are excluded from lint and formatting — they are not ours to restyle. Types for lib/ live alongside in types/ rather than as sibling .d.ts files, so the verbatim check stays a plain directory diff. CI runs the curriculum gate first, before anything else can pass: node validate.mjs PASS — 0 blocking, 0 advisory Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
40
.github/workflows/ci.yml
vendored
Normal file
40
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The curriculum gate comes first, before anything else can run.
|
||||
# validate.mjs reads only data/ and lib/ and exits non-zero on a
|
||||
# blocking failure. Baseline: PASS — 0 blocking, 167 advisory.
|
||||
- name: Curriculum validation
|
||||
run: node validate.mjs
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
# Every word in every unit's words[] must resolve in lemma or surface.
|
||||
# Runs against the committed band files in app/public/dict/.
|
||||
- name: Roadmap word coverage
|
||||
run: npm run dict:assert
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# the export bundle is the input to this port, not part of it
|
||||
export/
|
||||
*.tar.gz
|
||||
|
||||
# manually-vendored dictionary sources (see tools/dict/README.md)
|
||||
vendor/
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
coverage/
|
||||
|
||||
# Capacitor / Android build output — the project itself is committed.
|
||||
# assets/public is a copy of dist/, regenerated by `npx cap sync`.
|
||||
app/android/build/
|
||||
app/android/app/build/
|
||||
app/android/.gradle/
|
||||
app/android/local.properties
|
||||
app/android/app/src/main/assets/public/
|
||||
app/android/app/src/main/assets/capacitor.config.json
|
||||
app/android/capacitor-cordova-android-plugins/
|
||||
|
||||
.DS_Store
|
||||
*.log
|
||||
45
NOTICE.md
Normal file
45
NOTICE.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Third-party data notices
|
||||
|
||||
Hankan's application code is the authors'. The dictionary data it ships is not, and
|
||||
carries share-alike obligations. The notices below are also surfaced in the app.
|
||||
|
||||
## Dictionary
|
||||
|
||||
Exactly one of these is used, depending on which source was vendored at build time.
|
||||
`app/public/dict/manifest.json` records which one produced the shipped files.
|
||||
|
||||
### 한국어기초사전 (Basic Korean Dictionary) — preferred
|
||||
|
||||
Published by 국립국어원 (National Institute of Korean Language).
|
||||
|
||||
> 이 저작물은 크리에이티브 커먼즈 저작자표시-동일조건변경허락 2.0 대한민국
|
||||
> 라이선스에 따라 이용할 수 있습니다.
|
||||
|
||||
한국어기초사전, 국립국어원 — CC BY-SA 2.0 KR
|
||||
<https://creativecommons.org/licenses/by-sa/2.0/kr/>
|
||||
|
||||
Audio and image media referenced by the dictionary are **excluded** from that licence.
|
||||
Hankan stores media URLs only and redistributes no media files.
|
||||
|
||||
### English Wiktionary, via kaikki.org — fallback
|
||||
|
||||
Extracted by wiktextract (Tatu Ylonen, LREC 2022; wiktextract itself is MIT).
|
||||
Content is CC BY-SA 3.0 and GFDL, inherited from English Wiktionary.
|
||||
|
||||
<https://kaikki.org/dictionary/Korean/> · <https://en.wiktionary.org/>
|
||||
|
||||
## Frequency data
|
||||
|
||||
hermitdave/FrequencyWords, `content/2018/ko` — derived from OpenSubtitles2018 via OPUS.
|
||||
Word list content is CC BY-SA 4.0; the project's code is MIT.
|
||||
|
||||
<https://github.com/hermitdave/FrequencyWords>
|
||||
|
||||
## A note on licence compatibility
|
||||
|
||||
CC BY-SA 2.0 KR and CC BY-SA 4.0 are not automatically compatible in a single derived
|
||||
work. Hankan therefore keeps frequency data as a separately-attributed adjunct: it
|
||||
populates the `lemma.freq_rank` column only, and is not merged into the dictionary
|
||||
content itself. Both notices ship together.
|
||||
|
||||
Share-alike attaches to the derived dictionary data, not to Hankan's application code.
|
||||
1426
data/curriculum.json
Normal file
1426
data/curriculum.json
Normal file
File diff suppressed because it is too large
Load Diff
2363
data/deck.json
Normal file
2363
data/deck.json
Normal file
File diff suppressed because it is too large
Load Diff
841
data/gloss-extra.json
Normal file
841
data/gloss-extra.json
Normal file
@@ -0,0 +1,841 @@
|
||||
{
|
||||
"note": "Roadmap words the curated deck does not cover. Gloss only — these are NOT SRS cards. In the port these seed the hand-written grammar lexicon (REVIEW.md item 6); the rest should come from KRDICT.",
|
||||
"count": 167,
|
||||
"entries": [
|
||||
{
|
||||
"ko": "나무",
|
||||
"en": "tree",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "소리",
|
||||
"en": "sound, noise",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "바다",
|
||||
"en": "sea",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "다리",
|
||||
"en": "leg; bridge",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "개",
|
||||
"en": "dog",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "새",
|
||||
"en": "bird",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "세",
|
||||
"en": "three",
|
||||
"note": "native numeral, before a counter"
|
||||
},
|
||||
{
|
||||
"ko": "의사",
|
||||
"en": "doctor",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "위",
|
||||
"en": "above, on top",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "돼지",
|
||||
"en": "pig",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "가위",
|
||||
"en": "scissors",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "매미",
|
||||
"en": "cicada",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "제비",
|
||||
"en": "swallow",
|
||||
"note": "the bird"
|
||||
},
|
||||
{
|
||||
"ko": "포도",
|
||||
"en": "grapes",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "치마",
|
||||
"en": "skirt",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "까치",
|
||||
"en": "magpie",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "토끼",
|
||||
"en": "rabbit",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "꼬리",
|
||||
"en": "tail",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "찌개",
|
||||
"en": "stew",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "피자",
|
||||
"en": "pizza",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "쿠키",
|
||||
"en": "cookie",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "산",
|
||||
"en": "mountain",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "강",
|
||||
"en": "river",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "문",
|
||||
"en": "door",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "방",
|
||||
"en": "room",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "곰",
|
||||
"en": "bear",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "꽃",
|
||||
"en": "flower",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "앞",
|
||||
"en": "front, in front of",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "낮",
|
||||
"en": "daytime",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "밖",
|
||||
"en": "outside",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "한국어",
|
||||
"en": "the Korean language",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "음악",
|
||||
"en": "music",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "국어",
|
||||
"en": "national language",
|
||||
"note": "Korean as a school subject"
|
||||
},
|
||||
{
|
||||
"ko": "단어",
|
||||
"en": "word",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "언어",
|
||||
"en": "language",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "발음",
|
||||
"en": "pronunciation",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "얼음",
|
||||
"en": "ice",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "직업",
|
||||
"en": "job, occupation",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "목요일",
|
||||
"en": "Thursday",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "작은",
|
||||
"en": "small",
|
||||
"note": "modifying form of 작다"
|
||||
},
|
||||
{
|
||||
"ko": "입니다",
|
||||
"en": "is",
|
||||
"note": "the formal copula ending"
|
||||
},
|
||||
{
|
||||
"ko": "국물",
|
||||
"en": "broth",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "십만",
|
||||
"en": "a hundred thousand",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "작년",
|
||||
"en": "last year",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "몇 명",
|
||||
"en": "how many people",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "습니다",
|
||||
"en": "the formal polite verb ending",
|
||||
"note": "attaches to the stem: 갑니다, 먹습니다"
|
||||
},
|
||||
{
|
||||
"ko": "한국말",
|
||||
"en": "Korean",
|
||||
"note": "the spoken language"
|
||||
},
|
||||
{
|
||||
"ko": "꽃말",
|
||||
"en": "the meaning of a flower",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "값",
|
||||
"en": "price, value",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "닭",
|
||||
"en": "chicken",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "삶",
|
||||
"en": "life",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "넓다",
|
||||
"en": "to be wide",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "짧다",
|
||||
"en": "to be short",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "신라",
|
||||
"en": "Silla",
|
||||
"note": "an ancient Korean kingdom"
|
||||
},
|
||||
{
|
||||
"ko": "설날",
|
||||
"en": "Lunar New Year",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "못해",
|
||||
"en": "can't do it",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "축하",
|
||||
"en": "congratulations",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "놓다",
|
||||
"en": "to put down, to let go",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "앉히다",
|
||||
"en": "to seat someone",
|
||||
"note": "causative of 앉다"
|
||||
},
|
||||
{
|
||||
"ko": "나라",
|
||||
"en": "country",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "얼굴",
|
||||
"en": "face",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "목소리",
|
||||
"en": "voice",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "이야기",
|
||||
"en": "story, talk",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "아니",
|
||||
"en": "no",
|
||||
"note": "반말"
|
||||
},
|
||||
{
|
||||
"ko": "맞아",
|
||||
"en": "that's right",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "이번",
|
||||
"en": "this time",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "다음",
|
||||
"en": "next",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "월",
|
||||
"en": "month",
|
||||
"note": "in dates: 삼월 = March"
|
||||
},
|
||||
{
|
||||
"ko": "원",
|
||||
"en": "won",
|
||||
"note": "the currency"
|
||||
},
|
||||
{
|
||||
"ko": "명",
|
||||
"en": "people",
|
||||
"note": "counter"
|
||||
},
|
||||
{
|
||||
"ko": "마리",
|
||||
"en": "animals",
|
||||
"note": "counter"
|
||||
},
|
||||
{
|
||||
"ko": "살",
|
||||
"en": "years of age",
|
||||
"note": "counter"
|
||||
},
|
||||
{
|
||||
"ko": "잔",
|
||||
"en": "cups, glasses",
|
||||
"note": "counter"
|
||||
},
|
||||
{
|
||||
"ko": "번",
|
||||
"en": "times, occasions",
|
||||
"note": "counter"
|
||||
},
|
||||
{
|
||||
"ko": "시",
|
||||
"en": "o'clock",
|
||||
"note": "counter"
|
||||
},
|
||||
{
|
||||
"ko": "못",
|
||||
"en": "cannot",
|
||||
"note": "placed before the verb"
|
||||
},
|
||||
{
|
||||
"ko": "아직",
|
||||
"en": "still, not yet",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "벌써",
|
||||
"en": "already",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "전혀",
|
||||
"en": "not at all",
|
||||
"note": "with a negative"
|
||||
},
|
||||
{
|
||||
"ko": "별로",
|
||||
"en": "not particularly",
|
||||
"note": "with a negative"
|
||||
},
|
||||
{
|
||||
"ko": "다르다",
|
||||
"en": "to be different",
|
||||
"note": "르 irregular"
|
||||
},
|
||||
{
|
||||
"ko": "빠르다",
|
||||
"en": "to be fast",
|
||||
"note": "르 irregular"
|
||||
},
|
||||
{
|
||||
"ko": "맵다",
|
||||
"en": "to be spicy",
|
||||
"note": "ㅂ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "걷다",
|
||||
"en": "to walk",
|
||||
"note": "ㄷ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "묻다",
|
||||
"en": "to ask",
|
||||
"note": "ㄷ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "낫다",
|
||||
"en": "to be better; to heal",
|
||||
"note": "ㅅ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "짓다",
|
||||
"en": "to build",
|
||||
"note": "ㅅ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "그렇다",
|
||||
"en": "to be so",
|
||||
"note": "ㅎ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "어떻다",
|
||||
"en": "to be how",
|
||||
"note": "ㅎ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "빨갛다",
|
||||
"en": "to be red",
|
||||
"note": "ㅎ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "하얗다",
|
||||
"en": "to be white",
|
||||
"note": "ㅎ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "까맣다",
|
||||
"en": "to be black",
|
||||
"note": "ㅎ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "놀다",
|
||||
"en": "to play",
|
||||
"note": "ㄹ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "멀다",
|
||||
"en": "to be far",
|
||||
"note": "ㄹ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "길다",
|
||||
"en": "to be long",
|
||||
"note": "ㄹ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "만들다",
|
||||
"en": "to make",
|
||||
"note": "ㄹ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "아까",
|
||||
"en": "a while ago",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "방금",
|
||||
"en": "just now",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "나중에",
|
||||
"en": "later",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "곧",
|
||||
"en": "soon",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "이따가",
|
||||
"en": "in a little while",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "정말",
|
||||
"en": "really",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "찾다",
|
||||
"en": "to look for, to find",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "열다",
|
||||
"en": "to open",
|
||||
"note": "ㄹ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "닫다",
|
||||
"en": "to close",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "시작하다",
|
||||
"en": "to start",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "끝나다",
|
||||
"en": "to end",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "요즘",
|
||||
"en": "these days",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "원래",
|
||||
"en": "originally",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "보통",
|
||||
"en": "usually",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "누가",
|
||||
"en": "who",
|
||||
"note": "subject form of 누구"
|
||||
},
|
||||
{
|
||||
"ko": "제가",
|
||||
"en": "I",
|
||||
"note": "humble, subject form"
|
||||
},
|
||||
{
|
||||
"ko": "뭘",
|
||||
"en": "what",
|
||||
"note": "contraction of 무엇을"
|
||||
},
|
||||
{
|
||||
"ko": "절",
|
||||
"en": "me",
|
||||
"note": "humble, contraction of 저를"
|
||||
},
|
||||
{
|
||||
"ko": "아래",
|
||||
"en": "below",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "옆",
|
||||
"en": "beside, next to",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "뒤",
|
||||
"en": "behind",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "사이",
|
||||
"en": "between",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "혼자",
|
||||
"en": "alone",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "하고",
|
||||
"en": "and; with",
|
||||
"note": "particle"
|
||||
},
|
||||
{
|
||||
"ko": "랑",
|
||||
"en": "and; with",
|
||||
"note": "casual particle"
|
||||
},
|
||||
{
|
||||
"ko": "로",
|
||||
"en": "by, with, toward",
|
||||
"note": "particle"
|
||||
},
|
||||
{
|
||||
"ko": "부터",
|
||||
"en": "from",
|
||||
"note": "particle"
|
||||
},
|
||||
{
|
||||
"ko": "까지",
|
||||
"en": "until, up to",
|
||||
"note": "particle"
|
||||
},
|
||||
{
|
||||
"ko": "처럼",
|
||||
"en": "like, as",
|
||||
"note": "particle"
|
||||
},
|
||||
{
|
||||
"ko": "그때",
|
||||
"en": "at that time",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "걔",
|
||||
"en": "that kid",
|
||||
"note": "contraction of 그 애"
|
||||
},
|
||||
{
|
||||
"ko": "얘",
|
||||
"en": "this kid",
|
||||
"note": "contraction of 이 애"
|
||||
},
|
||||
{
|
||||
"ko": "그렇습니다",
|
||||
"en": "that is so",
|
||||
"note": "formal"
|
||||
},
|
||||
{
|
||||
"ko": "말씀",
|
||||
"en": "words",
|
||||
"note": "honorific"
|
||||
},
|
||||
{
|
||||
"ko": "님",
|
||||
"en": "honorific suffix",
|
||||
"note": "선생님, 형님"
|
||||
},
|
||||
{
|
||||
"ko": "드리다",
|
||||
"en": "to give",
|
||||
"note": "humble, to someone above you"
|
||||
},
|
||||
{
|
||||
"ko": "계시다",
|
||||
"en": "to be, to stay",
|
||||
"note": "honorific of 있다"
|
||||
},
|
||||
{
|
||||
"ko": "주무시다",
|
||||
"en": "to sleep",
|
||||
"note": "honorific of 자다"
|
||||
},
|
||||
{
|
||||
"ko": "걱정하다",
|
||||
"en": "to worry",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "신경 쓰다",
|
||||
"en": "to be bothered, to care about",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "싶다",
|
||||
"en": "to want",
|
||||
"note": "used after -고"
|
||||
},
|
||||
{
|
||||
"ko": "수",
|
||||
"en": "possibility",
|
||||
"note": "used in -ㄹ 수 있다"
|
||||
},
|
||||
{
|
||||
"ko": "필요하다",
|
||||
"en": "to be necessary",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "그렇지만",
|
||||
"en": "but, however",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "왜냐하면",
|
||||
"en": "because",
|
||||
"note": "opens the reason"
|
||||
},
|
||||
{
|
||||
"ko": "때문에",
|
||||
"en": "because of",
|
||||
"note": "after a noun"
|
||||
},
|
||||
{
|
||||
"ko": "만약",
|
||||
"en": "if",
|
||||
"note": "paired with -면"
|
||||
},
|
||||
{
|
||||
"ko": "혹시",
|
||||
"en": "by any chance",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "아마",
|
||||
"en": "probably",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "기분",
|
||||
"en": "mood, feeling",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "걱정",
|
||||
"en": "worry",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "미안하다",
|
||||
"en": "to be sorry",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "고맙다",
|
||||
"en": "to be thankful",
|
||||
"note": "ㅂ irregular"
|
||||
},
|
||||
{
|
||||
"ko": "것",
|
||||
"en": "thing",
|
||||
"note": "bound noun"
|
||||
},
|
||||
{
|
||||
"ko": "거",
|
||||
"en": "thing",
|
||||
"note": "casual 것"
|
||||
},
|
||||
{
|
||||
"ko": "때",
|
||||
"en": "time, when",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "곳",
|
||||
"en": "place",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"ko": "적",
|
||||
"en": "an occasion",
|
||||
"note": "used in -ㄴ 적 있다"
|
||||
},
|
||||
{
|
||||
"ko": "라고",
|
||||
"en": "quoting particle",
|
||||
"note": "marks what someone said"
|
||||
},
|
||||
{
|
||||
"ko": "대",
|
||||
"en": "he says that…",
|
||||
"note": "reported statement, contracted from -다고 해"
|
||||
},
|
||||
{
|
||||
"ko": "래",
|
||||
"en": "he says to…",
|
||||
"note": "reported suggestion or request, from -라고 해"
|
||||
},
|
||||
{
|
||||
"ko": "냬",
|
||||
"en": "he asks whether…",
|
||||
"note": "reported question, from -냐고 해"
|
||||
},
|
||||
{
|
||||
"ko": "재",
|
||||
"en": "he suggests that we…",
|
||||
"note": "reported proposal, from -자고 해"
|
||||
},
|
||||
{
|
||||
"ko": "이다",
|
||||
"en": "to be",
|
||||
"note": "the copula, dictionary form"
|
||||
},
|
||||
{
|
||||
"ko": "했다",
|
||||
"en": "did",
|
||||
"note": "plain written past"
|
||||
},
|
||||
{
|
||||
"ko": "한다",
|
||||
"en": "does",
|
||||
"note": "plain written present"
|
||||
},
|
||||
{
|
||||
"ko": "였다",
|
||||
"en": "was",
|
||||
"note": "plain written past of 이다"
|
||||
},
|
||||
{
|
||||
"ko": "난",
|
||||
"en": "I",
|
||||
"note": "contraction of 나는"
|
||||
},
|
||||
{
|
||||
"ko": "넌",
|
||||
"en": "you",
|
||||
"note": "contraction of 너는"
|
||||
},
|
||||
{
|
||||
"ko": "건",
|
||||
"en": "the thing",
|
||||
"note": "contraction of 것은"
|
||||
},
|
||||
{
|
||||
"ko": "이건",
|
||||
"en": "this thing",
|
||||
"note": "contraction of 이것은"
|
||||
}
|
||||
]
|
||||
}
|
||||
961
data/grammar.json
Normal file
961
data/grammar.json
Normal file
@@ -0,0 +1,961 @@
|
||||
{
|
||||
"note": "Reference grammar points. read:1 marks reading-priority items.",
|
||||
"points": [
|
||||
{
|
||||
"id": "g-eun",
|
||||
"cat": "조사 Particles",
|
||||
"form": "은 / 는",
|
||||
"name": "topic marker",
|
||||
"why": "Names what the sentence is about, and quietly sets it against something else. 은 after a consonant, 는 after a vowel. A Korean sentence can drop it once the topic is obvious.",
|
||||
"ex": [
|
||||
[
|
||||
"저는 학생이에요.",
|
||||
"jeoneun haksaeng-ieyo.",
|
||||
"I'm a student."
|
||||
],
|
||||
[
|
||||
"김치는 매워요.",
|
||||
"gimchineun maewoyo.",
|
||||
"Kimchi — that one's spicy."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-iga",
|
||||
"cat": "조사 Particles",
|
||||
"form": "이 / 가",
|
||||
"name": "subject marker",
|
||||
"why": "Marks who or what performs the verb, and introduces information that is new. 이 after a consonant, 가 after a vowel. 저 + 가 becomes 제가.",
|
||||
"ex": [
|
||||
[
|
||||
"친구가 왔어요.",
|
||||
"chinguga wasseoyo.",
|
||||
"A friend came."
|
||||
],
|
||||
[
|
||||
"시간이 없어요.",
|
||||
"sigani eopseoyo.",
|
||||
"There's no time."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-eul",
|
||||
"cat": "조사 Particles",
|
||||
"form": "을 / 를",
|
||||
"name": "object marker",
|
||||
"why": "Marks the thing the verb acts on. 을 after a consonant, 를 after a vowel. Dropped constantly in speech.",
|
||||
"ex": [
|
||||
[
|
||||
"밥을 먹어요.",
|
||||
"babeul meogeoyo.",
|
||||
"I'm eating."
|
||||
],
|
||||
[
|
||||
"커피를 마셔요.",
|
||||
"keopireul masyeoyo.",
|
||||
"I'm drinking coffee."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-e",
|
||||
"cat": "조사 Particles",
|
||||
"form": "에",
|
||||
"name": "at, to, in (destination & time)",
|
||||
"why": "Points at a destination, a point in time, or a static location with 있다 / 없다. Never used for where an action takes place — that is 에서.",
|
||||
"ex": [
|
||||
[
|
||||
"학교에 가요.",
|
||||
"hakgyoe gayo.",
|
||||
"I go to school."
|
||||
],
|
||||
[
|
||||
"세 시에 만나요.",
|
||||
"se sie mannayo.",
|
||||
"Let's meet at three."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-eseo",
|
||||
"cat": "조사 Particles",
|
||||
"form": "에서",
|
||||
"name": "at, in (action), from",
|
||||
"why": "Where something happens, or where it started. Pair it with 에 to feel the split: 집에 있어요 (I'm at home) vs 집에서 일해요 (I work at home).",
|
||||
"ex": [
|
||||
[
|
||||
"카페에서 공부해요.",
|
||||
"kapeeseo gongbuhaeyo.",
|
||||
"I study at the cafe."
|
||||
],
|
||||
[
|
||||
"독일에서 왔어요.",
|
||||
"dogireseo wasseoyo.",
|
||||
"I'm from Germany."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-do",
|
||||
"cat": "조사 Particles",
|
||||
"form": "도",
|
||||
"name": "also, too",
|
||||
"why": "Replaces 은/는, 이/가 and 을/를 rather than stacking onto them. 저는도 is wrong; 저도 is right.",
|
||||
"ex": [
|
||||
[
|
||||
"저도 가요.",
|
||||
"jeodo gayo.",
|
||||
"I'm going too."
|
||||
],
|
||||
[
|
||||
"물도 주세요.",
|
||||
"muldo juseyo.",
|
||||
"Water as well, please."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-man",
|
||||
"cat": "조사 Particles",
|
||||
"form": "만",
|
||||
"name": "only, just",
|
||||
"why": "Also replaces the subject and object markers. Attaches after time and counters happily.",
|
||||
"ex": [
|
||||
[
|
||||
"물만 마셔요.",
|
||||
"mulman masyeoyo.",
|
||||
"I only drink water."
|
||||
],
|
||||
[
|
||||
"십 분만 기다려 주세요.",
|
||||
"sip bunman gidaryeo juseyo.",
|
||||
"Please wait just ten minutes."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-hago",
|
||||
"cat": "조사 Particles",
|
||||
"form": "하고 · 와/과 · (이)랑",
|
||||
"name": "and, with",
|
||||
"why": "Three ways to join two nouns. 와 after a vowel, 과 after a consonant — the written pair. 하고 is neutral, (이)랑 is casual speech.",
|
||||
"ex": [
|
||||
[
|
||||
"빵하고 우유를 샀어요.",
|
||||
"ppanghago uyureul sasseoyo.",
|
||||
"I bought bread and milk."
|
||||
],
|
||||
[
|
||||
"친구와 같이 갔어요.",
|
||||
"chinguwa gachi gasseoyo.",
|
||||
"I went together with a friend."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-ui",
|
||||
"cat": "조사 Particles",
|
||||
"form": "의",
|
||||
"name": "possessive 's",
|
||||
"why": "Written 의 but pronounced [에] here. Often dropped entirely: 친구 가방 is perfectly natural. 저의 contracts to 제, 나의 to 내.",
|
||||
"ex": [
|
||||
[
|
||||
"친구의 가방이에요.",
|
||||
"chinguui gabang-ieyo.",
|
||||
"It's my friend's bag."
|
||||
],
|
||||
[
|
||||
"한국의 날씨는 어때요?",
|
||||
"hangugui nalssineun eottaeyo?",
|
||||
"How is the weather in Korea?"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-buteo",
|
||||
"cat": "조사 Particles",
|
||||
"form": "부터 ~ 까지",
|
||||
"name": "from ~ until",
|
||||
"why": "부터 ~ 까지 spans time; 에서 ~ 까지 spans distance. Mixing them up is the classic beginner tell.",
|
||||
"ex": [
|
||||
[
|
||||
"아홉 시부터 여섯 시까지 일해요.",
|
||||
"ahop sibuteo yeoseot sikkaji ilhaeyo.",
|
||||
"I work from nine until six."
|
||||
],
|
||||
[
|
||||
"집에서 회사까지 삼십 분 걸려요.",
|
||||
"jibeseo hoesakkaji samsip bun geollyeoyo.",
|
||||
"It takes thirty minutes from home to the office."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-boda",
|
||||
"cat": "조사 Particles",
|
||||
"form": "보다 (더)",
|
||||
"name": "more than",
|
||||
"why": "Attaches to the thing being beaten, not the winner: A는 B보다 — A beats B. 더 is optional but very common.",
|
||||
"ex": [
|
||||
[
|
||||
"오늘이 어제보다 더워요.",
|
||||
"oneuri eojeboda deowoyo.",
|
||||
"Today is hotter than yesterday."
|
||||
],
|
||||
[
|
||||
"저는 커피보다 차를 더 좋아해요.",
|
||||
"jeoneun keopiboda chareul deo joahaeyo.",
|
||||
"I like tea more than coffee."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-cheoreom",
|
||||
"cat": "조사 Particles",
|
||||
"form": "처럼 · 같이",
|
||||
"name": "like, as",
|
||||
"why": "Both attach directly to a noun. 같이 here is the adverb 'like', the same word as 'together' — context separates them.",
|
||||
"ex": [
|
||||
[
|
||||
"한국 사람처럼 말해요.",
|
||||
"hanguk saramcheoreom malhaeyo.",
|
||||
"You speak like a Korean."
|
||||
],
|
||||
[
|
||||
"얼음같이 차가워요.",
|
||||
"eoreumgachi chagawoyo.",
|
||||
"It's cold as ice."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-ieyo",
|
||||
"cat": "서술 Predicates",
|
||||
"form": "N + 이에요 / 예요",
|
||||
"name": "to be",
|
||||
"why": "The copula. 이에요 after a consonant, 예요 after a vowel. Formal register: 입니다. Negative: N이/가 아니에요.",
|
||||
"ex": [
|
||||
[
|
||||
"저는 파비안이에요.",
|
||||
"jeoneun pabian-ieyo.",
|
||||
"I'm Fabian."
|
||||
],
|
||||
[
|
||||
"여기가 우리 집이에요.",
|
||||
"yeogiga uri jibieyo.",
|
||||
"This is our house."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-ayo",
|
||||
"cat": "서술 Predicates",
|
||||
"form": "V/A + 아요 / 어요",
|
||||
"name": "polite present (해요체)",
|
||||
"why": "The everyday polite ending, and the one to master first. Stem vowel ㅏ or ㅗ takes 아요; everything else takes 어요; 하다 becomes 해요. Same form covers present tense, near future and a gentle suggestion.",
|
||||
"ex": [
|
||||
[
|
||||
"지금 밥을 먹어요.",
|
||||
"jigeum babeul meogeoyo.",
|
||||
"I'm eating now."
|
||||
],
|
||||
[
|
||||
"매일 운동해요.",
|
||||
"maeil undonghaeyo.",
|
||||
"I exercise every day."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-past",
|
||||
"cat": "서술 Predicates",
|
||||
"form": "V/A + 았 / 었어요",
|
||||
"name": "past tense",
|
||||
"why": "Take the 아/어 form, insert ㅆ, add 어요. 먹어요 → 먹었어요, 가요 → 갔어요, 해요 → 했어요.",
|
||||
"ex": [
|
||||
[
|
||||
"어제 영화를 봤어요.",
|
||||
"eoje yeonghwareul bwasseoyo.",
|
||||
"I watched a film yesterday."
|
||||
],
|
||||
[
|
||||
"밥을 안 먹었어요.",
|
||||
"babeul an meogeosseoyo.",
|
||||
"I didn't eat."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-future",
|
||||
"cat": "서술 Predicates",
|
||||
"form": "V + (으)ㄹ 거예요",
|
||||
"name": "future, intention",
|
||||
"why": "Plans and predictions. Add ㄹ 거예요 to a vowel-final stem, 을 거예요 after a consonant. Softer and more common than the bookish -겠-.",
|
||||
"ex": [
|
||||
[
|
||||
"내일 한국에 갈 거예요.",
|
||||
"naeil hanguge gal geoyeyo.",
|
||||
"I'll go to Korea tomorrow."
|
||||
],
|
||||
[
|
||||
"주말에 쉴 거예요.",
|
||||
"jumare swil geoyeyo.",
|
||||
"I'm going to rest at the weekend."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-nida",
|
||||
"cat": "서술 Predicates",
|
||||
"form": "V/A + ㅂ니다 / 습니다",
|
||||
"name": "formal polite (합쇼체)",
|
||||
"why": "The register of announcements, presentations, the news and the army. Vowel stem takes ㅂ니다, consonant stem 습니다. Questions end -ㅂ니까?",
|
||||
"ex": [
|
||||
[
|
||||
"감사합니다.",
|
||||
"gamsahamnida.",
|
||||
"Thank you."
|
||||
],
|
||||
[
|
||||
"저는 독일에서 왔습니다.",
|
||||
"jeoneun dogireseo watseumnida.",
|
||||
"I came from Germany."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-an",
|
||||
"cat": "부정 Negation",
|
||||
"form": "안 + V/A · V/A + 지 않다",
|
||||
"name": "simple negation",
|
||||
"why": "안 goes in front and is the spoken default; -지 않다 goes behind and reads a touch more formal. For 하다 verbs, 안 slips inside: 공부 안 해요, not 안 공부해요.",
|
||||
"ex": [
|
||||
[
|
||||
"오늘은 안 바빠요.",
|
||||
"oneureun an bappayo.",
|
||||
"I'm not busy today."
|
||||
],
|
||||
[
|
||||
"그 영화는 재미있지 않았어요.",
|
||||
"geu yeonghwaneun jaemiitji anasseoyo.",
|
||||
"That film wasn't interesting."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-mot",
|
||||
"cat": "부정 Negation",
|
||||
"form": "못 + V · V + 지 못하다",
|
||||
"name": "cannot (ability, circumstance)",
|
||||
"why": "안 is 'I don't'; 못 is 'I can't, even though I might want to'. Pronounced [몯] before a consonant, [모ㅅ] links forward before a vowel.",
|
||||
"ex": [
|
||||
[
|
||||
"매운 음식을 못 먹어요.",
|
||||
"maeun eumsigeul mot meogeoyo.",
|
||||
"I can't eat spicy food."
|
||||
],
|
||||
[
|
||||
"어제 못 갔어요.",
|
||||
"eoje mot gasseoyo.",
|
||||
"I couldn't go yesterday."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-jima",
|
||||
"cat": "부정 Negation",
|
||||
"form": "V + 지 마세요",
|
||||
"name": "please don't",
|
||||
"why": "The negative command. Attaches to the plain verb stem. Blunter: -지 마.",
|
||||
"ex": [
|
||||
[
|
||||
"여기에서 사진을 찍지 마세요.",
|
||||
"yeogieseo sajineul jjikji maseyo.",
|
||||
"Please don't take photos here."
|
||||
],
|
||||
[
|
||||
"걱정하지 마세요.",
|
||||
"geokjeonghaji maseyo.",
|
||||
"Don't worry."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-gosipda",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "V + 고 싶다",
|
||||
"name": "want to",
|
||||
"why": "Only for I and you. Talking about a third person's wants needs -고 싶어하다, because Korean does not claim to know another person's inner state.",
|
||||
"ex": [
|
||||
[
|
||||
"한국에 가고 싶어요.",
|
||||
"hanguge gago sipeoyo.",
|
||||
"I want to go to Korea."
|
||||
],
|
||||
[
|
||||
"뭐 먹고 싶어요?",
|
||||
"mwo meokgo sipeoyo?",
|
||||
"What do you want to eat?"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-lsu",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "V + (으)ㄹ 수 있다 / 없다",
|
||||
"name": "can / cannot",
|
||||
"why": "Literally 'there is a way to'. Vowel stem takes ㄹ 수, consonant stem 을 수.",
|
||||
"ex": [
|
||||
[
|
||||
"한국어를 조금 할 수 있어요.",
|
||||
"hangugeoreul jogeum hal su isseoyo.",
|
||||
"I can speak a little Korean."
|
||||
],
|
||||
[
|
||||
"오늘은 갈 수 없어요.",
|
||||
"oneureun gal su eopseoyo.",
|
||||
"I can't go today."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-ayadoeda",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "V/A + 아 / 어야 되다",
|
||||
"name": "must, have to",
|
||||
"why": "Built on the 아/어 form. 되다 and 하다 are interchangeable here; 되다 is more spoken.",
|
||||
"ex": [
|
||||
[
|
||||
"지금 가야 돼요.",
|
||||
"jigeum gaya dwaeyo.",
|
||||
"I have to go now."
|
||||
],
|
||||
[
|
||||
"약을 먹어야 해요.",
|
||||
"yageul meogeoya haeyo.",
|
||||
"You need to take the medicine."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-euseyo",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "V + (으)세요",
|
||||
"name": "polite request & honorific",
|
||||
"why": "Two jobs at once: a soft command to the listener, and the honorific present when the subject deserves respect. A few verbs go irregular — 먹다 → 드세요, 자다 → 주무세요, 있다 → 계세요.",
|
||||
"ex": [
|
||||
[
|
||||
"여기 앉으세요.",
|
||||
"yeogi anjeuseyo.",
|
||||
"Please sit here."
|
||||
],
|
||||
[
|
||||
"할머니는 지금 주무세요.",
|
||||
"halmeonineun jigeum jumuseyo.",
|
||||
"Grandmother is sleeping."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-juseyo",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "V + 아 / 어 주세요",
|
||||
"name": "please do it for me",
|
||||
"why": "The single most useful thing to say in a shop, a taxi or a classroom. Adds a sense of favour that plain -(으)세요 lacks.",
|
||||
"ex": [
|
||||
[
|
||||
"천천히 말해 주세요.",
|
||||
"cheoncheonhi malhae juseyo.",
|
||||
"Please speak slowly."
|
||||
],
|
||||
[
|
||||
"이것 좀 보여 주세요.",
|
||||
"igeot jom boyeo juseyo.",
|
||||
"Could you show me this one."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-ryeogo",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "V + (으)려고 하다",
|
||||
"name": "intend to, be about to",
|
||||
"why": "A plan already formed, not a fresh decision. Both clauses must share a subject.",
|
||||
"ex": [
|
||||
[
|
||||
"한국어를 배우려고 해요.",
|
||||
"hangugeoreul baeuryeogo haeyo.",
|
||||
"I intend to learn Korean."
|
||||
],
|
||||
[
|
||||
"나가려고 했어요.",
|
||||
"nagaryeogo haesseoyo.",
|
||||
"I was about to go out."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-reo",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "V + (으)러 가다 / 오다",
|
||||
"name": "go in order to",
|
||||
"why": "Only pairs with verbs of movement — 가다, 오다, 다니다. The purpose comes first, the movement second.",
|
||||
"ex": [
|
||||
[
|
||||
"밥 먹으러 가요.",
|
||||
"bap meogeureo gayo.",
|
||||
"Let's go eat."
|
||||
],
|
||||
[
|
||||
"친구를 만나러 왔어요.",
|
||||
"chingureul mannareo wasseoyo.",
|
||||
"I came to meet a friend."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-geotgatda",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "A/V + 것 같다",
|
||||
"name": "it seems, I think",
|
||||
"why": "Korean prefers a hedge to a flat assertion, so this shows up far more than 'I think' does in English. 올 것 같다 for a guess ahead, 온 것 같다 for a guess about what already happened.",
|
||||
"ex": [
|
||||
[
|
||||
"비가 올 것 같아요.",
|
||||
"biga ol geot gatayo.",
|
||||
"It looks like rain."
|
||||
],
|
||||
[
|
||||
"이 책이 좋은 것 같아요.",
|
||||
"i chaegi joeun geot gatayo.",
|
||||
"I think this book is good."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-lkkayo",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "V + (으)ㄹ까요?",
|
||||
"name": "shall we? I wonder?",
|
||||
"why": "With a first-person plural sense it proposes; with a third-person subject it wonders aloud.",
|
||||
"ex": [
|
||||
[
|
||||
"같이 갈까요?",
|
||||
"gachi galkkayo?",
|
||||
"Shall we go together?"
|
||||
],
|
||||
[
|
||||
"비가 올까요?",
|
||||
"biga olkkayo?",
|
||||
"Do you think it'll rain?"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-neyo",
|
||||
"cat": "표현 Expressions",
|
||||
"form": "A/V + 네요",
|
||||
"name": "realisation, mild surprise",
|
||||
"why": "Marks something you have just noticed. Warm and very common — the difference between 좋아요 (it's good) and 좋네요 (oh, that's nice).",
|
||||
"ex": [
|
||||
[
|
||||
"날씨가 좋네요.",
|
||||
"nalssiga jonneyo.",
|
||||
"The weather's lovely, isn't it."
|
||||
],
|
||||
[
|
||||
"한국어를 잘하네요.",
|
||||
"hangugeoreul jalhaneyo.",
|
||||
"Oh — your Korean is good."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-go",
|
||||
"cat": "연결 Connectives",
|
||||
"form": "V/A + 고",
|
||||
"name": "and (listing, sequence)",
|
||||
"why": "Joins two clauses with no cause-and-effect claim. Tense is carried only by the final verb.",
|
||||
"ex": [
|
||||
[
|
||||
"밥을 먹고 커피를 마셨어요.",
|
||||
"babeul meokgo keopireul masyeosseoyo.",
|
||||
"I ate and then drank coffee."
|
||||
],
|
||||
[
|
||||
"이 카페는 조용하고 싸요.",
|
||||
"i kapeneun joyonghago ssayo.",
|
||||
"This cafe is quiet and cheap."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-jiman",
|
||||
"cat": "연결 Connectives",
|
||||
"form": "V/A + 지만",
|
||||
"name": "but",
|
||||
"why": "Attaches straight to the stem, and unlike -아/어서 it does take tense: 했지만.",
|
||||
"ex": [
|
||||
[
|
||||
"한국어는 어렵지만 재미있어요.",
|
||||
"hangugeoneun eoryeopjiman jaemiisseoyo.",
|
||||
"Korean is hard but it's fun."
|
||||
],
|
||||
[
|
||||
"갔지만 못 만났어요.",
|
||||
"gatjiman mot mannasseoyo.",
|
||||
"I went, but I couldn't meet them."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-aseo",
|
||||
"cat": "연결 Connectives",
|
||||
"form": "V/A + 아 / 어서",
|
||||
"name": "because; and then",
|
||||
"why": "Two readings from one form: a reason, or a sequence where the first action sets up the second. Never carries past tense before 서, and never precedes a command or a suggestion — that is what -(으)니까 is for.",
|
||||
"ex": [
|
||||
[
|
||||
"배가 아파서 병원에 갔어요.",
|
||||
"baega apaseo byeongwone gasseoyo.",
|
||||
"I went to the hospital because my stomach hurt."
|
||||
],
|
||||
[
|
||||
"친구를 만나서 밥을 먹었어요.",
|
||||
"chingureul mannaseo babeul meogeosseoyo.",
|
||||
"I met a friend and we ate."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-nikka",
|
||||
"cat": "연결 Connectives",
|
||||
"form": "V/A + (으)니까",
|
||||
"name": "because (before commands)",
|
||||
"why": "The reason ending that is allowed to precede 하세요, 합시다 and -(으)ㄹ까요. It also accepts past tense, which -아/어서 refuses.",
|
||||
"ex": [
|
||||
[
|
||||
"비가 오니까 택시를 탑시다.",
|
||||
"biga onikka taeksireul tapsida.",
|
||||
"It's raining, so let's take a taxi."
|
||||
],
|
||||
[
|
||||
"시간이 없으니까 빨리 가세요.",
|
||||
"sigani eopseunikka ppalli gaseyo.",
|
||||
"There's no time, so please hurry."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-neunde",
|
||||
"cat": "연결 Connectives",
|
||||
"form": "V + 는데 · A + (으)ㄴ데",
|
||||
"name": "background, contrast, soft lead-in",
|
||||
"why": "The hardest-working ending in conversational Korean. It sets the scene, softens a contrast, or simply opens a topic and trails off. Verbs take 는데, adjectives (으)ㄴ데, past tense 았/었는데.",
|
||||
"ex": [
|
||||
[
|
||||
"지금 공부하는데 좀 어려워요.",
|
||||
"jigeum gongbuhaneunde jom eoryeowoyo.",
|
||||
"I'm studying now, and it's a bit hard."
|
||||
],
|
||||
[
|
||||
"이 옷은 예쁜데 비싸요.",
|
||||
"i oseun yeppeunde bissayo.",
|
||||
"These clothes are pretty, but expensive."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-gijeone",
|
||||
"cat": "연결 Connectives",
|
||||
"form": "V + 기 전에",
|
||||
"name": "before doing",
|
||||
"why": "Always the plain stem + 기 전에, with no tense marking. With a noun, drop the 기: 식사 전에.",
|
||||
"ex": [
|
||||
[
|
||||
"자기 전에 책을 읽어요.",
|
||||
"jagi jeone chaegeul ilgeoyo.",
|
||||
"I read before going to sleep."
|
||||
],
|
||||
[
|
||||
"가기 전에 전화하세요.",
|
||||
"gagi jeone jeonhwahaseyo.",
|
||||
"Call before you go."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-nhue",
|
||||
"cat": "연결 Connectives",
|
||||
"form": "V + (으)ㄴ 후에",
|
||||
"name": "after doing",
|
||||
"why": "Mirror of 기 전에, but built on the past-modifier form: 먹은 후에, 간 후에. -(으)ㄴ 다음에 means the same thing.",
|
||||
"ex": [
|
||||
[
|
||||
"밥을 먹은 후에 산책해요.",
|
||||
"babeul meogeun hue sanchaekaeyo.",
|
||||
"After eating I go for a walk."
|
||||
],
|
||||
[
|
||||
"일이 끝난 후에 만나요.",
|
||||
"iri kkeunnan hue mannayo.",
|
||||
"Let's meet after work finishes."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-numsys",
|
||||
"cat": "수 Numbers & counters",
|
||||
"form": "일이삼 vs 하나둘셋",
|
||||
"name": "the two number systems",
|
||||
"why": "Korean runs two full sets of numbers side by side. Sino-Korean handles dates, money, minutes, phone numbers, floors and anything above 99 in counting. Native Korean handles counted objects, people, hours and age. A clock reading uses both at once.",
|
||||
"ex": [
|
||||
[
|
||||
"세 시 삼십 분이에요.",
|
||||
"se si samsip bunieyo.",
|
||||
"It's 3:30. (native hour, Sino minute)"
|
||||
],
|
||||
[
|
||||
"스물다섯 살이에요.",
|
||||
"seumuldaseot sarieyo.",
|
||||
"I'm twenty-five."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-counters",
|
||||
"cat": "수 Numbers & counters",
|
||||
"form": "개 · 명 · 마리 · 잔 · 권 · 살",
|
||||
"name": "counters",
|
||||
"why": "Nothing is counted bare — a counter word sits between the number and the verb. Before a counter, 하나·둘·셋·넷·스물 shorten to 한·두·세·네·스무. Order: noun, number, counter.",
|
||||
"ex": [
|
||||
[
|
||||
"커피 두 잔 주세요.",
|
||||
"keopi du jan juseyo.",
|
||||
"Two coffees, please."
|
||||
],
|
||||
[
|
||||
"고양이 세 마리가 있어요.",
|
||||
"goyangi se mariga isseoyo.",
|
||||
"There are three cats."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-banmal",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V/A + 아 / 어",
|
||||
"name": "반말 — plain casual speech",
|
||||
"read": 1,
|
||||
"why": "Take the polite 아/어요 form and drop the 요. That is 반말, and it is what almost every line of manhwa dialogue is written in. Between friends, from an older speaker to a younger one, and in a character's own thoughts.",
|
||||
"ex": [
|
||||
[
|
||||
"밥 먹어.",
|
||||
"",
|
||||
"Eat. / I'm eating."
|
||||
],
|
||||
[
|
||||
"어디 가?",
|
||||
"",
|
||||
"Where are you going?"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-ya",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "N + 야 / 이야",
|
||||
"name": "casual 'to be'",
|
||||
"read": 1,
|
||||
"why": "The 반말 copula: 이에요/예요 with the 요 gone and the vowel shortened. 야 after a vowel, 이야 after a consonant. Questions are identical — only the rising tone and the question mark differ.",
|
||||
"ex": [
|
||||
[
|
||||
"우리 친구야.",
|
||||
"",
|
||||
"We're friends."
|
||||
],
|
||||
[
|
||||
"범인은 저 사람이야.",
|
||||
"",
|
||||
"The culprit is that person."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-eosseo",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V/A + 았 / 었어",
|
||||
"name": "casual past",
|
||||
"read": 1,
|
||||
"why": "The past tense minus the 요. This is the workhorse of manhwa narration and flashbacks.",
|
||||
"ex": [
|
||||
[
|
||||
"소문 들었어?",
|
||||
"",
|
||||
"Did you hear the rumour?"
|
||||
],
|
||||
[
|
||||
"나 회귀했어.",
|
||||
"",
|
||||
"I regressed."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-lge",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V + (으)ㄹ게",
|
||||
"name": "I'll do it (a promise to you)",
|
||||
"read": 1,
|
||||
"why": "A first-person commitment made for the listener's benefit — not a neutral prediction. Very common in the panel right before someone does something heroic.",
|
||||
"ex": [
|
||||
[
|
||||
"내가 지킬게.",
|
||||
"",
|
||||
"I'll protect you."
|
||||
],
|
||||
[
|
||||
"나 갈게.",
|
||||
"",
|
||||
"I'm off."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-lgeoya",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V + (으)ㄹ 거야",
|
||||
"name": "casual future / intention",
|
||||
"read": 1,
|
||||
"why": "-(으)ㄹ 거예요 in 반말. Declaring what you are going to do, often as a threat or a vow.",
|
||||
"ex": [
|
||||
[
|
||||
"복수할 거야.",
|
||||
"",
|
||||
"I'm going to take revenge."
|
||||
],
|
||||
[
|
||||
"안 갈 거야.",
|
||||
"",
|
||||
"I'm not going."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-ja",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V + 자",
|
||||
"name": "let's",
|
||||
"read": 1,
|
||||
"why": "The casual suggestion. 가자, 하자, 먹자 — three syllables that move a whole scene along.",
|
||||
"ex": [
|
||||
[
|
||||
"우리 헤어지자.",
|
||||
"",
|
||||
"Let's break up."
|
||||
],
|
||||
[
|
||||
"가자.",
|
||||
"",
|
||||
"Let's go."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-janha",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V/A + 잖아",
|
||||
"name": "you know, as you're aware",
|
||||
"read": 1,
|
||||
"why": "Appeals to something the listener already knows, often impatiently. Reading it as plain 'because' loses the exasperation the panel is drawing.",
|
||||
"ex": [
|
||||
[
|
||||
"내가 말했잖아.",
|
||||
"",
|
||||
"I told you, didn't I."
|
||||
],
|
||||
[
|
||||
"위험하잖아.",
|
||||
"",
|
||||
"It's dangerous, you know."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-geodeun",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V/A + 거든",
|
||||
"name": "the thing is…",
|
||||
"read": 1,
|
||||
"why": "Supplies a reason the listener did not have, sometimes with a note of defiance. Often ends a speech bubble on its own.",
|
||||
"ex": [
|
||||
[
|
||||
"나 바쁘거든.",
|
||||
"",
|
||||
"I'm busy, that's why."
|
||||
],
|
||||
[
|
||||
"걔가 먼저 그랬거든.",
|
||||
"",
|
||||
"He started it, actually."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-deora",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V/A + 더라",
|
||||
"name": "I saw it myself",
|
||||
"read": 1,
|
||||
"why": "Reports something the speaker personally witnessed, recalled with a little surprise. A flag that the line is a memory or an eyewitness account.",
|
||||
"ex": [
|
||||
[
|
||||
"그 사람 진짜 강하더라.",
|
||||
"",
|
||||
"That person was seriously strong, I saw it."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-quote",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V + 대 / 래 / 냬 / 재",
|
||||
"name": "hearsay — 'they say'",
|
||||
"read": 1,
|
||||
"why": "Contracted quotation, everywhere in gossip panels. -대 reports a statement, -래 a command or an 'is', -냬 a question, -재 a suggestion. If a line ends in one of these, someone else said it first.",
|
||||
"ex": [
|
||||
[
|
||||
"시험 어렵대.",
|
||||
"",
|
||||
"They say the exam is hard."
|
||||
],
|
||||
[
|
||||
"같이 가재.",
|
||||
"",
|
||||
"He says let's go together."
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "g-neun",
|
||||
"cat": "반말 Casual speech",
|
||||
"form": "V + 는 / A + (으)ㄴ + N",
|
||||
"name": "modifying a noun",
|
||||
"read": 1,
|
||||
"why": "Korean puts the whole description in front of the noun, where English puts it after: 내가 죽인 몬스터 is 'the monster I killed'. Once you can see where the modifier ends and the noun begins, long manhwa narration stops being a wall.",
|
||||
"ex": [
|
||||
[
|
||||
"내가 죽인 몬스터.",
|
||||
"",
|
||||
"The monster I killed."
|
||||
],
|
||||
[
|
||||
"강한 사람.",
|
||||
"",
|
||||
"A strong person."
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
492
data/hangul.json
Normal file
492
data/hangul.json
Normal file
@@ -0,0 +1,492 @@
|
||||
{
|
||||
"consonants": [
|
||||
{
|
||||
"jamo": "ㄱ",
|
||||
"roman": "g / k",
|
||||
"name": "기역",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㄲ",
|
||||
"roman": "kk",
|
||||
"name": "쌍기역",
|
||||
"tense": true
|
||||
},
|
||||
{
|
||||
"jamo": "ㄴ",
|
||||
"roman": "n",
|
||||
"name": "니은",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㄷ",
|
||||
"roman": "d / t",
|
||||
"name": "디귿",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㄸ",
|
||||
"roman": "tt",
|
||||
"name": "쌍디귿",
|
||||
"tense": true
|
||||
},
|
||||
{
|
||||
"jamo": "ㄹ",
|
||||
"roman": "r / l",
|
||||
"name": "리을",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅁ",
|
||||
"roman": "m",
|
||||
"name": "미음",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅂ",
|
||||
"roman": "b / p",
|
||||
"name": "비읍",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅃ",
|
||||
"roman": "pp",
|
||||
"name": "쌍비읍",
|
||||
"tense": true
|
||||
},
|
||||
{
|
||||
"jamo": "ㅅ",
|
||||
"roman": "s",
|
||||
"name": "시옷",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅆ",
|
||||
"roman": "ss",
|
||||
"name": "쌍시옷",
|
||||
"tense": true
|
||||
},
|
||||
{
|
||||
"jamo": "ㅇ",
|
||||
"roman": "— / ng",
|
||||
"name": "이응",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅈ",
|
||||
"roman": "j",
|
||||
"name": "지읒",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅉ",
|
||||
"roman": "jj",
|
||||
"name": "쌍지읒",
|
||||
"tense": true
|
||||
},
|
||||
{
|
||||
"jamo": "ㅊ",
|
||||
"roman": "ch",
|
||||
"name": "치읓",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅋ",
|
||||
"roman": "k",
|
||||
"name": "키읔",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅌ",
|
||||
"roman": "t",
|
||||
"name": "티읕",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅍ",
|
||||
"roman": "p",
|
||||
"name": "피읖",
|
||||
"tense": false
|
||||
},
|
||||
{
|
||||
"jamo": "ㅎ",
|
||||
"roman": "h",
|
||||
"name": "히읗",
|
||||
"tense": false
|
||||
}
|
||||
],
|
||||
"vowels": [
|
||||
{
|
||||
"jamo": "ㅏ",
|
||||
"roman": "a",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅑ",
|
||||
"roman": "ya",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅓ",
|
||||
"roman": "eo",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅕ",
|
||||
"roman": "yeo",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅗ",
|
||||
"roman": "o",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅛ",
|
||||
"roman": "yo",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅜ",
|
||||
"roman": "u",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅠ",
|
||||
"roman": "yu",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅡ",
|
||||
"roman": "eu",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅣ",
|
||||
"roman": "i",
|
||||
"kind": "basic"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅐ",
|
||||
"roman": "ae",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅒ",
|
||||
"roman": "yae",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅔ",
|
||||
"roman": "e",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅖ",
|
||||
"roman": "ye",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅘ",
|
||||
"roman": "wa",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅙ",
|
||||
"roman": "wae",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅚ",
|
||||
"roman": "oe",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅝ",
|
||||
"roman": "wo",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅞ",
|
||||
"roman": "we",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅟ",
|
||||
"roman": "wi",
|
||||
"kind": "compound"
|
||||
},
|
||||
{
|
||||
"jamo": "ㅢ",
|
||||
"roman": "ui",
|
||||
"kind": "compound"
|
||||
}
|
||||
],
|
||||
"batchim": [
|
||||
{
|
||||
"sound": "ㄱ",
|
||||
"roman": "k",
|
||||
"writtenAs": "ㄱ ㅋ ㄲ ㄳ ㄺ"
|
||||
},
|
||||
{
|
||||
"sound": "ㄴ",
|
||||
"roman": "n",
|
||||
"writtenAs": "ㄴ ㄵ ㄶ"
|
||||
},
|
||||
{
|
||||
"sound": "ㄷ",
|
||||
"roman": "t",
|
||||
"writtenAs": "ㄷ ㅅ ㅆ ㅈ ㅊ ㅌ ㅎ"
|
||||
},
|
||||
{
|
||||
"sound": "ㄹ",
|
||||
"roman": "l",
|
||||
"writtenAs": "ㄹ ㄼ ㄽ ㄾ ㅀ"
|
||||
},
|
||||
{
|
||||
"sound": "ㅁ",
|
||||
"roman": "m",
|
||||
"writtenAs": "ㅁ ㄻ"
|
||||
},
|
||||
{
|
||||
"sound": "ㅂ",
|
||||
"roman": "p",
|
||||
"writtenAs": "ㅂ ㅍ ㅄ ㄿ"
|
||||
},
|
||||
{
|
||||
"sound": "ㅇ",
|
||||
"roman": "ng",
|
||||
"writtenAs": "ㅇ"
|
||||
}
|
||||
],
|
||||
"soundRules": [
|
||||
{
|
||||
"n": "Liaison",
|
||||
"k": "연음",
|
||||
"p": "A batchim followed by a silent ㅇ slides across into the next block. This is why written Korean and spoken Korean look so different.",
|
||||
"a": "한국어",
|
||||
"b": "한구거",
|
||||
"r": "han-gu-geo"
|
||||
},
|
||||
{
|
||||
"n": "Nasalisation",
|
||||
"k": "비음화",
|
||||
"p": "Before ㄴ or ㅁ, a stop batchim turns nasal: ㄱ→ㅇ, ㄷ→ㄴ, ㅂ→ㅁ.",
|
||||
"a": "감사합니다",
|
||||
"b": "감사함니다",
|
||||
"r": "gam-sa-ham-ni-da"
|
||||
},
|
||||
{
|
||||
"n": "Lateralisation",
|
||||
"k": "유음화",
|
||||
"p": "ㄴ next to ㄹ, in either order, becomes ㄹㄹ.",
|
||||
"a": "신라",
|
||||
"b": "실라",
|
||||
"r": "sil-la"
|
||||
},
|
||||
{
|
||||
"n": "Aspiration",
|
||||
"k": "격음화",
|
||||
"p": "ㅎ meeting ㄱ ㄷ ㅈ ㅂ fuses into the aspirated ㅋ ㅌ ㅊ ㅍ.",
|
||||
"a": "좋다",
|
||||
"b": "조타",
|
||||
"r": "jo-ta"
|
||||
},
|
||||
{
|
||||
"n": "Tensification",
|
||||
"k": "경음화",
|
||||
"p": "After a ㄱ ㄷ ㅂ batchim, a following ㄱ ㄷ ㅂ ㅅ ㅈ tenses to ㄲ ㄸ ㅃ ㅆ ㅉ.",
|
||||
"a": "학교",
|
||||
"b": "학꾜",
|
||||
"r": "hak-kkyo"
|
||||
},
|
||||
{
|
||||
"n": "Palatalisation",
|
||||
"k": "구개음화",
|
||||
"p": "A ㄷ or ㅌ batchim before 이 becomes 지 or 치.",
|
||||
"a": "같이",
|
||||
"b": "가치",
|
||||
"r": "ga-chi"
|
||||
},
|
||||
{
|
||||
"n": "ㅎ dropping",
|
||||
"k": "ㅎ 탈락",
|
||||
"p": "ㅎ between vowels, or after ㄴ ㄹ ㅁ ㅇ, is simply not pronounced.",
|
||||
"a": "좋아요",
|
||||
"b": "조아요",
|
||||
"r": "jo-a-yo"
|
||||
}
|
||||
],
|
||||
"soundPairs": [
|
||||
{
|
||||
"written": "한국어",
|
||||
"spoken": "한구거",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "음악",
|
||||
"spoken": "으막",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "읽어",
|
||||
"spoken": "일거",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "앉아",
|
||||
"spoken": "안자",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "꽃이",
|
||||
"spoken": "꼬치",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "옷을",
|
||||
"spoken": "오슬",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "앞에",
|
||||
"spoken": "아페",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "낮에",
|
||||
"spoken": "나제",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "있어",
|
||||
"spoken": "이써",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "없어",
|
||||
"spoken": "업써",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "맛있어",
|
||||
"spoken": "마시써",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "끝났어",
|
||||
"spoken": "끈나써",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "감사합니다",
|
||||
"spoken": "감사함니다",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "입니다",
|
||||
"spoken": "임니다",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "십만",
|
||||
"spoken": "심만",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "국물",
|
||||
"spoken": "궁물",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "작년",
|
||||
"spoken": "장년",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "백만",
|
||||
"spoken": "뱅만",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "몇 명",
|
||||
"spoken": "면 명",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "독립",
|
||||
"spoken": "동닙",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "설날",
|
||||
"spoken": "설랄",
|
||||
"rule": "유음화"
|
||||
},
|
||||
{
|
||||
"written": "신라",
|
||||
"spoken": "실라",
|
||||
"rule": "유음화"
|
||||
},
|
||||
{
|
||||
"written": "좋다",
|
||||
"spoken": "조타",
|
||||
"rule": "격음화"
|
||||
},
|
||||
{
|
||||
"written": "못해",
|
||||
"spoken": "모태",
|
||||
"rule": "격음화"
|
||||
},
|
||||
{
|
||||
"written": "같아요",
|
||||
"spoken": "가타요",
|
||||
"rule": "연음"
|
||||
},
|
||||
{
|
||||
"written": "학교",
|
||||
"spoken": "학꾜",
|
||||
"rule": "경음화"
|
||||
},
|
||||
{
|
||||
"written": "할 수 있어",
|
||||
"spoken": "할 쑤 이써",
|
||||
"rule": "경음화"
|
||||
},
|
||||
{
|
||||
"written": "밥 먹어",
|
||||
"spoken": "밤머거",
|
||||
"rule": "비음화"
|
||||
},
|
||||
{
|
||||
"written": "같이",
|
||||
"spoken": "가치",
|
||||
"rule": "구개음화"
|
||||
},
|
||||
{
|
||||
"written": "좋아요",
|
||||
"spoken": "조아요",
|
||||
"rule": "ㅎ 탈락"
|
||||
},
|
||||
{
|
||||
"written": "싫어",
|
||||
"spoken": "시러",
|
||||
"rule": "ㅎ 탈락"
|
||||
},
|
||||
{
|
||||
"written": "많이",
|
||||
"spoken": "마니",
|
||||
"rule": "ㅎ 탈락"
|
||||
},
|
||||
{
|
||||
"written": "좋은",
|
||||
"spoken": "조은",
|
||||
"rule": "ㅎ 탈락"
|
||||
},
|
||||
{
|
||||
"written": "넣어",
|
||||
"spoken": "너어",
|
||||
"rule": "ㅎ 탈락"
|
||||
}
|
||||
]
|
||||
}
|
||||
149
data/irregulars.json
Normal file
149
data/irregulars.json
Normal file
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"classes": [
|
||||
{
|
||||
"k": "ㅡ",
|
||||
"n": "ㅡ drops",
|
||||
"p": "A stem ending in ㅡ loses it. The 아/어 choice then follows the vowel BEFORE it.",
|
||||
"ex": [
|
||||
[
|
||||
"크다",
|
||||
"커"
|
||||
],
|
||||
[
|
||||
"바쁘다",
|
||||
"바빠"
|
||||
],
|
||||
[
|
||||
"슬프다",
|
||||
"슬퍼"
|
||||
],
|
||||
[
|
||||
"쓰다",
|
||||
"써"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"k": "ㅂ",
|
||||
"n": "ㅂ → 우",
|
||||
"p": "A ㅂ batchim turns into 우, which fuses with the ending: 워. Almost all are adjectives.",
|
||||
"ex": [
|
||||
[
|
||||
"덥다",
|
||||
"더워"
|
||||
],
|
||||
[
|
||||
"어렵다",
|
||||
"어려워"
|
||||
],
|
||||
[
|
||||
"무섭다",
|
||||
"무서워"
|
||||
],
|
||||
[
|
||||
"쉽다",
|
||||
"쉬워"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"k": "ㄷ",
|
||||
"n": "ㄷ → ㄹ",
|
||||
"p": "A ㄷ batchim becomes ㄹ before a vowel. 받다 and 닫다 are regular — they do not change.",
|
||||
"ex": [
|
||||
[
|
||||
"듣다",
|
||||
"들어"
|
||||
],
|
||||
[
|
||||
"걷다",
|
||||
"걸어"
|
||||
],
|
||||
[
|
||||
"묻다",
|
||||
"물어"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"k": "르",
|
||||
"n": "르 doubles ㄹ",
|
||||
"p": "The 르 drops its ㅡ and an extra ㄹ lands on the previous syllable.",
|
||||
"ex": [
|
||||
[
|
||||
"모르다",
|
||||
"몰라"
|
||||
],
|
||||
[
|
||||
"부르다",
|
||||
"불러"
|
||||
],
|
||||
[
|
||||
"다르다",
|
||||
"달라"
|
||||
],
|
||||
[
|
||||
"빠르다",
|
||||
"빨라"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"k": "ㅅ",
|
||||
"n": "ㅅ drops",
|
||||
"p": "A ㅅ batchim disappears before a vowel, and no contraction follows. 웃다 and 씻다 are regular.",
|
||||
"ex": [
|
||||
[
|
||||
"낫다",
|
||||
"나아"
|
||||
],
|
||||
[
|
||||
"짓다",
|
||||
"지어"
|
||||
],
|
||||
[
|
||||
"붓다",
|
||||
"부어"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"k": "ㅎ",
|
||||
"n": "ㅎ drops, vowel shifts",
|
||||
"p": "Colour adjectives and 그렇다-type words: the ㅎ goes and the vowel becomes ㅐ.",
|
||||
"ex": [
|
||||
[
|
||||
"그렇다",
|
||||
"그래"
|
||||
],
|
||||
[
|
||||
"빨갛다",
|
||||
"빨개"
|
||||
],
|
||||
[
|
||||
"어떻다",
|
||||
"어때"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"k": "ㄹ",
|
||||
"n": "ㄹ drops before ㄴ ㅂ ㅅ",
|
||||
"p": "The 아/어 form is regular; the ㄹ only vanishes before certain endings.",
|
||||
"ex": [
|
||||
[
|
||||
"살다",
|
||||
"살아 · 사는"
|
||||
],
|
||||
[
|
||||
"알다",
|
||||
"알아 · 아는"
|
||||
],
|
||||
[
|
||||
"멀다",
|
||||
"멀어 · 먼"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
765
data/sentences.json
Normal file
765
data/sentences.json
Normal file
@@ -0,0 +1,765 @@
|
||||
{
|
||||
"note": "Glossed sentences. parts[] are [chunk, gloss]; the LAST chunk is the 서술어 (predicate).",
|
||||
"levels": {
|
||||
"A": "subject + ending word",
|
||||
"B": "manhwa dialogue"
|
||||
},
|
||||
"sentences": [
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "나 배고파.",
|
||||
"en": "I'm hungry.",
|
||||
"parts": [
|
||||
[
|
||||
"나",
|
||||
"I"
|
||||
],
|
||||
[
|
||||
"배고파",
|
||||
"am hungry"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "너 어디 가?",
|
||||
"en": "Where are you going?",
|
||||
"parts": [
|
||||
[
|
||||
"너",
|
||||
"you"
|
||||
],
|
||||
[
|
||||
"어디",
|
||||
"where"
|
||||
],
|
||||
[
|
||||
"가",
|
||||
"go"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "이거 뭐야?",
|
||||
"en": "What is this?",
|
||||
"parts": [
|
||||
[
|
||||
"이거",
|
||||
"this thing"
|
||||
],
|
||||
[
|
||||
"뭐야",
|
||||
"is what"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "그 사람 누구야?",
|
||||
"en": "Who is that person?",
|
||||
"parts": [
|
||||
[
|
||||
"그",
|
||||
"that"
|
||||
],
|
||||
[
|
||||
"사람",
|
||||
"person"
|
||||
],
|
||||
[
|
||||
"누구야",
|
||||
"is who"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "우리 친구야.",
|
||||
"en": "We're friends.",
|
||||
"parts": [
|
||||
[
|
||||
"우리",
|
||||
"we"
|
||||
],
|
||||
[
|
||||
"친구야",
|
||||
"are friends"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "나 진짜 슬퍼.",
|
||||
"en": "I'm really sad.",
|
||||
"parts": [
|
||||
[
|
||||
"나",
|
||||
"I"
|
||||
],
|
||||
[
|
||||
"진짜",
|
||||
"really"
|
||||
],
|
||||
[
|
||||
"슬퍼",
|
||||
"am sad"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "이 책 좋아.",
|
||||
"en": "This book is good. / I like this book.",
|
||||
"parts": [
|
||||
[
|
||||
"이",
|
||||
"this"
|
||||
],
|
||||
[
|
||||
"책",
|
||||
"book"
|
||||
],
|
||||
[
|
||||
"좋아",
|
||||
"is good"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "저 사람 커.",
|
||||
"en": "That person is big.",
|
||||
"parts": [
|
||||
[
|
||||
"저",
|
||||
"that over there"
|
||||
],
|
||||
[
|
||||
"사람",
|
||||
"person"
|
||||
],
|
||||
[
|
||||
"커",
|
||||
"is big"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "여기 위험해.",
|
||||
"en": "It's dangerous here.",
|
||||
"parts": [
|
||||
[
|
||||
"여기",
|
||||
"here"
|
||||
],
|
||||
[
|
||||
"위험해",
|
||||
"is dangerous"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "너 왜 그래?",
|
||||
"en": "What's wrong with you?",
|
||||
"parts": [
|
||||
[
|
||||
"너",
|
||||
"you"
|
||||
],
|
||||
[
|
||||
"왜",
|
||||
"why"
|
||||
],
|
||||
[
|
||||
"그래",
|
||||
"are like that"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "밥 먹어.",
|
||||
"en": "Eat. / I'm eating.",
|
||||
"parts": [
|
||||
[
|
||||
"밥",
|
||||
"rice, a meal"
|
||||
],
|
||||
[
|
||||
"먹어",
|
||||
"eat"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "물 마셔.",
|
||||
"en": "Drink some water.",
|
||||
"parts": [
|
||||
[
|
||||
"물",
|
||||
"water"
|
||||
],
|
||||
[
|
||||
"마셔",
|
||||
"drink"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "나 갈게.",
|
||||
"en": "I'm off.",
|
||||
"parts": [
|
||||
[
|
||||
"나",
|
||||
"I"
|
||||
],
|
||||
[
|
||||
"갈게",
|
||||
"will go"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "그거 안 돼.",
|
||||
"en": "That won't work.",
|
||||
"parts": [
|
||||
[
|
||||
"그거",
|
||||
"that thing"
|
||||
],
|
||||
[
|
||||
"안",
|
||||
"not"
|
||||
],
|
||||
[
|
||||
"돼",
|
||||
"becomes, works"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "시험 언제야?",
|
||||
"en": "When's the exam?",
|
||||
"parts": [
|
||||
[
|
||||
"시험",
|
||||
"exam"
|
||||
],
|
||||
[
|
||||
"언제야",
|
||||
"is when"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "소문 들었어?",
|
||||
"en": "Did you hear the rumour?",
|
||||
"parts": [
|
||||
[
|
||||
"소문",
|
||||
"rumour"
|
||||
],
|
||||
[
|
||||
"들었어",
|
||||
"heard"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "쟤 우리 반이야.",
|
||||
"en": "That kid is in our class.",
|
||||
"parts": [
|
||||
[
|
||||
"쟤",
|
||||
"that kid"
|
||||
],
|
||||
[
|
||||
"우리",
|
||||
"our"
|
||||
],
|
||||
[
|
||||
"반이야",
|
||||
"is class"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "증거 없어.",
|
||||
"en": "There's no evidence.",
|
||||
"parts": [
|
||||
[
|
||||
"증거",
|
||||
"evidence"
|
||||
],
|
||||
[
|
||||
"없어",
|
||||
"does not exist"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "이제 끝났어.",
|
||||
"en": "It's over now.",
|
||||
"parts": [
|
||||
[
|
||||
"이제",
|
||||
"now"
|
||||
],
|
||||
[
|
||||
"끝났어",
|
||||
"has ended"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "A",
|
||||
"ko": "검 들어.",
|
||||
"en": "Pick up the sword.",
|
||||
"parts": [
|
||||
[
|
||||
"검",
|
||||
"sword"
|
||||
],
|
||||
[
|
||||
"들어",
|
||||
"lift, pick up"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "나 회귀했어.",
|
||||
"en": "I've regressed.",
|
||||
"parts": [
|
||||
[
|
||||
"나",
|
||||
"I"
|
||||
],
|
||||
[
|
||||
"회귀했어",
|
||||
"did regression"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "이번엔 죽지 않아.",
|
||||
"en": "This time I won't die.",
|
||||
"parts": [
|
||||
[
|
||||
"이번엔",
|
||||
"this time"
|
||||
],
|
||||
[
|
||||
"죽지 않아",
|
||||
"do not die"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "던전이 열렸어.",
|
||||
"en": "The dungeon has opened.",
|
||||
"parts": [
|
||||
[
|
||||
"던전이",
|
||||
"the dungeon"
|
||||
],
|
||||
[
|
||||
"열렸어",
|
||||
"has opened"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "저 몬스터 보스야.",
|
||||
"en": "That monster is the boss.",
|
||||
"parts": [
|
||||
[
|
||||
"저",
|
||||
"that over there"
|
||||
],
|
||||
[
|
||||
"몬스터",
|
||||
"monster"
|
||||
],
|
||||
[
|
||||
"보스야",
|
||||
"is the boss"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "스킬 각성했어?",
|
||||
"en": "Did you awaken a skill?",
|
||||
"parts": [
|
||||
[
|
||||
"스킬",
|
||||
"skill"
|
||||
],
|
||||
[
|
||||
"각성했어",
|
||||
"did awaken"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "길드에서 연락 왔어.",
|
||||
"en": "The guild got in touch.",
|
||||
"parts": [
|
||||
[
|
||||
"길드에서",
|
||||
"from the guild"
|
||||
],
|
||||
[
|
||||
"연락",
|
||||
"contact"
|
||||
],
|
||||
[
|
||||
"왔어",
|
||||
"came"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "그 녀석 진짜 강해.",
|
||||
"en": "That guy is seriously strong.",
|
||||
"parts": [
|
||||
[
|
||||
"그",
|
||||
"that"
|
||||
],
|
||||
[
|
||||
"녀석",
|
||||
"guy"
|
||||
],
|
||||
[
|
||||
"진짜",
|
||||
"really"
|
||||
],
|
||||
[
|
||||
"강해",
|
||||
"is strong"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "도망쳐! 빨리!",
|
||||
"en": "Run! Quickly!",
|
||||
"parts": [
|
||||
[
|
||||
"도망쳐",
|
||||
"run away"
|
||||
],
|
||||
[
|
||||
"빨리",
|
||||
"quickly"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "내가 지킬게.",
|
||||
"en": "I'll protect you.",
|
||||
"parts": [
|
||||
[
|
||||
"내가",
|
||||
"I (as the one doing it)"
|
||||
],
|
||||
[
|
||||
"지킬게",
|
||||
"will protect"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "널 믿어.",
|
||||
"en": "I trust you.",
|
||||
"parts": [
|
||||
[
|
||||
"널",
|
||||
"you (object)"
|
||||
],
|
||||
[
|
||||
"믿어",
|
||||
"trust"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "잠깐만, 너 지금 뭐 했어?",
|
||||
"en": "Hold on — what did you just do?",
|
||||
"parts": [
|
||||
[
|
||||
"잠깐만",
|
||||
"hold on"
|
||||
],
|
||||
[
|
||||
"너",
|
||||
"you"
|
||||
],
|
||||
[
|
||||
"지금",
|
||||
"now"
|
||||
],
|
||||
[
|
||||
"뭐",
|
||||
"what"
|
||||
],
|
||||
[
|
||||
"했어",
|
||||
"did"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "말도 안 돼. 네가 어떻게 여기 있어?",
|
||||
"en": "No way. How are you here?",
|
||||
"parts": [
|
||||
[
|
||||
"말도 안 돼",
|
||||
"that's absurd"
|
||||
],
|
||||
[
|
||||
"네가",
|
||||
"you"
|
||||
],
|
||||
[
|
||||
"어떻게",
|
||||
"how"
|
||||
],
|
||||
[
|
||||
"여기",
|
||||
"here"
|
||||
],
|
||||
[
|
||||
"있어",
|
||||
"are, exist"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "나 너 좋아해.",
|
||||
"en": "I like you.",
|
||||
"parts": [
|
||||
[
|
||||
"나",
|
||||
"I"
|
||||
],
|
||||
[
|
||||
"너",
|
||||
"you"
|
||||
],
|
||||
[
|
||||
"좋아해",
|
||||
"like"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "우리 헤어지자.",
|
||||
"en": "Let's break up.",
|
||||
"parts": [
|
||||
[
|
||||
"우리",
|
||||
"we"
|
||||
],
|
||||
[
|
||||
"헤어지자",
|
||||
"let's part"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "오해야. 진짜야.",
|
||||
"en": "It's a misunderstanding. Really.",
|
||||
"parts": [
|
||||
[
|
||||
"오해야",
|
||||
"is a misunderstanding"
|
||||
],
|
||||
[
|
||||
"진짜야",
|
||||
"is true"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "왜 말 안 했어?",
|
||||
"en": "Why didn't you say anything?",
|
||||
"parts": [
|
||||
[
|
||||
"왜",
|
||||
"why"
|
||||
],
|
||||
[
|
||||
"말",
|
||||
"words"
|
||||
],
|
||||
[
|
||||
"안",
|
||||
"not"
|
||||
],
|
||||
[
|
||||
"했어",
|
||||
"did"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "심장이 두근두근해.",
|
||||
"en": "My heart is pounding.",
|
||||
"parts": [
|
||||
[
|
||||
"심장이",
|
||||
"the heart"
|
||||
],
|
||||
[
|
||||
"두근두근해",
|
||||
"goes thump-thump"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "그 사람이랑 계약결혼 했어.",
|
||||
"en": "I did a contract marriage with him.",
|
||||
"parts": [
|
||||
[
|
||||
"그 사람이랑",
|
||||
"with that person"
|
||||
],
|
||||
[
|
||||
"계약결혼",
|
||||
"contract marriage"
|
||||
],
|
||||
[
|
||||
"했어",
|
||||
"did"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "선배가 불렀어.",
|
||||
"en": "The senior was calling for you.",
|
||||
"parts": [
|
||||
[
|
||||
"선배가",
|
||||
"the senior"
|
||||
],
|
||||
[
|
||||
"불렀어",
|
||||
"called"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "범인은 저 사람이야.",
|
||||
"en": "The culprit is that person.",
|
||||
"parts": [
|
||||
[
|
||||
"범인은",
|
||||
"as for the culprit"
|
||||
],
|
||||
[
|
||||
"저 사람이야",
|
||||
"is that person"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "복수할 거야.",
|
||||
"en": "I'm going to take revenge.",
|
||||
"parts": [
|
||||
[
|
||||
"복수할",
|
||||
"to take revenge"
|
||||
],
|
||||
[
|
||||
"거야",
|
||||
"is the plan"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "배신자는 죽어.",
|
||||
"en": "Traitors die.",
|
||||
"parts": [
|
||||
[
|
||||
"배신자는",
|
||||
"as for traitors"
|
||||
],
|
||||
[
|
||||
"죽어",
|
||||
"die"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "형사님, 여기 좀 보세요.",
|
||||
"en": "Detective, take a look over here.",
|
||||
"parts": [
|
||||
[
|
||||
"형사님",
|
||||
"detective (respectful)"
|
||||
],
|
||||
[
|
||||
"여기",
|
||||
"here"
|
||||
],
|
||||
[
|
||||
"좀",
|
||||
"just, a bit"
|
||||
],
|
||||
[
|
||||
"보세요",
|
||||
"please look"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"lvl": "B",
|
||||
"ko": "괜찮아? 다쳤어?",
|
||||
"en": "Are you okay? Are you hurt?",
|
||||
"parts": [
|
||||
[
|
||||
"괜찮아",
|
||||
"are you fine"
|
||||
],
|
||||
[
|
||||
"다쳤어",
|
||||
"got hurt"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
85
data/sfx.json
Normal file
85
data/sfx.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"note": "의성어 · 의태어 — lettered into the artwork, absent from most textbooks.",
|
||||
"items": [
|
||||
{
|
||||
"ko": "쿵",
|
||||
"en": "a heavy thud"
|
||||
},
|
||||
{
|
||||
"ko": "쾅",
|
||||
"en": "a bang, a crash"
|
||||
},
|
||||
{
|
||||
"ko": "헉",
|
||||
"en": "a gasp"
|
||||
},
|
||||
{
|
||||
"ko": "헐",
|
||||
"en": "disbelief"
|
||||
},
|
||||
{
|
||||
"ko": "두근두근",
|
||||
"en": "a pounding heart"
|
||||
},
|
||||
{
|
||||
"ko": "반짝",
|
||||
"en": "a sparkle"
|
||||
},
|
||||
{
|
||||
"ko": "스윽",
|
||||
"en": "a smooth, quiet movement"
|
||||
},
|
||||
{
|
||||
"ko": "털썩",
|
||||
"en": "slumping down"
|
||||
},
|
||||
{
|
||||
"ko": "씨익",
|
||||
"en": "a slow grin spreading"
|
||||
},
|
||||
{
|
||||
"ko": "꿀꺽",
|
||||
"en": "a gulp"
|
||||
},
|
||||
{
|
||||
"ko": "덜덜",
|
||||
"en": "trembling"
|
||||
},
|
||||
{
|
||||
"ko": "빙글",
|
||||
"en": "turning around"
|
||||
},
|
||||
{
|
||||
"ko": "화악",
|
||||
"en": "a sudden rush or flush"
|
||||
},
|
||||
{
|
||||
"ko": "철썩",
|
||||
"en": "a slap"
|
||||
},
|
||||
{
|
||||
"ko": "바스락",
|
||||
"en": "a rustle"
|
||||
},
|
||||
{
|
||||
"ko": "싸늘",
|
||||
"en": "a chill in the air"
|
||||
},
|
||||
{
|
||||
"ko": "울컥",
|
||||
"en": "a surge of emotion"
|
||||
},
|
||||
{
|
||||
"ko": "찌릿",
|
||||
"en": "a jolt, a tingle"
|
||||
},
|
||||
{
|
||||
"ko": "멍",
|
||||
"en": "blanked out, dazed"
|
||||
},
|
||||
{
|
||||
"ko": "끄덕",
|
||||
"en": "a nod"
|
||||
}
|
||||
]
|
||||
}
|
||||
75
eslint.config.js
Normal file
75
eslint.config.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
|
||||
/* The clock guard.
|
||||
|
||||
The artifact's sync bug was a fresh device stamping its own empty defaults
|
||||
as newer than the server's real data. db/writes.ts owns the distinction
|
||||
between a seed write (updated_at stays 0) and a user edit (stamped), so it
|
||||
is the only file under src/db/ allowed to read the clock. Anywhere else,
|
||||
reaching for Date.now() means someone is about to stamp a row without
|
||||
deciding which kind of write it is. */
|
||||
const CLOCK_BAN = [
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']",
|
||||
message:
|
||||
"Only db/writes.ts may read the clock. Use a seedX() helper (leaves updated_at 0) " +
|
||||
"or an editX() helper (stamps it) — see the timestamp rule in db/writes.ts.",
|
||||
},
|
||||
{
|
||||
selector: "NewExpression[callee.name='Date']",
|
||||
message:
|
||||
"Only db/writes.ts may read the clock. Use a seedX() or editX() helper from db/writes.ts.",
|
||||
},
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
// lib/, data/ and validate.mjs are copied in from the export bundle
|
||||
// unchanged and must stay byte-identical — they are not ours to restyle.
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"export/**",
|
||||
"vendor/**",
|
||||
"app/android/**",
|
||||
"lib/**",
|
||||
"data/**",
|
||||
"validate.mjs",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
// Build scripts and the shared modules are plain Node ESM.
|
||||
{
|
||||
files: ["tools/**/*.mjs", "shared/**/*.mjs", "*.config.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
console: "readonly",
|
||||
process: "readonly",
|
||||
URL: "readonly",
|
||||
fetch: "readonly",
|
||||
Buffer: "readonly",
|
||||
},
|
||||
},
|
||||
},
|
||||
/* The rules of hooks, and exhaustive deps in particular. A stale closure
|
||||
in an effect is what let the tutor seed its opening turn three times. */
|
||||
{
|
||||
files: ["app/src/**/*.{ts,tsx}"],
|
||||
plugins: { "react-hooks": reactHooks },
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["app/src/db/**/*.ts"],
|
||||
rules: { "no-restricted-syntax": ["error", ...CLOCK_BAN] },
|
||||
},
|
||||
{
|
||||
files: ["app/src/db/writes.ts"],
|
||||
rules: { "no-restricted-syntax": "off" },
|
||||
},
|
||||
);
|
||||
86
lib/blocks.js
Normal file
86
lib/blocks.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/* The block protocol — how 선생님 drives the UI.
|
||||
The tutor writes prose plus fenced blocks; the client renders them
|
||||
as real interface and sends structured answers back. */
|
||||
|
||||
const RE = {
|
||||
task: /::task\s+(translate|match|build|choice)\s*\n([\s\S]*?)(?:\n::|$)/,
|
||||
words: /::words\s*\n([\s\S]*?)(?:\n::|$)/,
|
||||
gloss: /::gloss\s*\n([\s\S]*?)(?:\n::|$)/,
|
||||
progress: /::progress\s+(\d{1,3})\s*(?:\|\s*([^\n]*))?/,
|
||||
};
|
||||
const rows = s => s.split("\n").map(l => l.trim()).filter(l => l && !/^::/.test(l));
|
||||
const cols = l => l.split("|").map(x => x.trim());
|
||||
|
||||
export function parse(text) {
|
||||
const w = text.match(RE.words), t = text.match(RE.task);
|
||||
const g = text.match(RE.gloss), p = text.match(RE.progress);
|
||||
|
||||
const words = w ? rows(w[1]).map(l => { const c = cols(l);
|
||||
return { ko: c[0], gloss: c[1] || "", note: c[2] || "" }; }) : null;
|
||||
|
||||
let task = null;
|
||||
if (t) {
|
||||
const r = rows(t[2]);
|
||||
if (t[1] === "translate") task = { type: "translate", items: r.map(q => ({ q })) };
|
||||
if (t[1] === "match") task = { type: "match", pairs: r.map(l => { const c = cols(l);
|
||||
return { ko: c[0], gloss: c[1] }; }).filter(x => x.ko && x.gloss) };
|
||||
if (t[1] === "build") task = { type: "build", items: r.map(l => { const c = cols(l);
|
||||
return { en: c[0], chips: c.slice(1).filter(Boolean) }; }).filter(x => x.en && x.chips.length) };
|
||||
if (t[1] === "choice") task = { type: "choice", items: r.map(l => { const c = cols(l);
|
||||
return { q: c[0], options: c.slice(1).filter(Boolean) }; }).filter(x => x.q && x.options.length > 1) };
|
||||
}
|
||||
|
||||
let gloss = null;
|
||||
if (g) {
|
||||
const blocks = []; let cur = null;
|
||||
g[1].split("\n").forEach(line => {
|
||||
const l = line.trim();
|
||||
if (!l || /^::/.test(l)) return;
|
||||
if (l.startsWith("=")) { if (cur) cur.en = l.slice(1).trim(); return; }
|
||||
const c = cols(l);
|
||||
if (!cur) { cur = { parts: [], en: "" }; blocks.push(cur); }
|
||||
cur.parts.push({ ko: c[0], role: (c[1] || "N").toUpperCase()[0], gloss: c[2] || "", highlight: c[3] || "" });
|
||||
});
|
||||
const keep = blocks.filter(b => b.parts.length);
|
||||
if (keep.length) gloss = keep;
|
||||
}
|
||||
|
||||
let body = text;
|
||||
if (p) body = body.replace(p[0], "");
|
||||
if (g) body = body.replace(g[0], "");
|
||||
if (w) body = body.slice(0, body.indexOf("::words") >= 0 ? body.indexOf("::words") : body.length);
|
||||
if (t) body = body.replace(t[0], "");
|
||||
|
||||
return {
|
||||
body: body.replace(/\n{3,}/g, "\n\n").trim(),
|
||||
words, task, gloss,
|
||||
progress: p ? { score: Math.max(0, Math.min(100, +p[1])), note: (p[2] || "").trim() } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Roles a gloss part can carry, and what the UI should do with each. */
|
||||
export const ROLES = {
|
||||
S: "주어 subject", T: "주제 topic", O: "목적어 object",
|
||||
V: "서술어 predicate", P: "자리·때 place/time", C: "이음 connective",
|
||||
Q: "인용 quotation", M: "수식 modifier", N: "",
|
||||
};
|
||||
|
||||
/** Turn a completed task back into the message the student sends. */
|
||||
export function answerText(task, state, lookups = []) {
|
||||
let body;
|
||||
if (task.type === "translate")
|
||||
body = "My answers:\n" + task.items.map((it, i) =>
|
||||
`${it.q} → ${(state[i] || "").trim() || "(not sure)"}`).join("\n");
|
||||
else if (task.type === "match")
|
||||
body = "My pairings:\n" + (state.pairs.map(p => `${p.ko} = ${p.gloss}`).join("\n") || "(none)");
|
||||
else if (task.type === "build")
|
||||
body = "My sentences:\n" + task.items.map((it, i) =>
|
||||
`${it.en} → ${(state[i] || []).join(" ") || "(not sure)"}`).join("\n");
|
||||
else
|
||||
body = "My choices:\n" + task.items.map((it, i) =>
|
||||
`${it.q} → ${state[i] == null ? "(not sure)" : it.options[state[i]]}`).join("\n");
|
||||
|
||||
return body + (lookups.length
|
||||
? `\n\n(I had to look up: ${lookups.join(", ")})`
|
||||
: "\n\n(No lookups.)");
|
||||
}
|
||||
99
lib/conjugation.js
Normal file
99
lib/conjugation.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/* Korean conjugation — the 아/어 rule and the seven irregular classes.
|
||||
Used three ways: to mark the conjugation trainer, to generate the
|
||||
surface-form index at build time, and to explain WHICH rule was missed. */
|
||||
import { decompose, compose } from "./hangul.js";
|
||||
|
||||
/** Forms that do not fall out of the rules and are simply known. */
|
||||
export const IRREGULAR_FORMS = {
|
||||
"덥다":"더워","춥다":"추워","쉽다":"쉬워","어렵다":"어려워","무섭다":"무서워",
|
||||
"맵다":"매워","가깝다":"가까워",
|
||||
"듣다":"들어","걷다":"걸어","묻다":"물어",
|
||||
"모르다":"몰라","부르다":"불러","다르다":"달라","빠르다":"빨라","고르다":"골라",
|
||||
"낫다":"나아","짓다":"지어","붓다":"부어",
|
||||
"하다":"해","되다":"돼","이다":"야","그렇다":"그래","어떻다":"어때",
|
||||
};
|
||||
|
||||
/** Dictionary form → 반말 present (해체). Returns null for non-verbs. */
|
||||
export function haeche(dict) {
|
||||
if (IRREGULAR_FORMS[dict]) return IRREGULAR_FORMS[dict];
|
||||
if (!dict || dict.slice(-1) !== "다") return null;
|
||||
const stem = dict.slice(0, -1);
|
||||
if (!stem) return null;
|
||||
if (stem.slice(-1) === "하") return stem.slice(0, -1) + "해";
|
||||
|
||||
const d = decompose(stem[stem.length - 1]);
|
||||
if (!d) return null;
|
||||
const [i, m, f] = d;
|
||||
const bright = (m === 0 || m === 8); // ㅏ or ㅗ → 아, else 어
|
||||
|
||||
if (m === 18 && f === 0) { // ㅡ drops: 크다 → 커, 바쁘다 → 바빠
|
||||
let h = 4;
|
||||
if (stem.length >= 2) {
|
||||
const prev = decompose(stem[stem.length - 2]);
|
||||
if (prev && (prev[1] === 0 || prev[1] === 8)) h = 0;
|
||||
}
|
||||
return stem.slice(0, -1) + compose(i, h, 0);
|
||||
}
|
||||
if (f === 0) { // vowel-final stem contracts
|
||||
if ([0, 4, 1, 5, 6, 2].includes(m)) return stem; // 가 · 서 · 보내 · 세 · 켜
|
||||
if (m === 8) return stem.slice(0, -1) + compose(i, 9, 0); // ㅗ+아 → ㅘ 오다 → 와
|
||||
if (m === 13) return stem.slice(0, -1) + compose(i, 14, 0); // ㅜ+어 → ㅝ 주다 → 줘
|
||||
if (m === 20) return stem.slice(0, -1) + compose(i, 6, 0); // ㅣ+어 → ㅕ 마시다 → 마셔
|
||||
if (m === 11) return stem.slice(0, -1) + compose(i, 10, 0); // ㅚ+어 → ㅙ 되다 → 돼
|
||||
return stem + (bright ? "아" : "어");
|
||||
}
|
||||
return stem + (bright ? "아" : "어");
|
||||
}
|
||||
|
||||
/** 반말 present → 반말 past. 먹어 → 먹었어, 가 → 갔어, 해 → 했어. */
|
||||
export function past(present) {
|
||||
if (!present) return null;
|
||||
const d = decompose(present[present.length - 1]);
|
||||
if (!d) return null;
|
||||
if (d[2] !== 0) return present + "었어";
|
||||
return present.slice(0, -1) + compose(d[0], d[1], 20) + "어";
|
||||
}
|
||||
|
||||
export const polite = present => present ? present + "요" : null;
|
||||
|
||||
/** Which class a dictionary form belongs to — drives the "why" in feedback. */
|
||||
export function irregularClass(dict) {
|
||||
if (IRREGULAR_FORMS[dict]) {
|
||||
if (/르다$/.test(dict)) return "르";
|
||||
if (/^(듣다|걷다|묻다)$/.test(dict)) return "ㄷ";
|
||||
if (/(렇다|얗다|갛다|떻다)$/.test(dict)) return "ㅎ";
|
||||
if (/^(낫다|짓다|붓다)$/.test(dict)) return "ㅅ";
|
||||
if (/^(하다|되다|이다)$/.test(dict)) return "special";
|
||||
return "ㅂ";
|
||||
}
|
||||
const stem = dict.slice(0, -1);
|
||||
const d = decompose(stem[stem.length - 1]);
|
||||
if (!d) return "regular";
|
||||
if (d[1] === 18 && d[2] === 0) return "ㅡ";
|
||||
return "regular";
|
||||
}
|
||||
|
||||
/** Human explanation of the rule applied — shown when an answer is wrong. */
|
||||
export function explain(dict) {
|
||||
const cls = irregularClass(dict);
|
||||
if (cls !== "regular") return `${cls} 불규칙`;
|
||||
const stem = dict.slice(0, -1);
|
||||
if (stem.slice(-1) === "하") return "하다 → 해";
|
||||
const d = decompose(stem[stem.length - 1]);
|
||||
const bright = d && (d[1] === 0 || d[1] === 8);
|
||||
return `stem ${stem} · last vowel ${bright ? "ㅏ/ㅗ → 아" : "neither → 어"}`;
|
||||
}
|
||||
|
||||
/** Build-time: every surface form a learner will meet, mapped back to its lemma.
|
||||
Feed this the dictionary; it replaces a runtime morphological analyser. */
|
||||
export function surfaceForms(dict, gloss) {
|
||||
const out = [];
|
||||
const p = haeche(dict);
|
||||
if (!p) return out;
|
||||
const g = gloss.replace(/^to be /, "").replace(/^to /, "");
|
||||
out.push({ form: p, gloss: g, note: `반말, from ${dict}` });
|
||||
out.push({ form: polite(p), gloss: g, note: `polite, from ${dict}` });
|
||||
const q = past(p);
|
||||
if (q) out.push({ form: q, gloss: `${g} (past)`, note: `반말 past, from ${dict}` });
|
||||
return out;
|
||||
}
|
||||
72
lib/gate.js
Normal file
72
lib/gate.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/* The gate — what the tutor is allowed to know, say and use, right now.
|
||||
Generated from the curriculum plus progress. This is the mechanism that
|
||||
stops material being taught out of order; port it before improving it. */
|
||||
|
||||
export function flatten(curriculum) {
|
||||
const units = [];
|
||||
curriculum.phases.forEach(p => p.units.forEach(u => units.push({ ...u, phase: p.phase, phaseKo: p.ko, phaseName: p.name })));
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param curriculum curriculum.json
|
||||
* @param progress { current: "1.4", done: {"1.1":true,...}, confidence: {"1.4":62} }
|
||||
* @param opts { vocabQuery } — with a dictionary, replaces the hand-listed words
|
||||
*/
|
||||
export function buildGate(curriculum, progress, opts = {}) {
|
||||
const units = flatten(curriculum);
|
||||
const at = id => units.findIndex(u => u.id === id);
|
||||
const i = Math.max(0, at(progress.current));
|
||||
const unit = units[i];
|
||||
const done = units.filter(u => progress.done[u.id]);
|
||||
|
||||
const taught = done.flatMap(u => u.teaches);
|
||||
|
||||
// everything a later unit teaches is, by construction, forbidden now
|
||||
const future = units.filter((u, k) => k > i || (!progress.done[u.id] && k !== i));
|
||||
const near = [...new Set(future.flatMap(u => u.teaches))].slice(0, 18);
|
||||
const tail = future.length ? future[future.length - 1] : null;
|
||||
|
||||
const vocabulary = opts.vocabQuery
|
||||
? opts.vocabQuery(unit, done) // e.g. freq_rank BETWEEN …
|
||||
: [...new Set(done.flatMap(u => u.words))];
|
||||
|
||||
// spiral targets: words first met in an earlier unit that this unit should
|
||||
// deliberately bring back. Only those whose home unit is actually finished.
|
||||
const doneIds = new Set(done.map(u => u.id));
|
||||
const revisits = (unit.revisits || []).filter(r => doneIds.has(r.from)).map(r => r.word);
|
||||
|
||||
return {
|
||||
unit, phase: { n: unit.phase, ko: unit.phaseKo, name: unit.phaseName },
|
||||
taught, forbidden: { near, tailUnit: tail, count: future.length },
|
||||
vocabulary, newWords: unit.words, revisits,
|
||||
confidence: progress.confidence?.[unit.id] ?? null,
|
||||
next: units[i + 1] || null,
|
||||
finished: done.map(u => u.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the gate into the system prompt section. Keep the headings — the
|
||||
model keys off them, and the pre-flight check refers to them by name. */
|
||||
export function renderGate(g) {
|
||||
const L = [];
|
||||
L.push(`He is on PHASE ${g.phase.n} · ${g.phase.ko} (${g.phase.name}), UNIT ${g.unit.id} · ${g.unit.ko} (${g.unit.name})${g.unit.vocabUnit ? " — a VOCABULARY unit" : ""}.`);
|
||||
L.push(`THIS UNIT'S GOAL: ${g.unit.goal}`, "");
|
||||
L.push("════ WHAT HE KNOWS — the complete list ════");
|
||||
L.push(g.taught.length ? g.taught.map(t => "• " + t).join("\n") : "• nothing yet — this is the very first unit", "");
|
||||
L.push("════ THIS UNIT ADDS ════", g.unit.teaches.map(t => "• " + t).join("\n"));
|
||||
if (g.unit.avoid?.length)
|
||||
L.push("\nAND EXPLICITLY EXCLUDES, even if it seems natural:\n" + g.unit.avoid.map(t => "✗ " + t).join("\n"));
|
||||
L.push("", "════ NOT TAUGHT YET — MUST NOT APPEAR ════");
|
||||
L.push("Every one of these belongs to a later unit. Using any of them, even in passing, even to be helpful, breaks the sequence:");
|
||||
L.push(g.forbidden.near.length ? g.forbidden.near.map(t => "✗ " + t).join("\n") : "— nothing; this is the last unit");
|
||||
if (g.forbidden.tailUnit)
|
||||
L.push(`…and everything else on the roadmap through ${g.forbidden.tailUnit.id} ${g.forbidden.tailUnit.ko}. If a thing is not on the KNOWS list above, it is not taught. That is the whole test — you do not need to recognise it on this list to exclude it.`);
|
||||
L.push("", "════ VOCABULARY YOU MAY USE ════", g.vocabulary.join(" · ") || "(none yet)");
|
||||
L.push("NEW WORDS THIS UNIT MAY INTRODUCE — and no others:",
|
||||
g.newWords.length ? g.newWords.join(" · ")
|
||||
: "(none — this unit adds no new vocabulary on purpose. It is a contrast/synthesis unit: work it entirely with words he already has.)");
|
||||
if (g.revisits.length)
|
||||
L.push("BRING BACK ON PURPOSE — he met these earlier and they are due for reuse here:", g.revisits.join(" · "));
|
||||
return L.join("\n");
|
||||
}
|
||||
125
lib/hangul.js
Normal file
125
lib/hangul.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/* 한글 — decomposition, composition, and a 두벌식 input method.
|
||||
No dependencies. Lifted from the artifact; the IME is unit-tested
|
||||
against 먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace. */
|
||||
|
||||
export const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
|
||||
export const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ";
|
||||
export const JONG = " ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ";
|
||||
|
||||
const VJOIN = {"ㅗㅏ":"ㅘ","ㅗㅐ":"ㅙ","ㅗㅣ":"ㅚ","ㅜㅓ":"ㅝ","ㅜㅔ":"ㅞ","ㅜㅣ":"ㅟ","ㅡㅣ":"ㅢ"};
|
||||
const FJOIN = {"ㄱㅅ":"ㄳ","ㄴㅈ":"ㄵ","ㄴㅎ":"ㄶ","ㄹㄱ":"ㄺ","ㄹㅁ":"ㄻ","ㄹㅂ":"ㄼ",
|
||||
"ㄹㅅ":"ㄽ","ㄹㅌ":"ㄾ","ㄹㅍ":"ㄿ","ㄹㅎ":"ㅀ","ㅂㅅ":"ㅄ"};
|
||||
const FSPLIT = Object.fromEntries(Object.entries(FJOIN).map(([k,v]) => [v, [k[0], k[1]]]));
|
||||
|
||||
/** [initialIndex, medialIndex, finalIndex] or null if not a syllable block. */
|
||||
export function decompose(ch) {
|
||||
const c = ch.charCodeAt(0) - 0xAC00;
|
||||
if (c < 0 || c > 11171) return null;
|
||||
return [Math.floor(c / 588), Math.floor((c % 588) / 28), c % 28];
|
||||
}
|
||||
export function compose(i, m, f = 0) {
|
||||
return String.fromCharCode(0xAC00 + (i * 21 + m) * 28 + f);
|
||||
}
|
||||
export const isJamo = {
|
||||
initial: c => CHO.includes(c),
|
||||
medial: c => JUNG.includes(c),
|
||||
final: c => c && JONG.indexOf(c) > 0,
|
||||
};
|
||||
|
||||
/* ── 두벌식 IME ───────────────────────────────────────────
|
||||
Hold one composing buffer per input. Feed jamo with key(),
|
||||
punctuation and spaces with text(), and Backspace with back().
|
||||
Each call returns the full new value. */
|
||||
export class Composer {
|
||||
constructor() { this.reset(); }
|
||||
reset() { this.cho = null; this.jung = null; this.jong = null; }
|
||||
get empty() { return !this.cho && !this.jung && !this.jong; }
|
||||
|
||||
/** The syllable currently being assembled, as text. */
|
||||
render() {
|
||||
if (this.cho && this.jung) {
|
||||
const i = CHO.indexOf(this.cho), m = JUNG.indexOf(this.jung);
|
||||
const f = this.jong ? JONG.indexOf(this.jong) : 0;
|
||||
if (i >= 0 && m >= 0 && f >= 0) return compose(i, m, f);
|
||||
}
|
||||
return (this.cho || "") + (this.jung || "") + (this.jong || "");
|
||||
}
|
||||
/** Strip the composing tail off a value so it can be rebuilt. */
|
||||
_base(value) {
|
||||
const cur = this.render();
|
||||
return cur && value.endsWith(cur) ? value.slice(0, -cur.length) : value;
|
||||
}
|
||||
|
||||
key(value, j) {
|
||||
let base = this._base(value);
|
||||
if (isJamo.medial(j)) {
|
||||
if (this.jong) { // final splits off to start a new block
|
||||
const parts = FSPLIT[this.jong];
|
||||
let moved;
|
||||
if (parts) { this.jong = parts[0]; moved = parts[1]; }
|
||||
else { moved = this.jong; this.jong = null; }
|
||||
base += this.render();
|
||||
this.cho = moved; this.jung = j; this.jong = null;
|
||||
} else if (this.jung) {
|
||||
const join = VJOIN[this.jung + j];
|
||||
if (join) this.jung = join;
|
||||
else { base += this.render(); this.reset(); this.jung = j; }
|
||||
} else this.jung = j;
|
||||
} else {
|
||||
if (this.cho && this.jung) {
|
||||
if (this.jong) {
|
||||
const join = FJOIN[this.jong + j];
|
||||
if (join) this.jong = join;
|
||||
else { base += this.render(); this.reset(); this.cho = j; }
|
||||
} else if (isJamo.final(j)) this.jong = j;
|
||||
else { base += this.render(); this.reset(); this.cho = j; }
|
||||
} else {
|
||||
if (!this.empty) base += this.render();
|
||||
this.reset(); this.cho = j;
|
||||
}
|
||||
}
|
||||
return base + this.render();
|
||||
}
|
||||
|
||||
back(value) {
|
||||
let base = this._base(value);
|
||||
if (this.jong) {
|
||||
const parts = FSPLIT[this.jong];
|
||||
this.jong = parts ? parts[0] : null;
|
||||
} else if (this.jung) {
|
||||
let peeled = null;
|
||||
for (const [k, v] of Object.entries(VJOIN)) if (v === this.jung) peeled = k[0];
|
||||
this.jung = peeled;
|
||||
} else if (this.cho) {
|
||||
this.cho = null;
|
||||
} else { // pull a finished block back in
|
||||
const last = base.slice(-1);
|
||||
base = base.slice(0, -1);
|
||||
const d = last ? decompose(last) : null;
|
||||
if (d) {
|
||||
this.cho = CHO[d[0]]; this.jung = JUNG[d[1]];
|
||||
this.jong = d[2] ? JONG[d[2]] : null;
|
||||
if (this.jong) { const p = FSPLIT[this.jong]; this.jong = p ? p[0] : null; }
|
||||
else this.jung = null;
|
||||
}
|
||||
}
|
||||
return base + this.render();
|
||||
}
|
||||
|
||||
/** Commit the buffer and append literal text (space, punctuation). */
|
||||
text(value, t) {
|
||||
const base = this._base(value) + this.render();
|
||||
this.reset();
|
||||
return base + t;
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard 두벌식 layout, top row first. Shift gives the tense pairs. */
|
||||
export const KEYBOARD = {
|
||||
rows: [
|
||||
["ㅂ","ㅈ","ㄷ","ㄱ","ㅅ","ㅛ","ㅕ","ㅑ","ㅐ","ㅔ"],
|
||||
["ㅁ","ㄴ","ㅇ","ㄹ","ㅎ","ㅗ","ㅓ","ㅏ","ㅣ"],
|
||||
["ㅋ","ㅌ","ㅊ","ㅍ","ㅠ","ㅜ","ㅡ"],
|
||||
],
|
||||
shift: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"},
|
||||
};
|
||||
46
lib/srs.js
Normal file
46
lib/srs.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/* SM-2 lite. Four grades, day-granularity intervals. */
|
||||
export const AGAIN = 0, HARD = 1, GOOD = 2, EASY = 3;
|
||||
export const NEW = 0, LEARNING = 1, REVIEW = 2;
|
||||
export const SECURE_INTERVAL = 21; // days at which a card counts as known
|
||||
|
||||
export const newCard = () => ({ state: NEW, interval: 0, ease: 2.5, due: 0, reps: 0, lapses: 0 });
|
||||
|
||||
export function grade(card, g, today) {
|
||||
const c = { ...card };
|
||||
if (c.state === NEW || c.state === LEARNING) {
|
||||
if (g <= HARD) { c.state = LEARNING; c.interval = 0; c.due = today; }
|
||||
else if (g === GOOD){ c.state = REVIEW; c.interval = 1; c.due = today + 1; }
|
||||
else { c.state = REVIEW; c.interval = 4; c.due = today + 4; }
|
||||
} else {
|
||||
if (g === AGAIN) { c.ease = Math.max(1.3, c.ease - 0.2); c.lapses++; c.state = LEARNING; c.interval = 0; c.due = today; }
|
||||
else if (g === HARD) { c.ease = Math.max(1.3, c.ease - 0.15); c.interval = Math.max(1, Math.round(c.interval * 1.2)); c.due = today + c.interval; }
|
||||
else if (g === GOOD) { c.interval = Math.max(1, Math.round(c.interval * c.ease)); c.due = today + c.interval; }
|
||||
else { c.ease = Math.min(3, c.ease + 0.15); c.interval = Math.max(2, Math.round(c.interval * c.ease * 1.3)); c.due = today + c.interval; }
|
||||
c.interval = Math.min(c.interval, 365);
|
||||
c.due = Math.min(c.due, today + 365);
|
||||
}
|
||||
c.reps++;
|
||||
return c;
|
||||
}
|
||||
|
||||
export const markKnown = today => ({ state: REVIEW, interval: SECURE_INTERVAL, ease: 2.5, due: today + SECURE_INTERVAL, reps: 0, lapses: 0 });
|
||||
|
||||
export function statusOf(card) {
|
||||
if (!card || card.state === NEW) return "new";
|
||||
if (card.state === LEARNING) return "learning";
|
||||
return card.interval >= SECURE_INTERVAL ? "secure" : "review";
|
||||
}
|
||||
|
||||
/** Label for the interval a grade would produce — shown on the buttons. */
|
||||
export function preview(card, g, today) {
|
||||
const c = grade(card || newCard(), g, today);
|
||||
if (c.interval === 0) return "again now";
|
||||
if (c.interval === 1) return "1 day";
|
||||
if (c.interval < 30) return `${c.interval} days`;
|
||||
const mo = Math.round(c.interval / 30);
|
||||
return `${mo} month${mo === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** Local day number, DST-safe. */
|
||||
export const dayNumber = (d = new Date()) =>
|
||||
Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 864e5);
|
||||
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "hankan",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Hankan — a Korean reading tutor for manhwa. Offline-first, web + Android from one codebase.",
|
||||
"workspaces": [
|
||||
"app"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"validate": "node validate.mjs",
|
||||
"dict:fetch": "node tools/dict/fetch.mjs",
|
||||
"dict:build": "node tools/dict/build.mjs",
|
||||
"dict:assert": "node tools/dict/assert-roadmap.mjs",
|
||||
"typecheck": "tsc -b --pretty",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"dev": "npm run dev -w app",
|
||||
"build": "npm run build -w app",
|
||||
"preview": "npm run preview -w app",
|
||||
"cap:sync": "npm run cap:sync -w app",
|
||||
"check": "npm run validate && npm run typecheck && npm run test && npm run dict:assert"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
100
prompt/tutor-system.md
Normal file
100
prompt/tutor-system.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 선생님 — system prompt
|
||||
|
||||
Assembled per turn. `{{GATE}}` is `renderGate()` from `lib/gate.js`; `{{VARIETY}}`
|
||||
and `{{FOCUS}}` are one-liners built from recent state. Everything else is fixed.
|
||||
|
||||
The conversation is sent as turns; there is no separate system role in the Agent
|
||||
SDK path, so this whole document goes in as the leading instruction.
|
||||
|
||||
---
|
||||
|
||||
You are 선생님, a Korean reading tutor built into the student's own study app. You teach ONE student. Everything below is his real profile.
|
||||
|
||||
HIS GOAL: read Korean manhwa. Reading and decoding meaning ONLY. Never drill pronunciation production, handwriting or conversation.
|
||||
|
||||
WHAT HE BRINGS
|
||||
- Romanization is fully retired. NEVER write romanization or IPA — not once, not as a hint. 한글 and English only. For a spoken form use 한글 in brackets: 학교 [학꾜].
|
||||
- Register: manhwa is written in 반말 — teach 먹어, 가, 좋아, 안 돼, not 해요/합니다, until the roadmap says otherwise.
|
||||
|
||||
{{GATE}}
|
||||
|
||||
════ PRE-FLIGHT CHECK — DO THIS BEFORE WRITING EVERY EXERCISE ════
|
||||
Go through your exercise word by word and ask of each: does this trace to the KNOWS list, to this unit's own additions, or to this unit's new-word list? If not, remove it. Check the same way for phenomena — a final consonant sliding into the next block, a nasalised ending, a double batchim, an irregular verb, a particle, a tense marker. Do not include something because he has probably seen it; include it only if it is listed.
|
||||
|
||||
If you notice mid-lesson that you have already used something ungated: say so plainly in one clause, drop it, and carry on. Never build a justification for why it was acceptable.
|
||||
|
||||
════ SCOPE DISCIPLINE ════
|
||||
This unit's goal is narrow on purpose. Every exercise tests THAT and nothing wider. When he is answering correctly, do not widen the scope to keep it interesting — go faster, or go deeper inside the same rule, or use the same rule on less familiar words. Edge cases, exceptions and "what about…" variants belong to whichever unit owns them. Silently expanding scope is the failure mode to avoid.
|
||||
|
||||
════ STARTING A NEW UNIT — TEACH IT PROPERLY FIRST ════
|
||||
The first message of a unit is a LESSON, not a warm-up, and it is the one place you are allowed to be long (250–450 words). Do not gloss over the rule: state it explicitly, show WHY it exists and what it contrasts with, walk through two or three colour-glossed examples, and name the mistake a learner reliably makes. Only then give a first, easy exercise. If a unit has several moving parts, say up front what they are. He has told you directly that earlier units skated over things he needed — err long. On later turns in the same unit, go back to short messages.
|
||||
|
||||
════ HOW TO TEACH ════
|
||||
- Short messages once the unit is underway. One exercise at a time, 4–8 items. Give it, then STOP — never answer your own exercise.
|
||||
- When he answers, mark each item: start the line with ✓ or ✗, then the Korean, then his reading. Lead with what he got right.
|
||||
- For a wrong one, name the exact word he mistook and why, in a sentence or two. No lectures.
|
||||
- If he answers only some items, mark those and list the ones still open.
|
||||
- Introduce 2–3 new words per exercise, taken ONLY from this unit's new-word list, and reuse them later so they stick. Never number items 1. 2. 3.
|
||||
- Every unit carries a small set of new words, so vocabulary grows the whole way through — you do not need to wait for a vocabulary unit. On a unit marked VOCABULARY, the words ARE the lesson: go wider, more per round, lean on matching.
|
||||
- SPIRAL. Every exercise must quietly reuse two or three things from EARLIER units, buried inside otherwise-new material. Nothing taught early is allowed to go idle. Do not announce that you are doing this.
|
||||
- He leans on the word list too much and cannot yet read an unfamiliar sentence unaided. His answers tell you which words he looked up. Words he keeps looking up are the ones to build the next exercise from; words he never looks up can be used freely and should be.
|
||||
|
||||
════ EXERCISES — THE DEFAULT, NOT THE EXCEPTION ════
|
||||
The app renders four kinds of exercise as real interactive UI. EVERY teaching message must end with exactly one task block. Only skip it when he asked a direct question that wants a plain answer. Never describe the mechanics and never ask him to type answers into the chat box when a task block would do.
|
||||
|
||||
{{VARIETY}}
|
||||
|
||||
Typing — he types an English translation per line:
|
||||
::task translate
|
||||
우리 밥 먹어
|
||||
학교 작아
|
||||
::
|
||||
|
||||
Matching — he pairs Korean with meanings, 6–8 pairs:
|
||||
::task match
|
||||
친구 | friend
|
||||
물 | water
|
||||
::
|
||||
|
||||
Building — he assembles Korean from shuffled chips. First field is the English, the rest are the chips IN CORRECT ORDER (the app shuffles them):
|
||||
::task build
|
||||
We eat rice. | 우리 | 밥 | 먹어
|
||||
::
|
||||
|
||||
Choice — one pick per line. Best for a contrast: which particle, which ending, which of two readings. Use ___ for a blank:
|
||||
::task choice
|
||||
나 학교 ___ 가 | 에 | 에서 | 을
|
||||
::
|
||||
|
||||
His answers come back as one message. Mark them the normal way.
|
||||
|
||||
════ COLOUR-CODED SENTENCE BREAKDOWN ════
|
||||
Whenever you show a full sentence he has not seen worked through — always in a unit intro, always when correcting a sentence he misread, and whenever a new pattern first appears — add a gloss block. Roles: S subject, T topic, O object, V predicate, P place or time, C connective, Q quotation, M modifier. The fourth field is optional and highlights the meaningful piece INSIDE the word — the tense marker, the particle, the ending.
|
||||
|
||||
::gloss
|
||||
저는 | T | I | 는
|
||||
학교에 | P | to school | 에
|
||||
갔어 | V | went | 었
|
||||
= I went to school.
|
||||
::
|
||||
|
||||
Several sentences in one block: start a new one after its `=` line. Use it for teaching, NOT for every repetition — once a pattern is familiar, drop the gloss and let him read it plain. That fading is deliberate.
|
||||
|
||||
════ VOCABULARY REFERENCE — REQUIRED ════
|
||||
Any message containing Korean MUST end with a reference block:
|
||||
::words
|
||||
한글 | English meaning | short note or dictionary form (may be empty)
|
||||
::
|
||||
List EVERY Korean form that appears in your message, exactly as written — every word of every exercise item, every word quoted in your prose, conjugated forms as they appear (먹어, not just 먹다), and words he already knows. One per line, in order of appearance. The app renders this as a panel beside the chat, so do NOT repeat the glosses in your prose.
|
||||
|
||||
════ REPORTING PROGRESS — REQUIRED whenever he answered an exercise ════
|
||||
::progress 0-100 | one short clause on what is or isn't landing
|
||||
|
||||
Your honest read of how well he has THIS UNIT. Move it gradually — a good round is a few points, not thirty. Under 60: keep drilling the basics. 60–84: he mostly has it. 85+: ready to move on, and the app offers him the next unit — do not offer that in your prose.
|
||||
|
||||
════ FORMAT ════
|
||||
Plain text. **bold** is the only markup. No headings, tables, code fences or bullet characters.
|
||||
BLOCK ORDER: gloss blocks inline where you refer to them; then at the END, the task block, then the words block, then the progress line. Nothing after them.
|
||||
He types Korean with an on-screen 한글 keyboard built into the app, so asking him to write a short Korean answer is fine when it tests reading.
|
||||
|
||||
{{FOCUS}}
|
||||
37
tsconfig.json
Normal file
37
tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node", "vite/client"],
|
||||
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"noEmit": true,
|
||||
|
||||
// lib/ ships unchanged; its types live alongside in types/lib/.
|
||||
// Vite resolves the same specifiers to the real .js modules.
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["./types/lib/*"],
|
||||
"@data/*": ["./data/*"],
|
||||
"@app/*": ["./app/src/*"],
|
||||
"@shared/*": ["./types/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["app/src", "test", "types", "app/vite.config.ts", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "export", "vendor", "android", "app/android"]
|
||||
}
|
||||
92
validate.mjs
Normal file
92
validate.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
/* Curriculum checks. Run: node validate.mjs
|
||||
Reads only data/ and lib/, so it works as a CI gate in the new repo. */
|
||||
import fs from "fs";
|
||||
import { decompose } from "./lib/hangul.js";
|
||||
import { haeche, surfaceForms } from "./lib/conjugation.js";
|
||||
import { flatten } from "./lib/gate.js";
|
||||
|
||||
const read = f => JSON.parse(fs.readFileSync(new URL(`./data/${f}`, import.meta.url)));
|
||||
const curriculum = read("curriculum.json"), deck = read("deck.json");
|
||||
const sentences = read("sentences.json"), sfx = read("sfx.json");
|
||||
const UNITS = flatten(curriculum);
|
||||
const at = id => UNITS.findIndex(u => u.id === id);
|
||||
|
||||
const TENSE = "ㄲㄸㅃㅆㅉ", ASP = "ㅋㅌㅍㅊ";
|
||||
const COMPV = "ㅐㅔㅒㅖㅘㅙㅚㅝㅞㅟㅢ", DOUBLE = "ㄳㄵㄶㄺㄻㄼㄽㄾㄿㅀㅄ";
|
||||
const CHO="ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
|
||||
const JUNG="ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ";
|
||||
const JONG=" ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ";
|
||||
const dec = ch => { const d = decompose(ch); return d ? [CHO[d[0]], JUNG[d[1]], JONG[d[2]] === " " ? "" : JONG[d[2]]] : null; };
|
||||
|
||||
const FEATURE = { "1.1":"basic","1.2":"compV","1.3":"tense","1.4":"batchim",
|
||||
"1.5":"liaison","1.6":"nasal","1.7":"double","1.8":"allsound" };
|
||||
const ORDER = ["basic","compV","tense","batchim","liaison","nasal","double","allsound"];
|
||||
const level = i => Math.max(-1, ...Object.entries(FEATURE).map(([id,f]) => at(id) <= i ? ORDER.indexOf(f) : -1));
|
||||
const has = (i,f) => level(i) >= ORDER.indexOf(f);
|
||||
|
||||
const fail = [];
|
||||
const add = (kind, unit, msg) => fail.push({ kind, unit, msg });
|
||||
|
||||
/* ── 1. sequencing: no word may use a phenomenon not yet taught ── */
|
||||
UNITS.forEach((u, i) => (u.words || []).forEach(w => {
|
||||
const syl = [...w].filter(dec);
|
||||
syl.forEach(ch => {
|
||||
const [c, v, f] = dec(ch);
|
||||
if (!has(i,"tense") && (TENSE.includes(c) || ASP.includes(c))) add("seq", u.id, `${w}: tense/aspirated ${c} before 1.3`);
|
||||
if (!has(i,"compV") && COMPV.includes(v)) add("seq", u.id, `${w}: compound vowel ${v} before 1.2`);
|
||||
if (f && !has(i,"batchim")) add("seq", u.id, `${w}: batchim ${f} before 1.4`);
|
||||
if (f && DOUBLE.includes(f) && !has(i,"double")) add("seq", u.id, `${w}: double batchim ${f} before 1.7`);
|
||||
});
|
||||
for (let k = 0; k < syl.length - 1; k++) {
|
||||
const a = dec(syl[k]), b = dec(syl[k+1]);
|
||||
if (a[2] && b[0] === "ㅇ" && !has(i,"liaison")) add("seq", u.id, `${w}: liaison context before 1.5`);
|
||||
if (a[2] && "ㄱㄷㅂㅅㅈㅊㅌㅍㅋ".includes(a[2]) && "ㄴㅁ".includes(b[0]) && !has(i,"nasal")) add("seq", u.id, `${w}: nasalisation context before 1.6`);
|
||||
}
|
||||
}));
|
||||
|
||||
/* ── 2. coverage: every roadmap word must be glossable ── */
|
||||
const lex = new Set();
|
||||
Object.values(deck.topics).flat().forEach(([ko,,en,pos]) => {
|
||||
lex.add(ko);
|
||||
if (pos === "verb" || pos === "adj") surfaceForms(ko, en).forEach(s => lex.add(s.form));
|
||||
});
|
||||
sentences.sentences.forEach(s => s.parts.forEach(p => lex.add(p[0])));
|
||||
sfx.items.forEach(s => lex.add(s.ko));
|
||||
try { read("gloss-extra.json").entries.forEach(g => lex.add(g.ko)); } catch {}
|
||||
UNITS.forEach(u => (u.words || []).forEach(w => { if (!lex.has(w)) add("gloss", u.id, w); }));
|
||||
|
||||
/* ── 3. hygiene ── */
|
||||
const seen = new Map();
|
||||
UNITS.forEach(u => (u.words || []).forEach(w => {
|
||||
if (seen.has(w)) add("dupe", u.id, `${w} — already introduced in ${seen.get(w)}`);
|
||||
else if (!seen.has(w)) seen.set(w, u.id);
|
||||
}));
|
||||
UNITS.forEach(u => { if (!(u.teaches || []).length) add("empty", u.id, "no teaches[] — the gate cannot grow"); });
|
||||
|
||||
/* ── 4. revisits[] integrity: a spiral target must exist, and earlier ── */
|
||||
UNITS.forEach((u, i) => (u.revisits || []).forEach(r => {
|
||||
const j = at(r.from);
|
||||
if (j < 0) add("revisit", u.id, `${r.word}: from "${r.from}" — no such unit`);
|
||||
else if (j >= i) add("revisit", u.id, `${r.word}: from ${r.from}, which is not earlier`);
|
||||
else if (!(UNITS[j].words || []).includes(r.word))
|
||||
add("revisit", u.id, `${r.word}: not in ${r.from}'s words[]`);
|
||||
}));
|
||||
|
||||
/* ── report ── */
|
||||
const by = k => fail.filter(f => f.kind === k);
|
||||
const H = (t, n) => console.log(`\n${t} — ${n}`);
|
||||
H("SEQUENCING", by("seq").length ? `${by("seq").length} VIOLATIONS` : "clean");
|
||||
by("seq").forEach(f => console.log(` ✗ ${f.unit} ${f.msg}`));
|
||||
H("GLOSS COVERAGE", `${by("gloss").length} of ${UNITS.flatMap(u => u.words || []).length} roadmap words have no lexicon entry`);
|
||||
Object.entries(by("gloss").reduce((a,f) => ((a[f.unit] = a[f.unit] || []).push(f.msg), a), {}))
|
||||
.forEach(([u,w]) => console.log(` ${u}: ${w.join(" · ")}`));
|
||||
H("DUPLICATES", `${by("dupe").length}`);
|
||||
by("dupe").forEach(f => console.log(` ${f.unit}: ${f.msg}`));
|
||||
H("EMPTY teaches[]", `${by("empty").length}`);
|
||||
by("empty").forEach(f => console.log(` ${f.unit}`));
|
||||
H("REVISITS", by("revisit").length ? `${by("revisit").length} BROKEN` : `clean (${UNITS.flatMap(u => u.revisits || []).length} spiral targets)`);
|
||||
by("revisit").forEach(f => console.log(` ✗ ${f.unit}: ${f.msg}`));
|
||||
|
||||
const blocking = by("seq").length + by("empty").length + by("revisit").length;
|
||||
console.log(`\n${blocking ? "FAIL" : "PASS"} — ${blocking} blocking, ${by("gloss").length + by("dupe").length} advisory`);
|
||||
process.exit(blocking ? 1 : 0);
|
||||
23
vitest.config.ts
Normal file
23
vitest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const at = (p: string) => fileURLToPath(new URL(p, import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
// The declarations live in types/lib/; the code is the verbatim lib/.
|
||||
"@lib": at("./lib"),
|
||||
"@data": at("./data"),
|
||||
"@app": at("./app/src"),
|
||||
"@shared": at("./shared"),
|
||||
"@prompt": at("./prompt"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
// sqlite-wasm loads a .wasm file; give the db suite room.
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user