前回の記事:Dendo — Pendoのデータを自然言語で聞けるノートブックを作った
以前、Pendoのデータを自然言語で聞けるノートブック Dendo を作った話を書きました。あれから格段にパワーアップして帰ってきました!
Dendoとはなんぞや?を一言で説明すると、Dendoは Pendoの中のプロダクト利用データに、agenticに質問できるノートブックです。「先週のダッシュボード、ペルソナ別の定着どう?」と平文で聞くと、エージェントがクエリを組み立てて、Pendoに投げて、表とグラフを返す。良かったものだけノートに残していける。
裏で動いているのは Pendo MCP ではありません。aggDSL という自作のPendo Aggregation APIのDSLで、エージェント自身がクエリを書いています。
v2の目玉はオントロジー層なのですが、まずアプリそのものの話から。Dendoのメリットは、実はもっと素朴なところにあります。
Dendoは「喋りながら、発見をノートに残していく」アプリです
スクショはヘッドレスブラウザで撮った関係で、すべて英語ですが、UIは日本語もサポートしています。
画面は3分割です。左がワークスペース、真ん中がノートブック、右がエージェント。(エージェントはフルスクリーンも可)
探索はチャットで、結論はページに。
右のチャットで「先週リリースしたダッシュボード、使われてる?」と聞く。エージェントが aggDSL を書いて、Pendoに投げて、表を返してくる。ここまでは普通のAIアシスタント。違うのはその回答の下に並んでいるボタンです。
- Add text — 回答の文章だけノートに置く
- Add DSL as query — 走ったクエリをクエリセルとして置く(以後は自分のもの。編集できる)
- Add table / Add chart / Add both — 結果の表・グラフを置く
- Save as question — 質問そのものをセルとして置く(後述)
押すまでは何もノートに入りません。 ここが地味ですが便利で、チャットには思いつきを何度でも疑問を投げかけられる場所として使えます。10回聞いて、残すのは2回ぶん。Pendoのダッシュボードツールでは「先に何を見るか決めてから作る」のに対して、こっちは喋りながら、良かったものだけ拾って積む。
ノートは、実行できるセルの積み重ね
| セル | 中身 | 再実行 |
|---|---|---|
| Note | Markdownのコメント | — |
| Query | 名前を付けた aggDSL クエリ |
Run でDSLを実行し、自分の結果テーブルを更新(LLMなし) |
| Result | 返ってきた行の表。1つのクエリが所有する |
Refresh で裏のDSLを再実行(LLMなし) |
| Chart | 折れ線 / 棒 / ドーナツ。複数系列可 |
Refresh で各系列のDSLを再実行(LLMなし) |
| Question | 平文の質問+その回答・表・グラフ |
Re-run で保存済みDSLを再生 → LLMが解釈し直す |
Jupyter Notebookと同じで、上から順に読める文書でありながら、全部が生きている。
「クエリと表は1対1」にした
クエリセルには名前が付けられて、その名前が下の結果テーブルの見出しにも出ます。長いノートの真ん中にある表が「どの問いから出てきた数字なのか」ソースも見られる。名前はクリックしてその場で直せて、再クエリなしで見出しが追従します。
どの表も、裏のDSLを開いて直せる
結果セルとチャートセルには backing source があります。エージェントが実際に投げたDSLがそのまま出てくるので、開いて、直して、更新できる。自分で書くのが面倒なら Ask agent to tweak でチャットに戻して交渉してもいい。
AIが書いたものをブラックボックスにしないというのは、この手のツールで信用を保つ最低条件だと思っています。数字が変なとき、クエリを見れば分かる。表の列はドラッグで並べ替えられるし、ヘッダでソートもできます。
そのほか、日々効いているもの
- 表の中身が読める。 question cell の表は日付を人間が読める形式にして、Pendoの内部IDを実際のPage名・Feature名に解決します。保存した答えが、半年後に見ても意味が分かるようにしてある
-
チャートの裏の数字が見られる。 エージェントが作ったサマリーチャートには
Show sourceがあって、グラフとその元データの表を行き来できます - ノートブック単位のデフォルトセグメント。 ヘッダで設定すると、セルもチャットも含めた全部のaggregationに効きます(クエリ側で上書きしない限り)
- ワークスペース指示。 「料金プランはこう」「このアカウントは社内テナントなので常に除外」みたいな、毎回書きたくないことを平文で置いておく場所。毎ターン読み直されるので、直したら即効きます
- カスタムスキル。 自分のアダプションスコアカード、QBRのテンプレート、解約リスク監査 — 再利用するプレイブックを登録しておくと、質問の内容に応じてエージェントが自動で該当するものを適用します
- MCPサーバも繋がる。 streamable HTTP で外部のツールサーバを接続でき、組み込みのPendoクエリと並べてエージェントが呼びます
- 全画面チャット。 まだノートに何も残す気がない探索フェーズでは、キャンバス全部をチャットに明け渡せます
- ノートは自動保存です
./dev.sh 一発で、pyenv + venv + Python依存 → nvm + Node + Web依存 の順に入って localhost:3000 で立ち上がります。
裏側:Pendo MCPではなく、aggdslをAIに書かせている
aggDSL とは
Pendo には Aggregation API という強力なクエリAPIがあります。強力なんですが、リクエストボディが巨大なJSONです。pipeline の配列に source / timeSeries / filter / group / eval / fork …がネストして積み上がる。JSONなので人が書くのもつらいし、LLMに書かせるには、ドキュメンテーションが足りない(そして間違えたときに直しづらい)。
aggDSL は、そのJSONに1対1で対応するDSLを書きました。何が起きているのか分かりやすく、各オペレーションをパイプで繋いで書きます。構文はKustoから着想を得てデザインしました。
RESPONSE mimeType=application/json
REQUEST name="DiagEventsAppIdHasData"
FROM event([source=events,blacklist="apply"])
TIMESERIES period=dayRange first=dateAdd(startOfPeriod("daily", now()), -30, "days") count=30
| filter appId == 1234567890123456
| group by day fields { events=sum(numEvents) }
| sort -events
| limit 5
これを aggdsl compile に通すと、Pendoにそのまま投げられるJSONボディが出てきます。
{
"response": { "location": "request", "mimeType": "application/json" },
"request": {
"pipeline": [
{ "source": {
"events": { "blacklist": "apply" },
"timeSeries": { "period": "dayRange",
"first": "dateAdd(startOfPeriod(\"daily\", now()), -30, \"days\")",
"count": 30 } } },
{ "filter": "appId == 1234567890123456" },
{ "group": { "group": ["day"], "fields": [ { "events": { "sum": "numEvents" } } ] } },
{ "sort": ["-events"] },
{ "limit": 5 }
],
"name": "DiagEventsAppIdHasData"
}
}
DSLで327文字、JSONで620文字。 これがそのまま、トークン量に影響します。
Pythonで、パーサ・コンパイラ・デコンパイラの3点セット。デコンパイラがあるので、既にPendo側にある aggregation の JSON を投げ込んで DSL に戻し、ノートに持ち込むこともできます。
aggdsl compile query.dsl # DSL → Pendo JSON
aggdsl decompile body.json # Pendo JSON → DSL
PESのような一発ものも書けます。
PIPELINE
| pes {"appId":1234567890123456,"firstDay":"now()","dayCount":-30}
複雑なほうの例も置いておくと、ページ→機能のファネル分析(fork / branch / unwind / accumulate を使うやつ)は、生JSONだと数百行になるものが数十行のDSLで書けます。examples/ にひととおり入れてあります。
仕組み
構成は、**Nuxt 3 のWebアプリ(apps/web)+ Python のツールチェーン(src/aggdsl, tools/pendo)**です。
エージェントに渡しているツール類:
| ツール | 役割 |
|---|---|
lookup_ontology |
製品マップをローカル検索。pendoIdもconceptも即返る(ネットワーク無し) |
lookup_pendo_features / _pages / _segments
|
上で見つからないときのフォールバック(Pendo API直) |
run_pendo_aggregation |
aggDSLの文字列と説明を受け取って実行する |
build_summary_chart |
複数のaggregation結果を1枚のグラフにまとめる |
run_pendo_aggregation が呼ばれると、サーバ側でこうなります。
DSL文字列
→ python -m aggdsl compile(サブプロセス、結果はキャッシュ)
→ Pendo Aggregation API に POST
→ 返ってきた featureId / pageId を実際の名前に解決(enrich)
→ 行と列にflatten
→ エージェントに返す / セルに描く
この経路は、セルの手動実行とまったく同じものです。 だからチャットで見た結果とノートのセルの結果は、必ず同じ形になります。「チャットだと出るのにセルにすると違う」が起きない。サブプロセスはセッション単位で登録してあるので、途中で止めれば本当に止まります。
なぜPendo MCPではなく、DSLを書かせるのか
ここが設計上いちばん大きい判断でした。
Pendo MCPを使うと、AIは「ツールを呼ぶ」だけになります。 クエリの実体はサーバの向こう側で組み立てられる。正しい結果が返ってくればいいけど、問題は失敗したときで、
AIには、なぜ失敗したのかが分からない。
自分が書いていないものが失敗しているので、直しようがない。せいぜいパラメータを変えてもう一度呼ぶくらいで、それはデバッグではなくガチャです。
aggDSLを書かせると、失敗が全部AI自身の責任になります。そして返ってくるエラーが具体的です。
// apps/web/server/utils/toolRegistry.ts
const compiled = await compileDsl(dsl, sessionId, { defaultSegmentId: opts.defaultSegmentId ?? null })
if (!compiled.success) return { result: null, error: `DSL compile error: ${compiled.error}` }
const agg = await runAggregation(JSON.stringify(compiled.data), false, sessionId, orgId)
if (!agg.success) return { result: null, error: `Aggregation error: ${agg.error}` }
パーサのエラーなら「この行のこのトークンが不正」、APIのエラーなら「このsourceにそのフィールドは無い」。AIの手元には、自分が書いた文字列と、それに対する具体的なミスの両方がある。 だから修正も自由に行える。
これがself-healingの土台です。この記事の後半に出てくるものは全部この性質の上に乗っています。
- question cell の再実行でDSLが古くなったとき、エージェントが自分で書き直せるのは、そのDSLが自分の書いたテキストだから
- Product map の concept KPI が壊れたとき、どのテンプレートが悪いか名指しできるのも同じ理由
- backing source パネルに出てくるのが読めるDSLなので、人間側もレビューできる
MCPが悪いという話ではなくて(DendoもPendo MCPサーバは繋げられます)、エージェントに自分で直させたいものは、エージェント自身に書かせないといけない、ということです。ブラックボックスの向こうで失敗したものは、誰にも直せません。
さて。ここまでが「対話しながらノートを作る」側の話です。ここからが v2 の本題で、この対話そのものの質をどう上げたか、という話になります。
問題:エージェントは、あなたのアプリのこともビジネスゴールも知らない
Pendoには Leo という優秀なAIアシスタントがいます。Pendo MCP もある。LLM自体は賢い。それでも、実際に日々の分析で使っていると同じところで詰まります。
エージェントが、こちらのプロダクトが何をするものなのかを一切知らない。
具体的にはこういうことが起きます。
- 「アクティベーション」と聞いても、それがどのFeatureの、どの期間の、どのセグメントの話なのかを毎回ゼロから推測される
- 名前が似ているだけの別のFeatureを掴んでくる。
Dashboardで検索して7件返ってきた中から、それっぽいやつを選ぶ。そこが間違っていても、後段のクエリは完璧に動くので気づきにくい - 前のターンで「そのアカウントは社内テナントだから除外して」と言ったのに、次のセッションでは忘れている
- ビジネスゴールを共有していないので、出てくる示唆が「一般的なSaaSのプラクティス」に着地する
これはモデルが賢くないからではありません。渡していないものを知っているはずがない、というだけの話です。
そしてなぜ渡せていないかというと、計測ツールの中には「事実」しか入っていないからです。Feature、Page、Segment、イベント数、訪問者数。Pendoが持っているのはここまで。
「そのFeatureがオンボーディングの完了地点である」とか「このセグメントは無料プランなので売上目標の対象外」とか、それが何を意味するかはPendoの外にあります。担当者の頭の中と、Confluenceのどこかと、Slackの過去ログに散っている。
だから、エージェントはあてずっぽうで推測するしかない。Pendo上のタグやレポートと、こちらのビジネスゴールを結びつける場所が、そもそもどこにも無かった。
それを作ったのが v2 です。
解:Pendoの上に、薄いオントロジー層を敷く
考え方は別記事(「意味付けは後からでいい」)に詳しく書いたのでそちらも読んでほしいのですが、要点は3層に分けたことです。
┌─────────────────────────────────────────┐
│ 3. 計算層 — usage overlay / concept KPI │ ← キャッシュ。グラフに「塗る」
├─────────────────────────────────────────┤
│ 2. 意味層 — concepts(ビジネス定義) │ ← 人とAIが書く。後から足せる
├─────────────────────────────────────────┤
│ 1. 構造層 — Pendoから自動同期 │ ← 手で触らない。腐らない
└─────────────────────────────────────────┘
// apps/web/types/ontology.ts
/**
* Workspace ontology — a lightweight typed graph over the org's Pendo data.
*
* Three layers:
* 1. STRUCTURAL (auto-synced from Pendo, never hand-edited): product areas,
* features, pages, segments + `belongs_to` edges. Re-syncable at any time.
* 2. SEMANTIC (human/agent-authored "concepts"): business definitions with an
* optional canonical aggDSL template, links to the entities they measure,
* and cause/action playbooks that turn a metric into next questions.
* 3. OVERLAY (computed, cached separately): live usage metrics painted onto
* the graph. Never stored inside the ontology blob.
*
* Deliberately NOT RDF/OWL — a typed JSON graph is enough for grounding the
* agent and rendering the product map, and it stays maintainable.
*/
なぜRDF/OWLじゃないのか
「オントロジー」と言うと身構えられるので先に書いておくと、これはRDF/OWLではありません。型のついたJSONグラフです。
目的が「推論エンジンを回すこと」ではなく「LLMに文脈を渡すこと」だからです。LLMに渡すなら、最終的には全部プロンプトの文字列になる。トリプルストアを立ててSPARQLを書く必要はないし、そこまでやると誰もメンテしなくなります。表現力より、運用され続けることを取りました。
1. 構造層 — ここは「Pendoの鏡」なので手で触らない
Sync from Pendo で Product Area / Feature / Page / Segment を引いてきて、力学レイアウトのグラフとして描きます。
この層“だけ”は手編集させません。 ここはPendoの鏡だからです。かならずPendo上にタグを作る — という役割分担にしてあります(意味層のconceptは手で自由に書けます。次章)。
いつでも再同期でき、再同期しても上に載せた意味層は生き残ります。
ノードIDは feature:<pendoId> のようにプレフィックス付きで安定させてあり、再同期してもconceptからの参照が切れません。
/**
* Structural node. `id` is prefixed and stable across syncs so concept
* references survive re-sync: `feature:<pendoId>`, `page:<id>`,
* `area:<groupId>`, `segment:<id>`.
*/
2. 意味層 — concept(ここは人間が書く場所)
concept は名前のついたビジネス定義です。Activation、Power account、Onboarding completion。
conceptは手で書けますし、いつでも書き換えられます。 専用のエディタがあって、定義もクエリもリンク先も自分で編集できる。「このFeatureとこのSegmentが自分の関心事だ」と最初に教えておきたいなら、そう書いておけばいい。
上から Name / Definition(散文の定義) / Canonical DSL(保存時にコンパイルチェックが走る) / KPI column(数値列が複数返るとき、どれを見出しの数字にするか) / Measures(測っている構造ノードをタグで選ぶ。右上の Map with AI で平文からリンク候補を引ける) / Causes と Actions(それぞれ本文+「次に聞く質問」テンプレートの2列)。
※このスクリーンショットは合成デモワークスペース(架空の製品 "Acme Workspace"、架空の機能名・数値)から撮ったもので、実データは一切写っていません。
AIに下書きさせる口はありますが、保存されるのは人が確認したあとです。
つまり、ここは人間の領分として開けてあります。 後半に書く「使ううちに覚えていく」仕組みは、この手書きを置き換えるものではなく、放っておくと抜け落ちるぶんを拾うためのものです。
export interface OntologyConcept {
id: string
name: string
/** Prose business definition — the "meaning" the workspace agreed on. */
definition: string
/** Canonical aggDSL for measuring this concept. The agent prefers this over
* re-deriving a query when the user asks about the concept. */
dslTemplate?: string
kpiColumn?: string
/** Structural node ids this concept measures. */
measures: string[]
causes: ConceptCause[]
actions: ConceptAction[]
source: 'manual' | 'suggested'
createdAt: string
updatedAt: string
}
- definition:何が数えられ、どの期間で、誰を対象にするのか。散文で書く。
- dslTemplate:それを測る正典クエリ。保存時にコンパイルチェックが走る。
- measures:どのFeature/Page/Segmentから計算されるのか、構造層への実リンク。
- causes / actions:数字が動いた理由の仮説と、打ち手。それぞれに「次に聞くべき質問」のテンプレートが付く。
これがシステムプロンプトに注入されます。「アクティベーションどう?」と聞いたとき、エージェントは定義を推測し直すのではなく、こちらが決めた定義とクエリを使う。同じ指標が毎回同じ数字で返ってきます。
あてずっぽうの推測は、pendoIdを直接渡すと消える
digestを組み立てるコードで、いちばん効いたのはここです。
// apps/web/server/utils/ontologyDigest.ts
// pendoIds are usable directly in DSL (featureId=[...] etc.) — emitting
// them here is what lets the agent skip live lookup round-trips.
const shown = measures
.slice(0, MAX_ITEMS)
.map(n => `${n.name} (${n.kind}, pendoId="${n.pendoId}")`)
.join(', ')
オントロジーが無い状態のエージェントは、毎ターンこれをやります。
-
lookup_pendo_features("dashboard")を呼ぶ - 似た名前が7件返ってくる
- どれか選ぶ ← 冒頭に書いた「あてずっぽう」はここ
- DSLを組む
- 実行する
digestにpendoIdを書いておくと、1〜3が丸ごと消えます。取り違える余地そのものが無くなる。
エージェントのUXを良くするというのは、賢いモデルに替えることより先に「そもそも推測させない」ことでした。往復が減るので速くなり、安くなり、そして何より正しくなる。
digestにはこういう使い方の指示も同梱しています。
- When the question matches a concept by name or meaning, START from its DSL
template — adapt only the time window / segment / grouping. Do not re-derive
the query from scratch.
- pendoIds listed here are valid directly in aggDSL — no lookup round-trip needed.
プロンプトの層の切り方
Dendoのシステムプロンプトは3層です。
- Layer 1:aggDSLの仕様書、ワークフロースキル、セッションリプレイの例。プロセス寿命でキャッシュし、プロンプトキャッシュに載せる。
- Layer 2:ノートブックの文脈など、毎ターン変わるもの。
- Layer 3:オントロジーのdigest、カスタムスキル、ワークスペース指示。キャッシュしない。
オントロジーをLayer 3に置いたのは、conceptを直した瞬間に次のターンから効いてほしいからです。キャッシュ効率よりそちらを優先しました。定義を直したのに反映が1ターン遅れるツールは、たぶん誰も定義を直さなくなる。
文字数上限も付けてあります。
/** Hard cap so the digest can never crowd out the rest of Layer 3. */
const MAX_DIGEST_CHARS = 6000
concept単位でしか切りません。
// Whole-concept budgeting: never slice mid-block (the old blind tail-slice
// could cut inside a DSL code fence). Overflowing concepts are reachable
// via lookup_ontology instead.
3. 計算層 — conceptは「生きた数字」を持つ
定義だけ書いてある辞書は、誰も見に来ません。なので各conceptはKPIとスパークラインと前期間比を持ちます。dslTemplateがあればそれで、無ければ measures から自動生成したクエリで計算する。
そしてどちらで計算したかをラベルに書くようにしました。
export interface ConceptMetric {
value: number
/** What `value` is — encodes the extraction heuristic honestly, e.g.
* "events (latest)" or "Σ visitors (12 rows)". */
label: string
series?: Array<{ date: string; value: number }>
/** Fractional change vs the previous window (0.12 = +12%). */
delta?: number
source: 'template' | 'measures'
/** Per-concept degradation — an errored concept is still a cached result. */
error?: string
}
Σ accounts (4 rows) のように「この数字が何なのか」を必ず出す。数字を、それが本当は表していないものと取り違えさせないのは、この手のツールで一番大事な誠実さだと思っています。
エラーもconcept単位に閉じ込めてあります。dslTemplateが1本壊れても、そのconceptが "KPI unavailable" になるだけで地図全体は落ちない。
意味は、先に書いてもいいし、後から生えてもいい
ここが今回いちばん作りたかった部分です。
前提として、さっき書いたとおりconceptは手で書けます。関心のあるタグを最初に登録しておくのが早いなら、そうするのが正解です。
ただ、最初に全部書き切ろうとすると失敗します。 全タグに意味を付けるプロジェクトは、だいたい設計した人が異動した時点で死ぬ。そして実際に運用してみると、あとから効いてくる知識は「やってみて分かったこと」の側にあります。「この指標が落ちるときは、だいたい月初のインポートが詰まっている」みたいなやつは、事前設計では絶対に出てこない。
なので両方を用意しました。先に書いてもいいし、使われながら育ってもいい。 以下は後者の話です。
エージェントが答えたあと、conceptが自分を更新する
conceptEvolution.ts がやっていることです。エージェントが実データで質問に答えたあと、その会話が触れた既存のconceptだけを追記更新します。
/**
* Concept self-maintenance: after an agent answers a question with live Pendo
* data, enrich the EXISTING concepts that question touched — additive only
* (new measures / causes / actions), never touching names, definitions,
* templates, or anything already on the concept. NEW concepts stay a human
* decision (the Suggest drawer); this module only keeps approved ones current.
*
* Selective by design — the ontology's value is precision, so:
* - no LLM call at all unless a concept deterministically matches the
* exchange (name/measure mention, or the ask's origin node);
* - the model is told that adding NOTHING is the usual right answer;
* - additions are deduped, clamped, and validated against real node ids.
*/
ガードレールを4つ入れています。
① 決定論的ゲートを先に通す。マッチしなければLLMを呼ばない
conceptの名前が会話に出たか、conceptが測っているエンティティ名が出たか、地図のどのノードから質問が始まったか。これを文字列マッチで判定して、候補ゼロならそこで終わり。
if (candidates.length === 0) return []
「全部の会話をLLMに読ませてオントロジーを更新させる」はコストも精度も破綻します。安いフィルタで大半を落としてから高いモデルを呼ぶ。
② 追記のみ。名前・定義・テンプレートは絶対に触らせない
人が合意した定義をAIが書き換えたら、それはもう共有語彙ではありません。増やせるのは measures / causes / actions だけです。
③ 「何も足さないのが普通は正解」とプロンプトで明言する
- Adding NOTHING is the correct answer for most exchanges — an ordinary lookup
teaches the ontology nothing. Only add durable knowledge the workspace will reuse.
- If an existing cause or action already covers the same theme or recommendation —
even with different wording or newer numbers — do NOT add another.
Few strong items beat many overlapping ones.
LLMは放っておくと必ず何かを足します。「今日は学ぶことはありませんでした」と言わせるには、明示的に指示する必要がありました。これを書くまで、オントロジーは1週間でノイズの山になっていました。
④ リンクできる実体を、その会話に出てきたものだけに絞る
ハルシネーション対策です。モデルには「このIDリストからだけ選べ」と渡す。しかもその候補リスト自体を、その会話に実際に名前が出たノードだけで構成する。
// Entities the model may link as new measures: only ones this exchange
// actually names, so a hallucinated link is impossible. Single-word names
// ("Guides", "Features") match the prose of almost any answer — those only
// qualify when the USER's question names them; multi-word names appearing
// anywhere in the exchange are near-always genuine.
const measureCandidates = blob.structural.nodes
.filter(n => {
if (n.kind === 'productArea' || n.name.length < 4) return false
const name = n.name.toLowerCase()
if (!text.includes(name)) return false
return name.trim().includes(' ') || questionText.includes(name)
})
.slice(0, MAX_MEASURE_CANDIDATES)
Guides Features のような1語のFeature名は、どんな回答の散文にも偶然マッチします。なので1語の名前はユーザーの質問文に出てきたときだけ候補に入れる。複数語の名前なら会話のどこに出ていても本物とみなす。この非対称なルールに落ち着くまで、誤リンクをだいぶ踏みました。
書き戻す側でも、重複判定・上限クランプ・実在ノードID検証をもう一度かけます。LLMの出力をそのまま信用する場所をひとつも作らない。
/** Paraphrase-tolerant duplicate test: exact or substring either way. */
function isDuplicate(candidate: string, existing: string[]): boolean {
const c = norm(candidate)
return existing.some(e => {
const x = norm(e)
return x === c || x.includes(c) || c.includes(x)
})
}
新しいconceptを作るのは、あくまで人間
新規concept生成は自動化していません。 これは意図的です。オントロジーの価値は網羅性ではなく精度なので、増えるところに人の承認を挟む。
代わりに Suggest が提案だけ出します。証拠源は2つ。
- conversations:保存された質問セルと、直近のユーザー発話。このワークスペースが実際に何を聞いているか。
- the map & data:使用量オーバーレイ、Product Areaのロールアップ、そして一番強いシグナルであるカバレッジギャップ — よく使われているのに、どのconceptも測っていないエンティティ。
/**
* Evidence mined from the map itself: 30d usage leaders, product-area
* rollups, and — the strongest concept signal — high-usage entities that NO
* existing concept measures.
*/
提案は必ず根拠つきで出ます。「この提案は、あなたが3回聞いたこの質問から来ています」と言えないと採用の判断ができないからです。そしてこのエンドポイントは何も保存しません。採用した瞬間に、通常のconcept POSTを source: 'suggested' で通ります。
UX面ではSSEストリーミングにしました。理由はコメントに残っています。
/**
* LLM-drafted concept proposals, STREAMED over SSE — each proposal is
* emitted the moment its JSON object completes in the model output ...
* Waiting for the whole 8000-token response made the drawer feel dead for ~45s;
* streaming shows the first card in the time it takes the model to write ONE proposal.
*/
8000トークン待たせると45秒間ドロワーが死んで見える。JSONオブジェクトが1つ閉じた瞬間にカードを1枚出す。合計時間は変わらないのに体感がまったく違います。agenticなUXの改善は、だいたいこういう地味な話です。
Ask the map — 地図の上で直接聞く
グラフの上に質問ボックスを置きました。ノートブックを開かずに、地図から直接聞ける。
面白いのは、答えが触れたノードをグラフ上でハイライトするところです。起点ノードの近傍(構造エッジ、conceptのmeasures/causes)と、質問文に名前が出たconcept/エンティティを集めて光らせる。
/**
* Which map nodes does this question touch? Origin node + its immediate
* neighborhood (structural edges, concept measures/causes), plus any concept
* or entity whose name appears in the question text. Drives the graph
* highlight so the user sees what the question is connected to.
*/
function findRelatedNodes(question: string, blob: OntologyBlob, originNodeId?: string): string[]
「エージェントがプロダクトのどの部分について考えたのか」が目に見える。答えの信頼性を判断する材料が、文章の外側にもあるという状態です。
ちなみに、オントロジーが本当に効いているのかを測るために noOntology というA/B用フラグをAPIに残してあります。
/** EXPERIMENT ONLY: strips the ontology digest + lookup_ontology tool for an
* A/B control run. Never set by the UI. */
noOntology: z.boolean().optional()
「オントロジーを渡さない対照群」を同じコードパスで走らせられるようにしておく。効いている気がする、では判断できないので。
指標から「次の一手」まで
各conceptは causes(数字が動いた理由の仮説)と actions(打ち手)を持ち、それぞれに質問テンプレートが付いています。Save as question を押すと、それがそのままノートブックの実行可能なセルになる。
「アダプションが下がっている」で終わらせない。原因候補を辿って、質問を実行して、打ち手に進むところまでを地図の上に置く、というのが狙いです。
問いが、再実行できる資産になる
オントロジーがあると、その上の層でできることが変わります。v2では question cell という新しいセルを入れました。平文の質問と、その最新の回答・表・グラフをひとつにまとめたセルです。Re-run で丸ごと再生成されます。
保存されているのは答えではなく問いである、というのがポイントです。答えはいつでも作り直せる派生物として扱う。
再実行は決定論的にした
素朴にやるなら、Re-runのたびにエージェントに質問を投げ直せばいい。でもそれをやると、同じ質問なのに毎回違うクエリが走る。先週は featureEvents で数えていたのに今週は pageEvents で数えている、みたいなことが起きる。数字が動いたときに、プロダクトが動いたのかクエリが動いたのか区別できません。
なので、こう分けました。
- 初回:エージェントが「どのクエリを投げるか」を決める。ここは非決定論的でいい。
- 2回目以降:そのDSLをそのまま再生する。データは最新、クエリは固定。
- 解釈だけ、ツールを渡さないLLM呼び出しで作り直す。
// apps/web/server/api/notebooks/[id]/question/rerun.post.ts
const res = await callLlm({
provider, model: agent.model, systemPrompt,
messages: [{ role: 'user', content: userMessage }],
tools: [], // ← 新しいクエリを生成させない
maxTokens,
onTextDelta: () => {}
})
ツールが無ければ、モデルは目の前のデータ以外を語れません。「ついでにこれも調べますね」と勝手に走り出すこともない。データ取得は決定論、言語化だけ非決定論という切り分けです。
壊れたクエリだけ、エージェントに差し戻す
決定論的リプレイの弱点は、スキーマが動くことです。Featureがリネームされる、IDが変わる、先月動いていたクエリが今日400を返す。
このとき「取れたデータだけで答える」のが一番よくない。半分のデータで自信満々のレポートが出てくるからです。
なので、失敗したクエリだけを、元の問いの意図ごとエージェントに戻します。
const repairMessage =
`While re-running a saved analysis, one of its queries failed and must be rebuilt.\n\n` +
`The original question this analysis answers:\n${question}\n\n` +
`What this specific query is meant to produce: ${purpose}\n\n` +
`The saved DSL that is now failing:\n${failingDsl}\n\n` +
`The error it produced:\n${error}\n\n` +
`Rebuild a correct Pendo aggregation DSL that fulfils the same purpose for the ` +
`original question, then run it with run_pendo_aggregation. ...`
「このDSLを直して」ではなく「この問いに答えるという目的を満たすDSLを組み直して」と頼むのが肝です。エラーメッセージだけ渡すと、モデルは構文を直そうとして意味を壊すことがある。
直ったDSLはセルに書き戻されるので、次のRe-runはまた決定論的リプレイに戻ります。動いているクエリには一切触らない。壊れたものだけエージェントを呼ぶ。
実際の一日
文章で機能を並べても伝わりにくいので、いつもの流れを書きます。
- 新しいノートを開いてタイトルを付ける。
- 右のチャットで喋りはじめる。「先週のダッシュボード、ペルソナ別の定着どう?」
- エージェントが
lookupを挟まずにDSLを組んで(conceptにpendoIdが載っているので)、表を返す。 - 「あ、これは残しておきたい」と思ったやつだけ Add chart や Save as question でノートに落とす。残さないものは流す。
- さらに聞く。「じゃあ落ちてる方のペルソナ、どこで詰まってる?」 数字が動いていれば、Product map のconceptに書いてあるcauses(原因の仮説)から辿れる。
- 気になったところに Note セルで一行メモを書く。「これは10月のUI変更の影響っぽい」
- 数字の意味が曖昧になってきたら Product map を開く。conceptの定義を確認する。定義が無ければその場で書く。
- 一週間後、同じノートを開いて Re-run を押す。数字だけ入れ替わって、文章も書き直される。メモはそのまま残っている。
探索と記録が同じ画面で完結していて、しかも記録の側が生きている。 ここが、チャットとダッシュボードのどちらでもできなかったところです。
作ってみて分かったこと
1. エージェントに一番効くのは、モデルではなく文脈の形
同じモデルでも、pendoIdが手元にあるかないかで挙動がまるで違います。推測させる余地を潰すほど賢く見える。プロンプトエンジニアリングより先に、そもそも何を知っている状態で呼ぶかの設計でした。
2. 決定論と非決定論は、機能単位ではなく工程単位で切る
「question cellはAI機能」ではなく「データ取得は決定論、言語化は非決定論」。この粒度で切ると、再現性とAIらしさを同時に取れます。self-healingも同じで、壊れたものだけ非決定論に落とす。
3. 事前設計を「必須」にしなければ、メタデータ管理は続く
書きたい人が先に書けること自体は大事です。死ぬのは「全部を事前に設計しないと使い始められない」形のほうでした。手で書ける口と、実際に聞かれた問いから後から蒸留される口の両方を開けておくと、量は少なくても全部生きている状態を保てます。「意味付けは後からでいい」は怠慢ではなく、運用が続く形の話です。
4. エージェントに直させたいものは、エージェント自身に書かせる
MCP越しのツール呼び出しは、成功する限りは楽です。でも失敗したとき、AIの手元には「呼んだら失敗した」しか残らない。自分でDSLを書かせておくと、失敗したときに何をどう間違えたかが自分の文字列として見える。自己修復できるかどうかは、賢さではなくこの構造で決まっていました。
5. AIの賢さより、答えの「置き場所」のほうが効いた
これは作る前に一番読み違えていたところです。エージェントを賢くする工夫(オントロジー、self-healing)にほとんどの時間を使いましたが、一番効いたのはいい答えが返ってきたときそれを保存しておくこと。いい答えとは何かAIが把握できることが一番重要でした。
まだできていないこと
- conceptのバージョン管理がない。定義が変わったとき、過去のレポートがどの定義で計算されたのか追えない。
- 複数人ワークスペースでの定義の合意プロセスが薄い。(むしろ今は一人でしか使わない想定のアプリ。マルチユーザー設計になっていない
- 構造層の同期に上限(Feature 300 / Page 300 / Segment 100)があり、大規模ワークスペースでは切り捨てが起きる。切り捨てたことはUIに出しているが、賢い選び方はできていない。
- causes の
conceptIdリンク(concept間の因果グラフ)は型としては入っているが、UIからの活用がまだ浅い。
付録:MCPも繋がる — Pendo MCPとの併用が効く
前の章で「MCPではなくaggDSLを書かせている」と書きましたが、あれは 全部をMCPに任せるな という話であって、MCPを使うなではありません。むしろDendoは外部のMCPサーバを繋げるようにしてあって、併用したときが一番強いです。
繋ぎ方
Admin → MCP Servers から streamable HTTP のMCPサーバを登録できます。
/**
* Streamable-HTTP MCP servers (e.g. Pendo's /mcp/v0/shttp) are session-based:
* every request after `initialize` must echo the `mcp-session-id` response
* header or the server rejects it ("Invalid session ID").
*/
-
initializeのセッションハンドシェイクと、SSEフレームで返ってくるレスポンスの両方に対応(素のJSON-RPCも通ります) - OAuthは、401の
WWW-Authenticateからリソースメタデータを辿る discovery(RFC 9728)、動的クライアント登録、トークンの自動リフレッシュまで面倒を見ます - Test Connection と認証フローは保存済みの設定をそのまま使うので、テストが通った状態=エージェントが実際に使う状態です
繋ぐと、そのサーバのツールが組み込みのaggDSLツールと同じ卓の上に並びます。エージェントから見れば run_pendo_aggregation と外部ツールは対等で、1つの質問に答えるために両方を混ぜて呼べます。
例:Pendo MCP と組み合わせてリテンションを見る
一番わかりやすい併用先が Pendo MCP です。Aggregation APIで素直に取れないものを、そっちから取ってくる。
代表格がリテンションとファネル。aggdslでも近似は書けます — 実際 examples/feature_retention_top5.dsl には「直近30日で2日以上アクティブだった訪問者の割合」という自前定義のリテンションが入っています。
| group by featureId,visitorId fields { activeDays=count(day) }
| eval { retained=if(activeDays > 1, 1, 0) }
| group by featureId fields { totalUsers=count(visitorId), retainedUsers=sum(retained) }
| eval { retentionRate=retainedUsers / totalUsers }
でもこれは自分で決めた近似であって、Pendoの画面に出ている Retention レポート(コホートを切って、週を追うごとに何%戻ってきたかを見るあれ)とは別物です。同じ数字にはなりません。
Pendo MCP には cohortRetentionCurve のように、Pendo側が計算した結果をそのまま返してくれるツールがあります。だったらそっちを呼べばいい。同じ会話の中で、
- Pendo MCP から本物のリテンションカーブを取る
- aggdsl で「そのコホートが実際に触った機能」を掘る
- Product map の concept が「うちの定着とは何か」を定義している
- エージェントが3つを突き合わせて答える
……ということができます。そして出てきた答えは、いつもどおりノートに残せます。
あと、実はファネルはAggregation APIでは取れません。そのため、Pendo MCPのファネルツールを使うと正確なデータが取れるので、便利です。
使い分けの原則
自分で直させたいものは自分で書かせる。Pendoが正解を持っているものは取りに行かせる。
- aggdslで書ける → 書かせる。壊れたら自分で直せるし、人間もレビューできる(前章のとおり)
- Pendoの計算そのものが欲しい → MCPで取る。リテンション、ファネル、Agent Analytics系など、自前で近似すると必ずズレるもの
「MCPかDSLか」ではなく、どっちの性質の仕事なのかで振り分けるという話でした。
まとめ
Dendoでやりたかったことは、結局この2つです。
ひとつは、喋りながら発見を残せること。 ダッシュボードは先に何を見るか決めないと作れないし、チャットは聞いた端から流れていく。その間に、探索したまま記録が積み上がる場所が無かった。だからノートブックにして、チャットの回答に「これを残す」ボタンを付けて、残したものは再実行できるようにしました。
もうひとつは、その対話の相手にちゃんと文脈を持たせること。 エージェントがあてずっぽうでタグを選ぶのは、モデルの問題ではなく渡す文脈が存在しなかったという問題でした。計測ツールの中には事実しかない。その上に薄い意味の層を敷いて、使うたびにそこへ蒸留していく。それだけで、エージェントは「アプリのことを何も知らない外部の人」から「文脈を共有している同僚」に変わります。
しかもその意味は、最初に全部書き切らなくていい。先に書いてもいいし、聞かれた問いから後から生えてもいい。
そしてこの2つを支えているのが、クエリをAI自身に書かせているという土台です。aggDSLという読めるテキストを挟んだおかげで、壊れたら直せるし、人間もレビューできるし、そのままノートに残せる。MCP越しに「呼んだら失敗した」だけが返ってくる世界では、どれも成立しませんでした。
リポジトリはこちらです。./dev.sh 一発で動きます。
https://github.com/Band-Aid/dendo_ai
aggDSLのリポジトリはこちら:
https://github.com/Band-Aid/aggdsl
オントロジーの設計背景の考え方は 「意味付けは後からでいい」— AIに聞けば答えが出る時代のデータ管理 に書いたので、興味があればそちらも。







