diff --git a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl index 05028ec3..0a859300 100644 --- a/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl +++ b/src/genkit/flows/campaign_assistant/prompts/campaign_assistant.tmpl @@ -8,6 +8,7 @@ You can help with: 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. 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). ## 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. @@ -19,6 +20,7 @@ You can help with: - 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. - 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. 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. @@ -42,6 +44,8 @@ Rules for `action`: - "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. + +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}} {{define "context"}} ## Campaign diff --git a/src/genkit/flows/campaign_assistant/run.go b/src/genkit/flows/campaign_assistant/run.go index d150691c..a122ce0a 100644 --- a/src/genkit/flows/campaign_assistant/run.go +++ b/src/genkit/flows/campaign_assistant/run.go @@ -99,6 +99,7 @@ func runCampaignAssistant( campaign: campaign, repos: repos, onEvent: onEvent, + embedder: cfg.Embedder, contentPlan: cfg.ContentPlan, enrichBrief: cfg.EnrichBrief, overview: cfg.Overview, @@ -193,7 +194,7 @@ func runCampaignAssistant( ai.WithSystem(systemBlock), ai.WithMessages(history...), ai.WithPrompt(req.Instruction), - ai.WithTools(tools.runContentPlan, tools.enrichBrief, tools.listCampaignPosts, tools.getCampaignOverview, tools.generatePosts, tools.setCampaignDates, tools.redistributePosts, tools.checkBrief, tools.checkPostsConsistency), + ai.WithTools(tools.runContentPlan, tools.enrichBrief, tools.listCampaignPosts, tools.getCampaignOverview, tools.generatePosts, tools.setCampaignDates, tools.redistributePosts, tools.checkBrief, tools.checkPostsConsistency, tools.askCampaignAssets), ai.WithMaxTurns(maxTurns), ai.WithStreaming(streamCb), cfg.Provider.CallConfig(maxTokens), diff --git a/src/genkit/flows/campaign_assistant/tools.go b/src/genkit/flows/campaign_assistant/tools.go index 587b6c39..e4a61552 100644 --- a/src/genkit/flows/campaign_assistant/tools.go +++ b/src/genkit/flows/campaign_assistant/tools.go @@ -3,19 +3,24 @@ package campaign_assistant import ( "context" "fmt" + "log/slog" "sort" "strings" "time" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" + "github.com/pgvector/pgvector-go" "github.com/ogen-app/ogen/src/campaign_actions/overview" "github.com/ogen-app/ogen/src/campaign_actions/reschedule" + "github.com/ogen-app/ogen/src/genkit/embedopts" "github.com/ogen-app/ogen/src/genkit/flows/consistency" "github.com/ogen-app/ogen/src/genkit/flows/content_plan" "github.com/ogen-app/ogen/src/genkit/flows/enrich_brief" + "github.com/ogen-app/ogen/src/logging" "github.com/ogen-app/ogen/src/models" + "github.com/ogen-app/ogen/src/repository" ) // ── Context key for per-request state ──────────────────────────────────────── @@ -32,6 +37,9 @@ type requestState struct { campaign *models.Campaign repos CampaignAssistantRepos onEvent OnEventFunc + // embedder backs the askCampaignAssets read tool (CON-118); nil / unavailable + // degrades that tool to "search unavailable". + embedder ai.Embedder // Injected sub-flow callbacks, invoked as tools. contentPlan func(ctx context.Context, campaignID string, onEvent content_plan.OnEventFunc) (*content_plan.ContentPlanResponse, error) @@ -82,8 +90,9 @@ type EnrichBriefOutput struct { // RunContentPlanOutput is returned to the model after a content plan runs. type RunContentPlanOutput struct { - PostCount int `json:"postCount"` - WarningCount int `json:"warningCount"` + PostCount int `json:"postCount"` + WarningCount int `json:"warningCount"` + UsedAssets []AssetRef `json:"usedAssets,omitempty"` // CON-118: assets that informed the plan } // CampaignPostInfo is a single element of the listCampaignPosts output. @@ -108,13 +117,14 @@ type GeneratePostsInput struct { // GeneratePostsOutput is returned to the model after targeted posts are created. type GeneratePostsOutput struct { - PostCount int `json:"postCount"` - RequestedCount int `json:"requestedCount"` - 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 - Warnings []string `json:"warnings,omitempty"` + PostCount int `json:"postCount"` + RequestedCount int `json:"requestedCount"` + 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 + Warnings []string `json:"warnings,omitempty"` + UsedAssets []AssetRef `json:"usedAssets,omitempty"` // CON-118: assets that informed the posts } // SetCampaignDatesInput is the input for the setCampaignDates tool (CON-115). @@ -142,6 +152,27 @@ type CheckPostsInput struct { Max int `json:"max,omitempty" jsonschema:"description=Optional cap on how many posts to review; omit for the default."` } +// AskCampaignAssetsInput is the input for the askCampaignAssets read tool (CON-118). +type AskCampaignAssetsInput struct { + Query string `json:"query" jsonschema:"description=The question to answer from the campaign's attached assets, e.g. 'what does the pricing PDF say about enterprise tiers?'"` +} + +// AskCampaignAssetsOutput returns the asset excerpts most relevant to the query +// for the planner to answer from (CON-118). +type AskCampaignAssetsOutput struct { + Excerpts []AssetExcerpt `json:"excerpts"` + Available bool `json:"available"` // false when asset search could not run + Note string `json:"note,omitempty"` // status when excerpts is empty +} + +// AssetExcerpt is one relevant chunk of an attached asset. +type AssetExcerpt struct { + AssetID string `json:"assetId"` + Title string `json:"title"` + Pages string `json:"pages,omitempty"` + Text string `json:"text"` +} + // ── Tool registration ──────────────────────────────────────────────────────── type toolSet struct { @@ -154,6 +185,7 @@ type toolSet struct { redistributePosts ai.ToolRef checkBrief ai.ToolRef checkPostsConsistency ai.ToolRef + askCampaignAssets ai.ToolRef } func defineTools(g *genkit.Genkit) *toolSet { @@ -231,6 +263,16 @@ func defineTools(g *genkit.Genkit) *toolSet { }, ) + askCampaignAssets := genkit.DefineTool(g, "askCampaignAssets", + "Answers a question grounded in the campaign's attached assets (uploaded PDFs, markdown, etc.). "+ + "Call this when the user asks what an attached asset says or wants facts pulled from the campaign's assets — e.g. \"what does the pricing PDF say about enterprise tiers?\". "+ + "Read-only: it returns the most relevant excerpts (with asset title + page) for you to answer from; it does NOT generate posts. "+ + "If the result reports available:false or no excerpts, tell the user you couldn't search / find matching asset content.", + func(ctx *ai.ToolContext, in AskCampaignAssetsInput) (*AskCampaignAssetsOutput, error) { + return toolAskCampaignAssets(ctx, in) + }, + ) + return &toolSet{ runContentPlan: runContentPlan, enrichBrief: enrichBrief, @@ -241,6 +283,7 @@ func defineTools(g *genkit.Genkit) *toolSet { redistributePosts: redistributePosts, checkBrief: checkBrief, checkPostsConsistency: checkPostsConsistency, + askCampaignAssets: askCampaignAssets, } } @@ -252,6 +295,9 @@ func toolRunContentPlan(ctx context.Context) (*RunContentPlanOutput, error) { return nil, fmt.Errorf("content plan generation is not available") } + // CON-118: generate from the campaign's attached assets when it has any. + ensureCampaignAssetUse(ctx, st) + emit(st.onEvent, SSEEventContentPlanStarted, ContentPlanStartedEventPayload{}) // Forward the sub-flow's native events, namespaced, so the client sees @@ -273,9 +319,28 @@ func toolRunContentPlan(ctx context.Context) (*RunContentPlanOutput, error) { return nil, err } - res := &ContentPlanResult{PostCount: len(resp.Posts), Warnings: resp.Warnings} + // CON-118: report which attached assets informed the plan. + used := toAssetRefs(resp.UsedAssets) + if len(used) > 0 { + emit(st.onEvent, SSEEventAssetsUsed, AssetsUsedEventPayload{Assets: used}) + } + + res := &ContentPlanResult{PostCount: len(resp.Posts), Warnings: resp.Warnings, UsedAssets: used} st.contentPlanResult = res - return &RunContentPlanOutput{PostCount: res.PostCount, WarningCount: len(res.Warnings)}, nil + return &RunContentPlanOutput{PostCount: res.PostCount, WarningCount: len(res.Warnings), UsedAssets: used}, nil +} + +// toAssetRefs maps the content_plan provenance list into the assistant's local +// AssetRef type for SSE events + tool output (CON-118). +func toAssetRefs(in []content_plan.AssetRef) []AssetRef { + if len(in) == 0 { + return nil + } + out := make([]AssetRef, len(in)) + for i, a := range in { + out[i] = AssetRef{ID: a.ID, Title: a.Title} + } + return out } func toolEnrichBrief(ctx context.Context, in EnrichBriefInput) (*EnrichBriefOutput, error) { @@ -361,6 +426,8 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos if st.generatePosts == nil { return nil, fmt.Errorf("targeted post generation is not available") } + // CON-118: generate from the campaign's attached assets when it has any. + ensureCampaignAssetUse(ctx, st) campaign := st.campaign now := time.Now().UTC() @@ -430,11 +497,18 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos return nil, err } + // CON-118: report which attached assets informed the generated posts. + used := toAssetRefs(resp.UsedAssets) + if len(used) > 0 { + emit(st.onEvent, SSEEventAssetsUsed, AssetsUsedEventPayload{Assets: used}) + } + st.generatedPostsResult = &GeneratedPostsResult{ PostCount: len(resp.Posts), PlatformIDs: platformIDs, PhaseID: phaseID, Warnings: resp.Warnings, + UsedAssets: used, } return &GeneratePostsOutput{ PostCount: len(resp.Posts), @@ -444,9 +518,146 @@ func toolGeneratePosts(ctx context.Context, in GeneratePostsInput) (*GeneratePos PhaseName: phaseName, Platforms: platformNames, Warnings: resp.Warnings, + UsedAssets: used, }, nil } +// 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). +const minAskAssetsSimilarity = 0.5 + +// askAssetsChunkLimit caps how many chunks the Q&A tool returns to the planner. +const askAssetsChunkLimit = 8 + +// toolAskCampaignAssets answers a question grounded in the campaign's attached +// assets by embedding the query and searching the assets' chunks (CON-118). It +// is read-only and degrades cleanly (available:false / a note) rather than +// failing the turn when the embedder is unavailable or nothing matches. +func toolAskCampaignAssets(ctx context.Context, in AskCampaignAssetsInput) (*AskCampaignAssetsOutput, error) { + st := getRequestState(ctx) + query := strings.TrimSpace(in.Query) + if query == "" { + return nil, fmt.Errorf("ask a question about the campaign's assets") + } + if !embedopts.Available(st.embedder) || st.repos.Chunks == nil || st.repos.Assets == nil { + return &AskCampaignAssetsOutput{Available: false, Note: "asset search is unavailable right now"}, nil + } + + ids, err := readyCampaignAssetIDs(ctx, st.campaign, st.repos.Assets) + if err != nil { + return nil, fmt.Errorf("resolve campaign assets: %w", err) + } + if len(ids) == 0 { + return &AskCampaignAssetsOutput{Available: true, Note: "this campaign has no ready assets to search"}, nil + } + + qResp, err := st.embedder.Embed(ctx, &ai.EmbedRequest{ + Input: []*ai.Document{ai.DocumentFromText(query, nil)}, + Options: embedopts.Query(), + }) + if err != nil || len(qResp.Embeddings) == 0 { + return &AskCampaignAssetsOutput{Available: false, Note: "asset search is unavailable right now"}, nil + } + + chunks, err := st.repos.Chunks.SearchSimilar(ctx, pgvector.NewHalfVector(qResp.Embeddings[0].Embedding), ids, minAskAssetsSimilarity, askAssetsChunkLimit) + if err != nil { + return nil, fmt.Errorf("search assets: %w", err) + } + if len(chunks) == 0 { + return &AskCampaignAssetsOutput{Available: true, Note: "no attached asset content matched the question"}, nil + } + + titles := make(map[string]string) + excerpts := make([]AssetExcerpt, 0, len(chunks)) + for _, c := range chunks { + title, ok := titles[c.AssetID] + if !ok { + if a, err := st.repos.Assets.GetByID(ctx, c.AssetID); err == nil { + title = a.Title + } + titles[c.AssetID] = title + } + excerpts = append(excerpts, AssetExcerpt{ + AssetID: c.AssetID, + Title: title, + Pages: pageRef(c.PageStart, c.PageEnd), + Text: c.Content, + }) + } + return &AskCampaignAssetsOutput{Excerpts: excerpts, Available: true}, nil +} + +// readyCampaignAssetIDs resolves the campaign's attached, ready asset IDs — the +// explicit AssetIDs list when set, otherwise all tenant-ready assets — excluding +// failed/partial. Mirrors content_plan's candidate resolution (CON-118). It does +// NOT require campaign.UseAssets: that flag governs automatic inclusion during +// generation, whereas Q&A is an explicit request to consult the assets. +func readyCampaignAssetIDs(ctx context.Context, campaign *models.Campaign, assets repository.AssetRepository) ([]string, error) { + bad := func(status string) bool { + return status == models.AssetStatusFailed || status == models.AssetStatusPartial + } + if len(campaign.AssetIDs) > 0 { + out := make([]string, 0, len(campaign.AssetIDs)) + for _, id := range campaign.AssetIDs { + a, err := assets.GetByID(ctx, id) + if err != nil || bad(a.Status) { + continue + } + out = append(out, a.ID) + } + return out, nil + } + all, err := assets.List(ctx) + if err != nil { + return nil, err + } + out := make([]string, 0, len(all)) + for _, a := range all { + if !bad(a.Status) { + out = append(out, a.ID) + } + } + return out, nil +} + +// ensureCampaignAssetUse turns on asset-sourced content generation when the +// campaign has ready attached assets but UseAssets is still off, and persists +// the flag so it sticks for later turns and the UI (CON-118). content_plan +// injects the attached assets into the generation prompt whenever UseAssets is +// true, so flipping it here is all that's needed. Best-effort: if the flag +// can't be persisted, generation just proceeds without assets, as before. +func ensureCampaignAssetUse(ctx context.Context, st *requestState) { + if st.campaign.UseAssets || st.repos.Assets == nil || st.repos.Campaigns == nil { + return + } + if len(st.campaign.AssetIDs) == 0 { + return // no assets attached to this campaign + } + ids, err := readyCampaignAssetIDs(ctx, st.campaign, st.repos.Assets) + if err != nil || len(ids) == 0 { + return // nothing ready to use + } + st.campaign.UseAssets = true + if err := st.repos.Campaigns.Update(ctx, st.campaign); err != nil { + st.campaign.UseAssets = false // keep in-memory state consistent with the DB + slog.WarnContext(ctx, "could not enable asset use for generation", + logging.AttrComponent, "genkit.campaign_assistant", + "campaign_id", st.campaignID, logging.AttrError, err) + } +} + +// pageRef renders an asset chunk's page span for citation (CON-118). +func pageRef(start, end *int) string { + if start == nil { + return "" + } + if end == nil || *end == *start { + return fmt.Sprintf("p. %d", *start) + } + return fmt.Sprintf("pp. %d-%d", *start, *end) +} + // resolveTargetPlatforms maps requested platform names/ids to campaign-target // platform ids. It errors (naming the targets) when a requested platform isn't // one the campaign already targets — no silent scope expansion. diff --git a/src/genkit/flows/campaign_assistant/tools_test.go b/src/genkit/flows/campaign_assistant/tools_test.go index 5164d06f..7fd3d843 100644 --- a/src/genkit/flows/campaign_assistant/tools_test.go +++ b/src/genkit/flows/campaign_assistant/tools_test.go @@ -160,3 +160,22 @@ func TestResolveWindow(t *testing.T) { t.Fatal("expected error for bad windowEnd") } } + +// CON-118: page citation rendering for asset Q&A excerpts. +func TestPageRef(t *testing.T) { + p := func(i int) *int { return &i } + cases := []struct { + start, end *int + want string + }{ + {nil, nil, ""}, + {p(4), nil, "p. 4"}, + {p(4), p(4), "p. 4"}, + {p(3), p(5), "pp. 3-5"}, + } + for _, c := range cases { + if got := pageRef(c.start, c.end); got != c.want { + t.Errorf("pageRef(%v, %v) = %q, want %q", c.start, c.end, got, c.want) + } + } +} diff --git a/src/genkit/flows/campaign_assistant/types.go b/src/genkit/flows/campaign_assistant/types.go index d8c60794..6c3e7bb8 100644 --- a/src/genkit/flows/campaign_assistant/types.go +++ b/src/genkit/flows/campaign_assistant/types.go @@ -3,6 +3,8 @@ package campaign_assistant import ( "context" + "github.com/firebase/genkit/go/ai" + "github.com/ogen-app/ogen/src/campaign_actions/overview" "github.com/ogen-app/ogen/src/eventhub" "github.com/ogen-app/ogen/src/genkit/flows/consistency" @@ -69,12 +71,23 @@ type GeneratedPostsResult struct { PlatformIDs []string `json:"platformIds"` PhaseID string `json:"phaseId"` Warnings []string `json:"warnings,omitempty"` + // UsedAssets lists the campaign assets that informed the posts (CON-118); + // empty when none were used. + UsedAssets []AssetRef `json:"usedAssets,omitempty"` } // ContentPlanResult summarises a runContentPlan tool invocation. type ContentPlanResult struct { PostCount int `json:"postCount"` Warnings []string `json:"warnings,omitempty"` + // UsedAssets lists the campaign assets that informed the plan (CON-118). + UsedAssets []AssetRef `json:"usedAssets,omitempty"` +} + +// AssetRef is the id+title of a campaign asset that informed generation (CON-118). +type AssetRef struct { + ID string `json:"id"` + Title string `json:"title"` } // BriefResult reports whether the enrichBrief tool applied a new brief. @@ -88,6 +101,10 @@ type CampaignAssistantRepos struct { Campaigns repository.CampaignRepository // Posts backs the listCampaignPosts read tool used for grounded Q&A. Posts repository.PostRepository + // Assets + Chunks back the askCampaignAssets read tool (CON-118): resolve the + // campaign's ready attached assets and search their embedded chunks. + Assets repository.AssetRepository + Chunks repository.AssetChunksRepository } // CampaignAssistantFlowConfig holds static settings for the flow. @@ -100,7 +117,10 @@ type CampaignAssistantFlowConfig struct { Recorder *usage.Recorder // Checker gates the flow against the tenant's spend caps; nil = no gate. Checker *usage.Checker - ModelID string + // Embedder embeds the askCampaignAssets query for chunk search (CON-118). + // A nil / unavailable embedder disables asset Q&A gracefully. + Embedder ai.Embedder + ModelID string // MaxOutputTokens caps a single planner call. 0 falls back to 8192 — the // planner only emits a short JSON envelope, never long prose. MaxOutputTokens int64 @@ -178,6 +198,10 @@ const ( SSEEventGeneratePostsWarning SSEEventKind = "generate_posts_warning" SSEEventGeneratePostsComplete SSEEventKind = "generate_posts_complete" + // SSEEventAssetsUsed reports which attached assets informed the generated + // posts (CON-118); emitted by runContentPlan/generatePosts when non-empty. + SSEEventAssetsUsed SSEEventKind = "assets_used" + SSEEventDatesUpdated SSEEventKind = "dates_updated" SSEEventPostsRedistributed SSEEventKind = "posts_redistributed" @@ -241,6 +265,12 @@ type GeneratePostsCompleteEventPayload struct { Warnings []string `json:"warnings,omitempty"` } +// AssetsUsedEventPayload lists the attached assets that informed a generation +// (CON-118). +type AssetsUsedEventPayload struct { + Assets []AssetRef `json:"assets"` +} + // DatesUpdatedEventPayload is emitted once the campaign's dates are saved. type DatesUpdatedEventPayload struct { StartDate string `json:"startDate"` diff --git a/src/genkit/flows/content_plan/assets.go b/src/genkit/flows/content_plan/assets.go index c1565358..a12d4edb 100644 --- a/src/genkit/flows/content_plan/assets.go +++ b/src/genkit/flows/content_plan/assets.go @@ -20,6 +20,86 @@ import ( // 2's cosine distribution once there is real corpus data. const minAssetSimilarity = 0.7 +// assetIDsOf returns the distinct IDs of the assets actually retrieved into the +// generation context. These become each generated post's UsedAssetIDs — a +// binding grounded on what the model was given, not its self-reported claims +// (CON-118). +func assetIDsOf(assets []resolvedPiece) []string { + if len(assets) == 0 { + return nil + } + seen := make(map[string]struct{}, len(assets)) + out := make([]string, 0, len(assets)) + for _, a := range assets { + if a.ID == "" { + continue + } + if _, ok := seen[a.ID]; ok { + continue + } + seen[a.ID] = struct{}{} + out = append(out, a.ID) + } + return out +} + +// idSet builds a lookup set from a slice of asset IDs — the retrieved-context +// grounding set used to validate each post's self-reported assetRefs (CON-118). +func idSet(ids []string) map[string]struct{} { + set := make(map[string]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + return set +} + +// groundedRefs filters a post's model-reported assetRefs (DraftPost.AssetRefs) +// down to the ids actually retrieved into the generation context, deduped and in +// the model's order. This is each post's UsedAssetIDs binding (CON-118): only +// the assets the model said it drew on for that specific post, and only those we +// can confirm were placed in its prompt — a hallucinated id is dropped, and a +// post that cited nothing records an empty list rather than inheriting the whole +// retrieved set. grounded is the id set from assetIDsOf, precomputed once per run. +func groundedRefs(refs []string, grounded map[string]struct{}) []string { + out := make([]string, 0, len(refs)) + seen := make(map[string]struct{}, len(refs)) + for _, id := range refs { + if id == "" { + continue + } + if _, ok := grounded[id]; !ok { + continue + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +// assetRefsOf projects the retrieved pieces into the deduped id+title provenance +// surfaced on ContentPlanResponse.UsedAssets (CON-118). +func assetRefsOf(assets []resolvedPiece) []AssetRef { + if len(assets) == 0 { + return nil + } + seen := make(map[string]struct{}, len(assets)) + out := make([]AssetRef, 0, len(assets)) + for _, a := range assets { + if a.ID == "" { + continue + } + if _, ok := seen[a.ID]; ok { + continue + } + seen[a.ID] = struct{}{} + out = append(out, AssetRef{ID: a.ID, Title: a.Title}) + } + return out +} + func resolveAssets(ctx context.Context, campaign *models.Campaign, cfg ContentPlanFlowConfig, repos ContentPlanRepos) ([]resolvedPiece, []string, error) { if !campaign.UseAssets { return nil, nil, nil diff --git a/src/genkit/flows/content_plan/assets_test.go b/src/genkit/flows/content_plan/assets_test.go index a782d7de..03626bab 100644 --- a/src/genkit/flows/content_plan/assets_test.go +++ b/src/genkit/flows/content_plan/assets_test.go @@ -60,3 +60,62 @@ func TestJoinPagedChunks_Empty(t *testing.T) { t.Errorf("expected empty string, got %q", got) } } + +// CON-118: the grounded-binding helpers dedupe by id, preserve order, and skip +// empty ids. +func TestAssetIDsOf(t *testing.T) { + got := assetIDsOf([]resolvedPiece{ + {ID: "a", Title: "Alpha"}, + {ID: "b", Title: "Beta"}, + {ID: "a", Title: "Alpha dup"}, // deduped + {ID: "", Title: "no id"}, // skipped + }) + want := []string{"a", "b"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %v want %v", got, want) + } + if assetIDsOf(nil) != nil { + t.Error("nil input should return nil") + } +} + +// CON-118: a post's UsedAssetIDs is its self-reported assetRefs, filtered to the +// retrieved-context set — hallucinated ids dropped, dups removed, order kept, and +// an empty (non-nil) slice when nothing valid remains so the jsonb column stores +// [] rather than null. +func TestGroundedRefs(t *testing.T) { + grounded := idSet([]string{"a", "b", "c"}) + + got := groundedRefs([]string{"b", "x", "a", "b", ""}, grounded) + want := []string{"b", "a"} // "x" not retrieved, second "b" deduped, "" skipped + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %v want %v", got, want) + } + + // A post that cited nothing (or only hallucinated ids) gets a non-nil empty + // slice, never nil. + if got := groundedRefs(nil, grounded); got == nil || len(got) != 0 { + t.Errorf("nil refs should yield non-nil empty slice, got %#v", got) + } + if got := groundedRefs([]string{"z"}, grounded); got == nil || len(got) != 0 { + t.Errorf("unretrieved-only refs should yield non-nil empty slice, got %#v", got) + } +} + +func TestAssetRefsOf(t *testing.T) { + got := assetRefsOf([]resolvedPiece{ + {ID: "a", Title: "Alpha"}, + {ID: "a", Title: "Alpha again"}, // deduped by id, first title kept + {ID: "c", Title: "Gamma"}, + {ID: ""}, // skipped + }) + if len(got) != 2 { + t.Fatalf("got %d refs, want 2: %+v", len(got), got) + } + if got[0] != (AssetRef{ID: "a", Title: "Alpha"}) || got[1] != (AssetRef{ID: "c", Title: "Gamma"}) { + t.Fatalf("got %+v, want [{a Alpha} {c Gamma}]", got) + } + if assetRefsOf(nil) != nil { + t.Error("nil input should return nil") + } +} diff --git a/src/genkit/flows/content_plan/flow.go b/src/genkit/flows/content_plan/flow.go index 24dd16d1..a306ed79 100644 --- a/src/genkit/flows/content_plan/flow.go +++ b/src/genkit/flows/content_plan/flow.go @@ -283,6 +283,7 @@ func runContentPlan( GeneratedAt: time.Now().UTC(), Posts: posts, Warnings: warnings, + UsedAssets: assetRefsOf(assets), }, nil } @@ -375,5 +376,6 @@ func runGeneratePosts( GeneratedAt: time.Now().UTC(), Posts: posts, Warnings: warnings, + UsedAssets: assetRefsOf(assets), }, nil } diff --git a/src/genkit/flows/content_plan/generate.go b/src/genkit/flows/content_plan/generate.go index 0faba14d..d921b3a5 100644 --- a/src/genkit/flows/content_plan/generate.go +++ b/src/genkit/flows/content_plan/generate.go @@ -181,8 +181,14 @@ func generatePosts( validPhaseIDs[ph.ID] = true } validate := newPostValidator(platforms, validPhaseIDs, data.StartDate, data.EndDate) + // CON-118: bind each post to the subset of retrieved assets the model + // reported drawing on for that post (dp.AssetRefs), filtered to the ids + // actually retrieved into context so a hallucinated id never persists. Posts + // no longer all inherit the full retrieved set — a post that cited no asset + // records an empty list. + grounded := idSet(assetIDsOf(assets)) persistFn := func(ctx context.Context, dp DraftPost) (string, error) { - return persistOne(ctx, dp, campaign, repos.Posts) + return persistOne(ctx, dp, campaign, repos.Posts, groundedRefs(dp.AssetRefs, grounded)) } // Fill the parallel budget (CON-112 perf): a plan that fits in one batch is @@ -517,7 +523,7 @@ func trimBody(body string) string { // CreateBatch — a client disconnect mid-stream now leaves whatever was // already persisted in the database, and a hard *AIError from one batch // no longer rolls back the surviving batches' rows. -func persistOne(ctx context.Context, dp DraftPost, campaign *models.Campaign, postRepo repository.PostRepository) (string, error) { +func persistOne(ctx context.Context, dp DraftPost, campaign *models.Campaign, postRepo repository.PostRepository, usedAssetIDs []string) (string, error) { id, err := models.NewID() if err != nil { return "", err @@ -545,7 +551,7 @@ func persistOne(ctx context.Context, dp DraftPost, campaign *models.Campaign, po CTAType: models.CTATypeNone, CTAUrl: "", TargetAudienceNotes: dp.ToneNotes, - UsedAssetIDs: models.StringSlice(dp.AssetRefs), + UsedAssetIDs: models.StringSlice(usedAssetIDs), CampaignTypePhaseID: phaseID, ScheduledAt: scheduledAt, CreatedBy: campaign.CreatedBy, diff --git a/src/genkit/flows/content_plan/types.go b/src/genkit/flows/content_plan/types.go index a6836ab5..ba15c11d 100644 --- a/src/genkit/flows/content_plan/types.go +++ b/src/genkit/flows/content_plan/types.go @@ -41,6 +41,19 @@ type ContentPlanResponse struct { GeneratedAt time.Time `json:"generatedAt"` Posts []DraftPost `json:"posts"` Warnings []string `json:"warnings,omitempty"` + // UsedAssets lists the campaign assets retrieved into the generation context + // and offered to the model for this plan (CON-118). Each post records only + // the subset it actually drew on (Post.UsedAssetIDs), so this plan-level list + // is a superset of any single post's binding. Empty when UseAssets is off or + // nothing was retrieved. + UsedAssets []AssetRef `json:"usedAssets,omitempty"` +} + +// AssetRef is the id+title provenance of an asset that informed generation +// (CON-118). +type AssetRef struct { + ID string `json:"id"` + Title string `json:"title"` } // resolvedPiece is an internal type used to build the prompt context. diff --git a/src/integration/campaign_assistant_test.go b/src/integration/campaign_assistant_test.go index 6ae4096d..fd484a0f 100644 --- a/src/integration/campaign_assistant_test.go +++ b/src/integration/campaign_assistant_test.go @@ -35,6 +35,7 @@ var _ = Describe("Campaign assistant flow", Ordered, func() { campaignRepo repository.CampaignRepository postRepo repository.PostRepository messageRepo repository.CampaignAssistantMessageRepository + assetRepo repository.AssetRepository callback func(ctx context.Context, req campaign_assistant.CampaignAssistantRequest, onEvent campaign_assistant.OnEventFunc) (*campaign_assistant.CampaignAssistantResponse, error) platformID = "AXqWG7U2qnpt" // seeded LinkedIn platform (Sqid) @@ -49,7 +50,7 @@ var _ = Describe("Campaign assistant flow", Ordered, func() { db = mustOpenIntegrationDB() tagRepo := repository.NewTagRepository(db) - assetRepo := repository.NewAssetRepository(db, tagRepo, repository.NewAssetFileRepository(db)) + assetRepo = repository.NewAssetRepository(db, tagRepo, repository.NewAssetFileRepository(db)) chunksRepo := repository.NewAssetChunksRepository(db) platformRepo := repository.NewPlatformRepository(db) campaignTypeRepo := repository.NewCampaignTypeRepository(db) @@ -104,7 +105,7 @@ var _ = Describe("Campaign assistant flow", Ordered, func() { } provider := llm.NewProvider(modelID, modelID, modelID) - Expect(content_plan.InitContentPlan(g, content_plan.ContentPlanFlowConfig{Provider: provider}, content_plan.ContentPlanRepos{ + Expect(content_plan.InitContentPlan(g, content_plan.ContentPlanFlowConfig{Provider: provider, MaxContextAssets: 5}, content_plan.ContentPlanRepos{ Campaigns: campaignRepo, Assets: assetRepo, Chunks: chunksRepo, @@ -133,6 +134,8 @@ var _ = Describe("Campaign assistant flow", Ordered, func() { Messages: messageRepo, Campaigns: campaignRepo, Posts: postRepo, + Assets: assetRepo, + Chunks: chunksRepo, })).To(Succeed()) callback = campaign_assistant.NewCampaignAssistantCallback() }) @@ -376,4 +379,92 @@ var _ = Describe("Campaign assistant flow", Ordered, func() { } }) }) + + Describe("attached assets (CON-118)", func() { + It("auto-uses attached assets for generation, persists UseAssets, and grounds the binding", func() { + // Seed a ready asset and attach it to the campaign for this spec only. + assetID, err := models.NewID() + Expect(err).NotTo(HaveOccurred()) + const assetTitle = "Go concurrency benchmark" + Expect(assetRepo.Create(ctx, &models.Asset{ + ID: assetID, + Title: assetTitle, + Content: "Benchmarks show goroutine pipelines sustain 1.2M msgs/sec on 8 cores with p99 latency under 3ms.", + Status: models.AssetStatusReady, + TagIDs: models.StringSlice{}, + CreatedBy: userID, + })).To(Succeed()) + + // Attach the asset but leave UseAssets OFF — the assistant should turn + // it on because the campaign has ready attached assets (CON-118). + full, err := campaignRepo.GetByID(ctx, campaignID) + Expect(err).NotTo(HaveOccurred()) + full.UseAssets = false + full.AssetIDs = models.StringSlice{assetID} + Expect(campaignRepo.Update(ctx, full)).To(Succeed()) + + // Restore the campaign and remove the asset so later specs are unaffected. + DeferCleanup(func() { + if c, err := campaignRepo.GetByID(ctx, campaignID); err == nil { + c.UseAssets = false + c.AssetIDs = models.StringSlice{} + _ = campaignRepo.Update(ctx, c) + } + _, _ = db.NewDelete().TableExpr("assets").Where("id = ?", assetID).Exec(ctx) + }) + + var assetsUsed []campaign_assistant.AssetRef + onEvent := campaign_assistant.OnEventFunc(func(name campaign_assistant.SSEEventKind, data any) { + if name == campaign_assistant.SSEEventAssetsUsed { + if p, ok := data.(campaign_assistant.AssetsUsedEventPayload); ok { + assetsUsed = p.Assets + } + } + }) + + resp, err := callback(ctx, campaign_assistant.CampaignAssistantRequest{ + CampaignID: campaignID, + Instruction: "Generate a content plan for this campaign.", + }, onEvent) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Action).To(Equal("content_plan_generated")) + + // Auto-use: the assistant enabled + persisted asset generation. + updated, err := campaignRepo.GetByID(ctx, campaignID) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.UseAssets).To(BeTrue(), "assistant should persist UseAssets when the campaign has attached assets") + + // Provenance: the assets_used event names the attached asset. + Expect(assetsUsed).To(ContainElement(campaign_assistant.AssetRef{ID: assetID, Title: assetTitle})) + + // Per-post grounding (CON-118): a post records only the assets the + // model drew on for it, never an id outside the retrieved set — and + // since the single attached asset is highly relevant to this campaign, + // at least one post should cite it. + posts, err := postRepo.ListByCampaign(ctx, campaignID) + Expect(err).NotTo(HaveOccurred()) + Expect(posts).NotTo(BeEmpty()) + cited := false + for _, p := range posts { + for _, id := range p.UsedAssetIDs { + Expect(id).To(Equal(assetID), "a post cited an asset outside the retrieved set") + cited = true + } + } + Expect(cited).To(BeTrue(), "at least one post should cite the attached asset") + }) + + It("handles an asset question without failing the turn", func() { + // No embedder is wired in the harness, so askCampaignAssets degrades to + // unavailable — the turn must still complete cleanly (CON-118 §9). + resp, err := callback(ctx, campaign_assistant.CampaignAssistantRequest{ + CampaignID: campaignID, + Instruction: "What do the attached assets say about concurrency benchmarks?", + }, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Explanation).NotTo(BeEmpty()) + }) + }) }) diff --git a/src/server/campaign_assistant.go b/src/server/campaign_assistant.go index c5d112a2..1eed836e 100644 --- a/src/server/campaign_assistant.go +++ b/src/server/campaign_assistant.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" "github.com/ogen-app/ogen/src/campaign_actions/overview" @@ -27,6 +28,7 @@ func initCampaignAssistant( provider *llm.Provider, recorder *usage.Recorder, checker *usage.Checker, + embedder ai.Embedder, hub eventhub.Hub, repos campaign_assistant.CampaignAssistantRepos, contentPlanFn func(ctx context.Context, campaignID string, onEvent content_plan.OnEventFunc) (*content_plan.ContentPlanResponse, error), @@ -40,6 +42,7 @@ func initCampaignAssistant( Provider: provider, Recorder: recorder, Checker: checker, + Embedder: embedder, ModelID: cfg.PlanningModelID, // Router slimming (CON-112 perf): the planner only emits a short JSON // envelope (explanation + action) plus tool calls, so 2048 is ample and diff --git a/src/server/genkit_runtime.go b/src/server/genkit_runtime.go index 7968e2e7..af44a997 100644 --- a/src/server/genkit_runtime.go +++ b/src/server/genkit_runtime.go @@ -308,7 +308,7 @@ func (r *genkitRuntime) rebuild(ctx context.Context, store secrets.Store) error // CON-112: the campaign assistant reuses the content_plan + enrich_brief // callbacks (plus the CON-114 generatePosts and CON-116 consistency // callbacks) as tools, so it is initialised after them. - campaignAssistantFn, err := initCampaignAssistant(g, r.cfg, provider, r.recorder, r.checker, r.hub, r.campaignAssistRepos, contentPlanFn, enrichBriefFn, r.campaignOverviewSvc, generatePostsFn, checkBriefFn, checkPostsFn) + campaignAssistantFn, err := initCampaignAssistant(g, r.cfg, provider, r.recorder, r.checker, r.embedder, r.hub, r.campaignAssistRepos, contentPlanFn, enrichBriefFn, r.campaignOverviewSvc, generatePostsFn, checkBriefFn, checkPostsFn) if err != nil { return fmt.Errorf("init campaign assistant: %w", err) } diff --git a/src/server/server.go b/src/server/server.go index 9035c398..2019ba54 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -389,6 +389,8 @@ func New(ctx context.Context, db, analyticsDB *bun.DB, cfg *config.Config, secre Messages: campaignMessageRepo, Campaigns: campaignRepo, Posts: postRepo, + Assets: pieceRepo, + Chunks: chunksRepo, }, campaignOverviewSvc: campaignOverviewSvc, cloneSvc: cloneSvc,