feat(identity): web metadata enrichment for titles missing on TMDB (META-1)
- 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]>
This commit is contained in:
1 parent
18a5cf1de7
commit
f4666a4671
36 files changed
+2514
-25
No files matched your search
@@ -266,3 +266,142 @@ export const IdentityPoolItem = IdentityCandidate.extend({
|
||||
}),
|
||||
});
|
||||
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>;
|
||||
Reference in new issue
Block a user