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

147 lines
4.5 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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