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
@@ -0,0 +1,167 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// 识别词(docs/design/identify-words.md):
|
||||
// - identify_words:全局一块(media_entity_id 为空,只能有一行)+ 每部作品一块;原文整块存,version 每次保存 +1;
|
||||
// reresolve 是全局识别词保存后后台重算的进度
|
||||
// - 0066 的 episode_mapping_rule 结构化规则逐作品转成作品识别词的文本行后删表:同一作品、同一季、同一换算的
|
||||
// 多个发布组合成一行,单集对照一行一条,停用的规则变成注释行。已有文本时只追加还没有的行(可重复执行)
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
type Db = Kysely<any>;
|
||||
|
||||
interface RuleRow {
|
||||
media_entity_id: string;
|
||||
season: number | null;
|
||||
release_group: string | null;
|
||||
kind: "expression" | "table" | "special";
|
||||
expression: string | null;
|
||||
target_season: number | null;
|
||||
table_entries: { from: { start: number; end: number }; to: { season: number; episode: number } }[] | null;
|
||||
special: "special" | "extra" | "ignore" | null;
|
||||
status: "active" | "disabled";
|
||||
created_by_type: string;
|
||||
evidence: { reasons?: unknown } | null;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const SPECIAL = { special: "特别篇", extra: "花絮", ignore: "忽略" } as const;
|
||||
const group = (g: string) => (/[\s"]|>>/.test(g) ? `@"${g.replace(/"/g, "")}"` : `@${g}`);
|
||||
|
||||
/** 一组同季、同内容的规则 → 文本行(作品识别词语法,与 contracts printEntityWord 一致) */
|
||||
function linesOf(rules: RuleRow[]): string[] {
|
||||
const r = rules[0]!;
|
||||
const groups = [...new Set(rules.map((x) => x.release_group?.trim()).filter((g): g is string => !!g))];
|
||||
const g = groups.length ? ` ${groups.map(group).join(" ")}` : "";
|
||||
const scope = `${r.season === null ? "*" : `S${pad(r.season)}`}${g}`;
|
||||
if (r.kind === "table") {
|
||||
return (r.table_entries ?? []).map((e) => {
|
||||
const range = e.from.start === e.from.end ? `E${pad(e.from.start)}` : `E${pad(e.from.start)}-E${pad(e.from.end)}`;
|
||||
return `${r.season === null ? "" : `S${pad(r.season)}`}${range}${g} >> S${pad(e.to.season)}E${pad(e.to.episode)}`;
|
||||
});
|
||||
}
|
||||
if (r.kind === "special") return [`${scope} >> ${SPECIAL[r.special ?? "ignore"]}`];
|
||||
const expr = (r.expression ?? "EP").replace(/\s+/g, "").replace(/(?<![A-Za-z0-9_])ep(?![A-Za-z0-9_])/gi, "EP");
|
||||
return [`${scope} >> ${r.target_season === null ? "" : `S${pad(r.target_season)} `}${expr || "EP"}`];
|
||||
}
|
||||
|
||||
export function entityWordsText(rows: RuleRow[]): string[] {
|
||||
const buckets = new Map<string, RuleRow[]>();
|
||||
for (const r of rows) {
|
||||
// 有发布组的同内容规则合成一行;没有发布组的单独一行;表格规则各自成行(条目不同)
|
||||
const content =
|
||||
r.kind === "table"
|
||||
? `table:${r.release_group ?? ""}`
|
||||
: `${r.kind}:${r.expression ?? ""}:${r.target_season ?? ""}:${r.special ?? ""}:${r.release_group ? "g" : "-"}`;
|
||||
const key = `${r.status}|${r.created_by_type === "agent" ? "agent" : "-"}|${r.season ?? "*"}|${content}`;
|
||||
buckets.set(key, [...(buckets.get(key) ?? []), r]);
|
||||
}
|
||||
const ordered = [...buckets.values()].sort((a, b) => {
|
||||
const x = a[0]!;
|
||||
const y = b[0]!;
|
||||
const s = (x.season ?? 9999) - (y.season ?? 9999);
|
||||
if (s) return s;
|
||||
if (!!x.release_group !== !!y.release_group) return x.release_group ? 1 : -1;
|
||||
return x.status === y.status ? 0 : x.status === "active" ? -1 : 1;
|
||||
});
|
||||
const out: string[] = [];
|
||||
for (const b of ordered) {
|
||||
const first = b[0]!;
|
||||
const reasons = b.flatMap((r) =>
|
||||
Array.isArray(r.evidence?.reasons) ? r.evidence.reasons.filter((x): x is string => typeof x === "string") : [],
|
||||
);
|
||||
if (first.created_by_type === "agent") out.push(`# 智能体${reasons.length ? `:${reasons.join(";")}` : ""}`);
|
||||
for (const l of linesOf(b)) out.push(first.status === "active" ? l : `# ${l}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 当前 schema 里有没有这张表(测试按 schema 隔离,search_path 后面的 public 不算) */
|
||||
const regclass = async (db: Db, name: string) =>
|
||||
(
|
||||
await sql<{
|
||||
t: string | null;
|
||||
}>`select to_regclass(quote_ident(current_schema()) || '.' || ${name})::text as t`.execute(db)
|
||||
).rows[0]?.t ?? null;
|
||||
|
||||
export async function up(db: Db): Promise<void> {
|
||||
if (!(await regclass(db, "identify_words")))
|
||||
await execDdl(
|
||||
db,
|
||||
`${entityTable(
|
||||
"identify_words",
|
||||
`media_entity_id uuid references media_entity (id) on delete cascade,
|
||||
text text not null default '',
|
||||
version integer not null default 0,
|
||||
updated_by_type text ${oneOf("updated_by_type", ["user", "agent", "system", "api_key"])},
|
||||
updated_by_id text,
|
||||
reresolve jsonb`,
|
||||
)}
|
||||
create unique index if not exists identify_words_entity on identify_words (media_entity_id) where media_entity_id is not null;
|
||||
create unique index if not exists identify_words_global on identify_words ((media_entity_id is null)) where media_entity_id is null;`,
|
||||
);
|
||||
|
||||
const rows: RuleRow[] = (await regclass(db, "episode_mapping_rule"))
|
||||
? ((await db
|
||||
.selectFrom("episode_mapping_rule")
|
||||
.selectAll()
|
||||
.orderBy("media_entity_id")
|
||||
.orderBy("created_at")
|
||||
.execute()) as RuleRow[])
|
||||
: [];
|
||||
const byEntity = new Map<string, RuleRow[]>();
|
||||
for (const r of rows) byEntity.set(r.media_entity_id, [...(byEntity.get(r.media_entity_id) ?? []), r]);
|
||||
for (const [entityId, list] of byEntity) {
|
||||
const lines = entityWordsText(list);
|
||||
const cur = await db
|
||||
.selectFrom("identify_words")
|
||||
.select(["id", "text"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.executeTakeFirst();
|
||||
if (!cur) {
|
||||
await db
|
||||
.insertInto("identify_words")
|
||||
.values({ media_entity_id: entityId, text: `${lines.join("\n")}\n`, version: 1, updated_by_type: "system" })
|
||||
.execute();
|
||||
continue;
|
||||
}
|
||||
const have = new Set((cur.text as string).split("\n").map((l) => l.trim()));
|
||||
const missing = lines.filter((l) => !have.has(l.trim()));
|
||||
if (!missing.length) continue;
|
||||
const base = (cur.text as string).replace(/\n*$/, "");
|
||||
await db
|
||||
.updateTable("identify_words")
|
||||
.set({ text: `${base ? `${base}\n` : ""}${missing.join("\n")}\n` })
|
||||
.where("id", "=", cur.id)
|
||||
.execute();
|
||||
}
|
||||
await execDdl(db, `drop table if exists episode_mapping_rule;`);
|
||||
}
|
||||
|
||||
export async function down(db: Db): Promise<void> {
|
||||
// 文本里的规则不回写成结构化规则:恢复空表结构,识别词原文随表删除
|
||||
await execDdl(
|
||||
db,
|
||||
`${entityTable(
|
||||
"episode_mapping_rule",
|
||||
`media_entity_id uuid not null references media_entity (id) on delete cascade,
|
||||
season integer,
|
||||
release_group text,
|
||||
kind text not null ${oneOf("kind", ["expression", "table", "special"])},
|
||||
expression text,
|
||||
target_season integer,
|
||||
table_entries jsonb,
|
||||
special text ${oneOf("special", ["special", "extra", "ignore"])},
|
||||
status text not null default 'active' ${oneOf("status", ["active", "disabled"])},
|
||||
evidence_level text not null default 'manual' ${oneOf("evidence_level", ["manual", "rule_confirmed", "multi_evidence", "needs_review"])},
|
||||
evidence jsonb not null default '{}'::jsonb,
|
||||
disabled_reason text,
|
||||
created_by_type text not null ${oneOf("created_by_type", ["user", "agent", "system", "api_key"])},
|
||||
created_by_id text`,
|
||||
)}
|
||||
create unique index episode_mapping_rule_active_scope on episode_mapping_rule
|
||||
(media_entity_id, coalesce(season, -1), coalesce(lower(release_group), '')) where status = 'active';
|
||||
create index episode_mapping_rule_entity on episode_mapping_rule (media_entity_id);
|
||||
drop table if exists identify_words;`,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user