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