From fb7727cf66a983effdeba78cd31e5ea14ab7921e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 13:44:57 +0800 Subject: [PATCH] feat(identity): open identity_unresolved decisions for resources with no candidates no_match resources (and lookup failures after auto-review gives up) now open a decision in the inbox with options to set a TMDB ID, pick a local work, or dismiss; ambiguous decisions get the same manual options. Resolve accepts identity {tmdb | mediaEntityId} and assigns with a manual lock. The inbox option dialog collects the TMDB type/ID or searches local works; the identity pool links each pending item to its inbox filter. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/modules/agent/tasks.ts | 5 +- .../sources-identity/routes/decisions.ts | 30 +++++++- .../sources-identity/service/common.ts | 2 +- .../sources-identity/service/resolve.ts | 60 +++++++++++++--- .../test/sources-identity/resolve.test.ts | 55 ++++++++++++++- .../app/components/console/confirm-dialog.tsx | 5 +- .../library/identity-pool-sheet.tsx | 22 +++--- apps/web/app/components/library/labels.ts | 1 + apps/web/app/lib/labels.ts | 1 + apps/web/app/routes/console/identity.tsx | 10 ++- apps/web/app/routes/console/inbox.tsx | 70 ++++++++++++++++--- packages/contracts/src/agent.ts | 20 ++++++ .../0030_decision_identity_unresolved.ts | 33 +++++++++ packages/db/src/types.ts | 3 +- 14 files changed, 280 insertions(+), 37 deletions(-) create mode 100644 packages/db/migrations/0030_decision_identity_unresolved.ts diff --git a/apps/server/src/modules/agent/tasks.ts b/apps/server/src/modules/agent/tasks.ts index 0373ade1..70b09c79 100644 --- a/apps/server/src/modules/agent/tasks.ts +++ b/apps/server/src/modules/agent/tasks.ts @@ -148,7 +148,7 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv) } /** - * 执行 decision 的处理结果(身份歧义由 sources-identity 在处理端点里直接执行,这里跳过): + * 执行 decision 的处理结果(身份类由 sources-identity 在处理端点里直接执行,这里跳过): * - 选项带 capabilityId → 经注册中心调用(仍走授权/审计/账本); * - retry → 重试关联任务;reevaluate / accept → 按对象重发 evaluateAcquisition;dismiss / ignore → 无动作。 */ @@ -156,7 +156,8 @@ export async function executeResolution(ctx: ServerContext, decisionId: string) const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst(); if (!d) return { executed: false, reason: "not_found" }; if (d.status !== "resolved") return { executed: false, reason: `status_${d.status}` }; - if (d.kind === "identity_ambiguous") return { executed: false, reason: "handled_by_sources_identity" }; + if (d.kind === "identity_ambiguous" || d.kind === "identity_unresolved") + return { executed: false, reason: "handled_by_sources_identity" }; const res = (d.resolution ?? {}) as { optionKey?: string | null; by?: string; execution?: unknown }; if (res.execution) return { executed: false, reason: "already_executed" }; const key = res.optionKey ?? null; diff --git a/apps/server/src/modules/sources-identity/routes/decisions.ts b/apps/server/src/modules/sources-identity/routes/decisions.ts index 6e0f7410..c6e23f22 100644 --- a/apps/server/src/modules/sources-identity/routes/decisions.ts +++ b/apps/server/src/modules/sources-identity/routes/decisions.ts @@ -11,7 +11,7 @@ import { audit } from "../service/common.ts"; import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts"; // 待处理中心数据源:decision 列表/详情/聚合/处理。 -// 身份歧义类由本模块直接执行所选候选;其它类型只记录处理结果并发 decision.updated, +// 身份类(歧义 / 待识别)由本模块直接执行所选候选或手动指定;其它类型只记录处理结果并发 decision.updated, // 需要执行能力的选项由 W2-D 的 resolveDecision 消费。 const OPEN = ["open", "investigating", "awaiting_user"] as const; @@ -113,8 +113,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { const opt = body.optionKey ? options.find((o) => o.key === body.optionKey) : undefined; if (body.optionKey && !opt) throw new ApiException("bad_request", `未知选项 ${body.optionKey}`); - // 身份歧义:直接执行(选候选 = 人工指定并加锁;忽略 = 待识别池 dismissed) - if (d.kind === "identity_ambiguous" && d.subject_type === "source_release" && d.subject_id) { + // 身份类:直接执行(选候选 / 手动指定 TMDB id / 选本地作品 = 人工指定并加锁;忽略 = 待识别池 dismissed) + 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 pool = await db() .selectFrom("identity_candidate") @@ -122,6 +124,28 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { .where("source_release_id", "=", d.subject_id) .executeTakeFirst(); if (pool) await dismissPoolEntry(rt(), pool.id, actor, body.note ?? null); + } else if (requires === "tmdb") { + const tmdb = body.identity?.tmdb; + if (!tmdb) throw new ApiException("bad_request", "需要 identity.tmdb {type, id}"); + await assignReleaseIdentity( + rt(), + d.subject_id, + { + external: { provider: "tmdb", entityType: tmdb.type, externalId: tmdb.id }, + lock: true, + reason: body.note ?? `decision ${d.id}`, + }, + actor, + ); + } else if (requires === "mediaEntityId") { + const mediaEntityId = body.identity?.mediaEntityId; + if (!mediaEntityId) throw new ApiException("bad_request", "需要 identity.mediaEntityId"); + await assignReleaseIdentity( + rt(), + d.subject_id, + { mediaEntityId, lock: true, reason: body.note ?? `decision ${d.id}` }, + actor, + ); } else if (opt?.input && typeof opt.input.candidateIndex === "number") { const pool = await db() .selectFrom("identity_candidate") diff --git a/apps/server/src/modules/sources-identity/service/common.ts b/apps/server/src/modules/sources-identity/service/common.ts index f73760d7..7f2a49aa 100644 --- a/apps/server/src/modules/sources-identity/service/common.ts +++ b/apps/server/src/modules/sources-identity/service/common.ts @@ -60,7 +60,7 @@ export async function openDecision( values (${d.kind}, ${d.severity ?? "warning"}, ${d.title}, ${d.summary ?? null}, ${d.subjectType}, ${d.subjectId}, ${d.dedupeKey}, ${jsonb(d.evidence ?? {})}::jsonb, ${jsonb(d.options ?? [])}::jsonb, ${d.nextReviewAt ?? null}) on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user') - do update set evidence = excluded.evidence, options = excluded.options, summary = excluded.summary, + do update set kind = excluded.kind, evidence = excluded.evidence, options = excluded.options, summary = excluded.summary, title = excluded.title, attempts = decision.attempts + 1 returning id, (xmax = 0) as inserted`.execute(trx); const r = row.rows[0]!; diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 2b910265..26e26945 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -505,6 +505,24 @@ function reviewDelayMs(reason: string, attempts: number): number | null { 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" }, + }, + ]; +} + async function toPool( rt: SourcesRuntime, release: SourceReleaseRow, @@ -552,6 +570,14 @@ async function toPool( const nextReviewAt = delay === null ? null : new Date(now.getTime() + delay); let decisionId: string | null = null; + const evidence = { + sourceReleaseId: release.id, + siteId: release.site_id, + reason, + detail, + evidenceVersion: version, + query, + }; if ((reason === "ambiguous" || reason === "proposed") && candidates.length > 0) { const d = await openDecision(ctx, trx, { kind: "identity_ambiguous", @@ -561,25 +587,43 @@ async function toPool( subjectType: "source_release", subjectId: release.id, dedupeKey: `identity:${release.id}`, - evidence: { - sourceReleaseId: release.id, - siteId: release.site_id, - reason, - detail, - evidenceVersion: version, - query, - }, + 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}),已停止自动复查。手动指定作品或忽略` + : `没有匹配的作品(${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; } await sql` diff --git a/apps/server/test/sources-identity/resolve.test.ts b/apps/server/test/sources-identity/resolve.test.ts index 711c155c..7e3ce039 100644 --- a/apps/server/test/sources-identity/resolve.test.ts +++ b/apps/server/test/sources-identity/resolve.test.ts @@ -132,7 +132,13 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => { 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.options as { key: string }[]).map((o) => o.key)).toEqual(["c0", "c1", "dismiss"]); + expect((d.options as { key: string }[]).map((o) => o.key)).toEqual([ + "c0", + "c1", + "manual_tmdb", + "local_entity", + "dismiss", + ]); expect((await h.outbox("decision.created")).some((e) => e.payload.decisionId === d.id)).toBe(true); const rel = await h.db.selectFrom("source_release").selectAll().where("id", "=", r.id).executeTakeFirstOrThrow(); expect(rel.resolution_status).toBe("ambiguous"); @@ -203,17 +209,60 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => { expect(after.media_entity_id).toBe(rel.media_entity_id); }); - it("no_match 只进池不开待处理;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).toBeNull(); + expect(a.decisionId).not.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"]) + .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, + ); + 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) } } }, + }), + ); + 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 + .selectFrom("external_identity") + .selectAll() + .where("media_entity_id", "=", rel1.media_entity_id!) + .where("provider", "=", "tmdb") + .executeTakeFirstOrThrow(); + expect(ident.external_id).toBe(String(fx.id)); + + // 选本地作品 + 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 } }, + }), + ); + 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 }); tmdb.fault(/\/search\//, { status: 500, times: 20 }); const r2 = await release("Qwerty.Unknown.2020.1080p.WEB-DL-NOPE"); diff --git a/apps/web/app/components/console/confirm-dialog.tsx b/apps/web/app/components/console/confirm-dialog.tsx index dd29eb1f..e97430be 100644 --- a/apps/web/app/components/console/confirm-dialog.tsx +++ b/apps/web/app/components/console/confirm-dialog.tsx @@ -27,6 +27,7 @@ export function ConfirmDialog({ confirmLabel = "确认", cancelLabel = "取消", destructive = false, + confirmDisabled = false, onConfirm, children, }: { @@ -38,6 +39,8 @@ export function ConfirmDialog({ confirmLabel?: string; cancelLabel?: string; destructive?: boolean; + /** 输入未填完整时禁用确认按钮 */ + confirmDisabled?: boolean; onConfirm: () => unknown | Promise; /** 描述下方的额外内容(输入确认等) */ children?: ReactNode; @@ -74,7 +77,7 @@ export function ConfirmDialog({ {children} {cancelLabel} - diff --git a/apps/web/app/components/library/identity-pool-sheet.tsx b/apps/web/app/components/library/identity-pool-sheet.tsx index dcccdda0..6431cb95 100644 --- a/apps/web/app/components/library/identity-pool-sheet.tsx +++ b/apps/web/app/components/library/identity-pool-sheet.tsx @@ -15,16 +15,22 @@ import { Chip, Tone } from "./parts.tsx"; // 待识别资源详情(只读):候选证据 + 重新识别。选候选、忽略在待处理里做(判定只在待处理与作品详情)。 -/** 多个候选 / 待确认的资源会生成"待确认身份"待处理事项 */ -export function inInbox(item: Pick): boolean { - return ( - item.status === "pending" && - (item.reason === "ambiguous" || item.reason === "proposed") && - item.candidates.length > 0 - ); +/** 多个候选 / 待确认的资源开"待确认身份"事项;没有候选的(无结果、复查耗尽)开"待识别"事项 */ +export function inInbox(item: Pick): boolean { + return item.status === "pending" && (item.decisionId !== null || hasCandidates(item)); +} + +function hasCandidates(item: Pick): boolean { + return (item.reason === "ambiguous" || item.reason === "proposed") && item.candidates.length > 0; } export const INBOX_IDENTITY = `${consolePath("inbox")}?kind=identity_ambiguous`; +export const INBOX_UNRESOLVED = `${consolePath("inbox")}?kind=identity_unresolved`; + +/** 条目对应的待处理筛选链接 */ +export function inboxLink(item: Pick): string { + return hasCandidates(item) ? INBOX_IDENTITY : INBOX_UNRESOLVED; +} /** query 摘要:标题 / 年份 / 季集 */ export function querySummary(q: Record): string { @@ -110,7 +116,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on {queued ? (