@codewalla_india/openspec 1.3.2 → 1.3.4

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.
@@ -35,19 +35,35 @@ export class AnthropicProvider extends BaseProvider {
35
35
  * @returns Array of generated questions
36
36
  */
37
37
  async generateQuestions(artifactContent, questionCount, artifactType) {
38
- // TODO: Implement Anthropic API call
39
- // For now, return placeholder questions
40
38
  const questions = [];
41
39
  for (let i = 0; i < questionCount; i++) {
42
- questions.push({
43
- text: `Sample question ${i + 1} for ${artifactType}`,
44
- options: ['Option A', 'Option B', 'Option C'],
45
- correctOption: 0,
46
- artifactSource: artifactType,
47
- });
40
+ const q = await this.generateSingleQuestion(artifactContent, artifactType, i, questionCount, questions.map((q) => q.text));
41
+ questions.push(q);
48
42
  }
49
- this.validateQuestions(questions);
50
43
  return questions;
51
44
  }
45
+ async generateSingleQuestion(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts) {
46
+ const { system, user } = this.buildSingleQuestionPrompt(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts);
47
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
48
+ method: 'POST',
49
+ headers: {
50
+ 'x-api-key': this.config.apiKey,
51
+ 'anthropic-version': '2023-06-01',
52
+ 'content-type': 'application/json',
53
+ },
54
+ body: JSON.stringify({
55
+ model: this.config.model ?? 'claude-3-opus-20240229',
56
+ max_tokens: 512,
57
+ system,
58
+ messages: [{ role: 'user', content: user }],
59
+ }),
60
+ });
61
+ if (!response.ok) {
62
+ throw new Error(`Anthropic API error ${response.status}: ${await response.text()}`);
63
+ }
64
+ const data = (await response.json());
65
+ const raw = data.content.find((b) => b.type === 'text')?.text ?? '';
66
+ return this.parseSingleQuestionResponse(raw, artifactType);
67
+ }
52
68
  }
53
69
  //# sourceMappingURL=anthropic.js.map
@@ -37,23 +37,62 @@ export declare abstract class BaseProvider implements LLMProvider {
37
37
  * @returns Array of generated questions
38
38
  */
39
39
  abstract generateQuestions(artifactContent: string, questionCount: number, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks'): Promise<Question[]>;
40
+ /**
41
+ * Generate a single question on-demand.
42
+ * Subclasses MUST call buildSingleQuestionPrompt() to get the messages and
43
+ * parseSingleQuestionResponse() to parse the LLM reply. This guarantees the
44
+ * correct-answer index never leaks into stdout or conversational context.
45
+ */
46
+ abstract generateSingleQuestion(artifactContent: string, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks', questionIndex: number, totalQuestions: number, previousQuestionTexts: string[]): Promise<Question>;
40
47
  /**
41
48
  * Validate provider configuration
42
49
  * @throws Error if configuration is invalid
43
50
  */
44
51
  abstract validateConfig(): void;
52
+ /**
53
+ * Build the system + user messages for single-question generation.
54
+ *
55
+ * Contract:
56
+ * - System message explicitly forbids listing multiple questions.
57
+ * - User message asks for exactly one question (question N of total).
58
+ * - Response format is strict JSON only — no prose, no explanation, no answer label.
59
+ * - The correct answer index (correctOption) is returned as a number 0-2; its
60
+ * meaning is never explained in the response so the LLM cannot read it back.
61
+ *
62
+ * @returns { system, user } message pair ready to send to the LLM API
63
+ */
64
+ protected buildSingleQuestionPrompt(artifactContent: string, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks', questionIndex: number, totalQuestions: number, previousQuestionTexts: string[]): {
65
+ system: string;
66
+ user: string;
67
+ };
68
+ /**
69
+ * Parse the raw LLM text response into a Question object.
70
+ * Strips markdown fences if present, then JSON-parses and validates.
71
+ *
72
+ * @param rawResponse - Raw text returned by the LLM API
73
+ * @param artifactType - Expected artifact source (used as fallback)
74
+ * @returns Validated Question object
75
+ * @throws Error if the response cannot be parsed or fails validation
76
+ */
77
+ protected parseSingleQuestionResponse(rawResponse: string, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks'): Question;
45
78
  /**
46
79
  * Validate generated questions before returning
47
80
  *
48
81
  * Ensures all questions have:
49
82
  * - Non-empty text
50
- - Exactly 3 options
51
- - Valid correct option index (0-2)
52
- - Valid artifact source
83
+ * - Exactly 3 options
84
+ * - Valid correct option index (0-2)
85
+ * - Valid artifact source
53
86
  *
54
87
  * @param questions - Questions to validate
55
88
  * @throws Error if any question is invalid
56
89
  */
57
90
  protected validateQuestions(questions: Question[]): void;
91
+ /**
92
+ * Validate a single question object.
93
+ * @param question - Question to validate
94
+ * @throws Error if invalid
95
+ */
96
+ protected validateQuestion(question: Question): void;
58
97
  }
59
98
  //# sourceMappingURL=interface.d.ts.map
@@ -31,32 +31,119 @@ export class BaseProvider {
31
31
  this.config = config;
32
32
  this.validateConfig();
33
33
  }
34
+ /**
35
+ * Build the system + user messages for single-question generation.
36
+ *
37
+ * Contract:
38
+ * - System message explicitly forbids listing multiple questions.
39
+ * - User message asks for exactly one question (question N of total).
40
+ * - Response format is strict JSON only — no prose, no explanation, no answer label.
41
+ * - The correct answer index (correctOption) is returned as a number 0-2; its
42
+ * meaning is never explained in the response so the LLM cannot read it back.
43
+ *
44
+ * @returns { system, user } message pair ready to send to the LLM API
45
+ */
46
+ buildSingleQuestionPrompt(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts) {
47
+ const previousBlock = previousQuestionTexts.length > 0
48
+ ? `\n\nAlready asked questions (do NOT repeat these):\n${previousQuestionTexts.map((t, i) => `${i + 1}. ${t}`).join('\n')}`
49
+ : '';
50
+ const system = [
51
+ 'You are a quiz question generator for a software change review system.',
52
+ 'STRICT RULES — violating any rule causes system failure:',
53
+ ' 1. Output EXACTLY ONE question. Never output a list, array, or multiple questions.',
54
+ ' 2. Output ONLY valid JSON matching the schema below. No prose before or after.',
55
+ ' 3. Do NOT include the word "Answer", "Correct", or any hint about which option is right.',
56
+ ' 4. Do NOT explain the options. Do NOT add notes, caveats, or additional text.',
57
+ ' 5. The "correctOption" field is an opaque integer (0, 1, or 2). Do not label it.',
58
+ '',
59
+ 'Response schema (output this JSON and nothing else):',
60
+ '{',
61
+ ' "text": "<question text>",',
62
+ ' "options": ["<option 1>", "<option 2>", "<option 3>"],',
63
+ ' "correctOption": <0|1|2>,',
64
+ ' "artifactSource": "<proposal|design|specs|plan|tasks>"',
65
+ '}',
66
+ ].join('\n');
67
+ const user = [
68
+ `Generate question ${questionIndex + 1} of ${totalQuestions} for the "${artifactType}" artifact.`,
69
+ 'The question must require reading the artifact to answer correctly.',
70
+ 'Distractors must be plausible but wrong based on artifact content.',
71
+ previousBlock,
72
+ '',
73
+ `Artifact content:\n---\n${artifactContent}\n---`,
74
+ ].join('\n');
75
+ return { system, user };
76
+ }
77
+ /**
78
+ * Parse the raw LLM text response into a Question object.
79
+ * Strips markdown fences if present, then JSON-parses and validates.
80
+ *
81
+ * @param rawResponse - Raw text returned by the LLM API
82
+ * @param artifactType - Expected artifact source (used as fallback)
83
+ * @returns Validated Question object
84
+ * @throws Error if the response cannot be parsed or fails validation
85
+ */
86
+ parseSingleQuestionResponse(rawResponse, artifactType) {
87
+ let text = rawResponse.trim();
88
+ // Strip markdown code fences if present
89
+ text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(text);
93
+ }
94
+ catch {
95
+ throw new Error(`LLM returned invalid JSON for single question: ${rawResponse.slice(0, 200)}`);
96
+ }
97
+ const q = parsed;
98
+ if (typeof q['text'] !== 'string' ||
99
+ !Array.isArray(q['options']) ||
100
+ typeof q['correctOption'] !== 'number' ||
101
+ typeof q['artifactSource'] !== 'string') {
102
+ throw new Error(`LLM response missing required fields: ${JSON.stringify(q)}`);
103
+ }
104
+ const question = {
105
+ text: q['text'],
106
+ options: q['options'],
107
+ correctOption: q['correctOption'],
108
+ artifactSource: q['artifactSource'] ?? artifactType,
109
+ };
110
+ this.validateQuestion(question);
111
+ return question;
112
+ }
34
113
  /**
35
114
  * Validate generated questions before returning
36
115
  *
37
116
  * Ensures all questions have:
38
117
  * - Non-empty text
39
- - Exactly 3 options
40
- - Valid correct option index (0-2)
41
- - Valid artifact source
118
+ * - Exactly 3 options
119
+ * - Valid correct option index (0-2)
120
+ * - Valid artifact source
42
121
  *
43
122
  * @param questions - Questions to validate
44
123
  * @throws Error if any question is invalid
45
124
  */
46
125
  validateQuestions(questions) {
47
126
  for (const question of questions) {
48
- if (!question.text || question.text.trim().length === 0) {
49
- throw new Error('Question text cannot be empty');
50
- }
51
- if (question.options.length !== 3) {
52
- throw new Error('Each question must have exactly 3 options');
53
- }
54
- if (question.correctOption < 0 || question.correctOption > 2) {
55
- throw new Error('Correct option index must be 0, 1, or 2');
56
- }
57
- if (!['proposal', 'design', 'specs', 'plan', 'tasks'].includes(question.artifactSource)) {
58
- throw new Error('Invalid artifact source');
59
- }
127
+ this.validateQuestion(question);
128
+ }
129
+ }
130
+ /**
131
+ * Validate a single question object.
132
+ * @param question - Question to validate
133
+ * @throws Error if invalid
134
+ */
135
+ validateQuestion(question) {
136
+ if (!question.text || question.text.trim().length === 0) {
137
+ throw new Error('Question text cannot be empty');
138
+ }
139
+ if (question.options.length !== 3) {
140
+ throw new Error('Each question must have exactly 3 options');
141
+ }
142
+ if (question.correctOption < 0 || question.correctOption > 2) {
143
+ throw new Error('Correct option index must be 0, 1, or 2');
144
+ }
145
+ if (!['proposal', 'design', 'specs', 'plan', 'tasks'].includes(question.artifactSource)) {
146
+ throw new Error('Invalid artifact source');
60
147
  }
61
148
  }
62
149
  }
@@ -30,5 +30,6 @@ export declare class LocalProvider extends BaseProvider {
30
30
  * @returns Array of generated questions
31
31
  */
32
32
  generateQuestions(artifactContent: string, questionCount: number, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks'): Promise<Question[]>;
33
+ generateSingleQuestion(artifactContent: string, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks', questionIndex: number, totalQuestions: number, previousQuestionTexts: string[]): Promise<Question>;
33
34
  }
34
35
  //# sourceMappingURL=local.d.ts.map
@@ -35,19 +35,35 @@ export class LocalProvider extends BaseProvider {
35
35
  * @returns Array of generated questions
36
36
  */
37
37
  async generateQuestions(artifactContent, questionCount, artifactType) {
38
- // TODO: Implement local model inference
39
- // For now, return placeholder questions
40
38
  const questions = [];
41
39
  for (let i = 0; i < questionCount; i++) {
42
- questions.push({
43
- text: `Sample question ${i + 1} for ${artifactType}`,
44
- options: ['Option A', 'Option B', 'Option C'],
45
- correctOption: 0,
46
- artifactSource: artifactType,
47
- });
40
+ const q = await this.generateSingleQuestion(artifactContent, artifactType, i, questionCount, questions.map((q) => q.text));
41
+ questions.push(q);
48
42
  }
49
- this.validateQuestions(questions);
50
43
  return questions;
51
44
  }
45
+ async generateSingleQuestion(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts) {
46
+ const { system, user } = this.buildSingleQuestionPrompt(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts);
47
+ const endpoint = this.config.endpoint ?? 'http://localhost:11434';
48
+ const response = await fetch(`${endpoint}/api/chat`, {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({
52
+ model: this.config.model,
53
+ stream: false,
54
+ format: 'json',
55
+ messages: [
56
+ { role: 'system', content: system },
57
+ { role: 'user', content: user },
58
+ ],
59
+ }),
60
+ });
61
+ if (!response.ok) {
62
+ throw new Error(`Local model API error ${response.status}: ${await response.text()}`);
63
+ }
64
+ const data = (await response.json());
65
+ const raw = data.message?.content ?? '';
66
+ return this.parseSingleQuestionResponse(raw, artifactType);
67
+ }
52
68
  }
53
69
  //# sourceMappingURL=local.js.map
@@ -30,5 +30,6 @@ export declare class OllamaProvider extends BaseProvider {
30
30
  * @returns Array of generated questions
31
31
  */
32
32
  generateQuestions(artifactContent: string, questionCount: number, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks'): Promise<Question[]>;
33
+ generateSingleQuestion(artifactContent: string, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks', questionIndex: number, totalQuestions: number, previousQuestionTexts: string[]): Promise<Question>;
33
34
  }
34
35
  //# sourceMappingURL=ollama.d.ts.map
@@ -35,19 +35,35 @@ export class OllamaProvider extends BaseProvider {
35
35
  * @returns Array of generated questions
36
36
  */
37
37
  async generateQuestions(artifactContent, questionCount, artifactType) {
38
- // TODO: Implement Ollama API call
39
- // For now, return placeholder questions
40
38
  const questions = [];
41
39
  for (let i = 0; i < questionCount; i++) {
42
- questions.push({
43
- text: `Sample question ${i + 1} for ${artifactType}`,
44
- options: ['Option A', 'Option B', 'Option C'],
45
- correctOption: 0,
46
- artifactSource: artifactType,
47
- });
40
+ const q = await this.generateSingleQuestion(artifactContent, artifactType, i, questionCount, questions.map((q) => q.text));
41
+ questions.push(q);
48
42
  }
49
- this.validateQuestions(questions);
50
43
  return questions;
51
44
  }
45
+ async generateSingleQuestion(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts) {
46
+ const { system, user } = this.buildSingleQuestionPrompt(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts);
47
+ const baseUrl = this.config.endpoint ?? 'http://localhost:11434';
48
+ const response = await fetch(`${baseUrl}/api/chat`, {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({
52
+ model: this.config.model ?? 'llama2',
53
+ stream: false,
54
+ format: 'json',
55
+ messages: [
56
+ { role: 'system', content: system },
57
+ { role: 'user', content: user },
58
+ ],
59
+ }),
60
+ });
61
+ if (!response.ok) {
62
+ throw new Error(`Ollama API error ${response.status}: ${await response.text()}`);
63
+ }
64
+ const data = (await response.json());
65
+ const raw = data.message?.content ?? '';
66
+ return this.parseSingleQuestionResponse(raw, artifactType);
67
+ }
52
68
  }
53
69
  //# sourceMappingURL=ollama.js.map
@@ -30,5 +30,6 @@ export declare class OpenAIProvider extends BaseProvider {
30
30
  * @returns Array of generated questions
31
31
  */
32
32
  generateQuestions(artifactContent: string, questionCount: number, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks'): Promise<Question[]>;
33
+ generateSingleQuestion(artifactContent: string, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks', questionIndex: number, totalQuestions: number, previousQuestionTexts: string[]): Promise<Question>;
33
34
  }
34
35
  //# sourceMappingURL=openai.d.ts.map
@@ -35,19 +35,38 @@ export class OpenAIProvider extends BaseProvider {
35
35
  * @returns Array of generated questions
36
36
  */
37
37
  async generateQuestions(artifactContent, questionCount, artifactType) {
38
- // TODO: Implement OpenAI API call
39
- // For now, return placeholder questions
40
38
  const questions = [];
41
39
  for (let i = 0; i < questionCount; i++) {
42
- questions.push({
43
- text: `Sample question ${i + 1} for ${artifactType}`,
44
- options: ['Option A', 'Option B', 'Option C'],
45
- correctOption: 0,
46
- artifactSource: artifactType,
47
- });
40
+ const q = await this.generateSingleQuestion(artifactContent, artifactType, i, questionCount, questions.map((q) => q.text));
41
+ questions.push(q);
48
42
  }
49
- this.validateQuestions(questions);
50
43
  return questions;
51
44
  }
45
+ async generateSingleQuestion(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts) {
46
+ const { system, user } = this.buildSingleQuestionPrompt(artifactContent, artifactType, questionIndex, totalQuestions, previousQuestionTexts);
47
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
48
+ method: 'POST',
49
+ headers: {
50
+ Authorization: `Bearer ${this.config.apiKey}`,
51
+ 'content-type': 'application/json',
52
+ },
53
+ body: JSON.stringify({
54
+ model: this.config.model ?? 'gpt-4',
55
+ max_tokens: 512,
56
+ temperature: 0.7,
57
+ response_format: { type: 'json_object' },
58
+ messages: [
59
+ { role: 'system', content: system },
60
+ { role: 'user', content: user },
61
+ ],
62
+ }),
63
+ });
64
+ if (!response.ok) {
65
+ throw new Error(`OpenAI API error ${response.status}: ${await response.text()}`);
66
+ }
67
+ const data = (await response.json());
68
+ const raw = data.choices[0]?.message?.content ?? '';
69
+ return this.parseSingleQuestionResponse(raw, artifactType);
70
+ }
52
71
  }
53
72
  //# sourceMappingURL=openai.js.map
@@ -49,10 +49,58 @@ export declare function calculateQuestionAllocation(totalQuestions: number, arti
49
49
  */
50
50
  export declare function generateQuestionsForArtifact(artifactContent: string, artifactType: 'proposal' | 'design' | 'specs' | 'plan' | 'tasks', questionCount: number, provider: LLMProvider): Promise<Question[]>;
51
51
  /**
52
- * Generate complete quiz question set for all artifacts
52
+ * On-demand question stream for the quiz executor.
53
53
  *
54
- * Reads all artifact files, calculates question allocation, and generates
55
- * questions for each artifact type using the provided or fallback LLM provider.
54
+ * This class resolves the artifact contents and allocation plan upfront, but
55
+ * NEVER pre-generates questions. Each call to `next()` fetches exactly ONE
56
+ * question from the LLM — only after the previous question has been answered.
57
+ *
58
+ * This enforces FR-10: questions are fetched on-demand, never pre-generated.
59
+ * The correct answer (correctOption) is held in process memory; it is never
60
+ * written to stdout, disk, or any external surface.
61
+ */
62
+ export declare class QuizQuestionStream {
63
+ private provider;
64
+ private plan;
65
+ private totalQuestions;
66
+ private fetchedCount;
67
+ private askedTexts;
68
+ private constructor();
69
+ /**
70
+ * Create and initialise a QuizQuestionStream.
71
+ * Reads artifact files and builds the question plan, but does NOT generate any questions.
72
+ *
73
+ * @param artifactPaths - Object mapping artifact types to file paths
74
+ * @param totalQuestions - Total number of questions the quiz will ask
75
+ * @param provider - LLM provider (optional, uses fallback if not provided)
76
+ */
77
+ static create(artifactPaths: {
78
+ proposal?: string;
79
+ design?: string;
80
+ specs?: string;
81
+ plan?: string;
82
+ tasks?: string;
83
+ }, totalQuestions: number, provider?: LLMProvider): Promise<QuizQuestionStream>;
84
+ /** Total number of questions this quiz will ask. */
85
+ get total(): number;
86
+ /** Whether there are more questions to fetch. */
87
+ get hasNext(): boolean;
88
+ /**
89
+ * Fetch the next question from the LLM.
90
+ * MUST only be called after the user has answered the current question.
91
+ * Only one question is held in memory at a time; this method discards the
92
+ * previous question before fetching the next.
93
+ *
94
+ * @returns The next Question, or null if all questions have been asked.
95
+ */
96
+ next(): Promise<Question | null>;
97
+ }
98
+ /**
99
+ * Generate complete quiz question set for all artifacts.
100
+ *
101
+ * NOTE: This function pre-generates all questions and should only be used for
102
+ * non-interactive contexts (e.g. testing, pass recording). During live quiz
103
+ * execution use QuizQuestionStream.create() instead.
56
104
  *
57
105
  * @param artifactPaths - Object mapping artifact types to file paths
58
106
  * @param totalQuestions - Total number of questions to generate
@@ -97,10 +97,93 @@ export async function generateQuestionsForArtifact(artifactContent, artifactType
97
97
  return provider.generateQuestions(artifactContent, questionCount, artifactType);
98
98
  }
99
99
  /**
100
- * Generate complete quiz question set for all artifacts
100
+ * On-demand question stream for the quiz executor.
101
101
  *
102
- * Reads all artifact files, calculates question allocation, and generates
103
- * questions for each artifact type using the provided or fallback LLM provider.
102
+ * This class resolves the artifact contents and allocation plan upfront, but
103
+ * NEVER pre-generates questions. Each call to `next()` fetches exactly ONE
104
+ * question from the LLM — only after the previous question has been answered.
105
+ *
106
+ * This enforces FR-10: questions are fetched on-demand, never pre-generated.
107
+ * The correct answer (correctOption) is held in process memory; it is never
108
+ * written to stdout, disk, or any external surface.
109
+ */
110
+ export class QuizQuestionStream {
111
+ provider;
112
+ plan = [];
113
+ totalQuestions = 0;
114
+ fetchedCount = 0;
115
+ askedTexts = [];
116
+ constructor(provider) {
117
+ this.provider = provider;
118
+ }
119
+ /**
120
+ * Create and initialise a QuizQuestionStream.
121
+ * Reads artifact files and builds the question plan, but does NOT generate any questions.
122
+ *
123
+ * @param artifactPaths - Object mapping artifact types to file paths
124
+ * @param totalQuestions - Total number of questions the quiz will ask
125
+ * @param provider - LLM provider (optional, uses fallback if not provided)
126
+ */
127
+ static async create(artifactPaths, totalQuestions, provider) {
128
+ const llmProvider = provider ?? (await createProviderWithFallback());
129
+ const stream = new QuizQuestionStream(llmProvider);
130
+ // Read artifact contents
131
+ const artifactContents = {};
132
+ const artifactSizes = { proposal: 0, design: 0, specs: 0, plan: 0, tasks: 0 };
133
+ for (const [artifactType, filePath] of Object.entries(artifactPaths)) {
134
+ if (filePath) {
135
+ const content = await readArtifactContent(filePath);
136
+ artifactContents[artifactType] = content;
137
+ artifactSizes[artifactType] = content.length;
138
+ }
139
+ }
140
+ // Build flat ordered plan: [artifactType, content] pairs for each question slot
141
+ const allocation = calculateQuestionAllocation(totalQuestions, artifactSizes);
142
+ for (const [artifactType, count] of Object.entries(allocation)) {
143
+ if (count > 0 && artifactContents[artifactType]) {
144
+ for (let i = 0; i < count; i++) {
145
+ stream.plan.push({
146
+ artifactType: artifactType,
147
+ content: artifactContents[artifactType],
148
+ });
149
+ }
150
+ }
151
+ }
152
+ stream.totalQuestions = stream.plan.length;
153
+ return stream;
154
+ }
155
+ /** Total number of questions this quiz will ask. */
156
+ get total() {
157
+ return this.totalQuestions;
158
+ }
159
+ /** Whether there are more questions to fetch. */
160
+ get hasNext() {
161
+ return this.fetchedCount < this.totalQuestions;
162
+ }
163
+ /**
164
+ * Fetch the next question from the LLM.
165
+ * MUST only be called after the user has answered the current question.
166
+ * Only one question is held in memory at a time; this method discards the
167
+ * previous question before fetching the next.
168
+ *
169
+ * @returns The next Question, or null if all questions have been asked.
170
+ */
171
+ async next() {
172
+ if (!this.hasNext)
173
+ return null;
174
+ const slot = this.plan[this.fetchedCount];
175
+ const question = await this.provider.generateSingleQuestion(slot.content, slot.artifactType, this.fetchedCount, this.totalQuestions, this.askedTexts);
176
+ this.fetchedCount += 1;
177
+ this.askedTexts.push(question.text);
178
+ return question;
179
+ }
180
+ }
181
+ /**
182
+ * Generate complete quiz question set for all artifacts.
183
+ *
184
+ * NOTE: This function pre-generates all questions and should only be used for
185
+ * non-interactive contexts (e.g. testing, pass recording). During live quiz
186
+ * execution use QuizQuestionStream.create() instead.
104
187
  *
105
188
  * @param artifactPaths - Object mapping artifact types to file paths
106
189
  * @param totalQuestions - Total number of questions to generate
@@ -108,33 +191,12 @@ export async function generateQuestionsForArtifact(artifactContent, artifactType
108
191
  * @returns Array of all generated questions
109
192
  */
110
193
  export async function generateQuizQuestions(artifactPaths, totalQuestions, provider) {
111
- // Use fallback provider if not provided
112
- const llmProvider = provider || await createProviderWithFallback();
113
- // Read all artifact contents and calculate sizes
114
- const artifactContents = {};
115
- const artifactSizes = {
116
- proposal: 0,
117
- design: 0,
118
- specs: 0,
119
- plan: 0,
120
- tasks: 0,
121
- };
122
- for (const [artifactType, filePath] of Object.entries(artifactPaths)) {
123
- if (filePath) {
124
- const content = await readArtifactContent(filePath);
125
- artifactContents[artifactType] = content;
126
- artifactSizes[artifactType] = content.length;
127
- }
128
- }
129
- // Calculate question allocation
130
- const allocation = calculateQuestionAllocation(totalQuestions, artifactSizes);
131
- // Generate questions for each artifact type
194
+ const stream = await QuizQuestionStream.create(artifactPaths, totalQuestions, provider);
132
195
  const allQuestions = [];
133
- for (const [artifactType, count] of Object.entries(allocation)) {
134
- if (count > 0 && artifactContents[artifactType]) {
135
- const questions = await generateQuestionsForArtifact(artifactContents[artifactType], artifactType, count, llmProvider);
136
- allQuestions.push(...questions);
137
- }
196
+ while (stream.hasNext) {
197
+ const q = await stream.next();
198
+ if (q)
199
+ allQuestions.push(q);
138
200
  }
139
201
  return allQuestions;
140
202
  }