Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions http-client/campaigns/campaigns.http
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions src/database/migrations/20260807000001_campaign_scheduling.up.sql
Original file line number Diff line number Diff line change
@@ -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;
35 changes: 27 additions & 8 deletions src/genkit/flows/content_plan/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -187,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))
persistFn := func(ctx context.Context, dp DraftPost) (string, error) {
return persistOne(ctx, dp, campaign, repos.Posts, repos.Notes, groundedRefs(dp.AssetRefs, grounded))
// 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, &startDate, &endDate, repos.Posts, repos.Notes, groundedRefs(dp.AssetRefs, grounded))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Fill the parallel budget (CON-112 perf): a plan that fits in one batch is
Expand Down Expand Up @@ -367,7 +373,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()
Expand Down Expand Up @@ -398,7 +404,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{
Expand Down Expand Up @@ -547,16 +553,29 @@ 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
}

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. 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, windowStart, windowEnd,
)
if noEnabledDay {
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

var phaseID *string
if dp.PhaseID != "" {
Expand Down
78 changes: 75 additions & 3 deletions src/genkit/flows/content_plan/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, campaign.StartDate, campaign.EndDate, postRepo, noteRepo, nil)
if err != nil {
t.Fatalf("persistOne: %v", err)
}
Expand Down Expand Up @@ -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, campaign.StartDate, campaign.EndDate, postRepo, noteRepo, nil); err != nil {
t.Fatalf("persistOne: %v", err)
}
if postRepo.created == nil {
Expand All @@ -114,16 +115,87 @@ 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, 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)
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, 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)
}
}
3 changes: 3 additions & 0 deletions src/genkit/flows/content_plan/prompts/content_plan.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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:
[
Expand Down
5 changes: 5 additions & 0 deletions src/genkit/flows/content_plan/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading