Files
UHD_Media_Tool/packages/contracts/src/identity.ts
T
rootandClaude Opus 5.5 9489e40e0c feat(identity): source-aware short-drama enrichment with snippet mode and hit rate (META-2)
- classify enrich sources: encyclopedia, platform pages (hongguo, kuaikaw, video
  platforms), douban (search snippet only) and other sites' search snippets
  (snippet mode, pages never opened); one extractor per source, fields carry
  source kind and confidence, merged by noisy-OR across hosts
- new cast field (applied as MediaCredit with placeholder ids)
- robots.txt check (cached per host 1 day) and login-wall/captcha give-up
- per-enrichment source attempts + GET /identity/enrich-stats, 30-day hit-rate
  table under settings -> metadata -> identity
- setting identity.enrichSnippetAllSites (default on); default domains add
  baike.com and kuaikaw.cn
- replay fixtures from the real run on the 4 user-library short dramas
  (sanitized Brave responses, hongguo page, robots.txt)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 18:40:19 +08:00

473 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
/**
* META-2 补全来源类别:encyclopedia = 百科(百度 / 搜狗 / 快懂百科、维基);platform = 平台作品页(短剧平台、视频平台);
* douban = 豆瓣(只用搜索摘要,不读页面);snippet = 其它网站的搜索摘要(只读搜索结果摘要,不读页面);other = 白名单里的其它网站
*/
export const EnrichSourceKind = z.enum(["encyclopedia", "platform", "douban", "snippet", "other"]);
export type EnrichSourceKind = z.infer<typeof EnrichSourceKind>;
const enrichValue = <T extends z.ZodType>(value: T) =>
z.object({
value,
/** 出处网页(白名单域名,或摘要模式下的搜索结果链接);模型给不出出处时为 null */
sourceUrl: z.string().nullable(),
evidenceLevel: EnrichEvidenceLevel,
/** 值在出处网页正文(或搜索摘要)里原样出现(否则是 Agent 归纳 / 改写的生成内容) */
verbatim: z.boolean(),
/** META-2:出处的来源类别(旧记录没有) */
sourceKind: EnrichSourceKind.optional(),
/** META-2:合并后的置信度 0–1(多个网站给出同一值时按 noisy-OR 叠加) */
confidence: z.number().min(0).max(1).optional(),
/** META-2:给出同一值的不同网站数 */
hosts: z.number().int().nonnegative().optional(),
});
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),
/** META-2:主演(旧记录没有,按空数组) */
cast: z
.array(enrichValue(z.string().min(1).max(60)))
.max(20)
.default([]),
});
export type EnrichFields = z.infer<typeof EnrichFields>;
export const EnrichFieldName = z.enum([
"title",
"aliases",
"year",
"platform",
"episodeCount",
"synopsis",
"posterUrl",
"genre",
"cast",
]);
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 = 读取失败;snippet_only = 只用搜索摘要的网站(豆瓣);robots = robots.txt 不允许;
* blocked = 页面是登录墙 / 验证码,放弃该来源
*/
skipped: z
.enum(["not_allowed", "site", "irrelevant", "limit", "error", "snippet_only", "robots", "blocked"])
.nullable(),
error: z.string().nullable(),
/** META-2 来源类别 */
kind: EnrichSourceKind.optional(),
/** META-2 这条链接的搜索摘要被用来抽字段 */
snippetUsed: z.boolean().optional(),
});
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>;
/** META-2 一次补全里某类来源的尝试与命中:results = 标题对得上的搜索结果数;hit = 最终建议里至少一个字段取自它 */
export const EnrichSourceAttempt = z.object({
kind: EnrichSourceKind,
results: z.number().int().nonnegative(),
fetched: z.number().int().nonnegative(),
hit: z.boolean(),
fields: z.array(z.string()),
});
export type EnrichSourceAttempt = z.infer<typeof EnrichSourceAttempt>;
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(),
/** META-2 各类来源的尝试与命中(命中率统计用;旧记录没有) */
attempts: z.array(EnrichSourceAttempt).optional(),
});
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>;
/** GET /identity/enrich-stats:最近 N 天补全命中率(按来源类别) */
export const EnrichStatsQuery = z.object({ days: z.coerce.number().int().min(1).max(365).default(30) });
export const EnrichStats = z.object({
days: z.number().int(),
/** 发起过搜索的补全次数(命中 7 天缓存的不计) */
total: z.number().int().nonnegative(),
/** 其中出了建议的次数 */
suggested: z.number().int().nonnegative(),
/** 有来源明细(META-2 之后)的次数:下面各来源的命中率以它为分母 */
tracked: z.number().int().nonnegative(),
sources: z.array(
z.object({
kind: EnrichSourceKind,
/** 有标题对得上的搜索结果的次数 */
withResults: z.number().int().nonnegative(),
/** 建议里至少一个字段取自这类来源的次数 */
hits: z.number().int().nonnegative(),
/** hits / tracked(0–1);tracked = 0 时为 null */
rate: z.number().min(0).max(1).nullable(),
}),
),
});
export type EnrichStats = z.infer<typeof EnrichStats>;
/** 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>;