feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
12f8fc59c3
commit
63e94b308e
90 files changed
+6366
-2131
No files matched your search
@@ -15,7 +15,8 @@ import { resolveRelease } from "../../src/modules/sources-identity/service/resol
|
||||
import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts";
|
||||
|
||||
// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):episode_mapping.inspect 的上下文包与上限、
|
||||
// simulate、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则写规则 + 重算 + 事项关闭)、
|
||||
// simulate(试一行作品识别词)、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则把这一行
|
||||
// 连同理由注释追加进作品识别词 + 重算 + 事项关闭)、
|
||||
// 后台调查接入(没有持续授权降级只读并留智能体建议 → 待处理一键确认;有授权时恢复动作按业务事实核实)。
|
||||
// 不调真实模型:按假模型会走的调用顺序直接经注册中心调用能力。
|
||||
|
||||
@@ -50,7 +51,8 @@ const KUSURIYA: TmdbFixture = {
|
||||
} 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;
|
||||
/** 作品识别词:第 1 季连续编号减 30 换到第 2 季 */
|
||||
const ep30 = (group: string, expr = "EP-30") => `S01 @${group} >> S02 ${expr}`;
|
||||
|
||||
describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
let h: Harness;
|
||||
@@ -110,8 +112,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
.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 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,
|
||||
@@ -143,7 +148,8 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
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.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",
|
||||
@@ -160,7 +166,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
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).toMatchObject({
|
||||
latestSeason: 2,
|
||||
ended: true,
|
||||
words: { text: "", lines: [{ line: 1, kind: "blank" }] },
|
||||
});
|
||||
expect(out.suggestion).toMatchObject({
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
@@ -198,15 +208,16 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
|
||||
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" } });
|
||||
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,
|
||||
rule: EP30,
|
||||
line: "s01 @Lilith-Raws >> s02 ep - 30",
|
||||
reasons: ["发布时间对上播出日期"],
|
||||
});
|
||||
expect(ok.rule).toMatchObject({ season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 });
|
||||
// 回给规范写法
|
||||
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]);
|
||||
@@ -226,7 +237,7 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
await expect(
|
||||
invoke("episode_mapping.apply_rule", {
|
||||
decisionId: d.id,
|
||||
rule: { ...EP30, expression: "EP-20" },
|
||||
line: ep30("Lilith-Raws", "EP-20"),
|
||||
reasons: ["试试"],
|
||||
}),
|
||||
).rejects.toThrow(/模拟没通过/);
|
||||
@@ -239,11 +250,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
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 });
|
||||
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, rule: EP30, reasons: ["连续编号"] }),
|
||||
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"]);
|
||||
@@ -270,18 +281,26 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
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 集的播出日期"] },
|
||||
{
|
||||
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, rule: EP30, reasons: ["对上播出日期"] }, run.id),
|
||||
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({
|
||||
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
|
||||
line: "S01 @Lilith-Raws >> S02 EP-30",
|
||||
reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"],
|
||||
simulation: { samples: 3, resolved: 3, ok: true },
|
||||
runId: run.id,
|
||||
@@ -311,14 +330,15 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
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 },
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -332,7 +352,7 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
evidence: JSON.stringify({
|
||||
...(d.evidence as object),
|
||||
agentSuggestion: {
|
||||
rule: { season: 1, releaseGroup: "Kirara", kind: "expression", expression: "EP-20", targetSeason: 2 },
|
||||
line: "S01 @Kirara >> S02 EP-20",
|
||||
reasons: ["旧建议"],
|
||||
simulation: {
|
||||
samples: 2,
|
||||
@@ -379,37 +399,38 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
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);
|
||||
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, rule: EP30, reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"] },
|
||||
{
|
||||
decisionId: d.id,
|
||||
line: ep30("Nekomoe"),
|
||||
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,
|
||||
});
|
||||
// 追加在已有内容后面,注释写明是智能体、哪个会话、理由
|
||||
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"]);
|
||||
|
||||
// 系统核实:事项内资源都已不是待确认
|
||||
@@ -433,18 +454,25 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
expect((closed.evidence as Any).autoRecover).toMatchObject({ outcome: "recovered", recoverAttempted: true });
|
||||
});
|
||||
|
||||
it("用户手动停用智能体写的规则后:资源回到待确认,同作用域不再自动写", async () => {
|
||||
const rule = (await rulesOf("Nekomoe"))[0]!;
|
||||
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(`/episode-mapping-rules/${rule.id}`, { method: "PATCH", body: { status: "disabled" } }),
|
||||
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, rule: EP30, reasons: ["再写一次"] }),
|
||||
).rejects.toThrow(/手动停用/);
|
||||
expect((await rulesOf("Nekomoe")).map((r) => r.status)).toEqual(["disabled"]);
|
||||
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 () => {
|
||||
|
||||
Reference in new issue
Block a user