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
6 changes: 6 additions & 0 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ type Config struct {
// tool (CON-114) so it stays an incremental add rather than a full re-plan.
GeneratePostsMax int `envconfig:"GENERATE_POSTS_MAX" default:"10"`

// DraftPostMax caps the Campaign Assistant's draftPost tool (CON-207) — how
// many finished, content-first drafts one "create a post from this research"
// call may produce (total across platforms). Smaller than GeneratePostsMax:
// each draft is full-length copy, not a terse thesis.
DraftPostMax int `envconfig:"DRAFT_POST_MAX" default:"5"`

// ConsistencyPostsMax caps how many posts the Campaign Assistant's posts-vs-
// brief consistency check (CON-116) analyzes in a single model call.
ConsistencyPostsMax int `envconfig:"CONSISTENCY_POSTS_MAX" default:"20"`
Expand Down
229 changes: 229 additions & 0 deletions src/genkit/flows/campaign_assistant/draft_post_tool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package campaign_assistant

import (
"context"
"fmt"
"testing"

"github.com/ogen-app/ogen/src/genkit/flows/draft_post"
"github.com/ogen-app/ogen/src/models"
"github.com/ogen-app/ogen/src/repository"
)

// fakeMessagesRepo is a minimal CampaignAssistantMessageRepository that returns
// a preset conversation history (oldest-first, as the real repo does).
type fakeMessagesRepo struct {
repository.CampaignAssistantMessageRepository
msgs []models.CampaignAssistantMessage
}

func (f *fakeMessagesRepo) ListRecentByCampaignID(context.Context, string, int) ([]models.CampaignAssistantMessage, error) {
return f.msgs, nil
}

func modelMsg(content string) models.CampaignAssistantMessage {
return models.CampaignAssistantMessage{Role: "model", Content: content}
}
func userMsg(content string) models.CampaignAssistantMessage {
return models.CampaignAssistantMessage{Role: "user", Content: content}
}

// CON-207 FR2: the source material is the latest *answer* in the chat, pulled
// from the stored JSON envelope's "explanation" field — an override wins, an
// action-confirmation turn (e.g. post_drafted) is skipped, and legacy plain-text
// model messages are used as-is.
func TestResolveDraftSource(t *testing.T) {
st := func(msgs []models.CampaignAssistantMessage) *requestState {
return &requestState{campaignID: "c1", repos: CampaignAssistantRepos{Messages: &fakeMessagesRepo{msgs: msgs}}}
}

// Override always wins, no history consulted.
if got, _ := resolveDraftSource(context.Background(), st(nil), " pasted "); got != "pasted" {
t.Fatalf("override = %q, want pasted", got)
}

// Latest answered explanation is used.
got, err := resolveDraftSource(context.Background(), st([]models.CampaignAssistantMessage{
userMsg("what do the assets say?"),
modelMsg(`{"action":"answered","explanation":"the research"}`),
}), "")
if err != nil || got != "the research" {
t.Fatalf("answered = %q err=%v", got, err)
}

// A post_drafted confirmation is skipped in favour of the earlier answer.
got, _ = resolveDraftSource(context.Background(), st([]models.CampaignAssistantMessage{
modelMsg(`{"action":"answered","explanation":"research A"}`),
userMsg("draft it"),
modelMsg(`{"action":"post_drafted","explanation":"I drafted 1 post."}`),
}), "")
if got != "research A" {
t.Fatalf("skip-confirmation = %q, want research A", got)
}

// No prior research → empty (the tool then declines).
if got, _ := resolveDraftSource(context.Background(), st([]models.CampaignAssistantMessage{userMsg("hi")}), ""); got != "" {
t.Fatalf("no-research = %q, want empty", got)
}

// Legacy plain-text model message (not JSON-wrapped) is used directly.
if got, _ := resolveDraftSource(context.Background(), st([]models.CampaignAssistantMessage{modelMsg("plain answer")}), ""); got != "plain answer" {
t.Fatalf("legacy = %q, want plain answer", got)
}
}

// stubDraftPost records each request and returns req.Count synthetic posts.
func stubDraftPost(calls *[]draft_post.DraftPostRequest) func(context.Context, draft_post.DraftPostRequest, draft_post.OnEventFunc) (*draft_post.DraftPostResponse, error) {
return func(_ context.Context, req draft_post.DraftPostRequest, _ draft_post.OnEventFunc) (*draft_post.DraftPostResponse, error) {
*calls = append(*calls, req)
posts := make([]draft_post.DraftedPost, req.Count)
for i := range posts {
posts[i] = draft_post.DraftedPost{PostID: fmt.Sprintf("%s-%d", req.PlatformID, i), PlatformID: req.PlatformID, PublishDate: req.WindowStart}
}
return &draft_post.DraftPostResponse{Posts: posts}, nil
}
}

func newDraftState(msgs []models.CampaignAssistantMessage, calls *[]draft_post.DraftPostRequest) *requestState {
return &requestState{
campaignID: "c1",
campaign: timelineCampaign(),
instruction: "make it punchy",
repos: CampaignAssistantRepos{Messages: &fakeMessagesRepo{msgs: msgs}},
draftPost: stubDraftPost(calls),
maxDraftPosts: 5,
}
}

// No research in the chat and no override → the tool declines without running
// the flow (CON-207 §9).
func TestToolDraftPost_DeclinesWithoutSource(t *testing.T) {
var calls []draft_post.DraftPostRequest
st := newDraftState([]models.CampaignAssistantMessage{userMsg("hi")}, &calls)
ctx := withRequestState(context.Background(), st)

out, err := toolDraftPost(ctx, DraftPostInput{Platforms: []string{"LinkedIn"}, Count: 1})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Note != noDraftSourceNote {
t.Fatalf("note = %q, want the no-source note", out.Note)
}
if len(calls) != 0 {
t.Fatalf("flow must not run without source, got %d calls", len(calls))
}
if st.draftPostResult != nil {
t.Fatal("draftPostResult must stay nil on decline")
}
// The decline must NOT consume the turn's heavy-action slot (CON-213): a later
// valid heavy tool call must still be able to reserve and run.
if !st.reserveHeavyAction() {
t.Fatal("a no-source decline must leave the heavy-action slot available")
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// The per-call cap is a TOTAL budget across platforms: count=3 over two
// platforms with max 5 yields 3 + 2 and reports clamped (CON-207 §10).
func TestToolDraftPost_BudgetAcrossPlatforms(t *testing.T) {
var calls []draft_post.DraftPostRequest
st := newDraftState([]models.CampaignAssistantMessage{
modelMsg(`{"action":"answered","explanation":"the research"}`),
}, &calls)
ctx := withRequestState(context.Background(), st)

out, err := toolDraftPost(ctx, DraftPostInput{Platforms: []string{"LinkedIn", "Threads"}, Count: 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.PostCount != 5 || !out.Clamped {
t.Fatalf("out = {PostCount:%d Clamped:%v}, want {5 true}", out.PostCount, out.Clamped)
}
if len(calls) != 2 || calls[0].Count != 3 || calls[1].Count != 2 {
t.Fatalf("flow calls counts = [%d %d], want [3 2]", calls[0].Count, calls[1].Count)
}
// Source + steering are threaded to the flow.
if calls[0].SourceMaterial != "the research" || calls[0].Instruction != "make it punchy" {
t.Fatalf("call[0] source=%q instruction=%q", calls[0].SourceMaterial, calls[0].Instruction)
}
if st.draftPostResult == nil || st.draftPostResult.PostCount != 5 {
t.Fatalf("draftPostResult = %+v", st.draftPostResult)
}
}

// CON-215 parity: a user-correctable input (past date, non-target platform)
// fails soft — zero posts + a warning, no flow call, draftPostResult unset, and
// the heavy-action slot left free — so the turn stays conversational instead of
// aborting with a raw "model call failed".
func TestToolDraftPost_SoftFailsUserInput(t *testing.T) {
research := []models.CampaignAssistantMessage{
modelMsg(`{"action":"answered","explanation":"the research"}`),
}

// A past publish date.
var calls []draft_post.DraftPostRequest
st := newDraftState(research, &calls)
ctx := withRequestState(context.Background(), st)
out, err := toolDraftPost(ctx, DraftPostInput{Platforms: []string{"LinkedIn"}, Count: 1, PublishDate: "2020-01-01"})
if err != nil {
t.Fatalf("past date must not error: %v", err)
}
if out.PostCount != 0 || len(out.Warnings) == 0 {
t.Fatalf("past date: out = {PostCount:%d Warnings:%v}, want zero posts + a warning", out.PostCount, out.Warnings)
}
if len(calls) != 0 || st.draftPostResult != nil {
t.Fatal("past date must not run the flow or set draftPostResult")
}
if !st.reserveHeavyAction() {
t.Fatal("a soft failure must leave the heavy-action slot available")
}

// A non-target platform.
calls = nil
st = newDraftState(research, &calls)
ctx = withRequestState(context.Background(), st)
out, err = toolDraftPost(ctx, DraftPostInput{Platforms: []string{"TikTok"}, Count: 1})
if err != nil {
t.Fatalf("non-target platform must not error: %v", err)
}
if out.PostCount != 0 || len(out.Warnings) == 0 || len(calls) != 0 {
t.Fatalf("non-target platform: out = %+v, calls = %d", out, len(calls))
}
}

// An explicit sourceMaterial override bypasses history lookup.
func TestToolDraftPost_SourceOverride(t *testing.T) {
var calls []draft_post.DraftPostRequest
st := newDraftState(nil, &calls) // no history at all
ctx := withRequestState(context.Background(), st)

if _, err := toolDraftPost(ctx, DraftPostInput{Platforms: []string{"LinkedIn"}, Count: 1, SourceMaterial: "pasted material"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(calls) != 1 || calls[0].SourceMaterial != "pasted material" {
t.Fatalf("override not used: %+v", calls)
}
}

// CON-213: once a heavy action ran this turn, draftPost yields the skip note and
// never runs the flow.
func TestToolDraftPost_HeavyLatch(t *testing.T) {
var calls []draft_post.DraftPostRequest
st := newDraftState([]models.CampaignAssistantMessage{
modelMsg(`{"action":"answered","explanation":"the research"}`),
}, &calls)
if !st.reserveHeavyAction() {
t.Fatal("first reservation should win")
}
ctx := withRequestState(context.Background(), st)

out, err := toolDraftPost(ctx, DraftPostInput{Platforms: []string{"LinkedIn"}, Count: 1})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Note != heavySkipNote {
t.Fatalf("note = %q, want heavy skip note", out.Note)
}
if len(calls) != 0 {
t.Fatal("flow must not run once a heavy action was reserved")
}
}
2 changes: 1 addition & 1 deletion src/genkit/flows/campaign_assistant/prewarm.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func prewarmToolCache(g *genkit.Genkit, cfg CampaignAssistantFlowConfig, t *tool
ai.WithPrompt("warmup"),
ai.WithTools(
t.runContentPlan, t.enrichBrief, t.listCampaignPosts, t.getCampaignOverview,
t.generatePosts, t.setCampaignDates, t.redistributePosts, t.checkBrief, t.checkPostsConsistency,
t.generatePosts, t.draftPost, t.setCampaignDates, t.redistributePosts, t.checkBrief, t.checkPostsConsistency,
),
ai.WithMaxTurns(1),
cfg.Provider.CallConfig(1), // max_tokens: 1 — grammar compiles during prep
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ You can help with:
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.
6. Answer questions and give an overview — the brief, phases, existing posts, and how content is distributed (listCampaignPosts, getCampaignOverview, or straight from the context below).
7. Answer questions grounded in the campaign's attached assets — uploaded PDFs, markdown, etc. (askCampaignAssets).
8. Draft a post from research — turn research just discussed in this chat into one or more finished, ready-to-publish post drafts (draftPost).

## Tools
- runContentPlan: Call this when the user asks to generate, create, build, or regenerate a content plan (e.g. "generate a content plan", "make me some posts for this campaign"). It creates and saves the draft posts. Takes no arguments.
Expand All @@ -21,22 +22,24 @@ You can help with:
- 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.
- checkPostsConsistency: Call this when the user asks whether the posts match, follow, or are consistent with the brief — e.g. "do the posts follow the brief?", "check the posts against the brief". It is READ-ONLY — it returns per-post findings but does NOT change any post. It reviews only non-published posts and is capped; when the result reports a cap, MENTION that only the first N were checked. Pass `max` only if the user asks for a specific number.
- askCampaignAssets: Call this when the user asks a question about the campaign's attached assets (uploaded PDFs, markdown, etc.) — e.g. "what does the pricing PDF say about enterprise tiers?", "summarise the attached brief". It is READ-ONLY: it returns the most relevant asset excerpts (with title + page) for you to answer from; it does NOT generate posts. Answer from the returned excerpts and cite the asset title(s) you used. If the result reports `available:false` or has no excerpts, tell the user you couldn't search or find matching asset content — do not invent an answer.
- draftPost: Call this when the user asks to turn the research just discussed into an actual post — e.g. "create a post with this info for tomorrow, for LinkedIn", "draft 3 LinkedIn posts from that research", "write a post based on what you just told me". It rewrites the research into finished, ready-to-publish copy (a full post body, NOT a bullet outline) and saves it as a draft. This is DIFFERENT from generatePosts, which produces terse bullet-point draft-theses for bulk planning: use draftPost when the user wants a real, finished post built from a specific piece of research or pasted material, and generatePosts when they want to bulk-add planning drafts for a platform/timeframe. Only platforms the campaign already targets are allowed; if the user names a different one, do NOT call the tool — tell them it isn't a target platform. Resolve any date into ISO YYYY-MM-DD against today's date below and pass it as publishDate (omit for the next two weeks); the date must be today or later. Set count to the number the user asks for. Do NOT paste the research into sourceMaterial — it is loaded automatically from this conversation; pass sourceMaterial only when the user gave brand-new material in this same message. If there's no prior research in the chat, the tool will tell you — then ask the user to research the assets first. If draftPost comes back with zero posts and a warning (e.g. the date is in the past or the platform isn't a target), do NOT report success — relay the warning and suggest a valid alternative (today or a later date, or one of the campaign's target platforms).

Use a tool only when the user's request clearly matches it. For a general question you can answer from the campaign brief and phases already shown below, answer directly without a tool. In particular, questions about the phases themselves (their names or purpose) can be answered from the context below without a tool; only call getCampaignOverview when you need the post counts / distribution.

Run at most ONE generation or review action per message — one of runContentPlan, generatePosts, enrichBrief, checkBrief, or checkPostsConsistency. After it returns, stop calling action tools and reply. If the user asked for several, do the most important one now and offer the rest as a follow-up. (Read tools like getCampaignOverview, listCampaignPosts, and askCampaignAssets may be used first to inform that one action.) If an action tool returns a `note` saying one already ran this turn, do not retry — just reply with what was done.
Run at most ONE generation or review action per message — one of runContentPlan, generatePosts, draftPost, enrichBrief, checkBrief, or checkPostsConsistency. After it returns, stop calling action tools and reply. If the user asked for several, do the most important one now and offer the rest as a follow-up. (Read tools like getCampaignOverview, listCampaignPosts, and askCampaignAssets may be used first to inform that one action.) If an action tool returns a `note` saying one already ran this turn, do not retry — just reply with what was done.

## Response format
After any tool calls, reply with a SINGLE JSON object and nothing else — no markdown fences, no prose before or after:

{
"explanation": "<a short, friendly reply to the user in their language>",
"action": "<answered | content_plan_generated | posts_generated | brief_enriched | dates_updated | posts_redistributed | brief_reviewed | posts_reviewed | declined>"
"action": "<answered | content_plan_generated | posts_generated | post_drafted | brief_enriched | dates_updated | posts_redistributed | brief_reviewed | posts_reviewed | declined>"
}

Rules for `action`:
- "content_plan_generated" — you called runContentPlan this turn.
- "posts_generated" — you called generatePosts this turn.
- "post_drafted" — you called draftPost this turn.
- "brief_enriched" — you called enrichBrief this turn.
- "dates_updated" — you called setCampaignDates this turn.
- "posts_redistributed" — you called redistributePosts this turn.
Expand Down
Loading
Loading