147 lines
4.5 KiB
Dart
147 lines
4.5 KiB
Dart
/// 文字列類似度ユーティリティ
|
||
///
|
||
/// さけのわ銘柄マッチングなどで使用
|
||
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);
|
||
}
|
||
}
|