Procházet zdrojové kódy

Brand/Account Audit — AI-powered health score for your posting activity

New POST /analytics/audit endpoint gathers the last 30 days of publishing
stats from the posts and post_metrics collections (posting frequency, success
rate, top hashtags, peak hours, avg engagement), then calls the active AI
provider to produce a structured audit: overall health score 0-100, three
sub-scores (posting frequency, engagement, content mix), and three specific
actionable recommendations.

UI: "Run Brand Audit" button in the Analytics header triggers the audit and
renders a dismissable results card with score badge, summary paragraph, score
bars for each dimension, and numbered recommendations. Respects the existing
per-account filter so agencies can audit each client account independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris před 3 týdny
rodič
revize
a8a29cdd15
4 změnil soubory, kde provedl 304 přidání a 0 odebrání
  1. 151 0
      services/gateway/server.js
  2. 13 0
      ui/src/locales/en.ts
  3. 13 0
      ui/src/locales/tr.ts
  4. 127 0
      ui/src/views/Analytics.vue

+ 151 - 0
services/gateway/server.js

@@ -2030,6 +2030,157 @@ app.get('/analytics/posts', async (request) => {
   return { posts: normalised, total: schedTotal + immTotal };
   return { posts: normalised, total: schedTotal + immTotal };
 });
 });
 
 
+// ─── Brand / Account Audit ────────────────────────────────────────────────────
+
+app.post('/analytics/audit', async (request, reply) => {
+  const filter = parseAccountFilter(request.query.account);
+  const db = await getDb();
+
+  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+  const sevenDaysAgo  = new Date(Date.now() -  7 * 24 * 60 * 60 * 1000);
+
+  const recentPosts = await db.collection('posts').find({
+    publishedAt: { $gte: thirtyDaysAgo },
+    ...ipFilter(filter),
+  }, { projection: { content: 1, destinations: 1, publishedAt: 1, status: 1 } }).toArray();
+
+  if (recentPosts.length < 3) {
+    return reply.code(400).send({ error: 'Not enough publishing history. Publish at least 3 posts first.' });
+  }
+
+  // Posting frequency
+  const postsLast30 = recentPosts.length;
+  const postsLast7  = recentPosts.filter((p) => new Date(p.publishedAt) >= sevenDaysAgo).length;
+  const postsPerWeek = Math.round((postsLast30 / 4) * 10) / 10;
+
+  // Platforms used
+  const platforms = [...new Set(recentPosts.flatMap((p) => (p.destinations || []).map((d) => d.platform)).filter(Boolean))];
+
+  // Success rate
+  const publishedCount = recentPosts.filter((p) => p.status === 'published').length;
+  const successRate    = Math.round((publishedCount / postsLast30) * 100);
+
+  // Top hashtags from post content
+  const hashtagCounts = {};
+  for (const post of recentPosts) {
+    const re = /#([a-zA-Z]\w*)/g;
+    let m;
+    re.lastIndex = 0;
+    while ((m = re.exec(post.content || '')) !== null) {
+      const tag = `#${m[1].toLowerCase()}`;
+      hashtagCounts[tag] = (hashtagCounts[tag] || 0) + 1;
+    }
+  }
+  const topHashtags = Object.entries(hashtagCounts)
+    .sort((a, b) => b[1] - a[1])
+    .slice(0, 5)
+    .map(([tag, count]) => `${tag} (${count}x)`)
+    .join(', ');
+
+  // Posting hour distribution — identify peak hours
+  const hourCounts = {};
+  for (const post of recentPosts) {
+    if (post.publishedAt) {
+      const h = new Date(post.publishedAt).getUTCHours();
+      hourCounts[h] = (hourCounts[h] || 0) + 1;
+    }
+  }
+  const peakHours = Object.entries(hourCounts)
+    .sort((a, b) => b[1] - a[1])
+    .slice(0, 3)
+    .map(([h]) => `${h}:00 UTC`)
+    .join(', ');
+
+  // Engagement data from post_metrics
+  const metricsFilter = filter
+    ? { platform: filter.platform, ...(filter.accountId && { accountId: filter.accountId }) }
+    : {};
+  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
+    : 0;
+
+  const statsBlock = [
+    `Publishing stats (last 30 days):`,
+    `- Total posts: ${postsLast30}`,
+    `- Posts this week: ${postsLast7}`,
+    `- Posts per week (avg): ${postsPerWeek}`,
+    `- Platforms used: ${platforms.join(', ') || 'unknown'}`,
+    `- Success rate: ${successRate}%`,
+    `- Average engagement per post: ${avgEngagement}`,
+    `- Current peak posting hours (UTC): ${peakHours || 'not enough data'}`,
+    `- Top hashtags in use: ${topHashtags || 'none detected'}`,
+  ].join('\n');
+
+  const system = 'You are a social media performance analyst. Return only valid JSON with no explanation, no markdown code blocks.';
+  const prompt = `Audit this social media account and return a structured report.
+
+${statsBlock}
+
+Return a JSON object with exactly these fields:
+{
+  "score": <overall health score 0-100>,
+  "summary": "<2-3 sentence assessment>",
+  "postingFrequency": { "score": <0-10>, "assessment": "<one sentence>" },
+  "engagement": { "score": <0-10>, "benchmark": "<Excellent|Good|Average|Below Average>", "assessment": "<one sentence>" },
+  "contentMix": { "score": <0-10>, "assessment": "<one sentence on variety and platform fit>" },
+  "recommendations": ["<specific action 1>", "<specific action 2>", "<specific action 3>"]
+}
+
+Scoring benchmarks: posting 5+x/week = 8-10, 3-4x = 6-7, 1-2x = 4-5, less = 1-3.
+Engagement benchmarks: >5 avg = Excellent, 3-5 = Good, 1-3 = Average, <1 = Below Average.
+Return ONLY the JSON object.`;
+
+  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' });
+    }
+
+    let audit = null;
+    try {
+      const jsonStr = (text.match(/\{[\s\S]*\}/) || ['{}'])[0];
+      audit = JSON.parse(jsonStr);
+      if (typeof audit.score !== 'number') throw new Error();
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid audit format — try again' });
+    }
+
+    log.info({ action: 'analytics_audit', account: request.query.account || 'all', outcome: 'success' });
+    return {
+      success: true,
+      ...audit,
+      stats: { postsLast30, postsLast7, postsPerWeek, platforms, successRate, avgEngagement },
+      generatedAt: new Date(),
+    };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Audit failed', detail: err.message });
+  }
+});
+
 // ─── Hashtag Groups ───────────────────────────────────────────────────────────
 // ─── Hashtag Groups ───────────────────────────────────────────────────────────
 
 
 app.get('/hashtag-groups', async () => {
 app.get('/hashtag-groups', async () => {

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

@@ -55,6 +55,19 @@ export default {
     crawling: 'Crawling…',
     crawling: 'Crawling…',
     crawlDone: 'Crawled {count} posts',
     crawlDone: 'Crawled {count} posts',
 
 
+    runAudit: 'Run Brand Audit',
+    runningAudit: 'Auditing…',
+    auditTitle: 'Brand Health Audit',
+    auditScore: 'Health Score',
+    auditGeneratedAt: 'Generated',
+    auditDismiss: 'Dismiss',
+    auditFrequency: 'Posting Frequency',
+    auditEngagement: 'Engagement',
+    auditMix: 'Content Mix',
+    auditRecommendations: 'Recommendations',
+    auditStats: '{count} posts · {freq}x/week · {rate}% success rate',
+    auditError: 'Audit failed — publish at least 3 posts first, then try again.',
+
     insightsTitle: 'Advanced Insights',
     insightsTitle: 'Advanced Insights',
     insightsSubtitle: 'Engagement metrics from connected platforms',
     insightsSubtitle: 'Engagement metrics from connected platforms',
     insightsEmpty: 'No engagement data yet.',
     insightsEmpty: 'No engagement data yet.',

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

@@ -55,6 +55,19 @@ export default {
     crawling: 'Getiriliyor…',
     crawling: 'Getiriliyor…',
     crawlDone: '{count} gönderi getirildi',
     crawlDone: '{count} gönderi getirildi',
 
 
+    runAudit: 'Marka Denetimi Yap',
+    runningAudit: 'Denetleniyor…',
+    auditTitle: 'Marka Sağlık Denetimi',
+    auditScore: 'Sağlık Puanı',
+    auditGeneratedAt: 'Oluşturuldu',
+    auditDismiss: 'Kapat',
+    auditFrequency: 'Yayın Sıklığı',
+    auditEngagement: 'Etkileşim',
+    auditMix: 'İçerik Karması',
+    auditRecommendations: 'Öneriler',
+    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.',
+
     insightsTitle: 'Gelişmiş İçgörüler',
     insightsTitle: 'Gelişmiş İçgörüler',
     insightsSubtitle: 'Bağlı platformlardan etkileşim metrikleri',
     insightsSubtitle: 'Bağlı platformlardan etkileşim metrikleri',
     insightsEmpty: 'Henüz etkileşim verisi yok.',
     insightsEmpty: 'Henüz etkileşim verisi yok.',

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

@@ -9,6 +9,14 @@
           <p class="text-sm text-gray-500 mt-1">{{ $t('analytics.subtitle') }}</p>
           <p class="text-sm text-gray-500 mt-1">{{ $t('analytics.subtitle') }}</p>
         </div>
         </div>
         <div class="flex items-center gap-2">
         <div class="flex items-center gap-2">
+          <button
+            @click="runAudit"
+            :disabled="auditLoading"
+            class="flex items-center gap-2 px-4 py-2 bg-violet-800 hover:bg-violet-700 border border-violet-700 disabled:opacity-40 rounded-xl text-sm font-medium transition-colors"
+          >
+            <i class="fa-solid fa-clipboard-check text-xs" :class="{ 'animate-pulse': auditLoading }"></i>
+            {{ auditLoading ? $t('analytics.runningAudit') : $t('analytics.runAudit') }}
+          </button>
           <button
           <button
             @click="crawlMetrics"
             @click="crawlMetrics"
             :disabled="crawling"
             :disabled="crawling"
@@ -53,6 +61,67 @@
         </button>
         </button>
       </div>
       </div>
 
 
+      <!-- Audit error -->
+      <div v-if="auditError" 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.auditError') }}
+        <button @click="auditError = false" class="text-red-400 hover:text-red-200 shrink-0">✕</button>
+      </div>
+
+      <!-- Audit results card -->
+      <div v-if="audit" class="mb-8 bg-gray-900 border border-violet-800/50 rounded-2xl overflow-hidden">
+        <!-- Card 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">
+            <div class="flex items-center gap-2">
+              <i class="fa-solid fa-clipboard-check text-violet-400"></i>
+              <span class="font-semibold text-white">{{ $t('analytics.auditTitle') }}</span>
+            </div>
+            <span class="text-xs text-gray-500">
+              {{ $t('analytics.auditStats', { count: audit.stats.postsLast30, freq: audit.stats.postsPerWeek, rate: audit.stats.successRate }) }}
+            </span>
+          </div>
+          <div class="flex items-center gap-3 shrink-0">
+            <!-- Score badge -->
+            <div class="flex items-center gap-2">
+              <span class="text-xs text-gray-400">{{ $t('analytics.auditScore') }}</span>
+              <span
+                class="text-lg font-bold px-2 py-0.5 rounded-lg"
+                :class="audit.score >= 70 ? 'text-green-300 bg-green-900/40' : audit.score >= 40 ? 'text-amber-300 bg-amber-900/40' : 'text-red-300 bg-red-900/40'"
+              >{{ audit.score }}/100</span>
+            </div>
+            <button @click="audit = null" class="text-gray-500 hover:text-gray-300 text-sm">{{ $t('analytics.auditDismiss') }}</button>
+          </div>
+        </div>
+
+        <!-- Summary -->
+        <div class="px-6 py-4 border-b border-gray-800 text-sm text-gray-300">{{ audit.summary }}</div>
+
+        <!-- Score breakdown -->
+        <div class="grid grid-cols-3 divide-x divide-gray-800 border-b border-gray-800">
+          <div v-for="section in auditSections" :key="section.key" class="px-5 py-4">
+            <div class="flex items-center justify-between mb-1">
+              <span class="text-xs text-gray-400">{{ section.label }}</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">{{ section.assessment }}</p>
+            <p v-if="section.benchmark" class="text-xs mt-0.5" :class="benchmarkColor(section.benchmark)">{{ section.benchmark }}</p>
+          </div>
+        </div>
+
+        <!-- Recommendations -->
+        <div class="px-6 py-4">
+          <div class="text-xs font-medium text-gray-400 mb-2">{{ $t('analytics.auditRecommendations') }}</div>
+          <ol class="space-y-1.5">
+            <li v-for="(rec, i) in audit.recommendations" :key="i" class="flex gap-2 text-sm text-gray-200">
+              <span class="text-violet-400 font-semibold shrink-0">{{ i + 1 }}.</span>{{ rec }}
+            </li>
+          </ol>
+        </div>
+      </div>
+
       <!-- Loading -->
       <!-- Loading -->
       <div v-if="loading && !summary" class="flex items-center justify-center h-64 text-gray-500">
       <div v-if="loading && !summary" class="flex items-center justify-center h-64 text-gray-500">
         {{ $t('analytics.loading') }}
         {{ $t('analytics.loading') }}
@@ -431,9 +500,12 @@
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import { ref, computed, watch, onMounted } from 'vue'
 import { ref, computed, watch, onMounted } from 'vue'
+import { useI18n } from 'vue-i18n'
 import axios from 'axios'
 import axios from 'axios'
 import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
 import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
 
 
+const { t } = useI18n()
+
 // ── Types ─────────────────────────────────────────────────────────────────────
 // ── Types ─────────────────────────────────────────────────────────────────────
 
 
 interface DayStat { date: string; count: number }
 interface DayStat { date: string; count: number }
@@ -500,6 +572,20 @@ const insights        = ref<Insights | null>(null)
 const crawling        = ref(false)
 const crawling        = ref(false)
 const crawlResult     = ref<number | null>(null)
 const crawlResult     = ref<number | null>(null)
 
 
+interface AuditSection { score: number; assessment: string; benchmark?: string }
+interface Audit {
+  score: number
+  summary: string
+  postingFrequency: AuditSection
+  engagement: AuditSection & { benchmark: string }
+  contentMix: AuditSection
+  recommendations: string[]
+  stats: { postsLast30: number; postsLast7: number; postsPerWeek: number; platforms: string[]; successRate: number; avgEngagement: number }
+}
+const audit        = ref<Audit | null>(null)
+const auditLoading = ref(false)
+const auditError   = ref(false)
+
 // ── Data loading ──────────────────────────────────────────────────────────────
 // ── Data loading ──────────────────────────────────────────────────────────────
 
 
 function accountParams(extra: Record<string, unknown> = {}) {
 function accountParams(extra: Record<string, unknown> = {}) {
@@ -541,6 +627,19 @@ async function loadInsights() {
   }
   }
 }
 }
 
 
+async function runAudit() {
+  auditLoading.value = true
+  auditError.value = false
+  try {
+    const res = await axios.post('/api/analytics/audit', {}, { params: accountParams() })
+    audit.value = res.data
+  } catch {
+    auditError.value = true
+  } finally {
+    auditLoading.value = false
+  }
+}
+
 async function crawlMetrics() {
 async function crawlMetrics() {
   crawling.value = true
   crawling.value = true
   crawlResult.value = null
   crawlResult.value = null
@@ -628,6 +727,34 @@ function statusWidth(count: number): number {
   return summary.value?.total ? (count / summary.value.total) * 100 : 0
   return summary.value?.total ? (count / summary.value.total) * 100 : 0
 }
 }
 
 
+const auditSections = computed(() => {
+  if (!audit.value) return []
+  return [
+    { key: 'frequency', label: t('analytics.auditFrequency'), score: audit.value.postingFrequency.score, assessment: audit.value.postingFrequency.assessment },
+    { key: 'engagement', label: t('analytics.auditEngagement'), score: audit.value.engagement.score, assessment: audit.value.engagement.assessment, benchmark: audit.value.engagement.benchmark },
+    { key: 'mix', label: t('analytics.auditMix'), score: audit.value.contentMix.score, assessment: audit.value.contentMix.assessment },
+  ]
+})
+
+function scoreColor(score: number): string {
+  if (score >= 7) return 'text-green-400'
+  if (score >= 4) return 'text-amber-400'
+  return 'text-red-400'
+}
+
+function scoreBarColor(score: number): string {
+  if (score >= 7) return 'bg-green-500'
+  if (score >= 4) return 'bg-amber-500'
+  return 'bg-red-500'
+}
+
+function benchmarkColor(benchmark: string): string {
+  if (benchmark === 'Excellent') return 'text-green-400'
+  if (benchmark === 'Good') return 'text-blue-400'
+  if (benchmark === 'Average') return 'text-amber-400'
+  return 'text-red-400'
+}
+
 function platformColor(platform: string): string {
 function platformColor(platform: string): string {
   return (PLATFORM_META as Record<string, { color: string }>)[platform]?.color ?? '#6b7280'
   return (PLATFORM_META as Record<string, { color: string }>)[platform]?.color ?? '#6b7280'
 }
 }