@capsiynau/intelligence 0.4.0 → 0.5.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 CHANGED
@@ -6,6 +6,41 @@ All notable changes to `@capsiynau/intelligence` go here. Format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-07-25
10
+
11
+ Additive. No existing import path changes shape, so 0.4.x consumers can
12
+ upgrade without touching their code.
13
+
14
+ ### Added
15
+
16
+ - **`dialect` subpath.** The Welsh regional lexicon as data rather than as a
17
+ prompt. `applyDialect(text, region)` rewrites off-region word choices
18
+ deterministically and case-preservingly ("Rŵan" to "Nawr", "mas" to
19
+ "allan"); `checkDialect(text, region)` reports the same findings WITHOUT
20
+ changing the text, so a reviewer gets evidence rather than a silent
21
+ rewrite; `listDialectTerms(region)` exposes the table for a management
22
+ screen or a sign-off export. `region` accepts the same values as
23
+ `getDialectSystemPrompt`, with `broadcast` and `formal` both mapping onto
24
+ the neutral lexicon.
25
+
26
+ This does not replace `getDialectSystemPrompt`. Register ("avoid
27
+ colloquialisms") has no deterministic transform and stays a prompt
28
+ concern; word choice does have one and should not be left to a model that
29
+ applies it unevenly and unverifiably. The two compose.
30
+
31
+ Deliberate limits, all pinned by tests: mutated surface forms are not
32
+ matched (needs syntactic context, as in `welsh/mutations.js`), pronoun and
33
+ copula forms are excluded because "mae o'r ardal" would be corrupted, and
34
+ where both regional forms are marked with no neutral standard
35
+ (taid/tad-cu) a neutral target leaves the text alone rather than picking a
36
+ side. Word boundaries use lookarounds over a Welsh letter class, not `\b`,
37
+ which treats the diacritic in "rŵan" as a word boundary and matches inside
38
+ the word.
39
+
40
+ **The seed table is not natively reviewed.** `DIALECT_TERMS_STATUS`
41
+ reports `reviewed: false` in code, and consumers should treat output as a
42
+ draft until that changes.
43
+
9
44
  ## [0.4.0] - 2026-07-22
10
45
 
11
46
  First release to ship TypeScript declarations. Everything here is
package/package.json CHANGED
@@ -1,23 +1,24 @@
1
1
  {
2
2
  "name": "@capsiynau/intelligence",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Bilingual (Welsh + English) intelligence layer: text normalisation, mutation handling, digraph splitting, spellcheck primitives, and glossary/correction utilities. Pure functions, runs in Node or browser.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Aled Parry <aled@aledparry.com>",
8
- "homepage": "https://github.com/aledprysparry/Capsiynau_V2_ClaudeAI/tree/main/packages/intelligence",
8
+ "homepage": "https://github.com/aledprysparry/Capsiynau.com/tree/main/packages/intelligence",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/aledprysparry/Capsiynau_V2_ClaudeAI.git",
11
+ "url": "git+https://github.com/aledprysparry/Capsiynau.com.git",
12
12
  "directory": "packages/intelligence"
13
13
  },
14
14
  "bugs": {
15
- "url": "https://github.com/aledprysparry/Capsiynau_V2_ClaudeAI/issues"
15
+ "url": "https://github.com/aledprysparry/Capsiynau.com/issues"
16
16
  },
17
17
  "keywords": [
18
18
  "welsh",
19
19
  "cymraeg",
20
20
  "bilingual",
21
+ "dialect",
21
22
  "spellcheck",
22
23
  "mutation",
23
24
  "digraph",
@@ -56,6 +57,10 @@
56
57
  "./speakers": {
57
58
  "types": "./types/speakers/index.d.ts",
58
59
  "default": "./src/speakers/index.js"
60
+ },
61
+ "./dialect": {
62
+ "types": "./types/dialect/index.d.ts",
63
+ "default": "./src/dialect/index.js"
59
64
  }
60
65
  },
61
66
  "files": [
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @capsiynau/intelligence/dialect
3
+ *
4
+ * The Welsh regional LEXICON as data, not as a prompt.
5
+ *
6
+ * `welsh/normaliser.js` already exposes `getDialectSystemPrompt()`, which
7
+ * returns a register instruction ("avoid colloquialisms", "maintain formal
8
+ * third-person constructions"). Register genuinely is a prompt concern -
9
+ * there is no deterministic transform for "sound less formal".
10
+ *
11
+ * Word choice is not. "rŵan" versus "nawr" is a dictionary lookup, and a
12
+ * dictionary applied in code is right every time, costs nothing, and can be
13
+ * shown to a reviewer. Asked of a model in a system prompt it is applied
14
+ * unevenly and cannot be verified afterwards. So this module owns the lexical
15
+ * half and the prompt fragment keeps the register half. They compose; neither
16
+ * replaces the other.
17
+ *
18
+ * What this module DOES:
19
+ * - `checkDialect(text, region)` - report off-region terms, mutating nothing
20
+ * - `applyDialect(text, region)` - deterministic case-preserving substitution
21
+ * - `listDialectTerms(region)` - expose the table for review UI and export
22
+ *
23
+ * What this module does NOT do:
24
+ * - Decide register, tone or formality. That is the prompt fragment's job.
25
+ * - Match MUTATED surface forms. "taid" after a possessive is "daid", and
26
+ * recognising that needs syntactic context, exactly as documented in
27
+ * `welsh/mutations.js`. Callers normalise first or accept the miss.
28
+ * - Touch pronouns or copula forms. See EXCLUDED_TERMS below; the false
29
+ * positives are worse than the misses.
30
+ * - Infer a region from text. The caller declares it, per the house rule
31
+ * that source language and variety are never auto-detected.
32
+ *
33
+ * Pure JS, no external deps, no I/O. Runs in Node or the browser.
34
+ */
35
+
36
+ /**
37
+ * Provenance for the seed table, per the "evidence beats confidence" rule:
38
+ * state what this data IS rather than implying an authority it has not earned.
39
+ * These are widely attested north/south pairs, but they are a starting seed
40
+ * compiled alongside the existing dialect prompt fragments - NOT a reviewed
41
+ * lexicographic source. A native reviewer signs this off before any consumer
42
+ * treats `applyDialect` output as publishable without a human read.
43
+ */
44
+ export const DIALECT_TERMS_STATUS = {
45
+ reviewed: false,
46
+ reviewedBy: null,
47
+ seededOn: '2026-07-25',
48
+ note: 'Seed table. Needs native review before authoritative use.',
49
+ }
50
+
51
+ // Welsh letters, including the diacritics JS `\w` and `\b` do not cover. `\b`
52
+ // is unusable here: to `\b` the `ŵ` in `rŵan` is a NON-word character, so
53
+ // /\brŵan\b/ finds a boundary in the middle of the word and matches inside
54
+ // longer tokens. Lookarounds over this class are the fix.
55
+ const WELSH_LETTERS = "A-Za-zÂÊÎÔÛŴŶâêîôûŵŷÁÉÍÓÚáéíóúÀÈÌÒÙàèìòùÄËÏÖÜäëïöü'"
56
+
57
+ /**
58
+ * Seed lexicon. `neutral` is the form a broadcast or formal register would
59
+ * use; `null` means BOTH forms are regionally marked and there is no neutral
60
+ * standard, so a neutral target leaves the text alone rather than inventing
61
+ * one. Saying "no neutral form exists" is more useful than picking a side.
62
+ */
63
+ export const DIALECT_TERMS = [
64
+ { id: 'now', north: 'rŵan', south: 'nawr', neutral: 'nawr', gloss: 'now' },
65
+ { id: 'with', north: 'efo', south: 'gyda', neutral: 'gyda', gloss: 'with' },
66
+ { id: 'milk', north: 'llefrith', south: 'llaeth', neutral: 'llaeth', gloss: 'milk' },
67
+ { id: 'want', north: 'isio', south: 'moyn', neutral: 'eisiau', gloss: 'to want' },
68
+ { id: 'out', north: 'allan', south: 'mas', neutral: 'allan', gloss: 'out' },
69
+ { id: 'money', north: 'pres', south: 'arian', neutral: 'arian', gloss: 'money' },
70
+ { id: 'to-come', north: 'dŵad', south: 'dod', neutral: 'dod', gloss: 'to come' },
71
+ { id: 'boy', north: 'hogyn', south: 'crwt', neutral: 'bachgen', gloss: 'boy' },
72
+ { id: 'girl', north: 'hogan', south: 'croten', neutral: 'merch', gloss: 'girl' },
73
+ { id: 'cuppa', north: 'panad', south: 'dishgled', neutral: 'paned', gloss: 'a cuppa' },
74
+ // Both forms regionally marked, no neutral standard.
75
+ { id: 'grandfather', north: 'taid', south: 'tad-cu', neutral: null, gloss: 'grandfather' },
76
+ { id: 'grandmother', north: 'nain', south: 'mam-gu', neutral: null, gloss: 'grandmother' },
77
+ ]
78
+
79
+ /**
80
+ * Deliberately NOT in the table, with the reason. Kept in code because the
81
+ * next person to extend this will reach for exactly these, and the reason
82
+ * they are absent is not self-evident.
83
+ */
84
+ export const EXCLUDED_TERMS = [
85
+ {
86
+ id: 'pronoun-o-e',
87
+ reason:
88
+ 'North "mae o" / south "mae e". The north form collides with the ' +
89
+ 'preposition "o" (from): substituting in "mae o\'r ardal" (is from the ' +
90
+ 'area) produces nonsense. Needs part-of-speech context.',
91
+ },
92
+ {
93
+ id: 'up-lan',
94
+ reason:
95
+ 'South "lan" (up) collides with "glan" soft-mutated to "lan" (shore), ' +
96
+ 'so "ar lan y môr" would be rewritten. Needs mutation awareness.',
97
+ },
98
+ {
99
+ id: 'watching-gwatsiad',
100
+ reason:
101
+ 'Appears in the north prompt fragment but is an anglicism, not a ' +
102
+ 'north/south pair. Codifying it as regional Welsh would be wrong.',
103
+ },
104
+ ]
105
+
106
+ // `broadcast` and `formal` both want the unmarked standard form, so they map
107
+ // onto the same lexical target. They still differ in REGISTER, which is the
108
+ // prompt fragment's concern, not this module's.
109
+ const REGION_ALIASES = {
110
+ north: 'north',
111
+ south: 'south',
112
+ neutral: 'neutral',
113
+ broadcast: 'neutral',
114
+ formal: 'neutral',
115
+ }
116
+
117
+ /** @param {string} region @returns {'north'|'south'|'neutral'} */
118
+ function resolveRegion(region) {
119
+ return REGION_ALIASES[region] || 'neutral'
120
+ }
121
+
122
+ function escapeRegExp(s) {
123
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
124
+ }
125
+
126
+ /**
127
+ * Whole-term matcher. Multi-word entries ("tad-cu", and any future entry with
128
+ * a space) tolerate variable internal whitespace.
129
+ */
130
+ function matcherFor(term) {
131
+ const body = escapeRegExp(term).replace(/\s+/g, '\\s+')
132
+ return new RegExp(`(?<![${WELSH_LETTERS}])(${body})(?![${WELSH_LETTERS}])`, 'gi')
133
+ }
134
+
135
+ /**
136
+ * Carry the observed capitalisation onto the replacement, so "Rŵan" becomes
137
+ * "Nawr" and "RŴAN" becomes "NAWR". Mixed case falls through to the table's
138
+ * own casing rather than guessing.
139
+ */
140
+ function matchCase(observed, replacement) {
141
+ if (observed === observed.toUpperCase() && observed !== observed.toLowerCase()) {
142
+ return replacement.toUpperCase()
143
+ }
144
+ if (observed[0] === observed[0].toUpperCase()) {
145
+ return replacement[0].toUpperCase() + replacement.slice(1)
146
+ }
147
+ return replacement
148
+ }
149
+
150
+ /**
151
+ * The entries that carry a usable target for `region`, each with the forms
152
+ * that should be replaced by it. Entries with no neutral form are skipped for
153
+ * a neutral target rather than forced onto one side.
154
+ */
155
+ function activeEntries(region) {
156
+ const resolved = resolveRegion(region)
157
+ const out = []
158
+ for (const entry of DIALECT_TERMS) {
159
+ const preferred = entry[resolved]
160
+ if (!preferred) continue
161
+ // Dedupe by lowercase form. Two regions often share a form (south and
162
+ // neutral are both "gyda"), and without this the same occurrence is
163
+ // reported twice by checkDialect.
164
+ const seen = new Set()
165
+ const alternatives = []
166
+ for (const r of ['north', 'south', 'neutral']) {
167
+ const form = r === resolved ? null : entry[r]
168
+ if (!form) continue
169
+ const key = form.toLowerCase()
170
+ if (key === preferred.toLowerCase() || seen.has(key)) continue
171
+ seen.add(key)
172
+ alternatives.push(form)
173
+ }
174
+ if (alternatives.length) out.push({ entry, preferred, alternatives })
175
+ }
176
+ return out
177
+ }
178
+
179
+ /**
180
+ * The lexicon as a reviewable list. This is what a "your preferred terms"
181
+ * management screen renders, and what a workspace exports for sign-off.
182
+ *
183
+ * @param {'north'|'south'|'neutral'|'broadcast'|'formal'} region
184
+ * @returns {Array<{id:string, gloss:string, preferred:string, alternatives:string[]}>}
185
+ */
186
+ export function listDialectTerms(region) {
187
+ return activeEntries(region).map(({ entry, preferred, alternatives }) => ({
188
+ id: entry.id,
189
+ gloss: entry.gloss,
190
+ preferred,
191
+ alternatives,
192
+ }))
193
+ }
194
+
195
+ /**
196
+ * Report off-region terms WITHOUT changing the text. This is the validator
197
+ * half: it produces evidence a reviewer can act on, which is what makes the
198
+ * lexical layer auditable rather than merely applied.
199
+ *
200
+ * @param {string} text
201
+ * @param {'north'|'south'|'neutral'|'broadcast'|'formal'} region
202
+ * @returns {Array<{id:string, found:string, expected:string, index:number}>}
203
+ * Findings in document order. Empty array for empty input.
204
+ */
205
+ export function checkDialect(text, region) {
206
+ if (!text) return []
207
+ const findings = []
208
+ for (const { entry, preferred, alternatives } of activeEntries(region)) {
209
+ for (const alternative of alternatives) {
210
+ const matcher = matcherFor(alternative)
211
+ let hit
212
+ while ((hit = matcher.exec(text)) !== null) {
213
+ findings.push({ id: entry.id, found: hit[1], expected: preferred, index: hit.index })
214
+ }
215
+ }
216
+ }
217
+ return findings.sort((a, b) => a.index - b.index)
218
+ }
219
+
220
+ /**
221
+ * Rewrite off-region terms to the target region's form. Deterministic and
222
+ * case-preserving; returns the input unchanged when there is nothing to do.
223
+ *
224
+ * Substitution is whole-term only and mutation-blind, so it is safe to run on
225
+ * mixed Welsh/English text: no entry in the table is an English word.
226
+ *
227
+ * @param {string} text
228
+ * @param {'north'|'south'|'neutral'|'broadcast'|'formal'} region
229
+ * @returns {string}
230
+ */
231
+ export function applyDialect(text, region) {
232
+ if (!text) return ''
233
+ let out = text
234
+ for (const { preferred, alternatives } of activeEntries(region)) {
235
+ for (const alternative of alternatives) {
236
+ out = out.replace(matcherFor(alternative), (m) => matchCase(m, preferred))
237
+ }
238
+ }
239
+ return out
240
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The lexicon as a reviewable list. This is what a "your preferred terms"
3
+ * management screen renders, and what a workspace exports for sign-off.
4
+ *
5
+ * @param {'north'|'south'|'neutral'|'broadcast'|'formal'} region
6
+ * @returns {Array<{id:string, gloss:string, preferred:string, alternatives:string[]}>}
7
+ */
8
+ export function listDialectTerms(region: "north" | "south" | "neutral" | "broadcast" | "formal"): Array<{
9
+ id: string;
10
+ gloss: string;
11
+ preferred: string;
12
+ alternatives: string[];
13
+ }>;
14
+ /**
15
+ * Report off-region terms WITHOUT changing the text. This is the validator
16
+ * half: it produces evidence a reviewer can act on, which is what makes the
17
+ * lexical layer auditable rather than merely applied.
18
+ *
19
+ * @param {string} text
20
+ * @param {'north'|'south'|'neutral'|'broadcast'|'formal'} region
21
+ * @returns {Array<{id:string, found:string, expected:string, index:number}>}
22
+ * Findings in document order. Empty array for empty input.
23
+ */
24
+ export function checkDialect(text: string, region: "north" | "south" | "neutral" | "broadcast" | "formal"): Array<{
25
+ id: string;
26
+ found: string;
27
+ expected: string;
28
+ index: number;
29
+ }>;
30
+ /**
31
+ * Rewrite off-region terms to the target region's form. Deterministic and
32
+ * case-preserving; returns the input unchanged when there is nothing to do.
33
+ *
34
+ * Substitution is whole-term only and mutation-blind, so it is safe to run on
35
+ * mixed Welsh/English text: no entry in the table is an English word.
36
+ *
37
+ * @param {string} text
38
+ * @param {'north'|'south'|'neutral'|'broadcast'|'formal'} region
39
+ * @returns {string}
40
+ */
41
+ export function applyDialect(text: string, region: "north" | "south" | "neutral" | "broadcast" | "formal"): string;
42
+ export namespace DIALECT_TERMS_STATUS {
43
+ let reviewed: boolean;
44
+ let reviewedBy: any;
45
+ let seededOn: string;
46
+ let note: string;
47
+ }
48
+ /**
49
+ * Seed lexicon. `neutral` is the form a broadcast or formal register would
50
+ * use; `null` means BOTH forms are regionally marked and there is no neutral
51
+ * standard, so a neutral target leaves the text alone rather than inventing
52
+ * one. Saying "no neutral form exists" is more useful than picking a side.
53
+ */
54
+ export const DIALECT_TERMS: {
55
+ id: string;
56
+ north: string;
57
+ south: string;
58
+ neutral: string;
59
+ gloss: string;
60
+ }[];
61
+ /**
62
+ * Deliberately NOT in the table, with the reason. Kept in code because the
63
+ * next person to extend this will reach for exactly these, and the reason
64
+ * they are absent is not self-evident.
65
+ */
66
+ export const EXCLUDED_TERMS: {
67
+ id: string;
68
+ reason: string;
69
+ }[];