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

@@ -7,11 +7,12 @@ import {
type PreferenceTag,
} from "@umt/contracts";
import { type DB, type PreferenceSuppressionRow, type RuleGroupRow, sql, type Transaction } from "@umt/db";
import { evaluateCandidate, parseRelease, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
import { evaluateCandidate, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
import { slotSpecFromRow } from "@umt/mapping";
import type { Context } from "hono";
import type { AuthEnv } from "../../../http/auth.ts";
import { ApiException } from "../../../http/errors.ts";
import { titleParser } from "../../sources-identity/service/identify-words-cache.ts";
import type { ModuleApp } from "../../types.ts";
import type { AoDeps, Exec } from "../services/common.ts";
import { normalizeGroupInput } from "../services/rules.ts";
@@ -811,9 +812,10 @@ export async function previewPolicy(d: AoDeps, rules: unknown) {
decision: string;
reasons: string[];
}[] = [];
const parse = await titleParser(d.ctx.db);
for (const rel of rels) {
const parsed = {
...parseRelease(rel.title, rel.subtitle ? { subtitle: rel.subtitle } : {}),
...parse(rel.title, rel.subtitle),
...(rel.size_bytes !== null ? { sizeBytes: rel.size_bytes } : {}),
};
for (const t of templates) {
@@ -35,6 +35,7 @@ import {
import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping";
import { withAttemptToken } from "@umt/tasks";
import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts";
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
import { errMessage, jsonb } from "../util.ts";
@@ -742,12 +743,15 @@ async function selectPackFiles(
: undefined;
const mapEpisodes = entity ? await createEntityEpisodeMapper(d.ctx.db, entity.id, entity.kind) : null;
const relRes = rel.episode_resolution as EpisodeResolution | null;
const relRaw = mapEpisodes ? parseRelease(rel.title, rel.subtitle ? { subtitle: rel.subtitle } : {}) : null;
const words = await wordsOption(d.ctx.db);
const relRaw = mapEpisodes
? parseRelease(rel.title, { ...words, ...(rel.subtitle ? { subtitle: rel.subtitle } : {}) })
: null;
const relSeason = relRaw && !relRaw.seasonInferred ? relRaw.season : undefined;
const mapOpts = { publishedAt: publishedAtOf(rel), ...(relRes?.method ? { release: relRes } : {}) };
const rows = files.map((f) => {
const kind = fileKindOf(f.name);
let p = parseReleasePath(f.name);
let p = parseReleasePath(f.name, words);
let special = false;
if (mapEpisodes && kind === "video" && p.episodes) {
if ((p.season === undefined || p.seasonInferred) && relSeason !== undefined) {
@@ -42,6 +42,7 @@ import {
settleEpisodeDecision,
} from "../../sources-identity/service/episode-resolution.ts";
import type { Layout } from "../../sources-identity/service/episode-structure.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
import { classifyEntity } from "./classification.ts";
@@ -669,9 +670,12 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
mediaType,
files.filter((f) => !overrideFor(req.fileOverrides, f.path) && fileKindOf(basename(f.path)) === "video"),
);
const words = await wordsOption(d.ctx.db);
for (const f of files) {
let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot));
let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot), words);
if (parsed.fileKind !== "video") continue;
// 全局识别词改了文件名里的季集时,命名器(按原文件名再解析)要拿覆盖值,和不带识别词的解析比
const plain = parsed.words ? parseReleasePath(segmentsWithin(f.path, sourceRoot)) : parsed;
const wrongTitle = mismatched.get(f.path);
if (wrongTitle !== undefined) {
mappedOverrides[f.path] = { exclude: { reason: "title_mismatch", detail: wrongTitle } };
@@ -699,10 +703,15 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
}
parsed = r.parsed;
const c = r.resolution.canonical;
if (c && (c.season !== (raw.season ?? 1) || !sameRange(c.episodes, raw.episodes))) {
if (c && (c.season !== (plain.season ?? 1) || !sameRange(c.episodes, plain.episodes))) {
mappedOverrides[f.path] = { season: c.season, episodes: { ...c.episodes } };
}
}
if (!mappedOverrides[f.path] && parsed.words && parsed.episodes && !ov) {
const season = parsed.season ?? 1;
if (season !== (plain.season ?? 1) || !sameRange(parsed.episodes, plain.episodes))
mappedOverrides[f.path] = { season, episodes: { ...parsed.episodes } };
}
const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined);
if (mediaType === "tv" && season !== undefined) seasons.add(season);
// 临时结构:命名 / 季目录按指定结构的季集;资产覆盖的单元换回作品当前结构(换不回就不登记集覆盖)
@@ -1216,6 +1225,8 @@ function episodeFacts(r: EpisodeResolution): EpisodeFacts {
source: r.source,
canonical: r.canonical,
...(r.ruleId ? { ruleId: r.ruleId } : {}),
...(r.ruleLine !== undefined ? { ruleLine: r.ruleLine } : {}),
...(r.ruleText ? { ruleText: r.ruleText } : {}),
...(r.special ? { special: r.special } : {}),
evidence: r.evidence,
};
@@ -95,7 +95,7 @@ export interface FileOverrideInput {
/** 冻结计划里的集数映射摘要(排查用) */
export type EpisodeFacts = Pick<
EpisodeResolution,
"method" | "source" | "canonical" | "evidence" | "ruleId" | "special"
"method" | "source" | "canonical" | "evidence" | "ruleId" | "ruleLine" | "ruleText" | "special"
>;
/** organization_record.plan 的冻结内容 */
@@ -19,7 +19,6 @@ import {
normalizeExpr,
type ParsedRelease,
parseExpr,
parseRelease,
parseRuleString,
type RuleExpr,
type RuleGroupSpec,
@@ -32,6 +31,7 @@ import {
validateKeywordPattern,
} from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { titleParser } from "../../sources-identity/service/identify-words-cache.ts";
import { hashJson, toApi } from "../util.ts";
import type { AoDeps, Exec } from "./common.ts";
@@ -313,6 +313,7 @@ export async function previewRuleGroup(
if (body.draft.ladderRequired !== undefined) spec.ladderRequired = body.draft.ladderRequired;
}
const catalog = catalogOf(cat.atoms);
const parse = await titleParser(db);
const samples: {
id: string | null;
title: string;
@@ -325,7 +326,7 @@ export async function previewRuleGroup(
id: null,
title: t.title,
subtitle: t.subtitle ?? null,
parsed: parseRelease(t.title, t.subtitle ? { subtitle: t.subtitle } : {}),
parsed: parse(t.title, t.subtitle),
ctx: t.subtitle ? { subtitle: t.subtitle } : {},
});
// 指定资源 → 只取这些;否则取最近 N 条(只粘贴标题且没给 limit 时不取)
@@ -37,6 +37,7 @@ import {
createEntityEpisodeMapper,
type EntityEpisodeMapper,
} from "../../sources-identity/service/episode-resolution.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import { errMessage, jsonb } from "../util.ts";
import type { AoDeps } from "./common.ts";
import { emitScrapeFinished } from "./lifecycle-events.ts";
@@ -1045,6 +1046,7 @@ export async function runScrapeRecord(
const dated = new Map<string, string>();
// 集数映射(批次 3):未登记的文件按本地剧集作品的季表 + 规则把文件名季集换成规范季集再对集元数据;
// 综艺按播出日期对上季表里的集
const words = await wordsOption(d.ctx.db);
for (const v of lay.videos) {
if (kind === "movie") {
videos.push({ path: v });
@@ -1055,7 +1057,7 @@ export async function runScrapeRecord(
videos.push({ path: v, season: cov.season, episode: cov.episode, episodes: cov.episodes });
continue;
}
const p = parseReleasePath(v);
const p = parseReleasePath(v, words);
let season = p.season ?? lay.dirSeason ?? seasonOfDirName(baseOf(dirOf(v))) ?? (p.episodes ? 1 : null);
let range = p.episodes ?? null;
if (mapEpisodes && (p.episodes || p.airDate)) {
@@ -6,12 +6,14 @@ import {
mergeParsed,
type OrganizePlan,
type ParsedRelease,
type ParseOptions,
parseRelease,
parseReleasePath,
} from "@umt/domain";
import { vstatSafe as statSafe } from "../../../storage/vfs.ts";
import type { DecisionHook, DecisionHookActor, DecisionResolveBody } from "../../decision-hooks.ts";
import { addAliasIfAbsent } from "../../sources-identity/service/entities.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import type { NameIdentifyResult } from "../ports.ts";
import { jsonb } from "../util.ts";
import { type AoDeps, type Exec, ensureTaskRow, hostPathOf, loadInstances, markTask } from "./common.ts";
@@ -42,10 +44,14 @@ export function pickMainVideo(files: readonly QbFileLike[]): string | null {
* 种子名 + 主视频文件名 → 解析结果。主视频路径(qB 文件名含种子根目录)按 文件 → 上级 → 上上级 目录合并;
* 种子名作为再上一级兜底(补标题 / 年份 / 季);文件名解析不出标题时直接用种子名。
*/
export function takeoverParsed(torrentName: string, mainFile: string | null): ParsedRelease {
const byName = parseRelease(torrentName, { isFile: fileKindOf(torrentName) !== undefined });
export function takeoverParsed(
torrentName: string,
mainFile: string | null,
opts: Pick<ParseOptions, "words"> = {},
): ParsedRelease {
const byName = parseRelease(torrentName, { ...opts, isFile: fileKindOf(torrentName) !== undefined });
if (!mainFile) return byName;
const byPath = parseReleasePath(mainFile);
const byPath = parseReleasePath(mainFile, opts);
if (!byPath.title || byPath.auxiliaryOnly) return byName;
// 多文件种子:根目录就是种子名,parseReleasePath 已合并过;单文件种子文件名≈种子名,再合并一次无害
return mergeParsed(byPath, byName, "parent_dir");
@@ -65,7 +71,7 @@ export async function identifyTakeover(
files: readonly QbFileLike[],
): Promise<TakeoverIdentify> {
const mainFile = pickMainVideo(files);
const parsed = takeoverParsed(dt.name, mainFile);
const parsed = takeoverParsed(dt.name, mainFile, await wordsOption(d.ctx.db));
const result = await d.ports.identify({
parsed,
evidence: { downloadTaskId: dt.id, torrentName: dt.name, mainFile },
+7 -7
View File
@@ -142,7 +142,7 @@ function suggestionFor(d: DecisionRow, reason: string): string {
if (d.kind === "identity_ambiguous")
return `${reason}。可在媒体库「未识别」里手动指定作品,或为 identity.resolve 发放持续授权。`;
if (d.kind === "episode_mapping")
return `${reason}。智能体模拟通过的规则会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`;
return `${reason}。智能体模拟通过的那一行作品识别词会作为建议留在作品的集数映射里,可在那里确认;或为 episode_mapping.apply_rule 发放持续授权。`;
return (
`${reason}。可在下载详情里「强制重新校验」(download.recheck),或为 download.recheck / task.retry 发放持续授权;` +
`换源、删除需人工决定。`
@@ -255,11 +255,11 @@ export async function autoInvestigate(
ep
? `作品 ${String(ep.mediaEntityId ?? d.subject_id ?? "")},来源季 ${ep.season ?? "未知"},发布组 ${ep.releaseGroup ?? "无"}。` +
(mode === "recover"
? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试草稿规则;" +
"模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 写一次规则(带理由);" +
"拿不准就不写,只把模拟通过的规则留作建议。不逐集处理,是否解决由系统核实。"
: "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试草稿规则;" +
"模拟通过的规则会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。")
? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试一行作品识别词(如 S02 @发布组 >> S01 EP+12);" +
"模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 把这一行写进作品识别词一次(带理由);" +
"拿不准就不写,只把模拟通过的那一行留作建议。不逐集处理,是否解决由系统核实。"
: "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试一行作品识别词;" +
"模拟通过的那一行会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。")
: mode === "recover"
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
@@ -469,7 +469,7 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[
facts[`decision:${id}`] = d ? { status: d.status } : null;
states.push(!d ? "failed" : d.status === "resolved" ? "recovered" : "pending");
} else if (a.capabilityId === "episode_mapping.apply_rule") {
// 事项内资源都已不是待确认(规则写入后的重算会把事项自动关闭)即视为恢复
// 事项内资源都已不是待确认(写进作品识别词后的重算会把事项自动关闭)即视为恢复
const id = String(a.input.decisionId ?? "");
const d = id
? await ctx.db.selectFrom("decision").select(["status", "evidence"]).where("id", "=", id).executeTakeFirst()
@@ -24,6 +24,7 @@ import { vlstat as stat, vreaddirents } from "../../storage/vfs.ts";
import { successfulOrganizeCounts } from "../acquisition-organize/services/organize-history.ts";
import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts";
import { createEntityEpisodeMapper, type EntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts";
import { wordsOption } from "../sources-identity/service/identify-words-cache.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts";
import { kindOfName } from "./list.ts";
@@ -370,6 +371,7 @@ export async function identifyCore(
};
const items: FileIdentifyItem[] = [];
const words = await wordsOption(d.ctx.db);
for (const v of videos) {
const size = await stat(v.abs)
.then((st) => Number(st.size))
@@ -381,7 +383,7 @@ export async function identifyCore(
lastStatus: (rec?.last_status ?? null) as OrganizationStatus | null,
successfulCount: successfulCounts.get(v.abs) ?? 0,
};
const parsed0 = parseReleasePath(segments(v.rel));
const parsed0 = parseReleasePath(segments(v.rel), words);
const parsed: ParsedRelease = {
...parsed0,
...(parsed0.year === undefined && body.hints?.year ? { year: body.hints.year } : {}),
@@ -3,6 +3,7 @@ import type { FilesSuggestNameRequest, FilesSuggestNameResult } from "@umt/contr
import { buildNamingVars, normalizeTitle, parseReleasePath, renderNamingPath } from "@umt/domain";
import { effectiveNaming, loadNamingEntity } from "../acquisition-organize/services/naming.ts";
import { createEntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts";
import { wordsOption } from "../sources-identity/service/identify-words-cache.ts";
import { FilesError, type FmDeps, resolveIn } from "./common.ts";
// G9 推荐名:按命名模板(作品 > 库根 > 全局)对选中文件/目录渲染名字(与整理用同一引擎与变量来源)。
@@ -31,7 +32,7 @@ export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Pro
const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "登记目录本身不能生成推荐名");
const isDir = !!r.lst?.isDirectory();
const parsed = parseReleasePath(segmentsOf(r.rel));
const parsed = parseReleasePath(segmentsOf(r.rel), await wordsOption(db));
let entityId = body.mediaEntityId ?? null;
if (!entityId && r.def.libraryRootId) {
+19 -4
View File
@@ -11,8 +11,16 @@ import type {
SearchSiteCoverage,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, parseRelease } from "@umt/domain";
import {
applyFacets,
type FacetInput,
normalizeTitle,
type ParsedRelease,
parseFacetParam,
parseRelease,
} from "@umt/domain";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { titleParser } from "../sources-identity/service/identify-words-cache.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { siteCan } from "../sources-identity/site-kinds.ts";
import { raceAbort } from "./media.ts";
@@ -186,8 +194,9 @@ export class ReleaseFanout {
rt.ctx.logger.warn({ err: { message: (e as Error).message }, siteId: site.id }, "搜索结果写入资源表失败");
}
await this.resolveUnmatched(rt, [...ids.values()]);
const parse = await titleParser(rt.ctx.db);
const items = found.map((r) => {
const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null);
const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null, parse);
this.raws.set(base.key, r);
return base;
});
@@ -251,8 +260,14 @@ function mergeSite(site: SiteRow, parts: SiteResult[]): SiteResult {
};
}
function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceReleaseId: string | null): BaseRelease {
const p = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
function toBase(
site: Pick<SiteRow, "id" | "name">,
r: AdapterRelease,
sourceReleaseId: string | null,
parse: (title: string, subtitle?: string | null) => ParsedRelease = (t, s) =>
parseRelease(t, s ? { subtitle: s } : {}),
): BaseRelease {
const p = parse(r.title, r.subtitle);
const pr = r.promotion;
return {
key: `${site.id}:${r.siteReleaseId}`,
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts";
import { sql } from "@umt/db";
import { type ParsedRelease, parseRelease } from "@umt/domain";
import type { ParsedRelease } from "@umt/domain";
import { z } from "zod";
import { ApiException } from "../../../http/errors.ts";
import { decisionHookFor } from "../../decision-hooks.ts";
@@ -17,6 +17,7 @@ import {
EPISODE_DECISION_CONFIRM_AGENT,
EPISODE_DECISION_IGNORE_RULE,
} from "../service/episode-resolution.ts";
import { parseTitle } from "../service/identify-words-cache.ts";
import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts";
import { groupMemberIds } from "../service/identity-decisions.ts";
@@ -166,7 +167,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.select(["title", "subtitle"])
.where("id", "=", srId)
.executeTakeFirst();
const parsed = r ? parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}) : null;
const parsed = r ? await parseTitle(db(), r.title, r.subtitle) : null;
if (parsed && !releaseFitsEntity(parsed, entity)) {
kept++;
continue;
@@ -3,28 +3,41 @@ import type { ModuleApp } from "../../types.ts";
import { route } from "../http.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { episodeStructureSeasons, listEpisodeGroups, setEpisodeGroup } from "../service/episode-groups.ts";
import { previewEpisodeMapping } from "../service/episode-mapping-admin.ts";
import {
createRule,
deleteRule,
listRules,
previewEpisodeMapping,
updateRule,
} from "../service/episode-mapping-admin.ts";
dryRunEntityWords,
dryRunGlobalWords,
getEntityWords,
getGlobalWords,
listEntityWords,
saveEntityWords,
saveGlobalWords,
tryWordsLine,
} from "../service/identify-words.ts";
// 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。
// 识别词(docs/design/identify-words.md):全局 / 作品识别词读写、试算、单行示例。
// 集数映射预览(纯读)。
// 剧集组:作品的季集结构(TMDB 默认季 / 某个剧集组)列表、季卡片预览、切换。
const T = ["episode-mapping"];
const E = Api.episodeMapping;
const W = Api.identifyWords;
const TW = ["identify-words"];
const G = Api.episodeGroups;
export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const ctx = () => rt().ctx;
route(app, { ...E.listRules, tags: T }, async ({ params }) => ({ items: await listRules(ctx().db, params.id) }));
route(app, { ...E.createRule, tags: T }, ({ params, body, actor }) => createRule(ctx(), params.id, body, actor));
route(app, { ...E.updateRule, tags: T }, ({ params, body, actor }) => updateRule(ctx(), params.id, body, actor));
route(app, { ...E.deleteRule, tags: T }, ({ params, actor }) => deleteRule(ctx(), params.id, actor));
route(app, { ...W.getGlobal, tags: TW }, () => getGlobalWords(ctx().db));
route(app, { ...W.dryRunGlobal, tags: TW }, ({ body }) => dryRunGlobalWords(ctx().db, body.text));
route(app, { ...W.saveGlobal, tags: TW }, ({ body, actor }) => saveGlobalWords(ctx(), body, actor));
route(app, { ...W.listEntities, tags: TW }, ({ query }) => listEntityWords(ctx().db, query.q));
route(app, { ...W.try, tags: TW }, ({ body }) => tryWordsLine(ctx().db, body));
route(app, { ...W.getEntity, tags: TW }, ({ params }) => getEntityWords(ctx().db, params.id));
route(app, { ...W.dryRunEntity, tags: TW }, ({ params, body }) => dryRunEntityWords(ctx().db, params.id, body.text));
route(app, { ...W.saveEntity, tags: TW }, ({ params, body, actor }) =>
saveEntityWords(ctx(), params.id, body, actor),
);
route(app, { ...E.preview, tags: T }, ({ params, body }) => previewEpisodeMapping(ctx().db, params.id, body));
route(app, { ...G.list, tags: T }, ({ params }) => listEpisodeGroups(rt(), params.id));
@@ -1,27 +1,22 @@
import type {
EpisodeMappingPreview,
EpisodeMappingPreviewGroup,
EpisodeMappingPreviewInput,
EpisodeMappingPreviewItem,
EpisodeMappingRuleCreate,
EpisodeMappingRuleRow,
EpisodeMappingRuleSpec,
EpisodeMappingRuleUpdate,
EpisodeMappingRuleWriteResult,
EpisodeResolutionMethod,
EpisodeResultBrief,
EpisodeSimulationItem,
EpisodeSimulationSummary,
EpisodeSuggestion,
EpisodeValidationState,
import {
compileEntityWords,
type EpisodeMappingPreview,
type EpisodeMappingPreviewGroup,
type EpisodeMappingPreviewInput,
type EpisodeMappingPreviewItem,
type EpisodeResolutionMethod,
type EpisodeResultBrief,
type EpisodeSimulationItem,
type EpisodeSimulationSummary,
type EpisodeSuggestion,
type EpisodeValidationState,
parseEntityWordLine,
} from "@umt/contracts";
import type { DecisionRow, EpisodeMappingRuleEvidenceLevel, EpisodeMappingRuleRow as RuleDbRow } from "@umt/db";
import type { DecisionRow } from "@umt/db";
import {
type EpisodeMappingRule,
type EpisodeResolution,
evalEpisodeOffset,
normalizeOffsetExpression,
parseRelease,
groupKey,
resolveEpisodes,
type SeasonTable,
type SuggestSample,
@@ -29,7 +24,7 @@ import {
} from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts";
import { jsonb, toApi } from "../rows.ts";
import { jsonb } from "../rows.ts";
import { type Actor, audit, type Exec } from "./common.ts";
import {
EPISODE_DECISION_CONFIRM,
@@ -41,289 +36,46 @@ import {
publishedAtOf,
reresolveEntityEpisodes,
sameResolution,
scopeLine,
} from "./episode-resolution.ts";
import { entityEpisodeGroup } from "./episode-structure.ts";
import { appendEntityWords } from "./identify-words.ts";
import { titleParser } from "./identify-words-cache.ts";
// 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、
// 待处理 episode_mapping 的「确认建议规则 / 忽略并建忽略规则」。判定语义全在 domain,这里只装配与校验。
// 集数映射的接口层:按作品识别词试算预览、智能体与集数待确认事项共用的「模拟一行」、
// 集数待确认事项的「确认建议 / 忽略」(把一行写进作品识别词再重算)。判定语义全在 domain,这里只装配。
const EPISODE_KINDS = new Set(["series", "variety"]);
const PREVIEW_LIMIT = 100;
/** 有季 / 组过滤时多取一些再在内存里筛(季与组要按原标题重解析才知道) */
const PREVIEW_SCAN = 1000;
export interface NormalizedRule {
season: number | null;
release_group: string | null;
kind: EpisodeMappingRuleSpec["kind"];
expression: string | null;
target_season: number | null;
table_entries: NonNullable<EpisodeMappingRuleSpec["tableEntries"]> | null;
special: NonNullable<EpisodeMappingRuleSpec["special"]> | null;
}
const bad = (msg: string) => new ApiException("bad_request", msg);
/** 校验并规范化规则内容:按 kind 只保留对应字段;表达式要能解析,表格区间要合法且不重叠 */
export function normalizeRuleSpec(spec: EpisodeMappingRuleSpec): NormalizedRule {
const group = spec.releaseGroup?.trim() || null;
const out: NormalizedRule = {
season: spec.season ?? null,
release_group: group,
kind: spec.kind,
expression: null,
target_season: null,
table_entries: null,
special: null,
};
if (spec.kind === "expression") {
const raw = spec.expression?.trim();
if (!raw) throw bad("偏移表达式不能为空");
let expr: string;
try {
expr = normalizeOffsetExpression(raw);
evalEpisodeOffset(expr, 1);
} catch (e) {
throw bad(`偏移表达式无法解析:${(e as Error).message}`);
}
out.expression = expr;
out.target_season = spec.targetSeason ?? null;
} else if (spec.kind === "table") {
const entries = spec.tableEntries ?? [];
if (!entries.length) throw bad("表格规则至少要有一行");
entries.forEach((e, i) => {
const row = `第 ${i + 1} 行`;
if (e.from.start < 0 || e.from.end < e.from.start) throw bad(`表格${row}:来源集号区间不合法`);
if (e.to.season < 0 || e.to.episode < 1) throw bad(`表格${row}:目标季集不合法`);
});
const sorted = [...entries].sort((a, b) => a.from.start - b.from.start);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i]!.from.start <= sorted[i - 1]!.from.end)
throw bad(`表格里的来源区间 ${sorted[i - 1]!.from.start}-${sorted[i - 1]!.from.end} 与后一行重叠`);
}
out.table_entries = entries;
} else {
if (!spec.special) throw bad("要选映射成特别篇、花絮还是忽略");
out.special = spec.special;
}
return out;
}
const scopeText = (season: number | null, group: string | null) =>
`${season === null ? "任意季" : `第 ${season} 季`} · ${group ?? "任意发布组"}`;
/** 同作品 + 季 + 发布组(忽略大小写)已有启用的规则 → conflict */
export async function assertScopeFree(
exec: Exec,
entityId: string,
season: number | null,
group: string | null,
exceptId: string | null,
): Promise<void> {
let q = exec
.selectFrom("episode_mapping_rule")
.select(["id", "release_group"])
.where("media_entity_id", "=", entityId)
.where("status", "=", "active");
q = season === null ? q.where("season", "is", null) : q.where("season", "=", season);
if (exceptId) q = q.where("id", "<>", exceptId);
const rows = await q.execute();
const hit = rows.find((r) => (r.release_group?.trim().toLowerCase() || null) === (group?.toLowerCase() ?? null));
if (hit) throw new ApiException("conflict", `「${scopeText(season, group)}」已有一条启用的规则,先停用或修改那一条`);
}
const isUniqueViolation = (e: unknown) => (e as { code?: string }).code === "23505";
async function loadEpisodicEntity(exec: Exec, entityId: string) {
export async function loadEpisodicEntity(exec: Exec, entityId: string) {
const e = await exec
.selectFrom("media_entity")
.select(["id", "kind", "status", "merged_into_id"])
.select(["id", "kind", "status", "merged_into_id", "title"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e) throw new ApiException("not_found", "作品不存在");
return e;
}
export async function listRules(exec: Exec, entityId: string): Promise<EpisodeMappingRuleRow[]> {
await loadEpisodicEntity(exec, entityId);
const rows = await exec
.selectFrom("episode_mapping_rule")
.selectAll()
.where("media_entity_id", "=", entityId)
.orderBy("status")
.orderBy("created_at", "desc")
.execute();
return rows.map((r) => toApi<EpisodeMappingRuleRow>(r));
}
/** 写入一条规则(不重算,调用方在同一事务里接着重算);证据等级缺省 manual(人工建 / 人工确认) */
export async function insertRule(
exec: Exec,
entityId: string,
n: NormalizedRule,
actor: Actor,
evidence: Record<string, unknown>,
evidenceLevel: EpisodeMappingRuleEvidenceLevel = "manual",
): Promise<RuleDbRow> {
await assertScopeFree(exec, entityId, n.season, n.release_group, null);
const row = await exec
.insertInto("episode_mapping_rule")
.values({
media_entity_id: entityId,
...n,
table_entries: n.table_entries ? jsonb(n.table_entries) : null,
evidence: jsonb(evidence),
evidence_level: evidenceLevel,
created_by_type: actor.type,
created_by_id: actor.id,
})
.returningAll()
.executeTakeFirstOrThrow();
await audit(
exec,
actor,
"episode_mapping_rule.created",
{ type: "episode_mapping_rule", id: row.id },
{
mediaEntityId: entityId,
...n,
},
);
return row;
}
export async function withConflict<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (isUniqueViolation(e)) throw new ApiException("conflict", "同一季和发布组已有一条启用的规则");
throw e;
}
}
export async function createRule(
ctx: ServerContext,
entityId: string,
body: EpisodeMappingRuleCreate,
actor: Actor,
): Promise<EpisodeMappingRuleWriteResult> {
const entity = await loadEpisodicEntity(ctx.db, entityId);
if (!EPISODE_KINDS.has(entity.kind)) throw bad("只有剧集和综艺能设置集数映射");
const n = normalizeRuleSpec(body);
return withConflict(() =>
ctx.db.transaction().execute(async (trx) => {
const row = await insertRule(trx, entityId, n, actor, body.evidence ?? {});
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
return { rule: toApi<EpisodeMappingRuleRow>(await reloadRule(trx, row.id)), reresolve };
}),
);
}
const reloadRule = (exec: Exec, id: string) =>
exec.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
function specOfRow(r: RuleDbRow): EpisodeMappingRuleSpec {
return {
season: r.season,
releaseGroup: r.release_group,
kind: r.kind,
expression: r.expression,
targetSeason: r.target_season,
tableEntries: (r.table_entries as EpisodeMappingRuleSpec["tableEntries"]) ?? null,
special: r.special,
};
}
export async function updateRule(
ctx: ServerContext,
id: string,
body: EpisodeMappingRuleUpdate,
actor: Actor,
): Promise<EpisodeMappingRuleWriteResult> {
const cur = await ctx.db.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "映射规则不存在");
const { status, ...patch } = body;
const merged: EpisodeMappingRuleSpec = { ...specOfRow(cur) };
for (const [k, v] of Object.entries(patch)) if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
const n = normalizeRuleSpec(merged);
const nextStatus = status ?? cur.status;
return withConflict(() =>
ctx.db.transaction().execute(async (trx) => {
if (nextStatus === "active") await assertScopeFree(trx, cur.media_entity_id, n.season, n.release_group, id);
const disabledReason =
nextStatus === "active"
? null
: status === "disabled" && cur.status === "active"
? JSON.stringify({ manual: true, by: `${actor.type}:${actor.id ?? ""}`, at: new Date().toISOString() })
: cur.disabled_reason;
await trx
.updateTable("episode_mapping_rule")
.set({
...n,
table_entries: n.table_entries ? jsonb(n.table_entries) : null,
status: nextStatus,
disabled_reason: disabledReason,
updated_at: new Date(),
})
.where("id", "=", id)
.execute();
await audit(
trx,
actor,
"episode_mapping_rule.updated",
{ type: "episode_mapping_rule", id },
{
mediaEntityId: cur.media_entity_id,
status: nextStatus,
...n,
},
);
const reresolve = await reresolveEntityEpisodes(ctx, trx, cur.media_entity_id);
return { rule: toApi<EpisodeMappingRuleRow>(await reloadRule(trx, id)), reresolve };
}),
);
}
export async function deleteRule(ctx: ServerContext, id: string, actor: Actor): Promise<EpisodeMappingRuleWriteResult> {
return ctx.db.transaction().execute(async (trx) => {
const cur = await trx.selectFrom("episode_mapping_rule").selectAll().where("id", "=", id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "映射规则不存在");
await trx.deleteFrom("episode_mapping_rule").where("id", "=", id).execute();
await audit(
trx,
actor,
"episode_mapping_rule.deleted",
{ type: "episode_mapping_rule", id },
{
mediaEntityId: cur.media_entity_id,
...specOfRow(cur),
},
);
const reresolve = await reresolveEntityEpisodes(ctx, trx, cur.media_entity_id);
return { rule: toApi<EpisodeMappingRuleRow>(cur), reresolve };
});
/** 一行作品识别词 → 规则;写错或不是规则行时报 400 */
export function rulesOfLine(line: string): EpisodeMappingRule[] {
const p = parseEntityWordLine(line);
if (p.kind === "blank" || p.kind === "comment") throw bad("要写一行规则,如 S02 >> S01 EP+12");
if (p.error) throw bad(`这一行写法不对:${p.error}`);
return compileEntityWords(line).rules;
}
// ── 模拟预览 ──
const DRAFT_ID = "draft";
export function domainRule(n: NormalizedRule, id: string): EpisodeMappingRule {
return {
id,
season: n.season,
releaseGroup: n.release_group,
kind: n.kind,
...(n.expression !== null ? { expression: n.expression } : {}),
...(n.target_season !== null ? { targetSeason: n.target_season } : {}),
...(n.table_entries ? { table: n.table_entries } : {}),
...(n.special !== null ? { special: n.special } : {}),
};
}
/** 规则范围相同(季 + 发布组,发布组按 groupKey 比较) */
export const sameScope = (a: EpisodeMappingRule, b: EpisodeMappingRule) =>
a.season === b.season &&
(a.releaseGroup?.trim().toLowerCase() ?? null) === (b.releaseGroup?.trim().toLowerCase() ?? null);
(a.releaseGroup ? groupKey(a.releaseGroup) : null) === (b.releaseGroup ? groupKey(b.releaseGroup) : null);
export function validationOf(r: EpisodeResolution): EpisodeValidationState {
if (r.special) return "special";
@@ -372,10 +124,15 @@ function groupSuggestion(
return s ? { season, releaseGroup, ...s } : null;
}
/**
* 预览:近期资源按原标题(套全局识别词)重解析,用作品识别词(input.words 是草稿原文;extraRules 追加一行试算)重算。
* 纯读,不写库。
*/
export async function previewEpisodeMapping(
exec: Exec,
entityId: string,
input: EpisodeMappingPreviewInput,
extraRules: readonly EpisodeMappingRule[] = [],
): Promise<EpisodeMappingPreview> {
const entity = await loadEpisodicEntity(exec, entityId);
const table = await loadSeasonTable(exec, entityId);
@@ -383,12 +140,8 @@ export async function previewEpisodeMapping(
const seasons = seasonSummary(table, group ? { groupId: group.id, name: group.name } : null);
if (!EPISODE_KINDS.has(entity.kind)) return { applicable: false, seasons, groups: [], items: [], changed: 0 };
let rules = await loadRules(exec, entityId);
if (input.replaceRuleId) rules = rules.filter((r) => r.id !== input.replaceRuleId);
if (input.draft) {
const draft = domainRule(normalizeRuleSpec(input.draft), DRAFT_ID);
rules = [...rules.filter((r) => !sameScope(r, draft)), draft];
}
let rules = input.words !== undefined ? compileEntityWords(input.words).rules : await loadRules(exec, entityId);
if (extraRules.length) rules = [...rules.filter((r) => !extraRules.some((x) => sameScope(r, x))), ...extraRules];
const limit = input.limit ?? PREVIEW_LIMIT;
const filtered = input.season != null || !!input.releaseGroup?.trim();
@@ -403,13 +156,14 @@ export async function previewEpisodeMapping(
.limit(filtered ? PREVIEW_SCAN : limit)
.execute();
const wantGroup = input.releaseGroup?.trim().toLowerCase() || null;
const wantGroup = input.releaseGroup?.trim() ? groupKey(input.releaseGroup) : null;
const parse = await titleParser(exec);
const items: (EpisodeMappingPreviewItem & { preview: EpisodeResolution })[] = [];
for (const row of rows) {
if (items.length >= limit) break;
const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {});
const raw = parse(row.title, row.subtitle);
const group = raw.releaseGroup?.name ?? null;
if (wantGroup && group?.toLowerCase() !== wantGroup) continue;
if (wantGroup && (group ? groupKey(group) : null) !== wantGroup) continue;
const publishedAt = publishedAtOf(row).toISOString();
const preview = resolveEpisodes({ parsed: raw, kind: entity.kind, table, rules, publishedAt });
const current = (row.episode_resolution as EpisodeResolution | null) ?? null;
@@ -425,6 +179,7 @@ export async function previewEpisodeMapping(
releaseGroup: group,
current,
preview,
words: raw.words?.applied ?? [],
validation: validationOf(preview),
changed: !sameResolution(current, preview),
});
@@ -432,7 +187,7 @@ export async function previewEpisodeMapping(
const byGroup = new Map<string, { name: string | null; items: typeof items }>();
for (const it of items) {
const key = it.releaseGroup?.toLowerCase() ?? "";
const key = it.releaseGroup ? groupKey(it.releaseGroup) : "";
const g = byGroup.get(key) ?? { name: it.releaseGroup, items: [] };
g.items.push(it);
byGroup.set(key, g);
@@ -454,7 +209,7 @@ export async function previewEpisodeMapping(
return { applicable: true, seasons, groups, items, changed: items.filter((i) => i.changed).length };
}
// ── 模拟一条草稿规则(批次 5:智能体 simulate / apply_rule、确认智能体建议共用) ──
// ── 模拟一行作品识别词(智能体 simulate / apply_rule、确认智能体建议共用) ──
/** 模拟最多扫多少条近期资源(有发布组时按组筛) */
const SIMULATE_LIMIT = 300;
@@ -474,26 +229,32 @@ export const briefOf = (r: EpisodeResolution | null | undefined): EpisodeResultB
const settled = (r: EpisodeResolution | null) => !!r && r.method !== "unresolved" && r.method !== "skipped";
/**
* 用 previewEpisodeMapping 试算草稿规则(纯读):sampleIds = 事项内资源;没有事项时取范围内当前待确认的资源。
* 用 previewEpisodeMapping 试算一行作品识别词(纯读;同范围的已有行被它替换):sampleIds = 事项内资源;
* 没有事项时取范围内当前待确认的资源。
* ok = 样本全部解决(非待确认、校验非 fail)且没有让当前已解决的资源变成待确认。
*/
export async function simulateEpisodeRule(
exec: Exec,
entityId: string,
spec: EpisodeMappingRuleSpec,
line: string,
sampleIds: readonly string[] | null,
): Promise<RuleSimulation> {
const n = normalizeRuleSpec(spec);
const p = await previewEpisodeMapping(exec, entityId, {
releaseGroup: n.release_group,
draft: spec,
limit: SIMULATE_LIMIT,
});
const rules = rulesOfLine(line);
const groups = [...new Set(rules.map((r) => r.releaseGroup))];
const p = await previewEpisodeMapping(
exec,
entityId,
{ ...(groups.length === 1 ? { releaseGroup: groups[0] } : {}), limit: SIMULATE_LIMIT },
rules,
);
if (!p.applicable) throw bad("只有剧集和综艺能设置集数映射");
const season = rules[0]?.season ?? null;
const groupKeys = new Set(rules.map((r) => (r.releaseGroup ? groupKey(r.releaseGroup) : null)));
const inScope = (i: EpisodeMappingPreviewItem) => {
const src = i.preview.source;
const season = src.season ?? (src.episodes ? 1 : null);
return n.season === null || season === n.season;
const s = src.season ?? (src.episodes ? 1 : null);
const g = i.releaseGroup ? groupKey(i.releaseGroup) : null;
return (season === null || s === season) && (groupKeys.has(null) || groupKeys.has(g));
};
const ids = new Set(
sampleIds ?? p.items.filter((i) => i.current?.method === "unresolved" && inScope(i)).map((i) => i.sourceReleaseId),
@@ -549,13 +310,12 @@ export async function simulateEpisodeRule(
return { summary, failures, items: out };
}
// ── 待处理 episode_mapping ──
// ── 集数待确认事项 ──
/**
* 处理 episode_mapping 事项:确认建议规则(建 expression 规则)/ 确认智能体建议(批次 5,建规则前再强制模拟一次)/
* 忽略并建 special=ignore 规则。
* 同一事务里:建规则 → 按用户的选择关闭事项 → 按作品重算(资源解决后不会再挂到已关闭的事项上;
* 仍待确认的资源会另开一条新事项)。
* 处理 episode_mapping 事项:确认建议规则 / 确认智能体建议(写之前再强制模拟一次)/ 忽略并写忽略规则。
* 都是把一行追加进作品识别词;同一事务里:写识别词 → 按用户的选择关闭事项 → 按作品重算
* (资源解决后不会再挂到已关闭的事项上;仍待确认的资源会另开一条新事项)。
*/
export async function resolveEpisodeDecision(
ctx: ServerContext,
@@ -568,79 +328,74 @@ export async function resolveEpisodeDecision(
const entityId = ev.mediaEntityId ?? (d.subject_type === "media_entity" ? d.subject_id : null);
if (!entityId) throw bad("事项缺少作品");
const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null };
let spec: EpisodeMappingRuleSpec;
let line: string;
let comment: string;
if (optionKey === EPISODE_DECISION_CONFIRM) {
if (!ev.suggestion) throw new ApiException("conflict", "这条事项没有建议规则,到作品页的集数映射里手动建");
spec = {
...scope,
kind: "expression",
expression: ev.suggestion.expression,
targetSeason: ev.suggestion.targetSeason,
};
if (!ev.suggestion) throw new ApiException("conflict", "这条事项没有建议规则,到作品页的集数映射里手动写");
line = scopeLine(scope, { kind: "map", expr: ev.suggestion.expression, targetSeason: ev.suggestion.targetSeason });
comment = "按发布时间对播出日期推出的建议";
} else if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) {
if (!ev.agentSuggestion?.rule) throw new ApiException("conflict", "这条事项没有智能体建议");
spec = ev.agentSuggestion.rule;
if (!ev.agentSuggestion?.line) throw new ApiException("conflict", "这条事项没有智能体建议");
line = ev.agentSuggestion.line;
comment = `智能体建议${ev.agentSuggestion.reasons.length ? `:${ev.agentSuggestion.reasons.join(";")}` : ""}`;
} else if (optionKey === EPISODE_DECISION_IGNORE_RULE) {
spec = { ...scope, kind: "special", special: "ignore" };
line = scopeLine(scope, { kind: "special", special: "ignore" });
comment = "集数待确认时选了忽略";
} else {
throw bad(`未知选项 ${optionKey}`);
}
const n = normalizeRuleSpec(spec);
rulesOfLine(line);
const by = `${actor.type}:${actor.id ?? ""}`;
const status = optionKey === EPISODE_DECISION_IGNORE_RULE ? "dismissed" : "resolved";
return withConflict(() =>
ctx.db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("decision")
.selectAll()
.where("id", "=", d.id)
.forUpdate()
.executeTakeFirstOrThrow();
if (!["open", "investigating", "awaiting_user"].includes(cur.status))
throw new ApiException("conflict", `事项已是 ${cur.status}`);
let simulation: unknown = null;
if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) {
// 智能体建议可能是一阵子之前模拟的:建规则前按当前资源再模拟一次,不通过就不写
const ids = ((cur.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? [];
const sim = await simulateEpisodeRule(trx, entityId, spec, ids);
if (!sim.summary.ok)
throw new ApiException("conflict", `智能体建议的规则现在模拟不通过:${sim.failures.join(";")}`);
simulation = sim.summary;
}
const rule = await insertRule(trx, entityId, n, actor, {
decisionId: d.id,
...(optionKey === EPISODE_DECISION_CONFIRM ? { suggestion: ev.suggestion } : {}),
...(optionKey === EPISODE_DECISION_CONFIRM_AGENT
? {
agentSuggestion: { reasons: ev.agentSuggestion?.reasons ?? [], runId: ev.agentSuggestion?.runId ?? null },
simulation,
}
: {}),
});
await trx
.updateTable("decision")
.set({
status,
resolution: jsonb({ optionKey, note, by, execution: { ruleId: rule.id } }),
resolved_by: by,
resolved_at: new Date(),
})
.where("id", "=", d.id)
.execute();
await ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId: d.id, status },
{ idempotencyKey: `decision.updated:${d.id}:${status}` },
);
await audit(trx, actor, "decision.resolved", { type: "decision", id: d.id }, { optionKey, status });
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
return trx
.updateTable("decision")
.set({ resolution: jsonb({ optionKey, note, by, execution: { ruleId: rule.id, reresolve } }) })
.where("id", "=", d.id)
.returningAll()
.executeTakeFirstOrThrow();
}),
);
return ctx.db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("decision")
.selectAll()
.where("id", "=", d.id)
.forUpdate()
.executeTakeFirstOrThrow();
if (!["open", "investigating", "awaiting_user"].includes(cur.status))
throw new ApiException("conflict", `事项已是 ${cur.status}`);
let simulation: unknown = null;
if (optionKey === EPISODE_DECISION_CONFIRM_AGENT) {
// 智能体建议可能是一阵子之前模拟的:写之前按当前资源再模拟一次,不通过就不写
const ids = ((cur.evidence ?? {}) as { sourceReleaseIds?: string[] }).sourceReleaseIds ?? [];
const sim = await simulateEpisodeRule(trx, entityId, line, ids);
if (!sim.summary.ok)
throw new ApiException("conflict", `智能体建议的规则现在模拟不通过:${sim.failures.join(";")}`);
simulation = sim.summary;
}
const appended = await appendEntityWords(trx, entityId, [line], actor, comment);
await trx
.updateTable("decision")
.set({
status,
resolution: jsonb({ optionKey, note, by, execution: { line, lineNumber: appended.lineNumber, simulation } }),
resolved_by: by,
resolved_at: new Date(),
})
.where("id", "=", d.id)
.execute();
await ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId: d.id, status },
{ idempotencyKey: `decision.updated:${d.id}:${status}` },
);
await audit(trx, actor, "decision.resolved", { type: "decision", id: d.id }, { optionKey, status, line });
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
return trx
.updateTable("decision")
.set({
resolution: jsonb({
optionKey,
note,
by,
execution: { line, lineNumber: appended.lineNumber, simulation, reresolve },
}),
})
.where("id", "=", d.id)
.returningAll()
.executeTakeFirstOrThrow();
});
}
@@ -1,29 +1,25 @@
import type {
DecisionOption,
EpisodeInspectRelease,
EpisodeMappingApplyRuleInput,
EpisodeMappingApplyRuleOutput,
EpisodeMappingInspectInput,
EpisodeMappingInspectOutput,
EpisodeMappingRuleSpec,
EpisodeMappingSimulateInput,
EpisodeMappingSimulateOutput,
EpisodeSuggestion,
import {
compileEntityWords,
type DecisionOption,
type EpisodeInspectRelease,
type EpisodeMappingApplyRuleInput,
type EpisodeMappingApplyRuleOutput,
type EpisodeMappingInspectInput,
type EpisodeMappingInspectOutput,
type EpisodeMappingSimulateInput,
type EpisodeMappingSimulateOutput,
type EpisodeSuggestion,
parseEntityWordLine,
printEntityWord,
splitLines,
} from "@umt/contracts";
import { type DecisionRow, sql } from "@umt/db";
import { type EpisodeResolution, parseRelease, type SuggestSample, suggestRule } from "@umt/domain";
import { type EpisodeResolution, groupKey, type ParsedRelease, type SuggestSample, suggestRule } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts";
import { jsonb } from "../rows.ts";
import type { Actor, Exec } from "./common.ts";
import {
briefOf,
insertRule,
normalizeRuleSpec,
type RuleSimulation,
simulateEpisodeRule,
withConflict,
} from "./episode-mapping-admin.ts";
import { briefOf, type RuleSimulation, rulesOfLine, simulateEpisodeRule } from "./episode-mapping-admin.ts";
import {
type EpisodeAgentSuggestion,
type EpisodeDecisionEvidence,
@@ -32,10 +28,13 @@ import {
publishedAtOf,
reresolveEntityEpisodes,
} from "./episode-resolution.ts";
import { appendEntityWords } from "./identify-words.ts";
import { titleParser } from "./identify-words-cache.ts";
// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):确定性流程都失败、开了 episode_mapping 待处理后,
// 智能体 inspect(上下文包)→ simulate(试草稿规则)→ apply_rule(服务端强制再模拟,通过才写规则并按作品重算)。
// 规则写入后同类资源由规则自动处理,智能体不逐集调用。没有写规则授权时,模拟通过的规则作为「智能体建议」留在事项上。
// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):确定性流程都失败、开了 episode_mapping 事项后,
// 智能体 inspect(上下文包,含作品识别词原文)→ simulate(试一行作品识别词)→ apply_rule(服务端强制再模拟,
// 通过才把这一行连同理由注释追加进作品识别词,并按作品重算)。写进去之后同类资源由这一行自动处理,智能体不逐集调用。
// 没有写规则授权时,模拟通过的那一行作为「智能体建议」留在事项上,由用户确认。
const OPEN = ["open", "investigating", "awaiting_user"] as const;
const EPISODIC = new Set(["series", "variety"]);
@@ -115,8 +114,11 @@ type ReleaseRow = {
episode_resolution: unknown;
};
function releaseView(row: ReleaseRow): EpisodeInspectRelease & { publishedAtMs: number } {
const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {});
function releaseView(
row: ReleaseRow,
parse: (title: string, subtitle?: string | null) => ParsedRelease,
): EpisodeInspectRelease & { publishedAtMs: number } {
const raw = parse(row.title, row.subtitle);
const res = (row.episode_resolution as EpisodeResolution | null) ?? null;
const published = publishedAtOf(row);
return {
@@ -162,8 +164,10 @@ export async function inspectEpisodeMapping(
.orderBy("id")
.limit(NEIGHBOR_SCAN)
.execute();
const group = scope.releaseGroup?.toLowerCase() ?? null;
const views = recent.map(releaseView).filter((v) => (v.releaseGroup?.toLowerCase() ?? null) === group);
const parse = await titleParser(exec);
const view = (r: ReleaseRow) => releaseView(r, parse);
const group = scope.releaseGroup ? groupKey(scope.releaseGroup) : null;
const views = recent.map(view).filter((v) => (v.releaseGroup ? groupKey(v.releaseGroup) : null) === group);
let members: ReturnType<typeof releaseView>[];
if (scope.sampleIds) {
@@ -175,7 +179,7 @@ export async function inspectEpisodeMapping(
.where("id", "in", ids)
.execute()
: [];
members = rows.map(releaseView);
members = rows.map(view);
} else {
// 没有事项:这个范围里当前待确认的资源
members = views.filter(
@@ -206,31 +210,21 @@ export async function inspectEpisodeMapping(
})),
}));
const ruleRows = await exec
.selectFrom("episode_mapping_rule")
.selectAll()
const wordsRow = await exec
.selectFrom("identify_words")
.select("text")
.where("media_entity_id", "=", scope.entityId)
.orderBy("status")
.orderBy("created_at", "desc")
.execute();
const rules = ruleRows.map((r) => {
const ev = (r.evidence ?? {}) as { reasons?: unknown };
return {
id: r.id,
season: r.season,
releaseGroup: r.release_group,
kind: r.kind,
expression: r.expression,
targetSeason: r.target_season,
tableEntries: (r.table_entries as EpisodeMappingRuleSpec["tableEntries"]) ?? null,
special: r.special,
status: r.status,
disabledReason: r.disabled_reason,
evidenceLevel: r.evidence_level,
byAgent: r.created_by_type === "agent",
reasons: Array.isArray(ev.reasons) ? ev.reasons.filter((x): x is string => typeof x === "string") : [],
};
});
.executeTakeFirst();
const wordsText = wordsRow?.text ?? "";
const words = {
text: wordsText,
lines: compileEntityWords(wordsText).lines.map((l) => ({
line: l.line,
text: l.text,
kind: l.kind,
error: l.error ?? null,
})),
};
// 确定性建议:事项内待确认样本按发布时间对播出日期(与待处理里的建议同一推导)
const samples: SuggestSample[] = members
@@ -252,7 +246,6 @@ export async function inspectEpisodeMapping(
status: d.status,
title: d.title,
summary: d.summary,
failedRules: ((d.evidence ?? {}) as { failedRules?: string[] }).failedRules ?? [],
}
: null,
releases: members.slice(0, RELEASE_CAP).map(strip),
@@ -260,46 +253,39 @@ export async function inspectEpisodeMapping(
seasons,
latestSeason: table.latestSeason,
ended: table.ended,
rules,
words,
suggestion,
truncated: { releases: members.length > RELEASE_CAP, neighbors: neighbors.length > RELEASE_CAP },
};
}
/** 草稿规则的季 / 发布组没给(undefined)时用事项的;显式 null 表示任意 */
function withScope(rule: EpisodeMappingRuleSpec, scope: EpisodeScope): EpisodeMappingRuleSpec {
return {
...rule,
season: rule.season === undefined ? scope.season : rule.season,
releaseGroup: rule.releaseGroup === undefined ? scope.releaseGroup : rule.releaseGroup,
};
}
/** 规范化后的规则内容(回给调用方 / 存进建议) */
function specOf(rule: EpisodeMappingRuleSpec): EpisodeMappingRuleSpec {
const n = normalizeRuleSpec(rule);
return {
season: n.season,
releaseGroup: n.release_group,
kind: n.kind,
expression: n.expression,
targetSeason: n.target_season,
tableEntries: n.table_entries,
special: n.special,
};
/** 规范写法的一行(按解析结果重新打印;写错时报 400) */
function canonicalLine(line: string): string {
rulesOfLine(line);
const p = parseEntityWordLine(line);
return printEntityWord({
kind: p.kind,
season: p.season ?? null,
groups: p.groups ?? [],
...(p.targetSeason !== undefined ? { targetSeason: p.targetSeason } : {}),
...(p.expr !== undefined ? { expr: p.expr } : {}),
...(p.from ? { from: p.from } : {}),
...(p.to ? { to: p.to } : {}),
...(p.special ? { special: p.special } : {}),
});
}
/** 模拟通过的规则作为智能体建议写进事项证据,并补「确认智能体建议」选项(不写规则) */
async function recordAgentSuggestion(
exec: Exec,
d: DecisionRow,
spec: EpisodeMappingRuleSpec,
line: string,
reasons: string[],
sim: RuleSimulation,
runId: string | null,
): Promise<boolean> {
const suggestion: EpisodeAgentSuggestion = {
rule: spec,
line,
reasons,
simulation: sim.summary,
samples: sim.items
@@ -333,52 +319,54 @@ async function recordAgentSuggestion(
}
/**
* episode_mapping.simulate:试草稿规则(复用模拟预览)。recordFor 给了(智能体会话 + 有事项)且模拟通过时,
* 规则与理由作为智能体建议留在事项上。
* episode_mapping.simulate:试一行作品识别词(复用模拟预览,同范围的已有行被它替换)。record 给了(智能体会话 + 有事项)
* 且模拟通过时,这一行与理由作为智能体建议留在事项上。
*/
export async function simulateForAgent(
exec: Exec,
input: EpisodeMappingSimulateInput,
record: { runId: string | null } | null,
): Promise<EpisodeMappingSimulateOutput> {
const scope = await resolveScope(exec, input, input.rule.season, input.rule.releaseGroup);
const spec = specOf(withScope(input.rule, scope));
const sim = await simulateEpisodeRule(exec, scope.entityId, spec, scope.sampleIds);
const line = canonicalLine(input.line);
const rule = rulesOfLine(line)[0]!;
const scope = await resolveScope(exec, input, rule.season, rule.releaseGroup);
const sim = await simulateEpisodeRule(exec, scope.entityId, line, scope.sampleIds);
let suggestionRecorded = false;
if (record && scope.decision && sim.summary.ok)
suggestionRecorded = await recordAgentSuggestion(
exec,
scope.decision,
spec,
line,
input.reasons ?? [],
sim,
record.runId,
);
return { rule: spec, summary: sim.summary, failures: sim.failures, items: sim.items, suggestionRecorded };
return { line, summary: sim.summary, failures: sim.failures, items: sim.items, suggestionRecorded };
}
async function manuallyDisabled(exec: Exec, entityId: string, spec: EpisodeMappingRuleSpec): Promise<boolean> {
let q = exec
.selectFrom("episode_mapping_rule")
.select(["release_group", "disabled_reason"])
/** 作品识别词里有同范围、被注释掉的行 = 用户停用过这个范围的规则 */
async function disabledByUser(exec: Exec, entityId: string, line: string): Promise<boolean> {
const row = await exec
.selectFrom("identify_words")
.select("text")
.where("media_entity_id", "=", entityId)
.where("status", "=", "disabled");
q = spec.season == null ? q.where("season", "is", null) : q.where("season", "=", spec.season);
const group = spec.releaseGroup?.trim().toLowerCase() || null;
return (await q.execute()).some((r) => {
if ((r.release_group?.trim().toLowerCase() || null) !== group) return false;
try {
return (JSON.parse(r.disabled_reason ?? "{}") as { manual?: boolean }).manual === true;
} catch {
return false;
}
.executeTakeFirst();
if (!row) return false;
const want = rulesOfLine(line).map((r) => `${r.season ?? "*"}|${r.releaseGroup ? groupKey(r.releaseGroup) : ""}`);
return splitLines(row.text).some((l) => {
const t = l.trim();
if (!t.startsWith("#")) return false;
const c = compileEntityWords(t.replace(/^#\s?/, ""));
return (
!c.errors.length &&
c.rules.some((r) => want.includes(`${r.season ?? "*"}|${r.releaseGroup ? groupKey(r.releaseGroup) : ""}`))
);
});
}
/**
* episode_mapping.apply_rule:同一事务里锁事项 → 强制模拟(样本全部解决、不让已解决的变待确认,否则拒绝不写)→
* 写规则(created_by_type = agent;样本 ≥ 3 且全部「对得上」记 multi_evidence,否则 needs_review)→ 按作品重算。
* 资源都解决时事项由重算自动关闭(批次 2 的摘除逻辑)。
* 把这一行连同理由注释追加进作品识别词 → 按作品重算。资源都解决时事项由重算自动关闭(批次 2 的摘除逻辑)。
*/
export async function applyAgentEpisodeRule(
ctx: ServerContext,
@@ -386,51 +374,41 @@ export async function applyAgentEpisodeRule(
actor: Actor,
runId: string | null,
): Promise<EpisodeMappingApplyRuleOutput> {
return withConflict(() =>
ctx.db.transaction().execute(async (trx) => {
const locked = await trx
.selectFrom("decision")
.selectAll()
.where("id", "=", input.decisionId)
.forUpdate()
.executeTakeFirst();
if (!locked) throw new ApiException("not_found", "事项不存在");
if (locked.kind !== "episode_mapping") throw bad("这条事项不是集数待确认");
if (!(OPEN as readonly string[]).includes(locked.status))
throw new ApiException("conflict", `事项已是 ${locked.status},不用再写规则`);
const scope = scopeOfDecision(locked);
const spec = specOf(withScope(input.rule, scope));
// 用户手动停用过同作用域的规则 = 撤销过智能体(或别人)的判断:不再自动写,留给人
if (await manuallyDisabled(trx, scope.entityId, spec))
throw new ApiException(
"conflict",
"这个范围的规则被手动停用过,不自动写规则;需要时到作品页的集数映射里手动建",
);
const sim = await simulateEpisodeRule(trx, scope.entityId, spec, scope.sampleIds);
if (!sim.summary.ok) throw new ApiException("conflict", `模拟没通过,没有写规则:${sim.failures.join(";")}`);
const level =
sim.summary.samples >= 3 && sim.summary.passed === sim.summary.samples ? "multi_evidence" : "needs_review";
const rule = await insertRule(
trx,
scope.entityId,
normalizeRuleSpec(spec),
actor,
{
decisionId: locked.id,
reasons: input.reasons,
samples: sim.summary.samples,
simulation: sim.summary,
runId,
},
level,
const line = canonicalLine(input.line);
return ctx.db.transaction().execute(async (trx) => {
const locked = await trx
.selectFrom("decision")
.selectAll()
.where("id", "=", input.decisionId)
.forUpdate()
.executeTakeFirst();
if (!locked) throw new ApiException("not_found", "事项不存在");
if (locked.kind !== "episode_mapping") throw bad("这条事项不是集数待确认");
if (!(OPEN as readonly string[]).includes(locked.status))
throw new ApiException("conflict", `事项已是 ${locked.status},不用再写规则`);
const scope = scopeOfDecision(locked);
// 用户把同范围的行注释掉过 = 撤销过智能体(或别人)的判断:不再自动写,留给人
if (await disabledByUser(trx, scope.entityId, line))
throw new ApiException(
"conflict",
"这个范围的规则在作品识别词里被注释掉了,不自动写;需要时到作品页的集数映射里改",
);
const reresolve = await reresolveEntityEpisodes(ctx, trx, scope.entityId);
const after = await trx
.selectFrom("decision")
.select("status")
.where("id", "=", locked.id)
.executeTakeFirstOrThrow();
return { ruleId: rule.id, evidenceLevel: level, summary: sim.summary, reresolve, decisionStatus: after.status };
}),
);
const sim = await simulateEpisodeRule(trx, scope.entityId, line, scope.sampleIds);
if (!sim.summary.ok) throw new ApiException("conflict", `模拟没通过,没有写规则:${sim.failures.join(";")}`);
const comment = `智能体${runId ? `(会话 ${runId.slice(0, 8)})` : ""}:${input.reasons.join(";")}`;
const r = await appendEntityWords(trx, scope.entityId, [line], actor, comment);
const reresolve = await reresolveEntityEpisodes(ctx, trx, scope.entityId);
const after = await trx
.selectFrom("decision")
.select("status")
.where("id", "=", locked.id)
.executeTakeFirstOrThrow();
return {
appended: r.appended,
lineNumber: r.lineNumber,
summary: sim.summary,
reresolve,
decisionStatus: after.status,
};
});
}
@@ -1,18 +1,17 @@
import type {
DecisionOption,
EpisodeMappingRuleSpec,
EpisodeResultBrief,
EpisodeSimulationSummary,
EvidenceLevel,
MediaKind,
import {
compileEntityWords,
type DecisionOption,
type EpisodeResultBrief,
type EpisodeSimulationSummary,
type EvidenceLevel,
type MediaKind,
printEntityWord,
} from "@umt/contracts";
import { type SourceReleaseRow, sql } from "@umt/db";
import {
type EpisodeMappingRule,
type EpisodeMappingTableEntry,
type EpisodeResolution,
type ParsedRelease,
parseRelease,
resolveEpisodes,
type SeasonInfo,
type SeasonTable,
@@ -25,12 +24,14 @@ import { jsonb } from "../rows.ts";
import { closeDecisions, type Exec, openDecision } from "./common.ts";
import { ensureUnits } from "./entities.ts";
import { type Layout, seasonOrigins } from "./episode-structure.ts";
import { titleParser } from "./identify-words-cache.ts";
import { isEntityWatched } from "./watched.ts";
// 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把
// 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库:
// - 季表:季单元 episode_total + 集单元 air_date + 作品 metadata.airStatus(Ended / Canceled 视为完结)
// - 规则:episode_mapping_rule(active);规则算出越界 → 停用(disabled_reason 写证据)+ 待处理
// - 规则:作品识别词(identify_words,docs/design/identify-words.md)编译出的规则;照写的用,算出来对不上季表的资源
// 待确认,规则不停用(要停用就在识别词里注释掉那一行)
// - 待确认(unresolved):资源照常绑定作品,不建集单元;待处理 episode_mapping 按作品 + 来源季 + 发布组合并,
// evidence.sourceReleaseIds 累加,附 suggestRule 的建议;资源后来解决了就从中摘除,摘空即关闭
@@ -112,25 +113,14 @@ function airStatusOf(metadata: unknown): string {
return typeof raw === "string" ? raw : "";
}
/** 作品下启用的映射规则 */
/** 作品识别词编译出的映射规则(注释掉的行、写错的行不参与) */
export async function loadRules(exec: Exec, entityId: string): Promise<EpisodeMappingRule[]> {
const rows = await exec
.selectFrom("episode_mapping_rule")
.selectAll()
const row = await exec
.selectFrom("identify_words")
.select("text")
.where("media_entity_id", "=", entityId)
.where("status", "=", "active")
.orderBy("id")
.execute();
return rows.map((r) => ({
id: r.id,
season: r.season,
releaseGroup: r.release_group,
kind: r.kind,
...(r.expression !== null ? { expression: r.expression } : {}),
...(r.target_season !== null ? { targetSeason: r.target_season } : {}),
...(Array.isArray(r.table_entries) ? { table: r.table_entries as unknown as EpisodeMappingTableEntry[] } : {}),
...(r.special !== null ? { special: r.special } : {}),
}));
.executeTakeFirst();
return row ? compileEntityWords(row.text).rules : [];
}
export interface ReleaseEpisodesInput {
@@ -310,7 +300,7 @@ export async function createStructureEpisodeMapper(
const p = opts.publishedAt;
const publishedAt = p instanceof Date ? p.toISOString() : (p ?? null);
const byRule = current(parsed, { publishedAt }).resolution;
if (byRule.method === "rule" || byRule.ruleFailed) {
if (byRule.method === "rule" || byRule.ruleId) {
if (!byRule.canonical) return { resolution: { ...byRule, evidence: [...byRule.evidence, tag] }, parsed };
const moved = toTarget(byRule.canonical);
const resolution: EpisodeResolution = moved
@@ -354,7 +344,7 @@ export function unitsAllowed(resolution: EpisodeResolution): boolean {
}
/**
* 识别绑定时的集数解析:解析 → 规则失效时停用规则 → 按规范结果建单元(待确认 / special 不建)。
* 识别绑定时的集数解析:解析 → 按规范结果建单元(待确认 / special 不建)。
* 调用方随后把 parsed / episodeResolution / units.primary 写进 source_release,再调 settleEpisodeDecision。
*/
export async function prepareReleaseEpisodes(
@@ -362,28 +352,12 @@ export async function prepareReleaseEpisodes(
input: ReleaseEpisodesInput,
): Promise<ReleaseEpisodes & { units: { unitIds: string[]; primary: string | null } }> {
const r = await resolveReleaseEpisodes(exec, input);
if (r.resolution.ruleFailed) await disableFailedRule(exec, r.resolution);
const units = unitsAllowed(r.resolution)
? await ensureUnits(exec, input.entityId, input.kind, r.parsed)
: { unitIds: [], primary: null };
return { ...r, units };
}
async function disableFailedRule(exec: Exec, resolution: EpisodeResolution): Promise<void> {
const src = resolution.source;
const reason = JSON.stringify({
source: src,
evidence: resolution.evidence,
at: new Date().toISOString(),
});
await exec
.updateTable("episode_mapping_rule")
.set({ status: "disabled", disabled_reason: reason })
.where("id", "=", resolution.ruleFailed!)
.where("status", "=", "active")
.execute();
}
/** 发布时间:站点发布时间,没有用首次发现时间 */
export const publishedAtOf = (r: Pick<SourceReleaseRow, "source_published_at" | "first_seen_at">): Date =>
r.source_published_at ?? r.first_seen_at;
@@ -424,18 +398,14 @@ export async function settleEpisodeDecision(
.where("dedupe_key", "=", target)
.where("status", "in", [...OPEN])
.executeTakeFirst();
const prev = (existing?.evidence ?? {}) as { sourceReleaseIds?: string[]; failedRules?: string[] };
const prev = (existing?.evidence ?? {}) as { sourceReleaseIds?: string[] };
const ids = [...new Set([...(prev.sourceReleaseIds ?? []), sourceReleaseId])];
const failedRules = [
...new Set([...(prev.failedRules ?? []), ...(resolution.ruleFailed ? [resolution.ruleFailed] : [])]),
];
await writeEpisodeDecision(ctx, exec, {
key: target,
entityId,
season: sourceSeasonOf(resolution),
releaseGroup: input.parsed.releaseGroup?.name ?? null,
ids,
failedRules,
latest: resolution,
});
}
@@ -446,7 +416,6 @@ interface EpisodeDecisionState {
season: number | null;
releaseGroup: string | null;
ids: string[];
failedRules: string[];
/** 最近进来的那条资源的解析结果(摘要里的原因) */
latest: EpisodeResolution | null;
}
@@ -490,7 +459,7 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe
const seasonText = s.season === null ? "季未知" : `第 ${s.season} 季`;
const groupText = s.releaseGroup ? ` · ${s.releaseGroup}` : "";
const title = `集数待确认:${entity?.title ?? "未知作品"} ${seasonText}${groupText}`;
const summary = `${sourceText(latest)}${reasonText(latest, s.failedRules.length > 0)}${
const summary = `${sourceText(latest)}${reasonText(latest)}${
releases.length > 1 ? `(共 ${releases.length} 条资源)` : ""
}`;
await openDecision(ctx, exec, {
@@ -513,7 +482,6 @@ async function writeEpisodeDecision(ctx: ServerContext, exec: Exec, s: EpisodeDe
source: r.res?.source ?? null,
evidence: r.res?.evidence ?? [],
})),
...(s.failedRules.length ? { failedRules: s.failedRules } : {}),
suggestion,
...kept,
},
@@ -529,9 +497,9 @@ export const EPISODE_DECISION_IGNORE_RULE = "ignore_rule";
/** 刷新事项时从旧证据里保留的字段 */
const KEEP_EVIDENCE = ["agentSuggestion", "autoRecover", "agentInvestigation"] as const;
/** 智能体建议(批次 5):模拟通过的草稿规则 + 理由,由用户在待处理里一键确认 */
/** 智能体建议(批次 5):模拟通过的一行作品识别词 + 理由,用户确认后写进作品识别词 */
export interface EpisodeAgentSuggestion {
rule: EpisodeMappingRuleSpec;
line: string;
reasons: string[];
simulation: EpisodeSimulationSummary;
/** 样本套用前后(最多 10 条) */
@@ -548,15 +516,24 @@ export interface EpisodeDecisionEvidence {
agentSuggestion?: EpisodeAgentSuggestion | null;
}
/** 规则内容一句话(选项说明用) */
export function ruleSpecText(r: EpisodeMappingRuleSpec): string {
if (r.kind === "expression")
return `按 ${r.expression ?? ""}${r.targetSeason != null ? ` 换算到第 ${r.targetSeason} 季` : ""}`;
if (r.kind === "table") return `按对照表(${r.tableEntries?.length ?? 0} 行)`;
return `映射为${r.special === "special" ? "特别篇" : r.special === "extra" ? "花絮" : "忽略"}`;
/** 事项范围(来源季 + 发布组)对应的作品识别词行 */
export function scopeLine(
scope: { season: number | null; releaseGroup: string | null },
target: { kind: "map"; expr: string; targetSeason: number | null } | { kind: "special"; special: "ignore" },
): string {
const groups = scope.releaseGroup ? [scope.releaseGroup] : [];
return target.kind === "map"
? printEntityWord({
kind: "map",
season: scope.season,
groups,
expr: target.expr,
targetSeason: target.targetSeason,
})
: printEntityWord({ kind: "special", season: scope.season, groups, special: target.special });
}
/** 开 / 刷新 episode_mapping 待处理时附的选项(批次 2 的 writeEpisodeDecision 调用) */
/** 开 / 刷新 episode_mapping 事项时附的选项:每个选项写进作品识别词的那一行放在 input.line */
export function episodeDecisionOptions(ev: EpisodeDecisionEvidence): DecisionOption[] {
const scope = { season: ev.season ?? null, releaseGroup: ev.releaseGroup ?? null };
const out: DecisionOption[] = [];
@@ -564,23 +541,29 @@ export function episodeDecisionOptions(ev: EpisodeDecisionEvidence): DecisionOpt
out.push({
key: EPISODE_DECISION_CONFIRM_AGENT,
label: "确认智能体建议",
description: `${ruleSpecText(ev.agentSuggestion.rule)},建规则前按当前资源再模拟一次,通过才写入并重算`,
input: { ...ev.agentSuggestion.rule },
description: `把「${ev.agentSuggestion.line}」写进作品识别词,写之前按当前资源再模拟一次,通过才写并重算`,
input: { line: ev.agentSuggestion.line },
});
}
if (ev.suggestion) {
const line = scopeLine(scope, {
kind: "map",
expr: ev.suggestion.expression,
targetSeason: ev.suggestion.targetSeason,
});
out.push({
key: EPISODE_DECISION_CONFIRM,
label: "确认建议规则",
description: `按 ${ev.suggestion.expression} 换算到第 ${ev.suggestion.targetSeason} 季,建规则后重算这部作品的资源`,
input: { ...scope, expression: ev.suggestion.expression, targetSeason: ev.suggestion.targetSeason },
description: `把「${line}」写进作品识别词,再重算这部作品的资源`,
input: { line },
});
}
const ignore = scopeLine(scope, { kind: "special", special: "ignore" });
out.push({
key: EPISODE_DECISION_IGNORE_RULE,
label: "忽略并建忽略规则",
description: "这个发布组这一季的资源以后都不参与集数解析,也不再提醒",
input: scope,
label: "忽略并写忽略规则",
description: `把「${ignore}」写进作品识别词:这个发布组这一季的资源以后都不参与集数解析,也不再提醒`,
input: { line: ignore },
});
return out;
}
@@ -598,8 +581,8 @@ function sourceText(res: EpisodeResolution | null): string {
const pad = (n: number) => String(n).padStart(2, "0");
function reasonText(res: EpisodeResolution | null, ruleFailed: boolean): string {
if (res?.ruleFailed || (ruleFailed && !res)) return "映射规则算出的集数对不上作品季表,规则已停用";
function reasonText(res: EpisodeResolution | null): string {
if (res?.ruleText) return `按作品识别词「${res.ruleText}」算出的集数对不上作品季表`;
if (res?.evidence.some((e) => e.startsWith("conversion:ambiguous"))) return "换算出多个可能的季集,无法确定";
return "集数超出作品季表,也换算不出对应的季集";
}
@@ -622,7 +605,6 @@ async function detachFromEpisodeDecisions(
if (d.dedupe_key === keep) continue;
const ev = (d.evidence ?? {}) as {
sourceReleaseIds?: string[];
failedRules?: string[];
mediaEntityId?: string;
season?: number | null;
releaseGroup?: string | null;
@@ -639,7 +621,6 @@ async function detachFromEpisodeDecisions(
season: ev.season ?? null,
releaseGroup: ev.releaseGroup ?? null,
ids,
failedRules: ev.failedRules ?? [],
latest: null,
});
}
@@ -699,10 +680,11 @@ export async function reresolveEntityEpisodes(
const rows = await q.orderBy("first_seen_at").execute();
if (!rows.length) return out;
const table = await loadSeasonTable(exec, entityId);
let rules = await loadRules(exec, entityId);
const rules = await loadRules(exec, entityId);
const parse = await titleParser(exec);
for (const row of rows) {
out.releases++;
const raw = parseRelease(row.title, row.subtitle ? { subtitle: row.subtitle } : {});
const raw = parse(row.title, row.subtitle);
const r = await prepareReleaseEpisodes(exec, {
entityId,
kind: entity.kind,
@@ -711,8 +693,6 @@ export async function reresolveEntityEpisodes(
table,
rules,
});
// 规则刚被停用:后面的资源不再用它
if (r.resolution.ruleFailed) rules = rules.filter((x) => x.id !== r.resolution.ruleFailed);
const before = row.episode_resolution as EpisodeResolution | null;
const changed = !sameResolution(before, r.resolution);
if (changed) out.changed++;
@@ -0,0 +1,62 @@
import {
type CompiledGlobalWord,
compileGlobalWords,
type ParsedRelease,
type ParseOptions,
parseRelease,
} from "@umt/domain";
import type { Exec } from "./common.ts";
// 全局识别词的进程内缓存(docs/design/identify-words.md「在哪里生效」):所有按标题 / 文件名识别的地方解析前都要套用。
// 三个进程各缓存一份,最多 5 秒按版本号核对一次;本进程保存后立即失效。后台重算任务开始前也强制重读。
const TTL_MS = 5_000;
let cached: { at: number; version: number; words: readonly CompiledGlobalWord[] } | null = null;
/** 当前的全局识别词(已编译) */
export async function globalWords(exec: Exec): Promise<readonly CompiledGlobalWord[]> {
if (cached && Date.now() - cached.at < TTL_MS) return cached.words;
const row = await exec
.selectFrom("identify_words")
.select(["text", "version"])
.where("media_entity_id", "is", null)
.executeTakeFirst();
const version = row?.version ?? 0;
const words = cached && cached.version === version ? cached.words : compileGlobalWords(row?.text ?? "");
cached = { at: Date.now(), version, words };
return words;
}
/** 保存后、或测试里直接改了表时调用 */
export function invalidateGlobalWords(): void {
cached = null;
}
/** 标题 + 副标题按全局识别词解析(资源识别、搜索、站点抓取共用) */
export async function parseTitle(
exec: Exec,
title: string,
subtitle?: string | null,
opts: Omit<ParseOptions, "subtitle" | "words"> = {},
): Promise<ParsedRelease> {
const words = await globalWords(exec);
return parseRelease(title, { ...opts, ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) });
}
/** 批量解析用:先取一次识别词,返回同步的解析函数 */
export async function titleParser(
exec: Exec,
): Promise<
(title: string, subtitle?: string | null, opts?: Omit<ParseOptions, "subtitle" | "words">) => ParsedRelease
> {
const words = await globalWords(exec);
return (title, subtitle, opts = {}) =>
parseRelease(title, { ...opts, ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) });
}
/** 文件路径解析用的选项(parseReleasePath 的 opts) */
export async function wordsOption(exec: Exec): Promise<Pick<ParseOptions, "words">> {
const words = await globalWords(exec);
return words.length ? { words } : {};
}
@@ -0,0 +1,567 @@
import { randomUUID } from "node:crypto";
import {
compileEntityWords,
type EntityWordsDryRun,
type EntityWordsList,
type EntityWordsSaveResult,
type EntityWordsView,
type GlobalWordsDryRun,
type GlobalWordsDryRunItem,
type GlobalWordsSaveResult,
type IdentifyWordsBlock,
type IdentifyWordsSave,
parseGlobalWords,
splitLines,
type WordsParsedBrief,
type WordsReresolveProgress,
type WordsTryInput,
type WordsTryResult,
} from "@umt/contracts";
import { type IdentifyWordsRow, sql } from "@umt/db";
import {
applyGlobalWords,
type CompiledGlobalWord,
compileGlobalWords,
type ParsedRelease,
parseRelease,
resolveEpisodes,
selectRule,
wordTouches,
} from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts";
import { jsonb } from "../rows.ts";
import { type Actor, audit, type Exec } from "./common.ts";
import { previewEpisodeMapping, rulesOfLine, validationOf } from "./episode-mapping-admin.ts";
import { loadSeasonTable, publishedAtOf, reresolveEntityEpisodes } from "./episode-resolution.ts";
import { globalWords, invalidateGlobalWords } from "./identify-words-cache.ts";
// 识别词(docs/design/identify-words.md):全局一块 + 每部作品一块,原文整块存在 identify_words。
// - 全局识别词(MP 自定义识别词原样):所有按标题 / 文件名识别的地方解析前套用(identify-words-cache.ts);
// 保存后后台扫一遍资源,标题 / 副标题命中改动行(新增或去掉的行)的重新识别,进度写在 reresolve 上
// - 作品识别词(季集换算):编译成规则给集数解析用;保存后按作品重算
// 保存都带读到的版本,别人先改过返回 409。
export const WORDS_RERESOLVE_TASK = "reresolveIdentifyWords";
/** 全局试算扫多少条近期资源、最多列多少条 */
const DRY_RUN_SCAN = 1000;
const DRY_RUN_ITEMS = 100;
/** 示例:找命中这一行的近期资源时最多扫多少条 */
const TRY_SCAN = 2000;
/** 后台重算每批资源数 */
const RERESOLVE_BATCH = 500;
/** 审计里最多记多少行改动 */
const AUDIT_LINES = 50;
const bad = (msg: string) => new ApiException("bad_request", msg);
const by = (row: Pick<IdentifyWordsRow, "updated_by_type" | "updated_by_id"> | undefined) =>
row?.updated_by_type ? `${row.updated_by_type}:${row.updated_by_id ?? ""}` : null;
function blockOf(row: IdentifyWordsRow | undefined, entityId: string | null): IdentifyWordsBlock {
return {
mediaEntityId: entityId,
text: row?.text ?? "",
version: row?.version ?? 0,
updatedAt: row?.updated_at ? row.updated_at.toISOString() : null,
updatedBy: by(row),
reresolve: (row?.reresolve as WordsReresolveProgress | null) ?? null,
};
}
const rowQuery = (exec: Exec, entityId: string | null) => {
const q = exec.selectFrom("identify_words").selectAll();
return entityId ? q.where("media_entity_id", "=", entityId) : q.where("media_entity_id", "is", null);
};
/** 生效行的增减(全局按原文比较;作品按去掉首尾空白比较) */
function diffLines(before: string[], after: string[]): { added: string[]; removed: string[] } {
const a = new Set(before);
const b = new Set(after);
return { added: after.filter((l) => !a.has(l)), removed: before.filter((l) => !b.has(l)) };
}
/** 参与套用的行(写错的行也算:与 compileGlobalWords 一致) */
const globalLines = (text: string) =>
parseGlobalWords(text)
.filter((w) => w.kind !== "blank" && w.kind !== "comment")
.map((w) => w.text);
const entityLines = (text: string) =>
splitLines(text)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
/** 写一块识别词(调用方在事务里);版本不符 → 409 */
async function writeBlock(
trx: Exec,
entityId: string | null,
text: string,
expectedVersion: number | null,
actor: Actor,
extra: { reresolve?: unknown } = {},
): Promise<{ row: IdentifyWordsRow; before: string }> {
const cur = await rowQuery(trx, entityId).forUpdate().executeTakeFirst();
const version = cur?.version ?? 0;
if (expectedVersion !== null && expectedVersion !== version)
throw new ApiException("conflict", "识别词已经被别处改过,刷新后再改");
const values = {
text,
version: version + 1,
updated_by_type: actor.type,
updated_by_id: actor.id,
...(extra.reresolve !== undefined ? { reresolve: jsonb(extra.reresolve) } : {}),
};
const row = cur
? await trx
.updateTable("identify_words")
.set(values)
.where("id", "=", cur.id)
.returningAll()
.executeTakeFirstOrThrow()
: await trx
.insertInto("identify_words")
.values({ media_entity_id: entityId, ...values })
.returningAll()
.executeTakeFirstOrThrow();
const before = cur?.text ?? "";
const d = entityId
? diffLines(entityLines(before), entityLines(text))
: diffLines(globalLines(before), globalLines(text));
await audit(
trx,
actor,
"identify_words.updated",
{ type: "identify_words", id: row.id },
{
mediaEntityId: entityId,
version: row.version,
added: d.added.slice(0, AUDIT_LINES),
removed: d.removed.slice(0, AUDIT_LINES),
},
);
return { row, before };
}
// ── 全局 ──
export async function getGlobalWords(exec: Exec): Promise<IdentifyWordsBlock> {
return blockOf(await rowQuery(exec, null).executeTakeFirst(), null);
}
function brief(p: ParsedRelease): WordsParsedBrief {
return {
title: p.title ?? null,
year: p.year ?? null,
season: p.season ?? null,
episodes: p.episodes ?? null,
releaseGroup: p.releaseGroup?.name ?? null,
explicitIds: p.explicitIds.map((x) => ({ provider: x.provider, id: x.id })),
};
}
const sameBrief = (a: WordsParsedBrief, b: WordsParsedBrief) => JSON.stringify(a) === JSON.stringify(b);
const parseWith = (title: string, subtitle: string | null, words: readonly CompiledGlobalWord[]) =>
parseRelease(title, { ...(subtitle ? { subtitle } : {}), ...(words.length ? { words } : {}) });
/** 试算全局识别词:逐行错误、增减的行、近期资源里识别结果会变的(纯读) */
export async function dryRunGlobalWords(exec: Exec, text: string): Promise<GlobalWordsDryRun> {
const errors = parseGlobalWords(text)
.filter((w) => w.error)
.map((w) => ({ line: w.line, message: w.error! }));
const cur = (await rowQuery(exec, null).executeTakeFirst())?.text ?? "";
const { added, removed } = diffLines(globalLines(cur), globalLines(text));
const changed = compileGlobalWords([...added, ...removed].join("\n"));
const out: GlobalWordsDryRun = { errors, added, removed, scanned: 0, matched: 0, items: [] };
if (!changed.length) return out;
const before = compileGlobalWords(cur);
const after = compileGlobalWords(text);
const rows = await exec
.selectFrom("source_release as r")
.leftJoin("media_entity as e", "e.id", "r.media_entity_id")
.select(["r.id", "r.title", "r.subtitle", "r.media_entity_id", "e.title as entity_title"])
.orderBy("r.first_seen_at", "desc")
.orderBy("r.id")
.limit(DRY_RUN_SCAN)
.execute();
for (const r of rows) {
out.scanned++;
if (!changed.some((w) => wordTouches(w, r.title, r.subtitle))) continue;
out.matched++;
const b = parseWith(r.title, r.subtitle, before);
const a = parseWith(r.title, r.subtitle, after);
const item: GlobalWordsDryRunItem = {
sourceReleaseId: r.id,
title: r.title,
subtitle: r.subtitle,
mediaEntityId: r.media_entity_id,
entityTitle: r.entity_title ?? null,
words: a.words?.applied ?? [],
before: brief(b),
after: brief(a),
};
if (!sameBrief(item.before, item.after) && out.items.length < DRY_RUN_ITEMS) out.items.push(item);
}
return out;
}
/** 保存全局识别词;生效行有增减时在后台重新识别命中改动行的资源 */
export async function saveGlobalWords(
ctx: ServerContext,
body: IdentifyWordsSave,
actor: Actor,
): Promise<GlobalWordsSaveResult> {
const result = await ctx.db.transaction().execute(async (trx) => {
const cur = (await rowQuery(trx, null).executeTakeFirst())?.text ?? "";
const { added, removed } = diffLines(globalLines(cur), globalLines(body.text));
let progress: (WordsReresolveProgress & { changed: string[] }) | undefined;
if (added.length || removed.length) {
const total = await trx
.selectFrom("source_release")
.select((eb) => eb.fn.countAll<string>().as("n"))
.executeTakeFirstOrThrow();
progress = {
status: "queued",
total: Number(total.n),
scanned: 0,
matched: 0,
queued: 0,
startedAt: new Date().toISOString(),
finishedAt: null,
changed: [...added, ...removed],
};
}
const { row } = await writeBlock(trx, null, body.text, body.expectedVersion, actor, {
...(progress ? { reresolve: progress } : {}),
});
return { row, progress };
});
invalidateGlobalWords();
if (result.progress) {
await ctx.engine.enqueue(
WORDS_RERESOLVE_TASK,
{ version: result.row.version },
{ idempotencyKey: `${WORDS_RERESOLVE_TASK}:${result.row.version}`, traceId: randomUUID() },
);
}
return { block: blockOf(result.row, null) };
}
export interface ReresolveState {
last: string | null;
scanned: number;
matched: number;
queued: number;
}
/**
* 后台重算一批:标题 / 副标题命中改动行的资源重新识别(身份与集数都重来;人工锁定的不动)。
* 返回 null 表示这一版已被更新的保存取代或已经扫完。
*/
export async function reresolveWordsBatch(
ctx: ServerContext,
version: number,
state: ReresolveState,
): Promise<ReresolveState | null> {
const row = await rowQuery(ctx.db, null).executeTakeFirst();
if (!row || row.version !== version) return null;
const progress = (row.reresolve ?? {}) as WordsReresolveProgress & { changed?: string[] };
const changed = compileGlobalWords((progress.changed ?? []).join("\n"));
invalidateGlobalWords();
let q = ctx.db
.selectFrom("source_release")
.select(["id", "title", "subtitle", "resolution_locked"])
.orderBy("id")
.limit(RERESOLVE_BATCH);
if (state.last) q = q.where("id", ">", state.last);
const rows = await q.execute();
const next: ReresolveState = { ...state, last: rows.at(-1)?.id ?? state.last };
for (const r of rows) {
next.scanned++;
if (!changed.some((w) => wordTouches(w, r.title, r.subtitle))) continue;
next.matched++;
if (r.resolution_locked) continue;
await ctx.engine.enqueue(
"resolveObservation",
{ sourceReleaseId: r.id, force: true, trigger: "review" },
{ idempotencyKey: `words:${version}:${r.id}`, traceId: randomUUID() },
);
next.queued++;
}
const done = rows.length < RERESOLVE_BATCH;
await ctx.db
.updateTable("identify_words")
.set({
reresolve: jsonb({
...progress,
status: done ? "done" : "running",
scanned: next.scanned,
matched: next.matched,
queued: next.queued,
finishedAt: done ? new Date().toISOString() : null,
}),
})
.where("id", "=", row.id)
.where("version", "=", version)
.execute();
return done ? null : next;
}
/** 后台任务失败时记在进度上 */
export async function markReresolveFailed(ctx: ServerContext, version: number, error: string): Promise<void> {
const row = await rowQuery(ctx.db, null).executeTakeFirst();
if (!row || row.version !== version) return;
await ctx.db
.updateTable("identify_words")
.set({
reresolve: jsonb({
...((row.reresolve ?? {}) as object),
status: "failed",
error,
finishedAt: new Date().toISOString(),
}),
})
.where("id", "=", row.id)
.execute();
}
// ── 作品 ──
const EPISODIC = new Set(["series", "variety"]);
async function loadEntity(exec: Exec, entityId: string) {
const e = await exec
.selectFrom("media_entity")
.select(["id", "title", "kind"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e) throw new ApiException("not_found", "作品不存在");
return e;
}
export async function listEntityWords(exec: Exec, q: string | undefined): Promise<EntityWordsList> {
let query = exec
.selectFrom("identify_words as w")
.innerJoin("media_entity as e", "e.id", "w.media_entity_id")
.select([
"w.media_entity_id",
"w.text",
"w.updated_at",
"w.updated_by_type",
"w.updated_by_id",
"e.title",
"e.year",
])
.where("w.media_entity_id", "is not", null)
.where("w.text", "<>", "")
.orderBy("w.updated_at", "desc");
if (q?.trim()) query = query.where("e.title", "ilike", `%${q.trim().replace(/[%_\\]/g, (c) => `\\${c}`)}%`);
const rows = await query.limit(500).execute();
return {
items: rows.map((r) => {
const c = compileEntityWords(r.text);
const disabled = c.lines.filter((l) => l.kind === "comment" && isCommentedRule(l.text)).length;
return {
mediaEntityId: r.media_entity_id!,
title: r.title,
year: r.year,
rules: c.lines.filter((l) => l.kind !== "blank" && l.kind !== "comment" && !l.error).length,
disabled,
errors: c.errors.length,
updatedAt: r.updated_at.toISOString(),
updatedBy: by(r),
};
}),
};
}
/** 注释行的内容是一行合法的作品识别词(停用的规则) */
function isCommentedRule(line: string): boolean {
const body = line.trimStart().replace(/^#\s?/, "");
const c = compileEntityWords(body);
return c.rules.length > 0 && c.errors.length === 0;
}
export async function getEntityWords(exec: Exec, entityId: string): Promise<EntityWordsView> {
const e = await loadEntity(exec, entityId);
const row = await rowQuery(exec, entityId).executeTakeFirst();
const groups = await exec
.selectFrom("source_release")
.select(sql<string | null>`parsed -> 'releaseGroup' ->> 'name'`.as("g"))
.where("media_entity_id", "=", entityId)
.orderBy("first_seen_at", "desc")
.limit(500)
.execute();
const known = new Map<string, string>();
for (const r of groups) {
const g = typeof r.g === "string" ? r.g.trim() : "";
if (g && !known.has(g.toLowerCase())) known.set(g.toLowerCase(), g);
}
return {
block: blockOf(row, entityId),
title: e.title,
applicable: EPISODIC.has(e.kind),
knownGroups: [...known.values()].sort((a, b) => a.localeCompare(b)),
};
}
export async function dryRunEntityWords(exec: Exec, entityId: string, text: string): Promise<EntityWordsDryRun> {
const c = compileEntityWords(text);
const preview = await previewEpisodeMapping(exec, entityId, { words: text });
return {
errors: c.errors,
rules: c.rules.length,
preview,
changed: preview.items.filter((i) => i.changed).length,
mismatched: preview.items.filter((i) => i.preview.method === "unresolved").length,
};
}
export async function saveEntityWords(
ctx: ServerContext,
entityId: string,
body: IdentifyWordsSave,
actor: Actor,
): Promise<EntityWordsSaveResult> {
const e = await loadEntity(ctx.db, entityId);
if (!EPISODIC.has(e.kind) && compileEntityWords(body.text).rules.length) throw bad("只有剧集和综艺能写季集换算");
const c = compileEntityWords(body.text);
if (c.errors.length) {
const first = c.errors[0]!;
throw bad(`第 ${first.line} 行:${first.message}`);
}
return ctx.db.transaction().execute(async (trx) => {
const { row } = await writeBlock(trx, entityId, body.text, body.expectedVersion, actor);
const reresolve = await reresolveEntityEpisodes(ctx, trx, entityId);
return { block: blockOf(row, entityId), reresolve };
});
}
/**
* 往作品识别词末尾追加几行(集数待确认的确认 / 忽略、智能体写规则),上方加一行注释说明来历。
* 调用方在事务里,随后自己重算。返回第一条规则行的行号。
*/
export async function appendEntityWords(
trx: Exec,
entityId: string,
lines: string[],
actor: Actor,
comment: string,
): Promise<{ text: string; lineNumber: number; appended: string }> {
const cur = await rowQuery(trx, entityId).executeTakeFirst();
const base = (cur?.text ?? "").replace(/\n+$/, "");
const head = base ? `${base}\n` : "";
const note = `# ${new Date().toISOString().slice(0, 10)} ${comment}`.replace(/\n/g, " ");
const appended = [note, ...lines].join("\n");
const text = `${head}${appended}\n`;
const c = compileEntityWords(text);
if (c.errors.length) {
const first = c.errors[0]!;
throw new ApiException("conflict", `写进作品识别词后第 ${first.line} 行有问题:${first.message}`);
}
await writeBlock(trx, entityId, text, null, actor);
return { text, lineNumber: (base ? splitLines(base).length : 0) + 2, appended };
}
// ── 实时示例 ──
/** 拿一个标题试一行识别词;没给标题就从近期资源里找这一行命中的 */
export async function tryWordsLine(exec: Exec, input: WordsTryInput): Promise<WordsTryResult> {
const empty: WordsTryResult = {
sample: null,
matched: false,
error: null,
titleAfter: null,
subtitleAfter: null,
parsed: null,
episode: null,
validation: null,
};
if (input.scope === "global") {
const g = parseGlobalWords(input.line)[0];
if (!g || g.kind === "blank" || g.kind === "comment") return { ...empty, error: "这一行不是识别词" };
if (g.error) return { ...empty, error: g.error };
const words = compileGlobalWords(input.line);
let sample: WordsTryResult["sample"] = input.title
? { sourceReleaseId: null, title: input.title, subtitle: input.subtitle ?? null }
: null;
if (!sample) {
const rows = await exec
.selectFrom("source_release")
.select(["id", "title", "subtitle"])
.orderBy("first_seen_at", "desc")
.limit(TRY_SCAN)
.execute();
const hit = rows.find((r) => wordTouches(words[0]!, r.title, r.subtitle));
if (!hit) return empty;
sample = { sourceReleaseId: hit.id, title: hit.title, subtitle: hit.subtitle };
}
const p = parseWith(sample.title, sample.subtitle, words);
const after = applyTitle(sample.title, sample.subtitle, words);
return {
...empty,
sample,
matched: !!p.words?.applied.length,
titleAfter: after.title,
subtitleAfter: after.subtitle,
parsed: brief(p),
};
}
if (!input.mediaEntityId) throw bad("要给作品");
const e = await loadEntity(exec, input.mediaEntityId);
let rules: ReturnType<typeof rulesOfLine>;
try {
rules = rulesOfLine(input.line);
} catch (err) {
return { ...empty, error: (err as Error).message };
}
const words = await globalWords(exec);
const table = await loadSeasonTable(exec, e.id);
const run = (title: string, subtitle: string | null, publishedAt: string | null) => {
const parsed = parseWith(title, subtitle, words);
const season = parsed.season ?? (parsed.episodes ? 1 : null);
const hit = selectRule(rules, season, parsed.releaseGroup?.name ?? null);
const res = resolveEpisodes({ parsed, kind: e.kind, table, rules, publishedAt });
return { parsed, hit, res };
};
let sample: WordsTryResult["sample"] = null;
let result: ReturnType<typeof run> | null = null;
if (input.title) {
sample = { sourceReleaseId: null, title: input.title, subtitle: input.subtitle ?? null };
result = run(input.title, input.subtitle ?? null, null);
} else {
const rows = await exec
.selectFrom("source_release")
.select(["id", "title", "subtitle", "source_published_at", "first_seen_at"])
.where("media_entity_id", "=", e.id)
.orderBy((eb) => eb.fn.coalesce("source_published_at", "first_seen_at"), "desc")
.limit(TRY_SCAN)
.execute();
for (const r of rows) {
const x = run(r.title, r.subtitle, publishedAtOf(r).toISOString());
if (x.hit) {
sample = { sourceReleaseId: r.id, title: r.title, subtitle: r.subtitle };
result = x;
break;
}
}
}
if (!sample || !result) return empty;
const applied = applyTitle(sample.title, sample.subtitle, words);
return {
...empty,
sample,
matched: !!result.hit,
titleAfter: applied.title,
subtitleAfter: applied.subtitle,
parsed: brief(result.parsed),
episode: result.res,
validation: validationOf(result.res),
};
}
function applyTitle(title: string, subtitle: string | null, words: readonly CompiledGlobalWord[]) {
const w = applyGlobalWords(title, subtitle, words);
return { title: w.title, subtitle: w.subtitle };
}
@@ -7,7 +7,7 @@ import type {
ReleaseIdentityAssign,
} from "@umt/contracts";
import { type ExternalIdentityRow, sql } from "@umt/db";
import { type EpisodeResolution, parseRelease } from "@umt/domain";
import type { EpisodeResolution } from "@umt/domain";
import { toParsedColumns } from "@umt/mapping";
import { ApiException } from "../../../http/errors.ts";
import { jsonb } from "../rows.ts";
@@ -28,6 +28,7 @@ import {
publishedAtOf,
settleEpisodeDecision,
} from "./episode-resolution.ts";
import { parseTitle } from "./identify-words-cache.ts";
import { detachFromIdentityDecisions } from "./identity-decisions.ts";
import { clearTmdbMetadata } from "./metadata.ts";
import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts";
@@ -86,7 +87,7 @@ export async function assignReleaseIdentity(
const { ctx } = rt;
const release = await ctx.db.selectFrom("source_release").selectAll().where("id", "=", srId).executeTakeFirst();
if (!release) throw new ApiException("not_found", "资源不存在");
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
const parsed = await parseTitle(ctx.db, release.title, release.subtitle);
const site = ((release.raw as Record<string, unknown>)?._site ?? {}) as { categoryName?: string };
const variety = looksLikeVariety(parsed, site.categoryName ?? release.category_raw);
@@ -1,12 +1,13 @@
import { recallTmdbCandidates } from "@umt/adapters";
import type { OrganizeMetadataOutput } from "@umt/contracts";
import { parseRelease, resolveIdentity } from "@umt/domain";
import { resolveIdentity } from "@umt/domain";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit } from "./common.ts";
import { detailScreenshotUrl, loadDetailPage } from "./detail-page.ts";
import { applyEnrichment, type EnrichDeps, type EnrichPage, suggestEnrichment } from "./enrich.ts";
import type { Candidate } from "./enrich-sources.ts";
import { parseTitle } from "./identify-words-cache.ts";
import { assignReleaseIdentity } from "./identity-admin.ts";
import { type EnrichmentFieldWrite, project, writeEnrichmentFields } from "./metadata.ts";
import { resolveRelease } from "./resolve.ts";
@@ -200,7 +201,7 @@ export async function resolveOrganizeMetadata(
verbatim: true,
by: "rule",
});
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
const parsed = await parseTitle(rt.ctx.db, release.title, release.subtitle);
const kind =
parsed.mediaTypeHint === "tv" ||
parsed.season !== undefined ||
@@ -8,7 +8,6 @@ import {
normalizeTitle,
PARSER_VERSION,
type ParsedRelease,
parseRelease,
queryTitles,
type RankedCandidate,
resolveIdentity,
@@ -47,6 +46,7 @@ import {
unitsAllowed,
} from "./episode-resolution.ts";
import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts";
import { parseTitle } from "./identify-words-cache.ts";
import {
detachFromIdentityDecisions,
identityGroupKey,
@@ -488,7 +488,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
const parsed = await rewriteHook(
ctx,
"name.recognize",
parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}),
await parseTitle(ctx.db, release.title, release.subtitle),
() => ({
title: release.title,
subtitle: release.subtitle ?? null,
@@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters";
import { lifecycleKey, type SiteSnapshotKind } from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { parseRelease } from "@umt/domain";
import { siteReleaseToSourceRelease } from "@umt/mapping";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
@@ -10,6 +9,7 @@ import { siteCan } from "../site-kinds.ts";
import { enqueueRelogin } from "./account.ts";
import { closeDecisions, type Exec, openDecision } from "./common.ts";
import { hasDetailPage, saveDetailPage } from "./detail-page.ts";
import { parseTitle } from "./identify-words-cache.ts";
import { emitSiteReachability } from "./lifecycle-events.ts";
// 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。
@@ -41,7 +41,7 @@ export async function upsertSiteRelease(
site: Pick<SiteRow, "id">,
r: SiteRelease,
): Promise<{ id: string; inserted: boolean }> {
const parsed = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
const parsed = await parseTitle(trx, r.title, r.subtitle);
const c = siteReleaseToSourceRelease(r, { siteId: site.id, parsed });
const row = (
await sql<{
@@ -5,6 +5,12 @@ import type { SourcesRuntime } from "./runtime.ts";
import { refreshSiteAccount } from "./service/account.ts";
import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts";
import { ingestFeed } from "./service/feeds.ts";
import {
markReresolveFailed,
type ReresolveState,
reresolveWordsBatch,
WORDS_RERESOLVE_TASK,
} from "./service/identify-words.ts";
import { refreshMetadata } from "./service/metadata.ts";
import { prewarmMetadata } from "./service/metadata-prewarm.ts";
import { resolveRelease } from "./service/resolve.ts";
@@ -69,6 +75,30 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
retryPolicy: { maximumAttempts: 1 },
handler: (t) => t.step.run("enrich", () => runAutoEnrich(rt().ctx, t.input.poolEntryId, t.runId, t.abortSignal)),
}),
// 全局识别词保存后:分批扫资源,标题 / 副标题命中改动行的重新识别(进度写在 identify_words.reresolve)
defineTask({
name: WORDS_RERESOLVE_TASK,
version: 1,
lane: "network",
inputSchema: z.object({ version: z.number().int() }),
retryPolicy: { maximumAttempts: 3, initialInterval: "5s", backoffCoefficient: 2, maximumInterval: "60s" },
handler: async (t) => {
let state: ReresolveState | null = { last: null, scanned: 0, matched: 0, queued: 0 };
try {
for (let i = 0; state; i++) {
const cur: ReresolveState = state;
state = await t.step.run(
`batch:${i}`,
(): Promise<ReresolveState | null> => reresolveWordsBatch(rt().ctx, t.input.version, cur),
);
}
} catch (e) {
await markReresolveFailed(rt().ctx, t.input.version, (e as Error).message);
throw e;
}
return { done: true };
},
}),
defineTask({
name: "refreshMetadata",
version: 1,