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

Market signal detection and classification using Porter's taxonomy

New POST /competitors/:id/detect-signals endpoint compares the latest scraped
content against the competitor's existing aiAnalysis baseline (themes + tone)
and calls the active AI provider to classify behavioural changes into 7 signal
types: topic_expansion, tone_shift, campaign_launch, pricing_change,
market_entry, competitive_aggression, frequency_change. Each signal includes
a severity rating (low/medium/high) and a one-sentence description.

UI: "Detect Signals" button in the action row (glows when contentChanged is
true, indicating fresh content to analyse). Detected signals render as colour-
coded chips above the profile fact-sheet — red for high-severity, amber for
medium, gray for low. Clearing contentChanged after detection so the button
does not prompt again until the next scrape finds new content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 недель назад
Родитель
Сommit
09216c3d2b
5 измененных файлов с 178 добавлено и 3 удалено
  1. 95 0
      services/gateway/server.js
  2. 10 0
      ui/src/locales/en.ts
  3. 10 0
      ui/src/locales/tr.ts
  4. 32 3
      ui/src/stores/competitors.ts
  5. 31 0
      ui/src/views/Competitors.vue

+ 95 - 0
services/gateway/server.js

@@ -2922,6 +2922,101 @@ app.post('/competitors/:id/analyze-gaps', async (request, reply) => {
   return { success: true, ...gapAnalysis };
 });
 
+// Detect and classify market signals from latest scraped content
+app.post('/competitors/:id/detect-signals', async (request, reply) => {
+  const db = await getDb();
+  const competitor = await db.collection('competitors').findOne({ _id: new ObjectId(request.params.id) });
+  if (!competitor) return reply.code(404).send({ error: 'Competitor not found' });
+
+  const recentContent = (competitor.scrapedContent || []).slice(0, 10);
+  if (!recentContent.length) return reply.code(400).send({ error: 'Scrape first before detecting signals' });
+
+  const contentText = recentContent.map((s) => `[${s.source}] ${s.text}`).join('\n\n').slice(0, 4000);
+  const baseline = competitor.aiAnalysis
+    ? `Previous profile — Themes: ${(competitor.aiAnalysis.themes || []).join(', ')}. Tone: ${competitor.aiAnalysis.tone || 'unknown'}.`
+    : 'No prior analysis available.';
+
+  const system = 'You are a competitive intelligence analyst specialising in market signal detection. Return only valid JSON with no explanation, no markdown.';
+  const prompt = `Analyse the latest content from competitor "${competitor.name}" and detect any notable changes or strategic signals.
+
+Baseline context:
+${baseline}
+
+Latest scraped content:
+${contentText}
+
+Classify any changes you detect. Use these signal types:
+- topic_expansion: new content themes or topics not previously seen
+- tone_shift: measurable change in communication style or voice
+- campaign_launch: evidence of a new marketing campaign or product promotion
+- pricing_change: any mention of new pricing, offers, or discounts
+- market_entry: signs of entering a new platform, audience, or geography
+- competitive_aggression: direct competitor comparisons, attack marketing, or poaching language
+- frequency_change: evidence of significantly more or less content activity
+
+Return ONLY a JSON array (empty array [] if no significant signals):
+[{"type":"<type>","description":"<one sentence describing the specific change>","severity":"<low|medium|high>"}]
+
+Severity guide: high = major strategic shift, medium = notable change worth monitoring, low = minor or uncertain change.`;
+
+  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 VALID_TYPES = new Set(['topic_expansion', 'tone_shift', 'campaign_launch', 'pricing_change', 'market_entry', 'competitive_aggression', 'frequency_change']);
+    const VALID_SEVERITIES = new Set(['low', 'medium', 'high']);
+    let signals = [];
+    try {
+      const jsonStr = (text.match(/\[[\s\S]*\]/) || ['[]'])[0];
+      const parsed = JSON.parse(jsonStr);
+      if (!Array.isArray(parsed)) throw new Error();
+      const now = new Date();
+      signals = parsed
+        .filter((s) => s && VALID_TYPES.has(s.type) && typeof s.description === 'string')
+        .slice(0, 8)
+        .map((s) => ({
+          type: s.type,
+          description: s.description.trim(),
+          severity: VALID_SEVERITIES.has(s.severity) ? s.severity : 'medium',
+          detectedAt: now,
+        }));
+    } catch {
+      signals = [];
+    }
+
+    await db.collection('competitors').updateOne(
+      { _id: new ObjectId(request.params.id) },
+      { $set: { signals, contentChanged: false, updatedAt: new Date() } },
+    );
+    log.info({ action: 'detect_signals', competitorId: request.params.id, count: signals.length, outcome: 'success' });
+    return { success: true, signals };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Signal detection failed', detail: err.message });
+  }
+});
+
 // Generate a 5-post content roadmap from competitor keywords and gaps
 app.post('/competitors/:id/content-roadmap', async (request, reply) => {
   const db = await getDb();

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

@@ -501,6 +501,16 @@ export default {
     intent_transactional: 'Transactional',
     intent_navigational: 'Navigational',
     sideBySideMode: 'Comparing competitors side by side',
+    detectSignals: 'Detect Signals',
+    detectingSignals: 'Detecting…',
+    signalsLabel: 'Market Signals',
+    signalType_topic_expansion: 'New topics',
+    signalType_tone_shift: 'Tone shift',
+    signalType_campaign_launch: 'Campaign launch',
+    signalType_pricing_change: 'Pricing change',
+    signalType_market_entry: 'Market entry',
+    signalType_competitive_aggression: 'Competitive move',
+    signalType_frequency_change: 'Frequency change',
     profilePricing: 'Pricing',
     profileTarget: 'Target customer',
     profileFeatures: 'Key features',

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

@@ -501,6 +501,16 @@ export default {
     intent_transactional: 'İşlemsel',
     intent_navigational: 'Yönlendirici',
     sideBySideMode: 'Rakipler yan yana karşılaştırılıyor',
+    detectSignals: 'Sinyalleri Algıla',
+    detectingSignals: 'Algılanıyor…',
+    signalsLabel: 'Pazar Sinyalleri',
+    signalType_topic_expansion: 'Yeni konular',
+    signalType_tone_shift: 'Ton değişimi',
+    signalType_campaign_launch: 'Kampanya başlatma',
+    signalType_pricing_change: 'Fiyat değişimi',
+    signalType_market_entry: 'Pazara giriş',
+    signalType_competitive_aggression: 'Rekabet hamlesi',
+    signalType_frequency_change: 'Sıklık değişimi',
     profilePricing: 'Fiyatlandırma',
     profileTarget: 'Hedef müşteri',
     profileFeatures: 'Önemli özellikler',

+ 32 - 3
ui/src/stores/competitors.ts

@@ -9,6 +9,15 @@ export interface CompetitorProfile {
   targetCustomer: string
 }
 
+export type SignalSeverity = 'low' | 'medium' | 'high'
+
+export interface CompetitorSignal {
+  type: string
+  description: string
+  severity: SignalSeverity
+  detectedAt: string
+}
+
 export interface AiAnalysis {
   themes: string[]
   tone: string
@@ -66,6 +75,7 @@ export interface Competitor {
   aiAnalysis?: AiAnalysis
   keywords: CompetitorKeyword[]
   contentChanged?: boolean
+  signals?: CompetitorSignal[]
   gapAnalysis?: GapAnalysis
   contentRoadmap?: RoadmapPost[]
   lastScraped: string | null
@@ -240,9 +250,27 @@ export const useCompetitorStore = defineStore('competitors', () => {
     }
   }
 
+  const detectingSignals = ref<Record<string, boolean>>({})
   const discoveringCompetitors = ref(false)
   const discoverySuggestions = ref<CompetitorSuggestion[]>([])
 
+  async function detectSignals(id: string): Promise<void> {
+    detectingSignals.value = { ...detectingSignals.value, [id]: true }
+    error.value = null
+    try {
+      const res = await axios.post(`/api/competitors/${id}/detect-signals`)
+      const idx = competitors.value.findIndex((c) => c._id === id)
+      if (idx !== -1) {
+        competitors.value[idx].signals = res.data.signals
+        competitors.value[idx].contentChanged = false
+      }
+    } catch (err: any) {
+      error.value = err.response?.data?.detail || err.response?.data?.error || 'Signal detection failed'
+    } finally {
+      detectingSignals.value = { ...detectingSignals.value, [id]: false }
+    }
+  }
+
   async function discoverCompetitors(): Promise<void> {
     discoveringCompetitors.value = true
     error.value = null
@@ -258,9 +286,10 @@ export const useCompetitorStore = defineStore('competitors', () => {
   }
 
   return {
-    competitors, loading, scraping, summarizing, extractingKeywords, analyzingGaps, generatingRoadmap, scrapeResults,
-    discoveringCompetitors, discoverySuggestions, error,
+    competitors, loading, scraping, summarizing, extractingKeywords, analyzingGaps, generatingRoadmap,
+    detectingSignals, discoveringCompetitors, discoverySuggestions, scrapeResults, error,
     fetchCompetitors, addCompetitor, updateCompetitor, deleteCompetitor,
-    scrapeCompetitor, summarizeCompetitor, extractKeywords, analyzeGaps, generateRoadmap, discoverCompetitors,
+    scrapeCompetitor, summarizeCompetitor, extractKeywords, analyzeGaps, generateRoadmap,
+    detectSignals, discoverCompetitors,
   }
 })

+ 31 - 0
ui/src/views/Competitors.vue

@@ -100,6 +100,15 @@
             <i class="fa-solid fa-tags" :class="{ 'animate-pulse': competitorStore.extractingKeywords[competitor._id] }"></i>
             {{ competitorStore.extractingKeywords[competitor._id] ? t('competitors.extractingKeywords') : t('competitors.extractKeywords') }}
           </button>
+          <button
+            @click="competitorStore.detectSignals(competitor._id)"
+            :disabled="competitorStore.detectingSignals[competitor._id] || !competitor.scrapedContent.length"
+            class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-yellow-700 hover:bg-yellow-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
+            :class="{ 'ring-1 ring-yellow-400': competitor.contentChanged && !competitorStore.detectingSignals[competitor._id] }"
+          >
+            <i class="fa-solid fa-tower-broadcast" :class="{ 'animate-pulse': competitorStore.detectingSignals[competitor._id] }"></i>
+            {{ competitorStore.detectingSignals[competitor._id] ? t('competitors.detectingSignals') : t('competitors.detectSignals') }}
+          </button>
           <button
             @click="competitorStore.analyzeGaps(competitor._id)"
             :disabled="competitorStore.analyzingGaps[competitor._id] || !competitor.keywords?.length"
@@ -144,6 +153,28 @@
           </span>
         </div>
 
+        <!-- Market Signals -->
+        <div v-if="competitor.signals?.length" class="mb-3">
+          <div class="text-xs text-yellow-400 font-medium mb-1.5">{{ t('competitors.signalsLabel') }}</div>
+          <div class="flex flex-wrap gap-1.5">
+            <span
+              v-for="signal in competitor.signals"
+              :key="signal.type + signal.detectedAt"
+              :title="signal.description"
+              :class="{
+                'bg-red-900/40 border-red-700/60 text-red-300':    signal.severity === 'high',
+                'bg-amber-900/40 border-amber-700/60 text-amber-300': signal.severity === 'medium',
+                'bg-gray-700/60 border-gray-600/60 text-gray-300':  signal.severity === 'low',
+              }"
+              class="inline-flex items-center gap-1 text-xs px-2 py-0.5 border rounded-full cursor-default"
+            >
+              <i class="fa-solid fa-tower-broadcast text-[9px]"></i>
+              {{ t(`competitors.signalType_${signal.type}`) || signal.type.replace(/_/g, ' ') }}
+            </span>
+          </div>
+          <p v-if="competitor.signals.length === 1" class="text-xs text-gray-400 mt-1.5 italic">{{ competitor.signals[0].description }}</p>
+        </div>
+
         <!-- Structured Profile Fact-sheet -->
         <div v-if="competitor.aiAnalysis?.profile?.pricing || competitor.aiAnalysis?.profile?.keyFeatures?.length" class="mb-3 p-3 bg-gray-900/50 border border-gray-700/60 rounded-lg text-xs space-y-1.5">
           <div v-if="competitor.aiAnalysis.profile.pricing" class="flex gap-1.5">