astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

AI社員のつくり方

フロントエンドエンジニアをしているkitoです。
今回は、Slackにいると便利なAI社員ことチャットボットについて書きたいと思います。
事の発端は、メルカリさんの「AI社員」が入社! 翻訳から日々の業務サポートまで大活躍中というブログのエントリーを読んだことです。Dialogflowを知らなかったこともあり、非常に興味深い内容で、弊社にもAI社員がいたら便利なのでは? と漠然と思うようになりました。
そして実際、HISASHIくんにインスパイアされたAI社員をつくってみたので、その概要を書きたいと思います。

Dialogflow

Dialogflowは、Googleが運用開発している自然言語解析プラットフォームです。自然言語処理を丸投げできるので、対話型botをつくる際には本当に有り難いサービスです。これなしで気軽にチャットボットはつくれないでしょう。しかも、Standard Editionの範囲なら無料で利用できます。今回、こちらのサービスを利用します。

さて、社内のヘルプデスク業務などを行うAI社員に必要な機能として最低限下記が必要かなと思います。

  • 気軽に話かけられる
  • メンションつきの質問に答えられる
  • 正しい答えを学習させられる

まず、気軽に話しかけるか否かですが、あまり難しく考えても仕方がないので、HISASHIくん同様いらすとやのイラストをアイコンにして、mizekoという名前にしました。(弊社、アスタミューゼなので。。)

f:id:astamuse:20191212111053p:plain

DialogflowとSlackの連携

メンションつきの質問に答えられるようにするのは、予想外に骨が折れました。
Dialogflowには、Slack、Facebookなどと簡単に連携できるintegrationsという機能があり、当初これを利用すれば簡単にAI社員ができるのではと思っていました。

しかし、実際には、@mizekoのようなメンションつきのテキストに反応するだけではなくて、あらゆるテキストに反応してしまい、チャットの邪魔になってしまいました。通常のチャットボットのユースケースならば発言する人が一人いて、それに随時反応すれば良いのかもしれませんが、会社のSlackで使う場合、様々な人がチャットに参加しているので、メンションつきもしくは特定のテキストにだけ反応して応答するようにしなければなりません。
これを解決するために、integrationsを使うのではなく、すべてAPIを通じて受け答えするように変更しました。
SlackのAPIでパブリックなチャンネルのテキストを取得し、@mizekoのメンションがついていた場合、DialogflowのAPIにテキストを投げ、解析の結果をSlackのAPIを通じて該当チャンネルに投稿するという流れです。

SlackのAPI

まず、SlackのEvent APIでパブリックなチャンネルのメッセージを待ち受けて、メッセージに@mizekoのメンションがあれば、DialogflowのAPIを叩く必要があるので、Request URLを待ち受けるサーバーが必要になります。
開発中や手元で試してみるなら、ngrokを使うのが簡単でしょう。本格的に試すならfirebaseが第一選択として考えられます。ただし、外部APIを呼ぶ場合は、有料プランのFlameかプランBlazeプランにする必要がある点注意です。Blazeプランの無料枠があるので、使い方によっては良い選択肢だと思います。私は、重量課金が少し気になったので他で用意しました。

さて、肝心のSlackのEvent APIですが、まずこちらでCreate New APPから新しいAPPを作成します。
OAuth & PermissionsにあるOAuth Access Tokenを控えておきましょう。 f:id:astamuse:20191212111130p:plain 次に、Event Subscriptionsの「Subscribe to workspace events」でSubscribeするEventからmessage.channelsを選びます。
f:id:astamuse:20191212111154p:plain 同じページの一番上にあるEnable EventのスイッチをONにして、Event Subscriptionsから、Request URLを指定します。
私の場合、Express.jsで下記のようなコードでRequest URLをSubscribeしました。

const express = require("express");
const app = express();
const bodyParser = require("body-parser");
app.use(bodyParser.json());

app.post("/api/channels.history", function(req, res) {
    res.setHeader("Content-Type", "text/plain");
    res.send(req.body.challenge);

    //ここでDialogflowの処理

});

app.listen(process.env.PORT || 8080);

channels.historyのレスポンスは、公式のSlack APIを参考にしてください。

DialogflowのAPI

Dialogflowは、先にも書いたように自然言語解析プラットフォームでIntentというユーザーのリクエスト単位で管理されています。「今日の東京の天気教えて?」というユーザリクエストに、「東京は今日晴れです」と答えるのがひとつのIntentになります。
今回のAI社員には、新規Intentを覚えさせ、またIntentをUpdateして再学習させなければなりません。
要は、Slackのメッセージに応じて、DialogflowのAPIでIntentをCreateしたりUpdateしたりすることになるのですが、例えばAI社員が間違った回答をしたとき何を基準にIntentをUpdateすれば良いのでしょうか?

メルカリさんのHISASHIくん場合、ブログのスクリーンショットから察すると、ある特定のチャンネルでの質問に対して、HISASHIくんの回答にスレッド形式で:hisashi:という絵文字をつけると正しい答えとしてみなして、後で?Updateしているようでした。

このスレッド形式での正しい答えを絵文字つきで教える方式は、クレバーなやり方だと思ったので、弊社のmizekoでも採用しました。
ただ、特定のチャンネルではなくすべてのチャンネルで、かつリアルタイムで学習して欲しかったので、APIでIntentをCreateもしくはUpdateするようにしました。SlackのAPIの設定で、Event Subscriptionsを「Subscribe to workspace events」で設定したのは、すべてのパブリックなチャンネルで、AI社員mizekoに質問でき学習させるためです。

DialogflowのAPIに関しては、公式のリファレンスが参考になります。

公式のサンプルをそのまま使えるので、最初の設定さえ間違えなければそれほど迷わないと思います。 ポイントとしては、detectTextIntentでintentの検出を行い、帰って来た結果をSlackで返信するところです。

function detectTextIntent(projectId, sessionId, queries, languageCode) {
  const dialogflow = require('dialogflow');
  const sessionClient = new dialogflow.SessionsClient();

  async function detectIntent(
    projectId,
    sessionId,
    query,
    contexts,
    languageCode
  ) {
    //省略  
    const responses = await sessionClient.detectIntent(request);
    return responses[0];
  }

  async function executeQueries(projectId, sessionId, queries, languageCode) {
       //省略

       //slackのチャンネルに返信
       await slack.chat.postMessage({
                    token: botToken,
                    channel: _channel,
                    text: intentResponse.queryResult.fulfillmentText
                })

  }
  executeQueries(projectId, sessionId, queries, languageCode);

}

また、Slackのメッセージがスレッドであるかは、channels.historyレスポンスのメッセージにthread_tsが含まれているかどうかで判断できます。 スレッド上でのメッセージの場合は、:mizeko:という絵文字があれば、それは正しい答えを教えようとしているのだと判断できます。 どのthreadが該当のthreadであるかは、conversations.repliesから導きだせます。

f:id:astamuse:20191212111217p:plain

Threadで正解を教え、もう一度同じ質問をすると正しく答えてくれます。(IntentのUpdateで上書きしているので、後から教えた方を覚えます) f:id:astamuse:20191212124201p:plain

まとめ

メルカリのHISASHIくんにインスパイアされたAI社員をつくり、APIを通じて簡単な学習機能をつけました。社内で広く使われていくためには、 Dialogflowコンソールからの手入力による柔軟な運用が必要になるのではないかという気もしますが、自動化できる部分は自動化して勝手に育ってくれると嬉しいですね。
これからのオフィスでは、生産性を上げるためにBotがさらに活用されていくことでしょう。Botとうまく付きあって生産性を上げていきたいですよね? f:id:astamuse:20191212120422p:plain

アスタミューゼでは、エンジニア・デザイナーを募集中です。ご興味のある方は遠慮なく採用サイトからご応募ください。お待ちしています。

しくじり リーンキャンバス

Gyopi です。
みかんが美味しい季節になりました。
風邪の予防にも、みかんいいですよ。

さて、今年を振り返る意味でも
今年取り組んだ取り組みの1つを振り返ります。

構成

  • リーンキャンバス のやってみたきっかけ
  • CPF/PSF/PMF を理解・整理する
  • 機能がフィットするかの検証が停滞した原因
  • 次につなげる

リーンキャンバス のやってみたきっかけ

当時、プロダクト開発を進めるにあたり開発側から「顧客のイメージが分からない」という声が多かったこともあり、
事業サイドが考えているターゲットを具体的に絞ってプロダクト開発を行うことを狙いました。

前提として私たちが開発しているInnovationCapital Pathfinder (通称 ICP)は、

新規事業を起こさなければならない企業の中の人に提供していた
新規事業コンサルティングをWebで広くサービス提供しよう!

と始まったもので、事業サイドはもともとコンサルティングを行なっていたチームが担っています。

参考までに新規事業を生み出すノウハウは本題ではないですが、下記エントリなどで少し紹介されています。 lab.astamuse.co.jp

コンサルティングノウハウは社内に存在しますが、

「コンサルティング」という形式では顕在化していたニーズを「Webサービス」という形でも引き続きニーズと定義できるのか

というのが大きな課題でした。というより、今でも常に課題です。

CPF/PSF/PMF を理解・整理する

置かれている立場を踏まえてフェーズを分解し、CPF/PSF/PMFを意識して
ブループリント作成や顧客インタビューなどに取り組みました。

CPF/PSF/PMFを意識すると言っても難解なのですが、それぞれのフェーズの役割というものがあります。

少し長くなりますが、以下の項目を確認していくことで Problem Solution Fit までの確認を行いました。

Customer Problem Fit の確認

  • 誰の何を解決するサービスなのか
    • 課題仮説の構築
    • 何の代替なのか(同業他社ではなく、時間を消費する相手として)
    • ジョブスペックの作成
  • 想定したカスタマーの課題は本当に存在するのか
    • 前提条件を洗い出す
    • 課題〜前提の検証(プロブレムインタビュー)
  • 終了条件
    • 課題が存在する前提条件をしっかり検証し、課題が存在することが確認できたか?
    • 課題を持っている顧客イメージを明確化にできたか?

Problem Solution Fit の確認

  • 課題を解決するベストな方法は何なのか
    • その際に、プロダクト側に最低限持たせる機能(MVP)は何なのか
    • UXブループリントの作成
    • プロトタイプの構築
      • ジョブスペックに対応する体験を一貫して提供できるようにする
    • プロトタイプインタビュー
  • 終了条件
    • 顧客がそのソリューションを利用する理由を明確にできるか?
    • ソリューション仮説の磨き込みを通じてカスタマーが持つ課題の理解がさらに深まったか?
    • その課題を解決できる必要最小限の機能を持つソリューションの洗い出しができているか?
    • カスタマーが期待すること全体を言語化できているか?

Product Market Fit の確認

  • Lean Canvas の Fix
  • スプリントの実行
    • 新しいユーザーストーリーをMVPに実装し、定量分析とカスタマーインタビューによる定性分析
    • カスタマーが継続的に欲しがるプロダクトの実現
    • AARRR(AARRR)Acquisition / Activation / Retention / Referral / Revenue
      • 特に Retention / Referral / Revenue を注意することを想定
  • 終了条件
    • PMF 到達先行指標
      • 各項目の NPS スコアがプラス(仮置き。9・10を付けた%から0〜6を付けた%を引いた数字)
      • Product Market Fit Survey の「非常に残念に思う」が40%以上
    • ユーザーの高いリテンションを保てているか?
    • カスタマー獲得から売上を確保するまでは確立できているか?
    • リーンキャンバスの項目全体を見て成立しているか?

各フェーズにおいて着目すべきリーンキャンバス の項目は異なります。
以下の図は理解に役立ちました。

CPF/PSF/PMF とリーンキャンバス の着目点
CPF/PSF/PMF とリーンキャンバス の着目点

業務のフローやキーワードをベースに「顧客層の具体化・条件化」「解決する課題の定義」を言語化しました。
そして、Adobe XD で作成したモックを使ったインタビューを実施し、最初の仮説の当たっていた部分も考慮不足の点もあぶり出されました。

コンサルティングでのニーズがあるのだから、Customer Problem Fit は飛ばしてもいいのかな、
という話も出ましたが最初から考えることで順序立てて捉えて議論に臨めたので良かったです。

今は Product Market Fit における最適解を模索するための企画と開発をしているということになります。

機能がフィットするかの検証が停滞した原因

今回の反省のメインとなるのですが、結果として機能単位での追加と仮説検証があまり進まなかったです。
ビジネス仮説の検証内容が大きくなり、機能開発とのバランスが良くなかったことが原因と考えています。

また、検証の対象の絞り方にも改善点がありました。
後付けの説明にはなりますが、複数の業界、複数の職種、複数の目的、というところを同時に検証しにいきました。
これは視野を最初の時点でむやみに狭めないという点では有用でしたが、機能開発面では要件を絞れず開発側に迷惑をかけました。

開発陣の頑張りの甲斐もあってリリースにはこぎつけましたが、スムーズだったとは言い難いです。
一度インタビューは行なったものの、その後のフィードバックを継続的に得ることも課題の1つです。

下記のようなフローを見据えると、上記で上がった課題は改善していかないといけません。

PMF到達までのフロー図
PMF到達までのフロー図

取り組むべき課題を3つまとめると

  • 仮説粒度を小さく定義する
  • 検証対象を絞る
  • 顧客からのフィードバックを集めることを仕組み化する

次につなげる

一番幸いなことは、コンサル→サービス、になっても課題解決ニーズ自体は観測できておりユーザとなってくださっていることです。 全体的な課題を色々と分解しながら、頓挫しないように小さな検証の繰り返しを進めたいです。

f:id:astamuse:20191204113159j:plain

仮説の粒度が大きく検証対象が絞れなかったという課題に関しては、具体的な顧客像を定め、その利用定着を具体的に目指しています。 顧客からのフィードバックも、利用していただくユーザーとの複合的なワークショップの取り組みや、使い勝手に関するやりとりを通じて、フィードバックを集められそうです。

当初にあった「顧客のイメージが分からない」に関して、事業サイドだけでなく開発面も含めて共通言語としてのペルソナを制定しています。 その取り組みに関しては主導してくれているデザイナーが今度書いてくれると思います!

さいごに

いくらでも長くかけるので、はしょり過ぎたかもしれません。

見ていただいた方の役に立つ内容だったのか怪しいですが、 未来の自分達にとってこの経験も乗り越えて上手くいったな、と語れるように頑張ります!

最後になりましたが、アスタミューゼでは現在、エンジニア・デザイナーを絶賛大大大募集中です! 興味のある方はぜひ下記バナーからご応募ください!!

Sangriaで始めるGraphQL入門

f:id:astamuse:20191120111334p:plain
sangria
お久しぶりでございます。Scalaでバックエンドを開発しているaxtstar(@axtstart)です。

今回は、WebAPI開発時にSangria(GraphQL)を使ってみて便利だなと感じたので紹介します。

GraphQLとは?

GraphQL(グラフQL)は、APIのために作られた、データクエリとデータ操作のための言語と、保存されたデータに対してクエリを実行するランタイムである
~~
GraphQLは、2012年にFacebookの内部で開発され、2015年に公開された
(抜粋: Wikipedia)

GitHubやNetflix、SpotifyでGraphQLを利用しているというニュースも記憶に新しいかと思います。

REST vs GraphQL

上記、Wikipediaにもあるように、RESTと比較して、 ウェブAPIの開発に、その他のWebサービスと比較して、効率的、堅牢、フレキシブルなアプローチを提供することを目的としているようです。

また、機能としては、読み込み、書き込み(ミューテーション)、データのサブスクリプション(リアルタイム更新機能)を持っています。

RESTとGraphQLの比較表

-- REST GraphQL
主導 クライアントドリブン サーバドリブン
エンドポイント インターフェース数分 基本的には一つ
通信 JSON JSON

ただ、以下の点では注意が必要とのこと

  • 比較的新しい技術のため、サーバライブラリは、言語によって差がある
  • クライアントでどんなクエリでもかけるのでパフォーマンスに注意
  • キャッシュの機構はRESTより複雑になる
  • ファイルアップロードのような機能は無い

Sangriaとは?

Sangriaとは、Scala版GraphQLの実装です。

github.com

play-frameworkやakka-http上で動作するようです。

今回はakka-http上で動作する公式サンプルに基づいて説明していきます。

依存関係

akka-http、JSONの変換にcirceを用いたバージョンのbuild.sbtは以下な感じです。

libraryDependencies ++= Seq(
  "org.sangria-graphql" %% "sangria" % "1.4.2",
  "org.sangria-graphql" %% "sangria-slowlog" % "0.1.8",
  "org.sangria-graphql" %% "sangria-circe" % "1.2.1",

  "com.typesafe.akka" %% "akka-http" % "10.1.10",
  "de.heikoseeberger" %% "akka-http-circe" % "1.29.1",

  "io.circe" %% "circe-core" % "0.12.1",
  "io.circe" %% "circe-parser" % "0.12.1",
  "io.circe" %% "circe-optics" % "0.9.3"
)

公式のakka-httpサンプルとはほんの少し変えています。

起動

sbt run で起動すると、playgroundが下記でアクセスできます。

http://localhost:8080/graphql

こんな画面

f:id:astamuse:20191119231758p:plain
playground

こちらはある種のIDE*1になっていて、補完やサジェッスションを提供してくれるため、意外なほど簡単に目的のクエリを記述することができます。

クエリ例

query{
  humans{
    id
    name
    homePlanet
    current_time
  }
}

補完の例

f:id:astamuse:20191120095436p:plain
completion

また右側のDOCS、SCHEMAというところに存在するクエリの仕様が表示されます。

スキーマ表示

f:id:astamuse:20191120095549p:plain
schema

スキーマ定義

Scalaで記載するスキーマ定義はsangria.schema._ で提供される、DSL*2で記述します。

EnumType、InterfaceType、ObjectType、Argument、ListType、OptionTypeなどを定義でき、最終的にObjectTypeとしてクエリのスキーマとして公開します。 StringやIntなども組み込みのTypeが存在するようですが、LocalDateTimeのような、文字列化の法則が決まっていないようなTypeは自分でParserを記述する必要があるようです。

スキーマ定義の例

Enum

  val EpisodeEnum = EnumType(
    "Episode",
    Some("One of the films in the Star Wars Trilogy"),
    List(
      EnumValue("NEWHOPE",
        value = Episode.NEWHOPE,
        description = Some("Released in 1977.")),
      EnumValue("EMPIRE",
        value = Episode.EMPIRE,
        description = Some("Released in 1980.")),
      EnumValue("JEDI",
        value = Episode.JEDI,
        description = Some("Released in 1983."))))

ObjectTypeの例

  val Human =
    ObjectType(
      "Human",
      "A humanoid creature in the Star Wars universe.",
      interfaces[CharacterRepo, Human](Character),
      fields[CharacterRepo, Human](
        Field("id", StringType,
          Some("The id of the human."),
          resolve = _.value.id),
        Field("name", OptionType(StringType),
          Some("The name of the human."),
          resolve = _.value.name),
        Field("friends", ListType(Character),
          Some("The friends of the human, or an empty list if they have none."),
          resolve = ctx ⇒ characters.deferSeqOpt(ctx.value.friends)),
        Field("appearsIn", OptionType(ListType(OptionType(EpisodeEnum))),
          Some("Which movies they appear in."),
          resolve = _.value.appearsIn map (e ⇒ Some(e))),
        Field("homePlanet", OptionType(StringType),
          Some("The home planet of the human, or null if unknown."),
          resolve = _.value.homePlanet),
        /* 試しに追加した型 */
        Field("current_time", Types.LocalDateTimeType,
          Some("current time."),
          resolve = _.value.current_time),
      ))

引数定義

  val LimitArg = Argument("limit", OptionInputType(IntType), defaultValue = 20)

LimitArg(最大取得件数)をクエリ定義の部分で利用する

      Field("humans", ListType(Human),
        arguments = LimitArg :: OffsetArg :: Nil,
        resolve = ctx ⇒ ctx.ctx.getHumans(ctx arg LimitArg, ctx arg OffsetArg))

上記、Types.LocalDateTimeType(LocalDateTime)のパーサー例*3

object Types {

  case object DateCoercionViolation extends ValueCoercionViolation("Date value expected")

  def parseDate(s: String) = Try(LocalDateTime.parse(s)) match {
    case Success(date) => Right(date)
    case Failure(_) => Left(DateCoercionViolation)
  }

  implicit val LocalDateTimeType = ScalarType[LocalDateTime](
    "DateTime",
    coerceOutput = (dt, _) => dt.toString,
    coerceUserInput = {
      case s: String => parseDate(s)
      case _ => Left(DateCoercionViolation)
    },
    coerceInput = {
      case StringValue(s, _, _, _, _) => parseDate(s)
      case _ => Left(DateCoercionViolation)
    })

}

パフォーマンス計測

GraphQLは複雑なクエリやネストの深いクエリをクライアントから自由に書けるようになっているためか、パフォーマンス測定を有効にできるようにサンプルではなっていました。

HTTP Headersに以下の設定を行うと有効になるよう*4です。

{
  "X-Apollo-Tracing":true
}

f:id:astamuse:20191119232453p:plain
tracing

クエリ(サーバサイド)

サーバサイド側でDSLライクにクエリを書くこともできます。 RESTのグルー(のり)として利用する*5のもありかなと思いました。

      path("query"){
        get {
          import sangria.ast._
          import sangria.macros._
          val queryAst: Document =
            graphql"""
            {
              humans {
                 name
              }
            }
          """
          executeGraphQL(queryAst, None, Json.obj(), false)
        }
      }

結果

$ curl http://localhost:8080/query
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   139  100   139    0     0    158      0 --:--:-- --:--:-- --:--:--   158
{"data":{"humans":[{"name":"Luke Skywalker"},{"name":"Darth Vader"},{"name":"Han Solo"},{"name":"Leia Organa"},{"name":"Wilhuff Tarkin"}]}}

クエリ

ここからはクライアント側でどのように呼び出すかを決めるクエリの記述法に関して見ていきます。

複数のスキーマを同時に呼び出す

query{
  humans{
    name
  }
  droids{
    name
  }
}

結果

f:id:astamuse:20191120101351p:plain
double schema

引数を取る場合

query{
  human(id:"1000"){
    name
  }
  droid(id:"2000"){
    id
    name
  }
}

結果

f:id:astamuse:20191120101835p:plain
argument

また、記述時に型エラーなどを教えてくれます(Validation)

以下は文字列型に数値を適用しようとしてエラー

f:id:astamuse:20191120102032p:plain
validation

クライアント側でクエリを自由に記述できるため、ある意味奇妙なクエリもかけるようです。

友達の友達の友達を検索

query  {
  humans{
    name
    friends{
      name
      friends{
        name
        friends{
          name
        }
      }
    }
  }
}

結果:

f:id:astamuse:20191120000357p:plain

まとめ

RESTでAPIを作ろうとした場合、丁寧につくれば作るほど、Endpointが細分化していき、またそれらを繋げるためにエッジ-サーバ間のラウンドトリップが増加する、もしくは統合的な項目を持った、メンテナンス性の低いEndpointが増加する問題があると感じていますが、GraphQLの場合はクエリに複数のスキーマ呼び出しを行ったりといったことが柔軟なので、エッジ-サーバ間の通信量をおさえつつもtidyな開発ができるのでは無いかと期待しています。

最後になりましたが、アスタミューゼでは現在、エンジニア・デザイナーを絶賛大大大募集中です! 興味のある方はぜひ下記バナーからご応募ください!!

*1:graphql-playground-reactというものを利用している

*2:ここに記載

*3:https://github.com/axtstar/sangria-akka-http-example/blob/forBlog/src/main/scala/Types.scala

*4:ここで指定しているので変更は可能

*5:ここに

Copyright © astamuse company, ltd. all rights reserved.