TECH PLAY

株式会社RevComm

株式会社RevComm の技術ブログ

171

※この記事はリサーチエンジニアのJennifer Santosoによる記事『 IPSJ 266th Natural Language Processing & 158th Speech Language Information Processing Joint Research Presentation Meeting - Presentation and Participation Report 』を翻訳したものです。 はじめに RevComm Researchのサントソです。12月中旬に開催された研究会に参加し、日頃の研究成果を登壇してきました。今回は「Generative Error Correction for Product Names with Phonemic and Lexical Constraints」というテーマで、最新の成果を報告しました。多くの専門家と議論を交わし、非常に有意義な時間を過ごすことができました 。 学会の概要 https://www.ipsj.or.jp/kenkyukai/event/nl266slp158.html 本学会は、国内最大級のIT団体である情報処理学会(IPSJ)が主催しています。今回は、言葉をコンピュータで扱う「自然言語処理(NL)」と、音声の解析・生成を担う「音声言語処理(SLP)」の2つの研究会が合同で開催されました。これらは年数回、分野を横断した議論の場として共催されており、口頭発表を中心に最新の研究報告が行われます。 開催期間 2025年12月15日〜17日 開催地 京都テルサ(京都市)( https://www.kyoto-terrsa.or.jp/ ) 対象分野 主に自然言語処理(NL)および音声言語処理(SLP)を対象としています。特に、大規模言語モデル(LLM)を用いた音声認識の高度化や、ドメイン特化型の言語処理が大きなテーマとなっていました 。 発表件数 口頭発表: 28件 招待講演: 2件 国際学会参加報告: 2件(INTERSPEECH 2025とACL2025) 計32件の非常に濃密なセッションが組まれました。 学会の概要と発表総数 参加人数 およそ200人(オンライン参加者が含む) 会場の様子 学会の看板 招待講演 登壇報告 ビジネス会話における音声認識(ASR)の精度向上、特に「商品名」の誤認識をLLMでいかに修正するかという研究を発表しました 。研究会にある発表はほとんど日本語で行われましたが、今回の発表・質疑を英語で行いました。 題目 Generative Error Correction for Product Names with Phonemic and Lexical Constraints(音韻的・語彙的制約を用いた商品名の生成的な誤り訂正) 背景・モチベーション 本研究では、ASRシステムがしばしば苦手とする製品名や型番といった用語を、LLMベースのフレームワークを用いて後処理・修正する手法を提案しました。具体的には、音声から抽出した音声情報を製品辞書を用いてLLMプロンプトに統合します。これにより、コアASRモデルを再学習することなく、語彙外(Out-of-vocabulary; OOV)用語を正確に修正することが可能になります。 英語と日本語の両方のデータセットを用いた実験結果では、製品名認識精度(Accuracy)が大幅に向上しました。この開発により、専門用語が頻繁に使用されるビジネス環境における自動議事録の品質が大幅に向上すると期待されます。 本研究の主な貢献 本研究の主なポイントは以下の2点です: データセット構築プロトコルの提供: 商品名や型番に特化した、会話音声データの構築手法を提案しました 。 図1.データセット構築プロトコル 新たな誤り訂正フレームワークの提案: ASRを再学習することなく、音素情報(Phoneme)と商品辞書(Lexical)、そして会話の文脈を融合させて、ゼロショットで誤りを訂正する手法(Generative Error Correction; GEC)を開発しました 。 実験の結果、英語・日本語の両データセットにおいて、商品名の認識精度が大幅に向上することを確認しました 。 図2.提案した誤り訂正フレームワーク(GEC)手法の流れ いただいた質問 発表後には、20分間の発表に対して5分間の活発な質疑応答が行われました。 Q1. ファジーマッチングの具体的な閾値とその理由は? 回答: 0.6から0.9の範囲を0.05刻みで検証し、最適な値を決定しました 。これは、検索の成功率と、LLMに渡す情報のノイズを抑える精度のバランスを最適化するためです 。 Q2. データセットの話者数が少ないようだが、テスト設定として一般的か? 回答: 本研究では、英語・日本語それぞれ男女2名ずつのリファレンスボイスを使用して合成データを作成しました 。この分野では話者数よりも商品名の多様性が重視されます。認識結果のばらつきが十分に見られるよう設定しているため、評価設定として一般的だと考えています。 Q3. 会話シーケンスの代わりに、要約を文脈として利用できるか? 回答: 本研究の目的は、商品名の認識精度を高めることでビジネスにおけるインサイトを明確にすることにあります。正確な要約を作成するためには、その前提となる商品名の正しさが不可欠です。もし誤認識が含まれたまま要約を行ってしまうと、重要なキーワードが抜け落ちたり、内容の質が低下したりする恐れがあります。その結果、LLMが本来注目すべき商品名を認識できず、修正能力が十分に発揮されない可能性があるため、要約ではなく生の会話シーケンスを直接文脈として活用する手法が適していると考えています 。 気になる発表 招待講演1:言語モデルのマルチモーダル言語理解能力 LLMが言語や視覚情報をどの程度「理解」しているのかを4つの側面から検証した刺激的な講演でした。逐次通訳への応用では、プロンプト制御によりデータ不足を克服し、従来手法を上回る精度を実現しています。一方、視覚情報を用いた調音推測などの実験を通じ、絶対的な視覚理解には依然として課題があるという知見が示されました。 招待講演 2: 音声コーパスの過去・現在・未来 本講演では、AI時代における音声データに関する提言が提示されました。日本語およびアジア言語のコーパス拡充に向けた国際協力の重要性、そしてLLM開発のためのデータの「量」と厳密な実験のためのデータの「質」のバランスを取る必要性が強調されました。また、会話音声、特に顔データを含む会話音声は、厳格な倫理的管理と同意取得を必要とするセンシティブな個人情報であることも強調されました。 国際学会参加報告 INTERSPEECH 2025: ASRとLLM、そして自己教師学習(Self-supervised learning; SSL)の融合が主流のトレンドであると報告されました。音声言語モデル(Speech Language Models)の台頭により、この分野はパラ言語理解、全二重対話、ゼロショットTTSへと拡大しています。 ACL 2025: ARR(ACLローリングレビュー)システムに関する知見が共有されました。議論は、改善のための再提出の重要性と、堅牢な反論の必要性に焦点が当てられました。また、混雑した会場で注目を集めるための、視覚的にミニマルでありながらインパクトのあるポスターを作成するためのヒントも提供されました。 まとめ 12月の研究会にて、私たちの最新の研究成果を共有できたことを光栄に思います。約200人の参加者が集まった会場は熱気に包まれ、招待講演や国際学会の報告を含め、LLMを用いた音声処理の可能性について活発な議論が交わされていました。 専門家の方々と直接対話することで、非常に有意義なフィードバックをいただくことができました。チーム内で議論を尽くして準備した発表でしたが、こうした対面での議論を通じて、新たな課題や今後の研究の方向性がより明確になったと感じています。 RevComm Researchでは、今後も継続的な研究と発表を通じて、コミュニケーションを科学する挑戦を続けていきます。現在、私たちと共にこれらの刺激的な技術課題に取り組んでいただける仲間を募集しています。
アバター
Introduction This is Santoso from RevComm Research. I participated in a research meeting held in mid-December and presented our ongoing research results. This time, RevComm Research reported on our latest findings under the theme "Generative Error Correction for Product Names with Phonemic and Lexical Constraints." I had very meaningful discussions with many experts and spent a very fruitful time. Conference Overview https://www.ipsj.or.jp/kenkyukai/event/nl266slp158.html The conference was hosted by the Information Processing Society of Japan (IPSJ), the largest IT-related academic organization in Japan. This specific event was a joint meeting of two special interest groups: Natural Language (NL) and Speech and Language Processing (SLP). These groups hold joint meetings several times a year to facilitate interdisciplinary dialogue, primarily through oral research presentations. The details of the conference are posted in this page. Conference Period December 15-17, 2025 Venue Kyoto Terrsa (Kyoto City) ( https://www.kyoto-terrsa.or.jp/ ) Target Fields Mainly targeting Natural Language Processing (NL) and Speech Language Processing (SLP). In particular, the advancement of speech recognition using Large Language Models (LLM) and domain-specific language processing were major themes. Number of Presentations Oral Presentations: 28 Invited Lectures: 2 International Conference Participation Reports: 2 (INTERSPEECH 2025 and ACL2025) In total, there are 32 presentations spanning over 13 sessions. Presentation statistics Number of Participants Approximately 200 people (including online participants) The atmosphere of the presentation venue Welcome to conference Invited lecture session About Our Presentation Title Generative Error Correction for Product Names with Phonemic and Lexical Constraints Background and Motivation In this study, we proposed a method to post-process and correct product names and model numbers, which are terms that ASR systems often struggle with, using an LLM-based framework. Specifically, we integrate phonetic information extracted from the audio with a product dictionary into the LLM prompt. This allows for accurate correction of out-of-vocabulary (OOV) terms without the need to retrain the core ASR model. Experimental results on both English and Japanese datasets showed significant improvements in product name recognition accuracy. This development is expected to greatly enhance the quality of automated meeting minutes in business settings where specialized terminology is frequent. Our Approach To solve the problem, we discussed two main points: Provision of a dataset construction protocol: We proposed a method for constructing conversational speech data specialized for product names and model numbers. Fig. 1. Dataset construction protocol Proposal of a new error correction framework: We developed a zero-shot error correction method (GEC) that combines phonemic information (Phoneme), a product dictionary (Lexical), and conversational context, without retraining the ASR. Fig. 2. Flow of the generative error correction (GEC) Experimental results confirmed that the accuracy of product name recognition improved significantly in both English and Japanese datasets. Q&A Highlights The 20-minute presentation was followed by a 5-minute lively Q&A session. Q1: What is the exact threshold for the fuzzy matching and why? A: We empirically tested thresholds between 0.6 and 0.9 with 0.05 intervals. This was determined during validation to balance retrieval success (recall) against the cleanliness of the context (precision) provided to the LLM. Q2: The speaker number for the dataset is too small. Is it common setting for testing? A: For this study, we used text-to-speech (TTS) to create synthetic data with two male and two female voices for each language. In this domain, the diversity of product names is often more critical than the number of speakers. We believe this is a valid testing setting as it ensures enough variation in how product names are recognized. Q3: Can a summary be used as context instead of the full conversation sequence? A: The goal of our research is to clarify business insights by improving product name recognition; accurate summaries actually depend on correct product names. If a summary is generated from misrecognized transcripts, key information might be lost or the quality degraded. This could prevent the LLM from focusing on the correct product identifiers, thereby reducing its correction capability. Thus, using the raw conversation sequence as direct context is more appropriate. Notable Sessions Invited Lecture 1: Multimodal Language Understanding in LLM This inspiring lecture examined the degree to which LLMs "understand" linguistic and visual information from four perspectives. In its application to consecutive interpretation, prompt control overcomes data shortages and achieves accuracy surpassing conventional methods. However, experiments such as articulatory estimation using visual information demonstrated that absolute visual understanding remains a challenge. Invited Lecture 2: The Past, Present, and Future of Speech Corpora This lecture presented recommendations regarding speech data in the AI era. It emphasized the importance of international collaboration to expand corpora for Japanese and Asian languages, and the need to balance the "quantity" of data for LLM development with the "quality" of data for rigorous experiments. It also emphasized that conversational speech, especially speech containing facial data, is sensitive personal information that requires strict ethical management and consent. International Conference Reports INTERSPEECH 2025: It was reported that the fusion of ASR, LLM, and self-supervised learning (SSL) is the mainstream trend. The rise of "Speech LMs" (Speech Language Models) is expanding the field into paralinguistic understanding, full-duplex dialogue, and zero-shot TTS. ACL 2025: The speaker shared insights into the ARR (ACL Rolling Review) system. Discussions focused on the importance of re-submissions for improvement and the necessity of robust rebuttals. It also provided tips for creating visually minimal, high-impact posters to attract attention in crowded venues. Summary It was a privilege to share our latest research at the conference this December. With nearly 200 participants, the event was full of energy, featuring heated discussions about the possibilities of speech processing using LLM, including invited lectures and international conference reports. Speaking directly with other experts provided us with incredibly helpful feedback. While our team worked hard to prepare a strong presentation, these live discussions were essential for identifying new challenges and shaping the future direction of our work. I felt that new challenges and future research directions became much clearer. At RevComm Research, we plan to advance research on multilingual support and applications to specific domains based on these research findings. We are currently looking for new colleagues to join us in solving these exciting technical challenges.
アバター
概要 こんにちは、RevCommでインフラを担当している齊藤です。 ECS Fargate を踏み台にする構成自体は、すでに多くの知見があります。 本記事ではそこからさらにもう一歩踏み込み、ABAC を用いた「適切な人に、適切な時だけ」アクセスを許可する仕組みについて紹介します。 単なるサーバーのコンテナ化に留まらず、IdP の属性を活用した本人認証の強化や、Slack 連携によるオンデマンドな環境提供を組み合わせることで、「必要な時に、権限を持つ本人のみが利用できる」という、実運用に即したアクセス管理をいかに実現したかについて共有できればと思います。 背景 当初は「昔ながら」の EC2 + SSH で運用していました。しかし、鍵の管理やセキュリティグループの運用負荷が高かったため、まずは EC2 + SSM Session Manager への移行を行いました。 これにより、踏み台をパブリックサブネットに置く必要がなくなり、SSH 鍵の運用からも解放されました。 しかし、EC2 + SSM に移行したことで利便性は向上しましたが、以下の3つの課題は残りました。 脆弱性対応 AWS Inspector を確認すると、どうしても Affected Resources(影響を受けているリソース)として踏み台 EC2 がリストアップされます。 常時起動によるセキュリティリスク 踏み台が常に稼働していることは、潜在的な侵入経路を晒し続けることを意味します。必要な時以外は「存在しない」状態が理想でした。 権限管理の粗さ IAM Role に権限があれば、その Role を持つ全員がログインできてしまいます。「特定の人が、その作業のためにだけ使う」というアイデンティティに基づいた制御には限界がありました。 この課題を解決するために今回紹介する構成に変更することにしました 解決策 OS 管理からの解放(脆弱性対応の自動化) 踏み台を EC2 から ECS Fargate に変更しました。 コンテナイメージのベースとなる OS パッチは AWS 側で更新されるため、これまでのEC2 の OS メンテナンスからは解放されます。 イメージを定期的にビルド・更新することで、脆弱性が Unresolved な状態で放置されるリスクを構造的に抑え込んでいます。 オンデマンド起動による攻撃の最小化 Slack Workflow + Amazon Q を活用し、必要な時だけ ECS タスクを起動する仕組みを構築しました。 踏み台が「常に存在している」状態をなくすことで、侵入経路としてのリスクを最小限に抑えています。 ABAC による「本人限定」のアクセス制御 単なる IAM Role による制御だけでなく、ABAC を導入しました。 起動した本人しかログインできない制約と、IdP 側の属性情報に基づいた動的な権限チェックを組み合わせ、ログイン権限を管理しています。 システム構成 システム概要 今回の仕組みは、大きく分けて「起動トリガー」「実行基盤」「アクセス制御」の3つの要素で構成されています。 起動トリガー:Slack Workflow + Amazon Q Slack Workflow リクエスト画面 Slack Workflow から Amazon Q へのリクエストサンプル @Amazon Q sns publish --topic-arn arn:aws:sns:ap-northeast-1:***:serverless-bastion-endpoint --chatbot-replace-curly-quotes enable --region ap-northeast-1 --message "{\"env\": \"dev\", \"timer\": \"60\",\"action\":\"start\",\"task\":\"base\",\"user\":\"@xxx\"}" 開発者が使い慣れた Slack をインターフェースにしています。 Slack Workflow ユーザーが踏み台を起動したい環境、コンテナイメージなどを選択し起動リクエスト出します Amazon Q Slack Workflow から送信されたパラメータを受け取りリクエストに合わせたコンテナを起動します 実行基盤:ECS Fargate + SSM Session Manager 実際の作業環境は、サーバーレスなコンテナとして提供されます。ここで重要なのが、「使い終わったら自動で消える」ための独自の実装です。 自動終了ロジックの組み込み コンテナ内の初期化処理(Entrypoint または Init プロセス)に、セッションと起動時間を監視するスクリプトを仕込んでいます。 アイドル監視 SSM Session Manager のプロセスが一定時間無いことを検知すると、タスクを自ら終了させます。 タイムアウト監視 万が一セッションが残ったままでも、指定した起動時間を経過すると強制的にタスクを終了するようにしています。 EFS マウントによるデータの永続化 コンテナ自体は使い捨てですが、/mnt/data などのディレクトリには Amazon EFS をマウントしています。 Public IP 不用 SSM Session Manager を使用するため、コンテナをプライベートサブネットに配置でき、セキュリティグループでインバウンドを空ける必要もありません。 ログの自動取得 誰がどのような操作を行ったかは、SSM の機能によって S3 に自動的に集約されます。 アクセス制御:ABAC による本人認証 Condition 句を使って、「起動した本人か」と「IdP の属性」をチェックしている IAM ポリシーのサンプル { "Version": "2012-10-17", "Statement": [ { "Sid": "StartSessionSubject", "Action": "ssm:StartSession", "Condition": { "StringNotEquals": { "aws:PrincipalTag/Subject": [ "Team_A", "Team_B", "..." ] } }, "Effect": "Deny", "Resource": [ "arn:aws:ecs:ap-northeast-1:***:task/serverless-bastion/*" ] }, { "Sid": "ServerlessBastionSession", "Action": "ssm:StartSession", "Condition": { "StringNotEquals": { "ssm:resourceTag/SessionName": [ "${aws:PrincipalTag/SessionName}" ] } }, "Effect": "Deny", "Resource": [ "arn:aws:ecs:ap-northeast-1:***:task/serverless-bastion/*" ] } ] } 起動者情報の付与 ECS タスクの起動時に、タグを利用して「誰が起動したか」という情報をコンテナに関連付けます。 動的なポリシー判定 SSM でコンテナに接続しようとする際、IAM ポリシーが以下の2点をチェックします。 Session 接続を試みているユーザーと、コンテナのタグに刻まれた起動者が一致しているか。 IdP から渡された属性情報が、アクセスを許可された条件を満たしているか。 これにより、たとえコンテナを起動できても、権限や属性が正しくないユーザーはログインすらできないという二重のガードレールが機能します 運用してみて工夫した点など 移行の推進 慣れた EC2 環境から移行するのは運用の大幅変更になるため猶予期間を設けて移行を促すようにした CPU アーキテクチャの混在 当初コンテナイメージは ARM しか用意していなかったが、x86_64 前提で構築されているツールがあったため、現在は ARM, x86_64 の両方のアーキテクチャに対応するようにした ECS 上での「docker run」の再現 踏み台上で直接コンテナを実行していた従来の運用を維持するため、「ECS タスクから別の ECS タスクを起動するコマンド」を自作して提供しました 起動時間の短縮(SOCI Index の導入) オンデマンド起動の宿命ですが、タスクのプロビジョニングに 1〜2 分ほどかかり、これに対しては SOCI (Seekable OCI) Index を導入し、イメージの遅延読み込みを有効にすることで、起動時間を短縮する改善を行いました。 ABAC 実装のハマりどころ(SessionName への Email 埋め込み) 本人特定のために Slack アカウントの Email を使用しましたが、aws:PrincipalTag/SessionName に動的に Email を含める方法に苦戦しました。最終的には AWS サポートの助けも借りながら、ログインユーザーを正確に識別・制限できるロジックを完成させることができました。 今後の課題 現在は AWS アカウントごとにこの仕組みを展開していますが、セットアップの自動化がまだ完全ではなく、横展開の手間が課題として残っています 最後に 脆弱性対応をやりたくないという、正直な動機から始まった改善でしたが、結果として SOCI や ABAC といった技術的な深掘りに繋がり、以前よりもセキュアな踏み台環境を構築することができたと思います
アバター
皆さんこんにちは。RevComm の CTO の平村 ( id:hiratake55 , @hiratake55) です。今年もあと数日となりました。この記事では、2025 年の RevComm の開発チームの振り返りを行いたいと思います。 この記事は、 RevComm Advent Calendar 2025 の 25 日目の記事です。 1月 MiiTel Analytics 共通ナビゲーションヘッダーリニューアル MiiTel は 2018 年のリリース以来の大規模なナビゲーションヘッダーの改善を行いました。現在では、MiiTel Phone、MiiTel Meetings、MiiTel RecPod、MiiTel Call Center など、プロダクトが増えてきたこともあり、ユーザー体験の向上を目指して複数の製品間をスムーズに切り替えられるナビゲーションデザインへとリニューアルしました。 これにより、それぞれのプロダクト開発に集中できるようになり、重複するコンポーネントは共通化することで、フロントエンドアプリケーションの開発者体験を高めることにも成功しています。 2月 New Year's Party 2025 フルリモートでさまざまな場所で働くレブコムの全社員が、リアルな場に集まって交流し、親睦を深めることを目的に、毎年、新年パーティーを開催しています。2025 年を成長の年とするために、Think Big, Act Now の考え方で進めましょうという挨拶から始まり、クイズ大会や表彰を通してチームを越えて交流を深めました。 note.com 5 月 Microsoft Build 2025 に参加 米国ワシントン州シアトルにて開催された、世界最大級のマイクロソフト社の開発者向けのイベント Microsoft Build 2025 に参加しました。Github Copilot, Azure OpenAI Service や Microsoft 365 のようなマイクロソフト製品のほか、Playwright のようなマイクロソフトが公開しているオープンソース製品に関する新機能や最新動向のキャッチアップが進み、ワークショップを通して実際に機能を扱ってアプリを作ることで理解を深めることができました。 6月 DASH 2025 に参加 ニューヨークで開催された、Datadog 社主催のイベントに 2 名のエンジニアが参加しました。 DASH 2025 は、Datadog の世界最大規模のユーザー・技術者向けイベント で、Datadog のプラットフォームや新技術の最新動向を学ぶイベントです。最新のインフラ運用・モニタリング・AI を活用した新機能の潮流を学んできました。 6月 フルスタックチーム開発合宿@別府 RevComm には、所属するチームで目標設定やメンバー間の相互理解を深めるため、必要に応じてオフサイトミーティングを企画できる制度があります。CTO Office のフルスタックチームでは、MCP を始めとする新技術の検証結果の共有や今後の進め方について、対面で集中してディスカッションしました。フルスタックチームは、西日本在住のメンバーが多いこともあり、大分県の別府市のゲストハウスを貸し切って議論しました。 7月 取締役プレジデント & COOとして平井さん入社 楽天元副社長、シスコ日本法人元社長を務めた平井康文さんが入社されました。これまで各社で経営に携わってきた経験を活かし、RevCommの組織や経営をさらに進化させ、事業成長を加速させたいと考えジョインしていただきました。日本発で世界に通用するプロダクトとエンジニア組織を目指し、挑戦を楽しめる環境を整え、チーム全体のレベルの向上に繋がっています。 note.com 7月 RevComm Webサイトリニューアル コーポレートサイトの全面的なデザイン・コンテンツのリニューアルを行いました。デザインは、昨今のトレンドに合わせて社内のデザインチームとマーケティングチームで再設計しました。合わせて、revcomm.co.jp から revcomm.com へドメインの変更も実施しました。これまで社内外から要望の多かった英語版も作成し、グローバルでのプレゼンスを高めることも目的にしています。 7 月 新サービス発表会 毎年 7 月頃に、記者向けに製品発表会を実施しています。本年は、音声解析 AI「MiiTel」を通じて蓄積された企業の音声データの統合活用を促進する生成 AI ソリューション「MiiTel Synapse」を発表しました。MiiTel Synapse Copilot と MiiTel Synapse Agent で構成されます。詳細は、以下の Note 記事をご覧ください。 note.com 8 月 全社オフサイトミーティング 毎年夏に、全社員が集まるイベントを開催し、普段は交わらないメンバー同士がリアルに交流できる貴重な時間となりました。BBQや企画を通じてコミュニケーションが生まれ、改めてチームの一体感やカルチャーを感じられる場になりました。Valueを称えるアワードも実施し、この経験を来年以降のさらなる成長につなげていきたいと感じる機会でした。 note.com 8 月 日本スタートアップ大賞 2025 受賞 日本スタートアップ大賞 2025 にて総務大臣賞を授賞し、首相官邸で開かれた表彰式に参加しました。石破首相や川崎総務大臣政務官へデモンストレーションを行いました。 prtimes.jp 9 月 Beyond Communication 2025 開催 弊社では初になる、大規模なビジネスイベントを日本橋 KABUTO ONE で開催しました。デモブースも設け、新製品や開発中の製品のプロトタイプをお客様に試していただけるブースも用意し、フィードバックをいただくことができました。 prtimes.jp 10 月 SDU (Strategic Development Unit) 設立 エンジニア組織内に新部署 SDU (Strategic Development Unit) を設立しました。これは新製品や新機能のプロトタイプを開発したり、高速に製品化するのがミッションのチームです。具体的には、生成 AI や音声対話 AI、LLM などの最新テクノロジーを活用したプロダクトの開発をミッションとしています。 11 月 丸ビルへの本社移転 RevComm では、フルリモート勤務が可能な働き方を取り入れていますが、オフィス勤務を希望するメンバーはオフィスで働くことももちろん可能です。毎日や週に数日の出社を希望するメンバーも増え、社員間の交流を促進したいことと、お客様やパートナー企業に来ていただきたいオフィスを目指して、渋谷から東京駅直結の丸ビルに移転しました。 移転の背景や、RevComm のリモートワークの考え方は、プレスリリースおよび、執行役員 Head of HR の乾の Note で詳しく説明しています。 prtimes.jp note.com 11 月 インドネシア開発拠点 ソフトウェア・AI 関連の開発を加速するため、インドネシアのジャカルタに RevComm Innovation Center in Indonesia を開設しました。これまでインドネシア子会社では、営業・サポート拠点を設置していましたが、エンジニア 4 名を現地採用し開発拠点としても事業開始しました。詳細は以下のプレスリリースをご覧ください。 prtimes.jp 12月 Hack Day 2025 技術力の向上を目的に、社内 ISUCONのようなイベントを開催しました。Web アプリケーションのパフォーマンスボトルネックの確認と改善について理解が深まりました。 AI コーディングエージェントの活用が進んだこともあり、サーバーサイドアプリケーションを扱う機会があまり多くないフロントエンドエンジニアやインフラエンジニアも参加して活躍しました。丸ビルオフィスでの現地開催とオフラインとオンラインで約 40 名の社員が参加しました。 まとめ 2026 年も魅力的な新サービス、新機能のリリースを予定しています。世界で活用される MiiTel のサービスの開発に興味のある方は、ぜひ応募をお待ちしております。 career.revcomm.com
アバター
はじめに 皆様こんにちは、RevCommでソフトウェアエンジニアをしている加藤です。私は主にSaaSにおけるユーザーやテナントの管理および認証を担当しています! 今回は社内で構築したチケット作成Slackアプリについて話します! 背景 私たちのチームではユーザーの認証を管理しているので、お客様がログインできなかったりした際にサポートチームを通じて調査依頼が送られてきていました。毎週チームメンバーの1人が”オンコール”を担当し、その調査を担当していました。頻度的には1日1回の依頼がありましたが、チームの人数が少ないこともあり、調査が長期化すると、オンコールのメンバーがすぐに対応できないことが何度か発生していました。 また、全ての依頼はSlackから行われ、私たちのチームはNotionでタスクや調査チケットを管理しています。”オンコール”メンバーは調査をする際にはSlackの内容をまとめ、Notionに書き換える必要がありました。これは平均10分ぐらいかかる内容です。また、Notionへのタスク記入を忘れて、他のチームメンバーが現在調査中のチケット数を把握できないこともありました。 そのため、以下の点を解消するためにチケット作成Slack Appを作成しました。 Slackでチームで決められたスタンプを押すとスタンプが押されたら、 お客様で認証が失敗した時間や、ユーザー情報をスレッドから読み取る slackのスレッドの要約を作成する Notionにチケットを作成する Notionに設定してある”オンコール”の担当者にメンションする アーキテクチャ AWS 構成図 使用技術一覧 こちらに今回使ったAWSサービスやライブラリをまとめておきます。 カテゴリ 技術 用途 API Gateway AWS API Gateway Slack Webhookエンドポイント コンピューティング AWS Lambda (Docker) サーバーレス実行環境 メッセージング SNS FIFO + SQS FIFO 非同期処理・順序保証 AI AWS Bedrock (Claude Sonnet 4) スレッド要約生成 ストレージ Amazon ECR LambdaのDockerイメージ保存 秘匿情報管理 AWS Systems Manager Parameter Store トークン・認証情報管理 チャット Slack Bolt for Python Slackイベント処理 データベース Notion API チケット管理 ポイント 2つのLambda関数で構成 Lambdaを選択したのはリクエストの頻度が非常に少ないからです。1日1回程度しか呼ばれないのであればサーバレス構成が非常に効果的です。リクエストにかかった時間の従量課金になります。 また、本システムは 2つのLambda関数 で構成されています: Slackからのイベントに即座に応答するLambda: リアクションイベントの検知 ユーザー・スレッドの確認とSNSへの送信 バックグラウンドでチケット作成を処理するLambda Slackスレッドの取得 Bedrock経由でAI要約 Notionチケット作成 Slack通知 なぜ2つに分けたのかというと、Slackは 3秒以内 にレスポンスを返さないとタイムアウトエラーになります。一方、AI要約は数秒〜十数秒かかることがあります。そこで、 即座に応答する部分 と 時間がかかる処理 を分離し、SNS/SQSで非同期連携することで、ユーザー体験を損なわずに確実な処理を実現しています。また、slackのイベントから別のlambdaをトリガーさせたくなるかもしれないと考え、SNSを挟んでいます。 Bedrock構造化出力で要約生成 AWS Bedrockの 構造化出力機能 を使用することで、AIの出力を確実にPydanticモデルに変換できます: スレッド内のテナント・ユーザー情報もしくは発生日時などをモデルに定義しておくことで、パースの失敗やバリデーションの自動化をすることができます。 #下記はサンプルです。 from typing import cast from pydantic import BaseModel, Field from strands import Agent from strands.models import BedrockModel logger = Logger() class BedrockService : def __init__ (self, model_id: str , region: str , temperature: float = 0.3 ): self.model = BedrockModel( model_id=model_id, temperature=temperature, region_name=region, ) self.agent = Agent(model=self.model) def generate_structured_output (self, model_class: type [BaseModel], prompt: str ) -> BaseModel: try : logger.info( "Sending request to Bedrock for structured output" ) result = self.agent.structured_output(model_class, prompt=prompt) return result except Exception as e: logger.error(f "Error generating structured output: {e}" ) raise class TicketSummary (BaseModel): title: str = Field(..., description= "One-line Notion ticket title without markdown headings" ) description: str = Field(..., description= "Markdown body for Notion." ) def summarize_thread (self, message: str ) -> TicketSummary: summary = cast( TicketSummary, self.bedrock_service.generate_structured_output(TicketSummary, message), ) return summary 結果 導入後以下の効果がありました。 オンコールメンバーの初回返信時間が20分以内になりました。 取りこぼすことなく、全ての調査チケットがNotionで一元的に管理できるようになりました。 まとめ 本記事では、Slackのリアクションで調査チケット作成を自動化するシステムを構築しました。 ポイントは以下です。 ✅ 2つのLambda関数による非同期処理でSlackタイムアウトを回避 AWS Bedrockを用いた構造化出力とPydanticによる型安全な実装 同様のパターンは、問い合わせ対応の自動化、レポート生成、データ分析など、様々な業務に応用できます。ぜひ皆さんの業務でも試してみてください! 参考リンク AWS Bedrock ドキュメント Slack Bolt for Python Notion API ドキュメント AWS Lambda Powertools Python Strand Agents
アバター
こんにちは。RevCommでフロントエンドエンジニアしているnobkzと申します。 はじめに 普段みなさんは、どのようなエディタをお使いでしょうか?私は普段から色んなエディタを使っていて、また色んなテキストエディタの実装を見ています。そこで、今回は、テキストエディタに関して重要な、テキストバッファの実装について見ていきましょう。 テキストバッファとは? そもそもテキストエディタとは何でしょうか?それは、テキストの情報を保持して、ユーザーの指示により内容を表示編集するプログラムです。 テキストバッファとは、 テキストエディタのテキストそのものを保持している場所であり、さまざまな機能が要求されます。 テキストバッファで特に重要な機能といえば、文字参照、挿入、削除です。 つまり、このcharAtやinsert、eraseの効率が重要になってきます。そのため、テキストバッファはこの効率のために、エディタごとに工夫したデータ構造をとっています。今回はそのようなテキストバッファのデータ構造を見ていきましょう。 テキストバッファのデータ構造 インターフェース 今回の記事では、TypeScriptで実装を紹介していきます。まず、簡単にテキストバッファを操作するためのインターフェースを定義してみましょう。今回は、TypeScriptを利用します。 type charAt < Buffer > = ( buffer : Buffer , pos : number ) => string ; type insert < Buffer > = ( buffer : Buffer , pos : number , char : string ) => Buffer ; type erase < Buffer > = ( buffer : Buffer , pos : number ) => Buffer ; 簡単なデータ構造によるテキストバッファ テキストバッファのデータ構造としてまず、簡単なデータ構造である配列やリストについて紹介して検討していきましょう。 配列をBufferにする まず、簡単にテキストを保持するデータ構造といえば、文字列型でしょう。文字列型は、言語によりますが基本的には文字の配列となっていることが多いでしょう。ここで配列とは、 文字の配列で、実際にTextBufferを実装してみます。JSには、さまざまな配列のメソッドが存在しますが、あえて配列のメソッドを使わず、わかりやすさのため配列の添字によるアクセス、代入によって実装してみます。 まずはデータ型の定義からですね。 type ArrayTextBuffer = { data : string [] , length : number ; capacity : number ; } ひとまず、stringの配列としてますが、1文字の配列だと考えてください。ここで、lengthはテキストの長さ、capacityは配列が確保している長さです。上記の図の例でいえば、lengthは7, capacityは10となります。 次に、charAtの実装をしてみましょう const arrayCharAt: charAt < ArrayTextBuffer > = ( buffer , pos ) => buffer.data[pos]; 文字参照は添字によって直接アクセスでき配列のサイズに依存しないので、計算量は となります。 次に、挿入と削除を実装してみましょう。以下のようになります。 const arrayInsert: insert < ArrayTextBuffer > = ( buffer , pos , char ) => { if (buffer. length === buffer.capacity) { buffer.capacity *= 2 ; const newData = new Array (buffer.capacity); buffer.data. forEach (( c , i ) => { newData[i] = c } ); buffer.data = newData; } for ( let i = buffer. length - 1 ; i >= pos ; i--) { buffer.data[i+ 1 ] = buffer.data[i]; } buffer.data[pos] = char; buffer. length ++; return buffer; } const arrayErase: erase < ArrayTextBuffer > = ( buffer , pos ) => { for ( let i = pos ; i < buffer. length; i++) { buffer.data[i] = buffer.data[i+ 1 ]; } buffer. length --; return buffer; } ; このように、挿入される場所から後の文字を1文字ずらして、文字を代入したり、削除は単純に1文字逆にずらして、実装します。このようにサイズが大きくなると、ずらす量も線形に増えて、計算量は、 となります。挿入においては、capacityとlengthが一致すると、バッファが満杯になっているので、バッファを2倍にしていることがわかると思います。 上記により配列をテキストバッファにすると、 文字参照は 挿入、削除は となり、小さなテキストだとこの実装で十分ですが、大きなテキストデータの挿入削除は非効率です。ただし、末尾に挿入や削除は になります。これが、後々Gab Bufferや、Piece Treeといったデータ構造に重要になってきます。 双方向リストをテキストバッファにしてみる 双方向リストをテキストバッファにしてみましょう。 データ型の定義は以下の通りになります。 type ListTextBuffer = { char : string , prev ?: ListTextBuffer , next ?: ListTextBuffer } | null ; // nullで空を表現 まず文字参照の実装をしてみましょう。実際には添え字の範囲外のエラーなどを出すべきですが、今回は簡単な実装に留めています。 const getNode = ( buffer : ListTextBuffer , pos : number ) => { let node = buffer; for ( let i = 0 ; i < pos ; i++) { node = node?. next ?? null ; } return node; } const listCharAt : charAt < ListTextBuffer > = ( buffer : ListTextBuffer , pos : number ) => { return getNode(buffer, pos)?.char || "" ; } 文字参照は、先頭から順番に参照を得ないといけないため、配列とは対照的に計算量は となります。 次に挿入と削除を実装しましょう。 const listInsert: insert < ListTextBuffer > = ( buffer : ListTextBuffer , pos : number , char : string ) => { const next = getNode(buffer, pos); const prev = next?.prev; const node : ListTextBuffer = { char , next , prev } ; if (next) { next.prev = node; } if (prev) { prev. next = node; } return buffer; } const listErase: erase < ListTextBuffer > = (buffer: ListTextBuffer, pos: number => { const targetNode = getNode(buffer, pos); const prev = targetNode?.prev; const next = targetNode?. next ; if (prev) { prev. next = next; } next.prev = prev; return buffer; } getNodeが現状の実装だと、 ですが、一旦それを無視してください。すると単につなぎかえるだけなので計算量が となります。 局所参照性 ところで、テキストエディタには局所参照性があります。局所参照性とは、メモリなどのリソースのアクセスが使用した部分の周辺が繰り返しアクセスされやすいという性質です。カーソル周りで編集がたくさんされやすいと考えればわかりやすいでしょう。テキストエディタの場合は、通常99%はシーケンシャルな参照 *1 らしいです。このような性質を前提としてテキストエディタは、データ構造を最適化して処理速度の向上が可能になります。 配列とリストの評価 さて、上記の getNode() の実装は簡単な実装に留めていましたが、実際にやるのであれば、このキャッシュしておくなどして、処理速度を効率化させて行くでしょう。 ここで、配列とリストについて評価をしていくと、配列はシンプルですが挿入や削除が です。一方でリストは文字参照が です。どちらも一長一短があります。局所参照性を考えると若干リストの方が良さそうに思えますが、他にも、実際のところ、1文字につきポインタが2つ必要になったりするのでメモリ使用量が増えたりなどの問題があります。 このような配列やリストの実装をベースとして他のバッファがこのようなパフォーマンスがどのように改善されていくか?みていきましょう。 行をリストで管理する 元々も、配列自体が挿入削除が となることわかります。このようになる原因はそもそも、テキスト全体一つの配列に詰め込んだからです。そうすると、テキスト全体を一つの配列ではなく区切って小さい単位に分けていこうとなるのは自然な発想となります。Software Tools *2 では、テキストエディタのバッファのデータ構造として、行単位で分けて、管理していました。 データ構造の定義は以下の通りになります。 type LineTextBuffer = { text : ArrayTextBuffer , prev ?: LineTextBuffer , next ?: LineTextBuffer } | null ; さて、文字参照、挿入削除。実装自体は省略します。(ぜひやってみてください)。文字参照は、Lが行数として、最初は となりますが、連続で同じ行をアクセスする場合は参照したい行が確定しているため となります。また、対象行の文字数をlと置くと、挿入削除は、最初は 、同じ行を編集するなら となります。配列やリストの管理を比較するとより、高速になったことがわかると思います。 こように行をリストで管理するのは、テキストエディタを実装としてよくみられます。VSCodeの最初の実装はこのような行単位管理による実装でした。ただし現在では後述するPieceTreeによって実装されます Gap Buffer 行単位でリスト管理する方法がある一方で、より局所参照の最適化を図ったデータ構造があります。それは、Gap Bufferです。Gap Bufferはemacsのテキストのデータ構造として利用されています。Gapバッファは、配列にGapつまり隙間があるデータ構造です。下の図のようはデータ構造になります。 データ構造を定義するなら以下のようになります。 type GapBuffer = { data : ArrayTextBuffer , capacity : number , gapStart : number , gapSize : number } まず文字参照をみていきましょう。 const gapBufferChartAt : charAt < GapBuffer > = ( buffer , pos ) => { if (pos < buffer.gapStart) { return buffer.data[pos]; } else { return buffer.data[pos+buffer.gapSize]; } } 文字参照は、gapStartの手前であれば、そのままposで参照し、超えていればgapSize+posで参照します。計算量は となります。 次に挿入や削除を実装してみましょう。動作のイメージは以下の通りです。 実装は以下のようになります。 const gapBufferInsert: insert < GapBuffer > = ( buffer , pos , char ) => { if (buffer.gapSize === 0 ) { buffer.gapStart = buffer.capacity; buffer.gapSize = buffer.capacity; buffer.capacity *= 2 ; const newData = new Array (buffer.capacity); buffer.data. forEach (( c , i ) => { newData[i] = c } ); buffer.data = newData; } while (pos !== buffer.gapStart) { const tmp = buffer.data[buffer.gapStart + buffer.gapSize - 1 ]; buffer.data[buffer.gapStart + buffer.gapSize - 1 ] = buffer.data[buffer.gapStart - 1 ]; buffer.data[buffer.gapStart - 1 ] = tmp; buffer.gapStart--; } buffer.data[pos] = char; buffer.gapSize--; buffer.gapStart++; return buffer; } const gapBufferErase: erase < GapBuffer > = ( buffer , pos ) => { while (pos !== buffer.gapStart) { const tmp = buffer.data[buffer.gapStart + buffer.gapSize - 1 ]; buffer.data[buffer.gapStart + buffer.gapSize - 1 ] = buffer.data[buffer.gapStart - 1 ]; buffer.data[buffer.gapStart - 1 ] = tmp; buffer.gapStart--; } buffer.gapSize++; buffer.gapStart--; return buffer; } さて、挿入は、gapSizeが0であれば、バッファが満杯なので、capacityを2倍にしていることがわかります。ArrayTextBufferの時と一緒ですね。そして、ギャップの最初の位置が一致するまで、ギャップをずらしています。削除の場合も、削除する位置にずらしています。ただし削除する場合は、配列からデータを消さずに、gapの位置だけずらして、文字参照から見えなくしているんですね。後述するデータ構造も、削除は実はデータ自体は保持されていますが、見えなくして削除しているデータ構造になっていることが多いです。さて、計算量について考えてみましょう。もしgapが一番後ろにあって、先頭に文字を挿入するならば、ほとんと配列と同様の操作となり になりますが、一方で、連続してテキストを編集するならば、ギャップをずらす必要がないため、 です。 gap bufferは非常に優秀なデータ構造で、これはテキストエディタの局所参照性を最大限に最適化したものと言えるでしょう。 PieceTable PieceTableは現在のVSCodeで利用されているPiece tree *3 の元となったデータ構造です。Pieceとは、断片という意味です。Piece Tableとは断片の表という意味ですが、ちょっと意味がわからないですよね。簡単に言えばPiece Tableとは、テキストの断片、つまり区間を表にしたデータ構造です。先ほどの行単位の管理のイメージを持つ人もいるとは思いますが、何が違うかと言えば、 編集区間 を表にしたと考えればわかりやすいかもしれません。 簡単にイメージしてみましょう。まず2つの配列バッファがあります。オリジナルバッファと、追記用のバッファです。オリジナルバッファは読み取り専用で、編集元のデータとなります。読み取り専用なので、このバッファ自体は変化しません。そして、追記用のバッファが、末尾に追加するだけのデータバッファで、編集でどんどん追記して大きくなっていきます。その2つのバッファとは別に、それぞれのテキストの区間を表す表があります。この区間の表は、順番があり、実際のテキストの順になるように作られています。 イメージとしては以下の通りです。 このイメージだと「吾輩はねLISPがすきである」という文章になります。オリジナルバッファの3番目の「猫」は表にないので、文章には入ってないことに注意してください。 さてデータ型を定義してみましょう。 type PieceTable = { originalData : ArrayTextBuffer , additionalData : ArrayTextBuffer , table : Piece [] } // startとendは閉区間にする type Piece = { bufferType : "original" | "additional" , start : number , end : number } さて文字参照を実装してみましょう。 const pieceTableCharAt : charAt < PieceTable > = ( buffer , pos ) => { const { offset , piece } = localPiece(buffer, pos); // pieceが見つからなかったらひとまず空文字を返しておく if (piece == null ) { return "" ; } return piece.bufferType == "original" ? arrayCharAt(buffer.originalData, piece.start + offset) : arrayCharAt(buffer.additinonalData, piece.start + offset); } // 閉区間なので長さは差分を取って+1 const pieceLen = ( p : Piece ) => p.end - p.start + 1 ; // 位置から、その位置を含むPieceとoffsetを取得する const localPiece = ( buffer : PieceTable , pos : number , ): { piece : Piece | null ; pieceIndex : number ; offset : number } => { if (pos < 0 ) return { piece : null , pieceIndex : - 1 , offset : 0 } ; let rem = pos; for ( let i = 0 ; i < buffer.table. length; i++) { const piece = buffer.table[i]; const len = pieceLen(piece); if (rem < len) return { piece , pieceIndex : i, offset : rem } ; rem -= len; } // 空テーブルの時など return { piece : null , pieceIndex : - 1 , offset : 0 } ; } ; Pieceの区間の処理をしないといけない分複雑になりましたね。さて、計算量はPieceの数に依存するので、Piecesの数を単純にPと置いて となります。ただし、この実装には含んでいませんが、部分を連続で参照する場合、発見したPieceを再活用できるため になります。 挿入削除を実装してみましょう。まず挿入のイメージですが以下になります。 つまり、編集したいPieceを発見して、分割して、その間に新しいPieceを挿入するということです。実装してみましょう。 const pieceTableInsert: insert < PieceTable > = ( buffer , pos , char ) => { // 追加バッファ末尾に1文字追加 const addPos = buffer.additionalData. length ; // 追加開始位置 arrayInsert(buffer.additionalData, addPos, char); const newPiece: Piece = { bufferType : "additional" , start : addPos, end : addPos } ; const { offset , piece , pieceIndex } = localPiece(buffer, pos); if (offset === 0 ) { // piece の先頭に挿入 = その前に newPiece を入れる buffer.table. splice (pieceIndex, 0 , newPiece); return buffer; } // pieceが見つからなかったらひとまず何もしないでおく if (piece == null ) { return buffer; } // piece を split して間に newPiece を挿入 // piece: [start .. end] (closed) // left: [start .. start+offset-1] // right: [start+offset .. end] const left: Piece = { bufferType : piece.bufferType, start : piece.start, end : piece.start + offset - 1 , } ; const right: Piece = { bufferType : piece.bufferType, start : piece.start + offset, end : piece.end, } ; buffer.table. splice (pieceIndex, 1 , left, newPiece, right); return buffer; } このように挿入の場合は、addtionalBufferに追加して、Pieceを分割して、新しいPieceを挿入すれば良いわけです。削除の場合は実装しませんが、こちらもPieceを発見して、分割、そして、Pieceのstartやendの位置を単にずらせばいいのですよね。Pieceが発見されてない場合は、 ですが、Pieceが発見されていて、newPieceに連続で編集する場合は、追記バッファの末尾の追加と、newPieceのendを増やせばいいだけになるので になります。削除も同様です。(削除は実装しないのでチャレンジしてみてください。) 上記の実装は簡単な実装であり、本来であれば、Pieceを合併したりする処理があるのですが、長くなるので割愛します。 Piece Tree さてVSCodeでは、Pieceの管理を、連続的な表ではなく、赤黒木で持つようにしました。その理由として、上記のPiece Tableの課題点として、編集が増えれば増えるほど、Pieceの数が増大し、探索がボトルネックになってきます。そこで、VSCodeでは、連続したTableとして持つのではなく、赤黒木としてPieceを持つようにしました。これがPiece Treeです。そのおかげで、文字参照の計算量は となりました。 Piece Treeの実装の方はやりません。(チャレンジしてみてもいいでしょう) 追記型の利点 Piece TableやPiece Treeは、このような性能のみならず、追記型であるがゆえに、編集履歴の管理にも強いと言う側面があります。なぜなら、バッファの追記のみで構成されるため、履歴管理には基本的には、Pieceの変更だけ追えば良いからです。 Rope さて、最近私はZed *4 を利用しています。Zedは共同編集やAIエディタなどを想定して、複数のユーザーから参照されると言う要件がでてきます。そのため、テキストバッファにRopeを採用しています。 RopeはPiece Treeと同様に大きな文字列を小さな断片に分け、それを木構造で保持するデータ構造です。Ropeは末端の葉は、文字の断片を表しますが、葉以外のノードは左部分木の文字数を示しています。イメージで言うと以下の通りです。 データ構造としては以下のようになります。今回、葉の設計がPiece Treeと同様の設計にしています。(一般的には、stringとして持つことが多いです。) type RopeNode = { kind : "node" ; weight : number ; left : RopeNode | RopeLeaf ; right : RopeNode | RopeLeaf ; } ; type RopeLeaf = { kind : "leaf" ; bufferType : "original" | "additional" ; start : number ; // closed end : number ; // closed } ; type RopeBuffer = { original : ArrayTextBuffer ; additional : ArrayTextBuffer ; root : ( RopeNode | RopeLeaf ) | null ; } ; 文字参照について簡単に実装してみましょう。動作イメージは以下の通りになります。 つまり、ノードと比較して、小さいなら左、大きいなら、右に進み、右に進むときに、調べる文字数を引けばいいのですね。 // 型ガード const isLeaf = ( x : RopeNode | RopeLeaf ): x is RopeLeaf => x.kind === "leaf" ; const leafLen = ( leaf : RopeLeaf ) => leaf.end - leaf.start + 1 ; const ropeCharAt: charAt < RopeBuffer > = ( buf , pos ) => { if (buf.root == null ) return "" ; if (pos < 0 ) return "" ; let node: RopeNode | RopeLeaf = buf.root; let i = pos; while ( true ) { if (isLeaf(node)) { if (i >= leafLen(node)) return "" ; const idx = node.start + i; return node.bufferType === "original" ? arrayCharAt(buf.original, idx) : arrayCharAt(buf.additional, idx); } // node は RopeNode に 좁まってる if (i < node.weight) { node = node.left; } else { i -= node.weight; node = node.right; } } } ; 木はバランスしている前提ならば、文字参照は高さに比例して (L は leaf 数)になります。 次に挿入や削除を見ていきましょう。Ropeの木の挿入と削除は、基本は split(分割) と concat(連結) の2つに落とし込めます。 split(tree, pos) : pos で木を左右の Rope に分割する concat(left, right) : 2つの Rope を連結する(新しいノードを作る) これができると、挿入は (L, R) = split(tree, pos) 追加バッファ末尾に char を追加し、それを指す leaf を作る tree' = concat(concat(L, newLeaf), R) 削除は (A, B) = split(tree, pos) (trash, C) = split(B, 1) (先頭 1 文字を落とす) tree' = concat(A, C) で表現できます。なので、今回はconcatとsplitのみ実装してみます。 const len = (t: (RopeNode | RopeLeaf) | null ): number => { if (t == null ) return 0 ; if (isLeaf(t)) return leafLen(t); return t.weight + len(t.right); } ; const concat = ( a: (RopeNode | RopeLeaf) | null , b: (RopeNode | RopeLeaf) | null , ): (RopeNode | RopeLeaf) | null => { if (a == null ) return b; if (b == null ) return a; return { kind : "node" , weight : len(a), left : a, right : b } ; } ; // split(t, pos) => [0..pos-1], [pos..end] const split = ( t: (RopeNode | RopeLeaf) | null , pos: number, ): [ (RopeNode | RopeLeaf) | null , (RopeNode | RopeLeaf) | null ] => { if (t == null ) return [ null , null ] ; if (pos <= 0 ) return [ null , t ] ; const tlen = len(t); if (pos >= tlen) return [ t, null ] ; if (isLeaf(t)) { const leftLen = pos; const leftLeaf: RopeLeaf = { kind : "leaf" , bufferType : t.bufferType, start : t.start, end : t.start + leftLen - 1 , } ; const rightLeaf: RopeLeaf = { kind : "leaf" , bufferType : t.bufferType, start : t.start + leftLen, end : t.end, } ; return [ leftLeaf, rightLeaf ] ; } if (pos < t.weight) { const [ l , r ] = split(t.left, pos); return [ l, concat(r, t.right) ] ; } else { const [ l , r ] = split(t.right, pos - t.weight); return [ concat(t.left, l), r ] ; } } ; これで挿入や削除は以下のように実装できます。 const ropeInsert: insert < RopeBuffer > = ( buf , pos , char ) => { // 追加バッファ末尾に 1 文字追加 const addPos = buf.additional. length ; arrayInsert(buf.additional, addPos, char); const newLeaf: RopeLeaf = {   ... 略 } ; const [ L , R ] = split(buf.tree, pos); return { tree : concat(concat(L, newLeaf), R) } ; } ; const ropeErase: erase < RopeBuffer > = ( buf , pos ) => { const [ A , B ] = split(buf.tree, pos); const [ , C ] = split(B, 1 ); // 先頭 1 文字を捨てる return { tree : concat(A,C) } ; } ; 注意点として、今回は二分木の平衡を考慮していない実装です。実際にはそれなりに複雑になります。 木がバランスしている前提なら、こちらの計算量も、 となります。今回の実装には含まれていませんが、同じLeafに連続で編集する限り実装によりますが計算量は末端の葉のサイズをlと置いて となります。 Ropeと共同編集の容易性 さて、Ropeは、PieceTreeやGap Bufferと比較すると若干性能が悪いように見えます。しかしながら、Ropeは性能よりも共同編集に主眼を置いています。上記のデータ構造は、挿入も削除もよくみてみると、元の木のデータ構造を直接編集していません。concatは単に元ある木の結合ですし、splitも単に分割するだけで、直接木のに変更を加えていません。そして、削除や挿入はこのconcatやsplitを組み合わせて元にある木から、新しい木を構築しているだけなのです。 挿入と削除のイメージは以下になります。 つまり、Ropeは更新のたびに全体をコピーせず、変更が必要な部分だけを新しく作り、残りは共有するデータ構造になります。つまり、元のテキストを保持したまま、変更後のテキストを構築することができ、また、並行編集などを考えると、並行して複数のバージョンが持ちやすく、その結果、共同編集などの機能に強いデータ構造になっています。 さらに、Piece Tableと同様に履歴操作にも強いデータ構造になっていることがわかるかと思います。 まとめ 本記事では、テキストエディタの内部で使われるテキストバッファのデータ構造を、文字参照(charAt)・挿入(insert)・削除(erase)という観点で見てきました。 配列: charAt は常に O(1)。ただし insert/erase は後ろをずらすので O(N)。 速い参照の代償として編集が重い。 リスト: insert/erase は繋ぎ替え自体は O(1) だが、位置まで行く探索が O(N)。 編集は軽く“できる”が、そもそも目的地に辿り着くのが遅い。 行単位管理: 「全文字を1本に持つ」せいで O(N) になるなら、分割して局所化する。 → 参照・編集が “行数 + 行内” に落ち、現実の編集(同じ行を触り続ける)で効く。 Gap Buffer: 局所参照性(カーソル近傍を連続編集)を前提に、“そこだけO(1)” を作る。ギャップ移動は最悪 O(N) だが、連続編集は実質 O(1)。 Piece Table / Piece Tree: 文字列本体を動かさず、編集を“参照の断片(Piece)”として表現する。Piece が増えると探索が効くので、表(配列)→ 木(赤黒木)で O(P) → O(log P) にする(VSCode)。 Rope: 断片+木で、split/concat に編集を還元する。変更は「必要な経路だけ作り直し、残りは共有」になり、履歴・複数ビュー・並行操作に自然に強い。 さまざまなテキストエディタのテキストバッファのデータ構造についてみていきました。普段、使っているエディタがどのような実装になっているかあまり気にしたことない方もたくさんいらっしゃるかもしれませんが、このようにさまざまなデータ構造の工夫がありとても面白いものとなっています。 また、ここまで書いてきましたが、テキストエディタの実装の話題ままだまだたくさんあり、たとえば、改行の管理や、カーソルの移動や、範囲選択、スクロール、折り返し、検索、共同編集などさまざまなことについてまだまだ深掘りできるところがあります。是非とも普段お使いのエディタについて探索してはいかがでしょうか? *1 : https://www.cs.unm.edu/~crowley/papers/sds.pdf *2 : P.J. Kernighan, Brian W. Plauger Software Tools 1976 Addison-Wesley Professional 020103669X *3 : https://code.visualstudio.com/blogs/2018/03/23/text-buffer-reimplementation *4 : https://zed.dev/
アバター
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 を活用しています。以下、具体的な事例を紹介します(コード例は実際のコードとは異なります)。 Chrome ブラウザの getUserMedia() API にバグがあり( Issue 370332086 )、特定の条件下でマイク選択が正しく動作しないという問題がありました。Factory Pattern を使うことで、この問題をクライアントコードから隠蔽しています。 // --- Custom MediaStream Factory --- // MediaStreamを取得する方法をカプセル化する const mediaStreamFactory = async ( constraints : MediaStreamConstraints ) => { if ( typeof constraints.audio === 'object' && typeof constraints.audio.deviceId === 'string' ) { const deviceId = constraints.audio.deviceId; try { // Chrome Bug Fix: deviceId を exact 形式に変換してみる const modifiedConstraints = { ...constraints, audio : { ...constraints.audio, deviceId : { exact : deviceId } } , } ; return await navigator .mediaDevices. getUserMedia (modifiedConstraints); } catch (error) { // exact 形式で失敗した場合、元の形式で再試行する console .warn( 'Exact deviceId failed, falling back' , error); } } // それ以外の場合はそのまま実行 return await navigator .mediaDevices. getUserMedia (constraints); } ; // --- クライアント側のコード例 --- // クライアントは内部処理を意識せずに必要な MediaStream を取得できる const startCall = async () => { // audio stream を取得 const stream = await mediaStreamFactory( { audio : { deviceId : 'default' }} ); // この後、通話・録音などに stream を利用できる } startCall(); Factory Pattern を使わない場合の問題点 Factory Pattern を使わない場合、コードは以下のように各所で条件分岐を書く必要があります。 const constraints = { audio : { deviceId : micId } } ; let mediaStream: MediaStream ; try { // Chrome Bug Fix mediaStream = await navigator .mediaDevices. getUserMedia ( { audio : { deviceId : { exact : micId } } } ); } catch (error) { // フォールバック mediaStream = await navigator .mediaDevices. getUserMedia ( { audio : { deviceId : micId } } ); } ブラウザ互換性のためのコードが複数箇所に散らばってしまうことや、通話開始、デバイス変更、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 token = localStorage. getItem ( 'authToken' ); if (token) { config. headers .Authorization = `Bearer ${ token } ` ; } 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 を手を動かして見るのが個人的にはおすすめです。
アバター