import { randomUUID } from "node:crypto"; import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; import { applyRecoveryOutcome, autoInvestigate, checkRecovery, recoveryActions, } from "../../src/modules/agent/investigate.ts"; import { INVESTIGATE_CAPABILITIES, RECOVER_CAPABILITIES } from "../../src/modules/agent/runner.ts"; import { registerSourcesCapabilities } from "../../src/modules/sources-identity/capabilities.ts"; import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts"; // 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):episode_mapping.inspect 的上下文包与上限、 // simulate(试一行作品识别词)、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则把这一行 // 连同理由注释追加进作品识别词 + 重算 + 事项关闭)、 // 后台调查接入(没有持续授权降级只读并留智能体建议 → 待处理一键确认;有授权时恢复动作按业务事实核实)。 // 不调真实模型:按假模型会走的调用顺序直接经注册中心调用能力。 const pg = await pgAvailable(); // biome-ignore lint/suspicious/noExplicitAny: 能力输出按 schema 校验过,测试里直接取字段 type Any = any; const weekly = (n: number, start: string): TmdbFixtureEpisode[] => Array.from({ length: n }, (_, k) => ({ episode_number: k + 1, air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, })); // 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集;E10 = 03-14、E11 = 03-21、E12 = 03-28) const KUSURIYA: TmdbFixture = { type: "tv", id: 990102, original_title: "Kusuriya no Hitorigoto", original_language: "ja", origin_country: ["JP"], date: "2023-10-22", titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" }, popularity: 30, alternative_titles: [], external_ids: {}, rich: { status: "Ended" }, seasons: [ { season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") }, { season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") }, ], } as TmdbFixture; const AIR = { 40: "2025-03-14T15:00:00Z", 41: "2025-03-21T15:00:00Z", 42: "2025-03-28T15:00:00Z" } as const; /** 作品识别词:第 1 季连续编号减 30 换到第 2 季 */ const ep30 = (group: string, expr = "EP-30") => `S01 @${group} >> S02 ${expr}`; describe.skipIf(!pg)("集数映射的智能体兜底", () => { let h: Harness; let tmdb: MockTmdb; let siteId: string; let n = 0; let kId: string; beforeAll(async () => { tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] }); h = await createHarness("episode_mapping_agent", { tmdbUrl: tmdb.url }); const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings }); registerSourcesCapabilities(registry, () => h.rt); (h.ctx as { capabilities: unknown }).capabilities = registry; siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; await h.db .insertInto("settings") .values({ key: "agent", value: JSON.stringify({ autoInvestigate: true, autoInvestigateMode: "recover", investigateMaxAttempts: 3 }), }) .execute(); }, 120_000); afterAll(async () => { await h?.close(); await tmdb?.close(); }); const resolve = async (title: string, publishedAt: string | null = null) => { const r = await h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: String(820000 + ++n), title, subtitle: "药屋少女的呢喃", source_published_at: publishedAt ? new Date(publishedAt) : null, }) .returningAll() .executeTakeFirstOrThrow(); const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); expect(res.outcome, JSON.stringify(res)).toBe("auto"); return { id: r.id, entityId: res.mediaEntityId! }; }; /** 某发布组的连续编号 40 / 41 / 42(发布时间贴着 S02E10-12 的播出日期) */ const continuous = async (group: string, eps: (40 | 41 | 42)[] = [40, 41, 42]) => { const ids: string[] = []; for (const e of eps) ids.push((await resolve(`[${group}] Kusuriya no Hitorigoto - ${e} [WEB-DL][1080p]`, AIR[e])).id); return ids; }; const decisionOf = async (group: string) => h.db .selectFrom("decision") .selectAll() .where("kind", "=", "episode_mapping") .where("dedupe_key", "=", `episode_mapping:${kId}:1:${group.toLowerCase()}`) .orderBy("created_at", "desc") .executeTakeFirstOrThrow(); /** 作品识别词里提到这个发布组的行(含注释) */ const wordsText = async () => (await h.db.selectFrom("identify_words").select("text").where("media_entity_id", "=", kId).executeTakeFirst()) ?.text ?? ""; const rulesOf = async (group: string) => (await wordsText()).split("\n").filter((l) => l.includes(`@${group}`)); const methods = async (ids: string[]) => (await h.db.selectFrom("source_release").select(["id", "episode_resolution"]).where("id", "in", ids).execute()).map( (r) => (r.episode_resolution as { method: string }).method, ); const invoke = (id: string, input: Record, runId?: string) => h.ctx.capabilities.invoke(id, input, { actorId: runId ? "system:auto-investigate" : "user:admin", taskId: randomUUID(), scopeIds: [], signal: new AbortController().signal, source: runId ? "agent" : "web", ...(runId ? { agentRunId: runId, invocationId: randomUUID() } : {}), }) as Promise; let lilith: string[]; it("准备:季表就绪,Lilith-Raws 连续编号 40-42 待确认、开一条待处理;同组 S02E09 原号已对上", async () => { kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId; expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed"); lilith = await continuous("Lilith-Raws"); await resolve("[Lilith-Raws] Kusuriya no Hitorigoto S02E09 [WEB-DL][1080p]", "2025-03-07T15:00:00Z"); expect(await methods(lilith)).toEqual(["unresolved", "unresolved", "unresolved"]); const d = await decisionOf("Lilith-Raws"); expect(d.status).toBe("open"); expect((d.evidence as Any).sourceReleaseIds.sort()).toEqual([...lilith].sort()); }); it("inspect:事项内资源、同组相邻资源、季表(播出日期与标题)、规则、确定性建议", async () => { const d = await decisionOf("Lilith-Raws"); const out = await invoke("episode_mapping.inspect", { decisionId: d.id }); expect(out.entity).toMatchObject({ id: kId, kind: "series" }); expect(out.scope).toEqual({ season: 1, releaseGroup: "Lilith-Raws" }); expect(out.decision).toMatchObject({ id: d.id, status: "open" }); expect(out.decision).not.toHaveProperty("failedRules"); expect(out.releases).toHaveLength(3); expect(out.releases[0]).toMatchObject({ releaseGroup: "Lilith-Raws", source: { season: 1, episodes: { start: 42, end: 42 } }, current: { method: "unresolved", canonical: null }, }); expect(out.releases[0].publishedAt).toBe("2025-03-28T15:00:00.000Z"); // 相邻资源:同组已对上的 S02E09;别的发布组(ANi)不在其中 expect(out.neighbors).toHaveLength(1); expect(out.neighbors[0]).toMatchObject({ source: { season: 2, episodes: { start: 9, end: 9 } }, current: { method: "raw", canonical: { season: 2, episodes: { start: 9, end: 9 } } }, }); const s2 = out.seasons.find((s: Any) => s.season === 2); expect(s2.total).toBe(13); expect(s2.episodes[9]).toMatchObject({ episode: 10, airDate: "2025-03-14", title: "第 10 集" }); expect(out).toMatchObject({ latestSeason: 2, ended: true, words: { text: "", lines: [{ line: 1, kind: "blank" }] }, }); expect(out.suggestion).toMatchObject({ season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2, }); expect(out.truncated).toEqual({ releases: false, neighbors: false }); }); it("inspect 有上限:相邻资源最多 50 条并标出截断;不给事项时按作品 + 发布组取", async () => { const rows = Array.from({ length: 55 }, (_, k) => ({ site_id: siteId, site_release_id: `cap-${k}`, title: `[Cap-Group] Kusuriya no Hitorigoto - ${String((k % 12) + 1).padStart(2, "0")} [${k}]`, media_entity_id: kId, resolution_status: "resolved" as const, episode_resolution: JSON.stringify({ method: "raw", source: { season: 1, episodes: { start: (k % 12) + 1, end: (k % 12) + 1 } }, canonical: { season: 1, episodes: { start: (k % 12) + 1, end: (k % 12) + 1 } }, evidence: ["validate:pass"], }), })); await h.db.insertInto("source_release").values(rows).execute(); const out = await invoke("episode_mapping.inspect", { mediaEntityId: kId, season: 1, releaseGroup: "cap-group" }); expect(out.decision).toBeNull(); expect(out.releases).toEqual([]); expect(out.neighbors).toHaveLength(50); expect(out.truncated).toEqual({ releases: false, neighbors: true }); // 每条只带判断用的字段 expect(Object.keys(out.neighbors[0]).sort()).toEqual( ["current", "evidence", "publishedAt", "releaseGroup", "source", "sourceReleaseId", "title"].sort(), ); }); it("simulate:草稿越界 → 没通过并写明原因;EP-30 → 样本全部解决;纯读,控制台调用不留建议", async () => { const d = await decisionOf("Lilith-Raws"); const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Lilith-Raws", "EP-20") }); expect(bad.summary).toMatchObject({ samples: 3, resolved: 0, ok: false }); expect(bad.failures.join("")).toContain("仍对不上季表"); const ok = await invoke("episode_mapping.simulate", { decisionId: d.id, line: "s01 @Lilith-Raws >> s02 ep - 30", reasons: ["发布时间对上播出日期"], }); // 回给规范写法 expect(ok.line).toBe("S01 @Lilith-Raws >> S02 EP-30"); expect(ok.summary).toMatchObject({ samples: 3, resolved: 3, passed: 3, regressions: 0, missing: 0, ok: true }); expect(ok.failures).toEqual([]); const i40 = ok.items.find((i: Any) => i.sourceReleaseId === lilith[0]); expect(i40).toMatchObject({ sample: true, before: { method: "unresolved" }, after: { method: "rule", canonical: { season: 2, episodes: { start: 10, end: 10 } } }, validation: "pass", }); expect(ok.suggestionRecorded).toBe(false); expect(await rulesOf("Lilith-Raws")).toHaveLength(0); expect((await decisionOf("Lilith-Raws")).evidence).not.toHaveProperty("agentSuggestion"); }); it("apply_rule:模拟不通过被拒,不写规则、资源与事项不变", async () => { const d = await decisionOf("Lilith-Raws"); await expect( invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Lilith-Raws", "EP-20"), reasons: ["试试"], }), ).rejects.toThrow(/模拟没通过/); expect(await rulesOf("Lilith-Raws")).toHaveLength(0); expect(await methods(lilith)).toEqual(["unresolved", "unresolved", "unresolved"]); expect((await decisionOf("Lilith-Raws")).status).toBe("open"); }); it("apply_rule:会让已对上的资源变成待确认的规则被拒(同组 S01E05 原号已对上)", async () => { await resolve("[Sakurato] Kusuriya no Hitorigoto - 05 [WEB-DL][1080p]", "2023-11-19T15:00:00Z"); const ids = await continuous("Sakurato", [40, 41]); const d = await decisionOf("Sakurato"); const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Sakurato") }); expect(sim.summary).toMatchObject({ samples: 2, resolved: 2, regressions: 1, ok: false }); expect(sim.failures.join("")).toContain("已对上的资源变成待确认"); await expect( invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Sakurato"), reasons: ["连续编号"] }), ).rejects.toThrow(/已对上的资源变成待确认/); expect(await rulesOf("Sakurato")).toHaveLength(0); expect(await methods(ids)).toEqual(["unresolved", "unresolved"]); }); it("后台调查没有持续授权:降级只读,模拟通过的规则留作智能体建议;写规则被拒;待处理一键确认", async () => { const d = await decisionOf("Lilith-Raws"); const r = await autoInvestigate(h.ctx, d.id); expect(r).toMatchObject({ created: true, mode: "investigate" }); const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", r.runId!).executeTakeFirstOrThrow(); expect(run).toMatchObject({ trigger: "event", mode: "plan_only" }); expect(run.scope).toMatchObject({ capabilities: [...INVESTIGATE_CAPABILITIES] }); expect((run.scope as Any).capabilities).toEqual( expect.arrayContaining(["episode_mapping.inspect", "episode_mapping.simulate"]), ); expect((run.scope as Any).capabilities).not.toContain("episode_mapping.apply_rule"); expect(run.goal).toContain("episode_mapping.inspect"); const inv = await decisionOf("Lilith-Raws"); expect(inv.status).toBe("investigating"); expect((inv.evidence as Any).autoRecover).toMatchObject({ mode: "investigate", requestedMode: "recover" }); expect((inv.evidence as Any).autoRecover.degraded.reason).toContain("episode_mapping.apply_rule"); // 假模型:inspect → simulate(带理由)→ 试图写规则 await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id); const sim = await invoke( "episode_mapping.simulate", { decisionId: d.id, line: ep30("Lilith-Raws"), reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"], }, run.id, ); expect(sim).toMatchObject({ suggestionRecorded: true, summary: { ok: true } }); await expect( invoke( "episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Lilith-Raws"), reasons: ["对上播出日期"] }, run.id, ), ).rejects.toThrow(/授权/); expect(await rulesOf("Lilith-Raws")).toHaveLength(0); const withSug = await decisionOf("Lilith-Raws"); expect((withSug.evidence as Any).agentSuggestion).toMatchObject({ line: "S01 @Lilith-Raws >> S02 EP-30", reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"], simulation: { samples: 3, resolved: 3, ok: true }, runId: run.id, }); expect((withSug.evidence as Any).agentSuggestion.samples).toHaveLength(3); expect((withSug.options as Any[]).map((o) => o.key)).toEqual([ "confirm_agent_suggestion", "confirm_suggestion", "ignore_rule", ]); // 新资源进来刷新事项:建议与自动调查记录保留 const more = await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 43 [WEB-DL][1080p]", "2025-04-04T15:00:00Z"); const refreshed = await decisionOf("Lilith-Raws"); expect(refreshed.id).toBe(d.id); expect((refreshed.evidence as Any).sourceReleaseIds).toContain(more.id); expect((refreshed.evidence as Any).agentSuggestion).toMatchObject({ runId: run.id }); expect((refreshed.evidence as Any).autoRecover).toMatchObject({ mode: "investigate" }); expect((refreshed.options as Any[])[0].key).toBe("confirm_agent_suggestion"); // 待处理「确认智能体建议」:按当前资源再模拟(43 → S02E13 也对得上)→ 建规则 + 重算 + 事项关闭 const body = await json( await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_agent_suggestion" } }), ); expect(body.status, JSON.stringify(body.body)).toBe(200); expect(body.body.data).toMatchObject({ status: "resolved", resolution: { optionKey: "confirm_agent_suggestion", execution: { reresolve: { resolved: 4 } } }, }); expect(body.body.data.resolution.execution).toMatchObject({ line: "S01 @Lilith-Raws >> S02 EP-30", simulation: { samples: 4, ok: true }, }); // 写进作品识别词:上面一行注释写明来历与理由 expect((await wordsText()).trimEnd().split("\n")).toEqual([ expect.stringMatching(/^# \d{4}-\d{2}-\d{2} 智能体建议:40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期$/), "S01 @Lilith-Raws >> S02 EP-30", ]); expect(await methods([...lilith, more.id])).toEqual(["rule", "rule", "rule", "rule"]); }); it("确认智能体建议前再模拟:现在不通过就拒绝,不写规则", async () => { const ids = await continuous("Kirara", [40, 41]); const d = await decisionOf("Kirara"); // 直接写一条现在模拟不通过的建议(如季表变了之后) await h.db .updateTable("decision") .set({ evidence: JSON.stringify({ ...(d.evidence as object), agentSuggestion: { line: "S01 @Kirara >> S02 EP-20", reasons: ["旧建议"], simulation: { samples: 2, resolved: 2, passed: 2, missing: 0, regressions: 0, changed: 2, scanned: 2, ok: true, }, samples: [], runId: null, at: new Date().toISOString(), }, }), options: JSON.stringify([{ key: "confirm_agent_suggestion", label: "确认智能体建议" }]), }) .where("id", "=", d.id) .execute(); const r = await json( await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_agent_suggestion" } }), ); expect(r.status).toBe(409); expect(r.body.error.message).toContain("模拟不通过"); expect(await rulesOf("Kirara")).toHaveLength(0); expect(await methods(ids)).toEqual(["unresolved", "unresolved"]); expect((await decisionOf("Kirara")).status).toBe("open"); }); it("后台调查有持续授权:inspect → simulate → apply_rule 写规则(智能体 / 多证据)+ 重算 + 事项关闭,系统核实已恢复", async () => { await h.db .insertInto("automation_grant") .values({ action: "episode_mapping.apply_rule", granted_by: "admin:test", reason: "集数映射兜底" }) .execute(); const ids = await continuous("Nekomoe"); const d = await decisionOf("Nekomoe"); const r = await autoInvestigate(h.ctx, d.id); expect(r).toMatchObject({ created: true, mode: "recover" }); const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", r.runId!).executeTakeFirstOrThrow(); expect(run).toMatchObject({ mode: "execute", created_by: "system:auto-recover" }); expect(run.scope).toMatchObject({ capabilities: [...RECOVER_CAPABILITIES] }); expect((run.scope as Any).capabilities).toContain("episode_mapping.apply_rule"); expect(run.goal).toContain("episode_mapping.apply_rule"); await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id); expect( (await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Nekomoe") }, run.id)).summary.ok, ).toBe(true); const out = await invoke( "episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Nekomoe"), reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"], }, run.id, ); expect(out).toMatchObject({ summary: { samples: 3, resolved: 3, ok: true }, reresolve: { resolved: 3 }, decisionStatus: "resolved", }); // 追加在已有内容后面,注释写明是智能体、哪个会话、理由 const lines = (await wordsText()).trimEnd().split("\n"); expect(lines.slice(-2)).toEqual([ `# ${new Date().toISOString().slice(0, 10)} 智能体(会话 ${run.id.slice(0, 8)}):发布时间对上第 2 季第 10-12 集播出日期;同组 S02E09 已按原号对上`, "S01 @Nekomoe >> S02 EP-30", ]); expect(out.appended).toBe(lines.slice(-2).join("\n")); expect(out.lineNumber).toBe(lines.length); const audit = await h.db .selectFrom("audit_event") .select(["actor_type", "actor_id"]) .where("action", "=", "identify_words.updated") .orderBy("at", "desc") .executeTakeFirstOrThrow(); expect(audit).toMatchObject({ actor_type: "agent", actor_id: run.id }); expect(await methods(ids)).toEqual(["rule", "rule", "rule"]); // 系统核实:事项内资源都已不是待确认 const acts = await recoveryActions(h.ctx, run.id); expect(acts.actions).toEqual([ expect.objectContaining({ capabilityId: "episode_mapping.apply_rule", status: "succeeded" }), ]); const check = await checkRecovery(h.ctx, acts.actions); expect(check).toMatchObject({ state: "recovered", facts: { [`decision:${d.id}`]: { unresolved: 0 } } }); const rec = await applyRecoveryOutcome( h.ctx, run.id, { status: "completed", reason: null, budget: null, message: null, finalText: "已写入规则。" }, acts, check, ); expect(rec).toMatchObject({ decisionId: d.id, outcome: "recovered" }); expect(rec!.summary).toMatch(/^已恢复(系统核实)/); const closed = await decisionOf("Nekomoe"); expect(closed.status).toBe("resolved"); expect((closed.evidence as Any).autoRecover).toMatchObject({ outcome: "recovered", recoverAttempted: true }); }); it("用户把智能体写的那一行注释掉后:资源回到待确认,同范围不再自动写", async () => { const cur = await json(await h.req(`/media-entities/${kId}/identify-words`)); const text = (cur.body.data.block.text as string).replace( "S01 @Nekomoe >> S02 EP-30", "# S01 @Nekomoe >> S02 EP-30", ); const off = await json( await h.req(`/media-entities/${kId}/identify-words`, { method: "PUT", body: { text, expectedVersion: cur.body.data.block.version }, }), ); expect(off.status, JSON.stringify(off.body)).toBe(200); const d = await decisionOf("Nekomoe"); expect(d.status).toBe("open"); await expect( invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Nekomoe"), reasons: ["再写一次"] }), ).rejects.toThrow(/注释掉/); expect(await rulesOf("Nekomoe")).toEqual(["# S01 @Nekomoe >> S02 EP-30"]); }); it("恢复核实:写规则没解决事项内资源时不算恢复", async () => { const check = await checkRecovery(h.ctx, [ { capabilityId: "episode_mapping.apply_rule", input: { decisionId: (await decisionOf("Sakurato")).id }, status: "succeeded", }, ]); expect(check.state).toBe("pending"); }); });