@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,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'
|