APIキーの生成

GAS の Webhook エンドポイントを利用し、ローカルの画像を LINE に送信 できるようにする

✅ GAS を利用し、ローカル画像を LINE Bot に送信できるように修正
✅ 画像を Base64 エンコードし、GAS に POST で送信
✅ GAS 側で画像を受け取り、LINE API で送信
✅ LINE Notify を使わず、LINE Bot で画像を送信可能に!

GASでは画像は保持されるのか? またセキュリティの問題などはあるか?
で調べる

GAS(Google Apps Script)自体は画像データを保存しません。

画像を LINE Bot に送信するだけであれば、GAS での保持は発生しません。
ただし、Base64 エンコードした画像を HTTP リクエストとして送信 するため、
Google の内部ログには一時的にデータが記録される可能性がありますが、

GAS で doPost(e) を処理する際に 明示的にデータを保存しない限り、GAS 側には一切画像は残りません
ということ

リスク1】GAS の Webhook URL が外部に漏れると誰でも画像送信できる
GAS の Webhook URL (https://script.google.com/macros/s/XXXXXX/exec) が漏れると、誰でも画像を送信できてしまう
➡ 対策:リクエストの認証を行う
✅ 対策1: シークレットキーを使って認証

リスク 対策
GAS の Webhook URL が外部に漏れる API キーで認証を追加
GAS のログに画像データが保存される ログに画像データを記録しない
GAS の公開範囲の設定ミス 「自分のみ」または「特定のGoogleアカウント」に制限
✅ GAS は画像を保存しないのでセキュリティ的には問題なし
✅ API キーによる認証を追加すれば、不正利用も防げる
✅ ログに画像データを残さないように修正すれば安心

秘密の API_KEY には LINE Bot のシークレットキーを使って良いか?
🔴 いいえ、LINE Bot の channel_secret を API_KEY に使うのは推奨されません。
➡ 理由:LINE Bot の channel_secret は、署名検証や API 呼び出しの認証に使用されるため、外部に渡さない方が良い。

✅ API_KEY は独自のランダムなシークレットキーを設定
🔹 API_KEY は LINE Bot の channel_secret とは別に、GAS 専用の認証キーとして作成する のがベスト。
🔹 Python 側(line_bot_sender.py)と GAS 側(コード.gs)で一致するように設定すればOK。
🔹 これは Webhook URL が漏洩しても、不正アクセスを防ぐ

このため自分でキーの値を作成すればOK

まずはApiキーを生成する

不正使用防止のためAPIキーを設定する

リスク 対策
GAS の Webhook URL が外部に漏れる API キーで認証を追加
GAS のログに画像データが保存される ログに画像データを記録しない
GAS の公開範囲の設定ミス 「自分のみ」または「特定のGoogleアカウント」に制限
✅ GAS は画像を保存しないのでセキュリティ的には問題なし
✅ API キーによる認証を追加すれば、不正利用も防げる
✅ ログに画像データを残さないように修正すれば安心
これで、GAS を経由して 安全に画像を LINE Bot に送信できる ようになります! 🚀✨

✅ API_KEY は独自のランダムなシークレットキーを設定
🔹 API_KEY は LINE Bot の channel_secret とは別に、GAS 専用の認証キーとして作成する のがベスト。
🔹 Python 側(line_bot_sender.py)と GAS 側(コード.gs)で一致するように設定すればOK。
🔹 これは Webhook URL が漏洩しても、不正アクセスを防ぐための対策 になります。

API_KEY は ランダムな英数字(32文字程度) にするのが理想

まずはファイルの作成

touch create_api_key.py

内容は

import secrets

api_key = secrets.token_hex(32) # 32バイト(64文字)のランダムなキーを生成
print(api_key)

実行するとキーが生成される

この値を
config.jsonへ追加する

{
“token”: “”,
“image_file_path”: “runs/detect”,
“line_bot_channel_access_token”: “”,
“channel_secret”: “”,
“line_bot_user_id”: “”,
“api_key”: “”
}
[・shell]

次にGASで
PropertiesService を使って APIキー を設定
APIキーをスクリプトプロパティに設定するには

1. Google Apps Script (GAS) のエディタを開く
2. [ファイル] → [プロジェクトのプロパティ] を選択
3. [スクリプトプロパティ] タブを開く
4. 新しいプロパティを追加
* キー名: API_KEY
* 値: your_random_api_key_xxxxxxx
5. 保存ボタンを押す

これでAPI設定はできた

Linebot 画像送信機能の追加

Linebot 画像送信機能の追加

{
  "token": "",
  "image_file_path": "runs/detect",
  "line_bot_channel_access_token": "",
  "channel_secret": "",
  "line_bot_user_id": ""
}

というように
Config.jsonの内容を追加

import json
import cv2
import configparser
from ultralytics import YOLO
from collections import defaultdict
from datetime import datetime
import os

from line_bot_sender import LineBotSender  # LineBotSender をインポート
from inventory_database_module import save_detection_to_db  # データベース保存用の関数をインポート

# 設定ファイルの読み込み
config = configparser.ConfigParser()
config.read('config.ini')

# 設定ファイルからモデルパスと画像ディレクトリを取得
model_path = config['Settings']['model_path']
image_directory = config['Settings']['image_directory']

# ラベルマッピングファイルのパス
label_mapping_path = 'label_mapping.json'

# JSONファイルからクラスラベルのマッピングを読み込み
with open(label_mapping_path, 'r', encoding='utf-8') as f:
    label_mapping = json.load(f)

# LINE Bot 送信クラスのインスタンスを作成
line_bot = LineBotSender("config.json")  # `config.json` を指定

# YOLOv8モデルのロード
model = YOLO(model_path)  # 設定ファイルからモデルパスを使用

# 画像ディレクトリ内の全画像ファイルを処理
for image_filename in os.listdir(image_directory):
    image_path = os.path.join(image_directory, image_filename)
    if os.path.isfile(image_path) and image_path.lower().endswith(('.png', '.jpg', '.jpeg')):
        # 画像のロード
        image = cv2.imread(image_path)

        # 画像の検出
        results = model(image, save=True, conf=0.2, iou=0.5)

        # 検出結果の取得
        detections = results[0]  # 最初の結果を取得
        classes = detections.boxes.cls

        # 検出物体のカウント
        object_counts = defaultdict(int)
        for cls in classes:
            class_label = model.names[int(cls)]
            if class_label in label_mapping:
                label = label_mapping[class_label]
            else:
                label = class_label
            object_counts[label] += 1

        # 検出結果のフィルタリング(1以下のもの)
        filtered_object_counts = {label: count for label, count in object_counts.items() if count <= 1}

        # フィルタリングされた検出結果のメッセージ生成
        message_lines = [f'{label}: {count}個' for label, count in filtered_object_counts.items()]
        message = '\n'.join(message_lines)

        # 現在の時刻を取得
        current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        message = f"{message}\n\nMessage sent at: {current_time}"

        # 検出結果の表示
        for line in message_lines:
            print(line)

        # LINE Bot にメッセージを送信(フィルタリングされた結果のみ)
        if message_lines:
            line_bot.send_message(message)  # `LineBotSender` を使用
            save_detection_to_db(filtered_object_counts)  # データベースに検出結果を保存
        else:
            print("No objects with counts of 1 or less detected in file:", image_filename)

というように

cp count_inventory.py count_inventory_send_bot.py


Bot送信のテストのためソースを分ける

変更点は
send_line_notify(message) を
line_bot.send_message(message) に変更

LineBotSender を line_bot_sender.py からインポートして、インスタンスを作成
config.json を LINE Bot の設定情報取得用 に使用

from line_bot_sender import LineBotSender  # LineBotSender をインポート

line_bot = LineBotSender("config.json")  # `config.json` を指定

# 変更前(LINE Notify を使っていた部分)
# send_line_notify(message)

# 変更後(LINE Bot を使用)
line_bot.send_message(message)

そしてDBへの保存を
# LINE Bot にメッセージを送信(フィルタリングされた結果のみ)
if message_lines:
    line_bot.send_message(message)  # `LineBotSender` を使用
    save_detection_to_db(filtered_object_counts)  # データベースに検出結果を保存

へ変更

python count_inventory_send_bot.py

で実行すると

0: 640x512 1 baskulin, 74.1ms
Speed: 5.2ms preprocess, 74.1ms inference, 4.6ms postprocess per image at shape (1, 3, 640, 512)
Results saved to runs/detect/predict24
バスクリン: 1個

とbot への送信ができたけど
Notifyの時の画像送信機能を作成していなかったので
これを追加する

import requests
import json
import os
from io import BytesIO
from PIL import Image

class LineBotSender:
    def __init__(self, config_path):
        with open(config_path, 'r') as file:
            config = json.load(file)
        self.channel_access_token = config.get('line_bot_channel_access_token')
        self.channel_secret = config.get('channel_secret')
        self.user_id = config.get('line_bot_user_id')  # ユーザーID
        self.api_endpoint = 'https://api.line.me/v2/bot/message/push'  # メッセージ送信用API
        self.image_api_endpoint = 'https://api.line.me/v2/bot/message/push'  # 画像送信用API

    def send_message(self, message):
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        data = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(self.api_endpoint, headers=headers, json=data)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

    def send_image(self, image_path, message="画像を送信します"):
        headers = {
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        with open(image_path, 'rb') as img_file:
            image_data = img_file.read()
            image_data = self.resize_image_if_needed(image_data)

            files = {'imageFile': BytesIO(image_data)}
            files['imageFile'].name = os.path.basename(image_path)

            payload = {
                "to": self.user_id,
                "messages": [
                    {
                        "type": "text",
                        "text": message
                    },
                    {
                        "type": "image",
                        "originalContentUrl": f"https://your-server.com/images/{os.path.basename(image_path)}",
                        "previewImageUrl": f"https://your-server.com/images/{os.path.basename(image_path)}"
                    }
                ]
            }

            response = requests.post(self.image_api_endpoint, headers=headers, json=payload, files=files)

            if response.status_code != 200:
                raise Exception(f"Error sending image to LINE bot: {response.status_code}, {response.text}")

    def resize_image_if_needed(self, image_data, max_size=3 * 1024 * 1024):
        if len(image_data) > max_size:
            image = Image.open(BytesIO(image_data))
            new_size = (image.width // 2, image.height // 2)
            image = image.resize(new_size, Image.LANCZOS)

            output = BytesIO()
            image_format = image.format if image.format else 'JPEG'
            image.save(output, format=image_format)
            return output.getvalue()
        return image_data

✅ send_image() メソッドを追加し、画像を送信できるようにした
✅ resize_image_if_needed() で 3MB 超過時にリサイズする処理を追加
✅ originalContentUrl と previewImageUrl は 公開 URL に設定する必要があるため、適宜サーバーにアップロードする処理が必要

しかし
また config.json で “image_file_path”: “runs/detect”,
が設定してあるので
コードを変更する

import requests
import json
import os
from io import BytesIO
from PIL import Image

class LineBotSender:
    def __init__(self, config_path):
        with open(config_path, 'r') as file:
            config = json.load(file)
        self.channel_access_token = config.get('line_bot_channel_access_token')
        self.channel_secret = config.get('channel_secret')
        self.user_id = config.get('line_bot_user_id')  # ユーザーID
        self.image_file_path = config.get('image_file_path')  # YOLOの検出画像ディレクトリ
        self.api_endpoint = 'https://api.line.me/v2/bot/message/push'

    def send_message(self, message):
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        data = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(self.api_endpoint, headers=headers, json=data)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

    def send_image(self, image_path, message="検出された画像を送信します"):
        headers = {
            'Authorization': f'Bearer {self.channel_access_token}',
            'Content-Type': 'application/json'
        }

        payload = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                },
                {
                    "type": "image",
                    "originalContentUrl": f"https://your-server.com/images/{os.path.basename(image_path)}",
                    "previewImageUrl": f"https://your-server.com/images/{os.path.basename(image_path)}"
                }
            ]
        }

        response = requests.post(self.api_endpoint, headers=headers, json=payload)

        if response.status_code != 200:
            raise Exception(f"Error sending image to LINE bot: {response.status_code}, {response.text}")

    def get_latest_detected_image(self):
        """ `image_file_path` ディレクトリから最新の検出画像を取得 """
        if not os.path.exists(self.image_file_path):
            print("Error: 指定されたディレクトリが存在しません:", self.image_file_path)
            return None

        images = sorted(
            [os.path.join(self.image_file_path, f) for f in os.listdir(self.image_file_path)
             if f.lower().endswith(('.png', '.jpg', '.jpeg'))],
            key=os.path.getmtime, reverse=True
        )

        return images[0] if images else None

なお
https://kotovuki.co.jp/archives/17715
によれば
1つの画像の最大ファイルサイズは 10MBらしい

1回のメッセージには吹き出しの上限が5つ
テキストの場合は 1つの吹き出しには最大5000文字まで入力可能

とりあえずサイズを縮小する機能が必要

縦横 50% に縮小

new_size = (image.width // 2, image.height // 2)  # 縦横 50% 縮小
image = image.resize(new_size, Image.LANCZOS)

JPEG/PNG を維持して保存

image_format = image.format if image.format else 'JPEG'
image.save(output, format=image_format)

リサイズ後の画像データを返す

return output.getvalue()

画像サイズ リサイズ処理
2MB そのまま送信
4MB リサイズして 50% 縮小
8MB リサイズして 50% 縮小(さらに 3MB 超えていれば再縮小)

✅ 3MB 以上の画像は自動で縮小
✅ JPEG/PNG フォーマットを保持
✅ LINE Bot に適したサイズで送信可能

ということで

import requests
import json
import os
from io import BytesIO
from PIL import Image

class LineBotSender:
    def __init__(self, config_path):
        """設定ファイルからLINE Botの情報を読み込む"""
        with open(config_path, 'r') as file:
            config = json.load(file)
        self.channel_access_token = config.get('line_bot_channel_access_token')
        self.channel_secret = config.get('channel_secret')
        self.user_id = config.get('line_bot_user_id')  # ユーザーID
        self.image_file_path = config.get('image_file_path')  # YOLOの検出画像ディレクトリ
        self.api_endpoint = 'https://api.line.me/v2/bot/message/push'

    def send_message(self, message):
        """テキストメッセージを送信"""
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {self.channel_access_token}'
        }
        data = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(self.api_endpoint, headers=headers, json=data)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

    def send_image(self, image_path, message="検出された画像を送信します"):
        """画像を送信(3MB超えた場合はリサイズ)"""
        if not os.path.exists(image_path):
            print(f"Error: 画像が見つかりません: {image_path}")
            return
        
        headers = {
            'Authorization': f'Bearer {self.channel_access_token}',
            'Content-Type': 'application/json'
        }

        with open(image_path, 'rb') as img_file:
            image_data = img_file.read()
            image_data = self.resize_image_if_needed(image_data)  # 3MB超えたらリサイズ

            # LINE Bot の API 用データを作成
            payload = {
                "to": self.user_id,
                "messages": [
                    {
                        "type": "text",
                        "text": message
                    },
                    {
                        "type": "image",
                        "originalContentUrl": f"https://your-server.com/images/{os.path.basename(image_path)}",
                        "previewImageUrl": f"https://your-server.com/images/{os.path.basename(image_path)}"
                    }
                ]
            }

            response = requests.post(self.api_endpoint, headers=headers, json=payload)

            if response.status_code != 200:
                raise Exception(f"Error sending image to LINE bot: {response.status_code}, {response.text}")

    def resize_image_if_needed(self, image_data, max_size=3 * 1024 * 1024):
        """画像が max_size (3MB) を超える場合はリサイズ"""
        while len(image_data) > max_size:
            image = Image.open(BytesIO(image_data))
            new_size = (image.width // 2, image.height // 2)  # 縦横 50% 縮小
            image = image.resize(new_size, Image.LANCZOS)

            output = BytesIO()
            image_format = image.format if image.format else 'JPEG'
            image.save(output, format=image_format)
            image_data = output.getvalue()

        return image_data  # 3MB 以下になった画像を返す

    def get_latest_detected_image(self):
        """ `image_file_path` ディレクトリから最新の検出画像を取得 """
        if not os.path.exists(self.image_file_path):
            print("Error: 指定されたディレクトリが存在しません:", self.image_file_path)
            return None

        images = sorted(
            [os.path.join(self.image_file_path, f) for f in os.listdir(self.image_file_path)
             if f.lower().endswith(('.png', '.jpg', '.jpeg'))],
            key=os.path.getmtime, reverse=True
        )

        return images[0] if images else None


if __name__ == "__main__":
    sender = LineBotSender("config.json")  # 設定ファイルのパスを指定
    sender.send_message("こんにちは!")  # LINE Bot で送信

    latest_image = sender.get_latest_detected_image()
    if latest_image:
        sender.send_image(latest_image, "最新の検出画像を送信します")

これだけで実行しても画像がつかない

とりあえず
GAS の Webhook エンドポイントを利用し、ローカルの画像を LINE に送信 できるようにする

LINE botにメッセージを送信するためのモジュールを作る

linebot の動作確認できたので
過去に通知に line notify を使っていたものを調べて
Linebot に変える

リポジトリは
https://github.com/Snowpooll
にあるので

https://github.com/Snowpooll/store_adversting_lists
gmailで件名を指定し、未読の最新のメールを取得後にURLを抽出、
抽出したURLを元にブラウザを開き画像をダウンロード
ダウンロード画像へcloud vision api を実行し
購入リストにマッチしたものを LINE norifyで買い物リストが送信

https://github.com/Snowpooll/inventory_count
yoov8と学習したモデルで 指定した画像から物体検出をして在庫数が1以下になったときに
LINE norifyで買い物リストが送信
検出時の結果はsqlite3データベースに保存

とりあえずこの2つの機能を変えることにする

以前

LINE botにメッセージを送信するためのモジュールを作る

touch line_bot_sender.py

中身は

# line_bot_sender.py
import requests
import json

class LineBotSender:
    def __init__(self, config_file_path):
        self.config = self._load_config(config_file_path)
        self.channel_access_token = self.config["line_bot_channel_access_token"]
        self.user_id = self.config["line_bot_user_id"]

    def _load_config(self, config_file_path):
        with open(config_file_path, 'r') as file:
            return json.load(file)

    def send_message(self, message):
        url = "https://api.line.me/v2/bot/message/push"
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.channel_access_token}"
        }
        payload = {
            "to": self.user_id,
            "messages": [
                {
                    "type": "text",
                    "text": message
                }
            ]
        }
        response = requests.post(url, headers=headers, json=payload)
        if response.status_code != 200:
            raise Exception(f"Error sending message to LINE bot: {response.status_code}, {response.text}")

として保存

config.json にLINE botの情報を追加

{
    "line_notify_token": "YOUR_LINE_NOTIFY_TOKEN",
    "ollama_model": "elyza:jp8b",
    "line_bot_channel_access_token": "YOUR_LINE_BOT_CHANNEL_ACCESS_TOKEN",
    "line_bot_user_id": "TARGET_USER_ID"
}

Main.pyでは linenotifyで送信
Main2.pyで lintbot へ送信
Main3.pyで firestoeeへメッセージ保存
Main4.pyで指定のユーザメッセージを指定できるようにしている

今回GASを使うので
使用するのはlinebot のみなので
Main2.pyの機能になる

また

from line_bot_sender import LineBotSender  # LINE bot送信用のモジュールをインポート

となっているので
line_bot_sender.pyを使えばいいのでコピー

設定ファイルとして
Config.jsonを読み込んでいるので
これも使うことにする

まずGAS関連をフォルダにまとめて移動しておく

mkdir gas
mv *.gs gas 

そして linebot 関連をコピー

cp ../linebot/config.json .
 cp ../linebot/line_bot_sender.py .

次にGASへの送信テスト

Channel ID

Channel secret

チャネルアクセストークン

これを設定ファイルに書き込む

なおユーザは指定しないので省いたらバグったので必要

過去ログを調べたら
確認方法は
https://developers.line.biz/ja/docs/messaging-api/getting-user-ids/#get-own-user-id
によると
開発者は自分自身のユーザーIDをLINE Developersコンソールのチャネルの[チャネル基本設定]タブにある[あなたのユーザーID]で確認

開発者が自分自身のユーザーIDを取得するためのAPIはありません
とのこと

これでわかったので

Config.jsonに記述する

これで動作したので
次に
inventoryの仕組みを notify から bot へ変更する

count_inventory.py

line_notifyの部分を書き換えると動作するはず
コードは gpt にあるので
これを試すことにする

config.jsonの項目にbot 関連を追加して
メソッドを変更すればできるはず

LinebotとGASの設定

【初めてでも10分で作れる】GASで作るお問い合わせLINEbot【GAS×LINE Messaging API】

Gemini APIとGASを活用したLINEBOT開発レシピ
を参考に
スマホから送信するLINEbot を作成できるようにする

現状だとスマホからの送信のみ対応できていない

これを使うには
Google Apps Scriptの料金を調べる
調べた結果
料金は無料だけど制約がある

とりあえず LINE bot の設定をする
https://developers.line.biz/ja/docs/messaging-api/getting-started/
へアクセスし
コンソールをクリックし
LINEアカウントでログイン

ログインはQRコードからできるので
LINEアプリを起動しQRを読み込み
そこに表示された数字を入力すればログインできる

ログインしたら新規プロパイダーを作成

これは他と被らないものにすればOK

チャネル設定をmessaging API にして
作成しようとしたら
公式アカウントを作成しないとだめになっている

とりあえず以前作成したことはあるので
新しい公式アカウントを作成する

次にチャネル作成

これは設定が変わっていて
公式アカウントリストの中から
今回作成した公式あかうんとをクリックし
設定をクリックすると
Messaging API の項目が出てくる

これで
Messaging API を利用する
をクリックし
プロバイダーを選択する

WebhookのURLは後で設定できるのでスキップしてもOK

Channel ID

Channel secret
をコピーしておく

次に
応答設定をクリックし
応答メッセージをOFFにする

次に
LINE Developers
https://developers.line.biz/ja/
へアクセスし
コンソールをクリック

プロバイダー一覧から
先ほど設定したプロバイダーを選択

チャネル選択しクリック

Messaging API設定
をクリック

チャネルアクセストークン
で発行をクリックする

次に
チャットボットの裏側となるGoogleAppsScriptの設定を実施

Google アカウントでログインした状態でアクセスし
コピーを作成する

コピーができたら、【設定】シートの【セルC2】に、先ほどメモしましたLINEのチャネルアクセストークンを貼り付け

次に
【拡張機能】⇒【Apps Script】を選択すると、AppsScriptの画面が開く

【デプロイ】⇒【新しいデプロイ】を選択し
* 次のユーザーとして実行 自分(〇〇〇〇〇@gmail.com)
* アクセスできるユーザー 全員
を選択し、【デプロイ】を選択

新しいデプロイを初回実行すると、【アクセスの承認】を押下して、その後利用するアカウントを選択します。
すると、【このアプリはGoogleで確認されていません】と注意書きがでますので
左下の詳細を選択します。(ここで【安全なページに戻る】を押下すると元に戻ってしまうので、デプロイからやり直してください。)
詳細が表示されたら、【ファイル名+(安全でないページ)に移動】が表示されますので、そちらを選択します。
Googleアカウントへのアクセスをリクエストしてくるので【許可】を選択

デプロイ ID
ウェブアプリ
URL
が表示されているので
このURLを LINEbot の web hook に設定する

そして
LINE Developers
https://developers.line.biz/ja/

Messaging API設定

Webhookの利用
をONにする

これで
スマホからlinebot で
こんにちわ
と入力すると
自動でメッセージが返ってくる

とりあえず実験は成功

続いて

Gemini APIとGASを活用したLINEBOT開発レシピ
を実践する

* 1分間あたり15リクエスト
* 1分あたり100万トークン
•1日あたり1500回のリクエスト
がgemini の無料枠

コードをそのまま使うので
前回と同じようにコピーする

Channel ID

Channel secret

チャネルアクセストークン
を忘れないようにメモしておく

GASでlinebot

GASでlinebot

Line notify が廃止されるので代替を linebot にする必要がある

var SS = SpreadsheetApp.getActiveSpreadsheet(); //SpreadsheetのURL
var sheet = SS.getSheetByName("問い合わせ内容"); //Spreadsheetのシート名(タブ名)


var Trainingsheet= SS.getSheetByName('応答メッセージ');
var Settingsheet= SS.getSheetByName('設定');
var DBsheet= SS.getSheetByName('チャットボット用DB');
var keywordsheet= SS.getSheetByName('キーワード登録済み単語');


const CHANNEL_ACCESS_TOKEN = Settingsheet.getRange("C2").getValue();

//DBシート
var kensaku_words = DBsheet.getRange("A:A").getValues();
var kaitou_array = DBsheet.getRange("B:B").getValues();
var keyword_array = keywordsheet.getRange("A:A").getValues();

var kensaku_words1 = Array.prototype.concat.apply([], kensaku_words);
var kaitou_array1 = Array.prototype.concat.apply([], kaitou_array);
var keyword_array1 = Array.prototype.concat.apply([], keyword_array);




function doPost(request) {



 //POSTリクエストをJSONデータにパース
 const receiveJSON = JSON.parse(request.postData.contents);
 const event = receiveJSON.events[0];

var replytext =""


//検索ワードDBの何個目に送信されたメッセージが該当するか検索(ない場合は【-1】が帰ってきます。)
  j = kensaku_words1.indexOf(event.message.text) 


  if (j != -1.0){ // messageに、DBの文字列が含まれるか検索、含まれていたら
    replytext =  kaitou_array1[j];
    replyToUser(event.replyToken, replytext)  

    }else if(keyword_array1.indexOf(event.message.text)!= -1.0 ){
    replytext = "登録済応答メッセージ回答"
    }else{
    replytext =  keywordsheet.getRange(2,5).getValue();
    replyToUser(event.replyToken, replytext)  
    }

//送られてきたデータをlogシートに記載
sheet.appendRow([getCurrentTime(),event.message.text,replytext]);


}

function getCurrentTime() {
  //日付の宣言
  return Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

の問題点として
問題点と改善点
1. CHANNEL_ACCESS_TOKEN を直接 Settingsheet から取得している
* 問題点:
* CHANNEL_ACCESS_TOKEN をスプレッドシートから取得するのは安全ではない(シートが漏洩した場合、外部から悪用される可能性)。
* 改善策:
* Google Apps Script のスクリプトプロパティ(PropertiesService)を使用する。
* Settingsheet.getRange(“C2”).getValue(); を PropertiesService.getScriptProperties().getProperty(“CHANNEL_ACCESS_TOKEN”) に変更。
2. kensaku_words1, kaitou_array1, keyword_array1 の取得方法
* 問題点:
* スプレッドシートのデータ全体を毎回読み込むのは非効率(特に行数が増えた場合、実行速度が遅くなる)。
* 改善策:
* 1回の getDataRange().getValues() で配列を取得し、flat() でフラット化する。
3. doPost のエラーハンドリングがない
* 問題点:
* 予期しないデータ(不正なJSON、nullなど)が来たときの処理がない。
* 改善策:
* try-catch を導入し、エラーが発生した場合のログ出力と適切なエラーレスポンスを返す。
4. event.message.text が undefined の場合の処理がない
* 問題点:
* event.message.text が undefined の場合、.indexOf() の実行時にエラーが発生する可能性がある。
* 改善策:
* event.message && event.message.text を確認して処理する。
5. replyToUser() の呼び出しが一部抜けている
* 問題点:
* keyword_array1.indexOf(event.message.text)!= -1.0 の場合、replyToUser() を呼んでいない。
* 改善策:
* replyToUser() を適切に呼び出す。

となっている

これを

var SS = SpreadsheetApp.getActiveSpreadsheet();
var sheet = SS.getSheetByName("問い合わせ内容");
var Trainingsheet = SS.getSheetByName('応答メッセージ');
var Settingsheet = SS.getSheetByName('設定');
var DBsheet = SS.getSheetByName('チャットボット用DB');
var keywordsheet = SS.getSheetByName('キーワード登録済み単語');

// セキュアな方法でアクセストークンを取得
const CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("CHANNEL_ACCESS_TOKEN");

// DBデータを一括取得
var kensaku_words1 = DBsheet.getDataRange().getValues().map(row => row[0]).flat();
var kaitou_array1 = DBsheet.getDataRange().getValues().map(row => row[1]).flat();
var keyword_array1 = keywordsheet.getDataRange().getValues().map(row => row[0]).flat();

function doPost(request) {
  try {
    // POSTデータをパース
    const receiveJSON = JSON.parse(request.postData.contents);

    // イベントがない、または不正な場合は処理しない
    if (!receiveJSON.events || receiveJSON.events.length === 0) {
      return ContentService.createTextOutput("No event").setMimeType(ContentService.MimeType.TEXT);
    }

    const event = receiveJSON.events[0];

    // メッセージがない、またはテキストがない場合は処理しない
    if (!event.message || !event.message.text) {
      return ContentService.createTextOutput("No message text").setMimeType(ContentService.MimeType.TEXT);
    }

    var replytext = "";

    // 検索ワードDBにメッセージがあるかチェック
    var j = kensaku_words1.indexOf(event.message.text);

    if (j !== -1) {
      // 返信メッセージを取得
      replytext = kaitou_array1[j];
    } else if (keyword_array1.includes(event.message.text)) {
      // 登録済みの応答メッセージ
      replytext = "登録済応答メッセージ回答";
    } else {
      // デフォルトメッセージを取得
      replytext = keywordsheet.getRange(2, 5).getValue();
    }

    // LINEに返信
    replyToUser(event.replyToken, replytext);

    // 送信データをスプレッドシートに記録
    sheet.appendRow([getCurrentTime(), event.message.text, replytext]);

    return ContentService.createTextOutput("Success").setMimeType(ContentService.MimeType.TEXT);

  } catch (error) {
    Logger.log("Error in doPost: " + error.toString());
    return ContentService.createTextOutput("Error processing request").setMimeType(ContentService.MimeType.TEXT);
  }
}

// 現在の時間を取得
function getCurrentTime() {
  return Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

// LINEに返信
function replyToUser(replyToken, message) {
  var url = "https://api.line.me/v2/bot/message/reply";
  var payload = {
    "replyToken": replyToken,
    "messages": [{ "type": "text", "text": message }]
  };

  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(payload)
  };

  try {
    UrlFetchApp.fetch(url, options);
  } catch (error) {
    Logger.log("Error in replyToUser: " + error.toString());
  }
}

として保存

改善点まとめ
✅ アクセストークンを PropertiesService に保存し、スプレッドシートの流出リスクを防止。
✅ スプレッドシートのデータ取得を最適化し、getDataRange().getValues() で一括取得。

✅ エラーハンドリング (try-catch) を追加し、不正なリクエストの処理を安全に。

✅ イベントデータの存在確認 (event.message && event.message.text) を追加し、エラー防止。

✅ replyToUser() のエラーハンドリングを強化し、ログ出力を追加。

✅ keyword_array1.indexOf() の処理を includes() に変更し、可読性を向上。

✅ 関数を整理し、再利用しやすく。

Google Apps Script の スクリプトプロパティ (ScriptProperties) を使うと、スプレッドシートに機密情報を保存せずに管理できます。CHANNEL_ACCESS_TOKEN のような LINEのアクセストークン を安全に保存するのに適しています

スクリプトエディタで スクリプトプロパティ を設定するには、以下の手順を実行します。
① スクリプトプロパティにアクセストークンを保存
1. Google Apps Script エディタ を開く
* スプレッドシートの 拡張機能 → Apps Script を開く
2. エディタの左側メニュー で ⚙️プロジェクトの設定 を開く
3. 下の方にある スクリプトプロパティ の + プロパティを追加 をクリック
4. プロパティ名: CHANNEL_ACCESS_TOKEN
5. 値: <あなたのLINEアクセストークン> を入力して 保存 をクリック

ということなので
プロジェクトの設定をクリック
スクリプトプロパティの追加

プロパティ名: CHANNEL_ACCESS_TOKEN
値: <あなたのLINEアクセストークン> を入力して 保存

アクセストークンの値を一度削除して

var SS = SpreadsheetApp.getActiveSpreadsheet();
var sheet = SS.getSheetByName("問い合わせ内容");
var Trainingsheet = SS.getSheetByName('応答メッセージ');
var Settingsheet = SS.getSheetByName('設定');
var DBsheet = SS.getSheetByName('チャットボット用DB');
var keywordsheet = SS.getSheetByName('キーワード登録済み単語');

// セキュアな方法でアクセストークンを取得
const CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("CHANNEL_ACCESS_TOKEN");

// DBデータを一括取得
var kensaku_words1 = DBsheet.getDataRange().getValues().map(row => row[0]).flat();
var kaitou_array1 = DBsheet.getDataRange().getValues().map(row => row[1]).flat();
var keyword_array1 = keywordsheet.getDataRange().getValues().map(row => row[0]).flat();

function doPost(request) {
  try {
    // POSTデータをパース
    const receiveJSON = JSON.parse(request.postData.contents);

    // イベントがない、または不正な場合は処理しない
    if (!receiveJSON.events || receiveJSON.events.length === 0) {
      return ContentService.createTextOutput("No event").setMimeType(ContentService.MimeType.TEXT);
    }

    const event = receiveJSON.events[0];

    // メッセージがない、またはテキストがない場合は処理しない
    if (!event.message || !event.message.text) {
      return ContentService.createTextOutput("No message text").setMimeType(ContentService.MimeType.TEXT);
    }

    var replytext = "";

    // 検索ワードDBにメッセージがあるかチェック
    var j = kensaku_words1.indexOf(event.message.text);

    if (j !== -1) {
      // 返信メッセージを取得
      replytext = kaitou_array1[j];
    } else if (keyword_array1.includes(event.message.text)) {
      // 登録済みの応答メッセージ
      replytext = "登録済応答メッセージ回答";
    } else {
      // デフォルトメッセージを取得
      replytext = keywordsheet.getRange(2, 5).getValue();
    }

    // LINEに返信
    replyToUser(event.replyToken, replytext);

    // 送信データをスプレッドシートに記録
    sheet.appendRow([getCurrentTime(), event.message.text, replytext]);

    return ContentService.createTextOutput("Success").setMimeType(ContentService.MimeType.TEXT);

  } catch (error) {
    Logger.log("Error in doPost: " + error.toString());
    return ContentService.createTextOutput("Error processing request").setMimeType(ContentService.MimeType.TEXT);
  }
}

// 現在の時間を取得
function getCurrentTime() {
  return Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

// LINEに返信
function replyToUser(replyToken, message) {
  var url = "https://api.line.me/v2/bot/message/reply";
  var payload = {
    "replyToken": replyToken,
    "messages": [{ "type": "text", "text": message }]
  };

  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(payload)
  };

  try {
    UrlFetchApp.fetch(url, options);
  } catch (error) {
    Logger.log("Error in replyToUser: " + error.toString());
  }
}

とコード変更

比べたらやはりURLが変わっている

この変更後のURLをlinebot の web hook に再度設定すれば動作する

次はDB関連の変更

function training() {
  
  var lastrow = Trainingsheet.getLastRow();
  var lastcolumn = Trainingsheet.getLastColumn();
  
  //Trainingシートのタイトル行除く全てのデータを取得
  // var Alldata  = Trainingsheet.getRange(2,2,lastrow-1,lastcolumn-1).getValues();
  var Alldata  = Trainingsheet.getRange(2,1,lastrow,lastcolumn).getValues();
  
  var DB_lastrow = DBsheet.getLastRow();
  var DB_lastcolumn = DBsheet.getLastColumn();
  
  //DBシートのデータ消去
  if(DB_lastrow!=2){
    DBsheet.getRange(3,1,DB_lastrow-2,DB_lastcolumn).clear();
  }
  
  var count = 0;
  
  for(var i = 0; i < Alldata.length; i++) {
    
    for(var j = 0; j < Alldata[i].length-1; j++){
      
      if(Alldata[i][j+1] != ""){
        DBsheet.getRange(3+count,1).setValue(Alldata[i][j+1]);//応答メッセージ
        
        DBsheet.getRange(3+count,2).setValue(Alldata[i][0]);//キーワード
        count++
          
      }
    }
  }
  
}

のコードレビュー

この training() 関数は、Trainingsheet のデータを DBsheet に転記する処理を行っていますが、以下の点で改善

### **コードレビュー & 改善点**

この `training()` 関数は、`Trainingsheet` のデータを `DBsheet` に転記する処理を行っていますが、以下の点で改善できます。

## **🔹 改善点と修正方法**
### **1. `DBsheet` のデータ消去処理のバグ**
#### **🔹 問題点**

if(DB_lastrow!=2){
  DBsheet.getRange(3,1,DB_lastrow-2,DB_lastcolumn).clear();
}

– **`DB_lastrow != 2` のチェックが不適切**:
– `DBsheet` が空の場合 (`lastRow == 0` など)、`DBsheet.getRange(3,1,DB_lastrow-2,DB_lastcolumn)` の範囲指定でエラーが発生する可能性がある。
– `lastRow < 3` の場合 (`DBsheet` に2行未満のデータしかない場合)、`DBsheet.getRange(3,1,DB_lastrow-2,DB_lastcolumn)` の範囲が **負の値になりエラー** になる。 #### **✅ 改善策** [shell] if(DB_lastrow > 2){ DBsheet.getRange(3, 1, DB_lastrow - 2, DB_lastcolumn).clear(); } [/shell] - **`DB_lastrow > 2` の場合のみクリアする** ことで **エラー防止**。

### **2. `getValues()` を `getValues()` + `setValues()` に最適化**
#### **🔹 問題点**

DBsheet.getRange(3+count,1).setValue(Alldata[i][j+1]);//応答メッセージ
DBsheet.getRange(3+count,2).setValue(Alldata[i][0]);//キーワード

– **`setValue()` をループ内で何回も呼んでいる**(パフォーマンスが悪い)。
– **Google Apps Script は I/O 操作が遅い** ため、`getValues()` と `setValues()` を使って **一括処理** する方が高速。

#### **✅ 改善策**
– **データを `配列` に格納して、最後に `setValues()` で一括書き込み** する。

### **3. `Alldata[i][j+1]` の条件チェックを厳密に**
#### **🔹 問題点**

if(Alldata[i][j+1] != ""){

– `Alldata[i][j+1]` が `undefined` の場合 **比較が正しく機能しない可能性がある**。
– `null` や `空白スペース (” “)` のみのデータも無視した方がよい。

#### **✅ 改善策**

if (Alldata[i][j+1] && Alldata[i][j+1].trim() !== "") {

– `&& Alldata[i][j+1].trim() !== “”` を追加して、 **空白のみの値も無視** する。

## **🔹 改善後のコード**

function training() {
  var lastrow = Trainingsheet.getLastRow();
  var lastcolumn = Trainingsheet.getLastColumn();
  
  // Trainingシートのタイトル行を除く全データ取得
  var Alldata = Trainingsheet.getRange(2, 1, lastrow - 1, lastcolumn).getValues();
  
  var DB_lastrow = DBsheet.getLastRow();
  var DB_lastcolumn = DBsheet.getLastColumn();

  // DBシートのデータをクリア(タイトル行以降を削除)
  if (DB_lastrow > 2) {
    DBsheet.getRange(3, 1, DB_lastrow - 2, DB_lastcolumn).clear();
  }
  
  var dataToWrite = [];
  
  for (var i = 0; i < Alldata.length; i++) {
    for (var j = 0; j < Alldata[i].length - 1; j++) {
      if (Alldata[i][j+1] && Alldata[i][j+1].trim() !== "") {
        // データを配列に追加
        dataToWrite.push([Alldata[i][j+1], Alldata[i][0]]);
      }
    }
  }
  
  // まとめてDBシートに書き込み
  if (dataToWrite.length > 0) {
    DBsheet.getRange(3, 1, dataToWrite.length, 2).setValues(dataToWrite);
  }
}

## **🔹 改善点まとめ**
✅ **エラー防止:** `DB_lastrow > 2` のチェックを修正し、**負の範囲エラーを回避**
✅ **高速化:** `setValue()` の代わりに **配列にデータを溜めて `setValues()` で一括書き込み**
✅ **データチェックの厳密化:** `Alldata[i][j+1] && Alldata[i][j+1].trim() !== “”` で **空白や `undefined` を無視**
✅ **リファクタリング:** 変数名やロジックを **シンプルかつ分かりやすく** 変更

この修正により、処理速度と信頼性が向上し、エラーのリスクを減らせます!🚀

これを実行し再度デプロイ
URLが変更されるのでwebhookへ再度設定する

次に
replytouser.gs のコードレビュー

/**
 * 該当ユーザーへの
 * @param {*} replyToken 
 * @param {*} text 
 */
function replyToUser(replyToken, text) {

    const replyText = {
        "replyToken": replyToken,
        "messages": [{
            "type": "text",
            "text": text,
        }]
    }

    const options = {
        "method": "post",
        "headers":
        {
            "Content-Type": "application/json",
            "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN,
        },
        "payload": JSON.stringify(replyText)
    };

    // Line該当ユーザーに応答を返している
    UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", options);
}

この replyToUser() 関数は、LINE Bot を使って特定のユーザーに応答メッセージを送る処理を行っています。
基本的な構造は問題ありませんが、以下の改善点があり
### **コードレビュー & 改善点**

この `replyToUser()` 関数は、LINE Bot を使って特定のユーザーに応答メッセージを送る処理を行っています。
基本的な構造は問題ありませんが、以下の改善点があります。

## **🔹 改善点と修正方法**

### **1. `CHANNEL_ACCESS_TOKEN` の存在チェック**
#### **🔹 問題点**

"Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN,

– **`CHANNEL_ACCESS_TOKEN` が `null` または `undefined` の場合、エラーになる可能性がある**。
– もし `CHANNEL_ACCESS_TOKEN` の取得に失敗した場合、LINE API へのリクエストが **無駄に送信されてしまう**。

#### **✅ 改善策**
– `CHANNEL_ACCESS_TOKEN` の **存在を事前にチェック** し、エラーが発生する前に処理を停止する。

if (!CHANNEL_ACCESS_TOKEN) {
    throw new Error("CHANNEL_ACCESS_TOKEN が設定されていません");
}

### **2. `UrlFetchApp.fetch()` のエラーハンドリング**
#### **🔹 問題点**
– `UrlFetchApp.fetch()` で **LINE API へのリクエストが失敗してもエラーログが記録されない**。
– 例えば、ネットワークエラーや無効な `replyToken` の場合、原因が分からない。

#### **✅ 改善策**
– `try-catch` を使用してエラー発生時に `Logger.log()` でエラーメッセージを記録する。

try {
    const response = UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", options);
    Logger.log("LINE API Response: " + response.getContentText());
} catch (error) {
    Logger.log("Error in replyToUser: " + error.toString());
}

### **3. LINE API のレスポンスをチェック**
#### **🔹 問題点**
– `UrlFetchApp.fetch()` のレスポンスを **無視している**。
– **エラーメッセージやステータスコードを確認しないと、何が問題か分からない**。

#### **✅ 改善策**
– `response.getResponseCode()` を使って **200(成功)以外の場合にログを記録する**。

const response = UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", options);
const responseCode = response.getResponseCode();

if (responseCode !== 200) {
    Logger.log("LINE API Error: " + response.getContentText());
}

### **4. コメントの改善**
#### **🔹 問題点**
– 関数の説明コメントが **途中で途切れている**。
– `@param` の説明が不十分(`replyToken` の説明がない)。
– **関数が何をするのか明確に記述すべき**。

#### **✅ 改善策**
– **関数の説明を具体化** し、`@param` も明確に。

/**
 * LINE Bot で特定のユーザーにメッセージを送信する関数
 *
 * @param {string} replyToken - LINEから受け取った返信用トークン
 * @param {string} text - 送信するメッセージ内容
 */

## **🔹 改善後のコード**

/**
 * LINE Bot で特定のユーザーにメッセージを送信する関数
 *
 * @param {string} replyToken - LINEから受け取った返信用トークン
 * @param {string} text - 送信するメッセージ内容
 */
function replyToUser(replyToken, text) {
    // CHANNEL_ACCESS_TOKEN が設定されているか確認
    if (!CHANNEL_ACCESS_TOKEN) {
        throw new Error("CHANNEL_ACCESS_TOKEN が設定されていません");
    }

    const replyText = {
        "replyToken": replyToken,
        "messages": [{
            "type": "text",
            "text": text,
        }]
    };

    const options = {
        "method": "post",
        "headers": {
            "Content-Type": "application/json",
            "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN,
        },
        "payload": JSON.stringify(replyText)
    };

    try {
        // LINE API へリクエストを送信
        const response = UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply", options);
        const responseCode = response.getResponseCode();

        // レスポンスをログに記録
        if (responseCode === 200) {
            Logger.log("LINE API Response: " + response.getContentText());
        } else {
            Logger.log("LINE API Error: " + response.getContentText());
        }
    } catch (error) {
        // エラーハンドリング
        Logger.log("Error in replyToUser: " + error.toString());
    }
}

## **🔹 改善点まとめ**
✅ **`CHANNEL_ACCESS_TOKEN` が `null` の場合にエラーをスロー**(事前チェック)
✅ **`try-catch` を追加し、`UrlFetchApp.fetch()` のエラーハンドリングを強化**
✅ **`response.getResponseCode()` をチェックし、API のレスポンスを記録**
✅ **コメント (`@param` の説明) を改善し、関数の目的を明確化**

この修正により、**エラーハンドリングが強化され、問題が発生したときにすぐ原因を特定できるようになります!** 🚀

再度デプロイして
webhookのURL更新

これでlinebot の動作確認できたので
過去に通知に line notify を使っていたものを調べて
Linebot に変える

リポジトリは
https://github.com/Snowpooll
にあるので

https://github.com/Snowpooll/store_adversting_lists
gmailで件名を指定し、未読の最新のメールを取得後にURLを抽出、
抽出したURLを元にブラウザを開き画像をダウンロード
ダウンロード画像へcloud vision api を実行し
購入リストにマッチしたものを LINE norifyで買い物リストが送信

https://github.com/Snowpooll/inventory_count
yoov8と学習したモデルで 指定した画像から物体検出をして在庫数が1以下になったときに
LINE norifyで買い物リストが送信
検出時の結果はsqlite3データベースに保存

とりあえずこの2つの機能を変えることにする

Ollama のモデル削除

Ollama のモデル削除

ollama list

でインストール済みモデルを調べる

NAME                               	ID          	SIZE  	MODIFIED     
deepseek-r1:8b-llama-distill-q4_K_M	28f8fd6cdc67	4.9 GB	47 hours ago	
llava:latest                       	8dd30f6b0cb1	4.7 GB	6 months ago	
elyza:jp8b                         	4b08bac08c49	4.9 GB	6 months ago	
llama3:latest                      	365c0bd3c000	4.7 GB	6 months ago	

elyza:jp8b
以外は不要なので削除する

 ollama rm llava:latest 
ollama rm deepseek-r1:8b-llama-distill-q4_K_M
ollama rm llama3:latest 

これで再度

ollama run hf.co/mmnga/cyberagent-DeepSeek-R1-Distill-Qwen-14B-Japanese-gguf

を実行したけど

pulling manifest 
pulling daf92c0e1436... 100% ▕████████████████▏ 9.0 GB                         
pulling 8aa4c0321ccd...   0% ▕                ▏    0 B/ 493 B                  
Error: unexpected status code 200

となる

 ollama --version

だと

ollama version is 0.3.6

2024年8月の時点のものなので最新のものにする

Apple Silicon(M3) Macで Ollama を動かしてみる
を参考に

https://ollama.com/download
からダウンロードして
Ollama-darwin.zip
をダブルクリックで解凍

Ollama.app
ができているので
これをダブルクリックで進めればOK

再度

ollama --version        

で最新のものになるのを確認

ollama version is 0.5.11

https://huggingface.co/cyberagent/DeepSeek-R1-Distill-Qwen-14B-Japanese
を参考にモデル名を
cyberagent/DeepSeek-R1-Distill-Qwen-14B-Japanese

しかし

ollama list
NAME                                                                        ID              SIZE      MODIFIED       
hf.co/mmnga/cyberagent-DeepSeek-R1-Distill-Qwen-14B-Japanese-gguf:latest    26e5cc1b03cd    9.0 GB    13 minutes ago    
elyza:jp8b                                                                  4b08bac08c49    4.9 GB    6 months ago     

となる

このためモデル名を
hf.co/mmnga/cyberagent-DeepSeek-R1-Distill-Qwen-14B-Japanese-gguf:latest
として実行したが

INFO     [browser_use] BrowserUse logging setup complete with level info
INFO     [root] Anonymized telemetry enabled. See https://github.com/browser-use/browser-use for more information.
INFO     [agent] 🚀 Starting task: 
        1. https://cookpad.com/jp にアクセス
        
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 1/3 times:
 
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 2/3 times:
 
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 3/3 times:
 
ERROR    [agent] ❌ Stopping due to 3 consecutive failures
INFO     [agent] Created GIF at agent_history.gif
AgentHistoryList(all_results=[ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True), ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True), ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True)], all_model_outputs=[])

もしかしたら、蒸留してしまうと使えないのかもしれない

とりあえず、モデル名の指定がダメかも

import os

from langchain_ollama import OllamaLLM
from browser_use import Agent
from browser_use.browser.browser import Browser, BrowserConfig
from browser_use.controller.service import Controller

import asyncio

controller = Controller()
agent = None

@controller.registry.action('スクリーンショットを保存する')
async def save_screenshot(filename: str = "screenshot.png") -> str:
    page = await agent.browser_context.get_current_page()
    await page.screenshot(path=filename)
    return f"スクリーンショットを {filename} として保存しました"
    
async def main():
    global agent
    llm = OllamaLLM(model="hf.co/mmnga/cyberagent-DeepSeek-R1-Distill-Qwen-14B-Japanese-gguf:latest")
    agent = Agent(
        task="""
        1. https://cookpad.com/jp にアクセス
        """,
        llm=llm,
        controller=controller,
        browser=Browser(config=BrowserConfig(
            disable_security=True, 
            headless=False,
        )),
    )
    result = await agent.run()
    print(result)

if __name__ == "__main__":
    asyncio.run(main())

だったので
Latest を抜いて再度実行する
しかし変わらない

ollama rm hf.co/mmnga/cyberagent-DeepSeek-R1-Distill-Qwen-14B-Japanese-gguf:latest

で一度削除

deepseekr1 browser-use ollama

で検索

Browser-use web-uiでollamaを使ってDeepseek-r1を使用する
を参考に

ollama run deepseek-r1:14b

しかし

import os

from langchain_ollama import OllamaLLM
from browser_use import Agent
from browser_use.browser.browser import Browser, BrowserConfig
from browser_use.controller.service import Controller

import asyncio

controller = Controller()
agent = None

@controller.registry.action('スクリーンショットを保存する')
async def save_screenshot(filename: str = "screenshot.png") -> str:
    page = await agent.browser_context.get_current_page()
    await page.screenshot(path=filename)
    return f"スクリーンショットを {filename} として保存しました"
    
async def main():
    global agent
    llm = OllamaLLM(model="deepseek-r1:14b")
    agent = Agent(
        task="""
        Navigate to 'https://en.wikipedia.org/wiki/Internet' and scroll to the string 'The vast majority of computer'
        """,
        llm=llm,
        controller=controller,
        browser=Browser(config=BrowserConfig(
            disable_security=True, 
            headless=False,
        )),
    )
    result = await agent.run()
    print(result)

if __name__ == "__main__":
    asyncio.run(main())

としても結果は変わらない

もしかしたらコードが違うのかもしれない

ollama browser-use

で検索

browser-use + ollamaでローカルサイト(Zabbix)を操作してみた
を参考に

Test.pyとして動かすが失敗

どうやらモデルが原因みたい
取り合えず動く事例が出るまでまつ

Browser-useでollamaを使ってローカルLLMを使用する
https://zenn.dev/tossy21/articles/5b0ff1452af70a#googleへアクセスし、%22openai%22で検索した最初のurlを取得
では実験したすべてのモデルが失敗に終わってるし

Browser-use. deepseekR1 + Ollama (エラーとなるため失敗)

Browser-use. deepseekR1 + Ollama (エラーとなるため失敗)

browser-useをGeminiで動かすまで(langchain-google-genai 2.0.8がPyPIにリリースされるまでは暫定的にリポジトリからインストール)
が気になったけど
これはgemini を動かす時に必要なリポジトリについて
なので
Deepseek には関係ない

DeepSeek R1をMac Book Airで動かしてみた:R1の6つの「蒸留」モデルの使い方
では
実際に、「DeepSeek-R1-Distill-Llama-8B」を筆者のMac Book Airで動かしてみたところ、普通に使えるスピードで動作したので、夢が広がる。
ちなみに、iPhone / Android でもDeepSeek R1を動かすことができる

とのこと

DeepSeek-R1ローカル環境とWebUIのお手軽実装 & モデル選択のススメ
とかもあるけど
スペックが
* Windows11 WSL2(2.3.26.0)
* Ubuntu(22.04.3 LTS)
* Docker Desktop(27.3.1)
* PCスペック:RTX 4070 VRAM 12GB / RAM 32GB

ログインを伴う作業を行いたいため deepseekR1 をローカルで動かすため
M1 MacBook Air 16GBで動作可能なモデルを調べてみる

GPTによれば

蒸留モデルだと
M1 MacBook Air (16GB) で動作可能な DeepSeek R1 の蒸留モデルをまとめます。
推奨モデル
1. DeepSeek R1 1.5B 蒸留モデル
* モデル名: deepseek-r1:1.5b-qwen-distill-q4_K_M
* パラメータ数: 1.5B(15億)
* サイズ: 約1.1 GB
* 推奨メモリ: 8GB 以上
* 特徴: 最小の蒸留モデルで、軽量かつ高速に動作します。基本的なタスクや軽量な処理に適しています。
2. DeepSeek R1 7B 蒸留モデル
* モデル名: deepseek-r1:7b-qwen-distill-q4_K_M
* パラメータ数: 7B(70億)
* サイズ: 約4.7 GB
* 推奨メモリ: 16GB
* 特徴: バランスの取れた性能を持ち、一般的なNLPタスクに適しています。M1 MacBook Air でも快適に動作します。
3. DeepSeek R1 8B 蒸留モデル
* モデル名: deepseek-r1:8b-llama-distill-q4_K_M
* パラメータ数: 8B(80億)
* サイズ: 約4.9 GB
* 推奨メモリ: 16GB
* 特徴: やや大きめのモデルですが、M1 MacBook Air でも動作可能です。より高度なタスクや複雑な処理に適しています。

とりあえず browser-use の動作ができれば目的は達成できそうなので
* deepseek-r1:8b-llama-distill-q4_K_M
で実験

deepseek-r1:8b-llama-distill-q4_K_M
で検索

https://www.youtube.com/watch?v=oUBeJkKwBcc の動画を日本語に要約。 セットアップ手順を示して

とGPTで調べたが
実行方法が出たのは蒸留モデルではない方だった
とは言ってもこれで要約して方法を動画から抽出できるのは便利だと思う

【蒸留モデル】DeepSeek-R1ローカル実行時におすすめのモデル
を参考に行う

https://ollama.com/library/deepseek-r1/tags

利用可能なすべてのモデルタグは公式タグ一覧で確認可能

8b-llama-distill-q4_K_M
で検索すると出るので
これを使うことにする

インストールコマンドは

ollama pull deepseek-r1:8b-llama-distill-q4_K_M

これでインストールできるので
Deepseek で browser-use を試す

import os

from langchain_community.llms import Ollama  # Ollamaを使用
from browser_use import Agent
from browser_use.browser.browser import Browser, BrowserConfig
from browser_use.controller.service import Controller

import asyncio

controller = Controller()
agent = None

@controller.registry.action('スクリーンショットを保存する')
async def save_screenshot(filename: str = "screenshot.png") -> str:
    page = await agent.browser_context.get_current_page()
    await page.screenshot(path=filename)
    return f"スクリーンショットを {filename} として保存しました"
    
async def main():
    global agent
    llm = Ollama(model="deepseek-r1:8b-llama-distill-q4_K_M")  # Ollamaのローカルモデルを指定
    agent = Agent(
        task="""
        1. https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc にアクセス
        2. 「フルスクリーン」ボタンをクリック後、数秒待機
        3. スクリーンショットを step-{n}.png として保存
        """,
        llm=llm,
        controller=controller,
        browser=Browser(config=BrowserConfig(
            disable_security=True, 
            headless=False,
        )),
    )
    result = await agent.run()
    print(result)

if __name__ == "__main__":
    asyncio.run(main())

として実行

しかし

Traceback (most recent call last): File "/Users/snowpool/aw10s/deepseekR1/advertisement.py", line 3, in <module> from langchain_community.llms import Ollama # Ollamaを使用 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ModuleNotFoundError: No module named 'langchain_community'

となるので

pip install langchain-community
pip install -U langchain-ollama

の後に
from langchain_ollama import OllamaLLM
llm = OllamaLLM(model="deepseek-r1:8b-llama-distill-q4_K_M")

としたけど

INFO     [browser_use] BrowserUse logging setup complete with level info
INFO     [root] Anonymized telemetry enabled. See https://github.com/browser-use/browser-use for more information.
INFO     [agent] 🚀 Starting task: 
        1. https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc にアクセス
        2. 「フルスクリーン」ボタンをクリック後、数秒待機
        3. スクリーンショットを step-{n}.png として保存
        
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 1/3 times:
 
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 2/3 times:
 
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 3/3 times:
 
ERROR    [agent] ❌ Stopping due to 3 consecutive failures
INFO     [agent] Created GIF at agent_history.gif
AgentHistoryList(all_results=[ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True), ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True), ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True)], all_model_outputs=[])

このため ollamaの動作確認

ollama run deepseek-r1:8b-llama-distill-q4_K_M --prompt "こんにちは。あなたは何ができますか?"

Error: unknown flag: --prompt

となる

–prompt フラグはサポートされていないため、プロンプトは位置引数として渡すので

ollama run deepseek-r1:8b-llama-distill-q4_K_M "こんにちは。あなたは何ができますか?"

とした

<think>
Alright, the user greeted me with "こんにちは。" which is Japanese for 
"Hello." I should respond in a friendly manner.

I need to let them know what I can do. Since I'm an AI assistant, I should 
mention various tasks like answering questions, providing information, and 
helping with problems.

It's important to keep it simple and conversational, avoiding any 
technical jargon.

I'll make sure my response is welcoming and open-ended to encourage them 
to ask more.
</think>

こんにちは!私は人工知能助手です。何ができますか?回答、情報検索、問題解決な
ど、さまざまなタスクを担当します。どのようなお手伝いが必要ですか?

と問題ない

しかしエラーになるので
クックパッドの検索を試す

import os

from langchain_ollama import OllamaLLM
from browser_use import Agent
from browser_use.browser.browser import Browser, BrowserConfig
from browser_use.controller.service import Controller

import asyncio

controller = Controller()
agent = None

@controller.registry.action('スクリーンショットを保存する')
async def save_screenshot(filename: str = "screenshot.png") -> str:
    page = await agent.browser_context.get_current_page()
    await page.screenshot(path=filename)
    return f"スクリーンショットを {filename} として保存しました"
    
async def main():
    global agent
    llm = OllamaLLM(model="deepseek-r1:8b-llama-distill-q4_K_M")
    agent = Agent(
        task="""
        1. https://cookpad.com/jp にアクセスし、ページが完全に読み込まれるまで待機する。
        2. ページ上部にある検索ボックスに「ぶり大根」と入力し、検索ボタンをクリックする。
        3. 検索結果が表示されたら、一番最初のレシピをクリックする。
        4. 各ステップが完了するたびに、スクリーンショットを step-{n}.png として保存する。
        """,
        llm=llm,
        controller=controller,
        browser=Browser(config=BrowserConfig(
            disable_security=True, 
            headless=False,
        )),
    )
    result = await agent.run()
    print(result)

if __name__ == "__main__":
    asyncio.run(main())

しかし

INFO     [browser_use] BrowserUse logging setup complete with level info
INFO     [root] Anonymized telemetry enabled. See https://github.com/browser-use/browser-use for more information.
INFO     [agent] 🚀 Starting task: 
        1. https://cookpad.com/jp にアクセスし、ページが完全に読み込まれるまで待機する。
        2. ページ上部にある検索ボックスに「ぶり大根」と入力し、検索ボタンをクリックする。
        3. 検索結果が表示されたら、一番最初のレシピをクリックする。
        4. 各ステップが完了するたびに、スクリーンショットを step-{n}.png として保存する。
        
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 1/3 times:
 
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 2/3 times:
 
INFO     [agent] 
📍 Step 1
ERROR    [agent] ❌ Result failed 3/3 times:
 
ERROR    [agent] ❌ Stopping due to 3 consecutive failures
INFO     [agent] Created GIF at agent_history.gif
AgentHistoryList(all_results=[ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True), ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True), ActionResult(is_done=False, extracted_content=None, error='', include_in_memory=True)], all_model_outputs=[])

となる

gemini でのスクショの撮影

プロンプトを

https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pcへアクセスし、「日替」と書かれた画像をクリックした後に「プリント」をクリックし
送信先から「PDFに保存」を選択して「保存」をクリックしてください。

だと
プリント画面で固まる
おそらくプリントの部分がbrowserではないため制御外になると思う

なのでフルスクリーンにしてスクショ
が正解っぽい

スクショに関しては
https://zenn.dev/gunjo/articles/2f6898b846d371

https://zenn.dev/kbyk/articles/3e997a2f762018
を参考に進める

以下コード

from langchain_google_genai import ChatGoogleGenerativeAI
from browser_use import Agent
from browser_use.browser.browser import Browser, BrowserConfig
from browser_use.controller.service import Controller

import asyncio

controller = Controller()
agent = None

@controller.registry.action('スクリーンショットを保存する')
async def save_screenshot(filename: str = "screenshot.png") -> str:
    page = await agent.browser_context.get_current_page()
    await page.screenshot(path=filename)
    return f"スクリーンショットを {filename} として保存しました"
    
async def main():
    global agent
    llm = ChatGoogleGenerativeAI(model="gemini-pro")  # Gemini Pro モデルを指定
    agent = Agent(
        task="""
        以下の手順を実行してください:
        1. https://s.shufoo.net/chirashi/860323/?cid=nmail_pc にアクセス
        2. 「日替」という画像をクリック
        3. 「フルスクリーン」ボタンをクリック
        4. 拡大画像が表示されたら、スクリーンショットを step-{n}.png として保存
        """,
        llm=llm,
        controller=controller,
        browser=Browser(config=BrowserConfig(
            disable_security=True, 
            headless=False,
        )),
    )
    result = await agent.run()
    print(result)

if __name__ == "__main__":
    asyncio.run(main())

これで実行すると

INFO [browser_use] BrowserUse logging setup complete with level info INFO [root] Anonymized telemetry enabled. See https://github.com/browser-use/browser-use for more information. INFO [agent] 🚀 Starting task: 以下の手順を実行してください: 1. https://s.shufoo.net/chirashi/860323/?cid=nmail_pc にアクセス 2. 「日替」という画像をクリック 3. 「フルスクリーン」ボタンをクリック 4. 拡大画像が表示されたら、スクリーンショットを step-{n}.png として保存 INFO [agent] 📍 Step 1 WARNING [langchain_google_genai.chat_models] Retrying langchain_google_genai.chat_models._achat_with_retry.<locals>._achat_with_retry in 2.0 seconds as it raised NotFound: 404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.. ERROR [agent] ❌ Result failed 1/3 times: 404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods. INFO [agent] 📍 Step 1 WARNING [langchain_google_genai.chat_models] Retrying langchain_google_genai.chat_models._achat_with_retry.<locals>._achat_with_retry in 2.0 seconds as it raised NotFound: 404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.. ERROR [agent] ❌ Result failed 2/3 times: 404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods. INFO [agent] 📍 Step 1 WARNING [langchain_google_genai.chat_models] Retrying langchain_google_genai.chat_models._achat_with_retry.<locals>._achat_with_retry in 2.0 seconds as it raised NotFound: 404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.. ERROR [agent] ❌ Result failed 3/3 times: 404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods. ERROR [agent] ❌ Stopping due to 3 consecutive failures INFO [agent] Created GIF at agent_history.gif AgentHistoryList(all_results=[ActionResult(is_done=False, extracted_content=None, error='404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.', include_in_memory=True), ActionResult(is_done=False, extracted_content=None, error='404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.', include_in_memory=True), ActionResult(is_done=False, extracted_content=None, error='404 models/gemini-gemini-2.0-flash-exp is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.', include_in_memory=True)], all_model_outputs=[]) となった

エラーメッセージから、指定した gemini-gemini-2.0-flash-exp というモデルが Google Generative AI API v1beta では見つからない という問題が発生

現在、LangChain で GoogleGenerativeAI を使用する際にサポートされているモデルには以下の
gemini-pro
gemini-pro-vision
gemini-1.5-pro
gemini-1.5-flash
らしい

gemini-1.5-flash
で実験したが
意図しない場所でスクショになってしまう

そもそも見ているページが違っている

https://www.shufoo.net/pntweb/shopDetail/860323/45225639804667/
の挙動を見ていたが

指定しているURLが
メールだと
https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc
になっているので

メールからではなく
チラシのURLが動的でないかを調べることにする

なおメールに記載されているURLに関しては固定のままなので
そのまま使えるURLになっている

URLを変更したが、フルスクリーンをクリックした後すぐにスクショだと
フルスクリーン画面になる前に撮影してしまうため
3秒停止とプロンプトに追加したが失敗する

このため

        1. https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc にアクセス
        2. 「フルスクリーン」ボタンをクリック後、数秒待機
        3. スクリーンショットを step-{n}.png として保存
        """,

というようにプロンプトを変更することで成功

画像が小さいため

        以下の手順を実行してください:
        1. https://www.shufoo.net/pntweb/shopDetail/860323/?cid=nmail_pc にアクセス
        2. 「フルスクリーン」ボタンをクリック後、数秒待機
        3. フルスクリーン画面が表示されたら、拡大をクリック後、数秒待機
        4. スクリーンショットを step-{n}.png として保存
        """,

とすると今度は拡大のみになる
ブラウザの取り扱いが別のブラウザ扱いなのかもしれない

冷蔵庫の食材画像から料理を提案するWebアプリをGeminiで作る

冷蔵庫の食材画像から料理を提案するWebアプリをGeminiで作るレシピ

を試す

既にAPIキーは取得済み

pip install google-generativeai

でインストール

モデルを指定してgenerate_content関数にプロンプトを与えることでtext to textの生成結果を取得できる

import google.generativeai as genai

# APIKEY
key = ""

genai.configure(api_key=key)

model = genai.GenerativeModel('gemini-1.5-flash')

# res = model.generate_content("こんにちは、今日の天気は?")
# # print(res)
# print(res.text)

prompt = "日本のラーメンについて語ってください"

response = model.generate_content(prompt)

print(response.text)

で実験

実行結果は

python gemini_text1.py 

の結果

日本のラーメンは、単なる食べ物ではなく、日本の食文化を代表する存在と言えるでしょう。その歴史は長く、中国から伝わった麺料理が、日本の風土や食文化と融合し、多様な地域性と個性豊かな一杯へと進化を遂げてきました。

まず、その**スープ**はラーメンの命と言えるでしょう。豚骨、鶏ガラ、魚介、醤油、味噌など、ベースとなるスープの種類は多岐にわたり、それぞれのスープに合わせた独特の製法と材料の組み合わせが、奥深い味わいを生み出しています。豚骨スープの濃厚なコク、鶏ガラのあっさりとした上品さ、魚介の繊細な旨味など、スープだけでも多様な体験が可能です。

**麺**も重要な要素です。細麺、中太麺、太麺など、スープとのバランスや食感の組み合わせが綿密に考えられています。小麦粉の種類や製法によって、食感や風味も変化し、それぞれのラーメンに最適な麺が選ばれています。ツルツルとした喉越し、コシの強さ、小麦の香りなど、麺だけでも多くのバリエーションがあります。

**具材**もラーメンの魅力を高める重要な要素です。チャーシュー、メンマ、ネギ、海苔、味玉など、定番の具材に加え、地域独特の食材や季節感を取り入れたものなど、多様な組み合わせが可能です。それぞれの具材がスープや麺と調和し、全体的なバランスを考慮した構成となっています。

そして忘れてならないのが、**地域性**です。北海道の味噌ラーメン、東京の醤油ラーメン、九州の豚骨ラーメンなど、地域によってスープの種類や麺、具材、味付けなどが異なり、まさにラーメンは日本の食文化の縮図と言えます。同じ豚骨ラーメンでも、地域によって濃厚さや油の量、風味などが大きく異なるため、食べ比べる楽しみも尽きません。

さらに近年では、**創作ラーメン**と呼ばれる新しいタイプのラーメンも数多く登場しています。伝統的なラーメンをベースに、斬新なアイデアや食材を取り入れた、独創的な一杯が次々と生み出されています。

一口に「日本のラーメン」と言っても、そのバリエーションは無限と言えるほど広大です。それぞれのラーメンに込められた作り手の情熱と、地域ごとの文化や歴史を感じながら、一杯のラーメンを味わうことは、日本の食文化に触れる貴重な体験となるでしょう。  ぜひ、様々なラーメンを味わって、あなたのお気に入りの一杯を見つけてみてください。

WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1738680923.836079 3266497 init.cc:232] grpc_wait_for_shutdown_with_timeout() timed out.

つまり

generate_content()


プロンプトを渡せばOK
変数にして入れても、そのまま直打ちしてもOK

send_message関数を使えばマルチターンのチャットにも対応
既存のチャットの内容を反映した返信を返してくれます。
初期化したい場合はstart_chat(history=[])を実行

例として

chat = model.start_chat(history=[])

response = chat.send_message(
    'しりとりしましょう。りんご')

print(response.text)
#response
#んご、ですね!では、 **ごりら**  はどうでしょう? 

response = chat.send_message(
    'ラッパ')

print(response.text)
#response
#ぱ、ですね!では**パンダ** はどうでしょう?


image to textは以下のようにupload_file関数を使います。
pathに読み込みたい画像のファイルパスを与えます

upload_file = genai.upload_file(path="/image.jpg",
                            display_name="image")

image to textの場合はgenerate_content関数の第一引数に先ほどのupload_file を与えます。第二引数にpromptを与えます。

upload_file = genai.upload_file(path="./image.jpg",
                            display_name="image")

prompt = "日本語で内容を簡潔にまとめてください"

# Prompt the model with text and the previously uploaded image.
response = model.generate_content([upload_file, prompt ])

print(response.text)

とすれば

python gemini_text1.py

を実行すると

白い背景に、オレンジ色の毛並みの小さな猫が写っています。猫はカメラの方を見ていますが、少しうつむき加減です。  かわいらしい表情をしています。

WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1738681425.059689 3274804 init.cc:232] grpc_wait_for_shutdown_with_timeout() timed out.

というように写真の説明が出る

なおプロンプトを要約する
にした場合
MP3などの音声ファイルやPDFでも要約可能

とりあえず色々できるようなので
要約には使えるかもしれない

とりあえずそれは置いておき、冷蔵庫の画像から食材の認識を進める

pip install streamlit pillow google-generativeai

で必要なライブラリをインストール

touch ingredients.py

でファイルを作成

import streamlit as st
from PIL import Image
import io
import google.generativeai as genai

でライブラリインポート

PIL: 画像の操作に使用されるライブラリで、ユーザーがアップロードした画像を扱います。
io: バイトデータを扱うために使用され、画像をバイト形式に変換するために利用します。
google.generativeai: Googleの生成AIを利用して、食材認識とレシピ生成を行うために使用

key = ""

genai.configure(api_key=key)

model = genai.GenerativeModel('gemini-1.5-flash')


geminiの認証

st.title('AI料理研究家')
st.write("冷蔵庫の中身の写真をアップロードすると、レシピを提案します!")

st.title: Webアプリのタイトルとして「AI料理研究家」を表示します。

st.write: 説明文として「冷蔵庫の中身の写真をアップロードすると、レシピを提案します!」を表示

uploaded_image = st.file_uploader("冷蔵庫の中身の写真を選択してください", type=["jpg", "png", "jpeg"])


ユーザーが画像ファイルをアップロードできるインターフェースを作成

def recognize_ingredients(input_file):
    response = model.generate_content([input_file, "日本語で写っている食材を詳細に説明してください"])
    return response.text

recognize_ingredients: 画像から食材を認識するための関数

input_fileとして画像をGoogle Generative AIに送り、「日本語で写っている食材を説明してください」というプロンプトを追加

generate_content: モデルを使用して指定されたプロンプトに基づき、食材を認識します。返されるのは食材のリストやテキストになる

次にレシピ提案メソッドの作成

def suggest_recipes(ingredients):
    prompt = f"以下の食材だけを使った料理とそのレシピをいくつか提案してください: " + ingredients
    
    response = model.generate_content(prompt)

suggest_recipes: 認識された食材に基づいてレシピを生成する関数
ngredientsに基づいてプロンプトを作成し、AIに「これらの食材だけを使った料理とレシピを提案してください」と指示
再度、generate_contentを使用して、レシピを生成

    if response:
        return response.text  
    else:
        st.error("レシピの生成に失敗しました")
        return None

レスポンスが正常に返ってきた場合、その内容を返し
何らかのエラーが発生した場合には、Streamlitを使ってエラーメッセージを表示

if uploaded_image is not None:
    image = Image.open(uploaded_image)
    st.image(image, caption="Uploaded Image", use_column_width=True)

画像がアップロードされた場合、その画像を開き、Streamlitのインターフェースに表示
captionとして「Uploaded Image」を設定し、画像を表示させる領域の幅に合わせてリサイズ

    img_byte_arr = io.BytesIO()
    image.save(img_byte_arr, format='PNG')
    img_byte_arr = img_byte_arr.getvalue()

アップロードされた画像をバイトデータに変換
BytesIO()を使って、画像データをバイト形式に保存し、それをimg_byte_arrに取得

    with open("uploaded_fridge_image.png", "wb") as f:
        f.write(img_byte_arr)
    input_file = genai.upload_file(path=r"<pythonファイルが置かれたフォルダパス>\uploaded_fridge_image.png",
                            display_name="image")

MacOSの場合は以下のよう

input_file = genai.upload_file(path="/Users/test/python/uploaded_fridge_image.png",
                            display_name="image")

変換した画像をwrite関数でローカルに保存します。ファイル名は「uploaded_fridge_image.png」
genai.upload_file: Google Generative AIのために画像をアップロード
このために
パスと表示名を指定

の部分は
Pythonファイルを保存しているフォルダのパスで置き換える

一度手順6のコマンドでStreamlitを実行し(途中でエラーになりますが画像は保存されます)、画像を保存してからその画像をVScodeで右クリックしてパスをコピーすると楽

    with st.spinner('食材を認識中...'):
        ingredients = recognize_ingredients(input_file)

画像から食材を認識している間、st.spinnerで「食材を認識中…」というローディングメッセージを表示

recognize_ingredients関数を呼び出して、食材を認識
    if ingredients:
        st.write("認識された食材:")
        st.write(ingredients)

食材が正常に認識された場合、「認識された食材:」というメッセージを表示し、具体的にどの食材が認識されたかをリスト形式で表示

        with st.spinner('レシピを提案中...'):
            recipes = suggest_recipes(ingredients)

認識された食材に基づいてレシピを生成する際に、「レシピを提案中…」というローディングメッセージを表示します。
suggest_recipes関数を呼び出し、レシピを提案

        if recipes:
            st.write("こちらがいくつかのレシピ提案です:")
            st.write(recipes)
        else:
            st.error("レシピが見つかりませんでした。")

提案されたレシピがあれば、「こちらがいくつかのレシピ提案です:」というメッセージを表示し、生成されたレシピをリストとして表示

レシピが生成されなかった場合にはエラーメッセージを表示

input_file = genai.upload_file(path="/Users/snowpool/aw10s/gemini/uploaded_fridge_image.png",
                            display_name="image")

の部分は出力先なので
この設定にした

で実行

streamlit run ingredients.py 

  You can now view your Streamlit app in your browser.

  Local URL: http://localhost:8501
  Network URL: http://192.168.1.17:8501

  For better performance, install the Watchdog module:

  $ xcode-select --install
  $ pip install watchdog
            
2025-02-08 05:04:21.476 Uncaught app execution
Traceback (most recent call last):
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 88, in exec_func_with_error_handling
    result = func()
             ^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 579, in code_to_exec
    exec(code, module.__dict__)
  File "/Users/snowpool/aw10s/gemini/ingredients.py", line 43, in <module>
    input_file = genai.upload_file(path="/Users/snowpool/aw10s/gemini/uploaded_fridge_image.png",
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/generativeai/files.py", line 85, in upload_file
    response = client.create_file(
               ^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/generativeai/client.py", line 114, in create_file
    media = googleapiclient.http.MediaFileUpload(
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/googleapiclient/_helpers.py", line 130, in positional_wrapper
    return wrapped(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/googleapiclient/http.py", line 594, in __init__
    self._fd = open(self._filename, "rb")
               ^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/Users/snowpool/aw10s/gemini/uploaded_fridge_image.png'
2025-02-08 05:05:16.750 The `use_column_width` parameter has been deprecated and will be removed in a future release. Please utilize the `use_container_width` parameter instead.
2025-02-08 05:05:19.701 Uncaught app execution
Traceback (most recent call last):
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 88, in exec_func_with_error_handling
    result = func()
             ^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 579, in code_to_exec
    exec(code, module.__dict__)
  File "/Users/snowpool/aw10s/gemini/ingredients.py", line 43, in <module>
    input_file = genai.upload_file(path="/Users/snowpool/aw10s/gemini/uploaded_fridge_image.png",
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/generativeai/files.py", line 85, in upload_file
    response = client.create_file(
               ^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/generativeai/client.py", line 114, in create_file
    media = googleapiclient.http.MediaFileUpload(
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/googleapiclient/_helpers.py", line 130, in positional_wrapper
    return wrapped(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/googleapiclient/http.py", line 594, in __init__
    self._fd = open(self._filename, "rb")
               ^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/Users/snowpool/aw10s/gemini/uploaded_fridge_image.png'
2025-02-08 05:05:30.335 The `use_column_width` parameter has been deprecated and will be removed in a future release. Please utilize the `use_container_width` parameter instead.
2025-02-08 05:05:31.820 Uncaught app execution
Traceback (most recent call last):
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 88, in exec_func_with_error_handling
    result = func()
             ^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 579, in code_to_exec
    exec(code, module.__dict__)
  File "/Users/snowpool/aw10s/gemini/ingredients.py", line 43, in <module>
    input_file = genai.upload_file(path="/Users/snowpool/aw10s/gemini/uploaded_fridge_image.png",
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/generativeai/files.py", line 85, in upload_file
    response = client.create_file(
               ^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/generativeai/client.py", line 114, in create_file
    media = googleapiclient.http.MediaFileUpload(
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/googleapiclient/_helpers.py", line 130, in positional_wrapper
    return wrapped(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/googleapiclient/http.py", line 594, in __init__
    self._fd = open(self._filename, "rb")
               ^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/Users/snowpool/aw10s/gemini/uploaded_fridge_image.png'

となった

ファイルが存在しないとなるので

mv ~/Downloads/PXL_20250205_220637199.jpg ./uploaded_fridge_image.png

で撮影した写真を
Downloadsフォルダにある PXL_20250205_220637199.jpg を mv コマンドでカレントディレクトリに uploaded_fridge_image.png にリネームして移動

再度実行したら

streamlit run ingredients.py

  You can now view your Streamlit app in your browser.

  Local URL: http://localhost:8501
  Network URL: http://192.168.1.17:8501

  For better performance, install the Watchdog module:

  $ xcode-select --install
  $ pip install watchdog
            
2025-02-08 05:15:18.391 The `use_column_width` parameter has been deprecated and will be removed in a future release. Please utilize the `use_container_width` parameter instead.
2025-02-08 05:15:30.390 Uncaught app execution
Traceback (most recent call last):
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/exec_code.py", line 88, in exec_func_with_error_handling
    result = func()
             ^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/streamlit/runtime/scriptrunner/script_runner.py", line 579, in code_to_exec
    exec(code, module.__dict__)
  File "/Users/snowpool/aw10s/gemini/ingredients.py", line 47, in <module>
    ingredients = recognize_ingredients(input_file)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/aw10s/gemini/ingredients.py", line 19, in recognize_ingredients
    response = model.generate_content([input_file, "日本語で写っている食材を詳細に説明してください"])
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/generativeai/generative_models.py", line 331, in generate_content
    response = self._client.generate_content(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/ai/generativelanguage_v1beta/services/generative_service/client.py", line 830, in generate_content
    response = rpc(
               ^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/api_core/gapic_v1/method.py", line 131, in __call__
    return wrapped_func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/api_core/retry/retry_unary.py", line 293, in retry_wrapped_func
    return retry_target(
           ^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/api_core/retry/retry_unary.py", line 153, in retry_target
    _retry_error_helper(
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/api_core/retry/retry_base.py", line 212, in _retry_error_helper
    raise final_exc from source_exc
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/api_core/retry/retry_unary.py", line 144, in retry_target
    result = target()
             ^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/api_core/timeout.py", line 120, in func_with_timeout
    return func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/snowpool/.pyenv/versions/3.11.0/lib/python3.11/site-packages/google/api_core/grpc_helpers.py", line 78, in error_remapped_callable
    raise exceptions.from_grpc_error(exc) from exc
google.api_core.exceptions.ResourceExhausted: 429 Resource has been exhausted (e.g. check quota).

となった
今度は

google.api_core.exceptions.ResourceExhausted: 429 Resource has been exhausted (e.g. check quota).

の部分から
Google の API(この場合は Generative AI の API)で利用できるリソースやクォータが使い果たされた、もしくは上限に達しているのが原因

無料プランなので
1分以上経過すればリソースが回復するので
再度実行したらできた

実行結果は

認識された食材:
はい、冷蔵庫の中身にある日本語で書かれた食材を詳細に説明します。
上段:
* 卵: パックに入った鶏卵。個数は不明ですが、少なくとも数個入っています。
* パン粉、または何かしら粉状のもの: 透明なビニール袋に入っています。内容物の特定は難しいです。
* 様々な食品: 複数の袋やパッケージに入った食品が詰め込まれており、個々の商品の特定は困難です。日本語のラベルが見えますが、商品名は判読できません。
* 雪印スノーブランドバター: ピンク色のパッケージのバター。
2段目:
* BIOブランドヨーグルト: 少なくとも2個のヨーグルト。プレーン味と、おそらく加糖味があります。
* その他ヨーグルト: いくつかのヨーグルト容器が見えますが、ブランドや味は不明です。
* その他食品: 複数の小さな容器やパックに入った食品があり、内容物は判別できません。
* 野菜や漬物など: 青色のプラスチック容器に入った野菜、おそらく漬物類が入っています。
3段目:
* 生みそ: 「生みそ」と大きく書かれた容器に入った味噌。中甘口のようです。
* その他食品: いくつかの小さな容器やパックに入った食品が確認できますが、詳細は不明です。
* クリームチーズ: クラフトのクリームチーズと思われる容器が見えます。
* ご飯: 透明なプラスチック容器に入ったご飯が、少なくとも2つあります。
* 調理済みの料理: ラップで覆われたお皿に料理が残っています。内容は不明です。
4段目:
* 赤い鍋: 蓋付きの赤い鍋が、中に何か入っている状態で置かれています。
* バナナ: ラップで覆われたお皿に、バナナが1本入っています。
* その他食品: いくつかの小さな容器やパックに入った食品が見えますが、詳細は不明です。
* 豚肉の塊: 透明なプラスチック容器に入った、生の豚肉の塊と思われるものがあります。
下段:
* 牛乳: 明治の牛乳。900mlパック。
* ジュース: コープブランドのアップルジュースとグレープジュースのパック。他にもいくつかジュースのパックが見えますが、味は不明です。
全体的に、冷蔵庫は様々な食品でかなりいっぱい詰まっており、整理整頓されていない状態です。多くの食品の内容物が判別できないのが現状です。
こちらがいくつかのレシピ提案です:
冷蔵庫の中身が不明瞭な部分が多いので、確実にできる料理と、可能性のある料理を分けて提案します。
確実に作れる料理 (材料の特定が可能なもの):
1. 豚みそ炒めご飯:
    * 材料: 豚肉、生みそ、ご飯、雪印スノーブランドバター、卵(あれば)、冷蔵庫にある野菜(漬物でも可、刻んで使用)。
    * レシピ: 豚肉を一口大に切り、バターで炒め、味噌を溶き入れ、ご飯を加えて炒め合わせる。卵を割り入れて混ぜても良い。野菜や漬物を加えて炒め、塩胡椒で味を調える。
2. ヨーグルト和風ドレッシングの豚しゃぶサラダ風:
    * 材料: 豚肉、BIOブランドヨーグルト(プレーン)、冷蔵庫にある野菜(漬物でも可、細かく刻む)、ご飯(少量)、雪印スノーブランドバター(少量、風味付け)。
    * レシピ: 豚肉をしゃぶしゃぶ風に軽く茹でる。ヨーグルトをベースに、塩、醤油、少しのバターで和風ドレッシングを作る。茹でた豚肉と野菜、ご飯をドレッシングで和える。
可能性のある料理 (材料の推測が必要なもの):
1. パン粉を使った揚げ物: 粉状のものがパン粉なら、豚肉をパン粉で揚げることができます。卵を溶いて衣に混ぜると美味しくなります。野菜があれば一緒に揚げても良いでしょう。しかし、油がないので、バターで焼くか、少量の油で揚げる必要があります。
2. クリームチーズを使った何か: クリームチーズとヨーグルト、パン粉(もしパン粉なら)、卵で何か作れる可能性があります。例えば、クリームチーズとヨーグルトを混ぜて、パン粉を混ぜて焼いたり、卵と混ぜて焼いたり。詳細は不明なため、レシピは提示できませんが、可能性はあります。
3. 冷蔵庫の残り物を使ったスープ: 冷蔵庫にある調理済み料理と、野菜、牛乳、ヨーグルトなどを活用して、雑炊風スープを作ることができます。味見をして、塩や味噌で味を調える必要があります。
重要な注意点:
* 多くの食材が不明なため、上記のレシピはあくまで推測に基づいています。
* 食材の賞味期限を確認し、安全な食材のみを使用してください。
* 冷蔵庫の中身を整理して、食材を把握してから料理を作る方が効率的です。
冷蔵庫の整理と食材の特定ができれば、もっと多くの料理のバリエーションを提案できます。 写真があれば、より正確な提案が可能になります。

Llama 3.2とNeo4jでローカルGraphRAG環境を構築

Llama 3.2とNeo4jでローカルGraphRAG環境を構築
を参考にしてみる

ソースが
https://github.com/satken2/local-llama-graphrag
にあるので
Git clone してみる

この中の
graph_generation.py
で作成しているので
コードを調べることにするのと

graph_retrieval.py
でリレーションをしているので
おそらく分割が必要と思われる

有料プランでならGPTとか使えばできるけど
毎回トークンを使用するのは無理
ってそもそもllama3.2なので課金は存在しない
むしろ試すならcolabで行けるはず

まずはgit clone を Mac でやってモデルをelzaにして動かしてみる

解説は
https://qiita.com/satken2/items/ea3ddc5f273d43cd63d4?utm_source=chatgpt.com
もみながら調べていく

Graph RAGではナレッジグラフを活用することで、データ間の関係性や全体の文脈を考慮したコンテキストの取得をすることを試みる技術

これまでのRAGではベクトルデータベースの中からプロンプトに合致しそうなデータを局所的に取得してきて、
それをコンテキストとして言語モデルに与えていました。
ですがこの方法は元となるデータ全体の文脈をあまり考慮できておらず、
結果としてあまり一致度の高くないデータを取得してしまって回答精度の低下に繋がる場合がありました。

* CPU: Core i5 12400F
* メモリ: DDR4 32GB
* GPU : RTX 3070 (8GB)
* OS: Windows 11
* Python: 3.11.0

が参考スペック

とりあえず M1MacbookAir 16GBで試してみる

グラフデータベースとしてNeo4jをDockerで起動
メインとなる言語モデルはオープンの言語モデルであるLlama3.2の3BモデルをOllamaで動作させ、
LangChainでグラフデータとの連携とかプロンプトの管理をしています。
* Llama 3.2 (3B)
* Ollama
* Neo4j
* langchain

とりあえず実験終了したらOllamaを日本語対応可能な
elyza:jp8b
にしてみる

Neo4jのCypherクエリ言語を拡張するライブラリapoc-coreを使用は
既にセットアップしたのでそのまま使う

docker compose up -d

で起動

次に
Wikipediaのハリーポッター(人物)の記事をRAGで検索したい
これはリポジトリに含まれているので
harrypotter.txt
の内容をそのまま使う

ちなみに内容は

Harry James Potter is a fictional character in the Harry Potter series of novels by J. K. Rowling. The plot of the seven-book series chronicles seven years in the life of the orphan Harry, who, on his eleventh birthday, learns he is a wizard. He attends Hogwarts, a school of magic, where he receives guidance from the headmaster Albus Dumbledore and becomes friends with Ron Weasley and Hermione Granger. Harry learns that during his infancy, the Dark wizard Lord Voldemort murdered his parents but was unable to kill him as well. The plot of the series revolves around Harry's struggle to adapt to the wizarding world and defeat Voldemort.

Harry is regarded as a fictional icon and has been described by many critics, readers, and audiences as one of the greatest literary and film characters of all time. He is portrayed by Daniel Radcliffe in all eight Harry Potter films. Harry also appears in the play Harry Potter and the Cursed Child, which has been produced on stage since 2016.

According to Rowling, the idea for Harry Potter and the novel series came to her while she was waiting for a train in 1990. She first conceived of Harry as a "scrawny, black-haired, bespectacled boy" who is unaware that he is a wizard.[2] While developing ideas for the first book, she decided to make Harry an orphan who attends a boarding school called Hogwarts. She explained in a 1999 interview with The Guardian: "Harry had to be an orphan—so that he's a free agent, with no fear of letting down his parents, disappointing them ... Then there's the security. Having a child of my own reinforces my belief that children above all want security, and that's what Hogwarts offers Harry."[3]

The death of Rowling's mother in December 1990 inspired her to write Harry as a boy longing for his dead parents. She explained that his anguish became "much deeper" and "much more real" than in earlier drafts because she related to it herself.[2] In a 2000 interview, Rowling said that the character Wart in T. H. White's novel The Once and Future King is Harry's "spiritual ancestor".[4] Although Rowling gave Harry her own birth date of 31 July, she maintained that he is not directly based on any real-life person. She explained that he came "out of a part of me".[5]

Rowling has claimed that Harry is a suitable real-life role model for children. She said, "The advantage of a fictional hero or heroine is that you can know them better than you can know a living hero, many of whom you would never meet ... if people like Harry and identify with him, I am pleased, because I think he is very likeable."[6]

Novels
Harry Potter and the Philosopher's Stone
Harry is first introduced in Harry Potter and the Philosopher's Stone (1997) as an orphan living with his abusive aunt and uncle, Vernon and Petunia Dursley, and their bullying son, Dudley. On his eleventh birthday, Harry discovers he is a wizard when Rubeus Hagrid brings him an acceptance letter from Hogwarts School of Witchcraft and Wizardry. Harry learns that his parents, James and Lily Potter, were murdered by a powerful Dark wizard, Lord Voldemort, who also tried to kill Harry. However, Voldemort's killing curse bounced back and apparently destroyed him, leaving Harry with a lightning-bolt-shaped scar on his forehead. Due to his miraculous survival, Harry became famous among wizards.

Hagrid takes Harry to Diagon Alley to shop for school supplies. Harry buys a wand, and the proprietor tells him that his wand and Voldemort's wand contain feathers from the same phoenix. At the end of the summer, Harry boards the Hogwarts Express, where he befriends Ron Weasley and meets Hermione Granger. When he arrives at Hogwarts, he is assigned to Gryffindor House by the Sorting Hat. During the school year, he receives guidance from the Transfiguration professor Minerva McGonagall and the headmaster Albus Dumbledore. He develops animosity towards a fellow first-year named Draco Malfoy, and becomes increasingly wary of the Potions professor, Severus Snape. Harry and Ron become friends with Hermione after they save her from a troll, and Harry becomes the youngest Seeker on the Gryffindor Quidditch team in a century.

During Christmas holidays, Harry receives an anonymous gift – his father's invisibility cloak. Aided by the cloak, he is able to explore the school freely. He, Ron and Hermione decide to enter a trapdoor they discovered one night, which is guarded by an enormous three-headed dog. They believe a valuable magical object, the Philosopher's Stone, lies beyond the door. After they navigate a series of obstacles, Harry faces Professor Quirrell, whose body has been possessed by Voldemort. When Harry obtains the Stone, Quirrell tries to kill him. However, Quirrell's flesh burns upon contact with Harry's skin, and Harry passes out.

Harry awakens in the Hogwarts infirmary, where Dumbledore explains that Harry was protected by his mother's love, which also killed Quirrell. Voldemort survived, and the Philosopher's Stone was destroyed. During the school's end-of-year feast, Gryffindor is awarded the House Cup thanks to the many points Harry and his friends obtained from their adventure.

Harry Potter and the Chamber of Secrets
In Harry Potter and the Chamber of Secrets (1998), Muggle-born students are being Petrified in the Hogwarts castle. Some students suspect that Harry is behind the attacks, which alienates him from his peers. Harry learns that he shares Voldemort's ability to communicate with snakes, and he begins to question whether he is worthy of Gryffindor House. After Ron's sister Ginny disappears, Harry enters the Chamber of Secrets to rescue her. He battles Tom Riddle, a version of Voldemort that lives in a diary which had possessed Ginny. In addition to Riddle, Harry encounters the basilisk which has been attacking students. To defeat it, Harry summons the Sword of Gryffindor from the Sorting Hat. After Harry triumphs over the basilisk and saves Ginny, Dumbledore reassures him that he is worthy of Gryffindor.

Harry Potter and the Prisoner of Azkaban
Near the beginning of Harry Potter and the Prisoner of Azkaban (1999), Harry becomes angry after his Aunt Marge disparages his parents. He accidentally inflates her with magic, then runs away from home. As he re-enters the wizarding community, he learns about Sirius Black, a notorious killer who escaped from the wizard prison Azkaban. On the train to Hogwarts, Harry is assaulted by Dementors, which cause him to relive his worst memories. During a Care of Magical Creatures lesson with Hagrid, Draco is injured after provoking a hippogriff named Buckbeak. As a result, the Ministry of Magic sentences Buckbeak to death. After Harry has more encounters with Dementors, he asks the new Defence Against the Dark Arts professor, Remus Lupin, to help him ward them off. Lupin teaches Harry the Patronus charm, which Harry struggles to master.

While in the village of Hogsmeade, Harry learns that his parents were betrayed by Sirius, their friend and Harry's godfather, and that Sirius also killed Peter Pettigrew. After Ron is taken to the Shrieking Shack by a wild dog, Harry and Hermione give chase and realize the dog is Sirius in Animagus form. Lupin arrives, and he and Sirius explain that Sirius did not kill Pettigrew. They also reveal that Ron's pet rat, Scabbers, is Pettigrew. Lupin then transforms into a werewolf and injures Sirius, and Pettigrew escapes amid the chaos. A swarm of Dementors appear, but they are driven off by a Patronus cast by a mysterious figure.

Awakening in the school infirmary, Harry and Hermione learn that Sirius is being held captive in Hogwarts and is awaiting the Dementor's Kiss. At Dumbledore's advice, they use a Time-Turner to travel back in time to the moment before Buckbeak's execution. After they save the hippogriff, Harry notices the past versions of himself, Hermione, and Sirius being attacked by Dementors. He conjures a strong Patronus to fight them off, then realizes that he was the mysterious figure who appeared earlier. Harry and Hermione free Sirius, who flies away on Buckbeak, still a wanted fugitive. Harry and Hermione then return to their original timeline.

Harry Potter and the Goblet of Fire
In Harry Potter and the Goblet of Fire (2000), Harry is mysteriously chosen by the Goblet of Fire to compete in the dangerous Triwizard Tournament, even though another Hogwarts champion, Cedric Diggory, has already been selected. During the Tournament's final challenge, Harry and Cedric are transported to a graveyard, where Cedric is killed by Peter Pettigrew. Voldemort, aided by Pettigrew, uses Harry's blood to resurrect his body. Harry then duels Voldemort, and their wands connect. Spirit echoes of Voldemort's victims, including Harry's parents, emerge from his wand. The spirits protect Harry as he escapes to Hogwarts with Cedric's body.

Rowling said it was necessary to depict violence and death in this novel because it allows Harry's bravery and compassion to show. She said that Harry's effort to prevent Cedric's body from falling into Voldemort's hands was based on the scene in Homer's Iliad where Achilles retrieves the corpse of his friend Patroclus from his enemy Hector.[7] She asserted that Goblet of Fire marks a turning point in Harry's life. She said Harry has been "very protected until now" but in this novel he is no longer protected.[8]

Harry Potter and the Order of the Phoenix
In Harry Potter and the Order of the Phoenix (2003), the Ministry of Magic wages a smear campaign against Harry and Dumbledore, disputing their claims that Voldemort has returned. The Ministry appoints Dolores Umbridge as the new Defence Against the Dark Arts teacher. Because the Ministry suspects that Dumbledore is building a student army, Umbridge refuses to teach students practical defensive magic. Harry, Hermione and Ron subsequently form a secret group called Dumbledore's Army to teach students defensive spells. The group is disbanded, however, when one student informs Umbridge about it. To shield his students from punishment, Dumbledore falsely claims that Dumbledore's Army was his idea. As a result, he is ousted from his position as headmaster and replaced with Umbridge.

During this time, Harry's increasingly angry and erratic behaviour nearly estranges him from Ron and Hermione. He suffers another emotional blow when his godfather Sirius is killed by the Death Eater Bellatrix Lestrange during a battle at the Department of Mysteries. Also present is Voldemort, who tries to possess Harry so Dumbledore will kill him. This ruse fails, and Harry and Dumbledore thwart Voldemort's plan to steal an important prophecy. Later, Dumbledore explains to Harry that Voldemort chose Harry as his equal, and that either he or Voldemort must eventually kill the other. A subplot of the novel involves Harry's romance with Cho Chang, which quickly unravels.[9]

Rowling said she put Harry through extreme emotional stress in Order of the Phoenix to show his emotional vulnerability and humanity, which contrast with Voldemort's inhumanity.[10]

Harry Potter and the Half-Blood Prince
In Harry Potter and the Half-Blood Prince (2005), Dumbledore brings Harry along as he attempts to persuade Horace Slughorn to rejoin the Hogwarts faculty as the Potions professor. Harry excels in Slughorn's class after he begins using an old textbook that once belonged to someone known as "The Half-Blood Prince". Outside of class, Dumbledore uses the Pensieve to teach Harry about Voldemort's life and his rise to power. Harry and Dumbledore learn that Voldemort created six Horcruxes to gain immortality, and that they must be destroyed before Voldemort can be killed. Harry repeatedly warns Dumbledore that Draco Malfoy is an agent of Voldemort, but Dumbledore refuses to take action against Draco. He tells Harry that he already knows more about what is happening than Harry does. During the course of the novel, Harry becomes romantically involved with Ginny.

Near the end of the book, Dumbledore and Harry enter a cave in search of a Horcrux. Dumbledore drinks a potion inside the Horcrux's container and begins to scream in agony. When Harry attempts to retrieve some lake water for Dumbledore to drink, he is attacked by Inferi. They try to drown Harry, but Dumbledore rescues him. Dumbledore and Harry then return to the Hogwarts Astronomy Tower with the Horcrux. Dumbledore asks Harry not to interfere in the events that are about to take place, and puts him in a body-binding curse. Immobilized, Harry is unable to intervene as Snape arrives and kills Dumbledore. Later, Harry ends his relationship with Ginny to protect her from Voldemort.

Rowling said that Harry's tumultuous puberty in the novel is based on her own difficult teenage years and those of her sister.[11][12] She said that after Half-Blood Prince Harry becomes "battle-hardened" and is ready to fight and take revenge against Voldemort and Snape.[13]

Harry Potter and the Deathly Hallows
Harry, Ron, and Hermione leave Hogwarts in Harry Potter and the Deathly Hallows (2007) to search for and demolish Voldemort's four remaining Horcruxes. They must be destroyed with basilisk venom, the Sword of Gryffindor, or some other powerful means. Ron, Hermione, Vincent Crabbe and Neville Longbottom each destroy one of these Horcruxes. After a battle at Hogwarts, Harry goes into the Forbidden Forest to meet Voldemort, who uses the Elder Wand to cast the Killing Curse on Harry. The curse stuns Harry into a deathlike state, and he finds himself in an ethereal realm with Dumbledore. The deceased headmaster tells Harry that he is a Horcrux that Voldemort unknowingly created. There was a part of Voldemort's soul inside Harry, but it was destroyed by the curse Voldemort cast moments ago. Harry subsequently returns to the living world and duels with Voldemort. The Dark Lord uses the Killing Curse again, but it rebounds and kills him. Harry, not Voldemort, was the true master of the Elder Wand, and the wand did not wish to harm its master. Harry decides to leave the Elder Wand in Dumbledore's tomb and the Resurrection Stone hidden in the forest, but he keeps the Invisibility Cloak.

In the epilogue, set nineteen years after Voldemort's death, Harry and Ginny are a married couple and have three children: James Sirius Potter, Albus Severus Potter and Lily Luna Potter. Albus is departing for his first year at Hogwarts, and is worried that he will be sorted into Slytherin. Harry explains to him that not all Slytherins are bad.

Harry Potter and the Cursed Child
Harry Potter and the Cursed Child is a play written by Jack Thorne from a story by Thorne, J. K. Rowling and John Tiffany. It takes place nineteen years after the events of Deathly Hallows. In the play, Harry is Head of the Department of Magical Law Enforcement at the Ministry of Magic. The story focuses on Harry's son, Albus Severus Potter.

Film adaptations
Harry is portrayed by Daniel Radcliffe in all eight Harry Potter film adaptations. Radcliffe was asked to audition for the role in 2000 by David Heyman, who was producing the first film in the series. Radcliffe met Heyman while attending a London play called Stones in His Pockets.[14][15]

In a 2007 interview with MTV, Radcliffe described the development of his character during the course of the series: "That's what the films are about for me: a loss of innocence, going from being a young kid in awe of the world around him, to someone who is more battle-hardened by the end of it."[16] Radcliffe said that after the death of Cedric Diggory in Goblet of Fire, Harry experiences survivor's guilt and feels immense loneliness. Because of this, Radcliffe talked to a bereavement counsellor to help him prepare for the role.[16] Radcliffe stated that the most repeated question he has been asked is how Harry Potter has influenced his own life, to which he regularly answers it has been "fine", and that he did not feel pigeonholed by the role, but rather sees it as a huge privilege to portray Harry.[17]

In 2011, Empire magazine ranked Radcliffe's Harry Potter as the 36th greatest film character of all time. The magazine published another character ranking in 2018, this time placing Harry at 67th.[18]


Outward appearance

A stylized image of Harry and the two most instantly recognizable features of his appearance: his lightning bolt scar and his circular glasses.
In the novels, Harry is described as having his father's perpetually untidy black hair, his mother's bright green eyes, and a lightning bolt-shaped scar on his forehead. He is short and skinny for his age, with a thin face and "knobbly" knees, and he wears Windsor glasses. When asked about the meaning behind Harry's scar, Rowling said she wanted him to be "physically marked by what he has been through". She said the scar is "an outward expression of what he has been through inside".[19] Rowling also said that Harry's glasses are the clue to his vulnerability.[20] Harry eventually grows taller, and by the final book is almost the height of his father, and is described as tall by other characters.[21]

Personality
According to Rowling, Harry is strongly guided by his own conscience, and has a keen feeling of what is right and wrong. Having "very limited access to truly caring adults", Rowling said, Harry "is forced, for such a young person, to make his own choices".[22] He "does make mistakes", she conceded, but in the end, he does what his conscience tells him to do. According to Rowling, one of Harry's pivotal scenes came in the fourth book when he protects his dead schoolmate Cedric Diggory's body from Voldemort, because it shows he is brave and selfless.[7]

Rowling has stated that Harry's character flaws include anger and impulsiveness; however, Harry is also innately honourable.[23][24] "He's not a cruel boy. He's competitive, and he's a fighter. He doesn't just lie down and take abuse. But he does have native integrity, which makes him a hero to me. He's a normal boy but with those qualities most of us really admire."[25] For the most part, Harry shows humility and modesty, often downplaying his achievements; though he uses a litany of his adventures as examples of his maturity early in the fifth book. However, these very same accomplishments are later employed to explain why he should lead Dumbledore's Army, at which point he asserts them as having just been luck, and denies that they make him worthy of authority. After the seventh book, Rowling commented that Harry has the ultimate character strength, which not even Voldemort possesses: the acceptance of the inevitability of death.

Magical abilities and skills
Throughout the series, Harry is described as a gifted wizard. He has a particular talent for flying, which manifests itself in Harry Potter and the Philosopher's Stone the first time he tries it, and gets him a place on a Quidditch team one year before the normal minimum joining age. He captains it in his sixth year. In his fourth year, Harry is able to confront a dragon on his broomstick.

Harry is also gifted in Defence Against the Dark Arts, in which he becomes proficient due to his repeated encounters with Voldemort and various monsters. In his third year, Harry becomes able to cast the very advanced Patronus Charm, and by his fifth year he has become so talented at the subject that he is able to teach his fellow students in Dumbledore's Army, some even older than him how to defend themselves against Dark Magic. At the end of that year, he achieves an 'Outstanding' Defence Against the Dark Arts O.W.L., something that not even Hermione achieved.

Harry also has the unusual ability to speak and understand "Parseltongue", a language associated with Dark Magic. This, it transpires, is because he harbours a piece of Voldemort's soul. He loses this ability after the part of Voldemort's soul inside him is destroyed at the end of The Deathly Hallows. However, in the events of Harry Potter and the Cursed Child, it was revealed that he had not lost the ability to recognise or speak the language when he encountered Delphini, Voldemort's daughter, who was trying to use his son and Draco Malfoy's son Scorpius to fulfill a prophecy that could guarantee the return of Voldemort by changing time.

Possessions
Harry's parents left him a large pile of gold in the wizarding bank, Gringotts. After Sirius' death later in the series, all of his remaining possessions are also passed along to Harry, including Number Twelve, Grimmauld Place, and Sirius's vast amount of gold were transferred into Harry's account at Gringotts.

Among the school items Harry purchases in Diagon Alley after discovering his gold inheritance is his first wand, an 11-inch-long holly and phoenix feather model that he learns is the twin of Voldemort's wand, as the feathers that both wands contain as their cores both comes from Fawkes, the phoenix that Dumbledore keeps as a pet in his office until his death in Half-Blood Prince.[26] Harry's wand is broken in Deathly Hallows. For a time, he borrows Hermione's wand, and later steals Draco's. With his defeat of Voldemort at the end of the series, he comes into the possession of the Elder Wand, but uses it only to repair his holly wand, before returning it to Dumbledore's tomb, from which Voldemort had stolen it. In the film version of Deathly Hallows Part 2, Harry destroys the Elder Wand.

Harry also inherits indirectly two of his father's prized possessions. One is the Marauder's Map, given to him by interim owners Fred and George Weasley, which endows Harry with comprehensive knowledge of Hogwarts' facilities, grounds, and occupants. The other is his father's invisibility cloak, given to him by Dumbledore, which eventually proves Harry's descent from the Peverell family. Harry uses these tools both to aid in excursions at school and to protect those he cares about; the Invisibility Cloak, in particular, can hide two full-grown people. If three fully-grown people hide under the cloak their feet will be visible.

Throughout the majority of the books, Harry also has a pet owl named Hedwig, used to deliver and receive messages and packages. Hedwig is killed in the seventh book, about which Rowling says: "The loss of Hedwig represented a loss of innocence and security. She has been almost like a cuddly toy to Harry at times. I know that death upset a lot of people!"[23] As a Quidditch player, Harry has owned two high-quality brooms. The first, a Nimbus Two Thousand, was procured for him by Professor Minerva McGonagall when Harry was added to Gryffindor's Quidditch team despite being a first-year student. This broom was destroyed by the Whomping Willow during a match in Harry's third year. It was replaced by a Firebolt, an even faster broom, purchased for Harry by Sirius; however, as Sirius was believed to be trying to murder Harry at the time, the broom was subjected to stringent security inspections before Harry was allowed to ride it. Harry used it throughout his Hogwarts career until it, along with Hedwig, was lost during the July escape from Privet Drive in the final book.

Harry also owns a mokeskin pouch, or small 'bag' that is used for storing items, which no one but the owner can get out. Harry uses the pouch throughout the course of Deathly Hallows to keep several objects such as the Marauder's Map, a shard of the magical mirror given to him by his god-father Sirius, the fake Horcrux locket that had belonged to Sirius's brother R.A.B. (Regulus Arcturus Black), the Snitch bequeathed to him by Dumbledore, containing the Resurrection Stone that had previously been set into Voldemort's grandfather Marvolo Gaunt's signet ring, which Harry discovers is actually the second Hallow, a letter from his mother to Sirius with part of a photo (of him and his father, James), and eventually, his own broken wand (which Harry later repairs with the Elder Wand).

Family
In the novels and films, Harry is the only child of James and Lily Potter, orphaned as an infant. Rowling made him an orphan because she felt an orphan would be interesting to write about.[3] However, after her mother's death, Rowling wrote Harry as a child longing to see his dead parents again, incorporating her own anguish into him. Harry is categorised as a "half-blood" wizard in the series, because although both his parents were magical, Lily was "Muggle-born", and James was a pure-blood.

Harry's aunt and uncle kept the truth about his parents' deaths from Harry, telling him that they had died in a car crash.[2] James Potter is a descendant of Ignotus Peverell, the third of the three original owners of the Deathly Hallows, and thus so is Harry, a realisation he makes during the course of the final book. The lineage continues at the end of the saga through his three children with Ginny: James Sirius Potter, Albus Severus Potter and Lily Luna Potter.

Reception
In 2002, Book magazine ranked Harry Potter No. 85 among the "100 Best Fictional Characters since 1900".[27] Entertainment Weekly ranked Harry Potter number two on its 2010 "100 Greatest Characters of the Last 20 Years" list, saying "Long after we've turned the last page and watched the last end credit, Harry still feels like someone we know. And that's the most magical thing about him."[28] UGO Networks listed Harry as one of their best heroes of all time, who said that "Harry is a hero to the often oppressed and downtrodden young fan boys and girls out there, who finally have an icon that is respected and revered by those who might otherwise look down on robe-wearing and wand waving as dork fodder".[29] Harry Potter was also ranked number thirty-six on Empire's 2008 list of "100 Greatest Movie Characters of All Time".[30] IGN said that Harry Potter was their favourite Harry Potter character, calling him a "sympathetic figure" and saying in response to his fights against Voldemort that "everybody loves an underdog story of good vs. evil".[31]

On the other hand, he has received criticism. In 2010, he was voted the 35th "Worst Briton" in Channel 4's "100 Worst Britons We Love to Hate" programme.[32] In The Irish Times, Ed Power called Harry an "anointed cherub" who is "told he is special from the very outset" before he has any "winning attributes". Power said Harry is "celebrated as an overachiever before he's achieved anything".[33] Lannah Marshall called Harry "a bland character". She further described him as "an audience surrogate, or what I call a 'puppet protagonist'. A puppet protagonist is a main character with dull, limited personality, enabling the audience to step inside the role and use their imagination to fill in the rest.[34]

Despite blowback from some Christian fundamentalists critical of Rowling's usage of witchcraft and magic in the series,[35] other Christian critics, including Rev. John Killinger, have argued that Potter is a Christ figure in the series. Killinger opined in 2002 that "J.K. Rowling has written the Christ story of the 21st century, and it's wonderful that she has attained such a magnificent following worldwide." He noted several allusions to Jesus in Potter's character arcs in Philosopher's Stone and Chamber of Secrets.[36] Rowling herself later admitted that the Gospel story inspired that of Harry Potter, especially with his ultimate sacrifice in Deathly Hallows and apparent death before returning to defeat Voldemort once and for all.[37]

既にOllamaのセットもしてあるので
これも飛ばす

次にRAGの構築のため
グラフデータベースを構築

Neo4jの初期化とLlama3.2の読み込み
No4jとOllamaはどちらもlangchainを使う

from langchain_ollama import ChatOllama
from langchain_community.graphs import Neo4jGraph

# Initialize LLM model
llm = ChatOllama(model="llama3.2:1b", temperature=0, format="json")

# Initialize the Neo4j graph
graph = Neo4jGraph()

このモデルの部分を
llama3.2:1b
から別のモデルにすれば行けるはず
elyza:jp8b
にしてみる

RAGの構築用プログラムgraph_generator.pyを作成していく

テキストデータの読み込みと分割
最初に準備したWikipediaの記事 harrypotter.txtを読み込んで、
RecursiveCharacterTextSplitterを使ってチャンクに分割する

しかし
このテキスト分割は文脈などを考慮せず機械的に切っているだけなので、重要な情報が途中でぶった切られる

対策として
重要な情報が途中でぶった切られるのを防ぐため、
chunk_overlapを設定して
チャンクと次のチャンクの範囲をある程度重複させて分割

# Load documents from a text file
loader = TextLoader(file_path="harrypotter.txt", encoding = 'UTF-8')
docs = loader.load()
# Split documents into chunks
text_splitter = RecursiveCharacterTextSplitter(chunk_size=250, chunk_overlap=24)
documents = text_splitter.split_documents(documents=docs)

このチャンクの範囲の設定については適切な量はGPTで調べてみる

## 1. **チャンクサイズ (`chunk_size`) の決定**
– **短すぎると**:単語や文章が分断され、情報の流れが失われる。
– **長すぎると**:分割する意味が薄れ、メモリ消費が大きくなる。
– **一般的な目安**:
– **250~500文字**:短め(検索用、要約用)
– **500~1000文字**:バランス型(QAシステム、ベクトル検索)
– **1000~2000文字**:長め(LLMのコンテキスト処理)

例:
– 段落単位で検索するなら **500~1000文字**
– 文脈を広く持ちたいなら **1000~2000文字**

## 2. **チャンクの重複 (`chunk_overlap`) の決定**
`chunk_overlap` を設定することで、情報の途切れを防ぐことができます。適切な値は以下のように決めます。

– **目安**:
– `chunk_size` の **10~20%** くらいが一般的
– 例えば `chunk_size=500` なら `chunk_overlap=50~100`
– 重要な文脈を保持したいなら `chunk_overlap=20~30%`

### **例**
– `chunk_size=250`, `chunk_overlap=24` (10%程度) → 短めのコンテンツ
– `chunk_size=500`, `chunk_overlap=100` (20%程度) → 一般的な設定
– `chunk_size=1000`, `chunk_overlap=200` (20%程度) → 長めのコンテンツ

– 一般的には **`chunk_size=500`, `chunk_overlap=100` (20%)** くらいが良い
– **内容が切れやすいなら `chunk_overlap` を増やす**
– **より自然な分割には `separators` を活用する**
– **実際に試して調整するのが最も確実**

とのこと

この辺りは別の時に試すことにする

チャンクからグラフデータを作成

LLMGraphTransformerを使ってチャンクに分割したテキストをグラフドキュメントに変換する

これは
LLMがテキストからエンティティやその間の関係性を抽出し、構造化されたグラフデータを生成
その後、それをNeo4jに保存
という流れになる

# Convert documents to graph format
llm_transformer = LLMGraphTransformer(llm=llm, ignore_tool_usage=False)
graph_documents = llm_transformer.convert_to_graph_documents(documents)

# Add documents to the graph
graph.add_graph_documents(
    graph_documents,
    baseEntityLabel=True,
    include_source=True
)

この辺りのコードは変更なさそう

次にベクトルインデックスの作成
グラフデータだけでなく
テキストから作成した埋め込み行列(ベクトルインデックス)も計算し、
これもNeo4jに格納

ベクトルインデックスの計算にはmxbai-embed-largeという別のモデルを使用
Neo4jVector.from_existing_graphを使用することで、
embeddingsに指定したモデルを使用してテキストの埋め込み行列が作成され、
グラフデータベースに保存される

このコードも変わらないと思う

# Initialize embeddings
embeddings = OllamaEmbeddings(
    model="mxbai-embed-large",
)

# Create a vector index from the existing graph
Neo4jVector.from_existing_graph(
    embeddings,
    search_type="hybrid",
    node_label="Document",
    text_node_properties=["text"],
    embedding_node_property="embedding"
)

次に
フルテキストインデックスの作成
最後に通常の全文検索に使用するインデックス
(ベクトルインデックスとは異なります)を作成

これは単にNeo4jに接続してCREATE FULLTEXT INDEXを使えばOK

これは

# Connect to the Neo4j database and create a full-text index
driver = GraphDatabase.driver(
    uri=os.environ["NEO4J_URI"],
    auth=(os.environ["NEO4J_USERNAME"], os.environ["NEO4J_PASSWORD"])
)

def create_fulltext_index(tx):
    query = '''
    CREATE FULLTEXT INDEX `fulltext_entity_id` 
    FOR (n:__Entity__) 
    ON EACH [n.id];
    '''
    tx.run(query)

try:
    with driver.session() as session:
        session.execute_write(create_fulltext_index)
        print("Fulltext index created successfully.")
except Exception as e:
    print(f"Failed to create index: {e}")

driver.close()

でコードもそのままで行けるはず

上記のコードが実行できたら、http://localhost:7474にアクセスしてグラフの状況を確認

次に
RAGを使った質問の回答
このグラフRAGでは質問に回答するときに以下の3段階でデータを取ってます。
1. 質問文に含まれる人名や組織名を抽出し、それに関連するグラフノードを取得する
2. 取得したグラフノードでベクトル検索を行い、コンテキストを作る
3. 元の質問文に作成したコンテキストを添えて言語モデルに投げ、最終的な回答を得る

“What does Harry Potter looks like?”という質問をした場合

1.質問に関連するグラフデータの抽出
質問文から”Harry Potter”という人名が抽出され、”Harry Potter”に言及するグラフノードが取得されます

2.ベクトル検索による関連情報の抽出
さらに、これはハリーの見た目に関する質問だということを言語モデルが解釈して、1で取得したグラフノードに含まれる文章に対してベクトル検索を行い、見た目に関する言及を抽出してコンテキストとします

3.最終的な回答の生成
最後に、context_builderが作ったコンテキストと元の質問である”What does Harry Potter looks like?”をつなげてLlamaに投げています

ここからが検索用プログラム

質問文からの人名抽出

まず、ユーザーの質問文からエンティティを抽出するための処理を書きます。
Entitiesクラスを定義し、descriptionに「テキストに出てくる全ての人名・組織名」と定義することで言語モデルに人名と組織名を抽出させるように指定

そして、そのEntitiesクラスをllm.with_structured_outputで言語モデルに渡してinvokeすることで質問から人名・組織名を抽出

コードは

class Entities(BaseModel):
    """Identifying information about entities."""
    names: list[str] = Field(
        ...,
        description="All the person, organization, or business entities that "
        "appear in the text",
    )

# Create a prompt for extracting entities
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are extracting organization and person entities from the text."),
        ("human", "Use the given format to extract information from the following input: {question}"),
    ]
)

dict_schema = convert_to_openai_function(Entities)
entity_chain = prompt | llm.with_structured_output(dict_schema)
entities = entity_chain.invoke({"question": question})
entity_names = eval(entities['names'])

この中の英文
フィールドの指定で
descriptionで
All the person, organization, or business entities that ”
“appear in the text”

「すべての個人、組織、または事業体」
「本文中に登場します」

プロンプトで
You are extracting organization and person entities from the text.
テキストから組織と個人のエンティティを抽出しています。

Use the given format to extract information from the following input: {question}
指定された形式を使用して、次の入力から情報を抽出します: {question}

わかりにくいのでGPTでこの部分を調べた

system と human の関係
* system メッセージ → AI の振る舞いやタスクの定義
* human メッセージ → 実際の入力データと実行すべき処理の具体的な指示
この二つを組み合わせることで、AI に 「何をするべきか(system)」 と 「何を元に処理するか(human)」を伝える

このため応用するなら
プロンプトとフィールドの部分を変えれば
他の用途、例えば場所や商品の検索にも使えるらしい

次にグラフデータ取得
ここは単なるNeo4jの操作
先ほどリスト形式で取得したエンティティをループさせてNeo4jのクエリを複数回投げる

これもそのまま使える

# Retrival of related nodes
related_nodes = []
for entity in entity_names:
    response = graph.query(
        """
        MATCH (n)-[:MENTIONS]->(m)
        WHERE m.id = $entity
        RETURN n.text AS text, n.embedding AS embedding
        """,
        {"entity": entity},
    )
    for record in response:
        text = record["text"]
        embedding = np.array(record["embedding"])
        related_nodes.append((entity, text, embedding))

次に取得したグラフデータに対するベクトル類似度計算
上の質問で質問に関連する人物に関するグラフノードを取得しましたが、
元のデータの量によっては膨大な量のデータが取れるかもしれません

そのため、取得したグラフデータに対して質問文との類似度を計算し、
上位いくつかのデータだけをコンテキストとして採用する

まずは質問文の埋め込みベクトルを計算し、cosine_similarityで各グラフデータとの類似度を計算
上位いくつかのデータを並べて、#Documentというタグをつけておく

これもそのままで行けるはず

# Create embeddings from question
question_embedding = embeddings.embed_query(question)

# Calculation and sorting of cosine similarity
def cosine_similarity(vec1, vec2):
    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

related_nodes.sort(
    key=lambda x: cosine_similarity(question_embedding, x[2]),
    reverse=True
)

top_related_texts = [node[0] for node in related_nodes[:top_n]]

graph_context = ""
for node in related_nodes[:top_n]:
    graph_context += f"#Document\nExplanation about '{node[0]}' - '{node[1]}'\n\n"

次に通常のベクトル検索も行う
グラフデータの検索だけでなく通常のRAG検索であるベクトル検索も行います
Neo4jVector.from_existing_indexを使用してグラフデータを取得するためのリトリーバーを取得してから、
それを使って検索を実行
これも#Documentというタグをつけてテキストに整形

ここもコードはそのままでOK

# Retrieve the vector retriever
vector_index = Neo4jVector.from_existing_index(
    embeddings,
    search_type="hybrid",
    index_name="Document_embedding",
    keyword_index_name="Document_keyword",
    node_label="Document",
    text_node_property="text",
    embedding_node_property="embedding"
)
vector_retriever = vector_index.as_retriever()

# Execute vector search
vector_data = [el.page_content for el in vector_retriever.invoke(question)]
vector_context = ""
for text in vector_data[:top_n]:
    vector_context += f"#Document\nAdditional information - '{text}'\n\n"

コンテキストの作成と言語モデルの実行
コンテキストができたので、あとはこれをLlamaに渡すだけ
このコード例ではgraph_retrieverとvector_retriverが上記のコンテキスト作成プログラムになっています

def context_builder(question: str) -> str:
    graph_data = graph_retriever(question)
    vector_data = vector_retriver(question)
    return f"""
    Context from graph data:
    {graph_data}

    Context from vector data:
    {vector_data}
    """

# Create a prompt template for the final answer
template = """Answer the question based only on the following context:
{context}

Question: {question}
Use natural language and be concise.
Answer:"""
prompt = ChatPromptTemplate.from_template(template)

# Set up the final chain
chain = (
    {
        "context": context_builder,
        "question": lambda x: x,
    }
    | prompt
    | llm
    | StrOutputParser()
)

input_question = input("Question: ")
result = chain.invoke(question)
print("Answer:", result)

プロンプトやchainの作成などはLangChainの基本的な使い方の話なのでここでは割愛
とあるのでGPTで解説

このコードは、質問 (question) に対して グラフデータとベクトルデータ を取得し、
それらの情報を元に回答を生成する処理を行っています。
LangChain を用いた パイプライン処理 を実装しており、関数型プログラミングのスタイルでデータの流れを明確にしています。

def context_builder(question: str) -> str:
    graph_data = graph_retriever(question)
    vector_data = vector_retriver(question)
    return f"""
    Context from graph data:
    {graph_data}

    Context from vector data:
    {vector_data}
    """


graph_retriever(question) を用いて、質問に関連する グラフデータ を取得
vector_retriver(question) を用いて、質問に関連する ベクトルデータ を取得
それらを フォーマット済みのコンテキスト にまとめて返す

ポイント
* グラフデータ:ノードとエッジの関係を利用した情報(例:知識グラフ)
* ベクトルデータ:埋め込みモデルを使った検索結果(例:類似度検索)
* 統合したコンテキストを作成し、後続の LLM へ渡す

template = """Answer the question based only on the following context:
{context}

Question: {question}
Use natural language and be concise.
Answer:"""
prompt = ChatPromptTemplate.from_template(template)

でプロンプトの作成

役割
* LLM に与える プロンプトのテンプレート を定義。
* {context} の部分に context_builder() で作成したコンテキストを挿入。
* {question} に元の質問を挿入。
* 「与えられたコンテキストだけを使って回答せよ」 という指示を明示。


例えば、質問 “What is the capital of France?” に対して、context_builder() が以下のようなデータを返したとする:
Context from graph data:
– France -> Capital -> Paris

Context from vector data:
– The capital of France is Paris.

この場合、最終的なプロンプトは:
Answer the question based only on the following context:
Context from graph data:
– France -> Capital -> Paris

Context from vector data:
– The capital of France is Paris.

Question: What is the capital of France?
Use natural language and be concise.
Answer:

このプロンプトを LLM に渡して、回答を生成す

chain(処理の流れを定義)
chain = (
    {
        "context": context_builder,
        "question": lambda x: x,
    }
    | prompt
    | llm
    | StrOutputParser()
)

データの流れを順番に処理するためのパイプライン を構築

{
    "context": context_builder,
    "question": lambda x: x,
}

context_builder(question) で コンテキストを生成。
lambda x: x で 質問をそのまま渡す
| prompt
ChatPromptTemplate を適用し、コンテキストと質問を埋め込んだプロンプトを作成。

| llm
prompt で作成したプロンプトを LLM に入力し、回答を得る

| StrOutputParser()
LLM の出力を文字列としてパース し、最終的な回答として返す

ユーザーの質問を処理

input_question = input("Question: ")
result = chain.invoke(question)
print("Answer:", result)

* input() で ユーザーの質問を受け取る。
* chain.invoke(question) を実行し、質問に基づいて LLM に回答を生成させる。
* print() で 最終的な回答を出力。

ポイント
✅ グラフ検索 (graph_retriever()) と ベクトル検索 (vector_retriver()) を組み合わせて、幅広い情報を取得

✅ プロンプトテンプレート (ChatPromptTemplate) を使用して、質問とコンテキストを LLM に適切に渡す

✅ データ処理の流れをパイプライン (| 演算子) で簡潔に記述

✅ LLM への入力と出力を一貫して処理できるフレームワーク

改良のアイデア
1. スコア付きの情報を使用
* graph_data や vector_data にスコアを追加し、信頼性の高い情報を優先。
2. 回答に信頼性スコアを付与
* LLM の回答に「出典(graph / vector)」を示す。
3. エンティティ拡張
* question からエンティティを抽出し、graph_retriever() の検索クエリを改善。

とのこと

ここまで完成したら、以下のようなプロンプトを投げてみます。
Where did Dumbledore and Harry entered in search of a Horcrux?
ダンブルドアとハリーは分霊箱を探してどこに入りましたか?

graph_dataをログに出力してみると、HarryとDumbledoreが人名として解釈され、2人に言及するグラフデータが取得されてコンテキストとして整形されています

結果ログは

Context from graph data:
#Document
Explanation about 'Dumbledore' - 'Near the end of the book, Dumbledore and Harry enter a cave in search of a Horcrux. Dumbledore drinks a potion inside the Horcrux's container and begins to scream in agony. When Harry attempts to retrieve some lake water for Dumbledore to drink, he'

#Document
Explanation about 'Harry' - 'Near the end of the book, Dumbledore and Harry enter a cave in search of a Horcrux. Dumbledore drinks a potion inside the Horcrux's container and begins to scream in agony. When Harry attempts to retrieve some lake water for Dumbledore to drink, he'

#Document
Explanation about 'Dumbledore' - 'Harry awakens in the Hogwarts infirmary, where Dumbledore explains that Harry was protected by his mother's love, which also killed Quirrell. Voldemort survived, and the Philosopher's Stone was destroyed. During the school's end-of-year feast,'

#Document
Explanation about 'Harry' - 'Hagrid takes Harry to Diagon Alley to shop for school supplies. Harry buys a wand, and the proprietor tells him that his wand and Voldemort's wand contain feathers from the same phoenix. At the end of the summer, Harry boards the Hogwarts Express,'

#Document
Explanation about 'Dumbledore' - 'that both wands contain as their cores both comes from Fawkes, the phoenix that Dumbledore keeps as a pet in his office until his death in Half-Blood Prince.[26] Harry's wand is broken in Deathly Hallows. For a time, he borrows Hermione's wand, and'

graph_dataをログに出力してみると、HarryとDumbledoreが人名として解釈され、
2人に言及するグラフデータが取得されてコンテキストとして整形されていま

ベクトル検索データの確認
ベクトル検索から得られたコンテキストも確認します。
だいたい同じようなデータが取得されています

Context from vector data:
#Document
Additional information - 'Near the end of the book, Dumbledore and Harry enter a cave in search of a Horcrux. Dumbledore drinks a potion inside the Horcrux's container and begins to scream in agony. When Harry attempts to retrieve some lake water for Dumbledore to drink, he'

#Document
Additional information - 'Dumbledore to drink, he is attacked by Inferi. They try to drown Harry, but Dumbledore rescues him. Dumbledore and Harry then return to the Hogwarts Astronomy Tower with the Horcrux. Dumbledore asks Harry not to interfere in the events that are'

#Document
Additional information - 'Harry, Ron, and Hermione leave Hogwarts in Harry Potter and the Deathly Hallows (2007) to search for and demolish Voldemort's four remaining Horcruxes. They must be destroyed with basilisk venom, the Sword of Gryffindor, or some other powerful'

#Document
Additional information - 'or some other powerful means. Ron, Hermione, Vincent Crabbe and Neville Longbottom each destroy one of these Horcruxes. After a battle at Hogwarts, Harry goes into the Forbidden Forest to meet Voldemort, who uses the Elder Wand to cast the Killing'

最終的な質問文
ChatPromptTemplateに渡しているテンプレートの内容と合わさって、最終的にLlamaには以下のような質問が投げられます

Answer the question based only on the following context:
Context from graph data:
#Document
Explanation about 'Dumbledore' - 'Near the end of the book, Dumbledore and Harry enter a cave in search of a Horcrux. Dumbledore drinks a potion inside the Horcrux's container and begins to scream in agony. When Harry attempts to retrieve some lake water for Dumbledore to drink, he'

#Document
Explanation about 'Harry' - 'Near the end of the book, Dumbledore and Harry enter a cave in search of a Horcrux. Dumbledore drinks a potion inside the Horcrux's container and begins to scream in agony. When Harry attempts to retrieve some lake water for Dumbledore to drink, he'

#Document
Explanation about 'Dumbledore' - 'Harry awakens in the Hogwarts infirmary, where Dumbledore explains that Harry was protected by his mother's love, which also killed Quirrell. Voldemort survived, and the Philosopher's Stone was destroyed. During the school's end-of-year feast,'

#Document
Explanation about 'Harry' - 'Hagrid takes Harry to Diagon Alley to shop for school supplies. Harry buys a wand, and the proprietor tells him that his wand and Voldemort's wand contain feathers from the same phoenix. At the end of the summer, Harry boards the Hogwarts Express,'

#Document
Explanation about 'Dumbledore' - 'that both wands contain as their cores both comes from Fawkes, the phoenix that Dumbledore keeps as a pet in his office until his death in Half-Blood Prince.[26] Harry's wand is broken in Deathly Hallows. For a time, he borrows Hermione's wand, and'

Context from vector data:
#Document
Additional information - 'Near the end of the book, Dumbledore and Harry enter a cave in search of a Horcrux. Dumbledore drinks a potion inside the Horcrux's container and begins to scream in agony. When Harry attempts to retrieve some lake water for Dumbledore to drink, he'

#Document
Additional information - 'Dumbledore to drink, he is attacked by Inferi. They try to drown Harry, but Dumbledore rescues him. Dumbledore and Harry then return to the Hogwarts Astronomy Tower with the Horcrux. Dumbledore asks Harry not to interfere in the events that are'

#Document
Additional information - 'Harry, Ron, and Hermione leave Hogwarts in Harry Potter and the Deathly Hallows (2007) to search for and demolish Voldemort's four remaining Horcruxes. They must be destroyed with basilisk venom, the Sword of Gryffindor, or some other powerful'

#Document
Additional information - 'or some other powerful means. Ron, Hermione, Vincent Crabbe and Neville Longbottom each destroy one of these Horcruxes. After a battle at Hogwarts, Harry goes into the Forbidden Forest to meet Voldemort, who uses the Elder Wand to cast the Killing'

Question: Where did Dumbledore and Harry entered in search of a Horcrux?

Use natural language and be concise.

Answer:

これだけ事前情報が与えられれば、パラメータ数の少ないLlama3.2 3Bでも正確な回答ができます。回答は以下の

A cave.

となる