소스 검색

Live community research before AI content generation

Adds POST /ai/research which scrapes Reddit for industry/keyword discussions
and AI-summarises them into a researchBrief (painPoints, trendingTopics,
communityLanguage, contentAngles) stored on account_profiles. The Compose
AI panel gains a "Research audience" button, expandable brief preview, and
"Include community research context" checkbox. When checked, /ai/stream and
/ai/generate inject the brief into the system prompt so generated posts use
real audience language instead of generic copy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 주 전
부모
커밋
46285de39e
5개의 변경된 파일234개의 추가작업 그리고 6개의 파일을 삭제
  1. 141 4
      services/gateway/server.js
  2. 8 0
      ui/src/locales/en.ts
  3. 8 0
      ui/src/locales/tr.ts
  4. 3 1
      ui/src/stores/ai.ts
  5. 74 1
      ui/src/views/Compose.vue

+ 141 - 4
services/gateway/server.js

@@ -814,13 +814,149 @@ app.get('/ai/models', async (request, reply) => {
   }
 });
 
+// ─── Community Research ───────────────────────────────────────────────────────
+
+async function fetchRedditSnippets(query, limit = 5) {
+  try {
+    const res = await axios.get(
+      `https://www.reddit.com/search.json?q=${encodeURIComponent(query)}&sort=hot&limit=${limit}&t=month`,
+      { headers: { 'User-Agent': 'SocialManager/1.0 (research bot)' }, timeout: 8000 },
+    );
+    return (res.data?.data?.children || [])
+      .map((p) => p.data)
+      .filter((d) => d.title)
+      .map((d) => `"${d.title}": ${(d.selftext || d.url || '').slice(0, 180)}`);
+  } catch { return []; }
+}
+
+// POST /ai/research — scrape Reddit for industry/keyword discussions, AI-summarise into brief
+// Stores researchBrief + researchBriefAt on account_profiles
+app.post('/ai/research', async (request, reply) => {
+  const { accountKey, topics } = request.body || {};
+  const db = await getDb();
+  const profileKey = accountKey || null;
+  const profile = profileKey
+    ? await db.collection('account_profiles').findOne({ _id: profileKey })
+    : await db.collection('account_profiles').findOne({});
+
+  const industry = profile?.industry || '';
+  const keywords = profile?.keywords || '';
+  const businessName = profile?.businessName || '';
+  const extraTopics = Array.isArray(topics) ? topics.slice(0, 3) : [];
+
+  // Build 3 search queries from profile context
+  const queries = [
+    industry ? `${industry} challenges pain points` : null,
+    keywords ? `${keywords} community discussion` : null,
+    businessName ? `${businessName} competitors alternatives` : null,
+    ...extraTopics.map((t) => String(t)),
+  ].filter(Boolean).slice(0, 4);
+
+  if (!queries.length) return reply.code(400).send({ error: 'No research context — fill in Industry or Keywords in your Account Profile first' });
+
+  const snippets = [];
+  await Promise.all(queries.map(async (q) => {
+    const results = await fetchRedditSnippets(q, 5);
+    snippets.push(...results);
+  }));
+
+  if (!snippets.length) {
+    return reply.code(503).send({ error: 'Could not fetch community data — Reddit may be temporarily unavailable' });
+  }
+
+  const system = 'You are an audience research analyst. Return ONLY valid JSON — no markdown, no explanation.';
+  const prompt = `Based on these community discussions about "${industry || keywords || businessName}":
+
+${snippets.slice(0, 20).map((s, i) => `${i + 1}. ${s}`).join('\n')}
+
+Extract a research brief with these exact fields:
+{
+  "painPoints": ["<3-5 real audience frustrations or challenges found in the discussions>"],
+  "trendingTopics": ["<3-5 topics the community is actively discussing right now>"],
+  "communityLanguage": ["<5-8 exact phrases, words, or terms the audience uses — quote them>"],
+  "contentAngles": ["<3-5 specific post angles that would resonate based on what you 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: 60000 });
+      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: 60000 });
+      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: 60000 },
+      );
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    let brief = null;
+    try {
+      const cleaned = text.replace(/```(?:json)?\s*/gi, '').replace(/```\s*/g, '');
+      const jsonStr = (cleaned.match(/\{[\s\S]*\}/) || ['{}'])[0];
+      brief = JSON.parse(jsonStr);
+      if (!brief.painPoints) throw new Error('Missing painPoints');
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid research format — try again' });
+    }
+
+    const updatedAt = new Date();
+    if (profileKey) {
+      await db.collection('account_profiles').updateOne(
+        { _id: profileKey },
+        { $set: { researchBrief: brief, researchBriefAt: updatedAt } },
+        { upsert: false },
+      );
+    }
+
+    log.info({ action: 'ai_research', accountKey: profileKey || 'any', snippets: snippets.length, outcome: 'success' });
+    return { brief, accountKey: profileKey, updatedAt };
+  } catch (err) {
+    log.error({ action: 'ai_research', outcome: 'failure', err: err.message });
+    return reply.code(503).send({ error: `Research failed: ${err.message}` });
+  }
+});
+
+// Helper: inject research brief into a system prompt when available
+async function buildResearchBriefSuffix(accountKey) {
+  if (!accountKey) return '';
+  try {
+    const db = await getDb();
+    const profile = await db.collection('account_profiles').findOne({ _id: accountKey });
+    const b = profile?.researchBrief;
+    if (!b) return '';
+    const parts = [];
+    if (b.painPoints?.length)       parts.push(`Audience pain points: ${b.painPoints.join('; ')}`);
+    if (b.trendingTopics?.length)   parts.push(`Trending topics right now: ${b.trendingTopics.join('; ')}`);
+    if (b.communityLanguage?.length) parts.push(`Language the audience uses: ${b.communityLanguage.join(', ')}`);
+    if (b.contentAngles?.length)    parts.push(`Proven content angles: ${b.contentAngles.join('; ')}`);
+    if (!parts.length) return '';
+    return '\n\nAUDIENCE RESEARCH CONTEXT (use this to make the post feel native to the community):\n' + parts.join('\n');
+  } catch { return ''; }
+}
+
 app.post('/ai/generate', async (request, reply) => {
-  const { prompt, system: rawSystem, model: reqModel, useCompetitorContext, destinations } = request.body || {};
+  const { prompt, system: rawSystem, model: reqModel, useCompetitorContext, useResearchBrief, accountKey, destinations } = request.body || {};
   if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
 
   const competitorSuffix = useCompetitorContext ? await buildCompetitorSystemSuffix() : '';
+  const researchSuffix = useResearchBrief ? await buildResearchBriefSuffix(accountKey) : '';
   const platformRules = buildPlatformRulesBlock(destinations);
-  const system = rawSystem ? rawSystem + competitorSuffix + platformRules : ((competitorSuffix + platformRules) || undefined);
+  const system = rawSystem ? rawSystem + competitorSuffix + researchSuffix + platformRules : ((competitorSuffix + researchSuffix + platformRules) || undefined);
 
   const pconf = await getActiveProviderConfig();
   const model = reqModel || pconf.model;
@@ -928,12 +1064,13 @@ app.post('/ai/caption', async (request, reply) => {
 
 // SSE streaming endpoint — normalized data: { token, done } format for all providers
 app.post('/ai/stream', async (request, reply) => {
-  const { prompt, system: rawSystem, model: reqModel, useCompetitorContext, destinations } = request.body || {};
+  const { prompt, system: rawSystem, model: reqModel, useCompetitorContext, useResearchBrief, accountKey, destinations } = request.body || {};
   if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
 
   const competitorSuffix = useCompetitorContext ? await buildCompetitorSystemSuffix() : '';
+  const researchSuffix = useResearchBrief ? await buildResearchBriefSuffix(accountKey) : '';
   const platformRules = buildPlatformRulesBlock(destinations);
-  const system = rawSystem ? rawSystem + competitorSuffix + platformRules : ((competitorSuffix + platformRules) || undefined);
+  const system = rawSystem ? rawSystem + competitorSuffix + researchSuffix + platformRules : ((competitorSuffix + researchSuffix + platformRules) || undefined);
 
   const pconf = await getActiveProviderConfig();
   const model = reqModel || pconf.model;

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

@@ -225,6 +225,14 @@ export default {
     firstCommentToggle: 'First Comment',
     firstCommentPlaceholder: 'Add a first comment (hashtags, links, extra context)…',
     firstCommentHint: 'Supported on Instagram, Facebook, Mastodon, and Bluesky.',
+
+    researchAudience: 'Research audience',
+    researching: 'Researching…',
+    researchBriefLabel: 'Brief',
+    researchJustNow: 'just now',
+    researchMinutesAgo: '{n}m ago',
+    researchHoursAgo: '{n}h ago',
+    useResearchBrief: 'Include community research context',
   },
 
   scheduler: {

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

@@ -225,6 +225,14 @@ export default {
     firstCommentToggle: 'İlk Yorum',
     firstCommentPlaceholder: 'İlk yorum ekle (hashtagler, bağlantılar, ek bilgi)…',
     firstCommentHint: 'Instagram, Facebook, Mastodon ve Bluesky\'de desteklenir.',
+
+    researchAudience: 'Kitleyi araştır',
+    researching: 'Araştırılıyor…',
+    researchBriefLabel: 'Özet',
+    researchJustNow: 'az önce',
+    researchMinutesAgo: '{n}d önce',
+    researchHoursAgo: '{n}s önce',
+    useResearchBrief: 'Topluluk araştırma bağlamını dahil et',
   },
 
   scheduler: {

+ 3 - 1
ui/src/stores/ai.ts

@@ -175,11 +175,13 @@ export const useAiStore = defineStore('ai', () => {
     signal?: AbortSignal,
     useCompetitorContext?: boolean,
     destinations?: { platform: string; key: string }[],
+    useResearchBrief?: boolean,
+    accountKey?: string,
   ): AsyncGenerator<string> {
     const response = await fetch('/api/ai/stream', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ prompt, system, model, useCompetitorContext, destinations }),
+      body: JSON.stringify({ prompt, system, model, useCompetitorContext, destinations, useResearchBrief, accountKey }),
       signal,
     })
 

+ 74 - 1
ui/src/views/Compose.vue

@@ -281,6 +281,45 @@
                 </label>
                 <span class="text-gray-600">— {{ $t('compose.aiUseCompetitorsHint', { names: competitorNames }) }}</span>
               </div>
+
+              <!-- Community research strip -->
+              <div class="border-t border-violet-900/30 pt-2 space-y-2">
+                <div class="flex items-center gap-2 flex-wrap">
+                  <button
+                    @click="researchAudience"
+                    :disabled="researchLoading || !aiContextAccount"
+                    class="flex items-center gap-1 text-xs px-2.5 py-1 bg-gray-800 hover:bg-gray-700 disabled:opacity-40 border border-gray-700 rounded-lg transition-colors text-gray-300"
+                  >
+                    <i class="fa-solid fa-magnifying-glass-chart text-[10px]" :class="{ 'animate-pulse': researchLoading }"></i>
+                    {{ researchLoading ? $t('compose.researching') : $t('compose.researchAudience') }}
+                  </button>
+                  <span v-if="researchBriefAt" class="text-xs text-gray-600">{{ researchBriefAgeLabel() }}</span>
+                  <button
+                    v-if="researchBrief"
+                    @click="researchBriefOpen = !researchBriefOpen"
+                    class="ml-auto text-xs text-gray-600 hover:text-gray-400"
+                  >{{ researchBriefOpen ? '▲' : '▼' }} {{ $t('compose.researchBriefLabel') }}</button>
+                </div>
+
+                <!-- Brief preview -->
+                <div v-if="researchBrief && researchBriefOpen" class="bg-gray-800/60 rounded-lg p-2.5 text-xs text-gray-300 space-y-1.5">
+                  <div v-if="researchBrief.painPoints?.length">
+                    <span class="text-gray-500 font-medium">Pain points: </span>{{ researchBrief.painPoints.slice(0, 3).join(' · ') }}
+                  </div>
+                  <div v-if="researchBrief.contentAngles?.length">
+                    <span class="text-gray-500 font-medium">Content angles: </span>{{ researchBrief.contentAngles.slice(0, 3).join(' · ') }}
+                  </div>
+                  <div v-if="researchBrief.communityLanguage?.length">
+                    <span class="text-gray-500 font-medium">Community language: </span>{{ researchBrief.communityLanguage.slice(0, 5).join(', ') }}
+                  </div>
+                </div>
+
+                <!-- Use brief checkbox -->
+                <div v-if="researchBrief" class="flex items-center gap-2 text-xs text-gray-400">
+                  <input id="useResearchCtx" v-model="useResearchBrief" type="checkbox" class="accent-violet-500" />
+                  <label for="useResearchCtx" class="cursor-pointer select-none">{{ $t('compose.useResearchBrief') }}</label>
+                </div>
+              </div>
             </template>
           </div>
         </div>
@@ -710,6 +749,11 @@ const aiError = ref(false)
 const aiContextAccount = ref('')
 const abortController = ref<AbortController | null>(null)
 const useCompetitorContext = ref(false)
+const useResearchBrief = ref(false)
+const researchLoading = ref(false)
+const researchBrief = ref<{ painPoints: string[]; trendingTopics: string[]; communityLanguage: string[]; contentAngles: string[] } | null>(null)
+const researchBriefAt = ref<Date | null>(null)
+const researchBriefOpen = ref(false)
 
 const hasCompetitorSummaries = computed(() =>
   competitorStore.competitors.some((c) => c.aiSummary?.trim())
@@ -777,6 +821,31 @@ function buildSystemPrompt(profile: Record<string, string>): string {
   return lines.join('\n')
 }
 
+async function researchAudience() {
+  const firstDest = composeStore.selectedDestinations[0]
+  researchLoading.value = true
+  try {
+    const res = await axios.post('/api/ai/research', { accountKey: firstDest?.key })
+    researchBrief.value = res.data.brief
+    researchBriefAt.value = new Date(res.data.updatedAt)
+    researchBriefOpen.value = true
+    useResearchBrief.value = true
+  } catch (err: any) {
+    console.error('Research failed:', err)
+  } finally {
+    researchLoading.value = false
+  }
+}
+
+function researchBriefAgeLabel(): string {
+  if (!researchBriefAt.value) return ''
+  const mins = Math.round((Date.now() - researchBriefAt.value.getTime()) / 60000)
+  if (mins < 1) return t('compose.researchJustNow')
+  if (mins < 60) return t('compose.researchMinutesAgo', { n: mins })
+  const hrs = Math.round(mins / 60)
+  return t('compose.researchHoursAgo', { n: hrs })
+}
+
 async function generatePost() {
   aiError.value = false
   const firstDest = composeStore.selectedDestinations[0]
@@ -789,7 +858,11 @@ async function generatePost() {
   composeStore.content = ''
 
   try {
-    const gen = aiStore.streamGenerate(prompt, system, undefined, abortController.value.signal, useCompetitorContext.value, composeStore.selectedDestinations)
+    const gen = aiStore.streamGenerate(
+      prompt, system, undefined, abortController.value.signal,
+      useCompetitorContext.value, composeStore.selectedDestinations,
+      useResearchBrief.value, firstDest?.key,
+    )
     for await (const token of gen) {
       composeStore.content += token
     }