@capsiynau/intelligence 0.3.0 → 0.4.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 +63 -0
- package/README.md +35 -4
- package/package.json +33 -7
- package/src/corrections/index.js +11 -1
- package/src/corrections/pure.js +133 -0
- package/src/corrections/tracker.js +36 -116
- package/src/speakers/index.js +204 -0
- package/src/spellcheck/core.js +73 -6
- package/src/welsh/normaliser.js +11 -0
- package/types/corrections/index.d.ts +2 -0
- package/types/corrections/pure.d.ts +21 -0
- package/types/corrections/tracker.d.ts +36 -0
- package/types/glossary/import.d.ts +55 -0
- package/types/glossary/index.d.ts +6 -0
- package/types/glossary/parse.d.ts +63 -0
- package/types/glossary/persist.d.ts +66 -0
- package/types/glossary/prompts.d.ts +10 -0
- package/types/glossary/tools.d.ts +4 -0
- package/types/glossary/url.d.ts +9 -0
- package/types/index.d.ts +2 -0
- package/types/speakers/index.d.ts +85 -0
- package/types/spellcheck/core.d.ts +74 -0
- package/types/spellcheck/index.d.ts +1 -0
- package/types/welsh/digraphs.d.ts +102 -0
- package/types/welsh/index.d.ts +3 -0
- package/types/welsh/mutations.d.ts +76 -0
- package/types/welsh/normaliser.d.ts +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,69 @@ All notable changes to `@capsiynau/intelligence` go here. Format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-07-22
|
|
10
|
+
|
|
11
|
+
First release to ship TypeScript declarations. Everything here is
|
|
12
|
+
additive: no existing import path changes shape, so 0.3.x consumers can
|
|
13
|
+
upgrade without touching their code.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`speakers` subpath.** `assessDiarisation(utterances, segments, opts)`
|
|
18
|
+
is a diarisation QUALITY GATE: it computes clipped-union coverage so
|
|
19
|
+
speech outside the transcript window cannot inflate the number,
|
|
20
|
+
suppresses labels below `minCoverage` (0.6), and suppresses a sole
|
|
21
|
+
speaker on audio longer than 90s (the classic diarisation false
|
|
22
|
+
negative, where it failed to separate voices rather than there being
|
|
23
|
+
one voice). `mapSpeakersToNames(acoustic, labelled, opts)` maps
|
|
24
|
+
anonymous "Speaker 1" labels onto real names by greatest time overlap,
|
|
25
|
+
requiring the winner to cover >= 50% of that speaker's talk time AND
|
|
26
|
+
beat the runner-up by 1.5x; ambiguous speakers stay unmapped so the
|
|
27
|
+
caller keeps the anonymous label. `clampedUnionMs` is exported for
|
|
28
|
+
callers that want the coverage primitive on its own. Pure and
|
|
29
|
+
dependency-free.
|
|
30
|
+
- **`corrections/pure` subpath.** `extractProperNounCorrections(before,
|
|
31
|
+
after)` without any of Capsiynau's schema. See "Changed" below.
|
|
32
|
+
- **TypeScript declarations for every subpath.** Generated from the
|
|
33
|
+
existing JSDoc into `types/` and shipped in the tarball, with per-subpath
|
|
34
|
+
`types` conditions in the `exports` map plus a top-level `types` field.
|
|
35
|
+
Consumers no longer need a hand-written ambient shim; Nodiadau's
|
|
36
|
+
`src/types/capsiynau-intelligence.d.ts` is redundant from this release
|
|
37
|
+
and can be deleted in the notes-app repo.
|
|
38
|
+
Regenerate with `npm run build:types` in `packages/intelligence`. The
|
|
39
|
+
output is committed so publishing needs no build step.
|
|
40
|
+
- First unit tests for the corrections module
|
|
41
|
+
(`__tests__/corrections-pure.test.js`, 14 cases). The heuristic had
|
|
42
|
+
shipped since 0.1.0 covered only by a single smoke call in the publish
|
|
43
|
+
workflow.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **`corrections` is split into a portable half and a Capsiynau half.**
|
|
48
|
+
`extractProperNounCorrections` (a whitespace-token diff plus the
|
|
49
|
+
Title-Case proper-noun heuristic) moved to `src/corrections/pure.js`
|
|
50
|
+
and is importable as `@capsiynau/intelligence/corrections/pure`.
|
|
51
|
+
`captureCorrections` stays in `src/corrections/tracker.js`: it reads
|
|
52
|
+
`live_segments` and `live_sessions` and writes `word_boost_approved`
|
|
53
|
+
via the `bump_word_boost_approved` RPC, so it only works against
|
|
54
|
+
Capsiynau's database and is not a shared capability.
|
|
55
|
+
|
|
56
|
+
**Backwards-compatible.** `@capsiynau/intelligence/corrections` still
|
|
57
|
+
exports BOTH halves, so existing imports keep working unchanged. Only
|
|
58
|
+
a consumer importing `src/corrections/tracker.js` by deep path would
|
|
59
|
+
notice, and no published entry point allows that.
|
|
60
|
+
|
|
61
|
+
Why: an app on a different database (Postia is on a separate Supabase
|
|
62
|
+
project entirely) could not import the heuristic without inheriting
|
|
63
|
+
tables it does not have. This is the lib-versus-API rule applied:
|
|
64
|
+
pure functions of their inputs ship as library modules, anything
|
|
65
|
+
needing a database ships as an API endpoint.
|
|
66
|
+
|
|
67
|
+
- JSDoc on `spellcheck/checkSentence` and `spellcheck/suggest` now
|
|
68
|
+
documents the option-bag members that were already accepted but
|
|
69
|
+
undocumented (`nspell`, `accept`, `welsh`). No behaviour change; it
|
|
70
|
+
makes the generated declarations accurate rather than `object`.
|
|
71
|
+
|
|
9
72
|
## [0.3.0] - 2026-05-19
|
|
10
73
|
|
|
11
74
|
### Fixed
|
package/README.md
CHANGED
|
@@ -67,14 +67,27 @@ const errors = checkSentence('Mae cymareg yn iath pwysig', {
|
|
|
67
67
|
const fixes = suggest('cymareg', { nspell: cy, max: 6 })
|
|
68
68
|
// → ['Cymraeg']
|
|
69
69
|
|
|
70
|
-
// Proper-noun extraction (correction-learning heuristic)
|
|
71
|
-
|
|
70
|
+
// Proper-noun extraction (correction-learning heuristic). Import from
|
|
71
|
+
// /corrections/pure if you want the heuristic with no Capsiynau schema
|
|
72
|
+
// attached; /corrections re-exports it too.
|
|
73
|
+
import { extractProperNounCorrections } from '@capsiynau/intelligence/corrections/pure'
|
|
72
74
|
|
|
73
75
|
const corrections = extractProperNounCorrections(
|
|
74
76
|
'Allard Perry spoke at the event',
|
|
75
77
|
'Aled Parry spoke at the event',
|
|
76
78
|
)
|
|
77
|
-
// → ['Aled Parry']
|
|
79
|
+
// → ['Aled', 'Parry'] (corrected TOKENS, not the phrase)
|
|
80
|
+
|
|
81
|
+
// Diarisation quality gate: decide whether speaker labels are trustworthy
|
|
82
|
+
// enough to show at all.
|
|
83
|
+
import { assessDiarisation } from '@capsiynau/intelligence/speakers'
|
|
84
|
+
|
|
85
|
+
const verdict = assessDiarisation(utterances, segments)
|
|
86
|
+
// e.g. for a 10-minute recording that diarisation collapsed to one speaker:
|
|
87
|
+
// → { reliable: false, speakerCount: 1, coverage: 0.933…, durationMs: 600000,
|
|
88
|
+
// reason: 'sole_speaker_long_audio' }
|
|
89
|
+
// Suppress the labels when reliable === false. "Speaker 1" on everything
|
|
90
|
+
// when four people spoke is worse than showing nothing.
|
|
78
91
|
|
|
79
92
|
// And to persist into word_boost_approved (caller provides the Supabase
|
|
80
93
|
// client; the package itself has zero Supabase coupling):
|
|
@@ -91,9 +104,27 @@ const persisted = await captureCorrections(before, after, { sessionId, supabase
|
|
|
91
104
|
|--------|----------|
|
|
92
105
|
| `@capsiynau/intelligence/welsh` | `normaliseWelsh`, `possibleRoots` + mutation helpers, `splitIntoLetters` + digraph utilities |
|
|
93
106
|
| `@capsiynau/intelligence/spellcheck` | `checkWord`, `checkSentence`, `tokenise`, `suggest` — mutation-aware, accepts caller's nspell instance |
|
|
94
|
-
| `@capsiynau/intelligence/corrections` |
|
|
107
|
+
| `@capsiynau/intelligence/corrections` | Both halves below, for backwards compatibility |
|
|
108
|
+
| `@capsiynau/intelligence/corrections/pure` | `extractProperNounCorrections` - portable, no database |
|
|
109
|
+
| `@capsiynau/intelligence/speakers` | `assessDiarisation` (diarisation quality gate), `mapSpeakersToNames`, `clampedUnionMs` |
|
|
95
110
|
| `@capsiynau/intelligence/glossary` | Glossary Builder pipeline helpers: `parse`, `prompts`, `tools`, `url`, `import`, `persist` |
|
|
96
111
|
|
|
112
|
+
Every subpath ships TypeScript declarations (`types/`, generated from the
|
|
113
|
+
JSDoc), so no ambient shim is needed in a TS consumer.
|
|
114
|
+
|
|
115
|
+
### Portability
|
|
116
|
+
|
|
117
|
+
Two exports are **Capsiynau-specific** and will not work against another
|
|
118
|
+
database. They stay in the package but are not shared capabilities:
|
|
119
|
+
|
|
120
|
+
- `captureCorrections` (`/corrections`) reads `live_segments` and
|
|
121
|
+
`live_sessions`, writes `word_boost_approved` via the
|
|
122
|
+
`bump_word_boost_approved` RPC. Import `/corrections/pure` instead if you
|
|
123
|
+
only want the heuristic.
|
|
124
|
+
- `glossary/persist` builds rows shaped to Capsiynau's `glossaries` table.
|
|
125
|
+
|
|
126
|
+
Everything else is a pure function of its inputs.
|
|
127
|
+
|
|
97
128
|
## Design
|
|
98
129
|
|
|
99
130
|
- **Caller-provided dictionaries.** The package doesn't bundle nspell or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capsiynau/intelligence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -27,15 +27,40 @@
|
|
|
27
27
|
"capsiynau"
|
|
28
28
|
],
|
|
29
29
|
"main": "./src/index.js",
|
|
30
|
+
"types": "./types/index.d.ts",
|
|
30
31
|
"exports": {
|
|
31
|
-
".":
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"./
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./types/index.d.ts",
|
|
34
|
+
"default": "./src/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./welsh": {
|
|
37
|
+
"types": "./types/welsh/index.d.ts",
|
|
38
|
+
"default": "./src/welsh/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./corrections": {
|
|
41
|
+
"types": "./types/corrections/index.d.ts",
|
|
42
|
+
"default": "./src/corrections/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./corrections/pure": {
|
|
45
|
+
"types": "./types/corrections/pure.d.ts",
|
|
46
|
+
"default": "./src/corrections/pure.js"
|
|
47
|
+
},
|
|
48
|
+
"./glossary": {
|
|
49
|
+
"types": "./types/glossary/index.d.ts",
|
|
50
|
+
"default": "./src/glossary/index.js"
|
|
51
|
+
},
|
|
52
|
+
"./spellcheck": {
|
|
53
|
+
"types": "./types/spellcheck/index.d.ts",
|
|
54
|
+
"default": "./src/spellcheck/index.js"
|
|
55
|
+
},
|
|
56
|
+
"./speakers": {
|
|
57
|
+
"types": "./types/speakers/index.d.ts",
|
|
58
|
+
"default": "./src/speakers/index.js"
|
|
59
|
+
}
|
|
36
60
|
},
|
|
37
61
|
"files": [
|
|
38
62
|
"src",
|
|
63
|
+
"types",
|
|
39
64
|
"README.md",
|
|
40
65
|
"SCHEMA.md",
|
|
41
66
|
"CHANGELOG.md",
|
|
@@ -58,7 +83,8 @@
|
|
|
58
83
|
"access": "public"
|
|
59
84
|
},
|
|
60
85
|
"scripts": {
|
|
61
|
-
"test": "vitest run __tests__"
|
|
86
|
+
"test": "vitest run __tests__",
|
|
87
|
+
"build:types": "tsc -p tsconfig.json"
|
|
62
88
|
},
|
|
63
89
|
"sideEffects": false
|
|
64
90
|
}
|
package/src/corrections/index.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @capsiynau/intelligence/corrections
|
|
3
3
|
*
|
|
4
|
-
* Public surface for the correction-learning loop.
|
|
4
|
+
* Public surface for the correction-learning loop. Re-exports BOTH
|
|
5
|
+
* halves so this import path keeps its full 0.3.0 surface:
|
|
6
|
+
*
|
|
7
|
+
* - `./pure.js` portable. Diff + proper-noun heuristic. No database.
|
|
8
|
+
* - `./tracker.js` Capsiynau-specific. Reads live_sessions /
|
|
9
|
+
* live_segments, writes word_boost_approved.
|
|
10
|
+
*
|
|
11
|
+
* A consumer on a different schema should import
|
|
12
|
+
* `@capsiynau/intelligence/corrections/pure` instead, and get the
|
|
13
|
+
* heuristic without the schema coupling.
|
|
5
14
|
*/
|
|
15
|
+
export * from './pure.js'
|
|
6
16
|
export * from './tracker.js'
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @capsiynau/intelligence/corrections/pure
|
|
3
|
+
*
|
|
4
|
+
* The PORTABLE half of the correction-learning loop: a whitespace-token
|
|
5
|
+
* diff plus a Title-Case proper-noun heuristic. Pure functions of their
|
|
6
|
+
* inputs - no database, no API key, no spend, no per-tenant state - so
|
|
7
|
+
* per the lib-versus-API rule this half is a library capability that any
|
|
8
|
+
* consuming app can import.
|
|
9
|
+
*
|
|
10
|
+
* Split out of `./tracker.js` in 0.4.0. The other half,
|
|
11
|
+
* `captureCorrections()`, reads `live_segments` / `live_sessions` and
|
|
12
|
+
* writes `word_boost_approved` via the `bump_word_boost_approved` RPC:
|
|
13
|
+
* that is Capsiynau's schema, not a shared capability, and an app on a
|
|
14
|
+
* different database cannot use it. Importing from here gets you the
|
|
15
|
+
* heuristic without inheriting any of that.
|
|
16
|
+
*
|
|
17
|
+
* `./corrections` still re-exports both halves, so existing consumers
|
|
18
|
+
* (Nodiadau's src/lib/correctionTracker.ts) keep working unchanged.
|
|
19
|
+
*
|
|
20
|
+
* Ported verbatim (TS -> JS) from Nodiadau commit 5d75dde
|
|
21
|
+
* (aledprysparry/notes-app#239). Keep parity with the Nodiadau
|
|
22
|
+
* implementation - any heuristic change here must mirror in
|
|
23
|
+
* notes-app/src/lib/correctionTracker.ts (or vice versa) or the two
|
|
24
|
+
* apps' learning behaviour silently diverges.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Words that are *not* worth promoting even if Title-Case. Keep narrow -
|
|
29
|
+
* this is a deny-list, not a dictionary.
|
|
30
|
+
*/
|
|
31
|
+
const PROPER_NOUN_STOPWORDS = new Set([
|
|
32
|
+
// English filler
|
|
33
|
+
'The', 'This', 'That', 'These', 'Those', 'There', 'Their', 'They',
|
|
34
|
+
'Then', 'When', 'Where', 'What', 'Which', 'While', 'Whose', 'Whom',
|
|
35
|
+
'With', 'Without', 'About', 'Above', 'After', 'Again', 'Against',
|
|
36
|
+
'Also', 'Some', 'Such', 'Same', 'Should', 'Would', 'Could', 'Might',
|
|
37
|
+
'Have', 'Been', 'Were', 'From', 'Into', 'Onto',
|
|
38
|
+
// Welsh filler (Title-Case start-of-sentence is common in Welsh too)
|
|
39
|
+
'Mae', "Mae'r", "Mae'n", 'Dyma', 'Dyna', 'Roedd', "Roedd'r",
|
|
40
|
+
'Rwy', 'Rwyt', 'Bydd', "Bydd'r", 'Bod', 'Cael',
|
|
41
|
+
'Wedi', 'Wedyn', 'Felly', 'Ond', 'Achos', 'Ar', "Ar y",
|
|
42
|
+
'Yn', 'Mewn', 'Heb', 'Drwy', 'Drwyddo', 'Drwyddi', 'Drwyddyn',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
const MIN_PROPER_NOUN_LENGTH = 4
|
|
46
|
+
const ALPHA_ONLY = /^[\p{L}\p{M}'-]+$/u
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tokenise on whitespace AND strip trailing punctuation per word. Keeps
|
|
50
|
+
* in-word punctuation (apostrophes, hyphens) which Welsh names need
|
|
51
|
+
* ("ap Iolo", "Tŷ'r-pol", etc.).
|
|
52
|
+
*
|
|
53
|
+
* @param {string} text
|
|
54
|
+
* @returns {string[]}
|
|
55
|
+
*/
|
|
56
|
+
function tokenise(text) {
|
|
57
|
+
return text
|
|
58
|
+
.trim()
|
|
59
|
+
.split(/\s+/)
|
|
60
|
+
.map((tok) => tok.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, ''))
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns true if the token looks like a name/place/brand we want to bias
|
|
66
|
+
* toward. Heuristics, not a dictionary.
|
|
67
|
+
*
|
|
68
|
+
* --- PART OF THE LANGUAGE-AGNOSTIC CONTRACT --------------------------
|
|
69
|
+
* The relay's `word_boost_approved` loader (worker/live-relay.js) reads
|
|
70
|
+
* rows from this table language-agnostically, trusting that this
|
|
71
|
+
* heuristic only matches semantically language-invariant terms (Title-
|
|
72
|
+
* Case proper nouns: personal names, brand names, place names).
|
|
73
|
+
*
|
|
74
|
+
* If you ever loosen this heuristic to also match per-language vocabulary
|
|
75
|
+
* (hyphenated compounds, lowercase acronyms, dialect markers, domain
|
|
76
|
+
* lexicon, etc.), the loader has to follow. Either:
|
|
77
|
+
* (a) re-introduce a per-language filter on the read side, OR
|
|
78
|
+
* (b) add a row-level `word_boost_approved.cross_language` discriminator
|
|
79
|
+
* so the loader can decide per row (Phase 1 of notes-app#262 queues
|
|
80
|
+
* this micro-add).
|
|
81
|
+
*
|
|
82
|
+
* Change the heuristic, change the loader.
|
|
83
|
+
* ---------------------------------------------------------------------
|
|
84
|
+
*
|
|
85
|
+
* @param {string} token
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function looksLikeProperNoun(token) {
|
|
89
|
+
if (!token || token.length < MIN_PROPER_NOUN_LENGTH) return false
|
|
90
|
+
if (!ALPHA_ONLY.test(token)) return false
|
|
91
|
+
const first = token[0]
|
|
92
|
+
// Must start with an upper-case letter
|
|
93
|
+
if (first.toUpperCase() !== first || first.toLowerCase() === first) return false
|
|
94
|
+
if (PROPER_NOUN_STOPWORDS.has(token)) return false
|
|
95
|
+
// Avoid promoting ALL-CAPS strings (likely acronyms or shouting)
|
|
96
|
+
if (token.toUpperCase() === token && token.length > 4) return false
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute the set of proper-noun corrections worth promoting.
|
|
102
|
+
*
|
|
103
|
+
* Diffs `before` against `after` on whitespace tokens and returns the
|
|
104
|
+
* corrected tokens that look like proper nouns. Structural edits (the
|
|
105
|
+
* token count changed) return `[]`: word-for-word swaps are the
|
|
106
|
+
* high-signal class and everything else is too noisy to learn from.
|
|
107
|
+
*
|
|
108
|
+
* In Capsiynau these terms become rows in `word_boost_approved` (per-org,
|
|
109
|
+
* tagged with the session's language) and the relay's loader treats those
|
|
110
|
+
* rows as language-agnostic on read - see the contract note on
|
|
111
|
+
* `looksLikeProperNoun` above for the full invariant and what must change
|
|
112
|
+
* if the heuristic ever expands beyond proper nouns. The function itself
|
|
113
|
+
* knows nothing about that table; a consumer on a different schema can
|
|
114
|
+
* do whatever it likes with the returned terms.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} before Original text before the edit.
|
|
117
|
+
* @param {string} after Text after the edit.
|
|
118
|
+
* @returns {string[]} Deduplicated corrected tokens worth biasing toward.
|
|
119
|
+
*/
|
|
120
|
+
export function extractProperNounCorrections(before, after) {
|
|
121
|
+
const a = tokenise(before)
|
|
122
|
+
const b = tokenise(after)
|
|
123
|
+
// Skip structural changes (token count differs) - same heuristic as the
|
|
124
|
+
// 2am vocabulary-learning cron. Word-for-word swaps are the high-signal class.
|
|
125
|
+
if (a.length !== b.length || a.length === 0) return []
|
|
126
|
+
const out = new Set()
|
|
127
|
+
for (let i = 0; i < a.length; i++) {
|
|
128
|
+
if (a[i] === b[i]) continue
|
|
129
|
+
const corrected = b[i]
|
|
130
|
+
if (looksLikeProperNoun(corrected)) out.add(corrected)
|
|
131
|
+
}
|
|
132
|
+
return Array.from(out)
|
|
133
|
+
}
|
|
@@ -1,74 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @capsiynau/intelligence/corrections/tracker
|
|
3
3
|
*
|
|
4
|
+
* The CAPSIYNAU-PERSISTENT half of the correction-learning loop. This
|
|
5
|
+
* module is NOT portable: it reads `live_segments` and `live_sessions`
|
|
6
|
+
* and writes `word_boost_approved` via the `bump_word_boost_approved`
|
|
7
|
+
* RPC. An app on a different database cannot use it. The Supabase client
|
|
8
|
+
* is dependency-injected (0.2.0), which removed the module-alias coupling
|
|
9
|
+
* but not the schema coupling.
|
|
10
|
+
*
|
|
11
|
+
* The portable half - the diff plus the proper-noun heuristic - lives in
|
|
12
|
+
* `./pure.js` and was split out here in 0.4.0 so a consumer can import
|
|
13
|
+
* `@capsiynau/intelligence/corrections/pure` without inheriting any of
|
|
14
|
+
* Capsiynau's schema. `./index.js` re-exports both halves, so importing
|
|
15
|
+
* `@capsiynau/intelligence/corrections` still gets you everything.
|
|
16
|
+
*
|
|
4
17
|
* Moved here in Phase A (see docs/intelligence-layer-phase-a.md +
|
|
5
18
|
* PR #448) so Nodiadau can git-subtree this package rather than
|
|
6
19
|
* maintain a hand-mirrored copy. Original path: src/lib/correctionTracker.js.
|
|
7
20
|
*
|
|
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
21
|
* When a moderator fixes a Whisper mis-recognition in the live transcript
|
|
13
|
-
* (e.g. "Allard Perry"
|
|
22
|
+
* (e.g. "Allard Perry" -> "Aled Parry"), we want the model to recognise the
|
|
14
23
|
* corrected term next time. The Capsiynau live-relay reads
|
|
15
24
|
* `word_boost_approved` for the session's org + language and injects
|
|
16
25
|
* matching terms into the Whisper `prompt` parameter (PR #421).
|
|
17
26
|
*
|
|
18
27
|
* Flow:
|
|
19
|
-
* 1. Diff the segment text before/after the edit
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
28
|
+
* 1. Diff the segment text before/after the edit and pick out the
|
|
29
|
+
* proper-noun swaps worth learning (`./pure.js`).
|
|
30
|
+
* 2. Upsert them into word_boost_approved with the session's
|
|
31
|
+
* organisation_id + source_language. RLS scopes by org via
|
|
32
|
+
* get_user_org_ids() so a term never leaks into another tenant's bias.
|
|
32
33
|
*/
|
|
33
34
|
|
|
34
35
|
// Phase B (2026-05-17, PR #495): the Supabase client is passed in by
|
|
35
36
|
// the caller rather than imported from a Capsiynau-specific path. This
|
|
36
37
|
// removes the package's only coupling to the host app's module alias
|
|
37
38
|
// (`@/api/supabaseClient`) and lets Nodiadau consume captureCorrections
|
|
38
|
-
// without a re-export shim. Breaking API change vs 0.1.0
|
|
39
|
+
// without a re-export shim. Breaking API change vs 0.1.0 - bumped to
|
|
39
40
|
// 0.2.0. Callers now pass `supabase` in the options bag alongside
|
|
40
41
|
// `sessionId`.
|
|
41
42
|
|
|
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
|
|
43
|
+
import { extractProperNounCorrections } from './pure.js'
|
|
62
44
|
|
|
63
45
|
/**
|
|
64
46
|
* Collapse the session's source_language to the two-bucket convention used
|
|
65
47
|
* everywhere else in this repo for STT bias loading: cy-GB (any Welsh
|
|
66
|
-
* variant
|
|
48
|
+
* variant - broadcast, north, south, formal) or en-GB (everything else,
|
|
67
49
|
* including NULL).
|
|
68
50
|
*
|
|
69
51
|
* Pre-this fix, this module defaulted unknown / NULL to 'cy-GB', which
|
|
70
52
|
* silently routed English users' legacy-session learned vocab into the
|
|
71
53
|
* Welsh bias bucket. Mirrors notes-app/src/lib/correctionTracker.ts.
|
|
54
|
+
*
|
|
55
|
+
* @param {string|null|undefined} source
|
|
56
|
+
* @returns {'cy-GB'|'en-GB'}
|
|
72
57
|
*/
|
|
73
58
|
function normaliseLanguageBucket(source) {
|
|
74
59
|
// Case-insensitive: legacy / externally-written source_language values
|
|
@@ -79,85 +64,19 @@ function normaliseLanguageBucket(source) {
|
|
|
79
64
|
return 'en-GB'
|
|
80
65
|
}
|
|
81
66
|
|
|
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
67
|
/**
|
|
154
68
|
* Upsert proper-noun corrections into word_boost_approved for the session's
|
|
155
69
|
* org + source language. Returns the list of terms persisted (excludes
|
|
156
|
-
* duplicates already in the table
|
|
70
|
+
* duplicates already in the table - upsert is silent on conflict).
|
|
157
71
|
*
|
|
158
|
-
* Never throws
|
|
72
|
+
* Never throws - failures are logged and swallowed so a segment-edit save
|
|
159
73
|
* is never blocked by a vocabulary-learning hiccup.
|
|
160
74
|
*
|
|
75
|
+
* CAPSIYNAU-SPECIFIC. Requires `live_sessions`, `live_segments` and
|
|
76
|
+
* `word_boost_approved`. If you only want the heuristic, import
|
|
77
|
+
* `extractProperNounCorrections` from
|
|
78
|
+
* `@capsiynau/intelligence/corrections/pure`.
|
|
79
|
+
*
|
|
161
80
|
* @param {string} before Original segment text before the edit.
|
|
162
81
|
* @param {string} after Text after the edit.
|
|
163
82
|
* @param {object} opts
|
|
@@ -174,6 +93,7 @@ export function extractProperNounCorrections(before, after) {
|
|
|
174
93
|
* @param {object} opts.supabase Caller-provided Supabase client (DI).
|
|
175
94
|
* Must have `.from()` and `.rpc()` methods.
|
|
176
95
|
* RLS scopes writes by org.
|
|
96
|
+
* @returns {Promise<string[]>} Terms persisted (empty on any failure).
|
|
177
97
|
*/
|
|
178
98
|
export async function captureCorrections(before, after, { sessionId, segmentId, supabase }) {
|
|
179
99
|
if (!supabase) {
|
|
@@ -225,7 +145,7 @@ export async function captureCorrections(before, after, { sessionId, segmentId,
|
|
|
225
145
|
|
|
226
146
|
const language = normaliseLanguageBucket(segmentLanguage || sess.source_language)
|
|
227
147
|
|
|
228
|
-
// Phase 4.2
|
|
148
|
+
// Phase 4.2 - call the SECURITY DEFINER RPC for an atomic
|
|
229
149
|
// INSERT ... ON CONFLICT DO UPDATE SET confirmed_by_count =
|
|
230
150
|
// confirmed_by_count + 1, last_confirmed_at = now(). This is what makes
|
|
231
151
|
// repeat confirmations of the same proper noun count toward bias
|
|
@@ -246,12 +166,12 @@ export async function captureCorrections(before, after, { sessionId, segmentId,
|
|
|
246
166
|
p_auto_approved: true,
|
|
247
167
|
})
|
|
248
168
|
if (rpcErr) {
|
|
249
|
-
// RPC not deployed on this env
|
|
169
|
+
// RPC not deployed on this env -> bail out and fall back to upsert.
|
|
250
170
|
if (rpcErr.code === 'PGRST202' || /Could not find the function/i.test(rpcErr.message || '')) {
|
|
251
171
|
rpcMissing = true
|
|
252
172
|
break
|
|
253
173
|
}
|
|
254
|
-
// Other errors (forbidden, network)
|
|
174
|
+
// Other errors (forbidden, network) - log and skip this term.
|
|
255
175
|
console.warn('[correctionTracker] bump_word_boost_approved error:', rpcErr.message)
|
|
256
176
|
continue
|
|
257
177
|
}
|