o2o-infinith-demo/supabase/functions/adjust-strategy/index.ts

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" },
}
);
}
});