open-classify 0.4.0 → 0.5.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 +129 -86
- package/dist/src/aggregator.d.ts +11 -4
- package/dist/src/aggregator.js +108 -121
- package/dist/src/classifiers/{custom/context_shift → context_shift}/manifest.json +6 -11
- package/dist/src/classifiers/{custom/context_shift → context_shift}/prompt.md +1 -1
- package/dist/src/classifiers/{custom/conversation_digest → conversation_digest}/manifest.json +7 -12
- package/dist/src/classifiers/{custom/conversation_digest → conversation_digest}/prompt.md +2 -2
- package/dist/src/classifiers/{custom/memory_retrieval_queries → memory_retrieval_queries}/manifest.json +6 -11
- package/dist/src/classifiers/{custom/memory_retrieval_queries → memory_retrieval_queries}/prompt.md +2 -2
- package/dist/src/classifiers/{stock/model_specialization → model_specialization}/manifest.json +2 -2
- package/dist/src/classifiers/model_specialization/prompt.md +5 -0
- package/dist/src/classifiers/preflight/manifest.json +34 -0
- package/dist/src/classifiers/preflight/prompt.md +10 -0
- package/dist/src/classifiers/{stock/prompt_injection → prompt_injection}/manifest.json +6 -2
- package/dist/src/classifiers/prompt_injection/prompt.md +14 -0
- package/dist/src/classifiers/{stock/routing → routing}/manifest.json +2 -2
- package/dist/src/classifiers/routing/prompt.md +5 -0
- package/dist/src/classifiers/{stock/tools → tools}/manifest.json +3 -3
- package/dist/src/classifiers/tools/prompt.md +5 -0
- package/dist/src/classifiers.js +31 -29
- package/dist/src/classify.d.ts +9 -2
- package/dist/src/classify.js +26 -12
- package/dist/src/config.d.ts +1 -4
- package/dist/src/config.js +6 -34
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/input.d.ts +4 -1
- package/dist/src/input.js +12 -10
- package/dist/src/manifest.d.ts +11 -7
- package/dist/src/pipeline.d.ts +9 -1
- package/dist/src/pipeline.js +51 -25
- package/dist/src/reserved-fields.d.ts +18 -0
- package/dist/src/reserved-fields.js +175 -0
- package/dist/src/stock-prompt.d.ts +9 -2
- package/dist/src/stock-prompt.js +165 -45
- package/dist/src/stock-validation.d.ts +16 -17
- package/dist/src/stock-validation.js +263 -236
- package/dist/src/stock.d.ts +24 -60
- package/dist/src/stock.js +7 -14
- package/docs/adding-a-classifier.md +74 -32
- package/docs/manifests.md +112 -71
- package/docs/resolver.md +25 -34
- package/docs/signals.md +39 -58
- package/open-classify.config.example.json +9 -11
- package/package.json +1 -1
- package/dist/src/classifiers/stock/preflight/manifest.json +0 -11
- package/dist/src/classifiers/stock/prompts/classifier-header.md +0 -4
- package/dist/src/classifiers/stock/prompts/custom-output.md +0 -7
- package/dist/src/classifiers/stock/prompts/model_specialization.md +0 -7
- package/dist/src/classifiers/stock/prompts/preflight-output.md +0 -10
- package/dist/src/classifiers/stock/prompts/preflight.md +0 -47
- package/dist/src/classifiers/stock/prompts/prompt-injection-output.md +0 -5
- package/dist/src/classifiers/stock/prompts/prompt_injection.md +0 -24
- package/dist/src/classifiers/stock/prompts/routing-output.md +0 -5
- package/dist/src/classifiers/stock/prompts/routing.md +0 -9
- package/dist/src/classifiers/stock/prompts/specialty.md +0 -12
- package/dist/src/classifiers/stock/prompts/tier.md +0 -7
- package/dist/src/classifiers/stock/prompts/tools-output.md +0 -11
- package/dist/src/classifiers/stock/prompts/tools.md +0 -10
- /package/dist/src/classifiers/{stock/prompts → _prompts}/base.md +0 -0
- /package/dist/src/classifiers/{stock/prompts → _prompts}/confidence.md +0 -0
- /package/dist/src/classifiers/{stock/prompts → _prompts}/reason.md +0 -0
package/dist/src/classifiers.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import {
|
|
4
|
+
import { buildClassifierPrompt } from "./stock-prompt.js";
|
|
5
5
|
import { validateJsonClassifierManifest, validateOutputForManifest, } from "./stock-validation.js";
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const CLASSIFIERS_DIR = join(__dirname, "classifiers");
|
|
8
|
-
|
|
8
|
+
// Directories whose names start with "_" are reserved for shared assets
|
|
9
|
+
// (e.g. `_prompts/`) and are not loaded as classifiers.
|
|
10
|
+
const SHARED_DIRECTORY_PREFIX = "_";
|
|
9
11
|
export class ClassifierManifestError extends Error {
|
|
10
12
|
constructor(message) {
|
|
11
13
|
super(message);
|
|
@@ -17,53 +19,53 @@ export function loadClassifierRegistry(classifiersDir = CLASSIFIERS_DIR) {
|
|
|
17
19
|
throw new ClassifierManifestError(`classifier directory not found: ${classifiersDir}`);
|
|
18
20
|
}
|
|
19
21
|
const manifests = [];
|
|
20
|
-
for (const
|
|
21
|
-
|
|
22
|
-
if (!existsSync(kindDir))
|
|
22
|
+
for (const entry of readdirSync(classifiersDir, { withFileTypes: true })) {
|
|
23
|
+
if (!entry.isDirectory())
|
|
23
24
|
continue;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (kind === "stock" && entry.name === "prompts")
|
|
28
|
-
continue;
|
|
29
|
-
manifests.push(loadClassifierManifest(join(kindDir, entry.name), kind));
|
|
30
|
-
}
|
|
25
|
+
if (entry.name.startsWith(SHARED_DIRECTORY_PREFIX))
|
|
26
|
+
continue;
|
|
27
|
+
manifests.push(loadClassifierManifest(join(classifiersDir, entry.name)));
|
|
31
28
|
}
|
|
32
|
-
|
|
29
|
+
// Lower dispatch_order runs first. Classifiers without dispatch_order sort
|
|
30
|
+
// last (treated as +Infinity) — useful for "run me whenever there's a slot".
|
|
31
|
+
manifests.sort((a, b) => (a.dispatch_order ?? Infinity) - (b.dispatch_order ?? Infinity));
|
|
33
32
|
validateRegistry(manifests);
|
|
34
33
|
return manifests;
|
|
35
34
|
}
|
|
36
|
-
function loadClassifierManifest(classifierDir
|
|
35
|
+
function loadClassifierManifest(classifierDir) {
|
|
37
36
|
const manifestPath = join(classifierDir, "manifest.json");
|
|
38
37
|
const promptPath = join(classifierDir, "prompt.md");
|
|
39
38
|
if (!existsSync(manifestPath)) {
|
|
40
39
|
throw new ClassifierManifestError(`missing manifest.json in ${classifierDir}`);
|
|
41
40
|
}
|
|
42
|
-
if (
|
|
41
|
+
if (!existsSync(promptPath)) {
|
|
43
42
|
throw new ClassifierManifestError(`missing prompt.md in ${classifierDir}`);
|
|
44
43
|
}
|
|
45
44
|
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
46
|
-
const manifest = validateJsonClassifierManifest(parsed, manifestPath);
|
|
47
|
-
if (manifest.kind !== expectedKind) {
|
|
48
|
-
throw new ClassifierManifestError(`${manifestPath}: manifest kind "${manifest.kind}" does not match parent directory "${expectedKind}"`);
|
|
49
|
-
}
|
|
45
|
+
const { manifest, reservedFields, composedOutputSchema, appliesTo } = validateJsonClassifierManifest(parsed, manifestPath);
|
|
50
46
|
const directoryName = basename(classifierDir);
|
|
51
47
|
if (manifest.name !== directoryName) {
|
|
52
48
|
throw new ClassifierManifestError(`${manifestPath}: manifest name "${manifest.name}" does not match directory "${directoryName}"`);
|
|
53
49
|
}
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
if (classifierPrompt.length === 0) {
|
|
58
|
-
throw new ClassifierManifestError(`prompt.md must not be empty: ${promptPath}`);
|
|
59
|
-
}
|
|
60
|
-
systemPrompt = `${systemPrompt}\n\nClassifier guidance:\n${classifierPrompt}`;
|
|
50
|
+
const classifierPromptText = readFileSync(promptPath, "utf8");
|
|
51
|
+
if (classifierPromptText.trim().length === 0) {
|
|
52
|
+
throw new ClassifierManifestError(`prompt.md must not be empty: ${promptPath}`);
|
|
61
53
|
}
|
|
62
|
-
|
|
54
|
+
const systemPrompt = buildClassifierPrompt({
|
|
55
|
+
manifest,
|
|
56
|
+
reservedFields,
|
|
57
|
+
appliesTo,
|
|
58
|
+
classifierPromptText,
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
...manifest,
|
|
62
|
+
systemPrompt,
|
|
63
|
+
composedOutputSchema,
|
|
64
|
+
reservedFields,
|
|
65
|
+
appliesTo,
|
|
66
|
+
};
|
|
63
67
|
}
|
|
64
68
|
function validateRegistry(manifests) {
|
|
65
|
-
// Duplicate orders are allowed: same-order classifiers schedule adjacent
|
|
66
|
-
// and run in parallel when concurrency permits, sequentially otherwise.
|
|
67
69
|
const names = new Set();
|
|
68
70
|
for (const manifest of manifests) {
|
|
69
71
|
if (names.has(manifest.name)) {
|
package/dist/src/classify.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { type RunClassifier } from "./classifiers.js";
|
|
2
2
|
import { type OpenClassifyConfig } from "./config.js";
|
|
3
|
-
import type { AggregatorConfig, Catalog, PipelineResult } from "./manifest.js";
|
|
3
|
+
import type { AggregatorConfig, Catalog, InspectResult, PipelineResult } from "./manifest.js";
|
|
4
4
|
import type { OpenClassifyInput } from "./types.js";
|
|
5
5
|
export type Classifier = (input: OpenClassifyInput, options?: {
|
|
6
6
|
signal?: AbortSignal;
|
|
7
7
|
}) => Promise<PipelineResult>;
|
|
8
|
+
export type Inspector = (input: OpenClassifyInput, options?: {
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
}) => Promise<InspectResult>;
|
|
11
|
+
export interface OpenClassify {
|
|
12
|
+
readonly classify: Classifier;
|
|
13
|
+
readonly inspect: Inspector;
|
|
14
|
+
}
|
|
8
15
|
export interface CreateClassifierOptions {
|
|
9
16
|
runClassifier?: RunClassifier;
|
|
10
17
|
catalog?: Catalog;
|
|
@@ -20,4 +27,4 @@ export interface CreateClassifierOptions {
|
|
|
20
27
|
maxConcurrency?: number;
|
|
21
28
|
aggregator?: AggregatorConfig;
|
|
22
29
|
}
|
|
23
|
-
export declare function createClassifier(options?: CreateClassifierOptions):
|
|
30
|
+
export declare function createClassifier(options?: CreateClassifierOptions): OpenClassify;
|
package/dist/src/classify.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// High-level facade for the pipeline. Builds the runner and catalog once,
|
|
2
|
-
// then returns
|
|
3
|
-
//
|
|
4
|
-
// `runClassifier` to bypass the bundled Ollama runner entirely.
|
|
2
|
+
// then returns two functions — classify() for the user-input/routing pass
|
|
3
|
+
// and inspect() for the assistant-output lean pass. Backend-agnostic: pass a
|
|
4
|
+
// custom `runClassifier` to bypass the bundled Ollama runner entirely.
|
|
5
5
|
import { loadCatalog } from "./catalog.js";
|
|
6
6
|
import { classifierModelsFromConfig, loadOpenClassifyConfig, } from "./config.js";
|
|
7
7
|
import { assertOllamaResources, createOllamaClassifierRunner, OLLAMA_DEFAULT_CATALOG_PATH, } from "./ollama.js";
|
|
8
|
-
import { classifyOpenClassifyInput } from "./pipeline.js";
|
|
8
|
+
import { classifyOpenClassifyInput, inspectOpenClassifyInput, } from "./pipeline.js";
|
|
9
9
|
export function createClassifier(options = {}) {
|
|
10
10
|
const fileConfig = options.config ??
|
|
11
11
|
loadOpenClassifyConfig(options.configPath, {
|
|
@@ -30,14 +30,17 @@ export function createClassifier(options = {}) {
|
|
|
30
30
|
loadCatalog(options.catalogPath ?? fileConfig?.catalog ?? OLLAMA_DEFAULT_CATALOG_PATH);
|
|
31
31
|
const aggregator = options.aggregator ?? fileConfig?.aggregator;
|
|
32
32
|
let resourceCheck;
|
|
33
|
-
|
|
34
|
-
if (needsResourceCheck)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
const ensureResources = async () => {
|
|
34
|
+
if (!needsResourceCheck)
|
|
35
|
+
return;
|
|
36
|
+
resourceCheck ??= assertOllamaResources({
|
|
37
|
+
minTotalMemoryBytes: options.minTotalMemoryBytes,
|
|
38
|
+
minAvailableMemoryBytes: options.minAvailableMemoryBytes,
|
|
39
|
+
});
|
|
40
|
+
await resourceCheck;
|
|
41
|
+
};
|
|
42
|
+
const classify = async (input, callOptions) => {
|
|
43
|
+
await ensureResources();
|
|
41
44
|
return classifyOpenClassifyInput(input, {
|
|
42
45
|
runClassifier,
|
|
43
46
|
catalog,
|
|
@@ -48,4 +51,15 @@ export function createClassifier(options = {}) {
|
|
|
48
51
|
signal: callOptions?.signal,
|
|
49
52
|
});
|
|
50
53
|
};
|
|
54
|
+
const inspect = async (input, callOptions) => {
|
|
55
|
+
await ensureResources();
|
|
56
|
+
return inspectOpenClassifyInput(input, {
|
|
57
|
+
runClassifier,
|
|
58
|
+
classifierTimeoutMs: options.classifierTimeoutMs,
|
|
59
|
+
classifierRetryCount: options.classifierRetryCount,
|
|
60
|
+
maxConcurrency: options.maxConcurrency,
|
|
61
|
+
signal: callOptions?.signal,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
return { classify, inspect };
|
|
51
65
|
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -16,10 +16,7 @@ export interface OllamaRunnerConfig {
|
|
|
16
16
|
readonly seed?: number;
|
|
17
17
|
readonly num_ctx?: number;
|
|
18
18
|
};
|
|
19
|
-
readonly models?:
|
|
20
|
-
readonly stock?: Readonly<Record<string, string>>;
|
|
21
|
-
readonly custom?: Readonly<Record<string, string>>;
|
|
22
|
-
};
|
|
19
|
+
readonly models?: Readonly<Record<string, string>>;
|
|
23
20
|
}
|
|
24
21
|
export declare class OpenClassifyConfigError extends Error {
|
|
25
22
|
constructor(message: string);
|
package/dist/src/config.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import { STOCK_CLASSIFIER_NAMES } from "./stock.js";
|
|
2
|
+
import { CLASSIFIER_NAMES } from "./classifiers.js";
|
|
4
3
|
import { isRecord } from "./validation.js";
|
|
5
4
|
export const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify.config.json";
|
|
6
5
|
export class OpenClassifyConfigError extends Error {
|
|
@@ -25,13 +24,7 @@ export function loadOpenClassifyConfig(path = process.env.OPEN_CLASSIFY_CONFIG ?
|
|
|
25
24
|
return validateOpenClassifyConfig(parsed, path);
|
|
26
25
|
}
|
|
27
26
|
export function classifierModelsFromConfig(config) {
|
|
28
|
-
|
|
29
|
-
if (!models)
|
|
30
|
-
return {};
|
|
31
|
-
return {
|
|
32
|
-
...models.stock,
|
|
33
|
-
...models.custom,
|
|
34
|
-
};
|
|
27
|
+
return { ...config?.runner?.models };
|
|
35
28
|
}
|
|
36
29
|
export function validateOpenClassifyConfig(value, path = "open-classify config") {
|
|
37
30
|
if (!isRecord(value)) {
|
|
@@ -103,37 +96,16 @@ function validateModels(value, path) {
|
|
|
103
96
|
if (!isRecord(value)) {
|
|
104
97
|
throwConfig(path, "runner.models must be an object");
|
|
105
98
|
}
|
|
106
|
-
|
|
107
|
-
return {
|
|
108
|
-
...(value.stock === undefined
|
|
109
|
-
? {}
|
|
110
|
-
: { stock: validateModelMap(value.stock, path, "runner.models.stock", stockClassifierNames()) }),
|
|
111
|
-
...(value.custom === undefined
|
|
112
|
-
? {}
|
|
113
|
-
: { custom: validateModelMap(value.custom, path, "runner.models.custom", customClassifierNames()) }),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
function validateModelMap(value, path, field, allowedNames) {
|
|
117
|
-
if (!isRecord(value)) {
|
|
118
|
-
throwConfig(path, `${field} must be an object`);
|
|
119
|
-
}
|
|
99
|
+
const allowed = new Set(CLASSIFIER_NAMES);
|
|
120
100
|
const out = {};
|
|
121
101
|
for (const [name, model] of Object.entries(value)) {
|
|
122
|
-
if (!
|
|
123
|
-
throwConfig(path,
|
|
102
|
+
if (!allowed.has(name)) {
|
|
103
|
+
throwConfig(path, `runner.models.${name} is not a known classifier`);
|
|
124
104
|
}
|
|
125
|
-
out[name] = requireString(model, path,
|
|
105
|
+
out[name] = requireString(model, path, `runner.models.${name}`);
|
|
126
106
|
}
|
|
127
107
|
return out;
|
|
128
108
|
}
|
|
129
|
-
function stockClassifierNames() {
|
|
130
|
-
return new Set(STOCK_CLASSIFIER_NAMES);
|
|
131
|
-
}
|
|
132
|
-
function customClassifierNames() {
|
|
133
|
-
return new Set(REGISTRY
|
|
134
|
-
.filter((classifier) => classifier.kind === "custom")
|
|
135
|
-
.map((classifier) => classifier.name));
|
|
136
|
-
}
|
|
137
109
|
function requireString(value, path, field) {
|
|
138
110
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
139
111
|
throwConfig(path, `${field} must be a non-empty string`);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./input.js";
|
|
|
8
8
|
export * from "./manifest.js";
|
|
9
9
|
export * from "./ollama.js";
|
|
10
10
|
export * from "./pipeline.js";
|
|
11
|
+
export * from "./reserved-fields.js";
|
|
11
12
|
export * from "./stock.js";
|
|
12
13
|
export * from "./stock-prompt.js";
|
|
13
14
|
export * from "./stock-validation.js";
|
package/dist/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./input.js";
|
|
|
13
13
|
export * from "./manifest.js";
|
|
14
14
|
export * from "./ollama.js";
|
|
15
15
|
export * from "./pipeline.js";
|
|
16
|
+
export * from "./reserved-fields.js";
|
|
16
17
|
export * from "./stock.js";
|
|
17
18
|
export * from "./stock-prompt.js";
|
|
18
19
|
export * from "./stock-validation.js";
|
package/dist/src/input.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ClassifierInput, NormalizedOpenClassifyInput, OpenClassifyInput } from "./types.js";
|
|
2
2
|
export declare function sanitizeText(raw: string): string;
|
|
3
|
-
export
|
|
3
|
+
export interface NormalizeOptions {
|
|
4
|
+
readonly expectedRole?: "user" | "assistant";
|
|
5
|
+
}
|
|
6
|
+
export declare function normalizeOpenClassifyInput(input: OpenClassifyInput, options?: NormalizeOptions): NormalizedOpenClassifyInput;
|
|
4
7
|
export declare function toClassifierInput(normalized: NormalizedOpenClassifyInput): ClassifierInput;
|
package/dist/src/input.js
CHANGED
|
@@ -51,10 +51,11 @@ export function sanitizeText(raw) {
|
|
|
51
51
|
function hashCanonicalValue(value) {
|
|
52
52
|
return createHash("sha256").update(canonicalJson(value)).digest("hex").slice(0, 8);
|
|
53
53
|
}
|
|
54
|
-
export function normalizeOpenClassifyInput(input) {
|
|
54
|
+
export function normalizeOpenClassifyInput(input, options = {}) {
|
|
55
55
|
assertPlainObject(input, "input");
|
|
56
56
|
rejectUnknownFields(input, INPUT_FIELDS, "input");
|
|
57
|
-
const
|
|
57
|
+
const expectedRole = options.expectedRole ?? "user";
|
|
58
|
+
const messages = normalizeMessages(input.messages, expectedRole);
|
|
58
59
|
const target = messages[messages.length - 1];
|
|
59
60
|
const text = target.text;
|
|
60
61
|
const normalized = {
|
|
@@ -63,7 +64,7 @@ export function normalizeOpenClassifyInput(input) {
|
|
|
63
64
|
target_message_hash: "",
|
|
64
65
|
};
|
|
65
66
|
normalized.target_message_hash = hashCanonicalValue({
|
|
66
|
-
role: target.role ??
|
|
67
|
+
role: target.role ?? expectedRole,
|
|
67
68
|
text,
|
|
68
69
|
});
|
|
69
70
|
return normalized;
|
|
@@ -75,14 +76,14 @@ export function toClassifierInput(normalized) {
|
|
|
75
76
|
target_message_hash: normalized.target_message_hash,
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
|
-
function normalizeMessages(messages) {
|
|
79
|
+
function normalizeMessages(messages, expectedRole) {
|
|
79
80
|
if (!Array.isArray(messages)) {
|
|
80
81
|
throw new TypeError("input.messages must be an array");
|
|
81
82
|
}
|
|
82
83
|
if (messages.length === 0) {
|
|
83
84
|
throw new Error("input.messages must contain at least one message");
|
|
84
85
|
}
|
|
85
|
-
return takeNewestWholeMessagesThatFit(messages);
|
|
86
|
+
return takeNewestWholeMessagesThatFit(messages, expectedRole);
|
|
86
87
|
}
|
|
87
88
|
function normalizeConversationMessage(message, path) {
|
|
88
89
|
assertPlainObject(message, path);
|
|
@@ -104,12 +105,13 @@ function normalizeConversationMessage(message, path) {
|
|
|
104
105
|
// Walk the message history newest → oldest, keeping whole messages while
|
|
105
106
|
// we're under both the count cap and the character budget. The final
|
|
106
107
|
// message is non-negotiable (it's what we're classifying) — we validate it
|
|
107
|
-
// stricter, force role
|
|
108
|
+
// stricter, force role to the expected one, and never drop it on length
|
|
109
|
+
// grounds.
|
|
108
110
|
//
|
|
109
111
|
// We never slice text inside a message: classifiers depend on the full
|
|
110
112
|
// final message, and slicing earlier ones at arbitrary boundaries tends to
|
|
111
113
|
// confuse models more than it helps.
|
|
112
|
-
function takeNewestWholeMessagesThatFit(messages) {
|
|
114
|
+
function takeNewestWholeMessagesThatFit(messages, expectedRole) {
|
|
113
115
|
const selected = [];
|
|
114
116
|
let totalChars = 0;
|
|
115
117
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
@@ -119,10 +121,10 @@ function takeNewestWholeMessagesThatFit(messages) {
|
|
|
119
121
|
if (normalized.text.length === 0) {
|
|
120
122
|
throw new Error("final message is empty after sanitization");
|
|
121
123
|
}
|
|
122
|
-
if (normalized.role !== undefined && normalized.role !==
|
|
123
|
-
throw new Error(
|
|
124
|
+
if (normalized.role !== undefined && normalized.role !== expectedRole) {
|
|
125
|
+
throw new Error(`final message must have role ${expectedRole}`);
|
|
124
126
|
}
|
|
125
|
-
normalized.role =
|
|
127
|
+
normalized.role = expectedRole;
|
|
126
128
|
if (normalized.text.length > CONVERSATION_TEXT_MAX_CHARS) {
|
|
127
129
|
throw new RangeError(`final message must be ${CONVERSATION_TEXT_MAX_CHARS} characters or fewer`);
|
|
128
130
|
}
|
package/dist/src/manifest.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AckReplySignal,
|
|
1
|
+
import type { AckReplySignal, ClassifierAuditOutput, ClassifierOutput, FinalReplySignal, PromptInjectionSignal, RoutingSignal, RuntimeClassifierManifest, ToolsSignal } from "./stock.js";
|
|
2
2
|
import type { ClassifierInput, ClassifierRunStatus } from "./types.js";
|
|
3
3
|
import type { DownstreamModelTier, ModelSpecialization } from "./enums.js";
|
|
4
4
|
export type ClassifierName = string;
|
|
@@ -20,11 +20,11 @@ export interface Catalog {
|
|
|
20
20
|
}
|
|
21
21
|
export interface ModelRecommendationResolution {
|
|
22
22
|
readonly constraints_used: Partial<{
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
model_specialization: ModelSpecialization;
|
|
24
|
+
model_tier: DownstreamModelTier;
|
|
25
25
|
}>;
|
|
26
26
|
readonly constraints_dropped: ReadonlyArray<{
|
|
27
|
-
readonly axis: "
|
|
27
|
+
readonly axis: "model_specialization" | "model_tier";
|
|
28
28
|
readonly reason: "low_confidence" | "no_match_relaxed" | "default_fallback";
|
|
29
29
|
}>;
|
|
30
30
|
readonly confidences: Partial<{
|
|
@@ -47,10 +47,10 @@ export interface Envelope {
|
|
|
47
47
|
readonly routing?: RoutingSignal;
|
|
48
48
|
readonly tools?: ToolsSignal;
|
|
49
49
|
readonly prompt_injection?: PromptInjectionSignal;
|
|
50
|
-
readonly
|
|
50
|
+
readonly classifier_outputs: ReadonlyArray<ClassifierAuditOutput>;
|
|
51
51
|
readonly model_recommendation: ModelRecommendation;
|
|
52
52
|
}
|
|
53
|
-
export type
|
|
53
|
+
export type ClassifierPublicOutputs = Record<string, Record<string, unknown>>;
|
|
54
54
|
export interface DownstreamTargetMessage {
|
|
55
55
|
readonly role: "user";
|
|
56
56
|
readonly text: string;
|
|
@@ -76,11 +76,15 @@ export interface PipelineMeta {
|
|
|
76
76
|
export interface PipelineAudit extends Envelope {
|
|
77
77
|
readonly meta: PipelineMeta;
|
|
78
78
|
}
|
|
79
|
+
export interface InspectResult {
|
|
80
|
+
readonly target_message_hash: string;
|
|
81
|
+
readonly classifier_outputs: ClassifierPublicOutputs;
|
|
82
|
+
}
|
|
79
83
|
export interface PipelineResult {
|
|
80
84
|
readonly action: "route";
|
|
81
85
|
readonly target_message_hash: string;
|
|
82
86
|
readonly downstream: DownstreamPayload;
|
|
83
|
-
readonly classifier_outputs:
|
|
87
|
+
readonly classifier_outputs: ClassifierPublicOutputs;
|
|
84
88
|
readonly audit: PipelineAudit;
|
|
85
89
|
}
|
|
86
90
|
export interface AggregatorConfig {
|
package/dist/src/pipeline.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RunClassifier } from "./classifiers.js";
|
|
2
|
-
import type { AggregatorConfig, Catalog, PipelineResult } from "./manifest.js";
|
|
2
|
+
import type { AggregatorConfig, Catalog, InspectResult, PipelineResult } from "./manifest.js";
|
|
3
3
|
import type { OpenClassifyInput } from "./types.js";
|
|
4
4
|
export declare const DEFAULT_CLASSIFIER_TIMEOUT_MS = 15000;
|
|
5
5
|
export declare const DEFAULT_CLASSIFIER_RETRY_COUNT = 1;
|
|
@@ -16,4 +16,12 @@ export interface ClassifyOptions {
|
|
|
16
16
|
aggregator?: AggregatorConfig;
|
|
17
17
|
signal?: AbortSignal;
|
|
18
18
|
}
|
|
19
|
+
export interface InspectOptions {
|
|
20
|
+
runClassifier: RunClassifier;
|
|
21
|
+
classifierTimeoutMs?: number;
|
|
22
|
+
classifierRetryCount?: number;
|
|
23
|
+
maxConcurrency?: number;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
19
26
|
export declare function classifyOpenClassifyInput(input: OpenClassifyInput, options: ClassifyOptions): Promise<PipelineResult>;
|
|
27
|
+
export declare function inspectOpenClassifyInput(input: OpenClassifyInput, options: InspectOptions): Promise<InspectResult>;
|
package/dist/src/pipeline.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { composeEnvelope } from "./aggregator.js";
|
|
2
2
|
import { MODULES_BY_NAME, REGISTRY, } from "./classifiers.js";
|
|
3
3
|
import { normalizeOpenClassifyInput, toClassifierInput } from "./input.js";
|
|
4
|
-
import { certaintyScore
|
|
4
|
+
import { certaintyScore } from "./stock.js";
|
|
5
5
|
export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 15_000;
|
|
6
6
|
export const DEFAULT_CLASSIFIER_RETRY_COUNT = 1;
|
|
7
7
|
export const DEFAULT_MAX_CONCURRENCY = 7;
|
|
@@ -12,9 +12,28 @@ export class OpenClassifyNormalizationError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
export async function classifyOpenClassifyInput(input, options) {
|
|
15
|
+
const { request, results, meta } = await runPipeline(input, "user", options);
|
|
16
|
+
const classifierInput = toClassifierInput(request);
|
|
17
|
+
const envelope = composeEnvelope({
|
|
18
|
+
registry: filteredRegistry("user"),
|
|
19
|
+
results,
|
|
20
|
+
catalog: options.catalog,
|
|
21
|
+
input: classifierInput,
|
|
22
|
+
config: options.aggregator,
|
|
23
|
+
});
|
|
24
|
+
return buildRouteResult(request, envelope, results, meta);
|
|
25
|
+
}
|
|
26
|
+
export async function inspectOpenClassifyInput(input, options) {
|
|
27
|
+
const { request, results } = await runPipeline(input, "assistant", options);
|
|
28
|
+
return {
|
|
29
|
+
target_message_hash: request.target_message_hash,
|
|
30
|
+
classifier_outputs: classifierPublicOutputs(filteredRegistry("assistant"), results),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function runPipeline(input, role, options) {
|
|
15
34
|
let request;
|
|
16
35
|
try {
|
|
17
|
-
request = normalizeOpenClassifyInput(input);
|
|
36
|
+
request = normalizeOpenClassifyInput(input, { expectedRole: role });
|
|
18
37
|
}
|
|
19
38
|
catch (error) {
|
|
20
39
|
throw new OpenClassifyNormalizationError(error);
|
|
@@ -33,26 +52,26 @@ export async function classifyOpenClassifyInput(input, options) {
|
|
|
33
52
|
const classifierTimeoutMs = options.classifierTimeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
|
|
34
53
|
const classifierRetryCount = options.classifierRetryCount ?? DEFAULT_CLASSIFIER_RETRY_COUNT;
|
|
35
54
|
const maxConcurrency = resolveMaxConcurrency(options.maxConcurrency);
|
|
36
|
-
// REGISTRY is already sorted by `
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
const
|
|
55
|
+
// REGISTRY is already sorted by `dispatch_order` ascending. Filter by
|
|
56
|
+
// applies_to so we only dispatch classifiers relevant to this role; the
|
|
57
|
+
// worker pool then runs them in the remaining order.
|
|
58
|
+
const registry = filteredRegistry(role);
|
|
59
|
+
const queue = registry.map((m) => m.name);
|
|
40
60
|
try {
|
|
41
61
|
const settled = await runWithConcurrency(queue, maxConcurrency, controller.signal, (name) => runClassifierWithRetry(name, classifierInput, options.runClassifier, controller.signal, classifierTimeoutMs, classifierRetryCount));
|
|
42
|
-
const { results, meta } = collectFullEntries(settled);
|
|
43
|
-
|
|
44
|
-
registry: REGISTRY,
|
|
45
|
-
results,
|
|
46
|
-
catalog: options.catalog,
|
|
47
|
-
input: classifierInput,
|
|
48
|
-
config: options.aggregator,
|
|
49
|
-
});
|
|
50
|
-
return buildRouteResult(request, envelope, results, meta);
|
|
62
|
+
const { results, meta } = collectFullEntries(settled, registry);
|
|
63
|
+
return { request, results, meta };
|
|
51
64
|
}
|
|
52
65
|
finally {
|
|
53
66
|
options.signal?.removeEventListener("abort", abortFromOptions);
|
|
54
67
|
}
|
|
55
68
|
}
|
|
69
|
+
function filteredRegistry(role) {
|
|
70
|
+
return REGISTRY.filter((m) => roleAppliesTo(m.appliesTo, role));
|
|
71
|
+
}
|
|
72
|
+
function roleAppliesTo(appliesTo, role) {
|
|
73
|
+
return appliesTo === "both" || appliesTo === role;
|
|
74
|
+
}
|
|
56
75
|
function resolveMaxConcurrency(value) {
|
|
57
76
|
if (value === undefined)
|
|
58
77
|
return DEFAULT_MAX_CONCURRENCY;
|
|
@@ -90,7 +109,7 @@ async function runWithConcurrency(names, maxConcurrency, signal, start) {
|
|
|
90
109
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
91
110
|
return results;
|
|
92
111
|
}
|
|
93
|
-
function collectFullEntries(settled) {
|
|
112
|
+
function collectFullEntries(settled, registry) {
|
|
94
113
|
const results = {};
|
|
95
114
|
const classifiers = {};
|
|
96
115
|
for (const s of settled) {
|
|
@@ -103,10 +122,10 @@ function collectFullEntries(settled) {
|
|
|
103
122
|
version: manifest.version,
|
|
104
123
|
};
|
|
105
124
|
}
|
|
106
|
-
return { results, meta: { classifiers, certainty: certaintySummary(results) } };
|
|
125
|
+
return { results, meta: { classifiers, certainty: certaintySummary(results, registry) } };
|
|
107
126
|
}
|
|
108
|
-
function certaintySummary(results) {
|
|
109
|
-
const scores =
|
|
127
|
+
function certaintySummary(results, registry) {
|
|
128
|
+
const scores = registry.map((m) => scoreCertainty(results[m.name]?.certainty));
|
|
110
129
|
if (scores.length === 0)
|
|
111
130
|
return { min: 0, avg: 0 };
|
|
112
131
|
const min = Math.min(...scores);
|
|
@@ -130,25 +149,32 @@ function buildRouteResult(request, envelope, results, meta) {
|
|
|
130
149
|
action: "route",
|
|
131
150
|
target_message_hash: request.target_message_hash,
|
|
132
151
|
downstream,
|
|
133
|
-
classifier_outputs:
|
|
152
|
+
classifier_outputs: classifierPublicOutputs(filteredRegistry("user"), results),
|
|
134
153
|
audit: {
|
|
135
154
|
...envelope,
|
|
136
155
|
meta,
|
|
137
156
|
},
|
|
138
157
|
};
|
|
139
158
|
}
|
|
140
|
-
|
|
159
|
+
// Expose each classifier's payload — every output field except `reason` and
|
|
160
|
+
// `certainty`. Iterates the supplied registry so we only surface classifiers
|
|
161
|
+
// that actually ran for this role.
|
|
162
|
+
function classifierPublicOutputs(registry, results) {
|
|
141
163
|
const out = {};
|
|
142
|
-
for (const manifest of
|
|
143
|
-
if (!isCustomManifest(manifest))
|
|
144
|
-
continue;
|
|
164
|
+
for (const manifest of registry) {
|
|
145
165
|
const result = results[manifest.name];
|
|
146
166
|
if (result === undefined)
|
|
147
167
|
continue;
|
|
148
|
-
out[manifest.name] = result
|
|
168
|
+
out[manifest.name] = stripMetadata(result);
|
|
149
169
|
}
|
|
150
170
|
return out;
|
|
151
171
|
}
|
|
172
|
+
function stripMetadata(output) {
|
|
173
|
+
const { reason, certainty, ...payload } = output;
|
|
174
|
+
void reason;
|
|
175
|
+
void certainty;
|
|
176
|
+
return payload;
|
|
177
|
+
}
|
|
152
178
|
async function runClassifierWithRetry(name, input, runClassifier, rootSignal, timeoutMs, retryCount) {
|
|
153
179
|
let lastError = new Error(`${name} classifier did not run`);
|
|
154
180
|
let lastReason = "error";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ToolDefinition } from "./stock.js";
|
|
2
|
+
export declare const RESERVED_FIELD_NAMES: readonly ["final_reply", "ack_reply", "model_tier", "model_specialization", "tools", "risk_level"];
|
|
3
|
+
export type ReservedFieldName = (typeof RESERVED_FIELD_NAMES)[number];
|
|
4
|
+
export declare const RESERVED_FIELD_NAME_SET: ReadonlySet<string>;
|
|
5
|
+
export declare const RESERVED_FIELD_EXCLUSIONS: ReadonlyArray<ReadonlyArray<ReservedFieldName>>;
|
|
6
|
+
export declare const RESERVED_REPLY_MAX_CHARS = 200;
|
|
7
|
+
export interface ReservedFieldContext {
|
|
8
|
+
readonly allowed_tools?: ReadonlyArray<ToolDefinition>;
|
|
9
|
+
}
|
|
10
|
+
export interface ReservedFieldDefinition {
|
|
11
|
+
readonly name: ReservedFieldName;
|
|
12
|
+
readonly requiresAllowedTools: boolean;
|
|
13
|
+
subSchema(context: ReservedFieldContext): unknown;
|
|
14
|
+
promptFragment(context: ReservedFieldContext): string;
|
|
15
|
+
}
|
|
16
|
+
export declare const RESERVED_FIELDS: Readonly<Record<ReservedFieldName, ReservedFieldDefinition>>;
|
|
17
|
+
export declare function isReservedFieldName(name: string): name is ReservedFieldName;
|
|
18
|
+
export declare function normalizeToolId(tool: string): string;
|