@ihabdevteam/core 0.293.0 → 0.295.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1651,7 +1651,7 @@ export declare function copyText(text: string): Promise<boolean>;
1651
1651
  *
1652
1652
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1653
1653
  */
1654
- export declare const CORE_VERSION = "0.293.0";
1654
+ export declare const CORE_VERSION = "0.295.0";
1655
1655
 
1656
1656
  /** @public */
1657
1657
  declare const coreDefault: Record<string, unknown>;
@@ -10483,6 +10483,45 @@ export declare interface WordFilterSnapshot {
10483
10483
  wordTypeFilter?: string[];
10484
10484
  }
10485
10485
 
10486
+ /**
10487
+ * @public **자리(어두·어중·어말)를 음절이 아니라 자음에 매긴다** — 말귀 요청 (93), 2026-10-06.
10488
+ *
10489
+ * `wordHasSyllableMatching` 의 위치 제약은 **음절 색인**으로 가른다(0번=어두 · 마지막=어말 ·
10490
+ * 그 사이=어중). 그러면 **2음절 낱말에는 어중이 아예 없다** — `선물` 의 ㄴ받침·ㅁ초성을
10491
+ * 뽑을 길이 없어 검색이 자료의 일부에 못 닿았다(말귀 두 화면이 2음절 이하면 어중 단추를
10492
+ * 잠가 두고 있었다).
10493
+ *
10494
+ * 임상에서 쓰는 모델은 자리를 **자음에만** 매긴다(김동민 팀장·사공정희 선생, 2026-10-06):
10495
+ *
10496
+ * 선물 → ㅅ 어두초성 · ㄴ 어중종성 · ㅁ 어중초성 · ㄹ 어말종성
10497
+ * 아기 → ㄱ 어중초성 (어두초성 없음 — `ㅇ` 은 음가가 없다 · 어말종성 없음)
10498
+ *
10499
+ * 어두 = 첫 음절의 **초성**(`ㅇ` 이면 **없는 것**으로 본다)
10500
+ * 어말 = 마지막 음절의 **받침**(없으면 없는 것)
10501
+ * 어중 = 그 밖의 **모든 초성·종성** — **2음절에도 생긴다**
10502
+ * 중성(모음)에는 자리를 **안 매긴다** — 그래서 이 함수는 `jung` 을 안 받는다
10503
+ *
10504
+ * **`mid` 에서 `cho` 와 `jong` 을 둘 다 주면 둘 다 있어야 한다**(AND, 사용자 결정). `선물`
10505
+ * 처럼 **받침+다음 초성**이 맞물리는 자음동화 꼴을 잡으려는 것이다. 다만 **붙어 있어야
10506
+ * 하는 것은 아니다** — 낱말 어디에든 하나씩 있으면 된다. 하나만 주면 그것만 본다.
10507
+ *
10508
+ * `wordHasSyllableMatching` 은 **그대로 둔다** — 위치가 `무관` 일 때는 초성·중성·종성을
10509
+ * **한 음절 안에서** 묶어 보는 것이 맞다(`ㄱ+ㅏ+ㅇ` = `강`). 두 함수는 묻는 것이 다르다.
10510
+ *
10511
+ * > **`mid` 에 `jongNone` 을 주는 길은 아직 쓰는 자리가 없다**(말귀 확인, 2026-10-06).
10512
+ * > 단어표에서 종성 `없음` 은 *낱말 전체가 열린 음절*이라는 뜻으로 쓰여(`wordHasNoCoda`),
10513
+ * > 자리를 고르는 물음과 섞인다. 그래도 **뜻을 정해 두고 못으로 박아 둔다** — 안 정해 두면
10514
+ * > 나중에 쓰는 사람이 제 짐작으로 쓰고, 그 짐작이 여기 셈과 다를 수 있다.
10515
+ * > 정한 뜻: **어중에 받침 없는 음절이 하나라도 있나**(마지막 음절은 어말이라 빼고 센다).
10516
+ */
10517
+ export declare function wordHasConsonantAt(word: string, parts: {
10518
+ cho?: string[] | null;
10519
+ jong?: string[] | null;
10520
+ jongNone?: boolean;
10521
+ }, position: 'head' | 'mid' | 'tail',
10522
+ /** 종성을 무엇으로 견줄지 — `wordHasSyllableMatching` 과 같다(`JongMatch` 참고) */
10523
+ jongMatch?: JongMatch): boolean;
10524
+
10486
10525
  /** @public 단어의 모든 한글 음절에 종성(받침)이 없는지 (열린 음절로만 구성) */
10487
10526
  export declare function wordHasNoCoda(word: string): boolean;
10488
10527
 
@@ -93,6 +93,44 @@ export declare function wordHasSyllableMatching(word: string, parts: {
93
93
  /** 종성을 무엇으로 견줄지 — 기본은 **글자 그대로**(`'glyph'`). 받침 **소리**로 모으려면 `'phoneme'`(`JongMatch` 참고).
94
94
  * 발음 입력 화면은 넘기는 낱말 자체가 표준발음이라 겹받침이 안 나온다 — 기본 그대로 두면 된다. */
95
95
  jongMatch?: JongMatch): boolean;
96
+ /**
97
+ * @public **자리(어두·어중·어말)를 음절이 아니라 자음에 매긴다** — 말귀 요청 (93), 2026-10-06.
98
+ *
99
+ * `wordHasSyllableMatching` 의 위치 제약은 **음절 색인**으로 가른다(0번=어두 · 마지막=어말 ·
100
+ * 그 사이=어중). 그러면 **2음절 낱말에는 어중이 아예 없다** — `선물` 의 ㄴ받침·ㅁ초성을
101
+ * 뽑을 길이 없어 검색이 자료의 일부에 못 닿았다(말귀 두 화면이 2음절 이하면 어중 단추를
102
+ * 잠가 두고 있었다).
103
+ *
104
+ * 임상에서 쓰는 모델은 자리를 **자음에만** 매긴다(김동민 팀장·사공정희 선생, 2026-10-06):
105
+ *
106
+ * 선물 → ㅅ 어두초성 · ㄴ 어중종성 · ㅁ 어중초성 · ㄹ 어말종성
107
+ * 아기 → ㄱ 어중초성 (어두초성 없음 — `ㅇ` 은 음가가 없다 · 어말종성 없음)
108
+ *
109
+ * 어두 = 첫 음절의 **초성**(`ㅇ` 이면 **없는 것**으로 본다)
110
+ * 어말 = 마지막 음절의 **받침**(없으면 없는 것)
111
+ * 어중 = 그 밖의 **모든 초성·종성** — **2음절에도 생긴다**
112
+ * 중성(모음)에는 자리를 **안 매긴다** — 그래서 이 함수는 `jung` 을 안 받는다
113
+ *
114
+ * **`mid` 에서 `cho` 와 `jong` 을 둘 다 주면 둘 다 있어야 한다**(AND, 사용자 결정). `선물`
115
+ * 처럼 **받침+다음 초성**이 맞물리는 자음동화 꼴을 잡으려는 것이다. 다만 **붙어 있어야
116
+ * 하는 것은 아니다** — 낱말 어디에든 하나씩 있으면 된다. 하나만 주면 그것만 본다.
117
+ *
118
+ * `wordHasSyllableMatching` 은 **그대로 둔다** — 위치가 `무관` 일 때는 초성·중성·종성을
119
+ * **한 음절 안에서** 묶어 보는 것이 맞다(`ㄱ+ㅏ+ㅇ` = `강`). 두 함수는 묻는 것이 다르다.
120
+ *
121
+ * > **`mid` 에 `jongNone` 을 주는 길은 아직 쓰는 자리가 없다**(말귀 확인, 2026-10-06).
122
+ * > 단어표에서 종성 `없음` 은 *낱말 전체가 열린 음절*이라는 뜻으로 쓰여(`wordHasNoCoda`),
123
+ * > 자리를 고르는 물음과 섞인다. 그래도 **뜻을 정해 두고 못으로 박아 둔다** — 안 정해 두면
124
+ * > 나중에 쓰는 사람이 제 짐작으로 쓰고, 그 짐작이 여기 셈과 다를 수 있다.
125
+ * > 정한 뜻: **어중에 받침 없는 음절이 하나라도 있나**(마지막 음절은 어말이라 빼고 센다).
126
+ */
127
+ export declare function wordHasConsonantAt(word: string, parts: {
128
+ cho?: string[] | null;
129
+ jong?: string[] | null;
130
+ jongNone?: boolean;
131
+ }, position: 'head' | 'mid' | 'tail',
132
+ /** 종성을 무엇으로 견줄지 — `wordHasSyllableMatching` 과 같다(`JongMatch` 참고) */
133
+ jongMatch?: JongMatch): boolean;
96
134
  /**
97
135
  * @public 문자열이 음절 패턴 DSL 문법인지 — 쉼표(,) 포함 여부로 판별한다.
98
136
  * 일반 단어 검색어엔 쉼표가 쓰이지 않으므로 충돌 없이 자동 분기할 수 있다.
@@ -337,6 +337,78 @@ jongMatch = 'glyph') {
337
337
  return true;
338
338
  return false;
339
339
  }
340
+ /**
341
+ * @public **자리(어두·어중·어말)를 음절이 아니라 자음에 매긴다** — 말귀 요청 (93), 2026-10-06.
342
+ *
343
+ * `wordHasSyllableMatching` 의 위치 제약은 **음절 색인**으로 가른다(0번=어두 · 마지막=어말 ·
344
+ * 그 사이=어중). 그러면 **2음절 낱말에는 어중이 아예 없다** — `선물` 의 ㄴ받침·ㅁ초성을
345
+ * 뽑을 길이 없어 검색이 자료의 일부에 못 닿았다(말귀 두 화면이 2음절 이하면 어중 단추를
346
+ * 잠가 두고 있었다).
347
+ *
348
+ * 임상에서 쓰는 모델은 자리를 **자음에만** 매긴다(김동민 팀장·사공정희 선생, 2026-10-06):
349
+ *
350
+ * 선물 → ㅅ 어두초성 · ㄴ 어중종성 · ㅁ 어중초성 · ㄹ 어말종성
351
+ * 아기 → ㄱ 어중초성 (어두초성 없음 — `ㅇ` 은 음가가 없다 · 어말종성 없음)
352
+ *
353
+ * 어두 = 첫 음절의 **초성**(`ㅇ` 이면 **없는 것**으로 본다)
354
+ * 어말 = 마지막 음절의 **받침**(없으면 없는 것)
355
+ * 어중 = 그 밖의 **모든 초성·종성** — **2음절에도 생긴다**
356
+ * 중성(모음)에는 자리를 **안 매긴다** — 그래서 이 함수는 `jung` 을 안 받는다
357
+ *
358
+ * **`mid` 에서 `cho` 와 `jong` 을 둘 다 주면 둘 다 있어야 한다**(AND, 사용자 결정). `선물`
359
+ * 처럼 **받침+다음 초성**이 맞물리는 자음동화 꼴을 잡으려는 것이다. 다만 **붙어 있어야
360
+ * 하는 것은 아니다** — 낱말 어디에든 하나씩 있으면 된다. 하나만 주면 그것만 본다.
361
+ *
362
+ * `wordHasSyllableMatching` 은 **그대로 둔다** — 위치가 `무관` 일 때는 초성·중성·종성을
363
+ * **한 음절 안에서** 묶어 보는 것이 맞다(`ㄱ+ㅏ+ㅇ` = `강`). 두 함수는 묻는 것이 다르다.
364
+ *
365
+ * > **`mid` 에 `jongNone` 을 주는 길은 아직 쓰는 자리가 없다**(말귀 확인, 2026-10-06).
366
+ * > 단어표에서 종성 `없음` 은 *낱말 전체가 열린 음절*이라는 뜻으로 쓰여(`wordHasNoCoda`),
367
+ * > 자리를 고르는 물음과 섞인다. 그래도 **뜻을 정해 두고 못으로 박아 둔다** — 안 정해 두면
368
+ * > 나중에 쓰는 사람이 제 짐작으로 쓰고, 그 짐작이 여기 셈과 다를 수 있다.
369
+ * > 정한 뜻: **어중에 받침 없는 음절이 하나라도 있나**(마지막 음절은 어말이라 빼고 센다).
370
+ */
371
+ export function wordHasConsonantAt(word, parts, position,
372
+ /** 종성을 무엇으로 견줄지 — `wordHasSyllableMatching` 과 같다(`JongMatch` 참고) */
373
+ jongMatch = 'glyph') {
374
+ if (!word)
375
+ return false;
376
+ const 음절 = [...word].map(parseSyllable).filter((s) => !!s);
377
+ if (음절.length === 0)
378
+ return false;
379
+ const 끝 = 음절.length - 1;
380
+ if (position === 'head') {
381
+ /* 어두는 **첫 음절의 초성**뿐이다. `ㅇ` 은 음가가 없어 자리를 안 가진다 —
382
+ `아기` 의 어두초성은 **없다**(`cho: ['ㅇ']` 로 물으면 거짓). */
383
+ if (!parts.cho)
384
+ return false;
385
+ const c = CHOSEONG[음절[0].cho];
386
+ return c !== 'ㅇ' && parts.cho.includes(c);
387
+ }
388
+ if (position === 'tail') {
389
+ /* 어말은 **마지막 받침**뿐이다. 받침이 없으면 자리가 없다. */
390
+ if (parts.jongNone)
391
+ return 음절[끝].jong === 0;
392
+ if (!parts.jong)
393
+ return false;
394
+ return syllableJongMatches(음절[끝], parts.jong, jongMatch);
395
+ }
396
+ /* 어중 — 첫 초성과 마지막 종성을 **뺀** 나머지 자음 전부. 2음절 `선물` 이면
397
+ ㄴ(0번 종성) · ㅁ(1번 초성) 둘이 어중이다. */
398
+ const 초성맞나 = !parts.cho || 음절.some((s, i) => {
399
+ if (i === 0)
400
+ return false; // 첫 초성은 어두다
401
+ const c = CHOSEONG[s.cho];
402
+ return c !== 'ㅇ' && parts.cho.includes(c); // 음가 없는 ㅇ 은 자리를 안 가진다
403
+ });
404
+ const 종성맞나 = parts.jongNone
405
+ ? 음절.some((s, i) => i !== 끝 && s.jong === 0)
406
+ : !parts.jong || 음절.some((s, i) => i !== 끝 && syllableJongMatches(s, parts.jong, jongMatch));
407
+ /* 둘 다 줬으면 **둘 다** 있어야 한다(AND) — 하나만 줬으면 그쪽만 참이면 된다 */
408
+ if (!parts.cho && !parts.jong && !parts.jongNone)
409
+ return false;
410
+ return 초성맞나 && 종성맞나;
411
+ }
340
412
  /**
341
413
  * @public 문자열이 음절 패턴 DSL 문법인지 — 쉼표(,) 포함 여부로 판별한다.
342
414
  * 일반 단어 검색어엔 쉼표가 쓰이지 않으므로 충돌 없이 자동 분기할 수 있다.
@@ -122,15 +122,35 @@ position) {
122
122
  return s != null && selParts.every(part => selSets[part].has(PART_ARR[part][s[part]]));
123
123
  };
124
124
  for (const pw of words) {
125
- // 위치 제약이 있으면 그 자리(어두=0, 어말=L-1, 어중=중간)에서 매칭하는 음절을, 없으면 첫 매칭 음절을 대립 대상으로.
125
+ /* 위치 제약이 있으면 그 자리에서 매칭하는 음절을, 없으면 첫 매칭 음절을 대립 대상으로.
126
+ **자리는 음절이 아니라 자음에 매긴다**(말귀 요청 (94), 2026-10-07 — (93) 의 자매다).
127
+
128
+ 전에는 어중을 `k ∈ 1..L-2` 로 봤다. 그러면 **2음절에는 어중이 한 자리도 없어**
129
+ `가마`·`거미` 의 ㅁ 초성으로 묶음이 **0 개** 나왔다(말귀 실측: 어중+초성 ㅁ → 0 묶음,
130
+ 같은 조건의 어두는 37 묶음). 검색 쪽은 0.294.0 의 `wordHasConsonantAt` 으로 고쳤는데
131
+ **묶음 짓는 쪽이 안 따라와** 정답 풀에 서른 남짓 남아도 짝이 안 섰다.
132
+
133
+ 어중은 **첫 초성과 마지막 받침만 빼면 나머지 자음 전부**다. 그래서 고른 성분에 따라
134
+ 범위가 달라진다:
135
+
136
+ 어중 + 초성만 k ∈ 1..L-1 2음절 둘째 음절이 든다(`가마` 의 `마`)
137
+ 어중 + 종성만 k ∈ 0..L-2 첫 음절 받침이 든다(`선물` 의 `선`)
138
+ 어중 + 초성 + 종성 k ∈ 1..L-2 전과 같다 — 아래 까닭
139
+
140
+ **초성·종성을 함께 고르면 한 음절 안에서 둘 다** 봐야 한다. 검색(`wordHasConsonantAt`)
141
+ 은 둘이 다른 음절에 흩어져도 참이지만(AND, 자음동화 꼴을 잡으려고), **대립은 갈릴
142
+ 음절이 하나 있어야** 성립한다 — 가릴 자리(`pos`)가 하나이기 때문이다. 묻는 것이
143
+ 다르므로 셈도 다르다. */
126
144
  let i;
127
145
  if (position === 'head')
128
146
  i = matchesAt(pw, 0) ? 0 : -1;
129
147
  else if (position === 'tail')
130
148
  i = matchesAt(pw, L - 1) ? L - 1 : -1;
131
149
  else if (position === 'mid') {
150
+ const 처음 = selParts.includes('cho') ? 1 : 0; // 첫 초성은 어두다
151
+ const 마지막 = selParts.includes('jong') ? L - 2 : L - 1; // 마지막 받침은 어말이다
132
152
  i = -1;
133
- for (let k = 1; k < L - 1; k++)
153
+ for (let k = 처음; k <= 마지막; k++)
134
154
  if (matchesAt(pw, k)) {
135
155
  i = k;
136
156
  break;
@@ -51,7 +51,7 @@ export type { TrainingCounts, ComputeDailySelectionOptions } from './utils/getTr
51
51
  export { TRAINING_TYPES, TOPIC_TYPES, TRAINING_TYPE_I18N_KEY } from './utils/CategoryData';
52
52
  export type { WordEntry } from './utils/phonemePairSearch';
53
53
  export { CAPABILITY, isValidQuestionWord, hasQuestionWordConstraint, isManualOnlyType } from './utils/wordTrainingRuntime';
54
- export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, syllableLength } from './utils/koreanSyllable';
54
+ export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, wordHasConsonantAt, syllableLength } from './utils/koreanSyllable';
55
55
  export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput, patternToComposition, matchesWordQuery, searchAndRankWords } from './utils/koreanSyllable';
56
56
  export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, type PlanTier, type DataLevel, type PlanFeatures, type PlanLimits, } from './utils/planFeatures';
57
57
  export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
@@ -34,7 +34,7 @@ export { classifyPairDifficulty, firstChoseongOf, toChoseong, isAllChoseong, mat
34
34
  export { ensureTrainingDataLoaded, getTrainingPools, loadTrainingData, isTrainingDataLoaded, getDailySelection, computeDailySelection, NOISE_DATAS, SHORT_STORIES, LONG_STORIES, ORDER_SETS } from './utils/getTrainingData';
35
35
  export { TRAINING_TYPES, TOPIC_TYPES, TRAINING_TYPE_I18N_KEY } from './utils/CategoryData';
36
36
  export { CAPABILITY, isValidQuestionWord, hasQuestionWordConstraint, isManualOnlyType } from './utils/wordTrainingRuntime';
37
- export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, syllableLength } from './utils/koreanSyllable';
37
+ export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, wordHasConsonantAt, syllableLength } from './utils/koreanSyllable';
38
38
  export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput, patternToComposition, matchesWordQuery, searchAndRankWords } from './utils/koreanSyllable';
39
39
  /* 요금제 정의 — 회원앱과 말귀어드민이 둘 다 본다. 예전에는 회원앱(src/libs) 안에 있었고
40
40
  어드민이 '@app/libs/planFeatures' 로 끌어다 썼는데, 그 한 줄 때문에 어드민이 회원앱
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.293.0";
49
+ export declare const CORE_VERSION = "0.295.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.293.0';
49
+ export const CORE_VERSION = '0.295.0';