sanity-plugin-jev-fields 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frederik von Sperling
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 CHANGED
@@ -1,4 +1,185 @@
1
- # Temporary Holding Version
1
+ # sanity-plugin-jev-fields
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
- If no other versions are published within 30 days, this package and version will be deleted.
3
+ > Beta: expect `0.x` releases to change until the stored value shapes are frozen at `1.0.0`.
4
+
5
+ Jev signals for Sanity Studio: questions about a field's content, answered by
6
+ [TypeSafe's Jev](https://typesafe.ai) decision model through
7
+ [Vercel AI Gateway](https://vercel.com/ai-gateway) as editors write. Each signal is a chip under
8
+ the field it judges; click it for the details.
9
+
10
+ - `noul`: a yes/no question, answered with the probability that the answer is yes
11
+ - `score`: a position on an ordered scale you define
12
+ - `choice`: one option from a named set, with a probability for each option
13
+
14
+ Signals re-evaluate shortly after the field is edited. Opening a document never writes to it.
15
+
16
+ ![A Body field in Sanity Studio with three signal chips under it: Readable 90%, Evidence 1.5 of 3 and Tone Casual. The Evidence details are open: a four-step bar from None to Cited, filled to Anecdotal, and the hint "To move up: add a source or figure."](https://raw.githubusercontent.com/frederikvonsperling/sanity-plugin-jev-fields/main/docs/images/signals.png)
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ pnpm add sanity-plugin-jev-fields
22
+ ```
23
+
24
+ Requires Sanity Studio 6.
25
+
26
+ ## Usage
27
+
28
+ Add the plugin, and wrap your schema types with `withJevAnswers` so every signal gets a field to
29
+ store its answer in:
30
+
31
+ ```ts
32
+ // sanity.config.ts
33
+ import {defineConfig} from 'sanity'
34
+ import {jev, withJevAnswers} from 'sanity-plugin-jev-fields'
35
+
36
+ export default defineConfig({
37
+ // ...
38
+ plugins: [jev()],
39
+ schema: {types: withJevAnswers(schemaTypes)},
40
+ })
41
+ ```
42
+
43
+ Then attach signals to any field with `options.jev`:
44
+
45
+ ```ts
46
+ import {defineArrayMember, defineField} from 'sanity'
47
+ import {choice, noul, score} from 'sanity-plugin-jev-fields'
48
+
49
+ defineField({
50
+ name: 'body',
51
+ type: 'array',
52
+ of: [defineArrayMember({type: 'block'})],
53
+ options: {
54
+ jev: {
55
+ readable: noul({
56
+ instructions: 'Is this article easy to read for a general audience?',
57
+ true: 'Short sentences, plain words, clear structure',
58
+ false: 'Dense, jargon-heavy or hard to follow',
59
+ label: 'likely to read easily',
60
+ }),
61
+ evidence: score({
62
+ instructions: 'How well does this article support its claims?',
63
+ // Lowest first. Text before a colon is the label.
64
+ criteria: [
65
+ 'none: no backing for its claims',
66
+ 'anecdotal: personal experience only',
67
+ 'data: add a source or figure',
68
+ 'cited: key claims cite their sources',
69
+ ],
70
+ }),
71
+ tone: choice({
72
+ instructions: 'What is the tone of this article?',
73
+ criteria: {formal: 'Professional and reserved', casual: 'Conversational and relaxed'},
74
+ }),
75
+ },
76
+ },
77
+ })
78
+ ```
79
+
80
+ A signal reads only the field it is attached to. Portable Text, slugs and nested objects are
81
+ flattened to plain text. Each key (`readable`, `evidence`, `tone`) becomes the name of a field
82
+ next to it that stores the answer; `withJevAnswers` adds those fields and stops with an error if a
83
+ name is already taken.
84
+
85
+ ## Rules
86
+
87
+ Give a signal a `warn` or `require` rule to act on its answer: `warn` shows a warning on the
88
+ attached field, `require` an error that blocks publishing.
89
+
90
+ ```ts
91
+ options: {
92
+ jev: {
93
+ readable: noul({...signal, warn: {atLeast: 0.6}}), // probability, 0–1
94
+ evidence: score({...signal, require: {atLeast: 2}}), // position on the scale
95
+ tone: choice({...signal, warn: {oneOf: ['formal', 'casual']}}),
96
+ },
97
+ }
98
+ ```
99
+
100
+ Rules judge the stored answer, so they say nothing until a signal has been evaluated, and an
101
+ answer that is out of date is still judged as it is.
102
+
103
+ ## What Jev is good at
104
+
105
+ Jev judges meaning in text: tone, clarity, whether claims are backed up. It reads only text, so
106
+ images and other media in a field are ignored. It is not built for counting or arithmetic: ask
107
+ "Is this under 150 words?" in a validation rule instead. Test signals on content in your own
108
+ languages before relying on them. A field's text may be up to about 32k tokens; the Studio warns
109
+ when a field gets close.
110
+
111
+ ## API key
112
+
113
+ Open the **Jev** tool in the Studio, click **Set key** and paste an AI Gateway API key. The tool
114
+ shows whether a key is set, when it last changed, and can test the connection.
115
+
116
+ The key is stored in the document `secrets.jev` in your dataset. Unauthenticated queries can't
117
+ read it, but anyone who can read the dataset can: every Studio user, and every API token with read
118
+ access, such as a frontend's read or preview token. It is also included in dataset exports and
119
+ backups. Use a dedicated key with a spend limit.
120
+
121
+ If editors and tokens must never see the key, send requests through your own server instead:
122
+
123
+ ```ts
124
+ jev({
125
+ transport: (request, {signal}) =>
126
+ fetch('/api/jev', {method: 'POST', body: JSON.stringify(request), signal}),
127
+ })
128
+ ```
129
+
130
+ Your endpoint forwards the body unchanged to `https://ai-gateway.vercel.sh/v1/evaluate` with an
131
+ `Authorization: Bearer <key>` header, and returns the Gateway's response.
132
+
133
+ ## Stored values
134
+
135
+ ```groq
136
+ *[_type == "article"]{
137
+ title,
138
+ "readable": readable.probability, // 0–1
139
+ "evidence": evidence{score, max, label}, // score runs from 0 to max
140
+ "tone": tone.choice
141
+ }
142
+ ```
143
+
144
+ Every value also stores `evaluatedAt`, `model` (e.g. `typesafe-ai/jev`) and a `sourceHash` the
145
+ Studio uses to show when a value is out of date.
146
+
147
+ ## Translations
148
+
149
+ Everything editors see is in the `jev` namespace of the Studio's translations, in US English.
150
+ Add another language with a locale bundle that uses the same keys (see `JevTranslationKey`):
151
+
152
+ ```ts
153
+ import {defineLocaleResourceBundle} from 'sanity'
154
+ import {JEV_NAMESPACE} from 'sanity-plugin-jev-fields'
155
+
156
+ export const jevNorwegian = defineLocaleResourceBundle({
157
+ locale: 'nb-NO',
158
+ namespace: JEV_NAMESPACE,
159
+ resources: {'strip.set-up': 'Sett opp Jev' /* … */},
160
+ })
161
+ ```
162
+
163
+ Rule messages and config problems stay in English: Sanity's validation has no translation hook,
164
+ and config problems are meant for schema authors.
165
+
166
+ ## Development
167
+
168
+ ```sh
169
+ pnpm install
170
+ pnpm test
171
+ pnpm build
172
+ ```
173
+
174
+ `pnpm record-fixtures` re-records the real Gateway responses in `src/__fixtures__` (needs
175
+ `AI_GATEWAY_API_KEY` in `.env`). [CONTEXT.md](./CONTEXT.md) defines the vocabulary and
176
+ [docs/adr](./docs/adr) records the main decisions.
177
+
178
+ To release a change, add a changeset to its PR with `pnpm changeset`. The release workflow keeps a
179
+ "Version packages" PR up to date; merging it stages the new version on npm (trusted publishing,
180
+ no token) and creates the GitHub release. A maintainer then approves the staged version on
181
+ npmjs.com, with 2FA, to publish it.
182
+
183
+ ## License
184
+
185
+ MIT
@@ -0,0 +1,283 @@
1
+ import { SchemaTypeDefinition } from "sanity";
2
+ import "react";
3
+ /** @public */
4
+ type JevQuestion = {
5
+ type: 'boolean';
6
+ instructions: string;
7
+ criteria: {
8
+ true: string;
9
+ false: string;
10
+ };
11
+ } | {
12
+ type: 'score';
13
+ instructions: string;
14
+ criteria: string[];
15
+ } | {
16
+ type: 'choice';
17
+ instructions: string;
18
+ criteria: Record<string, string>;
19
+ };
20
+ /** @public */
21
+ type JevAnswer = {
22
+ type: 'boolean';
23
+ probability: number;
24
+ } | {
25
+ type: 'score';
26
+ score: number;
27
+ probabilities: Record<string, number>;
28
+ confidence?: number;
29
+ } | {
30
+ type: 'choice';
31
+ choice: string;
32
+ probabilities: Record<string, number>;
33
+ confidence?: number;
34
+ };
35
+ /**
36
+ * Body of a request to AI Gateway's `/v1/evaluate`. A `transport` receives it as-is, so a
37
+ * proxy only has to forward it with an `Authorization` header.
38
+ * @public
39
+ */
40
+ interface JevRequest {
41
+ model: string;
42
+ state: string | Record<string, string>;
43
+ questions: {
44
+ q: JevQuestion;
45
+ };
46
+ providerOptions: {
47
+ gateway: {
48
+ tags: string[];
49
+ };
50
+ };
51
+ }
52
+ /**
53
+ * Sends one request and resolves with the HTTP response, which must carry AI Gateway's
54
+ * status and JSON body. Errors and retries are handled by the plugin either way.
55
+ * @public
56
+ */
57
+ type JevTransport = (request: JevRequest, init: {
58
+ signal?: AbortSignal;
59
+ }) => Promise<Response>;
60
+ interface JevPluginConfig {
61
+ /** Vercel AI Gateway key. Anything set here is bundled into the Studio's JavaScript. */
62
+ apiKey?: string;
63
+ /** Decision model to call. Defaults to `typesafe-ai/jev`. */
64
+ model?: string;
65
+ /** Delay after the last edit before re-evaluating. Defaults to 500ms. */
66
+ debounceMs?: number;
67
+ /** Defaults to `https://ai-gateway.vercel.sh/v1/evaluate`. Ignored when `transport` is set. */
68
+ endpoint?: string;
69
+ /**
70
+ * Sends evaluation requests yourself, e.g. through your own server so the API key never
71
+ * reaches the browser. Takes precedence over `apiKey`.
72
+ */
73
+ transport?: JevTransport;
74
+ /**
75
+ * Adds a "Jev" tool to the Studio for setting, testing and removing the stored API key.
76
+ * Defaults to `true`.
77
+ */
78
+ tool?: boolean;
79
+ /**
80
+ * AI Gateway reporting tags, for cost attribution. Defaults to `['feature:jev-fields']`.
81
+ * Each request also gets a per-signal tag, e.g. `jev.noul:article.readable`.
82
+ */
83
+ tags?: string[];
84
+ }
85
+ /** @public */
86
+ interface ChoiceSignal extends SignalBase {
87
+ type: 'choice';
88
+ /** Two to 255 options: option name → what it means. */
89
+ criteria: Record<string, string>;
90
+ /** Warn unless the answer is one of these options, e.g. `{oneOf: ['formal', 'casual']}`. */
91
+ warn?: ChoiceRule;
92
+ /** Block publishing unless the answer is one of these options. */
93
+ require?: ChoiceRule;
94
+ }
95
+ /** Options an answer must be one of. @public */
96
+ interface ChoiceRule {
97
+ oneOf: string[];
98
+ }
99
+ /** One option from a named set. @public */
100
+ export declare const choice: (signal: Omit<ChoiceSignal, "type">) => ChoiceSignal;
101
+ interface ChoiceProbability {
102
+ _key: string;
103
+ _type?: 'jev.choiceProbability';
104
+ option?: string;
105
+ probability?: number;
106
+ }
107
+ /** @public */
108
+ interface ChoiceValue extends EvaluatedValue {
109
+ _type?: 'jev.choice';
110
+ choice?: string;
111
+ /** The model's confidence in this choice (0–1). */
112
+ confidence?: number;
113
+ probabilities?: ChoiceProbability[];
114
+ }
115
+ /** @public */
116
+ interface NoulSignal extends SignalBase {
117
+ type: 'noul';
118
+ /** What a "yes" means. Shown when the probability is 50% or higher. */
119
+ true: string;
120
+ /** What a "no" means. Shown when the probability is below 50%. */
121
+ false: string;
122
+ /** Short phrase after the percentage, e.g. "likely to read easily". */
123
+ label?: string;
124
+ /** Warn when the probability is outside these bounds (0–1), e.g. `{atLeast: 0.6}`. */
125
+ warn?: RangeRule;
126
+ /** Block publishing when the probability is outside these bounds (0–1). */
127
+ require?: RangeRule;
128
+ }
129
+ /** A yes/no signal: the chip shows the probability that the answer is yes. @public */
130
+ export declare const noul: (signal: Omit<NoulSignal, "type">) => NoulSignal;
131
+ /** @public */
132
+ interface NoulValue extends EvaluatedValue {
133
+ _type?: 'jev.noul';
134
+ /** Probability (0–1) that the answer is yes. */
135
+ probability?: number;
136
+ }
137
+ /** @public */
138
+ interface ScoreSignal extends SignalBase {
139
+ type: 'score';
140
+ /**
141
+ * Two to ten criteria, lowest first. Text before a colon becomes the short label,
142
+ * e.g. `'anecdotal: personal experience only'`.
143
+ */
144
+ criteria: string[];
145
+ /**
146
+ * `traffic` (default): red at the bottom, green at the top.
147
+ * `reverse`: green at the bottom, for scales where lower is better (e.g. risk).
148
+ * `neutral`: one colour, for scales that aren't good or bad (e.g. reading level).
149
+ */
150
+ colors?: 'traffic' | 'reverse' | 'neutral';
151
+ /** Warn when the score is outside these bounds, as criterion positions: `{atLeast: 2}`. */
152
+ warn?: RangeRule;
153
+ /** Block publishing when the score is outside these bounds. */
154
+ require?: RangeRule;
155
+ }
156
+ /** A position on an ordered scale. @public */
157
+ export declare const score: (signal: Omit<ScoreSignal, "type">) => ScoreSignal;
158
+ /** @public */
159
+ interface ScoreValue extends EvaluatedValue {
160
+ _type?: 'jev.score';
161
+ /** Interpolated position on the scale, from 0 to `max`. */
162
+ score?: number;
163
+ /** Index of the top criterion (number of criteria minus one). */
164
+ max?: number;
165
+ /** Short label of the nearest criterion. */
166
+ label?: string;
167
+ /** The model's confidence in this score (0–1). */
168
+ confidence?: number;
169
+ }
170
+ interface SignalBase {
171
+ /** Chip and detail heading. Defaults to the signal's key, e.g. `readable` → "Readable". */
172
+ title?: string;
173
+ /** The question Jev answers about the attached field. */
174
+ instructions: string;
175
+ }
176
+ interface EvaluatedValue {
177
+ evaluatedAt?: string;
178
+ /** The model that answered, as AI Gateway names it, e.g. `typesafe-ai/jev`. */
179
+ model?: string;
180
+ /** Fingerprint of the evaluated content and question, used to detect stale results. */
181
+ sourceHash?: string;
182
+ }
183
+ /** Bounds on a probability (Noul, 0–1) or a score (0 to the top criterion). @public */
184
+ interface RangeRule {
185
+ atLeast?: number;
186
+ atMost?: number;
187
+ }
188
+ /** @public */
189
+ type JevSignal = NoulSignal | ScoreSignal | ChoiceSignal;
190
+ /** Signals attached to a field, keyed by the name of the field that stores each answer. */
191
+ type JevSignals = Record<string, JevSignal>;
192
+ /**
193
+ * Adds a field that stores each signal's answer next to every field with `options.jev`.
194
+ * Plugins can't see the Studio's own schema types, so wrap them in `sanity.config`:
195
+ *
196
+ * ```ts
197
+ * schema: {types: withJevAnswers(schemaTypes)}
198
+ * ```
199
+ * @public
200
+ */
201
+ export declare function withJevAnswers<T extends SchemaTypeDefinition>(types: T[]): T[];
202
+ /** The plugin's translation namespace: `useTranslation(JEV_NAMESPACE)`. */
203
+ export declare const JEV_NAMESPACE: "jev";
204
+ /**
205
+ * Every string an editor sees, in US English. Other locales can add a bundle for the `jev`
206
+ * namespace with the same keys. Config problems and validation messages stay in English: they
207
+ * are for schema authors, and Sanity's validation has no translation hook.
208
+ */
209
+ declare const resources: {
210
+ readonly 'strip.label': "Jev";
211
+ readonly 'strip.set-up': "Set up Jev";
212
+ readonly 'strip.evaluate-all': "Evaluate all signals now";
213
+ readonly 'chip.empty': "–";
214
+ readonly 'chip.error': "Error";
215
+ readonly 'chip.out-of-date': "Out of date";
216
+ readonly 'detail.empty-field': "Add content to this field to evaluate it.";
217
+ readonly 'detail.not-evaluated': "Not evaluated yet.";
218
+ readonly 'detail.out-of-date': "Out of date: the field changed since this was evaluated.";
219
+ readonly 'detail.update-key': "Update API key";
220
+ readonly 'detail.try-again': "Try again";
221
+ readonly 'field.too-long': "This field is about {{tokens}}k tokens long. Jev reads at most {{limit}}k per question, so longer text may be refused.";
222
+ readonly 'error.auth': "AI Gateway rejected the API key. Check the key in the Jev tool.";
223
+ readonly 'error.invalid': "AI Gateway rejected the question: {{detail}}";
224
+ readonly 'error.busy': "AI Gateway is busy. Try again in a moment.";
225
+ readonly 'error.failed': "AI Gateway responded with {{status}}.";
226
+ readonly 'error.failed-with-detail': "AI Gateway responded with {{status}}: {{detail}}";
227
+ readonly 'error.unexpected': "AI Gateway returned no answer.";
228
+ readonly 'noul.level.low': "Low";
229
+ readonly 'noul.level.medium': "Medium";
230
+ readonly 'noul.level.high': "High";
231
+ readonly 'noul.yes': "Yes";
232
+ readonly 'score.summary': "{{meaning}} (score {{score}} of {{max}}).";
233
+ readonly 'score.next': "To move up: {{next}}.";
234
+ readonly 'key-dialog.title': "AI Gateway API key";
235
+ readonly 'key-dialog.label': "API key";
236
+ readonly 'key-dialog.description': "Create one in the Vercel dashboard under AI Gateway → API Keys, ideally with a spend limit. It is stored in this dataset, so Studio users and API tokens that can read the dataset can see it.";
237
+ readonly 'key-dialog.cancel': "Cancel";
238
+ readonly 'key-dialog.save': "Save";
239
+ readonly 'key-dialog.saving': "Saving…";
240
+ readonly 'key-dialog.save-failed': "Could not save the key: {{error}}";
241
+ readonly 'tool.title': "Jev";
242
+ readonly 'tool.intro': "Jev signals are answered by TypeSafe’s Jev model through Vercel AI Gateway.";
243
+ readonly 'tool.key.heading': "AI Gateway API key";
244
+ readonly 'tool.key.status.config': "From plugin config";
245
+ readonly 'tool.key.status.checking': "Checking";
246
+ readonly 'tool.key.status.set': "Set";
247
+ readonly 'tool.key.status.not-set': "Not set";
248
+ readonly 'tool.key.from-transport': "Requests go through the transport in the plugin config, so no key is needed here.";
249
+ readonly 'tool.key.from-config': "The key comes from apiKey in the plugin config. A key stored here is ignored.";
250
+ readonly 'tool.key.ends-with': "Key ending in <Code>{{last4}}</Code>";
251
+ readonly 'tool.key.changed': "Last changed {{date}}";
252
+ readonly 'tool.key.checking': "Checking for a stored key…";
253
+ readonly 'tool.key.none': "No key is stored, so Jev signals cannot evaluate yet.";
254
+ readonly 'tool.key.set': "Set key";
255
+ readonly 'tool.key.change': "Change key";
256
+ readonly 'tool.key.remove': "Remove key";
257
+ readonly 'tool.key.remove-confirm': "Remove the key? Jev signals stop evaluating until a new one is set.";
258
+ readonly 'tool.key.remove-cancel': "Cancel";
259
+ readonly 'tool.key.remove-confirm-button': "Remove";
260
+ readonly 'tool.key.remove-failed': "Could not remove the key: {{error}}";
261
+ readonly 'tool.test.run': "Test connection";
262
+ readonly 'tool.test.running': "Testing…";
263
+ readonly 'tool.test.passed': "Connection works. {{model}} answered.";
264
+ readonly 'tool.visibility.heading': "Who can see the key";
265
+ readonly 'tool.visibility.body': "The key is stored in this dataset in the document <Code>secrets.jev</Code>. It is not public, but anyone who can read the dataset can see it: every Studio user, and every API token with read access, such as a frontend’s read or preview token. It is also included in dataset exports and backups. Use a dedicated key with a spend limit. If editors and tokens must never see the key, set a <Code>transport</Code> that sends requests through your own server.";
266
+ };
267
+ /** A key of the plugin's translations. */
268
+ type JevTranslationKey = keyof typeof resources;
269
+ declare module 'sanity' {
270
+ interface BaseSchemaTypeOptions {
271
+ /** Jev signals shown on this field, keyed by the name of the field storing each answer. */
272
+ jev?: JevSignals;
273
+ }
274
+ }
275
+ /**
276
+ * Jev signals for Sanity Studio: yes/no, score and choice questions attached to a field with
277
+ * `options.jev`, answered by TypeSafe's Jev model through Vercel AI Gateway. Pair it with
278
+ * `withJevAnswers(schemaTypes)` in `schema.types`.
279
+ * @public
280
+ */
281
+ export declare const jev: import("sanity").Plugin<void | JevPluginConfig>;
282
+ export type { ChoiceRule, ChoiceSignal, ChoiceValue, JevAnswer, JevPluginConfig, JevQuestion, JevRequest, JevSignal, JevSignals, JevTranslationKey, JevTransport, NoulSignal, NoulValue, RangeRule, ScoreSignal, ScoreValue };
283
+ //# sourceMappingURL=index.d.ts.map