⚔️

第七話:LINEボット2刀流

2024/12/30に公開

フィットネス事業でAIチャットボットを導入したいと考えています。ユーザー属性は以下の通りです。これに応じた回答内容の分け方についてアドバイスをいただければと思います。

  • 体を良くしたい一般ユーザー
  • フィットネス知識を深めたい同業者(育成目的)

また、実装場所についてもご相談させていただきたく、以下の選択肢を検討しています。

  • ホームページ
  • LINE
  • スマホアプリ

最適な実装場所についてもアドバイスをいただけると幸いです。よろしくお願いいたします。

今回もお寄せいただいたお問い合わせから厳選し、AIの力でお悩み解決していきます。

ユーザー属性が複数パターン分かれるので、属性に応じて回答内容を分けたいそうです。LINEで2つのチャットボットを動かす方法をお伝えしていきます。

LIFF

LIFFはフレームワークの1種で、これで開発したWebアプリはLINEで動かすことができます。

yarn add @line/liff

LINE Developersにて、LIFF IDとエンドポイントの設定が必要となります。また、LINEビジネスアカウントも必要で、すべて無料で発行が可能です。

// .env
NEXT_PUBLIC_LIFF_ID=xxxxx

// src/global-config.ts
export type ConfigValue = {
  appName: string;
  appVersion: string;
  serverUrl: string;
  assetsDir: string;
  isStaticExport: boolean;
  auth: {
    method: 'jwt' | 'amplify' | 'firebase' | 'supabase' | 'auth0';
    skip: boolean;
    redirectPath: string;
  };
  mapboxApiKey: string;
  firebase: {
    appId: string;
    apiKey: string;
    projectId: string;
    authDomain: string;
    storageBucket: string;
    measurementId: string;
    messagingSenderId: string;
  };
  amplify: { userPoolId: string; userPoolWebClientId: string; region: string };
  auth0: { clientId: string; domain: string; callbackUrl: string };
  supabase: { url: string; key: string };
  liffId: string; // LIFF ID を追加
};

export const CONFIG: ConfigValue = {
  appName: 'Minimal UI',
  appVersion: packageJson.version,
  serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
  assetsDir: process.env.NEXT_PUBLIC_ASSETS_DIR ?? '',
  isStaticExport: JSON.parse(`${process.env.BUILD_STATIC_EXPORT}`),
  /**
   * Auth
   * @method jwt | amplify | firebase | supabase | auth0
   */
  auth: {
    method: 'jwt',
    skip: false,
    redirectPath: paths.dashboard.root,
  },
  /**
   * Mapbox
   */
  mapboxApiKey: process.env.NEXT_PUBLIC_MAPBOX_API_KEY ?? '',
  /**
   * Firebase
   */
  firebase: {
    apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? '',
    authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN ?? '',
    projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ?? '',
    storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET ?? '',
    messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID ?? '',
    appId: process.env.NEXT_PUBLIC_FIREBASE_APPID ?? '',
    measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID ?? '',
  },
  /**
   * Amplify
   */
  amplify: {
    userPoolId: process.env.NEXT_PUBLIC_AWS_AMPLIFY_USER_POOL_ID ?? '',
    userPoolWebClientId: process.env.NEXT_PUBLIC_AWS_AMPLIFY_USER_POOL_WEB_CLIENT_ID ?? '',
    region: process.env.NEXT_PUBLIC_AWS_AMPLIFY_REGION ?? '',
  },
  /**
   * Auth0
   */
  auth0: {
    clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID ?? '',
    domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN ?? '',
    callbackUrl: process.env.NEXT_PUBLIC_AUTH0_CALLBACK_URL ?? '',
  },
  /**
   * Supabase
   */
  supabase: {
    url: process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
    key: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '',
  },
  liffId: process.env.NEXT_PUBLIC_LIFF_ID ?? '', // LIFF ID を追加
  
// src/components/liff/liff.tsx
'use client'; // これを追加

import { useEffect, useState } from 'react';
import liff from '@line/liff';
import { CONFIG } from 'src/global-config';
import { Typography } from '@mui/material';

interface UserProfile {
  displayName: string;
  pictureUrl: string;
}

async function initializeLiff() {
  try {
    await liff.init({ liffId: CONFIG.liffId });
    console.log('LIFF initialized successfully.');

    if (!liff.isLoggedIn()) {
      liff.login();
    }
  } catch (error) {
    console.error('LIFF initialization failed:', error);
    alert('LIFF initialization failed. Please reload the page.');
  }
}

export const LiffUserInfo = () => {
  const [profile, setProfile] = useState<UserProfile | null>(null);

  useEffect(() => {
    async function fetchProfile() {
      try {
        await initializeLiff();

        if (liff.isLoggedIn()) {
          const userProfile = await liff.getProfile();
          setProfile({
            displayName: userProfile.displayName,
            pictureUrl: userProfile.pictureUrl || 'https://via.placeholder.com/150', // デフォルト画像
          });
        }
      } catch (error) {
        console.error('Failed to fetch profile:', error);
      }
    }

    fetchProfile();
  }, []);

  if (!profile) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <Typography sx={{ ml: 40, mt: 3 }}>{`ログイン成功:${profile.displayName}`}</Typography>
    </div>
  );
};

//src/auth/context/jwt/auth-provider.tsx
'use client';

import { useSetState } from 'minimal-shared/hooks';
import { useMemo, useEffect, useCallback } from 'react';
import liff from '@line/liff';
import { CONFIG } from 'src/global-config';
import { AuthContext } from '../auth-context';

import type { AuthState } from '../../types';

// ----------------------------------------------------------------------

/**
 * NOTE:
 * Adjusted to use LIFF authentication instead of JWT-based authentication.
 */

type Props = {
  children: React.ReactNode;
};

export function AuthProvider({ children }: Props) {
  const { state, setState } = useSetState<AuthState>({ user: null, loading: true });

  const checkUserSession = useCallback(async () => {
    try {
      // LIFF 初期化
      await liff.init({ liffId: CONFIG.liffId });
      console.log('LIFF initialized successfully.');

      if (!liff.isLoggedIn()) {
        liff.login();
        return; // ログイン後にリロードされる
      }

      // ユーザープロフィール取得
      const userProfile = await liff.getProfile();

      setState({
        user: {
          displayName: userProfile.displayName,
          pictureUrl: userProfile.pictureUrl || 'https://via.placeholder.com/150', // デフォルト画像
        },
        loading: false,
      });
    } catch (error) {
      console.error('Failed to initialize or fetch LIFF profile:', error);
      setState({ user: null, loading: false });
    }
  }, [setState]);

  useEffect(() => {
    checkUserSession();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ----------------------------------------------------------------------

  const checkAuthenticated = state.user ? 'authenticated' : 'unauthenticated';

  const status = state.loading ? 'loading' : checkAuthenticated;

  const memoizedValue = useMemo(
    () => ({
      user: state.user,
      checkUserSession,
      loading: status === 'loading',
      authenticated: status === 'authenticated',
      unauthenticated: status === 'unauthenticated',
    }),
    [checkUserSession, state.user, status]
  );

  return <AuthContext.Provider value={memoizedValue}>{children}</AuthContext.Provider>;
}

package.json

{
  "scripts": {
    "dev": "next dev",
    "dev:https": "next dev --experimental-https",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }

と、package.jsonのsctiptsに追加しておけば、

yarn dev:https

で、開発サーバをhttpsを立ち上げることができます。めちゃめちゃ便利です。

ローカルホストにアクセスすると、下記のように認証を求められるようになります。

ログインした後、しっかり名前も引用できています。

LINEビジネスのリッチメニューにて、表示期間と画像を設定し、このパターンのテンプレートを選択します。

今まで全てローカルで開発してきましたので、一旦は弊社のホームページを仮置きします。

友達追加してメニューを押すと、URLにアクセスできるようになりました。

では、「ここから体を良くしたい一般ユーザー用のページ」と、「フィットネス知識を深めたい同業者(育成目的)用のページ」をそれぞれ開発し、ここにつなげます。

ここから体を良くしたい一般ユーザー用のページの開発

まずは、第六話にて金融機関マニュアルのインプットを行なっていましたので、それをフォーゲットします。

DELETE FROM public."questionAnswer";

2つの属性の質問と回答が発生しますので、generalのような属性を保存できるように、データベースを改造します。

model questionAnswer {
  id         Int      @id @default(autoincrement())
  question   String
  answer     String
  pdf_name   String
  pdf_url    String?
  created_by String
  updated_by String
  approved   Boolean  @default(true)
  embedding  Bytes
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
  attribute  String? // 新しく追加されたフィールド(nullableなテキスト型)
}
npx prisma migrate dev --name add_text_field_to_questionAnswer

OneのメニューからPDFを投入した時は、一般ユーザー属性とし、Twoから投入した時は、プロフェッショナル属性となるようにします。

フロントエンドの更新

// src/components/page-one/quiz.tsx
const handleSubmit = async () => {
    setIsSubmitted(true);

    // URLの末尾が"two"の場合はattributeを"professional"に、それ以外は"general"に設定
    const path = window.location.pathname.replace(/\/$/, ''); // 末尾のスラッシュを取り除く
    const attribute = path.endsWith('two') ? 'professional' : 'general';

    try {
      const response = await fetch('/api/preserve', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          attribute, // 設定したattributeを送信
          questions,
          userAnswers: answers,
          pdf_name: pdfName,
        }),
      });

      if (!response.ok) {
        throw new Error('Failed to submit quiz results.');
      }

      mutate('/api/preserve', {
        questions,
        userAnswers: answers,
        pdf_name: pdfName,
      });
    } catch (error) {
      console.error(error);
    }
  };

バックエンドの更新

// src/app/api/preserve/route.ts
try {
        const body = await req.json(); // リクエストボディを解析

        const { questions, userAnswers, pdf_name, approved, attribute } = body; // pdf_name, questions, userAnswers, embedding, attribute を受け取る

        // 必須フィールドのチェック
        if (!questions || !Array.isArray(questions) || questions.length === 0) {
            throw new Error("Missing or invalid 'questions' field");
        }

        if (!pdf_name || pdf_name.trim() === "") {
            throw new Error("Missing 'pdf_name' in the request body");
        }

        // userAnswers の存在と対応チェック
        if (!userAnswers || !Array.isArray(userAnswers) || userAnswers.length !== questions.length) {
            throw new Error("The number of answers must match the number of questions");
        }

        // approvedが渡されていない場合は true を設定
        const isApproved = approved !== undefined ? approved : true;

        // attributeの検証('general' または 'professional')
        if (!attribute || (attribute !== 'general' && attribute !== 'professional')) {
            throw new Error("Invalid 'attribute' value. Must be 'general' or 'professional'.");
        }

        // 各質問と回答に対してデータベースに保存
        const savedAnswers = await Promise.all(
            questions.map(async (questionObj, index) => {
                const { question, answer } = questionObj;

                if (!question || !answer) {
                    throw new Error(`Missing required fields for question ${index + 1}`);
                }

                // 埋め込み生成
                const embedding = await generateEmbedding([{ question, answer }]); // 修正: 配列として渡す

                // 直接Uint8Arrayを作成
                const embeddingUint8Array = new Uint8Array(embedding);

                const newData = await prisma.questionAnswer.create({
                    data: {
                        question,
                        answer,
                        pdf_name: pdf_name, // 外部で取得した pdf_name を使用
                        pdf_url: null, // 必要に応じて設定
                        created_by: "system_admin",
                        updated_by: "system_admin",
                        approved: isApproved, // approvedが渡されていない場合はtrue
                        embedding: embeddingUint8Array, // embeddingがない場合は空のUint8Arrayとして保存
                        attribute, // 新たに attribute を保存
                    },
                });

                return newData;
            })
        );

        return NextResponse.json(savedAnswers, { status: 201 });
    }

では、以下をインプットに走らせてみます。

https://bletainasushome.s3.ap-northeast-1.amazonaws.com/%25E5%2581%25A5%25E5%25BA%25B7%25E5%2595%2593%25E7%2599%25BA%25E6%2595%2599%25E6%259D%25902021-%25E9%25AB%2598%25E6%25A0%25A1%25E7%2594%259F%25E7%2594%25A807%25EF%25BC%258D09.pdf

わずか6ページのPDFに対して30もQ&Aを作るように依頼すると、かなり重複してしまっているようです。

ただ、今回はattributeがメインですので、このコントロールはまた別の機会とさせていただきます(時間節約のため設定値を10にしておきます)。

プロフェッショナルフィットネス用の開発

次に、Page Twoから、フィットネス知識を深めたい方向けのQ&Aを生成します。

https://bletainasushome.s3.ap-northeast-1.amazonaws.com/%25E5%258E%259A%25E7%2594%259F%25E5%258A%25B4%25E5%2583%258D%25E7%259C%2581e-%25E3%2583%2598%25E3%2583%25AB%25E3%2582%25B9%25E3%2583%258D%25E3%2583%2583%25E3%2583%2588.pdf

分割して、インプットすることに成功しました。

次は、このattributeに合わせて回答分岐させます。

// フロントエンド src/components/page-one/chatbot.tsx
// URLパスから attribute を判定
  const path = window.location.pathname.replace(/\/$/, ''); // 末尾のスラッシュを取り除く
  const attribute = path.endsWith('two') ? 'professional' : 'general';

  const handleSendMessage = async (message: string) => {
    setMessages((prevMessages) => [...prevMessages, { content: message, type: 'sent' }]);

    try {
      const response = await fetch('/api/chatbot', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ inputValue: message, attribute }), // 判定した attribute を送信
      });

      const data = await response.json();

      if (data.error) {
        setMessages((prevMessages) => [
          ...prevMessages,
          {
            content: '申し訳ありませんが、処理中にエラーが発生しました。',
            type: 'received',
          },
        ]);
      } else {
        if (data.promptForAnswer) {
          // If promptForAnswer is true, switch to the "provide answer" mode
          setIsAnswerMode(true);
          setMessages((prevMessages) => [
            ...prevMessages,
            {
              content: '自信のある回答が見つかりませんでした。正しい答えを教えてください。',
              type: 'received',
            },
          ]);
        } else {
          setMessages((prevMessages) => [
            ...prevMessages,
            {
              content: data.answer || data.closestQuestion || '回答を取得できませんでした。',
              type: 'received',
              score: data.score,
            },
          ]);
        }
      }
    } catch (error) {
      console.error('Error in API request:', error);
      setMessages((prevMessages) => [
        ...prevMessages,
        {
          content: '申し訳ありませんが、エラーが発生しました。',
          type: 'received',
        },
      ]);
    }
  };

  const handleSubmitAnswer = async () => {
    if (correctAnswer.trim()) {
      // Create a combined question string
      const previousQuestion = messages
        .filter((msg) => msg.type === 'sent')
        .map((msg) => msg.content)
        .join(' ');

      const combinedQuestion = `前回の質問: ${previousQuestion} 今回の質問: ${correctAnswer}`;

      // Add a message indicating the correct answer submission
      setMessages((prevMessages) => [
        ...prevMessages,
        {
          content: `回答承認依頼をしました。質問:「${combinedQuestion}」回答:「${correctAnswer}`,
          type: 'received',
        },
      ]);

      setIsAnswerMode(false); // Exit "provide answer" mode
      setCorrectAnswer(''); // Clear the answer field

      // Send the combined question to the server with approved=false
      const response = await fetch('/api/preserve', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          questions: [{ question: combinedQuestion, answer: correctAnswer }],
          userAnswers: [correctAnswer],
          pdf_name: 'Sample PDF',
          approved: false, // Set approved to false on the server
          attribute: attribute,
        }),
      });

      const result = await response.json();
      if (!response.ok) {
        setMessages((prevMessages) => [
          ...prevMessages,
          {
            content: `回答承認依頼の送信に失敗しました: ${result.error}`,
            type: 'received',
          },
        ]);
      }
    } else {
      setMessages((prevMessages) => [
        ...prevMessages,
        { content: '答えを入力してください。', type: 'received' },
      ]);
    }
  };
    
//  バックエンド src/api/chatbot/route.ts
const { inputValue, attribute } = body;

        if (!inputValue) {
            return NextResponse.json({ error: 'Missing input' }, { status: 400 });
        }

        // Fetch approved question-answers only
        const questionAnswers = await prisma.questionAnswer.findMany({
            where: {
                approved: true, // Fetch only approved entries
                attribute, // Filter by attribute
            },
        });

一般用のメニューでは、プロフェッショナルの質問をしても正しい答えを求めるようになりました。逆に、プロフェッショナル用では、回答を適切にしてくれます。

今までローカルで開発をしてきましたので、こちらをウェブ上に公開し、リッチメニューと接続させます。

yarn build

// エラー1
src/app/api/qa/route.ts
Type error: Route "src/app/api/qa/route.ts" does not match the required types of a Next.js Route.
  "createStore" is not a valid Route export field.

// utils/qa-store
import { MemoryVectorStore } from 'langchain/vectorstores/memory';
import { OpenAIEmbeddings } from 'langchain/embeddings/openai';

export const createStore = (docs: any[]) =>
    MemoryVectorStore.fromDocuments(docs, new OpenAIEmbeddings());

// src/app/api/qa/route.ts
import { createStore } from '@/utils/qa-store';

const docsFromPDF = async (fileBuffer: Buffer) => {
    const blob = new Blob([fileBuffer], { type: 'application/pdf' }); // PDFファイルのMIMEタイプを指定
    const loader = new PDFLoader(blob);
    return loader.loadAndSplit(
        new CharacterTextSplitter({
            separator: '. ',
            chunkSize: 2500,
            chunkOverlap: 200,
        })
    );
};

// エラー2
./src/components/ui/card.tsx:3:20
Type error: Cannot find module '@/lib/utils' or its corresponding type declarations.

  1 | import * as React from "react"
  2 |
> 3 | import { cn } from "@/lib/utils"
    |                    ^
  4 |
  5 | const Card = React.forwardRef<
  6 |   HTMLDivElement,

// lib/utils
export function cn(...classes: (string | undefined | null | false)[]) {
    return classes.filter(Boolean).join(' ');
}

ビルド時にエラーが発生しましたので、修正しておきます。

新しいスターターキットから一度もコミットしてませんでしたので、リポジトリを作成し、コミットしておきます。

必要なものは.gitignoreに追加しておいてください。


git init
git add .
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:takayanomasashi2/aichatbot.git
git push -u origin main

vercelで環境変数(OPENAI_API_KEY・DATABASE_URL・NEXT_PUBLIC_LIFF_ID)をセットし、デプロイします。データベースはローカルからRDSに変更しておきます。

デプロイエラーしましたので、修正します。

デプロイが成功しました。

ローカルではなくURLでアクセスできるようになりましたので、Line DevelopersのエンドポイントURLを更新します。

LINEビジネスのアカウントにURLをそれぞれインプットし直します。

これで左はプロ用で、右は一般用のAIチャットボットの完成です。

まとめ

LINEボット2刀流いかがでしたでしょうか?

これでまたレベルアップいたしましたね。少ないコードでも機能のスイッチングが意外と簡単にできることが伝わりましたら、幸いです。

それではまた、ごきげんよう🍀

1話
https://note.com/bletainasus/n/nab5840b01b4b
2話
https://www.bletainasus.com/dashboard/post/second-story/
4話
https://qiita.com/takayamasashi/items/38fbb23bd70cfc357046
5話
https://note.com/bletainasus/n/n1f86b01e91a7
6話
https://note.com/bletainasus/n/nf631567c8669

Discussion