semantic-model について
RevOS MCP サーバー経由で組織のライブセマンティックモデルを使い、業務上の質問に答えるスキルです。クエリ可能な項目の確認、Cube クエリの作成、結合経路や結果制限の扱い方を説明します。
主な機能
- 01Cube とクエリ可能なメンバーの確認
- 02集計・レコードクエリの作成
- 03フィルター、セグメント、期間の適用
- 04Cube 結合前の経路確認
- 05行数と結果制限の処理
活用シーン
- 01期間を指定して売上を集計
- 02ディメンション別に指標を分類
- 03利用可能な Cube と項目を確認
MCP 対応
RevOS MCP サーバーの `api_read` ツールを使い、`cubes` リソースからセマンティックモデルのメタデータを取得し、行をクエリします。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ revosai/ skills --skill semantic-modelインストール後、Claude Code で/semantic-modelを入力して読み込まれたか確認します。
同梱ファイルevals/evals.json
詳細
セマンティックモデル
RevOS MCP サーバー経由で組織のライブセマンティックモデルをクエリし、結果をチャットに直接表示して、ビジネス上の質問に回答します。
MCP サーバーが公開する汎用ツールは少数です。セマンティックモデルには、そのうちの1つである api_read を使って cubes リソースにアクセスします。
| 呼び出し | 取得できるもの |
|---|---|
api_read({ resource: "cubes", method: "meta", params: {…} }) | コンパイル済みセマンティックモデル — メジャー、ディメンション、セグメント、結合を含むすべてのキューブ/ビュー |
api_read({ resource: "cubes", method: "query", body: { query: {…} } }) | 行 |
api_details({ resource: "cubes", operation: "query" }) は、エンドポイントが適用する正確なスキーマを、その OpenAPI ドキュメントから読み取って返します。ここに書かれている内容と api_details が食い違う場合は、api_details が正しい情報です。呼び出しが拒否され、理由が明らかでない場合は、いつでも api_details を確認してください。
method を指定しない api_read({ resource: "cubes" }) もあります。これは 保存されているキューブ定義 — 誰かが作成した YAML — を一覧表示します。「何をクエリできるか」とは別の問いです。ビジネス上の質問に回答する場合は、常に meta を使います。
ステップ 1:クエリ可能なものを確認する
メンバー名を推測する前に、カタログを読みます。meta はほかのエンドポイントと同様のリストエンドポイントなので、ドキュメント全体を取得するのではなく、必要な項目に絞り込みます。
{
"resource": "cubes",
"method": "meta",
"params": { "fields": "name,title,description", "pageSize": 100 }
}
これで数キロバイト程度のカタログ全体を取得できます。読み込みやすく、選択にも手間がかかりません。pageSize はデフォルトで 100、meta では最大 1000 です。レスポンスに
metadata.nextPageToken が含まれている場合は、残りを取得するために params.pageToken として渡します。
カタログには、想定されるビジネス用キューブに加えて、内部用またはシステム生成のキューブが表示されることがあります。たとえば、「最新スコア」の集計を支える補助キューブ、内部セグメントキューブ、あるいは別のキューブの結合を支えるために存在し、直接クエリするものではない類似の仕組みです。RevOS が生成したキューブは [Table] … または [Segment] … というタイトルが付いており(名前は model_stream_…___local、segment_…)、テーブルのビューには model_… という名前が付いています。それ以外では、説明が簡素または欠けているものや、機械的な印象の名前やタイトルのものが該当します。検索で、明確なビジネス名のキューブとこれらのキューブの両方が一致した場合、質問が内部用キューブについてのものでない限り、ビジネス用キューブを説明し、クエリします。
続いて、fields を省略し、必要なキューブに絞り込んで、完全な定義を取得します。
{
"resource": "cubes",
"method": "meta",
"params": { "filter": "name == \"gold_order_items_enriched\"" }
}
これにより、参照可能な measures、dimensions(関連する場合は時間の粒度を含む)、segments、joins が正確に返されます。常に <CubeName>.<member> として指定します。例:gold_order_items_enriched.count や gold_order_items_enriched.order_date。joins でわかることと、わからないことについては、ステップ 2 を参照してください。
params.filter は name、title、description、type を対象とする CEL 式です。例:name.contains("invoice")、!name.endsWith("_local")、type == "view"。メンバーはフィルタリングできません。キューブが持つフィールドを使ってキューブを検索することはできないため、前述のようにカタログを必要な項目に絞り込む方法を使います。
ユーザーの質問が「セマンティックモデルには何があるか」「何をクエリできるか」「キューブ/データセットを一覧表示して」といった発見だけを目的とするものなら、ここで終了します。キューブ(完全な定義を取得した場合は、そのフィールドも)を文章または短い表でまとめ、ステップ 3~6 は省略します。ユーザーが求めていないクエリは実行しないでください。
メンバー名を決め打ちしたり、推測したりしないでください。meta の結果から質問に答えられない場合は、その旨を伝えてください。フィールドをでっち上げてはいけません。
質問にソースシステムの名前が出ていても、セマンティックモデルを使います。「1 件以上のコンタクトを持つ HubSpot オーナーは何人か」のような質問では HubSpot が挙げられていますが、これは集計に関する質問です。直接 HubSpot/Zammad/Exact Online/その他のソースコネクターを使うのではなく、セマンティックモデルから回答します(たとえば、gold_contacts から構築され、重複排除と完全な同期が済んだ users キューブを使います)。ソースコネクターが利用可能で、キーワード検索では質問によりよく合致しているように見えても同様です。直接接続するソースコネクターは通常、ページネーションを行い、同期済みの完全なデータセットではなく、稼働中のデータの一部スナップショットを返します。タスクの途中でこれに切り替えると、コネクター呼び出し自体は正常に成功してエラーも表示されないまま、セマンティックモデルが参照するデータとは異なるデータを見ているため、回答が桁違いに誤ることがあります。直接接続のソースコネクターは、すでに特定済みのレコード 1 件を照会する場合(例:「コンタクト X のメールアドレスは何か」「HubSpot で会社 Y を所有しているのは誰か」)に限って使用してください。データセット全体にわたる件数、合計、内訳の集計には使用しないでください。
ステップ 2:キューブ間をまたぐ — joins を読み、2 つの盲点を把握する
meta は各キューブについて、接続先となる他のキューブごとに { name, relationship, sql } を含む joins 配列を返します。複数のキューブにまたがるクエリを書く前に、これを確認してください。たとえば、質問で gold_order_items_enriched と gold_users_with_order_stats の両方が必要な場合は、まず一方を取得し、もう一方がその joins に含まれているかを確認します。2 つのキューブに似た名前の「アカウント ID」のような列があるというだけで、関係を推測してはいけません。
盲点は 2 つあります。joins が全体像を網羅していると信じると、どちらも問題になります。
一覧に表示されるのは、外向きの結合だけです。 キューブが報告するのは、自ら宣言している結合であり、他のキューブがそのキューブを指して宣言している結合ではありません。このコードベースでは結合は一方向に記述されています。deals は customers への結合を宣言していますが、customers は逆向きの結合を宣言していません。一方、Cube のプランナーはそのような結合をどちら側からでもたどります。そのため、すべてのキューブが結合するディメンションスタイルのキューブでも、joins が 0 件でありながら、十数か所から結合できる場合があります。空または短い joins 配列を「このキューブは何にも接続していない」と解釈してはいけません。パスがないと判断する前に、もう一方のキューブの joins も確認してください。
捏造した joinHints のパスは拒否されません。 2 つのキューブを結ぶパスが複数ある場合、joinHints でルートを固定します。これはパスのリストで、各パスは経由するキューブ名を順番に並べた配列です。直接結合なら 2 つ、それ以上のホップを経由する場合はさらに多くなります。たとえば [["gold_order_items_enriched", "gold_users_with_order_stats"]] や [["orders", "line_items", "products"]] のようになります。そのパス内の組み合わせに宣言済みの結合がなくても、エラーにはなりません。Cube は実際のパスを自動解決する処理にフォールバックし、選んでいないルートを使って計算した結果を返します。実際のパスが 1 つしかない場合は問題ありません。Cube は不正なヒントを無視し、正しい結果を得るからです。しかし、スキーマに有効なパスが 2 つある分岐が存在する場合は、ヒントがどちらかを確実に固定するわけではないため、気付かないうちに誤った結果になります。クエリが成功したからといって、そのパスが実在した証拠にはなりません。
したがって、joinHints を設定するのは、その中の連続するすべての組み合わせが、最初の盲点を踏まえて両方向を確認したうえで、いずれかのキューブの joins で実際に確認できた結合である場合に限ってください。もっともらしいルートが複数ある場合、たとえば同じフィールドに至るパスが 2 つある場合や、ファクトテーブルからディメンションへ直接到達する方法と、3 つ目のキューブを経由する方法がある場合は、黙ってどれかを選ばないでください。曖昧さを明確に伝え、クエリを実行する前に、意図する関係または粒度をユーザーに確認してください。たとえば、「注文明細ごとの売上」か「ユーザーごとの売上」か、という違いです。joinHints を設定した場合は、使用したパスとその理由を、実行後の説明で必ず伝えてください。そうすれば、ユーザーは誤った前提に気付くことができます。質問でキューブをまたぐ必要がない場合は、単一キューブで完結する、もっともシンプルな回答を優先してください。
ステップ 3:クエリを作成する
Cube.js のクエリは body.query に指定します。入れ子構造に注意してください。body は { "query": { … } } であり、クエリオブジェクトそのものではありません。
| フィールド | 形式 | 説明 |
|---|---|---|
measures | string[] | 集計する数量です。meta に記載されているものに限ります。例:gold_order_items_enriched.count。そこに count がない場合は、「行数のカウント」を参照してください |
dimensions | string[] | グループ化または内訳に使用する属性です |
segments | string[] | meta に記載されている名前付きフィルターです。追加のフィルターとして適用されます |
timeDimensions | [{ dimension, granularity?, dateRange? }] | 以下を参照してください |
filters | 以下を参照 | リーフ条件です。必要に応じて and/or グループにネストできます |
order | [[member, dir], …] または { member: dir } | どちらの形式も使用できます。[member, "asc"|"desc"] のペアの配列、またはオブジェクトマップです |
joinHints | [[cube, …], …] | 曖昧な結合経路を指定します。各エントリーは、2つ以上のキューブ名を順に並べた経路です。ステップ2を参照してください |
limit | number | 以下の「行数制限」を参照してください |
offset | number | limit を超えた位置からページングするために使用します |
timezone | IANA 形式の文字列。例:Europe/Amsterdam | デフォルトは UTC です |
total | boolean | limit/offset を無視した一致する行の総数も返します。「842件中10件を表示」のような表示に便利です |
ungrouped | boolean | ディメンションによる集計を行わず、生の行を返します。要約ではなく個別のレコードを確認する場合に使用します。行数制限は引き続き適用されます |
ここに記載されていないキーは変更せずに Cube に転送されるため、API で明示されていない Cube.js の機能も引き続き利用できます。
timeDimensions[].granularity — 通常は day、week、month、quarter、year のいずれかです(Cube では hour/minute/second や、ディメンションで定義されたカスタム粒度も使用できます)。期間全体の単一の合計を取得する場合は、系列ではなく、これを省略してください。
timeDimensions[].dateRange — 相対期間を表す文字列("last quarter"、"last 12 months"、"this month")、または ISO 日付の明示的なペア(["2026-01-01", "2026-03-31"])です。
期間比較(「今月と先月」) — 各期間に独自の dateRange を設定して期間ごとに1つのクエリを実行し、自分で比較してください。compareDateRange は避けてください。エンドポイントは Cube の回答を通常の結果として読み取りますが、比較クエリは通常の結果ではないため、本文のない 500 エラーが発生する可能性が高くなります。
行数のカウント(「取引はいくつあるか」) — meta にそのキューブの count メジャーが記載されている場合に限り、<cube>.count を使用してください。count がない場合(measures が空、または count がない場合)は、meta で primaryKey: true と示されているプライマリーキーのディメンションを、limit: 0 および total: true でクエリし、metadata.total を使って回答してください。
{ "query": { "dimensions": ["hubspot_deals.id"], "limit": 0, "total": true } }
total はクエリしたディメンションの個別値をカウントするため、行数を得られるのはプライマリーキーのみです。primaryKey としてマークされたディメンションがない場合は、正確なカウントはできないと伝えてください。フィルター、セグメント、dateRange はカウントに適用されます。
data は空で返されます。これは想定どおりであり、「一致なし」を意味しません。metadata.total を使って文章で回答し、1行の表(例:Deals | 141)を示してください。グラフは不要です。内訳(「ステージごとの件数」など)には count メジャーが必要です。ない場合は、そのキューブには存在しないと伝えてください。
filters は条件のリストで、各条件は次のいずれかです。
- 条件:
{ "member": "...", "operator": "...", "values": [...] }。 値は通常文字列です(数値や日付でも["100"]のように指定できます)が、数値、ブール値、nullも受け付けます。演算子:equals、notEquals、in、notIn、contains、notContains、startsWith、notStartsWith、endsWith、notEndsWith、gt、gte、lt、lte、inDateRange、notInDateRange、onTheDate、beforeDate、beforeOrOnDate、afterDate、afterOrOnDate、measureFilter。set/notSetは値の有無を確認するもので、valuesは不要です。 - グループ:
{ "and": [...] }または{ "or": [...] }。各エントリーは条件またはグループで、ネストできます。filters配列のトップレベルにあるエントリーは暗黙的に AND で結合されます。「すべて」ではなく「いずれか」を求める質問では、明示的なorグループを使用してください。同じand/orグループ内でディメンション条件とメジャー条件を混在させることはサポートされていないため、別々にしてください。 これらは Cube 独自のフィルターで、body.query.filtersに指定します。エンドポイントはフィルターの形を検証します(条件を指定する箇所に文字列や数値だけを置くと、パラメーターエラーになります)。メンバー名と値の検証は Cube が行い、誤りがあれば Cube のエラーが返されます。Step 1 のparams.filterに指定する CEL 文字列と混同しないでください。あちらは カタログ をフィルタリングし、こちらは データ をフィルタリングします。
行数の上限
limit は Cube にそのまま渡されます。クエリに指定がなければ Cube 独自のデフォルトが適用され、設定済みの最大値を超えると拒否されます。これらのデフォルト値はチャット画面ではなく、データウェアハウス向けに設定されています。次の 2 点に注意してください。
- 実際に必要な
limitを必ず指定してください。limitのない上限なしのクエリは、数千行をそのまま会話に返す可能性があります。 - 応答の
metadata.limitが正式な値です — Cube が実際に適用した上限が示されます。結果が切り捨てられたと伝える際は、この値を使ってください。
例
最もシンプルな例:
{
"resource": "cubes",
"method": "query",
"body": { "query": { "measures": ["gold_order_items_enriched.count"] } }
}
カテゴリ別の上位 N 件:
{
"resource": "cubes",
"method": "query",
"body": {
"query": {
"measures": ["gold_order_items_enriched.count"],
"dimensions": ["gold_order_items_enriched.traffic_source"],
"order": [["gold_order_items_enriched.count", "desc"]],
"limit": 10
}
}
}
時系列(グラフに最適な形式):
{
"resource": "cubes",
"method": "query",
"body": {
"query": {
"measures": ["gold_order_items_enriched.revenue"],
"timeDimensions": [
{
"dimension": "gold_order_items_enriched.order_date",
"granularity": "month",
"dateRange": "last 12 months"
}
]
}
}
}
OR 条件でのフィルタリング(「返金済み、またはテスト注文」):
{
"resource": "cubes",
"method": "query",
"body": {
"query": {
"measures": ["gold_order_items_enriched.count"],
"filters": [
{
"or": [
{ "member": "gold_order_items_enriched.is_refunded", "operator": "equals", "values": ["true"] },
{ "member": "gold_order_items_enriched.is_test_order", "operator": "equals", "values": ["true"] }
]
}
]
}
}
}
結合パスを固定して Cube をまたぐクエリ(Step 2 を参照):
{
"resource": "cubes",
"method": "query",
"body": {
"query": {
"measures": ["gold_order_items_enriched.revenue"],
"dimensions": ["gold_users_with_order_stats.country"],
"joinHints": [["gold_order_items_enriched", "gold_users_with_order_stats"]]
}
}
}
目安:
- メジャー 1 つ + ディメンション 1 つ → カテゴリ別グラフの候補です。
- メジャー 1 つ +
granularityを指定したtimeDimensions1 つ → 時系列グラフの候補です。 - メジャーが複数、またはディメンションが複数 → テーブルのみです。グラフにしようとしないでください。
- 「上位 N 件」の質問では、必ず
limitを明示的に設定してください(limit: N+ 対応するorder)。また、上限なしのデータ取得でデフォルト値に頼らないでください。
Step 4:クエリを実行して応答を読む
応答は { data, metadata } です。
data— 行の配列です。各行は、クエリで使用したものと同じメンバー名をキーとするフラットなオブジェクトです。metadata.rowCount— 返された行数です。metadata.limit/metadata.offset— Cube が実際に適用した値です。metadata.total— クエリでtotal: trueを設定した場合にのみ含まれます。これはlimitとoffsetを無視した一致行数です。そのため、offset + rowCount < totalなら次のページがあると判断できます。この情報がない場合、ちょうどページいっぱいの結果が最終行で終わっているのか、それとも次のページがあるのか区別できません。「N 件中 10 件を表示」と伝える場合やページ送りを提示する場合は、必ずtotal: trueを設定してください。metadata.lastRefreshTime— 基になるデータが最後に更新された時刻です。数値の鮮度について尋ねられた場合は、伝えるとよいでしょう。metadata.annotation— 各メンバーに関する Cube 独自の説明です。measures/dimensions/segments/timeDimensionsに分類され、行で使われているものと同じ完全修飾名をキーとします。少なくともtitle、shortTitle、typeが含まれます。行に含まれるのは値のみなので、人が読みやすい列見出しや値の型はここから取得します。生のメンバー名を表示する代わりに、テーブルの見出しにはshortTitleを使用し、数値列を右揃えにして書式設定するかどうかの判断にはtypeを使用してください。
呼び出しがエラーになった場合は、メッセージをそのまま伝えてください。よくある原因は、メンバー名の入力ミス(meta を読み直してください)、時間ディメンションではないディメンションに対する dateRange / granularity の使用、または Cube の最大値を超える limit です。この一覧に含まれていない点にも注意してください。誤った joinHints パスを指定してもエラーにはならないため(Step 2)、正常な結果が返ってもパスが正しかったとは限りません。メッセージを見ても原因がわからない場合は、api_details({ resource: "cubes", operation: "query" }) を呼び出し、指定した形式がエンドポイントで実際に受け付けられるものか確認してください。
Step 5:チャットに結果を表示する
5a. テーブル — 常に表示
返されたすべての行を、ASCII アートではなく標準的な Markdown テーブルとして表示してください。チャット UI でそのままレンダリングされるため、手動で列幅を計算する必要はありません。生のメンバー名を表示する代わりに、metadata.annotation の shortTitle を見出しに使用してください。数値列は見出し区切りに ---: の構文を使って右揃えにします。数値には桁区切りを入れ、整数でない場合は小数点以下 2 桁に丸めてください。
| Traffic Source | Count |
|---|---:|
| Search | 142,318 |
| Organic | 88,204 |
| Email | 41,907 |
| Facebook | 22,015 |
| Display | 9,471 |
5b. グラフ — データの形状が適している場合
クエリの結果が測定値1つとディメンション1つ(カテゴリまたは時間)のみの場合は、実際のグラフを含むアーティファクトも作成してください(カテゴリ別の内訳には棒グラフ、時系列には折れ線グラフまたは棒グラフ)。テキストアートではなく、実際のグラフとしてインライン表示されます。小さく自己完結したSVGが、通常は最も簡単で移植性にも優れた選択肢です。外部ライブラリもネットワークアクセスも必要ありませんが、クライアントに最適なグラフアーティファクトを使ってください。軸とカテゴリにラベルを付け、値を表示してください。配色はシンプルにしてください。
現在のクライアントがアーティファクトに対応していない場合は、グラフを省略して表だけにしてください。テキスト文字でグラフを描く方法に切り替えないでください。
次の場合はグラフを完全に省略してください。結果セットが空の場合、クエリに測定値が2つ以上ある場合、ディメンションが2つ以上ある場合、単一のスカラー値(ディメンションなし)の場合、または測定値がすべてゼロかnullの場合です。単一のスカラー値の場合は、数値を文章で示した後に1行の表を表示してください。グラフは不要です。
Step 6:結果を簡潔に説明する
出力をレンダリングした後、測定した内容、最も高い/低い区分、表で確認できる明らかな異常(例:期間の欠落、単一カテゴリが合計の大半を占めているなど)を、平易な英語で1~3文にまとめてください。データから確認できない原因を推測しないでください。次にユーザーが尋ねられる具体的なフォローアップクエリを、ちょうど1つ提案してください。
ルール
- キューブ名やメンバー名をハードコードしないでください。必ず先に
metaで確認してください。 - 質問でソースシステム(HubSpot、Zammad、Exact Onlineなど)が指定されていても、集計・分析に関する質問にはセマンティックモデルから回答してください。タスクの途中でソースに直接接続する方法に切り替えないでください。これは、データセット全体の件数ではなく、すでに分かっている1件のレコードをポイント検索する場合に使います(Step 1を参照)。
- 複数のキューブを結合する方法が曖昧な場合、黙って決めないでください。ユーザーに確認してください(Step 2を参照)。
- 空の
joins配列を「リレーションなし」と解釈しないでください。表示されるのは外向きの結合だけなので、もう一方のキューブも確認してください(Step 2を参照)。 joinHintsの各経路を設定する前に、宣言済みの結合と照合してください。でっち上げた組み合わせでも受け入れられて回答が返され、拒否されるとは限りません。クエリが成功しても、その経路が実在した証拠にはなりません(Step 2を参照)。- 検索で一致する候補が複数ある場合は、内部用またはシステム生成のキューブよりも、業務内容が明確に分かる名前のキューブを優先してください(Step 1を参照)。
- 必ず表を表示してください。グラフはデータの形状が適している場合にのみ表示してください。
limitは必ず意図的に設定してください。エンドポイントが適用するCube独自のデフォルト値は、会話で扱うには大きすぎます。- 「N件中M件」と報告する、または次のページを提示する予定がある場合は、必ず
total: trueを設定してください。 <cube>.countで件数を数えるのは、metaにその項目が記載されている場合に限ります。そうでない場合は「行数のカウント」(Step 3)を参照してください。- 「見つからない」エラーが発生した後は、キューブの
metaを再読み込みし、そこに記載されたメンバーだけを使ってください。名前の別表記を試したり、ディメンションをmeasuresに指定したりしないでください。 - エンドポイントが受け付ける内容の正確な根拠は
api_detailsです。呼び出しが拒否された場合は、形状を推測せず、これを参照してください。 - これはチャット内で読み取り専用で使うスキルです。ファイル書き込み、ローカルプロジェクト、YAMLは使いません。セマンティックモデルの読み取りには
api_readのみを使います。MCP サーバーはapi_writeとapi_deleteも提供していますが、このスキルでは呼び出しません。 metaからキューブが返されない場合は、ユーザーの組織にはセマンティックモデルがまだ設定されていないようだと伝えてください。理由を推測しないでください。