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