diff --git a/apps/server/src/modules/sources-identity/routes/decisions.ts b/apps/server/src/modules/sources-identity/routes/decisions.ts index b4fad28a..285fd0c3 100644 --- a/apps/server/src/modules/sources-identity/routes/decisions.ts +++ b/apps/server/src/modules/sources-identity/routes/decisions.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts"; import { sql } from "@umt/db"; +import { type ParsedRelease, parseRelease } from "@umt/domain"; import { z } from "zod"; import { ApiException } from "../../../http/errors.ts"; import { decisionHookFor } from "../../decision-hooks.ts"; @@ -41,6 +42,18 @@ const SummaryGroup = z.object({ 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) { const db = () => rt().ctx.db; @@ -119,7 +132,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { return toApi(row); }); - /** 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它 */ + /** + * 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它。 + * H3:年份 / 类型与所选作品不相容的资源不随组锁定,留在事项里单独处理。返回留下的资源数。 + */ async function applyToGroup( decisionId: string, subjectId: string, @@ -127,15 +143,35 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { dismissing: boolean, note: string | null, actor: Parameters[2], - ) { + ): Promise { const subject = await db() .selectFrom("source_release") .select(["media_entity_id"]) .where("id", "=", subjectId) .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) { 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) { const pool = await db() .selectFrom("identity_candidate") @@ -155,6 +191,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { rt().ctx.logger.warn({ err: e, srId, decisionId }, "合并事项组内资源处理失败"); } } + return kept; } 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 (!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") { diff --git a/apps/server/src/modules/sources-identity/service/identity-decisions.ts b/apps/server/src/modules/sources-identity/service/identity-decisions.ts index 3547fec5..fa3b08ae 100644 --- a/apps/server/src/modules/sources-identity/service/identity-decisions.ts +++ b/apps/server/src/modules/sources-identity/service/identity-decisions.ts @@ -1,5 +1,6 @@ import type { DecisionOption, PoolCandidate } from "@umt/contracts"; import { sql } from "@umt/db"; +import type { ParsedRelease } from "@umt/domain"; import { storageSettings } from "../../../shared-cache.ts"; import type { ServerContext } from "../../types.ts"; import { jsonb } from "../rows.ts"; @@ -8,7 +9,7 @@ import { closeDecisions, type Exec, openDecision } from "./common.ts"; // DEC-1 身份类待处理的合并与池治理(REQ §7.5、§13): // - 只有与订阅 / 本地作品相关(本地别名命中)或已查过 TMDB 仍有歧义的资源才开事项; -// - 同一来源 + 同一归一化标题合并为一条事项(dedupeKey identity-group::),成员 = 池里 +// - 同一来源 + 同一归一化标题(+ 年份 / 剧集线索)合并为一条事项(dedupeKey identity-group::<分组键>),成员 = 池里 // decision_id 指向它且仍 pending 的条目;事项的 subject 与候选选项取自代表资源(最近一次进来的那条); // - 成员被识别 / 指定 / 忽略时从组里摘除,组空了才关闭事项; // - 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}`; +/** + * 合并事项的分组键(H3):归一化标题,解析出年份 / 剧集线索时带上(`标题:年份:tv`)。同名不同年份或电影 / 剧集 + * 不进同一组,选择也就不会被整组套用到别的作品上。 + */ +export function identityGroupKey( + normalized: string, + p: Pick, +) { + 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 的对应字段 */ function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] { return [ diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index a41e0008..b7018efc 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -46,7 +46,12 @@ import { unitsAllowed, } from "./episode-resolution.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 { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts"; import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts"; @@ -933,6 +938,20 @@ interface ResolveScope { 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; @@ -997,22 +1016,18 @@ async function toPool( evidenceVersion: version, query, }; - // 只收相关的:查过 TMDB 仍有歧义(有候选可选),或与本地作品相关却无结果 / 查询失败且复查已停 - // ID-3:能从站点拿到 ID 的资源不开"没识别出作品":升级链还在推迟中(之后会抓详情)不开任何事项; - // 资源上已有 IMDb / TMDB / 豆瓣 ID 却仍无结果的,不开 identity_unresolved(只留在池里,可手动指定) - const hasSiteIds = siteExplicitIds(release.raw).length > 0 || (scope.escalation?.ids.length ?? 0) > 0; - const wantsDecision = pendingEscalation - ? false - : reason === "ambiguous" || reason === "proposed" - ? candidates.length > 0 - : scope.tracked && !hasSiteIds && (reason === "no_match" || (reason === "lookup_failed" && delay === null)); + // 只收相关的:查过 TMDB 仍有歧义(有候选可选),与本地作品相关却无结果,或查询失败且复查已停。 + // ID-3:升级链还在推迟中(之后会抓详情)不开任何事项。 + // 复查耗尽的查询失败不论是否命中本地作品都开(新作品收录规则 / eager 触发的也要让人看到); + // 与本地作品相关却无结果的,资源带站点 ID 也开(升级链已跑过仍没认出,不再静默留池) + const wantsDecision = decisionWanted(reason, candidates.length, scope.tracked, delay, pendingEscalation); 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, + groupKey: normalized ? identityGroupKey(normalized, parsed) : release.id, workTitle: parsed.title ?? null, sourceReleaseId: release.id, releaseTitle: release.title, diff --git a/apps/server/test/sources-identity/lazy-identity.test.ts b/apps/server/test/sources-identity/lazy-identity.test.ts index dee354d0..e3c53e3a 100644 --- a/apps/server/test/sources-identity/lazy-identity.test.ts +++ b/apps/server/test/sources-identity/lazy-identity.test.ts @@ -174,7 +174,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => { const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); 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); expect(ds).toHaveLength(1); const d = ds[0]!; @@ -210,7 +210,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => { ids.push(r.id); 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); const d = open[0]!; const pool = await h.db diff --git a/apps/server/test/sources-identity/resolve.test.ts b/apps/server/test/sources-identity/resolve.test.ts index 00b31fb3..d391215e 100644 --- a/apps/server/test/sources-identity/resolve.test.ts +++ b/apps/server/test/sources-identity/resolve.test.ts @@ -147,7 +147,7 @@ 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, 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([ "c0", "c1", @@ -178,7 +178,7 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => { await h.db .selectFrom("decision") .select("id") - .where("dedupe_key", "=", `identity-group:${siteId}:twins`) + .where("dedupe_key", "=", `identity-group:${siteId}:twins:1988`) .execute(), ).toHaveLength(1); });