Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,22 @@ The list comes from `models.json`, merged at build with your gitignored
| `gemini-generate` | Gemini generateContent | `key_secret` (shipped: `GOOGLE_AI_KEY`) |
| `workers-ai` | Cloudflare Workers AI, through the Worker's binding | an `ai` binding (`binding`, default `AI`); no key |
| `openai-chat` | any OpenAI-compatible Chat Completions endpoint | `base_url`, and `key_secret` unless the endpoint is keyless |
| `systemone` | TypeSafe's System One decision API (Jev), `POST {base_url}/systemone` | `key_secret` (shipped: `TYPESAFE_API_KEY`); `base_url` optional, default `https://api.typesafe.ai/v1` |

**Decision models and the change classifier.** A model listed with
`"kind": "decision"`, and every `systemone` model, is a decision model: it
answers typed questions with probabilities and writes no text. Decision models
are offered only for the change classifier (Settings → AI → change classifier,
`ai_model_classify`), and text models never are. The classifier is **off by
default**; with a model chosen, the publish dialog labels each new version of
your own items `typo`, `minor` or `major` (a republish of the same words is
`refresh`, decided with no model call), and you can change or clear the label.
The shipped list has Clef and Clef-flash on Workers AI (the `ai` binding; their
`params` carry the request's `model` selector) and `jev-latest` on TypeSafe (set
`npx wrangler@4 secret put TYPESAFE_API_KEY`). Each classification is one call
against the daily AI budget. The label is kept privately with the version and
never sent as data: readers see it only as words in your change note. See
[`docs/api.md`](docs/api.md#change-classifier).

**Workers AI.** The shipped list includes Gemma 4 26B and a few other `@cf/…`
models, and any `@cf/…` id typed in with *other…* runs there too. Inference is
Expand Down
63 changes: 63 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,71 @@ Publication, withdrawal, restoration, and generation remain explicit operations:
- `POST /items/{id}/generate` generates one TK scope.
- `POST /subscriptions/{id}/resync` refreshes a Blyg subscription.
- `POST /items/{id}/note-draft` drafts a changelog note from the change between the published version and the working copy. It never publishes. When the published version is unpinned, the note must not reproduce wording that only that version had; a draft that does returns 422. Send `note_generated: true` with `POST /items/{id}/publish` only when the note is that draft, unedited: it becomes `changelog[].generated` on the wire.
- `POST /items/{id}/classify` asks the optional change classifier what kind of change the working copy would publish. See [Change classifier](#change-classifier).
- `POST /items/{id}/refresh` republishes a thread to re-bake quotes whose source has a newer version. See below.

## Change classifier

Optional and **off by default**: nothing below happens until Settings → AI
names a decision model in `ai_model_classify`. A decision model (Cloudflare's
Clef or Clef-flash on Workers AI, or TypeSafe's Jev) answers typed questions
with probabilities and writes no text, so `GET /ai/models` marks it
`kind: "decision"`, `ai_model_classify` accepts only a decision model, and the
text functions (`ai_model_tk`, `ai_model_changelog`, `ai_model_feed`) refuse one
(400).

The category set is fixed: `refresh`, `typo`, `minor`, `major`.

| Category | Meaning | Decided by |
|---|---|---|
| `refresh` | a republish of the same words (the new `content_hash` equals the published one) | locally, with no model call |
| `typo` | spelling, punctuation, formatting or a link fixed; meaning unchanged | the model |
| `minor` | small edits to wording or detail; a returning reader need not reread | the model |
| `major` | the substance changed; a returning reader should read it again | the model |

The model is asked one `choice` question over both versions and the word-level
change between them; the instruction and option descriptions are
`CLASSIFY_QUESTION` in `src/change-class.ts`.

`POST /items/{id}/classify` (scope `owner:draft`, like `note-draft`) never
publishes. It returns `{ version, category, model, probability, reason? }`:

- `409` when the classifier is off, for a first version, or when the published
version is a withdrawal.
- `200` with `category: null` and a `reason` when no category could be had: no
binding or key, a provider error, the daily AI budget (`AI_DAILY_CALL_LIMIT`;
every classify that calls a model counts, `refresh` does not), or an answer
that does not parse. **None of these is fatal**: publish as usual.

The Studio calls it from the *Version N* publish dialog, which shows the answer
as a chip the author can change or clear. With `change_category_in_note` (on by
default; it matters only while the classifier is on) the dialog also writes the
category into the note, where the author sees it before confirming:

- an empty note becomes the bare word, `minor`;
- a note becomes `minor: <note>`;
- a note that already starts with a category word is left alone.

The server never rewrites a note. Send `note_generated: true` only when the note
is exactly the machine's text: a drafted note led by the classifier's own
category, or the classifier's bare word on an empty note. A category changed by
the author, or a category prefixed to the author's own words, is not generated.

`POST /items/{id}/publish` takes an optional `change_category`: the category the
author confirmed (`null` for cleared; omitted, a classification made for exactly
these words stands). It is kept with the version in studio-private storage
(migration 0030, `change_class`: category, model, probability, classified_at,
overridden) and read back as `change_class` on each version in
`GET /items/{id}`.

**Nothing of this is on the wire.** Spec decision #19 / TN-1 refuses a
version-significance marker, and decision #77 makes "ignore this version" a
reader-side derivation from `content_hash`. So there is no new member in item
documents, `changelog[]`, `feed.xml` or `blyg.json`; the category reaches
readers only as words in the note the author confirmed.
`test/change-class.test.ts` asserts that a classified publish emits the same
document keys as an unclassified one.

Preview and search use `/preview` and `/search`.
`GET /changes` returns `{ epoch, domains }`, with revision counters for items,
Reading, subscriptions, hoppers, signals, settings and the public feed. It uses
Expand Down
2 changes: 1 addition & 1 deletion docs/release-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ random bytes, such as 64 hex characters generated by a password manager). Keep
both out of the config. Open `https://blyg.example.com/studio`, sign in, and set
your title and author name in Settings. Add subscriptions and publish a fragment
to check the setup. AI keys are optional: `AI_PROVIDER_KEY` (Anthropic), `OPENAI_API_KEY` or
`GOOGLE_AI_KEY`, for whichever provider's models you choose in Settings; set
`GOOGLE_AI_KEY`, for whichever provider's models you choose in Settings (and `TYPESAFE_API_KEY` if you turn on the optional change classifier with Jev); set
one with `npx wrangler@4 secret put AI_PROVIDER_KEY`. The `@cf/…` models run
on Cloudflare Workers AI with no key: uncomment the `"ai"` binding in the
included `wrangler.jsonc` to offer them. This prebuilt worker
Expand Down
79 changes: 79 additions & 0 deletions e2e/change-class.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { test, expect } from "./fixture";

// The optional change classifier's chip in the Version N dialog (session 49).
// The classify route is fulfilled here: what is under test is what the dialog
// does with an answer, and what it sends at publish.
const CLEF = "@cf/cloudflare/clef-flash";

test.describe("change classifier", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/studio/login");
await page.locator('[name="password"]').fill("test-password");
await page.getByRole("button", { name: "log in", exact: true }).click();
await expect(page.locator("#composer-text")).toBeVisible();
});

const apiFor = (page: import("@playwright/test").Page) => (m: string, p: string, d?: unknown) =>
page.evaluate(async ([m, p, d]) => (await fetch('/api' + p, { method: m, headers: { 'content-type': 'application/json' }, body: d === undefined ? undefined : JSON.stringify(d) })).json(), [m, p, d] as const);

test("the chip writes the category into the note, the author can change it, and publish carries the choice", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const api = apiFor(page);
await api("PATCH", "/settings", { ai_model_classify: CLEF });
try {
await page.route("**/classify", (route) => route.fulfill({ json: { version: 2, category: "minor", model: CLEF, probability: 0.83 } }));
const publishes: any[] = [];
page.on("request", (r) => { if (r.url().endsWith("/publish")) publishes.push(r.postDataJSON()); });
const id = (await api("POST", "/items", { content_md: `Classifier fixture ${Date.now()}` })).id;
await api("POST", `/items/${id}/publish`, {});
await page.goto(`/studio/edit/${id}`);
const input = page.locator("#md-input");
await input.fill(`${await input.inputValue()} Teh end.`);

await page.locator("#publish-btn").click();
const minor = page.locator('#change-category [data-category="minor"]');
await expect(minor).toHaveAttribute("aria-checked", "true");
await expect(minor).toContainText("83%");
// An empty note becomes the bare word, which is the machine's text.
await expect(page.locator("#note-confirm-text")).toHaveValue("minor");
await expect(page.locator("#note-confirm-generated")).toBeVisible();

// The author overrides: the note follows, and is no longer generated.
await page.locator('#change-category [data-category="typo"]').click();
await expect(page.locator("#note-confirm-text")).toHaveValue("typo");
await expect(page.locator("#note-confirm-generated")).toHaveCount(0);
// A note of their own keeps the prefix in front of it.
await page.locator("#note-confirm-text").fill("typo: Fixed a word.");
await page.locator('#change-category [data-category="minor"]').click();
await expect(page.locator("#note-confirm-text")).toHaveValue("minor: Fixed a word.");
await page.locator("#note-confirm-ok").click();
await expect(page.locator('[data-action="view-version"]')).toHaveCount(2);
expect(publishes.at(-1)).toMatchObject({ note: "minor: Fixed a word.", note_generated: false, change_category: "minor" });
expect(errors).toEqual([]);
} finally {
await api("PATCH", "/settings", { ai_model_classify: "" });
}
});

test("a classifier that gives no answer leaves the note alone and publishes with no category", async ({ page }) => {
const api = apiFor(page);
await api("PATCH", "/settings", { ai_model_classify: CLEF });
try {
await page.route("**/classify", (route) => route.fulfill({ json: { version: 2, category: null, model: null, probability: null, reason: "upstream unavailable" } }));
const id = (await api("POST", "/items", { content_md: `Classifier failure fixture ${Date.now()}` })).id;
await api("POST", `/items/${id}/publish`, {});
await page.goto(`/studio/edit/${id}`);
const input = page.locator("#md-input");
await input.fill(`${await input.inputValue()} More.`);
const published = page.waitForRequest((r) => r.url().endsWith(`/items/${id}/publish`));
await page.locator("#publish-btn").click();
await expect(page.locator("#change-category-none")).toContainText("upstream unavailable");
await expect(page.locator("#note-confirm-text")).toHaveValue("");
await page.locator("#note-confirm-ok").click();
expect((await published).postDataJSON()).toMatchObject({ note: "", note_generated: false, change_category: null });
} finally {
await api("PATCH", "/settings", { ai_model_classify: "" });
}
});
});
30 changes: 30 additions & 0 deletions migrations/0030_change_class.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Session 49. 0028 stays reserved for the upload guard (blygger-studio#7).
--
-- The optional change classifier's studio-private record: one row per own
-- version it classified (or whose category the author set by hand). Never on
-- the wire: spec decision #19 / TN-1 refuse a version-significance marker, and
-- #77 keeps "ignore this version" reader-side. A category reaches readers only
-- as words in the changelog note, which the author confirms.
--
-- A row is written by POST /items/{id}/classify for the version the working
-- copy would publish as (items.version + 1), with the content_hash of the text
-- it classified, and settled at publish: kept when the published hash matches,
-- marked overridden when the author changed or cleared the category, dropped
-- when the text changed after classifying. A row whose version was never
-- published is replaced by the next classify.
--
-- No change trigger: the Studio reads these rows only inside an item's
-- versions, and the only write that matters to a reader of those (settling at
-- publish) happens in the same request as the versions insert, whose own
-- trigger already bumps the items domain.
CREATE TABLE change_class (
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
category TEXT CHECK (category IS NULL OR category IN ('refresh', 'typo', 'minor', 'major')),
model TEXT,
probability REAL,
classified_at TEXT NOT NULL,
overridden INTEGER NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL,
PRIMARY KEY (item_id, version)
);
10 changes: 7 additions & 3 deletions models.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"$comment": "The AI models offered in Settings → AI models. Edit this file to add, remove or relabel models, then redeploy. To keep your edits out of upgrade merges, put them in models.local.json instead (same shape, gitignored): its providers merge over these by key, its models replace or add by id, and ids listed in its \"remove\" array are dropped. Model ids not listed here can still be typed in with \"other…\"; the provider is then guessed from the id's prefix (claude-, gpt-, gemini-, @cf/). Provider apis: anthropic-messages, openai-responses and gemini-generate each need a key_secret; workers-ai calls the Worker's AI binding, with no key (optional \"binding\", default AI); openai-chat is any OpenAI-compatible Chat Completions endpoint and needs a base_url, plus a key_secret unless the endpoint is keyless. workers-ai and openai-chat providers and their models may carry \"params\", extra request-body members (a model's merge over its provider's). See README § AI models. Checked against each provider's model page 2026-10-09.",
"$comment": "The AI models offered in Settings → AI models. Edit this file to add, remove or relabel models, then redeploy. To keep your edits out of upgrade merges, put them in models.local.json instead (same shape, gitignored): its providers merge over these by key, its models replace or add by id, and ids listed in its \"remove\" array are dropped. Model ids not listed here can still be typed in with \"other…\"; the provider is then guessed from the id's prefix (claude-, gpt-, gemini-, @cf/). Provider apis: anthropic-messages, openai-responses and gemini-generate each need a key_secret; workers-ai calls the Worker's AI binding, with no key (optional \"binding\", default AI); openai-chat is any OpenAI-compatible Chat Completions endpoint and needs a base_url, plus a key_secret unless the endpoint is keyless. systemone is TypeSafe's System One decision API (Jev): a key_secret, and an optional base_url (default https://api.typesafe.ai/v1; requests go to {base_url}/systemone). A model with \"kind\": \"decision\" (and every systemone model) is a decision model: offered only for the change classifier, never for text generation, and a text model is never offered for the classifier. Clef runs on the workers-ai binding; its params carry the body's model selector (clef or clef-flash). workers-ai and openai-chat providers and their models may carry \"params\", extra request-body members (a model's merge over its provider's). See README § AI models. Checked against each provider's model page 2026-10-09.",
"providers": {
"anthropic": { "label": "Anthropic", "api": "anthropic-messages", "key_secret": "AI_PROVIDER_KEY", "prefixes": ["claude-"] },
"openai": { "label": "OpenAI", "api": "openai-responses", "key_secret": "OPENAI_API_KEY", "prefixes": ["gpt-", "o1", "o3", "o4"] },
"google": { "label": "Google", "api": "gemini-generate", "key_secret": "GOOGLE_AI_KEY", "prefixes": ["gemini-"] },
"workers-ai": { "label": "Cloudflare Workers AI", "api": "workers-ai", "binding": "AI", "prefixes": ["@cf/"] }
"workers-ai": { "label": "Cloudflare Workers AI", "api": "workers-ai", "binding": "AI", "prefixes": ["@cf/"] },
"typesafe": { "label": "TypeSafe", "api": "systemone", "key_secret": "TYPESAFE_API_KEY", "prefixes": ["jev-"] }
},
"models": [
{ "id": "claude-fable-5-1", "provider": "anthropic", "label": "Claude Fable 5.1", "note": "most capable; highest cost" },
Expand All @@ -20,6 +21,9 @@
{ "id": "@cf/google/gemma-4-26b-a4b-it", "provider": "workers-ai", "label": "Gemma 4 26B", "note": "billed to your Cloudflare account", "params": { "chat_template_kwargs": { "enable_thinking": false } } },
{ "id": "@cf/meta/llama-4-scout-17b-16e-instruct", "provider": "workers-ai", "label": "Llama 4 Scout 17B", "note": "billed to your Cloudflare account" },
{ "id": "@cf/meta/llama-3.3-70b-instruct-fp8-fast", "provider": "workers-ai", "label": "Llama 3.3 70B (fast)", "note": "billed to your Cloudflare account" },
{ "id": "@cf/mistralai/mistral-small-3.1-24b-instruct", "provider": "workers-ai", "label": "Mistral Small 3.1 24B", "note": "billed to your Cloudflare account" }
{ "id": "@cf/mistralai/mistral-small-3.1-24b-instruct", "provider": "workers-ai", "label": "Mistral Small 3.1 24B", "note": "billed to your Cloudflare account" },
{ "id": "@cf/cloudflare/clef-flash", "provider": "workers-ai", "kind": "decision", "label": "Clef-flash", "note": "decision model, fastest; billed to your Cloudflare account", "params": { "model": "clef-flash" } },
{ "id": "@cf/cloudflare/clef", "provider": "workers-ai", "kind": "decision", "label": "Clef", "note": "decision model; billed to your Cloudflare account", "params": { "model": "clef" } },
{ "id": "jev-latest", "provider": "typesafe", "kind": "decision", "label": "Jev (latest)", "note": "decision model" }
]
}
Loading
Loading