居酒屋で日本酒を注文するときの悩み解決LINE Bot

居酒屋で日本酒のメニューを見ても、お酒の名前しか書いていないことがありますよね。
日本酒に詳しい人なら銘柄を見ただけでその特性が分かるかもしれませんが、そうでない人にとってはまったくの謎です。

どれを選べばいいのか分からず、つい名前や値段だけで適当に注文してしまうこと、ありませんか？
もう少しちゃんと選びたいですよね....

そんな時に役立つLINE Botを作成しました！


## 動作画面
![RPReplay_Final1716608579.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3770353/ad9d0d81-74b0-dbf5-8849-b910e9340977.gif)

日本酒の名前をLINEに入力すると
・生産地
・酒造場名
・美味しく飲むシチュエーション
・マッチする料理
・アピールポイント
・フレーバーの評価
を教えてくれます！


## LINE Bot の全体像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3770353/eb2ef658-4b95-bb14-73df-d1cffecae50b.png)

詳細気になる方はこちらからどうぞ↓
```
[{"id":"0fa51428165c39cb","type":"Webhook","z":"b98c7f7c1659167a","name":"","url":"/webhook","x":120,"y":100,"wires":[["5a603002f11dc947"]]},{"id":"be53559c44419657","type":"ReplyMessage","z":"b98c7f7c1659167a","name":"","replyMessage":"","x":1280,"y":640,"wires":[]},{"id":"6aae01c3fbf98dd4","type":"http request","z":"b98c7f7c1659167a","name":"htttp request brands","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://muro.sakenowa.com/sakenowa-data/api/brands","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":500,"y":100,"wires":[["addd1dd81f714f75"]]},{"id":"addd1dd81f714f75","type":"function","z":"b98c7f7c1659167a","name":"findIdAndBreweryIdByName","func":"msg.brands = msg.payload.brands\n\nfunction findIdAndBreweryIdByName(name) {\n    for (const brand of msg.brands) {\n        if (brand.name === name) {\n            return { id: brand.id, breweryId: brand.breweryId };\n        }\n    }\n    return null;\n}\n\n\nconst result = findIdAndBreweryIdByName(msg.searchBrand);\nif (result) {\n    msg.brandId = result.id;\n    msg.breweryId = result.breweryId;\n} else {\n    msg.brandId = null\n}\n\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":740,"y":100,"wires":[["147f9d9258f6ce85"]]},{"id":"5a603002f11dc947","type":"function","z":"b98c7f7c1659167a","name":"intSearchBrand","func":"msg.searchBrand = msg.payload;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":300,"y":100,"wires":[["6aae01c3fbf98dd4"]]},{"id":"147f9d9258f6ce85","type":"switch","z":"b98c7f7c1659167a","name":"nullCheck","property":"brandId","propertyType":"msg","rules":[{"t":"nnull"},{"t":"null"}],"checkall":"true","repair":false,"outputs":2,"x":160,"y":260,"wires":[["b303c6a02fcf3ac4"],["5d2e791bc00f9fee"]]},{"id":"b303c6a02fcf3ac4","type":"http request","z":"b98c7f7c1659167a","name":"http request flavor-charts","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://muro.sakenowa.com/sakenowa-data/api/flavor-charts","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":390,"y":240,"wires":[["bcd718be99e9873a"]]},{"id":"5d2e791bc00f9fee","type":"function","z":"b98c7f7c1659167a","name":"levenshtein","func":"// レーベンシュタイン距離を計算する関数\nfunction levenshteinDistance(a, b) {\n    const matrix = Array.from({ length: b.length + 1 }, () => []);\n    for (let i = 0; i <= b.length; i++) {\n        matrix[i][0] = i;\n    }\n    for (let j = 0; j <= a.length; j++) {\n        matrix[0][j] = j;\n    }\n    for (let i = 1; i <= b.length; i++) {\n        for (let j = 1; j <= a.length; j++) {\n            if (b[i - 1] === a[j - 1]) {\n                matrix[i][j] = matrix[i - 1][j - 1];\n            } else {\n                matrix[i][j] = Math.min(\n                    matrix[i - 1][j - 1] + 1,\n                    matrix[i][j - 1] + 1,\n                    matrix[i - 1][j] + 1\n                );\n            }\n        }\n    }\n    return matrix[b.length][a.length];\n}\n\n// 似ている名前を検索する関数\nfunction findSimilarNames(targetName, brands, topN = 3) {\n    const similarities = brands.map(brand => {\n        const distance = levenshteinDistance(targetName, brand.name);\n        return { ...brand, distance };\n    });\n    similarities.sort((a, b) => a.distance - b.distance);\n    return similarities.slice(0, topN).map(brand => ({\n        id: brand.id,\n        name: brand.name\n    }));\n}\n\n\nconst similarNames = findSimilarNames(msg.searchBrand, msg.brands);\n// 名前だけを抽出してカンマ区切りの文字列を作成\nconst similarNamesString = similarNames.map(brand => brand.name).join(', ');\n\nmsg.payload = `あなたが検索した日本酒はヒットしませんでした。\\n※漢字とひらがなの間違いに気を付けてください。\\nもしかしたらこちらのお酒ではないですか？\\n${similarNamesString}`;\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":350,"y":640,"wires":[["be53559c44419657"]]},{"id":"bcd718be99e9873a","type":"function","z":"b98c7f7c1659167a","name":"getFlavorValuesByBrandId","func":"const flavorCharts = msg.payload.flavorCharts\n\nfunction getFlavorValuesByBrandId(brandId) {\n    const result = flavorCharts.find(chart => chart.brandId === brandId);\n    if (result) {\n        return {\n            f1: result.f1,\n            f2: result.f2,\n            f3: result.f3,\n            f4: result.f4,\n            f5: result.f5,\n            f6: result.f6\n        };\n    } else {\n        return null; // 該当するbrandIdがない場合\n    }\n}\n\n\nmsg.flavorValues = getFlavorValuesByBrandId(msg.brandId);\n\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":660,"y":240,"wires":[["c6a4b49f80afb12d"]]},{"id":"c6a4b49f80afb12d","type":"switch","z":"b98c7f7c1659167a","name":"nullCheck","property":"flavorValues","propertyType":"msg","rules":[{"t":"nnull"},{"t":"null"}],"checkall":"true","repair":false,"outputs":2,"x":880,"y":240,"wires":[["27951c29954c35b9"],["4ad8aae870affa13"]]},{"id":"cbd828499cb6b0ad","type":"function","z":"b98c7f7c1659167a","name":"formatFlavorValues","func":"function formatFlavorValues(flavorValues) {\n\n    // 小数点第二位を四捨五入して少数第一位までにする\n    const roundedValues = {\n        f1: Math.round(flavorValues.f1 * 10),\n        f2: Math.round(flavorValues.f2 * 10),\n        f3: Math.round(flavorValues.f3 * 10),\n        f4: Math.round(flavorValues.f4 * 10),\n        f5: Math.round(flavorValues.f5 * 10),\n        f6: Math.round(flavorValues.f6 * 10)\n    };\n\n    return `「${msg.searchBrand}」のフレーバー\n華やか度：${roundedValues.f1}\n芳醇度：${roundedValues.f2}\n重厚度：${roundedValues.f3}\n穏やか度：${roundedValues.f4}\nドライ度：${roundedValues.f5}\n軽快度：${roundedValues.f6}\n※0~10段階で強度を評価`;\n}\n\n\nmsg.flavor = formatFlavorValues(msg.flavorValues);\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1290,"y":280,"wires":[["fb6bcb42aea19a26"]]},{"id":"4ad8aae870affa13","type":"function","z":"b98c7f7c1659167a","name":"replyMessage","func":"msg.payload = `「${msg.searchBrand}」のフレーバーは見つかりませんでしたが、きっと美味しいお酒です`;\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1020,"y":440,"wires":[["be53559c44419657"]]},{"id":"831a5aa3db609918","type":"simple-chatgpt","z":"b98c7f7c1659167a","name":"","Token":"","Model":"gpt-3.5-turbo","SystemSetting":"","functions":"","functionsType":"str","function_call":"auto","function_callType":"str","x":1280,"y":440,"wires":[["8acca8ef198c4de0"]]},{"id":"fb6bcb42aea19a26","type":"function","z":"b98c7f7c1659167a","name":"makePrompt","func":"\nconst prompt = `あなたは日本酒のソムリエです。\n日本酒を日頃から良く飲んでいる人に対して新たな知見を与えるために日本酒を解説します。\n下記情報を読み、依頼事項の対応をお願いします。\n\n■伝えたい日本酒のブランド\n「${msg.areaName}」にある「${msg.brewerName}」が造る「${msg.searchBrand}」\n\n■「${msg.searchBrand}」のフレーバー評価\n${msg.flavor}\n\n\n■依頼事項\nあなたの知識を使い、下記を合計300文字以内で日本語で教えてください。\n・「${msg.searchBrand}」の酒造場\n・「${msg.searchBrand}」の生産地\n・「${msg.searchBrand}」を美味しく飲むシチュエーション\n・「${msg.searchBrand}」にマッチする料理\n・「${msg.searchBrand}」をアピールポイント\n\n※生産地は都道府県までの情報で良いです。詳細な市町村情報は求めません。\n※アピールポイントは、日本酒ショップで「${msg.searchBrand}」の購入を悩んでいる方が購入を決心できるような内容を100字程度で望みます。\n※誤った情報を含んだら罰金1000000万円\n\n■出力形式※箇条書き形式\n【${msg.searchBrand}】\\n\n生産地：〇〇\\n\n酒造場名：〇〇\\n\n美味しく飲むシチュエーション：〇〇\\n\nマッチする料理：〇〇\\n\nアピールポイント：〇〇\\n\n\\n\n\n↓に回答を開始する\n`\n\nmsg.payload = prompt\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1270,"y":360,"wires":[["831a5aa3db609918"]]},{"id":"db46ddba3c0306b9","type":"function","z":"b98c7f7c1659167a","name":"findBrewerNameAndAreaIdBybrandId","func":"const breweries = msg.payload.breweries\n\nfunction findBrewerNameAndAreaIdBybreweryId(breweryId) {\n    for (const brewer of breweries) {\n        if (brewer.id === breweryId) {\n            return { name: brewer.name, areaId: brewer.areaId };\n        }\n    }\n    return null;\n}\n\n\n\n\nconst result = findBrewerNameAndAreaIdBybreweryId(msg.breweryId);\nif (result) {\n    msg.brewerName = result.name;\n    msg.areaId = result.areaId;\n} else {\n    msg.brewerName = null;\n    msg.areaId = null;\n}\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1330,"y":60,"wires":[["4aff3328e1494b28"]]},{"id":"27951c29954c35b9","type":"http request","z":"b98c7f7c1659167a","name":"http request breweries","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://muro.sakenowa.com/sakenowa-data/api/breweries","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":1040,"y":160,"wires":[["db46ddba3c0306b9"]]},{"id":"4aff3328e1494b28","type":"http request","z":"b98c7f7c1659167a","name":"http request areas","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://muro.sakenowa.com/sakenowa-data/api/areas","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":1290,"y":120,"wires":[["4743304a28877f25"]]},{"id":"4743304a28877f25","type":"function","z":"b98c7f7c1659167a","name":"findAreaNameByAreaId","func":"const areas = msg.payload.areas\n\nfunction findAreaNameByAreaId(areaId) {\n    for (const area of areas) {\n        if (area.id === areaId) {\n            return area.name;\n        }\n    }\n    return null;\n}\n\nmsg.areaName= findAreaNameByAreaId(msg.areaId);\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1290,"y":220,"wires":[["cbd828499cb6b0ad"]]},{"id":"8acca8ef198c4de0","type":"function","z":"b98c7f7c1659167a","name":"replayFormat","func":"msg.payload = msg.payload + `\\n\\n${msg.flavor}`\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1270,"y":520,"wires":[["be53559c44419657"]]}]
```
※使う際はReplyノードにシークレットキーとアクセストークンの設定と
![Reply.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3770353/55f1551b-5fdd-c23f-ad7b-af24531d5687.jpeg)
simple-chatgptノードにトークンの設定を各自お願いします。
![chatgpt.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3770353/9847ea28-1a2a-5cd5-0802-0d4324312765.jpeg)


## 利用しているAPI
①日本酒アプリの「さけのわ」が提供している[さけのわデータ](https://sakenowa.com)
②[OpenAI API](https://openai.com/index/openai-api/)


## プロンプトの工夫
LINEで入力される酒の名前から該当する酒の情報を「さけのわデータ」で検索し、見つかったデータを組み合わせて、以下のプロンプトを作成してChatGPTに送信しています。
RAGほどのものではありませんが、単純に酒の名前だけを入力してChatGPTから回答をもらうよりも、回答内容の精度を上げるために「さけのわデータ」で一度情報検索するイメージです。

```javascript
const prompt = `あなたは日本酒のソムリエです。
日本酒を日頃から良く飲んでいる人に対して新たな知見を与えるために日本酒を解説します。
下記情報を読み、依頼事項の対応をお願いします。

■伝えたい日本酒のブランド
「${msg.areaName}」にある「${msg.brewerName}」が造る「${msg.searchBrand}」

■「${msg.searchBrand}」のフレーバー評価
${msg.flavor}


■依頼事項
あなたの知識を使い、下記を合計300文字以内で日本語で教えてください。
・「${msg.searchBrand}」の酒造場
・「${msg.searchBrand}」の生産地
・「${msg.searchBrand}」を美味しく飲むシチュエーション
・「${msg.searchBrand}」にマッチする料理
・「${msg.searchBrand}」をアピールポイント

※生産地は都道府県までの情報で良いです。詳細な市町村情報は求めません。
※アピールポイントは、日本酒ショップで「${msg.searchBrand}」の購入を悩んでいる方が購入を決心できるような内容を100字程度で望みます。
※誤った情報を含んだら罰金1000000万円

■出力形式※箇条書き形式
【${msg.searchBrand}】\n
生産地：〇〇\n
酒造場名：〇〇\n
美味しく飲むシチュエーション：〇〇\n
マッチする料理：〇〇\n
アピールポイント：〇〇\n
\n

↓に回答を開始する
`;
```

## 入力間違え時のアシスト
このLINE Botは居酒屋で使うことを想定しています。
と言うことは、酔っぱらうと入力間違いが発生するかもしれないですよね。
入力したのに情報がでてこないとイライラする可能性があるので、そんなときのアシスト機能を追加しました。

仕組みとしては[レーベンシュタイン距離](https://ja.wikipedia.org/wiki/%E3%83%AC%E3%83%BC%E3%83%99%E3%83%B3%E3%82%B7%E3%83%A5%E3%82%BF%E3%82%A4%E3%83%B3%E8%B7%9D%E9%9B%A2)を使い、入力した日本酒名に近いさけのわデータ内の日本酒名Top3を提案するようにしています。
>二つの文字列がどの程度異なっているかを示す距離の一種である。編集距離（へんしゅうきょり、英: edit distance）とも呼ばれる。具体的には、1文字の挿入・削除・置換によって、一方の文字列をもう一方の文字列に変形するのに必要な手順の最小回数として定義される
>(wikipediaより引用)

この動作がこちらです。
![RPReplay_Final1716608744.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3770353/188119d5-cefe-0809-861a-2876c412ebba.gif)

これで正しい名前の入力がしやすくなりますね！

中身はこれです↓
```javascript
// レーベンシュタイン距離を計算する関数
function levenshteinDistance(a, b) {
    const matrix = Array.from({ length: b.length + 1 }, () => []);
    for (let i = 0; i <= b.length; i++) {
        matrix[i][0] = i;
    }
    for (let j = 0; j <= a.length; j++) {
        matrix[0][j] = j;
    }
    for (let i = 1; i <= b.length; i++) {
        for (let j = 1; j <= a.length; j++) {
            if (b[i - 1] === a[j - 1]) {
                matrix[i][j] = matrix[i - 1][j - 1];
            } else {
                matrix[i][j] = Math.min(
                    matrix[i - 1][j - 1] + 1,
                    matrix[i][j - 1] + 1,
                    matrix[i - 1][j] + 1
                );
            }
        }
    }
    return matrix[b.length][a.length];
}

// 似ている名前を検索する関数
function findSimilarNames(targetName, brands, topN = 3) {
    const similarities = brands.map(brand => {
        const distance = levenshteinDistance(targetName, brand.name);
        return { ...brand, distance };
    });
    similarities.sort((a, b) => a.distance - b.distance);
    return similarities.slice(0, topN).map(brand => ({
        id: brand.id,
        name: brand.name
    }));
}

const similarNames = findSimilarNames(msg.searchBrand, msg.brands);
```
brandsには約3,200個の銘柄の名前が格納されています。


## ダメだったこと
お酒の歴史を出力させて、より理解を深めようとしたのですが、どうやってもハルシネーションが頻発してしまうので今回は出力を諦めました。**通常のWEBのChatGPTであれば上手くできるのに何故なのか...**

## 早く使ってみたい
作ってみたものの、私は基本最初から最後までビールで終わることが多いので、あまり居酒屋での出番を期待できません。**チャンスを増やすべく飲み会の回数を増やしていきたいですね!**