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.
@@ -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
+ })