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; export const ExternalEntityType = z.enum(["movie", "tv", "season", "episode", "collection", "person"]); export type ExternalEntityType = z.infer; /** 证据等级:未经标注集校准前不展示概率(REQ §5.3) */ export const EvidenceLevel = z.enum(["manual", "rule_confirmed", "multi_evidence", "needs_review"]); export type EvidenceLevel = z.infer; export const IdentityVerifyStatus = z.enum(["unverified", "ok", "not_found", "type_mismatch", "error"]); export type IdentityVerifyStatus = z.infer; 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; 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; /** 变更一律生成 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; export const IdentityRevisionAction = z.enum([ "created", "status_changed", "relinked", "evidence_updated", "locked", "unlocked", ]); export type IdentityRevisionAction = z.infer; /** 身份修订历史:只追加,不修改 */ 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; /** 由服务端在身份变更事务内写入;此输入仅供内部仓储使用 */ 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; // ── W2-B:待识别池、识别尝试、合并跳转与人工纠错(REQ §7.2、§7.5) ── export const IdentityCandidateStatus = z.enum(["pending", "resolved", "dismissed", "archived"]); export type IdentityCandidateStatus = z.infer; /** * proposed = 证据足够但自动化关闭/只读,待确认; * unmatched = 惰性识别:本地召回无结果且没有触发 TMDB 查询(不相关的 RSS / 列表资源),只进池、不开待处理 */ export const IdentityCandidateReason = z.enum(["ambiguous", "no_match", "proposed", "lookup_failed", "unmatched"]); export type IdentityCandidateReason = z.infer; /** 待识别池里的一个候选(只存 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; /** 待识别池条目:每条 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; export const ResolutionOutcome = z.enum([ "auto", "proposed", "ambiguous", "no_match", "lookup_failed", "manual", "skipped_locked", "unmatched", ]); export type ResolutionOutcome = z.infer; /** 一次识别尝试(只追加) */ 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; /** 作品合并后的旧 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; /** 外部身份引用(provider + 类型 + ID) */ export const ExternalRef = z.object({ provider: IdentityProvider, entityType: z.enum(["movie", "tv"]), externalId: z.string().min(1), }); export type ExternalRef = z.infer; /** * 人工指定来源资源的身份:本地作品 / 外部身份(未登记时按外部详情建档)/ 池内候选序号,三选一。 * 指定后加人工锁,自动识别不再改写。 */ 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; /** 改作品的外部映射:旧的同 provider+类型 accepted 映射退役(留修订),新映射 accepted + 人工锁 */ export const EntityIdentitySet = ExternalRef.extend({ reason: z.string().max(2000).optional(), /** 默认加人工锁 */ lock: z.boolean().default(true), }); export type EntityIdentitySet = z.infer; 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; /** 待识别池列表条目:附来源资源摘要 */ 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; // ── 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; /** 字段证据:写在 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; /** * META-2 补全来源类别:encyclopedia = 百科(百度 / 搜狗 / 快懂百科、维基);platform = 平台作品页(短剧平台、视频平台); * douban = 豆瓣(只用搜索摘要,不读页面);snippet = 其它网站的搜索摘要(只读搜索结果摘要,不读页面);other = 白名单里的其它网站 */ export const EnrichSourceKind = z.enum(["encyclopedia", "platform", "douban", "snippet", "other"]); export type EnrichSourceKind = z.infer; const enrichValue = (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; export const EnrichFieldName = z.enum([ "title", "aliases", "year", "platform", "episodeCount", "synopsis", "posterUrl", "genre", "cast", ]); export type EnrichFieldName = z.infer; /** 搜索结果里的一条链接:是否在白名单、是否读取、为什么跳过 */ 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; 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; /** 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; 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; export const EnrichmentStatus = z.enum(["suggested", "applied", "failed"]); export type EnrichmentStatus = z.infer; export const EnrichmentTrigger = z.enum(["manual", "auto", "agent"]); export type EnrichmentTrigger = z.infer; /** 补全记录(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; /** 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; /** 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; /** 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; 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;