o2o-infinith-demo/supabase/functions/generate-content-plan/index.ts

457 lines
17 KiB
TypeScript

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<string, unknown> = {};
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<string, unknown>;
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<string, unknown>;
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<string, unknown> | 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<string, Record<string, unknown>> | undefined;
const kpiTargets = report.kpiTargets as Record<string, unknown>[] | undefined;
const recommendations = report.recommendations as Record<string, unknown>[] | undefined;
const competitors = report.competitors as Record<string, unknown>[] | undefined;
const reportKeywords = report.keywords as { primary?: Record<string, unknown>[]; longTail?: Record<string, unknown>[] } | undefined;
// Channel-specific tone matrix (Audit C3)
const TONE_MAP: Record<string, string> = {
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<string, unknown>;
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<string, Record<string, unknown>> | undefined,
services: string[],
clinicName: string,
): Record<string, unknown> {
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: "한국 직장인/성형 관심층 주요 활동 시간대 기반",
},
};
}