🤖

Dify + Discord botで運動を手軽に記録する試み

2024/06/24に公開

やりたいこと

Disocrdで雑に筋トレ内容をしゃべるといい感じにjsonで記録してくれる。

背景

運動を記録するためのアプリをいちいち開くのが面倒だし、何の種目を何回とか入れるのも面倒
そもそも運動記録の目的は以下

  • モチベ―ション上げ(自分で見返したり人に見てもらって褒められたい)
  • トレーニング部位に偏りはないか(腕ばっかりやってたりとか)
  • どんくらいやればちょうどいいのか(筋肉痛ひどすぎて腕伸ばせないとかを避けたい)

ユースケース図的な

Difyで出来ることを調べる

イメージ

Function Callingみたいなイメージ。チャットの返答結果をJsonなどの型に変形させて返してくれるみたいな。

RAGって何?

RAGって何?:検索拡張生成(外部情報の検索を組み合わせることで、回答精度を向上させる技術) https://www.nri.com/jp/knowledge/glossary/lst/alphabet/rag

とりあえずDifyを使ってみる

以下の記事を参考にlocalhostでDifyを立てる
https://docs.dify.ai/getting-started/install-self-hosted/docker-compose

nodeについて

ここにまとまってた:https://docs.dify.ai/guides/workflow/node
使えそうなノード

  • Answer:出力形式を定義できる?
  • End: これも出力形式を定義?
  • Start: ここに筋トレ報告を入れる感じかな
  • Parameter Extractor: 後続ツールのために構造化パラメータを抽出 これでは??

妄想

Inputで報告を自然言語として受付→ LLMで筋トレ分析 →Parameter Extractorでパラメータを抽出

やってみる

テンプレートを見る

Sentiment Analysis(感情分析器)がやりたいことと近そう
→LLMノードとIf/Elseしか使ってない。LLMノードでJson以外出力するなって言って無理やりやってる?

Input/Outputをテンプレートに則って考えてみる

Sentiment Analysisのinputは以下

カテゴリとかは部位で使えそう。Multisentimentは何だろう。いったん条件分岐なしで見てみる。

LLMノードの中身を見てみる

  1. SYSTEMで高次元の指示を与える

You are a text sentiment analysis model. Analyze text sentiment, categorize, and extract positive and negative keywords. If no categories are provided, categories should be automatically determined. Assign a sentiment score (-1.0 to 1.0, in 0.1 increments). Return a JSON response only.
Always attempt to return a sentiment score without exceptions.
Define a single score for the entire text and identify categories that are relevant to that text
IMPORTANT: Format the output as a JSON. Only return a JSON response with no other comment or text. If you return any other text than JSON, you will have failed.

  1. Userからの入力例を提示する

input_text: The Pizza was delicious and staff was friendly , long wait.
categories: quality, service, price

  1. ASSISTANTからの出力例を提示する

{
"positive_keywords": ["delicious", "friendly staff"],
"negative_keywords": ["long wait"],
"score": 0.3,
"sentiment": "Slightly Positive",
"categories": ["quality", "service"]
}

  1. Userからの入力を与える(変数埋め込み)

Input Text: {{埋め込み変数}}
categories: {{埋め込み変数}}

  1. 出力を次のブロックに渡す

筋トレに変換する

SYSTEMへの指示を日本語に翻訳するとこう

あなたはテキスト感情分析モデルです。テキストのセンチメントを分析し、分類し、肯定的なキーワードと否定的なキーワードを抽出する。カテゴリが提供されていない場合、カテゴリは自動的に決定されます。センチメントスコア(-1.0 から 1.0、0.1 刻み)を割り当てます。JSON レスポンスのみを返します。
例外なく、常にセンチメントスコアを返します。テキスト全体に対して単一のスコアを定義し、そのテキストに関連するカテゴリを特定する。重要:出力を JSON としてフォーマットする。他のコメントやテキストを含まない JSON 応答のみを返します。JSON 以外のテキストを返すと、失敗します。

筋トレ化してみる

あなたは運動分析モデルです。テキストの運動記録を分析し、分類し、トレーニング名と部位カテゴリ、セット数と1セット当たりの回数を抽出する。部位カテゴリが提供されていない場合、カテゴリは自動的に決定されます。JSON レスポンスのみを返します。
重要:出力を JSON としてフォーマットする。他のコメントやテキストを含まない JSON 応答のみを返します。JSON 以外のテキストを返すと、失敗します。

英語にする

You are an exercise analysis model. Text exercise records are analyzed and categorized to extract training name and site category, number of sets and number of times per set. If no site category is provided, the category is determined automatically; only the JSON response is returned.
Important: Format output as JSON. Returns only JSON responses that do not contain other comments or text; returning text other than JSON will result in failure.

Userの入力例を筋トレ化

input_text: 腹筋を10回3セット、腕立て伏せを5回3セット、スクワットを15回10セットやりました!
site categories: 大胸筋, 三角筋, 上腕三頭筋, 僧帽筋, 広背筋, 上腕二頭筋, 前腕筋群, 腹筋群, 長背筋群, 腸腰筋群・臀筋群・内転筋群, 大腿四頭筋, 大腿二頭筋, 下腿三頭筋, 前脛骨筋

Assistantの出力例を日本語化

{
    "response": [
        {
            "training_name": "腹筋",
            "site_category": ["腹筋群", "長背筋群"],
            "number_of_sets": 10,
            "number_of_times_per_set": 3
        },
        {
            "training_name": "腕立て伏せ",
            "site_category": ["大胸筋", "三角筋", "上腕三頭筋"],
            "number_of_sets": 5,
            "number_of_times_per_set": 3
        },
        {
            "training_name": "スクワット",
            "site_category": ["大腿四頭筋", "大腿二頭筋", "臀筋群", "下腿三頭筋"],
            "number_of_sets": 15,
            "number_of_times_per_set": 10
        }
    ]
}

Userのインプットは変更なし

結果

あまりに思い通り過ぎてびっくりしてる。

例1: ちゃんと必要な情報があるっぽい入力

Input: ドラゴンフライを10セット20回ずつと、ランジ10回20セットした。
Output

{
"response": [
{
"training_name": "ドラゴンフライ",
"site_category": ["腹筋群", "長背筋群"],
"number_of_sets": 10,
"number_of_times_per_set": 20
},
{
"training_name": "ランジ",
"site_category": ["大腿四頭筋", "臀筋群", "下腿三頭筋"],
"number_of_sets": 20,
"number_of_times_per_set": 10
}
]
}

例2: 適当だけど筋トレしたことはわかる入力

Input: 腕立てした。あと腹筋もした。きつかった。
Output

{
"response": [
{
"training_name": "腕立て",
"site_category": ["大胸筋", "三角筋", "上腕三頭筋"],
"number_of_sets": 1,
"number_of_times_per_set": 1
},
{
"training_name": "腹筋",
"site_category": ["腹筋群", "長背筋群"],
"number_of_sets": 1,
"number_of_times_per_set": 1
}
]
}

例3: 筋トレではないけど運動はしている入力

Input: 今日はビートセイバーを10曲くらいやった。腕がパンパンすぎる。
Output

{
"response": []
}

例4: 運動してない入力

Input: どん兵衛を1個を3回も食べた!おいしかったなあ。
Output

{
"response": []
}

jsからAPIを叩いてみる

左ペインのAPIアクセスを元にjavascriptを書いてみる。(余分な空白や改行の削除が面倒。Dify側でどうにかならないものか。

axios
  .post('http://localhost/v1/workflows/run', data, { headers })
  .then((response) => {
    // レスポンスから必要なデータを取得
    let outputText = response.data.data.outputs.text;
    
    // 文字列から余分な空白や改行を削除
    outputText = outputText.trim().replace(/\n/g, '').replace(/\s+/g, ' ');
    
    // 文字列をJSONオブジェクトにパース
    try {
      const parsedData = JSON.parse(outputText);
      console.log('Parsed data:', parsedData);
      
      // レスポンスの各トレーニング項目を処理
      parsedData.response.forEach(item => {
        console.log('Training:', item.training_name);
        console.log('Categories:', item.site_category.join(', '));
        console.log('Sets:', item.number_of_sets);
        console.log('Reps per set:', item.number_of_times_per_set);
        console.log('---');
      });
    } catch (error) {
      console.error('Error parsing JSON:', error);
      console.error('Raw output text:', outputText);
    }
  })
  .catch((error) => {
    console.error('Error:', error);
  });

結果

Training: 腹筋
Categories: 腹筋群, 長背筋群
Sets: 10
Reps per set: 3
---
Training: 腕立て伏せ
Categories: 大胸筋, 三角筋, 上腕三頭筋
Sets: 5
Reps per set: 3
---
Training: スクワット
Categories: 大腿四頭筋, 大腿二頭筋, 臀筋群, 下腿三頭筋
Sets: 15
Reps per set: 10
---

discord bot化してみる

Claude君に頼みつつコーディング

require('dotenv').config();
const { Client, GatewayIntentBits } = require('discord.js');
const axios = require('axios');

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent
  ]
});

const TARGET_CHANNEL_ID = process.env.CHANNEL_ID;
const DIFY_API_KEY = process.env.DIFY_API_KEY;
const API_ENDPOINT = process.env.API_ENDPOINT;

client.on('ready', () => {
  console.log(`Logged in as ${client.user.tag}!`);
});

client.on('messageCreate', async message => {
  if (message.channel.id === TARGET_CHANNEL_ID && !message.author.bot) {
    const userInput = message.content;

    const data = {
      inputs: {
        "input_text": userInput,
        "Categorial": "胸筋, 三角筋, 上腕三頭筋, 僧帽筋, 広背筋, 上腕二頭筋, 前腕筋群, 腹筋群, 長背筋群, 腸腰筋群・臀筋群・内転筋群, 大腿四頭筋, 大腿二頭筋, 下腿三頭筋, 前脛骨筋"
      },
      response_mode: 'blocking',
      user: message.author.id,
    };

    const headers = {
      Authorization: `Bearer ${DIFY_API_KEY}`,
      'Content-Type': 'application/json',
    };

    try {
      const response = await axios.post(API_ENDPOINT, data, { headers });
      let outputText = response.data.data.outputs.text;
      outputText = outputText.trim().replace(/\n/g, '').replace(/\s+/g, ' ');

      try {
        const parsedData = JSON.parse(outputText);
        let replyMessage = '分析結果:\n';

        parsedData.response.forEach(item => {
          replyMessage += `トレーニング: ${item.training_name}\n`;
          replyMessage += `カテゴリ: ${item.site_category.join(', ')}\n`;
          replyMessage += `セット数: ${item.number_of_sets}\n`;
          replyMessage += `1セットあたりの回数: ${item.number_of_times_per_set}\n`;
          replyMessage += '---\n';
        });

        await message.reply(replyMessage);
      } catch (error) {
        console.error('Raw output text:', outputText);
        await message.reply('申し訳ありません。結果の解析中にエラーが発生しました。');
      }
    } catch (error) {
      console.error('Error:', error);
      await message.reply('申し訳ありません。APIリクエスト中にエラーが発生しました。');
    }
  }
});

client.login(process.env.DISCORD_TOKEN);

動作確認

データの保存とフロントエンドの実装(追記予定)

express.jsとSQLiteで実装

最後までよくわかんなかったところ

WSL2環境下において、Difyのapiを他のコンテナから叩く方法がよくわからなかった。

difyのdocker/docker-composeにbotというサービス名で登録し、botコンテナからhttp://api:5001/v1/workflows/runにリクエストしたが返してくれず、結局bot開始時にWSLのIPを取得して環境変数に設定するstart.shを作った。

#!/bin/bash

# WSL2のIPアドレスを取得
WSL_IP=$(ip addr show eth0 | grep -oP '(?<=inet\s)\d+(\.\d+){3}')

# 一時ファイルを作成
temp_env=$(mktemp)

# 既存の.envファイルが存在する場合、その内容をコピー
if [ -f .env ]; then
    cp .env "$temp_env"
else
    touch "$temp_env"
fi

# API_ENDPOINTが既に存在する場合は更新し、存在しない場合は追加
if grep -q '^API_ENDPOINT=' "$temp_env"; then
    sed -i "s|^API_ENDPOINT=.*|API_ENDPOINT=http://${WSL_IP}/v1/workflows/run|" "$temp_env"
else
    echo "API_ENDPOINT=http://${WSL_IP}/v1/workflows/run" >> "$temp_env"
fi

# 一時ファイルを.envファイルに移動
mv "$temp_env" .env

# Docker Composeを実行
docker compose build --no-cache
docker compose up

たまに平気で部位を空で返してくる

そんなわけないだろ!
この辺の制約をDifyでかけられるのか...?

所感

正直Difyのメリットを引き出せていない感じがする(FunctionCallingでよくない?)
LLMノードしか使っていないのでそれはそうかも...。
Difyの強みを活かせるような改善点あったらコメントしていただけると嬉しいです!!

Discussion