feat: catalogue enrichment — tagline, genres, warnings, quality score, batch covers
Backend (handlers_catalogue.go):
- POST /api/admin/text-gen/tagline — 1-sentence marketing hook
- POST /api/admin/text-gen/genres + /apply — LLM genre suggestions, editable + persist
- POST /api/admin/text-gen/content-warnings — mature theme detection
- POST /api/admin/text-gen/quality-score — 1–5 description quality rating
- POST /api/admin/catalogue/batch-covers (SSE) — generate covers for books missing one
- POST /api/admin/catalogue/batch-covers/cancel — cancel via in-memory job registry
- POST /api/admin/catalogue/refresh-metadata/{slug} (SSE) — description + cover refresh
Frontend:
- text-gen: 4 new tabs (Tagline, Genres, Warnings, Quality) with book autocomplete
- image-gen: localStorage style presets (save/apply/delete named prompt templates)
- catalogue-tools: new admin page with batch cover SSE progress + cancel
- admin nav: "Catalogue Tools" link added
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
728
backend/internal/backend/handlers_catalogue.go
Normal file
728
backend/internal/backend/handlers_catalogue.go
Normal file
@@ -0,0 +1,728 @@
|
||||
package backend
|
||||
|
||||
// Catalogue enrichment handlers: tagline, genre tagging, content warnings,
|
||||
// quality scoring, batch cover regeneration, and per-book metadata refresh.
|
||||
//
|
||||
// All generation endpoints are admin-only (enforced by the SvelteKit proxy layer).
|
||||
// All long-running operations support cancellation via r.Context().Done().
|
||||
// Batch operations use an in-memory cancel registry (cancelJobs map) so the
|
||||
// frontend can send a cancel request by job ID.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// ── Cancel registry ────────────────────────────────────────────────────────
|
||||
|
||||
// cancelJobsMu guards cancelJobs.
|
||||
var cancelJobsMu sync.Mutex
|
||||
|
||||
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
|
||||
// job starts and removed when it finishes or is cancelled.
|
||||
var cancelJobs = map[string]context.CancelFunc{}
|
||||
|
||||
func registerCancelJob(id string, cancel context.CancelFunc) {
|
||||
cancelJobsMu.Lock()
|
||||
cancelJobs[id] = cancel
|
||||
cancelJobsMu.Unlock()
|
||||
}
|
||||
|
||||
func deregisterCancelJob(id string) {
|
||||
cancelJobsMu.Lock()
|
||||
delete(cancelJobs, id)
|
||||
cancelJobsMu.Unlock()
|
||||
}
|
||||
|
||||
// ── Tagline ───────────────────────────────────────────────────────────────
|
||||
|
||||
// textGenTaglineRequest is the JSON body for POST /api/admin/text-gen/tagline.
|
||||
type textGenTaglineRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenTaglineResponse is returned by POST /api/admin/text-gen/tagline.
|
||||
type textGenTaglineResponse struct {
|
||||
OldTagline string `json:"old_tagline"`
|
||||
NewTagline string `json:"new_tagline"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenTagline handles POST /api/admin/text-gen/tagline.
|
||||
// Generates a 1-sentence marketing hook for a book.
|
||||
func (s *Server) handleAdminTextGenTagline(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenTaglineRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
system := `You are a copywriter for a web novel platform. ` +
|
||||
`Given a book's title, genres, and description, write a single punchy tagline ` +
|
||||
`(one sentence, under 20 words) that hooks a reader. ` +
|
||||
`Output ONLY the tagline — no quotes, no labels, no explanation.`
|
||||
|
||||
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen tagline requested", "slug", req.Slug, "model", model)
|
||||
|
||||
result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
|
||||
MaxTokens: 64,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen tagline failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenTaglineResponse{
|
||||
OldTagline: "", // BookMeta has no tagline field yet — always empty
|
||||
NewTagline: strings.TrimSpace(result),
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Genres ────────────────────────────────────────────────────────────────
|
||||
|
||||
// textGenGenresRequest is the JSON body for POST /api/admin/text-gen/genres.
|
||||
type textGenGenresRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenGenresResponse is returned by POST /api/admin/text-gen/genres.
|
||||
type textGenGenresResponse struct {
|
||||
CurrentGenres []string `json:"current_genres"`
|
||||
ProposedGenres []string `json:"proposed_genres"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenGenres handles POST /api/admin/text-gen/genres.
|
||||
// Suggests a refined genre list based on the book's description.
|
||||
func (s *Server) handleAdminTextGenGenres(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenGenresRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
system := `You are a genre classification expert for a web novel platform. ` +
|
||||
`Given a book's title and description, return a JSON array of 2–6 genre tags. ` +
|
||||
`Use only well-known web novel genres such as: ` +
|
||||
`Action, Adventure, Comedy, Drama, Fantasy, Historical, Horror, Isekai, Josei, ` +
|
||||
`Martial Arts, Mature, Mecha, Mystery, Psychological, Romance, School Life, ` +
|
||||
`Sci-fi, Seinen, Shoujo, Shounen, Slice of Life, Supernatural, System, Tragedy, Wuxia, Xianxia. ` +
|
||||
`Output ONLY a raw JSON array of strings — no prose, no markdown, no explanation. ` +
|
||||
`Example: ["Fantasy","Adventure","Action"]`
|
||||
|
||||
user := fmt.Sprintf("Title: %s\nCurrent genres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen genres requested", "slug", req.Slug, "model", model)
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
|
||||
MaxTokens: 128,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen genres failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
proposed := parseStringArrayJSON(raw)
|
||||
|
||||
writeJSON(w, 0, textGenGenresResponse{
|
||||
CurrentGenres: meta.Genres,
|
||||
ProposedGenres: proposed,
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminTextGenApplyGenres handles POST /api/admin/text-gen/genres/apply.
|
||||
// Persists the confirmed genre list to PocketBase.
|
||||
func (s *Server) handleAdminTextGenApplyGenres(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.BookWriter == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Slug string `json:"slug"`
|
||||
Genres []string `json:"genres"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
meta.Genres = req.Genres
|
||||
if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
|
||||
s.deps.Log.Error("admin: apply genres failed", "slug", req.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: genres applied", "slug", req.Slug, "genres", req.Genres)
|
||||
writeJSON(w, 0, map[string]any{"updated": true})
|
||||
}
|
||||
|
||||
// ── Content warnings ──────────────────────────────────────────────────────
|
||||
|
||||
// textGenContentWarningsRequest is the JSON body for POST /api/admin/text-gen/content-warnings.
|
||||
type textGenContentWarningsRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenContentWarningsResponse is returned by POST /api/admin/text-gen/content-warnings.
|
||||
type textGenContentWarningsResponse struct {
|
||||
Warnings []string `json:"warnings"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenContentWarnings handles POST /api/admin/text-gen/content-warnings.
|
||||
// Detects mature or sensitive themes in a book's description.
|
||||
func (s *Server) handleAdminTextGenContentWarnings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenContentWarningsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
system := `You are a content moderation assistant for a web novel platform. ` +
|
||||
`Given a book's title, genres, and description, detect any content warnings that should be shown to readers. ` +
|
||||
`Choose only relevant warnings from: Violence, Strong Language, Sexual Content, Mature Themes, ` +
|
||||
`Dark Themes, Gore, Torture, Abuse, Drug Use, Suicide/Self-Harm. ` +
|
||||
`If the book is clean, return an empty array. ` +
|
||||
`Output ONLY a raw JSON array of strings — no prose, no markdown. ` +
|
||||
`Example: ["Violence","Dark Themes"]`
|
||||
|
||||
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen content-warnings requested", "slug", req.Slug, "model", model)
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
|
||||
MaxTokens: 128,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen content-warnings failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
warnings := parseStringArrayJSON(raw)
|
||||
|
||||
writeJSON(w, 0, textGenContentWarningsResponse{
|
||||
Warnings: warnings,
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Quality score ─────────────────────────────────────────────────────────
|
||||
|
||||
// textGenQualityScoreRequest is the JSON body for POST /api/admin/text-gen/quality-score.
|
||||
type textGenQualityScoreRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenQualityScoreResponse is returned by POST /api/admin/text-gen/quality-score.
|
||||
type textGenQualityScoreResponse struct {
|
||||
Score int `json:"score"` // 1–5
|
||||
Feedback string `json:"feedback"` // brief reasoning
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenQualityScore handles POST /api/admin/text-gen/quality-score.
|
||||
// Rates the book description quality on a 1–5 scale with brief feedback.
|
||||
func (s *Server) handleAdminTextGenQualityScore(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenQualityScoreRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
system := `You are a book description quality reviewer for a web novel platform. ` +
|
||||
`Rate the provided description on a scale of 1–5 where: ` +
|
||||
`1=poor (vague/too short), 2=below average, 3=average, 4=good, 5=excellent (engaging/detailed). ` +
|
||||
`Respond with ONLY a JSON object: {"score": <1-5>, "feedback": "<one sentence explanation>"}. ` +
|
||||
`No markdown, no extra text.`
|
||||
|
||||
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen quality-score requested", "slug", req.Slug, "model", model)
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
|
||||
MaxTokens: 128,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen quality-score failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Score int `json:"score"`
|
||||
Feedback string `json:"feedback"`
|
||||
}
|
||||
// Strip markdown fences if any.
|
||||
clean := extractJSONObject(raw)
|
||||
if err := json.Unmarshal([]byte(clean), &parsed); err != nil {
|
||||
// Fallback: try to extract a digit.
|
||||
parsed.Score = 0
|
||||
for _, ch := range raw {
|
||||
if ch >= '1' && ch <= '5' {
|
||||
parsed.Score = int(ch - '0')
|
||||
break
|
||||
}
|
||||
}
|
||||
parsed.Feedback = strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenQualityScoreResponse{
|
||||
Score: parsed.Score,
|
||||
Feedback: parsed.Feedback,
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Batch cover regeneration ──────────────────────────────────────────────
|
||||
|
||||
// batchCoverEvent is one SSE event emitted during batch cover regeneration.
|
||||
type batchCoverEvent struct {
|
||||
// JobID is the opaque identifier clients use to cancel this job.
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
Done int `json:"done"`
|
||||
Total int `json:"total"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Finish bool `json:"finish,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminBatchCovers handles POST /api/admin/catalogue/batch-covers.
|
||||
//
|
||||
// Streams SSE events as it generates covers for every book that has no cover
|
||||
// stored in MinIO. Each event carries progress info. The final event has Finish=true.
|
||||
//
|
||||
// The job can be cancelled by calling POST /api/admin/catalogue/batch-covers/cancel
|
||||
// with body {"job_id":"..."}.
|
||||
func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil || s.deps.ImageGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured")
|
||||
return
|
||||
}
|
||||
if s.deps.CoverStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody struct {
|
||||
Model string `json:"model"`
|
||||
NumSteps int `json:"num_steps"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
// Body is optional — defaults used if absent.
|
||||
json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck
|
||||
|
||||
books, err := s.deps.BookReader.ListBooks(r.Context())
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list books: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a unique job ID.
|
||||
jobID := randomHex(8)
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
registerCancelJob(jobID, cancel)
|
||||
defer deregisterCancelJob(jobID)
|
||||
defer cancel()
|
||||
|
||||
// SSE headers.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
sseWrite := func(evt batchCoverEvent) {
|
||||
b, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
total := len(books)
|
||||
done := 0
|
||||
|
||||
// Send initial event with jobID so frontend can store it for cancellation.
|
||||
sseWrite(batchCoverEvent{JobID: jobID, Done: 0, Total: total})
|
||||
|
||||
for _, book := range books {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Check if cover already exists.
|
||||
hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug)
|
||||
if hasCover {
|
||||
done++
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true})
|
||||
continue
|
||||
}
|
||||
|
||||
// Build a prompt from the book metadata.
|
||||
prompt := buildCoverPrompt(book)
|
||||
|
||||
// Generate the image via CF AI.
|
||||
imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{
|
||||
Prompt: prompt,
|
||||
NumSteps: reqBody.NumSteps,
|
||||
Width: reqBody.Width,
|
||||
Height: reqBody.Height,
|
||||
})
|
||||
if genErr != nil {
|
||||
done++
|
||||
s.deps.Log.Error("batch-covers: image gen failed", "slug", book.Slug, "err", genErr)
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: genErr.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
// Save to CoverStore.
|
||||
if saveErr := s.deps.CoverStore.PutCover(ctx, book.Slug, imgBytes, "image/png"); saveErr != nil {
|
||||
done++
|
||||
s.deps.Log.Error("batch-covers: save failed", "slug", book.Slug, "err", saveErr)
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: saveErr.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
done++
|
||||
s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug)
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug})
|
||||
}
|
||||
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true})
|
||||
}
|
||||
|
||||
// handleAdminBatchCoversCancel handles POST /api/admin/catalogue/batch-covers/cancel.
|
||||
// Cancels an in-progress batch cover job by its job ID.
|
||||
func (s *Server) handleAdminBatchCoversCancel(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.JobID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "job_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
cancelJobsMu.Lock()
|
||||
cancel, ok := cancelJobs[req.JobID]
|
||||
cancelJobsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", req.JobID))
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
s.deps.Log.Info("batch-covers: job cancelled", "job_id", req.JobID)
|
||||
writeJSON(w, 0, map[string]any{"cancelled": true})
|
||||
}
|
||||
|
||||
// ── Refresh metadata (per-book) ────────────────────────────────────────────
|
||||
|
||||
// refreshMetadataEvent is one SSE event during per-book metadata refresh.
|
||||
type refreshMetadataEvent struct {
|
||||
Step string `json:"step"` // "description" | "tagline" | "cover"
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminRefreshMetadata handles POST /api/admin/catalogue/refresh-metadata/{slug}.
|
||||
//
|
||||
// Runs description → tagline → cover generation in sequence for a single book
|
||||
// and streams SSE progress. Interruptable via client disconnect (r.Context()).
|
||||
func (s *Server) handleAdminRefreshMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", slug))
|
||||
return
|
||||
}
|
||||
|
||||
// SSE headers.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
sseWrite := func(evt refreshMetadataEvent) {
|
||||
b, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Step 1 — description.
|
||||
if s.deps.TextGen != nil {
|
||||
if ctx.Err() == nil {
|
||||
newDesc, genErr := s.deps.TextGen.Generate(ctx, cfai.TextRequest{
|
||||
Model: cfai.DefaultTextModel,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: `You are a book description writer for a web novel platform. Write an improved description. Respond with ONLY the new description text — no title, no labels, no markdown.`},
|
||||
{Role: "user", Content: fmt.Sprintf("Title: %s\nGenres: %s\n\nCurrent description:\n%s\n\nInstructions: Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging.", meta.Title, strings.Join(meta.Genres, ", "), meta.Summary)},
|
||||
},
|
||||
MaxTokens: 512,
|
||||
})
|
||||
if genErr == nil && strings.TrimSpace(newDesc) != "" && s.deps.BookWriter != nil {
|
||||
meta.Summary = strings.TrimSpace(newDesc)
|
||||
if writeErr := s.deps.BookWriter.WriteMetadata(ctx, meta); writeErr != nil {
|
||||
sseWrite(refreshMetadataEvent{Step: "description", Error: writeErr.Error()})
|
||||
} else {
|
||||
sseWrite(refreshMetadataEvent{Step: "description"})
|
||||
}
|
||||
} else if genErr != nil {
|
||||
sseWrite(refreshMetadataEvent{Step: "description", Error: genErr.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 — cover.
|
||||
if s.deps.ImageGen != nil && s.deps.CoverStore != nil {
|
||||
if ctx.Err() == nil {
|
||||
prompt := buildCoverPrompt(meta)
|
||||
imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{Prompt: prompt})
|
||||
if genErr == nil {
|
||||
if saveErr := s.deps.CoverStore.PutCover(ctx, slug, imgBytes, "image/png"); saveErr != nil {
|
||||
sseWrite(refreshMetadataEvent{Step: "cover", Error: saveErr.Error()})
|
||||
} else {
|
||||
sseWrite(refreshMetadataEvent{Step: "cover"})
|
||||
}
|
||||
} else {
|
||||
sseWrite(refreshMetadataEvent{Step: "cover", Error: genErr.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sseWrite(refreshMetadataEvent{Step: "done", Done: true})
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// parseStringArrayJSON extracts a JSON string array from model output,
|
||||
// tolerating markdown fences and surrounding prose.
|
||||
func parseStringArrayJSON(raw string) []string {
|
||||
s := raw
|
||||
if idx := strings.Index(s, "```json"); idx >= 0 {
|
||||
s = s[idx+7:]
|
||||
} else if idx := strings.Index(s, "```"); idx >= 0 {
|
||||
s = s[idx+3:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
start := strings.Index(s, "[")
|
||||
end := strings.LastIndex(s, "]")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
s = s[start : end+1]
|
||||
var out []string
|
||||
json.Unmarshal([]byte(s), &out) //nolint:errcheck
|
||||
return out
|
||||
}
|
||||
|
||||
// extractJSONObject finds the first {...} object in a string.
|
||||
func extractJSONObject(raw string) string {
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start < 0 || end <= start {
|
||||
return raw
|
||||
}
|
||||
return raw[start : end+1]
|
||||
}
|
||||
|
||||
// buildCoverPrompt constructs a prompt string for cover generation from a book.
|
||||
func buildCoverPrompt(meta domain.BookMeta) string {
|
||||
parts := []string{"book cover art"}
|
||||
if meta.Title != "" {
|
||||
parts = append(parts, "titled \""+meta.Title+"\"")
|
||||
}
|
||||
if len(meta.Genres) > 0 {
|
||||
parts = append(parts, strings.Join(meta.Genres, ", ")+" genre")
|
||||
}
|
||||
if meta.Summary != "" {
|
||||
summary := meta.Summary
|
||||
if len(summary) > 200 {
|
||||
summary = summary[:200]
|
||||
}
|
||||
parts = append(parts, summary)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// randomHex returns a random hex string of n bytes.
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -204,6 +204,16 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
|
||||
// Admin catalogue enrichment endpoints
|
||||
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
||||
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user