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 { 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<typeof dismissPoolEntry>[2],
|
||||
) {
|
||||
): Promise<number> {
|
||||
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") {
|
||||
|
||||
@@ -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:<siteId>:<normalized>),成员 = 池里
|
||||
// - 同一来源 + 同一归一化标题(+ 年份 / 剧集线索)合并为一条事项(dedupeKey identity-group:<siteId>:<分组键>),成员 = 池里
|
||||
// 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<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 的对应字段 */
|
||||
function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] {
|
||||
return [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user