feat: PDF_ATTACHMENT stimulus, dynamic question_text rules, admin builder docs

Add PDF_ATTACHMENT stimulus kind and MinIO pdf upload (media_type=pdf) for question-side PDFs.

Reject top-level question_text on DYNAMIC create/update; omit it from API responses and derive stored text from stimulus only.

Expand DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md with full API request/response reference and workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-04 11:07:02 -07:00
parent 08a2886654
commit 33355a4b23
8 changed files with 1204 additions and 326 deletions

File diff suppressed because it is too large Load Diff

View File

@ -65,7 +65,7 @@ If you create/update dynamic definitions:
## Step 0 (Optional): Upload Media
Use this when question content references audio/image URLs.
Use this when question content references audio/image/PDF URLs (e.g. dynamic `IMAGE`, `AUDIO_CLIP`, or `PDF_ATTACHMENT` stimulus).
### Endpoint
@ -74,7 +74,7 @@ Use this when question content references audio/image URLs.
### Form fields
- `file`: binary
- `media_type`: `image` or `audio` or `video`
- `media_type`: `image`, `audio`, `video`, or `pdf` (PDF is stored in MinIO; response includes presigned `url` and `object_key`)
### Example success response (shape)

View File

@ -22,6 +22,8 @@ const (
StimulusSelectMissingWords StimulusComponentKind = "SELECT_MISSING_WORDS"
StimulusTable StimulusComponentKind = "TABLE"
StimulusFlowChart StimulusComponentKind = "FLOW_CHART"
// StimulusPDFAttachment is question-side PDF content (URL from MinIO upload or HTTPS).
StimulusPDFAttachment StimulusComponentKind = "PDF_ATTACHMENT"
)
// Response-side components for the question-type builder (Section B — answer types).
@ -75,6 +77,7 @@ var (
StimulusSelectMissingWords,
StimulusTable,
StimulusFlowChart,
StimulusPDFAttachment,
}
stimulusSet map[string]struct{}
@ -564,27 +567,48 @@ var stimulusTextKindsForQuestionText = []string{
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
// UsesDynamicQuestionPayload reports whether the runtime type stores prompt content in dynamic_payload only.
func UsesDynamicQuestionPayload(questionType string) bool {
return strings.ToUpper(strings.TrimSpace(questionType)) == "DYNAMIC"
}
// ValidateQuestionTextNotAllowedForDynamic rejects top-level question_text on DYNAMIC create/update requests.
func ValidateQuestionTextNotAllowedForDynamic(questionType string, explicit string) error {
if !UsesDynamicQuestionPayload(questionType) {
return nil
}
if derived := stimulusTextFromPayload(payload); derived != "" {
if strings.TrimSpace(explicit) != "" {
return fmt.Errorf("question_text is not used for DYNAMIC questions; set prompt text in dynamic_payload stimulus (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)")
}
return nil
}
// ResolveDynamicStoredQuestionText returns the questions.question_text column value for DYNAMIC rows
// (search/activity logs only; clients read prompt text from dynamic_payload).
func ResolveDynamicStoredQuestionText(payload *DynamicQuestionPayload, existing string) (string, error) {
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")
return "", fmt.Errorf("dynamic_payload must include prompt text (QUESTION_TEXT, INSTRUCTION, or TEXT_PASSAGE)")
}
func stimulusTextFromPayload(payload *DynamicQuestionPayload) string {
// QuestionTextJSONField returns question_text for API responses. Omitted (nil) for DYNAMIC questions.
func QuestionTextJSONField(questionType string, stored string) *string {
if UsesDynamicQuestionPayload(questionType) {
return nil
}
stored = strings.TrimSpace(stored)
if stored == "" {
return nil
}
return &stored
}
// StimulusTextFromPayload extracts the first non-empty prompt string from allowed stimulus kinds.
func StimulusTextFromPayload(payload *DynamicQuestionPayload) string {
if payload == nil {
return ""
}

View File

@ -5,6 +5,20 @@ import (
"testing"
)
func TestStimulusComponentCatalog_includesPDFAttachment(t *testing.T) {
catalog := StimulusComponentCatalog()
found := false
for _, k := range catalog {
if k == string(StimulusPDFAttachment) {
found = true
break
}
}
if !found {
t.Fatalf("expected PDF_ATTACHMENT in stimulus catalog, got %v", catalog)
}
}
func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"INSTRUCTION", "IMAGE"},
@ -15,6 +29,16 @@ func TestValidateDynamicQuestionTypeDefinition_valid(t *testing.T) {
}
}
func TestValidateDynamicQuestionTypeDefinition_pdfAttachmentStimulus(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"QUESTION_TEXT", "PDF_ATTACHMENT"},
[]string{"SHORT_ANSWER"},
)
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
func TestValidateDynamicQuestionTypeDefinition_unknownStimulus(t *testing.T) {
err := ValidateDynamicQuestionTypeDefinition(
[]string{"NOT_A_KIND"},
@ -177,58 +201,65 @@ 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 TestValidateQuestionTextNotAllowedForDynamic(t *testing.T) {
if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", "nope"); err == nil {
t.Fatal("expected error when question_text sent for DYNAMIC")
}
if err := ValidateQuestionTextNotAllowedForDynamic("DYNAMIC", ""); err != nil {
t.Fatalf("expected nil for empty explicit, got %v", err)
}
if err := ValidateQuestionTextNotAllowedForDynamic("MCQ", "ok"); err != nil {
t.Fatalf("expected nil for legacy, got %v", err)
}
}
func TestResolveQuestionTextForWrite_fromQUESTION_TEXTStimulus(t *testing.T) {
func TestResolveDynamicStoredQuestionText_fromQUESTION_TEXTStimulus(t *testing.T) {
payload := &DynamicQuestionPayload{
Stimulus: []DynamicElementInstance{
{ID: "prompt", Kind: "QUESTION_TEXT", Value: "Pick the correct answer."},
},
}
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "")
got, err := ResolveDynamicStoredQuestionText(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) {
func TestResolveDynamicStoredQuestionText_fromINSTRUCTIONStimulus(t *testing.T) {
payload := &DynamicQuestionPayload{
Stimulus: []DynamicElementInstance{
{ID: "prompt", Kind: "INSTRUCTION", Value: "Choose true or false."},
},
}
got, err := ResolveQuestionTextForWrite("", "DYNAMIC", payload, "")
got, err := ResolveDynamicStoredQuestionText(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{}, "")
func TestResolveDynamicStoredQuestionText_missingText(t *testing.T) {
_, err := ResolveDynamicStoredQuestionText(&DynamicQuestionPayload{}, "")
if err == nil {
t.Fatal("expected error when no question text source")
t.Fatal("expected error when no prompt in payload")
}
}
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")
func TestResolveDynamicStoredQuestionText_updateKeepsExisting(t *testing.T) {
got, err := ResolveDynamicStoredQuestionText(nil, "Previous title")
if err != nil || got != "Previous title" {
t.Fatalf("expected existing text, got %q err=%v", got, err)
}
}
func TestQuestionTextJSONField_omitsDynamic(t *testing.T) {
if got := QuestionTextJSONField("DYNAMIC", "stored"); got != nil {
t.Fatalf("expected nil for DYNAMIC, got %v", got)
}
if got := QuestionTextJSONField("MCQ", "Pick one"); got == nil || *got != "Pick one" {
t.Fatalf("expected MCQ text, got %v", got)
}
}
func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) {
id := int64(10)
summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{

View File

@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
@ -174,11 +175,11 @@ func (h *Handler) RefreshFileURL(c *fiber.Ctx) error {
})
}
// UploadMedia uploads an image/audio/video file and returns its URL and key.
// UploadMedia uploads an image/audio/video/pdf file and returns its URL and key.
// @Summary Upload media file
// @Tags files
// @Accept multipart/form-data
// @Param media_type formData string true "Media type: image|audio|video"
// @Param media_type formData string true "Media type: image|audio|video|pdf"
// @Param file formData file true "Media file"
// @Success 200 {object} domain.Response
// @Router /api/v1/files/upload [post]
@ -205,10 +206,10 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
if mediaType == "" {
mediaType = "file"
}
if mediaType != "image" && mediaType != "audio" && mediaType != "video" {
if mediaType != "image" && mediaType != "audio" && mediaType != "video" && mediaType != "pdf" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid media_type",
Error: "media_type must be one of: image, audio, video",
Error: "media_type must be one of: image, audio, video, pdf",
})
}
@ -218,6 +219,8 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
maxSize = 10 * 1024 * 1024
case "audio":
maxSize = 50 * 1024 * 1024
case "pdf":
maxSize = 25 * 1024 * 1024
case "video":
maxSize = 500 * 1024 * 1024
}
@ -226,9 +229,9 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
Message: "Vimeo service is not available for video uploads",
})
}
if (mediaType == "image" || mediaType == "audio") && h.minioSvc == nil {
if (mediaType == "image" || mediaType == "audio" || mediaType == "pdf") && h.minioSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "MinIO service is not available for image/audio uploads",
Message: "MinIO service is not available for image/audio/pdf uploads",
})
}
@ -396,6 +399,15 @@ func normalizeAndValidateMediaContentType(mediaType, contentType, fileName strin
if !strings.HasPrefix(contentType, "video/") {
return "", fmt.Errorf("only video files are allowed")
}
case "pdf":
if contentType == "application/octet-stream" {
if ext := strings.ToLower(path.Ext(fileName)); ext == ".pdf" {
contentType = "application/pdf"
}
}
if contentType != "application/pdf" {
return "", fmt.Errorf("only PDF files are allowed")
}
}
return contentType, nil

View File

@ -0,0 +1,20 @@
package handlers
import "testing"
func TestNormalizeAndValidateMediaContentType_pdf(t *testing.T) {
got, err := normalizeAndValidateMediaContentType("pdf", "application/pdf", "reading-passage.pdf")
if err != nil || got != "application/pdf" {
t.Fatalf("expected application/pdf, got %q err=%v", got, err)
}
got, err = normalizeAndValidateMediaContentType("pdf", "application/octet-stream", "notes.pdf")
if err != nil || got != "application/pdf" {
t.Fatalf("expected pdf from extension, got %q err=%v", got, err)
}
_, err = normalizeAndValidateMediaContentType("pdf", "image/png", "file.png")
if err == nil {
t.Fatal("expected error for non-pdf content")
}
}

View File

@ -28,12 +28,7 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
}
questionType := normalizeRuntimeQuestionType(req.QuestionType)
questionText, err := domain.ResolveQuestionTextForWrite(
optionalTrimmedString(req.QuestionText),
questionType,
req.DynamicPayload,
"",
)
questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, req.DynamicPayload, "")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_text",
@ -88,7 +83,7 @@ func (h *Handler) CreateAssessmentQuestion(c *fiber.Ctx) error {
Success: true,
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
@ -136,7 +131,7 @@ func (h *Handler) ListAssessmentQuestions(c *fiber.Ctx) error {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
@ -207,7 +202,7 @@ func (h *Handler) GetAssessmentQuestionByID(c *fiber.Ctx) error {
Message: "Question fetched successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,

View File

@ -58,7 +58,7 @@ type shortAnswerRes struct {
type questionRes struct {
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
QuestionText *string `json:"question_text,omitempty"`
QuestionType string `json:"question_type"`
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
@ -96,9 +96,36 @@ func optionalTrimmedString(s *string) string {
return strings.TrimSpace(*s)
}
func resolveStoredQuestionText(questionType string, explicit *string, payload *domain.DynamicQuestionPayload, existing string) (string, error) {
exp := optionalTrimmedString(explicit)
if err := domain.ValidateQuestionTextNotAllowedForDynamic(questionType, exp); err != nil {
return "", err
}
if domain.UsesDynamicQuestionPayload(questionType) {
return domain.ResolveDynamicStoredQuestionText(payload, existing)
}
if exp == "" {
return "", fmt.Errorf("question_text is required")
}
return exp, nil
}
func questionTextField(questionType string, stored string) *string {
return domain.QuestionTextJSONField(questionType, stored)
}
func activityLogQuestionSummary(questionType string, stored string, payload *domain.DynamicQuestionPayload) string {
if domain.UsesDynamicQuestionPayload(questionType) {
if s := domain.StimulusTextFromPayload(payload); s != "" {
return s
}
}
return stored
}
// CreateQuestion godoc
// @Summary Create a new question
// @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).
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER). DYNAMIC questions must not send question_text; use dynamic_payload stimulus instead. Legacy types require question_text.
// @Tags questions
// @Accept json
// @Produce json
@ -193,12 +220,7 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
})
}
questionText, err := domain.ResolveQuestionTextForWrite(
optionalTrimmedString(req.QuestionText),
questionType,
req.DynamicPayload,
"",
)
questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, req.DynamicPayload, "")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_text",
@ -240,13 +262,13 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
"question_type": question.QuestionType,
"question_type_definition_id": req.QuestionTypeDefinitionID,
})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+question.QuestionText, meta, &ip, &ua)
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+activityLogQuestionSummary(question.QuestionType, question.QuestionText, question.DynamicPayload), meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question created successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
@ -319,7 +341,7 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
Message: "Question retrieved successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,
@ -386,7 +408,7 @@ func (h *Handler) ListQuestions(c *fiber.Ctx) error {
for _, q := range questions {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload,
@ -446,7 +468,7 @@ func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
for _, q := range questions {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionText: questionTextField(q.QuestionType, q.QuestionText),
QuestionType: q.QuestionType,
QuestionTypeDefinitionID: q.QuestionTypeDefinitionID,
DynamicPayload: q.DynamicPayload,
@ -604,12 +626,7 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
})
}
questionText, err := domain.ResolveQuestionTextForWrite(
optionalTrimmedString(req.QuestionText),
questionType,
effectiveDynamicPayload,
existingQuestion.QuestionText,
)
questionText, err := resolveStoredQuestionText(questionType, req.QuestionText, effectiveDynamicPayload, existingQuestion.QuestionText)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_text",
@ -1245,7 +1262,7 @@ type questionSetItemRes struct {
SetID int64 `json:"set_id"`
QuestionID int64 `json:"question_id"`
DisplayOrder int32 `json:"display_order"`
QuestionText string `json:"question_text"`
QuestionText *string `json:"question_text,omitempty"`
QuestionType string `json:"question_type"`
DynamicPayload *domain.DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
@ -1274,7 +1291,7 @@ func questionSetItemsToRes(items []domain.QuestionSetItemWithQuestion) []questio
SetID: item.SetID,
QuestionID: item.QuestionID,
DisplayOrder: item.DisplayOrder,
QuestionText: item.QuestionText,
QuestionText: questionTextField(item.QuestionType, item.QuestionText),
QuestionType: item.QuestionType,
DynamicPayload: item.DynamicPayload,
DifficultyLevel: item.DifficultyLevel,
@ -1477,7 +1494,7 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
questionResponses = append(questionResponses, questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
QuestionType: question.QuestionType,
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
DynamicPayload: question.DynamicPayload,