mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 00:05:01 +00:00
Use subject-specific humanization so short warmup subjects are not mangled by body-level opener stripping. Add rDNS verification logging after provisioning and remove the unused public campaign breaker interface method.
354 lines
11 KiB
Go
354 lines
11 KiB
Go
// Package warmupcontent runs the offline warmup-content generator: it calls
|
|
// the AI client, lints each result, and caches accepted threads in the
|
|
// warmup_conversations bank for the live send path to draw from. Generation is
|
|
// always offline (admin-triggered or scheduled) and never on the send hot path.
|
|
package warmupcontent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/generation"
|
|
"github.com/warmbly/warmbly/internal/pkg/humanlint"
|
|
"github.com/warmbly/warmbly/internal/pkg/warmlint"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// ErrNotConfigured is returned when generation is requested but no AI client is
|
|
// wired (OPENAI_API_KEY unset).
|
|
var ErrNotConfigured = errors.New("warmup AI generation is not configured")
|
|
|
|
// defaultThemes seed topic variety when a generation run doesn't pin a theme.
|
|
var defaultThemes = []string{
|
|
"productivity", "learning", "collaboration", "industry trends", "tools",
|
|
"networking", "feedback", "planning", "reading", "wellness", "events",
|
|
"career growth", "remote work", "meetings", "automation", "team culture",
|
|
"customer success", "writing", "travel", "community",
|
|
}
|
|
|
|
// maxPerRun bounds a single sync generation run regardless of request size.
|
|
const maxPerRun = 200
|
|
|
|
// maxPerBatch bounds a single Batch API submission. Batch is async and cheap so
|
|
// it tolerates a much larger fan-out than the sync path; OpenAI itself allows up
|
|
// to 50,000 requests per batch input file.
|
|
const maxPerBatch = 2000
|
|
|
|
// GenerateRequest describes one generation run.
|
|
type GenerateRequest struct {
|
|
RequestedBy *uuid.UUID
|
|
Trigger string // "manual" | "schedule"
|
|
PoolType string
|
|
Segment string
|
|
Theme string
|
|
Model string
|
|
Count int
|
|
// MaxMessages overrides the per-thread follow-up count for this run; 0 falls
|
|
// back to the admin generation settings. Used by the batch path so callers
|
|
// have full control over the thread shape.
|
|
MaxMessages int
|
|
// CompletionWindow is the OpenAI Batch API processing window (batch path
|
|
// only); empty defaults to "24h".
|
|
CompletionWindow string
|
|
}
|
|
|
|
// Service drives offline warmup content generation.
|
|
type Service interface {
|
|
// Generate starts an offline generation run in the background and returns
|
|
// the job ID immediately so callers can track progress.
|
|
Generate(ctx context.Context, req GenerateRequest) (uuid.UUID, error)
|
|
// GenerateBatch submits an async OpenAI Batch API run (~50% cheaper) and
|
|
// returns the job ID immediately. Results are ingested later by PollBatches.
|
|
GenerateBatch(ctx context.Context, req GenerateRequest) (uuid.UUID, error)
|
|
// PollBatches reconciles in-flight batch jobs against OpenAI: it ingests
|
|
// completed batches and marks failed/expired/cancelled ones.
|
|
PollBatches(ctx context.Context) error
|
|
// CancelBatch cancels an in-flight batch job (OpenAI + local job row).
|
|
CancelBatch(ctx context.Context, jobID uuid.UUID) error
|
|
// RunScheduled tops every enabled pool/segment up toward its target.
|
|
RunScheduled(ctx context.Context) error
|
|
// Enabled reports whether an AI client is configured.
|
|
Enabled() bool
|
|
}
|
|
|
|
type service struct {
|
|
repo repository.WarmupContentRepository
|
|
gen *generation.GenerationClient
|
|
}
|
|
|
|
// NewService creates the generation service. gen may be nil (no OPENAI_API_KEY),
|
|
// in which case generation requests return ErrNotConfigured.
|
|
func NewService(repo repository.WarmupContentRepository, gen *generation.GenerationClient) Service {
|
|
return &service{repo: repo, gen: gen}
|
|
}
|
|
|
|
func (s *service) Enabled() bool { return s.gen != nil }
|
|
|
|
func (s *service) Generate(ctx context.Context, req GenerateRequest) (uuid.UUID, error) {
|
|
if s.gen == nil {
|
|
return uuid.Nil, ErrNotConfigured
|
|
}
|
|
if req.PoolType == "" {
|
|
req.PoolType = "premium"
|
|
}
|
|
if req.Trigger == "" {
|
|
req.Trigger = "manual"
|
|
}
|
|
if req.Count <= 0 {
|
|
req.Count = 10
|
|
}
|
|
if req.Count > maxPerRun {
|
|
req.Count = maxPerRun
|
|
}
|
|
|
|
settings, _ := s.repo.GetGenerationSettings(ctx)
|
|
if req.Model == "" && settings != nil {
|
|
req.Model = settings.Model
|
|
}
|
|
|
|
job := &models.WarmupGenerationJob{
|
|
ID: uuid.New(),
|
|
RequestedBy: req.RequestedBy,
|
|
Trigger: req.Trigger,
|
|
PoolType: req.PoolType,
|
|
Segment: req.Segment,
|
|
Theme: req.Theme,
|
|
Model: req.Model,
|
|
RequestedCount: req.Count,
|
|
Status: "pending",
|
|
}
|
|
if err := s.repo.CreateGenerationJob(ctx, job); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
|
|
// Run in the background so the admin request returns immediately; the job
|
|
// row is the progress/visibility surface.
|
|
go func() {
|
|
bg, cancel := context.WithTimeout(context.Background(), time.Duration(req.Count)*45*time.Second+time.Minute)
|
|
defer cancel()
|
|
s.runJob(bg, job)
|
|
}()
|
|
|
|
return job.ID, nil
|
|
}
|
|
|
|
func (s *service) RunScheduled(ctx context.Context) error {
|
|
if s.gen == nil {
|
|
return nil
|
|
}
|
|
settings, err := s.repo.GetGenerationSettings(ctx)
|
|
if err != nil || settings == nil {
|
|
return err
|
|
}
|
|
if !settings.ScheduleEnabled {
|
|
return nil
|
|
}
|
|
|
|
remaining := dailyRemaining(ctx, s.repo, settings.DailyGenerationCap)
|
|
if settings.DailyGenerationCap > 0 && remaining <= 0 {
|
|
log.Info().Msg("warmup generation: daily cap reached; scheduled run skipped")
|
|
return nil
|
|
}
|
|
|
|
for _, pool := range settings.Pools {
|
|
if !pool.Enabled {
|
|
continue
|
|
}
|
|
segments := pool.Segments
|
|
if len(segments) == 0 {
|
|
segments = []string{""}
|
|
}
|
|
for _, segment := range segments {
|
|
active, _ := s.repo.CountActiveConversations(ctx, pool.PoolType, segment)
|
|
deficit := pool.TargetActiveThreads - active
|
|
if deficit <= 0 {
|
|
continue
|
|
}
|
|
count := deficit
|
|
if count > 25 {
|
|
count = 25 // per-segment per-run cap so one run doesn't monopolise
|
|
}
|
|
if settings.DailyGenerationCap > 0 && count > remaining {
|
|
count = remaining
|
|
}
|
|
if count <= 0 {
|
|
continue
|
|
}
|
|
|
|
job := &models.WarmupGenerationJob{
|
|
ID: uuid.New(),
|
|
Trigger: "schedule",
|
|
PoolType: pool.PoolType,
|
|
Segment: segment,
|
|
Model: settings.Model,
|
|
RequestedCount: count,
|
|
Status: "pending",
|
|
}
|
|
if err := s.repo.CreateGenerationJob(ctx, job); err != nil {
|
|
log.Warn().Err(err).Msg("warmup generation: failed to create scheduled job")
|
|
continue
|
|
}
|
|
generated := s.runJob(ctx, job)
|
|
if settings.DailyGenerationCap > 0 {
|
|
remaining -= generated
|
|
if remaining <= 0 {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runJob executes a generation job, updating its row as it goes. Returns the
|
|
// number of conversations successfully cached.
|
|
func (s *service) runJob(ctx context.Context, job *models.WarmupGenerationJob) int {
|
|
now := time.Now()
|
|
job.StartedAt = &now
|
|
job.Status = "running"
|
|
if err := s.repo.UpdateGenerationJob(ctx, job); err != nil {
|
|
log.Warn().Err(err).Str("job_id", job.ID.String()).Msg("warmup generation: failed to mark job running")
|
|
}
|
|
|
|
settings, _ := s.repo.GetGenerationSettings(ctx)
|
|
maxMessages := 4
|
|
if settings != nil {
|
|
maxMessages = settings.MaxMessagesPerThread
|
|
}
|
|
|
|
for i := 0; i < job.RequestedCount; i++ {
|
|
if ctx.Err() != nil {
|
|
job.Error = ctx.Err().Error()
|
|
break
|
|
}
|
|
theme := job.Theme
|
|
if theme == "" {
|
|
theme = defaultThemes[i%len(defaultThemes)]
|
|
}
|
|
|
|
conv, err := s.gen.GenerateConversation(ctx, theme, job.Model, maxMessages)
|
|
if err != nil {
|
|
job.FailedCount++
|
|
log.Warn().Err(err).Str("job_id", job.ID.String()).Str("theme", theme).Msg("warmup generation: model call failed")
|
|
continue
|
|
}
|
|
|
|
subject := strings.TrimSpace(conv.Subject)
|
|
description := strings.TrimSpace(conv.Description)
|
|
messages := cleanMessages(conv.Messages)
|
|
if description == "" || subject == "" {
|
|
job.FailedCount++
|
|
continue
|
|
}
|
|
|
|
// Humanize BEFORE the gates: strip AI tells (em-dashes, stock openers,
|
|
// AI-accent vocabulary, "not only X but also Y") and apply casual
|
|
// contractions, so the cached copy reads like a person wrote it.
|
|
subject, description, messages = humanizeThread(subject, description, messages)
|
|
|
|
lintBody := description
|
|
if len(messages) > 0 {
|
|
lintBody += "\n\n" + strings.Join(messages, "\n")
|
|
}
|
|
// Reject threads that still read machine-generated after cleanup.
|
|
if humanlint.LooksRobotic(lintBody) {
|
|
job.LintRejectedCount++
|
|
continue
|
|
}
|
|
if err := warmlint.Check(subject, lintBody, false); err != nil {
|
|
job.LintRejectedCount++
|
|
log.Debug().Err(err).Str("job_id", job.ID.String()).Msg("warmup generation: lint rejected a thread")
|
|
continue
|
|
}
|
|
|
|
record := &models.WarmupConversation{
|
|
ID: uuid.New(),
|
|
PoolType: job.PoolType,
|
|
Segment: job.Segment,
|
|
Source: models.WarmupContentSourceAI,
|
|
Theme: theme,
|
|
Subject: subject,
|
|
Description: description,
|
|
Messages: messages,
|
|
Status: "active",
|
|
LintPassed: true,
|
|
GeneratedByJob: &job.ID,
|
|
}
|
|
if err := s.repo.InsertConversation(ctx, record); err != nil {
|
|
job.FailedCount++
|
|
sentry.CaptureException(err)
|
|
continue
|
|
}
|
|
job.GeneratedCount++
|
|
}
|
|
|
|
finished := time.Now()
|
|
job.FinishedAt = &finished
|
|
job.Status = "completed"
|
|
if job.GeneratedCount == 0 && (job.FailedCount > 0 || job.Error != "") {
|
|
job.Status = "failed"
|
|
if job.Error == "" {
|
|
job.Error = fmt.Sprintf("all %d generations failed", job.FailedCount)
|
|
}
|
|
}
|
|
if err := s.repo.UpdateGenerationJob(ctx, job); err != nil {
|
|
log.Warn().Err(err).Str("job_id", job.ID.String()).Msg("warmup generation: failed to finalise job")
|
|
}
|
|
log.Info().
|
|
Str("job_id", job.ID.String()).
|
|
Int("generated", job.GeneratedCount).
|
|
Int("lint_rejected", job.LintRejectedCount).
|
|
Int("failed", job.FailedCount).
|
|
Msg("warmup generation run finished")
|
|
return job.GeneratedCount
|
|
}
|
|
|
|
func cleanMessages(in []string) []string {
|
|
out := make([]string, 0, len(in))
|
|
for _, m := range in {
|
|
m = strings.TrimSpace(m)
|
|
if m != "" {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// humanizeThread runs the deterministic humanizer over a generated thread's
|
|
// subject, opening line, and follow-ups, seeded by content so the same thread
|
|
// humanizes identically (reproducible) while differing across threads. Shared
|
|
// by the sync (runJob) and batch (ingestBatch) ingest paths.
|
|
func humanizeThread(subject, description string, messages []string) (string, string, []string) {
|
|
h := fnv.New64a()
|
|
_, _ = h.Write([]byte(subject + "|" + description))
|
|
seed := int64(h.Sum64())
|
|
|
|
subject = humanlint.HumanizeSubject(subject, seed)
|
|
description = humanlint.Humanize(description, seed+1)
|
|
for i := range messages {
|
|
messages[i] = humanlint.Humanize(messages[i], seed+int64(i)+2)
|
|
}
|
|
return subject, description, messages
|
|
}
|
|
|
|
func dailyRemaining(ctx context.Context, repo repository.WarmupContentRepository, dailyCap int) int {
|
|
if dailyCap <= 0 {
|
|
return 1 << 30 // effectively unlimited
|
|
}
|
|
since := time.Now().Truncate(24 * time.Hour)
|
|
used, err := repo.GeneratedCountSince(ctx, since)
|
|
if err != nil {
|
|
return dailyCap
|
|
}
|
|
return dailyCap - used
|
|
}
|