prpm-dbp 0.6.0 → 0.8.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.
package/README.md CHANGED
@@ -132,7 +132,9 @@ Pengguna kemudian menarik versi baharu dengan `npx skills update`.
132
132
  | `banding <kata...>` | silang semak PRPM lawan KamusDBP |
133
133
  | `cache` | status cache, `--buang <kata>` untuk padam satu |
134
134
 
135
- Pilihan: `--refresh`, `--had <n>`, `--delay <ms>`, `--json`.
135
+ Pilihan: `--refresh`, `--had <n>`, `--delay <ms>`, `--json`, `--cepat`, `--pedoman`.
136
+
137
+ `--pedoman` membenarkan penjanaan imbuhan menurut peraturan DBP apabila kamus tiada jawapan. Ketepatannya diukur 88-98% terhadap 66,000 perkataan sebenar, jadi ia mati secara lalai dan hasilnya sentiasa ditanda.
136
138
 
137
139
  ## Guna sebagai library
138
140
 
package/SKILL.md CHANGED
@@ -159,6 +159,52 @@ Kata dasar dicari dalam kamus seperti biasa, jadi ejaannya tetap datang daripada
159
159
 
160
160
  §17.2 mengecualikan kata ulang yang **berubah bentuk** (berimbuhan atau berentak) seperti `bermaaf-maafan`. Di situ sempang kekal dan peraturan `٢` tidak terpakai, jadi padanan menuntut kedua-dua bahagian benar-benar serupa.
161
161
 
162
+ ## Penjanaan imbuhan (pilihan, mati secara lalai)
163
+
164
+ `--pedoman` membenarkan pakej **menjana** ejaan kata berimbuhan menurut §13 dan §15, apabila tiada sumber lain mempunyai jawapan. Ejaan kata dasar tetap dicari dalam kamus; hanya imbuhan yang dijana.
165
+
166
+ Ia mati secara lalai kerana ketepatannya diukur, bukan diandaikan. Diuji terhadap 66,000 perkataan sebenar:
167
+
168
+ | Imbuhan | Kes diuji | Betul |
169
+ |---|---|---|
170
+ | `-mu` | 333 | 98% |
171
+ | `-nya` | 3,697 | 97% |
172
+ | `di-` | 2,435 | 92% |
173
+ | `se-` | 1,605 | 90% |
174
+ | `ke-` | 976 | 88% |
175
+
176
+ Sebahagian kegagalan itu artifak ukuran (`tamu` bukan `ta`+`mu`), jadi ketepatan sebenar lebih tinggi. Walaupun begitu, 98% tidak cukup untuk menjana ejaan secara senyap dalam alat yang seluruh tujuannya menghalang Jawi yang nampak betul tetapi salah.
177
+
178
+ Bila dihidupkan, hasilnya **tetap ditanda** dan dilaporkan dalam medan `dijanaPedoman` berserta rujukan seksyennya:
179
+
180
+ ```
181
+ $ npx prpm-dbp jawi nota.txt --pedoman
182
+ «اباديڽ» ککل.
183
+ dijana ikut Pedoman, BELUM disahkan DBP (1): Abadinya=اباديڽ [Pedoman DBP §15.1]
184
+ ```
185
+
186
+ Peraturan yang dilaksanakan: §13.5-13.7 (awalan `di-`, `se-`, `ke-`), §13.10 (tiga cara akhiran `-an`), §13.11 (tiga cara akhiran `-i`), §15.1 (kata ganti singkat `ku`, `mu`, `nya`).
187
+
188
+ **Kodpoint setiap imbuhan disahkan empirik daripada dataset, bukan dibaca daripada peraturan.** Hamzah dalam akhiran ialah `ء` U+0621 berdiri sendiri, manakala dalam awalan ia `أ` U+0623 di atas alif. Peraturan bertulis tidak membezakan dua itu.
189
+
190
+ ## Apa yang sebenarnya tercicir
191
+
192
+ Diuji terhadap satu pelajaran Pendidikan Islam Tingkatan 4 sebenar (406 perkataan):
193
+
194
+ ```
195
+ disahkan DBP 223 | dari dataset 38 | kata sendi §14.1 3
196
+ nama khas 15 | belum disahkan 10
197
+ ```
198
+
199
+ **Tiada satu pun kata Melayu biasa tercicir.** Yang tinggal terbahagi kepada dua kategori sahaja:
200
+
201
+ | Kategori | Contoh | Selesaian |
202
+ |---|---|---|
203
+ | Istilah tajwid Arab | `Qasirah`, `Tawilah`, `Mukhaffaf`, `Muthaqqal`, `Sughra`, `Kubra`, `Waqaf`, `hijaiyah` | `nama` dengan `jenis: istilah`, sahkan sekali |
204
+ | Akronim | `KPM`, `DBP`, `KSSM`, `PBD`, `KBAT` | dilaporkan berasingan, kekal Rumi |
205
+
206
+ Inilah sebab transliterator pola suku kata (§7 hingga §9) **tidak dilaksanakan**. Ia tidak akan menyentuh satu pun perkataan di atas. Lebih buruk lagi, istilah tajwid itu kata serapan Arab, dan §11.1 menetapkan ejaan Jawinya ialah ejaan Arab asal — peraturan pola Melayu akan menghasilkan ejaan fonetik yang salah untuk setiap satu, iaitu tepat di tempat ia paling merosakkan.
207
+
162
208
  ## Rantai kepercayaan
163
209
 
164
210
  Setiap ejaan Jawi yang dikeluarkan membawa asal-usulnya. Jangan runtuhkan lapisan ini.
package/bin/cli.js CHANGED
@@ -33,7 +33,8 @@ const BANTUAN = `prpm-dbp — semak Bahasa Melayu dan Jawi terhadap PRPM DBP
33
33
  npx prpm-dbp cache [--buang <kata>] status cache
34
34
  npx prpm-dbp install [--link] pasang sebagai Claude Code skill
35
35
 
36
- Pilihan: --refresh (abai cache) --had <n> --delay <ms> --json --cepat (langkau carian kata dasar)
36
+ Pilihan: --refresh --had <n> --delay <ms> --json --cepat
37
+ --pedoman jana imbuhan ikut peraturan DBP (88-98% tepat, hasil ditanda)
37
38
 
38
39
  Cache: ${CACHE_PATH}
39
40
  `
@@ -47,6 +48,7 @@ function huraiArgs(argv) {
47
48
  else if (a === '--json') pilihan.json = true
48
49
  else if (a === '--link') pilihan.link = true
49
50
  else if (a === '--cepat') pilihan.cepat = true
51
+ else if (a === '--pedoman') pilihan.pedoman = true
50
52
  else if (a === '--had') pilihan.had = Number(argv[++i])
51
53
  else if (a === '--delay') pilihan.delay = Number(argv[++i])
52
54
  else if (a === '--buang') pilihan.buang = argv[++i]
@@ -150,7 +152,11 @@ async function main() {
150
152
 
151
153
  case 'jawi': {
152
154
  const teks = await readFile(baki[0], 'utf8')
153
- const h = await keJawi(teks, cache, { ...opts, cubaDasar: !pilihan.cepat })
155
+ const h = await keJawi(teks, cache, {
156
+ ...opts,
157
+ cubaDasar: !pilihan.cepat,
158
+ janaImbuhan: pilihan.pedoman,
159
+ })
154
160
  await saveCache(cache)
155
161
  if (pilihan.json) { keluar(h); break }
156
162
  console.log(h.teks)
@@ -163,6 +169,15 @@ async function main() {
163
169
  h.adaEntriTanpaJawi.join(', '),
164
170
  )
165
171
  }
172
+ if (h.dijanaPedoman.length) {
173
+ console.error(
174
+ `dijana ikut Pedoman, BELUM disahkan DBP (${h.dijanaPedoman.length}): ` +
175
+ h.dijanaPedoman.map((x) => `${x.kata}=${x.jawi} [${x.rujukan}]`).join(', '),
176
+ )
177
+ }
178
+ if (h.akronim.length) {
179
+ console.error(`akronim, dikekalkan Rumi (${h.akronim.length}): ${h.akronim.join(', ')}`)
180
+ }
166
181
  if (h.namaKhas.length) {
167
182
  console.error(
168
183
  `nama khas belum disahkan (${h.namaKhas.length}): ${h.namaKhas.join(', ')}\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prpm-dbp",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Semak ejaan Bahasa Melayu dan Jawi terhadap PRPM Dewan Bahasa dan Pustaka. CLI dan skill untuk AI agent.",
5
5
  "keywords": [
6
6
  "bahasa-melayu",
package/src/nama.js CHANGED
@@ -55,6 +55,23 @@ export async function cariNama(kata) {
55
55
  return { ...rec, dijumpai: true, disahkanDbp: false }
56
56
  }
57
57
 
58
+ /**
59
+ * §19 — Akronim dan singkatan.
60
+ *
61
+ * Singkatan inisialisme (KPM, DBP, PDF, KSSM, PBD, KBAT) ialah kategori
62
+ * tersendiri, bukan salah eja dan bukan nama orang. Pedoman §19.3 dan §19.4
63
+ * menetapkan ia ditulis dengan huruf abjad Jawi berserta titik selepas
64
+ * setiap huruf, tetapi banyak dokumen pendidikan mengekalkannya dalam Rumi
65
+ * dengan sengaja. Alat ini tidak memutuskan bagi pihak anda: ia hanya
66
+ * mengasingkan akronim supaya keputusan itu dibuat sekali, bukan dianggap
67
+ * sebagai ejaan yang salah.
68
+ */
69
+ export function mungkinAkronim(kata) {
70
+ const k = String(kata)
71
+ // Dua huruf besar atau lebih berturut-turut, tanpa huruf kecil.
72
+ return /^[A-Z][A-Z0-9]+$/.test(k) && k.length >= 2
73
+ }
74
+
58
75
  /**
59
76
  * Teka sama ada perkataan itu nama khas.
60
77
  *
package/src/pedoman.js CHANGED
@@ -137,3 +137,81 @@ export function pisahUlangJawi(jawi) {
137
137
  const dasar = jawi.slice(0, -ANGKA_DUA.length)
138
138
  return dasar ? dasar : null
139
139
  }
140
+
141
+ const HAMZAH = 'ء' // ء berdiri sendiri, BUKAN أ U+0623 seperti dalam awalan
142
+ const NUN = 'ن'
143
+ const YA = 'ي'
144
+ const WAU = 'و'
145
+
146
+ /**
147
+ * §13.10 — Tiga cara mengeja akhiran -an, bergantung pada huruf akhir kata
148
+ * dasar dalam ejaan JAWI (bukan Rumi):
149
+ * (i) selepas wau -> alif-nun tinjau -> تينجاوان
150
+ * (ii) selepas alif -> hamzah-nun buka -> بوکاءن
151
+ * (iii) selain itu -> nun sumpit -> سومڤيتن
152
+ *
153
+ * Catatan §13.10(ii): kata dasar yang berakhir dengan suku kata terbuka
154
+ * bervokal [a] tetapi tidak ditulis dengan alif perlu ditambah alif dahulu.
155
+ * Itulah sebabnya buka (بوک) menjadi بوکاءن dan bukan بوکءن.
156
+ *
157
+ * Kodpoint disahkan empirik daripada dataset, bukan diandaikan.
158
+ */
159
+ export function imbuhAkhiranAn(jawiDasar, rumiDasar) {
160
+ if (!jawiDasar) return null
161
+ if (jawiDasar.endsWith(WAU)) return { jawi: jawiDasar + ALIF + NUN, rujukan: 'Pedoman DBP §13.10(i)' }
162
+ if (jawiDasar.endsWith(ALIF)) return { jawi: jawiDasar + HAMZAH + NUN, rujukan: 'Pedoman DBP §13.10(ii)' }
163
+ // Kata dasar berakhir vokal [a] dalam Rumi tetapi tanpa alif dalam Jawi:
164
+ // alif ditambah dahulu, menurut Catatan §13.10(ii).
165
+ if (/a$/i.test(rumiDasar ?? '')) {
166
+ return { jawi: jawiDasar + ALIF + HAMZAH + NUN, rujukan: 'Pedoman DBP §13.10(ii) Catatan' }
167
+ }
168
+ return { jawi: jawiDasar + NUN, rujukan: 'Pedoman DBP §13.10(iii)' }
169
+ }
170
+
171
+ /**
172
+ * §13.11 — Tiga cara mengeja akhiran -i:
173
+ * (i) selepas alif -> hamzah-ya
174
+ * (ii) selepas wau -> hamzah-ya
175
+ * (iii) selain itu -> ya
176
+ */
177
+ export function imbuhAkhiranI(jawiDasar, rumiDasar) {
178
+ if (!jawiDasar) return null
179
+ if (jawiDasar.endsWith(ALIF) || jawiDasar.endsWith(WAU)) {
180
+ return { jawi: jawiDasar + HAMZAH + YA, rujukan: 'Pedoman DBP §13.11(i)' }
181
+ }
182
+ if (/a$/i.test(rumiDasar ?? '')) {
183
+ return { jawi: jawiDasar + ALIF + HAMZAH + YA, rujukan: 'Pedoman DBP §13.11(i) Catatan' }
184
+ }
185
+ return { jawi: jawiDasar + YA, rujukan: 'Pedoman DBP §13.11(iii)' }
186
+ }
187
+
188
+ /**
189
+ * §15.1 — Kata ganti singkat ditulis serangkai dengan kata di depannya.
190
+ * ku -> kaf-wau mu -> mim-wau nya -> nya
191
+ * §13.5, §13.6, §13.7 — Awalan di-, se-, ke- ditulis rapat; jika kata dasar
192
+ * bermula dengan alif, hamzah dibubuh di atas alif itu (alif berhamzah).
193
+ */
194
+ export const AKHIRAN_GANTI = new Map([
195
+ ['nya', 'ڽ'],
196
+ ['ku', 'كو'.replace('ك', 'ک')],
197
+ ['mu', 'مو'],
198
+ ])
199
+
200
+ export const AWALAN = new Map([
201
+ ['di', 'د'],
202
+ ['se', 'س'],
203
+ ['ke', 'ک'],
204
+ ])
205
+
206
+ export function imbuhAwalan(awalan, jawiDasar) {
207
+ const huruf = AWALAN.get(String(awalan).toLowerCase())
208
+ if (!huruf || !jawiDasar) return null
209
+ const dasar = jawiDasar.startsWith(ALIF) ? ALIF_HAMZAH + jawiDasar.slice(ALIF.length) : jawiDasar
210
+ return { jawi: huruf + dasar, rujukan: `Pedoman DBP §13.5-13.7 (${awalan}-)` }
211
+ }
212
+
213
+ export function imbuhGanti(ganti, jawiDasar) {
214
+ const huruf = AKHIRAN_GANTI.get(String(ganti).toLowerCase())
215
+ if (!huruf || !jawiDasar) return null
216
+ return { jawi: jawiDasar + huruf, rujukan: 'Pedoman DBP §15.1' }
217
+ }
package/src/tukar.js CHANGED
@@ -11,8 +11,12 @@
11
11
  import { lookup } from './prpm.js'
12
12
  import { normalisasiJawi } from './kamus.js'
13
13
  import { cariDataset, muatDataset } from './dataset.js'
14
- import { cariNama, mungkinNamaKhas } from './nama.js'
14
+ import { cariNama, mungkinAkronim, mungkinNamaKhas } from './nama.js'
15
15
  import {
16
+ imbuhAkhiranAn,
17
+ imbuhAkhiranI,
18
+ imbuhAwalan,
19
+ imbuhGanti,
16
20
  binaKataUlang,
17
21
  bolehCantumSendi,
18
22
  cantumSendi,
@@ -79,6 +83,53 @@ export function calonDasar(kata) {
79
83
  return [...calon]
80
84
  }
81
85
 
86
+ /**
87
+ * Jana ejaan kata berimbuhan menurut peraturan Pedoman.
88
+ *
89
+ * Ejaan kata DASAR tetap dicari dalam kamus; hanya imbuhan yang dijana.
90
+ * Diukur terhadap 66k perkataan sebenar: -mu 98%, -nya 97%, di- 92%,
91
+ * se- 90%, ke- 88%. Kerana itu ia mati secara lalai dan hasilnya sentiasa
92
+ * ditanda.
93
+ */
94
+ async function janaIkutPedoman(kata, cache, opts, gunaDataset) {
95
+ const ejaan = async (dasar) => {
96
+ const rec = await lookup(dasar, cache, opts)
97
+ if (rec?.jawi) return rec.jawi
98
+ if (!gunaDataset) return null
99
+ return (await cariDataset(dasar))?.jawi ?? null
100
+ }
101
+
102
+ for (const [awalan] of AWALAN_URUTAN) {
103
+ if (!kata.startsWith(awalan) || kata.length - awalan.length < 3) continue
104
+ const jawiDasar = await ejaan(kata.slice(awalan.length))
105
+ const hasil = imbuhAwalan(awalan, jawiDasar)
106
+ if (hasil) return hasil
107
+ }
108
+
109
+ for (const ganti of ['nya', 'mu', 'ku']) {
110
+ if (!kata.endsWith(ganti) || kata.length - ganti.length < 3) continue
111
+ const dasar = kata.slice(0, -ganti.length)
112
+ const hasil = imbuhGanti(ganti, await ejaan(dasar))
113
+ if (hasil) return hasil
114
+ }
115
+
116
+ if (kata.endsWith('an') && kata.length > 4) {
117
+ const dasar = kata.slice(0, -2)
118
+ const hasil = imbuhAkhiranAn(await ejaan(dasar), dasar)
119
+ if (hasil) return hasil
120
+ }
121
+
122
+ if (kata.endsWith('i') && kata.length > 3) {
123
+ const dasar = kata.slice(0, -1)
124
+ const hasil = imbuhAkhiranI(await ejaan(dasar), dasar)
125
+ if (hasil) return hasil
126
+ }
127
+
128
+ return null
129
+ }
130
+
131
+ const AWALAN_URUTAN = [['di'], ['se'], ['ke']]
132
+
82
133
  /**
83
134
  * Cari kata dasar yang sah bagi kata berimbuhan.
84
135
  *
@@ -133,7 +184,7 @@ export function petaSongsang(cache, dataset = null) {
133
184
  * Perkataan yang tidak disahkan dibalut penanda, tidak pernah direka.
134
185
  */
135
186
  export async function keJawi(teks, cache, opts = {}) {
136
- const { penanda = ['«', '»'], cubaDasar = true, gunaDataset = true } = opts
187
+ const { penanda = ['«', '»'], cubaDasar = true, gunaDataset = true, janaImbuhan = false } = opts
137
188
  const belumDisahkan = []
138
189
  const terbitanSah = []
139
190
  const dariDataset = []
@@ -142,6 +193,8 @@ export async function keJawi(teks, cache, opts = {}) {
142
193
  const dariNama = []
143
194
  const namaKhas = []
144
195
  const kataUlang = []
196
+ const dijanaPedoman = []
197
+ const akronim = []
145
198
  const bahagian = teks.split(PECAH_RUMI)
146
199
  const keluar = []
147
200
 
@@ -182,6 +235,18 @@ export async function keJawi(teks, cache, opts = {}) {
182
235
  }
183
236
  }
184
237
 
238
+ // Penjanaan imbuhan menurut Pedoman. MATI secara lalai: diukur 88-98%
239
+ // tepat terhadap 66k perkataan sebenar, dan pakej ini tidak menjana
240
+ // ejaan yang mungkin salah tanpa kebenaran eksplisit.
241
+ if (janaImbuhan) {
242
+ const j = await janaIkutPedoman(cebis.toLowerCase(), cache, opts, gunaDataset)
243
+ if (j) {
244
+ dijanaPedoman.push({ kata: cebis, jawi: j.jawi, rujukan: j.rujukan })
245
+ keluar.push(penanda[0] + j.jawi + penanda[1])
246
+ continue
247
+ }
248
+ }
249
+
185
250
  // Nama khas yang pengguna sudah sahkan sendiri.
186
251
  const nm = await cariNama(cebis)
187
252
  if (nm?.jawi) {
@@ -204,7 +269,11 @@ export async function keJawi(teks, cache, opts = {}) {
204
269
  // Nama khas yang belum disahkan bukan salah eja. Ia dilaporkan
205
270
  // berasingan supaya pengguna tahu apa yang perlu disahkan sekali, dan
206
271
  // bukan mengulang menyemak ejaan yang memang betul.
207
- if (mungkinNamaKhas(cebis, bahagian[i - 1])) namaKhas.push(cebis)
272
+ // §19: akronim ialah kategori tersendiri. Ia bukan salah eja dan bukan
273
+ // nama orang, jadi mencampurkannya dengan dua-dua itu menyembunyikan
274
+ // apa yang benar-benar perlu disemak.
275
+ if (mungkinAkronim(cebis)) akronim.push(cebis)
276
+ else if (mungkinNamaKhas(cebis, bahagian[i - 1])) namaKhas.push(cebis)
208
277
  else belumDisahkan.push(cebis)
209
278
  keluar.push(penanda[0] + cebis + penanda[1])
210
279
  }
@@ -234,7 +303,9 @@ export async function keJawi(teks, cache, opts = {}) {
234
303
  dariDataset,
235
304
  dariNama,
236
305
  kataUlang,
306
+ dijanaPedoman,
237
307
  namaKhas: [...new Set(namaKhas)],
308
+ akronim: [...new Set(akronim)],
238
309
  adaEntriTanpaJawi: [...new Set(adaEntriTanpaJawi)],
239
310
  belumDisahkan: [...new Set(belumDisahkan)],
240
311
  terbitanSah,