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
SELECT
q.question_type_definition_id,
q.question_type,
COUNT(*)::bigint AS question_count
FROM question_set_items qsi
JOIN questions q ON q.id = qsi.question_id
WHERE qsi.set_id = $1
AND q.status != 'ARCHIVED'
GROUP BY q.question_type
GROUP BY q.question_type_definition_id, q.question_type
ORDER BY q.question_type;
-- name: GetQuestionSetsContainingQuestion :many

View File

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

View File

@ -213,6 +213,130 @@ func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string
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
// existing question execution paths. Empty string means the definition cannot be executed yet.
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)
}
}
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
}
// 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 {
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"`
}

View File

@ -58,7 +58,7 @@ type QuestionStore interface {
RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error
UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) 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)
// 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)
}
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)
if err != nil {
return nil, err
}
result := make([]domain.QuestionSetQuestionTypeCount, len(rows))
result := make([]domain.QuestionSetQuestionTypeGroup, len(rows))
for i, r := range rows {
result[i] = domain.QuestionSetQuestionTypeCount{
result[i] = domain.QuestionSetQuestionTypeGroup{
QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID),
QuestionType: r.QuestionType,
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) {
counts, err := s.questionStore.GetQuestionTypeCountsInSet(ctx, setID)
groups, err := s.questionStore.GetQuestionTypeCountsInSet(ctx, setID)
if err != nil {
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
for _, c := range counts {
total += c.Count

View File

@ -1324,7 +1324,7 @@ func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error {
// GetQuestionTypesInSet godoc
// @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
// @Produce json
// @Param setId path int true "Question Set ID"