cms.c2sgmbh/prompts/Phase2.2 youtube sync ai replies prompt.md
Martin Porwoll 77f70876f4 chore: add Claude Code config, prompts, and tenant setup scripts
- 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>
2026-01-18 10:18:05 +00:00

1763 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
```typescript
// 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)
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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',
},
})
}
```
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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:
```tsx
// 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
```tsx
// 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
```scss
// 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)
```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)
```bash
# /etc/systemd/system/youtube-sync.timer
[Unit]
Description=YouTube Comments Sync Timer
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
```
```bash
# /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
```bash
# .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
1. Type Definitions (`youtube.ts`)
2. YouTubeClient erweitern
3. CommentsSyncService
4. Job Runner + Logger
5. API Endpoints
### Tag 3: Claude Reply Service
1. ClaudeReplyService
2. API Endpoint `/generate-reply`
3. ClaudeAnalysisService erweitern
### Tag 4: UI Integration
1. Inbox-Komponente erweitern
2. AI Reply Section
3. Sync Status UI
4. SCSS Ergänzungen
### Tag 5: Testing & Deployment
1. Manueller Sync testen
2. Cron-Job einrichten
3. Reply-Generation testen
4. 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:
1. ✅ YouTubeClient mit getChannelComments, getVideoComments, replyToComment
2. ✅ CommentsSyncService mit vollständigem Sync-Flow
3. ✅ Cron-Endpoint mit Authentifizierung
4. ✅ ClaudeReplyService mit kanalspezifischen Prompts
5. ✅ API-Endpoint für Reply-Generation
6. ✅ UI-Integration in CommunityInbox
7. ✅ Sync-Status Anzeige
8. ✅ SCSS für neue Komponenten
9. ✅ Environment Variables dokumentiert
10. ✅ 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.