open-classify 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -88
- package/bin/open-classify.mjs +201 -0
- package/dist/src/aggregator.d.ts +7 -23
- package/dist/src/aggregator.js +108 -186
- package/dist/src/classifiers/{routing → model_tier}/manifest.json +2 -2
- package/dist/src/classifiers/{routing → model_tier}/prompt.md +1 -1
- package/dist/src/classifiers/preflight/manifest.json +9 -8
- package/dist/src/classifiers/preflight/prompt.md +12 -6
- package/dist/src/classifiers/prompt_injection/manifest.json +2 -3
- package/dist/src/classifiers.d.ts +12 -5
- package/dist/src/classifiers.js +32 -16
- package/dist/src/classify.d.ts +5 -3
- package/dist/src/classify.js +28 -8
- package/dist/src/config.d.ts +1 -3
- package/dist/src/config.js +1 -28
- package/dist/src/index.js +2 -3
- package/dist/src/manifest.d.ts +25 -70
- package/dist/src/ollama.d.ts +5 -6
- package/dist/src/ollama.js +17 -11
- package/dist/src/pipeline.d.ts +3 -2
- package/dist/src/pipeline.js +32 -94
- package/dist/src/stock-validation.js +8 -4
- package/docs/adding-a-classifier.md +50 -27
- package/docs/manifests.md +6 -6
- package/docs/resolver.md +20 -44
- package/docs/signals.md +18 -8
- package/open-classify.config.example.json +2 -7
- package/package.json +6 -1
- /package/{dist/src/classifiers → templates}/context_shift/manifest.json +0 -0
- /package/{dist/src/classifiers → templates}/context_shift/prompt.md +0 -0
- /package/{dist/src/classifiers → templates}/conversation_digest/manifest.json +0 -0
- /package/{dist/src/classifiers → templates}/conversation_digest/prompt.md +0 -0
- /package/{dist/src/classifiers → templates}/memory_retrieval_queries/manifest.json +0 -0
- /package/{dist/src/classifiers → templates}/memory_retrieval_queries/prompt.md +0 -0
- /package/{dist/src/classifiers → templates}/tools/manifest.json +0 -0
- /package/{dist/src/classifiers → templates}/tools/prompt.md +0 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
Decide what should happen to a user message <em>before</em> it reaches your downstream model.
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
|
-
Open Classify is a pre-routing layer for AI products. It runs a small set of fast classifiers in parallel against the latest user message, then returns a single
|
|
9
|
+
Open Classify is a pre-routing layer for AI products. It runs a small set of fast classifiers in parallel against the latest user message, then returns a single `PipelineResult` your app can act on: an action (`route`, `block`, or `reply`), a downstream model recommendation, a tool exposure list, an optional immediate reply, and any custom signals your own classifiers contribute.
|
|
10
10
|
|
|
11
11
|
Use it when your frontier model should not be the first thing every request touches. Open Classify can handle tiny terminal replies before they hit an expensive model, recommend the right downstream model for the actual task, suggest what tools or context the downstream model should receive, and add a focused prompt-injection pass.
|
|
12
12
|
|
|
@@ -17,7 +17,7 @@ message
|
|
|
17
17
|
normalize + trim classifier context
|
|
18
18
|
│
|
|
19
19
|
├─► preflight ─────────────► final_reply? / ack_reply?
|
|
20
|
-
├─►
|
|
20
|
+
├─► model_tier ────────────► model_tier?
|
|
21
21
|
├─► model_specialization ──► model_specialization?
|
|
22
22
|
├─► tools ─────────────────► tools?
|
|
23
23
|
├─► prompt_injection ─────► risk_level?
|
|
@@ -28,81 +28,102 @@ normalize + trim classifier context
|
|
|
28
28
|
aggregator + model catalog
|
|
29
29
|
│
|
|
30
30
|
▼
|
|
31
|
-
|
|
31
|
+
PipelineResult { action, model_id, tools, reply, ... }
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Every classifier uses the same manifest shape and emits the same output envelope: `{ reason, certainty, ...payload }`. Some payload fields are **reserved** — like `model_tier`, `final_reply`, and `risk_level` — and the aggregator knows how to consume them into a routing decision. Everything else is your classifier's own data and passes through to the caller untouched.
|
|
35
35
|
|
|
36
36
|
## Why Open Classify
|
|
37
37
|
|
|
38
|
-
- **Spend frontier tokens only when they matter.** Simple greetings, thanks, spelling checks, and small arithmetic can be answered immediately
|
|
39
|
-
- **Keep the user interface responsive.** For complex work, preflight
|
|
38
|
+
- **Spend frontier tokens only when they matter.** Simple greetings, thanks, spelling checks, and small arithmetic can be answered immediately (`action: "reply"`) without sending the request downstream.
|
|
39
|
+
- **Keep the user interface responsive.** For complex work, preflight emits an `ack_reply` — a task-specific acknowledgement your UI can show while routing the real request.
|
|
40
40
|
- **Pick the right model per message.** Classifiers emit soft constraints like tier and specialization; your catalog turns those into a concrete model optimized for cost, capability, and fit.
|
|
41
41
|
- **Shape downstream context intentionally.** Built-in and custom classifiers can recommend tools, retrieval queries, summaries, or other context hints without passing the full conversation history back to the caller.
|
|
42
|
-
- **Add another defensive layer.** The `prompt_injection` classifier surfaces instruction-override attempts
|
|
42
|
+
- **Add another defensive layer.** The `prompt_injection` classifier surfaces instruction-override attempts. High-risk or unknown injection risk automatically sets `action: "block"`.
|
|
43
43
|
|
|
44
|
-
##
|
|
44
|
+
## Getting started
|
|
45
|
+
|
|
46
|
+
Node 18+. The packaged runner uses local Ollama with `gemma4:e4b-it-q4_K_M` as the zero-config classifier model. Pluggable via `open-classify.config.json` or a custom `RunClassifier`.
|
|
47
|
+
|
|
48
|
+
**1. Install**
|
|
45
49
|
|
|
46
50
|
```sh
|
|
47
51
|
npm install open-classify
|
|
48
52
|
```
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
**2. Scaffold**
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
```sh
|
|
57
|
+
npx open-classify init
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This creates `open-classify.config.json` and a `classifiers/` directory in your project root. You'll see exactly what will be written and asked to confirm. Re-run safe: existing files are skipped.
|
|
61
|
+
|
|
62
|
+
**3. Use it**
|
|
53
63
|
|
|
54
64
|
```ts
|
|
55
65
|
import { createClassifier } from "open-classify";
|
|
56
66
|
|
|
57
|
-
const { classify
|
|
67
|
+
const { classify } = createClassifier({
|
|
68
|
+
extraClassifierDirs: ["./classifiers"],
|
|
69
|
+
});
|
|
58
70
|
|
|
59
71
|
const result = await classify({
|
|
60
|
-
messages: [
|
|
61
|
-
{ role: "user", text: "Can you review the attached contract?" },
|
|
62
|
-
],
|
|
72
|
+
messages: [{ role: "user", text: "Can you review the attached contract?" }],
|
|
63
73
|
});
|
|
64
74
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
if (result.action === "reply") respondToUser(result.reply.text); // preflight answered it
|
|
76
|
+
else if (result.action === "block") handleBlock(result.block_reason); // injection or error
|
|
77
|
+
else callDownstream(result.model_id, result.tools, result.reply?.text); // route the real request
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**4. Activate or customize a classifier**
|
|
81
|
+
|
|
82
|
+
Inside `classifiers/` you'll find four `_<name>/` directories — templates copied from the package, inactive because of the underscore prefix. To turn one on, drop the underscore:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
mv classifiers/_tools classifiers/tools
|
|
70
86
|
```
|
|
71
87
|
|
|
72
|
-
`
|
|
88
|
+
Edit `manifest.json` first if you need to (e.g. trim `allowed_tools` for your app). The same underscore convention works the other way too: rename `my_classifier/` → `_my_classifier/` to take any classifier out of the active set without deleting it.
|
|
89
|
+
|
|
90
|
+
To write a new classifier from scratch, drop a `<name>/manifest.json` + `<name>/prompt.md` in `classifiers/`. See [docs/adding-a-classifier.md](docs/adding-a-classifier.md).
|
|
73
91
|
|
|
74
92
|
### Classifying assistant output
|
|
75
93
|
|
|
76
|
-
`inspect()` is a lean second pass for the **assistant's reply**. It only runs classifiers tagged `applies_to: "both"` (or `"assistant"`) in their manifest, and returns
|
|
94
|
+
`inspect()` is a lean second pass for the **assistant's reply**. It only runs classifiers tagged `applies_to: "both"` (or `"assistant"`) in their manifest, and returns the per-classifier outputs plus the message that was inspected — no routing, no action, no block logic.
|
|
77
95
|
|
|
78
96
|
```ts
|
|
79
|
-
const
|
|
97
|
+
const result = await inspect({
|
|
80
98
|
messages: [
|
|
81
99
|
{ role: "user", text: "Summarize the contract." },
|
|
82
100
|
{ role: "assistant", text: "The contract has three notable risks…" },
|
|
83
101
|
],
|
|
84
102
|
});
|
|
85
103
|
|
|
86
|
-
|
|
104
|
+
// result.message is { role: "assistant", text: "..." }
|
|
105
|
+
const risk = result.classifier_outputs.prompt_injection?.risk_level;
|
|
87
106
|
```
|
|
88
107
|
|
|
89
|
-
Use it for things like prompt-injection checks on model output, summarized slugs, or any classifier you want to apply post-hoc. The built-in `prompt_injection` classifier ships tagged `"both"`, so it runs in both passes; everything else is `"user"` by default.
|
|
108
|
+
Use it for things like prompt-injection checks on model output, summarized slugs, or any classifier you want to apply post-hoc. The built-in `prompt_injection` classifier ships tagged `"both"`, so it runs in both passes; everything else is `"user"` by default.
|
|
90
109
|
|
|
91
110
|
## What you get back
|
|
92
111
|
|
|
93
|
-
Every call returns a `PipelineResult`:
|
|
112
|
+
Every `classify()` call returns a `PipelineResult`:
|
|
94
113
|
|
|
95
114
|
| Field | What it is |
|
|
96
115
|
|---|---|
|
|
97
|
-
| `action` |
|
|
116
|
+
| `action` | `"route"` \| `"block"` \| `"reply"` |
|
|
117
|
+
| `block_reason` | `"prompt_injection"` \| `"classification_error"` (only when `action === "block"`) |
|
|
98
118
|
| `target_message_hash` | Stable 8-hex fingerprint of the target message |
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
|
|
105
|
-
|
|
119
|
+
| `model_id` | Concrete model id chosen from your catalog (or `null` if unresolvable) |
|
|
120
|
+
| `tools` | Recommended tool ids (always an array; empty if not emitted) |
|
|
121
|
+
| `reply` | `{ text }` — the `ack_reply` or `final_reply` text, if any |
|
|
122
|
+
| `prompt_injection` | `{ risk_level }` from the injection classifier, or `null` |
|
|
123
|
+
| `avg_certainty` | Arithmetic mean certainty score (float 0–1) across all classifiers |
|
|
124
|
+
| `min_certainty` | Minimum certainty score (float 0–1) across all classifiers |
|
|
125
|
+
| `failed_classifiers` | Names of classifiers that errored or timed out (always present; may be empty) |
|
|
126
|
+
| `classifier_outputs` | Each classifier's payload with `reason` (string) and `certainty` (float) |
|
|
106
127
|
|
|
107
128
|
Example result:
|
|
108
129
|
|
|
@@ -110,56 +131,55 @@ Example result:
|
|
|
110
131
|
{
|
|
111
132
|
"action": "route",
|
|
112
133
|
"target_message_hash": "b11d5268",
|
|
113
|
-
"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
134
|
+
"model_id": "gpt-5.5",
|
|
135
|
+
"tools": ["workspace"],
|
|
136
|
+
"reply": { "text": "On it — I'll review the contract now." },
|
|
137
|
+
"prompt_injection": { "risk_level": "normal" },
|
|
138
|
+
"avg_certainty": 0.84,
|
|
139
|
+
"min_certainty": 0.75,
|
|
140
|
+
"failed_classifiers": [],
|
|
118
141
|
"classifier_outputs": {
|
|
119
|
-
"
|
|
120
|
-
"model_specialization": { "model_specialization": "coding" },
|
|
121
|
-
"tools": { "tools": ["workspace"] },
|
|
122
|
-
"prompt_injection": { "risk_level": "normal" },
|
|
123
|
-
"memory_retrieval_queries": { "queries": ["user code review preferences"] }
|
|
124
|
-
},
|
|
125
|
-
"audit": {
|
|
126
|
-
"ack_reply": { "text": "Let me check." },
|
|
127
|
-
"routing": { "model_tier": "frontier_strong", "model_specialization": "coding" },
|
|
128
|
-
"tools": { "tools": ["workspace"] },
|
|
129
|
-
"prompt_injection": { "risk_level": "normal" },
|
|
130
|
-
"classifier_outputs": [ /* every classifier's full output, with reason + certainty */ ],
|
|
131
|
-
"model_recommendation": {
|
|
132
|
-
"id": "gpt-5.5",
|
|
133
|
-
"context_window": 1050000,
|
|
134
|
-
"resolution": { "...": "..." }
|
|
135
|
-
},
|
|
136
|
-
"meta": { "classifiers": { "...": "..." } }
|
|
142
|
+
"model_tier": { "model_tier": "frontier_strong", "reason": "...", "certainty": 0.88 },
|
|
143
|
+
"model_specialization": { "model_specialization": "coding", "reason": "...", "certainty": 0.75 },
|
|
144
|
+
"tools": { "tools": ["workspace"], "reason": "...", "certainty": 0.88 },
|
|
145
|
+
"prompt_injection": { "risk_level": "normal", "reason": "...", "certainty": 0.97 },
|
|
146
|
+
"memory_retrieval_queries": { "queries": ["user code review preferences"], "reason": "...", "certainty": 0.75 }
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
```
|
|
140
150
|
|
|
141
151
|
## Classifier model
|
|
142
152
|
|
|
143
|
-
|
|
153
|
+
Every classifier — bundled or your own — uses the same two-file shape (`manifest.json` + `prompt.md`) and emits the same envelope: `{ reason, certainty, ...payload }`. Some payload fields are **reserved** (like `model_tier`, `final_reply`, `risk_level`); the aggregator knows how to consume them into the routing decision. Everything else passes through to the caller.
|
|
154
|
+
|
|
155
|
+
Open Classify ships eight built-in classifiers. **Four are mandatory** — they always load, they can't be turned off, and extras can't override them. The other four ship as **templates** that `init` copies into your project as inactive (`_<name>/`); rename to activate.
|
|
144
156
|
|
|
145
|
-
| Name | Reserved fields | What the aggregator does with it |
|
|
146
|
-
|
|
147
|
-
| `preflight` | `final_reply`, `ack_reply` |
|
|
148
|
-
| `
|
|
149
|
-
| `model_specialization` | `model_specialization` | Feeds the catalog resolver as a soft constraint |
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `memory_retrieval_queries` | — | Passes through to `classifier_outputs
|
|
153
|
-
| `conversation_digest` | — | Passes through |
|
|
154
|
-
| `context_shift` | — | Passes through |
|
|
157
|
+
| Name | dispatch_order | Reserved fields | Bundled as | What the aggregator does with it |
|
|
158
|
+
|---|---|---|---|---|
|
|
159
|
+
| `preflight` | 10 | `final_reply`, `ack_reply` | mandatory | Sets `action: "reply"` or populates `result.reply` |
|
|
160
|
+
| `model_tier` | 20 | `model_tier` | mandatory | Feeds the catalog resolver as a soft constraint |
|
|
161
|
+
| `model_specialization` | 30 | `model_specialization` | mandatory | Feeds the catalog resolver as a soft constraint |
|
|
162
|
+
| `prompt_injection` | 50 | `risk_level` | mandatory | High-risk/unknown → `action: "block"`; suspicious → advisory |
|
|
163
|
+
| `tools` | 40 | `tools` | template | Sets `result.tools` |
|
|
164
|
+
| `memory_retrieval_queries` | 60 | — | template | Passes through to `classifier_outputs` |
|
|
165
|
+
| `conversation_digest` | 70 | — | template | Passes through |
|
|
166
|
+
| `context_shift` | 80 | — | template | Passes through |
|
|
155
167
|
|
|
156
|
-
|
|
168
|
+
The directory-naming convention (`_<name>/` = inactive) is the only on/off mechanism, and it applies equally to bundled templates and your own classifiers. No `disabled` config, no allow-lists, no flags. If a folder is in `classifiers/` without a leading underscore, it runs.
|
|
157
169
|
|
|
158
|
-
|
|
170
|
+
> Need to customize `preflight`'s prompt or any other mandatory built-in? Use a custom `RunClassifier` (see [Bring your own backend](#bring-your-own-backend)) to intercept it, or fork the package.
|
|
159
171
|
|
|
160
|
-
|
|
172
|
+
## Adding your own classifier
|
|
161
173
|
|
|
162
|
-
|
|
174
|
+
The two files are:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
classifiers/topic_tags/
|
|
178
|
+
├── manifest.json
|
|
179
|
+
└── prompt.md
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`manifest.json` declares the output shape and a fallback for when the classifier errors:
|
|
163
183
|
|
|
164
184
|
```json
|
|
165
185
|
{
|
|
@@ -184,35 +204,26 @@ Every classifier is two files in `src/classifiers/<name>/`:
|
|
|
184
204
|
}
|
|
185
205
|
```
|
|
186
206
|
|
|
187
|
-
`prompt.md`
|
|
207
|
+
`prompt.md` is the classification rule in plain language. No need to write JSON examples — the runtime synthesizes one from your schema — and no need to paste enum values for reserved fields:
|
|
188
208
|
|
|
189
209
|
```markdown
|
|
190
210
|
You are the topic_tags classifier.
|
|
191
211
|
|
|
192
212
|
`tags` are short single-word topic labels (lowercase, no spaces). Use at most five.
|
|
193
213
|
Return an empty array when no clear topic applies.
|
|
194
|
-
Do not invent tags for vague or ambiguous messages.
|
|
195
214
|
```
|
|
196
215
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
- `name` must match the directory name.
|
|
200
|
-
- Reserved field names cannot appear in `output_schema.properties` — declare them in `reserved_fields` instead.
|
|
201
|
-
- `fallback` must validate against the composed schema; reserved fields are optional in fallback since "I failed" means "no signal."
|
|
202
|
-
- If you want hand-picked examples (preflight does this), add an `output_schema.examples` array. Each entry must validate against the composed schema at load time. Otherwise the runtime synthesizes a skeleton example for you.
|
|
203
|
-
|
|
204
|
-
Consume your output:
|
|
216
|
+
Consume:
|
|
205
217
|
|
|
206
218
|
```ts
|
|
207
|
-
const result = await classify(input);
|
|
208
219
|
const tags = result.classifier_outputs.topic_tags?.tags ?? [];
|
|
209
220
|
```
|
|
210
221
|
|
|
211
|
-
See [docs/adding-a-classifier.md](docs/adding-a-classifier.md) for
|
|
222
|
+
Rules: `name` must match the directory name; reserved-field names can't appear in `output_schema.properties` (declare them under `reserved_fields` instead); `fallback` only needs `reason` and `certainty`; name collisions throw at startup. See [docs/adding-a-classifier.md](docs/adding-a-classifier.md) for the full reference.
|
|
212
223
|
|
|
213
224
|
## Using reserved fields in your own classifier
|
|
214
225
|
|
|
215
|
-
Any classifier can emit reserved fields. If you write your own `task_router` that emits `model_tier`, the aggregator will fold it into the model resolution alongside the built-in `
|
|
226
|
+
Any classifier can emit reserved fields. If you write your own `task_router` that emits `model_tier`, the aggregator will fold it into the model resolution alongside the built-in `model_tier` classifier — highest-certainty contributor wins, ties broken by manifest `dispatch_order` ascending.
|
|
216
227
|
|
|
217
228
|
```json
|
|
218
229
|
{
|
|
@@ -262,7 +273,7 @@ Classifiers never emit model ids. They emit constraints; your catalog maps const
|
|
|
262
273
|
}
|
|
263
274
|
```
|
|
264
275
|
|
|
265
|
-
The resolver picks the cheapest model matching `model_specialization` and `model_tier`, relaxing constraints in order when nothing fits
|
|
276
|
+
The resolver picks the cheapest model matching `model_specialization` and `model_tier`, relaxing constraints in order when nothing fits. See [docs/resolver.md](docs/resolver.md) for ranking details.
|
|
266
277
|
|
|
267
278
|
## Input contract
|
|
268
279
|
|
|
@@ -292,14 +303,11 @@ cp open-classify.config.example.json open-classify.config.json
|
|
|
292
303
|
"provider": "ollama",
|
|
293
304
|
"defaultModel": "gemma4:e4b-it-q4_K_M",
|
|
294
305
|
"models": {
|
|
295
|
-
"
|
|
306
|
+
"model_tier": "qwen2.5:7b-instruct-q4_K_M",
|
|
296
307
|
"prompt_injection": "llama-guard3:8b",
|
|
297
308
|
"memory_retrieval_queries": "qwen2.5:7b-instruct-q4_K_M"
|
|
298
309
|
}
|
|
299
310
|
},
|
|
300
|
-
"aggregator": {
|
|
301
|
-
"certaintyThreshold": 0.65
|
|
302
|
-
},
|
|
303
311
|
"catalog": "downstream-models.json"
|
|
304
312
|
}
|
|
305
313
|
```
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// open-classify CLI. Currently exposes a single subcommand: `init`.
|
|
3
|
+
//
|
|
4
|
+
// `init` scaffolds the standard project layout for a consumer:
|
|
5
|
+
// - open-classify.config.json (minimal)
|
|
6
|
+
// - classifiers/
|
|
7
|
+
// - README.md
|
|
8
|
+
// - _conversation_digest/ (templates, prefix means inactive)
|
|
9
|
+
// - _context_shift/
|
|
10
|
+
// - _memory_retrieval_queries/
|
|
11
|
+
// - _tools/
|
|
12
|
+
//
|
|
13
|
+
// Re-run safe: existing files are skipped, never overwritten. Use
|
|
14
|
+
// `--yes` to skip the confirmation prompt (for scripted setup).
|
|
15
|
+
|
|
16
|
+
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..");
|
|
23
|
+
const TEMPLATES_DIR = join(PACKAGE_ROOT, "templates");
|
|
24
|
+
|
|
25
|
+
const TEMPLATE_NAMES = ["conversation_digest", "context_shift", "memory_retrieval_queries", "tools"];
|
|
26
|
+
|
|
27
|
+
const CLASSIFIERS_README = `# classifiers/
|
|
28
|
+
|
|
29
|
+
Drop a folder here per classifier. Each folder needs:
|
|
30
|
+
|
|
31
|
+
- \`manifest.json\` — see [open-classify docs](https://github.com/taylorbayouth/open-classify/blob/main/docs/adding-a-classifier.md)
|
|
32
|
+
- \`prompt.md\` — the classifier-specific instructions
|
|
33
|
+
|
|
34
|
+
## Activating templates
|
|
35
|
+
|
|
36
|
+
The four \`_<name>/\` directories below are templates copied from the package — they ship inactive (the loader skips any folder starting with \`_\`). Activate one by dropping the underscore:
|
|
37
|
+
|
|
38
|
+
\`\`\`sh
|
|
39
|
+
mv _tools tools
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
You probably also want to edit its \`manifest.json\` first to fit your app (e.g. trim the \`allowed_tools\` list).
|
|
43
|
+
|
|
44
|
+
## Deactivating without deleting
|
|
45
|
+
|
|
46
|
+
Same trick in reverse — rename \`my_classifier\` → \`_my_classifier\` to take it out of the active set without losing your work.
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const DEFAULT_CONFIG = {
|
|
50
|
+
runner: {
|
|
51
|
+
provider: "ollama",
|
|
52
|
+
host: "http://127.0.0.1:11434",
|
|
53
|
+
defaultModel: "gemma4:e4b-it-q4_K_M",
|
|
54
|
+
},
|
|
55
|
+
catalog: "downstream-models.json",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
const args = process.argv.slice(2);
|
|
60
|
+
const subcommand = args[0];
|
|
61
|
+
|
|
62
|
+
if (!subcommand || subcommand === "-h" || subcommand === "--help") {
|
|
63
|
+
printHelp();
|
|
64
|
+
process.exit(subcommand ? 0 : 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (subcommand === "init") {
|
|
68
|
+
const yes = args.includes("--yes") || args.includes("-y");
|
|
69
|
+
await runInit({ cwd: process.cwd(), yes });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
74
|
+
printHelp();
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printHelp() {
|
|
79
|
+
process.stdout.write(`open-classify — runtime CLI
|
|
80
|
+
|
|
81
|
+
Commands:
|
|
82
|
+
init [--yes] Scaffold open-classify.config.json and classifiers/ in the
|
|
83
|
+
current directory. Re-run safe: existing files are skipped.
|
|
84
|
+
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runInit({ cwd, yes }) {
|
|
89
|
+
const plan = planInit(cwd);
|
|
90
|
+
|
|
91
|
+
if (plan.toCreate.length === 0) {
|
|
92
|
+
console.log("Nothing to do — your project already has all the scaffolded files.");
|
|
93
|
+
if (plan.toSkip.length > 0) {
|
|
94
|
+
console.log("\nAlready in place:");
|
|
95
|
+
for (const p of plan.toSkip) console.log(` ${p}`);
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log("This will create:");
|
|
101
|
+
for (const p of plan.toCreate) console.log(` ${p}`);
|
|
102
|
+
if (plan.toSkip.length > 0) {
|
|
103
|
+
console.log("\nAlready exists (will skip):");
|
|
104
|
+
for (const p of plan.toSkip) console.log(` ${p}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!yes) {
|
|
108
|
+
const proceed = await confirm("\nContinue? [Y/n] ");
|
|
109
|
+
if (!proceed) {
|
|
110
|
+
console.log("Aborted.");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const action of plan.actions) {
|
|
116
|
+
action();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log("\nDone. Wire it into your code:\n");
|
|
120
|
+
console.log(" import { createClassifier } from \"open-classify\";");
|
|
121
|
+
console.log(" const { classify } = createClassifier({");
|
|
122
|
+
console.log(" extraClassifierDirs: [\"./classifiers\"],");
|
|
123
|
+
console.log(" });");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function planInit(cwd) {
|
|
127
|
+
const toCreate = [];
|
|
128
|
+
const toSkip = [];
|
|
129
|
+
const actions = [];
|
|
130
|
+
|
|
131
|
+
const configPath = join(cwd, "open-classify.config.json");
|
|
132
|
+
if (existsSync(configPath)) {
|
|
133
|
+
toSkip.push(relative(cwd, configPath));
|
|
134
|
+
} else {
|
|
135
|
+
toCreate.push(relative(cwd, configPath));
|
|
136
|
+
actions.push(() => {
|
|
137
|
+
writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
|
|
138
|
+
console.log(`wrote ${relative(cwd, configPath)}`);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const classifiersDir = join(cwd, "classifiers");
|
|
143
|
+
if (!existsSync(classifiersDir)) {
|
|
144
|
+
toCreate.push(relative(cwd, classifiersDir) + "/");
|
|
145
|
+
actions.push(() => {
|
|
146
|
+
mkdirSync(classifiersDir, { recursive: true });
|
|
147
|
+
console.log(`created ${relative(cwd, classifiersDir)}/`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const readmePath = join(classifiersDir, "README.md");
|
|
152
|
+
if (existsSync(readmePath)) {
|
|
153
|
+
toSkip.push(relative(cwd, readmePath));
|
|
154
|
+
} else {
|
|
155
|
+
toCreate.push(relative(cwd, readmePath));
|
|
156
|
+
actions.push(() => {
|
|
157
|
+
// The classifiers dir may not yet exist when we generated the plan,
|
|
158
|
+
// but it will by the time this action runs.
|
|
159
|
+
mkdirSync(classifiersDir, { recursive: true });
|
|
160
|
+
writeFileSync(readmePath, CLASSIFIERS_README);
|
|
161
|
+
console.log(`wrote ${relative(cwd, readmePath)}`);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const name of TEMPLATE_NAMES) {
|
|
166
|
+
const inactivePath = join(classifiersDir, `_${name}`);
|
|
167
|
+
const activePath = join(classifiersDir, name);
|
|
168
|
+
|
|
169
|
+
if (existsSync(inactivePath) || existsSync(activePath)) {
|
|
170
|
+
// Either already scaffolded (inactive) or already activated by the
|
|
171
|
+
// consumer. Either way, leave it alone.
|
|
172
|
+
toSkip.push(relative(cwd, existsSync(activePath) ? activePath : inactivePath) + "/");
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
toCreate.push(relative(cwd, inactivePath) + "/");
|
|
177
|
+
actions.push(() => {
|
|
178
|
+
mkdirSync(classifiersDir, { recursive: true });
|
|
179
|
+
cpSync(join(TEMPLATES_DIR, name), inactivePath, { recursive: true });
|
|
180
|
+
console.log(`wrote ${relative(cwd, inactivePath)}/`);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { toCreate, toSkip, actions };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function confirm(prompt) {
|
|
188
|
+
return new Promise((resolveAnswer) => {
|
|
189
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
190
|
+
rl.question(prompt, (answer) => {
|
|
191
|
+
rl.close();
|
|
192
|
+
const normalized = (answer || "").trim().toLowerCase();
|
|
193
|
+
resolveAnswer(normalized === "" || normalized === "y" || normalized === "yes");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
main().catch((err) => {
|
|
199
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
200
|
+
process.exit(1);
|
|
201
|
+
});
|
package/dist/src/aggregator.d.ts
CHANGED
|
@@ -1,28 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { AckReplySignal,
|
|
3
|
-
|
|
4
|
-
import type { ClassifierInput } from "./types.js";
|
|
5
|
-
export declare const DEFAULT_CERTAINTY_THRESHOLD = 0.65;
|
|
6
|
-
/** @deprecated Use DEFAULT_CERTAINTY_THRESHOLD. */
|
|
7
|
-
export declare const DEFAULT_CONFIDENCE_THRESHOLD = 0.65;
|
|
8
|
-
export interface ComposeEnvelopeArgs {
|
|
1
|
+
import type { Catalog, ClassifierPublicOutputs, ClassifierRegistry, ClassifierResults, PipelineResult } from "./manifest.js";
|
|
2
|
+
import type { AckReplySignal, FinalReplySignal, ToolsSignal } from "./stock.js";
|
|
3
|
+
export interface AssembleResultArgs {
|
|
9
4
|
readonly registry: ClassifierRegistry;
|
|
10
5
|
readonly results: ClassifierResults;
|
|
6
|
+
readonly failedClassifiers: ReadonlyArray<string>;
|
|
11
7
|
readonly catalog: Catalog;
|
|
12
|
-
readonly input: ClassifierInput;
|
|
13
|
-
readonly config?: AggregatorConfig;
|
|
14
8
|
}
|
|
15
|
-
|
|
16
|
-
export declare function
|
|
17
|
-
export declare function
|
|
18
|
-
export declare function resolveModel(results: Readonly<{
|
|
19
|
-
routing?: {
|
|
20
|
-
model_tier?: DownstreamModelTier;
|
|
21
|
-
certainty?: Certainty;
|
|
22
|
-
};
|
|
23
|
-
model_specialization?: {
|
|
24
|
-
model_specialization?: ModelSpecialization;
|
|
25
|
-
certainty?: Certainty;
|
|
26
|
-
};
|
|
27
|
-
}>, catalog: Catalog, threshold: number): ModelRecommendation;
|
|
9
|
+
type AssembledResult = Omit<PipelineResult, "target_message_hash">;
|
|
10
|
+
export declare function assembleResult(args: AssembleResultArgs): AssembledResult;
|
|
11
|
+
export declare function buildPublicOutputs(registry: ClassifierRegistry, results: ClassifierResults): ClassifierPublicOutputs;
|
|
28
12
|
export type { FinalReplySignal, AckReplySignal, ToolsSignal };
|