feat(identity): lazy TMDB lookup and bounded identity pool (DEC-1)
RSS/list releases that match no subscription or local work no longer hit
TMDB or open a pending item; they go to the identity pool as `unmatched`.
TMDB is queried only on a local alias hit, a published new-title rule whose
prefilter passes, manual/Agent retry, search results, or when the new
setting identity.eagerTmdbLookup (default off) is on. Mirrors MoviePilot:
torrents are recognised only when matched to a subscription or searched,
and recognition failures are logged, not alerted.
- Pending items only for related releases (ambiguous after lookup, or
related to a local work but unresolved); same source + normalized title
merge into one decision ("来源「X」的 N 条资源待识别"), resolve/dismiss
applies to the whole group, members detach as they get resolved.
- Pool governance: unmatched reviews capped (daily, 3x), title_alias
insert trigger wakes similar entries, stale unmatched entries archived
after identity.poolArchiveDays (30); review idempotency keys include the
due time.
- Migration 0047_identity_pool: reason/outcome `unmatched`,
identity_candidate.normalized_title (+trgm index), wake trigger, and
cleanup of open identity_unresolved items with no related work
(dismissed, resolution=moved_to_pool).
- Inbox: group by type + work/source with collapse, hide info-only items
by default (minSeverity=warning), badge counts warning+ only; pool sheet
gets manual TMDB/local-work assign and dismiss; settings card for the
new identity settings.
- Runbook docs/runbook/identity.md.
Requires rebuild/restart (migration runs on start).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
524af64bec
commit
94fd48bff2
24 files changed
+1326
-153
No files matched your search
@@ -166,7 +166,8 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
|
||||
decisionId: null,
|
||||
};
|
||||
}
|
||||
const res = await resolveRelease(r, { sourceReleaseId: srId, force: true });
|
||||
// Agent / 用户显式要求识别:按手动触发查 TMDB(惰性识别不拦)
|
||||
const res = await resolveRelease(r, { sourceReleaseId: srId, force: true, trigger: "manual" });
|
||||
const status: "resolved" | "proposed" | "ambiguous" | "unidentifiable" =
|
||||
res.outcome === "auto" || res.outcome === "skipped" || res.outcome === "skipped_locked"
|
||||
? res.mediaEntityId
|
||||
|
||||
@@ -96,6 +96,8 @@ const TE = ["media-entities"];
|
||||
const TI = ["identity"];
|
||||
const TD = ["decisions"];
|
||||
|
||||
export const DecisionMinSeverity = z.enum(["info", "warning", "critical"]);
|
||||
|
||||
export const D = {
|
||||
sites: {
|
||||
list: {
|
||||
@@ -297,7 +299,12 @@ export const D = {
|
||||
"/decisions",
|
||||
"待处理事项(缺省只列未关闭)",
|
||||
Decision,
|
||||
{ status: DecisionStatus.optional(), kind: DecisionKind.optional() },
|
||||
{
|
||||
status: DecisionStatus.optional(),
|
||||
kind: DecisionKind.optional(),
|
||||
/** 只看不低于此级别的事项;收件箱缺省传 warning 隐藏"仅信息"类 */
|
||||
minSeverity: DecisionMinSeverity.optional(),
|
||||
},
|
||||
TD,
|
||||
),
|
||||
get: get("/decisions/{id}", "待处理事项详情", Decision, TD),
|
||||
|
||||
@@ -4,12 +4,13 @@ import { sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { D } from "../defs.ts";
|
||||
import { D, DecisionMinSeverity } from "../defs.ts";
|
||||
import { route } from "../http.ts";
|
||||
import { decodeCursor, jsonb, page, toApi, toApiList } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { audit } from "../service/common.ts";
|
||||
import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts";
|
||||
import { groupMemberIds } from "../service/identity-decisions.ts";
|
||||
|
||||
// 待处理中心数据源:decision 列表/详情/聚合/处理。
|
||||
// 身份类(歧义 / 待识别)由本模块直接执行所选候选或手动指定;其它类型只记录处理结果并发 decision.updated,
|
||||
@@ -17,6 +18,11 @@ import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-adm
|
||||
|
||||
const OPEN = ["open", "investigating", "awaiting_user"] as const;
|
||||
const T = ["decisions"];
|
||||
const SEV_AT_LEAST = {
|
||||
info: ["info", "warning", "critical"],
|
||||
warning: ["warning", "critical"],
|
||||
critical: ["critical"],
|
||||
} as const;
|
||||
|
||||
const SummaryGroup = z.object({
|
||||
kind: DecisionKind,
|
||||
@@ -36,6 +42,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
let q = db().selectFrom("decision").selectAll();
|
||||
q = query.status ? q.where("status", "=", query.status) : q.where("status", "in", [...OPEN]);
|
||||
if (query.kind) q = q.where("kind", "=", query.kind);
|
||||
if (query.minSeverity) q = q.where("severity", "in", [...SEV_AT_LEAST[query.minSeverity]]);
|
||||
const rows = await q
|
||||
.orderBy(sql`case severity when 'critical' then 0 when 'warning' then 1 else 2 end`)
|
||||
.orderBy("created_at", "desc")
|
||||
@@ -55,11 +62,12 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
scope: "read",
|
||||
summary: "未关闭待处理事项按作品与问题类型聚合",
|
||||
tags: T,
|
||||
query: z.object({ status: DecisionStatus.optional() }),
|
||||
query: z.object({ status: DecisionStatus.optional(), minSeverity: DecisionMinSeverity.optional() }),
|
||||
response: z.object({ total: z.number().int(), groups: z.array(SummaryGroup) }),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const statuses = query.status ? [query.status] : [...OPEN];
|
||||
const severities: string[] = [...SEV_AT_LEAST[query.minSeverity ?? "info"]];
|
||||
// 作品归属:evidence.mediaEntityId,或 subject 为 media_entity,或 subject 为已识别的 source_release
|
||||
const rows = await sql<{
|
||||
kind: z.infer<typeof DecisionKind>;
|
||||
@@ -75,7 +83,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
case when d.subject_type = 'media_entity' then d.subject_id end,
|
||||
(select r.media_entity_id from source_release r where d.subject_type = 'source_release' and r.id = d.subject_id)
|
||||
) as entity_id
|
||||
from decision d where d.status = any(${statuses})
|
||||
from decision d where d.status = any(${statuses}) and d.severity = any(${severities})
|
||||
)
|
||||
select d.kind, d.entity_id, e.title as entity_title, count(*)::int as count,
|
||||
max(case d.severity when 'critical' then 2 when 'warning' then 1 else 0 end)::int as max_sev,
|
||||
@@ -104,6 +112,44 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
return toApi(row);
|
||||
});
|
||||
|
||||
/** 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它 */
|
||||
async function applyToGroup(
|
||||
decisionId: string,
|
||||
subjectId: string,
|
||||
others: string[],
|
||||
dismissing: boolean,
|
||||
note: string | null,
|
||||
actor: Parameters<typeof dismissPoolEntry>[2],
|
||||
) {
|
||||
const subject = await db()
|
||||
.selectFrom("source_release")
|
||||
.select(["media_entity_id"])
|
||||
.where("id", "=", subjectId)
|
||||
.executeTakeFirst();
|
||||
if (!dismissing && !subject?.media_entity_id) return;
|
||||
for (const srId of others) {
|
||||
try {
|
||||
if (dismissing) {
|
||||
const pool = await db()
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["id"])
|
||||
.where("source_release_id", "=", srId)
|
||||
.executeTakeFirst();
|
||||
if (pool) await dismissPoolEntry(rt(), pool.id, actor, note);
|
||||
} else {
|
||||
await assignReleaseIdentity(
|
||||
rt(),
|
||||
srId,
|
||||
{ mediaEntityId: subject!.media_entity_id!, lock: true, reason: note ?? `decision ${decisionId}` },
|
||||
actor,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
rt().ctx.logger.warn({ err: e, srId, decisionId }, "合并事项组内资源处理失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route(app, D.decisions.resolve, async ({ params, body, actor }) => {
|
||||
const d = await db().selectFrom("decision").selectAll().where("id", "=", params.id).executeTakeFirst();
|
||||
if (!d) throw new ApiException("not_found", "待处理事项不存在");
|
||||
@@ -118,7 +164,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
const identityKind = d.kind === "identity_ambiguous" || d.kind === "identity_unresolved";
|
||||
if (identityKind && d.subject_type === "source_release" && d.subject_id) {
|
||||
const requires = opt?.input?.requires;
|
||||
if (body.dismiss || opt?.key === "dismiss") {
|
||||
// 合并事项(同来源同标题):先处理代表资源,再把结果套到组内其它资源
|
||||
const others = d.dedupe_key.startsWith("identity-group:") ? await groupMemberIds(db(), d.id, d.subject_id) : [];
|
||||
const dismissing = body.dismiss || opt?.key === "dismiss";
|
||||
if (dismissing) {
|
||||
const pool = await db()
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["id"])
|
||||
@@ -163,6 +212,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
actor,
|
||||
);
|
||||
}
|
||||
if (others.length) await applyToGroup(d.id, d.subject_id, others, dismissing, body.note ?? null, actor);
|
||||
}
|
||||
// 站点登录需人工验证:"重试登录" = 入队刷新登录(结果另行更新账号状态与待处理)
|
||||
if (d.dedupe_key?.startsWith("site-login:") && opt?.key === "retry") {
|
||||
|
||||
@@ -628,7 +628,8 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
if (!pool) throw new ApiException("not_found", "待识别条目不存在");
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"resolveObservation",
|
||||
{ sourceReleaseId: pool.source_release_id, force: true },
|
||||
// 用户点"重新识别" = 手动触发:即使不相关也查 TMDB(惰性识别的 c 路径)
|
||||
{ sourceReleaseId: pool.source_release_id, force: true, trigger: "manual" },
|
||||
{ idempotencyKey: `resolve:retry:${params.id}:${Math.floor(Date.now() / 10_000)}`, traceId: randomUUID() },
|
||||
);
|
||||
return { taskId: r.runId };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "@umt/db";
|
||||
import type { SourcesRuntime } from "./runtime.ts";
|
||||
import { archiveStalePool } from "./service/identity-decisions.ts";
|
||||
|
||||
// worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、
|
||||
// 按待识别池 next_review_at 入队复查。幂等键按时间槽生成,多 worker 并发也不会重复。
|
||||
@@ -86,13 +87,17 @@ export async function tickSnapshots(rt: SourcesRuntime): Promise<number> {
|
||||
return sites.rows.length;
|
||||
}
|
||||
|
||||
/** 待识别池复查:到期的先顺延一个租约,避免下个 tick 重复入队 */
|
||||
/**
|
||||
* 待识别池复查:到期的先顺延一个租约,避免下个 tick 重复入队;顺带(每小时至多一次)归档过期的 unmatched 条目。
|
||||
* 幂等键带上到期时间:别名触发器唤醒 / 证据版本变化后计数归 1 时,不会撞上旧的复查键而被吞掉。
|
||||
*/
|
||||
export async function tickReviews(rt: SourcesRuntime): Promise<number> {
|
||||
const { ctx } = rt;
|
||||
const now = rt.now();
|
||||
await archiveStalePool(rt);
|
||||
const reviews = await ctx.db.transaction().execute(async (trx) => {
|
||||
const rows = await sql<{ id: string; source_release_id: string; attempts: number }>`
|
||||
select id, source_release_id, attempts from identity_candidate
|
||||
const rows = await sql<{ id: string; source_release_id: string; attempts: number; due: Date }>`
|
||||
select id, source_release_id, attempts, next_review_at as due from identity_candidate
|
||||
where status = 'pending' and next_review_at is not null and next_review_at <= ${now}
|
||||
order by next_review_at limit 50 for update skip locked`.execute(trx);
|
||||
for (const r of rows.rows) {
|
||||
@@ -108,7 +113,7 @@ export async function tickReviews(rt: SourcesRuntime): Promise<number> {
|
||||
await ctx.engine.enqueue(
|
||||
"resolveObservation",
|
||||
{ sourceReleaseId: r.source_release_id, force: true },
|
||||
{ idempotencyKey: `review:${r.id}:${r.attempts}`, traceId: randomUUID() },
|
||||
{ idempotencyKey: `review:${r.id}:${r.attempts}:${new Date(r.due).getTime()}`, traceId: randomUUID() },
|
||||
);
|
||||
}
|
||||
return reviews.length;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { toParsedColumns } from "@umt/mapping";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, closeDecisions, type Exec } from "./common.ts";
|
||||
import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import {
|
||||
acceptIdentity,
|
||||
createEntityFromTmdb,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
looksLikeVariety,
|
||||
resolveEntityId,
|
||||
} from "./entities.ts";
|
||||
import { detachFromIdentityDecisions } from "./identity-decisions.ts";
|
||||
import { recordAttempt } from "./resolve.ts";
|
||||
import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||
|
||||
@@ -149,10 +150,10 @@ export async function assignReleaseIdentity(
|
||||
})
|
||||
.where("source_release_id", "=", srId)
|
||||
.execute();
|
||||
await closeDecisions(
|
||||
await detachFromIdentityDecisions(
|
||||
ctx,
|
||||
trx,
|
||||
`identity:${srId}`,
|
||||
srId,
|
||||
"resolved",
|
||||
{ mediaEntityId: entityId, by: "manual" },
|
||||
actorName(actor),
|
||||
@@ -468,7 +469,7 @@ export async function dismissPoolEntry(rt: SourcesRuntime, poolId: string, actor
|
||||
.where("id", "=", row.source_release_id)
|
||||
.where("resolution_locked", "=", false)
|
||||
.execute();
|
||||
await closeDecisions(ctx, trx, `identity:${row.source_release_id}`, "dismissed", { note }, actorName(actor));
|
||||
await detachFromIdentityDecisions(ctx, trx, row.source_release_id, "dismissed", { note }, actorName(actor));
|
||||
await audit(trx, actor, "identity.pool_dismissed", { type: "identity_candidate", id: poolId }, { note });
|
||||
return row;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { DecisionOption, PoolCandidate } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { closeDecisions, type Exec, openDecision } from "./common.ts";
|
||||
|
||||
// DEC-1 身份类待处理的合并与池治理(REQ §7.5、§13):
|
||||
// - 只有与订阅 / 本地作品相关(本地别名命中)或已查过 TMDB 仍有歧义的资源才开事项;
|
||||
// - 同一来源 + 同一归一化标题合并为一条事项(dedupeKey identity-group:<siteId>:<normalized>),成员 = 池里
|
||||
// decision_id 指向它且仍 pending 的条目;事项的 subject 与候选选项取自代表资源(最近一次进来的那条);
|
||||
// - 成员被识别 / 指定 / 忽略时从组里摘除,组空了才关闭事项;
|
||||
// - unmatched 池条目超过 identity.poolArchiveDays 没有新证据就归档。
|
||||
|
||||
const OPEN = ["open", "investigating", "awaiting_user"] as const;
|
||||
/** 事项证据里最多列多少条成员资源(完整列表在待识别池按标题检索) */
|
||||
export const GROUP_LIST_LIMIT = 50;
|
||||
|
||||
export const groupDedupeKey = (siteId: string, groupKey: string) => `identity-group:${siteId}:${groupKey}`;
|
||||
|
||||
/** 身份类事项的手动指定选项;resolve 时按 input.requires 取 DecisionResolveInput.identity 的对应字段 */
|
||||
function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] {
|
||||
return [
|
||||
{
|
||||
key: "manual_tmdb",
|
||||
label: "手动指定 TMDB ID",
|
||||
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
|
||||
input: { sourceReleaseId, requires: "tmdb" },
|
||||
},
|
||||
{
|
||||
key: "local_entity",
|
||||
label: "选本地作品",
|
||||
description: "关联到已有的本地作品,加人工锁",
|
||||
input: { sourceReleaseId, requires: "mediaEntityId" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function identityOptions(sourceReleaseId: string, candidates: readonly PoolCandidate[]): DecisionOption[] {
|
||||
return [
|
||||
...candidates.slice(0, 5).map((c, i) => ({
|
||||
key: `c${i}`,
|
||||
label: `${c.title}${c.year ? ` (${c.year})` : ""} · ${c.localEntityId ? "本地作品" : `${c.provider}:${c.entityType}/${c.externalId}`}`,
|
||||
input: { sourceReleaseId, candidateIndex: i },
|
||||
})),
|
||||
...manualIdentityOptions(sourceReleaseId),
|
||||
{ key: "dismiss", label: "忽略此资源" },
|
||||
];
|
||||
}
|
||||
|
||||
interface Member {
|
||||
source_release_id: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
candidates: unknown;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
async function members(exec: Exec, decisionId: string, exclude: string | null): Promise<Member[]> {
|
||||
const rows = await sql<Member>`
|
||||
select p.source_release_id, r.title, p.reason, p.candidates, p.updated_at
|
||||
from identity_candidate p join source_release r on r.id = p.source_release_id
|
||||
where p.decision_id = ${decisionId} and p.status = 'pending'
|
||||
and (${exclude}::uuid is null or p.source_release_id <> ${exclude}::uuid)
|
||||
order by p.updated_at desc, p.source_release_id`.execute(exec);
|
||||
return rows.rows;
|
||||
}
|
||||
|
||||
/** 合并事项的成员资源 ID(不含 subject 本身时传 exclude) */
|
||||
export async function groupMemberIds(exec: Exec, decisionId: string, exclude: string | null = null) {
|
||||
return (await members(exec, decisionId, exclude)).map((m) => m.source_release_id);
|
||||
}
|
||||
|
||||
function titleFor(
|
||||
kind: "identity_ambiguous" | "identity_unresolved",
|
||||
siteName: string,
|
||||
n: number,
|
||||
one: string,
|
||||
work: string | null,
|
||||
) {
|
||||
const what = kind === "identity_ambiguous" ? "待确认身份" : "待识别";
|
||||
if (n <= 1) return `${what}:${one}`;
|
||||
return `来源「${siteName}」的 ${n} 条资源${what}${work ? `:${work}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前成员重写合并事项的标题 / 摘要 / 成员清单;代表资源 = representative(缺省取最近更新的成员)。
|
||||
* 组内没有成员时关闭事项并返回 false。
|
||||
*/
|
||||
export async function refreshGroupDecision(
|
||||
ctx: ServerContext,
|
||||
trx: Exec,
|
||||
decisionId: string,
|
||||
opts: {
|
||||
representative?: string | null;
|
||||
exclude?: string | null;
|
||||
closeAs?: { status: "resolved" | "dismissed"; resolution: Record<string, unknown>; by: string };
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
const d = await trx
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("id", "=", decisionId)
|
||||
.where("status", "in", [...OPEN])
|
||||
.executeTakeFirst();
|
||||
if (!d) return false;
|
||||
const list = await members(trx, decisionId, opts.exclude ?? null);
|
||||
if (!list.length) {
|
||||
const close = opts.closeAs ?? { status: "resolved" as const, resolution: { by: "group_empty" }, by: "system" };
|
||||
await closeDecisions(ctx, trx, d.dedupe_key, close.status, close.resolution, close.by);
|
||||
return false;
|
||||
}
|
||||
const rep = list.find((m) => m.source_release_id === opts.representative) ?? list[0]!;
|
||||
const ev = (d.evidence ?? {}) as Record<string, unknown>;
|
||||
const group = (ev.group ?? {}) as { siteName?: string; title?: string | null };
|
||||
const kind = (d.kind === "identity_ambiguous" ? "identity_ambiguous" : "identity_unresolved") as
|
||||
| "identity_ambiguous"
|
||||
| "identity_unresolved";
|
||||
const candidates = (Array.isArray(rep.candidates) ? rep.candidates : []) as PoolCandidate[];
|
||||
const title = titleFor(kind, group.siteName ?? "未知来源", list.length, rep.title, group.title ?? null);
|
||||
await trx
|
||||
.updateTable("decision")
|
||||
.set({
|
||||
title,
|
||||
subject_type: "source_release",
|
||||
subject_id: rep.source_release_id,
|
||||
options: jsonb(identityOptions(rep.source_release_id, candidates)),
|
||||
evidence: jsonb({
|
||||
...ev,
|
||||
sourceReleaseId: rep.source_release_id,
|
||||
count: list.length,
|
||||
releases: list
|
||||
.slice(0, GROUP_LIST_LIMIT)
|
||||
.map((m) => ({ sourceReleaseId: m.source_release_id, title: m.title })),
|
||||
}),
|
||||
})
|
||||
.where("id", "=", decisionId)
|
||||
.execute();
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface GroupDecisionInput {
|
||||
kind: "identity_ambiguous" | "identity_unresolved";
|
||||
siteId: string;
|
||||
/** 分组键:归一化标题;解析不出标题时用资源 ID(不合并) */
|
||||
groupKey: string;
|
||||
/** 分组展示用的作品标题(解析标题) */
|
||||
workTitle: string | null;
|
||||
sourceReleaseId: string;
|
||||
releaseTitle: string;
|
||||
summary: string;
|
||||
candidates: PoolCandidate[];
|
||||
evidence: Record<string, unknown>;
|
||||
nextReviewAt: Date | null;
|
||||
}
|
||||
|
||||
/** 开 / 并入合并事项;返回事项 ID(调用方随后把池条目 decision_id 指向它,再 refreshGroupDecision) */
|
||||
export async function openGroupDecision(ctx: ServerContext, trx: Exec, g: GroupDecisionInput): Promise<string> {
|
||||
const site = await trx.selectFrom("site").select(["name"]).where("id", "=", g.siteId).executeTakeFirst();
|
||||
const d = await openDecision(ctx, trx, {
|
||||
kind: g.kind,
|
||||
// 与订阅 / 本地作品相关的身份问题会卡住追更,不是"仅信息"
|
||||
severity: "warning",
|
||||
title: titleFor(g.kind, site?.name ?? "未知来源", 1, g.releaseTitle, g.workTitle),
|
||||
summary: g.summary,
|
||||
subjectType: "source_release",
|
||||
subjectId: g.sourceReleaseId,
|
||||
dedupeKey: groupDedupeKey(g.siteId, g.groupKey),
|
||||
evidence: {
|
||||
...g.evidence,
|
||||
group: { siteId: g.siteId, siteName: site?.name ?? null, key: g.groupKey, title: g.workTitle },
|
||||
},
|
||||
options: identityOptions(g.sourceReleaseId, g.candidates),
|
||||
nextReviewAt: g.nextReviewAt,
|
||||
});
|
||||
return d.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源已识别 / 已指定 / 已忽略:关闭它的旧式单条事项(identity:<id>),并从合并事项里摘除;组空了才关闭合并事项。
|
||||
*/
|
||||
export async function detachFromIdentityDecisions(
|
||||
ctx: ServerContext,
|
||||
trx: Exec,
|
||||
srId: string,
|
||||
status: "resolved" | "dismissed",
|
||||
resolution: Record<string, unknown>,
|
||||
by: string,
|
||||
): Promise<void> {
|
||||
await closeDecisions(ctx, trx, `identity:${srId}`, status, resolution, by);
|
||||
const pool = await trx
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["decision_id"])
|
||||
.where("source_release_id", "=", srId)
|
||||
.executeTakeFirst();
|
||||
if (!pool?.decision_id) return;
|
||||
const d = await trx
|
||||
.selectFrom("decision")
|
||||
.select(["id", "dedupe_key"])
|
||||
.where("id", "=", pool.decision_id)
|
||||
.where("status", "in", [...OPEN])
|
||||
.executeTakeFirst();
|
||||
if (!d?.dedupe_key.startsWith("identity-group:")) return;
|
||||
await refreshGroupDecision(ctx, trx, d.id, { exclude: srId, closeAs: { status, resolution, by } });
|
||||
}
|
||||
|
||||
const ARCHIVE_EVERY_MS = 3600_000;
|
||||
let lastArchiveAt = 0;
|
||||
|
||||
/**
|
||||
* 过期归档:unmatched 池条目超过 identity.poolArchiveDays 没有新证据 → archived(仍可按状态筛出、可手动指定;
|
||||
* 同一资源再被观察到或新增相近别名时回到 pending)。每进程每小时最多执行一次。
|
||||
*/
|
||||
export async function archiveStalePool(rt: SourcesRuntime, opts: { force?: boolean } = {}): Promise<number> {
|
||||
const now = rt.now();
|
||||
if (!opts.force && now.getTime() - lastArchiveAt < ARCHIVE_EVERY_MS) return 0;
|
||||
lastArchiveAt = now.getTime();
|
||||
const { poolArchiveDays } = await rt.ctx.settings.get("identity");
|
||||
const cutoff = new Date(now.getTime() - poolArchiveDays * 86_400_000);
|
||||
const r = await rt.ctx.db
|
||||
.updateTable("identity_candidate")
|
||||
.set({ status: "archived", next_review_at: null })
|
||||
.where("status", "=", "pending")
|
||||
.where("reason", "=", "unmatched")
|
||||
.where((eb) => eb(eb.fn.coalesce("last_attempt_at", "created_at"), "<", cutoff))
|
||||
.executeTakeFirst();
|
||||
return Number(r.numUpdatedRows ?? 0);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "@umt/mapping";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
|
||||
import { type Actor, closeDecisions, type Exec, SYSTEM } from "./common.ts";
|
||||
import {
|
||||
addAliasIfAbsent,
|
||||
createEntityFromTmdb,
|
||||
@@ -32,12 +32,21 @@ import {
|
||||
looksLikeVariety,
|
||||
resolveEntityId,
|
||||
} from "./entities.ts";
|
||||
import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts";
|
||||
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||
|
||||
// resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ TMDB 候选 → domain.resolveIdentity。
|
||||
// resolveObservation:parseRelease → 本地召回(别名 pg_trgm + 已有外部身份 + 人工锁)→ [按需] TMDB 候选 → domain.resolveIdentity。
|
||||
// auto 才建/关联正式作品;ambiguous / no_match / 查询失败进待识别池(候选只存 JSON),有界退避复查。
|
||||
//
|
||||
// DEC-1 惰性识别(对照 MP:RSS / 站点资源只在匹配订阅或用户搜索时识别,识别失败不告警):TMDB 只在 lookupTrigger
|
||||
// 给出理由时查询——命中本地作品别名(订阅 / 追踪 / 已登记的作品)、已发布的新作品收录规则前置条件通过、用户手动识别、
|
||||
// 搜索结果、池条目此前已查过 TMDB(复查延续),或设置 identity.eagerTmdbLookup 打开。其余只进池(reason=unmatched),
|
||||
// 不开待处理。待处理只收"相关"的:与本地作品相关却无法识别,或查过 TMDB 仍有歧义;同来源同标题合并为一条。
|
||||
|
||||
const MAX_REVIEW_ATTEMPTS = 8;
|
||||
/** unmatched(只做了本地召回)的周期复查:每天一次、最多 3 次;新增相近别名时由触发器立即唤醒,不受此上限 */
|
||||
const UNMATCHED_REVIEW_MS = 24 * 3600_000;
|
||||
const MAX_UNMATCHED_REVIEWS = 3;
|
||||
const TRGM_THRESHOLD = 0.45;
|
||||
|
||||
export interface ResolveInput {
|
||||
@@ -45,10 +54,26 @@ export interface ResolveInput {
|
||||
sourceReleaseId?: string | undefined;
|
||||
/** 复查/手动重试:忽略"已识别且证据未变"的短路 */
|
||||
force?: boolean | undefined;
|
||||
/**
|
||||
* 谁触发的识别,决定是否查 TMDB(惰性识别):observed = 新观察到的资源(缺省);review = 池周期复查(force 缺省);
|
||||
* manual = 用户在资源列表 / 待识别池点开或重新识别、Agent 调 identity.resolve;search = 搜索结果。
|
||||
* manual / search 总是查 TMDB;observed / review 只在命中本地别名、新作品收录规则或设置打开时查。
|
||||
*/
|
||||
trigger?: ResolveTrigger | undefined;
|
||||
}
|
||||
|
||||
export type ResolveTrigger = "observed" | "review" | "manual" | "search";
|
||||
|
||||
export interface ResolveResult {
|
||||
outcome: "auto" | "proposed" | "ambiguous" | "no_match" | "lookup_failed" | "skipped_locked" | "skipped";
|
||||
outcome:
|
||||
| "auto"
|
||||
| "proposed"
|
||||
| "ambiguous"
|
||||
| "no_match"
|
||||
| "lookup_failed"
|
||||
| "unmatched"
|
||||
| "skipped_locked"
|
||||
| "skipped";
|
||||
sourceReleaseId: string | null;
|
||||
mediaEntityId: string | null;
|
||||
contentUnitIds: string[];
|
||||
@@ -251,12 +276,22 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
}
|
||||
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
|
||||
const cols = toParsedColumns(parsed);
|
||||
const trigger: ResolveTrigger = input.trigger ?? (input.force ? "review" : "observed");
|
||||
if (!parsed.title || parsed.auxiliaryOnly) {
|
||||
return toPool(rt, release, observationId, parsed, null, [], "no_match", "unparseable_title", null);
|
||||
return toPool(rt, release, observationId, parsed, null, [], "unmatched", "unparseable_title", null, {
|
||||
tracked: false,
|
||||
lookup: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 召回:本地优先,再 TMDB;TMDB 失败不等于无结果
|
||||
// 召回:本地优先;TMDB 只在有理由时查(惰性识别),查询失败不等于无结果
|
||||
const local = await localCandidates(ctx.db, parsed, release);
|
||||
const lookup = await lookupReason(rt, parsed, trigger, local.length > 0, pool?.reason ?? null);
|
||||
const scope = { tracked: local.length > 0, lookup };
|
||||
if (!lookup) {
|
||||
// 不相关的资源:只进池(可检索、可手动指定),不查 TMDB、不开待处理
|
||||
return toPool(rt, release, observationId, parsed, null, [], "unmatched", "no_local_match", null, scope);
|
||||
}
|
||||
let remote: MetadataCandidate[] = [];
|
||||
let lookupError: string | null = null;
|
||||
let recognize: { cacheHit: boolean; meta: RecognizeMeta | null } = { cacheHit: false, meta: null };
|
||||
@@ -321,7 +356,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
}
|
||||
}
|
||||
}
|
||||
return bindAuto(rt, release, observationId, parsed, cols, resolution, version, detailed ?? null);
|
||||
return bindAuto(rt, release, observationId, parsed, cols, resolution, version, detailed ?? null, lookup);
|
||||
}
|
||||
|
||||
if (resolution.status === "auto" && !autoAccept) {
|
||||
@@ -335,6 +370,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
"proposed",
|
||||
"automation_off",
|
||||
version,
|
||||
scope,
|
||||
);
|
||||
}
|
||||
if (lookupError && resolution.status !== "ambiguous") {
|
||||
@@ -348,6 +384,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
"lookup_failed",
|
||||
lookupError,
|
||||
version,
|
||||
scope,
|
||||
);
|
||||
}
|
||||
return toPool(
|
||||
@@ -360,14 +397,60 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
resolution.status === "ambiguous" ? "ambiguous" : "no_match",
|
||||
resolution.reason,
|
||||
version,
|
||||
scope,
|
||||
);
|
||||
}
|
||||
|
||||
/** 为什么要查 TMDB;null = 不查(惰性识别) */
|
||||
export type LookupReason = "tracked_alias" | "manual" | "search" | "previously_looked_up" | "eager" | "new_title_rule";
|
||||
|
||||
async function lookupReason(
|
||||
rt: SourcesRuntime,
|
||||
parsed: ParsedRelease,
|
||||
trigger: ResolveTrigger,
|
||||
tracked: boolean,
|
||||
poolReason: string | null,
|
||||
): Promise<LookupReason | null> {
|
||||
if (trigger === "manual") return "manual";
|
||||
if (trigger === "search") return "search";
|
||||
if (tracked) return "tracked_alias";
|
||||
// 池条目此前已查过 TMDB(旧行为 / 手动 / 规则触发):复查延续,仍受识别缓存与有界退避约束
|
||||
if (trigger === "review" && poolReason && poolReason !== "unmatched") return "previously_looked_up";
|
||||
if ((await rt.ctx.settings.get("identity")).eagerTmdbLookup) return "eager";
|
||||
if (await passesNewTitlePrefilter(rt, parsed)) return "new_title_rule";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新作品收录规则的前置硬条件:有已发布且开启 newTitle 的获取策略(并有有效持续授权),资源的类型落在规则的
|
||||
* mediaKinds 内,标题年份(若有)不早于窗口能覆盖的年份。通过才值得查 TMDB 拿首播 / 上映日期交给评估判断。
|
||||
*/
|
||||
async function passesNewTitlePrefilter(rt: SourcesRuntime, parsed: ParsedRelease): Promise<boolean> {
|
||||
const rows = await sql<{ rules: unknown }>`
|
||||
select p.rules from policy_revision p
|
||||
where p.kind = 'acquisition' and p.status = 'published'
|
||||
and coalesce((p.rules->'newTitle'->>'enabled')::boolean, false)
|
||||
and exists (select 1 from automation_grant g where g.policy_revision_id = p.id and g.status = 'active')`.execute(
|
||||
rt.ctx.db,
|
||||
);
|
||||
if (!rows.rows.length) return false;
|
||||
const tvLike = parsed.mediaTypeHint === "tv" || parsed.season !== undefined || parsed.episodes !== undefined;
|
||||
const nowYear = rt.now().getUTCFullYear();
|
||||
return rows.rows.some((r) => {
|
||||
const nt = ((r.rules ?? {}) as { newTitle?: { windowDays?: number; mediaKinds?: string[] } }).newTitle ?? {};
|
||||
const kinds = nt.mediaKinds ?? [];
|
||||
if (kinds.length && !kinds.some((k) => (tvLike ? k === "series" || k === "variety" : k === "movie"))) return false;
|
||||
// 与评估同一缺省(acquisition-organize ports.newTitleWindowDays = 30)
|
||||
const windowYears = Math.ceil((nt.windowDays ?? 30) / 365);
|
||||
return parsed.year === undefined || parsed.year >= nowYear - windowYears;
|
||||
});
|
||||
}
|
||||
|
||||
async function recordAttempt(
|
||||
exec: Exec,
|
||||
srId: string,
|
||||
observationId: string | null,
|
||||
outcome: "auto" | "proposed" | "ambiguous" | "no_match" | "lookup_failed" | "manual" | "skipped_locked",
|
||||
outcome: "auto" | "proposed" | "ambiguous" | "no_match" | "lookup_failed" | "unmatched" | "manual" | "skipped_locked",
|
||||
reason: string | null,
|
||||
version: string,
|
||||
count: number,
|
||||
@@ -407,6 +490,7 @@ async function bindAuto(
|
||||
resolution: IdentityResolution,
|
||||
version: string,
|
||||
tmdbDetail: MetadataCandidate | null,
|
||||
lookup: LookupReason,
|
||||
): Promise<ResolveResult> {
|
||||
const { ctx } = rt;
|
||||
const best = resolution.best!;
|
||||
@@ -458,6 +542,7 @@ async function bindAuto(
|
||||
evidenceVersion: version,
|
||||
best: rankedSummary(best),
|
||||
candidateCount: resolution.ranked.length,
|
||||
lookup,
|
||||
};
|
||||
await trx
|
||||
.updateTable("source_release")
|
||||
@@ -484,10 +569,10 @@ async function bindAuto(
|
||||
.where("source_release_id", "=", release.id)
|
||||
.where("status", "=", "pending")
|
||||
.execute();
|
||||
await closeDecisions(
|
||||
await detachFromIdentityDecisions(
|
||||
ctx,
|
||||
trx,
|
||||
`identity:${release.id}`,
|
||||
release.id,
|
||||
"resolved",
|
||||
{ mediaEntityId: entityId, by: "auto" },
|
||||
"system",
|
||||
@@ -532,29 +617,12 @@ async function bindAuto(
|
||||
}
|
||||
|
||||
function reviewDelayMs(reason: string, attempts: number): number | null {
|
||||
if (reason === "unmatched") return attempts >= MAX_UNMATCHED_REVIEWS ? null : UNMATCHED_REVIEW_MS;
|
||||
if (attempts >= MAX_REVIEW_ATTEMPTS) return null;
|
||||
const base = reason === "lookup_failed" ? 15 * 60_000 : 3600_000;
|
||||
return Math.min(base * 2 ** Math.max(0, attempts - 1), 7 * 24 * 3600_000);
|
||||
}
|
||||
|
||||
/** 身份类事项的手动指定选项;resolve 时按 input.requires 取 DecisionResolveInput.identity 的对应字段 */
|
||||
function manualIdentityOptions(sourceReleaseId: string) {
|
||||
return [
|
||||
{
|
||||
key: "manual_tmdb",
|
||||
label: "手动指定 TMDB ID",
|
||||
description: "按 TMDB 电影或剧集 ID 建档并关联,加人工锁",
|
||||
input: { sourceReleaseId, requires: "tmdb" },
|
||||
},
|
||||
{
|
||||
key: "local_entity",
|
||||
label: "选本地作品",
|
||||
description: "关联到已有的本地作品,加人工锁",
|
||||
input: { sourceReleaseId, requires: "mediaEntityId" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 事项摘要里给人看的原因;原始代码仍记在 resolution_evidence */
|
||||
const DETAIL_TEXT: Record<string, string> = {
|
||||
no_viable_candidate: "没有可信候选",
|
||||
@@ -562,9 +630,18 @@ const DETAIL_TEXT: Record<string, string> = {
|
||||
weak_evidence: "证据不足",
|
||||
multiple_candidates_same_level: "多个候选同级",
|
||||
automation_off: "自动化已关闭",
|
||||
no_local_match: "没有命中订阅或本地作品,未查询 TMDB",
|
||||
};
|
||||
const detailText = (d: string) => DETAIL_TEXT[d] ?? d;
|
||||
|
||||
type PoolReason = "ambiguous" | "no_match" | "proposed" | "lookup_failed" | "unmatched";
|
||||
|
||||
/** 这次识别的相关性:tracked = 本地别名命中(订阅 / 追踪 / 已登记作品);lookup = 查 TMDB 的理由(null = 未查) */
|
||||
interface ResolveScope {
|
||||
tracked: boolean;
|
||||
lookup: LookupReason | null;
|
||||
}
|
||||
|
||||
async function toPool(
|
||||
rt: SourcesRuntime,
|
||||
release: SourceReleaseRow,
|
||||
@@ -572,9 +649,10 @@ async function toPool(
|
||||
parsed: ParsedRelease,
|
||||
resolution: IdentityResolution | null,
|
||||
ranked: RankedCandidate[],
|
||||
reason: "ambiguous" | "no_match" | "proposed" | "lookup_failed",
|
||||
reason: PoolReason,
|
||||
detail: string,
|
||||
versionIn: string | null,
|
||||
scope: ResolveScope,
|
||||
): Promise<ResolveResult> {
|
||||
const { ctx } = rt;
|
||||
const version = versionIn ?? evidenceVersion(parsed, ranked);
|
||||
@@ -584,14 +662,16 @@ async function toPool(
|
||||
.slice(0, 10)
|
||||
.map(poolCandidate);
|
||||
const now = rt.now();
|
||||
const normalized = parsed.title ? normalizeTitle(parsed.title) || null : null;
|
||||
const query = {
|
||||
title: parsed.title ?? null,
|
||||
titleCn: parsed.titleCn ?? null,
|
||||
titleEn: parsed.titleEn ?? null,
|
||||
normalized: parsed.title ? normalizeTitle(parsed.title) : null,
|
||||
normalized,
|
||||
year: parsed.year ?? null,
|
||||
season: parsed.season ?? null,
|
||||
episodes: parsed.episodes ?? null,
|
||||
lookup: scope.lookup,
|
||||
};
|
||||
const contractStatus =
|
||||
reason === "proposed"
|
||||
@@ -603,7 +683,7 @@ async function toPool(
|
||||
return ctx.db.transaction().execute(async (trx) => {
|
||||
const prev = await trx
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["id", "attempts", "evidence_version"])
|
||||
.select(["id", "attempts", "evidence_version", "decision_id"])
|
||||
.where("source_release_id", "=", release.id)
|
||||
.executeTakeFirst();
|
||||
// 证据版本变化 = 新证据:从头计数;同一证据反复复查按退避递增,达上限停止自动复查(仍可检索)
|
||||
@@ -611,7 +691,6 @@ async function toPool(
|
||||
const delay = reviewDelayMs(reason, attempts);
|
||||
const nextReviewAt = delay === null ? null : new Date(now.getTime() + delay);
|
||||
|
||||
let decisionId: string | null = null;
|
||||
const evidence = {
|
||||
sourceReleaseId: release.id,
|
||||
siteId: release.site_id,
|
||||
@@ -620,64 +699,54 @@ async function toPool(
|
||||
evidenceVersion: version,
|
||||
query,
|
||||
};
|
||||
if ((reason === "ambiguous" || reason === "proposed") && candidates.length > 0) {
|
||||
const d = await openDecision(ctx, trx, {
|
||||
kind: "identity_ambiguous",
|
||||
severity: "info",
|
||||
title: `待确认身份:${release.title}`,
|
||||
summary: `${candidates.length} 个候选证据等级相近(${detailText(detail)})。选择一个或忽略`,
|
||||
subjectType: "source_release",
|
||||
subjectId: release.id,
|
||||
dedupeKey: `identity:${release.id}`,
|
||||
// 只收相关的:查过 TMDB 仍有歧义(有候选可选),或与本地作品相关却无结果 / 查询失败且复查已停
|
||||
const wantsDecision =
|
||||
reason === "ambiguous" || reason === "proposed"
|
||||
? candidates.length > 0
|
||||
: scope.tracked && (reason === "no_match" || (reason === "lookup_failed" && delay === null));
|
||||
let decisionId: string | null = null;
|
||||
if (wantsDecision) {
|
||||
const ambiguous = (reason === "ambiguous" || reason === "proposed") && candidates.length > 0;
|
||||
decisionId = await openGroupDecision(ctx, trx, {
|
||||
kind: ambiguous ? "identity_ambiguous" : "identity_unresolved",
|
||||
siteId: release.site_id,
|
||||
groupKey: normalized ?? release.id,
|
||||
workTitle: parsed.title ?? null,
|
||||
sourceReleaseId: release.id,
|
||||
releaseTitle: release.title,
|
||||
summary: ambiguous
|
||||
? `${candidates.length} 个候选证据等级相近(${detailText(detail)})。选择一个或忽略;同组资源一并处理`
|
||||
: reason === "lookup_failed"
|
||||
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略;同组资源一并处理`
|
||||
: `标题与本地作品相关,但没有匹配的作品(${detailText(detail)})。手动指定 TMDB ID、选本地作品或忽略;同组资源一并处理`,
|
||||
candidates,
|
||||
evidence,
|
||||
options: [
|
||||
...candidates.slice(0, 5).map((c, i) => ({
|
||||
key: `c${i}`,
|
||||
label: `${c.title}${c.year ? ` (${c.year})` : ""} · ${c.localEntityId ? "本地作品" : `${c.provider}:${c.entityType}/${c.externalId}`}`,
|
||||
input: { sourceReleaseId: release.id, candidateIndex: i },
|
||||
})),
|
||||
...manualIdentityOptions(release.id),
|
||||
{ key: "dismiss", label: "忽略此资源" },
|
||||
],
|
||||
nextReviewAt,
|
||||
});
|
||||
decisionId = d.id;
|
||||
} else if (reason !== "lookup_failed" || delay === null) {
|
||||
// 没有可选候选(无结果 / 无法解析 / 查询失败且自动复查已停):只能人工指定,同样进待处理
|
||||
const d = await openDecision(ctx, trx, {
|
||||
kind: "identity_unresolved",
|
||||
severity: "info",
|
||||
title: `待识别:${release.title}`,
|
||||
summary:
|
||||
reason === "lookup_failed"
|
||||
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
|
||||
: `没有匹配的作品(${detailText(detail)})。手动指定 TMDB ID、选本地作品或忽略`,
|
||||
subjectType: "source_release",
|
||||
subjectId: release.id,
|
||||
dedupeKey: `identity:${release.id}`,
|
||||
evidence,
|
||||
options: [
|
||||
...manualIdentityOptions(release.id),
|
||||
{
|
||||
key: "dismiss",
|
||||
label: "忽略此资源",
|
||||
},
|
||||
],
|
||||
nextReviewAt,
|
||||
});
|
||||
decisionId = d.id;
|
||||
} else if (prev?.decision_id) {
|
||||
// 不再需要人工处理(例如别名删除后变得不相关):从原合并事项里摘除
|
||||
await detachFromIdentityDecisions(ctx, trx, release.id, "resolved", { by: "no_longer_relevant" }, "system");
|
||||
}
|
||||
|
||||
await sql`
|
||||
insert into identity_candidate (source_release_id, status, reason, query, candidates, evidence_version, attempts,
|
||||
last_attempt_at, next_review_at, decision_id)
|
||||
values (${release.id}, 'pending', ${reason}, ${jsonb(query)}::jsonb, ${jsonb(candidates)}::jsonb, ${version}, ${attempts},
|
||||
${now}, ${nextReviewAt}, ${decisionId})
|
||||
insert into identity_candidate (source_release_id, status, reason, query, normalized_title, candidates, evidence_version,
|
||||
attempts, last_attempt_at, next_review_at, decision_id)
|
||||
values (${release.id}, 'pending', ${reason}, ${jsonb(query)}::jsonb, ${normalized}, ${jsonb(candidates)}::jsonb, ${version},
|
||||
${attempts}, ${now}, ${nextReviewAt}, ${decisionId})
|
||||
on conflict (source_release_id) do update set
|
||||
status = 'pending', reason = excluded.reason, query = excluded.query, candidates = excluded.candidates,
|
||||
evidence_version = excluded.evidence_version, attempts = excluded.attempts, last_attempt_at = excluded.last_attempt_at,
|
||||
next_review_at = excluded.next_review_at, decision_id = coalesce(excluded.decision_id, identity_candidate.decision_id),
|
||||
status = 'pending', reason = excluded.reason, query = excluded.query, normalized_title = excluded.normalized_title,
|
||||
candidates = excluded.candidates, evidence_version = excluded.evidence_version, attempts = excluded.attempts,
|
||||
last_attempt_at = excluded.last_attempt_at, next_review_at = excluded.next_review_at,
|
||||
decision_id = case when ${wantsDecision} then excluded.decision_id else null end,
|
||||
resolved_media_entity_id = null, resolved_by = null, resolved_at = null`.execute(trx);
|
||||
if (decisionId) {
|
||||
await refreshGroupDecision(ctx, trx, decisionId, { representative: release.id });
|
||||
// 旧式单条事项(identity:<id>)并入合并事项;换了组(标题更新)的,原组重算成员
|
||||
await closeDecisions(ctx, trx, `identity:${release.id}`, "resolved", { mergedInto: decisionId }, "system");
|
||||
if (prev?.decision_id && prev.decision_id !== decisionId) {
|
||||
await refreshGroupDecision(ctx, trx, prev.decision_id);
|
||||
}
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("source_release")
|
||||
@@ -685,7 +754,13 @@ async function toPool(
|
||||
parser_version: cols.parserVersion,
|
||||
parsed: jsonb(cols.parsed),
|
||||
resolution_status: toReleaseResolutionStatus(contractStatus),
|
||||
resolution_evidence: jsonb({ reason, detail, evidenceVersion: version, candidateCount: candidates.length }),
|
||||
resolution_evidence: jsonb({
|
||||
reason,
|
||||
detail,
|
||||
evidenceVersion: version,
|
||||
candidateCount: candidates.length,
|
||||
lookup: scope.lookup,
|
||||
}),
|
||||
})
|
||||
.where("id", "=", release.id)
|
||||
.where("resolution_locked", "=", false)
|
||||
|
||||
@@ -36,7 +36,13 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
|
||||
name: "resolveObservation",
|
||||
version: 1,
|
||||
inputSchema: z
|
||||
.object({ observationId: id.nullish(), sourceReleaseId: id.nullish(), force: z.boolean().optional() })
|
||||
.object({
|
||||
observationId: id.nullish(),
|
||||
sourceReleaseId: id.nullish(),
|
||||
force: z.boolean().optional(),
|
||||
// 惰性识别(DEC-1):manual / search 总是查 TMDB;缺省按 force 视为 review / observed
|
||||
trigger: z.enum(["observed", "review", "manual", "search"]).optional(),
|
||||
})
|
||||
.refine((v) => !!v.observationId || !!v.sourceReleaseId, { message: "需要 observationId 或 sourceReleaseId" }),
|
||||
retryPolicy: NETWORK_RETRY,
|
||||
handler: (t) =>
|
||||
@@ -45,6 +51,7 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
|
||||
observationId: t.input.observationId ?? undefined,
|
||||
sourceReleaseId: t.input.sourceReleaseId ?? undefined,
|
||||
force: t.input.force,
|
||||
trigger: t.input.trigger,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -47,7 +47,8 @@ describe.skipIf(!hasPg)("E5 后台唤醒有界调查", () => {
|
||||
.executeTakeFirstOrThrow();
|
||||
await e.booted.ctx.engine.enqueue(
|
||||
"resolveObservation",
|
||||
{ sourceReleaseId: rel.id },
|
||||
// 惰性识别(DEC-1):不相关的资源不查 TMDB;按搜索结果触发才会查到两个同名候选
|
||||
{ sourceReleaseId: rel.id, trigger: "search" },
|
||||
{ idempotencyKey: `e5:resolve:${rel.id}`, traceId: rel.id },
|
||||
);
|
||||
const decision = await waitFor(
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { down, up } from "../../../../packages/db/migrations/0047_identity_pool.ts";
|
||||
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||
import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts";
|
||||
|
||||
// DEC-1 惰性识别(对照 MP:RSS / 站点资源只在匹配订阅或用户搜索时识别,识别失败不告警)与待识别池治理。
|
||||
|
||||
const pg = await pgAvailable();
|
||||
|
||||
const twin = (id: number, en = "Twins", zh = "龙兄鼠弟", date = "1988-12-09"): TmdbFixture => ({
|
||||
type: "movie",
|
||||
id,
|
||||
original_title: en,
|
||||
original_language: "en",
|
||||
origin_country: ["US"],
|
||||
date,
|
||||
titles: { "zh-CN": zh, "en-US": en },
|
||||
popularity: 10 + id / 1000,
|
||||
alternative_titles: [],
|
||||
external_ids: {},
|
||||
});
|
||||
|
||||
describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let siteId: string;
|
||||
let n = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb({
|
||||
fixtures: [
|
||||
...TMDB_FIXTURES,
|
||||
twin(111),
|
||||
twin(112),
|
||||
twin(211, "Nightfall", "夜幕", "1995-03-01"),
|
||||
twin(212, "Nightfall", "夜幕", "1995-03-01"),
|
||||
],
|
||||
});
|
||||
h = await createHarness("lazyid", { tmdbUrl: tmdb.url });
|
||||
siteId = (await seedSite(h, { baseUrl: "https://hhan.example/" })).site.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
const release = async (title: string) =>
|
||||
h.db
|
||||
.insertInto("source_release")
|
||||
.values({ site_id: siteId, site_release_id: String(900000 + ++n), title })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const tmdbCalls = () => tmdb.callsTo(/\/3\//).length;
|
||||
const identityDecisions = () =>
|
||||
h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("kind", "in", ["identity_ambiguous", "identity_unresolved"])
|
||||
.where("status", "in", ["open", "investigating", "awaiting_user"])
|
||||
.execute();
|
||||
|
||||
it("RSS 100 条都不相关 → 0 条待处理、100 条池条目(unmatched)、0 次 TMDB 请求", async () => {
|
||||
const calls = tmdbCalls();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const r = await release(`真仙觉醒,我透视万物${i} 1080p WEB-DL H265 AAC-HHWEB`);
|
||||
ids.push(r.id);
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res).toMatchObject({ outcome: "unmatched", decisionId: null, reason: "no_local_match" });
|
||||
}
|
||||
expect(tmdbCalls()).toBe(calls);
|
||||
expect(await identityDecisions()).toHaveLength(0);
|
||||
const pool = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.selectAll()
|
||||
.where("source_release_id", "in", ids)
|
||||
.execute();
|
||||
expect(pool).toHaveLength(100);
|
||||
expect(pool.every((p) => p.status === "pending" && p.reason === "unmatched" && p.decision_id === null)).toBe(true);
|
||||
// 有界复查:每天一次本地召回
|
||||
const next = pool[0]!.next_review_at!.getTime() - Date.now();
|
||||
expect(next).toBeGreaterThan(23 * 3600_000);
|
||||
// 池可检索
|
||||
const list = await json(await h.req("/identity-pool?reason=unmatched&q=真仙觉醒&limit=200"));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.data.items.length).toBe(100);
|
||||
expect(list.body.data.items[0].normalizedTitle).toBeTruthy();
|
||||
const rel = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["resolution_status", "resolution_evidence"])
|
||||
.where("id", "=", ids[0]!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rel.resolution_status).toBe("unidentifiable");
|
||||
expect((rel.resolution_evidence as { lookup: unknown }).lookup).toBeNull();
|
||||
});
|
||||
|
||||
it("复查次数有上限:unmatched 3 次后不再自动复查", async () => {
|
||||
const r = await release("完全无关的短剧 1080p WEB-DL-HHWEB");
|
||||
for (let i = 0; i < 3; i++) await resolveRelease(h.rt, { sourceReleaseId: r.id, force: true });
|
||||
const p = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.selectAll()
|
||||
.where("source_release_id", "=", r.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(p.attempts).toBe(3);
|
||||
expect(p.next_review_at).toBeNull();
|
||||
});
|
||||
|
||||
let oppId: string;
|
||||
it("手动识别(池里点重新识别 / Agent)总是查 TMDB;命中订阅作品别名的新资源查 TMDB 并 resolved", async () => {
|
||||
const seed = await release("Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC-FGT");
|
||||
const lazy = await resolveRelease(h.rt, { sourceReleaseId: seed.id });
|
||||
expect(lazy.outcome).toBe("unmatched");
|
||||
const manual = await resolveRelease(h.rt, { sourceReleaseId: seed.id, force: true, trigger: "manual" });
|
||||
expect(manual.outcome).toBe("auto");
|
||||
oppId = manual.mediaEntityId!;
|
||||
const pool = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["status"])
|
||||
.where("source_release_id", "=", seed.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(pool.status).toBe("resolved");
|
||||
|
||||
// 同一作品的新 RSS 条目:标题命中本地别名 → 相关 → 查 TMDB → 自动识别
|
||||
const r = await release("奥本海默.Oppenheimer.2023.1080p.WEB-DL.H264-GRP");
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res).toMatchObject({ outcome: "auto", mediaEntityId: oppId });
|
||||
const rel = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["resolution_evidence", "resolution_status"])
|
||||
.where("id", "=", r.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rel.resolution_status).toBe("resolved");
|
||||
expect((rel.resolution_evidence as { lookup: string }).lookup).toBe("tracked_alias");
|
||||
});
|
||||
|
||||
it("新增相近别名(新证据)立即唤醒 unmatched 池条目;复查命中别名后查 TMDB", async () => {
|
||||
const r = await release("The.Bear.S03E02.2160p.DSNP.WEB-DL.DDP5.1.H.265-FLUX");
|
||||
expect((await resolveRelease(h.rt, { sourceReleaseId: r.id })).outcome).toBe("unmatched");
|
||||
const before = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["next_review_at"])
|
||||
.where("source_release_id", "=", r.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(before.next_review_at!.getTime()).toBeGreaterThan(Date.now() + 3600_000);
|
||||
const created = await json(await h.req("/media-entities", { body: { kind: "series", title: "The Bear" } }));
|
||||
expect(created.status).toBe(200);
|
||||
const woke = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["next_review_at"])
|
||||
.where("source_release_id", "=", r.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(woke.next_review_at!.getTime()).toBeLessThanOrEqual(Date.now());
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, force: true });
|
||||
expect(res.outcome).not.toBe("unmatched");
|
||||
expect(["auto", "ambiguous", "proposed"]).toContain(res.outcome);
|
||||
});
|
||||
|
||||
it("歧义 → 同来源同标题合并为 1 条事项;选候选对整组生效", async () => {
|
||||
const titles = [
|
||||
"Twins.1988.1080p.BluRay.x264-GRP",
|
||||
"Twins.1988.720p.BluRay.x264-OTHER",
|
||||
"Twins.1988.2160p.UHD.BluRay.REMUX-THIRD",
|
||||
];
|
||||
const ids: string[] = [];
|
||||
for (const t of titles) {
|
||||
const r = await release(t);
|
||||
ids.push(r.id);
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
|
||||
expect(res.outcome).toBe("ambiguous");
|
||||
}
|
||||
const twinsGroup = `identity-group:${siteId}:twins`;
|
||||
const ds = (await identityDecisions()).filter((d) => d.dedupe_key === twinsGroup);
|
||||
expect(ds).toHaveLength(1);
|
||||
const d = ds[0]!;
|
||||
expect(d.severity).toBe("warning");
|
||||
expect(d.title).toMatch(/^来源「.+」的 3 条资源待确认身份:Twins$/);
|
||||
const ev = d.evidence as { count: number; releases: { sourceReleaseId: string }[] };
|
||||
expect(ev.count).toBe(3);
|
||||
expect(ev.releases.map((x) => x.sourceReleaseId).sort()).toEqual([...ids].sort());
|
||||
const pool = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["decision_id"])
|
||||
.where("source_release_id", "in", ids)
|
||||
.execute();
|
||||
expect(pool.every((p) => p.decision_id === d.id)).toBe(true);
|
||||
|
||||
const ok = await json(await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "c0" } }));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.data.status).toBe("resolved");
|
||||
const rels = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["media_entity_id", "resolution_locked"])
|
||||
.where("id", "in", ids)
|
||||
.execute();
|
||||
expect(new Set(rels.map((r) => r.media_entity_id)).size).toBe(1);
|
||||
expect(rels.every((r) => r.media_entity_id && r.resolution_locked)).toBe(true);
|
||||
expect((await identityDecisions()).filter((x) => x.dedupe_key === twinsGroup)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("组内一条被单独指定 → 只从组里摘除;最后一条处理完才关闭事项", async () => {
|
||||
const ids: string[] = [];
|
||||
for (const t of ["Nightfall.1995.1080p.WEB-DL-AAA", "Nightfall.1995.720p.WEB-DL-BBB"]) {
|
||||
const r = await release(t);
|
||||
ids.push(r.id);
|
||||
expect((await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" })).outcome).toBe("ambiguous");
|
||||
}
|
||||
const open = (await identityDecisions()).filter((x) => x.dedupe_key === `identity-group:${siteId}:nightfall`);
|
||||
expect(open).toHaveLength(1);
|
||||
const d = open[0]!;
|
||||
const pool = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["id", "source_release_id"])
|
||||
.where("decision_id", "=", d.id)
|
||||
.where("status", "=", "pending")
|
||||
.execute();
|
||||
const first = pool[0]!;
|
||||
expect(
|
||||
(await json(await h.req(`/identity-pool/${first.id}/assign`, { body: { mediaEntityId: oppId } }))).status,
|
||||
).toBe(200);
|
||||
expect(pool).toHaveLength(2);
|
||||
const after = await h.db.selectFrom("decision").selectAll().where("id", "=", d.id).executeTakeFirstOrThrow();
|
||||
expect(after.status).toBe("open");
|
||||
expect((after.evidence as { count: number }).count).toBe(1);
|
||||
expect(after.subject_id).toBe(pool[1]!.source_release_id);
|
||||
expect(after.title).toMatch(/^待确认身份:Nightfall\.1995\./);
|
||||
// 最后一条忽略 → 事项关闭
|
||||
const last = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["id"])
|
||||
.where("source_release_id", "=", pool[1]!.source_release_id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((await h.req(`/identity-pool/${last.id}/dismiss`, { body: {} })).status).toBe(200);
|
||||
const closed = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["status"])
|
||||
.where("id", "=", d.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(closed.status).toBe("dismissed");
|
||||
});
|
||||
|
||||
it("收件箱级别筛选:minSeverity=warning 不含仅信息类", async () => {
|
||||
await h.db
|
||||
.insertInto("decision")
|
||||
.values({ kind: "other", severity: "info", title: "巡检报告", dedupe_key: "test:info" })
|
||||
.execute();
|
||||
const all = await json(await h.req("/decisions/summary"));
|
||||
const warn = await json(await h.req("/decisions/summary?minSeverity=warning"));
|
||||
expect(all.body.data.total - warn.body.data.total).toBe(1);
|
||||
const list = await json(await h.req("/decisions?minSeverity=warning"));
|
||||
expect(list.body.data.items.every((d: { severity: string }) => d.severity !== "info")).toBe(true);
|
||||
});
|
||||
|
||||
it("过期归档:unmatched 超过 poolArchiveDays 没有新证据 → archived", async () => {
|
||||
const { archiveStalePool } = await import("../../src/modules/sources-identity/service/identity-decisions.ts");
|
||||
const r = await release("很久以前的短剧 1080p WEB-DL-HHWEB");
|
||||
await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
await h.db
|
||||
.updateTable("identity_candidate")
|
||||
.set({ last_attempt_at: new Date(Date.now() - 31 * 86_400_000) })
|
||||
.where("source_release_id", "=", r.id)
|
||||
.execute();
|
||||
expect(await archiveStalePool(h.rt, { force: true })).toBeGreaterThanOrEqual(1);
|
||||
const p = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["status", "next_review_at"])
|
||||
.where("source_release_id", "=", r.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(p).toMatchObject({ status: "archived", next_review_at: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!pg)("迁移 0047:存量 identity_unresolved 清理", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("dec1mig");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("无关联的 open identity_unresolved → dismissed(moved_to_pool),池条目改 unmatched;相关的保留", async () => {
|
||||
const siteId = (await seedSite(h, { baseUrl: "https://hhan.example/" })).site.id;
|
||||
await down(h.db);
|
||||
const mk = async (title: string, parsedTitle: string) => {
|
||||
const r = await h.db
|
||||
.insertInto("source_release")
|
||||
.values({ site_id: siteId, site_release_id: title, title, resolution_status: "unidentifiable" })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const d = await h.db
|
||||
.insertInto("decision")
|
||||
.values({
|
||||
kind: "identity_unresolved",
|
||||
severity: "info",
|
||||
title: `待识别:${title}`,
|
||||
subject_type: "source_release",
|
||||
subject_id: r.id,
|
||||
dedupe_key: `identity:${r.id}`,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await sql`insert into identity_candidate (source_release_id, reason, query, evidence_version, attempts, decision_id,
|
||||
next_review_at)
|
||||
values (${r.id}, 'no_match', ${JSON.stringify({ title: parsedTitle, normalized: normalizeTitle(parsedTitle) })}::jsonb,
|
||||
'v1', 1, ${d.id}, now() + interval '1 hour')`.execute(h.db);
|
||||
return { releaseId: r.id, decisionId: d.id };
|
||||
};
|
||||
const noise = await mk("真仙觉醒,我透视万物 1080p WEB-DL H265 AAC-HHWEB", "真仙觉醒,我透视万物");
|
||||
const related = await mk("龙兄鼠弟.1988.1080p.WEB-DL", "龙兄鼠弟");
|
||||
const e = await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "龙兄鼠弟" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await sql`insert into title_alias (media_entity_id, alias, normalized, kind, source)
|
||||
values (${e.id}, '龙兄鼠弟', ${normalizeTitle("龙兄鼠弟")}, 'translated', 'user')`.execute(h.db);
|
||||
|
||||
await up(h.db);
|
||||
|
||||
const d1 = await h.db
|
||||
.selectFrom("decision")
|
||||
.selectAll()
|
||||
.where("id", "=", noise.decisionId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d1.status).toBe("dismissed");
|
||||
expect(d1.resolution).toMatchObject({ resolution: "moved_to_pool" });
|
||||
const p1 = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.selectAll()
|
||||
.where("source_release_id", "=", noise.releaseId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(p1).toMatchObject({ reason: "unmatched", decision_id: null, next_review_at: null, status: "pending" });
|
||||
expect(p1.normalized_title).toBe(normalizeTitle("真仙觉醒,我透视万物"));
|
||||
|
||||
const d2 = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["status"])
|
||||
.where("id", "=", related.decisionId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(d2.status).toBe("open");
|
||||
const p2 = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["reason", "decision_id"])
|
||||
.where("source_release_id", "=", related.releaseId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(p2).toMatchObject({ reason: "no_match", decision_id: related.decisionId });
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,11 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, twin(111), twin(112)] });
|
||||
h = await createHarness("resolve", { tmdbUrl: tmdb.url });
|
||||
siteId = (await seedSite(h, { baseUrl: "https://audiences.example/" })).site.id;
|
||||
// 本组覆盖 TMDB 召回本身:打开"每条新资源都查 TMDB"(旧行为);惰性识别见 lazy-identity.test.ts
|
||||
await h.db
|
||||
.insertInto("settings")
|
||||
.values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never)
|
||||
.execute();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
@@ -120,7 +125,7 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
});
|
||||
|
||||
let twinsRelease: string;
|
||||
it("ambiguous:进待识别池 + 待处理事项,不建正式作品", async () => {
|
||||
it("ambiguous:进待识别池 + 待处理事项(合并事项,warning),不建正式作品", async () => {
|
||||
const before = await h.db.selectFrom("media_entity").select("id").execute();
|
||||
const r = await release("Twins.1988.1080p.BluRay.x264-GRP");
|
||||
twinsRelease = r.id;
|
||||
@@ -136,7 +141,8 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
expect((pool.candidates as unknown[]).length).toBe(2);
|
||||
expect(pool.next_review_at!.getTime()).toBeGreaterThan(Date.now());
|
||||
const d = await h.db.selectFrom("decision").selectAll().where("id", "=", res.decisionId!).executeTakeFirstOrThrow();
|
||||
expect(d).toMatchObject({ kind: "identity_ambiguous", status: "open", subject_id: r.id });
|
||||
expect(d).toMatchObject({ kind: "identity_ambiguous", status: "open", subject_id: r.id, severity: "warning" });
|
||||
expect(d.dedupe_key).toBe(`identity-group:${siteId}:twins`);
|
||||
expect((d.options as { key: string }[]).map((o) => o.key)).toEqual([
|
||||
"c0",
|
||||
"c1",
|
||||
@@ -164,7 +170,11 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
expect(pool2.attempts).toBe(2);
|
||||
expect(pool2.next_review_at!.getTime()).toBeGreaterThan(pool.next_review_at!.getTime());
|
||||
expect(
|
||||
await h.db.selectFrom("decision").select("id").where("dedupe_key", "=", `identity:${r.id}`).execute(),
|
||||
await h.db
|
||||
.selectFrom("decision")
|
||||
.select("id")
|
||||
.where("dedupe_key", "=", `identity-group:${siteId}:twins`)
|
||||
.execute(),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -214,38 +224,32 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
expect(after.media_entity_id).toBe(rel.media_entity_id);
|
||||
});
|
||||
|
||||
it("no_match 进池并开未识别事项;手动指定 TMDB id / 选本地作品即人工指定加锁;TMDB 失败记为 lookup_failed", async () => {
|
||||
it("no_match(与本地作品无关)只进池不开事项;池条目上手动指定 TMDB id / 选本地作品即人工指定加锁;TMDB 失败记为 lookup_failed", async () => {
|
||||
const r1 = await release("Zzqxv.Nonexistent.2019.1080p.WEB-DL-NOPE");
|
||||
const a = await resolveRelease(h.rt, { sourceReleaseId: r1.id });
|
||||
expect(a.outcome).toBe("no_match");
|
||||
expect(a.decisionId).not.toBeNull();
|
||||
expect(a.decisionId).toBeNull();
|
||||
const rel = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["resolution_status"])
|
||||
.where("id", "=", r1.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rel.resolution_status).toBe("unidentifiable");
|
||||
const d = await h.db.selectFrom("decision").selectAll().where("id", "=", a.decisionId!).executeTakeFirstOrThrow();
|
||||
expect(d).toMatchObject({ kind: "identity_unresolved", status: "open", subject_id: r1.id });
|
||||
expect((d.options as { key: string }[]).map((o) => o.key)).toEqual(["manual_tmdb", "local_entity", "dismiss"]);
|
||||
const pool1 = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["decision_id"])
|
||||
.select(["id", "decision_id", "reason", "normalized_title"])
|
||||
.where("source_release_id", "=", r1.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(pool1.decision_id).toBe(d.id);
|
||||
// 缺少输入 → 400;给 TMDB id → 人工指定加锁,事项关闭
|
||||
expect((await json(await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "manual_tmdb" } }))).status).toBe(
|
||||
400,
|
||||
);
|
||||
expect(pool1).toMatchObject({ decision_id: null, reason: "no_match", normalized_title: "zzqxvnonexistent" });
|
||||
// 缺少输入 → 422(校验失败);给 TMDB id → 人工指定加锁
|
||||
expect((await json(await h.req(`/identity-pool/${pool1.id}/assign`, { body: {} }))).status).toBe(422);
|
||||
const fx = TMDB_FIXTURES.find((f) => f.type === "movie")!;
|
||||
const ok = await json(
|
||||
await h.req(`/decisions/${d.id}/resolve`, {
|
||||
body: { optionKey: "manual_tmdb", identity: { tmdb: { type: "movie", id: String(fx.id) } } },
|
||||
await h.req(`/identity-pool/${pool1.id}/assign`, {
|
||||
body: { external: { provider: "tmdb", entityType: "movie", externalId: String(fx.id) } },
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.data.status).toBe("resolved");
|
||||
const rel1 = await h.db.selectFrom("source_release").selectAll().where("id", "=", r1.id).executeTakeFirstOrThrow();
|
||||
expect(rel1).toMatchObject({ resolution_status: "resolved", resolution_locked: true });
|
||||
const ident = await h.db
|
||||
@@ -260,11 +264,12 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
const r3 = await release("Xyzzy.Plugh.2018.1080p.WEB-DL-NOPE");
|
||||
const c = await resolveRelease(h.rt, { sourceReleaseId: r3.id });
|
||||
expect(c.outcome).toBe("no_match");
|
||||
const local = await json(
|
||||
await h.req(`/decisions/${c.decisionId}/resolve`, {
|
||||
body: { optionKey: "local_entity", identity: { mediaEntityId: oppId } },
|
||||
}),
|
||||
);
|
||||
const pool3 = await h.db
|
||||
.selectFrom("identity_candidate")
|
||||
.select(["id"])
|
||||
.where("source_release_id", "=", r3.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
const local = await json(await h.req(`/identity-pool/${pool3.id}/assign`, { body: { mediaEntityId: oppId } }));
|
||||
expect(local.status).toBe(200);
|
||||
const rel3 = await h.db.selectFrom("source_release").selectAll().where("id", "=", r3.id).executeTakeFirstOrThrow();
|
||||
expect(rel3).toMatchObject({ media_entity_id: oppId, resolution_locked: true });
|
||||
|
||||
@@ -15,10 +15,13 @@ import { getParsed, parseOrThrow } from "./library.ts";
|
||||
export const decisionKeys = {
|
||||
all: ["decisions"] as const,
|
||||
list: (f: DecisionFilters) => ["decisions", "list", f] as const,
|
||||
summary: (status?: DecisionStatus) => ["decisions", "summary", status ?? "open"] as const,
|
||||
summary: (status?: DecisionStatus, minSeverity?: MinSeverity) =>
|
||||
["decisions", "summary", status ?? "open", minSeverity ?? "info"] as const,
|
||||
};
|
||||
|
||||
export type DecisionFilters = { status?: DecisionStatus; kind?: DecisionKind };
|
||||
/** 只看不低于此级别的事项;收件箱缺省 warning(隐藏"仅信息"类) */
|
||||
export type MinSeverity = "info" | "warning" | "critical";
|
||||
export type DecisionFilters = { status?: DecisionStatus; kind?: DecisionKind; minSeverity?: MinSeverity };
|
||||
|
||||
/** /decisions/summary(合同 DecisionSummary) */
|
||||
export { DecisionSummary };
|
||||
@@ -37,10 +40,10 @@ export function useDecisions(filters: DecisionFilters, enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDecisionSummary(status?: DecisionStatus) {
|
||||
export function useDecisionSummary(status?: DecisionStatus, minSeverity?: MinSeverity) {
|
||||
return useQuery({
|
||||
queryKey: decisionKeys.summary(status),
|
||||
queryFn: ({ signal }) => getParsed(DecisionSummary, "/decisions/summary", { status }, signal),
|
||||
queryKey: decisionKeys.summary(status, minSeverity),
|
||||
queryFn: ({ signal }) => getParsed(DecisionSummary, "/decisions/summary", { status, minSeverity }, signal),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useSse } from "~/lib/sse.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 顶栏收件箱(IA-3):待处理不进侧栏,铃铛 + 未决角标;下拉按问题类型列最近事项,点开进待处理页。
|
||||
// 角标只算需要你处理的事项(警告及以上):"仅信息"类(巡检报告、旧版本待处置等)不计入,在待处理页可展开查看。
|
||||
// 身份类事项已按来源 + 标题合并,一条事项可能代表多条资源(标题里写明 N 条)。
|
||||
|
||||
const RECENT = 8;
|
||||
const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "bg-destructive" } as const;
|
||||
@@ -27,7 +29,7 @@ export function useLiveDecisions() {
|
||||
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
||||
}, 800);
|
||||
});
|
||||
return useDecisionSummary();
|
||||
return useDecisionSummary(undefined, "warning");
|
||||
}
|
||||
|
||||
export const inboxPath = (kind?: string) => (kind ? `/console/inbox?kind=${kind}` : "/console/inbox");
|
||||
@@ -35,7 +37,7 @@ export const inboxPath = (kind?: string) => (kind ? `/console/inbox?kind=${kind}
|
||||
export function InboxBell() {
|
||||
const summary = useLiveDecisions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const list = useDecisions({}, open);
|
||||
const list = useDecisions({ minSeverity: "warning" }, open);
|
||||
const navigate = useNavigate();
|
||||
const total = summary.data?.total ?? 0;
|
||||
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import type { IdentityPoolItem, PoolCandidate } from "@umt/contracts";
|
||||
import { ArrowRight, ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
|
||||
import { ArrowRight, BellOff, ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useRetryPool } from "~/api/identity.ts";
|
||||
import { useAssignPool, useDismissPool, useRetryPool } from "~/api/identity.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { EVIDENCE_LEVEL, POOL_REASON, POOL_STATUS } from "./labels.ts";
|
||||
import { Chip, Tone } from "./parts.tsx";
|
||||
|
||||
// 待识别资源详情(只读):候选证据 + 重新识别。选候选、忽略在待处理里做(判定只在待处理与作品详情)。
|
||||
// 待识别资源详情:候选证据 + 重新识别 + 手动指定。进了待处理的(与订阅相关的歧义 / 无结果)在待处理里选候选;
|
||||
// 没进待处理的(unmatched:未命中订阅,惰性识别没查 TMDB)在这里手动指定 TMDB ID / 本地作品,或忽略。
|
||||
|
||||
/** 多个候选 / 待确认的资源开"待确认身份"事项;没有候选的(无结果、复查耗尽)开"待识别"事项 */
|
||||
/** 与订阅 / 本地作品相关的歧义开"待确认身份"事项,相关却无结果开"待识别"事项(同来源同标题合并);unmatched 不进待处理 */
|
||||
export function inInbox(item: Pick<IdentityPoolItem, "status" | "reason" | "candidates" | "decisionId">): boolean {
|
||||
return item.status === "pending" && (item.decisionId !== null || hasCandidates(item));
|
||||
}
|
||||
@@ -85,6 +91,97 @@ function CandidateRow({ c, index }: { c: PoolCandidate; index: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 手动指定(复用 /identity-pool/{id}/assign,加人工锁)与忽略 */
|
||||
function ManualAssign({ item }: { item: IdentityPoolItem }) {
|
||||
const uid = useId();
|
||||
const assign = useAssignPool();
|
||||
const dismiss = useDismissPool();
|
||||
const [tmdbType, setTmdbType] = useState<"movie" | "tv">(item.query.season != null ? "tv" : "movie");
|
||||
const [tmdbId, setTmdbId] = useState("");
|
||||
const [entity, setEntity] = useState<PickedEntity | null>(null);
|
||||
const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim());
|
||||
const run = async (body: Parameters<typeof assign.mutateAsync>[0], ok: string) => {
|
||||
try {
|
||||
await assign.mutateAsync(body);
|
||||
toast.success(ok);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "指定失败"));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<DetailSection title="手动指定">
|
||||
<div className="grid gap-x-2 sm:grid-cols-[8rem_minmax(0,1fr)_auto] sm:items-end">
|
||||
<FormField label="类型" htmlFor={`${uid}-type`}>
|
||||
<Select value={tmdbType} onValueChange={(v) => setTmdbType(v as "movie" | "tv")}>
|
||||
<SelectTrigger id={`${uid}-type`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="movie">电影</SelectItem>
|
||||
<SelectItem value="tv">剧集</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} hint="纯数字,如 872585">
|
||||
<Input
|
||||
id={`${uid}-tmdb`}
|
||||
inputMode="numeric"
|
||||
value={tmdbId}
|
||||
onChange={(e) => setTmdbId(e.target.value)}
|
||||
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
disabled={!tmdbOk || assign.isPending}
|
||||
onClick={() =>
|
||||
run(
|
||||
{
|
||||
id: item.id,
|
||||
external: { provider: "tmdb", entityType: tmdbType, externalId: tmdbId.trim() },
|
||||
lock: true,
|
||||
},
|
||||
"已按 TMDB ID 指定",
|
||||
)
|
||||
}
|
||||
>
|
||||
指定
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-x-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
|
||||
<FormField label="或选本地作品">
|
||||
<EntityPicker value={entity} onChange={setEntity} />
|
||||
</FormField>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mb-4"
|
||||
disabled={!entity || assign.isPending}
|
||||
onClick={() => entity && run({ id: item.id, mediaEntityId: entity.id, lock: true }, "已关联本地作品")}
|
||||
>
|
||||
关联
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={dismiss.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dismiss.mutateAsync({ id: item.id });
|
||||
toast.success("已忽略");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "操作失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BellOff />
|
||||
忽略此资源
|
||||
</Button>
|
||||
</DetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; onClose: () => void }) {
|
||||
const retry = useRetryPool();
|
||||
if (!item) return null;
|
||||
@@ -144,6 +241,8 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
]}
|
||||
/>
|
||||
|
||||
{item.status !== "resolved" && !queued ? <ManualAssign item={item} /> : null}
|
||||
|
||||
<DetailSection title={`候选(${item.candidates.length})`}>
|
||||
{item.candidates.length ? (
|
||||
<ul className="space-y-2">
|
||||
@@ -152,7 +251,11 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">没有候选。有新证据时自动重试。</p>
|
||||
<p className="text-caption text-muted-foreground">
|
||||
{item.reason === "unmatched"
|
||||
? "没有命中订阅或本地作品,未查询 TMDB。点「重新识别」会查一次 TMDB;新增相近别名时自动重试。"
|
||||
: "没有候选。有新证据时自动重试。"}
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ export function PoolTab() {
|
||||
empty={{
|
||||
icon: SearchX,
|
||||
title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源",
|
||||
description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。",
|
||||
description: "未命中订阅、证据不足或冲突的资源会出现在这里;新增相近别名时自动重试,也可手动指定。",
|
||||
}}
|
||||
hasMore={pool.hasNextPage}
|
||||
onLoadMore={() => pool.fetchNextPage()}
|
||||
|
||||
@@ -164,6 +164,7 @@ export const POOL_REASON: Record<IdentityCandidateReason, ToneLabel> = {
|
||||
no_match: { label: "没有匹配", tone: "neutral" },
|
||||
proposed: { label: "待确认", tone: "warning" },
|
||||
lookup_failed: { label: "查询失败", tone: "destructive" },
|
||||
unmatched: { label: "未命中订阅", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const POOL_STATUS: Record<string, ToneLabel> = {
|
||||
|
||||
@@ -180,6 +180,39 @@ function ImageCacheStatus() {
|
||||
);
|
||||
}
|
||||
|
||||
// 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)
|
||||
const IDENTITY_CARD: CardSpec = {
|
||||
anchor: "identity",
|
||||
title: "资源识别",
|
||||
description:
|
||||
"缺省只在资源命中订阅或本地作品别名、新作品收录规则前置条件通过、你手动识别或搜索时查 TMDB;其余进待识别池,不进待处理。",
|
||||
fields: [
|
||||
{
|
||||
name: "identity.eagerTmdbLookup",
|
||||
label: "每条新资源都查 TMDB",
|
||||
kind: "switch",
|
||||
hint: "旧行为。每条资源约 1–2 次搜索 + 命中时 1 次详情(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "identity.poolArchiveDays",
|
||||
label: "待识别池归档(天)",
|
||||
kind: "number",
|
||||
hint: "未命中订阅的条目超过这么多天没有新证据就归档,仍可筛出并手动指定",
|
||||
},
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"identity.eagerTmdbLookup": s.identity.eagerTmdbLookup,
|
||||
"identity.poolArchiveDays": n(s.identity.poolArchiveDays),
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
identity: {
|
||||
eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true,
|
||||
poolArchiveDays: Number(v["identity.poolArchiveDays"]),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export function MetadataSection() {
|
||||
return (
|
||||
<SettingsGate>
|
||||
@@ -187,6 +220,7 @@ export function MetadataSection() {
|
||||
<>
|
||||
<SettingsCard section="metadata" spec={TMDB_CARD} settings={s} />
|
||||
<SettingsCard section="metadata" spec={TMDB_CACHE_CARD} settings={s} />
|
||||
<SettingsCard section="metadata" spec={IDENTITY_CARD} settings={s} />
|
||||
<SettingsCard section="metadata" spec={IMAGES_CARD} settings={s} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
|
||||
import { BellOff, Bot, CheckCheck, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
|
||||
import { BellOff, Bot, CheckCheck, ChevronDown, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
|
||||
import { type ReactNode, useId, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,8 +26,10 @@ import { actorText } from "~/lib/labels.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// 待处理(REQ §13.1):按作品与问题类型合并;每项给证据、建议、影响范围与预览;
|
||||
// 待处理(REQ §13.1):按问题类型 + 作品 / 来源合并并可折叠;每项给证据、建议、影响范围与预览;
|
||||
// 处理选项来自后端 decision.options;SSE 收到 decision.* 事件即刷新。
|
||||
// 缺省只看需要你处理的事项(警告及以上),"仅信息"类在"级别"筛选里展开。身份类事项后端已按来源 + 标题合并,
|
||||
// 卡片里列出组内资源,选候选 / 指定 / 忽略对整组生效。
|
||||
|
||||
const OPEN: DecisionStatus[] = ["open", "investigating", "awaiting_user"];
|
||||
const STATUS_FILTER = [
|
||||
@@ -36,9 +38,55 @@ const STATUS_FILTER = [
|
||||
{ value: "expired", label: "已过期" },
|
||||
];
|
||||
const SEV_RANK = { info: 0, warning: 1, critical: 2 } as const;
|
||||
const SEV_FILTER = [{ value: "all", label: "含仅信息" }];
|
||||
/** 缺省展开的组数;其余折叠 */
|
||||
const OPEN_GROUPS = 3;
|
||||
|
||||
/** 证据里约定俗成的字段(Agent/后端写入);其余原样放进"原始证据" */
|
||||
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v : null);
|
||||
/** 身份类合并事项的来源分组(后端 evidence.group) */
|
||||
function sourceGroupOf(d: Decision): { siteId: string; siteName: string | null } | null {
|
||||
const g = d.evidence.group as { siteId?: unknown; siteName?: unknown } | undefined;
|
||||
return g && typeof g.siteId === "string" ? { siteId: g.siteId, siteName: str(g.siteName) } : null;
|
||||
}
|
||||
|
||||
/** 合并事项的组内资源 */
|
||||
function GroupReleases({ d }: { d: Decision }) {
|
||||
const list = Array.isArray(d.evidence.releases)
|
||||
? (d.evidence.releases as { sourceReleaseId?: string; title?: string }[])
|
||||
: [];
|
||||
const count = typeof d.evidence.count === "number" ? d.evidence.count : list.length;
|
||||
const [open, setOpen] = useState(false);
|
||||
if (count <= 1) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{open ? "收起" : "展开"}组内 {count} 条资源
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="mt-1.5 space-y-1 border-l border-border/60 pl-3">
|
||||
{list.map((r) => (
|
||||
<li key={r.sourceReleaseId} className="font-mono text-meta break-all text-muted-foreground">
|
||||
{r.title}
|
||||
</li>
|
||||
))}
|
||||
{count > list.length ? (
|
||||
<li className="text-meta text-muted-foreground">
|
||||
另有 {count - list.length} 条,可在媒体概览 → 待识别按标题检索
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function entityOf(d: Decision): string | null {
|
||||
const m = d.evidence.mediaEntityId;
|
||||
if (typeof m === "string" && /^[0-9a-f-]{36}$/.test(m)) return m;
|
||||
@@ -240,6 +288,7 @@ function DecisionCard({ d }: { d: Decision }) {
|
||||
</div>
|
||||
<h3 className="mt-2 text-body font-medium break-words text-foreground">{d.title}</h3>
|
||||
{d.summary ? <p className="mt-1 text-caption break-words text-muted-foreground">{d.summary}</p> : null}
|
||||
<GroupReleases d={d} />
|
||||
<div className="mt-3">
|
||||
<Evidence d={d} />
|
||||
</div>
|
||||
@@ -272,12 +321,20 @@ function DecisionCard({ d }: { d: Decision }) {
|
||||
);
|
||||
}
|
||||
|
||||
type Group = { key: string; entityId: string | null; kind: DecisionKind; items: Decision[] };
|
||||
type Group = {
|
||||
key: string;
|
||||
entityId: string | null;
|
||||
source: { siteId: string; siteName: string | null } | null;
|
||||
kind: DecisionKind;
|
||||
items: Decision[];
|
||||
};
|
||||
|
||||
export default function InboxPage() {
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const status = (sp.get("status") ?? "") as DecisionStatus | "";
|
||||
const kind = (sp.get("kind") ?? "") as DecisionKind | "";
|
||||
const sev = sp.get("sev") ?? "";
|
||||
const minSeverity = sev === "all" ? undefined : "warning";
|
||||
const set = (k: string, v: string) =>
|
||||
setSp(
|
||||
(prev) => {
|
||||
@@ -289,8 +346,8 @@ export default function InboxPage() {
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const summary = useDecisionSummary(status || undefined);
|
||||
const list = useDecisions({ status: status || undefined, kind: kind || undefined });
|
||||
const summary = useDecisionSummary(status || undefined, minSeverity);
|
||||
const list = useDecisions({ status: status || undefined, kind: kind || undefined, minSeverity });
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
|
||||
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
|
||||
@@ -324,13 +381,14 @@ export default function InboxPage() {
|
||||
return [...m.entries()].sort((a, b) => SEV_RANK[b[1].sev] - SEV_RANK[a[1].sev] || b[1].count - a[1].count);
|
||||
}, [summary.data]);
|
||||
|
||||
// 按作品 + 问题类型合并;组内按严重度、时间
|
||||
// 按问题类型 + 作品 / 来源合并;组内按严重度、时间
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, Group>();
|
||||
for (const d of rows ?? []) {
|
||||
const entityId = entityOf(d);
|
||||
const key = `${entityId ?? `subject:${d.subjectType ?? "none"}`}|${d.kind}`;
|
||||
const g = m.get(key) ?? { key, entityId, kind: d.kind, items: [] };
|
||||
const source = entityId ? null : sourceGroupOf(d);
|
||||
const key = `${entityId ?? (source ? `site:${source.siteId}` : `subject:${d.subjectType ?? "none"}`)}|${d.kind}`;
|
||||
const g = m.get(key) ?? { key, entityId, source, kind: d.kind, items: [] };
|
||||
g.items.push(d);
|
||||
m.set(key, g);
|
||||
}
|
||||
@@ -339,6 +397,9 @@ export default function InboxPage() {
|
||||
}, [rows]);
|
||||
|
||||
const isOpenView = !status;
|
||||
// 折叠状态:缺省前 OPEN_GROUPS 组展开;用户点过的组按用户的来
|
||||
const [toggled, setToggled] = useState<Record<string, boolean>>({});
|
||||
const isExpanded = (key: string, index: number) => toggled[key] ?? index < OPEN_GROUPS;
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -388,7 +449,7 @@ export default function InboxPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBar onReset={status || kind ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
|
||||
<FilterBar onReset={status || kind || sev ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={status}
|
||||
@@ -397,6 +458,7 @@ export default function InboxPage() {
|
||||
allLabel="未关闭"
|
||||
/>
|
||||
<FilterSelect label="问题类型" value={kind} onChange={(v) => set("kind", v)} options={opts(DECISION_KIND)} />
|
||||
<FilterSelect label="级别" value={sev} onChange={(v) => set("sev", v)} options={SEV_FILTER} allLabel="需处理" />
|
||||
</FilterBar>
|
||||
|
||||
{list.isPending ? (
|
||||
@@ -413,15 +475,27 @@ export default function InboxPage() {
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((g) => {
|
||||
{groups.map((g, index) => {
|
||||
const title = g.entityId ? titles.get(g.entityId) : null;
|
||||
const expanded = isExpanded(g.key, index);
|
||||
const maxSev = g.items.reduce<keyof typeof SEV_RANK>(
|
||||
(a, d) => (SEV_RANK[d.severity] > SEV_RANK[a] ? d.severity : a),
|
||||
"info",
|
||||
);
|
||||
return (
|
||||
<section key={g.key} className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b border-border/60 px-4 py-3 sm:px-5">
|
||||
<header
|
||||
className={`flex flex-wrap items-center gap-2 px-4 py-3 sm:px-5 ${expanded ? "border-b border-border/60" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToggled((t) => ({ ...t, [g.key]: !expanded }))}
|
||||
className="inline-flex cursor-pointer items-center text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "折叠" : "展开"}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
<StatusBadge tone={SEVERITY[maxSev].tone}>{DECISION_KIND[g.kind]}</StatusBadge>
|
||||
{g.entityId ? (
|
||||
<Link
|
||||
@@ -430,6 +504,10 @@ export default function InboxPage() {
|
||||
>
|
||||
{title ?? "关联作品"}
|
||||
</Link>
|
||||
) : g.source ? (
|
||||
<span className="min-w-0 truncate text-body font-semibold text-foreground">
|
||||
来源「{g.source.siteName ?? "未知站点"}」
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body font-semibold text-muted-foreground">
|
||||
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
|
||||
@@ -437,9 +515,7 @@ export default function InboxPage() {
|
||||
)}
|
||||
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
|
||||
</header>
|
||||
{g.items.map((d) => (
|
||||
<DecisionCard key={d.id} d={d} />
|
||||
))}
|
||||
{expanded ? g.items.map((d) => <DecisionCard key={d.id} d={d} />) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# 资源识别与待识别池运行手册(DEC-1)
|
||||
|
||||
新观察到的资源(RSS、站点列表快照、搜索结果)先进待识别池,只在有理由时才查 TMDB。只有和订阅或本地作品相关、
|
||||
又需要你决定的资源才进待处理。对照 MoviePilot:RSS / 站点资源只在匹配订阅或用户搜索时才识别,识别失败只写日志、不告警
|
||||
(`app/chain/subscribe/match.py` `_prepare_match_torrents`:未识别的种子最多重试 3 次,之后跳过)。
|
||||
|
||||
需求依据:REQ §4.2–4.3(观察不等于事实)、§6.3(新作品收录 / 追更补缺 / 升级都由规则触发)、§7.5(待识别池可检索、
|
||||
不污染正式库、有界复查、不留死数据)、§13(待处理按作品与问题类型合并)。
|
||||
|
||||
## 识别流程(resolveObservation)
|
||||
|
||||
1. 解析标题(release-parser),本地召回:显式外部 ID、`title_alias` 精确 / pg_trgm ≥ 0.45(本地作品 = 订阅、追踪、已登记的作品)。
|
||||
2. 判断是否查 TMDB(`lookup`,记在 `source_release.resolution_evidence.lookup` 和池条目 `query.lookup`):
|
||||
|
||||
| lookup | 条件 |
|
||||
|---|---|
|
||||
| `manual` | 任务输入 `trigger: "manual"`:池里点「重新识别」、Agent / 能力 `identity.resolve` |
|
||||
| `search` | 任务输入 `trigger: "search"`:搜索结果 |
|
||||
| `tracked_alias` | 本地召回有候选(标题命中订阅 / 本地作品的别名) |
|
||||
| `previously_looked_up` | 周期复查,且这条池条目以前查过 TMDB(旧数据、手动、规则触发) |
|
||||
| `eager` | 设置 `identity.eagerTmdbLookup = true` |
|
||||
| `new_title_rule` | 有已发布、开启 `newTitle` 且有有效持续授权的获取策略,资源类型在规则的 `mediaKinds` 内,标题年份(若有)不早于窗口能覆盖的年份 |
|
||||
|
||||
都不满足:只进池,`reason = unmatched`,不查 TMDB,不开待处理。
|
||||
3. 查了 TMDB 的,走原来的 `domain.resolveIdentity`:auto 建档 / 关联并发 `release.resolved`;否则进池。
|
||||
|
||||
任务输入 `trigger` 缺省:`force` 为 true 视为 `review`(池复查),否则 `observed`。
|
||||
搜索模块想让搜索结果直接识别,入队 `resolveObservation` 时传 `trigger: "search"`(目前搜索结果经 `release.observed` 进来,
|
||||
按 observed 处理;命中订阅别名的照样会查)。
|
||||
|
||||
## 待处理只收相关的
|
||||
|
||||
| 结果 | 开事项? |
|
||||
|---|---|
|
||||
| `unmatched` | 否 |
|
||||
| `ambiguous` / `proposed`(查过 TMDB,有候选) | 是,`identity_ambiguous`,级别 warning |
|
||||
| `no_match` / 复查已停的 `lookup_failed` | 只有本地召回有候选(与本地作品相关)时开 `identity_unresolved`,级别 warning |
|
||||
|
||||
合并:dedupeKey `identity-group:<siteId>:<归一化标题>`,同来源同标题的资源并成一条。成员 = `identity_candidate.decision_id`
|
||||
指向该事项且仍 `pending` 的条目。标题 1 条时写资源原标题,多条时写「来源「X」的 N 条资源待识别 / 待确认身份:<标题>」,
|
||||
`evidence.releases` 列前 50 条,`evidence.count` 为总数。
|
||||
|
||||
- 处理(选候选 / 手动 TMDB ID / 选本地作品):先处理代表资源(subject),再把确定的作品套到组内其它资源(都加人工锁)。
|
||||
- 忽略:组内每条池条目都 `dismissed`。
|
||||
- 组内某条被单独识别 / 指定 / 忽略:从组里摘除,代表资源和候选选项换成剩下的最近一条;组空了才关事项。
|
||||
- 旧式单条事项(`identity:<releaseId>`)在该资源下次复查时并入合并事项并关闭。
|
||||
|
||||
收件箱:顶栏角标和待处理页缺省只算 warning 及以上(`minSeverity=warning`),"仅信息"类在待处理页「级别 → 含仅信息」里看。
|
||||
待处理页按问题类型 + 作品 / 来源分组,前 3 组展开,其余折叠;合并事项卡片可展开组内资源。
|
||||
|
||||
## 池的治理
|
||||
|
||||
- 复查有上限:`unmatched` 每天一次本地召回,最多 3 次;查过 TMDB 的按原退避(无结果 1h 起、查询失败 15min 起,翻倍到 7 天,最多 8 次)。
|
||||
- 新证据即重试:`title_alias` 插入触发器 `title_alias_wake_identity_pool`,把归一化标题相同或相似度 ≥ 0.45 的
|
||||
`unmatched` / `no_match` 池条目的 `next_review_at` 设为现在。建档、手动加别名、合并作品都会触发。
|
||||
- 过期归档:`identity.poolArchiveDays`(缺省 30)天没有新尝试的 `unmatched` 条目改为 `archived`,在池复查定时任务里做,
|
||||
每进程每小时最多一次。归档不删资源,仍可按状态筛出、手动指定。
|
||||
- 检索:媒体概览 → 待识别,按原始标题 / 解析标题检索,原因筛「未命中订阅」即 unmatched。条目详情里可「重新识别」
|
||||
(手动触发,会查 TMDB)、手动指定 TMDB ID / 本地作品(复用 `/identity-pool/{id}/assign`,加人工锁)或忽略。
|
||||
|
||||
## 设置
|
||||
|
||||
设置 → 元数据 → 资源识别:
|
||||
|
||||
- `identity.eagerTmdbLookup`(缺省关):打开即旧行为,每条新资源都查 TMDB。每条约 1–2 次搜索 + 命中时 1 次详情
|
||||
(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到。
|
||||
打开后无结果的资源仍只进池,不开事项。
|
||||
- `identity.poolArchiveDays`(缺省 30)。
|
||||
|
||||
## 存量清理(迁移 0047_identity_pool)
|
||||
|
||||
未关闭的 `identity_unresolved` 事项中,资源未关联作品、池里没有候选、归一化标题也不命中任何本地别名的:
|
||||
事项改 `dismissed`,`resolution = {"resolution":"moved_to_pool","by":"migration:0047_identity_pool"}`;
|
||||
池条目改 `reason = unmatched`、清 `decision_id`、停自动复查。相关的保留原样,下次复查时并入合并事项。
|
||||
|
||||
需要重建镜像 / 重启 api + worker 才生效(迁移随启动执行)。
|
||||
|
||||
## 排查
|
||||
|
||||
```sql
|
||||
-- 池分布
|
||||
select reason, status, count(*) from identity_candidate group by 1, 2 order by 3 desc;
|
||||
-- 某条资源为什么没查 TMDB
|
||||
select resolution_status, resolution_evidence->>'lookup' as lookup, resolution_evidence->>'detail' as detail
|
||||
from source_release where id = '<id>';
|
||||
-- 某个合并事项的成员
|
||||
select p.source_release_id, r.title, p.reason from identity_candidate p join source_release r on r.id = p.source_release_id
|
||||
where p.decision_id = '<decisionId>' and p.status = 'pending';
|
||||
-- 迁移搬走的事项
|
||||
select count(*) from decision where resolution->>'resolution' = 'moved_to_pool';
|
||||
```
|
||||
@@ -126,8 +126,11 @@ export type IdentityRevisionCreate = z.infer<typeof IdentityRevisionCreate>;
|
||||
|
||||
export const IdentityCandidateStatus = z.enum(["pending", "resolved", "dismissed", "archived"]);
|
||||
export type IdentityCandidateStatus = z.infer<typeof IdentityCandidateStatus>;
|
||||
/** proposed = 证据足够但自动化关闭/只读,待确认 */
|
||||
export const IdentityCandidateReason = z.enum(["ambiguous", "no_match", "proposed", "lookup_failed"]);
|
||||
/**
|
||||
* proposed = 证据足够但自动化关闭/只读,待确认;
|
||||
* unmatched = 惰性识别:本地召回无结果且没有触发 TMDB 查询(不相关的 RSS / 列表资源),只进池、不开待处理
|
||||
*/
|
||||
export const IdentityCandidateReason = z.enum(["ambiguous", "no_match", "proposed", "lookup_failed", "unmatched"]);
|
||||
export type IdentityCandidateReason = z.infer<typeof IdentityCandidateReason>;
|
||||
|
||||
/** 待识别池里的一个候选(只存 JSON,不建正式作品) */
|
||||
@@ -151,6 +154,8 @@ export const IdentityCandidate = EntityBase.extend({
|
||||
reason: IdentityCandidateReason,
|
||||
/** 解析出的检索条件(标题/年份/季集) */
|
||||
query: JsonObject,
|
||||
/** 归一化标题(与 title_alias.normalized 同口径):新增别名时按它唤醒复查,待处理按"来源 + 它"合并 */
|
||||
normalizedTitle: z.string().nullable(),
|
||||
candidates: z.array(PoolCandidate),
|
||||
evidenceVersion: z.string(),
|
||||
attempts: z.number().int().nonnegative(),
|
||||
@@ -171,6 +176,7 @@ export const ResolutionOutcome = z.enum([
|
||||
"lookup_failed",
|
||||
"manual",
|
||||
"skipped_locked",
|
||||
"unmatched",
|
||||
]);
|
||||
export type ResolutionOutcome = z.infer<typeof ResolutionOutcome>;
|
||||
|
||||
|
||||
@@ -357,6 +357,19 @@ export const BudgetSettings = z.object({
|
||||
});
|
||||
export type BudgetSettings = z.infer<typeof BudgetSettings>;
|
||||
|
||||
/**
|
||||
* 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)。
|
||||
* eagerTmdbLookup = true 恢复旧行为:每条新观察到的资源都查 TMDB。每条资源约 1–2 次搜索 + 命中时 1 次详情
|
||||
* (识别缓存命中则 0 次);一个每小时 100 条的 RSS 约为每天 2400–7200 次 TMDB 请求,多数是站点独有的短剧 / 综艺,查不到。
|
||||
* 关闭(缺省)时只在命中订阅 / 本地作品别名、已发布的新作品收录规则前置条件通过、用户手动识别或搜索时查询。
|
||||
*/
|
||||
export const IdentitySettings = z.object({
|
||||
eagerTmdbLookup: z.boolean(),
|
||||
/** 待识别池里 unmatched 条目多少天没有新证据就归档(仍可按状态筛出、可手动指定) */
|
||||
poolArchiveDays: z.number().int().min(1).max(3650),
|
||||
});
|
||||
export type IdentitySettings = z.infer<typeof IdentitySettings>;
|
||||
|
||||
export const GlobalSettings = z.object({
|
||||
automation: AutomationSettings,
|
||||
cache: CacheSettings,
|
||||
@@ -368,6 +381,7 @@ export const GlobalSettings = z.object({
|
||||
metadata: MetadataSettings,
|
||||
sites: SitesSettings,
|
||||
naming: NamingSettings,
|
||||
identity: IdentitySettings,
|
||||
});
|
||||
export type GlobalSettings = z.infer<typeof GlobalSettings>;
|
||||
|
||||
@@ -438,6 +452,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
userDataMaxSeedingPages: 10,
|
||||
},
|
||||
naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false },
|
||||
identity: { eagerTmdbLookup: false, poolArchiveDays: 30 },
|
||||
};
|
||||
|
||||
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
|
||||
@@ -486,6 +501,7 @@ export const GlobalSettingsUpdate = z.object({
|
||||
.optional(),
|
||||
sites: SitesSettings.partial().optional(),
|
||||
naming: NamingSettings.partial().optional(),
|
||||
identity: IdentitySettings.partial().optional(),
|
||||
});
|
||||
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
|
||||
|
||||
@@ -510,6 +526,7 @@ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpda
|
||||
metadata: { ...current.metadata, ...metadata },
|
||||
sites: { ...(current.sites ?? DEFAULT_SETTINGS.sites), ...patch.sites },
|
||||
naming: { ...(current.naming ?? DEFAULT_SETTINGS.naming), ...patch.naming },
|
||||
identity: { ...(current.identity ?? DEFAULT_SETTINGS.identity), ...patch.identity },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -666,11 +683,12 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||
{
|
||||
id: "metadata",
|
||||
label: "元数据",
|
||||
description: "TMDB API Key 与地址、TMDB 缓存、图片代理",
|
||||
description: "TMDB API Key 与地址、TMDB 缓存、资源识别、图片代理",
|
||||
legacyTabs: [],
|
||||
anchors: [
|
||||
{ id: "tmdb", label: "TMDB" },
|
||||
{ id: "tmdb-cache", label: "TMDB 缓存" },
|
||||
{ id: "identity", label: "资源识别" },
|
||||
{ id: "images", label: "图片代理与缓存" },
|
||||
],
|
||||
fields: [
|
||||
@@ -679,6 +697,8 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||
"cache.tmdbTtlHours",
|
||||
"cache.tmdbMaxSize",
|
||||
"cache.emptyResultTtlSec",
|
||||
"identity.eagerTmdbLookup",
|
||||
"identity.poolArchiveDays",
|
||||
"cache.globalImageCache",
|
||||
"cache.globalImageCacheDays",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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<any>): Promise<void> {
|
||||
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<any>): Promise<void> {
|
||||
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)};
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export type FeedSourceHealthStatus = "unknown" | "healthy" | "degraded" | "faili
|
||||
export type FieldOverrideSource = "tmdb" | "user" | "agent" | "nfo" | "site" | "import";
|
||||
export type FieldOverrideActorType = "user" | "agent" | "system" | "api_key";
|
||||
export type IdentityCandidateStatus = "pending" | "resolved" | "dismissed" | "archived";
|
||||
export type IdentityCandidateReason = "ambiguous" | "no_match" | "proposed" | "lookup_failed";
|
||||
export type IdentityCandidateReason = "ambiguous" | "no_match" | "proposed" | "lookup_failed" | "unmatched";
|
||||
export type IdentityRedirectActorType = "user" | "agent" | "system" | "api_key";
|
||||
export type IdentityRevisionAction =
|
||||
| "created"
|
||||
@@ -210,7 +210,8 @@ export type ResolutionAttemptOutcome =
|
||||
| "no_match"
|
||||
| "lookup_failed"
|
||||
| "manual"
|
||||
| "skipped_locked";
|
||||
| "skipped_locked"
|
||||
| "unmatched";
|
||||
export type ResolutionAttemptActorType = "user" | "agent" | "system" | "api_key";
|
||||
export type RuleGroupKind = "acquisition" | "upgrade" | "search";
|
||||
export type RuleGroupSource = "manual" | "chat" | "agent";
|
||||
@@ -787,6 +788,7 @@ export interface IdentityCandidateTable {
|
||||
status: Generated<IdentityCandidateStatus>;
|
||||
reason: IdentityCandidateReason;
|
||||
query: Generated<Json>;
|
||||
normalized_title: string | null;
|
||||
candidates: Generated<Json>;
|
||||
evidence_version: string;
|
||||
attempts: Generated<number>;
|
||||
|
||||
Reference in new issue
Block a user