マニアックな資格の勉強ができるサイトがないので作った

# 初めに
皆さん、資格は好きですか？
資格を持っていると仕事ができたり、評価されたりで資格を持っていること自体は好きな人は多いと思います。
しかし、マニアックな資格を取りたい場合、意外と情報がネットに転がってなく、勉強しづらいという経験をした方は多いと思います。
実際私は測量士の問題を調べようとしましたが測量士補しかなく、大変苦労しました。
そこでLLMを活用し、資格の勉強をしやすくするサービスを開発しました。

# どんなもの？
https://skill-passport.vercel.app/
このサービスはLLMを活用し、資格の問題を自動生成するものです。
ホスティングサービスはvercel、バックエンドなどはFirebaseを活用することで開発コストをできる限り下げています。
またフロントエンドのライブラリは個人的趣味＆広告を表示するのにいろいろと面倒だったためSvelteKitを使用しています

# 苦労した点
LLMを活用する際に問題となるのはプロンプトのチューニングではないでしょうか？
例えば資格の項目を生成するプロンプトだとして
```
`${qualification}を取得するロードマップを作成し、ロードマップの項目のみをjsonの配列形式で列挙してください。また階層構造は持たないようにしてください。
```
というプロンプトと
```
`${qualification}を取得するのに必要な知識を網羅した試験項目を作成し、試験項目の項目のみをjsonの配列形式で列挙してください。また階層構造は持たないようにしてください。
```
というプロンプトでは生成内容が違います。（このサービスでは後者を活用しています）
またほかにもsvelteKitでFCMを用いて通知を行う方法などに関してはまだ知見が少ないと思われます。
今回の、サービス開発で得られた知見をいくつか共有させていただきたいと思います

## プロンプトエンジニアリング編
### まずは目的から
プロンプトをいじればそれっぽい結果が作成されるため、プロンプトの作成はある程度雑になりがちです。
今回のサービスではとりあえずロードマップを作製したらいいだろという考えで
```
`${qualification}を取得するロードマップを作成し、ロードマップの項目のみをjsonの配列形式で列挙してください。また階層構造は持たないようにしてください。
```
というプロンプトを作成しましたが、何回か実験するにつれこれは本当に大丈夫なのか？という疑念が深まっていきました。
それもそのはずで今回のサービスは"試験に沿った問題を生成するサービス"であり"試験で資格を取るためのロードマップをもとに知識を付けるサービス"ではなかったからです。
このように目的に応じたプロンプトの開発が必要であり、目的を正しく把握するというソフトスキルがプロンプトエンジニアリングでは特に必要になります。

### ちゃんと評価しよう
プロンプトをいじる際に、皆さん感覚で評価していませんか？
なんとなくよさそう、なんとなくダメっぽそう
そのような評価を行っていることが多いと思われます、しかしプロンプトエンジニアリングに際し定量的な評価が行えないと改善方法の施策を立てられず暗中模索することになりがちです。
なのでプロンプトはきちんと評価を行いましょう。
今回のサービスでは試験の項目を生成する際に、いくつかの試験をピックアップし、その項目とのexact match ratioをもとに評価を行いました。ただし項目が一致しているかの評価はLLMを活用し行っています。
この方法により感覚的に正しいという評価からexact match ratioの数値が向上しているから正しいという評価に切り替えることができ、評価の施策方針が感覚的評価による行き当たりばったりな方針から定量的な再現性のある方針へ切り替えることができたと感じています。
ほかにも指標としては様々なものがあり、LLMの評価に関しては問題にあった指標を選ぶことが重要になりますが、目的にあった指標を活用することで再現性のある改善が見込めるため、感覚的ななんとなく正しいようなプロンプトエンジニアリングから定量的な再現性のあるプロンプトエンジニアリングへの転換が必要になるのではと感じました。

## SvelteKit編
### FCMの導入
svelteKitではsrc/service-worker.jsでservice-workerを導入することができます。
しかしFCMではfirebase-messaging-sw.jsという名前のservice-workerを導入する必要があります。
そこでfirebase-messaging-sw.jsをstaticフォルダに作成することを考えます。
その後、+layout.svelteで
```
if ('serviceWorker' in navigator) {
	navigator.serviceWorker
		.register('/firebase-messaging-sw.js')
		.then((registration) => {
			console.log('Service Worker registration successful with scope: ', registration.scope);
		})
		.catch((err) => {
			console.log('Service Worker registration failed: ', err);
		});
}
```
と読み込むことでfirebase-messaging-sw.jsを読み込ませることに成功しました。
あとはFCMを使えるようにfirebase-messaging-sw.jsを
```
importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-messaging-compat.js');

const firebaseConfig = {
    // configを入れる
};

firebase.initializeApp(firebaseConfig);

const messaging = firebase.messaging();

messaging.onBackgroundMessage(function (payload) {
    console.log('[firebase-messaging-sw.js] Received background message ', payload);
    const notificationTitle = payload.notification.title;
    const notificationOptions = {
        body: payload.notification.body,
    };

    self.registration.showNotification(notificationTitle,
        notificationOptions);
});

self.addEventListener('notificationclick', e => {
    e.notification.close()
    e.waitUntil(clients.openWindow(e.notification.data))
})
```
とし、+layout.jsに
```
import { getMessaging, onMessage } from 'firebase/messaging';
onMessage(messaging, (payload) => {
    // Add the new message to the messages array
    messages.push(payload);

    // Display the message content as a toast notification
    toast.success(`${payload.notification?.title}}(foreground)`, {
        body: payload.notification.body,
        data: payload.notification.click_action // これを追加
    });
});
```
を、firebase functionsに
```
const {onRequest} = require("firebase-functions/v2/https");
const logger = require("firebase-functions/logger");
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

const db = admin.firestore();

// テスト通知を送信するためのHTTPリクエスト対応関数
exports.sendTestNotification = onRequest(async (req, res) => {
    const { userId, message } = req.query;

    if (!userId || !message) {
        return res.status(400).send('userId and message parameters are required.');
    }

    try {
        // 指定されたユーザーにテスト通知を送信
        const userSnapshot = await db.collection('users').doc(userId).get();
        if (!userSnapshot.exists) {
            return res.status(404).send('User not found.');
        }

        const userData = userSnapshot.data();
        const payload = {
            notification: {
                title: 'Test Notification',
                body: message,
                click_action: 'https://example.com'  // クリック時に開くURLを指定
            }
        };

        await admin.messaging().sendToDevice(userData.deviceToken, payload);
        res.status(200).send('Test notification sent successfully.');
    } catch (error) {
        logger.error('Error sending test notification:', error);
        res.status(500).send('Error sending test notification.');
    }
});
```
を追加することで送信周りの実装が完了しました。
またindexページなどに
```
	const initializeMessagingAndStoreToken = async (uid) => {
		try {
			const messaging = getMessaging(app);
			console.log(messaging);

			const permission = await Notification.requestPermission();
			if (permission === 'granted') {
				getToken(messaging, {
					vapidKey
				})
					.then(async (fetchedToken) => {
						// Store the received token
						const deviceToken = fetchedToken;
						if (deviceToken) {
							await setDoc(doc(db, 'users', uid), {
								deviceToken: deviceToken
							});
							console.log('Device token saved to Firestore:', deviceToken);
						}
					})
					.catch((error) => {
						// Handle any errors in fetching the token
						console.error('Error fetching token:', error);
					});
			}
			// } else {
			// 	console.error('Unable to get permission to notify.');
			// }
		} catch (error) {
			console.error('Error getting or saving device token:', error);
		}
	};
```
という関数を追加することでuserごとにusersコレクションにuidをドキュメントidに持つドキュメントを作成し、このドキュメントにデバイスtokenを保存することでuserごとにメッセージを送信できるようにしました。

### PWA対応
意外と面倒なのがPWA対応
svelteKitでは@vite-pwa/sveltekitを導入することでPWA化できますが、一つ落とし穴が存在しました。
それはmanifest.jsonではなくmanifest.webmanifestでなければいけないことです。（私はこれで3時間無駄にしました）
後の設定は簡単なので、ここさえ注意したら簡単にPWA化できると思います。

### スマホのwebユーザーをpwaのインストールページに無理やり飛ばす。
pwa良いですよね、スマホでpush通知が遅れるのは大変な魅力です。
しかし、pwaは導入してもらうのに大変です、そこでpwaのインストールページを作成し、そこでインストールしてもらうことにしました。
そのためにまずはモバイルであるかを判断する必要があります、そこで以下の関数を作成しました
```
function isMobileDevice() {
    const userAgent = window.navigator.userAgent || window.navigator.vendor || window.opera;
    // ユーザーエージェントに含まれる文字列をチェック
    return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
}
```
この関数によりユーザーエージェントベースでモバイルかどうかを判断することができました。
次にこの関数を用いてpwaからのアクセスでない場合、/install-pwaに遷移するようにしましょう
```
if (typeof window !== 'undefined') {
        const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone;
        const isMobile = isMobileDevice();
        console.log(isMobile, navigator.userAgent);
        //alert(!isStandalone +" : " + isMobile + " : " + navigator.userAgent)
        const isInstallPage = window.location.pathname === '/install-pwa';

        // スマホデバイスかつPWAとしてインストールされていない、かつ現在のページが /install-pwa でない場合にリダイレクト
        if (!isStandalone && isMobile && !isInstallPage) {
                window.location.href = '/install-pwa'; // 'install-pwa'はPWAインストール案内ページ
        }
    }
```
重要なのはisStandaloneでもしpwaの表示モードがStandaloneである場合、window.matchMedia('(display-mode: standalone)').matchesでStandaloneであるか否かをもとにpwa化を判断しています。
こうすることでpwa以外からのアクセスを/install-pwaにリダイレクトすることでpwaをインストールさせ、通知を送れるようにしています。


### https化
普通にFCMを使ったサービスをnpm run devしようとすると失敗します、なぜならnpm run devで生成されるurlは"http"://localhost:~であり"https"ではないからです。
そこで導入したいのが@vitejs/plugin-basic-sslです、これはvite.config.jsに
```
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
import basicSsl from '@vitejs/plugin-basic-ssl';

export default defineConfig({
	plugins: [
		sveltekit(), 
		basicSsl(),
	],
	test: {
		include: ['src/**/*.{test,spec}.{js,ts}']
	},
	server: {
		https: true,  // HTTPS を有効にする
	  },
});
```
とするだけでhttps化してくれる優れものです。
FCMなどを導入する前はhttpsでないため500Errorが返ってきたりしましたが、@vitejs/plugin-basic-sslを導入したことによりそのようなerrorは帰ってこなくなりテストができるようになりました(通知などに関してはErrorが出ますが…)
もしFCMの設定などを行ったが500errorでサイトが開けない方はぜひ@vitejs/plugin-basic-sslの導入を進めてはいかがでしょうか

# このサービスでこれからやりたいこと
これからの改善点としては
* インプレッション型広告の導入
* 課金システムの導入(途中までできている)
* ~~バッジシステムの導入~~
  
などを考えています。
是非皆さん、応援のほどよろしくお願いいたします。

# 更新１(バッジシステムの導入)
バッジシステムを導入しました、一晩で作ったので雑かもしれませんが…
またゲットしたバッジをtwitter(僕は何があってもこう呼びます)で共有する機能も実装しました。
ジャンジャンゲットしてジャンジャン共有してください

# 更新2(連続ログイン日数を確認できるようになった)
連続ログイン日数がprofileで確認できるようになりました。
毎日ログインして、この記録を伸ばそう！