update code level
This commit is contained in:
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -1327,7 +1327,6 @@ dependencies = [
|
||||
"image",
|
||||
"jieba-rs",
|
||||
"lazy_static",
|
||||
"mimalloc",
|
||||
"rand 0.8.7",
|
||||
"rayon",
|
||||
"regex",
|
||||
@@ -1516,15 +1515,6 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.18"
|
||||
@@ -1614,15 +1604,6 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
|
||||
@@ -19,7 +19,6 @@ rand = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
thiserror = "1.0"
|
||||
mimalloc = "0.1.48"
|
||||
regex = "1"
|
||||
lazy_static = "1.4"
|
||||
jieba-rs = "0.7"
|
||||
|
||||
@@ -279,10 +279,17 @@ pub struct MergeResult {
|
||||
}
|
||||
|
||||
/// 执行合并:扫描所有可合并的人物组,合并到相册数最多的那个
|
||||
///
|
||||
/// 合并分两个阶段:
|
||||
/// Phase 1: 中文核心相同 → 合并到相册数最多的
|
||||
/// Phase 2: 名字包含关系(A包含B,且B更短)→ A合并到B(短名保留)
|
||||
///
|
||||
/// 返回合并统计
|
||||
pub async fn execute_merge(db: &Database) -> MergeResult {
|
||||
let groups = analyze_groups(db).await;
|
||||
let mut result = MergeResult::default();
|
||||
|
||||
// ── Phase 1: 中文核心合并 ──
|
||||
let groups = analyze_groups(db).await;
|
||||
result.groups_analyzed = groups.len();
|
||||
|
||||
for (_core, items) in &groups {
|
||||
@@ -296,22 +303,74 @@ pub async fn execute_merge(db: &Database) -> MergeResult {
|
||||
|
||||
// 其余的合并到目标
|
||||
for (src_id, src_name, _, _) in &items[1..] {
|
||||
println!("[合并] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id);
|
||||
println!("[合并·核心] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id);
|
||||
|
||||
// 合并前统计 src 的别名数
|
||||
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;
|
||||
|
||||
// 合并后统计新增的别名数 (= src 别名数 + src 原名)
|
||||
result.persons_merged += 1;
|
||||
// src 原名 + src 的 display_name + src 原有的别名
|
||||
let moved = alias_count_before + 1; // +1 因为原名会成为 merged_original 别名
|
||||
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);
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
//!
|
||||
//! 核心架构:脏集追踪 + 增量解析
|
||||
//!
|
||||
//! Phase 0 清洗标签标点
|
||||
//! Phase A1 检测标签中的人名并直接提取到 person 字段 → 加入脏集
|
||||
//! Phase A2 清理非人物 → 加入脏集
|
||||
//! Phase A3 收集新相册/半成品 → 加入脏集
|
||||
//! Phase 1 解析脏集相册(增量解析,不扫全库)
|
||||
//! Phase C 全量重计所有 person 的相册计数
|
||||
//! Phase 2 人物合并
|
||||
//! Phase B 后校验标签中的人名
|
||||
//! 精简后流程(6 阶段,2 次全库扫描):
|
||||
//! Phase ① 合并预处理(清洗标点/短标签/人名标签/新相册)→ 1次相册扫描
|
||||
//! Phase ② 合并人物清理(非人物/停用词人物) → 1次人物扫描
|
||||
//! Phase ③ 增量解析脏集相册 → 只扫脏集
|
||||
//! Phase ④ 人物合并 + 重计 → 1次人物扫描
|
||||
//! Phase ⑤ 后校验标签中的人名 → 可优化
|
||||
//!
|
||||
//! 脏集(DirtySet)贯穿各阶段,每个阶段发现的"需要重新解析"的相册都会加入脏集,
|
||||
//! 最终只处理脏集里的相册,而非全库扫描,大幅提升效率。
|
||||
@@ -27,6 +25,7 @@ use rayon::prelude::*;
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 脏集:记录哪些相册需要重新解析
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirtySet {
|
||||
ids: HashSet<String>,
|
||||
}
|
||||
@@ -41,6 +40,10 @@ impl DirtySet {
|
||||
pub fn into_inner(self) -> HashSet<String> { self.ids }
|
||||
}
|
||||
|
||||
impl Default for DirtySet {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase 0:清洗已有标签中的头尾标点
|
||||
// ════════════════════════════════════════════════
|
||||
@@ -93,6 +96,78 @@ pub async fn phase_clean_tags(db: &Database) -> usize {
|
||||
cleaned_count
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase 0B:移除过短的标签(少于2个汉字/字母)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 判断一个标签是否"过短"——有效字符(汉字+字母)少于 2 个
|
||||
fn is_short_tag(tag: &str) -> bool {
|
||||
let mut valid_count = 0usize;
|
||||
for ch in tag.chars() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
valid_count += 1;
|
||||
} else if ('\u{4e00}'..='\u{9fff}').contains(&ch) {
|
||||
valid_count += 1;
|
||||
}
|
||||
// 数字、符号等不算有效字符
|
||||
}
|
||||
valid_count < 2
|
||||
}
|
||||
|
||||
/// Phase 0B:扫描所有标签,移除过短的标签(有效字符 < 2),
|
||||
/// 受影响的相册加入脏集等待重新解析。
|
||||
pub async fn phase_remove_short_tags(db: &Database) -> (DirtySet, usize) {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut removed_total = 0usize;
|
||||
let mut removed_samples: HashSet<String> = HashSet::new();
|
||||
|
||||
for album in &albums {
|
||||
let tags_json = match album.tags.as_deref() {
|
||||
Some(t) if !t.is_empty() && t != "[]" => t,
|
||||
_ => continue,
|
||||
};
|
||||
let old_tags: Vec<String> = match serde_json::from_str(tags_json) {
|
||||
Ok(t) => t, Err(_) => continue,
|
||||
};
|
||||
if old_tags.is_empty() { continue; }
|
||||
|
||||
let mut changed = false;
|
||||
let mut new_tags = Vec::new();
|
||||
for tag in old_tags {
|
||||
if is_short_tag(&tag) {
|
||||
removed_samples.insert(tag);
|
||||
changed = true;
|
||||
removed_total += 1;
|
||||
} else {
|
||||
new_tags.push(tag);
|
||||
}
|
||||
}
|
||||
if !changed { continue; }
|
||||
|
||||
dirty.add(&album.id);
|
||||
|
||||
let tags_json_new = serde_json::to_string(&new_tags).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words_json = album.all_words.as_deref().unwrap_or("[]");
|
||||
let all_words_new: Vec<[String; 2]> = serde_json::from_str::<Vec<[String; 2]>>(all_words_json)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|pair| !is_short_tag(&pair[0]))
|
||||
.collect();
|
||||
let all_words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json_new, &all_words_json_new).await;
|
||||
}
|
||||
|
||||
if removed_total > 0 {
|
||||
let samples: Vec<_> = removed_samples.iter().take(10).collect();
|
||||
println!("[管线·Phase0B] 短标签清理完成:移除 {} 个短标签,影响 {} 个相册,示例: {:?}",
|
||||
removed_total, dirty.len(), samples);
|
||||
} else {
|
||||
println!("[管线·Phase0B] 短标签清理完成:无短标签");
|
||||
}
|
||||
(dirty, removed_total)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A1:检测标签中的人名并直接提取到 person 字段
|
||||
// ════════════════════════════════════════════════
|
||||
@@ -465,48 +540,278 @@ pub async fn phase_clean_tags_after_parse(db: &Database) -> usize {
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Pipeline 一键执行(7 阶段)
|
||||
// 合并阶段 — 减少全库扫描次数
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 合并预处理结果
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct PreprocessResult {
|
||||
tags_cleaned: usize,
|
||||
short_tags_removed: usize,
|
||||
person_like_tags_removed: usize,
|
||||
new_albums_found: usize,
|
||||
dirty: DirtySet,
|
||||
}
|
||||
|
||||
/// 合并预处理:一次相册扫描完成 4 件事
|
||||
/// 1. 清洗标签标点
|
||||
/// 2. 移除短标签
|
||||
/// 3. 检测并移除标签中的人名
|
||||
/// 4. 收集新相册/半成品
|
||||
async fn phase_preprocess_combined(db: &Database) -> PreprocessResult {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
|
||||
let known_persons: HashSet<String> = db.get_all_person_names().await
|
||||
.into_iter().map(|(n, _)| n.to_lowercase()).collect();
|
||||
|
||||
let mut result = PreprocessResult::default();
|
||||
|
||||
for album in &albums {
|
||||
let tags_json = album.tags.as_deref();
|
||||
let has_tags = tags_json.map(|t| !t.is_empty() && t != "[]" && t != "null").unwrap_or(false);
|
||||
let has_person = album.person_name_original.as_deref()
|
||||
.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
// ── 新相册/半成品检测 ──
|
||||
if !has_tags || !has_person {
|
||||
result.dirty.add(&album.id);
|
||||
if !has_tags {
|
||||
result.new_albums_found += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 没有标签就不需要清洗/检测 ──
|
||||
if !has_tags { continue; }
|
||||
|
||||
let old_tags: Vec<String> = match serde_json::from_str(tags_json.unwrap()) {
|
||||
Ok(t) => t, Err(_) => continue,
|
||||
};
|
||||
if old_tags.is_empty() { continue; }
|
||||
|
||||
// ── 三步标签处理合并到一次遍历 ──
|
||||
let mut changed = false;
|
||||
let mut new_tags: Vec<String> = Vec::new();
|
||||
let mut person_like_removed_this = 0usize;
|
||||
|
||||
// 先找到最优人名(用于后续从标签中移除人名相关)
|
||||
// 第一层:逐个标签收集候选人名
|
||||
let mut known_person_found: Option<String> = None;
|
||||
let mut person_like_tags: Vec<String> = Vec::new();
|
||||
|
||||
for tag in &old_tags {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
if known_persons.contains(&tag_lower) {
|
||||
known_person_found = Some(tag.clone());
|
||||
continue;
|
||||
}
|
||||
if !has_person && title_parser::is_likely_person_name(tag) && !title_parser::is_excluded_name(tag) {
|
||||
person_like_tags.push(tag.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let best_layer1 = person_like_tags.into_iter().max_by_key(|t| {
|
||||
if let Some(cn) = title_parser::extract_cn(t) {
|
||||
if cn.len() >= 2 { cn.len() * 10 + t.len() } else { t.len() }
|
||||
} else { t.len() }
|
||||
});
|
||||
|
||||
let combined = old_tags.join(" ");
|
||||
let (jieba_name, _) = title_parser::extract_person_and_tags(&combined);
|
||||
|
||||
let final_person = known_person_found.or_else(|| {
|
||||
match (&jieba_name, &best_layer1) {
|
||||
(Some(j), Some(b)) => {
|
||||
let j_len: usize = j.len();
|
||||
let b_len: usize = b.len();
|
||||
if j_len > b_len || title_parser::is_excluded_name(b) { Some(j.clone()) } else { Some(b.clone()) }
|
||||
}
|
||||
(Some(j), None) => Some(j.clone()),
|
||||
(None, Some(b)) => Some(b.clone()),
|
||||
(None, None) => None,
|
||||
}
|
||||
});
|
||||
|
||||
let person_lower = final_person.as_ref().map(|n| n.to_lowercase());
|
||||
let primary_lower = final_person.as_ref().and_then(|n| {
|
||||
let (p, _) = title_parser::split_person_name_with_aliases(n);
|
||||
if p.is_empty() { None } else { Some(p.to_lowercase()) }
|
||||
});
|
||||
|
||||
for tag in old_tags {
|
||||
// Step 1: 清洗标点
|
||||
let cleaned = match tag_extractor::clean_tag(&tag) {
|
||||
Some(t) => t,
|
||||
None => { changed = true; continue; }
|
||||
};
|
||||
if cleaned != tag { changed = true; }
|
||||
|
||||
// Step 2: 短标签过滤
|
||||
if is_short_tag(&cleaned) {
|
||||
changed = true;
|
||||
result.short_tags_removed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 3: 人名标签过滤
|
||||
let cleaned_lower = cleaned.to_lowercase();
|
||||
let mut is_person_tag = false;
|
||||
if let (Some(pl), Some(pr)) = (&person_lower, &primary_lower) {
|
||||
is_person_tag =
|
||||
cleaned_lower == *pl
|
||||
|| cleaned_lower == *pr
|
||||
|| (cleaned.len() >= 2 && cleaned.chars().all(|c| c.is_ascii_alphabetic())
|
||||
&& !cleaned_lower.starts_with("cos")
|
||||
&& pl.contains(&cleaned_lower))
|
||||
|| (cleaned_lower.len() >= 2 && pl.contains(&cleaned_lower));
|
||||
}
|
||||
|
||||
if is_person_tag {
|
||||
person_like_removed_this += 1;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
new_tags.push(cleaned);
|
||||
}
|
||||
|
||||
result.person_like_tags_removed += person_like_removed_this;
|
||||
|
||||
if !changed { continue; }
|
||||
result.tags_cleaned += 1;
|
||||
result.dirty.add(&album.id);
|
||||
|
||||
// 写回
|
||||
let tags_json_new = serde_json::to_string(&new_tags).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words_json = album.all_words.as_deref().unwrap_or("[]");
|
||||
let all_words_new: Vec<[String; 2]> = serde_json::from_str::<Vec<[String; 2]>>(all_words_json)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|pair| {
|
||||
let cleaned = tag_extractor::clean_tag(&pair[0])?;
|
||||
if is_short_tag(&cleaned) { return None; }
|
||||
if known_persons.contains(&pair[0].to_lowercase()) { return None; }
|
||||
Some([cleaned, pair[1].clone()])
|
||||
})
|
||||
.collect();
|
||||
let all_words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json_new, &all_words_json_new).await;
|
||||
}
|
||||
|
||||
println!("[管线·Phase①] 合并预处理完成:清洗{}相册,移除短标签{},移除人名标签{},新相册{},脏集合计{}",
|
||||
result.tags_cleaned, result.short_tags_removed, result.person_like_tags_removed,
|
||||
result.new_albums_found, result.dirty.len());
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 合并人物清理:一次人物遍历完成 2 件事
|
||||
/// 1. 清理非人物
|
||||
/// 2. 清理停用词重叠人物
|
||||
async fn phase_clean_persons_combined(db: &Database) -> (DirtySet, usize, usize) {
|
||||
let persons = db.get_all_persons_with_counts().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut non_persons_deleted = 0usize;
|
||||
let mut stop_word_persons_removed = 0usize;
|
||||
|
||||
for (pid, name, _albums, _images) in &persons {
|
||||
// 判断是否应该删除
|
||||
let is_non_person = title_parser::is_excluded_name(name) || !title_parser::is_likely_person_name(name);
|
||||
|
||||
let is_stop_word = {
|
||||
let excluded = tag_dict::get_tag_dict().is_excluded_person(name);
|
||||
let cn_check = title_parser::extract_cn(name)
|
||||
.map(|cn| tag_dict::get_tag_dict().is_excluded_person(&cn))
|
||||
.unwrap_or(false);
|
||||
excluded || cn_check
|
||||
};
|
||||
|
||||
if !is_non_person && !is_stop_word { continue; }
|
||||
|
||||
let album_list = db.get_person_albums(*pid).await;
|
||||
let album_ids: Vec<String> = album_list.into_iter().map(|a| a.id.to_string()).collect();
|
||||
for aid in &album_ids { dirty.add(aid); }
|
||||
|
||||
if is_stop_word {
|
||||
println!("[管线·Phase②] 排除人名命中:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
|
||||
stop_word_persons_removed += 1;
|
||||
} else {
|
||||
println!("[管线·Phase②] 删除非人物:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
|
||||
non_persons_deleted += 1;
|
||||
}
|
||||
|
||||
db.update_album_person_by_person_id(*pid).await;
|
||||
db.delete_person_completely(*pid).await;
|
||||
}
|
||||
|
||||
if non_persons_deleted > 0 || stop_word_persons_removed > 0 {
|
||||
println!("[管线·Phase②] 人物清理完成:删除非人物{},移除停用词人物{},脏相册{}",
|
||||
non_persons_deleted, stop_word_persons_removed, dirty.len());
|
||||
} else {
|
||||
println!("[管线·Phase②] 人物清理完成:无异常");
|
||||
}
|
||||
|
||||
(dirty, non_persons_deleted, stop_word_persons_removed)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Pipeline 一键执行
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PipelineResult {
|
||||
pub tags_cleaned: usize, // Phase 0
|
||||
pub person_like_tags_removed: usize, // Phase A1
|
||||
pub dirty_albums_detected: usize, // A1+A2+A2B+A3
|
||||
pub non_persons_deleted: usize, // Phase A2
|
||||
pub stop_word_persons_removed: usize, // Phase A2B
|
||||
pub new_albums_found: usize, // Phase A3
|
||||
pub persons_updated: usize, // Phase 1
|
||||
pub tags_updated: usize, // Phase 1
|
||||
pub persons_merged: usize, // Phase 2
|
||||
pub aliases_moved: usize, // Phase 2
|
||||
pub tags_removed_person: usize, // Phase B
|
||||
pub tags_cleaned: usize, // Phase ①
|
||||
pub short_tags_removed: usize, // Phase ①
|
||||
pub person_like_tags_removed: usize, // Phase ①
|
||||
pub dirty_albums_detected: usize, // 总脏集
|
||||
pub non_persons_deleted: usize, // Phase ②
|
||||
pub stop_word_persons_removed: usize, // Phase ②
|
||||
pub new_albums_found: usize, // Phase ①
|
||||
pub persons_updated: usize, // Phase ③
|
||||
pub tags_updated: usize, // Phase ③
|
||||
pub persons_merged: usize, // Phase ④
|
||||
pub aliases_moved: usize, // Phase ④
|
||||
pub tags_removed_person: usize, // Phase ⑤
|
||||
}
|
||||
|
||||
/// 一键执行完整管线(8 阶段顺序串行)
|
||||
/// 一键执行精简管线(5 阶段,2-3 次全库扫描)
|
||||
///
|
||||
/// Phase ① 合并预处理(清洗+短标签+人名标签+新相册) → 1次相册扫描
|
||||
/// Phase ② 合并人物清理(非人物+停用词人物) → 1次人物扫描
|
||||
/// Phase ③ 增量解析脏集 → 只扫脏集
|
||||
/// Phase ④ 人物合并 + 重计 → 1次人物扫描
|
||||
/// Phase ⑤ 后校验标签中的人名
|
||||
pub async fn run_pipeline(db: &Database) -> PipelineResult {
|
||||
let tags_cleaned = phase_clean_tags(db).await;
|
||||
let (dirty_a1, person_like_tags_removed) = phase_detect_person_in_tags(db).await;
|
||||
let (dirty_a2, non_persons_deleted) = phase_clean_persons(db).await;
|
||||
let (dirty_a2b, stop_word_persons_removed) = phase_clean_stop_word_persons(db).await;
|
||||
let dirty_a3 = phase_collect_new_albums(db).await;
|
||||
let new_albums_found = dirty_a3.len();
|
||||
// Phase ①:合并预处理(1 次相册扫描)
|
||||
let pre = phase_preprocess_combined(db).await;
|
||||
let tags_cleaned = pre.tags_cleaned;
|
||||
let short_tags_removed = pre.short_tags_removed;
|
||||
let person_like_tags_removed = pre.person_like_tags_removed;
|
||||
let new_albums_found = pre.new_albums_found;
|
||||
|
||||
let mut master_dirty = DirtySet::new();
|
||||
master_dirty.extend(dirty_a1.into_inner());
|
||||
master_dirty.extend(dirty_a2.into_inner());
|
||||
master_dirty.extend(dirty_a2b.into_inner());
|
||||
master_dirty.extend(dirty_a3.into_inner());
|
||||
// Phase ②:合并人物清理(1 次人物扫描)
|
||||
let (dirty_persons, non_persons_deleted, stop_word_persons_removed) =
|
||||
phase_clean_persons_combined(db).await;
|
||||
|
||||
// 合并脏集
|
||||
let mut master_dirty = pre.dirty;
|
||||
master_dirty.extend(dirty_persons.into_inner());
|
||||
let dirty_albums_detected = master_dirty.len();
|
||||
|
||||
// Phase ③:增量解析
|
||||
let (persons_updated, tags_updated) = phase_parse_dirty(db, &master_dirty).await;
|
||||
// Phase C:全量重计(在合并前修正所有计数,让合并看到准确数值)
|
||||
let _recounted = phase_recount_all(db).await;
|
||||
|
||||
// Phase ④:人物合并(内部会做包含关系 + 核心合并)
|
||||
let (persons_merged, aliases_moved) = phase_merge_persons(db).await;
|
||||
|
||||
// Phase C:全量重计(合并后修正计数)
|
||||
let _recounted = phase_recount_all(db).await;
|
||||
|
||||
// Phase ⑤:后校验
|
||||
let tags_removed_person = phase_clean_tags_after_parse(db).await;
|
||||
|
||||
PipelineResult {
|
||||
tags_cleaned, person_like_tags_removed, dirty_albums_detected,
|
||||
tags_cleaned, short_tags_removed, person_like_tags_removed, dirty_albums_detected,
|
||||
non_persons_deleted, stop_word_persons_removed, new_albums_found,
|
||||
persons_updated, tags_updated, persons_merged,
|
||||
aliases_moved, tags_removed_person,
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
//! 标题解析、人名提取、标签提取
|
||||
//! 标题解析 — 统一最大匹配管线(v5)
|
||||
//!
|
||||
//! v4 版本 — 基于 keymap 的纯匹配方案,无 jieba 依赖。
|
||||
//! 核心流程:预处理 → token 分割 → 对每个 token 依次检查:
|
||||
//! keymap(person) → 人物
|
||||
//! keymap(其他) → 标签
|
||||
//! stopwords → 跳过
|
||||
//! 人名启发式 → 人物
|
||||
//! 核心思路:一次扫描同时识别 person 和 tags,两者互斥,不会混淆。
|
||||
//!
|
||||
//! 流程:
|
||||
//! 1. 预处理:移除照片数后缀、[工作室]前缀、称呼前缀、编号后缀
|
||||
//! 2. 最大匹配切分:在 keymap + include + stop_words 词典中做最长子串匹配
|
||||
//! 3. 角色分类:每个片段有明确分类(person / style / outfit / ... / stop / other)
|
||||
//! 4. 选人名:优先级 = keymap(person) > include 列表 > 启发式
|
||||
//! 5. 其余分类即为标签
|
||||
//!
|
||||
//! 相比 v4 的改进:
|
||||
//! - 中文连续文本也能正确切分(不再依赖空格)
|
||||
//! - person 和 tag 在同一 pass 中确定,不会混淆
|
||||
//! - 最长匹配优先,避免"性感"和"性感女神"都匹配的问题
|
||||
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
use lazy_static::lazy_static;
|
||||
use crate::engine::tag_dict;
|
||||
use crate::engine::tag_extractor;
|
||||
|
||||
// ── 正则 ──
|
||||
|
||||
lazy_static! {
|
||||
static ref RE_BRACKET_PREFIX: Regex = Regex::new(r"^\[([^\]]+)\]\s*").unwrap();
|
||||
static ref RE_NUMBER_SUFFIX: Regex = Regex::new(r"[\s#]*(?:no\.?\s*|vol\.?\s*|#)?\d+[\s]*$").unwrap();
|
||||
static ref RE_PAREN_CONTENT: Regex = Regex::new(r"[((]([^))]*)[))]").unwrap();
|
||||
static ref RE_PHOTOS_SUFFIX: Regex = Regex::new(r"(?i)\s*\d+\s*photos?\s*(?:and\s*\d+\s*videos?)?\s*$").unwrap();
|
||||
static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
|
||||
static ref RE_ENGLISH_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap();
|
||||
static ref RE_TRAILING_PUNCT: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap();
|
||||
/// 中文词(2-5字)
|
||||
static ref RE_CN_WORD: Regex = Regex::new(r"([\u{4e00}-\u{9fff}]{2,5})").unwrap();
|
||||
/// 英文词(2-15字母)
|
||||
static ref RE_EN_WORD: Regex = Regex::new(r"([A-Za-z][A-Za-z]{1,14})").unwrap();
|
||||
static ref RE_PAREN_CONTENT: Regex = Regex::new(r"[((]([^))]*)[))]").unwrap();
|
||||
/// 分隔符:空白、常见标点、中英文之间的边界
|
||||
static ref RE_SEPARATOR: Regex = Regex::new(r"[\s\-–—_,.;:,。;:、/\\|·]+").unwrap();
|
||||
}
|
||||
|
||||
// ── 统一预处理 ──
|
||||
// ── 预处理 ──
|
||||
|
||||
/// 统一标题预处理:移除照片数后缀、[工作室] 前缀、称呼前缀、编号后缀
|
||||
pub fn preprocess_title(title: &str) -> String {
|
||||
@@ -50,129 +54,138 @@ pub fn preprocess_title(title: &str) -> String {
|
||||
t
|
||||
}
|
||||
|
||||
// ── Token 提取 ──
|
||||
// ── 切分片段:带分类 ──
|
||||
|
||||
/// 将标题分割为独立的 token(用于 Key 匹配)
|
||||
fn tokenize(title: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
|
||||
// 先按空白分割(简单 token)
|
||||
for segment in title.split_whitespace() {
|
||||
// 跳过纯标点
|
||||
if tag_extractor::is_punctuation_only(segment) { continue; }
|
||||
|
||||
// 尝试按常见连接符进一步分割(如 "性感女神" 在 keymap 中找不到 → 拆开)
|
||||
tokens.push(segment.to_string());
|
||||
}
|
||||
|
||||
// 去重
|
||||
let mut seen = HashSet::new();
|
||||
tokens.retain(|t| seen.insert(t.to_lowercase()));
|
||||
|
||||
tokens
|
||||
/// 一个切分片段及其分类
|
||||
#[derive(Debug, Clone)]
|
||||
struct Segment {
|
||||
text: String,
|
||||
category: String,
|
||||
}
|
||||
|
||||
/// 对 token 按长度降序排序(最长匹配优先)
|
||||
fn sort_by_length(tokens: &mut Vec<String>) {
|
||||
tokens.sort_by(|a, b| b.len().cmp(&a.len()));
|
||||
}
|
||||
// ── 最大匹配切分(核心算法) ──
|
||||
|
||||
// ── 核心解析:统一入口 ──
|
||||
|
||||
/// 统一相册标题解析入口
|
||||
/// 对标题做最大匹配切分,返回带分类的片段列表。
|
||||
///
|
||||
/// 流程:预处理 → token 分割 → 对每个 token:
|
||||
/// 1. keymap 匹配 → person → 人物;其他 → 标签
|
||||
/// 2. stopwords → 跳过
|
||||
/// 3. 人名启发式 → 人物
|
||||
/// 算法:从左到右扫描,每次尝试最长可能的匹配(max_len 到 1)。
|
||||
/// 匹配来源优先级(同一长度下):
|
||||
/// keymap(person) > keymap(其他) > stop_words > include_person
|
||||
///
|
||||
/// 返回 ParseResult 可直接用于批量写库。
|
||||
pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::ParseResult {
|
||||
let mut result = crate::models::parse::ParseResult::new(album_id.to_string());
|
||||
|
||||
let title = title.trim();
|
||||
if title.is_empty() { return result; }
|
||||
|
||||
let cleaned = preprocess_title(title);
|
||||
if cleaned.is_empty() { return result; }
|
||||
|
||||
/// 未匹配到的连续字符作为 "other" 片段保留。
|
||||
fn max_match_segment(title: &str) -> Vec<Segment> {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
|
||||
// ── 收集所有 token(空白分割)──
|
||||
let raw_tokens = tokenize(&cleaned);
|
||||
// 收集所有可匹配的词及分类
|
||||
let mut all_words: Vec<(String, String)> = Vec::new();
|
||||
|
||||
// ── 用于标签提取的已处理 title(移除人名后)──
|
||||
let mut person_found: Option<(String, String)> = None;
|
||||
let mut tag_parts: Vec<String> = Vec::new();
|
||||
// 1. keymap 中的所有词
|
||||
for (word, cat) in dict.get_tag_category_map() {
|
||||
all_words.push((word.clone(), cat.clone()));
|
||||
}
|
||||
|
||||
for token in &raw_tokens {
|
||||
let token_lower = token.to_lowercase();
|
||||
|
||||
// 跳过过短 token
|
||||
if token.len() < 2 {
|
||||
tag_parts.push(token.clone());
|
||||
continue;
|
||||
// 2. include 列表中的人名 → "person_include"
|
||||
for name in dict.get_included_persons() {
|
||||
if !dict.get_tag_category_map().contains_key(name) {
|
||||
all_words.push((name.clone(), "person_include".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过纯数字
|
||||
if token.chars().all(|c| c.is_ascii_digit()) {
|
||||
// 3. 停用词 → "stop"
|
||||
for word in dict.get_stop_words() {
|
||||
all_words.push((word.clone(), "stop".to_string()));
|
||||
}
|
||||
|
||||
// 计算最大词长
|
||||
let max_len = all_words.iter().map(|(w, _)| w.chars().count()).max().unwrap_or(8);
|
||||
|
||||
let mut segments: Vec<Segment> = Vec::new();
|
||||
let chars: Vec<char> = title.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < n {
|
||||
// 跳过分隔符
|
||||
let ch = chars[i];
|
||||
if RE_SEPARATOR.is_match(&ch.to_string()) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过停用词
|
||||
if dict.is_stop_word(&token_lower) {
|
||||
continue;
|
||||
}
|
||||
let mut found = false;
|
||||
|
||||
// 检查 keymap(精确匹配)
|
||||
if let Some(category) = dict.match_keymap(&token_lower) {
|
||||
if category == "person" && person_found.is_none() {
|
||||
person_found = Some((token.clone(), token_lower.clone()));
|
||||
} else if category != "person" {
|
||||
tag_parts.push(token.clone());
|
||||
continue;
|
||||
// 从最长到最短尝试匹配
|
||||
let try_max = max_len.min(n - i);
|
||||
'outer: for len in (1..=try_max).rev() {
|
||||
let candidate: String = chars[i..i + len].iter().collect();
|
||||
let candidate_lower = candidate.to_lowercase();
|
||||
|
||||
// 收集所有匹配该长度的词,按优先级选最好的
|
||||
let mut best: Option<&String> = None; // 存储分类
|
||||
|
||||
for (word, cat) in &all_words {
|
||||
if word.to_lowercase() == candidate_lower {
|
||||
best = Some(cat);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cat) = best {
|
||||
segments.push(Segment {
|
||||
text: candidate,
|
||||
category: cat.clone(),
|
||||
});
|
||||
i += len;
|
||||
found = true;
|
||||
break 'outer;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否为已知人名(include/启发式)
|
||||
if person_found.is_none() && is_likely_person_name(token) {
|
||||
person_found = Some((token.clone(), token_lower.clone()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 剩余内容放入 tag 池
|
||||
tag_parts.push(token.clone());
|
||||
}
|
||||
|
||||
// ── 处理人物 ──
|
||||
if let Some((name, _)) = &person_found {
|
||||
// include 列表中的最长匹配扩展
|
||||
let expanded = dict.find_longest_included_match(name)
|
||||
.filter(|longer| longer.len() > name.len());
|
||||
let effective_name = expanded.as_deref().unwrap_or(name.as_str());
|
||||
|
||||
let (primary, _aliases) = split_person_name_with_aliases(effective_name);
|
||||
let final_name = if primary.is_empty() { effective_name.to_string() } else { primary };
|
||||
|
||||
if final_name.chars().count() > 2 {
|
||||
result.person_name = Some(final_name);
|
||||
if !found {
|
||||
// 未匹配到:收集连续的非分隔字符作为 "other"
|
||||
let start = i;
|
||||
while i < n {
|
||||
let ch = chars[i];
|
||||
if RE_SEPARATOR.is_match(&ch.to_string()) {
|
||||
break;
|
||||
}
|
||||
// 如果下一个字符开始能匹配到词典中的词,也停在这里
|
||||
let remaining = n - i;
|
||||
if i > start && remaining >= 1 {
|
||||
let peek_max = max_len.min(remaining);
|
||||
let mut matched = false;
|
||||
for len in (1..=peek_max).rev() {
|
||||
let peek: String = chars[i..i + len].iter().collect();
|
||||
let peek_lower = peek.to_lowercase();
|
||||
for (word, _) in &all_words {
|
||||
if word.to_lowercase() == peek_lower {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if matched { break; }
|
||||
}
|
||||
if matched { break; }
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if i > start {
|
||||
let other: String = chars[start..i].iter().collect();
|
||||
segments.push(Segment {
|
||||
text: other,
|
||||
category: "other".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 提取标签 ──
|
||||
let person_exclude = result.person_name.as_deref();
|
||||
let raw_tags = tag_extractor::extract_tags_from_title(title, person_exclude);
|
||||
let unique = tag_extractor::unique_tags(raw_tags);
|
||||
|
||||
result.tags = unique;
|
||||
result
|
||||
segments
|
||||
}
|
||||
|
||||
// ── 人名启发式判断 ──
|
||||
|
||||
/// 判断一个词是否"看起来像人名"(v4 简化版,不再查 tag_dict classify,因为 keymap 已覆盖)
|
||||
/// 返回 true = 像人名;false = 不像
|
||||
/// 判断一个词是否"看起来像人名"
|
||||
///
|
||||
/// 注意:调用方应已排除 keymap 中的非 person 类型词、停用词等。
|
||||
pub fn is_likely_person_name(name: &str) -> bool {
|
||||
let name = name.trim();
|
||||
if name.is_empty() { return false; }
|
||||
@@ -180,15 +193,18 @@ pub fn is_likely_person_name(name: &str) -> bool {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
|
||||
// 强制包含名单优先放行
|
||||
if dict.is_included_person(name) || dict.find_longest_included_match(name).is_some() {
|
||||
if dict.is_included_person(name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 字符数限制
|
||||
if name.chars().count() <= 2 { return false; }
|
||||
let char_count = name.chars().count();
|
||||
if char_count < 2 { return false; }
|
||||
|
||||
// keymap 中的词一定不是人名(已在 parse_album_title 中提前检查过,双保险)
|
||||
if dict.match_keymap(&name.to_lowercase()).is_some() { return false; }
|
||||
// keymap 中的非 person 类型词一定不是人名
|
||||
if let Some(cat) = dict.classify(name) {
|
||||
if cat != "person" { return false; }
|
||||
}
|
||||
|
||||
// 排除已知非人名
|
||||
if dict.is_excluded_person(name) { return false; }
|
||||
@@ -202,7 +218,7 @@ pub fn is_likely_person_name(name: &str) -> bool {
|
||||
let digit_count = name.chars().filter(|c| c.is_ascii_digit()).count();
|
||||
if digit_count * 2 > name.len() { return false; }
|
||||
|
||||
// 纯英文且长度 > 15 排除
|
||||
// 纯英文且过长排除
|
||||
let has_cn = RE_CHINESE.is_match(name);
|
||||
if !has_cn && name.len() > 15 { return false; }
|
||||
|
||||
@@ -224,10 +240,107 @@ pub fn is_likely_person_name(name: &str) -> bool {
|
||||
// 全大写且长度 > 6 排除(缩写/工作室名)
|
||||
if name.chars().all(|c| c.is_ascii_uppercase()) && name.len() > 6 { return false; }
|
||||
|
||||
// 中文字符数判断:2-5 个中文字符更像人名
|
||||
let cn_count: usize = name.chars().filter(|c| RE_CHINESE.is_match(&c.to_string())).count();
|
||||
if has_cn && (cn_count < 2 || cn_count > 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// ── 人名清洗 ──
|
||||
// ── 核心解析入口 ──
|
||||
|
||||
/// 统一相册标题解析入口(v5 最大匹配版本)
|
||||
///
|
||||
/// 流程:预处理 → 最大匹配切分 → 选人 → 其余为标签
|
||||
///
|
||||
/// 返回 ParseResult 可直接用于批量写库。
|
||||
pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::ParseResult {
|
||||
let mut result = crate::models::parse::ParseResult::new(album_id.to_string());
|
||||
|
||||
let title = title.trim();
|
||||
if title.is_empty() { return result; }
|
||||
|
||||
let cleaned = preprocess_title(title);
|
||||
if cleaned.is_empty() { return result; }
|
||||
|
||||
// Step 1: 最大匹配切分
|
||||
let segments = max_match_segment(&cleaned);
|
||||
|
||||
// Step 2: 从 segments 中选人(优先级最高的)
|
||||
let mut person_candidates: Vec<(String, u8)> = Vec::new(); // (name, priority)
|
||||
|
||||
for seg in &segments {
|
||||
match seg.category.as_str() {
|
||||
"person" => {
|
||||
person_candidates.push((seg.text.clone(), 0)); // 优先级 0 = 最高
|
||||
}
|
||||
"person_include" => {
|
||||
person_candidates.push((seg.text.clone(), 1)); // 优先级 1
|
||||
}
|
||||
"other" => {
|
||||
if is_likely_person_name(&seg.text) {
|
||||
person_candidates.push((seg.text.clone(), 2)); // 优先级 2 = 启发式
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级排序,取最优
|
||||
person_candidates.sort_by_key(|(_, p)| *p);
|
||||
|
||||
if let Some((name, _)) = person_candidates.first() {
|
||||
let (primary, _aliases) = split_person_name_with_aliases(name);
|
||||
let final_name = if primary.is_empty() { name.clone() } else { primary };
|
||||
|
||||
if final_name.chars().count() >= 2 {
|
||||
result.person_name = Some(final_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: 收集标签(非 person / stop / other 类的片段)
|
||||
let mut tags: Vec<(String, String)> = Vec::new();
|
||||
let person_lower = result.person_name.as_ref().map(|n| n.to_lowercase());
|
||||
|
||||
for seg in &segments {
|
||||
let cat = &seg.category;
|
||||
|
||||
// 跳过停用词
|
||||
if cat == "stop" { continue; }
|
||||
|
||||
// 跳过 person 相关
|
||||
if cat == "person" || cat == "person_include" { continue; }
|
||||
|
||||
// 如果是 other 类,检查是否被选为人名
|
||||
if cat == "other" {
|
||||
if let Some(ref pl) = person_lower {
|
||||
if seg.text.to_lowercase().contains(pl) || pl.contains(&seg.text.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// other 类也做个简单过滤:太短、纯数字等跳过
|
||||
if seg.text.chars().count() < 2 { continue; }
|
||||
if seg.text.chars().all(|c| c.is_ascii_digit()) { continue; }
|
||||
// 太短的英文 other 也跳过(噪声)
|
||||
if !RE_CHINESE.is_match(&seg.text) && seg.text.len() < 3 { continue; }
|
||||
}
|
||||
|
||||
// 有效分类标签
|
||||
let display_cat = if cat == "other" { "other".to_string() } else { cat.clone() };
|
||||
tags.push((seg.text.clone(), display_cat));
|
||||
}
|
||||
|
||||
// 去重(小写)
|
||||
let mut seen = HashSet::new();
|
||||
tags.retain(|(t, _)| seen.insert(t.to_lowercase()));
|
||||
|
||||
result.tags = tags;
|
||||
result
|
||||
}
|
||||
|
||||
// ── 人名清洗工具函数 ──
|
||||
|
||||
/// 提取中文核心(开头的连续中文字符)
|
||||
pub fn extract_cn(name: &str) -> Option<String> {
|
||||
@@ -326,7 +439,7 @@ pub fn normalize_tag(tag: &str) -> String {
|
||||
tag.trim().to_lowercase()
|
||||
}
|
||||
|
||||
// ── 兼容函数(供 pipeline / person_merger 调用)──
|
||||
// ── 兼容函数(供 pipeline / person_merger 调用) ──
|
||||
|
||||
/// 从标题中提取人物名(兼容函数)
|
||||
pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
@@ -348,6 +461,9 @@ mod tests {
|
||||
let r = parse_album_title("id1", "杨晨晨Yome 私房写真");
|
||||
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
|
||||
assert!(!r.tags.is_empty());
|
||||
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(tag_names.contains(&"私房"), "应包含 '私房' 标签");
|
||||
assert!(tag_names.contains(&"写真"), "应包含 '写真' 标签");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -355,6 +471,10 @@ mod tests {
|
||||
let r = parse_album_title("id2", "浴室 可爱 性感 写真");
|
||||
assert!(r.person_name.is_none());
|
||||
assert!(!r.tags.is_empty());
|
||||
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(tag_names.contains(&"浴室"));
|
||||
assert!(tag_names.contains(&"性感"));
|
||||
assert!(tag_names.contains(&"写真"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -363,6 +483,16 @@ mod tests {
|
||||
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_continuous_chinese() {
|
||||
// 连续中文:杨晨晨私房写真 — 应能切分出人名和标签
|
||||
let r = parse_album_title("id4", "杨晨晨私房写真");
|
||||
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
|
||||
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(tag_names.contains(&"私房"), "应包含 '私房'");
|
||||
assert!(tag_names.contains(&"写真"), "应包含 '写真'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cn() {
|
||||
assert_eq!(extract_cn("杨晨晨Yome").unwrap(), "杨晨晨");
|
||||
@@ -383,4 +513,23 @@ mod tests {
|
||||
let (name3, _) = split_person_name_with_aliases("Yome");
|
||||
assert_eq!(name3, "Yome");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_not_in_tags() {
|
||||
// 人名不应出现在标签中
|
||||
let r = parse_album_title("id5", "杨晨晨 日系 比基尼");
|
||||
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(!tag_names.contains(&"杨晨晨"), "人名 '杨晨晨' 不应出现在标签中");
|
||||
assert!(tag_names.contains(&"日系"));
|
||||
assert!(tag_names.contains(&"比基尼"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_words_removed() {
|
||||
// 停用词不应出现在标签或人名中
|
||||
let r = parse_album_title("id6", "杨晨晨 photos collection");
|
||||
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(!tag_names.contains(&"photos"), "停用词 'photos' 不应在标签中");
|
||||
assert!(!tag_names.contains(&"collection"), "停用词 'collection' 不应在标签中");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ use std::sync::Arc;
|
||||
use tokio::time::{Duration, interval};
|
||||
use tracing::{error, info};
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
@@ -75,8 +72,8 @@ async fn main() -> std::io::Result<()> {
|
||||
// ② 全量解析 + 人物合并
|
||||
info!("[启动管线] ② 全量解析 + 人物合并...");
|
||||
let result = engine::pipeline::run_pipeline(&boot_db).await;
|
||||
info!("[启动管线] 完成:清洗标点{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
|
||||
result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected,
|
||||
info!("[启动管线] 完成:清洗标点{},移除短标签{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
|
||||
result.tags_cleaned, result.short_tags_removed, result.person_like_tags_removed, result.dirty_albums_detected,
|
||||
result.non_persons_deleted, result.stop_word_persons_removed, result.new_albums_found,
|
||||
result.persons_updated, result.tags_updated,
|
||||
result.persons_merged, result.aliases_moved, result.tags_removed_person);
|
||||
|
||||
Reference in New Issue
Block a user