株式会社RevCommのブログ - TECH PLAY

TECH PLAY

株式会社RevComm

株式会社RevComm の技術ブログ

185

RevCommでモバイルアプリ開発を担当している藤田と申します。本日はApple Foundation Modelsと呼ばれるオンデバイスAIを活用したオフライン対応のiOSアプリのプロトタイピングについて記載していきたいと思います。 はじめに:AIとモバイルアプリケーションの新たな可能性 近年、生成AIの急激な普及によりさまざまな領域でAIを活用したWeb, モバイルアプリが展開されています。そのほとんどはクラウドへデータ送信することでAIと連携している一方、 Apple Foundation Models のようなデバイス上でAIを完結できるオンデバイスAIと呼ばれる技術も着実に進歩しています。 デバイス上だけでAIを活用できると、オフライン環境下やプライバシーに配慮が求められる場面でもAIを活用した機能を提供できるようになります。たとえば弊社が開発しているMiiTel RecPodと呼ばれる会議を録音して解析するアプリへの応用を考えた場合、機密性の高い経営会議のような会議の解析や電波の届かないような環境での顧客との打ち合わせのような状況でもAIを活用した機能提供を実現できる可能性が期待できます。 モバイル領域では、特に最近、先述のApple Foundation Modelsと呼ばれるオンデバイスAI処理に特化したAPIの提供を開始しており、アプリ開発者も気軽にオンデバイスAIを試すことが可能となりました。 これらを踏まえて、今回は録音後に生成される文字を要約できるiOSアプリをApple Foundation Modelsを使って実装してみることにします。 モバイルでのオンデバイスAI実装の選択肢 iOS上でオンデバイスLLMを実装する方法として、TensorFlow Lite, CoreMLを使った独自モデルの統合など複数の選択肢が存在します。これらと比較して、iOS 26以降で利用可能になったApple Foundation Modelsは、AIエンジニアと連携するなどしてモデルファイルを自前で用意する必要がなく、システムレベルで統合されているためアプリ開発者にとって簡単に扱えるという利点があります。 一方で 想定されているユースケース 以外での利用の場合はうまく組み込めない可能性もあるので万能ではないですが、公式ドキュメントに記載されている想定されるケースでは積極的に採用するとよさそうです。今回実装するアプリの主機能である要約もこちらに含まれています。 Foundation Modelsの制約 簡単にオンデバイスAIを機能組み込めるApple Foundation Modelsですが、いくつか制約があります。その一つがセッションあたりのトークン長制限です。Foundation Modelsでは、一度のセッションで処理できる トークン数に上限が4096まで と設定されており、これには入力テキストと生成される出力の両方が含まれます。 たとえば、30分の会議音声を文字にすると数千文字以上に及ぶことが一般的です。この制約を考慮せずに長文テキストをそのまま入力すると、処理が失敗してしまい長い文章を要約する機能を実現できません。したがって、この課題を解決するために長い文章を処理するためにはそのまま文章を入力するだけでは不十分で、適切に処理できるような工夫が必要です。 Map Reduceパターンによる長文要約 先述の長文処理の手法はいくつか存在するようですが、今回はGoogleなどが提案している、シンプルかつ標準的な Map-reduceのアプローチ を採用することにしました。 この手法は、大規模なドキュメント要約において実績のある標準的なパターンであり、全体の設計は主に三段階で構成されます。 Chunking (分割) : 元のテキストをトークン制限内に収まるサイズに分割する。 Map (部分要約) : 各チャンクを個別にAIに送信し、部分的な要約を生成する。 Reduce (統合) : 生成された複数の部分要約を一つにまとめ、全体を代表する最終要約を作成する。 このアプローチにより、Foundation Modelsのようにトークンに制約がある場合であっても、長い文章の要約が可能になります。 1. チャンク分割処理の実装 Map段階の最初のステップとして、長文テキストを適切なサイズのチャンクに分割するロジックを実装します。単純に文字数で機械的に分割すると文脈が途切れて要約の品質が低下するため、文や段落の境界を考慮した分割が必要です。 実装では、まず改行や句点で文を識別し、トークン制限の6, 7割程度を目安に文単位でチャンクを構成していきます。各チャンクには前後のチャンクとの重複部分を持たせることで、文脈の連続性を保つ工夫も施します。 この分割処理により、意味的なまとまりをある程度維持しながら処理可能なサイズにテキストを分解することができます。こちらはswiftで実装すると次のようになります。 struct Chunk { let id : Int ; let text : String } func makeChunks (from text : String , targetCharsPerChunk : Int = 2200 , overlap : Int = 200 ) -> [ Chunk ] { guard ! text.isEmpty, targetCharsPerChunk > 0 else { return [] } var chunks : [ Chunk ] = [] var id = 0 let end = text.endIndex var start = text.startIndex let maxRightwardExtension = 400 while start < end { // The desired end position for this chunk let hardEnd = text.index(start, offsetBy : targetCharsPerChunk , limitedBy : end ) ?? end var actualEnd = hardEnd if hardEnd < end { // Look a bit past hardEnd for a likely sentence boundary let lookaheadEnd = text.index( hardEnd, offsetBy : maxRightwardExtension , limitedBy : end ) ?? end let searchRange = text[hardEnd ..< lookaheadEnd] var boundary : String.Index? // Prioritize Japanese period '。' as the sentence end if let jpPeriod = searchRange.firstIndex(of : "。" ) { boundary = jpPeriod } // Next, try to find '. ' or '.\n' else if let period = searchRange.firstIndex(of : "." ) { let after = text.index(after : period ) if after < lookaheadEnd { let nextChar = text[after] if nextChar == " " || nextChar.isNewline { boundary = period } } } // Lastly, look for newline else if let newline = searchRange.firstIndex(of : "\n" ) { boundary = newline } if let b = boundary, b > start { actualEnd = text.index(after : b ) } } let slice = text[start ..< actualEnd] guard ! slice.isEmpty else { break } chunks.append(. init (id : id , text : String (slice))) id += 1 if actualEnd >= end { break } // Next start is current end minus overlap let overlapClamped = max( 0 , min(overlap, slice.count - 1 )) let nextStart = text.index( actualEnd, offsetBy : - overlapClamped, limitedBy : text.startIndex ) ?? text.startIndex if nextStart <= start { start = text.index(after : start ) } else { start = nextStart } } return chunks } 2. 部分要約の生成 (Map処理) 分割された各チャンクに対して、Foundation Models APIを使用して部分要約を生成します。ここでは @Generable および @Guide というマクロを活用することで、JSON形式を明示的に指定することなく、構造化されたデータとして要約結果を取得できるようにします。 @Generable および @Guide を使用すると、Swiftの構造体を定義するだけで、APIのレスポンスを型安全な形で直接マッピングできます。これにより、従来必要だったJSON文字列のパースやエラーハンドリングの多くを省略でき、実装が大幅に簡潔になります。また、出力に関するプロンプトの記述も省略できるためトークン消費の節約にもなります。各チャンクの要約生成では、元のテキストの重要な情報を保持しつつ簡潔にまとめるようプロンプトを設計し、生成された部分要約を配列として保持します。 今回は概要とキーポイントを持つようなデータ構造で出力結果を受け取れるよう、次のような構造体を宣言してみます。キーポイントの範囲をもう少し厳密に定めたい場合は、 @Guide の中に .count(4) や .range(1..7) のような出力の範囲を定めることも可能です。 @Generable struct DocSummary : Codable { @Guide ( description: "全体の要旨。日本語で100〜200字で簡潔に。" ) var overview : String @Guide ( description: "章や段落ごとの主要ポイント。各項目は1文50〜100字。最大5件程度。" ) var keyPoints : [ String ] } さらにこのデータ構造を出力結果として得るための部分要約の生成を次のようにInstructions、出力の設定、プロンプトを設定することで実現します。トークン長の制約を考慮して、部分要約のたびにセッションを新たに作りなおしています。なお、temperatureはLLMの出力を調整するためのパラメータで、温度を高く設定するとより多様で創造的な出力となり、低くするとより一貫性の高い出力となります。今回は要約というある程度出力の方向性が決まったタスクのためやや低めの温度に設定しています。 func summarize (chunk : Chunk ,temperature : Double , maxOutput : Int = 400 ) async throws -> DocSummary { let instructions = """ テキストの要約者として振る舞ってください。 具体的な事実・数字・固有名詞を落とさないようにまとめます。 """ let session = LanguageModelSession(instructions : instructions ) let options = GenerationOptions( temperature : min ( 0.5 , max( 0.2 , temperature)), maximumResponseTokens : maxOutput ) let prompt = """ 次の文章を要約してください。 - overview : 全体の要旨 - keyPoints : 具体的なポイント テキスト : \( chunk.text ) """ let summary = try await session.respond(to: prompt, generating: DocSummary.self, options: options) return summary.content } 3. 統合要約の生成(Reduce処理) すべてのチャンクから部分要約が生成されたら、Reduce段階に進みます。複数の部分要約を結合して一つのテキストとし、再度Foundation Modelsに送信して最終的な全体要約を生成します。この段階では、部分要約間の重複を排除し、文書全体の主要なポイントを抽出するようプロンプトを調整します。部分要約の数が多い場合には、階層的なアプローチも検討できますが、今回はプロトタイプ的な実装のためシンプルな方法で進めます。Reduce段階でも @Generable を使用して、最終要約を構造化されたデータとして取得し、アプリケーションのUIに表示する準備を整えます。 今回は次のように部分要約をひとつにまとめて全体要約を生成する処理を実現します。 func reduce (_ parts : [ DocSummary ] , temperature : Double , maxOutput : Int = 600 ) async throws -> DocSummary { let instructions = """ あなたはプロの要約者です。 与えられた複数の部分要約を読み、重複や類似内容を統合して1つの要約を作成してください。 事実・固有名詞・数字は残し、抽象的・汎用的な文は減らしてください。 """ let session = LanguageModelSession(instructions : instructions ) // Output tokens are set slightly higher for the reduce proces let adjustedMaxOutput : Int = { if maxOutput <= 0 { return 400 } return min(maxOutput, 700 ) }() let adjustedTemp = min( 0.8 , max( 0.3 , temperature)) let options = GenerationOptions( temperature : adjustedTemp , maximumResponseTokens : adjustedMaxOutput ) // Throttle the number of input parts to prevent the prompt from becoming too large. let maxPartsToShow = min( parts.count, 12 ) let partsToMerge = Array(parts. prefix (maxPartsToShow)) let merged = partsToMerge.enumerated().map { i, s in """ [ \( i + 1 ) ] \( s.overview ) Points: \( s.keyPoints. prefix ( 5 ).joined(separator : " | " ) ) """ }.joined(separator : "\n\n" ) let reducePrompt = """ 次の \( partsToMerge.count ) 個のテキストサマリを、1つのサマリに統合してください。 - 重複・似た内容のポイントは 1 つに統合 - 具体的な事実・数字・固有名詞はできる限り残す - 最大 5 個程度の keyPoints に絞る \(merged) """ let response = try await session.respond( to : reducePrompt , generating : DocSummary.self , options : options ) let result = response.content return result } エラーハンドリング Foundation Models APIの呼び出しは、トークン長の制限を超える入力が行われる場合以外にも、デバイスがそもそも対応していない、指定したデータ構造に出力する場合に内部でデシリアライズに失敗するなどさまざまな理由により失敗する可能性があります。また、失敗する原因の一部はFoundation Modelsが抱えている不具合といわれており、これらを完全にゼロにすることはLLMの性質上おそらく難しいです。 したがって今回は、次のように特に頻繁に発生する、内部でのデータ構造に変換する際に生じるエラーに対してのフォールバック処理として、失敗したら制約を緩めたプロンプトでリトライすることで要約処理を続行できるようにしています。今回はプロトタイピングなので簡易的な対応にとどめていますが、実用的なアプリを作る場合はもう少し堅牢な処理が必要になると思われます。 do { let summary = try await session.respond( to : prompt , generating : DocSummary.self , options : options ) return summary.content } catch LanguageModelSession.GenerationError.decodingFailure( let llmContext ) { // Retry with simplified prompt let fallbackPrompt = """ 次の文章を要約してください。 - overview : 全体の要旨 - keyPoints : 具体的なポイント テキスト : \( chunk.text ) """ let fallbackOptions = GenerationOptions( temperature: 0.2, maximumResponseTokens: 300 ) let summary = try await session.respond( to: fallbackPrompt, generating: DocSummary.self, options: fallbackOptions ) return summary.content } catch { throw error } 完成したアプリ app-summary-ss 完成したアプリはこちらです。画面上部のテキストフィールドに文字を入力して要約ボタンを押下すると、文字列が長い場合は先述の処理が実行され、要約結果が画面下部に表示されます。 完成したプロトタイプアプリを使って、実際に長い文章を要約させると、5000字くらいで10秒くらい、15000字くらいで30秒から60秒くらいで結果が得られました。30分程度の会議音声を文字起こしすると15000字前後くらいになることもあるので、ある程度実用に耐えうる処理時間であることがわかりました。また、機内モードなどオフライン環境下に設定して要約を実行しても問題なく処理できることも確認できました。精度は最近の高性能なモデルと比較すると劣りますが、主要なポイントは抽出されており、こちらも実用に耐えうるレベルと考えます。 まとめ 本記事では、Apple Foundation Modelsと呼ばれるオンデバイスAIフレームワークを用いて、オフラインでも動作する長文要約アプリをiOSで実現する方法を紹介しました。Apple Foundation Modelsは比較的新しい技術で知見が多くないのか、普段の開発と比較してAIが正しくない回答をすることが多く、実装過程では想定より試行錯誤が必要でした。 実務で組み込むにはエラーハンドリングなどより安定性や精度、パフォーマンスを高める工夫が必要かもしれませんが、今回のプロトタイピングを通してよりユーザー体験を高められる機能提供を目指したいと思います。この取り組みが、同様の課題に取り組む開発者の参考になれば幸いです。
この記事は、 RevComm Advent Calendar 2025  の 19 日目の記事です。 はじめに MiiTel Phone とは デザインパターンとは GoF デザインパターン JavaScript のデザインパターン なぜ今さら? MiiTel Phone で使われているデザインパターン Factory Pattern Factory Pattern の基本例 Factory Pattern を使わない場合 MiiTel Phone における活用例 Factory Pattern を使わない場合の問題点 Container/Presentational Pattern React Hooks の活用 Container/Presentational Pattern を使わない場合 MiiTel Phone における活用例 Atomic Design と Container/Presentational Pattern を組み合わせるメリット Middleware Pattern Axios Interceptor の使用例 Axios の Middleware Pattern 実装を見る InterceptorManager の実装 Axios クラスの構造 Interceptor チェーンの実行 Middleware の実行順序(LIFO と FIFO) Middleware Pattern の利点 Observer Pattern シンプルな自作 EventEmitter の実装 SIP.js と Observer Pattern SIP.js の EventEmitter 実装 Session クラスにおける EventEmitter の利用 発信シナリオのイベント通知フロー Observer Pattern の利点 さいごに 参考文献 はじめに 2025 年 7 月に RevComm にフロントエンドエンジニアとして入社した 林 です。現在は主に MiiTel Phone の開発を担当しています。 今回は MiiTel Phone におけるデザインパターンの活用事例について紹介したいと思います。 ※ この記事で使用されているコードは説明用のサンプルコードであり、実際のコードとは異なります。 MiiTel Phone とは MiiTel Phone とは、電話の通話内容を AI が自動で録音・文字起こしし、その会話の特徴を数値化して分析するクラウド型の IP 電話サービスです。 パソコンやスマートフォンとインターネットがあれば固定電話は不要で、通話と同時にその内容が自動で記録され、話すスピードや会話のラリー回数、キーワードの出現状況などが可視化されます。 こうしたデータは CRM や SFA といった営業管理ツールと連携でき、通話履歴を一元管理したり、商談内容をチームで共有したりするのに役立ちます。 また、AI が通話の傾向を解析して改善ポイントを指摘するため、営業トークの質向上や新人教育にもよく活用されています。 デザインパターンとは デザインパターンとは、 ソフトウェア開発で頻繁に発生する設計上の問題に対する再利用可能で汎用的な解決方法 のことです。 ある程度以上の規模や複雑さを持つシステムでは、状態管理、依存関係の制御、拡張性の確保、責務分離など、設計上の課題が自然と発生してしまいます。 そのような場面でデザインパターンを理解していると、すでに確立された構造を使って問題を整理し、保守性の高い設計を行うことができます。 一方で、 デザインパターンは銀の弾丸ではなく、あくまで過去の開発者たちがまとめたよくある問題に対する典型的な解法にすぎません 。小規模なコードやシンプルな処理に対して無理にパターンを当てはめると、かえって構造が複雑になってしまうので注意が必要です。 GoF デザインパターン デザインパターンの中でも特に有名なのが、今ではほとんど伝説として語られる GoF *1 がまとめた 23 のパターンです。これらはオブジェクト指向設計の基本原則を体系化したもので、次の 3 種類に分類されます。 生成:オブジェクトの作り方を工夫する(Factory Method、Singleton など) 構造:クラスやオブジェクトの組み合わせ方を整理する(Adapter、Decorator など) 振る舞い:オブジェクト同士の連携方法を定義する(Observer、Strategy など) GoF デザインパターンは、現在でも設計の基礎として広く活用されています。 ここでは細かくは触れません。もし興味のある方はオライリーの『 Head First デザインパターン 』が図やイラストが多くて分かりやすいのでおすすめです。 JavaScript のデザインパターン 一方で JavaScript はマルチパラダイム言語であり、関数をファーストクラスオブジェクトとして扱えるなど、オブジェクト指向言語とは異なる特徴を持ちます。そのため、 GoF デザインパターンを JavaScript に適用する際、従来のオブジェクト指向言語のようにそのままの形で利用できるとは限りません。 pattens.dev では、こうした言語特性の違いを踏まえ、実際の Web 開発で活用しやすいパターンが次のように整理されています。 Vanilla / React / Vue Patterns: JS の言語特性に合わせたパターン Rendering Patterns: レンダリングを最適化するためのパターン Performance Patterns: パフォーマンスを最適化するためのパターン 例えば Bundle Splitting や Tree Shaking といったパターンは、多くの場合ライブラリやフレームワーク側で自動的に対応されており、開発者が特別に意識しなくても自然に利用されていることが少なくありません。 この記事では主にフロントエンドで使う JavaScript のデザインパターンを想定していますが、バックエンド側のデザインパターンを学びたい場合は『 Node.js デザインパターン 』という本がおすすめです。 なぜ今さら? デザインパターンは古典的な概念ですが、モダンなフロントエンド開発においても依然として重要です。その理由はいくつかあります。 まず、 コードベースの複雑化 です。React や Vue といったコンポーネントベースのフレームワークでは、状態管理、非同期処理、パフォーマンス最適化など、多くの設計上の課題が自然に発生します。デザインパターンを意識することで、こうした課題に対して構造を整理し、保守性や可読性を高めることができます。 次に、 AI エージェントを活用した開発の観点 です。AI が生成するコードにデザインパターンが含まれていても、私たちがその意図や構造を理解できなければ、結果的に技術的負債につながる可能性があります。一方で、デザインパターンの名称を指示として AI に与えることで、生成されるコードの構造を意図通りに導き、負債を抑制することができます。 これは、名前を付ける行為が単なるラベリングではなく、概念を抽象化して世界を理解しやすくする行為であることと同じです。名前を付ける=カテゴリー化することで、無限に複雑な現象を整理し理解可能にし、 車輪の再発明を防ぐ ことができます。 さらに、名前やパターンを共有することで、 チーム内のコミュニケーションが効率化 されます。共通の語彙があることで、設計意図やコードの構造を迅速に共有でき、レビューや議論をスムーズに進められます。 加えて、フロントエンド技術は非常に速いスピードで進化します。新しいフレームワークやライブラリ、ビルドツールが次々に登場し、短期間で流行が変化することも珍しくありません。しかし、デザインパターンは言語やフレームワークに依存しない設計原則であるため、その価値は陳腐化しません。 使用する技術スタックが変わっても、パターンの本質を理解していれば、あらゆる環境で応用できます 。 MiiTel Phone で使われているデザインパターン MiiTel Phone では、さまざまなデザインパターンが活用されていますが、ここではその中から特に興味深い部分を抜粋して紹介します。 あわせて、使用しているライブラリ側でデザインパターンが用いられている箇所についても触れて説明したいと思います。 Factory Pattern まずは、最も単純かつ割とデザインパターンとは意識せずに使われている印象が強い Factory Pattern について取り上げます。 Factory Pattern とは、 オブジェクトの生成方法をカプセル化 し、クライアントコードが直接 new を呼ばずにオブジェクトを作れるようにするデザインパターンのことです。 Factory Pattern の基本例 これだけだと分かりづらいのでサンプルコードを例示します。 // animal-factory.ts interface Animal { speak (): void ; } class Dog implements Animal { speak () { console .log( 'Woof!' ); } } class Cat implements Animal { speak () { console .log( 'Meow!' ); } } const createAnimalFactory = ( type : 'dog' | 'cat' ): Animal => { switch (type) { case 'dog' : return new Dog(); case 'cat' : return new Cat(); default : throw new Error ( 'Unknown animal' ); } } ; // bun run animal-factory.ts createAnimalFactory( 'dog' ).speak(); // Woof! createAnimalFactory( 'cat' ).speak(); // Meow! 上記の例では、 createAnimalFactory 関数が Factory メソッドとして機能しています。 クライアントコードは Animal 型に依存しているだけで、具体的な Dog や Cat クラスを直接参照する必要がありません。 Factory Pattern を使わない場合 次にFactory Pattern を使わない例を見てみましょう。 // animal-no-factory.ts interface Animal { speak (): void ; } class Dog implements Animal { speak () { console .log( 'Woof!' ); } } class Cat implements Animal { speak () { console .log( 'Meow!' ); } } // Factory を使用せずに直接インスタンス化 const dog = new Dog(); const cat = new Cat(); // bun run animal-no-factory.ts dog.speak(); // Woof! cat.speak(); // Meow! Factory Pattern を使わない場合、クライアントは具体的なクラスに依存してしまいます。 つまり、 new Dog() や new Cat() のように直接インスタンス化する必要があり、新しい動物クラス、例えば Bird を追加した場合はクライアントコードの修正も必要になります。 また、 生成ロジックが複数の場所に分散してしまうため、初期化処理や条件分岐が散らばり、複雑なコンストラクタや初期設定が増えると保守が大変になります 。 さらに、実行時にどのクラスを生成するかを動的に切り替えるのが難しく、柔軟性にも欠けるという問題があります。 一方で Factory Pattern を使うと、クライアントは型である Animal に依存するだけで済むため、具体クラスに直接触れる必要がなくなります。生成ロジックを一箇所にまとめられるため、コードの可読性や保守性が向上します。 patterns.dev にはオブジェクトを生成する関数が例として紹介されています( Factory Pattern より)。 いずれにせよ、Factory Pattern の核心は オブジェクト生成ロジックのカプセル化 にあります。Factory Function を使うか Class Constructor を使うかは実装の詳細であり、重要なのは生成ロジックを一箇所にまとめることで、クライアントコードを具体的な実装から切り離すことができる点です。 MiiTel Phone における活用例 MiiTel Phone では、ブラウザ互換性を確保するために Factory Pattern を活用しています。以下、具体的な事例を紹介します(コード例は実際のコードとは異なります)。 MediaStream の取得に際しては、制約の正規化やリスナーの設定、トラック制御といった共通処理が必要になります。Factory Pattern を使うことでこれらの処理をカプセル化し、クライアントコードから隠蔽しています。 // --- Custom MediaStream Factory --- // MediaStreamを取得する方法をカプセル化する const mediaStreamFactory = async ( constraints : MediaStreamConstraints ) => { const normalizedConstraints = normalizeContraints(constraints); const mediaStream = await navigator .mediaDevices. getUserMedia (normalizedConstraints); setupListenersForMediaStream(mediaStream); for ( const track of mediaStream.getTracks()) { track.enabled = false ; } return mediaStream; } ; // --- クライアント側のコード例 --- // クライアントは内部処理を意識せずに必要な MediaStream を取得できる const startCall = async () => { // audio stream を取得 const stream = await mediaStreamFactory( { audio : { deviceId : 'default' }} ); // この後、通話・録音などに stream を利用できる } startCall(); Factory Pattern を使わない場合の問題点 Factory Pattern を使わない場合、コードは以下のように各所で条件分岐を書く必要があります。 const constraints = { audio : { deviceId : micId } } ; const normalizedConstraints = normalizeConstraints(constraints); const mediaStream = await navigator .mediaDevices. getUserMedia (normalizedConstraints); setupListenersForMediaStream(mediaStream); for ( const track of mediaStream.getTracks()) { track.enabled = false ; } MediaStream の取得に伴う共通処理が複数箇所に散らばってしまうことや、通話開始、デバイス変更、Re-INVITE *2 などの場面で同じ処理を繰り返し書く必要がある点、さらに新しい要件が追加された場合にはすべての箇所を修正しなければならない点が問題となります。 このような状況に対して、Factory Pattern を使うことで生成ロジックを一箇所にまとめられるため、クライアントコードをシンプルに保ち、保守性を大幅に向上させることができます。 Container/Presentational Pattern フロントエンド開発では、アプリケーションが大きくなるとコンポーネントの管理が難しくなります。そんなときに役立つのが Container/Presentational Pattern です。 Container/Presentational Pattern とは、 コンポーネントを状態やロジックを持つコンテナと表示だけを担当するプレゼンテーションに分ける デザインパターンのことです。 Container Component データ取得、状態管理、ビジネスロジックを担当 UI 表示にはほとんど関与しない Presentational コンポーネントに props を渡して描画させる Presentational Component 親から渡されたデータをもとに UI を描画する 基本的に状態を持たない 再利用性が高く、単体テストがしやすい 以下は簡単なサンプルコードになります。 // Presentational Component export const UserList = ( { users } ) => ( < ul > { users . map ( user = > ( <li key = { user . id } className = "text-zinc-900" >{user.name}</li> )) } </ ul > ); // Container Component import { useState, useEffect } from 'react' ; export const UserListContainer = () => { const [ users , setUsers ] = useState( [] ); useEffect(() => { fetch ( '<https://api.example.com/users>' ) . then ( res => res.json()) . then ( data => setUsers(data)); } , [] ); return < UserList users = {users} />; } ; このよう役割ごとにコンポーネントを分けることで、UI とロジックを分離できます。 React Hooks の活用 React Hooks が登場してからは、状態管理や副作用の処理を簡単に切り出せるようになりました。以下のように、カスタムフックを作れば Container Component をさらにスッキリさせられます。 import UserList from './user-list' ; import useUsers from './use-users' ; export const UserListContainer = () => { const { users } = useUsers(); return < UserList users = {users} />; } ; patterns.dev では「React Hooks を使用した場合でも Container/Presentational Pattern を使用できるが、小規模なアプリケーションでは過剰になる可能性がある」と記載されています( Container/Presentational Pattern より)。このパターンを使うかどうかはプロジェクトの規模を考慮した上で判断するのが良さそうです。 Container/Presentational Pattern を使わない場合 import useUsers from './use-users' ; export const UserListContainer = () => { const { users } = useUsers(); return ( < ul > { users . map ( user = > ( <li key = { user . id } className = "text-zinc-900" >{user.name}</li> )) } </ ul > ); } ; Container/Presentational Pattern を使わない場合は上記のようなコードになります。1 つのコンポーネント内でデータ取得とスタイリングを行っていますが、Hooks でデータ取得を行っているので、部分的に関心の分離 *3 ができていると言えます。ただし、完全に関心の分離ができているとは言えない点に注意してください。 MiiTel Phone における活用例 MiiTel Phone では、Atomic Design と Container/Presentational Pattern を組み合わせて活用しています。 Atomic Design とは、UI を構造的に設計するための方法論です。Brad Frost 氏によって提唱されました( ブログ記事 )。小さな部品を組み合わせて効率よく、一貫性のある UI を作ることを目的としています。 詳しい説明は前述した Brad Frost 氏のブログ記事に譲るとして、プロジェクトのディレクトリ構造は以下のようになっています。 src/components/ ├── atoms/ # これ以上分解できない基本的な要素 (Button, Input など) ├── molecules/ # 小さな機能を持つ部品として再利用可能 (Accordion, FieldInput など) ├── organisms/ # ページの中で独立して機能する部分 (Modal, Header など) ├── templates/ # organisms を配置してページのレイアウトを定義 └── pages/ # 実際のコンテンツを templates に流し込んだ完成形 この構造の中で、Container/Presentational Pattern は主に次のように適用されています。 Container Component: pages ディレクトリに配置 Presentation Component: pages ディレクトリ以外に配置 Atomic Design と Container/Presentational Pattern を組み合わせるメリット この組み合わせの利点は複数あります。 まず、関心の分離が強化されるため、 UI の変更がビジネスロジックに与える影響を最小限に抑える ことができます。例えば、Organisms や Molecules の見た目を変更しても、データ取得のロジックを持つ Container Component にはほとんど影響を与えません。逆に、データ取得や状態管理の方法を変更しても、Presentational Component 側はそのまま使い続けることができます。 また、この設計は テストの容易性 にも寄与します。Presentational Component は純粋関数のように振る舞うため、与えられた props に応じて正しい UI が描画されるかを簡単に検証できます(MiiTel Phone では Storybook や Chromatic を使った UI テストを実施しています)。一方で Container Component は、API からのデータ取得や状態管理のテストに集中できるため、役割ごとにテストの粒度を分けやすくなります。 さらに、Atomic Design の階層と Container/Presentational Pattern の組み合わせにより、 コンポーネントの再利用性 が向上します。Atoms や Molecules はプロジェクト全体で共通して使用できる部品として整備され、Organisms はある程度独立した UI 機能を持つ単位として他のページでも再利用できます。Container Component は各ページや画面ごとに異なるデータフローを管理する役割に集中するため、UI 部品の再利用性を損なうことなく画面ごとの差異を吸収できます。 Middleware Pattern 次に、JavaScript で最も特徴的なデザインパターンである Middleware Pattern について見ていきたいと思います。 Middleware Pattern とは、 処理の途中に挟む共通の処理層を作ることで、コードの再利用性や柔軟性を高める デザインパターンのことです。 MiiTel Phone では、HTTP クライアントライブラリの Axios を使用しており、その Interceptor 機能 が Middleware Pattern の典型的な実装例となっています。 Axios Interceptor の使用例 Axios の Interceptor を使うことで、HTTP リクエストやレスポンスの直前・直後に共通処理を挿入できます。例えば、リクエストヘッダーへの認証情報の追加や、レスポンスエラーの統一的な処理などを行うことが可能です。 import axios from 'axios' ; const apiClient = axios.create( {} ); // Request Interceptor apiClient.interceptors.request.use( ( config ) => { // リクエストごとに識別子をヘッダーとして追加 const requestID = crypto .randomUUID(); config. headers [ "X-Request-ID" ] = requestID; console .log( 'Request sent:' , config. url ); return config; } , ( error ) => { return Promise . reject (error); } ); // Response Interceptor apiClient.interceptors. response .use( ( response ) => { console .log( 'Response received:' , response. status ); return response; } , ( error ) => { // 401 エラーの共通処理 if (error. response ?. status === 401 ) { console .log( 'Unauthorized! Redirecting to login...' ); // ログインページへリダイレクト処理 } return Promise . reject (error); } ); // API call apiClient. get ( '/users' ) . then ( response => console .log(response.data)) . catch ( error => console .error(error)); このように、Axios の Interceptor を活用することで、HTTP リクエストやレスポンスの処理を共通化・一元管理できるようになります。Interceptor がなければ、認証トークンの付与、共通ヘッダーの設定、レスポンスのエラーチェックなどを全てのリクエストで毎回書く必要があります。 Axios の Middleware Pattern 実装を見る さて、ここまでは Middleware Pattern で実装されたライブラリの使い方を見ましたが、Middleware Pattern の実装方法自体を確認したわけではありません。このまま終わっては面白くないので Axios のコードを確認して、実装方法を見てみましょう。 InterceptorManager の実装 それでは、実際に Axios のソースコードを確認して、Middleware Pattern がどのように実装されているのかを見ていきます。 まず、Interceptor を管理する InterceptorManager クラスです。 lib/core/InterceptorManager.js class InterceptorManager { constructor () { this .handlers = [] ; // interceptor を配列で管理 } /** * 新しい interceptor を登録 */ use ( fulfilled , rejected , options ) { this .handlers. push ( { fulfilled , // 成功時の処理 rejected , // 失敗時の処理 synchronous : options?.synchronous ?? false , runWhen : options?.runWhen ?? null // 条件付き実行 } ); // ID を返す(後で削除できるように) return this .handlers. length - 1 ; } /** * interceptor を削除 */ eject ( id ) { if ( this .handlers[id]) { this .handlers[id] = null ; // 無効化のため null をセット } } /** * 全ての interceptor を削除 */ clear () { if ( this .handlers) { this .handlers = [] ; } } /** * 登録されている interceptor を順に処理 */ forEach ( fn ) { this .handlers. forEach (( h ) => { if (h !== null ) { fn(h); } } ); } } この実装で注目すべき点は、 Interceptor を配列で管理している ことです。 use() メソッドで追加し、 eject() メソッドで削除できる柔軟な設計になっています。 また、削除時に配列から要素を取り除くのではなく null に設定することで、他の Interceptor の ID がずれないようにしている点もポイントです。これにより、Interceptor の ID を使った安全な削除が可能になります。 Axios クラスの構造 次に、Axios クラスのコンストラクタを見てみましょう。 lib/core/Axios.js class Axios { constructor ( instanceConfig ) { this .defaults = instanceConfig || {} ; this .interceptors = { request : new InterceptorManager(), // For requests response : new InterceptorManager() // For response } ; } } リクエスト用とレスポンス用、それぞれ独立した InterceptorManager インスタンスを持っています。 Interceptor チェーンの実行 Interceptor で最も重要なポイントは、 request() メソッド内でのチェーン実行の順序です。 lib/core/Axios.js request(configOrUrl, config) { // ... 設定のマージなど ... // ① request interceptor を収集 const requestInterceptorChain = [] ; let synchronousRequestInterceptors = true ; this .interceptors.request. forEach (( interceptor ) => { // runWhen 条件チェック if ( typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false ) { return ; // 条件が合わない場合はスキップ } synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous; // unshift を使用し先頭に追加(後に追加されたものが先に実行される) requestInterceptorChain. unshift ( interceptor.fulfilled, interceptor.rejected ); } ); // ② response interceptor を収集 const responseInterceptorChain = [] ; this .interceptors. response . forEach (( interceptor ) => { // push で末尾に追加(登録された順に実行される) responseInterceptorChain. push ( interceptor.fulfilled, interceptor.rejected ); } ); let promise; // ③ 非同期の場合:Promise チェーンを構築 if (!synchronousRequestInterceptors) { // 実際の HTTP リクエスト処理を中心とする const chain = [ dispatchRequest. bind ( this ), undefined ] ; // request interceptor を前に追加 chain. unshift (...requestInterceptorChain); // response interceptor を後ろに追加 chain. push (...responseInterceptorChain); // ④ チェーンを順番に実行 promise = Promise . resolve (config); let i = 0 ; while (i < chain. length ) { promise = promise. then (chain[i++], chain[i++]); } return promise; } // 同期処理の場合の処理... } Middleware の実行順序(LIFO と FIFO) Request Intercepter は unshift を使って配列の 先頭 に追加しているため、 登録順の逆順 で実行されます。これは LIFO *4 の動作です。 // Request Intercepter 1 axios.interceptors.request.use( config => { console .log( 'First' ); // 実際には 2 番目に実行される return config; } ); // Request Intercepter 2 axios.interceptors.request.use( config => { console .log( 'Second' ); // 実際には 1 番目に実行される return config; } ); // Output: "Second" → "First" これは直感的ではないように見えますが、実は理にかなっています。後から追加される処理(例えば認証チェックや特定のヘッダー追加)をより早い段階で実行したいケースが多いためです。スタック構造(LIFO)を採用することで、より具体的な処理を後から上乗せできる設計になっています。 一方、Response Intercepter は push を使って配列の 末尾 に追加するため、 登録順 に実行されます。これは FIFO *5 の動作です。 // Response Intercepter 1 axios.interceptors. response .use( response => { console .log( 'First' ); // 最初に実行される return response; } ); // Response Intercepter 2 axios.interceptors. response .use( response => { console .log( 'Second' ); // 次に実行される return response; } ); // Output: "First" → "Second" レスポンスの場合はキュー構造(FIFO)となり、登録順に処理されます。これにより、基本的なデータ変換を先に行い、その後でより具体的な処理を行うという自然な流れを作ることができます。 Middleware Pattern の利点 この実装から、Middleware Pattern の利点が見えてきます。まず、認証、ログ、エラーハンドリングなど、異なる責務を独立したミドルウェアとして実装できるため、 関心の分離 が実現できます。また、一度書いたミドルウェアを複数の場所で使い回せる 再利用性の高さ も魅力です。 今回は HTTP クライアント Axios の Middleware Pattern の実装を見ましたが、HTTP サーバー( Express , Koa など) にも Axios とは違った Middleware の実装がされているので興味がある方は調べてみてください。 Observer Pattern Observer Pattern は MiiTel Phone において中心的な役割を担っているデザインパターンです。後ほど触れますが、具体的には SIP イベントとアプリケーション状態を同期させる重要な役割を担っています。 まず、Observer Pattern とはある オブジェクトの状態変化を、依存する複数のオブジェクトへ自動的に通知する仕組みを提供する デザインパターンのことです。 Node.js には組み込みの EventEmitter クラス が定義されており、それを使うことで特定のイベントが発生した時に呼び出される関数をリスナーとして登録できます。また、ブラウザ用にバンドルされた events モジュール を使えばブラウザでも EventEmitter が使えます。 ただし、ここでは Observer Pattern の概念を直感的に理解することを目的に、Node.js の EventEmitter に近いインターフェースを持つシンプルな自作 EventEmitter をブラウザ用に実装した例を紹介します。 シンプルな自作 EventEmitter の実装 以下は最小限のコードで実装された EventEmitter で、on / off / emit といった基本的なイベント購読・解除・通知が行えます。 // emitter.ts type EventsMap = Record < string , unknown >; type Handler < T > = ( event : T ) => void ; type ListenerEntry < T > = { handler : Handler < T > ; once : boolean ; } ; export const createEmitter = < T extends EventsMap >() => { // 各イベント名ごとにリスナーの配列を保持する const all = new Map < keyof T , ListenerEntry < unknown >[]>(); // イベントが発火するたびに呼ばれる通常のリスナーを登録する const on = < K extends keyof T >( type : K , handler : Handler < T [K]>) => { const handlers = (all. get (type) as ListenerEntry < T [K]>[] | undefined ) ?? [] ; handlers. push ( { handler , once : false } ); all. set (type, handlers as ListenerEntry < unknown >[]); } ; // 次にそのイベントが発火したとき 1 回だけ実行され、自動的に解除されるリスナーを登録する const once = < K extends keyof T >( type : K , handler : Handler < T [K]>) => { const handlers = (all. get (type) as ListenerEntry < T [K]>[] | undefined ) ?? [] ; handlers. push ( { handler , once : true } ); all. set (type, handlers as ListenerEntry < unknown >[]); } ; // 特定のイベントリスナーだけを削除する const off = < K extends keyof T >( type : K , handler : Handler < T [K]>) => { const handlers = all. get (type) as ListenerEntry < T [K]>[] | undefined ; if (!handlers) return ; const filtered = handlers. filter (( h ) => h.handler !== handler); all. set (type, filtered as ListenerEntry < unknown >[]); } ; // 登録されたリスナーを順番に実行し、once の場合は残さないようにフィルタリングする const emit = < K extends keyof T >( type : K , event : T [K]) => { const handlers = all. get (type) as ListenerEntry < T [K]>[] | undefined ; if (!handlers) return ; const remaining: ListenerEntry < T [K]>[] = [] ; for ( const h of handlers) { h.handler(event); if (!h.once) remaining. push (h); } all. set (type, remaining as ListenerEntry < unknown >[]); } ; return { on , once , off , emit } ; } ; 上記は最小限の実装ですが、Node.js の EventEmitter の基本的な処理を踏襲しています。 以下の使用例は message と count という 2 種類のイベントを持つ Emitter を作成し、それぞれにリスナーを登録してイベント発火するだけのシンプルなものです。 // example.ts type MyEvents = { message : string ; count : number ; } ; const emitter = createEmitter< MyEvents >(); emitter.on( "message" , ( msg ) => console .log( "on message:" , msg)); emitter.once( "count" , ( n ) => console .log( "once count:" , n)); console . log ( "---- First emit ----" ); emitter.emit( "message" , "1st hello!" ); emitter.emit( "count" , 1 ); console . log ( "---- Second emit ----" ); emitter.emit( "message" , "2nd hello!" ); emitter.emit( "count" , 2 ); // ← once 用リスナーはすでに削除済み // bun run example.ts // ---- First emit ---- // on message: 1st hello! // once count: 1 // ---- Second emit ---- // on message: 2nd hello! 実行結果を見ると、 message イベントは発火するたびに登録されたリスナーが毎回実行されるのに対し、 count イベントに登録されたリスナーは once を使っているため最初の 1 回だけ実行され、2 回目の emit("count", 2) ではすでにリスナーが削除されているため何も起こりません。 この簡易的な EventEmitter でイベントを複数のリスナーに通知するという振る舞いがなんとなく分かっていただけたと思います。今回、一応実装はしてみましたが、ブラウザで EventEmitter を使いたい場合は、軽量な mitt というライブラリがおすすめです。 SIP.js と Observer Pattern さて、次に MiiTel Phone において最も重要なライブラリと言っても過言ではない SIP.js について触れていきたいと思います。 SIP.js とは WebRTC ベースの VoIP 通話機能を実現するためのライブラリです。 SIP プロトコルを介した PBX との通信、WebRTC セッションの管理、音声ストリームの制御など、通話機能の低レベルな実装を担当します。SIP について解説するのはこの記事では全然足りないので割愛します。 SIP.js の最大の特徴は、内部で イベント駆動型のアーキテクチャ を採用している点です。 Registerer や Session といった主要オブジェクトは Observer Pattern に則って構築されており、通話の着信や接続、終了、メッセージ受信など、さまざまな状態変化をイベントとして外部に通知します。開発者はこれらのイベントにリスナーを登録することで、アプリケーションの UI や内部状態をリアルタイムに同期させることができます。 SIP.js の EventEmitter 実装 SIP.js では以下の EventEmitter が実装されています。 src/api/emitter.ts export interface Emitter < T > { addListener ( listener : ( data : T ) => void , options? : { once ?: boolean } ): void ; removeListener ( listener : ( data : T ) => void ): void ; on ( listener : ( data : T ) => void ): void ; // deprecated off ( listener : ( data : T ) => void ): void ; // deprecated once ( listener : ( data : T ) => void ): void ; // deprecated } export class EmitterImpl< T > implements Emitter< T > { private listeners = new Array <( data : T ) => void >(); public addListener ( listener : ( data : T ) => void , options? : { once ?: boolean } ): void { const onceWrapper = ( data : T ): void => { this .removeListener(onceWrapper); listener(data); } ; options?.once === true ? this .listeners. push (onceWrapper) : this .listeners. push (listener); } public emit ( data : T ): void { this .listeners. slice (). forEach (( listener ) => listener(data)); } public removeAllListeners (): void { this .listeners = [] ; } public removeListener ( listener : ( data : T ) => void ): void { this .listeners = this .listeners. filter (( l ) => l !== listener); } public on ( listener : ( data : T ) => void ): void { return this .addListener(listener); } public off ( listener : ( data : T ) => void ): void { return this .removeListener(listener); } public once ( listener : ( data : T ) => void ): void { return this .addListener(listener, { once : true } ); } } on / off / once といった基本的なイベント操作が提供されており、先ほど書いた自作の EventEmitter と同様に、特定のイベントに対してリスナーを登録したり削除したりすることができます。 次に実際にどのようにして SIP.js から MiiTel Phone に通知が送られているのかを一連の流れを追いながら簡単に確認してみたいと思います。 今回は Session クラス の実装を確認してみます。SIP.js の Session は、2 つの端末間の通話や接続を管理するオブジェクトです。通話の開始(INVITE)、応答(ACK)、終了(BYE)、転送(REFER)などの処理をまとめて扱うことができます。また、通話の保留や条件変更も Session を通して行います。 Session クラスにおける EventEmitter の利用 まず初めに、Session クラスがどのように EventEmitter を初期化しているかを見てみましょう。 src/api/session.ts export abstract class Session { private _state : SessionState = SessionState.Initial; private _stateEventEmitter : EmitterImpl < SessionState >; protected constructor ( userAgent : UserAgent , options : SessionOptions = {} ) { this .delegate = options.delegate; // EmitterImpl を初期化(listeners = []) this ._stateEventEmitter = new EmitterImpl< SessionState >(); this ._userAgent = userAgent; } public get stateChange (): Emitter < SessionState > { return this ._stateEventEmitter; // 外部には Emitter として公開 } protected stateTransition ( newState : SessionState ): void { // 状態遷移の妥当性チェック // 状態を更新 this ._state = newState; this .logger. log ( `Session ${ this . id} transitioned to state ${ this ._state } ` ); // ここで emit() を呼んで全リスナーに通知 this ._stateEventEmitter.emit( this ._state); // Terminated になったら自動的に dispose if (newState === SessionState.Terminated) { this .dispose(); } } } Session クラスは内部に _stateEventEmitter という EmitterImpl のインスタンスを持っています。コンストラクタでこれが初期化され、この時点では listeners 配列は空です。外部からは stateChange というゲッターを通じてアクセスでき、ここにリスナーを登録することで状態変化の通知を受け取れるようになります。 状態が遷移するときは stateTransition メソッドが呼ばれ、このメソッド内で _stateEventEmitter.emit() を実行することで、登録されているすべてのリスナーへ通知が送られます。 発信シナリオのイベント通知フロー それでは、実際に発信(Inviter)のシナリオで、どのように状態変化が通知されるのかをステップごとに見ていきましょう。 アプリケーションコード(MiiTel Phone 側の実装)で Inviter のインスタンスを作成します。 // アプリケーションコード const target = UserAgent.makeURI( "sip:bob@example.com" ); const inviter = new Inviter(userAgent, target); 内部では Inviter クラスのコンストラクタが実行されます。 src/api/inviter.ts // Inviter クラスのコンストラクタ export class Inviter extends Session { constructor ( userAgent : UserAgent , targetURI : URI , options? : InviterOptions ) { // 親クラス(Session)のコンストラクタを呼ぶ super (userAgent, options); // Session のコンストラクタ内 // this._stateEventEmitter = new EmitterImpl<SessionState>(); // this._state = SessionState.Initial; // この時点での状態 // _state = SessionState.Initial // _stateEventEmitter.listeners = [] (まだリスナーは0個) } } Inviter は Session を継承しているため、親クラスのコンストラクタが呼ばれ、 EmitterImpl が初期化されます。この時点では状態は Initial で、リスナーは 1 つも登録されていません。 次に、アプリケーション側で状態変化を監視するリスナーを登録します。 // アプリケーションコード inviter.stateChange.addListener(( newState ) => { console .log( `[リスナー1] セッション状態: ${ newState } ` ); } ); inviter.stateChange.addListener(( newState ) => { if (newState === SessionState.Established) { console .log( `[リスナー2] 通話が確立されました` ); } } ); inviter.stateChange.addListener(( newState ) => { console .log( `[リスナー3] 初回のみ: ${ newState } ` ); } , { once : true } ); これらのリスナーが登録されると、 EmitterImpl の内部でどのような処理が行われるのでしょうか。 src/api/emitter.ts // EmitterImpl の addListener() が呼ばれる public addListener(listener: ( data : T ) => void , options?: { once ?: boolean } ): void { const onceWrapper = ( data : T): void => { this . removeListener ( onceWrapper ); listener ( data ); } ; options?.once === true ? this .listeners. push (onceWrapper) // リスナー3: ラッパー関数 : this .listeners. push (listener); // リスナー1, 2: そのまま } // この時点での内部状態 // _stateEventEmitter.listeners = [ // listener1, // (newState) => { console.log(`[リスナー1] ... `) } // listener2, // (newState) => { if (newState === ...) { ... } } // onceWrapper // once オプション用のラッパー // ] 通常のリスナーはそのまま listeners 配列に追加されますが、 once: true オプション付きのリスナーは、ラッパー関数でくるまれて追加されます。このラッパー関数は、実行時に自分自身を削除してから元のリスナーを呼び出すという仕組みになっています。 では、実際に発信を開始します。 // アプリケーションコード await inviter.invite(); Inviter クラスの invite() メソッドが実行されます。 src/api/inviter.ts // Inviter クラスの invite() メソッド export class Inviter extends Session { public async invite ( options : InviterInviteOptions = {} ): Promise < OutgoingInviteRequest > { // 状態チェック if ( this . state !== SessionState.Initial) { throw new Error ( `Invalid session state ${ this . state} ` ); } // 状態を Establishing に遷移 this .stateTransition(SessionState.Establishing); // → この時点で emit() が呼ばれる! // INVITE リクエストを送信 const inviteRequest = this .userAgentCore.invite( /* ... */ ); return inviteRequest; } } まず現在の状態が Initial であることをチェックし、その後 stateTransition を呼び出して状態を Establishing に遷移させます。 stateTransition メソッドの内部処理を詳しく見てみましょう。 src/api/session.ts // Session クラスの stateTransition() メソッド protected stateTransition(newState: SessionState): void { // 現在の状態は Initial console . log ( `現在の状態 : $ {this._state}` ); // "Initial" // 状態遷移の妥当性チェック switch ( this._state ) { case SessionState.Initial: if ( newState !== SessionState.Establishing && newState !== SessionState.Established && newState !== SessionState.Terminating && newState !== SessionState.Terminated ) { throw new Error ( `Invalid state transition` ); } break ; // ... 他のケース ... } // 状態を更新 this . _state = newState ; // Initial → Establishing this . logger . log ( `Session ${this.id} transitioned to state ${this._state}` ); // ★★★ ここで emit() を呼ぶ ★★★ this . _stateEventEmitter . emit ( this._state ); // → emit(SessionState.Establishing) が実行される } まず現在の状態から新しい状態への遷移が妥当かどうかをチェックします。 Initial から Establishing への遷移は許可されているため、状態を更新し、その後 emit() を呼び出します。 emit() メソッドが呼ばれると、登録されているすべてのリスナーが実行されます。 src/api/emitter.ts // EmitterImpl の emit() メソッド public emit(data: T): void { // listeners 配列のコピーを作成 const listenersCopy = this . listeners . slice (); // listenersCopy = [listener1, listener2, onceWrapper] // 各リスナーを順番に実行 listenersCopy . forEach ( (listener ) => listener ( data )); // data = SessionState.Establishing } emit() メソッドは、まず listeners 配列のコピーを作成します。これは、リスナーの実行中に配列が変更される可能性があるためです。その後、各リスナーを順番に実行していきます。 各リスナーの実行順序を見てみましょう。 // リスナー1 が実行される listener1(SessionState.Establishing) → console . log ( `[リスナー1] セッション状態: Establishing` ); // コンソール出力: "[リスナー1] セッション状態: Establishing" // リスナー2 が実行される listener2(SessionState.Establishing) → if (SessionState.Establishing === SessionState.Established) { ... } // false なので何も出力されない // リスナー3(onceWrapper)が実行される onceWrapper(SessionState.Establishing) ① this .removeListener(onceWrapper) // 自分を削除 ② listener3(SessionState.Establishing) → console . log ( `[リスナー3] 初回のみ: Establishing` ); // コンソール出力: "[リスナー3] 初回のみ: Establishing" // この時点で listeners 配列の状態: // _stateEventEmitter.listeners = [listener1, listener2] // (onceWrapper は削除された) 最初のリスナーは単純にコンソールに出力します。2 つ目のリスナーは条件に合致しないため何もしません。3 つ目の onceWrapper は、まず自分自身を listeners 配列から削除し、その後元のリスナーを実行します。これにより、このリスナーは今後呼ばれることはありません。 SIP サーバーから 200 OK レスポンスを受信すると、Inviter クラスの内部処理が実行されます。 // SIPサーバーから 200 OK レスポンスを受信 // ↓ // Inviter クラスの内部処理 private onAccept(response: IncomingResponseMessage): void { // ACK を送信 response . ack (); // SessionDescriptionHandler でメディアを設定 const body = getBody ( response.message ); await this . setAnswer ( body , options ); // 状態を Established に遷移 this . stateTransition ( SessionState.Established ); // → 再度 emit() が呼ばれる! } ACK を送信してメディアを設定した後、再び stateTransition を呼び出して状態を Established に遷移させます。 再度 stateTransition メソッドが呼ばれます。 // Session クラスの stateTransition() メソッド(再度呼ばれる) protected stateTransition(newState: SessionState): void { // 現在の状態は Establishing console . log ( `現在の状態 : $ {this._state}` ); // "Establishing" // 状態遷移の妥当性チェック switch ( this._state ) { case SessionState.Establishing: if ( newState !== SessionState.Established && newState !== SessionState.Terminating && newState !== SessionState.Terminated ) { throw new Error ( `Invalid state transition` ); } break ; // ... 他のケース ... } // 状態を更新 this . _state = newState ; // Establishing → Established this . logger . log ( `Session ${this.id} transitioned to state ${this._state}` ); // ★★★ 再度 emit() を呼ぶ ★★★ this . _stateEventEmitter . emit ( this._state ); // → emit(SessionState.Established) が実行される } 今度は Establishing から Established への遷移です。この遷移も妥当であるため、状態を更新して emit() を呼び出します。すると再び emit() メソッドが実行されて、リスナーが実行されます。 後続の処理は省略しますが、状態を更新して emit() を呼び出してリスナーを実行するという部分は上述した内容と大きくは変わりません。 Observer Pattern の利点 このパターンの優れている点は、 通知する側(Subject)が通知される側(Observer)の具体的な実装を知る必要がないという点 です。いわゆる 疎結合 ということです。Session クラスは単に状態が変わったので通知するという責任だけを持ち、その通知を誰がどのように使うかは、リスナーを登録する側が決定します。 共通のインターフェースを通じて通知を行うだけでよいため、変更や拡張に強い設計を実現できますし、新しい Observer を追加する場合でも Subject のコードを修正する必要がなく、既存機能に影響を与えずに振る舞いを拡張できるので、保守性の向上につながります。 さいごに SIP.js で使われている Delegate pattern や List Virtualization にも触れたいと思っていましたが、想像以上に記事が長くなってしまったため、ここで終わりたいと思います。 この記事では、MiiTel Phone における具体的なデザインパターンの活用事例を紹介しました。 デザインパターンは単なる理論ではなく、実際の開発現場で直面する具体的な課題に対する実践的な解決策です。特に、フロントエンド開発では状態管理の複雑化、非同期処理の制御、コンポーネント間の依存関係など、さまざまな設計上の課題が自然に発生します。こうした場面でデザインパターンを理解していると、車輪の再発明を避け、保守性の高い設計を行うことができます。 この記事が、デザインパターンへの理解を深め、日々の開発に活かすきっかけになれば幸いです。 参考文献 Casciaro, Mario, and Luciano Mammino 著,武舎広幸訳, Node.js デザインパターン 第 2 版,オライリー・ジャパン,2019 年 Patterns.dev, Patterns.dev – Improve how you architect webapps , 取得日 2025 年 12 月 6 日, https://www.patterns.dev *1 : Design Patterns: Elements of Reusable Object-Oriented Software(1994)を著した 4 名(Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides)のこと *2 : Re-INVITE は、主に SIP に関連する通信用語で、既に確立された通信セッションの中で設定を変更したいときに使われるリクエストのこと *3 : コードベースを複数の「関心(=役割・責務)」に分けて、それぞれを独立して設計・実装・変更できるようにする原則のこと *4 : Last In, First Out:後入れ先出し *5 : First In, First Out:先入れ先出し
はじめに MiiTel Advent Calendar 17 日目です。 RevComm でフロントエンドエンジニアをしている渡部と申します MiiTel では Recoil を使用しているアプリが多数ありますが、 MiiTel PhoneでRecoilからJotaiへの移行を行いました のように Jotai への移行を進めています。 私が開発を担当している MiiTel Analytics でも Recoil を使用しており、現在は Jotai と共存しながら新規では Jotai を使用、改修・リファクタリング時可能であれば Recoil -> Jotai への移行も合わせて行っていました。 しかし、 Store の数も使用している箇所も多く、日々他の業務を行いながらの移行作業は中々進めるのが大変でした。 そこで MiiTel でも活用している Devin に頑張ってもらいました。 tech.revcomm.co.jp 移行の進め方 Store の数も多かったため、まとめて実行し PR でレビューしてもらうのは大変です。 まずは作業前に Store の把握と使用しているファイルの洗い出しをしてもらい、 Store ごとのタスクを作成してもらいました。 Devin 実行の流れ Devin に全体把握してもらう タスクテンプレートを元にタスクを作成してもらう 作成したタスクごとに実行してもらう タスクごとに PR を作成してもらう PR レビュー タスクテンプレート # PR タイトル ## 作業ブランチ `main` ブランチから checkout し作業してください。 ## PR ### PR タイトル `{Notion Task ID} [main] PR Title` ### PR 内容 日本語で記載してください。 ### 作成 PR PR は Draft で作成してください。 ### CI チェック CI チェックが通ることを確認してください。 ### PR Assign `@watanabe` を Assignee に設定してください。 ### PR Label `Release: internal use` のラベルをつけてください。 ## タスク概要 タスク概要 ### 対象の移行済みファイル - ファイル1 - ファイル2 ## 実装計画 1 . 作業内容1 2 . 作業内容2 ## 実装手順 Devin の対応結果 タスク数はおよそ 26 件になり、約 4 ヶ月ほどで完了しました。 タスクの内容量で 1 週間どのぐらいお願いするか決めていて、 1 週間で 3 ~ 4 件ほど対応してもらいました。 この間も普段の業務の手を止めることなくすすめることができたのは非常に助かりました。 反省と次回への改善点 改善点1: タスク管理ツールの選択 課題: Notion へのタスク登録が手動でやる必要があり大変だった MiiTel ではタスクの管理に Notion を使用しているのですが、今回のタスク登録は手動で行う必要がありました。 Devin から Notion へアクセスはできるのですが、 チームで使用しているタスクテンプレートに対して適切な情報を埋め込むのは難しかったようです。 そのため作成したタスクは一旦 Markdown 形式で出力していただき、自分で登録することになりました。 改善策: Github Projectsの活用を検討 Devin に対してのタスクの管理には Github Projects の使用のほうが良かったかもしれません。 Devin, Notion, Github Projects の連携はまだ試したことがないため、次回以降試してみたいと思います。 改善点2: タスクの粒度 課題: Store 数が多くタスク内容にブレが生じた Store 数が多いためか、作成いただいたタスクの内容にもブレが生じることがありました。 作成いただいたタスクも多くなるため、ブレが生じたタスクの内容を修正するとなると発見が難しく、コンテキストも加算されてしまいます。 改善策: Storeのカテゴリごとにタスクを分ける Devin にタスクの作成を頂く場合も、ある程度数を絞ることがより精度の高いタスク作成につながると感じました。 次回の作成では Store のカテゴリごとにタスクを分けるなど、タスクの粒度を大きくすることを検討したいと思います。 まとめ Devin に Recoil -> Jotai への移行をしてもらった話を紹介しました。 時に精度の低い実装や反省点も見られましたが対応工数を大幅に削減できたため、非常に助かりました。 Roomba が掃除しやすいように道を整えるように、Devin にも作業しやすいようにタスクを整えることが重要だと感じました。 まだまだ Recoil -> Jotai への移行は続きます。 反省を活かして、より効率的に進めていきたいと思います。
Background Speaker diarization has become increasingly valuable in applications designed for high-noise environments, often tailored for complex audio settings, emphasizing robust audio processing capabilities. Initially, the development of these systems centered on audio data alone. However, there is a growing shift toward incorporating multimodal information, such as visual and textual cues, motivated by the fact that multimodal integration can significantly improve performance. Recent advancements in NLP have introduced powerful tools such as BERT and large language models (LLM). BERT is a pre-trained transformer model designed to process bidirectional context, enabling it to understand the relationships between words in a sentence more effectively. It has been widely adopted for tasks requiring fine-grained contextual understanding, such as text classification and question answering. On the other hand, LLMs extend these capabilities by scaling up model size and training on diverse datasets, enabling them to generate coherent text and handle complex reasoning tasks. These models have demonstrated their ability to capture semantic nuances, where leveraging contextual information can mitigate speaker ID errors and improve overall accuracy. This is the paper presented in ASJ(日本音響学会) jglobal.jst.go.jp Methods 1. Speaker Diarization with BERT The system processes a combination of input features: the initial speaker ID sequence, contextual embeddings for the current and subsequent sentences derived using BERT, and speaker embeddings for the current and next sentences obtained from the diarization model. These features are fed into a 3-layer LSTM network, designed to capture sequential dependencies across sentence boundaries, thereby enhancing the robustness of speaker identification. The output is a refined sequence of speaker IDs, referred to as the Second Pass Speaker ID Sequence. The model is trained with Cross Entropy loss, which optimizes accuracy in predicting speaker assignments. Speaker Diarization with BERT 2. Speaker Diarization with Large Language Models (LLMs) The second approach leverages the capabilities of an LLM to refine speaker diarization results by incorporating contextual understanding. The process begins with the outputs from the initial diarization step, including input sequences of speaker utterances and speaker IDs, which are potentially prone to errors. To address these issues, instructions are provided to the LLM, guiding it to correct speaker ID assignments based on the contextual information in the text. In this pipeline, the LLM processes the input text containing speaker utterances and their initially assigned speaker IDs and generates corrected speaker ID assignments in the output text. This integration of contextual embeddings and speaker IDs ensures more accurate speaker diarization. Speaker Diarization with Large Language Models (LLMs) Experiments 1. Experiment Setting Dataset RevComm Dataset: The RevComm dataset, a Japanese Meeting dataset, consists of 237 two-speaker conversations extracted from Zoom meetings. Each conversation is enriched with channel information, enabling the precise extraction and labeling of individual speakers. The dataset comprises approximately 189 hours of audio data, divided into 133 hours for training, consisting of 165 dialogues and 56 hours for testing, consisting of 72 dialogues. This dataset reflects real-world conversational scenarios, including variations in speech quality, overlapping speech, and environmental noise. AMI Meeting Corpus: The AMI Meeting Corpus is a widely used dataset for speaker diarization research, consisting of recorded multi-speaker meetings. The corpus features multiple audio sources, including individual headset microphones (IHM), single distant microphones (SDM), allowing for comprehensive testing of diarization models across varied acoustic conditions. For this study, the standard train-test split provided in the dataset was used to evaluate the effectiveness of our proposed approaches. Pretrained model rinna/llama-3-youko-8b for RevComm dataset Llama3.1-8b for the AMI dataset 2. Experiment Results This section presents the experimental results for both the BERT-based and LLM-based speaker diarization approaches, evaluated using the CDER. Insights into the impact of window sizes, speaker embeddings (SE), and performance trade-offs are discussed. RevComm Dataset (BERT): The BERT-based approach refines first-pass diarization results using contextual embeddings and SE. Table 1 summarizes the CDER results for different window sizes and configurations. The results demonstrate that increasing the window size improves performance, as larger windows provide additional context, enabling the model to make better-informed speaker attribution decisions. Additionally, integrating SE further reduces CDER, particularly with larger window sizes. The best performance of 5.79% CDER is achieved with a window size of 32 and SE, marking a significant improvement over the baseline CDER of 7.62%. CDER for BERT-based diarization on the RevComm dataset, evaluated with varying window sizes and the inclusion/exclusion of SE Character DER Pyannote Baseline 7.62% window 8 6.31% window 16 6.29% window 16 + SE 6.27% window 32 6.24% Window 32 + SE 5.79% RevComm Dataset (LLM): The LLM-based approach builds upon the output of the BERT-based model by leveraging the advanced contextual understanding of a large language model. Table 2 shows the CDER results for PyAnnote Baseline, BERT, and LLM configurations with a window size of 32. The LLM-based approach achieves the best overall performance, with a CDER of 5.00%. This result highlights the ability of LLMs to refine speaker labels further by effectively incorporating global contextual information. CDER Comparison on RevComm Dataset Character DER PyAnnote Baseline 7.62% BERT + SE Embedding 5.79% LLM (window size 32) 5.00% AMI Meeting Dataset(LLM): The LLM-based approach was also evaluated on the AMI dataset. Table 3 presents the CDER results for PyAnnote Baseline and the LLM-based approach. The results show significant improvements with the LLM-based method, reducing CDER by 5.6% for IHM and 7.2% for SDM, demonstrating the effectiveness and generalizability of the LLM-based method. AMI(IHM) AMI(SDM) PyAnnote Baseline 18.8% 22.4% LLM 13.2% 15.2% Conclusion In this study, we investigated the use of language models to improve speaker diarization accuracy through post-processing techniques, evaluating both BERT-based and LLM-based approaches. The BERT-based method is effective, and the LLM-based approach further improves the performance with higher computational costs. These findings highlight the trade-off between accuracy and computational efficiency, emphasizing the need for practical optimizations in real-world applications. Speaker Diarization with Language Model is a purely NLP-based post-processing module. Functioning as an ASR post-processor, it boosts accuracy and expands the potential for further analysis in products such as Recpod.
Rushed. Pushed. Silenced. These are the ingredients for burnout, not breakthroughs. In the current economic climate, the instinct to treat engineering teams like assembly lines is understandable; everyone is chasing revenue. But it is ultimately self-defeating. When you trade sanity for speed, you aren't just accruing technical debt; you are dismantling the very team you need to scale. My name is Ciptoning, currently with RevComm Indonesia. Throughout my career in this industry, I have seen this tension dismantle teams time and time again. The market pressure isn't going away, but our reaction to it must change. Navigating this 'Tech Winter' requires less panic and more precision. Great software isn't born from frantic coding sessions; it is born from clarity. If we want to survive the storm, we have to stop confusing 'busy' with 'productive' and get back to the one thing that actually drives value: thoughtful, deliberate planning. But how do we know if a plan is actually clear? I’ve worked with brilliant Product Managers who deliver PRDs that are works of art, yet the engineering output still stalls. The problem is rarely a lack of talent; it is a lack of translation. We have a missing link between the 'Product Vision' and the 'Technical Execution.' The requirements are there, but the bridge to turn those words into engineering reality hasn't been built. It often begins with the 'Silent Handover.' Engineers, unwilling to slow the momentum, accept complex docs without interrogation. Engineering leaders compound the error by confusing delegation with abdication, tossing empty ticket titles downstream without the necessary technical context. The result? A workflow that looks fast is actually paralyzed by constant clarification. Developers are forced to down tools mid-stream to hunt for answers that should have been there day one. By obsessing over a 'fast start,' we are guaranteeing a slow finish. The Silent Handover The escape route lies in borrowing a tactic from Amazon’s playbook: 'Working Backwards.' The concept is famous: write the press release before you write the code. But for a team operating under the Agile Scrum framework, the rhythm is different. We live in sprints, not quarters. We needed to adapt the tactic to fit the trenches. We don't need a literal press release; we need a 'Reverse Pitch.' Here is the rule we implemented: No ticket enters the sprint until the engineer can verbally explain the solution back to the Product Manager. This isn't just a reading assignment; it is a comprehension check. Instead of a silent handover, we force a conversation. The engineer presents the user journey, the edge cases, and the outcome before a single line of code is written. The impact was immediate. The mid-sprint chaos vanished because the confusion was caught upstream. By forcing the team to slow down and articulate the 'What' and the 'Why,' we unlocked the velocity to finally deliver the 'How.' Reverse Pitch But clarity is only half the battle. Once you know what you are building, you must decide if it is worth building at all. This brings us to the second pillar of survival: Ruthless Prioritization. In the chaotic world of B2C, the industry where I cut my professional teeth, user data is noisy and market trends shift overnight. Stakeholders often disguise hunches as requirements. They want everything, and they wanted it yesterday. The common trap is to let the loudest voice in the room dictate the roadmap. My approach is different: I demand an Impact Audit. We don't prioritize based on emotion; we prioritize based on evidence. Stakeholders must justify their requests not merely with "I think," but with a strategic hypothesis backed by data. If the data is murky, as it often is with new features, we rank by a clear rationale of potential upside versus engineering cost, rather than forcing a fictitious ROI calculation. And let’s be clear about bugs: if the customer journey is blocked, that isn't a ticket; it is a crisis. That is 'Priority Zero.' It overrides everything else. This rigorous filtering is necessary because we have forgotten the etymology of the word itself. 'Priority' was originally a singular noun. It means the very first thing. You cannot have five priorities. You have one. The rest is just a wish list. Singular Priority None of this feels intuitive in a tech culture addicted to speed. Creating 'Reverse Pitches' and enforcing 'Singular Priority' feels slow. It feels trivial. But in a 'Tech Winter,' these disciplines are the difference between shipping value and churning noise. We cannot control the market volatility. We cannot stop the waves. But we can choose whether we drown in the chaos or build a process strong enough to ride it.
RevCommモバイルアプリチームの横内です。本日はRevCommアドベントカレンダー12日目として、私たちがモバイルアプリチームとして実行しているログ戦略についてご紹介します。 私たちはMiiTel RecPodやMiiTel Phone Mobileなどのアプリを開発/運用しております。このようなtoBアプリにおいて、ログは単なるエラー検知以上の役割を持ちます。 特に重要なのは「 特定顧客の特定事象の再現性確保 」です。お客様から問題報告があった際、ログがなければ再現調査に多大な時間を要します。 本記事では、この再現性確保を目的としてDatadog RUM, SDKの機能とloggerパッケージを用いた実践的なFlutterアプリのデータ収集・分析について解説します。 環境に応じたログレベル制御と本番データの最適化 ログの検索効率とデータ量を最適化するため、環境に応じてログレベルを制御しています。 ログレベルの切り替え: 開発環境では Debug レベル までのログを許可し、本番環境では Info レベル以上 のログのみを許可しています。 制御方法: この切り替えは、 --dart-define を利用したビルド時の制御 によって行っています。 Info ログの配置 : 本番環境で有効な Info レベルのログは、処理の起点を示す目的で ViewModel や UseCase などの処理層の開始時に限定して配置しています。UI層やRepository層には原則配置しません。本番環境ではログが少ないほうが検索効率が上がるので、必要最低限のログにとどめています。 logger パッケージによるデータ収集と制御の構造 私たちが作っているモバイルアプリではログの記述には logger パッケージ を使用し、その出力をDatadog SDKにcustomアクションとして送信することで追跡可能なロギングを実現しています。 class AppLogger extends Logger { ... @override void i( dynamic message, { DateTime? time, Object? error, StackTrace? stackTrace, }){ ... super .i(message, time: time, error: error, stackTrace: stackTrace); stackTrace ??= Trace.current(1).terse; // customアクションのコード例 DatadogSdk.instance.rum?.addAction( RumActionType.custom, name: message.toString(), attributes: { 'level' : 'Info' , 'stackTrace' : stackTrace.toString(), } ); ... } } メタデータのグローバル付与 ログメッセージ内に手動で追跡用IDを埋め込むと、付与漏れや誤った情報が記録されるリスクがあります。 Datadog SDKの機能には 、すべてのログとRUMイベントに追跡用IDを自動で付与する仕組みがあります。 ユーザーがログインした際などに setUserInfo / addUserExtraInfo を呼び出すと、ユーザーIDやセッションIDなどの基本的な情報やテナントのIDといった カスタム属性 がその後のすべてのログおよびRUMイベントに付与することができます https://docs.datadoghq.com/ja/real_user_monitoring/application_monitoring/flutter/advanced_configuration/?tab=sdkversion265#track-user-sessions Datadog RUMによる「ユーザー操作の自動キャプチャ」 Flutterアプリケーションにおけるユーザーの操作と画面のコンテキストを効果的に収集するため、Datadog RUM (Real User Monitoring) SDKの自動トラッキング機能を活用しています。 これにより、エラー発生時の状況把握を迅速化し、再現調査の負担軽減を目指しています。 導入方法は以下を参考にしてください https://docs.datadoghq.com/ja/real_user_monitoring/application_monitoring/flutter/setup DatadogNavigationObserverによる画面遷移の自動追跡 エラーログを調査する際、ユーザーが「どの画面で」「どれくらいの時間操作していたか」という情報(View Event)は不可欠なコンテキストです。私たちは、この画面のコンテキストを自動で記録するために、Datadog SDKが提供する DatadogNavigationObserver を使用しています。 MaterialApp( home: HomeScreen(), navigatorObservers: [ DatadogNavigationObserver(DatadogSdk.instance), ], ); https://docs.datadoghq.com/ja/real_user_monitoring/application_monitoring/flutter/setup/?tab=rum#flutter-navigator-v1 この設定により、Datadog上でクラッシュやエラーが発生した直前のView Eventが明確に紐づけられ、再現調査に必要な画面のコンテキストを容易に把握できます。 RumUserActionDetectorとSemanticsによる操作アクションの記録 画面のコンテキストだけでなく、「ユーザーが何をしたか」というアクション(タップ、ジェスチャー)の記録も再現性には重要です。私たちは、RumUserActionDetectorとFlutterのSemanticsを組み合わせて、この操作アクションの記録を実現しています。 アプリの一番上の階層のウィジェットをRumUserActionDetectorでラップすることで、タップやスクロールといったユーザーのジェスチャーが自動的にキャプチャされ、RUM Action EventとしてDatadogに送信されます。これにより、アクションログの記録漏れを防ぎ、再現時の操作手順を後から辿りやすくしています。 RumUserActionDetector( rum: DatadogSdk.instance.rum, child: Scaffold( appBar: AppBar( title: const Text( 'RUM' ), ), body: // Rest of your application ), ); https://docs.datadoghq.com/ja/real_user_monitoring/application_monitoring/flutter/setup/?tab=rum#automatically-track-actions Semanticsによるアクション名の明確化 RumUserActionDetectorはTextButtonやListTileなど、テキスト情報を持つウィジェットに対する操作についてはそのテキストをアクション名として自動で記録する機能を持っています。 IconButtonやFloatingActionButtonなど、アイコンのみで構成されたボタン 画像やカスタム描画された装飾的なウィジェット これらは、RUM上で tap on InkWell(unknown) のように表示され、再現調査のボトルネックとなります。 そこで、名前が自動で付与されないウィジェットに対してFlutterの Semantics を活用し、意味のある名前を明示的に付与しています。 InkWell( ... onPressed: onPressed, child: Semantics( label: <付与したい名前>, child: SvgPicture.asset( ... ), ), ), これにより、RUM上で tap on InkWell(録音) のようにひと目でわかるテキストで表示されます。 まとめ 本記事では、toBアプリにおける再現性確保を目的としたログ戦略について解説しました。 環境別ログレベル制御: 本番環境ではInfoレベル以上に制限し、検索効率を最適化 loggerパッケージとDatadog SDKの連携: ログをカスタムアクションとして送信し、詳細情報を自動付与 Datadog RUMによる自動トラッキング: 画面遷移と操作アクションを自動記録 Semanticsによる明確化: アイコンボタンなどに意味のある名前を付与 これらの仕組みにより問題報告時などの状況把握と再現調査を迅速化し、顧客満足度の向上につなげています。
はじめに Corporate Engineering で社内システムの開発・運用を担当している瀧山です。 先日2025年11月14日に行われた、Salesforceが主催するハッカソンイベント「Agentforce Hackathon Tokyo」にTeam RevCommとして出場しました。今回は、そこで開発したソリューションの概要や技術構成、そして結果から得られた学びについて共有したいと思います。 本ブログ内で書かないこと Agentforceの設定に関する詳細な手順 Apex等の具体的なコーディング内容 想定読者 Salesforce Agentforce の活用に興味がある方 生成AIを用いたビジネスソリューション開発に関心がある方 ハッカソンへの取り組みや振り返りに興味がある方 参加の背景 Salesforceは現在、CRMツールとしての側面だけでなく、AIエージェント機能である「Agentforce」を強力に推進しています。今回参加した「Agentforce Hackathon Tokyo」は、このAgentforceを使用して任意のビジネス課題を解決するソリューションを作成し、プレゼンを行うというものです。 日本のSalesforce界隈で初めて開催されるハッカソンイベントであり、最先端の技術に触れられる機会であること、そして何より入賞時の賞金(1位はなんと150万円)やSalesforceの一大イベントでの表彰という点に惹かれ、エンジニア2名(同じチーム所属の長谷部と参加)で挑戦することにしました。 開発したソリューションについて チーム名・ソリューション名 チーム名 :Team RevComm ソリューション名 :Book Concierge(AI書籍購入サービス) 解決したい課題 書籍購入において、読者(買い手)と書店(売り手)双方に以下の課題があると考えました。 読者の課題 :レビューやランキングだけでは「今の自分」に最適な本がわからず、探すだけで疲れてしまう。 売り手の課題 :個別の顧客に寄り添った対応をしたいが、オペレーションに時間がかかり、機会損失や労働体験の低下を招いている。 これらを解決するため、「従来の検索型の購入体験」から、「対話を通じてAIがレコメンドする購入体験」への変革を目指しました。 使用した技術 Salesforceプラットフォーム上の以下の技術要素を組み合わせて構築しました。 Experience Cloud :ユーザーインターフェース(ECサイト)の構築 Data Cloud :書籍データや顧客データの統合・管理 Service Cloud :顧客対応・注文管理 Agentforce / Agent API :自律型AIエージェントの動作基盤 Prompt Builder :エージェントの回答精度の調整 Custom Retriever :RAG(検索拡張生成)におけるデータ検索ロジック 処理概要 開発した『Book Concierge』の処理フローは以下の通りです。 サイト訪問・対話開始 :ユーザーがExperience Cloudで構築されたサイトへアクセスし、エージェントとチャットを開始。 ニーズのヒアリングとレコメンド :ユーザーの曖昧な要望(例:「新卒IT社員に合う本」)に対し、AgentforceがData Cloud内の書籍情報を参照して最適な一冊を提案。 注文処理 :チャット画面上でそのまま注文を実行。 バックオフィス処理の自動化 :注文受付後、エージェントが注文内容の要約を作成し、確認メールのドラフトを自動生成。オペレーターの工数を削減。 結果について 37チームのエントリーから予選を通過し、20チームによる本戦(プレゼン)に出場しましたが、残念ながら 落選 という結果になりました。 個人的な話ですが、結果発表の翌週はずっと体調を崩してしまい、悔しさと共に38度台の熱で寝込むことになりました...。 振り返りと学び 審査員の方々からのフィードバックはいただけなかったのですが、自分たち自身の反省を含め、敗因は大きく以下の3点にあったと考えています。 1. 課題設定の甘さ これが最大の要因でした。初めての大会ということもあり、「わかりやすさ」を重視して一般的なECサイトの課題を設定しました。しかし、他の上位チームはより具体的かつ複雑なビジネス課題(特定の業界に特化した深い課題など)解決に取り組んでおり、課題設定の「深さ」や「温度感」で差をつけられました。 2. 準備不足 大会までの準備期間がタイトだったことに加え、業務の方でも重要な案件が重なってしまい、開発や資料作成に十分なリソースを割くことができませんでした。 3. チーム構成 今回はエンジニアのみの構成でしたが、プロダクトマネージャーやビジネスサイドのメンバー、あるいは他の技術領域のエンジニアなどを巻き込み、より多角的な視点でソリューションを磨き上げるべきでした。 今後について 今回のハッカソンでは悔しい結果に終わりましたが、Salesforceの最新AI技術であるAgentforceを実践的に扱えたことは大きな収穫でした。 本筋のハッカソンとは少し逸れますが、社内では現在、Salesforceに蓄積されたデータと、自社プロダクトである「MiiTel」の通話・会議履歴データを掛け合わせた業務効率化に取り組んでいます。 商談情報の自動サマリ作成 見積の自動作成 インサイドセールス向けの業務効率化施策 今後は、今回の経験を活かし、社内のSalesforce活用においてもAgentforce等のAI機能を積極的に取り入れ、より高度な自動化やデータ活用を推進していきたいと考えています。また、次回の機会があれば、より強いチーム体制と練り上げられた課題設定でリベンジしたいです。
ハロー、ホセです。今年の9月、PyConJPとGoConJPが同じ日に開催されることになり、どちらに参加するか迷いました。結局PyConJPに 参加すること にし、「GoConのスライドは後で見よう」と心に決めて広島へ向かいました。しかし、12月になった今でも一つもスライドを読んでいません(涙)。 ということで、Goへのお詫びとして必読書「 初めてのGo言語 第2版 」を購入し、毎晩Goの勉強を始めました。 本記事では、Python経験者でGoの初心者である私の観点から、Go言語で驚いたポイントを紹介します。Let’s go! Goはつまらない言語? Goの柱は互換性と安定性 Goはコンパイル言語 Goは強い型付け言語です Goの配列の型にはサイズが含まれます Pythonのlistのようなものはslice Goの定数はコンパイル時に確定する Go は値渡しだけど参照もできる? Goの文字列はバイト列? Goでは try/except がない おまけ:マスコットはGopherと呼ぶ まとめ 参照 Goはつまらない言語? 「つまらない」というのは「単純」という意味ではありませんし、「不変である」という意味ではありません。 - John Bodner 初めてGoコードを書いたのは2015年のインターンシップでした。2ヶ月間だけだったのであまり学べませんでしたが、当時C++しか知らなかった私にはコードの可読性がすごく印象的でした。その後Pythonを学んだときには、コードの見た目がGolangと似ているなとの感覚が残りました。 そして今年、ちゃんとGolangを真面目に勉強してBodner氏の本を読んだら、「Goはつまらない」という表現を見かけて驚きました。初印象と全然違うけれど、10年ほどの経験を重ねた今の私にはよくわかってきました。 「つまらない」とは、破壊的な変更をしないということ。信頼性があり、長く使え、保守可能なコードを書くということです。それはGoです。そして、その意思は言語のバージョン管理に繋がっています。 Goの柱は互換性と安定性 「つまらない」は良いことだ。「つまらない」であるからこそ、Goバーションの特異な点に気を取られず、本来の仕事に集中できるのだ。 - Russ Cox, How Go programs keep working. GopherCon 2022 安定なプログラムは実装中のコードだけではなく、リリース後も、長年信頼できるコードです。Goでは「互換性の約束」があります。例えば、Go 1.4のプログラムはGo 1.7で実行しても問題なく動きます。それを理想で終わらせないために、Golangの開発者たちは頑張っています。 ただし、将来の変更によってプログラムが破損しないことを保証するのは不可能。あのGoogleでもGoのアップデートでシステムが影響されることがあったらしい。 ところで、Golangはまだ1.x系です。Version 2 が出る時、約束は破られるのか?という質問に対して、Russ Cox(元 GoのTech Lead)はこう回答しました。 Go 2.0はリリースしません。なぜかというと、1.0のプログラムは2.0で実行できませんから。 - Russ Cox, How Go programs keep working. GopherCon 2022 ちゃんと約束を守ってくれると、コードのメンテナとして安心しますね。 Goはコンパイル言語 Goはコンパイル言語ですから、実行可能ファイルに変換してから実行する必要があります。 PythonとJavaScriptなどのインタプリタ言語では、簡単なスクリプトを書いてすぐに実行できますが、Goの場合はそうはいきません。 - 初めてのGo 言語 第2版 第11章 これは驚いたというより、長年PythonとJavaScriptを使ってから、再びコンパイル言語に戻るための慣れ期間が必要でした。実装中は go run で素早く試せます。 Python: # hello.py print ( "Hello, World!" ) # 実行: python hello.py Go // hello.go package main import "fmt" func main() { fmt.Println( "Hello, World!" ) } // 実行: go run hello.go // または // コンパイル: go build hello.go // 実行: ./hello Goは強い型付け言語です GoではTypeScriptのように型があり、Pythonと違って型が強制されます。Pythonでは型ヒントを書いても実行時にチェックされませんが、Goでは型が一致しないとコンパイルエラーになります。 Python: # 動的型付け x = 10 # 整数 x = "hello" # 文字列に変更可能 y = 5 print (x + str (y)) # 異なる型の結合には明示的な変換が必要 # 型ヒントがあっても強制されない例 def add_numbers (a: int , b: int ) -> int : return a + b # 型が違っても実行時エラーにならない result = add_numbers( "hello" , "world" ) # 実行できてしまう print (result) # "helloworld" が出力される Go: package main import "fmt" func main() { var x int = 10 // x = "hello" // コンパイルエラー: 型不一致 y := 5 fmt.Println(x + y) // 同じ型のみ演算可能 // 異なる型の結合 s := "Result: " + fmt.Sprint(x) fmt.Println(s) } 型についての細かなルールもありますので、 A tour of Go と「初めてのGo 第7章」がおすすめです。 Goの配列の型にはサイズが含まれます 配列が直接使われることは多くはない。 - 初めてのGo 言語 第2版 第3章 Goの配列は型に「長さ」も含まれるため、異なるサイズの配列は別型です。また、長さが異なる配列への型変換もできませんし、同じ関数に異なる長さの配列を渡すこともできません。これは「直感的ではない」と感じるかもしれませんが、Goの配列はただのサブキャラ。主人公であるスライスのために存在しています。 var x = [ 3 ] int { 1 , 2 , 3 } // type: [3]int var y = [...] int { 1 , 2 , 3 , 4 } // type: [4]int func mutateArray(a [ 3 ] int ) { a[ 0 ] = 99 // 値渡しのため呼び出し元の配列は変わらない } mutateArray(x) // OK // mutateArray(y) // コンパイルエラー Pythonのlistのようなものはslice 一連の値を保持するためのデータ構造が必要ならば、「可変長の配列」とも言えるスライスを使うのが正解です。 - 初めてのGo 言語 第2版 第3章 Goの主人公型の一つはスライスです。Pythonのlistと似ていますが、要素の型宣言が必要です。またスライスには長さとともにキャパシティ(容量)という属性もあります。キャパシティを設定すると想定されているメモリブロックを確保できます。 package main import "fmt" func main() { // スライスの基本的な宣言と初期化 // 型の宣言が必要: []int は int 型のスライス var numbers [] int = [] int { 1 , 2 , 3 , 4 , 5 } fmt.Println( "数値のスライス:" , numbers) // 短縮構文による宣言 names := [] string { "Alice" , "Bob" , "Charlie" } fmt.Println( "名前のスライス:" , names) // make関数でスライスを作成 // make(型, 長さ, キャパシティ) // キャパシティはオプション。省略すると長さと同じになる scores := make ([] int , 3 , 5 ) // 長さ3、キャパシティ5のスライス fmt.Println( "スコアのスライス:" , scores) fmt.Println( "長さ:" , len (scores)) fmt.Println( "キャパシティ:" , cap (scores)) // スライスへの要素追加 scores = append (scores, 100 ) scores = append (scores, 95 ) fmt.Println( "要素追加後のスライス:" , scores) fmt.Println( "追加後の長さ:" , len (scores)) fmt.Println( "追加後のキャパシティ:" , cap (scores)) // キャパシティを超えると自動的に拡張される // Go 1.18 の段階でキャパシティが 256 未満の場合は 2 倍になる傾向 scores = append (scores, 88 , 92 , 78 ) fmt.Println( "キャパシティ超過後のスライス:" , scores) fmt.Println( "新しい長さ:" , len (scores)) fmt.Println( "新しいキャパシティ:" , cap (scores)) // 通常は2倍に拡張される // 先頭3つを参照するスライス firstThree := scores[: 3 ] // 元のスライスを変更すると、同じ基底配列を参照するスライスも影響を受ける scores[ 0 ] = 999 fmt.Println( "scores[0]変更後のfirstThree:" , firstThree) } キャパシティが超える場合は、スライスの内容が拡張後のメモリにコピーされるため、パフォーマンスに影響します。拡張ルールの話に関して「初めてのGo 6.7、マップとスライスの違い」をご覧ください。 Goの定数はコンパイル時に確定する Goの定数はリテラルに名前を付与するものです。「変数」がイミュータブルであることを宣言する方法はありません。 - 初めてのGo 言語 第2版 第2章 Goの定数はコンパイル時にのみイミュータブルであることが保証されます。つまり、リテラルに名前を付けているだけの機能です。 package main func main() { const a = 10 // a = 20 // エラー:cannot assign to a (neither addressable nor a map index expression) x := 10 y := 20 // const z = x + y // エラー:x + y (value of type int) is not constant } Go は値渡しだけど参照もできる? Goでは関数に渡された引数は関数内で新たなコピーになります。これによりイミュータビリティが確保され、データフローが分かりやすくなります。 一方で mutable なオブジェクトを渡したい場合は、ポインタを使えます。ちなみにPythonではクラスは裏側で参照(ポインタ)として扱われます。ただ、Pythonと違ってGoではポインタを使うかどうかを開発者が選べます。 選べるからといって、ポインタを多用するとデータフローがぐちゃぐちゃになりやすいので、JSONオブジェクトなどを除き慎重に使うのがよさそうです。 package main import "fmt" func modifySlice(s [] int ) { s = append (s, 4 ) // スライスは参照的に振る舞うため呼び出し元に反映されやすい } func modifyInt(x int ) { x = 100 // 値渡しのため呼び出し元には反映されない } func modifyIntPointer(x * int ) { *x = 100 // ポインタを使うと呼び出し元の値を変更できる } func main() { mySlice := [] int { 1 , 2 , 3 } modifySlice(mySlice) fmt.Println(mySlice) // [1 2 3 4] myInt := 10 modifyInt(myInt) fmt.Println(myInt) // 10(変更されない) modifyIntPointer(&myInt) fmt.Println(myInt) // 100(変更される) } ポインタの場合は参照渡しに似ていますが、本質的には値渡しです。関数に渡す際に、ポインタのアドレスがコピーされているだけです。 Goの文字列はバイト列? Go言語での文字列は、Pythonのそれとは根本的に異なります。Goの文字列は実際にはバイト列であり、UTF-8エンコードを基本としています。これに対してPythonの文字列はUnicodeコードポイントのシーケンスです。マルチバイトを扱う場合はruneを使いましょう。 // 文字列はバイト列 s := "Hello, 世界" // len(s)はバイト数を返す(UnicodeコードポイントではなくUTF-8バイト数) fmt.Println( len (s)) // 13(UTF-8では「世界」が6バイトを占める) // 文字列をバイト単位で処理 for i := 0 ; i < len (s); i++ { fmt.Printf( "%d: %c (%x) \n " , i, s[i], s[i]) // 注意:マルチバイト文字は正しく表示されない } // 文字列をruneに変換して処理(runeはUnicodeコードポイント) for i, r := range s { fmt.Printf( "%d: %c (%U) \n " , i, r, r) // iはバイトオフセット、rはUnicodeコードポイント } Pythonだともっと簡単ですね。 # Pythonでの文字列はUnicodeコードポイントのシーケンス s = "Hello, 世界" # len(s)は文字数(コードポイント数)を返す print ( len (s)) # 9 # 文字列の各文字(コードポイント)を処理 for i, c in enumerate (s): print (f "{i}: {c} ({ord(c):x})" ) Goでは try/except がない Python の try/except に相当する構文は Go にはありません。理由はコードのネストは読みづらいからです。Go では「エラーは例外ではなく値」として扱い、関数が「値, error」を多値で返すのが基本です。呼び出し側はエラーをその場で判定し、必要なら早期 return します。これにより制御フローが明示的で読みやすくなります。 v, err := Do() if err != nil { return fmt.Errorf( "do failed: %w" , err) } // v を安全に使える 多値が前提なので、関数設計も自然と次のようになります。 // 値と error を返す関数の例 func LoadUser(id string ) (User, error ) { if id == "" { return User{}, fmt.Errorf( "id must not be empty" ) } // ... 実処理 ... return user, nil } u, err := LoadUser(id) if err != nil { return fmt.Errorf( "load user %s: %w" , id, err) } おまけ:マスコットはGopherと呼ぶ ご存知の方も多いかもしれませんが、Goのマスコットは Gopher です。ストーリーが面白いのでぜひ このQiitaの記事 を読んでてください。 まとめ 今回はGoの訓練の中で一番驚いたことを書いてきました。Golangについてはまだまだ学ぶことがあるので、進捗は改めて報告させてください! Happy New Year! 参照 初めてのGo 言語。第2版。John Bodner(作者)、武舎 広幸(訳)。 互換性の約束について「 https://go.dev/blog/compat 」 GopherCon 2022: Compatibility: How Go Programs Keep Working - Russ Cox なぜGo言語のマスコットはGopherなのか - Qiita
Introduction Hello, I'm Santoso, and I work as a Research Engineer at RevComm Research Team. Today, I will introduce our recent study on improving domain-specific vocabulary (DSV) recognition in our products using generative error correction (GEC). Introduction Automatic speech recognition (ASR) and domain-specific vocabulary (DSV) recognition The problem with traditional approaches Our approach: A scalable solution with generative error correction (GEC) How do we measure DSV recognition performance? How does GEC improve DSV performance? Our experiments Our experimental setup and methodology Our proposed methods Our findings Simple GEC: Is it enough? Analysis: Beyond the numbers Challenges and limitations Conclusions and future work Automatic speech recognition (ASR) and domain-specific vocabulary (DSV) recognition ASR is a business essential, powering platforms like our AI-powered IP phone, MiiTel. While MiiTel already helps businesses improve sales and customer service with instant transcripts and in-depth analytics, we must overcome a key challenge to fully unlock its power: the accurate recognition of DSV. DSV includes industry-specific jargon, company names, and product terms that general ASR models often fail to recognize because they are rare or absent in their training data. Such errors can have real-world consequences, like a customer service representative misinterpreting a client's request or a sales team failing to log key details. To address this, our research moves beyond traditional ASR limitations and develops a new, scalable approach to automatically identify and correct these transcription errors. The problem with traditional approaches Improving DSV recognition has long been technically challenging since the traditional method involving ASR model fine-tuning on a large dataset of specialized vocabulary struggles with scalability. Fine-tuning requires costly human labor to create and annotate extensive DSV dictionaries for every new domain, making it a major barrier for businesses operating across diverse fields and highlighting the need for a more efficient, scalable solution. Our approach: A scalable solution with generative error correction (GEC) Our research tackles this problem with GEC, a powerful post-processing technique that leverages large language models (LLMs) to automatically identify and correct transcription errors using conversational context. This method avoids costly manual dictionary creation. GEC is particularly advantageous for DSV because the LLM can infer the correct term from the surrounding context. Unlike simple word-swapping methods, GEC models use the entire sentence context, similar to a human proofreader, to generate a corrected version. This allows us to efficiently and scalably fix errors without manually updating the entire ASR model for every new domain. By focusing on this scalable error correction method, we aim to ensure that MiiTel delivers the highest transcription quality, no matter the domain. This will allow businesses to capture every critical detail from their conversations, providing deeper insights and a superior customer experience. How do we measure DSV recognition performance? To solve the DSV problem, we needed a precise way to measure our progress using the following metrics: Character Error Rate (CER): We measure the total number of errors in the full transcript to give us a baseline of the model's general performance. Match Accuracy: We use a binary metric to check if the specific DSV was transcribed perfectly for the most critical words. Match Ratio: We use this metric to quantify how close a misrecognized DSV is to the correct term, helping us understand the nature of the error. For example: CDMA2000 transcribed as CTNA2000 has a match ratio of 0.75, with differences in the second and third letter. By using these metrics together, we can prove our model isn't just getting better in general, but specifically mastering the specialized language that matters most. How does GEC improve DSV performance? Our experiments Our experimental setup and methodology To prove the effectiveness of our approach, we defined our own dataset from operator-customer conversations in MiiTel from three key domains: finance, human resources, and real estate. We created this specific dataset to verify that our method would be useful for our product's specific, real-world conversational data. Our dataset is divided into utterances containing annotated DSV and those known to contain no DSV. Our core experimental setup includes MiiTel's internal ASR engine as the primary baseline, alongside other leading commercial models as ASR output references. Our core methodology revolves around GEC post-processing with LLMs to correct transcription errors. Given that our target language is Japanese, where a single pronunciation can have multiple meanings depending on context, we explored several methods to leverage both the transcribed text and its pronunciation. Our proposed methods We tested several methods, each designed to tackle the DSV problem from a different angle: Prompt-based Correction: Our initial approach used simple prompts to ask an LLM to correct a transcription, with another ASR result as a reference. This serves as a baseline for how a simple LLM-only solution performs. Context-Informed Correction: Recognizing that context is key, we developed methods that provided the LLM with additional information, such as the conversation's domain (e.g., "This conversation is about finance"). This was intended to help the LLM better focus on the relevant vocabulary. The Two-Step Method: Our most advanced approach was a two-step process that proved to be the most effective. First, we identify a list of potential DSVs by comparing the outputs of multiple ASR models. Second, we present the LLM with a list of plausible sentences, including our identified candidates, and ask it to select the most reasonable one. This method corrects only the specific segment with the DSV, preserving the original sentence's fillers and contents. Our findings Simple GEC: Is it enough? The short answer is no ; correcting an entire sentence with simple GEC can harm the original, well-recognized parts of the transcript. GEC performance accuracy: CER GEC performance accuracy: match ratio GEC performance: Match accuracy As illustrated in the figures, our first two methods (simple prompt-based and context-informed correction) did show some improvement in DSV Match Ratio and Accuracy compared to MiiTel’s ASR without GEC. However, this came at a high cost of CER degradation. Directly correcting the full ASR transcript, even with multiple references and simple context provided, ends up introducing more errors than it fixed—a phenomenon known as "overcorrection." The two-step correction method dramatically improves overall transcription quality by achieving a remarkably low CER and significantly outperforming all ASR baselines. By identifying only the segments likely to contain a DSV and correcting just those parts, our method preserved the original transcription's integrity, allowing us to dramatically lower the overall CER. Despite the overall improvement, our two-step method does present a slight tradeoff in DSV recognizability, which is slightly lower than that of the simple prompt-based method. This is a small, acceptable trade-off given the dramatic improvement in overall CER, making the two-step method the most practical and robust solution for our use case. We also observed some performance differences across domains, suggesting the varying challenges of ASR and LLM in recognizing DSV from different fields. Analysis: Beyond the numbers While our metrics provide the proof, a look at real-world error types shows the true impact and precision of our methods. Let's examine a specific case from the finance domain where our internal ASR struggled with a key term such as "NISA". A term like " NISA " poses a unique challenge because it is pronounced similarly to common Japanese homophones. For example, our baseline ASR models often misrecognized this term as the homophone "入社" (" nyuusha ", "joined the company") or completely misinterpreted it as a related, yet incorrect, term like "新任者" (" shinninsha ", "new person"). Simple GEC methods that correct the entire sentence successfully fixed these types of errors by leveraging the broader knowledge of the LLM. The crucial advantage of our two-step GEC method lies in its precision; it targets only the specific segment where an error is likely to have occurred. For a challenging term like " NISA ," our method correctly identified the problematic segment and provided a list of plausible candidates to the LLM, leading to a successful correction. While this approach proved highly effective in maintaining overall transcription quality, we did observe a limitation: the method's accuracy is only as good as the candidates provided to the LLM. In some cases, the correct term (such as a unique, compound DSV) was not generated as a candidate, presenting a clear opportunity for future work. This highlights a core strength of our approach, which lies on its ability to solve a major problem while clearly identifying its own remaining challenges. Challenges and limitations While GEC significantly improves ASR performance, our research identified several key limitations that require consideration. First, GEC is highly dependent on the quality of the initial ASR output. When a model's transcription drastically changes a speaker's style or paraphrases an utterance, the LLM can lose the necessary context, making correction difficult. Furthermore, we found that LLMs, despite their vast knowledge, don't know every DSV. This means some very rare or newly updated vocabulary may still require external knowledge. This also highlights a core trade-off, where achieving high accuracy with GEC involves a slight increase in computational overhead and prompt engineering costs. Finally, our findings reveal a language-specific challenge rooted in the existence of different notations or similar readings in Japanese. Other than the ones mentioned in our experiments, there are subtle differences that would be considered the “correct” terminology, such as 振り込み vs 振込 (both read as “furikomi”). We found that some of the subtle decreases in our two-step method's DSV recognition accuracy were caused by these notational differences, even when the LLM correctly identified the word's pronunciation and the intended word. Given the significant CER improvement, this trade-off is a reasonable cost for maintaining overall transcription quality. Conclusions and future work This research demonstrates that GEC is a highly effective and scalable post-processing step for improving DSV recognition. By leveraging GEC, we've shown that specialized ASR solutions are not only possible but essential for platforms like MiiTel, where accuracy in specialized terminology is critical for sales, customer service, and data analysis. We believe GEC has the potential to transform ASR from a general utility into a powerful, industry-specific tool. The findings and limitations we've identified are clear signposts for future research that will expand the capabilities of our method. Our work is now expanding to explore how these methods can be combined with other ASR architectures and applied to more diverse and niche domains. We are particularly excited to tackle the challenging task of recognizing industry-specific jargon and product names, which are frequently misrecognized due to their unconventional naming and rarity. The principles of our two-step GEC-based method can be applied to languages beyond Japanese, such as English. We look forward to exploring these opportunities to create a more versatile, multilingual system that continues to push the boundaries of ASR accuracy.
この記事はRevComm Advent Calendar 2025 8日目の記事です。 qiita.com 1. はじめに こんにちは。Research Engineerの髙瀬です。 近年、大規模言語モデル(LLM)の性能向上により、テキスト生成や分類タスク、さらには評価やアノテーションなど、様々な場面でLLMが活用されるようになってきました。個人的にも注目しているのが、「LLM as a Judge」というアプローチです。これは、LLM自体を評価者として活用し、他のLLMの出力や分類タスクの正誤を自動的に判定させる手法です。人手が必要な判定作業をLLMで自動化できることは素晴らしいことだと思います。 しかし、ここで重要な疑問が浮かび上がります。 「評価者であるLLMの判定結果を、本当に信頼していいのか?」 LLM as a Judgeによる自動化は、人手アノテーションのコストを削減したり、スケーラブルな評価を実現できる可能性があります。一方で、評価者であるLLMの精度が不十分であれば、誤った判定に基づいて意思決定を行うことになり、かえって問題を引き起こしかねません。そのため、実務でLLMを評価者として採用する前に、その精度を事前に検証することが極めて重要になります。 本記事では、実際の業務で行った対話連続性判定タスクにおいて、GPT-4とChatGPTを用いたLLM as a Judgeの実験を実施し、その精度と実用性を検証した結果を紹介します。 ※本記事で紹介する実験は1年前に実施しているため利用しているLLMのモデルが古いことに注意してください。 2. 背景と課題 今回、実務上で膨大なデータに対して精度評価を行う際、人手で正解データを用意する必要が生じました。しかし、人手によるアノテーションには以下の課題があります。 コストの問題 : 大量のデータに正解ラベルを付与するには、多大な時間とコストが必要 スケーラビリティの問題 : データ量が増えるほど、人手での対応が困難になる 一貫性の問題 : アノテーターによって判断基準にばらつきが生じる可能性がある そこで、LLMに判定を任せることで、これらの課題を解決できる可能性があります。しかし、冒頭で述べた通り、評価者LLM自体の判定精度が十分かを検証しなければ、実務での採用はできません。 本実験では、実際の業務データを用いて以下の点を明らかにすることを目指しました。 異なるLLMモデル(GPT-4とChatGPT)の判定精度はどの程度か? プロンプト手法(Zero-Shot、One-Shot、Few-Shot、Self-Consistency)によって精度はどう変わるか? 実務で評価者LLMとして採用できる水準の精度を達成できるか? これらの検証を通じて、LLM as a Judgeの実用性を評価しました。 3. 実験内容 タスク設定 本実験では、対話の連続性判定タスクを設定しました。具体的には、ある発話に対する返答として適切か不適切かを、LLMを使って自動的にフィルタリングできるかを検証しました。 判定基準は以下の通りです。 適切 : 対話内の連続する発話のペア 不適切 : 異なる対話から取得した発話同士を組み合わせたペア このタスクは対話システムの開発やデータ品質管理において重要です。正しい対話ペアを識別することで、学習データの品質やシステムの応答精度が向上します。 データセット 実験には、MediaSUMデータセットから抽出した発話と返答のペア21件を評価データセットとして使用しました。 評価データセットの構成 適切な発話・返答ペア:10件 不適切な発話・返答ペア:11件 適切な対話データの作成方法 MediaSUMから対話Aを抽出 対話Aから連続する話者の発話と返答を取得 適切データとしてラベルを付与 不適切な対話データの作成方法 MediaSUMから対話A、対話Bを抽出 対話A、対話Bから発話を1件ずつピックアップ 不適切データとしてラベルを付与 データセットのサイズは小規模ですが、本実験の目的は「 評価者LLMの精度検証の重要性を示すこと 」であり、実務での採用判断に必要な初期検証として位置づけられます。 実験手法 本実験では、以下の2つのモデルと4つのプロンプト手法を用いて比較検証を行いました。 使用モデル: ChatGPT(GPT-3.5) GPT-4 プロンプト手法: Zero-Shot : 例を示さず、タスクの説明のみを与える One-Shot : 1つの例を示してタスクを説明 Few-Shot : 複数の例(本実験では2つ)を示してタスクを説明 Self-Consistency : 複数回試行して出現頻度が最も多い結論を結果とする手法 各手法について5回試行を行い、Accuracyを評価指標として精度を比較しました。 実験結果 以下に、各プロンプト手法とモデルの組み合わせによる実験結果を示します。 Zero-Shot 試行回数 Accuracy(ChatGPT) Accuracy(GPT-4) 1 71.43% 95.24% 2 76.19% 95.24% 3 66.67% 95.24% 4 76.19% 95.24% 5 71.43% 95.24% 平均 72.38% 95.24% One-Shot 試行回数 Accuracy(ChatGPT) Accuracy(GPT-4) 1 76.19% 95.24% 2 85.71% 90.48% 3 76.19% 95.24% 4 71.43% 90.48% 5 80.95% 90.48% 平均 78.09% 92.38% Few-Shot 試行回数 Accuracy(ChatGPT) Accuracy(GPT-4) 1 85.71% 95.24% 2 85.71% 90.48% 3 80.95% 90.48% 4 85.71% 95.24% 5 71.43% 90.48% 平均 81.90% 92.38% Self-Consistency 試行回数 Accuracy(ChatGPT) Accuracy(GPT-4) 1 71.43% 95.24% 2 71.43% 95.24% 3 80.95% 95.24% 4 66.67% 95.24% 5 71.43% 95.24% 平均 72.38% 95.24% 4. 考察 モデル別の精度比較 ChatGPTはFew-Shotで最高81.9%の精度を示しましたが、適切な対話データで誤判定が多く、実務採用には不十分な水準でした。一方、GPT-4はすべての手法で90%以上を維持し、Zero-ShotとSelf-Consistencyで95.2%の高精度を達成しました。 プロンプト手法の効果 ChatGPTは具体例を示すことで精度が向上(72.4% → 81.9%)しましたが、GPT-4はプロンプト手法による差が小さく、Zero-Shotで既に高精度を実現しました。これは、モデルの性能が高いほど、複雑なプロンプト設計の必要性が低くなることを示唆しています。 最も重要なポイントは、 LLMを評価者として実務で採用する前に、必ず精度検証を行うこと です。本実験のように、実際のタスクで小規模データセットを用いて事前検証し、求められる精度水準を満たすかを確認することが不可欠です。 5. まとめ 本記事では、実際の業務で行った対話連続性判定タスクにおける、LLM as a Judgeの実用性を検証しました。LLM as a Judgeは、自動化とスケーラビリティの向上により、評価プロセスを大きく変革する可能性があります。しかし、 「評価者LLMの結果を盲目的に信じることは危険」 であり、必ず事前に精度検証を行い、求められる水準を満たすかを事前に確認する必要があります。 実務においてLLM as a Judgeを活用する際は、事前検証プロセスを踏むことで、信頼性の高い自動評価システムを構築できると思います。 参考文献 https://github.com/zcgzcgzcg1/MediaSum
はじめに RevCommの熊谷です。どうぞよろしくお願いします! Vue 3のComposition API、使ってますか〜?便利ですよね。 もう、Compositionのない世界には戻れない... 今回は、実際のプロジェクトで使っている useList というComposableを例に、Composablesの使い方をシェアしてみますね。 困ってたこと 紹介するのは、 MiiTel Admin という管理画面での話です。MiiTel Adminは、自社サービス「MiiTel(ミーテル)」を支える管理画面で、 Vue 3 + Nuxt 4 + TypeScript で作られたWebアプリケーション。会社の成長とともに機能が増え続け、現在は84ページの大規模な管理画面になっています(ちょっと整理したいとは思ってる...!)。 それぞれのページは、一覧・編集・削除がセットになっているパターンで統一されています。 そんなMiiTel Adminですが、ページ数が増えてくると、 同じようなロジックがあちこちに散らばる問題 が出てきちゃってました。 似たような処理が 各ページでコピペ されてる 1つのコンポーネントが 500行、1000行超え ... UIとロジックが混ざってて、テストが書きづらい たとえば、ユーザー一覧ページはこんな感じになってました: < script lang = "ts" setup> // 色々な関数で使うrefがごちゃ混ぜ const users = ref ([]) const filteredUsers = ref ([]) const currentPage = ref ( 1 ) const searchValue = ref ( '' ) const total = computed (() => filteredUsers . value . length ) const isEditModalOpen = ref ( false ) const editingUser = ref ( null ) // データ取得 const fetchUsers = async () => { /* ... */ } // データ変換 const mapUsers = ( response ) => { /* ... */ } // ページング const movePage = ( page ) => { /* ... */ } // 登録・編集関連 const openEditModal = () => { /* ... */ } // 削除関連 const deleteUser = async ( id ) => { /* ... */ } // その他、大量の処理がずらーーーっと... </ script > こういうコードが、いろんな一覧ページで繰り返されてたんです...😭 もともとVue 2で作ってて、リアクティブな状態を持つ共通機能を再利用するには Mixin しかなかったんですよね。でも Mixin は色々と使い勝手が悪かったこともあり、結局コピペで対応してました。 で、Vue 3の移行が完全に完了したタイミングで、Composablesを導入することにしたんです! Vue 2からVue 3への移行は決して楽じゃなかったけど、その先にはComposablesによる美しく整理された世界が待っていました✨ Composablesってなに? 簡単に言うと、Vue 3のComposition APIを使って ロジックをまとめて再利用できるようにした関数 のこと。Reactの hooks みたいな感じですね。私たちは、再利用性とロジックのカプセル化を目的に導入しました。(詳しくは Vue公式ドキュメント ) 実装イメージはこんな感じ: Before : 機能ごとのコードが ref 、 computed 、 functions に散らばってごちゃごちゃ... After : 各機能が独立したComposableに整理されてスッキリ〜 こうすると、こんないいことがありました: コードの見通しが良くなる : 機能ごとにファイルが分かれて、どこに何があるか一目瞭然 バグが減る : ref を機能間で共有しないから、意図しない副作用が起きにくい 開発スピードが上がる : 共通ロジックを再利用できて、同じコードを何度も書かなくていい テストが書きやすい : ロジックが独立してるから、単体テストがシンプルに そして何より、 各開発者が自分の担当ロジックに集中できる ようになったのが大きいです。レビューも「このComposableは何をしてるか」が明確だから、ポイントを絞って確認できるようになりました✨ useListを作ってみた さて、じゃあ具体的にどうやったかというと... MiiTel Adminには一覧ページがたくさんあるんですが、どのページも「データ取得」「ページネーション」「検索・フィルタリング」みたいな共通機能が必要なんですよね。これを毎回書くのは大変だし、同じようなバグも生まれやすい。 そこで、これらの共通機能をまとめた useList というComposableを作ることにしました! export interface UseListOptions < T , U > { mapFunction : ( list : U []) => T [] ; // ... } export interface UseListResponse < T , U > { listData : DeepReadonly < Ref < T []>> ; total : ComputedRef < number > ; movePage : ( page : number ) => void ; // ... } export const useList = < T , U >( options : UseListOptions < T , U >): UseListResponse < T , U > => { const listData: Ref < T []> = ref( [] ); const currentPage = ref( 1 ); const movePage = ( page : number ) => { /* ... */ } ; return { listData : readonly(listData), currentPage : readonly(currentPage), total , movePage , // ... } ; } ; 設計で気をつけたこと ジェネリクスで型安全に export const useList = < T , U >( { mapFunction , filterFunction , pageSize } : UseListOptions< T , U >) U : APIから取得した生のデータ型 T : UIで使うために変換後のデータ型 useList<User, ApiUser> と useList<UserGroup, ApiUserGroup> みたいに、同じComposableをいろんな型で使えます。TypeScript最高! mapFunctionを外から渡す 汎用的なロジックと個別のロジックを分けられるし、テストもしやすくなりました。 readonlyで守る listData: readonly ( listData ), currentPage: readonly ( currentPage ), 外から勝手に変更されるのを防いで、バグを減らせます。安心✨ 1つのComposableに機能を詰め込みすぎない いわゆる単一責任の原則ですね。例えば、ポーリング機能が欲しくなったとき、 useList に追加するのではなく、別のComposableとして作って組み合わせるようにしてます。 // ❌ useListにポーリング機能を追加してしまう const { listData , startPolling } = useList( { polling : true , ... } ); // ✅ 別のComposableとして作って組み合わせる const { listData , ... } = withListPolling(useList( { ... } )); 小さく作って組み合わせる方が、テストしやすいし、必要な機能だけ使えるので便利です! 実際に使ってみる ページ固有のComposableを作る useList をベースにして、ユーザー一覧用のComposableを作りますね。 export const useUserList = (): UseUserListResponse => { const base = useList< UserListItem , ApiUser >( { mapFunction : mapUsers, } ); const initializeUsers = async () => { const list = await fetch ( /** ... */ ); base.setData(list); } ; return { ...base, initializeUsers } ; } ; // 純粋関数として切り出し、テストしやすく。 export const mapUsers = ( response : ApiUser []): UserListItem [] => { /** ... */ } ; return で ...base を返すことで、 useList の機能をそのまま公開しつつ、ページ固有の処理を追加しています。 コンポーネントで使う < template > < div > < ListUser : data -source= "listData" :page= "currentPage" /> </ div > </ template > < script lang = "ts" setup> const { listData , currentPage , initializeUsers } = useUserList () ; const { /* ... */ } = useUserEdit () ; const { /* ... */ } = useUserDelete () ; await initializeUsers () ; </ script > コンポーネント側がめっちゃスッキリしましたよね? 他のページにも同じパターンを このパターンを他のページにも展開していきます。 共通のComposable( useList 、 useEdit 、 useDelete )をベースに、各ページ固有のComposableを作っていく。 ベースとなるComposableを使うことで、関数名や変数名も自然と統一されて、プロジェクト全体の保守性がぐっと上がりました。 AIも使って横展開 でも、これを84ページ全部に適用するのって結構大変そうじゃないですか? ...というわけで、もちろんAIをフル活用してます! お手本ページの作り込み まずはお手本となるページを1つ、チーム全員で徹底的に磨き上げます。ペアプロでみんなの意見を聞いて、コード品質・可読性・保守性にこだわってます。( 昨日のペアプロの記事 もぜひ見てね) AIで横展開 お手本ができたら、AIに他のページを作ってもらいます: お手本ページのコードをAIに見せる 「このパターンで〇〇ページを作って」とお願い AIが作ったコードをレビュー&微調整 お手本の設計にチームの知恵を集める → AIで展開 → 人がレビュー、という流れで、 品質とスピードを両立 できてます。AIを使うからこそ、最初の設計が大事なんですよね〜。 おわりに Composablesの実践例をまとめた記事があんまりなかったので、書いてみました。 誰かの参考になれば嬉しいです〜
はじめまして。フロントエンドエンジニアをしている伊藤と申します。 この記事では、チームで週に1回開催しているペアプロという名の技術共有会について、お届けします。 技術共有の中身や始めたきっかけなどをお伝えできればと思っています。 目次 目次 ペアプロとは ゆるーいペアプロとは スケジュール 事前準備 活動内容 Vue Fes Japan 2025 composition 化観察 開発しているプロジェクトの実装相談 記事を紹介するなど技術共有 きっかけ まとめ ペアプロとは まず、記事の中身に触れる前に、本来のペアプロのやり方について記載します。 AI による説明は以下です。 「ペアプロ」とは、2人のプログラマーが1台のコンピューターを共有し、協力してコードを作成する開発手法です。 具体的には、実際にコードを書く「ドライバー」と、コードをチェックしながら助言する「ナビゲーター」の役割を、定期的に交代しながら進めます。 ペアプロのイメージは、黙々とコードを書いていき、指摘された箇所を修正する真面目な印象が強いです。 本来のペアプロを実施すると、私を初め苦手意識を持つ人もいるかもしれません。 ゆるーいペアプロとは 私のチームが行なっているペアプロは、かなりゆるいです。 技術共有会だとイマイチなネーミングだと思い、なんとなくペアプロと名乗っています。 チームメンバーが集まり、雑談を交えつつ実装した箇所の説明やA,Bどちらのパターンで実装すべきかなどを話します。 コーディングの時間よりも、話している時間の方が多いと思います。 スケジュール 週に1回の頻度で行っています。時間は1時間くらいです。 決まって開催する曜日はなく、週の初めに各メンバーの予定を見て、日程を組みます。 大型連休や予定が多い場合などはスキップする場合もあります。 2023/12/14 から始まり、まもなく100回目を迎えようとしています🎉🎉🎉 事前準備 週の初めに、各メンバーの予定を見て1時間のミーティングをセットします。 話したいことを書くためのメモを用意し、共有します。 各メンバーがメモの中に、議題を書いていきます。 下記は議題の参考例です。 Vuex から Pinia への移行 カスタムバリデーションをどのファイルに書くか AI 使用時・不使用時の作業時間比較 コードをシェアするときは、「Visual Studio Live Share」を使いました。 visualstudio.microsoft.com 活動内容 議題に対してメンバーが意見を出し合いながら、コードを見て実装等をしていきます。 過去に議題に上がった主なものをご紹介します。 Vue Fes Japan 2025 10 月末にチームで参加したVue Fes Japanについて話し合いました。 どのセッションを聞くかなど、チームで収穫の多いものにするため作戦を立てました。 composition 化観察 コードのcomposition 化(最新の記法に書き直す)作業を見てもらっていました。 こちらは本来のペアプロの意味に近い内容をしたと思います。 開発しているプロジェクトの実装相談 PRのレビューをしてもらう前に、開発している実装の相談をしました。 こちらは開発したコードに対して、他の提案をしてもらったり、アドバイスをもらったりしていました。 他のメンバーの意見を聞くことで、違う角度からコードを見ることができます。 記事を紹介するなど技術共有 技術の紹介や既存コードに対しての新しい書き方などを共有します。 これまで共有してきたものは以下があります。 CSS Flexboxの使い方を徹底解説 GA の使い方講座 CSSアニメーション Findy Team+ Vitest tech.revcomm.co.jp きっかけ はじめたきっかけとしては、表向きはチームのコミュニケーションを促進しようと思ったからです。 弊社はフルリモートの会社なので、チームのコミュニケーションの場所がほとんどありませんでした。 せっかくチームで動いているのだから、何かできないかという思いでスタートさせました。 同じプロダクトで、同じ技術を用いているので、技術共有もしやすく、先輩エンジニアからの知識も借りられます。 実装で詰まった箇所を相談もでき、私個人としては大変ありがたく活用させてもらっています。 裏側の理由としては、チームのコミュニケーションを促進させると評価が上がると書いてあったので、スタートさせました。 まとめ チーム内で行っている活動のため、こういった記事にでき、日の目を浴びれたのが嬉しいです。 執筆の機会をもらったことに感謝すると共に、毎週参加してくださるチームメンバーにも感謝しています。 週一回ですが、自分はとても有意義に活用できています。 一人では解決できないことや、ちょっとした悩みも雑談ベースだと聞きやすいため、自分の性格にもあっているなと思います。 無理のない範囲で、よければチームに取り入れてみてください。 私たちは、これからも細く長く、活動していきたいと思います。
著者: Researchチーム 春日 1. はじめに 「レビューがいつまでも終わらない」「人によって指摘の粒度がバラバラ」「些細な指摘で心理的安全性が削られる」……。 開発規模の拡大や専門性が深まるにつれ、ドキュメントやコードのレビューにまつわる悩みは、どの現場でも尽きないテーマではないでしょうか。Bacchelliら *1 の研究でも示されている通り、現代のレビューには単なる欠陥発見以上に「知識の共有」や「チームの認識合わせ」という高度な役割が期待されています。特に私たちResearchチームにおいては、 プロジェクト完了後の最終レポート作成が重要な文化として定着しており 、その際に質の高いドキュメントレビューが不可欠です。しかし、一般的なリモートワークの課題に加え、フルリモートを前提とする私たちのチームでは、レビュアーとの物理的・心理的な『距離』がより大きなコミュニケーションの壁となっています。その結果、 文脈の共有や質の高いフィードバックの維持という課題 *2 がより顕著になっています。 また、Googleの「Project Aristotle *3 」が明らかにしたように、チームのパフォーマンスを支えるのは「心理的安全性」です。レビューにおける攻撃的な指摘や不透明な基準は、この土台を揺るがしかねません。 そこで私たちResearchチームは、これらの課題への一手として最終レポートなどのレビュープロセス(特に一次レビュー)へのAI (LLM) 導入を検証しました。本記事では、実際にGeminiを活用したレビューフローを構築し、チームメンバーによる評価で明らかになった「AI導入の効果」と、そこから見えてきた「活用の勘所」を、実例とともに共有します。 想定読者 コードレビューやドキュメントレビューの工数削減に関心があるエンジニア・研究者 チームの心理的安全性を保ちながら、成果物の品質を上げたいマネージャー 業務プロセスへの生成AI導入・活用を検討している方 2. 現状の課題とAI導入の狙い 従来の人間が行うレビュープロセスは、どうしても属人的になりがちで、主に以下の3つの課題を抱えていました。 レビュー工数の増大 : 規模拡大や専門領域の細分化により、確認に時間がかかる。 指摘内容のばらつき : 担当者のスキルや気分によって指摘が異なり、一貫性が欠如する。 心理的安全性の低下 : ネガティブなフィードバックが続くと、レビュアー・レビュイー双方がストレスを抱えやすい。 AIレビューによるソリューション 私たちは「一次レビューの自動化」としてAIを導入することで、人間が本質的な議論に集中できる環境を目指しました。これによって以下の利点があると予想されます。 工数削減 : 単純なミス(Typo)や論理矛盾をAIが事前に修正指示。 品質の均一化 : 常に一定の基準で、客観的なフィードバックが提供される。 心理的安全性の確保 : AIからの指摘は感情的な抵抗が少なく、客観的な事実として受け入れやすい。 3. 関連研究: 学術界におけるAIレビューの現在地 今回の調査にあたり、私たちはまず学術論文の査読(ピアレビュー)プロセスにおいて、現在AIがどのように活用されているかを調査しました。そこから見えてきた「AIレビューの分類」と「透明性へのトレンド」について紹介します。 論文レビューにおけるAI活用の3つのフェーズ Chenらのサーベイ論文 *4 によると、論文レビューにおけるAI活用は大きく3つのカテゴリに分類されます。 Pre-Review(事前レビュー) : 原稿の初期評価や、専門分野の特定、適切な人間のレビュアーとのマッチングなど、査読に入る前の準備作業を効率化するフェーズです。 In-Review(レビュー中) : レビューレポートの自動生成や、人間のレビュアーの支援を行うフェーズです。数値スコアの予測や、書面による評価コメントの生成が含まれます。 Post-Review(事後レビュー) : 論文採択後の影響度評価(引用分析)や、プロモーション(要約や解説ビデオの生成)を行うフェーズです。 今回の私たちの取り組みは、レポートの品質向上とフィードバックの提供を目的としているため、この中の 「In-Review」 領域 に位置づけられます。 AIによる採択予測の可能性と限界 「AIは論文の良し悪しを判断できるのか?」という問いに対して、興味深いデータがあります。 ICLR 2022の論文データセットを用いた研究("The AI Scientist") *5 では、GPT-4oを使用したAIモデルが、 論文の採択・不採択を70%程度の精度で予測 できたと報告されています。また、本来採択されるべき論文を不採択にしてしまう「偽陰性率」に関しては、人間よりも低い値(つまり見落としが少ない)を記録しました。さらに、AI(LLM)が算出した評価スコアは、無作為に選んだ一人の人間のスコアよりも、 「人間のレビュアー全員の平均スコア」に近い という結果が示されました。これは、人間とAIの「評価の性質の違い」を浮き彫りにしています。人間の場合、どうしても「その人の好み」「その時の気分」「得意・不得意」といったバイアスが入ります。その結果、ある人は「満点」を出し、別の人は「不合格」を出すといった具合に、 誰が担当するかによって評価が大きくブレる(当たり外れがある) ことがあります。一方でAIは、個人のような強い感情や特定のバイアスを持ちません。そのため、特定の個人の意見というよりは、 「審査員全員の意見をならした平均値(コンセンサス)」に近い、極端さのない安定した評価を導き出す傾向 があります。 ただし、この手法をそのまま社内のレビューに適用することには慎重であるべきです。なぜなら、これらの成果は「明確なレビュー基準」と「大量の学習データ」が存在するコンペティション(学会)環境だからこそ実現できたものであり、社内のドキュメントレビューにそのまま転用するのは難しいためです。 レビューの透明化(Transparent Peer Review) もう一つの重要なトレンドが、 「レビュープロセスの透明化」 です。 これまで査読コメントは非公開が一般的でしたが、近年ではレビューコメントやそれに対する著者の返答も「論文の一部」とみなして公開する動きが広がっています。 例えば、世界的な学術誌であるNatureは、2025年から全ての新規投稿論文について、論文公開時に査読レポートと著者の回答を併せて公開すると発表しました *6 。また、CS(コンピュータサイエンス)分野では OpenReview *7 というプラットフォームが有名で、ここでは以前からレビューコメントが公開されています。 4. 実践したAIレビューの手法 今回私たちのAIレビューでは、Notionで書いたレポートのテキストを手動でコピーアンドペーストしたものを入力として、Googleの Gemini 2.5 Flash を使用してレビューを行いました。レビュープロセスにおいて工夫した点は 「プロンプトのシンプルさ」 と 「透明性の確保」 です。 プロンプト戦略 「レポートをレビューして不明な点を列挙してください。」 上記のような非常にシンプルな指示を採用しました。近年のLLMのサービスでは複雑な役割定義をしなくても、シンプルな指示で自動的に調整してくれるためです。 レビュープロセスの透明化 Nature誌などが採用している「Transparent Peer Review」の流れを参考にしました。 単にAIが修正案を出すだけでなく、 「AIのレビュー指摘」に対して「著者がどう返答・修正したか」までをセットにしてドキュメント管理 します。これにより、意思決定のプロセスが可視化され、ナレッジとして蓄積されます。 5. アンケート評価 私たちは実際にResearchチーム内でAIレビュー導入を行い、メンバー6名を対象にその有用性を評価するアンケートを実施しました。 定量評価は大きく「レビューの品質(Quality)」と「懸念点(Challenges)」の2つの軸で行われました。また、定性評価として幾つかの自由記述の質問項目を用意しました。 AIレビューに対する評価アンケートの例 AIレビューの品質評価 (Quality of AI Review) まず、AIによるレビューが業務にどの程度貢献したかを測るため、以下の6項目について5段階評価(1: 全くそう思わない 〜 5: 強くそう思う)を行いました。 Q1. 有用性: AIのコメントは自分の仕事に役立ったか。 Q2. 正確性: フィードバックは技術的・論理的に正確だったか。 Q3. 提案の具体性: 改善提案は具体的で分かりやすかったか。 Q4. 見落としの検出: 人間が見落としがちな点(Typoや規約など)を指摘できたか。 Q5. 新規視点: 自分になかった新しい視点やアイデアを提供してくれたか。 Q6. 品質の向上: 最終的な成果物の品質は向上したか。 ▼ 評価結果 AIレビューの品質評価の結果 結果として、 「Q6. 最終的なアウトプットの品質の向上」 および 「Q1. 自身の仕事への有用性」 が非常に高いスコアを記録しました。特に、Typoや単純なミスといった「人間が見落としやすい点」の検出(Q4)についても高評価が得られています。 一方で、 「Q3. 改善提案の具体性」 や 「Q5. 新たな視点やアイデアの提供」 のスコアは相対的に低くなりました。 これについては、 「欠点は指摘できるが、具体的な代案を出す能力はまだイマイチである」 という現状が浮き彫りになりました。また、自由記述では 「自分では思いつかないアイデアが出ることは稀だが、自分の考えに対する自信(裏付け)を持つ助けにはなる」 といった意見も寄せられました。 AIレビューに対する懸念点 (Challenges and Concerns) 次に、AIをレビューに導入することに対する不安や懸念についても調査しました(複数回答可)。 ▼ 質問項目と結果 AIレビューに対する懸念点の評価結果 もっとも多かった懸念は 「文脈や背景の無理解に基づいた的外れなフィードバック」 でした。 レポートには書かれていない背景事情(例:実験を早く進めるためにあえて特定の条件を無視している等)をAIは理解できないため、指摘が的外れになるケースがあるという声が上がっています。この課題は、RAGによってある程度解消できると見込んでいます。 次いで多かったのが 「ハルシネーション(もっともらしい嘘)を誤って信じるリスク」 です。実際に「関連論文を教えて」と聞いた際に存在しない論文を捏造するケースも確認されており、情報の正確性には引き続き注意が必要です。 また、過半数は超えませんでしたが、 「AIへの過度な依存による人間自身のレビュー能力の低下」 を懸念する声もありました。 これらの懸念に加えて自由記述として、 「AIのレビューの過信は避け、レビュイー自身が主体的に考える必要性」 を唱えるコメントがありました。 AIレビューのこれから:現場の声と今後のロードマップ (Future Use of AI Review) アンケートの最後には、今後のAIレビューに期待する機能や、具体的な活用方法についてのアイデア(Future Use)を自由記述形式でヒアリングしました。また、それらを踏まえた私たちの今後の技術的なロードマップについても共有します。 現場からの改善要望とアイデア チームメンバーからは、AIをより実用的な「パートナー」にするための具体的な機能要望が多く寄せられました。 入力の強化(マルチモーダル化) : テキストだけでなく、画像や表などの情報も読み込ませることで、より正確なレビューを実現したいという要望がありました。 擬似的なクロスチェック : 1つのAIだけでなく、レビューの観点が異なる複数のプロンプト(ペルソナ)を用意し、多角的な視点でチェックを行いたいというアイデアが出ました。 指摘から「修正」へ : 単に問題を指摘するだけでなく、具体的な修正案の雛形(ドラフト)までをAIに書かせることで、修正工数をさらに削減できる可能性があります。 レビューの「甘さ」の改善 : 「少し甘口すぎる」という意見もあり、プロンプトを変更することなどによって、より厳格なレビューにすることが求められています。 上記から、よりAIに正確かつ過不足なく情報を与え、より多角的・批判的・具体的なレビューをさせる工夫が必要であることが窺えます。 AIをレビューに活用することについてのその他のコメント AIレビューの限界 : 現状ではAIはそのタスクやチーム・会社の方針に対する背景を十分に理解した上でのレビューができないため、今のところでは人間のレビュワーの完全な代替とはならないというコメントがありました。 AIレビューの効率性 : 人間がレポートやコードを細部までレビューするのは労力を要し、特に情報が不完全だったり読みづらい場合は多大な時間がかかります。AIによる事前レビューでレポートやコードをブラッシュアップすることで、大幅な労力削減が可能であるという、 「レビュープロセスの前工程」 としてのAI活用が推奨されていました。 小規模チームにおける利点 : 現在の私たちのResearchチームのような小規模チームでは複数人によるダブルチェックやクロスチェックが難しいですが、AIを活用することで擬似的にこれらを実現できる利点も述べられていました。 主体的判断の重要性 : 人間のレビューと同様に、AIの提案をそのまま受け入れることが常に最善とは限りません。レビュイーは自身で主体的に判断し、適切な改善を行う必要があることを戒める必要性があります。 AIとの対話も「成果物」に : レポート提出時には、AIによるチェックとそれに対する修正履歴も含めて「レポートの一部」とみなす運用が推奨されていました。 6. 考察:AIは「優秀な校正者」だが「共著者」ではない アンケート結果から導き出された、現段階でのAIレビュー活用のポイントは 「適材適所」 です。以下のようなAIの得意不得意を把握した上で、レビュイー自身が主体性を持つことが重要であると言えます。 活用のための3つの原則 今回の検証を通じて、私たちは以下の3原則を定めました。 鵜呑みにしない : 最終的な判断と責任は必ず人間が持つ(主体性の維持)。 得意を任せる : 機械的なチェック作業はAIに任せ、人間は本質的な設計や議論に集中する。 文脈を伝える : 重要な背景情報や制約条件がある場合は、プロンプトで可能な限り言語化して伝える。 今後の技術的ロードマップ これらのフィードバックを受け、私たちはAIレビューの精度と深度を高めるために、以下の3つの方向性で開発・改善を進めていくことが考えられます。 プロンプトエンジニアリングの深化 : 現在のシンプルな指示から、レビュー観点をより明確化し、具体的かつ多角的なアクションプランを出力できるよう改善します。 外部情報(RAG)との連携 : AIの最大の弱点である「文脈理解」を補うため、社内文書や関連文献を検索・参照させるRAG(Retrieval-Augmented Generation)の仕組みを取り入れます。 マルチエージェントシステムの導入 : 単一のAIではなく、異なる専門性や役割を持った複数のAIエージェントが協調してレビューを行う仕組み *8 も有効である可能性があります。 7. おわりに 検証の結果、AIレビューはチーム全体の生産性と品質向上に確かに寄与することが確認できました。ただし、現状ではAIはあくまで「優秀な校正者」であり、人間のサポーターです。AIを「信頼しすぎず、上手に使いこなす」。 このバランス感覚を持つことで、私たちのレビュープロセスはより効率的で、創造的なものへと進化していくはずです。 参考文献 *1 : Bacchelli, A., & Bird, C. (2013). Expectations, outcomes, and challenges of modern code review. Proceedings of the 35th International Conference on Software Engineering (ICSE). *2 : Sadowski, C., Söderberg, E., Church, L., Sipko, M., & Bacchelli, A. (2018). Modern Code Review: A Case Study at Google. Proceedings of the 40th International Conference on Software Engineering (ICSE). *3 : Google re:Work. (n.d.). Guide: Understand team effectiveness. (Project Aristotle). *4 : Chen, Q., Yang, M., Qin, L., et al. (2025). AI4Research: A Survey of Artificial Intelligence for Scientific Research. ArXiv, abs/2507.01903. *5 : Lu, C., Lu, C., Lange, R. T., et al. (2024). The AI Scientist: Towards Fully Automated Open-Ended Scientific Discovery. ArXiv, abs/2408.06292. *6 : Nature. (2025). Transparent peer review to be extended to all of Nature's research papers. Nature. *7 : OpenReview. https://openreview.net/ *8 : D'Arcy, M., Hope, T., Birnbaum, L., & Downey, D. (2024). MARG: Multi-Agent Review Generation for Scientific Papers. ArXiv, abs/2401.04259.
RevComm Advent Calendar 2025 2日目です。 qiita.com AI Div. Research Group で機械学習のサービスの開発・運用(+MLOps)を行うチームでエンジニアリングマネージャーをしている高橋です。我々のチームは、EKSで音声解析や自然言語処理に関するサービスを提供したり、MLモデルの改善サイクルを回すための仕組みの構築を行っています。現在、あるプロジェクトでKubernetes の持つ宣言的定義でシンプルに管理しつつ、動的に状態を管理することができる Kubernetes Operator を活用して、推論プラットフォームを発展させる活動を行っています。 この記事は Kubernetes Operator の入門のための記事です。Kubernetes Operator に関しては、 kubebuilder-training など素晴らしいサイトがありますが、この記事ではさらにその入門として、Kubernetes の基本的な話も含めて解説しています。Kubernetes の基本概念に自信がない人は、この記事を読むことで kubebuilder-training をスラスラ進みやすくなれば良いなと思っています。逆にいうと、 kubebuilder-training を読める方はそちらをみていただければこの記事を読む意味はあまりないと思います。 Operator に初めて触れると、 Reconcile, Controller とは何なのか どういう仕組みで Operator が動作するのか といった点が把握しにくいことがあります。 この記事では、 まず Kubernetes 自体の Reconciliation (調整) を丁寧に理解し、その延長として Operator を自然に理解できる流れ を重視しています。 1. Operator とは何か? Operator は、 「Kubernetes に自作の調整ロジック(コントローラー)を追加する仕組み」 です。 Kubernetes には標準で多くのコントローラーが存在しており、ユーザーが宣言した状態にクラスタを自動で寄せてくれます。 例:Deployment(replicas=3) Pod が2つ → Controller が1つ増やす Pod が4つ → Controller が1つ減らす 最終的に3つに“収束”する Operator は、この「自動調整」の仕組みを自作のリソースでも実現するための手段です。 *後で書くように厳密には、Deployment のコントローラーが直接 Pod の数を調整するのではなく、Deployment は ReplicaSet を管理し、ReplicaSet のコントローラーが Pod の管理を行います。 2. リソースとコントローラー ― Kubernetes の基本概念 Operator を理解するには、Kubernetes の根本アイデアである リソース と コントローラー を押さえる必要があります。 2.1 リソースとは Kubernetes のクラスタ上の情報はすべて「リソース」という形で管理されています。 Pod Deployment Service ConfigMap Node など、 kubectl get で一覧できるものはすべてリソースです。 Operator が扱う CR(Custom Resource)も、 この仲間に加わるだけ です。 2.2 コントローラーとは コントローラーは次のようなプログラムです: リソースの状態を監視し、クラスタを“望ましい状態”へ調整する Deployment Controller を例にすると: Deployment を監視 Pod の状態を読み取り 差分があれば調整 何度でも実行される(Reconcile Loop) この仕組みが Kubernetes 全体を構成しています。 3. API Server とは何か? 「API Server」は Kubernetes の中心にある“窓口”ですが、初心者には突然登場するため混乱しやすいポイントです。 そこで kubectl との関係から説明 します。 3.1 kubectl は常に API Server を叩いている kubectl apply kubectl get kubectl describe これらすべては API Server に対する HTTP(S) リクエストです。 つまり、 kubectl は API Server と会話しているだけ です。 3.2 Controller も同じ構造 Controller も基本的に次の2つのことを実行します。 API Server からリソースを読む API Server にリソース作成/更新/削除を依頼する kubectl も Controller も、API Server を通じてクラスタ状態を操作している。 *厳密には、Controller が直接 API Server へリクエストを大量に送るということはなく、キャッシュなどを行うアクセスを最適化する Informer からデータを取得するが、Informer のデータソースも結局 API Server になっています。メンタルモデルとしては、Controller は API Server で読み書きをしているイメージがシンプルで分かりやすいですが、実際には Informer などで最適化されています。 4. Reconcile Loop ― Controller のコア すべての Controller(標準のものも Operator も)は Reconcile Loop の思想で動きます。 4.1 Reconcile が担当するのは3つだけ あるべき状態(ユーザーの宣言)を読む 現在の状態(クラスタの実情)を読む 差分を埋める Deployment Controller も ReplicaSet Controller も、Operator の Reconcile も同じです。 4.2 冪等性が重要 Kubernetes では Reconcile が何度も呼ばれます: イベントの重複 キャッシュ更新 再試行(Retry) そのため次のような「安全な書き方」が重要です。 存在しないときだけ作成 必要なときだけ更新 同じ状態なら何もしない 5. Operator の構造理解のための Deployment Controller Deployment Controller の流れは次のとおりです: Deployment(望ましい状態)を読む ReplicaSet を読む Pod を読む 差分があれば調整 収束まで繰り返す Operator はこれを自作の CR で行うだけです。 Deployment → 自作の CR Deployment Controller → 自作の Controller 構造は全く同じです。 6. 手元で動かせるクラスタ: kind(Kubernetes in Docker) Operator を学ぶうえで「手元で動かす」ことは非常に重要です。 今回は kind を使います。 6.1 インストール https://kind.sigs.k8s.io/docs/user/quick-start/#installation を参考にインストールしてください 6.2 クラスタ作成 kind create cluster --name operator-demo 確認: kubectl get nodes Node が表示されれば準備完了です。 7. Kubebuilder プロジェクトの作成 7.1 プロジェクト作成 mkdir operator-demo cd operator-demo kubebuilder init --domain example.com --repo example.com/operator-demo 生成物: . ├── cmd ├── config │ ├── default │ ├── manager │ ├── network-policy │ ├── prometheus │ └── rbac ├── Dockerfile ├── go.mod ├── go.sum ├── hack ├── internal │ └── controller # ロジックのコア部分 ├── Makefile ├── PROJECT ├── README.md └── test 7.2 CR と Controller の生成 kubebuilder create api \ --group demo \ --version v1alpha1 \ --kind Sample Create Resource [y/n] , Create Controller [y/n] と聞かれるので y で進んでください。 増えるファイル: api/v1alpha1/sample_types.go api/v1alpha1/groupversion_info.go internal/controller/suite_test.go internal/controller/sample_controller.go internal/controller/sample_controller_test.go 8. Operator ミニマム実装: ConfigMap 同期 Operator CR の内容に応じて ConfigMap を作成・更新する 単に「ConfigMap を作るだけ」では Reconcile の本質ががわかりにくいので、 CR の spec.message を読む ConfigMap に message を書き込む 差分がある場合にのみ更新する 同じであれば何もしない(冪等性) という“Reconcile そのもの”がわかりやすく、かつミニマムな例にしています。 8.1 CR の Spec に message を追加 api/v1alpha1/sample_types.go の SampleSpec にフィールドを追加: type SampleSpec struct { Message string `json:"message,omitempty"` } サンプル CR: config/samples/demo_v1alpha1_sample.yaml apiVersion : demo.example.com/v1alpha1 kind : Sample metadata : labels : app.kubernetes.io/name : operator-demo app.kubernetes.io/managed-by : kustomize name : sample-sample spec : message : "hello world" 8.2 Reconcile の本体(差分判定・更新・冪等性) operator-demo/internal/controller/sample_controller.go import ( ... corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ... ) ... func (r *SampleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error ) { // CR を取得(あるべき状態) var cr demov1alpha1.Sample if err := r.Get(ctx, req.NamespacedName, &cr); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } // 望ましい ConfigMap(desired) desired := corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: cr.Name + "-config" , Namespace: cr.Namespace, }, Data: map [ string ] string { "message" : cr.Spec.Message, }, } // 現在の ConfigMap(current) var current corev1.ConfigMap err := r.Get(ctx, client.ObjectKeyFromObject(&desired), &current) // 存在しない → 作る if apierrors.IsNotFound(err) { if err := r.Create(ctx, &desired); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil } if err != nil { return ctrl.Result{}, err } // 差分チェック → message が違う場合のみ更新 if current.Data[ "message" ] != cr.Spec.Message { current.Data[ "message" ] = cr.Spec.Message if err := r.Update(ctx, &current); err != nil { return ctrl.Result{}, err } } // 差分なし → 何もしない(冪等) return ctrl.Result{}, nil } Reconcile の本質の復習 ここで、Reconcile の以下の点を実感できるはずです。 “あるべき状態” を CR から取得 “現在の状態” をクラスタから取得 差分があれば調整 同じなら何もしない これは Kubernetes Controller が日常的に行っている動作と同じです。 9. ローカルで動かすコントローラー 9.1 make install 次をクラスタへ適用します: CR の定義(Customer Resource Definition: CRD) config/crd の内容が適用されます。 クラスタに「新しいリソース」が使えるようにするフェーズ 9.2 make run main.go をビルド コントローラーをローカルプロセスとして起動 この実行方法の場合、Operator 自体はクラスタ内で動かない(ローカルで動く) ローカルテストでない場合は、クラスタ内にデプロイします ( make deploy )が、この記事では扱いません 10. 動作確認 CR を適用します: kubectl apply -f config/samples/demo_v1alpha1_sample.yaml ConfigMap を確認: kubectl get configmap kubectl get configmap sample-sample-config -o yaml 次のような表示になっていれば成功です! hello world が確認できているところがポイントです。 apiVersion: v1 data: message: hello world kind: ConfigMap metadata: creationTimestamp: "2025-11-28T01:53:35Z" name: sample-sample-config namespace: default resourceVersion: "3604" uid: 680ec943-8527-4044-a8d6-b22c4020ccb6 次に CR を変更してみます: kubectl patch sample/sample-sample --type merge -p '{"spec":{"message":"updated"}}' ConfigMap の内容が自動で更新されます。確認してみましょう。 % kubectl get configmap sample-sample-config -o yaml apiVersion: v1 data: message: updated kind: ConfigMap metadata: creationTimestamp: "2025-11-28T01:53:35Z" name: sample-sample-config namespace: default resourceVersion: "3761" uid: 680ec943-8527-4044-a8d6-b22c4020ccb6 updated に更新されています。逆に、ConfigMap の方を直接編集してみましょう。 kubectl edit configmap sample-sample-config を実行して、 data.message を次のように更新します。 data: message: fixed_directly どうなるでしょうか? % kubectl get configmap sample-sample-config -o yaml apiVersion: v1 data: message: fixed_directly kind: ConfigMap metadata: creationTimestamp: "2025-11-28T01:53:35Z" name: sample-sample-config namespace: default resourceVersion: "3881" uid: 680ec943-8527-4044-a8d6-b22c4020ccb6 変化がありません。予想通りでしたでしょうか?これは、現状の設定だと Reconcile が実行されれば CR の内容に上書きされますが、ConfigMap の編集では Reconcile がトリガーされていない、と説明できます。 Controller が Watch していないリソースの変更は検知されない 点は重要なので、頭に入れておきましょう。ここでは、 OwnerReference を設定して、 SetupWithManager を次のように更新することでリソースの変更を検知できるようにします。 internal/controller/sample_controller.go // SetupWithManager sets up the controller with the Manager. func (r *SampleReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&demov1alpha1.Sample{}). Named( "sample" ). Owns(&corev1.ConfigMap{}). // これを追加 Complete(r) } また、ConfigMap にオーナーへの参照 (OwnerReference) を設定する必要があります。 internal/controller/sample_controller.go の Reconcile 関数の中身を少し修正します。 // 望ましい ConfigMap(desired) desired := corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: cr.Name + "-config" , Namespace: cr.Namespace, }, Data: map [ string ] string { "message" : cr.Spec.Message, }, } // [NEW] OwnerReference を設定 if err := ctrl.SetControllerReference(&cr, &desired, r.Scheme); err != nil { return ctrl.Result{}, err } // 現在の ConfigMap(current) var current corev1.ConfigMap err := r.Get(ctx, client.ObjectKeyFromObject(&desired), &current) 編集後、 make run を止めてコントローラを止めてください。また、 kubectl delete configmap sample-sample-config で一度 ConfigMap を削除してください。その後、再度 make run を実行します。そうすると、前回最後に CR 側で更新した updated の値になっています。 % kubectl get configmap sample-sample-config -o yaml apiVersion: v1 data: message: updated kind: ConfigMap metadata: creationTimestamp: "2025-11-28T02:07:38Z" name: sample-sample-config namespace: default ownerReferences: - apiVersion: demo.example.com/v1alpha1 blockOwnerDeletion: true controller: true kind: Sample name: sample-sample uid: 323bdd14-45ff- 4e98 -806a-4f82086616b0 resourceVersion: "4695" uid: d41f188f-c701-49d1-895e-9b86e63864e7 また、今回は ownerReferences が追加されています。この状態で、先ほどと同様に ConfigMap を書き換えてみましょう。 kubectl edit configmap sample-sample-config を実行して、 data.message を次のように更新します。 data: message: fixed_directly どうなるでしょうか? % kubectl get configmap sample-sample-config -o yaml apiVersion: v1 data: message: updated kind: ConfigMap metadata: creationTimestamp: "2025-11-28T02:07:38Z" name: sample-sample-config namespace: default ownerReferences: - apiVersion: demo.example.com/v1alpha1 blockOwnerDeletion: true controller: true kind: Sample name: sample-sample uid: 323bdd14-45ff- 4e98 -806a-4f82086616b0 resourceVersion: "4940" uid: d41f188f-c701-49d1-895e-9b86e63864e7 今度は CR の値に上書きされました。OwnerReference が設定されている ConfigMap を更新したことで Reconcile がトリガーされ、値が更新されたわけです。 (ちなみに、 kubectl delete sample/sample-sample とすると、作成されていた ConfigMap も消えていることもわかります。OwnerReference はこうしたトリガーのためだけのものではなく、 ガベージコレクション の際にも利用されます。) CRで宣言的に定義した内容が、Reconcile Loop によって実現 されていく様子を見ることができました。最後の方ではいつトリガーされるか、という点に関しても修正してみました。基本的には、Reconcile をコアロジックとしていつトリガーするかを工夫することで多くのことが実現でき、シンプルに管理できるという点を何となく感じ取れたかと思います。 11. まとめ この記事で押さえたポイント Operator のコアは 自作のコントローラー Operator のコアロジックは Reconciliation (調整) Reconcile 処理で、あるべき状態と現在の状態の差分を埋める(← 冪等性が重要) Operator 開発は、ハードルが高そうに見えますが、kubebuilder を活用し、kind を使うことで簡単に試したり、遊んだりすることができます。Kubernetes の “宣言的な自動調整” の思想を自分で実装できるので色々試して実運用に活かしたいものです。より実践的な例や他のトピックが気になる方は、 kubebuilder-training を手を動かして見るのが個人的にはおすすめです。
はじめに RevComm Advent Calendar 2025 1日目の記事です。 qiita.com 昨今では AI コーディングエージェントが話題です。AI コーディングエージェントを活用することで、フロントエンド開発においても生産性の大きな改善が期待できます。 AI コーディングエージェントの活用を広めていくためには、信頼性の高いテストコードがあるとより積極的かつ安全に活用や導入を進めていきやすいのではないかと考えています。 そこで、この記事ではフロントエンド開発において信頼性の高いテストコードを記述するための方法論などについて説明できればと思います。 テストダブルの使用について 前提: テストダブルとは? 大雑把に要約すると、テストにおいて本物のオブジェクトの代わりとして機能してくれるオブジェクトのことを指します。 テストダブルには、スタブ、モック、スパイ、フェイクなど、様々な種類があります。以下の Wikipedia ページに分類が解説されていますが、これらの定義を参考にテストダブルについて紹介します (これらの種別の用法や意味には正確な定義や標準が存在するわけではなく、チームや文脈などによって意味が異なる場合もあると思うため、各用語の意味については参考程度にとどめていただければと思います) en.wikipedia.org ja.wikipedia.org テストダブルの分類 フェイク まず比較的イメージしやすいと思われるフェイクから紹介します。 https://en.wikipedia.org/wiki/Test_double からフェイクの定義を引用します。 Fake — a relatively full-function implementation that is better suited to testing than the production version; e.g. an in-memory  database  instead of a database  server ) 置き換え対象の本物のオブジェクトの振る舞いを模倣したオブジェクトがフェイクであると考えられそうです。 例えば、ユーザーの永続化に関する責務を定義した UserRepository インターフェースがあったとします。これに対して、 ProductionUserRepository は本番コードでの利用が想定された UserRepository の実装であり、REST API を使用してオブジェクトを永続化します。 class ProductionUserRepository implements UserRepository { constructor ( client ) { this .#client = client; } async get ( id ) { try { const response = await this .#client.getUserById(id); return this .#makeUserFromResponse(response); } catch (error) { if (isNotFoundError(error)) return Promise . reject ( new UserNotFoundError(id)); throw error; } } async add ( user ) { await this .#client.updateUser(user); } #makeUserFromResponse () { // 省略... } } これに対して以下はフェイクの実装例です。データはインメモリで管理され永続化は行われないものの、外から見た際には ProductionUserRepository と概ね同じように動作をします。 class FakeUserRepository implements UserRepository { #userById = new Map (); get ( id ) { const maybeUser = this .#userById. get (id); if (maybeUser == null ) return Promise . reject ( new UserNotFoundError(id)); return Promise . resolve (maybeUser); } add ( user ) { this .#userById. set (user. id , user); return Promise . resolve (); } } 依存性の注入 (DI) と併用することにより、実際にコードを動作させる際は ProductionUserRepository を利用し、テストコードの実行時はフェイク実装である FakeUserRepository の方を利用するなど、柔軟に実装を切り替えることができます。 { // 本番コード const service = new UserService ( new ProductionUserRepository ( client )) ; // ... } { // テストコード const service = new UserService ( new FakeUserRepository ()) ; // ... } フェイクを実装する際は、本番向けの実装とフェイク実装とで共通のテストコードを実行しておくと、それぞれの振る舞いを維持することができ、より高い信頼性が期待できます。後述する Google のソフトウェアエンジニアリング においてもこの手法は推奨されており、 https://en.wikipedia.org/wiki/Test_double においては Verified fake と呼ばれています。 フェイクには他のテストダブルと比較して信頼性が高いというメリットがあります。しかし、デメリットとして、後述するスタブやモックなどと比較すると実装コストが高くなりがちであり、またメンテナンスも必要です。このフェイクの実装やメンテナンス作業というのはまさに AI コーディングエージェントが得意としている分野であると考えられるため、もしフェイクの利用を進める場合はぜひ活用を検討してみると良さそうです。 スタブ https://en.wikipedia.org/wiki/Test_double から定義を引用します。 Stub — provides static input 定義に基づいて考えると、スタブは以下のように実装することができます。ライブラリーなどを使用せずとも比較的容易に実装が可能で、フェイクと比較すると、実装がかなり簡単です。 class StubUserRepository implements UserRepository { get ( id ) { return Promise . resolve ( new User(id, "foobar" )); } add ( _user ) { return Promise . resolve (); // NOOP } } JavaScript は動的言語であり、例えば Jest の jest.spyOn() を使うと特定のメソッドのみをスタブに置き換えることも容易に行えます。 jest . spyOn ( localStorage , 'getItem' ) . mockImplementation (() => 'foo' ) ; スタブは実装がとても容易であるというメリットがありますが、先に紹介したフェイクと比較すると信頼性においては大きく劣るというデメリットがあります。乱用はし過ぎずに適度な利用がおすすめであると考えます。 モック https://en.wikipedia.org/wiki/Test_double から定義を引用します。 Mock — verifies output via expectations defined before the test runs 上記のモックの定義に従うと、 Sinon.JS における mock() が定義に近いと思います。 // ... const mock = sinon . mock ( userRepository ) ; // Expectations mock . expects ( 'add' ) . once () . withArgs ( user ) ; const userService = new UserService ( userRepository ) ; await userService . add ({ id : '1' , name : 'foobar' }) ; mock . verify () ; このように JavaScript においては、Sinon.JS や testdouble.js などのライブラリーを使うと比較的簡単にモックを実装できます。依存しているオブジェクト間でのコミュニケーションを検証することで、意図した副作用が発生していることをテストすることができます。例えば、特定のメソッドがある順番に従って呼び出されていることを検証したいケースにおいて利用ができます。ただし、テスト対象がどの順番で一連のメソッドを呼び出すかは実装の詳細であり、大抵の場合、過度にモックを乱用したり依存することは望ましくないと考えられます。 モックは便利な仕組みではあると思いますが、契約による設計が言語レベルでサポートされているような稀なケースを除いて、モックに過度に依存しすぎるのは避けた方が良いと筆者は考えています。特定のメソッドが呼ばれたかどうかを検証するのではなく、それによって引き起こされる状態遷移などを検証した方がテストの信頼性が高まると思います。 スパイ https://en.wikipedia.org/wiki/Test_double から定義を引用します。 Spy — supports setting the output of a call before a test runs and verifying input parameters after the test runs スタブやモックなどとの違いが少しややこしいですが、テストの実行後に入力パラメーターの検証が行えるという点が大きな違いであると思います。 スパイについても JavaScript では jest.fn() や vi.fn() , jest.spyOn() などを利用すると容易に実装できます。 const stubUserRepository: UserRepository = { get : jest.fn(( id ) => new User(id, "foobar" )), // スタブ add : jest.fn(), // スパイ } ; doSomethingWithUserRepository(stubUserRepository); expect (stubUserRepository. add ).toHaveBeenCalledWith( new User(id, "foobar" )); // 入力値の検証 どれを使えばいいの? テストダブルの使い分けについては基準が難しいところではありますが、まず前提として、テストダブルを使わなくともテストを書くことが可能なのであれば、それがコードが意図した通りに動作していることを保証するための最も信頼性の高い方法であると思います。ただし、現実には外部の REST API や サービスなどに対してテストから直接接続する場合、意図せぬ副作用が発生してしまったり、テストの実行時間が大きく増加してしまうことなども考えられます。そのような場合はテストダブルの使用を検討すると良いでしょう。 参考までに、 Google のソフトウェアエンジニアリング という本の13章においてテストダブルの使用に関して非常に詳しく解説されています。 この本ではまず「忠実性」の考えについて紹介されています。「忠実性」とはテストダブルが置き換え対象の本物のオブジェクトの挙動にどれくらい近いかを表す指標であると説明されています。 前提としてテストダブルを使用せずとも十分にテストが可能である際は、テストダブルを使用せずに本物のオブジェクトを使用することがこの本でも推奨されています。それがコード上に存在するバグを正しく検出してくれる可能性が高いケースが多いと考えられるためです。 もしテストダブルの使用が必要である際はフェイクの使用が推奨されています。フェイクは本物のオブジェクトの挙動を模倣したものであり、スタブやモックなどと比較して忠実性が高いためです。逆にスタブやモックを過度に用いることは、フェイクを使用した場合と比較してテスト対象の実装の詳細への依存度が高くなってしまいがちであり、脆いテストコードができてしまうリスクがあると説明されています。 jest.mock() / vi.mock() の使用はできるだけ避ける これらの前提に基づいて、まずは Jest における jest.mock() や Vitest における vi.mock() について考えてみます。これらの API は先ほどのテストダブルの定義に照らし合わせた場合、スタブに該当するものであると考えられます。そのため、フェイクと比較すると忠実度が低く、乱用しすぎると信頼性が低いテストコードができてしまう原因になってしまう可能性が考えられます。 jest.fn() や 後述する jest.spyOn() などを使用してスタブを実装する場合においても信頼性の低いテストコードができてしまうケースは考えられますが、 jest.mock() や vi.mock() に関しては実装の詳細へ強く依存したテストコードがより一層容易に記述できてしまうため、特に注意が必要であると考えます。 例えば、 apis/getUser モジュールを介して REST API を実行し、ユーザー情報を取得するフックがあったとします。 // src/hooks/useUser.ts import { getUser } from 'apis/getUser' ; export function useUser ( id ) { const [ user , setUser ] = useState( null ); const [ isLoading , setIsLoading ] = useState( false ); const [ error , setError ] = useState( null ); useEffect(() => { if (isLoading) return ; setIsLoading( true ); getUser(id) . then (( user ) => setUser(user)) . catch (( error ) => setError(error)) . finally (() => setIsLoading( false )); } , [ id ] ); return { error , user , isLoading } ; } jest.mock() を使うことにより、非常に簡単にスタブをセットアップすることができます。 // src/hooks/__tests__/useUser.spec.tsx import { renderHook, waitFor } from '@testing-library/react' ; import { useUser } from '@/hooks/useUser' ; jest.mock( 'apis/getUser' , () => { return { getUser : ( _id ) => Promise . resolve (dummyUser), } ; } ); test ( 'useUser()' , async () => { const { result } = renderHook(() => useUser(dummyUser. id )); expect (result. current .isLoading).toBe( true ); await waitFor(() => expect (result. current .user).toEqual(dummyUser)); expect (result. current .isLoading).toBe( false ); } ); jest.mock() を使うことにより、見かけ上はシンプルにテストを記述することができました。ではこれの何が問題なのでしょうか? 例として、ここで apis/getUser モジュールを apis/users/get にリネームしたとします。それに伴い、 src/hooks/useUser.ts の import も修正する必要があります。 // src/hooks/useUser.ts - import { getUser } from 'apis/getUser'; + import { getUser } from 'apis/users/get'; export function useUser(id) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (isLoading) return; setIsLoading(true); getUser(id) .then((user) => setUser(user)) .catch((error) => setError(error)) .finally(() => setIsLoading(false)); }, [id]); return { error, user, isLoading }; } ソースコードは適切に修正されており、プロダクションコードは意図通りに機能し続けます。 しかし、この状態で src/hooks/__tests__/useUser.spec.tsx を実行すると、テストは失敗してしまいます。 apis/getUser.ts から apis/users/get.ts へのリネームは適切に行われており、 getUser() の振る舞いも変わってはいないので、本来であればこの状況でテストが失敗してしまうことは望ましくありません。これはテストコードが脆い状態に陥ってしまっており、信頼性が低下してしまっていることを示唆しています。 この問題が発生してしまうのは、 src/hooks/__tests__/useUser.spec.tsx が テスト対象である src/hooks/useUser.ts モジュールの実装の詳細である「モジュール間の依存関係」に強く依存してしまっていることが原因です。 jest . mock ( 'apis/getUser' , () => { return { getUser : ( _id ) => Promise . resolve ( dummyUser ) , } ; }) ; モジュール間の依存関係というのは、実装の詳細の中でもかなり詳細度の高いものであると考えられるため、これにテストコードが依存してしまうことは望ましくないと考えます。 改善案 Testing Library (詳細は後述します) がコンポーネントの実装の詳細への強い依存を避けることでテストコードの信頼性を高めることを重視していることと同様に、この問題においてもテストコードがテスト対象の実装の詳細へ強く依存しすぎてしまうことを避けることで改善することができます。具体的に2つの解決策について紹介します。 1. ユーザーの取得に関する責務を抽象化する (フェイクを使用した改善例) useUser() において重要なのは、何かしらの手段によってユーザー情報を取得し、それに関する状態管理を行うことです。どのようにしてユーザーを取得するかについては実装の詳細にあたり、重要ではないと考えられます。 そこで、このユーザー情報の永続化に関する責務を表現する interface を用意します。 export interface UserRepository { get ( id : UserID ): Promise < User > ; add ( user : User ): Promise < void > ; } UserRepository の実装は Context を介して注入するように変更します。 // src/hooks/useUser.ts import { useContext } from 'react' ; export function useUser ( id ) { const [ user , setUser ] = useState( null ); const [ isLoading , setIsLoading ] = useState( false ); const [ error , setError ] = useState( null ); const userRepository = useContext(UserRepositoryContext); useEffect(() => { if (isLoading) return ; setIsLoading( true ); userRepository. get (id) . then (( user ) => setUser(user)) . catch (( error ) => setError(error)) . finally (() => setIsLoading( false )); } , [ id ] ); return { error , user , isLoading } ; } こうすることで、テスト時はフェイク実装によって代用することが可能です // src/hooks/__tests__/useUser.spec.tsx import { renderHook, waitFor } from '@testing-library/react' ; import { useUser } from '@/hooks/useUser' ; import { FakeUserRepository } from '@/repositories/user.fake.ts' ; test ( 'useUser()' , async () => { const userRepository = new FakeUserRepository(); await userRepository. add (dummyUser); const { result } = renderHook(() => useUser(dummyUser. id ), { wrapper : ( { children } ) => ( < UserRepositoryContext.Provider value = {userRepository} > { children } </ UserRepositoryContext.Provider > ), } ); expect (result. current .isLoading).toBe( true ); await waitFor(() => expect (result. current .user).toEqual(dummyUser)); expect (result. current .isLoading).toBe( false ); } ); このように interface によって責務を抽象化し、依存注入によって依存関係を取り扱うパターンはフレームワークとして Angular などを採用されている場合は比較的一般的なパターンではないかと思われます。しかし、そうではない場合は ここまでやらなくても十分なケースも多いと思われるため、最終的にはプロジェクトの規模やチームの方針などに応じて決めると良いと思います。 ここではもう一つの方法として msw を使った方法についても紹介します。 2. mswを使う msw とは HTTP に関するスタブライブラリーです。 github.com 元の例における src/hooks/__tests__/useUser.spec.tsx は src/hooks/useUser.ts が apis/users/get.ts に依存しているということを前提に記述されていました。モジュール間の依存関係というのは、実装の詳細の中でもかなり詳細度が高いものであると考えられ、テストコードがその詳細に依存することによって信頼性が低下してしまっています。msw を使うことによって、テストコードが「このコードは apis/users/get.ts モジュールに依存し、それを使って HTTP リクエストを送信している」という強い前提への依存から「このコードは何らかの方法で HTTP リクエストを送信している」という前提への依存へ緩めることができます。 // src/hooks/__tests__/useUser.spec.tsx import { renderHook, waitFor } from '@testing-library/react' ; import { useUser } from '@/hooks/useUser' ; import { http, HttpResponse } from 'msw' ; import { setupServer } from 'msw/node' ; const server = setupServer( http. get ( '/api/users/:id' , () => HttpResponse.json(dummyUser)), ); beforeAll (() => server.listen( { onUnhandledRequest : 'error' } )); afterEach (() => { server.resetHandlers(); } ); afterAll (() => server. close ()); test ( 'useUser()' , async () => { const { result } = renderHook(() => useUser(dummyUser. id )); expect (result. current .isLoading).toBe( true ); await waitFor(() => expect (result. current .user).toEqual(dummyUser)); expect (result. current .isLoading).toBe( false ); } ); jest.mock() を使用した例と比較して記述量は増えるものの、大きなメリットとして、 useUser() の実装を TanStack Query などを使用して書き換えたとしても、そのままテストが動作してくれます (ただし、 renderHook を呼ぶ際の Provider の指定は必要です) msw を使用する際は意図せぬ API リクエストの送信を検知できるよう、 onUnhandledRequest に error の設定をオススメします。 beforeAll (() => server . listen ({ onUnhandledRequest : 'error' })) ; msw を使うことにより、 jest.mock() を使用した場合と比較して、テストコードがテスト対象コードの実装の詳細へ強く依存しすぎてしまう状況を緩和することができました。 ただし、この msw を使用した例においてもテストコードは依然として「テスト対象が何らかの方法で HTTP リクエストを送信する」という実装の詳細へ依存した状態です。1つ目のフェイクを使用した改善例と比較すると実装の詳細への依存度は高い状態であると考えられます。しかし、あまりにも抽象化することを意識しすぎてしまうと、今度は過度な抽象化を招いてしまうリスクも考えられます。フロントエンド開発においては、大抵の場合はこの msw を使用してスタブをセットアップする解決策で十分なケースが多いのではないかと考えています。 また、msw を利用する場合も、可能であれば本物の API の振る舞いに近づけるとより信頼性が高まることが期待されます。必要に応じて検討すると良いでしょう。 const users = [] ; const server = setupServer( http. get ( '/api/users/:id' , ( { params } ) => { const user = users. find (( x ) => x. id .equals(params. id )); if (user == null ) return new HttpResponse( null , { status : 404 } ); else return HttpResponse.json(serializeUser(user)); } ), http.post( '/api/users' , async ( { request } ) => { const { name } = await request.json(); const id = makeUserID(); users. push ( new User(id, name )); return HttpResponse.json( { id , name } ); } ), ); jest.mock() / vi.mock() の使用が適したケースについて 筆者としては jest.mock() の使用は極力避けた方が良いとは考えていますが、まだテストコードが導入されておらず、これから導入していきたいというようなケースにおいては、どうしても jest.mock() を使わないとなかなかテストを追加することが難しいというような場合もあると思います。そういったケースにおいては jest.mock() は非常に便利な機能であると思うため、用途や場面を限定して使用すると良いと思っています。 jest.spyOn() / vi.spyOn() の使用について 先ほどの定義に基づいて考えると、 jest.spyOn() や vi.spyOn() はスタブやスパイなどのセットアップに利用できる機能です。つまり、テストにおいて本物のオブジェクトを使用する場合やフェイクを使用する場合と比較して、忠実性は低下してしまいます。 例として localStorage に依存した useSidebar() フックをテストするケースについて考えてみます。 function useSidebar () { const [ isCollapsed , _setIsCollapsed ] = useState(() => JSON . parse (localStorage. getItem ( 'isSidebarCollapsed' ) ?? 'false' )); const setIsCollapsed = useCallback(( isCollapsed ) => { localStorage. setItem ( 'isSidebarCollapsed' , JSON . stringify (isCollapsed)); _setIsCollapsed(isCollapsed); } , [] ); return { isCollapsed , setIsCollapsed } ; } これをテストする場合、例えば jest.spyOn() を使用して localStorage をスタブする方法が考えられます。 test ( 'useSidebar' , async () => { jest.spyOn(localStorage, 'getItem' ).mockImplementation(() => 'true' ); const setItem = jest.spyOn(localStorage, 'setItem' ); const { result } = renderHook(() => useSidebar()); expect (result. current .isCollapsed).toBe( true ); expect (setItem).not.toHaveBeenCalled(); act(() => result. current .setIsCollapsed( false )); expect (result. current .isCollapsed).toBe( false ); expect (setItem).toHaveBeenCalledTimes( 1 ); } ); 上記の例では jest.spyOn() を使用して localStorage をスタブしていますが、 jsdom には localStorage のフェイク実装がすでに含まれており、こちらに依存した方がより信頼性が高まるでしょう。特に localStorage は Web 標準の API であり、その振る舞いや API に破壊的変更が生じる可能性は比較的低いと考えられます。また、 localStorage はネットワークアクセスが発生するわけでもなく、高速に動作することが期待されます。そのため、わざわざフェイク実装やスタブを用意せずとも比較的信頼性の高いテストが書けそうです。 afterEach (() => localStorage. clear ()); test ( 'useSidebar' , async () => { localStorage. setItem ( 'isSidebarCollapsed' , 'true' ); const { result } = renderHook(() => useSidebar()); expect (result. current .isCollapsed).toBe( true ); act(() => result. current .setIsCollapsed( false )); expect (result. current .isCollapsed).toBe( false ); expect (localStorage. getItem ( 'isSidebarCollapsed' )).toBe( 'false' ); } ); このようにスタブを使用せずに、実際のオブジェクトとのやりとりも含めたインテグレーションテストを記述することで、より信頼性の高いテストが書けるケースもあります。 Testing Library を使ってコンポーネントのインテグレーションテストを記述する 次はコンポーネントに対するテストの観点から考えてみます。 Enzyme / Vue Test Utils について コンポーネントのテストという観点では、React においては Enzyme 、Vue.js においては Vue Test Utils のような高機能なテスト用パッケージがあります。 これらのパッケージはレンダリングされたコンポーネントの状態を直接問い合わせたり、CSS セレクターによるレンダリング結果の柔軟な問い合わせなどをサポートしてくれる便利なライブラリーです。 これらのライブラリーを使用して信頼性の高いテストコードを書くことも可能ではあると思います。しかし、そのためには注意深くテストの記述やレビューなどを行う必要があります。 React Testing Library や Vue Testing Library などのいわゆる Testing Library では最初から信頼性を念頭に置いて設計されており、これらのライブラリーを利用することでより信頼性の高いテストコードを記述しやすいです。 なぜ Testing Library を使うのか? 公式の Guiding Principles で解説されていますが、Testing Library の考えとして、テスト対象のコンポーネントに対して「ユーザーは実際にブラウザー上でどのようにして対話するか?」という観点からテストを記述できるようにすることで、テストコードが対象コンポーネントの実装の詳細に依存することを回避し、より信頼性の高いテストコードを記述できるようにしてくれます。 github.com Testing Library の説明は Web 上にすでにたくさん存在しているため、簡潔に特徴を紹介します。 例えば、Enzyme においてはコンポーネントのレンダリング結果に対して CSS セレクターを使用して柔軟に問い合わせを行うことが可能です。 const wrapper = shallow(< MyForm />); wrapper. find ( '.some-button' ).simulate( 'click' ); それに対して Testing Library では CSS セレクターによるレンダリング結果の問い合わせ機能が意図的に提供されていません。その代わりにアクセシビリティーの観点から問い合わせることが推奨されています。以下は React Testing Library を使用した例です。 import { render } from '@testing-library/react' ; import { userEvent } from '@testing-library/user-event' ; test ( 'MyForm' , () => { const user = userEvent.setup(); const screen = render(< MyForm />); // button ロールを持ち Save というアクセシブル名をもつ要素を問い合わせ、それをクリックします await user.click( screen .getByRole( 'button' , { name : 'Save' } )); } ); ユーザーが実際に UI を操作する際は CSS セレクターという実装の詳細に基づいて要素を認識しているわけではなく、個々の要素の役割やラベルなどに基づいて操作します。Testing Library ではこの考えに基づき、意図的に CSS セレクターによる問い合わせを禁止し、代わりにアクセシビリティーに基づいた問い合わせを推奨しています。 また、CSS セレクターによる問い合わせを避けることで、例えば、クラス名がリネームされた際に意図せずテストが失敗してしまうことを防止できます。 また、Enzyme においてはコンポーネントの現在の状態を直接問い合わせたり、またはコンポーネントで定義されたメソッドを直接呼ぶことも可能でした。しかし、コンポーネントの状態や定義されたメソッドというのは実装の詳細です。例えば、コンポーネントの状態がリネームされた場合、それにテストコードが依存していた場合、テストは容易に壊れてしまいます。 const wrapper = shallow(< MyForm />); wrapper. find ( '.some-input' ).simulate( 'click' ); expect (wrapper. state ( 'isSaving' )).toBe( true ); // コンポーネントの状態を直接問い合わせる (もし state がリネームされると、このテストは失敗します) Testing Library ではこのようなコンポーネントの状態の問い合わせやメソッドの直接的な呼び出しも廃止されており、先ほどの CSS セレクターの例と同様にアクセシビリティーの観点からレンダリング結果を問い合わせたり、要素を操作することによって対応することが想定されています。 const user = userEvent.setup(); const screen = render(< MyForm />); await user.click( screen .getByRole( 'button' , { name : 'Save' } )); expect ( await screen .findByRole( 'img' , { name : 'Saving' } )).toBeVisible(); // アクセシビリティーに基づいてレンダリング結果を問い合わせる このように Testing Library では意図的に実装の詳細への依存を回避することで、高い信頼性を提供してくれます。 Testing Library を利用する際の Tips どのクエリメソッドを使うべきか? 詳細については公式ドキュメントに記載されていますが、基本的には ByRole クエリーを使用して問い合わせをすることが推奨されています。 github.com 具体的には、以下の場合、 Save というアクセスシブル名を持つ button ロール を問い合わせます (大抵の場合、 Save というラベルが設定された button が見つかることでしょう) screen .getByRole( 'button' , { name : 'Save' } ); ByRole クエリーを使用することで、特定の要素をユーザーが特定・操作する際の意図に基づいて問い合わせることができます。極端な例ではありますが、ある UI コンポーネントフレームワークから別の UI コンポーネントフレームワークへ移行したとしても、 ByRole クエリーに基づいて要素を問い合わせていれば、ある程度はテストコードを変更せずにそのまま動作し続けてくれることが期待できます。 もし ByRole クエリーを利用することが難しい場合は、 ByLabelText または ByPlaceholderText などの代替手段を利用すると良いと思います。 ByTestId はどうしても他の手段では要素を問い合わせることが困難な場合に限定して使用すると良いです。 イベントを発火させる際は @testing-library/user-event を使う 例えば、React Testing Library には fireEvent() というAPIがあり、要素に対して任意のイベントを発火させることが可能です。例えば以下のように記述することで、特定要素に対して click イベントを発火させることができます。 fireEvent.click(someButton); しかし、実際にユーザーがブラウザーをマウスで操作してクリックする際は、まずマウスによってカーソルをボタンの上部まで移動させ 、その後、マウスの左キーをクリックするといったように、実際にはその背後では様々なイベントが発火されています。 @testing-library/user-event パッケージは、このようなユーザーが実際にブラウザー上で特定の要素を操作する際の一連の振る舞いを可能な限り再現してくれるパッケージです。 import { userEvent } from '@testing-library/user-event' ; // ... const user = userEvent.setup(); const someButton = screen .getByRole( 'button' , { name : 'Foo' } ); await user.click(someButton); 要素を操作する際は fireEvent() ではなく @testing-library/user-event を使用することで、より信頼性が改善されることが期待できます。 バグの修正時にはリグレッションテストを記述する 長年、プロダクトの開発や運用を続けていると、どうしてもバグ修正などが積み重なった結果、意図の不明瞭なコードなどができてしまいがちです。 しかし、AI コーディングエージェントはそれらの背景の情報を持っておらず、意図せずそういった不明瞭なコードを書き換えてしまう可能性が考えられます。 そういったケースへの対策や、バグの再発防止などのために、リグレッションテストを記述するとより安心して運用が行いやすくなると思います。 具体的には、あるバグが発見された際には、まずそのバグを再現するためのテストコード (リグレッションテスト) を記述すると理想的です。 例えば、与えられた数値の合計値を求める sum() を例に考えてみます。 const sum = (... numbers ) => numbers. reduce (( x , y ) => x + y); この sum() はある特定の状況下で例外が発生してしまうことが発覚しました。具体的には引数が一つも与えられていない場合に例外が起きてしまいます。この場合、 0 が返却されると望ましそうです。 sum() を修正する前にまずはバグを再現するテストコードを記述します。 test ( 'Regression test for issue #1234' , () => { expect (sum()).toBe( 0 ); } ); この状況でこのテストコードを実行してみます。失敗した場合、意図通りにテストコードによってバグを再現できています。 それでは実際にこのバグを修正してみます。 const sum = (... numbers ) => numbers. reduce (( x , y ) => x + y, 0 ); 修正後、再度テストコードを実行し、今度はテストが成功することを確認します。 こうすることにより、追加したリグレッションテストコードがバグの再発防止のための仕組みとして機能してくれます。もし AI コーディングエージェントによって本来の意図を損なう形でコード変更が行われてしまった際も、CI でテストコードを自動実行しておけば、事前に気づくことができます。 今回は解説のために単純な関数を使用した例で紹介しましたが、実際には React Testing Library などを活用することで、UI のバグなどに関するリグレッションテストを記述することも可能です。 まとめ ブラックボックステストを意識する テストダブルや Testing Library などを例に、信頼性の高いテストコードを記述するための方法について紹介しました。本番コードと同様にテストコードにおいても実装の詳細に強く依存してしまうと、テストコードの信頼性が低下してしまうことがあります。できる限りブラックボックステストとして記述することを意識すると良いと思います。 より安定したものに依存する テストコードにおいても本番コードと同様により安定したものに依存することを意識すると、信頼性を高めることが期待できます。 具体的には、サードパーティーライブラリーは「変わりやすいもの」の最たる例ではないかと思います。サードパーティーライブラリーに依存したテストコードを記述する際は、サードパーティーライブラリーが提供する API に対して jest.spyOn() や jest.mock() などを使用してスタブするよりも、以下の方法などを検討すると良いでしょう。 サードパーティーライブラリーが提供する API をスタブせずにインテグレーションテストを記述する サードパーティーライブラリーによって達成したい目的に基づいて interface を定義し、テスト対象コードをサードパーティーライブラリーではなくその interface に依存させる (これによってフェイクオブジェクトを注入したり、サードパーティーライブラリーの API に破壊的変更が加わった際のテストコードへの影響を避けることが期待できます) 終わりに 本記事で紹介した内容が少しでも役に立てば幸いです。この記事で度々紹介した Googleのソフトウェアエンジニアリング はオススメなので、今回紹介したような内容などに興味があればぜひ参照ください! また、明日も Advent Calendar の記事を公開予定のため、もしご興味があればぜひご覧ください! 参考文献・出典 書籍 『Googleのソフトウェアエンジニアリング ―持続可能なプログラミングを支える技術、文化、プロセス』 Wikipedia "Test double"
こんにちは、ホセです! 日本語と英語話せるエンジニアです。PyCon JPには初めて参加しました! 本件はPyCon JP って何か、今年の気になったトーク、開発スプリントのテーマと個人感想を書かせていただきます! 今年のテーマ PyConは、Pythonに関する世界最大級のカンファレンスであり、日本では2011年1月末には、品川シーサイドで日本初のPyConである「PyCon mini JP」が開催されて以来、これまで14年間にわたり東京で開催されてきました。 今年は「多様性」をテーマに、初めて広島で行われました! 2025.pycon.jp 日程: 2025年9月26日(金)〜27日(土) 会場: 広島国際会議場 (広島県広島市中区中島町1−5) 主催: 一般社団法人 PyCon JP Association トーク [招待講演】PEP 750 の共同著者 青野高大 氏による Python3.14の新機能の紹介 github.com さすがの招待講演。新たな T-strings の開発者の一人から説明を聞くのが貴重な経験でした。リリースは楽しみですね! Financial analysis in Python 2025.pycon.jp Nicholas氏は用語の説明はうまくてわかりやすかったです!ファイナンスが興味ある方におすすめ! Pythonスレッドとは結局何なのか? ~CPython実装から見るNoGIL時代の変化~ 本日の発表資料です。PyConにてThreadについてのテーマで お話させていただきました。 とてもたくさんの方に聞いて頂きまして大変嬉しかったです。講演の後も10人の質問行列ができるなど、発表してよかったと感じました。 https://t.co/50ehUcvNbD #pyconjp_4 #pyconjp2025 — Shugo Manabe: Recustomer CTO (@curekoshimizu) 2025年9月26日 Manabeさんの深い知識に圧倒され続け、CPythonの歴史に関するお話もとても面白い講義でした。 Weaponizing MCP Servers: Production-Ready AI Agent Infrastructure with Python 2025.pycon.jp 本番に動いているMCPってどんな問題が発生するのかとMicrosoft エンジニアの方からの発表は興味深かったです。 Vijayさんもとてもフレンドリー、レクチャの後いろいろ話させてもらいました! 【Day 1 Keynote】Sebastián Ramírez 氏 Behind the scenes of FastAPI and friends for developers and builders 2025.pycon.jp Sebastian氏は前からYouTubeで発表をフォローしていて、実際にお会いできて一番驚いたのは「お気遣い」でした。 今回は日本での初めての発表ということもあり、分かりやすい英語で話してくれたことにオーディエンスは感謝していました。 発表も素晴らしくてメッセージも簡単でも深い:「Solve a problem」。問題があったら、解決を考えて解決してみろと。本当に解決できたら、いいプロダクトになると。エンジニアって結局プログラムを作って誰かに使って欲しいから、開発する前にちゃんとプロブレムを考えないと時間の無駄になります。 その他 Apache Arrowの Contributorからの説明:  https://2025.pycon.jp/en/timetable/talk/Q7YYNZ Pythonでモバイルアプリを作るの? https://2025.pycon.jp/en/timetable/talk/S8DUBR Django Ninja入門! https://2025.pycon.jp/en/timetable/talk/ZNSAA9 帰宅してからすぐに使わせてもらったpytestの10個のこつ  https://2025.pycon.jp/en/timetable/talk/ZFYREY 開発スプリント スプリントの日はチームを組んでコードを書くのがテーマです。今回は Tachibanaさん のチームに参加して Streamlit へのPRを投げました! github.com オフィシャルパーティー 会議間の枠は15分ぐらいしかないのであまり他の参加者と話す時間が少ないため、パーティーの時は最高でした。広島フードも美味しかったし、いろんな方と話して本当に良かった。 スペイン語圏同士でもあって、Sebastian氏ともいろいろ話しました! 感想 「多様性」というテーマは本当にふさわしいと感じました。外国人参加者は50人以上いたそうで、英語のライブ翻訳もきちんと用意されており、スタッフの皆さんも英語で案内していました。私の場合は日本語で話すことが多かったのですが、英語圏の方と話したときに「日本の温かさを感じた」と言ってもらえたのが印象的でした。 そして、来年の会場も広島に!プロポサールを準備して楽しみにしてます! まとめ 今年のRevcomm エンジニア参加は7名、その中で松土さんが登壇して 陶山さん がスタッフでした。 みんなと面白い時間を過ごして良かったです。
RevCommで主に音声認識・音声感情認識・話者分離の研究開発を担当している石塚です。 本記事では、 日本音響学会2025年秋季研究発表会 で発表した「Room Simulatorを用いたデータ拡張によるNeural Speaker Diarizationモデルの実環境適応」の研究について、解説します。 石塚賢吉(いしづか けんきち) プリンシパルリサーチエンジニア。筑波大学大学院博士後期課程卒業。博士(工学)。日本HP株式会社にて通信事業者向けのシステム開発、株式会社ドワンゴで全文検索システムの開発などに従事。2019年12月、株式会社RevComm入社。音声認識、音声感情認識、全文検索システムの研究開発を行なっている。 → 過去記事一覧 背景:「いつ、誰が話しているか」の推定は難しい AI技術の進歩は目覚ましく、その恩恵はビジネスシーンにも広がっています。特に、会議の音声を自動でテキスト化するアプリケーションは、議事録作成の効率化や情報共有の促進に大きく貢献しており、導入する企業が増加傾向にあります。これらのアプリケーションの中核を担う技術の一つが、Speaker Diarization (SD) です。これは、音声データの中から「いつ、誰が話しているか」を推定する技術であり、会議の参加者を識別し、発言内容を整理するために不可欠です。 しかし、現実は理想通りとは限りません。会議室の環境は千差万別であり、アプリケーションの利用環境によっては、音声品質が大きく劣化する可能性があります。例えば、話者とマイクの距離が遠い場合、音声が小さくなり、周囲の雑音に埋もれてしまうことがあります。また、室内の残響が大きいと、音声の輪郭がぼやけて聞き取りにくくなります。これらの要因が複合的に作用することで、SDの精度が低下し、結果として、テキスト化された議事録の信頼性が損なわれてしまうという課題がありました。 課題:SDモデルの学習には「大量の教師データ」が必要…でも、作成コストが高い SDモデルを特定の環境、例えば「カフェでの会話をスマートフォンで録音する」のような環境でうまく機能させるためには、その環境で録音された大量の音声データでSDモデルをファインチューンすることが有効です。 しかし、この学習データを作成するには、録音された音声を聞きながら「この区間はAさん、次の区間はBさん…」と、手でラベル付け(アノテーション)することとなり、膨大な時間とコストがかかるという問題がありました 。 解決策:「Room Simulator」でリアルな学習データを人工的に作り出す そこで注目したのが、 「Room Simulator」 という技術です。これは、部屋の広さや反響、マイクと話者の位置関係などをコンピュータ上で再現し、まるでその環境で録音したかのような音声をシミュレートできる技術です。 RevCommは、話者ごとに録音されたビデオ会議の音声データを大量に保有しています。本研究では、話者ごとに録音されたビデオ会議の音声データを元として、 PyRoomAcoustics という、 鏡像法に基づくRoom Simulator で対象の環境を想定した音声の学習データを大量に生成することを考えます。この手法の利点は以下の通りです。 大量の自然な会話データが使える : 合成音声などでなく、実際のビデオ会議の発話録音なので、会話の内容が自然です ラベル付けが不要 : 最初から話者ごとに音声が分かれているため、面倒な手作業のアノテーションが必要ありません 低コストで大量生産 : コンピュータ上でシミュレーションするため、大量の学習データを生成できます 評価実験: データセット構築 提案手法の有効性を確かめるため、まず対面会議をスマートフォンで録音する環境を想定した3種類のデータセットを構築しました。 1. Computer Simulation Dataset (CSD) - 人工的な学習データ MiiTelで行われたビデオ会議(2〜7名)の音声をもとに、会議室とカフェで行われる対面会議をスマートフォンで録音する環境をRoom Simulatorでシミュレートしながら生成した音声のデータセットです。 シミュレートした環境 : - 会議室 : 8畳の部屋 (4m×5m×2.5m) を想定し、反響や音の減衰を再現しました。話者の音源は会議の参加人数に応じて図1の丸の記号に付与された番号の順番で配置します。 仮想会議室の音源とマイクの配置の例 カフェ : 広い空間 (20m×20m×4m) を想定し、Musanデータセットの人混みの環境音や音楽をミックスして、より雑音の多い環境を再現しました。音源とマイクの位置関係は会議室と同じです。 このデータセットの長所と短所: 長所: 高速(AWS EC2 r5.2xlargeインスタンスで音声時間の0.0045倍の処理時間)かつ大量にデータを生成可能です 短所: 現実世界の複雑な音響特性を完全には反映できません 2. Human Annotation Dataset (HAD) - 人間がアノテーションした評価データ 対面会議(30件, 24h)をスマートフォンで録音した音声について、人間が手作業で「いつ、誰が話しているか」をアノテーションして構築した評価用データセットです。これは、最も現実に近い評価用データです。 このデータセットの長所と短所: 長所 : 現実の音響特性を最も忠実に反映できます 短所 : 作成に 音声の長さの約2倍から8倍 もの時間がかかり、コストが非常に大きいです 3. Loudspeaker Simulation Dataset (LSD) - 物理的に再現した評価データ 話者ごとに録音されたビデオ会議(125件, 112h)の音声を、図1の構成で複数のスピーカー装置で再生し、スマートフォンで録音することで、会議室での対面会議をスマートフォンで録音する環境を物理的にシミュレーションしながら構築したデータセットです。この方法は、 LibriCSS データセットの作り方を参考にしています。 このデータセットの長所と短所: 長所 : 低コストでCSDより現実的な録音環境を反映できます 短所 : スピーカーから再生される音声と人間の声道から発せられる音声との違いは反映されず、また録音に実時間分の時間がかかります 評価実験: SDモデルのファインチューニング 次に、PyAnnote Audio 3.1というSDツールキットの事前学習モデルをベースライン (モデルP) として、下記の4種類のファインチューン版のモデル(モデルA〜D)を構築し、精度の比較を行います。 モデルP: PyAnnote Audio 3.1の事前学習モデルです モデルA: モデルPを元のビデオ会議音声(386件, 309h)でファインチューン (FT) したモデルです モデルB: モデルPをCSD(386×2[会議室とカフェ]件, 647h)でFTしたモデル。386件の会議はモデルAと同じものです モデルC: モデルPをより大規模なCSD(1,516×2件, 2,536h)でFTしたモデルです モデルD: モデルPをより大規模なCSD(1,516×2件, 2,536h)と、中国語のSDデータセット AISHELL-4 (168件, 93h)の学習セットでFTしたモデルです 上記のSDモデルP,A~Dを用いて、学習に使用されていないLSDとHAD、および中国語のSDデータセットである AISHELL-4 のテストセットをSDした時の Diarization Error Rate (DER) を下記の図に示します *1 。Diarization Error Rateは、値が小さいほど精度が良いことを示します。 各データセットに対するDiarization Error Rate(DER、エラーバーは標準偏差) ご覧の通り、Room Simulatorで生成したデータで学習した モデルC と モデルD は、既存モデル(モデルP)や、シミュレーションなしのデータで学習したモデル(モデルA)よりも、HADとLSDでのエラー率が大幅に改善していることが分かります。 また、モデルDの結果を見ると、ターゲット環境のデータ (CSD) だけでなく、中国語のデータセットなども追加で学習させることで、特定環境への適応(LSDやHADでの高精度)と、他の環境への対応力(汎化性能)を両立した、より安定して堅牢(ロバスト)なモデルになることが示唆されました。 まとめ 本研究により、Room Simulatorという技術を活用することで、「いつ、誰が話しているか」を特定するAIモデルを、低コストかつ効率的に特定の実環境へ適応させられることがわかりました。 今回は、PyRoomAcousticsという、計算量の小さな鏡像法をベースとするRoom Simulatorを用いましたが、より精密な波動音響解析などのシミュレーション手法を用いた場合に、どのように結果が変わるのかにも興味が湧きます。 今後も、SDの精度改善に取り組んでいきます。 *1 : こちらのDERは時間の誤差許容量[ms] のcollar を500ms とし、オーバーラップを無視する設定で計算されていることにご注意ください。
イベント概要 https://2025.pycon.jp/ja 公式サイトから引用 2025年、Pythonカンファレンス「PyCon JP」は、「あつまれPythonのピース」をテーマに、広島で開催されます。初の地方開催となる今回は、会場が平和記念公園内の国際会議場。平和を願い、発信し続けてきたこの地で、Pythonが大切にしてきた「多様性」や「オープンさ」を、あらためて感じられるイベントになるでしょう。 関東や遠方の方も、少し足を伸ばして、凛とした空気の平和公園で技術トークを楽しむ時間には、きっと特別な価値があります。 全国から集まる仲間たちと、コードの話も、これからの社会の話もちょっぴりしてみませんか?お好み焼きも、牡蠣も待っています。9月、広島でお会いしましょう! 日程: 2025年9月26日(金)〜27日(土) 会場: 広島国際会議場 (広島県広島市中区中島町1−5) 主催: 一般社団法人 PyCon JP Association チケット申し込みはこちらから pyconjp.connpass.com 登壇情報 Pythonだけでつながるあなたのアイディア、フロントエンドもPythonで Pythonユーザーが作成したスクリプトやデータ分析、機械学習モデルなどの成果物を「見せる」「使ってもらう」には、アプリ化というハードルがあります。 本セッションでは、Pythonユーザーが自分のコードをすぐWebで共有できるようになるための手法として、PythonだけでGUIを構築できるWebアプリフレームワーク「Flet」の仕組みと使い方を紹介します。 Fletは、裏側でPyodideを使ってWebAssemblyを活用することで、従来のWeb開発の壁を取り払ってくれています。まずはこのフレームワークを通してPiodideとWASMを深掘ります。 次に実際に使えるサンプルを通じて、「見せられるPythonアプリ」を手軽に作る方法をデモを交えて解説します。 Pythonだけで、誰かとつながれる そんな開発体験を提案します。 日時: 2025年9月26日(金) 15:00 - 15:30 登壇者: Shintaro Matsudo リンク: https://2025.pycon.jp/ja/timetable/talk/S8DUBR
はじめに 基本知識・用語の確認 1. なぜ移行をしたのか 1.1 テストの実行時間がとにかく長い 1.2 Nuxt3移行に伴うビルド&テスト環境の統一 1.3 テストコードの見直しが必要 2. Vitest を選んだ理由 2.1 手軽 2.2 フロントエンドロードマップの Testing に変化 2.3 Jest との互換性も問題なし 3. 実装 3.1 Vitest 1. Vitest をインストール 2. Vitest の設定ファイルを追加 3. Jest で記述されている箇所を、vi に変更する 4. テストで使用する環境変数の読み込み 5. エラーの解消 6. テストを成功させる 7. Jest で使っていた記述を削除する 4. 結果 まとめ はじめに はじめまして。フロントエンドエンジニアをしている伊藤と申します。 私はプロダクトの動作を保証するものとして、テストは欠かせないものだと思っています。 私が所属するチームのプロダクトでも以下のテストを行なっています。 テストフレームワークを利用したユニット/コンポーネントテスト クラウドサービスを利用したE2Eテスト QA その中で、私が一番関わりが深いのは、テストフレームワークです。現在のチームに参画した時から、チームの JavaScript テストフレームワークは Jest が使われており、長年苦楽を共にしてきました。ただ、そんな親友の Jest ともついにお別れする時がきてしまいました。 本記事では、 経緯などを踏まえて、Jest → Vitest への移行について紹介していきます。 基本知識・用語の確認 Jest JavaScript コードの品質と信頼性を確保するために、テストの作成・実行・検証・分析を一つで完結できるように設計された便利なツール Vite モダンな JavaScript/TypeScript プロジェクトの開発とビルドを、信じられないほど速く、シンプルに、そして効率的に行うための次世代ツール Vitest Vite の高速性を活かし、Jest と高い互換性を持つことで、開発者体験とテスト効率を大幅に向上させるJavaScript/TypeScript向けの新しいテスティングフレームワーク 1. なぜ移行をしたのか 1.1 テストの実行時間がとにかく長い 所属するチームでは、Git のワークフローを使用しています。特定のブランチに向けて PR が作成されるとワークフローが開始されます。そのワークフロー内で、Jest で記述されたテストを実行しています。 ただ、そのテストの完了までがとにかく長いです。平均して15分ほどかかっていました。DevUX の観点からすると、とんでもない悪影響です。 開発者の満足度低下 生産性やイテレーション速度の低下 バグ発見の遅れ コード品質への影響 1.2 Nuxt3移行に伴うビルド&テスト環境の統一 Vue2/Nuxt2 から Vue3/Nuxt3 へ移行した際、ビルドツールについても変更を行いました。 Nuxt3 では標準で Vite が採用されているため、Webpack から Vite へ切り替えています。 ただし、テスト環境は従来どおり Jest(Webpackベース)を利用しており、本番環境との間に差異が残っていました。 そこで、より実行環境に近い形でテストを行えるように、Nuxt3と親和性の高いVitestへ移行が必要でした。 上記を解決することで、環境の一貫性が保たれ、テストの信頼性も向上すると考えました。 1.3 テストコードの見直しが必要 私が参画した後もテストコードは増えていく一方でした。 どんなテスト内容か、最適なテストができているかなどは年々不透明になっていました。 潜在的な課題とはなっていましたが、テストコードを見直すことは、他の新規開発を優先してしまったため、後回しになっていました。 2. Vitest を選んだ理由 2.1 手軽 Vitest は Vite がないプロジェクトでも活用することができます。 手軽に勧められる点がポイントです。 2.2 フロントエンドロードマップの Testing に変化 フロントエンドエンジニアが学ぶべきロードマップの中に、Testing という項目があります。 おそらく Jest を勧めていた箇所が、Vitest に変わっていました。 https://roadmap.sh/frontend 2.3 Jest との互換性も問題なし 下記の表は、簡単に Jest と Vitest を比較したものです。 比較をして、Vitest への移行をしても問題ないと思いました。 (独自で調べた内容で作成したため、誤っている場合もあります) 3. 実装 3.1 Vitest 1. Vitest をインストール npm install -D vitest 2. Vitest の設定ファイルを追加 Vite を使っているプロジェクトなら、vite.config.ts に設定を記述 使っていない場合は、vitest.config.ts を新規作成 (下記は、弊社の例です。) ``` /// <reference types="vitest" /> import path from 'path'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import { config } from 'dotenv'; // .env.test を明示的に読み込む config({ path: path.resolve(__dirname, 'src/.env.test') }); export default defineConfig(() => { return { plugins: [vue()], resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js', '@@': `${__dirname}`, '@': `${__dirname}/src`, }, }, test: { setupFiles: ['src/tests/setup.ts'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], exclude: ['node_modules'], // テスト対象から除外するパターン environment: 'jsdom', globals: true, environmentOptions: { jsdom: { url: 'http://localhost/', // 必要であれば設定 }, }, cache: true, }, }; }); ``` 3. Jest で記述されている箇所を、vi に変更する 一括変換でもいいですし、AI エージェントに任せるのも手です。 4. テストで使用する環境変数の読み込み テスト用の環境変数を用意している場合は、vite.config.ts にその旨を記載します。 // .env.test を明示的に読み込む config({ path: path.resolve(__dirname, 'src/.env.test') }); 5. エラーの解消 mock を使用している箇所でエラーが出たので解消しました。 (エラー内容:mock を使うなら、一番最初の行に記述してください。) Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock 6. テストを成功させる 7. Jest で使っていた記述を削除する jest.config.ts など、Jest 関連のものを削除します。 4. 結果 テストの実行時間の大幅な短縮ができました。 Vitest への移行と、既存の不要テストの削除による成果だと思います。 以前まで、15分かかっていたテストが5分で終了するようになりました。 おかげでリリースまでの時間も縮小することができました。 もしかすると、Vitest への移行だけでここまで縮小することはなかったかもしれませんが、 プラスの成果はあったと思います。 まとめ Jest → Vitest 移行を行なったことで一番の収穫は、既存のテストを見直す機会を得たことです。 どうしても新規開発をしていると、テストコードにまで気が回らなくなってしまいます。 その結果、放置されてしまっていて、テスト内容が適正かなどの判断が後回しになっていました。 ただ、今回の移行でテストコードの見直しをするきっかけをもらったと思います。 AI を使った開発により、テストコードでテストしたいことが実現しやすくなりました。 本記事が、皆様の関わっているプロジェクトのテストコードを見直すきっかけになれば幸いです。 これからもより良いテストコードを目指していきます。
はじめに こんにちは、RevCommでエンジニアをしている加藤と申します。先日AWS Unicorn Day Tokyo 2025に参加してきました!今回はその中で特に印象に残ったセッションをお話ししたいと思います! 会場の様子/全体の雰囲気 会場は原宿駅からほど近い東郷記念館というところでした。オフィスは渋谷にあるので朝早めにオフィスで仕事をしてから会場まで歩くことにしました。暑かったですが、青山通りを抜けて行く感じは気持ちが良かったです。 東郷記念館 外から バルコニーが綺麗 今年は AWS Summit Tokyo(幕張メッセ)や Google Cloud のイベント(Google 渋谷オフィス)にも参加しましたが、今回の会場である東郷記念館はまた違った印象でした。結婚式場としても使われるだけあって華やかで、池には鮮やかな鯉が泳ぎ、外国人観光客が写真を撮る姿も多く見られました。ちょっとした観光地のような雰囲気の中でのイベントでした。 参加セッション & 学び 今回は参加セッションの中でも特に印象的だった3つについてご紹介します。 1. “AIエージェント時代の創業” — コネクシー株式会社 コネクシー社のSlackからPRまでのワークフロー 設立わずか2025年2月のコネクシー社による、「エージェントネイティブ」な開発体制の構築事例が紹介されました。特に意識されていたのは「モバイルから誰でも開発できること」のようです。エンジニアだけでなく CEO も Slack 経由で Devin にメッセージを送り、PR まで作ってもらっているそうです。 面白かったのは、Devin への指示がどんどん短くなっていったという話です。最初は「XX という機能を作りたいです。ヘッダーは YY に配置して、ボタンは ZZ に……」と詳細に指示していたのが、日が経つにつれ「XX を追加してください」といったシンプルな命令になっていったそうです。 人間の側がAI が文脈(コンテキスト)を理解していると自然に感じてしまう心理的変化が興味深く、コンテキストを確実に伝えるためにはDevin AI の Knowledge や Playbook の機能をきちんと整備する必要があると感じました。 2. “ID 管理基盤内製化の意思決定” — 株式会社カミナシ カミナシ社によるID基盤内製化 概略構成図 一般的には IDaaS が選ばれるケースが多い中、カミナシ社はあえて ID 管理基盤の内製を選んだそうです。直前のセッション「自社ブランドの共通 ID・シングルサインオンの設計と実装 - Amazon Cognito 設計パターン -」で Cognito の全体像を紹介した後に、「Cognito を使わない」という選択が語られたのが印象的でした。AWS的にはCognitoを使わなくてもAWSの別サービスを使っているからアリなんでしょうね。 もちろん内製には RFC 準拠の開発やドメイン知識が求められ、難易度は高いものの、今回の意思決定では「役員の迅速なトップダウン判断」が大きなポイントになったそうです。ID 基盤の移行は数年単位の大規模プロジェクトになることも多いため、将来的なプロダクトビジョンに基づく判断が有効だったのだと思います。 3. “AI エージェントとはそもそも何か? - 技術背景から Amazon Bedrock AgentCore での実装まで -” — AWS Japan speakerdeck.com 最後に聴講したのは、AWS が提供する Agent 構築環境に関するセッションでした。Agent構築においてAWSが重視しているのは「差別化に繋がらない重労働(Undifferentiated Heavy Lifting)」のようです。後で調べましたが、これはAWSではよく出る言葉で「サーバーのラッキング、積み上げ、電力供給などのデータセンターの手間のかかる運用作業」が Well-Architected フレームワーク に書いてありました。この考え方は、AWS Lambdaにも引き継がれ、そして現在は、エージェントの構築にもこの考え方が活かされているようです。 特に認証、ブラウザ利用、メモリ保存といったどのエージェントでも必須になるような機能をAWS側で提供してくれることは非常にありがたいなと思いました。 まとめ AWS Unicorn Day Tokyo2025に参加してきました。技術的な話だけでなく、事業戦略や意思決定の考え方まで踏み込んだイベントでした。 やはりAWSはエージェントという新しい機能をどうAWSで実行するかという内容を含んでいました。スタートアップに参加しやすいようなシステム作り、コミュニティづくりを目指しているように感じました。 私も業務でStrand AgentsやBedrock AgentCoreを触ったりしています。今回得られた知見を活かし、自社プロダクトにどのように適用できるかをさらに検討していきたいと思います!