@hickeroar/tsbayes 0.1.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 +13 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/classifier.d.ts +36 -0
- package/dist/core/classifier.js +227 -0
- package/dist/core/classifier.js.map +1 -0
- package/dist/core/constants.d.ts +3 -0
- package/dist/core/constants.js +4 -0
- package/dist/core/constants.js.map +1 -0
- package/dist/core/errors.d.ts +6 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/persistence.d.ts +7 -0
- package/dist/core/persistence.js +92 -0
- package/dist/core/persistence.js.map +1 -0
- package/dist/core/tokenizer.d.ts +5 -0
- package/dist/core/tokenizer.js +17 -0
- package/dist/core/tokenizer.js.map +1 -0
- package/dist/core/types.d.ts +16 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/server/app.d.ts +15 -0
- package/dist/server/app.js +107 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/auth.d.ts +5 -0
- package/dist/server/auth.js +49 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/config.d.ts +6 -0
- package/dist/server/config.js +19 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/readiness.d.ts +5 -0
- package/dist/server/readiness.js +10 -0
- package/dist/server/readiness.js.map +1 -0
- package/dist/server/start.d.ts +3 -0
- package/dist/server/start.js +10 -0
- package/dist/server/start.js.map +1 -0
- package/package.json +77 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## [1.0.0] - 2026-02-21
|
|
6
|
+
|
|
7
|
+
- Initial TypeScript implementation of the classifier core.
|
|
8
|
+
- Added HTTP service with training, scoring, classification, and lifecycle endpoints.
|
|
9
|
+
- Invalid `/train/:category` and `/untrain/:category` params now return `400` with `{"error":"invalid request"}`.
|
|
10
|
+
- Added JSON model persistence with validation and atomic file writes.
|
|
11
|
+
- Added strict testing, linting, and CI foundations.
|
|
12
|
+
- Added scoped security audit policy in CI (blocking production dependency audit + non-blocking full-tree visibility report).
|
|
13
|
+
- Pinned vulnerable transitive `minimatch` via npm `overrides` to clear high-severity audit findings.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Vennell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# tsbayes
|
|
2
|
+
|
|
3
|
+
A memory-based, optional-persistence naive Bayesian text classification package and web API for TypeScript/Node.js.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/hickeroar/tsbayes/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/@hickeroar/tsbayes)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why?
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
Bayesian text classification is useful for things like spam detection,
|
|
15
|
+
sentiment determination, and general category routing.
|
|
16
|
+
|
|
17
|
+
You gather representative samples for each category, train the model,
|
|
18
|
+
then classify new text based on learned token patterns.
|
|
19
|
+
|
|
20
|
+
Once the model is trained, you can:
|
|
21
|
+
- classify input into a best-fit category
|
|
22
|
+
- inspect relative per-category scores
|
|
23
|
+
- persist and reload model state
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Requires Node.js 20 or newer.
|
|
29
|
+
|
|
30
|
+
Package usage:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @hickeroar/tsbayes
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Contributor/development setup:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone <your-repo-url>
|
|
40
|
+
cd tsbayes
|
|
41
|
+
npm ci
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Run as an API Server
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm run dev
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Production-style run:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm run build
|
|
56
|
+
npm run start
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Environment variables:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
TSBAYES_HOST
|
|
63
|
+
TSBAYES_PORT
|
|
64
|
+
TSBAYES_AUTH_TOKEN
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
When `TSBAYES_AUTH_TOKEN` is configured, all API endpoints except `/healthz` and `/readyz` require:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
Authorization: Bearer <token>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Use as a Library in Your App
|
|
74
|
+
|
|
75
|
+
Import and create a classifier:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { TextClassifier, loadFromFile, saveToFile } from "@hickeroar/tsbayes";
|
|
79
|
+
|
|
80
|
+
const classifier = new TextClassifier();
|
|
81
|
+
|
|
82
|
+
classifier.train("spam", "buy now limited offer click here");
|
|
83
|
+
classifier.train("ham", "team meeting schedule for tomorrow");
|
|
84
|
+
|
|
85
|
+
const classification = classifier.classificationResult("limited offer today");
|
|
86
|
+
console.log(`category=${classification.category} score=${classification.score}`);
|
|
87
|
+
|
|
88
|
+
const scores = classifier.score("team schedule update");
|
|
89
|
+
console.log(scores);
|
|
90
|
+
|
|
91
|
+
classifier.untrain("spam", "buy now limited offer click here");
|
|
92
|
+
|
|
93
|
+
await saveToFile(classifier, "/tmp/tsbayes-model.json");
|
|
94
|
+
const loaded = new TextClassifier();
|
|
95
|
+
await loadFromFile(loaded, "/tmp/tsbayes-model.json");
|
|
96
|
+
console.log(loaded.classificationResult("limited offer today"));
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Notes for library usage:
|
|
100
|
+
|
|
101
|
+
- Classifier operations are safe for concurrent request handling in a single Node process.
|
|
102
|
+
- Scores are relative values; compare scores within the same model.
|
|
103
|
+
- Default tokenization applies Unicode NFKC normalization, lowercasing, non-word splitting, and English stemming.
|
|
104
|
+
- Category names accepted by `train` and `untrain` match `^[-_A-Za-z0-9]{1,64}$`.
|
|
105
|
+
|
|
106
|
+
File API notes:
|
|
107
|
+
|
|
108
|
+
- `saveToFile` and `loadFromFile` default to `/tmp/tsbayes-model.json` when no path is provided.
|
|
109
|
+
- Provided file paths must be absolute.
|
|
110
|
+
|
|
111
|
+
## Development Checks
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm run format:check
|
|
115
|
+
npm run lint
|
|
116
|
+
npm run typecheck
|
|
117
|
+
npm run test:coverage
|
|
118
|
+
npm run build
|
|
119
|
+
npm run standalone:audit
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Security checks used by CI:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npm audit --omit=dev --audit-level=high
|
|
126
|
+
npm audit --audit-level=high
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Using the HTTP API
|
|
132
|
+
|
|
133
|
+
### API Notes
|
|
134
|
+
|
|
135
|
+
- Category names in `/train/:category` and `/untrain/:category` must match `^[-_A-Za-z0-9]{1,64}$`.
|
|
136
|
+
- Invalid category path params return `400` with `{"error":"invalid request"}`.
|
|
137
|
+
- Request body size is capped at 1 MiB.
|
|
138
|
+
- Error responses are JSON: `{"error":"<message>"}`.
|
|
139
|
+
- If `charset` is declared in `Content-Type`, it must be UTF-8.
|
|
140
|
+
- The HTTP service stores classifier state in memory; process restarts clear training data.
|
|
141
|
+
- Empty request bodies are accepted for `/train/:category` and `/untrain/:category`; this is a no-op for model token tallies.
|
|
142
|
+
|
|
143
|
+
### Common Error Responses
|
|
144
|
+
|
|
145
|
+
| Status | When |
|
|
146
|
+
| ------ | ----------------------------------------------------------------------------------------------------------- |
|
|
147
|
+
| `400` | Invalid payload or route params (for example non-UTF-8 charset, non-text body, or invalid category pattern) |
|
|
148
|
+
| `401` | Missing/invalid bearer token when auth is enabled |
|
|
149
|
+
| `404` | Invalid route |
|
|
150
|
+
| `413` | Request body exceeds 1 MiB |
|
|
151
|
+
| `500` | Unexpected server error |
|
|
152
|
+
|
|
153
|
+
### Training the Classifier
|
|
154
|
+
|
|
155
|
+
##### Endpoint:
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
/train/:category
|
|
159
|
+
Example: /train/spam
|
|
160
|
+
Accepts: POST
|
|
161
|
+
Body: raw text/plain
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
curl -s -X POST "http://localhost:8000/train/spam" \
|
|
168
|
+
-H "Content-Type: text/plain" \
|
|
169
|
+
--data "buy now limited offer click here"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Untraining the Classifier
|
|
173
|
+
|
|
174
|
+
##### Endpoint:
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
/untrain/:category
|
|
178
|
+
Example: /untrain/spam
|
|
179
|
+
Accepts: POST
|
|
180
|
+
Body: raw text/plain
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Getting Classifier Status
|
|
184
|
+
|
|
185
|
+
##### Endpoint:
|
|
186
|
+
|
|
187
|
+
```text
|
|
188
|
+
/info
|
|
189
|
+
Accepts: GET
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Example response:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"categories": [
|
|
197
|
+
{
|
|
198
|
+
"category": "spam",
|
|
199
|
+
"tokenTally": 6
|
|
200
|
+
}
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Classifying Text
|
|
206
|
+
|
|
207
|
+
##### Endpoint:
|
|
208
|
+
|
|
209
|
+
```text
|
|
210
|
+
/classify
|
|
211
|
+
Accepts: POST
|
|
212
|
+
Body: raw text/plain
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Example response:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"category": "spam",
|
|
220
|
+
"score": 3.2142857142857144
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
If no category can be selected (for example, untrained model), `category` is returned as `null`.
|
|
225
|
+
|
|
226
|
+
### Scoring Text
|
|
227
|
+
|
|
228
|
+
##### Endpoint:
|
|
229
|
+
|
|
230
|
+
```text
|
|
231
|
+
/score
|
|
232
|
+
Accepts: POST
|
|
233
|
+
Body: raw text/plain
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Example response:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"spam": 3.2142857142857144,
|
|
241
|
+
"ham": 0.7857142857142857
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Flushing Training Data
|
|
246
|
+
|
|
247
|
+
##### Endpoint:
|
|
248
|
+
|
|
249
|
+
```text
|
|
250
|
+
/flush
|
|
251
|
+
Accepts: POST
|
|
252
|
+
Body: raw text/plain (optional)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Health and Readiness
|
|
256
|
+
|
|
257
|
+
##### Liveness endpoint
|
|
258
|
+
|
|
259
|
+
```text
|
|
260
|
+
/healthz
|
|
261
|
+
Accepts: GET
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
##### Readiness endpoint
|
|
265
|
+
|
|
266
|
+
```text
|
|
267
|
+
/readyz
|
|
268
|
+
Accepts: GET
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`/healthz` and `/readyz` are intentionally unauthenticated even when API auth is enabled.
|
|
272
|
+
|
|
273
|
+
## Operational Notes
|
|
274
|
+
|
|
275
|
+
- The HTTP server is in-memory by default; deploys/restarts wipe trained state.
|
|
276
|
+
- Use `saveToFile` and `loadFromFile` in library workflows to persist/reload model state.
|
|
277
|
+
- `/readyz` returns `200` while accepting traffic and `503` when draining during shutdown.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { loadConfig } from "./server/config.js";
|
|
2
|
+
import { startServer } from "./server/start.js";
|
|
3
|
+
async function run() {
|
|
4
|
+
const config = loadConfig(process.env);
|
|
5
|
+
const context = await startServer(config);
|
|
6
|
+
const shutdown = async () => {
|
|
7
|
+
context.readiness.setReady(false);
|
|
8
|
+
await context.app.close();
|
|
9
|
+
process.exit(0);
|
|
10
|
+
};
|
|
11
|
+
process.on("SIGTERM", () => void shutdown());
|
|
12
|
+
process.on("SIGINT", () => void shutdown());
|
|
13
|
+
}
|
|
14
|
+
void run();
|
|
15
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,KAAK,UAAU,GAAG;IAChB,MAAM,MAAM,GAAG,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACvC,MAAM,OAAO,GAAG,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC;IAE1C,MAAM,QAAQ,GAAG,KAAK,IAAmB,EAAE;QACzC,OAAO,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QAClC,MAAM,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;QAC1B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC;IAEF,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,KAAK,QAAQ,EAAE,CAAC,CAAC;IAC7C,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK,QAAQ,EAAE,CAAC,CAAC;AAC9C,CAAC;AAED,KAAK,GAAG,EAAE,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CategorySummary, ClassificationResult, PersistedModelState } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory naive Bayes classifier for train/untrain/score/classify workflows.
|
|
4
|
+
* Scores are relative ranking values, not calibrated probabilities.
|
|
5
|
+
*/
|
|
6
|
+
export declare class TextClassifier {
|
|
7
|
+
private readonly categories;
|
|
8
|
+
/** Adds token counts from text into the target category. */
|
|
9
|
+
train(category: string, text: string): void;
|
|
10
|
+
/**
|
|
11
|
+
* Removes token counts contributed by text from a category.
|
|
12
|
+
* Missing categories/tokens are treated as no-ops.
|
|
13
|
+
*/
|
|
14
|
+
untrain(category: string, text: string): void;
|
|
15
|
+
/** Returns per-category relative scores for the input text. */
|
|
16
|
+
score(text: string): Record<string, number>;
|
|
17
|
+
/** Convenience API returning only the predicted category. */
|
|
18
|
+
classify(text: string): string | null;
|
|
19
|
+
/** Returns top category plus score; ties resolve lexicographically. */
|
|
20
|
+
classificationResult(text: string): ClassificationResult;
|
|
21
|
+
/** Clears all trained model state. */
|
|
22
|
+
flush(): void;
|
|
23
|
+
/** Summarizes trained categories in stable lexical order. */
|
|
24
|
+
categorySummaries(): CategorySummary[];
|
|
25
|
+
/** Serializes classifier state into the versioned persistence model. */
|
|
26
|
+
save(): PersistedModelState;
|
|
27
|
+
/** Loads and validates a persisted model state into memory. */
|
|
28
|
+
load(model: PersistedModelState): void;
|
|
29
|
+
private ensureCategory;
|
|
30
|
+
private validateCategory;
|
|
31
|
+
private totalTally;
|
|
32
|
+
private tokenTotal;
|
|
33
|
+
private calculateBayesianProbability;
|
|
34
|
+
}
|
|
35
|
+
/** Validates persisted model invariants before load. */
|
|
36
|
+
export declare function validateModelState(model: PersistedModelState): void;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { CATEGORY_PATTERN, MODEL_VERSION } from "./constants.js";
|
|
2
|
+
import { PersistenceError, ValidationError } from "./errors.js";
|
|
3
|
+
import { tokenize } from "./tokenizer.js";
|
|
4
|
+
/**
|
|
5
|
+
* In-memory naive Bayes classifier for train/untrain/score/classify workflows.
|
|
6
|
+
* Scores are relative ranking values, not calibrated probabilities.
|
|
7
|
+
*/
|
|
8
|
+
export class TextClassifier {
|
|
9
|
+
categories = new Map();
|
|
10
|
+
/** Adds token counts from text into the target category. */
|
|
11
|
+
train(category, text) {
|
|
12
|
+
this.validateCategory(category);
|
|
13
|
+
const tokens = tokenize(text);
|
|
14
|
+
const tokenOccurrences = countOccurrences(tokens);
|
|
15
|
+
const state = this.ensureCategory(category);
|
|
16
|
+
for (const [token, count] of tokenOccurrences) {
|
|
17
|
+
const currentCount = state.tokens.get(token) ?? 0;
|
|
18
|
+
state.tokens.set(token, currentCount + count);
|
|
19
|
+
state.tally += count;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Removes token counts contributed by text from a category.
|
|
24
|
+
* Missing categories/tokens are treated as no-ops.
|
|
25
|
+
*/
|
|
26
|
+
untrain(category, text) {
|
|
27
|
+
this.validateCategory(category);
|
|
28
|
+
const state = this.categories.get(category);
|
|
29
|
+
if (!state) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const tokenOccurrences = countOccurrences(tokenize(text));
|
|
33
|
+
for (const [token, count] of tokenOccurrences) {
|
|
34
|
+
const existing = state.tokens.get(token) ?? 0;
|
|
35
|
+
if (existing <= 0) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const decrement = Math.min(existing, count);
|
|
39
|
+
const nextValue = existing - decrement;
|
|
40
|
+
state.tally -= decrement;
|
|
41
|
+
if (nextValue <= 0) {
|
|
42
|
+
state.tokens.delete(token);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
state.tokens.set(token, nextValue);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (state.tally <= 0) {
|
|
49
|
+
this.categories.delete(category);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Returns per-category relative scores for the input text. */
|
|
53
|
+
score(text) {
|
|
54
|
+
const tokens = tokenize(text);
|
|
55
|
+
const occurrences = countOccurrences(tokens);
|
|
56
|
+
const totalTally = this.totalTally();
|
|
57
|
+
if (totalTally <= 0) {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
const scores = new Map();
|
|
61
|
+
for (const [token, occurrenceCount] of occurrences) {
|
|
62
|
+
const tokenTotal = this.tokenTotal(token);
|
|
63
|
+
// Unknown tokens add no evidence to any category.
|
|
64
|
+
if (tokenTotal <= 0) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const [name, state] of this.categories) {
|
|
68
|
+
const tokenInCategory = state.tokens.get(token) ?? 0;
|
|
69
|
+
const probability = this.calculateBayesianProbability({
|
|
70
|
+
tokenInCategory,
|
|
71
|
+
tokenTotal,
|
|
72
|
+
categoryTally: state.tally,
|
|
73
|
+
totalTally
|
|
74
|
+
});
|
|
75
|
+
const current = scores.get(name) ?? 0;
|
|
76
|
+
scores.set(name, current + occurrenceCount * probability);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const response = {};
|
|
80
|
+
for (const [category, value] of scores) {
|
|
81
|
+
// Keep output aligned with reference behavior: only positive scores are emitted.
|
|
82
|
+
if (value > 0) {
|
|
83
|
+
response[category] = value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return response;
|
|
87
|
+
}
|
|
88
|
+
/** Convenience API returning only the predicted category. */
|
|
89
|
+
classify(text) {
|
|
90
|
+
return this.classificationResult(text).category;
|
|
91
|
+
}
|
|
92
|
+
/** Returns top category plus score; ties resolve lexicographically. */
|
|
93
|
+
classificationResult(text) {
|
|
94
|
+
const scores = this.score(text);
|
|
95
|
+
const orderedScores = Object.entries(scores).sort(([a], [b]) => a.localeCompare(b));
|
|
96
|
+
let bestCategory = null;
|
|
97
|
+
let bestScore = 0;
|
|
98
|
+
// We sort first so strict ">" makes tie results deterministic.
|
|
99
|
+
for (const [category, score] of orderedScores) {
|
|
100
|
+
if (score > bestScore) {
|
|
101
|
+
bestScore = score;
|
|
102
|
+
bestCategory = category;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { category: bestCategory, score: bestScore };
|
|
106
|
+
}
|
|
107
|
+
/** Clears all trained model state. */
|
|
108
|
+
flush() {
|
|
109
|
+
this.categories.clear();
|
|
110
|
+
}
|
|
111
|
+
/** Summarizes trained categories in stable lexical order. */
|
|
112
|
+
categorySummaries() {
|
|
113
|
+
return [...this.categories.entries()]
|
|
114
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
115
|
+
.map(([category, state]) => ({
|
|
116
|
+
category,
|
|
117
|
+
tokenTally: state.tally
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
/** Serializes classifier state into the versioned persistence model. */
|
|
121
|
+
save() {
|
|
122
|
+
const categories = {};
|
|
123
|
+
for (const [name, state] of this.categories) {
|
|
124
|
+
categories[name] = {
|
|
125
|
+
tally: state.tally,
|
|
126
|
+
tokens: Object.fromEntries(state.tokens)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
version: MODEL_VERSION,
|
|
131
|
+
categories
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Loads and validates a persisted model state into memory. */
|
|
135
|
+
load(model) {
|
|
136
|
+
validateModelState(model);
|
|
137
|
+
this.categories.clear();
|
|
138
|
+
for (const [name, persisted] of Object.entries(model.categories)) {
|
|
139
|
+
const tokens = new Map();
|
|
140
|
+
let tally = 0;
|
|
141
|
+
for (const [token, count] of Object.entries(persisted.tokens)) {
|
|
142
|
+
tokens.set(token, count);
|
|
143
|
+
tally += count;
|
|
144
|
+
}
|
|
145
|
+
this.categories.set(name, { tally, tokens });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
ensureCategory(category) {
|
|
149
|
+
const existing = this.categories.get(category);
|
|
150
|
+
if (existing) {
|
|
151
|
+
return existing;
|
|
152
|
+
}
|
|
153
|
+
const created = { tally: 0, tokens: new Map() };
|
|
154
|
+
this.categories.set(category, created);
|
|
155
|
+
return created;
|
|
156
|
+
}
|
|
157
|
+
validateCategory(category) {
|
|
158
|
+
if (!CATEGORY_PATTERN.test(category)) {
|
|
159
|
+
throw new ValidationError("invalid category");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
totalTally() {
|
|
163
|
+
let total = 0;
|
|
164
|
+
for (const state of this.categories.values()) {
|
|
165
|
+
total += state.tally;
|
|
166
|
+
}
|
|
167
|
+
return total;
|
|
168
|
+
}
|
|
169
|
+
tokenTotal(token) {
|
|
170
|
+
let total = 0;
|
|
171
|
+
for (const state of this.categories.values()) {
|
|
172
|
+
total += state.tokens.get(token) ?? 0;
|
|
173
|
+
}
|
|
174
|
+
return total;
|
|
175
|
+
}
|
|
176
|
+
calculateBayesianProbability(input) {
|
|
177
|
+
const { tokenInCategory, tokenTotal, categoryTally, totalTally } = input;
|
|
178
|
+
if (categoryTally <= 0 || totalTally <= 0) {
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
const categoryPrior = categoryTally / totalTally;
|
|
182
|
+
const nonCategoryPrior = 1 - categoryPrior;
|
|
183
|
+
const inCategoryProb = tokenInCategory / categoryTally;
|
|
184
|
+
const remainingTally = totalTally - categoryTally;
|
|
185
|
+
const nonCategoryTokenCount = tokenTotal - tokenInCategory;
|
|
186
|
+
// If no non-category evidence exists, treat non-category probability as zero.
|
|
187
|
+
const notInCategoryProb = remainingTally > 0 && nonCategoryTokenCount > 0 ? nonCategoryTokenCount / remainingTally : 0;
|
|
188
|
+
const numerator = inCategoryProb * categoryPrior;
|
|
189
|
+
const denominator = numerator + notInCategoryProb * nonCategoryPrior;
|
|
190
|
+
return numerator / denominator;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Validates persisted model invariants before load. */
|
|
194
|
+
export function validateModelState(model) {
|
|
195
|
+
if (model.version !== MODEL_VERSION) {
|
|
196
|
+
throw new PersistenceError("unsupported model version");
|
|
197
|
+
}
|
|
198
|
+
for (const [name, category] of Object.entries(model.categories)) {
|
|
199
|
+
if (!CATEGORY_PATTERN.test(name)) {
|
|
200
|
+
throw new PersistenceError("invalid category in model");
|
|
201
|
+
}
|
|
202
|
+
if (!Number.isInteger(category.tally) || category.tally < 0) {
|
|
203
|
+
throw new PersistenceError("invalid tally in model");
|
|
204
|
+
}
|
|
205
|
+
let sum = 0;
|
|
206
|
+
for (const [token, count] of Object.entries(category.tokens)) {
|
|
207
|
+
if (token.length === 0) {
|
|
208
|
+
throw new PersistenceError("invalid token in model");
|
|
209
|
+
}
|
|
210
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
211
|
+
throw new PersistenceError("invalid token count in model");
|
|
212
|
+
}
|
|
213
|
+
sum += count;
|
|
214
|
+
}
|
|
215
|
+
if (sum !== category.tally) {
|
|
216
|
+
throw new PersistenceError("inconsistent tally in model");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function countOccurrences(tokens) {
|
|
221
|
+
const occurrences = new Map();
|
|
222
|
+
for (const token of tokens) {
|
|
223
|
+
occurrences.set(token, (occurrences.get(token) ?? 0) + 1);
|
|
224
|
+
}
|
|
225
|
+
return occurrences;
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=classifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classifier.js","sourceRoot":"","sources":["../../src/core/classifier.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.js","sourceRoot":"","sources":["../../src/core/constants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,gBAAgB,GAAG,uBAAuB,CAAC;AACxD,MAAM,CAAC,MAAM,uBAAuB,GAAG,yBAAyB,CAAC;AACjE,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class ValidationError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "ValidationError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class PersistenceError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "PersistenceError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/core/errors.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,eAAgB,SAAQ,KAAK;IACxC,YAAmB,OAAe;QAChC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;IAChC,CAAC;CACF;AAED,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzC,YAAmB,OAAe;QAChC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;IACjC,CAAC;CACF"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TextClassifier } from "./classifier.js";
|
|
2
|
+
/** Saves model state to disk via temp-write + rename for atomic replacement. */
|
|
3
|
+
export declare function saveToFile(classifier: TextClassifier, filePath?: string): Promise<void>;
|
|
4
|
+
/** Loads model state from disk and validates structure before classifier-level validation. */
|
|
5
|
+
export declare function loadFromFile(classifier: TextClassifier, filePath?: string): Promise<void>;
|
|
6
|
+
/** Helper for tests and tooling that need an ephemeral persisted model file. */
|
|
7
|
+
export declare function saveToTempFile(classifier: TextClassifier): Promise<string>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mkdtemp, open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { DEFAULT_MODEL_FILE_PATH } from "./constants.js";
|
|
5
|
+
import { PersistenceError } from "./errors.js";
|
|
6
|
+
/** Saves model state to disk via temp-write + rename for atomic replacement. */
|
|
7
|
+
export async function saveToFile(classifier, filePath = DEFAULT_MODEL_FILE_PATH) {
|
|
8
|
+
if (!isAbsolute(filePath)) {
|
|
9
|
+
throw new PersistenceError("model path must be absolute");
|
|
10
|
+
}
|
|
11
|
+
const model = classifier.save();
|
|
12
|
+
const dir = dirname(filePath);
|
|
13
|
+
const tmpDir = await mkdtemp(join(dir, ".tsbayes-"));
|
|
14
|
+
const tmpFile = join(tmpDir, "model.json.tmp");
|
|
15
|
+
const payload = `${JSON.stringify(model)}\n`;
|
|
16
|
+
try {
|
|
17
|
+
await writeFile(tmpFile, payload, "utf8");
|
|
18
|
+
// fsync before rename to reduce the chance of partially persisted writes.
|
|
19
|
+
const handle = await open(tmpFile, "r");
|
|
20
|
+
try {
|
|
21
|
+
await handle.sync();
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await handle.close();
|
|
25
|
+
}
|
|
26
|
+
await rename(tmpFile, filePath);
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Loads model state from disk and validates structure before classifier-level validation. */
|
|
33
|
+
export async function loadFromFile(classifier, filePath = DEFAULT_MODEL_FILE_PATH) {
|
|
34
|
+
if (!isAbsolute(filePath)) {
|
|
35
|
+
throw new PersistenceError("model path must be absolute");
|
|
36
|
+
}
|
|
37
|
+
const content = await readFile(filePath, "utf8");
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(content);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new PersistenceError("model file is not valid JSON");
|
|
44
|
+
}
|
|
45
|
+
if (!isPersistedModelStateShape(parsed)) {
|
|
46
|
+
throw new PersistenceError("model file has invalid structure");
|
|
47
|
+
}
|
|
48
|
+
classifier.load(parsed);
|
|
49
|
+
}
|
|
50
|
+
/** Helper for tests and tooling that need an ephemeral persisted model file. */
|
|
51
|
+
export async function saveToTempFile(classifier) {
|
|
52
|
+
const path = join(tmpdir(), `tsbayes-${Date.now()}.json`);
|
|
53
|
+
await saveToFile(classifier, path);
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
/** Fast runtime shape check so invalid JSON shapes fail with clear errors. */
|
|
57
|
+
function isPersistedModelStateShape(value) {
|
|
58
|
+
if (!isRecord(value)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (!Number.isInteger(value.version)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (!isRecord(value.categories)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
for (const [categoryName, categoryValue] of Object.entries(value.categories)) {
|
|
68
|
+
if (categoryName.length === 0 || !isRecord(categoryValue)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const tally = categoryValue.tally;
|
|
72
|
+
if (typeof tally !== "number" || !Number.isInteger(tally) || tally < 0) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (!isRecord(categoryValue.tokens)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
for (const [token, count] of Object.entries(categoryValue.tokens)) {
|
|
79
|
+
if (token.length === 0 ||
|
|
80
|
+
typeof count !== "number" ||
|
|
81
|
+
!Number.isInteger(count) ||
|
|
82
|
+
count <= 0) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
function isRecord(value) {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=persistence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.js","sourceRoot":"","sources":["../../src/core/persistence.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { newStemmer } from "snowball-stemmers";
|
|
2
|
+
const NON_WORD_SPLIT = /[^\p{L}\p{N}]+/u;
|
|
3
|
+
const stemmer = newStemmer("english");
|
|
4
|
+
/**
|
|
5
|
+
* Default tokenizer used by classifier training/scoring.
|
|
6
|
+
* Pipeline: NFKC normalize -> lowercase -> split -> trim/filter -> stem.
|
|
7
|
+
*/
|
|
8
|
+
export function tokenize(text) {
|
|
9
|
+
return text
|
|
10
|
+
.normalize("NFKC")
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.split(NON_WORD_SPLIT)
|
|
13
|
+
.map((token) => token.trim())
|
|
14
|
+
.filter((token) => token.length > 0)
|
|
15
|
+
.map((token) => stemmer.stem(token));
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=tokenizer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenizer.js","sourceRoot":"","sources":["../../src/core/tokenizer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAE/C,MAAM,cAAc,GAAG,iBAAiB,CAAC;AACzC,MAAM,OAAO,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;AAEtC;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAY;IACnC,OAAO,IAAI;SACR,SAAS,CAAC,MAAM,CAAC;SACjB,WAAW,EAAE;SACb,KAAK,CAAC,cAAc,CAAC;SACrB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;SAC5B,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;SACnC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;AACzC,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface CategorySummary {
|
|
2
|
+
category: string;
|
|
3
|
+
tokenTally: number;
|
|
4
|
+
}
|
|
5
|
+
export interface ClassificationResult {
|
|
6
|
+
category: string | null;
|
|
7
|
+
score: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PersistedCategoryState {
|
|
10
|
+
tally: number;
|
|
11
|
+
tokens: Record<string, number>;
|
|
12
|
+
}
|
|
13
|
+
export interface PersistedModelState {
|
|
14
|
+
version: number;
|
|
15
|
+
categories: Record<string, PersistedCategoryState>;
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/core/types.ts"],"names":[],"mappings":""}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { TextClassifier, validateModelState } from "./core/classifier.js";
|
|
2
|
+
export { tokenize } from "./core/tokenizer.js";
|
|
3
|
+
export { saveToFile, loadFromFile } from "./core/persistence.js";
|
|
4
|
+
export { createApp } from "./server/app.js";
|
|
5
|
+
export type { AppContext, AppOptions } from "./server/app.js";
|
|
6
|
+
export type { ClassificationResult, CategorySummary, PersistedModelState } from "./core/types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { TextClassifier, validateModelState } from "./core/classifier.js";
|
|
2
|
+
export { tokenize } from "./core/tokenizer.js";
|
|
3
|
+
export { saveToFile, loadFromFile } from "./core/persistence.js";
|
|
4
|
+
export { createApp } from "./server/app.js";
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1E,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type FastifyInstance } from "fastify";
|
|
2
|
+
import { TextClassifier } from "../core/classifier.js";
|
|
3
|
+
import { Readiness } from "./readiness.js";
|
|
4
|
+
export interface AppOptions {
|
|
5
|
+
authToken: string | null;
|
|
6
|
+
classifier?: TextClassifier;
|
|
7
|
+
readiness?: Readiness;
|
|
8
|
+
}
|
|
9
|
+
export interface AppContext {
|
|
10
|
+
app: FastifyInstance;
|
|
11
|
+
classifier: TextClassifier;
|
|
12
|
+
readiness: Readiness;
|
|
13
|
+
}
|
|
14
|
+
/** Builds the Fastify app and wires classifier, auth, validation, and health endpoints. */
|
|
15
|
+
export declare function createApp(options: AppOptions): AppContext;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import { TextClassifier } from "../core/classifier.js";
|
|
3
|
+
import { CATEGORY_PATTERN } from "../core/constants.js";
|
|
4
|
+
import { ValidationError } from "../core/errors.js";
|
|
5
|
+
import { Readiness } from "./readiness.js";
|
|
6
|
+
import { checkAuthorization } from "./auth.js";
|
|
7
|
+
const MAX_BODY_SIZE_BYTES = 1024 * 1024;
|
|
8
|
+
const CATEGORY_PARAMS_SCHEMA = {
|
|
9
|
+
type: "object",
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
required: ["category"],
|
|
12
|
+
properties: {
|
|
13
|
+
category: {
|
|
14
|
+
type: "string",
|
|
15
|
+
minLength: 1,
|
|
16
|
+
maxLength: 64,
|
|
17
|
+
pattern: CATEGORY_PATTERN.source
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** Builds the Fastify app and wires classifier, auth, validation, and health endpoints. */
|
|
22
|
+
export function createApp(options) {
|
|
23
|
+
const classifier = options.classifier ?? new TextClassifier();
|
|
24
|
+
const readiness = options.readiness ?? new Readiness();
|
|
25
|
+
const app = Fastify({
|
|
26
|
+
logger: false,
|
|
27
|
+
bodyLimit: MAX_BODY_SIZE_BYTES
|
|
28
|
+
});
|
|
29
|
+
// Parse all payloads as UTF-8 text; endpoint handlers decide how to use content.
|
|
30
|
+
app.addContentTypeParser("*", { parseAs: "buffer" }, (request, body, done) => {
|
|
31
|
+
done(null, body.toString("utf8"));
|
|
32
|
+
});
|
|
33
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
34
|
+
// Charset checks happen early so invalid content types fail consistently.
|
|
35
|
+
const charset = parseCharset(request.headers["content-type"]);
|
|
36
|
+
if (charset && charset.toLowerCase() !== "utf-8") {
|
|
37
|
+
throw new ValidationError("content must be utf-8");
|
|
38
|
+
}
|
|
39
|
+
if (!checkAuthorization(options.authToken, request, reply)) {
|
|
40
|
+
return reply;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
});
|
|
44
|
+
app.get("/healthz", () => ({ status: "ok" }));
|
|
45
|
+
app.get("/readyz", (request, reply) => {
|
|
46
|
+
if (!readiness.isReady()) {
|
|
47
|
+
return reply.code(503).send({ error: "not ready" });
|
|
48
|
+
}
|
|
49
|
+
return { status: "ready" };
|
|
50
|
+
});
|
|
51
|
+
app.get("/info", () => ({ categories: classifier.categorySummaries() }));
|
|
52
|
+
app.post("/train/:category", { schema: { params: CATEGORY_PARAMS_SCHEMA } }, async (request, reply) => {
|
|
53
|
+
const { category } = request.params;
|
|
54
|
+
classifier.train(category, bodyAsText(request.body));
|
|
55
|
+
return reply.code(204).send();
|
|
56
|
+
});
|
|
57
|
+
app.post("/untrain/:category", { schema: { params: CATEGORY_PARAMS_SCHEMA } }, async (request, reply) => {
|
|
58
|
+
const { category } = request.params;
|
|
59
|
+
classifier.untrain(category, bodyAsText(request.body));
|
|
60
|
+
return reply.code(204).send();
|
|
61
|
+
});
|
|
62
|
+
app.post("/classify", (request) => classifier.classificationResult(bodyAsText(request.body)));
|
|
63
|
+
app.post("/score", (request) => classifier.score(bodyAsText(request.body)));
|
|
64
|
+
app.post("/flush", (request, reply) => {
|
|
65
|
+
classifier.flush();
|
|
66
|
+
return reply.code(204).send();
|
|
67
|
+
});
|
|
68
|
+
app.setErrorHandler((error, request, reply) => {
|
|
69
|
+
if (error instanceof ValidationError) {
|
|
70
|
+
return reply.code(400).send({ error: error.message });
|
|
71
|
+
}
|
|
72
|
+
if (error.code === "FST_ERR_VALIDATION") {
|
|
73
|
+
// Route-schema validation failures map to one stable API error.
|
|
74
|
+
return reply.code(400).send({ error: "invalid request" });
|
|
75
|
+
}
|
|
76
|
+
if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
|
|
77
|
+
return reply.code(413).send({ error: "payload too large" });
|
|
78
|
+
}
|
|
79
|
+
request.log.error(error);
|
|
80
|
+
return reply.code(500).send({ error: "internal server error" });
|
|
81
|
+
});
|
|
82
|
+
app.setNotFoundHandler((request, reply) => reply.code(404).send({ error: "not found" }));
|
|
83
|
+
return { app, classifier, readiness };
|
|
84
|
+
}
|
|
85
|
+
function bodyAsText(body) {
|
|
86
|
+
if (body == null) {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
if (typeof body === "string") {
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
92
|
+
throw new ValidationError("body must be text");
|
|
93
|
+
}
|
|
94
|
+
/** Extracts optional charset from Content-Type (for example "text/plain; charset=utf-8"). */
|
|
95
|
+
function parseCharset(contentType) {
|
|
96
|
+
if (!contentType) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const parts = contentType.split(";").map((part) => part.trim());
|
|
100
|
+
for (const part of parts) {
|
|
101
|
+
if (part.toLowerCase().startsWith("charset=")) {
|
|
102
|
+
return part.slice("charset=".length).trim();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=app.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app.js","sourceRoot":"","sources":["../../src/server/app.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
/** Probe routes intentionally bypass auth checks. */
|
|
3
|
+
export declare function isProbePath(path: string): boolean;
|
|
4
|
+
/** Validates bearer auth for protected routes and writes 401 responses when needed. */
|
|
5
|
+
export declare function checkAuthorization(token: string | null, request: FastifyRequest, reply: FastifyReply): boolean;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
/** Probe routes intentionally bypass auth checks. */
|
|
3
|
+
export function isProbePath(path) {
|
|
4
|
+
return path === "/healthz" || path === "/readyz";
|
|
5
|
+
}
|
|
6
|
+
/** Validates bearer auth for protected routes and writes 401 responses when needed. */
|
|
7
|
+
export function checkAuthorization(token, request, reply) {
|
|
8
|
+
if (!token || isProbePath(request.url)) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
const header = request.headers.authorization;
|
|
12
|
+
if (!header) {
|
|
13
|
+
reply.header("WWW-Authenticate", "Bearer");
|
|
14
|
+
reply.code(401).send({ error: "unauthorized" });
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const match = /^Bearer ([^\s]+)$/i.exec(header.trim());
|
|
18
|
+
if (!match) {
|
|
19
|
+
reply.header("WWW-Authenticate", "Bearer");
|
|
20
|
+
reply.code(401).send({ error: "unauthorized" });
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const value = match[1];
|
|
24
|
+
if (!safeEqual(value, token)) {
|
|
25
|
+
reply.header("WWW-Authenticate", "Bearer");
|
|
26
|
+
reply.code(401).send({ error: "unauthorized" });
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Constant-time comparison for token equality.
|
|
33
|
+
* For mismatched lengths we still run timingSafeEqual on padded buffers.
|
|
34
|
+
*/
|
|
35
|
+
function safeEqual(left, right) {
|
|
36
|
+
const l = Buffer.from(left, "utf8");
|
|
37
|
+
const r = Buffer.from(right, "utf8");
|
|
38
|
+
if (l.length !== r.length) {
|
|
39
|
+
const max = Math.max(l.length, r.length);
|
|
40
|
+
const lp = Buffer.alloc(max);
|
|
41
|
+
const rp = Buffer.alloc(max);
|
|
42
|
+
l.copy(lp);
|
|
43
|
+
r.copy(rp);
|
|
44
|
+
timingSafeEqual(lp, rp);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return timingSafeEqual(l, r);
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG9C,qDAAqD;AACrD,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,IAAI,KAAK,UAAU,IAAI,IAAI,KAAK,SAAS,CAAC;AACnD,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,kBAAkB,CAChC,KAAoB,EACpB,OAAuB,EACvB,KAAmB;IAEnB,IAAI,CAAC,KAAK,IAAI,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QACvC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC;IAC7C,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,KAAK,CAAC,MAAM,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;QAC3C,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;QAChD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,KAAK,GAAG,oBAAoB,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACvD,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,KAAK,CAAC,MAAM,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;QAC3C,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;QAChD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;IAExB,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC;QAC7B,KAAK,CAAC,MAAM,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;QAC3C,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;QAChD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,SAAS,SAAS,CAAC,IAAY,EAAE,KAAa;IAC5C,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACpC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACrC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;QACzC,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7B,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7B,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACX,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACX,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;QACxB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,OAAO,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/B,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ValidationError } from "../core/errors.js";
|
|
2
|
+
export function loadConfig(env) {
|
|
3
|
+
const host = env.TSBAYES_HOST ?? "0.0.0.0";
|
|
4
|
+
const port = parsePort(env.TSBAYES_PORT ?? "8000");
|
|
5
|
+
const authToken = (env.TSBAYES_AUTH_TOKEN ?? "").trim();
|
|
6
|
+
return {
|
|
7
|
+
host,
|
|
8
|
+
port,
|
|
9
|
+
authToken: authToken.length > 0 ? authToken : null
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function parsePort(raw) {
|
|
13
|
+
const value = Number(raw);
|
|
14
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
15
|
+
throw new ValidationError("invalid port");
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/server/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAQpD,MAAM,UAAU,UAAU,CAAC,GAAsB;IAC/C,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,IAAI,SAAS,CAAC;IAC3C,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,YAAY,IAAI,MAAM,CAAC,CAAC;IACnD,MAAM,SAAS,GAAG,CAAC,GAAG,CAAC,kBAAkB,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAExD,OAAO;QACL,IAAI;QACJ,IAAI;QACJ,SAAS,EAAE,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI;KACnD,CAAC;AACJ,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC5B,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,KAAK,EAAE,CAAC;QAC3D,MAAM,IAAI,eAAe,CAAC,cAAc,CAAC,CAAC;IAC5C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"readiness.js","sourceRoot":"","sources":["../../src/server/readiness.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,SAAS;IACZ,KAAK,GAAG,IAAI,CAAC;IAEd,OAAO;QACZ,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAEM,QAAQ,CAAC,KAAc;QAC5B,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;CACF"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createApp } from "./app.js";
|
|
2
|
+
export async function startServer(config) {
|
|
3
|
+
const context = createApp({ authToken: config.authToken });
|
|
4
|
+
await context.app.listen({
|
|
5
|
+
host: config.host,
|
|
6
|
+
port: config.port
|
|
7
|
+
});
|
|
8
|
+
return context;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=start.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"start.js","sourceRoot":"","sources":["../../src/server/start.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAmB,MAAM,UAAU,CAAC;AAGtD,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAoB;IACpD,MAAM,OAAO,GAAG,SAAS,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC;IAC3D,MAAM,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC;QACvB,IAAI,EAAE,MAAM,CAAC,IAAI;QACjB,IAAI,EAAE,MAAM,CAAC,IAAI;KAClB,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hickeroar/tsbayes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A trainable Bayesian text classifier library and HTTP service.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Ryan Vennell <ryan.vennell@gmail.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/hickeroar/tsbayes.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/hickeroar/tsbayes#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/hickeroar/tsbayes/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"bayes",
|
|
18
|
+
"naive-bayes",
|
|
19
|
+
"text-classification",
|
|
20
|
+
"fastify",
|
|
21
|
+
"typescript"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"tsbayes": "dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"CHANGELOG.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20.0.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json",
|
|
44
|
+
"clean": "rm -rf dist coverage",
|
|
45
|
+
"start": "node dist/cli.js",
|
|
46
|
+
"dev": "tsx watch src/cli.ts",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"lint": "eslint .",
|
|
49
|
+
"format": "prettier --write .",
|
|
50
|
+
"format:check": "prettier --check .",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest",
|
|
53
|
+
"test:coverage": "vitest run --coverage",
|
|
54
|
+
"precommit": "npm run format:check && npm run lint && npm run typecheck && npm run test",
|
|
55
|
+
"prepublishOnly": "npm run clean && npm run lint && npm run typecheck && npm run test:coverage && npm run build",
|
|
56
|
+
"standalone:audit": "node scripts/standalone-audit.mjs"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"fastify": "5.7.4",
|
|
60
|
+
"snowball-stemmers": "0.6.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@eslint/js": "10.0.1",
|
|
64
|
+
"@types/node": "25.3.0",
|
|
65
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
66
|
+
"eslint": "10.0.1",
|
|
67
|
+
"eslint-config-prettier": "10.1.8",
|
|
68
|
+
"prettier": "3.8.1",
|
|
69
|
+
"tsx": "4.21.0",
|
|
70
|
+
"typescript": "5.9.3",
|
|
71
|
+
"typescript-eslint": "8.56.0",
|
|
72
|
+
"vitest": "4.0.18"
|
|
73
|
+
},
|
|
74
|
+
"overrides": {
|
|
75
|
+
"minimatch": "^10.2.1"
|
|
76
|
+
}
|
|
77
|
+
}
|