Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
92 changes: 92 additions & 0 deletions http-client/campaigns/campaigns.http
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,95 @@ Content-Type: application/json
### Enrich campaign brief — non-existent ID (expects 404) [protected]
POST {{baseUrl}}/api/campaigns/nonexistent/enrich-brief
Accept: text/event-stream

###
# ── Campaign Assistant — CON-112 ──────────────────────────────────────────────
###
# A conversational, per-campaign assistant. Available to ANY user in the
# campaign's tenant (no owner-only guard). It routes natural-language
# instructions to the content_plan and enrich_brief flows (run as tools) or
# answers grounded questions about the campaign.
#
# A cheap planning model (PLANNING_MODEL_ID, Haiku) drives routing; the wrapped
# flows do the heavy prose on the generation model. enrichBrief auto-applies the
# new brief to the campaign; runContentPlan persists the draft posts.
#
# Requires ANTHROPIC_API_KEY on the server; returns 503 when the flow is unwired.
# Returns 400 when "instruction" is missing/empty.
# Response is a Server-Sent Events stream. Events:
# "explanation_delta" ({"delta":"..."}) — the conversational reply streaming;
# "tool_call" / "tool_result" — the assistant invoking a tool;
# "content_plan_started" / "content_plan_step" / "content_plan_post" /
# "content_plan_warning" / "content_plan_complete" — forwarded, namespaced,
# while runContentPlan runs;
# "enrich_brief_started" / "enrich_brief_description_delta" /
# "enrich_brief_persona_delta" / "enrich_brief_messages_delta" /
# "enrich_brief_tone_delta" / "enrich_brief_complete" — forwarded while
# enrichBrief runs;
# "complete" (the full CampaignAssistantResponse — the source of truth);
# "error" ({"message":"...","code":<http_code>}).

### Campaign assistant — generate a content plan [protected]
POST {{baseUrl}}/api/campaigns/{{campaignId}}/assistant
Accept: text/event-stream
Content-Type: application/json

{
"instruction": "Generate a content plan for this campaign."
}

### Campaign assistant — enrich / improve the brief [protected]
POST {{baseUrl}}/api/campaigns/{{campaignId}}/assistant
Accept: text/event-stream
Content-Type: application/json

{
"instruction": "Improve the campaign brief — make the tone more B2B, technical, and benchmark-driven."
}

### Campaign assistant — grounded question (no mutation) [protected]
POST {{baseUrl}}/api/campaigns/{{campaignId}}/assistant
Accept: text/event-stream
Content-Type: application/json

{
"instruction": "Summarise the brief and tell me what posts already exist in this campaign."
}

### Campaign assistant — out-of-scope request (expects action=declined) [protected]
POST {{baseUrl}}/api/campaigns/{{campaignId}}/assistant
Accept: text/event-stream
Content-Type: application/json

{
"instruction": "Delete all of my posts and cancel my subscription."
}

### Campaign assistant — missing instruction (expects 400) [protected]
POST {{baseUrl}}/api/campaigns/{{campaignId}}/assistant
Accept: text/event-stream
Content-Type: application/json

{}

### Campaign assistant — non-existent campaign (streams error, campaign not found) [protected]
POST {{baseUrl}}/api/campaigns/nonexistent/assistant
Accept: text/event-stream
Content-Type: application/json

{
"instruction": "Generate a content plan."
}

### List campaign assistant messages [protected]
# Returns up to the 50 most-recent conversation turns (oldest-first).
GET {{baseUrl}}/api/campaigns/{{campaignId}}/messages
Accept: application/json

### Campaign overview — brief, phases, content distribution [protected] (CON-113)
# Synchronous JSON (not SSE). Returns the brief recap, the campaign's phases with
# per-phase post counts, and content distribution by status, platform, and
# content type. Available to any user in the campaign's tenant.
# 404 if the campaign does not exist (or belongs to another tenant).
GET {{baseUrl}}/api/campaigns/{{campaignId}}/overview
Accept: application/json
66 changes: 66 additions & 0 deletions src/campaignoverview/overview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Package campaignoverview computes a read-only "quick overview" of a campaign
// (CON-113): its brief, phases with per-phase post counts, and content
// distribution by status, platform, and content type. The same service backs
// both the Campaign Assistant's getCampaignOverview tool and the
// GET /api/campaigns/:id/overview REST endpoint.
package campaignoverview

import (
"errors"
"time"
)

// ErrNotFound is returned by Service.Overview when the campaign does not exist
// (or belongs to another tenant). Handlers map it to 404.
var ErrNotFound = errors.New("campaign not found")

// Overview is the read-only snapshot returned to the assistant tool and the
// REST endpoint.
type Overview struct {
CampaignID string `json:"campaignId"`
Name string `json:"name"`
Status string `json:"status"`
Type string `json:"type"` // campaign type name
Language string `json:"language"`
Brief Brief `json:"brief"`
Phases []PhaseInfo `json:"phases"` // ordered by sequence
TotalPosts int `json:"totalPosts"`
Distribution Distribution `json:"distribution"`
GeneratedAt time.Time `json:"generatedAt"`
}

// Brief recaps the campaign's brief fields.
type Brief struct {
Description string `json:"description"`
TargetPersona string `json:"targetPersona"`
KeyMessages string `json:"keyMessages"`
ToneGuidelines string `json:"toneGuidelines"`
}

// PhaseInfo is one campaign phase plus how many posts are assigned to it — the
// content distribution across phases.
type PhaseInfo struct {
ID string `json:"id"`
Sequence int `json:"sequence"`
Name string `json:"name"`
Purpose string `json:"purpose"`
PostCount int `json:"postCount"`
}

// Distribution holds the flat post breakdowns. Counts reconcile: TotalPosts
// equals the sum of each breakdown, and the sum of phase PostCounts plus
// UnassignedPhasePostCount.
type Distribution struct {
// UnassignedPhasePostCount counts posts with no phase (or a stale phase id).
UnassignedPhasePostCount int `json:"unassignedPhasePostCount"`
ByStatus []Bucket `json:"byStatus"`
ByPlatform []Bucket `json:"byPlatform"`
ByContentType []Bucket `json:"byContentType"`
}

// Bucket is one entry in a distribution breakdown.
type Bucket struct {
Key string `json:"key"` // status string / platform id / post-type slug
Label string `json:"label"` // human label (platform name; else the key or "None")
Count int `json:"count"`
}
194 changes: 194 additions & 0 deletions src/campaignoverview/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package campaignoverview

import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"time"

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

// statusOrder is the fixed display order for the byStatus breakdown, so the
// output is deterministic and reads lifecycle-first.
var statusOrder = []models.PostStatus{
models.PostStatusDraft,
models.PostStatusReadyForPublish,
models.PostStatusScheduled,
models.PostStatusScheduledForManualPublish,
models.PostStatusPublished,
models.PostStatusNotPublished,
models.PostStatusFailed,
}

// Service computes campaign overviews from tenant-scoped repositories.
type Service struct {
campaigns repository.CampaignRepository
posts repository.PostRepository
platforms repository.PlatformRepository
}

// New builds a Service. The repositories carry tenant scoping, so Overview is
// safe to call in any tenant context.
func New(campaigns repository.CampaignRepository, posts repository.PostRepository, platforms repository.PlatformRepository) *Service {
return &Service{campaigns: campaigns, posts: posts, platforms: platforms}
}

// Overview loads the campaign (with its type + phases hydrated) and its posts,
// then computes the brief recap, per-phase post counts, and the status /
// platform / content-type distribution. Returns ErrNotFound when the campaign
// does not exist in the caller's tenant.
func (s *Service) Overview(ctx context.Context, campaignID string) (*Overview, error) {
campaign, err := s.campaigns.GetByID(ctx, campaignID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("load campaign: %w", err)
}

posts, err := s.posts.ListByCampaign(ctx, campaignID)
if err != nil {
return nil, fmt.Errorf("list posts: %w", err)
}

// Platform-name resolution is best-effort: on failure the byPlatform buckets
// fall back to platform ids rather than failing the whole overview.
names := map[string]string{}
if platforms, perr := s.platforms.List(ctx); perr == nil {
names = make(map[string]string, len(platforms))
for _, p := range platforms {
names[p.ID] = p.Name
}
}

ov := buildOverview(campaign, posts, names)
ov.GeneratedAt = time.Now().UTC()
return ov, nil
}

// buildOverview is the pure aggregation (no I/O), unit-testable with hand-built
// inputs. GeneratedAt is stamped by the caller.
func buildOverview(campaign *models.Campaign, posts []models.Post, platformNames map[string]string) *Overview {
typeName := campaign.CampaignTypeID
var phaseDefs []models.CampaignTypePhase
if campaign.CampaignType != nil {
if campaign.CampaignType.Name != "" {
typeName = campaign.CampaignType.Name
}
phaseDefs = campaign.CampaignType.Phases
}

knownPhase := make(map[string]bool, len(phaseDefs))
for _, ph := range phaseDefs {
knownPhase[ph.ID] = true
}

var (
phaseCount = map[string]int{}
statusCount = map[models.PostStatus]int{}
platformCount = map[string]int{}
typeCount = map[string]int{}
unassigned int
)
for _, p := range posts {
if p.CampaignTypePhaseID != nil && knownPhase[*p.CampaignTypePhaseID] {
phaseCount[*p.CampaignTypePhaseID]++
} else {
unassigned++
}
statusCount[p.Status]++
platformCount[p.PlatformID]++
typeCount[p.PlatformPostType]++
}

phases := make([]PhaseInfo, 0, len(phaseDefs))
for _, ph := range phaseDefs {
phases = append(phases, PhaseInfo{
ID: ph.ID,
Sequence: ph.Sequence,
Name: ph.Name,
Purpose: ph.Purpose,
PostCount: phaseCount[ph.ID],
})
}

return &Overview{
CampaignID: campaign.ID,
Name: campaign.Name,
Status: string(campaign.Status),
Type: typeName,
Language: campaign.Language,
Brief: Brief{
Description: campaign.Description,
TargetPersona: campaign.TargetPersona,
KeyMessages: campaign.KeyMessages,
ToneGuidelines: campaign.ToneGuidelines,
},
Phases: phases,
TotalPosts: len(posts),
Distribution: Distribution{
UnassignedPhasePostCount: unassigned,
ByStatus: statusBuckets(statusCount),
ByPlatform: platformBuckets(platformCount, platformNames),
ByContentType: slugBuckets(typeCount),
},
}
}

// statusBuckets emits non-zero statuses in the fixed lifecycle order.
func statusBuckets(counts map[models.PostStatus]int) []Bucket {
out := make([]Bucket, 0, len(counts))
for _, st := range statusOrder {
if n := counts[st]; n > 0 {
out = append(out, Bucket{Key: string(st), Label: string(st), Count: n})
}
}
return out
}

// platformBuckets resolves ids to names (empty id → "None", unknown id → the id)
// and orders by count desc, then label asc.
func platformBuckets(counts map[string]int, names map[string]string) []Bucket {
out := make([]Bucket, 0, len(counts))
for id, n := range counts {
label := names[id]
switch {
case id == "":
label = "None"
case label == "":
label = id
}
out = append(out, Bucket{Key: id, Label: label, Count: n})
}
sortBuckets(out)
return out
}

// slugBuckets orders content-type slugs by count desc, then slug asc (empty
// slug → "None").
func slugBuckets(counts map[string]int) []Bucket {
out := make([]Bucket, 0, len(counts))
for slug, n := range counts {
label := slug
if slug == "" {
label = "None"
}
out = append(out, Bucket{Key: slug, Label: label, Count: n})
}
sortBuckets(out)
return out
}

// sortBuckets applies the deterministic count-desc, label-asc order.
func sortBuckets(b []Bucket) {
sort.SliceStable(b, func(i, j int) bool {
if b[i].Count != b[j].Count {
return b[i].Count > b[j].Count
}
return b[i].Label < b[j].Label
})
}
Loading
Loading