- metadata.enrich (propose, low, replay safe): normalized-title cache (kv_cache
region enrich, 7 days) -> daily budget -> web.search (title + 短剧 / 电视剧 平台,
second query limited to whitelist via site:) -> web.fetch of up to 3 relevant
whitelisted pages (registered site domains never read, redirects out of the
whitelist dropped) -> upstream model extraction with rule fallback -> server-side
evidence check (rule_confirmed / multi_evidence / needs_review). Suggestion only.
- POST /identity-pool/{id}/enrich (suggest) and /enrich/apply (console session):
provisional entity or same-title merge, season/episode units, field-level
metadata with source web/agent + evidence, aliases, release assignment.
Poster proxy GET /img/enrich/{id} (og:image from whitelisted pages only).
- Auto-enrich once per pool entry when subscription/rule lookups find nothing on
TMDB (identity.autoEnrich, off by default); identity.enrichDailyBudget,
identity.enrichDomains settings; web.fetch now returns og:image.
- Pool sheet "元数据补全" section; entity detail shows evidence links.
- Migration 0049_enrichment (metadata_enrichment, metadata_field.evidence, source
web). Requires image rebuild / api + worker restart to take effect.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
import { z } from "zod";
|
|
import { ActorType, EntityBase, Id, JsonObject, Timestamp } from "./common.ts";
|
|
|
|
// 身份域:外部身份映射与修订(REQ §7.2)
|
|
// provider + entityType + externalId 对 accepted 唯一;TMDB movie:123 与 tv:123 是两个身份。
|
|
|
|
export const IdentityStatus = z.enum(["accepted", "proposed", "conflicted", "retired"]);
|
|
export type IdentityStatus = z.infer<typeof IdentityStatus>;
|
|
export const ExternalEntityType = z.enum(["movie", "tv", "season", "episode", "collection", "person"]);
|
|
export type ExternalEntityType = z.infer<typeof ExternalEntityType>;
|
|
/** 证据等级:未经标注集校准前不展示概率(REQ §5.3) */
|
|
export const EvidenceLevel = z.enum(["manual", "rule_confirmed", "multi_evidence", "needs_review"]);
|
|
export type EvidenceLevel = z.infer<typeof EvidenceLevel>;
|
|
export const IdentityVerifyStatus = z.enum(["unverified", "ok", "not_found", "type_mismatch", "error"]);
|
|
export type IdentityVerifyStatus = z.infer<typeof IdentityVerifyStatus>;
|
|
export const IdentityProvider = z.string().regex(/^[a-z][a-z0-9_]*$/);
|
|
|
|
export const ExternalIdentity = EntityBase.extend({
|
|
mediaEntityId: Id.nullable(),
|
|
contentUnitId: Id.nullable(),
|
|
provider: IdentityProvider,
|
|
entityType: ExternalEntityType,
|
|
externalId: z.string().min(1),
|
|
status: IdentityStatus,
|
|
evidenceLevel: EvidenceLevel,
|
|
evidence: JsonObject,
|
|
/** 人工锁:自动匹配/刷新不得改动 */
|
|
locked: z.boolean(),
|
|
revision: z.number().int().min(1),
|
|
validFrom: Timestamp.nullable(),
|
|
validUntil: Timestamp.nullable(),
|
|
verifiedAt: Timestamp.nullable(),
|
|
/** 404/超时/鉴权失败只记录到这里,不是删除映射的依据 */
|
|
verifyStatus: IdentityVerifyStatus,
|
|
/** 附属字段,如 TMDB episode_group */
|
|
extra: JsonObject,
|
|
/** 派生字段(列表返回):作品标题(挂在内容单元上时取所属作品) */
|
|
mediaEntityTitle: z.string().nullable().optional(),
|
|
});
|
|
export type ExternalIdentity = z.infer<typeof ExternalIdentity>;
|
|
|
|
const oneTarget = (v: { mediaEntityId?: string | null; contentUnitId?: string | null }) =>
|
|
Number(!!v.mediaEntityId) + Number(!!v.contentUnitId) === 1;
|
|
|
|
export const ExternalIdentityCreate = ExternalIdentity.pick({
|
|
mediaEntityId: true,
|
|
contentUnitId: true,
|
|
provider: true,
|
|
entityType: true,
|
|
externalId: true,
|
|
status: true,
|
|
evidenceLevel: true,
|
|
evidence: true,
|
|
locked: true,
|
|
validFrom: true,
|
|
validUntil: true,
|
|
extra: true,
|
|
})
|
|
.partial({
|
|
mediaEntityId: true,
|
|
contentUnitId: true,
|
|
status: true,
|
|
evidenceLevel: true,
|
|
evidence: true,
|
|
locked: true,
|
|
validFrom: true,
|
|
validUntil: true,
|
|
extra: true,
|
|
})
|
|
.refine(oneTarget, { message: "mediaEntityId 与 contentUnitId 必须且只能给一个" });
|
|
export type ExternalIdentityCreate = z.infer<typeof ExternalIdentityCreate>;
|
|
|
|
/** 变更一律生成 identity_revision;expectedRevision 用于乐观并发 */
|
|
export const ExternalIdentityUpdate = z.object({
|
|
expectedRevision: z.number().int().min(1),
|
|
status: IdentityStatus.optional(),
|
|
evidenceLevel: EvidenceLevel.optional(),
|
|
evidence: JsonObject.optional(),
|
|
locked: z.boolean().optional(),
|
|
validUntil: Timestamp.nullable().optional(),
|
|
extra: JsonObject.optional(),
|
|
reason: z.string().max(2000).optional(),
|
|
});
|
|
export type ExternalIdentityUpdate = z.infer<typeof ExternalIdentityUpdate>;
|
|
|
|
export const IdentityRevisionAction = z.enum([
|
|
"created",
|
|
"status_changed",
|
|
"relinked",
|
|
"evidence_updated",
|
|
"locked",
|
|
"unlocked",
|
|
]);
|
|
export type IdentityRevisionAction = z.infer<typeof IdentityRevisionAction>;
|
|
|
|
/** 身份修订历史:只追加,不修改 */
|
|
export const IdentityRevision = EntityBase.extend({
|
|
externalIdentityId: Id,
|
|
revision: z.number().int().min(1),
|
|
action: IdentityRevisionAction,
|
|
fromStatus: IdentityStatus.nullable(),
|
|
toStatus: IdentityStatus.nullable(),
|
|
before: JsonObject.nullable(),
|
|
after: JsonObject,
|
|
actorType: ActorType,
|
|
actorId: z.string().nullable(),
|
|
reason: z.string().nullable(),
|
|
});
|
|
export type IdentityRevision = z.infer<typeof IdentityRevision>;
|
|
/** 由服务端在身份变更事务内写入;此输入仅供内部仓储使用 */
|
|
export const IdentityRevisionCreate = IdentityRevision.pick({
|
|
externalIdentityId: true,
|
|
revision: true,
|
|
action: true,
|
|
fromStatus: true,
|
|
toStatus: true,
|
|
before: true,
|
|
after: true,
|
|
actorType: true,
|
|
actorId: true,
|
|
reason: true,
|
|
}).partial({ fromStatus: true, toStatus: true, before: true, actorId: true, reason: true });
|
|
export type IdentityRevisionCreate = z.infer<typeof IdentityRevisionCreate>;
|
|
|
|
// ── W2-B:待识别池、识别尝试、合并跳转与人工纠错(REQ §7.2、§7.5) ──
|
|
|
|
export const IdentityCandidateStatus = z.enum(["pending", "resolved", "dismissed", "archived"]);
|
|
export type IdentityCandidateStatus = z.infer<typeof IdentityCandidateStatus>;
|
|
/**
|
|
* proposed = 证据足够但自动化关闭/只读,待确认;
|
|
* unmatched = 惰性识别:本地召回无结果且没有触发 TMDB 查询(不相关的 RSS / 列表资源),只进池、不开待处理
|
|
*/
|
|
export const IdentityCandidateReason = z.enum(["ambiguous", "no_match", "proposed", "lookup_failed", "unmatched"]);
|
|
export type IdentityCandidateReason = z.infer<typeof IdentityCandidateReason>;
|
|
|
|
/** 待识别池里的一个候选(只存 JSON,不建正式作品) */
|
|
export const PoolCandidate = z.object({
|
|
provider: z.string(),
|
|
entityType: z.enum(["movie", "tv"]),
|
|
externalId: z.string(),
|
|
title: z.string(),
|
|
year: z.number().int().nullable(),
|
|
/** 本地候选的作品 ID */
|
|
localEntityId: Id.nullable(),
|
|
level: z.enum(["rule_confirmed", "multi_evidence", "needs_confirm", "rejected"]),
|
|
evidence: z.array(z.string()),
|
|
});
|
|
export type PoolCandidate = z.infer<typeof PoolCandidate>;
|
|
|
|
/** 待识别池条目:每条 source_release 至多一条;失败原因、证据版本与下一次复查时间 */
|
|
export const IdentityCandidate = EntityBase.extend({
|
|
sourceReleaseId: Id,
|
|
status: IdentityCandidateStatus,
|
|
reason: IdentityCandidateReason,
|
|
/** 解析出的检索条件(标题/年份/季集) */
|
|
query: JsonObject,
|
|
/** 归一化标题(与 title_alias.normalized 同口径):新增别名时按它唤醒复查,待处理按"来源 + 它"合并 */
|
|
normalizedTitle: z.string().nullable(),
|
|
candidates: z.array(PoolCandidate),
|
|
evidenceVersion: z.string(),
|
|
attempts: z.number().int().nonnegative(),
|
|
lastAttemptAt: Timestamp.nullable(),
|
|
nextReviewAt: Timestamp.nullable(),
|
|
decisionId: Id.nullable(),
|
|
resolvedMediaEntityId: Id.nullable(),
|
|
resolvedBy: z.string().nullable(),
|
|
resolvedAt: Timestamp.nullable(),
|
|
});
|
|
export type IdentityCandidate = z.infer<typeof IdentityCandidate>;
|
|
|
|
export const ResolutionOutcome = z.enum([
|
|
"auto",
|
|
"proposed",
|
|
"ambiguous",
|
|
"no_match",
|
|
"lookup_failed",
|
|
"manual",
|
|
"skipped_locked",
|
|
"unmatched",
|
|
]);
|
|
export type ResolutionOutcome = z.infer<typeof ResolutionOutcome>;
|
|
|
|
/** 一次识别尝试(只追加) */
|
|
export const ResolutionAttempt = EntityBase.extend({
|
|
sourceReleaseId: Id,
|
|
observationId: Id.nullable(),
|
|
outcome: ResolutionOutcome,
|
|
reason: z.string().nullable(),
|
|
evidenceVersion: z.string(),
|
|
candidateCount: z.number().int().nonnegative(),
|
|
mediaEntityId: Id.nullable(),
|
|
best: JsonObject.nullable(),
|
|
error: JsonObject.nullable(),
|
|
actorType: ActorType,
|
|
actorId: z.string().nullable(),
|
|
});
|
|
export type ResolutionAttempt = z.infer<typeof ResolutionAttempt>;
|
|
|
|
/** 作品合并后的旧 ID 跳转 */
|
|
export const IdentityRedirect = EntityBase.extend({
|
|
fromEntityId: Id,
|
|
toEntityId: Id,
|
|
reason: z.string().nullable(),
|
|
actorType: ActorType,
|
|
actorId: z.string().nullable(),
|
|
});
|
|
export type IdentityRedirect = z.infer<typeof IdentityRedirect>;
|
|
|
|
/** 外部身份引用(provider + 类型 + ID) */
|
|
export const ExternalRef = z.object({
|
|
provider: IdentityProvider,
|
|
entityType: z.enum(["movie", "tv"]),
|
|
externalId: z.string().min(1),
|
|
});
|
|
export type ExternalRef = z.infer<typeof ExternalRef>;
|
|
|
|
/**
|
|
* 人工指定来源资源的身份:本地作品 / 外部身份(未登记时按外部详情建档)/ 池内候选序号,三选一。
|
|
* 指定后加人工锁,自动识别不再改写。
|
|
*/
|
|
export const ReleaseIdentityAssign = z
|
|
.object({
|
|
mediaEntityId: Id.optional(),
|
|
external: ExternalRef.optional(),
|
|
candidateIndex: z.number().int().min(0).optional(),
|
|
/** 同时关联到某个内容单元(季/集);缺省按解析出的季集建临时单元 */
|
|
contentUnitId: Id.nullable().optional(),
|
|
lock: z.boolean().default(true),
|
|
reason: z.string().max(2000).optional(),
|
|
})
|
|
.refine((v) => [v.mediaEntityId, v.external, v.candidateIndex].filter((x) => x !== undefined).length === 1, {
|
|
message: "mediaEntityId / external / candidateIndex 必须且只能给一个",
|
|
});
|
|
export type ReleaseIdentityAssign = z.infer<typeof ReleaseIdentityAssign>;
|
|
|
|
/** 改作品的外部映射:旧的同 provider+类型 accepted 映射退役(留修订),新映射 accepted + 人工锁 */
|
|
export const EntityIdentitySet = ExternalRef.extend({
|
|
reason: z.string().max(2000).optional(),
|
|
/** 默认加人工锁 */
|
|
lock: z.boolean().default(true),
|
|
});
|
|
export type EntityIdentitySet = z.infer<typeof EntityIdentitySet>;
|
|
|
|
export const IdentityPoolQuery = z.object({
|
|
status: IdentityCandidateStatus.optional(),
|
|
reason: IdentityCandidateReason.optional(),
|
|
/** 按原始标题/解析标题检索(已观察到的资源必须能从待识别入口找回) */
|
|
q: z.string().trim().min(1).max(200).optional(),
|
|
siteId: Id.optional(),
|
|
cursor: z.string().min(1).optional(),
|
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
});
|
|
export type IdentityPoolQuery = z.infer<typeof IdentityPoolQuery>;
|
|
|
|
/** 待识别池列表条目:附来源资源摘要 */
|
|
export const IdentityPoolItem = IdentityCandidate.extend({
|
|
release: z.object({
|
|
id: Id,
|
|
siteId: Id,
|
|
siteReleaseId: z.string(),
|
|
title: z.string(),
|
|
subtitle: z.string().nullable(),
|
|
categoryRaw: z.string().nullable(),
|
|
firstSeenAt: Timestamp,
|
|
resolutionStatus: z.string(),
|
|
}),
|
|
});
|
|
export type IdentityPoolItem = z.infer<typeof IdentityPoolItem>;
|
|
|
|
// ── META-1:TMDB 缺失作品的元数据补全(REQ R03 / R10、§7.3、§7.5)──
|
|
// 网页搜索 + 读取白名单域名的网页 → 模型(或规则)抽字段,每个字段带来源网页与证据等级;
|
|
// 建议先存补全记录(metadata_enrichment),用户确认后才建本地临时作品并写字段级元数据(来源 web / agent,不冒充 TMDB)。
|
|
|
|
/** 补全字段的证据等级(服务端按网页原文核对得出,不采信模型自报) */
|
|
export const EnrichEvidenceLevel = z.enum(["rule_confirmed", "multi_evidence", "needs_review"]);
|
|
export type EnrichEvidenceLevel = z.infer<typeof EnrichEvidenceLevel>;
|
|
|
|
/** 字段证据:写在 metadata_field.evidence,作品详情"元数据来源"里显示链接 */
|
|
export const FieldEvidence = z.object({
|
|
sourceUrl: z.string().nullable(),
|
|
evidenceLevel: EnrichEvidenceLevel,
|
|
enrichmentId: Id.nullable(),
|
|
/** model = 模型从网页正文抽取;rule = 规则从网页正文抽取 */
|
|
extractor: z.enum(["model", "rule"]).optional(),
|
|
});
|
|
export type FieldEvidence = z.infer<typeof FieldEvidence>;
|
|
|
|
const enrichValue = <T extends z.ZodType>(value: T) =>
|
|
z.object({
|
|
value,
|
|
/** 出处网页(白名单域名);模型给不出出处时为 null */
|
|
sourceUrl: z.string().nullable(),
|
|
evidenceLevel: EnrichEvidenceLevel,
|
|
/** 值在出处网页正文里原样出现(否则是 Agent 归纳 / 改写的生成内容) */
|
|
verbatim: z.boolean(),
|
|
});
|
|
|
|
export const EnrichFields = z.object({
|
|
title: enrichValue(z.string().min(1).max(200)).nullable(),
|
|
aliases: z.array(enrichValue(z.string().min(1).max(200))).max(20),
|
|
year: enrichValue(z.number().int().min(1900).max(2100)).nullable(),
|
|
platform: enrichValue(z.string().min(1).max(100)).nullable(),
|
|
episodeCount: enrichValue(z.number().int().min(1).max(2000)).nullable(),
|
|
synopsis: enrichValue(z.string().min(1).max(2000)).nullable(),
|
|
posterUrl: enrichValue(z.string().min(1).max(2048)).nullable(),
|
|
genre: z.array(enrichValue(z.string().min(1).max(40))).max(10),
|
|
});
|
|
export type EnrichFields = z.infer<typeof EnrichFields>;
|
|
|
|
export const EnrichFieldName = z.enum([
|
|
"title",
|
|
"aliases",
|
|
"year",
|
|
"platform",
|
|
"episodeCount",
|
|
"synopsis",
|
|
"posterUrl",
|
|
"genre",
|
|
]);
|
|
export type EnrichFieldName = z.infer<typeof EnrichFieldName>;
|
|
|
|
/** 搜索结果里的一条链接:是否在白名单、是否读取、为什么跳过 */
|
|
export const EnrichSource = z.object({
|
|
url: z.string(),
|
|
title: z.string().nullable(),
|
|
domain: z.string(),
|
|
allowed: z.boolean(),
|
|
fetched: z.boolean(),
|
|
/** not_allowed = 不在白名单;site = 是已登记站点的域名;irrelevant = 标题对不上;limit = 超出读取条数;error = 读取失败 */
|
|
skipped: z.enum(["not_allowed", "site", "irrelevant", "limit", "error"]).nullable(),
|
|
error: z.string().nullable(),
|
|
});
|
|
export type EnrichSource = z.infer<typeof EnrichSource>;
|
|
|
|
export const EnrichSuggestion = z.object({
|
|
fields: EnrichFields,
|
|
/** 建档类型推断:有集数按剧集 */
|
|
kind: z.enum(["series", "movie"]),
|
|
queries: z.array(z.string()),
|
|
sources: z.array(EnrichSource),
|
|
extractor: z.enum(["model", "rule"]),
|
|
model: z.string().nullable(),
|
|
});
|
|
export type EnrichSuggestion = z.infer<typeof EnrichSuggestion>;
|
|
|
|
export const EnrichUsage = z.object({
|
|
searchCalls: z.number().int().nonnegative(),
|
|
fetchCalls: z.number().int().nonnegative(),
|
|
inputTokens: z.number().int().nonnegative(),
|
|
outputTokens: z.number().int().nonnegative(),
|
|
});
|
|
export type EnrichUsage = z.infer<typeof EnrichUsage>;
|
|
|
|
export const EnrichmentStatus = z.enum(["suggested", "applied", "failed"]);
|
|
export type EnrichmentStatus = z.infer<typeof EnrichmentStatus>;
|
|
export const EnrichmentTrigger = z.enum(["manual", "auto", "agent"]);
|
|
export type EnrichmentTrigger = z.infer<typeof EnrichmentTrigger>;
|
|
|
|
/** 补全记录(metadata_enrichment 行) */
|
|
export const MetadataEnrichment = EntityBase.extend({
|
|
identityCandidateId: Id.nullable(),
|
|
normalizedTitle: z.string(),
|
|
query: JsonObject,
|
|
status: EnrichmentStatus,
|
|
trigger: EnrichmentTrigger,
|
|
suggestion: JsonObject.nullable(),
|
|
error: z.string().nullable(),
|
|
usage: JsonObject,
|
|
mediaEntityId: Id.nullable(),
|
|
appliedAt: Timestamp.nullable(),
|
|
appliedBy: z.string().nullable(),
|
|
createdBy: z.string().nullable(),
|
|
});
|
|
export type MetadataEnrichment = z.infer<typeof MetadataEnrichment>;
|
|
|
|
/** POST /identity-pool/{id}/enrich 的请求体;fresh = 忽略归一化标题缓存重新查(计入每日预算) */
|
|
export const EnrichRequest = z.object({
|
|
hints: z.string().trim().max(200).optional(),
|
|
fresh: z.boolean().optional(),
|
|
});
|
|
export type EnrichRequest = z.infer<typeof EnrichRequest>;
|
|
|
|
/** POST /identity-pool/{id}/enrich/apply */
|
|
export const EnrichApplyInput = z.object({
|
|
enrichmentId: Id,
|
|
/** 只应用这些字段;缺省应用建议里有值的全部字段 */
|
|
fields: z.array(EnrichFieldName).min(1).optional(),
|
|
/** 应用的字段同时加锁(之后的自动来源不改写) */
|
|
lock: z.boolean().default(false),
|
|
/** 并入已有作品而不是新建(同名去重时由用户选) */
|
|
mediaEntityId: Id.optional(),
|
|
kind: z.enum(["series", "movie"]).optional(),
|
|
});
|
|
export type EnrichApplyInput = z.infer<typeof EnrichApplyInput>;
|
|
|
|
export const EnrichApplyResult = z.object({
|
|
enrichmentId: Id,
|
|
mediaEntityId: Id,
|
|
/** false = 同名去重命中已有作品,字段写到它上面 */
|
|
created: z.boolean(),
|
|
contentUnits: z.number().int().nonnegative(),
|
|
fields: z.array(z.string()),
|
|
aliases: z.number().int().nonnegative(),
|
|
/** 待识别资源已关联到该作品 */
|
|
assigned: z.boolean(),
|
|
});
|
|
export type EnrichApplyResult = z.infer<typeof EnrichApplyResult>;
|