@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,279 @@
1
+ /**
2
+ * @capsiynau/intelligence/corrections/tracker
3
+ *
4
+ * Moved here in Phase A (see docs/intelligence-layer-phase-a.md +
5
+ * PR #448) so Nodiadau can git-subtree this package rather than
6
+ * maintain a hand-mirrored copy. Original path: src/lib/correctionTracker.js.
7
+ *
8
+ * Ported verbatim (TS → JS) from Nodiadau commit 5d75dde
9
+ * (aledprysparry/notes-app#239) so Capsiynau Live edits feed the same
10
+ * closed-loop vocabulary learning channel.
11
+ *
12
+ * When a moderator fixes a Whisper mis-recognition in the live transcript
13
+ * (e.g. "Allard Perry" → "Aled Parry"), we want the model to recognise the
14
+ * corrected term next time. The Capsiynau live-relay reads
15
+ * `word_boost_approved` for the session's org + language and injects
16
+ * matching terms into the Whisper `prompt` parameter (PR #421).
17
+ *
18
+ * Flow:
19
+ * 1. Diff the segment text before/after the edit (whitespace-tokenised).
20
+ * 2. Find equal-length word swaps — if the lengths differ, skip; structural
21
+ * changes are too noisy to feed back into vocabulary biasing.
22
+ * 3. For each swap, decide whether the corrected token looks like a proper
23
+ * noun worth biasing (Title-Case, ≥4 chars, not a Welsh/English
24
+ * start-of-sentence stopword, alphabetic-only).
25
+ * 4. Upsert into word_boost_approved with the session's organisation_id +
26
+ * source_language. RLS scopes by org via get_user_org_ids() so a term
27
+ * never leaks into another tenant's bias.
28
+ *
29
+ * Keep parity with the Nodiadau implementation — any heuristic change here
30
+ * must mirror in notes-app/src/lib/correctionTracker.ts (or vice versa) or
31
+ * the two apps' learning behaviour silently diverges.
32
+ */
33
+
34
+ // Phase B (2026-05-17, PR #495): the Supabase client is passed in by
35
+ // the caller rather than imported from a Capsiynau-specific path. This
36
+ // removes the package's only coupling to the host app's module alias
37
+ // (`@/api/supabaseClient`) and lets Nodiadau consume captureCorrections
38
+ // without a re-export shim. Breaking API change vs 0.1.0 — bumped to
39
+ // 0.2.0. Callers now pass `supabase` in the options bag alongside
40
+ // `sessionId`.
41
+
42
+ /**
43
+ * Words that are *not* worth promoting even if Title-Case. Keep narrow —
44
+ * this is a deny-list, not a dictionary.
45
+ */
46
+ const PROPER_NOUN_STOPWORDS = new Set([
47
+ // English filler
48
+ 'The', 'This', 'That', 'These', 'Those', 'There', 'Their', 'They',
49
+ 'Then', 'When', 'Where', 'What', 'Which', 'While', 'Whose', 'Whom',
50
+ 'With', 'Without', 'About', 'Above', 'After', 'Again', 'Against',
51
+ 'Also', 'Some', 'Such', 'Same', 'Should', 'Would', 'Could', 'Might',
52
+ 'Have', 'Been', 'Were', 'From', 'Into', 'Onto',
53
+ // Welsh filler (Title-Case start-of-sentence is common in Welsh too)
54
+ 'Mae', "Mae'r", "Mae'n", 'Dyma', 'Dyna', 'Roedd', "Roedd'r",
55
+ 'Rwy', 'Rwyt', 'Bydd', "Bydd'r", 'Bod', 'Cael',
56
+ 'Wedi', 'Wedyn', 'Felly', 'Ond', 'Achos', 'Ar', "Ar y",
57
+ 'Yn', 'Mewn', 'Heb', 'Drwy', 'Drwyddo', 'Drwyddi', 'Drwyddyn',
58
+ ])
59
+
60
+ const MIN_PROPER_NOUN_LENGTH = 4
61
+ const ALPHA_ONLY = /^[\p{L}\p{M}'-]+$/u
62
+
63
+ /**
64
+ * Collapse the session's source_language to the two-bucket convention used
65
+ * everywhere else in this repo for STT bias loading: cy-GB (any Welsh
66
+ * variant — broadcast, north, south, formal) or en-GB (everything else,
67
+ * including NULL).
68
+ *
69
+ * Pre-this fix, this module defaulted unknown / NULL to 'cy-GB', which
70
+ * silently routed English users' legacy-session learned vocab into the
71
+ * Welsh bias bucket. Mirrors notes-app/src/lib/correctionTracker.ts.
72
+ */
73
+ function normaliseLanguageBucket(source) {
74
+ // Case-insensitive: legacy / externally-written source_language values
75
+ // can arrive as 'CY-GB' or 'Cy-GB-north'. Without toLowerCase() those
76
+ // get classified as en-GB and learned terms land in the wrong bucket
77
+ // (Codex catch on notes-app#261 / @capsiynau/intelligence 0.2.0).
78
+ if (typeof source === 'string' && source.toLowerCase().startsWith('cy')) return 'cy-GB'
79
+ return 'en-GB'
80
+ }
81
+
82
+ /**
83
+ * Tokenise on whitespace AND strip trailing punctuation per word. Keeps
84
+ * in-word punctuation (apostrophes, hyphens) which Welsh names need
85
+ * ("ap Iolo", "Tŷ'r-pol", etc.).
86
+ */
87
+ function tokenise(text) {
88
+ return text
89
+ .trim()
90
+ .split(/\s+/)
91
+ .map((tok) => tok.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, ''))
92
+ .filter(Boolean)
93
+ }
94
+
95
+ /**
96
+ * Returns true if the token looks like a name/place/brand we want to bias
97
+ * toward. Heuristics, not a dictionary.
98
+ *
99
+ * ─── PART OF THE LANGUAGE-AGNOSTIC CONTRACT ──────────────────────────
100
+ * The relay's `word_boost_approved` loader (worker/live-relay.js) reads
101
+ * rows from this table language-agnostically, trusting that this
102
+ * heuristic only matches semantically language-invariant terms (Title-
103
+ * Case proper nouns: personal names, brand names, place names).
104
+ *
105
+ * If you ever loosen this heuristic to also match per-language vocabulary
106
+ * (hyphenated compounds, lowercase acronyms, dialect markers, domain
107
+ * lexicon, etc.), the loader has to follow. Either:
108
+ * (a) re-introduce a per-language filter on the read side, OR
109
+ * (b) add a row-level `word_boost_approved.cross_language` discriminator
110
+ * so the loader can decide per row (Phase 1 of notes-app#262 queues
111
+ * this micro-add).
112
+ *
113
+ * Change the heuristic, change the loader.
114
+ * ─────────────────────────────────────────────────────────────────────
115
+ */
116
+ function looksLikeProperNoun(token) {
117
+ if (!token || token.length < MIN_PROPER_NOUN_LENGTH) return false
118
+ if (!ALPHA_ONLY.test(token)) return false
119
+ const first = token[0]
120
+ // Must start with an upper-case letter
121
+ if (first.toUpperCase() !== first || first.toLowerCase() === first) return false
122
+ if (PROPER_NOUN_STOPWORDS.has(token)) return false
123
+ // Avoid promoting ALL-CAPS strings (likely acronyms or shouting)
124
+ if (token.toUpperCase() === token && token.length > 4) return false
125
+ return true
126
+ }
127
+
128
+ /**
129
+ * Compute the set of proper-noun corrections worth promoting. Exported
130
+ * for unit tests.
131
+ *
132
+ * Returned terms become rows in `word_boost_approved` (per-org, with the
133
+ * session's source_language tag). The relay's loader treats those rows
134
+ * as language-agnostic on read - see the contract note on
135
+ * `looksLikeProperNoun` above for the full invariant and what must
136
+ * change if the heuristic ever expands beyond proper nouns.
137
+ */
138
+ export function extractProperNounCorrections(before, after) {
139
+ const a = tokenise(before)
140
+ const b = tokenise(after)
141
+ // Skip structural changes (token count differs) — same heuristic as the
142
+ // 2am vocabulary-learning cron. Word-for-word swaps are the high-signal class.
143
+ if (a.length !== b.length || a.length === 0) return []
144
+ const out = new Set()
145
+ for (let i = 0; i < a.length; i++) {
146
+ if (a[i] === b[i]) continue
147
+ const corrected = b[i]
148
+ if (looksLikeProperNoun(corrected)) out.add(corrected)
149
+ }
150
+ return Array.from(out)
151
+ }
152
+
153
+ /**
154
+ * Upsert proper-noun corrections into word_boost_approved for the session's
155
+ * org + source language. Returns the list of terms persisted (excludes
156
+ * duplicates already in the table — upsert is silent on conflict).
157
+ *
158
+ * Never throws — failures are logged and swallowed so a segment-edit save
159
+ * is never blocked by a vocabulary-learning hiccup.
160
+ *
161
+ * @param {string} before Original segment text before the edit.
162
+ * @param {string} after Text after the edit.
163
+ * @param {object} opts
164
+ * @param {string} opts.sessionId Live session UUID for org / fallback-lang lookup.
165
+ * @param {string} [opts.segmentId] Phase 1+ (notes-app#262): live_segments
166
+ * UUID for the edited segment. When provided,
167
+ * the segment's own `language` column is used
168
+ * for the new word_boost_approved row (drops
169
+ * silent rot in mixed-language sessions where
170
+ * Phase 3's per-chunk detected_language differs
171
+ * from session.source_language). Falls back to
172
+ * session.source_language if absent or the
173
+ * segment's language column is null.
174
+ * @param {object} opts.supabase Caller-provided Supabase client (DI).
175
+ * Must have `.from()` and `.rpc()` methods.
176
+ * RLS scopes writes by org.
177
+ */
178
+ export async function captureCorrections(before, after, { sessionId, segmentId, supabase }) {
179
+ if (!supabase) {
180
+ console.warn('[correctionTracker] captureCorrections called without a supabase client; skipping.')
181
+ return []
182
+ }
183
+ const terms = extractProperNounCorrections(before, after)
184
+ if (terms.length === 0) return []
185
+
186
+ // Phase 1+ (notes-app#262): prefer the segment's per-row language over
187
+ // the session's blanket source_language so corrections in a mixed-
188
+ // language session (Phase 3 per-chunk detected_language) land in the
189
+ // correct bucket. Best-effort: pre-Phase-1 envs or older relays will
190
+ // have segment.language = NULL, which falls through to the session
191
+ // lookup below.
192
+ //
193
+ // The lookup is bound to (segmentId, sessionId) together - never id
194
+ // alone. A stale or mismatched segmentId from a client race or a
195
+ // tampered payload could otherwise reference a row in a different
196
+ // session within the same org, contaminating the bucket with that
197
+ // OTHER segment's language while writing under THIS session's org
198
+ // (Codex follow-up on PR #511). RLS prevents cross-org leakage but
199
+ // not cross-session-within-org, so we constrain explicitly.
200
+ let segmentLanguage = null
201
+ if (segmentId) {
202
+ const { data: seg } = await supabase
203
+ .from('live_segments')
204
+ .select('language')
205
+ .eq('id', segmentId)
206
+ .eq('session_id', sessionId)
207
+ .maybeSingle()
208
+ segmentLanguage = seg?.language || null
209
+ }
210
+
211
+ // Look up the session's org + fallback-language at write time so the
212
+ // caller doesn't need to thread them through. One small SELECT,
213
+ // RLS-scoped. Always needed (org_id is the bucketing key) even when
214
+ // segmentLanguage resolved above.
215
+ const { data: sess, error: sessErr } = await supabase
216
+ .from('live_sessions')
217
+ .select('organisation_id, source_language')
218
+ .eq('id', sessionId)
219
+ .maybeSingle()
220
+
221
+ if (sessErr || !sess || !sess.organisation_id) {
222
+ console.warn('[correctionTracker] session lookup failed or missing org:', sessErr?.message)
223
+ return []
224
+ }
225
+
226
+ const language = normaliseLanguageBucket(segmentLanguage || sess.source_language)
227
+
228
+ // Phase 4.2 — call the SECURITY DEFINER RPC for an atomic
229
+ // INSERT ... ON CONFLICT DO UPDATE SET confirmed_by_count =
230
+ // confirmed_by_count + 1, last_confirmed_at = now(). This is what makes
231
+ // repeat confirmations of the same proper noun count toward bias
232
+ // ranking instead of silently no-op'ing under ignoreDuplicates.
233
+ //
234
+ // Fallback to plain upsert if the RPC isn't deployed yet (envs that
235
+ // haven't applied 20260515_word_boost_approved_confidence.sql).
236
+ // PostgREST returns a structured error with code='PGRST202' or
237
+ // message includes 'Could not find the function'; we catch broadly
238
+ // and degrade gracefully.
239
+ const persisted = []
240
+ let rpcMissing = false
241
+ for (const term of terms) {
242
+ const { error: rpcErr } = await supabase.rpc('bump_word_boost_approved', {
243
+ p_term: term,
244
+ p_language: language,
245
+ p_organisation_id: sess.organisation_id,
246
+ p_auto_approved: true,
247
+ })
248
+ if (rpcErr) {
249
+ // RPC not deployed on this env → bail out and fall back to upsert.
250
+ if (rpcErr.code === 'PGRST202' || /Could not find the function/i.test(rpcErr.message || '')) {
251
+ rpcMissing = true
252
+ break
253
+ }
254
+ // Other errors (forbidden, network) — log and skip this term.
255
+ console.warn('[correctionTracker] bump_word_boost_approved error:', rpcErr.message)
256
+ continue
257
+ }
258
+ persisted.push(term)
259
+ }
260
+
261
+ if (rpcMissing) {
262
+ const rows = terms.map((term) => ({
263
+ term,
264
+ language,
265
+ organisation_id: sess.organisation_id,
266
+ auto_approved: true,
267
+ }))
268
+ const { error } = await supabase
269
+ .from('word_boost_approved')
270
+ .upsert(rows, { onConflict: 'term,language,organisation_id', ignoreDuplicates: true })
271
+ if (error) {
272
+ console.warn('[correctionTracker] word_boost_approved upsert fallback failed:', error.message)
273
+ return []
274
+ }
275
+ return terms
276
+ }
277
+
278
+ return persisted
279
+ }
@@ -0,0 +1,45 @@
1
+ # `@capsiynau/intelligence/glossary`
2
+
3
+ Shared engine for the Glossary Builder wizard. Consumed by Capsiynau
4
+ (this repo) and Nodiadau (via git-subtree, mirroring the
5
+ `@capsiynau/intelligence/corrections` arrangement landed in PR #448).
6
+
7
+ ## Exports
8
+
9
+ | Export | Purpose |
10
+ |---|---|
11
+ | `normaliseUrl(raw)` | Prepend `https://` when scheme is missing; trim whitespace. |
12
+ | `isHttpUrl(url)` | Cheap guard before handing a URL to Anthropic's web_search. |
13
+ | `extractText(message)` | Pull plain text out of an Anthropic Messages response, filtering tool-use / tool-result / thinking blocks. Safe on Claude 4.5+. |
14
+ | `parseTermsJson(raw)` | Defensive parse: strip markdown fences, fall back to greedy `[...]` regex, return `null` on total failure. Never throws. |
15
+ | `normaliseTermRow(t)` | Tolerate `{term}` / `{source_term}` / `{preferred_term}` and `{definition}` / `{description}` / `{meaning}` shape wobbles; return `null` on missing basics. |
16
+ | `RESEARCH_SYSTEM` | System prompt for the research call. |
17
+ | `buildGenerationSystemPrompt({ audience, topics })` | System prompt for the generation call. |
18
+ | `WEB_SEARCH_TOOL` | `{ type: 'web_search_20250305', name: 'web_search' }`. |
19
+
20
+ ## Why this split
21
+
22
+ The handler at `api/functions/_handlers/glossaryBuilder.js` stays
23
+ thin and lives at the Vercel serverless boundary. The engine is pure
24
+ functions plus prompts - trivially testable, shareable between the
25
+ two apps without dragging the Vercel handler shape into Nodiadau.
26
+
27
+ ## Phase 4 extension points
28
+
29
+ - `buildGenerationSystemPrompt` will gain `languageMode` (`'en' | 'cy'
30
+ | 'bilingual'`) and `dialect` (`'north' | 'south' | 'broadcast' |
31
+ 'formal'`) parameters; bilingual mode returns `{term_en, term_cy,
32
+ definition_en, definition_cy, category}` rows.
33
+ - `normaliseTermRow` will gain `category` + bilingual fields, piping
34
+ `*_cy` values through `normaliseWelsh()` from
35
+ `@capsiynau/intelligence/welsh`.
36
+ - Diacritic correctness will be enforced as a hard guard - flag, do
37
+ not silently strip.
38
+
39
+ ## Phase 5 additions (alongside, same folder)
40
+
41
+ - `parseImportedCsv(raw)` + `parseImportedText(raw)` for the
42
+ import-and-merge flow.
43
+ - `mergeRows(existing, additions, { onConflict })` returning conflict
44
+ records the UI surfaces for human resolution rather than silent
45
+ merge.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Glossary Builder - import + merge helpers.
3
+ *
4
+ * The wizard lets a user paste or upload an existing partial glossary
5
+ * (CSV or plain text). Parse defensively, surface errors per line
6
+ * rather than crashing the whole import, then split incoming rows
7
+ * against the existing set so the UI can prompt for conflict
8
+ * resolution rather than silently overwrite.
9
+ *
10
+ * v1 scope: single-language imports. In bilingual wizard mode rows
11
+ * populate EN slots only; users top up CY via Expand-with-AI or
12
+ * inline edit. The import dialog documents this.
13
+ */
14
+
15
+ const HEADER_TOKENS = ['term', 'definition', 'category', 'name', 'meaning', 'tag']
16
+
17
+ /**
18
+ * Parse a CSV-ish string. Accepts (with or without header row):
19
+ * term,definition
20
+ * term,definition,category
21
+ * term,category,definition
22
+ * Header row (detected by token match) maps columns by name and
23
+ * tolerates aliases (name, meaning, tag). RFC-4180-lite: quoted
24
+ * fields + escaped quotes on a single line. Multi-line quoted values
25
+ * are out of scope - report and skip.
26
+ */
27
+ export function parseImportedCsv(raw) {
28
+ if (typeof raw !== 'string' || !raw.trim()) return { rows: [], errors: [] }
29
+ const text = raw.replace(/^/, '') // strip BOM
30
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0)
31
+ if (lines.length === 0) return { rows: [], errors: [] }
32
+
33
+ const firstFields = parseCsvLine(lines[0])
34
+ const headerLower = firstFields.map((f) => f.toLowerCase())
35
+ const hasHeader = headerLower.some((h) => HEADER_TOKENS.includes(h))
36
+
37
+ let mapping
38
+ if (hasHeader) {
39
+ mapping = headerLower.map((h) => {
40
+ if (h === 'term' || h === 'name') return 'source_term'
41
+ if (h === 'definition' || h === 'meaning') return 'definition'
42
+ if (h === 'category' || h === 'tag') return 'category'
43
+ return null
44
+ })
45
+ } else {
46
+ // Positional fallback: 2 cols = term,definition; 3 cols use
47
+ // header-aware aliases. If column 2 looks short (<= 25 chars
48
+ // average) and column 3 is long, assume term,category,definition;
49
+ // otherwise term,definition,category.
50
+ if (firstFields.length === 2) {
51
+ mapping = ['source_term', 'definition']
52
+ } else if (firstFields.length >= 3) {
53
+ mapping = guessPositionalThree(lines)
54
+ } else {
55
+ mapping = ['source_term']
56
+ }
57
+ }
58
+
59
+ const rows = []
60
+ const errors = []
61
+ const startIdx = hasHeader ? 1 : 0
62
+ for (let i = startIdx; i < lines.length; i++) {
63
+ const fields = parseCsvLine(lines[i])
64
+ const row = mappingToRow(fields, mapping)
65
+ if (!row) {
66
+ errors.push({ line: i + 1, reason: 'Missing term or definition' })
67
+ continue
68
+ }
69
+ rows.push(row)
70
+ }
71
+ return { rows, errors }
72
+ }
73
+
74
+ /**
75
+ * Parse plain text. One term per line. Separator tried in order: tab,
76
+ * `:` followed by space, ` - ` (hyphen with spaces). Three columns
77
+ * (term, definition, optional category) match our copy-as-text output
78
+ * so users can round-trip cleanly.
79
+ */
80
+ export function parseImportedText(raw) {
81
+ if (typeof raw !== 'string' || !raw.trim()) return { rows: [], errors: [] }
82
+ const lines = raw.split(/\r?\n/).map((l) => l.trim())
83
+ const rows = []
84
+ const errors = []
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const line = lines[i]
87
+ if (!line || line.startsWith('#')) continue
88
+ let parts = line.split('\t')
89
+ if (parts.length < 2) parts = line.split(/:\s+/)
90
+ if (parts.length < 2) parts = line.split(/\s-\s/)
91
+ if (parts.length < 2) {
92
+ errors.push({ line: i + 1, reason: 'No separator (tab, ": ", or " - ") found' })
93
+ continue
94
+ }
95
+ const [term, definition, category] = parts
96
+ if (!term?.trim() || !definition?.trim()) {
97
+ errors.push({ line: i + 1, reason: 'Missing term or definition' })
98
+ continue
99
+ }
100
+ rows.push({
101
+ source_term: term.trim(),
102
+ preferred_term: term.trim(),
103
+ definition: definition.trim(),
104
+ category: typeof category === 'string' && category.trim() ? category.trim() : null,
105
+ })
106
+ }
107
+ return { rows, errors }
108
+ }
109
+
110
+ /**
111
+ * Split incoming rows against the existing set. Matches case-insensitive
112
+ * on trimmed source_term. Returns {additions, conflicts} where conflicts
113
+ * is an array of {existing, incoming} pairs the UI surfaces for
114
+ * resolution. Dedup within the import itself happens against additions
115
+ * (the second occurrence of a duplicated import term becomes a conflict
116
+ * with the first occurrence).
117
+ */
118
+ export function splitImportAgainstExisting(existing, incoming) {
119
+ const have = new Map()
120
+ for (const t of existing || []) {
121
+ const key = (t?.source_term || '').trim().toLowerCase()
122
+ if (key) have.set(key, t)
123
+ }
124
+ const additions = []
125
+ const conflicts = []
126
+ for (const t of incoming || []) {
127
+ const key = (t?.source_term || '').trim().toLowerCase()
128
+ if (!key) continue
129
+ if (have.has(key)) {
130
+ conflicts.push({ existing: have.get(key), incoming: t })
131
+ } else {
132
+ additions.push(t)
133
+ have.set(key, t)
134
+ }
135
+ }
136
+ return { additions, conflicts }
137
+ }
138
+
139
+ // ── internal helpers ─────────────────────────────────────────────────────
140
+
141
+ function parseCsvLine(line) {
142
+ const fields = []
143
+ let buf = ''
144
+ let inQuotes = false
145
+ for (let i = 0; i < line.length; i++) {
146
+ const c = line[i]
147
+ if (inQuotes) {
148
+ if (c === '"' && line[i + 1] === '"') {
149
+ buf += '"'
150
+ i++
151
+ } else if (c === '"') {
152
+ inQuotes = false
153
+ } else {
154
+ buf += c
155
+ }
156
+ } else if (c === '"') {
157
+ inQuotes = true
158
+ } else if (c === ',') {
159
+ fields.push(buf)
160
+ buf = ''
161
+ } else {
162
+ buf += c
163
+ }
164
+ }
165
+ fields.push(buf)
166
+ return fields.map((f) => f.trim())
167
+ }
168
+
169
+ function mappingToRow(fields, mapping) {
170
+ const row = { source_term: '', preferred_term: '', definition: '', category: null }
171
+ for (let i = 0; i < mapping.length && i < fields.length; i++) {
172
+ const key = mapping[i]
173
+ if (!key) continue
174
+ row[key] = fields[i] || ''
175
+ }
176
+ if (!row.source_term?.trim() || !row.definition?.trim()) return null
177
+ row.source_term = row.source_term.trim()
178
+ row.preferred_term = row.source_term
179
+ row.definition = row.definition.trim()
180
+ row.category = typeof row.category === 'string' && row.category.trim() ? row.category.trim() : null
181
+ return row
182
+ }
183
+
184
+ function guessPositionalThree(lines) {
185
+ // Sample a few rows; if column 2 is consistently short (likely a
186
+ // tag) and column 3 long (likely a definition), it's term,cat,def.
187
+ // Otherwise default to term,def,cat (matches our CSV export).
188
+ let shortMid = 0
189
+ let total = 0
190
+ for (let i = 0; i < Math.min(lines.length, 5); i++) {
191
+ const f = parseCsvLine(lines[i])
192
+ if (f.length < 3) continue
193
+ total++
194
+ if ((f[1] || '').length <= 20 && (f[2] || '').length > 20) shortMid++
195
+ }
196
+ return total > 0 && shortMid / total >= 0.6
197
+ ? ['source_term', 'category', 'definition']
198
+ : ['source_term', 'definition', 'category']
199
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @capsiynau/intelligence/glossary
3
+ *
4
+ * Shared engine for the Glossary Builder wizard. Consumed by Capsiynau
5
+ * (this repo) and Nodiadau (via git-subtree, mirroring the
6
+ * @capsiynau/intelligence/corrections arrangement from PR #448).
7
+ *
8
+ * Phase 2 (this PR): URL + parse + prompts + tools extracted from the
9
+ * legacy api/functions/_handlers/glossaryBuilder.js. Handler now
10
+ * delegates here.
11
+ *
12
+ * Phase 4 will add a bilingual prompt variant + Welsh-aware row
13
+ * normaliser that pipes definition_cy through normaliseWelsh().
14
+ * Phase 5 will add CSV/text import parsers alongside.
15
+ */
16
+
17
+ export { normaliseUrl, isHttpUrl } from './url.js'
18
+ export {
19
+ extractText,
20
+ parseTermsJson,
21
+ normaliseTermRow,
22
+ flagDiacriticStrip,
23
+ } from './parse.js'
24
+ export {
25
+ RESEARCH_SYSTEM,
26
+ buildGenerationSystemPrompt,
27
+ buildExpandSystemPrompt,
28
+ } from './prompts.js'
29
+ export { WEB_SEARCH_TOOL } from './tools.js'
30
+ export {
31
+ parseImportedCsv,
32
+ parseImportedText,
33
+ splitImportAgainstExisting,
34
+ } from './import.js'
35
+ export {
36
+ suggestGlossaryName,
37
+ languagePairFor,
38
+ expandRowsForDb,
39
+ buildGlossaryRow,
40
+ suggestVersionedName,
41
+ } from './persist.js'