"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>
113 lines
4.1 KiB
TypeScript
113 lines
4.1 KiB
TypeScript
/* 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 { echoBackend } from "./backends/echo.ts";
|
|
import { openaiBackend } from "./backends/openai.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();
|
|
// Keyless, for proving a deployment before any billing happens.
|
|
if (name === "echo") return echoBackend();
|
|
// Anything speaking OpenAI's /chat/completions: LM Studio, Ollama,
|
|
// llama.cpp, vLLM, LiteLLM, OpenRouter, OpenAI.
|
|
if (name === "openai") return openaiBackend();
|
|
throw new Error(`unknown HANKAN_TUTOR_BACKEND: ${name} (anthropic | openai | echo)`);
|
|
}
|
|
|
|
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;
|
|
systemTail?: 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!,
|
|
systemTail: typeof body.systemTail === "string" ? body.systemTail : undefined,
|
|
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;
|
|
}
|