mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 15:04:14 +00:00
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
48 KiB
48 KiB
Community Management Phase 2.2 – YouTube Auto-Sync & AI Reply Suggestions
Projekt-Kontext
Du arbeitest am Community Management System für Complex Care Solutions (CCS).
Abgeschlossene Phasen:
- ✅ Phase 1: Community Inbox, YouTube OAuth, Rules Engine, Export, Claude Sentiment-Analyse
- ✅ Phase 2.1: Analytics Dashboard mit 6 Komponenten
Deine Aufgabe: Implementiere automatischen YouTube-Kommentar-Sync und intelligente AI-Antwortvorschläge.
Tech Stack
| Technologie | Version |
|---|---|
| Payload CMS | 3.x |
| Next.js | 15.x (App Router) |
| React | 19.x |
| TypeScript | 5.x |
| Datenbank | PostgreSQL 17 |
| AI Service | Claude (Anthropic) – NICHT OpenAI |
| YouTube API | googleapis v140+ |
Bestehende Infrastruktur
YouTube OAuth (bereits implementiert)
// src/lib/integrations/youtube/oauth.ts
// - getAuthUrl(): string
// - exchangeCodeForTokens(code: string): Promise<Tokens>
// - refreshAccessToken(refreshToken: string): Promise<Tokens>
// src/lib/integrations/youtube/YouTubeClient.ts
// - getComments(videoId: string): Promise<Comment[]>
// - replyToComment(commentId: string, text: string): Promise<void>
Claude Integration (bereits implementiert)
// src/lib/integrations/claude/ClaudeAnalysisService.ts
// - analyzeSentiment(message: string): Promise<SentimentResult>
// - extractTopics(message: string): Promise<string[]>
// - detectMedicalContent(message: string): Promise<boolean>
Collections
| Collection | Slug | Relevante Felder |
|---|---|---|
| SocialAccounts | social-accounts | platform, channelId, accessToken, refreshToken, tokenExpiresAt, isActive, lastSyncAt |
| CommunityInteractions | community-interactions | externalId, platform, socialAccount, linkedContent, analysis, status |
| YouTubeContent | youtube-content | videoId, title, publishedAt, channelId |
Teil 1: YouTube Auto-Sync
1.1 Architektur
┌─────────────────────────────────────────────────────────────────┐
│ SYNC ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Cron Job │────▶│ Sync │────▶│ YouTube │ │
│ │ (15 min) │ │ Service │ │ API │ │
│ └─────────────┘ └──────┬──────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Claude │ │
│ │ Analysis │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Database │ │
│ │ (Payload) │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
1.2 Dateistruktur erstellen
src/
├── lib/
│ ├── integrations/
│ │ ├── youtube/
│ │ │ ├── oauth.ts # Bestehend
│ │ │ ├── YouTubeClient.ts # Erweitern
│ │ │ └── CommentsSyncService.ts # NEU
│ │ └── claude/
│ │ ├── ClaudeAnalysisService.ts # Bestehend
│ │ └── ClaudeReplyService.ts # NEU
│ └── jobs/
│ ├── syncAllComments.ts # NEU - Cron Entry Point
│ └── JobLogger.ts # NEU - Logging
├── app/(payload)/api/
│ ├── community/
│ │ └── sync/
│ │ ├── route.ts # Manueller Sync Trigger
│ │ └── status/route.ts # Sync Status
│ └── cron/
│ └── youtube-sync/route.ts # Cron Endpoint (Vercel/externe Cron)
└── types/
└── youtube.ts # Type Definitions
1.3 Type Definitions
// src/types/youtube.ts
export interface YouTubeComment {
id: string
snippet: {
videoId: string
topLevelComment: {
id: string
snippet: {
textDisplay: string
textOriginal: string
authorDisplayName: string
authorProfileImageUrl: string
authorChannelId: { value: string }
likeCount: number
publishedAt: string
updatedAt: string
}
}
totalReplyCount: number
canReply: boolean
}
}
export interface YouTubeCommentThread {
kind: string
etag: string
id: string
snippet: YouTubeComment['snippet']
replies?: {
comments: Array<{
id: string
snippet: {
textDisplay: string
textOriginal: string
authorDisplayName: string
authorProfileImageUrl: string
authorChannelId: { value: string }
parentId: string
likeCount: number
publishedAt: string
}
}>
}
}
export interface SyncResult {
accountId: number
accountName: string
videosProcessed: number
newComments: number
updatedComments: number
errors: string[]
duration: number
}
export interface SyncJobStatus {
isRunning: boolean
lastRunAt: string | null
lastResult: SyncResult[] | null
nextRunAt: string | null
}
1.4 YouTubeClient erweitern
// src/lib/integrations/youtube/YouTubeClient.ts
import { google, youtube_v3 } from 'googleapis'
import { getPayload } from 'payload'
import config from '@payload-config'
import type { YouTubeCommentThread } from '@/types/youtube'
export class YouTubeClient {
private youtube: youtube_v3.Youtube
private accountId: number
constructor(accessToken: string, accountId: number) {
const auth = new google.auth.OAuth2()
auth.setCredentials({ access_token: accessToken })
this.youtube = google.youtube({ version: 'v3', auth })
this.accountId = accountId
}
/**
* Hole alle Kommentare für einen Kanal (über alle Videos)
*/
async getChannelComments(
channelId: string,
options: {
maxResults?: number
publishedAfter?: Date
pageToken?: string
} = {}
): Promise<{
comments: YouTubeCommentThread[]
nextPageToken?: string
}> {
const { maxResults = 100, publishedAfter, pageToken } = options
try {
const response = await this.youtube.commentThreads.list({
part: ['snippet', 'replies'],
allThreadsRelatedToChannelId: channelId,
maxResults,
pageToken,
order: 'time',
// Wenn publishedAfter gesetzt, nur neuere Kommentare
...(publishedAfter && {
searchTerms: '', // Workaround: API hat keinen direkten Filter
}),
})
const comments = (response.data.items || []) as YouTubeCommentThread[]
// Manuell nach Datum filtern wenn nötig
const filteredComments = publishedAfter
? comments.filter(c =>
new Date(c.snippet.topLevelComment.snippet.publishedAt) > publishedAfter
)
: comments
return {
comments: filteredComments,
nextPageToken: response.data.nextPageToken || undefined,
}
} catch (error: any) {
console.error('YouTube API Error:', error.message)
throw new Error(`Failed to fetch comments: ${error.message}`)
}
}
/**
* Hole Kommentare für ein spezifisches Video
*/
async getVideoComments(
videoId: string,
options: {
maxResults?: number
pageToken?: string
} = {}
): Promise<{
comments: YouTubeCommentThread[]
nextPageToken?: string
}> {
const { maxResults = 100, pageToken } = options
try {
const response = await this.youtube.commentThreads.list({
part: ['snippet', 'replies'],
videoId,
maxResults,
pageToken,
order: 'time',
})
return {
comments: (response.data.items || []) as YouTubeCommentThread[],
nextPageToken: response.data.nextPageToken || undefined,
}
} catch (error: any) {
// Video hat möglicherweise Kommentare deaktiviert
if (error.code === 403) {
console.warn(`Comments disabled for video ${videoId}`)
return { comments: [] }
}
throw error
}
}
/**
* Antwort auf einen Kommentar posten
*/
async replyToComment(parentId: string, text: string): Promise<string> {
try {
const response = await this.youtube.comments.insert({
part: ['snippet'],
requestBody: {
snippet: {
parentId,
textOriginal: text,
},
},
})
return response.data.id || ''
} catch (error: any) {
console.error('Failed to post reply:', error.message)
throw new Error(`Failed to post reply: ${error.message}`)
}
}
/**
* Prüfe ob Token noch gültig, sonst refreshen
*/
static async getValidClient(accountId: number): Promise<YouTubeClient | null> {
const payload = await getPayload({ config })
const account = await payload.findByID({
collection: 'social-accounts',
id: accountId,
})
if (!account || !account.accessToken) {
return null
}
// Token abgelaufen?
const now = new Date()
const expiresAt = account.tokenExpiresAt ? new Date(account.tokenExpiresAt) : null
if (expiresAt && expiresAt <= now) {
// Token refreshen
if (!account.refreshToken) {
console.error(`No refresh token for account ${accountId}`)
return null
}
try {
const { refreshAccessToken } = await import('./oauth')
const newTokens = await refreshAccessToken(account.refreshToken)
// Update in DB
await payload.update({
collection: 'social-accounts',
id: accountId,
data: {
accessToken: newTokens.access_token,
tokenExpiresAt: new Date(Date.now() + (newTokens.expires_in || 3600) * 1000).toISOString(),
},
})
return new YouTubeClient(newTokens.access_token, accountId)
} catch (error) {
console.error(`Failed to refresh token for account ${accountId}:`, error)
return null
}
}
return new YouTubeClient(account.accessToken, accountId)
}
}
1.5 CommentsSyncService
// src/lib/integrations/youtube/CommentsSyncService.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { YouTubeClient } from './YouTubeClient'
import { ClaudeAnalysisService } from '../claude/ClaudeAnalysisService'
import type { YouTubeCommentThread, SyncResult } from '@/types/youtube'
export class CommentsSyncService {
private payload: Awaited<ReturnType<typeof getPayload>>
private claude: ClaudeAnalysisService
constructor() {
this.claude = new ClaudeAnalysisService()
}
private async getPayload() {
if (!this.payload) {
this.payload = await getPayload({ config })
}
return this.payload
}
/**
* Sync alle aktiven YouTube-Accounts
*/
async syncAllAccounts(): Promise<SyncResult[]> {
const payload = await this.getPayload()
const results: SyncResult[] = []
// Finde alle aktiven YouTube-Accounts
const accounts = await payload.find({
collection: 'social-accounts',
where: {
and: [
{ isActive: { equals: true } },
{ 'platform.slug': { equals: 'youtube' } },
],
},
limit: 100,
})
console.log(`[Sync] Found ${accounts.docs.length} active YouTube accounts`)
for (const account of accounts.docs) {
const startTime = Date.now()
const result: SyncResult = {
accountId: account.id,
accountName: account.displayName || `Account ${account.id}`,
videosProcessed: 0,
newComments: 0,
updatedComments: 0,
errors: [],
duration: 0,
}
try {
const client = await YouTubeClient.getValidClient(account.id)
if (!client) {
result.errors.push('Could not get valid YouTube client')
results.push(result)
continue
}
// Hole letzte Sync-Zeit
const lastSyncAt = account.lastSyncAt
? new Date(account.lastSyncAt)
: new Date(Date.now() - 24 * 60 * 60 * 1000) // Default: letzte 24h
// Sync Kommentare für diesen Account
const syncResult = await this.syncAccountComments(
client,
account,
lastSyncAt
)
result.videosProcessed = syncResult.videosProcessed
result.newComments = syncResult.newComments
result.updatedComments = syncResult.updatedComments
result.errors = syncResult.errors
// Update lastSyncAt
await payload.update({
collection: 'social-accounts',
id: account.id,
data: {
lastSyncAt: new Date().toISOString(),
},
})
} catch (error: any) {
result.errors.push(error.message)
console.error(`[Sync] Error syncing account ${account.id}:`, error)
}
result.duration = Date.now() - startTime
results.push(result)
}
return results
}
/**
* Sync Kommentare für einen spezifischen Account
*/
private async syncAccountComments(
client: YouTubeClient,
account: any,
since: Date
): Promise<{
videosProcessed: number
newComments: number
updatedComments: number
errors: string[]
}> {
const payload = await this.getPayload()
const errors: string[] = []
let videosProcessed = 0
let newComments = 0
let updatedComments = 0
// Hole alle Videos dieses Kanals aus der DB
const videos = await payload.find({
collection: 'youtube-content',
where: {
channelId: { equals: account.channelId },
},
limit: 50, // Letzte 50 Videos
sort: '-publishedAt',
})
console.log(`[Sync] Processing ${videos.docs.length} videos for ${account.displayName}`)
for (const video of videos.docs) {
try {
let pageToken: string | undefined
let pageCount = 0
const maxPages = 5 // Max 500 Kommentare pro Video
do {
const { comments, nextPageToken } = await client.getVideoComments(
video.videoId,
{ maxResults: 100, pageToken }
)
for (const thread of comments) {
const result = await this.processCommentThread(
thread,
account,
video
)
if (result === 'new') newComments++
else if (result === 'updated') updatedComments++
}
pageToken = nextPageToken
pageCount++
// Rate limiting
await this.sleep(100)
} while (pageToken && pageCount < maxPages)
videosProcessed++
} catch (error: any) {
errors.push(`Video ${video.videoId}: ${error.message}`)
}
// Rate limiting zwischen Videos
await this.sleep(200)
}
return { videosProcessed, newComments, updatedComments, errors }
}
/**
* Verarbeite einen Kommentar-Thread
*/
private async processCommentThread(
thread: YouTubeCommentThread,
account: any,
video: any
): Promise<'new' | 'updated' | 'skipped'> {
const payload = await this.getPayload()
const comment = thread.snippet.topLevelComment.snippet
// Prüfe ob Kommentar bereits existiert
const existing = await payload.find({
collection: 'community-interactions',
where: {
externalId: { equals: thread.id },
},
limit: 1,
})
// Analysiere mit Claude (nur für neue Kommentare)
let analysis = null
if (existing.docs.length === 0) {
try {
analysis = await this.analyzeComment(comment.textOriginal)
} catch (error) {
console.error('[Sync] Analysis failed:', error)
// Fallback-Analyse
analysis = {
sentiment: 'neutral',
sentimentScore: 0,
confidence: 50,
topics: [],
isMedicalQuestion: false,
suggestedReply: null,
}
}
}
const interactionData = {
platform: account.platform.id || account.platform,
socialAccount: account.id,
linkedContent: video.id,
type: 'comment' as const,
externalId: thread.id,
externalParentId: null,
author: {
externalId: comment.authorChannelId?.value || '',
name: comment.authorDisplayName,
handle: comment.authorDisplayName,
avatarUrl: comment.authorProfileImageUrl,
isVerified: false,
isSubscriber: false, // Müsste separat geprüft werden
subscriberCount: 0,
},
message: comment.textOriginal,
publishedAt: comment.publishedAt,
engagement: {
likes: comment.likeCount || 0,
replies: thread.snippet.totalReplyCount || 0,
},
...(analysis && {
analysis: {
sentiment: analysis.sentiment,
sentimentScore: analysis.sentimentScore,
confidence: analysis.confidence,
topics: analysis.topics.map((t: string) => ({ topic: t })),
suggestedReply: analysis.suggestedReply,
},
flags: {
isMedicalQuestion: analysis.isMedicalQuestion,
requiresEscalation: analysis.isMedicalQuestion, // Med. Fragen = Eskalation
isSpam: false,
isFromInfluencer: false,
},
}),
status: 'new' as const,
priority: analysis?.isMedicalQuestion ? 'high' as const : 'normal' as const,
}
if (existing.docs.length === 0) {
// Neuer Kommentar
await payload.create({
collection: 'community-interactions',
data: interactionData,
})
// Verarbeite auch Replies
if (thread.replies?.comments) {
for (const reply of thread.replies.comments) {
await this.processReply(reply, thread.id, account, video)
}
}
return 'new'
} else {
// Update Engagement-Zahlen
const existingDoc = existing.docs[0]
if (
existingDoc.engagement?.likes !== comment.likeCount ||
existingDoc.engagement?.replies !== thread.snippet.totalReplyCount
) {
await payload.update({
collection: 'community-interactions',
id: existingDoc.id,
data: {
engagement: {
likes: comment.likeCount || 0,
replies: thread.snippet.totalReplyCount || 0,
},
},
})
return 'updated'
}
}
return 'skipped'
}
/**
* Verarbeite eine Reply auf einen Kommentar
*/
private async processReply(
reply: any,
parentId: string,
account: any,
video: any
): Promise<void> {
const payload = await this.getPayload()
const snippet = reply.snippet
// Prüfe ob bereits existiert
const existing = await payload.find({
collection: 'community-interactions',
where: {
externalId: { equals: reply.id },
},
limit: 1,
})
if (existing.docs.length > 0) return
// Prüfe ob es unsere eigene Antwort ist (dann überspringen)
if (snippet.authorChannelId?.value === account.channelId) {
return
}
// Analysiere
let analysis = null
try {
analysis = await this.analyzeComment(snippet.textOriginal)
} catch (error) {
analysis = {
sentiment: 'neutral',
sentimentScore: 0,
confidence: 50,
topics: [],
isMedicalQuestion: false,
suggestedReply: null,
}
}
await payload.create({
collection: 'community-interactions',
data: {
platform: account.platform.id || account.platform,
socialAccount: account.id,
linkedContent: video.id,
type: 'reply',
externalId: reply.id,
externalParentId: parentId,
author: {
externalId: snippet.authorChannelId?.value || '',
name: snippet.authorDisplayName,
handle: snippet.authorDisplayName,
avatarUrl: snippet.authorProfileImageUrl,
isVerified: false,
isSubscriber: false,
subscriberCount: 0,
},
message: snippet.textOriginal,
publishedAt: snippet.publishedAt,
engagement: {
likes: snippet.likeCount || 0,
replies: 0,
},
analysis: {
sentiment: analysis.sentiment,
sentimentScore: analysis.sentimentScore,
confidence: analysis.confidence,
topics: analysis.topics.map((t: string) => ({ topic: t })),
suggestedReply: analysis.suggestedReply,
},
flags: {
isMedicalQuestion: analysis.isMedicalQuestion,
requiresEscalation: analysis.isMedicalQuestion,
isSpam: false,
isFromInfluencer: false,
},
status: 'new',
priority: analysis.isMedicalQuestion ? 'high' : 'normal',
},
})
}
/**
* Analysiere Kommentar mit Claude
*/
private async analyzeComment(text: string): Promise<{
sentiment: string
sentimentScore: number
confidence: number
topics: string[]
isMedicalQuestion: boolean
suggestedReply: string | null
}> {
return await this.claude.analyzeInteraction(text)
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
1.6 Cron Job Entry Point
// src/lib/jobs/syncAllComments.ts
import { CommentsSyncService } from '../integrations/youtube/CommentsSyncService'
import { JobLogger } from './JobLogger'
let isRunning = false
let lastRunAt: Date | null = null
let lastResult: any = null
export async function runSync(): Promise<{
success: boolean
results?: any[]
error?: string
}> {
if (isRunning) {
return { success: false, error: 'Sync already running' }
}
isRunning = true
const logger = new JobLogger('youtube-sync')
try {
logger.info('Starting YouTube comments sync')
const syncService = new CommentsSyncService()
const results = await syncService.syncAllAccounts()
lastRunAt = new Date()
lastResult = results
const totalNew = results.reduce((sum, r) => sum + r.newComments, 0)
const totalUpdated = results.reduce((sum, r) => sum + r.updatedComments, 0)
const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0)
logger.info(`Sync completed: ${totalNew} new, ${totalUpdated} updated, ${totalErrors} errors`)
return { success: true, results }
} catch (error: any) {
logger.error('Sync failed', error)
return { success: false, error: error.message }
} finally {
isRunning = false
}
}
export function getSyncStatus() {
return {
isRunning,
lastRunAt: lastRunAt?.toISOString() || null,
lastResult,
}
}
1.7 Job Logger
// src/lib/jobs/JobLogger.ts
export class JobLogger {
private jobName: string
constructor(jobName: string) {
this.jobName = jobName
}
info(message: string, data?: any) {
console.log(`[${this.timestamp()}] [${this.jobName}] INFO: ${message}`, data || '')
}
warn(message: string, data?: any) {
console.warn(`[${this.timestamp()}] [${this.jobName}] WARN: ${message}`, data || '')
}
error(message: string, error?: any) {
console.error(`[${this.timestamp()}] [${this.jobName}] ERROR: ${message}`, error || '')
}
private timestamp(): string {
return new Date().toISOString()
}
}
1.8 API Endpoints für Sync
// src/app/(payload)/api/cron/youtube-sync/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments'
// Geheimer Token für Cron-Authentifizierung
const CRON_SECRET = process.env.CRON_SECRET
export async function GET(request: NextRequest) {
// Auth prüfen
const authHeader = request.headers.get('authorization')
if (CRON_SECRET && authHeader !== `Bearer ${CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await runSync()
return NextResponse.json(result, {
status: result.success ? 200 : 500,
})
}
// Status-Endpoint
export async function HEAD() {
const status = getSyncStatus()
return new NextResponse(null, {
status: status.isRunning ? 423 : 200,
headers: {
'X-Sync-Running': status.isRunning.toString(),
'X-Last-Run': status.lastRunAt || 'never',
},
})
}
// src/app/(payload)/api/community/sync/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments'
// Manueller Sync Trigger (aus Admin Panel)
export async function POST(request: NextRequest) {
const result = await runSync()
return NextResponse.json(result, {
status: result.success ? 200 : 500,
})
}
// Status abfragen
export async function GET() {
const status = getSyncStatus()
return NextResponse.json(status)
}
Teil 2: AI Reply Suggestions
2.1 ClaudeReplyService
// src/lib/integrations/claude/ClaudeReplyService.ts
import Anthropic from '@anthropic-ai/sdk'
import { getPayload } from 'payload'
import config from '@payload-config'
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
interface ReplyContext {
channelName: string
channelType: 'corporate' | 'lifestyle' | 'business'
videoTitle: string
videoTopics: string[]
commentText: string
commentSentiment: string
authorName: string
isMedicalQuestion: boolean
previousReplies?: string[]
}
interface GeneratedReply {
text: string
tone: string
confidence: number
alternatives: string[]
warnings: string[]
}
export class ClaudeReplyService {
/**
* Generiere Antwortvorschlag basierend auf Kontext
*/
async generateReply(context: ReplyContext): Promise<GeneratedReply> {
const systemPrompt = this.buildSystemPrompt(context)
const userPrompt = this.buildUserPrompt(context)
try {
const response = await anthropic.messages.create({
model: 'claude-3-haiku-20240307', // Schnell & kostengünstig
max_tokens: 500,
system: systemPrompt,
messages: [
{ role: 'user', content: userPrompt }
],
})
const content = response.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type')
}
return this.parseReplyResponse(content.text, context)
} catch (error: any) {
console.error('[ClaudeReply] Generation failed:', error)
throw error
}
}
/**
* Generiere mehrere Varianten für A/B-Testing
*/
async generateReplyVariants(
context: ReplyContext,
count: number = 3
): Promise<GeneratedReply[]> {
const variants: GeneratedReply[] = []
const tones = ['freundlich', 'professionell', 'empathisch']
for (let i = 0; i < Math.min(count, tones.length); i++) {
const contextWithTone = { ...context, requestedTone: tones[i] }
const reply = await this.generateReply(contextWithTone)
reply.tone = tones[i]
variants.push(reply)
// Rate limiting
await this.sleep(100)
}
return variants
}
private buildSystemPrompt(context: ReplyContext): string {
const basePrompt = `Du bist der Community Manager für "${context.channelName}", einen deutschen YouTube-Kanal.
KANAL-KONTEXT:
${this.getChannelContext(context.channelType)}
WICHTIGE REGELN:
1. Antworte IMMER auf Deutsch
2. Verwende "Du" (nicht "Sie")
3. Halte Antworten kurz (max. 2-3 Sätze für normale Kommentare)
4. Unterschreibe mit "— ${this.getSignature(context.channelType)}"
5. Sei authentisch, nicht roboterhaft
6. Verwende KEINE Emojis außer: ❤️ 👍 😊 (sparsam)
${context.isMedicalQuestion ? this.getMedicalWarning() : ''}
TONALITÄT:
${this.getToneGuidelines(context.channelType, context.commentSentiment)}
`
return basePrompt
}
private buildUserPrompt(context: ReplyContext): string {
return `KOMMENTAR VON "${context.authorName}":
"${context.commentText}"
VIDEO: "${context.videoTitle}"
SENTIMENT: ${context.commentSentiment}
${context.isMedicalQuestion ? '⚠️ MEDIZINISCHE FRAGE ERKANNT' : ''}
Generiere eine passende Antwort. Antworte NUR mit dem Antworttext, keine Erklärungen.`
}
private getChannelContext(channelType: string): string {
switch (channelType) {
case 'corporate':
return `Kanal: zweitmeinu.ng (Complex Care Solutions)
Thema: Medizinische Zweitmeinungen, Patientenrechte, Gesundheitsentscheidungen
Zielgruppe: Patienten, Angehörige, Krankenkassen
Persona: Dr. Caroline Porwoll (Gesundheitsberaterin, NICHT Ärztin)
Wichtig: Keine medizinische Beratung geben, auf Hotline verweisen`
case 'lifestyle':
return `Kanal: BlogWoman by Caroline Porwoll
Thema: Premium-Lifestyle für Karrierefrauen (Mode, Systeme, Regeneration)
Zielgruppe: Berufstätige Mütter 35-45
Persona: Caroline Porwoll (Unternehmerin, Mutter, Stilexpertin)
Wichtig: Editorial-Premium-Ton, keine Influencer-Sprache`
case 'business':
return `Kanal: CCS Business (English)
Thema: B2B Second Opinion Programs, ROI, Healthcare Governance
Zielgruppe: International Healthcare Decision-Makers
Persona: Dr. Caroline Porwoll (CEO, Healthcare Expert)
Wichtig: Professionell, faktenbasiert, auf Demo/Whitepaper verweisen`
default:
return 'Allgemeiner YouTube-Kanal'
}
}
private getToneGuidelines(channelType: string, sentiment: string): string {
const baseGuidelines: Record<string, string> = {
'corporate': 'Warm, empathisch, ermutigend, professionell',
'lifestyle': 'Editorial Premium, systemorientiert, "Performance & Pleasure"',
'business': 'Professional, fact-based, ROI-oriented',
}
const sentimentAdjustments: Record<string, string> = {
'positive': 'Teile die Freude, bedanke dich herzlich',
'negative': 'Zeige Verständnis, biete Hilfe an, eskaliere nicht',
'question': 'Beantworte klar und hilfreich, verweise auf Ressourcen',
'gratitude': 'Bedanke dich aufrichtig, zeige dass es geschätzt wird',
'frustration': 'Validiere Gefühle, biete konkrete Lösung, bleib ruhig',
'neutral': 'Freundlich und hilfsbereit',
}
return `Grundton: ${baseGuidelines[channelType] || 'Freundlich'}
Bei ${sentiment}: ${sentimentAdjustments[sentiment] || sentimentAdjustments['neutral']}`
}
private getMedicalWarning(): string {
return `
⚠️ MEDIZINISCHE FRAGE - BESONDERE VORSICHT:
- Gib KEINE medizinische Beratung
- Gib KEINE Diagnosen oder Behandlungsempfehlungen
- Verweise IMMER auf professionelle Beratung
- Standardformulierung: "Für eine persönliche Einschätzung Ihrer Situation empfehlen wir ein Gespräch mit unserem Beratungsteam: [Hotline/Link]"
`
}
private getSignature(channelType: string): string {
switch (channelType) {
case 'corporate': return 'Caroline'
case 'lifestyle': return 'Caroline'
case 'business': return 'The CCS Team'
default: return 'Das Team'
}
}
private parseReplyResponse(text: string, context: ReplyContext): GeneratedReply {
// Bereinige die Antwort
let cleanedText = text.trim()
// Entferne mögliche Anführungszeichen am Anfang/Ende
cleanedText = cleanedText.replace(/^["']|["']$/g, '')
const warnings: string[] = []
// Prüfe auf potenzielle Probleme
if (context.isMedicalQuestion && !cleanedText.includes('Beratung')) {
warnings.push('Medizinische Frage: Verweis auf Beratung fehlt möglicherweise')
}
if (cleanedText.length > 500) {
warnings.push('Antwort ist möglicherweise zu lang')
}
return {
text: cleanedText,
tone: 'standard',
confidence: 85,
alternatives: [],
warnings,
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
2.2 API Endpoint für Reply Generation
// src/app/(payload)/api/community/generate-reply/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { ClaudeReplyService } from '@/lib/integrations/claude/ClaudeReplyService'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { interactionId, variants = false } = body
if (!interactionId) {
return NextResponse.json(
{ error: 'interactionId is required' },
{ status: 400 }
)
}
const payload = await getPayload({ config })
// Hole Interaction mit Relations
const interaction = await payload.findByID({
collection: 'community-interactions',
id: interactionId,
depth: 2, // Inkludiert platform, socialAccount, linkedContent
})
if (!interaction) {
return NextResponse.json(
{ error: 'Interaction not found' },
{ status: 404 }
)
}
// Bestimme Channel-Typ basierend auf Account
const channelType = determineChannelType(interaction.socialAccount)
// Baue Kontext
const context = {
channelName: interaction.socialAccount?.displayName || 'YouTube Channel',
channelType,
videoTitle: interaction.linkedContent?.title || 'Video',
videoTopics: interaction.linkedContent?.topics?.map((t: any) => t.topic) || [],
commentText: interaction.message,
commentSentiment: interaction.analysis?.sentiment || 'neutral',
authorName: interaction.author?.name || 'User',
isMedicalQuestion: interaction.flags?.isMedicalQuestion || false,
}
const replyService = new ClaudeReplyService()
if (variants) {
// Generiere mehrere Varianten
const replies = await replyService.generateReplyVariants(context, 3)
return NextResponse.json({ variants: replies })
} else {
// Einzelne Antwort
const reply = await replyService.generateReply(context)
return NextResponse.json({ reply })
}
} catch (error: any) {
console.error('[GenerateReply] Error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to generate reply' },
{ status: 500 }
)
}
}
function determineChannelType(account: any): 'corporate' | 'lifestyle' | 'business' {
const name = account?.displayName?.toLowerCase() || ''
if (name.includes('blogwoman') || name.includes('lifestyle')) {
return 'lifestyle'
}
if (name.includes('business') || name.includes('ccs business')) {
return 'business'
}
return 'corporate'
}
2.3 ClaudeAnalysisService erweitern
// src/lib/integrations/claude/ClaudeAnalysisService.ts
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
export interface AnalysisResult {
sentiment: 'positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration'
sentimentScore: number
confidence: number
topics: string[]
isMedicalQuestion: boolean
suggestedReply: string | null
}
export class ClaudeAnalysisService {
/**
* Analysiere eine Community-Interaktion
*/
async analyzeInteraction(text: string): Promise<AnalysisResult> {
const prompt = `Analysiere folgenden YouTube-Kommentar und antworte NUR mit einem JSON-Objekt (keine Erklärungen):
KOMMENTAR:
"${text}"
Antworte mit diesem exakten JSON-Format:
{
"sentiment": "positive|neutral|negative|question|gratitude|frustration",
"sentimentScore": <number zwischen -1.0 und 1.0>,
"confidence": <number zwischen 0 und 100>,
"topics": ["topic1", "topic2"],
"isMedicalQuestion": <true|false>,
"suggestedReplyType": "thanks|answer|empathy|redirect|none"
}
Regeln:
- sentiment: Wähle das passendste aus den 6 Optionen
- sentimentScore: -1.0 = sehr negativ, 0 = neutral, 1.0 = sehr positiv
- confidence: Wie sicher bist du bei der Einschätzung (0-100%)
- topics: Extrahiere 1-3 Hauptthemen (auf Deutsch)
- isMedicalQuestion: true wenn nach medizinischem Rat gefragt wird
- suggestedReplyType: Art der empfohlenen Antwort`
try {
const response = await anthropic.messages.create({
model: 'claude-3-haiku-20240307',
max_tokens: 300,
messages: [
{ role: 'user', content: prompt }
],
})
const content = response.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type')
}
// Parse JSON aus der Antwort
const jsonMatch = content.text.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('No JSON found in response')
}
const parsed = JSON.parse(jsonMatch[0])
return {
sentiment: this.validateSentiment(parsed.sentiment),
sentimentScore: this.clamp(parsed.sentimentScore || 0, -1, 1),
confidence: this.clamp(parsed.confidence || 50, 0, 100),
topics: Array.isArray(parsed.topics) ? parsed.topics.slice(0, 5) : [],
isMedicalQuestion: Boolean(parsed.isMedicalQuestion),
suggestedReply: null, // Wird separat generiert wenn gewünscht
}
} catch (error: any) {
console.error('[ClaudeAnalysis] Error:', error)
// Fallback-Analyse bei Fehler
return {
sentiment: 'neutral',
sentimentScore: 0,
confidence: 30,
topics: [],
isMedicalQuestion: false,
suggestedReply: null,
}
}
}
private validateSentiment(
sentiment: string
): 'positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration' {
const valid = ['positive', 'neutral', 'negative', 'question', 'gratitude', 'frustration']
return valid.includes(sentiment) ? sentiment as any : 'neutral'
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
}
Teil 3: UI Integration
3.1 CommunityInbox erweitern
Füge folgende Funktionen zur bestehenden CommunityInbox.tsx hinzu:
// Ergänzungen für CommunityInbox.tsx
// State für AI Reply
const [isGeneratingReply, setIsGeneratingReply] = useState(false)
const [generatedReplies, setGeneratedReplies] = useState<any[]>([])
// Funktion zum Generieren von Antworten
const handleGenerateReply = async (interactionId: number, variants: boolean = false) => {
setIsGeneratingReply(true)
setGeneratedReplies([])
try {
const response = await fetch('/api/community/generate-reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interactionId, variants }),
})
if (!response.ok) throw new Error('Generation failed')
const data = await response.json()
if (variants && data.variants) {
setGeneratedReplies(data.variants)
} else if (data.reply) {
setGeneratedReplies([data.reply])
}
} catch (error) {
console.error('Failed to generate reply:', error)
// Toast oder Error-Anzeige
} finally {
setIsGeneratingReply(false)
}
}
// Sync-Status und manueller Trigger
const [syncStatus, setSyncStatus] = useState<any>(null)
const [isSyncing, setIsSyncing] = useState(false)
const handleManualSync = async () => {
setIsSyncing(true)
try {
const response = await fetch('/api/community/sync', { method: 'POST' })
const result = await response.json()
if (result.success) {
// Refresh Interactions
fetchInteractions()
}
} catch (error) {
console.error('Sync failed:', error)
} finally {
setIsSyncing(false)
}
}
// Fetch Sync Status
useEffect(() => {
const fetchSyncStatus = async () => {
const response = await fetch('/api/community/sync')
const status = await response.json()
setSyncStatus(status)
}
fetchSyncStatus()
const interval = setInterval(fetchSyncStatus, 60000) // Jede Minute
return () => clearInterval(interval)
}, [])
3.2 UI-Komponente für AI Replies
// Innerhalb des Detail-Panels in CommunityInbox.tsx
{/* AI Reply Section */}
<div className="inbox__ai-reply">
<div className="inbox__ai-reply-header">
<h4>🤖 KI-Antwortvorschläge</h4>
<div className="inbox__ai-reply-actions">
<button
onClick={() => handleGenerateReply(selectedInteraction.id, false)}
disabled={isGeneratingReply}
className="inbox__btn inbox__btn--secondary"
>
{isGeneratingReply ? 'Generiere...' : '1 Vorschlag'}
</button>
<button
onClick={() => handleGenerateReply(selectedInteraction.id, true)}
disabled={isGeneratingReply}
className="inbox__btn inbox__btn--secondary"
>
{isGeneratingReply ? 'Generiere...' : '3 Varianten'}
</button>
</div>
</div>
{generatedReplies.length > 0 && (
<div className="inbox__ai-reply-suggestions">
{generatedReplies.map((reply, index) => (
<div key={index} className="inbox__ai-reply-card">
{reply.tone && (
<span className="inbox__ai-reply-tone">
Ton: {reply.tone}
</span>
)}
<p className="inbox__ai-reply-text">{reply.text}</p>
{reply.warnings?.length > 0 && (
<div className="inbox__ai-reply-warnings">
{reply.warnings.map((w: string, i: number) => (
<span key={i} className="inbox__ai-reply-warning">⚠️ {w}</span>
))}
</div>
)}
<button
onClick={() => {
setReplyText(reply.text)
// Scroll zum Reply-Editor
}}
className="inbox__btn inbox__btn--primary inbox__btn--small"
>
Übernehmen
</button>
</div>
))}
</div>
)}
</div>
{/* Sync Status Badge */}
<div className="inbox__sync-status">
{syncStatus && (
<>
<span className={`inbox__sync-indicator ${syncStatus.isRunning ? 'inbox__sync-indicator--active' : ''}`} />
<span className="inbox__sync-text">
{syncStatus.isRunning
? 'Sync läuft...'
: `Letzter Sync: ${syncStatus.lastRunAt ? new Date(syncStatus.lastRunAt).toLocaleString('de-DE') : 'Nie'}`
}
</span>
<button
onClick={handleManualSync}
disabled={isSyncing || syncStatus.isRunning}
className="inbox__btn inbox__btn--icon"
title="Manuell synchronisieren"
>
🔄
</button>
</>
)}
</div>
3.3 SCSS Ergänzungen
// Ergänzungen für inbox.scss
// AI Reply Section
.inbox__ai-reply {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--theme-elevation-100);
&-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
}
}
&-actions {
display: flex;
gap: 0.5rem;
}
&-suggestions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&-card {
background: var(--theme-elevation-50);
border: 1px solid var(--theme-elevation-100);
border-radius: 8px;
padding: 1rem;
position: relative;
}
&-tone {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-size: 0.625rem;
text-transform: uppercase;
color: var(--theme-elevation-500);
background: var(--theme-elevation-100);
padding: 0.125rem 0.5rem;
border-radius: 4px;
}
&-text {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
}
&-warnings {
margin-bottom: 0.75rem;
}
&-warning {
display: block;
font-size: 0.75rem;
color: #F59E0B;
margin-bottom: 0.25rem;
}
}
// Sync Status
.inbox__sync-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--theme-elevation-50);
border-radius: 6px;
font-size: 0.75rem;
color: var(--theme-elevation-600);
}
.inbox__sync-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--theme-elevation-300);
&--active {
background: #10B981;
animation: pulse 1.5s infinite;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.inbox__sync-text {
flex: 1;
}
Teil 4: Cron-Konfiguration
4.1 Vercel Cron (vercel.json)
{
"crons": [
{
"path": "/api/cron/youtube-sync",
"schedule": "*/15 * * * *"
}
]
}
4.2 Alternative: Externes Cron (z.B. cron-job.org)
URL: https://your-domain.com/api/cron/youtube-sync
Methode: GET
Header: Authorization: Bearer YOUR_CRON_SECRET
Intervall: Alle 15 Minuten
4.3 Alternative: Systemd Timer (Self-hosted)
# /etc/systemd/system/youtube-sync.timer
[Unit]
Description=YouTube Comments Sync Timer
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
# /etc/systemd/system/youtube-sync.service
[Unit]
Description=YouTube Comments Sync
[Service]
Type=oneshot
ExecStart=/usr/bin/curl -s -H "Authorization: Bearer $CRON_SECRET" https://localhost:3000/api/cron/youtube-sync
Teil 5: Environment Variables
# .env.local (ergänzen)
# Bestehend
ANTHROPIC_API_KEY=sk-ant-api03-...
YOUTUBE_CLIENT_ID=...
YOUTUBE_CLIENT_SECRET=...
YOUTUBE_REDIRECT_URI=https://your-domain.com/api/youtube/callback
# NEU
CRON_SECRET=your-secure-random-string-here
Implementierungs-Reihenfolge
Tag 1-2: YouTube Auto-Sync
- Type Definitions (
youtube.ts) - YouTubeClient erweitern
- CommentsSyncService
- Job Runner + Logger
- API Endpoints
Tag 3: Claude Reply Service
- ClaudeReplyService
- API Endpoint
/generate-reply - ClaudeAnalysisService erweitern
Tag 4: UI Integration
- Inbox-Komponente erweitern
- AI Reply Section
- Sync Status UI
- SCSS Ergänzungen
Tag 5: Testing & Deployment
- Manueller Sync testen
- Cron-Job einrichten
- Reply-Generation testen
- Error Handling prüfen
Gesamtaufwand: ~5 Tage
Testfälle
YouTube Sync
- Manueller Sync über API funktioniert
- Cron-Endpoint authentifiziert korrekt
- Neue Kommentare werden importiert
- Bestehende Kommentare werden aktualisiert (Engagement)
- Token-Refresh funktioniert automatisch
- Rate Limiting wird eingehalten
- Fehler werden geloggt und nicht propagiert
AI Replies
- Single Reply Generation funktioniert
- 3 Varianten werden generiert
- Channel-Typ wird korrekt erkannt
- Medizinische Warnung erscheint bei Med. Fragen
- Signatur ist kanalspezifisch
- "Übernehmen" kopiert Text in Editor
- Warnings werden angezeigt
UI
- Sync-Status wird angezeigt
- Manueller Sync-Button funktioniert
- AI Reply Section erscheint
- Loading States für alle Aktionen
- Error States mit Feedback
Deliverables
Nach Abschluss solltest du haben:
- ✅ YouTubeClient mit getChannelComments, getVideoComments, replyToComment
- ✅ CommentsSyncService mit vollständigem Sync-Flow
- ✅ Cron-Endpoint mit Authentifizierung
- ✅ ClaudeReplyService mit kanalspezifischen Prompts
- ✅ API-Endpoint für Reply-Generation
- ✅ UI-Integration in CommunityInbox
- ✅ Sync-Status Anzeige
- ✅ SCSS für neue Komponenten
- ✅ Environment Variables dokumentiert
- ✅ Cron-Konfiguration (Vercel oder Alternative)
Beginne mit den Type Definitions und arbeite dich durch die Services zur UI vor.
Bei Fragen zur YouTube API Quota oder Claude API Kosten: Implementiere erst die Grundfunktion, Optimierungen können danach erfolgen.