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:
rootandClaude Opus 5.5 committed 2026-10-08 17:03:00 +08:00
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 () => {