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 <[email protected]>
This commit is contained in:
1 parent
a0b0a64246
commit
fb7727cf66
14 files changed
+280
-37
No files matched your search
@@ -148,7 +148,7 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 decision 的处理结果(身份歧义由 sources-identity 在处理端点里直接执行,这里跳过):
|
* 执行 decision 的处理结果(身份类由 sources-identity 在处理端点里直接执行,这里跳过):
|
||||||
* - 选项带 capabilityId → 经注册中心调用(仍走授权/审计/账本);
|
* - 选项带 capabilityId → 经注册中心调用(仍走授权/审计/账本);
|
||||||
* - retry → 重试关联任务;reevaluate / accept → 按对象重发 evaluateAcquisition;dismiss / ignore → 无动作。
|
* - 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();
|
const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
|
||||||
if (!d) return { executed: false, reason: "not_found" };
|
if (!d) return { executed: false, reason: "not_found" };
|
||||||
if (d.status !== "resolved") return { executed: false, reason: `status_${d.status}` };
|
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 };
|
const res = (d.resolution ?? {}) as { optionKey?: string | null; by?: string; execution?: unknown };
|
||||||
if (res.execution) return { executed: false, reason: "already_executed" };
|
if (res.execution) return { executed: false, reason: "already_executed" };
|
||||||
const key = res.optionKey ?? null;
|
const key = res.optionKey ?? null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { audit } from "../service/common.ts";
|
|||||||
import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts";
|
import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts";
|
||||||
|
|
||||||
// 待处理中心数据源:decision 列表/详情/聚合/处理。
|
// 待处理中心数据源:decision 列表/详情/聚合/处理。
|
||||||
// 身份歧义类由本模块直接执行所选候选;其它类型只记录处理结果并发 decision.updated,
|
// 身份类(歧义 / 待识别)由本模块直接执行所选候选或手动指定;其它类型只记录处理结果并发 decision.updated,
|
||||||
// 需要执行能力的选项由 W2-D 的 resolveDecision 消费。
|
// 需要执行能力的选项由 W2-D 的 resolveDecision 消费。
|
||||||
|
|
||||||
const OPEN = ["open", "investigating", "awaiting_user"] as const;
|
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;
|
const opt = body.optionKey ? options.find((o) => o.key === body.optionKey) : undefined;
|
||||||
if (body.optionKey && !opt) throw new ApiException("bad_request", `未知选项 ${body.optionKey}`);
|
if (body.optionKey && !opt) throw new ApiException("bad_request", `未知选项 ${body.optionKey}`);
|
||||||
|
|
||||||
// 身份歧义:直接执行(选候选 = 人工指定并加锁;忽略 = 待识别池 dismissed)
|
// 身份类:直接执行(选候选 / 手动指定 TMDB id / 选本地作品 = 人工指定并加锁;忽略 = 待识别池 dismissed)
|
||||||
if (d.kind === "identity_ambiguous" && d.subject_type === "source_release" && d.subject_id) {
|
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") {
|
if (body.dismiss || opt?.key === "dismiss") {
|
||||||
const pool = await db()
|
const pool = await db()
|
||||||
.selectFrom("identity_candidate")
|
.selectFrom("identity_candidate")
|
||||||
@@ -122,6 +124,28 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
|||||||
.where("source_release_id", "=", d.subject_id)
|
.where("source_release_id", "=", d.subject_id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (pool) await dismissPoolEntry(rt(), pool.id, actor, body.note ?? null);
|
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") {
|
} else if (opt?.input && typeof opt.input.candidateIndex === "number") {
|
||||||
const pool = await db()
|
const pool = await db()
|
||||||
.selectFrom("identity_candidate")
|
.selectFrom("identity_candidate")
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export async function openDecision(
|
|||||||
values (${d.kind}, ${d.severity ?? "warning"}, ${d.title}, ${d.summary ?? null}, ${d.subjectType}, ${d.subjectId},
|
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})
|
${d.dedupeKey}, ${jsonb(d.evidence ?? {})}::jsonb, ${jsonb(d.options ?? [])}::jsonb, ${d.nextReviewAt ?? null})
|
||||||
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
|
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
|
title = excluded.title, attempts = decision.attempts + 1
|
||||||
returning id, (xmax = 0) as inserted`.execute(trx);
|
returning id, (xmax = 0) as inserted`.execute(trx);
|
||||||
const r = row.rows[0]!;
|
const r = row.rows[0]!;
|
||||||
|
|||||||
@@ -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);
|
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(
|
async function toPool(
|
||||||
rt: SourcesRuntime,
|
rt: SourcesRuntime,
|
||||||
release: SourceReleaseRow,
|
release: SourceReleaseRow,
|
||||||
@@ -552,6 +570,14 @@ async function toPool(
|
|||||||
const nextReviewAt = delay === null ? null : new Date(now.getTime() + delay);
|
const nextReviewAt = delay === null ? null : new Date(now.getTime() + delay);
|
||||||
|
|
||||||
let decisionId: string | null = null;
|
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) {
|
if ((reason === "ambiguous" || reason === "proposed") && candidates.length > 0) {
|
||||||
const d = await openDecision(ctx, trx, {
|
const d = await openDecision(ctx, trx, {
|
||||||
kind: "identity_ambiguous",
|
kind: "identity_ambiguous",
|
||||||
@@ -561,25 +587,43 @@ async function toPool(
|
|||||||
subjectType: "source_release",
|
subjectType: "source_release",
|
||||||
subjectId: release.id,
|
subjectId: release.id,
|
||||||
dedupeKey: `identity:${release.id}`,
|
dedupeKey: `identity:${release.id}`,
|
||||||
evidence: {
|
evidence,
|
||||||
sourceReleaseId: release.id,
|
|
||||||
siteId: release.site_id,
|
|
||||||
reason,
|
|
||||||
detail,
|
|
||||||
evidenceVersion: version,
|
|
||||||
query,
|
|
||||||
},
|
|
||||||
options: [
|
options: [
|
||||||
...candidates.slice(0, 5).map((c, i) => ({
|
...candidates.slice(0, 5).map((c, i) => ({
|
||||||
key: `c${i}`,
|
key: `c${i}`,
|
||||||
label: `${c.title}${c.year ? ` (${c.year})` : ""} · ${c.localEntityId ? "本地作品" : `${c.provider}:${c.entityType}/${c.externalId}`}`,
|
label: `${c.title}${c.year ? ` (${c.year})` : ""} · ${c.localEntityId ? "本地作品" : `${c.provider}:${c.entityType}/${c.externalId}`}`,
|
||||||
input: { sourceReleaseId: release.id, candidateIndex: i },
|
input: { sourceReleaseId: release.id, candidateIndex: i },
|
||||||
})),
|
})),
|
||||||
|
...manualIdentityOptions(release.id),
|
||||||
{ key: "dismiss", label: "忽略此资源" },
|
{ key: "dismiss", label: "忽略此资源" },
|
||||||
],
|
],
|
||||||
nextReviewAt,
|
nextReviewAt,
|
||||||
});
|
});
|
||||||
decisionId = d.id;
|
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`
|
await sql`
|
||||||
|
|||||||
@@ -132,7 +132,13 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
|||||||
expect(pool.next_review_at!.getTime()).toBeGreaterThan(Date.now());
|
expect(pool.next_review_at!.getTime()).toBeGreaterThan(Date.now());
|
||||||
const d = await h.db.selectFrom("decision").selectAll().where("id", "=", res.decisionId!).executeTakeFirstOrThrow();
|
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 });
|
||||||
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);
|
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();
|
const rel = await h.db.selectFrom("source_release").selectAll().where("id", "=", r.id).executeTakeFirstOrThrow();
|
||||||
expect(rel.resolution_status).toBe("ambiguous");
|
expect(rel.resolution_status).toBe("ambiguous");
|
||||||
@@ -203,17 +209,60 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
|||||||
expect(after.media_entity_id).toBe(rel.media_entity_id);
|
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 r1 = await release("Zzqxv.Nonexistent.2019.1080p.WEB-DL-NOPE");
|
||||||
const a = await resolveRelease(h.rt, { sourceReleaseId: r1.id });
|
const a = await resolveRelease(h.rt, { sourceReleaseId: r1.id });
|
||||||
expect(a.outcome).toBe("no_match");
|
expect(a.outcome).toBe("no_match");
|
||||||
expect(a.decisionId).toBeNull();
|
expect(a.decisionId).not.toBeNull();
|
||||||
const rel = await h.db
|
const rel = await h.db
|
||||||
.selectFrom("source_release")
|
.selectFrom("source_release")
|
||||||
.select(["resolution_status"])
|
.select(["resolution_status"])
|
||||||
.where("id", "=", r1.id)
|
.where("id", "=", r1.id)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
expect(rel.resolution_status).toBe("unidentifiable");
|
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 });
|
tmdb.fault(/\/search\//, { status: 500, times: 20 });
|
||||||
const r2 = await release("Qwerty.Unknown.2020.1080p.WEB-DL-NOPE");
|
const r2 = await release("Qwerty.Unknown.2020.1080p.WEB-DL-NOPE");
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function ConfirmDialog({
|
|||||||
confirmLabel = "确认",
|
confirmLabel = "确认",
|
||||||
cancelLabel = "取消",
|
cancelLabel = "取消",
|
||||||
destructive = false,
|
destructive = false,
|
||||||
|
confirmDisabled = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -38,6 +39,8 @@ export function ConfirmDialog({
|
|||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
|
/** 输入未填完整时禁用确认按钮 */
|
||||||
|
confirmDisabled?: boolean;
|
||||||
onConfirm: () => unknown | Promise<unknown>;
|
onConfirm: () => unknown | Promise<unknown>;
|
||||||
/** 描述下方的额外内容(输入确认等) */
|
/** 描述下方的额外内容(输入确认等) */
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -74,7 +77,7 @@ export function ConfirmDialog({
|
|||||||
{children}
|
{children}
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>
|
<AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>
|
||||||
<Button variant={destructive ? "destructive" : "default"} onClick={run} disabled={pending}>
|
<Button variant={destructive ? "destructive" : "default"} onClick={run} disabled={pending || confirmDisabled}>
|
||||||
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,16 +15,22 @@ import { Chip, Tone } from "./parts.tsx";
|
|||||||
|
|
||||||
// 待识别资源详情(只读):候选证据 + 重新识别。选候选、忽略在待处理里做(判定只在待处理与作品详情)。
|
// 待识别资源详情(只读):候选证据 + 重新识别。选候选、忽略在待处理里做(判定只在待处理与作品详情)。
|
||||||
|
|
||||||
/** 多个候选 / 待确认的资源会生成"待确认身份"待处理事项 */
|
/** 多个候选 / 待确认的资源开"待确认身份"事项;没有候选的(无结果、复查耗尽)开"待识别"事项 */
|
||||||
export function inInbox(item: Pick<IdentityPoolItem, "status" | "reason" | "candidates">): boolean {
|
export function inInbox(item: Pick<IdentityPoolItem, "status" | "reason" | "candidates" | "decisionId">): boolean {
|
||||||
return (
|
return item.status === "pending" && (item.decisionId !== null || hasCandidates(item));
|
||||||
item.status === "pending" &&
|
}
|
||||||
(item.reason === "ambiguous" || item.reason === "proposed") &&
|
|
||||||
item.candidates.length > 0
|
function hasCandidates(item: Pick<IdentityPoolItem, "reason" | "candidates">): boolean {
|
||||||
);
|
return (item.reason === "ambiguous" || item.reason === "proposed") && item.candidates.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INBOX_IDENTITY = `${consolePath("inbox")}?kind=identity_ambiguous`;
|
export const INBOX_IDENTITY = `${consolePath("inbox")}?kind=identity_ambiguous`;
|
||||||
|
export const INBOX_UNRESOLVED = `${consolePath("inbox")}?kind=identity_unresolved`;
|
||||||
|
|
||||||
|
/** 条目对应的待处理筛选链接 */
|
||||||
|
export function inboxLink(item: Pick<IdentityPoolItem, "reason" | "candidates">): string {
|
||||||
|
return hasCandidates(item) ? INBOX_IDENTITY : INBOX_UNRESOLVED;
|
||||||
|
}
|
||||||
|
|
||||||
/** query 摘要:标题 / 年份 / 季集 */
|
/** query 摘要:标题 / 年份 / 季集 */
|
||||||
export function querySummary(q: Record<string, unknown>): string {
|
export function querySummary(q: Record<string, unknown>): string {
|
||||||
@@ -110,7 +116,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
|||||||
</Button>
|
</Button>
|
||||||
{queued ? (
|
{queued ? (
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={INBOX_IDENTITY}>
|
<Link to={inboxLink(item)}>
|
||||||
去待处理
|
去待处理
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
|
|||||||
|
|
||||||
export const DECISION_KIND: Record<DecisionKind, string> = {
|
export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||||
identity_ambiguous: "身份歧义",
|
identity_ambiguous: "身份歧义",
|
||||||
|
identity_unresolved: "待识别",
|
||||||
download_stalled: "下载无进展",
|
download_stalled: "下载无进展",
|
||||||
download_failed: "下载失败",
|
download_failed: "下载失败",
|
||||||
path_conflict: "路径冲突",
|
path_conflict: "路径冲突",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const downloadLabel = (v: string | null | undefined) => pick(DOWNLOAD, v)
|
|||||||
|
|
||||||
const DECISION_KIND: Record<string, string> = {
|
const DECISION_KIND: Record<string, string> = {
|
||||||
identity_ambiguous: "识别有歧义",
|
identity_ambiguous: "识别有歧义",
|
||||||
|
identity_unresolved: "待识别",
|
||||||
download_stalled: "下载无进展",
|
download_stalled: "下载无进展",
|
||||||
download_failed: "下载失败",
|
download_failed: "下载失败",
|
||||||
path_conflict: "路径冲突",
|
path_conflict: "路径冲突",
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
|||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { AliasesPanel } from "~/components/library/identity-aliases.tsx";
|
import { AliasesPanel } from "~/components/library/identity-aliases.tsx";
|
||||||
import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx";
|
import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx";
|
||||||
import { INBOX_IDENTITY, inInbox, PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
|
import {
|
||||||
|
INBOX_IDENTITY,
|
||||||
|
inboxLink,
|
||||||
|
inInbox,
|
||||||
|
PoolSheet,
|
||||||
|
querySummary,
|
||||||
|
} from "~/components/library/identity-pool-sheet.tsx";
|
||||||
import {
|
import {
|
||||||
EVIDENCE_LEVEL,
|
EVIDENCE_LEVEL,
|
||||||
IDENTITY_STATUS,
|
IDENTITY_STATUS,
|
||||||
@@ -77,7 +83,7 @@ function PoolTab() {
|
|||||||
<Tone v={POOL_REASON[r.reason]} />
|
<Tone v={POOL_REASON[r.reason]} />
|
||||||
{inInbox(r) ? (
|
{inInbox(r) ? (
|
||||||
<Link
|
<Link
|
||||||
to={INBOX_IDENTITY}
|
to={inboxLink(r)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="inline-flex items-center gap-0.5 text-meta whitespace-nowrap text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
className="inline-flex items-center gap-0.5 text-meta whitespace-nowrap text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import { consolePath } from "~/components/console/nav.ts";
|
|||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
|
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
|
||||||
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
|
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
|
||||||
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
|
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||||
@@ -99,9 +101,31 @@ function Evidence({ d }: { d: Decision }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */
|
||||||
|
type Requires = "tmdb" | "mediaEntityId" | null;
|
||||||
|
function requiresOf(option: DecisionOption): Requires {
|
||||||
|
const r = option.input?.requires;
|
||||||
|
return r === "tmdb" || r === "mediaEntityId" ? r : null;
|
||||||
|
}
|
||||||
|
|
||||||
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
||||||
const resolve = useResolveDecision();
|
const resolve = useResolveDecision();
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
const requires = requiresOf(option);
|
||||||
|
const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie");
|
||||||
|
const [tmdbId, setTmdbId] = useState("");
|
||||||
|
const [entity, setEntity] = useState<PickedEntity | null>(null);
|
||||||
|
const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim());
|
||||||
|
const identity =
|
||||||
|
requires === "tmdb"
|
||||||
|
? tmdbOk
|
||||||
|
? { tmdb: { type: tmdbType, id: tmdbId.trim() } }
|
||||||
|
: null
|
||||||
|
: requires === "mediaEntityId"
|
||||||
|
? entity
|
||||||
|
? { mediaEntityId: entity.id }
|
||||||
|
: null
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
@@ -117,16 +141,46 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
confirmLabel={option.label}
|
confirmLabel={option.label}
|
||||||
|
confirmDisabled={identity === null}
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
resolve.mutateAsync({ id: d.id, optionKey: option.key, note: note.trim() || undefined }).then(
|
resolve
|
||||||
() => toast.success(`已处理:${option.label}`),
|
.mutateAsync({
|
||||||
(err) => {
|
id: d.id,
|
||||||
toast.error(errorMessage(err, "处理失败"));
|
optionKey: option.key,
|
||||||
throw err;
|
note: note.trim() || undefined,
|
||||||
},
|
...(identity ? { identity } : {}),
|
||||||
)
|
})
|
||||||
|
.then(
|
||||||
|
() => toast.success(`已处理:${option.label}`),
|
||||||
|
(err) => {
|
||||||
|
toast.error(errorMessage(err, "处理失败"));
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{requires === "tmdb" ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="TMDB 类型"
|
||||||
|
value={tmdbType}
|
||||||
|
onChange={(e) => setTmdbType(e.target.value as "movie" | "tv")}
|
||||||
|
className="h-9 shrink-0 rounded-lg border border-input bg-transparent px-2 text-caption"
|
||||||
|
>
|
||||||
|
<option value="movie">电影</option>
|
||||||
|
<option value="tv">剧集</option>
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
aria-label="TMDB ID"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={tmdbId}
|
||||||
|
onChange={(e) => setTmdbId(e.target.value)}
|
||||||
|
placeholder="TMDB ID,如 872585"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : requires === "mediaEntityId" ? (
|
||||||
|
<EntityPicker value={entity} onChange={setEntity} />
|
||||||
|
) : null}
|
||||||
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="备注(可选)" rows={2} />
|
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="备注(可选)" rows={2} />
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
);
|
);
|
||||||
@@ -366,7 +420,7 @@ export default function InboxPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-body font-semibold text-muted-foreground">
|
<span className="text-body font-semibold text-muted-foreground">
|
||||||
{g.kind === "identity_ambiguous" ? "尚未归属作品" : "系统级"}
|
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
|
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export const DecisionKind = z.enum([
|
|||||||
"metadata_conflict",
|
"metadata_conflict",
|
||||||
"source_unreachable",
|
"source_unreachable",
|
||||||
"other",
|
"other",
|
||||||
|
/** 待识别资源没有可选候选(no_match / 复查耗尽的查询失败):手动指定 TMDB id、选本地作品或忽略 */
|
||||||
|
"identity_unresolved",
|
||||||
]);
|
]);
|
||||||
export type DecisionKind = z.infer<typeof DecisionKind>;
|
export type DecisionKind = z.infer<typeof DecisionKind>;
|
||||||
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
|
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
|
||||||
@@ -220,6 +222,24 @@ export const DecisionResolveInput = z.object({
|
|||||||
optionKey: z.string().min(1).optional(),
|
optionKey: z.string().min(1).optional(),
|
||||||
dismiss: z.boolean().optional(),
|
dismiss: z.boolean().optional(),
|
||||||
note: z.string().max(2000).optional(),
|
note: z.string().max(2000).optional(),
|
||||||
|
/**
|
||||||
|
* 身份类事项的手动指定(选项 input.requires 标明需要哪一项):
|
||||||
|
* requires=tmdb → tmdb {type, id};requires=mediaEntityId → mediaEntityId
|
||||||
|
*/
|
||||||
|
identity: z
|
||||||
|
.object({
|
||||||
|
mediaEntityId: Id.optional(),
|
||||||
|
tmdb: z
|
||||||
|
.object({
|
||||||
|
type: z.enum(["movie", "tv"]),
|
||||||
|
id: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^\d{1,10}$/),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
export type DecisionResolveInput = z.infer<typeof DecisionResolveInput>;
|
export type DecisionResolveInput = z.infer<typeof DecisionResolveInput>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import { execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||||
|
|
||||||
|
// W8-A 待识别资源没有匹配候选(no_match / 复查耗尽的查询失败)也进待处理中心:decision.kind 增加 identity_unresolved,
|
||||||
|
// 处理选项为手动指定 TMDB id / 选本地作品 / 忽略。
|
||||||
|
|
||||||
|
const OLD_KINDS = [
|
||||||
|
"identity_ambiguous",
|
||||||
|
"download_stalled",
|
||||||
|
"download_failed",
|
||||||
|
"path_conflict",
|
||||||
|
"space_insufficient",
|
||||||
|
"approval_required",
|
||||||
|
"metadata_conflict",
|
||||||
|
"source_unreachable",
|
||||||
|
"other",
|
||||||
|
];
|
||||||
|
const NEW_KINDS = [...OLD_KINDS, "identity_unresolved"];
|
||||||
|
|
||||||
|
const swap = (values: readonly string[]) => `
|
||||||
|
alter table decision drop constraint decision_kind_check;
|
||||||
|
alter table decision add constraint decision_kind_check ${oneOf("kind", values)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await execDdl(db, swap(NEW_KINDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await execDdl(db, `update decision set kind = 'other' where kind = 'identity_unresolved';\n${swap(OLD_KINDS)}`);
|
||||||
|
}
|
||||||
@@ -104,7 +104,8 @@ export type DecisionKind =
|
|||||||
| "approval_required"
|
| "approval_required"
|
||||||
| "metadata_conflict"
|
| "metadata_conflict"
|
||||||
| "source_unreachable"
|
| "source_unreachable"
|
||||||
| "other";
|
| "other"
|
||||||
|
| "identity_unresolved";
|
||||||
export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired";
|
export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired";
|
||||||
export type DecisionSeverity = "info" | "warning" | "critical";
|
export type DecisionSeverity = "info" | "warning" | "critical";
|
||||||
export type DownloadTaskOrigin = "submitted" | "adopted";
|
export type DownloadTaskOrigin = "submitted" | "adopted";
|
||||||
|
|||||||
Reference in new issue
Block a user