소스 검색

Industry type diagnosis — AI archetype detection with content mix and tactics

POST /ai/industry-diagnosis classifies an account profile into one of 13
industry archetypes (B2B SaaS, E-commerce, Personal Brand, etc.) with a
confidence score, three ranked tactics with rationale, and a recommended
content-mix breakdown (educational / social proof / promotional / engagement).
Diagnosis is persisted on account_profiles.industryDiagnosis. Settings UI
adds a sky-blue Diagnose Industry button per account with a stacked results
panel including a segmented content-mix bar chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 주 전
부모
커밋
27305f410a
4개의 변경된 파일190개의 추가작업 그리고 2개의 파일을 삭제
  1. 99 0
      services/gateway/server.js
  2. 6 0
      ui/src/locales/en.ts
  3. 6 0
      ui/src/locales/tr.ts
  4. 79 2
      ui/src/views/Settings.vue

+ 99 - 0
services/gateway/server.js

@@ -3666,6 +3666,105 @@ No explanation, no markdown.`;
   }
 });
 
+// ─── Industry Type Diagnosis ─────────────────────────────────────────────────
+
+app.post('/ai/industry-diagnosis', 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 the profile first.' });
+
+  const fields = [profile.businessName, profile.description, profile.industry, profile.toneOfVoice].filter(Boolean);
+  if (fields.length < 2) return reply.code(400).send({ error: 'Fill in at least a business name and description before running diagnosis.' });
+
+  const profileBlock = [
+    profile.businessName ? `Business name: ${profile.businessName}` : '',
+    profile.description  ? `Description: ${profile.description}` : '',
+    profile.industry     ? `Self-reported industry: ${profile.industry}` : '',
+    profile.targetAudience ? `Target audience: ${profile.targetAudience}` : '',
+    profile.toneOfVoice  ? `Brand tone: ${profile.toneOfVoice}` : '',
+    profile.keywords     ? `Keywords: ${profile.keywords}` : '',
+    profile.postingGuidelines ? `Posting guidelines: ${profile.postingGuidelines}` : '',
+  ].filter(Boolean).join('\n');
+
+  const system = 'You are a social media strategy expert. Return only valid JSON with no markdown or explanation.';
+  const prompt = `Diagnose this brand's industry archetype based on the profile below and return strategic recommendations.
+
+${profileBlock}
+
+Industry archetypes to choose from (pick the closest fit):
+B2B SaaS, B2B Services, E-commerce (Product), Retail / Local Business, Hospitality / Food & Beverage,
+Personal Brand / Creator, Health & Wellness, Finance / Fintech, Education / EdTech, Non-profit,
+Media / Entertainment, Real Estate, Professional Services (Law / Consulting / Accounting).
+
+Return this exact JSON:
+{
+  "diagnosedType": "<archetype name>",
+  "confidence": <0-100>,
+  "summary": "<2-sentence explanation of why this archetype fits>",
+  "characteristics": ["<trait 1>", "<trait 2>", "<trait 3>"],
+  "tactics": [
+    { "title": "<tactic name>", "rationale": "<why it works for this archetype>", "action": "<specific concrete action>" },
+    { "title": "<tactic name>", "rationale": "<why it works>", "action": "<specific action>" },
+    { "title": "<tactic name>", "rationale": "<why it works>", "action": "<specific action>" }
+  ],
+  "contentMix": { "educational": <pct>, "social_proof": <pct>, "promotional": <pct>, "engagement": <pct> }
+}
+
+The contentMix percentages must sum to 100.
+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 (!result.diagnosedType) throw new Error();
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid diagnosis format — try again' });
+    }
+
+    // Persist the diagnosis on the account profile for future use
+    await db.collection('account_profiles').updateOne(
+      { _id: accountKey },
+      { $set: { industryDiagnosis: result, industryDiagnosedAt: new Date(), updatedAt: new Date() } },
+    );
+
+    log.info({ action: 'industry_diagnosis', account: accountKey, diagnosedType: result.diagnosedType, outcome: 'success' });
+    return { success: true, ...result, diagnosedAt: new Date() };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Industry diagnosis failed', detail: err.message });
+  }
+});
+
 // ─── Social Channel Audit ────────────────────────────────────────────────────
 
 app.post('/ai/channel-audit', async (request, reply) => {

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

@@ -337,6 +337,12 @@ export default {
       auditing: 'Auditing…',
       auditTitle: 'Strategy Consistency Audit',
       auditStrengths: 'Strengths',
+      diagnoseIndustry: 'Diagnose Industry',
+      diagnosing: 'Diagnosing…',
+      diagnosisTitle: 'Industry Archetype',
+      diagnosisCharacteristics: 'Characteristics',
+      diagnosisTactics: 'Recommended Tactics',
+      diagnosisContentMix: 'Recommended Content Mix',
     },
 
     hashtags: {

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

@@ -337,6 +337,12 @@ export default {
       auditing: 'Denetleniyor…',
       auditTitle: 'Strateji Tutarlılık Denetimi',
       auditStrengths: 'Güçlü Yönler',
+      diagnoseIndustry: 'Sektör Tanı',
+      diagnosing: 'Tanılanıyor…',
+      diagnosisTitle: 'Sektör Arketipi',
+      diagnosisCharacteristics: 'Özellikler',
+      diagnosisTactics: 'Önerilen Taktikler',
+      diagnosisContentMix: 'Önerilen İçerik Karması',
     },
 
     hashtags: {

+ 79 - 2
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="diagnoseIndustry(account.key)"
+                  :disabled="industryDiagnosing === account.key"
+                  class="px-3 py-2 bg-sky-800 hover:bg-sky-700 disabled:opacity-40 rounded-lg text-sm transition-colors flex items-center gap-1.5"
+                >
+                  <i class="fa-solid fa-flask text-xs" :class="{ 'animate-pulse': industryDiagnosing === account.key }"></i>
+                  {{ industryDiagnosing === account.key ? $t('settings.profiles.diagnosing') : $t('settings.profiles.diagnoseIndustry') }}
+                </button>
                 <button
                   @click="auditProfile(account.key)"
                   :disabled="profileAuditing === account.key"
@@ -781,6 +789,61 @@
                 </div>
               </div>
 
+              <!-- Industry diagnosis results -->
+              <div v-if="industryDiagnoses[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-flask text-xs text-sky-400"></i>
+                    <span class="text-sm font-medium text-white">{{ $t('settings.profiles.diagnosisTitle') }}</span>
+                    <span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-sky-900/50 border border-sky-700 text-sky-300">
+                      {{ industryDiagnoses[account.key].diagnosedType }}
+                    </span>
+                    <span class="text-xs text-gray-500">{{ industryDiagnoses[account.key].confidence }}% confidence</span>
+                  </div>
+                  <button @click="industryDiagnoses[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">{{ industryDiagnoses[account.key].summary }}</p>
+
+                <!-- Characteristics -->
+                <div v-if="industryDiagnoses[account.key].characteristics?.length" class="mb-3">
+                  <div class="text-xs font-medium text-gray-400 mb-1.5">{{ $t('settings.profiles.diagnosisCharacteristics') }}</div>
+                  <ul class="space-y-0.5">
+                    <li v-for="c in industryDiagnoses[account.key].characteristics" :key="c" class="flex gap-1.5 text-xs text-gray-300">
+                      <span class="text-sky-400 shrink-0">›</span>{{ c }}
+                    </li>
+                  </ul>
+                </div>
+
+                <!-- Content mix -->
+                <div v-if="industryDiagnoses[account.key].contentMix" class="mb-3">
+                  <div class="text-xs font-medium text-gray-400 mb-1.5">{{ $t('settings.profiles.diagnosisContentMix') }}</div>
+                  <div class="flex gap-1 h-3 rounded-full overflow-hidden">
+                    <div class="bg-blue-500" :style="{ width: industryDiagnoses[account.key].contentMix.educational + '%' }" :title="'Educational: ' + industryDiagnoses[account.key].contentMix.educational + '%'"></div>
+                    <div class="bg-green-500" :style="{ width: industryDiagnoses[account.key].contentMix.social_proof + '%' }" :title="'Social proof: ' + industryDiagnoses[account.key].contentMix.social_proof + '%'"></div>
+                    <div class="bg-amber-500" :style="{ width: industryDiagnoses[account.key].contentMix.promotional + '%' }" :title="'Promotional: ' + industryDiagnoses[account.key].contentMix.promotional + '%'"></div>
+                    <div class="bg-purple-500" :style="{ width: industryDiagnoses[account.key].contentMix.engagement + '%' }" :title="'Engagement: ' + industryDiagnoses[account.key].contentMix.engagement + '%'"></div>
+                  </div>
+                  <div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
+                    <span class="flex items-center gap-1 text-xs text-gray-500"><span class="w-2 h-2 rounded-sm bg-blue-500 inline-block"></span>Educational {{ industryDiagnoses[account.key].contentMix.educational }}%</span>
+                    <span class="flex items-center gap-1 text-xs text-gray-500"><span class="w-2 h-2 rounded-sm bg-green-500 inline-block"></span>Social proof {{ industryDiagnoses[account.key].contentMix.social_proof }}%</span>
+                    <span class="flex items-center gap-1 text-xs text-gray-500"><span class="w-2 h-2 rounded-sm bg-amber-500 inline-block"></span>Promotional {{ industryDiagnoses[account.key].contentMix.promotional }}%</span>
+                    <span class="flex items-center gap-1 text-xs text-gray-500"><span class="w-2 h-2 rounded-sm bg-purple-500 inline-block"></span>Engagement {{ industryDiagnoses[account.key].contentMix.engagement }}%</span>
+                  </div>
+                </div>
+
+                <!-- Tactics -->
+                <div v-if="industryDiagnoses[account.key].tactics?.length">
+                  <div class="text-xs font-medium text-gray-400 mb-1.5">{{ $t('settings.profiles.diagnosisTactics') }}</div>
+                  <div class="space-y-2">
+                    <div v-for="tactic in industryDiagnoses[account.key].tactics" :key="tactic.title" class="p-2.5 bg-gray-800 border border-gray-700 rounded-lg text-xs">
+                      <div class="font-medium text-sky-300 mb-0.5">{{ tactic.title }}</div>
+                      <p class="text-gray-400 mb-1">{{ tactic.rationale }}</p>
+                      <p class="text-gray-200 italic">→ {{ tactic.action }}</p>
+                    </div>
+                  </div>
+                </div>
+              </div>
+
             </div>
           </div>
         </div>
@@ -1607,8 +1670,10 @@ const expandedProfileKey = ref<string | null>(null)
 const editingProfiles = ref<Record<string, AccountProfile>>({})
 const profileSaving   = ref<string | null>(null)
 const profileSavedKey = ref<string | null>(null)
-const profileAuditing = ref<string | null>(null)
-const profileAudits   = ref<Record<string, any>>({})
+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 allConnectedAccounts = computed((): ProfileAccount[] => {
   const accounts: ProfileAccount[] = []
@@ -1702,6 +1767,18 @@ async function removePlacesKey() {
   placesKeyHint.value = null
 }
 
+async function diagnoseIndustry(key: string) {
+  industryDiagnosing.value = key
+  try {
+    const res = await axios.post('/api/ai/industry-diagnosis', { accountKey: key })
+    industryDiagnoses.value = { ...industryDiagnoses.value, [key]: res.data }
+  } catch (err: any) {
+    alert(err.response?.data?.error || 'Industry diagnosis failed')
+  } finally {
+    industryDiagnosing.value = null
+  }
+}
+
 async function auditProfile(key: string) {
   profileAuditing.value = key
   try {