import type { Kysely } from "kysely"; import { execDdl, oneOf } from "../src/migration-helpers.ts"; // DEC-1 惰性识别 + 待识别池治理(REQ §4.2–4.3、§7.5、§13;对照 MP:RSS 资源只在匹配订阅 / 用户搜索时识别,识别失败不告警): // - identity_candidate.reason 新增 unmatched:本地召回无结果、也不属于任何需要 TMDB 查询的触发(订阅别名 / 新作品收录规则 / // 手动 / 搜索),只进池、不查 TMDB、不开待处理; // - resolution_attempt.outcome 同步新增 unmatched; // - identity_candidate.normalized_title:归一化标题(与 title_alias.normalized 同一口径),也是待处理合并分组键 // (来源 + 归一化标题)的一部分;title_alias 插入触发器按它唤醒相近的池条目复查(新证据到来即重试); // - 清理存量:未关闭的 identity_unresolved 事项里,资源未关联作品、池内无候选、标题也不命中任何本地别名的, // 事项关闭为 dismissed(resolution.resolution = 'moved_to_pool'),池条目改为 unmatched 并解除事项关联。 const OLD_REASONS = ["ambiguous", "no_match", "proposed", "lookup_failed"] as const; const OLD_OUTCOMES = [ "auto", "proposed", "ambiguous", "no_match", "lookup_failed", "manual", "skipped_locked", ] as const; // biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 export async function up(db: Kysely): Promise { await execDdl( db, ` alter table identity_candidate drop constraint identity_candidate_reason_check; alter table identity_candidate add constraint identity_candidate_reason_check ${oneOf("reason", [...OLD_REASONS, "unmatched"])}; alter table resolution_attempt drop constraint resolution_attempt_outcome_check; alter table resolution_attempt add constraint resolution_attempt_outcome_check ${oneOf("outcome", [...OLD_OUTCOMES, "unmatched"])}; alter table identity_candidate add column normalized_title text; update identity_candidate set normalized_title = nullif(query->>'normalized', ''); create index identity_candidate_normalized_trgm_idx on identity_candidate using gin (normalized_title gin_trgm_ops) where status = 'pending'; -- 新证据唤醒:任何路径新增别名(建档 / 手动加别名 / 合并)后,归一化标题相同或足够相近(与本地召回同阈值 0.45)的 -- unmatched / no_match 池条目立即到期复查(复查仍是本地召回优先,命中别名才查 TMDB) create function identity_pool_wake_on_alias() returns trigger language plpgsql as $$ begin if new.normalized is null or new.normalized = '' then return null; end if; update identity_candidate set next_review_at = now() where status = 'pending' and reason in ('unmatched', 'no_match') and normalized_title is not null and (next_review_at is null or next_review_at > now()) and (normalized_title = new.normalized or (length(new.normalized) >= 3 and normalized_title % new.normalized and similarity(normalized_title, new.normalized) >= 0.45)); return null; end $$; create trigger title_alias_wake_identity_pool after insert on title_alias for each row execute function identity_pool_wake_on_alias(); with moved as ( select p.id as pool_id, d.id as decision_id from identity_candidate p join source_release r on r.id = p.source_release_id join decision d on d.id = p.decision_id or d.dedupe_key = 'identity:' || p.source_release_id::text where d.kind = 'identity_unresolved' and d.status in ('open', 'investigating', 'awaiting_user') and p.status = 'pending' and r.media_entity_id is null and jsonb_array_length(p.candidates) = 0 and not exists ( select 1 from title_alias a where p.normalized_title is not null and (a.normalized = p.normalized_title or (length(p.normalized_title) >= 3 and similarity(a.normalized, p.normalized_title) >= 0.45))) ), closed as ( update decision set status = 'dismissed', resolution = jsonb_build_object('resolution', 'moved_to_pool', 'by', 'migration:0047_identity_pool'), resolved_by = 'system:identity-pool', resolved_at = now() where id in (select decision_id from moved) returning id ) update identity_candidate set reason = 'unmatched', decision_id = null, next_review_at = null where id in (select pool_id from moved); `, ); } // biome-ignore lint/suspicious/noExplicitAny: 同上 export async function down(db: Kysely): Promise { await execDdl( db, ` update identity_candidate set reason = 'no_match' where reason = 'unmatched'; update resolution_attempt set outcome = 'no_match' where outcome = 'unmatched'; drop trigger if exists title_alias_wake_identity_pool on title_alias; drop function if exists identity_pool_wake_on_alias(); drop index if exists identity_candidate_normalized_trgm_idx; alter table identity_candidate drop column normalized_title; alter table identity_candidate drop constraint identity_candidate_reason_check; alter table identity_candidate add constraint identity_candidate_reason_check ${oneOf("reason", OLD_REASONS)}; alter table resolution_attempt drop constraint resolution_attempt_outcome_check; alter table resolution_attempt add constraint resolution_attempt_outcome_check ${oneOf("outcome", OLD_OUTCOMES)}; `, ); }