オムライス指数をGoogleMapとOpenAIで算出するLINE Bot

## 昔ながらのオムライスが食べたい

昔ながらのオムライスを出す店が急激に減っています。
そこで、この駅に行けばありつけるよ、という情報を数値化することを考えています。

その数値を***オムライス指数***と名付けて、それを算出、活用できるアプリを開発して、オムライスを食べる探索に出かけたいと考えています。
noteに実際のオムライス探索とオムライス指数についてまとめていますので、合わせてみて頂ければ分かりやすいと思います。

https://note.com/tadokuno/n/nb3d3a7f96f01

今回は、アプリのプロトタイプを LINE Bot で作ってみました。

## 作成した LINE Bot

https://x.com/tadokuno/status/1825799731223867719

駅名を入力すると、その駅のオムライス指数を返します。

それを算出する根拠となる情報も同時に返しますが、本来は返す必要のない情報です。
プログラムが正しく動いていることを確認するために出力しています。

アプリを作るための第1ステップなので、ユーザービリティは考えられていませんが、ご了承ください。

## オムライス指数の各要素

最終的なオムライス指数の構成要素は10個なのですが、今回のプロトタイプではそれを6個としました。

各要素は、0点から10点の間の整数値をとり、合算して0点から60点の間を取ります。

60点満点です。
1. 古い喫茶店の数
1. 町中華の数
1. 古い商店街の存在感
1. 道が入り組んでいる度合い
1. 飲食店に限らず古い店が生き残っている度合い
1. 古いショーケースや食品サンプルが飾ってある店の存在感

### システムの概要

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212141/cfde304c-da8a-e7c1-55b3-0ce0f59c2fb9.png)

### 数値で取れる情報はGoogle MAPで
1.と2.の店の数については、原理的に数えることができます。
- Google MAP Places APIのNearby Searchを使用
- 駅から半径300m以内の店

をカウントします。

検索するには、駅の緯度と経度が必要になりますが、これはGoogle MAP Geocode APIを使って、駅名から緯度と経度を取ってきます。

検索結果ですが、APIの制限として取れる件数の最大が60件となっています。ほとんどの場合60件を大きく下回る件数ではあります。
今回は、60件を10点、0件を0点として正規化します。
点数＝件数÷６　とします。

検索キーは以下のように設定します。
1. 喫茶店の数
  type="cafe", keyword="local"
keywordの設定は、スターバックスのようなチェーン店を排除するために行っています。

2. 町中華の数
type="restaurant", keywork="町中華"
 

### 感覚値は生成AI（GPT-4o）で
3.から6.については、存在感とか度合いとか漠然とした情報なので、感覚的に得点を付ける必要があります。
今回は、OpenAIのGPT-4oの最新モデルを使って算出します。

まだβリリースですが、Structured Outputs の JSON modeを使って確実に数値が返るようにします。
プロンプトは以下のとおりです。
```
あなたは日本の町の懐かしさを評価する専門家です。駅名「${stationName}」に基づいて、
以下の4つの要素を0から10のスケールで評価し、それぞれの理由を述べてください：
    - 古い商店街の存在感 (shoutengai)
    - 道が入り組んでいる度合い (michi)
    - 飲食店に限らず古い店が生き残っている度合い (furui-mise)
    - 古いショーケースや食品サンプルが飾ってある店の存在感 (shoku-sample)
    
    結果を次の形式のJSONオブジェクトとして返してください：
    {
      "shoutengai":  { "index": 得点, "text": "根拠" },
      "michi":  { "index": 得点, "text": "根拠" },
      "furui-mise": { "index": 得点, "text": "根拠" },
      "shoku-sample": { "index": 得点, "text": "根拠" },
    }`;
```

出力値は各項目10点満点なので、これがそのまま点数となります。
得点と、根拠となっているtextは、そのままLineBOTの出力としています。

戻り値はこんな感じ
```javascript
{
  shoutengai: {
    index: 3,
    text: '渋谷は近代的なショッピングエリアとして有名で、古い商店街の存在は薄いです。ただ、一部に昭和の雰囲気を残した小さなエリアも存在しています。'
  },
  michi: {
    index: 6,
    text: '渋谷は大きな交差点や広い通りがある一方で、裏通りや入り組んだ小道も多く、探検しがいのあるエリアです。'
  },
  furuiMise: {
    index: 4,
    text: '近代化によって多くの老舗が姿を消していますが、まだわずかに歴史ある店舗が存在しており、地元の人々に根強く支持されています。'
  },
  shokuSample: {
    index: 2,
    text: '渋谷はトレンドの発信地であるため、古いショーケースや食品サンプルを扱う店は少なく、むしろ現代的なディスプレイが主流です。'
  }
}
```

以上の点数を全て合算して60点満点で確定した得点が、その駅のオムライス指数となります。

## 実装

LINEのMessaging APIを使用しています。

Messaging APIの使い方は、以下の記事を参考にしてください。

https://qiita.com/tadokuno/items/bb4ba809083d260e73ed#line-messaging-api

Webhookはnetlifyのfunctionsとしてホスティングします。
処理は全て、netlifyで実行します。

https://www.netlify.com/

netlifyについては、また今後記事にしたいと思っています。

処理に必要なソースファイルは３つ
1. lineWebhook.js
1. placesApi.js
1. openaiApi.js

<details>
<summary>ソースコード</summary>

```javascript:lineWebhook.js
import * as line from '@line/bot-sdk';
import dotenv from 'dotenv';
import { getOmuIndex } from './placesApi.js';
import { calculateOmuIndex } from './openaiApi.js';

dotenv.config();

const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.CHANNEL_SECRET,
};

const client = new line.Client(config);

export async function handler(event, context) {
  const body = JSON.parse(event.body);

  const promises = body.events.map(async (singleEvent) => {
    if (singleEvent.type === 'message' && singleEvent.message.type === 'text') {
      const stationName = singleEvent.message.text;
      try {
        fetch('https://api.line.me/v2/bot/chat/loading/start', {
          method: 'POST',
          headers: {
            "Authorization": `Bearer ${config.channelAccessToken}`,
            "Content-Type": "application/json"
          },
          body: JSON.stringify({"chatId": singleEvent.source?.userId})
        })
      } catch {
        console.log("Loading Animation Error");
      }

      try {
        const result = await getOmuIndex(stationName);

	      let messages='';
	      let point = 0;
        if( result ) {
          messages = `${result.stationName}周辺の喫茶店の数: ${result.cafeCount}件\n${result.stationName}周辺の町中華の数: ${result.chineseRestaurantCount}件\n\n`
	        point = Math.round((result.cafeCount + result.chineseRestaurantCount)/6);
	      }

	      const result2 = await calculateOmuIndex(stationName);
	      for (let key in result2 ) {
	        const data = result2[key];
	        point += data.index;
	        messages += `${key} - 得点: ${data.index}, 根拠: ${data.text}\n`;
	      }
        messages += '\n' + result.cafeMessage + '\n';
        messages += result.chineseRestaurantMessage + '\n';
	      const replyMessage = {
          type: 'text',
	        text: `オムライス指数: ${point}\n\n${messages}`
	      };
       	await client.replyMessage(singleEvent.replyToken, replyMessage);
      } catch (error) {
        console.error('Error fetching Omu Index:', error);
        const errorMessage = {
          type: 'text',
          text: 'オムライスインデックスの取得中にエラーが発生しました。'
        };
        await client.replyMessage(singleEvent.replyToken, errorMessage);
      }
    }
  });

  await Promise.all(promises);

  return {
    statusCode: 200,
    body: JSON.stringify({ status: 'success' }),
  };
}
```
```javascript:placesApi.js
import * as line from '@line/bot-sdk';
import dotenv from 'dotenv';

dotenv.config();

const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.CHANNEL_SECRET,
};

// 駅の緯度経度を取得する関数
async function getCoordinates(stationName) {
    const apiKey = process.env.GOOGLE_API_KEY;
    const geocodeUrl = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(stationName)}&key=${apiKey}`;
console.log(geocodeUrl);    
    const response = await fetch(geocodeUrl);
    const data = await response.json();
    
    if (data.status === 'OK') {
        const location = data.results[0].geometry.location;
        return location;
    } else {
        throw new Error('Geocoding API Error: ' + data.status);
    }
}

// Nearby Search APIを使用して店舗数を取得する関数
async function getAllPlaceCount(lat, lng, tpy,kwd) {
    const apiKey = process.env.GOOGLE_API_KEY;
    let totalCount = 0;
    let nextPageToken = null;
    let places = "";

    do {
        let placesUrl = `https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${lat},${lng}&radius=200&type=${tpy}&key=${apiKey}&language=ja&keyword=${kwd}`;
        if(nextPageToken) {
            placesUrl += `&pagetoken=${nextPageToken}`;
        }

	    let response;
	    try {
            response = await fetch(placesUrl);
	    } catch (error) {
		    console.log(error);
		    throw new Error('Nearby Search Error');
	    }
        if( !response.ok ) {
		    throw new Error('Nearby Search Error: ' + response.status );
        }
        const data = await response.json();

        for(let place of data.results) {
	        console.log(place.name);
            places += `${place.name}\n`;
	        totalCount ++;
	    }
        nextPageToken = data.next_page_token;

        console.log(`現在の店舗数: ${totalCount}件`);
        if (nextPageToken) {
            await new Promise(resolve => setTimeout(resolve, 2000));  // APIに負荷をかけないように待機
        }
    } while (nextPageToken);

    return {
        count: totalCount,
        message: `${places}`
    }
}

// Nearby Search API (New)を使用して店舗数を取得する関数
async function getAllPlaceCount2(lat, lng, tpy) {
    const apiKey = process.env.GOOGLE_API_KEY;
    let totalCount = 0;
    let nextPageToken = null;

    do {
        const requestBody = {
            locationRestriction: {
		circle: {
		    center: {
			latitude: lat,
			longitude: lng
		    },
            	    radius: 500  // 500m以内
		}
	    },
            includedTypes: [`${tpy}`],  // 喫茶店,町中華
	        languageCode: "ja"
//            pagetoken: nextPageToken || null
        };

        const placesUrl = 'https://places.googleapis.com/v1/places:searchNearby';

	let response;
	try {
            response = await fetch(placesUrl, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
		    'X-Goog-Api-Key': `${apiKey}`,
		    'X-Goog-FieldMask': 'places.displayName'
                },
                body: JSON.stringify(requestBody)
            });
	} catch (error) {
		console.log(error);
		throw new Error('Nearby Search Error');
	}
        const result = await response.json();
	for(let place of result.places) {
	    console.log(place.displayName.text);
	    totalCount ++;
	}
//      totalCount += result.places.length;
        nextPageToken = result.next_page_token;

        console.log(`現在の店舗数: ${totalCount}件`);
        if (nextPageToken) {
            await new Promise(resolve => setTimeout(resolve, 2000));  // APIに負荷をかけないように待機
        }
//        }
//	else {
//            throw new Error('Places API Error: ' + result.status);
//        }
    } while (nextPageToken);

    return totalCount;
}


//
//
//
export async function getOmuIndex (stationName) {
    try {
        // 駅の緯度経度を取得
        const location = await getCoordinates(stationName);
        const lat = location.lat;
        const lng = location.lng;

        // 緯度経度から指定した範囲内の店舗数を取得
        const cafeCount = await getAllPlaceCount(lat, lng, "cafe","local");
        const chineseRestaurantCount = await getAllPlaceCount(lat, lng, "restaurant","町中華");

        // ここでオムライスインデックスの計算を行う
        const omuIndex = cafeCount.count + chineseRestaurantCount.count;

        // 結果をオブジェクトとして返す
        return {
            stationName: stationName,
            cafeCount: cafeCount.count,
            chineseRestaurantCount: chineseRestaurantCount.count,
            omuIndex: omuIndex,
            cafeMessage: cafeCount.message,
            chineseRestaurantMessage: chineseRestaurantCount.message
        };
    } catch (error) {
        console.error(error);
        return null;
    } finally {
    }
};

// オムライスインデックスを計算する関数
const calculateOmuIndex = (cafeCount, chineseRestaurantCount) => {
    // 任意のロジックをここに記述
    // 例えば、喫茶店と町中華の数を合算して返す
    return cafeCount + chineseRestaurantCount;
};
```
```javascript:openaiApi.js
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";

// Initialize the OpenAI client
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_Key,
});

// Define the JSON schema using Zod
const OmuIndexSchema = z.object({
  shoutengai: z.object({
    index: z.number(), // Zodのint型に変更し、整数のみ許可
    text: z.string(),
  }),
  michi: z.object({
    index: z.number(), // Zodのint型に変更
    text: z.string(),
  }),
  furuiMise: z.object({
    index: z.number(), // Zodのint型に変更
    text: z.string(),
  }),
  shokuSample: z.object({
    index: z.number(), // Zodのint型に変更
    text: z.string(),
  }),
});

// Function to calculate the Omu Index
export const calculateOmuIndex = async (stationName) => {
  const prompt = `
    あなたは日本の町の懐かしさを評価する専門家です。駅名「${stationName}」に基づいて、以下の4つの要素を0から10のスケールで評価し、それぞれの理由を述べてください：
    - 古い商店街の存在感 (shoutengai)
    - 道が入り組んでいる度合い (michi)
    - 飲食店に限らず古い店が生き残っている度合い (furui-mise)
    - 古いショーケースや食品サンプルが飾ってある店の存在感 (shoku-sample)
    
    結果を次の形式のJSONオブジェクトとして返してください：
    {
      "shoutengai":  { "index": 得点, "text": "根拠" },
      "michi":  { "index": 得点, "text": "根拠" },
      "furui-mise": { "index": 得点, "text": "根拠" },
      "shoku-sample": { "index": 得点, "text": "根拠" },
    }`;

  try {
console.log("openai call");
    // Make a request to the OpenAI API
    const completion = await openai.beta.chat.completions.parse({
      model: "gpt-4o-2024-08-06",
//      model: "gpt-4o",
      messages: [
        { role: "system", content: "次のJSONスキーマに従って出力を生成してください。" },
        { role: "user", content: prompt },
      ],
      response_format: zodResponseFormat(OmuIndexSchema, "omuIndex"),
    });

    // Get the parsed and validated response
    const omuIndex = completion.choices[0].message.parsed;
    return omuIndex;

  } catch (error) {
    console.error("Error generating Omu Index:", error);
    return null;
  }
};
```
</details>


## 課題
### １．店の数の精度について
- 店の数から指数を計算するロジックに改善の余地がある。現状だと店の数が指数に影響するウェイトが低い。
- 検索結果の件数をそのまま鵜呑みにするのも問題がある。古い喫茶店の数についても、local cafe　で検索しているのだが、本来は古い喫茶店に該当しないタリーズコーヒーなどが引っかかる場合がある。
- もしかしたら、廃業している店も検索に引っかかっている可能性がある。
- 半径300mで考えているが、この半径が適切かどうか。500mくらいは範囲を広げた方がいいかも知れない。

### ２．生成AIのハルシネーション
- 古い商店街の存在感について、ハルシネーションが起こる可能性が高い。日本の商店街の情報がネットに十分にあるかどうか分からない。
- 道が入り組んでいる度合いについても定義が曖昧で、ここもハルシネーションが起こっている可能性が高い。整然と碁盤の目のように道路が並んでいるのと、駅を中心に放射状に道が伸びているのと、どちらがどれだけ入り組んでいるのか、ここがはっきりしない。
自分のイメージだと入り組んでいる道とは、幾何学的な法則が無い、交差点に奇数の道路が入り込む、といったイメージがある。
- 古い店については、原理的には数えることができるが、古さが創業以来なのか、建物の古さなのかといった問題もある。
- 古いショーケースや食品サンプルがある店は、現地に行けばカウントできる。
GoogleのAPIで写真が入手できるようなので、それを機械学習で判定するという方法も考えられる。

## 今後の取り組み
　このプロトタイプの数値の精度を上げていくことが重要ではあるが、実際にオムライスクエストを行ったあとのユーザーによるアップデートにより精度を上げることを考えていきたい。今の数値は初期値という考え方になる。
　それでもある程度は納得性のある数値でないと、その数値を見てクエストへのモチベーションになるようなものにならないため、初期値の精度も上げていきたい。

以上