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>
126 lines
3.9 KiB
TypeScript
126 lines
3.9 KiB
TypeScript
/* 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 };
|
|
};
|
|
}
|