@@ -10,39 +10,222 @@ function makeSeed(): string {
1010 return `${ Date . now ( ) } _${ Math . random ( ) . toString ( 16 ) . slice ( 2 ) } ` ;
1111}
1212
13- export async function generateSet ( params : {
13+ /** =========================
14+ * Robust question signature + answerKey normalization
15+ * ========================= */
16+
17+ function norm ( s : string ) {
18+ return s . trim ( ) . toLowerCase ( ) . replace ( / \s + / g, " " ) ;
19+ }
20+
21+ function getQuestionText ( q : any ) : string {
22+ return String ( q ?. question ?? q ?. prompt ?? q ?. stem ?? q ?. text ?? q ?. q ?? "" ) . trim ( ) ;
23+ }
24+
25+ function getOptions ( q : any ) : string [ ] {
26+ const opts = q ?. options ?? q ?. choices ?? q ?. mcqOptions ?? q ?. answers ;
27+ return Array . isArray ( opts ) ? opts . map ( ( x : any ) => String ( x ) ) : [ ] ;
28+ }
29+
30+ function getQuestionId ( q : any ) : string | null {
31+ const v =
32+ q ?. id ??
33+ q ?. qid ??
34+ q ?. questionId ??
35+ q ?. uid ??
36+ q ?. uuid ??
37+ q ?. key ??
38+ q ?. slug ??
39+ q ?. meta ?. id ??
40+ q ?. meta ?. qid ??
41+ q ?. meta ?. questionId ;
42+
43+ if ( typeof v === "string" && v . trim ( ) ) return v . trim ( ) ;
44+ if ( typeof v === "number" && Number . isFinite ( v ) ) return String ( v ) ;
45+ return null ;
46+ }
47+
48+ function getContextSnippet ( set : any , q : any ) : string {
49+ const ctx =
50+ q ?. context ??
51+ q ?. passage ??
52+ q ?. stimulus ??
53+ q ?. story ??
54+ q ?. reading ??
55+ q ?. instructions ??
56+ q ?. meta ?. context ??
57+ set ?. context ??
58+ set ?. passage ??
59+ set ?. stimulus ??
60+ set ?. story ??
61+ set ?. reading ??
62+ set ?. instructions ??
63+ "" ;
64+ const s = String ( ctx ?? "" ) . trim ( ) ;
65+ if ( ! s ) return "" ;
66+ // keep signature stable but short
67+ return norm ( s ) . slice ( 0 , 180 ) ;
68+ }
69+
70+ function questionSignature ( set : any , q : any ) : string {
71+ const id = getQuestionId ( q ) ;
72+ if ( id ) return `id:${ id } ` ;
73+
74+ const t = norm ( getQuestionText ( q ) ) ;
75+ const o = getOptions ( q ) . map ( norm ) . join ( "|" ) ;
76+ const c = getContextSnippet ( set , q ) ;
77+ return `t:${ t } ::o:${ o } ::c:${ c } ` ;
78+ }
79+
80+ function normalizeAnswerKeyEntry ( entry : any , questionId : string | null ) {
81+ // Normalize everything into { questionId, answer, ...rest }
82+ const qid = questionId ?? "" ;
83+
84+ if ( entry && typeof entry === "object" && ! Array . isArray ( entry ) ) {
85+ // Already has questionId
86+ if ( typeof entry . questionId === "string" || typeof entry . questionId === "number" ) {
87+ return {
88+ ...entry ,
89+ questionId : String ( entry . questionId ) ,
90+ answer : entry . answer ?? entry . value ?? entry . correct ?? entry . solution ?? entry . expected ?? entry ,
91+ } ;
92+ }
93+
94+ // Might be keyed differently
95+ const maybeId = entry . id ?? entry . qid ?? entry . questionID ?? entry . question_id ;
96+ const maybeAnswer = entry . answer ?? entry . value ?? entry . correct ?? entry . solution ?? entry . expected ;
97+
98+ return {
99+ ...entry ,
100+ questionId : String ( maybeId ?? qid ) ,
101+ answer : maybeAnswer ?? entry ,
102+ } ;
103+ }
104+
105+ // primitive (string/number/etc.)
106+ return { questionId : String ( qid ) , answer : entry } ;
107+ }
108+
109+ function buildAnswerKeyMap ( answerKey : any [ ] ) : Map < string , any > {
110+ const m = new Map < string , any > ( ) ;
111+ for ( const k of answerKey ) {
112+ if ( k && typeof k === "object" && ! Array . isArray ( k ) && ( k . questionId != null || k . id != null || k . qid != null ) ) {
113+ const id = k . questionId ?? k . id ?? k . qid ;
114+ if ( id != null ) m . set ( String ( id ) , k ) ;
115+ }
116+ }
117+ return m ;
118+ }
119+
120+ function dedupeSet ( base : GeneratedSet , desiredCount : number ) : GeneratedSet {
121+ const qs : any [ ] = Array . isArray ( ( base as any ) . questions ) ? ( ( base as any ) . questions as any [ ] ) : [ ] ;
122+ const ak : any [ ] = Array . isArray ( ( base as any ) . answerKey ) ? ( ( base as any ) . answerKey as any [ ] ) : [ ] ;
123+
124+ const akMap = buildAnswerKeyMap ( ak ) ;
125+ const hasParallelIndexKey = ak . length === qs . length && akMap . size === 0 ;
126+
127+ const seen = new Set < string > ( ) ;
128+ const outQ : any [ ] = [ ] ;
129+ const outAK : any [ ] = [ ] ;
130+
131+ for ( let i = 0 ; i < qs . length ; i ++ ) {
132+ const q = qs [ i ] ;
133+ const sig = questionSignature ( base , q ) ;
134+ if ( seen . has ( sig ) ) continue ;
135+ seen . add ( sig ) ;
136+
137+ outQ . push ( q ) ;
138+
139+ const qid = getQuestionId ( q ) ;
140+ const rawKey =
141+ ( qid ? akMap . get ( String ( qid ) ) : undefined ) ??
142+ ( hasParallelIndexKey ? ak [ i ] : undefined ) ;
143+
144+ outAK . push ( normalizeAnswerKeyEntry ( rawKey , qid ) ) ;
145+
146+ if ( outQ . length >= desiredCount ) break ;
147+ }
148+
149+ return {
150+ ...base ,
151+ questions : outQ as any ,
152+ answerKey : outAK as any ,
153+ } ;
154+ }
155+
156+ function mergeUnique ( into : GeneratedSet , extra : GeneratedSet , desiredCount : number ) : GeneratedSet {
157+ const merged = {
158+ ...into ,
159+ questions : [ ...( into . questions as any [ ] ) ] ,
160+ answerKey : [ ...( into . answerKey as any [ ] ) ] ,
161+ } as GeneratedSet ;
162+
163+ const seen = new Set < string > ( ) ;
164+ for ( const q of merged . questions as any [ ] ) {
165+ seen . add ( questionSignature ( merged , q ) ) ;
166+ }
167+
168+ const extraQs : any [ ] = Array . isArray ( ( extra as any ) . questions ) ? ( ( extra as any ) . questions as any [ ] ) : [ ] ;
169+ const extraAk : any [ ] = Array . isArray ( ( extra as any ) . answerKey ) ? ( ( extra as any ) . answerKey as any [ ] ) : [ ] ;
170+ const extraMap = buildAnswerKeyMap ( extraAk ) ;
171+ const hasParallelIndexKey = extraAk . length === extraQs . length && extraMap . size === 0 ;
172+
173+ for ( let i = 0 ; i < extraQs . length ; i ++ ) {
174+ if ( ( merged . questions as any [ ] ) . length >= desiredCount ) break ;
175+
176+ const q = extraQs [ i ] ;
177+ const sig = questionSignature ( extra , q ) ;
178+ if ( seen . has ( sig ) ) continue ;
179+ seen . add ( sig ) ;
180+
181+ ( merged . questions as any [ ] ) . push ( q ) ;
182+
183+ const qid = getQuestionId ( q ) ;
184+ const rawKey =
185+ ( qid ? extraMap . get ( String ( qid ) ) : undefined ) ??
186+ ( hasParallelIndexKey ? extraAk [ i ] : undefined ) ;
187+
188+ ( merged . answerKey as any [ ] ) . push ( normalizeAnswerKeyEntry ( rawKey , qid ) ) ;
189+ }
190+
191+ return merged ;
192+ }
193+
194+ /** =========================
195+ * Raw generator (same logic as before)
196+ * ========================= */
197+
198+ async function generateRaw ( params : {
14199 subject : Subject ;
15200 ukYear : number ;
16201 difficulty : number ;
17202 count : number ;
18- seed ? : string ;
203+ seed : string ;
19204 topic ?: MathsTopic ;
20205} ) : Promise < GeneratedSet > {
21- const seed = params . seed ?? makeSeed ( ) ;
22-
23206 // ✅ Maths stays on the maths-only generator
24207 if ( params . subject === "maths" ) {
25208 return generateMathSet ( {
26209 ukYear : params . ukYear ,
27210 difficulty : params . difficulty ,
28211 count : params . count ,
29- seed,
212+ seed : params . seed ,
30213 topic : params . topic ,
31214 } ) ;
32215 }
33216
34- // ✅ English uses the new procedural generator (no packs)
217+ // ✅ English uses the procedural generator (no packs)
35218 if ( params . subject === "english" ) {
36219 return generateEnglishSet ( {
37220 ukYear : params . ukYear ,
38221 difficulty : params . difficulty ,
39222 count : params . count ,
40- seed,
223+ seed : params . seed ,
41224 } ) ;
42225 }
43226
44- // ✅ Everything else continues using packs (science, etc.)
45- const rng = createRng ( seed ) ;
227+ // ✅ Everything else uses packs
228+ const rng = createRng ( params . seed ) ;
46229 const bank = await loadPack ( params . subject , params . ukYear , rng ) ;
47230
48231 const out = generateFromBank ( {
@@ -55,11 +238,66 @@ export async function generateSet(params: {
55238 } ) ;
56239
57240 return {
58- seed,
241+ seed : params . seed ,
59242 subject : params . subject ,
60243 ukYear : params . ukYear ,
61244 difficulty : params . difficulty ,
62245 questions : out . questions ,
63246 answerKey : out . answerKey ,
64247 } ;
65248}
249+
250+ /** =========================
251+ * Public API: generateSet (now unique)
252+ * ========================= */
253+
254+ export async function generateSet ( params : {
255+ subject : Subject ;
256+ ukYear : number ;
257+ difficulty : number ;
258+ count : number ;
259+ seed ?: string ;
260+ topic ?: MathsTopic ;
261+ } ) : Promise < GeneratedSet > {
262+ const seed = params . seed ?? makeSeed ( ) ;
263+
264+ // First pass
265+ let base = await generateRaw ( {
266+ subject : params . subject ,
267+ ukYear : params . ukYear ,
268+ difficulty : params . difficulty ,
269+ count : params . count ,
270+ seed,
271+ topic : params . topic ,
272+ } ) ;
273+
274+ // Deduplicate
275+ base = dedupeSet ( base , params . count ) ;
276+
277+ // Top-up if needed (generate extra with seed variants)
278+ let attempt = 0 ;
279+ while ( ( base . questions as any [ ] ) . length < params . count && attempt < 6 ) {
280+ attempt ++ ;
281+ const needed = params . count - ( base . questions as any [ ] ) . length ;
282+
283+ const extra = await generateRaw ( {
284+ subject : params . subject ,
285+ ukYear : params . ukYear ,
286+ difficulty : params . difficulty ,
287+ count : Math . max ( needed * 2 , needed ) , // ask for a bit more to beat duplicates
288+ seed : `${ seed } __topup${ attempt } ` ,
289+ topic : params . topic ,
290+ } ) ;
291+
292+ base = mergeUnique ( base , dedupeSet ( extra , extra . questions . length ) , params . count ) ;
293+ }
294+
295+ // Keep the original seed in the final object (for UI consistency)
296+ return {
297+ ...base ,
298+ seed,
299+ subject : params . subject ,
300+ ukYear : params . ukYear ,
301+ difficulty : params . difficulty ,
302+ } ;
303+ }
0 commit comments