2025-10-31
Jigsaw Sensemakerとは
Polisからのエクスポート
元データには星さんのPRでの議論のPolisを使う
Common ground: 参加者は、個人の自己決定権を「家族の伝統」よりも優先すべきだと主張しました。
Differences of opinion: 婚姻制度そのものを性別を問わない「パートナーシップ制度」に再編すべきかという点について、参加者の間で意見が分かれました。
YouTubeからの抽出
SenseMakerの処理の流れ
export async function categorizeCommentsRecursive(
comments: Comment[],
topicDepth: 1 | 2 | 3,
model: Model,
topics?: Topic[],
additionalContext?: string
): Promise<Comment[]>
以下のコメントを分析し、共通するトピックを特定してください。
トピックの粒度を考慮してください。トピックが少なすぎると内容を過度に単純化して重要なニュアンスを見落とすおそれがあり、多すぎると冗長になって全体の構造が不明瞭になる可能性があります。
過度な詳細に踏み込みすぎることなく、主要なテーマを効果的に要約できるバランスの取れたトピック数を目指してください。
コメントを分析したのち、その内容を効果的に表現できる最適なトピック数を決定してください。
また、トピック数が少ない場合になぜ最適でないのか(過度の単純化や重要なニュアンスの欠落の可能性)、トピック数が多い場合になぜ最適でないのか(冗長さや全体構造の不明瞭化の可能性)をそれぞれ正当化してください。
最適なトピック数を決めた後、そのトピックを特定してください。
As of April 10, 2025 the cost for running topic identification, statement categorization, and summarization was in total under $1 on Gemini 1.5 Pro.
[
{ name: "Polisとデジタル民主主義" },
{ name: "生成AI(特にイラスト)を巡る議論" },
{ name: "動画内容や出演者への感想・反応" },
{ name: "SNSとオンラインコミュニケーションのあり方" },
{ name: "入湯税など具体例に関する解説・補足" }
]
// categorization.ts:573
comments = await oneLevelCategorization(comments, model, topics, additionalContext);
- ここでは1095件のコメントを100個ずつの11バッチに分割して実行する
prompt
以下の各コメントについて、下記のリストから最も関連性の高いトピックを特定してください。
入力トピック:
${JSON.stringify(topics)}
重要な留意点:
- 割り当てるトピックがコメントの意味を正確に反映していることを確認してください。
- 必要に応じて1つのコメントを複数のトピックに割り当てても構いませんが、可能な限り1つにとどめてください。
- 可能な限り既存のトピックを優先して使用してください。
- すべてのコメントは少なくとも1つの既存トピックに割り当ててください。
- 既存のトピックにうまく当てはまらない場合は「Other」トピックに割り当ててください。
- 入力トピックに記載されていない新しいトピックを作成しないでください。
- JSON出力を生成する際は、レスポンスサイズを最小化してください。たとえば、不要な空白や改行を加えず、次のようなコンパクトな形式を推奨します: {"id": "5258", "topics": [{"name": "Arts, Culture, And Recreation"}]}
- 分類できなかったら3回までリトライして、それでもダメだったやつはOtherにする
- 分類結果はCommentオブジェクトのtopicフィールドに書く、最初はundefinedで、この時に`{name: string}`になる
// このトピックに属するコメントだけを抽出
const commentsInTopic = getCommentTextsWithTopicsAtDepth(comments, topic.name, 1);
// 例: "Polisとデジタル民主主義" → 356件
- このコメント集合に対してサブトピックを作る `learnSubtopicsForOneTopicPrompt`
prompt
以下のコメントを分析し、次の上位トピック「${parentTopic.name}」の中に共通するサブトピックを特定してください。
サブトピックの粒度を考慮してください。サブトピックが少なすぎると内容を過度に単純化して重要なニュアンスを見落とすおそれがあり、多すぎると冗長になって全体構造が不明瞭になる可能性があります。
過度な詳細に踏み込みすぎず、主要テーマを効果的に要約できるバランスの取れたサブトピック数を目指してください。
コメントを分析したのち、内容を効果的に表現できる最適なサブトピック数を決定してください。
さらに、サブトピック数が少ない場合になぜ最適でないのか(過度の単純化や重要なニュアンスの欠落の可能性)、多い場合になぜ最適でないのか(冗長化や全体構造の不明瞭化の可能性)をそれぞれ正当化してください。
最適なサブトピック数を決めた後、そのサブトピックを特定してください。
重要な留意点:
- サブトピック名を上位トピックと同一にしないでください。
- ほかのコメント群では別の上位トピックが使われています。以下の上位トピック名はサブトピック名として使用しないでください: ${otherTopicNames}
- 追加で「悪い例」としてサブトピック同士が似すぎているものを例示している
- このサブトピックに対してまた分類をする
library/src/tasks/summarization_subtasks/topics.tsのgetThemesSummary()で要約文として生成されたものprompt
すべてのstatementsを対象に、最大5個の顕著なテーマを特定し、簡潔な箇条書きで作成してください。これらのstatementsはすべて${this.topicStat.name}に関するものです。各テーマは、太字の短いテーマ説明で始め、続けてコロンを置き、その後にそのテーマを説明する1文のみを書いてください。以下のCriteriaを満たし、Output Formatを厳密に遵守してください。箇条書きの前にテキストを付けないでください。
<criteria format="markdown">
* 公平性(Impartiality): statementsに対する賛否や警鐘などの規範的判断を述べないこと。
* 忠実性(Faithfulness): statementsを正確に反映し、虚構や誤解釈を避けること。
* 同様に、statements間の合意の程度を仮定・誤記しないこと(例:一部のstatementsにしか言及がないのに「全会一致」などと表現しない)。
* この基準はテーマ名にも適用される:疑いようのない圧倒的合意がない限り、テーマ名に「〜への支持」などの強い合意を示す表現を用いないこと。
* 具体的であること。「もの」「側面」などの曖昧名詞を避けること。
* 包括性(Comprehensiveness): すべての意見を出現頻度に応じて反映すること。ただし、少数意見を絶対に除外しないこと。強い異議や立場の混在がある場合は具体的に含めること。
* 用語の一貫性(Consistent terminology): 常に "statements" を使用し、"comments" は使用しないこと。
</criteria>
<output_format format="markdown">
タイトルケースのテーマ: 文
</output_format>
tttc-light-jsの処理の流れ
@app.post("/topic_tree")
def comments_to_tree(
req: CommentsLLMConfig, ...
) -> dict
- LLMで1発でトピックとサブトピックを作る
- まずサニタイズしている(Sensemakerはしてない、Claude Code談)(後述)
defaultSystemPrompt
あなたはプロのリサーチアシスタントです。多くのパブリック・コンサルテーション、調査、市民アセンブリの運営を支援してきました。興味深いインサイトを抽出することに関して優れた直感を持っています。あなたは Pol.is のようなパブリック・コンサルテーション・ツールに精通しており、他者が投票できるような明確で簡潔な主張を用いて作業することの利点を理解しています。
defaultClusteringPrompt
コメントのリストを渡します。
これらのコメントに含まれる情報を、関心のあるトピックとサブトピックに分解する方法を提案してください。
トピック名とサブトピック名はできるだけ簡潔にし、短い説明でそのトピックの内容を説明してください。
次の形式のJSONオブジェクトを返してください:
{
"taxonomy": [
{
"topicName": string,
"topicShortDescription": string(最大30文字),
"subtopics": [
{
"subtopicName": string,
"subtopicShortDescription": string(最大140文字),
},
...
]
},
...
]
}
では、コメントのリストはこちらです:
\${comments}
- これらのプロンプトが`common/prompts/index.ts`に書かれててAPIのペイロードに積まれてそのままOpenAIに送信されてるので、これは外部に露出したらまずいな
@app.post("/claims")
async def all_comments_to_claims(
defaultExtractionPrompt
参加者が述べたコメントと、すでに抽出済みのトピックおよびサブトピックの一覧を渡します。
参加者が支持し得る、最も重要で簡潔な「主張(claim)」を抽出してください。
関心があるのは、与えられたトピック/サブトピックのいずれかにマッピングできる主張のみです。
主張は十分に一般的でありつつも、陳腐な決まり文句ではないこと。
他の人が反対し得る内容であること。
また、各主張は「原子的(複合せず、一点の立場を表す)」である必要があります。
【抽出ルール(厳格運用)】
1. あいまい・散漫で要点のないコメントからは「主張を0件」抽出すること
2. 一般化に至らない単なる逸話からは「主張を0件」抽出すること
3. 明確で実質的に論争可能な立場が複数含まれる場合は複数の主張を抽出してよいが、類似点は別主張にせず「1つの主張のバリエーション」として扱うこと
4. 本当に論争可能な立場のみを抽出すること
5. 次に挙げるものは抽出しないこと:
- 紋切り型・自明な言い回し(例:「communication is important」)
- 立場を伴わない単なる経験の記述
- 同一の根底アイデアの些細な言い換え
- 明確な立場のない質問や思索
【品質閾値】
そのコメントに抽出に値する十分な主張があるか確信が持てない場合は、何も抽出しない側に倒してください。
周辺的な主張を拾ってノイズを増やすより、取りこぼしの方が望ましいとします。
各主張については、該当する「引用」も併せて提示してください。
引用は主張を裏付けるのに十分でありつつ、可能な限り簡潔にしてください。
引用は論理的な議論である必要はありません。発言者がその主張をする動機を示す個人的なエピソードや逸話でも構いません。
引用内で重要でない部分は「[...]」を用いて省略しても構いません。
次の形式のJSONオブジェクトを返してください:
{
"claims": [
{
"claim": string, // 非常に簡潔な抽出主張
"quote": string, // そのままの引用
"topicName": string, // 与えられたトピック名から
"subtopicName": string // 与えられたサブトピック名から
}
]
}
ここにトピック/サブトピックの一覧があります:
\${taxonomy}
そしてコメントは次のとおりです:
\${comment}
- <img src='https://scrapbox.io/api/pages/nishio/human/icon' alt='human.icon' height="19.5"/>不要なものを抽出しないための予防策がすごい。
- 相手がLLMだと、結構無理難題も平気でプロンプトに書くんだなあ。
- <img src='https://scrapbox.io/api/pages/nishio/human/icon' alt='human.icon' height="19.5"/>このプロンプトも LLM に作らせたのかな?
- <img src='https://scrapbox.io/api/pages/nishio/nishio/icon' alt='nishio.icon' height="19.5"/>[プロンプトはLLMに作らせるほうがいい](/ja/%E3%83%97%E3%83%AD%E3%83%B3%E3%83%97%E3%83%88%E3%81%AFLLM%E3%81%AB%E4%BD%9C%E3%82%89%E3%81%9B%E3%82%8B%E3%81%BB%E3%81%86%E3%81%8C%E3%81%84%E3%81%84)
- <img src='https://scrapbox.io/api/pages/nishio/human/icon' alt='human.icon' height="19.5"/>どういうプロンプトで、今のロジックのプロンプトを作らせたんだろ?
- <img src='https://scrapbox.io/api/pages/nishio/nishio/icon' alt='nishio.icon' height="19.5"/>それはわからんけど、ちょうど昨日僕がやってたのはこう
- > なので僕の外部脳を読んだGPT5に考えさせて、そのプロンプトを実際に使って抽出されたものを見てフィードバックを返してアップデートさせた
- from [チャットから知見を引き出すシステム#69037b1f0000000000ae2403](/ja/%E3%83%81%E3%83%A3%E3%83%83%E3%83%88%E3%81%8B%E3%82%89%E7%9F%A5%E8%A6%8B%E3%82%92%E5%BC%95%E3%81%8D%E5%87%BA%E3%81%99%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%2369037b1f0000000000ae2403)
- コメントそのままを分類するのではなく「主張の抽出」をするのが独特
- これは[Talk to the City Scatter](/ja/Talk%20to%20the%20City%20Scatter)の時もまずextraction phaseを持っていた
- 一方でこのバージョンでは「引用」もセットで抽出している
- [Plurality本の概念マップ](/ja/Plurality%E6%9C%AC%E3%81%AE%E6%A6%82%E5%BF%B5%E3%83%9E%E3%83%83%E3%83%97)を作った時に僕もやった
- このデザインは「AIがそう主張する根拠はユーザのこの発言です」とやって手軽に[どこから来たのかのトレーサビリティ](/ja/%E3%81%A9%E3%81%93%E3%81%8B%E3%82%89%E6%9D%A5%E3%81%9F%E3%81%AE%E3%81%8B%E3%81%AE%E3%83%88%E3%83%AC%E3%83%BC%E3%82%B5%E3%83%93%E3%83%AA%E3%83%86%E3%82%A3)ができるのが良い
- コメントは1つずつLLM処理している
- SenseMakerが100個ずつ束ねて処理していたのとは違う
- LLM呼び出し自体は6並列にしている
- Step1で作られたtaxonomyを元にPydanticでスキーマ定義を作って、それをOpenAIの呼び出し時にJSON Schema validationとして与えている
- `pyserver/structured_schemas.py`: 構造化スキーマ生成 (L25)
py
TopicEnum = PyEnum('TopicEnum', {name: name for name in topic_names})
SubtopicEnum = PyEnum('SubtopicEnum', {name: name for name in subtopic_names})
# Create the Claim model with constrained fields
Claim = create_model(
'Claim',
claim=(str, Field(description="A concise extracted claim")),
quote=(str, Field(description="The exact quote supporting this claim")),
topicName=(TopicEnum, Field(description=f"Must be one of: {', '.join(topic_names)}")),
subtopicName=(SubtopicEnum, Field(description=f"Must be one of: {', '.join(subtopic_names)}")),
__base__=BaseModel
)
- 「限られたtopic名から選ぶ」ということをEnumとして表現しているわけ
- さらにコンテキストにも積んでる
py
# Build prompt with explicit taxonomy constraints
# This is "belt and suspenders" with structured outputs
taxonomy_constraints = create_taxonomy_prompt_with_constraints(taxonomy_list)
full_prompt = llm.user_prompt + "\n\n" + taxonomy_constraints + "\n\nComment:\n" + sanitized_comment
- これの費用対効果がどうなのかは個人的には疑問に思っている
- まあ大したコストではないから念のためおまじないをするか〜ぐらいの感じなのではと思う
- 余談だけどプロンプトを`+ "\n\n" +`で組み立てるね
- SenseMakerでは`<instruction>...</instruction><data><comment>...</comment>...</data>`って感じでXML的プロンプト
- どちらが良いのかは不明
defaultDedupPrompt
あなたは、今回のコンサルテーションで「どのテーマが最も重要か」を利用者が理解できるように、主張(claims)をグルーピングします。目的は、類似する主張を裏付けのあるグループに統合しつつ、本当に独自の視点は保持することです。
各主張には ID・主張テキスト・引用テキストが含まれます。
グルーピング判断フレームワーク(FRAMEWORK):
Step 1 - コアテーマの特定:
自問してください:「これらすべての主張に共通して表明されている“3~5個の主なアイデアや懸念”は何か?」
これらを候補グループとします。
Step 2 - グルーピング基準の適用:
次のいずれかを共有していれば、主張を同じグループにまとめます:
✓ 同一の根本的懸念・問題(提案する解決策が異なっていても可)
✓ 同一の提言・解決策(理由づけが異なっていても可)
✓ 表明している価値観・原則が同じ
✓ 同じトピックの異なる側面(例:「コストが高い」+「価格が不明確」=価格に関する懸念)
✓ 一般的なパターンの具体例
以下の場合のみ、主張は別グループのままにします:
✗ このサブトピックの中で、完全に別のトピックを扱っている
✗ 互いに対立する立場(何かへの賛否)が表明されている
✗ 片方はプロセス/方法(how)、もう片方は成果/何を(what)に関するもの
Step 3 - 強いグループ主張(Group Claim)の作成:
各グループについて、次を満たす主張を書きます:
- 共有される本質を、より高い抽象度で捉える
- 元の主張に現れる語彙・概念を用いる(新しい用語の導入を避ける)
- 具体性があり意味のあるレベル(「Xを改善」などの曖昧な常套句は避ける)
- グループ中のすべての引用が妥当に支持し得る
- 明快で平易な言葉遣い
- 参加者の発言内容に忠実である
Step 4 - グループの妥当性確認:
- 目標のグループ数に合わせるよりも、自然な主題的一貫性を優先
- 各グループは明確で意味のあるテーマを表すべき
- 単一主張のみのグループは比較的まれであるべき。多い場合は、主張同士を結びつける上位テーマを見落としていないか再検討する
- 過度な統合は避ける:数を減らすために無理に結合しない
- 適切なグループ数は、入力に含まれる視点の自然な多様性に依存する
良いグルーピング例:
入力主張:
- 「駐車料金が高すぎる」
- 「駐車パスの仕組みが分かりにくい」
- 「駐車スペースを増やすべき」
良いグループ主張: 「駐車のアクセス性と負担可能性の改善が必要」
理由: 3つとも、強調点は違っても「駐車が障壁」という点を扱っているため。
入力主張:
- 「再生可能エネルギーを優先すべき」
- 「市はレジ袋を禁止すべき」
悪いグルーピング: 「環境施策が必要」
理由: どちらも環境ではあるが、政策としては別個に扱うべき。
次の形式の JSON オブジェクトを返してください:
{
"groupedClaims": [
{
"claimText": "このグループの引用すべてを代表できる上位主張",
"originalClaimIds": ["claimId1", "claimId3", "claimId5"]
}
]
}
では、グルーピング対象の主張はこちらです:
\${claims}`;
export const defaultSummariesPrompt = `
トピックの説明・サブトピック・主張を含む JSON オブジェクトを渡します。
各トピックについて、そのトピックのサブトピックと主張の詳細サマリーを生成してください。
サマリーは140文字以内とします。
次の形式の JSON オブジェクトを返してください:
{
"summaries": [
{
"topicName": string, // 与えられたトピック名
"summary": string // 最大140文字
}
]
}
では、トピックはこちらです:
\${topics}
- ここ面白い
- > 同一の根本的懸念・問題(提案する解決策が異なっていても可)
- チームみらいの[しゃべれるマニフェスト](/ja/%E3%81%97%E3%82%83%E3%81%B9%E3%82%8C%E3%82%8B%E3%83%9E%E3%83%8B%E3%83%95%E3%82%A7%E3%82%B9%E3%83%88)のデータを見て僕が「問題意識を抽出してまとめ直そう」としたのと関連している
- 同じ問題意識に対する異なる解決策の提案はまとまっていた方が良い
- 同様に「根拠が異なるが主張は同じ」もまとめるようだ
- > 同一の提言・解決策(理由づけが異なっていても可)
トピックの説明・サブトピック・主張を含む JSON オブジェクトを渡します。
各トピックについて、そのトピックのサブトピックと主張の詳細サマリーを生成してください。
サマリーは140文字以内とします。
次の形式の JSON オブジェクトを返してください:
{
"summaries": [
{
"topicName": string, // 与えられたトピック名
"summary": string // 最大140文字
}
]
}
では、トピックはこちらです:
\${topics}
defaultCruxPrompt
説明付きのトピックと、このトピックについて異なる参加者が述べた高レベルの主張リストを渡します(参加者は "Person 1" や "A" のような仮名で識別されます)。あなたには、当該トピックに関するすべての発言に基づき、参加者を二つのグループに最もうまく分けられる新たな具体的な文「cruxClaim(分岐点となる主張)」を作成してほしいです。一方はその主張に賛成、もう一方は反対になるようにしてください。理由づけを説明し、参加者を "agree" と "disagree" のグループに割り当ててください。
次の形式の JSON オブジェクトを返してください
{
"crux": {
"cruxClaim": string, // 新たに抽出した主張
"agree": list of strings, // cruxClaim に賛成する参加者(与えられた仮名)のリスト
"disagree": list of strings,// cruxClaim に反対する参加者(与えられた仮名)のリスト
"explanation": string // 参加者の視点からこの cruxClaim を統合した理由
}
}
大分けから小分けにもっていくのはまったく邪道である。かならず小分けから大分けに進まなければならないのである。これがこの方法の 決定的な問題点のひとつであ る(第7図参照)。
...
自分の心のなかに、「これだけの紙きれの資料は、自分の考えによれば、内容的に市場調査・品質管理・労務管理と三つに大きく仕切るのが正しい」などというたぐいの、グループ分けについての独断的な原理をあらかじめ頭の中にもっているからである。その独断的な分類のワクぐみを適用し、そのできあいのワクの中にたんに紙きれの資料をふるい分けて、はめこんでいるにすぎないのである。これでは KJ法の発想的意義はまったく死んでしま う。
細かい話
pyserver/simple_sanitizer.py
:# 1. プロンプトインジェクション検出(例: "ignore previous instructions")
# 2. PII検出(メール、電話番号、SSN、クレカ)→ [EMAIL]などに置換
# 3. 長さチェック(10,000文字まで)
# 4. 意味のあるコメントか(3文字以上)
// vertex_model.ts:172-193
const safetySettings = [
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_NONE, // ← すべて無効化!
},
// ...他のカテゴリも同様にBLOCK_NONE
];
ここまでのまとめ
SenseMakerやtttc-light-jsはTalk to the City Scatterや広聴AIと根本的に異なるパイプライン
いいとこどりをすることは可能なのだろうか?
素朴な方法ではなかなか難しそう
KJ法的な分類がLLMでできるようになったらいいなあ・・・
KJ法で分類して、だけじゃできないのか。
そのうちデータ渡して、「Talk To The City 方式で散布図出して」でできるようになるかな。
logs
2025-10-28 クラスタリング結果をUMAPして観察
https://chatgpt.com/share/69007cdb-c414-8011-9eff-aac21e0a0870