/// 文字列類似度ユーティリティ /// /// さけのわ銘柄マッチングなどで使用 class StringSimilarity { /// Levenshtein距離(編集距離)を計算 /// /// 文字列s1をs2に変換するために必要な最小編集回数 /// 編集: 挿入、削除、置換 /// /// 例: /// - levenshteinDistance("獺祭", "だっさい") = 3 /// - levenshteinDistance("獺祭", "獺祭") = 0 static int levenshteinDistance(String s1, String s2) { if (s1 == s2) return 0; if (s1.isEmpty) return s2.length; if (s2.isEmpty) return s1.length; final len1 = s1.length; final len2 = s2.length; // DP table: (len1+1) x (len2+1) final matrix = List.generate( len1 + 1, (i) => List.filled(len2 + 1, 0), ); // 初期化 for (int i = 0; i <= len1; i++) { matrix[i][0] = i; } for (int j = 0; j <= len2; j++) { matrix[0][j] = j; } // DP処理 for (int i = 1; i <= len1; i++) { for (int j = 1; j <= len2; j++) { final cost = s1[i - 1] == s2[j - 1] ? 0 : 1; matrix[i][j] = _min3( matrix[i - 1][j] + 1, // 削除 matrix[i][j - 1] + 1, // 挿入 matrix[i - 1][j - 1] + cost, // 置換 ); } } return matrix[len1][len2]; } /// 類似度スコアを計算(0.0-1.0) /// /// 1.0に近いほど類似 /// 0.0は全く異なる /// /// 計算式: 1.0 - (編集距離 / 長い方の文字列長) static double similarity(String s1, String s2) { if (s1 == s2) return 1.0; if (s1.isEmpty || s2.isEmpty) return 0.0; final distance = levenshteinDistance(s1, s2); final maxLength = s1.length > s2.length ? s1.length : s2.length; return 1.0 - (distance / maxLength); } /// 正規化された類似度(全角半角・大文字小文字を無視) /// /// 日本酒銘柄のマッチングに最適 /// /// 例: /// - normalizedSimilarity("獺祭", "だっさい") ≈ 0.0 (漢字とひらがなは別物) /// - normalizedSimilarity("DASSAI", "dassai") = 1.0 (大文字小文字無視) /// - normalizedSimilarity("獺祭 純米大吟醸", "獺祭 純米大吟醸") ≈ 1.0 (スペース正規化) static double normalizedSimilarity(String s1, String s2) { // 正規化処理 final normalized1 = _normalize(s1); final normalized2 = _normalize(s2); return similarity(normalized1, normalized2); } /// 文字列の正規化 /// /// - 小文字化 /// - 全角スペース→半角スペース /// - 連続スペース→単一スペース /// - 前後のスペース削除 static String _normalize(String str) { return str .toLowerCase() .replaceAll(' ', ' ') // 全角スペース→半角 .replaceAll(RegExp(r'\s+'), ' ') // 連続スペース→単一 .trim(); } /// 部分一致スコア(s2がs1に含まれているか) /// /// 例: /// - containsSimilarity("獺祭 純米大吟醸45", "獺祭") = 1.0 /// - containsSimilarity("八海山", "獺祭") = 0.0 static double containsSimilarity(String haystack, String needle) { final normalizedHaystack = _normalize(haystack); final normalizedNeedle = _normalize(needle); if (normalizedNeedle.isEmpty) return 0.0; if (normalizedHaystack.contains(normalizedNeedle)) return 1.0; // 部分一致しない場合は通常の類似度 return normalizedSimilarity(haystack, needle); } /// 3つの値の最小値を返す static int _min3(int a, int b, int c) { return a < b ? (a < c ? a : c) : (b < c ? b : c); } /// さけのわ銘柄マッチング専用スコア /// /// 日本酒銘柄の特性に最適化: /// 1. 完全一致: 1.0 /// 2. 正規化後一致: 0.95 /// 3. 部分一致(銘柄名を含む): 0.9 /// 4. 高類似度(0.8以上): そのまま /// 5. それ以外: 類似度スコア static double sakenowaMatchScore(String geminiName, String sakenowaName) { // 完全一致 if (geminiName == sakenowaName) return 1.0; // 正規化 final normalizedGemini = _normalize(geminiName); final normalizedSakenowa = _normalize(sakenowaName); // 正規化後一致 if (normalizedGemini == normalizedSakenowa) return 0.95; // 部分一致チェック(どちらかが含まれている) if (normalizedGemini.contains(normalizedSakenowa) || normalizedSakenowa.contains(normalizedGemini)) { return 0.9; } // 類似度計算 return normalizedSimilarity(geminiName, sakenowaName); } }