Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
93cd0d2
CON-112: Campaign Assistant — per-campaign chat over content_plan + e…
grsmv Jul 14, 2026
cf30301
CON-112: tests + http-client for Campaign Assistant
grsmv Jul 14, 2026
4b0fa18
CON-112: make history write non-fatal + atomic
grsmv Jul 14, 2026
7c1510a
CON-112: ListMessages returns [] not null for empty history
grsmv Jul 14, 2026
b0419b5
CON-112: PostsHandler.ListMessages returns [] not null for empty history
grsmv Jul 14, 2026
f237fcf
CON-113: campaign overview — brief, phases, content distribution
grsmv Jul 14, 2026
0d45644
CON-113: move campaignoverview to campaign_actions/overview
grsmv Jul 14, 2026
a7eb6e0
refactor: extract duplicated JSONStringScanner to genkit/jsonstream
grsmv Jul 14, 2026
7d39dcf
CON-114: targeted content generation (add posts by platform/phase/tim…
grsmv Jul 14, 2026
5c51ed2
CON-114: refresh Campaign Assistant system prompt for current capabil…
grsmv Jul 14, 2026
09659dd
CON-115: change campaign dates & redistribute non-published content
grsmv Jul 15, 2026
0649b93
CON-114: preserve resolveAssets warnings in targeted generation
grsmv Jul 15, 2026
891b2ab
jsonstream: emit carried partial-UTF8 tail at end of a watched string
grsmv Jul 15, 2026
3497ad6
CON-116: brief & content consistency review
grsmv Jul 15, 2026
60945f5
Remove prototyping — extracted to ../ui-prototyping with full history
grsmv Jul 15, 2026
9fcc984
CON-112: instrument assistant turn + slim router + parallelize conten…
grsmv Jul 15, 2026
8389fcc
CON-112: keep the useful perf diagnostics, drop the dead-end workarounds
grsmv Jul 16, 2026
2788156
CON-112: stabilize Anthropic tool order to fix ~50s per-request latency
grsmv Jul 16, 2026
7cad1b5
CON-112: extend tool-cache pre-warm to post_assistant
grsmv Jul 16, 2026
101536c
CON-112: document tool-order/strict-schema latency in add-genkit-flow…
grsmv Jul 16, 2026
bc6c47d
CON-112: fix setCampaignDates error handling and count ordering
grsmv Jul 16, 2026
39346dd
CON-112: cap posts-review limit at configured maximum
grsmv Jul 16, 2026
34c26d8
CON-112: guard batch reschedule against ineligible posts
grsmv Jul 16, 2026
fe0bf89
CON-112: close resp.Body in anthropic logging transport test
grsmv Jul 16, 2026
85698a3
CON-112: don't mutate caller's request in tool-order transport
grsmv Jul 16, 2026
5667017
CON-117: fill in a missing generatePosts date-window bound
grsmv Jul 17, 2026
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
7 changes: 7 additions & 0 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ type Config struct {
MaxContextAssets int `envconfig:"MAX_ASSET_CONTEXT" default:"15"`
MaxContextChars int `envconfig:"MAX_CONTEXT_CHARS" default:"10000"`

// PlanningModelID (CON-112) backs the cheap/fast "planning" role used by
// the Campaign Assistant's orchestration + intent-routing loop. Prose
// generation happens inside the content_plan / enrich_brief sub-flows it
// invokes as tools, which stay on ModelID (Sonnet-tier) — so the assistant
// routes cheaply on Haiku while the heavy writing stays capable.
PlanningModelID string `envconfig:"PLANNING_MODEL_ID" default:"claude-haiku-4-5-20251001"`

// 64K matches Claude 4.x Haiku/Sonnet's max output. Anthropic charges
// only for tokens actually emitted, so a generous cap costs nothing on
// short responses but prevents truncation on long rewrites (assistant
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- CON-112: drop the Campaign Assistant conversation history table.
DROP TABLE IF EXISTS campaign_assistant_messages;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- CON-112: per-campaign conversation history between the user and the
-- Campaign Assistant. Mirrors post_assistant_messages, tenant-scoped from the
-- start (CON-97) since it is created after the tenant foundation migrations.
CREATE TABLE campaign_assistant_messages (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL REFERENCES campaigns (id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'model')),
content TEXT NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_campaign_assistant_messages_campaign_id ON campaign_assistant_messages (campaign_id);
CREATE INDEX idx_campaign_assistant_messages_tenant_id ON campaign_assistant_messages (tenant_id);
69 changes: 69 additions & 0 deletions src/genkit/flows/campaign_assistant/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package campaign_assistant

import (
"bytes"
"text/template"

"github.com/ogen-app/ogen/src/models"
)

// assistantContext holds the rendered prompts ready for the model call.
type assistantContext struct {
SystemPrompt string // stable system instructions (cacheable prefix)
ContextBlock string // the campaign's current brief (stable within a turn)
}

// contextTemplateData is the view model for the prompt template. It carries
// only fields already loaded on the campaign, so context assembly costs no
// extra query — the planner stays fast (CON-112 execution-time optimisation).
type contextTemplateData struct {
CampaignName string
Status string
CampaignType string
Language string
Description string
TargetPersona string
KeyMessages string
ToneGuidelines string
}

// assembleContext renders the system + context blocks from the campaign. The
// brief is placed in the context block so grounded Q&A ("summarise the brief")
// needs no tool round-trip. The rendered strings are deterministic, so an
// unchanged brief produces an identical prefix and Anthropic prompt caching
// still applies without an in-process cache.
func assembleContext(campaign *models.Campaign, systemTmpl, contextTmpl *template.Template) (*assistantContext, error) {
campaignType := campaign.CampaignTypeID
if campaign.CampaignType != nil && campaign.CampaignType.Name != "" {
campaignType = campaign.CampaignType.Name
}

data := contextTemplateData{
CampaignName: campaign.Name,
Status: string(campaign.Status),
CampaignType: campaignType,
Language: campaign.Language,
Description: campaign.Description,
TargetPersona: campaign.TargetPersona,
KeyMessages: campaign.KeyMessages,
ToneGuidelines: campaign.ToneGuidelines,
}

systemPrompt, err := renderTemplate(systemTmpl, data)
if err != nil {
return nil, err
}
contextBlock, err := renderTemplate(contextTmpl, data)
if err != nil {
return nil, err
}
return &assistantContext{SystemPrompt: systemPrompt, ContextBlock: contextBlock}, nil
}

func renderTemplate(tmpl *template.Template, data any) (string, error) {
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
121 changes: 121 additions & 0 deletions src/genkit/flows/campaign_assistant/flow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package campaign_assistant

import (
"context"
"embed"
"fmt"
"log/slog"
"text/template"

"github.com/firebase/genkit/go/core"
"github.com/firebase/genkit/go/genkit"

"github.com/ogen-app/ogen/src/eventhub"
"github.com/ogen-app/ogen/src/logging"
"github.com/ogen-app/ogen/src/models"
)

//go:embed prompts/campaign_assistant.tmpl
var promptFS embed.FS

// CampaignAssistantFlow is the singleton Genkit flow. Set by
// InitCampaignAssistant. Registered for Dev-UI discovery; the SSE path uses the
// runner closure below so it can stream events.
var CampaignAssistantFlow *core.Flow[CampaignAssistantRequest, *CampaignAssistantResponse, struct{}]

// campaignAssistantRunner is the direct closure that threads an OnEventFunc for
// SSE streaming. Set by InitCampaignAssistant.
var campaignAssistantRunner func(ctx context.Context, req CampaignAssistantRequest, onEvent OnEventFunc) (*CampaignAssistantResponse, error)

// InitCampaignAssistant parses the prompt template, registers the tools, and
// registers the campaignAssistant Genkit flow. Must be called after the Genkit
// instance has been initialised with the Anthropic plugin.
func InitCampaignAssistant(g *genkit.Genkit, cfg CampaignAssistantFlowConfig, repos CampaignAssistantRepos) error {
raw, err := promptFS.ReadFile("prompts/campaign_assistant.tmpl")
if err != nil {
return fmt.Errorf("load campaign_assistant.tmpl: %w", err)
}
tmpl, err := template.New("campaign_assistant").Parse(string(raw))
if err != nil {
return fmt.Errorf("parse campaign_assistant.tmpl: %w", err)
}
systemTmpl := tmpl.Lookup("system")
contextTmpl := tmpl.Lookup("context")
if systemTmpl == nil || contextTmpl == nil {
return fmt.Errorf("campaign_assistant.tmpl must define both {{define \"system\"}} and {{define \"context\"}} blocks")
}

tools := defineTools(g)

CampaignAssistantFlow = genkit.DefineFlow(g, "campaignAssistant",
func(ctx context.Context, req CampaignAssistantRequest) (*CampaignAssistantResponse, error) {
return runCampaignAssistant(ctx, g, req, cfg, repos, systemTmpl, contextTmpl, tools, nil)
},
)

campaignAssistantRunner = func(ctx context.Context, req CampaignAssistantRequest, onEvent OnEventFunc) (*CampaignAssistantResponse, error) {
return runCampaignAssistant(ctx, g, req, cfg, repos, systemTmpl, contextTmpl, tools, onEvent)
}

return nil
}

// NewCampaignAssistantCallback returns a callback suitable for passing to the
// campaigns handler. onEvent is forwarded to the flow for SSE streaming; pass
// nil for a silent, non-streaming call.
func NewCampaignAssistantCallback() func(ctx context.Context, req CampaignAssistantRequest, onEvent OnEventFunc) (*CampaignAssistantResponse, error) {
return func(ctx context.Context, req CampaignAssistantRequest, onEvent OnEventFunc) (*CampaignAssistantResponse, error) {
return campaignAssistantRunner(ctx, req, onEvent)
}
}

// emit calls onEvent when it is non-nil. It is a safe no-op otherwise.
func emit(onEvent OnEventFunc, name SSEEventKind, data any) {
if onEvent != nil {
onEvent(name, data)
}
}

// publishAssistantFinalised announces the end of an assistant run on the shared
// event hub. Topic is "entity:campaign:<id>"; type is "assistant_completed" on
// success, "assistant_failed" on error — driving cross-tab notifications.
func publishAssistantFinalised(
hub eventhub.Hub,
campaignID, ownerID string,
resp *CampaignAssistantResponse,
err error,
) {
if hub == nil {
return
}
id, idErr := models.NewID()
if idErr != nil {
slog.Error("cannot mint event id", logging.AttrComponent, "genkit.campaign_assistant", logging.AttrError, idErr)
return
}
ev := eventhub.Event{
ID: id,
Topic: "entity:campaign:" + campaignID,
UserID: ownerID,
}
if err != nil {
ev.Type = "assistant_failed"
ev.Payload = map[string]any{
"campaignId": campaignID,
"error": err.Error(),
}
} else {
action := ""
if resp != nil {
action = resp.Action
}
ev.Type = "assistant_completed"
ev.Payload = map[string]any{
"campaignId": campaignID,
"action": action,
}
}
if pubErr := hub.Publish(context.Background(), ev); pubErr != nil {
slog.Error("hub publish failed", logging.AttrComponent, "genkit.campaign_assistant", "campaign_id", campaignID, logging.AttrError, pubErr)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{{define "system"}}
You are the Campaign Assistant for Ogen, a social-media content platform. You help a user work on ONE campaign through a chat conversation. You are focused, concise, and never invent facts about the campaign.

You can do three things:
1. Generate a content plan — a set of draft posts across the campaign's platforms.
2. Enrich the campaign brief — improve its description, target persona, key messages, and tone guidelines.
3. Answer questions about the campaign, grounded in the brief and its posts.

## 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.
- 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.

Use a tool only when the user's request clearly matches it. For a general question you can answer from the campaign brief already shown below, answer directly without a tool.

## 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 | brief_enriched | declined>"
}

Rules for `action`:
- "content_plan_generated" — you called runContentPlan this turn.
- "brief_enriched" — you called enrichBrief this turn.
- "answered" — you answered a question or made small talk without changing anything.
- "declined" — the request is out of scope (anything other than the three 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.
{{end}}
{{define "context"}}
## Campaign
- Name: {{.CampaignName}}
- Status: {{.Status}}
- Type: {{.CampaignType}}
- Language: {{.Language}}

## Current brief
Description: {{.Description}}
Target persona: {{.TargetPersona}}
Key messages: {{.KeyMessages}}
Tone guidelines: {{.ToneGuidelines}}
{{end}}
Loading
Loading