カテキョの生徒のために、英語読解問題生成サイトを作った

## はじめに
### 背景
バイトの家庭教師で、高校3年生の生徒に大学受験を指導しています。
その生徒は英語の素養がある一方、長文の読解速度・精度に課題を抱えていました。
一般に大学受験における英語は出題傾向として、西日本は精読重視、東日本は速読重視といわれていましたが、現代は共通テストとかいうので全国民が **速読** の能力が必要になりました。
**速読** の能力を底上げするにはとにかく **演習量** を通じて「問題で聞かれている箇所だけ目を通す」訓練を内面化することかと思います。

### やったこと
演習量が欲しいので、今驚きのLLMを使って無限に英語読解問題を生成するサイト[「EnglishExamForever」](https://englishexamforever.pages.dev/#/)をサーバーからフロントまで一人で開発しました。

[生徒思いの王、生徒思いキング](https://qiita.com/uts1_6/items/11e348ead4bae71571b8)ですね。

動作動画はこんな感じです。

<iframe width="560" height="315" src="https://www.youtube.com/embed/fOkzlC_pk98?si=Yzn1iyTRxJZWl00l" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

長文をつっこむと、パラグラフごとのTF問題（正誤判定問題）が生成されるってわけですね。

嬉しさとして
- 10題程度しかない1600円の問題集を積まなくともよい
    - 質より量重視！
- 自分な好きな（あるいは苦手な）ジャンルの文章で読解問題ができる
    - 生徒はHCI分野に興味があるというので、HCI系の文章を引っ張ってきて、読解させるついでに分野を紹介しています。（~~そこらへんの高校の進路指導教員は大学入学以後について何も情報提供しないので~~）
- 文章の内容により難易度を調整しやすい
    - めっちゃ難しくしたいなら[デカルトの「方法序説」](https://www.gutenberg.org/files/59/59-h/59-h.htm)がオススメです。

ちなみに生成はGoogleの`Gemini`です。

## アプリ仕様
### 本質部分
- 問題生成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/425d122b-965e-6f3e-9236-d2bfb38fbb87.png)
本文を入れ込むテキストボックスと、問題設定。
現在はまだTF問題の数調整しか用意していないですね。

- ボタンを押すと生成開始
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/e8191054-d43c-d394-d58e-bccfc67299ce.png)

- サーバーから問題が送られてくると問題開始ボタン
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/b2eac6d1-c039-9abe-300d-d6436dc110ae.png)

- 問題画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/2158d2fc-9519-2268-64f0-a3058ae8c325.png)
（もちろん縮小しています）
左半分が出題文で、右半分が問題文ですね。
ちなみに出題文はデカルト「方法序説」です。
    - 焦らせる1minsec単位のストップウォッチ
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/8d61b11e-e674-8502-e9b9-e6b875c75ed5.png)
    焦りますね
    - フォント調節ボタン
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/e22e031d-4d5c-40dc-cce3-88dcf0ad8527.png)
    でっか
    - 配置変更トグル（右上）
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/37a700bf-bcaf-c854-8d32-77f2866e7fa3.png)
    スマホなどの縦長スクリーン用の配置に。
    - 採点
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/97e19f30-c3c8-84c2-700c-5e7ab5f41b42.png)
    一番下の採点ボタンを押すと、インターフェースが変わり採点結果が出てきます。
    正答データは生成時に同時に`Gemini`に吐かせているので解く前からもうすでにデータとして持っているわけですね。
    **※画像はランダムに解答しただけで、著者の英語読解能力を示すものではありません。**

### ユーザー関係
不特定多数に生成されると`Gemini`料金で破産するので、会員制にしています。
- ログイン機能
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/2f577095-0a85-1467-d85f-d5a216361bf7.png)
  ページ遷移です。どこでもよくみるインターフェースですね。（`AWS Cognito`のデフォルトですからね）
- トークン制
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/94d8d3e9-2145-5cca-0718-dae552816388.png)
  維持費があり、貧困学生ごときが無料配布できるはずもないので、月額100円頂いています。
  
:::note
同人文化の **頒布** の体をとっています。
:::

- Mosh自動化
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/3b504390-ee83-35bf-cdad-05c90953926e.png)
トークン販売を[Mosh.jp](Mosh.jp)にて行っておりますが、Moshからのメールを`GAS`で自動検知してトークン発行し、購入ユーザーにメールを送っています。
[業務効率化の王、業務効率化キング](https://qiita.com/uts1_6/items/11e348ead4bae71571b8)ですね。

### その他
- トップページの操作説明
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/05a20dad-31e9-78f8-3286-b4f678108d5f.png)
わかってくれ～

- 多言語対応
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/1debb10b-143e-e0f4-3abb-574b9fad9615.png)
お気持ち程度の。

### 効能
#### 想定運用
想定している使い方としては、速読訓練の内面化を目指してほしく、
- 問題文を読み、必要な情報を念頭に入れ、
- その情報を検索するように出題文を走査する

という試験用の読解プロセスを体得してほしいのですね。（参考: [東大入試のノリで解いてデータベーススペシャリストを攻略した話](https://qiita.com/konbraphat51/items/9f5350448b54d802675f)）
なので、気持ちとしては、**ストップウォッチのタイムが早ければ早いほど偉い（満点前提で）** という気持ちでやってほしいなという。

#### 質
問題の質ですが、割と本文そのままの内容が聞かれます。ここはプロンプトエンジニアリングの範疇かな？
例えば先ほどの画像の、
本文1行目が「Good sence is, of all things among men, the most equally distributed」とあり、
問題1問目が「Good sense is not equally distributed among men」
そのままですね。

一方で、`Gemini`がちょっとした機転を利かせてくれるときもあります。
こちらは著作物なのでみだりに転載しませんが（デカルトのものは著作権フリー）、
本文「A, B, **and then** C」というセンテンス構成に対して
問題文が「B has proceeded A」
のように、**and**が等位接続詞として働いているのでAとBは並列、そして**then**があるから時間的な前後関係がある、という文章理解を問うような問題を出してきました。

## 技術構成
### フロントエンド
`Vue.js`により静的サイトを作り、静的ならだいたい無料の`Cloudflare`でインターネッツに放出しています。
必要な情報はサーバーからAPI通信で動的に取得しているわけですね。

`npm`アンチなので、`SFC`構造を保ったまま`CDN`として記述しているので、デプロイはビルドせずにドラッグ&ドロップで済みます。
参考: [CDN版Vue3で単一ファイルコンポーネントを作る](https://qiita.com/konbraphat51/items/768c5c414fca988ffa97)

ファイル構成の一部
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/1f1f81b4-5d72-4d6d-a2f5-e545f92ea183.png)

コンポーネントコードの例
```vue
<template>
	<div class="ScoreDisplay">
		<div>
			<p>{{ scoreEarned }} / {{ scoreMax }}</p>
			<p>{{ scorePercentage }}%</p>
		</div>
	</div>
</template>

<script>
export default {
	name: "ScoreDisplay",
	props: {
		scoreMax: {
			type: Number,
			required: true,
		},
		scoreEarned: {
			type: Number,
			required: true,
		},
	},
	setup() {
		//set up i18n
		const {t} = VueI18n.useI18n()
		return {t}
	},
	computed: {
		scorePercentage() {
			let double = (this.scoreEarned / this.scoreMax) * 100
			return double.toFixed(2)
		},
	},
}
</script>

<style>
.ScoreDisplay {
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;

	border: 1px solid #000;
	border-radius: 0.5em;
	padding: 0.5em;
	margin: 0.1em;
	background-color: rgb(254, 176, 202);
}

.ScoreDisplay p {
	font-family: "Roboto", sans-serif;
	font-weight: 300;
	font-style: normal;
	font-size: large;
	margin: 0;
}
</style>

```

また、ログイン状態のセッション関係は`AWS Cognito`に依拠していますが、これが結構メンドウだったのでそのうち記事書きます。

### バックエンド
フロントエンドから直接ウチの`Gemini`を呼ばれては、APIキーがバレたり悪者に大量リクエストされたりで苦しいので、自分のサーバーでコントロールする必要があります。

[貧困の王、貧困キング](https://qiita.com/uts1_6/items/11e348ead4bae71571b8)なので、`AWS Lambda`のサーバーレスでいきます。

ちなみに、本アプリの構築をしながらこの記事を書きました: 
[【Go, AWS】低予算APIサーバー構築を最初から最後まで【Golang, Lambda, DynamoDB, API Gateway, Cognito】](https://qiita.com/konbraphat51/items/0ff0555dcdc538123d61)

まさにこの構成です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/c911bc05-a1f5-d159-abfb-11b4be0db4db.png)

ここで、`Lambda`を使って、`GCP`の`Gemini`を叩いているわけですね。

ちなみに`Golang`で`Gemini`叩く日本語ソースなかった記憶があるので、ここに書いておきます
```go:FFMFetcher.go
package LlmFetcher

import (
	"context"
	"fmt"
	"os"

	"github.com/google/generative-ai-go/genai"
	"google.golang.org/api/option"
)

func getApiKey() (string, error) {
	apikey := os.Getenv("GEMINI_KEY")
	if apikey == "" {
		return "", fmt.Errorf("API key is empty")
	}

	return apikey, nil
}


//make a Google AI client
func initializeClient() (*genai.Client, error) {
	ctx := context.Background()

	apikey, err := getApiKey()
	if err != nil {
		return nil, fmt.Errorf("error getting API key: %v", err)
	}

	client, err := genai.NewClient(ctx, option.WithAPIKey(apikey))
	if err != nil {
		return nil, fmt.Errorf("error creating client: %v", err)
	}

	return client, nil
}

//Fetch response from the LLM
func FetchLlm(prompt string) (string, error) {
	context := context.Background()

	client, err := initializeClient()
	if err != nil {
		return "", err
	}
	
	//fetch
	model := client.GenerativeModel("gemini-pro")
	resp, err := model.GenerateContent(context, genai.Text(prompt))
	if err != nil {
		return "", fmt.Errorf("error generating content: %v prompt(%v)", err, prompt)
	}

	client.Close()

	//get result
	result := ""
	for _, part := range resp.Candidates[0].Content.Parts{
		//convert to string
		_text, ok := part.(genai.Text)
		if !ok {
			continue
		}
		str := string(_text)

		result += str
	}

	return result, nil
}
```
Geminiはマルチモーダルモデルで、レスポンスはテキスト+画像の配列として設計されているので、最後に一つの`string`にするという操作が必要ですね。メンドウ。

## 最後に
もっと細かい技術詳細は小分けな記事にして放流していきます。

:::note
この記事に「いいね」をくだされば、泣いて喜びます！！！！！！！！
:::