import "@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { PERPLEXITY_MODEL } from "../_shared/config.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", }; interface ContentPlanRequest { reportId?: string; clinicId?: string; runId?: string; } Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response("ok", { headers: corsHeaders }); } try { const body = (await req.json()) as ContentPlanRequest; const PERPLEXITY_API_KEY = Deno.env.get("PERPLEXITY_API_KEY"); if (!PERPLEXITY_API_KEY) throw new Error("PERPLEXITY_API_KEY not configured"); const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const supabase = createClient(supabaseUrl, supabaseKey); // ─── 1. Load report data ─── let report: Record = {}; let clinicId = body.clinicId; let runId = body.runId; let clinicName = ""; let services: string[] = []; // Try V3 analysis_runs first if (runId) { const { data: run } = await supabase .from("analysis_runs") .select("report, clinic_id") .eq("id", runId) .single(); if (run?.report) { report = run.report as Record; clinicId = clinicId || run.clinic_id; } } // Fallback to marketing_reports if (Object.keys(report).length === 0 && body.reportId) { const { data: row } = await supabase .from("marketing_reports") .select("report, clinic_name") .eq("id", body.reportId) .single(); if (row?.report) { report = row.report as Record; clinicName = row.clinic_name || ""; } } if (Object.keys(report).length === 0) { throw new Error("No report data found. Provide reportId or runId."); } // Resolve clinic info if (clinicId) { const { data: clinic } = await supabase .from("clinics") .select("name, services") .eq("id", clinicId) .single(); if (clinic) { clinicName = clinic.name || clinicName; services = clinic.services || []; } } const clinicInfo = report.clinicInfo as Record | undefined; if (!clinicName) clinicName = (clinicInfo?.name as string) || "병원"; if (services.length === 0) services = (clinicInfo?.services as string[]) || []; // ─── 2. Build enriched channel summary for AI prompt ─── const channelAnalysis = report.channelAnalysis as Record> | undefined; const kpiTargets = report.kpiTargets as Record[] | undefined; const recommendations = report.recommendations as Record[] | undefined; const competitors = report.competitors as Record[] | undefined; const reportKeywords = report.keywords as { primary?: Record[]; longTail?: Record[] } | undefined; // Channel-specific tone matrix (Audit C3) const TONE_MAP: Record = { youtube: "교육적·권위(Long) / 캐주얼·후킹(Shorts) / 친근·대화(Live)", instagram: "트렌디·공감(Reel) / 감성적·프리미엄(Feed) / 친근·일상(Stories)", naverBlog: "정보성·SEO 최적화", gangnamUnni: "전문·응대·신뢰", facebook: "타겟팅·CTA 중심", tiktok: "밈·교육·MZ세대", }; const channelLines: string[] = []; if (channelAnalysis) { for (const [key, ch] of Object.entries(channelAnalysis)) { const score = (ch.score as number) ?? 0; const status = (ch.status as string) || "unknown"; const followers = ch.followers || ch.subscribers || ch.reviews || ""; const tone = TONE_MAP[key] || "전문적·친근한"; channelLines.push(`${key}: 점수 ${score}/100, 상태: ${status}${followers ? `, 규모: ${followers}` : ""}, 톤: ${tone}`); } } const kpiLines = (kpiTargets || []).slice(0, 5).map( (k) => `${k.metric}: 현재 ${k.current} → 3개월 목표 ${k.target3Month}` ); const recLines = (recommendations || []).slice(0, 5).map( (r) => `[${r.priority}] ${r.title}` ); // Competitor summary (Audit: inject competitor data) const competitorLines = (competitors || []).slice(0, 3).map( (c) => `${c.name}: 강점=${(c.strengths as string[] || []).join(",")} 약점=${(c.weaknesses as string[] || []).join(",")}` ); // Keyword summary (Audit C5: connect keywords to topics) const keywordLines = (reportKeywords?.primary || []).slice(0, 10).map( (k) => `${k.keyword} (월 ${k.monthlySearches || "?"}회 검색)` ); // ─── 3. Call Perplexity sonar (enhanced prompt) ─── const userPrompt = ` ${clinicName} 성형외과의 콘텐츠 마케팅 전략을 수립해주세요. ## 시술 분야 ${services.join(", ") || "성형외과 전반"} ## 채널 현황 (채널별 톤앤매너 준수) ${channelLines.join("\n")} ## 검색 키워드 (블로그·YouTube SEO에 활용) ${keywordLines.join("\n") || "키워드 데이터 없음"} ## 경쟁사 분석 ${competitorLines.join("\n") || "경쟁사 데이터 없음"} ## KPI 목표 ${kpiLines.join("\n") || "설정 전"} ## 주요 추천사항 ${recLines.join("\n") || "없음"} ## 고객 여정 기반 주간 테마 (반드시 적용) - Week 1: 인지 확대 (YouTube Shorts, TikTok, Instagram Reels 집중) - Week 2: 관심 유도 (Long-form, 블로그 SEO, Carousel 집중) - Week 3: 신뢰 구축 (강남언니 리뷰, Before/After, 의사 소개 집중) - Week 4: 전환 최적화 (Facebook 광고, Instagram DM CTA, 프로모션 집중) ## 필수 포함 채널 (모두 캘린더에 포함할 것) YouTube (Shorts + Long + Live + Community), Instagram (Reel + Carousel + Feed + Stories), 강남언니 (리뷰관리 + 프로필최적화 + 이벤트), TikTok (숏폼), 네이버 블로그 (SEO글 + 의사칼럼), Facebook (오가닉 + 광고) ## 콘텐츠 필러 (5개 필러 반드시 포함) 1. 전문성·신뢰, 2. 비포·애프터, 3. 환자 후기, 4. 트렌드·교육, 5. 안전·케어 ## 요청 출력 (반드시 아래 JSON 구조로) { "channelStrategies": [ { "channelId": "youtube|instagram|naverBlog|facebook|tiktok", "channelName": "한국어 채널명", "icon": "youtube|instagram|blog|facebook|video", "currentStatus": "현재 상태 요약", "targetGoal": "3개월 목표", "contentTypes": ["콘텐츠 유형 1", "콘텐츠 유형 2"], "postingFrequency": "주 N회", "tone": "톤앤매너", "formatGuidelines": ["가이드라인 1"], "priority": "P0|P1|P2" } ], "contentPillars": [ { "title": "필러 제목", "description": "설명", "relatedUSP": "연관 USP", "exampleTopics": ["주제 1", "주제 2", "주제 3"], "color": "#hex" } ], "calendar": { "weeks": [ { "weekNumber": 1, "label": "Week 1: 주제", "entries": [ { "id": "uuid", "dayOfWeek": 0, "channel": "채널명", "channelIcon": "아이콘", "contentType": "video|blog|social|ad", "title": "콘텐츠 제목", "description": "제작 가이드", "pillar": "필러 제목", "status": "draft", "aiPromptSeed": "AI 재생성용 컨텍스트" } ] } ], "monthlySummary": [ {"type": "video", "label": "영상", "count": 0, "color": "#6C5CE7"}, {"type": "blog", "label": "블로그", "count": 0, "color": "#00B894"}, {"type": "social", "label": "소셜", "count": 0, "color": "#E17055"}, {"type": "ad", "label": "광고", "count": 0, "color": "#FDCB6E"} ] }, "postingSchedule": { "bestTimes": {"youtube": "오후 6-8시", "instagram": "오후 12-1시, 오후 7-9시"}, "rationale": "근거 설명" } } 4주간 캘린더를 생성하되, 각 주에 최소 8-12개 엔트리를 포함하세요. 각 엔트리의 id는 고유한 UUID 형식이어야 합니다. 콘텐츠 제목은 구체적이고 실행 가능해야 합니다 (예: "코성형 전후 비교 리얼 후기" ✅, "콘텐츠 #1" ❌). `; console.log(`[content-plan] Calling Perplexity for ${clinicName}...`); const aiRes = await fetch("https://api.perplexity.ai/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${PERPLEXITY_API_KEY}`, }, body: JSON.stringify({ model: PERPLEXITY_MODEL, messages: [ { role: "system", content: "You are a Korean medical marketing content strategist. Respond ONLY with valid JSON, no markdown code blocks. Use Korean for all text fields. Generate UUID v4 for each calendar entry id.", }, { role: "user", content: userPrompt }, ], temperature: 0.3, }), }); const aiData = await aiRes.json(); let responseText = aiData.choices?.[0]?.message?.content || ""; // Strip markdown code blocks if present const jsonMatch = responseText.match(/```(?:json)?\n?([\s\S]*?)```/); if (jsonMatch) responseText = jsonMatch[1]; // Try raw JSON match as fallback if (!responseText.trim().startsWith("{")) { const rawMatch = responseText.match(/\{[\s\S]*\}/); if (rawMatch) responseText = rawMatch[0]; } let contentPlan: Record; try { contentPlan = JSON.parse(responseText); } catch { console.error("[content-plan] JSON parse failed, using fallback"); contentPlan = buildFallbackPlan(channelAnalysis, services, clinicName); } // ─── 4. Store in content_plans table ─── const resolvedClinicId = clinicId || null; const resolvedRunId = runId || null; // Deactivate previous active plans for this clinic if (resolvedClinicId) { await supabase .from("content_plans") .update({ is_active: false }) .eq("clinic_id", resolvedClinicId) .eq("is_active", true); } const { data: plan, error: insertErr } = await supabase .from("content_plans") .insert({ clinic_id: resolvedClinicId, run_id: resolvedRunId, channel_strategies: contentPlan.channelStrategies || [], content_strategy: { pillars: contentPlan.contentPillars || [], typeMatrix: [], workflow: [], repurposingSource: "", repurposingOutputs: [], }, calendar: contentPlan.calendar || { weeks: [], monthlySummary: [] }, brand_guide: {}, is_active: true, }) .select("id") .single(); if (insertErr) { console.error("[content-plan] DB insert error:", insertErr); } console.log(`[content-plan] Plan created: ${plan?.id} for ${clinicName}`); return new Response( JSON.stringify({ success: true, planId: plan?.id || null, contentPlan, clinicName, }), { headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } catch (error) { console.error("[content-plan] Error:", error); return new Response( JSON.stringify({ success: false, error: error.message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, } ); } }); // ─── Fallback: Deterministic plan when AI fails ─── function buildFallbackPlan( channelAnalysis: Record> | undefined, services: string[], clinicName: string, ): Record { const channels = channelAnalysis ? Object.keys(channelAnalysis) : ["youtube", "instagram"]; const svcList = services.length > 0 ? services : ["성형외과 시술"]; const PILLAR_COLORS = ["#6C5CE7", "#E17055", "#00B894", "#FDCB6E"]; const pillars = [ { title: "전문성 · 신뢰", description: "의료진 소개, 수술 과정, 인증 콘텐츠", relatedUSP: "전문 의료진", exampleTopics: svcList.slice(0, 3).map((s) => `${s} 시술 과정 소개`), color: PILLAR_COLORS[0] }, { title: "비포 · 애프터", description: "실제 환자 사례, 수술 전후 비교", relatedUSP: "검증된 결과", exampleTopics: svcList.slice(0, 3).map((s) => `${s} 전후 비교`), color: PILLAR_COLORS[1] }, { title: "환자 후기", description: "실제 환자 인터뷰, 후기 콘텐츠", relatedUSP: "환자 만족도", exampleTopics: ["환자 인터뷰 영상", "리뷰 하이라이트", "회복 일기"], color: PILLAR_COLORS[2] }, { title: "트렌드 · 교육", description: "시술 트렌드, Q&A, 의학 정보", relatedUSP: "최신 트렌드", exampleTopics: ["자주 묻는 질문 Q&A", "시술별 비용 가이드", "최신 트렌드"], color: PILLAR_COLORS[3] }, ]; const WEEK_THEMES = ["브랜드 정비", "콘텐츠 엔진 가동", "소셜 증거 강화", "전환 최적화"]; const weeks = WEEK_THEMES.map((theme, wi) => { const entries = []; const pillar = pillars[wi % pillars.length]; let entryIdx = 0; if (channels.includes("youtube")) { for (let d = 0; d < 3; d++) { entries.push({ id: crypto.randomUUID(), dayOfWeek: d * 2, channel: "YouTube", channelIcon: "youtube", contentType: "video", title: `Shorts: ${svcList[entryIdx % svcList.length]} ${pillar.title}`, description: `${pillar.description} 관련 숏폼 콘텐츠`, pillar: pillar.title, status: "draft", aiPromptSeed: `${clinicName} ${svcList[entryIdx % svcList.length]} ${pillar.title} shorts`, }); entryIdx++; } } if (channels.includes("instagram")) { entries.push({ id: crypto.randomUUID(), dayOfWeek: 1, channel: "Instagram", channelIcon: "instagram", contentType: "social", title: `Carousel: ${svcList[0]} ${pillar.exampleTopics[0] || ""}`, description: "인스타그램 카드뉴스", pillar: pillar.title, status: "draft", aiPromptSeed: `${clinicName} instagram carousel ${pillar.title}`, }); entries.push({ id: crypto.randomUUID(), dayOfWeek: 3, channel: "Instagram", channelIcon: "instagram", contentType: "video", title: `Reel: ${svcList[0]} ${pillar.title}`, description: "인스타그램 릴스", pillar: pillar.title, status: "draft", aiPromptSeed: `${clinicName} instagram reel ${pillar.title}`, }); } if (channels.includes("naverBlog")) { entries.push({ id: crypto.randomUUID(), dayOfWeek: 2, channel: "네이버 블로그", channelIcon: "blog", contentType: "blog", title: `${svcList[entryIdx % svcList.length]} ${pillar.exampleTopics[0] || ""}`, description: "SEO 최적화 블로그 포스트", pillar: pillar.title, status: "draft", aiPromptSeed: `${clinicName} naver blog ${pillar.title}`, }); } entries.sort((a, b) => a.dayOfWeek - b.dayOfWeek); return { weekNumber: wi + 1, label: `Week ${wi + 1}: ${theme}`, entries, }; }); const allEntries = weeks.flatMap((w) => w.entries); return { channelStrategies: channels.map((ch) => ({ channelId: ch, channelName: ch, icon: ch === "youtube" ? "youtube" : ch === "instagram" ? "instagram" : "globe", currentStatus: "분석 완료", targetGoal: "콘텐츠 전략 수립", contentTypes: ["숏폼", "피드"], postingFrequency: "주 2-3회", tone: "전문적 · 친근한", formatGuidelines: [], priority: "P1", })), contentPillars: pillars, calendar: { weeks, monthlySummary: [ { type: "video", label: "영상", count: allEntries.filter((e) => e.contentType === "video").length, color: "#6C5CE7" }, { type: "blog", label: "블로그", count: allEntries.filter((e) => e.contentType === "blog").length, color: "#00B894" }, { type: "social", label: "소셜", count: allEntries.filter((e) => e.contentType === "social").length, color: "#E17055" }, { type: "ad", label: "광고", count: allEntries.filter((e) => e.contentType === "ad").length, color: "#FDCB6E" }, ], }, postingSchedule: { bestTimes: { youtube: "오후 6-8시", instagram: "오후 12-1시, 오후 7-9시" }, rationale: "한국 직장인/성형 관심층 주요 활동 시간대 기반", }, }; }