跳到正文
Qiita · Unity 开发社区· 来源· haru452·· 45 分钟前AI 评分26

在Unity中制作"AI会批改说出的英语"的英语日记应用 其二(备忘录)

正文 · 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. 主题

9927.png

流程为:题目 → 思考时间 → 说话45~60秒 → 评分 → 重说。

在第1部分中,是“先用终端的语音识别转成文字,再发送给 Gemini”,但在 Topic 中则是把录音直接交给 Gemini,在一次请求中完成转写和评分。等待时间大约缩短了一半。(主观感受)

评分之后,有一个“Read aloud”环节,要求把修改后的句子大声朗读一遍。系统会逐词比较朗读的句子和修改后的句子,读对的单词标为绿色,没读对的单词标为红色。比较方式沿用了第1部分中 Discord 下划线所用的同一种 LCS(按单词单位的差分)。

将常见错误分为 10 类进行计数

把日记、摘要和题目中被纠正的地方,交给 Gemini 按“冠词、时态、介词……”等 10 种类型分类,并存储到 Supabase 中。
出现较多的错误,会在下次讲题目之前作为“今日注意事项”展示出来,评分时也会重点检查。

多亏了这个,我才知道自己原来特别不擅长单数和复数。
image.png

3. Repeat:跟读与影子练习

重复练习

在不看英文的情况下朗读并复述。输出的句子顺序如下:

  1. 上次正确率低于80%的句子
  2. 在个人日记中被修正的句子
  3. 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. 词汇表:单词卡

combined_9928_9929.png

使用 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 的表中。

  1. 在电脑上预先制作:Python 脚本会从指定频道的视频字幕中查找并插入单词场景
  2. 在应用中即时查找:对于第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

查看点赞用户列表

0

注册为新用户并更便捷地使用 Qiita

  1. 获取符合您需求的文章
  2. 高效回顾有用的信息
  3. 可以使用深色主题

注册后您可以做什么

来源:Qiita · Unity 开发社区 · qiita.com