457 lines
17 KiB
TypeScript
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: "한국 직장인/성형 관심층 주요 활동 시간대 기반",
|
|
},
|
|
};
|
|
}
|