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 → 经注册中心调用(仍走授权/审计/账本);
|
||||
* - 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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user