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>, 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>) => [ { 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>, 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"); }); });