dify上で生成AIに作ってもらったコードを実行したいというお話

# はじめに
SaaS版のchatGPTやClaudeのサービスのようにセルフホスト版のDify上で生成AIに作ってもらったグラフ可視化コードを実行したいけど、Difyのコードブロックだとpython標準モジュールしかインポートできない...という課題点があります。
なので、生成AIが作成したコードを実行するための実行環境をDifyの真横に立てて実行結果をDifyで受け取るという構成でfastAPIをベースにコード実行環境を構築したよ。というお話です。

# なぜdifyなのか？
やっぱり社内データとか機密データとかをSaaS版アプリにアップロードするのは社内規定的にNGという会社が多いのでは？と思います。
そんな中Difyなら、セルフホストで社内環境に構築でき、かつ、各企業で契約しているLLMに対してデータを投入することができる。つまり、社内規定違反にならずSaaS版chatGPTみたいなことができる！ということもあり、Difyを選定し色々試してみました。

# 注意事項
- 本記事では、Difyのセルフホストでの構築方法については記載しませんが、公式上でも手順書が出ているのでそちらを参照ください
- Docker及び、Docker composeについても利用できる前提で記載します
- あくまで個人利用想定なのでセキュリティ部分はあまり細かく制御かけていません

# Difyの制約
- コードブロックなるものがあるが、pythonの標準モジュールしかインポートできない
- コードブロックを実行するdifyのコンテナにライブラリ等のインストールを行ってみたものの、動作が安定しなかった

# Difyのymlファイル
<details><summary>とりあえずDify用のymlファイルの中身</summary>

これを拡張子.ymlにして、difyにインポートすればワークフローが立ち上がります。
```yml
app:
  description: ''
  icon: 🤖
  icon_background: '#FFEAD5'
  mode: advanced-chat
  name: test
  use_icon_as_answer_icon: false
dependencies:
- current_identifier: null
  type: marketplace
  value:
    marketplace_plugin_unique_identifier: langgenius/gemini:0.2.1@88c1b2c816ef2ea36fc411b35298a621b3260d34bc08bd9357772092728aadde
kind: app
version: 0.3.0
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      allowed_file_extensions:
      - .JPG
      - .JPEG
      - .PNG
      - .GIF
      - .WEBP
      - .SVG
      allowed_file_types:
      - image
      allowed_file_upload_methods:
      - local_file
      - remote_url
      enabled: false
      fileUploadConfig:
        audio_file_size_limit: 50
        batch_count_limit: 5
        file_size_limit: 15
        image_file_size_limit: 10
        video_file_size_limit: 100
        workflow_file_upload_limit: 10
      image:
        enabled: false
        number_limits: 3
        transfer_methods:
        - local_file
        - remote_url
      number_limits: 3
    opening_statement: ''
    retriever_resource:
      enabled: true
    sensitive_word_avoidance:
      enabled: false
    speech_to_text:
      enabled: false
    suggested_questions: []
    suggested_questions_after_answer:
      enabled: false
    text_to_speech:
      enabled: false
      language: ''
      voice: ''
  graph:
    edges:
    - data:
        sourceType: start
        targetType: llm
      id: 1747533164339-llm
      source: '1747533164339'
      sourceHandle: source
      target: llm
      targetHandle: target
      type: custom
    - data:
        isInIteration: false
        isInLoop: false
        sourceType: llm
        targetType: code
      id: llm-source-1747542796003-target
      source: llm
      sourceHandle: source
      target: '1747542796003'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        isInLoop: false
        sourceType: http-request
        targetType: answer
      id: 1747546166083-source-answer-target
      source: '1747546166083'
      sourceHandle: source
      target: answer
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        isInLoop: false
        sourceType: code
        targetType: if-else
      id: 1747542796003-source-1747549203456-target
      source: '1747542796003'
      sourceHandle: source
      target: '1747549203456'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInLoop: false
        sourceType: if-else
        targetType: http-request
      id: 1747549203456-true-1747546166083-target
      source: '1747549203456'
      sourceHandle: 'true'
      target: '1747546166083'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInIteration: false
        isInLoop: false
        sourceType: if-else
        targetType: answer
      id: 1747549203456-false-1747549268209-target
      source: '1747549203456'
      sourceHandle: 'false'
      target: '1747549268209'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInLoop: false
        sourceType: llm
        targetType: code
      id: llm-source-1747551257595-target
      source: llm
      sourceHandle: source
      target: '1747551257595'
      targetHandle: target
      type: custom
      zIndex: 0
    - data:
        isInLoop: false
        sourceType: code
        targetType: if-else
      id: 1747551257595-source-1747549203456-target
      source: '1747551257595'
      sourceHandle: source
      target: '1747549203456'
      targetHandle: target
      type: custom
      zIndex: 0
    nodes:
    - data:
        desc: ''
        selected: false
        title: 開始
        type: start
        variables: []
      height: 54
      id: '1747533164339'
      position:
        x: 80
        y: 282
      positionAbsolute:
        x: 80
        y: 282
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        context:
          enabled: false
          variable_selector: []
        desc: ''
        memory:
          query_prompt_template: '{{#sys.query#}}'
          role_prefix:
            assistant: ''
            user: ''
          window:
            enabled: false
            size: 10
        model:
          completion_params: {}
          mode: chat
          name: gemini-1.5-flash
          provider: langgenius/gemini/google
        prompt_template:
        - id: 1be6443f-92e6-489c-bacc-0d18c4e22102
          role: system
          text: "ユーザーから入力されるデータをまとめ考察を行い日本語でレポートを作成して、出力は \"report\"に出力してください。\n\n\
            また、ユーザーから入力されるデータを1枚のグラフにするコードもmatplotlibで作成してください。\nPythonコードは、JSON形式の\"\
            code\"に出力に含めてください。参考にするコードも参照してコードを作成してください。\n\n【参考にするコード】\nimport matplotlib.pyplot\
            \ as plt\ndef create_sample_plot():\n    fig, ax = plt.subplots()\n  \
            \  x = [1, 2, 3, 4, 5]\n    y = [2, 4, 1, 8, 7]\n    ax.plot(x, y, marker='o')\n\
            \    ax.set_title(\\\"Sample\\\")\n    ax.set_xlabel(\\\"X\\\")\n    ax.set_ylabel(\\\
            \"Y\\\")\n    ax.grid(True)\n    return fig\nfig = create_sample_plot()\n\
            \n【出力ルール（重要）】\n・出力は JSON形式 にしてください。\n・JSONの code フィールドには、上記のPythonコードを\
            \ 1つの文字列として格納してください。\n・Pythonコード内の改行は、文字列中で \\\\n（バックスラッシュ2つ + n）に変換してください。\n\
            ・Pythonコードの中で 三重クォート（\"\"\"）は使用しないでください。\n・フォーマット内では、(```)や(```JSON)は使用しないでください。\n\
            ・コメントは不要です。\n・グラフ画像は1枚のみ描画するコードとしてください。\n・グラフ描画を行う関数create_sample_plot()とそれを実行するfig\
            \ = create_sample_plot()を含めてください。\n・japanize_matplotlibをインポートするコードも含めてください。\n\
            ・ax.set_ylabel、ax.set_xlabel、ax.set_titleで設定する軸、グラフのタイトルは英語で記載してください。\n\
            \n出力全体は以下のフォーマットに完全準拠してください:\n\n{\n  \"report\": \"test\",\n  \"code\"\
            : \"response = \\\"test\\\"\\\\nprint(response)\"\n}\n\n\nこの形式で出力してください。"
        - role: user
          text: '{{#sys.query#}}'
        selected: false
        structured_output_enabled: true
        title: LLM
        type: llm
        variables: []
        vision:
          enabled: false
      height: 90
      id: llm
      position:
        x: 380
        y: 282
      positionAbsolute:
        x: 380
        y: 282
      selected: true
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        answer: '{{#1747551257595.report#}}


          {{#1747546166083.files#}}'
        desc: ''
        selected: false
        title: 回答
        type: answer
        variables: []
      height: 122
      id: answer
      position:
        x: 1580
        y: 74.36266919532903
      positionAbsolute:
        x: 1580
        y: 74.36266919532903
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        code: "import json\nimport re\n\ndef main(arg1: str) -> dict:\n    # \"code\"\
          : \"...\"\"\" の部分を正規表現で取り出す\n    # match = re.search(r'\"code\"\\s*:\\s*\"\
          \"\"(?P<code>.*?)\"\"\"', arg1, re.DOTALL)\n    # match = re.search(r'\"\
          code\"\\s*:\\s*\"(?P<code>.*?)\"', arg1, re.DOTALL)\n    match = re.search(r'\"\
          code\"\\s*:\\s*\"(?P<code>(?:[^\"\\\\]|\\\\.)*?)\"', arg1, re.DOTALL)\n\
          \    if not match:\n        return {\n            \"code\": \"200\",\n \
          \       }\n    else:\n        # そのまま取り出せばOK（unicode_escapeしない！）\n      \
          \  code_raw = match.group(\"code\")\n\n        return {\n            \"\
          code\": code_raw,\n        }\n\n"
        code_language: python3
        desc: ''
        outputs:
          code:
            children: null
            type: string
        selected: false
        title: コード実行
        type: code
        variables:
        - value_selector:
          - llm
          - text
          variable: arg1
      height: 54
      id: '1747542796003'
      position:
        x: 681.2273575077277
        y: 282
      positionAbsolute:
        x: 681.2273575077277
        y: 282
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        authorization:
          config: null
          type: no-auth
        body:
          data:
          - id: key-value-350
            key: ''
            type: text
            value: "{\n  \"code\": \"{{#1747542796003.code#}}\"\n}"
          type: raw-text
        desc: ''
        headers: 'accept:application/json

          Content-Type:application/json'
        method: POST
        params: ''
        retry_config:
          max_retries: 3
          retry_enabled: true
          retry_interval: 100
        selected: false
        ssl_verify: true
        timeout:
          connect: 60
          max_connect_timeout: 0
          max_read_timeout: 0
          max_write_timeout: 0
          read: 60
          write: 60
        title: HTTPリクエスト
        type: http-request
        url: http://{コンテナのIPアドレス}:8000/execute
        variables: []
      height: 110
      id: '1747546166083'
      position:
        x: 1276.596109331071
        y: 74.36266919532903
      positionAbsolute:
        x: 1276.596109331071
        y: 74.36266919532903
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        cases:
        - case_id: 'true'
          conditions:
          - comparison_operator: is not
            id: fe3a97af-494c-4444-bd41-eea0666125d3
            value: '200'
            varType: string
            variable_selector:
            - '1747542796003'
            - code
          id: 'true'
          logical_operator: and
        desc: ''
        selected: false
        title: IF/ELSE
        type: if-else
      height: 126
      id: '1747549203456'
      position:
        x: 980
        y: 282
      positionAbsolute:
        x: 980
        y: 282
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        answer: '{{#llm.text#}}

          {{#1747551257595.report#}}'
        desc: ''
        selected: false
        title: 回答 2
        type: answer
        variables: []
      height: 122
      id: '1747549268209'
      position:
        x: 1276.596109331071
        y: 253.36266919532903
      positionAbsolute:
        x: 1276.596109331071
        y: 253.36266919532903
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    - data:
        code: "import re\nimport json\n\ndef main(arg1: str) -> str:\n    # arg1 はすでに文字列として定義済み\n\
          \    # 1. ```json\\n...\\n``` の中身を取り出す\n    match = re.search(r\"```json\\\
          n(.*?)\\n```\", arg1, re.DOTALL)\n    if match:\n        json_str = match.group(1)\n\
          \        # 2. JSONとして読み込む（エスケープされた文字列もデコードされる）\n        data = json.loads(json_str)\n\
          \        report = data.get(\"report\", \"\")\n\n        return {\n     \
          \       \"report\": report,\n        }\n\n    else:\n        return {\n\
          \            \"report\": \"正常にレポートの作成を行うことができませんでした。\"\n        }\n    "
        code_language: python3
        desc: ''
        outputs:
          report:
            children: null
            type: string
        selected: false
        title: コード実行 2
        type: code
        variables:
        - value_selector:
          - llm
          - text
          variable: arg1
      height: 54
      id: '1747551257595'
      position:
        x: 651.2001439993564
        y: 516.0618484150948
      positionAbsolute:
        x: 651.2001439993564
        y: 516.0618484150948
      selected: false
      sourcePosition: right
      targetPosition: left
      type: custom
      width: 244
    viewport:
      x: 8.420840102169336
      y: 244.27199138055713
      zoom: 0.4744883430657055
```
</details>


- この中身のうち、以下の部分をdocker用のIPアドレスに置き換えてください
ipconfigして、こいつの下に書かれているipがdockerようのものです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3798380/56145ad9-7483-430d-aec1-3f8cad498f01.png)

```yml
        title: HTTPリクエスト
        type: http-request
        url: http://{コンテナのIPアドレス}:8000/execute
        variables: []
```

# ワークフローに関する解説
## 🧠 アプリ情報

| 項目 | 内容 |
|------|------|
| アプリ名 | test |
| モード | advanced-chat |
| 使用モデル | Gemini 1.5 Flash (`langgenius/gemini/google`) |
| バージョン | 0.3.0 |

---

## 🔧 フロー構成概要

Difyのワークフローは以下のノードで構成されています：

```
開始 → LLM → [コード実行1, コード実行2] → IF/ELSE → [HTTPリクエスト or 回答2] → 回答
```

---

## 🧩 各ノードの詳細

### 1. 開始（start）

- ワークフローの起点
- 入力変数はなし

---

### 2. LLM（gemini-1.5-flash）

- モデル: Google Gemini 1.5 Flash
- 入力: ユーザーの自然言語
- システムプロンプト内容:
  - ユーザー入力をもとに日本語で考察レポート (`report`) を生成
  - matplotlibで可視化用Pythonコード (`code`) をJSONで返却
  - 出力形式は以下のようなJSON形式に厳密に準拠：

    ```json
    {
      "report": "考察内容",
      "code": "Pythonコード文字列"
    }
    ```

---

### 3. コード実行1 (`1747542796003`)

- 処理内容：
  - LLM出力テキストから `"code"` フィールドのPythonコード文字列を抽出
  - 正規表現を使ってJSON内の `"code"` を切り出す

---

### 4. コード実行2 (`1747551257595`)

- 処理内容：
  - LLM出力内にある ```json ブロックから `"report"` フィールドを抽出
  - JSON文字列を正規表現で抽出して `json.loads()` で読み込む

---

### 5. IF/ELSE (`1747549203456`)

- 条件判定：
  - `コード実行1`の結果 (`code`) が `"200"` でない場合、HTTPリクエストに進む
  - `"200"` の場合は LLMとコード実行2の出力を回答に表示

---

### 6. HTTPリクエスト (`1747546166083`)

- URL: `http://100.64.1.35:8000/execute`
- メソッド: `POST`
- ヘッダー:
  ```
  Accept: application/json
  Content-Type: application/json
  ```
- ボディ:
  ```json
  {
    "code": "{{#1747542796003.code#}}"
  }
  ```
- 認証: なし (`no-auth`)

---

### 7. 回答 (`answer` / `回答 2`)

- `回答`: `コード実行1`の結果と `HTTPリクエスト`のファイルレスポンスを表示
- `回答 2`: `LLMの生出力`と`コード実行2`の結果を表示

---

## 🔒 認証と依存関係

- 認証方式: 全ノードで「認証なし」（`no-auth`）
- 依存プラグイン:
  - `langgenius/gemini:0.2.1`（Google Geminiプロバイダ）

---

## 📁 ファイルアップロード設定

- ファイルアップロード機能: **無効化**
- 設定値は存在するが、`enabled: false` のため無効

---

## 🧪 デバッグ・ロジックポイント

- 正規表現に基づく文字列処理が2箇所あり、LLMの出力形式に依存
- 出力JSONの仕様逸脱時には `"200"` を返しIF文岐により表示パスを変える

---

## 📌 備考

- ワークフロー全体の目的は：
  1. ユーザーの自然文入力をもとにGeminiでレポート + Pythonグラフコードを生成
  2. コードを抽出して外部実行APIへ渡す
  3. 結果を表示またはレポートのみ返す

---



# LLM生成コード実行環境
- gitリポジトリ
https://github.com/tom160313/code_executor.git

- gitクローン
```bash
git clone https://github.com/tom160313/code_executor.git
```

- docker composeでコンテナ立ち上げ
```bash
docker compose up
```
- 立ち上がったFastAPIのサーバーを確認
http://localhost:8000/docs
にアクセスし以下の画面が表示されたら成功
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3798380/575ed5b0-2a8a-4df3-a40e-69c3458494bd.png)

- コードの中身
コード自体はいたってシンプルで、受け取ったコードを実行→画像ファイルを保存→そのファイルをレスポンスとして返却といった感じです。
```python
from fastapi import FastAPI, Request, UploadFile, File
from pydantic import BaseModel
import uuid
import os
import traceback
import matplotlib.pyplot as plt
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from starlette.middleware.cors import CORSMiddleware
import codecs

app = FastAPI()

# CORS対応
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"]
)

# static ディレクトリ作成
STATIC_DIR = "static"
os.makedirs(STATIC_DIR, exist_ok=True)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

class CodeInput(BaseModel):
    code: str

@app.post("/execute")
async def execute_code(payload: CodeInput):
    code = codecs.decode(payload.code, "unicode_escape")
    local_vars = {}

    try:
        exec(code, local_vars, local_vars)

        for val in local_vars.values():
            if hasattr(val, "savefig"):
                filename = f"{uuid.uuid4().hex}.png"
                filepath = os.path.join(STATIC_DIR, filename)
                val.savefig(filepath)
                plt.close("all")

                # ファイルをそのままレスポンスとして返す（ダウンロード対応）
                return FileResponse(
                    path=filepath,
                    filename="graph.png",
                    media_type="image/png",
                    headers={
                        "Access-Control-Allow-Origin": "*",
                        "Access-Control-Expose-Headers": "Content-Disposition"
                    }
                )

        return {
            "status_code": 200,
            "body": "コードは実行されましたが、画像は生成されませんでした。",
            "files": []
        }

    except Exception as e:
        return {
            "status_code": 500,
            "body": f"エラーが発生しました: {str(e)}",
            "traceback": traceback.format_exc(),
            "files": []
        }
```
# 🐳 Dify用コンテナ・アプリケーション構成概要

このドキュメントは、提供された `docker-compose.yml`、`Dockerfile`、`main.py`、および `tree.txt` に基づき、コンテナ構成、ファイル構成、プログラムの動作制御内容をまとめたものです。

---

## 1. 🐋 コンテナ構成

### docker-compose.yml

- **サービス名**: `api`
- **ビルド元**: `./docker/Dockerfile`
- **ボリューム**: カレントディレクトリを `/app` にマウント
- **ポート公開**: `8501:8501`

```yaml
services:
  api:
    build:
      context: .
      dockerfile: ./docker/Dockerfile
    ports:
      - "8501:8501"
    volumes:
      - .:/app
```

### Dockerfile

- ベースイメージ: `python:3.10`
- 作業ディレクトリ: `/app`
- 依存パッケージ: `requirements.txt`
- 公開ポート: `8501`

```dockerfile
FROM python:3.10
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
EXPOSE 8501
```

---

## 2. 📁 ファイル構成（tree.txtより）

```
.
├── docker-compose.yml
├── requirements.txt
├── app/
│   ├── main.py
│   ├── static/
│   └── __pycache__/
├── docker/
│   └── Dockerfile
├── static/
│   ├── .gitkeep
│   └── *.png
```

---

## 3. ⚙️ アプリケーション動作（main.py）

### 概要

- フレームワーク: **FastAPI**
- 静的ファイル: `/static` にマウント
- 実行コード受け取りAPI: `POST /execute`

### エンドポイント `/execute`

#### 入力形式:

```json
{
  "code": "（エスケープされたPythonコード文字列）"
}
```

#### 処理の流れ:

1. 受信コードを `unicode_escape` でデコード
2. `exec()` によりコードを実行
3. `savefig()` を持つオブジェクトがあれば PNG 画像を `/static` に保存
4. 作成画像を `FileResponse` として返却

#### レスポンス例:

- 成功時（画像生成あり）: PNGファイルをレスポンス
- 成功時（画像なし）:
```json
{
  "status_code": 200,
  "body": "コードは実行されましたが、画像は生成されませんでした。",
  "files": []
}
```

- 失敗時:
```json
{
  "status_code": 500,
  "body": "エラーが発生しました: ...",
  "traceback": "..."
}
```

---

## ✅ まとめ

| 項目 | 内容 |
|------|------|
| フレームワーク | FastAPI |
| 機能 | Pythonコードの実行とグラフ画像生成 |
| ファイル保存先 | `static/` ディレクトリ |
| コンテナ構成 | Python3.10ベース、ポート8501公開、ボリュームマウント型 |
| セキュリティ | `exec()`使用のため信頼された入力が前提 |
| 利用用途想定 | Dify等からのコード生成API実行環境として利用 |

---