From dad81e17cd633b1b3d07e0e189961b8c22a61fd5 Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Sun, 19 Jul 2026 13:09:09 +0300 Subject: [PATCH 1/6] CON-114: honor an exact post count in generatePosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking the assistant to "generate 1 post" produced 3. The engine already generates exactly req.Count, and the tool honors any positive in.Count — but the model-facing instructions told the planner to *infer* the count and default to "a few = 3", so it omitted count for explicit requests and the tool fell back to 3. Make the instructions explicit instead: - count schema: "set count to the exact number the user names; omit only when the user gives no number (defaults to 3)". - prompt: same guidance, plus a "write 1 post" example, and de-bias the "a few new drafts" framing to "one or more". Also extract the count resolution into resolveGenerateCount (behavior unchanged) and unit-test it: exact 1->1 and 5->5, omitted/negative->3, over-cap->clamped. Both modes now work: exact numbers pass through, vague ("a few") -> 3. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../prompts/campaign_assistant.tmpl | 4 +- src/genkit/flows/campaign_assistant/tools.go | 43 ++++++++++++------- .../flows/campaign_assistant/tools_test.go | 27 ++++++++++++ 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl index 0a85930..6bf49ab 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. Set count to the EXACT number the user names ("write 1 post" -> count 1, "generate 5 articles" -> count 5); omit count only when the user gives no number ("add some Threads posts"), which defaults to 3 ("a few"). 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. diff --git a/src/genkit/flows/campaign_assistant/tools.go b/src/genkit/flows/campaign_assistant/tools.go index e4a6155..b991d4a 100644 --- a/src/genkit/flows/campaign_assistant/tools.go +++ b/src/genkit/flows/campaign_assistant/tools.go @@ -109,7 +109,7 @@ 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=Exact number of posts to add. When the user names a quantity, set count to that number (\"add 1 post\"->1, \"generate 5 articles\"->5). Omit only when the user gives no number at all; an omitted count defaults to 3 (\"a few\"). 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)."` PostType string `json:"postType,omitempty" jsonschema:"description=Optional post-type slug (e.g. text-post, article); omit for the platform default."` @@ -444,21 +444,10 @@ 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 3 ("a few") only 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) @@ -522,6 +511,28 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos }, 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 — "add 1 post" yields 1, not the "a few" default — while a missing or +// non-positive count falls back to 3 ("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 to the model 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 = 3 + } + count = requested + if count > maxN { + count = maxN + clamped = true + } + return count, requested, clamped +} + // 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). diff --git a/src/genkit/flows/campaign_assistant/tools_test.go b/src/genkit/flows/campaign_assistant/tools_test.go index 7fd3d84..99a4ff9 100644 --- a/src/genkit/flows/campaign_assistant/tools_test.go +++ b/src/genkit/flows/campaign_assistant/tools_test.go @@ -179,3 +179,30 @@ func TestPageRef(t *testing.T) { } } } + +// CON-114: a count the user names is honored exactly; only an omitted/zero count +// defaults to 3 ("a few"). 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 a few", 0, 10, 3, 3, false}, + {"negative treated as omitted", -2, 10, 3, 3, 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) + } + } +} From ed03f3c993a72078839c86880f6dcdc9ea63f06a Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Sun, 19 Jul 2026 13:30:01 +0300 Subject: [PATCH 2/6] CON-114: cap persisted posts at the batch's planned count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generation model can over-produce (stream more posts than a batch asked for), and generatePostsStreaming persisted every valid post — so a targeted "generate 1 post" request yielded 3. Add an expectedCount to the streaming persist loop (withinCount gate): the batched path passes the batch's planned PostCount, the count-less fallback passes 0 (uncapped). Deterministic guarantee: count=1 -> one batch of PostCount 1 -> at most 1 post persisted, regardless of how many the model streams. Also stops the full content-plan path from exceeding a batch's planned size. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/genkit/flows/content_plan/generate.go | 24 +++++++++++++++-- .../flows/content_plan/generate_test.go | 26 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/genkit/flows/content_plan/generate_test.go 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) + } + } +} From 28085833572e8f4f6f64d39b468373886267ccbb Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Sun, 19 Jul 2026 13:30:01 +0300 Subject: [PATCH 3/6] CON-114: pin a single named date to windowStart=windowEnd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "generate 1 draft for Aug 20" set windowStart=Aug 20 and omitted windowEnd, which resolveWindow expands to a 14-day window — so posts spread across the range (e.g. Aug 29) instead of landing on Aug 20. Instruct the model (prompt + windowEnd schema) to set both bounds to the same date for a single specific date. --- .../flows/campaign_assistant/prompts/campaign_assistant.tmpl | 2 +- src/genkit/flows/campaign_assistant/tools.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl index 6bf49ab..5e52bf0 100644 --- a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl +++ b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl @@ -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 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. Set count to the EXACT number the user names ("write 1 post" -> count 1, "generate 5 articles" -> count 5); omit count only when the user gives no number ("add some Threads posts"), which defaults to 3 ("a few"). Count 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. Set count to the EXACT number the user names ("write 1 post" -> count 1, "generate 5 articles" -> count 5); omit count only when the user gives no number ("add some Threads posts"), which defaults to 3 ("a few"). 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. diff --git a/src/genkit/flows/campaign_assistant/tools.go b/src/genkit/flows/campaign_assistant/tools.go index b991d4a..d2a3f9a 100644 --- a/src/genkit/flows/campaign_assistant/tools.go +++ b/src/genkit/flows/campaign_assistant/tools.go @@ -111,7 +111,7 @@ type GeneratePostsInput struct { Phase string `json:"phase,omitempty" jsonschema:"description=Phase name, id, or \"current\"; omit for the current phase."` Count int `json:"count,omitempty" jsonschema:"description=Exact number of posts to add. When the user names a quantity, set count to that number (\"add 1 post\"->1, \"generate 5 articles\"->5). Omit only when the user gives no number at all; an omitted count defaults to 3 (\"a few\"). 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."` } From 3c00d7d844d75d35f47a08bf7d49d7a7d2c5ae27 Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Sun, 19 Jul 2026 13:43:08 +0300 Subject: [PATCH 4/6] CON-114: default an omitted post count to 1, not 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haiku (the planner) omits `count` for "generate 1 post", so the tool's resolveGenerateCount fell through to its default of 3 — and with the engine cap in place, a request for 1 still produced 3. Default a missing/zero count to 1 (the safe minimum) instead: an omitting model can no longer over-produce, and the planner is told to pass an explicit 3 for a vague "a few". Combined with the batch cap, "generate 1" now yields exactly 1 unless the model explicitly asks for more. Trade-off: a vague "a few" with no number now depends on the model passing 3; if it omits, one post is created (safe under-, not over-, delivery). --- .../prompts/campaign_assistant.tmpl | 2 +- src/genkit/flows/campaign_assistant/tools.go | 15 ++++++++------- src/genkit/flows/campaign_assistant/tools_test.go | 9 +++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl index 5e52bf0..9c700b2 100644 --- a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl +++ b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl @@ -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 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. Set count to the EXACT number the user names ("write 1 post" -> count 1, "generate 5 articles" -> count 5); omit count only when the user gives no number ("add some Threads posts"), which defaults to 3 ("a few"). Count 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. 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. diff --git a/src/genkit/flows/campaign_assistant/tools.go b/src/genkit/flows/campaign_assistant/tools.go index d2a3f9a..6551d7d 100644 --- a/src/genkit/flows/campaign_assistant/tools.go +++ b/src/genkit/flows/campaign_assistant/tools.go @@ -109,7 +109,7 @@ 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=Exact number of posts to add. When the user names a quantity, set count to that number (\"add 1 post\"->1, \"generate 5 articles\"->5). Omit only when the user gives no number at all; an omitted count defaults to 3 (\"a few\"). Capped per call."` + 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). 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."` @@ -513,17 +513,18 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos // resolveGenerateCount maps the model-supplied count to the number of posts the // generatePosts tool will actually create. An explicit positive count is honored -// exactly — "add 1 post" yields 1, not the "a few" default — while a missing or -// non-positive count falls back to 3 ("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 to the model as RequestedCount), -// and whether the request was clamped. +// 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 = 3 + requested = 1 } count = requested if count > maxN { diff --git a/src/genkit/flows/campaign_assistant/tools_test.go b/src/genkit/flows/campaign_assistant/tools_test.go index 99a4ff9..1c1bc11 100644 --- a/src/genkit/flows/campaign_assistant/tools_test.go +++ b/src/genkit/flows/campaign_assistant/tools_test.go @@ -180,8 +180,9 @@ func TestPageRef(t *testing.T) { } } -// CON-114: a count the user names is honored exactly; only an omitted/zero count -// defaults to 3 ("a few"). Guards the "generate 1 post" -> 3 regression. +// 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 @@ -193,8 +194,8 @@ func TestResolveGenerateCount(t *testing.T) { }{ {"exact one is honored", 1, 10, 1, 1, false}, {"exact five is honored", 5, 10, 5, 5, false}, - {"omitted defaults to a few", 0, 10, 3, 3, false}, - {"negative treated as omitted", -2, 10, 3, 3, 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}, } From 44a490b78f0b5a7863b487a1965d1dfa9c3294a2 Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Sun, 19 Jul 2026 13:50:21 +0300 Subject: [PATCH 5/6] CON-114: pin a single post to its start date deterministically "generate 1 for Jul 22" landed on Jul 29: the model set windowStart=Jul 22 and omitted windowEnd, resolveWindow derived a 14-day window (->Aug 5), and the lone post was placed at the window's midpoint. The prompt nudge to set windowEnd=windowStart wasn't reliable. Add singlePostWindowEnd: when count==1 and the user gave no explicit end, collapse the window to the start day so newPostValidator pins the publish date exactly. Explicit ends and multi-post requests keep their range. --- src/genkit/flows/campaign_assistant/tools.go | 21 ++++++++++++++++-- .../flows/campaign_assistant/tools_test.go | 22 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/genkit/flows/campaign_assistant/tools.go b/src/genkit/flows/campaign_assistant/tools.go index 6551d7d..8d09b88 100644 --- a/src/genkit/flows/campaign_assistant/tools.go +++ b/src/genkit/flows/campaign_assistant/tools.go @@ -445,8 +445,8 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos } // Count: honor an explicit number exactly (so "add 1 post" yields 1); fall - // back to 3 ("a few") only when the model omitted it; clamp to the per-call - // cap. Extracted as resolveGenerateCount for unit testing. + // 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. @@ -454,6 +454,11 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos 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, @@ -534,6 +539,18 @@ func resolveGenerateCount(requested, maxN int) (count, requestedOut int, clamped 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 +} + // 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). diff --git a/src/genkit/flows/campaign_assistant/tools_test.go b/src/genkit/flows/campaign_assistant/tools_test.go index 1c1bc11..e3fcabc 100644 --- a/src/genkit/flows/campaign_assistant/tools_test.go +++ b/src/genkit/flows/campaign_assistant/tools_test.go @@ -207,3 +207,25 @@ func TestResolveGenerateCount(t *testing.T) { } } } + +// 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) + } + } +} From d3be05f19812f569e2dd2cadf5d549d85562e07f Mon Sep 17 00:00:00 2001 From: Serhii Herasymov Date: Sun, 19 Jul 2026 14:12:14 +0300 Subject: [PATCH 6/6] CON-114: reject past dates and report the real publish dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems surfaced testing "generate 1 for Jul 17" while today is Jul 19: the past date was silently clamped to today (post landed on Jul 19), and the assistant's reply invented dates ("Jul 12", wrong counts) because the tool output carried no dates for the model to report. - Reject past dates instead of clamping (per product decision). The planner is told to catch a before-today date and reply conversationally ("that date has passed — pick today or later"); resolveWindow rejects an explicit past start/end as the backstop so no past-dated draft slips in. - Stop the date confabulation: GeneratePostsOutput now returns the actual publish dates of the created posts, and the prompt tells the model to report only the counts/dates the tool returned, never invent them. --- .../prompts/campaign_assistant.tmpl | 4 +-- src/genkit/flows/campaign_assistant/tools.go | 30 +++++++++++++++---- .../flows/campaign_assistant/tools_test.go | 11 ++++--- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl index 9c700b2..555941d 100644 --- a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl +++ b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl @@ -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 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. 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. +- 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 8d09b88..767e2b5 100644 --- a/src/genkit/flows/campaign_assistant/tools.go +++ b/src/genkit/flows/campaign_assistant/tools.go @@ -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 } @@ -511,6 +512,7 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos PhaseID: phaseID, PhaseName: phaseName, Platforms: platformNames, + Dates: publishDatesOf(resp.Posts), Warnings: resp.Warnings, UsedAssets: used, }, nil @@ -551,6 +553,18 @@ func singlePostWindowEnd(resolvedStart, resolvedEnd, rawEnd string, count int) s 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). @@ -844,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 e3fcabc..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.