diff --git a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl index 0a85930..555941d 100644 --- a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl +++ b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl @@ -3,7 +3,7 @@ You are the Campaign Assistant for Ogen, a social-media content platform. You he You can help with: 1. Generate a content plan — a full set of draft posts across the campaign's platforms and phases (runContentPlan). -2. Add targeted posts — a few new drafts for a specific platform, phase, and timeframe (generatePosts). +2. Add targeted posts — one or more new drafts for a specific platform, phase, and timeframe (generatePosts). 3. Enrich the campaign brief — improve its description, target persona, key messages, and tone guidelines (enrichBrief). 4. Change the campaign's start/end dates (setCampaignDates), and redistribute the publish dates of its non-published drafts across the timeline (redistributePosts). 5. Review consistency — check the brief for internal consistency and completeness (checkBrief), and check whether the campaign's non-published posts follow the brief (checkPostsConsistency). Both are read-only. @@ -15,7 +15,7 @@ You can help with: - enrichBrief: Call this when the user asks to enrich, improve, refine, sharpen, or rewrite the brief (e.g. "enrich the brief", "improve the brief", "make the brief more B2B"). Pass the user's steering as the `instruction` argument when they give any. The enriched brief is saved to the campaign automatically — you do not need to ask for confirmation. - listCampaignPosts: Call this when you need to know what posts already exist in the campaign to answer a question. Takes no arguments. - getCampaignOverview: Call this for a quick overview or status of the campaign, or for how content is distributed — e.g. "give me an overview", "how is content spread across phases", "which phase has the least content", "how many posts per platform / status / content type". It returns the phases with per-phase post counts and the distribution by status, platform, and content type. Takes no arguments. -- generatePosts: Call this to ADD a few new draft posts targeted at a specific platform, phase, and timeframe — e.g. "add a few Threads posts in the current phase for the upcoming weeks", "generate 5 LinkedIn articles for the launch phase". This is different from runContentPlan (which regenerates the whole plan). Only platforms the campaign already targets are allowed; if the user names a different platform, do NOT call the tool — tell them it isn't a target platform and offer to add it or pick a targeted one. Pass phase:"current" (or omit) for the current phase. Resolve the requested timeframe into windowStart/windowEnd (ISO YYYY-MM-DD) using today's date shown below; omit both to default to the next two weeks. Infer count from the request ("a few" = 3); it is capped per call. +- generatePosts: Call this to ADD one or more new draft posts targeted at a specific platform, phase, and timeframe — e.g. "add a few Threads posts in the current phase for the upcoming weeks", "generate 5 LinkedIn articles for the launch phase", "write 1 post for Threads". This is different from runContentPlan (which regenerates the whole plan). Only platforms the campaign already targets are allowed; if the user names a different platform, do NOT call the tool — tell them it isn't a target platform and offer to add it or pick a targeted one. Pass phase:"current" (or omit) for the current phase. Resolve the requested timeframe into windowStart/windowEnd (ISO YYYY-MM-DD) using today's date shown below; omit both to default to the next two weeks; for a single specific date ("for Aug 20"), set BOTH windowStart and windowEnd to that same date so the post lands exactly there. The window must be today or later: if the user asks for a date BEFORE today (shown below), do NOT call generatePosts — tell them that date has already passed and offer to use today or a future date. ALWAYS set count: the exact number the user names ("write 1 post" -> count 1, "generate 5 articles" -> count 5), or 3 for a vague "a few"/"some". If you leave count out, only 1 post is created — so never omit it for a multi-post request. Count is capped per call. - setCampaignDates: Call this when the user asks to move, shift, extend, or shorten the campaign's start/end dates — e.g. "move the campaign end to the beginning of July", "push the start to next Monday". Resolve relative phrasing into ISO YYYY-MM-DD using today's date below; pass only the field(s) that change. The change is saved automatically. When the result reports posts now outside the new range, MENTION the count and OFFER to redistribute them — but do NOT call redistributePosts in the same turn unless the user also asked to. - redistributePosts: Call this when the user asks to redistribute, re-spread, rebalance, or re-schedule the drafts / unpublished posts — e.g. "redistribute the drafts". It evenly re-dates only draft and ready-for-publish posts across the timeline, phase by phase; it never moves already-scheduled or published posts. Takes no arguments. - checkBrief: Call this when the user asks to check, review, or critique the brief, or whether the brief is consistent/complete — e.g. "is the brief consistent?", "review the brief". It is READ-ONLY — it returns findings with suggestions but does NOT change the brief. Takes no arguments. When it reports issues, summarise them and OFFER to improve the brief with enrichBrief — but do NOT call enrichBrief in the same turn unless the user also asks you to. @@ -43,7 +43,7 @@ Rules for `action`: - "answered" — you answered a question or gave an overview without changing anything. - "declined" — the request is out of scope (anything outside the capabilities above, e.g. deleting posts, publishing, billing). Explain briefly what you can help with instead. -Keep `explanation` to a few sentences. Never put the full generated posts or the full brief into `explanation` — the client already receives those through their own channels. +Keep `explanation` to a few sentences. Never put the full generated posts or the full brief into `explanation` — the client already receives those through their own channels. When you mention specific posts, state only the publish dates and counts the tool actually returned (e.g. generatePosts' `dates`) — never invent, guess, or infer a date the tool did not report. When a `runContentPlan` or `generatePosts` result includes `usedAssets`, briefly note in your `explanation` that the posts drew on those attached assets, naming them by title (e.g. "drawing on your pricing PDF and brand guide"). Say nothing about assets when `usedAssets` is empty. {{end}} diff --git a/src/genkit/flows/campaign_assistant/tools.go b/src/genkit/flows/campaign_assistant/tools.go index e4a6155..767e2b5 100644 --- a/src/genkit/flows/campaign_assistant/tools.go +++ b/src/genkit/flows/campaign_assistant/tools.go @@ -109,9 +109,9 @@ type CampaignPostInfo struct { type GeneratePostsInput struct { Platforms []string `json:"platforms" jsonschema:"description=Platform names or ids to generate for, e.g. [\"Threads\"]. Must be platforms the campaign already targets."` Phase string `json:"phase,omitempty" jsonschema:"description=Phase name, id, or \"current\"; omit for the current phase."` - Count int `json:"count,omitempty" jsonschema:"description=How many posts to add; omit to infer from the request (a few = 3)."` + Count int `json:"count,omitempty" jsonschema:"description=Number of posts to add: the exact number the user names (\"add 1 post\"->1, \"5 articles\"->5), or 3 for a vague \"a few\"/\"some\". Always set it; if omitted only 1 post is created. Capped per call."` WindowStart string `json:"windowStart,omitempty" jsonschema:"description=First publish date (ISO YYYY-MM-DD), resolved from the requested timeframe against today."` - WindowEnd string `json:"windowEnd,omitempty" jsonschema:"description=Last publish date (ISO YYYY-MM-DD)."` + WindowEnd string `json:"windowEnd,omitempty" jsonschema:"description=Last publish date (ISO YYYY-MM-DD). For a single specific date, set this equal to windowStart."` PostType string `json:"postType,omitempty" jsonschema:"description=Optional post-type slug (e.g. text-post, article); omit for the platform default."` } @@ -122,7 +122,8 @@ type GeneratePostsOutput struct { Clamped bool `json:"clamped"` // true when requestedCount exceeded the per-call cap PhaseID string `json:"phaseId"` PhaseName string `json:"phaseName"` - Platforms []string `json:"platforms"` // resolved platform names + Platforms []string `json:"platforms"` // resolved platform names + Dates []string `json:"dates,omitempty"` // CON-114: actual publish dates of the created posts, so the model reports them instead of inventing dates Warnings []string `json:"warnings,omitempty"` UsedAssets []AssetRef `json:"usedAssets,omitempty"` // CON-118: assets that informed the posts } @@ -444,27 +445,21 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos return nil, err } - // Count: default 3 when vague, clamp to [1, cap]. - maxN := st.maxGeneratePosts - if maxN <= 0 { - maxN = 10 - } - requested := in.Count - if requested <= 0 { - requested = 3 - } - count := requested - clamped := false - if count > maxN { - count = maxN - clamped = true - } + // Count: honor an explicit number exactly (so "add 1 post" yields 1); fall + // back to 1 (the safe minimum) when the model omitted it; clamp to the + // per-call cap. Extracted as resolveGenerateCount for unit testing. + count, requested, clamped := resolveGenerateCount(in.Count, st.maxGeneratePosts) // Window: default to the next 14 days when omitted; validate otherwise. windowStart, windowEnd, err := resolveWindow(in.WindowStart, in.WindowEnd, now) if err != nil { return nil, err } + // A lone post has nothing to spread across a 14-day window, so "generate 1 + // for Jul 22" must land ON Jul 22 — not the window's midpoint. When the model + // gave only a start for a single post, collapse the derived range to that day + // so validation pins the publish date exactly (CON-114). + windowEnd = singlePostWindowEnd(windowStart, windowEnd, in.WindowEnd, count) emit(st.onEvent, SSEEventGeneratePostsStarted, GeneratePostsStartedEventPayload{ PlatformIDs: platformIDs, @@ -517,11 +512,59 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos PhaseID: phaseID, PhaseName: phaseName, Platforms: platformNames, + Dates: publishDatesOf(resp.Posts), Warnings: resp.Warnings, UsedAssets: used, }, nil } +// resolveGenerateCount maps the model-supplied count to the number of posts the +// generatePosts tool will actually create. An explicit positive count is honored +// exactly. A missing or non-positive count defaults to 1 — the safe minimum, so +// a planner that omits count (as Haiku does for "generate 1 post") can never +// over-produce; the model is instead told to pass 3 for a vague "a few". Anything +// above the per-call cap (maxN, default 10) is clamped down. Returns the +// effective count, the requested count after the default is applied (surfaced as +// RequestedCount), and whether the request was clamped. +func resolveGenerateCount(requested, maxN int) (count, requestedOut int, clamped bool) { + if maxN <= 0 { + maxN = 10 + } + if requested <= 0 { + requested = 1 + } + count = requested + if count > maxN { + count = maxN + clamped = true + } + return count, requested, clamped +} + +// singlePostWindowEnd collapses a derived date range to a single day when the +// tool is creating exactly one post and the user gave no explicit end. A lone +// post has nothing to spread across a window, so "generate 1 for Jul 22" must +// land on Jul 22 rather than the midpoint of resolveWindow's 14-day default. An +// explicit end (rawEnd != "") or a multi-post request keeps the resolved end. +func singlePostWindowEnd(resolvedStart, resolvedEnd, rawEnd string, count int) string { + if count == 1 && rawEnd == "" { + return resolvedStart + } + return resolvedEnd +} + +// publishDatesOf extracts the actual publish dates of the created posts, so the +// model reports the real dates in its reply instead of inventing them (CON-114). +func publishDatesOf(posts []content_plan.DraftPost) []string { + out := make([]string, 0, len(posts)) + for _, p := range posts { + if p.PublishDate != "" { + out = append(out, p.PublishDate) + } + } + return out +} + // minAskAssetsSimilarity is the cosine-similarity floor for asset Q&A. Lower // than content_plan's generation threshold: a specific question benefits from // recall, and the planner filters the returned excerpts (CON-118). @@ -815,11 +858,15 @@ func resolveWindow(startStr, endStr string, today time.Time) (string, string, er if e.Before(s) { return "", "", fmt.Errorf("the timeframe's end is before its start") } - if s.Before(todayDate) { // never date drafts in the past - s = todayDate - if e.Before(s) { - e = s - } + // Reject an explicitly-requested past date rather than silently clamping it + // to today (CON-114). Derived bounds default to today, so only a user-supplied + // start/end can be in the past here; the planner is told to catch this first + // and reply conversationally, and this is the backstop. + if haveStart && s.Before(todayDate) { + return "", "", fmt.Errorf("%s is in the past — choose %s (today) or a later date", startStr, todayDate.Format(iso)) + } + if haveEnd && e.Before(todayDate) { + return "", "", fmt.Errorf("%s is in the past — choose %s (today) or a later date", endStr, todayDate.Format(iso)) } return s.Format(iso), e.Format(iso), nil } diff --git a/src/genkit/flows/campaign_assistant/tools_test.go b/src/genkit/flows/campaign_assistant/tools_test.go index 7fd3d84..8eb6a46 100644 --- a/src/genkit/flows/campaign_assistant/tools_test.go +++ b/src/genkit/flows/campaign_assistant/tools_test.go @@ -127,10 +127,13 @@ func TestResolveWindow(t *testing.T) { t.Fatalf("passthrough = %s..%s err=%v", s, e, err) } - // Past start clamps to today. - s, _, err = resolveWindow("2026-01-01", "2026-02-28", today) - if err != nil || s != "2026-02-10" { - t.Fatalf("past-start clamp = %s err=%v", s, err) + // Past start is rejected, not clamped (CON-114: never date drafts in the past). + if _, _, err := resolveWindow("2026-01-01", "2026-02-28", today); err == nil { + t.Fatal("expected error for a past windowStart") + } + // Today itself is allowed (equal, not before). + if s, _, err := resolveWindow("2026-02-10", "", today); err != nil || s != "2026-02-10" { + t.Fatalf("today start = %s err=%v", s, err) } // End before start → error. @@ -179,3 +182,53 @@ func TestPageRef(t *testing.T) { } } } + +// CON-114: a count the user names is honored exactly; an omitted/zero count +// defaults to 1 (the safe minimum) so an omitting planner can't over-produce. +// Guards the "generate 1 post" -> 3 regression. +func TestResolveGenerateCount(t *testing.T) { + cases := []struct { + name string + requested int + maxN int + wantCount int + wantReq int + wantClamped bool + }{ + {"exact one is honored", 1, 10, 1, 1, false}, + {"exact five is honored", 5, 10, 5, 5, false}, + {"omitted defaults to 1 (safe minimum)", 0, 10, 1, 1, false}, + {"negative treated as omitted", -2, 10, 1, 1, false}, + {"above cap clamps down", 25, 10, 10, 25, true}, + {"unset cap falls back to 10", 25, 0, 10, 25, true}, + } + for _, c := range cases { + gotCount, gotReq, gotClamped := resolveGenerateCount(c.requested, c.maxN) + if gotCount != c.wantCount || gotReq != c.wantReq || gotClamped != c.wantClamped { + t.Errorf("%s: resolveGenerateCount(%d, %d) = (count=%d, requested=%d, clamped=%v); want (count=%d, requested=%d, clamped=%v)", + c.name, c.requested, c.maxN, gotCount, gotReq, gotClamped, c.wantCount, c.wantReq, c.wantClamped) + } + } +} + +// CON-114: a single post with only a start date is pinned to that day, so +// "generate 1 for Jul 22" lands on Jul 22 instead of the midpoint of the +// derived 14-day window. Explicit ends and multi-post requests keep their range. +func TestSinglePostWindowEnd(t *testing.T) { + cases := []struct { + name string + start, end, rawEnd string + count int + want string + }{ + {"one post, derived end -> pinned to start", "2026-07-22", "2026-08-05", "", 1, "2026-07-22"}, + {"one post, explicit end -> range kept", "2026-07-22", "2026-07-29", "2026-07-29", 1, "2026-07-29"}, + {"multi post, derived end -> range kept", "2026-07-22", "2026-08-05", "", 3, "2026-08-05"}, + } + for _, c := range cases { + if got := singlePostWindowEnd(c.start, c.end, c.rawEnd, c.count); got != c.want { + t.Errorf("%s: singlePostWindowEnd(%q, %q, %q, %d) = %q, want %q", + c.name, c.start, c.end, c.rawEnd, c.count, got, c.want) + } + } +} diff --git a/src/genkit/flows/content_plan/generate.go b/src/genkit/flows/content_plan/generate.go index d921b3a..afcc281 100644 --- a/src/genkit/flows/content_plan/generate.go +++ b/src/genkit/flows/content_plan/generate.go @@ -216,7 +216,9 @@ func generatePosts( return nil, nil, fmt.Errorf("render user prompt: %w", err) } slog.DebugContext(ctx, "user prompt (no batch plan)", logging.AttrComponent, "genkit.content_plan", "prompt", userPrompt) - posts, genErr := generatePostsStreaming(ctx, g, modelName, systemPrompt, userPrompt, modelCfg, recordUsage, 0, validate, persistFn, onEvent) + // expectedCount 0 = uncapped: no batch plan, so the model decides how + // many posts the campaign warrants (pre-CON-67 behaviour). + posts, genErr := generatePostsStreaming(ctx, g, modelName, systemPrompt, userPrompt, modelCfg, recordUsage, 0, 0, validate, persistFn, onEvent) if genErr != nil { // Even on hard failure, return what was persisted so the // caller's partial-success aggregation has the rows. @@ -235,7 +237,9 @@ func generatePosts( return nil, fmt.Errorf("render user prompt for batch %d: %w", spec.Index, err) } slog.DebugContext(ctx, "batch user prompt", logging.AttrComponent, "genkit.content_plan", "batch", spec.Index+1, "total", len(batches), "posts", spec.PostCount, "window_start", spec.DateWindow.Start, "window_end", spec.DateWindow.End, "prompt", userPrompt) - return generatePostsStreaming(ctx, g, modelName, systemPrompt, userPrompt, modelCfg, recordUsage, spec.GlobalStartIndex, validate, persistFn, emit) + // Cap persistence at the batch's planned size so an over-producing model + // can't inflate the count (CON-114). + return generatePostsStreaming(ctx, g, modelName, systemPrompt, userPrompt, modelCfg, recordUsage, spec.GlobalStartIndex, spec.PostCount, validate, persistFn, emit) } return runBatchesParallel(ctx, batches, maxParallel, gen, onEvent) } @@ -361,6 +365,7 @@ func generatePostsStreaming( modelCfg ai.GenerateOption, recordUsage func(context.Context, *ai.ModelResponse), globalStartIndex int, + expectedCount int, validate postValidator, persistFn func(ctx context.Context, post DraftPost) (string, error), onEvent OnEventFunc, @@ -379,6 +384,14 @@ func generatePostsStreaming( var totalBytes int tryPersist := func(post DraftPost, position int) { + // CON-114: never persist more than this batch asked for. The generation + // model can over-produce (e.g. stream 3 posts for a "generate exactly 1" + // batch); without this cap every extra valid post is persisted, so a + // request for 1 post yielded 3. expectedCount <= 0 = uncapped (the + // count-less fallback where the model decides how many to produce). + if !withinCount(len(posts), expectedCount) { + return + } if err := validate(post); err != nil { emit(onEvent, SSEEventWarning, WarningPayload{ Message: fmt.Sprintf("post %q dropped: %s", post.Title, err), @@ -517,6 +530,13 @@ func trimBody(body string) string { return body } +// withinCount reports whether another post may still be persisted for a batch +// that asked for expectedCount posts. expectedCount <= 0 means uncapped — the +// count-less fallback where the generation model decides how many to produce. +func withinCount(persisted, expectedCount int) bool { + return expectedCount <= 0 || persisted < expectedCount +} + // persistOne inserts a single DraftPost as a new Post row and returns the // generated row ID. Per CON-66 the streaming path calls this for each // parsed-and-validated post immediately rather than aggregating to a final diff --git a/src/genkit/flows/content_plan/generate_test.go b/src/genkit/flows/content_plan/generate_test.go new file mode 100644 index 0000000..6aaf7ff --- /dev/null +++ b/src/genkit/flows/content_plan/generate_test.go @@ -0,0 +1,26 @@ +package content_plan + +import "testing" + +// CON-114: the streaming persist path stops at the batch's requested count, so +// an over-producing model can't turn "generate exactly 1" into 3 persisted +// posts. expectedCount <= 0 is the uncapped fallback (model decides the count). +func TestWithinCount(t *testing.T) { + cases := []struct { + name string + persisted, expected int + want bool + }{ + {"first post for a 1-post batch", 0, 1, true}, + {"second post exceeds a 1-post batch", 1, 1, false}, + {"under a 3-post batch", 2, 3, true}, + {"at a 3-post batch cap", 3, 3, false}, + {"uncapped (zero)", 5, 0, true}, + {"uncapped (negative)", 5, -1, true}, + } + for _, c := range cases { + if got := withinCount(c.persisted, c.expected); got != c.want { + t.Errorf("%s: withinCount(%d, %d) = %v, want %v", c.name, c.persisted, c.expected, got, c.want) + } + } +}