feat: initial commit

This commit is contained in:
2025-06-15 23:22:54 +02:00
parent 21b334bc70
commit b080f3eb55
12 changed files with 355 additions and 23 deletions

View File

@ -11,6 +11,7 @@ type Config struct {
HTTP HTTP `envPrefix:"HTTP_"`
Store Store `envPrefix:"STORE_"`
Quiz Quiz `envPrefix:"QUIZ_"`
LLM LLM `envPrefix:"LLM_"`
}
func Parse() (*Config, error) {

10
internal/config/llm.go Normal file
View File

@ -0,0 +1,10 @@
package config
import "github.com/bornholm/genai/llm/provider"
type LLM struct {
Provider provider.Name `env:"PROVIDER" envDefault:"mistral"`
BaseURL string `env:"BASE_URL" envDefault:"https://api.mistral.ai/v1/"`
APIKey string `env:"API_KEY"`
Model string `env:"MODEL" envDefault:"mistral-small-latest"`
}

View File

@ -0,0 +1,41 @@
package api
import (
"net/http"
"time"
"forge.cadoles.com/wpetit/kouiz/internal/store"
"forge.cadoles.com/wpetit/kouiz/internal/timex"
"github.com/bornholm/genai/llm"
)
type Handler struct {
mux *http.ServeMux
store *store.Store
llm llm.Client
playInterval time.Duration
playPeriod timex.PeriodType
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func NewHandler(store *store.Store, llm llm.Client, playInterval time.Duration, playPeriod timex.PeriodType) *Handler {
h := &Handler{
mux: http.NewServeMux(),
store: store,
llm: llm,
playInterval: playInterval,
playPeriod: playPeriod,
}
h.mux.HandleFunc("GET /presentation/leaderboard", h.getLeaderboardPresentation)
h.mux.HandleFunc("GET /presentation/turn", h.getTurnPresentation)
return h
}
var _ http.Handler = &Handler{}

View File

@ -0,0 +1,161 @@
package api
import (
"io"
"log"
"net/http"
"forge.cadoles.com/wpetit/kouiz/internal/store"
"github.com/bornholm/genai/llm"
"github.com/bornholm/genai/llm/prompt"
"github.com/pkg/errors"
"gorm.io/gorm"
)
const systemPromptTemplate string = `
Tu es "Panda", un présentateur de jeu télévisé charismatique et plein d'énergie qui anime un jeu de question/réponse en ligne. Ta personnalité est :
- Enthousiaste et dynamique : Tu t'exprimes avec beaucoup d'énergie, utilises des exclamations et des expressions colorées
- Bienveillant mais taquin : Tu encourages tous les joueurs tout en les chambrant gentiment
- Théâtral : Tu dramatises les situations, crées du suspense même pour les scores
- Métaphoriquement créatif : Tu utilises des comparaisons amusantes liées au monde du panda, de la nature ou de la culture pop
Ton style :
- Utilise des expressions comme "Extraordinaire !", "Incroyable retournement de situation !"
- Varie tes présentations : parfois comme un commentateur sportif, parfois comme un chroniqueur dramatique
- Ajoute des petites blagues ou références pop culture
- Maintiens le suspense même pour des écarts de score importants
- Format attendu : Présente les situations de manière narrative et engageante.
`
const leaderboardPromptTemplate string = `
Fais le bilan des scores du jeu de manière captivante et divertissante en 1 court paragraphe.
## Liste des joueurs
{{ range $rank, $player := .Players }}
### {{ $rank }}. {{ $player.Name }}
- **Score:** {{ $player.Score }}
{{ end }}
`
func (h *Handler) getLeaderboardPresentation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
systemPrompt, err := prompt.Template[any](systemPromptTemplate, nil)
if err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
var players []*store.Player
err = h.store.Do(ctx, func(db *gorm.DB) error {
err := db.Model(&store.Player{}).
Select("id", "name", "score").
Order("score DESC").Find(&players).
Error
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
userPrompt, err := prompt.Template(leaderboardPromptTemplate, struct {
Players []*store.Player
}{
Players: players,
})
if err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
res, err := h.llm.ChatCompletion(ctx,
llm.WithMessages(
llm.NewMessage(llm.RoleSystem, systemPrompt),
llm.NewMessage(llm.RoleUser, userPrompt),
),
)
if err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if _, err := io.WriteString(w, res.Message().Content()); err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
return
}
}
const turnPromptTemplate string = `
Présente le tour de jeu actuel en créant du suspense et en motivant les joueurs à participer en 1 court paragraphe.
## Tour #{{ .Turn.ID }}
### Thématiques disponibles pour ce tour
{{ range .Turn.Entries }}
#### {{ .Category.Theme }}
- **Difficulté:** {{ .Level }}
- **Catégorie:** {{ .Category.Name }}
- **Description de la catégorie:** {{ .Category.Description }}
{{ end }}
`
func (h *Handler) getTurnPresentation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
systemPrompt, err := prompt.Template[any](systemPromptTemplate, nil)
if err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
currentTurn, err := h.store.GetQuizTurn(ctx, h.playInterval, h.playPeriod)
if err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
userPrompt, err := prompt.Template(turnPromptTemplate, struct {
Turn *store.QuizTurn
}{
Turn: currentTurn,
})
if err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
res, err := h.llm.ChatCompletion(ctx,
llm.WithMessages(
llm.NewMessage(llm.RoleSystem, systemPrompt),
llm.NewMessage(llm.RoleUser, userPrompt),
),
)
if err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if _, err := io.WriteString(w, res.Message().Content()); err != nil {
log.Printf("[ERROR] %+v", errors.WithStack(err))
return
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

View File

@ -0,0 +1,31 @@
package setup
import (
"context"
"forge.cadoles.com/wpetit/kouiz/internal/config"
"forge.cadoles.com/wpetit/kouiz/internal/http/handler/api"
"github.com/bornholm/genai/llm/provider"
"github.com/pkg/errors"
_ "github.com/bornholm/genai/llm/provider/all"
)
func NewAPIHandlerFromConfig(ctx context.Context, conf *config.Config) (*api.Handler, error) {
store, err := getStoreFromConfig(ctx, conf)
if err != nil {
return nil, errors.WithStack(err)
}
llm, err := provider.Create(ctx, provider.WithChatCompletionOptions(provider.ClientOptions{
Provider: conf.LLM.Provider,
BaseURL: conf.LLM.BaseURL,
APIKey: conf.LLM.APIKey,
Model: conf.LLM.Model,
}))
if err != nil {
return nil, errors.WithStack(err)
}
return api.NewHandler(store, llm, conf.Quiz.PlayInterval, conf.Quiz.PlayPeriod), nil
}

34
internal/setup/helper.go Normal file
View File

@ -0,0 +1,34 @@
package setup
import (
"context"
"sync"
"forge.cadoles.com/wpetit/kouiz/internal/config"
"github.com/pkg/errors"
)
func createFromConfigOnce[T any](factory func(ctx context.Context, conf *config.Config) (T, error)) func(ctx context.Context, conf *config.Config) (T, error) {
var (
once sync.Once
service T
onceErr error
)
return func(ctx context.Context, conf *config.Config) (T, error) {
once.Do(func() {
srv, err := factory(ctx, conf)
if err != nil {
onceErr = errors.WithStack(err)
return
}
service = srv
})
if onceErr != nil {
return *new(T), onceErr
}
return service, nil
}
}

View File

@ -2,25 +2,37 @@ package setup
import (
"context"
"net/http"
"forge.cadoles.com/wpetit/kouiz/internal/config"
"forge.cadoles.com/wpetit/kouiz/internal/http"
kouizHTTP "forge.cadoles.com/wpetit/kouiz/internal/http"
"github.com/pkg/errors"
)
func NewHTTPServerFromConfig(ctx context.Context, conf *config.Config) (*http.Server, error) {
func NewHTTPServerFromConfig(ctx context.Context, conf *config.Config) (*kouizHTTP.Server, error) {
// Configure Web UI handler
webui, err := NewWebUIHandlerFromConfig(ctx, conf)
if err != nil {
return nil, errors.Wrap(err, "could not configure webui handler from config")
}
// Configure API handler
api, err := NewAPIHandlerFromConfig(ctx, conf)
if err != nil {
return nil, errors.Wrap(err, "could not configure webui handler from config")
}
mux := http.NewServeMux()
mux.Handle("/", webui)
mux.Handle("/api/", http.StripPrefix("/api", api))
// Create HTTP server
server := http.NewServer(
webui,
http.WithAddress(conf.HTTP.Address),
http.WithBaseURL(conf.HTTP.BaseURL),
server := kouizHTTP.NewServer(
mux,
kouizHTTP.WithAddress(conf.HTTP.Address),
kouizHTTP.WithBaseURL(conf.HTTP.BaseURL),
)
return server, nil

View File

@ -3,34 +3,21 @@ package setup
import (
"context"
"fmt"
"time"
"forge.cadoles.com/wpetit/kouiz/internal/config"
"forge.cadoles.com/wpetit/kouiz/internal/http/handler/webui/quiz"
"forge.cadoles.com/wpetit/kouiz/internal/store"
"forge.cadoles.com/wpetit/kouiz/misc/quiz/openquizzdb"
"github.com/glebarez/sqlite"
"github.com/pkg/errors"
"gorm.io/datatypes"
"gorm.io/gorm"
)
func NewQuizHandlerFromConfig(ctx context.Context, conf *config.Config) (*quiz.Handler, error) {
db, err := gorm.Open(sqlite.Open(string(conf.Store.DSN)), &gorm.Config{
NowFunc: func() time.Time {
return time.Now().UTC()
},
})
store, err := getStoreFromConfig(ctx, conf)
if err != nil {
return nil, errors.WithStack(err)
}
if conf.Store.Debug {
db = db.Debug()
}
store := store.New(db)
if err := loadEmbeddedQuizz(ctx, store); err != nil {
return nil, errors.WithStack(err)
}

32
internal/setup/store.go Normal file
View File

@ -0,0 +1,32 @@
package setup
import (
"context"
"time"
"forge.cadoles.com/wpetit/kouiz/internal/config"
"forge.cadoles.com/wpetit/kouiz/internal/store"
"github.com/glebarez/sqlite"
"github.com/pkg/errors"
"gorm.io/gorm"
)
var getStoreFromConfig = createFromConfigOnce(func(ctx context.Context, conf *config.Config) (*store.Store, error) {
db, err := gorm.Open(sqlite.Open(string(conf.Store.DSN)), &gorm.Config{
NowFunc: func() time.Time {
return time.Now().UTC()
},
})
if err != nil {
return nil, errors.WithStack(err)
}
if conf.Store.Debug {
db = db.Debug()
}
store := store.New(db)
return store, nil
})