493 lines
15 KiB
Rust
493 lines
15 KiB
Rust
//! 人名匹配与合并
|
||
//! 对应 Python 的 match_person_from_title.py + merge_persons_by_core.py
|
||
//! 所有函数均为 async,因为底层 Database 操作是异步的
|
||
|
||
use crate::db::Database;
|
||
use crate::engine::title_parser::{extract_cn, is_excluded_name};
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
/// 匹配模式
|
||
#[derive(Debug, Clone)]
|
||
pub struct Pattern {
|
||
pub text: String,
|
||
pub person_name: String,
|
||
pub source: String, // "name" | "alias"
|
||
pub length: usize,
|
||
}
|
||
|
||
/// 匹配结果
|
||
#[derive(Debug, Clone)]
|
||
pub struct MatchResult {
|
||
pub matched: bool,
|
||
pub method: String,
|
||
pub person: Option<String>,
|
||
pub matched_text: Option<String>,
|
||
pub all_persons: Vec<String>,
|
||
pub multi_person: bool,
|
||
}
|
||
|
||
/// 深度匹配结果
|
||
#[derive(Debug, Clone)]
|
||
pub struct DeepMatchResult {
|
||
pub matched: bool,
|
||
pub method: String, // "jieba_extract" | "exact_name_match" | "alias_fuzzy_match"
|
||
pub person: Option<String>,
|
||
pub matched_text: Option<String>,
|
||
pub all_persons: Vec<String>,
|
||
pub multi_person: bool,
|
||
}
|
||
|
||
// ── Pass1: 收集模式 ──
|
||
|
||
/// 收集所有人名和别名,构建匹配模式列表
|
||
/// 按长度降序排列,确保长名优先匹配
|
||
pub async fn collect_person_patterns(db: &Database) -> Vec<Pattern> {
|
||
let mut patterns = Vec::new();
|
||
|
||
// 1. 收集所有人名
|
||
let rows = db.get_all_person_names().await;
|
||
for (name, _albums) in &rows {
|
||
if is_excluded_name(name) {
|
||
continue;
|
||
}
|
||
patterns.push(Pattern {
|
||
text: name.clone(),
|
||
person_name: name.clone(),
|
||
source: "name".to_string(),
|
||
length: name.len(),
|
||
});
|
||
}
|
||
|
||
// 2. 收集所有别名
|
||
let aliases = db.get_all_aliases().await;
|
||
for (alias_name, person_name) in &aliases {
|
||
if is_excluded_name(person_name) || is_excluded_name(alias_name) {
|
||
continue;
|
||
}
|
||
if alias_name != person_name {
|
||
patterns.push(Pattern {
|
||
text: alias_name.clone(),
|
||
person_name: person_name.clone(),
|
||
source: "alias".to_string(),
|
||
length: alias_name.len(),
|
||
});
|
||
}
|
||
}
|
||
|
||
// 3. 去重
|
||
let mut best_map: HashMap<String, Pattern> = HashMap::new();
|
||
for p in patterns {
|
||
let key = p.text.to_lowercase();
|
||
best_map.entry(key).or_insert(p);
|
||
}
|
||
let mut patterns: Vec<Pattern> = best_map.into_values().collect();
|
||
|
||
// 4. 按长度降序排列
|
||
patterns.sort_by(|a, b| b.length.cmp(&a.length));
|
||
|
||
patterns
|
||
}
|
||
|
||
/// Pass1: 简单子串匹配
|
||
pub fn match_title(title: &str, patterns: &[Pattern]) -> Option<(String, String, String)> {
|
||
if title.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
for p in patterns {
|
||
if p.text.len() < 2 {
|
||
continue;
|
||
}
|
||
if title.contains(&p.text) {
|
||
return Some((p.text.clone(), p.person_name.clone(), p.source.clone()));
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Pass2: 深度匹配(多重策略)
|
||
pub fn deep_match_title(
|
||
title: &str,
|
||
known_names: &HashSet<String>,
|
||
known_aliases: &HashSet<String>,
|
||
) -> DeepMatchResult {
|
||
let mut result = DeepMatchResult {
|
||
matched: false,
|
||
method: String::new(),
|
||
person: None,
|
||
matched_text: None,
|
||
all_persons: Vec::new(),
|
||
multi_person: false,
|
||
};
|
||
|
||
if title.is_empty() {
|
||
return result;
|
||
}
|
||
|
||
let title_clean = title.trim();
|
||
|
||
// 策略1: 用正则提取人名
|
||
let extracted = crate::engine::title_parser::extract_person_name_from_title(title_clean);
|
||
if let Some(ref e) = extracted {
|
||
if known_names.contains(e) {
|
||
result.matched = true;
|
||
result.method = "jieba_extract".to_string();
|
||
result.person = Some(e.clone());
|
||
result.matched_text = Some(e.clone());
|
||
return result;
|
||
}
|
||
}
|
||
|
||
// 策略2: 标题中精确子串匹配已知人名(长名优先)
|
||
let mut found: Vec<String> = Vec::new();
|
||
let mut sorted_names: Vec<&String> = known_names.iter().collect();
|
||
sorted_names.sort_by(|a, b| b.len().cmp(&a.len()));
|
||
|
||
for name in &sorted_names {
|
||
if name.len() < 2 {
|
||
continue;
|
||
}
|
||
if title_clean.contains(name.as_str()) {
|
||
found.push(name.to_string());
|
||
}
|
||
}
|
||
|
||
if !found.is_empty() {
|
||
found.sort_by(|a, b| b.len().cmp(&a.len()));
|
||
let best = found[0].clone();
|
||
result.matched = true;
|
||
result.method = "exact_name_match".to_string();
|
||
result.person = Some(best.clone());
|
||
result.matched_text = Some(best);
|
||
result.all_persons = found.clone();
|
||
result.multi_person = found.len() > 1;
|
||
return result;
|
||
}
|
||
|
||
// 策略3: 别名模糊匹配
|
||
let title_lower = title_clean.to_lowercase();
|
||
let mut sorted_aliases: Vec<&String> = known_aliases.iter().collect();
|
||
sorted_aliases.sort_by(|a, b| b.len().cmp(&a.len()));
|
||
|
||
for alias in &sorted_aliases {
|
||
if alias.len() < 2 {
|
||
continue;
|
||
}
|
||
if title_lower.contains(alias.to_lowercase().as_str()) {
|
||
result.matched = true;
|
||
result.method = "alias_fuzzy_match".to_string();
|
||
result.person = Some(alias.to_string());
|
||
result.matched_text = Some(alias.to_string());
|
||
return result;
|
||
}
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// 收集所有已知人名和别名
|
||
pub async fn collect_known_names(db: &Database) -> (HashSet<String>, HashSet<String>) {
|
||
let mut known_names = HashSet::new();
|
||
let mut known_aliases = HashSet::new();
|
||
|
||
for (name, _) in db.get_all_person_names().await {
|
||
known_names.insert(name);
|
||
}
|
||
for (alias_name, _) in db.get_all_aliases().await {
|
||
known_aliases.insert(alias_name);
|
||
}
|
||
|
||
(known_names, known_aliases)
|
||
}
|
||
|
||
/// 检测含多个人名的相册
|
||
pub async fn detect_multi_person_titles(
|
||
db: &Database,
|
||
known_names: Option<&HashSet<String>>,
|
||
) -> Vec<(String, String, Vec<String>)> {
|
||
let names = match known_names {
|
||
Some(n) => n.clone(),
|
||
None => collect_known_names(db).await.0,
|
||
};
|
||
|
||
let albums = db.get_all_albums_full().await;
|
||
let mut multi_person = Vec::new();
|
||
|
||
for album in &albums {
|
||
let title = album.title.as_deref().unwrap_or("");
|
||
if title.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let mut found: Vec<String> = Vec::new();
|
||
let mut sorted_names: Vec<&String> = names.iter().collect();
|
||
sorted_names.sort_by(|a, b| b.len().cmp(&a.len()));
|
||
|
||
for name in &sorted_names {
|
||
if name.len() < 2 {
|
||
continue;
|
||
}
|
||
if title.contains(name.as_str()) {
|
||
found.push(name.to_string());
|
||
}
|
||
}
|
||
|
||
if found.len() > 1 {
|
||
multi_person.push((
|
||
album.id.clone(),
|
||
title.to_string(),
|
||
found,
|
||
));
|
||
}
|
||
}
|
||
|
||
multi_person
|
||
}
|
||
|
||
// ── 中文核心合并分析 ──
|
||
|
||
/// 分析所有人名的中文核心分组
|
||
/// 返回: {core: [(id, name, albums, images), ...]}
|
||
pub async fn analyze_groups(db: &Database) -> HashMap<String, Vec<(i64, String, i64, i64)>> {
|
||
let persons = db.get_all_persons_with_counts().await;
|
||
let mut groups: HashMap<String, Vec<(i64, String, i64, i64)>> = HashMap::new();
|
||
|
||
for (pid, name, albums, images) in persons {
|
||
if let Some(cn) = extract_cn(&name) {
|
||
if cn.len() >= 2 {
|
||
groups.entry(cn).or_default().push((pid, name, albums, images));
|
||
}
|
||
}
|
||
}
|
||
|
||
for items in groups.values_mut() {
|
||
items.sort_by(|a, b| b.2.cmp(&a.2));
|
||
}
|
||
|
||
groups.retain(|_, v| v.len() >= 2);
|
||
|
||
groups
|
||
}
|
||
|
||
/// 合并结果统计
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct MergeResult {
|
||
pub groups_analyzed: usize,
|
||
pub persons_merged: usize,
|
||
pub aliases_moved: usize,
|
||
}
|
||
|
||
/// 执行合并:扫描所有可合并的人物组,合并到相册数最多的那个
|
||
///
|
||
/// 合并分两个阶段:
|
||
/// Phase 1: 中文核心相同 → 合并到相册数最多的
|
||
/// Phase 2: 名字包含关系(A包含B,且B更短)→ A合并到B(短名保留)
|
||
///
|
||
/// 返回合并统计
|
||
pub async fn execute_merge(db: &Database) -> MergeResult {
|
||
let mut result = MergeResult::default();
|
||
|
||
// ── Phase 1: 中文核心合并 ──
|
||
let groups = analyze_groups(db).await;
|
||
result.groups_analyzed = groups.len();
|
||
|
||
for (_core, items) in &groups {
|
||
if items.len() < 2 {
|
||
continue;
|
||
}
|
||
|
||
// 第一个(相册数最多)作为目标
|
||
let dst_id = items[0].0;
|
||
let dst_name = &items[0].1;
|
||
|
||
// 其余的合并到目标
|
||
for (src_id, src_name, _, _) in &items[1..] {
|
||
println!("[合并·核心] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id);
|
||
|
||
let src_aliases = db.get_person_aliases(*src_id).await;
|
||
let alias_count_before = src_aliases.len();
|
||
|
||
db.merge_person_into(*src_id, dst_id).await;
|
||
|
||
result.persons_merged += 1;
|
||
let moved = alias_count_before + 1;
|
||
result.aliases_moved += moved;
|
||
}
|
||
}
|
||
|
||
// ── Phase 2: 包含关系合并 ──
|
||
// 重新获取当前所有人物(Phase 1 后可能有变化)
|
||
let mut persons = db.get_all_persons_with_counts().await;
|
||
|
||
// 过滤掉排除名单中的
|
||
persons.retain(|(_, name, _, _)| !is_excluded_name(name));
|
||
|
||
// 按名字长度升序排列(短名在前,作为合并目标候选)
|
||
persons.sort_by(|a, b| a.1.len().cmp(&b.1.len()));
|
||
|
||
// 用一个 set 记录已被合并的 id,避免重复操作
|
||
let mut merged_ids: HashSet<i64> = HashSet::new();
|
||
|
||
for i in 0..persons.len() {
|
||
let (dst_id, dst_name, _dst_albums, _) = &persons[i];
|
||
if merged_ids.contains(dst_id) { continue; }
|
||
if dst_name.len() < 2 { continue; }
|
||
|
||
// 找所有比 dst_name 长且包含 dst_name 的人
|
||
let dst_lower = dst_name.to_lowercase();
|
||
|
||
for j in (i + 1)..persons.len() {
|
||
let (src_id, src_name, _, _) = &persons[j];
|
||
if merged_ids.contains(src_id) { continue; }
|
||
if src_name.len() <= dst_name.len() { continue; }
|
||
|
||
let src_lower = src_name.to_lowercase();
|
||
if src_lower.contains(&dst_lower) {
|
||
// 额外保护:dst 的中文核心必须和 src 的中文核心一致,
|
||
// 或者 dst 完全匹配 src 的开头/结尾,避免误合并(如 "小美" 包含 "小")
|
||
let dst_cn = extract_cn(dst_name).unwrap_or_default();
|
||
let src_cn = extract_cn(src_name).unwrap_or_default();
|
||
let cn_ok = !dst_cn.is_empty() && !src_cn.is_empty() && src_cn.contains(&dst_cn);
|
||
let starts_with = src_lower.starts_with(&dst_lower);
|
||
let ends_with = src_lower.ends_with(&dst_lower);
|
||
|
||
if !cn_ok && !starts_with && !ends_with {
|
||
continue;
|
||
}
|
||
|
||
println!("[合并·包含] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id);
|
||
|
||
let src_aliases = db.get_person_aliases(*src_id).await;
|
||
let alias_count_before = src_aliases.len();
|
||
|
||
db.merge_person_into(*src_id, *dst_id).await;
|
||
|
||
merged_ids.insert(*src_id);
|
||
result.persons_merged += 1;
|
||
let moved = alias_count_before + 1;
|
||
result.aliases_moved += moved;
|
||
}
|
||
}
|
||
}
|
||
|
||
if result.persons_merged > 0 {
|
||
println!("[合并] 完成:分析 {} 组,合并 {} 个人物,迁移 {} 个别名",
|
||
result.groups_analyzed, result.persons_merged, result.aliases_moved);
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// 统计匹配分析
|
||
#[derive(Debug, Clone)]
|
||
pub struct MatchStats {
|
||
pub total_albums: usize,
|
||
pub matched: usize,
|
||
pub unmatched: usize,
|
||
pub already_set: usize,
|
||
pub changed: usize,
|
||
pub by_source: HashMap<String, usize>,
|
||
pub name_frequency: HashMap<String, usize>,
|
||
}
|
||
|
||
/// 执行 Pass1 匹配分析
|
||
pub async fn analyze_matches(db: &Database, patterns: &[Pattern]) -> MatchStats {
|
||
let albums = db.get_all_albums_full().await;
|
||
let mut stats = MatchStats {
|
||
total_albums: albums.len(),
|
||
matched: 0,
|
||
unmatched: 0,
|
||
already_set: 0,
|
||
changed: 0,
|
||
by_source: HashMap::new(),
|
||
name_frequency: HashMap::new(),
|
||
};
|
||
|
||
for album in &albums {
|
||
let title = album.title.as_deref().unwrap_or("");
|
||
let current = album.person_name_original.as_deref().unwrap_or("");
|
||
|
||
if let Some((_text, person, source)) = match_title(title, patterns) {
|
||
*stats.by_source.entry(source).or_insert(0) += 1;
|
||
*stats.name_frequency.entry(person.clone()).or_insert(0) += 1;
|
||
|
||
if current.is_empty() {
|
||
stats.matched += 1;
|
||
} else if current != person {
|
||
stats.changed += 1;
|
||
} else {
|
||
stats.already_set += 1;
|
||
}
|
||
} else {
|
||
stats.unmatched += 1;
|
||
}
|
||
}
|
||
|
||
stats
|
||
}
|
||
|
||
/// 深度匹配分析统计
|
||
#[derive(Debug, Clone)]
|
||
pub struct DeepMatchStats {
|
||
pub total_albums: usize,
|
||
pub pass1_matched: usize,
|
||
pub pass1_unmatched: usize,
|
||
pub pass2_jieba_match: usize,
|
||
pub pass2_exact_match: usize,
|
||
pub pass2_alias_match: usize,
|
||
pub pass2_still_unmatched: usize,
|
||
pub pass2_multi_person: usize,
|
||
}
|
||
|
||
/// 执行 Pass2 深度匹配分析
|
||
pub async fn analyze_deep_matches(db: &Database) -> DeepMatchStats {
|
||
let (known_names, known_aliases) = collect_known_names(db).await;
|
||
let albums = db.get_all_albums_full().await;
|
||
|
||
let mut stats = DeepMatchStats {
|
||
total_albums: albums.len(),
|
||
pass1_matched: 0,
|
||
pass1_unmatched: 0,
|
||
pass2_jieba_match: 0,
|
||
pass2_exact_match: 0,
|
||
pass2_alias_match: 0,
|
||
pass2_still_unmatched: 0,
|
||
pass2_multi_person: 0,
|
||
};
|
||
|
||
for album in &albums {
|
||
let title = album.title.as_deref().unwrap_or("");
|
||
let current = album.person_name_original.as_deref().unwrap_or("");
|
||
|
||
if title.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
if !current.is_empty() && known_names.contains(current) {
|
||
stats.pass1_matched += 1;
|
||
continue;
|
||
}
|
||
|
||
stats.pass1_unmatched += 1;
|
||
|
||
let result = deep_match_title(title, &known_names, &known_aliases);
|
||
if !result.matched {
|
||
stats.pass2_still_unmatched += 1;
|
||
continue;
|
||
}
|
||
|
||
match result.method.as_str() {
|
||
"jieba_extract" => stats.pass2_jieba_match += 1,
|
||
"exact_name_match" => stats.pass2_exact_match += 1,
|
||
"alias_fuzzy_match" => stats.pass2_alias_match += 1,
|
||
_ => {}
|
||
}
|
||
|
||
if result.multi_person {
|
||
stats.pass2_multi_person += 1;
|
||
}
|
||
}
|
||
|
||
stats
|
||
} |