feat(tutor): the turn enforced — retries, evidence, the 다지기 checklist, earned progress
"The client enforces; the prompt only explains." Every rule the artifact's tutor was merely asked to follow, it broke: it certified words on one correct answer, scored a unit before anything was answered, used a word from three phases ahead, answered in Korean, and invented spelling diagnoses. The reworked app fixed each by making the client refuse. This ports those refusals; domain/turn.ts holds the turn, testable without React. The gate. A reply is scanned before he sees it — the side of the exercise he must decode, through the one resolver, and its prose for Korean. A refused draft is never stored, shown or applied: the tutor is asked again and told exactly why. After two retries the reply is shown with its words flagged, and the next turn names them. (The artifact's follow-up told the tutor it could declare such a word in ::words; that contradicts the gate and is left out.) Marking. ::result feeds recall evidence per word. lib/srs.js is looser than PORT.md, so the call site tightens it: one outcome per word per round, and "learned" also needs five rounds between the first and last CORRECT answer — lib alone counted a wrong answer as the start of the span. A lookup is never recall. What he mistook a word for is kept. The schedule takes at most one good grade a day from marking; in the artifact five good rounds in one afternoon made a word "secure" by interval alone. Phase reviews. The client holds the 다지기 checklist — each unit's rule and every word the phase introduced, 132 items for Phase 1 — worked in batches of ten. ::confirmed ticks a rule on the tutor's word but a word only on evidence; "-item" puts one back; anything off the list is ignored. Progress is earned: ignored until the unit has an answer, +25 at most per message, a fall honoured in full, and the next unit only at 85% with three answers — plus, in a review, nothing open. advanceUnit() enforces it too, not only the banner. The prompt gains a per-round tail after the shipped prompt — the practice set (scored on the evidence, round-robin by word class, each word with the words one letter away), the checklist, retry notes — sent as a second, uncached system block so the stable prefix still caches. Also: recall answers carry the letter-level jamo comparison (kept out of his own bubble, since it is written to the model); match chips are keyed by pair index, the bug PORT.md names; and the stand-in tutor exercises every path offline — recall, ::result, ::confirmed, progress only after answers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,9 +29,13 @@ export function anthropicBackend(client = new Anthropic()): TutorBackend {
|
||||
{
|
||||
model: MODEL,
|
||||
max_tokens: MAX_TOKENS,
|
||||
// Stable for the whole unit, so it caches; the messages after it
|
||||
// are what vary per turn.
|
||||
system: [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }],
|
||||
// Stable for the whole unit, so it caches. The round's tail comes
|
||||
// after the breakpoint: it changes every turn and would otherwise
|
||||
// break the cached prefix.
|
||||
system: [
|
||||
{ type: "text", text: req.system, cache_control: { type: "ephemeral" } },
|
||||
...(req.systemTail ? [{ type: "text" as const, text: req.systemTail }] : []),
|
||||
],
|
||||
messages: [
|
||||
...req.history.map((t) => ({ role: t.role, content: t.content })),
|
||||
{ role: "user" as const, content: req.message },
|
||||
|
||||
@@ -25,6 +25,7 @@ export function echoBackend(): TutorBackend {
|
||||
"(echo backend — no model was called.)",
|
||||
"",
|
||||
`system prompt: ${req.system.length} characters`,
|
||||
`this round's tail: ${req.systemTail?.length ?? 0} characters`,
|
||||
`history: ${req.history.length} turns`,
|
||||
`you said: ${req.message}`,
|
||||
].join("\n");
|
||||
|
||||
@@ -133,11 +133,12 @@ export function openaiBackend(opts: OpenAIOptions = {}): TutorBackend {
|
||||
name: `openai:${model}`,
|
||||
|
||||
async *stream(req: TutorRequest): AsyncIterable<TutorEvent> {
|
||||
/* The system prompt is a message here, not a parameter. It stays
|
||||
first and unchanged for the whole unit, which is what lets a local
|
||||
server reuse its KV cache across turns. */
|
||||
/* The system prompt is a message here, not a parameter. The stable
|
||||
part leads, unchanged for the whole unit, which is what lets a local
|
||||
server reuse its KV cache across turns; the round's tail follows it,
|
||||
so only the tail and the new turns are re-read. */
|
||||
const messages = [
|
||||
{ role: "system", content: req.system },
|
||||
{ role: "system", content: req.systemTail ? `${req.system}\n\n${req.systemTail}` : req.system },
|
||||
...req.history.map((t) => ({ role: t.role, content: t.content })),
|
||||
{ role: "user", content: req.message },
|
||||
];
|
||||
|
||||
@@ -13,8 +13,13 @@ export interface TutorTurn {
|
||||
}
|
||||
|
||||
export interface TutorRequest {
|
||||
/** The assembled system prompt: tutor-system.md with {{GATE}} filled in. */
|
||||
/** The assembled system prompt: tutor-system.md with {{GATE}} filled in.
|
||||
Stable for as long as the learner stays in a unit, so it caches. */
|
||||
system: string;
|
||||
/** This round's additions — practice words, the review checklist, a note
|
||||
that the last draft was refused. Changes every turn, so it is sent
|
||||
after the stable prompt and never cached. */
|
||||
systemTail?: string;
|
||||
/** The transcript so far. The client owns it and sends it every turn. */
|
||||
history: TutorTurn[];
|
||||
/** What the learner just said. */
|
||||
|
||||
@@ -44,6 +44,7 @@ export function tutorRoute(backend?: TutorBackend) {
|
||||
app.post("/", async (c) => {
|
||||
const body = (await c.req.json()) as {
|
||||
system?: string;
|
||||
systemTail?: string;
|
||||
history?: TutorTurn[];
|
||||
message?: string;
|
||||
};
|
||||
@@ -75,6 +76,7 @@ export function tutorRoute(backend?: TutorBackend) {
|
||||
try {
|
||||
for await (const event of resolved!.stream({
|
||||
system: body.system!,
|
||||
systemTail: typeof body.systemTail === "string" ? body.systemTail : undefined,
|
||||
history,
|
||||
message: body.message!,
|
||||
signal: abort.signal,
|
||||
|
||||
Reference in New Issue
Block a user