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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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}}
Expand Down
93 changes: 70 additions & 23 deletions src/genkit/flows/campaign_assistant/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Comment on lines +448 to +451

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove comments describing WHAT the code does.

These comments merely detail implementation mechanics. As per path instructions, default to no comments and avoid describing WHAT the code does.

  • src/genkit/flows/campaign_assistant/tools.go#L447-L450: Remove the inline comment.
  • src/genkit/flows/campaign_assistant/tools.go#L514-L521: Remove the function docstring.
Proposed fix
-	// 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)
-// 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) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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)
count, requested, clamped := resolveGenerateCount(in.Count, st.maxGeneratePosts)
Suggested change
// 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)
func resolveGenerateCount(requested, maxN int) (count, requestedOut int, clamped bool) {
📍 Affects 1 file
  • src/genkit/flows/campaign_assistant/tools.go#L447-L450 (this comment)
  • src/genkit/flows/campaign_assistant/tools.go#L514-L521
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/genkit/flows/campaign_assistant/tools.go` around lines 447 - 450, Remove
the inline implementation comment above the resolveGenerateCount call and remove
the function docstring for resolveGenerateCount in
src/genkit/flows/campaign_assistant/tools.go at lines 447-450 and 514-521; leave
the code behavior unchanged.

Source: Path instructions


// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Inline trivial helpers to avoid premature abstraction.

As per path instructions, avoid premature abstractions; three similar lines is better than a speculative helper. singlePostWindowEnd and withinCount are single-use, one-line conditionals.

  • src/genkit/flows/campaign_assistant/tools.go#L462-L462: Inline the condition: if count == 1 && in.WindowEnd == "" { windowEnd = windowStart }.
  • src/genkit/flows/campaign_assistant/tools.go#L544-L554: Remove the singlePostWindowEnd function.
  • src/genkit/flows/campaign_assistant/tools_test.go#L214-L234: Remove TestSinglePostWindowEnd.
  • src/genkit/flows/content_plan/generate.go#L392-L394: Inline the check: if expectedCount > 0 && len(posts) >= expectedCount { return }.
  • src/genkit/flows/content_plan/generate.go#L533-L538: Remove the withinCount function.
  • src/genkit/flows/content_plan/generate_test.go#L5-L26: Remove TestWithinCount.
📍 Affects 4 files
  • src/genkit/flows/campaign_assistant/tools.go#L462-L462 (this comment)
  • src/genkit/flows/campaign_assistant/tools.go#L544-L554
  • src/genkit/flows/campaign_assistant/tools_test.go#L214-L234
  • src/genkit/flows/content_plan/generate.go#L392-L394
  • src/genkit/flows/content_plan/generate.go#L533-L538
  • src/genkit/flows/content_plan/generate_test.go#L5-L26
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/genkit/flows/campaign_assistant/tools.go` at line 462, Inline the
single-use helper logic and remove its dedicated tests: in
src/genkit/flows/campaign_assistant/tools.go:462-462, replace
singlePostWindowEnd with the count and WindowEnd condition; remove
singlePostWindowEnd at src/genkit/flows/campaign_assistant/tools.go:544-554 and
TestSinglePostWindowEnd at
src/genkit/flows/campaign_assistant/tools_test.go:214-234. In
src/genkit/flows/content_plan/generate.go:392-394, inline the
expectedCount/posts length check; remove withinCount at
src/genkit/flows/content_plan/generate.go:533-538 and TestWithinCount at
src/genkit/flows/content_plan/generate_test.go:5-26.

Source: Path instructions


emit(st.onEvent, SSEEventGeneratePostsStarted, GeneratePostsStartedEventPayload{
PlatformIDs: platformIDs,
Expand Down Expand Up @@ -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
}
Comment on lines +533 to +535

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Set the default count to 3.

The PR objectives state that omitted or vague counts should default to 3, but the code falls back to 1.

  • src/genkit/flows/campaign_assistant/tools.go#L533-L535: Change requested = 1 to requested = 3.
  • src/genkit/flows/campaign_assistant/tools_test.go#L200-L201: Update the wantCount and wantReq assertions in the test cases to 3.
📍 Affects 2 files
  • src/genkit/flows/campaign_assistant/tools.go#L533-L535 (this comment)
  • src/genkit/flows/campaign_assistant/tools_test.go#L200-L201
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/genkit/flows/campaign_assistant/tools.go` around lines 533 - 535, Change
the fallback in the requested-count handling to default to 3 instead of 1.
Update the related wantCount and wantReq assertions in
src/genkit/flows/campaign_assistant/tools_test.go lines 200-201 to expect 3.

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).
Expand Down Expand Up @@ -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
}
Expand Down
61 changes: 57 additions & 4 deletions src/genkit/flows/campaign_assistant/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
}
24 changes: 22 additions & 2 deletions src/genkit/flows/content_plan/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading