fix(identity): 合并事项按年份与类型分组,组内不相容的资源不随组锁定;复查耗尽 / 相关无结果的资源开待处理(H3、M4、M5)

分组键 = 归一化标题 + 年份 + 剧集线索(解析有才带);选择后组内年份 / 类型与所选作品不相容的
资源不指定、留在事项里(事项保持打开,代表资源换成留下的那条)。
TMDB 查询失败复查耗尽的资源不论是否命中本地作品都开 identity_unresolved;命中本地作品却无结果的
资源带站点 ID 也开(升级链推迟中仍不开),不再静默留池。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:31:12 +08:00
1 parent dca3012227
commit fa18015354
5 files changed
+88 -19

No files matched your search

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts"; import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import { type ParsedRelease, parseRelease } from "@umt/domain";
import { z } from "zod"; import { z } from "zod";
import { ApiException } from "../../../http/errors.ts"; import { ApiException } from "../../../http/errors.ts";
import { decisionHookFor } from "../../decision-hooks.ts"; import { decisionHookFor } from "../../decision-hooks.ts";
@@ -41,6 +42,18 @@ const SummaryGroup = z.object({
latestAt: z.string(), latestAt: z.string(),
}); });
/**
* 组内资源与所选作品是否相容:资源有剧集线索而作品是电影不相容;资源年份与作品年份差超过 1 年不相容
* (剧集允许资源年份更晚,后续季)。缺年份按相容处理。
*/
export function releaseFitsEntity(p: ParsedRelease, e: { kind: string; year: number | null }): boolean {
const tv = p.mediaTypeHint === "tv" || p.season !== undefined || p.episodes !== undefined;
if (tv && e.kind === "movie") return false;
if (p.year === undefined || e.year === null) return true;
if (Math.abs(p.year - e.year) <= 1) return true;
return e.kind !== "movie" && p.year > e.year;
}
export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const db = () => rt().ctx.db; const db = () => rt().ctx.db;
@@ -119,7 +132,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
return toApi(row); return toApi(row);
}); });
/** 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它 */ /**
* 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它。
* H3:年份 / 类型与所选作品不相容的资源不随组锁定,留在事项里单独处理。返回留下的资源数。
*/
async function applyToGroup( async function applyToGroup(
decisionId: string, decisionId: string,
subjectId: string, subjectId: string,
@@ -127,15 +143,35 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
dismissing: boolean, dismissing: boolean,
note: string | null, note: string | null,
actor: Parameters<typeof dismissPoolEntry>[2], actor: Parameters<typeof dismissPoolEntry>[2],
) { ): Promise<number> {
const subject = await db() const subject = await db()
.selectFrom("source_release") .selectFrom("source_release")
.select(["media_entity_id"]) .select(["media_entity_id"])
.where("id", "=", subjectId) .where("id", "=", subjectId)
.executeTakeFirst(); .executeTakeFirst();
if (!dismissing && !subject?.media_entity_id) return; if (!dismissing && !subject?.media_entity_id) return 0;
const entity = dismissing
? null
: await db()
.selectFrom("media_entity")
.select(["kind", "year"])
.where("id", "=", subject!.media_entity_id!)
.executeTakeFirst();
let kept = 0;
for (const srId of others) { for (const srId of others) {
try { try {
if (!dismissing && entity) {
const r = await db()
.selectFrom("source_release")
.select(["title", "subtitle"])
.where("id", "=", srId)
.executeTakeFirst();
const parsed = r ? parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}) : null;
if (parsed && !releaseFitsEntity(parsed, entity)) {
kept++;
continue;
}
}
if (dismissing) { if (dismissing) {
const pool = await db() const pool = await db()
.selectFrom("identity_candidate") .selectFrom("identity_candidate")
@@ -155,6 +191,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
rt().ctx.logger.warn({ err: e, srId, decisionId }, "合并事项组内资源处理失败"); rt().ctx.logger.warn({ err: e, srId, decisionId }, "合并事项组内资源处理失败");
} }
} }
return kept;
} }
route(app, D.decisions.resolve, async ({ params, body, actor }) => { route(app, D.decisions.resolve, async ({ params, body, actor }) => {
@@ -231,6 +268,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
); );
} }
if (others.length) await applyToGroup(d.id, d.subject_id, others, dismissing, body.note ?? null, actor); if (others.length) await applyToGroup(d.id, d.subject_id, others, dismissing, body.note ?? null, actor);
// 组里还有没处理的资源(不相容而留下的):事项保持打开,代表资源已换成留下的那条
if (!dismissing && others.length && (await groupMemberIds(db(), d.id)).length) {
return toApi(await db().selectFrom("decision").selectAll().where("id", "=", d.id).executeTakeFirstOrThrow());
}
} }
// 站点登录需人工验证:"重试登录" = 入队刷新登录(结果另行更新账号状态与待处理) // 站点登录需人工验证:"重试登录" = 入队刷新登录(结果另行更新账号状态与待处理)
if (d.dedupe_key?.startsWith("site-login:") && opt?.key === "retry") { if (d.dedupe_key?.startsWith("site-login:") && opt?.key === "retry") {
@@ -1,5 +1,6 @@
import type { DecisionOption, PoolCandidate } from "@umt/contracts"; import type { DecisionOption, PoolCandidate } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import type { ParsedRelease } from "@umt/domain";
import { storageSettings } from "../../../shared-cache.ts"; import { storageSettings } from "../../../shared-cache.ts";
import type { ServerContext } from "../../types.ts"; import type { ServerContext } from "../../types.ts";
import { jsonb } from "../rows.ts"; import { jsonb } from "../rows.ts";
@@ -8,7 +9,7 @@ import { closeDecisions, type Exec, openDecision } from "./common.ts";
// DEC-1 身份类待处理的合并与池治理(REQ §7.5、§13): // DEC-1 身份类待处理的合并与池治理(REQ §7.5、§13):
// - 只有与订阅 / 本地作品相关(本地别名命中)或已查过 TMDB 仍有歧义的资源才开事项; // - 只有与订阅 / 本地作品相关(本地别名命中)或已查过 TMDB 仍有歧义的资源才开事项;
// - 同一来源 + 同一归一化标题合并为一条事项(dedupeKey identity-group:<siteId>:<normalized>),成员 = 池里 // - 同一来源 + 同一归一化标题(+ 年份 / 剧集线索)合并为一条事项(dedupeKey identity-group:<siteId>:<分组键>),成员 = 池里
// decision_id 指向它且仍 pending 的条目;事项的 subject 与候选选项取自代表资源(最近一次进来的那条); // decision_id 指向它且仍 pending 的条目;事项的 subject 与候选选项取自代表资源(最近一次进来的那条);
// - 成员被识别 / 指定 / 忽略时从组里摘除,组空了才关闭事项; // - 成员被识别 / 指定 / 忽略时从组里摘除,组空了才关闭事项;
// - unmatched 池条目超过 storage.retentionDays.identityPoolArchive 天没有新证据就归档。 // - unmatched 池条目超过 storage.retentionDays.identityPoolArchive 天没有新证据就归档。
@@ -19,6 +20,18 @@ export const GROUP_LIST_LIMIT = 50;
export const groupDedupeKey = (siteId: string, groupKey: string) => `identity-group:${siteId}:${groupKey}`; export const groupDedupeKey = (siteId: string, groupKey: string) => `identity-group:${siteId}:${groupKey}`;
/**
* 合并事项的分组键(H3):归一化标题,解析出年份 / 剧集线索时带上(`标题:年份:tv`)。同名不同年份或电影 / 剧集
* 不进同一组,选择也就不会被整组套用到别的作品上。
*/
export function identityGroupKey(
normalized: string,
p: Pick<ParsedRelease, "year" | "season" | "episodes" | "mediaTypeHint">,
) {
const tv = p.mediaTypeHint === "tv" || p.season !== undefined || p.episodes !== undefined;
return [normalized, ...(p.year !== undefined ? [String(p.year)] : []), ...(tv ? ["tv"] : [])].join(":");
}
/** 身份类事项的手动指定选项;resolve 时按 input.requires 取 DecisionResolveInput.identity 的对应字段 */ /** 身份类事项的手动指定选项;resolve 时按 input.requires 取 DecisionResolveInput.identity 的对应字段 */
function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] { function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] {
return [ return [
@@ -46,7 +46,12 @@ import {
unitsAllowed, unitsAllowed,
} from "./episode-resolution.ts"; } from "./episode-resolution.ts";
import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts"; import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts";
import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts"; import {
detachFromIdentityDecisions,
identityGroupKey,
openGroupDecision,
refreshGroupDecision,
} from "./identity-decisions.ts";
import { loadSubject, searchBangumi } from "./meta-subjects.ts"; import { loadSubject, searchBangumi } from "./meta-subjects.ts";
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts"; import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts"; import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
@@ -933,6 +938,20 @@ interface ResolveScope {
escalation: EscalationEvidence | null; escalation: EscalationEvidence | null;
} }
/** 进池时是否开(或保留)身份类待处理 */
export function decisionWanted(
reason: PoolReason,
candidateCount: number,
tracked: boolean,
delay: number | null,
pendingEscalation: boolean,
): boolean {
if (pendingEscalation) return false;
if (reason === "ambiguous" || reason === "proposed") return candidateCount > 0;
if (reason === "lookup_failed") return delay === null;
return tracked && reason === "no_match";
}
/** 推迟的升级(限额已满 / 入库时不抓)多久后复查 */ /** 推迟的升级(限额已满 / 入库时不抓)多久后复查 */
const ESCALATION_RETRY_MS = 15 * 60_000; const ESCALATION_RETRY_MS = 15 * 60_000;
@@ -997,22 +1016,18 @@ async function toPool(
evidenceVersion: version, evidenceVersion: version,
query, query,
}; };
// 只收相关的:查过 TMDB 仍有歧义(有候选可选),或与本地作品相关却无结果 / 查询失败且复查已停 // 只收相关的:查过 TMDB 仍有歧义(有候选可选),与本地作品相关却无结果,或查询失败且复查已停。
// ID-3:能从站点拿到 ID 的资源不开"没识别出作品":升级链还在推迟中(之后会抓详情)不开任何事项; // ID-3:升级链还在推迟中(之后会抓详情)不开任何事项。
// 资源上已有 IMDb / TMDB / 豆瓣 ID 却仍无结果的,不开 identity_unresolved(只留在池里,可手动指定) // 复查耗尽的查询失败不论是否命中本地作品都开(新作品收录规则 / eager 触发的也要让人看到);
const hasSiteIds = siteExplicitIds(release.raw).length > 0 || (scope.escalation?.ids.length ?? 0) > 0; // 与本地作品相关却无结果的,资源带站点 ID 也开(升级链已跑过仍没认出,不再静默留池)
const wantsDecision = pendingEscalation const wantsDecision = decisionWanted(reason, candidates.length, scope.tracked, delay, pendingEscalation);
? false
: reason === "ambiguous" || reason === "proposed"
? candidates.length > 0
: scope.tracked && !hasSiteIds && (reason === "no_match" || (reason === "lookup_failed" && delay === null));
let decisionId: string | null = null; let decisionId: string | null = null;
if (wantsDecision) { if (wantsDecision) {
const ambiguous = (reason === "ambiguous" || reason === "proposed") && candidates.length > 0; const ambiguous = (reason === "ambiguous" || reason === "proposed") && candidates.length > 0;
decisionId = await openGroupDecision(ctx, trx, { decisionId = await openGroupDecision(ctx, trx, {
kind: ambiguous ? "identity_ambiguous" : "identity_unresolved", kind: ambiguous ? "identity_ambiguous" : "identity_unresolved",
siteId: release.site_id, siteId: release.site_id,
groupKey: normalized ?? release.id, groupKey: normalized ? identityGroupKey(normalized, parsed) : release.id,
workTitle: parsed.title ?? null, workTitle: parsed.title ?? null,
sourceReleaseId: release.id, sourceReleaseId: release.id,
releaseTitle: release.title, releaseTitle: release.title,
@@ -174,7 +174,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
expect(res.outcome).toBe("ambiguous"); expect(res.outcome).toBe("ambiguous");
} }
const twinsGroup = `identity-group:${siteId}:twins`; const twinsGroup = `identity-group:${siteId}:twins:1988`;
const ds = (await identityDecisions()).filter((d) => d.dedupe_key === twinsGroup); const ds = (await identityDecisions()).filter((d) => d.dedupe_key === twinsGroup);
expect(ds).toHaveLength(1); expect(ds).toHaveLength(1);
const d = ds[0]!; const d = ds[0]!;
@@ -210,7 +210,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
ids.push(r.id); ids.push(r.id);
expect((await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" })).outcome).toBe("ambiguous"); expect((await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" })).outcome).toBe("ambiguous");
} }
const open = (await identityDecisions()).filter((x) => x.dedupe_key === `identity-group:${siteId}:nightfall`); const open = (await identityDecisions()).filter((x) => x.dedupe_key === `identity-group:${siteId}:nightfall:1995`);
expect(open).toHaveLength(1); expect(open).toHaveLength(1);
const d = open[0]!; const d = open[0]!;
const pool = await h.db const pool = await h.db
@@ -147,7 +147,7 @@ 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, severity: "warning" }); expect(d).toMatchObject({ kind: "identity_ambiguous", status: "open", subject_id: r.id, severity: "warning" });
expect(d.dedupe_key).toBe(`identity-group:${siteId}:twins`); expect(d.dedupe_key).toBe(`identity-group:${siteId}:twins:1988`);
expect((d.options as { key: string }[]).map((o) => o.key)).toEqual([ expect((d.options as { key: string }[]).map((o) => o.key)).toEqual([
"c0", "c0",
"c1", "c1",
@@ -178,7 +178,7 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
await h.db await h.db
.selectFrom("decision") .selectFrom("decision")
.select("id") .select("id")
.where("dedupe_key", "=", `identity-group:${siteId}:twins`) .where("dedupe_key", "=", `identity-group:${siteId}:twins:1988`)
.execute(), .execute(),
).toHaveLength(1); ).toHaveLength(1);
}); });