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

Automatic competitor discovery from account profile context

New POST /competitors/discover endpoint queries the active AI provider with
the first account profile (business name, description, industry, website) and
asks it to identify the top 5 direct competitors, returning name, websiteUrl,
and a one-sentence reason for each.

UI: "Find Competitors Automatically" button appears on the empty state and as
a secondary button in the add form (hidden once suggestions are shown).
Suggestions render as accept/reject cards; clicking Add calls addCompetitor and
removes that card from the list. Requires an Account Profile to be set up first
so the AI has business context to work from.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 недель назад
Родитель
Сommit
8f97aca25d

+ 83 - 0
services/gateway/server.js

@@ -2541,6 +2541,89 @@ async function buildCompetitorSystemSuffix() {
   }
 }
 
+// Discover competitors automatically using AI + account profile context
+app.post('/competitors/discover', async (request, reply) => {
+  const db = await getDb();
+
+  // Use the first account profile for business context
+  const profile = 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.websiteUrl)     contextParts.push(`Website: ${profile.websiteUrl}`);
+    if (profile.targetAudience) contextParts.push(`Target audience: ${profile.targetAudience}`);
+  }
+
+  if (!contextParts.length) {
+    return reply.code(400).send({ error: 'Set up at least one Account Profile in Settings before discovering competitors.' });
+  }
+
+  const system = 'You are a market research analyst. Return only valid JSON with no explanation, no markdown code blocks.';
+  const prompt = `Based on the following business profile, identify the top 5 direct competitors.
+
+${contextParts.join('\n')}
+
+Return ONLY a JSON array of objects, e.g.:
+[{"name":"Competitor Name","websiteUrl":"https://example.com","reason":"One sentence on why they compete directly"}]
+
+Rules:
+- Return real, existing businesses only.
+- Include only direct competitors (same product/service category, same target audience).
+- websiteUrl must be a valid https URL to the competitor's main website.
+- No explanation outside the JSON array.`;
+
+  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 suggestions = [];
+    try {
+      const jsonStr = (text.match(/\[[\s\S]*\]/) || ['[]'])[0];
+      const parsed = JSON.parse(jsonStr);
+      if (!Array.isArray(parsed)) throw new Error();
+      suggestions = parsed
+        .filter((s) => s && typeof s.name === 'string' && typeof s.websiteUrl === 'string')
+        .slice(0, 5)
+        .map((s) => ({
+          name: s.name.trim(),
+          websiteUrl: s.websiteUrl.trim(),
+          reason: typeof s.reason === 'string' ? s.reason.trim() : '',
+        }));
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid format — try again' });
+    }
+
+    log.info({ action: 'competitor_discover', count: suggestions.length, outcome: 'success' });
+    return { success: true, suggestions };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Discovery failed', detail: err.message });
+  }
+});
+
 // List competitors
 app.get('/competitors', async (request, reply) => {
   const db = await getDb();

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

@@ -501,6 +501,10 @@ export default {
     intent_transactional: 'Transactional',
     intent_navigational: 'Navigational',
     sideBySideMode: 'Comparing competitors side by side',
+    discoverButton: 'Find Competitors Automatically',
+    discovering: 'Discovering…',
+    discoverySuggestionsLabel: 'AI-suggested competitors — click Add to track them:',
+    discoverAccept: 'Add',
     sharedGapsNote: 'Red keywords are also targeted by {name} — highest priority gaps',
     sharedGapTitle: 'Also targeted by {name}',
     analyzeGaps: 'Gap Analysis',

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

@@ -501,6 +501,10 @@ export default {
     intent_transactional: 'İşlemsel',
     intent_navigational: 'Yönlendirici',
     sideBySideMode: 'Rakipler yan yana karşılaştırılıyor',
+    discoverButton: 'Rakipleri Otomatik Bul',
+    discovering: 'Aranıyor…',
+    discoverySuggestionsLabel: 'YZ tarafından önerilen rakipler — eklemek için Ekle\'ye tıklayın:',
+    discoverAccept: 'Ekle',
     sharedGapsNote: 'Kırmızı anahtar kelimeler {name} tarafından da hedefleniyor — en öncelikli boşluklar',
     sharedGapTitle: '{name} tarafından da hedefleniyor',
     analyzeGaps: 'Boşluk Analizi',

+ 26 - 2
ui/src/stores/competitors.ts

@@ -42,6 +42,12 @@ export interface RoadmapPost {
   rationale: string
 }
 
+export interface CompetitorSuggestion {
+  name: string
+  websiteUrl: string
+  reason: string
+}
+
 export interface Competitor {
   _id: string
   name: string
@@ -226,9 +232,27 @@ export const useCompetitorStore = defineStore('competitors', () => {
     }
   }
 
+  const discoveringCompetitors = ref(false)
+  const discoverySuggestions = ref<CompetitorSuggestion[]>([])
+
+  async function discoverCompetitors(): Promise<void> {
+    discoveringCompetitors.value = true
+    error.value = null
+    discoverySuggestions.value = []
+    try {
+      const res = await axios.post('/api/competitors/discover')
+      discoverySuggestions.value = res.data.suggestions || []
+    } catch (err: any) {
+      error.value = err.response?.data?.detail || err.response?.data?.error || 'Discovery failed'
+    } finally {
+      discoveringCompetitors.value = false
+    }
+  }
+
   return {
-    competitors, loading, scraping, summarizing, extractingKeywords, analyzingGaps, generatingRoadmap, scrapeResults, error,
+    competitors, loading, scraping, summarizing, extractingKeywords, analyzingGaps, generatingRoadmap, scrapeResults,
+    discoveringCompetitors, discoverySuggestions, error,
     fetchCompetitors, addCompetitor, updateCompetitor, deleteCompetitor,
-    scrapeCompetitor, summarizeCompetitor, extractKeywords, analyzeGaps, generateRoadmap,
+    scrapeCompetitor, summarizeCompetitor, extractKeywords, analyzeGaps, generateRoadmap, discoverCompetitors,
   }
 })

+ 54 - 3
ui/src/views/Competitors.vue

@@ -329,13 +329,55 @@
     </div>
 
     <!-- Empty state -->
-    <div v-else-if="!competitorStore.loading" class="mb-6 p-8 text-center bg-gray-800 border border-gray-700 rounded-lg text-gray-400 max-w-xl">
-      {{ t('competitors.emptyState') }}
+    <div v-else-if="!competitorStore.loading" class="mb-6 p-8 text-center bg-gray-800 border border-gray-700 rounded-lg max-w-xl">
+      <p class="text-gray-400 mb-4">{{ t('competitors.emptyState') }}</p>
+      <button
+        @click="competitorStore.discoverCompetitors()"
+        :disabled="competitorStore.discoveringCompetitors"
+        class="inline-flex items-center gap-2 px-4 py-2 bg-violet-700 hover:bg-violet-600 text-white text-sm rounded disabled:opacity-50"
+      >
+        <i class="fa-solid fa-magnifying-glass" :class="{ 'animate-pulse': competitorStore.discoveringCompetitors }"></i>
+        {{ competitorStore.discoveringCompetitors ? t('competitors.discovering') : t('competitors.discoverButton') }}
+      </button>
+    </div>
+
+    <!-- Discovery suggestions -->
+    <div v-if="competitorStore.discoverySuggestions.length" class="mb-6 max-w-xl">
+      <div class="text-xs text-gray-400 mb-2">{{ t('competitors.discoverySuggestionsLabel') }}</div>
+      <div class="space-y-2">
+        <div
+          v-for="s in competitorStore.discoverySuggestions"
+          :key="s.websiteUrl"
+          class="flex items-start gap-3 p-3 bg-gray-800 border border-gray-700 rounded-lg"
+        >
+          <div class="flex-1 min-w-0">
+            <div class="text-sm font-medium text-white">{{ s.name }}</div>
+            <a :href="s.websiteUrl" target="_blank" rel="noopener" class="text-xs text-violet-400 hover:underline truncate block">{{ s.websiteUrl }}</a>
+            <p v-if="s.reason" class="text-xs text-gray-400 mt-0.5">{{ s.reason }}</p>
+          </div>
+          <button
+            @click="acceptSuggestion(s)"
+            :disabled="competitorStore.competitors.length >= 5"
+            class="shrink-0 text-xs px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white rounded disabled:opacity-40"
+          >{{ t('competitors.discoverAccept') }}</button>
+        </div>
+      </div>
     </div>
 
     <!-- Add competitor form -->
     <div v-if="competitorStore.competitors.length < 5" class="bg-gray-800 border border-gray-700 rounded-lg p-5 max-w-xl">
-      <h2 class="text-sm font-semibold text-white mb-3">{{ t('competitors.addCompetitor') }}</h2>
+      <div class="flex items-center justify-between mb-3">
+        <h2 class="text-sm font-semibold text-white">{{ t('competitors.addCompetitor') }}</h2>
+        <button
+          v-if="!competitorStore.discoverySuggestions.length"
+          @click="competitorStore.discoverCompetitors()"
+          :disabled="competitorStore.discoveringCompetitors"
+          class="flex items-center gap-1.5 text-xs px-2.5 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded disabled:opacity-50"
+        >
+          <i class="fa-solid fa-magnifying-glass text-[10px]" :class="{ 'animate-pulse': competitorStore.discoveringCompetitors }"></i>
+          {{ competitorStore.discoveringCompetitors ? t('competitors.discovering') : t('competitors.discoverButton') }}
+        </button>
+      </div>
       <div class="space-y-2">
         <input
           v-model="newForm.name"
@@ -478,6 +520,15 @@ async function createCompetitor() {
   }
 }
 
+async function acceptSuggestion(s: { name: string; websiteUrl: string }) {
+  const ok = await competitorStore.addCompetitor({ name: s.name, websiteUrl: s.websiteUrl })
+  if (ok) {
+    competitorStore.discoverySuggestions.splice(
+      competitorStore.discoverySuggestions.findIndex((x) => x.websiteUrl === s.websiteUrl), 1,
+    )
+  }
+}
+
 function startEdit(competitor: Competitor) {
   editingId.value = competitor._id
   editForm.name = competitor.name