Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ test-results/
.vercel/
.vite/
.vercel
# Answers of a live tutor eval run (pnpm tutor:eval --live)
/tutor-eval/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ Convex speichert gelöste Übungen mit Fingerprint, versionierte Entwürfe, gele

Die globale Suche (Lupe in der Kopfzeile, Taste `/` oder `Strg`/`⌘`+`K`) findet Module, Lektionsabschnitte, Glossarbegriffe, Spickzettel-Abschnitte, Projekte und Interview-Karten aller Bereiche und springt direkt an die Stelle. Der Index entsteht beim Build aus den Inhalten, wird erst beim ersten Öffnen geladen und funktioniert offline. Einen externen Suchdienst gibt es nicht.

Optional gibt es einen **KI-Tutor** unter den Hinweisen einer Übung: drei Hinweisstufen ohne die Lösung, danach freie Fragen, mit Monatsbudget. Er erscheint nur, wenn im Convex-Deployment ein Anthropic-Schlüssel hinterlegt ist. Details: [KI-Tutor](docs/CONVEX.md#ki-tutor-optional-lrn-6).

Die Plattform ist als App installierbar (PWA) und offline lesbar. Details und Grenzen: [Offline und installierbare App](docs/CONVEX.md#offline-und-installierbare-app).

Das Design (hell oder dunkel) folgt der Systemeinstellung, bis es über den Schalter in der Kopfzeile gewählt wird. Diese Wahl gilt pro Gerät (`localStorage`, Schlüssel `learn:theme`) und wird nicht synchronisiert.
Expand Down
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import type * as auth from "../auth.js";
import type * as model from "../model.js";
import type * as progress from "../progress.js";
import type * as tutor from "../tutor.js";

import type {
ApiFromModules,
Expand All @@ -22,6 +23,7 @@ declare const fullApi: ApiFromModules<{
auth: typeof auth;
model: typeof model;
progress: typeof progress;
tutor: typeof tutor;
}>;

/**
Expand Down
6 changes: 6 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ export default defineSchema({
.index('by_area_module', ['areaId', 'moduleId']),
imports: defineTable({ areaId: v.string(), importId: v.string(), importedAt: v.number() })
.index('by_area_import', ['areaId', 'importId']),
// AI tutor (convex/tutor.ts): one row per UTC month with token counters and cost in micro-dollars. No texts.
// pendingMicros holds the worst case of requests in flight, so the monthly budget also holds for parallel requests.
tutorUsage: defineTable({
month: v.string(), requests: v.number(), inputTokens: v.number(), cacheWriteTokens: v.number(), cacheReadTokens: v.number(), outputTokens: v.number(),
costMicros: v.number(), pendingMicros: v.number(), updatedAt: v.number(),
}).index('by_month', ['month']),
});
244 changes: 244 additions & 0 deletions convex/tutor.test.ts

Large diffs are not rendered by default.

127 changes: 127 additions & 0 deletions convex/tutor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import Anthropic from '@anthropic-ai/sdk';
import { v } from 'convex/values';
import { action, internalMutation, internalQuery, query, type MutationCtx, type QueryCtx } from './_generated/server';
import { internal } from './_generated/api';
import { requireOwner } from './auth';
import { assertId } from './model';
import {
buildPrompt, costMicros, leaksSolution, promptChars, tutorAllowed, tutorConfig, tutorRequestProblem, tutorStatus, usageMonth, worstCaseMicros,
TUTOR_MAX_TOKENS, type TutorReply, type TutorStatus,
} from '../src/engine/tutor';

// Optional AI tutor (LRN-6). Without ANTHROPIC_API_KEY in the Convex environment it does not exist for the app.
// The client sends only learning content (src/engine/tutor-context.ts); nothing here adds IDs or account data.

const findMonth = (ctx: QueryCtx | MutationCtx, month: string) => ctx.db.query('tutorUsage').withIndex('by_month', (q) => q.eq('month', month)).unique();

/** `month` comes from the client clock so that a subscription moves on at the turn of the month; the server's is the fallback. */
export const status = query({
args: { month: v.optional(v.string()) },
handler: async (ctx, args): Promise<TutorStatus> => {
await requireOwner(ctx);
const config = tutorConfig(process.env);
if (!config.enabled) return tutorStatus(config, '', 0, 0, 0);
const month = args.month && /^\d{4}-(0[1-9]|1[0-2])$/.test(args.month) ? args.month : usageMonth(new Date());
const row = await findMonth(ctx, month);
return tutorStatus(config, month, row?.costMicros ?? 0, row?.pendingMicros ?? 0, row?.requests ?? 0);
},
});

/** No request runs this long (90 s timeout, at most two attempts); an older reservation was left by an aborted action. */
const STALE_RESERVATION_MS = 15 * 60_000;

/** Books the most a request can cost before it is sent; false when that would exceed the monthly budget. */
export const reserve = internalMutation({
args: { month: v.string(), micros: v.number(), budgetMicros: v.number() },
handler: async (ctx, { month, micros, budgetMicros }) => {
const row = await findMonth(ctx, month);
const pending = row && Date.now() - row.updatedAt < STALE_RESERVATION_MS ? row.pendingMicros : 0;
if ((row?.costMicros ?? 0) + pending + micros > budgetMicros) return false;
if (row) await ctx.db.patch(row._id, { pendingMicros: pending + micros, updatedAt: Date.now() });
else await ctx.db.insert('tutorUsage', { month, requests: 0, inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, outputTokens: 0, costMicros: 0, pendingMicros: micros, updatedAt: Date.now() });
return true;
},
});

/** Replaces a reservation by the actual usage from the response; without usage (failed request) it only releases it. */
export const settle = internalMutation({
args: { month: v.string(), reserved: v.number(), usage: v.union(v.null(), v.object({ input: v.number(), cacheWrite: v.number(), cacheRead: v.number(), output: v.number(), costMicros: v.number() })) },
handler: async (ctx, { month, reserved, usage }) => {
const row = await findMonth(ctx, month);
if (!row) return;
await ctx.db.patch(row._id, {
pendingMicros: Math.max(0, row.pendingMicros - reserved),
...(usage ? {
requests: row.requests + 1,
inputTokens: row.inputTokens + usage.input, cacheWriteTokens: row.cacheWriteTokens + usage.cacheWrite,
cacheReadTokens: row.cacheReadTokens + usage.cacheRead, outputTokens: row.outputTokens + usage.output,
costMicros: row.costMicros + usage.costMicros,
} : {}),
updatedAt: Date.now(),
});
},
});

/** For tests and the eval: the stored counters of one month. */
export const monthUsage = internalQuery({
args: { month: v.string() },
handler: async (ctx, { month }) => await findMonth(ctx, month),
});

const tutorRequest = {
areaId: v.string(),
kind: v.string(),
level: v.union(v.literal(1), v.literal(2), v.literal(3)),
done: v.boolean(),
lesson: v.object({ title: v.string(), text: v.string() }),
exercise: v.string(),
attempt: v.string(),
feedback: v.string(),
previous: v.array(v.string()),
question: v.string(),
leak: v.object({ lang: v.string(), groups: v.array(v.object({ answers: v.array(v.string()), given: v.string(), exact: v.optional(v.boolean()) })), lines: v.array(v.number()), lineTexts: v.array(v.string()), options: v.array(v.number()), visible: v.string() }),
};

/**
* One hint (levels 1–3) or, once solved, one answer. Checks owner, size and budget first. An answer that contains an
* accepted solution while the exercise is unsolved is discarded and requested once more; a second leak returns
* `leak` and the page falls back to the static hints.
*/
export const ask = action({
args: tutorRequest,
handler: async (ctx, request): Promise<TutorReply> => {
await requireOwner(ctx);
const config = tutorConfig(process.env);
if (!config.enabled) return { ok: false, reason: 'disabled' };
assertId(request.areaId);
if (tutorRequestProblem(request)) return { ok: false, reason: 'too_large' };
if (!tutorAllowed(request.areaId, request.kind, request.leak)) return { ok: false, reason: 'not_allowed' };
// No automatic retries: a retried request could be billed twice without being counted.
const client = new Anthropic({ apiKey: config.apiKey, maxRetries: 0, timeout: 90_000 });
for (const retry of [false, true]) {
const prompt = buildPrompt(request, retry);
const month = usageMonth(new Date());
const reserved = worstCaseMicros(config.model, promptChars(prompt));
if (!await ctx.runMutation(internal.tutor.reserve, { month, micros: reserved, budgetMicros: config.budgetMicros })) return { ok: false, reason: 'budget' };
let message: Anthropic.Message;
try {
message = await client.messages.create({ model: config.model, max_tokens: TUTOR_MAX_TOKENS, output_config: { effort: config.effort }, system: prompt.system, messages: prompt.messages });
} catch (error) {
// After a timeout the request may still have been billed: the reservation then counts as spent.
const timedOut = error instanceof Anthropic.APIConnectionTimeoutError;
await ctx.runMutation(internal.tutor.settle, { month, reserved, usage: timedOut ? { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, costMicros: reserved } : null });
// Status and class only: the request carries the learner's text.
console.error('tutor request failed', error instanceof Anthropic.APIError ? `${error.constructor.name} ${error.status ?? ''}` : String((error as Error)?.name));
return { ok: false, reason: 'provider' };
}
const counted = { input: message.usage.input_tokens ?? 0, cacheWrite: message.usage.cache_creation_input_tokens ?? 0, cacheRead: message.usage.cache_read_input_tokens ?? 0, output: message.usage.output_tokens };
await ctx.runMutation(internal.tutor.settle, { month, reserved, usage: { ...counted, costMicros: costMicros(config.model, counted) } });
if (message.stop_reason === 'refusal') return { ok: false, reason: 'refusal' };
if (message.stop_reason === 'max_tokens') return { ok: false, reason: 'truncated' };
const text = message.content.flatMap((block) => block.type === 'text' ? [block.text] : []).join('\n\n').trim();
if (!text) return { ok: false, reason: 'provider' };
if (request.done || !leaksSolution(text, request.leak, request.attempt)) return { ok: true, text, retried: retry };
}
return { ok: false, reason: 'leak' };
},
});
25 changes: 25 additions & 0 deletions docs/CONVEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,30 @@ Die Clerk-Instanz wurde für `learn.kiumu.app` als **Secondary application** ang

Beim erstmaligen Produktionssetup wurde der Entwicklungsstand einmalig per Convex-Snapshot übertragen: vorher alle vier Produktionstabellen als leer geprüft, anschließend ohne `--replace` oder `--append` importiert. Zwei Fortschrittseinträge und ein Entwurf wurden unverändert übernommen und gegen den Snapshot verglichen; die Entwicklung wurde nicht gelöscht. **Diesen einmaligen Import nicht auf eine inzwischen benutzte Produktion wiederholen.** Für spätere Zusammenführungen den ergänzenden JSON-/Legacy-Import der App verwenden.

## KI-Tutor (optional, LRN-6)

Unter den Hinweisen einer Übung kann ein Tutor gestufte Hilfe geben: Stufe 1 Denkanstoß, Stufe 2 Konzept mit Link auf den Lektionsabschnitt, Stufe 3 Fehlerstelle. Die nächste Stufe wählt der Lernende. Ist die Übung gelöst oder die Lösung angezeigt, beantwortet er freie Fragen. **Ohne `ANTHROPIC_API_KEY` im Convex-Deployment gibt es ihn nicht**: Die Übungsseite und `/daten` sehen dann aus wie vorher. Offline und im Offline-Lesemodus ist er ebenfalls ausgeblendet.

Einschalten (Entwicklung; für Produktion dieselben Befehle mit `--prod`, erst nach Stevens Entscheidung):

```sh
pnpm exec convex env set ANTHROPIC_API_KEY sk-ant-… # Pflicht, Schlüssel aus der Claude Console
pnpm exec convex env set TUTOR_MODEL claude-sonnet-5-5 # optional, Vorschlag aus LRN-6
pnpm exec convex env set TUTOR_EFFORT medium # optional: low, medium oder high
pnpm exec convex env set TUTOR_MONTHLY_BUDGET_USD 5 # optional: Dollar je Monat, über 0 und höchstens 500
```

Modell, Effort und Budget sind Vorschläge aus LRN-6, keine Entscheidungen. Erlaubt sind nur Modelle mit hinterlegtem Preis (`PRICES` in `src/engine/tutor.ts`: `claude-sonnet-5-5`, `claude-haiku-5-5`, Stand 24.10.2026). Bei einem anderen Modell oder einem ungültigen Wert bleibt der Tutor aus, und `/daten` nennt den Grund. Claude Max enthält monatliches API-Guthaben; es gilt, wenn der Schlüssel aus der mit dem Plan verknüpften Console-Organisation stammt (Antwort im Kommentar zu LRN-6 vom 24.10.2026).

- **Budget:** Vor jeder Anfrage reserviert `convex/tutor.ts` die höchsten möglichen Kosten (jedes Zeichen als Token, alles als Cache-Schreibvorgang, volle Antwortlänge von 3.000 Tokens samt Denken) in `tutorUsage`. Passt das nicht mehr ins Monatsbudget, geht keine Anfrage raus. Danach ersetzt der echte Verbrauch aus `usage` die Reservierung, getrennt nach Input, Cache-Write, Cache-Read und Output in Mikro-Dollar. So hält der Deckel auch bei parallelen Anfragen. Das SDK wiederholt nichts selbst, weil ein wiederholter Aufruf doppelt kosten könnte; eine Zeitüberschreitung (90 s) wird mit der Reservierung als verbraucht gebucht. Reservierungen, die ein abgebrochener Aufruf liegen lässt, verfallen nach 15 Minuten. Monat ist der UTC-Kalendermonat wie bei den Ausgabenlimits der Console. Ab 80 % zeigt das Panel einen Hinweis; es pausiert bis Monatsende, sobald eine lange Anfrage nicht mehr ins Budget passt. Den Verbrauch zeigt `/daten`. Als zweite Sicherung in der Console einen eigenen Workspace mit Ausgabenlimit anlegen.
- **Daten:** Der Browser schickt Lektionstext, Übung mit Musterlösung, den aktuellen Entwurf, die Rückmeldung der letzten Prüfung (bei Python die Testausgabe), die Antworten des Tutors in diesem Besuch und die eigene Frage. Keine Übungs-ID, keine Clerk-ID, keine E-Mail, kein Lernstand anderer Übungen. Vorname, Nachname, Benutzername und E-Mail aus der Clerk-Sitzung ersetzt der Browser vor dem Senden überall durch gleich lange neutrale Platzhalter (`scrubRequest`), weil Kursbeispiele den Namen des Besitzers enthalten (`/home/steven`). Die Namen verlassen den Browser nicht. Beim Eval übernimmt das `TUTOR_PRIVATE_NAMES`. Convex speichert davon nichts außer den Zählern; der Verlauf verschwindet mit dem Neuladen. Anthropic nutzt API-Daten nicht fürs Training und löscht sie nach 30 Tagen; eine EU-Verarbeitung bietet Anthropic nicht.
- **Lösungsprüfung:** Vor der Anzeige prüft `leaksSolution` (`src/engine/tutor.ts`) mit den Normalisierern aus `src/engine/answers.ts`, ob die Antwort eine vollständige akzeptierte Lösung enthält: alle Lücken, die korrigierten Zeilen, die richtigen Optionen (als Text oder „Option 2“), bei Fehlersuche die noch nicht gefundenen Zeilen (als Nummer, Ordinalzahl, Bereich oder Zitat), bei Reihenfolgen jedes Zeilenpaar in richtiger Folge, bei Code 80 % des neuen Teils, auch über mehrere Codeblöcke verteilt. Nicht als neu zählt, was Aufgabe, Startcode, die fehlerhafte Zeile oder der eigene Versuch schon zeigen. Bei einem Treffer wird einmal neu angefragt, beim zweiten zeigt die Seite den nächsten statischen Hinweis. Die Prüfung fängt versehentliches Verraten, keine Umschreibungen. Lösungen unter drei Zeichen (etwa `?.` oder `42`) zählen nur, wenn sie allein als Code dastehen. Ein Test läuft über alle Übungen: Jede Anfrage passt in die Grenzen, und das Wiederholen der Aufgabe gilt nirgends als Verrat.
- **Bereiche mit eigener Regel:** In `narrative` erklärt der Tutor nur Handwerk, Struktur und Checklisten, Rechtschreibung nur auf ausdrücklichen Wunsch, keine inhaltlichen Vorschläge, kein Umschreiben, keine Bewertung der Wirkung (LRN-29). In `combat` schlägt er keine Designentscheidungen vor (`area.yaml`). In beiden Bereichen gibt es bei Schreibaufgaben (`practice` mit `lang: prose` oder `text`) keinen Tutor, auch wenn eine Musterlösung existiert; überall sonst fehlt er nur bei Praxisaufgaben ohne Musterlösung. Das prüft auch der Server.
- **Prompt-Cache:** Systemprompt, Lektion und Übung stehen in fester Reihenfolge vor zwei Cache-Breakpoints, alles Veränderliche danach. Ein Test in `convex/tutor.test.ts` prüft, dass der zweite Hinweis zur selben Übung byte-gleich bis zum Breakpoint ist und als Cache-Treffer verbucht wird. Mit echtem Schlüssel muss das einmal am echten `cache_read_input_tokens` bestätigt werden.
- **Tests ohne Schlüssel:** `convex/tutor.test.ts` ersetzt `fetch`, sodass das echte SDK seine echte Anfrage baut, aber nichts das Netz erreicht. `e2e/tutor.spec.ts` prüft das Panel im lokalen Testadapter mit `window.__tutorFake`.
- **Eval vor der Modellwahl:** `pnpm tutor:eval` prüft die 30 Fälle in `tooling/tutor-eval/cases.yaml`, baut dieselben Prompts wie die App und schätzt die Kosten, ohne Netz und ohne Schlüssel. Der echte Lauf braucht `ANTHROPIC_API_KEY` in der Shell und kostet Geld: `pnpm tutor:eval --live --max-usd 2 --variants sonnet-low,sonnet-medium,haiku-medium`. Er bricht ab, bevor die nächste Anfrage das Limit überschreiten könnte, und schreibt `results.jsonl` und einen Bewertungsbogen `grading.md` nach `tutor-eval/` (nicht im Repository).
- **Noch nicht gebaut:** Streaming (Antwort kommt nach einigen Sekunden komplett, mit Ladeanzeige), Bewertung von `explain` und `practice` gegen Kernpunkte (Schritt 3), Fragen in der Lektion, gespeicherter Verlauf, Rechtschreibprüfung auf Wunsch im Bereich `narrative`, Ausweichmodell bei `refusal`.

## Tests und lokale Backend-Prüfung

```sh
Expand All @@ -145,6 +169,7 @@ pnpm verify
pnpm test:e2e # Bestehende Lernabläufe mit lokalem Regressionstest-Adapter
pnpm test:pwa:e2e # Produktionsbuild: Manifest, Service Worker, Offline-Lesen
pnpm build
pnpm tutor:eval # Trockenlauf des Tutor-Evals, ohne Netz
```

Der lokale Adapter wird ausschließlich von `vite serve --mode test-local` geladen. Produktionsbuilds verwenden unabhängig vom Modus immer `src/main.ts` mit Auth-Gate. `test:convex:e2e` verwendet dagegen den echten Convex-JavaScript-Client und ein **wegwerfbares lokales Convex-Deployment**, um zwei Browser, WebSockets, Optimismus, Import/Export und Verbindungsabbrüche zu prüfen:
Expand Down
7 changes: 7 additions & 0 deletions e2e/convex/sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ test('separate devices share lessons, drafts, exercise success and the latest st
await phone.close();
});

test('without an API key the deployed tutor reports itself absent and the exercise page has no panel', async ({ page }) => {
expect(await http.query(api.tutor.status, {})).toEqual({ enabled: false, problem: null });
await page.goto(`${base}/1`); await synced(page);
await expect(page.locator('#answer-g1')).toBeVisible();
await expect(page.locator('#tutor')).toBeHidden();
});

test('continue learning on another device skips solved work and ignores card reviews', async ({ page, browser }) => {
const phone = await browser.newContext({ viewport: { width: 390, height: 844 } }); await fixture(phone);
const other = await phone.newPage();
Expand Down
Loading
Loading