feat(tutor): the real endpoint, streamed, with the system prompt cached

POST /api/tutor takes the assembled system prompt, the transcript the
client owns, and the new message, and streams tokens back. It holds
nothing between requests, so a dropped connection costs one turn rather
than the conversation.

The seam moved first: Sample took the assembled prompt as messages[0] with
role user. It now has an explicit system field, which is what lets the
backend put it in the API's system parameter as a cached block. The gate
is ~12k characters and is byte-identical for as long as the learner stays
in one unit, so every turn after the first reads the prefix at a fraction
of the input price. That is the single biggest cost lever in the design,
and it was unreachable through the old shape.

prompt/tutor-system.md still ships unchanged; only where the string is
placed changed.

SSE has three rules that are silent when broken, and all three are
handled: every event ends with a blank line, payloads are JSON-encoded
because a raw newline in Korean text would break the framing, and a `:`
heartbeat every 15s keeps intermediaries from timing the stream out.
Cache-Control is set on the returned Response rather than inside
streamSSE, which writes its own and would overwrite it; `no-transform` is
there because compression, not buffering, is what usually makes SSE look
like it hangs behind a proxy.

The client uses fetch + getReader, not EventSource — EventSource cannot
POST, and the body is {system, history, message}. Aborting closes the
connection, the server aborts upstream, and a cancelled turn stops
billing. With no server configured the app falls back to the stub, so the
offline build is untouched.

backends/anthropic.ts is the default. backends/agent-sdk.ts is deliberately
unimplemented and documents why the plain API was chosen over PORT.md's
Agent SDK — chiefly that its prompt accepts only user-role messages, so
the transcript would have to be flattened into a single turn.

The endpoint's own tests use a mock backend and need no API key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 19:58:01 +02:00
parent c2e1fc23fe
commit 66f92247d2
8 changed files with 582 additions and 6 deletions

View File

@@ -0,0 +1,49 @@
/* The Claude Agent SDK backend — deliberately not implemented.
*
* PORT.md originally specified this path, for one good reason: a
* CLAUDE_CODE_OAUTH_TOKEN bills against a Claude subscription rather than
* per token. If that matters more than the trade-offs below, this is the
* file to fill in; nothing else changes, because the route only knows the
* TutorBackend interface.
*
* What it would cost, so the decision can be made with the facts:
*
* 1. THE CONVERSATION SHAPE DOES NOT FIT. query()'s `prompt` accepts only
* user-role messages. There is no way to hand it prior *assistant*
* turns, so the transcript has to be flattened into one user turn —
* losing the role structure the tutor's marking behaviour depends on.
* The alternative, `resume: sessionId`, puts session state back on the
* server, which is precisely what PORT.md says to avoid.
*
* 2. IT IS NOT A LIBRARY. It supervises a `claude` CLI subprocess, ~215 MB
* unpacked, with hosting guidance of roughly 1 GiB RAM and a CPU per
* concurrent agent. That is heavy for a Pi already running Postgres
* and Caddy. Note also that `npm ci --omit=optional` yields no binary
* and fails at runtime with "Claude Code not found".
*
* 3. NO PROMPT CACHE HANDLE. The gate prompt cannot be given a
* cache_control breakpoint, only the coarser systemPrompt snapshot —
* so the cost saving that makes the API path cheap per turn is not
* available to offset the subscription's rate limits.
*
* 4. A POLICY QUESTION. Anthropic's docs say third-party developers may
* not offer claude.ai login or subscription rate limits for their
* products, including agents built on the Agent SDK, and point to API
* key auth instead. A personal, single-user, self-hosted endpoint is
* not addressed either way. Unsettled rather than permitted.
*
* If implemented, it would need: tools: [] (allowedTools only auto-approves,
* it does not restrict), settingSources: [], persistSession: false,
* maxTurns: 1, CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, and
* includePartialMessages: true to get token deltas. ANTHROPIC_API_KEY must
* be unset in the environment or it silently outranks the OAuth token.
*/
import type { TutorBackend } from "./types.ts";
export function agentSdkBackend(): TutorBackend {
throw new Error(
"The Agent SDK backend is not implemented — see the note at the top of " +
"server/src/backends/agent-sdk.ts. Use HANKAN_TUTOR_BACKEND=anthropic.",
);
}

View File

@@ -0,0 +1,81 @@
/* 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 messages after it
// are what vary per turn.
system: [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }],
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 };
}
},
};
}

View File

@@ -0,0 +1,34 @@
/* What the tutor route needs from a model, and nothing more.
Two backends are possible. The Claude API one is built and is the
default. The Claude Agent SDK one — which PORT.md originally specified,
for its subscription billing — is a documented stub, because it cannot
express this conversation shape without a compromise: its prompt accepts
only user-role messages, so prior assistant turns have to be flattened
into text. See backends/agent-sdk.ts. */
export interface TutorTurn {
role: "user" | "assistant";
content: string;
}
export interface TutorRequest {
/** The assembled system prompt: tutor-system.md with {{GATE}} filled in. */
system: string;
/** The transcript so far. The client owns it and sends it every turn. */
history: TutorTurn[];
/** What the learner just said. */
message: string;
signal: AbortSignal;
}
/** Emitted as the model produces them. Text chunks are deltas, not cumulative. */
export type TutorEvent =
| { type: "delta"; text: string }
| { type: "done"; stopReason: string | null; usage?: unknown }
| { type: "error"; code: string; message: string };
export interface TutorBackend {
readonly name: string;
stream(req: TutorRequest): AsyncIterable<TutorEvent>;
}

103
server/src/tutor.ts Normal file
View File

@@ -0,0 +1,103 @@
/* POST /api/tutor — one turn in, a token stream out.
No session state. The client posts the assembled system prompt, the
transcript it owns, and the new message; the server holds nothing between
requests, so a dropped connection costs one turn rather than the
conversation.
SSE framing has three rules that are easy to get wrong and silent when
broken: every event ends with a blank line, a `data:` field may not
contain a raw newline (Korean tutor replies are full of them, so payloads
are JSON-encoded), and a comment line starting with `:` is a heartbeat
that keeps intermediaries from timing the stream out. */
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { anthropicBackend } from "./backends/anthropic.ts";
import type { TutorBackend, TutorTurn } from "./backends/types.ts";
const HEARTBEAT_MS = 15_000;
/** Bound the transcript the client may send, so one request cannot be huge. */
const MAX_HISTORY = 40;
function pickBackend(): TutorBackend {
const name = process.env.HANKAN_TUTOR_BACKEND ?? "anthropic";
if (name === "anthropic") return anthropicBackend();
throw new Error(`unknown HANKAN_TUTOR_BACKEND: ${name}`);
}
export function tutorRoute(backend?: TutorBackend) {
const app = new Hono();
// Constructed lazily so the process can start — and serve sync — without
// an API key present.
let resolved: TutorBackend | null = backend ?? null;
app.post("/", async (c) => {
const body = (await c.req.json()) as {
system?: string;
history?: TutorTurn[];
message?: string;
};
if (!body.system || !body.message) {
return c.json({ error: "system and message are required" }, 400);
}
const history = (Array.isArray(body.history) ? body.history : [])
.filter((t) => t && (t.role === "user" || t.role === "assistant") && typeof t.content === "string")
.slice(-MAX_HISTORY);
try {
resolved ??= pickBackend();
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : "no backend" }, 503);
}
const res = streamSSE(c, async (stream) => {
const abort = new AbortController();
// The client going away is the cancel signal: abort upstream so a
// cancelled turn stops billing.
stream.onAbort(() => abort.abort());
const beat = setInterval(() => {
void stream.writeSSE({ data: "", event: "ping" });
}, HEARTBEAT_MS);
try {
for await (const event of resolved!.stream({
system: body.system!,
history,
message: body.message!,
signal: abort.signal,
})) {
await stream.writeSSE({ event: event.type, data: JSON.stringify(event) });
if (event.type === "done" || event.type === "error") break;
}
} catch (err) {
await stream.writeSSE({
event: "error",
data: JSON.stringify({
type: "error",
code: "server",
message: err instanceof Error ? err.message : String(err),
}),
});
} finally {
clearInterval(beat);
}
});
/* Set after streamSSE, which writes its own Cache-Control and would
overwrite these. `no-transform` tells intermediaries not to re-encode
the body — compression is the usual reason SSE appears to hang behind
a proxy, because an unfinished compression frame holds the events.
X-Accel-Buffering is nginx's opt-out and harmless elsewhere. */
res.headers.set("Cache-Control", "no-cache, no-transform");
res.headers.set("X-Accel-Buffering", "no");
return res;
});
return app;
}