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:
@@ -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());
|
||||
|
||||
|
||||
125
app/src/domain/tutor-client.ts
Normal file
125
app/src/domain/tutor-client.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user