vba를 활용해 엑셀에 chatgpt API를 호출하는 함수를 작성하는 방법을 알려줘. gpt-5-mini를 사용할거야. 한글로 입력하니까 유니코드 고려해서 코딩해줘.
API키 이걸 활용해.
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
/************************************************************
* Google Sheets A열의 한국어 문장을 교정/정리하여 B열에 저장
* - OpenAI Chat Completions API 사용
* - 맞춤법/띄어쓰기 교정, 특수문자·이모티콘 제거,
* 반복표현(ㅋㅋㅋ/ㅎㅎㅎ/ㅠㅠㅠ 등)은 1회로 축약
* - 오류 처리 + 지수 백오프 재시도
************************************************************/
const OPENAI_MODEL = 'gpt-4o-mini';
const MAX_RETRIES = 5; // 최대 재시도 횟수
const INITIAL_BACKOFF_MS = 1000; // 1초(숫자에 밑줄 X)
// 🔐 [권장] 스크립트 속성에 OPENAI_API_KEY 저장 후 사용
function getApiKeyFromProps_() {
return PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
}
// 🔐 [비권장] 하드코딩 위치(테스트용)
// 예: const HARDCODED_API_KEY = 'sk-xxxx'; ← 코드 내 보관은 위험
const HARDCODED_API_KEY = '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'; // 예: 'sk-xxxxx' ← 코드에 보관은 위험합니다.
/** 실행 메뉴 추가 */
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('문장 정리')
.addItem('A열→B열 실행', 'cleanColumnAtoB')
.addToUi();
}
/** 메인: 현재 활성 시트의 A열을 처리해 B열에 기록 */
function cleanColumnAtoB() {
const sheet = SpreadsheetApp.getActiveSheet();
const lastRow = sheet.getLastRow();
if (lastRow < 1) return;
const values = sheet.getRange(1, 1, lastRow, 1).getValues(); // A열
const out = [];
for (let i = 0; i < values.length; i++) {
const raw = (values[i][0] || '').toString().trim();
if (!raw) {
out.push(['']);
continue;
}
try {
const cleanedByAi = callOpenAIKoreanCleanup_(raw);
const finalText = postProcessKorean_(cleanedByAi);
out.push([finalText]);
} catch (err) {
out.push([`[ERROR] ${String(err).slice(0, 300)}`]);
}
}
sheet.getRange(1, 2, out.length, 1).setValues(out); // B열에 기록
}
/** OpenAI 호출: 한국어 문장 교정/정리 */
function callOpenAIKoreanCleanup_(inputText) {
const apiKey = getApiKeyFromProps_() || HARDCODED_API_KEY;
if (!apiKey) {
throw new Error('OpenAI API 키가 없습니다. 스크립트 속성 OPENAI_API_KEY를 설정하거나 HARDCODED_API_KEY에 입력하세요.');
}
const systemPrompt =
'너는 한국어 텍스트 교정 및 정제 도우미야.\n' +
'규칙:\n' +
'- 맞춤법과 띄어쓰기를 정확히 교정할 것.\n' +
'- 특수문자와 이모티콘(emoji)을 모두 삭제할 것.\n' +
'- 반복적인 표현(예: ㅋㅋㅋ, ㅎㅎㅎ, ㅠㅠㅠ 등)은 1회만 남길 것(예: ㅋ, ㅎ, ㅠ).\n' +
'- 군더더기 기호(불필요한 마크업, 중복 공백) 제거.\n' +
'- 결과는 한 줄의 최종 문장만 출력(주석/따옴표/설명 금지).';
const userPrompt =
'아래 한국어 문장을 위 규칙으로 교정/정리해줘. 결과는 문장만 출력해.\n\n문장:\n' +
inputText;
const payload = {
model: OPENAI_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0
};
const headers = {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json'
};
const options = {
method: 'post',
headers: headers,
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
// 지수 백오프 재시도
let backoff = INITIAL_BACKOFF_MS;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const res = UrlFetchApp.fetch(OPENAI_ENDPOINT, options);
const status = res.getResponseCode();
if (status >= 200 && status < 300) {
const json = JSON.parse(res.getContentText('UTF-8'));
const text = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content
? json.choices[0].message.content.trim()
: '';
if (!text) throw new Error('OpenAI 응답 파싱 실패(내용 없음).');
return text;
}
// 재시도 조건: 429(레이트리밋) 또는 5xx
if (status === 429 || (status >= 500 && status < 600)) {
if (attempt < MAX_RETRIES) {
Utilities.sleep(backoff);
backoff = backoff * 2;
continue;
}
}
let errMsg = res.getContentText('UTF-8');
if (errMsg && errMsg.length > 500) errMsg = errMsg.slice(0, 500) + '...';
throw new Error('OpenAI API 오류 (HTTP ' + status + '): ' + (errMsg || '응답 본문 없음'));
}
throw new Error('재시도 한도를 초과했습니다.');
}
/** 추가 후처리: 이모지/특수문자 제거, 반복 축약, 공백 정리 */
function postProcessKorean_(text) {
let t = (text || '').toString();
// 반복 표현 1회로 축약 (ㅋ, ㅎ, ㅠ, ㅜ, ㅡ)
t = t
.replace(/([ㅋㅎㅠㅜㅡ])\1+/g, '$1') // ㅋㅋㅋ→ㅋ, ㅎㅎㅎ→ㅎ, ㅠㅠㅠ→ㅠ
.replace(/([.!?])\1{1,}/g, '$1'); // !!!→!
// 허용 문자만 남기기: 한글(가-힣), 영문, 숫자, 공백, 기본 문장부호
// 남김: . , ? ! : ; " ' ( ) / -
t = t.replace(/[^가-힣a-zA-Z0-9\s\.\,\?\!\:\;\"\'\(\)\/\-]/g, '');
// 공백 정리
t = t.replace(/\s{2,}/g, ' ').trim();
return t;
}
import streamlit as st
import pandas as pd
import numpy as np
import altair as alt
from dateutil.relativedelta import relativedelta
st.set_page_config(page_title="Sales Explorer", layout="wide")
# ====== 데이터 로딩 ======
@st.cache_data
def load_data(file):
if isinstance(file, str):
df = pd.read_excel(file, sheet_name=0, engine="openpyxl")
else:
# st.file_uploader 객체
df = pd.read_excel(file, sheet_name=0, engine="openpyxl")
# 타입 정리
date_cols = [c for c in df.columns if "date" in c.lower()]
for c in date_cols:
df[c] = pd.to_datetime(df[c], errors="coerce")
# 매출액 계산: unit_price * quantity * (1 - discount)
# (없으면 0으로 처리)
for c in ["unit_price", "quantity", "discount"]:
if c not in df.columns:
df[c] = 0.0
df["sales"] = df["unit_price"].astype(float) * df["quantity"].astype(float) * (1 - df["discount"].astype(float))
# 보조 컬럼
if "country" not in df.columns:
df["country"] = "Unknown"
if "category_name" not in df.columns:
df["category_name"] = "Unknown"
if "order_date" not in df.columns:
# 첫 번째 날짜형 컬럼을 order_date로 사용
date_candidates = [c for c in date_cols if df[c].notna().any()]
df["order_date"] = df[date_candidates[0]] if date_candidates else pd.NaT
df["year"] = df["order_date"].dt.year
return df
st.title("📊 Sales Explorer (Streamlit)")
# 파일 입력: 로컬 파일명 또는 업로더
with st.sidebar:
st.header("⚙️ 데이터 선택")
default_path = "6교시 데이터 시각화 실습.xlsx"
use_uploader = st.toggle("파일 업로드 사용", value=False)
if use_uploader:
uploaded = st.file_uploader("엑셀 파일(.xlsx) 업로드", type=["xlsx"])
if uploaded is None:
st.info("좌측에서 엑셀 파일을 업로드해주세요.")
st.stop()
df = load_data(uploaded)
else:
st.caption(f"현재 경로의 파일 사용: **{default_path}**")
try:
df = load_data(default_path)
except Exception as e:
st.error(f"파일을 열 수 없습니다: {e}")
st.stop()
# ====== 사이드바 필터 ======
with st.sidebar:
st.header("🔎 필터")
# 국가 / 카테고리
countries = ["(All)"] + sorted(df["country"].dropna().astype(str).unique().tolist())
cats = ["(All)"] + sorted(df["category_name"].dropna().astype(str).unique().tolist())
country_sel = st.selectbox("Country", countries, index=0)
cat_sel = st.selectbox("Category", cats, index=0)
# 연도 범위
year_min = int(df["year"].min()) if df["year"].notna().any() else 1990
year_max = int(df["year"].max()) if df["year"].notna().any() else 1990
year_range = st.slider("Year Range", min_value=year_min, max_value=year_max, value=(year_min, year_max), step=1)
st.divider()
st.header("📈 차트 옵션")
freq = st.radio("집계 주기", ["Monthly", "Quarterly", "Yearly"], horizontal=True)
chart_type = st.radio("차트 타입", ["Line", "Bar"], horizontal=True)
metric = st.selectbox("지표(집계)", ["sales", "quantity", "freight"] if "freight" in df.columns else ["sales", "quantity"])
normalize = st.checkbox("범주별 정규화(백분율)", value=False, help="선택 카테고리/국가별로 비중(%) 보기")
# ====== 필터 적용 ======
mask = pd.Series(True, index=df.index)
if country_sel != "(All)":
mask &= (df["country"].astype(str) == country_sel)
if cat_sel != "(All)":
mask &= (df["category_name"].astype(str) == cat_sel)
mask &= df["year"].between(year_range[0], year_range[1])
filtered = df.loc[mask].copy()
if filtered.empty:
st.warning("선택한 조건에 해당하는 데이터가 없습니다.")
st.stop()
# ====== 시간 리샘플 ======
# 집계 기준 날짜: order_date
filtered = filtered[filtered["order_date"].notna()].copy()
if filtered.empty:
st.warning("유효한 날짜(order_date)가 없습니다.")
st.stop()
# 시계열 키 만들기
if freq == "Monthly":
filtered["period"] = filtered["order_date"].dt.to_period("M").dt.to_timestamp()
elif freq == "Quarterly":
filtered["period"] = filtered["order_date"].dt.to_period("Q").dt.to_timestamp()
else:
filtered["period"] = filtered["order_date"].dt.to_period("Y").dt.to_timestamp()
# 집계용 그룹 키(하나의 차트에서 카테고리 또는 국가로 분해)
group_key = "category_name" if cat_sel == "(All)" else ("country" if country_sel == "(All)" else None)
agg_cols = [c for c in ["sales", "quantity", "freight"] if c in filtered.columns]
group_cols = ["period"] + ([group_key] if group_key else [])
agg = filtered.groupby(group_cols, dropna=False)[agg_cols].sum().reset_index()
# 정규화(백분율)
if normalize and group_key:
agg["total"] = agg.groupby("period")[metric].transform("sum")
agg[metric] = np.where(agg["total"] > 0, agg[metric] / agg["total"] * 100, 0)
y_title = f"{metric} (%)"
else:
y_title = metric
# ====== Altair 차트 ======
alt.data_transformers.disable_max_rows()
base = alt.Chart(agg).encode(
x=alt.X("period:T", title="Period"),
y=alt.Y(f"{metric}:Q", title=y_title),
tooltip=[alt.Tooltip("period:T", title="Period"),
alt.Tooltip(metric, title=metric),
alt.Tooltip(group_key, title=group_key)] if group_key else
[alt.Tooltip("period:T", title="Period"),
alt.Tooltip(metric, title=metric)]
)
if group_key:
base = base.encode(color=alt.Color(f"{group_key}:N", legend=alt.Legend(title=group_key)))
if chart_type == "Line":
chart = base.mark_line(point=True)
else:
chart = base.mark_bar()
# 범례/마우스오버 인터랙션(하이라이트)
if group_key:
selection = alt.selection_point(fields=[group_key], bind="legend")
chart = chart.add_params(selection).encode(opacity=alt.condition(selection, alt.value(1), alt.value(0.25)))
st.subheader("📈 시계열 매출/수량/운임 추이 (상호작용 차트)")
st.caption("사이드바에서 필터·집계주기·차트 타입을 바꾸면 즉시 반영됩니다. 범례 클릭으로 특정 항목 하이라이트 가능합니다.")
st.altair_chart(chart.properties(height=420).interactive(), use_container_width=True)
# ====== 원자료 & 다운로드 ======
with st.expander("🔍 현재 필터가 적용된 데이터(원본 레코드) 보기"):
st.dataframe(filtered, use_container_width=True)
# 집계 테이블도 제공
with st.expander("📄 현재 차트 데이터(집계 결과) 보기"):
st.dataframe(agg, use_container_width=True)
def to_csv_bytes(df):
return df.to_csv(index=False).encode("utf-8-sig")
st.download_button("⬇️ 필터 적용 원자료 CSV 다운로드", data=to_csv_bytes(filtered), file_name="filtered_raw.csv", mime="text/csv")
st.download_button("⬇️ 차트용 집계 CSV 다운로드", data=to_csv_bytes(agg), file_name="chart_agg.csv", mime="text/csv")