@@ -4,8 +4,8 @@ import { createClient } from "@/lib/supabase/server";
44import { isRateLimited , getClientIp } from "@/lib/rate-limit" ;
55import { getLessonBySlug } from "@/lib/content/queries" ;
66import {
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" } ,
0 commit comments