package scalebank import ( _ "embed" "encoding/json" "fmt" "strconv" "sync" "github.com/google/uuid" ) //go:embed curated.json var curatedJSON []byte var ( bankNS = uuid.MustParse("6b1e9c2a-4d5f-4a8b-9c0d-1e2f3a4b5c6d") once sync.Once root *Root loadErr error ) // Root is the curated bank file. type Root struct { Version int `json:"version"` Categories []Category `json:"categories"` Scales []Scale `json:"scales"` } // Category groups scales for explore UI. type Category struct { Key string `json:"key"` Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` Slugs []string `json:"slugs"` Featured []string `json:"featured"` } // Scale is one exploration instrument. type Scale struct { Slug string `json:"slug"` Category string `json:"category"` Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` QuestionCount int `json:"question_count"` Questions []Question `json:"questions"` Disclaimer string `json:"disclaimer"` } // Question is prompt + options. type Question struct { Prompt string `json:"prompt"` Options []Option `json:"options"` } // Option is a choice. type Option struct { Key string `json:"key"` Text string `json:"text"` } // CatalogOut is explore bank payload. type CatalogOut struct { Featured []FeaturedItem `json:"featured"` Categories []CategoryCard `json:"categories"` } // FeaturedItem is icon+title tile. type FeaturedItem struct { Slug string `json:"slug"` Title string `json:"title"` Description string `json:"description"` CategoryKey string `json:"category_key"` Icon string `json:"icon"` Path string `json:"path"` } // CategoryCard is a category row / detail header. type CategoryCard struct { Key string `json:"key"` Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` Count int `json:"count"` Featured []FeaturedItem `json:"featured"` Path string `json:"path"` } // ScaleListItem is a scale in a category list. type ScaleListItem struct { Slug string `json:"slug"` Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` QuestionCount int `json:"question_count"` Path string `json:"path"` } func load() (*Root, error) { once.Do(func() { var r Root if err := json.Unmarshal(curatedJSON, &r); err != nil { loadErr = err return } root = &r }) return root, loadErr } // MustLoad panics only in tests if embed broken; production returns error via helpers. func MustLoad() *Root { r, err := load() if err != nil || r == nil { panic(fmt.Sprintf("scalebank: %v", err)) } return r } func bySlug(r *Root) map[string]Scale { m := make(map[string]Scale, len(r.Scales)) for _, s := range r.Scales { m[s.Slug] = s } return m } func catIcon(r *Root, key string) string { for _, c := range r.Categories { if c.Key == key { return c.Icon } } return "mbti" } func scaleIcon(s Scale) string { if s.Icon != "" { return s.Icon } return "mbti" } // Has reports whether slug is in the curated bank. func Has(slug string) bool { r, err := load() if err != nil || r == nil { return false } _, ok := bySlug(r)[slug] return ok } // Catalog builds featured tiles + category cards. func Catalog() (*CatalogOut, error) { r, err := load() if err != nil { return nil, err } m := bySlug(r) out := &CatalogOut{} for _, c := range r.Categories { card := CategoryCard{ Key: c.Key, Title: c.Title, Description: c.Description, Icon: c.Icon, Count: len(c.Slugs), Path: "/explore/bank/" + c.Key, } for _, slug := range c.Featured { s, ok := m[slug] if !ok { continue } item := FeaturedItem{ Slug: s.Slug, Title: s.Title, Description: s.Description, CategoryKey: c.Key, Icon: scaleIcon(s), Path: "/scales/" + s.Slug, } card.Featured = append(card.Featured, item) out.Featured = append(out.Featured, item) } out.Categories = append(out.Categories, card) } return out, nil } // CategoryScales lists scales in a category. func CategoryScales(key string) (*CategoryCard, []ScaleListItem, error) { r, err := load() if err != nil { return nil, nil, err } m := bySlug(r) for _, c := range r.Categories { if c.Key != key { continue } card := CategoryCard{ Key: c.Key, Title: c.Title, Description: c.Description, Icon: c.Icon, Count: len(c.Slugs), Path: "/explore/bank/" + c.Key, } items := make([]ScaleListItem, 0, len(c.Slugs)) for _, slug := range c.Slugs { s, ok := m[slug] if !ok { continue } items = append(items, ScaleListItem{ Slug: s.Slug, Title: s.Title, Description: s.Description, Icon: scaleIcon(s), QuestionCount: s.QuestionCount, Path: "/scales/" + s.Slug, }) } return &card, items, nil } return nil, nil, fmt.Errorf("category not found") } // Get returns scale detail questions in repository-compatible shape fields. func Get(slug string) (*Scale, error) { r, err := load() if err != nil { return nil, err } s, ok := bySlug(r)[slug] if !ok { return nil, fmt.Errorf("scale not found") } cp := s return &cp, nil } // QuestionID stable id for answer keys. func QuestionID(slug string, index int) uuid.UUID { return uuid.NewSHA1(bankNS, []byte(fmt.Sprintf("%s/%d", slug, index))) } // ScoreSum maps option keys (1..n) to a soft exploration band. func ScoreSum(answers map[string]string, questionCount int) (styleKey, label string, score, ceiling int) { sum := 0 maxOpt := 1 for _, v := range answers { n, err := strconv.Atoi(v) if err != nil || n < 1 { continue } sum += n if n > maxOpt { maxOpt = n } } ceiling = questionCount * maxOpt if ceiling <= 0 { ceiling = questionCount * 5 } pct := 0 if ceiling > 0 { pct = sum * 100 / ceiling } switch { case pct >= 75: return "high", "感受偏强烈型", sum, ceiling case pct >= 45: return "mid", "节奏适中型", sum, ceiling default: return "low", "感受偏轻柔型", sum, ceiling } }