能力 ID 的域名允许单个下划线分词(episode_mapping)。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
461 lines
22 KiB
TypeScript
461 lines
22 KiB
TypeScript
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;
|
|
const EP30 = { kind: "expression", expression: "EP-30", targetSeason: 2 } as const;
|
|
|
|
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 rulesOf = (group: string) =>
|
|
h.db.selectFrom("episode_mapping_rule").selectAll().where("release_group", "=", group).execute();
|
|
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<string, unknown>, 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<Any>;
|
|
|
|
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", 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, rules: [] });
|
|
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, rule: { ...EP30, expression: "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,
|
|
rule: EP30,
|
|
reasons: ["发布时间对上播出日期"],
|
|
});
|
|
expect(ok.rule).toMatchObject({ season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 });
|
|
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,
|
|
rule: { ...EP30, expression: "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, rule: EP30 });
|
|
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, rule: EP30, 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, rule: EP30, 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, rule: EP30, 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({
|
|
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
|
|
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 } } },
|
|
});
|
|
const rules = await rulesOf("Lilith-Raws");
|
|
expect(rules).toHaveLength(1);
|
|
expect(rules[0]).toMatchObject({ expression: "EP-30", created_by_type: "user", evidence_level: "manual" });
|
|
expect(rules[0]!.evidence).toMatchObject({
|
|
decisionId: d.id,
|
|
agentSuggestion: { runId: run.id },
|
|
simulation: { samples: 4, ok: true },
|
|
});
|
|
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: {
|
|
rule: { season: 1, releaseGroup: "Kirara", kind: "expression", expression: "EP-20", targetSeason: 2 },
|
|
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, rule: EP30 }, run.id)).summary.ok).toBe(true);
|
|
const out = await invoke(
|
|
"episode_mapping.apply_rule",
|
|
{ decisionId: d.id, rule: EP30, reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"] },
|
|
run.id,
|
|
);
|
|
expect(out).toMatchObject({
|
|
evidenceLevel: "multi_evidence",
|
|
summary: { samples: 3, resolved: 3, ok: true },
|
|
reresolve: { resolved: 3 },
|
|
decisionStatus: "resolved",
|
|
});
|
|
const rules = await rulesOf("Nekomoe");
|
|
expect(rules).toHaveLength(1);
|
|
expect(rules[0]).toMatchObject({
|
|
season: 1,
|
|
kind: "expression",
|
|
expression: "EP-30",
|
|
target_season: 2,
|
|
status: "active",
|
|
created_by_type: "agent",
|
|
created_by_id: run.id,
|
|
evidence_level: "multi_evidence",
|
|
});
|
|
expect(rules[0]!.evidence).toMatchObject({
|
|
decisionId: d.id,
|
|
reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"],
|
|
samples: 3,
|
|
simulation: { ok: true },
|
|
runId: 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 rule = (await rulesOf("Nekomoe"))[0]!;
|
|
const off = await json(
|
|
await h.req(`/episode-mapping-rules/${rule.id}`, { method: "PATCH", body: { status: "disabled" } }),
|
|
);
|
|
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, rule: EP30, reasons: ["再写一次"] }),
|
|
).rejects.toThrow(/手动停用/);
|
|
expect((await rulesOf("Nekomoe")).map((r) => r.status)).toEqual(["disabled"]);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|