From abc4a0dd0efbaa7049237a4c531466a94c08bd8b Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Thu, 6 Aug 2026 17:15:42 +0300 Subject: [PATCH 1/3] CON-181: per-campaign scheduling settings consumed by content-plan Give each campaign its own publishing time, timezone, publishing days, and spread, and make the content-plan strategist place every generated draft accordingly. - new src/scheduling package: pure, testable helpers (weekday/clock validation, enabled-day set, day labels, nearest-enabled-day snap, deterministic fnv spread, ComposeScheduledAt) shared by the handler and the flow - campaigns gains publishing_time / timezone / publishing_days / spread_minutes columns + model fields (defaults 09:00 / UTC / all days / +/-15, backfilled) - campaign create/update: normalize + validate the scheduling fields (400 on bad time, timezone, weekday set, or spread range) - content_plan persistOne composes scheduled_at = snap-to-enabled-day @ publishing_time in the campaign timezone +/- deterministic spread, reflects the snapped date back onto the streamed post, and surfaces the enabled weekdays to the model; covers full-plan and CON-114 targeted generation - http-client samples + scheduling/content-plan unit tests --- http-client/campaigns/campaigns.http | 42 +++- ...0260807000001_campaign_scheduling.down.sql | 6 + .../20260807000001_campaign_scheduling.up.sql | 10 + src/genkit/flows/content_plan/generate.go | 28 ++- .../flows/content_plan/generate_test.go | 37 +++- .../content_plan/prompts/content_plan.tmpl | 3 + src/genkit/flows/content_plan/types.go | 5 + src/handlers/campaigns.go | 70 +++++++ src/models/campaign.go | 9 + src/scheduling/scheduling.go | 183 ++++++++++++++++++ src/scheduling/scheduling_test.go | 145 ++++++++++++++ 11 files changed, 526 insertions(+), 12 deletions(-) create mode 100644 src/database/migrations/20260807000001_campaign_scheduling.down.sql create mode 100644 src/database/migrations/20260807000001_campaign_scheduling.up.sql create mode 100644 src/scheduling/scheduling.go create mode 100644 src/scheduling/scheduling_test.go diff --git a/http-client/campaigns/campaigns.http b/http-client/campaigns/campaigns.http index c4e13d55..2eb0f89d 100644 --- a/http-client/campaigns/campaigns.http +++ b/http-client/campaigns/campaigns.http @@ -59,11 +59,45 @@ Content-Type: application/json "budget": 5000.00, "currency": "USD", "language": "en", - "tag_ids": [] + "tag_ids": [], + "publishing_time": "09:00", + "timezone": "Asia/Nicosia", + "publishing_days": ["mon", "wed", "fri"], + "spread_minutes": 15 } > {% client.global.set("campaignId", response.body.id) %} +### Create campaign — invalid publishing_time (expects 400) [protected] +POST {{baseUrl}}/api/campaigns +Content-Type: application/json + +{ + "name": "Bad Time", + "campaign_type_id": "Uk", + "publishing_time": "9am" +} + +### Create campaign — invalid timezone (expects 400) [protected] +POST {{baseUrl}}/api/campaigns +Content-Type: application/json + +{ + "name": "Bad TZ", + "campaign_type_id": "Uk", + "timezone": "Mars/Phobos" +} + +### Create campaign — invalid publishing day (expects 400) [protected] +POST {{baseUrl}}/api/campaigns +Content-Type: application/json + +{ + "name": "Bad Day", + "campaign_type_id": "Uk", + "publishing_days": ["monday"] +} + ### Create campaign — missing name (expects 400) [protected] POST {{baseUrl}}/api/campaigns Content-Type: application/json @@ -129,7 +163,11 @@ Content-Type: application/json "budget": 6000.00, "currency": "USD", "language": "en", - "tag_ids": [] + "tag_ids": [], + "publishing_time": "08:30", + "timezone": "Europe/Kyiv", + "publishing_days": ["mon", "tue", "wed", "thu", "fri"], + "spread_minutes": 20 } ### Update campaign — invalid campaign_type_id (expects 400) [protected] diff --git a/src/database/migrations/20260807000001_campaign_scheduling.down.sql b/src/database/migrations/20260807000001_campaign_scheduling.down.sql new file mode 100644 index 00000000..c2fe4c45 --- /dev/null +++ b/src/database/migrations/20260807000001_campaign_scheduling.down.sql @@ -0,0 +1,6 @@ +-- CON-181: drop the per-campaign scheduling settings. +ALTER TABLE campaigns + DROP COLUMN IF EXISTS publishing_time, + DROP COLUMN IF EXISTS timezone, + DROP COLUMN IF EXISTS publishing_days, + DROP COLUMN IF EXISTS spread_minutes; diff --git a/src/database/migrations/20260807000001_campaign_scheduling.up.sql b/src/database/migrations/20260807000001_campaign_scheduling.up.sql new file mode 100644 index 00000000..271a0320 --- /dev/null +++ b/src/database/migrations/20260807000001_campaign_scheduling.up.sql @@ -0,0 +1,10 @@ +-- CON-181: per-campaign scheduling settings consumed by the content-plan flow. +-- publishing_time is a local "HH:MM" wall clock; timezone is an IANA name +-- ('' = UTC); publishing_days is the enabled weekday set; spread_minutes is the +-- ± jitter applied around the publishing time. Column defaults backfill existing +-- campaigns to 09:00 / UTC / every day / ±15 min. +ALTER TABLE campaigns + ADD COLUMN publishing_time TEXT NOT NULL DEFAULT '09:00', + ADD COLUMN timezone TEXT NOT NULL DEFAULT '', + ADD COLUMN publishing_days jsonb NOT NULL DEFAULT '["mon","tue","wed","thu","fri","sat","sun"]', + ADD COLUMN spread_minutes INTEGER NOT NULL DEFAULT 15; diff --git a/src/genkit/flows/content_plan/generate.go b/src/genkit/flows/content_plan/generate.go index 5cf274e6..1e2efcd0 100644 --- a/src/genkit/flows/content_plan/generate.go +++ b/src/genkit/flows/content_plan/generate.go @@ -17,6 +17,8 @@ import ( "github.com/ogen-app/ogen/src/logging" "github.com/ogen-app/ogen/src/models" "github.com/ogen-app/ogen/src/repository" + "github.com/ogen-app/ogen/src/scheduling" + "github.com/ogen-app/ogen/src/settings" "github.com/ogen-app/ogen/src/vendors/llm" ) @@ -139,6 +141,7 @@ func generatePosts( EstimatedPostCount: estCount, Platforms: platforms, Assets: assets, + PublishingDays: scheduling.DayLabels(campaign.PublishingDays), } // System prompt is identical for every batch — render once. @@ -187,7 +190,7 @@ func generatePosts( // no longer all inherit the full retrieved set — a post that cited no asset // records an empty list. grounded := idSet(assetIDsOf(assets)) - persistFn := func(ctx context.Context, dp DraftPost) (string, error) { + persistFn := func(ctx context.Context, dp *DraftPost) (string, error) { return persistOne(ctx, dp, campaign, repos.Posts, repos.Notes, groundedRefs(dp.AssetRefs, grounded)) } @@ -367,7 +370,7 @@ func generatePostsStreaming( globalStartIndex int, expectedCount int, validate postValidator, - persistFn func(ctx context.Context, post DraftPost) (string, error), + persistFn func(ctx context.Context, post *DraftPost) (string, error), onEvent OnEventFunc, ) ([]DraftPost, error) { scanner := newJSONPostScanner() @@ -398,7 +401,7 @@ func generatePostsStreaming( }) return } - id, err := persistFn(ctx, post) + id, err := persistFn(ctx, &post) if err != nil { slog.ErrorContext(ctx, "persist failed for post", logging.AttrComponent, "genkit.content_plan", "title", post.Title, logging.AttrError, err) emit(onEvent, SSEEventWarning, WarningPayload{ @@ -547,16 +550,27 @@ func withinCount(persisted, expectedCount int) bool { // CON-188: the model's bullet-point thesis (dp.Body) is no longer written into // the post body. The post is created with an empty body and the thesis is // stored as a draft_thesis note, so the assistant can later expand it into copy. -func persistOne(ctx context.Context, dp DraftPost, campaign *models.Campaign, postRepo repository.PostRepository, noteRepo repository.PostNoteRepository, usedAssetIDs []string) (string, error) { +func persistOne(ctx context.Context, dp *DraftPost, campaign *models.Campaign, postRepo repository.PostRepository, noteRepo repository.PostNoteRepository, usedAssetIDs []string) (string, error) { id, err := models.NewID() if err != nil { return "", err } - var scheduledAt *time.Time - if t, err := time.Parse("2006-01-02", dp.PublishDate); err == nil { - scheduledAt = &t + // CON-181: compose scheduled_at from the campaign's scheduling settings — + // snap the model's date to an enabled publishing day, place it at the + // publishing time in the campaign timezone, ± deterministic spread. The + // (possibly snapped) date is written back onto dp so the streamed preview + // matches the persisted instant. + loc, _ := settings.ResolveTimezone(campaign.Timezone) + scheduledAt, effDate, noEnabledDay := scheduling.ComposeScheduledAt( + dp.PublishDate, id, loc, campaign.PublishingTime, campaign.PublishingDays, + campaign.SpreadMinutes, campaign.StartDate, campaign.EndDate, + ) + if noEnabledDay { + slog.WarnContext(ctx, "no enabled publishing day in campaign window; kept model date", + logging.AttrComponent, "genkit.content_plan", "post_id", id, "date", dp.PublishDate) } + dp.PublishDate = effDate var phaseID *string if dp.PhaseID != "" { diff --git a/src/genkit/flows/content_plan/generate_test.go b/src/genkit/flows/content_plan/generate_test.go index 3bbc0dd8..229cfb75 100644 --- a/src/genkit/flows/content_plan/generate_test.go +++ b/src/genkit/flows/content_plan/generate_test.go @@ -3,6 +3,7 @@ package content_plan import ( "context" "testing" + "time" "github.com/ogen-app/ogen/src/models" "github.com/ogen-app/ogen/src/repository" @@ -62,7 +63,7 @@ func TestPersistOne_DraftThesisNote(t *testing.T) { campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"} dp := DraftPost{Title: "T", Body: "- point 1\n- point 2", PlatformID: "linkedin", ContentType: "article"} - id, err := persistOne(context.Background(), dp, campaign, postRepo, noteRepo, nil) + id, err := persistOne(context.Background(), &dp, campaign, postRepo, noteRepo, nil) if err != nil { t.Fatalf("persistOne: %v", err) } @@ -103,7 +104,7 @@ func TestPersistOne_EmptyThesisNoNote(t *testing.T) { campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"} dp := DraftPost{Title: "T", Body: " ", PlatformID: "linkedin", ContentType: "article"} - if _, err := persistOne(context.Background(), dp, campaign, postRepo, noteRepo, nil); err != nil { + if _, err := persistOne(context.Background(), &dp, campaign, postRepo, noteRepo, nil); err != nil { t.Fatalf("persistOne: %v", err) } if postRepo.created == nil { @@ -114,13 +115,43 @@ func TestPersistOne_EmptyThesisNoNote(t *testing.T) { } } +// CON-181: persistOne composes scheduled_at from the campaign's scheduling +// settings — the model's date placed at the publishing time in the campaign +// timezone — and reflects the (unchanged, enabled-day) date back onto dp. +func TestPersistOne_ComposesScheduledAt(t *testing.T) { + postRepo := &stubPostRepo{} + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) + campaign := &models.Campaign{ + ID: "camp1", CreatedBy: "user1", + PublishingTime: "09:00", + Timezone: "", // UTC + PublishingDays: models.StringSlice{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}, + SpreadMinutes: 0, + StartDate: &start, + EndDate: &end, + } + dp := DraftPost{Title: "T", Body: "x", PlatformID: "linkedin", ContentType: "article", PublishDate: "2026-08-12"} + + if _, err := persistOne(context.Background(), &dp, campaign, postRepo, nil, nil); err != nil { + t.Fatalf("persistOne: %v", err) + } + want := time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC) + if postRepo.created.ScheduledAt == nil || !postRepo.created.ScheduledAt.Equal(want) { + t.Errorf("scheduled_at = %v, want %v", postRepo.created.ScheduledAt, want) + } + if dp.PublishDate != "2026-08-12" { + t.Errorf("dp.PublishDate = %q, want 2026-08-12 (reflected)", dp.PublishDate) + } +} + // A nil note repo must not panic — the post is still created (note skipped). func TestPersistOne_NilNoteRepo(t *testing.T) { postRepo := &stubPostRepo{} campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"} dp := DraftPost{Title: "T", Body: "- point 1", PlatformID: "linkedin", ContentType: "article"} - if _, err := persistOne(context.Background(), dp, campaign, postRepo, nil, nil); err != nil { + if _, err := persistOne(context.Background(), &dp, campaign, postRepo, nil, nil); err != nil { t.Fatalf("persistOne: %v", err) } if postRepo.created == nil { diff --git a/src/genkit/flows/content_plan/prompts/content_plan.tmpl b/src/genkit/flows/content_plan/prompts/content_plan.tmpl index c60d2987..b091ca5e 100644 --- a/src/genkit/flows/content_plan/prompts/content_plan.tmpl +++ b/src/genkit/flows/content_plan/prompts/content_plan.tmpl @@ -12,6 +12,9 @@ Hard rules — posts that violate these will be discarded, and any missing posts • Each post's angle and content must serve both the campaign type's overall goal and the specific phase's purpose • Every post must clearly serve the stated campaign objective • body must be a bullet list of 5–7 short theses (key points to expand later), NOT finished copy — maximum 500 characters total +{{- if .PublishingDays}} +• Only choose publishDates that fall on these weekdays: {{.PublishingDays}} — never schedule a post on any other weekday +{{- end}} Output format — respond with a raw JSON array only, no markdown fences, no other text: [ diff --git a/src/genkit/flows/content_plan/types.go b/src/genkit/flows/content_plan/types.go index f577e37c..ac3dcb35 100644 --- a/src/genkit/flows/content_plan/types.go +++ b/src/genkit/flows/content_plan/types.go @@ -106,6 +106,11 @@ type contentPlanTemplateData struct { Platforms []resolvedPlatform Assets []resolvedPiece Batch *batchSpec + // PublishingDays is the comma-separated label list of enabled weekdays + // (e.g. "Mon, Wed, Fri"), or "" when every day is enabled (CON-181). The + // server snaps any stray date to an enabled day regardless; this just + // steers the model up front. + PublishingDays string } // ValidationError is returned by the flow when preconditions are not met. diff --git a/src/handlers/campaigns.go b/src/handlers/campaigns.go index c847c5fc..a486265f 100644 --- a/src/handlers/campaigns.go +++ b/src/handlers/campaigns.go @@ -22,6 +22,8 @@ import ( "github.com/ogen-app/ogen/src/genkit/flows/enrich_brief" "github.com/ogen-app/ogen/src/models" "github.com/ogen-app/ogen/src/repository" + "github.com/ogen-app/ogen/src/scheduling" + "github.com/ogen-app/ogen/src/settings" "github.com/ogen-app/ogen/src/tenantctx" ) @@ -203,6 +205,58 @@ type campaignRequest struct { Currency string `json:"currency"` Language string `json:"language"` TagIDs models.StringSlice `json:"tag_ids"` + // Scheduling settings (CON-181). All optional; omitted fields fall back to + // defaults (09:00 / UTC / every day / ±15 min) via normalizeScheduling. + PublishingTime string `json:"publishing_time"` + Timezone string `json:"timezone"` + PublishingDays models.StringSlice `json:"publishing_days"` + SpreadMinutes *int `json:"spread_minutes"` +} + +// normalizeScheduling validates the request's scheduling fields and returns the +// effective values with defaults applied. A validation failure is returned as a +// 400-worthy error. +func (r *campaignRequest) normalizeScheduling() (publishingTime string, timezone string, days models.StringSlice, spread int, err error) { + publishingTime = strings.TrimSpace(r.PublishingTime) + if publishingTime == "" { + publishingTime = scheduling.DefaultPublishingTime + } else if !scheduling.ValidClock(publishingTime) { + return "", "", nil, 0, fmt.Errorf("publishing_time must be HH:MM (24-hour), got %q", r.PublishingTime) + } + + timezone = strings.TrimSpace(r.Timezone) + if timezone != "" { + if _, tzErr := settings.ResolveTimezone(timezone); tzErr != nil { + return "", "", nil, 0, fmt.Errorf("invalid timezone: %s", timezone) + } + } + + if len(r.PublishingDays) == 0 { + days = scheduling.DefaultPublishingDays() + } else { + seen := make(map[string]bool, len(r.PublishingDays)) + days = make(models.StringSlice, 0, len(r.PublishingDays)) + for _, d := range r.PublishingDays { + tok := strings.ToLower(strings.TrimSpace(d)) + if !scheduling.ValidWeekday(tok) { + return "", "", nil, 0, fmt.Errorf("invalid publishing day: %q", d) + } + if seen[tok] { + return "", "", nil, 0, fmt.Errorf("duplicate publishing day: %q", tok) + } + seen[tok] = true + days = append(days, tok) + } + } + + spread = scheduling.DefaultSpreadMinutes + if r.SpreadMinutes != nil { + spread = *r.SpreadMinutes + if spread < 0 || spread > scheduling.MaxSpreadMinutes { + return "", "", nil, 0, fmt.Errorf("spread_minutes must be between 0 and %d", scheduling.MaxSpreadMinutes) + } + } + return publishingTime, timezone, days, spread, nil } func (r *campaignRequest) toStatus() models.CampaignStatus { @@ -256,6 +310,10 @@ func (h *CampaignsHandler) Create(c *fiber.Ctx) error { if _, err := h.campaignTypeRepo.GetByID(c.Context(), req.CampaignTypeID); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid campaign_type_id") } + publishingTime, timezone, publishingDays, spread, err := req.normalizeScheduling() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } session := c.Locals("session").(*models.Session) @@ -284,6 +342,10 @@ func (h *CampaignsHandler) Create(c *fiber.Ctx) error { Language: req.Language, TagIDs: nullSlice(req.TagIDs), Tags: []models.Tag{}, + PublishingTime: publishingTime, + Timezone: timezone, + PublishingDays: publishingDays, + SpreadMinutes: spread, CreatedBy: session.UserID, } if err := h.repo.Create(c.Context(), campaign); err != nil { @@ -347,6 +409,10 @@ func (h *CampaignsHandler) Update(c *fiber.Ctx) error { if _, err := h.campaignTypeRepo.GetByID(c.Context(), req.CampaignTypeID); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid campaign_type_id") } + publishingTime, timezone, publishingDays, spread, err := req.normalizeScheduling() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } campaign, err := h.repo.GetByID(c.Context(), c.Params("id")) if err != nil { @@ -377,6 +443,10 @@ func (h *CampaignsHandler) Update(c *fiber.Ctx) error { campaign.Currency = req.Currency campaign.Language = req.Language campaign.TagIDs = nullSlice(req.TagIDs) + campaign.PublishingTime = publishingTime + campaign.Timezone = timezone + campaign.PublishingDays = publishingDays + campaign.SpreadMinutes = spread campaign.UpdatedAt = time.Now().UTC() if err := h.repo.Update(c.Context(), campaign); err != nil { diff --git a/src/models/campaign.go b/src/models/campaign.go index d75c68bc..332309f2 100644 --- a/src/models/campaign.go +++ b/src/models/campaign.go @@ -39,6 +39,15 @@ type Campaign struct { EstimatedPostCount *int `bun:"estimated_post_count" json:"estimated_post_count"` Language string `bun:"language,notnull" json:"language"` + // Scheduling settings (CON-181) — consumed by the content-plan flow to + // place each generated draft's scheduled_at. PublishingTime is a local + // "HH:MM" wall clock; Timezone is an IANA name ("" = UTC); PublishingDays + // is a subset of mon..sun; SpreadMinutes is the ± jitter (0 = exact). + PublishingTime string `bun:"publishing_time,notnull,default:'09:00'" json:"publishing_time"` + Timezone string `bun:"timezone,notnull,default:''" json:"timezone"` + PublishingDays StringSlice `bun:"publishing_days,notnull,type:jsonb" json:"publishing_days"` + SpreadMinutes int `bun:"spread_minutes,notnull,default:15" json:"spread_minutes"` + // system Status CampaignStatus `bun:"status,notnull" json:"status"` Budget *float64 `bun:"budget" json:"budget"` diff --git a/src/scheduling/scheduling.go b/src/scheduling/scheduling.go new file mode 100644 index 00000000..7710dbe2 --- /dev/null +++ b/src/scheduling/scheduling.go @@ -0,0 +1,183 @@ +// Package scheduling holds the pure, dependency-free helpers that turn a +// campaign's scheduling settings (CON-181) — publishing time, timezone, +// publishing days, and spread — into a concrete publish instant. It is shared +// by the campaigns handler (validation + defaults) and the content-plan flow +// (composing each generated draft's scheduled_at), so the two never drift. +package scheduling + +import ( + "hash/fnv" + "strconv" + "strings" + "time" +) + +// Defaults applied when a campaign leaves a scheduling field unset. +const ( + DefaultPublishingTime = "09:00" + DefaultSpreadMinutes = 15 + // MaxSpreadMinutes caps the jitter at ±12h — generous, guards the column. + MaxSpreadMinutes = 720 +) + +// AllWeekdayTokens is the canonical set and week order of publishing-day tokens. +var AllWeekdayTokens = []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"} + +var tokenToWeekday = map[string]time.Weekday{ + "sun": time.Sunday, + "mon": time.Monday, + "tue": time.Tuesday, + "wed": time.Wednesday, + "thu": time.Thursday, + "fri": time.Friday, + "sat": time.Saturday, +} + +var weekdayLabel = map[string]string{ + "mon": "Mon", "tue": "Tue", "wed": "Wed", "thu": "Thu", + "fri": "Fri", "sat": "Sat", "sun": "Sun", +} + +// DefaultPublishingDays returns a fresh copy of the all-days default. +func DefaultPublishingDays() []string { + out := make([]string, len(AllWeekdayTokens)) + copy(out, AllWeekdayTokens) + return out +} + +// ValidWeekday reports whether token is a known weekday token (case-insensitive). +func ValidWeekday(token string) bool { + _, ok := tokenToWeekday[strings.ToLower(strings.TrimSpace(token))] + return ok +} + +// ValidClock reports whether s is a valid zero-padded "HH:MM" 24-hour time. +func ValidClock(s string) bool { + _, _, ok := parseClock(s) + return ok +} + +// parseClock parses a zero-padded "HH:MM" 24-hour string. +func parseClock(s string) (hh, mm int, ok bool) { + parts := strings.SplitN(strings.TrimSpace(s), ":", 2) + if len(parts) != 2 || len(parts[0]) != 2 || len(parts[1]) != 2 { + return 0, 0, false + } + h, err1 := strconv.Atoi(parts[0]) + m, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 { + return 0, 0, false + } + return h, m, true +} + +// EnabledWeekdays parses day tokens into a set. An empty or fully-unparseable +// list falls back to all seven days, so scheduling is never blocked. +func EnabledWeekdays(days []string) map[time.Weekday]bool { + set := make(map[time.Weekday]bool, len(days)) + for _, d := range days { + if wd, ok := tokenToWeekday[strings.ToLower(strings.TrimSpace(d))]; ok { + set[wd] = true + } + } + if len(set) == 0 { + for _, wd := range tokenToWeekday { + set[wd] = true + } + } + return set +} + +// DayLabels renders the enabled days as "Mon, Wed, Fri" in week order, or "" +// when all seven are enabled (nothing to restrict the model to). +func DayLabels(days []string) string { + set := EnabledWeekdays(days) + if len(set) >= 7 { + return "" + } + out := make([]string, 0, len(set)) + for _, tok := range AllWeekdayTokens { + if set[tokenToWeekday[tok]] { + out = append(out, weekdayLabel[tok]) + } + } + return strings.Join(out, ", ") +} + +// dateOnly strips the time-of-day, anchoring the date in UTC for weekday math. +func dateOnly(t time.Time) time.Time { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +// SnapToPublishingDay moves date to the nearest enabled publishing day within +// [start, end], preferring later days at equal distance. found is false only +// when the window contains no enabled weekday at all — the caller then keeps the +// original date and warns. +func SnapToPublishingDay(date, start, end time.Time, enabled map[time.Weekday]bool) (snapped time.Time, found bool) { + date, start, end = dateOnly(date), dateOnly(start), dateOnly(end) + if enabled[date.Weekday()] { + return date, true + } + for d := 1; d <= 6; d++ { + if fwd := date.AddDate(0, 0, d); !fwd.After(end) && enabled[fwd.Weekday()] { + return fwd, true + } + if bwd := date.AddDate(0, 0, -d); !bwd.Before(start) && enabled[bwd.Weekday()] { + return bwd, true + } + } + return date, false +} + +// SpreadOffset returns a deterministic minute offset in [-spread, +spread] +// derived from the post id, so posts around the same time fan out without an +// RNG (stable across re-runs). spread <= 0 yields 0. +func SpreadOffset(postID string, spread int) int { + if spread <= 0 { + return 0 + } + h := fnv.New32a() + _, _ = h.Write([]byte(postID)) + return int(h.Sum32()%uint32(2*spread+1)) - spread +} + +// ComposeScheduledAt turns the model's publishDate (YYYY-MM-DD) into a post's +// absolute UTC scheduled_at using the campaign's scheduling settings: snap to an +// enabled publishing day, place it at clock in loc, then nudge it within the +// spread window. It returns the effective (possibly snapped) date string and +// noEnabledDay=true when the campaign window had no enabled weekday (the date is +// kept, only the time-of-day is applied). A malformed publishDate yields a nil +// instant and the original string. +func ComposeScheduledAt(publishDate, postID string, loc *time.Location, clock string, days []string, spread int, start, end *time.Time) (at *time.Time, effectiveDate string, noEnabledDay bool) { + date, err := time.Parse("2006-01-02", publishDate) + if err != nil { + return nil, publishDate, false + } + if loc == nil { + loc = time.UTC + } + + lo, hi := start, end + // Fall back to a ±7-day window around the date when the campaign has no + // bounds (shouldn't happen post-validation) so snapping still resolves. + loBound := date.AddDate(0, 0, -7) + hiBound := date.AddDate(0, 0, 7) + if lo != nil { + loBound = *lo + } + if hi != nil { + hiBound = *hi + } + + day, found := SnapToPublishingDay(date, loBound, hiBound, EnabledWeekdays(days)) + + hh, mm, ok := parseClock(clock) + if !ok { + hh, mm = 9, 0 + } + y, mo, d := day.Date() + local := time.Date(y, mo, d, hh, mm, 0, 0, loc) + t := local.Add(time.Duration(SpreadOffset(postID, spread)) * time.Minute).UTC() + return &t, day.Format("2006-01-02"), !found +} diff --git a/src/scheduling/scheduling_test.go b/src/scheduling/scheduling_test.go new file mode 100644 index 00000000..2968c7cd --- /dev/null +++ b/src/scheduling/scheduling_test.go @@ -0,0 +1,145 @@ +package scheduling + +import ( + "testing" + "time" +) + +var wdToken = map[time.Weekday]string{ + time.Monday: "mon", time.Tuesday: "tue", time.Wednesday: "wed", time.Thursday: "thu", + time.Friday: "fri", time.Saturday: "sat", time.Sunday: "sun", +} + +func TestValidClock(t *testing.T) { + cases := map[string]bool{ + "09:00": true, "00:00": true, "23:59": true, + "9:00": false, "24:00": false, "12:60": false, "": false, "0900": false, "09:0": false, + } + for in, want := range cases { + if got := ValidClock(in); got != want { + t.Errorf("ValidClock(%q) = %v, want %v", in, got, want) + } + } +} + +func TestValidWeekday(t *testing.T) { + for _, ok := range []string{"mon", "MON", " tue ", "sun"} { + if !ValidWeekday(ok) { + t.Errorf("ValidWeekday(%q) = false, want true", ok) + } + } + for _, bad := range []string{"monday", "xyz", "", "8"} { + if ValidWeekday(bad) { + t.Errorf("ValidWeekday(%q) = true, want false", bad) + } + } +} + +func TestEnabledWeekdays(t *testing.T) { + if got := EnabledWeekdays(nil); len(got) != 7 { + t.Errorf("empty → %d days, want 7 (fallback)", len(got)) + } + if got := EnabledWeekdays([]string{"bogus"}); len(got) != 7 { + t.Errorf("all-invalid → %d days, want 7 (fallback)", len(got)) + } + got := EnabledWeekdays([]string{"mon", "wed"}) + if !got[time.Monday] || !got[time.Wednesday] || got[time.Tuesday] || len(got) != 2 { + t.Errorf("['mon','wed'] → %v", got) + } +} + +func TestDayLabels(t *testing.T) { + if got := DayLabels(AllWeekdayTokens); got != "" { + t.Errorf("all days → %q, want empty", got) + } + if got := DayLabels([]string{"fri", "mon", "wed"}); got != "Mon, Wed, Fri" { + t.Errorf("subset → %q, want 'Mon, Wed, Fri' (week order)", got) + } +} + +func TestSnapToPublishingDay(t *testing.T) { + date := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC) + start, end := date.AddDate(0, 0, -7), date.AddDate(0, 0, 7) + + // Already on an enabled day → unchanged. + if got, found := SnapToPublishingDay(date, start, end, map[time.Weekday]bool{date.Weekday(): true}); !found || !got.Equal(date) { + t.Errorf("on-enabled: got %v found %v, want %v true", got, found, date) + } + + // Disabled, next day enabled → snap forward one day. + next := map[time.Weekday]bool{(date.Weekday() + 1) % 7: true} + if got, found := SnapToPublishingDay(date, start, end, next); !found || !got.Equal(date.AddDate(0, 0, 1)) { + t.Errorf("snap-forward: got %v found %v", got, found) + } + + // Equal distance both sides → forward preferred. + both := map[time.Weekday]bool{(date.Weekday() + 1) % 7: true, (date.Weekday() + 6) % 7: true} + if got, _ := SnapToPublishingDay(date, start, end, both); !got.Equal(date.AddDate(0, 0, 1)) { + t.Errorf("equal-distance: got %v, want forward %v", got, date.AddDate(0, 0, 1)) + } + + // Single-day window on a disabled day → no enabled day found. + if got, found := SnapToPublishingDay(date, date, date, next); found || !got.Equal(date) { + t.Errorf("no-enabled-in-window: got %v found %v, want %v false", got, found, date) + } +} + +func TestSpreadOffset(t *testing.T) { + if got := SpreadOffset("anything", 0); got != 0 { + t.Errorf("spread 0 → %d, want 0", got) + } + // Deterministic + bounded. + a, b := SpreadOffset("post-123", 30), SpreadOffset("post-123", 30) + if a != b { + t.Errorf("not deterministic: %d vs %d", a, b) + } + if a < -30 || a > 30 { + t.Errorf("offset %d out of ±30", a) + } + // Different ids should generally differ (guards a constant-hash bug). + if SpreadOffset("a", 30) == SpreadOffset("b", 30) && SpreadOffset("c", 30) == SpreadOffset("d", 30) { + t.Error("offsets look constant across ids") + } +} + +func TestComposeScheduledAt(t *testing.T) { + loc := time.FixedZone("UTC+2", 2*3600) + date := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC) + start, end := date.AddDate(0, 0, -7), date.AddDate(0, 0, 7) + ds := date.Format("2006-01-02") + + // Enabled day, no jitter → date @ 09:00 in loc, as UTC (07:00Z). + at, eff, no := ComposeScheduledAt(ds, "p1", loc, "09:00", []string{wdToken[date.Weekday()]}, 0, &start, &end) + want := time.Date(2026, 8, 12, 9, 0, 0, 0, loc).UTC() + if at == nil || !at.Equal(want) { + t.Errorf("compose = %v, want %v", at, want) + } + if eff != ds || no { + t.Errorf("effectiveDate=%q noEnabledDay=%v, want %q false", eff, no, ds) + } + + // Disabled day, only next weekday enabled → snapped forward; effectiveDate moves. + at2, eff2, _ := ComposeScheduledAt(ds, "p1", loc, "09:00", []string{wdToken[(date.Weekday()+1)%7]}, 0, &start, &end) + wantDay := date.AddDate(0, 0, 1) + if eff2 != wantDay.Format("2006-01-02") { + t.Errorf("snap effectiveDate=%q, want %q", eff2, wantDay.Format("2006-01-02")) + } + if at2 == nil || !at2.Equal(time.Date(2026, 8, 13, 9, 0, 0, 0, loc).UTC()) { + t.Errorf("snapped instant = %v", at2) + } + + // Malformed date → nil instant, original string echoed. + if at3, eff3, _ := ComposeScheduledAt("not-a-date", "p1", loc, "09:00", nil, 0, &start, &end); at3 != nil || eff3 != "not-a-date" { + t.Errorf("malformed → %v %q, want nil 'not-a-date'", at3, eff3) + } + + // Jitter deterministic + within window. + j1, _, _ := ComposeScheduledAt(ds, "same-id", loc, "09:00", []string{wdToken[date.Weekday()]}, 30, &start, &end) + j2, _, _ := ComposeScheduledAt(ds, "same-id", loc, "09:00", []string{wdToken[date.Weekday()]}, 30, &start, &end) + if !j1.Equal(*j2) { + t.Errorf("jitter not deterministic: %v vs %v", j1, j2) + } + if d := j1.Sub(want).Minutes(); d < -30 || d > 30 { + t.Errorf("jitter drift %.0f min out of ±30", d) + } +} From 0de568df1ac949f1f3ab469d6255827208766871 Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Thu, 6 Aug 2026 21:36:46 +0300 Subject: [PATCH 2/3] CON-181: bound content-plan day-snapping to the active generation window persistOne snapped each post's publishing day within the full campaign window even for CON-114 targeted generation, whose window is narrower. A post on a disabled edge-day could snap onto an enabled day outside the targeting window the validator had just checked. Thread the active startDate/endDate into persistOne and ComposeScheduledAt so snapping stays inside the requested window; the full-plan path is unchanged. Adds TestPersistOne_SnapsWithinWindow and updates existing call sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/genkit/flows/content_plan/generate.go | 15 ++++-- .../flows/content_plan/generate_test.go | 49 +++++++++++++++++-- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/genkit/flows/content_plan/generate.go b/src/genkit/flows/content_plan/generate.go index 1e2efcd0..3e08d4c3 100644 --- a/src/genkit/flows/content_plan/generate.go +++ b/src/genkit/flows/content_plan/generate.go @@ -190,8 +190,11 @@ func generatePosts( // no longer all inherit the full retrieved set — a post that cited no asset // records an empty list. grounded := idSet(assetIDsOf(assets)) + // startDate/endDate are the active generation window — the campaign window, or + // the CON-114 targeting window when tgt != nil. persistOne snaps each post's + // publishing day within these bounds so a targeted run stays inside its window. persistFn := func(ctx context.Context, dp *DraftPost) (string, error) { - return persistOne(ctx, dp, campaign, repos.Posts, repos.Notes, groundedRefs(dp.AssetRefs, grounded)) + return persistOne(ctx, dp, campaign, &startDate, &endDate, repos.Posts, repos.Notes, groundedRefs(dp.AssetRefs, grounded)) } // Fill the parallel budget (CON-112 perf): a plan that fits in one batch is @@ -550,7 +553,7 @@ func withinCount(persisted, expectedCount int) bool { // CON-188: the model's bullet-point thesis (dp.Body) is no longer written into // the post body. The post is created with an empty body and the thesis is // stored as a draft_thesis note, so the assistant can later expand it into copy. -func persistOne(ctx context.Context, dp *DraftPost, campaign *models.Campaign, postRepo repository.PostRepository, noteRepo repository.PostNoteRepository, usedAssetIDs []string) (string, error) { +func persistOne(ctx context.Context, dp *DraftPost, campaign *models.Campaign, windowStart, windowEnd *time.Time, postRepo repository.PostRepository, noteRepo repository.PostNoteRepository, usedAssetIDs []string) (string, error) { id, err := models.NewID() if err != nil { return "", err @@ -560,14 +563,16 @@ func persistOne(ctx context.Context, dp *DraftPost, campaign *models.Campaign, p // snap the model's date to an enabled publishing day, place it at the // publishing time in the campaign timezone, ± deterministic spread. The // (possibly snapped) date is written back onto dp so the streamed preview - // matches the persisted instant. + // matches the persisted instant. Snapping is bounded by the active generation + // window (windowStart/windowEnd) — the campaign window, or the CON-114 + // targeting window — so a targeted run never snaps a post outside its window. loc, _ := settings.ResolveTimezone(campaign.Timezone) scheduledAt, effDate, noEnabledDay := scheduling.ComposeScheduledAt( dp.PublishDate, id, loc, campaign.PublishingTime, campaign.PublishingDays, - campaign.SpreadMinutes, campaign.StartDate, campaign.EndDate, + campaign.SpreadMinutes, windowStart, windowEnd, ) if noEnabledDay { - slog.WarnContext(ctx, "no enabled publishing day in campaign window; kept model date", + slog.WarnContext(ctx, "no enabled publishing day in window; kept model date", logging.AttrComponent, "genkit.content_plan", "post_id", id, "date", dp.PublishDate) } dp.PublishDate = effDate diff --git a/src/genkit/flows/content_plan/generate_test.go b/src/genkit/flows/content_plan/generate_test.go index 229cfb75..090ab410 100644 --- a/src/genkit/flows/content_plan/generate_test.go +++ b/src/genkit/flows/content_plan/generate_test.go @@ -63,7 +63,7 @@ func TestPersistOne_DraftThesisNote(t *testing.T) { campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"} dp := DraftPost{Title: "T", Body: "- point 1\n- point 2", PlatformID: "linkedin", ContentType: "article"} - id, err := persistOne(context.Background(), &dp, campaign, postRepo, noteRepo, nil) + id, err := persistOne(context.Background(), &dp, campaign, campaign.StartDate, campaign.EndDate, postRepo, noteRepo, nil) if err != nil { t.Fatalf("persistOne: %v", err) } @@ -104,7 +104,7 @@ func TestPersistOne_EmptyThesisNoNote(t *testing.T) { campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"} dp := DraftPost{Title: "T", Body: " ", PlatformID: "linkedin", ContentType: "article"} - if _, err := persistOne(context.Background(), &dp, campaign, postRepo, noteRepo, nil); err != nil { + if _, err := persistOne(context.Background(), &dp, campaign, campaign.StartDate, campaign.EndDate, postRepo, noteRepo, nil); err != nil { t.Fatalf("persistOne: %v", err) } if postRepo.created == nil { @@ -133,7 +133,7 @@ func TestPersistOne_ComposesScheduledAt(t *testing.T) { } dp := DraftPost{Title: "T", Body: "x", PlatformID: "linkedin", ContentType: "article", PublishDate: "2026-08-12"} - if _, err := persistOne(context.Background(), &dp, campaign, postRepo, nil, nil); err != nil { + if _, err := persistOne(context.Background(), &dp, campaign, campaign.StartDate, campaign.EndDate, postRepo, nil, nil); err != nil { t.Fatalf("persistOne: %v", err) } want := time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC) @@ -151,10 +151,51 @@ func TestPersistOne_NilNoteRepo(t *testing.T) { campaign := &models.Campaign{ID: "camp1", CreatedBy: "user1"} dp := DraftPost{Title: "T", Body: "- point 1", PlatformID: "linkedin", ContentType: "article"} - if _, err := persistOne(context.Background(), &dp, campaign, postRepo, nil, nil); err != nil { + if _, err := persistOne(context.Background(), &dp, campaign, campaign.StartDate, campaign.EndDate, postRepo, nil, nil); err != nil { t.Fatalf("persistOne: %v", err) } if postRepo.created == nil { t.Fatal("expected a post to be created even with a nil note repo") } } + +// CON-114: day-snapping is bounded by the window passed to persistOne (the +// targeting window), not the full campaign window — so a targeted run can't snap +// a post onto an enabled day outside the requested window. +func TestPersistOne_SnapsWithinWindow(t *testing.T) { + postRepo := &stubPostRepo{} + campStart := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + campEnd := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) + date := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC) + + // Enable only the weekday AFTER the model's date, so the date itself is on a + // disabled day and the nearest enabled day is date+1 — inside the campaign + // window but outside the single-day targeting window below. + tok := map[time.Weekday]string{ + time.Sunday: "sun", time.Monday: "mon", time.Tuesday: "tue", time.Wednesday: "wed", + time.Thursday: "thu", time.Friday: "fri", time.Saturday: "sat", + } + campaign := &models.Campaign{ + ID: "camp1", CreatedBy: "user1", + PublishingTime: "09:00", Timezone: "", + PublishingDays: models.StringSlice{tok[(date.Weekday()+1)%7]}, + SpreadMinutes: 0, + StartDate: &campStart, EndDate: &campEnd, + } + dp := DraftPost{Title: "T", Body: "x", PlatformID: "linkedin", ContentType: "article", PublishDate: "2026-08-12"} + + // Targeting window is the single (disabled) day: no enabled day inside it, so + // the date is kept rather than snapped forward to date+1. With the full + // campaign window it would snap to 2026-08-13. + win := date + if _, err := persistOne(context.Background(), &dp, campaign, &win, &win, postRepo, nil, nil); err != nil { + t.Fatalf("persistOne: %v", err) + } + want := time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC) + if postRepo.created.ScheduledAt == nil || !postRepo.created.ScheduledAt.Equal(want) { + t.Errorf("scheduled_at = %v, want %v (kept in-window, not snapped to date+1)", postRepo.created.ScheduledAt, want) + } + if dp.PublishDate != "2026-08-12" { + t.Errorf("dp.PublishDate = %q, want 2026-08-12 (not snapped out of window)", dp.PublishDate) + } +} From b4c4f5b2e06108d64af3bceaca24f7de50ebc0ac Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Thu, 6 Aug 2026 21:39:15 +0300 Subject: [PATCH 3/3] CON-181: clamp spread jitter to the snapped publishing day A near-midnight publishing time combined with a large spread could push the jittered instant into an adjacent calendar day, landing the post on a day outside PublishingDays or the campaign bounds, and disagreeing with the returned effectiveDate. Clamp the spread-adjusted local time to [00:00, 23:59] of the snapped day before converting to UTC, retaining only the on-day portion of the jitter. Adds boundary tests for positive and negative spreads at both day edges. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scheduling/scheduling.go | 13 ++++++- src/scheduling/scheduling_test.go | 60 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/scheduling/scheduling.go b/src/scheduling/scheduling.go index 7710dbe2..25796839 100644 --- a/src/scheduling/scheduling.go +++ b/src/scheduling/scheduling.go @@ -178,6 +178,17 @@ func ComposeScheduledAt(publishDate, postID string, loc *time.Location, clock st } y, mo, d := day.Date() local := time.Date(y, mo, d, hh, mm, 0, 0, loc) - t := local.Add(time.Duration(SpreadOffset(postID, spread)) * time.Minute).UTC() + // Keep the jitter on the selected day. A publishing time near midnight plus a + // large spread could otherwise push the instant into an adjacent calendar + // date — landing on a day outside PublishingDays or the campaign bounds, and + // disagreeing with the effectiveDate we return. Clamp to [00:00, 23:59] of the + // day in loc so only the on-day portion of the jitter is retained. + adjusted := local.Add(time.Duration(SpreadOffset(postID, spread)) * time.Minute) + if dayStart := time.Date(y, mo, d, 0, 0, 0, 0, loc); adjusted.Before(dayStart) { + adjusted = dayStart + } else if dayEnd := time.Date(y, mo, d, 23, 59, 0, 0, loc); adjusted.After(dayEnd) { + adjusted = dayEnd + } + t := adjusted.UTC() return &t, day.Format("2006-01-02"), !found } diff --git a/src/scheduling/scheduling_test.go b/src/scheduling/scheduling_test.go index 2968c7cd..120394e2 100644 --- a/src/scheduling/scheduling_test.go +++ b/src/scheduling/scheduling_test.go @@ -1,6 +1,7 @@ package scheduling import ( + "strconv" "testing" "time" ) @@ -143,3 +144,62 @@ func TestComposeScheduledAt(t *testing.T) { t.Errorf("jitter drift %.0f min out of ±30", d) } } + +func TestComposeScheduledAt_SpreadClampedToDay(t *testing.T) { + loc := time.UTC + date := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC) // days=nil → all enabled → no snap + start, end := date.AddDate(0, 0, -7), date.AddDate(0, 0, 7) + ds := date.Format("2006-01-02") + + dayStart := time.Date(2026, 8, 12, 0, 0, 0, 0, loc).UTC() + dayEnd := time.Date(2026, 8, 12, 23, 59, 0, 0, loc).UTC() + + // Force one id to a negative offset and one to a positive offset so both + // boundary directions are exercised deterministically. + var negID, posID string + for i := 0; negID == "" || posID == ""; i++ { + if i > 10000 { + t.Fatal("could not find both signed offsets") + } + id := "post-" + strconv.Itoa(i) + switch off := SpreadOffset(id, MaxSpreadMinutes); { + case off < 0 && negID == "": + negID = id + case off > 0 && posID == "": + posID = id + } + } + + // Negative spread at 00:00 must clamp UP to 00:00, not roll into the prior day. + atNeg, effNeg, _ := ComposeScheduledAt(ds, negID, loc, "00:00", nil, MaxSpreadMinutes, &start, &end) + if atNeg == nil || !atNeg.Equal(dayStart) { + t.Errorf("00:00 with negative spread = %v, want clamped to %v", atNeg, dayStart) + } + if effNeg != ds { + t.Errorf("negative-spread effectiveDate = %q, want %q (must not cross to prior day)", effNeg, ds) + } + + // Positive spread at 23:59 must clamp DOWN to 23:59, not roll into the next day. + atPos, effPos, _ := ComposeScheduledAt(ds, posID, loc, "23:59", nil, MaxSpreadMinutes, &start, &end) + if atPos == nil || !atPos.Equal(dayEnd) { + t.Errorf("23:59 with positive spread = %v, want clamped to %v", atPos, dayEnd) + } + if effPos != ds { + t.Errorf("positive-spread effectiveDate = %q, want %q (must not cross to next day)", effPos, ds) + } + + // Invariant across many ids at both boundary clocks: the instant never leaves + // the day and effectiveDate always agrees. + for i := 0; i < 300; i++ { + id := "x-" + strconv.Itoa(i) + for _, clock := range []string{"00:00", "23:59"} { + at, eff, _ := ComposeScheduledAt(ds, id, loc, clock, nil, MaxSpreadMinutes, &start, &end) + if at == nil || at.Before(dayStart) || at.After(dayEnd) { + t.Fatalf("clock %s id %s → %v, out of [%v, %v]", clock, id, at, dayStart, dayEnd) + } + if eff != ds { + t.Fatalf("clock %s id %s → effectiveDate %q, want %q", clock, id, eff, ds) + } + } + } +}