"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>
86 lines
3.2 KiB
TypeScript
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 };
|
|
}
|
|
},
|
|
};
|
|
}
|