diff --git a/src/__tests__/lib/categorizer.test.ts b/src/__tests__/lib/categorizer.test.ts index 8618e7a7..0f1fafc0 100644 --- a/src/__tests__/lib/categorizer.test.ts +++ b/src/__tests__/lib/categorizer.test.ts @@ -14,6 +14,33 @@ jest.mock('groq-sdk', () => { if (userMsg.includes('recursive')) { return { choices: [{ message: { content: 'Recursion' } }] }; } + if (userMsg.includes('lowercase-test')) { + return { choices: [{ message: { content: 'recursion' } }] }; + } + if (userMsg.includes('punctuation-test')) { + return { choices: [{ message: { content: 'Recursion.' } }] }; + } + if (userMsg.includes('whitespace-test')) { + return { choices: [{ message: { content: ' Differential Calculus ' } }] }; + } + if (userMsg.includes('empty-test')) { + return { choices: [{ message: { content: '' } }] }; + } + if (userMsg.includes('quoted-test')) { + return { choices: [{ message: { content: '"Recursion"' } }] }; + } + if (userMsg.includes('ellipsis-test')) { + return { choices: [{ message: { content: 'Recursion…' } }] }; + } + if (userMsg.includes('trailing-junk-test')) { + return { choices: [{ message: { content: 'Recursion) . .' } }] }; + } + if (userMsg.includes('sql-test')) { + return { choices: [{ message: { content: 'SQL' } }] }; + } + if (userMsg.includes('api-design-test')) { + return { choices: [{ message: { content: 'API Design' } }] }; + } return { choices: [{ message: { content: 'General' } }] }; }) } @@ -37,4 +64,51 @@ describe('AI Categorizer Service', () => { const category = await categorizeDoubt('Random text without keywords', 'Other'); expect(category).toBe('General'); }); -}); + + describe('output normalization', () => { + it('should title-case a lowercase response', async () => { + const category = await categorizeDoubt('lowercase-test', 'Programming'); + expect(category).toBe('Recursion'); + }); + + it('should strip trailing punctuation', async () => { + const category = await categorizeDoubt('punctuation-test', 'Programming'); + expect(category).toBe('Recursion'); + }); + + it('should collapse extra whitespace and title-case multi-word responses', async () => { + const category = await categorizeDoubt('whitespace-test', 'Mathematics'); + expect(category).toBe('Differential Calculus'); + }); + + it('should fallback to General when the model returns an empty string', async () => { + const category = await categorizeDoubt('empty-test', 'Other'); + expect(category).toBe('General'); + }); + + it('should strip leading and trailing quotes', async () => { + const category = await categorizeDoubt('quoted-test', 'Programming'); + expect(category).toBe('Recursion'); + }); + + it('should strip non-ASCII trailing punctuation like ellipsis', async () => { + const category = await categorizeDoubt('ellipsis-test', 'Programming'); + expect(category).toBe('Recursion'); + }); + + it('should strip trailing parenthesis and spaced periods', async () => { + const category = await categorizeDoubt('trailing-junk-test', 'Programming'); + expect(category).toBe('Recursion'); + }); + + it('should preserve known acronyms instead of lowercasing them', async () => { + const category = await categorizeDoubt('sql-test', 'Databases'); + expect(category).toBe('SQL'); + }); + + it('should preserve acronym casing within multi-word responses', async () => { + const category = await categorizeDoubt('api-design-test', 'Programming'); + expect(category).toBe('API Design'); + }); + }); +}); \ No newline at end of file diff --git a/src/lib/ai/categorizer.ts b/src/lib/ai/categorizer.ts index 314c0e3e..cf6c10c0 100644 --- a/src/lib/ai/categorizer.ts +++ b/src/lib/ai/categorizer.ts @@ -1,6 +1,25 @@ import type Groq from 'groq-sdk'; import { groq } from '@/lib/ai/groq-client'; - +/** + * Normalizes a raw LLM category string into a consistent, title-cased, + * punctuation-free label so the same topic never gets stored under + * multiple distinct strings (e.g. "recursion" vs "Recursion."). + */ +function normalizeCategory(raw: string): string { + return raw + .trim() + .split(/\s+/) + .map(w => w.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, "")) + .filter(Boolean) + .map(w => { + // Preserve likely acronyms (all-caps, 2+ letters) as-is + if (w.length > 1 && w === w.toUpperCase() && /[A-Z]/.test(w)) { + return w; + } + return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); + }) + .join(" "); +} /** * Automatically categorizes a doubt into a specific sub-topic based on its content and subject. */ @@ -33,7 +52,8 @@ Respond ONLY with the sub-topic name. No punctuation or explanation.`; max_tokens: 20, }); - return completion.choices[0]?.message?.content?.trim() || "General"; + const raw = completion.choices[0]?.message?.content?.trim() || ""; + return normalizeCategory(raw) || "General"; } catch (error) { console.error("Categorization failed:", error); return "General";