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

@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { reresolveEntityEpisodes } from "../../src/modules/sources-identity/service/episode-resolution.ts";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { setWordsText } from "../support/identify-words.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
// 集数解析接入识别(docs/plan/episode-resolution.md 批次 2):识别绑定作品后按季表 / 规则换算季集再建单元;
@@ -91,6 +92,7 @@ describe.skipIf(!pg)("集数解析接入识别", () => {
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, res, row: await row(r.id) };
};
const setWords = (entityId: string, text: string) => setWordsText(h.db, entityId, text);
const row = (id: string) =>
h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
// biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb
@@ -201,71 +203,41 @@ describe.skipIf(!pg)("集数解析接入识别", () => {
expect(ds[0]!.summary).toContain("共 2 条资源");
});
it("有 active 规则 `EP-12`(季 2、组 ANi)→ rule;规则算出越界 → 规则停用并开待处理", async () => {
const rule = await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 2,
release_group: "ANi",
kind: "expression",
expression: "EP-12",
created_by_type: "user",
created_by_id: "admin",
})
.returning("id")
.executeTakeFirstOrThrow();
// 同作用域(发布组忽略大小写)只能有一条 active
await expect(
h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 2,
release_group: "ani",
kind: "expression",
expression: "EP",
created_by_type: "user",
})
.execute(),
).rejects.toThrow();
it("作品识别词 `S02 @ANi >> EP-12` → rule;算出越界 → 待确认,这一行照常生效(不自动停用)", async () => {
await setWords(kId, "# 第二季接着第一季编号\nS02 @ANi >> EP-12\n");
const ok = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 13 [1080P][Baha][WEB-DL]");
expect(er(ok.row)).toMatchObject({
method: "rule",
ruleId: rule.id,
ruleLine: 2,
ruleText: "S02 @ANi >> EP-12",
canonical: { season: 2, episodes: { start: 1, end: 1 } },
});
expect(await unitOf(ok.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 1 });
const bad = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 30 [1080P][Baha][WEB-DL]");
expect(er(bad.row)).toMatchObject({ method: "unresolved", ruleFailed: rule.id });
expect(er(bad.row)).toMatchObject({ method: "unresolved", ruleText: "S02 @ANi >> EP-12" });
expect(er(bad.row)).not.toHaveProperty("ruleFailed");
expect(bad.row.content_unit_id).toBeNull();
const after = await h.db
.selectFrom("episode_mapping_rule")
.selectAll()
.where("id", "=", rule.id)
// 识别词原文没被改动
const w = await h.db
.selectFrom("identify_words")
.select(["text", "version"])
.where("media_entity_id", "=", kId)
.executeTakeFirstOrThrow();
expect(after.status).toBe("disabled");
expect(after.disabled_reason).toContain("rule:validate_fail");
expect(w.text).toBe("# 第二季接着第一季编号\nS02 @ANi >> EP-12\n");
const d = (await openEpisodeDecisions()).find((x) => x.dedupe_key === `episode_mapping:${kId}:2:ani`);
expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id], failedRules: [rule.id] });
expect(d?.summary).toContain("规则已停用");
expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id] });
expect(d?.evidence).not.toHaveProperty("failedRules");
expect(d?.summary).toContain("按作品识别词「S02 @ANi >> EP-12」算出的集数对不上作品季表");
expect(d?.summary).not.toContain("停用");
// 越界之后同一行对新资源照样生效
const again = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 14 [1080P][Baha][WEB-DL]");
expect(er(again.row)).toMatchObject({ method: "rule", canonical: { season: 2, episodes: { start: 2, end: 2 } } });
});
it("新增规则后按作品重算:之前待确认的资源解决、建出单元、待处理关闭", async () => {
await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 1,
release_group: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
target_season: 2,
created_by_type: "user",
})
.execute();
await setWords(kId, "S02 @ANi >> EP-12\nS01 @Lilith-Raws >> S02 EP-30\n");
const decisionId = (await openEpisodeDecisions()).find((x) => x.dedupe_key.endsWith(":1:lilith-raws"))!.id;
const r = await h.db.transaction().execute((trx) => reresolveEntityEpisodes(h.ctx, trx, kId));
expect(r.resolved).toBe(2);