diff --git a/go.mod b/go.mod index 54f23bb..003bfa2 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ toolchain go1.24.3 require ( github.com/a-h/templ v0.3.833 + github.com/bornholm/genai v0.0.0-20250522134458-696e8771bb84 github.com/caarlos0/env/v11 v11.3.1 - github.com/davecgh/go-spew v1.1.1 github.com/gabriel-vasile/mimetype v1.4.7 github.com/glebarez/sqlite v1.11.0 github.com/gorilla/sessions v1.1.1 @@ -23,11 +23,11 @@ require ( cloud.google.com/go/compute v1.20.1 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/RealAlexandreAI/json-repair v0.0.14 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/mux v1.6.2 // indirect @@ -35,8 +35,13 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/openai/openai-go v0.1.0-beta.10 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/stretchr/testify v1.10.0 // indirect + github.com/revrost/go-openrouter v0.0.0-20250414052218-c9123df8a97e // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect golang.org/x/net v0.39.0 // indirect diff --git a/go.sum b/go.sum index d27e130..cbbfe7d 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,12 @@ cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGB cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x518wl0bCsw0t0= +github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI= github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU= github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk= +github.com/bornholm/genai v0.0.0-20250522134458-696e8771bb84 h1:p8v5GSFZrOkpTMBnlvVp7YQaNnoKW/9C43rpD8Od8G0= +github.com/bornholm/genai v0.0.0-20250522134458-696e8771bb84/go.mod h1:W9kbOIXt50p7EL8qmjFy//gk6EzVHigsH5NnkfdTvZ8= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -64,6 +68,8 @@ github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwp github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/openai/openai-go v0.1.0-beta.10 h1:CknhGXe8aXQMRuqg255PFnWzgRY9nEryMxoNIBBM9tU= +github.com/openai/openai-go v0.1.0-beta.10/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -71,10 +77,22 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/revrost/go-openrouter v0.0.0-20250414052218-c9123df8a97e h1:sh6V3xdRzQDyqHI+3tolFjQKj0wlqybS6q8cHnKqLaA= +github.com/revrost/go-openrouter v0.0.0-20250414052218-c9123df8a97e/go.mod h1:HRfNDVNl2YQCfH9k4d2LGRZQWs9Da5K6ByWfDqwQAkY= github.com/samber/slog-http v1.4.4 h1:NuENLy39Lk6b7wfj9cG9R5C/JLZR4t6pb9cwlyroybI= github.com/samber/slog-http v1.4.4/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= diff --git a/internal/config/config.go b/internal/config/config.go index d3d99bc..fbfc0e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) { diff --git a/internal/config/llm.go b/internal/config/llm.go new file mode 100644 index 0000000..5a0b541 --- /dev/null +++ b/internal/config/llm.go @@ -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"` +} diff --git a/internal/http/handler/api/handler.go b/internal/http/handler/api/handler.go new file mode 100644 index 0000000..b63ea50 --- /dev/null +++ b/internal/http/handler/api/handler.go @@ -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{} diff --git a/internal/http/handler/api/presentation.go b/internal/http/handler/api/presentation.go new file mode 100644 index 0000000..6dda699 --- /dev/null +++ b/internal/http/handler/api/presentation.go @@ -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 + } +} diff --git a/internal/http/handler/webui/common/assets/panda.png b/internal/http/handler/webui/common/assets/panda.png new file mode 100644 index 0000000..34e8adc Binary files /dev/null and b/internal/http/handler/webui/common/assets/panda.png differ diff --git a/internal/setup/api_handler.go b/internal/setup/api_handler.go new file mode 100644 index 0000000..7045113 --- /dev/null +++ b/internal/setup/api_handler.go @@ -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 +} diff --git a/internal/setup/helper.go b/internal/setup/helper.go new file mode 100644 index 0000000..b410985 --- /dev/null +++ b/internal/setup/helper.go @@ -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 + } +} diff --git a/internal/setup/http_server.go b/internal/setup/http_server.go index 86de74e..4d1d7d2 100644 --- a/internal/setup/http_server.go +++ b/internal/setup/http_server.go @@ -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 diff --git a/internal/setup/quiz_handler.go b/internal/setup/quiz_handler.go index 2e2a511..6db74ee 100644 --- a/internal/setup/quiz_handler.go +++ b/internal/setup/quiz_handler.go @@ -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) } diff --git a/internal/setup/store.go b/internal/setup/store.go new file mode 100644 index 0000000..438500e --- /dev/null +++ b/internal/setup/store.go @@ -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 + +})