open-classify 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/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/src/aggregator.d.ts +18 -0
- package/dist/src/aggregator.js +267 -0
- package/dist/src/catalog.d.ts +7 -0
- package/dist/src/catalog.js +189 -0
- package/dist/src/classifiers/custom/conversation_diegest/manifest.json +28 -0
- package/dist/src/classifiers/custom/conversation_diegest/prompt.md +7 -0
- package/dist/src/classifiers/custom/memory_retrieval_queries/manifest.json +29 -0
- package/dist/src/classifiers/custom/memory_retrieval_queries/prompt.md +5 -0
- package/dist/src/classifiers/stock/model_specialization/manifest.json +8 -0
- package/dist/src/classifiers/stock/preflight/manifest.json +8 -0
- package/dist/src/classifiers/stock/prompts/base.md +1 -0
- package/dist/src/classifiers/stock/prompts/classifier-header.md +4 -0
- package/dist/src/classifiers/stock/prompts/confidence.md +3 -0
- package/dist/src/classifiers/stock/prompts/custom-output.md +1 -0
- package/dist/src/classifiers/stock/prompts/model_specialization.md +7 -0
- package/dist/src/classifiers/stock/prompts/preflight-output.md +10 -0
- package/dist/src/classifiers/stock/prompts/preflight.md +47 -0
- package/dist/src/classifiers/stock/prompts/reason.md +3 -0
- package/dist/src/classifiers/stock/prompts/routing-output.md +5 -0
- package/dist/src/classifiers/stock/prompts/routing.md +9 -0
- package/dist/src/classifiers/stock/prompts/security-output.md +8 -0
- package/dist/src/classifiers/stock/prompts/security.md +26 -0
- package/dist/src/classifiers/stock/prompts/specialty.md +10 -0
- package/dist/src/classifiers/stock/prompts/tier.md +7 -0
- package/dist/src/classifiers/stock/prompts/tools-output.md +7 -0
- package/dist/src/classifiers/stock/prompts/tools.md +10 -0
- package/dist/src/classifiers/stock/routing/manifest.json +8 -0
- package/dist/src/classifiers/stock/security/manifest.json +12 -0
- package/dist/src/classifiers/stock/tools/manifest.json +19 -0
- package/dist/src/classifiers.d.ts +14 -0
- package/dist/src/classifiers.js +87 -0
- package/dist/src/config.d.ts +29 -0
- package/dist/src/config.js +144 -0
- package/dist/src/enums.d.ts +10 -0
- package/dist/src/enums.js +62 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +18 -0
- package/dist/src/input.d.ts +4 -0
- package/dist/src/input.js +192 -0
- package/dist/src/manifest.d.ts +115 -0
- package/dist/src/manifest.js +1 -0
- package/dist/src/ollama.d.ts +54 -0
- package/dist/src/ollama.js +293 -0
- package/dist/src/pipeline.d.ts +17 -0
- package/dist/src/pipeline.js +274 -0
- package/dist/src/stock-prompt.d.ts +2 -0
- package/dist/src/stock-prompt.js +63 -0
- package/dist/src/stock-validation.d.ts +22 -0
- package/dist/src/stock-validation.js +329 -0
- package/dist/src/stock.d.ts +101 -0
- package/dist/src/stock.js +14 -0
- package/dist/src/types.d.ts +34 -0
- package/dist/src/types.js +6 -0
- package/dist/src/ui-server.d.ts +1 -0
- package/dist/src/ui-server.js +250 -0
- package/dist/src/validation.d.ts +17 -0
- package/dist/src/validation.js +127 -0
- package/open-classify.config.example.json +24 -0
- package/package.json +56 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// A tiny dev/demo HTTP server backing the bundled UI. Two responsibilities:
|
|
2
|
+
// 1. Serve the static UI from `./ui` (HTML, CSS, JS).
|
|
3
|
+
// 2. Run a classification over Server-Sent Events at /api/classify-stream.
|
|
4
|
+
//
|
|
5
|
+
// The SSE event vocabulary the UI listens for:
|
|
6
|
+
// pipeline_started — pipeline boot, includes the classifier list
|
|
7
|
+
// pipeline_phase — coarse phase ("normalizing" / "resource_check" /
|
|
8
|
+
// "running"); useful for progress UI
|
|
9
|
+
// classifier_started — a specific classifier is now running
|
|
10
|
+
// classifier_completed — that classifier returned a model result
|
|
11
|
+
// classifier_failed — that classifier threw without being aborted
|
|
12
|
+
// classifier_aborted — early-exit short-circuit cancelled this classifier
|
|
13
|
+
// classifier_timed_out — the per-classifier timeout fired
|
|
14
|
+
// pipeline_completed — final PipelineResult payload
|
|
15
|
+
// pipeline_failed — pipeline-level error (normalization, etc.)
|
|
16
|
+
//
|
|
17
|
+
// This server is intentionally minimal — no auth, no rate limiting, binds to
|
|
18
|
+
// 127.0.0.1 by default. It is not meant for production.
|
|
19
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
20
|
+
import { createServer } from "node:http";
|
|
21
|
+
import { extname, join, normalize } from "node:path";
|
|
22
|
+
import { loadCatalog } from "./catalog.js";
|
|
23
|
+
import { CLASSIFIER_NAMES, REGISTRY } from "./classifiers.js";
|
|
24
|
+
import { classifierModelsFromConfig, loadOpenClassifyConfig, } from "./config.js";
|
|
25
|
+
import { DOWNSTREAM_MODEL_TIER_VALUES, MODEL_SPECIALIZATION_VALUES, SECURITY_DECISION_VALUES, SECURITY_RISK_LEVEL_VALUES, SECURITY_SIGNAL_VALUES, } from "./enums.js";
|
|
26
|
+
import { createOllamaClassifierRunner, OLLAMA_CONTEXT_LENGTH, OLLAMA_DEFAULT_CATALOG_PATH, OLLAMA_MIN_AVAILABLE_MEMORY_BYTES, OLLAMA_MIN_TOTAL_MEMORY_BYTES, OLLAMA_REQUIRED_PARALLELISM, } from "./ollama.js";
|
|
27
|
+
import { classifyOpenClassifyInput } from "./pipeline.js";
|
|
28
|
+
// Served at GET /api/enums so the UI never needs to duplicate shared enum values.
|
|
29
|
+
const CLASSIFIER_ENUMS = {
|
|
30
|
+
downstream_model_tier: [...DOWNSTREAM_MODEL_TIER_VALUES],
|
|
31
|
+
model_specialization: [...MODEL_SPECIALIZATION_VALUES],
|
|
32
|
+
security_decision: [...SECURITY_DECISION_VALUES],
|
|
33
|
+
security_risk_level: [...SECURITY_RISK_LEVEL_VALUES],
|
|
34
|
+
security_signal: [...SECURITY_SIGNAL_VALUES],
|
|
35
|
+
};
|
|
36
|
+
const CLASSIFIER_METADATA = REGISTRY.map((classifier) => ({
|
|
37
|
+
name: classifier.name,
|
|
38
|
+
kind: classifier.kind,
|
|
39
|
+
version: classifier.version,
|
|
40
|
+
purpose: classifier.purpose,
|
|
41
|
+
order: classifier.order,
|
|
42
|
+
...("tools" in classifier ? { tools: classifier.tools ?? [] } : {}),
|
|
43
|
+
}));
|
|
44
|
+
const PORT = Number(process.env.OPEN_CLASSIFY_UI_PORT ?? 4317);
|
|
45
|
+
const HOST = process.env.OPEN_CLASSIFY_UI_HOST ?? "127.0.0.1";
|
|
46
|
+
const UI_DIR = join(process.cwd(), "ui");
|
|
47
|
+
const OPEN_CLASSIFY_CONFIG = loadOpenClassifyConfig(undefined, {
|
|
48
|
+
optional: process.env.OPEN_CLASSIFY_CONFIG === undefined,
|
|
49
|
+
});
|
|
50
|
+
const CATALOG_PATH = process.env.OPEN_CLASSIFY_CATALOG_PATH ??
|
|
51
|
+
OPEN_CLASSIFY_CONFIG?.catalog ??
|
|
52
|
+
OLLAMA_DEFAULT_CATALOG_PATH;
|
|
53
|
+
const MIME_TYPES = {
|
|
54
|
+
".html": "text/html; charset=utf-8",
|
|
55
|
+
".css": "text/css; charset=utf-8",
|
|
56
|
+
".js": "text/javascript; charset=utf-8",
|
|
57
|
+
".json": "application/json; charset=utf-8",
|
|
58
|
+
};
|
|
59
|
+
const server = createServer((request, response) => {
|
|
60
|
+
void route(request, response);
|
|
61
|
+
});
|
|
62
|
+
server.listen(PORT, HOST, () => {
|
|
63
|
+
console.log(`Open Classify UI running at http://${HOST}:${PORT}/`);
|
|
64
|
+
});
|
|
65
|
+
async function route(request, response) {
|
|
66
|
+
const startedAt = Date.now();
|
|
67
|
+
console.log(`[req] ${request.method} ${request.url}`);
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
70
|
+
if (request.method === "POST" && url.pathname === "/api/classify-stream") {
|
|
71
|
+
await classifyStream(request, response);
|
|
72
|
+
console.log(`[req] ${request.method} ${request.url} stream ended in ${Date.now() - startedAt}ms`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (request.method === "GET" && url.pathname === "/api/enums") {
|
|
76
|
+
sendJson(response, CLASSIFIER_ENUMS);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (request.method === "GET" && url.pathname === "/api/classifiers") {
|
|
80
|
+
sendJson(response, { classifiers: CLASSIFIER_METADATA });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (request.method === "GET") {
|
|
84
|
+
serveStatic(url.pathname, response);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
sendJson(response, { error: "method not allowed" }, 405);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.error(`[req] ${request.method} ${request.url} failed:`, error);
|
|
91
|
+
sendJson(response, { error: errorMessage(error) }, 500);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function classifyStream(request, response) {
|
|
95
|
+
response.writeHead(200, {
|
|
96
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
97
|
+
"cache-control": "no-cache, no-transform",
|
|
98
|
+
connection: "keep-alive",
|
|
99
|
+
"x-accel-buffering": "no",
|
|
100
|
+
});
|
|
101
|
+
response.flushHeaders();
|
|
102
|
+
// Disable Nagle so each event flushes immediately. SSE is interactive;
|
|
103
|
+
// batching kills the "live" feel.
|
|
104
|
+
request.socket.setNoDelay(true);
|
|
105
|
+
let closed = false;
|
|
106
|
+
const clientAbortController = new AbortController();
|
|
107
|
+
const abortForClientClose = () => {
|
|
108
|
+
closed = true;
|
|
109
|
+
clientAbortController.abort(new Error("SSE client disconnected"));
|
|
110
|
+
};
|
|
111
|
+
response.on("close", () => {
|
|
112
|
+
abortForClientClose();
|
|
113
|
+
});
|
|
114
|
+
response.on("error", () => {
|
|
115
|
+
abortForClientClose();
|
|
116
|
+
});
|
|
117
|
+
const send = (event, data) => {
|
|
118
|
+
if (closed || response.writableEnded || response.destroyed) {
|
|
119
|
+
console.warn(`[sse] dropped ${event} (closed=${closed} ended=${response.writableEnded})`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const ok = response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
123
|
+
console.log(`[sse] -> ${event}${data?.name ? ` ${data.name}` : ""}${ok ? "" : " [backpressure]"}`);
|
|
124
|
+
};
|
|
125
|
+
// SSE comment heartbeat. Some intermediaries (proxies, load balancers)
|
|
126
|
+
// close idle connections; a tiny ping every 5s keeps the stream warm.
|
|
127
|
+
// The leading `:` makes browsers ignore the line as a comment.
|
|
128
|
+
const heartbeat = setInterval(() => {
|
|
129
|
+
if (closed || response.writableEnded || response.destroyed) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
response.write(`: ping ${Date.now()}\n\n`);
|
|
133
|
+
}, 5000);
|
|
134
|
+
try {
|
|
135
|
+
const input = (await readJsonBody(request));
|
|
136
|
+
const baseRunner = createOllamaClassifierRunner({
|
|
137
|
+
host: OPEN_CLASSIFY_CONFIG?.runner?.host,
|
|
138
|
+
defaultModel: OPEN_CLASSIFY_CONFIG?.runner?.defaultModel,
|
|
139
|
+
models: classifierModelsFromConfig(OPEN_CLASSIFY_CONFIG),
|
|
140
|
+
options: OPEN_CLASSIFY_CONFIG?.runner?.options,
|
|
141
|
+
});
|
|
142
|
+
const runClassifier = async (name, classifierInput, signal) => {
|
|
143
|
+
send("classifier_started", { name, started_at: Date.now() });
|
|
144
|
+
try {
|
|
145
|
+
const result = await baseRunner(name, classifierInput, signal);
|
|
146
|
+
send("classifier_completed", { name, result, completed_at: Date.now() });
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
console.error(`[classifier] ${name} threw:`, error);
|
|
151
|
+
if (signal.aborted) {
|
|
152
|
+
send(isTimeoutAbort(name, signal) ? "classifier_timed_out" : "classifier_aborted", {
|
|
153
|
+
name,
|
|
154
|
+
reason: errorMessage(signal.reason ?? error),
|
|
155
|
+
completed_at: Date.now(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
send("classifier_failed", {
|
|
160
|
+
name,
|
|
161
|
+
error: errorMessage(error),
|
|
162
|
+
completed_at: Date.now(),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
send("pipeline_started", {
|
|
169
|
+
classifiers: CLASSIFIER_NAMES,
|
|
170
|
+
started_at: Date.now(),
|
|
171
|
+
});
|
|
172
|
+
send("pipeline_phase", { phase: "normalizing" });
|
|
173
|
+
send("pipeline_phase", {
|
|
174
|
+
phase: "resource_check",
|
|
175
|
+
required_parallelism: OLLAMA_REQUIRED_PARALLELISM,
|
|
176
|
+
context_length: OLLAMA_CONTEXT_LENGTH,
|
|
177
|
+
min_total_memory_bytes: OLLAMA_MIN_TOTAL_MEMORY_BYTES,
|
|
178
|
+
min_available_memory_bytes: OLLAMA_MIN_AVAILABLE_MEMORY_BYTES,
|
|
179
|
+
});
|
|
180
|
+
send("pipeline_phase", { phase: "running" });
|
|
181
|
+
const result = await classifyOpenClassifyInput(input, {
|
|
182
|
+
runClassifier,
|
|
183
|
+
catalog: loadCatalog(CATALOG_PATH),
|
|
184
|
+
signal: clientAbortController.signal,
|
|
185
|
+
});
|
|
186
|
+
send("pipeline_completed", result);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
console.error("[pipeline] failed:", error);
|
|
190
|
+
send("pipeline_failed", { error: errorMessage(error) });
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
clearInterval(heartbeat);
|
|
194
|
+
closed = true;
|
|
195
|
+
if (!response.writableEnded && !response.destroyed) {
|
|
196
|
+
response.end();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Distinguishes a timeout-driven abort from a pipeline early-exit abort, so
|
|
201
|
+
// the UI can show the right state. We sniff the abort reason's message
|
|
202
|
+
// because that's the only signal the pipeline gives us — it doesn't tag
|
|
203
|
+
// reasons with a structured discriminator.
|
|
204
|
+
function isTimeoutAbort(name, signal) {
|
|
205
|
+
return errorMessage(signal.reason).includes(`${name} classifier timed out`);
|
|
206
|
+
}
|
|
207
|
+
function serveStatic(pathname, response) {
|
|
208
|
+
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
|
209
|
+
// Two-layer path-traversal guard: strip leading `../` segments from the
|
|
210
|
+
// normalized path, then double-check the resolved file is still inside
|
|
211
|
+
// UI_DIR. The redundancy is intentional — defense in depth on a static
|
|
212
|
+
// file server is cheap.
|
|
213
|
+
const safePath = normalize(requestedPath).replace(/^(\.\.[/\\])+/, "");
|
|
214
|
+
const filePath = join(UI_DIR, safePath);
|
|
215
|
+
if (!filePath.startsWith(UI_DIR) || !existsSync(filePath)) {
|
|
216
|
+
sendJson(response, { error: "not found" }, 404);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
response.writeHead(200, {
|
|
220
|
+
"content-type": MIME_TYPES[extname(filePath)] ?? "application/octet-stream",
|
|
221
|
+
"cache-control": "no-store",
|
|
222
|
+
});
|
|
223
|
+
createReadStream(filePath).on("error", () => response.destroy()).pipe(response);
|
|
224
|
+
}
|
|
225
|
+
function sendJson(response, data, status = 200) {
|
|
226
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
227
|
+
response.end(JSON.stringify(data));
|
|
228
|
+
}
|
|
229
|
+
// 512 KiB cap matches the input contract (5,000-char message budget plus
|
|
230
|
+
// generous slack for history). Big enough for any legitimate
|
|
231
|
+
// classification request, small enough to not be a DoS vector.
|
|
232
|
+
async function readJsonBody(request) {
|
|
233
|
+
const chunks = [];
|
|
234
|
+
let size = 0;
|
|
235
|
+
for await (const chunk of request) {
|
|
236
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
237
|
+
size += buffer.byteLength;
|
|
238
|
+
if (size > 512 * 1024) {
|
|
239
|
+
throw new Error("request body is too large");
|
|
240
|
+
}
|
|
241
|
+
chunks.push(buffer);
|
|
242
|
+
}
|
|
243
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
244
|
+
}
|
|
245
|
+
function errorMessage(error) {
|
|
246
|
+
if (error instanceof Error) {
|
|
247
|
+
return error.message;
|
|
248
|
+
}
|
|
249
|
+
return String(error);
|
|
250
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class ClassifierValidationError extends Error {
|
|
2
|
+
readonly classifier: string;
|
|
3
|
+
readonly model: string;
|
|
4
|
+
constructor(classifier: string, model: string, message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function throwInvalid(classifier: string, model: string, message: string): never;
|
|
7
|
+
export declare function requireString(value: unknown, classifier: string, model: string, path: string): string;
|
|
8
|
+
export declare function requireBoolean(value: unknown, classifier: string, model: string, path: string): boolean;
|
|
9
|
+
export declare function requireNonNegativeSafeInteger(value: unknown, classifier: string, model: string, path: string): number;
|
|
10
|
+
export declare function requireStringArray(value: unknown, classifier: string, model: string, path: string): string[];
|
|
11
|
+
export declare function requireStringMaxLength(value: unknown, classifier: string, model: string, path: string, maxChars: number): string;
|
|
12
|
+
export declare function requireNonEmptyStringMaxLength(value: unknown, classifier: string, model: string, path: string, maxChars: number): string;
|
|
13
|
+
export declare function requireEnum<const Values extends readonly string[]>(value: unknown, values: Values, classifier: string, model: string, path: string): Values[number];
|
|
14
|
+
export declare function requireConfidence(value: unknown, classifier: string, model: string, path?: string): number;
|
|
15
|
+
export declare function ensureExactKeys(value: Record<string, unknown>, keys: readonly string[], classifier: string, model: string): void;
|
|
16
|
+
export declare function ensureNoDuplicates(values: string[], classifier: string, model: string, path: string): void;
|
|
17
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Backend-neutral validation helpers used by every classifier module's
|
|
2
|
+
// `validate` function. These exist because hand-rolled validation gives us
|
|
3
|
+
// precise error messages and full control over the failure mode, without
|
|
4
|
+
// pulling in a dependency.
|
|
5
|
+
//
|
|
6
|
+
// Each helper takes the value, the classifier name, the backend model id
|
|
7
|
+
// (for error messages), and a JSON path. On failure it throws a
|
|
8
|
+
// `ClassifierValidationError` — backends catch that boundary and convert it
|
|
9
|
+
// to their own error type if they want a richer one (e.g. the Ollama runner
|
|
10
|
+
// wraps it as `OllamaClassifierError`).
|
|
11
|
+
// Thrown by every helper here. Carries the classifier name and the backend
|
|
12
|
+
// model id so backend-specific runners can wrap or report cleanly.
|
|
13
|
+
export class ClassifierValidationError extends Error {
|
|
14
|
+
classifier;
|
|
15
|
+
model;
|
|
16
|
+
constructor(classifier, model, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ClassifierValidationError";
|
|
19
|
+
this.classifier = classifier;
|
|
20
|
+
this.model = model;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function throwInvalid(classifier, model, message) {
|
|
24
|
+
throw new ClassifierValidationError(classifier, model, `${classifier} classifier returned invalid output: ${message}`);
|
|
25
|
+
}
|
|
26
|
+
export function requireString(value, classifier, model, path) {
|
|
27
|
+
if (typeof value !== "string") {
|
|
28
|
+
throwInvalid(classifier, model, `${path} must be a string`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
export function requireBoolean(value, classifier, model, path) {
|
|
33
|
+
if (typeof value !== "boolean") {
|
|
34
|
+
throwInvalid(classifier, model, `${path} must be a boolean`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
export function requireNonNegativeSafeInteger(value, classifier, model, path) {
|
|
39
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
40
|
+
throwInvalid(classifier, model, `${path} must be a non-negative safe integer`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
export function requireStringArray(value, classifier, model, path) {
|
|
45
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
46
|
+
throwInvalid(classifier, model, `${path} must be an array of strings`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
export function requireStringMaxLength(value, classifier, model, path, maxChars) {
|
|
51
|
+
const text = requireString(value, classifier, model, path);
|
|
52
|
+
if (text.length > maxChars) {
|
|
53
|
+
throwInvalid(classifier, model, `${path} must be ${maxChars} characters or fewer`);
|
|
54
|
+
}
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
57
|
+
export function requireNonEmptyStringMaxLength(value, classifier, model, path, maxChars) {
|
|
58
|
+
const text = requireStringMaxLength(value, classifier, model, path, maxChars);
|
|
59
|
+
if (text.trim().length === 0) {
|
|
60
|
+
throwInvalid(classifier, model, `${path} must not be empty`);
|
|
61
|
+
}
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
export function requireEnum(value, values, classifier, model, path) {
|
|
65
|
+
if (typeof value !== "string" || !values.includes(value)) {
|
|
66
|
+
throwInvalid(classifier, model, `${path} has an unsupported value`);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
// `confidence` must be a finite number in [0, 1]. Required on every
|
|
71
|
+
// classifier output (ClassifierResultBase); fallback shapes use 0.
|
|
72
|
+
export function requireConfidence(value, classifier, model, path = "confidence") {
|
|
73
|
+
const confidence = normalizeConfidence(value);
|
|
74
|
+
if (typeof confidence !== "number" ||
|
|
75
|
+
!Number.isFinite(confidence) ||
|
|
76
|
+
confidence < 0 ||
|
|
77
|
+
confidence > 1) {
|
|
78
|
+
throwInvalid(classifier, model, `${path} must be a number between 0 and 1 inclusive`);
|
|
79
|
+
}
|
|
80
|
+
return confidence;
|
|
81
|
+
}
|
|
82
|
+
function normalizeConfidence(value) {
|
|
83
|
+
if (typeof value === "number") {
|
|
84
|
+
return value > 1 && value <= 100 ? value / 100 : value;
|
|
85
|
+
}
|
|
86
|
+
if (typeof value !== "string")
|
|
87
|
+
return value;
|
|
88
|
+
const text = value.trim().toLowerCase();
|
|
89
|
+
if (text === "")
|
|
90
|
+
return value;
|
|
91
|
+
if (text.endsWith("%")) {
|
|
92
|
+
const percent = Number(text.slice(0, -1).trim());
|
|
93
|
+
return Number.isFinite(percent) ? percent / 100 : value;
|
|
94
|
+
}
|
|
95
|
+
const numeric = Number(text);
|
|
96
|
+
if (Number.isFinite(numeric)) {
|
|
97
|
+
return numeric > 1 && numeric <= 100 ? numeric / 100 : numeric;
|
|
98
|
+
}
|
|
99
|
+
if (text === "high")
|
|
100
|
+
return 0.9;
|
|
101
|
+
if (text === "medium")
|
|
102
|
+
return 0.5;
|
|
103
|
+
if (text === "low")
|
|
104
|
+
return 0.2;
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
export function ensureExactKeys(value, keys, classifier, model) {
|
|
108
|
+
const expected = new Set(keys);
|
|
109
|
+
for (const key of Object.keys(value)) {
|
|
110
|
+
if (!expected.has(key)) {
|
|
111
|
+
throwInvalid(classifier, model, `${key} is not a supported field`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const key of keys) {
|
|
115
|
+
if (!(key in value)) {
|
|
116
|
+
throwInvalid(classifier, model, `${key} is required`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function ensureNoDuplicates(values, classifier, model, path) {
|
|
121
|
+
if (new Set(values).size !== values.length) {
|
|
122
|
+
throwInvalid(classifier, model, `${path} must not include duplicates`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function isRecord(value) {
|
|
126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"runner": {
|
|
3
|
+
"provider": "ollama",
|
|
4
|
+
"host": "http://127.0.0.1:11434",
|
|
5
|
+
"defaultModel": "gemma4:e4b-it-q4_K_M",
|
|
6
|
+
"options": {
|
|
7
|
+
"num_ctx": 4096,
|
|
8
|
+
"temperature": 0
|
|
9
|
+
},
|
|
10
|
+
"models": {
|
|
11
|
+
"stock": {
|
|
12
|
+
"preflight": "gemma4:e4b-it-q4_K_M",
|
|
13
|
+
"routing": "gemma4:e4b-it-q4_K_M",
|
|
14
|
+
"model_specialization": "gemma4:e4b-it-q4_K_M",
|
|
15
|
+
"tools": "gemma4:e4b-it-q4_K_M",
|
|
16
|
+
"security": "gemma4:e4b-it-q4_K_M"
|
|
17
|
+
},
|
|
18
|
+
"custom": {
|
|
19
|
+
"memory_retrieval_queries": "gemma4:e4b-it-q4_K_M"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"catalog": "downstream-models.json"
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "open-classify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Manifest-driven classifier runtime for routing user messages to downstream AI models",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Taylor Bayouth",
|
|
7
|
+
"homepage": "https://github.com/taylorbayouth/open-classify#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/taylorbayouth/open-classify.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/taylorbayouth/open-classify/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"classifier",
|
|
17
|
+
"llm",
|
|
18
|
+
"routing",
|
|
19
|
+
"ollama",
|
|
20
|
+
"gemma",
|
|
21
|
+
"ai",
|
|
22
|
+
"agent",
|
|
23
|
+
"prompt-injection"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/src/index.d.ts",
|
|
29
|
+
"default": "./dist/src/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist/src",
|
|
34
|
+
"open-classify.config.example.json",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc && node scripts/copy-classifier-assets.mjs",
|
|
43
|
+
"setup": "node scripts/setup.mjs",
|
|
44
|
+
"start": "node scripts/start.mjs",
|
|
45
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
46
|
+
"ui": "npm run build && node dist/src/ui-server.js",
|
|
47
|
+
"prepublishOnly": "npm run build && npm test"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^25.6.0",
|
|
51
|
+
"typescript": "^5.7.0"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"ajv": "^8.20.0"
|
|
55
|
+
}
|
|
56
|
+
}
|