garu-ko 0.6.10 → 0.7.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/README.md +24 -2
- package/dist/browser.d.ts +13 -0
- package/dist/browser.js +42 -0
- package/dist/{index.d.ts → core.d.ts} +11 -13
- package/dist/core.js +97 -0
- package/dist/node.d.ts +13 -0
- package/dist/node.js +39 -0
- package/models/cnn2.bin +0 -0
- package/package.json +21 -4
- package/pkg/garu_wasm.js +2 -2
- package/pkg/garu_wasm_bg.wasm +0 -0
- package/pkg/package.json +1 -1
- package/pkg/snippets/garu-core-5657584039ad7577/inline0.js +1 -0
- package/pkg/snippets/garu-core-c1b6130374165958/inline0.js +1 -0
- package/dist/index.js +0 -167
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Browser-native Korean morphological analyzer.** No server required.
|
|
4
4
|
|
|
5
|
-
- **1.
|
|
5
|
+
- **1.9MB model** bundled in npm package (no CDN needed)
|
|
6
6
|
- **93KB WASM** engine -- runs in any modern browser
|
|
7
7
|
- **F1 91.1%** on human-verified gold testset (vs. Kiwi 89.7%)
|
|
8
8
|
- **< 1ms** inference per sentence
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
| | Kiwi | MeCab-ko | garu-ko |
|
|
15
15
|
|---|---|---|---|
|
|
16
|
-
| Model size | ~40MB | ~50MB | **1.
|
|
16
|
+
| Model size | ~40MB | ~50MB | **1.9MB** |
|
|
17
17
|
| npm package | No | No | **Yes** |
|
|
18
18
|
| F1 (gold testset) | 89.7% | — | **91.1%** |
|
|
19
19
|
| F1 (NIKL MP) | 87.9% | ~85% | **93.7%** |
|
|
@@ -109,6 +109,28 @@ Returns surface-form strings only. Lightweight alternative to `analyze()`.
|
|
|
109
109
|
|
|
110
110
|
Free WASM memory. Instance is unusable after this call.
|
|
111
111
|
|
|
112
|
+
## Integrations
|
|
113
|
+
|
|
114
|
+
Drop-in tokenizers for popular JS search libraries:
|
|
115
|
+
|
|
116
|
+
- **[`garu-orama-tokenizer`](https://www.npmjs.com/package/garu-orama-tokenizer)** — Korean tokenization for [Orama](https://github.com/oramasearch/orama)
|
|
117
|
+
- **[`garu-minisearch-tokenizer`](https://www.npmjs.com/package/garu-minisearch-tokenizer)** — Korean tokenization for [MiniSearch](https://github.com/lucaong/minisearch)
|
|
118
|
+
|
|
119
|
+
Both solve the same problem: default tokenizers don't handle Korean particles or verb inflections, so `"먹다"` never matches `"먹었다"` and `"학교"` misses `"학교에"`. These adapters run morphological analysis so the inflections fall off before indexing.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { create, insert, search } from '@orama/orama'
|
|
123
|
+
import { createTokenizer } from 'garu-orama-tokenizer'
|
|
124
|
+
|
|
125
|
+
const db = await create({
|
|
126
|
+
schema: { title: 'string' },
|
|
127
|
+
components: { tokenizer: await createTokenizer() }
|
|
128
|
+
})
|
|
129
|
+
await insert(db, { title: '학교에서 점심을 먹었다' })
|
|
130
|
+
|
|
131
|
+
await search(db, { term: '먹다' }) // ← matches
|
|
132
|
+
```
|
|
133
|
+
|
|
112
134
|
## Acknowledgments
|
|
113
135
|
|
|
114
136
|
The morphological analysis model is trained on the **NIKL Morpheme-Tagged Corpus (v1.1)** provided by the National Institute of Korean Language (국립국어원). The model contains only derived frequency statistics, not original text.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { GaruBase } from './core.js';
|
|
2
|
+
import type { LoadOptions } from './core.js';
|
|
3
|
+
export type { POS, Token, AnalyzeResult, AnalyzeOptions, NounsOptions, LoadOptions, ModelInfo, NormalizeOptions, Segment, } from './core.js';
|
|
4
|
+
export { normalizeText, splitSentences } from './normalize.js';
|
|
5
|
+
/**
|
|
6
|
+
* Browser-targeted Garu. Resolves WASM and model assets via `fetch` and
|
|
7
|
+
* `new URL(..., import.meta.url)`. Has no Node-only imports (`fs/promises`,
|
|
8
|
+
* `url`, `path`), so bundlers (Vite, Webpack, Rollup) emit a clean
|
|
9
|
+
* browser-only chunk.
|
|
10
|
+
*/
|
|
11
|
+
export declare class Garu extends GaruBase {
|
|
12
|
+
static load(options?: LoadOptions): Promise<Garu>;
|
|
13
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { GaruBase } from './core.js';
|
|
2
|
+
export { normalizeText, splitSentences } from './normalize.js';
|
|
3
|
+
/**
|
|
4
|
+
* Browser-targeted Garu. Resolves WASM and model assets via `fetch` and
|
|
5
|
+
* `new URL(..., import.meta.url)`. Has no Node-only imports (`fs/promises`,
|
|
6
|
+
* `url`, `path`), so bundlers (Vite, Webpack, Rollup) emit a clean
|
|
7
|
+
* browser-only chunk.
|
|
8
|
+
*/
|
|
9
|
+
export class Garu extends GaruBase {
|
|
10
|
+
static async load(options) {
|
|
11
|
+
// @ts-ignore dynamic WASM import
|
|
12
|
+
const wasmModule = await import('../pkg/garu_wasm.js');
|
|
13
|
+
await wasmModule.default();
|
|
14
|
+
let modelBytes;
|
|
15
|
+
if (options?.modelData) {
|
|
16
|
+
modelBytes = new Uint8Array(options.modelData);
|
|
17
|
+
}
|
|
18
|
+
else if (options?.modelUrl) {
|
|
19
|
+
const response = await fetch(options.modelUrl);
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw new Error(`Failed to fetch model from ${options.modelUrl}: ${response.status} ${response.statusText}`);
|
|
22
|
+
}
|
|
23
|
+
modelBytes = new Uint8Array(await response.arrayBuffer());
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const url = new URL('../models/base.gmdl', import.meta.url).href;
|
|
27
|
+
const response = await fetch(url);
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Failed to fetch model from ${url}: ${response.status} ${response.statusText}`);
|
|
30
|
+
}
|
|
31
|
+
modelBytes = new Uint8Array(await response.arrayBuffer());
|
|
32
|
+
}
|
|
33
|
+
const cnnUrl = new URL('../models/cnn2.bin', import.meta.url).href;
|
|
34
|
+
const cnnResp = await fetch(cnnUrl);
|
|
35
|
+
if (!cnnResp.ok) {
|
|
36
|
+
throw new Error(`Failed to fetch CNN model from ${cnnUrl}: ${cnnResp.status}`);
|
|
37
|
+
}
|
|
38
|
+
const cnnBytes = new Uint8Array(await cnnResp.arrayBuffer());
|
|
39
|
+
const wasmInstance = new wasmModule.GaruWasm(modelBytes, cnnBytes);
|
|
40
|
+
return new Garu(wasmInstance, modelBytes.byteLength + cnnBytes.byteLength);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -28,19 +28,17 @@ export interface ModelInfo {
|
|
|
28
28
|
size: number;
|
|
29
29
|
accuracy: number;
|
|
30
30
|
}
|
|
31
|
-
export declare
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*/
|
|
43
|
-
static load(options?: LoadOptions): Promise<Garu>;
|
|
31
|
+
export declare const EMPTY_RESULT: AnalyzeResult;
|
|
32
|
+
/**
|
|
33
|
+
* Shared analyzer instance. The browser/node entry points subclass this and
|
|
34
|
+
* provide their own `static load()` that resolves WASM and model bytes via
|
|
35
|
+
* environment-appropriate APIs (fetch vs fs).
|
|
36
|
+
*/
|
|
37
|
+
export declare class GaruBase {
|
|
38
|
+
protected _wasm: any;
|
|
39
|
+
protected _loaded: boolean;
|
|
40
|
+
protected _modelSize: number;
|
|
41
|
+
protected constructor(wasmInstance: any, modelSize: number);
|
|
44
42
|
/**
|
|
45
43
|
* Analyze Korean text, returning morphological tokens with scores.
|
|
46
44
|
*
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export { normalizeText, splitSentences } from './normalize.js';
|
|
2
|
+
export const EMPTY_RESULT = Object.freeze({
|
|
3
|
+
tokens: [],
|
|
4
|
+
score: 0,
|
|
5
|
+
elapsed: 0,
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* Shared analyzer instance. The browser/node entry points subclass this and
|
|
9
|
+
* provide their own `static load()` that resolves WASM and model bytes via
|
|
10
|
+
* environment-appropriate APIs (fetch vs fs).
|
|
11
|
+
*/
|
|
12
|
+
export class GaruBase {
|
|
13
|
+
constructor(wasmInstance, modelSize) {
|
|
14
|
+
this._wasm = wasmInstance;
|
|
15
|
+
this._loaded = true;
|
|
16
|
+
this._modelSize = modelSize;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Analyze Korean text, returning morphological tokens with scores.
|
|
20
|
+
*
|
|
21
|
+
* When `options.topN` is greater than 1, returns an array of N-best results.
|
|
22
|
+
* Otherwise returns a single AnalyzeResult.
|
|
23
|
+
*
|
|
24
|
+
* Note: topN > 1 is not yet fully supported and may return fewer results.
|
|
25
|
+
*/
|
|
26
|
+
analyze(text, options) {
|
|
27
|
+
if (!this._loaded) {
|
|
28
|
+
throw new Error('Garu instance has been destroyed');
|
|
29
|
+
}
|
|
30
|
+
const topN = options?.topN ?? 1;
|
|
31
|
+
if (topN > 1) {
|
|
32
|
+
if (text === '') {
|
|
33
|
+
return [{ ...EMPTY_RESULT, tokens: [] }];
|
|
34
|
+
}
|
|
35
|
+
return this._wasm.analyze_topn(text, topN);
|
|
36
|
+
}
|
|
37
|
+
if (text === '') {
|
|
38
|
+
return { ...EMPTY_RESULT, tokens: [] };
|
|
39
|
+
}
|
|
40
|
+
return this._wasm.analyze(text);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Quick tokenisation — returns an array of surface-form strings.
|
|
44
|
+
*/
|
|
45
|
+
tokenize(text) {
|
|
46
|
+
if (!this._loaded) {
|
|
47
|
+
throw new Error('Garu instance has been destroyed');
|
|
48
|
+
}
|
|
49
|
+
if (text === '') {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return this._wasm.tokenize(text);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Extract nouns (NNG, NNP) from text.
|
|
56
|
+
* Set `options.includeSL` to also include foreign tokens (SL) like "AI", "BM25".
|
|
57
|
+
*/
|
|
58
|
+
nouns(text, options) {
|
|
59
|
+
if (!this._loaded) {
|
|
60
|
+
throw new Error('Garu instance has been destroyed');
|
|
61
|
+
}
|
|
62
|
+
if (text === '') {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const result = this._wasm.analyze(text);
|
|
66
|
+
const includeSL = options?.includeSL ?? false;
|
|
67
|
+
return result.tokens
|
|
68
|
+
.filter((t) => t.pos === 'NNG' || t.pos === 'NNP' || (includeSL && t.pos === 'SL'))
|
|
69
|
+
.map((t) => t.text);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether the WASM analyzer is loaded and ready.
|
|
73
|
+
*/
|
|
74
|
+
isLoaded() {
|
|
75
|
+
return this._loaded;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Return metadata about the loaded model.
|
|
79
|
+
*/
|
|
80
|
+
modelInfo() {
|
|
81
|
+
return {
|
|
82
|
+
version: this._wasm.constructor.version(),
|
|
83
|
+
size: this._modelSize,
|
|
84
|
+
accuracy: 0.939,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Free the WASM instance and mark this Garu as unloaded.
|
|
89
|
+
*/
|
|
90
|
+
destroy() {
|
|
91
|
+
if (this._wasm) {
|
|
92
|
+
this._wasm.free();
|
|
93
|
+
this._wasm = null;
|
|
94
|
+
}
|
|
95
|
+
this._loaded = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { GaruBase } from './core.js';
|
|
2
|
+
import type { LoadOptions } from './core.js';
|
|
3
|
+
export type { POS, Token, AnalyzeResult, AnalyzeOptions, NounsOptions, LoadOptions, ModelInfo, NormalizeOptions, Segment, } from './core.js';
|
|
4
|
+
export { normalizeText, splitSentences } from './normalize.js';
|
|
5
|
+
/**
|
|
6
|
+
* Node-targeted Garu. Resolves WASM and model bytes via `fs/promises`.
|
|
7
|
+
* Browser code paths must not import this entry — use `garu-ko` directly
|
|
8
|
+
* (resolves via package.json conditional exports) or import from
|
|
9
|
+
* `garu-ko/browser`.
|
|
10
|
+
*/
|
|
11
|
+
export declare class Garu extends GaruBase {
|
|
12
|
+
static load(options?: LoadOptions): Promise<Garu>;
|
|
13
|
+
}
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { GaruBase } from './core.js';
|
|
2
|
+
export { normalizeText, splitSentences } from './normalize.js';
|
|
3
|
+
/**
|
|
4
|
+
* Node-targeted Garu. Resolves WASM and model bytes via `fs/promises`.
|
|
5
|
+
* Browser code paths must not import this entry — use `garu-ko` directly
|
|
6
|
+
* (resolves via package.json conditional exports) or import from
|
|
7
|
+
* `garu-ko/browser`.
|
|
8
|
+
*/
|
|
9
|
+
export class Garu extends GaruBase {
|
|
10
|
+
static async load(options) {
|
|
11
|
+
// @ts-ignore dynamic WASM import
|
|
12
|
+
const wasmModule = await import('../pkg/garu_wasm.js');
|
|
13
|
+
const { readFile } = await import('fs/promises');
|
|
14
|
+
const { fileURLToPath } = await import('url');
|
|
15
|
+
const { join, dirname } = await import('path');
|
|
16
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const wasmBytes = await readFile(join(dir, '..', 'pkg', 'garu_wasm_bg.wasm'));
|
|
18
|
+
await wasmModule.default(wasmBytes);
|
|
19
|
+
let modelBytes;
|
|
20
|
+
if (options?.modelData) {
|
|
21
|
+
modelBytes = new Uint8Array(options.modelData);
|
|
22
|
+
}
|
|
23
|
+
else if (options?.modelUrl) {
|
|
24
|
+
const response = await fetch(options.modelUrl);
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(`Failed to fetch model from ${options.modelUrl}: ${response.status} ${response.statusText}`);
|
|
27
|
+
}
|
|
28
|
+
modelBytes = new Uint8Array(await response.arrayBuffer());
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const buf = await readFile(join(dir, '..', 'models', 'base.gmdl'));
|
|
32
|
+
modelBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
33
|
+
}
|
|
34
|
+
const cnnBuf = await readFile(join(dir, '..', 'models', 'cnn2.bin'));
|
|
35
|
+
const cnnBytes = new Uint8Array(cnnBuf.buffer, cnnBuf.byteOffset, cnnBuf.byteLength);
|
|
36
|
+
const wasmInstance = new wasmModule.GaruWasm(modelBytes, cnnBytes);
|
|
37
|
+
return new Garu(wasmInstance, modelBytes.byteLength + cnnBytes.byteLength);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/models/cnn2.bin
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "garu-ko",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Ultra-lightweight Korean morphological analyzer for the web (1.
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Ultra-lightweight Korean morphological analyzer for the web (1.9MB model, WASM, F1 93.9%)",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "dist/
|
|
7
|
-
"
|
|
6
|
+
"main": "dist/node.js",
|
|
7
|
+
"browser": "dist/browser.js",
|
|
8
|
+
"types": "dist/node.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/node.d.ts",
|
|
12
|
+
"browser": "./dist/browser.js",
|
|
13
|
+
"node": "./dist/node.js",
|
|
14
|
+
"default": "./dist/node.js"
|
|
15
|
+
},
|
|
16
|
+
"./browser": {
|
|
17
|
+
"types": "./dist/browser.d.ts",
|
|
18
|
+
"default": "./dist/browser.js"
|
|
19
|
+
},
|
|
20
|
+
"./node": {
|
|
21
|
+
"types": "./dist/node.d.ts",
|
|
22
|
+
"default": "./dist/node.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
8
25
|
"files": [
|
|
9
26
|
"dist",
|
|
10
27
|
"pkg",
|
package/pkg/garu_wasm.js
CHANGED
|
@@ -85,7 +85,7 @@ export class GaruWasm {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
if (Symbol.dispose) GaruWasm.prototype[Symbol.dispose] = GaruWasm.prototype.free;
|
|
88
|
-
import * as import1 from "./snippets/garu-core-
|
|
88
|
+
import * as import1 from "./snippets/garu-core-5657584039ad7577/inline0.js"
|
|
89
89
|
|
|
90
90
|
function __wbg_get_imports() {
|
|
91
91
|
const import0 = {
|
|
@@ -146,7 +146,7 @@ function __wbg_get_imports() {
|
|
|
146
146
|
return {
|
|
147
147
|
__proto__: null,
|
|
148
148
|
"./garu_wasm_bg.js": import0,
|
|
149
|
-
"./snippets/garu-core-
|
|
149
|
+
"./snippets/garu-core-5657584039ad7577/inline0.js": import1,
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
package/pkg/garu_wasm_bg.wasm
CHANGED
|
Binary file
|
package/pkg/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function performance_now() { return performance.now(); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function performance_now() { return performance.now(); }
|
package/dist/index.js
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
export { normalizeText, splitSentences } from './normalize.js';
|
|
2
|
-
const isNode = typeof process !== 'undefined' &&
|
|
3
|
-
process.versions != null &&
|
|
4
|
-
process.versions.node != null;
|
|
5
|
-
const EMPTY_RESULT = Object.freeze({
|
|
6
|
-
tokens: [],
|
|
7
|
-
score: 0,
|
|
8
|
-
elapsed: 0,
|
|
9
|
-
});
|
|
10
|
-
export class Garu {
|
|
11
|
-
constructor(wasmInstance, modelSize) {
|
|
12
|
-
this._wasm = wasmInstance;
|
|
13
|
-
this._loaded = true;
|
|
14
|
-
this._modelSize = modelSize;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Load the WASM module and model data, returning a ready-to-use Garu instance.
|
|
18
|
-
*
|
|
19
|
-
* @param options.modelData - Provide model bytes directly as an ArrayBuffer
|
|
20
|
-
* @param options.modelUrl - Fetch model from this URL
|
|
21
|
-
* If neither is provided, the model is fetched from the default CDN URL.
|
|
22
|
-
*/
|
|
23
|
-
static async load(options) {
|
|
24
|
-
// Dynamic import of the WASM glue module and initialise it
|
|
25
|
-
// @ts-ignore dynamic WASM import
|
|
26
|
-
const wasmModule = await import('../pkg/garu_wasm.js');
|
|
27
|
-
if (isNode) {
|
|
28
|
-
const { readFile } = await import('fs/promises');
|
|
29
|
-
const { fileURLToPath } = await import('url');
|
|
30
|
-
const { join, dirname } = await import('path');
|
|
31
|
-
const dir = dirname(fileURLToPath(import.meta.url));
|
|
32
|
-
const wasmBytes = await readFile(join(dir, '..', 'pkg', 'garu_wasm_bg.wasm'));
|
|
33
|
-
await wasmModule.default(wasmBytes);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
await wasmModule.default();
|
|
37
|
-
}
|
|
38
|
-
// Resolve model bytes
|
|
39
|
-
let modelBytes;
|
|
40
|
-
if (options?.modelData) {
|
|
41
|
-
modelBytes = new Uint8Array(options.modelData);
|
|
42
|
-
}
|
|
43
|
-
else if (options?.modelUrl) {
|
|
44
|
-
const response = await fetch(options.modelUrl);
|
|
45
|
-
if (!response.ok) {
|
|
46
|
-
throw new Error(`Failed to fetch model from ${options.modelUrl}: ${response.status} ${response.statusText}`);
|
|
47
|
-
}
|
|
48
|
-
modelBytes = new Uint8Array(await response.arrayBuffer());
|
|
49
|
-
}
|
|
50
|
-
else if (isNode) {
|
|
51
|
-
const { readFile } = await import('fs/promises');
|
|
52
|
-
const { fileURLToPath } = await import('url');
|
|
53
|
-
const { join, dirname } = await import('path');
|
|
54
|
-
const dir = dirname(fileURLToPath(import.meta.url));
|
|
55
|
-
const buf = await readFile(join(dir, '..', 'models', 'base.gmdl'));
|
|
56
|
-
modelBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
const url = new URL('../models/base.gmdl', import.meta.url).href;
|
|
60
|
-
const response = await fetch(url);
|
|
61
|
-
if (!response.ok) {
|
|
62
|
-
throw new Error(`Failed to fetch model from ${url}: ${response.status} ${response.statusText}`);
|
|
63
|
-
}
|
|
64
|
-
modelBytes = new Uint8Array(await response.arrayBuffer());
|
|
65
|
-
}
|
|
66
|
-
// Load CNN reranker
|
|
67
|
-
let cnnBytes;
|
|
68
|
-
if (isNode) {
|
|
69
|
-
const { readFile } = await import('fs/promises');
|
|
70
|
-
const { fileURLToPath } = await import('url');
|
|
71
|
-
const { join, dirname } = await import('path');
|
|
72
|
-
const dir = dirname(fileURLToPath(import.meta.url));
|
|
73
|
-
const buf = await readFile(join(dir, '..', 'models', 'cnn2.bin'));
|
|
74
|
-
cnnBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
const cnnUrl = new URL('../models/cnn2.bin', import.meta.url).href;
|
|
78
|
-
const cnnResp = await fetch(cnnUrl);
|
|
79
|
-
if (!cnnResp.ok) {
|
|
80
|
-
throw new Error(`Failed to fetch CNN model from ${cnnUrl}: ${cnnResp.status}`);
|
|
81
|
-
}
|
|
82
|
-
cnnBytes = new Uint8Array(await cnnResp.arrayBuffer());
|
|
83
|
-
}
|
|
84
|
-
// Construct the WASM analyzer with both models
|
|
85
|
-
const wasmInstance = new wasmModule.GaruWasm(modelBytes, cnnBytes);
|
|
86
|
-
return new Garu(wasmInstance, modelBytes.byteLength + cnnBytes.byteLength);
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Analyze Korean text, returning morphological tokens with scores.
|
|
90
|
-
*
|
|
91
|
-
* When `options.topN` is greater than 1, returns an array of N-best results.
|
|
92
|
-
* Otherwise returns a single AnalyzeResult.
|
|
93
|
-
*
|
|
94
|
-
* Note: topN > 1 is not yet fully supported and may return fewer results.
|
|
95
|
-
*/
|
|
96
|
-
analyze(text, options) {
|
|
97
|
-
if (!this._loaded) {
|
|
98
|
-
throw new Error('Garu instance has been destroyed');
|
|
99
|
-
}
|
|
100
|
-
const topN = options?.topN ?? 1;
|
|
101
|
-
if (topN > 1) {
|
|
102
|
-
if (text === '') {
|
|
103
|
-
return [{ ...EMPTY_RESULT, tokens: [] }];
|
|
104
|
-
}
|
|
105
|
-
return this._wasm.analyze_topn(text, topN);
|
|
106
|
-
}
|
|
107
|
-
if (text === '') {
|
|
108
|
-
return { ...EMPTY_RESULT, tokens: [] };
|
|
109
|
-
}
|
|
110
|
-
return this._wasm.analyze(text);
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Quick tokenisation — returns an array of surface-form strings.
|
|
114
|
-
*/
|
|
115
|
-
tokenize(text) {
|
|
116
|
-
if (!this._loaded) {
|
|
117
|
-
throw new Error('Garu instance has been destroyed');
|
|
118
|
-
}
|
|
119
|
-
if (text === '') {
|
|
120
|
-
return [];
|
|
121
|
-
}
|
|
122
|
-
return this._wasm.tokenize(text);
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Extract nouns (NNG, NNP) from text.
|
|
126
|
-
* Set `options.includeSL` to also include foreign tokens (SL) like "AI", "BM25".
|
|
127
|
-
*/
|
|
128
|
-
nouns(text, options) {
|
|
129
|
-
if (!this._loaded) {
|
|
130
|
-
throw new Error('Garu instance has been destroyed');
|
|
131
|
-
}
|
|
132
|
-
if (text === '') {
|
|
133
|
-
return [];
|
|
134
|
-
}
|
|
135
|
-
const result = this._wasm.analyze(text);
|
|
136
|
-
const includeSL = options?.includeSL ?? false;
|
|
137
|
-
return result.tokens
|
|
138
|
-
.filter((t) => t.pos === 'NNG' || t.pos === 'NNP' || (includeSL && t.pos === 'SL'))
|
|
139
|
-
.map((t) => t.text);
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Whether the WASM analyzer is loaded and ready.
|
|
143
|
-
*/
|
|
144
|
-
isLoaded() {
|
|
145
|
-
return this._loaded;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Return metadata about the loaded model.
|
|
149
|
-
*/
|
|
150
|
-
modelInfo() {
|
|
151
|
-
return {
|
|
152
|
-
version: this._wasm.constructor.version(),
|
|
153
|
-
size: this._modelSize,
|
|
154
|
-
accuracy: 0.939,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Free the WASM instance and mark this Garu as unloaded.
|
|
159
|
-
*/
|
|
160
|
-
destroy() {
|
|
161
|
-
if (this._wasm) {
|
|
162
|
-
this._wasm.free();
|
|
163
|
-
this._wasm = null;
|
|
164
|
-
}
|
|
165
|
-
this._loaded = false;
|
|
166
|
-
}
|
|
167
|
-
}
|