Insights — Accepted Learnings
Status: [To Build]
When a human reviews a generated insight, they can accept individual items (strengths,
weaknesses, opportunities, recommendations) and optionally add a comment. Accepted items are
stored in the tenant’s channel_insights RAG dataset and become available to all other agents
as verified institutional knowledge about the client’s marketing performance.
Why This Matters
Without this loop, every insight run starts from scratch. The same observations get regenerated week after week with no cumulative memory. With accepted learnings:
- The strategy writer knows “the client’s GSC branded CTR has historically been strong — focus non-branded terms” because a human verified it last quarter.
- The report writer can cite prior accepted observations and track whether they’ve improved.
- Future insight agents pre-load past accepted insights and build on them rather than repeating them — the analysis deepens each cycle rather than resetting.
The Acceptance Flow
Insight generated → status: "done"
│
▼
Human reviews insight items in the dashboard (Screen I2 or I3)
│
├─ Accepts an item (optionally adds a comment)
│ │
│ ▼
│ POST /tenant/v1/insights/:insightId/accept
│ { sectionKey, itemIndex, comment? }
│ │
│ ▼
│ ChannelInsightAcceptance record created
│ │
│ ▼
│ RAG ingestion job enqueued → queue: 'rag:ingestion'
│ { type: 'content', content: <formatted text>, tenantId, datasetId: channel_insights }
│ │
│ ▼
│ Item indexed in Qdrant → available to all agents
│
└─ Dismisses (or ignores) an item → no action takenAcceptance is per item, not per insight report. A report with 12 items might have 4 accepted and 8 dismissed. Each accepted item becomes a separate RAG chunk.
Database Schema
New model: ChannelInsightAcceptance
Add to packages/db/prisma/schema.prisma:
model ChannelInsightAcceptance {
id String @id @default(cuid())
tenantId String
channelInsightId String
connectedChannelId String
channelType String
sectionKey String
// "strengths" | "weaknesses" | "opportunities" | "recommendations"
itemIndex Int // Index of the item in the section array
title String // Copied from the insight item at acceptance time
detail String @db.Text // Copied from the insight item at acceptance time
priority String? // Only set for "recommendations": "high" | "medium" | "low"
period String // Period of the parent ChannelInsight, e.g. "last_6_months"
reviewerComment String? @db.Text // Optional note from the human reviewer
acceptedByUserId String // The user who accepted the item
ragFileId String? // Set after RAG ingestion completes — links to rag_files.id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
channelInsight ChannelInsight @relation(fields: [channelInsightId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([channelInsightId])
@@index([tenantId, channelType])
@@map("channel_insight_acceptance")
}Also add the reverse relation to ChannelInsight:
// Inside ChannelInsight model:
acceptances ChannelInsightAcceptance[]RAG Dataset: channel_insights
A fifth standard dataset added to every tenant’s RAG setup alongside the existing four.
Created automatically when the tenant is provisioned (same as client_docs, etc.).
// Added to STANDARD_DATASETS in packages/feature-knowledge/src/knowledge.types.ts
{
name: 'channel_insights',
description: 'Accepted insight observations from channel analysis — human-verified performance learnings',
allowedAgentRoles: [
// All insight agents — so they build on prior accepted observations
'gsc-insights', 'ga-insights', 'google-ads-insights',
'meta-ads-insights', 'facebook-insights', 'instagram-insights',
'linkedin-insights', 'gbp-insights',
// Content and strategy agents that benefit from knowing what's working
'strategy-writer', 'report-writer', 'ads-analyst',
'activity-planner', 'blog-writer',
// Legacy role names
'strategy', 'data_analyst',
],
useLocalEmbedding: false,
}Ingested Text Format
Each accepted item is ingested as a single type: 'content' RAG job. The text passed to the
ingestion pipeline is formatted as:
[CHANNEL INSIGHT — {channelType} — {month} {year}]
Section: {sectionKey} | Period: {period}
{priority badge if recommendations: "Priority: HIGH | "}Accepted by: {userName}
{title}
{detail}
{if reviewerComment: "Reviewer note: {reviewerComment}"}Example:
[CHANNEL INSIGHT — Google Search Console — Apr 2026]
Section: opportunities | Period: last_6_months
Accepted by: Sarah K.
12 keywords sitting in positions 4–10
These are within 1–2 optimisation cycles of page 1. Combined they represent ~800 additional
clicks/month if moved up. Top targets: "emergency plumber sydney" (pos 7, 4,200 impressions),
"plumber north sydney" (pos 8, 1,100 impressions), "blocked drain sydney" (pos 6, 890 impressions).
Reviewer note: Focus on the 3 plumbing service terms first — highest commercial intent.This format is designed to be retrieved meaningfully. An agent querying
"what GSC opportunities have been identified for this client?" will surface this chunk with
high recall because the channel type, section category, and key metric values are embedded
in the text.
API Endpoint
Accept an insight item
POST /tenant/v1/insights/:insightId/acceptRequest body:
{
sectionKey: "strengths" | "weaknesses" | "opportunities" | "recommendations";
itemIndex: number; // Index in the section array (0-based)
comment?: string; // Optional reviewer note
}Behaviour:
- Load
ChannelInsight— validate tenant ownership andstatus === "done"(can’t accept items from a pending/failed insight) - Extract the item at
insights.sections[sectionKey][itemIndex]— return 404 if out of bounds - Check for duplicate: if a
ChannelInsightAcceptancealready exists for thischannelInsightId+sectionKey+itemIndex, return the existing record (idempotent) - Create
ChannelInsightAcceptancerecord - Enqueue
rag:ingestionjob:{ type: 'content', content: <formatted text block>, fileId: acceptanceRecord.id, // used as the logical file ID in rag_files tenantId, datasetId: <channel_insights dataset id>, metadata: { source: 'channel_insight_acceptance', channelType: insight.channelType, sectionKey, acceptedAt: new Date().toISOString(), } } - After ingestion completes, the worker updates
ChannelInsightAcceptance.ragFileId - Return the created
ChannelInsightAcceptancerecord
Response:
{
id: string;
sectionKey: string;
itemIndex: number;
title: string;
ragQueued: true;
}Dismiss (undo accept)
DELETE /tenant/v1/insights/:insightId/accept/:acceptanceIdDeletes the ChannelInsightAcceptance record and removes the associated vectors from Qdrant
(via the existing DELETE /api/knowledge/datasets/:id/files/:fileId flow). This allows a
human to correct a mistaken acceptance.
List accepted items for an insight
GET /tenant/v1/insights/:insightId/acceptancesReturns all ChannelInsightAcceptance records for the insight, useful for rendering the
accepted/not-accepted state in the UI.
How Agents Use Accepted Learnings
Insight agents (pre-load pattern)
Each insight worker pre-loads prior accepted items from the channel_insights dataset for
the same channelType before building the Claude prompt:
// Inside e.g. gsc-insights.worker.ts, after fetching fresh metrics:
const priorLearnings = await searchService.search({
tenantId,
datasetId: channelInsightsDataset.id,
query: `Google Search Console observations ${tenantName}`,
topK: 10,
agentRole: 'gsc-insights',
});Injected in the prompt as a PRIOR ACCEPTED INSIGHTS block:
PRIOR ACCEPTED INSIGHTS (human-verified observations from previous analyses):
─────────────────────────────────────────────────────────────────────────────
[CHANNEL INSIGHT — Google Search Console — Jan 2026]
Section: opportunities | Period: last_6_months
...
[CHANNEL INSIGHT — Google Search Console — Oct 2025]
Section: weaknesses | Period: last_6_months
...
─────────────────────────────────────────────────────────────────────────────
Use these as context. Reference them if they are still relevant (e.g. "This opportunity
was flagged in January — position has now improved from 7 to 5"). Do not repeat them
verbatim as new findings unless supported by the current data.This prevents the insight agent from recycling the same observations every week and lets it track trend progress over time.
Strategy and report writers (standard RAG search)
These agents search the channel_insights dataset via the standard rag_search pre-load
pattern, alongside client_docs and published_content. No special handling needed — the
accepted learnings appear as ranked search results when the agent queries for performance
context.
UI Changes
Insight item cards (Screens I2 and I3)
Each item in the four section groups gains an acceptance control:
┌── ✓ Strong branded keyword performance ─────────────────────────────────┐
│ "acme plumbing" and "acme services" hold positions 1.2 and 1.4 with │
│ CTRs of 18% and 15% — well above the 3% average for branded terms. │
│ │
│ [Add note... ] [Accept Learning ✓] │
└──────────────────────────────────────────────────────────────────────────┘Accepted state:
┌── ✓ Strong branded keyword performance ─────────────────────── [Saved ✓] ┐
│ "acme plumbing" and "acme services" hold positions 1.2 and 1.4 with │
│ CTRs of 18% and 15% — well above the 3% average for branded terms. │
│ │
│ Note: "Continue monitoring — watch for competitor activity on brand" │
│ [Undo] │
└───────────────────────────────────────────────────────────────────────────┘The “Accept Learning” button calls POST /tenant/v1/insights/:insightId/accept.
The “Undo” button calls DELETE /tenant/v1/insights/:insightId/accept/:acceptanceId.
The note field is optional and submitted together with the acceptance (not separately saved).
Insights list page (Screen I1) — card addition
Show a count of accepted learnings on each insight card:
│ ✓ 3 Strengths ✗ 2 Weaknesses │
│ ◆ 4 Opportunities → 5 Recommendations │
│ │
│ 💾 4 learnings saved to knowledge base │Implementation Phase
This feature is Phase 7 in the implementation plan (after Phase 6 prompt tuning).
Tasks
- Add
ChannelInsightAcceptancemodel topackages/db/prisma/schema.prisma - Add
channel_insightstoSTANDARD_DATASETSinpackages/feature-knowledge - Add
POST /acceptandDELETE /accept/:idendpoints toinsights.routes.ts - Add
GET /:insightId/acceptancesendpoint - Update insight workers to pre-load prior accepted items from
channel_insightsand inject them as aPRIOR ACCEPTED INSIGHTSblock in the prompt - Update
strategy-writerandreport-writerworkers to includechannel_insightsin their pre-load dataset list - Add accept/undo UI controls to insight item cards (Screens I2 and I3)
- Add “N learnings saved” count to Screen I1 insight cards
Verify
- Accepting an item creates a
ChannelInsightAcceptancerecord and arag_filesentry withsource: 'channel_insight_acceptance' - The ingested chunk appears in Qdrant with correct tenantId filter
- Re-running an insight agent pre-loads and injects prior accepted items in the prompt
- Undoing an acceptance removes the Qdrant vectors
- Accepting the same item twice is idempotent (returns existing record)
Related Docs
- Architecture —
ChannelInsightmodel, worker pattern - UI Screens — I2 and I3 detail screens
- Implementation Plan — Full phase order
- RAG Integration —
channel_insightsdataset, ingestion pipeline - HITL — Approval Flow — How other approval types work (for contrast)