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

@@ -30,12 +30,27 @@ export interface SampleOptions {
onText?: (update: { text: string }) => void;
}
/**
* One turn's request.
*
* `system` is separate from `messages` on purpose. The artifact had no
* system role available and smuggled the whole prompt in as messages[0];
* carrying that forward would have meant the ~12k-character gate could not
* be given a cache breakpoint, and would be re-billed in full on every
* turn. Keeping it its own field is what makes prompt caching possible on
* the server.
*/
export interface SampleRequest {
system: string;
messages: SampleMessage[];
}
export interface SampleResult {
text: string;
truncated?: boolean;
}
export type Sample = (messages: SampleMessage[], opts?: SampleOptions) => Promise<SampleResult>;
export type Sample = (req: SampleRequest, opts?: SampleOptions) => Promise<SampleResult>;
export class SampleError extends Error {
code: string;
@@ -169,7 +184,7 @@ export interface StubOptions {
export function makeStubTutor(context: () => StubContext, opts: StubOptions = {}): Sample {
const delay = opts.chunkDelay ?? 18;
return async (_messages, options = {}) => {
return async (_req, options = {}) => {
const { signal, onText } = options;
const full = composeReply(context());

View File

@@ -0,0 +1,125 @@
/* The real tutor, over SSE.
Implements the same Sample contract as the stub, so swapping one for the
other touches nothing in the UI.
Not EventSource: it can only GET, and a turn is a POST carrying the
system prompt and the transcript. fetch + a reader gives us the body we
need and an AbortController for free — and the abort is load-bearing,
because it is what stops a cancelled turn from being billed. */
import {
SampleError,
type Sample,
type SampleOptions,
type SampleRequest,
type SampleResult,
} from "./stub-tutor.js";
export interface TutorEndpoint {
baseUrl: string;
token: string;
}
interface WireEvent {
type: "delta" | "done" | "error";
text?: string;
code?: string;
message?: string;
}
/**
* Split an SSE byte stream into events.
*
* Events are separated by a blank line and may span reads, so a partial
* event has to be carried over rather than parsed early. Lines beginning
* with ":" are heartbeat comments and carry no data.
*/
async function* readEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<WireEvent> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let split: number;
while ((split = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
const data = frame
.split("\n")
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).trim())
.join("");
if (!data) continue; // heartbeat, or an event with an empty payload
try {
yield JSON.parse(data) as WireEvent;
} catch {
// A frame we cannot parse is not worth killing the turn over.
}
}
}
} finally {
// Release the stream whatever happened. No `return` here: a return
// inside `finally` would swallow an in-flight exception.
reader.cancel().catch(() => undefined);
}
}
/** A Sample backed by the Pi. */
export function makeRemoteTutor(endpoint: TutorEndpoint): Sample {
return async (req: SampleRequest, opts: SampleOptions = {}): Promise<SampleResult> => {
const { signal, onText } = opts;
const history = req.messages.slice(0, -1);
const last = req.messages[req.messages.length - 1];
let res: Response;
try {
res = await fetch(`${endpoint.baseUrl.replace(/\/$/, "")}/api/tutor`, {
method: "POST",
signal,
headers: {
"content-type": "application/json",
authorization: `Bearer ${endpoint.token}`,
},
body: JSON.stringify({
system: req.system,
history,
message: last?.content ?? "",
}),
});
} catch (err) {
if (signal?.aborted) throw new SampleError("cancelled");
throw new SampleError("offline", err instanceof Error ? err.message : "Could not reach 선생님.");
}
if (res.status === 401) throw new SampleError("unauthorized", "The tutor token was rejected.");
if (!res.ok) throw new SampleError("upstream", `Tutor endpoint returned HTTP ${res.status}.`);
if (!res.body) throw new SampleError("upstream", "Tutor endpoint returned no body.");
// The UI wants cumulative text; the wire carries deltas.
let text = "";
for await (const event of readEvents(res.body)) {
if (event.type === "delta" && event.text) {
text += event.text;
onText?.({ text });
} else if (event.type === "error") {
// Keep whatever streamed before the failure — a partial lesson is
// worth more than an empty bubble.
throw new SampleError(event.code ?? "upstream", event.message, text);
} else if (event.type === "done") {
break;
}
}
if (signal?.aborted) throw new SampleError("cancelled", "stopped", text);
return { text };
};
}

View File

@@ -27,6 +27,7 @@ import {
import type { FocusMode } from "../../domain/gate.js";
import { applyProgressReport, currentUnit } from "../../domain/progress.js";
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
import { makeRemoteTutor } from "../../domain/tutor-client.js";
import { lookupMany } from "../../domain/lexicon.js";
import { REFERENCE_BAND, bandForUnit, ceilingForBand } from "@shared/bands.mjs";
import type { Db } from "../../db/types.js";
@@ -87,7 +88,7 @@ async function readBandWords(db: Db, band: number, ceiling: number): Promise<str
/* ── the tab ─────────────────────────────────────────────────────── */
export function TutorTab() {
const { db, progress, prefs, setPref, refreshProgress } = useStore();
const { db, progress, prefs, setPref, server, refreshProgress } = useStore();
const [turns, setTurns] = useState<Turn[]>([]);
const [streaming, setStreaming] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -148,6 +149,10 @@ export function TutorTab() {
/* ── the responder ── */
const sample: Sample = useMemo(() => {
// A configured server means the real 선생님; otherwise the local stand-in,
// so the app is complete offline rather than degraded.
if (server) return makeRemoteTutor(server);
return makeStubTutor(() => {
const words: StubWord[] = railVocabulary.current;
return {
@@ -157,7 +162,7 @@ export function TutorTab() {
confidence: progress.confidence?.[progress.current] ?? 0,
};
});
}, [gate, turns, progress]);
}, [gate, turns, progress, server]);
/* The unit's own new words, glossed — what the stub builds exercises from
and what the real tutor would be told it may introduce. */
@@ -205,7 +210,10 @@ export function TutorTab() {
try {
const result = await sample(
[{ role: "user", content: systemPrompt }, ...history, { role: "user", content: body }],
{
system: systemPrompt,
messages: [...history, { role: "user", content: body }],
},
{ signal: controller.signal, onText: ({ text }) => setStreaming(text) },
);
@@ -350,7 +358,8 @@ export function TutorTab() {
</select>
</label>
<span className="note">
{gate.vocabulary.length} words unlocked · {gate.newWords.length} new this unit
{server ? "connected" : "local stand-in"} · {gate.vocabulary.length} words unlocked ·{" "}
{gate.newWords.length} new this unit
</span>
</div>