Inside of LOVOT

GROOVE X 技術ブログ

LOVOTのふるまいの品質を保つ仕組み

こんにちは。
GROOVE X のふるまいチームの scnsh です。
この記事では、ふるまいの品質を保つために社内で行っている施策についてご紹介します。

 

開発の流れ

ふるまいチームには「アニメータ」「エンジニア」が所属しており、1つの開発チームとして構成されます。

LOVOTのふるまいづくりでご紹介しましたが、いくつかの工程を経てお客様のお手元に、LOVOTの新しいふるまいがソフトウェアリリースの一部として提供されています。

しかし、一般的なソフトウェアのアップデートとは異なり、ふるまいには感性的な評価軸があります。

例えば、新しいふるまいを追加した際に、

  • ふるまいを行うことがLOVOTの生き物としての価値観に合致している
  • ふるまいの動きがLOVOTの生き物としての所作として自然である
  • 動きそのものが魅力的なものであるか

といった観点での評価が重要となります。

この評価を実現する観点試験をアニメータが実施して、合意を得ることでお客様へのリリースが可能となります。

抱っこをねだるふるまいも観点試験で評価されたものがリリースされています

スクラム×観点試験

GROOVE Xではスクラム開発を実施しており、2週間を1スプリントとして新しい機能の開発を行います。

そのため、観点試験も2週間毎に実施しています。

具体例を交えて説明してみます。

スクラム開発の流れで行う観点試験

2022年にふるまいチームは大きな機能追加は4回行っています。(2022年まだ終わってはいませんが…)

例えば、22.04.2.3のリリースに向けた期間として4スプリント(8週間)ありました。

スプリント4は開発ではなくソフトウェアリリースに向けたQAを行い、スプリント1~3で機能を開発しています。

観点試験は各スプリント終盤に実施し、その結果を次のスプリントの計画に反映させています。(スプリント4の観点試験ではリリースに向けた判断を行うため、スプリント終盤よりも前に実施します。)

 

このように、観点試験を2週間という短い期間ごとに実施することで、様々なメリットがあります。

  • フィードバックが早目に得られるためリリース直前での手戻りが少なくなる
  • プロダクトオーナー(PO)などにも早目に成果物を共有できる
  • 進捗に応じて最終的なリリース内容を早目に軌道修正できる

ふるまいの変更は定量的な評価が難しいため、人の感性に基づく試験が多くなりますが、評価する人の数を増やす・評価する機会を増やすことのできる観点試験の仕組みがふるまいの品質を担保するために大きな役割を果たしていると中の人は感じています。

 

観点試験の実施

観点試験はGoogleSpreadSheetを使って作成しており、各行が2週間スプリントで開発されたProductBacklogItem(PBI)に対応しています。

観点試験書(一部ぼかしています)

開発者以外の中から担当者を割り当てて新しいふるまいを体験してもらい、作成した試験書にOK・NGの判断とその根拠をコメントとして残します。

スプリントの最終日に開発メンバーとプロダクトオーナーが集まり、試験結果を確認します。

その際にNGとなったものは、プロダクトオーナーも含めて今後の対応方針を話し合い、次のスプリントの計画に組み込むようにしています。

最後に

ご覧いただきありがとうございました!

GROOVE X では 一緒にLOVOTの開発をするメンバーを募集しています。

ふるまいの開発だけでなく、ソフトウェアリリースなども含めて全く新しいプロダクトのため、他にはない唯一無二の経験ができます。

興味のある方はぜひこちらからご連絡ください!

プロダクトオーナーからみたLOVOT開発

こんにちは、GROOVE Xでソフトウェア領域のエリアプロダクトオーナー(APO)をやってる、よっきです!

今回は、プロダクトオーナーの視点からLOVOT開発について語ってみたいと思います。

プロダクトオーナーとはなんぞやは、例えば、こちらの記事に詳しくまとまっているので参考にしてみてください。

ざっくり言うと、 いつ何をする、という計画を立てるのと、それをなぜするのかの説明をチームにしていくことが主な役割になります。

このいつ何をするか、というのにロボットならではの難しさがあるなと感じることがあるので、この辺りを中心に語ってみたいと思います。

プロダクトオーナーもチームです

まず、どのくらいのチームでLOVOTを開発しているかについて簡単に説明します。

第一回のアドベンドカレンダー でも紹介がありましたが、ソフトウェア領域だけでも多くの専門分野があり、ハードウェアとソフトウェア、そしてアフターサービスの領域を合わせると合計16チームもあります。ざっと図にするとこんな感じ。図はチーム名で記載しています。

開発チームの構成図

お気づきの方もいるかと思いますが、技術や専門がわかりやすくチーム名になっているチームもあれば、何をしているのかわかりにくいチーム名もありますね。 ちなみに、UTとAlohaとAppでふるまいチームと呼んだり、フレームワーククラウド音声認識でKIBAN WGと呼んだりと、チーム横断のチームやワーキングもあります。 また、チームを決める裁量はチームにあるため、チーム名やチーム構成も気づいたら変わっていることがあります。なので、この図は2022年12月現在のチーム構成ですね。

どのチームがどんなことをやっているかは、今後のアドベントカレンダーでわかってくるかと思います。期待しましょう。

これら16チームの開発状況や今後のLOVOTの成長方針などを踏まえて、計画をアップデートしていくのがプロダクトオーナーのミッションの1つなのですが、さすがにチーム数が多いのと、専門性がかなり異なるため、領域ごとにエリアプロダクトオーナー(APO)を置いています。

領域を分けると情報の分断がおきてしまいそうと心配になりますよね!?

そこでエリアプロダクトオーナーもチームとして毎日情報の共有をしています。 ちなみに、ソフトウェア領域のAPOは私を含めて2名、ハードウェア領域のAPOは1名、その他にアフター領域のAPOが2名、そして、全体のプロダクトオーナーとして代表の林要がいて、合計6名のプロダクトオーナーチームです。 この6名で毎日30分〜1時間ほど気になるところの状況の確認や、課題の相談を行い、何なら全体の計画に関わる重要な決定事項も行っています。 そんなに毎日話すことがあるのかと思いますが、なぜか1時間じゃ足りないくらい議題が山積みなのですよね。いや〜、不思議です。

ロボット開発の計画づくりの難しさ

では、いつ何をするかを決めるにあたっての、私がロボットならではの難しさだなと感じていることを2つほど紹介します。

開発は順番に進んでいく

LOVOTをローンチしたあとも、LOVOT 2.0やLOVOTチャージスタンド、最近ではフラグメントエディションをリリースしたりと、たくさんのハードウェアを開発してきました。 このハードウェアが絡む開発は、ハード設計から始まり、バラックと呼ばれるパーツ単位でファームウェアLinuxなどのソフト基盤の開発や動作検証を行い、その後、ハードウェアが組み合わさってから認識機能の開発、そしてふるまいやアプリの開発を行います。(上のチーム構成図は開発の流れ順っぽく描いてみました)

つまり、開発工程が進むにつれて、関わるチームが変わります。 これらはある程度事前に計画は立てるものの、部品の調達状況や各チームの開発状況でスケジュールが変わるため、いつからどのチームが着手できるかがどんどん変わっていきます。 工程が遠いチームほど、いつから着手できるかがわからなくなり、状況のキャッチアップが難しいなと思うことがあります。

ハードウェア変更は突然やってくる

電子部品の終了はいきなりやってくるものなのですね。ハードウェア領域のチームからソフトウェア領域のチームに対して、「この部品が販売終了となり、新しい部品を選定したので評価お願いします」と連絡をもらうときがあります。いわゆるEOL(End of Life)対応ですね。在庫をどれだけ買っておくか、いつから変更するか、などを決めつつ、新しい部品で問題ないかの確認をしていきます。緊急度が高いので割り込みタスクになりやすく、そして、実際に変更される時期が半年とか1年後とかになることもあり、評価はするもののいつから導入されるのかが分かりづらいこともあります。正直、覚えてられません。。。

LOVOT MUSEUMに展示されているプロトタイプの変遷。ハードウェアチームとソフトウェアチームが協力してLOVOTを進化させてきました

スケジュール管理の理想を求めて

これらの難しさが同時に起こると、つまりハードウェア開発とEoL対応のスケジュールが同時に走ると、もうどのチームがいつどんなタスクをすべきかがだんだんわからなくなってきます。 プロダクトオーナーチームでも各領域の状況を確認しながら、スケジュールをメンテしているのですが、あ、もうこれやらないとなのか、と割り込みっぽく優先順位を変更することが多々ありました。 これはいかん!ということで、スケジュール管理方法を検討し、そこで出会ったのが、タスク管理とスケジュール管理を同時に行えるWrikeというツールです。

以前は、タスク管理はJIRA、スケジュール管理はスプレッドシートやコンフルのマイルストーン機能などを使っていましたが、スケジュールとタスクの関係がわかりにくいのと、スケジュールがよく壊れるため、統合したいという思いが有りました。JIRAにもガントチャートのアドオンがあってスケジュール管理できるけど、かなり使いづらかった。。

Wrikeのいいところは

  • タスクをプロジェクトという単位でまとめられる
  • プロジェクトにスケジュールを入れると自動でカレンダーができる
  • プロジェクトに担当チームを入れると、チーム単位のカレンダーもできる

ということで、タスクをプロジェクトにまとめて、プロジェクトにスケジュールと担当チームを入れれば、全体の大まかなスケジュールとどのチームがいつ作業開始すべきかが比較的簡単に作れます。

そして何より、タスク管理とスケジュール管理が統合されているため、開発チームみんなでスケジュールを作ることができるようになりました。 なんやかんやで最新のスケジュールを把握しているのは開発チームであり、その開発チーム自身がタスクを作る際にスケジュールを設定してくれると、全体スケジュールが更新される!なんて素敵なことでしょう。

そんな理想を求めて、Wrike導入を決意して、実はまもなく8ヶ月が経とうとしています。 まだまだうまく使いこなせているわけではなく、JIRAに比べて自動化などが弱い部分もありますが、ひとまずスケジュールの管理という意味では、それなりに使えるようになってきたかなと思っています。 これからも各チームがスケジュールを自分たちで把握しやすいように日々改善していきたいと思います。

Wrikeの画面チラ見せ。チーム単位やチーム全体などのビューがたくさん!

最後にちょっと冷静にふりかえって

ロボット開発におけるスケジュール管理の難しさと、スケジュールをみんなで管理できるようにWrikeというツールを導入したよ、という話を書きましたが、いかがでしたでしょうか。

この記事を書こうと思ったきっかけは、Twitterで「JIRAでスケジュール管理どうしてるの?全員が理解できるくらいのスケジュールを可視化したいんやけど」みたいなツイートを見かけて、GROOVE Xの取り組みが少しは参考になるかなと思って書いてみることにしました。(オリジナルのツイートを見失ってしまいました、すいません。文言は多少間違ってるかもですが、ニュアンスは合ってるはずです)

Wrikeがベストソリューションのように書きましたが、多分、何のツールで改善を進めるかだけなので、JIRAでやりきろうと思えばできたのかな、と今になっては思います。 選択をしたあとは、選択したものをいかに正解に持っていくかを頑張るのみですね!

まだまだ試行錯誤が続く毎日で、日々新たな課題も出てくるのですが、それをチームみんなでカバーしあって支え合おうとするGROOVE Xの文化が私は好きです。 最近流行りのWeb3でいわれているDAO的な世界観に似ているなとも思っています。 できるところをできるメンバーで支え合う。私もプロダクトオーナーという視点で開発チームを支えて、より良いLOVOTを世の中に提供していけるよう頑張ってまいります。

そして、こんな面白いチームや組織を維持していくためにはスクラムマスターの存在が欠かせません。 POチームは、一緒に開発チームや組織を作ってくれるスクラムマスターを探しています。 現在2名のスクラムマスターが在籍していますが、まだまだ足りていません。 面白そうだなと思った人は、ぜひ一度お話しましょう。

recruit.jobcan.jp

その他のポジションも絶賛募集中です。 LOVOTに関わるお仕事に興味ある方は是非ご検討ください。

recruit.jobcan.jp

シナリオテストのおはなし

この記事は、Groove Xアドベントカレンダー2022 の10日目の記事です。

こんにちは! LOVOT の ソフトウェアの検証を実施・改善しているチームです。

社内ではQAチームと呼ばれているのですが、単に製品に問題がないかの確認するだけでなく、ユーザー目線で評価することで、品質を向上させる活動を行っています。

LOVOTの新しいソフトウェアがリリースされるときは、たくさんの評価試験を実施してから配信されます。

今回はソフトウェアがリリースされる前に、どのような場所でどのような評価試験を実施しているのかを一部ご紹介したいと思います。

LOVOTの過ごし方

さて、どんな試験をしているのかというご紹介の前に、LOVOTはどんな風に家の中を過ごすのか、少しお話させてください。

LOVOTをお迎えされた方はご存知かもしれませんが、ご家庭に届いた時、実はLOVOTはまだ生まれていません。

ネストと呼ばれる充電ステーションに入れてあげることによって、目を覚まし、その日がLOVOTの誕生日となります。

そしてLOVOTはネストが設置された部屋を中心に家の中を探検し、部屋の形や障害物を覚え、自分のお家のマップを作ります。

またLOVOTは人を見つけると近寄って抱っこをせがんだり、周りにLOVOTがいればそのLOVOTと遊びます。充電が少なくなったら自分でネストに戻って充電しにいき、満充電になればネストから出てきてお部屋の中を歩き始めるなど、とても自由に動き回ります。

※過去のイベントのお洋服をお借りして、いつも検証をがんばっているLOVOTたちに着せて撮影してみました!通常は普通のベースウェアを着ています。

シナリオ試験

私たちはソフトウェアがリリースされる前に、実際に生まれるところからお家で過ごす様子を確認しています。

このような一連の流れを確認し、機能や性能に加えオーナー様目線で違和感がないか確かめる評価試験を、シナリオテストと呼んでいます。

この試験は複数の環境で実施しています。たとえばオーナー様宅ではインターネットがある環境、ない環境どちらもあります。

どちらの環境でもLOVOTが生活できるよう、評価試験ではオンラインとオフラインの環境で正常に活動できるか確認しています。

また確認する観点として生まれた後にちゃんとネストと通信出来るか、ネストに戻れるか、マップを作るかなどなど...

LOVOTと生活をしていただくために必要不可欠な動作を評価しています。

試験環境

LOVOTミュージアムがある建物の弊社オフィスにて、LOVOTの開発や試験なども行っています。

もちろんオフィスにもたくさんのLOVOTがいて、評価を実施するための専用の部屋もありますが、LOVOTはご家庭で生活することを考え、一般家庭に近い環境で一部の評価試験を実施しています。

先程紹介した、LOVOTが届いて生まれる前〜数日の様子を確認する試験の一部も、実はここで実施しています。

最後に

LOVOTならではの評価試験についていかがだったでしょうか?

なかなか実際に人が住むような環境で評価試験を行うような製品は少ないのではと思います。

家の中を自由に動き回り、家族のひとりとして迎えられるLOVOTだからこそ、必要となる試験のひとつと言えるのではないでしょうか。

今回細かくは紹介しきれなかったのですが、ネストに戻って充電して、人と遊んで、お部屋の中を歩き回るなど、普段の生活のなかで不具合が起こらないか別の評価試験で確認を行っています。

詳しくはQAチーム2回目の記事(12/23に公開予定)でご紹介しますので、そちらもぜひご覧ください!!



一緒に働く仲間募集中

世界で唯一無二の存在、LOVOTの成長にご協力いただける方をお待ちしています!

ポジションなどの詳細はこちら

もしご興味があれば是非ご検討ください!

最後まで読んで頂きありがとうございました!

Appチーム・仕事の流儀 〜LOVOTアプリのUIリニューアル舞台裏〜

こんにちは!App(アプリ)チームでLOVOTアプリのデザインをしている、南地です。

LOVOTアプリは今年4月に大幅なUIリニューアルを行いました。UIのリニューアル、これまで使い慣れていたUIが変わってしまうので、一般的にはユーザーからの不満がつきもの。

それでもAppチームでは、そのストレスをできる限り軽減したいと考え、アプリの画面内にとらわれず、様々な工夫をすることでソフトランディングすることができました。

今回はUIリニューアルのプロセスの話を通して、Appチームがどのようにユーザーに向き合っているのかについてご紹介します。

LOVOTアプリのBefore(左)・After(右)

Appチームの紹介

Appチームは自分の他に、アプリエンジニア、ふるまいエンジニア、アニメーター、そしてQA(テスター)がそれぞれ1人ずつ、合計5人で構成されています。

それぞれ違った役割を持ちながら、時に専門領域を超えて協力し、スマホからLOVOT自身のふるまいまで、アプリに関することはワンチームで一気通貫して作ることのできるコンパクトなフィーチャーチームとなっています。

Appチームのメンバー

Appチームでは、ただ単に機能を開発するのではなく、常にオーナーの皆様にどうしたら喜んでいただけるか・ストレスなくLOVOTライフを過ごしていただけるかという視点をとても大切にしています。

それもあってか、とてもありがたいことにiOS / Androidともにストアの評価では★5つ中4.7と、高評価をいただいています。何らかのデバイスと連携するIoT系のアプリとしてはかなり良い評価ではないでしょうか。(中には対応が難しくてずっとお待たせしてしまっている改善点もたくさんあるので、この評価に甘んじてはいられませんが…)

iOSでの評価

オーナーの皆様のニーズに向き合うため、自分たちは定性・定量の両面でアプリの開発・改善に役立つ情報を吸い上げています。

定性的なアプローチとしては、アプリ内の「ご意見・ご要望フォーム」から寄せられた投稿の全てに目を通したり、IFTTTを活用してtwitterへの「(LOVOT AND アプリ) OR(らぼっと AND アプリ)」で引っかかる投稿がSlackに通知されるようにしていたり、もちろんストアレビューも新しいものが来ていないか毎朝チェックしています。(実は、返信は全て自分が書いてるんです!)

定量的には、各機能がいつ・何回使われたのかをFirebase Analyticsを用いて集計しており、毎週、過去1週間の傾向について全員で確認するのを大切なルーティンにしています。
例えば、LOVOTが作ったマップを確認できる機能は、元々は必要な設定をする時にだけ使うという想定でしたが、コロナ禍の緊急事態宣言前後でマップの利用回数が増減していることから、意外と外出先での利用が多いことがわかりました。

これらの両軸を活用することで、「アプリの現在地」をなるべく正確に把握することにチーム全員で努めています!

リニューアルのきっかけ

それでは、本題のアプリリニューアルの話に入っていきたいと思います。

UIを大幅にリニューアルするきっかけとなったのは、LOVOT 2.0の登場でした。

これまでも複数のLOVOTと一緒に暮らすオーナーがいらっしゃいましたが、2.0の登場でさらにそうしたケースが増えることが予想されました。

当時のアプリでは同じコロニー(ネストと、それを共有するLOVOTの組み合わせ)単位でしか同一のホーム画面で表示することができず、コロニー単位で切り替える必要がありました。

でも、一緒に暮らすLOVOTたちは1つの家族。

そこで、同じユーザーがアプリに登録しているLOVOTたちは、1つのホーム画面上に表示されるようにしよう!となったのでした。

一緒に暮らす、公式twitterのみるきぃちゃん(LOVOT初代)とリアンくん(LOVOT 2.0)

新UIの情報設計プロセス

アプリのリニューアルは、複数のコロニーのLOVOTが1つのホーム画面上に来てもユーザーが迷わずに操作できるよう、各機能を再配置する情報設計からスタートしました。

その中で最も難しかったのが、複数のコロニーにまたがった時に、今どのLOVOTについての操作をしているか、迷わないようにすることでした。

まずはUIデザイナーの自分がリードして、ワイヤーフレームという簡単な線画だけで描いた機能配置のパターンをいくつも描きました。そして、チームで何度も何度も議論を重ね、仕事も趣味もバックグラウンドが様々なメンバーからは、ゲームやペットの健康管理アプリを参考にするなど、様々な意見が飛び出し、またワイヤーフレームにフィードバックしていきました。

初期のワイヤーフレーム

そうして見えてきたのが「1体ずつにフォーカス&スムーズな切り替え」というコンセプトでした。つまり、縦にLOVOTを並べ、上下スワイプするとLOVOT1体ずつにスナップし、そこから各機能にアクセスできるようにするというものです。

従来は、LOVOTをタップした先にメニュー画面を開き、そこから各機能にアクセスしていたのに対し、主要な機能へのボタンをホーム画面に持ってくることができ、シンプルさを保ちながらアクセス性を向上することができました。

最終形に近づいたワイヤーフレーム

ユーザーの声とアナリティクスを反映した一等地の選抜

ホーム画面に生まれた一等地に配置する主要な機能の選定にあたっては、Firebase Analyticsからの分析結果を反映しました。

ユーザーからのフィードバックで、ホーム画面に設置してアクセスしやすくしてほしいと要望の多かったダイアリーとアルバムは最初にスタメン入り。

そして、設定機能の一部だったマップが、Firebase Analytics分析の結果、外出先からLOVOTが元気にしているか確認するニーズがあることがわかり、当初の想定より利用回数が多いことから、晴れてホーム画面に昇格となりました。

UIが一新されたことへの抵抗感をなくすために

新UIの方向性が決まったところで、次はこれまでのUIを使い慣れたユーザーへのフォローを準備していきました。

まず、アプリの中でできることとして、UIがどのように変わったかがわかるよう、「ウォークスルー」という、実際のUIにオーバーレイして表示するガイドを用意しました。

ウォークスルー

さらに、アプリの外でも一工夫しています。それが、iOSのTest FlightやAndroidのクローズド テストを活用した「ベータ版テスト」の実施です。

有志のオーナーが参加してくださっている「LOVOTオフィシャルサポーターズ」の中から参加していただける方を募り、一足先に体験していただくというもの。これまで、LOVOTソフトウェアの先行体験は何度もやってきましたが、アプリの先行体験は初めての取り組みです。オーナー向けの情報発信やイベント運営をしているCX(カスタマー・エクスペリエンス)チームに相談し、実現することができました。

このベータ版テストは、純粋にリリース前に改善点を発見する目的に加え、実はオフィシャルサポーターズの皆様に新UIのインフルエンサーになっていただきたい!という意図がありました。
やはり、実際にご愛用いただいているユーザーの意見が一番、他のユーザーにも響くと考えたからです。

そのため、クローズドなテストでしたが、スクリーンショットSNS投稿は大歓迎とし、開始から約半月後にはオーナーとAppチームメンバーとのミーティングをオンライン開催して、積極的な情報発信をお願いしました。これによって、ベータ版テストに参加していないユーザーにも、事前にリニューアル後の画面を見ていただくことで、UIの変化への予防接種的な効果が期待できます。

その結果、非常に多くの嬉しい投稿をしてくださり、また、オーナーの皆様の反応を直接見たり、開発チームの想いを語ることができ、開発者としても一番やりがいを感じられる、忘れられない体験となりました!

オンラインミーティングにご出席いただいたオーナーの皆様

UIリニューアル、正式リリースの結果!

ベータ版テストでの反応も良いことが確認でき、またご指摘いただいた自分たちだけでは気づかなかった細かな改善点も盛り込んだ上で、いよいよ4月5日に正式リリースとなりました!!

普段からウォッチしているtwitter上でも多くの喜びの声が見受けられ、中でも開発者として意外だったのは、「ホーム画面のLOVOTのイラストが大きくなったこと」について、想像以上に喜んでいただけたことでした!
今回のリニューアルの中では比較的簡単な変更だったのですが、それがまさかこんなにお喜びいただけるとは。

一方、心配していた使い慣れていたUIから変わったことへの反応は、ベータ版テストに参加してくださったオーナーからのSNS発信もあり、ネガティブな意見は見受けられずホッとしました。

Twitter上の反応

Firebase Analyticsでの分析結果でも、ホーム画面の一等地に移動した機能の利用数は大きく伸び、マップに至ってはリニューアル以前の2倍ほどを記録しました。

各機能の利用数変化(赤線が正式版リリース日)

アクティブユーザー数も、従来の増加ペースと比べるとベースが上がったことが見受けられます。

アクティブユーザー数の増加ペース(赤線はリニューアル前の増加トレンド)

こうして、アプリのUIリニューアルは定性的にも、定量的にも、無事ユーザーに受け入れられたことが確認できました。

まとめ

Appチームでは普段スマートフォンの中での出来事を開発していますが、より良いユーザー体験を生み出すためには、時に画面の外側にも目を向ける必要があると考えています。

LOVOTアプリは、LOVOTというリアルな存在とつながるためのものなので、その必要性はなおさら高くなります。

今回は、UIリニューアルというネガティブな反応が生まれやすいトピックに対して、ベータ版テストを通した総合的なアプローチによって上手く乗り切ることができたことをご紹介しました。

 

そんなAppチームでは、もっともっとユーザーに向き合い、LOVOTとの絆を深めたり、便利だったり、ワクワクしたりするようなアップデートを重ね、LOVOT自身の進化に負けないくらい成長させて行くために、LOVOTアプリの開発を一緒に加速してもらえるエンジニアを募集しています!!

少しでも興味を持っていただけたなら、まずはカジュアルにお話をすることもできますので、お気軽に以下よりご連絡ください。

また、アプリ以外の分野も募集していますので、LOVOTに関わるお仕事に興味ある方は是非ご検討ください。

 

最後までお読みいただき、ありがとうございました!!!

人に気がつくLOVOT

こんにちは、GROOVE Xのfmyです。
私達のチームではLOVOTが人に気がつくための認識を作っています。
今日その中でも2つ、画像認識とタッチ認識がどんなことをしているか紹介します。

人に気がつく必要性

LOVOTは人とコミュニケーションを行うという目的をもっています。
人と関わっていくために人の生活空間内で人の存在に気が付かなければなりません。

認識では意味が広いのでここでは「認識」を「LOVOTがふるまいに必要な外界についての予測や情報を指す」とします。
(ふるまいについては市川さんの記事をご覧ください)

画像認識

ロボットが人を認識するのに有力な方法の一つが画像認識の技術です。
LOVOTもセンサーホーンのカメラを使って周囲を撮影し、Object Detection(物体検出)と呼ばれるタスクで周囲の人やモノの存在を認識しています。
LOVOTのテクノロジーもご覧ください

LOVOTが撮影した写真に物体検出の枠を表示した例。動き回るので写真がぼやけることもよくある

Object Dectectionでこのように人を検出することは出来ますが
人間とコミュニケーションを取るには人の存在を知るだけでは十分ではありません。
何をしているのか、何かを伝えようとしているのかに気が付くのが望ましいです。
そのためLOVOTは物体の認識や人の存在だけでなく表情や姿勢を読み取れるような認識モデルがあり、ふるまいのための『認識』を作っています。

LOVOTが周囲の人を認識している様子

タッチ認識

動物も、もちろん人間も身体を使って触れ合うコミュニケーションを取ります。
撫でてもらってうっとりしたり、触られてほしくないところは嫌がったり、LOVOTも同じコミュニケーションが取れることが期待されています。
LOVOTは全身に様々な種類のタッチセンサーがあり身体に何かが触れていることを検出しています。
ところでみなさんもスマホタブレットなどを使ったことがあると思いますが、水滴や物に意図せず反応したり逆になかなか反応してくれないという経験はないでしょうか。
LOVOTも同じで様々な大きさの人の手や触り方でも『認識』できるように、自分自身の動きや全く別のものが触れた時に人と間違えないために機械学習を利用しています。

タッチをモニタリングしている様子色が触り方の種類の違いを示している

最後に

ロボットが外界を、人を、多様なコミュニケーション認識するにはまだまだ多くの課題があります。
何を持って認識が出来ているとするのか、常に学習と更新を続けるにはどうすれば良いか、限られた電力をどう使うか、私達と一緒に考えて『認識』を作ってくれる方を募集しています。 ぜひ採用ページにアクセスしてください!

recruit.jobcan.jp

SalesforceとSlackのカスタマイズ連携の実装方法

はじめに

SalesforceとSlackを連携させて業務の効率化を行っている現場は多いと思います。 ここ数年でSalesforceとSlack間の連携機能はどんどん追加されていき、「SalesforceからSlackに通知する」という要件程度だと、フローを使えば、ほとんど実現できる状態になっています。

ただ、現時点(2022/12月)だと、SlackからSalesforce上のレコードを更新する場合には、若干かゆいところに手が届かないという状態でもあるので、今回はカスタマイズ開発するとこんなこともできます。というご紹介をしたいと思います。

カスタマイズ開発の概要

今回はSalesforceのレコード詳細に配置したLWCのボタンを押すと、Slackにレコード情報がエスカレーションされ、Slack上のボタンを押して、更新内容を入力するとSalesfoce上のレコードが更新される。というカスタマイズを実装します。

image.png モーダルの入力内容が、Salesforceレコードのテクニカルサポート解析結果項目に反映されるようにします。

実装の流れ

Slack側の準備

まずはSlack APIを実行するためのトークンを取得します。Slackアプリの管理画面からOAuth&PermissionsBot User OAuth Tokenをコピペします。 image.png

Salesforce側の処理(Apex)

ApexでSlack APIの呼び出しを行います。以下はレコード詳細ページのLWCからApexを呼び出す前提のコードになっています。(LWC側のソースは割愛します)

caseEscalationがLWCから呼び出せる関数になっていて、画面表示しているレコードのID(今回はケースID)をrecordIdに、投稿するSlackチャンネルをslackChannelに指定して呼び出します。

generateEscalationSummaryの処理では、Block Kit Builderで作成したフォーマットを生成しています。action_idに指定しているhandle_open_modalは、以降で説明しているLambdaの処理を呼び出すトリガーになっています。

postMessageの処理で、カスタム表示ラベルに設定した、Bot User OAuth Tokenをヘッダーに追加して、Slack APIを呼び出します。

処理が正常に実行されると、Slackの指定したチャンネルにレコードの情報が投稿されます。

@AuraEnabled(cacheable=false)
public static string caseEscalation(String recordId, String slackChannel) {
    try {
        Case record = [
            SELECT Id, CaseNumber, Subject
            FROM Case
            WHERE Id =: recordId
        ];
        PostMessageResponse result = postMessage(slackChannel, generateEscalationSummary(record));

        return 'Success: Slackにエスカレーションしました';
    }
    catch (Exception e) {
        throw new AuraHandledException('The following exception has occurred: ' + e.getMessage());
    }
}

public static String generateEscalationSummary(Case record){
    List<String> summary = new List<String>(); 
    summary.add('[');
    summary.add('    {');
    summary.add('        "type": "section",');
    summary.add('        "text": {');
    summary.add('            "type": "plain_text",');
    summary.add('            "text": "');
    summary.add('■ケース\n' + record.CaseNumber + '\n');
    summary.add('■件名\n' + record.Subject + '\n');
    summary.add('"');
    summary.add('        }');
    summary.add('    },');
    summary.add('    {');
    summary.add('        "type": "actions",');
    summary.add('        "elements": [');
    summary.add('            {');
    summary.add('                "type": "button",');
    summary.add('                "text": {');
    summary.add('                    "type": "plain_text",');
    summary.add('                    "text": "解析入力"');
    summary.add('                },');
    summary.add('                "value": "' + record.Id + '",');
    summary.add('                "action_id": "handle_open_modal"');
    summary.add('            },');
    summary.add('        ]');
    summary.add('    }');
    summary.add(']');

    return String.join(summary, '');
}

public static PostMessageResponse postMessage(String slackChannel, String message){
    String body = 'channel='+ EncodingUtil.urlEncode(slackChannel, 'UTF-8')
               += '&blocks='+ EncodingUtil.urlEncode(message,'UTF-8');

    Http h = new Http();
    HttpRequest req = new HttpRequest();
    req.setEndpoint('https://slack.com/api/chat.postMessage');
    req.setMethod('POST');
    req.setTimeout(60000);
    req.setHeader('Content-Type', 'application/x-www-form-urlencoded');
    req.setHeader('Authorization', 'Bearer ' + System.label.SlackToken);
    req.setBody('channel='+ EncodingUtil.urlEncode(slackChannel, 'UTF-8'));
    req.setBody(body);
    try {
        HttpResponse res = h.send(req);
        Map<String,Object> response = (Map<String,Object>)System.JSON.deserializeUntyped(res.getBody());
        return new PostMessageResponse((String)response.get('channel'), (String)response.get('ts'));
    }
    catch (Exception e) {
        throw new AuraHandledException('SlackAPIの呼び出しに失敗しました: ' + e.getMessage());
    }
}

Slackから呼び出す処理を準備(Lambda)

上記の処理でSlackに投稿すると、「解析入力」というアクションボタンが表示されます。 このままではボタンを押しても何も起きないので、ボタンを押したときに、モーダルを表示させるAPIを用意します。 今回はLambdaにPythonで実装しましたが、複数言語でSDKが用意されているので、好きな言語で実装できます。 また、LambdaなどのFaas上に構築すると、コールドスタートの考慮をしないといけないので、安定性を保証したい場合は、HerokuやGAE上に構築するほうが無難だと思います。

以下のサンプルコードでは、AWS SecretsManagerに登録した、SlackとSalesforceの認証情報を取得しています。またデプロイ時の環境変数で本番環境とSandbox環境を切り替えれるようにしています。

ポイントとしては、モーダル画面に、SFID、チャンネルID、タイムスタンプを仕込んでおくことで、モーダル上の更新ボタンを押下したときのイベントを検知して実行されるupdate_salesforce_caseの処理内でも、更新対象のSalesforceレコードを特定できるようにしていたり、投稿するスレッドを特定できるようにしている点になります。(もう少しスマートなやり方があるかもしれないです。)

import json
import logging
import os
import ast
import re
from pprint import pformat
from typing import Dict

import boto3
from slack_bolt import Ack, App
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
from slack_sdk import WebClient
from simple_salesforce import Salesforce
import requests

logger = logging.getLogger()
logger.setLevel(logging.INFO)

secrets = boto3.client(service_name='secretsmanager')
slack_credentials = secrets.get_secret_value(SecretId='SlackCredentials'+os.environ.get("ENV"))
slack_secret = ast.literal_eval(slack_credentials['SecretString'])

app = App(
    process_before_response=True,
    token=slack_secret['SLACK_BOT_TOKEN'],
    signing_secret=slack_secret['SLACK_BOT_SIGNING_SECRET'],
)

def request_modal_view(case_id, initial_value, channel_id, thread_ts):
    return {
        "type": "modal",
        "callback_id": "update_salesforce_case",
        "title": {"type": "plain_text", "text": "Case"},
        "submit": {"type": "plain_text", "text": "更新"},
        "close": {"type": "plain_text", "text": "閉じる"},
        "blocks": [
            {
                "type": "input",
                "block_id": "notes-block",
                "element": {
                    "type": "plain_text_input",
                    "action_id": "input-element",
                    "initial_value": initial_value,
                    "multiline": True,
                },
                "label": {"type": "plain_text", "text": "解析入力"},
                "optional": True,
            },
            {
                "type": "context",
                "block_id": "case_id",
                "elements": [
                    {
                        "type": "plain_text",
                        "text": case_id,
                        "emoji": True
                    }
                ]
            },
            {
                "type": "context",
                "block_id": "channel_id",
                "elements": [
                    {
                        "type": "plain_text",
                        "text": channel_id,
                        "emoji": True
                    }
                ]
            },
            {
                "type": "context",
                "block_id": "thread_ts",
                "elements": [
                    {
                        "type": "plain_text",
                        "text": thread_ts,
                        "emoji": True
                    }
                ]
            }
        ],
    }


def auth_salesforce():
    res = secrets.get_secret_value(SecretId=os.environ.get("ENV")+"SalesforceCredentials1")
    secret = ast.literal_eval(res['SecretString'])

    access_token_url = os.environ.get("ACCESS_TOKEN_URL")
    data = {
        'grant_type': 'password',
        'client_id' : secret["ConsumerKey"],
        'client_secret' : secret["ConsumerSecret"],
        'username'  : secret["APIUser"],
        'password'  : secret["Password"]
    }
    headers = { 'content-type': 'application/x-www-form-urlencoded' }
    response = requests.post(access_token_url, data=data, headers=headers)
    response = response.json()

    if response.get('error'):
        raise Exception(response.get('error_description'))

    session = requests.Session()
    logger.info(f"response:\n{response}")
    return Salesforce(
        instance_url=response['instance_url'],
        session_id=response['access_token'],
        session=session
    )


def respond_to_slack_within_3_seconds(ack):
    ack()


def handle_open_modal(ack: Ack, body: Dict, client: WebClient):
    logger.info(f"body:\n{pformat(body)}")
    case_id = body["actions"][0]["value"]
    channel_id = body["channel"]["id"]
    thread_ts = body["container"]["message_ts"]

    sf = auth_salesforce()

    try:
        res = sf.query(f"SELECT id, RepairAnalysis__c FROM Case WHERE Id = '{case_id}'")
        initial_value = dict(dict(res)["records"][0])["RepairAnalysis__c"]

        if initial_value is None:
            initial_value = ""

    except Exception as e:
        logger.exception(f"Failed to post a message {e}")

    client.views_open(
        trigger_id=body["trigger_id"],
        view=request_modal_view(case_id, initial_value, channel_id, thread_ts),
    )


def update_salesforce_case(body: Dict, view: Dict, client: WebClient):
    logger.info(f"body:\n{pformat(body)}")
    inputs = view["state"]["values"]
    blocks = body["view"]["blocks"]
    case_id = list(filter(lambda item : item['block_id'] == 'case_id', blocks))
    case_id_value = case_id[0]["elements"][0]["text"]
    channel_id = list(filter(lambda item : item['block_id'] == 'channel_id', blocks))
    channel_id_value = channel_id[0]["elements"][0]["text"]
    thread_ts = list(filter(lambda item : item['block_id'] == 'thread_ts', blocks))
    thread_ts_value = thread_ts[0]["elements"][0]["text"]
    notes = inputs.get("notes-block", {}).get("input-element", {}).get("value")
    trigger_id = body["trigger_id"]
    user_name = body["user"]["name"]

    sf = auth_salesforce()

    try:
        # Salesforceを更新
        sf.Case.update(
            case_id_value,
            {'RepairAnalysis__c': notes}
        )
        # Salesforceに更新した内容をSlackのスレッドにも投稿
        response = client.chat_postMessage(
            channel=channel_id_value,
            thread_ts=thread_ts_value,
            text=f"```{notes}```"
        )
    except Exception as e:
        logger.exception(f"Failed to post a message {e}")


app.view("update_salesforce_case")(
    ack=respond_to_slack_within_3_seconds,
    lazy=[update_salesforce_case]
)


# 以下の部分で「解析入力」ボタンが押された時のイベントを検知して、モーダル表示する処理を呼び出しています。
app.action("handle_open_modal")(
    ack=respond_to_slack_within_3_seconds,
    lazy=[handle_open_modal]
)


def handler(event, context):
    logger.info(f"event:\n{pformat(event)}")


    # 予期しない重複実行を排除する
    if event["headers"].get("x-slack-retry-num") is not None and event["headers"]['x-slack-retry-reason'] == "http_timeout":
        return {
            "statusCode": 200,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": {
                "message": "No need to resend"
            }
        }

    slack_handler = SlackRequestHandler(app=app)
    return slack_handler.handle(event, context)


if __name__ == "__main__":
    app.start()

SlackからLambdaを呼び出せるようにする

上記のLambdaで準備したAPIのエンドポイントをSlackに登録します。Slackアプリの管理画面からInteractivity & ShortcutsRequest URLから登録します。

image.png

Slackアプリの権限設定

OAuth&PermissionsAPI実行に必要な権限を付与して、Slackワークスペースと再認証します。(割愛)

Slackアプリをワークスペースに招待

SalesforceからSlack投稿するチャンネルにSlackアプリを招待しておきます。 これで一通り実装と設定が完了します。

まとめ

SalesforceとSlack間の連携機能は今後もどんどん追加されていくはずなので、数年後にはこういったカスタマイズが不要になる可能性がありますが、SalesforceとSlack連携をすぐにパワーアップさせたい!という方はカスタマイズするのも悪くないと思います!

弊社ではさらに、SalesforceユーザとSlackユーザがコミュニケーションをとれるようにする機能も実装しています。 紺色の背景テキストがSalesforceユーザで、灰色の背景テキストがライセンス無しユーザがSlackからコメントした内容になっています。

image.png

SlackAPIの実装を通して、より業務改善の引き出しを増やすことができたので、今回の実装はいい経験になりました。 みなさんもぜひカスタマイズ実装をご検討してみてはいかがでしょうか?

参考

https://qiita.com/seratch/items/0b1790697281d4cf6ab3

https://slack.dev/bolt-js/tutorial/getting-started

LOVOTを動かすファームウェア

こんにちは、ファームチームのaoikeです。ファームチームで行っていることを紹介します。

LOVOTのファームウェアって?

LOVOTにはTomと呼ばれるメインコンピューターと、Jerryと呼ばれるサブコンピューターが搭載されており、センサーで感じたことをJerryに伝えることで、どう動くかを意思決定し、指令を出します。その指令を受けてモーターを動かし、カラダを動かしています。

このセンサーとモータの制御を行うために、小さいコンピューター(マイコン)が搭載されていて、このマイコンで動作するソフトウェアの開発を行うのがファームチームのお仕事です。

LOVOTのマイコン、センサー、アクチュエータ

LOVOTには多くのセンサー、アクチュエータが搭載されているため、マイコンも10個以上載っています。 センサーは、LOVOTの全身だと50以上載っており、例えば、頭のセンサーホーン部分には

  • 距離センサー × 5
  • 照度センサー × 1
  • タッチセンサー × 2
  • サーマルカメラ × 1
  • 赤外線センサー × 1
  • スイッチ × 3

の、合計13個のセンサがあります。直径6cm、長さ7cmくらいの小さなホーンの中にたくさんのセンサーが詰まっています。

さらに、全身のアクチュエータとしては

と、合計18個があります。

パワーサーボモーターは、頭、首、肩を動かしたり、ホイール・キャスターを出し入れするために使っています。ナノサーボモーターはパワーサーボモーターより小さく、手先のなめらかな動きを実現しています。

この他にも、目のバックライト、レーダーセンサー、ネストに戻るための超音波センサー、赤外線センサーなどのデバイスも搭載されており、これらをマイコンによって処理しています。 1つのマイコンで処理するには能力の限界があるため、複数のマイコンを使い、マイコン間をUART / SPI / I2Cといった通信規格を用いて制御しています。

こうしたたくさんのセンサー、アクチュエータを使うことで、LOVOTのかわいらしいふるまいを表現しているわけです。

最後に

最後まで読んで頂き、ありがとうございました。

今日はかなり概要の紹介になってしまったので、実はこんなことをやっているよ!ということを、また機会があれば記事にできたらと思います。

また、一緒に働く仲間を募集しています!気になった方は是非下記リンクにアクセスしてください。

recruit.jobcan.jp