소스 검색

Structured Output

Benjamin Harris 3 주 전
부모
커밋
ec41aae698
7개의 변경된 파일143개의 추가작업 그리고 16개의 파일을 삭제
  1. 8 1
      .claude/settings.local.json
  2. 3 0
      .gitignore
  3. 49 12
      services/gateway/server.js
  4. 6 0
      ui/src/locales/en.ts
  5. 6 0
      ui/src/locales/tr.ts
  6. 13 1
      ui/src/stores/competitors.ts
  7. 58 2
      ui/src/views/Competitors.vue

+ 8 - 1
.claude/settings.local.json

@@ -4,7 +4,14 @@
       "Bash(dir \"d:\\\\GIT_REPO\\\\social-media-manager\\\\services\\\\gateway\" /s /b)",
       "Bash(sed -n '110,155p' d:/GIT_REPO/social-media-manager/services/facebook/index.js)",
       "Bash(sed -n '85,125p' d:/GIT_REPO/social-media-manager/services/mastodon/index.js)",
-      "Bash(sed -n '90,130p' d:/GIT_REPO/social-media-manager/services/bluesky/index.js)"
+      "Bash(sed -n '90,130p' d:/GIT_REPO/social-media-manager/services/bluesky/index.js)",
+      "PowerShell(cd d:\\\\GIT_REPO\\\\social-media-manager && \\(Get-Content services/gateway/server.js\\).Count)",
+      "PowerShell(cd d:\\\\GIT_REPO\\\\social-media-manager && \\(Get-Content services/scheduler/index.js\\).Count)",
+      "PowerShell(cd d:\\\\GIT_REPO\\\\social-media-manager && \\(Get-Content ui/src/locales/en.ts\\).Count)",
+      "PowerShell(cd d:\\\\GIT_REPO\\\\social-media-manager && \\(Get-Content ui/src/locales/tr.ts\\).Count)",
+      "Bash(gh api *)",
+      "Bash(where gh *)",
+      "Bash(gh --version)"
     ]
   }
 }

+ 3 - 0
.gitignore

@@ -1,6 +1,7 @@
 CLAUDE.md
 .env
 .claude/
+.claude/settings.local.json
 
 # Logs
 logs
@@ -75,3 +76,5 @@ coverage/
 
 # Docs folder
 docs/
+
+

+ 49 - 12
services/gateway/server.js

@@ -2239,9 +2239,21 @@ async function runCompetitorScrape(competitorId) {
 async function buildCompetitorSystemSuffix() {
   try {
     const db = await getDb();
-    const competitors = await db.collection('competitors').find({ aiSummary: { $nin: ['', null] } }).toArray();
+    const competitors = await db.collection('competitors').find({
+      $or: [{ 'aiAnalysis.positioning': { $nin: ['', null] } }, { aiSummary: { $nin: ['', null] } }],
+    }).toArray();
     if (!competitors.length) return '';
-    const lines = competitors.map((c) => `- ${c.name}: ${c.aiSummary}`).join('\n');
+    const lines = competitors.map((c) => {
+      if (c.aiAnalysis?.positioning) {
+        const a = c.aiAnalysis;
+        const parts = [`- ${c.name}:`];
+        if (a.positioning) parts.push(`  Positioning: ${a.positioning}`);
+        if (a.gaps?.length) parts.push(`  Weaknesses/gaps: ${a.gaps.join('; ')}`);
+        if (a.themes?.length) parts.push(`  Key themes: ${a.themes.join(', ')}`);
+        return parts.join('\n');
+      }
+      return `- ${c.name}: ${c.aiSummary}`;
+    }).join('\n');
     return `\n\nCOMPETITOR CONTEXT (for differentiation — do not copy, use to contrast):\n${lines}\nEmphasise what makes this brand unique compared to the above.`;
   } catch {
     return '';
@@ -2312,7 +2324,7 @@ app.post('/competitors/scrape-all', async (request, reply) => {
   return { success: true, results };
 });
 
-// Summarize competitor content with AI
+// Summarize competitor content with AI — returns structured analysis
 app.post('/competitors/:id/summarize', async (request, reply) => {
   const db = await getDb();
   const competitor = await db.collection('competitors').findOne({ _id: new ObjectId(request.params.id) });
@@ -2321,23 +2333,35 @@ app.post('/competitors/:id/summarize', async (request, reply) => {
   const content = (competitor.scrapedContent || []).map((s) => `[${s.source}] ${s.text}`).join('\n\n').slice(0, 6000);
   if (!content) return reply.code(400).send({ error: 'No scraped content to summarize' });
 
-  const system = 'You are a competitive intelligence analyst. Be concise.';
-  const prompt = `Analyse the following content from "${competitor.name}" and summarise their key themes, messaging style, and content strategy in 2-3 sentences. Focus on topics, tone, and positioning.\n\n${content}`;
+  const system = 'You are a competitive intelligence analyst. Return only valid JSON with no explanation, no markdown code blocks.';
+  const prompt = `Analyse the following content from "${competitor.name}" and return a JSON object with exactly these fields:
+{
+  "themes": ["3-5 main content topics or pillars they focus on"],
+  "tone": "one sentence describing their voice and communication style",
+  "positioning": "one sentence on how they position themselves in the market",
+  "gaps": ["2-3 topics or angles they ignore or handle poorly — opportunities for you"],
+  "moves": ["3 specific content angles you could use to stand out against them"]
+}
+
+Content:
+${content}
+
+Return ONLY the JSON object. No explanation, no markdown.`;
 
   try {
     const pconf = await getActiveProviderConfig();
     const model = pconf.model;
-    let summary = '';
+    let text = '';
 
     if (pconf.provider === 'ollama') {
       const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 180000 });
-      summary = res.data.response;
+      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: 180000 });
-      summary = res.data.choices[0]?.message?.content || '';
+      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(
@@ -2345,17 +2369,30 @@ app.post('/competitors/:id/summarize', async (request, reply) => {
         { contents: buildGeminiContents(prompt, system) },
         { timeout: 180000 },
       );
-      summary = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
     } else {
       return reply.code(400).send({ error: 'AI not configured' });
     }
 
-    summary = summary.trim();
+    let aiAnalysis = null;
+    try {
+      const jsonStr = (text.match(/\{[\s\S]*\}/) || ['{}'])[0];
+      aiAnalysis = JSON.parse(jsonStr);
+      if (!Array.isArray(aiAnalysis.themes)) aiAnalysis.themes = [];
+      if (typeof aiAnalysis.tone !== 'string') aiAnalysis.tone = '';
+      if (typeof aiAnalysis.positioning !== 'string') aiAnalysis.positioning = '';
+      if (!Array.isArray(aiAnalysis.gaps)) aiAnalysis.gaps = [];
+      if (!Array.isArray(aiAnalysis.moves)) aiAnalysis.moves = [];
+    } catch {
+      aiAnalysis = null;
+    }
+    if (!aiAnalysis) return reply.code(503).send({ error: 'AI returned invalid analysis format — try again' });
+
     await db.collection('competitors').updateOne(
       { _id: new ObjectId(request.params.id) },
-      { $set: { aiSummary: summary, updatedAt: new Date() } },
+      { $set: { aiAnalysis, aiSummary: '', updatedAt: new Date() } },
     );
-    return { success: true, aiSummary: summary };
+    return { success: true, aiAnalysis };
   } catch (err) {
     return reply.code(503).send({ error: 'Summarization failed', detail: err.message });
   }

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

@@ -474,6 +474,12 @@ export default {
     extractKeywords: 'Extract Keywords',
     extractingKeywords: 'Extracting…',
     aiSummaryLabel: 'AI Summary',
+    aiAnalysisLabel: 'AI Analysis',
+    analysisTone: 'Tone',
+    analysisPositioning: 'Positioning',
+    analysisThemes: 'Content themes',
+    analysisGaps: 'Gaps & opportunities',
+    analysisMoves: 'Differentiation moves',
     keywordsLabel: 'Competitor Keywords',
     lastScraped: 'Last scraped',
     scrapeSuccess: 'Scraped {count} source(s) successfully',

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

@@ -474,6 +474,12 @@ export default {
     extractKeywords: 'Anahtar Kelime Çıkar',
     extractingKeywords: 'Çıkarılıyor…',
     aiSummaryLabel: 'YZ Özeti',
+    aiAnalysisLabel: 'YZ Analizi',
+    analysisTone: 'Ton',
+    analysisPositioning: 'Konumlandırma',
+    analysisThemes: 'İçerik temaları',
+    analysisGaps: 'Boşluklar ve fırsatlar',
+    analysisMoves: 'Farklılaşma hamleleri',
     keywordsLabel: 'Rakip Anahtar Kelimeleri',
     lastScraped: 'Son tarama',
     scrapeSuccess: '{count} kaynak başarıyla tarandı',

+ 13 - 1
ui/src/stores/competitors.ts

@@ -2,6 +2,14 @@ import { defineStore } from 'pinia'
 import { ref } from 'vue'
 import axios from 'axios'
 
+export interface AiAnalysis {
+  themes: string[]
+  tone: string
+  positioning: string
+  gaps: string[]
+  moves: string[]
+}
+
 export interface Competitor {
   _id: string
   name: string
@@ -9,6 +17,7 @@ export interface Competitor {
   socialUrls: Partial<Record<string, string>>
   scrapedContent: { source: string; url: string; text: string; scrapedAt: string }[]
   aiSummary: string
+  aiAnalysis?: AiAnalysis
   keywords: string[]
   lastScraped: string | null
   createdAt: string
@@ -97,7 +106,10 @@ export const useCompetitorStore = defineStore('competitors', () => {
     try {
       const res = await axios.post(`/api/competitors/${id}/summarize`)
       const idx = competitors.value.findIndex((c) => c._id === id)
-      if (idx !== -1) competitors.value[idx].aiSummary = res.data.aiSummary
+      if (idx !== -1) {
+        competitors.value[idx].aiAnalysis = res.data.aiAnalysis
+        competitors.value[idx].aiSummary = ''
+      }
     } catch (err: any) {
       error.value = err.response?.data?.detail || err.response?.data?.error || 'Summarization failed'
     } finally {

+ 58 - 2
ui/src/views/Competitors.vue

@@ -107,8 +107,64 @@
           {{ t('competitors.lastScraped') }}: {{ new Date(competitor.lastScraped).toLocaleString() }}
         </div>
 
-        <!-- AI Summary -->
-        <div v-if="competitor.aiSummary" class="mb-3 p-3 bg-gray-700/50 rounded border border-gray-600 text-sm text-gray-200">
+        <!-- Structured AI Analysis -->
+        <div v-if="competitor.aiAnalysis" class="mt-3 space-y-3">
+          <!-- Tone & Positioning -->
+          <div class="p-3 bg-gray-700/50 rounded border border-gray-600 space-y-2">
+            <div class="text-xs text-violet-400 font-medium">{{ t('competitors.aiAnalysisLabel') }}</div>
+            <div v-if="competitor.aiAnalysis.tone" class="text-sm">
+              <span class="text-xs text-gray-400">{{ t('competitors.analysisTone') }}: </span>
+              <span class="text-gray-200 italic">{{ competitor.aiAnalysis.tone }}</span>
+            </div>
+            <div v-if="competitor.aiAnalysis.positioning" class="text-sm">
+              <span class="text-xs text-gray-400">{{ t('competitors.analysisPositioning') }}: </span>
+              <span class="text-gray-200">{{ competitor.aiAnalysis.positioning }}</span>
+            </div>
+          </div>
+
+          <!-- Content Themes -->
+          <div v-if="competitor.aiAnalysis.themes?.length">
+            <div class="text-xs text-gray-400 mb-1.5">{{ t('competitors.analysisThemes') }}</div>
+            <div class="flex flex-wrap gap-1.5">
+              <span
+                v-for="theme in competitor.aiAnalysis.themes"
+                :key="theme"
+                class="text-xs px-2 py-0.5 bg-gray-700 border border-gray-600 text-gray-300 rounded-full"
+              >{{ theme }}</span>
+            </div>
+          </div>
+
+          <!-- Gaps & Opportunities -->
+          <div v-if="competitor.aiAnalysis.gaps?.length">
+            <div class="text-xs text-amber-400 font-medium mb-1.5">{{ t('competitors.analysisGaps') }}</div>
+            <ul class="space-y-1">
+              <li
+                v-for="gap in competitor.aiAnalysis.gaps"
+                :key="gap"
+                class="flex gap-1.5 items-start text-xs text-amber-200"
+              >
+                <span class="text-amber-400 mt-0.5 shrink-0">→</span>{{ gap }}
+              </li>
+            </ul>
+          </div>
+
+          <!-- Differentiation Moves -->
+          <div v-if="competitor.aiAnalysis.moves?.length">
+            <div class="text-xs text-green-400 font-medium mb-1.5">{{ t('competitors.analysisMoves') }}</div>
+            <ul class="space-y-1">
+              <li
+                v-for="move in competitor.aiAnalysis.moves"
+                :key="move"
+                class="flex gap-1.5 items-start text-xs text-green-200"
+              >
+                <span class="text-green-400 mt-0.5 shrink-0">✓</span>{{ move }}
+              </li>
+            </ul>
+          </div>
+        </div>
+
+        <!-- Legacy plain-text summary (for competitors analysed before this update) -->
+        <div v-else-if="competitor.aiSummary" class="mb-3 p-3 bg-gray-700/50 rounded border border-gray-600 text-sm text-gray-200">
           <div class="text-xs text-violet-400 font-medium mb-1">{{ t('competitors.aiSummaryLabel') }}</div>
           {{ competitor.aiSummary }}
         </div>