Переглянути джерело

Competitor matrix — side-by-side comparison table with AI synthesis

POST /competitors/social-matrix generates a 3–4 sentence competitive
landscape synthesis identifying the positioning white space, the weakest
competitor, and the highest-leverage content angle. Competitors.vue adds
a Comparison Matrix panel (visible when ≥2 competitors have AI analysis)
showing a six-row table (positioning, tone, themes, gaps, moves, keywords)
with one column per competitor. An AI Synthesis button adds the narrative
banner above the table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 тижнів тому
батько
коміт
043eca29e4
4 змінених файлів з 177 додано та 4 видалено
  1. 70 0
      services/gateway/server.js
  2. 4 0
      ui/src/locales/en.ts
  3. 4 0
      ui/src/locales/tr.ts
  4. 99 4
      ui/src/views/Competitors.vue

+ 70 - 0
services/gateway/server.js

@@ -3666,6 +3666,76 @@ No explanation, no markdown.`;
   }
 });
 
+// ─── Competitor Social Matrix ────────────────────────────────────────────────
+
+app.post('/competitors/social-matrix', async (request, reply) => {
+  const db = await getDb();
+  const competitors = await db.collection('competitors')
+    .find({ aiAnalysis: { $exists: true } })
+    .toArray();
+
+  if (competitors.length < 2) {
+    return reply.code(400).send({ error: 'Run "Summarise with AI" on at least 2 competitors first.' });
+  }
+
+  const competitorBlocks = competitors.map((c) => {
+    const a = c.aiAnalysis || {};
+    return [
+      `Competitor: ${c.name}`,
+      `  Positioning: ${a.positioning || 'unknown'}`,
+      `  Tone: ${a.tone || 'unknown'}`,
+      `  Themes: ${(a.themes || []).join(', ') || 'none'}`,
+      `  Weaknesses/Gaps: ${(a.gaps || []).join(', ') || 'none'}`,
+      `  Top keywords: ${(c.keywords || []).slice(0, 5).map((k) => k.term).join(', ') || 'none'}`,
+    ].join('\n');
+  }).join('\n\n');
+
+  const system = 'You are a competitive intelligence analyst. Write in plain text, no markdown or bullet points.';
+  const prompt = `Here are ${competitors.length} competitors you are analysing:
+
+${competitorBlocks}
+
+Write a concise competitive landscape synthesis (3–4 sentences) that:
+1. Identifies the dominant positioning gap none of them own (the white space).
+2. Names the competitor with the weakest differentiation.
+3. States the single highest-leverage content angle for someone competing against all of them.
+
+Write as direct, specific analysis. No fluff.`;
+
+  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' });
+    }
+
+    if (!text.trim()) return reply.code(503).send({ error: 'AI returned empty response — try again' });
+    log.info({ action: 'competitor_matrix', count: competitors.length, outcome: 'success' });
+    return { success: true, synthesis: text.trim(), generatedAt: new Date() };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Matrix synthesis failed', detail: err.message });
+  }
+});
+
 // ─── Industry Type Diagnosis ─────────────────────────────────────────────────
 
 app.post('/ai/industry-diagnosis', async (request, reply) => {

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

@@ -563,6 +563,10 @@ export default {
     intent_transactional: 'Transactional',
     intent_navigational: 'Navigational',
     sideBySideMode: 'Comparing competitors side by side',
+    compareMatrix: 'Comparison Matrix',
+    matrixTitle: 'Competitor Comparison Matrix',
+    matrixSynthesize: 'AI Synthesis',
+    matrixSynthesizing: 'Synthesising…',
     predictionLabel: 'Response Prediction',
     predictionSatisfied: 'Holding position',
     predictionPushing: 'Actively pushing',

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

@@ -563,6 +563,10 @@ export default {
     intent_transactional: 'İşlemsel',
     intent_navigational: 'Yönlendirici',
     sideBySideMode: 'Rakipler yan yana karşılaştırılıyor',
+    compareMatrix: 'Karşılaştırma Matrisi',
+    matrixTitle: 'Rakip Karşılaştırma Matrisi',
+    matrixSynthesize: 'Yapay Zeka Sentezi',
+    matrixSynthesizing: 'Sentezleniyor…',
     predictionLabel: 'Yanıt Tahmini',
     predictionSatisfied: 'Pozisyonunu koruyor',
     predictionPushing: 'Aktif büyüme peşinde',

+ 99 - 4
ui/src/views/Competitors.vue

@@ -9,10 +9,75 @@
       {{ competitorStore.error }}
     </div>
 
-    <!-- Side-by-side label -->
-    <div v-if="competitorStore.competitors.length >= 2" class="mb-3 flex items-center gap-2 text-xs text-gray-500">
-      <i class="fa-solid fa-table-columns"></i>
-      {{ t('competitors.sideBySideMode') }}
+    <!-- Side-by-side label + matrix toggle -->
+    <div v-if="competitorStore.competitors.length >= 2" class="mb-3 flex items-center justify-between gap-2">
+      <div class="flex items-center gap-2 text-xs text-gray-500">
+        <i class="fa-solid fa-table-columns"></i>
+        {{ t('competitors.sideBySideMode') }}
+      </div>
+      <button
+        v-if="analyzedCompetitors.length >= 2"
+        @click="matrixOpen = !matrixOpen"
+        class="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border transition-colors"
+        :class="matrixOpen ? 'bg-violet-900/50 border-violet-700 text-violet-300' : 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-500 hover:text-gray-200'"
+      >
+        <i class="fa-solid fa-table text-[10px]"></i>
+        {{ t('competitors.compareMatrix') }}
+      </button>
+    </div>
+
+    <!-- Competitor comparison matrix -->
+    <div v-if="matrixOpen && analyzedCompetitors.length >= 2" class="mb-6 bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
+      <!-- Matrix header -->
+      <div class="px-5 py-3 border-b border-gray-800 flex items-center justify-between gap-4">
+        <div class="flex items-center gap-2">
+          <i class="fa-solid fa-table text-xs text-violet-400"></i>
+          <span class="text-sm font-semibold text-white">{{ t('competitors.matrixTitle') }}</span>
+        </div>
+        <button
+          @click="generateMatrixSynthesis"
+          :disabled="matrixLoading"
+          class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-violet-800 hover:bg-violet-700 disabled:opacity-40 border border-violet-700 rounded-lg transition-colors text-violet-200"
+        >
+          <i class="fa-solid fa-wand-magic-sparkles text-[10px]" :class="{ 'animate-pulse': matrixLoading }"></i>
+          {{ matrixLoading ? t('competitors.matrixSynthesizing') : t('competitors.matrixSynthesize') }}
+        </button>
+      </div>
+
+      <!-- AI synthesis banner -->
+      <div v-if="matrixSynthesis" class="px-5 py-3 bg-violet-950/40 border-b border-violet-800/30 text-sm text-gray-300 leading-relaxed">
+        <i class="fa-solid fa-lightbulb text-violet-400 mr-2 text-xs"></i>{{ matrixSynthesis }}
+      </div>
+
+      <!-- Comparison table -->
+      <div class="overflow-x-auto">
+        <table class="w-full text-xs">
+          <thead>
+            <tr class="border-b border-gray-800">
+              <th class="text-left px-4 py-2.5 text-gray-500 font-medium w-32 shrink-0">Dimension</th>
+              <th
+                v-for="c in analyzedCompetitors"
+                :key="c._id"
+                class="text-left px-4 py-2.5 text-violet-300 font-semibold"
+              >{{ c.name }}</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr
+              v-for="row in MATRIX_ROWS"
+              :key="row.key"
+              class="border-b border-gray-800/60 hover:bg-gray-800/30 transition-colors"
+            >
+              <td class="px-4 py-2.5 text-gray-500 font-medium align-top shrink-0">{{ row.label }}</td>
+              <td
+                v-for="c in analyzedCompetitors"
+                :key="c._id"
+                class="px-4 py-2.5 text-gray-300 align-top"
+              >{{ row.get(c) }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
     </div>
 
     <!-- Competitor cards — stacked for 1, responsive grid for 2+ -->
@@ -537,6 +602,36 @@ function draftPost(headline: string) {
   router.push('/compose')
 }
 
+// ── Competitor matrix ─────────────────────────────────────────────────────────
+const matrixOpen     = ref(false)
+const matrixLoading  = ref(false)
+const matrixSynthesis = ref<string | null>(null)
+
+const analyzedCompetitors = computed(() =>
+  competitorStore.competitors.filter((c) => c.aiAnalysis),
+)
+
+const MATRIX_ROWS = [
+  { key: 'positioning',  label: 'Positioning',          get: (c: Competitor) => c.aiAnalysis?.positioning || '—' },
+  { key: 'tone',         label: 'Brand Tone',           get: (c: Competitor) => c.aiAnalysis?.tone || '—' },
+  { key: 'themes',       label: 'Content Themes',       get: (c: Competitor) => (c.aiAnalysis?.themes || []).join(', ') || '—' },
+  { key: 'gaps',         label: 'Weaknesses / Gaps',    get: (c: Competitor) => (c.aiAnalysis?.gaps || []).join(', ') || '—' },
+  { key: 'moves',        label: 'Differentiation Moves',get: (c: Competitor) => (c.aiAnalysis?.moves || []).join(', ') || '—' },
+  { key: 'keywords',     label: 'Top Keywords',         get: (c: Competitor) => ((c.keywords || []).slice(0, 5).map((k: any) => k.term).join(', ') || '—') },
+] as const
+
+async function generateMatrixSynthesis() {
+  matrixLoading.value = true
+  try {
+    const res = await axios.post('/api/competitors/social-matrix')
+    matrixSynthesis.value = res.data.synthesis
+  } catch (err: any) {
+    alert(err.response?.data?.error || 'Matrix synthesis failed')
+  } finally {
+    matrixLoading.value = false
+  }
+}
+
 // Grid layout: full-width multi-column for 2+ competitors
 const gridClass = computed(() => {
   const n = competitorStore.competitors.length