1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Databricks × Salesforce × watsonx】Databricks でリコメンドモデルを作る

1
Last updated at Posted at 2026-05-06

シリーズ: 「Databricks × Salesforce × watsonx、全部無料で繋いで確かめてみた」

  • Part 1: Databricks × Salesforce × watsonx 使いどころを整理してみた
  • Part 2-1(この記事): Databricks でリコメンドモデルを作る ← いまここ
  • Part 2-2: Agentforce でファン向けAgentを作る(予定)
  • Part 2-3: Orchestrate で業務Agentを作る+まとめ(予定)
  • Part 3: Zero Copy / BYOM / MCP で本番アーキテクチャを組む(予定)

はじめに

Part 1 では、Databricks・Salesforce Agentforce・watsonx Orchestrate の3製品を機能マップで比較して、「似ているけど深さが違う」ことを整理してみた。

  • Databricks = データの頭脳(データ処理・モデル構築・評価)
  • Salesforce Agentforce = 顧客の窓口(CRMデータ・顧客接点Agent)
  • watsonx Orchestrate = 業務の手足(マルチシステム横断の業務自動化)

整理したのはいいけど、「じゃあ実際に繋いだらどうなるの?」が気になる。しかも3つとも無料で使える環境がある。やってみるしかない。

Part 2 では、架空のプロ野球球団 「大正ブルーベルズ」 を題材に、ファンエンゲージメント向けのAI基盤を3つの無料環境で構築する。ボリュームが大きいので3回に分けている。

  • Part 2-1(この記事): Databricks でリコメンドモデルを構築して、REST API として公開

  • Part 2-2: Salesforce Agentforce でファン向けAgentを構築して、Databricks API を呼び出す

  • Part 2-3: watsonx Orchestrate でスタッフ向け業務Agentを構築して、3製品を比較

まずはデータとモデルという「土台」を作るところから。

大正ブルーベルズとは

架空のプロ野球球団。略称: ベルズ。

ベルズでは数年前から Salesforce でファンクラブの会員管理 をしている。会員情報、チケット購入、グッズ購入、イベント来場履歴――CRMとしての基本的なデータはすでに溜まっている。

ただ、最近こんな課題が出てきた:

  • データは溜まっているが、Salesforce のレポートやダッシュボードだけでは「次にどのファンにどのイベントを勧めるべきか」がわからない
  • 「あなたにおすすめのイベント」「あなたが好きそうなグッズ」のようなパーソナライズをやりたいが、予測モデルを作る環境がない
  • 将来的には、ファンクラブアプリのアクセスログやサブスクの動画視聴履歴、トレカゲームの課金状況なども分析に使いたいが、Salesforce だけでは処理が追いつかない

Salesforce にもカスタム予測モデルを作れる Einstein Prediction Builder があったが、2026年3月に廃止が発表された。後継の Data Cloud Model Builder はノーコードで手軽だが、好きなライブラリで特徴量を作り込みたい、Salesforce 以外のデータも学習に使いたいとなると、やはり専用のML基盤がほしくなる。

そこで Databricks を分析・ML基盤として追加導入 し、さらに社内の業務自動化のために watsonx Orchestrate も検討に入った――というのが今回のストーリー。

Salesforce が先にあって、データ活用の需要が高まって Databricks を導入。実際のケースでもよくあるパターンだと思う。

今回つくるもの

Part 2 全体を通して、以下の3つを構築する。

  1. リコメンドモデル(Databricks): ファンの来場・購買パターンから「次に来そうなイベント」を予測
  2. ファン向けAgent(Agentforce): 「おすすめのイベントは?」に答える接客Agent
  3. スタッフ向けAgent(watsonx Orchestrate): 「ターゲットリスト作って」に答える業務Agent

この記事(Part 2-1)では 1 のリコメンドモデルを作る。 コードはすべてコピペで動くようにしてあるので、記事を見ながら20分くらいで一通り動かせるはず。

なお、今回のハンズオンでは来場履歴とチケット・グッズの購買データだけを使っている。アプリログやサブスク視聴履歴といったデータソースの拡張は Part 3 で扱う予定。

アーキテクチャ全体像

Part 2 全体のアーキテクチャを先に見せておく。今回の Part 2-1 では左側の Databricks 部分を構築する。

[Databricks Free Edition]           [Salesforce Developer Ed.]          [watsonx Orchestrate Trial]
                                     (Part 2-2 で構築)                 (Part 2-3 で構築)
 ダミーデータ生成(Python)           標準オブジェクトにデータ投入         Salesforce コネクタ接続
        │                            ├─ Contact(ファン会員)           Databricks API を Tool 登録
        │                            ├─ Campaign(イベント)                    │
        ▼                            ├─ CampaignMember(来場履歴)              │
 リコメンドモデル構築                 └─ Opportunity(チケット・グッズ)          │
 (scikit-learn)                              │                               │
        │                                     │                               │
        ▼                                     ▼                               ▼
 Model Serving API 公開  ◄── REST ──  Agentforce                      スタッフ向け Agent
 (REST endpoint)          呼び出し   ファン向け Agent                 SF + Databricks を横断
                                      「おすすめイベントは?」          「ターゲットリスト作って」

データの上流(モデル)から下流(Agent)へ、順に構築していく流れ。

使用環境

3製品とも無料環境を使う。各エディションの詳細や注意点は Part 1 にまとめてある。

製品 エディション 期限
Databricks Free Edition 無期限(日次クォータあり)
Salesforce Developer Edition(Agentforce + Data 360) 無期限(45日ごとにログイン要)
watsonx Orchestrate Trial 30日(延長申請可)

Tips: watsonx Orchestrate は30日の Trial 制限がある。Databricks と Salesforce は期限がないので、先にそちらのアカウントを作っておいて、Orchestrate の Trial は Part 2-3 に入るタイミングでアクティベートするのがおすすめ。

サンプルデータ

今回使うダミーデータの構成。

テーブル(Databricks) 件数 主なカラム SF標準オブジェクト(Part 2-2で使用)
fans(ファン会員) 200名 fan_id, name, age, gender, area, favorite_player, membership_tier Contact
events(イベント) 30件 event_id, event_name, event_type(試合/ファン感/トークショー等), event_date, venue Campaign
attendance(来場履歴) ~2,000件 fan_id, event_id CampaignMember
purchases(チケット・グッズ購入) 1,500件 fan_id, item_type(ticket/goods), item_name, amount, purchased_at Opportunity

Salesforce 側はすべて標準オブジェクトで構成している。カスタムオブジェクトを作らないので、セットアップが軽い。Account は Part 2 では球団1社のみで、Part 3 で提携ホテルチェーンや飲食チェーンが入ってくる。

データ量はモデルがそれなりの精度を出せる程度にしてある。この記事の中でデータ生成用の Notebook コードをそのまま載せているので、コピペで再現できる。


ここからが本題。Databricks Free Edition でリコメンドモデルを構築して、REST API として公開する。

Step 1: Databricks — リコメンドモデル構築 & API公開

所要時間目安: 約20分

このStepでやること:

  1. Free Edition のアカウント作成 & ワークスペースにログイン
  2. Notebook でダミーデータを生成してテーブル化
  3. scikit-learn でリコメンドモデルを構築
  4. MLflow で実験記録 → Unity Catalog にモデル登録
  5. Model Serving で REST API として公開

最終的に「ファンのプロフィールを投げると、おすすめイベントとスコアが返ってくる REST API」ができあがる。

1-1. Free Edition のセットアップ

Databricks Free Edition にアクセスして、アカウントを作成する。

セットアップ自体は数分で終わる。メールアドレスを登録して、ワークスペースが作成されたらログイン。

いくつか注意点:

  • Free Edition はサーバーレス限定。クラスター作成の画面は出てこない。Notebook を開けばそのまま実行できる
  • 日次クォータがある(99%のユーザーは到達しない水準とのこと)。ハンズオン程度なら問題ない
  • 非商用利用限定。業務で本格的に検証するなら Free Trial を使う

ログインしたら、左メニューから Workspace を開いて、新しい Notebook を作成。言語は Python を選択。

1-2. ダミーデータの生成

最初の Notebook で、大正ブルーベルズのファンデータを生成する。

生成するデータは4テーブル。すべて Unity Catalog 上のテーブルとして保存する。

# Notebook: 01_generate_data

import pandas as pd
import numpy as np
from datetime import datetime, timedelta

np.random.seed(42)

# --- カタログとスキーマの準備 ---
# Free Edition のデフォルトカタログは "workspace"("main" ではないので注意)
catalog_name = "workspace"
schema_name = "bluebells"

spark.sql(f"CREATE SCHEMA IF NOT EXISTS {catalog_name}.{schema_name}")

# --- 1. ファン会員(fans) ---
n_fans = 200

players = ["山田太郎", "佐藤次郎", "鈴木三郎", "田中四郎", "高橋五郎",
           "伊藤六郎", "渡辺七郎", "中村八郎", "小林九郎", "加藤十郎"]
areas = ["大阪市内", "大阪府内", "兵庫", "京都", "奈良", "和歌山", "関東", "その他"]
tiers = ["レギュラー", "シルバー", "ゴールド", "プラチナ"]

fans = pd.DataFrame({
    "fan_id": [f"FAN_{i:04d}" for i in range(1, n_fans + 1)],
    "name": [f"ファン{i:04d}" for i in range(1, n_fans + 1)],
    "age": np.random.randint(18, 65, n_fans),
    "gender": np.random.choice(["M", "F", "Other"], n_fans, p=[0.55, 0.40, 0.05]),
    "area": np.random.choice(areas, n_fans, p=[0.30, 0.20, 0.15, 0.10, 0.08, 0.05, 0.07, 0.05]),
    "favorite_player": np.random.choice(players, n_fans),
    "membership_tier": np.random.choice(tiers, n_fans, p=[0.40, 0.30, 0.20, 0.10]),
})

spark.createDataFrame(fans).write.mode("overwrite").saveAsTable(f"{catalog_name}.{schema_name}.fans")
print(f"fans: {n_fans}")

# --- 2. イベント(events) ---
n_events = 30

event_types = ["公式戦", "オープン戦", "ファン感謝祭", "トークショー", "野球教室"]
venues = ["ベルズスタジアム", "ベルズスタジアム サブグラウンド", "ベルズホール"]

base_date = datetime(2025, 4, 1)
events = pd.DataFrame({
    "event_id": [f"EVT_{i:03d}" for i in range(1, n_events + 1)],
    "event_name": [f"イベント{i:03d}" for i in range(1, n_events + 1)],
    "event_type": np.random.choice(event_types, n_events, p=[0.40, 0.15, 0.15, 0.20, 0.10]),
    "event_date": [(base_date + timedelta(days=int(d))).strftime("%Y-%m-%d")
                   for d in sorted(np.random.randint(0, 180, n_events))],
    "venue": np.random.choice(venues, n_events, p=[0.60, 0.25, 0.15]),
})

spark.createDataFrame(events).write.mode("overwrite").saveAsTable(f"{catalog_name}.{schema_name}.events")
print(f"events: {n_events}")

# --- 3. 来場履歴(attendance) ---
n_attendance = 2000

attendance = pd.DataFrame({
    "fan_id": np.random.choice(fans["fan_id"], n_attendance),
    "event_id": np.random.choice(events["event_id"], n_attendance),
})
# fan_id + event_id の重複を除去(同じイベントに2回来場はないとする)
attendance = attendance.drop_duplicates().reset_index(drop=True)

spark.createDataFrame(attendance).write.mode("overwrite").saveAsTable(f"{catalog_name}.{schema_name}.attendance")
print(f"attendance: {len(attendance)}")

# --- 4. チケット・グッズ購入(purchases) ---
n_purchases = 1500

ticket_items = ["内野指定席", "外野自由席", "バックネット裏", "ファミリーシート"]
goods_items = ["ユニフォーム", "タオル", "キャップ", "Tシャツ", "アクリルスタンド", "トレカパック"]

item_types = np.random.choice(["ticket", "goods"], n_purchases, p=[0.45, 0.55])
item_names = []
amounts = []
for t in item_types:
    if t == "ticket":
        item_names.append(np.random.choice(ticket_items))
        amounts.append(int(np.random.choice([2500, 3500, 5000, 8000])))
    else:
        item_names.append(np.random.choice(goods_items))
        amounts.append(int(np.random.choice([1500, 2000, 3000, 5000, 8000])))

purchases = pd.DataFrame({
    "fan_id": np.random.choice(fans["fan_id"], n_purchases),
    "item_type": item_types,
    "item_name": item_names,
    "amount": amounts,
    "purchased_at": [(base_date + timedelta(days=int(d))).strftime("%Y-%m-%d")
                     for d in sorted(np.random.randint(0, 180, n_purchases))],
})

spark.createDataFrame(purchases).write.mode("overwrite").saveAsTable(f"{catalog_name}.{schema_name}.purchases")
print(f"purchases: {n_purchases}")

print("\n全テーブル作成完了!")

実行すると、workspace.bluebells スキーマの下に4つのテーブルができる。左メニューの Catalog から確認できる。

1-3. 特徴量の作成 & リコメンドモデル構築

新しい Notebook を作って、モデル構築に進む。

やることはシンプルで、「このファンは次にどのイベントに来そうか」を予測するモデルを作る。ファンごとの来場パターンと購買パターンを特徴量にして、イベントタイプごとの来場確率を出す。

# Notebook: 02_build_model

import pandas as pd
import numpy as np
import mlflow
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, f1_score
from sklearn.preprocessing import LabelEncoder

# --- データ読み込み ---
fans_df = spark.table("workspace.bluebells.fans").toPandas()
events_df = spark.table("workspace.bluebells.events").toPandas()
attendance_df = spark.table("workspace.bluebells.attendance").toPandas()
purchases_df = spark.table("workspace.bluebells.purchases").toPandas()

# --- 特徴量エンジニアリング ---

# ファンごとの来場回数
attendance_count = attendance_df.groupby("fan_id").size().reset_index(name="total_attendance")

# ファンごとのイベントタイプ別来場回数
att_with_type = attendance_df.merge(events_df[["event_id", "event_type"]], on="event_id")
type_counts = att_with_type.groupby(["fan_id", "event_type"]).size().unstack(fill_value=0)
type_counts.columns = [f"att_{c}" for c in type_counts.columns]
type_counts = type_counts.reset_index()

# ファンごとの購買サマリ
purchase_summary = purchases_df.groupby("fan_id").agg(
    total_spend=("amount", "sum"),
    purchase_count=("amount", "count"),
    ticket_count=("item_type", lambda x: (x == "ticket").sum()),
    goods_count=("item_type", lambda x: (x == "goods").sum()),
).reset_index()

# 特徴量テーブルを結合
features = fans_df[["fan_id", "age", "gender", "area", "membership_tier"]].copy()
features = features.merge(attendance_count, on="fan_id", how="left")
features = features.merge(type_counts, on="fan_id", how="left")
features = features.merge(purchase_summary, on="fan_id", how="left")
features = features.fillna(0)

# カテゴリ変数をエンコード
label_encoders = {}
for col in ["gender", "area", "membership_tier"]:
    le = LabelEncoder()
    features[col] = le.fit_transform(features[col])
    label_encoders[col] = le

# --- 教師ラベルの作成 ---
# 「次に来場するイベントタイプ」を予測する(最も来場回数が多いタイプを正解とする簡易版)
fan_top_type = att_with_type.groupby("fan_id")["event_type"].agg(
    lambda x: x.value_counts().index[0]
).reset_index(name="next_event_type")

features = features.merge(fan_top_type, on="fan_id", how="left")
features = features.dropna(subset=["next_event_type"])

# ラベルエンコード
le_target = LabelEncoder()
features["label"] = le_target.fit_transform(features["next_event_type"])

# --- 学習 ---
feature_cols = [c for c in features.columns if c not in ["fan_id", "next_event_type", "label"]]
X = features[feature_cols]
y = features["label"]

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# MLflow で実験を記録
mlflow.set_experiment("/bluebells-recommendation")

with mlflow.start_run(run_name="gradient_boosting_v1") as run:
    model = GradientBoostingClassifier(
        n_estimators=100,
        max_depth=4,
        learning_rate=0.1,
        random_state=42,
    )
    model.fit(X_train, y_train)

    y_pred = model.predict(X_test)
    acc = accuracy_score(y_test, y_pred)
    f1 = f1_score(y_test, y_pred, average="weighted")

    mlflow.log_param("n_estimators", 100)
    mlflow.log_param("max_depth", 4)
    mlflow.log_param("learning_rate", 0.1)
    mlflow.log_metric("accuracy", acc)
    mlflow.log_metric("f1_weighted", f1)

    print(f"Accuracy: {acc:.3f}")
    print(f"F1 (weighted): {f1:.3f}")

    event_types_list = list(le_target.classes_)
    print(f"イベントタイプ: {event_types_list}")

    # --- pyfunc ラッパー ---
    # Model Serving はデフォルトで predict() を呼ぶため、クラスラベル(整数)しか返らない。
    # イベントタイプ名と確率をセットで返すように、カスタム pyfunc でラップする。
    class RecommendationModel(mlflow.pyfunc.PythonModel):
        def __init__(self, sklearn_model, event_types):
            self.sklearn_model = sklearn_model
            self.event_types = event_types

        def predict(self, context, model_input, params=None):
            proba = self.sklearn_model.predict_proba(model_input)
            results = []
            for row in proba:
                ranked = sorted(
                    zip(self.event_types, row),
                    key=lambda x: x[1],
                    reverse=True,
                )
                results.append({
                    "recommendations": [
                        {"event_type": name, "score": round(float(score), 3)}
                        for name, score in ranked
                    ]
                })
            return results

    wrapping_model = RecommendationModel(model, event_types_list)

    # シグネチャを記録
    from mlflow.models.signature import infer_signature
    sample_output = wrapping_model.predict(None, X_train.head(3))
    signature = infer_signature(X_train.head(3), sample_output)

    mlflow.pyfunc.log_model(
        artifact_path="model",
        python_model=wrapping_model,
        signature=signature,
        input_example=X_train.head(3),
    )

    run_id = run.info.run_id
    print(f"\nMLflow Run ID: {run_id}")

実行すると、MLflow のエクスペリメントにモデルが記録される。左メニューの Experiments から確認できる。

補足: ダミーデータなので精度自体はそこまで意味がない。ここでのポイントは「Notebook 上で特徴量作成 → 学習 → MLflow 記録まで一気通貫でできる」という流れのほう。本番ではもっと丁寧に特徴量を作り込むことになると思う。

1-4. Unity Catalog にモデルを登録

学習したモデルを Unity Catalog に登録しておく。こうしておくと、あとで Model Serving に乗せるのが楽になる。

# 同じ Notebook の続きに追加、もしくは新しいセルとして実行

import mlflow

catalog_name = "workspace"
schema_name = "bluebells"
model_name = "fan_event_recommendation"

# --- 前のセルの run_id を取得 ---
# セルをまたぐと変数が参照できないことがあるため、MLflow API から最新の Run を取得する
mlflow.set_experiment("/bluebells-recommendation")
experiment = mlflow.get_experiment_by_name("/bluebells-recommendation")
runs = mlflow.search_runs(
    experiment_ids=[experiment.experiment_id],
    order_by=["start_time DESC"],
    max_results=1,
)
run_id = runs.iloc[0].run_id
print(f"最新の Run ID: {run_id}")

# Unity Catalog にモデルを登録
mlflow.set_registry_uri("databricks-uc")

model_uri = f"runs:/{run_id}/model"
registered_model_name = f"{catalog_name}.{schema_name}.{model_name}"

mv = mlflow.register_model(
    model_uri=model_uri,
    name=registered_model_name,
)

print(f"モデル登録完了: {registered_model_name}")
print(f"   Version: {mv.version}")

注意: モデル構築セルで定義した run_id は、セルを分けて実行すると NameError になることがある(with ブロック内のスコープ問題)。上記のように mlflow.search_runs で最新の Run を取得する方が確実。

登録されたモデルは、左メニューの Catalog をクリックし、ツリーから workspacebluebells スキーマを選択して、右側ペインの Models タブから確認できる。

1-5. Model Serving で REST API を公開

いよいよ最後のステップ。登録したモデルを REST API として公開する。

  1. 左メニューの Serving をクリック
  2. Create serving endpoint をクリック
  3. 以下を設定:
    • Name: bluebells-recommendation
    • Entity: 先ほど登録した workspace.bluebells.fan_event_recommendation を選択
    • Version: 最新バージョンを選択
    • Compute type: CPU を選択(デフォルトは GPU Small になっているが、scikit-learn モデルは CPU で十分。GPU を選んでも速くならず、クォータを余計に消費するだけ)
  4. Create をクリック

エンドポイントが Ready になるまで数分かかる。

動作確認

エンドポイントが Ready になったら、テストリクエストを投げる。

import requests
import json

# Databricks ホスト名とトークンを取得
databricks_host = dbutils.notebook.entry_point.getDbutils().notebook().getContext().apiUrl().get()
token = dbutils.notebook.entry_point.getDbutils().notebook().getContext().apiToken().get()
endpoint_name = "bluebells-recommendation"

# テスト用のファンデータ(特徴量と同じカラム構成)
test_data = {
    "dataframe_records": [
        {
            "age": 35,
            "gender": 1,       # エンコード済みの値
            "area": 0,
            "membership_tier": 2,
            "total_attendance": 12,
            "att_公式戦": 8,
            "att_オープン戦": 2,
            "att_ファン感謝祭": 1,
            "att_トークショー": 1,
            "att_野球教室": 0,
            "total_spend": 45000,
            "purchase_count": 10,
            "ticket_count": 6,
            "goods_count": 4,
        }
    ]
}

response = requests.post(
    f"{databricks_host}/serving-endpoints/{endpoint_name}/invocations",
    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    json=test_data,
)

result = response.json()
# ensure_ascii=False で日本語をそのまま表示
print(json.dumps(result, indent=2, ensure_ascii=False))

レスポンスにはイベントタイプ名とスコアがセットで返ってくる。たとえばこんな感じ:

{
  "predictions": [
    {
      "recommendations": [
        {"event_type": "公式戦", "score": 1.0},
        {"event_type": "トークショー", "score": 0.0},
        {"event_type": "ファン感謝祭", "score": 0.0},
        {"event_type": "オープン戦", "score": 0.0},
        {"event_type": "野球教室", "score": 0.0}
      ]
    }
  ]
}

スコア順にソートされているので、先頭が最もおすすめのイベントタイプ。この例だと「公式戦」がスコア 1.0 で最有力。Part 2-2 の Agentforce からは、この API を呼び出して「○○さんには公式戦の観戦がおすすめです(スコア: 1.0)」のような応答を返す予定。

補足: 今回はダミーデータの分布が極端なので、スコアが 1.0 / 0.0 / 0.0 / ... のようにピーキーになりやすい。実用ではもう少し滑らかな確率分布になるよう特徴量を工夫することになるが、ハンズオンの動作確認としてはこれで OK。

補足: デフォルトの mlflow.sklearn.log_model だと、Model Serving は predict() を呼ぶため整数のクラスラベルしか返らない。確率やイベントタイプ名も返したかったので、mlflow.pyfunc.log_model でカスタムラッパーを使っている。

ここまでのまとめ

Notebook 2つ(データ生成 + モデル構築)を動かすだけで、ここまでできた:

  • ダミーデータ 4テーブルの生成(workspace.bluebells スキーマ)
  • 特徴量エンジニアリング & GradientBoosting モデルの学習
  • MLflow での実験記録(パラメータ・メトリクス・モデルアーティファクト)
  • Unity Catalog へのモデル登録
  • Model Serving endpoint での REST API 公開

Free Edition でここまでできるのは正直驚いた。Notebook → MLflow → Unity Catalog → Model Serving の流れがシームレスに繋がっていて、「データサイエンティストが作ったモデルをすぐAPIにできる」という Databricks の強みがよくわかる。

ただ、この時点ではまだ「API が立っている」だけ。このAPIを誰かが使って初めて価値が出る。

次回: Part 2-2 — Salesforce Agentforce でファン向けAgentを作る

Part 2-2 では、Salesforce 側に同じファンデータを投入して、Agentforce でファン向けの接客Agentを構築する。このAgentが今回作った Databricks の REST API を呼び出して、「おすすめのイベントは?」に対してパーソナライズされた回答を返してくれる。

「Databricks がデータの頭脳、Agentforce が顧客の窓口」という役割分担が、実際に動く形で見えてくるはず。


参考リンク

1
0
0

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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?