2024年7月3日水曜日

【追記あり】ポートフォリオ最適化ツールを作りました

まずは頑張って資金をたくわえてコーストFIREへの到達を目指す。その後、日々の生活費は農業で稼いで、老後資金は資産運用でまかなう。こうした道も一つかもしれません。


天候不順、災害の頻発、市場価格の変動、急激な産業構造の変化、それらに伴うコスト増、エトセトラ、エトセトラ……。

昨今、農業経営の難易度が上がっているという話をそこかしこで聞きます。私自身、10年前とは比ぶべくもないというのが実感です。

こうなってくると、食料生産に資するという農業の本流かつ激流の土俵から下りて、よりコンパクトでコントローラブルな農業を目指そうという気持ちが芽生えてくるのも無理からぬことのように思います。

それは例えば、コーストFIRE、もしくはスローライフや牧歌的農業のイデアといった姿になるかもしれません。

資本力のない我々個人農家にとっては、利幅減少による窮余一策から規模拡大に舵を切りジリ貧になっていくというパターンこそ避けねばなりません。そこから先の人生の選択肢を狭めることになります。

大規模化は、より高度な栽培技術や経営のハンドリングが求められます。明確な勝ち筋なく、突っ込むものではないですね。

人が減り、農地が捨てられている今、産業としての農業のあり方も変えていく必要があります。大きな方向性としては、それはおそらく大規模化を伴うものであると思いますが、あえてあなたが、私が、それを担うこともないわけです。

まずは主体的に最適な規模、営農環境を整えることに注力し、生き残るために最適な土俵を見つけることが大切かと思います。


……そんなこんなで前振りが長くなりましたが、万が一コーストFIREして牧歌的農家になってしまったときに老後資金を適切なポートフォリオで運用していくことができるように「ポートフォリオ最適化ツール」を作ってみました(唐突)。よわよわサーバーで運用中です。


コード自体は9割 Claude 3.5 Sonnet に作ってもらいました。効率的フロンティアを計算し、最適なポートフォリオ配分として接点ポートフォリオと最小分散ポートフォリオを提示、結果をグラフで視覚化します。




ポートフォリオという考え方は、リスクを管理し、効率を追求するという点で、農業にも活かせるところがあるように思います。


-----
・追記(2024-7-9)


農家の資産運用について cultivationdata.net にまとめました。
-----

2024年6月29日土曜日

GAS で Claude API を使った LINE bot を作る

先日、GPTGemini を使った LINE bot をそれぞれ作りましたが、今回は Claude の API を使った LINE bot を GAS(Google Apps Script)で作ってみました。

モデルは、Claude 3.5 Sonnet を使っています。プログラミングのお供にめちゃくちゃ便利ですね。


それでは、作り方です。

LINE Messaging API チャンネルの作成とアクセストークンの取得。及び、Claude の API キーの取得は済んでる想定です。

それぞれ、GAS の「プロジェクトの設定」よりスクリプト プロパティとして追加・保存しておきます。プロパティ名は「Line_key」と「Claude_key」としています。

また、スプレッドシートを用意して、「chat」シート、「system」シート、「temperature」シートを作っておきます(サンプル)。(作り方は「GAS で ChatGPT とやりとりできる簡単な LINE bot の作り方」を参照)


★ 機能一覧
  • 文頭に「続き)」を付けることで会話を継続。(例:続き)もっと詳細に教えて
  • 文頭に「昔話!」のように付けることで、スプレッドシートの「system」シートにあらかじめ登録したシステム指示を実行。(例:昔話!きのこ
  • 文頭に「画像)」で画像の処理モード。(例:画像)何ですか? → 画像を送信)
  • 文末に「(厳密」「(創造」で temperature を指定。通常 0.5、厳密 0、創造 1。(例:3行で小噺を作って(創造

コードは以下の通りです。

const spreadsheet = SpreadsheetApp.openById('スプレッドシート ID); // スプレッドシート ID を書き換え

const chatSheet = spreadsheet.getSheetByName('chat');
let chatLastRow = chatSheet.getLastRow();
let chatRange = chatSheet.getRange(2, 1, chatLastRow, 2);

const systemSheet = spreadsheet.getSheetByName('system');
const systemLastRow = systemSheet.getLastRow();
const systemWatchwordRange = systemSheet.getRange(2, 1, systemLastRow - 1, 1);
const systemContentRange = systemSheet.getRange(2, 2, systemLastRow - 1, 1);

const tempSheet = spreadsheet.getSheetByName('temperature');
const tempRange = tempSheet.getRange(1, 1);

function doPost(e) {
  try {
    const event = JSON.parse(e.postData.contents).events[0];
    let prompt = [];
    let replyMessage = '';

    switch(event.message.type) {
      case 'text': // リクエストがテキストの場合
        let messageContent = event.message.text;

        if (messageContent.startsWith('画像)')) {
          // 「画像)」に続いて指示で画像処理
          // 画像処理の場合
          messageContent = messageContent.replace('画像)', '');
          claudeRequestText(messageContent);
          sendLineMessage(event.replyToken, '画像を送信してください'); 
        } else {
          // 画像処理以外の場合
          prompt = claudeRequestText(messageContent);
          replyMessage = getClaudeReply(prompt); // Claude で回答
          setAiMessage('assistant', replyMessage); // AI の返答をスプレッドシートに'role': 'assistant'で格納
          sendLineMessage(event.replyToken, replyMessage);
        }
        break;
      case 'image': // リクエストが画像の場合
        prompt = claudeRequestImage(event.message.id);
        replyMessage = getClaudeReply(prompt); // Claude で回答
        sendLineMessage(event.replyToken, replyMessage);
        break;
      default:
        // リクエストがサポート外のデータ形式の場合
        sendLineMessage(event.replyToken, 'サポート外のデータ形式です');
    }
  } catch {
    // エラー発生時
    sendLineMessage(event.replyToken, '不明なエラーが発生しました');
  }
}

// Claude でリクエストデータ(テキスト)を生成する関数
function claudeRequestText(messageContent) {
  // system ロールの配列を生成
  const systemWatchwordArry = systemWatchwordRange.getValues().flat();
  const systemContentArry = systemContentRange.getValues().flat();
  let systemWatchword = '';
  let systemContent = '';

  // リクエスト内容の処理
  if (messageContent.startsWith('続き)')) {
    // 文頭に「続き)」で会話を継続
    messageContent = messageContent.replace('続き)', '');
    chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([['user', messageContent]]);
  } else {
    // temperature を取得してスプレッドシートにセット
    // 文末に「(創造」「(厳密」で指定
    if (messageContent.endsWith('(創造')) {
      messageContent = messageContent.replace('(創造', '');
      tempRange.setValue(1);
    } else if (messageContent.endsWith('(厳密')) {
      messageContent = messageContent.replace('(厳密', '');
      tempRange.setValue(0);
    } else {
      tempRange.setValue(0.5);
    }
    // スプレッドシートにあらかじめ登録した system ロールの読み込み
    // 文頭に「〇〇!」でsystem ロールを指定
    for (let i = 0; i < systemWatchwordArry.length; i++) {
      if (messageContent.startsWith(systemWatchwordArry[i] + '!')) {
        systemWatchword = systemWatchwordArry[i] + '!';
        systemContent = systemContentArry[i];
        break;
      }
    }
    messageContent = messageContent.replace(systemWatchword, '');

    chatRange.clear(); // 以前のチャット内容を削除

    // リクエスト内容をスプレッドシートに格納
    if (systemContent) {
      chatSheet.getRange(2, 1, 2, 2).setValues([['system', systemContent], ['user', messageContent]]);
      chatRange = chatSheet.getRange(2, 1, 2, 2);
    } else {
      chatSheet.getRange(2, 1, 1, 2).setValues([['user', messageContent]]);
      chatRange = chatSheet.getRange(2, 1, 1, 2);
    }
  }

  // リクエストデータを生成
  let messages = [];
  let values = chatRange.getValues();
  for(let i = 0; i < values.length; i++) {
    if(values[i][0] == 'system') {
      continue;
    }
    messages.push({'role': values[i][0], 'content': values[i][1]});
  }

  return messages;
}

// Claude でリクエストデータ(画像)を生成する関数
function claudeRequestImage(id) {
  // 画像を取得
  const response = UrlFetchApp.fetch('https://api-data.line.me/v2/bot/message/' + id + '/content',{
    'headers': {
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'method': 'get'
  });
  const imageBlob = response.getBlob().getAs('image/png');

  // 画像を Base64 にエンコード
  const base64Image = Utilities.base64Encode(imageBlob.getBytes());

  // リクエストデータを生成
  let messages = [];
  let values = chatSheet.getRange(2, 1, chatLastRow - 1, 2).getValues();
  for(let i = 0; i < values.length; i++) {
    if(values[i][0] == 'user') {
      messages.push({'role': 'user','content': [{'type': 'image','source': {'type': 'base64','media_type': 'image/png','data': base64Image}},{'type': 'text','text': values[i][1]}]});
    } else {
      messages.push({'role': values[i][0], 'content': values[i][1]});
    }
  }

  return messages;
}

// Claude から回答を得る関数
function getClaudeReply(prompt) {
  let system = "";
  if(chatSheet.getRange(2, 1).getValue() == 'system') {
    system = chatSheet.getRange(2, 2).getValue();
  }

  const payload = {
    'model': 'claude-3-5-sonnet-20240620', // 使用するモデル,
    'max_tokens': 1024, // 生成する文章の最大トークン数
    'temperature': tempRange.getValue(),
    'system': system, // システム指示
    'messages': prompt
  };
  
  const options = {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': PropertiesService.getScriptProperties().getProperty('Claude_key'),
      'anthropic-version': '2023-06-01',
    },
    payload: JSON.stringify(payload),
  };

  try {
    const response = JSON.parse(UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', options).getContentText());
    return response.content[0].text; // Claude の回答
  } catch (error) {
    // エラー発生時
    console.error('Error:', error);
    return 'エラー: Claude の回答が得られませんでした';
  }
}

// AI の返答をスプレッドシートに格納する関数
function setAiMessage(role, replyMessage) {
  chatLastRow = chatSheet.getLastRow();
  chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([[role, replyMessage]]);
}

// LINE メッセージを送信する関数
function sendLineMessage(replyToken, replyMessage) {
  const linePayload = {
    'method': 'post',
    'headers': {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': replyMessage,
      }],
    }),
  };

  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', linePayload);
}

1行目の「スプレッドシート ID」は書き換えてください。

コード作成後にウェブアプリとして公開、URL を LINE Messaging API チャンネルで Webhook として設定して完成です。





***

生成AI、本当に日進月歩で性能が上がっていきますね。自身にとってのより良い活かし方を、日々探っていきたいですね。


cultivationdata.net では、GPT、Gemini、Claude を搭載した LINE bot の作り方を公開しています。

2024年5月28日火曜日

スプレッドシートのデータ分析を AI(GPT)に手伝ってもらう GAS ライブラリを作成しました


スプレッドシート上のデータの分析を AI(GPT)に手伝ってもらう GAS ライブラリを作りました。Google Apps Script 上で直接分析、または分析の提案を受けることができます(どういった分析になるかはデータによります)。

GAS ライブラリは、欠損値・重複値の探索を行う GAS ライブラリ以来、久しぶりに作りました。ライブラリ化しておくことで、いつでもお手軽に使えます。

GPT を使用するため、OpenAI の API キーが必要となります。


スクリプト ID

1kwJbmRU1hKumO3I_HeO2leId8SiTojKsdZOx5DsYTUWrqkrenj9OEq3W


ライブラリの追加方法:

  1. ライブラリの追加から上記スクリプト ID を入力して「検索」を押下
  2. バージョンを選択、任意の ID を指定して「追加」を押下

※ バージョンは最新のもの、もしくは「HEAD(開発モード)」を指定してください。

「HEAD(開発モード)」であれば常に最新の状態が維持されますが、バージョンに反映前の変更も加わりますので、予期せぬ不具合が発生する可能性があります(まぁ、最新バージョンでもありますが)。


使い方:

スクリプトの実行方法は、以下の通りです。

ライブラリの追加の際に指定した任意の ID.aiAnalysis(スプレッドシート ID, シート名, データ範囲, OpenAI の API キー, [システムプロンプト, ユーザープロンプト, 使用するモデル(default: gpt-4o), 生成する文章の最大トークン数(default: 4096), 生成された文章のランダムさ(default: 0.5)]*任意)

戻り値として GPT による回答を返します。

リファレンスはこちら


例えば test という ID でライブラリで読み込んで使う場合、以下のようなコードになります。

function myFunction() {
  const spId = "スプレッドシート ID";
  const shName = "シート名";
  const dataRange = "A1:C3";
  const key = "OpenAI API キー"

  Logger.log(test.aiAnalysis(spId, shName, dataRange, key));

  // 任意の設定
  /**
  const system = ""; // システムプロンプト
  const user = ""; // ユーザープロンプト
  const model = ""; // デフォルトは gpt-4o
  const max_tokens = 1024; // デフォルトは 4096
  const temperature = 0; // デフォルトは 0.5

  const specified = [system, user, model, max_tokens, temperature]

  Logger.log(test.aiAnalysis(spId, shName, dataRange, key, specified));
   */
}


以下は、試しに2020-2021年の原木椎茸の栽培データを投げてみた結果です。

データを分析するために、以下のステップを踏みます:

### 1. データの概要を把握
まず、データの全体的な構造と各列の意味を把握します。データには以下の列があります:
- 発生ID
- 発生開始日
- 品種
- 使用回数
- 棚番号
- 収穫日
- 積算温度(℃時)
- 平均温度(℃)
- 分散(温度)
- 標準偏差(温度)
- 平均湿度(%)
- 分散(湿度)
- 標準偏差(湿度)
- タイミング
- 品質
- 収穫までの日数
- 参考値

### 2. データのクリーニング
データに欠損値や異常値がないか確認し、必要に応じて処理します。

### 3. 基本統計量の計算
各数値列の基本統計量(平均、標準偏差、最小値、最大値など)を計算します。

### 4. データの可視化
データの分布や相関関係を可視化するために、ヒストグラム、散布図、箱ひげ図などを作成します。

### 5. 品質に影響を与える要因の特定
品質(良、優、可、不可)に影響を与える要因を特定するために、各要因と品質の関係を分析します。

### 6. モデルの構築
品質を予測するためのモデルを構築します。例えば、ロジスティック回帰や決定木などを使用します。

### 7. 結果の解釈と提言
分析結果を解釈し、品質向上のための提言を行います。

それでは、ステップごとに詳細に進めていきます。

### 1. データの概要を把握
データの全体的な構造を確認します。

```python
import pandas as pd

# データの読み込み
data = pd.read_csv('data.csv')

# データの概要を表示
print(data.info())
print(data.head())
```

### 2. データのクリーニング
欠損値や異常値がないか確認します。

```python
# 欠損値の確認
print(data.isnull().sum())

# 異常値の確認(数値列の基本統計量を表示)
print(data.describe())
```

必要に応じて欠損値の補完や異常値の処理を行います。

### 3. 基本統計量の計算
各数値列の基本統計量を計算します。

```python
# 基本統計量の計算
print(data.describe())
```

### 4. データの可視化
データの分布や相関関係を可視化します。

```python
import matplotlib.pyplot as plt
import seaborn as sns

# ヒストグラムの作成
data.hist(bins=30, figsize=(20, 15))
plt.show()

# 散布図行列の作成
sns.pairplot(data)
plt.show()
```

### 5. 品質に影響を与える要因の特定
品質(良、優、可、不可)に影響を与える要因を特定します。

```python
# 品質と各要因の関係を分析
sns.boxplot(x='品質', y='平均温度(℃)', data=data)
plt.show()

sns.boxplot(x='品質', y='平均湿度(%)', data=data)
plt.show()

# 相関行列の作成
corr_matrix = data.corr()
sns.heatmap(corr_matrix, annot=True, cmap='coolwarm')
plt.show()
```

### 6. モデルの構築
品質を予測するためのモデルを構築します。

```python
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report

# 特徴量とターゲットの分割
X = data[['平均温度(℃)', '平均湿度(%)', '積算温度(℃時)', '収穫までの日数']]
y = data['品質']

# データの分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# ロジスティック回帰モデルの構築
model = LogisticRegression()
model.fit(X_train, y_train)

# 予測と評価
y_pred = model.predict(X_test)
print(accuracy_score(y_test, y_pred))
print(classification_report(y_test, y_pred))
```

### 7. 結果の解釈と提言
分析結果を解釈し、品質向上のための提言を行います。

```markdown
# 結果の解釈

- 平均温度、平均湿度、積算温度、収穫までの日数が品質に影響を与えていることが分かりました。
- ロジスティック回帰モデルにより、品質の予測精度は約XX%でした。

# 提言

- 平均温度をXX℃に保つことで、品質が向上する可能性があります。
- 平均湿度をXX%に保つことで、品質が向上する可能性があります。
- 収穫までの日数をXX日に調整することで、品質が向上する可能性があります。
```

以上がデータ分析のステップです。具体的なデータを用いた分析の実行は、Pythonのコードを実際に実行する必要があります。


Python を用いた分析手順を解説してくれていますね。

デフォルトでは、ざっくりしたプロンプトで指示しているのですが、具体的な指示を与えれば、より詳細な分析もしてくれると思います。


本ライブラリに関する今後の更新は cultivationdata.net で行います。

2024年5月20日月曜日

GAS で Gemini API を使った LINE bot を作る

-----
・追記(2024-6-24)
cultivationdata.net で、GPT、Gemini、Claude を搭載した LINE bot の作り方を公開しました。

・追記(2024-6-29)
Claude の LINE bot を作りました → 「GAS で Claude API を使った LINE bot を作る
-----

先日、GPT-4o を使った LINE bot を作りましたが、今回は Google の生成 AI である Gemini の API を使った LINE bot を GAS(Google Apps Script)で作ってみました。

とりあえず、継続的なやりとりや temperature の指定、画像処理もできるようにしました。

LINE Messaging API チャンネルの作成とアクセストークンの取得。及び、Gemini の API キーの取得は済んでる想定です。

それぞれ、GAS の「プロジェクトの設定」よりスクリプト プロパティとして追加・保存しておきます。プロパティ名は「Line_key」と「Gemini_key」としています。

また、スプレッドシート(サンプル)を用意して、「chat」シートと「temperature」シートを作っておきます。(作り方は「GAS で ChatGPT とやりとりできる簡単な LINE bot の作り方」を参照)


★ 機能一覧
  • 文頭に「続き)」を付けることで会話を継続。(例:「続き)もっと詳細に教えて」)
  • 文頭に「画像)」で画像の処理。(例:「画像)何ですか?」 → 画像を送信)
  • 文末に「(厳密」「(創造」で temperature を指定。通常 0.5、厳密 0、創造 1。(例:「3行で小噺を作って(創造」)


コードは以下の通りです。

const spreadsheet = SpreadsheetApp.openById('スプレッドシート ID');

const chatSheet = spreadsheet.getSheetByName('chat');
let chatLastRow = chatSheet.getLastRow();
let chatRange = chatSheet.getRange(2, 1, chatLastRow, 2);

const tempSheet = spreadsheet.getSheetByName('temperature');
const tempRange = tempSheet.getRange(1, 1);

function doPost(e) {
  try {
    const event = JSON.parse(e.postData.contents).events[0];
    let prompt = [];
    let replyMessage = '';

    switch(event.message.type) {
      case 'text': // リクエストがテキストの場合
        let messageContent = event.message.text;

        if (messageContent.startsWith('画像)')) {
          // 画像処理の場合
          messageContent = messageContent.replace('画像)', '');
          geminiRequestText(messageContent); // リクエスト(テキスト)を成型・保存
          sendLineMessage(event.replyToken, '画像を送信してください'); 
        } else {
          // 画像処理以外の場合
          prompt = geminiRequestText(messageContent);
          replyMessage = getGeminiReply(prompt); // Gemini で回答
          setAiMessage('model', replyMessage); // AI の返答をスプレッドシートに'role': 'model'で格納
          sendLineMessage(event.replyToken, replyMessage);
        }
        break;
      case 'image': // リクエストが画像の場合
        prompt = geminiRequestImage(event.message.id);
        replyMessage = getGeminiReply(prompt); // Gemini で回答
        sendLineMessage(event.replyToken, replyMessage);
        break;
      default:
        // リクエストがサポート外のデータ形式の場合
        sendLineMessage(event.replyToken, 'サポート外のデータ形式です');
    }
  } catch {
    // エラー発生時
    sendLineMessage(event.replyToken, '不明なエラーが発生しました');
  }
}

// リクエストデータ(テキスト)を生成する関数
function geminiRequestText(messageContent) {
  // リクエスト内容の処理
  if (messageContent.startsWith('続き)')) {
    // 文頭に「続き)」で会話を継続
    messageContent = messageContent.replace('続き)', '');
    chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([['user', messageContent]]);
  } else {
    // temperature を取得してスプレッドシートにセット
    // 文末に「(創造」「(厳密」で指定
    if (messageContent.endsWith('(創造')) {
      messageContent = messageContent.replace('(創造', ''); // 1
      tempRange.setValue(1);
    } else if (messageContent.endsWith('(厳密')) {
      messageContent = messageContent.replace('(厳密', ''); // 0
      tempRange.setValue(0);
    } else {
      tempRange.setValue(0.5);
    }

    chatRange.clear(); // 以前のチャット内容を削除

    // リクエスト内容をスプレッドシートに格納
    chatSheet.getRange(2, 1, 1, 2).setValues([['user', messageContent]]);
    chatRange = chatSheet.getRange(2, 1, 1, 2);
  }

  // リクエストデータを生成
  let contents = [];
  let values = chatRange.getValues();
  for(let i = 0; i < values.length; i++) {
      contents.push({'role': values[i][0], 'parts': {'text': values[i][1]}});
  }

  return contents;
}

// Gemini でリクエストデータ(画像)を生成する関数
function geminiRequestImage(id) {
  // 画像を取得
  const response = UrlFetchApp.fetch('https://api-data.line.me/v2/bot/message/' + id + '/content',{
    'headers': {
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'method': 'get'
  });
  const imageBlob = response.getBlob().getAs('image/jpeg');

  // 画像を Base64 にエンコード
  const base64Image = Utilities.base64Encode(imageBlob.getBytes());

  // リクエストデータを生成
  let contents = [{"parts": [{"text": chatSheet.getRange(2, 2).getValue()}, {"inlineData": {"mimeType": "image/jpeg", "data": base64Image}}]}];

  return contents;
}

// Gemini から回答を得る関数
function getGeminiReply(prompt) {
  let url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=${PropertiesService.getScriptProperties().getProperty('Gemini_key')}`;

  const payload = {
    'contents': prompt,
    'generationConfig': {
      'maxOutputTokens': 1024, // 生成する文章の最大トークン数
      'temperature': tempRange.getValue() // 生成された文章のランダムさを制御するパラメータ。値が高いほど、よりランダムな文章が生成される
    }
  };

  const options = {
    'payload': JSON.stringify(payload),
    'method' : 'POST',
    'muteHttpExceptions': true,
    'contentType':'application/json'
  };

  try {
    const response = JSON.parse(UrlFetchApp.fetch(url, options).getContentText());
    return response.candidates[0].content.parts[0].text; // Gemini の回答
  } catch {
    // エラー発生時
    return 'エラー: Gemini の回答が得られませんでした';
  }
}

// AI の返答をスプレッドシートに格納する関数
function setAiMessage(role, replyMessage) {
  chatLastRow = chatSheet.getLastRow();
  chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([[role, replyMessage]]);
}

// LINE メッセージを送信する関数
function sendLineMessage(replyToken, replyMessage) {
  const linePayload = {
    'method': 'post',
    'headers': {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': replyMessage,
      }],
    }),
  };

  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', linePayload);
}
※ 「gemini-1.5-flash-latest」モデルを使ったコードに変更しました(2024-6-1)

1行目の「スプレッドシート ID」は書き換えてください。

コード作成後にウェブアプリとして公開、URL を LINE Messaging API チャンネルで Webhook として設定して完成です。


使い方は、GPT のものと同じです。(ただ、システム指示はできません)



***

今回 Gemini をはじめて触りました。GPT だけでなく、こちらもしっかり追っていければと思います。

LINE bot に関しては、GPT とGemini を切り替えて使えるようにしておくと便利そうな気がします。

2024年5月17日金曜日

GAS で GPT-4o API を使った画像処理ができる LINE bot を作る

-----
・追記(2024-5-21)
Gemini の LINE bot も作ってみました → 「GAS で Gemini API を使った LINE bot を作る

・追記(2024-6-24)
cultivationdata.net で、GPT、Gemini、Claude を搭載した LINE bot の作り方を公開しました。

・追記(2024-6-29)
Claude の LINE bot を作りました → 「GAS で Claude API を使った LINE bot を作る
-----

先日発表された GPT-4o が、いろいろすごいみたいですね。

すでに API の利用ができるそうなので、以前、GAS(Google Apps Script) で作った LINE bot(「GAS で ChatGPT とやりとりできる簡単な LINE bot の作り方」)に画像処理機能を追加してみました。

スプレッドシート(サンプル)の準備等、基本的な作り方は、以前作った LINE bot と同じです。


★ 機能一覧
  • 文頭に「続き)」を付けることで会話を継続。(例:「続き)もっと詳細に教えて」)
  • 文頭に「昔話!」のように付けることで、スプレッドシートの「system」シートにあらかじめ登録したシステム指示を実行。(例:「昔話!きのこ」)
  • 文頭に「画像)」で画像の処理モード。(例:「画像)何ですか?」 → 画像を送信)
  • 文末に「(厳密」「(創造」で temperature を指定。通常 0.5、厳密 0、創造 1。(例:「3行で小噺を作って(創造」)


GAS のコードを、以下のように書き換えます。

const spreadsheet = SpreadsheetApp.openById('スプレッドシート ID');

const chatSheet = spreadsheet.getSheetByName('chat');
let chatLastRow = chatSheet.getLastRow();
let chatRange = chatSheet.getRange(2, 1, chatLastRow, 2);

const systemSheet = spreadsheet.getSheetByName('system');
const systemLastRow = systemSheet.getLastRow();
const systemWatchwordRange = systemSheet.getRange(2, 1, systemLastRow - 1, 1);
const systemContentRange = systemSheet.getRange(2, 2, systemLastRow - 1, 1);

const tempSheet = spreadsheet.getSheetByName('temperature');
const tempRange = tempSheet.getRange(1, 1);

function doPost(e) {
  try {
    const event = JSON.parse(e.postData.contents).events[0];

    switch (event.message.type){
      case 'text': // リクエストがテキストの場合
        let messageContent = event.message.text;

        if (messageContent.startsWith('画像)')) {
          // 画像処理の場合
          messageContent = messageContent.replace('画像)', '');
          getRequestMessage(messageContent); // リクエスト(テキスト)を成型・保存
          sendLineMessage(event.replyToken, '画像を送信してください');  // LINE で応答
        } else {
          // 画像処理以外の場合
          const requestData = getRequestMessage(messageContent); // リクエスト(テキスト)を成型・保存
          const replyMessage = generateOpenAiReply(requestData); // GPT で回答
          setAssistantMessage(replyMessage); // スプレッドシートに'role': 'assistant'で格納
          sendLineMessage(event.replyToken, replyMessage); // LINE で応答
        }
        break;
      case 'image': // リクエストが画像の場合
        const requestData = getRequestImage(event.message.id); // リクエスト(画像)を成型・保存
        const replyMessage = generateOpenAiReply(requestData); // GPT で回答
        setAssistantMessage(replyMessage); // スプレッドシートに'role': 'assistant'で格納
        sendLineMessage(event.replyToken, replyMessage); // LINE で応答
        break;
      default:
        // リクエストがサポート外のデータ形式の場合
        sendLineMessage(event.replyToken, 'サポート外のデータ形式です'); // LINE で応答
    }
  } catch {
    // エラー発生時
    sendLineMessage(event.replyToken, 'エラーが発生しました'); // LINE で応答
  }
}

// リクエストデータ(テキスト)を生成する関数
function getRequestMessage(messageContent) {
  // system ロールの配列を生成
  const systemWatchwordArry = systemWatchwordRange.getValues().flat();
  const systemContentArry = systemContentRange.getValues().flat();
  let systemWatchword = '';
  let systemContent = '';

  // リクエスト内容の処理
  if (messageContent.startsWith('続き)')) {
    // 文頭に「続き)」で会話を継続
    messageContent = messageContent.replace('続き)', '');
    chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([['user', messageContent]]);
  } else {
    // temperatureを取得してスプレッドシートにセット
    // 文末に「(創造」「(厳密」で指定
    if (messageContent.endsWith('(創造')) {
      messageContent = messageContent.replace('(創造', ''); // 1
      tempRange.setValue(1);
    } else if (messageContent.endsWith('(厳密')) {
      messageContent = messageContent.replace('(厳密', ''); // 0
      tempRange.setValue(0);
    } else {
      tempRange.setValue(0.5);
    }
    // スプレッドシートにあらかじめ登録した system ロールの読み込み
    for (let i = 0; i < systemWatchwordArry.length; i++) {
      // 文頭に「〇〇!」でsystem ロールを指定
      if (messageContent.startsWith(systemWatchwordArry[i] + '!')) {
        systemWatchword = systemWatchwordArry[i] + '!';
        systemContent = systemContentArry[i];
        break;
      }
    }
    messageContent = messageContent.replace(systemWatchword, '');

    chatRange.clear(); // 以前のチャット内容を削除

    // リクエスト内容をスプレッドシートに格納
    if (systemContent) {
      chatSheet.getRange(2, 1, 2, 2).setValues([['system', systemContent], ['user', messageContent]]);
      chatRange = chatSheet.getRange(2, 1, 2, 2);
    } else {
      chatSheet.getRange(2, 1, 1, 2).setValues([['user', messageContent]]);
      chatRange = chatSheet.getRange(2, 1, 1, 2);
    }
  }

  // リクエストデータを生成
  let requestData = [];
  if (systemContent) {
    requestData.push({'role': 'system', 'content': systemContent});
  }
  let values = chatRange.getValues();
  for(let i = 0; i < values.length; i++) {
    requestData.push({'role': values[i][0], 'content': values[i][1]});
  }

  return requestData;
}

// リクエストデータ(画像)を生成する関数
function getRequestImage(id) {
  // 画像を取得
  const response = UrlFetchApp.fetch('https://api-data.line.me/v2/bot/message/' + id + '/content',{
    'headers': {
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'method': 'get'
  });
  const imageBlob = response.getBlob().getAs('image/png');

  // 画像を Base64 にエンコード
  const base64Image = Utilities.base64Encode(imageBlob.getBytes());

  // リクエストデータを生成
  let requestData = [];
  let values = chatSheet.getRange(2, 1, chatLastRow - 1, 2).getValues();
  for(let i = 0; i < values.length; i++) {
    if(values[i][0] == 'user') {
      requestData.push({'role': 'user', 'content': [{'type': 'text', 'text': values[i][1]},{'type': 'image_url', 'image_url': {'url': `data:image/png;base64,${base64Image}`}}]});
    } else {
      requestData.push({'role': values[i][0], 'content': values[i][1]});
    }
  }

  return requestData
}

// GPT から回答を得る関数
function generateOpenAiReply(requestMessage) {
  const openAiHeaders = {
    'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('OpenAI_key')}`,
    'Content-type': 'application/json',
    'X-Slack-No-Retry': 1
  };

  const openAiPayload = {
    'model': 'gpt-4o', // 使用するモデル
    'max_tokens': 1024, // 生成する文章の最大トークン数
    'temperature': tempRange.getValue(), // 生成された文章のランダムさを制御するパラメータ。値が高いほど、よりランダムな文章が生成される
    'messages': requestMessage // 生成する文章の元になるプロンプト
  };

  const openAiParams = {
    'method': 'POST',
    'headers': openAiHeaders,
    'payload': JSON.stringify(openAiPayload),
  };

  const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', openAiParams);
  const resData = JSON.parse(response.getContentText());

  return resData.choices[0].message.content; // GPT の回答
}

// アシスタントの返答をスプレッドシートに格納する関数
function setAssistantMessage(replyMessage) {
  chatLastRow = chatSheet.getLastRow();
  chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([['assistant', replyMessage]]);
}

// LINE メッセージを送信する関数
function sendLineMessage(replyToken, replyMessage) {
  const linePayload = {
    'method': 'post',
    'headers': {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': replyMessage,
      }],
    }),
  };

  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', linePayload);
}
※ コードを一部修正しました。

画像処理に関連しない部分の大きな変更点ですが、GPT-4o はより人間っぽく振る舞うことができるようになったとのことなので、英語をかませるのをやめて、日本語で直接 API とやりとりするようにしました。


改めて、画像処理モードの使い方です。


文頭に「画像)」と付けてプロンプトを書くと画像処理のモードになります。

「画像を送信してください」と返答があるので、続けて画像を送ると処理が行われます。

画像処理モードでも、これまで通り、「画像)日本語!写真の料理のレシピを教えて(創造」のような形式で、system ロールへの指示や、temperature パラメータの指定もできます(詳細は以前の投稿を参照してください)。

ただ、「続き)」を使った継続的なやりとりはできません(あまり意味ないかな?と思って)。



面倒な書き起こしもらくらく(ちょっと間違ってるけど)。


***

ということで、GPT-4o を使って画像処理ができる LINE bot を作ってみました。

GAS で作れるとサーバーのことを考えなくていいので、とても楽です。

この簡便さと能力であれば、アイデア次第で、個人でも農業に役立つものがあれこれ作れそうですね。

2024年4月25日木曜日

【定期】家計の見直し(2024年4月)

立て続けになりますが、家計の見直しです。


経営と家計が一体化した家族経営では、家計の最適化が心強い生存基盤になります。翻って、家族や自身にとって本当に価値あるものが何なのか、確認する良い機会になります。


・mineo で光回線代替

昨年、ひかり電話で使っていた固定電話を解約したのですが、今回、とうとう光回線自体を解約しました。

うちの場合、安定的な回線速度を要求するような使い方もしないので、戸建ての高価な光回線の維持しておく必要があるものだろうかと常々考えていました。

そんなわけで、3年ごとの契約更新のタイミングですっぱり解約してみました。

(ちなみに、auひかりですが、電話一本で簡単に即日解約できました)

代替回線は、mineo のマイピタ20GBとパケット放題 Plusで、1,925円です。これで、ざっくり3,400円の固定費削減になりました。

新しい mineo 回線は SIM カードを Rakuten WiFi Pocket に入れて運用することにしました。Rakuten WiFi Pocket は、以前キャンペーン時に1円で入手したもので、使いどころがなくてしまってありました。

基本は、節約ON で1.5Mbps の速度にしていますが、それほど不便なく使えています。突発的な何がなければ3日間で10GB の制限にも引っかからなそうです。

もちろん、複数人で同時に使う際には若干の遅れを感じますが、コストパフォーマンスを考えれば待てる範囲です。また、いざという時は20GB 分の高速通信も可能です。


自宅の電波が良ければ様々な選択肢があり得るんですけどね。ドコモ回線ダメ、au 回も線アップロードが貧弱という状況で、ソフトバンク回線のみまともに使えます。mineo には長くサービスを続けてほしいです。


・今年度も「do!浜通り」でガソリンを入れる

今年度も「do!浜通り」によるポイント還元をやるようです。

1回目は、4月25日(木)〜5月8日(水)で、いわき市は、10%還元の2,500ポイント上限(決済方法あたり)です。

今回から、決済方法が増えて楽天ペイも使えるので、ありがたいですね。いつものごとくガソリンを入れます。


・年金改革

年金制度の改革が以下のように検討されているそうです。

① 厚生年金の対象拡大
② 基礎年金の納付期間 40年 → 45年
③ 基礎年金の給付抑制を早期停止
④ 在職老齢年金の見直し
⑤ 保険料の基準額の上限上げ

このままでは制度が保たない、というのはわかる気がします。先日、自身(30代後半)の基礎年金のトータル納付額を確認したところ、すでに両親の納付額の1.5倍になっていました。

結局のところ、個人としては、使える制度を使いながらうまくやっていくしかないですね。


・昨今の雑感

塵も積もれば何とやらで、固定費削減の効果は抜群ですね。

今年は施設の大きな修繕等を予定しています。小規模農家が生き残っていくためには、全方位にアンテナを張って、地道にコツコツやっていくしかありません。

頑張っていきましょう!


花海棠
ちょっと前の写真ですが「花海棠」

2024年3月29日金曜日

【定期】家計の見直し(2024年3月)

家計の見直しです。

経営と家計が一体化した家族経営では、家計の最適化が心強い生存基盤になります。翻って、家族や自身にとって本当に価値あるものが何なのか、確認する良い機会になります。

・povo と楽天モバイルのデュアル SIM に変更

最近は以下の通り運用していました。

本回線 povo:5分以内通話かけ放題(550円)と3GB/30日間(990円)
データ通信用 IIJmio:ギガプラン eSIM(ドコモ回線) 5GB(660円)と10GB(1,100円)を月ごとに行ったり来たり

合計 2,200 or 2,640円

そもそも自宅でドコモ回線が入らないためこんなややこしい運用になっているのですが、この povo と IIJmio のデュアル SIM ですとデータ通信回線や通信容量の頻繁な切り替えが必須でやや面倒でした。

また、au と ドコモの回線を保持しておくといざという時に安心かなと思っていたのですが、最近はドコモ回線の調子がいまいち(?)な感もあってあまり当てにならないかなと……。

そんなこんなで、データ通信用の回線を IIJmio → 楽天モバイルに変更しました。

本回線 povo:5分以内通話かけ放題(550円)
データ通信用 楽天モバイル:だいたい20GB以内(2,178円)

合計 2,728円


合計金額は上がっているのですが、番号通知はなくていいし音声の品質も問わない、かつ5分以上かかりそうな電話に関しては、楽天モバイルの Rakuten Link を使うことで無料になります。この分を考えると年間トータルでとんとん以下になりそうな気がします。(ただやはり、重要な電話にはやはり電波や音質の不安が拭えきれないので povo を利用)

また、この povo と楽天モバイルのデュアル SIM であれば基本的に回線の切り替えが必要ないので日常的な手間はありません。

それから、楽天モバイルは追加料金なく2GBの海外利用ができるので旅行の際には便利ですね。

楽天モバイルで足りない時には povo でもデータ容量の購入が可能なので安心です。ちなみに povo は、アプリにログイン済みであれば、外部のインターネット接続環境がなくても海外データトッピングをご購入できるそうです。楽天モバイルを使い切った後から購入できるので、地味にありがたい機能です。


・楽天でんきの動力プランがけっこうお安め?と思いきや

4月から電気料金が値下げになるとのことだったので、現在動力を契約している楽天でんきと変更先候補の東北電力の料金の比較をしてみました。

東北電力の動力(低圧電力)の場合、基本料金が1,300円89銭/kW、電力量料金が夏季27円09銭/kWh、冬季25円64銭です。加えて、燃料費等調整額と再生可能エネルギー発電促進賦課金がかかります。

対して楽天でんきの動力プランは、基本料金が695円/kW、電力量料金が26.60円/kWhとなっています。加えて、市場価格調整額と再生可能エネルギー発電促進賦課金がかかります。

うちの場合、1年のうち半年は利用していないので、基本料金が安い方がお得になります。(使用量が0kWhだと基本料金も半額になります)

これだけ見ると圧倒的に楽天でんきがお得に見えます。

しかし、24年2月の燃料費等調整額と市場価格調整額を確認したところ、燃料費等調整額が約-10円なのに対して市場価格調整額は0円になっています。

たぶん逆転しているタイミングもあると思うのですが、仮にこの差が1年間続くと、トータル金額的にはとんとんくらいになります。悩ましいところです……。

とりあえず、楽天でんきのままもう少し様子を見ようかと思います。


・新 NISA

今年始まった新しい NISA ですが、みなさまご活用なさってますでしょうか?

全く持って先の読めない昨今、経営の財務的な安定性を高めるとともに、個人としてもしっかりと生活基盤を整えておかねばなりません。

クレジットカードでの積立も10万円まで可能になりましたから、上手に使っていきたいところですね。

ちなみに農家の老後の備えとしては、国民年金(+付加年金)をしっかり納めつつ、農業者年金 or iDeCo をできる範囲でやり、あとは新 NISA に全力を出せば、ひとまず人事は尽くした感があります。これでダメだったら天命だと思って諦めようという心境です(笑)。

(もちろん状況によって、新 NISA よりも所得控除ができる農業者年金や iDeCo の枠を埋めるのが優先されるパターンもあると思います)


・昨今の雑感

小規模な農家にとって、これからますます厳しい時代になると言われています。

ことの因果はいろいろあれど、簡単に言えば「利幅が小さくなっているから、規模を大きくするしかないよね。大きくなれないならしょうがないよね」ということだと理解しています。

こうした中で、われわれのような小さな個人農家が生き残っていくためには、利幅が小さ過ぎて大きな経営体が参入しないところで独自の価値を創造するしかありません。そして、それこそ社会の豊かさの源なんじゃないかと思ったり、思わなかったり。

まぁ、言うは易しなんですが……。

もちろん、そもそも素直に時流に乗って規模拡大すればいいんじゃないのかという考えももっともです。ただ、大きい方は大きい方で、やはり淘汰が進むと思うのですよね。

であれば、やはり自分の得意な、もしくは好きな土俵で頑張るのがよさそうな気がします。


きな粉とキクラゲ