Просмотр исходного кода

Narrative brief approval gate — two-step content calendar flow

POST /ai/content-brief generates only the strategic brief (theme, pillars,
tone guidance, per-platform notes) as a fast standalone call. CalendarPlan
gains a Generate Brief button that shows the brief for review; Approve &
Generate Posts then calls POST /ai/content-calendar with approvedBrief,
injecting it into the prompt so posts strictly follow the approved brief.
The original single-step Generate Plan button remains for quick flow.
Regenerate Brief re-drafts without clearing the calendar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 недель назад
Родитель
Сommit
83cdd92158
4 измененных файлов с 216 добавлено и 7 удалено
  1. 101 4
      services/gateway/server.js
  2. 5 0
      ui/src/locales/en.ts
  3. 5 0
      ui/src/locales/tr.ts
  4. 105 3
      ui/src/views/CalendarPlan.vue

+ 101 - 4
services/gateway/server.js

@@ -1152,12 +1152,98 @@ app.post('/ai/stream', async (request, reply) => {
 
 // ─── Monthly Content Calendar ─────────────────────────────────────────────────
 
-// POST /ai/content-calendar — generate a monthly content plan with narrative brief + sample posts
+// POST /ai/content-brief — generate a narrative brief only (step 1 of 2-step calendar flow)
 // Body: { accountKey?, platforms[], month? (YYYY-MM) }
-app.post('/ai/content-calendar', async (request, reply) => {
+app.post('/ai/content-brief', async (request, reply) => {
   const { accountKey, platforms = [], month } = request.body || {};
   if (!platforms.length) return reply.code(400).send({ error: 'Select at least one platform' });
 
+  const db = await getDb();
+  const calMonth = month || new Date().toISOString().slice(0, 7);
+  const monthName = new Date(`${calMonth}-01`).toLocaleString('en', { month: 'long', year: 'numeric' });
+
+  const profileKey = accountKey || null;
+  const profile = profileKey
+    ? await db.collection('account_profiles').findOne({ _id: profileKey })
+    : await db.collection('account_profiles').findOne({});
+
+  const contextParts = [];
+  if (profile) {
+    if (profile.businessName)   contextParts.push(`Business: ${profile.businessName}`);
+    if (profile.description)    contextParts.push(`Description: ${profile.description}`);
+    if (profile.industry)       contextParts.push(`Industry: ${profile.industry}`);
+    if (profile.toneOfVoice)    contextParts.push(`Tone: ${profile.toneOfVoice}`);
+    if (profile.targetAudience) contextParts.push(`Audience: ${profile.targetAudience}`);
+    if (profile.keywords)       contextParts.push(`Keywords: ${profile.keywords}`);
+  }
+
+  const platformList = platforms.slice(0, 5).join(', ');
+  const platformNoteKeys = platforms.slice(0, 5).map((p) => `"${p}": "<one-sentence strategy>"`).join(', ');
+
+  const system = 'You are a social media content strategist. Return ONLY a valid JSON object with no markdown or explanation.';
+  const prompt = `${contextParts.length ? contextParts.join('\n') + '\n\n' : ''}Create a brief for the ${monthName} content calendar. Platforms: ${platformList}.
+
+Return this exact JSON:
+{
+  "theme": "<one compelling sentence that defines the month's narrative — the red thread across all content>",
+  "pillars": ["<content pillar 1>", "<content pillar 2>", "<content pillar 3>"],
+  "toneGuidance": "<one sentence describing the voice and energy for this month>",
+  "platformNotes": { ${platformNoteKeys} }
+}
+
+Return ONLY valid JSON.`;
+
+  try {
+    const pconf = await getActiveProviderConfig();
+    const model = pconf.model;
+    let text = '';
+
+    if (pconf.provider === 'ollama') {
+      const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 90000 });
+      text = res.data.response;
+    } else if (pconf.provider === 'openai' || pconf.provider === 'groq') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: `${pconf.provider} API key not configured` });
+      const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
+        model, messages: buildOpenAIMessages(prompt, system), stream: false,
+      }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 90000 });
+      text = res.data.choices[0]?.message?.content || '';
+    } else if (pconf.provider === 'gemini') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: 'Gemini API key not configured' });
+      const res = await axios.post(
+        `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${pconf.apiKey}`,
+        { contents: buildGeminiContents(prompt, system) },
+        { timeout: 90000 },
+      );
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    let brief = null;
+    try {
+      const cleaned = text.replace(/```(?:json)?\s*/gi, '').replace(/```\s*/g, '');
+      const jsonStr = (cleaned.match(/\{[\s\S]*\}/) || ['{}'])[0];
+      brief = JSON.parse(jsonStr);
+      if (typeof brief.theme !== 'string' || !brief.theme) throw new Error('Missing theme');
+      if (!Array.isArray(brief.pillars)) brief.pillars = [];
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid brief format — try again' });
+    }
+
+    log.info({ action: 'content_brief', month: calMonth, platforms: platforms.join(','), outcome: 'success' });
+    return { success: true, brief, monthName, month: calMonth };
+  } catch (err) {
+    log.error({ action: 'content_brief', outcome: 'failure', err: err.message });
+    return reply.code(503).send({ error: `Brief generation failed: ${err.message}` });
+  }
+});
+
+// POST /ai/content-calendar — generate a monthly content plan with narrative brief + sample posts
+// Body: { accountKey?, platforms[], month? (YYYY-MM), approvedBrief? }
+app.post('/ai/content-calendar', async (request, reply) => {
+  const { accountKey, platforms = [], month, approvedBrief } = request.body || {};
+  if (!platforms.length) return reply.code(400).send({ error: 'Select at least one platform' });
+
   const db = await getDb();
   const calMonth = month || new Date().toISOString().slice(0, 7);
   const [year, mon] = calMonth.split('-');
@@ -1190,8 +1276,19 @@ app.post('/ai/content-calendar', async (request, reply) => {
   const platformNoteKeys = activePlatforms.map((p) => `"${p}"`).join(', ');
   const postSchema = `{ "platform": "<one of: ${platformList}>", "week": <1 or 2>, "content": "<ready-to-publish post>", "hashtags": ["<tag>"], "postType": "<educational|promotional|engagement|storytelling>", "suggestedDay": "<day>" }`;
 
+  // If an approved brief was passed, use it directly — only generate posts
+  const briefSection = approvedBrief
+    ? `The content brief has already been approved — do NOT change it. Use this brief exactly:
+Theme: "${approvedBrief.theme}"
+Pillars: ${(approvedBrief.pillars || []).join(', ')}
+Tone: ${approvedBrief.toneGuidance || ''}
+
+Generate posts that faithfully follow this approved brief.`
+    : '';
+
   const prompt = `${brandContext}
 
+${briefSection}
 Create a ${monthName} content calendar for: ${platformList}.
 Generate ${postsPerPlatform} posts per platform (${totalPosts} posts total across weeks 1 and 2).
 
@@ -1206,12 +1303,12 @@ Platform conventions to follow:
 
 Return a single JSON object:
 {
-  "brief": {
+  "brief": ${approvedBrief ? JSON.stringify(approvedBrief) : `{
     "theme": "<one-sentence monthly narrative>",
     "pillars": ["<pillar 1>", "<pillar 2>", "<pillar 3>"],
     "toneGuidance": "<one sentence>",
     "platformNotes": { ${platformNoteKeys.split(', ').map((k) => `${k}: "<strategy>"`).join(', ')} }
-  },
+  }`},
   "posts": [<${totalPosts} post objects using schema: ${postSchema}>]
 }
 

+ 5 - 0
ui/src/locales/en.ts

@@ -115,6 +115,11 @@ export default {
     platforms: 'Platforms',
     generate: 'Generate Plan',
     generating: 'Generating…',
+    generateBrief: 'Generate Brief',
+    generatingBrief: 'Drafting brief…',
+    briefPreviewTitle: 'Narrative Brief — Review & Approve',
+    regenerateBrief: 'Regenerate',
+    approveBrief: 'Approve & Generate Posts',
     briefTitle: 'Narrative Brief',
     theme: 'Monthly theme',
     pillars: 'Content pillars',

+ 5 - 0
ui/src/locales/tr.ts

@@ -115,6 +115,11 @@ export default {
     platforms: 'Platformlar',
     generate: 'Plan Oluştur',
     generating: 'Oluşturuluyor…',
+    generateBrief: 'Özet Oluştur',
+    generatingBrief: 'Özet hazırlanıyor…',
+    briefPreviewTitle: 'Anlatı Özeti — İncele ve Onayla',
+    regenerateBrief: 'Yeniden Oluştur',
+    approveBrief: 'Onayla ve Gönderileri Oluştur',
     briefTitle: 'Anlatı Özeti',
     theme: 'Aylık tema',
     pillars: 'İçerik sütunları',

+ 105 - 3
ui/src/views/CalendarPlan.vue

@@ -33,12 +33,20 @@
             </select>
           </div>
 
-          <!-- Generate button -->
-          <div class="flex items-end">
+          <!-- Step buttons -->
+          <div class="flex items-end gap-2">
+            <button
+              @click="generateBrief"
+              :disabled="briefLoading || loading || !selectedPlatforms.length"
+              class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-sky-700 hover:bg-sky-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
+            >
+              <i class="fa-solid fa-file-lines text-xs" :class="{ 'animate-pulse': briefLoading }"></i>
+              {{ briefLoading ? $t('calendarPlan.generatingBrief') : $t('calendarPlan.generateBrief') }}
+            </button>
             <button
               @click="generate"
               :disabled="loading || !selectedPlatforms.length"
-              class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
+              class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
             >
               <i class="fa-solid fa-calendar-days text-xs" :class="{ 'animate-pulse': loading }"></i>
               {{ loading ? $t('calendarPlan.generating') : $t('calendarPlan.generate') }}
@@ -67,6 +75,55 @@
         </div>
       </div>
 
+      <!-- Brief preview (approval step) -->
+      <div v-if="pendingBrief && !calendar" class="bg-gray-900 border border-sky-800/50 rounded-2xl p-6 mb-6">
+        <div class="flex items-center justify-between mb-4">
+          <div class="flex items-center gap-2">
+            <i class="fa-solid fa-file-lines text-sky-400 text-sm"></i>
+            <h2 class="font-semibold text-white">{{ $t('calendarPlan.briefPreviewTitle') }}</h2>
+            <span class="text-xs text-gray-500">{{ pendingBriefMonthName }}</span>
+          </div>
+          <div class="flex gap-2">
+            <button
+              @click="generateBrief"
+              :disabled="briefLoading"
+              class="flex items-center gap-1.5 text-xs px-3 py-2 bg-gray-700 hover:bg-gray-600 disabled:opacity-40 rounded-lg text-gray-200 transition-colors"
+            >
+              <i class="fa-solid fa-rotate-right text-[10px]" :class="{ 'animate-spin': briefLoading }"></i>
+              {{ $t('calendarPlan.regenerateBrief') }}
+            </button>
+            <button
+              @click="approveAndGenerate"
+              :disabled="loading"
+              class="flex items-center gap-1.5 text-sm px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded-lg text-white font-medium transition-colors"
+            >
+              <i class="fa-solid fa-check text-xs" :class="{ 'animate-pulse': loading }"></i>
+              {{ loading ? $t('calendarPlan.generating') : $t('calendarPlan.approveBrief') }}
+            </button>
+          </div>
+        </div>
+
+        <div class="p-4 bg-sky-950/40 border border-sky-800/30 rounded-xl mb-4">
+          <div class="text-xs text-sky-400 font-medium mb-1">{{ $t('calendarPlan.theme') }}</div>
+          <p class="text-sm text-gray-200">{{ pendingBrief.theme }}</p>
+        </div>
+
+        <div class="grid grid-cols-2 gap-3">
+          <div v-if="pendingBrief.pillars?.length">
+            <div class="text-xs text-gray-400 mb-2">{{ $t('calendarPlan.pillars') }}</div>
+            <ul class="space-y-1">
+              <li v-for="p in pendingBrief.pillars" :key="p" class="flex gap-1.5 text-xs text-gray-300">
+                <span class="text-sky-400">▪</span>{{ p }}
+              </li>
+            </ul>
+          </div>
+          <div v-if="pendingBrief.toneGuidance">
+            <div class="text-xs text-gray-400 mb-2">{{ $t('calendarPlan.toneGuidance') }}</div>
+            <p class="text-xs text-gray-300 italic">{{ pendingBrief.toneGuidance }}</p>
+          </div>
+        </div>
+      </div>
+
       <!-- Error -->
       <div v-if="error" class="mb-6 p-3 bg-red-900/40 border border-red-700 rounded-xl text-red-300 text-sm">
         {{ error }}
@@ -212,6 +269,10 @@ const savingAll  = ref(false)
 const error      = ref('')
 const calendar   = ref<any>(null)
 
+const briefLoading       = ref(false)
+const pendingBrief       = ref<any>(null)
+const pendingBriefMonthName = ref('')
+
 interface ProfileAccount { key: string; label: string }
 const connectedAccounts = computed((): ProfileAccount[] => {
   const list: ProfileAccount[] = []
@@ -241,10 +302,51 @@ function postsForPlatform(platform: string) {
   return (calendar.value?.posts ?? []).filter((p: any) => p.platform === platform)
 }
 
+async function generateBrief() {
+  if (!selectedPlatforms.value.length) return
+  briefLoading.value = true
+  error.value = ''
+  calendar.value = null
+  try {
+    const res = await axios.post('/api/ai/content-brief', {
+      accountKey: selectedAccount.value || undefined,
+      platforms: selectedPlatforms.value,
+      month: selectedMonth.value,
+    })
+    pendingBrief.value = res.data.brief
+    pendingBriefMonthName.value = res.data.monthName
+  } catch (err: any) {
+    error.value = err.response?.data?.error || 'Brief generation failed'
+  } finally {
+    briefLoading.value = false
+  }
+}
+
+async function approveAndGenerate() {
+  if (!pendingBrief.value) return
+  loading.value = true
+  error.value = ''
+  try {
+    const res = await axios.post('/api/ai/content-calendar', {
+      accountKey: selectedAccount.value || undefined,
+      platforms: selectedPlatforms.value,
+      month: selectedMonth.value,
+      approvedBrief: pendingBrief.value,
+    })
+    calendar.value = res.data
+    pendingBrief.value = null
+  } catch (err: any) {
+    error.value = err.response?.data?.error || 'Calendar generation failed'
+  } finally {
+    loading.value = false
+  }
+}
+
 async function generate() {
   if (!selectedPlatforms.value.length) return
   loading.value = true
   error.value = ''
+  pendingBrief.value = null
   try {
     const res = await axios.post('/api/ai/content-calendar', {
       accountKey: selectedAccount.value || undefined,