Learning from edits
When a person edits a draft, that's the best evidence you'll get: what they actually wanted, next to what the voice wrote. Learning from it is a loop that ends with a person deciding.
draft ─▶ preferred edit ─▶ split the evidence ─▶ candidate voice ─▶ held-out evaluation ─▶ a person promotes, or doesn't
A correction doesn't change a voice. Your application makes a new candidate from the evidence, evaluates it on edits it never saw, and decides whether to promote it. June gives you each piece of that loop; the Copy Agent is a complete implementation of it.
An edit as a preference example
The draft and the final are a pair: what the voice wrote, and what the person wanted instead. voice.edit({ request, draft, final }) returns a preference example, with the final text chosen and the draft rejected, ready for a dataset and, later, preference training such as DPO:
import { dataset } from "june/train";
import type { Voice } from "june/copy";
declare const fonzie: Voice;
const example = fonzie.edit({
request: { type: "tweet" },
draft: "Fonzie now has scheduling functionality available.",
final: "Fonzie books your interviews now. You're welcome.",
});
const edits = dataset([example]);
The example's input is the request as one user message, the same text as write()'s prompt but without its system prompt or exemplars, and its metadata records the voice's name and the normalized request. It makes no model calls and doesn't change the voice. Skip edits where final is the same as draft: they say the draft was fine, not what's better.
Preference training wants many such pairs. A handful of edits is far too few to fine-tune on, so keep collecting them, and in the meantime add them to the corpus.
From chat sessions
When people use a voice in a chat, their feedback is already recorded as the same evidence. june/chat keeps ratings and preferred edits in each session's journal, and june/train turns sessions into preference datasets and eval cases, each with the session, turn and the version of the agent that answered. The learning from chat guide covers it; the same rules about what counts as training material apply here.
Keep some edits for evaluation
Split the evidence before you learn from any of it. Some corrections have to stay unseen, or you'll never be able to measure whether the voice improved.
A voice that learns from an edit can't fairly be graded on it: the answer is in its corpus, so retrieval may show it the very text it's asked to write. Split edits before using them, deterministically and by something stable such as the edit's id, and use one share for learning and the other only for evaluation:
import { dataset, type PreferenceExample } from "june/train";
// From preferenceDatasetFromSessions(), whose metadata has sessionId and turnId.
// For voice.edit() examples, add your own id to their metadata and key on that.
declare const examples: PreferenceExample[];
const { train: learn, test: heldOut } = dataset(examples).split({
train: 0.7,
test: 0.3,
key: (example) =>
`${example.metadata?.sessionId}:${example.metadata?.turnId}`,
});
A held-out edit never makes the voice better, which is the cost of knowing whether the others did. Approved drafts, and rejected ones without a fix, are evaluation material too. For a voice, keep them out of training: they're the voice's words, not the person's. That's stricter than datasetFromSessions(), which includes positively rated replies by default, so filter its examples to the edits you learn from, as the Copy Agent does.
An edit as a new sample
The learnable share of the edits is what a candidate voice is built from, and the simplest way to learn from one is to add it to the corpus. The final text is what the person chose, so it can join the corpus like any other sample, though a lightly edited draft is still mostly the voice's words: count it as theirs when they rewrote it. It's also the closest thing there is to what they want now, for exactly the kind of request the voice will see again. Label it with the request's labels, so retrieval finds it for similar requests:
import { copyCorpus, type CopyRequest, type WritingSample } from "june/copy";
declare const samples: WritingSample[];
const request: CopyRequest = {
type: "tweet",
intent: "announce a feature",
};
const final = "Fonzie books your interviews now. You're welcome.";
const corpus = copyCorpus([
...samples,
{
text: final,
metadata: {
type: request.type,
intent: request.intent,
source: "edit",
date: new Date().toISOString(),
},
},
]);
Only the final text goes in, never the draft. A new corpus has a new fingerprint, so it's a new voice, with a new manifest; the voice you saved before still loads from the old corpus. If you write with a prepared voice, either prepare the new corpus or add the sample to the prepared samples yourself, so the voice keeps its inferred labels.
This needs no trainer and no served model, and works from the first edit. It's how the Copy Agent builds a candidate voice, which a person promotes only after reading its evaluation.
Deciding what changes
Promotion is the last step of the loop, and it's a person's. Nothing in June swaps a voice. voice.edit() returns an example, a new corpus makes a new voice, and a fine-tune produces an artifact; which voice writes next is a choice your application makes, and it should be one a person makes on evidence.
The Copy Agent shows one way to do that. A candidate is the active voice plus the person's learnable edits. It's evaluated against the active voice on the held-out edits and a fixed benchmark of requests, and a person promotes it, or rejects it, after reading the evaluation. Every promotion records the evaluation behind it, and every reply records which voice wrote it. Candidates, active agents and promotion are the Copy Agent's concepts, built from the primitives on this page and in june/train.