🐭

Foundation Models Frameworkで絵文字を推薦させる仕組み

に公開

azooKey v3.0.1からは「えもじ」と入力すると文脈に合わせた絵文字推薦が表示されるようになりました。

天才的な閃き、の後に推薦される絵文字のスクショ。電球が光るマークなどが並ぶ。

この機能はiOS 26で導入された「Foundation Models Framework」を利用して作られています。このフレームワークではApple Intelligenceでも用いられているオンデバイスのLLMを利用することができるため、アプリに簡単に知的な振る舞いを導入することができます。したがって、絵文字推薦程度は余裕のはずです。

https://developer.apple.com/documentation/FoundationModels

この記事ではFoundation Models Frameworkで絵文字を推薦させる仕組みと、意外に難しかったポイントを紹介します。

基本的な実装

Foundation Models FrameworkはFoundationModelsをインポートすることで利用できます。利用にはiOS 26以上が必要なので、それ以前のiOSをサポートする場合は@available(iOS 26, *)で囲む必要があります。

まずはシンプルな実装を確認します。

import FoundationModels
@Generable
struct EmojiSuggestion {
    @Guide(description: "Emoji Suggestions for the given context. Give 1-5 suggestions. Each suggestion must be a single character.")
    var emojis: [String]
}

@MainActor func triggerFoundationModelEmojiSuggestion(for inputData: ComposingText, leftSideContext: String) {
    Task {
        // モデルの準備
        let model = SystemLanguageModel(useCase: .general)
        print("FoundationModels availability", model.availability)
        let session = LanguageModelSession(
            model: model,
            instructions: "You are an emoji recommendation engine. Read the provided CONTEXT and suggest 1-5 emojis that best match the overall meaning, tone, or sentiment. Reply with only emoji characters separated by spaces."
        )

        // プロンプト
        let prompt = "context: \(leftSideContext)"

        // 推論の呼び出し本体
        let response = try await session.respond(to: prompt, generating: EmojiSuggestion.self)

        // 後処理
        let emojis: [String] = response.content.emojis
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }

        // emojisをUIに反映
        await MainActor.run {
            ...
        }
    }
}

このコードではsession.respond(to: prompt, generating: EmojiSuggestion.self)の呼び出しが推論本体です。このようにFoundation Models Frameworkでは@Generableマクロで定義された型を渡すことで自動的にその型の値に合わせて生成が行われます。
この円滑なStructured Outputへの対応はFoundation Models Frameworkのクールな点の一つだと思います。

提案する絵文字を1文字に制約する

今回は絵文字の推薦がタスクですが、絵文字は1文字の候補を5個程度に制限したいので後処理でフィルタをかけていきます。「絵文字が1文字であるか」の判定は言語によっては結構実装がめんどくさかったりするのですが、Swiftは書記素クラスタ単位の文字数カウントをデフォルトで採用しているので、emoji.count != 1で簡単にフィルタできます!

let emojis: [String] = response.content.emojis
    .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
    .filter { emoji in
        if emoji.count != 1 {
            return false
        }
        return true
    }

ところが、このように実装したはずなのに「ねむい」に対して「😴‍💨」という絵文字がサジェストされる事象が発生しました。

何が起きているのでしょうか。文字コードを簡単に確認できるcharcode.devで確認します。

文字コードの確認結果

文字コードを確認すると、「眠そうな顔の絵文字」と「風の絵文字」の間に<ZWJ>なる謎の文字が挟まっていることがわかります。これはzero-width joiner(ゼロ幅接合子)と呼ばれる特殊な記号で、2つの文字をくっつけて1つの書記素クラスタとして扱わせる機能を持ちます[1]。つまり、この記号が挟まっていることにより、全体が1つの書記素クラスタとしてSwift側に認識されてしまうわけです!ままならないですね。

しかし、どうしてこういうことが起こるのでしょうか。実は、一部の「1文字に見える」絵文字は、内部的には複数のコードポイントとZWJによって表現されていることがあります。例えば「目が回る顔」の絵文字である「😵‍💫」は内部的に「😵」と「💫」がくっついた構造を持っていますが、1つの別の絵文字として使われます。

「目が回る顔」の絵文字の文字コードの確認結果

Apple Intelligenceの言語モデルが出してきた「😴‍💨」に類似した構造を持った絵文字もあります。「息をつく顔」の絵文字である「😮‍💨」は内部的に「😮」と「💨」がくっついているのです。モデルとしては眠い顔でさらに息もついてほしかったという気持ちが察せてきますね。

「息をつく顔」の絵文字の文字コードの確認結果

実際のところ、このような組み合わせはUnicode側で事前に可能なパターンが決まっていて、「Emoji ZWJ Sequence」として定義されています。ここで定義されていないシーケンスに関しては描画の保証がありません。実際、今回モデルが出力したオリジナルのシーケンスについては2文字で描画されてしまっています。ところがモデルはこのような絵文字を勝手に編み出してしまったわけです。

https://unicode.org/emoji/charts/emoji-zwj-sequences.html

ということは単に<ZWJ>を含む絵文字をフィルターして削除するような手法は適用できません。したがって、「😴‍💨」を許さず「😵‍💫」や「😮‍💨」を許すには、別の工夫が必要になります。

Emoji ZWJ Sequenceのリストをリソースとして取り込み、リストに含まれるもののみを許す、という方式が1つの対処方針として可能ですが、ここで気にしなければならないのが、絵文字は現在進行形で拡張されている文字集合である、という観点です。

絵文字はUnicodeの更新に含まれるため、概ね年1回の頻度で更新が入ります。Apple Platformでは新しい絵文字を通常年明け〜春頃までに取り込みます。このタイミングで、更新されたEmoji ZWJ Sequenceリストをアプリケーションに反映する必要があります。また古いOSバージョンをサポートする場合、どのOSがどのUnicode Emojiバージョンに対応しているのかに注意する必要もあります。これはちょっとめんどくさい要件です。

azooKeyでは代わりに「文字が1文字に見えているか」をチェックする別手段を採用しました。これはCoreTextのAPIを利用することで実現できます。
こうすることにより、OS側でサポートされたUnicodeバージョンに拠らず、レンダリング結果としては確実に1文字であることを保証することができます。これはシンプルながらメンテナンスコストを大きく下げることができる方法です。

import CoreText
func rendersAsSingleGlyph(_ s: String, font: UIFont = .systemFont(ofSize: 17)) -> Bool {
    let attr = NSAttributedString(string: s, attributes: [.font: font])
    let line = CTLineCreateWithAttributedString(attr as CFAttributedString)
    let runs = CTLineGetGlyphRuns(line) as? [CTRun] ?? []
    var glyphCount = 0
    for run in runs {
        glyphCount += CTRunGetGlyphCount(run)
    }
    return glyphCount == 1
}

これを利用して以下のようにフィルターすることで、ようやく「1文字候補」しか表示されない絵文字推薦機能が手に入りました!

let emojis: [String] = response.content.emojis
    .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
    .filter { emoji in
        if emoji.count != 1 {
            return false
        }
        if !self.rendersAsSingleGlyph(emoji) {
            return false
        }
        return true
    }

最後に

Foundation Models Frameworkで絵文字推薦を実装する取り組みについて紹介しました。このフレームワークは簡潔で明瞭ながら、これまでは難しかった柔軟性を追加の費用負担なくアプリケーションに組み入れることができる有望な技術です。

もちろん、オンデバイスで動かせるモデルサイズの制約などもあり、全てが期待通りに動作しているわけではありません。しかし適切なエンジニアリングと組み合わせることでいろいろな面白い機能が実現できそうです。

キーボードアプリ「azooKey」では今後もオンデバイスAIを活用した便利な変換機能を検討していく予定です。ご期待ください!

脚注
  1. より正確にはUnicode Text Segmentationの仕様を参照してください。https://unicode.org/reports/tr29/ ↩︎

azooKey blogs

Discussion