feat: optional dynamic question_text and OPEN_LEARNER completed access

Derive question_text from QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE stimulus for DYNAMIC questions so the top-level field is no longer required on create.

OPEN_LEARNER access responses now set is_accessible and is_completed to true on all LMS and exam-prep content, with full progress when totals exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-04 09:48:33 -07:00
parent 2605877f12
commit 08a2886654
12 changed files with 210 additions and 33 deletions

View File

@ -267,7 +267,6 @@ Capture:
```json ```json
{ {
"question_text": "Listen and respond as Speaker B.",
"question_type": "DYNAMIC", "question_type": "DYNAMIC",
"question_type_definition_id": 123, "question_type_definition_id": 123,
"difficulty_level": "MEDIUM", "difficulty_level": "MEDIUM",

View File

@ -14182,9 +14182,6 @@ const docTemplate = `{
}, },
"handlers.createQuestionReq": { "handlers.createQuestionReq": {
"type": "object", "type": "object",
"required": [
"question_text"
],
"properties": { "properties": {
"audio_correct_answer_text": { "audio_correct_answer_text": {
"type": "string" "type": "string"

View File

@ -14174,9 +14174,6 @@
}, },
"handlers.createQuestionReq": { "handlers.createQuestionReq": {
"type": "object", "type": "object",
"required": [
"question_text"
],
"properties": { "properties": {
"audio_correct_answer_text": { "audio_correct_answer_text": {
"type": "string" "type": "string"

View File

@ -1988,8 +1988,6 @@ definitions:
type: string type: string
voice_prompt: voice_prompt:
type: string type: string
required:
- question_text
type: object type: object
handlers.createQuestionSetReq: handlers.createQuestionSetReq:
properties: properties:

View File

@ -2,7 +2,7 @@ package domain
// LMSEntityAccess describes learner gating for a program, course, module, or lesson. // LMSEntityAccess describes learner gating for a program, course, module, or lesson.
// Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses. // Included for STUDENT and OPEN_LEARNER; omitted (nil) for staff roles in API responses.
// OPEN_LEARNER always has is_accessible true; STUDENT may be false when prerequisites are unmet. // OPEN_LEARNER always has is_accessible and is_completed true; STUDENT reflects real progress and gating.
// Progress fields count completed published practices vs total published practices in the // Progress fields count completed published practices vs total published practices in the
// entity's scope. progress_percent keeps the legacy whole-number value; use // entity's scope. progress_percent keeps the legacy whole-number value; use
// progress_percent_precise for decimal precision in learner UIs. // progress_percent_precise for decimal precision in learner UIs.

View File

@ -556,3 +556,59 @@ func onlyAuxiliaryResponseKinds(response []string) bool {
} }
return true return true
} }
// stimulusTextKindsForQuestionText are checked in order when deriving question_text from dynamic_payload.
var stimulusTextKindsForQuestionText = []string{
string(StimulusQuestionText),
string(StimulusInstruction),
string(StimulusTextPassage),
}
// ResolveQuestionTextForWrite returns the questions.question_text column value for create/update.
// explicit is the optional request field; existing is the current DB value on update (empty on create).
// For DYNAMIC questions, text may come from stimulus components (QUESTION_TEXT, INSTRUCTION, TEXT_PASSAGE).
func ResolveQuestionTextForWrite(explicit string, questionType string, payload *DynamicQuestionPayload, existing string) (string, error) {
explicit = strings.TrimSpace(explicit)
if explicit != "" {
return explicit, nil
}
if derived := stimulusTextFromPayload(payload); derived != "" {
return derived, nil
}
if strings.TrimSpace(existing) != "" {
return strings.TrimSpace(existing), nil
}
if strings.ToUpper(strings.TrimSpace(questionType)) == "DYNAMIC" {
return "", fmt.Errorf("provide question_text or stimulus text (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE) in dynamic_payload")
}
return "", fmt.Errorf("question_text is required")
}
func stimulusTextFromPayload(payload *DynamicQuestionPayload) string {
if payload == nil {
return ""
}
for _, want := range stimulusTextKindsForQuestionText {
for _, el := range payload.Stimulus {
if strings.TrimSpace(el.Kind) != want {
continue
}
if s := stringFromDynamicElementValue(el.Value); s != "" {
return s
}
}
}
return ""
}
func stringFromDynamicElementValue(v interface{}) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}

View File

@ -177,6 +177,58 @@ func TestResolveQuestionTypeDefinitionForQuestion_linkedDefinition(t *testing.T)
} }
} }
func TestResolveQuestionTextForWrite_explicit(t *testing.T) {
got, err := ResolveQuestionTextForWrite("Hello", "DYNAMIC", nil, "")
if err != nil || got != "Hello" {
t.Fatalf("expected Hello, got %q err=%v", got, err)
}
}
func TestResolveQuestionTextForWrite_fromQUESTION_TEXTStimulus(t *testing.T) {
payload := &DynamicQuestionPayload{
Stimulus: []DynamicElementInstance{
{ID: "prompt", Kind: "QUESTION_TEXT", Value: "Pick the correct answer."},
},
}
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "")
if err != nil || got != "Pick the correct answer." {
t.Fatalf("expected derived text, got %q err=%v", got, err)
}
}
func TestResolveQuestionTextForWrite_fromINSTRUCTIONStimulus(t *testing.T) {
payload := &DynamicQuestionPayload{
Stimulus: []DynamicElementInstance{
{ID: "prompt", Kind: "INSTRUCTION", Value: "Choose true or false."},
},
}
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "")
if err != nil || got != "Choose true or false." {
t.Fatalf("expected derived text, got %q err=%v", got, err)
}
}
func TestResolveQuestionTextForWrite_dynamicMissingText(t *testing.T) {
_, err := ResolveQuestionTextForWrite("", "DYNAMIC", &DynamicQuestionPayload{}, "")
if err == nil {
t.Fatal("expected error when no question text source")
}
}
func TestResolveQuestionTextForWrite_legacyRequiresText(t *testing.T) {
_, err := ResolveQuestionTextForWrite("", "MCQ", nil, "")
if err == nil || !strings.Contains(err.Error(), "question_text is required") {
t.Fatalf("expected legacy required error, got %v", err)
}
}
func TestResolveQuestionTextForWrite_updateKeepsExisting(t *testing.T) {
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", nil, "Previous title")
if err != nil || got != "Previous title" {
t.Fatalf("expected existing text, got %q err=%v", got, err)
}
}
func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) { func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) {
id := int64(10) id := int64(10)
summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{ summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{

View File

@ -6,7 +6,7 @@ const (
RoleSuperAdmin Role = "SUPER_ADMIN" RoleSuperAdmin Role = "SUPER_ADMIN"
RoleAdmin Role = "ADMIN" RoleAdmin Role = "ADMIN"
RoleStudent Role = "STUDENT" RoleStudent Role = "STUDENT"
// RoleOpenLearner can consume LMS content like a learner but without sequential prerequisite locking (step-by-step gates). // RoleOpenLearner can consume LMS content like a learner without sequential locks; access APIs show all content accessible and completed.
RoleOpenLearner Role = "OPEN_LEARNER" RoleOpenLearner Role = "OPEN_LEARNER"
RoleInstructor Role = "INSTRUCTOR" RoleInstructor Role = "INSTRUCTOR"
RoleSupport Role = "SUPPORT" RoleSupport Role = "SUPPORT"

View File

@ -149,7 +149,7 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
} }
// ApplyAccessProgram sets p.Access for learner roles. Staff roles omit Access from JSON. // ApplyAccessProgram sets p.Access for learner roles. Staff roles omit Access from JSON.
// STUDENT: is_accessible reflects sequential prerequisites; OPEN_LEARNER: always true. // STUDENT: is_accessible reflects sequential prerequisites; OPEN_LEARNER: is_accessible and is_completed always true.
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error { func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
if !role.IsCustomerLearnerRole() { if !role.IsCustomerLearnerRole() {
p.Access = nil p.Access = nil
@ -166,7 +166,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
return err return err
} }
} }
p.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) p.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
return nil return nil
} }
@ -187,7 +187,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
return err return err
} }
} }
c.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) c.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
return nil return nil
} }
@ -208,7 +208,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
return err return err
} }
} }
m.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
return nil return nil
} }
@ -229,7 +229,7 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
return err return err
} }
} }
les.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction) les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
return nil return nil
} }
@ -243,7 +243,7 @@ func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role dom
if err != nil { if err != nil {
return err return err
} }
cc.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) cc.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
return nil return nil
} }
@ -257,7 +257,7 @@ func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role,
if err != nil { if err != nil {
return err return err
} }
u.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) u.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
return nil return nil
} }
@ -271,7 +271,7 @@ func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Rol
if err != nil { if err != nil {
return err return err
} }
m.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
return nil return nil
} }
@ -285,7 +285,7 @@ func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Rol
if err != nil { if err != nil {
return err return err
} }
les.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction) les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
return nil return nil
} }
@ -524,6 +524,22 @@ func lmsProgressComplete(completed, total int32) bool {
return total > 0 && completed >= total return total > 0 && completed >= total
} }
// finalizeOpenLearnerAccess forces OPEN_LEARNER access JSON to show every item as accessible and completed.
func finalizeOpenLearnerAccess(role domain.Role, access *domain.LMSEntityAccess) *domain.LMSEntityAccess {
if access == nil || role != domain.RoleOpenLearner {
return access
}
access.IsAccessible = true
access.IsCompleted = true
access.Reason = ""
if access.TotalCount > 0 {
access.CompletedCount = access.TotalCount
access.ProgressPercent = 100
access.ProgressPercentPrecise = 100
}
return access
}
func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess { func buildLMSEntityAccess(ok bool, reason string, done bool, completed, total int32) *domain.LMSEntityAccess {
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done) c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
return &domain.LMSEntityAccess{ return &domain.LMSEntityAccess{

View File

@ -1,6 +1,10 @@
package lmsprogress package lmsprogress
import "testing" import (
"testing"
"Yimaru-Backend/internal/domain"
)
func TestLMSProgressCounts(t *testing.T) { func TestLMSProgressCounts(t *testing.T) {
tests := []struct { tests := []struct {
@ -90,6 +94,28 @@ func TestPracticeScopeFraction(t *testing.T) {
} }
} }
func TestFinalizeOpenLearnerAccess(t *testing.T) {
incomplete := buildLMSEntityAccessFromFraction(false, "locked", false, 1, 5, 0.2)
got := finalizeOpenLearnerAccess(domain.RoleOpenLearner, incomplete)
if got == nil || !got.IsAccessible || !got.IsCompleted {
t.Fatalf("expected accessible and completed, got %+v", got)
}
if got.CompletedCount != 5 || got.ProgressPercent != 100 {
t.Fatalf("expected full progress, got %+v", got)
}
studentAccess := buildLMSEntityAccessFromFraction(false, "locked", false, 1, 5, 0.2)
unchanged := finalizeOpenLearnerAccess(domain.RoleStudent, studentAccess)
if unchanged != studentAccess || unchanged.IsCompleted || unchanged.IsAccessible {
t.Fatalf("STUDENT access should be unchanged, got %+v", unchanged)
}
emptyScope := finalizeOpenLearnerAccess(domain.RoleOpenLearner, buildLMSEntityAccessFromFraction(true, "", false, 0, 0, 0))
if emptyScope == nil || !emptyScope.IsCompleted {
t.Fatalf("expected completed even with zero total, got %+v", emptyScope)
}
}
func TestLMSProgressComplete(t *testing.T) { func TestLMSProgressComplete(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@ -27,10 +27,17 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
}) })
} }
if req.QuestionText == "" { questionType := normalizeRuntimeQuestionType(req.QuestionType)
questionText, err := domain.ResolveQuestionTextForWrite(
optionalTrimmedString(req.QuestionText),
questionType,
req.DynamicPayload,
"",
)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation error", Message: "Invalid question_text",
Error: "question_text is required", Error: err.Error(),
}) })
} }
@ -54,8 +61,8 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
} }
input := domain.CreateQuestionInput{ input := domain.CreateQuestionInput{
QuestionText: req.QuestionText, QuestionText: questionText,
QuestionType: req.QuestionType, QuestionType: questionType,
DifficultyLevel: req.DifficultyLevel, DifficultyLevel: req.DifficultyLevel,
Points: req.Points, Points: req.Points,
Explanation: req.Explanation, Explanation: req.Explanation,

View File

@ -26,7 +26,7 @@ type shortAnswerInput struct {
} }
type createQuestionReq struct { type createQuestionReq struct {
QuestionText string `json:"question_text" validate:"required"` QuestionText *string `json:"question_text,omitempty"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"` QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"` DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
@ -89,9 +89,16 @@ func normalizeRuntimeQuestionType(v string) string {
return strings.ToUpper(strings.TrimSpace(v)) return strings.ToUpper(strings.TrimSpace(v))
} }
func optionalTrimmedString(s *string) string {
if s == nil {
return ""
}
return strings.TrimSpace(*s)
}
// CreateQuestion godoc // CreateQuestion godoc
// @Summary Create a new question // @Summary Create a new question
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). Supports question_type_definition_id for dynamic builder-linked questions. // @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). question_text is optional for DYNAMIC questions when stimulus includes QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE; it is required for legacy types (MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO).
// @Tags questions // @Tags questions
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -186,8 +193,21 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
}) })
} }
questionText, err := domain.ResolveQuestionTextForWrite(
optionalTrimmedString(req.QuestionText),
questionType,
req.DynamicPayload,
"",
)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_text",
Error: err.Error(),
})
}
input := domain.CreateQuestionInput{ input := domain.CreateQuestionInput{
QuestionText: req.QuestionText, QuestionText: questionText,
QuestionType: questionType, QuestionType: questionType,
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID, QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
DynamicPayload: req.DynamicPayload, DynamicPayload: req.DynamicPayload,
@ -519,10 +539,6 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
}) })
} }
questionText := existingQuestion.QuestionText
if req.QuestionText != nil {
questionText = *req.QuestionText
}
questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType) questionType := normalizeRuntimeQuestionType(existingQuestion.QuestionType)
if req.QuestionType != nil { if req.QuestionType != nil {
questionType = normalizeRuntimeQuestionType(*req.QuestionType) questionType = normalizeRuntimeQuestionType(*req.QuestionType)
@ -588,6 +604,19 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
}) })
} }
questionText, err := domain.ResolveQuestionTextForWrite(
optionalTrimmedString(req.QuestionText),
questionType,
effectiveDynamicPayload,
existingQuestion.QuestionText,
)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_text",
Error: err.Error(),
})
}
input := domain.CreateQuestionInput{ input := domain.CreateQuestionInput{
QuestionText: questionText, QuestionText: questionText,
QuestionType: questionType, QuestionType: questionType,