439 lines
20 KiB
TypeScript
439 lines
20 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import {
|
|
CAPABILITY_CATALOG,
|
|
type DecisionStatus,
|
|
type KnownCapabilityId,
|
|
ReleaseApplyFactsInput,
|
|
type ReleaseStaticFacts,
|
|
} from "@umt/contracts";
|
|
import { evaluateCandidate, parseRelease, type SlotSpec } from "@umt/domain";
|
|
import { toParsedColumns } from "@umt/mapping";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
|
import { autoInvestigate, checkRecovery, recoveryActions } from "../../src/modules/agent/investigate.ts";
|
|
import { registerSourcesCapabilities } from "../../src/modules/sources-identity/capabilities.ts";
|
|
import { parsedOf } from "../../src/modules/sources-identity/service/release-facts.ts";
|
|
import { applyReleaseFacts } from "../../src/modules/sources-identity/service/release-facts-admin.ts";
|
|
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
|
|
|
|
// 真实 PG、假引擎、不调用模型或站点:事实补全必须受事项和授权约束,补完仍按原规则判断。
|
|
const pg = await pgAvailable();
|
|
const EVIDENCE = "资源详情 MediaInfo 的视频轨道标明宽 1920、高 1080,编码 AVC。";
|
|
|
|
describe.skipIf(!pg)("资源事实补全与恢复", () => {
|
|
let h: Harness;
|
|
let siteId: string;
|
|
let entityId: string;
|
|
let anotherEntityId: string;
|
|
let n = 0;
|
|
|
|
beforeAll(async () => {
|
|
h = await createHarness("release_facts_recovery");
|
|
siteId = (await seedSite(h, { baseUrl: "https://facts.example.test/" })).site.id;
|
|
const entities = await h.db
|
|
.insertInto("media_entity")
|
|
.values([
|
|
{ kind: "movie", title: "Silent Meridian", year: 2025, status: "active" },
|
|
{ kind: "movie", title: "Another Film", year: 2024, status: "active" },
|
|
])
|
|
.returning("id")
|
|
.execute();
|
|
entityId = entities[0]!.id;
|
|
anotherEntityId = entities[1]!.id;
|
|
for (const [key, value] of Object.entries({
|
|
automation: { enabled: true },
|
|
agent: { autoInvestigate: true, autoInvestigateMode: "recover", investigateMaxAttempts: 3 },
|
|
})) {
|
|
await h.db
|
|
.insertInto("settings")
|
|
.values({ key, value: JSON.stringify(value) })
|
|
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(value) }))
|
|
.execute();
|
|
}
|
|
const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
|
|
registerSourcesCapabilities(registry, () => h.rt);
|
|
h.ctx.capabilities = registry;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await h.db.deleteFrom("automation_grant").execute();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
});
|
|
|
|
async function seed(opts: { title?: string; missing?: string[]; status?: DecisionStatus } = {}) {
|
|
const title = opts.title ?? "Silent Meridian 2025";
|
|
const columns = toParsedColumns(parseRelease(title));
|
|
const release = await h.db
|
|
.insertInto("source_release")
|
|
.values({
|
|
site_id: siteId,
|
|
site_release_id: String(++n),
|
|
title,
|
|
media_entity_id: entityId,
|
|
resolution_status: "resolved",
|
|
parsed: JSON.stringify(columns.parsed),
|
|
parser_version: columns.parserVersion,
|
|
raw: JSON.stringify({ _site: { marker: "snapshot" }, _detail: { imdbId: "tt1234567" } }),
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
const decision = await h.db
|
|
.insertInto("decision")
|
|
.values({
|
|
kind: "other",
|
|
title: "资源属性缺失",
|
|
status: opts.status ?? "open",
|
|
subject_type: "source_release",
|
|
subject_id: release.id,
|
|
dedupe_key: `release_facts:${release.id}`,
|
|
evidence: JSON.stringify({
|
|
sourceReleaseId: release.id,
|
|
mediaEntityId: entityId,
|
|
missing: opts.missing ?? ["resolution"],
|
|
}),
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
return { releaseId: release.id, decisionId: decision.id };
|
|
}
|
|
|
|
function input(target: Awaited<ReturnType<typeof seed>>, facts: ReleaseStaticFacts = { resolution: "1080p" }) {
|
|
return ReleaseApplyFactsInput.parse({
|
|
decisionId: target.decisionId,
|
|
sourceReleaseId: target.releaseId,
|
|
facts,
|
|
evidence: EVIDENCE,
|
|
evidenceUrl: "https://facts.example.test/details.php?id=1",
|
|
});
|
|
}
|
|
|
|
const releaseOf = (id: string) =>
|
|
h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
|
const decisionOf = (id: string) =>
|
|
h.db.selectFrom("decision").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
|
const eventsOf = async (id: string) =>
|
|
(await h.outbox("release.resolved")).filter((event) => event.payload.sourceReleaseId === id);
|
|
const actionOf = (target: Awaited<ReturnType<typeof seed>>) => [
|
|
{
|
|
capabilityId: "release.apply_facts",
|
|
input: input(target),
|
|
status: "succeeded",
|
|
},
|
|
];
|
|
|
|
async function grant(allowedEntityId = entityId, action = "release.apply_facts") {
|
|
return h.db
|
|
.insertInto("automation_grant")
|
|
.values({
|
|
action,
|
|
scope: JSON.stringify({ mediaEntityIds: [allowedEntityId] }),
|
|
granted_by: "admin:test",
|
|
reason: "只补全资源事实",
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
function invoke(target: Awaited<ReturnType<typeof seed>>, runId: string) {
|
|
return h.ctx.capabilities.invoke("release.apply_facts", input(target), {
|
|
actorId: "system:auto-recover",
|
|
taskId: randomUUID(),
|
|
scopeIds: [],
|
|
signal: new AbortController().signal,
|
|
source: "agent",
|
|
agentRunId: runId,
|
|
invocationId: randomUUID(),
|
|
});
|
|
}
|
|
|
|
it("事项与资源必须一致,拒绝后没有写入或重评事件", async () => {
|
|
const first = await seed();
|
|
const second = await seed();
|
|
const before = await releaseOf(second.releaseId);
|
|
await expect(applyReleaseFacts(h.ctx, { ...input(first), sourceReleaseId: second.releaseId })).rejects.toThrow(
|
|
/事项.*资源.*不一致/,
|
|
);
|
|
expect((await releaseOf(second.releaseId)).raw).toEqual(before.raw);
|
|
expect((await decisionOf(first.decisionId)).status).toBe("open");
|
|
expect(await eventsOf(second.releaseId)).toHaveLength(0);
|
|
});
|
|
|
|
it("只填事项里仍缺失的字段,已知字段及未请求字段均不能覆盖", async () => {
|
|
const target = await seed({ title: "Silent Meridian 2025 2160p", missing: ["resolution", "codec"] });
|
|
const before = await releaseOf(target.releaseId);
|
|
await expect(applyReleaseFacts(h.ctx, input(target, { resolution: "1080p", videoCodec: "H.264" }))).rejects.toThrow(
|
|
/只能补全/,
|
|
);
|
|
await expect(applyReleaseFacts(h.ctx, input(target, { source: "WEB-DL" }))).rejects.toThrow(/只能补全/);
|
|
expect((await releaseOf(target.releaseId)).raw).toEqual(before.raw);
|
|
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "2160p" });
|
|
expect(await eventsOf(target.releaseId)).toHaveLength(0);
|
|
expect(await applyReleaseFacts(h.ctx, input(target, { videoCodec: "H.264" }))).toEqual({
|
|
resolved: true,
|
|
remaining: [],
|
|
});
|
|
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "2160p", videoCodec: "H.264" });
|
|
});
|
|
|
|
it.each(["dismissed", "expired"] as const)("%s 的事项不能再写事实或重新打开", async (status) => {
|
|
const target = await seed({ status });
|
|
const before = await releaseOf(target.releaseId);
|
|
await expect(applyReleaseFacts(h.ctx, input(target))).rejects.toThrow(/已关闭/);
|
|
expect((await releaseOf(target.releaseId)).raw).toEqual(before.raw);
|
|
expect((await decisionOf(target.decisionId)).status).toBe(status);
|
|
expect(await eventsOf(target.releaseId)).toHaveLength(0);
|
|
});
|
|
|
|
it("事实与出处持久化,解析版本过期仍保留事实和规范季集,后来的明确解析优先", async () => {
|
|
const target = await seed({ title: "Silent Meridian S01E25", missing: ["resolution", "codec", "source", "group"] });
|
|
await h.db
|
|
.updateTable("source_release")
|
|
.set({
|
|
episode_resolution: JSON.stringify({
|
|
method: "absolute_cross_season",
|
|
canonical: { season: 2, episodes: { start: 13, end: 13 } },
|
|
}),
|
|
})
|
|
.where("id", "=", target.releaseId)
|
|
.execute();
|
|
const facts = { resolution: "1080p", videoCodec: "H.264", source: "BluRay", releaseGroup: "EvidenceTeam" } as const;
|
|
expect(await applyReleaseFacts(h.ctx, input(target, facts))).toEqual({ resolved: true, remaining: [] });
|
|
const stored = await releaseOf(target.releaseId);
|
|
expect(stored.raw).toMatchObject({
|
|
_site: { marker: "snapshot" },
|
|
_detail: { imdbId: "tt1234567" },
|
|
_facts: {
|
|
values: facts,
|
|
evidence: { resolution: { text: EVIDENCE, url: "https://facts.example.test/details.php?id=1" } },
|
|
},
|
|
});
|
|
expect(parsedOf(stored)).toMatchObject({
|
|
resolution: "1080p",
|
|
videoCodec: "H.264",
|
|
source: "BluRay",
|
|
isEncode: true,
|
|
releaseGroup: { name: "EvidenceTeam" },
|
|
});
|
|
await h.db.updateTable("source_release").set({ parser_version: "0" }).where("id", "=", target.releaseId).execute();
|
|
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({
|
|
resolution: "1080p",
|
|
videoCodec: "H.264",
|
|
source: "BluRay",
|
|
releaseGroup: { name: "EvidenceTeam" },
|
|
season: 2,
|
|
episodes: { start: 13, end: 13 },
|
|
seasonInferred: false,
|
|
});
|
|
const reparsed = toParsedColumns(parseRelease("Silent Meridian S02E13 2160p WEB-DL H.265"));
|
|
await h.db
|
|
.updateTable("source_release")
|
|
.set({ parsed: JSON.stringify(reparsed.parsed), parser_version: reparsed.parserVersion })
|
|
.where("id", "=", target.releaseId)
|
|
.execute();
|
|
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({
|
|
resolution: "2160p",
|
|
videoCodec: "H.265",
|
|
source: "WEB-DL",
|
|
isEncode: false,
|
|
});
|
|
});
|
|
|
|
it("原生站点字段刷新后空补丁可以关闭事项,并触发原资源重评", async () => {
|
|
const target = await seed({ missing: ["size", "seeders"] });
|
|
await h.db
|
|
.updateTable("source_release")
|
|
.set({ size_bytes: 400_000_000, seeders: 0 })
|
|
.where("id", "=", target.releaseId)
|
|
.execute();
|
|
expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] });
|
|
expect((await decisionOf(target.decisionId)).status).toBe("resolved");
|
|
expect(await eventsOf(target.releaseId)).toEqual([
|
|
expect.objectContaining({
|
|
payload: expect.objectContaining({
|
|
sourceReleaseId: target.releaseId,
|
|
mediaEntityId: entityId,
|
|
contentUnitIds: [],
|
|
}),
|
|
}),
|
|
]);
|
|
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered");
|
|
});
|
|
|
|
it("部分补全保持待处理,补齐后恢复;重复调用不会重复发重评事件", async () => {
|
|
const target = await seed({ missing: ["resolution", "codec"] });
|
|
expect(await applyReleaseFacts(h.ctx, input(target))).toEqual({ resolved: false, remaining: ["codec"] });
|
|
expect((await decisionOf(target.decisionId)).status).toBe("open");
|
|
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending");
|
|
const finalInput = input(target, { videoCodec: "H.264" });
|
|
expect(await applyReleaseFacts(h.ctx, finalInput)).toEqual({ resolved: true, remaining: [] });
|
|
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered");
|
|
const events = await eventsOf(target.releaseId);
|
|
expect(events).toHaveLength(2);
|
|
expect(await applyReleaseFacts(h.ctx, finalInput)).toEqual({ resolved: true, remaining: [] });
|
|
expect(await eventsOf(target.releaseId)).toEqual(events);
|
|
});
|
|
|
|
it("部分完成后的同值补丁可以重试,保留事实和原出处;不同值仍拒绝", async () => {
|
|
const target = await seed({ missing: ["resolution", "size"] });
|
|
const result = { resolved: false, remaining: ["size"] };
|
|
expect(await applyReleaseFacts(h.ctx, input(target))).toEqual(result);
|
|
const stored = await releaseOf(target.releaseId);
|
|
const events = await eventsOf(target.releaseId);
|
|
expect(events).toHaveLength(1);
|
|
expect(stored.raw).toMatchObject({
|
|
_facts: {
|
|
values: { resolution: "1080p" },
|
|
evidence: { resolution: { text: EVIDENCE, url: "https://facts.example.test/details.php?id=1" } },
|
|
},
|
|
});
|
|
|
|
const retry = {
|
|
...input(target),
|
|
evidence: "重试提供了另一段说明,不应覆盖第一次补全的出处。",
|
|
evidenceUrl: "https://facts.example.test/retry",
|
|
};
|
|
expect(await applyReleaseFacts(h.ctx, retry)).toEqual(result);
|
|
expect((await releaseOf(target.releaseId)).raw).toEqual(stored.raw);
|
|
expect(await eventsOf(target.releaseId)).toEqual(events);
|
|
expect((await decisionOf(target.decisionId)).status).toBe("open");
|
|
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending");
|
|
|
|
await expect(applyReleaseFacts(h.ctx, input(target, { resolution: "2160p" }))).rejects.toThrow(/只能补全/);
|
|
expect((await releaseOf(target.releaseId)).raw).toEqual(stored.raw);
|
|
expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBe("1080p");
|
|
expect(await eventsOf(target.releaseId)).toEqual(events);
|
|
expect((await decisionOf(target.decisionId)).status).toBe("open");
|
|
});
|
|
|
|
it("先补分辨率、后由原生详情补容量:空补丁完成时另发重评事件", async () => {
|
|
const target = await seed({ missing: ["resolution", "size"] });
|
|
expect(await applyReleaseFacts(h.ctx, input(target))).toEqual({ resolved: false, remaining: ["size"] });
|
|
const partialEvents = await eventsOf(target.releaseId);
|
|
expect(partialEvents).toHaveLength(1);
|
|
expect((await decisionOf(target.decisionId)).status).toBe("open");
|
|
|
|
await h.db
|
|
.updateTable("source_release")
|
|
.set({ size_bytes: 400_000_000 })
|
|
.where("id", "=", target.releaseId)
|
|
.execute();
|
|
expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] });
|
|
const completeEvents = await eventsOf(target.releaseId);
|
|
expect(completeEvents).toHaveLength(2);
|
|
expect(completeEvents.map((event) => event.idempotency_key)).not.toContain(null);
|
|
expect(new Set(completeEvents.map((event) => event.idempotency_key)).size).toBe(2);
|
|
expect((await decisionOf(target.decisionId)).status).toBe("resolved");
|
|
expect(parsedOf(await releaseOf(target.releaseId))).toMatchObject({ resolution: "1080p", sizeBytes: 400_000_000 });
|
|
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("recovered");
|
|
expect(await applyReleaseFacts(h.ctx, input(target, {}))).toEqual({ resolved: true, remaining: [] });
|
|
expect(await eventsOf(target.releaseId)).toEqual(completeEvents);
|
|
});
|
|
|
|
it("补出的事实超出原收录范围时仍拒绝,事实能力不接受策略字段", async () => {
|
|
const target = await seed();
|
|
const spec: SlotSpec = { name: "1080p", hard: { resolutions: ["1080p"] }, unknownPolicy: "hold" };
|
|
const original = structuredClone(spec);
|
|
expect(evaluateCandidate(spec, parsedOf(await releaseOf(target.releaseId))).decision).toBe("unknown_field");
|
|
await applyReleaseFacts(h.ctx, input(target, { resolution: "2160p" }));
|
|
expect(evaluateCandidate(spec, parsedOf(await releaseOf(target.releaseId)))).toMatchObject({
|
|
decision: "reject",
|
|
violations: [{ field: "resolution", code: "not_allowed" }],
|
|
});
|
|
expect(spec).toEqual(original);
|
|
expect(
|
|
ReleaseApplyFactsInput.safeParse({ ...input(target), facts: { resolution: "2160p", priority: 100 } }).success,
|
|
).toBe(false);
|
|
expect(ReleaseApplyFactsInput.safeParse({ ...input(target), resolutions: ["2160p"] }).success).toBe(false);
|
|
});
|
|
|
|
it("无事实授权时只调查,身份授权和其他作品的事实授权不能代替", async () => {
|
|
const target = await seed();
|
|
const investigation = await autoInvestigate(h.ctx, target.decisionId);
|
|
expect(investigation).toMatchObject({ created: true, mode: "investigate" });
|
|
await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权/);
|
|
await grant(entityId, "policy.identity");
|
|
await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权/);
|
|
await grant(anotherEntityId);
|
|
await expect(invoke(target, investigation.runId!)).rejects.toThrow(/授权|作品/);
|
|
expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBeUndefined();
|
|
expect(await eventsOf(target.releaseId)).toHaveLength(0);
|
|
});
|
|
|
|
it("资源事实会话只开放读取,有授权时只增加事实补全能力", async () => {
|
|
const readTarget = await seed();
|
|
const read = await autoInvestigate(h.ctx, readTarget.decisionId);
|
|
expect(read).toMatchObject({ created: true, mode: "investigate" });
|
|
const readRun = await h.db
|
|
.selectFrom("agent_run")
|
|
.selectAll()
|
|
.where("id", "=", read.runId!)
|
|
.executeTakeFirstOrThrow();
|
|
const readCapabilities = (readRun.scope as { capabilities: string[] }).capabilities;
|
|
expect(readCapabilities).toEqual(
|
|
expect.arrayContaining(["release.inspect", "decision.list", "web.search", "web.fetch"]),
|
|
);
|
|
expect(readCapabilities.filter((id) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind !== "read")).toEqual([]);
|
|
expect(readCapabilities).not.toContain("release.apply_facts");
|
|
|
|
await grant();
|
|
const recoverTarget = await seed();
|
|
const recover = await autoInvestigate(h.ctx, recoverTarget.decisionId);
|
|
expect(recover).toMatchObject({ created: true, mode: "recover" });
|
|
const recoverRun = await h.db
|
|
.selectFrom("agent_run")
|
|
.selectAll()
|
|
.where("id", "=", recover.runId!)
|
|
.executeTakeFirstOrThrow();
|
|
const recoverCapabilities = (recoverRun.scope as { capabilities: string[] }).capabilities;
|
|
expect(recoverCapabilities.filter((id) => id !== "release.apply_facts").sort()).toEqual(
|
|
[...readCapabilities].sort(),
|
|
);
|
|
expect(recoverCapabilities.filter((id) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.kind !== "read")).toEqual([
|
|
"release.apply_facts",
|
|
]);
|
|
for (const id of [
|
|
"identity.resolve",
|
|
"task.retry",
|
|
"download.recheck",
|
|
"episode_mapping.apply_rule",
|
|
"policy.apply",
|
|
"download.submit",
|
|
]) {
|
|
expect(readCapabilities).not.toContain(id);
|
|
expect(recoverCapabilities).not.toContain(id);
|
|
}
|
|
});
|
|
|
|
it("有持续授权时仅写本次调查资源,恢复动作进入账本并按持久事实核实", async () => {
|
|
await grant();
|
|
const target = await seed();
|
|
const other = await seed();
|
|
const investigation = await autoInvestigate(h.ctx, target.decisionId);
|
|
expect(investigation).toMatchObject({ created: true, mode: "recover" });
|
|
await expect(invoke(other, investigation.runId!)).rejects.toThrow(/当前调查事项/);
|
|
expect(await invoke(target, investigation.runId!)).toEqual({ resolved: true, remaining: [] });
|
|
const acts = await recoveryActions(h.ctx, investigation.runId!);
|
|
expect(acts.actions.filter((action) => action.status === "succeeded")).toEqual([
|
|
expect.objectContaining({
|
|
capabilityId: "release.apply_facts",
|
|
input: expect.objectContaining({ decisionId: target.decisionId, sourceReleaseId: target.releaseId }),
|
|
}),
|
|
]);
|
|
expect((await checkRecovery(h.ctx, acts.actions)).state).toBe("recovered");
|
|
expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBe("1080p");
|
|
expect(parsedOf(await releaseOf(other.releaseId)).resolution).toBeUndefined();
|
|
expect((await decisionOf(other.decisionId)).status).toBe("open");
|
|
expect(await eventsOf(target.releaseId)).toHaveLength(1);
|
|
expect(await eventsOf(other.releaseId)).toHaveLength(0);
|
|
});
|
|
|
|
it("事项状态单独为 resolved 不足以证明缺失事实已经恢复", async () => {
|
|
const target = await seed({ status: "resolved" });
|
|
expect(parsedOf(await releaseOf(target.releaseId)).resolution).toBeUndefined();
|
|
expect((await checkRecovery(h.ctx, actionOf(target))).state).toBe("pending");
|
|
});
|
|
});
|