motion.search
Search for similar motion patterns or generate CSS/JS implementation code for web animations and transitions.
Instructions
モーションパターンを類似検索、または実装コードを生成します。action: search(デフォルト)で検索、action: generateでCSS/JS実装コードを生成します。
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| action | No | アクション: search(デフォルト)= モーション検索、generate = 実装コード生成 | search |
| query | No | 検索クエリ(自然言語、1-500文字)。action: searchで使用。 | |
| samplePattern | No | サンプルパターンで類似検索。action: searchで使用。 | |
| filters | No | 検索フィルター。action: searchで使用。 | |
| limit | No | 結果制限(1-50、デフォルト: 10)。action: searchで使用。 | |
| minSimilarity | No | 最小類似度しきい値(0-1、デフォルト: 0.5)。action: searchで使用。 | |
| include_js_animations | No | JSアニメーションパターンを検索結果に含める(デフォルト: true)。action: searchで使用。 | |
| js_animation_filters | No | JSアニメーション検索フィルター。action: searchで使用。 | |
| include_webgl_animations | No | WebGLアニメーションパターンを検索結果に含める(デフォルト: true)。action: searchで使用。 | |
| webgl_animation_filters | No | WebGLアニメーション検索フィルター。action: searchで使用。 | |
| include_implementation | No | 検索結果に実装コード(@keyframes, animation, tailwindクラス)を含める(デフォルト: false)。action: searchで使用。 | |
| pattern | No | モーションパターン定義。action: generateで必須。 | |
| format | No | 出力フォーマット(デフォルト: css)。action: generateで使用。 | css |
| options | No | 生成オプション。action: generateで使用。 | |
| profile_id | No | 嗜好プロファイルID(検索結果のリランキングに使用) / Preference profile ID (used for search result reranking) |
Implementation Reference
- motion.search ツールハンドラーの定義。検索 (action: 'search') とコード生成 (action: 'generate') の両方の機能を包含しています。
export async function motionSearchHandler(input: unknown): Promise<MotionSearchOutput> { if (isDevelopment()) { logger.info("[MCP Tool] motion.search called", { hasInput: input !== null && input !== undefined, }); } // 入力バリデーション let validated: MotionSearchInput; try { validated = motionSearchInputSchema.parse(input); } catch (error) { if (isDevelopment()) { logger.error("[MCP Tool] motion.search validation error", { error }); } return { success: false, error: { code: MOTION_SEARCH_ERROR_CODES.VALIDATION_ERROR, message: error instanceof Error ? error.message : "Invalid input", }, }; } // Phase3-3: action分岐 const action = validated.action ?? "search"; if (action === "generate") { // コード生成処理 return handleGenerateAction(validated); } // 検索処理(既存ロジック) return handleSearchAction(validated); } /** * action: 'search' の処理 */ async function handleSearchAction(validated: MotionSearchInput): Promise<MotionSearchOutput> { // サービスファクトリのチェック if (!serviceFactory) { if (isDevelopment()) { logger.error("[MCP Tool] motion.search service factory not set"); } return { success: false, error: { code: MOTION_SEARCH_ERROR_CODES.SERVICE_UNAVAILABLE, message: "Motion search service is not available", }, }; } try { const service = serviceFactory(); // 検索パラメータを構築(v0.1.0: JSアニメーション検索パラメータ追加、v0.1.0: WebGLアニメーション検索パラメータ追加、v0.1.0: include_implementation追加) const searchParams: MotionSearchParams = { query: validated.query, samplePattern: validated.samplePattern, filters: validated.filters, limit: validated.limit, minSimilarity: validated.minSimilarity, include_js_animations: validated.include_js_animations, js_animation_filters: validated.js_animation_filters, include_webgl_animations: validated.include_webgl_animations, webgl_animation_filters: validated.webgl_animation_filters, include_implementation: validated.include_implementation, }; if (isDevelopment()) { logger.info("[MCP Tool] motion.search executing search", { hasQuery: !!searchParams.query, hasSamplePattern: !!searchParams.samplePattern, hasFilters: !!searchParams.filters, limit: searchParams.limit, minSimilarity: searchParams.minSimilarity, includeJsAnimations: searchParams.include_js_animations, hasJsAnimationFilters: !!searchParams.js_animation_filters, includeWebglAnimations: searchParams.include_webgl_animations, hasWebglAnimationFilters: !!searchParams.webgl_animation_filters, includeImplementation: searchParams.include_implementation, diversityThreshold: validated.diversity_threshold ?? 0.3, ensureCategoryDiversity: validated.ensure_category_diversity ?? true, }); } // 検索実行(ハイブリッド検索優先) const searchResult = service.searchHybrid ? await service.searchHybrid(searchParams) : await service.search(searchParams); // v0.1.0: 多様性フィルタリングを適用 const diversityThreshold = validated.diversity_threshold ?? 0.3; const ensureCategoryDiversity = validated.ensure_category_diversity ?? true; const diverseResults = applyDiversityFilter( searchResult.results, diversityThreshold, ensureCategoryDiversity, validated.limit ); // v0.1.0: include_implementation が true の場合、実装コードを付与 let results = validated.include_implementation ? enrichResultsWithImplementation(diverseResults) : diverseResults; // 嗜好プロファイルによるリランキング / Preference profile reranking // motion結果はpattern.idにIDがあるため、top-levelにidをマッピング // Motion results have ID in pattern.id, so map id to top-level const resultsWithId = results.map((r) => ({ ...r, id: r.pattern.id })); results = (await applyPreferenceReranking( resultsWithId, validated.profile_id, prismaClientFactory, "motion", "motion.search" )) as typeof results; if (isDevelopment()) { logger.info("[MCP Tool] motion.search completed", { resultsCount: results.length, originalCount: searchResult.results.length, total: searchResult.total, includeImplementation: validated.include_implementation, diversityThreshold, ensureCategoryDiversity, }); } return { success: true, data: { results, total: searchResult.total, query: searchResult.query, }, }; } catch (error) { if (isDevelopment()) { logger.error("[MCP Tool] motion.search error", { error }); } // エラータイプに基づいてエラーコードを決定 const errorCode = error instanceof Error && error.message.includes("Embedding") ? MOTION_SEARCH_ERROR_CODES.EMBEDDING_ERROR : MOTION_SEARCH_ERROR_CODES.SEARCH_ERROR; return { success: false, error: { code: errorCode, message: error instanceof Error ? error.message : "Search failed", }, }; } } - apps/mcp-server/src/tools/motion/search.tool.ts:2001-2018 (registration)motion.search ツールの定義(登録用オブジェクト)。入力スキーマや説明が含まれています。
return { success: false, error: { code: errorCode, message: error instanceof Error ? error.message : "Search failed", }, }; } } /** * action: 'generate' の処理(Phase3-3統合) * v0.1.0: 重複検出機能追加 */ async function handleGenerateAction(validated: MotionSearchInput): Promise<MotionSearchOutput> { if (isDevelopment()) { logger.info("[MCP Tool] motion.search action: generate", { - motion.search ツールの入力パラメータのインターフェース。query, filters, limit などの検索関連パラメータや、action, pattern などの生成関連パラメータを定義。
/** * 検索パラメータ */ export interface MotionSearchParams { query?: string | undefined; samplePattern?: SamplePattern | undefined; filters?: MotionSearchFilters | undefined; limit: number; minSimilarity: number; /** JSアニメーションを検索に含めるか(v0.1.0) */ include_js_animations?: boolean; /** JSアニメーション検索フィルター(v0.1.0) */ js_animation_filters?: JSAnimationFilters | undefined; /** WebGLアニメーションを検索に含めるか(v0.1.0) */ include_webgl_animations?: boolean; /** WebGLアニメーション検索フィルター(v0.1.0) */ webgl_animation_filters?: WebGLAnimationFilters | undefined; /** 検索結果に実装コードを含めるか(v0.1.0) */ include_implementation?: boolean; /** 結果の多様性しきい値(v0.1.0、0.0-1.0、デフォルト: 0.3) */ diversity_threshold?: number; /** カテゴリ分散を強制するか(v0.1.0、デフォルト: true) */ ensure_category_diversity?: boolean; } /** * JSアニメーション検索結果アイテム * MotionSearchResultItemにJSアニメーション情報を追加 */ export interface JSAnimationSearchResultItem extends Omit<MotionSearchResultItem, "pattern"> { /** パターン情報 */ pattern: MotionSearchResultItem["pattern"]; /** JSアニメーション固有情報 */ jsAnimationInfo?: JSAnimationInfo; } /** * WebGLアニメーション検索結果アイテム * MotionSearchResultItemにWebGLアニメーション情報を追加 * v0.1.0 */ export interface WebGLAnimationSearchResultItem extends Omit<MotionSearchResultItem, "pattern"> { /** パターン情報 */ pattern: MotionSearchResultItem["pattern"]; /** WebGLアニメーション固有情報 */ webglAnimationInfo?: WebGLAnimationInfo; } /** * 検索結果 */ export interface MotionSearchResult { results: MotionSearchResultItem[]; total: number; query?: MotionSearchQueryInfo; } // ===================================================== // サービスインターフェース(DI用) // ===================================================== /** * モーション検索サービスインターフェース */ export interface IMotionSearchService { /** * モーションパターンを検索 */ search: (params: MotionSearchParams) => Promise<MotionSearchResult>; /** * ハイブリッド検索(ベクトル + 全文検索、RRFマージ) * 利用可能な場合に search() の代わりに使用される */ searchHybrid?: (params: MotionSearchParams) => Promise<MotionSearchResult>; /** * テキストからEmbeddingを取得(オプショナル) */ getEmbedding?: (text: string) => Promise<number[]>; } /** * コード生成結果インターフェース */ export interface GenerationResult { code: string; metadata: ImplementationMetadata; } /** * コード生成サービスインターフェース(Phase3-3統合用) */ export interface IMotionImplementationService { generate: ( pattern: MotionPatternInput, format: ImplementationFormat, options: ImplementationOptions ) => GenerationResult | null; } /** * サービスファクトリ型 */ type MotionSearchServiceFactory = () => IMotionSearchService; type MotionImplementationServiceFactory = () => IMotionImplementationService | null; let serviceFactory: MotionSearchServiceFactory | null = null; let implementationServiceFactory: MotionImplementationServiceFactory | null = null; /** * サービスファクトリを設定 */ export function setMotionSearchServiceFactory(factory: MotionSearchServiceFactory): void { serviceFactory = factory; } /** * サービスファクトリをリセット */ export function resetMotionSearchServiceFactory(): void { serviceFactory = null; } /** * コード生成サービスファクトリを設定(Phase3-3統合用) */ export function setMotionImplementationServiceFactory( factory: MotionImplementationServiceFactory ): void { implementationServiceFactory = factory; } /** * コード生成サービスファクトリをリセット */ export function resetMotionImplementationServiceFactory(): void { implementationServiceFactory = null; } /** * PrismaClientファクトリー(嗜好リランキング用DI) * PrismaClient factory (DI for preference reranking) */ let prismaClientFactory: (() => IPrismaClient) | null = null; /** * PrismaClientファクトリーを設定(嗜好リランキング用) * Set PrismaClient factory (for preference reranking) */ export function setMotionSearchPrismaClientFactory(factory: () => IPrismaClient): void { prismaClientFactory = factory; } - 検索エンジンのコア実装クラス。ベクトル検索、ハイブリッド検索、JSアニメーション検索との統合ロジックを担当。
export class MotionSearchService implements IMotionSearchService { private embeddingService: IEmbeddingService | null = null; private prismaClient: IPrismaClient | null = null; private jsAnimationSearchService: JSAnimationSearchService | 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"); } /** * JSAnimationSearchServiceを取得(v0.1.0) * @returns JSAnimationSearchService or null if not available */ private getJSAnimationSearchService(): JSAnimationSearchService | null { if (this.jsAnimationSearchService) { return this.jsAnimationSearchService; } if (jsAnimationSearchServiceFactory) { this.jsAnimationSearchService = jsAnimationSearchServiceFactory(); return this.jsAnimationSearchService; } return null; // Graceful degradation - JSAnimation search is optional } /** * CSSモーションのベクトル検索SQL・パラメータを構築 * * search() と searchHybrid() の共通ロジックを一元化。 * CCN削減のため抽出(TDA-Medium-2対応)。 */ private buildCSSVectorSearchQuery( clause: string, whereParams: unknown[], vectorString: string, similarityThreshold: number, limit: number ): { sql: string; params: unknown[] } { const vectorParamIndex = whereParams.length + 1; const similarityParamIndex = whereParams.length + 2; const limitParamIndex = whereParams.length + 3; const whereClauseWithSimilarity = clause ? `${clause} AND 1 - (me.embedding <=> $${vectorParamIndex}::vector) >= $${similarityParamIndex}` : `WHERE 1 - (me.embedding <=> $${vectorParamIndex}::vector) >= $${similarityParamIndex}`; const sql = ` SELECT mp.id, mp.name, mp.category, mp.trigger_type, mp.animation, mp.properties, mp.source_url, mp.web_page_id, 1 - (me.embedding <=> $${vectorParamIndex}::vector) as similarity FROM motion_patterns mp LEFT JOIN motion_embeddings me ON me.motion_pattern_id = mp.id ${whereClauseWithSimilarity} ORDER BY similarity DESC LIMIT $${limitParamIndex} `; return { sql, params: [...whereParams, vectorString, similarityThreshold, limit], }; } /** * CSSモーションの全文検索SQL・パラメータを構築 * * searchHybrid() の全文検索ロジックを抽出(TDA-Medium-2対応)。 */ private buildCSSFulltextSearchQuery( clause: string, whereParams: unknown[], queryText: string, limit: number ): { sql: string; params: unknown[] } { const ftParamIndex = whereParams.length + 1; const ftLimitParamIndex = whereParams.length + 2; const ftConditions = buildFulltextConditions("me.search_vector", ftParamIndex); const ftRank = buildFulltextRankExpression("me.search_vector", ftParamIndex); const ftWhereBase = clause ? `${clause} AND ${ftConditions}` : `WHERE ${ftConditions}`; const sql = ` SELECT mp.id, mp.name, mp.category, mp.trigger_type, mp.animation, mp.properties, mp.source_url, mp.web_page_id, ${ftRank} as similarity FROM motion_patterns mp LEFT JOIN motion_embeddings me ON me.motion_pattern_id = mp.id ${ftWhereBase} ORDER BY similarity DESC LIMIT $${ftLimitParamIndex} `; return { sql, params: [...whereParams, queryText, limit], }; } /** * モーションパターンを検索 */ async search(params: MotionSearchParams): Promise<MotionSearchResult> { const startTime = Date.now(); // v0.1.0: include_js_animations はデフォルトtrue const includeJsAnimations = params.include_js_animations !== false; if (isDevelopment()) { logger.info("[MotionSearchService] Starting search", { hasQuery: !!params.query, hasSamplePattern: !!params.samplePattern, hasFilters: !!params.filters, limit: params.limit, minSimilarity: params.minSimilarity, includeJsAnimations, hasJsAnimationFilters: !!params.js_animation_filters, }); } try { // クエリテキストを準備 // NOTE: generateEmbedding() が内部で E5 prefix ("query: " / "passage: ") を // 自動付与するため、ここではプレフィックスなしのテキストを渡す。 let queryText: string; if (params.query) { queryText = params.query; } else if (params.samplePattern) { queryText = samplePatternToText(params.samplePattern); } else { throw new Error("query or samplePattern is required"); } // Embedding生成を試みる let queryEmbedding: number[] | null = null; try { const embeddingService = this.getEmbeddingService(); queryEmbedding = await embeddingService.generateEmbedding(queryText, "query"); // Embedding ベクトルの検証(Phase6-SEC-2対応) // 検索はEmbeddingなしでは不可能なため、検証失敗時はエラーをスロー const validationResult = validateEmbeddingVector(queryEmbedding); if (!validationResult.isValid) { const error = validationResult.error; const errorMessage = error?.index !== undefined ? `${error.message} at index ${error.index}` : (error?.message ?? "Unknown validation error"); throw new EmbeddingValidationError( error?.code ?? "INVALID_VECTOR", errorMessage, error?.index ); } } catch (embeddingError) { // EmbeddingValidationError は再スロー(検索不可能) if (embeddingError instanceof EmbeddingValidationError) { throw embeddingError; } if (isDevelopment()) { logger.warn( "[MotionSearchService] Embedding generation failed, falling back to text search", { error: embeddingError instanceof Error ? embeddingError.message : "Unknown error", } ); } // Embedding生成に失敗した場合は空の結果を返す // (テキスト検索フォールバックは将来実装) } // PrismaClient取得を試みる let prisma: IPrismaClient; try { prisma = this.getPrismaClient(); } catch { // PrismaClientが利用できない場合は空の結果を返す if (isDevelopment()) { logger.warn("[MotionSearchService] PrismaClient not available, returning empty results"); } return { results: [], total: 0, query: { text: params.query || samplePatternToText(params.samplePattern!), }, }; } // ベクトル検索を実行 let results: MotionSearchResultItem[] = []; if (queryEmbedding) { const { clause, params: whereParams } = buildWhereClause(params.filters); const vectorString = `[${queryEmbedding.join(",")}]`; const { sql: query, params: queryParams } = this.buildCSSVectorSearchQuery( clause, whereParams, vectorString, params.minSimilarity, params.limit ); try { const searchResults = await prisma.$queryRawUnsafe<VectorSearchResult[]>( query, ...queryParams ); results = searchResults.map((r) => ({ pattern: recordToMotionPattern(r), similarity: r.similarity, source: r.web_page_id ? { pageId: r.web_page_id, url: r.source_url || undefined } : undefined, })); } catch (dbError) { if (isDevelopment()) { logger.warn("[MotionSearchService] Vector search failed, returning empty results", { error: dbError instanceof Error ? dbError.message : "Unknown error", }); } // データベースエラー時は空の結果を返す } } // v0.1.0: JSAnimation検索を実行 let jsAnimationResults: JSAnimationSearchResultItem[] = []; if (includeJsAnimations && queryEmbedding) { const jsSearchService = this.getJSAnimationSearchService(); if (jsSearchService) { try { // フィルターパラメータを構築 const jsSearchParams: JSAnimationSearchParams = { queryEmbedding, minSimilarity: params.minSimilarity, limit: params.limit, libraryType: params.js_animation_filters?.libraryType, animationType: params.js_animation_filters?.animationType, }; const jsSearchResult = await jsSearchService.search(jsSearchParams); jsAnimationResults = jsSearchResult.results; if (isDevelopment()) { logger.info("[MotionSearchService] JSAnimation search completed", { resultsCount: jsSearchResult.results.length, total: jsSearchResult.total, }); } } catch (jsError) { if (isDevelopment()) { logger.warn( "[MotionSearchService] JSAnimation search failed, continuing without JS results", { error: jsError instanceof Error ? jsError.message : "Unknown error", } ); } // JSAnimation検索失敗時は空の結果で継続(Graceful Degradation) } } else if (isDevelopment()) { logger.debug( "[MotionSearchService] JSAnimationSearchService not available, skipping JS search" ); } } // v0.1.0: 結果をマージして類似度順でソート const mergedResults = this.mergeAndSortResults(results, jsAnimationResults, params.limit); const processingTimeMs = Date.now() - startTime; if (isDevelopment()) { logger.info("[MotionSearchService] Search completed", { cssResultsCount: results.length, jsResultsCount: jsAnimationResults.length, mergedResultsCount: mergedResults.length, processingTimeMs, }); } const queryInfo: MotionSearchQueryInfo = { text: params.query || samplePatternToText(params.samplePattern!), }; return { results: mergedResults, total: mergedResults.length, query: queryInfo, }; } catch (error) { if (isDevelopment()) { logger.error("[MotionSearchService] Search error", { error: error instanceof Error ? error.message : "Unknown error", }); } throw error; } } /** * テキストからEmbeddingを取得 */ async getEmbedding(text: string): Promise<number[]> { const embeddingService = this.getEmbeddingService(); // NOTE: generateEmbedding() が内部で E5 prefix を自動付与するため、 // プレフィックスなしのテキストを渡す。 const embedding = await embeddingService.generateEmbedding(text, "query"); // Embedding ベクトルの検証(Phase6-SEC-2対応) const validationResult = validateEmbeddingVector(embedding); if (!validationResult.isValid) { const error = validationResult.error; const errorMessage = error?.index !== undefined ? `${error.message} at index ${error.index}` : (error?.message ?? "Unknown validation error"); throw new EmbeddingValidationError( error?.code ?? "INVALID_VECTOR", errorMessage, error?.index ); } return embedding; } /** * CSSモーションパターンとJSアニメーション結果をマージしてソート(v0.1.0) * * @param cssResults CSSモーションパターン検索結果 * @param jsResults JSアニメーション検索結果 * @param limit 結果制限数 * @returns マージ・ソート済みの検索結果 */ private mergeAndSortResults( cssResults: MotionSearchResultItem[], jsResults: JSAnimationSearchResultItem[], limit: number ): MotionSearchResultItem[] { // JSアニメーション結果をMotionSearchResultItem形式に変換 const convertedJsResults: MotionSearchResultItem[] = jsResults.map((jsItem) => ({ pattern: this.jsAnimationToMotionPattern(jsItem), similarity: jsItem.similarity, source: jsItem.webPageId ? { pageId: jsItem.webPageId } : undefined, jsAnimationInfo: { libraryType: jsItem.libraryType, animationType: jsItem.animationType ?? undefined, libraryVersion: jsItem.libraryVersion ?? undefined, }, })); // 両方の結果をマージ const merged = [...cssResults, ...convertedJsResults]; // 類似度で降順ソート merged.sort((a, b) => b.similarity - a.similarity); // limit制限を適用 return merged.slice(0, limit); } /** * JSAnimationSearchResultItemをMotionPatternに変換(v0.1.0) */ private jsAnimationToMotionPattern(jsItem: JSAnimationSearchResultItem): MotionPattern { // JSアニメーションタイプをCSSタイプにマッピング const typeMapping: Record<string, MotionPattern["type"]> = { tween: "css_animation", timeline: "css_animation", spring: "css_transition", physics: "css_animation", keyframe: "keyframes", morphing: "css_animation", path: "css_animation", scroll_driven: "css_animation", gesture: "css_transition", }; // ライブラリタイプをカテゴリにマッピング const categoryMapping: Record<string, MotionPattern["category"]> = { gsap: "micro_interaction", framer_motion: "page_transition", anime_js: "micro_interaction", three_js: "scroll_trigger", lottie: "loading_state", web_animations_api: "micro_interaction", unknown: "unknown", }; const animationType = jsItem.animationType ?? "tween"; const durationMs = jsItem.durationMs ?? undefined; const easing = jsItem.easing ?? undefined; // keyframesからpropertiesを抽出 const properties: Array<{ property: string; from?: string | number; to?: string | number; }> = []; if (jsItem.keyframes && Array.isArray(jsItem.keyframes)) { const keyframes = jsItem.keyframes as Array<Record<string, unknown>>; if (keyframes.length >= 2) { const firstKf = keyframes[0]; const lastKf = keyframes[keyframes.length - 1]; // 最初と最後のキーフレームからプロパティを抽出 const allKeys = new Set([...Object.keys(firstKf ?? {}), ...Object.keys(lastKf ?? {})]); for (const key of allKeys) { if (["offset", "easing", "composite", "computedOffset"].includes(key)) { continue; } const fromValue = firstKf?.[key]; const toValue = lastKf?.[key]; const propEntry: { property: string; from?: string | number; to?: string | number } = { property: key, }; if (typeof fromValue === "string" || typeof fromValue === "number") { propEntry.from = fromValue; } if (typeof toValue === "string" || typeof toValue === "number") { propEntry.to = toValue; } properties.push(propEntry); } } } // propertiesフィールドからも抽出 if (jsItem.properties && Array.isArray(jsItem.properties)) { for (const prop of jsItem.properties as Array<Record<string, unknown>>) { if (typeof prop === "object" && prop !== null && "property" in prop) { const propEntry: { property: string; from?: string | number; to?: string | number } = { property: String(prop.property), }; const fromValue = prop.from; const toValue = prop.to; if (typeof fromValue === "string" || typeof fromValue === "number") { propEntry.from = fromValue; } if (typeof toValue === "string" || typeof toValue === "number") { propEntry.to = toValue; } properties.push(propEntry); } } } // selectorフィールドを設定(v0.1.0) // 優先順位: targetSelector > nameから生成 const selector = jsItem.targetSelector || generateSelectorFromName(jsItem.name); return { id: jsItem.id, type: typeMapping[animationType] || "css_animation", category: categoryMapping[jsItem.libraryType] || "unknown", name: jsItem.name, selector, // JSアニメーションのターゲットセレクタ trigger: "load", // JSアニメーションはデフォルトでload animation: { duration: durationMs, easing: easing ? { type: mapEasingType(easing) } : undefined, }, properties: properties.length > 0 ? properties : [], }; } /** * ハイブリッド検索(ベクトル検索 + 全文検索 → RRF マージ) * * 既存の search() と同じインターフェースで、内部的に全文検索を追加し * Reciprocal Rank Fusion で結果をマージする。 * 全文検索が失敗した場合はベクトル検索のみにフォールバック。 */ async searchHybrid(params: MotionSearchParams): Promise<MotionSearchResult> { const startTime = Date.now(); const includeJsAnimations = params.include_js_animations !== false; if (isDevelopment()) { logger.info("[MotionSearchService] Starting hybrid search", { hasQuery: !!params.query, hasSamplePattern: !!params.samplePattern, }); } try { // クエリテキストを準備 // NOTE: generateEmbedding() が内部で E5 prefix を自動付与するため、 // プレフィックスなしのテキストを渡す。 let queryText: string; if (params.query) { queryText = params.query; } else if (params.samplePattern) { queryText = samplePatternToText(params.samplePattern); } else { throw new Error("query or samplePattern is required"); } // Embedding 生成 let queryEmbedding: number[] | null = null; try { const embeddingService = this.getEmbeddingService(); queryEmbedding = await embeddingService.generateEmbedding(queryText, "query"); const validationResult = validateEmbeddingVector(queryEmbedding); if (!validationResult.isValid) { const error = validationResult.error; const errorMessage = error?.index !== undefined ? `${error.message} at index ${error.index}` : (error?.message ?? "Unknown validation error"); throw new EmbeddingValidationError( error?.code ?? "INVALID_VECTOR", errorMessage, error?.index ); } } catch (embeddingError) { if (embeddingError instanceof EmbeddingValidationError) { throw embeddingError; } if (isDevelopment()) { logger.warn("[MotionSearchService] Embedding generation failed in hybrid search", { error: embeddingError instanceof Error ? embeddingError.message : "Unknown error", }); } } let prisma: IPrismaClient; try { prisma = this.getPrismaClient(); } catch { if (isDevelopment()) { logger.warn("[MotionSearchService] PrismaClient not available"); } return { results: [], total: 0, query: { text: queryText }, }; } // CSS モーション検索: ハイブリッド(ベクトル + 全文) let results: MotionSearchResultItem[] = []; if (queryEmbedding) { const { clause, params: whereParams } = buildWhereClause(params.filters); const vectorString = `[${queryEmbedding.join(",")}]`; const fetchLimit = Math.min(params.limit * 3, 150); // ベクトル検索関数(共通メソッドで SQL 構築) const vectorSearchFn = async (): Promise<RankedItem[]> => { const { sql, params: queryParams } = this.buildCSSVectorSearchQuery( clause, whereParams, vectorString, params.minSimilarity, fetchLimit ); const rows = await prisma.$queryRawUnsafe<VectorSearchResult[]>(sql, ...queryParams); return toRankedItems(rows); }; // 全文検索関数(共通メソッドで SQL 構築) const fulltextSearchFn = async (): Promise<RankedItem[]> => { try { const { sql, params: queryParams } = this.buildCSSFulltextSearchQuery( clause, whereParams, queryText, fetchLimit ); const rows = await prisma.$queryRawUnsafe<VectorSearchResult[]>(sql, ...queryParams); return toRankedItems(rows); } catch (ftError) { if (isDevelopment()) { logger.warn("[MotionSearchService] Full-text search failed, using vector only", { error: ftError instanceof Error ? ftError.message : "Unknown error", }); } return []; } }; try { const hybridResults = await executeHybridSearch(vectorSearchFn, fulltextSearchFn); results = hybridResults.slice(0, params.limit).map((hr) => { const data = hr.data as unknown as VectorSearchResult; data.id = hr.id; // mergeWithRRFがdataからidを除去するため復元 / restore id stripped by mergeWithRRF return { pattern: recordToMotionPattern(data), similarity: hr.similarity, source: data.web_page_id ? { pageId: data.web_page_id, url: data.source_url || undefined } : undefined, }; }); } catch (dbError) { if (isDevelopment()) { logger.warn("[MotionSearchService] Hybrid search failed, returning empty results", { error: dbError instanceof Error ? dbError.message : "Unknown error", }); } } } // JSAnimation 検索: ハイブリッドモードで実行(tsvector search_vector 使用) let jsAnimationResults: JSAnimationSearchResultItem[] = []; if (includeJsAnimations && queryEmbedding) { const jsSearchService = this.getJSAnimationSearchService(); if (jsSearchService) { try { const jsSearchParams: JSAnimationSearchParams = { queryEmbedding, queryText: queryText, // searchHybrid で全文検索に使用 minSimilarity: params.minSimilarity, limit: params.limit, libraryType: params.js_animation_filters?.libraryType, animationType: params.js_animation_filters?.animationType, }; const jsSearchResult = await jsSearchService.searchHybrid(jsSearchParams); jsAnimationResults = jsSearchResult.results; } catch (jsError) { if (isDevelopment()) { logger.warn("[MotionSearchService] JSAnimation hybrid search failed", { error: jsError instanceof Error ? jsError.message : "Unknown error", }); } } } } const mergedResults = this.mergeAndSortResults(results, jsAnimationResults, params.limit); const processingTimeMs = Date.now() - startTime; if (isDevelopment()) { logger.info("[MotionSearchService] Hybrid search completed", { cssResultsCount: results.length, jsResultsCount: jsAnimationResults.length, mergedResultsCount: mergedResults.length, processingTimeMs, }); } return { results: mergedResults, total: mergedResults.length, query: { text: queryText }, }; } catch (error) { if (isDevelopment()) { logger.error("[MotionSearchService] Hybrid search error, falling back to standard search", { error: error instanceof Error ? error.message : "Unknown error", }); } // フォールバック: 標準検索 return this.search(params); } } } // ===================================================== // シングルトンインスタンス // ===================================================== let motionSearchServiceInstance: MotionSearchService | null = null; /** * MotionSearchServiceインスタンスを取得 */ export function getMotionSearchService(): MotionSearchService { if (!motionSearchServiceInstance) { motionSearchServiceInstance = new MotionSearchService(); } return motionSearchServiceInstance; } /** * MotionSearchServiceインスタンスをリセット */ export function resetMotionSearchService(): void { motionSearchServiceInstance = null; } /** * MotionSearchServiceファクトリを作成 */ export function createMotionSearchServiceFactory(): () => IMotionSearchService { return () => getMotionSearchService(); } export default MotionSearchService;