🎃

E2B Sandboxとshadcn/uiで自然言語からインタラクティブなチャートを生成する

に公開

自然言語と統計データのcsvファイル(あるいは画像)からインタラクティブなチャートを生成したい」というニーズは、日々データ分析を行う方なら誰もが感じるはずです。

今回は、LLMが生成したコードをサンドボックス環境で実行してくれるE2B Sandboxと、見やすくインラクティブなチャートを描画してくれるshadcn/uiを用いて、そんな課題を解決していきたいと思います。

https://e2b.dev/
https://ui.shadcn.com/

E2Bとは?

正確には、「AIが生成したコードをセキュアなサンドボックス環境で実行できるオープンソースのインフラストラクチャー」です。(詳しくは公式のクイックスタートをご覧ください)

index.ts
import 'dotenv/config'
import { Sandbox } from '@e2b/code-interpreter'

const sbx = await Sandbox.create()
const execution = await sbx.runCode('print("hello world")')
console.log(execution.logs)

上記のように、まずはサンドボックス環境のインスタンスを作り、runCode()メソッドでそのサンドボックス環境下でコードを実行することができます。サンドボックスに渡すコードとしてLLMが生成したものを用いれば、自然言語からコード実行までを一気通貫で行うことができます。

サンドボックス環境の魅力は、単にLLMが生成したコードの実行環境であることだけでなく、ホスト環境から分離していることです。LLMが誤ってファイルの読み書きや外部へのリクエストを行うコードを生成してしまったとしても制限できるほか、CPU・メモリ・環境設定を使い回すことで実行環境の再現性を担保してくれます。

今回利用するオープンソースプロジェクト

E2Bには、代表的なユースケースを手元で実行できるオープンソースプロジェクトが複数存在します。今回はai-analystというプロジェクトを公式のGitHubからクローンして使って行きたいと思います。

https://github.com/e2b-dev/ai-analyst

依存関係をインストールし、.envファイルにE2B_API_KEYOPENAI_API_KEYをセットしたら、早速「2001年生まれの人の年齢推移をラインチャートで表示して」と打ってフォームを送信してみます。

LLMが、生成したコードブロックをサンドボックス環境で実行し、結果がインタラクティブなチャートとして表示されたのがお分かり頂けたでしょうか。なお、データが入力されたcsvファイルなどを入力としてアップロードすることもでき、「自然言語+csvファイルで自分の理想の統計分析を行う」というニーズはデフォルトでも叶っているように見えます。

ai-analystを改良する

一見十分に見えるai-analystですが、何度か実行すると以下のような問題点が見えてきます。

  • 生成されるチャートが安定せず、かなり意味のわからないチャートが生成されることもザラ
  • チャートのUIがイマイチ
    • 例えば、軸ラベルと軸の値が重なっていたり
  • 複数チャートの表示ができない

例えば、「2001年生まれの人の年齢推移と、strawberryに含まれる文字数をチャート化して」と頼んでみた結果、返ってきたのは以下のようなチャートでした。

複数のチャートを同時に描こうとした結果、かなり意味不明なチャートが生成されてしまいました。これは何も複数チャートを描こうとするときに限らず、とにかく出力されるチャートの質が安定せず、UIも微妙です。

これらの問題点を1つ1つ解消し、実務にも耐えうるようなものを作ろうというのが今回のテーマになります。今回の記事では、とりわけ生成されるチャートが安定しないという問題に着目してその解決手法を紹介していきたいと思います。

課題: 生成されるチャートが安定しない

これは一番大きな課題です。せっかく分析したい統計データが手元にあっても、思った通りのチャートが出力されないのであれば意味がありません。この問題を解決するにあたり、発想を大胆に転換してみることにしました。

そもそもコードでチャートを生成しなければ良いのでは?

チャートを生成したいのに生成しないって何?? と思われる方もいるかもしれません。ここでのポイントは、「"コードでは"生成しない」というところにあります。

デフォルトのai-analystは、「Pythonのmatplotlibなどで生成した静的なチャート画像からチャート情報を抜き出し、それをechartsというインタラクティブチャート描画ライブラリに渡す」というなんとも不確実な方法でチャートを生成しています。

そこで、「LLMに出力させるのはあくまで描きたいチャートの情報をもつJSONオブジェクトだけで、それを元に手元でチャートを組み立てる」ことにしました。

Step1. スキーマ定義

出力の構造化を安定させるために、ZODというデータ構造定義ライブラリを使います。これにより自然言語によるプロンプト制御の不確実さから脱し、こちらが定義した通りのオブジェクトをLLMに出力させることができます。

スキーマ定義コード
chart.ts
import { z } from 'zod';

// ---- チャートコンテントスキーマの定義 ----
// colorはカラーコード(文字列)なので z.string() とし、正規表現で形式を制限します。
const baseContentSchema = z.object({
  color: z.string({ description: "チャートの色。見やすい16進数カラーコード(例: #8884d8)" })
    .regex(/^#[0-9a-fA-F]{6}$/, "カラーコードは#から始まる6桁の16進数である必要があります。"),
  x_values: z.array(z.string(), { description: "チャートのX軸にあたる値の配列" }).min(1),
  x_label: z.string({ description: "X軸のラベル名" }).min(1),
  y_values: z.array(z.number(), { description: "チャートのY軸にあたる数値の配列" }),
  y_label: z.string({ description: "Y軸のラベル名" }).min(1),
  title: z.string({ description: "チャート全体のタイトル" }).min(1),
})
// x_valuesとy_valuesの要素数が一致することを保証するカスタムバリデーション
.refine(
  (data) => data.x_values.length === data.y_values.length,
  {
    message: "x_valuesとy_valuesの要素数は一致する必要があります。",
    path: ["x_values", "y_values"], // エラーが発生した箇所を明示
  }
);

// ---- 各チャートのコンテントスキーマ定義 ----
const barContentSchema = baseContentSchema;
const lineContentSchema = baseContentSchema;

// ---- 各チャートの全体スキーマ定義 ----
const barChartSchema = z.object({
  chart_type: z.literal("bar", { description: "チャートの種類が棒チャートであることを示す" }),
  chart_content: barContentSchema,
});
const lineChartSchema = z.object({
  chart_type: z.literal("line", { description: "チャートの種類が折れ線チャートであることを示す" }),
  chart_content: lineContentSchema,
});

// ---- `chart_type` の値によって使用するスキーマを切り替える ----
export const chartJsonSchema = z.discriminatedUnion("chart_type", [
  barChartSchema,
  lineChartSchema,
], {
  description: "統計計算の結果を表現する単一のチャート情報",
});

// ---- 最終的にLLMが出力するスキーマ ----
export const finalOutputSchema = z.object({
  chartsJson: z.array(chartJsonSchema, { description: "生成されたチャート情報の配列。チャートが1つの場合でも配列にする" }),
});

// スキーマからTypeScriptの型を生成(コード内で利用するため)
export type BarContent = z.infer<typeof barContentSchema>;
export type LineContent = z.infer<typeof lineContentSchema>;
export type ChartJson = z.infer<typeof chartJsonSchema>;
export type FinalOutput = z.infer<typeof finalOutputSchema>;

ここでは、コンテンツスキーマ(baseContentSchema)、すなわちチャートに欲しい情報を以下のように定義しています。

  • color(チャートの色)
  • x_values(x軸の値)
  • x_label(x軸ラベル)
  • y_values(y軸の値)
  • y_label(y軸ラベル)
  • title(チャートタイトル)

これをチャートの種類と組み合わせることで、棒チャートや線チャートなど種類ごとのチャートスキーマを定義します。

このように定義することで、コンテンツスキーマの形式が異なるチャートを定義したい場合が出てきても、また別のコンテンツスキーマを定義してそのチャートスキーマのchart_contentに置けば良くなり、拡張性が高い造りになっています。

複数のチャートを1つのスキーマとして扱うことを可能にしているのは、ZODのz.discriminatedUnion()メソッドです。最終的にLLMから出力したいのは棒チャートまたは線チャートの配列ですが、そのような分岐込みのスキーマを定義して型として扱うためにこのメソッドが用いられています。

Step2. プロンプト定義

LLMに渡すプロンプトを以下のように定義し直します。ここでのポイントは、Step1で定義したスキーマを文字列としてプロンプトに埋め込んでいることです。

ZodのスキーマオブジェクトにはTypeScriptの文字列そのものを返すようなtoString()メソッドが標準で用意されていないため、一旦JSONスキーマに変換してから文字列に変換しています。この問題の解決方法は他にもあるのですが、簡単なので今回はこの手法を取りました。

プロンプト定義コード
prompt.ts
import { CustomFiles } from "./types";
import { zodToJsonSchema } from 'zod-to-json-schema';
import { finalOutputSchema } from './schemas/chart';

// ZodスキーマをJSON Schemaオブジェクトに変換
const jsonSchema = zodToJsonSchema(finalOutputSchema, "chartOutput"); // 第2引数はスキーマの名前
const schemaString = JSON.stringify(jsonSchema, null, 2);

export function toPrompt(data: { files: CustomFiles[] }) {
  return `
あなたは高度な Python データサイエンティスト/アナリストです。
ユーザは「統計データが写った画像」または CSV 等のデータをアップロードし、
その内容に基づいた何らかの分析チャートを求めています。

### すでにインストール済みのライブラリ
- jupyter
- numpy
- pandas

### あなたが出力すべきもの
- **Jupyter Notebook で 1 回実行できる Python スクリプト**(Markdown の python コードブロック **1 つのみ**)
- スクリプトの先頭で 必要に応じて**!pip install** により追加パッケージを導入し、その直後で import する
- データに関する考察

### 絶対に守るべき事項
- **ファイルシステムにアクセスするコードを書かない**
  - 例:Image.open('path/...')、open('file.jpg','rb')、with open(...) などを含めない
- **生成するチャートのタイトル, x軸ラベル, y軸ラベルは必ず日本語で記述すること。**

### 必須要件
1. **ユーザの質問に応じた統計計算**(平均・中央値・成長率・カテゴリ別集計など)を行う。
2. **JSONへの整形**: 計算結果を、後述する **JSON_SCHEMA** に従って完璧に整形してください。
   - チャートを描くコードは一切不要です。チャート生成に必要なデータ構造のみをJSONとしてください。
   - chart_type は、ユーザーの要望とデータの性質から最も適切だと思われるものを推論して選択してください。
   - x_values と y_values の要素数は必ず一致させてください。
   - 指示で与えられたチャートの種類の数だけ、チャート情報オブジェクトを生成し、配列にまとめてください。

3. **最終出力**: 最終的に、JSON_SCHEMAに準拠したJSONオブジェクトを , ensure_ascii=Falseでjson.dumps() したものをPythonコードの最終行で評価(evaluate)してください。
   - print() は使用しないでください。
   - 例: import json; json_object = json.dumps({"chartsJson": [...]}, , ensure_ascii=False); json_object

---
### JSON_SCHEMA
以下に示すJSON Schemaの仕様に厳密に従ってください。説明(description)も参考にしてください。
\`\`\`json
${schemaString}
\`\`\`

### コード構成のひな形(例)
python
import pandas as pd, numpy as np

# 1) 読み取った情報をDataFrame 化
# 2) 質問に合わせた統計計算
# 3) チャート描画のために必要な情報を生成(日本語タイトル・軸ラベルなど)し、その情報をJSONに整形
   - ただし、結果はJSONのリストにしてください。
   - 複数のチャートの描画を求められた際には、チャートごとに1つのJSONを生成し、リスト化する。
# 4) JSONで結果を返す(print不要)
`;
}

最終的に単一のJSONオブジェクトを返させることで、チャート情報をクライアント側で取り出しやすくしています。

Step3. JSONオブジェクトからインタラクティブチャートを描画する

LLMがStep2でコードを通して生成したJSONオブジェクトは、app/api/sandbox/route.ts内の以下のresults変数に入っています。

route.ts
const sandbox = await Sandbox.create({
    apiKey: process.env.E2B_API_KEY,
    timeoutMs: sandboxTimeout,
  });
const { text, results, logs, error } = await sandbox.runCode(code);

ai-analystにおいて、このresultsが最終的に渡されるのはチャートをレンダーしているcomponents/charts.tsxです。詳細は省きますが、charts.tsxresult.text内に文字列としてJSONオブジェクトが埋め込まれています。

charts.tsx
export function RenderResult({
  result,
  viewMode,
}: {
  result: Result;
  viewMode: "static" | "interactive";
}) {

  if (viewMode === "interactive" && result.text) {
    const raw = result.text; // チャート情報はPythonのJSON文字列としてくる
    //...
  }
}
result.textの中身例
{
  "chartsJson": [
    {
      "chart_type": "line",
      "chart_content": {
        "color": "#1f77b4",
        "x_values": [
          "2001","2002","2003","2004","2005","2006","2007","2008","2009","2010",
          "2011","2012","2013","2014","2015","2016","2017","2018","2019","2020",
          "2021","2022","2023","2024","2025"
        ],
        "x_label": "西暦年",
        "y_values": [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24],
        "y_label": "年齢(歳)",
        "title": "2001年生まれの人の年齢推移"
      }
    }
  ]
}

このJSON文字列をパースしてJSONオブジェクトに復元し、中身のチャート情報が入ったJSONオブジェクト配列を取り出します。

const raw = result.text;
const raw_obj: FinalOutput = parseJSONstring(raw);
const chartsJson: ChartJson[] = raw_obj.chartsJson;

あとはJSONオブジェクトからチャート描画に必要な情報をそれぞれキー指定で取り出し、TypeScriptのUIライブラリであるshadcn/uiを使ってインタラクティブなチャートを生成します。

shadcn/uiがチャートを描画するには、以下のような構造を持つオブジェクトが必要です。ここではmonthはx軸、desktopはy軸の値となっています。他にも色々と工夫する点はあるのですが、大雑把には以下のchartDataのフォーマットに合うよう、先ほど取り出したJSONオブジェクト(chartsJson)を分解・再構築すればインタラクティブチャートが描画できます。

const chartData = [
  { month: "January", desktop: 186 },
  { month: "February", desktop: 305 },
  { month: "March", desktop: 237 },
  { month: "April", desktop: 73 },
  { month: "May", desktop: 209 },
  { month: "June", desktop: 214 },
]

shadcn/uiの詳細が気になる方は、以下のチュートリアルに目を通すとチャートUIについてよくわかると思います。

https://ui.shadcn.com/docs/components/chart

実行結果

e-statという日本の統計データサイトから入手した以下の統計データから、チャートを描画することを試してみます。

入力したメッセージ

添付したデータは、日本の統計データが参照できるe-Statというサイトから入手した、年ごとの
全国サービス産業売上高の時系列データです。
この添付データから、以下のようなチャートを生成してください。

  • 横軸が時系列(年)で、縦軸が年ごとのサービス産業売上高(100万円)の棒チャート
  • 横軸が時系列(年)で、縦軸がサービス産業売上高の前年比(%)の赤い折れ線チャート

結果が以下の通りです。

生成したコード
# 必要パッケージのインストール(今回追加なし)
import pandas as pd, json

# 1) データを直接DataFrame化(単位:100万円)
data = {
    "年": ["2013", "2014", "2015", "2016", "2017", "2018", "2019", "2020"],
    "サービス産業売上高_100万円": [
        30487224,
        31142159,
        31889960,
        32052744,
        32661352,
        33122786,
        33424836,
        30006392
    ]
}
df = pd.DataFrame(data)

# 2) 前年比(%) を計算
df["前年比_%"] = df["サービス産業売上高_100万円"].pct_change()*100
df_yoY = df.dropna()  # NaN(2013年)を除外

# 3) JSON 生成
charts_json = {
    "chartsJson": [
        {
            "chart_type": "bar",
            "chart_content": {
                "color": "#4e79a7",
                "x_values": df["年"].tolist(),
                "x_label": "年",
                "y_values": df["サービス産業売上高_100万円"].astype(float).tolist(),
                "y_label": "売上高(100万円)",
                "title": "全国サービス産業売上高の推移(棒チャート)"
            }
        },
        {
            "chart_type": "line",
            "chart_content": {
                "color": "#ff0000",
                "x_values": df_yoY["年"].tolist(),
                "x_label": "年",
                "y_values": df_yoY["前年比_%"].round(2).astype(float).tolist(),
                "y_label": "前年比(%)",
                "title": "全国サービス産業売上高 前年比(折れ線チャート)"
            }
        }
    ]
}

# 4) JSON を返す(printは使わない)
json.dumps(charts_json, ensure_ascii=False)

生成したチャート

売上高推移の棒チャート

売上高前年比の線チャート

入力データから、こちらが想定した通りの綺麗かつインタラクティブなチャートが返ってきました。 LLMがプロンプトに従って描画したいチャートに適したJSONオブジェクトの配列を生成していることが分かると思います。

また、ここではタブを使って複数チャートの切り替えを実装しています。気になる方は以下のコードも合わせてご覧ください。

複数タブ切り替えコンポーネントの実装
DynamicChartTabs.tsx
"use client";

import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { type ChartJson, type BarContent, type LineContent } from "@/lib/schemas/chart"; // 型定義

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart";
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@/components/ui/tabs";
import clsx from "clsx";

// --- チャート描画用の個別コンポーネント ---

// 棒チャートを描画するコンポーネント
const BarChartRenderer = ({ content }: { content: BarContent }) => {
  const chartData = useMemo(() => {
    return content.x_values.map((x, i) => ({
      x_value: x,
      y_value: content.y_values[i],
    }));
  }, [content.x_values, content.y_values]); // 依存配列。この配列に含まれる値が変化した時だけ1つ目の関数を再実行

  const chartConfig = {
    y_value: {
        label: content.y_label,
    },
  };

  return (
    <ChartContainer config={chartConfig} className="min-h-[300px] w-full">
      <BarChart data={chartData}>
        <CartesianGrid vertical={false} />
        <XAxis dataKey="x_value" tickLine={false} tickMargin={10} axisLine={false} height={50}  label={{ value: content.x_label, position: 'insideBottom', offset: 0, style: {textAnchor: 'middle', fontWeight: "bold" } }}/>
        <YAxis width={80} label={{ value: content.y_label, angle: -90, position: 'insideLeft', style: {textAnchor: 'middle', fontWeight: "bold" }}}/>
        <ChartTooltip content={<ChartTooltipContent />} />
        <Bar dataKey="y_value" fill={content.color} radius={4} />
      </BarChart>
    </ChartContainer>
  );
};

// 折れ線チャートを描画するコンポーネント
const LineChartRenderer = ({ content }: { content: LineContent }) => {
  const chartData = useMemo(() => {
    return content.x_values.map((x, i) => ({
      x_value: x,
      y_value: content.y_values[i],
    }));
  }, [content.x_values, content.y_values]);

  const chartConfig = {
    y_value: {
        label: content.y_label,
    },
  };

  return (
    <ChartContainer config={chartConfig} className="min-h-[300px] w-full">
      <LineChart data={chartData}>
        <CartesianGrid vertical={false} />
        <XAxis dataKey="x_value" tickLine={false} tickMargin={10} axisLine={false} height={50}  label={{ value: content.x_label, position: 'insideBottom', offset: 0, style: {textAnchor: 'middle', fontWeight: "bold" } }}/>
        <YAxis width={80} label={{ value: content.y_label, angle: -90, position: 'insideLeft', style: {textAnchor: 'middle', fontWeight: "bold" }}}/>
        <ChartTooltip content={<ChartTooltipContent />} />
        <Line type="monotone" dataKey="y_value" stroke={content.color} strokeWidth={2} dot={true} />
      </LineChart>
    </ChartContainer>
  );
};

// --- メインの動的タブコンポーネント ---

export function DynamicChartTabs( charts : ChartJson[]) {
  if (!charts || charts.length === 0) {
    return <p>表示するチャートデータがありません。</p>;
  }
  const count = charts.length;
  const cols = Math.min(count, 3);

  // 最初のタブをデフォルトで選択状態にする
  const defaultValue = `chart-0`;

  return (
    <Tabs defaultValue={defaultValue} className="w-full">
      {/* 1. TabsTriggerを動的に生成 */}
      {/* チャートの数だけタブを生成。今はMax3列 */}
      <TabsList className={clsx(
          "grid w-full",
          {
            "grid-cols-1": cols === 1,
            "grid-cols-2": cols === 2,
            "grid-cols-3": cols === 3,
          }
        )}>
        {charts.map((chart, index) => (
          <TabsTrigger key={`trigger-${index}`} value={`chart-${index}`} className="w-full truncate" >
            {chart.chart_content.title}
          </TabsTrigger>
        ))}
      </TabsList>

      {/* 2. TabsContentを動的に生成 */}
      {charts.map((chart, index) => (
        <TabsContent key={`content-${index}`} value={`chart-${index}`}>
          <Card>
            <CardHeader>
              <CardTitle>{chart.chart_content.title}</CardTitle>
              {/* <CardDescription>
                {`${chart.chart_content.y_label} by ${chart.chart_content.x_label}`}
              </CardDescription> */}
            </CardHeader>
            <CardContent>
              {/* 3. chart_typeに応じて描画するチャートを切り替え */}
              { // ① JSXにJSを埋め込むための「波カッコ」
                ( // ② 関数を「式」としてグループ化するための「丸カッコ」。即座実行を行うためのJsの構文
                    () => { // ③ 実行したいロジックを持つ「アロー関数」
                    switch (chart.chart_type) {
                        case "bar":
                        return <BarChartRenderer content={chart.chart_content} />;
                        case "line":
                        return <LineChartRenderer content={chart.chart_content} />;
                        default:
                        return <p>サポートされていないチャートタイプです</p>;
                    }
                    }
                )
                () // ④ 関数を「即座に実行する」ための「丸カッコ」。ロジックを使いたいその場で定義したい
               }
            </CardContent>
          </Card>
        </TabsContent>
      ))}
    </Tabs>
  );
}

まとめ

今回はE2B Sandboxとshadcn/uiを用いて、手元のデータと自然言語入力からインタラクティブな分析チャートを描画するという処理を実装してみました。

同様のことはClaudeチャットでもできますが、重要な点はそのロジックを抜き出すことに成功したところです。このロジックをニーズに応じてさらにカスタマイズし、自社あるいは個人向けの分析に利用すれば、データ分析がグッと楽になることと思います。

Sparkle AIブログ

Discussion