Files
rust-imager/src/engine/person_merger.rs
2026-07-20 07:35:26 +08:00

493 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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.

//! 人名匹配与合并
//! 对应 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
}