瀏覽代碼

Social channel audit — per-account quality scoring across 6 dimensions

POST /ai/channel-audit scores profile completeness, posting consistency,
visual content, CTA usage, hashtag strategy, and engagement quality (0-10
each). Analytics view gains a sky-blue Channel Audit button and a results
card with per-section score bars, inline recommendations, and a ranked
top-actions list. Works with or without an account filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 周之前
父節點
當前提交
c254418455
共有 4 個文件被更改,包括 274 次插入0 次删除
  1. 160 0
      services/gateway/server.js
  2. 10 0
      ui/src/locales/en.ts
  3. 10 0
      ui/src/locales/tr.ts
  4. 94 0
      ui/src/views/Analytics.vue

+ 160 - 0
services/gateway/server.js

@@ -3666,4 +3666,164 @@ No explanation, no markdown.`;
   }
 });
 
+// ─── Social Channel Audit ────────────────────────────────────────────────────
+
+app.post('/ai/channel-audit', async (request, reply) => {
+  const { accountKey } = request.body || {};
+  const db = await getDb();
+
+  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+
+  // Load profile (optional — enriches completeness & bio quality sections)
+  let profile = null;
+  if (accountKey) {
+    profile = await db.collection('account_profiles').findOne({ _id: accountKey });
+  }
+
+  // Load recent posts, optionally filtered by account
+  const postFilter = accountKey
+    ? { publishedAt: { $gte: thirtyDaysAgo }, 'destinations.key': accountKey }
+    : { publishedAt: { $gte: thirtyDaysAgo } };
+  const recentPosts = await db.collection('posts')
+    .find(postFilter, { projection: { content: 1, destinations: 1, publishedAt: 1, status: 1 } })
+    .toArray();
+
+  if (recentPosts.length < 2) {
+    return reply.code(400).send({ error: 'Not enough publishing history. Publish at least 2 posts first.' });
+  }
+
+  // ── Local stats computation ──────────────────────────────────────────────
+  const totalPosts = recentPosts.length;
+
+  // Format diversity: posts with imageUrl/videoUrl in any destination
+  const postsWithMedia = recentPosts.filter((p) =>
+    (p.destinations || []).some((d) => d.imageUrl || d.videoUrl),
+  ).length;
+  const mediaRatio = Math.round((postsWithMedia / totalPosts) * 100);
+
+  // CTA detection
+  const ctaWords = ['click', 'link in bio', 'shop', 'buy', 'register', 'sign up', 'subscribe', 'follow', 'comment', 'share', 'tag', 'dm'];
+  const postsWithCta = recentPosts.filter((p) =>
+    ctaWords.some((w) => (p.content || '').toLowerCase().includes(w)),
+  ).length;
+  const ctaRatio = Math.round((postsWithCta / totalPosts) * 100);
+
+  // Hashtag stats
+  const hashtagCounts = recentPosts.map((p) => ((p.content || '').match(/#\w+/g) || []).length);
+  const avgHashtags = Math.round((hashtagCounts.reduce((s, c) => s + c, 0) / totalPosts) * 10) / 10;
+
+  // Caption length
+  const avgCaptionLength = Math.round(recentPosts.reduce((s, p) => s + (p.content || '').length, 0) / totalPosts);
+
+  // Posting consistency: days between posts
+  const dates = recentPosts.map((p) => new Date(p.publishedAt).getTime()).sort((a, b) => a - b);
+  const gaps = dates.slice(1).map((d, i) => (d - dates[i]) / (1000 * 60 * 60 * 24));
+  const avgGap = gaps.length > 0 ? Math.round(gaps.reduce((s, g) => s + g, 0) / gaps.length * 10) / 10 : 30;
+  const maxGap = gaps.length > 0 ? Math.round(Math.max(...gaps) * 10) / 10 : 30;
+
+  // Engagement from post_metrics
+  const metricsFilter = accountKey
+    ? { accountKey }
+    : {};
+  const metrics = await db.collection('post_metrics')
+    .find({ ...metricsFilter, createdAt: { $gte: thirtyDaysAgo } })
+    .toArray();
+  const avgEngagement = metrics.length > 0
+    ? Math.round((metrics.reduce((s, m) => s + (m.metrics?.engagementTotal || 0), 0) / metrics.length) * 10) / 10
+    : null;
+
+  // Profile completeness score (0-100)
+  let profileScore = 0;
+  let profileNote = 'No profile configured for this account.';
+  if (profile) {
+    const fields = [profile.businessName, profile.description, profile.industry, profile.toneOfVoice, profile.targetAudience, profile.keywords, profile.websiteUrl];
+    const filled = fields.filter((f) => f && (Array.isArray(f) ? f.length > 0 : String(f).trim().length > 0)).length;
+    profileScore = Math.round((filled / fields.length) * 100);
+    profileNote = `${filled}/${fields.length} profile fields completed. Business name: ${profile.businessName || 'missing'}, industry: ${profile.industry || 'missing'}.`;
+  }
+
+  const statsBlock = [
+    `Total posts (last 30 days): ${totalPosts}`,
+    `Posts with media (images/video): ${mediaRatio}%`,
+    `Posts with a CTA (click, shop, follow, etc.): ${ctaRatio}%`,
+    `Average hashtags per post: ${avgHashtags}`,
+    `Average caption length: ${avgCaptionLength} characters`,
+    `Average days between posts: ${avgGap} (max gap: ${maxGap} days)`,
+    `Average engagement per post: ${avgEngagement !== null ? avgEngagement : 'no data'}`,
+    `Account profile completeness: ${profileScore}% — ${profileNote}`,
+    profile?.description ? `Bio/description: "${profile.description.slice(0, 200)}"` : 'No bio/description set.',
+    profile?.toneOfVoice ? `Brand tone: ${profile.toneOfVoice}` : '',
+  ].filter(Boolean).join('\n');
+
+  const system = 'You are a professional social media channel auditor. Return only valid JSON with no markdown or explanation.';
+  const prompt = `Audit this social media channel and return a quality score report.
+
+${statsBlock}
+
+Score each dimension 0-10 and return this exact JSON:
+{
+  "score": <overall 0-100>,
+  "sections": [
+    { "name": "Profile Completeness", "score": <0-10>, "assessment": "<one sentence>", "recommendations": ["<action>", "<action>"] },
+    { "name": "Posting Consistency", "score": <0-10>, "assessment": "<one sentence>", "recommendations": ["<action>", "<action>"] },
+    { "name": "Visual Content", "score": <0-10>, "assessment": "<one sentence>", "recommendations": ["<action>", "<action>"] },
+    { "name": "CTA Usage", "score": <0-10>, "assessment": "<one sentence>", "recommendations": ["<action>", "<action>"] },
+    { "name": "Hashtag Strategy", "score": <0-10>, "assessment": "<one sentence>", "recommendations": ["<action>", "<action>"] },
+    { "name": "Engagement Quality", "score": <0-10>, "assessment": "<one sentence>", "recommendations": ["<action>", "<action>"] }
+  ],
+  "topActions": ["<highest-impact action 1>", "<highest-impact action 2>", "<highest-impact action 3>"]
+}
+
+Scoring benchmarks:
+- Profile Completeness: 100% filled = 10, 70%+ = 7, 50%+ = 5, below = 2
+- Posting Consistency: daily = 10, every 2-3 days = 7, weekly = 5, less = 2; long gaps hurt the score
+- Visual Content: 80%+ posts with media = 10, 50%+ = 7, 25%+ = 4, 0% = 1
+- CTA Usage: 60%+ posts with CTA = 10, 40%+ = 7, 20%+ = 4, none = 2
+- Hashtag Strategy: 3-10 hashtags avg = 10, 1-2 or 11-20 = 7, 0 or 21+ = 3
+- Engagement Quality: avg engagement >5 = 10, 3-5 = 7, 1-3 = 5, <1 = 2, no data = 5 (neutral)
+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: 120000 });
+      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: 120000 });
+      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: 120000 },
+      );
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    const cleaned = text.replace(/```(?:json)?\s*/gi, '').replace(/```\s*/g, '');
+    let result = null;
+    try {
+      const jsonStr = (cleaned.match(/\{[\s\S]*\}/) || ['{}'])[0];
+      result = JSON.parse(jsonStr);
+      if (!Array.isArray(result.sections)) throw new Error();
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid channel audit format — try again' });
+    }
+
+    log.info({ action: 'channel_audit', account: accountKey || 'all', outcome: 'success' });
+    return { success: true, ...result, generatedAt: new Date() };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Channel audit failed', detail: err.message });
+  }
+});
+
 module.exports = app;

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

@@ -70,6 +70,16 @@ export default {
     auditStats: '{count} posts · {freq}x/week · {rate}% success rate',
     auditError: 'Audit failed — publish at least 3 posts first, then try again.',
 
+    runChannelAudit: 'Channel Audit',
+    runningChannelAudit: 'Auditing…',
+    channelAuditTitle: 'Social Channel Audit',
+    channelAuditScore: 'Channel Score',
+    channelAuditDismiss: 'Dismiss',
+    channelAuditTopActions: 'Top Actions',
+    channelAuditRecommendations: 'Recommendations',
+    channelAuditError: 'Channel audit failed — publish at least 2 posts first, then try again.',
+    channelAuditGeneratedAt: 'Generated',
+
     insightsTitle: 'Advanced Insights',
     insightsSubtitle: 'Engagement metrics from connected platforms',
     insightsEmpty: 'No engagement data yet.',

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

@@ -70,6 +70,16 @@ export default {
     auditStats: '{count} gönderi · haftada {freq}x · %{rate} başarı',
     auditError: 'Denetim başarısız — önce en az 3 gönderi yayınlayın.',
 
+    runChannelAudit: 'Kanal Denetimi',
+    runningChannelAudit: 'Denetleniyor…',
+    channelAuditTitle: 'Sosyal Kanal Denetimi',
+    channelAuditScore: 'Kanal Puanı',
+    channelAuditDismiss: 'Kapat',
+    channelAuditTopActions: 'Öncelikli Eylemler',
+    channelAuditRecommendations: 'Öneriler',
+    channelAuditError: 'Kanal denetimi başarısız — önce en az 2 gönderi yayınlayın.',
+    channelAuditGeneratedAt: 'Oluşturuldu',
+
     insightsTitle: 'Gelişmiş İçgörüler',
     insightsSubtitle: 'Bağlı platformlardan etkileşim metrikleri',
     insightsEmpty: 'Henüz etkileşim verisi yok.',

+ 94 - 0
ui/src/views/Analytics.vue

@@ -17,6 +17,14 @@
             <i class="fa-solid fa-clipboard-check text-xs" :class="{ 'animate-pulse': auditLoading }"></i>
             {{ auditLoading ? $t('analytics.runningAudit') : $t('analytics.runAudit') }}
           </button>
+          <button
+            @click="runChannelAudit"
+            :disabled="channelAuditLoading"
+            class="flex items-center gap-2 px-4 py-2 bg-sky-800 hover:bg-sky-700 border border-sky-700 disabled:opacity-40 rounded-xl text-sm font-medium transition-colors"
+          >
+            <i class="fa-solid fa-chart-bar text-xs" :class="{ 'animate-pulse': channelAuditLoading }"></i>
+            {{ channelAuditLoading ? $t('analytics.runningChannelAudit') : $t('analytics.runChannelAudit') }}
+          </button>
           <button
             @click="exportCsv"
             class="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-xl text-sm font-medium transition-colors"
@@ -129,6 +137,63 @@
         </div>
       </div>
 
+      <!-- Channel audit error -->
+      <div v-if="channelAuditError" class="mb-6 p-3 bg-red-900/40 border border-red-700 rounded-xl text-red-300 text-sm flex items-center justify-between gap-3">
+        {{ $t('analytics.channelAuditError') }}
+        <button @click="channelAuditError = false" class="text-red-400 hover:text-red-200 shrink-0">✕</button>
+      </div>
+
+      <!-- Channel audit results card -->
+      <div v-if="channelAudit" class="mb-8 bg-gray-900 border border-sky-800/50 rounded-2xl overflow-hidden">
+        <!-- Header -->
+        <div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between gap-4">
+          <div class="flex items-center gap-3">
+            <i class="fa-solid fa-chart-bar text-sky-400"></i>
+            <span class="font-semibold text-white">{{ $t('analytics.channelAuditTitle') }}</span>
+            <span v-if="selectedAccount" class="text-xs text-gray-500">{{ selectedAccount }}</span>
+          </div>
+          <div class="flex items-center gap-3 shrink-0">
+            <div class="flex items-center gap-2">
+              <span class="text-xs text-gray-400">{{ $t('analytics.channelAuditScore') }}</span>
+              <span
+                class="text-lg font-bold px-2 py-0.5 rounded-lg"
+                :class="channelAudit.score >= 70 ? 'text-green-300 bg-green-900/40' : channelAudit.score >= 40 ? 'text-amber-300 bg-amber-900/40' : 'text-red-300 bg-red-900/40'"
+              >{{ channelAudit.score }}/100</span>
+            </div>
+            <button @click="channelAudit = null" class="text-gray-500 hover:text-gray-300 text-sm">{{ $t('analytics.channelAuditDismiss') }}</button>
+          </div>
+        </div>
+
+        <!-- Section grid -->
+        <div class="grid grid-cols-2 lg:grid-cols-3 divide-x divide-y divide-gray-800 border-b border-gray-800">
+          <div v-for="section in channelAudit.sections" :key="section.name" class="px-5 py-4">
+            <div class="flex items-center justify-between mb-1">
+              <span class="text-xs text-gray-400 font-medium">{{ section.name }}</span>
+              <span class="text-sm font-semibold" :class="scoreColor(section.score)">{{ section.score }}/10</span>
+            </div>
+            <div class="w-full h-1.5 bg-gray-800 rounded-full mb-2">
+              <div class="h-1.5 rounded-full transition-all" :class="scoreBarColor(section.score)" :style="{ width: (section.score * 10) + '%' }"></div>
+            </div>
+            <p class="text-xs text-gray-400 leading-relaxed mb-2">{{ section.assessment }}</p>
+            <ul class="space-y-0.5">
+              <li v-for="(rec, i) in section.recommendations" :key="i" class="flex gap-1.5 text-xs text-gray-500">
+                <span class="text-sky-500 shrink-0">›</span>{{ rec }}
+              </li>
+            </ul>
+          </div>
+        </div>
+
+        <!-- Top actions -->
+        <div class="px-6 py-4">
+          <div class="text-xs font-medium text-gray-400 mb-2">{{ $t('analytics.channelAuditTopActions') }}</div>
+          <ol class="space-y-1.5">
+            <li v-for="(action, i) in channelAudit.topActions" :key="i" class="flex gap-2 text-sm text-gray-200">
+              <span class="text-sky-400 font-semibold shrink-0">{{ i + 1 }}.</span>{{ action }}
+            </li>
+          </ol>
+        </div>
+      </div>
+
       <!-- Loading -->
       <div v-if="loading && !summary" class="flex items-center justify-center h-64 text-gray-500">
         {{ $t('analytics.loading') }}
@@ -593,6 +658,22 @@ const audit        = ref<Audit | null>(null)
 const auditLoading = ref(false)
 const auditError   = ref(false)
 
+interface ChannelAuditSection {
+  name: string
+  score: number
+  assessment: string
+  recommendations: string[]
+}
+interface ChannelAudit {
+  score: number
+  sections: ChannelAuditSection[]
+  topActions: string[]
+  generatedAt: string
+}
+const channelAudit        = ref<ChannelAudit | null>(null)
+const channelAuditLoading = ref(false)
+const channelAuditError   = ref(false)
+
 // ── Data loading ──────────────────────────────────────────────────────────────
 
 function accountParams(extra: Record<string, unknown> = {}) {
@@ -659,6 +740,19 @@ async function runAudit() {
   }
 }
 
+async function runChannelAudit() {
+  channelAuditLoading.value = true
+  channelAuditError.value = false
+  try {
+    const res = await axios.post('/api/ai/channel-audit', { accountKey: selectedAccount.value || undefined })
+    channelAudit.value = res.data
+  } catch {
+    channelAuditError.value = true
+  } finally {
+    channelAuditLoading.value = false
+  }
+}
+
 async function crawlMetrics() {
   crawling.value = true
   crawlResult.value = null