ponshu-room-lite/lib/utils/string_similarity.dart

147 lines
4.5 KiB
Dart
Raw Normal View History

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