Compare commits

...

3 Commits

Author SHA1 Message Date
root
7351756056 make all books public by default (revert visibility gating)
All checks were successful
Release / Test backend (push) Successful in 5m47s
Release / Test UI (push) Successful in 1m4s
Release / Build and push images (push) Successful in 6m20s
Release / Deploy to prod (push) Successful in 3m53s
Release / Deploy to homelab (push) Successful in 8s
Release / Gitea Release (push) Successful in 25s
2026-04-27 11:46:05 +05:00
root
51adf0e7a5 fix(backend): split final UpdateAIJob + add goroutine recover() for AI job handlers
Some checks failed
Release / Test UI (push) Has been cancelled
Release / Build and push images (push) Has been cancelled
Release / Test backend (push) Has been cancelled
Release / Deploy to prod (push) Has been cancelled
Release / Deploy to homelab (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Split the single combined UpdateAIJob call at the end of each async
goroutine into two sequential calls (status first, payload second) so
that a failure writing the large results payload does not silently
prevent the job from being marked done. Added recover() to each
goroutine to catch and log panics. Affects chapter-names, description,
and image-gen handlers.
2026-04-24 22:47:52 +05:00
root
5bc69ad9ce fix(cfai): handle Llama 4 Scout direct JSON array response in Generate()
All checks were successful
Release / Test backend (push) Successful in 6m5s
Release / Test UI (push) Successful in 1m44s
Release / Build and push images (push) Successful in 7m6s
Release / Deploy to prod (push) Successful in 3m47s
Release / Deploy to homelab (push) Successful in 8s
Release / Gitea Release (push) Successful in 44s
Llama 4 Scout returns the model output directly as a JSON array in the
'response' field rather than as a plain string or [{"generated_text":"..."}].
The old fallback parsed it as [{"generated_text":""}] (Go JSON ignores
unknown fields) and silently returned an empty string — causing chapter-names
jobs to finish with results:null in the payload despite items_done matching
items_total.

Fix: only accept the generated_text fallback when the value is non-empty;
otherwise return the raw JSON bytes as a string so callers (parseChapterTitlesJSON
etc.) can extract what they need.
2026-04-24 18:24:25 +05:00
5 changed files with 95 additions and 17 deletions

View File

@@ -559,6 +559,11 @@ func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request
go func() {
defer deregisterCancelJob(jobID)
defer jobCancel()
defer func() {
if r := recover(); r != nil {
logger.Error("admin: image-gen goroutine panic", "job_id", jobID, "recover", r)
}
}()
if jobCtx.Err() != nil {
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
@@ -625,13 +630,19 @@ func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request
Guidance: capturedReq.Guidance,
})
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusDone),
"items_done": 1,
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusDone),
"items_done": 1,
"items_total": 1,
"payload": string(resultJSON),
"finished": time.Now().Format(time.RFC3339),
})
"finished": time.Now().Format(time.RFC3339),
}); err != nil {
logger.Error("admin: image-gen failed to mark job done", "job_id", jobID, "err", err)
}
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
"payload": string(resultJSON),
}); err != nil {
logger.Error("admin: image-gen failed to write job payload", "job_id", jobID, "err", err)
}
logger.Info("admin: image-gen async done",
"job_id", jobID, "slug", capturedReq.Slug,

View File

@@ -516,6 +516,11 @@ func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *h
go func() {
defer deregisterCancelJob(jobID)
defer jobCancel()
defer func() {
if r := recover(); r != nil {
logger.Error("admin: text-gen chapter-names goroutine panic", "job_id", jobID, "recover", r)
}
}()
var allResults []proposedChapterTitle
chaptersDone := 0
@@ -574,12 +579,18 @@ func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *h
if jobCtx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),
"payload": finalPayload,
})
}); err != nil {
logger.Error("admin: text-gen chapter-names failed to mark job done", "job_id", jobID, "err", err)
}
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
"payload": finalPayload,
}); err != nil {
logger.Error("admin: text-gen chapter-names failed to write job payload", "job_id", jobID, "err", err)
}
logger.Info("admin: text-gen chapter-names async done",
"job_id", jobID, "slug", capturedSlug,
"results", len(allResults), "status", string(status))
@@ -902,6 +913,11 @@ func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *ht
go func() {
defer deregisterCancelJob(jobID)
defer jobCancel()
defer func() {
if r := recover(); r != nil {
logger.Error("admin: text-gen description goroutine panic", "job_id", jobID, "recover", r)
}
}()
if jobCtx.Err() != nil {
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
@@ -955,13 +971,19 @@ func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *ht
NewDescription: strings.TrimSpace(newDesc),
})
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusDone),
"items_done": 1,
"items_total": 1,
"payload": string(resultJSON),
"finished": time.Now().Format(time.RFC3339),
})
}); err != nil {
logger.Error("admin: text-gen description failed to mark job done", "job_id", jobID, "err", err)
}
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
"payload": string(resultJSON),
}); err != nil {
logger.Error("admin: text-gen description failed to write job payload", "job_id", jobID, "err", err)
}
logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
}()

View File

@@ -237,14 +237,18 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
return text, nil
}
// Fall back: array of objects with a "generated_text" field.
// Fall back: array of objects with a "generated_text" field
// (older CF AI models return [{"generated_text":"..."}]).
var arr []struct {
GeneratedText string `json:"generated_text"`
}
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 {
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 && arr[0].GeneratedText != "" {
return arr[0].GeneratedText, nil
}
return "", fmt.Errorf("cfai/text: model %s: unrecognised response shape: %s", req.Model, wrapper.Result.Response)
// Final fallback: model returned the result directly as a JSON value
// (e.g. Llama 4 Scout returns [{"number":1,"title":"..."},...] directly).
// Return the raw JSON bytes as a string so callers can parse it themselves.
return string(wrapper.Result.Response), nil
}
// Models returns all supported text generation model metadata.

View File

@@ -86,12 +86,11 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
return fmt.Errorf("WriteMetadata: %w", err)
}
if err == ErrNotFound {
// New scraped book — default to admin_only visibility.
postPayload := make(map[string]any, len(patchPayload)+1)
for k, v := range patchPayload {
postPayload[k] = v
}
postPayload["visibility"] = domain.VisibilityAdminOnly
postPayload["visibility"] = domain.VisibilityPublic
postErr := s.pb.post(ctx, "/api/collections/books/records", postPayload, nil)
if postErr == nil {
return nil

View File

@@ -0,0 +1,42 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
coll, err := app.FindCollectionByNameOrId("books")
if err != nil {
return err
}
if coll.Fields.GetByName("visibility") == nil {
coll.Fields.Add(&core.TextField{Name: "visibility"})
if err := app.Save(coll); err != nil {
return err
}
}
const perPage = 200
for page := 1; ; page++ {
records, err := app.FindRecordsByFilter(
"books", `visibility="admin_only"`, "+id", perPage, (page-1)*perPage, nil,
)
if err != nil || len(records) == 0 {
break
}
for _, rec := range records {
rec.Set("visibility", "public")
_ = app.Save(rec)
}
if len(records) < perPage {
break
}
}
return nil
}, func(app core.App) error {
return nil
})
}