Ver Fonte

Porter's Five Forces industry analysis — stored on account profile

POST /ai/five-forces scores all five forces 1-10 (competitive rivalry,
new entrants, substitutes, supplier power, buyer power) using account
profile context plus any known competitors. Returns overall attractiveness
score (0-100), governing force, per-force drivers, and 3 positioning
recommendations. Result persisted to account_profiles.industryAnalysis.
Settings adds a green Five Forces button per account with a stacked results
panel showing score bars, driver chips, and positioning recommendations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris há 3 semanas atrás
pai
commit
8939afe6e3
4 ficheiros alterados com 201 adições e 0 exclusões
  1. 124 0
      services/gateway/server.js
  2. 5 0
      ui/src/locales/en.ts
  3. 5 0
      ui/src/locales/tr.ts
  4. 67 0
      ui/src/views/Settings.vue

+ 124 - 0
services/gateway/server.js

@@ -3833,6 +3833,130 @@ Write as direct, specific analysis. No fluff.`;
   }
 });
 
+// ─── Porter's Five Forces Analysis ───────────────────────────────────────────
+
+app.post('/ai/five-forces', async (request, reply) => {
+  const { accountKey } = request.body || {};
+  if (!accountKey) return reply.code(400).send({ error: 'accountKey is required' });
+
+  const db = await getDb();
+  const profile = await db.collection('account_profiles').findOne({ _id: accountKey });
+  if (!profile) return reply.code(404).send({ error: 'Account profile not found. Save a profile first.' });
+
+  if (!profile.industry && !profile.businessName) {
+    return reply.code(400).send({ error: 'Add at least a business name or industry to the profile first.' });
+  }
+
+  const profileBlock = [
+    profile.businessName   ? `Business: ${profile.businessName}` : '',
+    profile.industry       ? `Industry: ${profile.industry}` : '',
+    profile.description    ? `Description: ${profile.description}` : '',
+    profile.targetAudience ? `Target audience: ${profile.targetAudience}` : '',
+    profile.keywords       ? `Keywords / products: ${profile.keywords}` : '',
+  ].filter(Boolean).join('\n');
+
+  // Load competitor data for richer rivalry analysis
+  const competitors = await db.collection('competitors').find({}, { projection: { name: 1, aiAnalysis: 1 } }).toArray();
+  const competitorBlock = competitors.length
+    ? `Known direct competitors: ${competitors.map((c) => c.name).join(', ')}.`
+    : '';
+
+  const system = 'You are a Porter strategy analyst. Return only valid JSON with no markdown or explanation.';
+  const prompt = `Analyse this business using Porter\'s Five Forces framework.
+
+${profileBlock}
+${competitorBlock}
+
+Score each force 1–10 (1 = very weak/favourable, 10 = very strong/unfavourable) and return exactly:
+{
+  "attractiveness": "<High|Moderate|Low> — one sentence on overall industry attractiveness",
+  "overallScore": <0–100, where 100 = maximally attractive>,
+  "governingForce": "<name of the single most impactful force>",
+  "forces": [
+    {
+      "name": "Competitive Rivalry",
+      "score": <1–10>,
+      "assessment": "<one sentence>",
+      "drivers": ["<driver 1>", "<driver 2>", "<driver 3>"]
+    },
+    {
+      "name": "Threat of New Entrants",
+      "score": <1–10>,
+      "assessment": "<one sentence>",
+      "drivers": ["<driver>", "<driver>", "<driver>"]
+    },
+    {
+      "name": "Threat of Substitutes",
+      "score": <1–10>,
+      "assessment": "<one sentence>",
+      "drivers": ["<driver>", "<driver>", "<driver>"]
+    },
+    {
+      "name": "Supplier Power",
+      "score": <1–10>,
+      "assessment": "<one sentence>",
+      "drivers": ["<driver>", "<driver>", "<driver>"]
+    },
+    {
+      "name": "Buyer Power",
+      "score": <1–10>,
+      "assessment": "<one sentence>",
+      "drivers": ["<driver>", "<driver>", "<driver>"]
+    }
+  ],
+  "positioning": ["<strategic recommendation 1>", "<strategic recommendation 2>", "<strategic recommendation 3>"]
+}
+
+Scoring: overallScore = 100 minus the average of all five force scores × 10. 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.forces) || result.forces.length !== 5) throw new Error('Missing forces array');
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid Five Forces format — try again' });
+    }
+
+    await db.collection('account_profiles').updateOne(
+      { _id: accountKey },
+      { $set: { industryAnalysis: result, industryAnalyzedAt: new Date(), updatedAt: new Date() } },
+    );
+
+    log.info({ action: 'five_forces', account: accountKey, governingForce: result.governingForce, outcome: 'success' });
+    return { success: true, ...result, analyzedAt: new Date() };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Five Forces analysis failed', detail: err.message });
+  }
+});
+
 // ─── Industry Type Diagnosis ─────────────────────────────────────────────────
 
 app.post('/ai/industry-diagnosis', async (request, reply) => {

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

@@ -348,6 +348,11 @@ export default {
       diagnosisCharacteristics: 'Characteristics',
       diagnosisTactics: 'Recommended Tactics',
       diagnosisContentMix: 'Recommended Content Mix',
+      fiveForces: "Five Forces",
+      fiveForcesRunning: 'Analysing…',
+      fiveForcesTitle: "Porter's Five Forces",
+      fiveForcesGoverning: 'Governing force',
+      fiveForcesPositioning: 'Strategic positioning recommendations',
     },
 
     hashtags: {

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

@@ -348,6 +348,11 @@ export default {
       diagnosisCharacteristics: 'Özellikler',
       diagnosisTactics: 'Önerilen Taktikler',
       diagnosisContentMix: 'Önerilen İçerik Karması',
+      fiveForces: 'Beş Kuvvet',
+      fiveForcesRunning: 'Analiz ediliyor…',
+      fiveForcesTitle: "Porter'ın Beş Kuvveti",
+      fiveForcesGoverning: 'Belirleyici kuvvet',
+      fiveForcesPositioning: 'Stratejik konumlandırma önerileri',
     },
 
     hashtags: {

+ 67 - 0
ui/src/views/Settings.vue

@@ -727,6 +727,14 @@
                 <span v-if="profileSavedKey === account.key" class="text-xs text-green-400">
                   {{ $t('settings.profiles.saved') }}
                 </span>
+                <button
+                  @click="runFiveForces(account.key)"
+                  :disabled="fiveForcesRunning === account.key"
+                  class="px-3 py-2 bg-emerald-800 hover:bg-emerald-700 disabled:opacity-40 rounded-lg text-sm transition-colors flex items-center gap-1.5"
+                >
+                  <i class="fa-solid fa-star-of-david text-xs" :class="{ 'animate-pulse': fiveForcesRunning === account.key }"></i>
+                  {{ fiveForcesRunning === account.key ? $t('settings.profiles.fiveForcesRunning') : $t('settings.profiles.fiveForces') }}
+                </button>
                 <button
                   @click="diagnoseIndustry(account.key)"
                   :disabled="industryDiagnosing === account.key"
@@ -844,6 +852,51 @@
                 </div>
               </div>
 
+              <!-- Five Forces results -->
+              <div v-if="fiveForcesResults[account.key]" class="mt-4 border-t border-gray-700 pt-4">
+                <div class="flex items-center justify-between mb-3">
+                  <div class="flex items-center gap-2">
+                    <i class="fa-solid fa-star-of-david text-xs text-emerald-400"></i>
+                    <span class="text-sm font-medium text-white">{{ $t('settings.profiles.fiveForcesTitle') }}</span>
+                    <span class="px-2 py-0.5 rounded-full text-xs font-semibold"
+                      :class="fiveForcesResults[account.key].overallScore >= 60 ? 'bg-green-900/50 border border-green-700 text-green-300' : fiveForcesResults[account.key].overallScore >= 40 ? 'bg-amber-900/50 border border-amber-700 text-amber-300' : 'bg-red-900/50 border border-red-700 text-red-300'"
+                    >{{ fiveForcesResults[account.key].overallScore }}/100</span>
+                  </div>
+                  <button @click="fiveForcesResults[account.key] = null" class="text-xs text-gray-500 hover:text-gray-300">✕</button>
+                </div>
+                <p class="text-xs text-gray-400 mb-3 leading-relaxed italic">{{ fiveForcesResults[account.key].attractiveness }}</p>
+                <p v-if="fiveForcesResults[account.key].governingForce" class="text-xs text-emerald-400 font-medium mb-3">
+                  {{ $t('settings.profiles.fiveForcesGoverning') }}: {{ fiveForcesResults[account.key].governingForce }}
+                </p>
+
+                <!-- Force rows -->
+                <div class="space-y-2 mb-3">
+                  <div v-for="force in fiveForcesResults[account.key].forces" :key="force.name" class="p-2.5 bg-gray-800 border border-gray-700 rounded-lg text-xs">
+                    <div class="flex items-center justify-between mb-1">
+                      <span class="font-medium text-gray-200">{{ force.name }}</span>
+                      <span class="font-semibold" :class="force.score <= 3 ? 'text-green-400' : force.score <= 6 ? 'text-amber-400' : 'text-red-400'">{{ force.score }}/10</span>
+                    </div>
+                    <div class="w-full h-1 bg-gray-700 rounded-full mb-1.5">
+                      <div class="h-1 rounded-full" :class="force.score <= 3 ? 'bg-green-500' : force.score <= 6 ? 'bg-amber-500' : 'bg-red-500'" :style="{ width: (force.score * 10) + '%' }"></div>
+                    </div>
+                    <p class="text-gray-400 mb-1">{{ force.assessment }}</p>
+                    <div class="flex flex-wrap gap-1">
+                      <span v-for="d in force.drivers" :key="d" class="px-1.5 py-0.5 bg-gray-700 rounded text-gray-300">{{ d }}</span>
+                    </div>
+                  </div>
+                </div>
+
+                <!-- Positioning recommendations -->
+                <div v-if="fiveForcesResults[account.key].positioning?.length">
+                  <div class="text-xs font-medium text-gray-400 mb-1.5">{{ $t('settings.profiles.fiveForcesPositioning') }}</div>
+                  <ul class="space-y-0.5">
+                    <li v-for="p in fiveForcesResults[account.key].positioning" :key="p" class="flex gap-1.5 text-xs text-gray-300">
+                      <span class="text-emerald-400 shrink-0">›</span>{{ p }}
+                    </li>
+                  </ul>
+                </div>
+              </div>
+
             </div>
           </div>
         </div>
@@ -1674,6 +1727,8 @@ const profileAuditing    = ref<string | null>(null)
 const profileAudits      = ref<Record<string, any>>({})
 const industryDiagnosing = ref<string | null>(null)
 const industryDiagnoses  = ref<Record<string, any>>({})
+const fiveForcesRunning  = ref<string | null>(null)
+const fiveForcesResults  = ref<Record<string, any>>({})
 
 const allConnectedAccounts = computed((): ProfileAccount[] => {
   const accounts: ProfileAccount[] = []
@@ -1767,6 +1822,18 @@ async function removePlacesKey() {
   placesKeyHint.value = null
 }
 
+async function runFiveForces(key: string) {
+  fiveForcesRunning.value = key
+  try {
+    const res = await axios.post('/api/ai/five-forces', { accountKey: key })
+    fiveForcesResults.value = { ...fiveForcesResults.value, [key]: res.data }
+  } catch (err: any) {
+    alert(err.response?.data?.error || 'Five Forces analysis failed')
+  } finally {
+    fiveForcesRunning.value = null
+  }
+}
+
 async function diagnoseIndustry(key: string) {
   industryDiagnosing.value = key
   try {