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:
parent
a75700ffaa
commit
2605877f12
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user