@capsiynau/intelligence 0.2.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/CHANGELOG.md +112 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SCHEMA.md +75 -0
- package/package.json +64 -0
- package/src/corrections/index.js +6 -0
- package/src/corrections/tracker.js +279 -0
- package/src/glossary/README.md +45 -0
- package/src/glossary/import.js +199 -0
- package/src/glossary/index.js +41 -0
- package/src/glossary/parse.js +133 -0
- package/src/glossary/persist.js +160 -0
- package/src/glossary/prompts.js +122 -0
- package/src/glossary/tools.js +7 -0
- package/src/glossary/url.js +19 -0
- package/src/index.js +11 -0
- package/src/spellcheck/core.js +154 -0
- package/src/spellcheck/index.js +8 -0
- package/src/welsh/digraphs.js +184 -0
- package/src/welsh/index.js +8 -0
- package/src/welsh/mutations.js +252 -0
- package/src/welsh/normaliser.js +93 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @capsiynau/intelligence/welsh/digraphs
|
|
3
|
+
*
|
|
4
|
+
* Welsh digraph handling. In the Welsh alphabet, these eight digraphs
|
|
5
|
+
* are SINGLE letters and must be treated as one unit for:
|
|
6
|
+
* - character iteration
|
|
7
|
+
* - cursor movement
|
|
8
|
+
* - word length
|
|
9
|
+
* - alphabetical sort
|
|
10
|
+
* - reversal (for anagrams etc.)
|
|
11
|
+
*
|
|
12
|
+
* The eight digraphs (canonical order in the Welsh alphabet):
|
|
13
|
+
* ch dd ff ng ll ph rh th
|
|
14
|
+
*
|
|
15
|
+
* Note: `ng` is the most context-sensitive. In some words it represents
|
|
16
|
+
* a single sound (canu's "ng" is /ŋ/) but in compound words it can be
|
|
17
|
+
* `n` + `g` separately (e.g. "Bangor" is BAN+GOR phonetically but
|
|
18
|
+
* conventionally still written without splitting). For purely
|
|
19
|
+
* orthographic operations (length, indexing, anagrams), we treat every
|
|
20
|
+
* occurrence as a single digraph. Callers needing phonetic accuracy
|
|
21
|
+
* must layer their own lexicon lookup on top.
|
|
22
|
+
*
|
|
23
|
+
* Pure JS, no external deps.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The eight Welsh digraphs in their canonical alphabetical order.
|
|
28
|
+
* @type {readonly string[]}
|
|
29
|
+
*/
|
|
30
|
+
export const WELSH_DIGRAPHS = Object.freeze([
|
|
31
|
+
'ch', 'dd', 'ff', 'ng', 'll', 'ph', 'rh', 'th',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Map of digraph -> alphabetical position (0-indexed within the digraph set).
|
|
36
|
+
* For full Welsh-alphabet collation, layer this on top of the single-letter
|
|
37
|
+
* position map in `WELSH_ALPHABET_ORDER` below.
|
|
38
|
+
*/
|
|
39
|
+
export const DIGRAPH_INDEX = Object.freeze(
|
|
40
|
+
Object.fromEntries(WELSH_DIGRAPHS.map((d, i) => [d, i]))
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The canonical Welsh alphabet in collation order. Note the digraphs
|
|
45
|
+
* follow their leading letter: `c` then `ch`, `d` then `dd`, etc.
|
|
46
|
+
* Letters not in Welsh (`k`, `q`, `v`, `x`, `z`) are omitted; loan words
|
|
47
|
+
* containing them are conventionally sorted as if they were English.
|
|
48
|
+
*
|
|
49
|
+
* Source: Geiriadur Prifysgol Cymru collation rules.
|
|
50
|
+
* @type {readonly string[]}
|
|
51
|
+
*/
|
|
52
|
+
export const WELSH_ALPHABET_ORDER = Object.freeze([
|
|
53
|
+
'a', 'b', 'c', 'ch', 'd', 'dd', 'e', 'f', 'ff', 'g', 'ng',
|
|
54
|
+
'h', 'i', 'j', 'l', 'll', 'm', 'n', 'o', 'p', 'ph', 'r', 'rh',
|
|
55
|
+
's', 't', 'th', 'u', 'w', 'y',
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Split a Welsh word into its constituent letters, treating digraphs
|
|
60
|
+
* as single units. Case-preserving.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* splitIntoLetters('llwybr') // ['ll', 'w', 'y', 'b', 'r']
|
|
64
|
+
* splitIntoLetters('CHWARAE') // ['CH', 'W', 'A', 'R', 'A', 'E']
|
|
65
|
+
* splitIntoLetters('rhag-') // ['rh', 'a', 'g', '-']
|
|
66
|
+
*
|
|
67
|
+
* @param {string} word
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
export function splitIntoLetters(word) {
|
|
71
|
+
if (typeof word !== 'string') return []
|
|
72
|
+
const out = []
|
|
73
|
+
for (let i = 0; i < word.length; i++) {
|
|
74
|
+
const two = word.slice(i, i + 2).toLowerCase()
|
|
75
|
+
if (DIGRAPH_INDEX[two] !== undefined) {
|
|
76
|
+
out.push(word.slice(i, i + 2))
|
|
77
|
+
i++
|
|
78
|
+
} else {
|
|
79
|
+
out.push(word[i])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Welsh-aware character count: a digraph counts as ONE letter.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* welshLength('llwybr') // 5 (ll-w-y-b-r), NOT 6
|
|
90
|
+
* welshLength('chwarae') // 6 (ch-w-a-r-a-e)
|
|
91
|
+
* welshLength('Eisteddfod') // 9 (e-i-s-t-e-dd-f-o-d), NOT 10
|
|
92
|
+
*
|
|
93
|
+
* @param {string} word
|
|
94
|
+
* @returns {number}
|
|
95
|
+
*/
|
|
96
|
+
export function welshLength(word) {
|
|
97
|
+
return splitIntoLetters(word).length
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reverse a Welsh word preserving digraphs (used by anagram /
|
|
102
|
+
* caption-style transforms that mirror text).
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* welshReverse('llwybr') // 'rbywll' (NOT 'rbywll' broken to 'rbyw ll')
|
|
106
|
+
*
|
|
107
|
+
* @param {string} word
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function welshReverse(word) {
|
|
111
|
+
return splitIntoLetters(word).reverse().join('')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Welsh-collation compare function (sort comparator). Treats digraphs
|
|
116
|
+
* as their own collation slot, so 'cath' sorts before 'chwarae'
|
|
117
|
+
* (because `c` < `ch` < `d`).
|
|
118
|
+
*
|
|
119
|
+
* @param {string} a
|
|
120
|
+
* @param {string} b
|
|
121
|
+
* @returns {number}
|
|
122
|
+
*/
|
|
123
|
+
const POSITION_OF = Object.freeze(
|
|
124
|
+
Object.fromEntries(WELSH_ALPHABET_ORDER.map((l, i) => [l, i]))
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Strip combining diacritics so 'â' collates as 'a', 'ŵ' as 'w', etc.
|
|
129
|
+
* Welsh dictionaries treat accented vowels as the same slot as their
|
|
130
|
+
* base letter — `âl` sorts among the `a...` entries, not after `z`.
|
|
131
|
+
* Codex P2 on PR #454: without this, every accented form fell through
|
|
132
|
+
* to charCodeAt+1000 and sorted after the full alphabet.
|
|
133
|
+
*/
|
|
134
|
+
function stripAccents(letter) {
|
|
135
|
+
return letter.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function positionFor(letter) {
|
|
139
|
+
const direct = POSITION_OF[letter]
|
|
140
|
+
if (direct !== undefined) return direct
|
|
141
|
+
const stripped = stripAccents(letter)
|
|
142
|
+
if (stripped !== letter) {
|
|
143
|
+
const viaBase = POSITION_OF[stripped] ?? POSITION_OF[stripped[0]]
|
|
144
|
+
if (viaBase !== undefined) return viaBase
|
|
145
|
+
}
|
|
146
|
+
const head = POSITION_OF[letter[0]]
|
|
147
|
+
if (head !== undefined) return head
|
|
148
|
+
return letter.charCodeAt(0) + 1000
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function welshCompare(a, b) {
|
|
152
|
+
const la = splitIntoLetters(String(a).toLowerCase())
|
|
153
|
+
const lb = splitIntoLetters(String(b).toLowerCase())
|
|
154
|
+
const n = Math.min(la.length, lb.length)
|
|
155
|
+
for (let i = 0; i < n; i++) {
|
|
156
|
+
const pa = positionFor(la[i])
|
|
157
|
+
const pb = positionFor(lb[i])
|
|
158
|
+
if (pa !== pb) return pa - pb
|
|
159
|
+
}
|
|
160
|
+
return la.length - lb.length
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if a string position lies on a digraph boundary. Useful for
|
|
165
|
+
* cursor / selection logic: when the cursor sits after `l` in `llwybr`,
|
|
166
|
+
* a single backspace should remove BOTH letters of `ll`, not just one.
|
|
167
|
+
*
|
|
168
|
+
* Returns `true` if the character at `index` is the second half of a
|
|
169
|
+
* digraph (i.e. the digraph started at `index - 1`).
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* isDigraphContinuation('llwybr', 1) // true ('l' completing 'll')
|
|
173
|
+
* isDigraphContinuation('llwybr', 2) // false ('w')
|
|
174
|
+
* isDigraphContinuation('cath', 1) // false ('a')
|
|
175
|
+
*
|
|
176
|
+
* @param {string} word
|
|
177
|
+
* @param {number} index
|
|
178
|
+
* @returns {boolean}
|
|
179
|
+
*/
|
|
180
|
+
export function isDigraphContinuation(word, index) {
|
|
181
|
+
if (typeof word !== 'string' || index <= 0 || index >= word.length) return false
|
|
182
|
+
const pair = word.slice(index - 1, index + 1).toLowerCase()
|
|
183
|
+
return DIGRAPH_INDEX[pair] !== undefined
|
|
184
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @capsiynau/intelligence/welsh/mutations
|
|
3
|
+
*
|
|
4
|
+
* Welsh initial consonant mutations (treigladau dechreuol).
|
|
5
|
+
*
|
|
6
|
+
* Three mutations exist:
|
|
7
|
+
* - Soft (meddal): p->b, t->d, c->g, b->f, d->dd, g->(deleted), m->f, ll->l, rh->r
|
|
8
|
+
* - Nasal (trwynol): p->mh, t->nh, c->ngh, b->m, d->n, g->ng
|
|
9
|
+
* - Aspirate (llaes): p->ph, t->th, c->ch
|
|
10
|
+
*
|
|
11
|
+
* What this module DOES:
|
|
12
|
+
* - Forward mutation: apply a mutation to a root word (used when
|
|
13
|
+
* generating correct grammatical forms after triggers)
|
|
14
|
+
* - Reverse mutation: given an observed mutated word, list candidate
|
|
15
|
+
* root forms (used by spellcheck / dictionary lookup)
|
|
16
|
+
* - Mutation classification: identify which mutation a surface form
|
|
17
|
+
* could be the result of
|
|
18
|
+
*
|
|
19
|
+
* What this module does NOT do:
|
|
20
|
+
* - Decide WHETHER to mutate. That requires syntactic context (the
|
|
21
|
+
* trigger word: 'i', 'a', 'yn', 'fy', possessive pronouns, feminine
|
|
22
|
+
* singular nouns after 'y', etc.). Triggering rules belong in a
|
|
23
|
+
* higher layer that has access to part-of-speech tags.
|
|
24
|
+
* - Handle compound words or loanwords differently. Caller's
|
|
25
|
+
* responsibility to pre-filter (e.g. 'pizza' should not be treated
|
|
26
|
+
* as a Welsh root).
|
|
27
|
+
*
|
|
28
|
+
* Pure JS, no external deps. Case-preserving on the first letter of
|
|
29
|
+
* the result so 'Tad' -> soft -> 'Dad' (preserving the capital).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const SOFT_FORWARD = {
|
|
33
|
+
p: 'b', t: 'd', c: 'g',
|
|
34
|
+
b: 'f', d: 'dd', g: '',
|
|
35
|
+
m: 'f', ll: 'l', rh: 'r',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NASAL_FORWARD = {
|
|
39
|
+
p: 'mh', t: 'nh', c: 'ngh',
|
|
40
|
+
b: 'm', d: 'n', g: 'ng',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ASPIRATE_FORWARD = {
|
|
44
|
+
p: 'ph', t: 'th', c: 'ch',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The three mutation kinds as a const enum-ish object.
|
|
49
|
+
* @type {Readonly<{soft:'soft', nasal:'nasal', aspirate:'aspirate'}>}
|
|
50
|
+
*/
|
|
51
|
+
export const MUTATION = Object.freeze({
|
|
52
|
+
soft: 'soft',
|
|
53
|
+
nasal: 'nasal',
|
|
54
|
+
aspirate: 'aspirate',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const FORWARD_TABLE = {
|
|
58
|
+
soft: SOFT_FORWARD,
|
|
59
|
+
nasal: NASAL_FORWARD,
|
|
60
|
+
aspirate: ASPIRATE_FORWARD,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Reverse table: mutated-prefix -> [{ kind, root }, ...]
|
|
64
|
+
// Built once at module load. Multiple-entry rows mean a surface form
|
|
65
|
+
// is ambiguous (e.g. 'f' could be soft-mutated 'b' or soft-mutated 'm').
|
|
66
|
+
const REVERSE_TABLE = buildReverseTable()
|
|
67
|
+
function buildReverseTable() {
|
|
68
|
+
/** @type {Record<string, {kind:string,rootPrefix:string}[]>} */
|
|
69
|
+
const out = {}
|
|
70
|
+
for (const [kind, table] of Object.entries(FORWARD_TABLE)) {
|
|
71
|
+
for (const [root, mutated] of Object.entries(table)) {
|
|
72
|
+
if (mutated === '') continue // soft 'g' -> deletion handled specially
|
|
73
|
+
if (!out[mutated]) out[mutated] = []
|
|
74
|
+
out[mutated].push({ kind, rootPrefix: root })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply a mutation to a Welsh word. Returns the mutated form.
|
|
82
|
+
* Preserves the case of the first character.
|
|
83
|
+
*
|
|
84
|
+
* Soft mutation of `g` is DELETION — the word loses its initial `g`.
|
|
85
|
+
* e.g. 'gardd' -> soft -> 'ardd' (the garden, after 'yr' for feminine).
|
|
86
|
+
*
|
|
87
|
+
* If the word's initial consonant is not subject to the given mutation,
|
|
88
|
+
* returns the word unchanged.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} word
|
|
91
|
+
* @param {'soft'|'nasal'|'aspirate'} kind
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* applyMutation('cath', 'soft') // 'gath'
|
|
96
|
+
* applyMutation('Tad', 'soft') // 'Dad' (case preserved)
|
|
97
|
+
* applyMutation('ci', 'nasal') // 'nghi'
|
|
98
|
+
* applyMutation('Pen', 'aspirate') // 'Phen'
|
|
99
|
+
* applyMutation('gardd', 'soft') // 'ardd' (g-deletion)
|
|
100
|
+
* applyMutation('llaw', 'soft') // 'law'
|
|
101
|
+
* applyMutation('siop', 'soft') // 'siop' (s not subject)
|
|
102
|
+
*/
|
|
103
|
+
export function applyMutation(word, kind) {
|
|
104
|
+
if (typeof word !== 'string' || word.length === 0) return word
|
|
105
|
+
const table = FORWARD_TABLE[kind]
|
|
106
|
+
if (!table) throw new Error(`Unknown mutation kind: ${kind}`)
|
|
107
|
+
|
|
108
|
+
const first = word[0]
|
|
109
|
+
const lower = word.toLowerCase()
|
|
110
|
+
|
|
111
|
+
// Check 2-letter digraphs first (ll, rh) so 'llaw' soft-mutates to
|
|
112
|
+
// 'law' not 'lll' or similar nonsense.
|
|
113
|
+
const two = lower.slice(0, 2)
|
|
114
|
+
if (table[two] !== undefined) {
|
|
115
|
+
const replacement = table[two]
|
|
116
|
+
if (replacement === '') return promoteCase(word.slice(2), first)
|
|
117
|
+
return matchCase(replacement, first) + word.slice(2)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const one = lower[0]
|
|
121
|
+
if (table[one] !== undefined) {
|
|
122
|
+
const replacement = table[one]
|
|
123
|
+
// Codex P2 on PR #455: when soft mutation deletes the leading letter
|
|
124
|
+
// (only g -> '' today), we must promote the NEXT letter to match the
|
|
125
|
+
// deleted letter's case. Otherwise 'Gardd' -> 'ardd' loses the
|
|
126
|
+
// sentence-initial capital.
|
|
127
|
+
if (replacement === '') return promoteCase(word.slice(1), first)
|
|
128
|
+
return matchCase(replacement, first) + word.slice(1)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return word
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Reverse a mutation: given an observed (possibly mutated) word,
|
|
136
|
+
* return all candidate root forms. The caller can intersect this
|
|
137
|
+
* with a dictionary lookup to pick the real root.
|
|
138
|
+
*
|
|
139
|
+
* If `kind` is provided, only candidates from that mutation are
|
|
140
|
+
* returned. If omitted, candidates from all three mutations.
|
|
141
|
+
*
|
|
142
|
+
* The observed word itself is ALWAYS included as the first candidate
|
|
143
|
+
* (it might not actually be mutated).
|
|
144
|
+
*
|
|
145
|
+
* @param {string} word
|
|
146
|
+
* @param {'soft'|'nasal'|'aspirate'} [kind]
|
|
147
|
+
* @returns {{ root: string, kind: string | null }[]}
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* possibleRoots('gath') // [
|
|
151
|
+
* // { root: 'gath', kind: null }, // as-is
|
|
152
|
+
* // { root: 'cath', kind: 'soft' }, // c -> g
|
|
153
|
+
* // ]
|
|
154
|
+
* possibleRoots('phen', 'aspirate') // [
|
|
155
|
+
* // { root: 'phen', kind: null },
|
|
156
|
+
* // { root: 'pen', kind: 'aspirate' },
|
|
157
|
+
* // ]
|
|
158
|
+
*/
|
|
159
|
+
export function possibleRoots(word, kind) {
|
|
160
|
+
if (typeof word !== 'string' || word.length === 0) return []
|
|
161
|
+
const candidates = [{ root: word, kind: null }]
|
|
162
|
+
const lower = word.toLowerCase()
|
|
163
|
+
const first = word[0]
|
|
164
|
+
|
|
165
|
+
// Try 3-letter mutated prefixes (ngh, mh, nh, ng, ph, th, ch, dd)
|
|
166
|
+
// 2-letter mutated prefixes
|
|
167
|
+
// 1-letter mutated prefixes
|
|
168
|
+
for (const len of [3, 2, 1]) {
|
|
169
|
+
const prefix = lower.slice(0, len)
|
|
170
|
+
const matches = REVERSE_TABLE[prefix]
|
|
171
|
+
if (!matches) continue
|
|
172
|
+
for (const { kind: k, rootPrefix } of matches) {
|
|
173
|
+
if (kind && k !== kind) continue
|
|
174
|
+
candidates.push({
|
|
175
|
+
root: matchCase(rootPrefix, first) + word.slice(len),
|
|
176
|
+
kind: k,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Soft 'g' -> deletion case: 'ardd' could be soft-mutated 'gardd'
|
|
182
|
+
// (only relevant when no kind filter, or kind === 'soft')
|
|
183
|
+
if ((!kind || kind === MUTATION.soft) && /^[aeiouwâêîôûŵŷ]/i.test(word)) {
|
|
184
|
+
candidates.push({
|
|
185
|
+
root: (isUpperFirst(word) ? 'G' : 'g') + word,
|
|
186
|
+
kind: MUTATION.soft,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return candidates
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Best-effort classification: does this word LOOK mutated?
|
|
195
|
+
*
|
|
196
|
+
* Returns the mutation kind if the word starts with a sequence
|
|
197
|
+
* characteristic of one specific mutation (e.g. 'ph' / 'th' / 'ch' are
|
|
198
|
+
* aspirate-only; 'mh' / 'nh' / 'ngh' are nasal-only). Returns null if
|
|
199
|
+
* the word either isn't mutated or its surface form is ambiguous.
|
|
200
|
+
*
|
|
201
|
+
* NOT a reliable mutation detector on its own. Use with caller context.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} word
|
|
204
|
+
* @returns {'soft'|'nasal'|'aspirate'|null}
|
|
205
|
+
*/
|
|
206
|
+
export function classifyMutation(word) {
|
|
207
|
+
if (typeof word !== 'string' || word.length === 0) return null
|
|
208
|
+
const lower = word.toLowerCase()
|
|
209
|
+
// Codex P2 on PR #454/#455: `ph/th/ch` are NOT aspirate-only — many
|
|
210
|
+
// native unmutated Welsh lemmas start with these digraphs (chwarae,
|
|
211
|
+
// theatr, pharmacydd, chwerthin, ...). Classifying them as aspirate
|
|
212
|
+
// produces systematic false positives. Returning null is the honest
|
|
213
|
+
// answer; callers that need disambiguation should use possibleRoots()
|
|
214
|
+
// with dictionary intersection.
|
|
215
|
+
// Nasal classifications are kept: `ngh/mh/nh` never start native
|
|
216
|
+
// unmutated Welsh words, so a match really does indicate a mutation.
|
|
217
|
+
if (/^(ngh|mh|nh)/.test(lower)) return MUTATION.nasal
|
|
218
|
+
// Soft-only diagnostics:
|
|
219
|
+
// 'dd' at start is soft of 'd'
|
|
220
|
+
// 'f' at start could be soft of 'b' or 'm' (ambiguous, but soft)
|
|
221
|
+
// 'l' (single, not ll) could be soft of 'll'
|
|
222
|
+
// 'r' (single, not rh) could be soft of 'rh'
|
|
223
|
+
// These overlap with native Welsh words starting with those letters
|
|
224
|
+
// (e.g. 'lle' is a native word, not soft of 'llle'). Returning null
|
|
225
|
+
// here keeps the function honest; use possibleRoots() instead.
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
function isUpperFirst(s) {
|
|
232
|
+
return s[0] && s[0] === s[0].toUpperCase() && s[0] !== s[0].toLowerCase()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function matchCase(replacement, modelChar) {
|
|
236
|
+
if (!replacement) return replacement
|
|
237
|
+
if (modelChar && modelChar === modelChar.toUpperCase() && modelChar !== modelChar.toLowerCase()) {
|
|
238
|
+
return replacement[0].toUpperCase() + replacement.slice(1)
|
|
239
|
+
}
|
|
240
|
+
return replacement
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// When a mutation deletes the leading letter (g -> ''), promote the
|
|
244
|
+
// next character's case to match the deleted letter's case so
|
|
245
|
+
// 'Gardd' -> 'Ardd' instead of 'ardd'.
|
|
246
|
+
function promoteCase(rest, deletedChar) {
|
|
247
|
+
if (!rest) return rest
|
|
248
|
+
if (deletedChar && deletedChar === deletedChar.toUpperCase() && deletedChar !== deletedChar.toLowerCase()) {
|
|
249
|
+
return rest[0].toUpperCase() + rest.slice(1)
|
|
250
|
+
}
|
|
251
|
+
return rest
|
|
252
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @capsiynau/intelligence/welsh/normaliser
|
|
3
|
+
*
|
|
4
|
+
* Shared Welsh text normalisation. Pure JS, no external dependencies.
|
|
5
|
+
*
|
|
6
|
+
* Phase A canonicalisation (2026-05-15, PR #448):
|
|
7
|
+
* Merged successor to two drifted copies that lived at
|
|
8
|
+
* `src/lib/welshNormaliser.js` (frontend) and `api/_lib/welshNormaliser.js`
|
|
9
|
+
* (server). The api/_lib version was correct on every count - this file
|
|
10
|
+
* is a copy of it. The src/lib copy had silently regressed for ~6 weeks:
|
|
11
|
+
* dropped the `formal` dialect prompt, leaked an embedded dev comment
|
|
12
|
+
* into the north-dialect prompt ("...wait, reverse:..."), and missing
|
|
13
|
+
* several contraction rules. Both old paths now shim to here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const APOSTROPHE_VARIANTS = /[\u2018\u2019\u201A\u201B\u0060\u00B4]/g
|
|
17
|
+
const QUOTE_VARIANTS = /[\u201C\u201D\u201E\u201F\u00AB\u00BB]/g
|
|
18
|
+
const ZERO_WIDTH = /[\u200B\u200C\u200D\uFEFF]/g
|
|
19
|
+
|
|
20
|
+
// Deterministic Welsh broken-contraction repairs (ASR artefacts)
|
|
21
|
+
const CONTRACTION_REPAIRS = [
|
|
22
|
+
[/\b(a|o|i|â) r\b/gi, "$1'r"],
|
|
23
|
+
[/\bwedi (u|i|m|n|ch)\b/gi, "wedi'$1"],
|
|
24
|
+
[/\bwedi eu\b/gi, "wedi'u"],
|
|
25
|
+
[/\bwedi ei\b/gi, "wedi'i"],
|
|
26
|
+
[/\b(mae|dyma|dyna|dacw|lle|ble|sydd|oedd|fydd|bydd|roedd|cawn|gallai|allen|bydden) n\b/gi, "$1'n"],
|
|
27
|
+
[/\bdw i n\b/gi, "dw i'n"],
|
|
28
|
+
[/\b(dwi|ti|chi|ni|nhw|fe|hi) n\b/gi, "$1'n"],
|
|
29
|
+
[/\b(e|o|hi|fe|fo) n\b/gi, "$1'n"],
|
|
30
|
+
[/\bdo n\b/gi, "do'n"], // "do'n i ddim" — wasn't
|
|
31
|
+
[/\b(i|a|o) (w|i)\b/gi, "$1'$2"],
|
|
32
|
+
// Stray space before existing apostrophe
|
|
33
|
+
[/\b(\w+) '(\w)/g, "$1'$2"],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalise raw Welsh/English transcript text.
|
|
38
|
+
* Repairs broken contractions, fixes encoding, removes zero-width chars.
|
|
39
|
+
* Safe to apply to English too — repairs are Welsh-specific patterns unlikely
|
|
40
|
+
* to match English text.
|
|
41
|
+
*/
|
|
42
|
+
export function normaliseWelsh(text) {
|
|
43
|
+
if (!text) return ''
|
|
44
|
+
let t = text
|
|
45
|
+
t = t.replace(APOSTROPHE_VARIANTS, "'")
|
|
46
|
+
t = t.replace(QUOTE_VARIANTS, '"')
|
|
47
|
+
t = t.replace(ZERO_WIDTH, '')
|
|
48
|
+
t = t.replace(/ {2,}/g, ' ')
|
|
49
|
+
for (const [pattern, replacement] of CONTRACTION_REPAIRS) {
|
|
50
|
+
t = t.replace(pattern, replacement)
|
|
51
|
+
}
|
|
52
|
+
t = t.replace(/ {2,}/g, ' ')
|
|
53
|
+
return t.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Dialect-specific system prompts for AI calls ──────────────────────────────
|
|
57
|
+
|
|
58
|
+
const DIALECT_PROMPTS = {
|
|
59
|
+
broadcast: `Use standard Welsh broadcast register (Cymraeg Byw / Living Welsh).
|
|
60
|
+
Neutral between north and south — avoid strong regional markers.
|
|
61
|
+
Prefer: "mae e/hi", "nawr", "hefyd", "eisiau".
|
|
62
|
+
Avoid colloquialisms. Maintain formal third-person constructions.`,
|
|
63
|
+
|
|
64
|
+
north: `Use North Welsh (Gogledd Cymru) dialect conventions.
|
|
65
|
+
Prefer: "mae o" (not "mae e"), "rŵan" (not "nawr"), "efo" (not "gyda"),
|
|
66
|
+
"llefrith" (not "llaeth"), "fo" as pronoun, "isio" / "eisio" for "want",
|
|
67
|
+
"'dan ni" for "we are", "gwatsiad" for "watching".
|
|
68
|
+
Maintain natural conversational North Welsh.`,
|
|
69
|
+
|
|
70
|
+
south: `Use South Welsh (De Cymru) dialect conventions.
|
|
71
|
+
Prefer: "mae e" (not "mae o"), "nawr" (not "rŵan"), "gyda" (not "efo"),
|
|
72
|
+
"llaeth" (not "llefrith"), "fe" as pronoun, "moyn" for "want",
|
|
73
|
+
"ni'n" for "we are", "'da ni" for "with us".
|
|
74
|
+
Maintain natural conversational South Welsh.`,
|
|
75
|
+
|
|
76
|
+
formal: `Use formal written Welsh (Cymraeg ffurfiol).
|
|
77
|
+
Apply correct mutations (treigladau) throughout. Use full verb forms rather than
|
|
78
|
+
contractions — "rydw i" not "dwi", "dydw i ddim" not "dw i ddim".
|
|
79
|
+
Avoid colloquialisms and regional markers. Use "gyda" (not "efo"), "nawr" (not "rŵan").
|
|
80
|
+
Suitable for official documents, Senedd proceedings, and public sector publications.`,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get a dialect-specific system prompt fragment for AI calls.
|
|
85
|
+
* @param {'broadcast'|'north'|'south'|'formal'} dialect
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function getDialectSystemPrompt(dialect) {
|
|
89
|
+
return DIALECT_PROMPTS[dialect] || DIALECT_PROMPTS.broadcast
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// CJS-compatible export for use in api/ (Node.js, no bundler)
|
|
93
|
+
if (typeof module !== 'undefined') module.exports = { normaliseWelsh, getDialectSystemPrompt }
|