Files
Hankan/server/src/backends/anthropic.ts
MechaCat02 a1c86d9550 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>
2026-09-16 21:09:51 +02:00

86 lines
3.2 KiB
TypeScript

/* The Claude API backend.
One streaming completion per turn, no tools, no server-side session. The
request shape maps 1:1 onto what the tutor tab already sends.
The system prompt is cached. It is ~12k characters of gate — the taught
list, the forbidden list, the vocabulary — and it is byte-identical for
as long as the learner stays in one unit, which is many turns. Caching it
means every turn after the first reads the prefix at a fraction of the
input price instead of re-billing the whole thing. This is the single
biggest cost lever in the design, and it is the reason the prompt moved
out of messages[0] and into `system`. */
import Anthropic from "@anthropic-ai/sdk";
import type { TutorBackend, TutorEvent, TutorRequest } from "./types.ts";
const MODEL = "claude-opus-5";
/* A tutor turn is a lesson intro at most — the prompt caps it at 250-450
words. This is a ceiling that prevents a runaway, not an allocation. */
const MAX_TOKENS = 16_000;
export function anthropicBackend(client = new Anthropic()): TutorBackend {
return {
name: "anthropic",
async *stream(req: TutorRequest): AsyncIterable<TutorEvent> {
const stream = client.messages.stream(
{
model: MODEL,
max_tokens: MAX_TOKENS,
// 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 },
],
},
{ signal: req.signal },
);
try {
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta" &&
event.delta.text
) {
yield { type: "delta", text: event.delta.text };
}
}
const final = await stream.finalMessage();
// Safety classifiers can decline a request and still return 200.
// Reading content[0] without checking this is how that surfaces as
// a confusing empty reply rather than something the UI can explain.
if (final.stop_reason === "refusal") {
yield {
type: "error",
code: "refused",
message: "선생님 declined that one. Try rephrasing it.",
};
return;
}
yield { type: "done", stopReason: final.stop_reason, usage: final.usage };
} catch (err) {
if (req.signal.aborted) {
// The learner pressed stop. Not a failure, and the upstream
// request is already cancelled, so billing stops here.
yield { type: "done", stopReason: "cancelled" };
return;
}
const message = err instanceof Error ? err.message : String(err);
yield { type: "error", code: "upstream", message };
}
},
};
}