LoginSignup
83
31

More than 3 years have passed since last update.

「きのこの山」を「たけのこの里」に『正しく』自動で修正して差し上げるプログラム(Androidアプリ版)

Last updated at Posted at 2020-05-14

「たけのこの里」という非常に素晴らしいお菓子

人類であれば皆「たけのこの里」という非常に素晴らしいお菓子を食べたことがあるはずです。長さ2cmほどのたけのこ状のお菓子で、クッキーの生地の表面に程よい口溶けのチョコレートが二重にわたってコーティングされています。生地のクッキーは丁寧に形を整えながら焼くという「型焼」という当時日本初の手法を取っており、明治様がどれほどの愛情を「たけのこの里」に注いでいるのかがよくわかります。

しかしながら、たけのこの里という素晴らしいお菓子が発明される前に、明治様はとんでもない間違えを犯してしまいます。「きのこの山」という完全出来損ないの商品が発売されていたのです。たけのこの里に比べてポリポリと固く質感の悪いクラッカーの先端に適当にチョコを乗せただけという、なんとも言えないお菓子となっています。味も売り上げも旨さも人気さも全てにおいて「たけのこの里」のほうが1枚...いや300枚上手なのですが、悲しいことに姉妹品という括りになってしまっています。「たけのこの里」の爆発的ヒットの裏側で人気がガタ落ちしていた「きのこの山」の売り上げを伸ばすべく、姉妹品という言葉を使って「たけのこの里」にあやかろうとしたのでしょう。当時の担当者様も高位の存在である「たけのこの里」を「きのこの山」と同等の存在であるかのように宣伝するのは苦渋の決断だったでしょう。心中お察しいたします。

しかしその影響でか、ネット上では「きのこたけのこ戦争」という本来であれば起こるはずのない争いが生まれてしまっています。どういう理由か理解に苦しみますが、「きのこの山の方がおいしい」という妄言をおっしゃる方々がいらっしゃるのです。無知というのは非常に恐ろしいものですね。事実、国民総選挙でもたけのこの里が二度にわたって大金星をあげています。テレビ朝日の『日本国民がガチで投票! お菓子総選挙2016』という番組ではたけのこの里が8位でしたが、きのこの山はランク外という惨敗に終わっています。さらに、あの日本国民の英雄かつ霊長類最強とまで言われた「吉田沙保里」様もたけのこの里を支持1しています。

このような事実を正しく理解していれば、もはや争いなど起こる余地はないのですが、ネット上では面白がってか、この2製品の間にライバル関係があるかのような発言をする方がいます。たけのこの絶対的正義は揺るぎないのであまり気にする必要もないとは思うのですが、新参の方があらぬ間違いを起こしてしまう可能性も皆無ではありません。

このQiitaでも「たけのこの里」を「きのこの山」に『正しく』自動で修正して差し上げるプログラム という記事がでていました。この記事の影響で「きのこの山派」と呼ばれる事実を正しくとらえられない方が増えてしまう可能性もありますので、今回は「きのこの山」を「たけのこの里」に『正しく』自動で修正して差し上げるプログラムを作りたいと思います。

正しく修正できた例

今回はAndroidアプリとしての開発となります。レイアウトも含め後ほど掲載しますが、まずは動作例をご覧ください。

修正例その1

例えばTwitterで以下のようなツイートを見かけたとします。

今度みんなで「きのこの山パーティー」しようぜ!

かわいそうに。おそらくこの若者はきのこの山という時代遅れの製品しか食べたことがないのでしょう。もしくは若き日の間違いというやつでしょうか。理由は何にせよ、このようなことは決してあってはなりません。この若者だけではなく、パーティーに参加した人々にまで汚染が広まってしまい、歴史誤認が拡大しかねません。ツイートした本人またパーティーの参加される方々の正しい歴史認識を汚さないために修正して差し上げることにしましょう。

修正済みテキスト:今度みんなで「たけのこの里パーティー」しようぜ!

是非やってください。何なら私も呼んでください。国はこのような事実を正しく認識し、広めていこうとする動きに補助金を出すべきです。

修正例その2

次にAmazonで以下のようなレビューを見たとします。

たけのこの里って、チョコは味しないし土台は崩れやすいから嫌い。☆1もつけたくない。

意味が分からない。きのこの山のように、ただ甘ったるいチョコを大量に乗せることがいいとでも思っているのでしょうか。薬剤師様の批評2によると、きのこの山は異性化液糖が含まれていて体に悪いそうです。もしくは何かの変換エラーで誤って「たけのこの里」と打ってしまったのでしょうか。あり得る話です。人間ですから間違うこともあります。そのような場合はこのプログラムを適用させて、本来打ちたかった「きのこの山」に自動で変換してあげることにしましょう。

修正済みテキスト:きのこの山って、チョコは味しないし土台は崩れやすいから嫌い。☆1もつけたくない。

その通りです。たけのこの里は2種類のチョコレートを絶妙なハーモニーで掛け合わしているので、たけのこの里を食べた人は舌が肥えているのです。そのような人たちが出来損ないであるきのこの山を食べたところで、何の味もしなくて当たり前です。

修正例その3

次に以下のようなLINEが来たとします。

たけのこの里って本当においしいよな!

世界常識です。当たり前のことですが、何度も噛みしめるように言うのは良いことですよね。私もたけのこの里を食べるたびにこう思います。このような文章は本来修正する必要はないのですが、テストとして一度修正にかけてみましょう。「たけのこの里」を裏切るような行為に見えますが、これも「たけのこの里」のためを思ってのことです。

修正済みテキスト:たけのこの里って本当においしいよな!

何も変わりませんね。これが世界常識なのですから当たり前です。もし誤って「たけのこの里」を「きのこの山」に修正してしまうようなことがあれば、私は日本で生活できなくなることでしょう。プログラム通り動いてよかったです。

修正例その4

最後に、ネット掲示板で以下のようなコメントを見かけたとします。

たけのこの里マジで不味くてワロタwww

言語道断です。何がワロタなのか理解不能です。ふざけるのも大概にしてほしいものです。このようなコメントは青少年の育成に莫大な悪影響を及ぼす恐れがあります。吉田沙保里様も支持1している「たけのこの里」は国家に認められたお菓子といっても過言ではありません。そのような存在である「たけのこの里」を汚すような言葉は、名誉棄損罪や国家転覆罪(内乱罪)に当たる可能性があります。この一言を投稿した人が罪に問われないように、修正して差し上げることにしましょう。

修正済みテキスト:きのこの山マジで不味くてワロタwww

あまり1つの製品をやり玉に挙げて批判することは良くないと思いますが、とりあえず投稿者の方が罪に問われる可能性はなくなりました。よかったです。

仕組み

健全な方であれば、この時点でLGTMやTwitterへのシェアなどを行っていると思いますが、一応仕組みとコードについて解説します。

仕組み自体は「たけのこの里」を「きのこの山」を『正しく』自動で修正して差し上げるプログラム の仕組みと同じです。こちらの記事も併せてご覧ください。しかしながら、多少「たけのこの里」を貶すような発言が見られますので、皆さん注意してご覧ください。

上記の修正例をご覧になってお分かりかと思いますが、『正しく』修正するということは、ただ「たけのこの里」と「きのこの山」を入れ替えればいいということではありません。修正例その1のように何故か「きのこの山」を誉めている文章の場合は不適切ですので、本来称賛されるべき「たけのこの里」と入れ替えて差し上げます。逆に「たけのこの里」を褒め称えいる文章の場合は、それが当たり前ですので修正は実行しません。詰まる所、以下のような基準となります。

称賛している(ポジティブ)+「きのこの山」が含まれているといった文章の場合は修正を実行します。
貶している(ネガティブ)+「たけのこの里」が含まれているといった文章の場合、修正は実行しません。

さらに、ただ単に「たけのこの里」「きのこの山」という単語を検索するわけではありません。例えば以下のような文章をご覧ください。

「火事のときのこの山は危険だ」 (火事の時の、この山は危険だ)
「あの娘に首ったけのこの里見少年」 (あの子に首ったけの、この里見少年)

このような文章の場合「たけのこの里」という商品とは無関係ですので、誤って修正してしまうのは文章を書いた方に失礼ですし、何より「たけのこの里」への冒涜にあたります。ですので、「たけのこの里」「きのこの山」という固有名詞が文章に含まれているかを調べる必要があります。

今回は、ネガポジ判定にMicrosoft AzureGoogle 翻訳を利用します。Google翻訳を利用するのは、日本語のままだとネガポジ判定の精度が落ちるからです。固有名詞の抽出にはCOTOHA 固有表現抽出 APIを利用します。

注:本来であれば、主語を推定し、ネガポジがどこの単語までかかっているのか係り受け分析すべきですが、今回は省略させていただきます。

実装

環境

今回はより多くの方に正しい歴史認識を持ってもらうために、シェア率の高いAndroidアプリとして開発します。Androidアプリとして開発することで、日本国内だけでなく外国に対しても「たけのこの里」がいかに高位の存在であるかを知らしめることができます。余談ですが、外国人にも「たけのこの里」が絶対的正義であることは僅かながら浸透しつつあるようです。3(注釈参照)老若男女皮膚の色問わず「たけのこの里」がおいしいということでほぼ一致しましたね。喜ばしいことです。

さて、環境はAndroidStudio4.0 ac1で開発していきます。HTTPSクライアントライブラリはFuelを採用します。JsonパーサーはGoogle GsonKlaxonで迷いましたが、今回はKlaxonを採用することにしました。Fuelについては「Kotlin/FuelでHTTPアクセスしました。」をご覧ください。Klaxonについては「KotlinとKlaxsonによるJSONの処理」をご覧ください。

ライブラリを決定したら、Gradleに以下のように記述します。

以下コード(クリックして展開)
dependencies {
    implementation 'com.beust:klaxon:5.0.1'

    implementation 'com.github.kittinunf.fuel:fuel:1.12.0'
    implementation 'com.github.kittinunf.fuel:fuel-android:1.12.0'
}

Google翻訳 API

そもそもGoogle翻訳 APIは有料です。500,000文字までは一応無料で使うことができますが、世界中のありとあらゆる「きのこの山」を「たけのこの里」へと修正していたらあっという間に上限が来てしまいます。どうしたものかと困り果てていたところ、Google翻訳APIを無料で作る方法という記事を見つけました。こちらの記事をもとにAPIを作って利用します。

以下コード(クリックで展開)
    private val googleTranslateUrl = "作成したAPIのエンドポイント"

    private fun requestTranslate(text: String): String?{

        //自作したGoogle翻訳APIにリクエストを投げる
        val (_, _, result) = "$googleTranslateUrl?text=$text&source=ja&target=en".httpGet().responseJson()

        //実行結果を確認
        if(isSuccess(result.toString())){

            //Klaxonを使ってJsonをパース
            val jsonObject = Klaxon().parseJsonObject(StringReader((result.get().content)))
            val resultText =  jsonObject["text"] as String

            //Azureが通らない特殊文字を_に置き換える
            return textCheck(resultText)
        }

        return null
    }

    private fun textCheck(sentence: String): String{
        //Azureが通らない特殊文字を_に置き換える

        var resultSentence = sentence
        """/,\,",',#,$,>,<,^,|""".split(",").forEach {resultSentence = resultSentence.replace(it, "_")}

        return resultSentence
    }

COTOHA 固有表現抽出 API

COTOHA固有表現抽出APIは1日1000コールまで無料で利用することができます。1000という少ない数ですが毎日コツコツと不適切な文章を修正していけば、いつの日か「きのこの山」を消滅に追い込み、より良い世界へと導くことができます。こちらのページから新規登録してください。(COTOHA for Developerss)

COTOHAではセキュリティ上の懸念から1時間に1回アクセストークンが変更となります。そのため、アクセストークンを1時間に1回問い合わせるようなロジックを組む必要があります。...が、今回は面倒くさいので修正のたびにアクセストークンを問い合わせることとします。

固有表現抽出APIのエンドポイントはhttps://api.ce-cotoha.com/api/dev/nlp/v1/neです。
アクセストークンの問い合わせ先のエンドポイントはhttps://api.ce-cotoha.com/v1/oauth/accesstokensです。

以下コード(クリックして展開)
    private data class UniqueWordData(val form: String, val _class: String, val beginIndex: Int, val endIndex: Int)

    private val cotohaRequestUrl = "https://api.ce-cotoha.com/api/dev/nlp/v1/ne"
    private val cotohaRequestAccessTokenUrl = "https://api.ce-cotoha.com/v1/oauth/accesstokens"
    private val cotohaClientID = "****************************"
    private val cotohaClientSecretID = "*************"

    private fun requestCotohaAccessToken(): String?{

        //COTOHAにAccessTokenをリクエストする
        val (_, _, result) = cotohaRequestAccessTokenUrl.httpPost()
                .header(mapOf("Content-Type" to "application/json"))
                .body(getCotohaRequestAccessTokenBody())
                .responseJson()

        //実行結果を確認
        if(isSuccess(result.toString())){

            //Klaxonを使ってJsonをパース
            val jsonObject = Klaxon().parseJsonObject(StringReader(result.get().content))
            val resultToken = jsonObject["access_token"] as String

            return resultToken
        }

        return null
    }

    private fun requestExtractionUniqueWord(text: String): Boolean {

        //COTOHAにAccessTokenをリクエストする
        val cotohaAccessToken = requestCotohaAccessToken() ?: return false

        //uniqueWordListを初期化
        uniqueWordList.clear()

        //COTOHA固有単語抽出APIにリクエストを投げる
        val (_, _, result) = cotohaRequestUrl.httpPost()
                .header(Pair("Content-Type", "application/json"), Pair("charset", "UTF-8"), Pair("Authorization", "Bearer $cotohaAccessToken"))
                .body(getCotohaRequestBody(text))
                .responseJson()

        //実行結果を確認
        if(isSuccess(result.toString())){

            //Klaxonを使ってJsonをパース
            val jsonObject = Klaxon().parseJsonObject(StringReader(result.get().content))
            val resultArray = jsonObject["result"] as JsonArray<*>

            for(resultObject in resultArray){
                if(resultObject is JsonObject){
                    uniqueWordList.add(
                         UniqueWordData(
                              resultObject["form"] as String,
                              resultObject["class"] as String,
                              resultObject["begin_pos"] as Int,
                              resultObject["end_pos"] as Int
                        )
                    )
                }
            }

            return true
        }

        return false
    }

    private fun getCotohaRequestAccessTokenBody() = """
        {
            "grantType": "client_credentials",
            "clientId": "$cotohaClientID",
            "clientSecret": "$cotohaClientSecretID"
        }
    """.trimIndent()

    private fun getCotohaRequestBody(sentence: String) = """
        {
            "sentence": "$sentence",
            "type": "kuzure"
        }
    """.trimIndent()

Microsoft Azure Text Analytics API

Azureは世界でも有数の、高度な解析を可能とするAPI群です。今回はText Analytics APIを使用します。実はCOTOHAにも「感情推定API」というものがあったのですが、何度か使ってみたところ精度があまり良くなく、「きのこの山ってまずいよね。だよねだよね!」という同意しかない文章を、あろうことか「たけのこの里ってまずいよね。だよねだよね!」と修正してしまったのです。これはも「たけのこの里」に対する涜神以外の何物でもありません。私は憤怒しCOTOHAの開発元であるNTT DOCOMOに怒りの電話を入れようかと思いましたが、なんとか思いとどまりました。なので、今回はAzureを使用します。ちなみに、Azureで同じ文章を試してみたところ、正しく修正できました。Microsoftは「たけのこ派」であることが伺えます。

Azureアカウントを作成したら、Text Analyticsのダッシュボードを作成してください。その際に使用したリソース名がエンドポイントの一部となります。

以下コード(クリックして展開)
    private val azureRequestUrl = "https://リソース名.cognitiveservices.azure.com/text/analytics/v2.1/sentiment?showStats=true"
    private val azureKey1 = "********************************"
    private val azureKey2 = "********************************"

    @SuppressLint("SetTextI18n")
    private fun requestTextAnalyze(){

        //Androidでは通信は別スレッド
        thread {
            //修正して差し上げるテキスト
            val targetText = FM_BeforeTextEdit.text.toString()

            //修正する単語
            val fromWord = FM_LeftBattlerEdit.text.toString()

            //修正後の単語
            val toWord = FM_RightBattlerEdit.text.toString()

            //COTOHA APIを使用して固有表現を抽出する
            val extractionResult = requestExtractionUniqueWord(targetText)

            //日本語で実行すると結果が曖昧なので、一度英語に翻訳してからAzureに渡す
            val translatedSentence = requestTranslate(targetText)

            //Azureに引き渡せる形になっているか確認
            if(extractionResult && translatedSentence != null) {

                //FuelでAPIリクエストを実行
                val (_, _, result) = azureRequestUrl.httpPost()
                    .header(Pair("Content-Type", "application/json"), Pair("Ocp-Apim-Subscription-Key", azureKey1))
                    .body(getAzureRequestBody(translatedSentence)).responseJson()

                //実行結果を確認
                if (isSuccess(result.toString())) {

                    //Klaxonを使用してJsonをパース
                    val jsonObject = Klaxon().parseJsonObject(StringReader(result.get().content))
                    val documentsArray = jsonObject["documents"] as JsonArray<*>

                    //どうせ1つしかリクエストしてないので、要素が一個以上あるかの確認と、型チェックを同時に実行
                    if (documentsArray.size >= 1 && documentsArray[0] is JsonObject) {
                        val score = (documentsArray[0] as JsonObject)["score"] as Double
                        val resultSentence = if (score > 0.5) {
                            //スコア0.5より上はポジティブ(肯定的)な内容
                            //ポジティブかつ「きのこの山」が含まれていたら修正

                            textExchanger(targetText, fromWord, toWord)
                        } else {
                            //スコア0.5以下はネガティブ(否定的)な内容
                            //ネガティブかつ「たけのこの里」が含まれていたら修正

                            textExchanger(targetText, toWord, fromWord)
                        }

                        return@thread
                    }
                }
            }
        }
    }

レイアウト

画面レイアウトを作成します。この辺は正直好みです。普段から「たけのこの里」を選ぶようなセンスの良い皆様であればちょちょいのちょいだと思います。今回はFragmentとして作成しますがActivityでもなんでも構いません。MaterialComponentsを使用しているため、gradleに以下のように記述します。

以下コード(クリックして展開)
dependencies {
    implementation 'com.google.android.material:material:1.1.0' //追加

    implementation 'com.beust:klaxon:5.0.1'

    implementation 'com.github.kittinunf.fuel:fuel:1.12.0'
    implementation 'com.github.kittinunf.fuel:fuel-android:1.12.0'
}

シンプルなつくりでもMaterialComponentsを使えば、案外華やかな見た目になります。今回はTextInputLayoutとTextInputEditを使用します。Googleログインの画面などで表示されるあのレイアウトです。こちらのレイアウトの詳しい使い方については【TextInputLayout/TextInputEdit】Material.TextFieldのカスタマイズチートシートの記事をご覧ください。

以下コード(クリックして展開)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/colorBackground">

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/FM_LeftBattlerField"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="24dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        app:counterEnabled="true"
        app:counterMaxLength="10"
        app:layout_constraintEnd_toStartOf="@+id/FM_VStext"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/FM_LeftBattlerEdit"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/BeforeWord"
            android:text="@string/Kinoko" />

    </com.google.android.material.textfield.TextInputLayout>

    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/FM_RightBattlerField"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="24dp"
        app:counterEnabled="true"
        app:counterMaxLength="10"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/FM_VStext"
        app:layout_constraintTop_toTopOf="parent">

        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/FM_RightBattlerEdit"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/AfterWord"
            android:text="@string/Takenoko" />

    </com.google.android.material.textfield.TextInputLayout>

    <TextView
        android:id="@+id/FM_VStext"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="⇒"
        android:textColor="@color/colorChar"
        android:textSize="18sp"
        app:layout_constraintBottom_toBottomOf="@+id/FM_RightBattlerField"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="@+id/FM_RightBattlerField" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="16dp"
        android:orientation="vertical"
        app:layout_constraintBottom_toTopOf="@+id/FM_FixButton"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/FM_LeftBattlerField">

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/FM_BeforeTextField"
            style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginStart="24dp"
            android:layout_marginEnd="24dp"
            android:layout_weight="1"
            app:counterEnabled="true"
            app:counterMaxLength="60"
            app:endIconMode="clear_text"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/FM_LeftBattlerField">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/FM_BeforeTextEdit"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:hint="@string/BeforeText" />

        </com.google.android.material.textfield.TextInputLayout>

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/FM_AfterTextField"
            style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginStart="24dp"
            android:layout_marginTop="16dp"
            android:layout_marginEnd="24dp"
            android:layout_weight="1"
            app:counterEnabled="true"
            app:counterMaxLength="60"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/FM_BeforeTextField">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/FM_AfterTextEdit"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:hint="@string/AfterText"
                android:inputType="none" />

        </com.google.android.material.textfield.TextInputLayout>

        <ScrollView
            android:id="@+id/FM_ScrollView"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginTop="16dp"
            android:layout_weight="1">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content">

                <TextView
                    android:id="@+id/FM_Log"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="24dp"
                    android:layout_marginEnd="24dp"
                    android:text="@string/AnalyzeResult"
                    android:textColor="@color/colorCharSec" />

            </LinearLayout>
        </ScrollView>
    </LinearLayout>

    <com.google.android.material.button.MaterialButton
        android:id="@+id/FM_FixButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="16dp"
        android:text="@string/FixForTheWorld"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

string.xmlも掲載しておきます

<resources>
    <string name="app_name">Takenoko</string>

    <string name="Setting">設定</string>
    <string name="Help">ヘルプ</string>

    <string name="battle">修正</string>
    <string name="battleResult">修正の履歴</string>

    <string name="Takenoko">たけのこの里</string>
    <string name="Kinoko">きのこの山</string>

    <string name="FixForTheWorld">社会をより良くするために修正を実行</string>
    <string name="AfterWord">修正後の単語</string>
    <string name="BeforeWord">修正する単語</string>
    <string name="AfterText">修正済みの文章</string>
    <string name="BeforeText">修正して差し上げる文章</string>
    <string name="AnalyzeResult">解析結果をここに表示します</string>

    <string name="processing">処理中です</string>
    <string name="error">エラーが発生しました</string>

    <string name="drawer_open">Navigation drawer open</string>
    <string name="drawer_close">Navigation drawer close</string>

    <string name="textEmptyError">必須項目です</string>
    <string name="textLengthError">文字数オーバーです</string>
</resources>

プログラム全文

Fragmentの部分だけですが、全文を公開しておきます。

以下コード(クリックして展開)
package caios.android.takenoko

import android.annotation.SuppressLint
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.beust.klaxon.JsonArray
import com.beust.klaxon.JsonObject
import com.beust.klaxon.Klaxon
import com.github.kittinunf.fuel.android.extension.responseJson
import com.github.kittinunf.fuel.httpGet
import com.github.kittinunf.fuel.httpPost
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import kotlinx.android.synthetic.main.fragment_main.*
import java.io.StringReader
import kotlin.concurrent.thread

class MainFragment : Fragment(){

    private data class UniqueWordData(val form: String, val _class: String, val beginIndex: Int, val endIndex: Int)

    private val uniqueWordList = mutableListOf<UniqueWordData>()

    private val googleTranslateUrl = "作成したAPIのエンドポイント"

    private val azureRequestUrl = "https://リソース名.cognitiveservices.azure.com/text/analytics/v2.1/sentiment?showStats=true"
    private val azureKey1 = "********************************"
    private val azureKey2 = "********************************"

    private val cotohaRequestUrl = "https://api.ce-cotoha.com/api/dev/nlp/v1/ne"
    private val cotohaRequestAccessTokenUrl = "https://api.ce-cotoha.com/v1/oauth/accesstokens"
    private val cotohaClientID = "*********************************"
    private val cotohaClientSecretID = "*************"

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_main, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

        FM_LeftBattlerEdit.addTextChangedListener(getTextWatcher(FM_LeftBattlerField, 10))
        FM_RightBattlerEdit.addTextChangedListener(getTextWatcher(FM_RightBattlerField, 10))
        FM_BeforeTextEdit.addTextChangedListener(getTextWatcher(FM_BeforeTextField, 60))

        FM_AfterTextEdit.keyListener = null

        FM_FixButton.setOnClickListener {
            it.isEnabled = false
            FM_AfterTextEdit.text?.clear()
            FM_Log.text = ""
            requestTextAnalyze()
        }
    }

    @SuppressLint("SetTextI18n")
    private fun requestTextAnalyze(){
        Log.d(TAG, "requestTextAnalyze")

        //Androidでは通信は別スレッド
        thread {
            //修正して差し上げるテキスト
            val targetText = FM_BeforeTextEdit.text.toString()

            //修正する単語
            val fromWord = FM_LeftBattlerEdit.text.toString()

            //修正後の単語
            val toWord = FM_RightBattlerEdit.text.toString()

            //COTOHA APIを使用して固有表現を抽出する
            val extractionResult = requestExtractionUniqueWord(targetText)

            //日本語で実行すると結果が曖昧なので、一度英語に翻訳してからAzureに渡す
            val translatedSentence = requestTranslate(targetText)

            //Azureに引き渡せる形になっているか確認
            if(extractionResult && translatedSentence != null) {

                //FuelでAPIリクエストを実行
                val (_, _, result) = azureRequestUrl.httpPost()
                    .header(Pair("Content-Type", "application/json"), Pair("Ocp-Apim-Subscription-Key", azureKey1))
                    .body(getAzureRequestBody(translatedSentence)).responseJson()

                //実行結果を確認
                if (isSuccess(result.toString())) {

                    //Klaxonを使用してJsonをパース
                    val jsonObject = Klaxon().parseJsonObject(StringReader(result.get().content))
                    val documentsArray = jsonObject["documents"] as JsonArray<*>

                    //どうせ1つしかリクエストしてないので、要素が一個以上あるかの確認と、型チェックを同時に実行
                    if (documentsArray.size >= 1 && documentsArray[0] is JsonObject) {
                        val score = (documentsArray[0] as JsonObject)["score"] as Double
                        val resultSentence = if (score > 0.5) {
                            //スコア0.5より上はポジティブ(肯定的)な内容
                            //ポジティブかつ「きのこの山」が含まれていたら修正

                            log("Result -> score positive $score")

                            textExchanger(targetText, fromWord, toWord)
                        } else {
                            //スコア0.5以下はネガティブ(否定的)な内容
                            //ネガティブかつ「たけのこの里」が含まれていたら修正

                            log("Result -> score negative $score")

                            textExchanger(targetText, toWord, fromWord)
                        }

                        log("[SUCCESS]: $resultSentence")

                        FM_AfterTextEdit.post {
                            FM_FixButton.isEnabled = true
                            FM_AfterTextEdit.setText(resultSentence)
                        }

                        return@thread
                    }
                    else log("[FAILED 1]: ${documentsArray.size}")
                }
                else log("[FAILED 2]: $result")
            }
            else log("[FAILED 3]: $extractionResult, ${translatedSentence != null}")

            FM_AfterTextEdit.post {
                Toast.makeText(requireContext(), getString(R.string.error), Toast.LENGTH_SHORT).show()
                FM_FixButton.isEnabled = true
            }
        }
    }

    private fun requestCotohaAccessToken(): String?{
        log("requestCotohaAccessToken")

        //COTOHAにAccessTokenをリクエストする
        val (_, _, result) = cotohaRequestAccessTokenUrl.httpPost()
                .header(mapOf("Content-Type" to "application/json"))
                .body(getCotohaRequestAccessTokenBody())
                .responseJson()

        //実行結果を確認
        if(isSuccess(result.toString())){

            //Klaxonを使ってJsonをパース
            val jsonObject = Klaxon().parseJsonObject(StringReader(result.get().content))
            val resultToken = jsonObject["access_token"] as String

            log("Result -> ${resultToken.length} length")

            return resultToken
        }

        log("API request failed. -> $result")

        return null
    }

    private fun requestExtractionUniqueWord(text: String): Boolean {

        //COTOHAにAccessTokenをリクエストする
        val cotohaAccessToken = requestCotohaAccessToken() ?: return false

        log("requestExtractionUniqueWord ($text)")

        //uniqueWordListを初期化
        uniqueWordList.clear()

        //COTOHA固有単語抽出APIにリクエストを投げる
        val (_, _, result) = cotohaRequestUrl.httpPost()
                .header(Pair("Content-Type", "application/json"), Pair("charset", "UTF-8"), Pair("Authorization", "Bearer $cotohaAccessToken"))
                .body(getCotohaRequestBody(text))
                .responseJson()

        //実行結果を確認
        if(isSuccess(result.toString())){

            //Klaxonを使ってJsonをパース
            val jsonObject = Klaxon().parseJsonObject(StringReader(result.get().content))
            val resultArray = jsonObject["result"] as JsonArray<*>

            for(resultObject in resultArray){
                if(resultObject is JsonObject){
                    uniqueWordList.add(
                            UniqueWordData(
                                    resultObject["form"] as String,
                                    resultObject["class"] as String,
                                    resultObject["begin_pos"] as Int,
                                    resultObject["end_pos"] as Int
                            )
                    )
                }
            }

            log("Result -> Unique word list size :${uniqueWordList.size}")

            return true
        }

        log("API request failed. -> $result")

        return false
    }

    private fun requestTranslate(text: String): String?{
        log("requestTranslate ($text)")

        //自作したGoogle翻訳APIにリクエストを投げる
        val (_, _, result) = "$googleTranslateUrl?text=$text&source=ja&target=en".httpGet().responseJson()

        //実行結果を確認
        if(isSuccess(result.toString())){

            //Klaxonを使ってJsonをパース
            val jsonObject = Klaxon().parseJsonObject(StringReader((result.get().content)))
            val resultText =  jsonObject["text"] as String

            log("API Result -> $resultText")

            //Azureが通らない特殊文字を置き換える
            return textCheck(resultText)
        }

        log("API request failed. -> $$result")

        return null
    }

    private fun textExchanger(sentence: String, fromText: String, toText: String): String{
        //sentenceに含まれているfromTextをtoTextに変換して返す

        var resultSentence = sentence
        uniqueWordList.forEach {
            if(it.form == fromText) resultSentence = resultSentence.replaceRange(it.beginIndex, it.endIndex, toText)
        }

        return resultSentence
    }

    private fun textCheck(sentence: String): String{
        //Azureが通らない特殊文字を_に置き換える

        var resultSentence = sentence
        """/,\,",',#,$,>,<,^,|""".split(",").forEach {resultSentence = resultSentence.replace(it, "_")}

        log("textCheck -> $resultSentence")

        return resultSentence
    }

    private fun isSuccess(result: String): Boolean = (result.indexOf("Success") != -1)

    @SuppressLint("SetTextI18n")
    private fun log(message: String){
        Log.d(TAG, message)

        FM_Log.post {
            FM_Log.text = "${FM_Log.text}\n\n$message"
            FM_ScrollView.fullScroll(View.FOCUS_DOWN)
        }
    }

    private fun getAzureRequestBody(sentence: String) = """
            {
                "documents": [
                    {
                        "language": "en",
                        "id": "1",
                        "text": "$sentence"
                    }
                ]
            }
        """.trimIndent()

    private fun getCotohaRequestAccessTokenBody() = """
        {
            "grantType": "client_credentials",
            "clientId": "$cotohaClientID",
            "clientSecret": "$cotohaClientSecretID"
        }
    """.trimIndent()

    private fun getCotohaRequestBody(sentence: String) = """
        {
            "sentence": "$sentence",
            "type": "kuzure"
        }
    """.trimIndent()

    private fun getTextWatcher(editor: TextInputLayout, size: Int) = object : TextWatcher {
            override fun afterTextChanged(s: Editable?) {
                when {
                    s?.toString()?.isEmpty() == true -> editor.error = getString(R.string.textEmptyError)
                    s?.length ?: 0 > size         -> editor.error = getString(R.string.textLengthError)
                    else                        -> editor.error = null
                }
            }

            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
        }

    companion object{
        const val TAG = "<TAKENOKO>"
    }
}

修正時のスクショいろいろ

5119.jpg
5120.jpg
5121.jpg
5122.jpg

修正例いろいろ

きのこの山はおいしい。神の食べ物か。
修正済みテキスト:たけのこの里はおいしい。神の食べ物か。
チョコと土台のハーモニーが素晴らしい。たけのこの里は他のお菓子の追随を許さない。☆5です。
修正済みテキスト:チョコと土台のハーモニーが素晴らしい。たけのこの里は他のお菓子の追随を許さない。☆5です。
きのこの山まずい。
修正済みテキスト:きのこの山まずい。
たけのこの里大好きです
修正済みテキスト:たけのこの里大好きです
きのこの山最高! チョコを先食べてクッキーだけにして食べたら最高!
修正済みテキスト:たけのこの里最高! チョコを先食べてクッキーだけにして食べたら最高!
おれ今たけのこの里食ってるけどうまいw
修正済みテキスト:おれ今たけのこの里食ってるけどうまいw
手を汚さず食べれるから きのこの山かな
修正済みテキスト:手を汚さず食べれるから たけのこの里かな
きのこの山は安っぽいからいらない。
修正済みテキスト:きのこの山は安っぽいからいらない。
すぎのこ村
修正済みテキスト:帰れ

まとめ

いかがだったでしょうか。無事、正しい文章に修正して差し上げることができました。このアプリは近く公開も予定していますので、皆様ぜひダウンロードしてより良い世界をともに築いていきましょう。私としては、キーボードアプリにこのプログラムを仕込んで、邪悪な文章だと判定したら確定する前に修正して差し上げるアプリの開発を考えております。こちらも公開にかぎつけた暁には皆さまダウンロードよろしくお願いいたします。

今日も「きのこたけのこ戦争」が様々な場所で勃発しておりますが、一度冷静になってこの記事に目を通してみてください。正しい歴史認識と誤りを矯正するアプリを入手することができますよ。この記事が、1つでも多くの争いを止めることを祈っております。

......おいちょっと待て「きのこたけのこ戦争」ってなんで「きのこ」が先なんだよ。普通だったら「たけのこ」が先で、「たけのこきのこ戦争」だろ...

83
31
11

Register as a new user and use Qiita more conveniently

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