feat(search): 资源搜索结果附带规则判定
关联到本地作品的资源按作品收录版本的评估口径(硬条件 + 规则组链 + 阶梯,同自动获取的 evaluateCandidate)给出 ruleVerdict:命中第几档 / 不满足哪些维度 / 与库里当前版本相比高几档。 只调用 acquisition-organize 导出的 loadRuleCatalog、slotSpecWithRules、parsedOf、contextOf、 currentFacts;现有版本 pri 按 rulePriOfAsset 同口径在 search 模块里组合。一页最多判定 100 条、 20 个作品,收录版本 / 订阅 / 资源行 / 规则目录批量查一次;没关联作品或没有规则时为 null。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
fb6c76875c
commit
a7d20ca370
4 files changed
+556
-1
No files matched your search
@@ -16,6 +16,7 @@ import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
||||
import { siteCan } from "../sources-identity/site-kinds.ts";
|
||||
import { raceAbort } from "./media.ts";
|
||||
import { ruleVerdicts } from "./rule-verdict.ts";
|
||||
|
||||
// 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限);
|
||||
// 按(站点, 查询词)并发合并、短缓存 —— 换站点选择时已查过的站点直接复用;结果 upsert 为资源
|
||||
@@ -23,7 +24,7 @@ import { raceAbort } from "./media.ts";
|
||||
// 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。
|
||||
|
||||
/** 缓存里的资源条目(不含作品关联与库存叠加) */
|
||||
export type BaseRelease = Omit<SearchRelease, "mediaEntityId" | "entityMatch" | "entity" | "inventory">;
|
||||
export type BaseRelease = Omit<SearchRelease, "mediaEntityId" | "entityMatch" | "entity" | "inventory" | "ruleVerdict">;
|
||||
|
||||
export interface Fanout {
|
||||
items: BaseRelease[];
|
||||
@@ -502,6 +503,18 @@ export async function decorate(
|
||||
entities.set(e.id, e);
|
||||
}
|
||||
const inv = entityIds.length ? await loadInventory(entityIds) : new Map<string, InventoryEntry>();
|
||||
// 规则判定:失败只记警告,不影响结果列表
|
||||
const verdicts = await ruleVerdicts(
|
||||
db,
|
||||
items.map((it) => {
|
||||
const link = links.get(it.key);
|
||||
const e = link ? entities.get(link.id) : undefined;
|
||||
return { key: it.key, sourceReleaseId: it.sourceReleaseId, entity: e ? { id: e.id, kind: e.kind } : null };
|
||||
}),
|
||||
).catch((err: Error) => {
|
||||
rt.ctx.logger.warn({ err: { message: err.message } }, "搜索结果规则判定失败");
|
||||
return new Map<string, never>();
|
||||
});
|
||||
return items.map((it) => {
|
||||
const link = links.get(it.key);
|
||||
const e = link ? entities.get(link.id) : undefined;
|
||||
@@ -512,6 +525,7 @@ export async function decorate(
|
||||
entityMatch: e ? link!.match : null,
|
||||
entity: e ? { id: e.id, title: e.title, year: e.year, kind: e.kind as never } : null,
|
||||
inventory: entry ? overlay(entry, it.sourceReleaseId) : null,
|
||||
ruleVerdict: verdicts.get(it.key) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { SearchRuleVerdict } from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow, DB, Kysely, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||
import {
|
||||
currentPri,
|
||||
describeViolation,
|
||||
effectiveLadder,
|
||||
evaluateCandidate,
|
||||
type QualityFacts,
|
||||
qualityTier,
|
||||
ruleChainOf,
|
||||
type SlotEvaluation,
|
||||
type SlotSpec,
|
||||
} from "@umt/domain";
|
||||
import { contextOf, currentFacts, parsedOf } from "../acquisition-organize/services/acquisition.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog } from "../acquisition-organize/services/rules.ts";
|
||||
import { slotSpecWithRules } from "../acquisition-organize/services/subscriptions.ts";
|
||||
|
||||
// 搜索结果的规则判定:关联到本地作品的资源,按作品各收录版本的评估口径(硬条件 + 规则组链 + 阶梯,
|
||||
// 同自动获取的 evaluateCandidate)判断「命中第几档 / 不满足什么 / 比库里版本高几档」。
|
||||
// 只调用 acquisition-organize 导出的函数(规则目录、收录版本规格、资源 → 评估输入、当前版本特征);
|
||||
// 现有版本的 pri 重算(acquisition.ts 的 rulePriOfAsset 未导出)在这里按同一口径组合:
|
||||
// 有来源资源 → 按当前规则重算;否则取下载计划评估时记下的 pri;再否则按交付特征算。
|
||||
// 性能:一页最多判定 MAX_ITEMS 条、MAX_ENTITIES 个作品;收录版本、订阅、资源行、规则目录都是批量查一次。
|
||||
|
||||
export const MAX_VERDICT_ITEMS = 100;
|
||||
export const MAX_VERDICT_ENTITIES = 20;
|
||||
|
||||
export interface VerdictInput {
|
||||
key: string;
|
||||
sourceReleaseId: string | null;
|
||||
entity: { id: string; kind: string } | null;
|
||||
}
|
||||
|
||||
const FIELD_LABEL: Record<string, string> = {
|
||||
resolution: "分辨率",
|
||||
source: "来源",
|
||||
size: "体积",
|
||||
subtitles: "字幕",
|
||||
cut: "版本",
|
||||
group: "制作组",
|
||||
contentType: "内容类型",
|
||||
hdr: "HDR",
|
||||
codec: "编码",
|
||||
keywords: "关键字",
|
||||
site: "站点",
|
||||
seeders: "做种数",
|
||||
promotion: "促销",
|
||||
tier: "分档",
|
||||
rule: "规则组",
|
||||
};
|
||||
const fieldLabel = (f: string) => FIELD_LABEL[f] ?? f;
|
||||
|
||||
/** 收录版本有没有可判定的东西:硬条件、阶梯、带层或带硬排除的规则组(种子的默认组是空的,不算) */
|
||||
export function specHasRules(spec: SlotSpec): boolean {
|
||||
const hard = Object.values(spec.hard ?? {}).some((v) =>
|
||||
Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null && v !== false,
|
||||
);
|
||||
const groups = !!spec.rules?.groups.some((g) => g.layers.length > 0 || (g.hardExcludes?.length ?? 0) > 0);
|
||||
return hard || groups || !!spec.soft?.tiers?.length;
|
||||
}
|
||||
|
||||
const tieredSpec = (spec: SlotSpec) =>
|
||||
!!spec.soft?.tiers?.length || !!spec.rules?.groups.some((g) => g.layers.length > 0);
|
||||
|
||||
interface Candidate {
|
||||
verdict: Omit<SearchRuleVerdict, "slotCount" | "vsLibrary">;
|
||||
slot: AcquisitionSlotRow;
|
||||
spec: SlotSpec;
|
||||
ev: SlotEvaluation;
|
||||
pri: number;
|
||||
}
|
||||
|
||||
/** 一个收录版本的评估 → 判定(不含库存比较) */
|
||||
export function verdictOf(slot: Pick<AcquisitionSlotRow, "id" | "name">, ev: SlotEvaluation) {
|
||||
const base = { slotId: slot.id, slotName: slot.name };
|
||||
if (ev.decision === "unknown_field") {
|
||||
const missing = (ev.missing as string[]).filter((f) => f !== "current_asset");
|
||||
return {
|
||||
...base,
|
||||
outcome: "unknown" as const,
|
||||
tier: null,
|
||||
tierName: null,
|
||||
failed: [...new Set(missing.map(fieldLabel))],
|
||||
details: missing.map((f) => `标题里看不出${fieldLabel(f)}`),
|
||||
};
|
||||
}
|
||||
if (ev.decision === "reject") {
|
||||
const failed = ev.violations.map((v) =>
|
||||
v.field === "rule" && typeof v.expected === "string" ? `规则组「${v.expected}」` : fieldLabel(v.field),
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
outcome: "miss" as const,
|
||||
tier: null,
|
||||
tierName: null,
|
||||
failed: [...new Set(failed)],
|
||||
details: ev.violations.map(describeViolation),
|
||||
};
|
||||
}
|
||||
const level = ev.ladder?.level ?? null;
|
||||
return {
|
||||
...base,
|
||||
outcome: "hit" as const,
|
||||
tier: level === null ? null : level + 1,
|
||||
tierName: level === null ? null : (ev.ladder?.levelName ?? null),
|
||||
failed: [],
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
const RANK = { hit: 0, unknown: 1, miss: 2 } as const;
|
||||
|
||||
/**
|
||||
* 批量判定:返回 key → 判定。没关联作品、没有资源行、作品没有收录版本或收录版本都没配置规则的不出现在结果里。
|
||||
*/
|
||||
export async function ruleVerdicts(
|
||||
db: Kysely<DB>,
|
||||
items: readonly VerdictInput[],
|
||||
now: Date = new Date(),
|
||||
): Promise<Map<string, SearchRuleVerdict>> {
|
||||
const out = new Map<string, SearchRuleVerdict>();
|
||||
const entityIds: string[] = [];
|
||||
for (const it of items) {
|
||||
if (!it.entity || !it.sourceReleaseId) continue;
|
||||
if (!entityIds.includes(it.entity.id) && entityIds.length < MAX_VERDICT_ENTITIES) entityIds.push(it.entity.id);
|
||||
}
|
||||
const todo = items
|
||||
.filter((it) => it.entity && it.sourceReleaseId && entityIds.includes(it.entity.id))
|
||||
.slice(0, MAX_VERDICT_ITEMS);
|
||||
if (todo.length === 0) return out;
|
||||
|
||||
const slots = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "in", entityIds)
|
||||
.orderBy("position")
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
if (slots.length === 0) return out;
|
||||
const subIds = [...new Set(slots.map((s) => s.subscription_id).filter((x): x is string => !!x))];
|
||||
const [subs, rels, cat] = await Promise.all([
|
||||
subIds.length
|
||||
? db.selectFrom("subscription").select(["id", "params"]).where("id", "in", subIds).execute()
|
||||
: Promise.resolve([] as Pick<SubscriptionRow, "id" | "params">[]),
|
||||
db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where(
|
||||
"id",
|
||||
"in",
|
||||
todo.map((t) => t.sourceReleaseId!),
|
||||
)
|
||||
.execute(),
|
||||
loadRuleCatalog(db),
|
||||
]);
|
||||
const subById = new Map(subs.map((s) => [s.id, s]));
|
||||
const relById = new Map(rels.map((r) => [r.id, r]));
|
||||
const slotsByEntity = new Map<string, AcquisitionSlotRow[]>();
|
||||
for (const s of slots) slotsByEntity.set(s.media_entity_id, [...(slotsByEntity.get(s.media_entity_id) ?? []), s]);
|
||||
|
||||
const langs = new Map<string, Promise<string | undefined>>();
|
||||
const langOf = (id: string) => {
|
||||
if (!langs.has(id)) langs.set(id, entityOriginalLanguage(db, id));
|
||||
return langs.get(id)!;
|
||||
};
|
||||
const currentPriCache = new Map<string, Promise<{ facts: QualityFacts; pri: number | undefined } | null>>();
|
||||
|
||||
for (const it of todo) {
|
||||
const rel = relById.get(it.sourceReleaseId!);
|
||||
const entity = it.entity!;
|
||||
const entitySlots = slotsByEntity.get(entity.id) ?? [];
|
||||
if (!rel || entitySlots.length === 0) continue;
|
||||
const parsed = parsedOf(rel);
|
||||
const cands: Candidate[] = [];
|
||||
let ruled = 0;
|
||||
for (const slot of entitySlots) {
|
||||
const sub = slot.subscription_id ? subById.get(slot.subscription_id) : null;
|
||||
const spec = slotSpecWithRules(cat, slot, sub, entity, rel.site_id);
|
||||
if (!specHasRules(spec)) continue;
|
||||
ruled++;
|
||||
const ctx = contextOf(rel, now);
|
||||
if (spec.rules) {
|
||||
const lang = await langOf(entity.id);
|
||||
if (lang) ctx.originalLanguage = lang;
|
||||
}
|
||||
const ev = evaluateCandidate(spec, parsed, null, ctx);
|
||||
const pri = ev.decision === "accept" || ev.decision === "upgrade" ? (ev.pri ?? ev.ladder?.pri ?? 100) : -1;
|
||||
cands.push({ verdict: verdictOf(slot, ev), slot, spec, ev, pri });
|
||||
}
|
||||
if (cands.length === 0) continue;
|
||||
// 多个收录版本:命中 > 缺字段 > 不满足;同为命中取档位高的;再按收录版本顺序
|
||||
const best = [...cands].sort((a, b) => RANK[a.verdict.outcome] - RANK[b.verdict.outcome] || b.pri - a.pri)[0]!;
|
||||
let vsLibrary: SearchRuleVerdict["vsLibrary"] = null;
|
||||
if (best.verdict.outcome === "hit" && best.slot.current_asset_id) {
|
||||
const key = `${best.slot.id}:${rel.site_id}`;
|
||||
if (!currentPriCache.has(key))
|
||||
currentPriCache.set(key, currentVersion(db, best.spec, best.slot.current_asset_id, entity.id, langOf, now));
|
||||
const cur = await currentPriCache.get(key)!;
|
||||
if (cur) vsLibrary = compareWithLibrary(best.spec, parsed, best.pri, cur.facts, cur.pri);
|
||||
}
|
||||
out.set(it.key, { ...best.verdict, slotCount: ruled, vsLibrary });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 当前版本的交付特征 + 规则 pri(同 acquisition.ts rulePriOfAsset 的口径,只在分档时算) */
|
||||
async function currentVersion(
|
||||
db: Kysely<DB>,
|
||||
spec: SlotSpec,
|
||||
assetId: string,
|
||||
entityId: string,
|
||||
langOf: (id: string) => Promise<string | undefined>,
|
||||
now: Date,
|
||||
): Promise<{ facts: QualityFacts; pri: number | undefined } | null> {
|
||||
const facts = await currentFacts(db, assetId);
|
||||
if (!facts) return null;
|
||||
if (!tieredSpec(spec)) return { facts, pri: undefined };
|
||||
const a = await db
|
||||
.selectFrom("media_asset")
|
||||
.select(["source_release_id", "download_task_id"])
|
||||
.where("id", "=", assetId)
|
||||
.executeTakeFirst();
|
||||
let rel: SourceReleaseRow | undefined;
|
||||
if (a?.source_release_id)
|
||||
rel = await db.selectFrom("source_release").selectAll().where("id", "=", a.source_release_id).executeTakeFirst();
|
||||
if (rel) {
|
||||
const p = parsedOf(rel);
|
||||
const lang = spec.rules ? await langOf(entityId) : undefined;
|
||||
const ctx = { ...contextOf(rel, now), ...(lang ? { originalLanguage: lang } : {}) };
|
||||
return { facts, pri: effectiveLadder(spec, p, ctx, ruleChainOf(spec, p, ctx)).pri };
|
||||
}
|
||||
if (a?.download_task_id) {
|
||||
const p = await db
|
||||
.selectFrom("download_task as t")
|
||||
.innerJoin("acquisition_plan as p", "p.id", "t.acquisition_plan_id")
|
||||
.select("p.evaluation")
|
||||
.where("t.id", "=", a.download_task_id)
|
||||
.executeTakeFirst();
|
||||
const v = (p?.evaluation as { pri?: unknown } | undefined)?.pri;
|
||||
if (typeof v === "number") return { facts, pri: v };
|
||||
}
|
||||
return { facts, pri: currentPri(spec, facts) };
|
||||
}
|
||||
|
||||
/** 分档时比 pri(相差几档);不分档时比来源等级与分辨率(两项同向才下结论) */
|
||||
export function compareWithLibrary(
|
||||
spec: SlotSpec,
|
||||
candidate: Pick<QualityFacts, "resolution" | "source">,
|
||||
candidatePri: number,
|
||||
current: QualityFacts,
|
||||
currentRulePri: number | undefined,
|
||||
): SearchRuleVerdict["vsLibrary"] {
|
||||
if (tieredSpec(spec) && currentRulePri !== undefined) {
|
||||
const d = candidatePri - currentRulePri;
|
||||
return { cmp: d > 0 ? "higher" : d < 0 ? "lower" : "same", steps: Math.abs(d) };
|
||||
}
|
||||
const a = qualityTier(candidate, spec);
|
||||
const b = qualityTier(current, spec);
|
||||
if (a.source < 0 || a.resolution < 0 || b.source < 0 || b.resolution < 0) return null;
|
||||
const ds = a.source - b.source;
|
||||
const dr = a.resolution - b.resolution;
|
||||
if (ds === 0 && dr === 0) return { cmp: "same", steps: null };
|
||||
if (ds >= 0 && dr >= 0) return { cmp: "higher", steps: null };
|
||||
if (ds <= 0 && dr <= 0) return { cmp: "lower", steps: null };
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user