Преглед изворни кода

Strategy consistency audit for account profiles

New POST /profiles/:accountKey/audit endpoint checks a saved account profile
for internal inconsistencies using Porter's strategy consistency framework:
audience-tone mismatches, industry-keyword misalignment, hashtag-audience
conflicts, tone-guideline contradictions, and vague fields that weaken AI
content quality. Returns a score 0-100, summary, per-issue analysis with fix
recommendations, and a strengths list.

UI: "Audit Profile" button added next to the Save button in each expanded
profile card in Settings → Account Profiles. Results render inline below the
form — score badge (green/amber/red), one-sentence summary, colour-coded
issue cards (red/amber/gray by severity) each with field name, problem
description, and specific fix, plus a green strengths list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris пре 3 недеља
родитељ
комит
76c587d81c
4 измењених фајлова са 164 додато и 1 уклоњено
  1. 96 0
      services/gateway/server.js
  2. 4 0
      ui/src/locales/en.ts
  3. 4 0
      ui/src/locales/tr.ts
  4. 60 1
      ui/src/views/Settings.vue

+ 96 - 0
services/gateway/server.js

@@ -506,6 +506,102 @@ app.put('/profiles/:accountKey', async (request, reply) => {
   return { success: true };
 });
 
+// Strategy consistency audit — check if a profile's fields are internally coherent
+app.post('/profiles/:accountKey/audit', async (request, reply) => {
+  const { accountKey } = request.params;
+  const db = await getDb();
+  const profile = await db.collection('account_profiles').findOne({ _id: accountKey });
+  if (!profile) return reply.code(404).send({ error: 'Profile not found' });
+
+  const filled = [
+    profile.businessName, profile.description, profile.industry,
+    profile.toneOfVoice, profile.targetAudience, profile.keywords,
+    profile.hashtags, profile.postingGuidelines,
+  ].filter(Boolean);
+  if (filled.length < 3) {
+    return reply.code(400).send({ error: 'Fill in at least 3 profile fields before running an audit' });
+  }
+
+  const profileBlock = [
+    profile.businessName   && `Business: ${profile.businessName}`,
+    profile.description    && `Description: ${profile.description}`,
+    profile.industry       && `Industry: ${profile.industry}`,
+    profile.toneOfVoice    && `Tone of voice: ${profile.toneOfVoice}`,
+    profile.targetAudience && `Target audience: ${profile.targetAudience}`,
+    profile.keywords       && `Keywords: ${profile.keywords}`,
+    profile.hashtags       && `Preferred hashtags: ${profile.hashtags}`,
+    profile.postingGuidelines && `Posting guidelines: ${profile.postingGuidelines}`,
+  ].filter(Boolean).join('\n');
+
+  const system = 'You are a brand strategy consultant. Return only valid JSON with no explanation, no markdown code blocks.';
+  const prompt = `Audit the internal consistency of this social media account profile and identify any conflicts or misalignments.
+
+PROFILE:
+${profileBlock}
+
+Check for these issues:
+1. Audience-tone mismatch (e.g. B2B business using casual/Gen-Z tone)
+2. Industry-keyword misalignment (keywords don't reflect stated industry)
+3. Hashtag-audience misalignment (hashtags target a different audience than described)
+4. Tone-guideline conflicts (tone of voice contradicts posting guidelines)
+5. Missing critical context (fields that are vague and would hurt AI content quality)
+
+Return a JSON object:
+{
+  "score": <consistency score 0-100>,
+  "summary": "<2-3 sentence overall assessment>",
+  "issues": [
+    { "severity": "<high|medium|low>", "field": "<which field(s)>", "issue": "<what the conflict is>", "fix": "<specific recommendation>" }
+  ],
+  "strengths": ["<1-3 things the profile does well>"]
+}
+
+Return [] for issues if no problems found. Return ONLY the JSON object.`;
+
+  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: 90000 });
+      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: 90000 });
+      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: 90000 },
+      );
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    let result = null;
+    try {
+      const jsonStr = (text.match(/\{[\s\S]*\}/) || ['{}'])[0];
+      result = JSON.parse(jsonStr);
+      if (typeof result.score !== 'number') throw new Error();
+      if (!Array.isArray(result.issues)) result.issues = [];
+      if (!Array.isArray(result.strengths)) result.strengths = [];
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid audit format — try again' });
+    }
+
+    log.info({ action: 'profile_audit', accountKey, score: result.score, issues: result.issues.length, outcome: 'success' });
+    return { success: true, ...result };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Profile audit failed', detail: err.message });
+  }
+});
+
 // ─── AI / Multi-provider ─────────────────────────────────────────────────────
 
 const DEFAULT_OLLAMA_ENDPOINT = process.env.OLLAMA_ENDPOINT || 'http://ollama:11434';

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

@@ -315,6 +315,10 @@ export default {
       timezone: 'Timezone',
       timezoneHint: 'Used to schedule posts at the correct local time for this account.',
       timezoneAuto: 'Use browser timezone',
+      auditProfile: 'Audit Profile',
+      auditing: 'Auditing…',
+      auditTitle: 'Strategy Consistency Audit',
+      auditStrengths: 'Strengths',
     },
 
     hashtags: {

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

@@ -315,6 +315,10 @@ export default {
       timezone: 'Saat Dilimi',
       timezoneHint: 'Bu hesap için gönderilerin doğru yerel saatte zamanlanması için kullanılır.',
       timezoneAuto: 'Tarayıcı saat dilimini kullan',
+      auditProfile: 'Profil Denetle',
+      auditing: 'Denetleniyor…',
+      auditTitle: 'Strateji Tutarlılık Denetimi',
+      auditStrengths: 'Güçlü Yönler',
     },
 
     hashtags: {

+ 60 - 1
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="auditProfile(account.key)"
+                  :disabled="profileAuditing === account.key"
+                  class="px-3 py-2 bg-gray-700 hover:bg-gray-600 disabled:opacity-40 rounded-lg text-sm transition-colors flex items-center gap-1.5"
+                >
+                  <i class="fa-solid fa-magnifying-glass-chart text-xs" :class="{ 'animate-pulse': profileAuditing === account.key }"></i>
+                  {{ profileAuditing === account.key ? $t('settings.profiles.auditing') : $t('settings.profiles.auditProfile') }}
+                </button>
                 <button
                   @click="saveProfile(account.key)"
                   :disabled="profileSaving === account.key"
@@ -736,6 +744,43 @@
                 </button>
               </div>
 
+              <!-- Profile audit results -->
+              <div v-if="profileAudits[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">
+                    <span class="text-sm font-medium text-white">{{ $t('settings.profiles.auditTitle') }}</span>
+                    <span
+                      class="text-sm font-bold px-2 py-0.5 rounded-lg"
+                      :class="profileAudits[account.key].score >= 70 ? 'text-green-300 bg-green-900/40' : profileAudits[account.key].score >= 40 ? 'text-amber-300 bg-amber-900/40' : 'text-red-300 bg-red-900/40'"
+                    >{{ profileAudits[account.key].score }}/100</span>
+                  </div>
+                  <button @click="profileAudits[account.key] = null" class="text-xs text-gray-500 hover:text-gray-300">✕</button>
+                </div>
+                <p class="text-xs text-gray-400 mb-3">{{ profileAudits[account.key].summary }}</p>
+                <div v-if="profileAudits[account.key].issues?.length" class="space-y-2 mb-3">
+                  <div
+                    v-for="issue in profileAudits[account.key].issues"
+                    :key="issue.field"
+                    class="p-2.5 rounded-lg text-xs"
+                    :class="issue.severity === 'high' ? 'bg-red-900/30 border border-red-800/50' : issue.severity === 'medium' ? 'bg-amber-900/30 border border-amber-800/50' : 'bg-gray-800 border border-gray-700'"
+                  >
+                    <div class="flex items-center gap-1.5 mb-1">
+                      <span class="font-medium" :class="issue.severity === 'high' ? 'text-red-300' : issue.severity === 'medium' ? 'text-amber-300' : 'text-gray-300'">{{ issue.field }}</span>
+                    </div>
+                    <p class="text-gray-300 mb-1">{{ issue.issue }}</p>
+                    <p class="text-gray-400 italic">→ {{ issue.fix }}</p>
+                  </div>
+                </div>
+                <div v-if="profileAudits[account.key].strengths?.length">
+                  <div class="text-xs text-green-400 font-medium mb-1">{{ $t('settings.profiles.auditStrengths') }}</div>
+                  <ul class="space-y-0.5">
+                    <li v-for="s in profileAudits[account.key].strengths" :key="s" class="flex gap-1.5 text-xs text-green-200">
+                      <span class="text-green-400 shrink-0">✓</span>{{ s }}
+                    </li>
+                  </ul>
+                </div>
+              </div>
+
             </div>
           </div>
         </div>
@@ -1518,8 +1563,10 @@ function emptyProfile(): AccountProfile {
 
 const expandedProfileKey = ref<string | null>(null)
 const editingProfiles = ref<Record<string, AccountProfile>>({})
-const profileSaving = ref<string | null>(null)
+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 allConnectedAccounts = computed((): ProfileAccount[] => {
   const accounts: ProfileAccount[] = []
@@ -1578,6 +1625,18 @@ async function toggleProfile(key: string) {
   }
 }
 
+async function auditProfile(key: string) {
+  profileAuditing.value = key
+  try {
+    const res = await axios.post(`/api/profiles/${encodeURIComponent(key)}/audit`)
+    profileAudits.value = { ...profileAudits.value, [key]: res.data }
+  } catch (err: any) {
+    alert(err.response?.data?.error || 'Profile audit failed')
+  } finally {
+    profileAuditing.value = null
+  }
+}
+
 async function saveProfile(key: string) {
   profileSaving.value = key
   try {