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:
parent
2605877f12
commit
08a2886654
|
|
@ -267,7 +267,6 @@ Capture:
|
|||
|
||||
```json
|
||||
{
|
||||
"question_text": "Listen and respond as Speaker B.",
|
||||
"question_type": "DYNAMIC",
|
||||
"question_type_definition_id": 123,
|
||||
"difficulty_level": "MEDIUM",
|
||||
|
|
|
|||
|
|
@ -14182,9 +14182,6 @@ const docTemplate = `{
|
|||
},
|
||||
"handlers.createQuestionReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"question_text"
|
||||
],
|
||||
"properties": {
|
||||
"audio_correct_answer_text": {
|
||||
"type": "string"
|
||||
|
|
|
|||
|
|
@ -14174,9 +14174,6 @@
|
|||
},
|
||||
"handlers.createQuestionReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"question_text"
|
||||
],
|
||||
"properties": {
|
||||
"audio_correct_answer_text": {
|
||||
"type": "string"
|
||||
|
|
|
|||
|
|
@ -1988,8 +1988,6 @@ definitions:
|
|||
type: string
|
||||
voice_prompt:
|
||||
type: string
|
||||
required:
|
||||
- question_text
|
||||
type: object
|
||||
handlers.createQuestionSetReq:
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package domain
|
|||
|
||||
// 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.
|
||||
// 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
|
||||
// entity's scope. progress_percent keeps the legacy whole-number value; use
|
||||
// progress_percent_precise for decimal precision in learner UIs.
|
||||
|
|
|
|||
|
|
@ -556,3 +556,59 @@ func onlyAuxiliaryResponseKinds(response []string) bool {
|
|||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
id := int64(10)
|
||||
summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const (
|
|||
RoleSuperAdmin Role = "SUPER_ADMIN"
|
||||
RoleAdmin Role = "ADMIN"
|
||||
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"
|
||||
RoleInstructor Role = "INSTRUCTOR"
|
||||
RoleSupport Role = "SUPPORT"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// 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 {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
p.Access = nil
|
||||
|
|
@ -166,7 +166,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
|
|||
return err
|
||||
}
|
||||
}
|
||||
p.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
|
||||
p.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +187,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
|
|||
return err
|
||||
}
|
||||
}
|
||||
c.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
|
||||
c.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +208,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
|
|||
return err
|
||||
}
|
||||
}
|
||||
m.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
|
||||
m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +229,7 @@ func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userI
|
|||
return err
|
||||
}
|
||||
}
|
||||
les.Access = buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction)
|
||||
les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(ok, reason, done, comp, tot, fraction))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +243,7 @@ func (s *Service) ApplyExamPrepAccessCatalogCourse(ctx context.Context, role dom
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cc.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
|
||||
cc.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ func (s *Service) ApplyExamPrepAccessUnit(ctx context.Context, role domain.Role,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
|
||||
u.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +271,7 @@ func (s *Service) ApplyExamPrepAccessModule(ctx context.Context, role domain.Rol
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
|
||||
m.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +285,7 @@ func (s *Service) ApplyExamPrepAccessLesson(ctx context.Context, role domain.Rol
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
les.Access = buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction)
|
||||
les.Access = finalizeOpenLearnerAccess(role, buildLMSEntityAccessFromFraction(true, "", done, comp, tot, fraction))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -524,6 +524,22 @@ func lmsProgressComplete(completed, total int32) bool {
|
|||
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 {
|
||||
c, t, pct, pctPrecise := lmsProgressCounts(completed, total, done)
|
||||
return &domain.LMSEntityAccess{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package lmsprogress
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestLMSProgressCounts(t *testing.T) {
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
Message: "Validation error",
|
||||
Error: "question_text is required",
|
||||
Message: "Invalid question_text",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -54,8 +61,8 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
|
|||
}
|
||||
|
||||
input := domain.CreateQuestionInput{
|
||||
QuestionText: req.QuestionText,
|
||||
QuestionType: req.QuestionType,
|
||||
QuestionText: questionText,
|
||||
QuestionType: questionType,
|
||||
DifficultyLevel: req.DifficultyLevel,
|
||||
Points: req.Points,
|
||||
Explanation: req.Explanation,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ type shortAnswerInput struct {
|
|||
}
|
||||
|
||||
type createQuestionReq struct {
|
||||
QuestionText string `json:"question_text" validate:"required"`
|
||||
QuestionText *string `json:"question_text,omitempty"`
|
||||
QuestionType string `json:"question_type"`
|
||||
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id"`
|
||||
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload"`
|
||||
|
|
@ -89,9 +89,16 @@ func normalizeRuntimeQuestionType(v string) string {
|
|||
return strings.ToUpper(strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
func optionalTrimmedString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*s)
|
||||
}
|
||||
|
||||
// CreateQuestion godoc
|
||||
// @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
|
||||
// @Accept 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{
|
||||
QuestionText: req.QuestionText,
|
||||
QuestionText: questionText,
|
||||
QuestionType: questionType,
|
||||
QuestionTypeDefinitionID: req.QuestionTypeDefinitionID,
|
||||
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)
|
||||
if req.QuestionType != nil {
|
||||
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{
|
||||
QuestionText: questionText,
|
||||
QuestionType: questionType,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user