layout.search
Search for web layout patterns using natural language queries in English or Japanese. Filter by section types like hero, feature, or pricing, and use hybrid vision-text search to find relevant design components.
Instructions
セクションパターンを自然言語クエリでセマンティック検索します。日本語・英語の両方に対応しています。hero、feature、cta、testimonial、pricing、footer等のセクションタイプでフィルタリングできます。use_vision_search=trueでvision_embeddingを使用したハイブリッド検索(RRF: 60% vision + 40% text)が可能です。
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| query | Yes | 検索クエリ(日本語または英語、1-500文字) | |
| filters | No | 検索フィルター | |
| limit | No | 取得件数(1-50、デフォルト: 10) | |
| offset | No | オフセット(0以上、デフォルト: 0) | |
| include_html | No | HTMLスニペットを含めるか(デフォルト: false)- snake_case正式形式 | |
| includeHtml | No | HTMLスニペットを含めるか(デフォルト: false)- レガシー互換、include_html推奨 | |
| include_preview | No | サニタイズ済みHTMLプレビューを含めるか(デフォルト: true) | |
| preview_max_length | No | HTMLプレビューの最大文字数(100-1000、デフォルト: 500) | |
| project_context | No | プロジェクトコンテキスト解析オプション。プロジェクトのデザインパターンを検出し、検索結果の適合度を評価します。 | |
| auto_detect_context | No | クエリから業界・スタイルコンテキストを自動推論し、結果をブーストします。推論されたコンテキスト(業界: technology/ecommerce/healthcare等、スタイル: minimal/bold/corporate等)にマッチする結果の類似度スコアが最大0.15ブーストされます(デフォルト: true) | |
| use_vision_search | No | Vision検索を有効化。vision_embeddingを使用したセマンティック検索を行います(デフォルト: false) | |
| vision_search_query | No | Vision検索クエリ(use_vision_search=true時に使用) | |
| vision_search_options | No | Vision検索オプション(use_vision_search=true時に使用) | |
| search_mode | No | 検索モード。text_only: text_embeddingのみを使用(デフォルト)。vision_only: vision_embeddingのみを使用。combined: 両方を使用してRRF統合検索。 | text_only |
| multimodal_options | No | マルチモーダルオプション。search_mode='combined'時のRRF統合パラメータ。 | |
| profile_id | No | 嗜好プロファイルID(検索結果のリランキングに使用) / Preference profile ID (used for search result reranking) |
Implementation Reference
- LayoutSearchServiceクラスには、レイアウトセマンティック検索(ベクトル検索とハイブリッド検索)の主要なロジックが実装されています。特に `searchSectionPatterns` と `searchSectionPatternsHybrid` メソッドがコア機能を提供します。
export class LayoutSearchService implements ILayoutSearchService { private embeddingService: IEmbeddingService | null = null; private prismaClient: IPrismaClient | null = null; /** * EmbeddingServiceを取得 */ private getEmbeddingService(): IEmbeddingService { if (this.embeddingService) { return this.embeddingService; } if (embeddingServiceFactory) { this.embeddingService = embeddingServiceFactory(); return this.embeddingService; } throw new Error("EmbeddingService not initialized"); } /** * PrismaClientを取得 */ private getPrismaClient(): IPrismaClient { if (this.prismaClient) { return this.prismaClient; } if (prismaClientFactory) { this.prismaClient = prismaClientFactory(); return this.prismaClient; } throw new Error("PrismaClient not initialized"); } /** * クエリテキストからEmbeddingを生成 * EmbeddingServiceが利用できない場合はnullを返す */ async generateQueryEmbedding(query: string): Promise<number[] | null> { if (isDevelopment()) { logger.info("[LayoutSearchService] Generating query embedding", { queryLength: query.length, }); } // EmbeddingServiceが利用できない場合はnullを返す if (!embeddingServiceFactory) { if (isDevelopment()) { logger.warn("[LayoutSearchService] EmbeddingService not available, returning null"); } return null; } try { const embeddingService = this.getEmbeddingService(); return await embeddingService.generateEmbedding(query, "query"); } catch (error) { if (isDevelopment()) { logger.warn("[LayoutSearchService] Embedding generation failed, returning null", { error: error instanceof Error ? error.message : "Unknown error", }); } // エラー時もnullを返し、空の結果を返す return null; } } /** * セクションパターンを検索 */ async searchSectionPatterns( embedding: number[], options: SearchOptions ): Promise<SearchServiceResult | null> { const startTime = Date.now(); if (isDevelopment()) { logger.info("[LayoutSearchService] Starting section pattern search", { embeddingDimensions: embedding.length, limit: options.limit, offset: options.offset, hasFilters: !!options.filters, }); } // PrismaClient取得を試みる let prisma: IPrismaClient; try { prisma = this.getPrismaClient(); } catch { // PrismaClientが利用できない場合はnullを返す if (isDevelopment()) { logger.warn("[LayoutSearchService] PrismaClient not available, returning null"); } return null; } try { const { clause: filterClause, params: filterParams } = buildWhereClause(options.filters); const vectorString = `[${embedding.join(",")}]`; // パラメータインデックスを計算 const vectorParamIndex = filterParams.length + 1; const limitParamIndex = filterParams.length + 2; const offsetParamIndex = filterParams.length + 3; // WHERE句を構築 let whereClause = ""; if (filterClause) { whereClause = `WHERE ${filterClause} AND se.text_embedding IS NOT NULL`; } else { whereClause = "WHERE se.text_embedding IS NOT NULL"; } // ベクトル検索クエリ const query = ` SELECT sp.id, sp.web_page_id, sp.section_type, sp.section_name, sp.layout_info, sp.visual_features, sp.html_snippet, 1 - (se.text_embedding <=> $${vectorParamIndex}::vector) as similarity, wp.id as wp_id, wp.url as wp_url, wp.title as wp_title, wp.source_type as wp_source_type, wp.usage_scope as wp_usage_scope, wp.screenshot_desktop_url as wp_screenshot_desktop_url FROM section_patterns sp LEFT JOIN section_embeddings se ON se.section_pattern_id = sp.id INNER JOIN web_pages wp ON wp.id = sp.web_page_id ${whereClause} ORDER BY similarity DESC LIMIT $${limitParamIndex} OFFSET $${offsetParamIndex} `; // カウントクエリ const countQuery = ` SELECT COUNT(*) as total FROM section_patterns sp LEFT JOIN section_embeddings se ON se.section_pattern_id = sp.id INNER JOIN web_pages wp ON wp.id = sp.web_page_id ${whereClause} `; let searchResults: VectorSearchResult[] = []; let total = 0; try { // 検索実行 searchResults = await prisma.$queryRawUnsafe<VectorSearchResult[]>( query, ...filterParams, vectorString, options.limit, options.offset ); // カウント取得 const countResult = await prisma.$queryRawUnsafe<Array<{ total: bigint | number }>>( countQuery, ...filterParams, vectorString ); total = Number(countResult[0]?.total || 0); } catch (dbError) { if (isDevelopment()) { logger.warn("[LayoutSearchService] Vector search failed, returning empty results", { error: dbError instanceof Error ? dbError.message : "Unknown error", }); } // データベースエラー時は空の結果を返す return { results: [], total: 0, }; } // 結果をマップ let results: SearchResult[] = searchResults.map(vectorResultToSearchResult); // visualFeaturesフィルターを適用(アプリケーション層でのフィルタリング) if (options.filters?.visualFeatures) { const visualFeaturesFilter = options.filters.visualFeatures; results = results.filter((r) => matchesVisualFeaturesFilter(r.visualFeatures, visualFeaturesFilter) ); if (isDevelopment()) { logger.debug("[LayoutSearchService] Applied visualFeatures filter", { originalCount: searchResults.length, filteredCount: results.length, filter: visualFeaturesFilter, }); } } const processingTimeMs = Date.now() - startTime; if (isDevelopment()) { logger.info("[LayoutSearchService] Search completed", { resultsCount: results.length, total, processingTimeMs, }); } return { results, total, }; } catch (error) { if (isDevelopment()) { logger.error("[LayoutSearchService] Search error", { error: error instanceof Error ? error.message : "Unknown error", }); } throw error; } } /** * ハイブリッド検索: ベクトル検索 + 全文検索をRRFで統合 * * 両検索を並列実行し、Reciprocal Rank Fusion (60% vector + 40% fulltext) で * 結果をマージする。全文検索が失敗した場合はベクトル検索のみで結果を返す。 * * @param queryText - 生のクエリテキスト(全文検索用) * @param embedding - クエリEmbedding(ベクトル検索用) * @param options - 検索オプション * @returns マージされた検索結果 */ async searchSectionPatternsHybrid( queryText: string, embedding: number[], options: SearchOptions ): Promise<SearchServiceResult | null> { const startTime = Date.now(); if (isDevelopment()) { logger.info("[LayoutSearchService] Starting hybrid search (vector + fulltext)", { queryTextLength: queryText.length, embeddingDimensions: embedding.length, limit: options.limit, offset: options.offset, }); } let prisma: IPrismaClient; try { prisma = this.getPrismaClient(); } catch { if (isDevelopment()) { logger.warn("[LayoutSearchService] PrismaClient not available, returning null"); } return null; } try { const { clause: filterClause, params: filterParams } = buildWhereClause(options.filters); // RRFマージ用に多めに取得(最終的にlimit/offsetで切り出す) const fetchLimit = Math.min(options.limit * 3, 150); // ベクトル検索関数 const vectorSearchFn = async (): Promise<RankedItem[]> => { const vectorString = `[${embedding.join(",")}]`; const vectorParamIndex = filterParams.length + 1; const limitParamIndex = filterParams.length + 2; let whereClause = ""; if (filterClause) { whereClause = `WHERE ${filterClause} AND se.text_embedding IS NOT NULL`; } else { whereClause = "WHERE se.text_embedding IS NOT NULL"; } const query = ` SELECT sp.id, sp.web_page_id, sp.section_type, sp.section_name, sp.layout_info, sp.visual_features, sp.html_snippet, 1 - (se.text_embedding <=> $${vectorParamIndex}::vector) as similarity, wp.id as wp_id, wp.url as wp_url, wp.title as wp_title, wp.source_type as wp_source_type, wp.usage_scope as wp_usage_scope, wp.screenshot_desktop_url as wp_screenshot_desktop_url FROM section_patterns sp LEFT JOIN section_embeddings se ON se.section_pattern_id = sp.id INNER JOIN web_pages wp ON wp.id = sp.web_page_id ${whereClause} ORDER BY similarity DESC LIMIT $${limitParamIndex} `; const results = await prisma.$queryRawUnsafe<VectorSearchResult[]>( query, ...filterParams, vectorString, fetchLimit ); return toRankedItems(results); }; // 全文検索関数 const fulltextSearchFn = async (): Promise<RankedItem[]> => { const queryParamIndex = filterParams.length + 1; const limitParamIndex = filterParams.length + 2; const ftConditions = buildFulltextConditions("se.search_vector", queryParamIndex); const ftRank = buildFulltextRankExpression("se.search_vector", queryParamIndex); const whereClause = filterClause ? `WHERE ${filterClause} AND ${ftConditions}` : `WHERE ${ftConditions}`; const query = ` SELECT sp.id, sp.web_page_id, sp.section_type, sp.section_name, sp.layout_info, sp.visual_features, sp.html_snippet, ${ftRank} as similarity, wp.id as wp_id, wp.url as wp_url, wp.title as wp_title, wp.source_type as wp_source_type, wp.usage_scope as wp_usage_scope, wp.screenshot_desktop_url as wp_screenshot_desktop_url FROM section_patterns sp LEFT JOIN section_embeddings se ON se.section_pattern_id = sp.id INNER JOIN web_pages wp ON wp.id = sp.web_page_id ${whereClause} ORDER BY similarity DESC LIMIT $${limitParamIndex} `; try { const results = await prisma.$queryRawUnsafe<VectorSearchResult[]>( query, ...filterParams, queryText, fetchLimit ); return toRankedItems(results); } catch (ftError) { // 全文検索の失敗はハイブリッド検索全体をブロックしない if (isDevelopment()) { logger.warn("[LayoutSearchService] Full-text search failed, using vector only", { error: ftError instanceof Error ? ftError.message : "Unknown error", }); } return []; } }; // RRFハイブリッド検索を実行(両検索を並列実行) const hybridResults = await executeHybridSearch(vectorSearchFn, fulltextSearchFn); // offset/limitを適用 const paginatedResults = hybridResults.slice(options.offset, options.offset + options.limit); // HybridSearchResult を SearchResult に変換 // mergeWithRRF が data から id を除去するため、hr.id で復元する // mergeWithRRF strips id from data, so restore it from hr.id const results: SearchResult[] = paginatedResults.map((hr) => { const data = hr.data as unknown as VectorSearchResult; data.id = hr.id; const converted = vectorResultToSearchResult(data); converted.similarity = hr.similarity; return converted; }); // visualFeaturesフィルターを適用 let filteredResults = results; if (options.filters?.visualFeatures) { const visualFeaturesFilter = options.filters.visualFeatures; filteredResults = results.filter((r) => matchesVisualFeaturesFilter(r.visualFeatures, visualFeaturesFilter) ); } const processingTimeMs = Date.now() - startTime; if (isDevelopment()) { logger.info("[LayoutSearchService] Hybrid search completed", { totalMerged: hybridResults.length, resultsCount: filteredResults.length, processingTimeMs, }); } return { results: filteredResults, total: hybridResults.length, }; } catch (error) { if (isDevelopment()) { logger.error("[LayoutSearchService] Hybrid search error, falling back to vector only", { error: error instanceof Error ? error.message : "Unknown error", }); } // フォールバック: ベクトル検索のみ return this.searchSectionPatterns(embedding, options); } } } - apps/mcp-server/src/tools/index.ts:594-648 (registration)`toolHandlers` オブジェクトにより、"layout.search" が `layoutSearchHandler` にマッピングされ、MCPツールとして登録されています。
export const toolHandlers: Record<string, (input: unknown) => Promise<unknown>> = { // style.get_palette(ブランドパレット取得) "style.get_palette": styleGetPaletteHandler, // system.health(MCPサーバーヘルスチェック) "system.health": systemHealthHandler, // layout.inspect(Phase 2-4 Webページレイアウト解析) "layout.inspect": layoutInspectHandler, // layout.ingest(Phase 2-1 Webページインジェスト) "layout.ingest": layoutIngestHandler, // layout.search(Phase 2-5 レイアウトセマンティック検索) "layout.search": layoutSearchHandler, // layout.generate_code(Phase 2-6 レイアウトコード生成) // v0.1.0: layout.to_code から layout.generate_code にリネーム "layout.generate_code": layoutGenerateCodeHandler, // layout.batch_ingest(Phase 2-7 バッチインジェスト) "layout.batch_ingest": layoutBatchIngestHandler, // quality.evaluate(Phase 3-3 品質評価) "quality.evaluate": qualityEvaluateHandler, // quality.batch_evaluate(Phase 3-5 一括品質評価) "quality.batch_evaluate": batchQualityEvaluateHandler, // quality.getJobStatus(Phase 3-5 一括評価ジョブステータス確認) "quality.getJobStatus": qualityGetJobStatusHandler, // motion.detect(Phase 3-6 モーション検出) "motion.detect": motionDetectHandler, // motion.search(Phase 3-6 モーション検索) "motion.search": motionSearchHandler, // brief.validate(Phase 4-3 Design Brief Validation) "brief.validate": briefValidateHandler, // project.get(Studio プロジェクト取得) "project.get": projectGetHandler, // project.list(Studio プロジェクト一覧) "project.list": projectListHandler, // page.analyze(統合Web分析) "page.analyze": pageAnalyzeHandler, // page.getJobStatus(非同期ジョブステータス確認 Phase3-2) "page.getJobStatus": pageGetJobStatusHandler, // narrative.search(世界観・レイアウト構成セマンティック検索) "narrative.search": narrativeSearchHandler, // background.search(BackgroundDesignセマンティック検索) "background.search": backgroundSearchHandler, // responsive.search(レスポンシブ分析セマンティック検索) "responsive.search": responsiveSearchHandler, // preference.hear(ユーザー嗜好ヒアリングセッション) "preference.hear": preferenceHearHandler, // preference.get(プロファイル取得) "preference.get": preferenceGetHandler, // preference.reset(プロファイルリセット) "preference.reset": preferenceResetHandler, // part.search(パーツセマンティック検索) "part.search": partSearchHandler, // part.inspect(パーツ詳細情報取得) "part.inspect": partInspectHandler, // part.compare(パーツ並列比較) "part.compare": partCompareHandler, }; - `applyPreferenceReranking` ヘルパーは、検索結果に対してユーザーの嗜好プロファイルに基づいたリランキングを適用するために、`layout.search` ツール内で呼び出される共通ユーティリティです。
/** * 検索結果にpreference rerankingを適用する統合ヘルパー * Integrated helper to apply preference reranking to search results * * @param results - 検索結果配列 / Search result array * @param profileId - プロファイルID(undefinedの場合はスキップ) / Profile ID (skip if undefined) * @param prismaFactory - PrismaClientファクトリ / PrismaClient factory * @param domain - embeddingドメイン / Embedding domain * @param toolName - ログ用ツール名 / Tool name for logging * @returns リランキング済み結果(または元の結果) / Reranked results (or original) */ export async function applyPreferenceReranking<T extends { id: string; similarity: number }>( results: T[], profileId: string | undefined, prismaFactory: (() => IPrismaClient) | null, domain: EmbeddingDomain, toolName: string ): Promise<T[]> { if (!profileId || results.length === 0 || !prismaFactory) { return results; } try { const prisma = prismaFactory(); const rerankableItems: RerankableItem[] = results.map((item) => ({ ...item, id: item.id, similarity: item.similarity, })); const rerankResult = await rerankWithPreference(rerankableItems, profileId, prisma, { domain }); if (rerankResult.reranked) { logger.info(`[MCP Tool] ${toolName} preference reranking applied`, { profileId: truncateId(profileId), resultCount: results.length, }); return rerankResult.items as T[]; } logger.info(`[MCP Tool] ${toolName} preference reranking skipped`, { profileId: truncateId(profileId), reason: rerankResult.reason, }); return results; } catch (error) { logger.warn(`[MCP Tool] ${toolName} preference reranking failed`, { profileId: truncateId(profileId), error: error instanceof Error ? error.message : String(error), }); return results; } }