Skip to content

Commit 1383773

Browse files
committed
feat(ai): assist ladder — free tier, Socratic mode, community handoff, self-reset (#864)
1 parent 3855b0f commit 1383773

27 files changed

Lines changed: 2132 additions & 290 deletions

apps/web/src/app/api/ai/__tests__/partner-route.test.ts

Lines changed: 170 additions & 49 deletions
Large diffs are not rendered by default.

apps/web/src/app/api/ai/partner/log/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export async function GET(request: NextRequest) {
4040
return NextResponse.json({ error: "Lesson not found" }, { status: 404 });
4141
}
4242

43-
const { paidUsed, log } = await getAssistState(user.id, lesson._id);
44-
return NextResponse.json({ log, paidUsed });
43+
const { counts, resetState, resetAvailableAt, log } = await getAssistState(
44+
user.id,
45+
lesson._id
46+
);
47+
return NextResponse.json({ log, counts, resetState, resetAvailableAt });
4548
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { isRateLimited } from "@/lib/rate-limit";
4+
import { getLessonBySlug } from "@/lib/content/queries";
5+
import { resetAssists } from "@/lib/ai/assist-budget";
6+
7+
const MAX_BODY_CHARS = 4_000;
8+
const MAX_SLUG_CHARS = 256;
9+
10+
/**
11+
* Self-serve per-lesson assist reset (#864, P2-5 / owner D-8). Self-only by
12+
* construction: keyed to the AUTHENTICATED user's id — the request body only
13+
* names the lesson. Both guards — once per (user, lesson) AND the 7-day
14+
* cooldown — are enforced INSIDE the SECURITY DEFINER
15+
* `reset_challenge_assists` RPC (rule R-6), shipped in the same migration as
16+
* this route; this handler only relays the verdict. A denial is 200 with
17+
* `{ allowed: false, reason }` — it is an expected product state (cooldown /
18+
* already used), not an error.
19+
*/
20+
export async function POST(request: NextRequest) {
21+
const supabase = await createClient();
22+
const {
23+
data: { user },
24+
error: authError,
25+
} = await supabase.auth.getUser();
26+
if (authError || !user) {
27+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
28+
}
29+
30+
const raw = await request.text();
31+
if (raw.length > MAX_BODY_CHARS) {
32+
return NextResponse.json(
33+
{ error: "Request body too large" },
34+
{ status: 413 }
35+
);
36+
}
37+
38+
let body: { courseSlug?: unknown; lessonSlug?: unknown };
39+
try {
40+
body = JSON.parse(raw);
41+
} catch {
42+
return NextResponse.json(
43+
{ error: "Invalid request body" },
44+
{ status: 400 }
45+
);
46+
}
47+
48+
const { courseSlug, lessonSlug } = body;
49+
if (
50+
typeof courseSlug !== "string" ||
51+
typeof lessonSlug !== "string" ||
52+
!courseSlug ||
53+
!lessonSlug ||
54+
courseSlug.length > MAX_SLUG_CHARS ||
55+
lessonSlug.length > MAX_SLUG_CHARS
56+
) {
57+
return NextResponse.json({ error: "Missing lesson" }, { status: 400 });
58+
}
59+
60+
// Abuse throttle only — the RPC's internal guards are the real ceiling.
61+
// Fail-open is fine here (unlike the paid partner route): a reset spends no
62+
// Gemini tokens, and the once+cooldown guards hold regardless.
63+
if (
64+
await isRateLimited("ai:partner:reset", user.id, {
65+
maxTokens: 5,
66+
refillIntervalMs: 60_000,
67+
})
68+
) {
69+
return NextResponse.json(
70+
{ error: "Rate limit exceeded. Please wait before trying again." },
71+
{ status: 429, headers: { "Retry-After": "60" } }
72+
);
73+
}
74+
75+
// Resolve the lesson id the budget is keyed by (same seam the paid route
76+
// uses — the catalog gate applies here too).
77+
const lesson = await getLessonBySlug(courseSlug, lessonSlug);
78+
if (!lesson) {
79+
return NextResponse.json({ error: "Lesson not found" }, { status: 404 });
80+
}
81+
82+
const result = await resetAssists(user.id, lesson._id);
83+
return NextResponse.json({
84+
allowed: result.allowed,
85+
reason: result.reason,
86+
availableAt: result.availableAt,
87+
});
88+
}

apps/web/src/app/api/ai/partner/route.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { createClient } from "@/lib/supabase/server";
44
import { isRateLimited, getClientIp } from "@/lib/rate-limit";
55
import { getLessonBySlug } from "@/lib/content/queries";
66
import {
7-
spendAssist,
8-
refundAssist,
7+
spendAssistTurn,
8+
refundAssistTurn,
99
recordBilledAssist,
1010
appendAssistLog,
1111
} from "@/lib/ai/assist-budget";
@@ -368,14 +368,23 @@ export async function POST(request: NextRequest) {
368368
// cost lever) rather than refusing. "Degrade first, then stop" (owner).
369369
const degraded = spendGate.decision === "degraded";
370370

371-
// Every request that reaches this route is a PAID action — free authored
372-
// hints are served client-side from the block's `hints` ladder and never
373-
// hit this route. Spend atomically before calling Gemini so a denied budget
374-
// never triggers a model call. Budget is keyed by the lesson id.
375-
const spend = await spendAssist(user.id, lesson._id);
371+
// Every request that reaches this route is a METERED AI turn — free authored
372+
// hints are served client-side from the block's `hints` ladder and never hit
373+
// this route. Spend one assist-LADDER turn atomically before calling Gemini
374+
// (#864): the SECURITY DEFINER RPC resolves the tier server-side (2 free →
375+
// 8 metered → 20 Socratic, per (user, lesson)) and reports which tier this
376+
// turn landed in. A denial means the whole ladder is spent — the community
377+
// handoff (spec §4.2 turn 31): the client degrades to the forum link, never
378+
// a paywall shape. Budget is keyed by the lesson id.
379+
const spend = await spendAssistTurn(user.id, lesson._id);
376380
if (!spend.allowed) {
377-
return NextResponse.json({ budgetExhausted: true, used: spend.used });
381+
return NextResponse.json({ budgetExhausted: true, counts: spend.counts });
378382
}
383+
// Socratic tier (§4.4): flip the default contract for hint/ask to ONE
384+
// diagnostic question (prompt suffix below) at a hint-sized output cap.
385+
// `propose` keeps its full diff contract at every tier — the §4.2 ruling —
386+
// and `review` is post-pass and unaffected.
387+
const socratic = spend.tier === "socratic";
379388

380389
// Whether Gemini has BILLED us for this request. Flips true the instant the
381390
// model returns a 2xx (`response.ok`) — a non-2xx or a network throw means it
@@ -428,14 +437,17 @@ export async function POST(request: NextRequest) {
428437
tutorNotes,
429438
language: codeBlock.language,
430439
});
431-
const suffix = buildDynamicSuffix({
432-
lessonSlug,
433-
courseSlug,
434-
action,
435-
message,
436-
code,
437-
testSummary,
438-
});
440+
const suffix = buildDynamicSuffix(
441+
{
442+
lessonSlug,
443+
courseSlug,
444+
action,
445+
message,
446+
code,
447+
testSummary,
448+
},
449+
{ socratic }
450+
);
439451

440452
const response = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
441453
method: "POST",
@@ -451,9 +463,11 @@ export async function POST(request: NextRequest) {
451463
temperature: 0.3,
452464
// Degrade-first (#591): past a soft spend cap, halve the output budget
453465
// (floored to stay usable) to cut the dominant cost before any refusal.
466+
// Socratic hint/ask turns run at the hint-sized cap (one diagnostic
467+
// question); propose keeps its full budget at every tier.
454468
maxOutputTokens: degraded
455-
? degradedMaxTokens(maxTokensFor(action))
456-
: maxTokensFor(action),
469+
? degradedMaxTokens(maxTokensFor(action, { socratic }))
470+
: maxTokensFor(action, { socratic }),
457471
// gemini-3.5-flash is a thinking model and thinking tokens share the
458472
// maxOutputTokens budget; disable it so the full budget goes to the
459473
// structured response (and to cut latency/cost).
@@ -468,9 +482,9 @@ export async function POST(request: NextRequest) {
468482
const errorText = await response.text();
469483
console.error("Gemini partner API error:", response.status, errorText);
470484
// A spend already happened above (spend.allowed was true to reach
471-
// here) but Gemini never ran — refund so a failed call doesn't burn
472-
// one of the user's 4 paid assists.
473-
await refundAssist(user.id, lesson._id);
485+
// here) but Gemini never ran — refund exactly the ladder tier the spend
486+
// landed in so a failed call doesn't burn one of the learner's turns.
487+
await refundAssistTurn(user.id, lesson._id, spend.tier);
474488
// Surface the upstream status (not Gemini's raw body) so a config-side
475489
// failure (403 API-not-enabled / key-restricted, 404 model, 429 quota)
476490
// is diagnosable from the Network tab, not just the server logs.
@@ -615,7 +629,7 @@ export async function POST(request: NextRequest) {
615629
// appendAssistLog, so the old unconditional refund handed back a fully
616630
// billed success (the AIE-10 line-411 correction).
617631
if (!billed) {
618-
await refundAssist(user.id, lesson._id);
632+
await refundAssistTurn(user.id, lesson._id, spend.tier);
619633
}
620634
return NextResponse.json(
621635
{ error: "Failed to get response" },

apps/web/src/components/editor/ai-partner/__tests__/ai-partner-pane.test.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,23 @@ const review = vi.fn();
1515
const hookState = {
1616
messages: [] as unknown[],
1717
freeHintsUsed: 0,
18-
paidUsed: 0,
19-
paidRemaining: 4,
18+
counts: { free: 0, metered: 0, socratic: 0 },
19+
tier: "free" as const,
2020
budgetExhausted: false,
2121
spendCapped: false,
22+
resetState: "none" as const,
23+
resetAvailableAt: null as number | null,
2224
loading: false,
2325
error: null as string | null,
2426
requestHint: vi.fn(),
2527
proposeFix: vi.fn(),
2628
ask: vi.fn(),
2729
review,
30+
requestReset: vi.fn(async () => ({
31+
allowed: false,
32+
reason: "error",
33+
availableAt: null,
34+
})),
2835
verifyCheck: vi.fn(),
2936
};
3037

apps/web/src/components/editor/ai-partner/__tests__/lock-hint.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ vi.mock("@/lib/ai/use-ai-partner", () => ({
99
useAiPartner: () => ({
1010
messages: [],
1111
freeHintsUsed: 0,
12-
paidUsed: 0,
12+
counts: { free: 0, metered: 0, socratic: 0 },
13+
tier: "free",
1314
budgetExhausted: false,
1415
spendCapped: false,
16+
resetState: "none",
17+
resetAvailableAt: null,
1518
loading: false,
1619
error: null,
1720
requestHint: vi.fn(),
1821
review: vi.fn(),
22+
requestReset: vi.fn(),
1923
verifyCheck: vi.fn(),
2024
}),
2125
}));

0 commit comments

Comments
 (0)