コネヒト開発者ブログ

コネヒト開発者ブログ

LLMを使って分かち書きフィルタを書かずにテキスト処理をする

こんにちは。CTOの永井(shnagai)です。

この記事はコネヒト & コネヒト生成AI Advent Calendar 2024 の8日目の記事です。

adventar.org

今日は、LLMを使って形態素解析処理をいかに楽に出来るかを実験したので、その内容について書いていきたいと思います。

やりたいこと・モチベーション

  • 社内の盛り上げツールとして、ワードクラウドを作りたい
  • ワードクラウドは文章の特長を捉えているとよりうれしい
  • 一般的なワードは省きたい
  • Pythonを使う
  • フリーテキストなので形態素解析が必要だが、テンポラリな処理なので辞書等はない
  • できるだけ楽して作りたい(一番大事なモチベーション)

形態素解析する時につらい処理

形態素解析をする時に一番気を遣う部分が不要な単語をいかに削り意味のある文字を残せるかという部分です。 例えば、これまでだとPythonで形態素解析をするときは、 下記のように不要な単語を消すフィルタを書いていました。

秘伝のタレと化したreplace処理が使うごとに拡張されていきます。

def japanese_wakati(text):
    tokens = tokenizer.tokenize(text)
    words = " ".join(
        token.base_form
        for token in tokens
        if token.part_of_speech.split(",")[0] in ["名詞"]
    )
    return words

wakati_text = japanese_wakati(text)

wakati_text = (
    wakati_text.replace("https", "")
    .replace("今", "")
)

for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ":
    wakati_text = wakati_text.replace(char, "")

# ワードクラウドを生成する処理
...

LLMを使った関数を追加してみる

延々とフィルタを追加しているときに、ふとLLMを使ったらもっとうまくいくのではと思いつき試してみました。 is_not_significant という特長的でない単語をOpenAI APIを利用して削除する関数を用意します。 この関数内でのプロンプト内容を変えることで、最終的な出力がどう変わるかを実験しました。

def is_not_significant(text):
    response = openai.chat.completions.create(
        messages=[
            {
                "role": "user",
                "content": f"以下のテキストから特徴的ではない単語を削除してください:\n{text}\n",

            }
        ],
        model="gpt-4o",
    )
    return response.choices[0].message.content

# 日本語のテキストを分かち書き
wakati_text = japanese_wakati(text)

# 特徴的ではない文字列を削除
wakati_text = is_not_significant(wakati_text)

# ワードクラウドを生成する処理
...

最終的なアウトプットにどんな変化が起きたか

ここからは、上で説明した関数のプロンプトを変えることで最終的なアウトプットであるワードクラウドがどのように変わったかを示していきます。

0. まずは、LLMを使っていない元々のワードクラウド

1. [プロンプト]以下のテキストから最終的にワードクラウドを生成します。特徴的なワードクラウドを作りたいので不要な単語を削除してください

指示文に対する回答が返ってきていてワードクラウドに反映されています。それはそう。 また、この指示では特徴的な文章だけを残してしまいイメージと違うものが出来上がっています。

2. [プロンプト]以下のテキストから最終的にワードクラウドを生成します。不要な単語を削除してください。尚、出力はテキストの羅列のみで受け答えはしないでください

いい感じで元のワードクラウドに近いものが出てきました。いくつか不要なワードが削られていますが元のワードクラウドとの差分はいまいちわかりません。

3. 手動フィルタを外す+[プロンプト]以下のテキストから最終的にワードクラウドを生成します。不要な単語を削除してください。尚、出力はテキストの羅列のみで受け答えはしないでください

最後に手動フィルタを外して実行してみました。そうです、OpenAI APIでよしなに手動で除去していたようなノイズは削除されていました。 しかも泣く泣く諦めていた英単語も表示されるようになっています。 優秀すぎて、これまで自分が頑張って溜めてきたフィルタは一瞬にして用無しとなりました。 これが正解だなと個人的には納得して、フィルタをすべてコメントアウトしました。

まとめ

便利の一言です。 これまでフィルタや正規表現で頑張っていたテキスト処理の前処理において、LLMを活用して精度向上と開発効率向上を実現出来る可能性を感じました。 今回の例は一例で、社内ツールなので特に制約なく使えたという面が強いですが、遊び心をもって普段から知見を溜めていくことでプロダクトにも活用出来る武器を日常から増やしていけるといいなと思っております。

Jetpack ComposeのPreview、どう管理と活用してる?

この記事はコネヒトAdvent Calendar 2024 07日目の記事です。

コネヒトのエンジニアやデザイナーやPdMがお送りするアドベントカレンダーです。

コネヒトは「家族像」というテーマを取りまく様々な課題の解決を目指す会社で、
ママの一歩を支えるアプリ「ママリ」などを運営しています。

adventar.org


こんにちは、コネヒトAndroidエンジニアの中島(id:nacatl)です。 早いものでコネヒトにジョインしてもう1年半近くなりました。 今回はママリAndroidアプリにおけるJetpack ComposeのPreviewについて紹介いたします。

背景

ママリAndroidアプリでは、昨今のAndroid開発事情に漏れずJetpack Composeを取り入れ始めています。 新しく開発する箇所は、画面単位でなくても、例えばRecyclerViewのListItem単位などからも置き換えているところです。

さらにごく最近ではNavigation Composeの採用も始めています。 詳しくは同Advent Calendarの04日目の記事をご覧ください。

tech.connehito.com

さて、Composeといえば、開発中にPreviewを活用されている方が多いと思います。

xmlによるPreviewに比べ、Columnなどで並べることでStateの違いにおける表示を比較しやすくなっています。 さらに、ライト/ダーク設定や文字サイズ設定をはじめとして、Previewアノテーションの引数によってさまざまな設定が扱える点もあります。 総合的に取り回しが向上し、整備することでUIにおける一種のユニットテストのように扱えると感じています。

一方で、PreviewもKotlinで書く以上、Previewコードの量と置き場に課題を感じています。 手軽さで言えば、実態のコードと同じファイルに置きがちです。 しかし、そうするとPreviewのビルドの影響か、増えすぎるとAndroid Studioのパフォーマンスに影響が出ることがあります。 加えて上述のようにユニットテストのように扱おうとすると、サンプルにするStateも増えていくのでより顕著になります。

この記事では、これらの課題に対してのママリAndroidでのComposeのアプローチ、およびその先の活用をご紹介します。

ママリAndroidでの現状

前提として、執筆時点で Android Studio Ladybug | 2024.2.1 Patch 2Kotlin 2.0.21を利用しています。

Previewコードの管理

1. ファイルを分ける

まずは基本という感じで、ファイルを分割します。 ママリAndroidでは、.previewとsuffixを追加することでどのComposeのPreviewかを示しています。

  • FeatureFooItem.kt
  • FeatureFooItem.preview.kt

最初はこれだけでも十分効果を発揮します。 同階層に置けばComposeのvisibilityもinternalなどに絞りやすくなります。 しかし、Compose置換が進みファイルが増えるにつれ、プロジェクトのファイルツリーが長くなり煩わしさも出てきます。

2. File Nestingを活用する

IntelliJ IDEAには、ルールを指定してファイルをまとめてネスティングできる機能があります。

www.jetbrains.com

IntelliJ IDEAにある機能ということは、つまりAndroid Studioでも使える機能でもあります(もちろん基になったバージョンは確認必要ですが)。

この機能は、筆者がFlutterを開発しているときによく利用していました。 当時、DartにおいてKotlin data class相当の機能を扱うためにfreezedを利用しておりました。 freezedはAnnotationProcessorのように、data class的な機能やJsonパーサー相当の機能などの部分をコード生成してくれるパッケージです。 この生成されたコードは foo.dart に対して foo.freezed.dart foo.g.dartなどといった別ファイルで形成され、自動生成であるため開発者自身が編集することはありません。 普段は参照しない、名前の似たファイルがツリーに常駐してしまう煩わしさがどうしてもあったため、これらをまとめるために重宝していました。

これをComposeのPreviewファイル管理に応用します。

QuestionShow.preview.ktをQuestionShow.ktに、QuestionShowAnswer.preview.ktをQuestionShowAnswer.ktにそれぞれネスティングした結果のファイルツリーのスクリーンショット
File Nesting によるファイルツリー管理

これでPreviewファイルを折りたためるようになり、同階層に並べて一覧性を維持しつつ、ツリーの圧迫も緩和されました。 インデントが付くことで見分けやすくもなります。

Previewコードの活用

管理がひと段落したところで、活用の方に移ります。

最初に前提説明ですが、ママリAndroidは接続環境の分岐を Product Flavor の dev prod で管理しています。 社内にテスト配布する dev には、variant専用のコードとして開発用画面のActivityを追加しています。

結論から言うと、この開発用ActivityとしてPreviewコードをそのまま流用したサンプルコンポーネント画面の整備を進めています。 目的としては以下の二つです。

  1. いわゆるモックデータでのUI実装を、リリースコードのUI層と切り離しつつ、サーバー側の開発と並行して行える
    • 表示だけなら素のPreviewだけで十二分ですが、実機上の確認をしたい場合に既存UIと隔離して表示できるのは便利です
  2. 非エンジニアメンバーが配布されたアプリでPreviewを確認できる
    • そもそもこの拡張をしようと思ったきっかけです。実際の操作では作りにくいパターンなどの表示も確認できるため、開発中の連携を強められます

縦向きライトモードのスクリーンショット縦向きダークモードのスクリーンショット
コンポーネントサンプル(縦向き)

横向きライトモードのスクリーンショット横向きダークモードのスクリーンショット
コンポーネントサンプル(横向き)

PreviewComponentActivity ~ ComponentPreviewRoute

ベースとなるActivity ~ Route Composeでは、あまり特殊なことはしていません。 表示したいPreviewを選択表示できる、ダークモードなどの設定を変更できるように、よしなにStateおよびUIを組み立てるだけです。 強いて言えばNow in Androidを参考に、リリースコードに先行してEdgeToEdgeやアダプティブレイアウト対応を軽く取り込んでいる程度です。

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            // ダーク設定remember
            val defaultDark = isSystemInDarkTheme()
            var isDarkTheme by rememberSaveable { mutableStateOf(defaultDark) }

            DisposableEffect(isDarkTheme) {
                enableEdgeToEdge(
                    ~~~~,
                )
                onDispose {}
            }
            ComponentPreviewRoute(
                isDarkTheme = isDarkTheme,
                windowAdaptiveInfo = currentWindowAdaptiveInfo(),
                onDarkThemeChanged = { isDarkTheme = it },
                onBackPressed = { onBackPressedDispatcher.onBackPressed() },
            )
        }
    }

各Previewの工夫

基本的にはそのまま呼び出せると言っても、多少手を加える必要はありました。

1. MaterialThemeで括りなおしてしまうとDarkThemeが切り替わらない

普通にPreviewを作る場合、Previewアノテーションを付けたComposableにそのままThemeから全部書くことが多いと思います。 ですが、Preview用ビルドだけでなく外からも呼び出せるようにする場合、Themeで括ってしまっていると呼び出し側のTheme設定を上書きしてしまいます。 これを回避するにはPreview用の本体と、アノテーションの付いたTheme呼び出しを切り分けます。

// FeatureFooItem.preview.kt

internal val sampleFeatureFoo = FeatureFoo(id = 1, ~~)

/**
 * Preview表示用ビルドはこれを参照する
 */
@Preview(
    heightDp = PreviewHeight, // LazyColumnにできるようにした関係もあり、この辺もアプリ全体で少し整備しています
    ~~,
)
@Composable
private fun FeatureFooItemPreviewBase() {
    MaterialTheme(
        isDarkTheme = isSystemInDarkTheme(),
    ) {
        FeatureFooItemPreview(
            modifier = Modifier.height(PreviewHeight),
            baseFeatureFoo = sampleFeatureFoo,
        )
    }
}

/**
 * プレビュー描画の実態。開発用Activityからの呼び出しではこちらを使う
 */
@Composable
internal fun FeatureFooItemPreview(
    modifier: Modifier = Modifier,
    baseFeatureFoo: FeatureFoo,
) {
    val sampleList = listOf(
        sampleFeatureFoo,
        sampleFeatureFoo.copy( /* 並べて見たい別パターンになるようフィールドを変更 */ ),
        ~~,
    )
    LazyColumn(
        modifier = modifier,
    ) {
        items(sampleList.size) { index ->
            FeatureFooItem( ~~ )
        }
    }
}
2. 基本のサンプルデータを編集できるようにする

前セクションで書いたように、サンプルとなるデータはPreviewコードのファイルに用意しています。

これらのデータを開発用ActivityのUiStateに持たせますが、さらにViewModelの持つStateFlowの形で保持しています。 Flowにする理由としては、実機上でサンプルデータを変更可能にする目的です。 これは我々Androidエンジニアの開発というよりは、特にPdMやデザイナー、非エンジニアの方々の検証目的に役立ちます。

サンプルデータの編集UIのスクリーンショット
コンポーネントサンプル(編集UI)

// FeatureFooPreviewViewModel.kt

    val uiState: StateFlow<FeatureFooPreviewUiState>
        field = MutableStateFlow(FeatureFooPreviewUiState())

    // 表示するPreviewの切り替え
    fun changeComponent(component: FeatureFooPreviewComponent) =
        uiState.update { it.changeComponent(component) }

    // ベースとなる基本サンプルの編集
    fun updateBaseFeatureFoo(featureFoo: FeatureFoo) =
        uiState.update { it.updateBaseFeatureFoo(featureFoo) }

    // リセット
    fun initBaseFeatureFoo() =
        uiState.update { it.initBaseFeatureFoo() }
@Stable
data class FeatureFooPreviewUiState(
    val currentComponent: FeatureFooPreviewComponent = FeatureFooPreviewComponent.ITEM_A,
    val baseFeatureFoo: FeatureFoo = defaultFeatureFoo,
) {
    fun changeComponent(component: FeatureFooPreviewComponent) = copy(
        currentComponent = component,
    )

    fun updateBaseFeatureFoo(featureFoo: FeatureFoo) = copy(
        baseFeatureFoo = FeatureFoo,
    )

    fun initBaseFeatureFoo() = updateBaseFeatureFoo(defaultFeatureFoo)
}

// Compose側でPreview funの呼び出しを切り替えるためのenum
enum class FeatureFooPreviewComponent : PreviewComponent {
    ITEM_A,
    ITEM_B,
    ITEM_C,
    ;
}

利点

このPreview表示の開発用Activityを運用して良かった点を紹介します。

  • 開発中のUIテスト的利用
    • Previewの領域を超えるような数でも、LazyColumnなどでスクロールしながら確認できる
    • ボタンなどユーザーアクションも合わせてテストできる(適当にToast表示などをするだけでも配置ミス確認になります)
    • アダプティブレイアウト対応の実機テストにも転用できる
    • 画面が存在するようになるため、MagicPod等のUIテストサービスにも転用できる
  • 非エンジニアとのコミュニケーション
    • Firebase App Distributionでの配布を通じて、サーバーに依存せず、実機で手軽にPdM / デザイナーへの表示別確認などが行える
    • サンプルデータの編集まで実装できればより確認が捗る

前者については特に複雑な表示分岐仕様があるパーツに対してAndroidView→Composeに置換する際の利点が大きかったと感じます。 まだ始めて日が浅い事もあり後者についてはまだ周知し始めているところですが、「確認しやすくて便利」という声を頂き始めています。今後も浸透させていきたいです。

欠点

はっきり言ってしまえば相応のコーディング工数はあります。

表示に関する工数

Preview自体はそもそもこの活用がなくても作るでしょうし、後からの追加はenumへの追加程度で済みます。 なので、この段階では最初の画面作成 / Previewへの変更周りが主な工数となります。

編集に関する工数

各サンプルデータそれぞれについて編集UIを作る必要があるので、こちらに関してはそれなりに見込む必要があります。 ママリAndroidでも、現状では隙間を縫って実装を進めているような状況です。

if ルート

半分おまけで、現状では採用していませんが「こういうのもいいのでは?」と考えている草案も軽く紹介します。

previewコードの管理

File Nestingの欠点

File Nestingの活用によってファイル分割で起きる問題はほぼ解決します。 ですが、Android Studioのインターフェース面で地味に影響があります。

ダブルクリックで親ファイルが開かない

通常のディレクトリと同じように、ダブルクリックはツリーの collapse / expand に消費されます。 回避策はワンクリックでフォーカスを当てた後にenterです、もしくはファイル検索など経由で開けばいつも通りです。 ただやはり、咄嗟にダブルクリックする癖がいまだに抜けていません。

ネストの子ファイルに対し、Select Opened Fileが効かない

正直、これ不具合では??と思っていますが、効かないものはしょうがありません。 これについては、親ファイルに対して実行するしかないかと思われます。

3. devビルドのディレクトリに置く

File Nestingは確かに便利なのですが、上述のようにちょっとクセがあります。 代替案として現状ぼんやり考えているのは、「開発用Activity用に書いたComposeと同様にdev配下に置いてしまう」ことです。 そもそもリリースビルドには影響しないファイルですし、妥当性は感じます。

ただこの手法では、package上は同一になりますが実際の置き場は離れてしまいますのでツリー上での一覧性は劣ります。

どうするにしろ、トレードオフは何かしら発生するかなとも感じています。

おわりに

今回は、ママリAndroidにおけるCompose Previewの管理方法と活用について紹介させていただきました。

紹介した方法が正解かどうかははっきり言って「わかりません」。 今後も色々思いついたらチーム内で相談し、検証していきたいと思っています。 この記事が皆様のComposeライフをより豊かにする一助となれば幸いです。

LLMを使ってNotion上にデータカタログを自動生成している話

みなさんこんにちは。今期からPdMも兼務しております、たかぱい(@takapy0210)です。

先日、横浜マラソンのペアリレー(ハーフマラソン)に参加しました。
20km以上の距離を走るのは人生で初めてだったこともあり、3〜4ヶ月前くらいから練習をし本番に臨みました。
結果として完走はできたのですが、目標としていた2時間というタイムは達成できなかったので、2025年3月リベンジマラソンをする予定です。(ちなみにこのマラソンで右膝に爆弾を抱えたので、まずはこの爆弾を除去するところからのスタートです)

さて本日は、データカタログを自動生成する機構を作ってみたので、そのお話をしようと思います。

この記事はコネヒト Advent Calendar 2024 6日目の記事です。

adventar.org


目次


どんなものが自動生成されるのか

先に結論からということで、実際に自動生成されるものをお見せします。

以下は実際に弊社が運営する「ママリ」の質問情報が格納されているテーブルのデータカタログ例です。
メタ情報だけでなく、サンプルSQLなども合わせて生成することで、どのようなクエリを書けば良いかがパッと見で分かるような工夫をしています。
このサンプルSQLはLLMを用いて生成しており、この辺がLLMを使っている理由の1つとなります。

実際に作成されるデータカタログのページ(一部省略)

以降で、導入背景や実装の工夫についてお話ししていきます。

背景

2024/12現在、弊社ではデータETLツールとしてDataformを採用しています。
よくdbtと比較検討されるツールですが、学習コストや運用コストが低く導入ハードルが低い点において、専属のデータエンジニアがいない現在の組織状況とフィットしていることもあり採用に至りました。

このDataformですが、SQLファイルを拡張したSQLXと呼ばれるファイルを用いて、ETLワークフローを定義していきます。

SQLXファイルは以下のように記述でき、これらのファイルをGithubで管理しています。

config {
    type: "incremental",
    database: "dwh",
    schema: "warehouse",
    name: "users",
    columns: {
        user_id: "ユーザーID",
        user_name: "ユーザー名",
        ...
    },
    bigquery: {
        partitionBy: "meta_exec_date",
        partitionExpirationDays: 4000,
        requirePartitionFilter: true,
    },
    ...
}

SELECT
    user_id,
    user_name,
    ...
FROM ...

このDataform自体にはデータカタログの生成機能は備わっていないため、データカタログを作成・運用したい場合は、Google Cloudの他サービス(例:Data Catalog など)を用いて作成するか、自前でデータカタログを作成・運用していく必要があります。

今回はNotion上に自前でデータカタログを作成する運用にしました。

なぜNotionを使うことにしたのか

弊社では、ドキュメンテーションツールとして既にNotionを導入しており、全社員が日常的に利用しています。
このため、Notionの使い方に慣れており、他のツールやサービスを新たに導入する場合に比べて学習コストがかかりません。
日々利用しているツール上にデータカタログがあることで、誰でも簡単にアクセスできるという利点があります。

また、データカタログが見づらかったり操作が煩雑だったりすれば、せっかく整備したとしても使われないものになってしまう可能性があります。
データ分析を社内文化として定着させるためには、誰でも簡単に情報にアクセスでき、活用しやすい環境を整えることが不可欠だと思っています。

これらの点を考慮して今回はNotion上に作成することにしました。

全体像

主な使用技術と全体のアーキテクチャは以下の通りです。

  • OpenAI API: データカタログのサンプルSQLの自動生成などに利用
  • Notion API: Notionにデータカタログを作成・更新するために利用
  • GitHub Actions: データカタログ生成ワークフローの構築に利用
  • Python: OpenAI APIとNotion APIを用いてNotionのページを作成する処理に利用

全体の処理の流れとしては以下のようになっており、Dataformで開発を行っているだけで、意識せずにデータカタログが作成・更新されるようになっています。

全体の流れ

以降では、Github Actionsとそこから呼ばれるPythonの実装について詳しく紹介します。

データカタログ自動生成処理について

処理の流れはシンプルで、Github Actions上で以下2つの条件いずれかをトリガーとし、Notion上のデータカタログDBに新規追加 or 更新を行うPythonスクリプトを動かす、というものです。

  • Githubのmainブランチにマージしたタイミングで変更のあったSQLXファイルを対象に自動実行
  • 特定のディレクトリを指定して、そのディレクトリ配下のSQLXファイル全てを対象に手動実行

Github Actions

Actionsの処理としては、データカタログの生成対象となるSQLXファイルのリストを取得し、そのリストを CHANGED_FILES という環境変数に設定した状態で、指定したPythonスクリプトを実行する、というシンプルなワークフローとなっています。(コードは一部省略)

name: Update data catalog

on:
  push:
    branches:
      - main
    paths:
      - 'definitions/dwh/**/*.sqlx'
  workflow_dispatch:
    inputs:
      directory:
        description: 'Please enter the path of the directory you want to process'
        required: true
        default: 'definitions/dwh'

jobs:
  update-notion:
    runs-on: ubuntu-latest

    steps:
      - name: Checking out a repository
        uses: actions/checkout@v3

      - name: Python setup
        uses: actions/setup-python@v4
        with:
          python-version: '3.12'

      - name: Installing dependencies
        run: |
          python3 -m pip install -r workflow_scripts/requirements.txt

      # 自動実行:変更のあったSQLXファイル一覧を取得
      - name: Execute the process using the modified file (main push)
        id: get-changed-files
        if: github.event_name == 'push'
        uses: tj-actions/changed-files@v45
        with:
          files: |
            definitions/dwh/**/*.sqlx
          files_ignore: |
            definitions/dwh/sources/**/*.sqlx

      # 手動実行:inputsとして入力されたディレクトリ配下のSQLXファイル一覧を取得
      - name: Processes all files in the specified directory and sub-directories (workflow_dispatch)
        id: get-all-files
        if: github.event_name == 'workflow_dispatch'
        run: |
          DIRECTORY="${{ github.event.inputs.directory }}"
          if [ -d "$DIRECTORY" ]; then
            FILES=$(find "$DIRECTORY" -type f -name '*.sqlx' ! -path "$DIRECTORY/sources/*")
            FILES="${FILES//$'\n'/' '}"
            echo "all_files=$FILES" >> $GITHUB_OUTPUT
          else
            echo "The specified directory cannot be found: $DIRECTORY"
            exit 1
          fi

      # Pythonスクリプトを実行
      - name: Executing Python scripts
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
          NOTION_DB_ID: ${{ secrets.NOTION_DB_ID }}
          CHANGED_FILES: ${{ steps.get-changed-files.outputs.all_modified_files || steps.get-all-files.outputs.all_files }}
        run: |
          echo "Processing files: $CHANGED_FILES"
          python3 create_data_catalog.py

実際に動くPythonの処理について事項で紹介します。

実際に動いているPythonの処理

以下がOpenAI APIとNotion APIを用いてデータカタログを自動生成するPythonスクリプトのサンプルです(コードは一部省略)

この処理でのポイントは以下の2点です。

import os
import re

from openai import OpenAI
from notion_client import Client
from dotenv import load_dotenv

from logger import get_logger

LOGGER = get_logger(name=__name__)

load_dotenv()
openai = OpenAI(api_key=os.getenv("OPENAI_API_KEY"),)
notion = Client(auth=os.getenv("NOTION_API_KEY"))
database_id = os.getenv("NOTION_DB_ID")

# 環境変数から変更されたファイルのリストを取得
changed_files_env = os.getenv("CHANGED_FILES")
changed_files = changed_files_env.strip().split()


def read_sqlx_file(file_path: str) -> str:
    """sqlxファイルを読み込み、その内容を返す
    """
    with open(file_path, 'r') as file:
        return file.read()


def get_table_name(file_content: str) -> str:
    """sqlxファイルの内容からFROM句のテーブル名を取得する
    """
    # configブロックを抽出
    config_match = re.search(r'config\s*\{([^}]*)\}', file_content, re.DOTALL)
    table_name = ""

    if config_match:
        config_content = config_match.group(1)
        # database、schema、nameを抽出
        database_match = re.search(r'database\s*:\s*"([^"]+)"', config_content)
        schema_match = re.search(r'schema\s*:\s*"([^"]+)"', config_content)
        name_match = re.search(r'name\s*:\s*"([^"]+)"', config_content)
        
        if database_match and schema_match and name_match:
            database = database_match.group(1)
            schema = schema_match.group(1)
            name = name_match.group(1)
            table_name = f'{database}.{schema}.{name}'
            LOGGER.info(f"Table name is {table_name}")
        else:
            LOGGER.info("The database, schema, or name was not found")
            return ""
    else:
        LOGGER.info("The config block was not found")
        return ""
    
    return table_name

def generate_data_catalog(table_name:str, file_content: str) -> str:
    """LLMを用いてsqlxファイルからデータカタログを生成する
    """
    # プロンプト(一部省略)
    prompt = f"""
    弊社はDataformを用いて、BigQueryのデータ基盤を構築しています。
    そこで、添付のsqlxファイルからデータカタログを作りたいです。以下の注意事項を守りつつ、「出力したい項目」を抽出し、Markdown形式で出力してください。

    ...
    
    ### 出力したい項目
    以下の項目をH1「#」で出力し、その中身をsqlxファイルから抽出して記述してください。
    - ...
    - どのような指標を算出するのに使えるテーブルか
        - テーブルの説明やカラム名から推測して記述してください
        - 指標算出に使えるサンプルSQLの出力が可能であれば、それもセットで出力してください。自信がなければ出力しなくてOKです。
            - こちらのサンプルSQLもパーティション列を使用して出力してください。可能であれば1つだけでなく複数のサンプルがあると嬉しいです。
    - ...

    ### 実際のsqlxファイル
    """

    # LLMによるカタログの生成
    chat_completion = openai.chat.completions.create(
        messages=[
            {
                "role": "user",
                "content": prompt + "\n\n" + file_content,
            }
        ],
        model="gpt-4o-mini",
    )

    return chat_completion.choices[0].message.content


def markdown_to_notion_blocks(markdown_text: str) -> list[str]:
    """Markdown形式のテキストをNotionのBlockに変換する
    """

    blocks = []
    lines = markdown_text.split('\n')
    for line in lines:
        # ヘッディングの解析
        if re.match(r'^# ', line):
            blocks.append({
                "type": "heading_1",
                "heading_1": {
                    "rich_text": [{"type": "text", "text": {"content": line[2:]}}],
                }
            })
        elif re.match(r'^## ', line):
            blocks.append({
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": line[3:]}}],
                }
            })
        elif re.match(r'^### ', line):
            blocks.append({
                "type": "heading_3",
                "heading_3": {
                    "rich_text": [{"type": "text", "text": {"content": line[4:]}}],
                }
            })

        # コードブロックの解析
        elif re.match(r'^```', line):
            # コードブロックの開始または終了
            if 'in_code_block' in locals() and in_code_block:
                in_code_block = False
            else:
                in_code_block = True
                code_lines = []
        elif 'in_code_block' in locals() and in_code_block:
            code_lines.append(line)
            # コードブロック内では何もしない

        # 箇条書きの解析
        elif re.match(r'^- ', line):
            blocks.append({
                "type": "bulleted_list_item",
                "bulleted_list_item": {
                    "rich_text": [{"type": "text", "text": {"content": line[2:]}}],
                }
            })

        # 空行の処理
        elif line.strip() == '':
            pass  # 空行は無視
        else:
            # 通常のパラグラフ
            blocks.append({
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": line}}],
                }
            })

        # コードブロックの終了時にブロックを追加
        if 'in_code_block' in locals() and not in_code_block and 'code_lines' in locals():
            blocks.append({
                "type": "code",
                "code": {
                    "rich_text": [{"type": "text", "text": {"content": '\n'.join(code_lines)}}],
                    "language": "sql",  # 必要に応じて言語を設定
                }
            })
            del code_lines  # コードラインをリセット

    return blocks


def update_notion_page(table_name: str, catalog_content: str) -> dict:
    """Notionのデータベースにデータカタログを作成or更新する
    """

    search_response = notion.databases.query(database_id=database_id, filter={"property": "Name", "title": {"equals": table_name}})
    results = search_response.get("results")

    # markdown形式でページを作成するため、NotionのBlockに適応した形のdictに変換する
    markdown_blocks = markdown_to_notion_blocks(catalog_content)

    # rich_textプロパティに設定できる文字列の長さは2000文字以下なので、それを超える場合は文字列をカットする
    if len(catalog_content) > 2000:
        catalog_content = catalog_content[:2000]

    if results:
        page_id = results[0]["id"]

        # 「Description」プロパティを更新
        notion.pages.update(
            page_id=page_id,
            properties={
                "Description": {
                    "rich_text": [{"type": "text", "text": {"content": catalog_content}}]
                }
            }
        )

        # Notion APIではページの更新ができないため、一度ページ内全ての子ブロックを取得し削除する
        existing_children = notion.blocks.children.list(page_id)
        for child in existing_children['results']:
            block_id = child['id']
            notion.blocks.delete(block_id)

    else:
        # ページが存在しない場合は新しく作成する
        new_page = {
            "parent": {"database_id": database_id},
            "properties": {
                "Name": {
                    "title": [{"type": "text", "text": {"content": table_name}}]
                },
                "Description": {
                    "rich_text": [{"type": "text", "text": {"content": catalog_content}}]
                },
            },
        }
        page = notion.pages.create(**new_page)
        page_id = page["id"]

    res = notion.blocks.children.append(page_id, children=markdown_blocks)

    return res


if __name__ == "__main__":

    # actions/checkoutで取得したファイルのリストを処理
    for file_path in changed_files:
        if not file_path.endswith('.sqlx'):
            continue

        # ファイルの内容を取得
        file_content = read_sqlx_file(file_path)
        # sqlxの内容から、FROM句のテーブル名を取得
        table_name = get_table_name(file_content)
        # LLMを用いてデータカタログを生成
        catalog_content = generate_data_catalog(table_name, file_content)
        # Notionのデータベースにデータカタログを作成or更新
        res = update_notion_page(table_name, catalog_content)
        LOGGER.info(f"{file_path} has been processed")

サンプルSQLも自動生成している点

冒頭で紹介した通り、テーブルのメタ情報だけではなく、どのような指標を計算するのに使えるテーブルなのか、また、その指標はどのようなSQLで記述できるのか?という情報も合わせてカタログに出力しています。

このSQLの生成は、LLMを用いることで実現しています。

データカタログに付与されるサンプルSQL

NotionのURLが変更されないようにしている点

2024/12現在、Notion APIにUpdateのメソッドは無く、Delete or Createのみが行えます。

Notion上に既に存在するデータカタログをアップデートする場合、そのページ自体を削除し新規ページを作成してしまうと、新規ページのURLが元のページと異なってしまうため、例えばURLでブックマークなどをしているユーザーからしてみると、ページにアクセスできなくなってしまい不便です。

そこで、Notionのページとしては残しつつ、中身(Notion用語では子ブロック)だけを全部削除し、まっさらな状態にしたのち、更新されたSQLXの内容を用いてページに書き込む、といった処理を行なっています。
こうすることで、URLは変わらずにページの中身のみが更新されます。

実際の処理としては以下の部分です。
子ブロックを全て取得し、その要素を1つずつループしながらDelete APIを呼ぶ、というかなり泥臭いことをやっています。 (親ページのIDだけ指定したら中身を全部DeleteするAPIが欲しい・・・)

# Notion APIではページの更新ができないため、一度ページ内全ての子ブロックを取得し削除する
existing_children = notion.blocks.children.list(page_id)
for child in existing_children['results']:
    block_id = child['id']
    notion.blocks.delete(block_id)

蛇足ですが、Markdowm形式の文字列をそのままNotionのページに書き込むとUIが崩れるので、以下のようにブロック要素に適宜書き換えてあげる必要があります。
この辺はNotion APIを使う辛みだなと思いながら手を動かしていました。

def markdown_to_notion_blocks(markdown_text: str) -> list[str]:
    """Markdown形式のテキストをNotionのBlockに変換する
    """

    blocks = []
    lines = markdown_text.split('\n')
    for line in lines:
        # ヘッディングの解析
        if re.match(r'^# ', line):
            blocks.append({
                "type": "heading_1",
                "heading_1": {
                    "rich_text": [{"type": "text", "text": {"content": line[2:]}}],
                }
            })
        elif re.match(r'^## ', line):
            blocks.append({
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": line[3:]}}],
                }
            })
        elif re.match(r'^### ', line):
            blocks.append({
                "type": "heading_3",
                "heading_3": {
                    "rich_text": [{"type": "text", "text": {"content": line[4:]}}],
                }
            })

        # コードブロックの解析
        elif re.match(r'^```', line):
            # コードブロックの開始または終了
            if 'in_code_block' in locals() and in_code_block:
                in_code_block = False
            else:
                in_code_block = True
                code_lines = []
        elif 'in_code_block' in locals() and in_code_block:
            code_lines.append(line)
            # コードブロック内では何もしない

        # 箇条書きの解析
        elif re.match(r'^- ', line):
            blocks.append({
                "type": "bulleted_list_item",
                "bulleted_list_item": {
                    "rich_text": [{"type": "text", "text": {"content": line[2:]}}],
                }
            })

        # 空行の処理
        elif line.strip() == '':
            pass  # 空行は無視
        else:
            # 通常のパラグラフ
            blocks.append({
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": line}}],
                }
            })

        # コードブロックの終了時にブロックを追加
        if 'in_code_block' in locals() and not in_code_block and 'code_lines' in locals():
            blocks.append({
                "type": "code",
                "code": {
                    "rich_text": [{"type": "text", "text": {"content": '\n'.join(code_lines)}}],
                    "language": "sql",  # 必要に応じて言語を設定
                }
            })
            del code_lines  # コードラインをリセット

    return blocks

まとめ

本日はLLMを用いてNotion上にデータカタログを自動生成する方法について紹介しました。
Notionはブロックという概念で構成されていることもあり、APIの使い方は少々クセがあるので、その辺も踏まえてサンプルコードがみなさんの一助になればと思っています。

データカタログに関しては運用を始めて間もないこともあり、データ基盤の整備とセットで、今後使ってくれる人を増やす動きをしていきたいと考えています。

ゆっくりでいいんだよ人間だもの 〜 学びを続けるコツ

こんにちは!コネヒト株式会社でエンジニアリングマネージャーをしているさとやんと言います。コネヒトの2024年アドベントカレンダーの記事のひとつとして、こちらの記事を書かせていただきました。

ここ数年マネジメントが中心で、現在エンジニアとして絶賛リハビリ中、学び直し中の私が自学をどう続けているかについてお話しします。 この記事では効率的な学び方ではなくて、如何に「続けている」かを紹介していきます。

書こうと思った背景

先に告白としておくと私は何もしなくて熱心に勉強できるタイプの人間ではありません。学生の時も勉強自体が義務の様なもので、テストや進級で必要に迫られるから勉強するというタイプでしたし、他にやりたい事や好きな事がある時は今もそちらを優先してしまい、上手く工夫しないと自学を続けるのが難しい人間です。前置きが長くなりましたが、こんな私がどうやって自学を続けているかを書いていきたいと思います。

興味が少しでもある分野から始める

何か勉強するといっても勉強する内容は様々な種類があって、どこから手をつけるか正直迷ってしまうことがよくあるなぁと思っています。 必要に迫られている知識があればそこから勉強すれば良いわけですが、そうでない場合はどこからやれば良いのか分からなくなって、悩んでいる間にモチベーションが落ちて結局やらないという失敗をしていました。そのため最近はちょっとでも興味あることから勉強する様にしています。 まずは勉強する習慣や癖をつけないと継続していくというのが難しいので、「勉強するという行為」を継続するために、実用性などは考えず自分の興味あることを学ぶことを優先しています。

勉強する時間を固定化する

勉強する時間を他の予定が入りづらく、脳が元気な時間帯に固定しています。 私は元々夜型人間だったので、勉強も夜にすることが多かったのですが仕事終わりになると脳が疲れていたり、他にもやりたい事があって集中しづらいことがあったので思った様に捗らないことが多かったです。また無理に頑張っても勉強にストレスを感じてしまうということもありました。 そこで最近は生活リズムを頑張って朝型に切り替えて、平日と休日問わず朝に勉強するように時間を固定化しました。こうするとまだ疲れていない脳みそで勉強することができるし、仕事が終わった後や休日の昼以降の時間を自分の好きなことに使えるので勉強に対してストレスを感じることもなくなってきました。 私は朝が良かったので、朝に固定していますが、これはその人の生活リズムなどによって変わってくると思います。

手を動かす時は自分の好きと組み合わせる

技術の中でもプログラミングの勉強する時に一番効果的なのは、やはり実際に手を動かして作ってみることだと思います。しかし実際に手を動かす時に適当にコードを書いてみても全くモチベーションが湧かずに途中でやめてしまうというのがよくあったので、自分の好きなものに関連する物を作る様にしました。私の場合は競馬が好きなので、競走馬のデータや過去のレースの結果から予測を出したり、当日の馬の状態をチェックできるアプリケーションを作っています。こうすると勉強というより「好きなものをつくる」という方に意識が向くので作業したり、作るために必要な勉強をしている時もワクワクした気持ちになりモチベーションが維持できる様になりました。

気が向かなくなった時は無理しない

継続の話なのに何を言ってるんだ?と思った方もいらっしゃるかもしれませんが、これが結構大事だったりします。試験の様に期限があって、それまでに必要な知識を獲得しなければならない様な短期ブースト型の時は、気が向く向かないに関係なく頑張らねばならないし、期限や試験に合格というトリガーがあるので行動ができますが、普段の学習はこの様な短期ブースト型とは違い、陸上のマラソンの様にしっかりペースを守って長く走り続けることが大事です。 疲れた時はペースを緩めたり、休むフェーズを入れていかないと無意識のうちに学びが義務化してしまって、勉強そのものに苦痛を感じ始めてしまいます。 こうなると長く続けるのは難しくなってくるので、気が向かない時は途中でやめたり、休んだりするのが大切だと思っています。

短い内容でしたが、こういった工夫をして私は自学を続けています。 学びは如何に楽しんで、無理せず長く続けるかが大切だと思うので皆さんも良い自学ライフを過ごしてください。 最後まで読んでいただいて、ありがとうございました。

毎週1時間のペアプロとYWT形式で進める技術改善の1ヶ月間の取り組み

コネヒトアドベントカレンダー2024の4日目の記事です。

はじめに

コネヒトには、技術的な負債の解消や技術的にチャレンジングな取り組みを推進するための組織目標という目標制度があります。これに基づき、私たちのAndroidチームでは現在、Jetpack ComposeにおけるNavigation(以下Navigation Compose)のプロダクション導入を目指して活動しています。

Androidチームは2名体制の小さなチームですが、毎週1時間のペアプロを通じて、技術的な課題に取り組んでいます。このペアプロに加え、YWT形式を活用した取り組みを組み合わせることで、限られた時間で、継続的に改善と学びを進める仕組みを作っています。

この記事では、私たちの取り組みの具体的な進め方や得られた成果について紹介します。Navigation Composeの導入を通じて得た気づきや、ペアプロとYWT形式を組み合わせるメリットが、皆さんのチームの参考になれば幸いです。


導入までのマイルストーン

Navigation Composeをプロダクション環境で活用するため、安全策を取りながら段階的な導入を進めています。特に利用技術について詳しくない状況であったため、小さな成功体験を積み重ねることを意識し、以下のような3段階のマイルストーンを設定しました。

  1. 内部配布版用のデバッグ機能画面への導入
    Navigation Composeをまずは内部利用のみに限定した画面で試験的に導入しました。具体的には、デバッグ用機能画面への適用です。この段階では、Navigationの基本的な利用方法や構造を理解することに集中しました。

  2. シンプルな構成で、利用頻度が少ない画面への試験導入
    次のステップでは、ユーザー向けの画面でも利用頻度が少なく、構成がシンプルな画面に導入することを計画しています。この段階で得たフィードバックや課題をもとに、より複雑な画面への適用準備を進めます。

  3. 複雑な画面遷移がある既存画面への導入
    最終的には、複雑な画面遷移を含む既存画面にNavigation Composeを適用することで、本格的なプロダクション利用を目指します。

現在、私たちはマイルストーンの1段階目を完了し、次のステップに進む準備を進めています。こうした安全策を取りながら進めることで、リスクを最小限に抑えつつ、着実に技術習得とプロダクション導入の両立を図っています。


なぜペアプロとYWT形式を選んだのか

ペアプロを採用した理由の一つは、チームで新しい技術へのキャッチアップを効率的に進めるためです。特に、Navigation Composeの導入を進める中で、関わるエンジニアの双方がこの技術に詳しくない状況でした。ペアプロを通じてお互いの学びを共有しながら進めることで、技術の理解を深めると同時に、設計や実装の精度を高められると考えました。

また、ペアプロの効果を最大化するために、ペアプロのたびに進捗を振り返る仕組みとして、YWTでの記録を取り入れることにしました。 これにより、ただ一緒に作業するだけではなく、気づきを可視化し、次回につなげる具体的な改善点が見つけられるようになりました。

数ある中でYWT形式を選んだ理由は、そのシンプルさと実行のしやすさにあります。「やったこと(Y)」「わかったこと(W)」「次にやること(T)」という3つのステップだけで振り返りが完結するため、手軽に記録を残すことができ、短時間でも効果的な議論を進められます。

この形式を採用したことで、メンバー間で認識が揃いやすいという利点を感じています。同じ枠組みを使って話し合うことで、議論が整理され、次のアクションが明確になっています。YWT形式を取り入れることで、ペアプロで得た学びに対して共通認識を持つことにつなげることができるようになりました。


実際の取り組みと成果

毎週1時間のペアプロで、YWT形式を活用して作業内容や振り返りを記録しています。具体的には、毎回のペアプロ中にナビゲーターがYWTを埋めながら、最後にふたりで振り返りを行います:

  1. やったこと(Y)
    ペアプロの冒頭で取り組むタスクを具体的に整理します。例えば、「NavigationComposeを利用して下タブを実装した」といった内容です。ペアプロの冒頭に記入し、進捗によって修正を加えながら進めています。

  2. わかったこと(W)
    作業中に得た知識や気づきを記録します。ペアプロのナビゲーターが学びや気づきをメモを取るようにしています。例えば、「NavHostを利用したBottomNavigationの実装方法」や、「NavigationSuiteScaffoldを利用すると画面サイズに応じて適したNavigationに切り替わる」ということを記入しています。

  3. 次にやること(T)
    最後に次回までのアクションを明確にします。例えば、「NavControllerとNavigationSuiteScaffoldを接続する」ことを次の目標として記録し、次回のやったこと(Y)の記入に利用します。

このプロセスを通じて、進捗状況を明確にしながら継続的に改善を進めることができています。たとえば、初期段階で不明点が多かったNavigation Composeについても、段階的に理解を深め、現在ではプロダクトコードへの導入も安心して進められるレベルに到達しました。このように、YWT形式が「学びを次のアクションにつなげる仕組み」として効果的に機能していると感じています。 またペアプロ中はNow in Android Appを開き、ナビゲーターがコードリーディングしながら、実装に反映するようにしています。

特に良かったポイント

ペアプロの時間が限られていため、議論が長引くと実装時間が不足する可能性がありましたが、セッションの冒頭で「今回やること」を明確に設定し、ゴールを共有することでスムーズに進められています。例えば、「NavigationComposeで下タブを実装する」といったように、シンプルな目標を決めています。これにより、議論が発散することを防ぎ、限られた時間内で効率的に進められるようになりました。

また、最初は25分ごとにドライバー(実装担当)とナビゲーター(サポート担当)を交代していましたが、調べながらの実装を伴う場面では、頻繁な交代が逆に作業を停滞させることが分かりました。このため、週ごとにドライバーとナビゲーターを交代する形に変更しました。これにより、集中力を維持しやすくなり、ペアプロ全体の流れもスムーズになりました。 これらの工夫を取り入れることで、ペアプロの効率が向上すると同時に、議論と実装のバランスを保ちながら取り組みを進めることができました。


他のチームでも試せるポイント

今回のペアプロとYWT形式の取り組みは、どんなチームでも応用しやすいシンプルな方法です。以下に、他のチームでも取り入れやすいポイントをまとめました。

  1. 短時間から始める
    毎週1時間という短い時間でも、振り返りを続けることで着実に改善を進められます。「忙しいからできない」と思いがちですが、小さな一歩を踏み出すことが重要です。

  2. 明確な目標を設定する
    セッションの冒頭で「今回やること」を具体的に決めることで、議論が発散せず、効率的に進められます。目標は具体的かつ実行可能な内容に絞りましょう。

  3. 役割交代の工夫
    作業時間が短い場合において、ドライバーとナビゲーターの役割を週替わりで交代する方法は、慣れない技術を採用する際に効果的です。この方法を導入することで、作業のスムーズさと集中力を両立できます。

  4. YWT形式のシンプルさを活かす
    振り返りの記録を「やったこと」「わかったこと」「次にやること」の3つに整理するだけで、進捗と次のアクションが明確になります。特に「次にやること」を記録しておくだけでも、次回の作業がスムーズになります。

小さな取り組みをコツコツ積み重ねることで、チーム全体で学びを得るにつながります。ぜひこの方法を試してみてください!


まとめ:振り返りの効果と継続の価値

今回ご紹介した、毎週1時間のペアプロとYWT形式を活用した改善プロセスは、シンプルでありながら効果的な方法です。やったことを振り返り、学びを次のアクションにつなげるサイクルを続けることで、チーム全体の知識が着実に積み上がっていきます。

特に、短い時間であっても定期的に振り返ることで、課題を早期に発見し、スムーズに解決へとつなげられる点が大きなメリットです。また、チーム全員が同じフォーマットを使うことで、コミュニケーションがより円滑になり、効率的に議論を進められるようになります。

今後の改善ポイント

この取り組みをさらに発展させるために、以下の改善ポイントを考えています:

  • 時間の使い方を見直す
    毎週1時間のペアプロは知識をつける上では効率的ですが、改善できる物量が限られています。そのため、具体的には、1時間のペアプロに加え、別途作業用の時間を確保して、より多くの作業を進めるようにすることを検討しています。

  • 学んだことを整理し、設計やドキュメントに反映する
    これまでのペアプロで得た知識や気づきを整理し、設計を見直す予定です。YWTの内容をもとに、リファクタリングを行い、ドキュメントを更新するなどの取り組みを進めていきます。

改善プロセスにおいて大切なのは、「小さく始めて継続し、必要に応じてスケールアップすること」です。この取り組みが、あなたのチームにとって新たな発見や成長のきっかけになれば嬉しいです。ぜひこの方法を試し、楽しみながらチームの成長を実現してください!

ママリiOSアプリで今年取り組んだ画面の状態管理の改善について

「コネヒト Advent Calendar 2024」の3日目のブログです!

adventar.org

iOSエンジニアのyoshitakaです。

先日、初めての船釣りにコネヒトの釣り好きメンバーと一緒に行ってきました!

釣り、楽しいですね🎣 天気が良くて景色も最高でした☀️

3時間ほど船の上にいて一匹も釣れなかったのですが、それでも楽しいと思えました!

次回、まずは一匹釣れるように頑張りたいと思います💪


さて、今回は今年ママリiOSアプリで取り組んだ改善について紹介します。

どんな課題を改善したのかを説明する前に、ママリiOSアプリについて簡単に説明します。

ママリでは、ユーザー同士が質問を投稿し、回答をもらうことができるQ&Aプラットフォームを提供しています。 アプリ内の多くの画面において、リスト形式の質問フィードを表示しています。 この質問フィードでは、ページングやPullToRefreshで新しい質問を取得することができます。

この前提を踏まえて、抱えていた課題と改善した内容を紹介します。

課題に感じていたこと

画面に表示するコンテンツを取得する上での異常系の状態管理が複雑になっていました。

例えば、コンテンツの取得に失敗した状態と言っても2つのケースがありました。

  • 初期コンテンツ取得時にエラーが発生した場合: エラー画面を表示し、リトライを促す。表示するコンテンツがないため、リスト画面は表示しない
  • ページングやPullToRefreshでエラーが発生した場合: アラートを表示する。取得済みのコンテンツがあるため、リスト画面は表示する

このようなパターンを考慮して実装する際、ViewModelの状態管理が複雑になり、コードの可読性が低下していました。

どんな改善をしたか

初期コンテンツの取得が成功したか失敗したかで画面の状態を明確に分けることにしました。

  • 初期表示の画面の状態をidle, loading, failed, loadedの4つに分ける
  • 初期コンテンツの取得に成功した場合loadedとなりこの時初めてリスト画面が表示され、loadedの状態が維持される
  • 初期コンテンツの取得に失敗した場合はfailedとなり、エラー画面を表示しリトライのみ行えるようにする

改善前後の変化を図示すると以下のようになります。

改善前後の処理フローイメージ

この改善を実現するために、AsyncContentView, AsyncContentLoadableObjectを作成しました。

以下に今回の改善に関連する実装例を示します。(関連する部分のみ抜粋しています)

画面表示の状態はidleから始まり、初期コンテンツの取得が成功すると.loading.loadedの状態に変わり、ここで初めてリスト画面が表示されます。

つまりAsyncContentViewが実装された画面では、同期的に初期コンテンツの取得が成功するまで、ローディングのみが表示されます。その後エラーが発生した場合はリトライの実行のみ行えるエラー画面が表示されます。

一度取得に成功すればそれ以降は取得済みのコンテンツが表示されたままになり、再取得するかどうかはユーザーのアクション次第となります。

struct AsyncContentView<Source: AsyncContentLoadableObject, Content: View>: View {
    @ObservedObject var source: Source
    var content: () -> Content

    var body: some View {
        switch source.loadingState {
        case .idle:
            Color.clear
                .task {
                    await source.load()
                }
        case .loading:
            ProgressView()
                .progressViewStyle(.circular)
                .tint(Color.accentPrimary)
        case .failed:
            NetworkErrorView {
                Task {
                    await source.load()
                }
            }
        case .loaded:
            content()
                .loadingView(source.showLoading)
                .errorAlertView(errorHandler: source.errorHandler)
        }
    }
}
@MainActor
protocol AsyncContentLoadableObject: ObservableObject, Sendable {
    var loadingState: LoadingState { get }

    var errorHandler: ErrorHandler { get }
    var showLoading: Bool { get }

    func load() async
}

実際に質問フィードをもつ画面に適用した例が以下となります。

この改善を行なったことで、ユーザーに見せるコンテンツが正常に取得できている状態なのかどうかが明確になり、コードの可読性が向上しました。

また、同様の画面を作成する際にもAsyncContentViewを利用することで、コードの再利用性も向上しました。

struct HogeView: View {
    @StateObject var viewModel: HogeViewModel

    var body: some View {
        AsyncContentView(source: viewModel) {
            List {
                ForEach(viewModel.contentList, id: \.id) { feedContent in
                    ContentView(feedContent: feedContent)
                }

                if !viewModel.isAllLoaded {
                    ProgressView()
                        .onAppear {
                            viewModel.loadMore()
                        }
                }
            }
            .refreshable {
                await viewModel.refresh()
            }
        }
    }
}
@MainActor
final class HogeViewModel: AsyncContentLoadableObject {
    @Published var loadingState: LoadingState = .idle

    @Published var errorHandler = ErrorHandler()
    @Published var showLoading = false

    func load() async {
        loadingState = .loading

        do {
            try await fetchFeedQuestions()
            loadingState = .loaded
        } catch {
            loadingState = .failed
        }
    }

    func refresh() async {
        showLoading = true

        do {
            try await fetchFeedQuestions()
        } catch let error {
            updateErrorHandler.handle(error)
        }
        showLoading = false
    }

    private func fetchFeedQuestions() async throws {
        let response = try await self.apiService.call(
            from: QuestionRequest.CategoryFeed()
        )
        ...
    }

    func loadMore() {
        showLoading = true

        Task {
            do {
                let response = try await self.apiService.call(
                    from: QuestionRequest.CategoryFeed()
                )
                ...
            } catch let error {
                errorHandler.handle(error)
            }
            showLoading = false
        }
    }
}

まとめ

今回の改善は小さなものではありましたが、新規で画面を作る際にとても役立ちました。 異常系は動作確認も後回しになることが多いため、共通化しておくことで実装漏れが未然に防ぐことができ重要だと感じました。

今後もiOSアプリの品質向上を目指し、設計の見直しを積極的に行っていきたいと思います。

参考

www.swiftbysundell.com

4年運用している行動ログのドキュメンテーションの工夫

コネヒトアドベントカレンダー2024 1日目の記事です。

この記事では、コネヒトで4年間運用している行動ログのドキュメント化について紹介します。

行動ログの概要

コネヒトでは、Webサービスやモバイルアプリの開発をしています。その中で、ユーザーがどんな操作をしたのか(例えば、画面を開いたり、ボタンをタップしたり)を「行動ログ」として記録しています。このデータを分析して、サービス改善に役立てています。

しかし、次のような課題がありました。

  • 行動ログがどのバージョンから実装されているのか分からない。
  • イベントがどのタイミングで発火するのか明確でない。
  • 情報がまとまっていないため、ソースコードを確認しなければならない。

これらの課題を解決するために、2020年に行動ログのドキュメント整備を始めました。

ドキュメント作成の取り組み

選んだアプローチ

ドキュメントは、理想的にはコードから自動生成される形が望ましいですが、実装を大幅に変更する必要があり、工数が膨大になると判断しました。そこで、ライトに始めることを優先し、以下のような形式でMarkdownを用いて人の手で記述することにしました。

フォーマット例

コードをコピーする
# 行動ログのフォーマット

# EventName
<!-- イベント名 -->

## Description
<!-- イベントの説明文 -->

## Trigger
<!-- イベントが発火する条件 -->

## Parameter

|key|type|requirement|description|available version|
|:--|:--|:--|:--|:--|
|a|b|c|d|e|

## Send to
- [ ] Mixpanel
- [ ] Firebase

## Version

### Available Version
- iOS
  - vx.x.x~
- Android
  - vx.x.x~
- Web
  - 202x/xx/xx 以降

### Unavailable Version
<!-- 不具合などで動いていない時期があれば記載する -->

このフォーマットは2020年に作ってからほとんど変わっていませんが、今もこれで十分便利に使えています。

運用ルール

ドキュメントの運用は以下のように行っています。

  • Markdownで記述したドキュメントをGitHubで管理。
  • 実装前にドキュメントを作成し、GitHub上でレビューを受けるルールを導入。
  • 実装変更時にドキュメントの更新漏れを防ぐため、アプリケーション実装時のPRレビュー時のテンプレートに「行動ログの仕様を記載したか」というチェックボックスを追加。

社内への浸透施策

行動ログはエンジニアだけでなく、分析を行う他職種のメンバーも利用します。そのため、GitHubに不慣れな人でも使いやすい形にする必要がありました。そこで、Markdown形式のドキュメントをNext.jsを使ってWebページ化し、社内で閲覧できるようにしました。

また、ドキュメントの認知を広めるために以下の施策を行いました。

  • 行動ログについて質問された際に「こちらを参照してください」とリンクを送る。
  • Slackに行動ログWebページのリンクを簡単に表示できるショートカットを作成。
  • 行動ログの分析ワークショップを開催し、ドキュメントの活用方法を布教。

これらの取り組みを続けた結果、現在ではほとんどのメンバーがこのドキュメントを認知して活用してくれていると思います。

4年運用してみて

運用を始めてから4年が経過しましたが、行動ログのドキュメントは引き続きメンテナンスされ、サービス改善に活用されています。

課題と振り返り

一方で、次のような課題も明らかになりました。

Next.jsでWebページ化したものの、メンテナンスする人はごく一部だけでした。「社内ツールは実装した人しか触らない」というあるある問題ですね。 今なら、最初からNotionなどの既存ツールを使うのも良い選択肢かもしれません。

まとめ

ドキュメントは「作って終わり」ではなく、使われ続け、適切にメンテナンスされることが大事です。そのためには、会社の規模や文化に合った方法を選ぶことが重要だと思います。

コネヒトの事例が、みなさんのヒントになれば嬉しいです!