@@ -4,6 +4,7 @@ import { generateText } from 'ai';
44import { PrismaService } from 'prisma/prisma.service' ;
55import { AdaptiveService } from '../adaptive/adaptive.service' ;
66import { buildAdaptiveQuizPrompt } from './prompts/adaptive-quiz-generation' ;
7+ import { AI_CONFIG } from '../config/ai.config' ;
78
89@Injectable ( )
910export class AssessmentService {
@@ -39,6 +40,7 @@ export class AssessmentService {
3940 ] ;
4041
4142 async extractJsonArray ( text : string ) : Promise < any > {
43+ // 1. Extract JSON content from Markdown code blocks or find array brackets
4244 const jsonArrayMatch = text . match ( / ` ` ` j s o n \s * ( [ \s \S ] * ?) ` ` ` / i) || text . match ( / ( \[ \s * { [ \s \S ] * } \s * \] ) / ) ;
4345 let jsonString = '' ;
4446
@@ -57,6 +59,16 @@ export class AssessmentService {
5759 throw new Error ( 'AI response did not contain a valid JSON array' ) ;
5860 }
5961
62+ // 2. Clean the JSON string to handle common LLM errors
63+ // Remove single-line comments (// ...)
64+ jsonString = jsonString . replace ( / \/ \/ .* $ / gm, '' ) ;
65+ // Remove multi-line comments (/* ... */)
66+ jsonString = jsonString . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, '' ) ;
67+ // Remove trailing commas before closing brackets/braces
68+ jsonString = jsonString . replace ( / , ( \s * [ \] } ] ) / g, '$1' ) ;
69+ // Fix unquoted keys (simple cases like key: "value")
70+ jsonString = jsonString . replace ( / ( [ { , ] \s * ) ( [ a - z A - Z 0 - 9 _ ] + ) ( \s * : ) / g, '$1"$2"$3' ) ;
71+
6072 try {
6173 const parsed = JSON . parse ( jsonString ) ;
6274
@@ -68,7 +80,7 @@ export class AssessmentService {
6880
6981 return parsed ;
7082 } catch ( err ) {
71- console . error ( 'JSON parse error:' , err . message , '\nJSON string:' , jsonString . substring ( 0 , 500 ) ) ;
83+ console . error ( 'JSON parse error:' , err . message , '\nJSON string snippet :' , jsonString . substring ( 0 , 500 ) ) ;
7284 throw new Error ( `Failed to parse AI response as JSON: ${ err . message } ` ) ;
7385 }
7486 }
@@ -263,64 +275,90 @@ private getTagRecommendation(tag: string): string | null {
263275
264276 if ( ! lessons || lessons . length < 4 ) throw new Error ( 'Not enough lessons found' ) ;
265277
266- // Adjust question distribution based on user's weak areas
267278 const focusTopicIds = recommendations . focusTopics . map ( t => t . topicId ) ;
279+ const focusLessonIds = recommendations . focusTopics . map ( t => t . lessonId ) ;
268280
269281 let lesson1Topics = lessons . find ( l => l . id === 1 ) ?. topics ?? [ ] ;
270282 let lesson2Topics = lessons . find ( l => l . id === 2 ) ?. topics ?? [ ] ;
271283 let lesson3Topics = lessons . find ( l => l . id === 3 ) ?. topics ?? [ ] ;
272284 let lesson4Topics = lessons . find ( l => l . id === 4 ) ?. topics ?? [ ] ;
273285
274- // Prioritize focus topics - move them to the front of their respective lesson arrays
275- if ( focusTopicIds . length > 0 ) {
276- lesson1Topics = lesson1Topics . filter ( t => focusTopicIds . includes ( t . id ) ) . concat (
277- lesson1Topics . filter ( t => ! focusTopicIds . includes ( t . id ) )
278- ) ;
279- lesson2Topics = lesson2Topics . filter ( t => focusTopicIds . includes ( t . id ) ) . concat (
280- lesson2Topics . filter ( t => ! focusTopicIds . includes ( t . id ) )
281- ) ;
282- lesson3Topics = lesson3Topics . filter ( t => focusTopicIds . includes ( t . id ) ) . concat (
283- lesson3Topics . filter ( t => ! focusTopicIds . includes ( t . id ) )
284- ) ;
285- lesson4Topics = lesson4Topics . filter ( t => focusTopicIds . includes ( t . id ) ) . concat (
286- lesson4Topics . filter ( t => ! focusTopicIds . includes ( t . id ) )
287- ) ;
286+ const distribution = { lesson1 : 3 , lesson2 : 3 , lesson3 : 3 , lesson4 : 3 } ;
287+ let remainingQuestions = 8 ;
288+
289+ if ( focusLessonIds . length > 0 ) {
290+ const lessonCounts = focusLessonIds . reduce ( ( acc , id ) => {
291+ acc [ id ] = ( acc [ id ] || 0 ) + 1 ;
292+ return acc ;
293+ } , { } as Record < number , number > ) ;
294+
295+ const totalFocusCount = focusLessonIds . length ;
296+
297+ Object . entries ( lessonCounts ) . forEach ( ( [ lessonId , count ] ) => {
298+ const share = Math . round ( ( count / totalFocusCount ) * remainingQuestions ) ;
299+ if ( lessonId === '1' ) distribution . lesson1 += share ;
300+ if ( lessonId === '2' ) distribution . lesson2 += share ;
301+ if ( lessonId === '3' ) distribution . lesson3 += share ;
302+ if ( lessonId === '4' ) distribution . lesson4 += share ;
303+ } ) ;
304+
305+ const currentSum = Object . values ( distribution ) . reduce ( ( a , b ) => a + b , 0 ) ;
306+ const diff = 20 - currentSum ;
307+
308+ if ( diff !== 0 ) {
309+ const maxFocusLesson = Object . keys ( lessonCounts ) . reduce ( ( a , b ) => lessonCounts [ a ] > lessonCounts [ b ] ? a : b , '4' ) ;
310+ if ( maxFocusLesson === '1' ) distribution . lesson1 += diff ;
311+ else if ( maxFocusLesson === '2' ) distribution . lesson2 += diff ;
312+ else if ( maxFocusLesson === '3' ) distribution . lesson3 += diff ;
313+ else distribution . lesson4 += diff ;
314+ }
315+ } else {
316+ distribution . lesson1 += 2 ;
317+ distribution . lesson2 += 2 ;
318+ distribution . lesson3 += 2 ;
319+ distribution . lesson4 += 2 ;
288320 }
289321
290- // Select topics for adaptive question distribution
322+ const getTopicsForContext = ( topics : any [ ] , count : number ) => {
323+ const sorted = [ ...topics ] . sort ( ( a , b ) => {
324+ const aIsFocus = focusTopicIds . includes ( a . id ) ;
325+ const bIsFocus = focusTopicIds . includes ( b . id ) ;
326+ return ( aIsFocus === bIsFocus ) ? 0 : aIsFocus ? - 1 : 1 ;
327+ } ) ;
328+ return sorted . slice ( 0 , Math . max ( count , 3 ) ) ;
329+ } ;
330+
291331 const topicSelections = [
292- ...lesson1Topics . slice ( 0 , 2 ) ,
293- ...lesson2Topics . slice ( 0 , 6 ) ,
294- ...lesson3Topics . slice ( 0 , 6 ) ,
295- ...lesson4Topics . slice ( 0 , 6 ) ,
332+ ...getTopicsForContext ( lesson1Topics , distribution . lesson1 ) ,
333+ ...getTopicsForContext ( lesson2Topics , distribution . lesson2 ) ,
334+ ...getTopicsForContext ( lesson3Topics , distribution . lesson3 ) ,
335+ ...getTopicsForContext ( lesson4Topics , distribution . lesson4 ) ,
296336 ] . filter ( Boolean ) ;
297337
298- // Generate questions with adaptive difficulty
299338 const promptParts = topicSelections . map ( ( topic , idx ) => `
300- Topic ${ idx + 1 } :
301- Title: ${ topic . title }
339+ Topic: ${ topic . title } (Lesson ${ topic . lessonId } )
302340 Content: ${ topic . contentText }
303341 ` ) ;
304342
305343 const prompt = buildAdaptiveQuizPrompt ( {
306344 userMasteryPercent : parseFloat ( ( recommendations . overallMastery * 100 ) . toFixed ( 1 ) ) ,
307345 recommendedDifficulty : recommendations . recommendedDifficulty ,
308346 focusTopics : recommendations . focusTopics . map ( t => t . topicTitle ) ,
309- topicContents : promptParts
347+ topicContents : promptParts ,
348+ questionDistribution : distribution
310349 } ) ;
311350
312- // Read model and sampling controls from env with safe defaults
313- const modelName = process . env . GROQ_MODEL || "llama-3.1-8b-instant" ;
314- const temp = process . env . LLM_TEMPERATURE ? Number ( process . env . LLM_TEMPERATURE ) : 0.3 ;
315- const topP = process . env . LLM_TOP_P ? Number ( process . env . LLM_TOP_P ) : 0.9 ;
351+ // Read model and sampling controls from AI_CONFIG
352+ const modelName = AI_CONFIG . modelName ;
353+ const temp = AI_CONFIG . temperature ;
354+ const topP = AI_CONFIG . topP ;
316355
317- // Helper to call the model and parse JSON with minimal retry for robustness
318356 const requestQuestions = async ( ) => {
319357 const { text } = await generateText ( {
320358 model : groq ( modelName ) ,
321359 prompt,
322- temperature : isNaN ( temp ) ? 0.3 : temp ,
323- topP : isNaN ( topP ) ? 0.9 : topP ,
360+ temperature : temp ,
361+ topP : topP ,
324362 } ) ;
325363 return this . extractJsonArray ( text ) ;
326364 } ;
@@ -331,38 +369,33 @@ private getTagRecommendation(tag: string): string | null {
331369 if ( ! Array . isArray ( questions ) ) {
332370 console . error ( 'AI generated non-array response:' , questions ) ;
333371 // Retry once with slightly lower temperature for consistency
334- const retryTemp = Math . max ( 0.1 , ( isNaN ( temp ) ? 0.3 : temp ) - 0.1 ) ;
372+ const retryTemp = Math . max ( 0.1 , temp - 0.1 ) ;
335373 const { text : retryText } = await generateText ( {
336374 model : groq ( modelName ) ,
337375 prompt,
338376 temperature : retryTemp ,
339- topP : isNaN ( topP ) ? 0.9 : topP ,
377+ topP : topP ,
340378 } ) ;
341379 questions = await this . extractJsonArray ( retryText ) ;
342380 if ( ! Array . isArray ( questions ) ) {
343381 throw new Error ( 'Failed to generate valid questions array from AI response (after retry)' ) ;
344382 }
345383 }
346384
347- // Enhanced validation and filtering
348385 const filteredQuestions = ( questions || [ ] ) . map ( ( q : any , index : number ) => {
349- // Ensure exactly one correct answer
350386 const correctOptions = q . options ?. filter ( ( opt : any ) => opt . isCorrect ) || [ ] ;
351387 if ( correctOptions . length !== 1 ) {
352388 console . warn ( `Question ${ index + 1 } has ${ correctOptions . length } correct answers, should have exactly 1` ) ;
353- // Fix by ensuring only the first correct option is marked as correct
354389 q . options ?. forEach ( ( opt : any , idx : number ) => {
355390 opt . isCorrect = idx === 0 && correctOptions . length === 0 ? true :
356391 opt . isCorrect && correctOptions . indexOf ( opt ) === 0 ;
357392 } ) ;
358393 }
359394
360- // Ensure 3-4 options
361395 if ( q . options ?. length < 3 ) {
362396 console . warn ( `Question ${ index + 1 } has only ${ q . options ?. length } options, minimum is 3` ) ;
363397 }
364398
365- // Set answer ID to correct option
366399 const correctOption = q . options ?. find ( ( opt : any ) => opt . isCorrect ) ;
367400 if ( correctOption ) {
368401 q . answerId = correctOption . id ;
@@ -381,6 +414,20 @@ private getTagRecommendation(tag: string): string | null {
381414
382415 // Additional validation
383416 const validQuestions = filteredQuestions . filter ( ( q : any ) => {
417+ // Check for broken visuals (text implies visual but stem is string)
418+ if ( typeof q . stem === 'string' ) {
419+ const lowerStem = q . stem . toLowerCase ( ) ;
420+ if ( lowerStem . includes ( 'table below' ) ||
421+ lowerStem . includes ( 'circuit below' ) ||
422+ lowerStem . includes ( 'map below' ) ||
423+ lowerStem . includes ( 'shown below' ) ||
424+ lowerStem . includes ( 'following truth table' ) ||
425+ lowerStem . includes ( 'following circuit' ) ) {
426+ console . warn ( `Question rejected: Text implies visual but stem is string: "${ q . stem . substring ( 0 , 50 ) } ..."` ) ;
427+ return false ;
428+ }
429+ }
430+
384431 return q . stem &&
385432 q . options &&
386433 q . options . length >= 3 &&
0 commit comments