在Unity中制作"AI会批改说出的英语"的英语日记应用 其二(备忘录)
前言
在其1中,我介绍了这款英语日记应用的批改、日记、连续记录与提醒机制,该应用能利用AI对使用者说出的英语进行批改。
写完其1之后,我增加了练习的种类。原因是我希望它能成为一款更有助于应对Versant考试的应用。(并且想在求职时宣称:我用自己开发的应用把英语练到了这种程度!!)
本文仅作为个人备忘。
这次我将介绍新增功能中,开发过程中觉得有趣和费心的部分。
- 上次的仓库:https://github.com/haru452/Learningapp
- 这次的仓库:与上次相同的仓库的 develop 分支(develop-words-book 是当让AI编写代码导致出错时的备份)。
新增的功能
| 画面 | 可以做的事 |
|---|---|
| 🎬 摘要 | 观看一段简短的英语视频,在限定时间内口头总结。由 AI 评分 |
| 🗣 话题 | 围绕题目进行讲述 → 评分 → 重新朗读修改后的句子 |
| 🔁 重复 | 听英文后复述(重复练习)。在 YouTube 视频中进行影子跟读 |
| ❓ 回答 | 用一句话简短回答(Versant 的短答题) |
| 👄 发音难点 | 统计无法发出的音素,每天将针对该音素的练习视频发送到 Discord |
| 📚 词汇表 | 单词卡。使用 FSRS 决定复习日期。可以通过图像进行联想记忆,还能观看单词出现时的视频片段。 |
Summary(总结)、Topic(主题)和 Answer(回答)是按照 Versant 的题型设计的。
\n整体结构(新增部分)
| 項目 | 所用工具 |
|---|---|
| 動画 | YouTube Data API、YouTube 嵌入式播放器 |
| 评分・转录・出题 | Gemini API(直接读取视频・直接输入音频) |
| 朗读 | Gemini 语音合成 (TTS) |
| 单词列表 | CEFR-J 单词表 (A1〜B2) |
| 辞書 | Merriam-Webster 学习者词典 API |
| 复习日程 | FSRS-4.5(自行移植至 C#) |
| 卡片图片 | Pixabay API |
| 例句与跟读文本 | Tatoeba |
1. 摘要
这是Versant摘要题的练习。会播放带有字幕的简短英语视频(40秒至2分半钟),看完后在60秒内发表摘要。
起初我考虑的是“去掉字幕,将字幕与自己的摘要进行对比并评分”,但后来改为直接将YouTube的URL提供给Gemini,让它观看视频并进行评分。
- 评分标准:内容50分 + 结构25分 + 语法/词汇25分
- 还会返回你掌握的重点、遗漏的点以及参考摘要
- 结果与上次一样,
responseSchema用于确定 JSON 的格式
GeminiApiClient.cs
// 公開されている YouTube 動画は URL をそのまま渡せる
// fps を指定すると、動画を読むコマ数を減らせる (指定しなければ Gemini の初期値)
private static string YouTubePart(string videoUrl, float fps = 0f) =>
"{\"file_data\":{\"file_uri\":\"" + HttpUtil.JsonEscape(videoUrl) + "\"}" +
(fps > 0f
? ",\"video_metadata\":{\"fps\":" + fps.ToString(CultureInfo.InvariantCulture) + "}"
: "") +
"}";
GeminiApiClient.Practice.cs
// 動画はプロンプトより前に置くのが推奨されている
string tail = "," + TextPart(prompt) + (wav != null ? "," + AudioPart(wav) : "");
// 要約の採点に必要なのは主に音声なので、映像は低画質・少ないコマ数 (2 秒に 1 コマ) で読ませてトークンを節約する
string innerJson;
try
{
innerJson = await GeneratePartsAsync(
YouTubePart(youtubeUrl, VideoFps) + tail, SummaryGradeSchema,
extraConfig: "\"mediaResolution\":\"MEDIA_RESOLUTION_LOW\",\"temperature\":0.2");
}
catch (HttpException e) when (e.StatusCode == 400)
{
// この設定が受け付けられなかったときは、普通の送り方でやり直す
innerJson = await GeneratePartsAsync(YouTubePart(youtubeUrl) + tail, SummaryGradeSchema);
}
如果以每秒1帧的速度让模型阅读视频,即使是2分钟的视频也会消耗大量token。在总结评分中,由于声音比画面更重要,因此将video_metadata.fps设为0.5,并将mediaResolution也调低。
2. 主题
流程为:题目 → 思考时间 → 说话45~60秒 → 评分 → 重说。
在第1部分中,是“先用终端的语音识别转成文字,再发送给 Gemini”,但在 Topic 中则是把录音直接交给 Gemini,在一次请求中完成转写和评分。等待时间大约缩短了一半。(主观感受)
评分之后,有一个“Read aloud”环节,要求把修改后的句子大声朗读一遍。系统会逐词比较朗读的句子和修改后的句子,读对的单词标为绿色,没读对的单词标为红色。比较方式沿用了第1部分中 Discord 下划线所用的同一种 LCS(按单词单位的差分)。
将常见错误分为 10 类进行计数
把日记、摘要和题目中被纠正的地方,交给 Gemini 按“冠词、时态、介词……”等 10 种类型分类,并存储到 Supabase 中。
出现较多的错误,会在下次讲题目之前作为“今日注意事项”展示出来,评分时也会重点检查。
3. Repeat:跟读与影子练习
重复练习
在不看英文的情况下朗读并复述。输出的句子顺序如下:
- 上次正确率低于80%的句子
- 在个人日记中被修正的句子
- Tatoeba 中的句子
关键在于输出自己的句子,即反复说出“自己实际犯错的句子”。
声音方面,Tatoeba 的句子使用真人录音,自己的句子则使用 Gemini 语音合成。
影子跟读
一边播放 YouTube 视频,一边录音,并将录音与参考文本进行对比。
- 参考文本仅由 Gemini 根据首次视频的 URL 生成并保存到设备中。
- 将录音按每1分钟分段,由 Gemini 进行转录并与参考文本核对。
- 即使发送失败,录音仍会保留,可通过“Check again”从失败处重新开始。
4. 回答
这是Versant的“用一句话简短回答”题型。让Gemini生成10道题,每次只通过语音呈现一题,并立即作答。
最初是在按下Start后才开始出题,因此每次都要等待直到开始令人不满。于是改为预先制作题目。
- 打开应用后,在后台生成10道题并保存到终端设备上
- 按下Start时从预制的题目中取出,因此可以立即开始
- 使用过的题目会在后台补充生成
- 如果中途停止,未作答的题目会返回到预制队列中
- 如果 Gemini 繁忙无法生成,则每隔 1 分钟最多重试 5 次
ShortAnswerStock.cs
/// <summary>作り置きから最大 n 問を取り出す (残りは保存したまま)</summary>
public static List<ShortAnswerQuestion> Take(int n)
{
var all = Load();
var (taken, rest) = Split(all, n);
Save(rest);
return taken;
}
/// <summary>作り置きが足りなければ、裏で作り足す (同時に動くのは 1 つだけ)</summary>
public static Task FillAsync(GeminiApiClient client, Func<IList<string>> recent)
{
if (!_filling.IsCompleted) return _filling;
if (client == null || Count >= Size) return Task.CompletedTask;
_filling = DoFillAsync(client, recent);
return _filling;
}
private static async Task DoFillAsync(GeminiApiClient client, Func<IList<string>> recent)
{
try
{
var have = Load();
// 最近出した問題と作り置きの問題は、もう作らないように伝える
var avoid = have.Select(q => q.Question).Concat(recent?.Invoke() ?? new List<string>()).ToList();
var made = await client.GenerateShortAnswerQuestionsAsync(avoid);
Save(Merge(Load(), made)); // 作っている間に取り出された分も考えて、読み直してから足す
_retries = 0;
}
catch (Exception)
{
// Gemini が混んでいるときなどは、1 分おきに作り直す (最大 5 回)
if (_retries < MaxRetries)
{
_retries++;
_ = RetryLaterAsync(client, recent);
}
}
}
按下 Start 时,会从预存中取出答案,并立即在后台补充生成。
ShortAnswerScreen.cs
// 作り置きがあれば待たずに始める。足りなければ、作っている途中のものを待つ
var set = ShortAnswerStock.Take(GeminiApiClient.ShortAnswerCount);
if (set.Count < GeminiApiClient.ShortAnswerCount)
{
await ShortAnswerStock.FillAsync(_questions, LoadRecent);
set = ShortAnswerStock.Merge(set, ShortAnswerStock.Take(GeminiApiClient.ShortAnswerCount - set.Count));
}
_set = set;
SaveRecent(_set.Select(q => q.Question));
_ = ShortAnswerStock.FillAsync(_questions, LoadRecent); // 使った分を裏で作り足す
FillAsync 如果正在运行中,则返回其 Task,因此即使“打开时的补充生成”与“Start 的补充生成”重叠,也不会向 Gemini 请求两次。
为了避免连续出现相同问题,在生成新问题时,会将“预存中的问题”和“最近输出的 60 题”提供给 Gemini,请求生成不与之重复的问题。
5. 词汇表:单词卡
使用 FSRS 决定复习日期
单词卡使用 Anki 中内置的 FSRS-4.5 来决定下一次复习日期。评分分为 Didn't know / Hard / Knew it / Easy 四个等级,将“有90%概率能记住的那天”设为下一次复习日。
我将公式移植到 C#,并通过测试验证其计算结果是否与官方实现一致。
Fsrs.cs
private const double Decay = -0.5;
private const double Factor = 19.0 / 81.0;
/// <summary>最後に復習してから elapsedDays 日後に思い出せる確率</summary>
public static double Retrievability(double elapsedDays, double stability) =>
Math.Pow(1 + Factor * Math.Max(0, elapsedDays) / Math.Max(stability, 0.01), Decay);
/// <summary>想起率が retention (0.9) まで下がる日数 = 次の復習までの間隔</summary>
public static int IntervalDays(double stability, double retention)
{
double days = stability / Factor * (Math.Pow(retention, 1 / Decay) - 1);
if (double.IsNaN(days) || days < 1) return 1;
if (days > MaxIntervalDays) return MaxIntervalDays;
return Math.Max(1, (int)Math.Round(days, MidpointRounding.AwayFromZero));
}
/// <summary>2 回目以降の評価のあとの記憶 (1 = Didn't know, 2 = Hard, 3 = Knew it, 4 = Easy)</summary>
public static FsrsMemory Next(FsrsMemory m, int rating, double elapsedDays)
{
int g = ClampRating(rating);
double s = Math.Max(m.Stability, 0.01);
double d = ClampD(m.Difficulty);
double r = Retrievability(elapsedDays, s);
double nd = ClampD(W[7] * InitialDifficulty(3) + (1 - W[7]) * (d - W[6] * (g - 3)));
double ns;
if (g == 1)
{
ns = W[11] * Math.Pow(d, -W[12]) * (Math.Pow(s + 1, W[13]) - 1) * Math.Exp(W[14] * (1 - r));
ns = Math.Min(ns, s); // 忘れたのに強くはならない
}
else
{
double hard = g == 2 ? W[15] : 1;
double easy = g == 4 ? W[16] : 1;
ns = s * (Math.Exp(W[8]) * (11 - d) * Math.Pow(s, -W[9]) * (Math.Exp(W[10] * (1 - r)) - 1) * hard * easy + 1);
}
if (double.IsNaN(ns) || ns < 0.01) ns = 0.01;
return new FsrsMemory { Stability = ns, Difficulty = nd };
}
W 直接使用了 FSRS-4.5 的标准参数(共17个)。Stability 是“记忆遗忘至90%所需天数”的参考值,因此若评分为 retention = 0.9,则下次间隔大约为 Stability 天。
输出的单词从 CEFR-J 单词列表中选择自己级别(A1〜B2)的单词。当已过期的复习单词超过 50 个时,不再输出新单词(以免复习积压过多)。
词典与例句
释义、发音和例句均取自 Merriam-Webster 的学习者词典。
对于像名词和动词这样有多个词性的单词,我们按词性分别给出例句。
通过图像想象来记忆
卡片背面还会显示单词的图片。这是因为我们认为,比起只靠日语释义来记忆,通过图片把握单词的形象更容易留下记忆。
图片是通过 Pixabay 的 API,直接用单词进行搜索获取的。
- 搜索开启安全搜索,按人气从高到低取前3条结果,使用排名第一的图片
- 将获取到的图片保存到设备,同一词汇不再向Pixabay重复查询(根据Pixabay的使用规则,也要求保存后使用)
- 未找到图片的词汇记录为“无图片”,30天内不再重新搜索
- 在查看卡片正面时预先获取背面的图片,翻面后即可立即显示
- 屏幕上显示图片是从Pixabay获取的
PixabayClient.cs
private const string ApiUrl = "https://pixabay.com/api/";
/// <summary>単語の画像を探す URL (セーフサーチ・英語で検索・人気順で 3 件)</summary>
public static string SearchUrl(string apiKey, string word) =>
ApiUrl + "?key=" + Uri.EscapeDataString(apiKey ?? "") + "&q=" + Uri.EscapeDataString(word ?? "") +
"&lang=en&image_type=all&safesearch=true&order=popular&per_page=3";
/// <summary>検索の応答から、いちばん上の画像の URL (webformatURL、幅 640 まで) を選ぶ。無ければ null</summary>
public static string PickImageUrl(string json)
{
if (string.IsNullOrEmpty(json)) return null;
object root;
try { root = MiniJson.Parse(json); }
catch (Exception) { return null; }
var hits = (root as Dictionary<string, object>)?.TryGetValue("hits", out var h) == true ? h as List<object> : null;
if (hits == null) return null;
foreach (var hit in hits)
{
if (!(hit is Dictionary<string, object> d)) continue;
string url = JsonGet.Str(d, "webformatURL");
if (url.Length > 0) return url;
}
return null;
}
然而,令人困扰的是,某些单词会生成不匹配的图片。
为此,对于不匹配的图片,点击 Hide 可以仅隐藏该单词。隐藏后,它会变成细的“Show image”行,再次点击即可重新显示。
发音检查
在 Say it 中说出单词时,终端会通过语音识别即时生成 Clear / Close / Try again。
同时还会进行录音,并在每次练习的最后阶段,通过一次请求将最多 20 个单词发送给 Gemini 进行评分。这是因为如果逐个单词发送,很快就会达到每日上限。
视频片段(类似 YouGlish 的功能)
从卡片中,您可以看到该单词在实际英语视频中使用的场景,并带有字幕。
这些片段通过两种方法收集,并存储在 Supabase 的表中。
- 在电脑上预先制作:Python 脚本会从指定频道的视频字幕中查找并插入单词场景
- 在应用中即时查找:对于第1步中未找到的单词,应用会搜索 YouTube 并从字幕中查找场景。找到的场景会添加到表格中,以便下次快速显示
此外,会在后台预先查找即将出现的20个单词的场景,这样点击按钮时即可立即查看,无需等待。
YouTube Data API 的每次搜索消耗100个单元,很容易达到每日上限(10,000个单元)。因此,应用内将使用次数限制为“每天最多30次”。
6. 按界面区分 Gemini API 密钥
随着功能增加,达到 Gemini 每日上限(429)的情况增多。
因此,日记、摘要、题目、朗读、影子跟读和单词卡等功能分别使用不同的 API 密钥,当某个密钥达到上限时,借用其他界面的密钥。
在单词卡画面中,在自己的密钥之后,将其他画面的密钥作为“借用密钥”传递出去。
VocabManager.cs
var secrets = ApiSecrets.Data;
string diaryKey = secrets.geminiApiKey;
var gemini = new GeminiApiClient(ApiSecrets.Pick(secrets.geminiVocabApiKey, diaryKey), geminiModel, geminiFallbackModel,
backupApiKeys: new[] { diaryKey, secrets.geminiSummaryApiKey, secrets.geminiTopicApiKey,
secrets.geminiTtsApiKey, secrets.geminiShadowApiKey });
GeminiApiClient 中,会按照“用自己的密钥尝试主模型 → 备用模型,如果仍达到上限,则用下一个密钥……”的顺序进行尝试。
GeminiApiClient.cs
/// <summary>自分のキー → ほかのキーの順に並べる。空・重複したキーは除く</summary>
public static string[] KeyList(string apiKey, string[] backupApiKeys)
{
var all = new List<string> { apiKey };
if (backupApiKeys != null) all.AddRange(backupApiKeys);
return all.Where(k => !string.IsNullOrWhiteSpace(k)).Select(k => k.Trim()).Distinct().ToArray();
}
// 429: 利用上限 / 404: モデル名が見つからない → 同じモデルではやり直さず、すぐ次のモデル・キーへ
private static bool ShouldSwitchModel(HttpException e) => e.StatusCode == 429 || e.StatusCode == 404;
// 500 / 503: 一時的な混雑 → 少し待てば通ることが多いので、同じモデルでやり直す
private static bool IsTemporary(HttpException e) => e.StatusCode == 500 || e.StatusCode == 503;
private async Task<string> PostToModelsAsync(string body, string[] models, int timeoutSeconds, int attemptsPerModel = MaxAttemptsPerModel)
{
var keys = UsableKeys;
var order = AttemptOrder(keys.ToArray(), models); // (キー1, メイン), (キー1, 予備), (キー2, メイン), …
HttpException lastError = null;
foreach (var (keyIndex, model) in order)
{
string url = $"{EndpointBase}{model}:generateContent";
for (int attempt = 0; attempt < attemptsPerModel; attempt++)
{
try
{
return await HttpUtil.PostJsonAsync(
url, body, new[] { ("x-goog-api-key", keys[keyIndex]) }, timeoutSeconds: timeoutSeconds);
}
catch (HttpException e) when (ShouldSwitchModel(e))
{
lastError = e;
break; // 次のモデル・キーへ
}
catch (HttpException e) when (IsTemporary(e))
{
lastError = e;
if (attempt < attemptsPerModel - 1)
await Task.Delay(1000 * (1 << attempt)); // 1 秒 → 2 秒待ってやり直す
}
}
}
throw lastError;
}
当出现 429 错误时,如果用相同的模型重新发送,只会进一步占用配额,因此不进行等待而直接继续下一步。由于每个模型的配额是独立的,所以先尝试备用模型,如果仍然失败,再更换密钥。
总结
除了第一部分中的“说 → 请人纠正”之外,现在可以在应用内完成对 Versant 所需的各种练习,包括摘要、主题、简短回答、重复跟读、影子阅读和单词卡。
关于 Versant 的实际结果,我打算在半年后写一篇文章进行总结。(如果结果好的话)
查看点赞用户列表
0
注册为新用户并更便捷地使用 Qiita
- 获取符合您需求的文章
- 高效回顾有用的信息
- 可以使用深色主题
来源:Qiita · Unity 开发社区 · qiita.com



