// ==========================================
// 🚀 AI 마케팅 분석 시스템 (Gemini 2.5 Flash)
// ==========================================
// 1. 설정 변수
const GEMINI_MODEL_ID = 'gemini-2.5-flash'; // 최신 모델
const GEMINI_API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL_ID}:generateContent`;
// 2. Gemini API 호출 함수 (핵심 엔진)
function callGemini(prompt) {
// 스크립트 속성에서 API 키 가져오기
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
if (!apiKey) {
throw new Error("API 키가 없습니다. [설정 > 스크립트 속성]에 'GEMINI_API_KEY'를 추가하세요.");
}
if (!prompt || prompt.trim() === "") {
throw new Error("프롬프트가 비어 있습니다.");
}
// API 요청 본문 (Payload)
const payload = {
contents: [
{
role: "user",
parts: [{ text: prompt }]
}
]
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
// API 호출
const urlWithKey = `${GEMINI_API_URL}?key=${apiKey}`;
const response = UrlFetchApp.fetch(urlWithKey, options);
const responseCode = response.getResponseCode();
const responseText = response.getContentText();
// 에러 처리
if (responseCode !== 200) {
throw new Error(`Gemini API 오류 (${responseCode}): ${responseText}`);
}
const json = JSON.parse(responseText);
// 응답 추출
if (json.candidates && json.candidates.length > 0 && json.candidates[0].content) {
return json.candidates[0].content.parts[0].text;
} else {
if (json.promptFeedback && json.promptFeedback.blockReason) {
throw new Error(`AI 응답 차단됨 (사유: ${json.promptFeedback.blockReason})`);
}
throw new Error("AI 응답이 비어 있습니다.");
}
}
// 3. 데이터 분석 실행 함수 (시트 연동)
function runAIAnalysis() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const rawSheet = ss.getSheetByName('raw_data');
const summarySheet = ss.getSheetByName('model_summary');
if (!rawSheet || !summarySheet) {
SpreadsheetApp.getUi().alert("필수 시트 누락: 'raw_data', 'model_summary' 시트가 필요합니다.");
return;
}
// 데이터 가져오기
const data = rawSheet.getDataRange().getValues();
if (data.length <= 1) {
SpreadsheetApp.getUi().alert("'raw_data' 시트에 데이터가 없습니다.");
return;
}
// CSV 변환
const csvString = data.map(row => row.join(",")).join("\n");
// 프롬프트 작성
const prompt = `
당신은 데이터 분석 전문가입니다. 아래 데이터를 분석하여 'Product_Sold' 판매량 예측을 위한 회귀분석 리포트를 작성하세요.
[데이터]
${csvString}
[요청사항]
1. 'Product_Sold'를 종속변수로 하는 다중회귀분석을 수행하여 각 채널의 계수(Coefficient)와 절편(Intercept)을 도출하세요.
2. 경영진을 위한 핵심 인사이트 리포트를 작성하세요.
3. 결과는 반드시 아래 JSON 포맷으로만 출력하세요. (마크다운 없음, 순수 JSON)
{
"coefficients": {
"TV": 0.0,
"Billboards": 0.0,
"Google_Ads": 0.0,
"Social_Media": 0.0,
"Influencer_Marketing": 0.0,
"Affiliate_Marketing": 0.0,
"Intercept": 0.0
},
"report": "여기에 줄바꿈을 사용하여 리포트 내용을 작성하세요."
}
`;
try {
ss.toast("Gemini 2.5 Flash가 분석 중입니다...", "🚀 분석 시작", 10);
const resultText = callGemini(prompt);
// JSON 파싱 (마크다운 제거)
const cleanJson = resultText.replace(/```json/g, "").replace(/```/g, "").trim();
let result;
try {
result = JSON.parse(cleanJson);
} catch (e) {
throw new Error("AI 응답 형식이 올바르지 않습니다. 원본: " + resultText.substring(0, 50) + "...");
}
// 시트 초기화
summarySheet.clear();
// 헤더 작성
summarySheet.getRange("A1:C1").setValues([["변수명", "계수", "분석 리포트"]]);
summarySheet.getRange("A1:C1").setFontWeight("bold").setBackground("#EFEFEF");
// 계수 데이터 채우기
let row = 2;
if (result.coefficients) {
for (const key in result.coefficients) {
summarySheet.getRange(row, 1).setValue(key);
summarySheet.getRange(row, 2).setValue(result.coefficients[key]);
row++;
}
}
// 리포트 채우기
if (result.report) {
const reportCell = summarySheet.getRange("C2");
reportCell.setValue(result.report);
reportCell.setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP);
reportCell.setVerticalAlignment("top");
}
// 시트 디자인 정리
summarySheet.getRange(2, 3, 15, 6).merge();
summarySheet.setColumnWidth(1, 150);
summarySheet.setColumnWidth(2, 100);
summarySheet.setColumnWidth(3, 600);
// 🔁 여기서 optimizer_ui 템플릿 자동 생성
setupOptimizerUISheet();
ss.toast("분석이 완료되었습니다!", "✅ 성공", 5);
} catch (e) {
SpreadsheetApp.getUi().alert("오류 발생:\n" + e.toString());
}
}
// 🔧 optimizer_ui 기본 UI 자동 생성
function setupOptimizerUISheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('optimizer_ui');
// 시트가 없으면 새로 만들고, 있으면 내용만 비우기
if (!sheet) {
sheet = ss.insertSheet('optimizer_ui');
} else {
// 🔹 시트 전체가 아니라 1~20행만 초기화
const maxCols = sheet.getMaxColumns(); // 시트 전체 컬럼 수
sheet.getRange(1, 1, 20, maxCols).clear(); // 또는 clearContent()만 써도 됨
}
// ===== 1행: 총 예산 =====
sheet.getRange('A1').setValue('총 예산 ($)').setFontWeight('bold');
sheet.getRange('B1').setValue(0);
sheet.getRange('C1').setValue('← 여기에 예산 금액 입력');
// ===== 3~5행: 채널 / 최소비중 / 최대비중 =====
// 채널 목록 (원하면 나중에 시트에서 직접 수정 가능)
const channels = [
'TV',
'Billboards',
'Google_Ads',
'Social_Media',
'Influencer_Marketing',
'Affiliate_Marketing'
];
// 3행: 채널명 헤더 + 각 채널 이름
sheet.getRange('A3').setValue('채널명').setFontWeight('bold');
sheet.getRange(3, 2, 1, channels.length).setValues([channels]).setFontWeight('bold');
// 4행: 최소 비중 (기본 5%)
sheet.getRange('A4').setValue('최소 비중').setFontWeight('bold');
const minShares = [0.05, 0.05, 0.05, 0.05, 0.05, 0.05]; // 5%
sheet.getRange(4, 2, 1, channels.length).setValues([minShares]);
sheet.getRange(4, 2, 1, channels.length).setNumberFormat('0%');
// 5행: 최대 비중 (기본 50%)
sheet.getRange('A5').setValue('최대 비중').setFontWeight('bold');
const maxShares = [0.5, 0.5, 0.5, 0.5, 0.5, 0.5]; // 50%
sheet.getRange(5, 2, 1, channels.length).setValues([maxShares]);
sheet.getRange(5, 2, 1, channels.length).setNumberFormat('0%');
// ===== 7행: 최적화 결과 제목 + 수식 =====
sheet.getRange('A7').setValue('[최적화 결과]').setFontWeight('bold');
// B7: ArrayFormula로 OPTIMIZE_BUDGET 호출
// 총 예산: B1
// 채널 범위: B3 ~ 마지막 채널
// 최소/최대 비중: B4/B5 ~ 마지막 채널
const lastColLetter = sheet.getRange(3, 1 + channels.length).getA1Notation().replace(/[0-9]/g, ''); // 예: G
const formula = `=ArrayFormula(OPTIMIZE_BUDGET(B1, B3:${lastColLetter}3, B4:${lastColLetter}4, B5:${lastColLetter}5))`;
sheet.getRange('B7').setFormula(formula);
// 보기 좋게 약간 포맷팅
sheet.setColumnWidths(1, 1, 140); // A열
sheet.setColumnWidths(2, channels.length, 120); // B~G열
sheet.getRange('A1:C1').setVerticalAlignment('middle');
sheet.getRange('A3:A5').setVerticalAlignment('middle');
// 안내 문구가 잘 보이도록 줄바꿈
sheet.getRange('C1').setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP);
ss.toast('optimizer_ui 기본 템플릿이 생성되었습니다.', '✅ 완료', 5);
}
// 💰 4. 예산 최적화 UI 함수 (범위 인식 오류 해결 버전)
function OPTIMIZE_BUDGET(totalBudget, channelNames, minShares, maxShares) {
// 1. 필수 입력값 체크
if (!totalBudget || !channelNames || !minShares || !maxShares) {
return [["💡 안내: 수식을 완성해 주세요.\n사용법: =OPTIMIZE_BUDGET(총예산, 채널명범위, 최소비중범위, 최대비중범위)"]];
}
// 2. model_summary 시트에서 계수 읽어오기
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('model_summary');
if (!sheet) return [["Error: 'model_summary' 시트가 없습니다."]];
const data = sheet.getDataRange().getValues();
let coeffs = {};
let intercept = 0;
// 계수 파싱 (데이터가 있는 곳까지 순회)
for (let i = 1; i < data.length; i++) {
// 빈 행이면 패스
if (!data[i][0]) continue;
let key = data[i][0].toString().trim();
let val = data[i][1];
// 숫자가 아니면 패스
if (typeof val !== 'number') continue;
if (key.toLowerCase().includes('intercept') || key === '상수항' || key === '절편') {
intercept = val;
} else {
coeffs[key] = val;
}
}
// 3. [핵심 수정] 입력 범위 평탄화 (Flatten) 처리
// 2차원 배열([[A,B,C]])이든 1차원이든 무조건 1줄로 폄
var channels = flattenInput(channelNames);
var mins = flattenInput(minShares);
var maxs = flattenInput(maxShares);
// 길이 체크 (채널명 개수와 비중 개수가 다르면 경고)
if (channels.length !== mins.length || channels.length !== maxs.length) {
return [["Error: 채널명 개수와 비중(최소/최대) 입력 칸 수가 다릅니다. 범위를 똑같이 맞춰주세요."]];
}
var allocation = {};
var currentSpend = 0;
var positiveCoeffSum = 0;
// 4. 최적화 로직 실행
for (var i = 0; i < channels.length; i++) {
// 빈 셀은 건너뜀
if (channels[i] === "" || channels[i] === null) continue;
var ch = channels[i].toString().trim();
var cf = coeffs[ch];
// model_summary에 없는 채널명이면 0 처리 (에러 방지)
if (cf === undefined) {
cf = 0;
// 디버깅용: return [["Error: '" + ch + "' 채널의 계수를 찾을 수 없습니다. model_summary를 확인하세요."]];
}
var minAmt = totalBudget * (Number(mins[i]) || 0);
allocation[ch] = {
budget: minAmt,
coeff: cf,
maxShare: (maxs[i] !== undefined && maxs[i] !== "") ? Number(maxs[i]) : 1.0
};
currentSpend += minAmt;
if (cf > 0) positiveCoeffSum += cf;
}
// 잔여 예산 배분
var remain = totalBudget - currentSpend;
if (remain > 0 && positiveCoeffSum > 0) {
for (var key in allocation) {
var item = allocation[key];
if (item.coeff > 0) {
var extra = remain * (item.coeff / positiveCoeffSum);
// Max Cap 체크
var maxLimit = totalBudget * item.maxShare;
if ((item.budget + extra) > maxLimit) {
extra = Math.max(0, maxLimit - item.budget);
}
item.budget += extra;
}
}
}
// 5. 결과 테이블 생성
var output = [["채널명", "추천 예산 ($)", "비중 (%)", "효율 계수"]];
var totalSales = intercept;
var sumBudget = 0;
for (var i = 0; i < channels.length; i++) {
if (channels[i] === "" || channels[i] === null) continue;
var ch = channels[i].toString().trim();
var item = allocation[ch];
if (item) {
totalSales += item.budget * item.coeff;
sumBudget += item.budget;
output.push([
ch,
Math.round(item.budget),
(totalBudget > 0 ? (item.budget/totalBudget*100).toFixed(1) : 0) + "%",
item.coeff.toFixed(4)
]);
}
}
output.push(["합계", Math.round(sumBudget), "100%", "-"]);
output.push(["📈 예상 판매량", Math.round(totalSales), "-", "-"]);
return output;
}
// [도우미 함수] 2차원 배열을 1차원으로 펴주는 함수 (에러 해결사)
function flattenInput(input) {
if (!input) return [];
if (Array.isArray(input)) {
// 배열 안에 배열이 있으면(2D) 펴고, 아니면 그대로
return input.reduce(function(acc, val) {
return acc.concat(Array.isArray(val) ? flattenInput(val) : val);
}, []);
}
return [input]; // 단일 값이면 배열로 감싸서 리턴
}
// 5. 메뉴 생성 (시트 열릴 때 자동 실행)
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('🚀 AI 분석 시스템')
.addItem('데이터 분석 실행 (Gemini 2.5 Flash)', 'runAIAnalysis')
.addToUi();
}
// 🧪 5. AI 다중 시나리오 생성 함수 (Gemini가 3가지 전략 제안)
// 🧪 5. AI 다중 시나리오 생성 함수 (예상 판매량 자동 계산 추가)
function runScenarioAnalysis() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const summarySheet = ss.getSheetByName('model_summary');
const uiSheet = ss.getSheetByName('optimizer_ui');
// 1. 데이터 읽기 (총 예산, 계수, 절편)
const totalBudget = uiSheet.getRange("B1").getValue();
const summaryData = summarySheet.getDataRange().getValues();
let coeffs = {};
let intercept = 0;
let coeffInfo = "";
// 계수와 절편 분리해서 저장
for (let i = 1; i < summaryData.length; i++) {
let key = summaryData[i][0].toString().trim();
let val = summaryData[i][1];
if (typeof val === 'number') {
if (key.toLowerCase().includes('intercept') || key === '상수항' || key === '절편') {
intercept = val;
} else {
coeffs[key] = val;
coeffInfo += `- ${key}: ${val}\n`;
}
}
}
if (!coeffInfo) {
SpreadsheetApp.getUi().alert("먼저 'model_summary' 시트에 계수 데이터가 있어야 합니다.");
return;
}
// 2. 프롬프트 작성
const prompt = `
당신은 마케팅 최고 전략가(CMO)입니다.
현재 우리 회사의 광고 채널별 효율(회귀계수)은 아래와 같습니다.
[채널 효율 데이터]
${coeffInfo}
총 예산: $${totalBudget}
위 데이터를 바탕으로 **3가지 서로 다른 마케팅 예산 배분 시나리오**를 제안해주세요.
1. **ROI 극대화형**: 효율이 높은 채널에 예산을 집중 ("선택과 집중")
2. **리스크 분산형**: 효율이 낮아도 골고루 배분하여 안정성 확보 ("안전 제일")
3. **잠재력 실험형**: 효율이 중간인 채널 비중을 높여 성장 가능성 테스트 ("기회 탐색")
결과는 반드시 아래 **JSON 형식**으로만 출력하세요. 설명 없이 JSON만 주세요.
{
"scenarios": [
{
"name": "1. ROI 극대화형",
"description": "설명...",
"allocation": { "TV": 0, "Billboards": 0, ... }
},
{
"name": "2. 리스크 분산형",
"description": "설명...",
"allocation": { "TV": 0, "Billboards": 0, ... }
},
{
"name": "3. 잠재력 실험형",
"description": "설명...",
"allocation": { "TV": 0, "Billboards": 0, ... }
}
]
}
`;
try {
ss.toast("Gemini가 3가지 시나리오를 구상 중입니다...", "🤔 전략 수립 중", 10);
const resultText = callGemini(prompt);
// JSON 추출 및 파싱
let cleanJson = resultText.replace(/```json/g, "").replace(/```/g, "").trim();
const firstBrace = cleanJson.indexOf("{");
const lastBrace = cleanJson.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanJson = cleanJson.substring(firstBrace, lastBrace + 1);
}
let result;
try {
result = JSON.parse(cleanJson);
} catch (e) {
throw new Error("AI 응답을 JSON으로 읽을 수 없습니다. 원본:\n" + resultText);
}
// 4. optimizer_ui 시트에 결과 뿌리기
let startRow = 21;
uiSheet.getRange(startRow, 1).setValue("🤖 AI 추천 시나리오 (3 Options)");
uiSheet.getRange(startRow, 1).setFontWeight("bold").setFontSize(12);
let currentRow = startRow + 2;
// 헤더 생성 (예상 판매량 컬럼 추가!)
const channelKeys = Object.keys(result.scenarios[0].allocation);
const headers = ["시나리오명", "전략 설명", "📈 예상 판매량", "채널별 배분($) -->", ...channelKeys];
// 헤더 쓰기
uiSheet.getRange(currentRow, 1, 1, headers.length).setValues([headers]).setBackground("#EFEFEF").setFontWeight("bold");
currentRow++;
// 데이터 쓰기 (판매량 직접 계산)
result.scenarios.forEach(scenario => {
// 1. 예상 판매량 계산: (각 채널 예산 * 계수)의 합 + 절편
let predictedSales = intercept;
channelKeys.forEach(ch => {
let budget = scenario.allocation[ch] || 0;
let coeff = coeffs[ch] || 0;
predictedSales += budget * coeff;
});
// 2. 행 데이터 구성
let rowData = [
scenario.name,
scenario.description,
Math.round(predictedSales), // 계산된 판매량 (반올림)
"" // 구분용 빈칸
];
// 채널별 예산 추가
for (let i = 4; i < headers.length; i++) {
const chName = headers[i];
rowData.push(scenario.allocation[chName] || 0);
}
uiSheet.getRange(currentRow, 1, 1, rowData.length).setValues([rowData]);
currentRow++;
});
// 숫자 서식 적용 (판매량과 예산 열에 콤마 포맷)
// C열(판매량)과 E열 이후(예산) 전체
uiSheet.getRange(startRow + 3, 3, 3, 1).setNumberFormat("#,##0"); // 판매량
uiSheet.getRange(startRow + 3, 5, 3, channelKeys.length).setNumberFormat("$#,##0"); // 예산
ss.toast("예상 판매량이 포함된 시나리오가 완성되었습니다!", "✅ 성공", 5);
} catch (e) {
SpreadsheetApp.getUi().alert("시나리오 생성 중 오류:\n" + e.toString());
}
}
// 메뉴에 함수 추가
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('🚀 AI 분석 시스템')
.addItem('데이터 분석 실행 (Gemini 2.5 Flash)', 'runAIAnalysis')
.addSeparator() // 구분선
.addItem('💡 3가지 전략 시나리오 제안받기', 'runScenarioAnalysis') // 추가됨
.addToUi();
}
// End of Code.gs