1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[SwiftUI] 初心者が Foundation Model フレームワークを学んで AIアプリをリリースするまで - その5

Posted at

初心者同然の個人開発者が、WWDC25 で発表されていた iOS26 対応の FoundationModel フレームワークについて勉強しながら AIApp 開発にむけて勉強し記録として書いています。

間違いがあれば勉強になりますので是非ともコメントいただければ幸いです。

前回までの内容:

引き続き勉強していきたいと思います。

Tool プロトコル

これは開発する App の内容によって必要性は分かれるが、Foundation Modelでは すべてがオンデバイスで処理されるため Tool プロトコルを使うことでプライバシーを保護しながらデバイス内のデータにアクセスすることが可能とのこと。

例えばゲーム内の NPC を端末内のデータを元に作成することも可能。

公式ドキュメント

Tool プロトコルの実装の例:

struct FindContactTool: Tool {
  let name = "findContact"
  let description = "Finds a contact from a specified age generation."
  }

このようにまず名前を定義する。これは API によって自動的にプロンプトに挿入され、これを基にモデルは適切なタイミングと頻度でツールを呼び出します。
この際に名前は (name:) 動詞を含む短い英語名(例: findContact)のようにする。

略語を避け説明は簡潔にまとめ実装の詳細は含めない。なぜならこれらの文字列はプロンプトにそのまま挿入されるため、長いとトークンが多くなりレイテンシーが増加する可能性があるため。

つぎにツールの入力を定義する:

@Generable
  struct Arguments {
    let generation: Generation
        
    @Generable
    enum Generation {
      case babyBoomers
      case genX
      case millennial
      case genZ            
    }
  }

Generableを 使用することで、ツールが常に有効な入力引数を取得することができる。
これによってこれ以外のものは生成されなくなる。

次に呼び出しのための関数:

func call(arguments: Arguments) async throws -> ToolOutput {
    let store = CNContactStore()
    // 必要な情報のみ取得(プライバシー配慮)
    let keysToFetch = [CNContactGivenNameKey, CNContactBirthdayKey] as [CNKeyDescriptor]
    let request = CNContactFetchRequest(keysToFetch: keysToFetch)

    var contacts: [CNContact] = []
    // 全連絡先を検索し、該当する世代をフィルタリング
    try store.enumerateContacts(with: request) { contact, stop in
      if let year = contact.birthday?.year {
        if arguments.generation.yearRange.contains(year) {
          contacts.append(contact)
        }
      }
    }
    
    // ランダム選択またはフォールバック
    guard let pickedContact = contacts.randomElement() else {
      return ToolOutput("Could not find a contact.")
    }
    return ToolOutput(pickedContact.givenName)
  }

Callメソッドを使ったこのモデルはツールを呼び出すことを決定したときにこの関数を呼び出します。
この例ではつぎにContacts API を呼び出しています。そのクリエに対して連絡先の名前を返しています。

公式の動画ドキュメントで推奨しているToolOutputは対応しておらず Error になります。

公式ドキュメントのファイルをみる限り、PromptRepresentable 「プロンプトとして表現できるデータ」 String / Int / Double / Bool やその他基本型などで対応するのが正解みたい。なので今回は String 型で対応している。

さらに Generation に 世代ごとの年齢範囲を追加し、ここまでをとりあえずまとめるとこんな感じ↓

import FoundationModels
import Contacts

struct FindContactsTool: Tool {
    // ツールの一意識別子(短く、読みやすく)
    let name = "findContact"
    // AIがこのツールの用途を理解するための1文程度の簡潔な説明。実装の詳細は含めない(プロンプトに直接組み込まれるため)
    let description = "Finds a contact from a specified age generation."
    
    @Generable
    struct Arguments {
        let generation: Generation
        
        @Generable
        enum Generation {
            case babyBoomers
            case genX
            case millennial
            case genZ
            
            // 世代ごとの年齢範囲
            var yearRange: ClosedRange<Int> {
                switch self {
                case .babyBoomers: return 1946...1964
                case .genX:        return 1965...1980
                case .millennial:  return 1981...1996
                case .genZ:        return 1997...2010
                }
            }
        }
    }
    
    /*
     公式のドキュメントでは ToolOutput を推奨しているが、
     まだ X-code の更新遅延があるのかベータ版での仕様変更なのか Error になる
     X-codeはPromptRepresentable **「プロンプトとして表現できるデータ」**
     を推奨してくるため String/Int/Double/Bool やその他基本型でとりあえず対応
     */
    func call(arguments: Arguments) async -> String {
        let store = CNContactStore()
        
        // 必要な情報のみ取得(プライバシー配慮)
        let keysToFetch = [CNContactGivenNameKey, CNContactBirthdayKey] as [CNKeyDescriptor]
        let request = CNContactFetchRequest(keysToFetch: keysToFetch)
        var contacts: [CNContact] = []
        
        // 全連絡先を検索し、該当する世代をフィルタリング
        try store.enumerateContacts(with: request) { contact, stop in
            if let year = contact.birthday?.year {
                if arguments.generation.yearRange.contains(year) {
                    contacts.append(contact)
                }
            }
        }
        
        guard let pickedContact = contacts.randomElement() else {
            return "Could not find a contact."  // String は PromptRepresentable に準拠
        }
        
        return pickedContact.givenName  // String を直接返す
    }
}

これで呼び出せば使えるはず。
ツールを使用する際にはそれをセッションイニシャライザーに渡します↓

import FoundationModels

let session = LanguageModelSession(
  tools: [FindContactTool()],
  instructions: "Generate fun NPCs"
)

これで必要な場合にのみツールを呼び出します。
これは通常のContacts APIを使用しているとのこと。

公式ドキュメント:

このツールでは、呼び出された時にユーザーに通常の許可を求めます。

ユーザーが連絡先へのアクセスを拒否する場合も想定して作る必要があり、
これを導入する際にはそれでも動く仕組みにしておかないと多分リジェクトされそう。

しかし、このままでは時々同じ連絡先を取得する可能性がある。

そこで、それを調整するために使用された連絡先を追跡する仕組みを追加。
そのためにはまずstructは値型のため状態管理ようにClassにする必要がある。

こんな感じ↓

import FoundationModels
import Contacts
// structは値型のため状態管理ようにClassにする
class FindContactTool: Tool {
  let name = "findContact"
  let description = "Finds a contact from a specified age generation."
  
   // 使用済み連絡先を記録する状態
  var pickedContacts = Set<String>()
    
  ...

これで呼び出しメソッドから状態を変更できるらしい。

とりあえずここまでをまとめておくと↓

import FoundationModels
import Contacts

// structは値型のため状態管理ようにClassにする
class FindContactsTool: Tool {
    // ツールの一意識別子(短く、読みやすく)
    let name = "findContact"
    // AIがこのツールの用途を理解するための1文程度の簡潔な説明。実装の詳細は含めない(プロンプトに直接組み込まれるため)
    let description = "Finds a contact from a specified age generation."
    
    // 使用済み連絡先を記録する状態
    var pickedContacts = Set<String>()
    
    @Generable
    struct Arguments {
        let generation: Generation
        
        @Generable
        enum Generation {
            case babyBoomers
            case genX
            case millennial
            case genZ
            
            // 世代ごとの年齢範囲
            var yearRange: ClosedRange<Int> {
                switch self {
                case .babyBoomers: return 1946...1964
                case .genX:        return 1965...1980
                case .millennial:  return 1981...1996
                case .genZ:        return 1997...2010
                }
            }
        }
        
    }
    /*
     公式のドキュメントでは ToolOutput を推奨しているが、
     まだ X-code の更新遅延があるのかベータ版での仕様変更なのか Error になる
     X-codeはPromptRepresentable **「プロンプトとして表現できるデータ」**
     を推奨してくるため String/Int/Double/Bool やその他基本型でとりあえず対応
     */
    func call(arguments: Arguments) async -> String {
        let store = CNContactStore()
        // 必要な情報のみ取得(プライバシー配慮)
        let KeysToFetch = [CNContactGivenNameKey, CNContactBirthdayKey] as [CNKeyDescriptor]
        let request = CNContactFetchRequest(keysToFetch: KeysToFetch)
        var contacts: [CNContact] = []
        
        // 全連絡先を検索し、該当する世代をフィルタリング
        try store.enumerateContacts(with: request) { contact, stop in
            if let year = contact.birthday?.year {
                if arguments.generation.yearRange.contains(year) {
                    contacts.append(contact)
                }
            }
        }
        
        // 使用済み連絡先を除外
        let availableContacts = contacts.filter { contact in
            !pickedContacts.contains(contact.givenName)
        }
        
        // ランダム選択またはフォールバック(利用可能な連絡先から選択)
        guard let pickedContact = availableContacts.randomElement() else {
            // 全て使い切った場合のハンドリング
            if contacts.isEmpty {
                return "Could not find a contact." // String は PromptRepresentable に準拠
            } else {
                // リセットして再開
                pickedContacts.removeAll()
                guard let resetContact = contacts.randomElement() else {
                    return "Could not find a contact." // String は PromptRepresentable に準拠
                }
                pickedContacts.insert(resetContact.givenName)
                return resetContact.givenName
            }
        }
        // 使用済みリストに追加
        pickedContacts.insert(pickedContact.givenName)
        return pickedContact.givenName // String を直接返す
    }
    
    // 状態をリセットするメソッド
    func resetUsedContacts() {
        pickedContacts.removeAll()
    }
}

Eventkit と ToolOutput

公式ドキュメントで紹介されているカレンダー (EventKit) との連携の仕組み。
すべてがオンデバイスで処理されるため Tool プロトコルを使うことでプライバシーを保護しながらデバイス内のデータにアクセスする仕組みを使った応用みたいな感じ。

-公式ドキュメント-

ツール呼び出しの仕組みとしては、公式によると...

"セッションの冒頭でツールを渡すことから始めます。その際に指示も一緒に渡します。その後、ユーザーがセッションにプロンプトを入力するとモデルはテキストを分析します。例えば日時に対してイベントがあるかを聞いた場合、モデルはプロンプトがイベントを求めていることを理解しカレンダーツールの呼び出しを行う。

このツールを呼び出すためにモデルは最初に入力引数を生成します。この場合モデルはイベントを取得する日付を生成する必要があります。モデルは指示やプロンプトからの情報を関連付けてそれに基づいてツールの引数を適切に補完します。つまりこの例の場合ではtomorrowの意味を推論する際に指示にある今日の日付が使用されます。ツールの入力が生成されるとcallメソッドが呼び出されます。ツールは必要に応じてあらゆる処理ができます。ただし、セッションはツールの処理結果が返されるまで待機しそれまで他の出力を生成しません。"

ざっくりまとめるとこんな感じかな???
┌─────────────────────────────────────┐
ユーザー: "明日の田中さんとの予定は?"
├─────────────────────────────────────┤
AI思考プロセス:
├ 1. "明日" = 今日の日付 + 1日
├ 2. 指示文に "今日は2025年1月15日" とある
├ 3. つまり明日は 2025年1月16日
└ Arguments(day: 16, month: 1, year: 2025) を生成
└─────────────────────────────────────┘

並行処理を実行については...
┌─────────────────────────────────────┐
ユーザー: "明日、ミレニアル世代の知り合いと会う予定ある?"
├─────────────────────────────────────┤
処理フロー:
├ 1. AIが2つのツールが必要と判断
├ 2. 並列実行開始:
└ FindContactsTool.call() →  "田中太郎"
└ GetContactEventTool.call() →  "13:00からランチ"
├─────────────────────────────────────┤
両方完了後にAI応答生成
└─────────────────────────────────────┘

さらに...

ツールの出力は その後 トランスクリプトに記録されます。これはモデルからの出力と同様です。またツールの出力に基づいてモデルはプロンプトに対する応答を生成できます。ツールは1つのリクエストに対して複数回呼び出される可能性があります。そのような場合、ツールは並列に呼び出されます。ツールのcallメソッドからデータにアクセスするときはこれに注意してください。

とある。
問題のイメージとしては...
┌─────────────────────────────────────┐
ユーザー: "明日と明後日の予定教えて"
├─────────────────────────────────────┤
AIが同時に2回ツールを呼び出し:
├ スレッド1: call(Arguments(day: 16, month: 1, year: 2025))
└ スレッド2: call(Arguments(day: 17, month: 1, year: 2025))
├─────────────────────────────────────┤
両方が同時に requestCount にアクセス:
├ スレッド1: requestCount = 0 → 1 に更新しようとする
└ スレッド2: requestCount = 0 → 1 に更新しようとする

→ 結果: requestCount = 1 (本来は2になるべき)
├─────────────────────────────────────┤
さらにクラッシュの可能性:
├ スレッド1: cache["16-1"] = "処理中..."
└ スレッド2: cache["17-1"] = "処理中..." → 辞書の内部構造が破損してアプリクラッシュ
└─────────────────────────────────────┘

こんな感じのイメージであっているかな??

一応、公式のドキュメントのコード内では状態を持たない設計で毎回新しいインスタンスを作成することで安全な処理にしているみたい↓

func call(arguments: Arguments) async throws -> ToolOutput {
        // 毎回新しいインスタンスを作成 - 安全
        let eventStore = EKEventStore()
        let calendar = Calendar.current

        ......
    }

ここまで勉強したところでとりあえずコードを進めていく。

まず、基本的な構造は同じ↓

import FoundationModels
import EventKit

struct GetContactEventTool: Tool {
  let name = "getContactEvent"
  let description = "Get an event with a contact."

  let contactName: String
    
  @Generable
  struct Arguments {
    let day: Int
    let month: Int
    let year: Int
  }
  
}

もちろんこれも...

公式の動画ドキュメントで推奨しているToolOutputは対応しておらず Error になります。

公式ドキュメントのファイルをみる限り、PromptRepresentable 「プロンプトとして表現できるデータ」 String / Int / Double / Bool やその他基本型などで対応するのが正解みたい。なので今回は String 型で対応している。

call メソッドも ToolKit の時と同様に↓

func call(arguments: Arguments) async throws -> ToolOutput { ... }

このように書く必要がある。

そして呼び出しメソッドから状態を変更できるように以下のように調整↓

class CalendarTool: Tool {
    let name = "getCalendarEvents"
    let description: String
    let contactName: String // 検索対象の連絡先名(初期化時に設定)

    init(contactName: String) {
        self.contactName = contactName
        description = """
            Get an event from the player's calendar with \(contactName). \
            Today is \(Date().formatted(date: .complete, time: .omitted))
            """
    }

ここまでを Tool の時と同様にあくまで勉強用としてまとめると↓

import FoundationModels
import EventKit

class CalendarTool: Tool {
    let name = "getCalendarEvents"
    let description: String
    let contactName: String // 検索対象の連絡先名(初期化時に設定)

    init(contactName: String) {
        self.contactName = contactName
        description = """
            Get an event from the player's calendar with \(contactName). \
            Today is \(Date().formatted(date: .complete, time: .omitted))
            """
    }
    
    @Generable
    struct Arguments {
        let day: Int    // 検索する日
        let month: Int  // 検索する月
        let year: Int   // 検索する日
    }
    
    func call(arguments: Arguments) async -> String {
        do {
            // カレンダーアクセス許可の確認
            let eventStore = EKEventStore()                   // 毎回新しいインスタンスを作成
            try await eventStore.requestFullAccessToEvents()  // フルアクセスを要求
            let calendars = eventStore.calendars(for: .event) // 特定のカレンダー
            
            // モデルが渡す引数から開始日と終了日を構築
            let dateComponents = DateComponents(
                year: arguments.year,
                month: arguments.month,
                day: arguments.day
            )
            
            let startDate = Calendar.current.date(from: dateComponents)!
            let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
            let predicate = eventStore.predicateForEvents(
                withStart: startDate,
                end: endDate,
                calendars: calendars // 指定したカレンダーのみ
            )
                        
            // カレンダーでその人が予定しているイベントを確認します
            // 生成されたNPCについて
            let events = eventStore.events(matching: predicate)
            let relevantEvents = events.filter { event in
                // 参加者名が完全一致のみ
                event.attendees?.contains(where: { $0.name == contactName }) == true
            }
            
            let output: String
            if relevantEvents.isEmpty {
                output = "The player has \(events.count) events today, but no events with \(contactName)"
            } else {
                //各イベントを文字列に変換。(.joined(separator: "\n")で改行で結合して1つの文字列にする
                output = """
                Events with \(contactName):
                \((relevantEvents.map { $0.startDate.formatted() + ": " + $0.title }).joined(separator: "\n"))
                """
            }
            return output
            
        } catch {
            return "Sorry, I can't see your calendar"
        }
    }
}

とりあえず今回はここまで。
WWDC25 公式ドキュメント内の紹介もここまでなので、ここからは一旦サンプルのAPPを作って実践形式といこうかな。

書き方・解釈に間違いがあれば勉強になりますのでぜひコメントお願いします!
引き続きAIApp開発に向けて勉強した内容をまとめつつ記録していきます。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?