@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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Parsing helpers for the Glossary Builder engine.
3
+ *
4
+ * extractText pulls plain text out of an Anthropic Messages response.
5
+ * With the web_search tool active, content is a mix of
6
+ * server_tool_use, web_search_tool_result, and text blocks - we want
7
+ * only the text the model produced AFTER the searches resolved.
8
+ *
9
+ * Claude 4.5+ also prepends thinking blocks; .filter / .find is the
10
+ * safe accessor, never content[0].text. (Capsiynau lesson #16, PR
11
+ * #375.)
12
+ */
13
+
14
+ import { normaliseWelsh } from '../welsh/normaliser.js'
15
+
16
+ export function extractText(message) {
17
+ if (!message?.content) return ''
18
+ return message.content
19
+ .filter((b) => b.type === 'text')
20
+ .map((b) => b.text || '')
21
+ .join('\n')
22
+ .trim()
23
+ }
24
+
25
+ /**
26
+ * Defensive JSON parse for the generation phase. The model is told to
27
+ * reply with raw JSON only, but markdown fences and conversational
28
+ * preamble do leak through. Strip what we can, then fall back to the
29
+ * first [...] match. Return null on total failure - never throw.
30
+ */
31
+ export function parseTermsJson(raw) {
32
+ if (!raw) return null
33
+ let s = String(raw).trim()
34
+ s = s.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '')
35
+ try {
36
+ const parsed = JSON.parse(s)
37
+ if (Array.isArray(parsed)) return parsed
38
+ } catch {}
39
+ const m = s.match(/\[[\s\S]*\]/)
40
+ if (m) {
41
+ try {
42
+ const parsed = JSON.parse(m[0])
43
+ if (Array.isArray(parsed)) return parsed
44
+ } catch {}
45
+ }
46
+ return null
47
+ }
48
+
49
+ const WELSH_DIACRITICS = /[âêîôûŵŷÂÊÎÔÛŴŶ]/
50
+
51
+ /**
52
+ * Heuristic guard: any Welsh text longer than 25 chars with ZERO
53
+ * diacritics is suspicious - the model probably stripped them.
54
+ *
55
+ * Not a perfect check (some short Welsh text genuinely has none) but
56
+ * catches the common "model normalised everything to ASCII" failure
57
+ * mode. Substitution detection (ŵ → w, ŷ → y in specific positions)
58
+ * requires a dictionary and is deferred to a future Tier-3 spellcheck
59
+ * integration; not in scope for the wizard.
60
+ */
61
+ export function flagDiacriticStrip(text) {
62
+ if (typeof text !== 'string' || text.length < 25) return false
63
+ return !WELSH_DIACRITICS.test(text)
64
+ }
65
+
66
+ /**
67
+ * Normalise one row from the model's JSON output.
68
+ *
69
+ * In 'en' mode: tolerate naming wobbles ({term} vs {source_term}; etc).
70
+ * In 'cy' mode: same shape, but route Welsh text through normaliseWelsh().
71
+ * In 'bilingual' mode: expect {term_en, term_cy, definition_en,
72
+ * definition_cy, category}; route the *_cy fields through normaliseWelsh().
73
+ *
74
+ * Returns null for entries missing the required fields so the caller
75
+ * can `.map(normaliseTermRow).filter(Boolean)`. Adds a transient
76
+ * `_diacritic_warning` flag the handler can aggregate; it does NOT
77
+ * persist to the DB.
78
+ */
79
+ export function normaliseTermRow(t, opts = {}) {
80
+ const { languageMode = 'en' } = opts
81
+ const rawCategory = t?.category || t?.tag || t?.group
82
+ const category = typeof rawCategory === 'string' ? rawCategory.trim() || null : null
83
+
84
+ if (languageMode === 'bilingual') {
85
+ const termEn = t?.term_en || t?.termEn
86
+ const termCy = t?.term_cy || t?.termCy
87
+ const defEn = t?.definition_en || t?.definitionEn
88
+ const defCy = t?.definition_cy || t?.definitionCy
89
+ const allStrings = [termEn, termCy, defEn, defCy].every((v) => typeof v === 'string' && v.trim())
90
+ if (!allStrings) return null
91
+ const cyTerm = normaliseWelsh(termCy.trim())
92
+ const cyDef = normaliseWelsh(defCy.trim())
93
+ const enTerm = termEn.trim()
94
+ const enDef = defEn.trim()
95
+ return {
96
+ // Canonical key stays EN for dedup + Phase 7 source_term
97
+ // persistence; bilingual columns live alongside.
98
+ source_term: enTerm,
99
+ preferred_term: enTerm,
100
+ definition: enDef,
101
+ term_en: enTerm,
102
+ term_cy: cyTerm,
103
+ definition_en: enDef,
104
+ definition_cy: cyDef,
105
+ category,
106
+ _diacritic_warning: flagDiacriticStrip(cyDef) || flagDiacriticStrip(cyTerm),
107
+ }
108
+ }
109
+
110
+ // 'en' or 'cy' - single-language shape
111
+ const rawTerm = t?.term || t?.source_term || t?.preferred_term
112
+ const rawDef = t?.definition || t?.description || t?.meaning
113
+ if (!rawTerm || !rawDef || typeof rawTerm !== 'string' || typeof rawDef !== 'string') {
114
+ return null
115
+ }
116
+ if (languageMode === 'cy') {
117
+ const cyTerm = normaliseWelsh(rawTerm.trim())
118
+ const cyDef = normaliseWelsh(rawDef.trim())
119
+ return {
120
+ source_term: cyTerm,
121
+ preferred_term: cyTerm,
122
+ definition: cyDef,
123
+ category,
124
+ _diacritic_warning: flagDiacriticStrip(cyDef) || flagDiacriticStrip(cyTerm),
125
+ }
126
+ }
127
+ return {
128
+ source_term: rawTerm.trim(),
129
+ preferred_term: rawTerm.trim(),
130
+ definition: rawDef.trim(),
131
+ category,
132
+ }
133
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Pure helpers for converting Glossary Builder wizard rows into the
3
+ * shapes the `glossaries` + `terms` Supabase tables expect.
4
+ *
5
+ * No Supabase calls here - those live in the view layer
6
+ * (src/components/glossaryBuilder/savePersist.js) so this module
7
+ * stays unit-testable and side-effect-free, mirroring how
8
+ * @capsiynau/intelligence/welsh/normaliser splits pure logic from
9
+ * the API handler.
10
+ */
11
+
12
+ /**
13
+ * Suggest a glossary name based on the source URL. Strips
14
+ * protocol + leading `www.` so the result reads naturally
15
+ * ("Site terms - example.com").
16
+ */
17
+ export function suggestGlossaryName(url) {
18
+ if (typeof url !== 'string' || !url.trim()) return 'Site terms'
19
+ try {
20
+ const u = new URL(url)
21
+ const host = u.hostname.replace(/^www\./, '')
22
+ return `Site terms - ${host}`
23
+ } catch {
24
+ return 'Site terms'
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Map wizard languageMode to glossaries.language_pair. Single-language
30
+ * modes get 'en-GB' or 'cy-GB'; bilingual is 'en-GB,cy-GB' (the
31
+ * existing convention in the schema for multi-language glossaries).
32
+ */
33
+ export function languagePairFor(languageMode) {
34
+ if (languageMode === 'cy') return 'cy-GB'
35
+ if (languageMode === 'bilingual') return 'en-GB,cy-GB'
36
+ return 'en-GB'
37
+ }
38
+
39
+ /**
40
+ * Expand wizard rows into terms-table inserts.
41
+ *
42
+ * Single-language modes produce one row per wizard row.
43
+ * Bilingual produces TWO rows per pair (EN + CY), linked by sharing
44
+ * glossary_id and category, with source_language differing. This uses
45
+ * the schema as designed and keeps the live-relay engine able to
46
+ * query by language without a JOIN.
47
+ *
48
+ * Drops rows missing term or definition silently (the wizard
49
+ * shouldn't let them through, but defence in depth at persistence
50
+ * time means a partial bilingual row contributes only its complete
51
+ * leg rather than aborting the whole save).
52
+ */
53
+ export function expandRowsForDb(wizardRows, opts = {}) {
54
+ if (!Array.isArray(wizardRows)) return []
55
+ const { languageMode = 'en', glossaryId, organisationId, createdBy } = opts
56
+ const base = (extra) => ({
57
+ glossary_id: glossaryId,
58
+ organisation_id: organisationId,
59
+ created_by: createdBy,
60
+ approval_status: 'approved',
61
+ rule_strength: 'soft',
62
+ ...extra,
63
+ })
64
+ const out = []
65
+ for (const t of wizardRows) {
66
+ if (!t) continue
67
+ if (languageMode === 'bilingual') {
68
+ const termEn = (t.term_en || t.source_term || '').trim()
69
+ const termCy = (t.term_cy || '').trim()
70
+ const defEn = (t.definition_en || t.definition || '').trim()
71
+ const defCy = (t.definition_cy || '').trim()
72
+ const category = t.category || null
73
+ if (termEn && defEn) {
74
+ out.push(
75
+ base({
76
+ source_term: termEn,
77
+ preferred_term: termEn,
78
+ definition: defEn,
79
+ category,
80
+ source_language: 'en-GB',
81
+ target_language: 'en-GB',
82
+ }),
83
+ )
84
+ }
85
+ if (termCy && defCy) {
86
+ out.push(
87
+ base({
88
+ source_term: termCy,
89
+ preferred_term: termCy,
90
+ definition: defCy,
91
+ category,
92
+ source_language: 'cy-GB',
93
+ target_language: 'cy-GB',
94
+ }),
95
+ )
96
+ }
97
+ } else {
98
+ const term = (t.source_term || t.preferred_term || '').trim()
99
+ const definition = (t.definition || '').trim()
100
+ if (!term || !definition) continue
101
+ const lang = languageMode === 'cy' ? 'cy-GB' : 'en-GB'
102
+ out.push(
103
+ base({
104
+ source_term: term,
105
+ preferred_term: term,
106
+ definition,
107
+ category: t.category || null,
108
+ source_language: lang,
109
+ target_language: lang,
110
+ }),
111
+ )
112
+ }
113
+ }
114
+ return out
115
+ }
116
+
117
+ /**
118
+ * Build the glossaries-table insert payload for a wizard save.
119
+ * Defaults to visibility='shared' (org-wide, not just creator) - the
120
+ * wizard is collaborative tooling, not personal scratch space.
121
+ */
122
+ export function buildGlossaryRow({
123
+ name,
124
+ url,
125
+ languageMode,
126
+ organisationId,
127
+ createdBy,
128
+ visibility = 'shared',
129
+ }) {
130
+ const trimmedName = (name || '').trim()
131
+ return {
132
+ name: trimmedName || suggestGlossaryName(url),
133
+ description: url
134
+ ? `Generated by Glossary Builder from ${url}`
135
+ : 'Generated by Glossary Builder',
136
+ organisation_id: organisationId,
137
+ created_by: createdBy,
138
+ visibility,
139
+ language_pair: languagePairFor(languageMode),
140
+ source_url: url || null,
141
+ is_default: false,
142
+ is_system: false,
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Suggest a versioned name when "Create new version" is chosen on
148
+ * conflict. Bumps an existing (vN) suffix or appends (v2) to a name
149
+ * that lacks one.
150
+ */
151
+ export function suggestVersionedName(existingName) {
152
+ const s = String(existingName || '').trim() || 'Site terms'
153
+ const m = s.match(/^(.*?)\s*\(v(\d+)\)\s*$/)
154
+ if (m) {
155
+ const base = m[1].trim()
156
+ const next = parseInt(m[2], 10) + 1
157
+ return `${base} (v${next})`
158
+ }
159
+ return `${s} (v2)`
160
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * System prompts for the Glossary Builder engine.
3
+ *
4
+ * Three exports:
5
+ * - RESEARCH_SYSTEM: research call (identify industry/products/audience).
6
+ * - buildGenerationSystemPrompt: initial 12-18 term generation.
7
+ * - buildExpandSystemPrompt: Phase 6 - 10 ADDITIONAL terms given the
8
+ * existing list as first-class context. Replaces the prototype's
9
+ * topics-hijack hack where the existing terms were stuffed into
10
+ * the `topics` field as a string instruction.
11
+ *
12
+ * Generation and Expand share the language-branching helper so the
13
+ * two calls produce structurally identical JSON in each language mode.
14
+ */
15
+
16
+ import { getDialectSystemPrompt } from '../welsh/normaliser.js'
17
+
18
+ export const RESEARCH_SYSTEM = `You are helping a SaaS user build a starter glossary for their organisation. You have a web_search tool - use it to look up the URL the user provides and identify the industry, the products / services they offer, and the likely audience. Reply conversationally in 2-3 sentences confirming what you found, then ask who the glossary is for (e.g. "clients", "internal team", "general public", "broadcasters"). Keep the tone warm and direct. Do NOT produce a glossary yet - that comes in a follow-up step.`
19
+
20
+ const DIACRITIC_REMINDER = `Preserve Welsh diacritics (â ê î ô û ŵ ŷ). Fix contractions (e.g. dwi'n, mae'n, wedi'i).`
21
+
22
+ // Shared body for both generation and expand. Opening sentence is
23
+ // caller-supplied so the two prompts can frame themselves correctly
24
+ // ("producing" vs "extending").
25
+ const SEARCH_GUIDANCE = (audienceLine, topicsLine) => `Use the web_search tool to look up real product names, sector jargon, and technical terms FROM the URL (don't invent generic industry words).
26
+
27
+ ${audienceLine}
28
+ ${topicsLine}
29
+
30
+ Definitions must be short (one or two sentences), no marketing fluff, no circular definitions ("a Foo is a Foo that does Foo-ing").
31
+
32
+ Each entry also carries a short "category" tag (1-2 words, lower-case) that groups related terms. Use a small consistent vocabulary across the glossary - e.g. "product", "process", "role", "acronym", "system", "concept". Don't invent a new category for every entry; cluster.`
33
+
34
+ const JSON_TAIL = (schemaExample) => `Respond with a RAW JSON ARRAY ONLY. No markdown fences. No preamble. No trailing commentary. The exact format:
35
+
36
+ [
37
+ ${schemaExample}
38
+ ]
39
+
40
+ That's it. Anything else breaks the downstream parser.`
41
+
42
+ function audienceTopicsLines({ audience, topics }) {
43
+ return {
44
+ audienceLine: audience
45
+ ? `Audience: ${audience}.`
46
+ : 'Audience: general professional readers.',
47
+ topicsLine: topics
48
+ ? `Specific topics or areas to prioritise: ${topics}.`
49
+ : 'No specific topics requested - cover the broadest set of recurring terms.',
50
+ }
51
+ }
52
+
53
+ // Language-specific header + JSON schema. Shared by generation + expand
54
+ // so both calls produce structurally identical rows in each mode.
55
+ function languageBlock(languageMode, dialect) {
56
+ if (languageMode === 'cy') {
57
+ return {
58
+ header: `LANGUAGE: Welsh (Cymraeg). All terms and definitions in Welsh.
59
+ ${getDialectSystemPrompt(dialect)}
60
+ ${DIACRITIC_REMINDER}`,
61
+ schema: '{"term": "...", "definition": "...", "category": "..."}',
62
+ }
63
+ }
64
+ if (languageMode === 'bilingual') {
65
+ return {
66
+ header: `LANGUAGE: Bilingual - each entry carries both English and Welsh.
67
+ ${getDialectSystemPrompt(dialect)}
68
+ ${DIACRITIC_REMINDER}
69
+ Definitions in plain language for the stated audience in BOTH languages. Categories stay in English (they are tags, not user-facing copy).`,
70
+ schema: '{"term_en": "...", "term_cy": "...", "definition_en": "...", "definition_cy": "...", "category": "..."}',
71
+ }
72
+ }
73
+ return {
74
+ header: 'Definitions in plain English suitable for the stated audience.',
75
+ schema: '{"term": "...", "definition": "...", "category": "..."}',
76
+ }
77
+ }
78
+
79
+ export function buildGenerationSystemPrompt({
80
+ audience,
81
+ topics,
82
+ languageMode = 'en',
83
+ dialect = 'broadcast',
84
+ } = {}) {
85
+ const { audienceLine, topicsLine } = audienceTopicsLines({ audience, topics })
86
+ const lang = languageBlock(languageMode, dialect)
87
+ return `You are producing a starter glossary tailored to a specific organisation's website. ${SEARCH_GUIDANCE(audienceLine, topicsLine)}
88
+
89
+ Produce 12 to 18 entries.
90
+
91
+ ${lang.header}
92
+
93
+ ${JSON_TAIL(lang.schema)}`
94
+ }
95
+
96
+ export function buildExpandSystemPrompt({
97
+ audience,
98
+ topics,
99
+ languageMode = 'en',
100
+ dialect = 'broadcast',
101
+ existingTerms = [],
102
+ } = {}) {
103
+ const { audienceLine, topicsLine } = audienceTopicsLines({ audience, topics })
104
+ const lang = languageBlock(languageMode, dialect)
105
+ // Cap at 50 to keep the prompt budget reasonable. If the glossary
106
+ // is bigger than that the model still gets the most-recent slice
107
+ // and the client-side APPEND_TERMS dedup catches any repeats.
108
+ const cappedTerms = (existingTerms || []).slice(0, 50).filter(Boolean)
109
+ const existingLine = cappedTerms.length
110
+ ? `EXISTING TERMS IN THE GLOSSARY (do NOT repeat or paraphrase these): ${cappedTerms.join('; ')}.`
111
+ : 'The glossary is currently empty - treat this as an initial generation.'
112
+
113
+ return `You are EXTENDING an existing starter glossary for a specific organisation's website with new, additional terms. ${SEARCH_GUIDANCE(audienceLine, topicsLine)}
114
+
115
+ ${existingLine}
116
+
117
+ Produce 10 ADDITIONAL entries that the existing list does not already cover. Pick terms that complement the existing set - genuinely new ground, not paraphrases or near-synonyms of what's already there.
118
+
119
+ ${lang.header}
120
+
121
+ ${JSON_TAIL(lang.schema)}`
122
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Anthropic tool descriptors used by the Glossary Builder engine.
3
+ * web_search runs server-side - the model issues searches and
4
+ * Anthropic executes them. We never implement the search ourselves.
5
+ */
6
+
7
+ export const WEB_SEARCH_TOOL = { type: 'web_search_20250305', name: 'web_search' }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * URL helpers for the Glossary Builder engine.
3
+ *
4
+ * Anthropic's web_search expects a full URL; a bare 'example.com'
5
+ * returns "no relevant pages" and the whole flow degrades silently.
6
+ * normaliseUrl prepends https:// when the caller forgot a scheme.
7
+ */
8
+
9
+ export function normaliseUrl(raw) {
10
+ if (typeof raw !== 'string') return ''
11
+ const trimmed = raw.trim()
12
+ if (!trimmed) return ''
13
+ if (/^https?:\/\//i.test(trimmed)) return trimmed
14
+ return `https://${trimmed}`
15
+ }
16
+
17
+ export function isHttpUrl(url) {
18
+ return typeof url === 'string' && /^https?:\/\//i.test(url)
19
+ }
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @capsiynau/intelligence
3
+ *
4
+ * Shared bilingual intelligence layer. Re-exports the per-domain modules
5
+ * so consumers can pull from a single import path.
6
+ *
7
+ * Phase A (2026-05-15): extraction-only. Public surface will stabilise
8
+ * once App #3 lands and informs the contract.
9
+ */
10
+ export * from './corrections/index.js'
11
+ export * from './welsh/index.js'
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @capsiynau/intelligence/spellcheck/core
3
+ *
4
+ * Mutation-aware Welsh + English spell checking, built on top of
5
+ * Bangor's hunspell-cy + en-GB dictionaries via nspell.
6
+ *
7
+ * Three reasons this isn't just a raw nspell wrapper:
8
+ * 1. Welsh mutations make surface forms diverge from dictionary
9
+ * entries — `cath` is in the lexicon, `gath` (soft) and `chath`
10
+ * (aspirate) are not. We use possibleRoots() from mutations.js
11
+ * to recover every plausible root and accept the word if any
12
+ * root is in the lexicon.
13
+ * 2. Org-specific proper nouns (panellist names, brand terms,
14
+ * programme titles) must NOT be flagged. Callers pass a
15
+ * `glossary` set: any word appearing there is accepted as-is.
16
+ * 3. word_boost_approved (per-org learned vocab from corrections)
17
+ * is the same shape as `glossary` — passed in the same arg.
18
+ *
19
+ * Pure functions; the nspell instance is constructed once per worker
20
+ * and passed in. Caller manages caching.
21
+ */
22
+
23
+ import { possibleRoots } from '../welsh/mutations.js'
24
+
25
+ /**
26
+ * Check a single word against the supplied dictionary + accept-list.
27
+ *
28
+ * @param {string} word - The word to check (no punctuation, single token).
29
+ * @param {object} opts
30
+ * @param {object} opts.nspell - An nspell instance (server side, built
31
+ * from dictionary-cy or dictionary-en-gb).
32
+ * @param {Set<string>} [opts.accept] - Lowercased proper nouns + glossary
33
+ * + word_boost_approved terms that
34
+ * must never be flagged.
35
+ * @param {boolean} [opts.welsh] - When true, additionally try mutation
36
+ * reversal before declaring the word
37
+ * misspelled. Pass false for English.
38
+ * @returns {boolean} true if the word is considered correctly spelled.
39
+ */
40
+ export function checkWord(word, { nspell, accept, welsh }) {
41
+ if (typeof word !== 'string' || word.length === 0) return true
42
+ const cleaned = word.replace(/[^\p{L}\p{M}'’-]/gu, '')
43
+ if (cleaned.length === 0) return true
44
+ const lower = cleaned.toLowerCase()
45
+ if (accept && accept.has(lower)) return true
46
+ if (nspell.correct(cleaned)) return true
47
+ if (welsh) {
48
+ // Mutation-aware fallback: try every plausible root form.
49
+ const candidates = possibleRoots(cleaned)
50
+ for (const { root } of candidates) {
51
+ if (root === cleaned) continue // already tried
52
+ if (accept && accept.has(root.toLowerCase())) return true
53
+ if (nspell.correct(root)) return true
54
+ }
55
+ }
56
+ return false
57
+ }
58
+
59
+ /**
60
+ * Tokenise a string into checkable words + their positions, so a UI
61
+ * can underline only the wrong tokens without re-tokenising itself.
62
+ *
63
+ * Position is the offset in the original string. Tokens are word-like
64
+ * runs of letters/marks/apostrophes/hyphens; punctuation is skipped.
65
+ *
66
+ * @param {string} text
67
+ * @returns {{ word: string, start: number, end: number }[]}
68
+ */
69
+ export function tokenise(text) {
70
+ if (typeof text !== 'string') return []
71
+ const tokens = []
72
+ // \p{L} + \p{M} covers Welsh accented letters; ' and ’ + - keep
73
+ // contractions and hyphenated words intact.
74
+ const re = /[\p{L}\p{M}'’-]+/gu
75
+ let m
76
+ while ((m = re.exec(text)) !== null) {
77
+ tokens.push({ word: m[0], start: m.index, end: m.index + m[0].length })
78
+ }
79
+ return tokens
80
+ }
81
+
82
+ /**
83
+ * Check an entire string and return the misspelled tokens (if any).
84
+ * Convenience wrapper around tokenise + checkWord.
85
+ *
86
+ * @param {string} text
87
+ * @param {object} opts - same shape as checkWord opts
88
+ * @returns {{ word: string, start: number, end: number }[]}
89
+ */
90
+ export function checkSentence(text, opts) {
91
+ return tokenise(text).filter(t => !checkWord(t.word, opts))
92
+ }
93
+
94
+ /**
95
+ * Generate up to N suggestions for a misspelled word. Wraps
96
+ * nspell.suggest() but biases toward `accept` (proper nouns are
97
+ * surfaced first when they're close in edit distance).
98
+ *
99
+ * @param {string} word
100
+ * @param {object} opts
101
+ * @param {object} opts.nspell
102
+ * @param {Set<string>} [opts.accept]
103
+ * @param {number} [opts.max] - default 6
104
+ * @returns {string[]}
105
+ */
106
+ export function suggest(word, { nspell, accept, max = 6 }) {
107
+ if (typeof word !== 'string' || word.length === 0) return []
108
+ const base = nspell.suggest(word) || []
109
+ if (!accept || accept.size === 0) return base.slice(0, max)
110
+ const acceptHits = []
111
+ const lower = word.toLowerCase()
112
+ for (const term of accept) {
113
+ if (term === lower) continue
114
+ if (editDistance(lower, term, 2) <= 2) acceptHits.push(term)
115
+ }
116
+ // De-dupe: glossary hits first, then nspell suggestions that aren't dupes.
117
+ const seen = new Set(acceptHits.map(t => t.toLowerCase()))
118
+ const out = [...acceptHits]
119
+ for (const s of base) {
120
+ if (out.length >= max) break
121
+ if (!seen.has(s.toLowerCase())) {
122
+ out.push(s)
123
+ seen.add(s.toLowerCase())
124
+ }
125
+ }
126
+ return out
127
+ }
128
+
129
+ // Tight edit-distance with early abort at maxDist+1. Borrowed shape
130
+ // from worker/livePolish.js so behaviour matches the bias-loader.
131
+ function editDistance(a, b, maxDist) {
132
+ if (a === b) return 0
133
+ const al = a.length, bl = b.length
134
+ if (Math.abs(al - bl) > maxDist) return maxDist + 1
135
+ let prev = new Array(bl + 1)
136
+ let curr = new Array(bl + 1)
137
+ for (let j = 0; j <= bl; j++) prev[j] = j
138
+ for (let i = 1; i <= al; i++) {
139
+ curr[0] = i
140
+ let rowMin = i
141
+ for (let j = 1; j <= bl; j++) {
142
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
143
+ curr[j] = Math.min(
144
+ prev[j] + 1,
145
+ curr[j - 1] + 1,
146
+ prev[j - 1] + cost,
147
+ )
148
+ if (curr[j] < rowMin) rowMin = curr[j]
149
+ }
150
+ if (rowMin > maxDist) return maxDist + 1
151
+ ;[prev, curr] = [curr, prev]
152
+ }
153
+ return prev[bl]
154
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @capsiynau/intelligence/spellcheck
3
+ *
4
+ * Mutation-aware Welsh + English spellcheck. See ./core.js for the
5
+ * pure functions. This module exposes the public surface only.
6
+ */
7
+
8
+ export { checkWord, checkSentence, tokenise, suggest } from './core.js'