Skip to content

Optional change classifier on decision models (Clef, Jev), off by default - #77

Merged
vgururao merged 1 commit into
blygger:mainfrom
aneeshsathe:feat/change-classifier
Oct 20, 2026
Merged

vgururao merged 1 commit into
blygger:mainfrom
aneeshsathe:feat/change-classifier

Conversation

@aneeshsathe

Copy link
Copy Markdown
Contributor

Optional change classifier on decision models (Clef, Jev)

Off by default. Nothing here does anything until Settings → AI → change classifier names a decision model. With no model chosen, no route calls a model, the publish dialog behaves as it does today, and no row is written.

Why

Venkat's post (f/4dkqht955z9aq1ekr7bekex980) asks every blyg to "use a Jev-class super cheap classifier to post category-based changelogs like 'minor', 'major', 'typo'", so readers can present feeds less noisily.

What it does

  • Category set: refresh, typo, minor, major. refresh is a republish whose content_hash equals the published one. It is decided locally with no model call. The model gets one choice question over the other three, with both versions and the word-level change as state. The instruction text is CLASSIFY_QUESTION in src/change-class.ts. I left out addition: whether text was added doesn't tell a reader how much to care.
  • When it runs: before publish, never inside it, so publish stays network-free (Phase 3 (OAuth + MCP) is approved — four constraints for the auth PR (decision #52) #26), the same as note drafting. It runs for a new version (v2 and later) of an own item. The Version N dialog calls POST /items/{id}/classify (scope owner:draft, like note-draft) and shows the answer as a chip the author can change or clear. When the classifier is on, the dialog opens for every later version.
  • Into the note (change_category_in_note, on by default, matters only while the classifier is on):
    • An empty note becomes the bare word, e.g. minor.
    • A note becomes minor: <note>.
    • A note that already starts with a category word is left alone.
    • The author sees the final note before confirming. The server never rewrites a note.
  • Generated flag (Reduce D1 reads from Studio polling and public feeds #40): a note is generated only if it is exactly the machine's text. That means a drafted note led by the classifier's own category, or the classifier's bare word on an empty note. If the author overrides the category, or a category is prefixed to the author's own words, the note is not generated. The UI now keys this on noteIsGenerated() (src/change-category.ts) instead of comparing against the drafted text alone. With the classifier off, the behaviour is unchanged.
  • Failure is non-fatal:
    • The route returns 409 only when the classifier is off, for a first version, or when the prior version is a withdrawal.
    • It returns 200 { category: null, reason } for a missing binding or key, a provider error, the daily AI budget, or an answer that doesn't parse.
    • Every model call goes through admitAi like other AI calls. refresh costs nothing.
  • Studio-private record: migrations/0030_change_class.sql adds change_class, one row per own version, with category, model, probability, classified_at, overridden and content_hash.
    • Classify writes a row for the version the working copy would publish as.
    • Publish settles it through an optional change_category on POST /items/{id}/publish. Omitting it means the classification stands; null clears it; a different value marks it overridden.
    • A classification of words that were not published is dropped.
    • The record is read back as change_class on each version in GET /items/{id}, and as a badge in the editor's history.
    • No change trigger: the only write a reader of item versions sees happens in the same request as the versions insert, which already bumps items.

Providers

  • decide(env, modelId, state, questions) in src/ai/provider.ts is separate from complete()/generate(). It returns { answers: { [q]: { choice, probability } }, model }.
  • Clef / Clef-flash run on the Workers AI binding (bindingName(spec)). The body's required model selector (clef / clef-flash) comes from the model's params.
  • Jev goes over a new systemone provider api: POST {base_url}/systemone, default base https://api.typesafe.ai/v1, with the key as a bearer token from secret TYPESAFE_API_KEY.
  • In models.json, "kind": "decision" (and every systemone model) marks a decision model:
    • GET /api/ai/models returns kind: "decision" on those models.
    • Settings offers decision models only for the classifier and text models only for TK, changelog and feed.
    • PATCH /settings refuses a mismatch (400), and complete() refuses a decision model.
  • The build validator (scripts/models-manifest.ts) checks the new api and kind.

Nothing new on the wire

So this PR adds no member to item documents, changelog[] entries, feed.xml or blyg.json, and src/protocol.ts is untouched. The category reaches readers only as words in the note the author confirmed. test/change-class.test.ts asserts that a classified publish produces the same document key set (and the same changelog-entry keys) as an unclassified one. It also checks that the item document, feed and manifest carry no category member.

Verification status of the two APIs

  • Clef: verified against Cloudflare's model page and its published JSON schemas (input, output). A choice answer is { type: "choice", choice, probabilities, confidence }.
    • The parser also unwraps a REST { result: … } envelope.
    • It ignores a choice outside the asked options and falls back to the argmax of the asked options.
    • It uses confidence when the chosen option has no probability.
    • It drops anything else.
    • Not exercised against a live binding (the tests use a stubbed binding).
  • Jev: endpoint, auth and response shape are taken from TypeSafe's API reference (docs.typesafe.ai/api.md, models): POST https://api.typesafe.ai/v1/systemone, Authorization: Bearer, model jev-latest. This matches Cloudflare's schema. Not exercised against the live API: there is no key in this environment.

Reader-side follow-up, for Venkat

climbFor() in src/feeds/pace-layered.ts is unchanged; it gets only a comment pointing here. A reader-side version would classify imported versions locally, from the two versions this node already holds, and feed that into the climb. That stays consistent with #77: the reader derives significance and the wire never marks it. That is a decision to make with Venkat, not part of this PR.

Tests

  • npm run typecheck (both configs): clean.
  • npm test: 143 files, 1768 passed, 5 skipped. This includes the new test/change-class.test.ts (22 tests):
    • the classify route with a stubbed binding, covering choice parsing and probability;
    • refresh with no model call;
    • provider error and budget exhaustion are non-fatal;
    • the setting off gives a 409;
    • a missing binding through the route gives a null category;
    • garbage or unknown answers give no category;
    • override, clearing, and a stale record dropped;
    • note composition (empty, prefixed, already categorised) and the generated flag;
    • the wire-unchanged assertion.
    • The permission, MCP and SDK-operation oracles now list classifyChange under owner:draft.
  • npm run test:ui: 12 files, 97 passed.
  • npm run test:e2e for the touched areas: e2e/change-class.spec.ts (new: chip, override, prefix, no-answer path), plus e2e/studio.spec.ts and e2e/setup.spec.ts. 28 passed, desktop and mobile.
  • Mutation suites: npm run test:mutations (oracle) and npm run test:polling-cache:mutations both pass, with every mutation caught. The new migration puts every suite in ci-scope, but the auth-security mutation suites (about 13 min) were not run locally. Nothing in auth or OAuth changed.

🤖 Generated with Claude Code

…ault

A decision model labels each new version of an own item typo, minor or
major before publish; a republish of the same words is refresh, decided
locally from content_hash with no model call. The Version N dialog shows
the label as an editable chip and, with change_category_in_note, writes it
into the note where the author sees it. Failures are a null category,
never an error.

- decide() in src/ai/provider.ts: the System One request shape, Clef on
  the Workers AI binding, Jev over HTTP ({base_url}/systemone, bearer
  TYPESAFE_API_KEY); responses parsed against Cloudflare's published
  output schema and TypeSafe's API reference, defensively.
- models.json: kind "decision" (Clef, Clef-flash, jev-latest) and a
  systemone provider; decision models only for ai_model_classify, never
  for text functions, enforced in settings and complete().
- POST /items/{id}/classify (owner:draft), change_category on publish,
  change_class on the owner's versions; migration 0030, studio-private.
- Nothing on the wire (blygger#19 / TN-1, blygger#77): asserted by test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vgururao
vgururao merged commit 5dc1175 into blygger:main Oct 20, 2026
8 of 9 checks passed
vgururao added a commit that referenced this pull request Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants