diff --git a/client/src/lib/types/learning.types.ts b/client/src/lib/types/learning.types.ts index e84ffeec6..b029061a9 100644 --- a/client/src/lib/types/learning.types.ts +++ b/client/src/lib/types/learning.types.ts @@ -181,6 +181,17 @@ export interface DsaExecutionResult { submissionId: number; } +export interface DsaCodeReview { + timeComplexity: string; + spaceComplexity: string; + readability: { + score: number; + feedback: string; + }; + edgeCases: string[]; + suggestions: string[]; +} + export interface DsaSubmissionSummary { id: number; language: DsaLanguage; diff --git a/client/src/module/student/dsa/DsaProblemDetailPage.tsx b/client/src/module/student/dsa/DsaProblemDetailPage.tsx index d4500aa50..e7544289b 100644 --- a/client/src/module/student/dsa/DsaProblemDetailPage.tsx +++ b/client/src/module/student/dsa/DsaProblemDetailPage.tsx @@ -6,12 +6,13 @@ import { ExternalLink, CheckCircle2, Circle, Bookmark, BookmarkCheck, ChevronDown, Building2, BarChart3, Lightbulb, StickyNote, Link2, ArrowUpRight, - History, Terminal, Lock, Crown, Code2, Flag, X, + History, Terminal, Lock, Crown, Code2, Flag, X, Sparkles, } from "lucide-react"; import toast from "@/components/ui/toast"; import api from "../../../lib/axios"; import { queryKeys } from "../../../lib/query-keys"; -import type { DsaProblemDetail, DsaLanguage, DsaExecutionResult, DsaSubmissionSummary, DsaSimilarProblem } from "../../../lib/types"; +import type { DsaProblemDetail, DsaLanguage, DsaExecutionResult, DsaSubmissionSummary, DsaSimilarProblem, DsaCodeReview } from "../../../lib/types"; +import { DsaAiReviewPanel } from "./components/DsaAiReviewPanel"; import { useAuthStore } from "../../../lib/auth.store"; import { SEO } from "../../../components/SEO"; import { canonicalUrl, SITE_URL } from "../../../lib/seo.utils"; @@ -114,7 +115,7 @@ export default function DsaProblemDetailPage() { const [reportMessage, setReportMessage] = useState(""); const [activeTab, setActiveTab] = useState<"problem" | "code">("problem"); - const [rightTab, setRightTab] = useState<"results" | "history" | "output">("results"); + const [rightTab, setRightTab] = useState<"results" | "history" | "output" | "ai-review">("results"); const [language, setLanguage] = useState("python"); const [codeMap, setCodeMap] = useState>({ python: DEFAULT_CODE.python, @@ -247,6 +248,20 @@ export default function DsaProblemDetailPage() { }, }); + const aiReviewMutation = useMutation({ + mutationFn: (submissionId: number) => + api.post(`/dsa/submissions/${submissionId}/review`).then((r) => r.data), + onError: (err: { response?: { status?: number; data?: { message?: string } } }) => { + if (err?.response?.status === 429) { + toast.error(err.response?.data?.message ?? "Daily limit reached"); + } else { + toast.error(err?.response?.data?.message ?? "AI review failed"); + } + }, + }); + + useEffect(() => { aiReviewMutation.reset(); }, [problem?.id, aiReviewMutation]); + const handleRun = useCallback(() => { if (!problem || !user || !isPremium) return; executeMutation.mutate({ problemId: problem.id, lang: language, code: codeMap[language] }); @@ -644,6 +659,7 @@ export default function DsaProblemDetailPage() { { key: "results" as const, label: "test results", icon: null }, { key: "output" as const, label: "output", icon: Terminal }, { key: "history" as const, label: "history", icon: History }, + { key: "ai-review" as const, label: "ai review", icon: Sparkles }, ]).map(({ key, label, icon: Icon }) => ( + + ); + } + + if (!review) { + return ( +
+
+ +
+
+

+ AI Code Review +

+

+ {hasSubmission + ? "Get AI feedback on your solution — complexity, readability, edge cases, and suggestions." + : "Run your code first, then request an AI review of your submission." + } +

+
+ +
+ ); + } + + return ( +
+ {/* Complexity */} +
+
+
+ + time +
+

+ {review.timeComplexity} +

+
+
+
+ + space +
+

+ {review.spaceComplexity} +

+
+
+ + {/* Readability */} +
+
+
+ + readability +
+
+ + {review.readability.score}/10 + + + {scoreLabel(review.readability.score)} + +
+
+ {/* Score bar */} +
+
+
+

+ {review.readability.feedback} +

+
+ + {/* Edge Cases */} + {review.edgeCases.length > 0 && ( +
+
+
+ + + edge cases ({review.edgeCases.length}) + +
+
+
    + {review.edgeCases.map((ec, i) => ( +
  • + + {String(i + 1).padStart(2, "0")} + + {ec} +
  • + ))} +
+
+ )} + + {/* Suggestions */} + {review.suggestions.length > 0 && ( +
+
+
+ + + suggestions ({review.suggestions.length}) + +
+
+
    + {review.suggestions.map((s, i) => ( +
  • + + {String(i + 1).padStart(2, "0")} + + {s} +
  • + ))} +
+
+ )} + + {/* Re-review button */} +
+ +
+
+ ); +} diff --git a/server/src/database/prisma/schema/base.prisma b/server/src/database/prisma/schema/base.prisma index e085596c0..197738ac1 100644 --- a/server/src/database/prisma/schema/base.prisma +++ b/server/src/database/prisma/schema/base.prisma @@ -1226,6 +1226,7 @@ enum AIServiceType { LATEX_CHAT EMAIL_CHAT AI_ROADMAP_GENERATION + DSA_CODE_REVIEW } enum BadgeCategory { diff --git a/server/src/module/admin/admin.validation.ts b/server/src/module/admin/admin.validation.ts index f0624a18c..8a8c73fa0 100644 --- a/server/src/module/admin/admin.validation.ts +++ b/server/src/module/admin/admin.validation.ts @@ -347,7 +347,15 @@ export const broadcastEmailSchema = z.object({ // ==================== AI PROVIDER MANAGEMENT ==================== export const switchAIProviderSchema = z.object({ - service: z.enum(["ATS_SCORE", "COVER_LETTER", "RESUME_GEN", "LATEX_CHAT","AI_ROADMAP_GENERATION"]), + service: z.enum([ + "ATS_SCORE", + "COVER_LETTER", + "RESUME_GEN", + "LATEX_CHAT", + "EMAIL_CHAT", + "AI_ROADMAP_GENERATION", + "DSA_CODE_REVIEW", + ]), provider: z.enum(["GEMINI", "GROQ", "OPENROUTER", "CODESTRAL", "CLAUDE"]), modelName: z.string().min(1, "Model name is required"), }); diff --git a/server/src/module/dsa/dsa.controller.ts b/server/src/module/dsa/dsa.controller.ts index f8a5a8d34..d0280b4b8 100644 --- a/server/src/module/dsa/dsa.controller.ts +++ b/server/src/module/dsa/dsa.controller.ts @@ -2,6 +2,8 @@ import type { Request, Response, NextFunction } from "express"; import { DsaService } from "./dsa.service.js"; import { parsePagination } from "../../utils/pagination.utils.js"; import { syncLeetCodeSolvedProblems } from "./leetcode.service.js"; +import { prisma } from "../../database/db.js"; +import { isPremiumUser } from "../../utils/premium.utils.js"; export class DsaController { constructor(private dsaService: DsaService) {} @@ -299,4 +301,34 @@ export class DsaController { next(err); } } + + async generateCodeReview(req: Request, res: Response, next: NextFunction) { + try { + const userId = req.user?.id; + if (!userId) { res.status(401).json({ message: "Authentication required" }); return; } + + const isPremium = await isPremiumUser(userId); + if (!isPremium) { + res.status(403).json({ message: "Premium subscription required" }); + return; + } + + const submissionId = parseInt(req.params.submissionId as string); + if (isNaN(submissionId)) { res.status(400).json({ message: "Invalid submission ID" }); return; } + const review = await this.dsaService.generateCodeReview(submissionId, userId); + res.json(review); + } catch (err) { + if (err instanceof Error) { + if (err.message.includes("Submission not found")) { + res.status(404).json({ message: err.message }); + return; + } + if (err.message.includes("Not authorized")) { + res.status(403).json({ message: err.message }); + return; + } + } + next(err); + } + } } diff --git a/server/src/module/dsa/dsa.routes.ts b/server/src/module/dsa/dsa.routes.ts index 37acd8d62..66e38d267 100644 --- a/server/src/module/dsa/dsa.routes.ts +++ b/server/src/module/dsa/dsa.routes.ts @@ -33,6 +33,7 @@ dsaRouter.get("/streak", authMiddleware, requireRole("STUDENT"), (req, res, next dsaRouter.post("/problems/:problemId/execute", authMiddleware, requireRole("STUDENT"), usageLimit("CODE_RUN"), (req, res, next) => dsaController.executeCode(req, res, next)); dsaRouter.get("/problems/:problemId/submissions", authMiddleware, requireRole("STUDENT"), (req, res, next) => dsaController.getSubmissionHistory(req, res, next)); +dsaRouter.post("/submissions/:submissionId/review", authMiddleware, requireRole("STUDENT"), usageLimit("CODE_RUN"), (req, res, next) => dsaController.generateCodeReview(req, res, next)); dsaRouter.post("/sync/leetcode", authMiddleware, requireRole("STUDENT"), (req, res, next) => dsaController.syncLeetCode(req, res, next)); // Public routes (with optional auth) diff --git a/server/src/module/dsa/dsa.service.ts b/server/src/module/dsa/dsa.service.ts index 0f41967db..65440bbe0 100644 --- a/server/src/module/dsa/dsa.service.ts +++ b/server/src/module/dsa/dsa.service.ts @@ -1,6 +1,9 @@ import { GoogleGenerativeAI } from "@google/generative-ai"; import { prisma } from "../../database/db.js"; import { executeCode, LANGUAGE_IDS } from "../../utils/judge0.utils.js"; +import { getProviderForService } from "../../lib/ai-provider-registry.js"; +import { logAIRequest } from "../../lib/ai-request-logger.js"; +import { codeReviewResponseSchema, type CodeReviewResponse } from "./dsa.validation.js"; interface TestCaseResult { input: string; @@ -330,7 +333,7 @@ export class DsaService { })); } - async reportProblem({userId, problemId, reason, message,}: { userId: number; problemId: number; reason: string; message?: string;}) { + async reportProblem({ userId, problemId, reason, message, }: { userId: number; problemId: number; reason: string; message?: string; }) { return prisma.dsaProblemReport.create({ data: { userId, @@ -937,4 +940,150 @@ Return ONLY a JSON array, no markdown fences: return similar.slice(0, limit); } + + // ── AI Code Review ── + + async generateCodeReview(submissionId: number, studentId: number): Promise { + // 1. Fetch submission and verify ownership + const submission = await prisma.dsaSubmission.findUnique({ + where: { id: submissionId }, + }); + if (!submission) throw new Error("Submission not found"); + if (submission.studentId !== studentId) throw new Error("Not authorized to review this submission"); + + // 2. Fetch problem context + const problem = await prisma.dsaProblem.findUnique({ + where: { id: submission.problemId }, + select: { + title: true, + description: true, + constraints: true, + difficulty: true, + tags: true, + }, + }); + if (!problem) throw new Error("Problem not found"); + + // 3. Call AI via provider registry + const provider = getProviderForService("DSA_CODE_REVIEW"); + const prompt = this.buildCodeReviewPrompt(submission, problem); + const response = await provider.generateText(prompt); + + // 4. Parse and validate + try { + const parsed = this.parseCodeReviewResponse(response.text); + // 5. Log success + logAIRequest("DSA_CODE_REVIEW", response, true, undefined, studentId); + + await prisma.usageLog.create({ + data: { userId: studentId, action: "CODE_RUN" }, + }); + + return parsed; + } catch (err) { + logAIRequest("DSA_CODE_REVIEW", response, false, err instanceof Error ? err.message : "Parse failed", studentId); + throw err; + } + } + + private buildCodeReviewPrompt( + submission: { code: string; language: string; passed: number; total: number; allPassed: boolean }, + problem: { title: string; description: string | null; constraints: string | null; difficulty: string; tags: string[] }, + ): string { + return `You are an expert DSA code reviewer. Analyze the following student submission and provide structured feedback. + +PROBLEM: ${problem.title} +DIFFICULTY: ${problem.difficulty} +TAGS: ${problem.tags.join(", ")} + +${problem.description ? `DESCRIPTION:\n${problem.description}` : ""} + +${problem.constraints ? `CONSTRAINTS:\n${problem.constraints}` : ""} + +STUDENT'S CODE (${submission.language}): +\`\`\`${submission.language} +${submission.code} +\`\`\` + +TEST RESULTS: ${submission.passed}/${submission.total} passed${submission.allPassed ? " (All passed)" : ""} + +Analyze the code and respond with ONLY valid JSON (no markdown formatting, no code blocks, no explanation) in this exact structure: +{ + "timeComplexity": "", + "spaceComplexity": "", + "readability": { + "score": , + "feedback": "" + }, + "edgeCases": [ + "", + "" + ], + "suggestions": [ + "", + "" + ] +} + +Rules: +1. Be specific to THIS problem and THIS code — avoid generic advice +2. If all tests passed, focus on optimization and code quality +3. If tests failed, prioritize correctness issues +4. Provide 2-4 edge cases and 2-5 suggestions +5. For readability score: 1-3 = poor, 4-6 = decent, 7-8 = good, 9-10 = excellent`; + } + + private parseCodeReviewResponse(responseText: string): CodeReviewResponse { + let jsonStr = responseText.trim(); + + // Strip markdown code fences if present + const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch?.[1]) { + jsonStr = jsonMatch[1].trim(); + } + + // Strip trailing commas before ] or } (common AI quirk) + jsonStr = jsonStr.replace(/,\s*([\]}])/g, "$1"); + + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch { + // Try to extract just the JSON object if there's surrounding text + const objMatch = jsonStr.match(/\{[\s\S]*\}/); + if (objMatch) { + const cleaned = objMatch[0].replace(/,\s*([\]}])/g, "$1"); + parsed = JSON.parse(cleaned); + } else { + parsed = {}; + } + } + + // Validate with Zod + const result = codeReviewResponseSchema.safeParse(parsed); + if (result.success) { + return result.data; + } + + // Fallback: return a best-effort response from the raw parsed data + const obj = parsed as Record; + return { + timeComplexity: typeof obj["timeComplexity"] === "string" ? obj["timeComplexity"] : "Unable to determine", + spaceComplexity: typeof obj["spaceComplexity"] === "string" ? obj["spaceComplexity"] : "Unable to determine", + readability: { + score: typeof (obj["readability"] as Record)?.["score"] === "number" + ? Math.max(1, Math.min(10, Math.round((obj["readability"] as Record)["score"] as number))) + : 5, + feedback: typeof (obj["readability"] as Record)?.["feedback"] === "string" + ? (obj["readability"] as Record)["feedback"] as string + : "No feedback available", + }, + edgeCases: Array.isArray(obj["edgeCases"]) + ? obj["edgeCases"].filter((s): s is string => typeof s === "string") + : [], + suggestions: Array.isArray(obj["suggestions"]) + ? obj["suggestions"].filter((s): s is string => typeof s === "string") + : [], + }; + } } diff --git a/server/src/module/dsa/dsa.validation.ts b/server/src/module/dsa/dsa.validation.ts index 9eee040eb..e5b0c3cc6 100644 --- a/server/src/module/dsa/dsa.validation.ts +++ b/server/src/module/dsa/dsa.validation.ts @@ -4,3 +4,16 @@ export const executeCodeSchema = z.object({ language: z.enum(["python", "cpp", "java"]), code: z.string().min(1, "Code is required").max(50000, "Code too long"), }); + +export const codeReviewResponseSchema = z.object({ + timeComplexity: z.string(), + spaceComplexity: z.string(), + readability: z.object({ + score: z.number().min(1).max(10), + feedback: z.string(), + }), + edgeCases: z.array(z.string()), + suggestions: z.array(z.string()), +}); + +export type CodeReviewResponse = z.infer;