データを開くたびに同じ作業をしていませんか。CSV を pandas で読み込んで describe() を叩き、気になる列をプロットして、それから「で、何が言えるんだろう」と Gemini にコピペで聞く。私もしばらくこの往復を繰り返していたのですが、ある日気づきました。これ、全部アプリに閉じ込めてしまえばいいのではないか、と。
ここではStreamlit と Gemini API を組み合わせて「CSV を投げると数秒で洞察と推奨グラフが返ってくる」一枚のアプリを、実際に本番運用できる形で組み立てていきます。コードは断片ではなく、streamlit run app.py でそのまま動く完成形を提示します。アップロードした CSV のサイズが MB 級になっても落ちないように、列数が増えてもトークンが爆発しないように、429 が返ってきても止まらないように——本番で必ずぶつかる場所には、その都度対処パターンを差し込んでいきます。
なぜ Streamlit + Gemini API なのか
Streamlit を選ぶ理由は単純で、UI を書くのが速いからです。pandas と plotly で組んでいた notebook 上の分析コードを、ほとんど書き換えずに Web アプリに昇格できます。一方の Gemini API は、長コンテキストとマルチモーダルが強みのモデル群で、データ分析タスクとの相性が特によいと私は感じています。100 万トークンの 2.5 Pro は CSV をそのまま流し込めるサイズですし、Flash は秒単位で帰ってくるので「アップロード→洞察」の体験が止まりません。
組み合わせの旨味は、「Python 一本で完結する」 こと、そして 「Gemini に何を投げるか」のフォーマット設計だけに集中できる ことです。フロントエンドの状態管理も、サーバー側の API ルーティングも要りません。Streamlit が両方を引き受けてくれます。
完成形と全体アーキテクチャ
最終的に出来上がるのは、次のフローを 1 ファイルで完結させたアプリです。
ユーザーが CSV をアップロード
アプリが軽量なスキーマ要約を作成(型・統計・サンプル)
Gemini が「重要な傾向」「異常値」「次に検証すべき仮説」「推奨グラフ」を構造化出力で返す
Plotly が推奨グラフを自動描画
ユーザーが追加質問をチャット形式で投げ、会話履歴を圧縮しながら継続
設計のポイントは、「Gemini に CSV 全体を渡さない」 ことです。100 行を超えるテーブルを生のまま投げるのは、トークンの無駄遣いというより害悪に近い。代わりに、スキーマと統計サマリーをコンパクトに整形し、必要な箇所だけサンプル行を添える設計にします。これだけで、10 万行の CSV でも 2,000 トークン前後に収まります。
このスタックで譲れない 2 つの設計判断
コードに入る前に、明らかでないが私が押し通した選択を 2 つだけ書いておきます。チームメイトが「変えませんか」と言ってきたら、私はこの 2 つは反対します。
1 つ目は デフォルトのモデルに Gemini Flash を選んでいる ことです。直感的には「Pro の方が分析は得意」ですし、確かに 1 回限りの複雑なレポート用途では Pro の方が結果が良いです。しかし、Streamlit アプリのようにユーザーが「アップロード→眺める→追質問→別の CSV」を繰り返す体験では、Flash のサブ秒レイテンシが製品の手触りそのものを変えます。今回のような小さなスキーマ要約(4,000 トークン以下)では、精度差はほぼ計測できないレベルです。Pro はサイドバーのトグルから選べるようにしておき、「Flash の答えが弱い」と感じた時の逃げ道として置きます。実用上は 95% が Flash で済みます。
2 つ目は プロンプト内で「JSON で返して」とお願いするのではなく、response_schema で構造化出力を強制している ことです。技術的には前者でも JSON を返してもらうことは可能で、世のチュートリアルの多くがこちらを採用しています。ただ、現実には schema enforcement なしだと「50 回に 1 回はフォーマットが崩れて UI がエラー表示」が発生し、ユーザーには「壊れているアプリ」に見えます。schema は「デモでは動く」と「1,000 回のアップロードを生き残る」を分ける一線です。本記事から 1 つだけ持ち帰るとしたら、私はこれを推します。
環境構築と依存関係
まずは依存を揃えます。Streamlit Community Cloud にデプロイする前提なら、最小構成はこれです。
# requirements.txt
streamlit>=1.42
google-genai>=0.8
pandas>=2.2
plotly>=5.24
python-dotenv>=1.0
chardet>=5.2
API キーは、ローカルでは .env、Streamlit Cloud では st.secrets から読みます。両方に対応する読み込みヘルパーを最初に書いておくと、デプロイ後の事故が減ります。
# secrets_loader.py
import os
from dotenv import load_dotenv
import streamlit as st
def load_api_key () -> str :
"""ローカルでは .env、Streamlit Cloud では st.secrets から API キーを読む。
両方に存在しない場合は明示的に例外を投げる。"""
load_dotenv()
key = os.getenv( "GOOGLE_API_KEY" )
if not key:
try :
key = st.secrets[ "GOOGLE_API_KEY" ]
except ( KeyError , FileNotFoundError ):
key = None
if not key:
raise RuntimeError (
"GOOGLE_API_KEY が設定されていません。"
".env または .streamlit/secrets.toml を確認してください。"
)
return key
実行例:
$ python -c "from secrets_loader import load_api_key; print(load_api_key()[:6] + '...')"
YOUR_A...
このヘルパーがあるおかげで、後続のコードは load_api_key() を呼ぶだけで環境差を気にしなくて済みます。
CSV を「壊れずに」読み込む
ここは地味ですが、実運用で最初にバグるポイントです。日本語の CSV は Shift_JIS 混入が珍しくありませんし、Excel が吐いた BOM 付き UTF-8 もよくあります。「pandas が UnicodeDecodeError で落ちました」というのは、本番で最も避けたい初手の失敗です。
# loader.py
import io
from typing import Optional
import chardet
import pandas as pd
import streamlit as st
MAX_BYTES = 50 * 1024 * 1024 # 50 MB
def read_uploaded_csv (file) -> pd.DataFrame:
"""Streamlit の UploadedFile を受け取り、エンコーディング自動判定で DataFrame を返す。
50 MB を超えたら早期に拒否し、ユーザーに分かりやすく説明する。"""
raw: bytes = file .getvalue()
if len (raw) > MAX_BYTES :
raise ValueError (
f "ファイルが大きすぎます( { len (raw) / 1024 / 1024 :.1f } MB)。"
f "50 MB 以下に分割してください。"
)
# 先頭 32 KB だけで推定すれば 0.1 秒以内に終わる
detected = chardet.detect(raw[: 32_000 ])
encoding = detected.get( "encoding" ) or "utf-8"
try :
return pd.read_csv(io.BytesIO(raw), encoding = encoding)
except UnicodeDecodeError :
# 推定が外れることもあるので保険として cp932 を試す
return pd.read_csv(io.BytesIO(raw), encoding = "cp932" )
except pd.errors.ParserError as e:
raise ValueError ( f "CSV のパースに失敗しました: { e } " ) from e
なぜ早期に 50 MB で切るかと言うと、Streamlit のデフォルト最大アップロードサイズ(200 MB)と Gemini に渡せる現実的なトークン量の差を考えると、それ以上のデータは「アプリ内で完結させるべきではない」サイズだからです。大きい CSV は事前に SQL や DuckDB で集計してから渡してもらった方が、結局はユーザーにとっても結果が良くなります。
スキーマと統計サマリーを作る(トークンを節約する核心)
Gemini に渡すのは「CSV の全行」ではなく、「この CSV はどういう形をしていて、どういう値が入っているか 」という要約です。私が実用していて精度とコストのバランスが良いのは、次の 4 点を含めるパターンです。
# schema.py
from typing import Any
import pandas as pd
def build_schema_summary (df: pd.DataFrame, sample_rows: int = 5 ) -> dict[ str , Any]:
"""Gemini に渡すための軽量なスキーマ要約を作る。
全カラムを送るのではなく、型・null率・統計・サンプルだけを抽出する。"""
summary: dict[ str , Any] = {
"shape" : { "rows" : len (df), "columns" : len (df.columns)},
"columns" : [],
"sample" : df.head(sample_rows).to_dict( orient = "records" ),
}
for col in df.columns:
s = df[col]
info: dict[ str , Any] = {
"name" : col,
"dtype" : str (s.dtype),
"null_ratio" : round (s.isna().mean(), 3 ),
"unique" : int (s.nunique( dropna = True )),
}
if pd.api.types.is_numeric_dtype(s):
info[ "stats" ] = {
"min" : _safe_num(s.min()),
"max" : _safe_num(s.max()),
"mean" : _safe_num(s.mean()),
"std" : _safe_num(s.std()),
}
elif pd.api.types.is_datetime64_any_dtype(s):
info[ "range" ] = { "min" : str (s.min()), "max" : str (s.max())}
else :
# 文字列カラムは頻出値だけを示す(個人情報リスクを下げるため上位 3 件まで)
top = s.dropna().astype( str ).value_counts().head( 3 )
info[ "top_values" ] = top.to_dict()
summary[ "columns" ].append(info)
return summary
def _safe_num (x):
"""NaN / inf を JSON シリアライズ可能な None に変換する。"""
try :
v = float (x)
if v != v or v in ( float ( "inf" ), float ( "-inf" )):
return None
return round (v, 4 )
except ( TypeError , ValueError ):
return None
ここで地味に効くのが _safe_num の存在です。pandas の統計値は NaN や inf を返してきますが、これらは json.dumps で例外になります。私は最初これに気づかず、空列が含まれる CSV を投げると「結果が返ってこないバグ」を踏みました。サマリー生成の段階で潰しておくのが正解です。
文字列カラムの頻出値を「上位 3 件」に絞っているのも意図があります。氏名や住所のようなカラムを value_counts() でそのまま渡すと、個人情報がそのままプロンプトに乗ってしまいます。上位 3 件なら統計的に意味があり、かつ機微情報の偶発露出を最小化できます。
Gemini で構造化出力を生成する
ここが本記事の核心です。「自由記述で洞察を返してください」と頼むと、出力フォーマットが揺れて UI が崩れます。Gemini の structured output(response_schema)を使い、4 つのフィールドに必ず分割された JSON を返してもらいます。
# insights.py
import json
import time
from typing import Any
from google import genai
from google.genai import types
INSIGHT_SCHEMA = {
"type" : "OBJECT" ,
"properties" : {
"headline" : {
"type" : "STRING" ,
"description" : "このデータを 1 文で表すヘッドライン" ,
},
"trends" : {
"type" : "ARRAY" ,
"items" : { "type" : "STRING" },
"description" : "重要な傾向を 3〜5 件、各 60 字以内" ,
},
"anomalies" : {
"type" : "ARRAY" ,
"items" : { "type" : "STRING" },
"description" : "異常値や不自然な分布。発見できなければ空配列" ,
},
"next_questions" : {
"type" : "ARRAY" ,
"items" : { "type" : "STRING" },
"description" : "次に検証すべき仮説や追加データの提案を 2〜4 件" ,
},
"recommended_charts" : {
"type" : "ARRAY" ,
"items" : {
"type" : "OBJECT" ,
"properties" : {
"kind" : { "type" : "STRING" , "description" : "scatter / bar / line / hist / box のいずれか" },
"x" : { "type" : "STRING" },
"y" : { "type" : "STRING" },
"color" : { "type" : "STRING" },
"rationale" : { "type" : "STRING" },
},
"required" : [ "kind" , "x" ],
},
},
},
"required" : [ "headline" , "trends" , "anomalies" , "next_questions" , "recommended_charts" ],
}
INSTRUCTION = """あなたは経験豊富なデータアナリストです。
与えられたスキーマ要約とサンプル行から、ビジネス担当者が即座に行動できる洞察を返してください。
- trends は数値と単位を含めて具体的に書く(例「東京の客単価が他地域より 18% 高い」)
- 推奨グラフは実在する列名のみを使用する
- 個人を特定する記述は絶対にしない
"""
def generate_insights (client: genai.Client, schema_summary: dict[ str , Any]) -> dict[ str , Any]:
"""スキーマ要約を Gemini に渡し、構造化された洞察を返す。
429 / 503 は最大 3 回までエクスポネンシャルバックオフで再試行する。"""
prompt = f "以下が CSV のスキーマと先頭サンプルです。 \n ```json \n{ json.dumps(schema_summary, ensure_ascii = False , indent = 2 ) }\n ```"
config = types.GenerateContentConfig(
system_instruction = INSTRUCTION ,
response_mime_type = "application/json" ,
response_schema = INSIGHT_SCHEMA ,
temperature = 0.3 ,
)
last_err: Exception | None = None
for attempt in range ( 3 ):
try :
res = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = prompt,
config = config,
)
return json.loads(res.text)
except Exception as e:
last_err = e
msg = str (e).lower()
if "429" in msg or "503" in msg or "overloaded" in msg:
time.sleep( 2 ** attempt + 0.5 )
continue
raise
raise RuntimeError ( f "Gemini からの応答取得に失敗しました: { last_err } " )
temperature=0.3 にしているのは、データ分析の洞察はある程度決定論的であるべきだからです。0.7 にすると同じ CSV でも見出しや数字が毎回ぶれて、ユーザーに「壊れているのでは」という印象を与えます。一方で 0.0 まで下げるとフォーマットが極端に画一化されるので、私は 0.2〜0.4 を業務系で常用しています。詳しくは Gemini API のタスク別 temperature 設計のベストプラクティス で扱っています。
推奨グラフを Plotly で描画する
Gemini が {"kind": "scatter", "x": "age", "y": "income", "color": "region"} のような指示を返してくるので、それを Plotly に変換します。実在しない列を指定してくる事故もたまに起きるので、列の存在チェックは必須です。
# charts.py
import plotly.express as px
import pandas as pd
def render_chart (df: pd.DataFrame, spec: dict ) -> "plotly.graph_objs._figure.Figure | None" :
"""Gemini の出力した chart spec を Plotly Figure に変換する。
指定列が存在しない場合は None を返し、UI 側でユーザーに伝える。"""
kind = spec.get( "kind" , "" ).lower()
x = spec.get( "x" )
y = spec.get( "y" )
color = spec.get( "color" )
if x and x not in df.columns:
return None
if y and y not in df.columns:
return None
if color and color not in df.columns:
color = None
try :
if kind == "scatter" and y:
return px.scatter(df, x = x, y = y, color = color, opacity = 0.6 )
if kind == "bar" and y:
return px.bar(df.groupby(x, dropna = False )[y].mean().reset_index(), x = x, y = y, color = color)
if kind == "line" and y:
return px.line(df.sort_values(x), x = x, y = y, color = color)
if kind == "hist" :
return px.histogram(df, x = x, color = color, nbins = 40 )
if kind == "box" and y:
return px.box(df, x = x, y = y, color = color)
except Exception :
return None
return None
この関数を Streamlit 側でループするだけで、Gemini が提案したグラフが順に描画されます。kind が想定外の値だった場合は None を返し、UI 側で「描画できなかった旨」を表示する設計にしておくと、ユーザーが「なぜ何も出ないのか」と戸惑うことを防げます。
アプリ本体を組み立てる
ここまでの部品を app.py に組み合わせます。
# app.py
import streamlit as st
from google import genai
from secrets_loader import load_api_key
from loader import read_uploaded_csv
from schema import build_schema_summary
from insights import generate_insights
from charts import render_chart
st.set_page_config( page_title = "CSV Insight" , page_icon = "📊" , layout = "wide" )
st.title( "📊 CSV Insight — Gemini で自動洞察" )
if "client" not in st.session_state:
st.session_state.client = genai.Client( api_key = load_api_key())
uploaded = st.file_uploader( "分析したい CSV をアップロード" , type = [ "csv" ])
if not uploaded:
st.info( "CSV をアップロードすると、自動で洞察と推奨グラフを生成します。" )
st.stop()
with st.spinner( "CSV を読み込み中..." ):
df = read_uploaded_csv(uploaded)
st.success( f "読み込み完了: { len (df) :, } 行 × { len (df.columns) } 列" )
with st.expander( "先頭 20 行を確認" ):
st.dataframe(df.head( 20 ))
if st.button( "🚀 Gemini で洞察を生成" , type = "primary" ):
with st.spinner( "Gemini が分析中..." ):
summary = build_schema_summary(df)
insights = generate_insights(st.session_state.client, summary)
st.session_state.insights = insights
st.session_state.df = df
if "insights" in st.session_state:
ins = st.session_state.insights
st.subheader(ins[ "headline" ])
col1, col2 = st.columns( 2 )
with col1:
st.markdown( "### 🔍 重要な傾向" )
for t in ins[ "trends" ]:
st.write( f "- { t } " )
st.markdown( "### ⚠️ 異常値・気になる分布" )
if ins[ "anomalies" ]:
for a in ins[ "anomalies" ]:
st.write( f "- { a } " )
else :
st.write( "特筆すべき異常は見つかりませんでした。" )
with col2:
st.markdown( "### 💡 次に検証すべき仮説" )
for q in ins[ "next_questions" ]:
st.write( f "- { q } " )
st.markdown( "### 📈 推奨グラフ" )
for spec in ins[ "recommended_charts" ]:
fig = render_chart(st.session_state.df, spec)
if fig is None :
st.caption( f "⚠ { spec.get( 'kind' ) } を描画できませんでした(列が不正)" )
continue
st.plotly_chart(fig, use_container_width = True )
if spec.get( "rationale" ):
st.caption( f "なぜこのグラフか: { spec[ 'rationale' ] } " )
streamlit run app.py で起動して CSV をアップロードすると、数秒で洞察カードと推奨グラフが並びます。ここまでで MVP は完成です。
追質問をチャット形式で受ける(履歴圧縮込み)
データを眺めていると、「ではこの異常値はどの店舗で起きているのか」のような追加質問が必ず出てきます。Streamlit の st.chat_input を使えば 10 行ほどで対話 UI が足せますが、放っておくと会話履歴が膨らんで毎回トークンを浪費します。私は messages が 10 件を超えたら、古い 5 件を 1 件のサマリーに圧縮するパターンを愛用しています。
# chat.py
from google import genai
from google.genai import types
CHAT_INSTRUCTION = """あなたはデータアナリストです。直前にユーザーが上げた CSV のスキーマと洞察を踏まえ、
ユーザーの質問に短く・具体的に答えてください。確証がない場合は「データから断定できない」と明示してください。"""
def chat_reply (client: genai.Client, history: list[ dict ], schema_summary: dict , question: str ) -> str :
# 履歴が長くなったら古い半分を要約して 1 つの message に畳む
if len (history) > 10 :
old = history[: - 6 ]
recent = history[ - 6 :]
summary_prompt = "次の対話を 200 字以内で要約してください: \n " + \
" \n " .join( f " { m[ 'role' ] } : { m[ 'content' ] } " for m in old)
s = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = summary_prompt,
config = types.GenerateContentConfig( temperature = 0.0 ),
)
history = [{ "role" : "user" , "content" : "[これまでの要約] " + s.text}, * recent]
convo = " \n " .join( f " { m[ 'role' ] } : { m[ 'content' ] } " for m in history)
prompt = f "スキーマ要約: \n{ schema_summary }\n\n 対話履歴: \n{ convo }\n\n user: { question }\n assistant:"
res = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = prompt,
config = types.GenerateContentConfig( system_instruction = CHAT_INSTRUCTION , temperature = 0.4 ),
)
return res.text
このパターンは Gemini API の rolling summary によるチャット履歴圧縮 でも単独で扱っていますが、データ分析アプリでは特に効きます。CSV のスキーマ要約だけで毎回 1,000 トークン前後を使うので、会話履歴を野放しにすると 10 ターンで 1 万トークンを超えてしまいます。
レート制限とトークン肥大化への対処
このタイプのアプリで一番ありがちな失敗は、「ローカルでは動いたのにデプロイ後に 429 で止まった」というものです。Gemini Flash の無料枠は分単位でリクエスト数が制限されているので、複数ユーザーが同時に押すと簡単に当たります。対処は 3 段構えにします。
第一に、リクエスト前にトークン数を見積もって、サマリーが 4,000 トークンを超えそうなら自動で sample_rows を減らす 。第二に、429/503 が返ってきたら指数バックオフで最大 3 回までリトライ(generate_insights に既に実装済み)。第三に、Gemini Flash で詰まったら 2.5 Pro にフォールバックする選択肢を Streamlit のサイドバーに置いておく。
# token_guard.py
def estimate_tokens (text: str ) -> int :
"""日本語混在テキストのトークン数をざっくり見積もる。
厳密には count_tokens を呼ぶべきだが、UI 側の判断には十分な精度。"""
return len (text) // 2 + text.count( " " )
def shrink_summary_if_needed (summary: dict , limit: int = 4000 ) -> dict :
"""JSON 文字列化したときのトークン見積もりが limit を超えるなら、
サンプル行と top_values を半分に削る。"""
import json
s = json.dumps(summary, ensure_ascii = False )
while estimate_tokens(s) > limit and summary[ "sample" ]:
summary[ "sample" ] = summary[ "sample" ][: max ( 1 , len (summary[ "sample" ]) // 2 )]
for col in summary[ "columns" ]:
if "top_values" in col:
col[ "top_values" ] = dict ( list (col[ "top_values" ].items())[: 1 ])
s = json.dumps(summary, ensure_ascii = False )
return summary
count_tokens API を呼ぶのが厳密ですが、UI 側の「投げるかどうかの判断」には文字数の半分というラフな見積もりで十分実用的です。詳細な制御が必要になったら、Gemini API のレート制限・クォータ管理(本番ガイド) と Gemini API コスト最適化の完全ガイド を併読してください。
よくある間違いと落とし穴
このアプリを 4 サイト分作って同僚にも配布してみて、繰り返し見た失敗を 5 つ挙げます。回避策まで併記します。
第一に、「全行を Gemini に渡したくなる病」 。「サマリーじゃなくて全データ見せた方が精度上がるのでは」と試したくなりますが、結論として精度はほぼ変わらず、コストとレイテンシだけ跳ね上がります。1 万行を超える時点でトークン浪費の害の方が大きい、と割り切ってください。
第二に、「数値カラムを文字列として渡してしまう」 。CSV の数値列に 1,234 のようなカンマが混じっていると pandas は object 型として扱います。Gemini は文字列として受け取るので「平均」を計算できません。pd.to_numeric(s, errors="coerce") で数値化してからサマリーに入れると、洞察の質が劇的に上がります。
第三に、「個人情報を Gemini に流してしまう」 。氏名、メールアドレス、住所のような列をそのまま top_values で送ると、ログやキャッシュに残るリスクがあります。スキーマ生成時にカラム名から PII らしさを判定し、サンプル行から特定可能な値をマスクする一手間を入れてください。本番運用では Gemini API の PII レダクションと監査ログ のパターンが参考になります。
第四に、「response_schema を緩く書きすぎる」 。recommended_charts を単なる ARRAY にして中身を STRING で受けると、毎回フォーマットが揺れて Plotly に渡せません。OBJECT で kind x y を必須化する だけで、後段のレンダリング失敗率が一桁下がります。Pydantic ベースで型安全にする手法は Gemini API + Pydantic で構造化出力を型安全にする で詳しく扱っています。
第五に、「Streamlit のセッション状態を信用しすぎる」 。st.session_state はタブを閉じると消えます。ユーザーが洞察を「保存」したいシーンでは、JSON エクスポートボタンを必ず置いてください。私は st.download_button で insights.json と推奨グラフを HTML にした report.html の 2 つを出すようにしています。
デプロイと本番運用
ローカルで動いたら、最短で公開できる選択肢は Streamlit Community Cloud です。GitHub に push して secrets.toml に GOOGLE_API_KEY を入れるだけで動きます。ただし、無料枠はメモリ 1 GB なので、pandas が 200 MB 以上使うようなデータを扱う想定なら最初から Cloud Run か Fly.io を選んだ方が後悔しません。
Cloud Run にする場合は、Dockerfile で Python 3.12 + Streamlit を入れて、PORT=8080 で待ち受ければそのまま動きます。私は最低スペック(256 MB / 0.5 CPU)でも 10 MB 程度の CSV なら問題なく回ることを確認しています。スケーリングを Concurrency=80 に設定し、min instance を 0 にしておけば、月数千円以下で個人プロジェクトとして十分です。詳細は Gemini API × Cloud Run で従量課金 SaaS を構築する完全ガイド で扱っています。
セキュリティ面で必ず押さえておきたいのは、「アップロードされた CSV をサーバーに残さない」 ことです。streamlit は UploadedFile をメモリ上で扱いますが、ログに DataFrame をそのまま吐き出すコードがあると永続化されてしまいます。st.write(df) はユーザーには見えていますが、Streamlit Cloud のログには出ないので問題ありません。一方で自前で logger.info(df.to_string()) を書いてしまうと終わりです。
拡張のアイデア — ここから個人プロジェクトに育てる
このアプリは 1 ファイルで完結しますが、構造はそのままに次の拡張が自然に乗ります。
複数 CSV を結合して分析するモードを追加します。Slack のスラッシュコマンドから CSV を投げて Slack 上に洞察を返す(Slack Bolt Python が 30 行で書けます)。Stripe で従量課金を載せて B2B SaaS にする(私の Gemini API × Stripe SaaS 課金 のパターンがほぼそのまま使えます)。
個人開発者にとって嬉しいのは、Gemini Flash の単価が低いので「データ分析 1 回 10 円以下」で運用できることです。月 1,000 回使われても原価 1 万円以下に収まります。
出力品質をどうキャリブレーションするか
このパターンを試した開発者から繰り返し聞かれる質問があります。「洞察が良いかどうかをどう判定すればいいですか」。
私はシンプルな 2 段階の儀式を採用しています。1 つ目は、アプリを公開する前に「自分が答えを既に知っている」CSV を 3 種類流すことです。私の場合は自分で運営している事業の売上データ、自分で手分析した公開データセット、そして特定の異常値を意図的に仕込んだ合成データの 3 つです。仕込んだ異常値を見落としたり、存在しない傾向を捏造したりした場合は、スキーマサマリーのシグナル不足です(サンプル行を増やす、統計の粒度を上げる、システム指示でカラム説明を強める、のいずれか)。
2 つ目は、本番運用に入ってから、洞察と入力スキーマのハッシュをログに残し、毎週 5% を手動レビューすることです。1 ヶ月もすれば数百ペアの実例が貯まり、系統的な失敗パターンが見えてきます。「null 率の高い列の重要性を過大評価する」「ヒストグラムが適切な場面でも棒グラフを推奨する」など、特定の癖が見えたらシステム指示を調整して失敗率を下げていきます。
これは構造化出力パイプラインの QA 一般に通じるアプローチですが、データ分析の場合は失敗が「壊れた JSON」ではなく「もっともらしいが間違っている」形で現れるので、定点観測の価値が特に高いと感じます。
全体を振り返って
このアプリの本質は「Gemini に何を渡すかを設計する」ことに尽きます。CSV をそのまま投げるのではなく、スキーマと統計サマリーに圧縮し、構造化出力で受け取る。たったこれだけで、コスト・レイテンシ・出力品質の三拍子が揃います。本記事の他の部分は、すべてこの中核アイデアを取り囲む足場でしかありません。
まず streamlit run app.py で手元のデータを 1 回だけ流してみてください。手持ちの売上データでも、Kaggle の練習用データセットでも構いません。「自分が普段やっている分析」が 5 秒で返ってくる体験は、たぶん想像以上に気持ちが変わります。そこから「自分のチームが必要としている分析」を組み込んでいけば、それはもう立派な内製プロダクトです。週末の磨き込みと課金導線を足せば、十分プロダクトとして公開できます。