From e11177e21296be6d02290f8e43af39cf3485f519 Mon Sep 17 00:00:00 2001 From: Rudra-clrscr Date: Thu, 16 Jul 2026 02:20:05 +0530 Subject: [PATCH 1/2] fix(ai): dedupe lib/ai.ts and restore lost embedding helper A bad merge (af4c4d9) duplicated the ai tutor/chat/quiz/flashcards/ concept-map/study-plan/guide function block twice in lib/ai.ts (once around the topic-analyzer addition, once around the essay-grader addition) and dropped the embedding-generation helper entirely, breaking the build for every route that imports from this file. Also migrate the document-upload route off pdf-parse's old v1 function api to v2's class-based api, since package.json already points at pdf-parse ^2.4.5 but the import path (pdf-parse/lib/pdf-parse) and call site were still v1-shaped and unresolvable. --- app/api/documents/upload/route.ts | 6 +- lib/ai.ts | 2777 +++++++++++------------------ 2 files changed, 1089 insertions(+), 1694 deletions(-) diff --git a/app/api/documents/upload/route.ts b/app/api/documents/upload/route.ts index 92be12a..b39fbc7 100644 --- a/app/api/documents/upload/route.ts +++ b/app/api/documents/upload/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { createServerClient } from "@supabase/ssr" -import pdfParse from "pdf-parse/lib/pdf-parse" +import { PDFParse } from "pdf-parse" import { generateEmbedding } from "@/lib/ai" export const maxDuration = 60 @@ -50,8 +50,10 @@ export async function POST(req: NextRequest) { // 2. Parse PDF Text const arrayBuffer = await file.arrayBuffer() const buffer = Buffer.from(arrayBuffer) - const pdfData = await pdfParse(buffer) + const parser = new PDFParse({ data: buffer }) + const pdfData = await parser.getText() const text = pdfData.text + await parser.destroy() // 3. Create document record in DB const { data: doc, error: docError } = await supabase diff --git a/lib/ai.ts b/lib/ai.ts index 6b1a102..849a475 100644 --- a/lib/ai.ts +++ b/lib/ai.ts @@ -1,1692 +1,1085 @@ -// // ── AI Backend: Groq (free, fast, no daily quota limits) ───────────────────── -// // Get your free API key at: https://console.groq.com -// // Set GROQ_API_KEY in Vercel environment variables - -// async function callGroq(prompt: string): Promise { -// const key = process.env.GROQ_API_KEY -// if (!key) { -// throw new Error("GROQ_API_KEY is not set. Get a free key at console.groq.com and add it to Vercel environment variables.") -// } - -// const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { -// method: "POST", -// headers: { -// "Content-Type": "application/json", -// "Authorization": `Bearer ${key}`, -// }, -// body: JSON.stringify({ -// model: "llama-3.3-70b-versatile", // Free, very capable model on Groq -// messages: [{ role: "user", content: prompt }], -// temperature: 0.7, -// max_tokens: 2048, -// }), -// }) - -// if (!res.ok) { -// const err = await res.text() -// throw new Error(`AI error ${res.status}: ${err}`) -// } - -// const data = await res.json() -// const text = data?.choices?.[0]?.message?.content -// if (!text) throw new Error("Empty response from AI") -// return text -// } - -// // ── AI Tutor / Chat ────────────────────────────────────────────────────────── - -// export interface GenerateAIResponseOptions { -// mode?: "chat" | "web_search" -// webContext?: string -// attachmentContext?: string -// } - -// export async function generateAIResponse( -// message: string, -// options: GenerateAIResponseOptions = {} -// ): Promise { -// const extraSections = [ -// options.mode === "web_search" && options.webContext -// ? `Use these web search notes to answer accurately and cite the source names naturally when useful: -// ${options.webContext}` -// : "", -// options.attachmentContext -// ? `The user uploaded these files: -// ${options.attachmentContext} -// Use them as context when relevant.` -// : "", -// ] -// .filter(Boolean) -// .join("\n\n") - -// const prompt = `You are EduPilot, an intelligent AI tutor and study assistant. -// Help students learn effectively. Be clear, educational, and encouraging. -// Format your answers with clear sections when needed. Answer step-by-step when explaining concepts. - -// ${extraSections ? `${extraSections}\n\n` : ""}Student question: ${message} - -// Answer:` - -// return callGroq(prompt) -// } - -// // ── Quiz ───────────────────────────────────────────────────────────────────── - -// export interface QuizQuestion { -// question: string -// options: string[] -// answer: string -// explanation: string -// } - -// export async function generateQuiz(topic: string, count = 5): Promise { -// const prompt = `Generate exactly ${count} multiple-choice quiz questions about: "${topic}" - -// Return ONLY a valid JSON array. No markdown, no backticks, no explanation before or after: -// [ -// { -// "question": "Question text here?", -// "options": ["Option A", "Option B", "Option C", "Option D"], -// "answer": "Option A", -// "explanation": "Brief explanation of why this answer is correct." -// } -// ] - -// Requirements: -// - Each question must have exactly 4 options -// - The "answer" value must be the EXACT text of one of the options -// - Questions must be accurate and educational -// - Vary difficulty levels` - -// const raw = await callGroq(prompt) -// const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim() - -// try { -// const parsed = JSON.parse(cleaned) -// if (!Array.isArray(parsed)) throw new Error("Response is not an array") -// return parsed.slice(0, count).map((q: QuizQuestion) => ({ -// question: String(q.question || "Question"), -// options: Array.isArray(q.options) && q.options.length >= 2 -// ? q.options.slice(0, 4).map(String) -// : ["True", "False", "Maybe", "None of the above"], -// answer: String(q.answer || q.options?.[0] || ""), -// explanation: String(q.explanation || ""), -// })) -// } catch { -// throw new Error("AI returned invalid quiz format. Please try again.") -// } -// } - -// // ── Flashcards ─────────────────────────────────────────────────────────────── - -// export interface Flashcard { -// front: string -// back: string -// } - -// export async function generateFlashcards(topic: string, count = 5): Promise { -// const prompt = `Create exactly ${count} educational flashcards about: "${topic}" - -// Return ONLY a valid JSON array. No markdown, no backticks, no explanation: -// [ -// { -// "front": "Question or key term", -// "back": "Clear, concise answer or definition" -// } -// ] - -// Requirements: -// - Cover the most important concepts -// - Keep fronts as questions or key terms -// - Keep backs as concise, memorable answers` - -// const raw = await callGroq(prompt) -// const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim() - -// try { -// const parsed = JSON.parse(cleaned) -// if (!Array.isArray(parsed)) throw new Error("Response is not an array") -// return parsed.slice(0, count).map((f: Flashcard) => ({ -// front: String(f.front || "Front"), -// back: String(f.back || "Back"), -// })) -// } catch { -// throw new Error("AI returned invalid flashcard format. Please try again.") -// } -// } - -// // ── Study Plan ─────────────────────────────────────────────────────────────── - -// export async function generateStudyPlan(subject: string, duration: string, goal: string): Promise { -// const prompt = `Create a detailed, structured study plan: -// Subject: ${subject} -// Duration: ${duration} -// Goal: ${goal} - -// Include: weekly schedule, key topics to cover, study methods, resources, milestones, and success tips. -// Format clearly with headings and bullet points.` -// return callGroq(prompt) -// } -import { GoogleGenerativeAI } from "@google/generative-ai" - -// ========================= -// Shared helpers -// ========================= - -function getGroqKey() { - const key = process.env.GROQ_API_KEY?.trim() - if (!key) { - throw new Error("GROQ_API_KEY is not set") - } - return key -} - -function getGeminiKey() { - const key = process.env.GEMINI_API_KEY?.trim() - if (!key) { - throw new Error("GEMINI_API_KEY is not set") - } - return key -} - -function cleanJsonText(raw: string) { - const cleaned = raw - .replace(/```json\s*/gi, "") - .replace(/```/g, "") - .trim() - - if ( - (cleaned.startsWith("[") && cleaned.endsWith("]")) || - (cleaned.startsWith("{") && cleaned.endsWith("}")) - ) { - return cleaned - } - - const firstArray = cleaned.indexOf("[") - const lastArray = cleaned.lastIndexOf("]") - if (firstArray !== -1 && lastArray !== -1 && lastArray > firstArray) { - return cleaned.slice(firstArray, lastArray + 1) - } - - const firstObject = cleaned.indexOf("{") - const lastObject = cleaned.lastIndexOf("}") - if (firstObject !== -1 && lastObject !== -1 && lastObject > firstObject) { - return cleaned.slice(firstObject, lastObject + 1) - } - - return cleaned -} - -async function callGroq(prompt: string): Promise { - const key = getGroqKey() - - const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${key}`, - }, - body: JSON.stringify({ - model: "llama-3.3-70b-versatile", - messages: [ - { - role: "system", - content: - "You are EduPilot AI. Follow the user's output format exactly. If asked for JSON, return only valid JSON.", - }, - { - role: "user", - content: prompt, - }, - ], - temperature: 0.3, - max_tokens: 2048, - }), - }) - - const data = await res.json().catch(() => ({})) - - if (!res.ok) { - const message = - (data as { error?: { message?: string } })?.error?.message || - JSON.stringify(data) || - "Groq request failed" - throw new Error(message) - } - - const text = - (data as { choices?: Array<{ message?: { content?: string } }> })?.choices?.[0]?.message?.content?.trim() || - "" - - if (!text) { - throw new Error("Empty response from Groq") - } - - return text -} - -async function callGemini(prompt: string): Promise { - const key = getGeminiKey() - const client = new GoogleGenerativeAI(key) - - const modelNames = ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-flash-latest"] - - let lastError = "Gemini failed" - - for (const modelName of modelNames) { - try { - const model = client.getGenerativeModel({ - model: modelName, - generationConfig: { - temperature: 0.3, - responseMimeType: "application/json", - }, - }) - - const result = await model.generateContent(prompt) - const text = result.response.text().trim() - - if (!text) { - lastError = `Empty response from ${modelName}` - continue - } - - return text - } catch (error) { - lastError = error instanceof Error ? error.message : "Gemini request failed" - } - } - - throw new Error(lastError) -} - -async function callAIWithFallback(prompt: string): Promise { - const errors: string[] = [] - - try { - return await callGroq(prompt) - } catch (error) { - errors.push(`Groq: ${error instanceof Error ? error.message : "Unknown error"}`) - } - - try { - return await callGemini(prompt) - } catch (error) { - errors.push(`Gemini: ${error instanceof Error ? error.message : "Unknown error"}`) - } - - throw new Error(errors.join(" | ")) -} - -// ========================= -// AI Tutor / Chat -// ========================= - -export interface GenerateAIResponseOptions { - mode?: "chat" | "web_search" - webContext?: string - attachmentContext?: string -} - -export async function generateAIResponse( - message: string, - options: GenerateAIResponseOptions = {} -): Promise { - const extraSections = [ - options.mode === "web_search" && options.webContext - ? `Use these web search notes to answer accurately and cite source names naturally when useful: -${options.webContext}` - : "", - options.attachmentContext - ? `The user uploaded these files: -${options.attachmentContext} -Use them as context when relevant.` - : "", - ] - .filter(Boolean) - .join("\n\n") - - const prompt = `You are EduPilot, an intelligent AI tutor and study assistant. -Help students learn effectively. Be clear, educational, and encouraging. -Format answers with clear sections where useful. - -${extraSections ? `${extraSections}\n\n` : ""}Student question: ${message} - -Answer:` - - return callAIWithFallback(prompt) -} - -export type ExplainStyle = "simpler" | "analogy" | "step-by-step" | "real-world" - -export const EXPLAIN_STYLE_LABELS: Record = { - simpler: "Simpler (ELI5)", - analogy: "Use an analogy", - "step-by-step": "Step-by-step", - "real-world": "Real-world example", -} - -const EXPLAIN_STYLE_INSTRUCTIONS: Record = { - simpler: - "Explain it in the simplest possible terms, as if to someone with no background in the subject. Avoid jargon, use short sentences.", - analogy: - "Explain it using a clear, relatable analogy or metaphor that makes the concept easy to visualize and remember.", - "step-by-step": - "Break it down into clear, numbered steps that build up to the full explanation, one idea at a time.", - "real-world": - "Explain it using a concrete real-world example or practical scenario where this concept actually applies.", -} - -export async function generateAlternateExplanation( - question: string, - previousAnswer: string, - style: ExplainStyle -): Promise { - const prompt = `You are EduPilot, an AI tutor. A student asked the following question: -"${question}" - -They already received this explanation but found it hard to understand: -"${previousAnswer}" - -Re-explain the answer to the same question in a different way. ${EXPLAIN_STYLE_INSTRUCTIONS[style]} - -Stay accurate and stay focused on the original question. Do not just repeat the previous explanation in different words — genuinely change the approach.` - - return callAIWithFallback(prompt) -} - -// ========================= -// Quiz -// ========================= - -export interface QuizQuestion { - question: string - options: string[] - answer: string - explanation: string -} - -function normalizeQuizItem(item: unknown, index: number): QuizQuestion { - const raw = (item || {}) as { - question?: unknown - options?: unknown - answer?: unknown - explanation?: unknown - } - - let options = Array.isArray(raw.options) - ? raw.options.map((option) => String(option).trim()).filter(Boolean) - : [] - - if (options.length < 4) { - options = [...options, "Option B", "Option C", "Option D"].slice(0, 4) - } else { - options = options.slice(0, 4) - } - - let answer = String(raw.answer || "").trim() - if (!answer || !options.some((option) => option.toLowerCase() === answer.toLowerCase())) { - answer = options[0] - } - - return { - question: String(raw.question || `Question ${index + 1}`).trim(), - options, - answer, - explanation: String(raw.explanation || "").trim(), - } -} - -export type QuizDifficulty = "easy" | "medium" | "hard" - -const QUIZ_DIFFICULTY_GUIDANCE: Record = { - easy: "Keep questions at a beginner level: basic recall and simple definitions.", - medium: "Keep questions at an intermediate level: application of concepts, not just recall.", - hard: "Keep questions at an advanced level: multi-step reasoning, edge cases, and nuanced distinctions.", -} - -export async function generateQuiz( - topic: string, - count = 5, - difficulty: QuizDifficulty = "medium" -): Promise { - const prompt = `Generate exactly ${count} multiple-choice quiz questions about "${topic}". - -Difficulty: ${difficulty}. ${QUIZ_DIFFICULTY_GUIDANCE[difficulty]} - -Return ONLY valid JSON array in this exact structure: -[ - { - "question": "Question text?", - "options": ["Option A", "Option B", "Option C", "Option D"], - "answer": "Option A", - "explanation": "Brief explanation" - } -] - -Rules: -- Return only JSON -- No markdown -- No backticks -- Exactly ${count} questions -- Exactly 4 options per question -- "answer" must exactly match one option -- Questions should be educational and accurate -- Keep explanations short` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - - if (!Array.isArray(parsed)) { - throw new Error("Quiz response is not an array") - } - - const normalized = parsed.slice(0, count).map((item, index) => normalizeQuizItem(item, index)) - - if (!normalized.length) { - throw new Error("No quiz questions generated") - } - - return normalized - } catch (error) { - console.error("[generateQuiz] Invalid AI JSON:", raw) - throw new Error("AI returned invalid quiz format. Please try again.") - } -} - -export async function generateQuizFromContent( - title: string, - content: string, - count = 5, - difficulty: QuizDifficulty = "medium" -): Promise { - const prompt = `Read the study material below (titled "${title}") and generate exactly ${count} multiple-choice quiz questions that test understanding of it. - -Difficulty: ${difficulty}. ${QUIZ_DIFFICULTY_GUIDANCE[difficulty]} - -Study material: -${content.slice(0, 12000)} - -Return ONLY valid JSON array in this exact structure: -[ - { - "question": "Question text?", - "options": ["Option A", "Option B", "Option C", "Option D"], - "answer": "Option A", - "explanation": "Brief explanation" - } -] - -Rules: -- Base every question strictly on the material above -- Return only JSON -- No markdown -- No backticks -- Exactly ${count} questions -- Exactly 4 options per question -- "answer" must exactly match one option -- Keep explanations short` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - - if (!Array.isArray(parsed)) { - throw new Error("Quiz response is not an array") - } - - const normalized = parsed.slice(0, count).map((item, index) => normalizeQuizItem(item, index)) - - if (!normalized.length) { - throw new Error("No quiz questions generated") - } - - return normalized - } catch (error) { - console.error("[generateQuizFromContent] Invalid AI JSON:", raw) - throw new Error("AI returned invalid quiz format. Please try again.") - } -} - -// ========================= -// Flashcards -// ========================= - -export interface Flashcard { - front: string - back: string -} - -export async function generateFlashcards(topic: string, count = 5): Promise { - const prompt = `Create exactly ${count} educational flashcards about "${topic}". - -Return ONLY valid JSON array: -[ - { - "front": "Question or key term", - "back": "Clear answer or definition" - } -] - -Rules: -- Return only JSON -- No markdown -- No backticks` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - if (!Array.isArray(parsed)) throw new Error("Response is not an array") - - return parsed.slice(0, count).map((item: { front?: unknown; back?: unknown }) => ({ - front: String(item.front || "Front"), - back: String(item.back || "Back"), - })) - } catch { - throw new Error("AI returned invalid flashcard format. Please try again.") - } -} - -export async function generateFlashcardsFromContent(content: string, count = 10): Promise { - const prompt = `Read the study material below and create exactly ${count} educational flashcards that test the key concepts, facts, and definitions found in it. - -Study material: -${content.slice(0, 12000)} - -Return ONLY valid JSON array: -[ - { - "front": "Question or key term", - "back": "Clear answer or definition" - } -] - -Rules: -- Base every flashcard strictly on the material above -- Return only JSON -- No markdown -- No backticks` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - if (!Array.isArray(parsed)) throw new Error("Response is not an array") - - return parsed.slice(0, count).map((item: { front?: unknown; back?: unknown }) => ({ - front: String(item.front || "Front"), - back: String(item.back || "Back"), - })) - } catch { - throw new Error("AI returned invalid flashcard format. Please try again.") - } -} - -// ========================= -// Concept Map -// ========================= - -export interface ConceptMapNode { - id: string - label: string - excerpt: string -} - -export interface ConceptMapEdge { - source: string - target: string - label?: string -} - -export interface ConceptMapGraph { - nodes: ConceptMapNode[] - edges: ConceptMapEdge[] -} - -export async function generateConceptMap(title: string, content: string): Promise { - const prompt = `Read the study material below (titled "${title}") and extract the key concepts and how they relate to one another, as a concept map. - -Study material: -${content.slice(0, 12000)} - -Return ONLY valid JSON in this exact shape: -{ - "nodes": [ - { "id": "short-slug", "label": "Concept name", "excerpt": "1-2 sentence explanation of this concept, grounded in the material" } - ], - "edges": [ - { "source": "node-id", "target": "node-id", "label": "short relationship, e.g. 'leads to', 'is a type of'" } - ] -} - -Rules: -- Extract between 6 and 14 of the most important concepts -- Every edge's source and target must reference an id present in nodes -- Build a connected graph: every node should have at least one edge -- ids must be short kebab-case slugs, unique -- Return only JSON, no markdown, no backticks` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) as { - nodes?: Array<{ id?: unknown; label?: unknown; excerpt?: unknown }> - edges?: Array<{ source?: unknown; target?: unknown; label?: unknown }> - } - - const nodes: ConceptMapNode[] = (parsed.nodes || []) - .filter((node) => node?.id && node?.label) - .map((node) => ({ - id: String(node.id), - label: String(node.label), - excerpt: String(node.excerpt || ""), - })) - - if (!nodes.length) throw new Error("No concepts extracted") - - const nodeIds = new Set(nodes.map((node) => node.id)) - - const edges: ConceptMapEdge[] = (parsed.edges || []) - .filter((edge) => nodeIds.has(String(edge?.source)) && nodeIds.has(String(edge?.target))) - .map((edge) => ({ - source: String(edge.source), - target: String(edge.target), - label: edge.label ? String(edge.label) : undefined, - })) - - return { nodes, edges } - } catch { - throw new Error("AI returned invalid concept map format. Please try again.") - } -} - -// ========================= -// Study Plan -// ========================= - -export async function generateStudyPlan(subject: string, duration: string, goal: string): Promise { - const prompt = `Create a detailed, structured study plan. - -Subject: ${subject} -Duration: ${duration} -Goal: ${goal} - -Include: -- weekly schedule -- key topics -- study methods -- resources -- milestones -- success tips - -Format clearly with headings and bullet points.` - - return callAIWithFallback(prompt) -} - -export async function generateEduPilotGuideResponse(message: string): Promise { - const prompt = `You are EduPilot Guide, an in-app help assistant for the EduPilot platform. - -Your role: -- Answer ONLY EduPilot-related questions -- Help users understand how to use app features -- Explain clearly in a guided way -- Give practical steps users can follow inside the app -- Suggest the correct EduPilot feature when useful - -EduPilot areas you can explain: -- AI Tutor -- Notes -- Flashcards -- AI Voice -- Quiz -- Planner -- Dashboard -- Profile -- Pricing / plans -- Login / account / settings -- Help Center - -Strict rules: -- If the user asks a non-EduPilot question, politely refuse and say you only help with EduPilot usage -- Do NOT provide general study answers or general knowledge -- Do NOT act like the main AI Tutor -- Keep answers app-focused, helpful, and concise -- Always format the answer in a clean step-by-step way - -Response format rules: -- Start with 1 short intro sentence -- Then add: -Step 1: ... -Step 2: ... -Step 3: ... -- If relevant, add: -Tips: -- ... -- ... -- End with: -Try this next: ... - -User question: -${message} - -Answer as EduPilot Guide:` - - return callAIWithFallback(prompt) -} - -// ========================= -// Topic Difficulty Analyzer -// ========================= - -export interface TopicAnalysisResult { - difficulty: "Beginner" | "Intermediate" | "Advanced" - estimatedHours: string - confidence: number - summary: string - studyOrder: string[] - prerequisites: string[] - relatedConcepts: string[] - revisionSessions: number - tips: string[] -} - -export async function analyzeTopic(topic: string): Promise { - const prompt = `Analyze the complexity, requirements, and learning path for the academic/technical topic: "${topic}". - -Return ONLY a valid JSON object. Do not include any markdown, code blocks, backticks, or explanatory text before or after the JSON. -The JSON object must have exactly the following structure: -{ - "difficulty": "Beginner" | "Intermediate" | "Advanced", - "estimatedHours": "e.g., 10-14", - "confidence": 92, - "summary": "a short AI summary of what this topic is and its learning curve (2-3 sentences)", - "studyOrder": ["Step 1 explanation", "Step 2 explanation", ...], - "prerequisites": ["Prerequisite 1", "Prerequisite 2", ...], - "relatedConcepts": ["Concept 1", "Concept 2", ...], - "revisionSessions": 4, - "tips": ["Tip 1", "Tip 2", ...] -} - -Ensure the values are realistic and highly helpful for a student studying this topic.` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - - // Validate difficulty - let difficulty: "Beginner" | "Intermediate" | "Advanced" = "Intermediate" - const parsedDiff = String(parsed.difficulty || "").trim().toLowerCase() - if (parsedDiff === "beginner" || parsedDiff === "intermediate" || parsedDiff === "advanced") { - difficulty = (parsedDiff.charAt(0).toUpperCase() + parsedDiff.slice(1)) as "Beginner" | "Intermediate" | "Advanced" - } - - return { - difficulty, - estimatedHours: String(parsed.estimatedHours || "10-15").trim(), - confidence: Math.min(100, Math.max(0, Number(parsed.confidence) || 85)), - summary: String(parsed.summary || `A study profile for ${topic}.`).trim(), - studyOrder: Array.isArray(parsed.studyOrder) ? parsed.studyOrder.map(String).filter(Boolean) : [], - prerequisites: Array.isArray(parsed.prerequisites) ? parsed.prerequisites.map(String).filter(Boolean) : [], - relatedConcepts: Array.isArray(parsed.relatedConcepts) ? parsed.relatedConcepts.map(String).filter(Boolean) : [], - revisionSessions: Math.max(1, Number(parsed.revisionSessions) || 3), - tips: Array.isArray(parsed.tips) ? parsed.tips.map(String).filter(Boolean) : [], - } - } catch (error) { - console.error("[analyzeTopic] Malformed JSON from AI:", raw) - throw new Error("AI returned an invalid response format. Please try again.") - } -} -// // ── AI Backend: Groq (free, fast, no daily quota limits) ───────────────────── -// // Get your free API key at: https://console.groq.com -// // Set GROQ_API_KEY in Vercel environment variables - -// async function callGroq(prompt: string): Promise { -// const key = process.env.GROQ_API_KEY -// if (!key) { -// throw new Error("GROQ_API_KEY is not set. Get a free key at console.groq.com and add it to Vercel environment variables.") -// } - -// const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { -// method: "POST", -// headers: { -// "Content-Type": "application/json", -// "Authorization": `Bearer ${key}`, -// }, -// body: JSON.stringify({ -// model: "llama-3.3-70b-versatile", // Free, very capable model on Groq -// messages: [{ role: "user", content: prompt }], -// temperature: 0.7, -// max_tokens: 2048, -// }), -// }) - -// if (!res.ok) { -// const err = await res.text() -// throw new Error(`AI error ${res.status}: ${err}`) -// } - -// const data = await res.json() -// const text = data?.choices?.[0]?.message?.content -// if (!text) throw new Error("Empty response from AI") -// return text -// } - -// // ── AI Tutor / Chat ────────────────────────────────────────────────────────── - -// export interface GenerateAIResponseOptions { -// mode?: "chat" | "web_search" -// webContext?: string -// attachmentContext?: string -// } - -// export async function generateAIResponse( -// message: string, -// options: GenerateAIResponseOptions = {} -// ): Promise { -// const extraSections = [ -// options.mode === "web_search" && options.webContext -// ? `Use these web search notes to answer accurately and cite the source names naturally when useful: -// ${options.webContext}` -// : "", -// options.attachmentContext -// ? `The user uploaded these files: -// ${options.attachmentContext} -// Use them as context when relevant.` -// : "", -// ] -// .filter(Boolean) -// .join("\n\n") - -// const prompt = `You are EduPilot, an intelligent AI tutor and study assistant. -// Help students learn effectively. Be clear, educational, and encouraging. -// Format your answers with clear sections when needed. Answer step-by-step when explaining concepts. - -// ${extraSections ? `${extraSections}\n\n` : ""}Student question: ${message} - -// Answer:` - -// return callGroq(prompt) -// } - -// // ── Quiz ───────────────────────────────────────────────────────────────────── - -// export interface QuizQuestion { -// question: string -// options: string[] -// answer: string -// explanation: string -// } - -// export async function generateQuiz(topic: string, count = 5): Promise { -// const prompt = `Generate exactly ${count} multiple-choice quiz questions about: "${topic}" - -// Return ONLY a valid JSON array. No markdown, no backticks, no explanation before or after: -// [ -// { -// "question": "Question text here?", -// "options": ["Option A", "Option B", "Option C", "Option D"], -// "answer": "Option A", -// "explanation": "Brief explanation of why this answer is correct." -// } -// ] - -// Requirements: -// - Each question must have exactly 4 options -// - The "answer" value must be the EXACT text of one of the options -// - Questions must be accurate and educational -// - Vary difficulty levels` - -// const raw = await callGroq(prompt) -// const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim() - -// try { -// const parsed = JSON.parse(cleaned) -// if (!Array.isArray(parsed)) throw new Error("Response is not an array") -// return parsed.slice(0, count).map((q: QuizQuestion) => ({ -// question: String(q.question || "Question"), -// options: Array.isArray(q.options) && q.options.length >= 2 -// ? q.options.slice(0, 4).map(String) -// : ["True", "False", "Maybe", "None of the above"], -// answer: String(q.answer || q.options?.[0] || ""), -// explanation: String(q.explanation || ""), -// })) -// } catch { -// throw new Error("AI returned invalid quiz format. Please try again.") -// } -// } - -// // ── Flashcards ─────────────────────────────────────────────────────────────── - -// export interface Flashcard { -// front: string -// back: string -// } - -// export async function generateFlashcards(topic: string, count = 5): Promise { -// const prompt = `Create exactly ${count} educational flashcards about: "${topic}" - -// Return ONLY a valid JSON array. No markdown, no backticks, no explanation: -// [ -// { -// "front": "Question or key term", -// "back": "Clear, concise answer or definition" -// } -// ] - -// Requirements: -// - Cover the most important concepts -// - Keep fronts as questions or key terms -// - Keep backs as concise, memorable answers` - -// const raw = await callGroq(prompt) -// const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim() - -// try { -// const parsed = JSON.parse(cleaned) -// if (!Array.isArray(parsed)) throw new Error("Response is not an array") -// return parsed.slice(0, count).map((f: Flashcard) => ({ -// front: String(f.front || "Front"), -// back: String(f.back || "Back"), -// })) -// } catch { -// throw new Error("AI returned invalid flashcard format. Please try again.") -// } -// } - -// // ── Study Plan ─────────────────────────────────────────────────────────────── - -// export async function generateStudyPlan(subject: string, duration: string, goal: string): Promise { -// const prompt = `Create a detailed, structured study plan: -// Subject: ${subject} -// Duration: ${duration} -// Goal: ${goal} - -// Include: weekly schedule, key topics to cover, study methods, resources, milestones, and success tips. -// Format clearly with headings and bullet points.` -// return callGroq(prompt) -// } -import { GoogleGenerativeAI } from "@google/generative-ai" - -// ========================= -// Shared helpers -// ========================= - -function getGroqKey() { - const key = process.env.GROQ_API_KEY?.trim() - if (!key) { - throw new Error("GROQ_API_KEY is not set") - } - return key -} - -function getGeminiKey() { - const key = process.env.GEMINI_API_KEY?.trim() - if (!key) { - throw new Error("GEMINI_API_KEY is not set") - } - return key -} - -function cleanJsonText(raw: string) { - const cleaned = raw - .replace(/```json\s*/gi, "") - .replace(/```/g, "") - .trim() - - if ( - (cleaned.startsWith("[") && cleaned.endsWith("]")) || - (cleaned.startsWith("{") && cleaned.endsWith("}")) - ) { - return cleaned - } - - const firstArray = cleaned.indexOf("[") - const lastArray = cleaned.lastIndexOf("]") - if (firstArray !== -1 && lastArray !== -1 && lastArray > firstArray) { - return cleaned.slice(firstArray, lastArray + 1) - } - - const firstObject = cleaned.indexOf("{") - const lastObject = cleaned.lastIndexOf("}") - if (firstObject !== -1 && lastObject !== -1 && lastObject > firstObject) { - return cleaned.slice(firstObject, lastObject + 1) - } - - return cleaned -} - -async function callGroq(prompt: string): Promise { - const key = getGroqKey() - - const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${key}`, - }, - body: JSON.stringify({ - model: "llama-3.3-70b-versatile", - messages: [ - { - role: "system", - content: - "You are EduPilot AI. Follow the user's output format exactly. If asked for JSON, return only valid JSON.", - }, - { - role: "user", - content: prompt, - }, - ], - temperature: 0.3, - max_tokens: 2048, - }), - }) - - const data = await res.json().catch(() => ({})) - - if (!res.ok) { - const message = - (data as { error?: { message?: string } })?.error?.message || - JSON.stringify(data) || - "Groq request failed" - throw new Error(message) - } - - const text = - (data as { choices?: Array<{ message?: { content?: string } }> })?.choices?.[0]?.message?.content?.trim() || - "" - - if (!text) { - throw new Error("Empty response from Groq") - } - - return text -} - -async function callGemini(prompt: string): Promise { - const key = getGeminiKey() - const client = new GoogleGenerativeAI(key) - - const modelNames = ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-flash-latest"] - - let lastError = "Gemini failed" - - for (const modelName of modelNames) { - try { - const model = client.getGenerativeModel({ - model: modelName, - generationConfig: { - temperature: 0.3, - responseMimeType: "application/json", - }, - }) - - const result = await model.generateContent(prompt) - const text = result.response.text().trim() - - if (!text) { - lastError = `Empty response from ${modelName}` - continue - } - - return text - } catch (error) { - lastError = error instanceof Error ? error.message : "Gemini request failed" - } - } - - throw new Error(lastError) -} - -async function callAIWithFallback(prompt: string): Promise { - const errors: string[] = [] - - try { - return await callGroq(prompt) - } catch (error) { - errors.push(`Groq: ${error instanceof Error ? error.message : "Unknown error"}`) - } - - try { - return await callGemini(prompt) - } catch (error) { - errors.push(`Gemini: ${error instanceof Error ? error.message : "Unknown error"}`) - } - - throw new Error(errors.join(" | ")) -} - -// ========================= -// AI Tutor / Chat -// ========================= - -export interface GenerateAIResponseOptions { - mode?: "chat" | "web_search" - webContext?: string - attachmentContext?: string -} - -export async function generateAIResponse( - message: string, - options: GenerateAIResponseOptions = {} -): Promise { - const extraSections = [ - options.mode === "web_search" && options.webContext - ? `Use these web search notes to answer accurately and cite source names naturally when useful: -${options.webContext}` - : "", - options.attachmentContext - ? `The user uploaded these files: -${options.attachmentContext} -Use them as context when relevant.` - : "", - ] - .filter(Boolean) - .join("\n\n") - - const prompt = `You are EduPilot, an intelligent AI tutor and study assistant. -Help students learn effectively. Be clear, educational, and encouraging. -Format answers with clear sections where useful. - -${extraSections ? `${extraSections}\n\n` : ""}Student question: ${message} - -Answer:` - - return callAIWithFallback(prompt) -} - -export type ExplainStyle = "simpler" | "analogy" | "step-by-step" | "real-world" - -export const EXPLAIN_STYLE_LABELS: Record = { - simpler: "Simpler (ELI5)", - analogy: "Use an analogy", - "step-by-step": "Step-by-step", - "real-world": "Real-world example", -} - -const EXPLAIN_STYLE_INSTRUCTIONS: Record = { - simpler: - "Explain it in the simplest possible terms, as if to someone with no background in the subject. Avoid jargon, use short sentences.", - analogy: - "Explain it using a clear, relatable analogy or metaphor that makes the concept easy to visualize and remember.", - "step-by-step": - "Break it down into clear, numbered steps that build up to the full explanation, one idea at a time.", - "real-world": - "Explain it using a concrete real-world example or practical scenario where this concept actually applies.", -} - -export async function generateAlternateExplanation( - question: string, - previousAnswer: string, - style: ExplainStyle -): Promise { - const prompt = `You are EduPilot, an AI tutor. A student asked the following question: -"${question}" - -They already received this explanation but found it hard to understand: -"${previousAnswer}" - -Re-explain the answer to the same question in a different way. ${EXPLAIN_STYLE_INSTRUCTIONS[style]} - -Stay accurate and stay focused on the original question. Do not just repeat the previous explanation in different words — genuinely change the approach.` - - return callAIWithFallback(prompt) -} - -// ========================= -// Quiz -// ========================= - -export interface QuizQuestion { - question: string - options: string[] - answer: string - explanation: string -} - -function normalizeQuizItem(item: unknown, index: number): QuizQuestion { - const raw = (item || {}) as { - question?: unknown - options?: unknown - answer?: unknown - explanation?: unknown - } - - let options = Array.isArray(raw.options) - ? raw.options.map((option) => String(option).trim()).filter(Boolean) - : [] - - if (options.length < 4) { - options = [...options, "Option B", "Option C", "Option D"].slice(0, 4) - } else { - options = options.slice(0, 4) - } - - let answer = String(raw.answer || "").trim() - if (!answer || !options.some((option) => option.toLowerCase() === answer.toLowerCase())) { - answer = options[0] - } - - return { - question: String(raw.question || `Question ${index + 1}`).trim(), - options, - answer, - explanation: String(raw.explanation || "").trim(), - } -} - -export type QuizDifficulty = "easy" | "medium" | "hard" - -const QUIZ_DIFFICULTY_GUIDANCE: Record = { - easy: "Keep questions at a beginner level: basic recall and simple definitions.", - medium: "Keep questions at an intermediate level: application of concepts, not just recall.", - hard: "Keep questions at an advanced level: multi-step reasoning, edge cases, and nuanced distinctions.", -} - -export async function generateQuiz( - topic: string, - count = 5, - difficulty: QuizDifficulty = "medium" -): Promise { - const prompt = `Generate exactly ${count} multiple-choice quiz questions about "${topic}". - -Difficulty: ${difficulty}. ${QUIZ_DIFFICULTY_GUIDANCE[difficulty]} - -Return ONLY valid JSON array in this exact structure: -[ - { - "question": "Question text?", - "options": ["Option A", "Option B", "Option C", "Option D"], - "answer": "Option A", - "explanation": "Brief explanation" - } -] - -Rules: -- Return only JSON -- No markdown -- No backticks -- Exactly ${count} questions -- Exactly 4 options per question -- "answer" must exactly match one option -- Questions should be educational and accurate -- Keep explanations short` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - - if (!Array.isArray(parsed)) { - throw new Error("Quiz response is not an array") - } - - const normalized = parsed.slice(0, count).map((item, index) => normalizeQuizItem(item, index)) - - if (!normalized.length) { - throw new Error("No quiz questions generated") - } - - return normalized - } catch (error) { - console.error("[generateQuiz] Invalid AI JSON:", raw) - throw new Error("AI returned invalid quiz format. Please try again.") - } -} - -export async function generateQuizFromContent( - title: string, - content: string, - count = 5, - difficulty: QuizDifficulty = "medium" -): Promise { - const prompt = `Read the study material below (titled "${title}") and generate exactly ${count} multiple-choice quiz questions that test understanding of it. - -Difficulty: ${difficulty}. ${QUIZ_DIFFICULTY_GUIDANCE[difficulty]} - -Study material: -${content.slice(0, 12000)} - -Return ONLY valid JSON array in this exact structure: -[ - { - "question": "Question text?", - "options": ["Option A", "Option B", "Option C", "Option D"], - "answer": "Option A", - "explanation": "Brief explanation" - } -] - -Rules: -- Base every question strictly on the material above -- Return only JSON -- No markdown -- No backticks -- Exactly ${count} questions -- Exactly 4 options per question -- "answer" must exactly match one option -- Keep explanations short` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - - if (!Array.isArray(parsed)) { - throw new Error("Quiz response is not an array") - } - - const normalized = parsed.slice(0, count).map((item, index) => normalizeQuizItem(item, index)) - - if (!normalized.length) { - throw new Error("No quiz questions generated") - } - - return normalized - } catch (error) { - console.error("[generateQuizFromContent] Invalid AI JSON:", raw) - throw new Error("AI returned invalid quiz format. Please try again.") - } -} - -// ========================= -// Flashcards -// ========================= - -export interface Flashcard { - front: string - back: string -} - -export async function generateFlashcards(topic: string, count = 5): Promise { - const prompt = `Create exactly ${count} educational flashcards about "${topic}". - -Return ONLY valid JSON array: -[ - { - "front": "Question or key term", - "back": "Clear answer or definition" - } -] - -Rules: -- Return only JSON -- No markdown -- No backticks` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - if (!Array.isArray(parsed)) throw new Error("Response is not an array") - - return parsed.slice(0, count).map((item: { front?: unknown; back?: unknown }) => ({ - front: String(item.front || "Front"), - back: String(item.back || "Back"), - })) - } catch { - throw new Error("AI returned invalid flashcard format. Please try again.") - } -} - -export async function generateFlashcardsFromContent(content: string, count = 10): Promise { - const prompt = `Read the study material below and create exactly ${count} educational flashcards that test the key concepts, facts, and definitions found in it. - -Study material: -${content.slice(0, 12000)} - -Return ONLY valid JSON array: -[ - { - "front": "Question or key term", - "back": "Clear answer or definition" - } -] - -Rules: -- Base every flashcard strictly on the material above -- Return only JSON -- No markdown -- No backticks` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - if (!Array.isArray(parsed)) throw new Error("Response is not an array") - - return parsed.slice(0, count).map((item: { front?: unknown; back?: unknown }) => ({ - front: String(item.front || "Front"), - back: String(item.back || "Back"), - })) - } catch { - throw new Error("AI returned invalid flashcard format. Please try again.") - } -} - -// ========================= -// Concept Map -// ========================= - -export interface ConceptMapNode { - id: string - label: string - excerpt: string -} - -export interface ConceptMapEdge { - source: string - target: string - label?: string -} - -export interface ConceptMapGraph { - nodes: ConceptMapNode[] - edges: ConceptMapEdge[] -} - -export async function generateConceptMap(title: string, content: string): Promise { - const prompt = `Read the study material below (titled "${title}") and extract the key concepts and how they relate to one another, as a concept map. - -Study material: -${content.slice(0, 12000)} - -Return ONLY valid JSON in this exact shape: -{ - "nodes": [ - { "id": "short-slug", "label": "Concept name", "excerpt": "1-2 sentence explanation of this concept, grounded in the material" } - ], - "edges": [ - { "source": "node-id", "target": "node-id", "label": "short relationship, e.g. 'leads to', 'is a type of'" } - ] -} - -Rules: -- Extract between 6 and 14 of the most important concepts -- Every edge's source and target must reference an id present in nodes -- Build a connected graph: every node should have at least one edge -- ids must be short kebab-case slugs, unique -- Return only JSON, no markdown, no backticks` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) as { - nodes?: Array<{ id?: unknown; label?: unknown; excerpt?: unknown }> - edges?: Array<{ source?: unknown; target?: unknown; label?: unknown }> - } - - const nodes: ConceptMapNode[] = (parsed.nodes || []) - .filter((node) => node?.id && node?.label) - .map((node) => ({ - id: String(node.id), - label: String(node.label), - excerpt: String(node.excerpt || ""), - })) - - if (!nodes.length) throw new Error("No concepts extracted") - - const nodeIds = new Set(nodes.map((node) => node.id)) - - const edges: ConceptMapEdge[] = (parsed.edges || []) - .filter((edge) => nodeIds.has(String(edge?.source)) && nodeIds.has(String(edge?.target))) - .map((edge) => ({ - source: String(edge.source), - target: String(edge.target), - label: edge.label ? String(edge.label) : undefined, - })) - - return { nodes, edges } - } catch { - throw new Error("AI returned invalid concept map format. Please try again.") - } -} - -// ========================= -// Study Plan -// ========================= - -export async function generateStudyPlan(subject: string, duration: string, goal: string): Promise { - const prompt = `Create a detailed, structured study plan. - -Subject: ${subject} -Duration: ${duration} -Goal: ${goal} - -Include: -- weekly schedule -- key topics -- study methods -- resources -- milestones -- success tips - -Format clearly with headings and bullet points.` - - return callAIWithFallback(prompt) -} - -export async function generateEduPilotGuideResponse(message: string): Promise { - const prompt = `You are EduPilot Guide, an in-app help assistant for the EduPilot platform. - -Your role: -- Answer ONLY EduPilot-related questions -- Help users understand how to use app features -- Explain clearly in a guided way -- Give practical steps users can follow inside the app -- Suggest the correct EduPilot feature when useful - -EduPilot areas you can explain: -- AI Tutor -- Notes -- Flashcards -- AI Voice -- Quiz -- Planner -- Dashboard -- Profile -- Pricing / plans -- Login / account / settings -- Help Center - -Strict rules: -- If the user asks a non-EduPilot question, politely refuse and say you only help with EduPilot usage -- Do NOT provide general study answers or general knowledge -- Do NOT act like the main AI Tutor -- Keep answers app-focused, helpful, and concise -- Always format the answer in a clean step-by-step way - -Response format rules: -- Start with 1 short intro sentence -- Then add: -Step 1: ... -Step 2: ... -Step 3: ... -- If relevant, add: -Tips: -- ... -- ... -- End with: -Try this next: ... - -User question: -${message} - -Answer as EduPilot Guide:` - - return callAIWithFallback(prompt) -} - -// ========================= -// Essay Grader -// ========================= - -export interface EssayFeedback { - grade: string - pros: string[] - cons: string[] - suggestions: { quote: string; comment: string }[] -} - -export async function gradeEssay(essay: string, rubric?: string): Promise { - const prompt = `You are a strict but fair expert academic evaluator. You are reviewing a student's essay. -${rubric ? `Here is the grading rubric or assignment prompt to base your evaluation on:\n"""\n${rubric}\n"""\n` : `Use standard academic writing standards for college-level essays (clear thesis, good structure, strong arguments, proper grammar).\n`} -Here is the student's essay: -""" -${essay.slice(0, 15000)} -""" - -Evaluate the essay and provide structured feedback. -Return ONLY valid JSON in this exact shape: -{ - "grade": "Estimated Letter Grade (e.g., A-, B+, C)", - "pros": ["Strength 1", "Strength 2"], - "cons": ["Weakness 1", "Weakness 2"], - "suggestions": [ - { "quote": "exact quote from the essay with a flaw", "comment": "how to improve this specific part" } - ] -} - -Rules: -- Return ONLY JSON. -- No markdown formatting outside the JSON, no backticks. -- suggestions array should have 2 to 5 items.` - - const raw = await callAIWithFallback(prompt) - const cleaned = cleanJsonText(raw) - - try { - const parsed = JSON.parse(cleaned) - return { - grade: String(parsed.grade || "N/A"), - pros: Array.isArray(parsed.pros) ? parsed.pros.map(String) : [], - cons: Array.isArray(parsed.cons) ? parsed.cons.map(String) : [], - suggestions: Array.isArray(parsed.suggestions) - ? parsed.suggestions.map((s: any) => ({ - quote: String(s?.quote || ""), - comment: String(s?.comment || ""), - })) - : [], - } - } catch (error) { - console.error("[gradeEssay] Invalid AI JSON:", raw) - throw new Error("AI returned invalid evaluation format. Please try again.") - } -} +// // ── AI Backend: Groq (free, fast, no daily quota limits) ───────────────────── +// // Get your free API key at: https://console.groq.com +// // Set GROQ_API_KEY in Vercel environment variables + +// async function callGroq(prompt: string): Promise { +// const key = process.env.GROQ_API_KEY +// if (!key) { +// throw new Error("GROQ_API_KEY is not set. Get a free key at console.groq.com and add it to Vercel environment variables.") +// } + +// const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { +// method: "POST", +// headers: { +// "Content-Type": "application/json", +// "Authorization": `Bearer ${key}`, +// }, +// body: JSON.stringify({ +// model: "llama-3.3-70b-versatile", // Free, very capable model on Groq +// messages: [{ role: "user", content: prompt }], +// temperature: 0.7, +// max_tokens: 2048, +// }), +// }) + +// if (!res.ok) { +// const err = await res.text() +// throw new Error(`AI error ${res.status}: ${err}`) +// } + +// const data = await res.json() +// const text = data?.choices?.[0]?.message?.content +// if (!text) throw new Error("Empty response from AI") +// return text +// } + +// // ── AI Tutor / Chat ────────────────────────────────────────────────────────── + +// export interface GenerateAIResponseOptions { +// mode?: "chat" | "web_search" +// webContext?: string +// attachmentContext?: string +// } + +// export async function generateAIResponse( +// message: string, +// options: GenerateAIResponseOptions = {} +// ): Promise { +// const extraSections = [ +// options.mode === "web_search" && options.webContext +// ? `Use these web search notes to answer accurately and cite the source names naturally when useful: +// ${options.webContext}` +// : "", +// options.attachmentContext +// ? `The user uploaded these files: +// ${options.attachmentContext} +// Use them as context when relevant.` +// : "", +// ] +// .filter(Boolean) +// .join("\n\n") + +// const prompt = `You are EduPilot, an intelligent AI tutor and study assistant. +// Help students learn effectively. Be clear, educational, and encouraging. +// Format your answers with clear sections when needed. Answer step-by-step when explaining concepts. + +// ${extraSections ? `${extraSections}\n\n` : ""}Student question: ${message} + +// Answer:` + +// return callGroq(prompt) +// } + +// // ── Quiz ───────────────────────────────────────────────────────────────────── + +// export interface QuizQuestion { +// question: string +// options: string[] +// answer: string +// explanation: string +// } + +// export async function generateQuiz(topic: string, count = 5): Promise { +// const prompt = `Generate exactly ${count} multiple-choice quiz questions about: "${topic}" + +// Return ONLY a valid JSON array. No markdown, no backticks, no explanation before or after: +// [ +// { +// "question": "Question text here?", +// "options": ["Option A", "Option B", "Option C", "Option D"], +// "answer": "Option A", +// "explanation": "Brief explanation of why this answer is correct." +// } +// ] + +// Requirements: +// - Each question must have exactly 4 options +// - The "answer" value must be the EXACT text of one of the options +// - Questions must be accurate and educational +// - Vary difficulty levels` + +// const raw = await callGroq(prompt) +// const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim() + +// try { +// const parsed = JSON.parse(cleaned) +// if (!Array.isArray(parsed)) throw new Error("Response is not an array") +// return parsed.slice(0, count).map((q: QuizQuestion) => ({ +// question: String(q.question || "Question"), +// options: Array.isArray(q.options) && q.options.length >= 2 +// ? q.options.slice(0, 4).map(String) +// : ["True", "False", "Maybe", "None of the above"], +// answer: String(q.answer || q.options?.[0] || ""), +// explanation: String(q.explanation || ""), +// })) +// } catch { +// throw new Error("AI returned invalid quiz format. Please try again.") +// } +// } + +// // ── Flashcards ─────────────────────────────────────────────────────────────── + +// export interface Flashcard { +// front: string +// back: string +// } + +// export async function generateFlashcards(topic: string, count = 5): Promise { +// const prompt = `Create exactly ${count} educational flashcards about: "${topic}" + +// Return ONLY a valid JSON array. No markdown, no backticks, no explanation: +// [ +// { +// "front": "Question or key term", +// "back": "Clear, concise answer or definition" +// } +// ] + +// Requirements: +// - Cover the most important concepts +// - Keep fronts as questions or key terms +// - Keep backs as concise, memorable answers` + +// const raw = await callGroq(prompt) +// const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim() + +// try { +// const parsed = JSON.parse(cleaned) +// if (!Array.isArray(parsed)) throw new Error("Response is not an array") +// return parsed.slice(0, count).map((f: Flashcard) => ({ +// front: String(f.front || "Front"), +// back: String(f.back || "Back"), +// })) +// } catch { +// throw new Error("AI returned invalid flashcard format. Please try again.") +// } +// } + +// // ── Study Plan ─────────────────────────────────────────────────────────────── + +// export async function generateStudyPlan(subject: string, duration: string, goal: string): Promise { +// const prompt = `Create a detailed, structured study plan: +// Subject: ${subject} +// Duration: ${duration} +// Goal: ${goal} + +// Include: weekly schedule, key topics to cover, study methods, resources, milestones, and success tips. +// Format clearly with headings and bullet points.` +// return callGroq(prompt) +// } +import { GoogleGenerativeAI } from "@google/generative-ai" + +// ========================= +// Shared helpers +// ========================= + +function getGroqKey() { + const key = process.env.GROQ_API_KEY?.trim() + if (!key) { + throw new Error("GROQ_API_KEY is not set") + } + return key +} + +function getGeminiKey() { + const key = process.env.GEMINI_API_KEY?.trim() + if (!key) { + throw new Error("GEMINI_API_KEY is not set") + } + return key +} + +function cleanJsonText(raw: string) { + const cleaned = raw + .replace(/```json\s*/gi, "") + .replace(/```/g, "") + .trim() + + if ( + (cleaned.startsWith("[") && cleaned.endsWith("]")) || + (cleaned.startsWith("{") && cleaned.endsWith("}")) + ) { + return cleaned + } + + const firstArray = cleaned.indexOf("[") + const lastArray = cleaned.lastIndexOf("]") + if (firstArray !== -1 && lastArray !== -1 && lastArray > firstArray) { + return cleaned.slice(firstArray, lastArray + 1) + } + + const firstObject = cleaned.indexOf("{") + const lastObject = cleaned.lastIndexOf("}") + if (firstObject !== -1 && lastObject !== -1 && lastObject > firstObject) { + return cleaned.slice(firstObject, lastObject + 1) + } + + return cleaned +} + +async function callGroq(prompt: string): Promise { + const key = getGroqKey() + + const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ + model: "llama-3.3-70b-versatile", + messages: [ + { + role: "system", + content: + "You are EduPilot AI. Follow the user's output format exactly. If asked for JSON, return only valid JSON.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.3, + max_tokens: 2048, + }), + }) + + const data = await res.json().catch(() => ({})) + + if (!res.ok) { + const message = + (data as { error?: { message?: string } })?.error?.message || + JSON.stringify(data) || + "Groq request failed" + throw new Error(message) + } + + const text = + (data as { choices?: Array<{ message?: { content?: string } }> })?.choices?.[0]?.message?.content?.trim() || + "" + + if (!text) { + throw new Error("Empty response from Groq") + } + + return text +} + +export async function generateEmbedding(text: string): Promise { + const key = getGeminiKey() + const client = new GoogleGenerativeAI(key) + const model = client.getGenerativeModel({ model: "text-embedding-004" }) + const result = await model.embedContent(text) + const embedding = result.embedding + return embedding.values +} + +async function callGemini(prompt: string): Promise { + const key = getGeminiKey() + const client = new GoogleGenerativeAI(key) + + const modelNames = ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-flash-latest"] + + let lastError = "Gemini failed" + + for (const modelName of modelNames) { + try { + const model = client.getGenerativeModel({ + model: modelName, + generationConfig: { + temperature: 0.3, + responseMimeType: "application/json", + }, + }) + + const result = await model.generateContent(prompt) + const text = result.response.text().trim() + + if (!text) { + lastError = `Empty response from ${modelName}` + continue + } + + return text + } catch (error) { + lastError = error instanceof Error ? error.message : "Gemini request failed" + } + } + + throw new Error(lastError) +} + +async function callAIWithFallback(prompt: string): Promise { + const errors: string[] = [] + + try { + return await callGroq(prompt) + } catch (error) { + errors.push(`Groq: ${error instanceof Error ? error.message : "Unknown error"}`) + } + + try { + return await callGemini(prompt) + } catch (error) { + errors.push(`Gemini: ${error instanceof Error ? error.message : "Unknown error"}`) + } + + throw new Error(errors.join(" | ")) +} + +// ========================= +// AI Tutor / Chat +// ========================= + +export interface GenerateAIResponseOptions { + mode?: "chat" | "web_search" + webContext?: string + attachmentContext?: string +} + +export async function generateAIResponse( + message: string, + options: GenerateAIResponseOptions = {} +): Promise { + const extraSections = [ + options.mode === "web_search" && options.webContext + ? `Use these web search notes to answer accurately and cite source names naturally when useful: +${options.webContext}` + : "", + options.attachmentContext + ? `The user uploaded these files: +${options.attachmentContext} +Use them as context when relevant.` + : "", + ] + .filter(Boolean) + .join("\n\n") + + const prompt = `You are EduPilot, an intelligent AI tutor and study assistant. +Help students learn effectively. Be clear, educational, and encouraging. +Format answers with clear sections where useful. + +${extraSections ? `${extraSections}\n\n` : ""}Student question: ${message} + +Answer:` + + return callAIWithFallback(prompt) +} + +export type ExplainStyle = "simpler" | "analogy" | "step-by-step" | "real-world" + +export const EXPLAIN_STYLE_LABELS: Record = { + simpler: "Simpler (ELI5)", + analogy: "Use an analogy", + "step-by-step": "Step-by-step", + "real-world": "Real-world example", +} + +const EXPLAIN_STYLE_INSTRUCTIONS: Record = { + simpler: + "Explain it in the simplest possible terms, as if to someone with no background in the subject. Avoid jargon, use short sentences.", + analogy: + "Explain it using a clear, relatable analogy or metaphor that makes the concept easy to visualize and remember.", + "step-by-step": + "Break it down into clear, numbered steps that build up to the full explanation, one idea at a time.", + "real-world": + "Explain it using a concrete real-world example or practical scenario where this concept actually applies.", +} + +export async function generateAlternateExplanation( + question: string, + previousAnswer: string, + style: ExplainStyle +): Promise { + const prompt = `You are EduPilot, an AI tutor. A student asked the following question: +"${question}" + +They already received this explanation but found it hard to understand: +"${previousAnswer}" + +Re-explain the answer to the same question in a different way. ${EXPLAIN_STYLE_INSTRUCTIONS[style]} + +Stay accurate and stay focused on the original question. Do not just repeat the previous explanation in different words — genuinely change the approach.` + + return callAIWithFallback(prompt) +} + +// ========================= +// Quiz +// ========================= + +export interface QuizQuestion { + question: string + options: string[] + answer: string + explanation: string +} + +function normalizeQuizItem(item: unknown, index: number): QuizQuestion { + const raw = (item || {}) as { + question?: unknown + options?: unknown + answer?: unknown + explanation?: unknown + } + + let options = Array.isArray(raw.options) + ? raw.options.map((option) => String(option).trim()).filter(Boolean) + : [] + + if (options.length < 4) { + options = [...options, "Option B", "Option C", "Option D"].slice(0, 4) + } else { + options = options.slice(0, 4) + } + + let answer = String(raw.answer || "").trim() + if (!answer || !options.some((option) => option.toLowerCase() === answer.toLowerCase())) { + answer = options[0] + } + + return { + question: String(raw.question || `Question ${index + 1}`).trim(), + options, + answer, + explanation: String(raw.explanation || "").trim(), + } +} + +export type QuizDifficulty = "easy" | "medium" | "hard" + +const QUIZ_DIFFICULTY_GUIDANCE: Record = { + easy: "Keep questions at a beginner level: basic recall and simple definitions.", + medium: "Keep questions at an intermediate level: application of concepts, not just recall.", + hard: "Keep questions at an advanced level: multi-step reasoning, edge cases, and nuanced distinctions.", +} + +export async function generateQuiz( + topic: string, + count = 5, + difficulty: QuizDifficulty = "medium" +): Promise { + const prompt = `Generate exactly ${count} multiple-choice quiz questions about "${topic}". + +Difficulty: ${difficulty}. ${QUIZ_DIFFICULTY_GUIDANCE[difficulty]} + +Return ONLY valid JSON array in this exact structure: +[ + { + "question": "Question text?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "answer": "Option A", + "explanation": "Brief explanation" + } +] + +Rules: +- Return only JSON +- No markdown +- No backticks +- Exactly ${count} questions +- Exactly 4 options per question +- "answer" must exactly match one option +- Questions should be educational and accurate +- Keep explanations short` + + const raw = await callAIWithFallback(prompt) + const cleaned = cleanJsonText(raw) + + try { + const parsed = JSON.parse(cleaned) + + if (!Array.isArray(parsed)) { + throw new Error("Quiz response is not an array") + } + + const normalized = parsed.slice(0, count).map((item, index) => normalizeQuizItem(item, index)) + + if (!normalized.length) { + throw new Error("No quiz questions generated") + } + + return normalized + } catch (error) { + console.error("[generateQuiz] Invalid AI JSON:", raw) + throw new Error("AI returned invalid quiz format. Please try again.") + } +} + +export async function generateQuizFromContent( + title: string, + content: string, + count = 5, + difficulty: QuizDifficulty = "medium" +): Promise { + const prompt = `Read the study material below (titled "${title}") and generate exactly ${count} multiple-choice quiz questions that test understanding of it. + +Difficulty: ${difficulty}. ${QUIZ_DIFFICULTY_GUIDANCE[difficulty]} + +Study material: +${content.slice(0, 12000)} + +Return ONLY valid JSON array in this exact structure: +[ + { + "question": "Question text?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "answer": "Option A", + "explanation": "Brief explanation" + } +] + +Rules: +- Base every question strictly on the material above +- Return only JSON +- No markdown +- No backticks +- Exactly ${count} questions +- Exactly 4 options per question +- "answer" must exactly match one option +- Keep explanations short` + + const raw = await callAIWithFallback(prompt) + const cleaned = cleanJsonText(raw) + + try { + const parsed = JSON.parse(cleaned) + + if (!Array.isArray(parsed)) { + throw new Error("Quiz response is not an array") + } + + const normalized = parsed.slice(0, count).map((item, index) => normalizeQuizItem(item, index)) + + if (!normalized.length) { + throw new Error("No quiz questions generated") + } + + return normalized + } catch (error) { + console.error("[generateQuizFromContent] Invalid AI JSON:", raw) + throw new Error("AI returned invalid quiz format. Please try again.") + } +} + +// ========================= +// Flashcards +// ========================= + +export interface Flashcard { + front: string + back: string +} + +export async function generateFlashcards(topic: string, count = 5): Promise { + const prompt = `Create exactly ${count} educational flashcards about "${topic}". + +Return ONLY valid JSON array: +[ + { + "front": "Question or key term", + "back": "Clear answer or definition" + } +] + +Rules: +- Return only JSON +- No markdown +- No backticks` + + const raw = await callAIWithFallback(prompt) + const cleaned = cleanJsonText(raw) + + try { + const parsed = JSON.parse(cleaned) + if (!Array.isArray(parsed)) throw new Error("Response is not an array") + + return parsed.slice(0, count).map((item: { front?: unknown; back?: unknown }) => ({ + front: String(item.front || "Front"), + back: String(item.back || "Back"), + })) + } catch { + throw new Error("AI returned invalid flashcard format. Please try again.") + } +} + +export async function generateFlashcardsFromContent(content: string, count = 10): Promise { + const prompt = `Read the study material below and create exactly ${count} educational flashcards that test the key concepts, facts, and definitions found in it. + +Study material: +${content.slice(0, 12000)} + +Return ONLY valid JSON array: +[ + { + "front": "Question or key term", + "back": "Clear answer or definition" + } +] + +Rules: +- Base every flashcard strictly on the material above +- Return only JSON +- No markdown +- No backticks` + + const raw = await callAIWithFallback(prompt) + const cleaned = cleanJsonText(raw) + + try { + const parsed = JSON.parse(cleaned) + if (!Array.isArray(parsed)) throw new Error("Response is not an array") + + return parsed.slice(0, count).map((item: { front?: unknown; back?: unknown }) => ({ + front: String(item.front || "Front"), + back: String(item.back || "Back"), + })) + } catch { + throw new Error("AI returned invalid flashcard format. Please try again.") + } +} + +// ========================= +// Concept Map +// ========================= + +export interface ConceptMapNode { + id: string + label: string + excerpt: string +} + +export interface ConceptMapEdge { + source: string + target: string + label?: string +} + +export interface ConceptMapGraph { + nodes: ConceptMapNode[] + edges: ConceptMapEdge[] +} + +export async function generateConceptMap(title: string, content: string): Promise { + const prompt = `Read the study material below (titled "${title}") and extract the key concepts and how they relate to one another, as a concept map. + +Study material: +${content.slice(0, 12000)} + +Return ONLY valid JSON in this exact shape: +{ + "nodes": [ + { "id": "short-slug", "label": "Concept name", "excerpt": "1-2 sentence explanation of this concept, grounded in the material" } + ], + "edges": [ + { "source": "node-id", "target": "node-id", "label": "short relationship, e.g. 'leads to', 'is a type of'" } + ] +} + +Rules: +- Extract between 6 and 14 of the most important concepts +- Every edge's source and target must reference an id present in nodes +- Build a connected graph: every node should have at least one edge +- ids must be short kebab-case slugs, unique +- Return only JSON, no markdown, no backticks` + + const raw = await callAIWithFallback(prompt) + const cleaned = cleanJsonText(raw) + + try { + const parsed = JSON.parse(cleaned) as { + nodes?: Array<{ id?: unknown; label?: unknown; excerpt?: unknown }> + edges?: Array<{ source?: unknown; target?: unknown; label?: unknown }> + } + + const nodes: ConceptMapNode[] = (parsed.nodes || []) + .filter((node) => node?.id && node?.label) + .map((node) => ({ + id: String(node.id), + label: String(node.label), + excerpt: String(node.excerpt || ""), + })) + + if (!nodes.length) throw new Error("No concepts extracted") + + const nodeIds = new Set(nodes.map((node) => node.id)) + + const edges: ConceptMapEdge[] = (parsed.edges || []) + .filter((edge) => nodeIds.has(String(edge?.source)) && nodeIds.has(String(edge?.target))) + .map((edge) => ({ + source: String(edge.source), + target: String(edge.target), + label: edge.label ? String(edge.label) : undefined, + })) + + return { nodes, edges } + } catch { + throw new Error("AI returned invalid concept map format. Please try again.") + } +} + +// ========================= +// Study Plan +// ========================= + +export async function generateStudyPlan(subject: string, duration: string, goal: string): Promise { + const prompt = `Create a detailed, structured study plan. + +Subject: ${subject} +Duration: ${duration} +Goal: ${goal} + +Include: +- weekly schedule +- key topics +- study methods +- resources +- milestones +- success tips + +Format clearly with headings and bullet points.` + + return callAIWithFallback(prompt) +} + +export async function generateEduPilotGuideResponse(message: string): Promise { + const prompt = `You are EduPilot Guide, an in-app help assistant for the EduPilot platform. + +Your role: +- Answer ONLY EduPilot-related questions +- Help users understand how to use app features +- Explain clearly in a guided way +- Give practical steps users can follow inside the app +- Suggest the correct EduPilot feature when useful + +EduPilot areas you can explain: +- AI Tutor +- Notes +- Flashcards +- AI Voice +- Quiz +- Planner +- Dashboard +- Profile +- Pricing / plans +- Login / account / settings +- Help Center + +Strict rules: +- If the user asks a non-EduPilot question, politely refuse and say you only help with EduPilot usage +- Do NOT provide general study answers or general knowledge +- Do NOT act like the main AI Tutor +- Keep answers app-focused, helpful, and concise +- Always format the answer in a clean step-by-step way + +Response format rules: +- Start with 1 short intro sentence +- Then add: +Step 1: ... +Step 2: ... +Step 3: ... +- If relevant, add: +Tips: +- ... +- ... +- End with: +Try this next: ... + +User question: +${message} + +Answer as EduPilot Guide:` + + return callAIWithFallback(prompt) +} + +// ========================= +// Topic Difficulty Analyzer +// ========================= + +export interface TopicAnalysisResult { + difficulty: "Beginner" | "Intermediate" | "Advanced" + estimatedHours: string + confidence: number + summary: string + studyOrder: string[] + prerequisites: string[] + relatedConcepts: string[] + revisionSessions: number + tips: string[] +} + +export async function analyzeTopic(topic: string): Promise { + const prompt = `Analyze the complexity, requirements, and learning path for the academic/technical topic: "${topic}". + +Return ONLY a valid JSON object. Do not include any markdown, code blocks, backticks, or explanatory text before or after the JSON. +The JSON object must have exactly the following structure: +{ + "difficulty": "Beginner" | "Intermediate" | "Advanced", + "estimatedHours": "e.g., 10-14", + "confidence": 92, + "summary": "a short AI summary of what this topic is and its learning curve (2-3 sentences)", + "studyOrder": ["Step 1 explanation", "Step 2 explanation", ...], + "prerequisites": ["Prerequisite 1", "Prerequisite 2", ...], + "relatedConcepts": ["Concept 1", "Concept 2", ...], + "revisionSessions": 4, + "tips": ["Tip 1", "Tip 2", ...] +} + +Ensure the values are realistic and highly helpful for a student studying this topic.` + + const raw = await callAIWithFallback(prompt) + const cleaned = cleanJsonText(raw) + + try { + const parsed = JSON.parse(cleaned) + + // Validate difficulty + let difficulty: "Beginner" | "Intermediate" | "Advanced" = "Intermediate" + const parsedDiff = String(parsed.difficulty || "").trim().toLowerCase() + if (parsedDiff === "beginner" || parsedDiff === "intermediate" || parsedDiff === "advanced") { + difficulty = (parsedDiff.charAt(0).toUpperCase() + parsedDiff.slice(1)) as "Beginner" | "Intermediate" | "Advanced" + } + + return { + difficulty, + estimatedHours: String(parsed.estimatedHours || "10-15").trim(), + confidence: Math.min(100, Math.max(0, Number(parsed.confidence) || 85)), + summary: String(parsed.summary || `A study profile for ${topic}.`).trim(), + studyOrder: Array.isArray(parsed.studyOrder) ? parsed.studyOrder.map(String).filter(Boolean) : [], + prerequisites: Array.isArray(parsed.prerequisites) ? parsed.prerequisites.map(String).filter(Boolean) : [], + relatedConcepts: Array.isArray(parsed.relatedConcepts) ? parsed.relatedConcepts.map(String).filter(Boolean) : [], + revisionSessions: Math.max(1, Number(parsed.revisionSessions) || 3), + tips: Array.isArray(parsed.tips) ? parsed.tips.map(String).filter(Boolean) : [], + } + } catch (error) { + console.error("[analyzeTopic] Malformed JSON from AI:", raw) + throw new Error("AI returned an invalid response format. Please try again.") + } +} +// // ── AI Backend: Groq (free, fast, no daily quota limits) ───────────────────── +// // Get your free API key at: https://console.groq.com +// // Set GROQ_API_KEY in Vercel environment variables + +// async function callGroq(prompt: string): Promise { +// const key = process.env.GROQ_API_KEY +// if (!key) { +// throw new Error("GROQ_API_KEY is not set. Get a free key at console.groq.com and add it to Vercel environment variables.") +// } + +// const res = await fetch("https://api.groq.com/openai/v1/chat/completions", { +// method: "POST", +// headers: { +// "Content-Type": "application/json", +// "Authorization": `Bearer ${key}`, +// }, +// body: JSON.stringify({ +// model: "llama-3.3-70b-versatile", // Free, very capable model on Groq +// messages: [{ role: "user", content: prompt }], +// temperature: 0.7, +// max_tokens: 2048, +// }), +// }) + +// if (!res.ok) { +// const err = await res.text() +// throw new Error(`AI error ${res.status}: ${err}`) +// } + +// const data = await res.json() +// const text = data?.choices?.[0]?.message?.content +// if (!text) throw new Error("Empty response from AI") +// return text +// } + +// // ── AI Tutor / Chat ────────────────────────────────────────────────────────── + +// export interface GenerateAIResponseOptions { +// mode?: "chat" | "web_search" +// webContext?: string +// attachmentContext?: string +// } + +// export async function generateAIResponse( +// message: string, +// options: GenerateAIResponseOptions = {} +// ): Promise { +// const extraSections = [ +// options.mode === "web_search" && options.webContext +// ? `Use these web search notes to answer accurately and cite the source names naturally when useful: +// ${options.webContext}` +// : "", +// options.attachmentContext +// ? `The user uploaded these files: +// ${options.attachmentContext} +// Use them as context when relevant.` +// : "", +// ] +// .filter(Boolean) +// .join("\n\n") + +// const prompt = `You are EduPilot, an intelligent AI tutor and study assistant. +// Help students learn effectively. Be clear, educational, and encouraging. +// Format your answers with clear sections when needed. Answer step-by-step when explaining concepts. + +// ${extraSections ? `${extraSections}\n\n` : ""}Student question: ${message} + +// Answer:` + +// return callGroq(prompt) +// } + +// // ── Quiz ───────────────────────────────────────────────────────────────────── + +// export interface QuizQuestion { +// question: string +// options: string[] +// answer: string +// explanation: string +// } + +// export async function generateQuiz(topic: string, count = 5): Promise { +// const prompt = `Generate exactly ${count} multiple-choice quiz questions about: "${topic}" + +// Return ONLY a valid JSON array. No markdown, no backticks, no explanation before or after: +// [ +// { +// "question": "Question text here?", +// "options": ["Option A", "Option B", "Option C", "Option D"], +// "answer": "Option A", +// "explanation": "Brief explanation of why this answer is correct." +// } +// ] + +// Requirements: +// - Each question must have exactly 4 options +// - The "answer" value must be the EXACT text of one of the options +// - Questions must be accurate and educational +// - Vary difficulty levels` + +// const raw = await callGroq(prompt) +// const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim() + +// try { +// const parsed = JSON.parse(cleaned) +// if (!Array.isArray(parsed)) throw new Error("Response is not an array") +// return parsed.slice(0, count).map((q: QuizQuestion) => ({ +// question: String(q.question || "Question"), +// options: Array.isArray(q.options) && q.options.length >= 2 +// ? q.options.slice(0, 4).map(String) +// : ["True", "False", "Maybe", "None of the above"], +// answer: String(q.answer || q.options?.[0] || ""), +// explanation: String(q.explanation || ""), +// })) +// } catch { +// throw new Error("AI returned invalid quiz format. Please try again.") +// } +// } + +// // ── Flashcards ─────────────────────────────────────────────────────────────── + +// export interface Flashcard { +// front: string +// back: string +// } + +// export async function generateFlashcards(topic: string, count = 5): Promise { +// const prompt = `Create exactly ${count} educational flashcards about: "${topic}" + +// Return ONLY a valid JSON array. No markdown, no backticks, no explanation: +// [ +// { +// "front": "Question or key term", +// "back": "Clear, concise answer or definition" +// } +// ] + +// Requirements: +// - Cover the most important concepts +// - Keep fronts as questions or key terms +// - Keep backs as concise, memorable answers` + +// const raw = await callGroq(prompt) +// const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim() + +// try { +// const parsed = JSON.parse(cleaned) +// if (!Array.isArray(parsed)) throw new Error("Response is not an array") +// return parsed.slice(0, count).map((f: Flashcard) => ({ +// front: String(f.front || "Front"), +// back: String(f.back || "Back"), +// })) +// } catch { +// throw new Error("AI returned invalid flashcard format. Please try again.") +// } +// } + +// // ── Study Plan ─────────────────────────────────────────────────────────────── + +// export async function generateStudyPlan(subject: string, duration: string, goal: string): Promise { +// const prompt = `Create a detailed, structured study plan: +// Subject: ${subject} +// Duration: ${duration} +// Goal: ${goal} + +// Include: weekly schedule, key topics to cover, study methods, resources, milestones, and success tips. +// Format clearly with headings and bullet points.` +// return callGroq(prompt) +// } + +// ========================= +// Essay Grader +// ========================= + +export interface EssayFeedback { + grade: string + pros: string[] + cons: string[] + suggestions: { quote: string; comment: string }[] +} + +export async function gradeEssay(essay: string, rubric?: string): Promise { + const prompt = `You are a strict but fair expert academic evaluator. You are reviewing a student's essay. +${rubric ? `Here is the grading rubric or assignment prompt to base your evaluation on:\n"""\n${rubric}\n"""\n` : `Use standard academic writing standards for college-level essays (clear thesis, good structure, strong arguments, proper grammar).\n`} +Here is the student's essay: +""" +${essay.slice(0, 15000)} +""" + +Evaluate the essay and provide structured feedback. +Return ONLY valid JSON in this exact shape: +{ + "grade": "Estimated Letter Grade (e.g., A-, B+, C)", + "pros": ["Strength 1", "Strength 2"], + "cons": ["Weakness 1", "Weakness 2"], + "suggestions": [ + { "quote": "exact quote from the essay with a flaw", "comment": "how to improve this specific part" } + ] +} + +Rules: +- Return ONLY JSON. +- No markdown formatting outside the JSON, no backticks. +- suggestions array should have 2 to 5 items.` + + const raw = await callAIWithFallback(prompt) + const cleaned = cleanJsonText(raw) + + try { + const parsed = JSON.parse(cleaned) + return { + grade: String(parsed.grade || "N/A"), + pros: Array.isArray(parsed.pros) ? parsed.pros.map(String) : [], + cons: Array.isArray(parsed.cons) ? parsed.cons.map(String) : [], + suggestions: Array.isArray(parsed.suggestions) + ? parsed.suggestions.map((s: any) => ({ + quote: String(s?.quote || ""), + comment: String(s?.comment || ""), + })) + : [], + } + } catch (error) { + console.error("[gradeEssay] Invalid AI JSON:", raw) + throw new Error("AI returned invalid evaluation format. Please try again.") + } +} From e697272f57066b7efd29aef14ebe51c6fc95ed7e Mon Sep 17 00:00:00 2001 From: Rudra-clrscr Date: Thu, 16 Jul 2026 02:35:27 +0530 Subject: [PATCH 2/2] feat(analytics): add usage trend chart and aggregate stats by feature type Add /api/usage/history, a dedicated endpoint scoped to the analytics route that reads usage_logs for the three credit-metered features (ai_chat, flashcards, study_plan) - the same table lib/credits.ts writes to on every credit deduction. Extend the analytics page with a per-feature usage trend line chart and aggregate stat cards (total ai sessions, decks created, plans created). Kept separate from /api/user/stats, which the main dashboard cards also poll, so this historical query never adds latency to /dashboard. --- app/(dashboard)/analytics/page.tsx | 103 +++++++++++++++++++++++- app/api/usage/history/route.ts | 124 +++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 app/api/usage/history/route.ts diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx index d8e3665..47db48d 100644 --- a/app/(dashboard)/analytics/page.tsx +++ b/app/(dashboard)/analytics/page.tsx @@ -4,9 +4,9 @@ import { useEffect, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Area, AreaChart, Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts" +import { Area, AreaChart, Bar, BarChart, Line, LineChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Legend } from "recharts" import { - TrendingUp, Clock, Target, Flame, Brain, Trophy, BookOpen, Calendar + TrendingUp, Clock, Target, Flame, Brain, Trophy, BookOpen, Calendar, MessageSquareText, Layers, ListChecks } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" import { cn } from "@/lib/utils" @@ -22,11 +22,36 @@ interface StatsData { weeklyActivity: Array<{ day: string; count: number }> } +// Historical, per-feature-type usage trend + aggregates. Sourced from the same +// usage_logs table that lib/credits.ts writes to on every credit deduction +// (see app/api/usage/history/route.ts) rather than a separate pipeline. This +// is fetched independently of /api/user/stats so it never adds load to the +// main /dashboard page. +interface UsageHistoryPoint { + date: string + label: string + ai_chat: number + flashcards: number + study_plan: number +} + +interface UsageHistoryData { + trend: UsageHistoryPoint[] + aggregates: { + totalSessions: number + decksCreated: number + plansCreated: number + totalCreditActions: number + } +} + export default function AnalyticsPage() { const { credits, subscription } = useUser() const [stats, setStats] = useState(null) const [isLoading, setIsLoading] = useState(true) const [period, setPeriod] = useState("week") + const [usageHistory, setUsageHistory] = useState(null) + const [isHistoryLoading, setIsHistoryLoading] = useState(true) useEffect(() => { fetch("/api/user/stats") @@ -36,6 +61,15 @@ export default function AnalyticsPage() { .finally(() => setIsLoading(false)) }, []) + useEffect(() => { + setIsHistoryLoading(true) + fetch(`/api/usage/history?period=${period}`) + .then(r => r.json()) + .then(setUsageHistory) + .catch(() => setUsageHistory(null)) + .finally(() => setIsHistoryLoading(false)) + }, [period]) + const isTrial = subscription?.trial_active === true const statsCards = stats ? [ @@ -171,6 +205,71 @@ export default function AnalyticsPage() { + {/* Usage by Feature Type - aggregate stats sourced from usage_logs (same + source lib/credits.ts writes to on every credit deduction) */} + {isHistoryLoading ? ( +
+ {[1, 2, 3].map(i => )} +
+ ) : ( +
+ {[ + { label: "Total AI Sessions", value: usageHistory?.aggregates.totalSessions ?? 0, icon: MessageSquareText, color: "text-violet-500", bgColor: "bg-violet-500/10" }, + { label: "Decks Created", value: usageHistory?.aggregates.decksCreated ?? 0, icon: Layers, color: "text-emerald-500", bgColor: "bg-emerald-500/10" }, + { label: "Plans Created", value: usageHistory?.aggregates.plansCreated ?? 0, icon: ListChecks, color: "text-primary", bgColor: "bg-primary/10" }, + ].map((stat) => ( + + +
+ +
+
+

{stat.value}

+

{stat.label}

+
+
+
+ ))} +
+ )} + + {/* Credit Usage Trend, segmented by feature type */} + + + + + Usage Trend by Feature + + + + {isHistoryLoading ? ( + + ) : usageHistory?.trend && usageHistory.trend.some(d => d.ai_chat + d.flashcards + d.study_plan > 0) ? ( +
+ + + + + + + + + + + +
+ ) : ( +
+
+ +

No usage recorded for this period yet

+

Chats, flashcard sets, and study plans will show up here over time

+
+
+ )} +
+
+ {/* Summary */} diff --git a/app/api/usage/history/route.ts b/app/api/usage/history/route.ts new file mode 100644 index 0000000..a6931a4 --- /dev/null +++ b/app/api/usage/history/route.ts @@ -0,0 +1,124 @@ +export const dynamic = "force-dynamic" + +import { NextRequest, NextResponse } from "next/server" +import { getUser } from "@/lib/auth-server" +import { getSupabaseAdmin } from "@/lib/supabase-server" +import type { FeatureKey } from "@/types" + +// Scoped to the /analytics page only. Deliberately NOT called from the main +// /dashboard data-fetching path (that page and its cards use /api/user/stats) +// so this historical/trend query never adds latency to the main dashboard. + +type UsageLogRow = { + feature: string + created_at: string +} + +// These are the three features that are actually credit-metered (see +// FeatureKey in types/index.ts and lib/credits.ts::consumeCredit). Every AI +// route that deducts a credit also calls logUsage() for the same feature, so +// usage_logs is the single source of truth for both credit deductions and +// this analytics view — no parallel tracking table. +const CREDIT_FEATURES: FeatureKey[] = ["ai_chat", "flashcards", "study_plan"] + +const PERIOD_TO_DAYS: Record = { + week: 7, + month: 30, + all: 180, // bounded lookback so the query stays cheap even for old accounts +} + +function toDayKey(value: string) { + return new Date(value).toISOString().split("T")[0] +} + +function formatLabel(date: Date, days: number) { + return days > 45 + ? date.toLocaleString("en-US", { month: "short", day: "numeric" }) + : date.toLocaleString("en-US", { weekday: "short", day: "numeric" }) +} + +export async function GET(req: NextRequest) { + try { + const user = await getUser() + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const period = req.nextUrl.searchParams.get("period") ?? "month" + const days = PERIOD_TO_DAYS[period] ?? PERIOD_TO_DAYS.month + + const since = new Date() + since.setHours(0, 0, 0, 0) + since.setDate(since.getDate() - (days - 1)) + + const admin = await getSupabaseAdmin() + + // Single, narrow, indexed query (idx_usage_logs_user_id + created_at desc) + // limited to the credit-metered features and the selected lookback window. + const { data, error } = await admin + .from("usage_logs") + .select("feature, created_at") + .eq("user_id", user.id) + .in("feature", CREDIT_FEATURES) + .gte("created_at", since.toISOString()) + .order("created_at", { ascending: true }) + + if (error) throw error + + const logs = (data ?? []) as UsageLogRow[] + + const byDay = new Map>() + for (let i = 0; i < days; i++) { + const d = new Date(since) + d.setDate(since.getDate() + i) + byDay.set(toDayKey(d.toISOString()), { ai_chat: 0, flashcards: 0, study_plan: 0 }) + } + + const totals: Record = { ai_chat: 0, flashcards: 0, study_plan: 0 } + + for (const log of logs) { + const feature = log.feature as FeatureKey + if (!CREDIT_FEATURES.includes(feature)) continue + + totals[feature] += 1 + + const key = toDayKey(log.created_at) + const bucket = byDay.get(key) + if (bucket) bucket[feature] += 1 + } + + const trend = Array.from(byDay.entries()).map(([dateKey, counts]) => ({ + date: dateKey, + label: formatLabel(new Date(dateKey), days), + ai_chat: counts.ai_chat, + flashcards: counts.flashcards, + study_plan: counts.study_plan, + })) + + return NextResponse.json({ + period, + days, + trend, + totals, + aggregates: { + totalSessions: totals.ai_chat, + decksCreated: totals.flashcards, + plansCreated: totals.study_plan, + totalCreditActions: totals.ai_chat + totals.flashcards + totals.study_plan, + }, + }) + } catch (err) { + console.error("[usage/history] Error:", err) + return NextResponse.json( + { + period: "month", + days: 30, + trend: [], + totals: { ai_chat: 0, flashcards: 0, study_plan: 0 }, + aggregates: { totalSessions: 0, decksCreated: 0, plansCreated: 0, totalCreditActions: 0 }, + error: "Failed to load usage history", + }, + { status: 500 } + ) + } +}