Resolve question-set question-types to builder definitions.

Map legacy runtime types like AUDIO to catalog keys (e.g. audio_conversation_type) so the endpoint matches type-definitions API output.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-02 06:46:54 -07:00
parent a75700ffaa
commit 2605877f12
9 changed files with 211 additions and 16 deletions

View File

@ -104,13 +104,14 @@ WHERE qsi.set_id = $1
-- name: GetQuestionTypeCountsInSet :many -- name: GetQuestionTypeCountsInSet :many
SELECT SELECT
q.question_type_definition_id,
q.question_type, q.question_type,
COUNT(*)::bigint AS question_count COUNT(*)::bigint AS question_count
FROM question_set_items qsi FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id JOIN questions q ON q.id = qsi.question_id
WHERE qsi.set_id = $1 WHERE qsi.set_id = $1
AND q.status != 'ARCHIVED' AND q.status != 'ARCHIVED'
GROUP BY q.question_type GROUP BY q.question_type_definition_id, q.question_type
ORDER BY q.question_type; ORDER BY q.question_type;
-- name: GetQuestionSetsContainingQuestion :many -- name: GetQuestionSetsContainingQuestion :many

View File

@ -364,17 +364,19 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
const GetQuestionTypeCountsInSet = `-- name: GetQuestionTypeCountsInSet :many const GetQuestionTypeCountsInSet = `-- name: GetQuestionTypeCountsInSet :many
SELECT SELECT
q.question_type_definition_id,
q.question_type, q.question_type,
COUNT(*)::bigint AS question_count COUNT(*)::bigint AS question_count
FROM question_set_items qsi FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id JOIN questions q ON q.id = qsi.question_id
WHERE qsi.set_id = $1 WHERE qsi.set_id = $1
AND q.status != 'ARCHIVED' AND q.status != 'ARCHIVED'
GROUP BY q.question_type GROUP BY q.question_type_definition_id, q.question_type
ORDER BY q.question_type ORDER BY q.question_type
` `
type GetQuestionTypeCountsInSetRow struct { type GetQuestionTypeCountsInSetRow struct {
QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"`
QuestionType string `json:"question_type"` QuestionType string `json:"question_type"`
QuestionCount int64 `json:"question_count"` QuestionCount int64 `json:"question_count"`
} }
@ -388,7 +390,7 @@ func (q *Queries) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) (
var items []GetQuestionTypeCountsInSetRow var items []GetQuestionTypeCountsInSetRow
for rows.Next() { for rows.Next() {
var i GetQuestionTypeCountsInSetRow var i GetQuestionTypeCountsInSetRow
if err := rows.Scan(&i.QuestionType, &i.QuestionCount); err != nil { if err := rows.Scan(&i.QuestionTypeDefinitionID, &i.QuestionType, &i.QuestionCount); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)

View File

@ -213,6 +213,130 @@ func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string
return fmt.Errorf("%s", strings.Join(errs, "; ")) return fmt.Errorf("%s", strings.Join(errs, "; "))
} }
var legacyRuntimeToDefinitionKey = map[string]string{
"MCQ": "multiple_choice",
"TRUE_FALSE": "true_false",
"SHORT_ANSWER": "short_answer",
}
// ResolveQuestionTypeDefinitionForQuestion maps a stored question row to its builder definition.
// Legacy rows without question_type_definition_id are matched via runtime question_type and the catalog.
func ResolveQuestionTypeDefinitionForQuestion(definitionID *int64, runtimeQuestionType string, catalog []QuestionTypeDefinition) QuestionTypeDefinition {
byID := make(map[int64]QuestionTypeDefinition, len(catalog))
byKey := make(map[string]QuestionTypeDefinition, len(catalog))
for _, d := range catalog {
byID[d.ID] = d
byKey[d.Key] = d
}
if definitionID != nil && *definitionID > 0 {
if d, ok := byID[*definitionID]; ok {
return d
}
}
runtime := strings.ToUpper(strings.TrimSpace(runtimeQuestionType))
if key, ok := legacyRuntimeToDefinitionKey[runtime]; ok {
if d, ok := byKey[key]; ok {
return d
}
return QuestionTypeDefinition{Key: key, DisplayName: humanizeDefinitionKey(key)}
}
switch runtime {
case "AUDIO":
return resolveDefinitionForRuntimeAUDIO(catalog)
case "DYNAMIC":
return QuestionTypeDefinition{Key: "dynamic", DisplayName: "Dynamic"}
default:
if runtime == "" {
return QuestionTypeDefinition{Key: "unknown", DisplayName: "Unknown"}
}
key := strings.ToLower(runtime)
if d, ok := byKey[key]; ok {
return d
}
return QuestionTypeDefinition{Key: key, DisplayName: runtime}
}
}
func resolveDefinitionForRuntimeAUDIO(catalog []QuestionTypeDefinition) QuestionTypeDefinition {
var candidates []QuestionTypeDefinition
for _, d := range catalog {
if ResolveRuntimeQuestionTypeFromDefinition(d.Key, d.ResponseComponentKinds) == "AUDIO" {
candidates = append(candidates, d)
}
}
if len(candidates) == 0 {
return QuestionTypeDefinition{Key: "audio", DisplayName: "Audio"}
}
for _, d := range candidates {
if d.Key == "audio_conversation_type" {
return d
}
}
if len(candidates) == 1 {
return candidates[0]
}
for _, d := range candidates {
if !d.IsSystem {
return d
}
}
return candidates[0]
}
func humanizeDefinitionKey(key string) string {
parts := strings.Split(strings.ReplaceAll(key, "-", "_"), "_")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:])
}
return strings.Join(parts, " ")
}
// SummarizeQuestionSetQuestionTypes merges raw DB groups into definition-keyed counts for API responses.
func SummarizeQuestionSetQuestionTypes(groups []QuestionSetQuestionTypeGroup, catalog []QuestionTypeDefinition) []QuestionSetQuestionTypeCount {
merged := make(map[string]*QuestionSetQuestionTypeCount)
for _, g := range groups {
def := ResolveQuestionTypeDefinitionForQuestion(g.QuestionTypeDefinitionID, g.QuestionType, catalog)
entry, ok := merged[def.Key]
if !ok {
var defID *int64
if def.ID > 0 {
id := def.ID
defID = &id
}
merged[def.Key] = &QuestionSetQuestionTypeCount{
QuestionTypeDefinitionID: defID,
Key: def.Key,
DisplayName: def.DisplayName,
Count: g.Count,
}
continue
}
entry.Count += g.Count
}
out := make([]QuestionSetQuestionTypeCount, 0, len(merged))
for _, v := range merged {
out = append(out, *v)
}
sortQuestionSetQuestionTypeCounts(out)
return out
}
func sortQuestionSetQuestionTypeCounts(counts []QuestionSetQuestionTypeCount) {
sort.Slice(counts, func(i, j int) bool {
if counts[i].DisplayName == counts[j].DisplayName {
return counts[i].Key < counts[j].Key
}
return counts[i].DisplayName < counts[j].DisplayName
})
}
// ResolveRuntimeQuestionTypeFromDefinition derives the legacy runtime question_type code used by // ResolveRuntimeQuestionTypeFromDefinition derives the legacy runtime question_type code used by
// existing question execution paths. Empty string means the definition cannot be executed yet. // existing question execution paths. Empty string means the definition cannot be executed yet.
func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string) string { func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string) string {

View File

@ -139,3 +139,55 @@ func TestValidateDynamicPayloadAgainstDefinition_requiredMissing(t *testing.T) {
t.Fatalf("expected required element error, got %v", err) t.Fatalf("expected required element error, got %v", err)
} }
} }
func testQuestionTypeCatalog() []QuestionTypeDefinition {
return []QuestionTypeDefinition{
{ID: 1, Key: "multiple_choice", DisplayName: "Multiple Choice", IsSystem: true},
{ID: 2, Key: "true_false", DisplayName: "True / False", IsSystem: true},
{ID: 3, Key: "fill_in_the_blank", DisplayName: "Fill In The Blank", IsSystem: true},
{ID: 4, Key: "short_answer", DisplayName: "Short Answer", IsSystem: true},
{
ID: 10,
Key: "audio_conversation_type",
DisplayName: "Audio Conversation Type",
ResponseComponentKinds: []string{"AUDIO_RESPONSE", "TEXT_INPUT"},
},
}
}
func TestResolveQuestionTypeDefinitionForQuestion_legacyMCQ(t *testing.T) {
def := ResolveQuestionTypeDefinitionForQuestion(nil, "MCQ", testQuestionTypeCatalog())
if def.Key != "multiple_choice" {
t.Fatalf("expected multiple_choice, got %q", def.Key)
}
}
func TestResolveQuestionTypeDefinitionForQuestion_legacyAUDIO(t *testing.T) {
def := ResolveQuestionTypeDefinitionForQuestion(nil, "AUDIO", testQuestionTypeCatalog())
if def.Key != "audio_conversation_type" {
t.Fatalf("expected audio_conversation_type, got %q", def.Key)
}
}
func TestResolveQuestionTypeDefinitionForQuestion_linkedDefinition(t *testing.T) {
id := int64(10)
def := ResolveQuestionTypeDefinitionForQuestion(&id, "DYNAMIC", testQuestionTypeCatalog())
if def.Key != "audio_conversation_type" {
t.Fatalf("expected audio_conversation_type, got %q", def.Key)
}
}
func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) {
id := int64(10)
summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{
{QuestionType: "AUDIO", Count: 7},
{QuestionTypeDefinitionID: &id, QuestionType: "DYNAMIC", Count: 3},
}, testQuestionTypeCatalog())
if len(summary) != 1 {
t.Fatalf("expected 1 merged entry, got %d", len(summary))
}
if summary[0].Key != "audio_conversation_type" || summary[0].Count != 10 {
t.Fatalf("unexpected summary: %+v", summary[0])
}
}

View File

@ -120,9 +120,18 @@ type QuestionSetItem struct {
CreatedAt time.Time CreatedAt time.Time
} }
// QuestionSetQuestionTypeCount is one question_type present in a set with how many questions use it. // QuestionSetQuestionTypeGroup is a raw DB aggregate before resolving builder definitions.
type QuestionSetQuestionTypeGroup struct {
QuestionTypeDefinitionID *int64
QuestionType string
Count int64
}
// QuestionSetQuestionTypeCount is one builder question type present in a set with how many questions use it.
type QuestionSetQuestionTypeCount struct { type QuestionSetQuestionTypeCount struct {
QuestionType string `json:"question_type"` QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
Key string `json:"key"`
DisplayName string `json:"display_name"`
Count int64 `json:"count"` Count int64 `json:"count"`
} }

View File

@ -58,7 +58,7 @@ type QuestionStore interface {
RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error
UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error
CountQuestionsInSet(ctx context.Context, setID int64) (int64, error) CountQuestionsInSet(ctx context.Context, setID int64) (int64, error)
GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeCount, error) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeGroup, error)
GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error)
// User Personas in Question Sets // User Personas in Question Sets

View File

@ -1226,14 +1226,15 @@ func (s *Store) CountQuestionsInSet(ctx context.Context, setID int64) (int64, er
return s.queries.CountQuestionsInSet(ctx, setID) return s.queries.CountQuestionsInSet(ctx, setID)
} }
func (s *Store) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeCount, error) { func (s *Store) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeGroup, error) {
rows, err := s.queries.GetQuestionTypeCountsInSet(ctx, setID) rows, err := s.queries.GetQuestionTypeCountsInSet(ctx, setID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make([]domain.QuestionSetQuestionTypeCount, len(rows)) result := make([]domain.QuestionSetQuestionTypeGroup, len(rows))
for i, r := range rows { for i, r := range rows {
result[i] = domain.QuestionSetQuestionTypeCount{ result[i] = domain.QuestionSetQuestionTypeGroup{
QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID),
QuestionType: r.QuestionType, QuestionType: r.QuestionType,
Count: r.QuestionCount, Count: r.QuestionCount,
} }

View File

@ -193,10 +193,16 @@ func (s *Service) CountQuestionsInSet(ctx context.Context, setID int64) (int64,
} }
func (s *Service) GetQuestionTypesInSet(ctx context.Context, setID int64) (domain.QuestionSetQuestionTypesSummary, error) { func (s *Service) GetQuestionTypesInSet(ctx context.Context, setID int64) (domain.QuestionSetQuestionTypesSummary, error) {
counts, err := s.questionStore.GetQuestionTypeCountsInSet(ctx, setID) groups, err := s.questionStore.GetQuestionTypeCountsInSet(ctx, setID)
if err != nil { if err != nil {
return domain.QuestionSetQuestionTypesSummary{}, err return domain.QuestionSetQuestionTypesSummary{}, err
} }
active := "ACTIVE"
catalog, err := s.questionStore.ListQuestionTypeDefinitions(ctx, &active, true)
if err != nil {
return domain.QuestionSetQuestionTypesSummary{}, err
}
counts := domain.SummarizeQuestionSetQuestionTypes(groups, catalog)
var total int64 var total int64
for _, c := range counts { for _, c := range counts {
total += c.Count total += c.Count

View File

@ -1324,7 +1324,7 @@ func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error {
// GetQuestionTypesInSet godoc // GetQuestionTypesInSet godoc
// @Summary List question types in a question set // @Summary List question types in a question set
// @Description Returns distinct question_type values (with counts) for non-archived questions linked to the set. Use the question_set_id from a practice to see which types were used to build it. // @Description Returns distinct question type definitions (key, display_name, counts) for non-archived questions in the set. Legacy stored question_type values (e.g. AUDIO) are resolved to builder definitions when possible.
// @Tags question-set-items // @Tags question-set-items
// @Produce json // @Produce json
// @Param setId path int true "Question Set ID" // @Param setId path int true "Question Set ID"