240 lines
8.3 KiB
TypeScript
240 lines
8.3 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 AdjustStrategyRequest {
|
|
clinicId: string;
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") {
|
|
return new Response("ok", { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
const body = (await req.json()) as AdjustStrategyRequest;
|
|
if (!body.clinicId) throw new Error("clinicId is required");
|
|
|
|
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);
|
|
|
|
const clinicId = body.clinicId;
|
|
|
|
// ─── 1. Get channel deltas from weekly comparison view ───
|
|
const { data: deltas } = await supabase
|
|
.from("channel_weekly_delta")
|
|
.select("*")
|
|
.eq("clinic_id", clinicId);
|
|
|
|
// ─── 2. Get current active content plan ───
|
|
const { data: activePlan } = await supabase
|
|
.from("content_plans")
|
|
.select("id, channel_strategies, content_strategy, calendar")
|
|
.eq("clinic_id", clinicId)
|
|
.eq("is_active", true)
|
|
.order("created_at", { ascending: false })
|
|
.limit(1)
|
|
.single();
|
|
|
|
// ─── 3. Get latest analysis run for KPI targets ───
|
|
const { data: latestRun } = await supabase
|
|
.from("analysis_runs")
|
|
.select("id, report")
|
|
.eq("clinic_id", clinicId)
|
|
.eq("status", "complete")
|
|
.order("created_at", { ascending: false })
|
|
.limit(1)
|
|
.single();
|
|
|
|
const report = (latestRun?.report || {}) as Record<string, unknown>;
|
|
const kpiTargets = (report.kpiTargets || []) as Record<string, unknown>[];
|
|
|
|
// ─── 4. Calculate KPI progress ───
|
|
const kpiProgress = kpiTargets.map((kpi) => {
|
|
const current = parseFloat(String(kpi.current || "0").replace(/[^0-9.]/g, "")) || 0;
|
|
const target = parseFloat(String(kpi.target3Month || "0").replace(/[^0-9.]/g, "")) || 0;
|
|
const progress = target > 0 ? Math.min((current / target) * 100, 200) : 0;
|
|
return {
|
|
metric: kpi.metric,
|
|
current: kpi.current,
|
|
target: kpi.target3Month,
|
|
progress: Math.round(progress),
|
|
};
|
|
});
|
|
|
|
// ─── 5. Build channel delta summary ───
|
|
const deltaLines = (deltas || []).map((d: Record<string, unknown>) => {
|
|
const channel = d.channel as string;
|
|
const currentFollowers = d.current_followers as number || 0;
|
|
const prevFollowers = d.prev_followers as number || 0;
|
|
const change = currentFollowers - prevFollowers;
|
|
const changePercent = prevFollowers > 0
|
|
? ((change / prevFollowers) * 100).toFixed(1)
|
|
: "N/A";
|
|
return `${channel}: ${prevFollowers} → ${currentFollowers} (${change >= 0 ? "+" : ""}${changePercent}%)`;
|
|
});
|
|
|
|
const underperforming = (deltas || [])
|
|
.filter((d: Record<string, unknown>) => {
|
|
const current = d.current_followers as number || 0;
|
|
const prev = d.prev_followers as number || 0;
|
|
return prev > 0 && current <= prev;
|
|
})
|
|
.map((d: Record<string, unknown>) => d.channel as string);
|
|
|
|
// ─── 6. Call Perplexity for strategy adjustment ───
|
|
const userPrompt = `
|
|
현재 콘텐츠 전략의 성과를 분석하고 조정 추천을 해주세요.
|
|
|
|
## 채널 변화량 (최근 1주)
|
|
${deltaLines.join("\n") || "데이터 수집 중 (아직 비교 데이터 없음)"}
|
|
|
|
## KPI 달성률
|
|
${kpiProgress.map((k) => `${k.metric}: ${k.progress}% (${k.current} / ${k.target})`).join("\n") || "KPI 설정 전"}
|
|
|
|
## 부진 채널
|
|
${underperforming.join(", ") || "없음"}
|
|
|
|
## 현재 전략 요약
|
|
${JSON.stringify(activePlan?.channel_strategies || [], null, 2).slice(0, 2000)}
|
|
|
|
## 요청 출력 JSON
|
|
{
|
|
"strategySuggestions": [
|
|
{
|
|
"adjustmentType": "frequency_change|pillar_shift|channel_add|channel_pause|content_format_change|tone_shift",
|
|
"channel": "채널명",
|
|
"description": "변경 내용 설명",
|
|
"reason": "근거 (데이터 기반)",
|
|
"priority": "high|medium|low",
|
|
"beforeValue": "변경 전",
|
|
"afterValue": "변경 후"
|
|
}
|
|
],
|
|
"overallAssessment": "전체 전략 평가 (2-3문장)",
|
|
"topPerformingChannel": "가장 성과 좋은 채널",
|
|
"focusArea": "다음 2주 집중 영역"
|
|
}
|
|
`;
|
|
|
|
console.log(`[adjust-strategy] Calling Perplexity for clinic ${clinicId}...`);
|
|
|
|
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 performance analyst. Respond ONLY with valid JSON. Use Korean for text fields.",
|
|
},
|
|
{ role: "user", content: userPrompt },
|
|
],
|
|
temperature: 0.3,
|
|
}),
|
|
});
|
|
|
|
const aiData = await aiRes.json();
|
|
let responseText = aiData.choices?.[0]?.message?.content || "";
|
|
const jsonMatch = responseText.match(/```(?:json)?\n?([\s\S]*?)```/);
|
|
if (jsonMatch) responseText = jsonMatch[1];
|
|
if (!responseText.trim().startsWith("{")) {
|
|
const rawMatch = responseText.match(/\{[\s\S]*\}/);
|
|
if (rawMatch) responseText = rawMatch[0];
|
|
}
|
|
|
|
let adjustments: Record<string, unknown>;
|
|
try {
|
|
adjustments = JSON.parse(responseText);
|
|
} catch {
|
|
console.error("[adjust-strategy] JSON parse failed");
|
|
adjustments = {
|
|
strategySuggestions: [],
|
|
overallAssessment: "AI 분석 실패 — 수동 검토 필요",
|
|
topPerformingChannel: "N/A",
|
|
focusArea: "데이터 수집 대기",
|
|
};
|
|
}
|
|
|
|
// ─── 7. Store in performance_metrics ───
|
|
const channelDeltas: Record<string, unknown> = {};
|
|
for (const d of deltas || []) {
|
|
const ch = d.channel as string;
|
|
channelDeltas[ch] = {
|
|
currentFollowers: d.current_followers,
|
|
prevFollowers: d.prev_followers,
|
|
change: (d.current_followers as number || 0) - (d.prev_followers as number || 0),
|
|
};
|
|
}
|
|
|
|
const { data: perfMetric, error: perfErr } = await supabase
|
|
.from("performance_metrics")
|
|
.insert({
|
|
clinic_id: clinicId,
|
|
run_id: latestRun?.id || null,
|
|
prev_run_id: null,
|
|
channel_deltas: channelDeltas,
|
|
kpi_progress: kpiProgress,
|
|
top_performing_content: { channel: adjustments.topPerformingChannel },
|
|
underperforming_channels: underperforming,
|
|
strategy_suggestions: adjustments.strategySuggestions || [],
|
|
})
|
|
.select("id")
|
|
.single();
|
|
|
|
if (perfErr) console.error("[adjust-strategy] performance_metrics insert error:", perfErr);
|
|
|
|
// ─── 8. Store individual strategy_adjustments ───
|
|
const suggestions = (adjustments.strategySuggestions || []) as Record<string, unknown>[];
|
|
for (const s of suggestions) {
|
|
await supabase.from("strategy_adjustments").insert({
|
|
clinic_id: clinicId,
|
|
plan_id: activePlan?.id || null,
|
|
performance_id: perfMetric?.id || null,
|
|
adjustment_type: s.adjustmentType || "other",
|
|
description: s.description || "",
|
|
reason: s.reason || "",
|
|
before_value: { value: s.beforeValue },
|
|
after_value: { value: s.afterValue },
|
|
});
|
|
}
|
|
|
|
console.log(`[adjust-strategy] Created ${suggestions.length} adjustments for clinic ${clinicId}`);
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
performanceMetricId: perfMetric?.id || null,
|
|
adjustments,
|
|
channelDeltas,
|
|
kpiProgress,
|
|
underperforming,
|
|
}),
|
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
);
|
|
} catch (error) {
|
|
console.error("[adjust-strategy] Error:", error);
|
|
return new Response(
|
|
JSON.stringify({ success: false, error: error.message }),
|
|
{
|
|
status: 500,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
});
|