File size: 4,573 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import { apiPost } from "@/lib/api/client";
import { shouldUseDemoData } from "@/lib/api/mode";
import {
  deterministicTuitionPlan,
  type TuitionBrainMission,
  type TuitionBrainPlan,
  type TuitionBrainProfileInput,
} from "@/components/tuition/chapter-dashboard/tuitionBrainPlan";

/**
 * The real tuition brain client.
 *
 * `planTodaysTuition` is what the UI calls: it asks the backend LLM to reason
 * over the student's profile + their REAL curated missions (restate what they
 * need, pick today's focus, why), and falls back to the deterministic plan when
 * logged out or the backend/LLM is unavailable. Either way the recommendation
 * is always a real mission — the backend validates the LLM pick against the
 * candidates, and the fallback picks from them directly.
 */

type BrainMissionWire = {
  chapter_id: string;
  chapter_title: string;
  mission_id: string;
  mission_title: string;
  completed: boolean;
  importance: number;
};

type BrainPlanWire = {
  restatement: string;
  focus_reason: string;
  recommended_chapter_id: string | null;
  recommended_mission_id: string | null;
  study_tip: string;
  grounded: boolean;
  is_fallback: boolean;
};

type BrainRequestWire = {
  profile: {
    class_level: string;
    board: string;
    subject: string;
    goal: string | null;
    daily_minutes: number | null;
    exam_date: string | null;
    weak_topics: string[];
    low_mastery_topics: string[];
    recent_mistakes: string[];
    unfinished_mission_keys: string[];
    revision_due_mission_keys: string[];
    days_until_exam: number | null;
    language: string;
  };
  candidate_missions: BrainMissionWire[];
};

function toWireMissions(missions: TuitionBrainMission[]): BrainMissionWire[] {
  return missions.slice(0, 80).map((m) => ({
    chapter_id: m.chapterId,
    chapter_title: m.chapterTitle,
    mission_id: m.missionId,
    mission_title: m.missionTitle,
    completed: m.completed,
    importance: m.importance ?? 1,
  }));
}

function fromWirePlan(wire: BrainPlanWire): TuitionBrainPlan {
  return {
    restatement: wire.restatement,
    focusReason: wire.focus_reason,
    recommendedChapterId: wire.recommended_chapter_id,
    recommendedMissionId: wire.recommended_mission_id,
    studyTip: wire.study_tip,
    grounded: wire.grounded,
    isFallback: wire.is_fallback,
  };
}

/**
 * Best-effort remote call. Returns null on failure (backend down, offline,
 * over budget) so the caller falls back to the deterministic plan.
 *
 * No session gate: the /tuition-brain/plan endpoint serves anonymous beta
 * students too (bounded by the backend's daily provider budget), so the
 * login-less beta flow can still reach the real brain.
 */
export async function requestTuitionPlanRemote(
  profile: TuitionBrainProfileInput,
  missions: TuitionBrainMission[],
): Promise<TuitionBrainPlan | null> {
  if (shouldUseDemoData()) return null;
  try {
    const body: BrainRequestWire = {
      profile: {
        class_level: profile.classLevel,
        board: profile.board,
        subject: profile.subject,
        goal: profile.goal ?? null,
        daily_minutes: profile.dailyMinutes ?? null,
        exam_date: profile.examDate ?? null,
        weak_topics: profile.weakTopics,
        low_mastery_topics: profile.lowMasteryTopics ?? [],
        recent_mistakes: profile.recentMistakes ?? [],
        unfinished_mission_keys: profile.unfinishedMissionKeys ?? [],
        revision_due_mission_keys: profile.revisionDueMissionKeys ?? [],
        days_until_exam: profile.daysUntilExam ?? null,
        language: profile.language ?? "English",
      },
      candidate_missions: toWireMissions(missions),
    };
    const wire = await apiPost<BrainPlanWire, BrainRequestWire>("/tuition-brain/plan", body);
    // Guard: never trust a recommendation that isn't one of the missions we sent.
    if (wire.recommended_mission_id) {
      const isReal = missions.some(
        (m) => m.chapterId === wire.recommended_chapter_id && m.missionId === wire.recommended_mission_id,
      );
      if (!isReal) return null;
    }
    return fromWirePlan(wire);
  } catch {
    return null;
  }
}

/** The UI entry point: real brain when possible, deterministic otherwise. */
export async function planTodaysTuition(
  profile: TuitionBrainProfileInput,
  missions: TuitionBrainMission[],
): Promise<TuitionBrainPlan> {
  const remote = await requestTuitionPlanRemote(profile, missions);
  return remote ?? deterministicTuitionPlan(profile, missions);
}

export type { TuitionBrainMission, TuitionBrainPlan, TuitionBrainProfileInput };