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,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user