algocoach 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/cli/index.ts +97 -0
- package/dist/assets/index-C4ELaZwf.css +1 -0
- package/dist/assets/index-D5iezweF.js +55 -0
- package/dist/favicon.svg +11 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +17 -0
- package/package.json +59 -0
- package/server/auth/index.ts +14 -0
- package/server/db/custom-types.ts +13 -0
- package/server/db/index.ts +32 -0
- package/server/db/schema.ts +130 -0
- package/server/db/setup.ts +144 -0
- package/server/index.ts +57 -0
- package/server/lib/validation.test.ts +91 -0
- package/server/lib/validation.ts +25 -0
- package/server/local-dev/index.ts +5 -0
- package/server/local-dev/test-ai.ts +88 -0
- package/server/middleware/auth.ts +30 -0
- package/server/middleware/rate-limit.test.ts +77 -0
- package/server/middleware/rate-limit.ts +30 -0
- package/server/routes/leetcode.ts +189 -0
- package/server/routes/onboard.ts +75 -0
- package/server/routes/plan.ts +595 -0
- package/server/routes/survey.ts +44 -0
- package/server/services/ai-provider.ts +171 -0
- package/server/services/ai.test.ts +61 -0
- package/server/services/ai.ts +368 -0
- package/server/services/leetcode-search.test.ts +85 -0
- package/server/services/leetcode-search.ts +84 -0
- package/server/services/leetcode.ts +84 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { GoogleGenAI } from "@google/genai"
|
|
2
|
+
import OpenAI from "openai"
|
|
3
|
+
|
|
4
|
+
const AI_TIMEOUT = 180_000
|
|
5
|
+
|
|
6
|
+
export interface GenerateOptions {
|
|
7
|
+
prompt: string
|
|
8
|
+
temperature?: number
|
|
9
|
+
jsonMode?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AIProvider {
|
|
13
|
+
generate(options: GenerateOptions): Promise<string>
|
|
14
|
+
generateStream(options: GenerateOptions): AsyncGenerator<string>
|
|
15
|
+
readonly model: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class GoogleAIProvider implements AIProvider {
|
|
19
|
+
private client: GoogleGenAI
|
|
20
|
+
readonly model: string
|
|
21
|
+
|
|
22
|
+
constructor(model?: string) {
|
|
23
|
+
const key = process.env.GEMINI_API_KEY
|
|
24
|
+
if (!key) throw new Error("GEMINI_API_KEY not set")
|
|
25
|
+
this.client = new GoogleGenAI({ apiKey: key, httpOptions: { timeout: AI_TIMEOUT } })
|
|
26
|
+
this.model = model || process.env.AI_MODEL || "gemma-4-26b-a4b-it"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async generate(options: GenerateOptions): Promise<string> {
|
|
30
|
+
let text = ""
|
|
31
|
+
for await (const chunk of this.generateStream(options)) {
|
|
32
|
+
text += chunk
|
|
33
|
+
}
|
|
34
|
+
return text
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async *generateStream(options: GenerateOptions): AsyncGenerator<string> {
|
|
38
|
+
const stream = await this.client.models.generateContentStream({
|
|
39
|
+
model: this.model,
|
|
40
|
+
contents: options.prompt,
|
|
41
|
+
config: { temperature: options.temperature ?? 0.7 },
|
|
42
|
+
})
|
|
43
|
+
for await (const chunk of stream) {
|
|
44
|
+
const t = chunk.text
|
|
45
|
+
if (t) yield t
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class NvidiaAIProvider implements AIProvider {
|
|
51
|
+
private client: OpenAI
|
|
52
|
+
readonly model: string
|
|
53
|
+
|
|
54
|
+
constructor(model?: string) {
|
|
55
|
+
const key = process.env.NVIDIA_API_KEY
|
|
56
|
+
if (!key) throw new Error("NVIDIA_API_KEY not set")
|
|
57
|
+
this.client = new OpenAI({
|
|
58
|
+
apiKey: key,
|
|
59
|
+
baseURL: "https://integrate.api.nvidia.com/v1",
|
|
60
|
+
timeout: AI_TIMEOUT,
|
|
61
|
+
maxRetries: 2,
|
|
62
|
+
})
|
|
63
|
+
this.model = model || process.env.AI_MODEL || "meta/llama-3.1-8b-instruct"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async generate(options: GenerateOptions): Promise<string> {
|
|
67
|
+
let text = ""
|
|
68
|
+
for await (const chunk of this.generateStream(options)) {
|
|
69
|
+
text += chunk
|
|
70
|
+
}
|
|
71
|
+
return text
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async *generateStream(options: GenerateOptions): AsyncGenerator<string> {
|
|
75
|
+
const stream = await this.client.chat.completions.create({
|
|
76
|
+
model: this.model,
|
|
77
|
+
messages: [{ role: "user", content: options.prompt }],
|
|
78
|
+
temperature: options.temperature ?? 0.7,
|
|
79
|
+
stream: true,
|
|
80
|
+
...(options.jsonMode ? { response_format: { type: "json_object" } } : {}),
|
|
81
|
+
})
|
|
82
|
+
for await (const chunk of stream) {
|
|
83
|
+
const content = chunk.choices?.[0]?.delta?.content
|
|
84
|
+
if (content) yield content
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let GroqClient: any = null
|
|
90
|
+
async function getGroqClient(apiKey: string) {
|
|
91
|
+
if (!GroqClient) {
|
|
92
|
+
const mod = await import("groq-sdk")
|
|
93
|
+
GroqClient = new mod.Groq({ apiKey })
|
|
94
|
+
}
|
|
95
|
+
return GroqClient
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class GroqAIProvider implements AIProvider {
|
|
99
|
+
readonly model: string
|
|
100
|
+
private apiKey: string
|
|
101
|
+
private clientPromise: Promise<any> | null = null
|
|
102
|
+
|
|
103
|
+
constructor(model?: string) {
|
|
104
|
+
const key = process.env.GROQ_API_KEY
|
|
105
|
+
if (!key) throw new Error("GROQ_API_KEY not set")
|
|
106
|
+
this.apiKey = key
|
|
107
|
+
this.model = model || process.env.AI_MODEL || "llama-3.3-70b-versatile"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async client() {
|
|
111
|
+
if (!this.clientPromise) {
|
|
112
|
+
this.clientPromise = getGroqClient(this.apiKey)
|
|
113
|
+
}
|
|
114
|
+
return this.clientPromise
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async generate(options: GenerateOptions): Promise<string> {
|
|
118
|
+
let text = ""
|
|
119
|
+
for await (const chunk of this.generateStream(options)) {
|
|
120
|
+
text += chunk
|
|
121
|
+
}
|
|
122
|
+
return text
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async *generateStream(options: GenerateOptions): AsyncGenerator<string> {
|
|
126
|
+
const client = await this.client()
|
|
127
|
+
const stream = await client.chat.completions.create({
|
|
128
|
+
model: this.model,
|
|
129
|
+
messages: [{ role: "user", content: options.prompt }],
|
|
130
|
+
temperature: options.temperature ?? 0.7,
|
|
131
|
+
stream: true,
|
|
132
|
+
...(options.jsonMode ? { response_format: { type: "json_object" } } : {}),
|
|
133
|
+
})
|
|
134
|
+
for await (const chunk of stream) {
|
|
135
|
+
const content = chunk.choices?.[0]?.delta?.content
|
|
136
|
+
if (content) yield content
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createProvider(model?: string): AIProvider {
|
|
142
|
+
const providerName = process.env.AI_PROVIDER || "google"
|
|
143
|
+
switch (providerName) {
|
|
144
|
+
case "groq":
|
|
145
|
+
return new GroqAIProvider(model)
|
|
146
|
+
case "google":
|
|
147
|
+
return new GoogleAIProvider(model)
|
|
148
|
+
case "nvidia":
|
|
149
|
+
return new NvidiaAIProvider(model)
|
|
150
|
+
default:
|
|
151
|
+
throw new Error(`Unknown AI provider: ${providerName}. Use "google", "groq", or "nvidia"`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function extractJson(text: string): string {
|
|
156
|
+
const lastBrace = text.lastIndexOf("}")
|
|
157
|
+
const lastBracket = text.lastIndexOf("]")
|
|
158
|
+
const end = Math.max(lastBrace, lastBracket)
|
|
159
|
+
if (end === -1) return text
|
|
160
|
+
|
|
161
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
162
|
+
if (text[i] === "{" || text[i] === "[") {
|
|
163
|
+
const candidate = text.slice(i, end + 1)
|
|
164
|
+
try {
|
|
165
|
+
JSON.parse(candidate)
|
|
166
|
+
return candidate
|
|
167
|
+
} catch { }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return text
|
|
171
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import { extractJson } from "./ai-provider"
|
|
3
|
+
|
|
4
|
+
describe("extractJson", () => {
|
|
5
|
+
test("extracts JSON object from end of chain-of-thought text", () => {
|
|
6
|
+
const input = `* Input: something
|
|
7
|
+
* Constraint: some rules
|
|
8
|
+
* The JSON: \`{"message": "hello"}\`{"message": "hello"}`
|
|
9
|
+
const parsed = JSON.parse(extractJson(input))
|
|
10
|
+
expect(parsed.message).toBe("hello")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("extracts JSON array from end of text", () => {
|
|
14
|
+
const input = `Here is the roadmap:
|
|
15
|
+
[
|
|
16
|
+
{ "week": 1, "topic": "Arrays", "description": "Learn arrays", "problemsCount": 5 }
|
|
17
|
+
]
|
|
18
|
+
End.`
|
|
19
|
+
const extracted = extractJson(input)
|
|
20
|
+
expect(() => JSON.parse(extracted)).not.toThrow()
|
|
21
|
+
expect(JSON.parse(extracted)[0].week).toBe(1)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("returns original text if no JSON found", () => {
|
|
25
|
+
const input = "Just plain text without any JSON"
|
|
26
|
+
expect(extractJson(input)).toBe(input)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("handles clean JSON input", () => {
|
|
30
|
+
const input = `{"key": "value"}`
|
|
31
|
+
expect(extractJson(input)).toBe(input)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("handles empty string", () => {
|
|
35
|
+
expect(extractJson("")).toBe("")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("extracts JSON with trailing backticks", () => {
|
|
39
|
+
const input = "```json\n{\"key\": \"value\"}\n```"
|
|
40
|
+
const parsed = JSON.parse(extractJson(input))
|
|
41
|
+
expect(parsed.key).toBe("value")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("extracts JSON from markdown code blocks", () => {
|
|
45
|
+
const input = "```json\n[{\"week\":1,\"topic\":\"Arrays\"}]\n```"
|
|
46
|
+
const parsed = JSON.parse(extractJson(input))
|
|
47
|
+
expect(parsed[0].topic).toBe("Arrays")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("picks the LAST JSON object when multiple exist", () => {
|
|
51
|
+
const input = `Some text {"first": "one"} more text {"second": "two"}`
|
|
52
|
+
const parsed = JSON.parse(extractJson(input))
|
|
53
|
+
expect(parsed.second).toBe("two")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("picks the LAST JSON array when multiple exist", () => {
|
|
57
|
+
const input = `first [1, 2] second [3, 4]`
|
|
58
|
+
const parsed = JSON.parse(extractJson(input))
|
|
59
|
+
expect(parsed).toEqual([3, 4])
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { eq, and } from "drizzle-orm"
|
|
2
|
+
import { db } from "../db"
|
|
3
|
+
import { roadmapJob, roadmapPlan, userPreferences } from "../db/schema"
|
|
4
|
+
import { createProvider, extractJson } from "./ai-provider"
|
|
5
|
+
import { searchLeetCodeProblems } from "./leetcode-search"
|
|
6
|
+
|
|
7
|
+
const provider = createProvider()
|
|
8
|
+
|
|
9
|
+
interface UserPreferences {
|
|
10
|
+
experienceLevel: string
|
|
11
|
+
goals: string[]
|
|
12
|
+
weakTopics: string[]
|
|
13
|
+
targetCompanies?: string[]
|
|
14
|
+
hoursPerWeek: number
|
|
15
|
+
targetDate?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface RoadmapWeek {
|
|
19
|
+
week: number
|
|
20
|
+
topic: string
|
|
21
|
+
description: string
|
|
22
|
+
problemsCount: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildPrompt(prefs: UserPreferences): string {
|
|
26
|
+
return `You are a LeetCode coach creating a personalized study roadmap.
|
|
27
|
+
|
|
28
|
+
User Profile:
|
|
29
|
+
- Experience: ${prefs.experienceLevel}
|
|
30
|
+
- Goals: ${prefs.goals.join(", ")}
|
|
31
|
+
- Weak topics: ${prefs.weakTopics.join(", ")}
|
|
32
|
+
- Target companies: ${prefs.targetCompanies?.join(", ") || "Not specified"}
|
|
33
|
+
- Hours per week: ${prefs.hoursPerWeek}
|
|
34
|
+
- Target date: ${prefs.targetDate || "No deadline"}
|
|
35
|
+
|
|
36
|
+
Create a structured weekly roadmap that:
|
|
37
|
+
1. Starts with fundamentals and progresses to advanced topics
|
|
38
|
+
2. Prioritizes the user's weak topics
|
|
39
|
+
3. Allocates more weeks to harder topics
|
|
40
|
+
4. Is realistic given their hours per week
|
|
41
|
+
|
|
42
|
+
Return a JSON array where each entry has: week (number), topic (string), description (string), problemsCount (number).
|
|
43
|
+
|
|
44
|
+
IMPORTANT: Each topic MUST be a valid LeetCode problem tag name (e.g., "Arrays", "Strings", "Hash Table", "Dynamic Programming", "Linked List", "Binary Search", "Trees", "Graph", "Heap", "Backtracking", "Sliding Window", "Two Pointers", "Stack", "Queue", "Math", "Sorting", "Greedy", "Recursion", "Bit Manipulation"). Use STANDARD LeetCode tag names only, separated by commas if combining topics. EXAMPLE: "Binary Search, Bit Manipulation" not "Binary Search & Bit Manipulation".
|
|
45
|
+
|
|
46
|
+
Aim for 4-12 weeks total depending on the user's experience and goals.`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function generateRoadmap(preferences: UserPreferences): Promise<RoadmapWeek[]> {
|
|
50
|
+
const prompt = buildPrompt(preferences)
|
|
51
|
+
const text = extractJson(await provider.generate({ prompt, temperature: 0.7, jsonMode: true }))
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(text)
|
|
54
|
+
return Array.isArray(parsed) ? parsed : parsed.weeks || parsed.roadmap || []
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error("Failed to parse roadmap from AI response")
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function processRoadmapJob(jobId: string): Promise<void> {
|
|
61
|
+
const [job] = await db.update(roadmapJob)
|
|
62
|
+
.set({ status: "processing", updatedAt: new Date() })
|
|
63
|
+
.where(and(eq(roadmapJob.id, jobId), eq(roadmapJob.status, "pending")))
|
|
64
|
+
.returning()
|
|
65
|
+
if (!job) return
|
|
66
|
+
|
|
67
|
+
const prefs = await db.query.userPreferences.findFirst({ where: eq(userPreferences.userId, job.userId) })
|
|
68
|
+
if (!prefs) {
|
|
69
|
+
await db.update(roadmapJob).set({ status: "error", error: "Complete onboarding first", updatedAt: new Date() }).where(eq(roadmapJob.id, jobId))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const prompt = buildPrompt({
|
|
74
|
+
experienceLevel: prefs.experienceLevel,
|
|
75
|
+
goals: prefs.goals,
|
|
76
|
+
weakTopics: prefs.weakTopics,
|
|
77
|
+
targetCompanies: prefs.targetCompanies ?? undefined,
|
|
78
|
+
hoursPerWeek: prefs.hoursPerWeek,
|
|
79
|
+
targetDate: prefs.targetDate?.toISOString(),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
let fullText = ""
|
|
83
|
+
try {
|
|
84
|
+
for await (const chunk of provider.generateStream({ prompt, temperature: 0.7 })) {
|
|
85
|
+
fullText += chunk
|
|
86
|
+
await db.update(roadmapJob).set({ progress: fullText, updatedAt: new Date() }).where(eq(roadmapJob.id, jobId))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cleaned = extractJson(fullText)
|
|
90
|
+
let parsed: any
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(cleaned)
|
|
93
|
+
} catch {
|
|
94
|
+
await db.update(roadmapJob).set({ status: "error", error: "Failed to parse roadmap from AI response", updatedAt: new Date() }).where(eq(roadmapJob.id, jobId))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const weeks = Array.isArray(parsed) ? parsed : parsed.weeks || parsed.roadmap || []
|
|
99
|
+
|
|
100
|
+
const existing = await db.query.roadmapPlan.findFirst({ where: eq(roadmapPlan.userId, job.userId) })
|
|
101
|
+
if (existing) {
|
|
102
|
+
await db.update(roadmapPlan).set({ weeks, currentWeek: 1, updatedAt: new Date() }).where(eq(roadmapPlan.userId, job.userId))
|
|
103
|
+
} else {
|
|
104
|
+
await db.insert(roadmapPlan).values({ id: crypto.randomUUID(), userId: job.userId, weeks, currentWeek: 1 })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await db.update(roadmapJob).set({
|
|
108
|
+
status: "done",
|
|
109
|
+
result: weeks,
|
|
110
|
+
progress: fullText,
|
|
111
|
+
updatedAt: new Date(),
|
|
112
|
+
}).where(eq(roadmapJob.id, jobId))
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
await db.update(roadmapJob).set({
|
|
115
|
+
status: "error",
|
|
116
|
+
error: err.message || "Unknown error",
|
|
117
|
+
updatedAt: new Date(),
|
|
118
|
+
}).where(eq(roadmapJob.id, jobId))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function startJobProcessing(jobId: string): void {
|
|
123
|
+
processRoadmapJob(jobId).catch((err) => console.error("Roadmap job failed:", err))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface LeetCodeProblem {
|
|
127
|
+
title: string
|
|
128
|
+
titleSlug: string
|
|
129
|
+
difficulty: "Easy" | "Medium" | "Hard"
|
|
130
|
+
frontendQuestionId: string
|
|
131
|
+
topicTags: { name: string; slug: string }[]
|
|
132
|
+
acRate: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface DailyTask {
|
|
136
|
+
problems: {
|
|
137
|
+
title: string
|
|
138
|
+
titleSlug: string
|
|
139
|
+
difficulty: string
|
|
140
|
+
topicTags: string[]
|
|
141
|
+
leetcodeUrl: string
|
|
142
|
+
acRate: number
|
|
143
|
+
}[]
|
|
144
|
+
explanation: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function selectDailyProblems(params: {
|
|
148
|
+
roadmap: RoadmapWeek[]
|
|
149
|
+
currentWeek: number
|
|
150
|
+
progress: { problemId: string; status: string }[]
|
|
151
|
+
dedupCount: Record<string, number>
|
|
152
|
+
solvedSlugs: string[]
|
|
153
|
+
count?: number
|
|
154
|
+
difficultyFilter?: "EASY" | "MEDIUM" | "HARD" | "MIXED"
|
|
155
|
+
excludeSlugs?: string[]
|
|
156
|
+
}): Promise<DailyTask> {
|
|
157
|
+
const {
|
|
158
|
+
roadmap, currentWeek, dedupCount, solvedSlugs,
|
|
159
|
+
count = 3, difficultyFilter = "MIXED", excludeSlugs: extraExclude = [],
|
|
160
|
+
} = params
|
|
161
|
+
const week = roadmap[currentWeek - 1]
|
|
162
|
+
if (!week) throw new Error(`Week ${currentWeek} not found in roadmap`)
|
|
163
|
+
|
|
164
|
+
const overusedSlugs = Object.entries(dedupCount)
|
|
165
|
+
.filter(([, c]) => c >= 3)
|
|
166
|
+
.map(([slug]) => slug)
|
|
167
|
+
|
|
168
|
+
const exclude = [...new Set([...overusedSlugs, ...solvedSlugs, ...extraExclude])]
|
|
169
|
+
|
|
170
|
+
const topicSlugs = parseTopicToSlugs(week.topic)
|
|
171
|
+
let searchResults = await searchLeetCodeProblems({
|
|
172
|
+
topics: topicSlugs,
|
|
173
|
+
excludeSlugs: exclude,
|
|
174
|
+
limit: 30,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if (!searchResults.length) {
|
|
178
|
+
return {
|
|
179
|
+
problems: [],
|
|
180
|
+
explanation: `No unsolved problems found for ${week.topic}. Try a different topic or reset progress.`,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (difficultyFilter === "EASY") {
|
|
185
|
+
const filtered = searchResults.filter((p) => p.difficulty === "Easy")
|
|
186
|
+
if (filtered.length) searchResults = filtered
|
|
187
|
+
} else if (difficultyFilter === "MEDIUM") {
|
|
188
|
+
const filtered = searchResults.filter((p) => p.difficulty === "Medium")
|
|
189
|
+
if (filtered.length) searchResults = filtered
|
|
190
|
+
} else if (difficultyFilter === "HARD") {
|
|
191
|
+
const filtered = searchResults.filter((p) => p.difficulty === "Hard")
|
|
192
|
+
if (filtered.length) searchResults = filtered
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const easy = searchResults.filter((p) => p.difficulty === "Easy").sort((a, b) => b.acRate - a.acRate)
|
|
196
|
+
const medium = searchResults.filter((p) => p.difficulty === "Medium").sort((a, b) => b.acRate - a.acRate)
|
|
197
|
+
const hard = searchResults.filter((p) => p.difficulty === "Hard").sort((a, b) => b.acRate - a.acRate)
|
|
198
|
+
|
|
199
|
+
const selected: LeetCodeProblem[] = []
|
|
200
|
+
const usedTags = new Set<string>()
|
|
201
|
+
|
|
202
|
+
function pickFromBucket(bucket: LeetCodeProblem[], want: number): LeetCodeProblem[] {
|
|
203
|
+
const result: LeetCodeProblem[] = []
|
|
204
|
+
const diverse = bucket.filter((p) => p.topicTags.some((t) => !usedTags.has(t.slug)))
|
|
205
|
+
const rest = bucket.filter((p) => !diverse.includes(p))
|
|
206
|
+
for (const pool of [diverse, rest]) {
|
|
207
|
+
for (const p of pool) {
|
|
208
|
+
if (result.length >= want) break
|
|
209
|
+
result.push(p)
|
|
210
|
+
p.topicTags.forEach((t) => usedTags.add(t.slug))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return result
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (difficultyFilter !== "MIXED") {
|
|
217
|
+
const pool = difficultyFilter === "EASY" ? easy : difficultyFilter === "MEDIUM" ? medium : hard
|
|
218
|
+
selected.push(...pickFromBucket(pool, count))
|
|
219
|
+
} else {
|
|
220
|
+
if (easy.length) selected.push(...pickFromBucket(easy, 1))
|
|
221
|
+
if (medium.length) selected.push(...pickFromBucket(medium, 1))
|
|
222
|
+
if (hard.length) selected.push(...pickFromBucket(hard, 1))
|
|
223
|
+
if (selected.length < count) {
|
|
224
|
+
const remaining = [...easy, ...medium, ...hard].filter(
|
|
225
|
+
(p) => !selected.some((s) => s.titleSlug === p.titleSlug),
|
|
226
|
+
)
|
|
227
|
+
selected.push(...pickFromBucket(remaining, count - selected.length))
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const problems = selected.slice(0, count).map((p) => ({
|
|
232
|
+
title: p.title,
|
|
233
|
+
titleSlug: p.titleSlug,
|
|
234
|
+
difficulty: p.difficulty,
|
|
235
|
+
topicTags: p.topicTags.map((t) => t.name),
|
|
236
|
+
leetcodeUrl: `https://leetcode.com/problems/${p.titleSlug}/`,
|
|
237
|
+
acRate: p.acRate,
|
|
238
|
+
}))
|
|
239
|
+
|
|
240
|
+
const difficultySummary = problems.map((p) => p.difficulty).join(" → ")
|
|
241
|
+
return {
|
|
242
|
+
problems,
|
|
243
|
+
explanation: `${week.topic}: ${problems.length} problem(s) - ${difficultySummary}. Progressing from easier to harder.`,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const TOPIC_ALIASES: Record<string, string> = {
|
|
248
|
+
"array": "array",
|
|
249
|
+
"arrays": "array",
|
|
250
|
+
"hashing": "hash-table",
|
|
251
|
+
"hash table": "hash-table",
|
|
252
|
+
"hash tables": "hash-table",
|
|
253
|
+
"hashmap": "hash-table",
|
|
254
|
+
"hash map": "hash-table",
|
|
255
|
+
"hash maps": "hash-table",
|
|
256
|
+
"string": "string",
|
|
257
|
+
"strings": "string",
|
|
258
|
+
"two pointer": "two-pointers",
|
|
259
|
+
"two pointers": "two-pointers",
|
|
260
|
+
"sliding window": "sliding-window",
|
|
261
|
+
"binary search": "binary-search",
|
|
262
|
+
"sorting": "sorting",
|
|
263
|
+
"sort": "sorting",
|
|
264
|
+
"recursion": "recursion",
|
|
265
|
+
"recursive": "recursion",
|
|
266
|
+
"backtracking": "backtracking",
|
|
267
|
+
"stack": "stack",
|
|
268
|
+
"queue": "queue",
|
|
269
|
+
"linked list": "linked-list",
|
|
270
|
+
"linked lists": "linked-list",
|
|
271
|
+
"linkedlist": "linked-list",
|
|
272
|
+
"tree": "binary-tree",
|
|
273
|
+
"trees": "binary-tree",
|
|
274
|
+
"binary tree": "binary-tree",
|
|
275
|
+
"binary trees": "binary-tree",
|
|
276
|
+
"bst": "binary-search-tree",
|
|
277
|
+
"binary search tree": "binary-search-tree",
|
|
278
|
+
"heap": "heap-priority-queue",
|
|
279
|
+
"heaps": "heap-priority-queue",
|
|
280
|
+
"priority queue": "heap-priority-queue",
|
|
281
|
+
"graph": "graph",
|
|
282
|
+
"graphs": "graph",
|
|
283
|
+
"dynamic programming": "dynamic-programming",
|
|
284
|
+
"dp": "dynamic-programming",
|
|
285
|
+
"greedy": "greedy",
|
|
286
|
+
"bit manipulation": "bit-manipulation",
|
|
287
|
+
"bit": "bit-manipulation",
|
|
288
|
+
"trie": "trie",
|
|
289
|
+
"prefix tree": "trie",
|
|
290
|
+
"union find": "union-find",
|
|
291
|
+
"disjoint set": "union-find",
|
|
292
|
+
"divide and conquer": "divide-and-conquer",
|
|
293
|
+
"segment tree": "segment-tree",
|
|
294
|
+
"fenwick tree": "fenwick-tree",
|
|
295
|
+
"binary indexed tree": "fenwick-tree",
|
|
296
|
+
"bit (fenwick)": "fenwick-tree",
|
|
297
|
+
"topological sort": "topological-sort",
|
|
298
|
+
"topological": "topological-sort",
|
|
299
|
+
"monotonic stack": "monotonic-stack",
|
|
300
|
+
"monotonic queue": "monotonic-queue",
|
|
301
|
+
"prefix sum": "prefix-sum",
|
|
302
|
+
"prefix sums": "prefix-sum",
|
|
303
|
+
"counting": "counting",
|
|
304
|
+
"memoization": "memoization",
|
|
305
|
+
"memo": "memoization",
|
|
306
|
+
"matrix": "matrix",
|
|
307
|
+
"matrices": "matrix",
|
|
308
|
+
"math": "math",
|
|
309
|
+
"mathematics": "math",
|
|
310
|
+
"number theory": "math",
|
|
311
|
+
"geometry": "geometry",
|
|
312
|
+
"simulation": "simulation",
|
|
313
|
+
"design": "design",
|
|
314
|
+
"data stream": "data-stream",
|
|
315
|
+
"iterator": "iterator",
|
|
316
|
+
"string matching": "string-matching",
|
|
317
|
+
"kmp": "string-matching",
|
|
318
|
+
"rabin-karp": "string-matching",
|
|
319
|
+
"rolling hash": "rolling-hash",
|
|
320
|
+
"suffix array": "suffix-array",
|
|
321
|
+
"shortest path": "shortest-path",
|
|
322
|
+
"dijkstra": "shortest-path",
|
|
323
|
+
"bellman-ford": "shortest-path",
|
|
324
|
+
"mst": "minimum-spanning-tree",
|
|
325
|
+
"minimum spanning tree": "minimum-spanning-tree",
|
|
326
|
+
"scc": "strongly-connected-component",
|
|
327
|
+
"strongly connected component": "strongly-connected-component",
|
|
328
|
+
"articulation point": "articulation-point",
|
|
329
|
+
"game theory": "game-theory",
|
|
330
|
+
"combinatorics": "combinatorics",
|
|
331
|
+
"quickselect": "quickselect",
|
|
332
|
+
"bucket sort": "bucket-sort",
|
|
333
|
+
"counting sort": "counting-sort",
|
|
334
|
+
"radix sort": "radix-sort",
|
|
335
|
+
"line sweep": "line-sweep",
|
|
336
|
+
"merge sort": "merge-sort",
|
|
337
|
+
"ordered set": "ordered-set",
|
|
338
|
+
"ordered map": "ordered-map",
|
|
339
|
+
"doubly-linked list": "doubly-linked-list",
|
|
340
|
+
"circular linked list": "circular-linked-list",
|
|
341
|
+
"circular array": "circular-array",
|
|
342
|
+
"brainteaser": "brainteaser",
|
|
343
|
+
"reservoir sampling": "reservoir-sampling",
|
|
344
|
+
"rejection sampling": "rejection-sampling",
|
|
345
|
+
"probability": "probability-and-statistics",
|
|
346
|
+
"statistics": "probability-and-statistics",
|
|
347
|
+
"database": "database",
|
|
348
|
+
"sql": "sql",
|
|
349
|
+
"shell": "shell",
|
|
350
|
+
"json": "json",
|
|
351
|
+
"concurrency": "concurrency",
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parseTopicToSlugs(topic: string): string[] {
|
|
355
|
+
const parts = topic.split(/[,&/]/).map((s) => s.trim()).filter(Boolean)
|
|
356
|
+
const slugs = new Set<string>()
|
|
357
|
+
for (const part of parts) {
|
|
358
|
+
const normalized = part.toLowerCase().replace(/[^a-z0-9\s-]+/g, "").trim()
|
|
359
|
+
const mapped = TOPIC_ALIASES[normalized]
|
|
360
|
+
if (mapped) {
|
|
361
|
+
slugs.add(mapped)
|
|
362
|
+
} else {
|
|
363
|
+
const slug = normalized.replace(/\s+/g, "-").replace(/^-|-$/g, "")
|
|
364
|
+
if (slug) slugs.add(slug)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return [...slugs]
|
|
368
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
|
2
|
+
import { searchLeetCodeProblems } from "./leetcode-search"
|
|
3
|
+
|
|
4
|
+
const MOCK_SUCCESS = {
|
|
5
|
+
data: {
|
|
6
|
+
problemsetQuestionList: {
|
|
7
|
+
total: 5,
|
|
8
|
+
questions: [
|
|
9
|
+
{ acRate: 57.6, difficulty: "Easy", frontendQuestionId: "1", paidOnly: false, title: "Two Sum", titleSlug: "two-sum", topicTags: [{ name: "Array", slug: "array" }] },
|
|
10
|
+
{ acRate: 47.7, difficulty: "Easy", frontendQuestionId: "14", paidOnly: false, title: "Longest Common Prefix", titleSlug: "longest-common-prefix", topicTags: [{ name: "Array", slug: "array" }] },
|
|
11
|
+
{ acRate: 41.1, difficulty: "Medium", frontendQuestionId: "322", paidOnly: false, title: "Coin Change", titleSlug: "coin-change", topicTags: [{ name: "Dynamic Programming", slug: "dynamic-programming" }] },
|
|
12
|
+
{ acRate: 31.2, difficulty: "Medium", frontendQuestionId: "5", paidOnly: false, title: "Longest Palindromic Substring", titleSlug: "longest-palindromic-substring", topicTags: [{ name: "String", slug: "string" }] },
|
|
13
|
+
{ acRate: 60.0, difficulty: "Hard", frontendQuestionId: "100", paidOnly: true, title: "Paid Problem", titleSlug: "paid-problem", topicTags: [] },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const originalFetch = globalThis.fetch
|
|
20
|
+
|
|
21
|
+
describe("searchLeetCodeProblems", () => {
|
|
22
|
+
afterAll(() => { globalThis.fetch = originalFetch })
|
|
23
|
+
|
|
24
|
+
test("returns problems matching topic and difficulty", async () => {
|
|
25
|
+
globalThis.fetch = () => Promise.resolve({ json: () => Promise.resolve(MOCK_SUCCESS) } as Response)
|
|
26
|
+
const problems = await searchLeetCodeProblems({ topics: ["array"], difficulty: "EASY" })
|
|
27
|
+
expect(problems.length).toBeGreaterThan(0)
|
|
28
|
+
expect(problems[0]).toHaveProperty("title")
|
|
29
|
+
expect(problems[0]).toHaveProperty("titleSlug")
|
|
30
|
+
expect(problems[0]).toHaveProperty("difficulty")
|
|
31
|
+
expect(problems[0]).toHaveProperty("acRate")
|
|
32
|
+
expect(problems[0]).toHaveProperty("topicTags")
|
|
33
|
+
expect(Array.isArray(problems[0].topicTags)).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("filters out paid-only problems", async () => {
|
|
37
|
+
globalThis.fetch = () => Promise.resolve({ json: () => Promise.resolve(MOCK_SUCCESS) } as Response)
|
|
38
|
+
const problems = await searchLeetCodeProblems({})
|
|
39
|
+
const paidSlugs = problems.filter(p => MOCK_SUCCESS.data.problemsetQuestionList.questions.find(q => q.titleSlug === p.titleSlug)?.paidOnly)
|
|
40
|
+
expect(paidSlugs.length).toBe(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("excludes specified slugs", async () => {
|
|
44
|
+
globalThis.fetch = () => Promise.resolve({ json: () => Promise.resolve(MOCK_SUCCESS) } as Response)
|
|
45
|
+
const problems = await searchLeetCodeProblems({ excludeSlugs: ["two-sum"], limit: 10 })
|
|
46
|
+
expect(problems.some(p => p.titleSlug === "two-sum")).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("throws on API errors", async () => {
|
|
50
|
+
globalThis.fetch = () => Promise.resolve({ json: () => Promise.resolve({ errors: [{ message: "Rate limited" }] }) } as Response)
|
|
51
|
+
expect(searchLeetCodeProblems({})).rejects.toThrow("Rate limited")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("returns empty array when no results", async () => {
|
|
55
|
+
globalThis.fetch = () => Promise.resolve({ json: () => Promise.resolve({ data: { problemsetQuestionList: { total: 0, questions: [] } } }) } as Response)
|
|
56
|
+
const problems = await searchLeetCodeProblems({})
|
|
57
|
+
expect(problems).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("respects the limit parameter", async () => {
|
|
61
|
+
const manyResults = {
|
|
62
|
+
data: {
|
|
63
|
+
problemsetQuestionList: {
|
|
64
|
+
total: 20,
|
|
65
|
+
questions: Array.from({ length: 20 }, (_, i) => ({
|
|
66
|
+
acRate: 50, difficulty: "Easy", frontendQuestionId: String(i + 1), isPaidOnly: false,
|
|
67
|
+
title: `Problem ${i + 1}`, titleSlug: `problem-${i + 1}`, topicTags: [],
|
|
68
|
+
})),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
globalThis.fetch = () => Promise.resolve({ json: () => Promise.resolve(manyResults) } as Response)
|
|
73
|
+
const problems = await searchLeetCodeProblems({ limit: 5 })
|
|
74
|
+
expect(problems.length).toBe(5)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("real API: returns real problems", async () => {
|
|
78
|
+
globalThis.fetch = originalFetch
|
|
79
|
+
const problems = await searchLeetCodeProblems({ topics: ["array"], limit: 2 })
|
|
80
|
+
expect(problems.length).toBeGreaterThan(0)
|
|
81
|
+
expect(problems[0].title).toBeTruthy()
|
|
82
|
+
expect(problems[0].titleSlug).toBeTruthy()
|
|
83
|
+
expect(["Easy", "Medium", "Hard"]).toContain(problems[0].difficulty)
|
|
84
|
+
}, { timeout: 15000 })
|
|
85
|
+
})
|