diff --git a/docs/plans/bug-summary.md b/docs/plans/bug-summary.md new file mode 100644 index 0000000..8aa1c63 --- /dev/null +++ b/docs/plans/bug-summary.md @@ -0,0 +1,337 @@ +# Bug Summary + +## Purpose + +This document summarizes the bugs, improvement requests, and open product questions collected so far for CREATE. + +It is based on the issue list provided in the conversation and is meant to serve as a stable reference before the work is split into implementation-specific plans. + +## Status Legend + +- `Fixed`: already reported as fixed +- `Bug`: broken or incorrect behavior +- `Functional improve`: an improvement to an existing workflow +- `Functional request`: a new or expanded capability +- `AI system optimize`: AI behavior, prompt, or content-quality issue +- `H5P bug`: export or H5P-specific content issue + +## Current Bug and Request Inventory + +### 1. Question variants repetitive + +Category: + +- `AI system optimize` + +Problem summary: + +- When the learning objective is precise and the user asks for multiple multiple-choice questions, the system may generate repeated question stems with only different distractors. +- When a learning objective contains multiple sub-topics, the system does not reliably narrow the scope and generate questions for only one slice of the learning objective. + +Example noted: + +- For a learning objective like describing anthropogenic sources of `CO2`, `CH4`, and `N2O`, the system keeps generating the same broad question instead of targeting only one gas at a time. + +Likely area: + +- question-generation prompt +- duplication control +- learning-objective slicing logic + +### 2. Questions and learning objectives coverage + +Category: + +- `AI system optimize` + +Problem summary: + +- It is unclear how to ensure generated questions actually cover the intended content. +- The current assumption is that CREATE links learning objectives and materials correctly, but that coverage behavior is not transparent or guaranteed. + +Likely area: + +- retrieval logic +- prompt instructions +- coverage validation + +### 3. AI prompt customization + +Category: + +- `AI system optimize` + +Problem summary: + +- The user wants more control over the question generator prompt. +- Desired controls include: + - specific page or section of material + - specific misconception to use for distractors + - preferred option style + - more detailed generation guidance + +Likely area: + +- prompt architecture +- UI for optional generation controls +- generation request schema + +### 4. Preview content feature + +Category: + +- `Functional request` + +Problem summary: + +- It is unclear whether preview content means the full document or only part of it. +- The user wants a way to visualize and select the content CREATE should focus on. + +Likely area: + +- materials preview UX +- content selection workflow + +### 5. Export format: more text-based export options + +Category: + +- `Functional request` + +Problem summary: + +- The user wants markdown or another text export format in addition to current exports. + +Likely area: + +- export controller +- export service layer +- export UI + +### 6. Export format: include explanation panel in PDF solutions + +Category: + +- `Functional request` + +Problem summary: + +- The explanation panel that exists in the product should also appear in PDF export with solutions. + +Likely area: + +- PDF export formatting +- answer key layout + +### 7. Feedback on multiple-choice question + +Category: + +- `Functional improve` + +Problem summary: + +- The user wants answer-level feedback, not only a general explanation. +- H5P multiple-choice supports feedback per answer, and CREATE should support this too. + +Likely area: + +- question data model +- review/edit UI +- question generation output format +- H5P export mapping + +### 8. Regenerate questions with pedagogical re-selection + +Category: + +- `Functional improve` + +Problem summary: + +- When users regenerate questions, they may want to choose a different pedagogical approach instead of only regenerating the same pattern. +- The flow should redirect users back to the pedagogical approach options when appropriate. + +Likely area: + +- question generation flow +- AI Plan Configuration navigation + +### 9. Materials limits and quality impact + +Category: + +- `AI system optimize` + +Problem summary: + +- The user wants to know how many files can be uploaded under Materials. +- The user also wants to know how file count affects generated question quality. + +Likely area: + +- product limits +- retrieval quality +- documentation + +### 10. Unable to edit course name + +Category: + +- `Fixed` + +Problem summary: + +- Previously the course name could not be edited. + +Current status: + +- reported as fixed + +### 11. Export H5P quiz with flashcards produces empty dialog cards + +Category: + +- `H5P bug` + +Problem summary: + +- When exporting a quiz composed of flashcards, the import on H5P Hub works, but the dialog cards are empty. +- PDF export works correctly. +- Optional flashcard explanation/feedback always starts with the same verb, `"Understanding..."`. + +Likely area: + +- H5P flashcard export mapping +- flashcard back/explanation content formatting + +### 12. Adding learning objectives: "Generate from materials" does not load + +Category: + +- `Bug` + +Problem summary: + +- In the learning-objective creation flow, clicking `Generate from materials` does nothing. +- Other options such as `AI Classify` and `Add manually` still work. + +Likely area: + +- objective generation UI handler +- materials processing dependency +- loading state wiring + +### 13. AI Plan Configuration page is hard to access after generation + +Category: + +- `Functional improve` + +Problem summary: + +- Users who want to regenerate H5P elements should be able to return to the pedagogical stream page and choose between: + - Support Learning + - Assess Understanding + - Gamify Learning + +Current understanding: + +- This is the active issue now being planned under the `go back` workflow. + +Likely area: + +- `QuestionGeneration.tsx` +- view-state management +- generate-again UX + +### 14. AI Plan Configuration page should list H5P elements per stream + +Category: + +- `Fixed` + +Problem summary: + +- It would be useful to show some H5P elements available for each stream. + +Current status: + +- reported as fixed + +### 15. Add more quiz bug + +Category: + +- `Bug` + +Problem summary: + +- Reported in the source list, but no detailed description was included yet. + +Action needed: + +- collect exact reproduction steps +- define expected vs actual behavior + +## Current Focus Areas We Have Already Started Planning + +### Focus A: Go back to AI Plan Configuration + +Status: + +- planning in progress + +Related document: + +- [go-back-plan.md](/Users/fanhaocheng/tlef-create/tlef-create/docs/plans/go-back-plan.md) + +### Focus B: Improve system prompt + +Status: + +- analyzed at a high level +- not yet split into implementation plan + +### Focus C: Support more export formats + +Status: + +- identified as a future implementation track +- not yet split into implementation plan + +### Focus D: Feedback on multiple-choice question + +Status: + +- identified as a future implementation track +- not yet split into implementation plan + +## Suggested Next Planning Breakdown + +To keep the work manageable, these issues should eventually be split into separate implementation plans. + +### Recommended plan files to add later + +- `prompt-improvement-plan.md` +- `multiple-choice-feedback-plan.md` +- `export-format-plan.md` +- `materials-preview-plan.md` +- `learning-objective-generation-plan.md` +- `h5p-flashcard-export-plan.md` + +## Open Gaps In The Bug Inventory + +The following items still need more detail before implementation planning: + +- `Add more quiz bug` +- exact reproduction steps for `Generate from materials` +- exact expected behavior for content preview and content selection +- preferred first new export format after PDF and H5P +- desired UX for prompt customization controls + +## Notes + +This file is intentionally broad and descriptive. + +Implementation details for individual issues should go into separate plan documents once one issue is selected for active work. diff --git a/docs/plans/go-back-plan.md b/docs/plans/go-back-plan.md new file mode 100644 index 0000000..21a2bd1 --- /dev/null +++ b/docs/plans/go-back-plan.md @@ -0,0 +1,279 @@ +# Go Back Plan + +## Context + +This document captures the current bug context and the implementation plan for restoring a safe "go back to AI Plan Configuration" flow in CREATE. + +The goal is to avoid losing the original problem framing while we implement the fix. + +## Related Issues Collected So Far + +### Original bug report themes + +- Users want to go back and access the AI Plan Configuration page after questions have already been generated. +- Users may want to regenerate with a different pedagogical approach instead of only regenerating the same style of questions. +- Current state management is fragile when moving backward in the generation flow. +- The product needs a clear rule for what should happen to existing generated questions after the user goes back and changes the plan. + +### Broader issue list from the notes + +- Question variants can become repetitive. +- The system may not break a large learning objective into smaller assessable slices. +- Prompt quality and prompt controls need improvement. +- More export formats are desired. +- Multiple-choice questions need answer-level feedback, not only a general explanation. +- Regeneration flow should better support changing pedagogical approach. + +## What We Know From the Current Code + +### Frontend behavior + +In `src/components/generation/QuestionGeneration.tsx`, once questions exist, the component returns the generated-results view early. + +That means: + +- users can no longer access `AIConfigPanel` +- users can no longer access `PlanEditor` +- the flow becomes one-way after generation + +### Current regeneration behavior + +There is already a "Regenerate All Questions" action. + +Current behavior: + +- ask for confirmation +- delete all existing questions +- generate again + +This is too destructive for the new "go back" use case because users may only want to revise the plan first and decide later whether to keep or replace existing questions. + +## Product Decision We Discussed + +### Recommended behavior + +Going back should be a navigation action, not a destructive data action. + +When a user goes back: + +- return them to AI Plan Configuration and Plan Editor +- keep existing generated questions for now +- do not delete questions automatically + +When the user tries to generate again after modifying the plan: + +- ask whether to add new questions or replace existing questions + +### Why this is safer + +- users do not lose work by accident +- state transitions become easier to reason about +- destructive behavior happens only after explicit confirmation +- the product supports both "supplement existing quiz" and "start over" workflows + +## Proposed User Flows + +### Flow A: Add new questions + +User intent: + +- keep existing generated questions +- add more questions using the updated plan + +System behavior: + +- preserve existing questions +- generate new questions based on the current plan +- append them to the quiz + +### Flow B: Replace existing questions + +User intent: + +- discard the old generated set +- generate a fresh set from the new plan + +System behavior: + +- delete existing questions only after confirmation +- generate a new set using the updated plan + +## Scope For This Plan + +This document only covers the "go back" feature. + +It does not yet implement: + +- prompt redesign +- multiple-choice answer-level feedback +- new export formats + +Those should be tracked in separate plan documents later. + +## Implementation Plan + +### Phase 1: Stabilize the product behavior + +#### Goal + +Define explicit UI states instead of relying on `hasQuestions` alone. + +#### Tasks + +- Introduce a view state for the question generation page. +- Separate "plan/config view" from "results view". +- Stop using question existence as the only condition that determines the entire screen. +- Add a visible "Go back to plan" action in the generated-results view. + +#### Expected outcome + +Users can return to the plan/configuration screen even after question generation has completed. + +### Phase 2: Add a safe regeneration decision point + +#### Goal + +Prevent accidental deletion or accidental mixing of old and new generation runs. + +#### Tasks + +- Detect when a quiz already has generated questions and the user tries to generate again. +- Show a confirmation dialog with two explicit choices: + - Add new questions + - Replace existing questions +- Keep cancel behavior available. + +#### Expected outcome + +The user must intentionally choose how the new generation interacts with existing questions. + +### Phase 3: Refactor generation flow logic + +#### Goal + +Make the question generation path consistent for both add and replace flows. + +#### Tasks + +- Extract generation start logic into a reusable path. +- Extract replacement logic so deletion is only triggered in replace mode. +- Ensure save-plan behavior still runs before generation. +- Ensure question counts and notifications remain correct in both modes. + +#### Expected outcome + +The flow is easier to maintain and less likely to break when more generation options are added later. + +### Phase 4: Validate state transitions + +#### Goal + +Make sure the UI and data stay consistent when moving between generated results and plan editing. + +#### Tasks + +- Verify plan state is preserved when leaving and returning to config view. +- Verify AI config state is preserved. +- Verify existing questions are still shown if the user returns to results without regenerating. +- Verify add mode appends questions correctly. +- Verify replace mode deletes and regenerates correctly. + +#### Expected outcome + +Backward navigation no longer causes confusing or destructive state behavior. + +## Key Engineering Risks + +### Risk 1: UI state and data state remain coupled + +If the page still derives its entire mode from `questions.length > 0`, the "go back" fix will be unstable. + +### Risk 2: Replace flow becomes too implicit + +If deletion still happens deep inside the old regenerate path without a clear mode variable, the new UX will be brittle. + +### Risk 3: Add mode creates ambiguous totals + +If existing question counts and newly planned counts are not communicated clearly, users may be confused about what "Generate 10 Questions" means when 12 already exist. + +## Open Decisions + +These should be confirmed before implementation details are finalized. + +### Decision 1 + +Should the default action in the confirmation dialog be "Add new questions" or should there be no default emphasized choice? + +Current recommendation: + +- no destructive default +- visually emphasize the safer option + +### Decision 2 + +Should the generated-results screen continue to be the first screen shown when questions already exist, or should we remember the last sub-view the user selected? + +Current recommendation: + +- start with existing behavior plus a working "Go back to plan" button +- do not add persistent sub-view memory until the flow is stable + +## Suggested Next Execution Order + +1. Add explicit view state to `QuestionGeneration.tsx` +2. Add "Go back to plan" action +3. Add generate-again decision modal +4. Split add vs replace generation logic +5. Test state transitions + +## Phase 1 Checklist + +This checklist is for the first implementation pass only. + +The goal of Phase 1 is to restore safe backward navigation without changing question-deletion behavior yet. + +### Checklist + +- [ ] Add a dedicated UI view state in `QuestionGeneration.tsx` +- [ ] Limit that view state to navigation concerns only +- [ ] Stop using `hasQuestions` as the only determinant of the whole page view +- [ ] Keep the streaming early-return behavior unchanged +- [ ] Replace the generated-results early return with a conditional branch based on the new view state +- [ ] Preserve the existing default behavior where quizzes with generated questions initially open in the results view +- [ ] Ensure the default view decision runs only during initialization, not on every render +- [ ] Add a visible `Go back to plan` action in the generated-results view +- [ ] Ensure `Go back to plan` only changes the UI view and does not trigger any data mutation +- [ ] Ensure `Go back to plan` does not clear `planItems` +- [ ] Ensure `Go back to plan` does not clear `aiConfig` +- [ ] Ensure `Go back to plan` does not clear `planMode` +- [ ] Ensure `Go back to plan` does not clear `deliveryTarget` +- [ ] Ensure `Go back to plan` does not clear `targetFormat` +- [ ] Ensure question generation completion explicitly returns the UI to the results view +- [ ] Ensure `restoreSettings()` does not unintentionally overwrite a user-triggered view change +- [ ] Ensure reloading questions does not force the page back to the results view unless explicitly intended + +### Phase 1 Validation Scenarios + +- [ ] A quiz with no questions opens in the plan/configuration view +- [ ] A quiz with existing questions opens in the results view +- [ ] After generation completes, the user sees the results view +- [ ] The user can click `Go back to plan` and access AI Plan Configuration +- [ ] The user can still see their existing plan configuration after going back +- [ ] The user can return to the results view without losing previously generated questions +- [ ] Refreshing the page still behaves correctly for a quiz that already has generated questions + +### Not Included In Phase 1 + +- [ ] No question deletion confirmation flow yet +- [ ] No `Add new questions` vs `Replace existing questions` decision modal yet +- [ ] No refactor of regeneration semantics yet +- [ ] No prompt or export changes yet + +## Out Of Scope For This Document + +- prompt improvement strategy +- answer-level multiple-choice feedback +- markdown or other new export formats +- learning objective slicing logic + +These should be documented separately after the go-back flow is stable. diff --git a/docs/plans/intelligent-question-generation-workflow-plan.md b/docs/plans/intelligent-question-generation-workflow-plan.md new file mode 100644 index 0000000..599bf69 --- /dev/null +++ b/docs/plans/intelligent-question-generation-workflow-plan.md @@ -0,0 +1,471 @@ +# Intelligent Question Generation Workflow Plan + +## Purpose + +This document proposes a workflow-based solution for improving AI question generation in CREATE. + +The goal is to solve a core issue that prompt tuning alone does not fully solve: + +- when multiple questions are generated from the same learning objective, the system produces questions that are too similar +- the system does not reliably split a broad learning objective into multiple assessable tasks +- the system does not actively manage coverage across a generation batch + +## Problem This Workflow Is Designed To Solve + +### Current failure mode + +When the system is asked to generate multiple questions from one broad learning objective, it often behaves like this: + +1. receive one broad learning objective +2. retrieve relevant materials +3. generate one question +4. repeat that same pattern several times + +This creates two common bad outcomes: + +- repeated broad questions +- repeated narrow questions on the same selected slice + +### Example failure + +Learning objective: + +- `Describe the anthropogenic sources of carbon dioxide (CO2), methane (CH4), and nitrous oxide (N2O).` + +Expected outcome for 5 questions: + +- one question on `CO2` +- one on `CH4` +- one on `N2O` +- one comparative question +- one misconception-focused question + +Actual outcome seen during testing: + +- the system first repeated the whole broad LO +- after prompt improvement, it then repeated only the `CH4` slice + +This shows that prompt improvements helped, but the system still lacks workflow-level coverage management. + +## Recommendation + +Do not jump directly to a heavy multi-agent architecture. + +Instead, build a structured workflow with state. + +This should act like an internal planning loop for generation: + +1. analyze the learning objective +2. propose candidate slices +3. track used slices +4. generate one question at a time +5. validate for similarity +6. retry if needed + +## Why Workflow Is Better Than More Prompt Alone + +Prompt-only improvements can encourage better behavior, but they still depend on one generation call correctly reasoning about: + +- slicing +- coverage +- novelty +- distractor quality + +A workflow can enforce these steps explicitly. + +This makes the system: + +- more reliable +- easier to debug +- easier to extend +- easier to measure + +## High-Level Workflow + +### Stage 1: Learning Objective Analysis + +Input: + +- learning objective +- target question type +- relevant content +- requested number of questions + +Output: + +- LO type: `narrow` or `broad` +- candidate concept slices +- candidate misconception targets +- candidate comparison or application targets + +Example output for the greenhouse gas LO: + +- `CO2 anthropogenic sources` +- `CH4 anthropogenic sources` +- `N2O anthropogenic sources` +- `compare major source categories across gases` +- `distinguish anthropogenic vs natural sources` + +### Stage 2: Question Task Planning + +Input: + +- candidate slices +- requested question count +- question type + +Output: + +- a list of question tasks for the batch + +Example: + +- Task 1: assess `CO2 sources` with multiple-choice +- Task 2: assess `CH4 sources` with multiple-choice +- Task 3: assess `N2O sources` with multiple-choice +- Task 4: compare `CO2 vs CH4` +- Task 5: misconception check on `fertilizers vs methane` + +### Stage 3: Coverage Ledger + +Purpose: + +- maintain state during batch generation + +Track items such as: + +- used concept slices +- used scenarios +- used misconception categories +- used comparisons +- generated question texts + +This becomes the memory for the batch. + +### Stage 4: Generate One Question At A Time + +Instead of asking for 5 questions at once, the system should: + +1. choose the next planned task +2. inject the task into the generation prompt +3. generate exactly one question +4. attach generation metadata to it + +The prompt for each question should no longer say only: + +- `generate a question for this LO` + +It should say something closer to: + +- `generate a question for this specific planned slice` + +### Stage 5: Similarity And Coverage Validation + +After one question is generated, run a validator. + +Checks can include: + +- is the stem too similar to an existing question? +- is the assessed slice already covered? +- is the scenario too similar? +- for MCQ, are distractors distinct enough? + +If validation fails: + +- reject the question +- pick another slice or scenario +- regenerate + +### Stage 6: Save Final Batch + +Only after the full batch is assembled and validated should the questions be finalized and stored. + +## Recommended Architecture + +This does not need to be a full multi-agent system. + +### Recommended first version + +A single workflow service with multiple internal steps: + +- `analyzer` +- `task planner` +- `coverage ledger` +- `question generator` +- `validator` + +These can all be implemented in one backend pipeline first. + +### Possible future version + +If needed later, some stages can become specialized agents: + +- `LO-slicing agent` +- `question-writing agent` +- `question-critique agent` + +But this should come later, not first. + +## How This Solves The Current Problem + +### Problem + +Five questions from one LO become too similar. + +### Workflow fix + +Because the workflow explicitly plans five different tasks first, each generation call starts from a different intended target. + +So the system is no longer doing: + +- `same LO -> same call pattern -> repeated question` + +It becomes: + +- `same LO -> different planned task -> different question target` + +### Result + +This should dramatically reduce: + +- repeated stems +- repeated concept focus +- repeated scenario framing + +## Interaction With Question Type + +The workflow should consider not only the LO, but also the question type. + +### Example + +Same LO: + +- `Describe the anthropogenic sources of CO2, CH4, and N2O.` + +For `multiple-choice`, good tasks include: + +- single-gas source identification +- compare two gases +- identify a misconception + +For `matching`, good tasks include: + +- match gas to source category + +For `summary`, good tasks include: + +- synthesize all major sources and contrasts + +For `ordering`, that LO may be a weak fit, so fewer tasks should be assigned. + +This means the workflow can help with: + +- LO slicing +- question type suitability +- coverage planning + +## Proposed Backend Components + +### 1. LO Analysis Function + +Responsibility: + +- inspect LO +- extract candidate slices +- classify LO breadth + +Possible location: + +- new service under `routes/create/services/` + +### 2. Batch Task Planner + +Responsibility: + +- create a structured task list for `N` questions + +Possible location: + +- new service or helper used by question generation + +### 3. Coverage Ledger + +Responsibility: + +- store in-memory generation state for one batch + +Track: + +- used slices +- used misconceptions +- used scenarios +- rejected generations + +### 4. Question Validator + +Responsibility: + +- detect near-duplicates +- detect repeated concept focus +- detect poor distractor diversity + +### 5. Retry Controller + +Responsibility: + +- decide when to regenerate +- choose another slice or another framing + +## Suggested Integration Points In Current Code + +### Existing relevant files + +- [llmService.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/services/llmService.js) +- [questionStreamingService.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/services/questionStreamingService.js) +- [intelligentQuestionGenerationService.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/services/intelligentQuestionGenerationService.js) + +### Likely integration path + +#### Step 1 + +Introduce a planner stage before the per-question generation call. + +#### Step 2 + +Change generation from: + +- `repeat the same generation call N times` + +to: + +- `generate from a planned task list` + +#### Step 3 + +Add a lightweight validator before saving each question. + +## Implementation Phases + +## Phase 1: Explicit LO Slice Planning + +Goal: + +- identify candidate slices for one LO + +Deliverables: + +- helper that turns broad LO into multiple question tasks + +This phase alone would already help with the repeated-CH4 problem. + +## Phase 2: Batch Coverage Ledger + +Goal: + +- maintain per-batch memory of used slices + +Deliverables: + +- in-memory ledger structure +- selection rule for next task + +## Phase 3: One-Question-At-A-Time Generation + +Goal: + +- generate from task list, not directly from raw LO each time + +Deliverables: + +- updated orchestration path + +## Phase 4: Validation Loop + +Goal: + +- reject near-duplicate or low-diversity questions + +Deliverables: + +- similarity checks +- retry mechanism + +## Phase 5: Observability + +Goal: + +- understand whether workflow improves output + +Deliverables: + +- logs for chosen slices +- logs for rejected duplicates +- logs for coverage distribution + +## Validation Criteria + +The workflow should be considered successful if it improves these scenarios: + +### Scenario 1: One broad LO, five MCQs + +Expected: + +- questions cover different slices +- repeated stems drop significantly + +### Scenario 2: One broad LO, mixed question types + +Expected: + +- different types are matched to suitable sub-tasks + +### Scenario 3: Narrow LO + +Expected: + +- workflow should not over-split +- it should still vary framing and misconceptions when possible + +## Risks + +### Risk 1: Too much complexity too early + +Mitigation: + +- start with one service and explicit stages +- do not build a multi-agent system first + +### Risk 2: Latency increases + +Mitigation: + +- keep analysis and planning lightweight +- only retry when validation fails + +### Risk 3: Over-splitting harms pedagogical quality + +Mitigation: + +- allow synthesis tasks when appropriate +- not every broad LO must be decomposed into only atomic facts + +## Recommendation Summary + +Recommended solution: + +- build a workflow, not a heavy multi-agent system +- introduce slice planning, coverage memory, and validation + +This is the most practical way to solve: + +- repeated questions from the same LO +- weak LO decomposition +- lack of intelligent task splitting by question type + +## Next Suggested Plan + +After this document, the next implementation-oriented plan should likely be: + +- `lo-slice-planning-plan.md` + +That plan can define the first concrete coding step for this workflow. diff --git a/docs/plans/lo-slice-planning-plan.md b/docs/plans/lo-slice-planning-plan.md new file mode 100644 index 0000000..8161edb --- /dev/null +++ b/docs/plans/lo-slice-planning-plan.md @@ -0,0 +1,451 @@ +# LO Slice Planning Plan + +## Purpose + +This document defines the first concrete implementation step for intelligent question generation: + +- turn one learning objective into a set of candidate assessment slices +- use those slices to plan multiple non-redundant question tasks + +This plan is a child plan of: + +- [intelligent-question-generation-workflow-plan.md](/Users/fanhaocheng/tlef-create/tlef-create/docs/plans/intelligent-question-generation-workflow-plan.md) + +## Why Start Here + +The current repeated-question problem comes from a missing intermediate step. + +Right now the system often does this: + +1. receive one learning objective +2. ask the model to generate a question for it +3. repeat several times + +What is missing: + +- a planning step that says what each question should assess before generation begins + +Without that step, even a good prompt can still produce: + +- repeated broad questions +- repeated narrow questions on the same slice + +## Scope + +This plan only covers: + +- detecting whether an LO is broad or narrow +- producing candidate slices for that LO +- converting slices into structured question tasks + +This plan does not yet implement: + +- batch coverage ledger +- similarity validator +- retry loop +- multi-agent behavior + +## Core Idea + +For one LO, the system should build a small internal representation like this: + +- `originalLO` +- `breadth` +- `candidateSlices` +- `recommendedTaskSequence` + +Example: + +Original LO: + +- `Describe the anthropogenic sources of carbon dioxide (CO2), methane (CH4), and nitrous oxide (N2O).` + +Candidate slices: + +- `CO2 anthropogenic sources` +- `CH4 anthropogenic sources` +- `N2O anthropogenic sources` +- `compare source patterns across gases` +- `distinguish anthropogenic vs natural sources` + +Recommended task sequence for 5 MCQs: + +- Task 1: `CO2 anthropogenic sources` +- Task 2: `CH4 anthropogenic sources` +- Task 3: `N2O anthropogenic sources` +- Task 4: `compare source patterns across gases` +- Task 5: `misconception check on source attribution` + +## Desired Output Structure + +The slice-planning layer should eventually return a structured object like: + +```json +{ + "originalLO": "Describe the anthropogenic sources of carbon dioxide (CO2), methane (CH4), and nitrous oxide (N2O).", + "breadth": "broad", + "candidateSlices": [ + { + "id": "co2_sources", + "label": "CO2 anthropogenic sources", + "kind": "component" + }, + { + "id": "ch4_sources", + "label": "CH4 anthropogenic sources", + "kind": "component" + } + ], + "recommendedTasks": [ + { + "sliceId": "co2_sources", + "questionIntent": "source-identification" + } + ] +} +``` + +This is a conceptual target, not a final locked schema yet. + +## Breadth Detection + +The first question the planner should answer is: + +- is this LO `narrow` or `broad`? + +### Signals of a broad LO + +- multiple enumerated components +- conjunction-heavy structure such as `A, B, and C` +- multiple verbs or multiple outcomes in one statement +- category + comparison + explanation all bundled together + +Examples: + +- `Describe the anthropogenic sources of CO2, CH4, and N2O.` +- `Explain the structure and function of carbohydrates, lipids, proteins, and nucleic acids.` + +### Signals of a narrow LO + +- one core concept +- one clear skill +- one well-bounded process or relationship + +Example: + +- `Explain why methane emissions are strongly associated with livestock agriculture.` + +## Slice Types + +Slices should not all be treated the same. + +The planner should eventually support several slice categories. + +### Component slices + +Use when the LO lists multiple concepts or entities. + +Examples: + +- `CO2 sources` +- `CH4 sources` +- `N2O sources` + +### Comparison slices + +Use when multiple components can be meaningfully contrasted. + +Examples: + +- `compare CO2 and CH4 source patterns` +- `compare agricultural vs fossil-fuel-related emissions` + +### Misconception slices + +Use when the LO is suitable for common confusions. + +Examples: + +- `anthropogenic vs natural source confusion` +- `fertilizer-related emissions confused with methane` + +### Application slices + +Use when the LO can be grounded in case-based reasoning. + +Examples: + +- `identify which human activity mainly increases methane` + +## Question-Type-Aware Task Planning + +The planner should not produce the same task sequence for every question type. + +### For multiple-choice + +Good slice/task patterns: + +- source identification +- comparison +- misconception discrimination + +### For true/false + +Good slice/task patterns: + +- narrow claim validation +- misconception correction + +### For matching + +Good slice/task patterns: + +- gas to source category mapping + +### For summary + +Good slice/task patterns: + +- broader synthesis +- organized multi-slice explanation + +This means the task planner needs both: + +- LO slices +- question-type fit + +## Recommended First Implementation Strategy + +Do not start with a fully autonomous LLM planner. + +Start with a hybrid approach: + +### Step 1: Lightweight heuristic pre-processing + +Use deterministic logic to detect obvious broad structures: + +- comma-separated enumerations +- `and` / `or` joined components +- parenthetical abbreviations + +This should catch many important educational cases cheaply. + +### Step 2: Optional LLM-assisted slice extraction + +If heuristics detect a broad LO, use the LLM to propose candidate slices in a structured format. + +This is safer than asking the LLM to generate questions directly because: + +- the task is narrower +- the output can be validated more easily + +### Step 3: Deterministic task selection + +Once slices are produced, use deterministic rules to choose a task sequence for the requested number of questions. + +This helps avoid: + +- random repetition +- overuse of one slice + +## Proposed Backend Components + +### 1. `loSlicePlanner` service + +Responsibility: + +- accept one LO and generation context +- return slice-planning output + +Likely location: + +- `routes/create/services/loSlicePlanner.js` + +### 2. `questionTaskPlanner` helper + +Responsibility: + +- convert slices into question tasks for the requested count and question type + +Likely location: + +- same service at first, split later if needed + +## Proposed Inputs + +The first version should likely accept: + +- `learningObjective` +- `questionType` +- `requestedCount` +- `courseContext` +- `relevantContent` + +Optional future inputs: + +- `additionalInstructions` +- `alreadyUsedSlices` +- `targetDifficulty` + +## Proposed Outputs + +First version should likely return: + +- `breadth` +- `candidateSlices` +- `recommendedTasks` + +Optional later: + +- `reasoning` +- `confidence` +- `rejectedSlices` + +## Integration Plan + +### Current generation model + +Today, the system effectively repeats question generation for the same LO. + +### Target near-term integration + +Before generating a batch of questions for a single LO: + +1. call the slice planner +2. get candidate slices +3. build recommended tasks +4. generate each question from a selected task instead of the raw LO + +This can happen before the validator and coverage ledger are introduced. + +## Implementation Phases + +## Phase 1: Define schema and add planning service + +Goal: + +- create the planning service and return stable structured output + +Deliverables: + +- service file +- output schema +- simple tests or fixtures + +## Phase 2: Heuristic breadth detection + +Goal: + +- identify obvious broad LOs without needing an LLM call + +Deliverables: + +- heuristic rules +- broad/narrow classification + +## Phase 3: LLM-assisted slice extraction + +Goal: + +- use the LLM to propose candidate slices when needed + +Deliverables: + +- prompt for slice extraction +- parser and validation logic + +## Phase 4: Task sequencing by question type + +Goal: + +- turn slices into an ordered question task list + +Deliverables: + +- task-planning rules +- question-type-specific sequencing + +## Phase 5: Connect to generation pipeline + +Goal: + +- use planned tasks in the actual generation path + +Deliverables: + +- integration with existing generation service + +## Validation Scenarios + +### Scenario 1: Broad enumerated LO + +Input: + +- `Describe the anthropogenic sources of CO2, CH4, and N2O.` + +Expected: + +- classified as `broad` +- produces at least three component slices +- recommended tasks rotate across slices + +### Scenario 2: Narrow LO + +Input: + +- `Explain why methane emissions are associated with livestock agriculture.` + +Expected: + +- classified as `narrow` +- no unnecessary over-splitting + +### Scenario 3: Broad LO with conceptual families + +Input: + +- `Explain the structure and function of carbohydrates, lipids, proteins, and nucleic acids.` + +Expected: + +- component slices for each biomolecule family +- possible comparison slices + +## Risks + +### Risk 1: Over-splitting + +If every LO is broken too aggressively, question quality can become fragmented. + +Mitigation: + +- broad/narrow classification first +- allow narrow LO passthrough behavior + +### Risk 2: Slice extraction becomes noisy + +LLM-based slice proposals may be redundant or low quality. + +Mitigation: + +- validate and deduplicate slices after extraction +- start with constrained prompt format + +### Risk 3: Integration grows too big + +Trying to connect slice planning, ledger, validator, and retries all at once could slow delivery. + +Mitigation: + +- ship slice planning first +- add later workflow stages incrementally + +## Recommendation Summary + +The first real implementation step toward intelligent generation should be: + +- add LO slice planning +- classify LO breadth +- create structured question tasks before generation + +This is the smallest change that directly addresses the repeated-question problem seen in testing. diff --git a/docs/plans/prompt-improvement-plan.md b/docs/plans/prompt-improvement-plan.md new file mode 100644 index 0000000..38cc3fe --- /dev/null +++ b/docs/plans/prompt-improvement-plan.md @@ -0,0 +1,413 @@ +# Prompt Improvement Plan + +## Purpose + +This document captures the current AI prompt problems in CREATE and proposes a staged plan for improving prompt quality and prompt controls. + +The main motivation comes from the issue list already recorded in: + +- [bug-summary.md](/Users/fanhaocheng/tlef-create/tlef-create/docs/plans/bug-summary.md) + +## Main Problems To Solve + +### 1. Repetitive question variants + +Observed behavior: + +- When a user asks for multiple multiple-choice questions for one learning objective, the system may generate nearly the same question stem multiple times. +- Sometimes only the wrong answers change while the assessed concept stays identical. + +Why this matters: + +- poor pedagogical variety +- inflated quiz length without increasing coverage +- lower trust in AI-generated questions + +### 2. Large learning objectives are not sliced well + +Observed behavior: + +- If one learning objective contains multiple assessable sub-topics, the system often keeps generating broad questions instead of focusing on one sub-skill at a time. + +Example from issue notes: + +- A learning objective covering anthropogenic sources of `CO2`, `CH4`, and `N2O` should be sliceable into smaller assessable targets, but the system keeps asking the same broad question. + +Why this matters: + +- broad questions repeat easily +- question difficulty becomes inconsistent +- content coverage becomes shallow + +### 3. Weak coverage guarantees + +Observed behavior: + +- It is not clear how CREATE ensures that generated questions cover the intended materials and learning objectives well. +- Current behavior appears to rely on the model inferring coverage from the learning objective and retrieved content, but there is no explicit coverage strategy. + +Why this matters: + +- some content may be over-assessed +- some content may never be assessed +- instructor expectations are harder to satisfy + +### 4. Prompt is not configurable enough + +Observed behavior: + +- Instructors want more control over generation, such as: + - focus on a page or section + - use a specific misconception for distractors + - use a specific option style + - emphasize a specific content area + +Why this matters: + +- current prompt only supports a general `additionalInstructions` field +- important pedagogical constraints are not structured + +## Current Prompt Architecture + +There are currently two different prompt layers that should be treated separately. + +### Layer A: AI plan generation prompt + +Purpose: + +- decide question distribution across learning objectives and question types + +Main code entry points: + +- [planController.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/controllers/planController.js) +- [promptTemplateInitializer.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/services/promptTemplateInitializer.js) + +Current behavior: + +- a system template is selected by pedagogical approach +- learning objectives and materials are injected into a distribution prompt +- the LLM returns a `planItems` JSON object + +Current limitation: + +- the plan prompt thinks mainly in terms of counts and types +- it does not reason deeply about sub-skill slicing or coverage intent + +### Layer B: question generation prompt + +Purpose: + +- generate the actual question content for one question + +Main code entry point: + +- [llmService.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/services/llmService.js) + +Current behavior: + +- builds one prompt for a single question +- includes: + - learning objective or custom prompt + - relevant content chunks + - difficulty + - previous question texts to avoid duplication + +Current limitation: + +- duplication prevention is too weak +- prompt does not require explicit sub-skill selection +- prompt does not require explicit coverage reasoning +- prompt does not support structured instructor controls + +## Diagnosis Of Why Repetition Happens + +### Cause 1: duplication guard is text-only + +The current question-generation prompt mostly avoids duplication by listing previous `questionText` strings. + +Why this is weak: + +- same idea can be paraphrased +- same LO slice can be reused with different wording +- same stem can be regenerated with different distractors + +### Cause 2: no explicit "choose one slice" step + +The model is told to generate a question for a learning objective, but not forced to pick a specific sub-skill when the objective is broad. + +Effect: + +- the model defaults to broad, summary-style question stems +- those broad stems are easy to repeat + +### Cause 3: no explicit coverage ledger + +The prompt does not ask the model to track what has already been assessed across previous questions. + +Effect: + +- the model has no internal checklist of covered sub-topics +- repeated focus areas are common + +### Cause 4: distractor strategy is under-specified + +The prompt says to create plausible distractors, but does not require: + +- distinct misconception categories +- contrast between distractors +- evidence that distractors target different misunderstandings + +Effect: + +- distractors can feel shallow or repetitive + +## Prompt Improvement Strategy + +The plan is to strengthen prompt behavior in layers instead of trying to solve everything with one giant prompt edit. + +## Phase 1: Improve question-generation prompt quality + +### Goal + +Reduce repetition and improve coverage without changing the UI yet. + +### Prompt strategy changes + +- Require the model to identify the specific sub-skill or sub-topic it is assessing before generating the question. +- Require the model to avoid previously assessed sub-skills, not only previously used wording. +- Require the model to generate one question that targets a narrow slice of the learning objective when the objective is broad. +- For multiple-choice questions, require distractors to represent different misconception types. + +### Example conceptual additions + +- `Select one specific assessable sub-skill from the learning objective.` +- `Do not reuse a sub-skill, scenario, or conceptual contrast already covered by previous questions.` +- `If the learning objective contains multiple components, assess only one component unless the prompt explicitly requires synthesis.` +- `For multiple-choice questions, each incorrect option must reflect a different misconception or reasoning error.` + +### Expected impact + +- fewer repeated stems +- narrower and more varied questions +- better multiple-choice quality + +## Phase 2: Improve plan-generation prompt quality + +### Goal + +Generate better distributions before question generation starts. + +### Prompt strategy changes + +- Ask the plan model to identify whether each learning objective is broad or narrow. +- Ask it to allocate multiple questions across different conceptual slices when an LO is broad. +- Encourage balanced coverage instead of only proportional counts. + +### Example conceptual additions + +- `If a learning objective contains multiple assessable components, distribute questions so different components can be covered across the set.` +- `Prefer conceptual diversity within each learning objective when assigning repeated question counts.` + +### Expected impact + +- better diversity at the plan level +- less repetition before the question-generation step even begins + +## Phase 3: Add structured instructor controls + +### Goal + +Support more precise prompt customization than free-form text alone. + +### Candidate controls + +- focus section or page +- target misconception +- distractor style +- scenario style +- coverage emphasis +- question tone or formality + +### Likely implementation direction + +- extend generation request schema +- keep `additionalInstructions` as fallback +- inject structured controls into prompt in a stable format + +## Phase 4: Add observability and evaluation + +### Goal + +Make prompt improvements measurable. + +### Candidate checks + +- repeated concept rate across a generation batch +- repeated stem similarity +- LO coverage diversity +- distractor distinctness review + +### Why this matters + +- prompt changes are otherwise hard to evaluate reliably +- without measurement, regressions are easy to miss + +## Suggested Implementation Order + +1. Update the question-generation prompt in `llmService.js` +2. Improve duplication instructions beyond `previousQuestions` +3. Add explicit sub-skill selection behavior +4. Improve multiple-choice distractor instructions +5. Update plan-generation templates in `promptTemplateInitializer.js` +6. Add support for structured prompt controls in API and frontend later + +## Detailed Execution Plan + +### Step 1: Rewrite the question prompt instructions + +Files: + +- [llmService.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/services/llmService.js) + +Tasks: + +- strengthen prompt instructions for novelty +- require narrow assessment scope for broad LOs +- add explicit coverage and sub-skill guidance +- improve multiple-choice distractor instructions + +### Step 2: Expand previous-question context + +Files: + +- [llmService.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/services/llmService.js) + +Tasks: + +- stop relying on raw question text alone +- include stronger anti-duplication framing +- optionally include summarized concept-focus metadata later + +### Step 3: Improve plan prompt templates + +Files: + +- [promptTemplateInitializer.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/services/promptTemplateInitializer.js) +- [planController.js](/Users/fanhaocheng/tlef-create/tlef-create/routes/create/controllers/planController.js) + +Tasks: + +- revise pedagogical approach templates +- encourage conceptual diversity within the same LO +- improve distribution guidance for broad learning objectives + +### Step 4: Prepare for prompt controls + +Files likely affected later: + +- frontend AI config components +- generation request payloads +- backend prompt builders + +Tasks: + +- define a stable schema for instructor controls +- decide which controls belong in plan generation vs question generation + +## Proposed Prompt Principles + +These principles should guide future prompt edits. + +### Principle 1: Assess one thing clearly + +A question should usually assess one narrow idea unless synthesis is intentional. + +### Principle 2: Track conceptual novelty, not just wording novelty + +Different wording is not enough if the assessed concept is still the same. + +### Principle 3: Distinguish coverage from quantity + +More questions do not automatically mean better assessment coverage. + +### Principle 4: Make distractors pedagogically meaningful + +Wrong answers should reveal different misunderstandings, not just random incorrectness. + +### Principle 5: Prefer stable structure over ad hoc free-text instructions + +As more controls are added, they should become structured inputs rather than ever-growing free-text notes. + +## Risks + +### Risk 1: Prompt becomes too long + +If too many constraints are added at once, the model may become less reliable or more verbose. + +Mitigation: + +- prioritize the highest-impact constraints first +- keep JSON output instructions simple and clear + +### Risk 2: Over-constraining harms creativity + +If novelty rules are too rigid, the model may generate awkward or overly narrow questions. + +Mitigation: + +- test against both narrow and broad LOs +- prefer "target one slice when appropriate" instead of "always target one tiny fact" + +### Risk 3: Plan prompt and question prompt drift apart + +If the plan layer and question layer follow different assumptions, the resulting behavior may be inconsistent. + +Mitigation: + +- document both layers together +- update both prompt systems intentionally + +## Validation Scenarios + +The improved prompt system should be tested against cases like these. + +### Scenario 1: Broad LO with multiple assessable components + +Expected behavior: + +- questions spread across different components instead of repeating a broad synthesis question + +### Scenario 2: Requesting five multiple-choice questions on one LO + +Expected behavior: + +- question stems are not near-duplicates +- distractors vary by misconception type + +### Scenario 3: Limited material context + +Expected behavior: + +- prompt still generates usable questions without hallucinating too much unsupported specificity + +### Scenario 4: Instructor guidance present + +Expected behavior: + +- custom instructions influence output in a stable and visible way + +## Not In Scope For This Document + +- full UI design for prompt customization controls +- implementation of answer-level multiple-choice feedback +- markdown export +- content preview and section picker UX + +Those should be tracked in separate plans. + +## Suggested Next Plan Documents + +- `multiple-choice-feedback-plan.md` +- `export-format-plan.md` +- `materials-preview-plan.md` + diff --git a/routes/create/__tests__/integration/export.test.js b/routes/create/__tests__/integration/export.test.js index 0b0e8a7..2371275 100644 --- a/routes/create/__tests__/integration/export.test.js +++ b/routes/create/__tests__/integration/export.test.js @@ -296,6 +296,45 @@ describe('Export API Integration Tests', () => { }); }); + describe('POST /api/export/markdown/:quizId', () => { + test('should generate Markdown with questions type', async () => { + const response = await request(app) + .post(`/api/export/markdown/${quizId}`) + .send({ type: 'questions' }) + .expect(201); + + trackExportFile(response); + + expect(response.body.success).toBe(true); + expect(response.body.data.filename).toContain('.md'); + expect(response.body.data.metadata.exportFormat).toBe('markdown'); + expect(response.body.data.metadata.exportType).toBe('questions'); + expect(response.body.data.metadata.fileSize).toBeGreaterThan(0); + }); + + test('should generate Markdown with combined type', async () => { + const response = await request(app) + .post(`/api/export/markdown/${quizId}`) + .send({ type: 'combined' }) + .expect(201); + + trackExportFile(response); + + expect(response.body.success).toBe(true); + expect(response.body.data.metadata.exportType).toBe('combined'); + }); + + test('should return 400 for invalid markdown export type', async () => { + const response = await request(app) + .post(`/api/export/markdown/${quizId}`) + .send({ type: 'invalid' }) + .expect(400); + + expect(response.body.error).toBeDefined(); + expect(response.body.error.message).toContain('Invalid export type'); + }); + }); + describe('GET /api/export/:quizId/preview', () => { test('should return quiz preview successfully', async () => { const response = await request(app) @@ -398,6 +437,11 @@ describe('Export API Integration Tests', () => { expect(jsonFormat).toBeDefined(); expect(jsonFormat.supported).toBe(true); + const markdownFormat = response.body.data.formats.find(f => f.id === 'markdown'); + expect(markdownFormat).toBeDefined(); + expect(markdownFormat.supported).toBe(true); + expect(markdownFormat.fileExtension).toBe('.md'); + // Check QTI format exists but not supported const qtiFormat = response.body.data.formats.find(f => f.id === 'qti'); expect(qtiFormat).toBeDefined(); diff --git a/routes/create/__tests__/unit/markdownExportService.test.js b/routes/create/__tests__/unit/markdownExportService.test.js new file mode 100644 index 0000000..5777022 --- /dev/null +++ b/routes/create/__tests__/unit/markdownExportService.test.js @@ -0,0 +1,60 @@ +import { describe, test, expect } from '@jest/globals'; +import { + renderMarkdownQuestion, + renderMarkdownQuestionContent, + renderMarkdownAnswerContent +} from '../../services/markdownExportService.js'; + +describe('markdownExportService', () => { + test('renders multiple-choice question content with options', () => { + const output = renderMarkdownQuestionContent({ + type: 'multiple-choice', + content: { + options: [ + { text: 'Alpha', isCorrect: false }, + { text: 'Beta', isCorrect: true } + ] + } + }); + + expect(output).toContain('### Options'); + expect(output).toContain('- A. Alpha'); + expect(output).toContain('- B. Beta'); + }); + + test('renders answer content with explanation', () => { + const output = renderMarkdownAnswerContent({ + type: 'multiple-choice', + content: { + options: [ + { text: 'Wrong', isCorrect: false }, + { text: 'Right', isCorrect: true } + ] + }, + explanation: 'Because Right is supported by the material.' + }); + + expect(output).toContain('- Correct Answer: B\\. Right'); + expect(output).toContain('- Explanation:'); + expect(output).toContain('Because Right is supported by the material.'); + }); + + test('renders combined markdown question with metadata and answer section', () => { + const output = renderMarkdownQuestion({ + type: 'true-false', + questionText: 'The Earth orbits the Sun.', + difficulty: 'easy', + correctAnswer: 'true', + explanation: 'This is a basic astronomy fact.', + learningObjective: { text: 'Identify basic astronomy facts' } + }, 0, 'combined'); + + expect(output).toContain('## Question 1'); + expect(output).toContain('The Earth orbits the Sun.'); + expect(output).toContain('- Type: True False'); + expect(output).toContain('- Learning Objective: Identify basic astronomy facts'); + expect(output).toContain('### Answer'); + expect(output).toContain('- Correct Answer: True'); + }); +}); + diff --git a/routes/create/__tests__/unit/pdfExportService.test.js b/routes/create/__tests__/unit/pdfExportService.test.js index aec4345..7763509 100644 --- a/routes/create/__tests__/unit/pdfExportService.test.js +++ b/routes/create/__tests__/unit/pdfExportService.test.js @@ -160,6 +160,48 @@ describe('pdfExportService', () => { expect(texts.some(t => t.includes('B. Right'))).toBe(true); }); + test('multiple-choice: includes option feedback and explanation', () => { + const question = { + type: 'multiple-choice', + explanation: 'Because this aligns with the learning objective.', + content: { + options: [ + { text: 'Wrong', isCorrect: false, chosenFeedback: 'This reflects a common misconception.' }, + { text: 'Right', isCorrect: true, notChosenFeedback: 'You should have selected this option.' } + ] + } + }; + + addAnswerContent(doc, question); + const texts = getTextCalls(doc); + + expect(texts).toContain('Option Feedback:'); + expect(texts.some(t => t.includes('A. If selected: This reflects a common misconception.'))).toBe(true); + expect(texts.some(t => t.includes('B. If not selected: You should have selected this option.'))).toBe(true); + expect(texts).toContain('Explanation:'); + expect(texts).toContain('Because this aligns with the learning objective.'); + }); + + test('multiple-choice: supports multiple correct answers', () => { + const question = { + type: 'multiple-choice', + content: { + selectionMode: 'multiple', + options: [ + { text: 'Alpha', isCorrect: true }, + { text: 'Beta', isCorrect: false }, + { text: 'Gamma', isCorrect: true } + ] + } + }; + + addAnswerContent(doc, question); + const texts = getTextCalls(doc); + + expect(texts.some(t => t.includes('A. Alpha'))).toBe(true); + expect(texts.some(t => t.includes('C. Gamma'))).toBe(true); + }); + test('multiple-choice: falls back to correctAnswer when no isCorrect', () => { const question = { type: 'multiple-choice', diff --git a/routes/create/controllers/exportController.js b/routes/create/controllers/exportController.js index e0b40d9..0ded9f3 100644 --- a/routes/create/controllers/exportController.js +++ b/routes/create/controllers/exportController.js @@ -11,6 +11,7 @@ import crypto from 'crypto'; import { createH5PPackage } from '../services/h5pExportService.js'; import { createPDFExport } from '../services/pdfExportService.js'; +import { createMarkdownExport } from '../services/markdownExportService.js'; import { getQuestionTypeBreakdown, getDifficultyDistribution, estimateExportSize } from '../services/exportUtils.js'; const router = express.Router(); @@ -124,6 +125,58 @@ router.post('/pdf/:quizId', authenticateToken, validateQuizId, asyncHandler(asyn } })); +/** + * POST /api/export/markdown/:quizId + * Generate Markdown export + */ +router.post('/markdown/:quizId', authenticateToken, validateQuizId, asyncHandler(async (req, res) => { + const { type } = req.body; + + if (!['questions', 'answers', 'combined'].includes(type)) { + return errorResponse(res, 'Invalid export type. Must be "questions", "answers", or "combined"', 'INVALID_TYPE', HTTP_STATUS.BAD_REQUEST); + } + + const quiz = await loadQuizForExport(req.params.quizId, req.user.id); + if (!quiz) return notFoundResponse(res, 'Quiz'); + if (!quiz.questions || quiz.questions.length === 0) { + return errorResponse(res, 'Quiz must have questions before exporting', 'NO_QUESTIONS', HTTP_STATUS.BAD_REQUEST); + } + + try { + const exportId = crypto.randomBytes(16).toString('hex'); + const typeLabel = type.charAt(0).toUpperCase() + type.slice(1); + const filename = `${quiz.name.replace(/[^a-zA-Z0-9]/g, '_')}_${typeLabel}_${exportId}.md`; + const uploadsDir = path.join('./routes/create/uploads/'); + const filePath = path.join(uploadsDir, filename); + + await fs.mkdir(uploadsDir, { recursive: true }); + await createMarkdownExport(quiz, filePath, type); + + if (quiz.addExport) { + await quiz.addExport(filePath); + } else { + console.log(`Markdown export created for quiz ${quiz._id}: ${filePath}`); + } + + const stats = await fs.stat(filePath); + + return successResponse(res, { + exportId, + filename, + downloadUrl: `/api/export/${exportId}/download`, + metadata: { + questionCount: quiz.questions.length, + exportFormat: 'markdown', + exportType: type, + fileSize: stats.size + } + }, 'Markdown export generated successfully', HTTP_STATUS.CREATED); + } catch (error) { + console.error('Markdown export error:', error); + return errorResponse(res, 'Failed to generate Markdown export', 'EXPORT_ERROR', HTTP_STATUS.SERVICE_UNAVAILABLE); + } +})); + /** * GET /api/export/:exportId/download * Download exported file @@ -150,7 +203,11 @@ router.get('/:exportId/download', authenticateToken, asyncHandler(async (req, re const filename = path.basename(exportRecord.filePath); const fileExtension = path.extname(filename).toLowerCase(); - const contentType = fileExtension === '.pdf' ? 'application/pdf' : 'application/octet-stream'; + const contentType = fileExtension === '.pdf' + ? 'application/pdf' + : fileExtension === '.md' + ? 'text/markdown; charset=utf-8' + : 'application/octet-stream'; res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Type', contentType); @@ -255,6 +312,14 @@ router.get('/:quizId/formats', authenticateToken, validateQuizId, asyncHandler(a supported: true, fileExtension: '.json', features: ['Developer friendly', 'Easy parsing', 'Custom integration'] + }, + { + name: 'Markdown', + id: 'markdown', + description: 'Readable text export for editing, sharing, or version control', + supported: true, + fileExtension: '.md', + features: ['Human readable', 'Easy to edit', 'Works in docs and Git workflows'] } ]; diff --git a/routes/create/controllers/streamingController.js b/routes/create/controllers/streamingController.js index 7476270..7621ab9 100644 --- a/routes/create/controllers/streamingController.js +++ b/routes/create/controllers/streamingController.js @@ -7,6 +7,7 @@ import express from 'express'; import { authenticateToken } from '../middleware/auth.js'; import { asyncHandler } from '../utils/asyncHandler.js'; import sseService from '../services/sseService.js'; +import { planLOSlices } from '../services/loSlicePlanner.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); @@ -41,6 +42,74 @@ function enrichQuestionConfigsWithObjectives(questionConfigs, quiz) { }); } +function getLearningObjectiveText(config) { + if (typeof config.learningObjective === 'string') { + return config.learningObjective; + } + + return config.learningObjective?.text || ''; +} + +function buildPlanningGroupKey(config) { + const loText = getLearningObjectiveText(config); + return [ + config.learningObjective?._id?.toString?.() || config.learningObjectiveId || loText, + config.questionType, + config.selectionMode || 'single', + config.customPrompt || '' + ].join('::'); +} + +/** + * Assign a distinct LO slice to each repeated config before parallel generation. + * This prevents parallel LLM calls from independently choosing the same narrow focus. + */ +function attachPlannedTasksToQuestionConfigs(questionConfigs) { + const groups = new Map(); + + questionConfigs.forEach((config, index) => { + const loText = getLearningObjectiveText(config); + if (!loText || !config.questionType) { + return; + } + + const groupKey = buildPlanningGroupKey(config); + if (!groups.has(groupKey)) { + groups.set(groupKey, []); + } + groups.get(groupKey).push({ config, index, loText }); + }); + + const plannedConfigs = questionConfigs.map(config => ({ ...config })); + + for (const group of groups.values()) { + if (group.length < 2) { + continue; + } + + const slicePlan = planLOSlices({ + learningObjective: group[0].loText, + questionType: group[0].config.questionType, + requestedCount: group.length + }); + + console.log( + `🧩 Streaming slice plan for ${group[0].config.questionType}:`, + slicePlan.recommendedTasks.map(task => task.sliceLabel).join(' | ') + ); + + group.forEach(({ index }, taskIndex) => { + plannedConfigs[index] = { + ...plannedConfigs[index], + plannedTask: slicePlan.recommendedTasks[taskIndex] || null, + slicePlanBreadth: slicePlan.breadth + }; + }); + } + + return plannedConfigs; +} + // ============================================================ // Production endpoints // ============================================================ @@ -90,7 +159,9 @@ router.post('/generate-questions', authenticateToken, asyncHandler(async (req, r return res.status(404).json({ error: 'Quiz not found' }); } - const enrichedConfigs = enrichQuestionConfigsWithObjectives(questionConfigs, quiz); + const enrichedConfigs = attachPlannedTasksToQuestionConfigs( + enrichQuestionConfigsWithObjectives(questionConfigs, quiz) + ); // Get processed material IDs for RAG retrieval const processedMaterialIds = (quiz.materials || []) diff --git a/routes/create/h5p-libs/H5P.SingleChoiceSet-1.11/scripts/solution-view.js b/routes/create/h5p-libs/H5P.SingleChoiceSet-1.11/scripts/solution-view.js index 744105f..8c1bc8d 100644 --- a/routes/create/h5p-libs/H5P.SingleChoiceSet-1.11/scripts/solution-view.js +++ b/routes/create/h5p-libs/H5P.SingleChoiceSet-1.11/scripts/solution-view.js @@ -42,6 +42,9 @@ H5P.SingleChoiceSet.SolutionView = (function ($, EventDispatcher) { self.populate(); } + SolutionView.prototype = Object.create(EventDispatcher.prototype); + SolutionView.prototype.constructor = SolutionView; + /** * Will append the solution view to a container DOM * @param {jQuery} $container The DOM object to append to diff --git a/routes/create/models/Question.js b/routes/create/models/Question.js index 3ae48c8..149d5f5 100644 --- a/routes/create/models/Question.js +++ b/routes/create/models/Question.js @@ -53,8 +53,16 @@ const questionSchema = new mongoose.Schema({ options: [{ text: { type: String, required: true }, isCorrect: { type: Boolean, default: false }, - order: { type: Number } + order: { type: Number }, + tip: { type: String, default: '' }, + chosenFeedback: { type: String, default: '' }, + notChosenFeedback: { type: String, default: '' } }], + selectionMode: { + type: String, + enum: ['single', 'multiple'], + default: 'single' + }, // For Flashcard front: { type: String }, // Question side @@ -319,4 +327,4 @@ questionSchema.set('toJSON', { } }); -export default mongoose.model('Question', questionSchema); \ No newline at end of file +export default mongoose.model('Question', questionSchema); diff --git a/routes/create/services/h5pExportService.js b/routes/create/services/h5pExportService.js index 861283c..0e8d8c7 100644 --- a/routes/create/services/h5pExportService.js +++ b/routes/create/services/h5pExportService.js @@ -11,6 +11,27 @@ import { createWriteStream } from 'fs'; import LIBRARY_REGISTRY, { getNeededLibraries } from '../config/h5pLibraryRegistry.js'; import { escapeHtml, generateAvailableOptionsText } from './exportUtils.js'; +function getMultipleChoiceSelectionMode(question) { + if (question.content?.selectionMode === 'multiple') { + return 'multiple'; + } + + const correctCount = (question.content?.options || []).filter(option => option.isCorrect).length; + return correctCount > 1 ? 'multiple' : 'single'; +} + +function mapMultipleChoiceAnswers(options = []) { + return options.map(option => ({ + correct: option.isCorrect || false, + text: `
${escapeHtml(option.text)}
`, + tipsAndFeedback: { + tip: option.tip ? `

${escapeHtml(option.tip)}

` : '', + chosenFeedback: option.chosenFeedback ? `
${escapeHtml(option.chosenFeedback)}
` : '', + notChosenFeedback: option.notChosenFeedback ? `
${escapeHtml(option.notChosenFeedback)}
` : '' + } + })); +} + /** * Create an H5P ZIP package from a quiz. * @param {Object} quiz - Populated quiz document @@ -440,20 +461,17 @@ export function generateH5PQuestionSet(questions) { let h5pQuestion; if (question.type === 'multiple-choice') { + const selectionMode = getMultipleChoiceSelectionMode(question); h5pQuestion = { "params": { "media": { "disableImageZooming": false }, - "answers": question.content?.options?.map(option => ({ - "correct": option.isCorrect || false, - "text": `
${escapeHtml(option.text)}
`, - "tipsAndFeedback": {} - })) || [], + "answers": mapMultipleChoiceAnswers(question.content?.options || []), "overallFeedback": [{ "from": 0, "to": 100 }], "behaviour": { "enableRetry": true, "enableSolutionsButton": true, "enableCheckButton": true, - "type": "auto", + "type": selectionMode === 'multiple' ? 'multi' : 'single', "singlePoint": false, "randomAnswers": true, "showSolutionsRequiresInput": true, @@ -818,24 +836,20 @@ export function generateH5PDialogCards(flashcardQuestions) { */ export function convertQuestionToH5P(question, quiz) { if (question.type === 'multiple-choice') { + const selectionMode = getMultipleChoiceSelectionMode(question); return { "library": "H5P.MultiChoice 1.16", "params": { "question": `

${escapeHtml(question.questionText)}

`, - "answers": question.content?.options?.map(option => ({ - "correct": option.isCorrect || false, - "text": `
${escapeHtml(option.text)}
\n`, - "tipsAndFeedback": { - "tip": "", - "chosenFeedback": option.isCorrect ? "Correct!" : "Try again", - "notChosenFeedback": "" - } - })) || [], + "answers": mapMultipleChoiceAnswers(question.content?.options || []).map(answer => ({ + ...answer, + text: `${answer.text}\n` + })), "behaviour": { "enableRetry": true, "enableSolutionsButton": true, "enableCheckButton": true, - "type": "auto", + "type": selectionMode === 'multiple' ? 'multi' : 'single', "singlePoint": true, "randomAnswers": false, "showSolutionsRequiresInput": true, @@ -1246,7 +1260,7 @@ export function convertQuestionToH5P(question, quiz) { "choices": choices, "overallFeedback": [{ "from": 0, "to": 100 }], "behaviour": { - "autoContinue": true, + "autoContinue": false, "timeoutCorrect": 2000, "timeoutWrong": 3000, "soundEffectsEnabled": true, diff --git a/routes/create/services/llmService.js b/routes/create/services/llmService.js index 5a30d81..a194ffa 100644 --- a/routes/create/services/llmService.js +++ b/routes/create/services/llmService.js @@ -6,6 +6,7 @@ import { LLMModule } from 'ubc-genai-toolkit-llm'; import { ConsoleLogger } from 'ubc-genai-toolkit-core'; import { generateTemplateQuestion } from './templateQuestionGenerator.js'; +import { planLOSlices } from './loSlicePlanner.js'; import { QUESTION_TYPES } from '../config/constants.js'; import UserApiKey from '../models/UserApiKey.js'; import User from '../models/User.js'; @@ -166,6 +167,7 @@ class QuizLLMService { previousQuestions = [], customPrompt = null, userId = null, + selectionMode = 'single', branchingLayers = 2, branchingChoices = 2 } = questionConfig; @@ -192,6 +194,7 @@ class QuizLLMService { courseContext, previousQuestions, customPrompt, + selectionMode, branchingLayers, branchingChoices ); @@ -368,7 +371,7 @@ class QuizLLMService { } // Parse and validate response - const questionData = this.parseAndValidateResponse(finalContent, questionType); + const questionData = this.parseAndValidateResponse(finalContent, questionType, selectionMode); // Log generated Summary questions for debugging if (questionType === 'summary') { @@ -433,6 +436,7 @@ class QuizLLMService { previousQuestions = [], customPrompt = null, userId = null, + selectionMode = 'single', branchingLayers = 2, branchingChoices = 2 } = questionConfig; @@ -459,6 +463,7 @@ class QuizLLMService { courseContext, previousQuestions, customPrompt, + selectionMode, branchingLayers, branchingChoices ); @@ -478,7 +483,7 @@ class QuizLLMService { console.log(`🔍 Raw LLM response:`, response.content.substring(0, 500) + '...'); // Parse and validate response - const questionData = this.parseAndValidateResponse(response.content, questionType); + const questionData = this.parseAndValidateResponse(response.content, questionType, selectionMode); // Log generated Summary questions for debugging if (questionType === 'summary') { @@ -529,7 +534,7 @@ class QuizLLMService { /** * Build expert-level prompt for question generation */ - async buildExpertPrompt(learningObjective, questionType, relevantContent, difficulty, courseContext, previousQuestions, customPrompt, branchingLayers = 2, branchingChoices = 2) { + async buildExpertPrompt(learningObjective, questionType, relevantContent, difficulty, courseContext, previousQuestions, customPrompt, selectionMode = 'single', branchingLayers = 2, branchingChoices = 2) { // Handle different relevantContent formats const contentArray = Array.isArray(relevantContent) ? relevantContent @@ -539,8 +544,13 @@ class QuizLLMService { .map((chunk, index) => `[Content ${index + 1}] (from ${chunk.metadata?.source || chunk.source || 'unknown'}, relevance: ${chunk.score?.toFixed(2) || 'N/A'})\n${chunk.content}`) .join('\n\n'); - const previousQuestionsText = previousQuestions.length > 0 - ? `\n\nPREVIOUS QUESTIONS TO AVOID DUPLICATION:\n${previousQuestions.map((q, i) => `${i + 1}. ${q.questionText}`).join('\n')}` + const previousQuestionsText = previousQuestions.length > 0 + ? `\n\nPREVIOUS QUESTIONS TO AVOID DUPLICATION:\n${previousQuestions.map((q, i) => { + const questionStem = q.questionText || 'Unknown question text'; + const questionTypeLabel = q.type || 'unknown'; + const explanation = q.explanation ? ` | Explanation: ${q.explanation}` : ''; + return `${i + 1}. [${questionTypeLabel}] ${questionStem}${explanation}`; + }).join('\n')}` : ''; // Delegate to type-specific builders for complex standalone types @@ -579,14 +589,31 @@ INSTRUCTIONS:${customPrompt && learningObjective ? '\n⚠️ ADDITIONAL USER REQ 3. Ensure the question tests meaningful understanding, not just memorization 4. Make the question engaging and relevant to real-world applications 5. Follow educational best practices for ${questionType} questions -6. Avoid duplicating previous questions - create unique content and scenarios +6. Avoid duplicating previous questions - create unique content, scenarios, concept focus, and reasoning patterns +7. If the learning objective contains multiple components, select ONE clear assessable slice unless synthesis is explicitly necessary +8. Prefer a narrow and concrete target over a broad catch-all question +9. Use the course materials to ground the chosen slice in specific ideas, examples, terminology, or evidence + +NOVELTY AND COVERAGE REQUIREMENTS: +- First identify the specific sub-skill, sub-topic, misconception, comparison, process step, or application case that this question will assess +- Do NOT reuse the same assessed slice, scenario, comparison, or conceptual contrast used by previous questions +- Rewording a previous question is NOT enough; the assessed thinking task must be meaningfully different +- When the learning objective is broad, distribute coverage by targeting a different slice than previous questions +- Avoid broad survey-style stems if a more focused question can assess the objective better + +MULTIPLE-CHOICE QUALITY REQUIREMENTS: +- The correct answer must be clearly best according to the materials and learning objective +- Each distractor must reflect a different plausible misunderstanding, partial truth, or reasoning error +- Do not create distractors that are trivially wrong, redundant with each other, or only differ cosmetically +- Prefer distractors that help an instructor diagnose what a student misunderstood +- Respect the requested answer mode for multiple-choice questions: ${selectionMode === 'multiple' ? 'multiple answers allowed; at least two options must be correct' : 'single answer only; exactly one option must be correct'} ${customPrompt && learningObjective ? 'IMPORTANT: The additional user requirements above are MANDATORY and must be implemented exactly as specified.' : ''} RESPONSE FORMAT: Return ONLY a valid JSON object with this exact structure (all strings must be on single lines - no line breaks within strings):`; - const formatInstructions = this.getFormatInstructions(questionType); + const formatInstructions = this.getFormatInstructions(questionType, selectionMode); return basePrompt + '\n' + formatInstructions; } @@ -594,19 +621,48 @@ Return ONLY a valid JSON object with this exact structure (all strings must be o /** * Get format instructions for each question type */ - getFormatInstructions(questionType) { + getFormatInstructions(questionType, selectionMode = 'single') { const formats = { - 'multiple-choice': `{ - "questionText": "Your question here (should be clear, specific, and test understanding)", + 'multiple-choice': selectionMode === 'multiple' ? `{ + "questionText": "Your question here (make it clear learners should select all that apply when more than one answer is correct)", "options": [ - {"text": "Correct answer (substantive and detailed)", "isCorrect": true}, - {"text": "Plausible distractor 1 (common misconception)", "isCorrect": false}, - {"text": "Plausible distractor 2 (partially correct but incomplete)", "isCorrect": false}, - {"text": "Plausible distractor 3 (logical but incorrect)", "isCorrect": false} + {"text": "Correct answer 1 (substantive and detailed)", "isCorrect": true, "tip": "Short hint before checking", "chosenFeedback": "Why selecting this option is appropriate", "notChosenFeedback": "Why this correct option matters if missed"}, + {"text": "Correct answer 2 (also genuinely correct and non-overlapping)", "isCorrect": true, "tip": "Short hint before checking", "chosenFeedback": "Why selecting this option is appropriate", "notChosenFeedback": "Why this correct option matters if missed"}, + {"text": "Plausible distractor 1 (misconception type 1)", "isCorrect": false, "tip": "Short hint before checking", "chosenFeedback": "Why this selected option is not correct", "notChosenFeedback": "Why skipping this distractor was a good choice"}, + {"text": "Plausible distractor 2 (reasoning error type 2)", "isCorrect": false, "tip": "Short hint before checking", "chosenFeedback": "Why this selected option is not correct", "notChosenFeedback": "Why skipping this distractor was a good choice"} + ], + "correctAnswer": ["Correct answer 1 (substantive and detailed)", "Correct answer 2 (also genuinely correct and non-overlapping)"], + "explanation": "Detailed explanation of why each correct option is right and why each distractor is wrong, referencing course materials" +} + +IMPORTANT REQUIREMENTS FOR MULTIPLE-ANSWER QUESTIONS: +1. The stem must clearly signal that more than one answer can be selected. +2. You MUST mark at least 2 options as correct. +3. Each correct option should contribute a distinct valid idea, not a paraphrase of another correct option. +4. Each distractor must represent a DIFFERENT plausible error pattern. +5. The explanation must briefly clarify each correct choice and each distractor. +6. Every option MUST include tip, chosenFeedback, and notChosenFeedback fields. +7. Keep tip concise and actionable. Keep feedback specific to that option.` : `{ + "questionText": "Your question here (should be clear, specific, narrow in scope when appropriate, and test understanding)", + "options": [ + {"text": "Correct answer (substantive and detailed)", "isCorrect": true, "tip": "Short hint before checking", "chosenFeedback": "Why selecting this option is appropriate", "notChosenFeedback": "Why this correct option matters if missed"}, + {"text": "Plausible distractor 1 (misconception type 1)", "isCorrect": false, "tip": "Short hint before checking", "chosenFeedback": "Why this selected option is not correct", "notChosenFeedback": "Why skipping this distractor was a good choice"}, + {"text": "Plausible distractor 2 (partial-truth error type 2)", "isCorrect": false, "tip": "Short hint before checking", "chosenFeedback": "Why this selected option is not correct", "notChosenFeedback": "Why skipping this distractor was a good choice"}, + {"text": "Plausible distractor 3 (reasoning error type 3)", "isCorrect": false, "tip": "Short hint before checking", "chosenFeedback": "Why this selected option is not correct", "notChosenFeedback": "Why skipping this distractor was a good choice"} ], "correctAnswer": "Correct answer text (exact match)", "explanation": "Detailed explanation of why the correct answer is right and why distractors are wrong, referencing course materials" -}`, +} + +IMPORTANT REQUIREMENTS FOR MULTIPLE-CHOICE QUESTIONS: +1. If the learning objective is broad, target one specific assessable slice instead of trying to assess everything at once. +2. Do not repeat the same concept focus or scenario used in previous questions. +3. Each distractor must represent a DIFFERENT plausible error pattern. +4. Avoid distractors that are synonyms of each other or obviously wrong on sight. +5. The explanation must briefly clarify why each distractor is wrong, not only why the correct answer is right. +6. You MUST mark exactly 1 option as correct. +7. Every option MUST include tip, chosenFeedback, and notChosenFeedback fields. +8. Keep tip concise and actionable. Keep feedback specific to that option.`, 'true-false': `{ "questionText": "Your true/false statement here (should be clear and test nuanced understanding)", @@ -616,7 +672,12 @@ Return ONLY a valid JSON object with this exact structure (all strings must be o ], "correctAnswer": "True", "explanation": "Detailed explanation of why the statement is true/false, with specific references to course concepts" -}`, +} + +IMPORTANT REQUIREMENTS FOR TRUE/FALSE QUESTIONS: +1. Prefer a narrow claim that tests one meaningful idea. +2. Avoid statements that are so broad they collapse multiple concepts into one judgment. +3. Do not restate the same conceptual claim used in previous questions.`, 'flashcard': `{ "questionText": "Review this concept", @@ -731,7 +792,7 @@ IMPORTANT: Wrap correct words with asterisks (*word*) in the text field. Only th "explanation": "The mitochondria produce ATP (energy), and the nucleus contains the cell's DNA." } -IMPORTANT: Generate 2-4 sub-questions. The FIRST answer in each answers array is ALWAYS the correct one. Answers must be plain strings (NOT objects). Include 3-4 answer options per question.`, +IMPORTANT: Generate 2-4 sub-questions. The FIRST answer in each answers array is ALWAYS the correct one. Answers must be plain strings (NOT objects). Include 3-4 answer options per question. Make the sub-questions cover different slices of the learning objective instead of repeating the same fact pattern.`, 'essay': `{ "questionText": "Write an essay about the topic below", @@ -893,10 +954,29 @@ NOTE: Branching scenarios are complex container types. Generate a simple placeho return result; } + extractCorrectAnswerCandidates(correctAnswer, allowCommaSeparatedList = false) { + if (Array.isArray(correctAnswer)) { + return correctAnswer.map(answer => String(answer).trim()).filter(Boolean); + } + + if (typeof correctAnswer === 'string') { + if (allowCommaSeparatedList) { + return correctAnswer + .split(',') + .map(answer => answer.trim()) + .filter(Boolean); + } + + return [correctAnswer.trim()].filter(Boolean); + } + + return []; + } + /** * Parse and validate LLM response */ - parseAndValidateResponse(responseContent, questionType) { + parseAndValidateResponse(responseContent, questionType, selectionMode = 'single') { try { console.log(`🔍 Parsing LLM response for ${questionType}:`, responseContent.substring(0, 200) + '...'); @@ -1130,29 +1210,53 @@ NOTE: Branching scenarios are complex container types. Generate a simple placeho if (option.isCorrect === undefined || option.isCorrect === null) { option.isCorrect = false; } + + option.tip = typeof option.tip === 'string' ? option.tip.trim() : ''; + option.chosenFeedback = typeof option.chosenFeedback === 'string' ? option.chosenFeedback.trim() : ''; + option.notChosenFeedback = typeof option.notChosenFeedback === 'string' ? option.notChosenFeedback.trim() : ''; }); - // Check if exactly one option is marked correct + const resolvedSelectionMode = questionType === QUESTION_TYPES.TRUE_FALSE + ? 'single' + : (selectionMode === 'multiple' ? 'multiple' : 'single'); + + parsed.content.selectionMode = resolvedSelectionMode; + const correctOptions = parsed.content.options.filter(opt => opt.isCorrect === true); - // If no options are marked correct, try to find the correct one from correctAnswer + // If no options are marked correct, try to find them from correctAnswer if (correctOptions.length === 0 && parsed.correctAnswer) { - const correctAnswerText = parsed.correctAnswer.toLowerCase().trim(); - const matchingOption = parsed.content.options.find(opt => - opt.text.toLowerCase().trim().includes(correctAnswerText) || - correctAnswerText.includes(opt.text.toLowerCase().trim()) - ); - - if (matchingOption) { - matchingOption.isCorrect = true; - console.log(`🔧 Auto-corrected missing isCorrect flag for option: "${matchingOption.text}"`); - } + const correctAnswerCandidates = this.extractCorrectAnswerCandidates( + parsed.correctAnswer, + resolvedSelectionMode === 'multiple' + ) + .map(answer => answer.toLowerCase()); + + parsed.content.options.forEach(option => { + const normalizedOptionText = option.text.toLowerCase().trim(); + const matches = correctAnswerCandidates.some(candidate => + normalizedOptionText.includes(candidate) || candidate.includes(normalizedOptionText) + ); + + if (matches) { + option.isCorrect = true; + console.log(`🔧 Auto-corrected missing isCorrect flag for option: "${option.text}"`); + } + }); } // Final validation const finalCorrectOptions = parsed.content.options.filter(opt => opt.isCorrect === true); - if (finalCorrectOptions.length !== 1) { - throw new Error(`Exactly one option must be marked as correct, found ${finalCorrectOptions.length}`); + if (resolvedSelectionMode === 'multiple') { + if (finalCorrectOptions.length < 2) { + throw new Error(`Multiple-answer question must have at least 2 correct options, found ${finalCorrectOptions.length}`); + } + parsed.correctAnswer = finalCorrectOptions.map(option => option.text); + } else { + if (finalCorrectOptions.length !== 1) { + throw new Error(`Exactly one option must be marked as correct, found ${finalCorrectOptions.length}`); + } + parsed.correctAnswer = finalCorrectOptions[0].text; } console.log(`✅ Multiple-choice/True-false question validated: ${parsed.content.options.length} options`); @@ -1368,6 +1472,105 @@ NOTE: Branching scenarios are complex container types. Generate a simple placeho return Math.min(score, 1.0); } + normalizeTextForValidation(text = '') { + return String(text).toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim(); + } + + extractValidationKeywords(plannedTask) { + if (!plannedTask?.sliceLabel) return []; + + const base = plannedTask.sliceLabel + .replace(/^Compare\s+/i, '') + .replace(/^Distinguish\s+/i, ''); + + const normalized = this.normalizeTextForValidation(base); + const tokens = normalized.split(' ').filter(Boolean); + const keywords = []; + + const preferredTerms = [ + 'co2', + 'ch4', + 'n2o', + 'carbon dioxide', + 'methane', + 'nitrous oxide', + 'anthropogenic', + 'natural', + 'sources', + 'source' + ]; + + for (const term of preferredTerms) { + if (normalized.includes(term)) { + keywords.push(term); + } + } + + if (keywords.length === 0) { + return tokens.slice(0, 4); + } + + return [...new Set(keywords)]; + } + + questionMatchesPlannedTask(questionData, plannedTask) { + if (!plannedTask?.sliceLabel || !questionData) { + return { valid: true, reason: 'No planned task provided' }; + } + + const searchableText = this.normalizeTextForValidation([ + questionData.questionText, + questionData.explanation, + questionData.correctAnswer + ].filter(Boolean).join(' ')); + + const keywords = this.extractValidationKeywords(plannedTask); + const matchedKeywords = keywords.filter(keyword => searchableText.includes(this.normalizeTextForValidation(keyword))); + + if (plannedTask.sliceKind === 'component') { + const requiredComponentTerms = keywords.filter(keyword => + ['co2', 'ch4', 'n2o', 'carbon dioxide', 'methane', 'nitrous oxide'].includes(keyword) + ); + const componentMatched = requiredComponentTerms.filter(keyword => + searchableText.includes(this.normalizeTextForValidation(keyword)) + ); + + if (requiredComponentTerms.length > 0 && componentMatched.length === 0) { + return { + valid: false, + reason: `Generated question did not stay on planned component slice "${plannedTask.sliceLabel}"` + }; + } + } + + if (plannedTask.sliceKind === 'comparison') { + const componentTerms = keywords.filter(keyword => + ['co2', 'ch4', 'n2o', 'carbon dioxide', 'methane', 'nitrous oxide'].includes(keyword) + ); + const uniqueMatches = componentTerms.filter(keyword => + searchableText.includes(this.normalizeTextForValidation(keyword)) + ); + + if (uniqueMatches.length < 2) { + return { + valid: false, + reason: `Comparison slice "${plannedTask.sliceLabel}" was not reflected in the generated question` + }; + } + } + + if (plannedTask.sliceKind === 'misconception') { + if (matchedKeywords.length === 0) { + return { + valid: false, + reason: `Misconception slice "${plannedTask.sliceLabel}" was not reflected in the generated question` + }; + } + } + + return { valid: true, reason: 'Question matches planned slice' }; + } + /** * Generate multiple questions in batch */ @@ -1388,46 +1591,77 @@ NOTE: Branching scenarios are complex container types. Generate a simple placeho const provider = process.env.LLM_PROVIDER || 'ollama'; for (const config of questionConfigs) { + const slicePlan = planLOSlices({ + learningObjective, + questionType: config.questionType, + requestedCount: config.count + }); + + console.log(`🧩 Slice plan for ${config.questionType}:`, slicePlan.recommendedTasks.map(task => task.sliceLabel).join(' | ')); + for (let i = 0; i < config.count; i++) { try { - let result; - - // Use streaming for Ollama only - if (provider === 'ollama' && onStreamChunk) { - console.log(`🌊 Using streaming generation for ${config.questionType} (Ollama)`); - result = await this.generateQuestionStreaming({ - learningObjective, - questionType: config.questionType, - relevantContent, - difficulty, - courseContext, - previousQuestions: questions - }, (chunk, metadata) => { - // Forward streaming chunks to the callback - onStreamChunk({ + const plannedTask = slicePlan.recommendedTasks[i]; + let result = null; + let finalValidation = { valid: true, reason: 'Not validated' }; + const maxAttempts = plannedTask ? 3 : 1; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const taskPrompt = plannedTask + ? `The slice has already been selected for you. You MUST generate a question only about "${plannedTask.sliceLabel}" using the intent "${plannedTask.questionIntent}". You are NOT allowed to switch to another gas, another concept slice, or a broad all-in-one version of the learning objective. If the planned slice is a component such as CO2, CH4, or N2O, the question must stay on that component. If the planned slice is a comparison, the question must explicitly compare the named components. If the planned slice is a misconception task, the question must explicitly test that misconception target. Do not assess a slice already used by previous questions if other planned slices remain available.${attempt > 1 ? ` This is retry attempt ${attempt} because a previous attempt drifted away from the assigned slice.` : ''}` + : null; + + // Use streaming for Ollama only + if (provider === 'ollama' && onStreamChunk) { + console.log(`🌊 Using streaming generation for ${config.questionType} (Ollama), attempt ${attempt}`); + result = await this.generateQuestionStreaming({ + learningObjective, questionType: config.questionType, - questionIndex: i + 1, - chunk, - metadata + relevantContent, + difficulty, + courseContext, + previousQuestions: questions, + customPrompt: taskPrompt + }, (chunk, metadata) => { + onStreamChunk({ + questionType: config.questionType, + questionIndex: i + 1, + chunk, + metadata + }); }); - }); - } else { - // Use non-streaming for OpenAI or when no callback provided - result = await this.generateQuestion({ - learningObjective, - questionType: config.questionType, - relevantContent, - difficulty, - courseContext, - previousQuestions: questions - }); + } else { + result = await this.generateQuestion({ + learningObjective, + questionType: config.questionType, + relevantContent, + difficulty, + courseContext, + previousQuestions: questions, + customPrompt: taskPrompt + }); + } + + if (!result?.success) { + continue; + } + + finalValidation = this.questionMatchesPlannedTask(result.questionData, plannedTask); + if (finalValidation.valid) { + break; + } + + console.warn(`⚠️ Generated question drifted from planned slice on attempt ${attempt}: ${finalValidation.reason}`); + result = null; } - if (result.success) { + if (result?.success) { const questionWithMetadata = { ...result.questionData, type: config.questionType, - order: questions.length + order: questions.length, + plannedSlice: plannedTask?.sliceLabel || null, + plannedIntent: plannedTask?.questionIntent || null }; // Additional logging for Summary questions in batch @@ -1437,6 +1671,8 @@ NOTE: Branching scenarios are complex container types. Generate a simple placeho } questions.push(questionWithMetadata); + } else if (plannedTask && !finalValidation.valid) { + throw new Error(`Failed to generate a question that matches the planned slice "${plannedTask.sliceLabel}": ${finalValidation.reason}`); } // Small delay to avoid overwhelming the LLM @@ -1955,4 +2191,4 @@ Provide only the improved learning objective as your response (no additional tex // Export singleton instance const llmService = new QuizLLMService(); -export default llmService; \ No newline at end of file +export default llmService; diff --git a/routes/create/services/loSlicePlanner.js b/routes/create/services/loSlicePlanner.js new file mode 100644 index 0000000..de8397d --- /dev/null +++ b/routes/create/services/loSlicePlanner.js @@ -0,0 +1,200 @@ +const COMPARISON_INTENTS = ['compare', 'distinguish', 'contrast']; + +function normalizeWhitespace(text = '') { + return text.replace(/\s+/g, ' ').trim(); +} + +function toId(text = '') { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} + +function splitEnumeratedItems(text = '') { + const normalized = normalizeWhitespace(text) + .replace(/\s+(and|or)\s+/gi, ', ') + .replace(/,\s*,/g, ', '); + + return normalized + .split(',') + .map(item => normalizeWhitespace(item)) + .filter(Boolean); +} + +function extractTailAfterKeyword(learningObjective) { + const normalized = normalizeWhitespace(learningObjective); + const patterns = [ + /\b(?:sources?|structure and function|functions? and structure|types?|forms?|roles?|components?|causes?|effects?|steps?|stages?|features?|characteristics?)\s+of\s+(.+?)(?:[.;]|$)/i, + /\b(?:between|among|including)\s+(.+?)(?:[.;]|$)/i + ]; + + for (const pattern of patterns) { + const match = normalized.match(pattern); + if (match?.[1]) { + return match[1]; + } + } + + return ''; +} + +function detectBreadth(learningObjective, items) { + const normalized = normalizeWhitespace(learningObjective).toLowerCase(); + const hasEnumeration = items.length >= 2; + const hasListPunctuation = /,/.test(normalized) && /\band\b|\bor\b/.test(normalized); + const hasComparisonVerb = COMPARISON_INTENTS.some(intent => normalized.includes(intent)); + + if (hasEnumeration || hasListPunctuation || hasComparisonVerb) { + return 'broad'; + } + + return 'narrow'; +} + +function buildComponentSlices(learningObjective, items) { + const normalized = normalizeWhitespace(learningObjective); + const lower = normalized.toLowerCase(); + const sourceContext = lower.includes('source') ? 'anthropogenic sources' : ''; + const structureFunctionContext = lower.includes('structure and function') + ? 'structure and function' + : ''; + + return items.map(item => { + let label = item; + + if (sourceContext) { + label = `${item} ${sourceContext}`; + } else if (structureFunctionContext) { + label = `${item} ${structureFunctionContext}`; + } + + return { + id: toId(label), + label, + kind: 'component' + }; + }); +} + +function buildComparisonSlices(componentSlices) { + const comparisons = []; + + for (let i = 0; i < componentSlices.length; i++) { + for (let j = i + 1; j < componentSlices.length; j++) { + comparisons.push({ + id: toId(`${componentSlices[i].id}_${componentSlices[j].id}_comparison`), + label: `Compare ${componentSlices[i].label} and ${componentSlices[j].label}`, + kind: 'comparison' + }); + } + } + + return comparisons; +} + +function buildMisconceptionSlices(componentSlices, learningObjective) { + const lower = normalizeWhitespace(learningObjective).toLowerCase(); + const slices = []; + + if (lower.includes('source')) { + slices.push({ + id: 'anthropogenic_vs_natural_sources', + label: 'Distinguish anthropogenic sources from natural sources', + kind: 'misconception' + }); + } + + if (componentSlices.length >= 2) { + slices.push({ + id: 'cross_component_source_confusion', + label: `Distinguish the source patterns of ${componentSlices.map(slice => slice.label.split(' anthropogenic sources')[0]).join(', ')}`, + kind: 'misconception' + }); + } + + return slices; +} + +function buildCandidateSlices(learningObjective) { + const tail = extractTailAfterKeyword(learningObjective); + const items = splitEnumeratedItems(tail); + const breadth = detectBreadth(learningObjective, items); + + if (breadth === 'narrow' || items.length < 2) { + return { + breadth: 'narrow', + candidateSlices: [{ + id: toId(learningObjective), + label: normalizeWhitespace(learningObjective), + kind: 'whole-lo' + }] + }; + } + + const componentSlices = buildComponentSlices(learningObjective, items); + const comparisonSlices = buildComparisonSlices(componentSlices); + const misconceptionSlices = buildMisconceptionSlices(componentSlices, learningObjective); + + return { + breadth, + candidateSlices: [ + ...componentSlices, + ...comparisonSlices, + ...misconceptionSlices + ] + }; +} + +function buildTaskIntent(slice, questionType) { + if (slice.kind === 'comparison') { + return questionType === 'multiple-choice' ? 'compare-and-contrast' : 'comparison'; + } + + if (slice.kind === 'misconception') { + return questionType === 'multiple-choice' ? 'misconception-discrimination' : 'misconception-check'; + } + + return questionType === 'multiple-choice' ? 'source-identification' : 'concept-focus'; +} + +function selectTaskSequence(candidateSlices, requestedCount) { + const ordered = [ + ...candidateSlices.filter(slice => slice.kind === 'component'), + ...candidateSlices.filter(slice => slice.kind === 'comparison'), + ...candidateSlices.filter(slice => slice.kind === 'misconception'), + ...candidateSlices.filter(slice => slice.kind === 'whole-lo') + ]; + + const tasks = []; + + for (let index = 0; index < requestedCount; index++) { + const slice = ordered[index % ordered.length]; + tasks.push(slice); + } + + return tasks; +} + +export function planLOSlices({ learningObjective, questionType, requestedCount }) { + const normalizedLO = normalizeWhitespace(learningObjective); + const { breadth, candidateSlices } = buildCandidateSlices(normalizedLO); + const selectedSlices = selectTaskSequence(candidateSlices, requestedCount); + + return { + originalLO: normalizedLO, + breadth, + candidateSlices, + recommendedTasks: selectedSlices.map((slice, index) => ({ + index, + sliceId: slice.id, + sliceLabel: slice.label, + sliceKind: slice.kind, + questionIntent: buildTaskIntent(slice, questionType) + })) + }; +} + +export default { + planLOSlices +}; diff --git a/routes/create/services/markdownExportService.js b/routes/create/services/markdownExportService.js new file mode 100644 index 0000000..680603f --- /dev/null +++ b/routes/create/services/markdownExportService.js @@ -0,0 +1,266 @@ +/** + * Markdown Export Service + * Handles Markdown generation for quiz exports. + */ +import { writeFile } from 'fs/promises'; + +function normalizeText(value) { + return String(value ?? '').replace(/\r\n/g, '\n').trim(); +} + +function escapeMarkdown(value) { + return normalizeText(value).replace(/([\\`*_{}[\]()#+\-.!|>])/g, '\\$1'); +} + +function renderTypeLabel(type) { + return String(type || 'unknown') + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function getQuestionLearningObjective(question) { + if (typeof question.learningObjective === 'string') { + return question.learningObjective; + } + + return question.learningObjective?.text || ''; +} + +function getMultipleChoiceMode(question) { + if (question.content?.selectionMode === 'multiple') { + return 'multiple'; + } + + const correctCount = (question.content?.options || []).filter(option => option.isCorrect).length; + return correctCount > 1 ? 'multiple' : 'single'; +} + +export function renderMarkdownQuestion(question, index, exportType) { + const lines = []; + const questionNumber = index + 1; + const questionText = normalizeText(question.questionText) || 'Untitled question'; + const learningObjective = getQuestionLearningObjective(question); + + lines.push(`## Question ${questionNumber}`); + lines.push(''); + lines.push(questionText); + lines.push(''); + lines.push(`- Type: ${renderTypeLabel(question.type)}`); + + if (question.difficulty) { + lines.push(`- Difficulty: ${escapeMarkdown(question.difficulty)}`); + } + + if (learningObjective) { + lines.push(`- Learning Objective: ${escapeMarkdown(learningObjective)}`); + } + + if (exportType === 'questions' || exportType === 'combined') { + const questionContent = renderMarkdownQuestionContent(question); + if (questionContent.length > 0) { + lines.push(''); + lines.push(...questionContent); + } + } + + if (exportType === 'answers' || exportType === 'combined') { + const answerContent = renderMarkdownAnswerContent(question); + if (answerContent.length > 0) { + lines.push(''); + lines.push('### Answer'); + lines.push(''); + lines.push(...answerContent); + } + } + + return lines.join('\n'); +} + +export function renderMarkdownQuestionContent(question) { + const lines = []; + + if (question.type === 'multiple-choice') { + const options = question.content?.options || []; + if (options.length > 0) { + lines.push('### Options'); + lines.push(''); + options.forEach((option, index) => { + const letter = String.fromCharCode(65 + index); + lines.push(`- ${letter}. ${escapeMarkdown(option.text)}`); + }); + + const tips = options + .map((option, index) => option.tip ? `- Tip for ${String.fromCharCode(65 + index)}: ${escapeMarkdown(option.tip)}` : null) + .filter(Boolean); + + if (tips.length > 0) { + lines.push(''); + lines.push('### Tips'); + lines.push(''); + lines.push(...tips); + } + } + } else if (question.type === 'true-false') { + lines.push('### Options'); + lines.push(''); + lines.push('- A. True'); + lines.push('- B. False'); + } else if (question.type === 'cloze') { + lines.push('### Prompt'); + lines.push(''); + lines.push(normalizeText(question.content?.textWithBlanks || question.questionText)); + + const blankOptions = question.content?.blankOptions || []; + if (blankOptions.length > 0) { + lines.push(''); + lines.push('### Blank Options'); + lines.push(''); + blankOptions.forEach((options, index) => { + if (Array.isArray(options) && options.length > 0) { + lines.push(`- Blank ${index + 1}: ${options.map(option => escapeMarkdown(option)).join(', ')}`); + } + }); + } + } else if (question.type === 'ordering') { + const items = question.content?.items || []; + if (items.length > 0) { + lines.push('### Items to Order'); + lines.push(''); + items.forEach((item, index) => { + lines.push(`${index + 1}. ${escapeMarkdown(item)}`); + }); + } + } else if (question.type === 'matching') { + const leftItems = question.content?.leftItems || []; + const rightItems = question.content?.rightItems || []; + lines.push('### Matching Pairs'); + lines.push(''); + if (leftItems.length > 0) { + lines.push('Column A:'); + leftItems.forEach((item, index) => { + lines.push(`- ${index + 1}. ${escapeMarkdown(item)}`); + }); + lines.push(''); + } + if (rightItems.length > 0) { + lines.push('Column B:'); + rightItems.forEach((item, index) => { + const letter = String.fromCharCode(65 + index); + lines.push(`- ${letter}. ${escapeMarkdown(item)}`); + }); + } + } else if (question.type === 'flashcard') { + lines.push('### Front'); + lines.push(''); + lines.push(normalizeText(question.content?.front || question.questionText)); + } + + return lines; +} + +export function renderMarkdownAnswerContent(question) { + const lines = []; + + if (question.type === 'multiple-choice') { + const options = question.content?.options || []; + const correctOptions = options.filter(option => option.isCorrect); + if (correctOptions.length > 0) { + if (getMultipleChoiceMode(question) === 'multiple') { + lines.push('- Correct Answers:'); + correctOptions.forEach((correctOption) => { + const letter = String.fromCharCode(65 + options.indexOf(correctOption)); + lines.push(` - ${letter}. ${escapeMarkdown(correctOption.text)}`); + }); + } else { + const correctOption = correctOptions[0]; + const letter = String.fromCharCode(65 + options.indexOf(correctOption)); + lines.push(`- Correct Answer: ${letter}. ${escapeMarkdown(correctOption.text)}`); + } + } else if (question.correctAnswer) { + lines.push(`- Correct Answer: ${escapeMarkdown(question.correctAnswer)}`); + } + + const feedbackLines = options.flatMap((option, index) => { + const letter = String.fromCharCode(65 + index); + return [ + option.chosenFeedback ? ` - ${letter}. If selected: ${escapeMarkdown(option.chosenFeedback)}` : null, + option.notChosenFeedback ? ` - ${letter}. If not selected: ${escapeMarkdown(option.notChosenFeedback)}` : null + ].filter(Boolean); + }); + + if (feedbackLines.length > 0) { + lines.push('- Option Feedback:'); + lines.push(...feedbackLines); + } + } else if (question.type === 'true-false') { + const answer = String(question.correctAnswer).toLowerCase() === 'true' ? 'True' : 'False'; + lines.push(`- Correct Answer: ${answer}`); + } else if (question.type === 'cloze') { + const correctAnswers = question.content?.correctAnswers || []; + if (correctAnswers.length > 0) { + correctAnswers.forEach((answer, index) => { + lines.push(`- Blank ${index + 1}: ${escapeMarkdown(answer)}`); + }); + } else if (question.correctAnswer) { + lines.push(`- Correct Answer: ${escapeMarkdown(question.correctAnswer)}`); + } + } else if (question.type === 'ordering') { + const correctOrder = question.content?.correctOrder || []; + if (correctOrder.length > 0) { + lines.push('- Correct Order:'); + correctOrder.forEach((item, index) => { + lines.push(` ${index + 1}. ${escapeMarkdown(item)}`); + }); + } + } else if (question.type === 'matching') { + const matchingPairs = question.content?.matchingPairs || []; + if (matchingPairs.length > 0) { + lines.push('- Correct Matches:'); + matchingPairs.forEach(([left, right]) => { + lines.push(` - ${escapeMarkdown(left)} -> ${escapeMarkdown(right)}`); + }); + } + } else if (question.type === 'flashcard') { + lines.push(`- Back: ${escapeMarkdown(question.content?.back || question.correctAnswer || '')}`); + } else if (question.correctAnswer) { + lines.push(`- Correct Answer: ${escapeMarkdown(question.correctAnswer)}`); + } + + if (question.explanation) { + lines.push('- Explanation:'); + lines.push(''); + lines.push(normalizeText(question.explanation)); + } + + return lines.filter(line => line !== ''); +} + +/** + * Create a Markdown export of quiz questions. + * @param {Object} quiz - Quiz document with populated questions + * @param {string} outputPath - File path to save Markdown + * @param {string} type - Export type: 'questions', 'answers', or 'combined' + */ +export async function createMarkdownExport(quiz, outputPath, type) { + const typeLabels = { + questions: 'Questions Only', + answers: 'Answer Key', + combined: 'Questions and Answers' + }; + + const lines = [ + `# ${quiz.name}`, + '', + `Export Type: ${typeLabels[type]}`, + `Question Count: ${quiz.questions.length}`, + '' + ]; + + quiz.questions.forEach((question, index) => { + lines.push(renderMarkdownQuestion(question, index, type)); + lines.push(''); + }); + + await writeFile(outputPath, `${lines.join('\n').trim()}\n`, 'utf8'); +} diff --git a/routes/create/services/pdfExportService.js b/routes/create/services/pdfExportService.js index 06b83da..6faa5ce 100644 --- a/routes/create/services/pdfExportService.js +++ b/routes/create/services/pdfExportService.js @@ -5,6 +5,22 @@ import PDFDocument from 'pdfkit'; import { createWriteStream } from 'fs'; +function getMultipleChoiceMode(question) { + if (question.content?.selectionMode === 'multiple') { + return 'multiple'; + } + + const correctCount = (question.content?.options || []).filter(option => option.isCorrect).length; + return correctCount > 1 ? 'multiple' : 'single'; +} + +function addIndentedLines(doc, lines, indent = 20, fontSize = 9) { + lines.filter(Boolean).forEach((line) => { + doc.fontSize(fontSize).font('Helvetica').text(line, { indent }); + doc.moveDown(0.2); + }); +} + /** * Create a PDF export of quiz questions. * @param {Object} quiz - Quiz document with populated questions @@ -141,6 +157,17 @@ export function addQuestionContent(doc, question) { doc.fontSize(10).font('Helvetica').text(`${letter}. ${option.text}`, { indent: 20 }); doc.moveDown(0.2); }); + + const tips = options + .map((option, idx) => option.tip ? `${String.fromCharCode(65 + idx)}. ${option.tip}` : null) + .filter(Boolean); + + if (tips.length > 0) { + doc.moveDown(0.3); + doc.fontSize(9).font('Helvetica-Bold').text('Tips:', { indent: 20 }); + doc.moveDown(0.2); + addIndentedLines(doc, tips, 30, 9); + } } else if (question.type === 'true-false') { doc.fontSize(10).font('Helvetica').text('Options:', { underline: true }); doc.moveDown(0.3); @@ -202,14 +229,39 @@ export function addQuestionContent(doc, question) { export function addAnswerContent(doc, question) { if (question.type === 'multiple-choice') { const options = question.content?.options || []; - const correctOption = options.find(opt => opt.isCorrect); - if (correctOption) { - const correctIndex = options.indexOf(correctOption); - const letter = String.fromCharCode(65 + correctIndex); - doc.fontSize(10).text(`${letter}. ${correctOption.text}`, { indent: 20 }); + const correctOptions = options.filter(opt => opt.isCorrect); + if (correctOptions.length > 0) { + const answerMode = getMultipleChoiceMode(question); + if (answerMode === 'multiple') { + correctOptions.forEach((correctOption) => { + const correctIndex = options.indexOf(correctOption); + const letter = String.fromCharCode(65 + correctIndex); + doc.fontSize(10).text(`${letter}. ${correctOption.text}`, { indent: 20 }); + }); + } else { + const correctOption = correctOptions[0]; + const correctIndex = options.indexOf(correctOption); + const letter = String.fromCharCode(65 + correctIndex); + doc.fontSize(10).text(`${letter}. ${correctOption.text}`, { indent: 20 }); + } } else { doc.fontSize(10).text(question.correctAnswer || 'N/A', { indent: 20 }); } + + const feedbackLines = options.flatMap((option, idx) => { + const letter = String.fromCharCode(65 + idx); + return [ + option.chosenFeedback ? `${letter}. If selected: ${option.chosenFeedback}` : null, + option.notChosenFeedback ? `${letter}. If not selected: ${option.notChosenFeedback}` : null + ].filter(Boolean); + }); + + if (feedbackLines.length > 0) { + doc.moveDown(0.3); + doc.fontSize(9).font('Helvetica-Bold').text('Option Feedback:', { indent: 20 }); + doc.moveDown(0.2); + addIndentedLines(doc, feedbackLines, 30, 9); + } } else if (question.type === 'true-false') { const answer = String(question.correctAnswer).toLowerCase() === 'true' ? 'True' : 'False'; doc.fontSize(10).text(answer, { indent: 20 }); @@ -242,4 +294,11 @@ export function addAnswerContent(doc, question) { } else { doc.fontSize(10).text(question.correctAnswer || question.explanation || 'N/A', { indent: 20 }); } + + if (question.explanation) { + doc.moveDown(0.3); + doc.fontSize(9).font('Helvetica-Bold').text('Explanation:', { indent: 20 }); + doc.moveDown(0.2); + doc.fontSize(9).font('Helvetica').text(question.explanation, { indent: 30, width: 470 }); + } } diff --git a/routes/create/services/promptTemplateInitializer.js b/routes/create/services/promptTemplateInitializer.js index 654ecbb..fe71657 100644 --- a/routes/create/services/promptTemplateInitializer.js +++ b/routes/create/services/promptTemplateInitializer.js @@ -15,7 +15,10 @@ CRITICAL REQUIREMENTS: 1. The sum of ALL counts MUST equal EXACTLY {{totalQuestions}} 2. Distribute across ALL learning objectives proportionally 3. Each item must have count >= 1 -4. Return ONLY the JSON object below (no text, no markdown, no explanations) +4. If a learning objective is broad or contains multiple assessable components, distribute repeated questions so different components can be covered across the set +5. Avoid creating a plan that would force multiple near-duplicate questions for the same narrow concept unless the instructor explicitly asked for that +6. Prefer conceptual diversity within each learning objective when multiple questions are assigned to it +7. Return ONLY the JSON object below (no text, no markdown, no explanations) { "planItems": [ @@ -41,7 +44,8 @@ Question Type Rules: Design Guidelines: - Flashcards for key terms, definitions, and concepts - Summaries for broader understanding and synthesis -- Mark-the-words to help students identify important terms in context`, +- Mark-the-words to help students identify important terms in context +- If one learning objective covers multiple concepts, distribute cards so different concepts can be reinforced instead of repeating the same fact pattern`, questionTypeRules: { allowedTypes: ['flashcard', 'summary', 'mark-the-words'], distribution: new Map([['flashcard', 0.65], ['summary', 0.17], ['mark-the-words', 0.18]]), @@ -71,7 +75,8 @@ Design Guidelines: - True/false for fundamental concepts and quick checks - Single-choice-set for timed quizzes and quick recall - Essay for in-depth comprehension evaluation -- Mix question types within each learning objective for variety`, +- Mix question types within each learning objective for variety +- If a learning objective contains several assessable parts, allocate questions so different parts can be assessed instead of repeating the same broad prompt`, questionTypeRules: { allowedTypes: ['multiple-choice', 'true-false', 'single-choice-set', 'essay'], distribution: new Map([['multiple-choice', 0.45], ['true-false', 0.24], ['single-choice-set', 0.23], ['essay', 0.08]]), @@ -102,7 +107,8 @@ Design Guidelines: - Sort-paragraphs for logical sequencing of ideas - Mark-the-words for identifying key terms in context - Aim for visual variety and engagement -- Distribute question types so no single type dominates`, +- Distribute question types so no single type dominates +- If one learning objective is broad, use the diversity of question types to cover different slices of that objective rather than repeating the same challenge`, questionTypeRules: { allowedTypes: ['matching', 'ordering', 'cloze', 'discussion', 'crossword', 'sort-paragraphs', 'mark-the-words'], distribution: new Map([ diff --git a/routes/create/services/questionContentService.js b/routes/create/services/questionContentService.js index 9fb5eac..cfdf65c 100644 --- a/routes/create/services/questionContentService.js +++ b/routes/create/services/questionContentService.js @@ -14,7 +14,8 @@ export function formatContentForDatabase(generatedQuestion, questionType) { case 'multiple-choice': case 'true-false': return { - options: c.options || generatedQuestion.options || [] + options: c.options || generatedQuestion.options || [], + selectionMode: c.selectionMode || generatedQuestion.selectionMode || 'single' }; case 'flashcard': diff --git a/routes/create/services/questionStreamingService.js b/routes/create/services/questionStreamingService.js index 89ae2ea..0889a3a 100644 --- a/routes/create/services/questionStreamingService.js +++ b/routes/create/services/questionStreamingService.js @@ -8,6 +8,46 @@ import llmService from './llmService.js'; import { formatContentForDatabase } from './questionContentService.js'; class QuestionStreamingService { + buildPlannedTaskPrompt(plannedTask, attempt = 1) { + if (!plannedTask) { + return null; + } + + return [ + `The slice has already been selected for you. You MUST generate a question only about "${plannedTask.sliceLabel}" using the intent "${plannedTask.questionIntent}".`, + 'You are NOT allowed to switch to another gas, another concept slice, or a broad all-in-one version of the learning objective.', + 'If the planned slice is a component such as CO2, CH4, or N2O, the question must stay on that component.', + 'If the planned slice is a comparison, the question must explicitly compare the named components.', + 'If the planned slice is a misconception task, the question must explicitly test that misconception target.', + attempt > 1 ? `This is retry attempt ${attempt} because a previous attempt drifted away from the assigned slice.` : '' + ].filter(Boolean).join(' '); + } + + mergeCustomPromptWithPlannedTask(customPrompt, plannedTaskPrompt) { + return [customPrompt, plannedTaskPrompt].filter(Boolean).join('\n\n'); + } + + buildSelectionModePrompt(questionConfig) { + if (questionConfig.questionType !== 'multiple-choice') { + return null; + } + + if (questionConfig.selectionMode === 'multiple') { + return [ + 'This must be a MULTIPLE-ANSWER multiple choice question.', + 'Write the stem so students understand they should select all that apply.', + 'You MUST mark at least two options as correct.', + 'Return the correctAnswer field as an array of the correct option texts.' + ].join(' '); + } + + return [ + 'This must be a SINGLE-ANSWER multiple choice question.', + 'You MUST mark exactly one option as correct.', + 'Return the correctAnswer field as the single correct option text.' + ].join(' '); + } + /** * Generate a question with streaming progress updates */ @@ -74,19 +114,68 @@ class QuestionStreamingService { status: 'llm-started', message: 'Calling LLM service...' }); + + const plannedTask = questionConfig.plannedTask || null; + const maxAttempts = plannedTask ? 3 : 1; + let result = null; + let finalValidation = { valid: true, reason: 'No planned task validation required' }; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const plannedTaskPrompt = this.buildPlannedTaskPrompt(plannedTask, attempt); + const selectionModePrompt = this.buildSelectionModePrompt(questionConfig); + const customPrompt = this.mergeCustomPromptWithPlannedTask( + this.mergeCustomPromptWithPlannedTask(questionConfig.customPrompt, selectionModePrompt), + plannedTaskPrompt + ); + + if (plannedTask) { + sseService.streamQuestionProgress(sessionId, questionId, { + status: 'slice-planned', + message: `Generating planned slice: ${plannedTask.sliceLabel}`, + plannedSlice: plannedTask.sliceLabel, + attempt + }); + } + + result = await llmService.generateQuestionStreaming({ + learningObjective: learningObjective?.text || (typeof learningObjective === 'string' ? learningObjective : null), + questionType: questionConfig.questionType, + relevantContent: relevantContent || [], + difficulty: questionConfig.difficulty || 'moderate', + courseContext: questionConfig.courseContext || '', + previousQuestions: questionConfig.previousQuestions || [], + customPrompt, + selectionMode: questionConfig.selectionMode, + branchingLayers: questionConfig.branchingLayers ?? 2, + branchingChoices: questionConfig.branchingChoices ?? 2 + }, plannedTask ? null : onStreamChunk); + + if (!result?.success) { + continue; + } + + finalValidation = llmService.questionMatchesPlannedTask(result.questionData, plannedTask); + if (finalValidation.valid) { + break; + } + + console.warn(`[${questionId}] Planned slice validation failed on attempt ${attempt}: ${finalValidation.reason}`); + sseService.streamQuestionProgress(sessionId, questionId, { + status: 'slice-retry', + message: finalValidation.reason, + plannedSlice: plannedTask?.sliceLabel, + attempt + }); + result = null; + } - const result = await llmService.generateQuestionStreaming({ - learningObjective: learningObjective?.text || (typeof learningObjective === 'string' ? learningObjective : null), - questionType: questionConfig.questionType, - relevantContent: relevantContent || [], - difficulty: questionConfig.difficulty || 'moderate', - courseContext: questionConfig.courseContext || '', - previousQuestions: questionConfig.previousQuestions || [], - customPrompt: questionConfig.customPrompt, - branchingLayers: questionConfig.branchingLayers ?? 2, - branchingChoices: questionConfig.branchingChoices ?? 2 - }, onStreamChunk); - + if (!result?.success) { + const failureReason = plannedTask && !finalValidation.valid + ? `Failed to generate a question that matches the planned slice "${plannedTask.sliceLabel}": ${finalValidation.reason}` + : 'LLM generation failed'; + throw new Error(failureReason); + } + console.log(`[${questionId}] LLM returned, success=${result.success}`); sseService.streamQuestionProgress(sessionId, questionId, { status: 'llm-complete', @@ -94,6 +183,13 @@ class QuestionStreamingService { }); if (result.success) { + if (questionConfig.questionType === 'multiple-choice') { + result.questionData.content = { + ...(result.questionData.content || {}), + selectionMode: questionConfig.selectionMode || 'single' + }; + } + console.log(`[${questionId}] Starting database save...`); sseService.streamQuestionProgress(sessionId, questionId, { status: 'db-save-started', @@ -181,6 +277,9 @@ class QuestionStreamingService { confidence: 0.9, processingTime: result.metadata?.processingTime || 2000, streamingGenerated: true, + plannedSlice: plannedTask?.sliceLabel || null, + plannedIntent: plannedTask?.questionIntent || null, + plannedSliceValidation: finalValidation, generatedAt: new Date().toISOString() } }); @@ -330,4 +429,4 @@ class QuestionStreamingService { // Create singleton instance const questionStreamingService = new QuestionStreamingService(); -export default questionStreamingService; \ No newline at end of file +export default questionStreamingService; diff --git a/src/components/AddQuestionModal.tsx b/src/components/AddQuestionModal.tsx index 82bad4a..67e7a8d 100644 --- a/src/components/AddQuestionModal.tsx +++ b/src/components/AddQuestionModal.tsx @@ -97,7 +97,7 @@ const AddQuestionModal = ({ { id: 'discussion', label: 'Discussion' }, { id: 'matching', label: 'Matching' }, { id: 'ordering', label: 'Ordering' }, - { id: 'cloze', label: 'Cloze Test' }, + { id: 'cloze', label: 'Fill in the Blank' }, { id: 'mark-the-words', label: 'Mark the Words' }, { id: 'single-choice-set', label: 'Single Choice Set' }, { id: 'essay', label: 'Essay' }, diff --git a/src/components/PdfExportModal.tsx b/src/components/PdfExportModal.tsx index e8f9d47..4c8a673 100644 --- a/src/components/PdfExportModal.tsx +++ b/src/components/PdfExportModal.tsx @@ -6,9 +6,18 @@ interface PdfExportModalProps { onClose: () => void; onExport: (type: 'questions' | 'answers' | 'combined') => void; isLoading: boolean; + title?: string; + subtitle?: string; } -const PdfExportModal = ({ isOpen, onClose, onExport, isLoading }: PdfExportModalProps) => { +const PdfExportModal = ({ + isOpen, + onClose, + onExport, + isLoading, + title = 'Export to PDF', + subtitle = 'Choose what to include in your PDF export' +}: PdfExportModalProps) => { if (!isOpen) return null; const exportOptions = [ @@ -37,8 +46,8 @@ const PdfExportModal = ({ isOpen, onClose, onExport, isLoading }: PdfExportModal
e.stopPropagation()}>
-

Export to PDF

-

Choose what to include in your PDF export

+

{title}

+

{subtitle}

{/* Generation Summary */} @@ -546,13 +694,6 @@ const QuestionGeneration = ({ learningObjectives, assignedMaterials, quizId, onQ
-
@@ -573,6 +714,16 @@ const QuestionGeneration = ({ learningObjectives, assignedMaterials, quizId, onQ

Create a plan for your quiz questions by choosing a mode and configuring the distribution

+ {hasQuestions && ( +
+ +
+ )}
{learningObjectives.length === 0 ? ( @@ -682,15 +833,65 @@ const QuestionGeneration = ({ learningObjectives, assignedMaterials, quizId, onQ
)} + + {showGenerationModeModal && ( +
!isPreparingGeneration && setShowGenerationModeModal(false)}> +
e.stopPropagation()} style={{ padding: '24px', maxWidth: '560px' }}> +
+
+

Generate More Questions?

+

+ This quiz already has {questions.length} generated question{questions.length === 1 ? '' : 's'}. +

+
+
+
+

+ Choose whether to keep the existing questions and add new ones, or replace the current set with questions from the updated plan. +

+
+ + +
+
+
+ +
+
+
+ )} )} diff --git a/src/components/generation/generationTypes.ts b/src/components/generation/generationTypes.ts index af3f6bf..6b5b31a 100644 --- a/src/components/generation/generationTypes.ts +++ b/src/components/generation/generationTypes.ts @@ -21,6 +21,7 @@ export interface PlanItem { type: string; // question type learningObjectiveId: string; // LO._id (empty string = no LO) count: number; + selectionMode?: 'single' | 'multiple'; // multiple-choice only branchingLayers?: number; // branching-scenario only, default 2 branchingChoices?: number; // branching-scenario only, default 2 customPrompt?: string; // used when LO is absent or as supplemental context diff --git a/src/components/review/ManualQuestionForm.tsx b/src/components/review/ManualQuestionForm.tsx index a5d64cd..9e87e5a 100644 --- a/src/components/review/ManualQuestionForm.tsx +++ b/src/components/review/ManualQuestionForm.tsx @@ -1,12 +1,16 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Plus, Trash2, X, Wand2 } from 'lucide-react'; import { questionsApi, Question } from '../../services/api'; -import { ExtendedQuestion, questionTypes } from './reviewTypes'; +import { ExtendedQuestion } from './reviewTypes'; +import { QuestionTypeOption } from '../../constants/questionTypeCapabilities'; interface MCOption { text: string; isCorrect: boolean; order?: number; + tip?: string; + chosenFeedback?: string; + notChosenFeedback?: string; } interface KeyPoint { @@ -19,6 +23,7 @@ interface ManualQuestionFormState { question: string; loIndex: number; options: MCOption[]; + selectionMode: 'single' | 'multiple'; correctAnswer: string; front: string; back: string; @@ -39,21 +44,23 @@ interface ManualQuestionFormProps { onClose: () => void; quizId: string; learningObjectives: LearningObjectiveData[]; + availableQuestionTypes: QuestionTypeOption[]; onQuestionAdded: (question: ExtendedQuestion) => void; onGenerateAI?: (loIndex: number, prompt: string, questionType: string) => Promise; showNotification: (type: string, title: string, message: string) => void; } -const defaultFormState: ManualQuestionFormState = { - type: 'multiple-choice', +const createDefaultFormState = (defaultType: string): ManualQuestionFormState => ({ + type: defaultType, question: '', loIndex: 0, options: [ - { text: '', isCorrect: false }, - { text: '', isCorrect: false }, - { text: '', isCorrect: false }, - { text: '', isCorrect: false } + { text: '', isCorrect: false, tip: '', chosenFeedback: '', notChosenFeedback: '' }, + { text: '', isCorrect: false, tip: '', chosenFeedback: '', notChosenFeedback: '' }, + { text: '', isCorrect: false, tip: '', chosenFeedback: '', notChosenFeedback: '' }, + { text: '', isCorrect: false, tip: '', chosenFeedback: '', notChosenFeedback: '' } ], + selectionMode: 'single', correctAnswer: 'true', front: '', back: '', @@ -62,31 +69,46 @@ const defaultFormState: ManualQuestionFormState = { rightItems: ['', ''], items: ['', '', ''], textWithBlanks: '' -}; +}); -const ManualQuestionForm = ({ isOpen, onClose, quizId, learningObjectives, onQuestionAdded, onGenerateAI, showNotification }: ManualQuestionFormProps) => { +const ManualQuestionForm = ({ isOpen, onClose, quizId, learningObjectives, availableQuestionTypes, onQuestionAdded, onGenerateAI, showNotification }: ManualQuestionFormProps) => { + const defaultQuestionType = useMemo( + () => availableQuestionTypes[0]?.value || 'multiple-choice', + [availableQuestionTypes] + ); const [mode, setMode] = useState<'manual' | 'ai'>('manual'); - const [newQuestion, setNewQuestion] = useState({ ...defaultFormState }); + const [newQuestion, setNewQuestion] = useState(() => createDefaultFormState(defaultQuestionType)); // AI mode state const [aiLoIndex, setAiLoIndex] = useState(-1); const [aiPrompt, setAiPrompt] = useState(''); - const [aiQuestionType, setAiQuestionType] = useState('multiple-choice'); + const [aiQuestionType, setAiQuestionType] = useState(defaultQuestionType); const [aiLoading, setAiLoading] = useState(false); + useEffect(() => { + if (!availableQuestionTypes.some(type => type.value === newQuestion.type)) { + setNewQuestion(createDefaultFormState(defaultQuestionType)); + } + + if (!availableQuestionTypes.some(type => type.value === aiQuestionType)) { + setAiQuestionType(defaultQuestionType); + } + }, [availableQuestionTypes, aiQuestionType, defaultQuestionType, newQuestion.type]); + const resetForm = () => { setMode('manual'); + const nextDefaultState = createDefaultFormState(defaultQuestionType); setNewQuestion({ - ...defaultFormState, - options: defaultFormState.options.map(o => ({ ...o })), - keyPoints: defaultFormState.keyPoints.map(k => ({ ...k })), - leftItems: [...defaultFormState.leftItems], - rightItems: [...defaultFormState.rightItems], - items: [...defaultFormState.items], + ...nextDefaultState, + options: nextDefaultState.options.map(o => ({ ...o })), + keyPoints: nextDefaultState.keyPoints.map(k => ({ ...k })), + leftItems: [...nextDefaultState.leftItems], + rightItems: [...nextDefaultState.rightItems], + items: [...nextDefaultState.items], }); setAiLoIndex(-1); setAiPrompt(''); - setAiQuestionType('multiple-choice'); + setAiQuestionType(defaultQuestionType); }; const handleClose = () => { @@ -106,14 +128,27 @@ const ManualQuestionForm = ({ isOpen, onClose, quizId, learningObjectives, onQue switch (newQuestion.type) { case 'multiple-choice': questionText = newQuestion.question; + { + const correctOptions = newQuestion.options.filter((opt: MCOption) => opt.isCorrect && opt.text.trim()); + const normalizedSelectionMode = newQuestion.selectionMode === 'multiple' || correctOptions.length > 1 + ? 'multiple' + : 'single'; content = { options: newQuestion.options.map((opt: MCOption, index: number) => ({ text: opt.text, isCorrect: opt.isCorrect, - order: index + order: index, + tip: opt.tip || '', + chosenFeedback: opt.chosenFeedback || '', + notChosenFeedback: opt.notChosenFeedback || '' })) + .filter((opt: MCOption) => opt.text.trim()), + selectionMode: normalizedSelectionMode }; - correctAnswer = newQuestion.options.find((opt: MCOption) => opt.isCorrect)?.text || ''; + correctAnswer = normalizedSelectionMode === 'multiple' + ? correctOptions.map((opt: MCOption) => opt.text) + : (correctOptions[0]?.text || ''); + } break; case 'true-false': questionText = newQuestion.question; @@ -217,7 +252,13 @@ const ManualQuestionForm = ({ isOpen, onClose, quizId, learningObjectives, onQue const isManualFormValid = () => { if (newQuestion.type === 'multiple-choice') { - return newQuestion.question.trim() && newQuestion.options.some((opt: MCOption) => opt.isCorrect && opt.text.trim()); + const validOptions = newQuestion.options.filter((opt: MCOption) => opt.text.trim()); + const validCorrectOptions = validOptions.filter((opt: MCOption) => opt.isCorrect); + return newQuestion.question.trim() + && validOptions.length >= 2 + && (newQuestion.selectionMode === 'multiple' + ? validCorrectOptions.length >= 1 + : validCorrectOptions.length === 1); } if (newQuestion.type === 'true-false') return newQuestion.question.trim(); if (newQuestion.type === 'flashcard') return newQuestion.front.trim() && newQuestion.back.trim(); @@ -284,20 +325,21 @@ const ManualQuestionForm = ({ isOpen, onClose, quizId, learningObjectives, onQue value={newQuestion.type} onChange={(e) => { const type = e.target.value; + const nextDefaultState = createDefaultFormState(type); setNewQuestion({ - ...defaultFormState, + ...nextDefaultState, type, loIndex: newQuestion.loIndex, - options: defaultFormState.options.map(o => ({ ...o })), - keyPoints: defaultFormState.keyPoints.map(k => ({ ...k })), - leftItems: [...defaultFormState.leftItems], - rightItems: [...defaultFormState.rightItems], - items: [...defaultFormState.items], + options: nextDefaultState.options.map(o => ({ ...o })), + keyPoints: nextDefaultState.keyPoints.map(k => ({ ...k })), + leftItems: [...nextDefaultState.leftItems], + rightItems: [...nextDefaultState.rightItems], + items: [...nextDefaultState.items], }); }} > - {questionTypes.map(type => ( - + {availableQuestionTypes.map(type => ( + ))} @@ -335,44 +377,121 @@ const ManualQuestionForm = ({ isOpen, onClose, quizId, learningObjectives, onQue rows={3} /> +
+ +
+ + +
+
{newQuestion.options.map((option: MCOption, index: number) => ( -
- { - const updatedOptions = [...newQuestion.options]; - updatedOptions[index] = { ...updatedOptions[index], isCorrect: e.target.checked }; - setNewQuestion({...newQuestion, options: updatedOptions}); - }} - className="aq-option-checkbox" - /> - { - const updatedOptions = [...newQuestion.options]; - updatedOptions[index] = { ...updatedOptions[index], text: e.target.value }; - setNewQuestion({...newQuestion, options: updatedOptions}); - }} - placeholder={`Option ${index + 1}`} - className="input aq-option-input" - /> - {newQuestion.options.length > 2 && ( - - )} + className="aq-option-checkbox" + /> + { + const updatedOptions = [...newQuestion.options]; + updatedOptions[index] = { ...updatedOptions[index], text: e.target.value }; + setNewQuestion({ ...newQuestion, options: updatedOptions }); + }} + placeholder={`Option ${index + 1}`} + className="input aq-option-input" + /> + {newQuestion.options.length > 2 && ( + + )} +
+
+ { + const updatedOptions = [...newQuestion.options]; + updatedOptions[index] = { ...updatedOptions[index], tip: e.target.value }; + setNewQuestion({ ...newQuestion, options: updatedOptions }); + }} + placeholder="Tip shown before checking answers" + className="input aq-option-input" + /> + { + const updatedOptions = [...newQuestion.options]; + updatedOptions[index] = { ...updatedOptions[index], chosenFeedback: e.target.value }; + setNewQuestion({ ...newQuestion, options: updatedOptions }); + }} + placeholder="Feedback shown if this option is selected" + className="input aq-option-input" + /> + { + const updatedOptions = [...newQuestion.options]; + updatedOptions[index] = { ...updatedOptions[index], notChosenFeedback: e.target.value }; + setNewQuestion({ ...newQuestion, options: updatedOptions }); + }} + placeholder="Feedback shown if this option is not selected" + className="input aq-option-input" + /> +
))}
diff --git a/src/components/review/QuestionCard.tsx b/src/components/review/QuestionCard.tsx index 9d66e54..70e1cd0 100644 --- a/src/components/review/QuestionCard.tsx +++ b/src/components/review/QuestionCard.tsx @@ -16,6 +16,7 @@ interface QuestionCardProps { const QuestionCard = ({ question, index, handlers, onToggleEdit, onSave, onDelete, onRegenerate }: QuestionCardProps) => { const { updateQuestion, updateMultipleChoiceOption, updateMultipleChoiceCorrect, + toggleMultipleChoiceCorrect, updateMultipleChoiceSelectionMode, updateMultipleChoiceFeedback, addMultipleChoiceOption, removeMultipleChoiceOption, updateTrueFalseAnswer, updateMatchingLeftItem, updateMatchingRightItem, addMatchingLeftItem, addMatchingRightItem, removeMatchingLeftItem, removeMatchingRightItem, @@ -26,6 +27,11 @@ const QuestionCard = ({ question, index, handlers, onToggleEdit, onSave, onDelet updateKeyPoint, addKeyPoint, removeKeyPoint, } = handlers; + const multipleChoiceSelectionMode = question.content?.selectionMode === 'multiple' + || (question.content?.options?.filter(option => option.isCorrect).length || 0) > 1 + ? 'multiple' + : 'single'; + return (
@@ -70,17 +76,87 @@ const QuestionCard = ({ question, index, handlers, onToggleEdit, onSave, onDelet {question.type === 'multiple-choice' && question.content?.options ? (
+
+ + +
- {question.content.options.map((option: { text: string; isCorrect: boolean; order?: number }, idx: number) => ( + {question.content.options.map((option: { text: string; isCorrect: boolean; order?: number; tip?: string; chosenFeedback?: string; notChosenFeedback?: string }, idx: number) => (
- updateMultipleChoiceCorrect(question._id, idx)} className="option-radio" /> + { + if (multipleChoiceSelectionMode === 'multiple') { + toggleMultipleChoiceCorrect(question._id, idx, event.target.checked); + } else { + updateMultipleChoiceCorrect(question._id, idx); + } + }} + className="option-radio" + />