Lewati ke konten utama

Proyek GPT-5.2: Bangun Generator Excel dan PowerPoint

Pelajari cara menggunakan GPT-5.2 untuk mengubah CSV apa pun menjadi workbook Excel dan deck PowerPoint berbasis sitasi di dalam aplikasi Streamlit.
Diperbarui 28 Sep 2026  · 13 mnt Baca

Jelajahi bersama AI

ChatGPTClaudePerplexity

GPT-5.2 adalah model yang andal untuk menghasilkan alur kerja dan pipeline multi-langkah yang memerlukan keandalan, struktur, dan lebih sedikit halusinasi. OpenAI menyoroti peningkatan pada pekerjaan konteks panjang dan kinerja yang lebih baik pada tugas bergaya spreadsheet.

Dalam tutorial ini, kita akan membuat aplikasi Streamlit yang berperan seperti analis junior:

  • Unggah CSV dan tulis tujuan (contoh: “pembaruan 6 slide berfokus pada pertumbuhan, retensi, unit economics, runway”)
  • Hasilkan rencana JSON yang terkunci skema
  • Produksi workbook Excel dengan data mentah, metrik turunan, dan bagan
  • Terakhir, hasilkan deck PowerPoint yang sesuai dengan sitasi yang tepat

Kuncinya adalah GPT-5.2 tidak langsung “membuat slide.” Model ini menghasilkan rencana ketat yang dijalankan kode Anda secara deterministik.

Pastikan juga membaca panduan kami tentang model terbaru OpenAI, GPT-5.5, GPT-5.4, GPT-5.3 Codex, dan GPT-5.3 Instant

Apa Itu GPT-5.2?

GPT-5.2 adalah model seri GPT-5 terbaru dari OpenAI dengan peningkatan untuk tugas end-to-end seperti membuat spreadsheet, membangun presentasi, menulis dan men-debug kode, memahami konteks panjang, dan menggunakan alat. 

Berikut adalah properti GPT-5.2 yang paling relevan untuk proyek ini:

  • Penalaran konteks panjang: GPT-5.2 Thinking digambarkan sebagai yang terdepan pada evaluasi penalaran konteks panjang (OpenAI MRCRv2), dengan implikasi praktis untuk bekerja secara koheren pada dokumen yang sangat panjang dan input multi-berkas.
  • Eksekusi multi-langkah: Model ini unggul pada proyek kompleks multi-langkah, termasuk pemanggilan alat yang lebih kuat dan perilaku horizon panjang, yang langsung selaras dengan perencanaan terkunci skema dan loop rendering deterministik kita.
  • Faktualitas yang lebih baik: GPT-5.2 Thinking menghasilkan lebih sedikit respons keliru dibanding GPT-5.1 Thinking, yang membantu di sini karena mengurangi kemungkinan menghasilkan teks/angka yang mungkin melenceng dari rentang Excel yang kami sitir.
  • Trade-off latensi: Di API, Anda dapat beriterasi dengan GPT-5.2 Instant untuk umpan balik cepat, lalu beralih ke GPT-5.2 Thinking atau Pro saat Anda menginginkan perencanaan end-to-end yang kuat dan artefak berkualitas lebih tinggi, dengan catatan bahwa generasi yang lebih kompleks bisa memakan waktu menit.

Proyek Contoh GPT-5.2: Bangun Generator Structured Artifacts

Di bagian ini, kita akan membangun generator structured artifacts (Excel dan PowerPoint) menggunakan model GPT 5.2 yang dibungkus dalam aplikasi Streamlit. Secara garis besar, aplikasi Streamlit final melakukan hal-hal berikut:

  1. Membaca CSV dan membangun workbook kanonik

  2. Lalu memanggil GPT-5.2 via OpenAI atau OpenRouter dengan Structured Outputs (response_format: json_schema)

  3. Memvalidasi rencana yang dikembalikan terhadap skema kita

  4. Merender bagan ke dalam Excel dan menatanya sesuai palet merek kita

  5. Terakhir, menghasilkan PowerPoint dengan bagan tersemat beserta sitasinya

Demo akhir

Mari kita bangun selangkah demi selangkah.

Langkah 1: Prasyarat dan penyiapan

Sebelum membuat workbook Excel dan deck PowerPoint dengan GPT-5.2, kita perlu menyiapkan lingkungan lokal. Kita akan mulai dengan memasang pustaka inti dan mengatur kunci API sebagai variabel lingkungan.

pip install streamlit pandas pydantic python-dotenv requests openpyxl python-pptx

Kita akan menggunakan Streamlit untuk membangun UI web interaktif, Pandas untuk memuat dan menyiapkan CSV, Pydantic untuk memvalidasi rencana JSON terkunci skema GPT-5.2. Kita juga menggunakan python-dotenv untuk memuat kunci seperti OPENROUTER_API_KEY/OPENAI_API_KEY dari berkas .env secara aman. Pustaka requests memanggil endpoint yang kompatibel OpenRouter/OpenAI, sementara openpyxl menghasilkan workbook dan bagan Excel, dan python-pptx membuat deck PowerPoint dengan bagan native (dapat diedit). 

Selanjutnya, kita mengatur kunci API:

export OPENAI_API_KEY="your_key"
export OPENROUTER_API_KEY="your_key"

Perlu dicatat bahwa GPT 5.2 dapat diakses melalui beberapa layanan, termasuk API resmi OpenAI serta layanan seperti OpenRouter dan lainnya. 

Disarankan menggunakan berkas .env untuk menyimpan kunci API secara lokal:

Di dalam berkas .env, simpan hal berikut:

OPENROUTER_API_KEY="your_key"
OPENAI_API_KEY="your_key"

Lalu muat kunci API ini menggunakan pustaka load_dotenv:

from dotenv import load_dotenv
load_dotenv()

Pada tahap ini, lingkungan kita siap melakukan autentikasi dengan API OpenAI.

Langkah 2: Mendefinisikan skema rencana

Berikutnya, kita mendefinisikan rencana artefak terkunci skema yang terdiri dari cetak biru JSON ketat yang memberi tahu GPT-5.2 dengan tepat bagan mana yang harus dibuat dan bagaimana menyusun slide, sehingga output model dapat didekodekan dan divalidasi menggunakan Structured Outputs melalui response_format: { type: "json_schema", strict: true } alih-alih teks bebas.

import os
import io
import re
import json
import math
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Literal, Optional, Tuple
import requests
import pandas as pd
import numpy as np
import streamlit as st
import openpyxl 
from dotenv import load_dotenv
from pydantic import BaseModel, Field, ValidationError
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, Alignment, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.chart import LineChart, BarChart, Reference
from openpyxl.chart.series import SeriesLabel
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
ChartKind = Literal["line", "bar"]
def safe_float(x) -> float:
    try:
        if x is None:
            return 0.0
        if isinstance(x, (int, float)):
            return float(x)
        return float(str(x).replace(",", "").replace("$", ""))
    except Exception:
        return 0.0
def fmt_money(v: float) -> str:
    sign = "-" if v < 0 else ""
    v = abs(v)
    if v >= 1_000_000:
        return f"{sign}${v/1_000_000:.2f}M"
    if v >= 1_000:
        return f"{sign}${v/1_000:.1f}K"
    return f"{sign}${v:.0f}"
def fmt_pct(v: float) -> str:
    if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
        return "n/a"
    return f"{v*100:.1f}%"
class ChartSeriesSpec(BaseModel):
    name: str
    metric: str  
class ChartSpec(BaseModel):
    id: str
    kind: ChartKind
    title: str
    x_metric: str  
    series: List[ChartSeriesSpec]
    anchor: str 
class SlideBullet(BaseModel):
    text: str
    citations: List[str] = Field(min_length=1, max_length=4)
class SlideSpec(BaseModel):
    title: str
    chart_id: Optional[str] = None
    bullets: List[SlideBullet] = Field(min_length=2, max_length=4)
class ArtifactPlan(BaseModel):
    charts: List[ChartSpec] = Field(min_length=3, max_length=6)
    slides: List[SlideSpec] = Field(min_length=4, max_length=10)

Potongan kode di atas mendefinisikan rencana menggunakan model Pydantic, yang melakukan tiga hal penting:

  • Struktur rencana: ChartKind = Literal["line", "bar"] membatasi jenis bagan ke enum kecil, ChartSpec menangkap kontrak bagan penuh, dan SlideSpec mendefinisikan tata letak slide sambil opsional menautkan slide ke bagan melalui chart_id.

  • Penegakan kualitas otomatis: Batasan seperti Field() memastikan kita selalu mendapatkan cukup bagan/slide, setiap slide memiliki cukup butir, dan setiap butir menyertakan sitasi.

  • Perencanaan dan rendering: GPT-5.2 hanya menghasilkan cetak biru, yaitu bagan, slide, dan sitasi, sebagai JSON terstruktur, sementara sisanya ditangani kode: eksekusi, pemformatan Excel, rendering bagan, dan tata letak deck. 

Dengan skema ini, GPT-5.2 menjadi perencana yang menghasilkan cetak biru JSON tervalidasi.

Langkah 3: Memanggil GPT-5.2 dengan structured outputs

Dengan skema rencana didefinisikan, langkah berikutnya adalah memanggil GPT-5.2 dalam “mode skema”—sehingga ia mengembalikan cetak biru JSON yang dapat Anda validasi dan eksekusi. Alih-alih berharap model “berperilaku baik,” kita memberi tahu API untuk mendekode langsung ke JSON Schema Anda menggunakan response_format: { type: "json_schema", ... strict: true }, persis seperti tujuan Structured Outputs.

def chat_json_schema(
    model: str,
    messages: list,
    json_schema: dict,
    api_key: str,
    temperature: float = 0.2,
    max_tokens: int = 6000,
    timeout_s: int = 120,
    api_provider: Optional[str] = None, 
) -> dict:
    if api_provider is None:
        if "/" in model:
            api_provider = "openrouter"
        elif os.getenv("OPENAI_API_KEY") and api_key == os.getenv("OPENAI_API_KEY"):
            api_provider = "openai"
        elif os.getenv("OPENROUTER_API_KEY") and api_key == os.getenv("OPENROUTER_API_KEY"):
            api_provider = "openrouter"
        else:
            api_provider = "openrouter"
    if api_provider == "openai":
        url = "https://api.openai.com/v1/chat/completions"
        headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        }
        if "/" in model:
            model = model.split("/", 1)[1]
    else: 
        url = "https://openrouter.ai/api/v1/chat/completions"
        headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        }
        if os.getenv("OPENROUTER_SITE_URL"):
            headers["HTTP-Referer"] = os.getenv("OPENROUTER_SITE_URL")
        if os.getenv("OPENROUTER_APP_NAME"):
            headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME")
    payload = {
        "model": model,
        "messages": messages,
        "temperature": temperature,
        "max_tokens": max_tokens,
        "response_format": {
            "type": "json_schema",
            "json_schema": {
                "name": "artifact_plan",
                "strict": True,
                "schema": json_schema,
            },
        },
    }
    r = requests.post(url, headers=headers, data=json.dumps(payload), timeout=timeout_s)
    r.raise_for_status()
    data = r.json()
    if "error" in data:
        provider_name = "OpenAI" if api_provider == "openai" else "OpenRouter"
        raise RuntimeError(f"{provider_name} error: {data['error']}")
    finish_reason = data["choices"][0].get("finish_reason")
    if finish_reason == "length":
        raise RuntimeError("Model output truncated (max_tokens). Increase max_tokens or reduce schema size.")
    content = data["choices"][0]["message"]["content"]
    if isinstance(content, dict):
        return content
    try:
        return json.loads(content)
    except json.JSONDecodeError as e:
        preview = (content or "")[:800]
        raise RuntimeError(f"Failed to parse JSON. Preview:\n{preview}") from e

Fungsi chat_json_schema() adalah satu-satunya gerbang yang mengubah prompt menjadi rencana tervalidasi:

  1. Perutean penyedia (OpenRouter vs OpenAI): Jika nama model memiliki namespace (openai/gpt-5.2), Anda default ke OpenRouter karena itu format yang digunakan OpenRouter untuk perutean model. Jika Anda secara eksplisit mengatur api_provider="openai", Anda mengirim payload Chat Completions yang sama ke endpoint OpenAI. Silakan lewati OpenRouter sepenuhnya jika diinginkan.

  2. Pengaturan endpoint: Untuk OpenAI, Anda memanggil endpoint https://api.openai.com/v1/chat/completions dengan Bearer auth standar. Untuk OpenRouter, Anda memanggil endpoint https://openrouter.ai/api/v1/chat/completions dan opsional menyertakan header atribusi seperti HTTP-Referer dan X-Title, yang didokumentasikan OpenRouter sebagai metadata yang direkomendasikan.

  3. Dekode skema via response_format: Blok payload kunci adalah response_format: { type: "json_schema", json_schema: { strict: true, schema: ... } }, yang menginstruksikan model untuk menghasilkan output yang dibatasi oleh JSON Schema kita.

Selanjutnya, kita akan memvalidasi rencana JSON model dengan Pydantic, dan hanya jika lolos pemeriksaan batas, kita lanjutkan untuk merender bagan Excel dan menghasilkan deck PowerPoint.

Langkah 4: Fungsi pembantu

Sebelum merender apa pun, kita memerlukan lapisan kecil yang membuat CSV apa pun yang diunggah menjadi aman untuk dianalisis dan konsisten untuk direferensikan. Fungsi pembantu ini melakukan empat tugas: memuat dan membersihkan CSV, membuat tabel model turunan, menghitung set fakta ringkas untuk prompt, dan membuat output Excel mudah dibaca dengan kolom auto-size.

Langkah 4.1: Memuat dan memvalidasi CSV

GPT 5.2 tidak dapat secara andal merencanakan bagan dan slide jika tabel input kosong atau memiliki kolom numerik yang disimpan sebagai string. Fungsi ini menstandarkan dataset sehingga langkah hilir tidak perlu menebak.

def load_and_validate_csv(csv_path: str) -> pd.DataFrame:
    df = pd.read_csv(csv_path) 
    if len(df) == 0:
        raise ValueError("CSV is empty")   
    if len(df.columns) == 0:
        raise ValueError("CSV has no columns")
    date_cols = [c for c in df.columns if any(x in c.lower() for x in ['date', 'time', 'month', 'year', 'period', 'week'])]
    if date_cols:
        date_col = date_cols[0]
        df[date_col] = df[date_col].astype(str)
    for col in df.columns:
        if col not in date_cols:  
            try:
                df[col] = pd.to_numeric(df[col])
                if df[col].dtype in ['float64', 'int64']:
                    df[col] = df[col].fillna(0)
            except (ValueError, TypeError):
                pass
    return df

Fungsi di atas:

  • Membaca berkas ke DataFrame menggunakan pd.read_csv.
  • Mendeteksi kolom menggunakan heuristik nama (date, month, week, period) dan memaksanya menjadi string. Ini menjaga label tetap stabil pada bagan dan teks slide.
  • Terakhir, mengonversi kolom non-tanggal menjadi numerik menggunakan pd.to_numeric, dan mengisi nilai numerik yang hilang dengan nol untuk menghindari NaN berantai nanti. 

Di akhir langkah ini, Anda memiliki DataFrame yang tidak kosong dan memiliki kolom numerik.

Langkah 4.2: Menghitung DataFrame model

CSV mentah itu berantakan dan tidak konsisten lintas domain. Lembar model berfungsi sebagai lapisan normalisasi di mana kita menambahkan metrik turunan yang berguna untuk pembuatan bagan, tanpa mengharuskan pengguna menghitungnya secara manual.

def compute_model_df(raw_df: pd.DataFrame) -> pd.DataFrame:
    df = raw_df.copy()
    numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
    if "new_mrr" in df.columns and "churned_mrr" in df.columns:
        df["net_new_mrr"] = df["new_mrr"] - df["churned_mrr"]
    revenue_cols = [c for c in df.columns if any(x in c.lower() for x in ['revenue', 'mrr', 'income', 'sales'])]
    cost_cols = [c for c in df.columns if any(x in c.lower() for x in ['cost', 'cogs', 'expense'])]
    if revenue_cols and cost_cols:
        revenue = df[revenue_cols[0]]
        cost = df[cost_cols[0]]
        df["gross_margin"] = ((revenue - cost) / revenue.replace(0, pd.NA)).fillna(0)
    for col in numeric_cols[:5]: 
        if col not in df.columns:
            continue
        df[f"{col}_change"] = df[col].pct_change().fillna(0)
    return df

Fungsi compute_model_df() melakukan dua hal yang berguna secara umum:

  • Mencari kolom nilai output primer dan kolom input sekunder lalu menghitung rasio sederhana untuk menangkap seberapa besar sinyal primer yang tersisa setelah memperhitungkan sinyal sekunder.

  • Untuk sejumlah kecil kolom numerik, menghitung pct_change(), yang secara default mengembalikan perubahan fraksional dari baris sebelumnya, sehingga menjadi baseline mudah untuk menangkap pergerakan pada metrik deret waktu apa pun.

Langkah 4.3: Menghitung fakta

Bahkan dengan Structured Outputs, kita tetap ingin memberi GPT-5.2 ringkasan ringkas tentang apa yang berubah selama periode tersebut. Fakta-fakta ini juga berfungsi sebagai prompt yang dapat digunakan ulang model dalam butir slide.

def compute_facts(model_df: pd.DataFrame) -> Dict[str, str]:
    def delta(a: float, b: float) -> float:
        return 0.0 if a == 0 else (b - a) / a
    if len(model_df) == 0:
        return {"error": "No data"}
    first = model_df.iloc[0]
    last = model_df.iloc[-1]
    date_cols = [c for c in model_df.columns if any(x in c.lower() for x in ['date', 'time', 'month', 'year', 'period', 'week'])]
    period_col = date_cols[0] if date_cols else model_df.columns[0]   
    facts = {
        "period": f"{first[period_col]} → {last[period_col]}",
        "row_count": len(model_df),
        "column_count": len(model_df.columns),
    }
    numeric_cols = model_df.select_dtypes(include=[np.number]).columns.tolist()
    for i, col in enumerate(numeric_cols[:5]):
        if col in first.index and col in last.index:
            start_val = safe_float(first[col])
            end_val = safe_float(last[col])
            avg_val = safe_float(model_df[col].mean())
            facts[f"{col}_start"] = fmt_money(start_val) if start_val >= 100 else f"{start_val:.2f}"
            facts[f"{col}_end"] = fmt_money(end_val) if end_val >= 100 else f"{end_val:.2f}"
            facts[f"{col}_change_pct"] = fmt_pct(delta(start_val, end_val))
            facts[f"{col}_avg"] = fmt_money(avg_val) if avg_val >= 100 else f"{avg_val:.2f}"
    return facts

Kita terlebih dahulu memilih baris pertama dan terakhir untuk mendefinisikan jendela periode. Lalu kita memilih satu kolom (date, month, week, period) untuk pelaporan yang mudah dibaca manusia.

Untuk sejumlah kecil kolom numerik, kita menghitung snapshot nilai awal, akhir, rata-rata, dan persentase perubahan keseluruhan dari awal ke akhir. Ini menghasilkan kamus sinyal ringan yang membantu model menulis butir slide.

Langkah 4.4: Auto-size kolom Excel

Lebar kolom default Excel membuat workbook yang dihasilkan terlihat kasar. Auto-size adalah langkah pemolesan sederhana yang langsung meningkatkan kegunaan.

def autosize_columns(ws, max_width: int = 45):
    for col in ws.columns:
        max_len = 0
        col_letter = get_column_letter(col[0].column)
        for cell in col:
            if cell.value is None:
                continue
            max_len = max(max_len, len(str(cell.value)))
        ws.column_dimensions[col_letter].width = min(max_len + 2, max_width)

Fungsi autosize_columns() memindai setiap kolom, menemukan panjang string maksimum, dan mengatur ws.column_dimensions[col_letter].width. Lalu pustaka OpenPyXL mengekspos column_dimensions dan properti width-nya untuk mengontrol ukuran tampilan kolom di Excel. 

Selanjutnya, kita pasangkan tabel model yang telah dibersihkan dan fakta ke dalam prompt perencanaan GPT-5.2, validasi rencana yang dikembalikan, lalu render bagan dan slide.

Langkah 5: Bangun workbook 

Menggunakan tabel model yang telah dibersihkan dan ringkasan fakta ringan dari langkah sebelumnya, kita sekarang menghasilkan workbook Excel yang deterministik dengan openpyxl.

def build_workbook_base(raw_df: pd.DataFrame, model_df: pd.DataFrame, out_xlsx: str) -> Tuple[str, int]:
    wb = Workbook()
    wb.remove(wb.active)
    header_fill = PatternFill("solid", fgColor="F2F2F2")
    header_font = Font(bold=True)
    ws_raw = wb.create_sheet("Raw")
    for j, col in enumerate(raw_df.columns, start=1):
        c = ws_raw.cell(row=1, column=j, value=col)
        c.font = header_font
        c.fill = header_fill
        c.alignment = Alignment(horizontal="center", vertical="center")
    for i, row in enumerate(raw_df.itertuples(index=False), start=2):
        for j, val in enumerate(row, start=1):
            ws_raw.cell(row=i, column=j, value=val)
    ws_raw.freeze_panes = "A2"
    autosize_columns(ws_raw)
    row_end = 1 + len(raw_df)
    ws_model = wb.create_sheet("Model")
    for j, col in enumerate(model_df.columns, start=1):
        c = ws_model.cell(row=1, column=j, value=col)
        c.font = header_font
        c.fill = header_fill
        c.alignment = Alignment(horizontal="center", vertical="center")
    for i, row in enumerate(model_df.itertuples(index=False), start=2):
        for j, val in enumerate(row, start=1):
            ws_model.cell(row=i, column=j, value=val)
    ws_model.freeze_panes = "A2"
    autosize_columns(ws_model)
    col_index = {name: idx + 1 for idx, name in enumerate(model_df.columns)}
    money_keywords = ['mrr', 'revenue', 'income', 'sales', 'cost', 'expense', 'cash', 'balance', 'burn', 'price', 'amount', 'fee']
    pct_keywords = ['margin', 'rate', 'ratio', 'pct', 'percent', 'change', 'growth']
    for col_name, col_idx in col_index.items():
        col_lower = col_name.lower()
        is_money = any(kw in col_lower for kw in money_keywords)
        is_pct = any(kw in col_lower for kw in pct_keywords) or 'margin' in col_lower
        is_large_number = False
        if col_name in model_df.columns:
            sample_vals = model_df[col_name].dropna()
            if len(sample_vals) > 0 and pd.api.types.is_numeric_dtype(sample_vals):
                max_val = abs(sample_vals.max())
                is_large_number = max_val > 1000
        for r in range(2, row_end + 1):
            if is_money or is_large_number:
                ws_model.cell(row=r, column=col_idx).number_format = "$#,##0"
            elif is_pct:
                ws_model.cell(row=r, column=col_idx).number_format = "0.0%"
            elif pd.api.types.is_numeric_dtype(model_df[col_name]):
                ws_model.cell(row=r, column=col_idx).number_format = "0.0"
    ws_charts = wb.create_sheet("Charts")
    ws_charts["A1"] = "Charts"
    ws_charts["A1"].font = Font(bold=True)
    wb.save(out_xlsx)
    return out_xlsx, row_end

Berikut yang dilakukan pembangun workbook ini:

  • Membuat kerangka workbook: Workbook() membuat objek workbook baru, lalu wb.remove(wb.active) menghapus sheet default agar kita sepenuhnya mengontrol urutan sheet. Worksheet baru ditambahkan secara eksplisit dengan wb.create_sheet("Raw"), wb.create_sheet("Model"), dan wb.create_sheet("Charts").

  • Menulis sheet Raw: Kita menulis baris header menggunakan Font, PatternFill, dan Alignment, lalu menambahkan setiap baris dari raw_df. Fungsi .autosize_columns(ws_raw) membantu keterbacaan tanpa pengubahan ukuran manual.

  • Menghitung batas:  Kita gunakan row_end = 1 + len(raw_df), yang memberi baris terakhir stabil sehingga kita dapat dengan aman menghasilkan rentang seperti A2:A{row_end} untuk kategori bagan dan sitasi.

  • Menerapkan heuristik format angka: Berikutnya, kita atur cell.number_format berdasarkan kata kunci nama kolom dan dtype sehingga Excel menampilkan nilai sebagai mata uang, persentase, atau angka biasa. 

  • Membuat sheet Charts: Pernyataan wb.create_sheet("Charts") memberi kita kanvas stabil untuk jangkar bagan. Ini sesuai dengan alur kerja bagan openpyxl di mana kita membuat LineChart atau BarChart, mengikat rentang via Reference, mengatur kategori dengan set_categories, lalu menempatkannya dengan ws.add_chart().

Setelah langkah ini, Anda memiliki berkas Excel konsisten yang dapat direferensikan tahap berikutnya dengan rentang sel yang presisi.
Langkah 6: Render bagan 

Kini struktur workbook telah tetap, kita dapat merender bagan ke dalam sheet Charts dan menerapkan tampilan merek yang konsisten. Saya menerapkan palet warna DataCamp dengan:

  • Warna primer: #01ef63
  • Warna sekunder: #203147
def add_charts_to_workbook(xlsx_path: str, charts: List[ChartSpec], metric_to_range: Dict[str, str]):    
    wb = load_workbook(xlsx_path)
    ws_model = wb["Model"]
    ws_charts = wb["Charts"]
    header_map = {ws_model.cell(1, c).value: c for c in range(1, ws_model.max_column + 1)}
    def ref_from_metric(metric: str, row_start: int, row_end: int) -> Reference:
        if metric not in header_map:
            raise ValueError(f"Unknown metric in Model sheet: {metric}")
        col = header_map[metric]
        return Reference(ws_model, min_col=col, min_row=row_start, max_col=col, max_row=row_end)
    for ch in charts:
        chart = LineChart() if ch.kind == "line" else BarChart()
        chart.title = ch.title
        x_metric = ch.x_metric
        if x_metric not in header_map:
            date_keywords = ['date', 'time', 'month', 'year', 'period', 'week']
            date_cols = [col for col in header_map.keys() if col and any(kw in str(col).lower() for kw in date_keywords)]
            if date_cols:
                x_metric = date_cols[0]
            else:
                x_metric = list(header_map.keys())[0] if header_map else "month"       
        x_ref = ref_from_metric(x_metric, 2, ws_model.max_row)
        chart.set_categories(x_ref)
        chart_colors = ["01EF63", "203147"]  
        for i, s in enumerate(ch.series):
            y_ref = ref_from_metric(s.metric, 2, ws_model.max_row)
            chart.add_data(y_ref, titles_from_data=False)
            chart.series[-1].title = SeriesLabel(v=s.name)
            series = chart.series[-1]
            color_hex = chart_colors[i % len(chart_colors)]
            try:
                if ch.kind == "line":
                    series.graphicalProperties.line.solidFill = color_hex
                    series.graphicalProperties.line.width = 30000 
                else:
                    series.graphicalProperties.solidFill = color_hex
            except AttributeError:
                pass
        anchor = ch.anchor.strip()
        if "!" in anchor:
            anchor = anchor.split("!")[-1]
        if anchor and anchor.isalpha():
            anchor = f"{anchor}2"
        if not re.match(r'^[A-Z]+[0-9]+

Fungsi add_charts_to_workbook() adalah mesin perenderan bagan yang mengubah rencana skema kita menjadi visual Excel berbranding.

  • Memuat workbook: Kita mulai dengan membuka berkas Excel yang dihasilkan dengan load_workbook(xlsx_path). Selanjutnya, kita membangun header_map dari baris pertama, memetakan setiap nama kolom ke indeks kolom Excel-nya. 

  • Membangun referensi rentang sel: Fungsi Reference() mendefinisikan rentang tepat untuk diplot, dan chart.set_categories(x_ref) menghubungkan kategori sumbu-X. Pola standarnya adalah membuat objek referensi, menambahkan data, lalu mengatur kategori. 

  • Instansiasi jenis bagan: Untuk setiap spesifikasi bagan, kita membuat LineChart() atau BarChart(), dan mengatur chart.title. 

  • Menambahkan palet merek: Untuk setiap seri dalam spesifikasi, kita mengikat Y-range dengan chart.add_data(), lalu mengatur label tampilan menggunakan SeriesLabel(). 

Terakhir, fungsi ws_charts.add_chart() menempatkan setiap bagan pada sel kiri-atas tertentu. 

Langkah 7: Render PowerPoint

Langkah ini adalah saat rencana menjadi deck slide nyata. Kita membuat PowerPoint, menata judul dan butir, menyematkan bagan yang dibangun dari tabel yang sama seperti di Excel, dan menambahkan footer yang mempertahankan sitasi rentang sel dari rencana.

def build_pptx(out_pptx: str, model_df: pd.DataFrame, plan: ArtifactPlan):
    prs = Presentation()
    blank = prs.slide_layouts[6]
    chart_by_id = {c.id: c for c in plan.charts}
    def add_title(slide, title: str):
        box = slide.shapes.add_textbox(Inches(0.5), Inches(0.2), Inches(9.0), Inches(0.6))
        tf = box.text_frame
        tf.clear()
        p = tf.paragraphs[0]
        p.text = title
        p.font.size = Pt(32)
        p.font.bold = True
    def add_bullets(slide, bullets: List[SlideBullet], left, top, width, height):
        box = slide.shapes.add_textbox(left, top, width, height)
        tf = box.text_frame
        tf.word_wrap = True
        tf.clear()
        for i, b in enumerate(bullets):
            p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
            p.text = b.text
            p.font.size = Pt(18)
            p.level = 0
            p.space_before = Pt(4)
            p.space_after = Pt(8)
    def add_sources_footer(slide, bullets: List[SlideBullet]):
        sources = []
        seen = set()
        for b in bullets:
            for c in b.citations:
                if c not in seen:
                    seen.add(c)
                    sources.append(c)
        if not sources:
            return
        text = "Sources: " + "; ".join(sources)
        box = slide.shapes.add_textbox(Inches(0.5), Inches(6.8), Inches(9.0), Inches(0.4))
        tf = box.text_frame
        tf.clear()
        p = tf.paragraphs[0]
        p.text = text
        p.font.size = Pt(9)
        p.font.italic = True
    def add_chart(slide, chart_spec: ChartSpec, left, top, width, height):
        date_cols = [c for c in model_df.columns if any(x in c.lower() for x in ['date', 'time', 'month', 'year', 'period', 'week'])]
        x_col = date_cols[0] if date_cols else model_df.columns[0]
        categories = model_df[x_col].astype(str).tolist()
        chart_data = CategoryChartData()
        chart_data.categories = categories
        chart_colors = ["01EF63", "203147"]
        for i, s in enumerate(chart_spec.series):
            values = model_df[s.metric].apply(safe_float).tolist()
            chart_data.add_series(s.name, values)
        chart_type = XL_CHART_TYPE.LINE if chart_spec.kind == "line" else XL_CHART_TYPE.COLUMN_CLUSTERED
        graphic_frame = slide.shapes.add_chart(chart_type, left, top, width, height, chart_data)
        chart = graphic_frame.chart
        chart.has_title = True
        chart.chart_title.text_frame.text = chart_spec.title
        chart.has_legend = True
        chart.legend.position = XL_LEGEND_POSITION.BOTTOM
        chart.legend.include_in_layout = False
        from pptx.dml.color import RGBColor
        for i, series in enumerate(chart.series):
            color_hex = chart_colors[i % len(chart_colors)]
            color_rgb = RGBColor(
                int(color_hex[0:2], 16),
                int(color_hex[2:4], 16),
                int(color_hex[4:6], 16)
            )
            fill = series.format.fill
            fill.solid()
            fill.fore_color.rgb = color_rgb
            if chart_spec.kind == "line":
                line = series.format.line
                line.color.rgb = color_rgb
                line.width = Pt(3)
    for s in plan.slides:
        slide = prs.slides.add_slide(blank)
        add_title(slide, s.title)
        has_chart = bool(s.chart_id and s.chart_id in chart_by_id)
        if has_chart:
            add_bullets(
                slide,
                s.bullets,
                left=Inches(0.5),
                top=Inches(1.0),
                width=Inches(4.5),
                height=Inches(5.5),
            )
            add_chart(
                slide,
                chart_by_id[s.chart_id],
                left=Inches(5.2),
                top=Inches(1.0),
                width=Inches(4.5),
                height=Inches(5.5),
            )
        else:
            add_bullets(
                slide,
                s.bullets,
                left=Inches(0.5),
                top=Inches(1.0),
                width=Inches(9.0),
                height=Inches(5.5),
            )
        add_sources_footer(slide, s.bullets)
    prs.save(out_pptx)
def build_allowed_ranges(model_df: pd.DataFrame, row_end: int) -> Dict[str, str]:      
    ranges = {}
    for i, col in enumerate(model_df.columns, start=1):
        col_letter = get_column_letter(i)
        ranges[col] = f"Model!{col_letter}2:{col_letter}{row_end}"   
    return ranges
def validate_plan(plan: ArtifactPlan, allowed_ranges: Dict[str, str]) -> None:
    allowed_set = set(allowed_ranges.values())
    chart_ids = {c.id for c in plan.charts}
    if len(chart_ids) != len(plan.charts):
        raise ValueError("Duplicate chart ids in plan.")
    for s in plan.slides:
        if s.chart_id and s.chart_id not in chart_ids:
            raise ValueError(f"Slide references unknown chart_id: {s.chart_id}")
    for s in plan.slides:
        for b in s.bullets:
            for c in b.citations:
                if c not in allowed_set:
                    raise ValueError(f"Citation not in allowed set: {c}")
    allowed_metrics = set(allowed_ranges.keys())
    for c in plan.charts:
        if c.x_metric not in allowed_metrics:
            pass
        for s in c.series:
            if s.metric not in allowed_metrics:
                raise ValueError(f"Unknown series metric: {s.metric}")

Mari pahami kode di atas selangkah demi selangkah:

  • Membuat deck dan mengindeks bagan: prs = Presentation() memulai PPTX baru, sementara blank = prs.slide_layouts[6] memberi kanvas kosong untuk penempatan presisi.

  • Judul dan butir: Fungsi add_title() menulis ke dalam textbox text_frame, dan add_bullets() menambahkan satu paragraf per butir sambil mengontrol ukuran font dan spasi melalui pemformatan paragraf.

  • Footer sitasi: Fungsi add_sources_footer() menghapus duplikasi sitasi lintas butir dan merender baris ringkas Sources: di bagian bawah agar keterlacakan tetap utuh tanpa membuat slide penuh sesak.

  • Menyematkan bagan: add_chart() membangun objek CategoryChartData() menggunakan kolom X mirip waktu dan nilai seri yang direncanakan, lalu menyisipkannya melalui slide.shapes.add_chart(), yang mengembalikan GraphicFrame.

  • Gaya merek: Terakhir, kita mengonversi heks ke RGBColor dan menerapkannya per seri. 

  • Aturan tata letak dan pagar pembatas: Jika sebuah slide memiliki chart_id yang valid, maka kita gunakan tata letak dua kolom, jika tidak maka butir lebar penuh. Fungsi build_allowed_ranges() membuat daftar-izin rentang yang bisa disitir, dan validate_plan() menegakkan ID bagan unik, referensi valid, sitasi yang diizinkan, dan metrik yang valid sehingga model tidak bisa mengarang kolom.

Langkah 8: Menghasilkan rencana dengan LLM

Pada titik ini, kita memiliki data tabular bersih, yang akan kita ubah menjadi rencana pembuatan skema (bagan, slide, dan sitasi) yang dapat dieksekusi perender.

def generate_plan_with_llm(
    api_key: str,
    model: str,
    goal: str,
    allowed_ranges: Dict[str, str],
    facts: Dict[str, str],
    num_slides: int = 6,
    num_charts: int = 4,
) -> ArtifactPlan:
    plan_schema = {
        "type": "object",
        "properties": {
            "charts": {
                "type": "array",
                "minItems": 3,
                "maxItems": 6,
                "items": {
                    "type": "object",
                    "properties": {
                        "id": {"type": "string"},
                        "kind": {"type": "string", "enum": ["line", "bar"]},
                        "title": {"type": "string"},
                        "x_metric": {"type": "string"},
                        "series": {
                            "type": "array",
                            "minItems": 1,
                            "maxItems": 3,
                            "items": {
                                "type": "object",
                                "properties": {
                                    "name": {"type": "string"},
                                    "metric": {"type": "string"},
                                },
                                "required": ["name", "metric"],
                                "additionalProperties": False,
                            },
                        },
                        "anchor": {"type": "string"},
                    },
                    "required": ["id", "kind", "title", "x_metric", "series", "anchor"],
                    "additionalProperties": False,
                },
            },
            "slides": {
                "type": "array",
                "minItems": 4,
                "maxItems": 10,
                "items": {
                    "type": "object",
                    "properties": {
                        "title": {"type": "string"},
                        "chart_id": {"type": ["string", "null"]},
                        "bullets": {
                            "type": "array",
                            "minItems": 2,
                            "maxItems": 4,
                            "items": {
                                "type": "object",
                                "properties": {
                                    "text": {"type": "string"},
                                    "citations": {
                                        "type": "array",
                                        "minItems": 1,
                                        "maxItems": 4,
                                        "items": {"type": "string"},
                                    },
                                },
                                "required": ["text", "citations"],
                                "additionalProperties": False,
                            },
                        },
                    },
                    "required": ["title", "chart_id", "bullets"],
                    "additionalProperties": False,
                },
            },
        },
        "required": ["charts", "slides"],
        "additionalProperties": False,
    }
    system = (
        "You produce investor-grade slides with traceability.\n"
        "Output MUST match the JSON schema exactly.\n"
        "Rules:\n"
        "1) Use ONLY the allowed citation ranges (exact strings).\n"
        "2) No generic filler. Every bullet must contain at least ONE number (e.g., $X, X%, X mo).\n"
        "3) Keep bullets short: <= 12 words.\n"
        "4) Prefer trends (start→end) or averages; avoid speculation.\n"
        "5) Provide exactly the requested number of charts and slides.\n"
        "6) Use chart_id to attach the correct chart to each slide.\n"
        "7) Chart anchors: Use cell references ONLY (e.g., 'A2', 'A20', 'A38'). Do NOT include sheet name like 'CHARTS!A'.\n"
    )
    user = (
        f"Goal: {goal}\n"
        f"Requested: {num_charts} charts and {num_slides} slides.\n\n"
        f"Facts (use these; do not invent):\n{json.dumps(facts, indent=2)}\n\n"
        f"Allowed citation ranges (use exact values only):\n{json.dumps(list(allowed_ranges.values()), indent=2)}\n\n"
        "Metric keys available for charts:\n"
        f"{json.dumps(list(allowed_ranges.keys()), indent=2)}\n\n"
        "Chart requirements:\n"
        "- x_metric: Use a date/time column (e.g., 'month', 'date', 'period', 'week') for the X-axis.\n"
        "  If no date column exists, use the first column from the available metrics.\n"
        "- Create meaningful charts based on available metrics in the data.\n"
        "- Focus on trends, comparisons, and key business metrics.\n"
        "- Chart colors: Use these specific shades for all chart series:\n"
        "  * Primary color: #01ef63 (bright green)\n"
        "  * Secondary color: #203147 (dark blue/navy)\n"
        "  * Alternate between these two colors for multiple series in the same chart.\n"
        "Slide requirements:\n"
        "- Create slides that tell a story with the available data.\n"
        "- Suggested structure: Overview, Key Metrics, Trends, Analysis, Summary, Next Steps.\n"
        "- Adapt slide titles and content to match the data domain (e.g., sales, marketing, operations, finance).\n"
    )
    messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
    plan_json = chat_json_schema(
        model=model,
        messages=messages,
        json_schema=plan_schema,
        api_key=api_key,
        temperature=0.2,
        max_tokens=6000,
    )
    try:
        plan = ArtifactPlan.model_validate(plan_json)
        validate_plan(plan, allowed_ranges)
        if len(plan.slides) != num_slides:
            raise ValueError(f"Expected {num_slides} slides, got {len(plan.slides)}")
        if len(plan.charts) != num_charts:
            raise ValueError(f"Expected {num_charts} charts, got {len(plan.charts)}")
        return plan
    except (ValidationError, ValueError) as e:
        repair = messages + [
            {"role": "assistant", "content": json.dumps(plan_json)},
            {"role": "user", "content": f"Fix JSON to satisfy schema + rules. Error:\n{str(e)}"},
        ]
        plan_json = chat_json_schema(
            model=model,
            messages=repair,
            json_schema=plan_schema,
            api_key=api_key,
            temperature=0.0,
            max_tokens=6000,
        )
        plan = ArtifactPlan.model_validate(plan_json)
        validate_plan(plan, allowed_ranges)
        if len(plan.slides) != num_slides or len(plan.charts) != num_charts:
            raise RuntimeError("Plan repaired but counts still wrong. Tighten prompt or increase max_tokens.")
        return plan

Fungsi generate_plan_with_llm() adalah otak dari demo ini. Begini cara kita menambahkannya ke pipeline:

  • JSON Schema eksplisit: plan_schema mendefinisikan satu-satunya bentuk respons yang boleh dihasilkan model, charts[] dan slides[], dengan field wajib dan enum.

  • Penjagaan dalam system prompt: Kami menanamkan hal-hal non-negotiable untuk memastikan rencana tetap dapat dirender dan diaudit. Kami juga membatasi penempatan bagan ke sel polos, sehingga tata letak bagan tetap deterministik alih-alih diimprovisasi model. Kontrol multi-langkah semacam ini memang dirancang untuk ditangani GPT-5.2.

  • Pesan pengguna: Pesan pengguna memberikan tujuan, jumlah yang diminta, fakta yang dihitung, dan kunci metrik yang diizinkan. Karena model tidak melihat apa pun selain ini, ia memiliki jauh lebih sedikit cara untuk mengarang kolom, rentang, atau klaim yang tidak didukung.

  • Dekode patuh skema: Kita memanggil chat_json_schema() dengan response_format diatur ke json_schema dan strict: true, yang menegakkan kepatuhan skema saat dekode.

  • Validasi dua lapis: Pertama, fungsi ArtifactPlan.model_validate(plan_json) memastikan respons cocok dengan model Pydantic kita. Lalu, validate_plan() menegakkan batasan domain.

Langkah 9: Menjalankan pipeline

Akhirnya, kita menghubungkan pemuatan CSV, pembuatan Excel, perencanaan LLM, perenderan bagan, dan ekspor PPTX final, lalu mengembalikan path output.

@dataclass
class RunOutputs:
    xlsx_path: str
    pptx_path: str
    plan_path: str
    logs: str
def run_pipeline(csv_path: str, goal: str, outdir: str, model: str, num_slides: int, num_charts: int) -> RunOutputs:
    load_dotenv()
    openai_key = os.getenv("OPENAI_API_KEY")
    openrouter_key = os.getenv("OPENROUTER_API_KEY")
    if "/" in model or openrouter_key:
        api_key = openrouter_key
        if not api_key:
            raise RuntimeError("Missing OPENROUTER_API_KEY (required for OpenRouter models)")
    elif openai_key:
        api_key = openai_key
    else:
        raise RuntimeError("Missing API key. Set either OPENAI_API_KEY or OPENROUTER_API_KEY")
    out = Path(outdir)
    out.mkdir(parents=True, exist_ok=True)
    xlsx_path = str(out / "investor_update.xlsx")
    pptx_path = str(out / "investor_update.pptx")
    plan_path = str(out / "plan.json")
    raw_df = load_and_validate_csv(csv_path)
    model_df = compute_model_df(raw_df)
    xlsx_path, row_end = build_workbook_base(raw_df, model_df, xlsx_path)
    allowed_ranges = build_allowed_ranges(model_df, row_end)
    facts = compute_facts(model_df)
    plan = generate_plan_with_llm(
        api_key=api_key,
        model=model,
        goal=goal,
        allowed_ranges=allowed_ranges,
        facts=facts,
        num_slides=num_slides,
        num_charts=num_charts,
    )
    with open(plan_path, "w", encoding="utf-8") as f:
        json.dump(plan.model_dump(), f, indent=2)
    add_charts_to_workbook(xlsx_path, plan.charts, allowed_ranges)
    build_pptx(pptx_path, model_df, plan)
    logs = (
        f"CSV rows: {len(raw_df)}\n"
        f"Excel: {xlsx_path}\n"
        f"PPTX:  {pptx_path}\n"
        f"Plan:  {plan_path}\n"
        f"Facts used: {facts}\n"
    )
    return RunOutputs(xlsx_path=xlsx_path, pptx_path=pptx_path, plan_path=plan_path, logs=logs)

Mari gabungkan semua komponen pipeline kita selangkah demi selangkah:

  • Output pipeline terstruktur: @dataclass membuat kontainer ringan untuk xlsx_path, pptx_path, plan_path, dan logs, sehingga UI Streamlit dapat memperlakukan hasil pipeline sebagai satu nilai terstruktur alih-alih mengelola tuple atau global.
  • Perutean kunci API: Metode load_dotenv() memuat berkas .env ke variabel lingkungan sehingga OPENAI_API_KEY dan OPENROUTER_API_KEY dapat dibaca via os.getenv(). Lalu kita memilih kunci penyedia dengan aturan sederhana, yakni jika OPENROUTER_API_KEY ada, gunakan OpenRouter; jika tidak, kembali ke OpenAI jika tersedia.
  • Menjalankan persiapan data: Kita memuat dan memvalidasi CSV, menghitung tabel model turunan, lalu membuat kerangka Excel melalui build_workbook_base(). Fungsi generate_plan_with_llm() kemudian mengembalikan ArtifactPlan yang disimpan sebagai JSON menggunakan json.dump() sehingga mudah diperiksa dan di-debug.
  • Merender artefak: Fungsi add_charts_to_workbook() kemudian mematerialkan bagan Excel ke sheet Charts, dan build_pptx() membangun deck slide dari rencana dan tabel.

Setelah langkah ini, aplikasi Streamlit kita dapat memanggil run_pipeline(), menampilkan logs, dan menyediakan tiga output deterministik untuk diunduh, termasuk workbook Excel, deck PPTX, dan rencana JSON. 

Langkah 10: UI Streamlit

Kini pipeline inti telah siap, langkah terakhir adalah membungkusnya dalam aplikasi Streamlit agar siapa pun dapat mengunggah berkas, menetapkan tujuan, memilih model, dan mengunduh artefak yang dihasilkan.

load_dotenv()
st.set_page_config(
    page_title="GPT-5.2 PPT and Excel Generator",
    page_icon="",
    layout="wide",
    initial_sidebar_state="expanded"
)
st.markdown("""
<style>
    .main-header {
        font-size: 2.5rem;
        font-weight: 700;
        color: #1f77b4;
        margin-bottom: 1rem;
    }
    .sub-header {
        font-size: 1.2rem;
        color: #666;
        margin-bottom: 2rem;
    }
    .success-box {
        padding: 1rem;
        background-color: #d4edda;
        border-left: 4px solid #28a745;
        margin: 1rem 0;
    }
    .error-box {
        padding: 1rem;
        background-color: #f8d7da;
        border-left: 4px solid #dc3545;
        margin: 1rem 0;
    }
    .info-box {
        padding: 1rem;
        background-color: #d1ecf1;
        border-left: 4px solid #17a2b8;
        margin: 1rem 0;
    }
    .slide-preview {
        border: 2px solid #ddd;
        border-radius: 8px;
        padding: 10px;
        margin: 10px 0;
        background: white;
        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
</style>
""", unsafe_allow_html=True)

def main():
    try:
        if 'outputs' not in st.session_state:
            st.session_state.outputs = None
        if 'generated' not in st.session_state:
            st.session_state.generated = False
        st.markdown('<div class="main-header">Spreadsheet to PowerPoint Generator</div>', unsafe_allow_html=True)
        with st.sidebar:
            # API Provider selection
            api_provider = st.selectbox(
                "API Provider",
                ["OpenAI", "OpenRouter"],
                help="Select which API to use. Make sure the corresponding API key is set in your .env file"
            )
            
            # Model selection based on provider
            if api_provider == "OpenAI":
                model = st.selectbox(
                    "AI Model",
                    [
                        "gpt-4o",
                        "gpt-4-turbo",
                        "gpt-4",
                        "gpt-3.5-turbo",
                    ],
                    help="OpenAI models. Requires OPENAI_API_KEY in .env file"
                )
            else:  # OpenRouter
                model = st.selectbox(
                    "AI Model",
                    [
                        "openai/gpt-5.2",
                        "openai/gpt-4-turbo",
                        "openai/gpt-4o",
                        "anthropic/claude-3-opus",
                        "anthropic/claude-3-sonnet",
                    ],
                    help="OpenRouter models. Requires OPENROUTER_API_KEY in .env file"
                )
            with st.expander("Advanced Settings"):
                num_slides = st.slider("Number of Slides", 4, 10, 6)
                num_charts = st.slider("Number of Charts", 3, 6, 4)
        col1, col2 = st.columns([1, 1]) 
        with col1:
            st.markdown("### Input")
            uploaded_file = st.file_uploader(
                "Upload CSV",
                type=['csv'],
                help="Upload any CSV file. The app will automatically detect columns and create appropriate charts and slides."
            )
            use_example = st.checkbox("Use example data (kpis.csv)", value=True if not uploaded_file else False)
            if uploaded_file or use_example:
                if use_example:
                    csv_path = "kpis.csv"
                    df = pd.read_csv(csv_path)
                else:
                    df = pd.read_csv(uploaded_file)
                    with tempfile.NamedTemporaryFile(delete=False, suffix='.csv', mode='w') as f:
                        df.to_csv(f, index=False)
                        csv_path = f.name       
                with st.expander(" Preview Data", expanded=False):
                    try:
                        st.dataframe(df, width='stretch')
                    except Exception as e:
                        st.write("**Data Preview:**")
                        st.table(df.head(20))
                        if len(df) > 20:
                            st.caption(f"Showing first 20 of {len(df)} rows")
                    st.caption(f"Rows: {len(df)} | Columns: {len(df.columns)}")
            else:
                csv_path = None
                st.info("Upload a CSV or use example data to continue")
            goal = st.text_area(
                "Goal",
                value="Create a 6-slide investor update for Apr–Sep 2025. Focus on growth, retention, unit economics, and runway.",
                height=100,
                help="Describe what you want the AI to create"
            )
            generate_btn = st.button("Generate Artifacts", type="primary", use_container_width=True)       
        with col2:
            st.markdown("### Output")
            status_container = st.empty()
            progress_bar = st.progress(0)
            
            if not generate_btn:
                status_container.info("Configure settings and click 'Generate Artifacts'")
        if generate_btn:
            if not csv_path:
                st.error("Please upload a CSV or select 'Use example data'")
                st.stop()            
            try:
                status_container.info("Processing... This may take 30-60 seconds")
                progress_bar.progress(10)
                output_dir = Path("out")
                output_dir.mkdir(exist_ok=True)  
                progress_bar.progress(20)
                with st.spinner("Generating artifacts..."):
                    outputs: RunOutputs = run_pipeline(
                        csv_path=csv_path,
                        goal=goal,
                        outdir=str(output_dir),
                        model=model,
                        num_slides=num_slides,
                        num_charts=num_charts
                    )
                st.session_state.outputs = outputs
                st.session_state.generated = True
                progress_bar.progress(100)    
            except Exception as e:
                status_container.markdown(
                    f'<div class="error-box"> <b>Error:</b> {str(e)}</div>',
                    unsafe_allow_html=True
                )
                st.exception(e)
                st.session_state.generated = False
        if st.session_state.generated and st.session_state.outputs:
            outputs = st.session_state.outputs
            try:
                if "Error" not in outputs.logs:
                    status_container.markdown(
                        '<div class="success-box"><b>Success!</b> Artifacts generated successfully</div>',
                        unsafe_allow_html=True
                    )
                    tab1, tab2, tab3, tab4 = st.tabs(["PowerPoint", "Excel", "JSON Plan", "Logs"])    
                    with tab1:
                        st.markdown("### PowerPoint Slides")
                        pptx_path = Path(outputs.pptx_path)
                        if pptx_path.exists():
                            try:
                                prs = Presentation(str(pptx_path))
                                slide_count = len(prs.slides)
                            except:
                                slide_count = "unknown"
                            with open(pptx_path, "rb") as f:
                                st.download_button(
                                    label="Download PowerPoint",
                                    data=f.read(),
                                    file_name="investor_update.pptx",
                                    mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
                                    width='stretch',
                                    key="pptx_download"
                                )                            
                            st.markdown("---")
                            col_info1, col_info2 = st.columns(2)
                            with col_info1:
                                st.metric("Slides", slide_count)
                            with col_info2:
                                file_size = pptx_path.stat().st_size / 1024  # KB
                                st.metric("File Size", f"{file_size:.1f} KB")
                        else:
                            st.error("PowerPoint file not found")                   
                    with tab2:
                        st.markdown("### Excel Workbook")                        
                        xlsx_path = Path(outputs.xlsx_path)                       
                        if xlsx_path.exists():
                            with open(xlsx_path, "rb") as f:
                                st.download_button(
                                    label="Download Excel",
                                    data=f.read(),
                                    file_name="investor_update.xlsx",                               mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                                    width='stretch',
                                    key="xlsx_download"
                                )
                            wb = openpyxl.load_workbook(xlsx_path)
                            st.markdown("#### Data Preview")
                            ws = wb["Model"]
                            data = []
                            headers = [cell.value for cell in ws[1]]
                            for row in ws.iter_rows(min_row=2, values_only=True):
                                data.append(row)
                            df_preview = pd.DataFrame(data, columns=headers)
                            try:
                                st.dataframe(df_preview.head(10), width='stretch')
                            except Exception:
                                st.table(df_preview.head(10))
                            if len(df_preview) > 10:
                                st.caption(f"Showing first 10 of {len(df_preview)} rows")
                        else:
                            st.error("Excel file not found")
                    with tab3:
                        st.markdown("### Schema-Locked JSON Plan")
                        plan_path = Path(outputs.plan_path)
                        if plan_path.exists():
                            with open(plan_path, "r") as f:
                                plan_data = json.load(f)
                            st.download_button(
                                label="Download JSON",
                                data=json.dumps(plan_data, indent=2),
                                file_name="plan.json",
                                mime="application/json",
                                key="json_download"
                            )
                            st.markdown("---")
                            st.json(plan_data)
                            col_a, col_b = st.columns(2)
                            with col_a:
                                st.metric("Charts Generated", len(plan_data.get("charts", [])))
                            with col_b:
                                st.metric("Slides Generated", len(plan_data.get("slides", [])))
                        else:
                            st.error("JSON plan not found")
                    with tab4:
                        st.markdown("### Generation Logs")
                        st.code(outputs.logs, language="text")
                else:
                    status_container.markdown(
                        f'<div class="error-box"> <b>Error occurred</b><br>{outputs.logs}</div>',
                        unsafe_allow_html=True
                    )           
            except Exception as e:
                st.error(f"Error displaying results: {str(e)}")
                st.exception(e)
    except Exception as e:
        st.error(f"Fatal Error: {str(e)}")
        st.exception(e)
        st.info("Try installing dependencies: pip install -r requirements.txt")

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        import sys
        print(f"Fatal error: {e}", file=sys.stderr)
        import traceback
        traceback.print_exc()

Pada lapisan Streamlit, kita menyiapkan kerangka aplikasi dengan st.set_page_config( layout="wide"), lalu membagi layar menggunakan st.columns() sehingga sisi kiri mengumpulkan input sementara sisi kanan menampilkan kontrol proses dan output.  Pipeline hanya berjalan saat pengguna mengeklik st.button(), di mana kita terlebih dahulu mengantisipasi unggahan yang hilang, membungkus st.spinner() agar UI tidak terlihat beku, lalu mengirimkan artefak kembali ke pengguna dengan st.download_button() untuk berkas Excel, PowerPoint, dan rencana.

Dengan langkah ini selesai, kita simpan semuanya sebagai app.py dan luncurkan pengalaman penuh dengan:

streamlit run app.py

Kesimpulan

Dalam tutorial ini, kita membangun pipeline lengkap “CSV ke artefak” yang menggunakan GPT-5.2 untuk mengembalikan rencana berisi bagan, struktur slide, dan sitasi rentang sel. Kita kemudian memvalidasi rencana itu sebelum menghasilkan apa pun. Pendekatan rencana-lalu-render ini selaras dengan kekuatan GPT-5.2 pada alur kerja multi-langkah seperti spreadsheet dan presentasi.

Hasilnya adalah alur kerja yang dapat diulang di mana pengguna mengunggah CSV, kode membangun fondasi workbook yang bersih, GPT-5.2 menghasilkan rencana yang hanya dapat menyitir rentang Excel pada daftar-izin, dan perender mematerialkan bagan dan slide menggunakan openpyxl dan python-pptx. 

Dari sini, Anda dapat memperluas demo tanpa mengubah logika inti seperti menambah jenis bagan, memperkenalkan template slide, mendukung penggabungan multi-CSV, dan sebagainya. Pola kuncinya tetap sama, yaitu perlakukan model sebagai perencana yang harus mematuhi skema, dan pertahankan semua logika rendering di kode agar output tetap konsisten dan mudah di-debug.


Aashi Dutt's photo
Author
Aashi Dutt
LinkedIn
Twitter

Saya adalah Google Developers Expert di ML (Gen AI), Kaggle 3x Expert, dan Women Techmakers Ambassador dengan pengalaman lebih dari 3 tahun di bidang teknologi. Saya ikut mendirikan startup health-tech pada 2020 dan sedang menempuh studi magister ilmu komputer di Georgia Tech dengan spesialisasi machine learning.

FAQ

Mengapa memasangkan GPT-5.2 dengan Streamlit?

Streamlit memudahkan mengubah alur kerja AI menjadi aplikasi interaktif tanpa membangun frontend penuh. Dipadukan dengan GPT-5.2, ini memungkinkan pengembang bergerak cepat dari eksperimen ke alat siap pakai yang menerima input, menjalankan proses berbasis model, dan mengembalikan output nyata.

Apa itu Structured Outputs dan mengapa penting di sini?

Structured Outputs memaksa model mengembalikan data yang sesuai persis dengan JSON Schema yang telah ditentukan. Ini memastikan bagan, slide, metrik, dan sitasi mengikuti aturan ketat, memungkinkan rencana divalidasi sebelum berkas Excel atau PowerPoint dibuat.

Apakah saya perlu pengalaman frontend mendalam untuk membangun aplikasi seperti ini?

Tidak. Streamlit dirancang agar sebagian besar pekerjaan terjadi di Python, menggunakan alur kontrol dan pustaka yang familier. Ini menurunkan hambatan bagi data scientist dan analis yang ingin menggelar alat bertenaga AI tanpa mempelajari JavaScript atau kerangka kerja web yang kompleks.

Topik
Kecerdasan Buatan
ChatGPT

Belajar bersama DataCamp

Kursus

Pembersihan Data dengan Generative AI

1 jam
16.2K
Gunakan kecerdasan buatan generatif untuk menangani pembersihan data, memperbaiki duplikat, nilai kosong, dan format agar dataset menjadi konsisten dan akurat.
Lihat DetailRight Arrow
Mulai Kursus
Lihat SelengkapnyaRight Arrow
Terkait

blogs

12 Alternatif ChatGPT Terbaik yang Bisa Anda Coba pada 2026

Artikel ini menyajikan daftar alternatif ChatGPT yang akan meningkatkan produktivitas Anda.
Javier Canales Luna's photo

Javier Canales Luna

14 mnt

blogs

Tutorial Korelasi di R

Dapatkan pengenalan dasar-dasar korelasi di R: pelajari lebih lanjut tentang koefisien korelasi, matriks korelasi, plotting korelasi, dan sebagainya.
David Woods's photo

David Woods

13 mnt

blogs

Spaghetti Plot dan Jalur Badai

Temukan alasan mengapa Anda sebaiknya (tidak) menggunakan spaghetti plot untuk menyampaikan ketidakpastian jalur prediksi badai serta dampaknya terhadap interpretasi.
Hugo Bowne-Anderson's photo

Hugo Bowne-Anderson

13 mnt

blogs

40 Pertanyaan Wawancara DBMS Teratas di 2026

Kuasai pertanyaan wawancara basis data, dari konsep SQL dasar hingga skenario desain sistem tingkat lanjut. Panduan mendalam ini mencakup semua yang Anda perlukan untuk sukses di wawancara DBMS dan meraih peran berikutnya.
Dario Radečić's photo

Dario Radečić

15 mnt

Lihat SelengkapnyaLihat Selengkapnya