mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 15:51:09 +00:00
trigger endpoint and retry suggestions
This commit is contained in:
@@ -119,12 +119,14 @@ func parseMovieSuggestions(content string) ([]model.MovieSuggestion, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildPrompt constructs the recommendation prompt sent to the LLM.
|
// BuildPrompt constructs the recommendation prompt sent to the LLM.
|
||||||
func BuildPrompt(favorites, watched, library []model.Movie, count int) string {
|
// previouslySuggested contains movies returned in prior retry attempts that must not be repeated.
|
||||||
|
func BuildPrompt(favorites, watched, library, previouslySuggested []model.Movie, count int) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(fmt.Sprintf(
|
sb.WriteString(fmt.Sprintf(
|
||||||
"You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly %d movies they would enjoy.\n\n"+
|
"You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly %d movies they would enjoy.\n\n"+
|
||||||
"IMPORTANT:\n"+
|
"IMPORTANT:\n"+
|
||||||
"- Do NOT suggest any movie from the \"Already In Library\" list.\n"+
|
"- Do NOT suggest any movie from the \"Already In Library\" list.\n"+
|
||||||
|
"- Do NOT suggest any movie from the \"Already Suggested\" list.\n"+
|
||||||
"- Return ONLY a JSON array, no other text.\n"+
|
"- Return ONLY a JSON array, no other text.\n"+
|
||||||
"- Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}\n\n"+
|
"- Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}\n\n"+
|
||||||
"User's favorite movies:\n", count))
|
"User's favorite movies:\n", count))
|
||||||
@@ -150,5 +152,13 @@ func BuildPrompt(favorites, watched, library []model.Movie, count int) string {
|
|||||||
for _, m := range library {
|
for _, m := range library {
|
||||||
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
|
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(previouslySuggested) > 0 {
|
||||||
|
sb.WriteString("\nAlready suggested (DO NOT repeat these):\n")
|
||||||
|
for _, m := range previouslySuggested {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s (%d)\n", m.Name, m.Year))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func NewRecommendationsController(mux *http.ServeMux, cfg *configs.Config, rec *
|
|||||||
c := &RecommendationsController{cfg: cfg, rec: rec}
|
c := &RecommendationsController{cfg: cfg, rec: rec}
|
||||||
mux.HandleFunc("GET /recommendations", c.handleList)
|
mux.HandleFunc("GET /recommendations", c.handleList)
|
||||||
mux.HandleFunc("POST /recommendations/search", c.handleSearch)
|
mux.HandleFunc("POST /recommendations/search", c.handleSearch)
|
||||||
|
mux.HandleFunc("POST /recommendations/trigger", c.handleTrigger)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,21 @@ func (c *RecommendationsController) handleList(w http.ResponseWriter, r *http.Re
|
|||||||
httputil.WriteJSON(w, http.StatusOK, recs)
|
httputil.WriteJSON(w, http.StatusOK, recs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleTrigger runs the same pipeline as the cron job: recommend + auto-request if Radarr is enabled.
|
||||||
|
func (c *RecommendationsController) handleTrigger(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Info().Msg("Trigger: running recommendation pipeline")
|
||||||
|
recs, err := c.rec.Run()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Trigger: pipeline failed")
|
||||||
|
httputil.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.cfg.RadarrEnabled {
|
||||||
|
c.rec.AutoRequest(recs)
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, recs)
|
||||||
|
}
|
||||||
|
|
||||||
// handleSearch performs a natural-language LLM search and resolves results via TMDb.
|
// handleSearch performs a natural-language LLM search and resolves results via TMDb.
|
||||||
// Body: {"query": "sci-fi movies like Blade Runner but more recent"}
|
// Body: {"query": "sci-fi movies like Blade Runner but more recent"}
|
||||||
func (c *RecommendationsController) handleSearch(w http.ResponseWriter, r *http.Request) {
|
func (c *RecommendationsController) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -79,42 +79,58 @@ func (r *Recommender) RunWithCount(count int) ([]model.Recommendation, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Get raw suggestions
|
// 3. Retry loop: each attempt requests only the remaining needed count.
|
||||||
var suggestions []model.MovieSuggestion
|
// `seen` accumulates every TMDb-resolved movie across attempts so the
|
||||||
if r.cfg.LLMEnabled && r.llm != nil {
|
// LLM prompt excludes them by name, not just by ID.
|
||||||
prompt := client.BuildPrompt(favorites, watchedMovies, library, count)
|
|
||||||
suggestions, err = r.llm.Recommend(prompt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("llm recommendation failed: %w", err)
|
|
||||||
}
|
|
||||||
log.Info().Msgf("LLM returned %d suggestions", len(suggestions))
|
|
||||||
} else {
|
|
||||||
suggestions = r.tmdbFallback(signalMovies, exclude, count)
|
|
||||||
log.Info().Msgf("TMDb fallback returned %d suggestions", len(suggestions))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Resolve suggestions via TMDb and apply exclusions
|
|
||||||
var recommendations []model.Recommendation
|
var recommendations []model.Recommendation
|
||||||
for _, s := range suggestions {
|
var seen []model.Movie
|
||||||
if len(recommendations) >= count {
|
for attempt := 0; attempt <= r.cfg.RecommendMaxRetry && len(recommendations) < count; attempt++ {
|
||||||
|
remaining := count - len(recommendations)
|
||||||
|
if attempt > 0 {
|
||||||
|
log.Info().Msgf("Retry %d/%d: need %d more recommendations", attempt, r.cfg.RecommendMaxRetry, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
var suggestions []model.MovieSuggestion
|
||||||
|
if r.cfg.LLMEnabled && r.llm != nil {
|
||||||
|
prompt := client.BuildPrompt(favorites, watchedMovies, library, seen, remaining)
|
||||||
|
suggestions, err = r.llm.Recommend(prompt)
|
||||||
|
if err != nil {
|
||||||
|
return recommendations, fmt.Errorf("llm recommendation failed: %w", err)
|
||||||
|
}
|
||||||
|
log.Info().Msgf("LLM returned %d suggestions (attempt %d)", len(suggestions), attempt+1)
|
||||||
|
} else {
|
||||||
|
suggestions = r.tmdbFallback(signalMovies, exclude, remaining)
|
||||||
|
log.Info().Msgf("TMDb fallback returned %d suggestions (attempt %d)", len(suggestions), attempt+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := len(recommendations)
|
||||||
|
for _, s := range suggestions {
|
||||||
|
if len(recommendations) >= count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tm, err := r.tmdb.SearchMovie(s.Title, s.Year)
|
||||||
|
if err != nil || tm == nil {
|
||||||
|
log.Debug().Msgf("Could not resolve: %s (%d)", s.Title, s.Year)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen = append(seen, model.Movie{Name: tm.Title, Year: tm.Year, TMDbID: strconv.Itoa(tm.ID)})
|
||||||
|
if exclude[tm.ID] {
|
||||||
|
log.Debug().Msgf("Skipping (already in library): %s", tm.Title)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exclude[tm.ID] = true
|
||||||
|
recommendations = append(recommendations, model.Recommendation{
|
||||||
|
Title: tm.Title,
|
||||||
|
Year: tm.Year,
|
||||||
|
TMDbID: tm.ID,
|
||||||
|
Reason: s.Reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recommendations) == before {
|
||||||
|
log.Warn().Msgf("No new recommendations found on attempt %d, stopping retries", attempt+1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
tm, err := r.tmdb.SearchMovie(s.Title, s.Year)
|
|
||||||
if err != nil || tm == nil {
|
|
||||||
log.Debug().Msgf("Could not resolve: %s (%d)", s.Title, s.Year)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if exclude[tm.ID] {
|
|
||||||
log.Debug().Msgf("Skipping (already in library): %s", tm.Title)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
exclude[tm.ID] = true
|
|
||||||
recommendations = append(recommendations, model.Recommendation{
|
|
||||||
Title: tm.Title,
|
|
||||||
Year: tm.Year,
|
|
||||||
TMDbID: tm.ID,
|
|
||||||
Reason: s.Reason,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return recommendations, nil
|
return recommendations, nil
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ type Config struct {
|
|||||||
APIEnabled bool `envconfig:"API_ENABLED" default:"true"`
|
APIEnabled bool `envconfig:"API_ENABLED" default:"true"`
|
||||||
APIPort int `envconfig:"API_PORT" default:"8080"`
|
APIPort int `envconfig:"API_PORT" default:"8080"`
|
||||||
|
|
||||||
RecommendCount int `envconfig:"RECOMMEND_COUNT" default:"10"`
|
RecommendCount int `envconfig:"RECOMMEND_COUNT" default:"10"`
|
||||||
MinRatedMovies int `envconfig:"MIN_RATED_MOVIES" default:"5"`
|
RecommendMaxRetry int `envconfig:"RECOMMEND_MAX_RETRY" default:"3"`
|
||||||
|
MinRatedMovies int `envconfig:"MIN_RATED_MOVIES" default:"5"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads .env (best-effort) then processes environment variables into a Config.
|
// Load reads .env (best-effort) then processes environment variables into a Config.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httputil
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||||
@@ -14,3 +15,21 @@ func WriteJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
func WriteError(w http.ResponseWriter, status int, msg string) {
|
func WriteError(w http.ResponseWriter, status int, msg string) {
|
||||||
WriteJSON(w, status, map[string]string{"error": msg})
|
WriteJSON(w, status, map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware validates the Authorization: Bearer <key> header.
|
||||||
|
// If secretKey is empty, auth is disabled and all requests pass through.
|
||||||
|
// /health is always exempt.
|
||||||
|
func AuthMiddleware(secretKey string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if secretKey == "" || r.URL.Path == "/health" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok || token != secretKey {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/fjurmanovic/sugarr/internal/api"
|
"github.com/fjurmanovic/sugarr/internal/api"
|
||||||
"github.com/fjurmanovic/sugarr/internal/service"
|
"github.com/fjurmanovic/sugarr/internal/service"
|
||||||
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
"github.com/fjurmanovic/sugarr/internal/utl/configs"
|
||||||
|
"github.com/fjurmanovic/sugarr/internal/utl/httputil"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,5 +18,8 @@ func Start(cfg *configs.Config, rec *service.Recommender) error {
|
|||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.APIPort)
|
addr := fmt.Sprintf(":%d", cfg.APIPort)
|
||||||
log.Info().Msgf("HTTP API listening on %s", addr)
|
log.Info().Msgf("HTTP API listening on %s", addr)
|
||||||
return http.ListenAndServe(addr, mux)
|
if cfg.APISecretKey != "" {
|
||||||
|
log.Info().Msg("Secret key auth enabled")
|
||||||
|
}
|
||||||
|
return http.ListenAndServe(addr, httputil.AuthMiddleware(cfg.APISecretKey, mux))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user